Skip to content

Commit cad541e

Browse files
authored
Merge pull request #13873 from gitbutlerapp/merge-arithmetic-squash
commit squash: use merge arithmetic
2 parents d0b9025 + 75fda44 commit cad541e

5 files changed

Lines changed: 447 additions & 116 deletions

File tree

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

Lines changed: 86 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+
merge_commit_changes::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,69 @@ 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`.
122+
/// This operation assumes the provided editor is already normalized and up to
123+
/// date. Callers chaining previous editor mutations should first run
124+
/// `editor.rebase()?.into_editor()` before squashing.
141125
///
142-
/// After reordering and squashing, the resulting squashed commit has:
143-
/// - The tree of the commit that is top-most after reordering.
126+
/// After squashing, the resulting squashed commit has:
127+
/// - The tree produced from the target commit's full tree plus the subject
128+
/// commits' own change ranges.
144129
/// - A message determined by `how_to_combine_messages`:
145130
/// - `KeepTarget`: target message only.
146131
/// - `KeepSubject`: subject messages only.
147132
/// - `KeepBoth`: target message followed by subject messages.
148133
///
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.
134+
/// Subject messages are appended in the order they are provided, with at least
135+
/// one blank line between non-empty message blocks.
151136
///
152137
pub fn squash_commits<'ws, 'meta, M: RefMetadata, S: ToCommitSelector, T: ToCommitSelector>(
153138
editor: Editor<'ws, 'meta, M>,
154139
subjects: Vec<S>,
155140
target_commit: T,
156141
how_to_combine_messages: MessageCombinationStrategy,
157142
) -> Result<SquashCommitsOutcome<'ws, 'meta, M>> {
143+
let mut seen_subjects = std::collections::HashSet::with_capacity(subjects.len());
144+
158145
if subjects.is_empty() {
159146
bail!("Need at least 2 commits to squash")
160147
}
161148

162149
let (target_commit_selector, target_commit_obj) =
163150
editor.find_selectable_commit(target_commit)?;
164151

165-
let mut all_commits = Vec::with_capacity(subjects.len() + 1);
166-
all_commits.push(target_commit_selector);
152+
let mut subject_selectors = Vec::with_capacity(subjects.len());
167153
for subject_commit in subjects {
168154
let (subject_commit_selector, _) = editor.find_selectable_commit(subject_commit)?;
169155
if subject_commit_selector == target_commit_selector {
170156
bail!("Cannot squash a commit into itself")
171157
}
172-
all_commits.push(subject_commit_selector);
158+
if !seen_subjects.insert(subject_commit_selector) {
159+
continue;
160+
}
161+
subject_selectors.push(subject_commit_selector);
173162
}
174163

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-
}
164+
let subject_commit_ids = subject_selectors
165+
.iter()
166+
.map(|commit_selector| {
167+
let (_, commit) = editor.find_selectable_commit(*commit_selector)?;
168+
Ok(commit.id)
169+
})
170+
.collect::<Result<Vec<_>>>()?;
171+
let squashed_tree = editor.merge_commit_changes_to_tree(
172+
target_commit_obj.id,
173+
subject_commit_ids,
174+
editor.repo().merge_options_force_ours()?,
175+
)?;
176+
if squashed_tree.conflict.is_some() {
177+
bail!("Cannot squash commits that would result in merge conflicts");
191178
}
192179

193180
let mut combined_message = Vec::new();
194181
match how_to_combine_messages {
195182
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-
{
183+
for source_id in subject_selectors.iter().copied() {
202184
let (_, source_commit) = editor.find_selectable_commit(source_id)?;
203185
push_message_with_spacing(&mut combined_message, source_commit.message.as_ref());
204186
}
@@ -208,37 +190,34 @@ pub fn squash_commits<'ws, 'meta, M: RefMetadata, S: ToCommitSelector, T: ToComm
208190
}
209191
MessageCombinationStrategy::KeepBoth => {
210192
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-
{
193+
for source_id in subject_selectors.iter().copied() {
217194
let (_, source_commit) = editor.find_selectable_commit(source_id)?;
218195
push_message_with_spacing(&mut combined_message, source_commit.message.as_ref());
219196
}
220197
}
221198
}
222199

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;
200+
let mut editor = editor;
201+
for commit_selector in subject_selectors {
202+
let delimiter = SegmentDelimiter {
203+
child: commit_selector,
204+
parent: commit_selector,
235205
};
206+
editor.disconnect_segment_from(delimiter, SelectorSet::All, SelectorSet::All, false)?;
207+
let (selector, _) = editor.find_selectable_commit(commit_selector)?;
236208
editor.replace(selector, Step::None)?;
237209
}
238210

211+
let (editor, new_target_selector) = construct_new_squashed_commit(
212+
editor,
213+
squashed_tree,
214+
target_commit_selector,
215+
combined_message,
216+
)?;
217+
239218
Ok(SquashCommitsOutcome {
240219
rebase: editor.rebase()?,
241-
commit_selector: bottom_most_selector,
220+
commit_selector: new_target_selector,
242221
})
243222
}
244223

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 deeper stacks inside:
10+
# - One stack `A -> D` with file-a and file-d changes
11+
# - Another stack `B -> C -> E` with file-b, file-c, and file-e changes
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 A -b D
25+
echo d >file-d
26+
git add file-d
27+
git commit -m D
28+
git checkout B
29+
echo b >file-b
30+
git add file-b
31+
git commit -m B
32+
git checkout B -b C
33+
echo c >file-c
34+
git add file-c
35+
git commit -m C
36+
git checkout C -b E
37+
echo e >file-e
38+
git add file-e
39+
git commit -m E
40+
create_workspace_commit_once D E
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)