Skip to content

Commit 386657d

Browse files
committed
commit squash: use merge arithmetic
Use merge arithmetic instead of reordering the commits for a faster, more reliable squash.
1 parent e550b59 commit 386657d

4 files changed

Lines changed: 207 additions & 115 deletions

File tree

crates/but-workspace/src/commit/squash_commits.rs

Lines changed: 84 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
//! An action to squash multiple commits into a target commit.
22
33
use anyhow::{Result, bail};
4-
use but_core::RefMetadata;
4+
use but_core::{RefMetadata, RepositoryExt};
55
use but_rebase::{
66
commit::DateMode,
77
graph_rebase::{
8-
Editor, Selector, Step, SuccessfulRebase, ToCommitSelector, mutate::InsertSide,
8+
Editor, LookupStep as _, Selector, Step, SuccessfulRebase, ToCommitSelector,
9+
commit::MergeCommitChangesOutcome,
10+
mutate::{SegmentDelimiter, SelectorSet},
911
},
1012
};
1113

@@ -43,73 +45,56 @@ fn push_message_with_spacing(combined: &mut Vec<u8>, message: &[u8]) {
4345
combined.extend_from_slice(message);
4446
}
4547

46-
/// Reorder commits around `target_commit` so all selected commits become
47-
/// adjacent around the target in parentage order.
48-
///
49-
/// Returns the rewritten editor together with the original below/above anchor
50-
/// commit IDs used for remapping after the first rebase.
51-
fn reorder_commits_around_target<'ws, 'meta, M: RefMetadata>(
52-
mut editor: Editor<'ws, 'meta, M>,
53-
ordered_all_commits: &[Selector],
54-
target_commit: Selector,
55-
) -> Result<(Editor<'ws, 'meta, M>, Selector, Selector)> {
56-
let target_pos = ordered_all_commits
57-
.iter()
58-
.position(|id| *id == target_commit)
59-
.expect("target commit must be in ordered commit list");
60-
61-
let (below_commits, target_and_above_commits) = ordered_all_commits.split_at(target_pos);
62-
63-
let mut below_anchor = target_commit;
64-
for source_id in below_commits.iter().rev().copied() {
65-
editor = crate::commit::move_commit_no_rebase(
66-
editor,
67-
source_id,
68-
below_anchor,
69-
InsertSide::Below,
70-
)?;
71-
below_anchor = source_id;
72-
}
73-
74-
let mut above_anchor = target_commit;
75-
for source_id in target_and_above_commits.iter().skip(1).copied() {
76-
editor = crate::commit::move_commit_no_rebase(
77-
editor,
78-
source_id,
79-
above_anchor,
80-
InsertSide::Above,
81-
)?;
82-
above_anchor = source_id;
83-
}
84-
85-
Ok((editor, below_anchor, above_anchor))
86-
}
87-
88-
/// Build the squashed commit from the mapped top/bottom commits and replace the
89-
/// bottom selector with the newly created commit.
48+
/// Build the squashed commit and replace the target selector with the newly
49+
/// created commit.
9050
///
9151
/// Returns the updated editor and the selector that now points to the squashed
9252
/// commit.
9353
fn construct_new_squashed_commit<'ws, 'meta, M: RefMetadata>(
9454
mut editor: Editor<'ws, 'meta, M>,
95-
top_most_commit_id: Selector,
96-
bottom_most_commit_id: Selector,
55+
squashed_tree: MergeCommitChangesOutcome,
56+
target_commit_id: Selector,
9757
combined_message: Vec<u8>,
9858
) -> Result<(Editor<'ws, 'meta, M>, Selector)> {
99-
let (_, top_most_commit) = editor.find_selectable_commit(top_most_commit_id)?;
100-
let (bottom_most_selector, bottom_most_commit) =
101-
editor.find_selectable_commit(bottom_most_commit_id)?;
59+
let (target_selector, target_commit) = editor.find_selectable_commit(target_commit_id)?;
60+
let target_parent_ids = parent_commit_ids(&editor, target_selector)?;
10261

10362
let new_commit = {
104-
let mut squashed_commit = bottom_most_commit.clone();
105-
squashed_commit.tree = top_most_commit.tree;
63+
let mut squashed_commit = target_commit.clone();
64+
squashed_commit.inner.parents = target_parent_ids.into();
65+
squashed_commit.tree = squashed_tree.tree_id;
10666
squashed_commit.message = combined_message.into();
10767
editor.new_commit(squashed_commit, DateMode::CommitterUpdateAuthorKeep)?
10868
};
10969

110-
editor.replace(bottom_most_selector, Step::new_pick(new_commit))?;
70+
editor.replace(target_selector, Step::new_pick(new_commit))?;
11171

112-
Ok((editor, bottom_most_selector))
72+
Ok((editor, target_selector))
73+
}
74+
75+
fn parent_commit_ids<M: RefMetadata>(
76+
editor: &Editor<'_, '_, M>,
77+
selector: Selector,
78+
) -> Result<Vec<gix::ObjectId>> {
79+
let mut parents = editor.direct_parents(selector)?;
80+
parents.sort_by_key(|(_, order)| *order);
81+
82+
parents
83+
.into_iter()
84+
.map(|(parent_selector, _)| match editor.lookup_step(parent_selector)? {
85+
Step::Pick(_) => {
86+
let (_, commit) = editor.find_selectable_commit(parent_selector)?;
87+
Ok(commit.id)
88+
}
89+
Step::Reference { .. } => {
90+
let (_, commit) = editor.find_reference_target(parent_selector)?;
91+
Ok(commit.id)
92+
}
93+
Step::None => bail!(
94+
"BUG: expected parent selector {parent_selector:?} to point to a pick or reference"
95+
),
96+
})
97+
.collect()
11398
}
11499

