From 633e50c30656f3fc66ed7a54e746a843b1dd7c5a Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Fri, 26 Jun 2026 07:48:11 +0200 Subject: [PATCH 1/3] replay: add helper to put entry into mapped_commits 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 Signed-off-by: Junio C Hamano --- 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) From 7b77b482bbd2c12c60ec2f6c868118c081aff166 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Fri, 26 Jun 2026 07:48:12 +0200 Subject: [PATCH 2/3] replay: better explain how pick_regular_commit() picks a base 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 Signed-off-by: Junio C Hamano --- 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; From 94d7ae4f3802bcd84893f32696f40a193e1a8434 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 26 Jun 2026 07:48:13 +0200 Subject: [PATCH 3/3] replay: offer an option to linearize the commit topology 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 Signed-off-by: Johannes Schindelin Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- Documentation/git-replay.adoc | 8 +++- builtin/replay.c | 6 ++- replay.c | 46 ++++++++++++------- replay.h | 5 +++ t/t3650-replay-basics.sh | 84 ++++++++++++++++++++++++++++++++++- 5 files changed, 130 insertions(+), 19 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= | --advance= | --revert=) - [--ref=] [--ref-action=] + [--ref=] [--ref-action=] [--linearize] 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`. + :: Range of commits to replay; see "Specifying Ranges" in linkgit:git-rev-parse[1]. In `--advance=` 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= | --advance= | --revert=)\n" - "[--ref=] [--ref-action=] "), + "[--ref=] [--ref-action=] [--linearize] "), 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) { + 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; - 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); + } - 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 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..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