Skip to content

Commit 20551ca

Browse files
committed
graph: squash commits
Add an API that squashes commits using the new rebase engine.
1 parent dc0c262 commit 20551ca

7 files changed

Lines changed: 618 additions & 1 deletion

File tree

crates/but-graph/src/api.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use petgraph::{
1414

1515
use crate::{
1616
Commit, CommitIndex, Edge, EntryPoint, Graph, Segment, SegmentFlags, SegmentIndex,
17-
init::PetGraph, projection::commit::is_managed_workspace_by_message,
17+
SegmentRelation, init::PetGraph, projection::commit::is_managed_workspace_by_message,
1818
};
1919

2020
/// Mutation
@@ -74,6 +74,24 @@ impl Graph {
7474

7575
/// Merge-base computation
7676
impl Graph {
77+
/// Determine the ancestry relationship of `a` relative to `b`.
78+
///
79+
/// `Ancestor` means `a` is reachable from `b` when walking towards history,
80+
/// `Descendant` means the inverse, and `Diverged` means they share history
81+
/// but neither is ancestor of the other.
82+
pub fn relation_between(&self, a: SegmentIndex, b: SegmentIndex) -> SegmentRelation {
83+
if a == b {
84+
return SegmentRelation::Identity;
85+
}
86+
87+
match self.find_git_merge_base(a, b) {
88+
Some(base) if base == a => SegmentRelation::Ancestor,
89+
Some(base) if base == b => SegmentRelation::Descendant,
90+
Some(_) => SegmentRelation::Diverged,
91+
None => SegmentRelation::Disjoint,
92+
}
93+
}
94+
7795
/// Compute the merge-base just like Git would between segments `a` and `b`, but finding all possible merge-bases of a walk,
7896
/// which are then truncated to the highest merge-base that includes all the other merge-bases.
7997
///

crates/but-graph/src/lib.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,21 @@ pub struct EntryPoint<'graph> {
263263
pub commit: Option<&'graph Commit>,
264264
}
265265

266+
/// Relationship of one segment to another in terms of graph ancestry.
267+
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
268+
pub enum SegmentRelation {
269+
/// Both segment indices point to the same segment.
270+
Identity,
271+
/// The first segment is an ancestor of the second segment.
272+
Ancestor,
273+
/// The first segment is a descendant of the second segment.
274+
Descendant,
275+
/// Segments share history, but neither is ancestor of the other.
276+
Diverged,
277+
/// Segments do not share any history.
278+
Disjoint,
279+
}
280+
266281
/// This structure is used as data associated with each edge and is mainly for collecting
267282
/// the intent of an edge, which should always represent the connection of a commit to another.
268283
/// Sometimes, it represents the connection from a commit (or segment) to an empty segment which

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub mod move_commit;
2020
pub use move_commit::function::move_commit;
2121
pub mod discard_commit;
2222
pub use discard_commit::function::discard_commit;
23+
pub mod squash_commits;
24+
pub use squash_commits::function::{SquashCommitsOutcome, squash_commits};
2325

2426
/// A minimal stack for use by [WorkspaceCommit::new_from_stacks()].
2527
#[derive(Clone)]
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
//! An action to squash one commit into another.
2+
3+
pub(crate) mod function {
4+
use anyhow::{Result, bail};
5+
use but_core::RefMetadata;
6+
use but_graph::{SegmentIndex, SegmentRelation, projection::Workspace};
7+
use but_rebase::{
8+
commit::DateMode,
9+
graph_rebase::{
10+
Editor, Selector, Step, SuccessfulRebase, ToCommitSelector,
11+
mutate::{SegmentDelimiter, SelectorSet},
12+
},
13+
};
14+
15+
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
16+
enum ReorderDirection {
17+
MoveSubjectAboveTarget,
18+
MoveSubjectBelowTarget,
19+
}
20+
21+
fn find_commit_segment_index(
22+
workspace: &Workspace,
23+
commit_id: gix::ObjectId,
24+
) -> Option<SegmentIndex> {
25+
let (_, stack_segment, _) = workspace.find_commit_and_containers(commit_id)?;
26+
let commit_offset = stack_segment
27+
.commits
28+
.iter()
29+
.position(|c| c.id == commit_id)?;
30+
31+
let mut owning_segment = stack_segment.id;
32+
for (segment_id, offset) in &stack_segment.commits_by_segment {
33+
if *offset > commit_offset {
34+
break;
35+
}
36+
owning_segment = *segment_id;
37+
}
38+
39+
Some(owning_segment)
40+
}
41+
42+
fn determine_reorder_direction(
43+
workspace: &Workspace,
44+
repo: &gix::Repository,
45+
subject: &but_core::CommitOwned,
46+
target: &but_core::CommitOwned,
47+
) -> Result<ReorderDirection> {
48+
let subject_segment = find_commit_segment_index(workspace, subject.id)
49+
.ok_or_else(|| anyhow::anyhow!("Couldn't resolve subject commit segment"))?;
50+
let target_segment = find_commit_segment_index(workspace, target.id)
51+
.ok_or_else(|| anyhow::anyhow!("Couldn't resolve target commit segment"))?;
52+
53+
match workspace
54+
.graph
55+
.relation_between(subject_segment, target_segment)
56+
{
57+
SegmentRelation::Descendant => return Ok(ReorderDirection::MoveSubjectAboveTarget),
58+
SegmentRelation::Ancestor => return Ok(ReorderDirection::MoveSubjectBelowTarget),
59+
SegmentRelation::Disjoint | SegmentRelation::Diverged => {
60+
return Ok(ReorderDirection::MoveSubjectAboveTarget);
61+
}
62+
SegmentRelation::Identity => {
63+
// Commits can differ while still belonging to the same segment, so use commit-level
64+
// ancestry in this case.
65+
}
66+
}
67+
68+
let merge_base = match repo.merge_base(subject.id, target.id) {
69+
Ok(base) => base,
70+
// If commits don't have a merge-base (or merge-base resolution fails),
71+
// we still allow squashing by using a deterministic default ordering.
72+
Err(error) => match error {
73+
gix::repository::merge_base::Error::FindMergeBase(_)
74+
| gix::repository::merge_base::Error::NotFound { .. } => {
75+
return Ok(ReorderDirection::MoveSubjectAboveTarget);
76+
}
77+
_ => return Err(error.into()),
78+
},
79+
};
80+
81+
if merge_base == target.id {
82+
return Ok(ReorderDirection::MoveSubjectAboveTarget);
83+
}
84+
85+
if merge_base == subject.id {
86+
return Ok(ReorderDirection::MoveSubjectBelowTarget);
87+
}
88+
89+
Ok(ReorderDirection::MoveSubjectAboveTarget)
90+
}
91+
92+
/// The result of a squash_commits operation.
93+
#[derive(Debug)]
94+
pub struct SquashCommitsOutcome<'ws, 'meta, M: RefMetadata> {
95+
/// The successful rebase result.
96+
pub rebase: SuccessfulRebase<'ws, 'meta, M>,
97+
/// Selector pointing to the squashed replacement commit.
98+
pub commit_selector: Selector,
99+
}
100+
101+
/// Squash `subject_commit` into `target_commit`.
102+
///
103+
/// Depending on the ancestry relationship between the two commits, this operation may
104+
/// reorder them so that the subject ends up either above or below the target.
105+
///
106+
/// After any reordering, one of the two original commit positions (either the subject or
107+
/// the target) is replaced by a single squashed commit that has:
108+
/// - The tree of the commit that was top-most after reordering (subject or target)
109+
/// - The combined message `subject\n\ntarget`
110+
///
111+
/// The other original commit (subject or target, depending on the chosen ordering) is
112+
/// removed from history.
113+
pub fn squash_commits<'ws, 'meta, M: RefMetadata>(
114+
editor: Editor<'ws, 'meta, M>,
115+
subject_commit: impl ToCommitSelector,
116+
target_commit: impl ToCommitSelector,
117+
) -> Result<SquashCommitsOutcome<'ws, 'meta, M>> {
118+
let (subject_selector, subject) = editor.find_selectable_commit(subject_commit)?;
119+
let (target_selector, target) = editor.find_selectable_commit(target_commit)?;
120+
121+
if subject.id == target.id {
122+
bail!("Cannot squash a commit into itself")
123+
}
124+
125+
if subject.clone().attach(editor.repo()).is_conflicted() {
126+
bail!("Subject commit must not be conflicted")
127+
}
128+
129+
if target.clone().attach(editor.repo()).is_conflicted() {
130+
bail!("Target commit must not be conflicted")
131+
}
132+
133+
let direction =
134+
determine_reorder_direction(editor.workspace, editor.repo(), &subject, &target)?;
135+
let mut editor = editor;
136+
137+
let mut combined_message = Vec::new();
138+
combined_message.extend_from_slice(subject.message.as_ref());
139+
if !combined_message.ends_with(b"\n") {
140+
combined_message.push(b'\n');
141+
}
142+
combined_message.push(b'\n');
143+
combined_message.extend_from_slice(target.message.as_ref());
144+
145+
let (replace_selector, dropped_selector, mut commit_to_replace, top_tree_id) =
146+
match direction {
147+
ReorderDirection::MoveSubjectAboveTarget => (
148+
target_selector,
149+
subject_selector,
150+
target.clone(),
151+
subject.tree,
152+
),
153+
ReorderDirection::MoveSubjectBelowTarget => (
154+
subject_selector,
155+
target_selector,
156+
subject.clone(),
157+
target.tree,
158+
),
159+
};
160+
161+
let new_commit_id = {
162+
commit_to_replace.tree = top_tree_id;
163+
commit_to_replace.message = combined_message.into();
164+
editor.new_commit(commit_to_replace, DateMode::CommitterUpdateAuthorKeep)?
165+
};
166+
167+
let dropped_delimiter = SegmentDelimiter {
168+
child: dropped_selector,
169+
parent: dropped_selector,
170+
};
171+
editor.disconnect_segment_from(
172+
dropped_delimiter,
173+
SelectorSet::All,
174+
SelectorSet::All,
175+
false,
176+
)?;
177+
178+
editor.replace(replace_selector, Step::new_pick(new_commit_id))?;
179+
editor.replace(dropped_selector, Step::None)?;
180+
181+
Ok(SquashCommitsOutcome {
182+
rebase: editor.rebase()?,
183+
commit_selector: replace_selector,
184+
})
185+
}
186+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
3+
set -eu -o pipefail
4+
5+
git init
6+
7+
git checkout -b one
8+
echo "v1" > shared.txt
9+
git add shared.txt
10+
git commit -m "commit one"
11+
12+
git checkout -b two
13+
echo "v2" > shared.txt
14+
git add shared.txt
15+
git commit -m "commit two"
16+
17+
git checkout -b three
18+
echo "v3" > shared.txt
19+
git add shared.txt
20+
git commit -m "commit three"

crates/but-workspace/tests/workspace/commit/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod insert_blank_commit;
55
mod move_changes;
66
mod move_commit;
77
mod reword;
8+
mod squash_commits;
89
mod uncommit_changes;
910

1011
mod from_new_merge_with_metadata {

0 commit comments

Comments
 (0)