* [PATCH 0/3] Teach git-replay(1) to linearize merge commits
@ 2026-06-08 18:37 Toon Claes
2026-06-08 18:37 ` [PATCH 1/3] replay: refactor enum replay_mode into a bool Toon Claes
` (3 more replies)
0 siblings, 4 replies; 34+ messages in thread
From: Toon Claes @ 2026-06-08 18:37 UTC (permalink / raw)
To: git; +Cc: Toon Claes, Johannes Schindelin
As an alternative to dscho's patch series to replay merges[1], add
option to git-replay(1) to linearize merges. This mimics wath
git-rebase(1) does too with --no-rebase-merges (the default).
The first two patches do some refactoring. The third patch implements
the actual change. I was kindly helped by dscho to implement this
change.
The --linearize option is only added to git-replay(1) and not to
git-history(1) because in my opinion doesn't make much sense to do so,
but I'm happy to hear if anyone disagrees.
This series might conflict with Kristoffer's series to make
documentation changes[2], but should be trivial to resolve. And I don't
think there's a conflict with Patrick's series on adding "drop" to
git-history(1)[3].
dscho's series to replay merges[1] need a bit of rework to fit on top of
this, but I'm happy to help figuring that out.
[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>
[2]: <V2_CV_doc_replay_config.767@msgid.xyz>
[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
Johannes Schindelin (1):
replay: offer an option to linearize the commit topology
Toon Claes (2):
replay: refactor enum replay_mode into a bool
replay: add helper to put entry into mapped_commits
Documentation/git-replay.adoc | 5 ++
builtin/replay.c | 4 ++
replay.c | 109 +++++++++++++++++++++++-------------------
replay.h | 5 ++
t/t3650-replay-basics.sh | 22 +++++++++
5 files changed, 97 insertions(+), 48 deletions(-)
---
base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
change-id: 20260604-toon-git-replay-drop-merges-807fa008d395
^ permalink raw reply [flat|nested] 34+ messages in thread* [PATCH 1/3] replay: refactor enum replay_mode into a bool 2026-06-08 18:37 [PATCH 0/3] Teach git-replay(1) to linearize merge commits Toon Claes @ 2026-06-08 18:37 ` Toon Claes 2026-06-08 18:37 ` [PATCH 2/3] replay: add helper to put entry into mapped_commits Toon Claes ` (2 subsequent siblings) 3 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-08 18:37 UTC (permalink / raw) To: git; +Cc: Toon Claes In 2760ee4983 (replay: add --revert mode to reverse commit changes, 2026-03-26) the enum `replay_mode` was introduced. This has two possible values: - The value `REPLAY_MODE_REVERT` is used when option `--revert` is passed to git-replay(1). When using this value the commits are possible in reverse order and the inverse of the changes are applied. - The value `REPLAY_MODE_PICK` is used when either option `--onto` or `--advance` is used. In both cases the commits are pocessed in normal order, and the changes are applied as-is. Since there are only two possible values of this enum, simplify the code by converting the enum into a bool. This avoid adding code paths that check for invalid vaues of the enum, and shortens code where the value is checked with a ternary operator. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 59 +++++++++++++++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/replay.c b/replay.c index 4ef8abb607..1f8e5b083b 100644 --- a/replay.c +++ b/replay.c @@ -18,11 +18,6 @@ */ #define the_repository DO_NOT_USE_THE_REPOSITORY -enum replay_mode { - REPLAY_MODE_PICK, - REPLAY_MODE_REVERT, -}; - static const char *short_commit_name(struct repository *repo, struct commit *commit) { @@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo, struct tree *tree, struct commit *based_on, struct commit *parent, - enum replay_mode mode) + bool reverse) { struct object_id ret; struct object *obj = NULL; @@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo, commit_list_insert(parent, &parents); extra = read_commit_extra_headers(based_on, exclude_gpgsig); - if (mode == REPLAY_MODE_REVERT) { + if (reverse) { generate_revert_message(&msg, based_on, repo); /* For revert, use current user as author (NULL = use default) */ - } else if (mode == REPLAY_MODE_PICK) { + } else { find_commit_subject(message, &orig_message); strbuf_addstr(&msg, orig_message); author = get_author(message); - } else { - BUG("unexpected replay mode %d", mode); } reset_ident_date(); if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents, @@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, - enum replay_mode mode, + bool reverse, enum replay_empty_commit_action empty) { struct commit *base, *replayed_base; @@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo, replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); - if (mode == REPLAY_MODE_PICK) { + if (reverse) { + /* Revert: swap base and pickme to reverse the diff */ + const char *pickme_name = short_commit_name(repo, pickme); + merge_opt->branch1 = short_commit_name(repo, replayed_base); + merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); + merge_opt->ancestor = pickme_name; + + merge_incore_nonrecursive(merge_opt, + pickme_tree, + replayed_base_tree, + base_tree, + result); + + free((char *)merge_opt->branch2); + } else { /* Cherry-pick: normal order */ merge_opt->branch1 = short_commit_name(repo, replayed_base); merge_opt->branch2 = short_commit_name(repo, pickme); @@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo, result); free((char *)merge_opt->ancestor); - } else if (mode == REPLAY_MODE_REVERT) { - /* Revert: swap base and pickme to reverse the diff */ - const char *pickme_name = short_commit_name(repo, pickme); - merge_opt->branch1 = short_commit_name(repo, replayed_base); - merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); - merge_opt->ancestor = pickme_name; - - merge_incore_nonrecursive(merge_opt, - pickme_tree, - replayed_base_tree, - base_tree, - result); - - free((char *)merge_opt->branch2); - } else { - BUG("unexpected replay mode %d", mode); } merge_opt->ancestor = NULL; merge_opt->branch2 = NULL; @@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo, } } - return create_commit(repo, result->tree, pickme, replayed_base, mode); + return create_commit(repo, result->tree, pickme, replayed_base, reverse); } void replay_result_release(struct replay_result *result) @@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs, char *revert; const char *ref; struct object_id old_oid; - enum replay_mode mode = REPLAY_MODE_PICK; + bool reverse; int ret; advance = xstrdup_or_null(opts->advance); revert = xstrdup_or_null(opts->revert); - if (revert) - mode = REPLAY_MODE_REVERT; + reverse = !!revert; + set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, &detached_head, &advance, &revert, &onto, &update_refs); @@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs, die(_("replaying merge commits is not supported yet!")); last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - mode == REPLAY_MODE_REVERT ? last_commit : onto, - &merge_opt, &result, mode, opts->empty); + reverse ? last_commit : onto, + &merge_opt, &result, reverse, opts->empty); if (!last_commit) break; -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* [PATCH 2/3] replay: add helper to put entry into mapped_commits 2026-06-08 18:37 [PATCH 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-08 18:37 ` [PATCH 1/3] replay: refactor enum replay_mode into a bool Toon Claes @ 2026-06-08 18:37 ` Toon Claes 2026-06-08 18:37 ` [PATCH 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-10 14:49 ` [PATCH v2 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 3 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-08 18:37 UTC (permalink / raw) To: git; +Cc: Toon Claes The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put commit entry into mapped_commits into a helper function. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/replay.c b/replay.c index 1f8e5b083b..7921d7dba3 100644 --- a/replay.c +++ b/replay.c @@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo, strset_clear(&rinfo.positive_refs); } -static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, - struct commit *commit, - struct commit *fallback) +static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *fallback) { khint_t pos; if (!commit) @@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, return kh_value(replayed_commits, pos); } +static void put_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *new_commit) +{ + khint_t pos; + int ret; + + pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret); + if (ret == 0) + BUG("Duplicate rewritten commit: %s\n", + oid_to_hex(&commit->object.oid)); + + kh_value(replayed_commits, pos) = new_commit; +} + static struct commit *pick_regular_commit(struct repository *repo, struct commit *pickme, kh_oid_map_t *replayed_commits, @@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = mapped_commit(replayed_commits, base, onto); + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs, replayed_commits = kh_init_oid_map(); while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - khint_t pos; - int hr; if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); @@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs, break; /* Record commit -> last_commit mapping */ - pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr); - if (hr == 0) - BUG("Duplicate rewritten commit: %s\n", - oid_to_hex(&commit->object.oid)); - kh_value(replayed_commits, pos) = last_commit; + put_mapped_commit(replayed_commits, commit, last_commit); /* Update any necessary branches */ if (ref) -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* [PATCH 3/3] replay: offer an option to linearize the commit topology 2026-06-08 18:37 [PATCH 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-08 18:37 ` [PATCH 1/3] replay: refactor enum replay_mode into a bool Toon Claes 2026-06-08 18:37 ` [PATCH 2/3] replay: add helper to put entry into mapped_commits Toon Claes @ 2026-06-08 18:37 ` Toon Claes 2026-06-08 19:29 ` Junio C Hamano 2026-06-10 14:49 ` [PATCH v2 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 3 siblings, 1 reply; 34+ messages in thread From: Toon Claes @ 2026-06-08 18:37 UTC (permalink / raw) To: git; +Cc: Toon Claes, Johannes Schindelin From: Johannes Schindelin <Johannes.Schindelin@gmx.de> One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side. The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearized the commit history into a sequence of the regular (single-parent) commits. Add option `--linearize` to git-replay(1) do the same. Co-authored-by: Toon Claes <toon@iotcl.com> --- Documentation/git-replay.adoc | 5 +++++ builtin/replay.c | 4 ++++ replay.c | 25 +++++++++++++++++++------ replay.h | 5 +++++ t/t3650-replay-basics.sh | 22 ++++++++++++++++++++++ 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index a32f72aead..41c96c7061 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only). + The default mode can be configured via the `replay.refAction` configuration variable. +--linearize:: + In this mode, `git replay` imitates `git rebase --no-rebase-merges`, + i.e. it cherry-picks only non-merge commits, each one on top of the + previous one. + <revision-range>:: Range of commits to replay; see "Specifying Ranges" in linkgit:git-rev-parse[1]. In `--advance=<branch>` or diff --git a/builtin/replay.c b/builtin/replay.c index 39e3a86f6c..fedfe46dc6 100644 --- a/builtin/replay.c +++ b/builtin/replay.c @@ -111,6 +111,8 @@ int cmd_replay(int argc, N_("mode"), N_("control ref update behavior (update|print)"), PARSE_OPT_NONEG), + OPT_BOOL(0, "linearize", &opts.linearize, + N_("ignore merge commits instead of replaying them")), OPT_END() }; @@ -132,6 +134,8 @@ int cmd_replay(int argc, opts.contained, "--contained"); die_for_incompatible_opt2(!!opts.ref, "--ref", !!opts.contained, "--contained"); + die_for_incompatible_opt2(!!opts.revert, "--revert", + opts.linearize, "--linearize"); /* Parse ref action mode from command line or config */ ref_mode = get_ref_action_mode(repo, ref_action); diff --git a/replay.c b/replay.c index 7921d7dba3..3e36908131 100644 --- a/replay.c +++ b/replay.c @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, + struct commit *replayed_base, bool reverse, enum replay_empty_commit_action empty) { - struct commit *base, *replayed_base; + struct commit *base; struct tree *pickme_tree, *base_tree, *replayed_base_tree; + if (replayed_base && reverse) + BUG("Linearizing commits is not supported when replaying in reverse"); + if (pickme->parents) { base = pickme->parents->item; base_tree = repo_get_commit_tree(repo, base); @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = get_mapped_commit(replayed_commits, base, onto); + if (!replayed_base) + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - if (commit->parents && commit->parents->next) + if (opts->linearize && (!commit->parents || commit->parents->next)) + ; /* map current commit to the same as the previous commit */ + else if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); + else { + struct commit *to_pick = reverse ? last_commit : onto; + last_commit = + pick_regular_commit(revs->repo, commit, + replayed_commits, to_pick, + &merge_opt, &result, + opts->linearize ? last_commit : NULL, + reverse, opts->empty); + } - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - reverse ? last_commit : onto, - &merge_opt, &result, reverse, opts->empty); if (!last_commit) break; diff --git a/replay.h b/replay.h index 1851a07705..07e6fdcca3 100644 --- a/replay.h +++ b/replay.h @@ -62,6 +62,11 @@ struct replay_revisions_options { * Defaults to REPLAY_EMPTY_COMMIT_DROP. */ enum replay_empty_commit_action empty; + + /* + * Whether to linearize the commits (i.e. drop merge commits). + */ + int linearize; }; /* This struct is used as an out-parameter by `replay_revisions()`. */ diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh index 3353bc4a4d..c781a3bb1b 100755 --- a/t/t3650-replay-basics.sh +++ b/t/t3650-replay-basics.sh @@ -565,4 +565,26 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' test_grep "cannot be used with multiple revision ranges" err ' +test_expect_success 'linearize the commit topology' ' + test_tick && + N=$(git commit-tree -m N -p L -p I L:) && + N=$(git commit-tree -m N-child -p $N L:) && + git update-ref refs/heads/N $N && + + git replay --ref-action=print --linearize \ + --onto A B..refs/heads/N >out && + + test_line_count = 1 out && + read N1 N2 N3 N4 <out && + + cat >expect <<-EOF && + * N-child + * I + * L + o A + EOF + git log --format=%s --graph --boundary A...$N3 >actual && + test_cmp expect actual +' + test_done -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* Re: [PATCH 3/3] replay: offer an option to linearize the commit topology 2026-06-08 18:37 ` [PATCH 3/3] replay: offer an option to linearize the commit topology Toon Claes @ 2026-06-08 19:29 ` Junio C Hamano 2026-06-10 14:26 ` Toon Claes 0 siblings, 1 reply; 34+ messages in thread From: Junio C Hamano @ 2026-06-08 19:29 UTC (permalink / raw) To: Toon Claes; +Cc: git, Johannes Schindelin Toon Claes <toon@iotcl.com> writes: > From: Johannes Schindelin <Johannes.Schindelin@gmx.de> > > One of the stated goals of git-replay(1) is to allow implementing the > git-rebase(1) functionality on the server side. > > The default mode of git-rebase(1) is to act as if `--no-rebase-merges` > was given. This mode drops merge commits instead of replaying them, and > linearized the commit history into a sequence of the > regular (single-parent) commits. "linearized" -> "linearizes"? > > Add option `--linearize` to git-replay(1) do the same. "do the same" -> "to do the same"? > Co-authored-by: Toon Claes <toon@iotcl.com> There is no sign-off by any of the authors? > @@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs, > while ((commit = get_revision(revs))) { > const struct name_decoration *decoration; > > - if (commit->parents && commit->parents->next) > + if (opts->linearize && (!commit->parents || commit->parents->next)) > + ; /* map current commit to the same as the previous commit */ This uses the same treatment on either root commits or merge commits? If this were a mistake and this wants to handle merges but not roots, shouldn't it be more like if (opts->linearize && (commit->parents && commit->parents->next)) ; /* map the merge to the previous */ > + else if (commit->parents && commit->parents->next) > die(_("replaying merge commits is not supported yet!")); And because the next one is also about merges, perhaps the early part of this if/else if cascade can be written if (commit->parents && commit->parents->next) { /* We have a merge */ if (!opts->linearize) die(_("can't replay a merge (yet)")); ; /* map current to the previous */ } else { ... wouldn't it? If the "map current to prev" is applicable to root, any root are mapped to the last_commit in the above, and if we saw a root as the first thing in the loop, last_commit is NULL, we do not do anything here, and after the if/else if/else cascade, we see last_commit is NULL and break out of the loop. > + else { > + struct commit *to_pick = reverse ? last_commit : onto; > + last_commit = > + pick_regular_commit(revs->repo, commit, > + replayed_commits, to_pick, > + &merge_opt, &result, > + opts->linearize ? last_commit : NULL, > + reverse, opts->empty); > + } > > - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, > - reverse ? last_commit : onto, > - &merge_opt, &result, reverse, opts->empty); > if (!last_commit) > break; > diff --git a/replay.h b/replay.h > index 1851a07705..07e6fdcca3 100644 > --- a/replay.h > +++ b/replay.h > @@ -62,6 +62,11 @@ struct replay_revisions_options { > * Defaults to REPLAY_EMPTY_COMMIT_DROP. > */ > enum replay_empty_commit_action empty; > + > + /* > + * Whether to linearize the commits (i.e. drop merge commits). > + */ > + int linearize; > }; > > /* This struct is used as an out-parameter by `replay_revisions()`. */ > diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh > index 3353bc4a4d..c781a3bb1b 100755 > --- a/t/t3650-replay-basics.sh > +++ b/t/t3650-replay-basics.sh > @@ -565,4 +565,26 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' > test_grep "cannot be used with multiple revision ranges" err > ' > > +test_expect_success 'linearize the commit topology' ' > + test_tick && > + N=$(git commit-tree -m N -p L -p I L:) && > + N=$(git commit-tree -m N-child -p $N L:) && > + git update-ref refs/heads/N $N && > + > + git replay --ref-action=print --linearize \ > + --onto A B..refs/heads/N >out && > + > + test_line_count = 1 out && > + read N1 N2 N3 N4 <out && > + > + cat >expect <<-EOF && > + * N-child > + * I > + * L > + o A > + EOF > + git log --format=%s --graph --boundary A...$N3 >actual && > + test_cmp expect actual > +' Perhaps we would want to have a test that replays all the way down to the root commit? ^ permalink raw reply [flat|nested] 34+ messages in thread
* Re: [PATCH 3/3] replay: offer an option to linearize the commit topology 2026-06-08 19:29 ` Junio C Hamano @ 2026-06-10 14:26 ` Toon Claes 0 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-10 14:26 UTC (permalink / raw) To: Junio C Hamano, Johannes Schindelin; +Cc: git Junio C Hamano <gitster@pobox.com> writes: > Toon Claes <toon@iotcl.com> writes: > >> From: Johannes Schindelin <Johannes.Schindelin@gmx.de> >> >> One of the stated goals of git-replay(1) is to allow implementing the >> git-rebase(1) functionality on the server side. >> >> The default mode of git-rebase(1) is to act as if `--no-rebase-merges` >> was given. This mode drops merge commits instead of replaying them, and >> linearized the commit history into a sequence of the >> regular (single-parent) commits. > > "linearized" -> "linearizes"? Thanks. >> >> Add option `--linearize` to git-replay(1) do the same. > > "do the same" -> "to do the same"? Ack. >> Co-authored-by: Toon Claes <toon@iotcl.com> > > There is no sign-off by any of the authors? My bad. I'll add mine. @Johannes, can I re-add yours? I've removed it because I've made some changes on top of the patch you wrote, but if you agree, I'll add your Sign-off back. >> @@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs, >> while ((commit = get_revision(revs))) { >> const struct name_decoration *decoration; >> >> - if (commit->parents && commit->parents->next) >> + if (opts->linearize && (!commit->parents || commit->parents->next)) >> + ; /* map current commit to the same as the previous commit */ > > This uses the same treatment on either root commits or merge > commits? If this were a mistake and this wants to handle merges but > not roots, shouldn't it be more like > > if (opts->linearize && (commit->parents && commit->parents->next)) > ; /* map the merge to the previous */ > >> + else if (commit->parents && commit->parents->next) >> die(_("replaying merge commits is not supported yet!")); > > And because the next one is also about merges, perhaps the early > part of this if/else if cascade can be written > > if (commit->parents && commit->parents->next) { > /* We have a merge */ > if (!opts->linearize) > die(_("can't replay a merge (yet)")); > ; /* map current to the previous */ > } else { > ... > > wouldn't it? The way it was written in v1 was maybe a bit too smart and hard to follow. I agree with your suggestion and will adopt this (with some tweaks) in the next version. > If the "map current to prev" is applicable to root, any root are > mapped to the last_commit in the above, and if we saw a root as the > first thing in the loop, last_commit is NULL, we do not do anything > here, and after the if/else if/else cascade, we see last_commit is > NULL and break out of the loop. Yes, good observation. I did not test this. > Perhaps we would want to have a test that replays all the way down > to the root commit? I'll add it. -- Cheers, Toon ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v2 0/3] Teach git-replay(1) to linearize merge commits 2026-06-08 18:37 [PATCH 0/3] Teach git-replay(1) to linearize merge commits Toon Claes ` (2 preceding siblings ...) 2026-06-08 18:37 ` [PATCH 3/3] replay: offer an option to linearize the commit topology Toon Claes @ 2026-06-10 14:49 ` Toon Claes 2026-06-10 14:49 ` [PATCH v2 1/3] replay: refactor enum replay_mode into a bool Toon Claes ` (3 more replies) 3 siblings, 4 replies; 34+ messages in thread From: Toon Claes @ 2026-06-10 14:49 UTC (permalink / raw) To: git; +Cc: Toon Claes, Johannes Schindelin, Johannes Schindelin As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics wath git-rebase(1) does too with --no-rebase-merges (the default). The first two patches do some refactoring. The third patch implements the actual change. I was kindly helped by dscho to implement this change. The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion doesn't make much sense to do so, but I'm happy to hear if anyone disagrees. This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3]. dscho's series to replay merges[1] need a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize". [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im> Signed-off-by: Toon Claes <toon@iotcl.com> --- Changes in v2: - Restructured the conditions to detect merge commits and added a line of comment why the loop continues. - Rewrote tests to use the history from the setup step and added a few test cases. - Re-added Johannes's Signed-off-by trailer. Johannes gave me the patches with this trailer, and if I understand correctly, I can keep it. Please let me know if that wrong. - Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com --- Johannes Schindelin (1): replay: offer an option to linearize the commit topology Toon Claes (2): replay: refactor enum replay_mode into a bool replay: add helper to put entry into mapped_commits Documentation/git-replay.adoc | 5 ++ builtin/replay.c | 4 ++ replay.c | 114 ++++++++++++++++++++++++------------------ replay.h | 5 ++ t/t3650-replay-basics.sh | 26 ++++++++++ 5 files changed, 105 insertions(+), 49 deletions(-) Range-diff versus v1: 1: 7f3bc6f425 ! 1: 0975b142e3 replay: refactor enum replay_mode into a bool @@ Commit message - The value `REPLAY_MODE_REVERT` is used when option `--revert` is passed to git-replay(1). When using this value the commits are - possible in reverse order and the inverse of the changes are applied. + processed in reverse order and the inverse of the changes are + applied. - The value `REPLAY_MODE_PICK` is used when either option `--onto` or - `--advance` is used. In both cases the commits are pocessed in normal - order, and the changes are applied as-is. + `--advance` is used. In both cases the commits are processed in + normal order, and the changes are applied as-is. Since there are only two possible values of this enum, simplify the code - by converting the enum into a bool. This avoid adding code paths that - check for invalid vaues of the enum, and shortens code where the value + by converting the enum into a bool. This avoids adding code paths that + check for invalid values of the enum, and shortens code where the value is checked with a ternary operator. Signed-off-by: Toon Claes <toon@iotcl.com> 2: 0868871c78 ! 2: db88193624 replay: add helper to put entry into mapped_commits @@ Commit message replay: add helper to put entry into mapped_commits The function replay_revisions() in replay.c is rather lengthy. Extract - the logic to put commit entry into mapped_commits into a helper - function. + the logic to put a commit entry into mapped_commits into a helper + function put_mapped_commit(). + + While at it, rename mapped_commit() to get_mapped_commit() to pair with + this new function. Signed-off-by: Toon Claes <toon@iotcl.com> 3: a432ae753b ! 3: d0c220ec8e replay: offer an option to linearize the commit topology @@ Commit message The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and - linearized the commit history into a sequence of the + linearizes the commit history into a sequence of the regular (single-parent) commits. - Add option `--linearize` to git-replay(1) do the same. + Add option `--linearize` to git-replay(1) to do the same. Co-authored-by: Toon Claes <toon@iotcl.com> + Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> + Signed-off-by: Toon Claes <toon@iotcl.com> ## Documentation/git-replay.adoc ## @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only). @@ replay.c: int replay_revisions(struct rev_info *revs, const struct name_decoration *decoration; - if (commit->parents && commit->parents->next) -+ if (opts->linearize && (!commit->parents || commit->parents->next)) -+ ; /* map current commit to the same as the previous commit */ -+ else if (commit->parents && commit->parents->next) - die(_("replaying merge commits is not supported yet!")); -+ else { +- die(_("replaying merge commits is not supported yet!")); ++ if (commit->parents && commit->parents->next) { ++ if (!opts->linearize) ++ die(_("replaying merge commits is not supported yet!")); ++ /* ++ * When linearizing, a merge commit itself is not picked, ++ * but refs that point to it might need updating. ++ */ ++ } else { + struct commit *to_pick = reverse ? last_commit : onto; + last_commit = + pick_regular_commit(revs->repo, commit, @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl test_grep "cannot be used with multiple revision ranges" err ' -+test_expect_success 'linearize the commit topology' ' -+ test_tick && -+ N=$(git commit-tree -m N -p L -p I L:) && -+ N=$(git commit-tree -m N-child -p $N L:) && -+ git update-ref refs/heads/N $N && ++test_expect_success 'replay merge commit fails' ' ++ echo "fatal: replaying merge commits is not supported yet!" >expect && ++ test_must_fail git replay --ref-action=print --onto main I..P 2>actual && ++ test_cmp expect actual ++' ++ ++test_expect_success 'replay to rebase merge commit with --linearize' ' ++ git replay --ref-action=print --linearize --onto main I..topic-with-merge >result && ++ ++ test_line_count = 1 result && ++ ++ git log --format=%s $(cut -f 3 -d " " result) >actual && ++ test_write_lines O N J M L B A >expect && ++ test_cmp expect actual ++' + -+ git replay --ref-action=print --linearize \ -+ --onto A B..refs/heads/N >out && ++test_expect_success 'replay to rebase merge commit with --linearize down to root commit' ' ++ git replay --ref-action=print --linearize --onto main A..topic-with-merge >result && + -+ test_line_count = 1 out && -+ read N1 N2 N3 N4 <out && ++ test_line_count = 1 result && + -+ cat >expect <<-EOF && -+ * N-child -+ * I -+ * L -+ o A -+ EOF -+ git log --format=%s --graph --boundary A...$N3 >actual && ++ git log --format=%s $(cut -f 3 -d " " result) >actual && ++ test_write_lines O N J I M L B A >expect && + test_cmp expect actual +' + --- base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395 ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v2 1/3] replay: refactor enum replay_mode into a bool 2026-06-10 14:49 ` [PATCH v2 0/3] Teach git-replay(1) to linearize merge commits Toon Claes @ 2026-06-10 14:49 ` Toon Claes 2026-06-11 15:09 ` Justin Tobler 2026-06-10 14:49 ` [PATCH v2 2/3] replay: add helper to put entry into mapped_commits Toon Claes ` (2 subsequent siblings) 3 siblings, 1 reply; 34+ messages in thread From: Toon Claes @ 2026-06-10 14:49 UTC (permalink / raw) To: git; +Cc: Toon Claes In 2760ee4983 (replay: add --revert mode to reverse commit changes, 2026-03-26) the enum `replay_mode` was introduced. This has two possible values: - The value `REPLAY_MODE_REVERT` is used when option `--revert` is passed to git-replay(1). When using this value the commits are processed in reverse order and the inverse of the changes are applied. - The value `REPLAY_MODE_PICK` is used when either option `--onto` or `--advance` is used. In both cases the commits are processed in normal order, and the changes are applied as-is. Since there are only two possible values of this enum, simplify the code by converting the enum into a bool. This avoids adding code paths that check for invalid values of the enum, and shortens code where the value is checked with a ternary operator. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 59 +++++++++++++++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/replay.c b/replay.c index 4ef8abb607..1f8e5b083b 100644 --- a/replay.c +++ b/replay.c @@ -18,11 +18,6 @@ */ #define the_repository DO_NOT_USE_THE_REPOSITORY -enum replay_mode { - REPLAY_MODE_PICK, - REPLAY_MODE_REVERT, -}; - static const char *short_commit_name(struct repository *repo, struct commit *commit) { @@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo, struct tree *tree, struct commit *based_on, struct commit *parent, - enum replay_mode mode) + bool reverse) { struct object_id ret; struct object *obj = NULL; @@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo, commit_list_insert(parent, &parents); extra = read_commit_extra_headers(based_on, exclude_gpgsig); - if (mode == REPLAY_MODE_REVERT) { + if (reverse) { generate_revert_message(&msg, based_on, repo); /* For revert, use current user as author (NULL = use default) */ - } else if (mode == REPLAY_MODE_PICK) { + } else { find_commit_subject(message, &orig_message); strbuf_addstr(&msg, orig_message); author = get_author(message); - } else { - BUG("unexpected replay mode %d", mode); } reset_ident_date(); if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents, @@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, - enum replay_mode mode, + bool reverse, enum replay_empty_commit_action empty) { struct commit *base, *replayed_base; @@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo, replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); - if (mode == REPLAY_MODE_PICK) { + if (reverse) { + /* Revert: swap base and pickme to reverse the diff */ + const char *pickme_name = short_commit_name(repo, pickme); + merge_opt->branch1 = short_commit_name(repo, replayed_base); + merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); + merge_opt->ancestor = pickme_name; + + merge_incore_nonrecursive(merge_opt, + pickme_tree, + replayed_base_tree, + base_tree, + result); + + free((char *)merge_opt->branch2); + } else { /* Cherry-pick: normal order */ merge_opt->branch1 = short_commit_name(repo, replayed_base); merge_opt->branch2 = short_commit_name(repo, pickme); @@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo, result); free((char *)merge_opt->ancestor); - } else if (mode == REPLAY_MODE_REVERT) { - /* Revert: swap base and pickme to reverse the diff */ - const char *pickme_name = short_commit_name(repo, pickme); - merge_opt->branch1 = short_commit_name(repo, replayed_base); - merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); - merge_opt->ancestor = pickme_name; - - merge_incore_nonrecursive(merge_opt, - pickme_tree, - replayed_base_tree, - base_tree, - result); - - free((char *)merge_opt->branch2); - } else { - BUG("unexpected replay mode %d", mode); } merge_opt->ancestor = NULL; merge_opt->branch2 = NULL; @@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo, } } - return create_commit(repo, result->tree, pickme, replayed_base, mode); + return create_commit(repo, result->tree, pickme, replayed_base, reverse); } void replay_result_release(struct replay_result *result) @@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs, char *revert; const char *ref; struct object_id old_oid; - enum replay_mode mode = REPLAY_MODE_PICK; + bool reverse; int ret; advance = xstrdup_or_null(opts->advance); revert = xstrdup_or_null(opts->revert); - if (revert) - mode = REPLAY_MODE_REVERT; + reverse = !!revert; + set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, &detached_head, &advance, &revert, &onto, &update_refs); @@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs, die(_("replaying merge commits is not supported yet!")); last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - mode == REPLAY_MODE_REVERT ? last_commit : onto, - &merge_opt, &result, mode, opts->empty); + reverse ? last_commit : onto, + &merge_opt, &result, reverse, opts->empty); if (!last_commit) break; -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* Re: [PATCH v2 1/3] replay: refactor enum replay_mode into a bool 2026-06-10 14:49 ` [PATCH v2 1/3] replay: refactor enum replay_mode into a bool Toon Claes @ 2026-06-11 15:09 ` Justin Tobler 2026-06-12 8:19 ` Toon Claes 0 siblings, 1 reply; 34+ messages in thread From: Justin Tobler @ 2026-06-11 15:09 UTC (permalink / raw) To: Toon Claes; +Cc: git On 26/06/10 04:49PM, Toon Claes wrote: > In 2760ee4983 (replay: add --revert mode to reverse commit changes, > 2026-03-26) the enum `replay_mode` was introduced. This has two possible > values: > > - The value `REPLAY_MODE_REVERT` is used when option `--revert` is > passed to git-replay(1). When using this value the commits are > processed in reverse order and the inverse of the changes are > applied. > > - The value `REPLAY_MODE_PICK` is used when either option `--onto` or > `--advance` is used. In both cases the commits are processed in > normal order, and the changes are applied as-is. > > Since there are only two possible values of this enum, simplify the code > by converting the enum into a bool. This avoids adding code paths that > check for invalid values of the enum, and shortens code where the value > is checked with a ternary operator. Naive question: Do we expect there to only be two replay modes in the forseeable future? I suppose if other modes were added in the future this change would essentially be reverted. > Signed-off-by: Toon Claes <toon@iotcl.com> > --- > replay.c | 59 +++++++++++++++++++++++++---------------------------------- > 1 file changed, 25 insertions(+), 34 deletions(-) > > diff --git a/replay.c b/replay.c > index 4ef8abb607..1f8e5b083b 100644 > --- a/replay.c > +++ b/replay.c > @@ -18,11 +18,6 @@ > */ > #define the_repository DO_NOT_USE_THE_REPOSITORY > > -enum replay_mode { > - REPLAY_MODE_PICK, > - REPLAY_MODE_REVERT, > -}; > - > static const char *short_commit_name(struct repository *repo, > struct commit *commit) > { > @@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo, > struct tree *tree, > struct commit *based_on, > struct commit *parent, > - enum replay_mode mode) > + bool reverse) > { > struct object_id ret; > struct object *obj = NULL; > @@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo, > > commit_list_insert(parent, &parents); > extra = read_commit_extra_headers(based_on, exclude_gpgsig); > - if (mode == REPLAY_MODE_REVERT) { > + if (reverse) { > generate_revert_message(&msg, based_on, repo); > /* For revert, use current user as author (NULL = use default) */ > - } else if (mode == REPLAY_MODE_PICK) { > + } else { > find_commit_subject(message, &orig_message); > strbuf_addstr(&msg, orig_message); > author = get_author(message); > - } else { > - BUG("unexpected replay mode %d", mode); > } > reset_ident_date(); > if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents, > @@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo, > struct commit *onto, > struct merge_options *merge_opt, > struct merge_result *result, > - enum replay_mode mode, > + bool reverse, > enum replay_empty_commit_action empty) > { > struct commit *base, *replayed_base; > @@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo, > replayed_base_tree = repo_get_commit_tree(repo, replayed_base); > pickme_tree = repo_get_commit_tree(repo, pickme); > > - if (mode == REPLAY_MODE_PICK) { > + if (reverse) { > + /* Revert: swap base and pickme to reverse the diff */ > + const char *pickme_name = short_commit_name(repo, pickme); > + merge_opt->branch1 = short_commit_name(repo, replayed_base); > + merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); > + merge_opt->ancestor = pickme_name; > + > + merge_incore_nonrecursive(merge_opt, > + pickme_tree, > + replayed_base_tree, > + base_tree, > + result); > + > + free((char *)merge_opt->branch2); > + } else { > /* Cherry-pick: normal order */ > merge_opt->branch1 = short_commit_name(repo, replayed_base); > merge_opt->branch2 = short_commit_name(repo, pickme); > @@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo, > result); > > free((char *)merge_opt->ancestor); > - } else if (mode == REPLAY_MODE_REVERT) { > - /* Revert: swap base and pickme to reverse the diff */ > - const char *pickme_name = short_commit_name(repo, pickme); > - merge_opt->branch1 = short_commit_name(repo, replayed_base); > - merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); > - merge_opt->ancestor = pickme_name; > - > - merge_incore_nonrecursive(merge_opt, > - pickme_tree, > - replayed_base_tree, > - base_tree, > - result); > - > - free((char *)merge_opt->branch2); > - } else { > - BUG("unexpected replay mode %d", mode); > } > merge_opt->ancestor = NULL; > merge_opt->branch2 = NULL; > @@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo, > } > } > > - return create_commit(repo, result->tree, pickme, replayed_base, mode); > + return create_commit(repo, result->tree, pickme, replayed_base, reverse); > } > > void replay_result_release(struct replay_result *result) > @@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs, > char *revert; > const char *ref; > struct object_id old_oid; > - enum replay_mode mode = REPLAY_MODE_PICK; > + bool reverse; > int ret; > > advance = xstrdup_or_null(opts->advance); > revert = xstrdup_or_null(opts->revert); > - if (revert) > - mode = REPLAY_MODE_REVERT; > + reverse = !!revert; > + > set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, > &detached_head, &advance, &revert, &onto, &update_refs); > > @@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs, > die(_("replaying merge commits is not supported yet!")); > > last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, > - mode == REPLAY_MODE_REVERT ? last_commit : onto, > - &merge_opt, &result, mode, opts->empty); > + reverse ? last_commit : onto, > + &merge_opt, &result, reverse, opts->empty); > if (!last_commit) > break; The patch itself looks trivially correct. -Justin ^ permalink raw reply [flat|nested] 34+ messages in thread
* Re: [PATCH v2 1/3] replay: refactor enum replay_mode into a bool 2026-06-11 15:09 ` Justin Tobler @ 2026-06-12 8:19 ` Toon Claes 0 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-12 8:19 UTC (permalink / raw) To: Justin Tobler; +Cc: git Justin Tobler <jltobler@gmail.com> writes: > Naive question: Do we expect there to only be two replay modes in the > forseeable future? I suppose if other modes were added in the future > this change would essentially be reverted. The enum was mainly used to determine "direction": PICK to apply commits forward, and REVERT to apply them in opposite order. But it's a bit twofold, because REVERT also applies the inverse change. At the moment --onto and --advance use PICK and --revert uses REVERT. There could be added more options in the future, but I don't expect any of them to add a new mode. And if there is ever a new mode needed, I think it's better to re-add the enum then, or maybe a second bool makes sense then, who knows... -- Cheers, Toon ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v2 2/3] replay: add helper to put entry into mapped_commits 2026-06-10 14:49 ` [PATCH v2 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-10 14:49 ` [PATCH v2 1/3] replay: refactor enum replay_mode into a bool Toon Claes @ 2026-06-10 14:49 ` Toon Claes 2026-06-10 14:49 ` [PATCH v2 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-16 9:26 ` [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 3 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-10 14:49 UTC (permalink / raw) To: git; +Cc: Toon Claes The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit(). While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/replay.c b/replay.c index 1f8e5b083b..7921d7dba3 100644 --- a/replay.c +++ b/replay.c @@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo, strset_clear(&rinfo.positive_refs); } -static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, - struct commit *commit, - struct commit *fallback) +static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *fallback) { khint_t pos; if (!commit) @@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, return kh_value(replayed_commits, pos); } +static void put_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *new_commit) +{ + khint_t pos; + int ret; + + pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret); + if (ret == 0) + BUG("Duplicate rewritten commit: %s\n", + oid_to_hex(&commit->object.oid)); + + kh_value(replayed_commits, pos) = new_commit; +} + static struct commit *pick_regular_commit(struct repository *repo, struct commit *pickme, kh_oid_map_t *replayed_commits, @@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = mapped_commit(replayed_commits, base, onto); + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs, replayed_commits = kh_init_oid_map(); while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - khint_t pos; - int hr; if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); @@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs, break; /* Record commit -> last_commit mapping */ - pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr); - if (hr == 0) - BUG("Duplicate rewritten commit: %s\n", - oid_to_hex(&commit->object.oid)); - kh_value(replayed_commits, pos) = last_commit; + put_mapped_commit(replayed_commits, commit, last_commit); /* Update any necessary branches */ if (ref) -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* [PATCH v2 3/3] replay: offer an option to linearize the commit topology 2026-06-10 14:49 ` [PATCH v2 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-10 14:49 ` [PATCH v2 1/3] replay: refactor enum replay_mode into a bool Toon Claes 2026-06-10 14:49 ` [PATCH v2 2/3] replay: add helper to put entry into mapped_commits Toon Claes @ 2026-06-10 14:49 ` Toon Claes 2026-06-10 17:02 ` Junio C Hamano 2026-06-14 6:56 ` Elijah Newren 2026-06-16 9:26 ` [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 3 siblings, 2 replies; 34+ messages in thread From: Toon Claes @ 2026-06-10 14:49 UTC (permalink / raw) To: git; +Cc: Toon Claes, Johannes Schindelin, Johannes Schindelin From: Johannes Schindelin <Johannes.Schindelin@gmx.de> One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side. The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the commit history into a sequence of the regular (single-parent) commits. Add option `--linearize` to git-replay(1) to do the same. Co-authored-by: Toon Claes <toon@iotcl.com> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> Signed-off-by: Toon Claes <toon@iotcl.com> --- Documentation/git-replay.adoc | 5 +++++ builtin/replay.c | 4 ++++ replay.c | 30 +++++++++++++++++++++++------- replay.h | 5 +++++ t/t3650-replay-basics.sh | 26 ++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index a32f72aead..41c96c7061 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only). + The default mode can be configured via the `replay.refAction` configuration variable. +--linearize:: + In this mode, `git replay` imitates `git rebase --no-rebase-merges`, + i.e. it cherry-picks only non-merge commits, each one on top of the + previous one. + <revision-range>:: Range of commits to replay; see "Specifying Ranges" in linkgit:git-rev-parse[1]. In `--advance=<branch>` or diff --git a/builtin/replay.c b/builtin/replay.c index 39e3a86f6c..fedfe46dc6 100644 --- a/builtin/replay.c +++ b/builtin/replay.c @@ -111,6 +111,8 @@ int cmd_replay(int argc, N_("mode"), N_("control ref update behavior (update|print)"), PARSE_OPT_NONEG), + OPT_BOOL(0, "linearize", &opts.linearize, + N_("ignore merge commits instead of replaying them")), OPT_END() }; @@ -132,6 +134,8 @@ int cmd_replay(int argc, opts.contained, "--contained"); die_for_incompatible_opt2(!!opts.ref, "--ref", !!opts.contained, "--contained"); + die_for_incompatible_opt2(!!opts.revert, "--revert", + opts.linearize, "--linearize"); /* Parse ref action mode from command line or config */ ref_mode = get_ref_action_mode(repo, ref_action); diff --git a/replay.c b/replay.c index 7921d7dba3..81033fb889 100644 --- a/replay.c +++ b/replay.c @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, + struct commit *replayed_base, bool reverse, enum replay_empty_commit_action empty) { - struct commit *base, *replayed_base; + struct commit *base; struct tree *pickme_tree, *base_tree, *replayed_base_tree; + if (replayed_base && reverse) + BUG("Linearizing commits is not supported when replaying in reverse"); + if (pickme->parents) { base = pickme->parents->item; base_tree = repo_get_commit_tree(repo, base); @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = get_mapped_commit(replayed_commits, base, onto); + if (!replayed_base) + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - if (commit->parents && commit->parents->next) - die(_("replaying merge commits is not supported yet!")); + if (commit->parents && commit->parents->next) { + if (!opts->linearize) + die(_("replaying merge commits is not supported yet!")); + /* + * When linearizing, a merge commit itself is not picked, + * but refs that point to it might need updating. + */ + } else { + struct commit *to_pick = reverse ? last_commit : onto; + last_commit = + pick_regular_commit(revs->repo, commit, + replayed_commits, to_pick, + &merge_opt, &result, + opts->linearize ? last_commit : NULL, + reverse, opts->empty); + } - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - reverse ? last_commit : onto, - &merge_opt, &result, reverse, opts->empty); if (!last_commit) break; diff --git a/replay.h b/replay.h index 1851a07705..07e6fdcca3 100644 --- a/replay.h +++ b/replay.h @@ -62,6 +62,11 @@ struct replay_revisions_options { * Defaults to REPLAY_EMPTY_COMMIT_DROP. */ enum replay_empty_commit_action empty; + + /* + * Whether to linearize the commits (i.e. drop merge commits). + */ + int linearize; }; /* This struct is used as an out-parameter by `replay_revisions()`. */ diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh index 3353bc4a4d..64e0731188 100755 --- a/t/t3650-replay-basics.sh +++ b/t/t3650-replay-basics.sh @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' test_grep "cannot be used with multiple revision ranges" err ' +test_expect_success 'replay merge commit fails' ' + echo "fatal: replaying merge commits is not supported yet!" >expect && + test_must_fail git replay --ref-action=print --onto main I..P 2>actual && + test_cmp expect actual +' + +test_expect_success 'replay to rebase merge commit with --linearize' ' + git replay --ref-action=print --linearize --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual +' + +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' ' + git replay --ref-action=print --linearize --onto main A..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J I M L B A >expect && + test_cmp expect actual +' + test_done -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology 2026-06-10 14:49 ` [PATCH v2 3/3] replay: offer an option to linearize the commit topology Toon Claes @ 2026-06-10 17:02 ` Junio C Hamano 2026-06-16 8:38 ` Toon Claes 2026-06-14 6:56 ` Elijah Newren 1 sibling, 1 reply; 34+ messages in thread From: Junio C Hamano @ 2026-06-10 17:02 UTC (permalink / raw) To: Toon Claes; +Cc: git, Johannes Schindelin Toon Claes <toon@iotcl.com> writes: > From: Johannes Schindelin <Johannes.Schindelin@gmx.de> > > One of the stated goals of git-replay(1) is to allow implementing the > git-rebase(1) functionality on the server side. > > The default mode of git-rebase(1) is to act as if `--no-rebase-merges` > was given. This mode drops merge commits instead of replaying them, and > linearizes the commit history into a sequence of the > regular (single-parent) commits. > > Add option `--linearize` to git-replay(1) to do the same. > > Co-authored-by: Toon Claes <toon@iotcl.com> > Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> > Signed-off-by: Toon Claes <toon@iotcl.com> > --- > Documentation/git-replay.adoc | 5 +++++ > builtin/replay.c | 4 ++++ > replay.c | 30 +++++++++++++++++++++++------- > replay.h | 5 +++++ > t/t3650-replay-basics.sh | 26 ++++++++++++++++++++++++++ > 5 files changed, 63 insertions(+), 7 deletions(-) > > @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs, > while ((commit = get_revision(revs))) { > const struct name_decoration *decoration; > > - if (commit->parents && commit->parents->next) > - die(_("replaying merge commits is not supported yet!")); > + if (commit->parents && commit->parents->next) { > + if (!opts->linearize) > + die(_("replaying merge commits is not supported yet!")); > + /* > + * When linearizing, a merge commit itself is not picked, > + * but refs that point to it might need updating. > + */ In the review response during the previous iteration, I commented that (1) the original excluded only merges, but (2) your version excluded both merges and the root commits the same way. Your response was: The way it was written in v1 was maybe a bit too smart and hard to follow. I agree with your suggestion and will adopt this (with some tweaks) in the next version. which I took as saying "it may be confusing, but it correctly expresses what we want to do", meaning "yes, roots and merges should be handled the same way". But the above no longer treats roots the same way as merges. I think that is intended, but just wanted to double check. > diff --git a/replay.h b/replay.h > index 1851a07705..07e6fdcca3 100644 > --- a/replay.h > +++ b/replay.h > @@ -62,6 +62,11 @@ struct replay_revisions_options { > * Defaults to REPLAY_EMPTY_COMMIT_DROP. > */ > enum replay_empty_commit_action empty; > + > + /* > + * Whether to linearize the commits (i.e. drop merge commits). > + */ > + int linearize; > }; OK. > diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh > index 3353bc4a4d..64e0731188 100755 > --- a/t/t3650-replay-basics.sh > +++ b/t/t3650-replay-basics.sh > @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' > test_grep "cannot be used with multiple revision ranges" err > ' > > +test_expect_success 'replay merge commit fails' ' > + echo "fatal: replaying merge commits is not supported yet!" >expect && > + test_must_fail git replay --ref-action=print --onto main I..P 2>actual && > + test_cmp expect actual > +' > + > +test_expect_success 'replay to rebase merge commit with --linearize' ' > + git replay --ref-action=print --linearize --onto main I..topic-with-merge >result && > + > + test_line_count = 1 result && > + > + git log --format=%s $(cut -f 3 -d " " result) >actual && > + test_write_lines O N J M L B A >expect && > + test_cmp expect actual > +' > + > +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' ' > + git replay --ref-action=print --linearize --onto main A..topic-with-merge >result && As with other test pieces, this "git replay" command line is overly long and hides the important bit which is that the range being replayed is *not* actually down to the root, which is A (it excludes A). Intended? > + > + test_line_count = 1 result && > + > + git log --format=%s $(cut -f 3 -d " " result) >actual && > + test_write_lines O N J I M L B A >expect && > + test_cmp expect actual > +' > + > test_done Thanks. ^ permalink raw reply [flat|nested] 34+ messages in thread
* Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology 2026-06-10 17:02 ` Junio C Hamano @ 2026-06-16 8:38 ` Toon Claes 0 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-16 8:38 UTC (permalink / raw) To: Junio C Hamano; +Cc: git, Johannes Schindelin Junio C Hamano <gitster@pobox.com> writes: > In the review response during the previous iteration, I commented > that (1) the original excluded only merges, but (2) your version > excluded both merges and the root commits the same way. Your > response was: > > The way it was written in v1 was maybe a bit too smart and hard to > follow. I agree with your suggestion and will adopt this (with some > tweaks) in the next version. > > which I took as saying "it may be confusing, but it correctly > expresses what we want to do", meaning "yes, roots and merges should > be handled the same way". But the above no longer treats roots the > same way as merges. I think that is intended, but just wanted to > double check. Great callout. I was running the "replay down to root" test with v1 vs v2, but as you pointed out, the test I wrote doesn't actually replay down to root. Now I've fixed the test and reran the test against both versions and verified what you're saying. So to answer your question, yes this change is intentional and shout out to you for requesting to add this test (properly) so we actually catch this. >> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' ' >> + git replay --ref-action=print --linearize --onto main A..topic-with-merge >result && > > As with other test pieces, this "git replay" command line is overly > long and hides the important bit which is that the range being > replayed is *not* actually down to the root, which is A (it excludes > A). Intended? No, not intended. And while at it, I'll split up the command on two lines. -- Cheers, Toon ^ permalink raw reply [flat|nested] 34+ messages in thread
* Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology 2026-06-10 14:49 ` [PATCH v2 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-10 17:02 ` Junio C Hamano @ 2026-06-14 6:56 ` Elijah Newren 2026-06-16 7:09 ` Toon Claes 1 sibling, 1 reply; 34+ messages in thread From: Elijah Newren @ 2026-06-14 6:56 UTC (permalink / raw) To: Toon Claes; +Cc: git, Johannes Schindelin Hi, On Wed, Jun 10, 2026 at 7:51 AM Toon Claes <toon@iotcl.com> wrote: > > From: Johannes Schindelin <Johannes.Schindelin@gmx.de> > > One of the stated goals of git-replay(1) is to allow implementing the > git-rebase(1) functionality on the server side. > > The default mode of git-rebase(1) is to act as if `--no-rebase-merges` > was given. This mode drops merge commits instead of replaying them, and > linearizes the commit history into a sequence of the > regular (single-parent) commits. > > Add option `--linearize` to git-replay(1) to do the same. I think this version is nicer overall than the one from my replay-upstream branch; sorry for repeatedly getting distracted from that, but this does look nice. A few small comments: > Co-authored-by: Toon Claes <toon@iotcl.com> > Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> > Signed-off-by: Toon Claes <toon@iotcl.com> > --- > Documentation/git-replay.adoc | 5 +++++ > builtin/replay.c | 4 ++++ > replay.c | 30 +++++++++++++++++++++++------- > replay.h | 5 +++++ > t/t3650-replay-basics.sh | 26 ++++++++++++++++++++++++++ > 5 files changed, 63 insertions(+), 7 deletions(-) > > diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc > index a32f72aead..41c96c7061 100644 > --- a/Documentation/git-replay.adoc > +++ b/Documentation/git-replay.adoc > @@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only). > + > The default mode can be configured via the `replay.refAction` configuration variable. > > +--linearize:: > + In this mode, `git replay` imitates `git rebase --no-rebase-merges`, > + i.e. it cherry-picks only non-merge commits, each one on top of the > + previous one. The SYNOPSIS block at the top of the file is missing this new flag. The replay_usage[] variable in cmd_replay is also missing this new flag. > <revision-range>:: > Range of commits to replay; see "Specifying Ranges" in > linkgit:git-rev-parse[1]. In `--advance=<branch>` or > diff --git a/builtin/replay.c b/builtin/replay.c > index 39e3a86f6c..fedfe46dc6 100644 > --- a/builtin/replay.c > +++ b/builtin/replay.c > @@ -111,6 +111,8 @@ int cmd_replay(int argc, > N_("mode"), > N_("control ref update behavior (update|print)"), > PARSE_OPT_NONEG), > + OPT_BOOL(0, "linearize", &opts.linearize, > + N_("ignore merge commits instead of replaying them")), "ignore" feels a bit ambiguous to me. Can we use "drop" instead, matching your commit message? > OPT_END() > }; > > @@ -132,6 +134,8 @@ int cmd_replay(int argc, > opts.contained, "--contained"); > die_for_incompatible_opt2(!!opts.ref, "--ref", > !!opts.contained, "--contained"); > + die_for_incompatible_opt2(!!opts.revert, "--revert", > + opts.linearize, "--linearize"); Sensible; should the docs mention this incompatibility? (I'm not sure myself; just throwing it out as food for thought.) > > /* Parse ref action mode from command line or config */ > ref_mode = get_ref_action_mode(repo, ref_action); > diff --git a/replay.c b/replay.c > index 7921d7dba3..81033fb889 100644 > --- a/replay.c > +++ b/replay.c > @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo, > struct commit *onto, > struct merge_options *merge_opt, > struct merge_result *result, > + struct commit *replayed_base, > bool reverse, > enum replay_empty_commit_action empty) > { > - struct commit *base, *replayed_base; > + struct commit *base; > struct tree *pickme_tree, *base_tree, *replayed_base_tree; > > + if (replayed_base && reverse) > + BUG("Linearizing commits is not supported when replaying in reverse"); > + This is dead code given the die_for_incompatible_opt2 check above, right? Just extra defense in depth? > if (pickme->parents) { > base = pickme->parents->item; > base_tree = repo_get_commit_tree(repo, base); > @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo, > base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); > } > > - replayed_base = get_mapped_commit(replayed_commits, base, onto); > + if (!replayed_base) > + replayed_base = get_mapped_commit(replayed_commits, base, onto); > replayed_base_tree = repo_get_commit_tree(repo, replayed_base); > pickme_tree = repo_get_commit_tree(repo, pickme); > > @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs, > while ((commit = get_revision(revs))) { > const struct name_decoration *decoration; > > - if (commit->parents && commit->parents->next) > - die(_("replaying merge commits is not supported yet!")); > + if (commit->parents && commit->parents->next) { > + if (!opts->linearize) > + die(_("replaying merge commits is not supported yet!")); > + /* > + * When linearizing, a merge commit itself is not picked, > + * but refs that point to it might need updating. > + */ Is it worth pointing out that last_commit is intentionally not updated by this code path? That is implied by your comment, but it takes a bit of reasoning to get there, and I think it might help future readers to just explicitly state it. > + } else { > + struct commit *to_pick = reverse ? last_commit : onto; > + last_commit = > + pick_regular_commit(revs->repo, commit, > + replayed_commits, to_pick, > + &merge_opt, &result, > + opts->linearize ? last_commit : NULL, > + reverse, opts->empty); > + } > > - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, > - reverse ? last_commit : onto, > - &merge_opt, &result, reverse, opts->empty); > if (!last_commit) > break; > > diff --git a/replay.h b/replay.h > index 1851a07705..07e6fdcca3 100644 > --- a/replay.h > +++ b/replay.h > @@ -62,6 +62,11 @@ struct replay_revisions_options { > * Defaults to REPLAY_EMPTY_COMMIT_DROP. > */ > enum replay_empty_commit_action empty; > + > + /* > + * Whether to linearize the commits (i.e. drop merge commits). > + */ > + int linearize; > }; > > /* This struct is used as an out-parameter by `replay_revisions()`. */ > diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh > index 3353bc4a4d..64e0731188 100755 > --- a/t/t3650-replay-basics.sh > +++ b/t/t3650-replay-basics.sh > @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' > test_grep "cannot be used with multiple revision ranges" err > ' > > +test_expect_success 'replay merge commit fails' ' > + echo "fatal: replaying merge commits is not supported yet!" >expect && > + test_must_fail git replay --ref-action=print --onto main I..P 2>actual && > + test_cmp expect actual > +' > + > +test_expect_success 'replay to rebase merge commit with --linearize' ' > + git replay --ref-action=print --linearize --onto main I..topic-with-merge >result && > + > + test_line_count = 1 result && > + > + git log --format=%s $(cut -f 3 -d " " result) >actual && > + test_write_lines O N J M L B A >expect && > + test_cmp expect actual > +' > + > +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' ' > + git replay --ref-action=print --linearize --onto main A..topic-with-merge >result && You'd need to drop "A.." to have it go down to the root commit, as Junio mentioned elsewhere. > + > + test_line_count = 1 result && > + > + git log --format=%s $(cut -f 3 -d " " result) >actual && > + test_write_lines O N J I M L B A >expect && > + test_cmp expect actual > +' > + > test_done Should there also be a testcase combining --linearize and --advance? Should there be a test with the incompatibility of --revert & --linearize? I think we have a few other tests for incompatible options. One additional testing idea, borrowed from an older variant of this patch I had sitting in a local branch (dscho's original linearize patch, adapted): in addition to checking specific commit subjects, it's worth verifying that the linearized chain produces the *same patches* as the original. Something along the lines of: test_expect_success '--linearize preserves patches' ' test_when_finished "git update-ref -d refs/heads/merge_I_L" && test_tick && git checkout -b merge_I_L I && git merge --no-edit L && git replay --linearize --onto A B..merge_I_L && # range-diff ignores merges, so the original # {I, L, merge} reduces to {I, L} on the LHS, # and the replayed chain on the RHS should match. git range-diff B..merge_I_L@{1} B..merge_I_L >out && ! test_grep -v "=" out && git log --oneline A..merge_I_L >out && test_line_count = 2 out ' The range-diff check is nice because it asserts patch equivalence rather than tying the test to a particular replay ordering, which makes the test less brittle if the rev-walk order ever changes. Feel free to take, adapt, or ignore. Anyway, thanks for working on this; looking good. Elijah ^ permalink raw reply [flat|nested] 34+ messages in thread
* Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology 2026-06-14 6:56 ` Elijah Newren @ 2026-06-16 7:09 ` Toon Claes 0 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-16 7:09 UTC (permalink / raw) To: Elijah Newren; +Cc: git, Johannes Schindelin Elijah Newren <newren@gmail.com> writes: > Hi, > > On Wed, Jun 10, 2026 at 7:51 AM Toon Claes <toon@iotcl.com> wrote: >> >> From: Johannes Schindelin <Johannes.Schindelin@gmx.de> >> >> One of the stated goals of git-replay(1) is to allow implementing the >> git-rebase(1) functionality on the server side. >> >> The default mode of git-rebase(1) is to act as if `--no-rebase-merges` >> was given. This mode drops merge commits instead of replaying them, and >> linearizes the commit history into a sequence of the >> regular (single-parent) commits. >> >> Add option `--linearize` to git-replay(1) to do the same. > > I think this version is nicer overall than the one from my > replay-upstream branch; sorry for repeatedly getting distracted from > that, but this does look nice. Dscho gets most of the credit here. And don't worry about being distracted, we know how things go around here. I appreciate the review! > > A few small comments: > >> Co-authored-by: Toon Claes <toon@iotcl.com> >> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> >> Signed-off-by: Toon Claes <toon@iotcl.com> >> --- >> Documentation/git-replay.adoc | 5 +++++ >> builtin/replay.c | 4 ++++ >> replay.c | 30 +++++++++++++++++++++++------- >> replay.h | 5 +++++ >> t/t3650-replay-basics.sh | 26 ++++++++++++++++++++++++++ >> 5 files changed, 63 insertions(+), 7 deletions(-) >> >> diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc >> index a32f72aead..41c96c7061 100644 >> --- a/Documentation/git-replay.adoc >> +++ b/Documentation/git-replay.adoc >> @@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only). >> + >> The default mode can be configured via the `replay.refAction` configuration variable. >> >> +--linearize:: >> + In this mode, `git replay` imitates `git rebase --no-rebase-merges`, >> + i.e. it cherry-picks only non-merge commits, each one on top of the >> + previous one. > > The SYNOPSIS block at the top of the file is missing this new flag. > > The replay_usage[] variable in cmd_replay is also missing this new flag. > >> <revision-range>:: >> Range of commits to replay; see "Specifying Ranges" in >> linkgit:git-rev-parse[1]. In `--advance=<branch>` or >> diff --git a/builtin/replay.c b/builtin/replay.c >> index 39e3a86f6c..fedfe46dc6 100644 >> --- a/builtin/replay.c >> +++ b/builtin/replay.c >> @@ -111,6 +111,8 @@ int cmd_replay(int argc, >> N_("mode"), >> N_("control ref update behavior (update|print)"), >> PARSE_OPT_NONEG), >> + OPT_BOOL(0, "linearize", &opts.linearize, >> + N_("ignore merge commits instead of replaying them")), > > "ignore" feels a bit ambiguous to me. Can we use "drop" instead, > matching your commit message? Agreed, I don't like it too. "drop" sounds better. >> OPT_END() >> }; >> >> @@ -132,6 +134,8 @@ int cmd_replay(int argc, >> opts.contained, "--contained"); >> die_for_incompatible_opt2(!!opts.ref, "--ref", >> !!opts.contained, "--contained"); >> + die_for_incompatible_opt2(!!opts.revert, "--revert", >> + opts.linearize, "--linearize"); > > Sensible; should the docs mention this incompatibility? (I'm not sure > myself; just throwing it out as food for thought.) Let's add it. >> >> /* Parse ref action mode from command line or config */ >> ref_mode = get_ref_action_mode(repo, ref_action); >> diff --git a/replay.c b/replay.c >> index 7921d7dba3..81033fb889 100644 >> --- a/replay.c >> +++ b/replay.c >> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo, >> struct commit *onto, >> struct merge_options *merge_opt, >> struct merge_result *result, >> + struct commit *replayed_base, >> bool reverse, >> enum replay_empty_commit_action empty) >> { >> - struct commit *base, *replayed_base; >> + struct commit *base; >> struct tree *pickme_tree, *base_tree, *replayed_base_tree; >> >> + if (replayed_base && reverse) >> + BUG("Linearizing commits is not supported when replaying in reverse"); >> + > > This is dead code given the die_for_incompatible_opt2 check above, > right? Just extra defense in depth? We also have another defense-in-depth for --onto/--advance/--revert: BUG("expected one of onto_name, *advance_name, or *revert_name"); I don't mind having it for --linearize too. >> if (pickme->parents) { >> base = pickme->parents->item; >> base_tree = repo_get_commit_tree(repo, base); >> @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo, >> base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); >> } >> >> - replayed_base = get_mapped_commit(replayed_commits, base, onto); >> + if (!replayed_base) >> + replayed_base = get_mapped_commit(replayed_commits, base, onto); >> replayed_base_tree = repo_get_commit_tree(repo, replayed_base); >> pickme_tree = repo_get_commit_tree(repo, pickme); >> >> @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs, >> while ((commit = get_revision(revs))) { >> const struct name_decoration *decoration; >> >> - if (commit->parents && commit->parents->next) >> - die(_("replaying merge commits is not supported yet!")); >> + if (commit->parents && commit->parents->next) { >> + if (!opts->linearize) >> + die(_("replaying merge commits is not supported yet!")); >> + /* >> + * When linearizing, a merge commit itself is not picked, >> + * but refs that point to it might need updating. >> + */ > > Is it worth pointing out that last_commit is intentionally not updated > by this code path? That is implied by your comment, but it takes a > bit of reasoning to get there, and I think it might help future > readers to just explicitly state it. Ah yes, I didn't realize that, but you make a good point. I'll rephrase the comment a bit. >> + } else { >> + struct commit *to_pick = reverse ? last_commit : onto; >> + last_commit = >> + pick_regular_commit(revs->repo, commit, >> + replayed_commits, to_pick, >> + &merge_opt, &result, >> + opts->linearize ? last_commit : NULL, >> + reverse, opts->empty); >> + } >> >> - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, >> - reverse ? last_commit : onto, >> - &merge_opt, &result, reverse, opts->empty); >> if (!last_commit) >> break; >> >> diff --git a/replay.h b/replay.h >> index 1851a07705..07e6fdcca3 100644 >> --- a/replay.h >> +++ b/replay.h >> @@ -62,6 +62,11 @@ struct replay_revisions_options { >> * Defaults to REPLAY_EMPTY_COMMIT_DROP. >> */ >> enum replay_empty_commit_action empty; >> + >> + /* >> + * Whether to linearize the commits (i.e. drop merge commits). >> + */ >> + int linearize; >> }; >> >> /* This struct is used as an out-parameter by `replay_revisions()`. */ >> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh >> index 3353bc4a4d..64e0731188 100755 >> --- a/t/t3650-replay-basics.sh >> +++ b/t/t3650-replay-basics.sh >> @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' >> test_grep "cannot be used with multiple revision ranges" err >> ' >> >> +test_expect_success 'replay merge commit fails' ' >> + echo "fatal: replaying merge commits is not supported yet!" >expect && >> + test_must_fail git replay --ref-action=print --onto main I..P 2>actual && >> + test_cmp expect actual >> +' >> + >> +test_expect_success 'replay to rebase merge commit with --linearize' ' >> + git replay --ref-action=print --linearize --onto main I..topic-with-merge >result && >> + >> + test_line_count = 1 result && >> + >> + git log --format=%s $(cut -f 3 -d " " result) >actual && >> + test_write_lines O N J M L B A >expect && >> + test_cmp expect actual >> +' >> + >> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' ' >> + git replay --ref-action=print --linearize --onto main A..topic-with-merge >result && > > You'd need to drop "A.." to have it go down to the root commit, as > Junio mentioned elsewhere. Yes, thanks for double confirmation. >> + >> + test_line_count = 1 result && >> + >> + git log --format=%s $(cut -f 3 -d " " result) >actual && >> + test_write_lines O N J I M L B A >expect && >> + test_cmp expect actual >> +' >> + >> test_done > > Should there also be a testcase combining --linearize and --advance? Sure. > Should there be a test with the incompatibility of --revert & > --linearize? I think we have a few other tests for incompatible > options. I was already about to add that. > One additional testing idea, borrowed from an older variant of > this patch I had sitting in a local branch (dscho's original > linearize patch, adapted): in addition to checking specific commit > subjects, it's worth verifying that the linearized chain produces > the *same patches* as the original. Something along the lines of: > > test_expect_success '--linearize preserves patches' ' > test_when_finished "git update-ref -d refs/heads/merge_I_L" && > test_tick && > git checkout -b merge_I_L I && > git merge --no-edit L && > > git replay --linearize --onto A B..merge_I_L && > > # range-diff ignores merges, so the original > # {I, L, merge} reduces to {I, L} on the LHS, > # and the replayed chain on the RHS should match. > git range-diff B..merge_I_L@{1} B..merge_I_L >out && > ! test_grep -v "=" out && > > git log --oneline A..merge_I_L >out && > test_line_count = 2 out > ' > > The range-diff check is nice because it asserts patch equivalence > rather than tying the test to a particular replay ordering, which > makes the test less brittle if the rev-walk order ever changes. > Feel free to take, adapt, or ignore. Interesting idea and I like it. Lemme add it. > Anyway, thanks for working on this; looking good. Thanks! -- Cheers, Toon ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits 2026-06-10 14:49 ` [PATCH v2 0/3] Teach git-replay(1) to linearize merge commits Toon Claes ` (2 preceding siblings ...) 2026-06-10 14:49 ` [PATCH v2 3/3] replay: offer an option to linearize the commit topology Toon Claes @ 2026-06-16 9:26 ` Toon Claes 2026-06-16 9:26 ` [PATCH v3 1/3] replay: refactor enum replay_mode into a bool Toon Claes ` (3 more replies) 3 siblings, 4 replies; 34+ messages in thread From: Toon Claes @ 2026-06-16 9:26 UTC (permalink / raw) To: git; +Cc: Toon Claes, Johannes Schindelin, Johannes Schindelin As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does too with --no-rebase-merges (the default). The first two patches do some refactoring. The third patch implements the actual change. This patch was kindly provided by Dscho, which I've tweaked to be upstreamed. The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees. This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3]. dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize". [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im> Signed-off-by: Toon Claes <toon@iotcl.com> --- Changes in v3: - Add --linearize to Documentation SYNOPSIS, and mention it's incompatible with --revert. - Small language change in help message for --linearize. - Rephrase comment to include last_commit isn't modified when linearizing merges. - Remove test that was added in earlier versions, but actually is a duplicate of 'replaying merge commits is not supported yet'. - Add test to verify --revert and --linearize are incompatible. - Properly test that replaying down to root with --linearize works. - Add test for --linearize with --advance. - Add test that uses git-range-diff(1) to verify the patches created by --linearize are correct. - Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com Changes in v2: - Restructured the conditions to detect merge commits and added a line of comment why the loop continues. - Rewrote tests to use the history from the setup step and added a few test cases. - Re-added Johannes's Signed-off-by trailer. Johannes gave me the patches with this trailer, and if I understand correctly, I can keep it. Please let me know if that wrong. - Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com --- Johannes Schindelin (1): replay: offer an option to linearize the commit topology Toon Claes (2): replay: refactor enum replay_mode into a bool replay: add helper to put entry into mapped_commits Documentation/git-replay.adoc | 8 ++- builtin/replay.c | 6 ++- replay.c | 116 ++++++++++++++++++++++++------------------ replay.h | 5 ++ t/t3650-replay-basics.sh | 68 ++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 52 deletions(-) Range-diff versus v2: 1: 2075988ef1 = 1: 542b1c9267 replay: refactor enum replay_mode into a bool 2: 93ff03be65 = 2: 62f6df8375 replay: add helper to put entry into mapped_commits 3: ef56010c96 ! 3: 768646ee24 replay: offer an option to linearize the commit topology @@ Commit message Signed-off-by: Toon Claes <toon@iotcl.com> ## Documentation/git-replay.adoc ## +@@ Documentation/git-replay.adoc: SYNOPSIS + -------- + [verse] + (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>) +- [--ref=<ref>] [--ref-action=<mode>] <revision-range> ++ [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range> + + DESCRIPTION + ----------- @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only). + The default mode can be configured via the `replay.refAction` configuration variable. @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif + In this mode, `git replay` imitates `git rebase --no-rebase-merges`, + i.e. it cherry-picks only non-merge commits, each one on top of the + previous one. ++ This option is incompatible with `--revert`. + <revision-range>:: Range of commits to replay; see "Specifying Ranges" in linkgit:git-rev-parse[1]. In `--advance=<branch>` or ## builtin/replay.c ## +@@ builtin/replay.c: int cmd_replay(int argc, + const char *const replay_usage[] = { + N_("(EXPERIMENTAL!) git replay " + "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n" +- "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"), ++ "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"), + NULL + }; + struct option replay_options[] = { @@ builtin/replay.c: int cmd_replay(int argc, N_("mode"), N_("control ref update behavior (update|print)"), PARSE_OPT_NONEG), + OPT_BOOL(0, "linearize", &opts.linearize, -+ N_("ignore merge commits instead of replaying them")), ++ N_("drop merge commits, replaying only non-merge commits")), OPT_END() }; @@ replay.c: int replay_revisions(struct rev_info *revs, + if (!opts->linearize) + die(_("replaying merge commits is not supported yet!")); + /* -+ * When linearizing, a merge commit itself is not picked, -+ * but refs that point to it might need updating. ++ * Drop the merge commit: do not pick it and leave ++ * last_commit unchanged, so its children (and any ref ++ * pointing at it) are reparented onto the previous ++ * non-merge commit, which the ref-update loop below uses. + */ + } else { + struct commit *to_pick = reverse ? last_commit : onto; @@ replay.h: struct replay_revisions_options { /* This struct is used as an out-parameter by `replay_revisions()`. */ ## t/t3650-replay-basics.sh ## -@@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' ' - test_grep "cannot be used with multiple revision ranges" err +@@ t/t3650-replay-basics.sh: test_expect_success 'setup' ' + test_merge P O --no-ff && + git switch main && + ++ git switch --orphan unrelated && ++ test_commit unrelated-root && ++ + git switch -c conflict B && +- test_commit C.conflict C.t conflict ++ test_commit C.conflict C.t conflict && ++ git branch -D unrelated ' -+test_expect_success 'replay merge commit fails' ' -+ echo "fatal: replaying merge commits is not supported yet!" >expect && -+ test_must_fail git replay --ref-action=print --onto main I..P 2>actual && -+ test_cmp expect actual + test_expect_success 'setup bare' ' +@@ t/t3650-replay-basics.sh: test_expect_success '--advance and --contained cannot be used together' ' + test_grep "cannot be used together" actual + ' + ++test_expect_success '--revert and --linearize cannot be used together' ' ++ test_must_fail git replay --revert=main --linearize \ ++ topic1..topic2 2>actual && ++ test_grep "cannot be used together" actual +' + + test_expect_success 'cannot advance target ... ordering would be ill-defined' ' + echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect && + test_must_fail git replay --advance=main main topic1 topic2 2>actual && +@@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' ' + test_grep "cannot be used with multiple revision ranges" err + ' + +test_expect_success 'replay to rebase merge commit with --linearize' ' -+ git replay --ref-action=print --linearize --onto main I..topic-with-merge >result && ++ git replay --ref-action=print --linearize \ ++ --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl + test_cmp expect actual +' + -+test_expect_success 'replay to rebase merge commit with --linearize down to root commit' ' -+ git replay --ref-action=print --linearize --onto main A..topic-with-merge >result && ++test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' ' ++ git replay --ref-action=print --linearize \ ++ --onto unrelated-root topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && -+ test_write_lines O N J I M L B A >expect && ++ test_write_lines O N J I B A unrelated-root >expect && + test_cmp expect actual +' ++ ++test_expect_success 'replay to cherry-pick merge commit with --linearize' ' ++ git replay --ref-action=print --linearize \ ++ --advance main I..topic-with-merge >result && ++ ++ test_line_count = 1 result && ++ ++ git log --format=%s $(cut -f 3 -d " " result) >actual && ++ test_write_lines O N J M L B A >expect && ++ test_cmp expect actual && ++ ++ printf "update refs/heads/main " >expect && ++ printf "%s " $(cut -f 3 -d " " result) >>expect && ++ git rev-parse main >>expect && ++ test_cmp expect result ++' ++ ++test_expect_success 'replay --linearize produces the same patches' ' ++ git replay --ref-action=print --linearize \ ++ --onto main I..topic-with-merge >result && ++ ++ test_line_count = 1 result && ++ tip=$(cut -f 3 -d " " result) && ++ ++ # range-diff does not care about the dropped merge, ++ # so the original commits (I..topic-with-merge) ++ # and the replayed chain (main..tip) must produce identical patches. ++ git range-diff I..topic-with-merge main..$tip >out && ++ test_file_not_empty out && ++ ! grep -v "=" out && ++ ++ git log --oneline main..$tip >out && ++ test_line_count = 3 out ++' + test_done --- base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395 ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v3 1/3] replay: refactor enum replay_mode into a bool 2026-06-16 9:26 ` [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits Toon Claes @ 2026-06-16 9:26 ` Toon Claes 2026-06-16 9:26 ` [PATCH v3 2/3] replay: add helper to put entry into mapped_commits Toon Claes ` (2 subsequent siblings) 3 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-16 9:26 UTC (permalink / raw) To: git; +Cc: Toon Claes In 2760ee4983 (replay: add --revert mode to reverse commit changes, 2026-03-26) the enum `replay_mode` was introduced. This has two possible values: - The value `REPLAY_MODE_REVERT` is used when option `--revert` is passed to git-replay(1). When using this value the commits are processed in reverse order and the inverse of the changes are applied. - The value `REPLAY_MODE_PICK` is used when either option `--onto` or `--advance` is used. In both cases the commits are processed in normal order, and the changes are applied as-is. Since there are only two possible values of this enum, simplify the code by converting the enum into a bool. This avoids adding code paths that check for invalid values of the enum, and shortens code where the value is checked with a ternary operator. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 59 +++++++++++++++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/replay.c b/replay.c index 4ef8abb607..1f8e5b083b 100644 --- a/replay.c +++ b/replay.c @@ -18,11 +18,6 @@ */ #define the_repository DO_NOT_USE_THE_REPOSITORY -enum replay_mode { - REPLAY_MODE_PICK, - REPLAY_MODE_REVERT, -}; - static const char *short_commit_name(struct repository *repo, struct commit *commit) { @@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo, struct tree *tree, struct commit *based_on, struct commit *parent, - enum replay_mode mode) + bool reverse) { struct object_id ret; struct object *obj = NULL; @@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo, commit_list_insert(parent, &parents); extra = read_commit_extra_headers(based_on, exclude_gpgsig); - if (mode == REPLAY_MODE_REVERT) { + if (reverse) { generate_revert_message(&msg, based_on, repo); /* For revert, use current user as author (NULL = use default) */ - } else if (mode == REPLAY_MODE_PICK) { + } else { find_commit_subject(message, &orig_message); strbuf_addstr(&msg, orig_message); author = get_author(message); - } else { - BUG("unexpected replay mode %d", mode); } reset_ident_date(); if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents, @@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, - enum replay_mode mode, + bool reverse, enum replay_empty_commit_action empty) { struct commit *base, *replayed_base; @@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo, replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); - if (mode == REPLAY_MODE_PICK) { + if (reverse) { + /* Revert: swap base and pickme to reverse the diff */ + const char *pickme_name = short_commit_name(repo, pickme); + merge_opt->branch1 = short_commit_name(repo, replayed_base); + merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); + merge_opt->ancestor = pickme_name; + + merge_incore_nonrecursive(merge_opt, + pickme_tree, + replayed_base_tree, + base_tree, + result); + + free((char *)merge_opt->branch2); + } else { /* Cherry-pick: normal order */ merge_opt->branch1 = short_commit_name(repo, replayed_base); merge_opt->branch2 = short_commit_name(repo, pickme); @@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo, result); free((char *)merge_opt->ancestor); - } else if (mode == REPLAY_MODE_REVERT) { - /* Revert: swap base and pickme to reverse the diff */ - const char *pickme_name = short_commit_name(repo, pickme); - merge_opt->branch1 = short_commit_name(repo, replayed_base); - merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); - merge_opt->ancestor = pickme_name; - - merge_incore_nonrecursive(merge_opt, - pickme_tree, - replayed_base_tree, - base_tree, - result); - - free((char *)merge_opt->branch2); - } else { - BUG("unexpected replay mode %d", mode); } merge_opt->ancestor = NULL; merge_opt->branch2 = NULL; @@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo, } } - return create_commit(repo, result->tree, pickme, replayed_base, mode); + return create_commit(repo, result->tree, pickme, replayed_base, reverse); } void replay_result_release(struct replay_result *result) @@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs, char *revert; const char *ref; struct object_id old_oid; - enum replay_mode mode = REPLAY_MODE_PICK; + bool reverse; int ret; advance = xstrdup_or_null(opts->advance); revert = xstrdup_or_null(opts->revert); - if (revert) - mode = REPLAY_MODE_REVERT; + reverse = !!revert; + set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, &detached_head, &advance, &revert, &onto, &update_refs); @@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs, die(_("replaying merge commits is not supported yet!")); last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - mode == REPLAY_MODE_REVERT ? last_commit : onto, - &merge_opt, &result, mode, opts->empty); + reverse ? last_commit : onto, + &merge_opt, &result, reverse, opts->empty); if (!last_commit) break; -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* [PATCH v3 2/3] replay: add helper to put entry into mapped_commits 2026-06-16 9:26 ` [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-16 9:26 ` [PATCH v3 1/3] replay: refactor enum replay_mode into a bool Toon Claes @ 2026-06-16 9:26 ` Toon Claes 2026-06-16 9:26 ` [PATCH v3 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 3 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-16 9:26 UTC (permalink / raw) To: git; +Cc: Toon Claes The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit(). While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/replay.c b/replay.c index 1f8e5b083b..7921d7dba3 100644 --- a/replay.c +++ b/replay.c @@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo, strset_clear(&rinfo.positive_refs); } -static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, - struct commit *commit, - struct commit *fallback) +static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *fallback) { khint_t pos; if (!commit) @@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, return kh_value(replayed_commits, pos); } +static void put_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *new_commit) +{ + khint_t pos; + int ret; + + pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret); + if (ret == 0) + BUG("Duplicate rewritten commit: %s\n", + oid_to_hex(&commit->object.oid)); + + kh_value(replayed_commits, pos) = new_commit; +} + static struct commit *pick_regular_commit(struct repository *repo, struct commit *pickme, kh_oid_map_t *replayed_commits, @@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = mapped_commit(replayed_commits, base, onto); + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs, replayed_commits = kh_init_oid_map(); while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - khint_t pos; - int hr; if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); @@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs, break; /* Record commit -> last_commit mapping */ - pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr); - if (hr == 0) - BUG("Duplicate rewritten commit: %s\n", - oid_to_hex(&commit->object.oid)); - kh_value(replayed_commits, pos) = last_commit; + put_mapped_commit(replayed_commits, commit, last_commit); /* Update any necessary branches */ if (ref) -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* [PATCH v3 3/3] replay: offer an option to linearize the commit topology 2026-06-16 9:26 ` [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-16 9:26 ` [PATCH v3 1/3] replay: refactor enum replay_mode into a bool Toon Claes 2026-06-16 9:26 ` [PATCH v3 2/3] replay: add helper to put entry into mapped_commits Toon Claes @ 2026-06-16 9:26 ` Toon Claes 2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 3 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-16 9:26 UTC (permalink / raw) To: git; +Cc: Toon Claes, Johannes Schindelin, Johannes Schindelin From: Johannes Schindelin <Johannes.Schindelin@gmx.de> One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side. The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the commit history into a sequence of the regular (single-parent) commits. Add option `--linearize` to git-replay(1) to do the same. Co-authored-by: Toon Claes <toon@iotcl.com> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> Signed-off-by: Toon Claes <toon@iotcl.com> --- Documentation/git-replay.adoc | 8 ++++- builtin/replay.c | 6 +++- replay.c | 32 +++++++++++++++----- replay.h | 5 ++++ t/t3650-replay-basics.sh | 68 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 109 insertions(+), 10 deletions(-) diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index a32f72aead..ef56ee0f1b 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -10,7 +10,7 @@ SYNOPSIS -------- [verse] (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>) - [--ref=<ref>] [--ref-action=<mode>] <revision-range> + [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range> DESCRIPTION ----------- @@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only). + The default mode can be configured via the `replay.refAction` configuration variable. +--linearize:: + In this mode, `git replay` imitates `git rebase --no-rebase-merges`, + i.e. it cherry-picks only non-merge commits, each one on top of the + previous one. + This option is incompatible with `--revert`. + <revision-range>:: Range of commits to replay; see "Specifying Ranges" in linkgit:git-rev-parse[1]. In `--advance=<branch>` or diff --git a/builtin/replay.c b/builtin/replay.c index 39e3a86f6c..62962c73c7 100644 --- a/builtin/replay.c +++ b/builtin/replay.c @@ -85,7 +85,7 @@ int cmd_replay(int argc, const char *const replay_usage[] = { N_("(EXPERIMENTAL!) git replay " "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n" - "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"), + "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"), NULL }; struct option replay_options[] = { @@ -111,6 +111,8 @@ int cmd_replay(int argc, N_("mode"), N_("control ref update behavior (update|print)"), PARSE_OPT_NONEG), + OPT_BOOL(0, "linearize", &opts.linearize, + N_("drop merge commits, replaying only non-merge commits")), OPT_END() }; @@ -132,6 +134,8 @@ int cmd_replay(int argc, opts.contained, "--contained"); die_for_incompatible_opt2(!!opts.ref, "--ref", !!opts.contained, "--contained"); + die_for_incompatible_opt2(!!opts.revert, "--revert", + opts.linearize, "--linearize"); /* Parse ref action mode from command line or config */ ref_mode = get_ref_action_mode(repo, ref_action); diff --git a/replay.c b/replay.c index 7921d7dba3..5539daff00 100644 --- a/replay.c +++ b/replay.c @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, + struct commit *replayed_base, bool reverse, enum replay_empty_commit_action empty) { - struct commit *base, *replayed_base; + struct commit *base; struct tree *pickme_tree, *base_tree, *replayed_base_tree; + if (replayed_base && reverse) + BUG("Linearizing commits is not supported when replaying in reverse"); + if (pickme->parents) { base = pickme->parents->item; base_tree = repo_get_commit_tree(repo, base); @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = get_mapped_commit(replayed_commits, base, onto); + if (!replayed_base) + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - if (commit->parents && commit->parents->next) - die(_("replaying merge commits is not supported yet!")); + if (commit->parents && commit->parents->next) { + if (!opts->linearize) + die(_("replaying merge commits is not supported yet!")); + /* + * Drop the merge commit: do not pick it and leave + * last_commit unchanged, so its children (and any ref + * pointing at it) are reparented onto the previous + * non-merge commit, which the ref-update loop below uses. + */ + } else { + struct commit *to_pick = reverse ? last_commit : onto; + last_commit = + pick_regular_commit(revs->repo, commit, + replayed_commits, to_pick, + &merge_opt, &result, + opts->linearize ? last_commit : NULL, + reverse, opts->empty); + } - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - reverse ? last_commit : onto, - &merge_opt, &result, reverse, opts->empty); if (!last_commit) break; diff --git a/replay.h b/replay.h index 1851a07705..07e6fdcca3 100644 --- a/replay.h +++ b/replay.h @@ -62,6 +62,11 @@ struct replay_revisions_options { * Defaults to REPLAY_EMPTY_COMMIT_DROP. */ enum replay_empty_commit_action empty; + + /* + * Whether to linearize the commits (i.e. drop merge commits). + */ + int linearize; }; /* This struct is used as an out-parameter by `replay_revisions()`. */ diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh index 3353bc4a4d..1874d06769 100755 --- a/t/t3650-replay-basics.sh +++ b/t/t3650-replay-basics.sh @@ -52,8 +52,12 @@ test_expect_success 'setup' ' test_merge P O --no-ff && git switch main && + git switch --orphan unrelated && + test_commit unrelated-root && + git switch -c conflict B && - test_commit C.conflict C.t conflict + test_commit C.conflict C.t conflict && + git branch -D unrelated ' test_expect_success 'setup bare' ' @@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' ' test_grep "cannot be used together" actual ' +test_expect_success '--revert and --linearize cannot be used together' ' + test_must_fail git replay --revert=main --linearize \ + topic1..topic2 2>actual && + test_grep "cannot be used together" actual +' + test_expect_success 'cannot advance target ... ordering would be ill-defined' ' echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect && test_must_fail git replay --advance=main main topic1 topic2 2>actual && @@ -565,4 +575,60 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' test_grep "cannot be used with multiple revision ranges" err ' +test_expect_success 'replay to rebase merge commit with --linearize' ' + git replay --ref-action=print --linearize \ + --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual +' + +test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' ' + git replay --ref-action=print --linearize \ + --onto unrelated-root topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J I B A unrelated-root >expect && + test_cmp expect actual +' + +test_expect_success 'replay to cherry-pick merge commit with --linearize' ' + git replay --ref-action=print --linearize \ + --advance main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual && + + printf "update refs/heads/main " >expect && + printf "%s " $(cut -f 3 -d " " result) >>expect && + git rev-parse main >>expect && + test_cmp expect result +' + +test_expect_success 'replay --linearize produces the same patches' ' + git replay --ref-action=print --linearize \ + --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + tip=$(cut -f 3 -d " " result) && + + # range-diff does not care about the dropped merge, + # so the original commits (I..topic-with-merge) + # and the replayed chain (main..tip) must produce identical patches. + git range-diff I..topic-with-merge main..$tip >out && + test_file_not_empty out && + ! grep -v "=" out && + + git log --oneline main..$tip >out && + test_line_count = 3 out +' + test_done -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits 2026-06-16 9:26 ` [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits Toon Claes ` (2 preceding siblings ...) 2026-06-16 9:26 ` [PATCH v3 3/3] replay: offer an option to linearize the commit topology Toon Claes @ 2026-06-22 12:41 ` Toon Claes 2026-06-22 12:41 ` [PATCH v4 1/3] replay: refactor enum replay_mode into a bool Toon Claes ` (3 more replies) 3 siblings, 4 replies; 34+ messages in thread From: Toon Claes @ 2026-06-22 12:41 UTC (permalink / raw) To: git; +Cc: Elijah Newren, Toon Claes, Johannes Schindelin, Johannes Schindelin As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does too with --no-rebase-merges (the default). The first two patches do some refactoring. The third patch implements the actual change. This patch was kindly provided by Dscho, which I've tweaked to be upstreamed. The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees. This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3]. dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize". [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im> --- Changes in v4: - Use test_grep instead of a bare grep in the range-diff test, to prepare for mm/test-grep-lint. - Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com Changes in v3: - Add --linearize to Documentation SYNOPSIS, and mention it's incompatible with --revert. - Small language change in help message for --linearize. - Rephrase comment to include last_commit isn't modified when linearizing merges. - Remove test that was added in earlier versions, but actually is a duplicate of 'replaying merge commits is not supported yet'. - Add test to verify --revert and --linearize are incompatible. - Properly test that replaying down to root with --linearize works. - Add test for --linearize with --advance. - Add test that uses git-range-diff(1) to verify the patches created by --linearize are correct. - Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com Changes in v2: - Restructured the conditions to detect merge commits and added a line of comment why the loop continues. - Rewrote tests to use the history from the setup step and added a few test cases. - Re-added Johannes's Signed-off-by trailer. Johannes gave me the patches with this trailer, and if I understand correctly, I can keep it. Please let me know if that wrong. - Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com --- Johannes Schindelin (1): replay: offer an option to linearize the commit topology Toon Claes (2): replay: refactor enum replay_mode into a bool replay: add helper to put entry into mapped_commits Documentation/git-replay.adoc | 8 ++- builtin/replay.c | 6 ++- replay.c | 116 ++++++++++++++++++++++++------------------ replay.h | 5 ++ t/t3650-replay-basics.sh | 68 ++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 52 deletions(-) Range-diff versus v3: 1: 759fa1b52c = 1: 0f0e50c67f replay: refactor enum replay_mode into a bool 2: 68dd5ad77c = 2: 919a6495ee replay: add helper to put entry into mapped_commits 3: f99aeb3887 ! 3: bb03e78210 replay: offer an option to linearize the commit topology @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl + # and the replayed chain (main..tip) must produce identical patches. + git range-diff I..topic-with-merge main..$tip >out && + test_file_not_empty out && -+ ! grep -v "=" out && ++ test_grep ! -v "=" out && + + git log --oneline main..$tip >out && + test_line_count = 3 out --- base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395 ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v4 1/3] replay: refactor enum replay_mode into a bool 2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes @ 2026-06-22 12:41 ` Toon Claes 2026-06-22 13:53 ` Patrick Steinhardt 2026-06-22 12:41 ` [PATCH v4 2/3] replay: add helper to put entry into mapped_commits Toon Claes ` (2 subsequent siblings) 3 siblings, 1 reply; 34+ messages in thread From: Toon Claes @ 2026-06-22 12:41 UTC (permalink / raw) To: git; +Cc: Elijah Newren, Toon Claes In 2760ee4983 (replay: add --revert mode to reverse commit changes, 2026-03-26) the enum `replay_mode` was introduced. This has two possible values: - The value `REPLAY_MODE_REVERT` is used when option `--revert` is passed to git-replay(1). When using this value the commits are processed in reverse order and the inverse of the changes are applied. - The value `REPLAY_MODE_PICK` is used when either option `--onto` or `--advance` is used. In both cases the commits are processed in normal order, and the changes are applied as-is. Since there are only two possible values of this enum, simplify the code by converting the enum into a bool. This avoids adding code paths that check for invalid values of the enum, and shortens code where the value is checked with a ternary operator. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 59 +++++++++++++++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/replay.c b/replay.c index 4ef8abb607..1f8e5b083b 100644 --- a/replay.c +++ b/replay.c @@ -18,11 +18,6 @@ */ #define the_repository DO_NOT_USE_THE_REPOSITORY -enum replay_mode { - REPLAY_MODE_PICK, - REPLAY_MODE_REVERT, -}; - static const char *short_commit_name(struct repository *repo, struct commit *commit) { @@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo, struct tree *tree, struct commit *based_on, struct commit *parent, - enum replay_mode mode) + bool reverse) { struct object_id ret; struct object *obj = NULL; @@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo, commit_list_insert(parent, &parents); extra = read_commit_extra_headers(based_on, exclude_gpgsig); - if (mode == REPLAY_MODE_REVERT) { + if (reverse) { generate_revert_message(&msg, based_on, repo); /* For revert, use current user as author (NULL = use default) */ - } else if (mode == REPLAY_MODE_PICK) { + } else { find_commit_subject(message, &orig_message); strbuf_addstr(&msg, orig_message); author = get_author(message); - } else { - BUG("unexpected replay mode %d", mode); } reset_ident_date(); if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents, @@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, - enum replay_mode mode, + bool reverse, enum replay_empty_commit_action empty) { struct commit *base, *replayed_base; @@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo, replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); - if (mode == REPLAY_MODE_PICK) { + if (reverse) { + /* Revert: swap base and pickme to reverse the diff */ + const char *pickme_name = short_commit_name(repo, pickme); + merge_opt->branch1 = short_commit_name(repo, replayed_base); + merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); + merge_opt->ancestor = pickme_name; + + merge_incore_nonrecursive(merge_opt, + pickme_tree, + replayed_base_tree, + base_tree, + result); + + free((char *)merge_opt->branch2); + } else { /* Cherry-pick: normal order */ merge_opt->branch1 = short_commit_name(repo, replayed_base); merge_opt->branch2 = short_commit_name(repo, pickme); @@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo, result); free((char *)merge_opt->ancestor); - } else if (mode == REPLAY_MODE_REVERT) { - /* Revert: swap base and pickme to reverse the diff */ - const char *pickme_name = short_commit_name(repo, pickme); - merge_opt->branch1 = short_commit_name(repo, replayed_base); - merge_opt->branch2 = xstrfmt("parent of %s", pickme_name); - merge_opt->ancestor = pickme_name; - - merge_incore_nonrecursive(merge_opt, - pickme_tree, - replayed_base_tree, - base_tree, - result); - - free((char *)merge_opt->branch2); - } else { - BUG("unexpected replay mode %d", mode); } merge_opt->ancestor = NULL; merge_opt->branch2 = NULL; @@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo, } } - return create_commit(repo, result->tree, pickme, replayed_base, mode); + return create_commit(repo, result->tree, pickme, replayed_base, reverse); } void replay_result_release(struct replay_result *result) @@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs, char *revert; const char *ref; struct object_id old_oid; - enum replay_mode mode = REPLAY_MODE_PICK; + bool reverse; int ret; advance = xstrdup_or_null(opts->advance); revert = xstrdup_or_null(opts->revert); - if (revert) - mode = REPLAY_MODE_REVERT; + reverse = !!revert; + set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, &detached_head, &advance, &revert, &onto, &update_refs); @@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs, die(_("replaying merge commits is not supported yet!")); last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - mode == REPLAY_MODE_REVERT ? last_commit : onto, - &merge_opt, &result, mode, opts->empty); + reverse ? last_commit : onto, + &merge_opt, &result, reverse, opts->empty); if (!last_commit) break; -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool 2026-06-22 12:41 ` [PATCH v4 1/3] replay: refactor enum replay_mode into a bool Toon Claes @ 2026-06-22 13:53 ` Patrick Steinhardt 2026-06-22 15:43 ` Junio C Hamano 0 siblings, 1 reply; 34+ messages in thread From: Patrick Steinhardt @ 2026-06-22 13:53 UTC (permalink / raw) To: Toon Claes; +Cc: git, Elijah Newren On Mon, Jun 22, 2026 at 02:41:55PM +0200, Toon Claes wrote: > In 2760ee4983 (replay: add --revert mode to reverse commit changes, > 2026-03-26) the enum `replay_mode` was introduced. This has two possible > values: > > - The value `REPLAY_MODE_REVERT` is used when option `--revert` is > passed to git-replay(1). When using this value the commits are > processed in reverse order and the inverse of the changes are > applied. > > - The value `REPLAY_MODE_PICK` is used when either option `--onto` or > `--advance` is used. In both cases the commits are processed in > normal order, and the changes are applied as-is. > > Since there are only two possible values of this enum, simplify the code > by converting the enum into a bool. This avoids adding code paths that > check for invalid values of the enum, and shortens code where the value > is checked with a ternary operator. That's fair, and the result is easier to write. But is it really easier to read? And what if we ever have to create a third mode going forward? I'm generally no fan of booleans as parameters as they basically give you no information at all at the callsite, except if you're lucky and you already have an aptly-named variable available that you can pass. Which seems to be the case here, but I'm still not sure whether this change really improves the code. Patrick ^ permalink raw reply [flat|nested] 34+ messages in thread
* Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool 2026-06-22 13:53 ` Patrick Steinhardt @ 2026-06-22 15:43 ` Junio C Hamano 2026-06-24 19:15 ` Toon Claes 0 siblings, 1 reply; 34+ messages in thread From: Junio C Hamano @ 2026-06-22 15:43 UTC (permalink / raw) To: Patrick Steinhardt; +Cc: Toon Claes, git, Elijah Newren Patrick Steinhardt <ps@pks.im> writes: > On Mon, Jun 22, 2026 at 02:41:55PM +0200, Toon Claes wrote: >> In 2760ee4983 (replay: add --revert mode to reverse commit changes, >> 2026-03-26) the enum `replay_mode` was introduced. This has two possible >> values: >> >> - The value `REPLAY_MODE_REVERT` is used when option `--revert` is >> passed to git-replay(1). When using this value the commits are >> processed in reverse order and the inverse of the changes are >> applied. >> >> - The value `REPLAY_MODE_PICK` is used when either option `--onto` or >> `--advance` is used. In both cases the commits are processed in >> normal order, and the changes are applied as-is. >> >> Since there are only two possible values of this enum, simplify the code >> by converting the enum into a bool. This avoids adding code paths that >> check for invalid values of the enum, and shortens code where the value >> is checked with a ternary operator. > > That's fair, and the result is easier to write. But is it really easier > to read? And what if we ever have to create a third mode going forward? > > I'm generally no fan of booleans as parameters as they basically give > you no information at all at the callsite, except if you're lucky and > you already have an aptly-named variable available that you can pass. > Which seems to be the case here, but I'm still not sure whether this > change really improves the code. I tend to agree with you on both counts. The "what happens when somebody else wants a third choice?" is a quesiton I would ask the first thing as the maintainer of a project. Even if the boolean parameter is so obviously named, the callsite can only say "true" or "false", unlike some other popular languages that lets you say my_function(use_revert_mode=true, verbose=false); and you cannot tell what effect the author wanted out of that "true" if all you can write were my_function(true, false); Of course, we could go ultra verbose, like my_function(true, /* use_revert_mode */ false, /* verbose */); but then we are often better off writing: my_function(REPLAY_MODE_REVERT, REPLAY_QUIET); Thanks. ^ permalink raw reply [flat|nested] 34+ messages in thread
* Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool 2026-06-22 15:43 ` Junio C Hamano @ 2026-06-24 19:15 ` Toon Claes 0 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-24 19:15 UTC (permalink / raw) To: Junio C Hamano, Patrick Steinhardt; +Cc: git, Elijah Newren Junio C Hamano <gitster@pobox.com> writes: > Patrick Steinhardt <ps@pks.im> writes: > >> That's fair, and the result is easier to write. But is it really easier >> to read? You're bringing up a very valid point there, and not directly in what you're saying, but how it makes me reconsider. So we're comparing: pick_regular_commit(revs->repo, commit, replayed_commits, mode == REPLAY_MODE_REVERT ? last_commit : onto, &merge_opt, &result, mode, opts->empty); with: pick_regular_commit(revs->repo, commit, replayed_commits, reverse ? last_commit : onto, &merge_opt, &result, reverse, opts->empty); You can argue which of both is easier to read, but the problem isn't really whether it's a bool or an enum, but the ternary operator in this lengthy function call is. That is the problem I was trying to solve, and converting enum to bool isn't really the solution. >> And what if we ever have to create a third mode going forward? Personally I find this weak argument. As far as I know we most of the time do not write code in a way so "it will be ready to add X in the future". In my personal experience, I'm always wrong in predicting what might be added in the future. Although I must say this case is different, because we're not adding something new, no this commit was dumbing down something existing. So I'll revisit this commit in the next iteration. >> I'm generally no fan of booleans as parameters as they basically give >> you no information at all at the callsite, except if you're lucky and >> you already have an aptly-named variable available that you can pass. >> Which seems to be the case here, but I'm still not sure whether this >> change really improves the code. That's also a very valid argument, which I didn't take in mind. > I tend to agree with you on both counts. The "what happens when > somebody else wants a third choice?" is a quesiton I would ask the > first thing as the maintainer of a project. > > Even if the boolean parameter is so obviously named, the callsite > can only say "true" or "false", unlike some other popular languages > that lets you say > > my_function(use_revert_mode=true, verbose=false); > > and you cannot tell what effect the author wanted out of that "true" > if all you can write were > > my_function(true, false); > > Of course, we could go ultra verbose, like > > my_function(true, /* use_revert_mode */ > false, /* verbose */); > > but then we are often better off writing: > > my_function(REPLAY_MODE_REVERT, REPLAY_QUIET); Thanks for bringing in this illustrative example. Point made, I'll revisit. -- Cheers, Toon ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v4 2/3] replay: add helper to put entry into mapped_commits 2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-22 12:41 ` [PATCH v4 1/3] replay: refactor enum replay_mode into a bool Toon Claes @ 2026-06-22 12:41 ` Toon Claes 2026-06-22 13:53 ` Patrick Steinhardt 2026-06-22 12:41 ` [PATCH v4 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 3 siblings, 1 reply; 34+ messages in thread From: Toon Claes @ 2026-06-22 12:41 UTC (permalink / raw) To: git; +Cc: Elijah Newren, Toon Claes The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit(). While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/replay.c b/replay.c index 1f8e5b083b..7921d7dba3 100644 --- a/replay.c +++ b/replay.c @@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo, strset_clear(&rinfo.positive_refs); } -static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, - struct commit *commit, - struct commit *fallback) +static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *fallback) { khint_t pos; if (!commit) @@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, return kh_value(replayed_commits, pos); } +static void put_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *new_commit) +{ + khint_t pos; + int ret; + + pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret); + if (ret == 0) + BUG("Duplicate rewritten commit: %s\n", + oid_to_hex(&commit->object.oid)); + + kh_value(replayed_commits, pos) = new_commit; +} + static struct commit *pick_regular_commit(struct repository *repo, struct commit *pickme, kh_oid_map_t *replayed_commits, @@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = mapped_commit(replayed_commits, base, onto); + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs, replayed_commits = kh_init_oid_map(); while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - khint_t pos; - int hr; if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); @@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs, break; /* Record commit -> last_commit mapping */ - pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr); - if (hr == 0) - BUG("Duplicate rewritten commit: %s\n", - oid_to_hex(&commit->object.oid)); - kh_value(replayed_commits, pos) = last_commit; + put_mapped_commit(replayed_commits, commit, last_commit); /* Update any necessary branches */ if (ref) -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* Re: [PATCH v4 2/3] replay: add helper to put entry into mapped_commits 2026-06-22 12:41 ` [PATCH v4 2/3] replay: add helper to put entry into mapped_commits Toon Claes @ 2026-06-22 13:53 ` Patrick Steinhardt 0 siblings, 0 replies; 34+ messages in thread From: Patrick Steinhardt @ 2026-06-22 13:53 UTC (permalink / raw) To: Toon Claes; +Cc: git, Elijah Newren On Mon, Jun 22, 2026 at 02:41:56PM +0200, Toon Claes wrote: > diff --git a/replay.c b/replay.c > index 1f8e5b083b..7921d7dba3 100644 > --- a/replay.c > +++ b/replay.c > @@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, > return kh_value(replayed_commits, pos); > } > > +static void put_mapped_commit(kh_oid_map_t *replayed_commits, > + struct commit *commit, > + struct commit *new_commit) > +{ > + khint_t pos; > + int ret; > + > + pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret); > + if (ret == 0) > + BUG("Duplicate rewritten commit: %s\n", > + oid_to_hex(&commit->object.oid)); > + > + kh_value(replayed_commits, pos) = new_commit; > +} The khash map interfaces are quite awkward to use, so having a small wrapper feels sensible to me. It is one of those interfaces that really make you wish for generics in C. Patrick ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v4 3/3] replay: offer an option to linearize the commit topology 2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-22 12:41 ` [PATCH v4 1/3] replay: refactor enum replay_mode into a bool Toon Claes 2026-06-22 12:41 ` [PATCH v4 2/3] replay: add helper to put entry into mapped_commits Toon Claes @ 2026-06-22 12:41 ` Toon Claes 2026-06-22 13:53 ` Patrick Steinhardt 2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 3 siblings, 1 reply; 34+ messages in thread From: Toon Claes @ 2026-06-22 12:41 UTC (permalink / raw) To: git; +Cc: Elijah Newren, Johannes Schindelin, Toon Claes, Johannes Schindelin From: Johannes Schindelin <Johannes.Schindelin@gmx.de> One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side. The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the commit history into a sequence of the regular (single-parent) commits. Add option `--linearize` to git-replay(1) to do the same. Co-authored-by: Toon Claes <toon@iotcl.com> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> Signed-off-by: Toon Claes <toon@iotcl.com> --- Documentation/git-replay.adoc | 8 ++++- builtin/replay.c | 6 +++- replay.c | 32 +++++++++++++++----- replay.h | 5 ++++ t/t3650-replay-basics.sh | 68 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 109 insertions(+), 10 deletions(-) diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index a32f72aead..ef56ee0f1b 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -10,7 +10,7 @@ SYNOPSIS -------- [verse] (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>) - [--ref=<ref>] [--ref-action=<mode>] <revision-range> + [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range> DESCRIPTION ----------- @@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only). + The default mode can be configured via the `replay.refAction` configuration variable. +--linearize:: + In this mode, `git replay` imitates `git rebase --no-rebase-merges`, + i.e. it cherry-picks only non-merge commits, each one on top of the + previous one. + This option is incompatible with `--revert`. + <revision-range>:: Range of commits to replay; see "Specifying Ranges" in linkgit:git-rev-parse[1]. In `--advance=<branch>` or diff --git a/builtin/replay.c b/builtin/replay.c index 39e3a86f6c..62962c73c7 100644 --- a/builtin/replay.c +++ b/builtin/replay.c @@ -85,7 +85,7 @@ int cmd_replay(int argc, const char *const replay_usage[] = { N_("(EXPERIMENTAL!) git replay " "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n" - "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"), + "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"), NULL }; struct option replay_options[] = { @@ -111,6 +111,8 @@ int cmd_replay(int argc, N_("mode"), N_("control ref update behavior (update|print)"), PARSE_OPT_NONEG), + OPT_BOOL(0, "linearize", &opts.linearize, + N_("drop merge commits, replaying only non-merge commits")), OPT_END() }; @@ -132,6 +134,8 @@ int cmd_replay(int argc, opts.contained, "--contained"); die_for_incompatible_opt2(!!opts.ref, "--ref", !!opts.contained, "--contained"); + die_for_incompatible_opt2(!!opts.revert, "--revert", + opts.linearize, "--linearize"); /* Parse ref action mode from command line or config */ ref_mode = get_ref_action_mode(repo, ref_action); diff --git a/replay.c b/replay.c index 7921d7dba3..5539daff00 100644 --- a/replay.c +++ b/replay.c @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo, struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, + struct commit *replayed_base, bool reverse, enum replay_empty_commit_action empty) { - struct commit *base, *replayed_base; + struct commit *base; struct tree *pickme_tree, *base_tree, *replayed_base_tree; + if (replayed_base && reverse) + BUG("Linearizing commits is not supported when replaying in reverse"); + if (pickme->parents) { base = pickme->parents->item; base_tree = repo_get_commit_tree(repo, base); @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = get_mapped_commit(replayed_commits, base, onto); + if (!replayed_base) + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - if (commit->parents && commit->parents->next) - die(_("replaying merge commits is not supported yet!")); + if (commit->parents && commit->parents->next) { + if (!opts->linearize) + die(_("replaying merge commits is not supported yet!")); + /* + * Drop the merge commit: do not pick it and leave + * last_commit unchanged, so its children (and any ref + * pointing at it) are reparented onto the previous + * non-merge commit, which the ref-update loop below uses. + */ + } else { + struct commit *to_pick = reverse ? last_commit : onto; + last_commit = + pick_regular_commit(revs->repo, commit, + replayed_commits, to_pick, + &merge_opt, &result, + opts->linearize ? last_commit : NULL, + reverse, opts->empty); + } - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - reverse ? last_commit : onto, - &merge_opt, &result, reverse, opts->empty); if (!last_commit) break; diff --git a/replay.h b/replay.h index 1851a07705..07e6fdcca3 100644 --- a/replay.h +++ b/replay.h @@ -62,6 +62,11 @@ struct replay_revisions_options { * Defaults to REPLAY_EMPTY_COMMIT_DROP. */ enum replay_empty_commit_action empty; + + /* + * Whether to linearize the commits (i.e. drop merge commits). + */ + int linearize; }; /* This struct is used as an out-parameter by `replay_revisions()`. */ diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh index 3353bc4a4d..b9ce6c4868 100755 --- a/t/t3650-replay-basics.sh +++ b/t/t3650-replay-basics.sh @@ -52,8 +52,12 @@ test_expect_success 'setup' ' test_merge P O --no-ff && git switch main && + git switch --orphan unrelated && + test_commit unrelated-root && + git switch -c conflict B && - test_commit C.conflict C.t conflict + test_commit C.conflict C.t conflict && + git branch -D unrelated ' test_expect_success 'setup bare' ' @@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' ' test_grep "cannot be used together" actual ' +test_expect_success '--revert and --linearize cannot be used together' ' + test_must_fail git replay --revert=main --linearize \ + topic1..topic2 2>actual && + test_grep "cannot be used together" actual +' + test_expect_success 'cannot advance target ... ordering would be ill-defined' ' echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect && test_must_fail git replay --advance=main main topic1 topic2 2>actual && @@ -565,4 +575,60 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' test_grep "cannot be used with multiple revision ranges" err ' +test_expect_success 'replay to rebase merge commit with --linearize' ' + git replay --ref-action=print --linearize \ + --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual +' + +test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' ' + git replay --ref-action=print --linearize \ + --onto unrelated-root topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J I B A unrelated-root >expect && + test_cmp expect actual +' + +test_expect_success 'replay to cherry-pick merge commit with --linearize' ' + git replay --ref-action=print --linearize \ + --advance main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual && + + printf "update refs/heads/main " >expect && + printf "%s " $(cut -f 3 -d " " result) >>expect && + git rev-parse main >>expect && + test_cmp expect result +' + +test_expect_success 'replay --linearize produces the same patches' ' + git replay --ref-action=print --linearize \ + --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + tip=$(cut -f 3 -d " " result) && + + # range-diff does not care about the dropped merge, + # so the original commits (I..topic-with-merge) + # and the replayed chain (main..tip) must produce identical patches. + git range-diff I..topic-with-merge main..$tip >out && + test_file_not_empty out && + test_grep ! -v "=" out && + + git log --oneline main..$tip >out && + test_line_count = 3 out +' + test_done -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology 2026-06-22 12:41 ` [PATCH v4 3/3] replay: offer an option to linearize the commit topology Toon Claes @ 2026-06-22 13:53 ` Patrick Steinhardt 2026-06-26 5:36 ` Toon Claes 0 siblings, 1 reply; 34+ messages in thread From: Patrick Steinhardt @ 2026-06-22 13:53 UTC (permalink / raw) To: Toon Claes; +Cc: git, Elijah Newren, Johannes Schindelin On Mon, Jun 22, 2026 at 02:41:57PM +0200, Toon Claes wrote: > From: Johannes Schindelin <Johannes.Schindelin@gmx.de> > > One of the stated goals of git-replay(1) is to allow implementing the > git-rebase(1) functionality on the server side. > > The default mode of git-rebase(1) is to act as if `--no-rebase-merges` > was given. This mode drops merge commits instead of replaying them, and > linearizes the commit history into a sequence of the > regular (single-parent) commits. > > Add option `--linearize` to git-replay(1) to do the same. git-rebase(1) essentially knows about three different modes: - "--no-rebase-merges", which is the default and maps to your "--linearize". - "--rebase-merges", which by default doesn't rebase cousins by using "--ancestry-path" internally. - "--rebase-merges=rebase-cousins", which doesn't pass the above option. So it's not a simple boolean there, which makes me wonder whether we should mirror the same interface so that all of git-rebase(1)'s modes can be represented, as well. > diff --git a/replay.c b/replay.c > index 7921d7dba3..5539daff00 100644 > --- a/replay.c > +++ b/replay.c > @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo, > struct commit *onto, > struct merge_options *merge_opt, > struct merge_result *result, > + struct commit *replayed_base, > bool reverse, > enum replay_empty_commit_action empty) > { > - struct commit *base, *replayed_base; > + struct commit *base; > struct tree *pickme_tree, *base_tree, *replayed_base_tree; > > + if (replayed_base && reverse) > + BUG("Linearizing commits is not supported when replaying in reverse"); Nit: Error messages should typically start with a lower-case letter. > @@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs, > while ((commit = get_revision(revs))) { > const struct name_decoration *decoration; > > - if (commit->parents && commit->parents->next) > - die(_("replaying merge commits is not supported yet!")); > + if (commit->parents && commit->parents->next) { > + if (!opts->linearize) > + die(_("replaying merge commits is not supported yet!")); > + /* > + * Drop the merge commit: do not pick it and leave > + * last_commit unchanged, so its children (and any ref > + * pointing at it) are reparented onto the previous > + * non-merge commit, which the ref-update loop below uses. > + */ One could add a hint here that tells the user to pass the option. But I guess that might be somewhat weird, as we cannot assume that we're called by git-replay(1) here. In any case, this here is the core of the change where we stop dying in case "--linearize" was passed, and instead we simply skip the commit altogether. Makes sense. Thanks! Patrick ^ permalink raw reply [flat|nested] 34+ messages in thread
* Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology 2026-06-22 13:53 ` Patrick Steinhardt @ 2026-06-26 5:36 ` Toon Claes 0 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-26 5:36 UTC (permalink / raw) To: Patrick Steinhardt; +Cc: git, Elijah Newren, Johannes Schindelin Patrick Steinhardt <ps@pks.im> writes: > git-rebase(1) essentially knows about three different modes: > > - "--no-rebase-merges", which is the default and maps to your > "--linearize". > > - "--rebase-merges", which by default doesn't rebase cousins by using > "--ancestry-path" internally. > > - "--rebase-merges=rebase-cousins", which doesn't pass the above > option. > > So it's not a simple boolean there, which makes me wonder whether we > should mirror the same interface so that all of git-rebase(1)'s modes > can be represented, as well. That's a valid question, although I don't know a good answer to that. Basically you're asking for what the command line options will look like? Allow me to think out loud. In this series I'm adding --linearize to git-replay(1). As mentioned, I don't think it makes sense to add it to git-history(1) as well. Without this option, the process aborts when it encounters a merge. Dscho sent a patch series to properly replay (2-way) merges. I think this should become the default for both git-replay(1) and git-history(1). But then, do we want to have an option that brings back the current behavior of aborting at merges? Maybe with --no-merges? Then there's the option of rebasing cousins left. That's something that isn't covered by Dscho's series yet. Maybe --replay-cousins? To reiterate what the final design could look like: * <nothing>: replay merges preserving topology. * "--linearize": flattens merges (only git-replay(1)). * "--no-merges": dies when the process tries to replay a merge. * "--replay-cousins": does what --rebase-merges=rebase-cousins does. Now, all these options are (I think) mutually exclusive, so we could consider an option "--replay-merges=<mode>", but personally I find "--<option>=<value>" arguments harder to use than specifying separate options. I think I'm avoiding your question, because the design of the command line parameters doesn't need tot 1-on-1 correlate to the internal datastructure. And I agree the mode isn't a boolean, but does that mean we want to use an enum internally? Well, I don't know. And I also don't think that matters right now. Code is easy to change, I think the command line options should be designed with the future in mind, which I believe we do with "--linearize". Sorry for this long-winded rambling, but bottom line I think it's fine to add --linearize and in the future add more options and see how the code should evolve to support those. >> diff --git a/replay.c b/replay.c >> index 7921d7dba3..5539daff00 100644 >> --- a/replay.c >> +++ b/replay.c >> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo, >> struct commit *onto, >> struct merge_options *merge_opt, >> struct merge_result *result, >> + struct commit *replayed_base, >> bool reverse, >> enum replay_empty_commit_action empty) >> { >> - struct commit *base, *replayed_base; >> + struct commit *base; >> struct tree *pickme_tree, *base_tree, *replayed_base_tree; >> >> + if (replayed_base && reverse) >> + BUG("Linearizing commits is not supported when replaying in reverse"); > > Nit: Error messages should typically start with a lower-case letter. Thanks. >> @@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs, >> while ((commit = get_revision(revs))) { >> const struct name_decoration *decoration; >> >> - if (commit->parents && commit->parents->next) >> - die(_("replaying merge commits is not supported yet!")); >> + if (commit->parents && commit->parents->next) { >> + if (!opts->linearize) >> + die(_("replaying merge commits is not supported yet!")); >> + /* >> + * Drop the merge commit: do not pick it and leave >> + * last_commit unchanged, so its children (and any ref >> + * pointing at it) are reparented onto the previous >> + * non-merge commit, which the ref-update loop below uses. >> + */ > > One could add a hint here that tells the user to pass the option. But I > guess that might be somewhat weird, as we cannot assume that we're > called by git-replay(1) here. Yeah, true... -- Cheers, Toon ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits 2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes ` (2 preceding siblings ...) 2026-06-22 12:41 ` [PATCH v4 3/3] replay: offer an option to linearize the commit topology Toon Claes @ 2026-06-26 5:48 ` Toon Claes 2026-06-26 5:48 ` [PATCH v5 1/3] replay: add helper to put entry into mapped_commits Toon Claes ` (2 more replies) 3 siblings, 3 replies; 34+ messages in thread From: Toon Claes @ 2026-06-26 5:48 UTC (permalink / raw) To: git; +Cc: Elijah Newren, Toon Claes, Johannes Schindelin, Johannes Schindelin As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does too with --no-rebase-merges (the default). The first two patches do some refactoring. The third patch implements the actual change. This patch was kindly provided by Dscho, which I've tweaked to be upstreamed. The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees. This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3]. dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize". [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im> --- Changes in v5: - Dropped the enum->bool patch and instead added a patch that better explains how pick_regular_commit() picks a base. - Order of commits is shuffled. - (BIGGEST CHANGE) When working on a refactor to undo the enum->bool patch, I extended the code comments to explain how things work. This made me realize the use of the "replayed_base" was incorrect when multiple branches are rebased with --onto. This is fixed now and a test is added for this scenario. - Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com Changes in v4: - Use test_grep instead of a bare grep in the range-diff test, to prepare for mm/test-grep-lint. - Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com Changes in v3: - Add --linearize to Documentation SYNOPSIS, and mention it's incompatible with --revert. - Small language change in help message for --linearize. - Rephrase comment to include last_commit isn't modified when linearizing merges. - Remove test that was added in earlier versions, but actually is a duplicate of 'replaying merge commits is not supported yet'. - Add test to verify --revert and --linearize are incompatible. - Properly test that replaying down to root with --linearize works. - Add test for --linearize with --advance. - Add test that uses git-range-diff(1) to verify the patches created by --linearize are correct. - Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com Changes in v2: - Restructured the conditions to detect merge commits and added a line of comment why the loop continues. - Rewrote tests to use the history from the setup step and added a few test cases. - Re-added Johannes's Signed-off-by trailer. Johannes gave me the patches with this trailer, and if I understand correctly, I can keep it. Please let me know if that wrong. - Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com --- Johannes Schindelin (1): replay: offer an option to linearize the commit topology Toon Claes (2): replay: add helper to put entry into mapped_commits replay: better explain how pick_regular_commit() picks a base Documentation/git-replay.adoc | 8 ++++- builtin/replay.c | 6 +++- replay.c | 69 ++++++++++++++++++++++++++--------- replay.h | 5 +++ t/t3650-replay-basics.sh | 84 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 152 insertions(+), 20 deletions(-) Range-diff versus v4: 1: a08bc22330 < -: ---------- replay: refactor enum replay_mode into a bool 2: 3117fddcc5 = 1: bbd5a710bd replay: add helper to put entry into mapped_commits -: ---------- > 2: e08c7b46c0 replay: better explain how pick_regular_commit() picks a base 3: acbb1df6a9 ! 3: 043cf63c1c replay: offer an option to linearize the commit topology @@ builtin/replay.c: int cmd_replay(int argc, ref_mode = get_ref_action_mode(repo, ref_action); ## replay.c ## -@@ replay.c: static struct commit *pick_regular_commit(struct repository *repo, - struct commit *onto, - struct merge_options *merge_opt, - struct merge_result *result, -+ struct commit *replayed_base, - bool reverse, - enum replay_empty_commit_action empty) - { -- struct commit *base, *replayed_base; -+ struct commit *base; - struct tree *pickme_tree, *base_tree, *replayed_base_tree; - -+ if (replayed_base && reverse) -+ BUG("Linearizing commits is not supported when replaying in reverse"); -+ - if (pickme->parents) { - base = pickme->parents->item; - base_tree = repo_get_commit_tree(repo, base); -@@ replay.c: static struct commit *pick_regular_commit(struct repository *repo, - base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); - } - -- replayed_base = get_mapped_commit(replayed_commits, base, onto); -+ if (!replayed_base) -+ replayed_base = get_mapped_commit(replayed_commits, base, onto); - replayed_base_tree = repo_get_commit_tree(repo, replayed_base); - pickme_tree = repo_get_commit_tree(repo, pickme); - @@ replay.c: int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; +- /* +- * pick_regular_commit() looks up the parent of `commit` in +- * `replayed_commits` to determine the ancestor to replay onto. +- * The `default_base` parameter is used when no ancestor is found, +- * which happens for the first commit in the revision range. +- * When reverting, commits are replayed in reverse order, so the +- * lookup never succeeds, and we need to pass `last_commit`. +- */ +- struct commit *base = onto; +- if (mode == REPLAY_MODE_REVERT) +- base = last_commit; +- - if (commit->parents && commit->parents->next) - die(_("replaying merge commits is not supported yet!")); +- +- last_commit = pick_regular_commit(revs->repo, commit, base, +- replayed_commits, +- &merge_opt, &result, mode, opts->empty); + if (commit->parents && commit->parents->next) { + if (!opts->linearize) + die(_("replaying merge commits is not supported yet!")); + /* -+ * Drop the merge commit: do not pick it and leave -+ * last_commit unchanged, so its children (and any ref -+ * pointing at it) are reparented onto the previous -+ * non-merge commit, which the ref-update loop below uses. ++ * Drop the merge commit: do not pick it, leave ++ * `last_commit` unchanged, and fall through to the ++ * rest of the loop. As a result: ++ * - the merge commit is mapped to `last_commit` in ++ * `replayed_commits`, this will become the parent for ++ * the child commits. ++ * - refs previously pointing to the merge commit are ++ * rewritten to point to the previous non-merge commit. + */ + } else { -+ struct commit *to_pick = reverse ? last_commit : onto; -+ last_commit = -+ pick_regular_commit(revs->repo, commit, -+ replayed_commits, to_pick, -+ &merge_opt, &result, -+ opts->linearize ? last_commit : NULL, -+ reverse, opts->empty); ++ /* ++ * pick_regular_commit() looks up the parent of `commit` in ++ * `replayed_commits` to determine the ancestor to replay onto. ++ * The `default_base` parameter is used when no ancestor is found, ++ * which happens for the first commit in the revision range. ++ * When reverting, commits are replayed in reverse order, so the ++ * lookup never succeeds, and we need to pass `last_commit`. ++ */ ++ struct commit *base = onto; ++ if (mode == REPLAY_MODE_REVERT) ++ base = last_commit; ++ ++ last_commit = pick_regular_commit(revs->repo, commit, base, ++ replayed_commits, ++ &merge_opt, &result, ++ mode, opts->empty); + } - -- last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, -- reverse ? last_commit : onto, -- &merge_opt, &result, reverse, opts->empty); ++ if (!last_commit) break; @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl + git log --oneline main..$tip >out && + test_line_count = 3 out +' ++ ++test_expect_success 'replay with --linearize to rebase multiple divergent branches' ' ++ git replay --ref-action=print --linearize \ ++ --onto main ^B topic2 topic-with-merge >result && ++ ++ test_line_count = 2 result && ++ cut -f 3 -d " " result >new-branch-tips && ++ ++ git log --format=%s $(head -n 1 new-branch-tips) >actual && ++ test_write_lines E D C M L B A >expect && ++ test_cmp expect actual && ++ ++ git log --format=%s $(tail -n 1 new-branch-tips) >actual && ++ test_write_lines O N J I M L B A >expect && ++ test_cmp expect actual ++' + test_done --- base-commit: ab776a62a78576513ee121424adb19597fbb7613 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395 ^ permalink raw reply [flat|nested] 34+ messages in thread
* [PATCH v5 1/3] replay: add helper to put entry into mapped_commits 2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes @ 2026-06-26 5:48 ` Toon Claes 2026-06-26 5:48 ` [PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base Toon Claes 2026-06-26 5:48 ` [PATCH v5 3/3] replay: offer an option to linearize the commit topology Toon Claes 2 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-26 5:48 UTC (permalink / raw) To: git; +Cc: Elijah Newren, Toon Claes The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit(). While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/replay.c b/replay.c index da531d5bc6..7bde1c7e93 100644 --- a/replay.c +++ b/replay.c @@ -250,9 +250,9 @@ static void set_up_replay_mode(struct repository *repo, strset_clear(&rinfo.positive_refs); } -static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, - struct commit *commit, - struct commit *fallback) +static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *fallback) { khint_t pos; if (!commit) @@ -263,6 +263,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, return kh_value(replayed_commits, pos); } +static void put_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *new_commit) +{ + khint_t pos; + int ret; + + pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret); + if (ret == 0) + BUG("Duplicate rewritten commit: %s\n", + oid_to_hex(&commit->object.oid)); + + kh_value(replayed_commits, pos) = new_commit; +} + static struct commit *pick_regular_commit(struct repository *repo, struct commit *pickme, kh_oid_map_t *replayed_commits, @@ -283,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = mapped_commit(replayed_commits, base, onto); + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -423,8 +438,6 @@ int replay_revisions(struct rev_info *revs, replayed_commits = kh_init_oid_map(); while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - khint_t pos; - int hr; if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); @@ -436,11 +449,7 @@ int replay_revisions(struct rev_info *revs, break; /* Record commit -> last_commit mapping */ - pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr); - if (hr == 0) - BUG("Duplicate rewritten commit: %s\n", - oid_to_hex(&commit->object.oid)); - kh_value(replayed_commits, pos) = last_commit; + put_mapped_commit(replayed_commits, commit, last_commit); /* Update any necessary branches */ if (ref) -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* [PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base 2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-26 5:48 ` [PATCH v5 1/3] replay: add helper to put entry into mapped_commits Toon Claes @ 2026-06-26 5:48 ` Toon Claes 2026-06-26 5:48 ` [PATCH v5 3/3] replay: offer an option to linearize the commit topology Toon Claes 2 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-26 5:48 UTC (permalink / raw) To: git; +Cc: Elijah Newren, Toon Claes The function pick_regular_commit() will replay the `pickme` commit. To determine the ancestor where to replay this commit on, it takes the parent of the commit and looks up its replayed result in `replayed_commits`. If no ancestor is found, the `onto` parameter is used as fallback. The name `onto` is rather confusing, so rename it to `default_base`. And while at it, shuffle the function parameters so `struct commit` parameters are immediate siblings. When in mode REPLAY_MODE_REVERT, the fallback `default_base` will always be used. This happens because commits are replayed in reverse order, so looking up the `pickme`'s parent in `replayed_commits` will always return empty. And to make these commits stack on top of each other, we need to pass in `last_commit`. Signed-off-by: Toon Claes <toon@iotcl.com> --- replay.c | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/replay.c b/replay.c index 7bde1c7e93..86fba47fb9 100644 --- a/replay.c +++ b/replay.c @@ -280,8 +280,8 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits, static struct commit *pick_regular_commit(struct repository *repo, struct commit *pickme, + struct commit *default_base, kh_oid_map_t *replayed_commits, - struct commit *onto, struct merge_options *merge_opt, struct merge_result *result, enum replay_mode mode, @@ -298,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = get_mapped_commit(replayed_commits, base, onto); + replayed_base = get_mapped_commit(replayed_commits, base, default_base); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -439,11 +439,23 @@ int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; + /* + * pick_regular_commit() looks up the parent of `commit` in + * `replayed_commits` to determine the ancestor to replay onto. + * The `default_base` parameter is used when no ancestor is found, + * which happens for the first commit in the revision range. + * When reverting, commits are replayed in reverse order, so the + * lookup never succeeds, and we need to pass `last_commit`. + */ + struct commit *base = onto; + if (mode == REPLAY_MODE_REVERT) + base = last_commit; + if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - mode == REPLAY_MODE_REVERT ? last_commit : onto, + last_commit = pick_regular_commit(revs->repo, commit, base, + replayed_commits, &merge_opt, &result, mode, opts->empty); if (!last_commit) break; -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
* [PATCH v5 3/3] replay: offer an option to linearize the commit topology 2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-26 5:48 ` [PATCH v5 1/3] replay: add helper to put entry into mapped_commits Toon Claes 2026-06-26 5:48 ` [PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base Toon Claes @ 2026-06-26 5:48 ` Toon Claes 2 siblings, 0 replies; 34+ messages in thread From: Toon Claes @ 2026-06-26 5:48 UTC (permalink / raw) To: git; +Cc: Elijah Newren, Johannes Schindelin, Toon Claes, Johannes Schindelin From: Johannes Schindelin <Johannes.Schindelin@gmx.de> One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side. The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the commit history into a sequence of the regular (single-parent) commits. Add option `--linearize` to git-replay(1) to do the same. Co-authored-by: Toon Claes <toon@iotcl.com> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> Signed-off-by: Toon Claes <toon@iotcl.com> --- Documentation/git-replay.adoc | 8 ++++- builtin/replay.c | 6 +++- replay.c | 50 ++++++++++++++++---------- replay.h | 5 +++ t/t3650-replay-basics.sh | 84 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 132 insertions(+), 21 deletions(-) diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index a32f72aead..ef56ee0f1b 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -10,7 +10,7 @@ SYNOPSIS -------- [verse] (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>) - [--ref=<ref>] [--ref-action=<mode>] <revision-range> + [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range> DESCRIPTION ----------- @@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only). + The default mode can be configured via the `replay.refAction` configuration variable. +--linearize:: + In this mode, `git replay` imitates `git rebase --no-rebase-merges`, + i.e. it cherry-picks only non-merge commits, each one on top of the + previous one. + This option is incompatible with `--revert`. + <revision-range>:: Range of commits to replay; see "Specifying Ranges" in linkgit:git-rev-parse[1]. In `--advance=<branch>` or diff --git a/builtin/replay.c b/builtin/replay.c index 39e3a86f6c..62962c73c7 100644 --- a/builtin/replay.c +++ b/builtin/replay.c @@ -85,7 +85,7 @@ int cmd_replay(int argc, const char *const replay_usage[] = { N_("(EXPERIMENTAL!) git replay " "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n" - "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"), + "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"), NULL }; struct option replay_options[] = { @@ -111,6 +111,8 @@ int cmd_replay(int argc, N_("mode"), N_("control ref update behavior (update|print)"), PARSE_OPT_NONEG), + OPT_BOOL(0, "linearize", &opts.linearize, + N_("drop merge commits, replaying only non-merge commits")), OPT_END() }; @@ -132,6 +134,8 @@ int cmd_replay(int argc, opts.contained, "--contained"); die_for_incompatible_opt2(!!opts.ref, "--ref", !!opts.contained, "--contained"); + die_for_incompatible_opt2(!!opts.revert, "--revert", + opts.linearize, "--linearize"); /* Parse ref action mode from command line or config */ ref_mode = get_ref_action_mode(repo, ref_action); diff --git a/replay.c b/replay.c index 86fba47fb9..d803e0312f 100644 --- a/replay.c +++ b/replay.c @@ -439,24 +439,38 @@ int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - /* - * pick_regular_commit() looks up the parent of `commit` in - * `replayed_commits` to determine the ancestor to replay onto. - * The `default_base` parameter is used when no ancestor is found, - * which happens for the first commit in the revision range. - * When reverting, commits are replayed in reverse order, so the - * lookup never succeeds, and we need to pass `last_commit`. - */ - struct commit *base = onto; - if (mode == REPLAY_MODE_REVERT) - base = last_commit; - - if (commit->parents && commit->parents->next) - die(_("replaying merge commits is not supported yet!")); - - last_commit = pick_regular_commit(revs->repo, commit, base, - replayed_commits, - &merge_opt, &result, mode, opts->empty); + if (commit->parents && commit->parents->next) { + if (!opts->linearize) + die(_("replaying merge commits is not supported yet!")); + /* + * Drop the merge commit: do not pick it, leave + * `last_commit` unchanged, and fall through to the + * rest of the loop. As a result: + * - the merge commit is mapped to `last_commit` in + * `replayed_commits`, this will become the parent for + * the child commits. + * - refs previously pointing to the merge commit are + * rewritten to point to the previous non-merge commit. + */ + } else { + /* + * pick_regular_commit() looks up the parent of `commit` in + * `replayed_commits` to determine the ancestor to replay onto. + * The `default_base` parameter is used when no ancestor is found, + * which happens for the first commit in the revision range. + * When reverting, commits are replayed in reverse order, so the + * lookup never succeeds, and we need to pass `last_commit`. + */ + struct commit *base = onto; + if (mode == REPLAY_MODE_REVERT) + base = last_commit; + + last_commit = pick_regular_commit(revs->repo, commit, base, + replayed_commits, + &merge_opt, &result, + mode, opts->empty); + } + if (!last_commit) break; diff --git a/replay.h b/replay.h index faf95c7459..64f42b6512 100644 --- a/replay.h +++ b/replay.h @@ -62,6 +62,11 @@ struct replay_revisions_options { * Defaults to REPLAY_EMPTY_COMMIT_DROP. */ enum replay_empty_commit_action empty; + + /* + * Whether to linearize the commits (i.e. drop merge commits). + */ + int linearize; }; /* This struct is used as an out-parameter by `replay_revisions()`. */ diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh index 3353bc4a4d..34c038eab9 100755 --- a/t/t3650-replay-basics.sh +++ b/t/t3650-replay-basics.sh @@ -52,8 +52,12 @@ test_expect_success 'setup' ' test_merge P O --no-ff && git switch main && + git switch --orphan unrelated && + test_commit unrelated-root && + git switch -c conflict B && - test_commit C.conflict C.t conflict + test_commit C.conflict C.t conflict && + git branch -D unrelated ' test_expect_success 'setup bare' ' @@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' ' test_grep "cannot be used together" actual ' +test_expect_success '--revert and --linearize cannot be used together' ' + test_must_fail git replay --revert=main --linearize \ + topic1..topic2 2>actual && + test_grep "cannot be used together" actual +' + test_expect_success 'cannot advance target ... ordering would be ill-defined' ' echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect && test_must_fail git replay --advance=main main topic1 topic2 2>actual && @@ -565,4 +575,76 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' test_grep "cannot be used with multiple revision ranges" err ' +test_expect_success 'replay to rebase merge commit with --linearize' ' + git replay --ref-action=print --linearize \ + --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual +' + +test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' ' + git replay --ref-action=print --linearize \ + --onto unrelated-root topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J I B A unrelated-root >expect && + test_cmp expect actual +' + +test_expect_success 'replay to cherry-pick merge commit with --linearize' ' + git replay --ref-action=print --linearize \ + --advance main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual && + + printf "update refs/heads/main " >expect && + printf "%s " $(cut -f 3 -d " " result) >>expect && + git rev-parse main >>expect && + test_cmp expect result +' + +test_expect_success 'replay --linearize produces the same patches' ' + git replay --ref-action=print --linearize \ + --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + tip=$(cut -f 3 -d " " result) && + + # range-diff does not care about the dropped merge, + # so the original commits (I..topic-with-merge) + # and the replayed chain (main..tip) must produce identical patches. + git range-diff I..topic-with-merge main..$tip >out && + test_file_not_empty out && + test_grep ! -v "=" out && + + git log --oneline main..$tip >out && + test_line_count = 3 out +' + +test_expect_success 'replay with --linearize to rebase multiple divergent branches' ' + git replay --ref-action=print --linearize \ + --onto main ^B topic2 topic-with-merge >result && + + test_line_count = 2 result && + cut -f 3 -d " " result >new-branch-tips && + + git log --format=%s $(head -n 1 new-branch-tips) >actual && + test_write_lines E D C M L B A >expect && + test_cmp expect actual && + + git log --format=%s $(tail -n 1 new-branch-tips) >actual && + test_write_lines O N J I M L B A >expect && + test_cmp expect actual +' + test_done -- 2.53.0.1323.g189a785ab5 ^ permalink raw reply related [flat|nested] 34+ messages in thread
end of thread, other threads:[~2026-06-26 5:48 UTC | newest] Thread overview: 34+ messages (download: mbox.gz follow: Atom feed -- links below jump to the message on this page -- 2026-06-08 18:37 [PATCH 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-08 18:37 ` [PATCH 1/3] replay: refactor enum replay_mode into a bool Toon Claes 2026-06-08 18:37 ` [PATCH 2/3] replay: add helper to put entry into mapped_commits Toon Claes 2026-06-08 18:37 ` [PATCH 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-08 19:29 ` Junio C Hamano 2026-06-10 14:26 ` Toon Claes 2026-06-10 14:49 ` [PATCH v2 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-10 14:49 ` [PATCH v2 1/3] replay: refactor enum replay_mode into a bool Toon Claes 2026-06-11 15:09 ` Justin Tobler 2026-06-12 8:19 ` Toon Claes 2026-06-10 14:49 ` [PATCH v2 2/3] replay: add helper to put entry into mapped_commits Toon Claes 2026-06-10 14:49 ` [PATCH v2 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-10 17:02 ` Junio C Hamano 2026-06-16 8:38 ` Toon Claes 2026-06-14 6:56 ` Elijah Newren 2026-06-16 7:09 ` Toon Claes 2026-06-16 9:26 ` [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-16 9:26 ` [PATCH v3 1/3] replay: refactor enum replay_mode into a bool Toon Claes 2026-06-16 9:26 ` [PATCH v3 2/3] replay: add helper to put entry into mapped_commits Toon Claes 2026-06-16 9:26 ` [PATCH v3 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-22 12:41 ` [PATCH v4 1/3] replay: refactor enum replay_mode into a bool Toon Claes 2026-06-22 13:53 ` Patrick Steinhardt 2026-06-22 15:43 ` Junio C Hamano 2026-06-24 19:15 ` Toon Claes 2026-06-22 12:41 ` [PATCH v4 2/3] replay: add helper to put entry into mapped_commits Toon Claes 2026-06-22 13:53 ` Patrick Steinhardt 2026-06-22 12:41 ` [PATCH v4 3/3] replay: offer an option to linearize the commit topology Toon Claes 2026-06-22 13:53 ` Patrick Steinhardt 2026-06-26 5:36 ` Toon Claes 2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes 2026-06-26 5:48 ` [PATCH v5 1/3] replay: add helper to put entry into mapped_commits Toon Claes 2026-06-26 5:48 ` [PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base Toon Claes 2026-06-26 5:48 ` [PATCH v5 3/3] replay: offer an option to linearize the commit topology Toon Claes
This is a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox