Skip to content

Commit bbacaac

Browse files
authored
Merge pull request #13627 from gitbutlerapp/improve-integrated-pruning
Prune integrated commits at or below the target from all stacks
2 parents c83df80 + a373602 commit bbacaac

24 files changed

Lines changed: 269 additions & 172 deletions

crates/but-graph/src/projection/workspace/init.rs

Lines changed: 88 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -755,25 +755,47 @@ impl WorkspaceState {
755755
}
756756
}
757757

758-
/// Truncate each stack to only show commits above its fork point with the
759-
/// target, then remove empty branches and stacks.
760-
///
761-
/// The fork point is the first commit on the first-parent chain (walked via
762-
/// the graph's `CommitFlags::Integrated`) that is reachable from the target.
763-
///
764-
/// Stacks explicitly tracked in workspace metadata are never pruned —
765-
/// their integrated branches and commits must remain visible so that
766-
/// `integrate_upstream` can detect and remove them.
758+
/// Remove integrated commits and empty branches at the bottom of each
759+
/// stack, but only those at or below the workspace's target commit.
760+
/// Integrated commits above the target are kept until the user advances
761+
/// the target via upstream integration.
767762
fn prune_integrated_segments(&mut self, graph: &Graph) {
768763
if self.extra_target.is_some() || self.target_ref.is_none() {
769764
return;
770765
}
766+
let (target_segment_index, target_commit_id) = if let Some(tc) = self.target_commit.as_ref()
767+
{
768+
(tc.segment_index, Some(tc.commit_id))
769+
} else if let Some(tr) = self.target_ref.as_ref() {
770+
(tr.segment_index, None)
771+
} else {
772+
return;
773+
};
774+
775+
// Collect all commit IDs that are the target itself or ancestors
776+
// of it by walking the graph toward its ancestors
777+
// (Direction::Outgoing). In the target segment itself, only
778+
// include commits from the target position downward — commits
779+
// above it in the same segment are not reachable from the target.
780+
let mut is_target_or_below = HashSet::new();
781+
graph.visit_all_segments_excluding_start_until(
782+
target_segment_index,
783+
Direction::Outgoing,
784+
|s| {
785+
is_target_or_below.extend(s.commits.iter().map(|c| c.id));
786+
false
787+
},
788+
);
789+
// For the target segment, include commits from the target onward.
790+
let target_seg = &graph[target_segment_index];
791+
let start = target_commit_id
792+
.and_then(|id| target_seg.commits.iter().position(|c| c.id == id))
793+
.unwrap_or(0);
794+
is_target_or_below.extend(target_seg.commits[start..].iter().map(|c| c.id));
795+
771796
let metadata = self.metadata.as_ref();
772797
for stack in &mut self.stacks {
773-
if is_metadata_tracked(stack, metadata) {
774-
continue;
775-
}
776-
truncate_at_fork_point(stack, graph);
798+
truncate_at_fork_point(stack, &is_target_or_below);
777799
remove_empty_branches(stack, metadata);
778800
}
779801
self.stacks.retain(|stack| !stack.segments.is_empty());
@@ -967,54 +989,57 @@ impl WorkspaceState {
967989
}
968990
}
969991