115100
/// How to combine messages of commits being squashed.
@@ -133,72 +118,67 @@ but_schemars::register_sdk_type!(MessageCombinationStrategy);
133118

134119
/// Squash `subjects` into `target_commit`.
135120
///
136-
/// `subjects` may be provided in any order. They are ordered by
137-
/// parentage internally together with `target_commit` before reordering and
138-
/// squashing.
139-
///
140121
/// The `target_commit` must not also appear in `subjects`.
141122
///
142-
/// After reordering and squashing, the resulting squashed commit has:
143-
/// - The tree of the commit that is top-most after reordering.
123+
/// After squashing, the resulting squashed commit has:
124+
/// - The tree produced by merging the selected commits together.
144125
/// - A message determined by `how_to_combine_messages`:
145126
/// - `KeepTarget`: target message only.
146127
/// - `KeepSubject`: subject messages only.
147128
/// - `KeepBoth`: target message followed by subject messages.
148129
///
149-
/// Subject messages are appended in squash order (top-most first after
150-
/// reordering), with at least one blank line between non-empty message blocks.
130+
/// Subject messages are appended in the order they are provided, with at least
131+
/// one blank line between non-empty message blocks.
151132
///
152133
pub fn squash_commits<'ws, 'meta, M: RefMetadata, S: ToCommitSelector, T: ToCommitSelector>(
153134
editor: Editor<'ws, 'meta, M>,
154135
subjects: Vec<S>,
155136
target_commit: T,
156137
how_to_combine_messages: MessageCombinationStrategy,
157138
) -> Result<SquashCommitsOutcome<'ws, 'meta, M>> {
139+
let mut seen_subjects = std::collections::HashSet::with_capacity(subjects.len());
140+
158141
if subjects.is_empty() {
159142
bail!("Need at least 2 commits to squash")
160143
}
161144

162145
let (target_commit_selector, target_commit_obj) =
163146
editor.find_selectable_commit(target_commit)?;
164147

165-
let mut all_commits = Vec::with_capacity(subjects.len() + 1);
166-
all_commits.push(target_commit_selector);
148+
let mut subject_selectors = Vec::with_capacity(subjects.len());
167149
for subject_commit in subjects {
168150
let (subject_commit_selector, _) = editor.find_selectable_commit(subject_commit)?;
169151
if subject_commit_selector == target_commit_selector {
170152
bail!("Cannot squash a commit into itself")
171153
}
172-
all_commits.push(subject_commit_selector);
154+
if !seen_subjects.insert(subject_commit_selector) {
155+
continue;
156+
}
157+
subject_selectors.push(subject_commit_selector);
173158
}
174159

175-
let ordered_selectors = editor.order_commit_selectors_by_parentage(all_commits)?;
176-
177-
let (editor, below_anchor, above_anchor) =
178-
reorder_commits_around_target(editor, &ordered_selectors, target_commit_selector)?;
179-
180-
let rebase = editor.rebase()?;
181-
let editor = rebase.into_editor();
182-
183-
for commit_selector in &ordered_selectors {
184-
let (_, commit) = editor.find_selectable_commit(*commit_selector)?;
185-
if commit.clone().attach(editor.repo()).is_conflicted() {
186-
bail!(
187-
"Commit {} became conflicted after reordering. Can't continue with squash.",
188-
commit.id
189-
);
190-
}
160+
let mut commits_to_merge = Vec::with_capacity(subject_selectors.len() + 1);
161+
commits_to_merge.push(target_commit_selector);
162+
commits_to_merge.extend(subject_selectors.iter().copied());
163+
let commits_to_merge = editor.order_commit_selectors_by_parentage(commits_to_merge)?;
164+
let squashed_tree = editor.merge_commit_changes_to_tree(
165+
commits_to_merge
166+
.iter()
167+
.map(|commit_selector| {
168+
let (_, commit) = editor.find_selectable_commit(*commit_selector)?;
169+
Ok(commit.id)
170+
})
171+
.collect::<Result<Vec<_>>>()?,
172+
editor.repo().merge_options_force_ours()?,
173+
)?;
174+
if squashed_tree.conflict.is_some() {
175+
bail!("Cannot squash commits that would result in merge conflicts");
191176
}
192177

193178
let mut combined_message = Vec::new();
194179
match how_to_combine_messages {
195180
MessageCombinationStrategy::KeepSubject => {
196-
for source_id in ordered_selectors
197-
.iter()
198-
.rev()
199-
.copied()
200-
.filter(|commit_selector| *commit_selector != target_commit_selector)
201-
{
181+
for source_id in subject_selectors.iter().copied() {
202182
let (_, source_commit) = editor.find_selectable_commit(source_id)?;
203183
push_message_with_spacing(&mut combined_message, source_commit.message.as_ref());
204184
}
@@ -208,37 +188,34 @@ pub fn squash_commits<'ws, 'meta, M: RefMetadata, S: ToCommitSelector, T: ToComm
208188
}
209189
MessageCombinationStrategy::KeepBoth => {
210190
push_message_with_spacing(&mut combined_message, target_commit_obj.message.as_ref());
211-
for source_id in ordered_selectors
212-
.iter()
213-
.rev()
214-
.copied()
215-
.filter(|commit_selector| *commit_selector != target_commit_selector)
216-
{
191+
for source_id in subject_selectors.iter().copied() {
217192
let (_, source_commit) = editor.find_selectable_commit(source_id)?;
218193
push_message_with_spacing(&mut combined_message, source_commit.message.as_ref());
219194
}
220195
}
221196
}
222197

223-
let (editor, bottom_most_selector) =
224-
construct_new_squashed_commit(editor, above_anchor, below_anchor, combined_message)?;
225-
226-
let rebase = editor.rebase()?;
227-
let mut editor = rebase.into_editor();
228-
229-
for commit_selector in ordered_selectors {
230-
if commit_selector == below_anchor {
231-
continue;
232-
}
233-
let Ok((selector, _)) = editor.find_selectable_commit(commit_selector) else {
234-
continue;
198+
let mut editor = editor;
199+
for commit_selector in subject_selectors {
200+
let delimiter = SegmentDelimiter {
201+
child: commit_selector,
202+
parent: commit_selector,
235203
};
204+
editor.disconnect_segment_from(delimiter, SelectorSet::All, SelectorSet::All, false)?;
205+
let (selector, _) = editor.find_selectable_commit(commit_selector)?;
236206
editor.replace(selector, Step::None)?;
237207
}
238208

209+
let (editor, new_target_selector) = construct_new_squashed_commit(
210+
editor,
211+
squashed_tree,
212+
target_commit_selector,
213+
combined_message,
214+
)?;
215+
239216
Ok(SquashCommitsOutcome {
240217
rebase: editor.rebase()?,
241-
commit_selector: bottom_most_selector,
218+
commit_selector: new_target_selector,
242219
})
243220
}
244221

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env bash
2+
3+
set -eu -o pipefail
4+
5+
source "${BASH_SOURCE[0]%/*}/shared.sh"
6+
7+
### General Description
8+
9+
# A ws-ref points to a workspace commit, with two stacks inside:
10+
# - One stack `A` with file-a changes
11+
# - Another stack with `B` introducing file-b and `C` introducing file-c
12+
git init
13+
14+
echo base >base
15+
git add base
16+
git commit -m M
17+
setup_target_to_match_main
18+
19+
git branch B
20+
git checkout -b A
21+
echo a >file-a
22+
git add file-a
23+
git commit -m A
24+
git checkout B
25+
echo b >file-b
26+
git add file-b
27+
git commit -m B
28+
git checkout B -b C
29+
echo c >file-c
30+
git add file-c
31+
git commit -m C
32+
create_workspace_commit_once A C

0 commit comments

Comments
 (0)