Prune integrated commits from stacks in workspace projection#13552
Prune integrated commits from stacks in workspace projection#13552
Conversation
f42640e to
2697d7a
Compare
Byron
left a comment
There was a problem hiding this comment.
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?
| /// 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). |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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? 🙏
8c5d7e6 to
19c3197
Compare
a347294 to
9c9295e
Compare
440e4f6 to
5ac1633
Compare
There was a problem hiding this comment.
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 atmerge_base(stack_tip, target)and drop empty trailing segments unless tracked in workspace metadata. - Applies pruning in
but-workspace(head_info/ref_info) and legacybut statuscontext building. - Adds new CLI
status --upstreamfixtures/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. |
| 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); |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| 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(); |
There was a problem hiding this comment.
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.
| let mut pruned_ws = ws.clone(); | |
| let mut pruned_ws = ws; |
5ac1633 to
9ca07ef
Compare
9ca07ef to
71a584e
Compare
| @@ -362,6 +378,7 @@ impl Graph { | |||
| }; | |||
|
|
|||
| ws.prune_archived_segments(); | |||
| ws.prune_integrated_segments(&merge_bases); | |||
| ws.mark_remote_reachability(self)?; | |||
| /// `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. |
71a584e to
92bd5db
Compare
92bd5db to
5ddc38e
Compare
| let commits = &mut stack.segments[seg_idx].commits; | ||
| if let Some(pos) = commits.iter().position(|c| c.id == cutoff_id) { | ||
| commits.truncate(pos); |
| /// the target. Both should be pruned — empty bottom branches are always | ||
| /// removed regardless of metadata. |
| 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") |
72b5070 to
5ddc38e
Compare
5ddc38e to
4cb8f29
Compare
| /// the target. Both should be pruned — empty bottom branches are always | ||
| /// removed regardless of metadata. |
| 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() |
| /// 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. |
|
|
||
| ws.prune_archived_segments(); | ||
| ws.prune_integrated_segments(&merge_bases); | ||
| ws.mark_remote_reachability(self)?; |
4cb8f29 to
2389d29
Compare
2389d29 to
42eb8b2
Compare
| /// 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). |
| /// 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() { |
| 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; |
| /// 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. |
42eb8b2 to
f110276
Compare
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.
f110276 to
e992544
Compare
| /// the target. Both should be pruned — empty bottom branches are always | ||
| /// removed regardless of metadata. |
| 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; | ||
| } | ||
| } |
| /// 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. |
| /// 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). |
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
baseand stack B forks fromM2(two commits ahead ofbase), B's display would incorrectly include commitsM1andM2that 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 acrossstacks_v3,but status, etc.The approach is commit-count-based: for each stack, compute
count_commits_above_merge_base(tip, merge_base)and thentruncate_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:This correctly handles integrated merge commits at the bottom of stacks. For example, if a stack has commits
D → C → merge(fix)wheremerge(fix)is already in the target, onlyDandCare counted.Pruning steps
Graph::count_commits_above_merge_base()truncate_stack_to_commit_count()Key details
crates/but-graph/src/projection/workspace/init.rs(pruning),crates/but-graph/src/api.rs(counting)ref_info(),stacks_v3,but statusstack_tip == merge_base, no truncation occursTest plan
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 projectionstatus_upstream_prunes_untracked_integrated_branch— verifies an untracked branch sitting onmainis pruned from displaystatus_upstream_prunes_with_different_bases— verifies per-stack merge base truncation🤖 Generated with Claude Code