Skip to content

Prune integrated commits from stacks in workspace projection#13552

Open
mtsgrd wants to merge 2 commits intomasterfrom
prune-integrated-bottom-segments
Open

Prune integrated commits from stacks in workspace projection#13552
mtsgrd wants to merge 2 commits intomasterfrom
prune-integrated-bottom-segments

Conversation

@mtsgrd
Copy link
Copy Markdown
Contributor

@mtsgrd mtsgrd commented Apr 28, 2026

Problem

The graph is built from the combined merge base of all stack heads, which can include commits below a given stack's own merge base with the target. This means stacks can display commits that belong to the shared history below their actual branch point.

For example, if stack A forks from base and stack B forks from M2 (two commits ahead of base), B's display would incorrectly include commits M1 and M2 that are already in the target.

Additionally, when a stack has an integrated merge commit at its bottom (e.g. a local merge of an already-merged PR), that merge commit should not appear in the workspace projection since it's an integration point, not real branch work.

Solution

Add prune_integrated_segments() in the workspace projection layer, so all downstream consumers receive already-pruned data. This avoids duplicating pruning logic across stacks_v3, but status, etc.

The approach is commit-count-based: for each stack, compute count_commits_above_merge_base(tip, merge_base) and then truncate_stack_to_commit_count() to keep only that many commits from the top.

Counting commits above the merge base

count_commits_above_merge_base() walks from the stack tip downward following first-parent edges, counting commits in each segment. It stops when it reaches the merge base segment or a merge boundary — a segment that:

  1. Connects to the merge base (via any outgoing edge), AND
  2. Contains only merge commits (all commits have multiple parents)

This correctly handles integrated merge commits at the bottom of stacks. For example, if a stack has commits D → C → merge(fix) where merge(fix) is already in the target, only D and C are counted.

Pruning steps

  1. Count commits above the per-stack merge base using Graph::count_commits_above_merge_base()
  2. Truncate each stack to that count via truncate_stack_to_commit_count()
  3. Remove empty trailing branches that have no remaining commits after truncation, unless tracked in workspace metadata
Before pruning (B forks from M2):          After pruning:

  B                                           B
  ├─ B-change                                 └─ B-change
  ├─ M2          ← below B's merge base
  └─ M1          ← below B's merge base

Key details

  • Where: crates/but-graph/src/projection/workspace/init.rs (pruning), crates/but-graph/src/api.rs (counting)
  • Called from: workspace projection init, consumed by ref_info(), stacks_v3, but status
  • Metadata-aware: branches listed in workspace metadata are never pruned, even if empty after truncation
  • Safe for fully-integrated stacks: when stack_tip == merge_base, no truncation occurs
  • No impact on operational paths: rebase, upstream integration, and commit amend still see full history

Test plan

  • All existing tests pass (with snapshot updates for new pruning behavior)
  • integrated_merge_at_bottom_is_pruned — verifies a local merge commit at the bottom of a stack (already in target) is excluded from the workspace projection
  • status_upstream_prunes_untracked_integrated_branch — verifies an untracked branch sitting on main is pruned from display
  • status_upstream_prunes_with_different_bases — verifies per-stack merge base truncation
  • Test metadata fixed for multi-stack scenarios where branch ownership affects pruning

🤖 Generated with Claude Code

@github-actions github-actions Bot added the rust Pull requests that update Rust code label Apr 28, 2026
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from f42640e to 2697d7a Compare April 28, 2026 10:24
Copy link
Copy Markdown
Collaborator

@Byron Byron left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left all the interesting bis in the comment, and that gave me an idea: maybe this PR wants to implement the git log --first-parent stack_tip ^target.sha way of traversing stacks if there is a target_base_commit_id?

Comment on lines +917 to +920
/// Remove bottom segments from stacks where all commits are already integrated into the target branch.
/// This handles the case where a branch in a stack has been merged into the target, but remains
/// listed in the workspace metadata — its segment would otherwise appear with only integrated commits.
/// Always keeps at least one segment per stack (the top-most).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is also my intuition, I remember that originally it was desirable to keep branches that were merged. There was also special archival logic that we might not have or need anymore.

So the question becomes, what we want to see if:

  • there is a Stack [A, B] with A the tip and B the base
  • B gets integrated

Previously

Show B as integrated and make it an explicit 'integration' step to get rid of it.

With this PR

