{"thread":{"id":"65771","subject":"[PATCH 0/3] Teach git-replay(1) to linearize merge commits","startedAt":"2026-06-08T18:37:45Z","lastAt":"2026-09-01T22:15:34Z","messageCount":75,"participants":["Toon Claes","Junio C Hamano","Justin Tobler","Elijah Newren","Patrick Steinhardt","Phillip Wood","Johannes Schindelin"],"isPatch":true,"patchVersion":1,"patchTotal":3},"messages":[{"id":"544955","messageId":"20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com","threadId":"65771","inReplyTo":null,"subject":"[PATCH 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-08T18:37:18Z","receivedAt":"2026-06-08T18:37:45Z","isPatch":true,"body":"As an alternative to dscho's patch series to replay merges[1], add\noption to git-replay(1) to linearize merges. This mimics wath\ngit-rebase(1) does too with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. I was kindly helped by dscho to implement this\nchange.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion doesn't make much sense to do so,\nbut I'm happy to hear if anyone disagrees.\n\nThis series might conflict with Kristoffer's series to make\ndocumentation changes[2], but should be trivial to resolve. And I don't\nthink there's a conflict with Patrick's series on adding \"drop\" to\ngit-history(1)[3].\n\ndscho's series to replay merges[1] need a bit of rework to fit on top of\nthis, but I'm happy to help figuring that out.\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n[2]: <V2_CV_doc_replay_config.767@msgid.xyz>\n[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\nJohannes Schindelin (1):\n      replay: offer an option to linearize the commit topology\n\nToon Claes (2):\n      replay: refactor enum replay_mode into a bool\n      replay: add helper to put entry into mapped_commits\n\n Documentation/git-replay.adoc |   5 ++\n builtin/replay.c              |   4 ++\n replay.c                      | 109 +++++++++++++++++++++++-------------------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      |  22 +++++++++\n 5 files changed, 97 insertions(+), 48 deletions(-)\n\n\n\n---\nbase-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"544956","messageId":"20260608-toon-git-replay-drop-merges-v1-1-e3ee71fce7b4@iotcl.com","threadId":"65771","inReplyTo":"20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com","subject":"[PATCH 1/3] replay: refactor enum replay_mode into a bool","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-08T18:37:19Z","receivedAt":"2026-06-08T18:37:53Z","isPatch":true,"body":"In 2760ee4983 (replay: add --revert mode to reverse commit changes,\n2026-03-26) the enum `replay_mode` was introduced. This has two possible\nvalues:\n\n - The value `REPLAY_MODE_REVERT` is used when option `--revert` is\n   passed to git-replay(1). When using this value the commits are\n   possible in reverse order and the inverse of the changes are applied.\n\n - The value `REPLAY_MODE_PICK` is used when either option `--onto` or\n   `--advance` is used. In both cases the commits are pocessed in normal\n   order, and the changes are applied as-is.\n\nSince there are only two possible values of this enum, simplify the code\nby converting the enum into a bool. This avoid adding code paths that\ncheck for invalid vaues of the enum, and shortens code where the value\nis checked with a ternary operator.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 59 +++++++++++++++++++++++++----------------------------------\n 1 file changed, 25 insertions(+), 34 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 4ef8abb607..1f8e5b083b 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -18,11 +18,6 @@\n  */\n #define the_repository DO_NOT_USE_THE_REPOSITORY\n \n-enum replay_mode {\n-\tREPLAY_MODE_PICK,\n-\tREPLAY_MODE_REVERT,\n-};\n-\n static const char *short_commit_name(struct repository *repo,\n \t\t\t\t     struct commit *commit)\n {\n@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,\n \t\t\t\t    struct tree *tree,\n \t\t\t\t    struct commit *based_on,\n \t\t\t\t    struct commit *parent,\n-\t\t\t\t    enum replay_mode mode)\n+\t\t\t\t    bool reverse)\n {\n \tstruct object_id ret;\n \tstruct object *obj = NULL;\n@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,\n \n \tcommit_list_insert(parent, &parents);\n \textra = read_commit_extra_headers(based_on, exclude_gpgsig);\n-\tif (mode == REPLAY_MODE_REVERT) {\n+\tif (reverse) {\n \t\tgenerate_revert_message(&msg, based_on, repo);\n \t\t/* For revert, use current user as author (NULL = use default) */\n-\t} else if (mode == REPLAY_MODE_PICK) {\n+\t} else {\n \t\tfind_commit_subject(message, &orig_message);\n \t\tstrbuf_addstr(&msg, orig_message);\n \t\tauthor = get_author(message);\n-\t} else {\n-\t\tBUG(\"unexpected replay mode %d\", mode);\n \t}\n \treset_ident_date();\n \tif (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,\n@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n-\t\t\t\t\t  enum replay_mode mode,\n+\t\t\t\t\t  bool reverse,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n \tstruct commit *base, *replayed_base;\n@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n-\tif (mode == REPLAY_MODE_PICK) {\n+\tif (reverse) {\n+\t\t/* Revert: swap base and pickme to reverse the diff */\n+\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n+\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n+\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n+\t\tmerge_opt->ancestor = pickme_name;\n+\n+\t\tmerge_incore_nonrecursive(merge_opt,\n+\t\t\t\t\t  pickme_tree,\n+\t\t\t\t\t  replayed_base_tree,\n+\t\t\t\t\t  base_tree,\n+\t\t\t\t\t  result);\n+\n+\t\tfree((char *)merge_opt->branch2);\n+\t} else {\n \t\t/* Cherry-pick: normal order */\n \t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n \t\tmerge_opt->branch2 = short_commit_name(repo, pickme);\n@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  result);\n \n \t\tfree((char *)merge_opt->ancestor);\n-\t} else if (mode == REPLAY_MODE_REVERT) {\n-\t\t/* Revert: swap base and pickme to reverse the diff */\n-\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n-\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n-\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n-\t\tmerge_opt->ancestor = pickme_name;\n-\n-\t\tmerge_incore_nonrecursive(merge_opt,\n-\t\t\t\t\t  pickme_tree,\n-\t\t\t\t\t  replayed_base_tree,\n-\t\t\t\t\t  base_tree,\n-\t\t\t\t\t  result);\n-\n-\t\tfree((char *)merge_opt->branch2);\n-\t} else {\n-\t\tBUG(\"unexpected replay mode %d\", mode);\n \t}\n \tmerge_opt->ancestor = NULL;\n \tmerge_opt->branch2 = NULL;\n@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t}\n \t}\n \n-\treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n+\treturn create_commit(repo, result->tree, pickme, replayed_base, reverse);\n }\n \n void replay_result_release(struct replay_result *result)\n@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,\n \tchar *revert;\n \tconst char *ref;\n \tstruct object_id old_oid;\n-\tenum replay_mode mode = REPLAY_MODE_PICK;\n+\tbool reverse;\n \tint ret;\n \n \tadvance = xstrdup_or_null(opts->advance);\n \trevert = xstrdup_or_null(opts->revert);\n-\tif (revert)\n-\t\tmode = REPLAY_MODE_REVERT;\n+\treverse = !!revert;\n+\n \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n \n@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n \t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\t\t\t\t\t  reverse ? last_commit : onto,\n+\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"544957","messageId":"20260608-toon-git-replay-drop-merges-v1-2-e3ee71fce7b4@iotcl.com","threadId":"65771","inReplyTo":"20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com","subject":"[PATCH 2/3] replay: add helper to put entry into mapped_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-08T18:37:20Z","receivedAt":"2026-06-08T18:38:02Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put commit entry into mapped_commits into a helper\nfunction.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 1f8e5b083b..7921d7dba3 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"544958","messageId":"20260608-toon-git-replay-drop-merges-v1-3-e3ee71fce7b4@iotcl.com","threadId":"65771","inReplyTo":"20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com","subject":"[PATCH 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-08T18:37:21Z","receivedAt":"2026-06-08T18:38:05Z","isPatch":true,"body":"From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n\nOne of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearized the commit history into a sequence of the\nregular (single-parent) commits.\n\nAdd option `--linearize` to git-replay(1) do the same.\n\nCo-authored-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  5 +++++\n builtin/replay.c              |  4 ++++\n replay.c                      | 25 +++++++++++++++++++------\n replay.h                      |  5 +++++\n t/t3650-replay-basics.sh      | 22 ++++++++++++++++++++++\n 5 files changed, 55 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex a32f72aead..41c96c7061 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n+\ti.e. it cherry-picks only non-merge commits, each one on top of the\n+\tprevious one.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..fedfe46dc6 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"ignore merge commits instead of replaying them\")),\n \t\tOPT_END()\n \t};\n \n@@ -132,6 +134,8 @@ int cmd_replay(int argc,\n \t\t\t\t  opts.contained, \"--contained\");\n \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n \t\t\t\t  !!opts.contained, \"--contained\");\n+\tdie_for_incompatible_opt2(!!opts.revert, \"--revert\",\n+\t\t\t\t  opts.linearize, \"--linearize\");\n \n \t/* Parse ref action mode from command line or config */\n \tref_mode = get_ref_action_mode(repo, ref_action);\ndiff --git a/replay.c b/replay.c\nindex 7921d7dba3..3e36908131 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n+\t\t\t\t\t  struct commit *replayed_base,\n \t\t\t\t\t  bool reverse,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n-\tstruct commit *base, *replayed_base;\n+\tstruct commit *base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n \n+\tif (replayed_base && reverse)\n+\t\tBUG(\"Linearizing commits is not supported when replaying in reverse\");\n+\n \tif (pickme->parents) {\n \t\tbase = pickme->parents->item;\n \t\tbase_tree = repo_get_commit_tree(repo, base);\n@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n+\tif (!replayed_base)\n+\t\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\tif (commit->parents && commit->parents->next)\n+\t\tif (opts->linearize && (!commit->parents || commit->parents->next))\n+\t\t\t; /* map current commit to the same as the previous commit */\n+\t\telse if (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\telse {\n+\t\t\tstruct commit *to_pick = reverse ? last_commit : onto;\n+\t\t\tlast_commit =\n+\t\t\t\tpick_regular_commit(revs->repo, commit,\n+\t\t\t\t\t\t    replayed_commits, to_pick,\n+\t\t\t\t\t\t    &merge_opt, &result,\n+\t\t\t\t\t\t    opts->linearize ? last_commit : NULL,\n+\t\t\t\t\t\t    reverse, opts->empty);\n+\t\t}\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  reverse ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex 1851a07705..07e6fdcca3 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..c781a3bb1b 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -565,4 +565,26 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'linearize the commit topology' '\n+\ttest_tick &&\n+\tN=$(git commit-tree -m N -p L -p I L:) &&\n+\tN=$(git commit-tree -m N-child -p $N L:) &&\n+\tgit update-ref refs/heads/N $N &&\n+\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto A B..refs/heads/N >out &&\n+\n+\ttest_line_count = 1 out &&\n+\tread N1 N2 N3 N4 <out &&\n+\n+\tcat >expect <<-EOF &&\n+\t* N-child\n+\t* I\n+\t* L\n+\to A\n+\tEOF\n+\tgit log --format=%s --graph --boundary A...$N3 >actual &&\n+\ttest_cmp expect actual\n+'\n+\n test_done\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"544964","messageId":"xmqqtsrcvnjw.fsf@gitster.g","threadId":"65771","inReplyTo":"20260608-toon-git-replay-drop-merges-v1-3-e3ee71fce7b4@iotcl.com","subject":"Re: [PATCH 3/3] replay: offer an option to linearize the commit topology","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-08T19:29:07Z","receivedAt":"2026-06-08T19:29:10Z","isPatch":true,"body":"Toon Claes <toon@iotcl.com> writes:\n\n> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n>\n> One of the stated goals of git-replay(1) is to allow implementing the\n> git-rebase(1) functionality on the server side.\n>\n> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n> was given. This mode drops merge commits instead of replaying them, and\n> linearized the commit history into a sequence of the\n> regular (single-parent) commits.\n\n\"linearized\" -> \"linearizes\"?\n\n>\n> Add option `--linearize` to git-replay(1) do the same.\n\n\"do the same\" -> \"to do the same\"?\n\n> Co-authored-by: Toon Claes <toon@iotcl.com>\n\nThere is no sign-off by any of the authors?\n\n> @@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs,\n>  \twhile ((commit = get_revision(revs))) {\n>  \t\tconst struct name_decoration *decoration;\n>  \n> -\t\tif (commit->parents && commit->parents->next)\n> +\t\tif (opts->linearize && (!commit->parents || commit->parents->next))\n> +\t\t\t; /* map current commit to the same as the previous commit */\n\nThis uses the same treatment on either root commits or merge\ncommits?  If this were a mistake and this wants to handle merges but\nnot roots, shouldn't it be more like\n\n\t\tif (opts->linearize && (commit->parents && commit->parents->next))\n\t\t\t; /* map the merge to the previous */\n\n> +\t\telse if (commit->parents && commit->parents->next)\n>  \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n\nAnd because the next one is also about merges, perhaps the early\npart of this if/else if cascade can be written\n\n\t\tif (commit->parents && commit->parents->next) {\n\t\t\t/* We have a merge */\n\t\t\tif (!opts->linearize)\n\t\t\t\tdie(_(\"can't replay a merge (yet)\"));\n\t\t\t; /* map current to the previous */\n\t\t} else {\n\t\t\t...\n\nwouldn't it?\n\nIf the \"map current to prev\" is applicable to root, any root are\nmapped to the last_commit in the above, and if we saw a root as the\nfirst thing in the loop, last_commit is NULL, we do not do anything\nhere, and after the if/else if/else cascade, we see last_commit is\nNULL and break out of the loop.\n\n> +\t\telse {\n> +\t\t\tstruct commit *to_pick = reverse ? last_commit : onto;\n> +\t\t\tlast_commit =\n> +\t\t\t\tpick_regular_commit(revs->repo, commit,\n> +\t\t\t\t\t\t    replayed_commits, to_pick,\n> +\t\t\t\t\t\t    &merge_opt, &result,\n> +\t\t\t\t\t\t    opts->linearize ? last_commit : NULL,\n> +\t\t\t\t\t\t    reverse, opts->empty);\n> +\t\t}\n>  \n> -\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n> -\t\t\t\t\t\t  reverse ? last_commit : onto,\n> -\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n>  \t\tif (!last_commit)\n>  \t\t\tbreak;\n\n> diff --git a/replay.h b/replay.h\n> index 1851a07705..07e6fdcca3 100644\n> --- a/replay.h\n> +++ b/replay.h\n> @@ -62,6 +62,11 @@ struct replay_revisions_options {\n>  \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n>  \t */\n>  \tenum replay_empty_commit_action empty;\n> +\n> +\t/*\n> +\t * Whether to linearize the commits (i.e. drop merge commits).\n> +\t */\n> +\tint linearize;\n>  };\n>  \n>  /* This struct is used as an out-parameter by `replay_revisions()`. */\n> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n> index 3353bc4a4d..c781a3bb1b 100755\n> --- a/t/t3650-replay-basics.sh\n> +++ b/t/t3650-replay-basics.sh\n> @@ -565,4 +565,26 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n>  \ttest_grep \"cannot be used with multiple revision ranges\" err\n>  '\n>  \n> +test_expect_success 'linearize the commit topology' '\n> +\ttest_tick &&\n> +\tN=$(git commit-tree -m N -p L -p I L:) &&\n> +\tN=$(git commit-tree -m N-child -p $N L:) &&\n> +\tgit update-ref refs/heads/N $N &&\n> +\n> +\tgit replay --ref-action=print --linearize \\\n> +\t\t--onto A B..refs/heads/N >out &&\n> +\n> +\ttest_line_count = 1 out &&\n> +\tread N1 N2 N3 N4 <out &&\n> +\n> +\tcat >expect <<-EOF &&\n> +\t* N-child\n> +\t* I\n> +\t* L\n> +\to A\n> +\tEOF\n> +\tgit log --format=%s --graph --boundary A...$N3 >actual &&\n> +\ttest_cmp expect actual\n> +'\n\nPerhaps we would want to have a test that replays all the way down\nto the root commit?\n"},{"id":"545146","messageId":"87zf12zd33.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"xmqqtsrcvnjw.fsf@gitster.g","subject":"Re: [PATCH 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-10T14:26:08Z","receivedAt":"2026-06-10T14:26:27Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Toon Claes <toon@iotcl.com> writes:\n>\n>> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n>>\n>> One of the stated goals of git-replay(1) is to allow implementing the\n>> git-rebase(1) functionality on the server side.\n>>\n>> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n>> was given. This mode drops merge commits instead of replaying them, and\n>> linearized the commit history into a sequence of the\n>> regular (single-parent) commits.\n>\n> \"linearized\" -> \"linearizes\"?\n\nThanks.\n\n>>\n>> Add option `--linearize` to git-replay(1) do the same.\n>\n> \"do the same\" -> \"to do the same\"?\n\nAck.\n\n>> Co-authored-by: Toon Claes <toon@iotcl.com>\n>\n> There is no sign-off by any of the authors?\n\nMy bad. I'll add mine.\n\n@Johannes, can I re-add yours? I've removed it because I've made some\nchanges on top of the patch you wrote, but if you agree, I'll add your\nSign-off back.\n\n>> @@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs,\n>>  \twhile ((commit = get_revision(revs))) {\n>>  \t\tconst struct name_decoration *decoration;\n>>  \n>> -\t\tif (commit->parents && commit->parents->next)\n>> +\t\tif (opts->linearize && (!commit->parents || commit->parents->next))\n>> +\t\t\t; /* map current commit to the same as the previous commit */\n>\n> This uses the same treatment on either root commits or merge\n> commits?  If this were a mistake and this wants to handle merges but\n> not roots, shouldn't it be more like\n>\n> \t\tif (opts->linearize && (commit->parents && commit->parents->next))\n> \t\t\t; /* map the merge to the previous */\n>\n>> +\t\telse if (commit->parents && commit->parents->next)\n>>  \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n>\n> And because the next one is also about merges, perhaps the early\n> part of this if/else if cascade can be written\n>\n> \t\tif (commit->parents && commit->parents->next) {\n> \t\t\t/* We have a merge */\n> \t\t\tif (!opts->linearize)\n> \t\t\t\tdie(_(\"can't replay a merge (yet)\"));\n> \t\t\t; /* map current to the previous */\n> \t\t} else {\n> \t\t\t...\n>\n> wouldn't it?\n\nThe way it was written in v1 was maybe a bit too smart and hard to\nfollow. I agree with your suggestion and will adopt this (with some\ntweaks) in the next version.\n\n> If the \"map current to prev\" is applicable to root, any root are\n> mapped to the last_commit in the above, and if we saw a root as the\n> first thing in the loop, last_commit is NULL, we do not do anything\n> here, and after the if/else if/else cascade, we see last_commit is\n> NULL and break out of the loop.\n\nYes, good observation. I did not test this.\n\n> Perhaps we would want to have a test that replays all the way down\n> to the root commit?\n\nI'll add it.\n\n-- \nCheers,\nToon\n"},{"id":"545148","messageId":"20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com","threadId":"65771","inReplyTo":"20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com","subject":"[PATCH v2 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-10T14:49:11Z","receivedAt":"2026-06-10T14:49:26Z","isPatch":true,"body":"As an alternative to dscho's patch series to replay merges[1], add\noption to git-replay(1) to linearize merges. This mimics wath\ngit-rebase(1) does too with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. I was kindly helped by dscho to implement this\nchange.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion doesn't make much sense to do so,\nbut I'm happy to hear if anyone disagrees.\n\nThis series might conflict with Kristoffer's series to make\ndocumentation changes[2], but should be trivial to resolve. And I don't\nthink there's a conflict with Patrick's series on adding \"drop\" to\ngit-history(1)[3].\n\ndscho's series to replay merges[1] need a bit of rework to fit on top of\nthis, but I'm happy to help figuring that out. We've been discussing to\neither name the option --flatten or --linearize, but I've decided on\n\"linearize\" because the documentation of git-rebase(1) also mentions\n\"linearize\".\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n[2]: <V2_CV_doc_replay_config.767@msgid.xyz>\n[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\nChanges in v2:\n- Restructured the conditions to detect merge commits and added a line\n  of comment why the loop continues.\n- Rewrote tests to use the history from the setup step and added a few\n  test cases.\n- Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n  patches with this trailer, and if I understand correctly, I can keep\n  it. Please let me know if that wrong.\n- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n\n---\nJohannes Schindelin (1):\n      replay: offer an option to linearize the commit topology\n\nToon Claes (2):\n      replay: refactor enum replay_mode into a bool\n      replay: add helper to put entry into mapped_commits\n\n Documentation/git-replay.adoc |   5 ++\n builtin/replay.c              |   4 ++\n replay.c                      | 114 ++++++++++++++++++++++++------------------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      |  26 ++++++++++\n 5 files changed, 105 insertions(+), 49 deletions(-)\n\nRange-diff versus v1:\n\n1:  7f3bc6f425 ! 1:  0975b142e3 replay: refactor enum replay_mode into a bool\n    @@ Commit message\n     \n          - The value `REPLAY_MODE_REVERT` is used when option `--revert` is\n            passed to git-replay(1). When using this value the commits are\n    -       possible in reverse order and the inverse of the changes are applied.\n    +       processed in reverse order and the inverse of the changes are\n    +       applied.\n     \n          - The value `REPLAY_MODE_PICK` is used when either option `--onto` or\n    -       `--advance` is used. In both cases the commits are pocessed in normal\n    -       order, and the changes are applied as-is.\n    +       `--advance` is used. In both cases the commits are processed in\n    +       normal order, and the changes are applied as-is.\n     \n         Since there are only two possible values of this enum, simplify the code\n    -    by converting the enum into a bool. This avoid adding code paths that\n    -    check for invalid vaues of the enum, and shortens code where the value\n    +    by converting the enum into a bool. This avoids adding code paths that\n    +    check for invalid values of the enum, and shortens code where the value\n         is checked with a ternary operator.\n     \n         Signed-off-by: Toon Claes <toon@iotcl.com>\n2:  0868871c78 ! 2:  db88193624 replay: add helper to put entry into mapped_commits\n    @@ Commit message\n         replay: add helper to put entry into mapped_commits\n     \n         The function replay_revisions() in replay.c is rather lengthy. Extract\n    -    the logic to put commit entry into mapped_commits into a helper\n    -    function.\n    +    the logic to put a commit entry into mapped_commits into a helper\n    +    function put_mapped_commit().\n    +\n    +    While at it, rename mapped_commit() to get_mapped_commit() to pair with\n    +    this new function.\n     \n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n3:  a432ae753b ! 3:  d0c220ec8e replay: offer an option to linearize the commit topology\n    @@ Commit message\n     \n         The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n         was given. This mode drops merge commits instead of replaying them, and\n    -    linearized the commit history into a sequence of the\n    +    linearizes the commit history into a sequence of the\n         regular (single-parent) commits.\n     \n    -    Add option `--linearize` to git-replay(1) do the same.\n    +    Add option `--linearize` to git-replay(1) to do the same.\n     \n         Co-authored-by: Toon Claes <toon@iotcl.com>\n    +    Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n    +    Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n      ## Documentation/git-replay.adoc ##\n     @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).\n    @@ replay.c: int replay_revisions(struct rev_info *revs,\n      \t\tconst struct name_decoration *decoration;\n      \n     -\t\tif (commit->parents && commit->parents->next)\n    -+\t\tif (opts->linearize && (!commit->parents || commit->parents->next))\n    -+\t\t\t; /* map current commit to the same as the previous commit */\n    -+\t\telse if (commit->parents && commit->parents->next)\n    - \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n    -+\t\telse {\n    +-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n    ++\t\tif (commit->parents && commit->parents->next) {\n    ++\t\t\tif (!opts->linearize)\n    ++\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n    ++\t\t\t/*\n    ++\t\t\t * When linearizing, a merge commit itself is not picked,\n    ++\t\t\t * but refs that point to it might need updating.\n    ++\t\t\t */\n    ++\t\t} else {\n     +\t\t\tstruct commit *to_pick = reverse ? last_commit : onto;\n     +\t\t\tlast_commit =\n     +\t\t\t\tpick_regular_commit(revs->repo, commit,\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n      \ttest_grep \"cannot be used with multiple revision ranges\" err\n      '\n      \n    -+test_expect_success 'linearize the commit topology' '\n    -+\ttest_tick &&\n    -+\tN=$(git commit-tree -m N -p L -p I L:) &&\n    -+\tN=$(git commit-tree -m N-child -p $N L:) &&\n    -+\tgit update-ref refs/heads/N $N &&\n    ++test_expect_success 'replay merge commit fails' '\n    ++\techo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n    ++\ttest_must_fail git replay --ref-action=print --onto main I..P 2>actual &&\n    ++\ttest_cmp expect actual\n    ++'\n    ++\n    ++test_expect_success 'replay to rebase merge commit with --linearize' '\n    ++\tgit replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&\n    ++\n    ++\ttest_line_count = 1 result &&\n    ++\n    ++\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n    ++\ttest_write_lines O N J M L B A >expect &&\n    ++\ttest_cmp expect actual\n    ++'\n     +\n    -+\tgit replay --ref-action=print --linearize \\\n    -+\t\t--onto A B..refs/heads/N >out &&\n    ++test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '\n    ++\tgit replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&\n     +\n    -+\ttest_line_count = 1 out &&\n    -+\tread N1 N2 N3 N4 <out &&\n    ++\ttest_line_count = 1 result &&\n     +\n    -+\tcat >expect <<-EOF &&\n    -+\t* N-child\n    -+\t* I\n    -+\t* L\n    -+\to A\n    -+\tEOF\n    -+\tgit log --format=%s --graph --boundary A...$N3 >actual &&\n    ++\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n    ++\ttest_write_lines O N J I M L B A >expect &&\n     +\ttest_cmp expect actual\n     +'\n     +\n\n\n---\nbase-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"545149","messageId":"20260610-toon-git-replay-drop-merges-v2-1-5714a71c6d83@iotcl.com","threadId":"65771","inReplyTo":"20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com","subject":"[PATCH v2 1/3] replay: refactor enum replay_mode into a bool","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-10T14:49:12Z","receivedAt":"2026-06-10T14:49:30Z","isPatch":true,"body":"In 2760ee4983 (replay: add --revert mode to reverse commit changes,\n2026-03-26) the enum `replay_mode` was introduced. This has two possible\nvalues:\n\n - The value `REPLAY_MODE_REVERT` is used when option `--revert` is\n   passed to git-replay(1). When using this value the commits are\n   processed in reverse order and the inverse of the changes are\n   applied.\n\n - The value `REPLAY_MODE_PICK` is used when either option `--onto` or\n   `--advance` is used. In both cases the commits are processed in\n   normal order, and the changes are applied as-is.\n\nSince there are only two possible values of this enum, simplify the code\nby converting the enum into a bool. This avoids adding code paths that\ncheck for invalid values of the enum, and shortens code where the value\nis checked with a ternary operator.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 59 +++++++++++++++++++++++++----------------------------------\n 1 file changed, 25 insertions(+), 34 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 4ef8abb607..1f8e5b083b 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -18,11 +18,6 @@\n  */\n #define the_repository DO_NOT_USE_THE_REPOSITORY\n \n-enum replay_mode {\n-\tREPLAY_MODE_PICK,\n-\tREPLAY_MODE_REVERT,\n-};\n-\n static const char *short_commit_name(struct repository *repo,\n \t\t\t\t     struct commit *commit)\n {\n@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,\n \t\t\t\t    struct tree *tree,\n \t\t\t\t    struct commit *based_on,\n \t\t\t\t    struct commit *parent,\n-\t\t\t\t    enum replay_mode mode)\n+\t\t\t\t    bool reverse)\n {\n \tstruct object_id ret;\n \tstruct object *obj = NULL;\n@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,\n \n \tcommit_list_insert(parent, &parents);\n \textra = read_commit_extra_headers(based_on, exclude_gpgsig);\n-\tif (mode == REPLAY_MODE_REVERT) {\n+\tif (reverse) {\n \t\tgenerate_revert_message(&msg, based_on, repo);\n \t\t/* For revert, use current user as author (NULL = use default) */\n-\t} else if (mode == REPLAY_MODE_PICK) {\n+\t} else {\n \t\tfind_commit_subject(message, &orig_message);\n \t\tstrbuf_addstr(&msg, orig_message);\n \t\tauthor = get_author(message);\n-\t} else {\n-\t\tBUG(\"unexpected replay mode %d\", mode);\n \t}\n \treset_ident_date();\n \tif (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,\n@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n-\t\t\t\t\t  enum replay_mode mode,\n+\t\t\t\t\t  bool reverse,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n \tstruct commit *base, *replayed_base;\n@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n-\tif (mode == REPLAY_MODE_PICK) {\n+\tif (reverse) {\n+\t\t/* Revert: swap base and pickme to reverse the diff */\n+\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n+\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n+\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n+\t\tmerge_opt->ancestor = pickme_name;\n+\n+\t\tmerge_incore_nonrecursive(merge_opt,\n+\t\t\t\t\t  pickme_tree,\n+\t\t\t\t\t  replayed_base_tree,\n+\t\t\t\t\t  base_tree,\n+\t\t\t\t\t  result);\n+\n+\t\tfree((char *)merge_opt->branch2);\n+\t} else {\n \t\t/* Cherry-pick: normal order */\n \t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n \t\tmerge_opt->branch2 = short_commit_name(repo, pickme);\n@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  result);\n \n \t\tfree((char *)merge_opt->ancestor);\n-\t} else if (mode == REPLAY_MODE_REVERT) {\n-\t\t/* Revert: swap base and pickme to reverse the diff */\n-\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n-\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n-\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n-\t\tmerge_opt->ancestor = pickme_name;\n-\n-\t\tmerge_incore_nonrecursive(merge_opt,\n-\t\t\t\t\t  pickme_tree,\n-\t\t\t\t\t  replayed_base_tree,\n-\t\t\t\t\t  base_tree,\n-\t\t\t\t\t  result);\n-\n-\t\tfree((char *)merge_opt->branch2);\n-\t} else {\n-\t\tBUG(\"unexpected replay mode %d\", mode);\n \t}\n \tmerge_opt->ancestor = NULL;\n \tmerge_opt->branch2 = NULL;\n@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t}\n \t}\n \n-\treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n+\treturn create_commit(repo, result->tree, pickme, replayed_base, reverse);\n }\n \n void replay_result_release(struct replay_result *result)\n@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,\n \tchar *revert;\n \tconst char *ref;\n \tstruct object_id old_oid;\n-\tenum replay_mode mode = REPLAY_MODE_PICK;\n+\tbool reverse;\n \tint ret;\n \n \tadvance = xstrdup_or_null(opts->advance);\n \trevert = xstrdup_or_null(opts->revert);\n-\tif (revert)\n-\t\tmode = REPLAY_MODE_REVERT;\n+\treverse = !!revert;\n+\n \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n \n@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n \t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\t\t\t\t\t  reverse ? last_commit : onto,\n+\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"545150","messageId":"20260610-toon-git-replay-drop-merges-v2-2-5714a71c6d83@iotcl.com","threadId":"65771","inReplyTo":"20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com","subject":"[PATCH v2 2/3] replay: add helper to put entry into mapped_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-10T14:49:13Z","receivedAt":"2026-06-10T14:49:33Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put a commit entry into mapped_commits into a helper\nfunction put_mapped_commit().\n\nWhile at it, rename mapped_commit() to get_mapped_commit() to pair with\nthis new function.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 1f8e5b083b..7921d7dba3 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"545151","messageId":"20260610-toon-git-replay-drop-merges-v2-3-5714a71c6d83@iotcl.com","threadId":"65771","inReplyTo":"20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com","subject":"[PATCH v2 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-10T14:49:14Z","receivedAt":"2026-06-10T14:50:16Z","isPatch":true,"body":"From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n\nOne of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearizes the commit history into a sequence of the\nregular (single-parent) commits.\n\nAdd option `--linearize` to git-replay(1) to do the same.\n\nCo-authored-by: Toon Claes <toon@iotcl.com>\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  5 +++++\n builtin/replay.c              |  4 ++++\n replay.c                      | 30 +++++++++++++++++++++++-------\n replay.h                      |  5 +++++\n t/t3650-replay-basics.sh      | 26 ++++++++++++++++++++++++++\n 5 files changed, 63 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex a32f72aead..41c96c7061 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n+\ti.e. it cherry-picks only non-merge commits, each one on top of the\n+\tprevious one.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..fedfe46dc6 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"ignore merge commits instead of replaying them\")),\n \t\tOPT_END()\n \t};\n \n@@ -132,6 +134,8 @@ int cmd_replay(int argc,\n \t\t\t\t  opts.contained, \"--contained\");\n \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n \t\t\t\t  !!opts.contained, \"--contained\");\n+\tdie_for_incompatible_opt2(!!opts.revert, \"--revert\",\n+\t\t\t\t  opts.linearize, \"--linearize\");\n \n \t/* Parse ref action mode from command line or config */\n \tref_mode = get_ref_action_mode(repo, ref_action);\ndiff --git a/replay.c b/replay.c\nindex 7921d7dba3..81033fb889 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n+\t\t\t\t\t  struct commit *replayed_base,\n \t\t\t\t\t  bool reverse,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n-\tstruct commit *base, *replayed_base;\n+\tstruct commit *base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n \n+\tif (replayed_base && reverse)\n+\t\tBUG(\"Linearizing commits is not supported when replaying in reverse\");\n+\n \tif (pickme->parents) {\n \t\tbase = pickme->parents->item;\n \t\tbase_tree = repo_get_commit_tree(repo, base);\n@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n+\tif (!replayed_base)\n+\t\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\tif (commit->parents && commit->parents->next)\n-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (!opts->linearize)\n+\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\t\t/*\n+\t\t\t * When linearizing, a merge commit itself is not picked,\n+\t\t\t * but refs that point to it might need updating.\n+\t\t\t */\n+\t\t} else {\n+\t\t\tstruct commit *to_pick = reverse ? last_commit : onto;\n+\t\t\tlast_commit =\n+\t\t\t\tpick_regular_commit(revs->repo, commit,\n+\t\t\t\t\t\t    replayed_commits, to_pick,\n+\t\t\t\t\t\t    &merge_opt, &result,\n+\t\t\t\t\t\t    opts->linearize ? last_commit : NULL,\n+\t\t\t\t\t\t    reverse, opts->empty);\n+\t\t}\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  reverse ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex 1851a07705..07e6fdcca3 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..64e0731188 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'replay merge commit fails' '\n+\techo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n+\ttest_must_fail git replay --ref-action=print --onto main I..P 2>actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '\n+\tgit replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J I M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n test_done\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"545172","messageId":"xmqqjys6wcpo.fsf@gitster.g","threadId":"65771","inReplyTo":"20260610-toon-git-replay-drop-merges-v2-3-5714a71c6d83@iotcl.com","subject":"Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-10T17:02:27Z","receivedAt":"2026-06-10T17:02:29Z","isPatch":true,"body":"Toon Claes <toon@iotcl.com> writes:\n\n> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n>\n> One of the stated goals of git-replay(1) is to allow implementing the\n> git-rebase(1) functionality on the server side.\n>\n> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n> was given. This mode drops merge commits instead of replaying them, and\n> linearizes the commit history into a sequence of the\n> regular (single-parent) commits.\n>\n> Add option `--linearize` to git-replay(1) to do the same.\n>\n> Co-authored-by: Toon Claes <toon@iotcl.com>\n> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n>  Documentation/git-replay.adoc |  5 +++++\n>  builtin/replay.c              |  4 ++++\n>  replay.c                      | 30 +++++++++++++++++++++++-------\n>  replay.h                      |  5 +++++\n>  t/t3650-replay-basics.sh      | 26 ++++++++++++++++++++++++++\n>  5 files changed, 63 insertions(+), 7 deletions(-)\n>\n> @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs,\n>  \twhile ((commit = get_revision(revs))) {\n>  \t\tconst struct name_decoration *decoration;\n>  \n> -\t\tif (commit->parents && commit->parents->next)\n> -\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n> +\t\tif (commit->parents && commit->parents->next) {\n> +\t\t\tif (!opts->linearize)\n> +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n> +\t\t\t/*\n> +\t\t\t * When linearizing, a merge commit itself is not picked,\n> +\t\t\t * but refs that point to it might need updating.\n> +\t\t\t */\n\nIn the review response during the previous iteration, I commented\nthat (1) the original excluded only merges, but (2) your version\nexcluded both merges and the root commits the same way.  Your\nresponse was:\n\n    The way it was written in v1 was maybe a bit too smart and hard to\n    follow. I agree with your suggestion and will adopt this (with some\n    tweaks) in the next version.\n\nwhich I took as saying \"it may be confusing, but it correctly\nexpresses what we want to do\", meaning \"yes, roots and merges should\nbe handled the same way\".  But the above no longer treats roots the\nsame way as merges.  I think that is intended, but just wanted to\ndouble check.\n\n> diff --git a/replay.h b/replay.h\n> index 1851a07705..07e6fdcca3 100644\n> --- a/replay.h\n> +++ b/replay.h\n> @@ -62,6 +62,11 @@ struct replay_revisions_options {\n>  \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n>  \t */\n>  \tenum replay_empty_commit_action empty;\n> +\n> +\t/*\n> +\t * Whether to linearize the commits (i.e. drop merge commits).\n> +\t */\n> +\tint linearize;\n>  };\n\nOK.\n\n> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n> index 3353bc4a4d..64e0731188 100755\n> --- a/t/t3650-replay-basics.sh\n> +++ b/t/t3650-replay-basics.sh\n> @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n>  \ttest_grep \"cannot be used with multiple revision ranges\" err\n>  '\n>  \n> +test_expect_success 'replay merge commit fails' '\n> +\techo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n> +\ttest_must_fail git replay --ref-action=print --onto main I..P 2>actual &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success 'replay to rebase merge commit with --linearize' '\n> +\tgit replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&\n> +\n> +\ttest_line_count = 1 result &&\n> +\n> +\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n> +\ttest_write_lines O N J M L B A >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '\n> +\tgit replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&\n\nAs with other test pieces, this \"git replay\" command line is overly\nlong and hides the important bit which is that the range being\nreplayed is *not* actually down to the root, which is A (it excludes\nA).  Intended?\n\n> +\n> +\ttest_line_count = 1 result &&\n> +\n> +\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n> +\ttest_write_lines O N J I M L B A >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n>  test_done\n\nThanks.\n"},{"id":"545286","messageId":"airORumUxyTsN7Bz@denethor","threadId":"65771","inReplyTo":"20260610-toon-git-replay-drop-merges-v2-1-5714a71c6d83@iotcl.com","subject":"Re: [PATCH v2 1/3] replay: refactor enum replay_mode into a bool","fromName":"Justin Tobler","fromEmail":"jltobler@gmail.com","sentAt":"2026-06-11T15:09:13Z","receivedAt":"2026-06-11T15:09:18Z","isPatch":true,"body":"On 26/06/10 04:49PM, Toon Claes wrote:\n> In 2760ee4983 (replay: add --revert mode to reverse commit changes,\n> 2026-03-26) the enum `replay_mode` was introduced. This has two possible\n> values:\n> \n>  - The value `REPLAY_MODE_REVERT` is used when option `--revert` is\n>    passed to git-replay(1). When using this value the commits are\n>    processed in reverse order and the inverse of the changes are\n>    applied.\n> \n>  - The value `REPLAY_MODE_PICK` is used when either option `--onto` or\n>    `--advance` is used. In both cases the commits are processed in\n>    normal order, and the changes are applied as-is.\n> \n> Since there are only two possible values of this enum, simplify the code\n> by converting the enum into a bool. This avoids adding code paths that\n> check for invalid values of the enum, and shortens code where the value\n> is checked with a ternary operator.\n\nNaive question: Do we expect there to only be two replay modes in the\nforseeable future? I suppose if other modes were added in the future\nthis change would essentially be reverted.\n\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n>  replay.c | 59 +++++++++++++++++++++++++----------------------------------\n>  1 file changed, 25 insertions(+), 34 deletions(-)\n> \n> diff --git a/replay.c b/replay.c\n> index 4ef8abb607..1f8e5b083b 100644\n> --- a/replay.c\n> +++ b/replay.c\n> @@ -18,11 +18,6 @@\n>   */\n>  #define the_repository DO_NOT_USE_THE_REPOSITORY\n>  \n> -enum replay_mode {\n> -\tREPLAY_MODE_PICK,\n> -\tREPLAY_MODE_REVERT,\n> -};\n> -\n>  static const char *short_commit_name(struct repository *repo,\n>  \t\t\t\t     struct commit *commit)\n>  {\n> @@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,\n>  \t\t\t\t    struct tree *tree,\n>  \t\t\t\t    struct commit *based_on,\n>  \t\t\t\t    struct commit *parent,\n> -\t\t\t\t    enum replay_mode mode)\n> +\t\t\t\t    bool reverse)\n>  {\n>  \tstruct object_id ret;\n>  \tstruct object *obj = NULL;\n> @@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,\n>  \n>  \tcommit_list_insert(parent, &parents);\n>  \textra = read_commit_extra_headers(based_on, exclude_gpgsig);\n> -\tif (mode == REPLAY_MODE_REVERT) {\n> +\tif (reverse) {\n>  \t\tgenerate_revert_message(&msg, based_on, repo);\n>  \t\t/* For revert, use current user as author (NULL = use default) */\n> -\t} else if (mode == REPLAY_MODE_PICK) {\n> +\t} else {\n>  \t\tfind_commit_subject(message, &orig_message);\n>  \t\tstrbuf_addstr(&msg, orig_message);\n>  \t\tauthor = get_author(message);\n> -\t} else {\n> -\t\tBUG(\"unexpected replay mode %d\", mode);\n>  \t}\n>  \treset_ident_date();\n>  \tif (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,\n> @@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>  \t\t\t\t\t  struct commit *onto,\n>  \t\t\t\t\t  struct merge_options *merge_opt,\n>  \t\t\t\t\t  struct merge_result *result,\n> -\t\t\t\t\t  enum replay_mode mode,\n> +\t\t\t\t\t  bool reverse,\n>  \t\t\t\t\t  enum replay_empty_commit_action empty)\n>  {\n>  \tstruct commit *base, *replayed_base;\n> @@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>  \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n>  \tpickme_tree = repo_get_commit_tree(repo, pickme);\n>  \n> -\tif (mode == REPLAY_MODE_PICK) {\n> +\tif (reverse) {\n> +\t\t/* Revert: swap base and pickme to reverse the diff */\n> +\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n> +\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n> +\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n> +\t\tmerge_opt->ancestor = pickme_name;\n> +\n> +\t\tmerge_incore_nonrecursive(merge_opt,\n> +\t\t\t\t\t  pickme_tree,\n> +\t\t\t\t\t  replayed_base_tree,\n> +\t\t\t\t\t  base_tree,\n> +\t\t\t\t\t  result);\n> +\n> +\t\tfree((char *)merge_opt->branch2);\n> +\t} else {\n>  \t\t/* Cherry-pick: normal order */\n>  \t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n>  \t\tmerge_opt->branch2 = short_commit_name(repo, pickme);\n> @@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>  \t\t\t\t\t  result);\n>  \n>  \t\tfree((char *)merge_opt->ancestor);\n> -\t} else if (mode == REPLAY_MODE_REVERT) {\n> -\t\t/* Revert: swap base and pickme to reverse the diff */\n> -\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n> -\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n> -\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n> -\t\tmerge_opt->ancestor = pickme_name;\n> -\n> -\t\tmerge_incore_nonrecursive(merge_opt,\n> -\t\t\t\t\t  pickme_tree,\n> -\t\t\t\t\t  replayed_base_tree,\n> -\t\t\t\t\t  base_tree,\n> -\t\t\t\t\t  result);\n> -\n> -\t\tfree((char *)merge_opt->branch2);\n> -\t} else {\n> -\t\tBUG(\"unexpected replay mode %d\", mode);\n>  \t}\n>  \tmerge_opt->ancestor = NULL;\n>  \tmerge_opt->branch2 = NULL;\n> @@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>  \t\t}\n>  \t}\n>  \n> -\treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n> +\treturn create_commit(repo, result->tree, pickme, replayed_base, reverse);\n>  }\n>  \n>  void replay_result_release(struct replay_result *result)\n> @@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,\n>  \tchar *revert;\n>  \tconst char *ref;\n>  \tstruct object_id old_oid;\n> -\tenum replay_mode mode = REPLAY_MODE_PICK;\n> +\tbool reverse;\n>  \tint ret;\n>  \n>  \tadvance = xstrdup_or_null(opts->advance);\n>  \trevert = xstrdup_or_null(opts->revert);\n> -\tif (revert)\n> -\t\tmode = REPLAY_MODE_REVERT;\n> +\treverse = !!revert;\n> +\n>  \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n>  \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n>  \n> @@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,\n>  \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n>  \n>  \t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n> -\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n> -\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n> +\t\t\t\t\t\t  reverse ? last_commit : onto,\n> +\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n>  \t\tif (!last_commit)\n>  \t\t\tbreak;\n\nThe patch itself looks trivially correct.\n\n-Justin\n"},{"id":"545352","messageId":"87h5n8yxur.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"airORumUxyTsN7Bz@denethor","subject":"Re: [PATCH v2 1/3] replay: refactor enum replay_mode into a bool","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-12T08:19:40Z","receivedAt":"2026-06-12T08:20:10Z","isPatch":true,"body":"Justin Tobler <jltobler@gmail.com> writes:\n\n> Naive question: Do we expect there to only be two replay modes in the\n> forseeable future? I suppose if other modes were added in the future\n> this change would essentially be reverted.\n\nThe enum was mainly used to determine \"direction\": PICK to apply commits\nforward, and REVERT to apply them in opposite order. But it's a bit\ntwofold, because REVERT also applies the inverse change. At the moment\n--onto and --advance use PICK and --revert uses REVERT. There could be\nadded more options in the future, but I don't expect any of them to add\na new mode. And if there is ever a new mode needed, I think it's better\nto re-add the enum then, or maybe a second bool makes sense then, who\nknows...\n\n-- \nCheers,\nToon\n"},{"id":"545484","messageId":"CABPp-BGRi2obnqRGEY9pSMyvRbNGs8AdVUpZmr0C6vZSgHb=cg@mail.gmail.com","threadId":"65771","inReplyTo":"20260610-toon-git-replay-drop-merges-v2-3-5714a71c6d83@iotcl.com","subject":"Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology","fromName":"Elijah Newren","fromEmail":"newren@gmail.com","sentAt":"2026-06-14T06:56:04Z","receivedAt":"2026-06-14T06:56:16Z","isPatch":true,"body":"Hi,\n\nOn Wed, Jun 10, 2026 at 7:51 AM Toon Claes <toon@iotcl.com> wrote:\n>\n> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n>\n> One of the stated goals of git-replay(1) is to allow implementing the\n> git-rebase(1) functionality on the server side.\n>\n> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n> was given. This mode drops merge commits instead of replaying them, and\n> linearizes the commit history into a sequence of the\n> regular (single-parent) commits.\n>\n> Add option `--linearize` to git-replay(1) to do the same.\n\nI think this version is nicer overall than the one from my\nreplay-upstream branch; sorry for repeatedly getting distracted from\nthat, but this does look nice.\n\nA few small comments:\n\n> Co-authored-by: Toon Claes <toon@iotcl.com>\n> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n>  Documentation/git-replay.adoc |  5 +++++\n>  builtin/replay.c              |  4 ++++\n>  replay.c                      | 30 +++++++++++++++++++++++-------\n>  replay.h                      |  5 +++++\n>  t/t3650-replay-basics.sh      | 26 ++++++++++++++++++++++++++\n>  5 files changed, 63 insertions(+), 7 deletions(-)\n>\n> diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\n> index a32f72aead..41c96c7061 100644\n> --- a/Documentation/git-replay.adoc\n> +++ b/Documentation/git-replay.adoc\n> @@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n>  +\n>  The default mode can be configured via the `replay.refAction` configuration variable.\n>\n> +--linearize::\n> +       In this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n> +       i.e. it cherry-picks only non-merge commits, each one on top of the\n> +       previous one.\n\nThe SYNOPSIS block at the top of the file is missing this new flag.\n\nThe replay_usage[] variable in cmd_replay is also missing this new flag.\n\n>  <revision-range>::\n>         Range of commits to replay; see \"Specifying Ranges\" in\n>         linkgit:git-rev-parse[1]. In `--advance=<branch>` or\n> diff --git a/builtin/replay.c b/builtin/replay.c\n> index 39e3a86f6c..fedfe46dc6 100644\n> --- a/builtin/replay.c\n> +++ b/builtin/replay.c\n> @@ -111,6 +111,8 @@ int cmd_replay(int argc,\n>                              N_(\"mode\"),\n>                              N_(\"control ref update behavior (update|print)\"),\n>                              PARSE_OPT_NONEG),\n> +               OPT_BOOL(0, \"linearize\", &opts.linearize,\n> +                        N_(\"ignore merge commits instead of replaying them\")),\n\n\"ignore\" feels a bit ambiguous to me.  Can we use \"drop\" instead,\nmatching your commit message?\n\n>                 OPT_END()\n>         };\n>\n> @@ -132,6 +134,8 @@ int cmd_replay(int argc,\n>                                   opts.contained, \"--contained\");\n>         die_for_incompatible_opt2(!!opts.ref, \"--ref\",\n>                                   !!opts.contained, \"--contained\");\n> +       die_for_incompatible_opt2(!!opts.revert, \"--revert\",\n> +                                 opts.linearize, \"--linearize\");\n\nSensible; should the docs mention this incompatibility?  (I'm not sure\nmyself; just throwing it out as food for thought.)\n\n>\n>         /* Parse ref action mode from command line or config */\n>         ref_mode = get_ref_action_mode(repo, ref_action);\n> diff --git a/replay.c b/replay.c\n> index 7921d7dba3..81033fb889 100644\n> --- a/replay.c\n> +++ b/replay.c\n> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>                                           struct commit *onto,\n>                                           struct merge_options *merge_opt,\n>                                           struct merge_result *result,\n> +                                         struct commit *replayed_base,\n>                                           bool reverse,\n>                                           enum replay_empty_commit_action empty)\n>  {\n> -       struct commit *base, *replayed_base;\n> +       struct commit *base;\n>         struct tree *pickme_tree, *base_tree, *replayed_base_tree;\n>\n> +       if (replayed_base && reverse)\n> +               BUG(\"Linearizing commits is not supported when replaying in reverse\");\n> +\n\nThis is dead code given the die_for_incompatible_opt2 check above,\nright?  Just extra defense in depth?\n\n>         if (pickme->parents) {\n>                 base = pickme->parents->item;\n>                 base_tree = repo_get_commit_tree(repo, base);\n> @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>                 base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n>         }\n>\n> -       replayed_base = get_mapped_commit(replayed_commits, base, onto);\n> +       if (!replayed_base)\n> +               replayed_base = get_mapped_commit(replayed_commits, base, onto);\n>         replayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n>         pickme_tree = repo_get_commit_tree(repo, pickme);\n>\n> @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs,\n>         while ((commit = get_revision(revs))) {\n>                 const struct name_decoration *decoration;\n>\n> -               if (commit->parents && commit->parents->next)\n> -                       die(_(\"replaying merge commits is not supported yet!\"));\n> +               if (commit->parents && commit->parents->next) {\n> +                       if (!opts->linearize)\n> +                               die(_(\"replaying merge commits is not supported yet!\"));\n> +                       /*\n> +                        * When linearizing, a merge commit itself is not picked,\n> +                        * but refs that point to it might need updating.\n> +                        */\n\nIs it worth pointing out that last_commit is intentionally not updated\nby this code path?  That is implied by your comment, but it takes a\nbit of reasoning to get there, and I think it might help future\nreaders to just explicitly state it.\n\n> +               } else {\n> +                       struct commit *to_pick = reverse ? last_commit : onto;\n> +                       last_commit =\n> +                               pick_regular_commit(revs->repo, commit,\n> +                                                   replayed_commits, to_pick,\n> +                                                   &merge_opt, &result,\n> +                                                   opts->linearize ? last_commit : NULL,\n> +                                                   reverse, opts->empty);\n> +               }\n>\n> -               last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n> -                                                 reverse ? last_commit : onto,\n> -                                                 &merge_opt, &result, reverse, opts->empty);\n>                 if (!last_commit)\n>                         break;\n>\n> diff --git a/replay.h b/replay.h\n> index 1851a07705..07e6fdcca3 100644\n> --- a/replay.h\n> +++ b/replay.h\n> @@ -62,6 +62,11 @@ struct replay_revisions_options {\n>          * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n>          */\n>         enum replay_empty_commit_action empty;\n> +\n> +       /*\n> +        * Whether to linearize the commits (i.e. drop merge commits).\n> +        */\n> +       int linearize;\n>  };\n>\n>  /* This struct is used as an out-parameter by `replay_revisions()`. */\n> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n> index 3353bc4a4d..64e0731188 100755\n> --- a/t/t3650-replay-basics.sh\n> +++ b/t/t3650-replay-basics.sh\n> @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n>         test_grep \"cannot be used with multiple revision ranges\" err\n>  '\n>\n> +test_expect_success 'replay merge commit fails' '\n> +       echo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n> +       test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&\n> +       test_cmp expect actual\n> +'\n> +\n> +test_expect_success 'replay to rebase merge commit with --linearize' '\n> +       git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&\n> +\n> +       test_line_count = 1 result &&\n> +\n> +       git log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n> +       test_write_lines O N J M L B A >expect &&\n> +       test_cmp expect actual\n> +'\n> +\n> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '\n> +       git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&\n\nYou'd need to drop \"A..\" to have it go down to the root commit, as\nJunio mentioned elsewhere.\n\n> +\n> +       test_line_count = 1 result &&\n> +\n> +       git log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n> +       test_write_lines O N J I M L B A >expect &&\n> +       test_cmp expect actual\n> +'\n> +\n>  test_done\n\nShould there also be a testcase combining --linearize and --advance?\n\nShould there be a test with the incompatibility of --revert &\n--linearize?  I think we have a few other tests for incompatible\noptions.\n\nOne additional testing idea, borrowed from an older variant of\nthis patch I had sitting in a local branch (dscho's original\nlinearize patch, adapted): in addition to checking specific commit\nsubjects, it's worth verifying that the linearized chain produces\nthe *same patches* as the original.  Something along the lines of:\n\n        test_expect_success '--linearize preserves patches' '\n                test_when_finished \"git update-ref -d refs/heads/merge_I_L\" &&\n                test_tick &&\n                git checkout -b merge_I_L I &&\n                git merge --no-edit L &&\n\n                git replay --linearize --onto A B..merge_I_L &&\n\n                # range-diff ignores merges, so the original\n                # {I, L, merge} reduces to {I, L} on the LHS,\n                # and the replayed chain on the RHS should match.\n                git range-diff B..merge_I_L@{1} B..merge_I_L >out &&\n                ! test_grep -v \"=\" out &&\n\n                git log --oneline A..merge_I_L >out &&\n                test_line_count = 2 out\n        '\n\nThe range-diff check is nice because it asserts patch equivalence\nrather than tying the test to a particular replay ordering, which\nmakes the test less brittle if the rev-walk order ever changes.\nFeel free to take, adapt, or ignore.\n\nAnyway, thanks for working on this; looking good.\n\nElijah\n"},{"id":"545628","messageId":"874ij3ynab.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"CABPp-BGRi2obnqRGEY9pSMyvRbNGs8AdVUpZmr0C6vZSgHb=cg@mail.gmail.com","subject":"Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-16T07:09:16Z","receivedAt":"2026-06-16T07:09:24Z","isPatch":true,"body":"Elijah Newren <newren@gmail.com> writes:\n\n> Hi,\n>\n> On Wed, Jun 10, 2026 at 7:51 AM Toon Claes <toon@iotcl.com> wrote:\n>>\n>> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n>>\n>> One of the stated goals of git-replay(1) is to allow implementing the\n>> git-rebase(1) functionality on the server side.\n>>\n>> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n>> was given. This mode drops merge commits instead of replaying them, and\n>> linearizes the commit history into a sequence of the\n>> regular (single-parent) commits.\n>>\n>> Add option `--linearize` to git-replay(1) to do the same.\n>\n> I think this version is nicer overall than the one from my\n> replay-upstream branch; sorry for repeatedly getting distracted from\n> that, but this does look nice.\n\nDscho gets most of the credit here. And don't worry about being\ndistracted, we know how things go around here. I appreciate the review!\n\n>\n> A few small comments:\n>\n>> Co-authored-by: Toon Claes <toon@iotcl.com>\n>> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n>> Signed-off-by: Toon Claes <toon@iotcl.com>\n>> ---\n>>  Documentation/git-replay.adoc |  5 +++++\n>>  builtin/replay.c              |  4 ++++\n>>  replay.c                      | 30 +++++++++++++++++++++++-------\n>>  replay.h                      |  5 +++++\n>>  t/t3650-replay-basics.sh      | 26 ++++++++++++++++++++++++++\n>>  5 files changed, 63 insertions(+), 7 deletions(-)\n>>\n>> diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\n>> index a32f72aead..41c96c7061 100644\n>> --- a/Documentation/git-replay.adoc\n>> +++ b/Documentation/git-replay.adoc\n>> @@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n>>  +\n>>  The default mode can be configured via the `replay.refAction` configuration variable.\n>>\n>> +--linearize::\n>> +       In this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n>> +       i.e. it cherry-picks only non-merge commits, each one on top of the\n>> +       previous one.\n>\n> The SYNOPSIS block at the top of the file is missing this new flag.\n>\n> The replay_usage[] variable in cmd_replay is also missing this new flag.\n>\n>>  <revision-range>::\n>>         Range of commits to replay; see \"Specifying Ranges\" in\n>>         linkgit:git-rev-parse[1]. In `--advance=<branch>` or\n>> diff --git a/builtin/replay.c b/builtin/replay.c\n>> index 39e3a86f6c..fedfe46dc6 100644\n>> --- a/builtin/replay.c\n>> +++ b/builtin/replay.c\n>> @@ -111,6 +111,8 @@ int cmd_replay(int argc,\n>>                              N_(\"mode\"),\n>>                              N_(\"control ref update behavior (update|print)\"),\n>>                              PARSE_OPT_NONEG),\n>> +               OPT_BOOL(0, \"linearize\", &opts.linearize,\n>> +                        N_(\"ignore merge commits instead of replaying them\")),\n>\n> \"ignore\" feels a bit ambiguous to me.  Can we use \"drop\" instead,\n> matching your commit message?\n\nAgreed, I don't like it too. \"drop\" sounds better.\n\n>>                 OPT_END()\n>>         };\n>>\n>> @@ -132,6 +134,8 @@ int cmd_replay(int argc,\n>>                                   opts.contained, \"--contained\");\n>>         die_for_incompatible_opt2(!!opts.ref, \"--ref\",\n>>                                   !!opts.contained, \"--contained\");\n>> +       die_for_incompatible_opt2(!!opts.revert, \"--revert\",\n>> +                                 opts.linearize, \"--linearize\");\n>\n> Sensible; should the docs mention this incompatibility?  (I'm not sure\n> myself; just throwing it out as food for thought.)\n\nLet's add it.\n\n>>\n>>         /* Parse ref action mode from command line or config */\n>>         ref_mode = get_ref_action_mode(repo, ref_action);\n>> diff --git a/replay.c b/replay.c\n>> index 7921d7dba3..81033fb889 100644\n>> --- a/replay.c\n>> +++ b/replay.c\n>> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>>                                           struct commit *onto,\n>>                                           struct merge_options *merge_opt,\n>>                                           struct merge_result *result,\n>> +                                         struct commit *replayed_base,\n>>                                           bool reverse,\n>>                                           enum replay_empty_commit_action empty)\n>>  {\n>> -       struct commit *base, *replayed_base;\n>> +       struct commit *base;\n>>         struct tree *pickme_tree, *base_tree, *replayed_base_tree;\n>>\n>> +       if (replayed_base && reverse)\n>> +               BUG(\"Linearizing commits is not supported when replaying in reverse\");\n>> +\n>\n> This is dead code given the die_for_incompatible_opt2 check above,\n> right?  Just extra defense in depth?\n\nWe also have another defense-in-depth for --onto/--advance/--revert:\n\n    BUG(\"expected one of onto_name, *advance_name, or *revert_name\");\n\nI don't mind having it for --linearize too.\n\n>>         if (pickme->parents) {\n>>                 base = pickme->parents->item;\n>>                 base_tree = repo_get_commit_tree(repo, base);\n>> @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>>                 base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n>>         }\n>>\n>> -       replayed_base = get_mapped_commit(replayed_commits, base, onto);\n>> +       if (!replayed_base)\n>> +               replayed_base = get_mapped_commit(replayed_commits, base, onto);\n>>         replayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n>>         pickme_tree = repo_get_commit_tree(repo, pickme);\n>>\n>> @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs,\n>>         while ((commit = get_revision(revs))) {\n>>                 const struct name_decoration *decoration;\n>>\n>> -               if (commit->parents && commit->parents->next)\n>> -                       die(_(\"replaying merge commits is not supported yet!\"));\n>> +               if (commit->parents && commit->parents->next) {\n>> +                       if (!opts->linearize)\n>> +                               die(_(\"replaying merge commits is not supported yet!\"));\n>> +                       /*\n>> +                        * When linearizing, a merge commit itself is not picked,\n>> +                        * but refs that point to it might need updating.\n>> +                        */\n>\n> Is it worth pointing out that last_commit is intentionally not updated\n> by this code path?  That is implied by your comment, but it takes a\n> bit of reasoning to get there, and I think it might help future\n> readers to just explicitly state it.\n\nAh yes, I didn't realize that, but you make a good point. I'll rephrase\nthe comment a bit.\n\n>> +               } else {\n>> +                       struct commit *to_pick = reverse ? last_commit : onto;\n>> +                       last_commit =\n>> +                               pick_regular_commit(revs->repo, commit,\n>> +                                                   replayed_commits, to_pick,\n>> +                                                   &merge_opt, &result,\n>> +                                                   opts->linearize ? last_commit : NULL,\n>> +                                                   reverse, opts->empty);\n>> +               }\n>>\n>> -               last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n>> -                                                 reverse ? last_commit : onto,\n>> -                                                 &merge_opt, &result, reverse, opts->empty);\n>>                 if (!last_commit)\n>>                         break;\n>>\n>> diff --git a/replay.h b/replay.h\n>> index 1851a07705..07e6fdcca3 100644\n>> --- a/replay.h\n>> +++ b/replay.h\n>> @@ -62,6 +62,11 @@ struct replay_revisions_options {\n>>          * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n>>          */\n>>         enum replay_empty_commit_action empty;\n>> +\n>> +       /*\n>> +        * Whether to linearize the commits (i.e. drop merge commits).\n>> +        */\n>> +       int linearize;\n>>  };\n>>\n>>  /* This struct is used as an out-parameter by `replay_revisions()`. */\n>> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n>> index 3353bc4a4d..64e0731188 100755\n>> --- a/t/t3650-replay-basics.sh\n>> +++ b/t/t3650-replay-basics.sh\n>> @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n>>         test_grep \"cannot be used with multiple revision ranges\" err\n>>  '\n>>\n>> +test_expect_success 'replay merge commit fails' '\n>> +       echo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n>> +       test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&\n>> +       test_cmp expect actual\n>> +'\n>> +\n>> +test_expect_success 'replay to rebase merge commit with --linearize' '\n>> +       git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&\n>> +\n>> +       test_line_count = 1 result &&\n>> +\n>> +       git log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n>> +       test_write_lines O N J M L B A >expect &&\n>> +       test_cmp expect actual\n>> +'\n>> +\n>> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '\n>> +       git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&\n>\n> You'd need to drop \"A..\" to have it go down to the root commit, as\n> Junio mentioned elsewhere.\n\nYes, thanks for double confirmation.\n\n>> +\n>> +       test_line_count = 1 result &&\n>> +\n>> +       git log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n>> +       test_write_lines O N J I M L B A >expect &&\n>> +       test_cmp expect actual\n>> +'\n>> +\n>>  test_done\n>\n> Should there also be a testcase combining --linearize and --advance?\n\nSure.\n\n> Should there be a test with the incompatibility of --revert &\n> --linearize?  I think we have a few other tests for incompatible\n> options.\n\nI was already about to add that.\n\n> One additional testing idea, borrowed from an older variant of\n> this patch I had sitting in a local branch (dscho's original\n> linearize patch, adapted): in addition to checking specific commit\n> subjects, it's worth verifying that the linearized chain produces\n> the *same patches* as the original.  Something along the lines of:\n>\n>         test_expect_success '--linearize preserves patches' '\n>                 test_when_finished \"git update-ref -d refs/heads/merge_I_L\" &&\n>                 test_tick &&\n>                 git checkout -b merge_I_L I &&\n>                 git merge --no-edit L &&\n>\n>                 git replay --linearize --onto A B..merge_I_L &&\n>\n>                 # range-diff ignores merges, so the original\n>                 # {I, L, merge} reduces to {I, L} on the LHS,\n>                 # and the replayed chain on the RHS should match.\n>                 git range-diff B..merge_I_L@{1} B..merge_I_L >out &&\n>                 ! test_grep -v \"=\" out &&\n>\n>                 git log --oneline A..merge_I_L >out &&\n>                 test_line_count = 2 out\n>         '\n>\n> The range-diff check is nice because it asserts patch equivalence\n> rather than tying the test to a particular replay ordering, which\n> makes the test less brittle if the rev-walk order ever changes.\n> Feel free to take, adapt, or ignore.\n\nInteresting idea and I like it. Lemme add it.\n\n> Anyway, thanks for working on this; looking good.\n\nThanks!\n\n-- \nCheers,\nToon\n"},{"id":"545632","messageId":"871pe6zxpx.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"xmqqjys6wcpo.fsf@gitster.g","subject":"Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-16T08:38:34Z","receivedAt":"2026-06-16T08:38:41Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> In the review response during the previous iteration, I commented\n> that (1) the original excluded only merges, but (2) your version\n> excluded both merges and the root commits the same way.  Your\n> response was:\n>\n>     The way it was written in v1 was maybe a bit too smart and hard to\n>     follow. I agree with your suggestion and will adopt this (with some\n>     tweaks) in the next version.\n>\n> which I took as saying \"it may be confusing, but it correctly\n> expresses what we want to do\", meaning \"yes, roots and merges should\n> be handled the same way\".  But the above no longer treats roots the\n> same way as merges.  I think that is intended, but just wanted to\n> double check.\n\nGreat callout. I was running the \"replay down to root\" test with v1 vs\nv2, but as you pointed out, the test I wrote doesn't actually replay\ndown to root. Now I've fixed the test and reran the test against both\nversions and verified what you're saying.\n\nSo to answer your question, yes this change is intentional and shout out\nto you for requesting to add this test (properly) so we actually catch\nthis. \n\n>> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '\n>> +\tgit replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&\n>\n> As with other test pieces, this \"git replay\" command line is overly\n> long and hides the important bit which is that the range being\n> replayed is *not* actually down to the root, which is A (it excludes\n> A).  Intended?\n\nNo, not intended. And while at it, I'll split up the command on two\nlines. \n\n-- \nCheers,\nToon\n"},{"id":"545638","messageId":"20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com","threadId":"65771","inReplyTo":"20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com","subject":"[PATCH v3 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-16T09:26:34Z","receivedAt":"2026-06-16T09:27:03Z","isPatch":true,"body":"As an alternative to dscho's patch series to replay merges[1], add\noption to git-replay(1) to linearize merges. This mimics what\ngit-rebase(1) does too with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. This patch was kindly provided by Dscho, which I've\ntweaked to be upstreamed.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion it doesn't make much sense to do\nso, but I'm happy to hear if anyone disagrees.\n\nThis series might conflict with Kristoffer's series to make\ndocumentation changes[2], but should be trivial to resolve. And I don't\nthink there's a conflict with Patrick's series on adding \"drop\" to\ngit-history(1)[3].\n\ndscho's series to replay merges[1] needs a bit of rework to fit on top\nof this, but I'm happy to help figuring that out. We've been discussing\nto either name the option --flatten or --linearize, but I've decided on\n\"linearize\" because the documentation of git-rebase(1) also mentions\n\"linearize\".\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n[2]: <V2_CV_doc_replay_config.767@msgid.xyz>\n[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\nChanges in v3:\n- Add --linearize to Documentation SYNOPSIS, and mention it's\n  incompatible with --revert.\n- Small language change in help message for --linearize.\n- Rephrase comment to include last_commit isn't modified when\n  linearizing merges.\n- Remove test that was added in earlier versions, but actually is\n  a duplicate of 'replaying merge commits is not supported yet'.\n- Add test to verify --revert and --linearize are incompatible.\n- Properly test that replaying down to root with --linearize works.\n- Add test for --linearize with --advance.\n- Add test that uses git-range-diff(1) to verify the patches created by\n  --linearize are correct.\n- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com\n\nChanges in v2:\n- Restructured the conditions to detect merge commits and added a line\n  of comment why the loop continues.\n- Rewrote tests to use the history from the setup step and added a few\n  test cases.\n- Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n  patches with this trailer, and if I understand correctly, I can keep\n  it. Please let me know if that wrong.\n- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n\n---\nJohannes Schindelin (1):\n      replay: offer an option to linearize the commit topology\n\nToon Claes (2):\n      replay: refactor enum replay_mode into a bool\n      replay: add helper to put entry into mapped_commits\n\n Documentation/git-replay.adoc |   8 ++-\n builtin/replay.c              |   6 ++-\n replay.c                      | 116 ++++++++++++++++++++++++------------------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      |  68 ++++++++++++++++++++++++-\n 5 files changed, 151 insertions(+), 52 deletions(-)\n\nRange-diff versus v2:\n\n1:  2075988ef1 = 1:  542b1c9267 replay: refactor enum replay_mode into a bool\n2:  93ff03be65 = 2:  62f6df8375 replay: add helper to put entry into mapped_commits\n3:  ef56010c96 ! 3:  768646ee24 replay: offer an option to linearize the commit topology\n    @@ Commit message\n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n      ## Documentation/git-replay.adoc ##\n    +@@ Documentation/git-replay.adoc: SYNOPSIS\n    + --------\n    + [verse]\n    + (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n    +-\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n    ++\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n    + \n    + DESCRIPTION\n    + -----------\n     @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).\n      +\n      The default mode can be configured via the `replay.refAction` configuration variable.\n    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif\n     +\tIn this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n     +\ti.e. it cherry-picks only non-merge commits, each one on top of the\n     +\tprevious one.\n    ++\tThis option is incompatible with `--revert`.\n     +\n      <revision-range>::\n      \tRange of commits to replay; see \"Specifying Ranges\" in\n      \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\n     \n      ## builtin/replay.c ##\n    +@@ builtin/replay.c: int cmd_replay(int argc,\n    + \tconst char *const replay_usage[] = {\n    + \t\tN_(\"(EXPERIMENTAL!) git replay \"\n    + \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n    +-\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n    ++\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n    + \t\tNULL\n    + \t};\n    + \tstruct option replay_options[] = {\n     @@ builtin/replay.c: int cmd_replay(int argc,\n      \t\t\t     N_(\"mode\"),\n      \t\t\t     N_(\"control ref update behavior (update|print)\"),\n      \t\t\t     PARSE_OPT_NONEG),\n     +\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n    -+\t\t\t N_(\"ignore merge commits instead of replaying them\")),\n    ++\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n      \t\tOPT_END()\n      \t};\n      \n    @@ replay.c: int replay_revisions(struct rev_info *revs,\n     +\t\t\tif (!opts->linearize)\n     +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n     +\t\t\t/*\n    -+\t\t\t * When linearizing, a merge commit itself is not picked,\n    -+\t\t\t * but refs that point to it might need updating.\n    ++\t\t\t * Drop the merge commit: do not pick it and leave\n    ++\t\t\t * last_commit unchanged, so its children (and any ref\n    ++\t\t\t * pointing at it) are reparented onto the previous\n    ++\t\t\t * non-merge commit, which the ref-update loop below uses.\n     +\t\t\t */\n     +\t\t} else {\n     +\t\t\tstruct commit *to_pick = reverse ? last_commit : onto;\n    @@ replay.h: struct replay_revisions_options {\n      /* This struct is used as an out-parameter by `replay_revisions()`. */\n     \n      ## t/t3650-replay-basics.sh ##\n    -@@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' '\n    - \ttest_grep \"cannot be used with multiple revision ranges\" err\n    +@@ t/t3650-replay-basics.sh: test_expect_success 'setup' '\n    + \ttest_merge P O --no-ff &&\n    + \tgit switch main &&\n    + \n    ++\tgit switch --orphan unrelated &&\n    ++\ttest_commit unrelated-root &&\n    ++\n    + \tgit switch -c conflict B &&\n    +-\ttest_commit C.conflict C.t conflict\n    ++\ttest_commit C.conflict C.t conflict &&\n    ++\tgit branch -D unrelated\n      '\n      \n    -+test_expect_success 'replay merge commit fails' '\n    -+\techo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n    -+\ttest_must_fail git replay --ref-action=print --onto main I..P 2>actual &&\n    -+\ttest_cmp expect actual\n    + test_expect_success 'setup bare' '\n    +@@ t/t3650-replay-basics.sh: test_expect_success '--advance and --contained cannot be used together' '\n    + \ttest_grep \"cannot be used together\" actual\n    + '\n    + \n    ++test_expect_success '--revert and --linearize cannot be used together' '\n    ++\ttest_must_fail git replay --revert=main --linearize \\\n    ++\t\ttopic1..topic2 2>actual &&\n    ++\ttest_grep \"cannot be used together\" actual\n     +'\n     +\n    + test_expect_success 'cannot advance target ... ordering would be ill-defined' '\n    + \techo \"fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined\" >expect &&\n    + \ttest_must_fail git replay --advance=main main topic1 topic2 2>actual &&\n    +@@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' '\n    + \ttest_grep \"cannot be used with multiple revision ranges\" err\n    + '\n    + \n     +test_expect_success 'replay to rebase merge commit with --linearize' '\n    -+\tgit replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&\n    ++\tgit replay --ref-action=print --linearize \\\n    ++\t\t--onto main I..topic-with-merge >result &&\n     +\n     +\ttest_line_count = 1 result &&\n     +\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +\ttest_cmp expect actual\n     +'\n     +\n    -+test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '\n    -+\tgit replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&\n    ++test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '\n    ++\tgit replay --ref-action=print --linearize \\\n    ++\t\t--onto unrelated-root topic-with-merge >result &&\n     +\n     +\ttest_line_count = 1 result &&\n     +\n     +\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n    -+\ttest_write_lines O N J I M L B A >expect &&\n    ++\ttest_write_lines O N J I B A unrelated-root >expect &&\n     +\ttest_cmp expect actual\n     +'\n    ++\n    ++test_expect_success 'replay to cherry-pick merge commit with --linearize' '\n    ++\tgit replay --ref-action=print --linearize \\\n    ++\t\t--advance main I..topic-with-merge >result &&\n    ++\n    ++\ttest_line_count = 1 result &&\n    ++\n    ++\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n    ++\ttest_write_lines O N J M L B A >expect &&\n    ++\ttest_cmp expect actual &&\n    ++\n    ++\tprintf \"update refs/heads/main \" >expect &&\n    ++\tprintf \"%s \" $(cut -f 3 -d \" \" result) >>expect &&\n    ++\tgit rev-parse main >>expect &&\n    ++\ttest_cmp expect result\n    ++'\n    ++\n    ++test_expect_success 'replay --linearize produces the same patches' '\n    ++\tgit replay --ref-action=print --linearize \\\n    ++\t\t--onto main I..topic-with-merge >result &&\n    ++\n    ++\ttest_line_count = 1 result &&\n    ++\ttip=$(cut -f 3 -d \" \" result) &&\n    ++\n    ++\t# range-diff does not care about the dropped merge,\n    ++\t# so the original commits (I..topic-with-merge)\n    ++\t# and the replayed chain (main..tip) must produce identical patches.\n    ++\tgit range-diff I..topic-with-merge main..$tip >out &&\n    ++\ttest_file_not_empty out &&\n    ++\t! grep -v \"=\" out &&\n    ++\n    ++\tgit log --oneline main..$tip >out &&\n    ++\ttest_line_count = 3 out\n    ++'\n     +\n      test_done\n\n\n---\nbase-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"545639","messageId":"20260616-toon-git-replay-drop-merges-v3-1-153e9eb99ce1@iotcl.com","threadId":"65771","inReplyTo":"20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com","subject":"[PATCH v3 1/3] replay: refactor enum replay_mode into a bool","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-16T09:26:35Z","receivedAt":"2026-06-16T09:27:13Z","isPatch":true,"body":"In 2760ee4983 (replay: add --revert mode to reverse commit changes,\n2026-03-26) the enum `replay_mode` was introduced. This has two possible\nvalues:\n\n - The value `REPLAY_MODE_REVERT` is used when option `--revert` is\n   passed to git-replay(1). When using this value the commits are\n   processed in reverse order and the inverse of the changes are\n   applied.\n\n - The value `REPLAY_MODE_PICK` is used when either option `--onto` or\n   `--advance` is used. In both cases the commits are processed in\n   normal order, and the changes are applied as-is.\n\nSince there are only two possible values of this enum, simplify the code\nby converting the enum into a bool. This avoids adding code paths that\ncheck for invalid values of the enum, and shortens code where the value\nis checked with a ternary operator.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 59 +++++++++++++++++++++++++----------------------------------\n 1 file changed, 25 insertions(+), 34 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 4ef8abb607..1f8e5b083b 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -18,11 +18,6 @@\n  */\n #define the_repository DO_NOT_USE_THE_REPOSITORY\n \n-enum replay_mode {\n-\tREPLAY_MODE_PICK,\n-\tREPLAY_MODE_REVERT,\n-};\n-\n static const char *short_commit_name(struct repository *repo,\n \t\t\t\t     struct commit *commit)\n {\n@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,\n \t\t\t\t    struct tree *tree,\n \t\t\t\t    struct commit *based_on,\n \t\t\t\t    struct commit *parent,\n-\t\t\t\t    enum replay_mode mode)\n+\t\t\t\t    bool reverse)\n {\n \tstruct object_id ret;\n \tstruct object *obj = NULL;\n@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,\n \n \tcommit_list_insert(parent, &parents);\n \textra = read_commit_extra_headers(based_on, exclude_gpgsig);\n-\tif (mode == REPLAY_MODE_REVERT) {\n+\tif (reverse) {\n \t\tgenerate_revert_message(&msg, based_on, repo);\n \t\t/* For revert, use current user as author (NULL = use default) */\n-\t} else if (mode == REPLAY_MODE_PICK) {\n+\t} else {\n \t\tfind_commit_subject(message, &orig_message);\n \t\tstrbuf_addstr(&msg, orig_message);\n \t\tauthor = get_author(message);\n-\t} else {\n-\t\tBUG(\"unexpected replay mode %d\", mode);\n \t}\n \treset_ident_date();\n \tif (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,\n@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n-\t\t\t\t\t  enum replay_mode mode,\n+\t\t\t\t\t  bool reverse,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n \tstruct commit *base, *replayed_base;\n@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n-\tif (mode == REPLAY_MODE_PICK) {\n+\tif (reverse) {\n+\t\t/* Revert: swap base and pickme to reverse the diff */\n+\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n+\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n+\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n+\t\tmerge_opt->ancestor = pickme_name;\n+\n+\t\tmerge_incore_nonrecursive(merge_opt,\n+\t\t\t\t\t  pickme_tree,\n+\t\t\t\t\t  replayed_base_tree,\n+\t\t\t\t\t  base_tree,\n+\t\t\t\t\t  result);\n+\n+\t\tfree((char *)merge_opt->branch2);\n+\t} else {\n \t\t/* Cherry-pick: normal order */\n \t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n \t\tmerge_opt->branch2 = short_commit_name(repo, pickme);\n@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  result);\n \n \t\tfree((char *)merge_opt->ancestor);\n-\t} else if (mode == REPLAY_MODE_REVERT) {\n-\t\t/* Revert: swap base and pickme to reverse the diff */\n-\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n-\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n-\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n-\t\tmerge_opt->ancestor = pickme_name;\n-\n-\t\tmerge_incore_nonrecursive(merge_opt,\n-\t\t\t\t\t  pickme_tree,\n-\t\t\t\t\t  replayed_base_tree,\n-\t\t\t\t\t  base_tree,\n-\t\t\t\t\t  result);\n-\n-\t\tfree((char *)merge_opt->branch2);\n-\t} else {\n-\t\tBUG(\"unexpected replay mode %d\", mode);\n \t}\n \tmerge_opt->ancestor = NULL;\n \tmerge_opt->branch2 = NULL;\n@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t}\n \t}\n \n-\treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n+\treturn create_commit(repo, result->tree, pickme, replayed_base, reverse);\n }\n \n void replay_result_release(struct replay_result *result)\n@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,\n \tchar *revert;\n \tconst char *ref;\n \tstruct object_id old_oid;\n-\tenum replay_mode mode = REPLAY_MODE_PICK;\n+\tbool reverse;\n \tint ret;\n \n \tadvance = xstrdup_or_null(opts->advance);\n \trevert = xstrdup_or_null(opts->revert);\n-\tif (revert)\n-\t\tmode = REPLAY_MODE_REVERT;\n+\treverse = !!revert;\n+\n \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n \n@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n \t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\t\t\t\t\t  reverse ? last_commit : onto,\n+\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"545640","messageId":"20260616-toon-git-replay-drop-merges-v3-2-153e9eb99ce1@iotcl.com","threadId":"65771","inReplyTo":"20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com","subject":"[PATCH v3 2/3] replay: add helper to put entry into mapped_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-16T09:26:36Z","receivedAt":"2026-06-16T09:27:22Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put a commit entry into mapped_commits into a helper\nfunction put_mapped_commit().\n\nWhile at it, rename mapped_commit() to get_mapped_commit() to pair with\nthis new function.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 1f8e5b083b..7921d7dba3 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"545641","messageId":"20260616-toon-git-replay-drop-merges-v3-3-153e9eb99ce1@iotcl.com","threadId":"65771","inReplyTo":"20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com","subject":"[PATCH v3 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-16T09:26:37Z","receivedAt":"2026-06-16T09:27:27Z","isPatch":true,"body":"From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n\nOne of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearizes the commit history into a sequence of the\nregular (single-parent) commits.\n\nAdd option `--linearize` to git-replay(1) to do the same.\n\nCo-authored-by: Toon Claes <toon@iotcl.com>\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  8 ++++-\n builtin/replay.c              |  6 +++-\n replay.c                      | 32 +++++++++++++++-----\n replay.h                      |  5 ++++\n t/t3650-replay-basics.sh      | 68 ++++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 109 insertions(+), 10 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex a32f72aead..ef56ee0f1b 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n --------\n [verse]\n (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n-\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n+\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n+\ti.e. it cherry-picks only non-merge commits, each one on top of the\n+\tprevious one.\n+\tThis option is incompatible with `--revert`.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..62962c73c7 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -85,7 +85,7 @@ int cmd_replay(int argc,\n \tconst char *const replay_usage[] = {\n \t\tN_(\"(EXPERIMENTAL!) git replay \"\n \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n-\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n+\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n \t\tNULL\n \t};\n \tstruct option replay_options[] = {\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n \t\tOPT_END()\n \t};\n \n@@ -132,6 +134,8 @@ int cmd_replay(int argc,\n \t\t\t\t  opts.contained, \"--contained\");\n \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n \t\t\t\t  !!opts.contained, \"--contained\");\n+\tdie_for_incompatible_opt2(!!opts.revert, \"--revert\",\n+\t\t\t\t  opts.linearize, \"--linearize\");\n \n \t/* Parse ref action mode from command line or config */\n \tref_mode = get_ref_action_mode(repo, ref_action);\ndiff --git a/replay.c b/replay.c\nindex 7921d7dba3..5539daff00 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n+\t\t\t\t\t  struct commit *replayed_base,\n \t\t\t\t\t  bool reverse,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n-\tstruct commit *base, *replayed_base;\n+\tstruct commit *base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n \n+\tif (replayed_base && reverse)\n+\t\tBUG(\"Linearizing commits is not supported when replaying in reverse\");\n+\n \tif (pickme->parents) {\n \t\tbase = pickme->parents->item;\n \t\tbase_tree = repo_get_commit_tree(repo, base);\n@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n+\tif (!replayed_base)\n+\t\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\tif (commit->parents && commit->parents->next)\n-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (!opts->linearize)\n+\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\t\t/*\n+\t\t\t * Drop the merge commit: do not pick it and leave\n+\t\t\t * last_commit unchanged, so its children (and any ref\n+\t\t\t * pointing at it) are reparented onto the previous\n+\t\t\t * non-merge commit, which the ref-update loop below uses.\n+\t\t\t */\n+\t\t} else {\n+\t\t\tstruct commit *to_pick = reverse ? last_commit : onto;\n+\t\t\tlast_commit =\n+\t\t\t\tpick_regular_commit(revs->repo, commit,\n+\t\t\t\t\t\t    replayed_commits, to_pick,\n+\t\t\t\t\t\t    &merge_opt, &result,\n+\t\t\t\t\t\t    opts->linearize ? last_commit : NULL,\n+\t\t\t\t\t\t    reverse, opts->empty);\n+\t\t}\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  reverse ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex 1851a07705..07e6fdcca3 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..1874d06769 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -52,8 +52,12 @@ test_expect_success 'setup' '\n \ttest_merge P O --no-ff &&\n \tgit switch main &&\n \n+\tgit switch --orphan unrelated &&\n+\ttest_commit unrelated-root &&\n+\n \tgit switch -c conflict B &&\n-\ttest_commit C.conflict C.t conflict\n+\ttest_commit C.conflict C.t conflict &&\n+\tgit branch -D unrelated\n '\n \n test_expect_success 'setup bare' '\n@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '\n \ttest_grep \"cannot be used together\" actual\n '\n \n+test_expect_success '--revert and --linearize cannot be used together' '\n+\ttest_must_fail git replay --revert=main --linearize \\\n+\t\ttopic1..topic2 2>actual &&\n+\ttest_grep \"cannot be used together\" actual\n+'\n+\n test_expect_success 'cannot advance target ... ordering would be ill-defined' '\n \techo \"fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined\" >expect &&\n \ttest_must_fail git replay --advance=main main topic1 topic2 2>actual &&\n@@ -565,4 +575,60 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'replay to rebase merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto unrelated-root topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J I B A unrelated-root >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to cherry-pick merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--advance main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tprintf \"update refs/heads/main \" >expect &&\n+\tprintf \"%s \" $(cut -f 3 -d \" \" result) >>expect &&\n+\tgit rev-parse main >>expect &&\n+\ttest_cmp expect result\n+'\n+\n+test_expect_success 'replay --linearize produces the same patches' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# range-diff does not care about the dropped merge,\n+\t# so the original commits (I..topic-with-merge)\n+\t# and the replayed chain (main..tip) must produce identical patches.\n+\tgit range-diff I..topic-with-merge main..$tip >out &&\n+\ttest_file_not_empty out &&\n+\t! grep -v \"=\" out &&\n+\n+\tgit log --oneline main..$tip >out &&\n+\ttest_line_count = 3 out\n+'\n+\n test_done\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"546171","messageId":"20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com","threadId":"65771","inReplyTo":"20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com","subject":"[PATCH v4 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-22T12:41:54Z","receivedAt":"2026-06-22T12:42:05Z","isPatch":true,"body":"As an alternative to dscho's patch series to replay merges[1], add\noption to git-replay(1) to linearize merges. This mimics what\ngit-rebase(1) does too with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. This patch was kindly provided by Dscho, which I've\ntweaked to be upstreamed.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion it doesn't make much sense to do\nso, but I'm happy to hear if anyone disagrees.\n\nThis series might conflict with Kristoffer's series to make\ndocumentation changes[2], but should be trivial to resolve. And I don't\nthink there's a conflict with Patrick's series on adding \"drop\" to\ngit-history(1)[3].\n\ndscho's series to replay merges[1] needs a bit of rework to fit on top\nof this, but I'm happy to help figuring that out. We've been discussing\nto either name the option --flatten or --linearize, but I've decided on\n\"linearize\" because the documentation of git-rebase(1) also mentions\n\"linearize\".\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n[2]: <V2_CV_doc_replay_config.767@msgid.xyz>\n[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>\n\n---\nChanges in v4:\n- Use test_grep instead of a bare grep in the range-diff test, to\n  prepare for mm/test-grep-lint.\n- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com\n\nChanges in v3:\n- Add --linearize to Documentation SYNOPSIS, and mention it's\n  incompatible with --revert.\n- Small language change in help message for --linearize.\n- Rephrase comment to include last_commit isn't modified when\n  linearizing merges.\n- Remove test that was added in earlier versions, but actually is\n  a duplicate of 'replaying merge commits is not supported yet'.\n- Add test to verify --revert and --linearize are incompatible.\n- Properly test that replaying down to root with --linearize works.\n- Add test for --linearize with --advance.\n- Add test that uses git-range-diff(1) to verify the patches created by\n  --linearize are correct.\n- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com\n\nChanges in v2:\n- Restructured the conditions to detect merge commits and added a line\n  of comment why the loop continues.\n- Rewrote tests to use the history from the setup step and added a few\n  test cases.\n- Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n  patches with this trailer, and if I understand correctly, I can keep\n  it. Please let me know if that wrong.\n- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n\n---\nJohannes Schindelin (1):\n      replay: offer an option to linearize the commit topology\n\nToon Claes (2):\n      replay: refactor enum replay_mode into a bool\n      replay: add helper to put entry into mapped_commits\n\n Documentation/git-replay.adoc |   8 ++-\n builtin/replay.c              |   6 ++-\n replay.c                      | 116 ++++++++++++++++++++++++------------------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      |  68 ++++++++++++++++++++++++-\n 5 files changed, 151 insertions(+), 52 deletions(-)\n\nRange-diff versus v3:\n\n1:  759fa1b52c = 1:  0f0e50c67f replay: refactor enum replay_mode into a bool\n2:  68dd5ad77c = 2:  919a6495ee replay: add helper to put entry into mapped_commits\n3:  f99aeb3887 ! 3:  bb03e78210 replay: offer an option to linearize the commit topology\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +\t# and the replayed chain (main..tip) must produce identical patches.\n     +\tgit range-diff I..topic-with-merge main..$tip >out &&\n     +\ttest_file_not_empty out &&\n    -+\t! grep -v \"=\" out &&\n    ++\ttest_grep ! -v \"=\" out &&\n     +\n     +\tgit log --oneline main..$tip >out &&\n     +\ttest_line_count = 3 out\n\n\n---\nbase-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"546172","messageId":"20260622-toon-git-replay-drop-merges-v4-1-ff257f534319@iotcl.com","threadId":"65771","inReplyTo":"20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com","subject":"[PATCH v4 1/3] replay: refactor enum replay_mode into a bool","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-22T12:41:55Z","receivedAt":"2026-06-22T12:42:17Z","isPatch":true,"body":"In 2760ee4983 (replay: add --revert mode to reverse commit changes,\n2026-03-26) the enum `replay_mode` was introduced. This has two possible\nvalues:\n\n - The value `REPLAY_MODE_REVERT` is used when option `--revert` is\n   passed to git-replay(1). When using this value the commits are\n   processed in reverse order and the inverse of the changes are\n   applied.\n\n - The value `REPLAY_MODE_PICK` is used when either option `--onto` or\n   `--advance` is used. In both cases the commits are processed in\n   normal order, and the changes are applied as-is.\n\nSince there are only two possible values of this enum, simplify the code\nby converting the enum into a bool. This avoids adding code paths that\ncheck for invalid values of the enum, and shortens code where the value\nis checked with a ternary operator.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 59 +++++++++++++++++++++++++----------------------------------\n 1 file changed, 25 insertions(+), 34 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 4ef8abb607..1f8e5b083b 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -18,11 +18,6 @@\n  */\n #define the_repository DO_NOT_USE_THE_REPOSITORY\n \n-enum replay_mode {\n-\tREPLAY_MODE_PICK,\n-\tREPLAY_MODE_REVERT,\n-};\n-\n static const char *short_commit_name(struct repository *repo,\n \t\t\t\t     struct commit *commit)\n {\n@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,\n \t\t\t\t    struct tree *tree,\n \t\t\t\t    struct commit *based_on,\n \t\t\t\t    struct commit *parent,\n-\t\t\t\t    enum replay_mode mode)\n+\t\t\t\t    bool reverse)\n {\n \tstruct object_id ret;\n \tstruct object *obj = NULL;\n@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,\n \n \tcommit_list_insert(parent, &parents);\n \textra = read_commit_extra_headers(based_on, exclude_gpgsig);\n-\tif (mode == REPLAY_MODE_REVERT) {\n+\tif (reverse) {\n \t\tgenerate_revert_message(&msg, based_on, repo);\n \t\t/* For revert, use current user as author (NULL = use default) */\n-\t} else if (mode == REPLAY_MODE_PICK) {\n+\t} else {\n \t\tfind_commit_subject(message, &orig_message);\n \t\tstrbuf_addstr(&msg, orig_message);\n \t\tauthor = get_author(message);\n-\t} else {\n-\t\tBUG(\"unexpected replay mode %d\", mode);\n \t}\n \treset_ident_date();\n \tif (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,\n@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n-\t\t\t\t\t  enum replay_mode mode,\n+\t\t\t\t\t  bool reverse,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n \tstruct commit *base, *replayed_base;\n@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n-\tif (mode == REPLAY_MODE_PICK) {\n+\tif (reverse) {\n+\t\t/* Revert: swap base and pickme to reverse the diff */\n+\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n+\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n+\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n+\t\tmerge_opt->ancestor = pickme_name;\n+\n+\t\tmerge_incore_nonrecursive(merge_opt,\n+\t\t\t\t\t  pickme_tree,\n+\t\t\t\t\t  replayed_base_tree,\n+\t\t\t\t\t  base_tree,\n+\t\t\t\t\t  result);\n+\n+\t\tfree((char *)merge_opt->branch2);\n+\t} else {\n \t\t/* Cherry-pick: normal order */\n \t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n \t\tmerge_opt->branch2 = short_commit_name(repo, pickme);\n@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  result);\n \n \t\tfree((char *)merge_opt->ancestor);\n-\t} else if (mode == REPLAY_MODE_REVERT) {\n-\t\t/* Revert: swap base and pickme to reverse the diff */\n-\t\tconst char *pickme_name = short_commit_name(repo, pickme);\n-\t\tmerge_opt->branch1 = short_commit_name(repo, replayed_base);\n-\t\tmerge_opt->branch2 = xstrfmt(\"parent of %s\", pickme_name);\n-\t\tmerge_opt->ancestor = pickme_name;\n-\n-\t\tmerge_incore_nonrecursive(merge_opt,\n-\t\t\t\t\t  pickme_tree,\n-\t\t\t\t\t  replayed_base_tree,\n-\t\t\t\t\t  base_tree,\n-\t\t\t\t\t  result);\n-\n-\t\tfree((char *)merge_opt->branch2);\n-\t} else {\n-\t\tBUG(\"unexpected replay mode %d\", mode);\n \t}\n \tmerge_opt->ancestor = NULL;\n \tmerge_opt->branch2 = NULL;\n@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t}\n \t}\n \n-\treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n+\treturn create_commit(repo, result->tree, pickme, replayed_base, reverse);\n }\n \n void replay_result_release(struct replay_result *result)\n@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,\n \tchar *revert;\n \tconst char *ref;\n \tstruct object_id old_oid;\n-\tenum replay_mode mode = REPLAY_MODE_PICK;\n+\tbool reverse;\n \tint ret;\n \n \tadvance = xstrdup_or_null(opts->advance);\n \trevert = xstrdup_or_null(opts->revert);\n-\tif (revert)\n-\t\tmode = REPLAY_MODE_REVERT;\n+\treverse = !!revert;\n+\n \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n \n@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n \t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\t\t\t\t\t  reverse ? last_commit : onto,\n+\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"546173","messageId":"20260622-toon-git-replay-drop-merges-v4-2-ff257f534319@iotcl.com","threadId":"65771","inReplyTo":"20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com","subject":"[PATCH v4 2/3] replay: add helper to put entry into mapped_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-22T12:41:56Z","receivedAt":"2026-06-22T12:42:21Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put a commit entry into mapped_commits into a helper\nfunction put_mapped_commit().\n\nWhile at it, rename mapped_commit() to get_mapped_commit() to pair with\nthis new function.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 1f8e5b083b..7921d7dba3 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"546174","messageId":"20260622-toon-git-replay-drop-merges-v4-3-ff257f534319@iotcl.com","threadId":"65771","inReplyTo":"20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com","subject":"[PATCH v4 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-22T12:41:57Z","receivedAt":"2026-06-22T12:42:25Z","isPatch":true,"body":"From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n\nOne of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearizes the commit history into a sequence of the\nregular (single-parent) commits.\n\nAdd option `--linearize` to git-replay(1) to do the same.\n\nCo-authored-by: Toon Claes <toon@iotcl.com>\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  8 ++++-\n builtin/replay.c              |  6 +++-\n replay.c                      | 32 +++++++++++++++-----\n replay.h                      |  5 ++++\n t/t3650-replay-basics.sh      | 68 ++++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 109 insertions(+), 10 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex a32f72aead..ef56ee0f1b 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n --------\n [verse]\n (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n-\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n+\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n+\ti.e. it cherry-picks only non-merge commits, each one on top of the\n+\tprevious one.\n+\tThis option is incompatible with `--revert`.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..62962c73c7 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -85,7 +85,7 @@ int cmd_replay(int argc,\n \tconst char *const replay_usage[] = {\n \t\tN_(\"(EXPERIMENTAL!) git replay \"\n \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n-\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n+\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n \t\tNULL\n \t};\n \tstruct option replay_options[] = {\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n \t\tOPT_END()\n \t};\n \n@@ -132,6 +134,8 @@ int cmd_replay(int argc,\n \t\t\t\t  opts.contained, \"--contained\");\n \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n \t\t\t\t  !!opts.contained, \"--contained\");\n+\tdie_for_incompatible_opt2(!!opts.revert, \"--revert\",\n+\t\t\t\t  opts.linearize, \"--linearize\");\n \n \t/* Parse ref action mode from command line or config */\n \tref_mode = get_ref_action_mode(repo, ref_action);\ndiff --git a/replay.c b/replay.c\nindex 7921d7dba3..5539daff00 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n+\t\t\t\t\t  struct commit *replayed_base,\n \t\t\t\t\t  bool reverse,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n-\tstruct commit *base, *replayed_base;\n+\tstruct commit *base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n \n+\tif (replayed_base && reverse)\n+\t\tBUG(\"Linearizing commits is not supported when replaying in reverse\");\n+\n \tif (pickme->parents) {\n \t\tbase = pickme->parents->item;\n \t\tbase_tree = repo_get_commit_tree(repo, base);\n@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n+\tif (!replayed_base)\n+\t\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\tif (commit->parents && commit->parents->next)\n-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (!opts->linearize)\n+\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\t\t/*\n+\t\t\t * Drop the merge commit: do not pick it and leave\n+\t\t\t * last_commit unchanged, so its children (and any ref\n+\t\t\t * pointing at it) are reparented onto the previous\n+\t\t\t * non-merge commit, which the ref-update loop below uses.\n+\t\t\t */\n+\t\t} else {\n+\t\t\tstruct commit *to_pick = reverse ? last_commit : onto;\n+\t\t\tlast_commit =\n+\t\t\t\tpick_regular_commit(revs->repo, commit,\n+\t\t\t\t\t\t    replayed_commits, to_pick,\n+\t\t\t\t\t\t    &merge_opt, &result,\n+\t\t\t\t\t\t    opts->linearize ? last_commit : NULL,\n+\t\t\t\t\t\t    reverse, opts->empty);\n+\t\t}\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  reverse ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex 1851a07705..07e6fdcca3 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..b9ce6c4868 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -52,8 +52,12 @@ test_expect_success 'setup' '\n \ttest_merge P O --no-ff &&\n \tgit switch main &&\n \n+\tgit switch --orphan unrelated &&\n+\ttest_commit unrelated-root &&\n+\n \tgit switch -c conflict B &&\n-\ttest_commit C.conflict C.t conflict\n+\ttest_commit C.conflict C.t conflict &&\n+\tgit branch -D unrelated\n '\n \n test_expect_success 'setup bare' '\n@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '\n \ttest_grep \"cannot be used together\" actual\n '\n \n+test_expect_success '--revert and --linearize cannot be used together' '\n+\ttest_must_fail git replay --revert=main --linearize \\\n+\t\ttopic1..topic2 2>actual &&\n+\ttest_grep \"cannot be used together\" actual\n+'\n+\n test_expect_success 'cannot advance target ... ordering would be ill-defined' '\n \techo \"fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined\" >expect &&\n \ttest_must_fail git replay --advance=main main topic1 topic2 2>actual &&\n@@ -565,4 +575,60 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'replay to rebase merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto unrelated-root topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J I B A unrelated-root >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to cherry-pick merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--advance main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tprintf \"update refs/heads/main \" >expect &&\n+\tprintf \"%s \" $(cut -f 3 -d \" \" result) >>expect &&\n+\tgit rev-parse main >>expect &&\n+\ttest_cmp expect result\n+'\n+\n+test_expect_success 'replay --linearize produces the same patches' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# range-diff does not care about the dropped merge,\n+\t# so the original commits (I..topic-with-merge)\n+\t# and the replayed chain (main..tip) must produce identical patches.\n+\tgit range-diff I..topic-with-merge main..$tip >out &&\n+\ttest_file_not_empty out &&\n+\ttest_grep ! -v \"=\" out &&\n+\n+\tgit log --oneline main..$tip >out &&\n+\ttest_line_count = 3 out\n+'\n+\n test_done\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"546183","messageId":"ajk-YQxLWfspNWIm@pks.im","threadId":"65771","inReplyTo":"20260622-toon-git-replay-drop-merges-v4-1-ff257f534319@iotcl.com","subject":"Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-22T13:53:37Z","receivedAt":"2026-06-22T13:53:43Z","isPatch":true,"body":"On Mon, Jun 22, 2026 at 02:41:55PM +0200, Toon Claes wrote:\n> In 2760ee4983 (replay: add --revert mode to reverse commit changes,\n> 2026-03-26) the enum `replay_mode` was introduced. This has two possible\n> values:\n> \n>  - The value `REPLAY_MODE_REVERT` is used when option `--revert` is\n>    passed to git-replay(1). When using this value the commits are\n>    processed in reverse order and the inverse of the changes are\n>    applied.\n> \n>  - The value `REPLAY_MODE_PICK` is used when either option `--onto` or\n>    `--advance` is used. In both cases the commits are processed in\n>    normal order, and the changes are applied as-is.\n> \n> Since there are only two possible values of this enum, simplify the code\n> by converting the enum into a bool. This avoids adding code paths that\n> check for invalid values of the enum, and shortens code where the value\n> is checked with a ternary operator.\n\nThat's fair, and the result is easier to write. But is it really easier\nto read? And what if we ever have to create a third mode going forward?\n\nI'm generally no fan of booleans as parameters as they basically give\nyou no information at all at the callsite, except if you're lucky and\nyou already have an aptly-named variable available that you can pass.\nWhich seems to be the case here, but I'm still not sure whether this\nchange really improves the code.\n\nPatrick\n"},{"id":"546184","messageId":"ajk-Zok5pAClw7N3@pks.im","threadId":"65771","inReplyTo":"20260622-toon-git-replay-drop-merges-v4-2-ff257f534319@iotcl.com","subject":"Re: [PATCH v4 2/3] replay: add helper to put entry into mapped_commits","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-22T13:53:42Z","receivedAt":"2026-06-22T13:53:47Z","isPatch":true,"body":"On Mon, Jun 22, 2026 at 02:41:56PM +0200, Toon Claes wrote:\n> diff --git a/replay.c b/replay.c\n> index 1f8e5b083b..7921d7dba3 100644\n> --- a/replay.c\n> +++ b/replay.c\n> @@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n>  \treturn kh_value(replayed_commits, pos);\n>  }\n>  \n> +static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n> +\t\t\t      struct commit *commit,\n> +\t\t\t      struct commit *new_commit)\n> +{\n> +\tkhint_t pos;\n> +\tint ret;\n> +\n> +\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n> +\tif (ret == 0)\n> +\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n> +\t\t    oid_to_hex(&commit->object.oid));\n> +\n> +\tkh_value(replayed_commits, pos) = new_commit;\n> +}\n\nThe khash map interfaces are quite awkward to use, so having a small\nwrapper feels sensible to me. It is one of those interfaces that really\nmake you wish for generics in C.\n\nPatrick\n"},{"id":"546185","messageId":"ajk-a4a3KSJ2u7Ju@pks.im","threadId":"65771","inReplyTo":"20260622-toon-git-replay-drop-merges-v4-3-ff257f534319@iotcl.com","subject":"Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-22T13:53:47Z","receivedAt":"2026-06-22T13:53:52Z","isPatch":true,"body":"On Mon, Jun 22, 2026 at 02:41:57PM +0200, Toon Claes wrote:\n> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n> \n> One of the stated goals of git-replay(1) is to allow implementing the\n> git-rebase(1) functionality on the server side.\n> \n> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n> was given. This mode drops merge commits instead of replaying them, and\n> linearizes the commit history into a sequence of the\n> regular (single-parent) commits.\n> \n> Add option `--linearize` to git-replay(1) to do the same.\n\ngit-rebase(1) essentially knows about three different modes:\n\n  - \"--no-rebase-merges\", which is the default and maps to your\n    \"--linearize\".\n\n  - \"--rebase-merges\", which by default doesn't rebase cousins by using\n    \"--ancestry-path\" internally.\n\n  - \"--rebase-merges=rebase-cousins\", which doesn't pass the above\n    option.\n\nSo it's not a simple boolean there, which makes me wonder whether we\nshould mirror the same interface so that all of git-rebase(1)'s modes\ncan be represented, as well.\n\n> diff --git a/replay.c b/replay.c\n> index 7921d7dba3..5539daff00 100644\n> --- a/replay.c\n> +++ b/replay.c\n> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>  \t\t\t\t\t  struct commit *onto,\n>  \t\t\t\t\t  struct merge_options *merge_opt,\n>  \t\t\t\t\t  struct merge_result *result,\n> +\t\t\t\t\t  struct commit *replayed_base,\n>  \t\t\t\t\t  bool reverse,\n>  \t\t\t\t\t  enum replay_empty_commit_action empty)\n>  {\n> -\tstruct commit *base, *replayed_base;\n> +\tstruct commit *base;\n>  \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n>  \n> +\tif (replayed_base && reverse)\n> +\t\tBUG(\"Linearizing commits is not supported when replaying in reverse\");\n\nNit: Error messages should typically start with a lower-case letter.\n\n> @@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,\n>  \twhile ((commit = get_revision(revs))) {\n>  \t\tconst struct name_decoration *decoration;\n>  \n> -\t\tif (commit->parents && commit->parents->next)\n> -\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n> +\t\tif (commit->parents && commit->parents->next) {\n> +\t\t\tif (!opts->linearize)\n> +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n> +\t\t\t/*\n> +\t\t\t * Drop the merge commit: do not pick it and leave\n> +\t\t\t * last_commit unchanged, so its children (and any ref\n> +\t\t\t * pointing at it) are reparented onto the previous\n> +\t\t\t * non-merge commit, which the ref-update loop below uses.\n> +\t\t\t */\n\nOne could add a hint here that tells the user to pass the option. But I\nguess that might be somewhat weird, as we cannot assume that we're\ncalled by git-replay(1) here.\n\nIn any case, this here is the core of the change where we stop dying in\ncase \"--linearize\" was passed, and instead we simply skip the commit\naltogether. Makes sense.\n\nThanks!\n\nPatrick\n"},{"id":"546188","messageId":"xmqq7bnq37jm.fsf@gitster.g","threadId":"65771","inReplyTo":"ajk-YQxLWfspNWIm@pks.im","subject":"Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-22T15:43:09Z","receivedAt":"2026-06-22T15:43:12Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Mon, Jun 22, 2026 at 02:41:55PM +0200, Toon Claes wrote:\n>> In 2760ee4983 (replay: add --revert mode to reverse commit changes,\n>> 2026-03-26) the enum `replay_mode` was introduced. This has two possible\n>> values:\n>> \n>>  - The value `REPLAY_MODE_REVERT` is used when option `--revert` is\n>>    passed to git-replay(1). When using this value the commits are\n>>    processed in reverse order and the inverse of the changes are\n>>    applied.\n>> \n>>  - The value `REPLAY_MODE_PICK` is used when either option `--onto` or\n>>    `--advance` is used. In both cases the commits are processed in\n>>    normal order, and the changes are applied as-is.\n>> \n>> Since there are only two possible values of this enum, simplify the code\n>> by converting the enum into a bool. This avoids adding code paths that\n>> check for invalid values of the enum, and shortens code where the value\n>> is checked with a ternary operator.\n>\n> That's fair, and the result is easier to write. But is it really easier\n> to read? And what if we ever have to create a third mode going forward?\n>\n> I'm generally no fan of booleans as parameters as they basically give\n> you no information at all at the callsite, except if you're lucky and\n> you already have an aptly-named variable available that you can pass.\n> Which seems to be the case here, but I'm still not sure whether this\n> change really improves the code.\n\nI tend to agree with you on both counts.  The \"what happens when\nsomebody else wants a third choice?\" is a quesiton I would ask the\nfirst thing as the maintainer of a project.\n\nEven if the boolean parameter is so obviously named, the callsite\ncan only say \"true\" or \"false\", unlike some other popular languages\nthat lets you say\n\n\tmy_function(use_revert_mode=true, verbose=false);\n\nand you cannot tell what effect the author wanted out of that \"true\"\nif all you can write were\n\n\tmy_function(true, false);\n\nOf course, we could go ultra verbose, like\n\n\tmy_function(true, /* use_revert_mode */\n\t\t    false, /* verbose */);\n\nbut then we are often better off writing:\n\n\tmy_function(REPLAY_MODE_REVERT, REPLAY_QUIET);\n\nThanks.\n"},{"id":"546342","messageId":"87tsqrycke.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"xmqq7bnq37jm.fsf@gitster.g","subject":"Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-24T19:15:45Z","receivedAt":"2026-06-24T19:15:54Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Patrick Steinhardt <ps@pks.im> writes:\n>\n>> That's fair, and the result is easier to write. But is it really easier\n>> to read?\n\nYou're bringing up a very valid point there, and not directly in what\nyou're saying, but how it makes me reconsider.\n\nSo we're comparing:\n\n    pick_regular_commit(revs->repo, commit, replayed_commits,\n                        mode == REPLAY_MODE_REVERT ? last_commit : onto,\n                        &merge_opt, &result, mode, opts->empty);\n\nwith:\n\n    pick_regular_commit(revs->repo, commit, replayed_commits,\n                        reverse ? last_commit : onto,\n                        &merge_opt, &result, reverse, opts->empty);\n\nYou can argue which of both is easier to read, but the problem isn't\nreally whether it's a bool or an enum, but the ternary operator in this\nlengthy function call is. That is the problem I was trying to solve, and\nconverting enum to bool isn't really the solution.\n\n>> And what if we ever have to create a third mode going forward?\n\nPersonally I find this weak argument. As far as I know we most of the\ntime do not write code in a way so \"it will be ready to add X in the\nfuture\". In my personal experience, I'm always wrong in predicting what\nmight be added in the future. Although I must say this case is\ndifferent, because we're not adding something new, no this commit was\ndumbing down something existing. So I'll revisit this commit in the next\niteration.\n\n>> I'm generally no fan of booleans as parameters as they basically give\n>> you no information at all at the callsite, except if you're lucky and\n>> you already have an aptly-named variable available that you can pass.\n>> Which seems to be the case here, but I'm still not sure whether this\n>> change really improves the code.\n\nThat's also a very valid argument, which I didn't take in mind.\n\n> I tend to agree with you on both counts.  The \"what happens when\n> somebody else wants a third choice?\" is a quesiton I would ask the\n> first thing as the maintainer of a project.\n>\n> Even if the boolean parameter is so obviously named, the callsite\n> can only say \"true\" or \"false\", unlike some other popular languages\n> that lets you say\n>\n> \tmy_function(use_revert_mode=true, verbose=false);\n>\n> and you cannot tell what effect the author wanted out of that \"true\"\n> if all you can write were\n>\n> \tmy_function(true, false);\n>\n> Of course, we could go ultra verbose, like\n>\n> \tmy_function(true, /* use_revert_mode */\n> \t\t    false, /* verbose */);\n>\n> but then we are often better off writing:\n>\n> \tmy_function(REPLAY_MODE_REVERT, REPLAY_QUIET);\n\nThanks for bringing in this illustrative example. Point made, I'll\nrevisit.\n\n-- \nCheers,\nToon\n"},{"id":"546441","messageId":"87qzltyiao.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"ajk-a4a3KSJ2u7Ju@pks.im","subject":"Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-26T05:36:31Z","receivedAt":"2026-06-26T05:36:42Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> git-rebase(1) essentially knows about three different modes:\n>\n>   - \"--no-rebase-merges\", which is the default and maps to your\n>     \"--linearize\".\n>\n>   - \"--rebase-merges\", which by default doesn't rebase cousins by using\n>     \"--ancestry-path\" internally.\n>\n>   - \"--rebase-merges=rebase-cousins\", which doesn't pass the above\n>     option.\n>\n> So it's not a simple boolean there, which makes me wonder whether we\n> should mirror the same interface so that all of git-rebase(1)'s modes\n> can be represented, as well.\n\nThat's a valid question, although I don't know a good answer to that.\n\nBasically you're asking for what the command line options will look\nlike? Allow me to think out loud.\n\nIn this series I'm adding --linearize to git-replay(1). As mentioned, I\ndon't think it makes sense to add it to git-history(1) as well. Without\nthis option, the process aborts when it encounters a merge.\n\nDscho sent a patch series to properly replay (2-way) merges. I think\nthis should become the default for both git-replay(1) and\ngit-history(1).\n\nBut then, do we want to have an option that brings back the current\nbehavior of aborting at merges? Maybe with --no-merges?\n\nThen there's the option of rebasing cousins left. That's something that\nisn't covered by Dscho's series yet. Maybe --replay-cousins?\n\nTo reiterate what the final design could look like:\n\n * <nothing>: replay merges preserving topology.\n * \"--linearize\": flattens merges (only git-replay(1)).\n * \"--no-merges\": dies when the process tries to replay a merge.\n * \"--replay-cousins\": does what --rebase-merges=rebase-cousins does.\n\nNow, all these options are (I think) mutually exclusive, so we could\nconsider an option \"--replay-merges=<mode>\", but personally I find\n\"--<option>=<value>\" arguments harder to use than specifying separate\noptions.\n\nI think I'm avoiding your question, because the design of the command\nline parameters doesn't need tot 1-on-1 correlate to the internal\ndatastructure. And I agree the mode isn't a boolean, but does that mean\nwe want to use an enum internally? Well, I don't know. And I also don't\nthink that matters right now. Code is easy to change, I think the\ncommand line options should be designed with the future in mind, which I\nbelieve we do with \"--linearize\".\n\nSorry for this long-winded rambling, but bottom line I think it's fine\nto add --linearize and in the future add more options and see how the\ncode should evolve to support those.\n\n>> diff --git a/replay.c b/replay.c\n>> index 7921d7dba3..5539daff00 100644\n>> --- a/replay.c\n>> +++ b/replay.c\n>> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>>  \t\t\t\t\t  struct commit *onto,\n>>  \t\t\t\t\t  struct merge_options *merge_opt,\n>>  \t\t\t\t\t  struct merge_result *result,\n>> +\t\t\t\t\t  struct commit *replayed_base,\n>>  \t\t\t\t\t  bool reverse,\n>>  \t\t\t\t\t  enum replay_empty_commit_action empty)\n>>  {\n>> -\tstruct commit *base, *replayed_base;\n>> +\tstruct commit *base;\n>>  \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n>>  \n>> +\tif (replayed_base && reverse)\n>> +\t\tBUG(\"Linearizing commits is not supported when replaying in reverse\");\n>\n> Nit: Error messages should typically start with a lower-case letter.\n\nThanks.\n\n>> @@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,\n>>  \twhile ((commit = get_revision(revs))) {\n>>  \t\tconst struct name_decoration *decoration;\n>>  \n>> -\t\tif (commit->parents && commit->parents->next)\n>> -\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n>> +\t\tif (commit->parents && commit->parents->next) {\n>> +\t\t\tif (!opts->linearize)\n>> +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n>> +\t\t\t/*\n>> +\t\t\t * Drop the merge commit: do not pick it and leave\n>> +\t\t\t * last_commit unchanged, so its children (and any ref\n>> +\t\t\t * pointing at it) are reparented onto the previous\n>> +\t\t\t * non-merge commit, which the ref-update loop below uses.\n>> +\t\t\t */\n>\n> One could add a hint here that tells the user to pass the option. But I\n> guess that might be somewhat weird, as we cannot assume that we're\n> called by git-replay(1) here.\n\nYeah, true...\n\n-- \nCheers,\nToon\n"},{"id":"546442","messageId":"20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com","threadId":"65771","inReplyTo":"20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com","subject":"[PATCH v5 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-26T05:48:10Z","receivedAt":"2026-06-26T05:48:26Z","isPatch":true,"body":"As an alternative to dscho's patch series to replay merges[1], add\noption to git-replay(1) to linearize merges. This mimics what\ngit-rebase(1) does too with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. This patch was kindly provided by Dscho, which I've\ntweaked to be upstreamed.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion it doesn't make much sense to do\nso, but I'm happy to hear if anyone disagrees.\n\nThis series might conflict with Kristoffer's series to make\ndocumentation changes[2], but should be trivial to resolve. And I don't\nthink there's a conflict with Patrick's series on adding \"drop\" to\ngit-history(1)[3].\n\ndscho's series to replay merges[1] needs a bit of rework to fit on top\nof this, but I'm happy to help figuring that out. We've been discussing\nto either name the option --flatten or --linearize, but I've decided on\n\"linearize\" because the documentation of git-rebase(1) also mentions\n\"linearize\".\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n[2]: <V2_CV_doc_replay_config.767@msgid.xyz>\n[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>\n\n---\nChanges in v5:\n- Dropped the enum->bool patch and instead added a patch that better\n  explains how pick_regular_commit() picks a base.\n- Order of commits is shuffled.\n- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool\n  patch, I extended the code comments to explain how things work. This\n  made me realize the use of the \"replayed_base\" was incorrect when\n  multiple branches are rebased with --onto. This is fixed now and a\n  test is added for this scenario.\n- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com\n\nChanges in v4:\n- Use test_grep instead of a bare grep in the range-diff test, to\n  prepare for mm/test-grep-lint.\n- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com\n\nChanges in v3:\n- Add --linearize to Documentation SYNOPSIS, and mention it's\n  incompatible with --revert.\n- Small language change in help message for --linearize.\n- Rephrase comment to include last_commit isn't modified when\n  linearizing merges.\n- Remove test that was added in earlier versions, but actually is\n  a duplicate of 'replaying merge commits is not supported yet'.\n- Add test to verify --revert and --linearize are incompatible.\n- Properly test that replaying down to root with --linearize works.\n- Add test for --linearize with --advance.\n- Add test that uses git-range-diff(1) to verify the patches created by\n  --linearize are correct.\n- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com\n\nChanges in v2:\n- Restructured the conditions to detect merge commits and added a line\n  of comment why the loop continues.\n- Rewrote tests to use the history from the setup step and added a few\n  test cases.\n- Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n  patches with this trailer, and if I understand correctly, I can keep\n  it. Please let me know if that wrong.\n- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n\n---\nJohannes Schindelin (1):\n      replay: offer an option to linearize the commit topology\n\nToon Claes (2):\n      replay: add helper to put entry into mapped_commits\n      replay: better explain how pick_regular_commit() picks a base\n\n Documentation/git-replay.adoc |  8 ++++-\n builtin/replay.c              |  6 +++-\n replay.c                      | 69 ++++++++++++++++++++++++++---------\n replay.h                      |  5 +++\n t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 152 insertions(+), 20 deletions(-)\n\nRange-diff versus v4:\n\n1:  a08bc22330 < -:  ---------- replay: refactor enum replay_mode into a bool\n2:  3117fddcc5 = 1:  bbd5a710bd replay: add helper to put entry into mapped_commits\n-:  ---------- > 2:  e08c7b46c0 replay: better explain how pick_regular_commit() picks a base\n3:  acbb1df6a9 ! 3:  043cf63c1c replay: offer an option to linearize the commit topology\n    @@ builtin/replay.c: int cmd_replay(int argc,\n      \tref_mode = get_ref_action_mode(repo, ref_action);\n     \n      ## replay.c ##\n    -@@ replay.c: static struct commit *pick_regular_commit(struct repository *repo,\n    - \t\t\t\t\t  struct commit *onto,\n    - \t\t\t\t\t  struct merge_options *merge_opt,\n    - \t\t\t\t\t  struct merge_result *result,\n    -+\t\t\t\t\t  struct commit *replayed_base,\n    - \t\t\t\t\t  bool reverse,\n    - \t\t\t\t\t  enum replay_empty_commit_action empty)\n    - {\n    --\tstruct commit *base, *replayed_base;\n    -+\tstruct commit *base;\n    - \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n    - \n    -+\tif (replayed_base && reverse)\n    -+\t\tBUG(\"Linearizing commits is not supported when replaying in reverse\");\n    -+\n    - \tif (pickme->parents) {\n    - \t\tbase = pickme->parents->item;\n    - \t\tbase_tree = repo_get_commit_tree(repo, base);\n    -@@ replay.c: static struct commit *pick_regular_commit(struct repository *repo,\n    - \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n    - \t}\n    - \n    --\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n    -+\tif (!replayed_base)\n    -+\t\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n    - \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n    - \tpickme_tree = repo_get_commit_tree(repo, pickme);\n    - \n     @@ replay.c: int replay_revisions(struct rev_info *revs,\n      \twhile ((commit = get_revision(revs))) {\n      \t\tconst struct name_decoration *decoration;\n      \n    +-\t\t/*\n    +-\t\t * pick_regular_commit() looks up the parent of `commit` in\n    +-\t\t * `replayed_commits` to determine the ancestor to replay onto.\n    +-\t\t * The `default_base` parameter is used when no ancestor is found,\n    +-\t\t * which happens for the first commit in the revision range.\n    +-\t\t * When reverting, commits are replayed in reverse order, so the\n    +-\t\t * lookup never succeeds, and we need to pass `last_commit`.\n    +-\t\t */\n    +-\t\tstruct commit *base = onto;\n    +-\t\tif (mode == REPLAY_MODE_REVERT)\n    +-\t\t\tbase = last_commit;\n    +-\n     -\t\tif (commit->parents && commit->parents->next)\n     -\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n    +-\n    +-\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n    +-\t\t\t\t\t\t  replayed_commits,\n    +-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n     +\t\tif (commit->parents && commit->parents->next) {\n     +\t\t\tif (!opts->linearize)\n     +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n     +\t\t\t/*\n    -+\t\t\t * Drop the merge commit: do not pick it and leave\n    -+\t\t\t * last_commit unchanged, so its children (and any ref\n    -+\t\t\t * pointing at it) are reparented onto the previous\n    -+\t\t\t * non-merge commit, which the ref-update loop below uses.\n    ++\t\t\t * Drop the merge commit: do not pick it, leave\n    ++\t\t\t * `last_commit` unchanged, and fall through to the\n    ++\t\t\t * rest of the loop. As a result:\n    ++\t\t\t * - the merge commit is mapped to `last_commit` in\n    ++\t\t\t *   `replayed_commits`, this will become the parent for\n    ++\t\t\t *   the child commits.\n    ++\t\t\t * - refs previously pointing to the merge commit are\n    ++\t\t\t *   rewritten to point to the previous non-merge commit.\n     +\t\t\t */\n     +\t\t} else {\n    -+\t\t\tstruct commit *to_pick = reverse ? last_commit : onto;\n    -+\t\t\tlast_commit =\n    -+\t\t\t\tpick_regular_commit(revs->repo, commit,\n    -+\t\t\t\t\t\t    replayed_commits, to_pick,\n    -+\t\t\t\t\t\t    &merge_opt, &result,\n    -+\t\t\t\t\t\t    opts->linearize ? last_commit : NULL,\n    -+\t\t\t\t\t\t    reverse, opts->empty);\n    ++\t\t\t/*\n    ++\t\t\t * pick_regular_commit() looks up the parent of `commit` in\n    ++\t\t\t * `replayed_commits` to determine the ancestor to replay onto.\n    ++\t\t\t * The `default_base` parameter is used when no ancestor is found,\n    ++\t\t\t * which happens for the first commit in the revision range.\n    ++\t\t\t * When reverting, commits are replayed in reverse order, so the\n    ++\t\t\t * lookup never succeeds, and we need to pass `last_commit`.\n    ++\t\t\t */\n    ++\t\t\tstruct commit *base = onto;\n    ++\t\t\tif (mode == REPLAY_MODE_REVERT)\n    ++\t\t\t\tbase = last_commit;\n    ++\n    ++\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n    ++\t\t\t\t\t\t\t  replayed_commits,\n    ++\t\t\t\t\t\t\t  &merge_opt, &result,\n    ++\t\t\t\t\t\t\t  mode, opts->empty);\n     +\t\t}\n    - \n    --\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n    --\t\t\t\t\t\t  reverse ? last_commit : onto,\n    --\t\t\t\t\t\t  &merge_opt, &result, reverse, opts->empty);\n    ++\n      \t\tif (!last_commit)\n      \t\t\tbreak;\n      \n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +\tgit log --oneline main..$tip >out &&\n     +\ttest_line_count = 3 out\n     +'\n    ++\n    ++test_expect_success 'replay with --linearize to rebase multiple divergent branches' '\n    ++\tgit replay --ref-action=print --linearize \\\n    ++\t\t--onto main ^B topic2 topic-with-merge >result &&\n    ++\n    ++\ttest_line_count = 2 result &&\n    ++\tcut -f 3 -d \" \" result >new-branch-tips &&\n    ++\n    ++\tgit log --format=%s $(head -n 1 new-branch-tips) >actual &&\n    ++\ttest_write_lines E D C M L B A >expect &&\n    ++\ttest_cmp expect actual &&\n    ++\n    ++\tgit log --format=%s $(tail -n 1 new-branch-tips) >actual &&\n    ++\ttest_write_lines O N J I M L B A >expect &&\n    ++\ttest_cmp expect actual\n    ++'\n     +\n      test_done\n\n\n---\nbase-commit: ab776a62a78576513ee121424adb19597fbb7613\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"546443","messageId":"20260626-toon-git-replay-drop-merges-v5-1-5e120738b9d0@iotcl.com","threadId":"65771","inReplyTo":"20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com","subject":"[PATCH v5 1/3] replay: add helper to put entry into mapped_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-26T05:48:11Z","receivedAt":"2026-06-26T05:48:40Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put a commit entry into mapped_commits into a helper\nfunction put_mapped_commit().\n\nWhile at it, rename mapped_commit() to get_mapped_commit() to pair with\nthis new function.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex da531d5bc6..7bde1c7e93 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -250,9 +250,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -263,6 +263,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -283,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -423,8 +438,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -436,11 +449,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"546444","messageId":"20260626-toon-git-replay-drop-merges-v5-2-5e120738b9d0@iotcl.com","threadId":"65771","inReplyTo":"20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com","subject":"[PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-26T05:48:12Z","receivedAt":"2026-06-26T05:48:44Z","isPatch":true,"body":"The function pick_regular_commit() will replay the `pickme` commit. To\ndetermine the ancestor where to replay this commit on, it takes the\nparent of the commit and looks up its replayed result in\n`replayed_commits`. If no ancestor is found, the `onto` parameter is\nused as fallback.\n\nThe name `onto` is rather confusing, so rename it to `default_base`. And\nwhile at it, shuffle the function parameters so `struct commit`\nparameters are immediate siblings.\n\nWhen in mode REPLAY_MODE_REVERT, the fallback `default_base` will always\nbe used. This happens because commits are replayed in reverse order, so\nlooking up the `pickme`'s parent in `replayed_commits` will always\nreturn empty. And to make these commits stack on top of each other, we\nneed to pass in `last_commit`.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 20 ++++++++++++++++----\n 1 file changed, 16 insertions(+), 4 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 7bde1c7e93..86fba47fb9 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -280,8 +280,8 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n \n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n+\t\t\t\t\t  struct commit *default_base,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n-\t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n \t\t\t\t\t  enum replay_mode mode,\n@@ -298,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, default_base);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -439,11 +439,23 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n+\t\t/*\n+\t\t * pick_regular_commit() looks up the parent of `commit` in\n+\t\t * `replayed_commits` to determine the ancestor to replay onto.\n+\t\t * The `default_base` parameter is used when no ancestor is found,\n+\t\t * which happens for the first commit in the revision range.\n+\t\t * When reverting, commits are replayed in reverse order, so the\n+\t\t * lookup never succeeds, and we need to pass `last_commit`.\n+\t\t */\n+\t\tstruct commit *base = onto;\n+\t\tif (mode == REPLAY_MODE_REVERT)\n+\t\t\tbase = last_commit;\n+\n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n+\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t  replayed_commits,\n \t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"546445","messageId":"20260626-toon-git-replay-drop-merges-v5-3-5e120738b9d0@iotcl.com","threadId":"65771","inReplyTo":"20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com","subject":"[PATCH v5 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-26T05:48:13Z","receivedAt":"2026-06-26T05:48:56Z","isPatch":true,"body":"From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n\nOne of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearizes the commit history into a sequence of the\nregular (single-parent) commits.\n\nAdd option `--linearize` to git-replay(1) to do the same.\n\nCo-authored-by: Toon Claes <toon@iotcl.com>\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  8 ++++-\n builtin/replay.c              |  6 +++-\n replay.c                      | 50 ++++++++++++++++----------\n replay.h                      |  5 +++\n t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 132 insertions(+), 21 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex a32f72aead..ef56ee0f1b 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n --------\n [verse]\n (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n-\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n+\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n+\ti.e. it cherry-picks only non-merge commits, each one on top of the\n+\tprevious one.\n+\tThis option is incompatible with `--revert`.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..62962c73c7 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -85,7 +85,7 @@ int cmd_replay(int argc,\n \tconst char *const replay_usage[] = {\n \t\tN_(\"(EXPERIMENTAL!) git replay \"\n \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n-\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n+\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n \t\tNULL\n \t};\n \tstruct option replay_options[] = {\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n \t\tOPT_END()\n \t};\n \n@@ -132,6 +134,8 @@ int cmd_replay(int argc,\n \t\t\t\t  opts.contained, \"--contained\");\n \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n \t\t\t\t  !!opts.contained, \"--contained\");\n+\tdie_for_incompatible_opt2(!!opts.revert, \"--revert\",\n+\t\t\t\t  opts.linearize, \"--linearize\");\n \n \t/* Parse ref action mode from command line or config */\n \tref_mode = get_ref_action_mode(repo, ref_action);\ndiff --git a/replay.c b/replay.c\nindex 86fba47fb9..d803e0312f 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -439,24 +439,38 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\t/*\n-\t\t * pick_regular_commit() looks up the parent of `commit` in\n-\t\t * `replayed_commits` to determine the ancestor to replay onto.\n-\t\t * The `default_base` parameter is used when no ancestor is found,\n-\t\t * which happens for the first commit in the revision range.\n-\t\t * When reverting, commits are replayed in reverse order, so the\n-\t\t * lookup never succeeds, and we need to pass `last_commit`.\n-\t\t */\n-\t\tstruct commit *base = onto;\n-\t\tif (mode == REPLAY_MODE_REVERT)\n-\t\t\tbase = last_commit;\n-\n-\t\tif (commit->parents && commit->parents->next)\n-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n-\n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n-\t\t\t\t\t\t  replayed_commits,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (!opts->linearize)\n+\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\t\t/*\n+\t\t\t * Drop the merge commit: do not pick it, leave\n+\t\t\t * `last_commit` unchanged, and fall through to the\n+\t\t\t * rest of the loop. As a result:\n+\t\t\t * - the merge commit is mapped to `last_commit` in\n+\t\t\t *   `replayed_commits`, this will become the parent for\n+\t\t\t *   the child commits.\n+\t\t\t * - refs previously pointing to the merge commit are\n+\t\t\t *   rewritten to point to the previous non-merge commit.\n+\t\t\t */\n+\t\t} else {\n+\t\t\t/*\n+\t\t\t * pick_regular_commit() looks up the parent of `commit` in\n+\t\t\t * `replayed_commits` to determine the ancestor to replay onto.\n+\t\t\t * The `default_base` parameter is used when no ancestor is found,\n+\t\t\t * which happens for the first commit in the revision range.\n+\t\t\t * When reverting, commits are replayed in reverse order, so the\n+\t\t\t * lookup never succeeds, and we need to pass `last_commit`.\n+\t\t\t */\n+\t\t\tstruct commit *base = onto;\n+\t\t\tif (mode == REPLAY_MODE_REVERT)\n+\t\t\t\tbase = last_commit;\n+\n+\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t\t  replayed_commits,\n+\t\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t\t  mode, opts->empty);\n+\t\t}\n+\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex faf95c7459..64f42b6512 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..34c038eab9 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -52,8 +52,12 @@ test_expect_success 'setup' '\n \ttest_merge P O --no-ff &&\n \tgit switch main &&\n \n+\tgit switch --orphan unrelated &&\n+\ttest_commit unrelated-root &&\n+\n \tgit switch -c conflict B &&\n-\ttest_commit C.conflict C.t conflict\n+\ttest_commit C.conflict C.t conflict &&\n+\tgit branch -D unrelated\n '\n \n test_expect_success 'setup bare' '\n@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '\n \ttest_grep \"cannot be used together\" actual\n '\n \n+test_expect_success '--revert and --linearize cannot be used together' '\n+\ttest_must_fail git replay --revert=main --linearize \\\n+\t\ttopic1..topic2 2>actual &&\n+\ttest_grep \"cannot be used together\" actual\n+'\n+\n test_expect_success 'cannot advance target ... ordering would be ill-defined' '\n \techo \"fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined\" >expect &&\n \ttest_must_fail git replay --advance=main main topic1 topic2 2>actual &&\n@@ -565,4 +575,76 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'replay to rebase merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto unrelated-root topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J I B A unrelated-root >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to cherry-pick merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--advance main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tprintf \"update refs/heads/main \" >expect &&\n+\tprintf \"%s \" $(cut -f 3 -d \" \" result) >>expect &&\n+\tgit rev-parse main >>expect &&\n+\ttest_cmp expect result\n+'\n+\n+test_expect_success 'replay --linearize produces the same patches' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# range-diff does not care about the dropped merge,\n+\t# so the original commits (I..topic-with-merge)\n+\t# and the replayed chain (main..tip) must produce identical patches.\n+\tgit range-diff I..topic-with-merge main..$tip >out &&\n+\ttest_file_not_empty out &&\n+\ttest_grep ! -v \"=\" out &&\n+\n+\tgit log --oneline main..$tip >out &&\n+\ttest_line_count = 3 out\n+'\n+\n+test_expect_success 'replay with --linearize to rebase multiple divergent branches' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main ^B topic2 topic-with-merge >result &&\n+\n+\ttest_line_count = 2 result &&\n+\tcut -f 3 -d \" \" result >new-branch-tips &&\n+\n+\tgit log --format=%s $(head -n 1 new-branch-tips) >actual &&\n+\ttest_write_lines E D C M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tgit log --format=%s $(tail -n 1 new-branch-tips) >actual &&\n+\ttest_write_lines O N J I M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n test_done\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"546491","messageId":"xmqqa4sh8cvr.fsf@gitster.g","threadId":"65771","inReplyTo":"20260626-toon-git-replay-drop-merges-v5-1-5e120738b9d0@iotcl.com","subject":"Re: [PATCH v5 1/3] replay: add helper to put entry into mapped_commits","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-26T16:50:16Z","receivedAt":"2026-06-26T16:50:19Z","isPatch":true,"body":"Toon Claes <toon@iotcl.com> writes:\n\n> +static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n> +\t\t\t      struct commit *commit,\n> +\t\t\t      struct commit *new_commit)\n> +{\n> +\tkhint_t pos;\n> +\tint ret;\n> +\n> +\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n> +\tif (ret == 0)\n> +\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n\nPlease do not add terminating LF at the end of single-liner messages\nthat use our print infrastructure, like BUG(), warning(), error(),\nand die(), as the machinery adds one for you.\n\nOther than that, looking good.\n"},{"id":"546495","messageId":"xmqq5x358byf.fsf@gitster.g","threadId":"65771","inReplyTo":"20260626-toon-git-replay-drop-merges-v5-3-5e120738b9d0@iotcl.com","subject":"Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-26T17:10:16Z","receivedAt":"2026-06-26T17:10:19Z","isPatch":true,"body":"Toon Claes <toon@iotcl.com> writes:\n\n>  Documentation/git-replay.adoc |  8 ++++-\n>  builtin/replay.c              |  6 +++-\n>  replay.c                      | 50 ++++++++++++++++----------\n>  replay.h                      |  5 +++\n>  t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-\n>  5 files changed, 132 insertions(+), 21 deletions(-)\n\n\"replay --linearize\" behaves differently from the flattening rebase\nin a case where X and Y that forked from A are merged at Z, and we\nask to flatten the history leading to Z, doesn't it?\n\n     A----X\n      \\    \\\n       Y----Z (tip)\n\nA typical flattening rebase would rewrite X to X', Y to Y', while\ndropping Z, and would leave us a flattened history, like\n\n     A---X'---Y' (updated tip, the order of X' and Y' may be swapped)\n\nI may be misreading the logic, but doesn't \"replay --linearize\"\ninstead produce\n\n     A----X' (dangling)\n      \\ \n       Y' (tip -- Z is dropped and gets mapped)\n\nand leave X' dangling (or Y'; the point is that only one of them\nwill survive), never incorporating it in the resulting history?\n\n> +\t\tif (commit->parents && commit->parents->next) {\n> +\t\t\tif (!opts->linearize)\n> +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n> +\t\t\t/*\n> +\t\t\t * Drop the merge commit: do not pick it, leave\n> +\t\t\t * `last_commit` unchanged, and fall through to the\n> +\t\t\t * rest of the loop. As a result:\n> +\t\t\t * - the merge commit is mapped to `last_commit` in\n> +\t\t\t *   `replayed_commits`, this will become the parent for\n> +\t\t\t *   the child commits.\n> +\t\t\t * - refs previously pointing to the merge commit are\n> +\t\t\t *   rewritten to point to the previous non-merge commit.\n> +\t\t\t */\n> +\t\t} else {\n> +\t\t\t/*\n> +\t\t\t * pick_regular_commit() looks up the parent of `commit` in\n> +\t\t\t * `replayed_commits` to determine the ancestor to replay onto.\n> +\t\t\t * The `default_base` parameter is used when no ancestor is found,\n> +\t\t\t * which happens for the first commit in the revision range.\n> +\t\t\t * When reverting, commits are replayed in reverse order, so the\n> +\t\t\t * lookup never succeeds, and we need to pass `last_commit`.\n> +\t\t\t */\n> +\t\t\tstruct commit *base = onto;\n> +\t\t\tif (mode == REPLAY_MODE_REVERT)\n> +\t\t\t\tbase = last_commit;\n> +\n> +\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n> +\t\t\t\t\t\t\t  replayed_commits,\n> +\t\t\t\t\t\t\t  &merge_opt, &result,\n> +\t\t\t\t\t\t\t  mode, opts->empty);\n> +\t\t}\n> +\n>  \t\tif (!last_commit)\n>  \t\t\tbreak;\n\nImmediately after this hunk beyond the post-context are these lines.\n\n\t\t/* Record commit -> last_commit mapping */\n\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n\nLet's imagine X gets processed first. X (and other commits on its\nbranch) gets replayed, last_commit is set to X' (which is the\nrewritten X).  replayed_commits mapping holds X->X' mapping.\n\nThen let's imagine the history leading to Y is replayed next.\nlast_commit becomes Y', and Y->Y' mapping is stored in\nreplayed_commits.\n\nFinally, we see Z.  We are going to _drop_ it.  last_commit is left\nunchanged, pointing at Y'.  Then last_commit (i.e., Y') is used as\nthe merge commit Z maps to (i.e., correctly dropping Z).\n\nAny descendants of Z, if any, will be grafted as descendants of Y'.\nIf X did not have any descendants other than Z in the rewritten part\nof the history, then X' (and commits leading to it) would be lost,\nno?\n\nThis \"loss of the other branch\" may be an inherent characteristic of\nthis feature (i.e., I do not think it is necessarily a bug, and it\nmay even be that the \"bug\" is in the way I am reading the patch),\nbut then I wonder if the user may want to have control over which\nside branch should survive, perhaps?  It would probably need to be\ndocumented, and a test or two to cast this behaviour in stone.\n\n> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n> index 3353bc4a4d..34c038eab9 100755\n> --- a/t/t3650-replay-basics.sh\n> +++ b/t/t3650-replay-basics.sh\n> @@ -52,8 +52,12 @@ test_expect_success 'setup' '\n\nThe pre-context here has\n\n\tgit switch --detach topic4 &&\n\ttest_commit N &&\n\ttest_commit O &&\n\tgit switch -c topic-with-merge topic4 &&\n\n>  \ttest_merge P O --no-ff &&\n>  \tgit switch main &&\n\nThe above does prepare topic-with-merge branch, but ...\n\n> +test_expect_success 'replay to rebase merge commit with --linearize' '\n> +\tgit replay --ref-action=print --linearize \\\n> +\t\t--onto main I..topic-with-merge >result &&\n\n... this does not really exersize linearizing replay in a typical\nmergy history.  P merges O with --no-ff because otherwise there\nwon't be a merge, since O is a descendant of the commit \"test_merge\nP O\" runs on (i.e., topic4 == topic-with-merge).\n\n    topic4 --- N --- O\n          \\           \\\n           .-----------P\n\nSo, as long as O is replayed later than the parent of N (which is\ntrue), O' will be the surviving tip (corresponds to Y' that the\ndropped Z was mapped to in the earlier example), and nothing gets\norphaned, I think.\n\nPerhaps a test to try a real merge may look something like this.\n\ndiff --git c/t/t3650-replay-basics.sh w/t/t3650-replay-basics.sh\nindex 34c038eab9..bb737f729a 100755\n--- c/t/t3650-replay-basics.sh\n+++ w/t/t3650-replay-basics.sh\n@@ -647,4 +647,37 @@ test_expect_success 'replay with --linearize to rebase multiple divergent branch\n \ttest_cmp expect actual\n '\n \n+test_expect_success 'replay with --linearize of a divergent merge drops one branch' '\n+\tgit switch -c topic-divergent-base main &&\n+\ttest_commit base &&\n+\t# Fork 1: base -> X\n+\tgit switch -c topic-divergent-x &&\n+\ttest_commit X &&\n+\t# Fork 2: base -> Y\n+\tgit switch topic-divergent-base &&\n+\tgit switch -c topic-divergent-y &&\n+\ttest_commit Y &&\n+\t# Merge them at Z\n+\tgit switch topic-divergent-x &&\n+\ttest_merge Z topic-divergent-y --no-ff &&\n+\n+\t# History is now:\n+\t#\n+\t#       X - Z (topic-divergent-x)\n+\t#      /   /\n+\t#  base - Y\n+\t#\n+\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main topic-divergent-base..topic-divergent-x >result &&\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\t# Get the commits replayed onto main\n+\tgit log --format=%s main..$tip >actual &&\n+\t# We expect exactly one commit to be replayed (either X or Y)\n+\t# because the other one is left dangling due to the merge being dropped.\n+\ttest_line_count = 1 actual &&\n+\ttest_grep \"^[XY]$\" actual\n+'\n+\n test_done\n"},{"id":"546534","messageId":"b5d70a0b-ef32-49c9-84ba-8a64b7809574@gmail.com","threadId":"65771","inReplyTo":"xmqq5x358byf.fsf@gitster.g","subject":"Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-27T13:44:11Z","receivedAt":"2026-06-27T13:44:15Z","isPatch":true,"body":"On 26/06/2026 18:10, Junio C Hamano wrote:\n> Toon Claes <toon@iotcl.com> writes:\n> \n>>   Documentation/git-replay.adoc |  8 ++++-\n>>   builtin/replay.c              |  6 +++-\n>>   replay.c                      | 50 ++++++++++++++++----------\n>>   replay.h                      |  5 +++\n>>   t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-\n>>   5 files changed, 132 insertions(+), 21 deletions(-)\n> \n> \"replay --linearize\" behaves differently from the flattening rebase\n> in a case where X and Y that forked from A are merged at Z, and we\n> ask to flatten the history leading to Z, doesn't it?\n\nThat's a good point. rebase takes the list of commits given by \"git \nrev-list --reverse --no-merges\" and cherry-picks each on on top of the \nprevious one. In contrast replay cherry-picks each commit on top of its \nrewritten parent so it does not flatten the topology.\n\nThanks\n\nPhillip\n>       A----X\n>        \\    \\\n>         Y----Z (tip)\n> \n> A typical flattening rebase would rewrite X to X', Y to Y', while\n> dropping Z, and would leave us a flattened history, like\n> \n>       A---X'---Y' (updated tip, the order of X' and Y' may be swapped)\n> \n> I may be misreading the logic, but doesn't \"replay --linearize\"\n> instead produce\n> \n>       A----X' (dangling)\n>        \\\n>         Y' (tip -- Z is dropped and gets mapped)\n> \n> and leave X' dangling (or Y'; the point is that only one of them\n> will survive), never incorporating it in the resulting history?\n> \n>> +\t\tif (commit->parents && commit->parents->next) {\n>> +\t\t\tif (!opts->linearize)\n>> +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n>> +\t\t\t/*\n>> +\t\t\t * Drop the merge commit: do not pick it, leave\n>> +\t\t\t * `last_commit` unchanged, and fall through to the\n>> +\t\t\t * rest of the loop. As a result:\n>> +\t\t\t * - the merge commit is mapped to `last_commit` in\n>> +\t\t\t *   `replayed_commits`, this will become the parent for\n>> +\t\t\t *   the child commits.\n>> +\t\t\t * - refs previously pointing to the merge commit are\n>> +\t\t\t *   rewritten to point to the previous non-merge commit.\n>> +\t\t\t */\n>> +\t\t} else {\n>> +\t\t\t/*\n>> +\t\t\t * pick_regular_commit() looks up the parent of `commit` in\n>> +\t\t\t * `replayed_commits` to determine the ancestor to replay onto.\n>> +\t\t\t * The `default_base` parameter is used when no ancestor is found,\n>> +\t\t\t * which happens for the first commit in the revision range.\n>> +\t\t\t * When reverting, commits are replayed in reverse order, so the\n>> +\t\t\t * lookup never succeeds, and we need to pass `last_commit`.\n>> +\t\t\t */\n>> +\t\t\tstruct commit *base = onto;\n>> +\t\t\tif (mode == REPLAY_MODE_REVERT)\n>> +\t\t\t\tbase = last_commit;\n>> +\n>> +\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n>> +\t\t\t\t\t\t\t  replayed_commits,\n>> +\t\t\t\t\t\t\t  &merge_opt, &result,\n>> +\t\t\t\t\t\t\t  mode, opts->empty);\n>> +\t\t}\n>> +\n>>   \t\tif (!last_commit)\n>>   \t\t\tbreak;\n> \n> Immediately after this hunk beyond the post-context are these lines.\n> \n> \t\t/* Record commit -> last_commit mapping */\n> \t\tput_mapped_commit(replayed_commits, commit, last_commit);\n> \n> Let's imagine X gets processed first. X (and other commits on its\n> branch) gets replayed, last_commit is set to X' (which is the\n> rewritten X).  replayed_commits mapping holds X->X' mapping.\n> \n> Then let's imagine the history leading to Y is replayed next.\n> last_commit becomes Y', and Y->Y' mapping is stored in\n> replayed_commits.\n> \n> Finally, we see Z.  We are going to _drop_ it.  last_commit is left\n> unchanged, pointing at Y'.  Then last_commit (i.e., Y') is used as\n> the merge commit Z maps to (i.e., correctly dropping Z).\n> \n> Any descendants of Z, if any, will be grafted as descendants of Y'.\n> If X did not have any descendants other than Z in the rewritten part\n> of the history, then X' (and commits leading to it) would be lost,\n> no?\n> \n> This \"loss of the other branch\" may be an inherent characteristic of\n> this feature (i.e., I do not think it is necessarily a bug, and it\n> may even be that the \"bug\" is in the way I am reading the patch),\n> but then I wonder if the user may want to have control over which\n> side branch should survive, perhaps?  It would probably need to be\n> documented, and a test or two to cast this behaviour in stone.\n> \n>> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n>> index 3353bc4a4d..34c038eab9 100755\n>> --- a/t/t3650-replay-basics.sh\n>> +++ b/t/t3650-replay-basics.sh\n>> @@ -52,8 +52,12 @@ test_expect_success 'setup' '\n> \n> The pre-context here has\n> \n> \tgit switch --detach topic4 &&\n> \ttest_commit N &&\n> \ttest_commit O &&\n> \tgit switch -c topic-with-merge topic4 &&\n> \n>>   \ttest_merge P O --no-ff &&\n>>   \tgit switch main &&\n> \n> The above does prepare topic-with-merge branch, but ...\n> \n>> +test_expect_success 'replay to rebase merge commit with --linearize' '\n>> +\tgit replay --ref-action=print --linearize \\\n>> +\t\t--onto main I..topic-with-merge >result &&\n> \n> ... this does not really exersize linearizing replay in a typical\n> mergy history.  P merges O with --no-ff because otherwise there\n> won't be a merge, since O is a descendant of the commit \"test_merge\n> P O\" runs on (i.e., topic4 == topic-with-merge).\n> \n>      topic4 --- N --- O\n>            \\           \\\n>             .-----------P\n> \n> So, as long as O is replayed later than the parent of N (which is\n> true), O' will be the surviving tip (corresponds to Y' that the\n> dropped Z was mapped to in the earlier example), and nothing gets\n> orphaned, I think.\n> \n> Perhaps a test to try a real merge may look something like this.\n> \n> diff --git c/t/t3650-replay-basics.sh w/t/t3650-replay-basics.sh\n> index 34c038eab9..bb737f729a 100755\n> --- c/t/t3650-replay-basics.sh\n> +++ w/t/t3650-replay-basics.sh\n> @@ -647,4 +647,37 @@ test_expect_success 'replay with --linearize to rebase multiple divergent branch\n>   \ttest_cmp expect actual\n>   '\n>   \n> +test_expect_success 'replay with --linearize of a divergent merge drops one branch' '\n> +\tgit switch -c topic-divergent-base main &&\n> +\ttest_commit base &&\n> +\t# Fork 1: base -> X\n> +\tgit switch -c topic-divergent-x &&\n> +\ttest_commit X &&\n> +\t# Fork 2: base -> Y\n> +\tgit switch topic-divergent-base &&\n> +\tgit switch -c topic-divergent-y &&\n> +\ttest_commit Y &&\n> +\t# Merge them at Z\n> +\tgit switch topic-divergent-x &&\n> +\ttest_merge Z topic-divergent-y --no-ff &&\n> +\n> +\t# History is now:\n> +\t#\n> +\t#       X - Z (topic-divergent-x)\n> +\t#      /   /\n> +\t#  base - Y\n> +\t#\n> +\n> +\tgit replay --ref-action=print --linearize \\\n> +\t\t--onto main topic-divergent-base..topic-divergent-x >result &&\n> +\ttest_line_count = 1 result &&\n> +\ttip=$(cut -f 3 -d \" \" result) &&\n> +\t# Get the commits replayed onto main\n> +\tgit log --format=%s main..$tip >actual &&\n> +\t# We expect exactly one commit to be replayed (either X or Y)\n> +\t# because the other one is left dangling due to the merge being dropped.\n> +\ttest_line_count = 1 actual &&\n> +\ttest_grep \"^[XY]$\" actual\n> +'\n> +\n>   test_done\n> \n\n"},{"id":"546599","messageId":"f8b520d1-edeb-9e45-c503-025c8b5833c3@gmx.de","threadId":"65771","inReplyTo":"20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com","subject":"Re: [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Johannes Schindelin","fromEmail":"johannes.schindelin@gmx.de","sentAt":"2026-06-28T12:20:13Z","receivedAt":"2026-06-28T12:20:20Z","isPatch":true,"body":"Hi Toon,\n\nOn Fri, 26 Jun 2026, Toon Claes wrote:\n\n> - (BIGGEST CHANGE) When working on a refactor to undo the enum->bool\n>   patch, I extended the code comments to explain how things work. This\n>   made me realize the use of the \"replayed_base\" was incorrect when\n>   multiple branches are rebased with --onto. This is fixed now and a\n>   test is added for this scenario.\n\nI am not quite certain that this results in the desired outcome when\nworking with a single branch that contains a merge commit. Take for\nexample this topology (master~2..master at the time of writing):\n\n  *   6c3d7b73556d Merge branch 'ps/t4216-tap-fix'\n  |\\\n  | * f0411a4c717e t4216: fix no-op test that breaks TAP output\n  * | ab776a62a785 Git 2.55-rc2\n  o | 1ea786d14a1b Merge branch 'hn/macos-linker-warning'\n   /\n  o 08b6ae38c602 t4216: test changed path filters with high bit paths\n\nRunning `git replay --linearize --onto master~2 master~2..master` used to\nresult in this:\n\n  * 3ec7cc3e73c0 t4216: fix no-op test that breaks TAP output\n  * 8dca9f98dc05 Git 2.55-rc2\n  o 1ea786d14a1b Merge branch 'hn/macos-linker-warning'\n\nwhich is what I would expect. But now, due to the dropped `replayed_base`,\nthat tip commit is replayed directly on top of `onto` and the first\nreplayed commit (\"Git 2.55-rc2\") is simply (and inadvertently) dropped:\n\n  * 5e4899a3e03c t4216: fix no-op test that breaks TAP output\n  o 1ea786d14a1b Merge branch 'hn/macos-linker-warning'\n\nI had originally introduced that `replayed_base` specifically to prevent\nthis commit-dropping.\n\nAs to the question what should happen if multiple branches are replayed at\nthe same time with `--linearize`: This is a very tricky problem. Naively,\none would want all of those branches to be linearized _individually_. But\nthat idea breaks down when you replay three branches, two of them with\ndistinct commits, and the third branch a merge of the first two:\n\n  * Branch C: merge branches A and B\n  |\\\n  | * Branch B\n  * | Branch A\n  |/\n  o onto\n\nWhat should the replayed branch C look like? Should it have A' and B' in\nthat order? I.e. share the rewritten commit with the replayed branch A?\nBut then B' could not be the replayed B because that needs to be directly\non top of onto.\n\nSo I fear that the `replayed_base` design _is_ needed, and the only way\n`git replay --linearize` can work with multiple branches is by linearizing\nall of the replayed commits into one single, linear commit topology.\n\nObviously, there are ways one could _try_ to rescue the previous idea, so\nthat at least replaying just branches A and B would keep the replayed\ncommits non-reachable from each other, but I strongly suspect that any\nsuch design will invariably surprise users in nasty ways when the logic\nhas to fall back to the simple idea I outlined anyway.\n\nCiao,\nJohannes\n"},{"id":"546646","messageId":"akInDBlyWbbRFcLH@pks.im","threadId":"65771","inReplyTo":"87qzltyiao.fsf@emacs.iotcl.com","subject":"Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-29T08:04:28Z","receivedAt":"2026-06-29T08:04:34Z","isPatch":true,"body":"On Fri, Jun 26, 2026 at 07:36:31AM +0200, Toon Claes wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> \n> > git-rebase(1) essentially knows about three different modes:\n> >\n> >   - \"--no-rebase-merges\", which is the default and maps to your\n> >     \"--linearize\".\n> >\n> >   - \"--rebase-merges\", which by default doesn't rebase cousins by using\n> >     \"--ancestry-path\" internally.\n> >\n> >   - \"--rebase-merges=rebase-cousins\", which doesn't pass the above\n> >     option.\n> >\n> > So it's not a simple boolean there, which makes me wonder whether we\n> > should mirror the same interface so that all of git-rebase(1)'s modes\n> > can be represented, as well.\n> \n> That's a valid question, although I don't know a good answer to that.\n> \n> Basically you're asking for what the command line options will look\n> like? Allow me to think out loud.\n> \n> In this series I'm adding --linearize to git-replay(1). As mentioned, I\n> don't think it makes sense to add it to git-history(1) as well. Without\n> this option, the process aborts when it encounters a merge.\n> \n> Dscho sent a patch series to properly replay (2-way) merges. I think\n> this should become the default for both git-replay(1) and\n> git-history(1).\n> \n> But then, do we want to have an option that brings back the current\n> behavior of aborting at merges? Maybe with --no-merges?\n\nI think that would be a sensible option to have.\n\n> Then there's the option of rebasing cousins left. That's something that\n> isn't covered by Dscho's series yet. Maybe --replay-cousins?\n> \n> To reiterate what the final design could look like:\n> \n>  * <nothing>: replay merges preserving topology.\n>  * \"--linearize\": flattens merges (only git-replay(1)).\n>  * \"--no-merges\": dies when the process tries to replay a merge.\n>  * \"--replay-cousins\": does what --rebase-merges=rebase-cousins does.\n\nRight. And if we tried to be consistent with git-rebase(1), then this\ncould be done as:\n\n  - \"--rebase-merges\" to replay merges preserving topology, which is the\n    default once we support replaying them.\n\n  - \"--no-rebase-merges\" to flatten commits.\n\n  - \"--rebase-merges=abort\" to explicitly die when seeing merges.\n\n  - \"--rebase-merges=rebase-cousins\"\n\n> Now, all these options are (I think) mutually exclusive, so we could\n> consider an option \"--replay-merges=<mode>\", but personally I find\n> \"--<option>=<value>\" arguments harder to use than specifying separate\n> options.\n> \n> I think I'm avoiding your question, because the design of the command\n> line parameters doesn't need tot 1-on-1 correlate to the internal\n> datastructure. And I agree the mode isn't a boolean, but does that mean\n> we want to use an enum internally? Well, I don't know. And I also don't\n> think that matters right now. Code is easy to change, I think the\n> command line options should be designed with the future in mind, which I\n> believe we do with \"--linearize\".\n> \n> Sorry for this long-winded rambling, but bottom line I think it's fine\n> to add --linearize and in the future add more options and see how the\n> code should evolve to support those.\n\nHm, I dunno. You basically reasoned that we potentially want to have all\nof the same options that git-rebase(1)'s \"--rebase-merges=\" already\nsupports. So that begs the question why we need to reinvent the wheel\nthen and not just use the same syntax.\n\nNote that I'm not arguing that we should support all of these options\nnow. I'm merely arguing that we should try to be consistent, unless\nthere is a good argument not to do that. I'm fine with the interface if\nthere indeed is a good argument, but if so we should document why we\nthink that the current interface in git-rebase(1) is not a good fit for\nthis command.\n\nThanks!\n\nPatrick\n"},{"id":"546747","messageId":"9e7d14c4-82f0-2b89-b07b-f219119a199b@gmx.de","threadId":"65771","inReplyTo":"akInDBlyWbbRFcLH@pks.im","subject":"Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology","fromName":"Johannes Schindelin","fromEmail":"johannes.schindelin@gmx.de","sentAt":"2026-06-30T09:44:47Z","receivedAt":"2026-06-30T09:44:59Z","isPatch":true,"body":"Hi Patrick & Toon,\n\nOn Tue, 30 Jun 2026, Patrick Steinhardt wrote:\n\n> On Fri, Jun 26, 2026 at 07:36:31AM +0200, Toon Claes wrote:\n> > Patrick Steinhardt <ps@pks.im> writes:\n> > \n> > > git-rebase(1) essentially knows about three different modes:\n> > >\n> > >   - \"--no-rebase-merges\", which is the default and maps to your\n> > >     \"--linearize\".\n> > >\n> > >   - \"--rebase-merges\", which by default doesn't rebase cousins by using\n> > >     \"--ancestry-path\" internally.\n> > >\n> > >   - \"--rebase-merges=rebase-cousins\", which doesn't pass the above\n> > >     option.\n> > >\n> > > So it's not a simple boolean there, which makes me wonder whether we\n> > > should mirror the same interface so that all of git-rebase(1)'s modes\n> > > can be represented, as well.\n> > \n> > That's a valid question, although I don't know a good answer to that.\n> > \n> > Basically you're asking for what the command line options will look\n> > like? Allow me to think out loud.\n> > \n> > In this series I'm adding --linearize to git-replay(1). As mentioned, I\n> > don't think it makes sense to add it to git-history(1) as well. Without\n> > this option, the process aborts when it encounters a merge.\n> > \n> > Dscho sent a patch series to properly replay (2-way) merges. I think\n> > this should become the default for both git-replay(1) and\n> > git-history(1).\n> > \n> > But then, do we want to have an option that brings back the current\n> > behavior of aborting at merges? Maybe with --no-merges?\n> \n> I think that would be a sensible option to have.\n\nI also think that we'll need a way to abort at merges because linearizing\ncommits is a relatively common operation.\n\n> > Then there's the option of rebasing cousins left. That's something that\n> > isn't covered by Dscho's series yet. Maybe --replay-cousins?\n> > \n> > To reiterate what the final design could look like:\n> > \n> >  * <nothing>: replay merges preserving topology.\n> >  * \"--linearize\": flattens merges (only git-replay(1)).\n> >  * \"--no-merges\": dies when the process tries to replay a merge.\n> >  * \"--replay-cousins\": does what --rebase-merges=rebase-cousins does.\n> \n> Right. And if we tried to be consistent with git-rebase(1), then this\n> could be done as:\n> \n>   - \"--rebase-merges\" to replay merges preserving topology, which is the\n>     default once we support replaying them.\n> \n>   - \"--no-rebase-merges\" to flatten commits.\n> \n>   - \"--rebase-merges=abort\" to explicitly die when seeing merges.\n> \n>   - \"--rebase-merges=rebase-cousins\"\n\nThe `git rebase` options are unlikely to be a good precedent to follow.\nTheir history is full of usability warts, and in hindsight, I would really\nhave loved a more steady hand in developing and maintaining a good UX. The\nfact alone that this is called `rebase` speaks volumes about how hostile\nof a user experience this command surfaces.\n\nIn any case, these options should use the much more natural term \"replay\"\ninstead of \"rebase\".\n\nBut then: you said that `--no-rebase-merges` should flatten the commits?\nThat's not what this option name conveys to me; It would convey to me that\nthe operation would _abort_ on encountering merge commits.\n\nIn other words, I do think that the --linearize option is conceptually\nquite distinct from the different modes in which merge commits could be\nhandled. As such, this option should probably not be conflated with\nthe various `--replay-merges=<mode>` modes.\n\n> > Now, all these options are (I think) mutually exclusive, so we could\n> > consider an option \"--replay-merges=<mode>\", but personally I find\n> > \"--<option>=<value>\" arguments harder to use than specifying separate\n> > options.\n> > \n> > I think I'm avoiding your question, because the design of the command\n> > line parameters doesn't need tot 1-on-1 correlate to the internal\n> > datastructure. And I agree the mode isn't a boolean, but does that mean\n> > we want to use an enum internally? Well, I don't know. And I also don't\n> > think that matters right now. Code is easy to change, I think the\n> > command line options should be designed with the future in mind, which I\n> > believe we do with \"--linearize\".\n> > \n> > Sorry for this long-winded rambling, but bottom line I think it's fine\n> > to add --linearize and in the future add more options and see how the\n> > code should evolve to support those.\n> \n> Hm, I dunno. You basically reasoned that we potentially want to have all\n> of the same options that git-rebase(1)'s \"--rebase-merges=\" already\n> supports. So that begs the question why we need to reinvent the wheel\n> then and not just use the same syntax.\n\nI would strongly caution against repeating the same UX mistakes as `git\nrebase` has to live with.\n\nThe _functionality_, yes, I think that'd be good to have in `git replay`.\nBut we can surface that functionality in much better ways, with option\nnames that reflect the concepts much more intuitively.\n\nCiao,\nJohannes\n\n> Note that I'm not arguing that we should support all of these options\n> now. I'm merely arguing that we should try to be consistent, unless\n> there is a good argument not to do that. I'm fine with the interface if\n> there indeed is a good argument, but if so we should document why we\n> think that the current interface in git-rebase(1) is not a good fit for\n> this command.\n> \n> Thanks!\n> \n> Patrick\n> \n> \n"},{"id":"546755","messageId":"akOpOXeD_gS5U7rH@pks.im","threadId":"65771","inReplyTo":"9e7d14c4-82f0-2b89-b07b-f219119a199b@gmx.de","subject":"Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-30T11:32:09Z","receivedAt":"2026-06-30T11:32:16Z","isPatch":true,"body":"On Tue, Jun 30, 2026 at 11:44:47AM +0200, Johannes Schindelin wrote:\n> On Tue, 30 Jun 2026, Patrick Steinhardt wrote:\n> > On Fri, Jun 26, 2026 at 07:36:31AM +0200, Toon Claes wrote:\n> > > Then there's the option of rebasing cousins left. That's something that\n> > > isn't covered by Dscho's series yet. Maybe --replay-cousins?\n> > > \n> > > To reiterate what the final design could look like:\n> > > \n> > >  * <nothing>: replay merges preserving topology.\n> > >  * \"--linearize\": flattens merges (only git-replay(1)).\n> > >  * \"--no-merges\": dies when the process tries to replay a merge.\n> > >  * \"--replay-cousins\": does what --rebase-merges=rebase-cousins does.\n> > \n> > Right. And if we tried to be consistent with git-rebase(1), then this\n> > could be done as:\n> > \n> >   - \"--rebase-merges\" to replay merges preserving topology, which is the\n> >     default once we support replaying them.\n> > \n> >   - \"--no-rebase-merges\" to flatten commits.\n> > \n> >   - \"--rebase-merges=abort\" to explicitly die when seeing merges.\n> > \n> >   - \"--rebase-merges=rebase-cousins\"\n> \n> The `git rebase` options are unlikely to be a good precedent to follow.\n> Their history is full of usability warts, and in hindsight, I would really\n> have loved a more steady hand in developing and maintaining a good UX. The\n> fact alone that this is called `rebase` speaks volumes about how hostile\n> of a user experience this command surfaces.\n> \n> In any case, these options should use the much more natural term \"replay\"\n> instead of \"rebase\".\n> \n> But then: you said that `--no-rebase-merges` should flatten the commits?\n> That's not what this option name conveys to me; It would convey to me that\n> the operation would _abort_ on encountering merge commits.\n> \n> In other words, I do think that the --linearize option is conceptually\n> quite distinct from the different modes in which merge commits could be\n> handled. As such, this option should probably not be conflated with\n> the various `--replay-merges=<mode>` modes.\n\nFair enough. Arguments like this are basically what I want to read in\nthe commit message. As said in the below snippet: I'm not against\ndiverging from the git-rebase(1) interface, but if we do that we should\ndocument why we think that the current interface is bad.\n\n[snip]\n> > Note that I'm not arguing that we should support all of these options\n> > now. I'm merely arguing that we should try to be consistent, unless\n> > there is a good argument not to do that. I'm fine with the interface if\n> > there indeed is a good argument, but if so we should document why we\n> > think that the current interface in git-rebase(1) is not a good fit for\n> > this command.\n\nThanks!\n\nPatrick\n"},{"id":"546779","messageId":"87a4sc400s.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"f8b520d1-edeb-9e45-c503-025c8b5833c3@gmx.de","subject":"Re: [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-06-30T13:42:59Z","receivedAt":"2026-06-30T13:43:11Z","isPatch":true,"body":"Johannes Schindelin <Johannes.Schindelin@gmx.de> writes:\n\n> So I fear that the `replayed_base` design _is_ needed, and the only way\n> `git replay --linearize` can work with multiple branches is by linearizing\n> all of the replayed commits into one single, linear commit topology.\n\nWell, I'm getting the feeling you're right here. But then this change is\nno longer related to merge commits only. Replaying multiple branches\nwith --onto and --linearize would always replay them into a single line\nhiearchy?\n\nPersonally, I would be totally fine with that. We need to lay that out\nvery clearly in the docs, but that is in my humble opinion also a strong\nargument to name it `--linearize` and not `--replay-merge=linearize` or\nwhatever we've been discussing.\n\n> Obviously, there are ways one could _try_ to rescue the previous idea, so\n> that at least replaying just branches A and B would keep the replayed\n> commits non-reachable from each other, but I strongly suspect that any\n> such design will invariably surprise users in nasty ways when the logic\n> has to fall back to the simple idea I outlined anyway.\n\nI don't like the \"try\" in there. I think it's better to have predictable\nbehavior. Users always have the choice to replay branches in separate\ngit-replay(1) calls, although that comes with a downside that commits\nshared by those branches will be replayed separately and will get\nduplicated, unless they fiddle with the COMMITTER_DATE.\n\n-- \nCheers,\nToon\n"},{"id":"546862","messageId":"874iij3xge.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"xmqq5x358byf.fsf@gitster.g","subject":"Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-01T08:50:41Z","receivedAt":"2026-07-01T08:50:56Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Toon Claes <toon@iotcl.com> writes:\n>\n>>  Documentation/git-replay.adoc |  8 ++++-\n>>  builtin/replay.c              |  6 +++-\n>>  replay.c                      | 50 ++++++++++++++++----------\n>>  replay.h                      |  5 +++\n>>  t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-\n>>  5 files changed, 132 insertions(+), 21 deletions(-)\n>\n> \"replay --linearize\" behaves differently from the flattening rebase\n> in a case where X and Y that forked from A are merged at Z, and we\n> ask to flatten the history leading to Z, doesn't it?\n>\n>      A----X\n>       \\    \\\n>        Y----Z (tip)\n>\n> A typical flattening rebase would rewrite X to X', Y to Y', while\n> dropping Z, and would leave us a flattened history, like\n>\n>      A---X'---Y' (updated tip, the order of X' and Y' may be swapped)\n>\n> I may be misreading the logic, but doesn't \"replay --linearize\"\n> instead produce\n>\n>      A----X' (dangling)\n>       \\ \n>        Y' (tip -- Z is dropped and gets mapped)\n>\n> and leave X' dangling (or Y'; the point is that only one of them\n> will survive), never incorporating it in the resulting history?\n\nYou bring up a good point here, and it is very similar to what Dscho\nbrought up[1].\n\nWhen I started working on v5, I realized multiple tips can be passed to\ngit-replay(1) and the code in v1-v4 would replay all commits into a\nsingle linear history. I assumed that's not what we want.\n\nIn my mind, replaying unrelated histories with --onto and --linearize\nshould remain unrelated. Also assuming the --linearize option would only\nlinearize merge commits.\n\nLooking at less obvious situations, like your example above, things\naren't really that simple. And I agree the new Y' tip is not correct and\nX' shouldn't be dangling.\n\nOn the other hand though, if there was a branch pointing to X, we still\nneed a piece of history that has X', but doesn't have Y'. In your\nflattened history X' isn't a descendant of Y', but the order may be\nswapped and we would need to create something like:\n\n      A----X' (other tip)\n       \\\n        Y'----X' (tip)\n\nThat's quite complex trying to achieve something like that in code. In\nshort, --linearize will change the topology of the (merge) commits, and\nwe have to do this in a predictable way. Thus I'm currently leaning\ntoward bringing v1-v4 behavior back and linearize all commits in to a\nsingle line when using --linearize. Meaning:\n\n        B----C (other tip)\n       /\n      A----X\n       \\\n        Y----Z (tip)\n\n  $ git replay --onto X --linearize Z C\n\nWould result into:\n\n      A----X----Y----Z----B----C\n                               ^ (new other tip)\n                     ^ (new tip)\n\nThis might not always be the expected behavior, especially when\nreplaying multiple branches at once. But to those I would suggest: don't\nreplay multiple branches at once.\n\nBut then again: Given the above example, you want to replay Z and C on\ntop of eachother, but don't want to rewrite the \"(other tip)\"? They have\nI think two options:\n\n  $ git replay --onto X --linearize --ref=tip Z C\n\nBy passing --ref we could tell git-replay(1) to only update that ref.\n(sidenote: --ref currently cannot be combined with multiple revision\nranges, because that normally produces multiple tips and it would be\nambiguous which one --ref should point at. But --linearize collapses\neverything into a single tip, so that ambiguity goes away; maybe we\nshould loosen the constraint in that case.)\n\nOr:\n\n  $ git replay --onto X --linearize Z^{commit} C\n\nBy peeling Z to a commit, git-replay(1) doesn't see it as a ref to\nupdate.\n\nAnyhow, a lot to unpack and I'll try to do my best in the next version\nto cover that in the commit message, docs and test cases.\n\n[1]: <f8b520d1-edeb-9e45-c503-025c8b5833c3@gmx.de>\n\n>> +\t\tif (commit->parents && commit->parents->next) {\n>> +\t\t\tif (!opts->linearize)\n>> +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n>> +\t\t\t/*\n>> +\t\t\t * Drop the merge commit: do not pick it, leave\n>> +\t\t\t * `last_commit` unchanged, and fall through to the\n>> +\t\t\t * rest of the loop. As a result:\n>> +\t\t\t * - the merge commit is mapped to `last_commit` in\n>> +\t\t\t *   `replayed_commits`, this will become the parent for\n>> +\t\t\t *   the child commits.\n>> +\t\t\t * - refs previously pointing to the merge commit are\n>> +\t\t\t *   rewritten to point to the previous non-merge commit.\n>> +\t\t\t */\n>> +\t\t} else {\n>> +\t\t\t/*\n>> +\t\t\t * pick_regular_commit() looks up the parent of `commit` in\n>> +\t\t\t * `replayed_commits` to determine the ancestor to replay onto.\n>> +\t\t\t * The `default_base` parameter is used when no ancestor is found,\n>> +\t\t\t * which happens for the first commit in the revision range.\n>> +\t\t\t * When reverting, commits are replayed in reverse order, so the\n>> +\t\t\t * lookup never succeeds, and we need to pass `last_commit`.\n>> +\t\t\t */\n>> +\t\t\tstruct commit *base = onto;\n>> +\t\t\tif (mode == REPLAY_MODE_REVERT)\n>> +\t\t\t\tbase = last_commit;\n>> +\n>> +\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n>> +\t\t\t\t\t\t\t  replayed_commits,\n>> +\t\t\t\t\t\t\t  &merge_opt, &result,\n>> +\t\t\t\t\t\t\t  mode, opts->empty);\n>> +\t\t}\n>> +\n>>  \t\tif (!last_commit)\n>>  \t\t\tbreak;\n>\n> Immediately after this hunk beyond the post-context are these lines.\n>\n> \t\t/* Record commit -> last_commit mapping */\n> \t\tput_mapped_commit(replayed_commits, commit, last_commit);\n>\n> Let's imagine X gets processed first. X (and other commits on its\n> branch) gets replayed, last_commit is set to X' (which is the\n> rewritten X).  replayed_commits mapping holds X->X' mapping.\n>\n> Then let's imagine the history leading to Y is replayed next.\n> last_commit becomes Y', and Y->Y' mapping is stored in\n> replayed_commits.\n>\n> Finally, we see Z.  We are going to _drop_ it.  last_commit is left\n> unchanged, pointing at Y'.  Then last_commit (i.e., Y') is used as\n> the merge commit Z maps to (i.e., correctly dropping Z).\n>\n> Any descendants of Z, if any, will be grafted as descendants of Y'.\n> If X did not have any descendants other than Z in the rewritten part\n> of the history, then X' (and commits leading to it) would be lost,\n> no?\n\nI appreciate you're breaking down the code here.\n\n> This \"loss of the other branch\" may be an inherent characteristic of\n> this feature (i.e., I do not think it is necessarily a bug, and it\n> may even be that the \"bug\" is in the way I am reading the patch),\n> but then I wonder if the user may want to have control over which\n> side branch should survive, perhaps?  It would probably need to be\n> documented, and a test or two to cast this behaviour in stone.\n>\n>> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n>> index 3353bc4a4d..34c038eab9 100755\n>> --- a/t/t3650-replay-basics.sh\n>> +++ b/t/t3650-replay-basics.sh\n>> @@ -52,8 +52,12 @@ test_expect_success 'setup' '\n>\n> The pre-context here has\n>\n> \tgit switch --detach topic4 &&\n> \ttest_commit N &&\n> \ttest_commit O &&\n> \tgit switch -c topic-with-merge topic4 &&\n>\n>>  \ttest_merge P O --no-ff &&\n>>  \tgit switch main &&\n>\n> The above does prepare topic-with-merge branch, but ...\n>\n>> +test_expect_success 'replay to rebase merge commit with --linearize' '\n>> +\tgit replay --ref-action=print --linearize \\\n>> +\t\t--onto main I..topic-with-merge >result &&\n>\n> ... this does not really exersize linearizing replay in a typical\n> mergy history.  P merges O with --no-ff because otherwise there\n> won't be a merge, since O is a descendant of the commit \"test_merge\n> P O\" runs on (i.e., topic4 == topic-with-merge).\n>\n>     topic4 --- N --- O\n>           \\           \\\n>            .-----------P\n>\n> So, as long as O is replayed later than the parent of N (which is\n> true), O' will be the surviving tip (corresponds to Y' that the\n> dropped Z was mapped to in the earlier example), and nothing gets\n> orphaned, I think.\n>\n> Perhaps a test to try a real merge may look something like this.\n>\n> diff --git c/t/t3650-replay-basics.sh w/t/t3650-replay-basics.sh\n> index 34c038eab9..bb737f729a 100755\n> --- c/t/t3650-replay-basics.sh\n> +++ w/t/t3650-replay-basics.sh\n> @@ -647,4 +647,37 @@ test_expect_success 'replay with --linearize to rebase multiple divergent branch\n>  \ttest_cmp expect actual\n>  '\n>  \n> +test_expect_success 'replay with --linearize of a divergent merge drops one branch' '\n> +\tgit switch -c topic-divergent-base main &&\n> +\ttest_commit base &&\n> +\t# Fork 1: base -> X\n> +\tgit switch -c topic-divergent-x &&\n> +\ttest_commit X &&\n> +\t# Fork 2: base -> Y\n> +\tgit switch topic-divergent-base &&\n> +\tgit switch -c topic-divergent-y &&\n> +\ttest_commit Y &&\n> +\t# Merge them at Z\n> +\tgit switch topic-divergent-x &&\n> +\ttest_merge Z topic-divergent-y --no-ff &&\n> +\n> +\t# History is now:\n> +\t#\n> +\t#       X - Z (topic-divergent-x)\n> +\t#      /   /\n> +\t#  base - Y\n> +\t#\n> +\n> +\tgit replay --ref-action=print --linearize \\\n> +\t\t--onto main topic-divergent-base..topic-divergent-x >result &&\n> +\ttest_line_count = 1 result &&\n> +\ttip=$(cut -f 3 -d \" \" result) &&\n> +\t# Get the commits replayed onto main\n> +\tgit log --format=%s main..$tip >actual &&\n> +\t# We expect exactly one commit to be replayed (either X or Y)\n> +\t# because the other one is left dangling due to the merge being dropped.\n> +\ttest_line_count = 1 actual &&\n> +\ttest_grep \"^[XY]$\" actual\n> +'\n> +\n>  test_done\n\nThank you for providing this test case.\n\n-- \nCheers,\nToon\n"},{"id":"547003","messageId":"20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com","threadId":"65771","inReplyTo":"20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com","subject":"[PATCH v6 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-02T17:58:42Z","receivedAt":"2026-07-02T17:58:54Z","isPatch":true,"body":"As an alternative to dscho's patch series to replay merges[1], add\nan option to git-replay(1) to linearize merges. This mimics what\ngit-rebase(1) does with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. This patch was kindly provided by Dscho, which I've\ntweaked to be upstreamed.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion it doesn't make much sense to do\nso, but I'm happy to hear if anyone disagrees.\n\nThis series might conflict with Kristoffer's series to make\ndocumentation changes[2], but should be trivial to resolve. And I don't\nthink there's a conflict with Patrick's series on adding \"drop\" to\ngit-history(1)[3].\n\ndscho's series to replay merges[1] needs a bit of rework to fit on top\nof this, but I'm happy to help figuring that out. We've been discussing\nto either name the option --flatten or --linearize, but I've decided on\n\"linearize\" because the documentation of git-rebase(1) also mentions\n\"linearize\".\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n[2]: <V2_CV_doc_replay_config.767@msgid.xyz>\n[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>\n\n---\nChanges in v6:\n- Reworked the second commit that moves picking the base completely\n  outside pick_regular_commit(), instead of adding more explanation.\n- Drastically extended the commit message on commit #3.\n- Extended docs on flattening multiple revision ranges and how it's\n  different from git-rebase(1)'s --no-rebase-merges.\n- Added a bunch of tests to cover various scenarios.\n- Remove newline from BUG() message.\n- Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com\n\nChanges in v5:\n- Dropped the enum->bool patch and instead added a patch that better\n  explains how pick_regular_commit() picks a base.\n- Order of commits is shuffled.\n- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool\n  patch, I extended the code comments to explain how things work. This\n  made me realize the use of the \"replayed_base\" was incorrect when\n  multiple branches are rebased with --onto. This is fixed now and a\n  test is added for this scenario.\n- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com\n\nChanges in v4:\n- Use test_grep instead of a bare grep in the range-diff test, to\n  prepare for mm/test-grep-lint.\n- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com\n\nChanges in v3:\n- Add --linearize to Documentation SYNOPSIS, and mention it's\n  incompatible with --revert.\n- Small language change in help message for --linearize.\n- Rephrase comment to include last_commit isn't modified when\n  linearizing merges.\n- Remove test that was added in earlier versions, but actually is\n  a duplicate of 'replaying merge commits is not supported yet'.\n- Add test to verify --revert and --linearize are incompatible.\n- Properly test that replaying down to root with --linearize works.\n- Add test for --linearize with --advance.\n- Add test that uses git-range-diff(1) to verify the patches created by\n  --linearize are correct.\n- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com\n\nChanges in v2:\n- Restructured the conditions to detect merge commits and added a line\n  of comment why the loop continues.\n- Rewrote tests to use the history from the setup step and added a few\n  test cases.\n- Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n  patches with this trailer, and if I understand correctly, I can keep\n  it. Please let me know if that wrong.\n- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n\n---\nJohannes Schindelin (1):\n      replay: offer an option to linearize the commit topology\n\nToon Claes (2):\n      replay: add helper to put entry into replayed_commits\n      replay: resolve the replay base outside pick_regular_commit()\n\n Documentation/git-replay.adoc |  21 ++++++-\n builtin/replay.c              |   6 +-\n replay.c                      |  81 ++++++++++++++++--------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 225 insertions(+), 28 deletions(-)\n\nRange-diff versus v5:\n\n1:  b4512eb233 ! 1:  b957989fd9 replay: add helper to put entry into mapped_commits\n    @@ Metadata\n     Author: Toon Claes <toon@iotcl.com>\n     \n      ## Commit message ##\n    -    replay: add helper to put entry into mapped_commits\n    +    replay: add helper to put entry into replayed_commits\n     \n         The function replay_revisions() in replay.c is rather lengthy. Extract\n         the logic to put a commit entry into mapped_commits into a helper\n    @@ replay.c: static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n     +\n     +\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n     +\tif (ret == 0)\n    -+\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n    ++\t\tBUG(\"Duplicate rewritten commit: %s\",\n     +\t\t    oid_to_hex(&commit->object.oid));\n     +\n     +\tkh_value(replayed_commits, pos) = new_commit;\n2:  91ed61bafd < -:  ---------- replay: better explain how pick_regular_commit() picks a base\n-:  ---------- > 2:  6d457e8c39 replay: resolve the replay base outside pick_regular_commit()\n3:  eb6a3b0d72 ! 3:  af39c0ae44 replay: offer an option to linearize the commit topology\n    @@ Commit message\n     \n         The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n         was given. This mode drops merge commits instead of replaying them, and\n    -    linearizes the commit history into a sequence of the\n    -    regular (single-parent) commits.\n    +    linearizes the history into a sequence of regular (single-parent)\n    +    commits.\n     \n    -    Add option `--linearize` to git-replay(1) to do the same.\n    +    Add option `--linearize` to git-replay(1) to do the same. Each replayed\n    +    commit is stacked on top of the previously replayed one. When a merge is\n    +    encountered, the commits reachable from all of its sides are replayed\n    +    into the single line and the merge itself is dropped.\n    +\n    +    If a ref was pointing to a merge commit, that ref is updated to the\n    +    merge's last replayed ancestor.\n    +\n    +    git-replay(1) accepts multiple revision ranges, for example:\n    +\n    +        $ git replay --onto main topic1 topic2\n    +\n    +    Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n    +    independently and updates both refs.\n    +\n    +    With `--linearize` the whole set is flattened into one line: the ranges\n    +    are stacked on top of each other rather than replayed side by side, so\n    +    both refs end up pointing at different points along that single history.\n    +\n    +    Replaying all revision ranges into one single linear history is\n    +    intentional and it's the only way to ensure predictable results. A user\n    +    who wants to linearize ranges independently is advised to use separate\n    +    git-replay(1) invocations.\n    +\n    +    Linearizing is a distinct operation, and flattening merge commits is\n    +    just one aspect of that. Recreating merges would be a separate mode, so\n    +    rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,\n    +    git-replay(1) uses its own `--linearize` option.\n     \n         Co-authored-by: Toon Claes <toon@iotcl.com>\n         Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif\n      The default mode can be configured via the `replay.refAction` configuration variable.\n      \n     +--linearize::\n    -+\tIn this mode, `git replay` imitates `git rebase --no-rebase-merges`,\n    -+\ti.e. it cherry-picks only non-merge commits, each one on top of the\n    -+\tprevious one.\n    -+\tThis option is incompatible with `--revert`.\n    ++\tIn this mode, each replayed commit is stacked on top of the\n    ++\tpreviously replayed one, so all replayed commits are flattened into\n    ++\ta single linear history.\n    +++\n    ++When a merge commit is encountered, the behavior of git-rebase(1)'s\n    ++option `--no-rebase-merges` is imitated. All commits in the range\n    ++reachable from the merge commit are replayed into a linear history, and\n    ++the merge commit itself is dropped. A ref that pointed to a merge commit\n    ++is updated to the merge's last replayed ancestor.\n    +++\n    ++This flattens the `<revision-range>` as a whole. When multiple revision\n    ++ranges are given they are stacked on top of each other into one linear\n    ++history. Each of their refs is updated to point to its position in that\n    ++history. To linearize ranges separately, replay them in separate `git\n    ++replay` invocations.\n    +++\n    ++This option is incompatible with `--revert`.\n     +\n      <revision-range>::\n      \tRange of commits to replay; see \"Specifying Ranges\" in\n    @@ replay.c: int replay_revisions(struct rev_info *revs,\n      \t\tconst struct name_decoration *decoration;\n      \n     -\t\t/*\n    --\t\t * pick_regular_commit() looks up the parent of `commit` in\n    --\t\t * `replayed_commits` to determine the ancestor to replay onto.\n    --\t\t * The `default_base` parameter is used when no ancestor is found,\n    --\t\t * which happens for the first commit in the revision range.\n    --\t\t * When reverting, commits are replayed in reverse order, so the\n    --\t\t * lookup never succeeds, and we need to pass `last_commit`.\n    +-\t\t * Decide where to replay this commit on.\n    +-\t\t * If the parent commit was replayed already, the replayed result\n    +-\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n    +-\t\t * When reverting, commits are replayed in reverse order and thus\n    +-\t\t * its parent isn't replayed yet. Therefore revert commits are\n    +-\t\t * always replayed onto `last_commit`.\n     -\t\t */\n    --\t\tstruct commit *base = onto;\n    +-\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n    +-\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n    +-\n     -\t\tif (mode == REPLAY_MODE_REVERT)\n     -\t\t\tbase = last_commit;\n     -\n    @@ replay.c: int replay_revisions(struct rev_info *revs,\n     -\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n     -\n     -\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n    --\t\t\t\t\t\t  replayed_commits,\n    --\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n    +-\t\t\t\t\t\t  &merge_opt, &result,\n    +-\t\t\t\t\t\t  mode, opts->empty);\n     +\t\tif (commit->parents && commit->parents->next) {\n     +\t\t\tif (!opts->linearize)\n     +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n    @@ replay.c: int replay_revisions(struct rev_info *revs,\n     +\t\t\t * Drop the merge commit: do not pick it, leave\n     +\t\t\t * `last_commit` unchanged, and fall through to the\n     +\t\t\t * rest of the loop. As a result:\n    -+\t\t\t * - the merge commit is mapped to `last_commit` in\n    -+\t\t\t *   `replayed_commits`, this will become the parent for\n    -+\t\t\t *   the child commits.\n    -+\t\t\t * - refs previously pointing to the merge commit are\n    -+\t\t\t *   rewritten to point to the previous non-merge commit.\n    ++\t\t\t * - refs pointing to the merge commit will be updated\n    ++\t\t\t *   to `last_commit`.\n    ++\t\t\t * - the next replayed commit uses `last_commit` as its\n    ++\t\t\t *   `base`.\n     +\t\t\t */\n     +\t\t} else {\n     +\t\t\t/*\n    -+\t\t\t * pick_regular_commit() looks up the parent of `commit` in\n    -+\t\t\t * `replayed_commits` to determine the ancestor to replay onto.\n    -+\t\t\t * The `default_base` parameter is used when no ancestor is found,\n    -+\t\t\t * which happens for the first commit in the revision range.\n    -+\t\t\t * When reverting, commits are replayed in reverse order, so the\n    -+\t\t\t * lookup never succeeds, and we need to pass `last_commit`.\n    ++\t\t\t * Decide where to replay this commit onto.\n    ++\t\t\t * If the parent commit was replayed already, the replayed result\n    ++\t\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n    ++\t\t\t * When reverting, commits are replayed in reverse order and thus\n    ++\t\t\t * its parent isn't replayed yet. Therefore revert commits are\n    ++\t\t\t * always replayed onto `last_commit`.\n    ++\t\t\t * Also when opts->linearize is true, set the base to\n    ++\t\t\t * `last_commit` to create a single linear history.\n     +\t\t\t */\n    -+\t\t\tstruct commit *base = onto;\n    -+\t\t\tif (mode == REPLAY_MODE_REVERT)\n    ++\t\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n    ++\t\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n    ++\n    ++\t\t\tif (opts->linearize || mode == REPLAY_MODE_REVERT)\n     +\t\t\t\tbase = last_commit;\n     +\n     +\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n    -+\t\t\t\t\t\t\t  replayed_commits,\n     +\t\t\t\t\t\t\t  &merge_opt, &result,\n     +\t\t\t\t\t\t\t  mode, opts->empty);\n     +\t\t}\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +\ttest_line_count = 3 out\n     +'\n     +\n    -+test_expect_success 'replay with --linearize to rebase multiple divergent branches' '\n    ++test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' '\n     +\tgit replay --ref-action=print --linearize \\\n    -+\t\t--onto main ^B topic2 topic-with-merge >result &&\n    ++\t\t--onto main ^B topic2 topic3 topic4 >result &&\n     +\n    -+\ttest_line_count = 2 result &&\n    ++\ttest_line_count = 3 result &&\n     +\tcut -f 3 -d \" \" result >new-branch-tips &&\n     +\n    -+\tgit log --format=%s $(head -n 1 new-branch-tips) >actual &&\n    -+\ttest_write_lines E D C M L B A >expect &&\n    ++\t>expect &&\n    ++\tfor i in 2 3 4\n    ++\tdo\n    ++\t\tprintf \"update refs/heads/topic$i \" >>expect &&\n    ++\t\tprintf \"%s \" $(grep topic$i result | cut -f 3 -d \" \") >>expect &&\n    ++\t\tgit rev-parse topic$i >>expect || return 1\n    ++\tdone &&\n    ++\n    ++\ttest_cmp expect result &&\n    ++\n    ++\ttest_write_lines           E D C M L B A >expect2 &&\n    ++\ttest_write_lines     H G F E D C M L B A >expect3 &&\n    ++\ttest_write_lines J I H G F E D C M L B A >expect4 &&\n    ++\n    ++\tfor i in 2 3 4\n    ++\tdo\n    ++\t\tgit log --format=%s $(grep topic$i result | cut -f 3 -d \" \") >actual &&\n    ++\t\ttest_cmp expect$i actual || return 1\n    ++\tdone\n    ++'\n    ++\n    ++test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n    ++\ttest_when_finished \"git update-ref -d refs/heads/divergent-x\" &&\n    ++\ttest_when_finished \"git update-ref -d refs/heads/divergent-y\" &&\n    ++\n    ++\t# Build a real merge of two commits that diverged from a common base:\n    ++\t#\n    ++\t#       X - Z (divergent-x)\n    ++\t#      /   /\n    ++\t#  M  -  Y (divergent-y)\n    ++\t#\n    ++\tgit switch -c divergent-x main &&\n    ++\ttest_commit X &&\n    ++\tgit switch -c divergent-y main &&\n    ++\ttest_commit Y &&\n    ++\tgit switch divergent-x &&\n    ++\ttest_merge Z divergent-y --no-ff &&\n    ++\n    ++\tgit replay --ref-action=print --linearize \\\n    ++\t\t--onto main main..divergent-x >result &&\n    ++\ttest_line_count = 1 result &&\n    ++\ttip=$(cut -f 3 -d \" \" result) &&\n    ++\n    ++\t# The merge Z is dropped, but both X and Y are linearized onto main;\n    ++\t# neither side is lost.\n    ++\tgit log --format=%s main..$tip >actual &&\n    ++\ttest_write_lines Y X >expect &&\n    ++\ttest_cmp expect actual\n    ++'\n    ++\n    ++test_expect_success '--linearize with --contained updates contained refs' '\n    ++\tgit replay --ref-action=print --linearize --contained \\\n    ++\t\t--onto main ^B topic-with-merge >result &&\n    ++\n    ++\ttest_line_count = 2 result &&\n    ++\n    ++\tgit log --format=%s $(head -n 1 result | cut -f 3 -d \" \") >actual &&\n    ++\ttest_write_lines J I M L B A >expect &&\n     +\ttest_cmp expect actual &&\n     +\n    -+\tgit log --format=%s $(tail -n 1 new-branch-tips) >actual &&\n    ++\tgit log --format=%s $(tail -n 1 result | cut -f 3 -d \" \") >actual &&\n     +\ttest_write_lines O N J I M L B A >expect &&\n     +\ttest_cmp expect actual\n     +'\n\n\n---\nbase-commit: ab776a62a78576513ee121424adb19597fbb7613\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"547004","messageId":"20260702-toon-git-replay-drop-merges-v6-1-78a07cdd0382@iotcl.com","threadId":"65771","inReplyTo":"20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com","subject":"[PATCH v6 1/3] replay: add helper to put entry into replayed_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-02T17:58:43Z","receivedAt":"2026-07-02T17:58:57Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put a commit entry into mapped_commits into a helper\nfunction put_mapped_commit().\n\nWhile at it, rename mapped_commit() to get_mapped_commit() to pair with\nthis new function.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex da531d5bc6..b9f8fc47ce 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -250,9 +250,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -263,6 +263,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -283,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -423,8 +438,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -436,11 +449,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"547005","messageId":"20260702-toon-git-replay-drop-merges-v6-2-78a07cdd0382@iotcl.com","threadId":"65771","inReplyTo":"20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com","subject":"[PATCH v6 2/3] replay: resolve the replay base outside pick_regular_commit()","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-02T17:58:44Z","receivedAt":"2026-07-02T17:59:06Z","isPatch":true,"body":"Depending on what gets passed into the function pick_regular_commit(),\nit decides the new base for the replayed commit. It first tries to find\nthe replayed results of `pickme`'s parent in the `replayed_commits` map.\nIf not found, it falls back to `onto`.\n\nWhen using git-replay(1) with --onto, the fallback is the revision\npassed in with this option, but when using --revert, the fallback is\n`last_commit`.\n\nIt's rather confusing the base is decided partly inside\npick_regular_commit() and partly by its caller.\n\nMove the base selection completely into the caller: replay_revisions().\nThis bundles all the logic of deciding on the base together. Also, this\nreduces the number of parameters of pick_regular_commit(), making it's\ninterface cleaner.\n\nThis refactoring doesn't bring any behavior changes.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 34 +++++++++++++++++++++-------------\n 1 file changed, 21 insertions(+), 13 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex b9f8fc47ce..5aee0eafbc 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -280,25 +280,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n \n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n-\t\t\t\t\t  kh_oid_map_t *replayed_commits,\n-\t\t\t\t\t  struct commit *onto,\n+\t\t\t\t\t  struct commit *replayed_base,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n \t\t\t\t\t  enum replay_mode mode,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n-\tstruct commit *base, *replayed_base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n \n-\tif (pickme->parents) {\n-\t\tbase = pickme->parents->item;\n-\t\tbase_tree = repo_get_commit_tree(repo, base);\n-\t} else {\n-\t\tbase = NULL;\n+\tif (pickme->parents)\n+\t\tbase_tree = repo_get_commit_tree(repo, pickme->parents->item);\n+\telse\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n-\t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -439,12 +433,26 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n+\t\t/*\n+\t\t * Decide where to replay this commit on.\n+\t\t * If the parent commit was replayed already, the replayed result\n+\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n+\t\t * When reverting, commits are replayed in reverse order and thus\n+\t\t * its parent isn't replayed yet. Therefore revert commits are\n+\t\t * always replayed onto `last_commit`.\n+\t\t */\n+\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n+\n+\t\tif (mode == REPLAY_MODE_REVERT)\n+\t\t\tbase = last_commit;\n+\n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t  mode, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"547006","messageId":"20260702-toon-git-replay-drop-merges-v6-3-78a07cdd0382@iotcl.com","threadId":"65771","inReplyTo":"20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com","subject":"[PATCH v6 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-02T17:58:45Z","receivedAt":"2026-07-02T17:59:13Z","isPatch":true,"body":"From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n\nOne of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearizes the history into a sequence of regular (single-parent)\ncommits.\n\nAdd option `--linearize` to git-replay(1) to do the same. Each replayed\ncommit is stacked on top of the previously replayed one. When a merge is\nencountered, the commits reachable from all of its sides are replayed\ninto the single line and the merge itself is dropped.\n\nIf a ref was pointing to a merge commit, that ref is updated to the\nmerge's last replayed ancestor.\n\ngit-replay(1) accepts multiple revision ranges, for example:\n\n    $ git replay --onto main topic1 topic2\n\nWithout `--linearize` this replays 'topic1' and 'topic2' onto 'main'\nindependently and updates both refs.\n\nWith `--linearize` the whole set is flattened into one line: the ranges\nare stacked on top of each other rather than replayed side by side, so\nboth refs end up pointing at different points along that single history.\n\nReplaying all revision ranges into one single linear history is\nintentional and it's the only way to ensure predictable results. A user\nwho wants to linearize ranges independently is advised to use separate\ngit-replay(1) invocations.\n\nLinearizing is a distinct operation, and flattening merge commits is\njust one aspect of that. Recreating merges would be a separate mode, so\nrather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,\ngit-replay(1) uses its own `--linearize` option.\n\nCo-authored-by: Toon Claes <toon@iotcl.com>\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  21 ++++++-\n builtin/replay.c              |   6 +-\n replay.c                      |  54 ++++++++++------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 203 insertions(+), 23 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex a32f72aead..cc1d2bd251 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n --------\n [verse]\n (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n-\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n+\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -88,6 +88,25 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, each replayed commit is stacked on top of the\n+\tpreviously replayed one, so all replayed commits are flattened into\n+\ta single linear history.\n++\n+When a merge commit is encountered, the behavior of git-rebase(1)'s\n+option `--no-rebase-merges` is imitated. All commits in the range\n+reachable from the merge commit are replayed into a linear history, and\n+the merge commit itself is dropped. A ref that pointed to a merge commit\n+is updated to the merge's last replayed ancestor.\n++\n+This flattens the `<revision-range>` as a whole. When multiple revision\n+ranges are given they are stacked on top of each other into one linear\n+history. Each of their refs is updated to point to its position in that\n+history. To linearize ranges separately, replay them in separate `git\n+replay` invocations.\n++\n+This option is incompatible with `--revert`.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..62962c73c7 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -85,7 +85,7 @@ int cmd_replay(int argc,\n \tconst char *const replay_usage[] = {\n \t\tN_(\"(EXPERIMENTAL!) git replay \"\n \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n-\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n+\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n \t\tNULL\n \t};\n \tstruct option replay_options[] = {\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n \t\tOPT_END()\n \t};\n \n@@ -132,6 +134,8 @@ int cmd_replay(int argc,\n \t\t\t\t  opts.contained, \"--contained\");\n \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n \t\t\t\t  !!opts.contained, \"--contained\");\n+\tdie_for_incompatible_opt2(!!opts.revert, \"--revert\",\n+\t\t\t\t  opts.linearize, \"--linearize\");\n \n \t/* Parse ref action mode from command line or config */\n \tref_mode = get_ref_action_mode(repo, ref_action);\ndiff --git a/replay.c b/replay.c\nindex 5aee0eafbc..bd1f3bb898 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -433,26 +433,40 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\t/*\n-\t\t * Decide where to replay this commit on.\n-\t\t * If the parent commit was replayed already, the replayed result\n-\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n-\t\t * When reverting, commits are replayed in reverse order and thus\n-\t\t * its parent isn't replayed yet. Therefore revert commits are\n-\t\t * always replayed onto `last_commit`.\n-\t\t */\n-\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n-\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n-\n-\t\tif (mode == REPLAY_MODE_REVERT)\n-\t\t\tbase = last_commit;\n-\n-\t\tif (commit->parents && commit->parents->next)\n-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n-\n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n-\t\t\t\t\t\t  &merge_opt, &result,\n-\t\t\t\t\t\t  mode, opts->empty);\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (!opts->linearize)\n+\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\t\t/*\n+\t\t\t * Drop the merge commit: do not pick it, leave\n+\t\t\t * `last_commit` unchanged, and fall through to the\n+\t\t\t * rest of the loop. As a result:\n+\t\t\t * - refs pointing to the merge commit will be updated\n+\t\t\t *   to `last_commit`.\n+\t\t\t * - the next replayed commit uses `last_commit` as its\n+\t\t\t *   `base`.\n+\t\t\t */\n+\t\t} else {\n+\t\t\t/*\n+\t\t\t * Decide where to replay this commit onto.\n+\t\t\t * If the parent commit was replayed already, the replayed result\n+\t\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n+\t\t\t * When reverting, commits are replayed in reverse order and thus\n+\t\t\t * its parent isn't replayed yet. Therefore revert commits are\n+\t\t\t * always replayed onto `last_commit`.\n+\t\t\t * Also when opts->linearize is true, set the base to\n+\t\t\t * `last_commit` to create a single linear history.\n+\t\t\t */\n+\t\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\t\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n+\n+\t\t\tif (opts->linearize || mode == REPLAY_MODE_REVERT)\n+\t\t\t\tbase = last_commit;\n+\n+\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t\t  mode, opts->empty);\n+\t\t}\n+\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex faf95c7459..64f42b6512 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..e832e2c93d 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -52,8 +52,12 @@ test_expect_success 'setup' '\n \ttest_merge P O --no-ff &&\n \tgit switch main &&\n \n+\tgit switch --orphan unrelated &&\n+\ttest_commit unrelated-root &&\n+\n \tgit switch -c conflict B &&\n-\ttest_commit C.conflict C.t conflict\n+\ttest_commit C.conflict C.t conflict &&\n+\tgit branch -D unrelated\n '\n \n test_expect_success 'setup bare' '\n@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '\n \ttest_grep \"cannot be used together\" actual\n '\n \n+test_expect_success '--revert and --linearize cannot be used together' '\n+\ttest_must_fail git replay --revert=main --linearize \\\n+\t\ttopic1..topic2 2>actual &&\n+\ttest_grep \"cannot be used together\" actual\n+'\n+\n test_expect_success 'cannot advance target ... ordering would be ill-defined' '\n \techo \"fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined\" >expect &&\n \ttest_must_fail git replay --advance=main main topic1 topic2 2>actual &&\n@@ -565,4 +575,132 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'replay to rebase merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto unrelated-root topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J I B A unrelated-root >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to cherry-pick merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--advance main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tprintf \"update refs/heads/main \" >expect &&\n+\tprintf \"%s \" $(cut -f 3 -d \" \" result) >>expect &&\n+\tgit rev-parse main >>expect &&\n+\ttest_cmp expect result\n+'\n+\n+test_expect_success 'replay --linearize produces the same patches' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# range-diff does not care about the dropped merge,\n+\t# so the original commits (I..topic-with-merge)\n+\t# and the replayed chain (main..tip) must produce identical patches.\n+\tgit range-diff I..topic-with-merge main..$tip >out &&\n+\ttest_file_not_empty out &&\n+\ttest_grep ! -v \"=\" out &&\n+\n+\tgit log --oneline main..$tip >out &&\n+\ttest_line_count = 3 out\n+'\n+\n+test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main ^B topic2 topic3 topic4 >result &&\n+\n+\ttest_line_count = 3 result &&\n+\tcut -f 3 -d \" \" result >new-branch-tips &&\n+\n+\t>expect &&\n+\tfor i in 2 3 4\n+\tdo\n+\t\tprintf \"update refs/heads/topic$i \" >>expect &&\n+\t\tprintf \"%s \" $(grep topic$i result | cut -f 3 -d \" \") >>expect &&\n+\t\tgit rev-parse topic$i >>expect || return 1\n+\tdone &&\n+\n+\ttest_cmp expect result &&\n+\n+\ttest_write_lines           E D C M L B A >expect2 &&\n+\ttest_write_lines     H G F E D C M L B A >expect3 &&\n+\ttest_write_lines J I H G F E D C M L B A >expect4 &&\n+\n+\tfor i in 2 3 4\n+\tdo\n+\t\tgit log --format=%s $(grep topic$i result | cut -f 3 -d \" \") >actual &&\n+\t\ttest_cmp expect$i actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n+\ttest_when_finished \"git update-ref -d refs/heads/divergent-x\" &&\n+\ttest_when_finished \"git update-ref -d refs/heads/divergent-y\" &&\n+\n+\t# Build a real merge of two commits that diverged from a common base:\n+\t#\n+\t#       X - Z (divergent-x)\n+\t#      /   /\n+\t#  M  -  Y (divergent-y)\n+\t#\n+\tgit switch -c divergent-x main &&\n+\ttest_commit X &&\n+\tgit switch -c divergent-y main &&\n+\ttest_commit Y &&\n+\tgit switch divergent-x &&\n+\ttest_merge Z divergent-y --no-ff &&\n+\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main main..divergent-x >result &&\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# The merge Z is dropped, but both X and Y are linearized onto main;\n+\t# neither side is lost.\n+\tgit log --format=%s main..$tip >actual &&\n+\ttest_write_lines Y X >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--linearize with --contained updates contained refs' '\n+\tgit replay --ref-action=print --linearize --contained \\\n+\t\t--onto main ^B topic-with-merge >result &&\n+\n+\ttest_line_count = 2 result &&\n+\n+\tgit log --format=%s $(head -n 1 result | cut -f 3 -d \" \") >actual &&\n+\ttest_write_lines J I M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tgit log --format=%s $(tail -n 1 result | cut -f 3 -d \" \") >actual &&\n+\ttest_write_lines O N J I M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n test_done\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"547123","messageId":"xmqqbjcnhjvk.fsf@gitster.g","threadId":"65771","inReplyTo":"20260702-toon-git-replay-drop-merges-v6-3-78a07cdd0382@iotcl.com","subject":"Re: [PATCH v6 3/3] replay: offer an option to linearize the commit topology","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-03T20:57:03Z","receivedAt":"2026-07-03T20:57:06Z","isPatch":true,"body":"Toon Claes <toon@iotcl.com> writes:\n\n> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n> ...\n> Linearizing is a distinct operation, and flattening merge commits is\n> just one aspect of that. Recreating merges would be a separate mode, so\n> rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,\n> git-replay(1) uses its own `--linearize` option.\n>\n> Co-authored-by: Toon Claes <toon@iotcl.com>\n> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n>  Documentation/git-replay.adoc |  21 ++++++-\n>  builtin/replay.c              |   6 +-\n>  replay.c                      |  54 ++++++++++------\n>  replay.h                      |   5 ++\n>  t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-\n>  5 files changed, 203 insertions(+), 23 deletions(-)\n\nWith such an extensive change in behaviour, I wonder if Dscho is\nstill responsible for latent bugs in this round of implementation\nand documentation, or should you take the responsibility over?\n\n> +--linearize::\n> +\tIn this mode, each replayed commit is stacked on top of the\n> +\tpreviously replayed one, so all replayed commits are flattened into\n> +\ta single linear history.\n> ++\n> +When a merge commit is encountered, the behavior of git-rebase(1)'s\n> +option `--no-rebase-merges` is imitated. All commits in the range\n> +reachable from the merge commit are replayed into a linear history, and\n> +the merge commit itself is dropped. A ref that pointed to a merge commit\n> +is updated to the merge's last replayed ancestor.\n> ++\n> +This flattens the `<revision-range>` as a whole. When multiple revision\n> +ranges are given they are stacked on top of each other into one linear\n> +history. Each of their refs is updated to point to its position in that\n> +history. To linearize ranges separately, replay them in separate `git\n> +replay` invocations.\n\nOK, very much understandable.\n\n> +This option is incompatible with `--revert`.\n\nDefinitely it is OK to leave it outside the scope, but I am not sure\nif reverting a group of commits that happens to be \"closed\" and\nhappens to contain merges, is inherently incompatible with\nflattening.  If you have\n\n    ----O--A\n         \\  \\\n          B--M--C\n\nand you want to revert what happened while the history advanced from\nO to M, I would naïvely expect that I can arrive at\n\n    ----O--A\n         \\  \\\n          B--M--C-B'-A'\n\nby linearly applying the inverse of A and B (in either order).\n\nIf it is an inherent limitation, then the sentence may want \"because\n...\" at the end.  Otherwise, it would make more sense to strike the\nsentence from the main text, and have BUGS (or LIMITATIONS) section\nat the end of the page, perhaps?\n"},{"id":"547337","messageId":"87ldbm3kh6.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"xmqqbjcnhjvk.fsf@gitster.g","subject":"Re: [PATCH v6 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-07T15:09:09Z","receivedAt":"2026-07-07T15:09:28Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Toon Claes <toon@iotcl.com> writes:\n>\n>> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n>> ...\n>> Linearizing is a distinct operation, and flattening merge commits is\n>> just one aspect of that. Recreating merges would be a separate mode, so\n>> rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,\n>> git-replay(1) uses its own `--linearize` option.\n>>\n>> Co-authored-by: Toon Claes <toon@iotcl.com>\n>> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n>> Signed-off-by: Toon Claes <toon@iotcl.com>\n>> ---\n>>  Documentation/git-replay.adoc |  21 ++++++-\n>>  builtin/replay.c              |   6 +-\n>>  replay.c                      |  54 ++++++++++------\n>>  replay.h                      |   5 ++\n>>  t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-\n>>  5 files changed, 203 insertions(+), 23 deletions(-)\n>\n> With such an extensive change in behaviour, I wonder if Dscho is\n> still responsible for latent bugs in this round of implementation\n> and documentation, or should you take the responsibility over?\n\nI don't mind. But I don't want to steal credit.\n\nInitially, when Dscho shared the patch with me, he already informed me\nhe doesn't really care about authorship. So in the next version I'll be\ntaking over authorship and add a Based-on-patches-by trailer.\n\n@Dscho, let me know if you disagree?\n\n>> +--linearize::\n>> +\tIn this mode, each replayed commit is stacked on top of the\n>> +\tpreviously replayed one, so all replayed commits are flattened into\n>> +\ta single linear history.\n>> ++\n>> +When a merge commit is encountered, the behavior of git-rebase(1)'s\n>> +option `--no-rebase-merges` is imitated. All commits in the range\n>> +reachable from the merge commit are replayed into a linear history, and\n>> +the merge commit itself is dropped. A ref that pointed to a merge commit\n>> +is updated to the merge's last replayed ancestor.\n>> ++\n>> +This flattens the `<revision-range>` as a whole. When multiple revision\n>> +ranges are given they are stacked on top of each other into one linear\n>> +history. Each of their refs is updated to point to its position in that\n>> +history. To linearize ranges separately, replay them in separate `git\n>> +replay` invocations.\n>\n> OK, very much understandable.\n>\n>> +This option is incompatible with `--revert`.\n>\n> Definitely it is OK to leave it outside the scope, but I am not sure\n> if reverting a group of commits that happens to be \"closed\" and\n> happens to contain merges, is inherently incompatible with\n> flattening.  If you have\n>\n>     ----O--A\n>          \\  \\\n>           B--M--C\n>\n> and you want to revert what happened while the history advanced from\n> O to M, I would naïvely expect that I can arrive at\n>\n>     ----O--A\n>          \\  \\\n>           B--M--C-B'-A'\n>\n> by linearly applying the inverse of A and B (in either order).\n\nYou're absolutely right. Personally I'm not sure why the limitation was\nintroduced. I've done some testing and I cannot see why we wouldn't\nallow --revert and --linearize to be combined. So I'll be submitting v7\nwithout this restriction.\n\n-- \nCheers,\nToon\n"},{"id":"547381","messageId":"20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com","threadId":"65771","inReplyTo":"20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com","subject":"[PATCH v7 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-07T19:07:24Z","receivedAt":"2026-07-07T19:07:38Z","isPatch":true,"body":"As an alternative to dscho's patch series to replay merges[1], add\nan option to git-replay(1) to linearize merges. This mimics what\ngit-rebase(1) does with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. This patch was kindly provided by Dscho, which I've\ntweaked to be upstreamed.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion it doesn't make much sense to do\nso, but I'm happy to hear if anyone disagrees.\n\nThis series might conflict with Kristoffer's series to make\ndocumentation changes[2], but should be trivial to resolve. And I don't\nthink there's a conflict with Patrick's series on adding \"drop\" to\ngit-history(1)[3].\n\ndscho's series to replay merges[1] needs a bit of rework to fit on top\nof this, but I'm happy to help figuring that out. We've been discussing\nto either name the option --flatten or --linearize, but I've decided on\n\"linearize\" because the documentation of git-rebase(1) also mentions\n\"linearize\".\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n[2]: <V3_CV_doc_replay_config.780@msgid.xyz>\n[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>\n\n---\nChanges in v7:\n- Allow --revert and --linearize to be used together.\n- Because quite a lot of changes have been made since the original\n  patch, change author from Johannes to Toon for the last commit.\n  Johannes already told me he doesn't really care about authorship when\n  he initially shared the patch with me.\n- Link to v6: https://patch.msgid.link/20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com\n\nChanges in v6:\n- Reworked the second commit that moves picking the base completely\n  outside pick_regular_commit(), instead of adding more explanation.\n- Drastically extended the commit message on commit #3.\n- Extended docs on flattening multiple revision ranges and how it's\n  different from git-rebase(1)'s --no-rebase-merges.\n- Added a bunch of tests to cover various scenarios.\n- Remove newline from BUG() message.\n- Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com\n\nChanges in v5:\n- Dropped the enum->bool patch and instead added a patch that better\n  explains how pick_regular_commit() picks a base.\n- Order of commits is shuffled.\n- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool\n  patch, I extended the code comments to explain how things work. This\n  made me realize the use of the \"replayed_base\" was incorrect when\n  multiple branches are rebased with --onto. This is fixed now and a\n  test is added for this scenario.\n- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com\n\nChanges in v4:\n- Use test_grep instead of a bare grep in the range-diff test, to\n  prepare for mm/test-grep-lint.\n- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com\n\nChanges in v3:\n- Add --linearize to Documentation SYNOPSIS, and mention it's\n  incompatible with --revert.\n- Small language change in help message for --linearize.\n- Rephrase comment to include last_commit isn't modified when\n  linearizing merges.\n- Remove test that was added in earlier versions, but actually is\n  a duplicate of 'replaying merge commits is not supported yet'.\n- Add test to verify --revert and --linearize are incompatible.\n- Properly test that replaying down to root with --linearize works.\n- Add test for --linearize with --advance.\n- Add test that uses git-range-diff(1) to verify the patches created by\n  --linearize are correct.\n- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com\n\nChanges in v2:\n- Restructured the conditions to detect merge commits and added a line\n  of comment why the loop continues.\n- Rewrote tests to use the history from the setup step and added a few\n  test cases.\n- Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n  patches with this trailer, and if I understand correctly, I can keep\n  it. Please let me know if that wrong.\n- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n\n---\nToon Claes (3):\n      replay: add helper to put entry into replayed_commits\n      replay: resolve the replay base outside pick_regular_commit()\n      replay: offer an option to linearize the commit topology\n\n Documentation/git-replay.adoc |  19 +++++-\n builtin/replay.c              |   4 +-\n replay.c                      |  81 ++++++++++++++++--------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 221 insertions(+), 28 deletions(-)\n\nRange-diff versus v6:\n\n1:  96637c42a9 ! 1:  ce24fba6d6 replay: add helper to put entry into replayed_commits\n    @@ Commit message\n         replay: add helper to put entry into replayed_commits\n     \n         The function replay_revisions() in replay.c is rather lengthy. Extract\n    -    the logic to put a commit entry into mapped_commits into a helper\n    -    function put_mapped_commit().\n    +    the logic to put a commit entry into a `struct mapped_commits` into a\n    +    helper function put_mapped_commit().\n     \n         While at it, rename mapped_commit() to get_mapped_commit() to pair with\n         this new function.\n2:  ae6c27aee6 ! 2:  6a39274c1c replay: resolve the replay base outside pick_regular_commit()\n    @@ Commit message\n     \n         Move the base selection completely into the caller: replay_revisions().\n         This bundles all the logic of deciding on the base together. Also, this\n    -    reduces the number of parameters of pick_regular_commit(), making it's\n    +    reduces the number of parameters of pick_regular_commit(), making its\n         interface cleaner.\n     \n         This refactoring doesn't bring any behavior changes.\n3:  0208101e9b ! 3:  2960b9fdaf replay: offer an option to linearize the commit topology\n    @@\n      ## Metadata ##\n    -Author: Johannes Schindelin <Johannes.Schindelin@gmx.de>\n    +Author: Toon Claes <toon@iotcl.com>\n     \n      ## Commit message ##\n         replay: offer an option to linearize the commit topology\n    @@ Commit message\n         rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,\n         git-replay(1) uses its own `--linearize` option.\n     \n    -    Co-authored-by: Toon Claes <toon@iotcl.com>\n    -    Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n    +    Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n         Signed-off-by: Toon Claes <toon@iotcl.com>\n     \n      ## Documentation/git-replay.adoc ##\n    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif\n     +history. Each of their refs is updated to point to its position in that\n     +history. To linearize ranges separately, replay them in separate `git\n     +replay` invocations.\n    -++\n    -+This option is incompatible with `--revert`.\n     +\n      <revision-range>::\n      \tRange of commits to replay; see \"Specifying Ranges\" in\n    @@ builtin/replay.c: int cmd_replay(int argc,\n      \t\tOPT_END()\n      \t};\n      \n    -@@ builtin/replay.c: int cmd_replay(int argc,\n    - \t\t\t\t  opts.contained, \"--contained\");\n    - \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n    - \t\t\t\t  !!opts.contained, \"--contained\");\n    -+\tdie_for_incompatible_opt2(!!opts.revert, \"--revert\",\n    -+\t\t\t\t  opts.linearize, \"--linearize\");\n    - \n    - \t/* Parse ref action mode from command line or config */\n    - \tref_mode = get_ref_action_mode(repo, ref_action);\n     \n      ## replay.c ##\n     @@ replay.c: int replay_revisions(struct rev_info *revs,\n    @@ t/t3650-replay-basics.sh: test_expect_success 'setup' '\n      \tgit switch -c conflict B &&\n     -\ttest_commit C.conflict C.t conflict\n     +\ttest_commit C.conflict C.t conflict &&\n    -+\tgit branch -D unrelated\n    ++\tgit branch -D unrelated &&\n    ++\n    ++\tgit switch -c divergent-x main &&\n    ++\ttest_commit X &&\n    ++\tgit switch -c divergent-y main &&\n    ++\ttest_commit Y &&\n    ++\tgit switch divergent-x &&\n    ++\ttest_merge Z divergent-y --no-ff\n      '\n      \n      test_expect_success 'setup bare' '\n    -@@ t/t3650-replay-basics.sh: test_expect_success '--advance and --contained cannot be used together' '\n    - \ttest_grep \"cannot be used together\" actual\n    - '\n    - \n    -+test_expect_success '--revert and --linearize cannot be used together' '\n    -+\ttest_must_fail git replay --revert=main --linearize \\\n    -+\t\ttopic1..topic2 2>actual &&\n    -+\ttest_grep \"cannot be used together\" actual\n    -+'\n    -+\n    - test_expect_success 'cannot advance target ... ordering would be ill-defined' '\n    - \techo \"fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined\" >expect &&\n    - \ttest_must_fail git replay --advance=main main topic1 topic2 2>actual &&\n     @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' '\n      \ttest_grep \"cannot be used with multiple revision ranges\" err\n      '\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +'\n     +\n     +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n    -+\ttest_when_finished \"git update-ref -d refs/heads/divergent-x\" &&\n    -+\ttest_when_finished \"git update-ref -d refs/heads/divergent-y\" &&\n    -+\n    -+\t# Build a real merge of two commits that diverged from a common base:\n    -+\t#\n    -+\t#       X - Z (divergent-x)\n    -+\t#      /   /\n    -+\t#  M  -  Y (divergent-y)\n    -+\t#\n    -+\tgit switch -c divergent-x main &&\n    -+\ttest_commit X &&\n    -+\tgit switch -c divergent-y main &&\n    -+\ttest_commit Y &&\n    -+\tgit switch divergent-x &&\n    -+\ttest_merge Z divergent-y --no-ff &&\n    -+\n     +\tgit replay --ref-action=print --linearize \\\n     +\t\t--onto main main..divergent-x >result &&\n     +\ttest_line_count = 1 result &&\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +\ttest_write_lines O N J I M L B A >expect &&\n     +\ttest_cmp expect actual\n     +'\n    ++\n    ++test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '\n    ++\tgit replay --ref-action=print --revert=divergent-x --linearize \\\n    ++\t\tmain..divergent-x >result &&\n    ++\ttest_line_count = 1 result &&\n    ++\ttip=$(cut -f 3 -d \" \" result) &&\n    ++\n    ++\tgit log --format=%s $tip >actual &&\n    ++\ttest_write_lines \\\n    ++\t\t\"Revert \\\"X\\\"\" \"Revert \\\"Y\\\"\" Z Y X M L B A >expect &&\n    ++\ttest_cmp expect actual &&\n    ++\n    ++\ttest_must_fail git cat-file -e $tip:X.t &&\n    ++\ttest_must_fail git cat-file -e $tip:Y.t\n    ++'\n     +\n      test_done\n\n\n---\nbase-commit: ab776a62a78576513ee121424adb19597fbb7613\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"547382","messageId":"20260707-toon-git-replay-drop-merges-v7-1-808ab9b4afa6@iotcl.com","threadId":"65771","inReplyTo":"20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com","subject":"[PATCH v7 1/3] replay: add helper to put entry into replayed_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-07T19:07:25Z","receivedAt":"2026-07-07T19:07:49Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put a commit entry into a `struct mapped_commits` into a\nhelper function put_mapped_commit().\n\nWhile at it, rename mapped_commit() to get_mapped_commit() to pair with\nthis new function.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex da531d5bc6..b9f8fc47ce 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -250,9 +250,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -263,6 +263,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -283,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -423,8 +438,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -436,11 +449,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"547383","messageId":"20260707-toon-git-replay-drop-merges-v7-2-808ab9b4afa6@iotcl.com","threadId":"65771","inReplyTo":"20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com","subject":"[PATCH v7 2/3] replay: resolve the replay base outside pick_regular_commit()","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-07T19:07:26Z","receivedAt":"2026-07-07T19:07:57Z","isPatch":true,"body":"Depending on what gets passed into the function pick_regular_commit(),\nit decides the new base for the replayed commit. It first tries to find\nthe replayed results of `pickme`'s parent in the `replayed_commits` map.\nIf not found, it falls back to `onto`.\n\nWhen using git-replay(1) with --onto, the fallback is the revision\npassed in with this option, but when using --revert, the fallback is\n`last_commit`.\n\nIt's rather confusing the base is decided partly inside\npick_regular_commit() and partly by its caller.\n\nMove the base selection completely into the caller: replay_revisions().\nThis bundles all the logic of deciding on the base together. Also, this\nreduces the number of parameters of pick_regular_commit(), making its\ninterface cleaner.\n\nThis refactoring doesn't bring any behavior changes.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 34 +++++++++++++++++++++-------------\n 1 file changed, 21 insertions(+), 13 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex b9f8fc47ce..5aee0eafbc 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -280,25 +280,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n \n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n-\t\t\t\t\t  kh_oid_map_t *replayed_commits,\n-\t\t\t\t\t  struct commit *onto,\n+\t\t\t\t\t  struct commit *replayed_base,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n \t\t\t\t\t  enum replay_mode mode,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n-\tstruct commit *base, *replayed_base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n \n-\tif (pickme->parents) {\n-\t\tbase = pickme->parents->item;\n-\t\tbase_tree = repo_get_commit_tree(repo, base);\n-\t} else {\n-\t\tbase = NULL;\n+\tif (pickme->parents)\n+\t\tbase_tree = repo_get_commit_tree(repo, pickme->parents->item);\n+\telse\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n-\t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -439,12 +433,26 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n+\t\t/*\n+\t\t * Decide where to replay this commit on.\n+\t\t * If the parent commit was replayed already, the replayed result\n+\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n+\t\t * When reverting, commits are replayed in reverse order and thus\n+\t\t * its parent isn't replayed yet. Therefore revert commits are\n+\t\t * always replayed onto `last_commit`.\n+\t\t */\n+\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n+\n+\t\tif (mode == REPLAY_MODE_REVERT)\n+\t\t\tbase = last_commit;\n+\n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t  mode, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"547384","messageId":"20260707-toon-git-replay-drop-merges-v7-3-808ab9b4afa6@iotcl.com","threadId":"65771","inReplyTo":"20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com","subject":"[PATCH v7 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-07T19:07:27Z","receivedAt":"2026-07-07T19:08:02Z","isPatch":true,"body":"One of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearizes the history into a sequence of regular (single-parent)\ncommits.\n\nAdd option `--linearize` to git-replay(1) to do the same. Each replayed\ncommit is stacked on top of the previously replayed one. When a merge is\nencountered, the commits reachable from all of its sides are replayed\ninto the single line and the merge itself is dropped.\n\nIf a ref was pointing to a merge commit, that ref is updated to the\nmerge's last replayed ancestor.\n\ngit-replay(1) accepts multiple revision ranges, for example:\n\n    $ git replay --onto main topic1 topic2\n\nWithout `--linearize` this replays 'topic1' and 'topic2' onto 'main'\nindependently and updates both refs.\n\nWith `--linearize` the whole set is flattened into one line: the ranges\nare stacked on top of each other rather than replayed side by side, so\nboth refs end up pointing at different points along that single history.\n\nReplaying all revision ranges into one single linear history is\nintentional and it's the only way to ensure predictable results. A user\nwho wants to linearize ranges independently is advised to use separate\ngit-replay(1) invocations.\n\nLinearizing is a distinct operation, and flattening merge commits is\njust one aspect of that. Recreating merges would be a separate mode, so\nrather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,\ngit-replay(1) uses its own `--linearize` option.\n\nBased-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  19 +++++-\n builtin/replay.c              |   4 +-\n replay.c                      |  54 ++++++++++------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 199 insertions(+), 23 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex a32f72aead..98e20c1c6e 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n --------\n [verse]\n (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n-\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n+\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, each replayed commit is stacked on top of the\n+\tpreviously replayed one, so all replayed commits are flattened into\n+\ta single linear history.\n++\n+When a merge commit is encountered, the behavior of git-rebase(1)'s\n+option `--no-rebase-merges` is imitated. All commits in the range\n+reachable from the merge commit are replayed into a linear history, and\n+the merge commit itself is dropped. A ref that pointed to a merge commit\n+is updated to the merge's last replayed ancestor.\n++\n+This flattens the `<revision-range>` as a whole. When multiple revision\n+ranges are given they are stacked on top of each other into one linear\n+history. Each of their refs is updated to point to its position in that\n+history. To linearize ranges separately, replay them in separate `git\n+replay` invocations.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..5e6ff4191a 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -85,7 +85,7 @@ int cmd_replay(int argc,\n \tconst char *const replay_usage[] = {\n \t\tN_(\"(EXPERIMENTAL!) git replay \"\n \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n-\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n+\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n \t\tNULL\n \t};\n \tstruct option replay_options[] = {\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n \t\tOPT_END()\n \t};\n \ndiff --git a/replay.c b/replay.c\nindex 5aee0eafbc..bd1f3bb898 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -433,26 +433,40 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\t/*\n-\t\t * Decide where to replay this commit on.\n-\t\t * If the parent commit was replayed already, the replayed result\n-\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n-\t\t * When reverting, commits are replayed in reverse order and thus\n-\t\t * its parent isn't replayed yet. Therefore revert commits are\n-\t\t * always replayed onto `last_commit`.\n-\t\t */\n-\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n-\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n-\n-\t\tif (mode == REPLAY_MODE_REVERT)\n-\t\t\tbase = last_commit;\n-\n-\t\tif (commit->parents && commit->parents->next)\n-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n-\n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n-\t\t\t\t\t\t  &merge_opt, &result,\n-\t\t\t\t\t\t  mode, opts->empty);\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (!opts->linearize)\n+\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\t\t/*\n+\t\t\t * Drop the merge commit: do not pick it, leave\n+\t\t\t * `last_commit` unchanged, and fall through to the\n+\t\t\t * rest of the loop. As a result:\n+\t\t\t * - refs pointing to the merge commit will be updated\n+\t\t\t *   to `last_commit`.\n+\t\t\t * - the next replayed commit uses `last_commit` as its\n+\t\t\t *   `base`.\n+\t\t\t */\n+\t\t} else {\n+\t\t\t/*\n+\t\t\t * Decide where to replay this commit onto.\n+\t\t\t * If the parent commit was replayed already, the replayed result\n+\t\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n+\t\t\t * When reverting, commits are replayed in reverse order and thus\n+\t\t\t * its parent isn't replayed yet. Therefore revert commits are\n+\t\t\t * always replayed onto `last_commit`.\n+\t\t\t * Also when opts->linearize is true, set the base to\n+\t\t\t * `last_commit` to create a single linear history.\n+\t\t\t */\n+\t\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\t\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n+\n+\t\t\tif (opts->linearize || mode == REPLAY_MODE_REVERT)\n+\t\t\t\tbase = last_commit;\n+\n+\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t\t  mode, opts->empty);\n+\t\t}\n+\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex faf95c7459..64f42b6512 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..4d3d442e8a 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -52,8 +52,19 @@ test_expect_success 'setup' '\n \ttest_merge P O --no-ff &&\n \tgit switch main &&\n \n+\tgit switch --orphan unrelated &&\n+\ttest_commit unrelated-root &&\n+\n \tgit switch -c conflict B &&\n-\ttest_commit C.conflict C.t conflict\n+\ttest_commit C.conflict C.t conflict &&\n+\tgit branch -D unrelated &&\n+\n+\tgit switch -c divergent-x main &&\n+\ttest_commit X &&\n+\tgit switch -c divergent-y main &&\n+\ttest_commit Y &&\n+\tgit switch divergent-x &&\n+\ttest_merge Z divergent-y --no-ff\n '\n \n test_expect_success 'setup bare' '\n@@ -565,4 +576,131 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'replay to rebase merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto unrelated-root topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J I B A unrelated-root >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to cherry-pick merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--advance main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tprintf \"update refs/heads/main \" >expect &&\n+\tprintf \"%s \" $(cut -f 3 -d \" \" result) >>expect &&\n+\tgit rev-parse main >>expect &&\n+\ttest_cmp expect result\n+'\n+\n+test_expect_success 'replay --linearize produces the same patches' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# range-diff does not care about the dropped merge,\n+\t# so the original commits (I..topic-with-merge)\n+\t# and the replayed chain (main..tip) must produce identical patches.\n+\tgit range-diff I..topic-with-merge main..$tip >out &&\n+\ttest_file_not_empty out &&\n+\ttest_grep ! -v \"=\" out &&\n+\n+\tgit log --oneline main..$tip >out &&\n+\ttest_line_count = 3 out\n+'\n+\n+test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main ^B topic2 topic3 topic4 >result &&\n+\n+\ttest_line_count = 3 result &&\n+\tcut -f 3 -d \" \" result >new-branch-tips &&\n+\n+\t>expect &&\n+\tfor i in 2 3 4\n+\tdo\n+\t\tprintf \"update refs/heads/topic$i \" >>expect &&\n+\t\tprintf \"%s \" $(grep topic$i result | cut -f 3 -d \" \") >>expect &&\n+\t\tgit rev-parse topic$i >>expect || return 1\n+\tdone &&\n+\n+\ttest_cmp expect result &&\n+\n+\ttest_write_lines           E D C M L B A >expect2 &&\n+\ttest_write_lines     H G F E D C M L B A >expect3 &&\n+\ttest_write_lines J I H G F E D C M L B A >expect4 &&\n+\n+\tfor i in 2 3 4\n+\tdo\n+\t\tgit log --format=%s $(grep topic$i result | cut -f 3 -d \" \") >actual &&\n+\t\ttest_cmp expect$i actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main main..divergent-x >result &&\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# The merge Z is dropped, but both X and Y are linearized onto main;\n+\t# neither side is lost.\n+\tgit log --format=%s main..$tip >actual &&\n+\ttest_write_lines Y X >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--linearize with --contained updates contained refs' '\n+\tgit replay --ref-action=print --linearize --contained \\\n+\t\t--onto main ^B topic-with-merge >result &&\n+\n+\ttest_line_count = 2 result &&\n+\n+\tgit log --format=%s $(head -n 1 result | cut -f 3 -d \" \") >actual &&\n+\ttest_write_lines J I M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tgit log --format=%s $(tail -n 1 result | cut -f 3 -d \" \") >actual &&\n+\ttest_write_lines O N J I M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '\n+\tgit replay --ref-action=print --revert=divergent-x --linearize \\\n+\t\tmain..divergent-x >result &&\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\tgit log --format=%s $tip >actual &&\n+\ttest_write_lines \\\n+\t\t\"Revert \\\"X\\\"\" \"Revert \\\"Y\\\"\" Z Y X M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\ttest_must_fail git cat-file -e $tip:X.t &&\n+\ttest_must_fail git cat-file -e $tip:Y.t\n+'\n+\n test_done\n\n-- \n2.53.0.1323.g189a785ab5\n\n"},{"id":"547391","messageId":"xmqqy0fm1tkc.fsf@gitster.g","threadId":"65771","inReplyTo":"87ldbm3kh6.fsf@emacs.iotcl.com","subject":"Re: [PATCH v6 3/3] replay: offer an option to linearize the commit topology","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-07T19:35:47Z","receivedAt":"2026-07-07T19:35:50Z","isPatch":true,"body":"Toon Claes <toon@iotcl.com> writes:\n\n> Junio C Hamano <gitster@pobox.com> writes:\n>\n>> Definitely it is OK to leave it outside the scope, but I am not sure\n>> if reverting a group of commits that happens to be \"closed\" and\n>> happens to contain merges, is inherently incompatible with\n>> flattening.  If you have\n>>\n>>     ----O--A\n>>          \\  \\\n>>           B--M--C\n>>\n>> and you want to revert what happened while the history advanced from\n>> O to M, I would naïvely expect that I can arrive at\n>>\n>>     ----O--A\n>>          \\  \\\n>>           B--M--C-B'-A'\n>>\n>> by linearly applying the inverse of A and B (in either order).\n>\n> You're absolutely right. Personally I'm not sure why the limitation was\n> introduced. I've done some testing and I cannot see why we wouldn't\n> allow --revert and --linearize to be combined. So I'll be submitting v7\n> without this restriction.\n\nOf course, postponing this is a safe option (at least for our\ninitial effort) *if* we cannot reliably detect the good case.\n\nFor example, it is unclear what happens if the linearized range in\nthe diagram above contains M and A, but not B or O. We might want to\ndistinguish that scenario from the depicted case, where all of A, B,\nand O, as well as M, are in the range, but the current code may not\nbe able to do so reliably. However, if we can consistently provide\nbehavior that is logical and easy to explain, it would be ideal to\nlift this artificial restriction.\n\nThanks.\n"},{"id":"547418","messageId":"xmqq5x2qz42z.fsf@gitster.g","threadId":"65771","inReplyTo":"20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com","subject":"Re: [PATCH v7 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-08T01:02:12Z","receivedAt":"2026-07-08T01:02:14Z","isPatch":true,"body":"Toon Claes <toon@iotcl.com> writes:\n\n> This series might conflict with Kristoffer's series to make\n> documentation changes[2], but should be trivial to resolve. And I don't\n> think there's a conflict with Patrick's series on adding \"drop\" to\n> git-history(1)[3].\n>\n> dscho's series to replay merges[1] needs a bit of rework to fit on top\n> of this, but I'm happy to help figuring that out. We've been discussing\n> to either name the option --flatten or --linearize, but I've decided on\n> \"linearize\" because the documentation of git-rebase(1) also mentions\n> \"linearize\".\n>\n> [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n> [2]: <V3_CV_doc_replay_config.780@msgid.xyz>\n> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>\n>\n> ---\n> Changes in v7:\n> - Allow --revert and --linearize to be used together.\n> - Because quite a lot of changes have been made since the original\n>   patch, change author from Johannes to Toon for the last commit.\n>   Johannes already told me he doesn't really care about authorship when\n>   he initially shared the patch with me.\n\nLooks like all the previous review comments have been answered and\nthe topic is in a good shape to be merged to 'next' (and allow us to\npolish incrementally as needed)?\n\nThanks for working on the topic.  Let me mark it for 'next'.\n"},{"id":"547670","messageId":"CABPp-BGzU9KHGF1nipi2HZaa1AiikMKGGaapQzHVH06wO4V1ww@mail.gmail.com","threadId":"65771","inReplyTo":"20260707-toon-git-replay-drop-merges-v7-3-808ab9b4afa6@iotcl.com","subject":"Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology","fromName":"Elijah Newren","fromEmail":"newren@gmail.com","sentAt":"2026-07-10T03:47:37Z","receivedAt":"2026-07-10T03:47:50Z","isPatch":true,"body":"Hi Toon!\n\nThanks for continuing to work on the series.  Sorry that I've been out\non vacation for 3+ weeks and then playing catch up.  You addressed all\nmy v2 feedback, and most things in this latest v7 look good.  I do\nhave one substantive concern with this patch, which I'll cover in\ndetail below.\n\nOn Tue, Jul 7, 2026 at 12:07 PM Toon Claes <toon@iotcl.com> wrote:\n>\n> One of the stated goals of git-replay(1) is to allow implementing the\n> git-rebase(1) functionality on the server side.\n>\n> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n> was given. This mode drops merge commits instead of replaying them, and\n> linearizes the history into a sequence of regular (single-parent)\n> commits.\n>\n> Add option `--linearize` to git-replay(1) to do the same.\n\nRight, `--linearize` exists to change how merges are handled.  I'd\nargue that if there are no merges, then you should get the same\nbehavior whether or not --linearize appears on your command line.\n\n> Each replayed\n> commit is stacked on top of the previously replayed one. When a merge is\n> encountered, the commits reachable from all of its sides are replayed\n> into the single line and the merge itself is dropped.\n>\n> If a ref was pointing to a merge commit, that ref is updated to the\n> merge's last replayed ancestor.\n\nThis is a good description of the net effect of linearizing a single\nbranch.  I think it describes rebasing multiple branches at once much\nless well -- see below.\n\n> git-replay(1) accepts multiple revision ranges, for example:\n\nI think I know what you mean, but this isn't quite right:\ngit-replay(1) only ever accepts a single revision range.  From\ngitrevisions(7) (also in git-rev-parse(1)):\n\n       Commands that are specifically designed to take two distinct ranges\n       (e.g. \"git range-diff R1 R2\" to compare two ranges) do exist, but they\n       are exceptions. Unless otherwise noted, all \"git\" commands that operate\n       on a set of commits work on a single revision range. In other words,\n       writing two \"two-dot range notation\" next to each other, e.g.\n\n           $ git log A..B C..D\n\n       does not specify two revision ranges for most commands. Instead it will\n       name a single connected set of commits, i.e. those that are reachable\n       from either B or D but are reachable from neither A or C.\n\nYou could say that replay accepts multiple branches (references)\nwithin its revision range -- but even then that comes with an \"in some\ncases\" qualifier: `--advance` (and more recently, `--revert`)\nspecifically reject multiple positive refs, precisely because (a)\nsimply concatenating branches is surprising, and (b) the resulting\norder is ill-defined (or at least looks arbitrary to the user).\n\n>     $ git replay --onto main topic1 topic2\n>\n> Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n> independently and updates both refs.\n\nAnd, if there are no merges anywhere in the range, I'd argue that\nadding --linearize either ought to do the same thing -- or else error\nout that multiple positive refs are not allowed with `--linearize`,\nthe way `--advance` and `--revert` already do.\n\n> With `--linearize` the whole set is flattened into one line: the ranges\n> are stacked on top of each other rather than replayed side by side, so\n> both refs end up pointing at different points along that single history.\n\nTo me, this is a significant principle of least astonishment violation.\n\n> Replaying all revision ranges into one single linear history is\n> intentional and it's the only way to ensure predictable results.\n\nI have to push back on both \"only\" and \"predictable\".\n\nRegarding \"only\", there are at least two other choices:\n  * make --linearize incompatible with multiple positive refs\n  * More involved implementation (quick sketch): (a) Track a\nlast_commit per branch specified on the command line, (b) Make the\nrevision walk keep track of which branches each walked commit is\nreachable from, (c) for each commit to be replayed, for each branch\nit's reachable from, update the appropriate last_commit[branch].\n(Except that when last_commit[branchA] == last_commit[branchB] and a\ncommit is reachable from both branchA & branchB, you only replay the\ncommit once.)\n\nRegarding \"predictable\", I'd like to split predictability into two\npieces: guessable by the user, and consistent with other replay\ncommands.  This behavior gives us neither:\n  * guessable by the user:\n    * which of the multiple branches specified on the command line is\nfirst in your concatenated linearization?  It's decided by rev-walk,\nnot what the user wrote.\n  * consistent:\n    * why does a merge-free topology behave differently with\n--linearize than without it?\n    * why do `--advance` and `--revert` both refuse multiple positive\nrefs to avoid exactly this \"which branch first\" concatenation, while\n`--onto --linearize` embraces it?\n\nFor what it's worth, looking back at the v5 thread, it seems the `base\n= last_commit` rule came in to fix the real bug Junio and Phillip\npointed out there -- that without it, only one side of a linearized\nmerge survived.  That fix is clearly correct for the single-branch\ncase.  My worry is only that applying it unconditionally reintroduces\nthe multiple-positive-refs ordering problem we deliberately avoid\nelsewhere.  Making `--linearize` reject multiple positive refs would\nkeep the merge-flattening fix while sidestepping this entirely.\n\n> A user\n> who wants to linearize ranges independently is advised to use separate\n> git-replay(1) invocations.\n\nWhich, to me, is another argument for just disallowing multiple\npositive refs under `--linearize`: if the recommended way to do it is\nseparate invocations anyway, we may as well require them.\n\n> Linearizing is a distinct operation, and flattening merge commits is\n> just one aspect of that. Recreating merges would be a separate mode, so\n> rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,\n> git-replay(1) uses its own `--linearize` option.\n\nNo disagreement here on this point.\n\n\nThanks,\nElijah\n"},{"id":"548042","messageId":"xmqqbjcawnhp.fsf@gitster.g","threadId":"65771","inReplyTo":"CABPp-BGzU9KHGF1nipi2HZaa1AiikMKGGaapQzHVH06wO4V1ww@mail.gmail.com","subject":"Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-13T22:09:22Z","receivedAt":"2026-07-13T22:09:25Z","isPatch":true,"body":"Elijah Newren <newren@gmail.com> writes:\n\n> For what it's worth, looking back at the v5 thread, it seems the `base\n> = last_commit` rule came in to fix the real bug Junio and Phillip\n> pointed out there -- that without it, only one side of a linearized\n> merge survived.  That fix is clearly correct for the single-branch\n> case.  My worry is only that applying it unconditionally reintroduces\n> the multiple-positive-refs ordering problem we deliberately avoid\n> elsewhere.  Making `--linearize` reject multiple positive refs would\n> keep the merge-flattening fix while sidestepping this entirely.\n>\n>> A user\n>> who wants to linearize ranges independently is advised to use separate\n>> git-replay(1) invocations.\n>\n> Which, to me, is another argument for just disallowing multiple\n> positive refs under `--linearize`: if the recommended way to do it is\n> separate invocations anyway, we may as well require them.\n\nHmph.  To me, this is slightly different.  It acts more like an\nescape hatch: \"if you really do not want to mix unrelated things\ninto a single linear history, you can do this other thing.\"\n\nStepping back, the unpredictable order of multiple merged lines of\nhistory exists even without multiple positive refs.  If you have\nindependent lines of development that were merged and you linearize\nthem, someone must choose which line comes first.  If you let the\nmachinery make that decision, the resulting commit order may not\nreflect your preferences.\n\nWhile I rarely perform octopus merges anymore, in situations where an\noctopus merge is appropriate (e.g., when you have N independent\nbranches and their merge order does not matter), linearizing such\na history into a random sequence of N segments, built on top of\none another in an unspecified order, could actually be considered a\nfeature.  You do not have to make a decision about something that is\ninconsequential.\n\nSo, I am not convinced we should forbid this behavior to avoid\ndealing with a history containing merges or multiple positive tips.\n\nWhen achieving a strictly linear history is the user's goal under\nthe \"--linearize\" option, is it not inherent that there is no single\n\"correct\" order for these independent segments of history to appear\nin the final linear result?\n\nPerhaps I am not reading you correctly, but that is how I read that\nescape hatch explanation.\n\nThanks.\n\n\n\n\n"},{"id":"548234","messageId":"CABPp-BGxO0bd3UzDYNnhNUgDSKYwcFVCFsJ9rCzmNX7Q0xBrow@mail.gmail.com","threadId":"65771","inReplyTo":"xmqqbjcawnhp.fsf@gitster.g","subject":"Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology","fromName":"Elijah Newren","fromEmail":"newren@gmail.com","sentAt":"2026-07-15T07:34:02Z","receivedAt":"2026-07-15T07:34:15Z","isPatch":true,"body":"On Mon, Jul 13, 2026 at 3:09 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Elijah Newren <newren@gmail.com> writes:\n>\n> > For what it's worth, looking back at the v5 thread, it seems the `base\n> > = last_commit` rule came in to fix the real bug Junio and Phillip\n> > pointed out there -- that without it, only one side of a linearized\n> > merge survived.  That fix is clearly correct for the single-branch\n> > case.  My worry is only that applying it unconditionally reintroduces\n> > the multiple-positive-refs ordering problem we deliberately avoid\n> > elsewhere.  Making `--linearize` reject multiple positive refs would\n> > keep the merge-flattening fix while sidestepping this entirely.\n> >\n> >> A user\n> >> who wants to linearize ranges independently is advised to use separate\n> >> git-replay(1) invocations.\n> >\n> > Which, to me, is another argument for just disallowing multiple\n> > positive refs under `--linearize`: if the recommended way to do it is\n> > separate invocations anyway, we may as well require them.\n>\n> Hmph.  To me, this is slightly different.  It acts more like an\n> escape hatch: \"if you really do not want to mix unrelated things\n> into a single linear history, you can do this other thing.\"\n>\n> Stepping back, the unpredictable order of multiple merged lines of\n> history exists even without multiple positive refs.  If you have\n> independent lines of development that were merged and you linearize\n> them, someone must choose which line comes first.  If you let the\n> machinery make that decision, the resulting commit order may not\n> reflect your preferences.\n>\n> While I rarely perform octopus merges anymore, in situations where an\n> octopus merge is appropriate (e.g., when you have N independent\n> branches and their merge order does not matter), linearizing such\n> a history into a random sequence of N segments, built on top of\n> one another in an unspecified order, could actually be considered a\n> feature.  You do not have to make a decision about something that is\n> inconsequential.\n\nYou're right that when flattening merges within a single branch, the\nmachinery must pick an order, and that's fine — unavoidable, even.  My\nobjection isn't that; it's primarily the concatenation of distinct\nbranches named on the command line into one chain, and, as a secondary\npoint, the ignoring of the order of branches explicitly specified by\nthe user on the command line.\n\nConcretely: I have three branches to rebase onto master; one of them\nhappens to contain a merge I'd like flattened. I add  --linearize  for\nthat one merge — and now all three branches are silently concatenated\ninto a single chain.  That makes no sense to me, and I think won't to\nmost users.\n\nAnyway, I think I must have explained my position rather poorly; your\nresponse suggests I buried my main points, so let me try to restate\nthem:\n\nTL;DR version; my problems with the current implementation of\n`--linearize` are that it:\n  * Makes the rare usecase easy, and ignores the common usecase\n  * Makes it asymmetrically difficult to recover for those that wanted\nthe common usecase instead of the easy\n  * Makes `--linearize` mean something other than \"remove non-linearity\"\n  * Turns multiple branches into one, but updates several branches anyway\n  * Ignores order specified by the user on the command line\n  * Introduces an inconsistency within git-replay between `--advance`\nand `--linearize --onto`\n(The last three items being minor compared to the first three.)\n\nLonger version:\n\nConsider the following history\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- master\n    \\   \\\n     \\   \\  A1  A2  A3  A4\n      \\   \\-*---*---*---* <- branchA\n       \\        \\\n        \\        -*---* <- branchC\n         \\        C1  C2\n          \\\n           \\-*---*---* <- branchB\n            B1  B2  B3\n\ngit replay was designed to allow you to update all your branches at once.\nFor example, with this above history, running\n    git replay --onto master branchA branchB branchC\nwill rebase all three branches onto master (and handles the shared portion\nof history between branchA and branchC in the obvious way):\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- master\n                |\n                |  A1  A2  A3  A4\n                |--*---*---*---* <- branchA\n                |      \\\n                |       -*---* <- branchC\n                |        C1  C2\n                |\n                \\-*---*---* <- branchB\n                  B1  B2  B3\n\nWith the current implementation of --linearize, adding that flag, i.e.\n    git replay --linearize --onto master branchA branchB branchC\nwould instead give something like:\n\nM1  M2  M3  M4  M5  B1  B2  B3  A1  A2  C1  C2  A3  A4\n*---*---*---*---*---*---*---*---*---*---*---*---*---*\n                ^           ^               ^       ^\n                |           |               |       |\n              master     branchB         branchC  branchA\n\nThis topology strikes me as something that users would very rarely ever\nwant.  Further, it:\n  * Makes one question why branchB and branchC were kept instead of\n    deleted; if the whole point is to concatenate the branches, then\n    since whichever branch lands on top contains the other two, why not\n    just get rid of the others?\n  * Makes the command behave differently on *already linear* history\n    when --linearize is added, which makes no sense to me.\n  * (Minor point, but still confusing to me) Ignores the order of\n    branches the user employed on the command line\n\nOf course, the above involves no merges, so let's introduce one; consider\nthe following alternate initial history:\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- master\n    |   \\\n    |    \\  A1  A2  A4  A6  A7  A8\n    |     \\-*---*---*---*---*---* <- branchA\n    \\            \\     /    \\\n     \\            *---*      -*---* <- branchC\n      \\           A3  A5      C1  C2\n       \\\n        \\-*---* <- branchB\n          B1  B2\n\nReplaying the three branches,\n    git replay --onto master branchA branchB branchC\nwe would expect the base of the branches to simply be updated to current\nmaster:\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- master\n                |\n                |   A1  A2  A4  A6  A7  A8\n                |---*---*---*---*---*---* <- branchA\n                |        \\     /    \\\n                |         *---*      -*---* <- branchC\n                |         A3  A5      C1  C2\n                |\n                \\-*---* <- branchB\n                  B1  B2\n\nIf you were to add --linearize, i.e.\n    git replay --linearize --onto master branchA branchB branchC\nI personally would expect:\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- master\n                |\n                |   A1  A2  A4  A3  A5  A7  A8\n                |---*---*---*---*---*---*---* <- branchA\n                |                       \\\n                |                        -*---* <- branchC\n                |                         C1  C2\n                |\n                \\-*---* <- branchB\n                  B1  B2\n\nIn other words, `--linearize` should remove the non-linearity in the graph.\nInstead, the current implementation will return something like:\n\nM1  M2  M3  M4  M5  A1  A2  A4  A3  A5  A7  C1  C2  B1  B2  A8\n*---*---*---*---*---*---*---*---*---*---*---*---*---*---*---*\n                ^                               ^       ^   ^\n                |                               |       |    \\\n              master                         branchC branchB branchA\n\nI can only imagine this rarely being useful to the user.\n\nBut to make it worse, please consider the difficulty of someone who\nwanted the bottom graph but got the top one, vs. the difficulty of\nsomeone who wanted the top graph but got the bottom one:\n  * (wanted bottom, got top) Just rebase branchB and branchA again; easy\n  * (wanted top, got bottom) You need to meticulously figure out the common\n    points of history and which sets of commits belong to each branch in\n    order to sequentially rebase each branch into the expected result.\nIn particular, the need to meticulously track start and endpoints with\nindividual\nrebases was one of the reasons that led to `git replay` rather than improvements\nto `git rebase`; the latter was so focused on single branches, that it\nwasn't really\npossible to extend to multiple branches.  It's thus rather\ndisappointing to see new\nflags for `git replay` that make handling multiple branches more painful.\n\nThere's actually one more (admittedly minor) issue as well: it creates\nan inconsistency within git-replay itself.  The `--advance` flag has a\ncheck to error out when multiple positive refs are specified solely\nbecause I thought it was weird to override the order of branches the\nuser specified on the command line (and didn't want to implement\nsomething that could force the ordering of the revision walk); the error\nmessage even states \"because the ordering would be ill-defined\".  For\nconsistency, either both should be fine with ignoring the order of\nrevisions specified by the user, or neither should be.\n\n\nSo, what to do?\n\nBoth paths I have in mind end at the same place; the only real question\nis whether the desired behavior lands in this series or as follow-up.\n\nThe minimal move is to make --linearize reject multiple positive refs for\nnow (exactly as --advance and --revert already do), unblocking this series\nso it can merge down nearly as-is, and leave per-branch linearization as\nfuture work.\n\nThe complete move is to implement that desired behavior now, by tracking a\nlast_commit per command-line branch so each branch is linearized\nindependently.\n\nThe reason I am comfortable with erroring out as a stopgap: turning an\nerror into working behavior later never breaks anyone, whereas letting the\ncurrent concatenation semantics reach 'master' risks users coming to\ndepend on them, which would make switching to the better behavior a\ncompatibility break.  Erroring now keeps our options open; merging as-is\nquietly closes them.  (git-replay is still EXPERIMENTAL, so this is not\nfatal either way, but it seems better not to paint ourselves into a\ncorner.)\n\nFor this series I would be perfectly happy with just the error; the\nper-branch last_commit tracking can come later.\n"},{"id":"548316","messageId":"xmqqse5km6lc.fsf@gitster.g","threadId":"65771","inReplyTo":"CABPp-BGxO0bd3UzDYNnhNUgDSKYwcFVCFsJ9rCzmNX7Q0xBrow@mail.gmail.com","subject":"Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-15T18:49:03Z","receivedAt":"2026-07-15T18:49:07Z","isPatch":true,"body":"Elijah Newren <newren@gmail.com> writes:\n\n> You're right that when flattening merges within a single branch, the\n> machinery must pick an order, and that's fine — unavoidable, even.  My\n> objection isn't that; it's primarily the concatenation of distinct\n> branches named on the command line into one chain, and, as a secondary\n> point, the ignoring of the order of branches explicitly specified by\n> the user on the command line.\n\nThat is true, but a user who wishes to avoid flattening in\nan unspecified order can always choose to supply only one\nbranch at a time on the command line.\n\n> Concretely: I have three branches to rebase onto master; one of them\n> happens to contain a merge I'd like flattened. I add  --linearize  for\n> that one merge — and now all three branches are silently concatenated\n> into a single chain.  That makes no sense to me, and I think won't to\n> most users.\n\nBut if that is not the outcome they wanted, I fail to see why they\nwould feed all three branches to a single invocation of --linearize\nin the first place.  After all, the command is only doing what it\nwas asked to do.\n\n> Consider the following history\n>\n> M1  M2  M3  M4  M5\n> *---*---*---*---* <- master\n>     \\   \\\n>      \\   \\  A1  A2  A3  A4\n>       \\   \\-*---*---*---* <- branchA\n>        \\        \\\n>         \\        -*---* <- branchC\n>          \\        C1  C2\n>           \\\n>            \\-*---*---* <- branchB\n>             B1  B2  B3\n>\n> git replay was designed to allow you to update all your branches at once.\n> For example, with this above history, running\n>     git replay --onto master branchA branchB branchC\n> will rebase all three branches onto master (and handles the shared portion\n> of history between branchA and branchC in the obvious way):\n> ...\n> M1  M2  M3  M4  M5  B1  B2  B3  A1  A2  C1  C2  A3  A4\n> *---*---*---*---*---*---*---*---*---*---*---*---*---*\n>                 ^           ^               ^       ^\n>                 |           |               |       |\n>               master     branchB         branchC  branchA\n\nIf that is not what you want, why did you give all three to the\nsingle invocation?  If you want A's and B's all consecutive, linearlize\nbranchA on top of 'master', and brnachB on top of it, and branch C\non top, perhaps?\n\nIf that breaks because by the time you feed branchC to the machinery\nnobody remembers that A1 and A2 were already handled, _that_ is the\nproblem the command needs to solve, no?  I am confused.\n\nOr do you want to be able to tell \"linearlize B, A, and C in this\nturn on top of 'master'\" and M1..M5..B1'..B3'..A1'..A4'..C1'..C2' as\nthe result?  That would mean the command line syntax cannot be an\narbitrary rev list range, but limited to a single negative plus one\nor more positive revision, which may be very limited but is much\nless error prone for casual users.\n"},{"id":"548352","messageId":"CABPp-BGdK8v8Qk5XB=QL_yJDPTNjSb2rN08GiPpK50V2gAj1QQ@mail.gmail.com","threadId":"65771","inReplyTo":"xmqqse5km6lc.fsf@gitster.g","subject":"Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology","fromName":"Elijah Newren","fromEmail":"newren@gmail.com","sentAt":"2026-07-16T03:53:54Z","receivedAt":"2026-07-16T03:54:07Z","isPatch":true,"body":"On Wed, Jul 15, 2026 at 11:49 AM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Elijah Newren <newren@gmail.com> writes:\n>\n> > Concretely: I have three branches to rebase onto master; one of them\n> > happens to contain a merge I'd like flattened. I add  --linearize  for\n> > that one merge — and now all three branches are silently concatenated\n> > into a single chain.  That makes no sense to me, and I think won't to\n> > most users.\n>\n> But if that is not the outcome they wanted, I fail to see why they\n> would feed all three branches to a single invocation of --linearize\n> in the first place.  After all, the command is only doing what it\n> was asked to do.\n\nPassing several branches isn't the user asking for concatenation; it's\nthe user asking for replay's core feature: update many branches at\nonce. Adding --linearize  to flatten a merge does have to join the\nlines which that merge combined, but it shouldn't also weld together\nbranches that were never merged in the first place.  The user is\ncombining two intended features, and the concatenation is an emergent\nthird behavior that neither of them implies.\n\n(Also, please note that I'm aware of the bug you raised earlier about\ndropped lines of history; my suggestion(s) don't reintroduce that\nbug.)\n\n> If that breaks because by the time you feed branchC to the machinery\n> nobody remembers that A1 and A2 were already handled, _that_ is the\n> problem the command needs to solve, no?  I am confused.\n\nYes, precisely!  That is the problem I want to be able to solve:\nupdating multiple branches which may have shared history.  The current\nproposed behavior feels hostile towards that.  Concretely, I want to\nbe able to update this history:\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- main\n    |   \\\n    |    \\  A1  A2  A4  A6  A7  A8\n    |     \\-*---*---*---*---*---* <- branchA\n    \\            \\     /    \\\n     \\            *---*      -*---* <- branchC\n      \\           A3  A5      C1  C2\n       \\\n        \\-*---* <- branchB\n          B1  B2\n\nvia `git replay --linearize --onto main branchA branchB branchC` to\n(depending on where A4 is ordered relative to A3 & A5):\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- main\n                |\n                |   A1  A2  A4  A3  A5  A7  A8\n                |---*---*---*---*---*---*---* <- branchA\n                |                       \\\n                |                        -*---* <- branchC\n                |                         C1  C2\n                |\n                \\-*---* <- branchB\n                  B1  B2\n\n(note that both branchA and branchC become linear with the merge\ncommit A6 being dropped)\n\nIn this graph:\n  * branchA and branchC cannot easily be replayed with separate\ncommands (it requires tracking starting and stopping points and\nfiguring out shared history).\n  * branchB could be done with a separate command from replaying the\nother two, but _only if_ the user first verifies that it has no common\nhistory with the other branches, and I think that's not useful\ncognitive load to place on the user.\n\nIf concatenation really is the intended behavior for this patch\nseries, then --linearize  seems like the wrong name for it: the\nsurprising part isn't that each branch becomes linear, it's that the\noption also chains together branches that were never related.\n\n> Or do you want to be able to tell \"linearlize B, A, and C in this\n> turn on top of 'master'\" and M1..M5..B1'..B3'..A1'..A4'..C1'..C2' as\n> the result?\n\nNo, ordered-concatenation is not something I'm interested in.  I want\nseparate branches to stay separate, as in the second graph above.  I\nalmost wish I hadn't even mentioned ordering, even though I labelled\nit a \"minor\" point in my last email, because it seems to have\ndistracted from the real issue.\n\nAs I proposed last time, I'd be fine with erroring on multiple\npositive refs as an interim step (plus associated documentation and\ncommit message updates) so this series lands, with per-branch\nlinearization as the real fix later.\n"},{"id":"548516","messageId":"xmqq1pd17jgi.fsf@gitster.g","threadId":"65771","inReplyTo":"CABPp-BGdK8v8Qk5XB=QL_yJDPTNjSb2rN08GiPpK50V2gAj1QQ@mail.gmail.com","subject":"Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-17T14:57:01Z","receivedAt":"2026-07-17T14:57:06Z","isPatch":true,"body":"Elijah Newren <newren@gmail.com> writes:\n\n> On Wed, Jul 15, 2026 at 11:49 AM Junio C Hamano <gitster@pobox.com> wrote:\n>>\n>> Elijah Newren <newren@gmail.com> writes:\n>>\n>> But if that is not the outcome they wanted, I fail to see why they\n>> would feed all three branches to a single invocation of --linearize\n>> in the first place.  After all, the command is only doing what it\n>> was asked to do.\n>\n> Passing several branches isn't the user asking for concatenation; it's\n> the user asking for replay's core feature: update many branches at\n> once. Adding --linearize  to flatten a merge does have to join the\n> lines which that merge combined, but it shouldn't also weld together\n> branches that were never merged in the first place.  The user is\n> combining two intended features, and the concatenation is an emergent\n> third behavior that neither of them implies.\n\nI am not yet convinced by the above.\n\n * The fact that the user ran 'git replay' indicates that they\n   want the command's core feature of updating multiple\n   branches.\n\n * The fact that the user specified '--linearize' indicates that\n   they want a linear history, regardless of the number of positive\n   branch tips they gave.\n\nSo from that point of view, I still think it reasonable to expect\nsuch a history to be linearized.\n\nIn any case, I am not the primary audience for this new feature,\nand I have no desire to dictate the design one way or the other.  \nLet us hear what the topic author has to say.\n\nI will mark the topic as \"On hold, waiting for response\".\n\nThanks.\n"},{"id":"549084","messageId":"87bjbs4m43.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"CABPp-BGdK8v8Qk5XB=QL_yJDPTNjSb2rN08GiPpK50V2gAj1QQ@mail.gmail.com","subject":"Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-27T13:07:24Z","receivedAt":"2026-07-27T13:07:43Z","isPatch":true,"body":"Elijah Newren <newren@gmail.com> writes:\n\n> As I proposed last time, I'd be fine with erroring on multiple\n> positive refs as an interim step (plus associated documentation and\n> commit message updates) so this series lands, with per-branch\n> linearization as the real fix later.\n\nI appreciate you're open to this interim step, but I would like to\nunderstand the end goal better before we continue.\n\n> TL;DR version; my problems with the current implementation of\n> `--linearize` are that it:\n>   * Makes the rare usecase easy, and ignores the common usecase\n\nCannot deny that, although I'm not sure git-replay(1) is a popular\nend-user command.\n\n>   * Makes it asymmetrically difficult to recover for those that wanted\n> the common usecase instead of the easy\n\nI don't think many users would use --linearize anyway. I'm guessing\nproperly replaying merges would be far more useful to most people.\nI'm adding it mostly to scratch my own itch: do a server-side\nnon-interactive rebase that's identical git-rebase(1)'s\n--no-rebase-merges.\n\n>   * Makes `--linearize` mean something other than \"remove non-linearity\"\n\nIt's debatable what it means, because you can think of it linearizing\nall reachable commits (see also below what you said context of\ngitrevisions(7)).\n\n>   * Turns multiple branches into one, but updates several branches anyway\n>   * Ignores order specified by the user on the command line\n>   * Introduces an inconsistency within git-replay between `--advance`\n> and `--linearize --onto`\n> (The last three items being minor compared to the first three.)\n\nI'm surprised you consider these three more minor, because I have more\nissues with them personally (the ordering in particular).\n\nI don't have a feasible example, but as I understand from your\nargumentation, v7 might make commits reachable from a branch where they\nweren't before:\n\n> M1  M2  M3  M4  M5\n> *---*---*---*---* <- master\n>                 |\n>                 |  A1  A2  A3  A4\n>                 |--*---*---*---* <- branchA\n>                 |      \\\n>                 |       -*---* <- branchC\n>                 |        C1  C2\n>                 |\n>                 \\-*---*---* <- branchB\n>                   B1  B2  B3\n> \n> With the current implementation of --linearize, adding that flag, i.e.\n>     git replay --linearize --onto master branchA branchB branchC\n> would instead give something like:\n> \n> M1  M2  M3  M4  M5  B1  B2  B3  A1  A2  C1  C2  A3  A4\n> *---*---*---*---*---*---*---*---*---*---*---*---*---*\n>                 ^           ^               ^       ^\n>                 |           |               |       |\n>               master     branchB         branchC  branchA\n\nBefore the replay, branchC didn't reach any commits in branchB, while it\ndoes now. It kind of makes sense though, because branchC is specified\nafter branchB. But then again, why does now branchA contain branchB and\nbranchC? That's the problem I have with the ordering.\n\n> I think I know what you mean, but this isn't quite right:\n> git-replay(1) only ever accepts a single revision range.  From\n> gitrevisions(7) (also in git-rev-parse(1)):\n> \n>        Commands that are specifically designed to take two distinct ranges\n>        (e.g. \"git range-diff R1 R2\" to compare two ranges) do exist, but they\n>        are exceptions. Unless otherwise noted, all \"git\" commands that operate\n>        on a set of commits work on a single revision range. In other words,\n>        writing two \"two-dot range notation\" next to each other, e.g.\n> \n>            $ git log A..B C..D\n> \n>        does not specify two revision ranges for most commands. Instead it will\n>        name a single connected set of commits, i.e. those that are reachable\n>        from either B or D but are reachable from neither A or C.\n\nYou could think v7's implementation of --linearize converts the\n\"distinct ranges\" into a \"single connected set of commits\", but then the\noption name isn't very good.\n\n> The reason I am comfortable with erroring out as a stopgap: turning an\n> error into working behavior later never breaks anyone, whereas letting the\n> current concatenation semantics reach 'master' risks users coming to\n> depend on them, which would make switching to the better behavior a\n> compatibility break.\n\nI absolutely agree with that approach.\n\n> Erroring now keeps our options open; merging as-is\n> quietly closes them.  (git-replay is still EXPERIMENTAL, so this is not\n> fatal either way, but it seems better not to paint ourselves into a\n> corner.)\n\nBeing EXPERIMENTAL allows us to break things if we discover we didn't\nthink about before, that's not the case here.\n\nBut then again, what do we do about --contained?\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- master\n     \\\n      \\  A1  A2  A3  A4  A5  A6\n       \\-*---*---*---*---*---* <- branchA\n          \\   \\     /   /\n           \\   *---*   /  <- branchB\n            \\  B1  B2 /\n             \\---*---/  <- branchC\n                 C1\n\nThis would end up into something like:\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- master\n                |\n                |   A1  A2  A3  B1  B2  C1 A6\n                \\---*---*---*---*---*---*---* <- branchA\n                           branchB -^   ^- branchC\n\nSame issue, branchC suddenly contains the commits of branchB.\n\nThe only way we can linearize (as in flatten merges) these branches is\nby replaying some commits twice:\n\nM1  M2  M3  M4  M5\n*---*---*---*---* <- master\n                |\n                |   A1  A2  A3  B1  B2  C1 A6\n                \\---*---*---*---*---*---*---* <- branchA\n                     \\   \\\n                      \\   \\---*---*           <- branchB\n                       \\     B1'  B2'\n                        \\---*                 <- branchC\n                            C1'\n\nBut is that what the user wants? They could achieve that with running\ngit-replay(1) once for every single branch separately (let's assume they\nset COMMITTER_DATE).\nIs this the end goal we want for --linearize with multiple revision\nranges? I don't think that's doable with the last_commit per branch.\n\nBut for now, I would say --contained is not allowed with --linearize as\nwell.\n\nAnd maybe, maybe we should make --ref required when --linearize is\ngiven. Then the user would do something like:\n\n    $ git replay --onto master branchA branchB branchC --ref branchA\n\nThis makes the end result unambiguous: take all commits reachable from\nthese 3 branches, replay them linearly onto 'master' and *only* update\nref 'branchA'.\n\n\n-- \nCheers,\nToon\n"},{"id":"549143","messageId":"20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com","threadId":"65771","inReplyTo":"20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com","subject":"[PATCH v8 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-28T15:45:50Z","receivedAt":"2026-07-28T15:46:16Z","isPatch":true,"body":"As an alternative to dscho's patch series to replay merges[1], add\nan option to git-replay(1) to linearize merges. This mimics what\ngit-rebase(1) does with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. The original patch was kindly provided by Dscho,\nwhich I've tweaked to be upstreamed.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion it doesn't make much sense to do\nso, but I'm happy to hear if anyone disagrees.\n\nDscho's series to replay merges[1] needs a bit of rework to fit on top\nof this, but I'm happy to help figuring that out. We've been discussing\nto either name the option --flatten or --linearize, but I've decided on\n\"linearize\" because the documentation of git-rebase(1) also mentions\n\"linearize\".\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n\n---\nChanges in v8:\n- Disallow multiple revision ranges with --linearize.\n- Disallow --contained with --linearize.\n- Link to v7: https://patch.msgid.link/20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com\n\nChanges in v7:\n- Allow --revert and --linearize to be used together.\n- Because quite a lot of changes have been made since the original\n  patch, change author from Johannes to Toon for the last commit.\n  Johannes already told me he doesn't really care about authorship when\n  he initially shared the patch with me.\n- Link to v6: https://patch.msgid.link/20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com\n\nChanges in v6:\n- Reworked the second commit that moves picking the base completely\n  outside pick_regular_commit(), instead of adding more explanation.\n- Drastically extended the commit message on commit #3.\n- Extended docs on flattening multiple revision ranges and how it's\n  different from git-rebase(1)'s --no-rebase-merges.\n- Added a bunch of tests to cover various scenarios.\n- Remove newline from BUG() message.\n- Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com\n\nChanges in v5:\n- Dropped the enum->bool patch and instead added a patch that better\n  explains how pick_regular_commit() picks a base.\n- Order of commits is shuffled.\n- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool\n  patch, I extended the code comments to explain how things work. This\n  made me realize the use of the \"replayed_base\" was incorrect when\n  multiple branches are rebased with --onto. This is fixed now and a\n  test is added for this scenario.\n- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com\n\nChanges in v4:\n- Use test_grep instead of a bare grep in the range-diff test, to\n  prepare for mm/test-grep-lint.\n- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com\n\nChanges in v3:\n- Add --linearize to Documentation SYNOPSIS, and mention it's\n  incompatible with --revert.\n- Small language change in help message for --linearize.\n- Rephrase comment to include last_commit isn't modified when\n  linearizing merges.\n- Remove test that was added in earlier versions, but actually is\n  a duplicate of 'replaying merge commits is not supported yet'.\n- Add test to verify --revert and --linearize are incompatible.\n- Properly test that replaying down to root with --linearize works.\n- Add test for --linearize with --advance.\n- Add test that uses git-range-diff(1) to verify the patches created by\n  --linearize are correct.\n- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com\n\nChanges in v2:\n- Restructured the conditions to detect merge commits and added a line\n  of comment why the loop continues.\n- Rewrote tests to use the history from the setup step and added a few\n  test cases.\n- Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n  patches with this trailer, and if I understand correctly, I can keep\n  it. Please let me know if that wrong.\n- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n\n---\nToon Claes (3):\n      replay: add helper to put entry into replayed_commits\n      replay: resolve the replay base outside pick_regular_commit()\n      replay: offer an option to linearize the commit topology\n\n Documentation/git-replay.adoc |  19 +++++++-\n builtin/replay.c              |   6 ++-\n replay.c                      |  87 +++++++++++++++++++++++----------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 198 insertions(+), 28 deletions(-)\n\nRange-diff versus v7:\n\n1:  4faca15008 = 1:  0dc78a2a4b replay: add helper to put entry into replayed_commits\n2:  a32f28e1ec = 2:  7522f5940f replay: resolve the replay base outside pick_regular_commit()\n3:  2a689c90eb ! 3:  6f27442866 replay: offer an option to linearize the commit topology\n    @@ Commit message\n         Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n         independently and updates both refs.\n     \n    -    With `--linearize` the whole set is flattened into one line: the ranges\n    -    are stacked on top of each other rather than replayed side by side, so\n    -    both refs end up pointing at different points along that single history.\n    +    For now this is disallowed with option `--linearize`. Linearizing more\n    +    than one branch at once would concatenate unrelated histories into a\n    +    single line, and update each branch to some point in that line. That\n    +    won't be the result most users want, especially because the order\n    +    depends on the order of the revision walk, not the order of the branch\n    +    names on the command line.\n     \n    -    Replaying all revision ranges into one single linear history is\n    -    intentional and it's the only way to ensure predictable results. A user\n    -    who wants to linearize ranges independently is advised to use separate\n    -    git-replay(1) invocations.\n    +    For the same reason disallow the use of `--contained` with\n    +    `--linearize`.\n     \n    -    Linearizing is a distinct operation, and flattening merge commits is\n    -    just one aspect of that. Recreating merges would be a separate mode, so\n    -    rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,\n    -    git-replay(1) uses its own `--linearize` option.\n    +    Users who want to linearize multiple branches are advised to do this in\n    +    separate git-replay(1) invocations. Linearizing multiple branches at\n    +    once might be added later.\n    +\n    +    Note that `--linearize` is not modeled after git-rebase(1)'s\n    +    `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving\n    +    their topology, is a distinct operation that would be a separate mode.\n    +    `--linearize` only drops merges and replays commits linearly. So\n    +    git-replay(1) uses its own option rather than reusing that interface.\n     \n         Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n         Signed-off-by: Toon Claes <toon@iotcl.com>\n    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif\n     +the merge commit itself is dropped. A ref that pointed to a merge commit\n     +is updated to the merge's last replayed ancestor.\n     ++\n    -+This flattens the `<revision-range>` as a whole. When multiple revision\n    -+ranges are given they are stacked on top of each other into one linear\n    -+history. Each of their refs is updated to point to its position in that\n    -+history. To linearize ranges separately, replay them in separate `git\n    ++Only a single branch can be linearized at a time: `--linearize` cannot\n    ++be combined with multiple positive revisions or with `--contained`,\n    ++because that would concatenate otherwise unrelated histories into one\n    ++line. To linearize several branches, replay them in separate `git\n     +replay` invocations.\n     +\n      <revision-range>::\n    @@ builtin/replay.c: int cmd_replay(int argc,\n      \t\tOPT_END()\n      \t};\n      \n    +@@ builtin/replay.c: int cmd_replay(int argc,\n    + \t\t\t\t  opts.contained, \"--contained\");\n    + \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n    + \t\t\t\t  !!opts.contained, \"--contained\");\n    ++\tdie_for_incompatible_opt2(opts.linearize, \"--linearize\",\n    ++\t\t\t\t  !!opts.contained, \"--contained\");\n    + \n    + \t/* Parse ref action mode from command line or config */\n    + \tref_mode = get_ref_action_mode(repo, ref_action);\n     \n      ## replay.c ##\n    +@@ replay.c: int replay_revisions(struct rev_info *revs,\n    + \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n    + \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n    + \n    ++\tif (opts->linearize &&\n    ++\t    update_refs && strset_get_size(update_refs) > 1) {\n    ++\t\tret = error(_(\"'--linearize' cannot be used with multiple revision ranges\"));\n    ++\t\tgoto out;\n    ++\t}\n    ++\n    + \tif (opts->ref) {\n    + \t\tstruct object_id oid;\n    + \n     @@ replay.c: int replay_revisions(struct rev_info *revs,\n      \twhile ((commit = get_revision(revs))) {\n      \t\tconst struct name_decoration *decoration;\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +\ttest_line_count = 3 out\n     +'\n     +\n    -+test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' '\n    -+\tgit replay --ref-action=print --linearize \\\n    -+\t\t--onto main ^B topic2 topic3 topic4 >result &&\n    -+\n    -+\ttest_line_count = 3 result &&\n    -+\tcut -f 3 -d \" \" result >new-branch-tips &&\n    -+\n    -+\t>expect &&\n    -+\tfor i in 2 3 4\n    -+\tdo\n    -+\t\tprintf \"update refs/heads/topic$i \" >>expect &&\n    -+\t\tprintf \"%s \" $(grep topic$i result | cut -f 3 -d \" \") >>expect &&\n    -+\t\tgit rev-parse topic$i >>expect || return 1\n    -+\tdone &&\n    -+\n    -+\ttest_cmp expect result &&\n    -+\n    -+\ttest_write_lines           E D C M L B A >expect2 &&\n    -+\ttest_write_lines     H G F E D C M L B A >expect3 &&\n    -+\ttest_write_lines J I H G F E D C M L B A >expect4 &&\n    -+\n    -+\tfor i in 2 3 4\n    -+\tdo\n    -+\t\tgit log --format=%s $(grep topic$i result | cut -f 3 -d \" \") >actual &&\n    -+\t\ttest_cmp expect$i actual || return 1\n    -+\tdone\n    ++test_expect_success '--linearize rejects multiple revision ranges' '\n    ++\ttest_must_fail git replay --ref-action=print --linearize \\\n    ++\t\t--onto main ^B topic2 topic3 topic4 2>err &&\n    ++\ttest_grep \"cannot be used with multiple revision ranges\" err\n     +'\n     +\n     +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +\ttest_cmp expect actual\n     +'\n     +\n    -+test_expect_success '--linearize with --contained updates contained refs' '\n    -+\tgit replay --ref-action=print --linearize --contained \\\n    -+\t\t--onto main ^B topic-with-merge >result &&\n    -+\n    -+\ttest_line_count = 2 result &&\n    -+\n    -+\tgit log --format=%s $(head -n 1 result | cut -f 3 -d \" \") >actual &&\n    -+\ttest_write_lines J I M L B A >expect &&\n    -+\ttest_cmp expect actual &&\n    -+\n    -+\tgit log --format=%s $(tail -n 1 result | cut -f 3 -d \" \") >actual &&\n    -+\ttest_write_lines O N J I M L B A >expect &&\n    -+\ttest_cmp expect actual\n    ++test_expect_success '--linearize and --contained cannot be used together' '\n    ++\ttest_must_fail git replay --ref-action=print --linearize --contained \\\n    ++\t\t--onto main ^B topic-with-merge 2>err &&\n    ++\ttest_grep \"cannot be used together\" err\n     +'\n     +\n     +test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '\n\n\n---\nbase-commit: 13c7afec212fc97ce257d15601659314c6673d6c\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"549144","messageId":"20260728-toon-git-replay-drop-merges-v8-1-ced11dffe749@iotcl.com","threadId":"65771","inReplyTo":"20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com","subject":"[PATCH v8 1/3] replay: add helper to put entry into replayed_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-28T15:45:51Z","receivedAt":"2026-07-28T15:46:22Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put a commit entry into a `struct mapped_commits` into a\nhelper function put_mapped_commit().\n\nWhile at it, rename mapped_commit() to get_mapped_commit() to pair with\nthis new function.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 463c900d6c..860e194ba0 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -254,9 +254,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -267,6 +267,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -287,7 +302,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -427,8 +442,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -440,11 +453,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.55.0.424.g13c7afec21\n\n"},{"id":"549145","messageId":"20260728-toon-git-replay-drop-merges-v8-2-ced11dffe749@iotcl.com","threadId":"65771","inReplyTo":"20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com","subject":"[PATCH v8 2/3] replay: resolve the replay base outside pick_regular_commit()","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-28T15:45:52Z","receivedAt":"2026-07-28T15:46:25Z","isPatch":true,"body":"Depending on what gets passed into the function pick_regular_commit(),\nit decides the new base for the replayed commit. It first tries to find\nthe replayed results of `pickme`'s parent in the `replayed_commits` map.\nIf not found, it falls back to `onto`.\n\nWhen using git-replay(1) with --onto, the fallback is the revision\npassed in with this option, but when using --revert, the fallback is\n`last_commit`.\n\nIt's rather confusing the base is decided partly inside\npick_regular_commit() and partly by its caller.\n\nMove the base selection completely into the caller: replay_revisions().\nThis bundles all the logic of deciding on the base together. Also, this\nreduces the number of parameters of pick_regular_commit(), making its\ninterface cleaner.\n\nThis refactoring doesn't bring any behavior changes.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 34 +++++++++++++++++++++-------------\n 1 file changed, 21 insertions(+), 13 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 860e194ba0..7e35f40d37 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -284,25 +284,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n \n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n-\t\t\t\t\t  kh_oid_map_t *replayed_commits,\n-\t\t\t\t\t  struct commit *onto,\n+\t\t\t\t\t  struct commit *replayed_base,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n \t\t\t\t\t  enum replay_mode mode,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n-\tstruct commit *base, *replayed_base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n \n-\tif (pickme->parents) {\n-\t\tbase = pickme->parents->item;\n-\t\tbase_tree = repo_get_commit_tree(repo, base);\n-\t} else {\n-\t\tbase = NULL;\n+\tif (pickme->parents)\n+\t\tbase_tree = repo_get_commit_tree(repo, pickme->parents->item);\n+\telse\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n-\t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -443,12 +437,26 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n+\t\t/*\n+\t\t * Decide where to replay this commit on.\n+\t\t * If the parent commit was replayed already, the replayed result\n+\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n+\t\t * When reverting, commits are replayed in reverse order and thus\n+\t\t * its parent isn't replayed yet. Therefore revert commits are\n+\t\t * always replayed onto `last_commit`.\n+\t\t */\n+\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n+\n+\t\tif (mode == REPLAY_MODE_REVERT)\n+\t\t\tbase = last_commit;\n+\n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t  mode, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n\n-- \n2.55.0.424.g13c7afec21\n\n"},{"id":"549146","messageId":"20260728-toon-git-replay-drop-merges-v8-3-ced11dffe749@iotcl.com","threadId":"65771","inReplyTo":"20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com","subject":"[PATCH v8 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-07-28T15:45:53Z","receivedAt":"2026-07-28T15:46:29Z","isPatch":true,"body":"One of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearizes the history into a sequence of regular (single-parent)\ncommits.\n\nAdd option `--linearize` to git-replay(1) to do the same. Each replayed\ncommit is stacked on top of the previously replayed one. When a merge is\nencountered, the commits reachable from all of its sides are replayed\ninto the single line and the merge itself is dropped.\n\nIf a ref was pointing to a merge commit, that ref is updated to the\nmerge's last replayed ancestor.\n\ngit-replay(1) accepts multiple revision ranges, for example:\n\n    $ git replay --onto main topic1 topic2\n\nWithout `--linearize` this replays 'topic1' and 'topic2' onto 'main'\nindependently and updates both refs.\n\nFor now this is disallowed with option `--linearize`. Linearizing more\nthan one branch at once would concatenate unrelated histories into a\nsingle line, and update each branch to some point in that line. That\nwon't be the result most users want, especially because the order\ndepends on the order of the revision walk, not the order of the branch\nnames on the command line.\n\nFor the same reason disallow the use of `--contained` with\n`--linearize`.\n\nUsers who want to linearize multiple branches are advised to do this in\nseparate git-replay(1) invocations. Linearizing multiple branches at\nonce might be added later.\n\nNote that `--linearize` is not modeled after git-rebase(1)'s\n`--rebase-merges[=<mode>]` interface. Recreating merges, by preserving\ntheir topology, is a distinct operation that would be a separate mode.\n`--linearize` only drops merges and replays commits linearly. So\ngit-replay(1) uses its own option rather than reusing that interface.\n\nBased-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  19 +++++++-\n builtin/replay.c              |   6 ++-\n replay.c                      |  60 +++++++++++++++--------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 176 insertions(+), 23 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex a32f72aead..656a6924d9 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n --------\n [verse]\n (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n-\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n+\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, each replayed commit is stacked on top of the\n+\tpreviously replayed one, so all replayed commits are flattened into\n+\ta single linear history.\n++\n+When a merge commit is encountered, the behavior of git-rebase(1)'s\n+option `--no-rebase-merges` is imitated. All commits in the range\n+reachable from the merge commit are replayed into a linear history, and\n+the merge commit itself is dropped. A ref that pointed to a merge commit\n+is updated to the merge's last replayed ancestor.\n++\n+Only a single branch can be linearized at a time: `--linearize` cannot\n+be combined with multiple positive revisions or with `--contained`,\n+because that would concatenate otherwise unrelated histories into one\n+line. To linearize several branches, replay them in separate `git\n+replay` invocations.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..d39626a37d 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -85,7 +85,7 @@ int cmd_replay(int argc,\n \tconst char *const replay_usage[] = {\n \t\tN_(\"(EXPERIMENTAL!) git replay \"\n \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n-\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n+\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n \t\tNULL\n \t};\n \tstruct option replay_options[] = {\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n \t\tOPT_END()\n \t};\n \n@@ -132,6 +134,8 @@ int cmd_replay(int argc,\n \t\t\t\t  opts.contained, \"--contained\");\n \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n \t\t\t\t  !!opts.contained, \"--contained\");\n+\tdie_for_incompatible_opt2(opts.linearize, \"--linearize\",\n+\t\t\t\t  !!opts.contained, \"--contained\");\n \n \t/* Parse ref action mode from command line or config */\n \tref_mode = get_ref_action_mode(repo, ref_action);\ndiff --git a/replay.c b/replay.c\nindex 7e35f40d37..1e1bc7c10a 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,\n \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n \n+\tif (opts->linearize &&\n+\t    update_refs && strset_get_size(update_refs) > 1) {\n+\t\tret = error(_(\"'--linearize' cannot be used with multiple revision ranges\"));\n+\t\tgoto out;\n+\t}\n+\n \tif (opts->ref) {\n \t\tstruct object_id oid;\n \n@@ -437,26 +443,40 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\t/*\n-\t\t * Decide where to replay this commit on.\n-\t\t * If the parent commit was replayed already, the replayed result\n-\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n-\t\t * When reverting, commits are replayed in reverse order and thus\n-\t\t * its parent isn't replayed yet. Therefore revert commits are\n-\t\t * always replayed onto `last_commit`.\n-\t\t */\n-\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n-\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n-\n-\t\tif (mode == REPLAY_MODE_REVERT)\n-\t\t\tbase = last_commit;\n-\n-\t\tif (commit->parents && commit->parents->next)\n-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n-\n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n-\t\t\t\t\t\t  &merge_opt, &result,\n-\t\t\t\t\t\t  mode, opts->empty);\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (!opts->linearize)\n+\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\t\t/*\n+\t\t\t * Drop the merge commit: do not pick it, leave\n+\t\t\t * `last_commit` unchanged, and fall through to the\n+\t\t\t * rest of the loop. As a result:\n+\t\t\t * - refs pointing to the merge commit will be updated\n+\t\t\t *   to `last_commit`.\n+\t\t\t * - the next replayed commit uses `last_commit` as its\n+\t\t\t *   `base`.\n+\t\t\t */\n+\t\t} else {\n+\t\t\t/*\n+\t\t\t * Decide where to replay this commit onto.\n+\t\t\t * If the parent commit was replayed already, the replayed result\n+\t\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n+\t\t\t * When reverting, commits are replayed in reverse order and thus\n+\t\t\t * its parent isn't replayed yet. Therefore revert commits are\n+\t\t\t * always replayed onto `last_commit`.\n+\t\t\t * Also when opts->linearize is true, set the base to\n+\t\t\t * `last_commit` to create a single linear history.\n+\t\t\t */\n+\t\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\t\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n+\n+\t\t\tif (opts->linearize || mode == REPLAY_MODE_REVERT)\n+\t\t\t\tbase = last_commit;\n+\n+\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t\t  mode, opts->empty);\n+\t\t}\n+\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex 491db145e3..2c71afbfde 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..255bae5846 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -52,8 +52,19 @@ test_expect_success 'setup' '\n \ttest_merge P O --no-ff &&\n \tgit switch main &&\n \n+\tgit switch --orphan unrelated &&\n+\ttest_commit unrelated-root &&\n+\n \tgit switch -c conflict B &&\n-\ttest_commit C.conflict C.t conflict\n+\ttest_commit C.conflict C.t conflict &&\n+\tgit branch -D unrelated &&\n+\n+\tgit switch -c divergent-x main &&\n+\ttest_commit X &&\n+\tgit switch -c divergent-y main &&\n+\ttest_commit Y &&\n+\tgit switch divergent-x &&\n+\ttest_merge Z divergent-y --no-ff\n '\n \n test_expect_success 'setup bare' '\n@@ -565,4 +576,100 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'replay to rebase merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto unrelated-root topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J I B A unrelated-root >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to cherry-pick merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--advance main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tprintf \"update refs/heads/main \" >expect &&\n+\tprintf \"%s \" $(cut -f 3 -d \" \" result) >>expect &&\n+\tgit rev-parse main >>expect &&\n+\ttest_cmp expect result\n+'\n+\n+test_expect_success 'replay --linearize produces the same patches' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# range-diff does not care about the dropped merge,\n+\t# so the original commits (I..topic-with-merge)\n+\t# and the replayed chain (main..tip) must produce identical patches.\n+\tgit range-diff I..topic-with-merge main..$tip >out &&\n+\ttest_file_not_empty out &&\n+\ttest_grep ! -v \"=\" out &&\n+\n+\tgit log --oneline main..$tip >out &&\n+\ttest_line_count = 3 out\n+'\n+\n+test_expect_success '--linearize rejects multiple revision ranges' '\n+\ttest_must_fail git replay --ref-action=print --linearize \\\n+\t\t--onto main ^B topic2 topic3 topic4 2>err &&\n+\ttest_grep \"cannot be used with multiple revision ranges\" err\n+'\n+\n+test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main main..divergent-x >result &&\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# The merge Z is dropped, but both X and Y are linearized onto main;\n+\t# neither side is lost.\n+\tgit log --format=%s main..$tip >actual &&\n+\ttest_write_lines Y X >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--linearize and --contained cannot be used together' '\n+\ttest_must_fail git replay --ref-action=print --linearize --contained \\\n+\t\t--onto main ^B topic-with-merge 2>err &&\n+\ttest_grep \"cannot be used together\" err\n+'\n+\n+test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '\n+\tgit replay --ref-action=print --revert=divergent-x --linearize \\\n+\t\tmain..divergent-x >result &&\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\tgit log --format=%s $tip >actual &&\n+\ttest_write_lines \\\n+\t\t\"Revert \\\"X\\\"\" \"Revert \\\"Y\\\"\" Z Y X M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\ttest_must_fail git cat-file -e $tip:X.t &&\n+\ttest_must_fail git cat-file -e $tip:Y.t\n+'\n+\n test_done\n\n-- \n2.55.0.424.g13c7afec21\n\n"},{"id":"549161","messageId":"xmqqy0evc6n9.fsf@gitster.g","threadId":"65771","inReplyTo":"20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com","subject":"Re: [PATCH v8 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-28T18:26:34Z","receivedAt":"2026-07-28T18:26:37Z","isPatch":true,"body":"Toon Claes <toon@iotcl.com> writes:\n\n> As an alternative to dscho's patch series to replay merges[1], add\n> an option to git-replay(1) to linearize merges. This mimics what\n> git-rebase(1) does with --no-rebase-merges (the default).\n>\n> The first two patches do some refactoring. The third patch implements\n> the actual change. The original patch was kindly provided by Dscho,\n> which I've tweaked to be upstreamed.\n>\n> The --linearize option is only added to git-replay(1) and not to\n> git-history(1) because in my opinion it doesn't make much sense to do\n> so, but I'm happy to hear if anyone disagrees.\n>\n> Dscho's series to replay merges[1] needs a bit of rework to fit on top\n> of this, but I'm happy to help figuring that out. We've been discussing\n> to either name the option --flatten or --linearize, but I've decided on\n> \"linearize\" because the documentation of git-rebase(1) also mentions\n> \"linearize\".\n>\n> [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n>\n> ---\n> Changes in v8:\n> - Disallow multiple revision ranges with --linearize.\n> - Disallow --contained with --linearize.\n> - Link to v7: https://patch.msgid.link/20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com\n\nThe topic has been cooking in 'next' since Jul 9th, so I'll revert\nthe merge and queue this iteration instead, making sure I do not\naccidentally merge it down to 'next' prematurely.\n\nThanks.\n"},{"id":"550037","messageId":"anYLeQj4Sx2vZqvy@denethor","threadId":"65771","inReplyTo":"20260728-toon-git-replay-drop-merges-v8-3-ced11dffe749@iotcl.com","subject":"Re: [PATCH v8 3/3] replay: offer an option to linearize the commit topology","fromName":"Justin Tobler","fromEmail":"jltobler@gmail.com","sentAt":"2026-08-07T17:34:42Z","receivedAt":"2026-08-07T17:34:47Z","isPatch":true,"body":"On 26/07/28 05:45PM, Toon Claes wrote:\n> One of the stated goals of git-replay(1) is to allow implementing the\n> git-rebase(1) functionality on the server side.\n> \n> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n> was given. This mode drops merge commits instead of replaying them, and\n> linearizes the history into a sequence of regular (single-parent)\n> commits.\n> \n> Add option `--linearize` to git-replay(1) to do the same. Each replayed\n> commit is stacked on top of the previously replayed one. When a merge is\n> encountered, the commits reachable from all of its sides are replayed\n> into the single line and the merge itself is dropped.\n> \n> If a ref was pointing to a merge commit, that ref is updated to the\n> merge's last replayed ancestor.\n\nJust to clarify, does it really matter if the ref was pointing to the\nmerge commit directly? I assume it is just \"flattening\" the merge\ncommits in the revision range.\n\n> git-replay(1) accepts multiple revision ranges, for example:\n> \n>     $ git replay --onto main topic1 topic2\n\nPer some discussion earlier in the thread, is \"accepts multiple revision\nranges\" the correct wording here? Would it be more correct to say\nmultiple branches instead?\n\n> Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n> independently and updates both refs.\n\nOk, so git-replay(1) updates each branch sepecified separately.\n\n> For now this is disallowed with option `--linearize`. Linearizing more\n> than one branch at once would concatenate unrelated histories into a\n> single line, and update each branch to some point in that line. That\n> won't be the result most users want, especially because the order\n> depends on the order of the revision walk, not the order of the branch\n> names on the command line.\n\nI'm not quite sure I follow. Why would the inclusion of the\n`--linearize` option force concatenation of multiple references? Is it\nmot possible to linearize each of the branches in isolation and update\nthe reference accordingly?\n\n> For the same reason disallow the use of `--contained` with\n> `--linearize`.\n> \n> Users who want to linearize multiple branches are advised to do this in\n> separate git-replay(1) invocations. Linearizing multiple branches at\n> once might be added later.\n\nOk.\n\n> Note that `--linearize` is not modeled after git-rebase(1)'s\n> `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving\n> their topology, is a distinct operation that would be a separate mode.\n> `--linearize` only drops merges and replays commits linearly. So\n> git-replay(1) uses its own option rather than reusing that interface.\n> \n> Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> Signed-off-by: Toon Claes <toon@iotcl.com>\n> ---\n>  Documentation/git-replay.adoc |  19 +++++++-\n>  builtin/replay.c              |   6 ++-\n>  replay.c                      |  60 +++++++++++++++--------\n>  replay.h                      |   5 ++\n>  t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-\n>  5 files changed, 176 insertions(+), 23 deletions(-)\n> \n> diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\n> index a32f72aead..656a6924d9 100644\n> --- a/Documentation/git-replay.adoc\n> +++ b/Documentation/git-replay.adoc\n> @@ -10,7 +10,7 @@ SYNOPSIS\n>  --------\n>  [verse]\n>  (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n> -\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n> +\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n>  \n>  DESCRIPTION\n>  -----------\n> @@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n>  +\n>  The default mode can be configured via the `replay.refAction` configuration variable.\n>  \n> +--linearize::\n> +\tIn this mode, each replayed commit is stacked on top of the\n> +\tpreviously replayed one, so all replayed commits are flattened into\n> +\ta single linear history.\n> ++\n> +When a merge commit is encountered, the behavior of git-rebase(1)'s\n> +option `--no-rebase-merges` is imitated. All commits in the range\n> +reachable from the merge commit are replayed into a linear history, and\n> +the merge commit itself is dropped. A ref that pointed to a merge commit\n> +is updated to the merge's last replayed ancestor.\n> ++\n> +Only a single branch can be linearized at a time: `--linearize` cannot\n> +be combined with multiple positive revisions or with `--contained`,\n> +because that would concatenate otherwise unrelated histories into one\n> +line. To linearize several branches, replay them in separate `git\n> +replay` invocations.\n\nI still don't fully understand the justification here. I'm not sure it\nreally needs to be in the documentation though. It may be fine to just\nsay \"multiple branches are not supported with this option\" or something\nalong those lines.\n\n> +\n>  <revision-range>::\n>  \tRange of commits to replay; see \"Specifying Ranges\" in\n>  \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\n> diff --git a/builtin/replay.c b/builtin/replay.c\n> index 39e3a86f6c..d39626a37d 100644\n> --- a/builtin/replay.c\n> +++ b/builtin/replay.c\n> @@ -85,7 +85,7 @@ int cmd_replay(int argc,\n>  \tconst char *const replay_usage[] = {\n>  \t\tN_(\"(EXPERIMENTAL!) git replay \"\n>  \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n> -\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n> +\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n>  \t\tNULL\n>  \t};\n>  \tstruct option replay_options[] = {\n> @@ -111,6 +111,8 @@ int cmd_replay(int argc,\n>  \t\t\t     N_(\"mode\"),\n>  \t\t\t     N_(\"control ref update behavior (update|print)\"),\n>  \t\t\t     PARSE_OPT_NONEG),\n> +\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n> +\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n>  \t\tOPT_END()\n>  \t};\n>  \n> @@ -132,6 +134,8 @@ int cmd_replay(int argc,\n>  \t\t\t\t  opts.contained, \"--contained\");\n>  \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n>  \t\t\t\t  !!opts.contained, \"--contained\");\n> +\tdie_for_incompatible_opt2(opts.linearize, \"--linearize\",\n> +\t\t\t\t  !!opts.contained, \"--contained\");\n>  \n>  \t/* Parse ref action mode from command line or config */\n>  \tref_mode = get_ref_action_mode(repo, ref_action);\n> diff --git a/replay.c b/replay.c\n> index 7e35f40d37..1e1bc7c10a 100644\n> --- a/replay.c\n> +++ b/replay.c\n> @@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,\n>  \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n>  \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n>  \n> +\tif (opts->linearize &&\n> +\t    update_refs && strset_get_size(update_refs) > 1) {\n> +\t\tret = error(_(\"'--linearize' cannot be used with multiple revision ranges\"));\n\nShould this say \"multiple branches\" instead?\n\n> +\t\tgoto out;\n> +\t}\n> +\n>  \tif (opts->ref) {\n>  \t\tstruct object_id oid;\n>  \n> @@ -437,26 +443,40 @@ int replay_revisions(struct rev_info *revs,\n>  \twhile ((commit = get_revision(revs))) {\n>  \t\tconst struct name_decoration *decoration;\n>  \n> -\t\t/*\n> -\t\t * Decide where to replay this commit on.\n> -\t\t * If the parent commit was replayed already, the replayed result\n> -\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n> -\t\t * When reverting, commits are replayed in reverse order and thus\n> -\t\t * its parent isn't replayed yet. Therefore revert commits are\n> -\t\t * always replayed onto `last_commit`.\n> -\t\t */\n> -\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n> -\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n> -\n> -\t\tif (mode == REPLAY_MODE_REVERT)\n> -\t\t\tbase = last_commit;\n> -\n> -\t\tif (commit->parents && commit->parents->next)\n> -\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n> -\n> -\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n> -\t\t\t\t\t\t  &merge_opt, &result,\n> -\t\t\t\t\t\t  mode, opts->empty);\n> +\t\tif (commit->parents && commit->parents->next) {\n> +\t\t\tif (!opts->linearize)\n> +\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n> +\t\t\t/*\n> +\t\t\t * Drop the merge commit: do not pick it, leave\n> +\t\t\t * `last_commit` unchanged, and fall through to the\n> +\t\t\t * rest of the loop. As a result:\n> +\t\t\t * - refs pointing to the merge commit will be updated\n> +\t\t\t *   to `last_commit`.\n> +\t\t\t * - the next replayed commit uses `last_commit` as its\n> +\t\t\t *   `base`.\n> +\t\t\t */\n\nOk, when the linearize option is provided, we now drop the merge commit\nand continue on.\n\n> +\t\t} else {\n> +\t\t\t/*\n> +\t\t\t * Decide where to replay this commit onto.\n> +\t\t\t * If the parent commit was replayed already, the replayed result\n> +\t\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n> +\t\t\t * When reverting, commits are replayed in reverse order and thus\n> +\t\t\t * its parent isn't replayed yet. Therefore revert commits are\n> +\t\t\t * always replayed onto `last_commit`.\n> +\t\t\t * Also when opts->linearize is true, set the base to\n> +\t\t\t * `last_commit` to create a single linear history.\n> +\t\t\t */\n> +\t\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n> +\t\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n> +\n> +\t\t\tif (opts->linearize || mode == REPLAY_MODE_REVERT)\n> +\t\t\t\tbase = last_commit;\n\nOk IIUC, when we are linearizing commits we are just replaying them onto\nthe most recently replayed commit. Makes sense.\n\n-Justin\n"},{"id":"550091","messageId":"CABPp-BEFGku8msiJCcXburV+tcersr6uqEumKaPh-TguA1LjSg@mail.gmail.com","threadId":"65771","inReplyTo":"anYLeQj4Sx2vZqvy@denethor","subject":"Re: [PATCH v8 3/3] replay: offer an option to linearize the commit topology","fromName":"Elijah Newren","fromEmail":"newren@gmail.com","sentAt":"2026-08-08T09:17:58Z","receivedAt":"2026-08-08T09:18:11Z","isPatch":true,"body":"Adding my comments in response to Justin's, since I think he\nhighlights some good points.\n\nFirst of all, Toon, thanks for making --linearize and multiple\nbranches incompatible to avoid concatenating histories.  I really\nappreciate it.\n\nOn Fri, Aug 7, 2026 at 10:34 AM Justin Tobler <jltobler@gmail.com> wrote:\n>\n> On 26/07/28 05:45PM, Toon Claes wrote:\n> > One of the stated goals of git-replay(1) is to allow implementing the\n> > git-rebase(1) functionality on the server side.\n> >\n> > The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n> > was given. This mode drops merge commits instead of replaying them, and\n> > linearizes the history into a sequence of regular (single-parent)\n> > commits.\n> >\n> > Add option `--linearize` to git-replay(1) to do the same. Each replayed\n> > commit is stacked on top of the previously replayed one. When a merge is\n> > encountered, the commits reachable from all of its sides are replayed\n> > into the single line and the merge itself is dropped.\n> >\n> > If a ref was pointing to a merge commit, that ref is updated to the\n> > merge's last replayed ancestor.\n>\n> Just to clarify, does it really matter if the ref was pointing to the\n> merge commit directly? I assume it is just \"flattening\" the merge\n> commits in the revision range.\n\nToon's clarification is important, though depending on your mental\nmodel it might _appear_ to be an unnecessary clarification.  I think\nthere are two mental models:\n  - Each commit of the branch is replayed and the ref is updated as it\ngoes.  (This matches underlying implementation mechanics for `git\nrebase`, but not for `git replay`.)\n  - Each commit of the branch is replayed.  The ref is updated at the\nend to the corresponding replay of the final commit.  (Matches\nunderlying implementation mechanics for `git replay`.)\n\nReaders could possibly assume either mental model without knowing the\nunderlying mechanics.  Toon's clarification doesn't hurt those who\nassume the first style, but is an important clarification for those\nwho assume the second style.\n\n> > git-replay(1) accepts multiple revision ranges, for example:\n> >\n> >     $ git replay --onto main topic1 topic2\n>\n> Per some discussion earlier in the thread, is \"accepts multiple revision\n> ranges\" the correct wording here? Would it be more correct to say\n> multiple branches instead?\n\nYes, please; it would be nice to see this fixed.\n\n> > Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n> > independently and updates both refs.\n>\n> Ok, so git-replay(1) updates each branch sepecified separately.\n\nOh, that's a good callout.  The word \"independently\" and \"separately\"\nhere may well mislead users.  If branches \"topic1\" and \"topic2\" share\nsome history, claiming they are replayed \"independently\" or\n\"separately\" may cause people to assume the shared history becomes\ncopied and no longer shared.  I think the word \"independently\" should\nbe dropped.  If wanted, we could word this to something like:\n\nWithout `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n(keeping shared portions of history shared and keeping divergent parts\ndivergent), and updates both refs.\n\n> > For now this is disallowed with option `--linearize`. Linearizing more\n> > than one branch at once would concatenate unrelated histories into a\n> > single line, and update each branch to some point in that line. That\n> > won't be the result most users want, especially because the order\n> > depends on the order of the revision walk, not the order of the branch\n> > names on the command line.\n>\n> I'm not quite sure I follow. Why would the inclusion of the\n> `--linearize` option force concatenation of multiple references? Is it\n> mot possible to linearize each of the branches in isolation and update\n> the reference accordingly?\n\nMaybe:\n\nDue to current implementation limitations, replaying multiple branches\nwith `--linearize` is disallowed to avoid concatenating unrelated\nhistories into a single line...\n\n?\n\n> > For the same reason disallow the use of `--contained` with\n> > `--linearize`.\n\nGood catch and callout, Toon.\n\n> > Users who want to linearize multiple branches are advised to do this in\n> > separate git-replay(1) invocations. Linearizing multiple branches at\n> > once might be added later.\n>\n> Ok.\n>\n> > Note that `--linearize` is not modeled after git-rebase(1)'s\n> > `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving\n> > their topology, is a distinct operation that would be a separate mode.\n> > `--linearize` only drops merges and replays commits linearly. So\n> > git-replay(1) uses its own option rather than reusing that interface.\n> >\n> > Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> > Signed-off-by: Toon Claes <toon@iotcl.com>\n> > ---\n> >  Documentation/git-replay.adoc |  19 +++++++-\n> >  builtin/replay.c              |   6 ++-\n> >  replay.c                      |  60 +++++++++++++++--------\n> >  replay.h                      |   5 ++\n> >  t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-\n> >  5 files changed, 176 insertions(+), 23 deletions(-)\n> >\n> > diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\n> > index a32f72aead..656a6924d9 100644\n> > --- a/Documentation/git-replay.adoc\n> > +++ b/Documentation/git-replay.adoc\n> > @@ -10,7 +10,7 @@ SYNOPSIS\n> >  --------\n> >  [verse]\n> >  (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n> > -                          [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n> > +                          [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n> >\n> >  DESCRIPTION\n> >  -----------\n> > @@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n> >  +\n> >  The default mode can be configured via the `replay.refAction` configuration variable.\n> >\n> > +--linearize::\n> > +     In this mode, each replayed commit is stacked on top of the\n> > +     previously replayed one, so all replayed commits are flattened into\n> > +     a single linear history.\n> > ++\n> > +When a merge commit is encountered, the behavior of git-rebase(1)'s\n> > +option `--no-rebase-merges` is imitated. All commits in the range\n\nI dislike pointing new users to read a big chunk of another manual\npage to understand an option.  I have a personal gripe against the\nmanual for `git merge-base`, in particular, which feels like in order\nto understand various flags you have to understand what at first looks\nlike an unrelated command and multiple of its options first.  The rest\nof your paragraph is a good self-standing description; can you just\nmove your first sentence to the end of the paragraph and make it a\nparenthetical pointing out the similarity of the two options of the\ntwo commands?\n\n> > +reachable from the merge commit are replayed into a linear history, and\n> > +the merge commit itself is dropped. A ref that pointed to a merge commit\n> > +is updated to the merge's last replayed ancestor.\n> > ++\n> > +Only a single branch can be linearized at a time: `--linearize` cannot\n> > +be combined with multiple positive revisions or with `--contained`,\n> > +because that would concatenate otherwise unrelated histories into one\n> > +line. To linearize several branches, replay them in separate `git\n> > +replay` invocations.\n>\n> I still don't fully understand the justification here. I'm not sure it\n> really needs to be in the documentation though. It may be fine to just\n> say \"multiple branches are not supported with this option\" or something\n> along those lines.\n\nIt does feel like this unnecessarily explains implementation\nshortcomings, and further tries to list them as fundamental\nlimitations.  (If each commit were tagged with all branches it was\nreachable from, then as the replay walked over the commits and\nreplayed each, it could simply track the last commit for each branch\nrather than an overall last commit, and at the end update each branch\nto its corresponding last seen commit.  That would allow us to lift\nthe limitation.)  So, I agree with Justin's suggestion here to just\nmore simply state that the combination isn't supported.\n\n> > +\n> >  <revision-range>::\n> >       Range of commits to replay; see \"Specifying Ranges\" in\n> >       linkgit:git-rev-parse[1]. In `--advance=<branch>` or\n> > diff --git a/builtin/replay.c b/builtin/replay.c\n> > index 39e3a86f6c..d39626a37d 100644\n> > --- a/builtin/replay.c\n> > +++ b/builtin/replay.c\n> > @@ -85,7 +85,7 @@ int cmd_replay(int argc,\n> >       const char *const replay_usage[] = {\n> >               N_(\"(EXPERIMENTAL!) git replay \"\n> >                  \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n> > -                \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n> > +                \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n> >               NULL\n> >       };\n> >       struct option replay_options[] = {\n> > @@ -111,6 +111,8 @@ int cmd_replay(int argc,\n> >                            N_(\"mode\"),\n> >                            N_(\"control ref update behavior (update|print)\"),\n> >                            PARSE_OPT_NONEG),\n> > +             OPT_BOOL(0, \"linearize\", &opts.linearize,\n> > +                      N_(\"drop merge commits, replaying only non-merge commits\")),\n> >               OPT_END()\n> >       };\n> >\n> > @@ -132,6 +134,8 @@ int cmd_replay(int argc,\n> >                                 opts.contained, \"--contained\");\n> >       die_for_incompatible_opt2(!!opts.ref, \"--ref\",\n> >                                 !!opts.contained, \"--contained\");\n> > +     die_for_incompatible_opt2(opts.linearize, \"--linearize\",\n> > +                               !!opts.contained, \"--contained\");\n> >\n> >       /* Parse ref action mode from command line or config */\n> >       ref_mode = get_ref_action_mode(repo, ref_action);\n> > diff --git a/replay.c b/replay.c\n> > index 7e35f40d37..1e1bc7c10a 100644\n> > --- a/replay.c\n> > +++ b/replay.c\n> > @@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,\n> >       set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n> >                          &detached_head, &advance, &revert, &onto, &update_refs);\n> >\n> > +     if (opts->linearize &&\n> > +         update_refs && strset_get_size(update_refs) > 1) {\n> > +             ret = error(_(\"'--linearize' cannot be used with multiple revision ranges\"));\n>\n> Should this say \"multiple branches\" instead?\n\nYes, please.\n\nAlso, should we replace opts->linearize with (opts->linearize || mode\n== REPLAY_MODE_REVERT) ?  The reason being this line of code below:\n\n> > +                     if (opts->linearize || mode == REPLAY_MODE_REVERT)\n> > +                             base = last_commit;\n\nTrying to revert with multiple branches will (a) concatenate the\nreverts into a single branch (which probably *is* what is wanted) and\n(b) do so in revision walking order instead of the order of branches\nspecified by the user on the command line.  That ordering could have\nbisection or conflict ramifications that may surprise the user.  Also,\ncherry-pick disallows multiple branches (even though concatenation\nwould be wanted there too) because of this\nignore-user's-command-line-order issue.\n"},{"id":"551548","messageId":"87zey2wijh.fsf@emacs.iotcl.com","threadId":"65771","inReplyTo":"CABPp-BEFGku8msiJCcXburV+tcersr6uqEumKaPh-TguA1LjSg@mail.gmail.com","subject":"Re: [PATCH v8 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-08-31T13:01:22Z","receivedAt":"2026-08-31T13:01:44Z","isPatch":true,"body":"Elijah Newren <newren@gmail.com> writes:\n\n> Adding my comments in response to Justin's, since I think he\n> highlights some good points.\n>\n> First of all, Toon, thanks for making --linearize and multiple\n> branches incompatible to avoid concatenating histories.  I really\n> appreciate it.\n\nTo be honest, I would rather not to. But for now I think it's the best\nwe can do. We could dive deeper into the \"last_commit per command-line\"\nsuggestion you've made, but I think it would be more complex to\nimplement it correctly than you made it appear to be. So let's leave\nthat out now. And perhaps we can share this when we implement real\nreplaying of commits at some point. So I'm leaving that out for now\n\n> On Fri, Aug 7, 2026 at 10:34 AM Justin Tobler <jltobler@gmail.com> wrote:\n>>\n>> On 26/07/28 05:45PM, Toon Claes wrote:\n>> > One of the stated goals of git-replay(1) is to allow implementing the\n>> > git-rebase(1) functionality on the server side.\n>> >\n>> > The default mode of git-rebase(1) is to act as if `--no-rebase-merges`\n>> > was given. This mode drops merge commits instead of replaying them, and\n>> > linearizes the history into a sequence of regular (single-parent)\n>> > commits.\n>> >\n>> > Add option `--linearize` to git-replay(1) to do the same. Each replayed\n>> > commit is stacked on top of the previously replayed one. When a merge is\n>> > encountered, the commits reachable from all of its sides are replayed\n>> > into the single line and the merge itself is dropped.\n>> >\n>> > If a ref was pointing to a merge commit, that ref is updated to the\n>> > merge's last replayed ancestor.\n>>\n>> Just to clarify, does it really matter if the ref was pointing to the\n>> merge commit directly? I assume it is just \"flattening\" the merge\n>> commits in the revision range.\n>\n> Toon's clarification is important, though depending on your mental\n> model it might _appear_ to be an unnecessary clarification.  I think\n> there are two mental models:\n>   - Each commit of the branch is replayed and the ref is updated as it\n> goes.  (This matches underlying implementation mechanics for `git\n> rebase`, but not for `git replay`.)\n>   - Each commit of the branch is replayed.  The ref is updated at the\n> end to the corresponding replay of the final commit.  (Matches\n> underlying implementation mechanics for `git replay`.)\n>\n> Readers could possibly assume either mental model without knowing the\n> underlying mechanics.  Toon's clarification doesn't hurt those who\n> assume the first style, but is an important clarification for those\n> who assume the second style.\n\n@Justin, what I intended to explain is the following scenario:\n\n   A---B---C\n        \\\n         D---E---F---G\n              \\     /\n               X---Y\n\nIf `my-branch` points to G, replaying that branch onto C ends up into:\n\n   A---B---C---D'---E'---F'---X'---Y'  (order not guaranteed)\n\n`my-branch` will now point to Y', because G is dropped the ref is\n\"moved\".\n\n>> > git-replay(1) accepts multiple revision ranges, for example:\n>> >\n>> >     $ git replay --onto main topic1 topic2\n>>\n>> Per some discussion earlier in the thread, is \"accepts multiple revision\n>> ranges\" the correct wording here? Would it be more correct to say\n>> multiple branches instead?\n>\n> Yes, please; it would be nice to see this fixed.\n\nWill do.\n\n>> > Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n>> > independently and updates both refs.\n>>\n>> Ok, so git-replay(1) updates each branch sepecified separately.\n>\n> Oh, that's a good callout.  The word \"independently\" and \"separately\"\n> here may well mislead users.  If branches \"topic1\" and \"topic2\" share\n> some history, claiming they are replayed \"independently\" or\n> \"separately\" may cause people to assume the shared history becomes\n> copied and no longer shared.  I think the word \"independently\" should\n> be dropped.  If wanted, we could word this to something like:\n>\n> Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n> (keeping shared portions of history shared and keeping divergent parts\n> divergent), and updates both refs.\n\nOkay, let's take this.\n\n>> > For now this is disallowed with option `--linearize`. Linearizing more\n>> > than one branch at once would concatenate unrelated histories into a\n>> > single line, and update each branch to some point in that line. That\n>> > won't be the result most users want, especially because the order\n>> > depends on the order of the revision walk, not the order of the branch\n>> > names on the command line.\n>>\n>> I'm not quite sure I follow. Why would the inclusion of the\n>> `--linearize` option force concatenation of multiple references? Is it\n>> mot possible to linearize each of the branches in isolation and update\n>> the reference accordingly?\n>\n> Maybe:\n>\n> Due to current implementation limitations, replaying multiple branches\n> with `--linearize` is disallowed to avoid concatenating unrelated\n> histories into a single line...\n\nWorks for me.\n\n@Justin there's a whole history of mails preceding this issue, but to\ngive you the short summary: I think in v5 I've made a change because\nJunio and Philip noticed a bug where only one side would get replayed.\nBut the \"fix\" I made caused every branch to be replayed together in one\nsignle long history. This is probably unwanted behavior to most users,\nso Elijah suggested to disallow multiple branches with --linearize, at\nleast for now.\n\n>> > For the same reason disallow the use of `--contained` with\n>> > `--linearize`.\n>\n> Good catch and callout, Toon.\n\n<3\n\n>> > Users who want to linearize multiple branches are advised to do this in\n>> > separate git-replay(1) invocations. Linearizing multiple branches at\n>> > once might be added later.\n>>\n>> Ok.\n>>\n>> > Note that `--linearize` is not modeled after git-rebase(1)'s\n>> > `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving\n>> > their topology, is a distinct operation that would be a separate mode.\n>> > `--linearize` only drops merges and replays commits linearly. So\n>> > git-replay(1) uses its own option rather than reusing that interface.\n>> >\n>> > Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n>> > Signed-off-by: Toon Claes <toon@iotcl.com>\n>> > ---\n>> >  Documentation/git-replay.adoc |  19 +++++++-\n>> >  builtin/replay.c              |   6 ++-\n>> >  replay.c                      |  60 +++++++++++++++--------\n>> >  replay.h                      |   5 ++\n>> >  t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-\n>> >  5 files changed, 176 insertions(+), 23 deletions(-)\n>> >\n>> > diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\n>> > index a32f72aead..656a6924d9 100644\n>> > --- a/Documentation/git-replay.adoc\n>> > +++ b/Documentation/git-replay.adoc\n>> > @@ -10,7 +10,7 @@ SYNOPSIS\n>> >  --------\n>> >  [verse]\n>> >  (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n>> > -                          [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n>> > +                          [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n>> >\n>> >  DESCRIPTION\n>> >  -----------\n>> > @@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).\n>> >  +\n>> >  The default mode can be configured via the `replay.refAction` configuration variable.\n>> >\n>> > +--linearize::\n>> > +     In this mode, each replayed commit is stacked on top of the\n>> > +     previously replayed one, so all replayed commits are flattened into\n>> > +     a single linear history.\n>> > ++\n>> > +When a merge commit is encountered, the behavior of git-rebase(1)'s\n>> > +option `--no-rebase-merges` is imitated. All commits in the range\n>\n> I dislike pointing new users to read a big chunk of another manual\n> page to understand an option.  I have a personal gripe against the\n> manual for `git merge-base`, in particular, which feels like in order\n> to understand various flags you have to understand what at first looks\n> like an unrelated command and multiple of its options first.  The rest\n> of your paragraph is a good self-standing description; can you just\n> move your first sentence to the end of the paragraph and make it a\n> parenthetical pointing out the similarity of the two options of the\n> two commands?\n\nI'll rephrase it.\n\n>> > +reachable from the merge commit are replayed into a linear history, and\n>> > +the merge commit itself is dropped. A ref that pointed to a merge commit\n>> > +is updated to the merge's last replayed ancestor.\n>> > ++\n>> > +Only a single branch can be linearized at a time: `--linearize` cannot\n>> > +be combined with multiple positive revisions or with `--contained`,\n>> > +because that would concatenate otherwise unrelated histories into one\n>> > +line. To linearize several branches, replay them in separate `git\n>> > +replay` invocations.\n>>\n>> I still don't fully understand the justification here. I'm not sure it\n>> really needs to be in the documentation though. It may be fine to just\n>> say \"multiple branches are not supported with this option\" or something\n>> along those lines.\n>\n> It does feel like this unnecessarily explains implementation\n> shortcomings, and further tries to list them as fundamental\n> limitations.  (If each commit were tagged with all branches it was\n> reachable from, then as the replay walked over the commits and\n> replayed each, it could simply track the last commit for each branch\n> rather than an overall last commit, and at the end update each branch\n> to its corresponding last seen commit.  That would allow us to lift\n> the limitation.)  So, I agree with Justin's suggestion here to just\n> more simply state that the combination isn't supported.\n\nSure.\n\n>> > +\n>> >  <revision-range>::\n>> >       Range of commits to replay; see \"Specifying Ranges\" in\n>> >       linkgit:git-rev-parse[1]. In `--advance=<branch>` or\n>> > diff --git a/builtin/replay.c b/builtin/replay.c\n>> > index 39e3a86f6c..d39626a37d 100644\n>> > --- a/builtin/replay.c\n>> > +++ b/builtin/replay.c\n>> > @@ -85,7 +85,7 @@ int cmd_replay(int argc,\n>> >       const char *const replay_usage[] = {\n>> >               N_(\"(EXPERIMENTAL!) git replay \"\n>> >                  \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n>> > -                \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n>> > +                \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n>> >               NULL\n>> >       };\n>> >       struct option replay_options[] = {\n>> > @@ -111,6 +111,8 @@ int cmd_replay(int argc,\n>> >                            N_(\"mode\"),\n>> >                            N_(\"control ref update behavior (update|print)\"),\n>> >                            PARSE_OPT_NONEG),\n>> > +             OPT_BOOL(0, \"linearize\", &opts.linearize,\n>> > +                      N_(\"drop merge commits, replaying only non-merge commits\")),\n>> >               OPT_END()\n>> >       };\n>> >\n>> > @@ -132,6 +134,8 @@ int cmd_replay(int argc,\n>> >                                 opts.contained, \"--contained\");\n>> >       die_for_incompatible_opt2(!!opts.ref, \"--ref\",\n>> >                                 !!opts.contained, \"--contained\");\n>> > +     die_for_incompatible_opt2(opts.linearize, \"--linearize\",\n>> > +                               !!opts.contained, \"--contained\");\n>> >\n>> >       /* Parse ref action mode from command line or config */\n>> >       ref_mode = get_ref_action_mode(repo, ref_action);\n>> > diff --git a/replay.c b/replay.c\n>> > index 7e35f40d37..1e1bc7c10a 100644\n>> > --- a/replay.c\n>> > +++ b/replay.c\n>> > @@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,\n>> >       set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n>> >                          &detached_head, &advance, &revert, &onto, &update_refs);\n>> >\n>> > +     if (opts->linearize &&\n>> > +         update_refs && strset_get_size(update_refs) > 1) {\n>> > +             ret = error(_(\"'--linearize' cannot be used with multiple revision ranges\"));\n>>\n>> Should this say \"multiple branches\" instead?\n>\n> Yes, please.\n\nAck.\n\n> Also, should we replace opts->linearize with (opts->linearize || mode\n> == REPLAY_MODE_REVERT) ?  The reason being this line of code below:\n>\n>> > +                     if (opts->linearize || mode == REPLAY_MODE_REVERT)\n>> > +                             base = last_commit;\n>\n> Trying to revert with multiple branches will (a) concatenate the\n> reverts into a single branch (which probably *is* what is wanted) and\n> (b) do so in revision walking order instead of the order of branches\n> specified by the user on the command line.  That ordering could have\n> bisection or conflict ramifications that may surprise the user.  Also,\n> cherry-pick disallows multiple branches (even though concatenation\n> would be wanted there too) because of this\n> ignore-user's-command-line-order issue.\n\nThis is already covered in set_up_branch_mode(), used by both --revert\nand --advance.\n\n-- \nLaters,\nToon\n"},{"id":"551549","messageId":"20260831-toon-git-replay-drop-merges-v9-0-61c4232c6f36@iotcl.com","threadId":"65771","inReplyTo":"20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com","subject":"[PATCH v9 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-08-31T13:13:46Z","receivedAt":"2026-08-31T13:14:09Z","isPatch":true,"body":"Hi,\n\nI'm back with another iteration of the patch series to implement\n--linearize into git-replay(1). My apologies for the long period of\nradio silence, but this topic was stalled on reviews for a while, and\nwhen I got some feedback I was on leave, so I'm finally back.\n\nAs far as I could tell there weren't any remaining comments on the\nimplementation itself, only on commit message and docs.\n\nLaters,\nToon\n\nOriginal cover letter:\n===\n\nAs an alternative to dscho's patch series to replay merges[1], add\nan option to git-replay(1) to linearize merges. This mimics what\ngit-rebase(1) does with --no-rebase-merges (the default).\n\nThe first two patches do some refactoring. The third patch implements\nthe actual change. The original patch was kindly provided by Dscho,\nwhich I've tweaked to be upstreamed.\n\nThe --linearize option is only added to git-replay(1) and not to\ngit-history(1) because in my opinion it doesn't make much sense to do\nso, but I'm happy to hear if anyone disagrees.\n\nDscho's series to replay merges[1] needs a bit of rework to fit on top\nof this, but I'm happy to help figuring that out. We've been discussing\nto either name the option --flatten or --linearize, but I've decided on\n\"linearize\" because the documentation of git-rebase(1) also mentions\n\"linearize\".\n\n[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n\n---\nChanges in v9:\n- Rephrase \"multiple revision ranges\" to \"multiple branches\".\n- Reword some things in the commit message.\n- Tweak wording in replay.adoc.\n- Link to v8: https://patch.msgid.link/20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com\n\nChanges in v8:\n- Disallow multiple revision ranges with --linearize.\n- Disallow --contained with --linearize.\n- Link to v7: https://patch.msgid.link/20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com\n\nChanges in v7:\n- Allow --revert and --linearize to be used together.\n- Because quite a lot of changes have been made since the original\n  patch, change author from Johannes to Toon for the last commit.\n  Johannes already told me he doesn't really care about authorship when\n  he initially shared the patch with me.\n- Link to v6: https://patch.msgid.link/20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com\n\nChanges in v6:\n- Reworked the second commit that moves picking the base completely\n  outside pick_regular_commit(), instead of adding more explanation.\n- Drastically extended the commit message on commit #3.\n- Extended docs on flattening multiple revision ranges and how it's\n  different from git-rebase(1)'s --no-rebase-merges.\n- Added a bunch of tests to cover various scenarios.\n- Remove newline from BUG() message.\n- Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com\n\nChanges in v5:\n- Dropped the enum->bool patch and instead added a patch that better\n  explains how pick_regular_commit() picks a base.\n- Order of commits is shuffled.\n- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool\n  patch, I extended the code comments to explain how things work. This\n  made me realize the use of the \"replayed_base\" was incorrect when\n  multiple branches are rebased with --onto. This is fixed now and a\n  test is added for this scenario.\n- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com\n\nChanges in v4:\n- Use test_grep instead of a bare grep in the range-diff test, to\n  prepare for mm/test-grep-lint.\n- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com\n\nChanges in v3:\n- Add --linearize to Documentation SYNOPSIS, and mention it's\n  incompatible with --revert.\n- Small language change in help message for --linearize.\n- Rephrase comment to include last_commit isn't modified when\n  linearizing merges.\n- Remove test that was added in earlier versions, but actually is\n  a duplicate of 'replaying merge commits is not supported yet'.\n- Add test to verify --revert and --linearize are incompatible.\n- Properly test that replaying down to root with --linearize works.\n- Add test for --linearize with --advance.\n- Add test that uses git-range-diff(1) to verify the patches created by\n  --linearize are correct.\n- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com\n\nChanges in v2:\n- Restructured the conditions to detect merge commits and added a line\n  of comment why the loop continues.\n- Rewrote tests to use the history from the setup step and added a few\n  test cases.\n- Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n  patches with this trailer, and if I understand correctly, I can keep\n  it. Please let me know if that wrong.\n- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n\n---\nToon Claes (3):\n      replay: add helper to put entry into replayed_commits\n      replay: resolve the replay base outside pick_regular_commit()\n      replay: offer an option to linearize the commit topology\n\n Documentation/git-replay.adoc |  17 ++++++-\n builtin/replay.c              |   6 ++-\n replay.c                      |  87 +++++++++++++++++++++++----------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 196 insertions(+), 28 deletions(-)\n\nRange-diff versus v8:\n\n1:  9fd3641eaf = 1:  3ea73d7c98 replay: add helper to put entry into replayed_commits\n2:  0bd4cd9b9c = 2:  4a40c42684 replay: resolve the replay base outside pick_regular_commit()\n3:  45faa926f8 ! 3:  d6247ea743 replay: offer an option to linearize the commit topology\n    @@ Commit message\n         If a ref was pointing to a merge commit, that ref is updated to the\n         merge's last replayed ancestor.\n     \n    -    git-replay(1) accepts multiple revision ranges, for example:\n    +    git-replay(1) accepts multiple branches, for example:\n     \n             $ git replay --onto main topic1 topic2\n     \n         Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n    -    independently and updates both refs.\n    +    (keeping shared portions of history shared and divergent parts\n    +    divergent) and updates both refs.\n     \n    -    For now this is disallowed with option `--linearize`. Linearizing more\n    -    than one branch at once would concatenate unrelated histories into a\n    -    single line, and update each branch to some point in that line. That\n    -    won't be the result most users want, especially because the order\n    -    depends on the order of the revision walk, not the order of the branch\n    -    names on the command line.\n    -\n    -    For the same reason disallow the use of `--contained` with\n    -    `--linearize`.\n    +    Due to current implementation limitations, replaying multiple branches\n    +    with `--linearize` is disallowed to avoid concatenating unrelated\n    +    histories into a single line. For the same reason disallow the use of\n    +    `--contained` with `--linearize`.\n     \n         Users who want to linearize multiple branches are advised to do this in\n         separate git-replay(1) invocations. Linearizing multiple branches at\n    @@ Documentation/git-replay.adoc: SYNOPSIS\n      \n      DESCRIPTION\n      -----------\n    -@@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).\n    +@@ Documentation/git-replay.adoc: Expanded description list compared to 'replay.refAction'.\n      +\n      The default mode can be configured via the `replay.refAction` configuration variable.\n      \n    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif\n     +\tpreviously replayed one, so all replayed commits are flattened into\n     +\ta single linear history.\n     ++\n    -+When a merge commit is encountered, the behavior of git-rebase(1)'s\n    -+option `--no-rebase-merges` is imitated. All commits in the range\n    -+reachable from the merge commit are replayed into a linear history, and\n    -+the merge commit itself is dropped. A ref that pointed to a merge commit\n    -+is updated to the merge's last replayed ancestor.\n    ++When a merge commit is encountered, all commits in the range reachable\n    ++from the merge commit are replayed into the linear history, and the\n    ++merge commit itself is dropped. A ref that pointed to a merge commit is\n    ++updated to the merge's last replayed ancestor. (This matches the\n    ++behavior of git-rebase(1)'s `--no-rebase-merges` option.)\n     ++\n    -+Only a single branch can be linearized at a time: `--linearize` cannot\n    -+be combined with multiple positive revisions or with `--contained`,\n    -+because that would concatenate otherwise unrelated histories into one\n    -+line. To linearize several branches, replay them in separate `git\n    -+replay` invocations.\n    ++`--linearize` cannot be combined with multiple branches or with\n    ++`--contained`. To linearize several branches, replay them in separate\n    ++`git replay` invocations.\n     +\n      <revision-range>::\n      \tRange of commits to replay; see \"Specifying Ranges\" in\n    @@ replay.c: int replay_revisions(struct rev_info *revs,\n      \n     +\tif (opts->linearize &&\n     +\t    update_refs && strset_get_size(update_refs) > 1) {\n    -+\t\tret = error(_(\"'--linearize' cannot be used with multiple revision ranges\"));\n    ++\t\tret = error(_(\"'--linearize' cannot be used with multiple branches\"));\n     +\t\tgoto out;\n     +\t}\n     +\n    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n     +\ttest_line_count = 3 out\n     +'\n     +\n    -+test_expect_success '--linearize rejects multiple revision ranges' '\n    ++test_expect_success '--linearize rejects multiple branches' '\n     +\ttest_must_fail git replay --ref-action=print --linearize \\\n     +\t\t--onto main ^B topic2 topic3 topic4 2>err &&\n    -+\ttest_grep \"cannot be used with multiple revision ranges\" err\n    ++\ttest_grep \"cannot be used with multiple branches\" err\n     +'\n     +\n     +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n\n\n---\nbase-commit: c73e85354c275c9d409b26445089bc16940fc527\nchange-id: 20260604-toon-git-replay-drop-merges-807fa008d395\n\n"},{"id":"551550","messageId":"20260831-toon-git-replay-drop-merges-v9-1-61c4232c6f36@iotcl.com","threadId":"65771","inReplyTo":"20260831-toon-git-replay-drop-merges-v9-0-61c4232c6f36@iotcl.com","subject":"[PATCH v9 1/3] replay: add helper to put entry into replayed_commits","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-08-31T13:13:47Z","receivedAt":"2026-08-31T13:14:12Z","isPatch":true,"body":"The function replay_revisions() in replay.c is rather lengthy. Extract\nthe logic to put a commit entry into a `struct mapped_commits` into a\nhelper function put_mapped_commit().\n\nWhile at it, rename mapped_commit() to get_mapped_commit() to pair with\nthis new function.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 31 ++++++++++++++++++++-----------\n 1 file changed, 20 insertions(+), 11 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 463c900d6c..860e194ba0 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -254,9 +254,9 @@ static void set_up_replay_mode(struct repository *repo,\n \tstrset_clear(&rinfo.positive_refs);\n }\n \n-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n-\t\t\t\t    struct commit *commit,\n-\t\t\t\t    struct commit *fallback)\n+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\tstruct commit *fallback)\n {\n \tkhint_t pos;\n \tif (!commit)\n@@ -267,6 +267,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,\n \treturn kh_value(replayed_commits, pos);\n }\n \n+static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n+\t\t\t      struct commit *commit,\n+\t\t\t      struct commit *new_commit)\n+{\n+\tkhint_t pos;\n+\tint ret;\n+\n+\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);\n+\tif (ret == 0)\n+\t\tBUG(\"Duplicate rewritten commit: %s\",\n+\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tkh_value(replayed_commits, pos) = new_commit;\n+}\n+\n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n \t\t\t\t\t  kh_oid_map_t *replayed_commits,\n@@ -287,7 +302,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n \t}\n \n-\treplayed_base = mapped_commit(replayed_commits, base, onto);\n+\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -427,8 +442,6 @@ int replay_revisions(struct rev_info *revs,\n \treplayed_commits = kh_init_oid_map();\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n-\t\tkhint_t pos;\n-\t\tint hr;\n \n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n@@ -440,11 +453,7 @@ int replay_revisions(struct rev_info *revs,\n \t\t\tbreak;\n \n \t\t/* Record commit -> last_commit mapping */\n-\t\tpos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);\n-\t\tif (hr == 0)\n-\t\t\tBUG(\"Duplicate rewritten commit: %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\t\tkh_value(replayed_commits, pos) = last_commit;\n+\t\tput_mapped_commit(replayed_commits, commit, last_commit);\n \n \t\t/* Update any necessary branches */\n \t\tif (ref)\n\n-- \n2.55.0.679.g6767b8d81c\n\n"},{"id":"551551","messageId":"20260831-toon-git-replay-drop-merges-v9-2-61c4232c6f36@iotcl.com","threadId":"65771","inReplyTo":"20260831-toon-git-replay-drop-merges-v9-0-61c4232c6f36@iotcl.com","subject":"[PATCH v9 2/3] replay: resolve the replay base outside pick_regular_commit()","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-08-31T13:13:48Z","receivedAt":"2026-08-31T13:14:15Z","isPatch":true,"body":"Depending on what gets passed into the function pick_regular_commit(),\nit decides the new base for the replayed commit. It first tries to find\nthe replayed results of `pickme`'s parent in the `replayed_commits` map.\nIf not found, it falls back to `onto`.\n\nWhen using git-replay(1) with --onto, the fallback is the revision\npassed in with this option, but when using --revert, the fallback is\n`last_commit`.\n\nIt's rather confusing the base is decided partly inside\npick_regular_commit() and partly by its caller.\n\nMove the base selection completely into the caller: replay_revisions().\nThis bundles all the logic of deciding on the base together. Also, this\nreduces the number of parameters of pick_regular_commit(), making its\ninterface cleaner.\n\nThis refactoring doesn't bring any behavior changes.\n\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n replay.c | 34 +++++++++++++++++++++-------------\n 1 file changed, 21 insertions(+), 13 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 860e194ba0..7e35f40d37 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -284,25 +284,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,\n \n static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *pickme,\n-\t\t\t\t\t  kh_oid_map_t *replayed_commits,\n-\t\t\t\t\t  struct commit *onto,\n+\t\t\t\t\t  struct commit *replayed_base,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n \t\t\t\t\t  enum replay_mode mode,\n \t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n-\tstruct commit *base, *replayed_base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n \n-\tif (pickme->parents) {\n-\t\tbase = pickme->parents->item;\n-\t\tbase_tree = repo_get_commit_tree(repo, base);\n-\t} else {\n-\t\tbase = NULL;\n+\tif (pickme->parents)\n+\t\tbase_tree = repo_get_commit_tree(repo, pickme->parents->item);\n+\telse\n \t\tbase_tree = lookup_tree(repo, repo->hash_algo->empty_tree);\n-\t}\n \n-\treplayed_base = get_mapped_commit(replayed_commits, base, onto);\n \treplayed_base_tree = repo_get_commit_tree(repo, replayed_base);\n \tpickme_tree = repo_get_commit_tree(repo, pickme);\n \n@@ -443,12 +437,26 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n+\t\t/*\n+\t\t * Decide where to replay this commit on.\n+\t\t * If the parent commit was replayed already, the replayed result\n+\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n+\t\t * When reverting, commits are replayed in reverse order and thus\n+\t\t * its parent isn't replayed yet. Therefore revert commits are\n+\t\t * always replayed onto `last_commit`.\n+\t\t */\n+\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n+\n+\t\tif (mode == REPLAY_MODE_REVERT)\n+\t\t\tbase = last_commit;\n+\n \t\tif (commit->parents && commit->parents->next)\n \t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n \n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n-\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n+\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t  mode, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n\n-- \n2.55.0.679.g6767b8d81c\n\n"},{"id":"551552","messageId":"20260831-toon-git-replay-drop-merges-v9-3-61c4232c6f36@iotcl.com","threadId":"65771","inReplyTo":"20260831-toon-git-replay-drop-merges-v9-0-61c4232c6f36@iotcl.com","subject":"[PATCH v9 3/3] replay: offer an option to linearize the commit topology","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-08-31T13:13:49Z","receivedAt":"2026-08-31T13:14:16Z","isPatch":true,"body":"One of the stated goals of git-replay(1) is to allow implementing the\ngit-rebase(1) functionality on the server side.\n\nThe default mode of git-rebase(1) is to act as if `--no-rebase-merges`\nwas given. This mode drops merge commits instead of replaying them, and\nlinearizes the history into a sequence of regular (single-parent)\ncommits.\n\nAdd option `--linearize` to git-replay(1) to do the same. Each replayed\ncommit is stacked on top of the previously replayed one. When a merge is\nencountered, the commits reachable from all of its sides are replayed\ninto the single line and the merge itself is dropped.\n\nIf a ref was pointing to a merge commit, that ref is updated to the\nmerge's last replayed ancestor.\n\ngit-replay(1) accepts multiple branches, for example:\n\n    $ git replay --onto main topic1 topic2\n\nWithout `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n(keeping shared portions of history shared and divergent parts\ndivergent) and updates both refs.\n\nDue to current implementation limitations, replaying multiple branches\nwith `--linearize` is disallowed to avoid concatenating unrelated\nhistories into a single line. For the same reason disallow the use of\n`--contained` with `--linearize`.\n\nUsers who want to linearize multiple branches are advised to do this in\nseparate git-replay(1) invocations. Linearizing multiple branches at\nonce might be added later.\n\nNote that `--linearize` is not modeled after git-rebase(1)'s\n`--rebase-merges[=<mode>]` interface. Recreating merges, by preserving\ntheir topology, is a distinct operation that would be a separate mode.\n`--linearize` only drops merges and replays commits linearly. So\ngit-replay(1) uses its own option rather than reusing that interface.\n\nBased-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>\nSigned-off-by: Toon Claes <toon@iotcl.com>\n---\n Documentation/git-replay.adoc |  17 ++++++-\n builtin/replay.c              |   6 ++-\n replay.c                      |  60 +++++++++++++++--------\n replay.h                      |   5 ++\n t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-\n 5 files changed, 174 insertions(+), 23 deletions(-)\n\ndiff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc\nindex ea4d14badd..58b4c0c470 100644\n--- a/Documentation/git-replay.adoc\n+++ b/Documentation/git-replay.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n --------\n [verse]\n (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n-\t\t\t     [--ref=<ref>] [--ref-action=<mode>] <revision-range>\n+\t\t\t     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -91,6 +91,21 @@ Expanded description list compared to 'replay.refAction'.\n +\n The default mode can be configured via the `replay.refAction` configuration variable.\n \n+--linearize::\n+\tIn this mode, each replayed commit is stacked on top of the\n+\tpreviously replayed one, so all replayed commits are flattened into\n+\ta single linear history.\n++\n+When a merge commit is encountered, all commits in the range reachable\n+from the merge commit are replayed into the linear history, and the\n+merge commit itself is dropped. A ref that pointed to a merge commit is\n+updated to the merge's last replayed ancestor. (This matches the\n+behavior of git-rebase(1)'s `--no-rebase-merges` option.)\n++\n+`--linearize` cannot be combined with multiple branches or with\n+`--contained`. To linearize several branches, replay them in separate\n+`git replay` invocations.\n+\n <revision-range>::\n \tRange of commits to replay; see \"Specifying Ranges\" in\n \tlinkgit:git-rev-parse[1]. In `--advance=<branch>` or\ndiff --git a/builtin/replay.c b/builtin/replay.c\nindex 39e3a86f6c..d39626a37d 100644\n--- a/builtin/replay.c\n+++ b/builtin/replay.c\n@@ -85,7 +85,7 @@ int cmd_replay(int argc,\n \tconst char *const replay_usage[] = {\n \t\tN_(\"(EXPERIMENTAL!) git replay \"\n \t\t   \"([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\\n\"\n-\t\t   \"[--ref=<ref>] [--ref-action=<mode>] <revision-range>\"),\n+\t\t   \"[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>\"),\n \t\tNULL\n \t};\n \tstruct option replay_options[] = {\n@@ -111,6 +111,8 @@ int cmd_replay(int argc,\n \t\t\t     N_(\"mode\"),\n \t\t\t     N_(\"control ref update behavior (update|print)\"),\n \t\t\t     PARSE_OPT_NONEG),\n+\t\tOPT_BOOL(0, \"linearize\", &opts.linearize,\n+\t\t\t N_(\"drop merge commits, replaying only non-merge commits\")),\n \t\tOPT_END()\n \t};\n \n@@ -132,6 +134,8 @@ int cmd_replay(int argc,\n \t\t\t\t  opts.contained, \"--contained\");\n \tdie_for_incompatible_opt2(!!opts.ref, \"--ref\",\n \t\t\t\t  !!opts.contained, \"--contained\");\n+\tdie_for_incompatible_opt2(opts.linearize, \"--linearize\",\n+\t\t\t\t  !!opts.contained, \"--contained\");\n \n \t/* Parse ref action mode from command line or config */\n \tref_mode = get_ref_action_mode(repo, ref_action);\ndiff --git a/replay.c b/replay.c\nindex 7e35f40d37..6565e4d215 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,\n \tset_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,\n \t\t\t   &detached_head, &advance, &revert, &onto, &update_refs);\n \n+\tif (opts->linearize &&\n+\t    update_refs && strset_get_size(update_refs) > 1) {\n+\t\tret = error(_(\"'--linearize' cannot be used with multiple branches\"));\n+\t\tgoto out;\n+\t}\n+\n \tif (opts->ref) {\n \t\tstruct object_id oid;\n \n@@ -437,26 +443,40 @@ int replay_revisions(struct rev_info *revs,\n \twhile ((commit = get_revision(revs))) {\n \t\tconst struct name_decoration *decoration;\n \n-\t\t/*\n-\t\t * Decide where to replay this commit on.\n-\t\t * If the parent commit was replayed already, the replayed result\n-\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n-\t\t * When reverting, commits are replayed in reverse order and thus\n-\t\t * its parent isn't replayed yet. Therefore revert commits are\n-\t\t * always replayed onto `last_commit`.\n-\t\t */\n-\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n-\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n-\n-\t\tif (mode == REPLAY_MODE_REVERT)\n-\t\t\tbase = last_commit;\n-\n-\t\tif (commit->parents && commit->parents->next)\n-\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n-\n-\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n-\t\t\t\t\t\t  &merge_opt, &result,\n-\t\t\t\t\t\t  mode, opts->empty);\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (!opts->linearize)\n+\t\t\t\tdie(_(\"replaying merge commits is not supported yet!\"));\n+\t\t\t/*\n+\t\t\t * Drop the merge commit: do not pick it, leave\n+\t\t\t * `last_commit` unchanged, and fall through to the\n+\t\t\t * rest of the loop. As a result:\n+\t\t\t * - refs pointing to the merge commit will be updated\n+\t\t\t *   to `last_commit`.\n+\t\t\t * - the next replayed commit uses `last_commit` as its\n+\t\t\t *   `base`.\n+\t\t\t */\n+\t\t} else {\n+\t\t\t/*\n+\t\t\t * Decide where to replay this commit onto.\n+\t\t\t * If the parent commit was replayed already, the replayed result\n+\t\t\t * can be found in `replayed_commits`. Otherwise fall back to `onto`.\n+\t\t\t * When reverting, commits are replayed in reverse order and thus\n+\t\t\t * its parent isn't replayed yet. Therefore revert commits are\n+\t\t\t * always replayed onto `last_commit`.\n+\t\t\t * Also when opts->linearize is true, set the base to\n+\t\t\t * `last_commit` to create a single linear history.\n+\t\t\t */\n+\t\t\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\t\t\tstruct commit *base = get_mapped_commit(replayed_commits, parent, onto);\n+\n+\t\t\tif (opts->linearize || mode == REPLAY_MODE_REVERT)\n+\t\t\t\tbase = last_commit;\n+\n+\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, base,\n+\t\t\t\t\t\t\t  &merge_opt, &result,\n+\t\t\t\t\t\t\t  mode, opts->empty);\n+\t\t}\n+\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/replay.h b/replay.h\nindex 491db145e3..2c71afbfde 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -62,6 +62,11 @@ struct replay_revisions_options {\n \t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n \t */\n \tenum replay_empty_commit_action empty;\n+\n+\t/*\n+\t * Whether to linearize the commits (i.e. drop merge commits).\n+\t */\n+\tint linearize;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..d3409b9bb1 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -52,8 +52,19 @@ test_expect_success 'setup' '\n \ttest_merge P O --no-ff &&\n \tgit switch main &&\n \n+\tgit switch --orphan unrelated &&\n+\ttest_commit unrelated-root &&\n+\n \tgit switch -c conflict B &&\n-\ttest_commit C.conflict C.t conflict\n+\ttest_commit C.conflict C.t conflict &&\n+\tgit branch -D unrelated &&\n+\n+\tgit switch -c divergent-x main &&\n+\ttest_commit X &&\n+\tgit switch -c divergent-y main &&\n+\ttest_commit Y &&\n+\tgit switch divergent-x &&\n+\ttest_merge Z divergent-y --no-ff\n '\n \n test_expect_success 'setup bare' '\n@@ -565,4 +576,100 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '\n \ttest_grep \"cannot be used with multiple revision ranges\" err\n '\n \n+test_expect_success 'replay to rebase merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto unrelated-root topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J I B A unrelated-root >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'replay to cherry-pick merge commit with --linearize' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--advance main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\n+\tgit log --format=%s $(cut -f 3 -d \" \" result) >actual &&\n+\ttest_write_lines O N J M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tprintf \"update refs/heads/main \" >expect &&\n+\tprintf \"%s \" $(cut -f 3 -d \" \" result) >>expect &&\n+\tgit rev-parse main >>expect &&\n+\ttest_cmp expect result\n+'\n+\n+test_expect_success 'replay --linearize produces the same patches' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main I..topic-with-merge >result &&\n+\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# range-diff does not care about the dropped merge,\n+\t# so the original commits (I..topic-with-merge)\n+\t# and the replayed chain (main..tip) must produce identical patches.\n+\tgit range-diff I..topic-with-merge main..$tip >out &&\n+\ttest_file_not_empty out &&\n+\ttest_grep ! -v \"=\" out &&\n+\n+\tgit log --oneline main..$tip >out &&\n+\ttest_line_count = 3 out\n+'\n+\n+test_expect_success '--linearize rejects multiple branches' '\n+\ttest_must_fail git replay --ref-action=print --linearize \\\n+\t\t--onto main ^B topic2 topic3 topic4 2>err &&\n+\ttest_grep \"cannot be used with multiple branches\" err\n+'\n+\n+test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n+\tgit replay --ref-action=print --linearize \\\n+\t\t--onto main main..divergent-x >result &&\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# The merge Z is dropped, but both X and Y are linearized onto main;\n+\t# neither side is lost.\n+\tgit log --format=%s main..$tip >actual &&\n+\ttest_write_lines Y X >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--linearize and --contained cannot be used together' '\n+\ttest_must_fail git replay --ref-action=print --linearize --contained \\\n+\t\t--onto main ^B topic-with-merge 2>err &&\n+\ttest_grep \"cannot be used together\" err\n+'\n+\n+test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '\n+\tgit replay --ref-action=print --revert=divergent-x --linearize \\\n+\t\tmain..divergent-x >result &&\n+\ttest_line_count = 1 result &&\n+\ttip=$(cut -f 3 -d \" \" result) &&\n+\n+\tgit log --format=%s $tip >actual &&\n+\ttest_write_lines \\\n+\t\t\"Revert \\\"X\\\"\" \"Revert \\\"Y\\\"\" Z Y X M L B A >expect &&\n+\ttest_cmp expect actual &&\n+\n+\ttest_must_fail git cat-file -e $tip:X.t &&\n+\ttest_must_fail git cat-file -e $tip:Y.t\n+'\n+\n test_done\n\n-- \n2.55.0.679.g6767b8d81c\n\n"},{"id":"551705","messageId":"CABPp-BF1=DZAxX5Now4pCKPi8=cpXo506z=8QVu2vYCSiKdqMA@mail.gmail.com","threadId":"65771","inReplyTo":"20260831-toon-git-replay-drop-merges-v9-0-61c4232c6f36@iotcl.com","subject":"Re: [PATCH v9 0/3] Teach git-replay(1) to linearize merge commits","fromName":"Elijah Newren","fromEmail":"newren@gmail.com","sentAt":"2026-09-01T22:15:20Z","receivedAt":"2026-09-01T22:15:34Z","isPatch":true,"body":"On Mon, Aug 31, 2026 at 6:14 AM Toon Claes <toon@iotcl.com> wrote:\n>\n> Hi,\n>\n> I'm back with another iteration of the patch series to implement\n> --linearize into git-replay(1). My apologies for the long period of\n> radio silence, but this topic was stalled on reviews for a while, and\n> when I got some feedback I was on leave, so I'm finally back.\n>\n> As far as I could tell there weren't any remaining comments on the\n> implementation itself, only on commit message and docs.\n>\n> Laters,\n> Toon\n>\n> Original cover letter:\n> ===\n>\n> As an alternative to dscho's patch series to replay merges[1], add\n> an option to git-replay(1) to linearize merges. This mimics what\n> git-rebase(1) does with --no-rebase-merges (the default).\n>\n> The first two patches do some refactoring. The third patch implements\n> the actual change. The original patch was kindly provided by Dscho,\n> which I've tweaked to be upstreamed.\n>\n> The --linearize option is only added to git-replay(1) and not to\n> git-history(1) because in my opinion it doesn't make much sense to do\n> so, but I'm happy to hear if anyone disagrees.\n>\n> Dscho's series to replay merges[1] needs a bit of rework to fit on top\n> of this, but I'm happy to help figuring that out. We've been discussing\n> to either name the option --flatten or --linearize, but I've decided on\n> \"linearize\" because the documentation of git-rebase(1) also mentions\n> \"linearize\".\n>\n> [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>\n>\n> ---\n> Changes in v9:\n> - Rephrase \"multiple revision ranges\" to \"multiple branches\".\n> - Reword some things in the commit message.\n> - Tweak wording in replay.adoc.\n> - Link to v8: https://patch.msgid.link/20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com\n>\n> Changes in v8:\n> - Disallow multiple revision ranges with --linearize.\n> - Disallow --contained with --linearize.\n> - Link to v7: https://patch.msgid.link/20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com\n>\n> Changes in v7:\n> - Allow --revert and --linearize to be used together.\n> - Because quite a lot of changes have been made since the original\n>   patch, change author from Johannes to Toon for the last commit.\n>   Johannes already told me he doesn't really care about authorship when\n>   he initially shared the patch with me.\n> - Link to v6: https://patch.msgid.link/20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com\n>\n> Changes in v6:\n> - Reworked the second commit that moves picking the base completely\n>   outside pick_regular_commit(), instead of adding more explanation.\n> - Drastically extended the commit message on commit #3.\n> - Extended docs on flattening multiple revision ranges and how it's\n>   different from git-rebase(1)'s --no-rebase-merges.\n> - Added a bunch of tests to cover various scenarios.\n> - Remove newline from BUG() message.\n> - Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com\n>\n> Changes in v5:\n> - Dropped the enum->bool patch and instead added a patch that better\n>   explains how pick_regular_commit() picks a base.\n> - Order of commits is shuffled.\n> - (BIGGEST CHANGE) When working on a refactor to undo the enum->bool\n>   patch, I extended the code comments to explain how things work. This\n>   made me realize the use of the \"replayed_base\" was incorrect when\n>   multiple branches are rebased with --onto. This is fixed now and a\n>   test is added for this scenario.\n> - Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com\n>\n> Changes in v4:\n> - Use test_grep instead of a bare grep in the range-diff test, to\n>   prepare for mm/test-grep-lint.\n> - Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com\n>\n> Changes in v3:\n> - Add --linearize to Documentation SYNOPSIS, and mention it's\n>   incompatible with --revert.\n> - Small language change in help message for --linearize.\n> - Rephrase comment to include last_commit isn't modified when\n>   linearizing merges.\n> - Remove test that was added in earlier versions, but actually is\n>   a duplicate of 'replaying merge commits is not supported yet'.\n> - Add test to verify --revert and --linearize are incompatible.\n> - Properly test that replaying down to root with --linearize works.\n> - Add test for --linearize with --advance.\n> - Add test that uses git-range-diff(1) to verify the patches created by\n>   --linearize are correct.\n> - Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com\n>\n> Changes in v2:\n> - Restructured the conditions to detect merge commits and added a line\n>   of comment why the loop continues.\n> - Rewrote tests to use the history from the setup step and added a few\n>   test cases.\n> - Re-added Johannes's Signed-off-by trailer. Johannes gave me the\n>   patches with this trailer, and if I understand correctly, I can keep\n>   it. Please let me know if that wrong.\n> - Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com\n>\n> ---\n> Toon Claes (3):\n>       replay: add helper to put entry into replayed_commits\n>       replay: resolve the replay base outside pick_regular_commit()\n>       replay: offer an option to linearize the commit topology\n>\n>  Documentation/git-replay.adoc |  17 ++++++-\n>  builtin/replay.c              |   6 ++-\n>  replay.c                      |  87 +++++++++++++++++++++++----------\n>  replay.h                      |   5 ++\n>  t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-\n>  5 files changed, 196 insertions(+), 28 deletions(-)\n>\n> Range-diff versus v8:\n>\n> 1:  9fd3641eaf = 1:  3ea73d7c98 replay: add helper to put entry into replayed_commits\n> 2:  0bd4cd9b9c = 2:  4a40c42684 replay: resolve the replay base outside pick_regular_commit()\n> 3:  45faa926f8 ! 3:  d6247ea743 replay: offer an option to linearize the commit topology\n>     @@ Commit message\n>          If a ref was pointing to a merge commit, that ref is updated to the\n>          merge's last replayed ancestor.\n>\n>     -    git-replay(1) accepts multiple revision ranges, for example:\n>     +    git-replay(1) accepts multiple branches, for example:\n>\n>              $ git replay --onto main topic1 topic2\n>\n>          Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'\n>     -    independently and updates both refs.\n>     +    (keeping shared portions of history shared and divergent parts\n>     +    divergent) and updates both refs.\n>\n>     -    For now this is disallowed with option `--linearize`. Linearizing more\n>     -    than one branch at once would concatenate unrelated histories into a\n>     -    single line, and update each branch to some point in that line. That\n>     -    won't be the result most users want, especially because the order\n>     -    depends on the order of the revision walk, not the order of the branch\n>     -    names on the command line.\n>     -\n>     -    For the same reason disallow the use of `--contained` with\n>     -    `--linearize`.\n>     +    Due to current implementation limitations, replaying multiple branches\n>     +    with `--linearize` is disallowed to avoid concatenating unrelated\n>     +    histories into a single line. For the same reason disallow the use of\n>     +    `--contained` with `--linearize`.\n>\n>          Users who want to linearize multiple branches are advised to do this in\n>          separate git-replay(1) invocations. Linearizing multiple branches at\n>     @@ Documentation/git-replay.adoc: SYNOPSIS\n>\n>       DESCRIPTION\n>       -----------\n>     -@@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).\n>     +@@ Documentation/git-replay.adoc: Expanded description list compared to 'replay.refAction'.\n>       +\n>       The default mode can be configured via the `replay.refAction` configuration variable.\n>\n>     @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif\n>      +  previously replayed one, so all replayed commits are flattened into\n>      +  a single linear history.\n>      ++\n>     -+When a merge commit is encountered, the behavior of git-rebase(1)'s\n>     -+option `--no-rebase-merges` is imitated. All commits in the range\n>     -+reachable from the merge commit are replayed into a linear history, and\n>     -+the merge commit itself is dropped. A ref that pointed to a merge commit\n>     -+is updated to the merge's last replayed ancestor.\n>     ++When a merge commit is encountered, all commits in the range reachable\n>     ++from the merge commit are replayed into the linear history, and the\n>     ++merge commit itself is dropped. A ref that pointed to a merge commit is\n>     ++updated to the merge's last replayed ancestor. (This matches the\n>     ++behavior of git-rebase(1)'s `--no-rebase-merges` option.)\n>      ++\n>     -+Only a single branch can be linearized at a time: `--linearize` cannot\n>     -+be combined with multiple positive revisions or with `--contained`,\n>     -+because that would concatenate otherwise unrelated histories into one\n>     -+line. To linearize several branches, replay them in separate `git\n>     -+replay` invocations.\n>     ++`--linearize` cannot be combined with multiple branches or with\n>     ++`--contained`. To linearize several branches, replay them in separate\n>     ++`git replay` invocations.\n>      +\n>       <revision-range>::\n>         Range of commits to replay; see \"Specifying Ranges\" in\n>     @@ replay.c: int replay_revisions(struct rev_info *revs,\n>\n>      +  if (opts->linearize &&\n>      +      update_refs && strset_get_size(update_refs) > 1) {\n>     -+          ret = error(_(\"'--linearize' cannot be used with multiple revision ranges\"));\n>     ++          ret = error(_(\"'--linearize' cannot be used with multiple branches\"));\n>      +          goto out;\n>      +  }\n>      +\n>     @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl\n>      +  test_line_count = 3 out\n>      +'\n>      +\n>     -+test_expect_success '--linearize rejects multiple revision ranges' '\n>     ++test_expect_success '--linearize rejects multiple branches' '\n>      +  test_must_fail git replay --ref-action=print --linearize \\\n>      +          --onto main ^B topic2 topic3 topic4 2>err &&\n>     -+  test_grep \"cannot be used with multiple revision ranges\" err\n>     ++  test_grep \"cannot be used with multiple branches\" err\n>      +'\n>      +\n>      +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '\n\nThanks for making all these changes!  I think this version is good to go.\n\nReviewed-by: Elijah Newren <newren@gmail.com>\n"}]}