Skip to content

Commit 6c12944

Browse files
committed
feat(reword): support rewording merge commits
We don't support rebasing merge commits in-memory at present because we don't have a method for resolving conflicts that might arise as part of the attempt to create a new merge commit. Consequently, `git reword` also doesn't work on merge commits, since it uses the rebase machinery. Since `git reword` is guaranteed not to change the contents (or topology) of any of the reworded commits, we can make an exception for this case, and permit rebasing merge commits in memory when a replacement commit is specified, because that guarantees that we won't have to resolve merge conflicts.
1 parent 55ba903 commit 6c12944

File tree

5 files changed

+234
-43
lines changed

5 files changed

+234
-43
lines changed

git-branchless-lib/src/core/rewrite/execute.rs

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@ mod in_memory {
354354
use crate::core::rewrite::move_branches;
355355
use crate::core::rewrite::plan::{OidOrLabel, RebaseCommand, RebasePlan};
356356
use crate::git::{
357-
CherryPickFastError, CherryPickFastOptions, GitRunInfo, MaybeZeroOid, NonZeroOid, Repo,
357+
CherryPickFastError, CherryPickFastOptions, GitRunInfo, MaybeZeroOid, NonZeroOid,
358+
ReferenceName, Repo,
358359
};
359360
use crate::util::ExitCode;
360361

@@ -390,10 +391,18 @@ mod in_memory {
390391
.iter()
391392
.find_map(|command| match command {
392393
RebaseCommand::Merge {
394+
replacement_commit_oid: None,
393395
commit_oid,
394396
commits_to_merge: _,
395397
} => Some(commit_oid),
396-
RebaseCommand::CreateLabel { .. }
398+
RebaseCommand::Merge {
399+
// If we know exactly what tree to use, we don't
400+
// actually have to carry out a merge in memory.
401+
replacement_commit_oid: Some(_),
402+
commit_oid: _,
403+
commits_to_merge: _,
404+
}
405+
| RebaseCommand::CreateLabel { .. }
397406
| RebaseCommand::Reset { .. }
398407
| RebaseCommand::Pick { .. }
399408
| RebaseCommand::RegisterExtraPostRewriteHook
@@ -585,18 +594,109 @@ mod in_memory {
585594
}
586595

587596
RebaseCommand::Merge {
597+
replacement_commit_oid: None,
588598
commit_oid,
589599
commits_to_merge: _,
590600
} => {
591601
warn!(
592602
?commit_oid,
593-
"BUG: Merge commit should have been detected when starting in-memory rebase"
603+
"BUG: Merge commit without replacement should have been detected when starting in-memory rebase"
594604
);
595605
return Ok(RebaseInMemoryResult::CannotRebaseMergeCommit {
596606
commit_oid: *commit_oid,
597607
});
598608
}
599609

610+
RebaseCommand::Merge {
611+
replacement_commit_oid: Some(replacement_commit_oid),
612+
commit_oid,
613+
commits_to_merge,
614+
} => {
615+
let current_commit = repo
616+
.find_commit_or_fail(current_oid)
617+
.wrap_err("Finding current commit")?;
618+
let commit_to_apply = repo
619+
.find_commit_or_fail(*replacement_commit_oid)
620+
.wrap_err("Finding commit to apply")?;
621+
i += 1;
622+
623+
let commit_description = printable_styled_string(
624+
effects.get_glyphs(),
625+
commit_to_apply.friendly_describe(effects.get_glyphs())?,
626+
)?;
627+
let commit_num = format!("[{}/{}]", i, num_picks);
628+
progress.notify_progress(i, num_picks);
629+
630+
progress
631+
.notify_status(format!("Applying merge commit: {}", commit_description));
632+
let commit = repo.find_commit_or_fail(*replacement_commit_oid)?;
633+
let commit_tree = commit.get_tree()?;
634+
let commit_message = commit_to_apply.get_message_raw()?;
635+
let commit_message = commit_message.to_str().with_context(|| {
636+
eyre::eyre!(
637+
"Could not decode commit message for commit: {:?}",
638+
replacement_commit_oid,
639+
)
640+
})?;
641+
642+
progress
643+
.notify_status(format!("Committing to repository: {}", commit_description));
644+
let committer_signature = if *preserve_timestamps {
645+
commit_to_apply.get_committer()
646+
} else {
647+
commit_to_apply.get_committer().update_timestamp(*now)?
648+
};
649+
let parents = {
650+
let mut result = vec![current_commit];
651+
for parent in commits_to_merge {
652+
let parent = match parent {
653+
OidOrLabel::Oid(oid) => repo.find_commit_or_fail(*oid)?,
654+
OidOrLabel::Label(label) => {
655+
let reference = repo
656+
.find_reference(&ReferenceName::from(label.clone()))?
657+
.ok_or_else(|| {
658+
eyre::eyre!("Could not find label {label}")
659+
})?;
660+
reference.peel_to_commit()?.ok_or_else(|| {
661+
eyre::eyre!(
662+
"Reference {label} could not be resolved to a commit"
663+
)
664+
})?
665+
}
666+
};
667+
result.push(parent);
668+
}
669+
result
670+
};
671+
let rebased_commit_oid = repo
672+
.create_commit(
673+
None,
674+
&commit_to_apply.get_author(),
675+
&committer_signature,
676+
commit_message,
677+
&commit_tree,
678+
parents.iter().collect(),
679+
)
680+
.wrap_err("Applying rebased commit")?;
681+
682+
let commit_description = printable_styled_string(
683+
effects.get_glyphs(),
684+
repo.friendly_describe_commit_from_oid(
685+
effects.get_glyphs(),
686+
rebased_commit_oid,
687+
)?,
688+
)?;
689+
rewritten_oids.push((*commit_oid, MaybeZeroOid::NonZero(rebased_commit_oid)));
690+
current_oid = rebased_commit_oid;
691+
692+
writeln!(
693+
effects.get_output_stream(),
694+
"{} Committed as: {}",
695+
commit_num,
696+
commit_description
697+
)?;
698+
}
699+
600700
RebaseCommand::SkipUpstreamAppliedCommit { commit_oid } => {
601701
i += 1;
602702
let commit_num = format!("[{}/{}]", i, num_picks);

git-branchless-lib/src/core/rewrite/plan.rs

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::collections::{HashMap, HashSet};
2-
use std::fmt::Write;
2+
use std::fmt::{Display, Write};
33
use std::ops::Sub;
44
use std::path::PathBuf;
55
use std::sync::Arc;
@@ -24,11 +24,11 @@ pub enum OidOrLabel {
2424
Label(String),
2525
}
2626

27-
impl ToString for OidOrLabel {
28-
fn to_string(&self) -> String {
27+
impl Display for OidOrLabel {
28+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2929
match self {
30-
Self::Oid(oid) => oid.to_string(),
31-
Self::Label(label) => label.clone(),
30+
OidOrLabel::Oid(oid) => write!(f, "{oid}"),
31+
OidOrLabel::Label(label) => write!(f, "{label}"),
3232
}
3333
}
3434
}
@@ -51,7 +51,11 @@ pub enum RebaseCommand {
5151
},
5252

5353
Merge {
54-
/// The original merge commit to copy the commit message from.
54+
/// If specified, the new merge commit will use this commit's message
55+
/// and tree, rather than attempting a new merge.
56+
replacement_commit_oid: Option<NonZeroOid>,
57+
58+
/// The original merge commit to copy the commit contents from.
5559
commit_oid: NonZeroOid,
5660

5761
/// The other commits to merge into this one. This may be a list of
@@ -86,12 +90,13 @@ impl ToString for RebaseCommand {
8690
fn to_string(&self) -> String {
8791
match self {
8892
RebaseCommand::CreateLabel { label_name } => format!("label {}", label_name),
89-
RebaseCommand::Reset { target } => format!("reset {}", target.to_string()),
93+
RebaseCommand::Reset { target } => format!("reset {}", target),
9094
RebaseCommand::Pick {
9195
original_commit_oid: _,
9296
commit_to_apply_oid: commit_oid,
9397
} => format!("pick {}", commit_oid),
9498
RebaseCommand::Merge {
99+
replacement_commit_oid: None,
95100
commit_oid,
96101
commits_to_merge,
97102
} => format!(
@@ -102,6 +107,21 @@ impl ToString for RebaseCommand {
102107
.map(|commit_id| commit_id.to_string())
103108
.join(" ")
104109
),
110+
RebaseCommand::Merge {
111+
replacement_commit_oid: Some(replacement_commit_oid),
112+
commit_oid,
113+
commits_to_merge,
114+
} => {
115+
let other_parents = commits_to_merge
116+
.iter()
117+
.map(|parent| format!("-p {parent}"))
118+
.join(" ");
119+
// FIXME: Untested, this probably doesn't work. Currently,
120+
// on-disk rebases for merges with replacement commits are not
121+
// generated by any command, so this code path is never
122+
// triggered.
123+
format!("exec git show -s --format=%B {replacement_commit_oid} | git commit-tree -p {commit_oid} {other_parents} {replacement_commit_oid}^{{tree}}")
124+
}
105125
RebaseCommand::RegisterExtraPostRewriteHook => {
106126
"exec git branchless hook-register-extra-post-rewrite-hook".to_string()
107127
}
@@ -728,11 +748,36 @@ impl<'a> RebasePlanBuilder<'a> {
728748

729749
match commits_to_merge {
730750
Some(commits_to_merge) => {
731-
// All parents have been committed.
732-
acc.push(RebaseCommand::Merge {
733-
commit_oid: current_commit.get_oid(),
734-
commits_to_merge,
735-
});
751+
if acc.iter().any(|command| {
752+
matches!(command,
753+
RebaseCommand::Merge {
754+
replacement_commit_oid: _,
755+
commit_oid,
756+
commits_to_merge: _,
757+
} if *commit_oid == current_commit.get_oid())
758+
}) {
759+
// Above, we've checked to make sure that all parent
760+
// commits which are being moved have had their
761+
// labels applied. However, if the commit has
762+
// multiple parents which were *not* moved, then we
763+
// would visit this commit and attempt to reapply it
764+
// multiple times. The simplest fix is to not
765+
// reapply merge commits which have already been
766+
// applied.
767+
//
768+
// FIXME: O(n^2) algorithm.
769+
return Ok(acc);
770+
} else {
771+
// All parents have been committed.
772+
acc.push(RebaseCommand::Merge {
773+
replacement_commit_oid: self
774+
.replacement_commits
775+
.get(&current_commit.get_oid())
776+
.copied(),
777+
commit_oid: current_commit.get_oid(),
778+
commits_to_merge,
779+
});
780+
}
736781
}
737782

738783
None => {
@@ -1025,6 +1070,7 @@ impl<'a> RebasePlanBuilder<'a> {
10251070
commit_to_apply_oid: commit_oid,
10261071
}
10271072
| RebaseCommand::Merge {
1073+
replacement_commit_oid: _,
10281074
commit_oid,
10291075
commits_to_merge: _,
10301076
}

git-branchless/src/commands/reword.rs

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -186,37 +186,17 @@ pub fn reword(
186186
}
187187
};
188188

189-
let subtree_roots = find_subtree_roots(&repo, &dag, &commits)?;
190-
191189
let rebase_plan = {
192190
let pool = ThreadPoolBuilder::new().build()?;
193191
let repo_pool = RepoResource::new_pool(&repo)?;
194192
let mut builder = RebasePlanBuilder::new(&dag, permissions);
195193

196-
for root_commit in subtree_roots {
197-
let only_parent_id = root_commit.get_only_parent().map(|parent| parent.get_oid());
198-
let only_parent_id = match only_parent_id {
199-
Some(only_parent_id) => only_parent_id,
200-
None => {
201-
writeln!(
202-
effects.get_error_stream(),
203-
"Refusing to reword commit {}, which has {} parents.\n\
204-
Rewording is only supported for commits with 1 parent.\n\
205-
Aborting.",
206-
root_commit.get_oid(),
207-
root_commit.get_parents().len(),
208-
)?;
209-
return Ok(ExitCode(1));
210-
}
211-
};
212-
builder.move_subtree(root_commit.get_oid(), vec![only_parent_id])?;
213-
}
214-
215194
for commit in commits.iter() {
216195
let message = messages.get(&commit.get_oid()).unwrap();
217196
// This looks funny, but just means "leave everything but the message as is"
218197
let replacement_oid =
219198
commit.amend_commit(None, None, None, Some(message.as_str()), None)?;
199+
builder.move_subtree(commit.get_oid(), commit.get_parent_oids())?;
220200
builder.replace_commit(commit.get_oid(), replacement_oid)?;
221201
}
222202

@@ -266,13 +246,7 @@ pub fn reword(
266246
}
267247
ExecuteRebasePlanResult::Succeeded {
268248
rewritten_oids: None,
269-
} => {
270-
writeln!(
271-
effects.get_error_stream(),
272-
"BUG: Succeeded rewording commits via on-disk rebase? But reword should be rebasing in-memory!"
273-
)?;
274-
ExitCode(1)
275-
}
249+
} => ExitCode(0),
276250
ExecuteRebasePlanResult::DeclinedToMerge { merge_conflict: _ } => {
277251
writeln!(
278252
effects.get_error_stream(),

git-branchless/tests/command/test_move.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3625,6 +3625,7 @@ fn test_move_merge_commit() -> eyre::Result<()> {
36253625
label_name: "merge-parent-3",
36263626
},
36273627
Merge {
3628+
replacement_commit_oid: None,
36283629
commit_oid: NonZeroOid(28790c73f13f38ce0d3beb6cfeb2d818b32bcd09),
36293630
commits_to_merge: [
36303631
Oid(

git-branchless/tests/command/test_reword.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,73 @@ fn test_reword_exit_early_public_commit() -> eyre::Result<()> {
334334

335335
Ok(())
336336
}
337+
338+
#[test]
339+
fn test_reword_merge_commit() -> eyre::Result<()> {
340+
let git = make_git()?;
341+
342+
if !git.supports_reference_transactions()? {
343+
return Ok(());
344+
}
345+
git.init_repo()?;
346+
347+
git.commit_file("test1", 1)?;
348+
git.detach_head()?;
349+
let test2_oid = git.commit_file("test2", 2)?;
350+
git.run(&["checkout", "HEAD^"])?;
351+
git.commit_file("test3", 3)?;
352+
git.run(&["merge", &test2_oid.to_string()])?;
353+
354+
{
355+
let (stdout, _stderr) = git.run(&["smartlog"])?;
356+
insta::assert_snapshot!(stdout, @r###"
357+
:
358+
O 62fc20d (master) create test1.txt
359+
|\
360+
| o 96d1c37 create test2.txt
361+
| |
362+
| @ a4dd9b0 Merge commit '96d1c37a3d4363611c49f7e52186e189a04c531f' into HEAD
363+
|
364+
o 4838e49 create test3.txt
365+
|
366+
@ a4dd9b0 Merge commit '96d1c37a3d4363611c49f7e52186e189a04c531f' into HEAD
367+
"###);
368+
}
369+
370+
{
371+
let (stdout, stderr) = git.run(&["reword", "-m", "new message"])?;
372+
insta::assert_snapshot!(stderr, @r###"
373+
branchless: creating working copy snapshot
374+
Previous HEAD position was a4dd9b0 Merge commit '96d1c37a3d4363611c49f7e52186e189a04c531f' into HEAD
375+
branchless: processing 1 update: ref HEAD
376+
HEAD is now at 2fc54bd new message
377+
branchless: processing checkout
378+
"###);
379+
insta::assert_snapshot!(stdout, @r###"
380+
Attempting rebase in-memory...
381+
[1/1] Committed as: 2fc54bd new message
382+
branchless: processing 1 rewritten commit
383+
branchless: running command: <git-executable> checkout 2fc54bd59c79078e6d9012df241bcc90f0199596
384+
In-memory rebase succeeded.
385+
Reworded commit a4dd9b0 as 2fc54bd new message
386+
"###);
387+
}
388+
389+
{
390+
let (stdout, _stderr) = git.run(&["smartlog"])?;
391+
insta::assert_snapshot!(stdout, @r###"
392+
:
393+
O 62fc20d (master) create test1.txt
394+
|\
395+
| o 96d1c37 create test2.txt
396+
| |
397+
| @ 2fc54bd new message
398+
|
399+
o 4838e49 create test3.txt
400+
|
401+
@ 2fc54bd new message
402+
"###);
403+
}
404+
405+
Ok(())
406+
}

0 commit comments

Comments
 (0)