B disappears once the target is fetched and the integration check sees it merged by graph relations (i.e. recahable from target_tip) or because integration checks detect that. There is no extra step to control when it's removed.

My feeling is that with the upcoming changes to the way stacks are made, this will resolve itself:

From Discord

  • with the old apply, we never really saw how the WP does things because branches always build a fork on top of the base.
  • now with the new apply, we see that the base it uses is too low (the lower bound as the first commit that is reachable from all tips of the workspace)
  • what we want is git log --first-parent stack_tip ^target.sha

The above is also what the VBH-related code did previously.

The idea is to bring this to the workspace projection so everyone sees it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, as you suggest I've changed the code to exclude ancestors of merge_base(head.sha, target.sha), but feel unsure of the implementation. Could you have a look and help me out? 🙏

@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch 2 times, most recently from 8c5d7e6 to 19c3197 Compare April 28, 2026 10:56
@github-actions github-actions Bot added the CLI The command-line program `but` label Apr 28, 2026
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch 2 times, most recently from a347294 to 9c9295e Compare April 28, 2026 22:58
@mtsgrd mtsgrd changed the title Prune integrated bottom segments from stacks Truncate displayed stacks at per-stack merge base Apr 28, 2026
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch 6 times, most recently from 440e4f6 to 5ac1633 Compare April 29, 2026 09:29
@mtsgrd mtsgrd changed the title Truncate displayed stacks at per-stack merge base Prune integrated commits from stacks in workspace projection Apr 29, 2026
@mtsgrd mtsgrd marked this pull request as ready for review April 29, 2026 09:32
Copilot AI review requested due to automatic review settings April 29, 2026 09:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds stack-level pruning in the workspace projection so downstream consumers (CLI status, ref info, etc.) receive stack displays without commits already integrated into the target, especially when stacks have different merge bases.

Changes:

  • Introduces Workspace::prune_integrated_stacks() to truncate each stack at merge_base(stack_tip, target) and drop empty trailing segments unless tracked in workspace metadata.
  • Applies pruning in but-workspace (head_info/ref_info) and legacy but status context building.
  • Adds new CLI status --upstream fixtures/tests covering untracked integrated branches and differing per-stack merge bases.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
crates/but-graph/src/projection/workspace/api.rs Adds the pruning implementation in the workspace projection layer.
crates/but-workspace/src/ref_info.rs Ensures returned RefInfo is built from a pruned workspace.
crates/but/src/command/legacy/status/mod.rs Uses pruned stacks for status rendering paths.
crates/but/tests/but/command/status.rs Adds regression tests validating pruning behavior in CLI output.
crates/but/tests/fixtures/scenario/upstream-integrated-with-extra-branch.sh New scenario for pruning an untracked branch sitting on the target.
crates/but/tests/fixtures/scenario/upstream-different-bases.sh New scenario for per-stack merge-base truncation behavior.

Comment on lines +53 to +66
if self.extra_target.is_some() {
return;
}
let target_id = self.target_ref.as_ref().and_then(|tr| {
repo.try_find_reference(tr.ref_name.as_ref())
.ok()
.flatten()
.and_then(|mut r| r.peel_to_id().ok().map(|id| id.detach()))
});
let Some(target_id) = target_id else { return };