970-
/// Truncate the stack at the fork point: the first commit whose graph-level
971-
/// `CommitFlags::Integrated` flag is set, walking top-down through each
972-
/// segment's `commits_by_segment` entries.
973-
///
974-
/// If the very first commit is already integrated, the entire stack is
975-
/// integrated and we leave it untouched — pruning is only for partially
976-
/// integrated stacks where the bottom portion has been merged upstream.
977-
fn truncate_at_fork_point(stack: &mut Stack, graph: &Graph) {
978-
let mut is_first = true;
979-
for seg_idx in 0..stack.segments.len() {
992+
/// Truncate the stack by removing integrated commits at the bottom, but
993+
/// only those that are the target commit itself or ancestors of it.
994+
/// Integrated commits above the target are kept — they represent branch
995+
/// work that was merged upstream but whose target hasn't moved forward
996+
/// yet. Once the user integrates upstream and the target advances past
997+
/// them, they will be pruned.
998+
fn truncate_at_fork_point(stack: &mut Stack, is_target_or_below: &HashSet<gix::ObjectId>) {
999+
// Walk segments bottom-up, then commits bottom-up within each segment.
1000+
// Find the contiguous integrated tail whose commits are all in the
1001+
// `is_target_or_below` set. Stop at the first commit that is either
1002+
// not integrated or above the target.
1003+
let mut cut: Option<(usize, usize)> = None;
1004+
'outer: for seg_idx in (0..stack.segments.len()).rev() {
9801005
let seg = &stack.segments[seg_idx];
981-
for &(graph_sidx, ofs) in &seg.commits_by_segment {
982-
for (i, commit) in graph[graph_sidx].commits.iter().enumerate() {
983-
if commit.flags.contains(CommitFlags::Integrated) {
984-
if is_first {
985-
// Fully integrated — leave the stack as-is.
986-
return;
987-
}
988-
let cut = ofs + i;
989-
stack.segments[seg_idx].commits.truncate(cut);
990-
stack.segments[seg_idx]
991-
.commits_by_segment
992-
.retain(|(_, o)| *o < cut);
993-
// Clear commits in all segments below the cutoff, but keep
994-
// the segments themselves so that `remove_empty_branches`
995-
// can decide whether to retain metadata-tracked branches.
996-
for seg in &mut stack.segments[seg_idx + 1..] {
997-
seg.commits.clear();
998-
seg.commits_by_segment.clear();
999-
}
1000-
return;
1001-
}
1002-
is_first = false;
1006+
for (commit_idx, commit) in seg.commits.iter().enumerate().rev() {
1007+
if !is_target_or_below.contains(&commit.id) {
1008+
break 'outer;
1009+
}
1010+
if commit.flags.contains(StackCommitFlags::Integrated) {
1011+
cut = Some((seg_idx, commit_idx));
1012+
} else {
1013+
break 'outer;
10031014
}
10041015
}
10051016
}
1006-
}
10071017

1008-
/// Returns `true` if the stack is explicitly tracked in workspace metadata.
1009-
fn is_metadata_tracked(
1010-
stack: &Stack,
1011-
metadata: Option<&but_core::ref_metadata::Workspace>,
1012-
) -> bool {
1013-
stack.id.is_some_and(|stack_id| {
1014-
metadata.is_some_and(|meta| meta.stacks(Applied).any(|ms| ms.id == stack_id))
1015-
})
1018+
let Some((cut_seg_idx, cut_offset)) = cut else {
1019+
return;
1020+
};
1021+
1022+
// Truncate commits in the cut segment.
1023+
stack.segments[cut_seg_idx].commits.truncate(cut_offset);
1024+
stack.segments[cut_seg_idx]
1025+
.commits_by_segment
1026+
.retain(|(_, o)| *o < cut_offset);
1027+
// Remove all segments below the cut — their branch refs pointed into
1028+
// integrated territory, not the fork point.
1029+
// Also remove the cut segment itself if it became empty, UNLESS it is
1030+
// the topmost segment (index 0). Keeping an empty topmost segment
1031+
// lets `remove_empty_branches` decide whether the branch ref should
1032+
// be preserved (e.g. a metadata-tracked branch at the fork point).
1033+
let keep = if stack.segments[cut_seg_idx].commits.is_empty() && cut_seg_idx > 0 {
1034+
cut_seg_idx
1035+
} else {
1036+
cut_seg_idx + 1
1037+
};
1038+
stack.segments.truncate(keep);
10161039
}
10171040

1041+
/// Remove empty segments unless they are mentioned in workspace metadata
1042+
/// (e.g. a branch the user just added at the fork point with no commits yet).
10181043
fn remove_empty_branches(stack: &mut Stack, metadata: Option<&but_core::ref_metadata::Workspace>) {
10191044
let own_metadata_stack = stack.id.and_then(|stack_id| {
10201045
metadata.and_then(|meta| meta.stacks(Applied).find(|ms| ms.id == stack_id))
@@ -1024,7 +1049,15 @@ fn remove_empty_branches(stack: &mut Stack, metadata: Option<&but_core::ref_meta
10241049
|| own_metadata_stack.is_some_and(|ms| {
10251050
seg.ref_info
10261051
.as_ref()
1027-
.is_some_and(|ri| ms.branches.iter().any(|b| b.ref_name == ri.ref_name))
1052+
// NOTE: `!b.archived` compensates for `prune_archived_segments`
1053+
// running *before* integrated-commit pruning — archived segments
1054+
// that still had commits are skipped there, then emptied here.
1055+
// Once metadata is kept trimmed and up-to-date we can drop this.
1056+
.is_some_and(|ri| {
1057+
ms.branches
1058+
.iter()
1059+
.any(|b| b.ref_name == ri.ref_name && !b.archived)
1060+
})
10281061
})
10291062
});
10301063
}

crates/but-graph/src/projection/workspace/mod.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,20 +172,21 @@ pub struct TargetCommit {
172172
/// The hash of the commit that was once included in the [target ref](TargetRef), and that we remember to expand
173173
/// the reach of the workspace.
174174
pub commit_id: gix::ObjectId,
175-
/// The index to the respective segment in the graph for which [`Self::commit_id`] is the first commit.
175+
/// The index to the respective segment in the graph that contains [`Self::commit_id`].
176176
pub segment_index: SegmentIndex,
177177
}
178178

179179
impl TargetCommit {
180180
/// Find `target_commit_id` in the `graph` and store its segment in this instance, or return `None` if not found.
181181
fn from_commit(target_commit_id: gix::ObjectId, graph: &Graph) -> Option<Self> {
182182
graph.node_weights().find_map(|s| {
183-
s.commits.first().and_then(|c| {
184-
(c.id == target_commit_id).then_some(TargetCommit {
183+
s.commits
184+
.iter()
185+
.any(|c| c.id == target_commit_id)
186+
.then_some(TargetCommit {
185187
commit_id: target_commit_id,
186188
segment_index: s.id,
187189
})
188-
})
189190
})
190191
}
191192
}

crates/but-graph/tests/fixtures/scenarios.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,4 +1618,34 @@ EOF
16181618

16191619
create_workspace_commit_once my-branch
16201620
)
1621+
1622+
# A branch whose commits are integrated (merged upstream via origin/main)
1623+
# but the workspace target hasn't advanced past them yet.
1624+
# The target_commit_id will be set to "init" in the test, while origin/main
1625+
# has moved forward to include the branch's commits via a merge.
1626+
#
1627+
# History:
1628+
# gitbutler/workspace ─ B ─ A ─ init (my-branch at B)
1629+
# origin/main: merge(my-branch) ─ B ─ A ─ init
1630+
#
1631+
# With target at "init", A and B are above the target and should be kept
1632+
# even though they're marked integrated.
1633+
git init integrated-above-target
1634+
(cd integrated-above-target
1635+
commit init
1636+
1637+
# Branch with two commits
1638+
git checkout -b my-branch
1639+
commit A
1640+
commit B
1641+
1642+
# Simulate upstream merging the branch
1643+
git checkout main
1644+
git merge --no-ff -m "Merge branch my-branch" my-branch
1645+
setup_target_to_match_main
1646+
1647+
# Back to branch for workspace
1648+
git checkout my-branch
1649+
create_workspace_commit_once my-branch
1650+
)
16211651
)

0 commit comments

Comments
 (0)