let metadata = self.metadata.as_ref();
for stack in &mut self.stacks {
truncate_stack_to_target_merge_base(stack, target_id, repo);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prune_integrated_stacks() currently only prunes when target_ref is set and returns early if extra_target is present. However Workspace can legally have target_commit without target_ref (see projection/workspace/mod.rs docs), and the code already has merge_base_with_target_branch() which resolves the target as target_ref/target_commit/extra_target and computes a merge-base using the workspace graph. Consider reusing merge_base_with_target_branch(stack.tip()) (or otherwise falling back to target_commit/extra_target) so pruning also works for workspaces configured with a target commit only, and so it doesn’t silently no-op when extra_target is set.

Suggested change
if self.extra_target.is_some() {
return;
}
let target_id = self.target_ref.as_ref().and_then(|tr| {
repo.try_find_reference(tr.ref_name.as_ref())
.ok()
.flatten()
.and_then(|mut r| r.peel_to_id().ok().map(|id| id.detach()))
});
let Some(target_id) = target_id else { return };
let metadata = self.metadata.as_ref();
for stack in &mut self.stacks {
truncate_stack_to_target_merge_base(stack, target_id, repo);
let metadata = self.metadata.as_ref();
for stack_idx in 0..self.stacks.len() {
let target_id = {
let tip = self.stacks[stack_idx].tip();
self.merge_base_with_target_branch(tip)
};
let stack = &mut self.stacks[stack_idx];
if let Some(target_id) = target_id {
truncate_stack_to_target_merge_base(stack, target_id, repo);
}

Copilot uses AI. Check for mistakes.
let (_repo, ws, _db) = ctx.workspace_and_db_with_perm(guard.read_permission())?;
let (repo, ws, _db) = ctx.workspace_and_db_with_perm(guard.read_permission())?;
let resolved_target = workspace_target::ResolvedTarget::from_workspace(&ws)?;
let mut pruned_ws = ws.clone();
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a potentially expensive clone: ws.clone() clones the entire Workspace, including the underlying Graph (which is a full petgraph and #[derive(Clone)]). Since you only need pruned stacks, consider cloning just ws.stacks (and the minimal target/metadata info needed to prune) or adding a helper that returns pruned Vec<Stack> without cloning the graph, to avoid extra memory/time in but status.

Suggested change
let mut pruned_ws = ws.clone();
let mut pruned_ws = ws;

Copilot uses AI. Check for mistakes.
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from 5ac1633 to 9ca07ef Compare April 29, 2026 14:59
Copilot AI review requested due to automatic review settings April 29, 2026 17:04
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from 9ca07ef to 71a584e Compare April 29, 2026 17:04
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment on lines 352 to 382
@@ -362,6 +378,7 @@ impl Graph {
};

ws.prune_archived_segments();
ws.prune_integrated_segments(&merge_bases);
ws.mark_remote_reachability(self)?;
Comment on lines +642 to +644
/// `extra-untracked-2` is not. Both sit on `main` with zero commits above
/// the target. Both should be pruned — empty bottom branches are always
/// removed regardless of metadata.
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from 71a584e to 92bd5db Compare April 29, 2026 17:56
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from 92bd5db to 5ddc38e Compare April 29, 2026 18:35
Copilot AI review requested due to automatic review settings April 30, 2026 04:45
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Comment on lines +988 to +990
let commits = &mut stack.segments[seg_idx].commits;
if let Some(pos) = commits.iter().position(|c| c.id == cutoff_id) {
commits.truncate(pos);
Comment on lines +643 to +644
/// the target. Both should be pruned — empty bottom branches are always
/// removed regardless of metadata.
Comment on lines +743 to +747
use but_core::RefMetadata;
use std::ops::DerefMut;
let mut meta = env.meta()?;
let mut ws = meta.workspace(
gix::refs::FullName::try_from("refs/heads/gitbutler/workspace")
@Byron Byron force-pushed the prune-integrated-bottom-segments branch from 72b5070 to 5ddc38e Compare April 30, 2026 07:19
Copilot AI review requested due to automatic review settings April 30, 2026 07:20
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from 5ddc38e to 4cb8f29 Compare April 30, 2026 07:20
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Comment on lines +643 to +644
/// the target. Both should be pruned — empty bottom branches are always
/// removed regardless of metadata.
Comment on lines +352 to +368
let merge_bases: BTreeMap<SegmentIndex, MergeBaseInfo> =
if let Some(target_sidx) = target_ref.as_ref().map(|t| t.segment_index) {
stacks
.iter()
.filter_map(|stack| {
let tip_sidx = stack.segments.first()?.id;
let mb_sidx = self.find_git_merge_base(tip_sidx, target_sidx)?;
let tip_id = self.tip_skip_empty(tip_sidx)?.id;
let mb_id = self.tip_skip_empty(mb_sidx)?.id;
if tip_id == mb_id {
return None;
}
let commit_count =
self.count_commits_above_merge_base(tip_sidx, mb_sidx);
Some((tip_sidx, MergeBaseInfo { commit_count }))
})
.collect()
Comment on lines +784 to +787
/// Prunes commits below the merge base using two strategies:
/// 1. Commit-count truncation: keeps only the commits above the merge base.
/// 2. Segment-boundary truncation: removes bottom segments whose base is the merge-base segment.
/// Then removes empty branches not tracked in the owning stack's metadata.
Comment on lines 384 to 387

ws.prune_archived_segments();
ws.prune_integrated_segments(&merge_bases);
ws.mark_remote_reachability(self)?;
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from 4cb8f29 to 2389d29 Compare April 30, 2026 08:32
Copilot AI review requested due to automatic review settings April 30, 2026 08:39
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from 2389d29 to 42eb8b2 Compare April 30, 2026 08:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Comment on lines +121 to +123
/// Stops when reaching the merge base segment, or when a segment has the
/// merge base as any of its outgoing edges (e.g. a merge commit whose
/// second parent is the merge base).
Comment on lines +643 to +644
/// the target. Both should be pruned — empty bottom branches are always
/// removed regardless of metadata.
///
/// Then removes empty branches not tracked in the owning stack's metadata.
fn prune_integrated_segments(&mut self, merge_bases: &BTreeMap<SegmentIndex, MergeBaseInfo>) {
if self.extra_target.is_some() || self.target_ref.is_none() {
Comment on lines +993 to +1002
fn truncate_stack_to_commit_count(stack: &mut Stack, max_commits: usize) {
let mut remaining = max_commits;
for seg_idx in 0..stack.segments.len() {
let seg_len = stack.segments[seg_idx].commits.len();
if remaining >= seg_len {
remaining -= seg_len;
} else {
stack.segments[seg_idx].commits.truncate(remaining);
stack.segments.truncate(seg_idx + 1);
return;
Comment on lines +783 to +785
/// Prunes commits below the merge base using two strategies:
/// 1. Commit-count truncation: keeps only the commits above the merge base.
/// 2. Segment-boundary truncation: removes bottom segments whose base is the merge-base segment.
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from 42eb8b2 to f110276 Compare April 30, 2026 08:57
mtsgrd added 2 commits April 30, 2026 11:00
The graph is built from the combined merge base of all stack heads,
which can include commits below a given stack's own merge base with
the target. For display purposes, each stack should only show commits
above its own merge_base(stack_tip, target).

Precompute per-stack merge bases during graph construction using
`Graph::find_git_merge_base()` and store them in a local BTreeMap
keyed by tip segment index. After building the WorkspaceState, call
`prune_integrated_segments()` (next to `prune_archived_segments()`)
which truncates each stack at its merge base and removes trailing
empty branches not tracked in the owning stack's metadata.

Also filter base-segment commit mapping by the InWorkspace flag to
prevent non-workspace commits from being processed during stack
construction.

This centralizes pruning inside the workspace projection so all
downstream consumers (`ref_info`, `stacks_v3`, `but status`) receive
already-pruned data without duplicating logic or requiring a
`gix::Repository` at prune time.
Update tests to match the new pruning behavior introduced by
prune_integrated_segments().

two_stacks_many_refs, single_stack_ws_insertions: the test metadata
defined branches across multiple metadata stacks that collapsed into
a single workspace stack. Since remove_empty_branches only checks the
owning stack's metadata, branches from non-owning stacks were pruned.
Fix by merging all branches into the owning stack's metadata to match
the actual workspace topology.

workspace_with_stack_and_local_target, no_overzealous_stacks,
applied_stack_below_explicit_lower_bound: update snapshots to remove
commits that are below the per-stack merge base with the target. These
commits are correctly pruned by truncate_stack_to_merge_base.

dlib_rs_auto_fix: the confidence branch has no stack id and no metadata
entry, so it is correctly pruned as an empty unowned segment. Update
snapshots and expected metadata output accordingly.
Copilot AI review requested due to automatic review settings April 30, 2026 09:00
@mtsgrd mtsgrd force-pushed the prune-integrated-bottom-segments branch from f110276 to e992544 Compare April 30, 2026 09:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Comment on lines +643 to +644
/// the target. Both should be pruned — empty bottom branches are always
/// removed regardless of metadata.
Comment on lines +995 to +1004
for seg_idx in 0..stack.segments.len() {
let seg_len = stack.segments[seg_idx].commits.len();
if remaining >= seg_len {
remaining -= seg_len;
} else {
stack.segments[seg_idx].commits.truncate(remaining);
stack.segments.truncate(seg_idx + 1);
return;
}
}
Comment on lines +783 to +785
/// Prunes commits below the merge base using two strategies:
/// 1. Commit-count truncation: keeps only the commits above the merge base.
/// 2. Segment-boundary truncation: removes bottom segments whose base is the merge-base segment.
Comment on lines +121 to +123
/// Stops when reaching the merge base segment, or when a segment has the
/// merge base as any of its outgoing edges (e.g. a merge commit whose
/// second parent is the merge base).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLI The command-line program `but` rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants