{"thread":{"id":"65603","subject":"[PATCH/RFC 0/5] replay: support replaying 2-parent merges","startedAt":"2026-05-06T22:43:28Z","lastAt":"2026-05-26T21:16:18Z","messageCount":17,"participants":["Johannes Schindelin via GitGitGadget","D. Ben Knoble","Johannes Schindelin","Ben Knoble","Phillip Wood","Toon Claes","Kristoffer Haugsbakk"],"isPatch":true,"patchVersion":1,"patchTotal":5},"messages":[{"id":"542821","messageId":"pull.2106.git.1778107405.gitgitgadget@gmail.com","threadId":"65603","inReplyTo":null,"subject":"[PATCH/RFC 0/5] replay: support replaying 2-parent merges","fromName":"Johannes Schindelin via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-06T22:43:19Z","receivedAt":"2026-05-06T22:43:28Z","isPatch":true,"body":"git history, the new history-rewriting builtin in v2.54, dies on any merge\nin the rewrite path with replaying merge commits is not supported yet!. That\nmakes it not very useful for the workflows I actually have, where almost\nevery interesting branch contains at least one merge of a feature topic. The\nnatural fallback, git rebase --rebase-merges, is interactive and stops to\nask for re-resolution even when no re-resolution is needed.\n\nThis series lifts that limitation for the common 2-parent case. The\nalgorithm itself is not new: Elijah Newren wrote it down in his replay\ndesign notes\n[https://github.com/newren/git/blob/replay/replay-design-notes.txt] and\nprototyped it in a 2022 work-in-progress sketch\n[https://github.com/newren/git/commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45].\nWhat is new is wiring it into the replay_revisions() API that backs both git\nreplay and git history, plus three specific tweaks that make the trickier\ncases work where the WIP sketch bailed out: identical conflict-marker labels\nfor the inner remerges of the original and the rewritten parents (so their\nconflict-markered trees compare equal in the regions the user did not\ntouch), tolerating result.clean == 0 from those inner merges (their\nwell-defined conflict-markered trees are valid inputs to the outer 3-way\nmerge), and self-fallback for both merge parents combined with mapping the\nrev-range boundary commits to the onto commit.\n\nOctopus merges and revert-of-merge are surfaced as explicit errors at the\ndispatch point. The split sub-command of git history continues to refuse\nwhen its target is a merge: split semantics simply do not apply there. The\nxdiff special mode for matching conflict-marker hunks across inner remerges,\nthe XDL_MERGE_FAVOR_BASE variant, and the modify/delete and binary-file\nspecials that the design notes flag as future work all remain future work.\n\nWhile I was at it, git history reword had a pre-existing silent-success bug:\na positive return from replay_revisions() (which means \"conflict, no updates\nqueued\") was treated as success. Obviously this should never occur, as a\nreword simply does not change any of the file contents, but bugs do happen.\nThe merge-replay work is complex enough to make that class of bugs more\nlikely, therefore I introduce error messages for those instances.\n\nJohannes Schindelin (5):\n  replay: support replaying 2-parent merges\n  replay: short-circuit merge replay when parent and base trees are\n    unchanged\n  history.adoc: describe merge-replay support and its limits\n  test-tool: add a \"historian\" subcommand for building merge fixtures\n  t3454: cover merge-replay scenarios with the historian helper\n\n Documentation/git-history.adoc |  27 ++-\n Makefile                       |   1 +\n builtin/history.c              |  16 +-\n replay.c                       | 258 +++++++++++++++++++++++++--\n t/helper/meson.build           |   1 +\n t/helper/test-historian.c      | 189 ++++++++++++++++++++\n t/helper/test-tool.c           |   1 +\n t/helper/test-tool.h           |   1 +\n t/meson.build                  |   1 +\n t/t3451-history-reword.sh      |  21 ++-\n t/t3452-history-split.sh       |   6 +-\n t/t3454-history-merges.sh      | 308 +++++++++++++++++++++++++++++++++\n t/t3650-replay-basics.sh       |  46 ++++-\n 13 files changed, 840 insertions(+), 36 deletions(-)\n create mode 100644 t/helper/test-historian.c\n create mode 100755 t/t3454-history-merges.sh\n\n\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2106%2Fdscho%2Fsupport-merge-commits-in-git-history-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2106/dscho/support-merge-commits-in-git-history-v1\nPull-Request: https://github.com/gitgitgadget/git/pull/2106\n-- \ngitgitgadget\n"},{"id":"542822","messageId":"034ab0f83822e6db67baa423d9fcb753b12b5ac8.1778107405.git.gitgitgadget@gmail.com","threadId":"65603","inReplyTo":"pull.2106.git.1778107405.gitgitgadget@gmail.com","subject":"[PATCH/RFC 1/5] replay: support replaying 2-parent merges","fromName":"Johannes Schindelin via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-06T22:43:20Z","receivedAt":"2026-05-06T22:43:30Z","isPatch":true,"body":"From: Johannes Schindelin <johannes.schindelin@gmx.de>\n\n`git history` (introduced in v2.54) and the underlying `git replay`\ninfrastructure both refused to walk past any commit with more than\none parent, dying with \"replaying merge commits is not supported\nyet!\". For real history-rewriting work this is a showstopper: the\nnatural fallback `git rebase --rebase-merges` is interactive and\nstops to ask for re-resolution even when no re-resolution is needed.\n\nElijah Newren spelled out a way to lift this limitation in his\nreplay-design-notes [1] and prototyped it in a 2022\nwork-in-progress sketch [2]. The idea is that a merge commit M on\nparents (P1, P2) records both an automatic merge of those parents\nAND any manual layer the author put on top of that automatic merge\n(textual conflict resolution and any semantic edit outside conflict\nmarkers). Replaying M onto rewritten parents (P1', P2') must\npreserve that manual layer, but the rewritten parents change the\nautomatic merge, so a simple cherry-pick is wrong: the manual layer\nwould be re-introduced on top of stale auto-merge text.\n\nWhat works instead is a three-way merge of three trees the existing\ninfrastructure already knows how to compute. Let R be the recursive\nauto-merge of (P1, P2), O be M's actual tree and N be the recursive\nauto-merge of (P1', P2'). Then `git diff R O` is morally\n`git show --remerge-diff M`: it captures exactly what the author\nadded on top of the automatic merge. A non-recursive 3-way merge\nwith R as the merge base, O as side 1 and N as side 2 layers that\nmanual contribution onto the freshly auto-merged rewritten parents\n(N) and produces the replayed tree.\n\nImplement `pick_merge_commit()` along those lines and dispatch to it\nfrom `replay_revisions()` when the commit being replayed has exactly\ntwo parents. Two specific points (learned the hard way) keep\nnon-trivial cases working where the WIP sketch [2] bailed out.\nFirst, R and N use identical `merge_options.branch1` and `branch2`\nlabels (\"ours\"/\"theirs\"). When the original parents conflicted on a\nregion of a file, both R and N produce textually identical conflict\nmarkers; the outer non-recursive merge then sees N == R in that\nregion and the user's manual resolution from O wins cleanly. Without\nthis, the conflict-marker text would differ between R and N (because\nthe inner merges would label the conflicts differently), and the\nouter merge would itself be unclean even when the user did supply a\nclean resolution. Second, an unclean inner merge\n(`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in\nthat case still has well-defined contents (with conflict markers in\nthe conflicted files) and is a valid input to the outer\nnon-recursive merge. Only a real error (`< 0`) propagates as\nfailure.\n\nThe replay propagates the textual diffs the user actually made in M;\nit does _not_ extrapolate symbol-level intent. If rewriting the\nparents pulls in genuinely new content (for example, a brand-new\ncaller of a function that the merge renamed), that new content stays\nas the rewritten parents have it. Symbol-aware refactoring is out of\nscope here, just as it is for plain rebase.\n\nOctopus merges (more than two parents) and revert-of-merge are not\nsupported and are surfaced as explicit errors at the dispatch point.\nThe \"split\" sub-command of `git history` continues to refuse when\nthe targeted commit is itself a merge: split semantics do not apply\nto merges. The pre-walk gate in `builtin/history.c` that previously\nrejected any merge in the rewrite path now only rejects octopus\nmerges; rename it accordingly.\n\nA small refactor in `create_commit()` makes the merge case possible:\nthe helper now takes a `struct commit_list *parents` rather than a\nsingle parent pointer and takes ownership of the list. The single\nexisting caller in `pick_regular_commit()` builds and passes a\none-element list; the new `pick_merge_commit()` builds a two-element\nlist, with the order of the `from` and `merge` parents preserved.\n\nUpdate the negative expectations in t3451, t3452 and t3650 that were\nasserting the now-retired \"not supported yet\" message, replacing\nthem with positive coverage where it fits. Octopus rejection and\nrevert-of-merge rejection are covered by new positive tests in\nt3650. A dedicated test script with merge-replay scenarios driven by\na new test-tool fixture builder will follow in a subsequent commit.\n\n[1] https://github.com/newren/git/blob/replay/replay-design-notes.txt\n[2] https://github.com/newren/git/commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45\n\nHelped-by: Elijah Newren <newren@gmail.com>\nAssisted-by: Claude Opus 4.7\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n---\n builtin/history.c         |  16 ++-\n replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--\n t/t3451-history-reword.sh |  21 ++--\n t/t3452-history-split.sh  |   6 +-\n t/t3650-replay-basics.sh  |  46 ++++++++-\n 5 files changed, 269 insertions(+), 29 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 9526938085..00097b2226 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -195,15 +195,15 @@ static int parse_ref_action(const struct option *opt, const char *value, int uns\n \treturn 0;\n }\n \n-static int revwalk_contains_merges(struct repository *repo,\n-\t\t\t\t   const struct strvec *revwalk_args)\n+static int revwalk_contains_octopus_merges(struct repository *repo,\n+\t\t\t\t\t   const struct strvec *revwalk_args)\n {\n \tstruct strvec args = STRVEC_INIT;\n \tstruct rev_info revs;\n \tint ret;\n \n \tstrvec_pushv(&args, revwalk_args->v);\n-\tstrvec_push(&args, \"--min-parents=2\");\n+\tstrvec_push(&args, \"--min-parents=3\");\n \n \trepo_init_revisions(repo, &revs, NULL);\n \n@@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository *repo,\n \t}\n \n \tif (get_revision(&revs)) {\n-\t\tret = error(_(\"replaying merge commits is not supported yet!\"));\n+\t\tret = error(_(\"replaying octopus merges is not supported\"));\n \t\tgoto out;\n \t}\n \n@@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,\n \t\tstrvec_push(&args, \"HEAD\");\n \t}\n \n-\tret = revwalk_contains_merges(repo, &args);\n+\tret = revwalk_contains_octopus_merges(repo, &args);\n \tif (ret < 0)\n \t\tgoto out;\n \n@@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,\n \tif (ret < 0) {\n \t\tret = error(_(\"failed replaying descendants\"));\n \t\tgoto out;\n+\t} else if (ret) {\n+\t\tret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n+\t\tgoto out;\n \t}\n \n \tret = 0;\n@@ -721,6 +724,9 @@ static int cmd_history_split(int argc,\n \tif (ret < 0) {\n \t\tret = error(_(\"failed replaying descendants\"));\n \t\tgoto out;\n+\t} else if (ret) {\n+\t\tret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n+\t\tgoto out;\n \t}\n \n \tret = 0;\ndiff --git a/replay.c b/replay.c\nindex f96f1f6551..3dbce095f9 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"git-compat-util.h\"\n+#include \"commit-reach.h\"\n #include \"environment.h\"\n #include \"hex.h\"\n #include \"merge-ort.h\"\n@@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf *msg,\n \trepo_unuse_commit_buffer(repo, commit, message);\n }\n \n+/*\n+ * Build a new commit with the given tree and parent list, copying author,\n+ * extra headers and (for pick mode) the commit message from `based_on`.\n+ *\n+ * Takes ownership of `parents`: it will be freed before returning, even on\n+ * error. Parent order is preserved as supplied by the caller.\n+ */\n 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    struct commit_list *parents,\n \t\t\t\t    enum replay_mode mode)\n {\n \tstruct object_id ret;\n \tstruct object *obj = NULL;\n-\tstruct commit_list *parents = NULL;\n \tchar *author = NULL;\n \tchar *sign_commit = NULL; /* FIXME: cli users might want to sign again */\n \tstruct commit_extra_header *extra = NULL;\n@@ -96,7 +103,6 @@ static struct commit *create_commit(struct repository *repo,\n \tconst char *orig_message = NULL;\n \tconst char *exclude_gpgsig[] = { \"gpgsig\", \"gpgsig-sha256\", NULL };\n \n-\tcommit_list_insert(parent, &parents);\n \textra = read_commit_extra_headers(based_on, exclude_gpgsig);\n \tif (mode == REPLAY_MODE_REVERT) {\n \t\tgenerate_revert_message(&msg, based_on, repo);\n@@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n {\n \tstruct commit *base, *replayed_base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n+\tstruct commit_list *parents = NULL;\n \n \tif (pickme->parents) {\n \t\tbase = pickme->parents->item;\n@@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \tif (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&\n \t    !oideq(&pickme_tree->object.oid, &base_tree->object.oid))\n \t\treturn replayed_base;\n-\treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n+\tcommit_list_insert(replayed_base, &parents);\n+\treturn create_commit(repo, result->tree, pickme, parents, mode);\n+}\n+\n+/*\n+ * Replay a 2-parent merge commit by composing three calls into merge-ort:\n+ *\n+ *   R = recursive merge of pickme's two original parents (auto-remerge of\n+ *       the original merge, accepting any conflicts)\n+ *   N = recursive merge of the (possibly rewritten) parents\n+ *   O = pickme's tree (the user's actual merge, including any manual\n+ *       resolutions)\n+ *\n+ * The picked tree comes from a non-recursive merge using R as the base,\n+ * O as side1 and N as side2. `git diff R O` is morally `git show\n+ * --remerge-diff $oldmerge`, so this layers the user's original manual\n+ * resolution on top of the freshly auto-merged rewritten parents (see\n+ * `replay-design-notes.txt` on the `replay` branch of newren/git).\n+ *\n+ * If the outer 3-way merge is unclean, propagate the conflict status to\n+ * the caller via `result->clean = 0` and return NULL. The two inner\n+ * merges (R and N) being unclean is _not_ fatal: the conflict-markered\n+ * trees they produce are valid inputs to the outer merge, and using\n+ * identical labels for both inner merges keeps the marker text\n+ * byte-equal between R and N so the user's resolution recorded in O\n+ * collapses the conflict cleanly there. Octopus merges (more than two\n+ * parents) and revert-of-merge are rejected by the caller before this\n+ * function is invoked.\n+ */\n+static struct commit *pick_merge_commit(struct repository *repo,\n+\t\t\t\t\tstruct commit *pickme,\n+\t\t\t\t\tkh_oid_map_t *replayed_commits,\n+\t\t\t\t\tstruct merge_options *merge_opt,\n+\t\t\t\t\tstruct merge_result *result)\n+{\n+\tstruct commit *parent1, *parent2;\n+\tstruct commit *replayed_par1, *replayed_par2;\n+\tstruct tree *pickme_tree;\n+\tstruct merge_options remerge_opt = { 0 };\n+\tstruct merge_options new_merge_opt = { 0 };\n+\tstruct merge_result remerge_res = { 0 };\n+\tstruct merge_result new_merge_res = { 0 };\n+\tstruct commit_list *parent_bases = NULL;\n+\tstruct commit_list *replayed_bases = NULL;\n+\tstruct commit_list *parents;\n+\tstruct commit *picked = NULL;\n+\tchar *ancestor_name = NULL;\n+\n+\tparent1 = pickme->parents->item;\n+\tparent2 = pickme->parents->next->item;\n+\n+\t/*\n+\t * Map the merge's parents to their replayed counterparts. With the\n+\t * boundary commits pre-seeded into `replayed_commits`, every parent\n+\t * either has an explicit mapping (rewritten or boundary -> onto) or\n+\t * sits outside the rewrite range entirely; the latter must stay at\n+\t * the original parent commit, so use `parent` itself as the fallback\n+\t * for both sides.\n+\t */\n+\treplayed_par1 = mapped_commit(replayed_commits, parent1, parent1);\n+\treplayed_par2 = mapped_commit(replayed_commits, parent2, parent2);\n+\n+\t/*\n+\t * R: auto-remerge of the original parents.\n+\t *\n+\t * Use the same branch labels for the inner merges that compute R\n+\t * and N so conflict markers (if any) are textually identical\n+\t * between the two; the outer non-recursive merge can then collapse\n+\t * the manual resolution from O against them.\n+\t */\n+\tinit_basic_merge_options(&remerge_opt, repo);\n+\tremerge_opt.show_rename_progress = 0;\n+\tremerge_opt.branch1 = \"ours\";\n+\tremerge_opt.branch2 = \"theirs\";\n+\tif (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0) {\n+\t\tresult->clean = -1;\n+\t\tgoto out;\n+\t}\n+\tmerge_incore_recursive(&remerge_opt, parent_bases,\n+\t\t\t       parent1, parent2, &remerge_res);\n+\tparent_bases = NULL; /* consumed by merge_incore_recursive */\n+\tif (remerge_res.clean < 0) {\n+\t\tresult->clean = remerge_res.clean;\n+\t\tgoto out;\n+\t}\n+\n+\t/* N: fresh merge of the (possibly rewritten) parents. */\n+\tinit_basic_merge_options(&new_merge_opt, repo);\n+\tnew_merge_opt.show_rename_progress = 0;\n+\tnew_merge_opt.branch1 = \"ours\";\n+\tnew_merge_opt.branch2 = \"theirs\";\n+\tif (repo_get_merge_bases(repo, replayed_par1, replayed_par2,\n+\t\t\t\t &replayed_bases) < 0) {\n+\t\tresult->clean = -1;\n+\t\tgoto out;\n+\t}\n+\tmerge_incore_recursive(&new_merge_opt, replayed_bases,\n+\t\t\t       replayed_par1, replayed_par2, &new_merge_res);\n+\treplayed_bases = NULL; /* consumed by merge_incore_recursive */\n+\tif (new_merge_res.clean < 0) {\n+\t\tresult->clean = new_merge_res.clean;\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.\n+\t */\n+\tpickme_tree = repo_get_commit_tree(repo, pickme);\n+\tancestor_name = xstrfmt(\"auto-remerge of %s\",\n+\t\t\t\toid_to_hex(&pickme->object.oid));\n+\tmerge_opt->ancestor = ancestor_name;\n+\tmerge_opt->branch1 = short_commit_name(repo, pickme);\n+\tmerge_opt->branch2 = \"merge of replayed parents\";\n+\tmerge_incore_nonrecursive(merge_opt,\n+\t\t\t\t  remerge_res.tree,\n+\t\t\t\t  pickme_tree,\n+\t\t\t\t  new_merge_res.tree,\n+\t\t\t\t  result);\n+\tmerge_opt->ancestor = NULL;\n+\tmerge_opt->branch1 = NULL;\n+\tmerge_opt->branch2 = NULL;\n+\tif (!result->clean)\n+\t\tgoto out;\n+\n+\tparents = NULL;\n+\tcommit_list_insert(replayed_par2, &parents);\n+\tcommit_list_insert(replayed_par1, &parents);\n+\tpicked = create_commit(repo, result->tree, pickme, parents,\n+\t\t\t       REPLAY_MODE_PICK);\n+\n+out:\n+\tfree(ancestor_name);\n+\tfree_commit_list(parent_bases);\n+\tfree_commit_list(replayed_bases);\n+\tmerge_finalize(&remerge_opt, &remerge_res);\n+\tmerge_finalize(&new_merge_opt, &new_merge_res);\n+\treturn picked;\n }\n \n void replay_result_release(struct replay_result *result)\n@@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,\n \tmerge_opt.show_rename_progress = 0;\n \tlast_commit = onto;\n \treplayed_commits = kh_init_oid_map();\n+\n+\t/*\n+\t * Seed the rewritten-commit map with each negative-side (\"BOTTOM\")\n+\t * cmdline entry pointing at `onto`. This matters for merge replay:\n+\t * a 2-parent merge whose first parent is the boundary (e.g. the\n+\t * commit being reworded) must replay onto the rewritten boundary,\n+\t * yet pick_merge_commit uses a self fallback so the second parent\n+\t * (a side branch outside the rewrite range) is preserved as-is.\n+\t * Pre-seeding the boundary disambiguates the two: in the map ->\n+\t * rewritten, missing -> kept as-is.\n+\t *\n+\t * Only do this for the pick path; revert mode chains reverts\n+\t * through last_commit and a pre-seeded boundary would short-circuit\n+\t * that chain.\n+\t */\n+\tif (mode == REPLAY_MODE_PICK) {\n+\t\tfor (size_t i = 0; i < revs->cmdline.nr; i++) {\n+\t\t\tstruct rev_cmdline_entry *e = &revs->cmdline.rev[i];\n+\t\t\tstruct commit *boundary;\n+\t\t\tkhint_t pos;\n+\t\t\tint hr;\n+\n+\t\t\tif (!(e->flags & BOTTOM))\n+\t\t\t\tcontinue;\n+\t\t\tboundary = lookup_commit_reference_gently(revs->repo,\n+\t\t\t\t\t\t\t\t  &e->item->oid, 1);\n+\t\t\tif (!boundary)\n+\t\t\t\tcontinue;\n+\t\t\tpos = kh_put_oid_map(replayed_commits,\n+\t\t\t\t\t     boundary->object.oid, &hr);\n+\t\t\tif (hr != 0)\n+\t\t\t\tkh_value(replayed_commits, pos) = onto;\n+\t\t}\n+\t}\n+\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-\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);\n+\t\tif (commit->parents && commit->parents->next) {\n+\t\t\tif (commit->parents->next->next) {\n+\t\t\t\tret = error(_(\"replaying octopus merges is not supported\"));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tif (mode == REPLAY_MODE_REVERT) {\n+\t\t\t\tret = error(_(\"reverting merge commits is not supported\"));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tlast_commit = pick_merge_commit(revs->repo, commit,\n+\t\t\t\t\t\t\treplayed_commits,\n+\t\t\t\t\t\t\t&merge_opt, &result);\n+\t\t} else {\n+\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n+\t\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n+\t\t\t\t\t\t\t  &merge_opt, &result, mode);\n+\t\t}\n \t\tif (!last_commit)\n \t\t\tbreak;\n \ndiff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh\nindex de7b357685..d103f866a2 100755\n--- a/t/t3451-history-reword.sh\n+++ b/t/t3451-history-reword.sh\n@@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '\n \t\tgit switch - &&\n \t\tgit merge theirs &&\n \n-\t\t# It is not possible to replay merge commits embedded in the\n-\t\t# history (yet).\n-\t\ttest_must_fail git -c core.editor=false history reword HEAD~ 2>err &&\n-\t\ttest_grep \"replaying merge commits is not supported yet\" err &&\n+\t\t# Reword a non-merge commit whose descendants include the\n+\t\t# merge: replay carries the merge through.\n+\t\treword_with_message HEAD~ <<-EOF &&\n+\t\tours reworded\n+\t\tEOF\n+\t\texpect_graph <<-EOF &&\n+\t\t*   Merge tag ${SQ}theirs${SQ}\n+\t\t|\\\\\n+\t\t| * theirs\n+\t\t* | ours reworded\n+\t\t|/\n+\t\t* base\n+\t\tEOF\n \n-\t\t# But it is possible to reword a merge commit directly.\n+\t\t# And reword a merge commit directly.\n \t\treword_with_message HEAD <<-EOF &&\n \t\tReworded merge commit\n \t\tEOF\n@@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '\n \t\t*   Reworded merge commit\n \t\t|\\\n \t\t| * theirs\n-\t\t* | ours\n+\t\t* | ours reworded\n \t\t|/\n \t\t* base\n \t\tEOF\ndiff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh\nindex 8ed0cebb50..ad6309f98b 100755\n--- a/t/t3452-history-split.sh\n+++ b/t/t3452-history-split.sh\n@@ -36,7 +36,7 @@ expect_tree_entries () {\n \ttest_cmp expect actual\n }\n \n-test_expect_success 'refuses to work with merge commits' '\n+test_expect_success 'refuses to split a merge commit' '\n \ttest_when_finished \"rm -rf repo\" &&\n \tgit init repo &&\n \t(\n@@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge commits' '\n \t\tgit switch - &&\n \t\tgit merge theirs &&\n \t\ttest_must_fail git history split HEAD 2>err &&\n-\t\ttest_grep \"cannot split up merge commit\" err &&\n-\t\ttest_must_fail git history split HEAD~ 2>err &&\n-\t\ttest_grep \"replaying merge commits is not supported yet\" err\n+\t\ttest_grep \"cannot split up merge commit\" err\n \t)\n '\n \ndiff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\nindex 3353bc4a4d..368b1b0f9a 100755\n--- a/t/t3650-replay-basics.sh\n+++ b/t/t3650-replay-basics.sh\n@@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ... ordering would be ill-defined' '\n \ttest_cmp expect actual\n '\n \n-test_expect_success 'replaying merge commits is not supported yet' '\n-\techo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n-\ttest_must_fail git replay --advance=main main..topic-with-merge 2>actual &&\n-\ttest_cmp expect actual\n+test_expect_success 'using replay to rebase a 2-parent merge' '\n+\t# main..topic-with-merge contains a 2-parent merge (P) introduced\n+\t# via test_merge. Use --ref-action=print so this test does not\n+\t# mutate state for subsequent tests in this file.\n+\tgit replay --ref-action=print --onto main main..topic-with-merge >result &&\n+\ttest_line_count = 1 result &&\n+\n+\tnew_tip=$(cut -f 3 -d \" \" result) &&\n+\n+\t# Result is still a 2-parent merge.\n+\tgit cat-file -p $new_tip >cat &&\n+\tgrep -c \"^parent \" cat >count &&\n+\techo 2 >expect &&\n+\ttest_cmp expect count &&\n+\n+\t# Merge subject is preserved.\n+\techo P >expect &&\n+\tgit log -1 --format=%s $new_tip >actual &&\n+\ttest_cmp expect actual &&\n+\n+\t# The replayed merge sits on top of main: walking back via the\n+\t# first-parent chain reaches main.\n+\tgit merge-base --is-ancestor main $new_tip\n+'\n+\n+test_expect_success 'replaying an octopus merge is rejected' '\n+\t# Build an octopus side-branch so the rest of the test state stays\n+\t# untouched.\n+\ttest_when_finished \"git update-ref -d refs/heads/octopus-tip\" &&\n+\toctopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \\\n+\t\t-m \"octopus\" $(git rev-parse topic4^{tree})) &&\n+\tgit update-ref refs/heads/octopus-tip \"$octopus_tip\" &&\n+\n+\ttest_must_fail git replay --ref-action=print --onto main \\\n+\t\ttopic4..octopus-tip 2>actual &&\n+\ttest_grep \"octopus merges\" actual\n+'\n+\n+test_expect_success 'reverting a merge commit is rejected' '\n+\ttest_must_fail git replay --ref-action=print --revert=topic-with-merge \\\n+\t\ttopic4..topic-with-merge 2>actual &&\n+\ttest_grep \"reverting merge commits\" actual\n '\n \n test_expect_success 'using replay to rebase two branches, one on top of other' '\n-- \ngitgitgadget\n\n"},{"id":"542823","messageId":"2f3d696104f7f634e2ca3bb51d57d5c57ffb0bbd.1778107405.git.gitgitgadget@gmail.com","threadId":"65603","inReplyTo":"pull.2106.git.1778107405.gitgitgadget@gmail.com","subject":"[PATCH/RFC 2/5] replay: short-circuit merge replay when parent and base trees are unchanged","fromName":"Johannes Schindelin via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-06T22:43:21Z","receivedAt":"2026-05-06T22:43:32Z","isPatch":true,"body":"From: Johannes Schindelin <johannes.schindelin@gmx.de>\n\nFor the common `git history reword` case the rewrite changes only\ncommit messages, so every commit on the line being replayed has the\nsame tree as before. When such a rewrite reaches a 2-parent merge\nwhose rewritten parents AND merge bases all carry the same trees as\nthe originals, the inner auto-merge of the rewritten parents (N) is\ntree-equal to the inner auto-merge of the original parents (R), and\nthe outer 3-way merge with R as the merge base, the original merge\ntree as side 1 and N as side 2 yields the original tree as result.\n\nDetect this in `pick_merge_commit()` before doing any merge work and\nwrite the new merge commit directly with the original tree and the\nrewritten parents. This saves two recursive merges and one\nnon-recursive merge per merge commit on the rewrite path, which\ndominates the cost of `git history reword` across histories with\nmany merges.\n\nThe merge-base trees must be checked too, in order. Tree-same\nparents over a tree-different base could still produce a different\nauto-merge (a conflict region that did not exist before, or vice\nversa), and the original resolution would be inappropriate to apply.\n\nTo avoid recomputing the merge bases when the fast path does not\napply, both pairs are computed up front and the slow path that\nfollows reuses them.\n\nAssisted-by: Claude Opus 4.7\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n---\n replay.c | 67 ++++++++++++++++++++++++++++++++++++++++++++++++--------\n 1 file changed, 58 insertions(+), 9 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex 3dbce095f9..5dfdef1447 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -396,6 +396,64 @@ static struct commit *pick_merge_commit(struct repository *repo,\n \treplayed_par1 = mapped_commit(replayed_commits, parent1, parent1);\n \treplayed_par2 = mapped_commit(replayed_commits, parent2, parent2);\n \n+\t/*\n+\t * Compute both pairs of merge bases up front. The fast path below\n+\t * needs them for the tree-equality check, and the slow path that\n+\t * follows reuses them to avoid recomputing.\n+\t */\n+\tif (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0 ||\n+\t    repo_get_merge_bases(repo, replayed_par1, replayed_par2,\n+\t\t\t\t &replayed_bases) < 0) {\n+\t\tresult->clean = -1;\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Fast path: when both rewritten parents carry the same trees as\n+\t * the originals AND every merge base does too (in order), the\n+\t * auto-merges R and N would be tree-equal (their inputs match\n+\t * content-wise), so the outer 3-way merge trivially yields the\n+\t * original merge's tree. Skip the inner merges and write the new\n+\t * merge commit directly.\n+\t *\n+\t * This is the common case for `git history reword`, which only\n+\t * changes commit messages and so leaves every tree on the line\n+\t * being replayed unchanged. The merge-base trees must be checked\n+\t * too: tree-same parents over a tree-different base could still\n+\t * produce a different auto-merge (a conflict region that did not\n+\t * exist before, or vice versa), and the original resolution would\n+\t * be inappropriate.\n+\t */\n+\tif (oideq(&repo_get_commit_tree(repo, parent1)->object.oid,\n+\t\t  &repo_get_commit_tree(repo, replayed_par1)->object.oid) &&\n+\t    oideq(&repo_get_commit_tree(repo, parent2)->object.oid,\n+\t\t  &repo_get_commit_tree(repo, replayed_par2)->object.oid)) {\n+\t\tstruct commit_list *bo, *bn;\n+\t\tint bases_match = 1;\n+\n+\t\tfor (bo = parent_bases, bn = replayed_bases;\n+\t\t     bo && bn;\n+\t\t     bo = bo->next, bn = bn->next) {\n+\t\t\tif (!oideq(&repo_get_commit_tree(repo, bo->item)->object.oid,\n+\t\t\t\t   &repo_get_commit_tree(repo, bn->item)->object.oid)) {\n+\t\t\t\tbases_match = 0;\n+\t\t\t\tbreak;\n+\t\t\t}\n+\t\t}\n+\t\tif (bo || bn)\n+\t\t\tbases_match = 0;\n+\n+\t\tif (bases_match) {\n+\t\t\tpickme_tree = repo_get_commit_tree(repo, pickme);\n+\t\t\tparents = NULL;\n+\t\t\tcommit_list_insert(replayed_par2, &parents);\n+\t\t\tcommit_list_insert(replayed_par1, &parents);\n+\t\t\tpicked = create_commit(repo, pickme_tree, pickme,\n+\t\t\t\t\t       parents, REPLAY_MODE_PICK);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n \t/*\n \t * R: auto-remerge of the original parents.\n \t *\n@@ -408,10 +466,6 @@ static struct commit *pick_merge_commit(struct repository *repo,\n \tremerge_opt.show_rename_progress = 0;\n \tremerge_opt.branch1 = \"ours\";\n \tremerge_opt.branch2 = \"theirs\";\n-\tif (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0) {\n-\t\tresult->clean = -1;\n-\t\tgoto out;\n-\t}\n \tmerge_incore_recursive(&remerge_opt, parent_bases,\n \t\t\t       parent1, parent2, &remerge_res);\n \tparent_bases = NULL; /* consumed by merge_incore_recursive */\n@@ -425,11 +479,6 @@ static struct commit *pick_merge_commit(struct repository *repo,\n \tnew_merge_opt.show_rename_progress = 0;\n \tnew_merge_opt.branch1 = \"ours\";\n \tnew_merge_opt.branch2 = \"theirs\";\n-\tif (repo_get_merge_bases(repo, replayed_par1, replayed_par2,\n-\t\t\t\t &replayed_bases) < 0) {\n-\t\tresult->clean = -1;\n-\t\tgoto out;\n-\t}\n \tmerge_incore_recursive(&new_merge_opt, replayed_bases,\n \t\t\t       replayed_par1, replayed_par2, &new_merge_res);\n \treplayed_bases = NULL; /* consumed by merge_incore_recursive */\n-- \ngitgitgadget\n\n"},{"id":"542825","messageId":"1dfbd26d757e7e610f8ef8058d839f103059b733.1778107405.git.gitgitgadget@gmail.com","threadId":"65603","inReplyTo":"pull.2106.git.1778107405.gitgitgadget@gmail.com","subject":"[PATCH/RFC 3/5] history.adoc: describe merge-replay support and its limits","fromName":"Johannes Schindelin via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-06T22:43:22Z","receivedAt":"2026-05-06T22:43:32Z","isPatch":true,"body":"From: Johannes Schindelin <johannes.schindelin@gmx.de>\n\nReplace the blanket \"does not (yet) work with histories that contain\nmerges\" caveat now that 2-parent merges are supported via the R/O/N\nalgorithm. Spell out what works (the user's manual conflict\nresolution and any semantic edits inside the merge are preserved\nthrough the replay), what is intentionally out of scope (octopus\nmerges; symbol-level extrapolation when rewriting parents pulls in\ngenuinely new content), and what still requires interactive rebase\n(merges that would actually conflict on replay).\n\nAssisted-by: Claude Opus 4.7\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n---\n Documentation/git-history.adoc | 27 ++++++++++++++++++++-------\n 1 file changed, 20 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 24dc907033..56328a7c59 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -40,13 +40,26 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n-\n-Furthermore, the command does not support operations that can result in merge\n-conflicts. This limitation is by design as history rewrites are not intended to\n-be stateful operations. The limitation can be lifted once (if) Git learns about\n-first-class conflicts.\n+This command supports two-parent merge commits in the rewrite path:\n+the auto-remerged tree of the original parents, the merge commit\n+itself, and the auto-merged tree of the rewritten parents are\n+combined so that the user's manual conflict resolution (textual or\n+semantic) is preserved through the replay. Octopus merges (more than\n+two parents) are not supported and are rejected with an error.\n+\n+The replay propagates the textual diffs the user actually made in\n+the merge commit. It does _not_ extrapolate symbol-level intent: if\n+rewriting the parents pulls in genuinely new content (for example, a\n+new caller of a function that the merge renamed), that new content\n+is _not_ rewritten by the replay and may need a follow-up edit.\n+Symbol-aware refactoring is out of scope here, just as it is for\n+plain rebase.\n+\n+The command does not support operations that can result in merge\n+conflicts on the replayed merge itself. This limitation is by design\n+as history rewrites are not intended to be stateful operations. Use\n+linkgit:git-rebase[1] with the `--rebase-merges` flag when the\n+rewrite is expected to require interactive conflict resolution.\n \n COMMANDS\n --------\n-- \ngitgitgadget\n\n"},{"id":"542824","messageId":"2dec28b43a8c12e9d0cb309945c06d927833bcf3.1778107405.git.gitgitgadget@gmail.com","threadId":"65603","inReplyTo":"pull.2106.git.1778107405.gitgitgadget@gmail.com","subject":"[PATCH/RFC 5/5] t3454: cover merge-replay scenarios with the historian helper","fromName":"Johannes Schindelin via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-06T22:43:24Z","receivedAt":"2026-05-06T22:43:37Z","isPatch":true,"body":"From: Johannes Schindelin <johannes.schindelin@gmx.de>\n\nAdd a dedicated test script for `git history reword` (and\n`git replay` via the same code path) across 2-parent merges, using\nthe `test-tool historian` fixture builder so each scenario reads as\na small declarative recipe rather than a sequence of plumbing\ncommands.\n\nThe script exercises the cases that motivated the merge-replay\nwork:\n\n  * a clean merge where each side touches unrelated files;\n\n  * a non-trivial merge where the same line was changed on both\n    sides and the user resolved by hand (textual manual resolution\n    must be preserved through the replay);\n\n  * a non-trivial merge where the user also touched a line outside\n    any conflict region (a \"semantic\" edit must also be preserved\n    through the replay);\n\n  * an octopus merge in the rewrite path, which is rejected;\n\n  * a function rename across the merge with a brand-new caller\n    introduced by the rewritten parents. The pre-existing caller\n    that the user manually renamed in the original merge must keep\n    its rename, and the brand-new caller must _not_ be rewritten\n    (calvin/hobbes naming chosen for legibility). This second part\n    is the documented limitation: the replay propagates the textual\n    diffs the user actually made, it does not extrapolate\n    symbol-level intent. Symbol-aware refactoring is out of scope,\n    just as it is for plain rebase.\n\nThe fixture builder lets each scenario sit in roughly a dozen lines\nof historian directives plus the assertions, which keeps the test\nfile readable when more scenarios are added later.\n\nAssisted-by: Claude Opus 4.7\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n---\n t/meson.build             |   1 +\n t/t3454-history-merges.sh | 308 ++++++++++++++++++++++++++++++++++++++\n 2 files changed, 309 insertions(+)\n create mode 100755 t/t3454-history-merges.sh\n\ndiff --git a/t/meson.build b/t/meson.build\nindex 7528e5cda5..25b0119d43 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -397,6 +397,7 @@ integration_tests = [\n   't3450-history.sh',\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n+  't3454-history-merges.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3454-history-merges.sh b/t/t3454-history-merges.sh\nnew file mode 100755\nindex 0000000000..2eb3c947eb\n--- /dev/null\n+++ b/t/t3454-history-merges.sh\n@@ -0,0 +1,308 @@\n+#!/bin/sh\n+\n+test_description='git history reword across merge commits\n+\n+Exercises the merge-replay path in `git history reword` using the\n+`test-tool historian` test fixture builder so each scenario is\n+described in a small declarative input rather than a sprawling\n+sequence of plumbing commands. The interesting cases are:\n+\n+  * a clean merge with each side touching unrelated files;\n+  * a non-trivial merge whose conflicting line was resolved by hand\n+    (textually) and whose resolution must be preserved through the\n+    replay;\n+  * a non-trivial merge with a manual *semantic* edit (an additional\n+    change outside the conflict region) that must also be preserved.\n+'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+# Replace the commit's message via a fake editor and run reword.\n+reword_to () {\n+\tnew_msg=\"$1\"\n+\ttarget=\"$2\"\n+\twrite_script fake-editor.sh <<-EOF &&\n+\techo \"$new_msg\" >\"\\$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/fake-editor.sh\" &&\n+\tgit history reword \"$target\" &&\n+\trm fake-editor.sh\n+}\n+\n+build_clean_merge () {\n+\ttest-tool historian <<-\\EOF\n+\t# Setup:\n+\t#       A (a) --- C (a, h) ----+--- M (a, g, h)\n+\t#        \\                    /\n+\t#         +-- B (a, g) ------+\n+\t#\n+\t# Topic touches `g` only; main touches `h` only. The auto-merge\n+\t# at M is clean.\n+\tblob a \"shared content\"\n+\tblob g guarded\n+\tblob h host\n+\tcommit A main \"A\" a=a\n+\tcommit B topic \"B (introduces g)\" from=A a=a g=g\n+\tcommit C main \"C (introduces h)\" a=a h=h\n+\tcommit M main \"Merge topic\" merge=B a=a g=g h=h\n+\tEOF\n+}\n+\n+test_expect_success 'clean merge: both sides touch unrelated files' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tbuild_clean_merge &&\n+\n+\t\treword_to \"AA\" A &&\n+\n+\t\t# The merge is still a 2-parent merge with the same subject\n+\t\t# and tree (clean replay leaves content unchanged).\n+\t\ttest_cmp_rev HEAD^{tree} M^{tree} &&\n+\n+\t\techo \"Merge topic\" >expect-subject &&\n+\t\tgit log -1 --format=%s HEAD >subject &&\n+\t\ttest_cmp expect-subject subject &&\n+\n+\t\tgit rev-list --merges HEAD~..HEAD >merges &&\n+\t\ttest_line_count = 1 merges\n+\t)\n+'\n+\n+build_textual_resolution () {\n+\ttest-tool historian <<-\\EOF\n+\t# Both sides change the same line of `a`; the user resolved with\n+\t# their own combined text, recorded directly as the merge tree.\n+\tblob a_v1 line1 line2 line3\n+\tblob a_main line1 line2-main line3\n+\tblob a_topic line1 line2-topic line3\n+\tblob a_resolution line1 line2-merged-by-hand line3\n+\tcommit A main \"A\" a=a_v1\n+\tcommit B topic \"B (line2 on topic)\" from=A a=a_topic\n+\tcommit C main \"C (line2 on main)\" a=a_main\n+\tcommit M main \"Merge topic\" merge=B a=a_resolution\n+\tEOF\n+}\n+\n+test_expect_success 'non-trivial merge: textual manual resolution is preserved' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tbuild_textual_resolution &&\n+\n+\t\treword_to \"AA\" A &&\n+\n+\t\tgit show HEAD:a >after &&\n+\t\ttest_write_lines line1 line2-merged-by-hand line3 >expect &&\n+\t\ttest_cmp expect after\n+\t)\n+'\n+\n+build_semantic_edit () {\n+\ttest-tool historian <<-\\EOF\n+\t# Topic and main conflict on line2 of `a`. The user's resolution\n+\t# at M not only picks combined text on line2 but ALSO touches\n+\t# line5 (a \"semantic\" edit outside any conflict region) -- this\n+\t# kind of edit is invisible to a naive pick-one-side strategy and\n+\t# must be preserved by replay.\n+\tblob a_v1 line1 line2 line3 line4 line5\n+\tblob a_main line1 line2-main line3 line4 line5\n+\tblob a_topic line1 line2-topic line3 line4 line5\n+\tblob a_resolution line1 line2-merged line3 line4 line5-touched\n+\tcommit A main \"A\" a=a_v1\n+\tcommit B topic \"B (line2 on topic)\" from=A a=a_topic\n+\tcommit C main \"C (line2 on main)\" a=a_main\n+\tcommit M main \"Merge topic\" merge=B a=a_resolution\n+\tEOF\n+}\n+\n+test_expect_success 'non-trivial merge: semantic edit outside conflict region is preserved' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tbuild_semantic_edit &&\n+\n+\t\treword_to \"AA\" A &&\n+\n+\t\tgit show HEAD:a >after &&\n+\t\ttest_write_lines line1 line2-merged line3 line4 line5-touched \\\n+\t\t\t>expect &&\n+\t\ttest_cmp expect after\n+\t)\n+'\n+\n+build_octopus () {\n+\ttest-tool historian <<-\\EOF\n+\tblob a \"x\"\n+\tcommit A main \"A\" a=a\n+\tcommit B b1 \"B\" from=A a=a\n+\tcommit C b2 \"C\" from=A a=a\n+\tcommit D b3 \"D\" from=A a=a\n+\tcommit O main \"octopus\" merge=B merge=C merge=D a=a\n+\tEOF\n+}\n+\n+test_expect_success 'octopus merge in the rewrite path is rejected' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tbuild_octopus &&\n+\n+\t\ttest_must_fail git -c core.editor=true history reword \\\n+\t\t\t--dry-run A 2>err &&\n+\t\ttest_grep \"octopus\" err\n+\t)\n+'\n+\n+build_with_boundary_other_than_onto () {\n+\ttest-tool historian <<-\\EOF\n+\t# Setup an \"evil merge\" topology where the rewrite range crosses\n+\t# a 2-parent merge whose first parent sits outside that range:\n+\t#\n+\t#   side -- O (a=v0)\n+\t#            \\\n+\t#             M (parent1=O, parent2=R, a=v0, s=top)\n+\t#            /\n+\t#   A (a=v0) -- R (a=v0) -- T (a=v0, s=top)\n+\t#       |\n+\t#       reword target\n+\t#\n+\t# The walk for `history reword A` excludes A and its ancestors,\n+\t# so O sits outside the rewrite range and is not the boundary\n+\t# either. Replaying M correctly requires that first parent to\n+\t# remain at O (preserve, not replant).\n+\tblob v0 line1 line2 line3\n+\tblob top \"marker\"\n+\tcommit X side \"X\" v0=v0\n+\tcommit O side \"O\" v0=v0\n+\tcommit A main \"A\" from=X v0=v0\n+\tcommit R main \"R\" v0=v0\n+\tcommit M main \"Merge side into main\" from=O merge=R v0=v0 s=top\n+\tcommit T main \"T\" v0=v0 s=top\n+\tEOF\n+}\n+\n+# A descendant merge whose first parent sits outside the rewrite\n+# range is a topology that any reasonable replay of merges has to\n+# handle correctly: the first parent must be preserved verbatim,\n+# while the in-range second parent is rewritten. Without that, the\n+# replayed merge would silently graft itself onto a different\n+# ancestry than the author chose, which is far worse than a loud\n+# failure.\n+test_expect_success 'merge whose first parent sits outside the rewrite range keeps that parent' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tbuild_with_boundary_other_than_onto &&\n+\n+\t\treword_to \"AA\" A &&\n+\n+\t\t# The replayed M (now HEAD~) is still a 2-parent merge.\n+\t\t# Its first parent is the original O (preserved, outside\n+\t\t# the rewrite range), its second parent is the rewritten\n+\t\t# R. T was rebased on top of M, so HEAD = T.\n+\t\tgit rev-list --parents -1 HEAD~ >parents &&\n+\t\tnew_p1=$(awk \"{print \\$2}\" parents) &&\n+\t\tnew_p2=$(awk \"{print \\$3}\" parents) &&\n+\n+\t\t# First parent is preserved verbatim.\n+\t\ttest_cmp_rev O $new_p1 &&\n+\n+\t\t# Second parent is the rewritten R: a fresh commit whose\n+\t\t# subject is still \"R\" but whose OID differs from the\n+\t\t# original (because its parent A is now reworded).\n+\t\techo R >expect &&\n+\t\tgit log -1 --format=%s $new_p2 >actual &&\n+\t\ttest_cmp expect actual &&\n+\t\t! test_cmp_rev R $new_p2 &&\n+\n+\t\t# T was rebased on top of the new M, and its tree still\n+\t\t# contains the s=top marker introduced in the original M.\n+\t\techo \"marker\" >expect &&\n+\t\tgit show HEAD:s >actual &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+build_function_rename () {\n+\ttest-tool historian <<-\\EOF\n+\t# Topic renames harry() -> hermione() (defs.h plus caller1). main\n+\t# adds caller2 calling harry(); the original merge M manually\n+\t# renames caller2 to hermione(). The \"newer\" base on a side branch\n+\t# contains caller2 AND a brand-new caller3 calling harry();\n+\t# replaying onto `newer` therefore introduces caller3 into the\n+\t# merged tree.\n+\tblob defs_harry \"void harry(void);\"\n+\tblob defs_hermione \"void hermione(void);\"\n+\tblob harry_call \"harry();\"\n+\tblob hermione_call \"hermione();\"\n+\tcommit A main \"A\" defs.h=defs_harry caller1=harry_call\n+\tcommit B topic \"B (rename)\" from=A defs.h=defs_hermione caller1=hermione_call\n+\tcommit C main \"C (caller2 calls harry)\" defs.h=defs_harry caller1=harry_call caller2=harry_call\n+\tcommit M main \"Merge topic\" merge=B defs.h=defs_hermione caller1=hermione_call caller2=hermione_call\n+\tcommit NEW newer \"newer base with caller3\" from=A defs.h=defs_harry caller1=harry_call caller2=harry_call caller3=harry_call\n+\tEOF\n+}\n+\n+# This case checks two things at once. First, the manual semantic\n+# edit in M (renaming caller2) must be preserved when we replay onto\n+# a different base; that is the case `git history` and `git replay`\n+# need to handle correctly, even though nothing in the conflict\n+# markers tells us about it. Second, a file that only enters the\n+# tree via the rewritten parents (caller3, present on the `newer`\n+# base) is _not_ renamed by the replay. The replay propagates the\n+# textual diffs the user actually made in M; it does _not_ infer\n+# the user's symbol-level intent (\"rename every caller of harry\").\n+# This is a known and intentional limitation. Symbol-aware\n+# refactoring is out of scope here, just as it is for plain rebase.\n+test_expect_success 'preserves manual rename of pre-existing caller; does not extrapolate to new files' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tbuild_function_rename &&\n+\n+\t\t# Replay (C, B, M) onto the newer base. A `main..M` style\n+\t\t# range across two unrelated branches is awkward; spin up a\n+\t\t# temp branch and use --advance.\n+\t\tgit branch tmp main &&\n+\t\tgit replay --ref-action=print --onto NEW A..tmp >result &&\n+\t\tnew_tip=$(cut -f 3 -d \" \" result) &&\n+\n+\t\t# defs.h and caller1 came from B (clean cherry-pick of the\n+\t\t# rename commit) and must reflect the rename.\n+\t\techo \"void hermione(void);\" >expect &&\n+\t\tgit show $new_tip:defs.h >actual &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\techo \"hermione();\" >expect &&\n+\t\tgit show $new_tip:caller1 >actual &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\t# caller2 existed in the original M; its manual rename to\n+\t\t# hermione() is the semantic edit the replay must preserve.\n+\t\techo \"hermione();\" >expect &&\n+\t\tgit show $new_tip:caller2 >actual &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\t# caller3 only exists on the newer base, so it was brought\n+\t\t# in by N (the auto-merge of the rewritten parents). The\n+\t\t# replay has no way to know the user intended to rename\n+\t\t# every caller; caller3 keeps harry(). The resulting tree\n+\t\t# is therefore _not_ symbol-correct and needs a follow-up\n+\t\t# edit. This is the documented limitation.\n+\t\techo \"harry();\" >expect &&\n+\t\tgit show $new_tip:caller3 >actual &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_done\n-- \ngitgitgadget\n"},{"id":"542826","messageId":"72c486312cde9a9fd2dedb60bc43c5c3e40a0d64.1778107405.git.gitgitgadget@gmail.com","threadId":"65603","inReplyTo":"pull.2106.git.1778107405.gitgitgadget@gmail.com","subject":"[PATCH/RFC 4/5] test-tool: add a \"historian\" subcommand for building merge fixtures","fromName":"Johannes Schindelin via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-06T22:43:23Z","receivedAt":"2026-05-06T22:43:37Z","isPatch":true,"body":"From: Johannes Schindelin <johannes.schindelin@gmx.de>\n\nThe merge-replay tests added in a follow-up commit need a way to set\nup specific topologies with full control over blob contents, parent\norder, and per-side trees. Sequencing plumbing commands or driving\nplain `git fast-import` from shell quickly becomes unreadable for\nthe kinds of scenarios that exercise non-trivial merge resolution\n(textual conflicts, semantic edits outside the conflict region,\nintentional limitations such as new content on one side).\n\nAdd a small `test-tool historian` subcommand that reads a tight,\nshell-quoted, one-line-per-object DSL and feeds an equivalent stream\nto a `git fast-import` child process. Each blob and commit is given\na logical name; the helper allocates fast-import marks on first use\nand emits a lightweight tag for every commit so tests can refer to\nthe resulting object via `refs/tags/<name>`.\n\nThe DSL has just two directives:\n\n  blob NAME LINE...\n  commit NAME BRANCH SUBJECT [from=NAME] [merge=NAME]... [PATH=BLOB]...\n\nA blob's content is the listed lines joined with `\\n` (and a final\n`\\n`); a commit's tree is exactly the listed PATH=BLOB pairs (the\nhelper emits a `deleteall` so nothing leaks in from the implicit\nparent). Token splitting is delegated to `split_cmdline()` so quoted\narguments work as in shell. Marks for parent references and file\ncontents go through the same `strintmap`-backed name resolver, which\nkeeps the helper itself trivially small: blob writing, tree\nconstruction, commit creation and merge-base computation are all\nhandled by `git fast-import`.\n\nNote that the DSL reserves the names `from` and `merge` (with a\ntrailing `=`) for parent specification; a tree path called `from` or\n`merge` cannot be expressed via this helper. That is acceptable here\nbecause every input is a tightly controlled test fixture and the\nfilenames are chosen by the test author.\n\nThe helper trusts its caller: malformed input results in a\nfast-import error rather than a friendly diagnostic.\n\nWire the new subcommand into the Makefile and meson build, register\nit in `t/helper/test-tool.{c,h}`.\n\nAssisted-by: Claude Opus 4.7\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n---\n Makefile                  |   1 +\n t/helper/meson.build      |   1 +\n t/helper/test-historian.c | 189 ++++++++++++++++++++++++++++++++++++++\n t/helper/test-tool.c      |   1 +\n t/helper/test-tool.h      |   1 +\n 5 files changed, 193 insertions(+)\n create mode 100644 t/helper/test-historian.c\n\ndiff --git a/Makefile b/Makefile\nindex cedc234173..b38678b484 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -832,6 +832,7 @@ TEST_BUILTINS_OBJS += test-hash-speed.o\n TEST_BUILTINS_OBJS += test-hash.o\n TEST_BUILTINS_OBJS += test-hashmap.o\n TEST_BUILTINS_OBJS += test-hexdump.o\n+TEST_BUILTINS_OBJS += test-historian.o\n TEST_BUILTINS_OBJS += test-json-writer.o\n TEST_BUILTINS_OBJS += test-lazy-init-name-hash.o\n TEST_BUILTINS_OBJS += test-match-trees.o\ndiff --git a/t/helper/meson.build b/t/helper/meson.build\nindex 675e64c010..704edd1e1f 100644\n--- a/t/helper/meson.build\n+++ b/t/helper/meson.build\n@@ -29,6 +29,7 @@ test_tool_sources = [\n   'test-hash.c',\n   'test-hashmap.c',\n   'test-hexdump.c',\n+  'test-historian.c',\n   'test-json-writer.c',\n   'test-lazy-init-name-hash.c',\n   'test-match-trees.c',\ndiff --git a/t/helper/test-historian.c b/t/helper/test-historian.c\nnew file mode 100644\nindex 0000000000..2250d420c0\n--- /dev/null\n+++ b/t/helper/test-historian.c\n@@ -0,0 +1,189 @@\n+/*\n+ * Build a small history out of a tiny declarative input. Used by tests\n+ * that need specific merge topologies without long sequences of\n+ * plumbing commands or fragile shell helpers.\n+ *\n+ * The historian reads stdin line by line and emits an equivalent\n+ * stream to a `git fast-import` child process. It also allocates marks\n+ * for named objects so tests can refer to commits and blobs by name.\n+ *\n+ * Input directives (one per line, shell-style quoting):\n+ *\n+ *\tblob NAME LINE1 LINE2 ...\n+ *\t    Each LINE becomes a content line in the blob; lines are\n+ *\t    joined with '\\n' and the blob ends with a final '\\n'. With\n+ *\t    no LINEs, the blob is empty.\n+ *\n+ *\tcommit NAME BRANCH SUBJECT [from=PARENT] [merge=PARENT]... [PATH=BLOB]...\n+ *\t    Creates a commit on refs/heads/BRANCH using the listed\n+ *\t    file=blob mappings as the entire tree (no inheritance from\n+ *\t    parents). Up to one `from=` and any number of `merge=`\n+ *\t    parents may be given. `from=` defaults to the current branch\n+ *\t    tip; if BRANCH has no tip yet, the commit becomes a root.\n+ *\n+ * Each `commit NAME` directive also creates a lightweight tag\n+ * `refs/tags/NAME` so tests can `git rev-parse NAME`.\n+ *\n+ * This helper trusts its caller; malformed input results in fast-import\n+ * errors. That is fine because test scripts feed it tightly controlled\n+ * input.\n+ */\n+\n+#define USE_THE_REPOSITORY_VARIABLE\n+\n+#include \"test-tool.h\"\n+#include \"git-compat-util.h\"\n+#include \"alias.h\"\n+#include \"run-command.h\"\n+#include \"setup.h\"\n+#include \"strbuf.h\"\n+#include \"strmap.h\"\n+#include \"strvec.h\"\n+\n+static int next_mark = 1;\n+\n+static int resolve_mark(struct strintmap *names, const char *name)\n+{\n+\tint n = strintmap_get(names, name);\n+\tif (!n) {\n+\t\tn = next_mark++;\n+\t\tstrintmap_set(names, name, n);\n+\t}\n+\treturn n;\n+}\n+\n+static void emit_data(FILE *out, const char *data, size_t len)\n+{\n+\tfprintf(out, \"data %\"PRIuMAX\"\\n\", (uintmax_t)len);\n+\tfwrite(data, 1, len, out);\n+\tfputc('\\n', out);\n+}\n+\n+static void emit_blob(FILE *out, struct strintmap *names,\n+\t\t      int argc, const char **argv)\n+{\n+\tstruct strbuf content = STRBUF_INIT;\n+\tint n = resolve_mark(names, argv[1]);\n+\tint i;\n+\n+\tfor (i = 2; i < argc; i++) {\n+\t\tstrbuf_addstr(&content, argv[i]);\n+\t\tstrbuf_addch(&content, '\\n');\n+\t}\n+\n+\tfprintf(out, \"blob\\nmark :%d\\n\", n);\n+\temit_data(out, content.buf, content.len);\n+\tstrbuf_release(&content);\n+}\n+\n+static void emit_tag(FILE *out, const char *name, int mark)\n+{\n+\tfprintf(out, \"reset refs/tags/%s\\nfrom :%d\\n\\n\", name, mark);\n+}\n+\n+static void emit_commit(FILE *out, struct strintmap *names,\n+\t\t\tint argc, const char **argv, int seq)\n+{\n+\tint n = resolve_mark(names, argv[1]);\n+\tconst char *branch = argv[2];\n+\tconst char *subject = argv[3];\n+\tconst char *rest;\n+\tint i;\n+\n+\tfprintf(out, \"commit refs/heads/%s\\nmark :%d\\n\", branch, n);\n+\tfprintf(out, \"author A <a@e> %d +0000\\n\", 1700000000 + seq);\n+\tfprintf(out, \"committer A <a@e> %d +0000\\n\", 1700000000 + seq);\n+\temit_data(out, subject, strlen(subject));\n+\n+\t/*\n+\t * fast-import requires `from` and `merge` to precede all file\n+\t * operations; emit them first regardless of argv ordering.\n+\t */\n+\tfor (i = 4; i < argc; i++) {\n+\t\tif (skip_prefix(argv[i], \"from=\", &rest))\n+\t\t\tfprintf(out, \"from :%d\\n\", resolve_mark(names, rest));\n+\t\telse if (skip_prefix(argv[i], \"merge=\", &rest))\n+\t\t\tfprintf(out, \"merge :%d\\n\", resolve_mark(names, rest));\n+\t}\n+\n+\t/*\n+\t * The PATH=BLOB list is the entire tree; wipe whatever the\n+\t * implicit parent contributed before re-applying it.\n+\t */\n+\tfprintf(out, \"deleteall\\n\");\n+\tfor (i = 4; i < argc; i++) {\n+\t\tconst char *eq;\n+\t\tsize_t key_len;\n+\t\tchar *path;\n+\n+\t\tif (skip_prefix(argv[i], \"from=\", &rest) ||\n+\t\t    skip_prefix(argv[i], \"merge=\", &rest))\n+\t\t\tcontinue;\n+\t\teq = strchr(argv[i], '=');\n+\t\tif (!eq)\n+\t\t\tdie(\"bad commit spec '%s'\", argv[i]);\n+\t\tkey_len = eq - argv[i];\n+\t\tpath = xmemdupz(argv[i], key_len);\n+\t\tfprintf(out, \"M 100644 :%d %s\\n\",\n+\t\t\tresolve_mark(names, eq + 1), path);\n+\t\tfree(path);\n+\t}\n+\n+\tfputc('\\n', out);\n+\temit_tag(out, argv[1], n);\n+}\n+\n+int cmd__historian(int argc, const char **argv UNUSED)\n+{\n+\tstruct child_process fi = CHILD_PROCESS_INIT;\n+\tstruct strintmap names = STRINTMAP_INIT;\n+\tstruct strbuf line = STRBUF_INIT;\n+\tint seq = 0;\n+\tint ret = 0;\n+\tFILE *fi_in;\n+\n+\tif (argc != 1)\n+\t\tdie(\"usage: test-tool historian <input\");\n+\n+\tsetup_git_directory();\n+\n+\tstrvec_pushl(&fi.args, \"fast-import\", \"--quiet\", \"--force\", NULL);\n+\tfi.git_cmd = 1;\n+\tfi.in = -1;\n+\tfi.no_stdout = 1;\n+\tif (start_command(&fi))\n+\t\tdie(\"failed to start git fast-import\");\n+\tfi_in = xfdopen(fi.in, \"w\");\n+\n+\twhile (strbuf_getline_lf(&line, stdin) != EOF) {\n+\t\tconst char **a = NULL;\n+\t\tint n;\n+\n+\t\tstrbuf_trim(&line);\n+\t\tif (!line.len || line.buf[0] == '#')\n+\t\t\tcontinue;\n+\n+\t\tn = split_cmdline(line.buf, &a);\n+\t\tif (n < 0)\n+\t\t\tdie(\"split_cmdline failed: %s\",\n+\t\t\t    split_cmdline_strerror(n));\n+\n+\t\tif (n >= 2 && !strcmp(a[0], \"blob\"))\n+\t\t\temit_blob(fi_in, &names, n, a);\n+\t\telse if (n >= 4 && !strcmp(a[0], \"commit\"))\n+\t\t\temit_commit(fi_in, &names, n, a, seq++);\n+\t\telse\n+\t\t\tdie(\"unknown directive: %s\", a[0]);\n+\n+\t\tfree(a);\n+\t}\n+\n+\tif (fclose(fi_in))\n+\t\tdie_errno(\"close fast-import stdin\");\n+\tif (finish_command(&fi))\n+\t\tret = 1;\n+\n+\tstrbuf_release(&line);\n+\tstrintmap_clear(&names);\n+\treturn ret;\n+}\ndiff --git a/t/helper/test-tool.c b/t/helper/test-tool.c\nindex a7abc618b3..28bde98ce1 100644\n--- a/t/helper/test-tool.c\n+++ b/t/helper/test-tool.c\n@@ -39,6 +39,7 @@ static struct test_cmd cmds[] = {\n \t{ \"hashmap\", cmd__hashmap },\n \t{ \"hash-speed\", cmd__hash_speed },\n \t{ \"hexdump\", cmd__hexdump },\n+\t{ \"historian\", cmd__historian },\n \t{ \"json-writer\", cmd__json_writer },\n \t{ \"lazy-init-name-hash\", cmd__lazy_init_name_hash },\n \t{ \"match-trees\", cmd__match_trees },\ndiff --git a/t/helper/test-tool.h b/t/helper/test-tool.h\nindex 7f150fa1eb..78cec8594a 100644\n--- a/t/helper/test-tool.h\n+++ b/t/helper/test-tool.h\n@@ -32,6 +32,7 @@ int cmd__getcwd(int argc, const char **argv);\n int cmd__hashmap(int argc, const char **argv);\n int cmd__hash_speed(int argc, const char **argv);\n int cmd__hexdump(int argc, const char **argv);\n+int cmd__historian(int argc, const char **argv);\n int cmd__json_writer(int argc, const char **argv);\n int cmd__lazy_init_name_hash(int argc, const char **argv);\n int cmd__match_trees(int argc, const char **argv);\n-- \ngitgitgadget\n\n"},{"id":"542848","messageId":"CALnO6CDJgUEiEgG=4r_F4jeyrHSsSpwD0X8rZzh+EScL+vJn7g@mail.gmail.com","threadId":"65603","inReplyTo":"pull.2106.git.1778107405.gitgitgadget@gmail.com","subject":"Re: [PATCH/RFC 0/5] replay: support replaying 2-parent merges","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-05-07T14:14:44Z","receivedAt":"2026-05-07T14:14:56Z","isPatch":true,"body":"Hi Dscho,\n\nOn Wed, May 6, 2026 at 6:44 PM Johannes Schindelin via GitGitGadget\n<gitgitgadget@gmail.com> wrote:\n>\n> git history, the new history-rewriting builtin in v2.54, dies on any merge\n> in the rewrite path with replaying merge commits is not supported yet!. That\n> makes it not very useful for the workflows I actually have, where almost\n> every interesting branch contains at least one merge of a feature topic. The\n> natural fallback, git rebase --rebase-merges, is interactive and stops to\n> ask for re-resolution even when no re-resolution is needed.\n>\n> This series lifts that limitation for the common 2-parent case. The\n> algorithm itself is not new: Elijah Newren wrote it down in his replay\n> design notes\n> [https://github.com/newren/git/blob/replay/replay-design-notes.txt] and\n> prototyped it in a 2022 work-in-progress sketch\n> [https://github.com/newren/git/commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45].\n> What is new is wiring it into the replay_revisions() API that backs both git\n> replay and git history, plus three specific tweaks that make the trickier\n> cases work where the WIP sketch bailed out: identical conflict-marker labels\n> for the inner remerges of the original and the rewritten parents (so their\n> conflict-markered trees compare equal in the regions the user did not\n> touch), tolerating result.clean == 0 from those inner merges (their\n> well-defined conflict-markered trees are valid inputs to the outer 3-way\n> merge), and self-fallback for both merge parents combined with mapping the\n> rev-range boundary commits to the onto commit.\n>\n> Octopus merges and revert-of-merge are surfaced as explicit errors at the\n> dispatch point. The split sub-command of git history continues to refuse\n> when its target is a merge: split semantics simply do not apply there. The\n> xdiff special mode for matching conflict-marker hunks across inner remerges,\n> the XDL_MERGE_FAVOR_BASE variant, and the modify/delete and binary-file\n> specials that the design notes flag as future work all remain future work.\n>\n> While I was at it, git history reword had a pre-existing silent-success bug:\n> a positive return from replay_revisions() (which means \"conflict, no updates\n> queued\") was treated as success. Obviously this should never occur, as a\n> reword simply does not change any of the file contents, but bugs do happen.\n> The merge-replay work is complex enough to make that class of bugs more\n> likely, therefore I introduce error messages for those instances.\n\nFixing this bug sounded interesting; I had a hard time spotting it\nwhile skimming the first 2 patches.\n\nDid I just miss it? Is it worth splitting that fix out to a separate patch?\n\nBest,\n-- \nD. Ben Knoble\n"},{"id":"542852","messageId":"4a94c675-661c-1f2a-27d0-3f10f761cf6a@gmx.de","threadId":"65603","inReplyTo":"CALnO6CDJgUEiEgG=4r_F4jeyrHSsSpwD0X8rZzh+EScL+vJn7g@mail.gmail.com","subject":"Re: [PATCH/RFC 0/5] replay: support replaying 2-parent merges","fromName":"Johannes Schindelin","fromEmail":"johannes.schindelin@gmx.de","sentAt":"2026-05-07T15:06:08Z","receivedAt":"2026-05-07T15:06:16Z","isPatch":true,"body":"Hi Ben,\n\nOn Thu, 7 May 2026, D. Ben Knoble wrote:\n\n> On Wed, May 6, 2026 at 6:44 PM Johannes Schindelin via GitGitGadget\n> <gitgitgadget@gmail.com> wrote:\n> >\n> > [...]\n> >\n> > While I was at it, git history reword had a pre-existing\n> > silent-success bug: a positive return from replay_revisions() (which\n> > means \"conflict, no updates queued\") was treated as success. Obviously\n> > this should never occur, as a reword simply does not change any of the\n> > file contents, but bugs do happen. The merge-replay work is complex\n> > enough to make that class of bugs more likely, therefore I introduce\n> > error messages for those instances.\n> \n> Fixing this bug sounded interesting; I had a hard time spotting it\n> while skimming the first 2 patches.\n\nIt's this part:\n\n@@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,\n \tif (ret < 0) {\n \t\tret = error(_(\"failed replaying descendants\"));\n \t\tgoto out;\n+\t} else if (ret) {\n+\t\tret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n+\t\tgoto out;\n \t}\n \n \tret = 0;\n@@ -721,6 +724,9 @@ static int cmd_history_split(int argc,\n \tif (ret < 0) {\n \t\tret = error(_(\"failed replaying descendants\"));\n \t\tgoto out;\n+\t} else if (ret) {\n+\t\tret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n+\t\tgoto out;\n \t}\n \n \tret = 0;\n\n> Did I just miss it? Is it worth splitting that fix out to a separate patch?\n\nWell, you _could_ argue that they were not bugs at all: a `git history\nreword` isn't supposed to be able to result in merge conflicts, nor is\n`git history split` because they leave the respective commits tree-same\n(in the `split` case, the second commit).\n\nI could see the point were anybody to suggest using `BUG()` instead of\n`error()` here, but erred on the \"nicer to the user\" side.\n\nThe only way this _might_ be triggered before this patch series is most\nlikely by playing games with replace objects. Or maybe you cannot trigger\nit at all.\n\nWith the changes in this here patch series, I wasn't so certain that I had\ncovered all the edge cases (an early iteration of the quick short-cut in\npatch 2/5 keyed only on the parent commits' trees, and forgot to verify\nthe merge _bases_' trees, for example). That's why I think it matters more\nnow than it did before.\n\nCiao,\nJohannes\n"},{"id":"542853","messageId":"21A507D3-1B0D-4404-8AF5-9485B01E63A6@gmail.com","threadId":"65603","inReplyTo":"4a94c675-661c-1f2a-27d0-3f10f761cf6a@gmx.de","subject":"Re: [PATCH/RFC 0/5] replay: support replaying 2-parent merges","fromName":"Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-05-07T15:39:09Z","receivedAt":"2026-05-07T15:39:22Z","isPatch":true,"body":"\n> \n> Le 7 mai 2026 à 11:06, Johannes Schindelin <johannes.schindelin@gmx.de> a écrit :\n> \n> ﻿Hi Ben,\n> \n>> On Thu, 7 May 2026, D. Ben Knoble wrote:\n>> \n>>> On Wed, May 6, 2026 at 6:44 PM Johannes Schindelin via GitGitGadget\n>>> <gitgitgadget@gmail.com> wrote:\n>>> \n>>> [...]\n>>> \n>>> While I was at it, git history reword had a pre-existing\n>>> silent-success bug: a positive return from replay_revisions() (which\n>>> means \"conflict, no updates queued\") was treated as success. Obviously\n>>> this should never occur, as a reword simply does not change any of the\n>>> file contents, but bugs do happen. The merge-replay work is complex\n>>> enough to make that class of bugs more likely, therefore I introduce\n>>> error messages for those instances.\n>> \n>> Fixing this bug sounded interesting; I had a hard time spotting it\n>> while skimming the first 2 patches.\n> \n> It's this part:\n> \n> @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,\n>    if (ret < 0) {\n>        ret = error(_(\"failed replaying descendants\"));\n>        goto out;\n> +    } else if (ret) {\n> +        ret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n> +        goto out;\n>    }\n> \n>    ret = 0;\n> @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,\n>    if (ret < 0) {\n>        ret = error(_(\"failed replaying descendants\"));\n>        goto out;\n> +    } else if (ret) {\n> +        ret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n> +        goto out;\n>    }\n> \n>    ret = 0;\n\nThanks, super helpful.\n\n(Perhaps later) if we can say _which_ descendants weren’t rewritten, that might be good.\n\n>> Did I just miss it? Is it worth splitting that fix out to a separate patch?\n> \n> Well, you _could_ argue that they were not bugs at all: a `git history\n> reword` isn't supposed to be able to result in merge conflicts, nor is\n> `git history split` because they leave the respective commits tree-same\n> (in the `split` case, the second commit).\n\nI seem to recall Patrick making a similar argument, but don’t let me put words in anyone’s mouth. \n\n> I could see the point were anybody to suggest using `BUG()` instead of\n> `error()` here, but erred on the \"nicer to the user\" side.\n> \n> The only way this _might_ be triggered before this patch series is most\n> likely by playing games with replace objects. Or maybe you cannot trigger\n> it at all.\n> \n> With the changes in this here patch series, I wasn't so certain that I had\n> covered all the edge cases (an early iteration of the quick short-cut in\n> patch 2/5 keyed only on the parent commits' trees, and forgot to verify\n> the merge _bases_' trees, for example). That's why I think it matters more\n> now than it did before.\n> \n> Ciao,\n> Johannes\n\nMakes sense, thanks."},{"id":"542903","messageId":"72901ee2-1212-46cd-b752-f451cce6e1ff@gmail.com","threadId":"65603","inReplyTo":"034ab0f83822e6db67baa423d9fcb753b12b5ac8.1778107405.git.gitgitgadget@gmail.com","subject":"Re: [PATCH/RFC 1/5] replay: support replaying 2-parent merges","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-05-08T09:36:41Z","receivedAt":"2026-05-08T09:36:38Z","isPatch":true,"body":"Hi Johannes\n\nOn 06/05/2026 23:43, Johannes Schindelin via GitGitGadget wrote:\n> \n> Elijah Newren spelled out a way to lift this limitation in his\n> replay-design-notes [1] and prototyped it in a 2022\n> work-in-progress sketch [2]. The idea is that a merge commit M on\n> parents (P1, P2) records both an automatic merge of those parents\n> AND any manual layer the author put on top of that automatic merge\n> (textual conflict resolution and any semantic edit outside conflict\n> markers). Replaying M onto rewritten parents (P1', P2') must\n> preserve that manual layer, but the rewritten parents change the\n> automatic merge, so a simple cherry-pick is wrong: the manual layer\n> would be re-introduced on top of stale auto-merge text.\n> \n> What works instead is a three-way merge of three trees the existing\n> infrastructure already knows how to compute. Let R be the recursive\n> auto-merge of (P1, P2), O be M's actual tree and N be the recursive\n> auto-merge of (P1', P2'). Then `git diff R O` is morally\n> `git show --remerge-diff M`: it captures exactly what the author\n> added on top of the automatic merge. A non-recursive 3-way merge\n> with R as the merge base, O as side 1 and N as side 2 layers that\n> manual contribution onto the freshly auto-merged rewritten parents\n> (N) and produces the replayed tree.\n\nSo we cherry-pick the difference between the user's conflict resolution \nO and the auto-merge M of the original parents onto the auto-merge N of \nthe replayed parents. If we have a topology that looks like\n\n         |\n         A\n        /|\\\n       / B \\\n      E  |  D\n         C /\n         |/\n         O\n\nthen running\n\n     git replay --onto E --ancestry-path B..O\n\nwill replay C and O onto E. If the changes in E and D conflict but those \nconflicts do not overlap with the conflicts in M that were resolved to \ncreate O then the replayed version of O will contain conflict markers \nfrom the conflicting changes in E and D. Because the previous conflict \nresolution applies to N without conflicts we do not recognize that there \nare still conflicts in N that need to be resolved.\n\nHaving realized this I went to look at Elijah's notes and they recognize \nthis possibility and suggest extending the xdiff merge code to detect \nwhen N has conflicts that do not correspond to the conflicts in M. That \nsounds like quite a lot of work. I've not put much effort into coming up \nwith a counterexample but think that because \"git replay\" and \"git \nhistory\" do not yet allow the commits in the merged branches to be \nedited we may be able to safely use the implementation proposed in this \nseries if both merge parents have been rebased (or we might want all the \nmerge bases of the new merge to be a descendants of \"--onto\"). In the \nexample above if both the parents were rebased onto E then any new \nconflicts would happen when picking D rather than when recreating the merge.\n\nThanks\n\nPhillip\n\n> Implement `pick_merge_commit()` along those lines and dispatch to it\n> from `replay_revisions()` when the commit being replayed has exactly\n> two parents. Two specific points (learned the hard way) keep\n> non-trivial cases working where the WIP sketch [2] bailed out.\n> First, R and N use identical `merge_options.branch1` and `branch2`\n> labels (\"ours\"/\"theirs\"). When the original parents conflicted on a\n> region of a file, both R and N produce textually identical conflict\n> markers; the outer non-recursive merge then sees N == R in that\n> region and the user's manual resolution from O wins cleanly. Without\n> this, the conflict-marker text would differ between R and N (because\n> the inner merges would label the conflicts differently), and the\n> outer merge would itself be unclean even when the user did supply a\n> clean resolution. Second, an unclean inner merge\n> (`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in\n> that case still has well-defined contents (with conflict markers in\n> the conflicted files) and is a valid input to the outer\n> non-recursive merge. Only a real error (`< 0`) propagates as\n> failure.\n> \n> The replay propagates the textual diffs the user actually made in M;\n> it does _not_ extrapolate symbol-level intent. If rewriting the\n> parents pulls in genuinely new content (for example, a brand-new\n> caller of a function that the merge renamed), that new content stays\n> as the rewritten parents have it. Symbol-aware refactoring is out of\n> scope here, just as it is for plain rebase.\n> \n> Octopus merges (more than two parents) and revert-of-merge are not\n> supported and are surfaced as explicit errors at the dispatch point.\n> The \"split\" sub-command of `git history` continues to refuse when\n> the targeted commit is itself a merge: split semantics do not apply\n> to merges. The pre-walk gate in `builtin/history.c` that previously\n> rejected any merge in the rewrite path now only rejects octopus\n> merges; rename it accordingly.\n> \n> A small refactor in `create_commit()` makes the merge case possible:\n> the helper now takes a `struct commit_list *parents` rather than a\n> single parent pointer and takes ownership of the list. The single\n> existing caller in `pick_regular_commit()` builds and passes a\n> one-element list; the new `pick_merge_commit()` builds a two-element\n> list, with the order of the `from` and `merge` parents preserved.\n> \n> Update the negative expectations in t3451, t3452 and t3650 that were\n> asserting the now-retired \"not supported yet\" message, replacing\n> them with positive coverage where it fits. Octopus rejection and\n> revert-of-merge rejection are covered by new positive tests in\n> t3650. A dedicated test script with merge-replay scenarios driven by\n> a new test-tool fixture builder will follow in a subsequent commit.\n> \n> [1] https://github.com/newren/git/blob/replay/replay-design-notes.txt\n> [2] https://github.com/newren/git/commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45\n> \n> Helped-by: Elijah Newren <newren@gmail.com>\n> Assisted-by: Claude Opus 4.7\n> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> ---\n>   builtin/history.c         |  16 ++-\n>   replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--\n>   t/t3451-history-reword.sh |  21 ++--\n>   t/t3452-history-split.sh  |   6 +-\n>   t/t3650-replay-basics.sh  |  46 ++++++++-\n>   5 files changed, 269 insertions(+), 29 deletions(-)\n> \n> diff --git a/builtin/history.c b/builtin/history.c\n> index 9526938085..00097b2226 100644\n> --- a/builtin/history.c\n> +++ b/builtin/history.c\n> @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option *opt, const char *value, int uns\n>   \treturn 0;\n>   }\n>   \n> -static int revwalk_contains_merges(struct repository *repo,\n> -\t\t\t\t   const struct strvec *revwalk_args)\n> +static int revwalk_contains_octopus_merges(struct repository *repo,\n> +\t\t\t\t\t   const struct strvec *revwalk_args)\n>   {\n>   \tstruct strvec args = STRVEC_INIT;\n>   \tstruct rev_info revs;\n>   \tint ret;\n>   \n>   \tstrvec_pushv(&args, revwalk_args->v);\n> -\tstrvec_push(&args, \"--min-parents=2\");\n> +\tstrvec_push(&args, \"--min-parents=3\");\n>   \n>   \trepo_init_revisions(repo, &revs, NULL);\n>   \n> @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository *repo,\n>   \t}\n>   \n>   \tif (get_revision(&revs)) {\n> -\t\tret = error(_(\"replaying merge commits is not supported yet!\"));\n> +\t\tret = error(_(\"replaying octopus merges is not supported\"));\n>   \t\tgoto out;\n>   \t}\n>   \n> @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,\n>   \t\tstrvec_push(&args, \"HEAD\");\n>   \t}\n>   \n> -\tret = revwalk_contains_merges(repo, &args);\n> +\tret = revwalk_contains_octopus_merges(repo, &args);\n>   \tif (ret < 0)\n>   \t\tgoto out;\n>   \n> @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,\n>   \tif (ret < 0) {\n>   \t\tret = error(_(\"failed replaying descendants\"));\n>   \t\tgoto out;\n> +\t} else if (ret) {\n> +\t\tret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n> +\t\tgoto out;\n>   \t}\n>   \n>   \tret = 0;\n> @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,\n>   \tif (ret < 0) {\n>   \t\tret = error(_(\"failed replaying descendants\"));\n>   \t\tgoto out;\n> +\t} else if (ret) {\n> +\t\tret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n> +\t\tgoto out;\n>   \t}\n>   \n>   \tret = 0;\n> diff --git a/replay.c b/replay.c\n> index f96f1f6551..3dbce095f9 100644\n> --- a/replay.c\n> +++ b/replay.c\n> @@ -1,6 +1,7 @@\n>   #define USE_THE_REPOSITORY_VARIABLE\n>   \n>   #include \"git-compat-util.h\"\n> +#include \"commit-reach.h\"\n>   #include \"environment.h\"\n>   #include \"hex.h\"\n>   #include \"merge-ort.h\"\n> @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf *msg,\n>   \trepo_unuse_commit_buffer(repo, commit, message);\n>   }\n>   \n> +/*\n> + * Build a new commit with the given tree and parent list, copying author,\n> + * extra headers and (for pick mode) the commit message from `based_on`.\n> + *\n> + * Takes ownership of `parents`: it will be freed before returning, even on\n> + * error. Parent order is preserved as supplied by the caller.\n> + */\n>   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    struct commit_list *parents,\n>   \t\t\t\t    enum replay_mode mode)\n>   {\n>   \tstruct object_id ret;\n>   \tstruct object *obj = NULL;\n> -\tstruct commit_list *parents = NULL;\n>   \tchar *author = NULL;\n>   \tchar *sign_commit = NULL; /* FIXME: cli users might want to sign again */\n>   \tstruct commit_extra_header *extra = NULL;\n> @@ -96,7 +103,6 @@ static struct commit *create_commit(struct repository *repo,\n>   \tconst char *orig_message = NULL;\n>   \tconst char *exclude_gpgsig[] = { \"gpgsig\", \"gpgsig-sha256\", NULL };\n>   \n> -\tcommit_list_insert(parent, &parents);\n>   \textra = read_commit_extra_headers(based_on, exclude_gpgsig);\n>   \tif (mode == REPLAY_MODE_REVERT) {\n>   \t\tgenerate_revert_message(&msg, based_on, repo);\n> @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>   {\n>   \tstruct commit *base, *replayed_base;\n>   \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n> +\tstruct commit_list *parents = NULL;\n>   \n>   \tif (pickme->parents) {\n>   \t\tbase = pickme->parents->item;\n> @@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct repository *repo,\n>   \tif (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&\n>   \t    !oideq(&pickme_tree->object.oid, &base_tree->object.oid))\n>   \t\treturn replayed_base;\n> -\treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n> +\tcommit_list_insert(replayed_base, &parents);\n> +\treturn create_commit(repo, result->tree, pickme, parents, mode);\n> +}\n> +\n> +/*\n> + * Replay a 2-parent merge commit by composing three calls into merge-ort:\n> + *\n> + *   R = recursive merge of pickme's two original parents (auto-remerge of\n> + *       the original merge, accepting any conflicts)\n> + *   N = recursive merge of the (possibly rewritten) parents\n> + *   O = pickme's tree (the user's actual merge, including any manual\n> + *       resolutions)\n> + *\n> + * The picked tree comes from a non-recursive merge using R as the base,\n> + * O as side1 and N as side2. `git diff R O` is morally `git show\n> + * --remerge-diff $oldmerge`, so this layers the user's original manual\n> + * resolution on top of the freshly auto-merged rewritten parents (see\n> + * `replay-design-notes.txt` on the `replay` branch of newren/git).\n> + *\n> + * If the outer 3-way merge is unclean, propagate the conflict status to\n> + * the caller via `result->clean = 0` and return NULL. The two inner\n> + * merges (R and N) being unclean is _not_ fatal: the conflict-markered\n> + * trees they produce are valid inputs to the outer merge, and using\n> + * identical labels for both inner merges keeps the marker text\n> + * byte-equal between R and N so the user's resolution recorded in O\n> + * collapses the conflict cleanly there. Octopus merges (more than two\n> + * parents) and revert-of-merge are rejected by the caller before this\n> + * function is invoked.\n> + */\n> +static struct commit *pick_merge_commit(struct repository *repo,\n> +\t\t\t\t\tstruct commit *pickme,\n> +\t\t\t\t\tkh_oid_map_t *replayed_commits,\n> +\t\t\t\t\tstruct merge_options *merge_opt,\n> +\t\t\t\t\tstruct merge_result *result)\n> +{\n> +\tstruct commit *parent1, *parent2;\n> +\tstruct commit *replayed_par1, *replayed_par2;\n> +\tstruct tree *pickme_tree;\n> +\tstruct merge_options remerge_opt = { 0 };\n> +\tstruct merge_options new_merge_opt = { 0 };\n> +\tstruct merge_result remerge_res = { 0 };\n> +\tstruct merge_result new_merge_res = { 0 };\n> +\tstruct commit_list *parent_bases = NULL;\n> +\tstruct commit_list *replayed_bases = NULL;\n> +\tstruct commit_list *parents;\n> +\tstruct commit *picked = NULL;\n> +\tchar *ancestor_name = NULL;\n> +\n> +\tparent1 = pickme->parents->item;\n> +\tparent2 = pickme->parents->next->item;\n> +\n> +\t/*\n> +\t * Map the merge's parents to their replayed counterparts. With the\n> +\t * boundary commits pre-seeded into `replayed_commits`, every parent\n> +\t * either has an explicit mapping (rewritten or boundary -> onto) or\n> +\t * sits outside the rewrite range entirely; the latter must stay at\n> +\t * the original parent commit, so use `parent` itself as the fallback\n> +\t * for both sides.\n> +\t */\n> +\treplayed_par1 = mapped_commit(replayed_commits, parent1, parent1);\n> +\treplayed_par2 = mapped_commit(replayed_commits, parent2, parent2);\n> +\n> +\t/*\n> +\t * R: auto-remerge of the original parents.\n> +\t *\n> +\t * Use the same branch labels for the inner merges that compute R\n> +\t * and N so conflict markers (if any) are textually identical\n> +\t * between the two; the outer non-recursive merge can then collapse\n> +\t * the manual resolution from O against them.\n> +\t */\n> +\tinit_basic_merge_options(&remerge_opt, repo);\n> +\tremerge_opt.show_rename_progress = 0;\n> +\tremerge_opt.branch1 = \"ours\";\n> +\tremerge_opt.branch2 = \"theirs\";\n> +\tif (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0) {\n> +\t\tresult->clean = -1;\n> +\t\tgoto out;\n> +\t}\n> +\tmerge_incore_recursive(&remerge_opt, parent_bases,\n> +\t\t\t       parent1, parent2, &remerge_res);\n> +\tparent_bases = NULL; /* consumed by merge_incore_recursive */\n> +\tif (remerge_res.clean < 0) {\n> +\t\tresult->clean = remerge_res.clean;\n> +\t\tgoto out;\n> +\t}\n> +\n> +\t/* N: fresh merge of the (possibly rewritten) parents. */\n> +\tinit_basic_merge_options(&new_merge_opt, repo);\n> +\tnew_merge_opt.show_rename_progress = 0;\n> +\tnew_merge_opt.branch1 = \"ours\";\n> +\tnew_merge_opt.branch2 = \"theirs\";\n> +\tif (repo_get_merge_bases(repo, replayed_par1, replayed_par2,\n> +\t\t\t\t &replayed_bases) < 0) {\n> +\t\tresult->clean = -1;\n> +\t\tgoto out;\n> +\t}\n> +\tmerge_incore_recursive(&new_merge_opt, replayed_bases,\n> +\t\t\t       replayed_par1, replayed_par2, &new_merge_res);\n> +\treplayed_bases = NULL; /* consumed by merge_incore_recursive */\n> +\tif (new_merge_res.clean < 0) {\n> +\t\tresult->clean = new_merge_res.clean;\n> +\t\tgoto out;\n> +\t}\n> +\n> +\t/*\n> +\t * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.\n> +\t */\n> +\tpickme_tree = repo_get_commit_tree(repo, pickme);\n> +\tancestor_name = xstrfmt(\"auto-remerge of %s\",\n> +\t\t\t\toid_to_hex(&pickme->object.oid));\n> +\tmerge_opt->ancestor = ancestor_name;\n> +\tmerge_opt->branch1 = short_commit_name(repo, pickme);\n> +\tmerge_opt->branch2 = \"merge of replayed parents\";\n> +\tmerge_incore_nonrecursive(merge_opt,\n> +\t\t\t\t  remerge_res.tree,\n> +\t\t\t\t  pickme_tree,\n> +\t\t\t\t  new_merge_res.tree,\n> +\t\t\t\t  result);\n> +\tmerge_opt->ancestor = NULL;\n> +\tmerge_opt->branch1 = NULL;\n> +\tmerge_opt->branch2 = NULL;\n> +\tif (!result->clean)\n> +\t\tgoto out;\n> +\n> +\tparents = NULL;\n> +\tcommit_list_insert(replayed_par2, &parents);\n> +\tcommit_list_insert(replayed_par1, &parents);\n> +\tpicked = create_commit(repo, result->tree, pickme, parents,\n> +\t\t\t       REPLAY_MODE_PICK);\n> +\n> +out:\n> +\tfree(ancestor_name);\n> +\tfree_commit_list(parent_bases);\n> +\tfree_commit_list(replayed_bases);\n> +\tmerge_finalize(&remerge_opt, &remerge_res);\n> +\tmerge_finalize(&new_merge_opt, &new_merge_res);\n> +\treturn picked;\n>   }\n>   \n>   void replay_result_release(struct replay_result *result)\n> @@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,\n>   \tmerge_opt.show_rename_progress = 0;\n>   \tlast_commit = onto;\n>   \treplayed_commits = kh_init_oid_map();\n> +\n> +\t/*\n> +\t * Seed the rewritten-commit map with each negative-side (\"BOTTOM\")\n> +\t * cmdline entry pointing at `onto`. This matters for merge replay:\n> +\t * a 2-parent merge whose first parent is the boundary (e.g. the\n> +\t * commit being reworded) must replay onto the rewritten boundary,\n> +\t * yet pick_merge_commit uses a self fallback so the second parent\n> +\t * (a side branch outside the rewrite range) is preserved as-is.\n> +\t * Pre-seeding the boundary disambiguates the two: in the map ->\n> +\t * rewritten, missing -> kept as-is.\n> +\t *\n> +\t * Only do this for the pick path; revert mode chains reverts\n> +\t * through last_commit and a pre-seeded boundary would short-circuit\n> +\t * that chain.\n> +\t */\n> +\tif (mode == REPLAY_MODE_PICK) {\n> +\t\tfor (size_t i = 0; i < revs->cmdline.nr; i++) {\n> +\t\t\tstruct rev_cmdline_entry *e = &revs->cmdline.rev[i];\n> +\t\t\tstruct commit *boundary;\n> +\t\t\tkhint_t pos;\n> +\t\t\tint hr;\n> +\n> +\t\t\tif (!(e->flags & BOTTOM))\n> +\t\t\t\tcontinue;\n> +\t\t\tboundary = lookup_commit_reference_gently(revs->repo,\n> +\t\t\t\t\t\t\t\t  &e->item->oid, 1);\n> +\t\t\tif (!boundary)\n> +\t\t\t\tcontinue;\n> +\t\t\tpos = kh_put_oid_map(replayed_commits,\n> +\t\t\t\t\t     boundary->object.oid, &hr);\n> +\t\t\tif (hr != 0)\n> +\t\t\t\tkh_value(replayed_commits, pos) = onto;\n> +\t\t}\n> +\t}\n> +\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> -\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);\n> +\t\tif (commit->parents && commit->parents->next) {\n> +\t\t\tif (commit->parents->next->next) {\n> +\t\t\t\tret = error(_(\"replaying octopus merges is not supported\"));\n> +\t\t\t\tgoto out;\n> +\t\t\t}\n> +\t\t\tif (mode == REPLAY_MODE_REVERT) {\n> +\t\t\t\tret = error(_(\"reverting merge commits is not supported\"));\n> +\t\t\t\tgoto out;\n> +\t\t\t}\n> +\t\t\tlast_commit = pick_merge_commit(revs->repo, commit,\n> +\t\t\t\t\t\t\treplayed_commits,\n> +\t\t\t\t\t\t\t&merge_opt, &result);\n> +\t\t} else {\n> +\t\t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n> +\t\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n> +\t\t\t\t\t\t\t  &merge_opt, &result, mode);\n> +\t\t}\n>   \t\tif (!last_commit)\n>   \t\t\tbreak;\n>   \n> diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh\n> index de7b357685..d103f866a2 100755\n> --- a/t/t3451-history-reword.sh\n> +++ b/t/t3451-history-reword.sh\n> @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '\n>   \t\tgit switch - &&\n>   \t\tgit merge theirs &&\n>   \n> -\t\t# It is not possible to replay merge commits embedded in the\n> -\t\t# history (yet).\n> -\t\ttest_must_fail git -c core.editor=false history reword HEAD~ 2>err &&\n> -\t\ttest_grep \"replaying merge commits is not supported yet\" err &&\n> +\t\t# Reword a non-merge commit whose descendants include the\n> +\t\t# merge: replay carries the merge through.\n> +\t\treword_with_message HEAD~ <<-EOF &&\n> +\t\tours reworded\n> +\t\tEOF\n> +\t\texpect_graph <<-EOF &&\n> +\t\t*   Merge tag ${SQ}theirs${SQ}\n> +\t\t|\\\\\n> +\t\t| * theirs\n> +\t\t* | ours reworded\n> +\t\t|/\n> +\t\t* base\n> +\t\tEOF\n>   \n> -\t\t# But it is possible to reword a merge commit directly.\n> +\t\t# And reword a merge commit directly.\n>   \t\treword_with_message HEAD <<-EOF &&\n>   \t\tReworded merge commit\n>   \t\tEOF\n> @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '\n>   \t\t*   Reworded merge commit\n>   \t\t|\\\n>   \t\t| * theirs\n> -\t\t* | ours\n> +\t\t* | ours reworded\n>   \t\t|/\n>   \t\t* base\n>   \t\tEOF\n> diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh\n> index 8ed0cebb50..ad6309f98b 100755\n> --- a/t/t3452-history-split.sh\n> +++ b/t/t3452-history-split.sh\n> @@ -36,7 +36,7 @@ expect_tree_entries () {\n>   \ttest_cmp expect actual\n>   }\n>   \n> -test_expect_success 'refuses to work with merge commits' '\n> +test_expect_success 'refuses to split a merge commit' '\n>   \ttest_when_finished \"rm -rf repo\" &&\n>   \tgit init repo &&\n>   \t(\n> @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge commits' '\n>   \t\tgit switch - &&\n>   \t\tgit merge theirs &&\n>   \t\ttest_must_fail git history split HEAD 2>err &&\n> -\t\ttest_grep \"cannot split up merge commit\" err &&\n> -\t\ttest_must_fail git history split HEAD~ 2>err &&\n> -\t\ttest_grep \"replaying merge commits is not supported yet\" err\n> +\t\ttest_grep \"cannot split up merge commit\" err\n>   \t)\n>   '\n>   \n> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n> index 3353bc4a4d..368b1b0f9a 100755\n> --- a/t/t3650-replay-basics.sh\n> +++ b/t/t3650-replay-basics.sh\n> @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ... ordering would be ill-defined' '\n>   \ttest_cmp expect actual\n>   '\n>   \n> -test_expect_success 'replaying merge commits is not supported yet' '\n> -\techo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n> -\ttest_must_fail git replay --advance=main main..topic-with-merge 2>actual &&\n> -\ttest_cmp expect actual\n> +test_expect_success 'using replay to rebase a 2-parent merge' '\n> +\t# main..topic-with-merge contains a 2-parent merge (P) introduced\n> +\t# via test_merge. Use --ref-action=print so this test does not\n> +\t# mutate state for subsequent tests in this file.\n> +\tgit replay --ref-action=print --onto main main..topic-with-merge >result &&\n> +\ttest_line_count = 1 result &&\n> +\n> +\tnew_tip=$(cut -f 3 -d \" \" result) &&\n> +\n> +\t# Result is still a 2-parent merge.\n> +\tgit cat-file -p $new_tip >cat &&\n> +\tgrep -c \"^parent \" cat >count &&\n> +\techo 2 >expect &&\n> +\ttest_cmp expect count &&\n> +\n> +\t# Merge subject is preserved.\n> +\techo P >expect &&\n> +\tgit log -1 --format=%s $new_tip >actual &&\n> +\ttest_cmp expect actual &&\n> +\n> +\t# The replayed merge sits on top of main: walking back via the\n> +\t# first-parent chain reaches main.\n> +\tgit merge-base --is-ancestor main $new_tip\n> +'\n> +\n> +test_expect_success 'replaying an octopus merge is rejected' '\n> +\t# Build an octopus side-branch so the rest of the test state stays\n> +\t# untouched.\n> +\ttest_when_finished \"git update-ref -d refs/heads/octopus-tip\" &&\n> +\toctopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \\\n> +\t\t-m \"octopus\" $(git rev-parse topic4^{tree})) &&\n> +\tgit update-ref refs/heads/octopus-tip \"$octopus_tip\" &&\n> +\n> +\ttest_must_fail git replay --ref-action=print --onto main \\\n> +\t\ttopic4..octopus-tip 2>actual &&\n> +\ttest_grep \"octopus merges\" actual\n> +'\n> +\n> +test_expect_success 'reverting a merge commit is rejected' '\n> +\ttest_must_fail git replay --ref-action=print --revert=topic-with-merge \\\n> +\t\ttopic4..topic-with-merge 2>actual &&\n> +\ttest_grep \"reverting merge commits\" actual\n>   '\n>   \n>   test_expect_success 'using replay to rebase two branches, one on top of other' '\n\n"},{"id":"542904","messageId":"3dd21593-9945-4f9c-a9a0-f5c66504da49@gmail.com","threadId":"65603","inReplyTo":"72901ee2-1212-46cd-b752-f451cce6e1ff@gmail.com","subject":"Re: [PATCH/RFC 1/5] replay: support replaying 2-parent merges","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-05-08T10:05:48Z","receivedAt":"2026-05-08T10:05:44Z","isPatch":true,"body":"On 08/05/2026 10:36, Phillip Wood wrote:\n> Hi Johannes\n> \n> On 06/05/2026 23:43, Johannes Schindelin via GitGitGadget wrote:\n>>\n>> Elijah Newren spelled out a way to lift this limitation in his\n>> replay-design-notes [1] and prototyped it in a 2022\n>> work-in-progress sketch [2]. The idea is that a merge commit M on\n>> parents (P1, P2) records both an automatic merge of those parents\n>> AND any manual layer the author put on top of that automatic merge\n>> (textual conflict resolution and any semantic edit outside conflict\n>> markers). Replaying M onto rewritten parents (P1', P2') must\n>> preserve that manual layer, but the rewritten parents change the\n>> automatic merge, so a simple cherry-pick is wrong: the manual layer\n>> would be re-introduced on top of stale auto-merge text.\n>>\n>> What works instead is a three-way merge of three trees the existing\n>> infrastructure already knows how to compute. Let R be the recursive\n>> auto-merge of (P1, P2), O be M's actual tree and N be the recursive\n>> auto-merge of (P1', P2'). Then `git diff R O` is morally\n>> `git show --remerge-diff M`: it captures exactly what the author\n>> added on top of the automatic merge. A non-recursive 3-way merge\n>> with R as the merge base, O as side 1 and N as side 2 layers that\n>> manual contribution onto the freshly auto-merged rewritten parents\n>> (N) and produces the replayed tree.\n> \n> So we cherry-pick the difference between the user's conflict resolution \n> O and the auto-merge M of the original parents onto the auto-merge N of \n> the replayed parents. If we have a topology that looks like\n> \n>          |\n>          A\n>         /|\\\n>        / B \\\n>       E  |  D\n>          C /\n>          |/\n>          O\n> \n> then running\n> \n>      git replay --onto E --ancestry-path B..O\n> \n> will replay C and O onto E. If the changes in E and D conflict but those \n> conflicts do not overlap with the conflicts in M that were resolved to \n> create O then the replayed version of O will contain conflict markers \n> from the conflicting changes in E and D. Because the previous conflict \n> resolution applies to N without conflicts we do not recognize that there \n> are still conflicts in N that need to be resolved.\n> \n> Having realized this I went to look at Elijah's notes and they recognize \n> this possibility and suggest extending the xdiff merge code to detect \n> when N has conflicts that do not correspond to the conflicts in M. That \n> sounds like quite a lot of work. I've not put much effort into coming up \n> with a counterexample but think that because \"git replay\" and \"git \n> history\" do not yet allow the commits in the merged branches to be \n> edited we may be able to safely use the implementation proposed in this \n> series if both merge parents have been rebased (or we might want all the \n> merge bases of the new merge to be a descendants of \"--onto\"). In the \n> example above if both the parents were rebased onto E then any new \n> conflicts would happen when picking D rather than when recreating the \n> merge.\n\nOne further thought - if only one of the parents has been rebased (i.e. \nwe're replaying O with parents P1' and P2) then can we just cherry-pick \nthe merge - instead of merging P1' and P2, use P1 as the merge-base with \nO and P1' as the merge heads?\n\nThanks\n\nPhillip\n\n> Thanks\n> \n> Phillip\n> \n>> Implement `pick_merge_commit()` along those lines and dispatch to it\n>> from `replay_revisions()` when the commit being replayed has exactly\n>> two parents. Two specific points (learned the hard way) keep\n>> non-trivial cases working where the WIP sketch [2] bailed out.\n>> First, R and N use identical `merge_options.branch1` and `branch2`\n>> labels (\"ours\"/\"theirs\"). When the original parents conflicted on a\n>> region of a file, both R and N produce textually identical conflict\n>> markers; the outer non-recursive merge then sees N == R in that\n>> region and the user's manual resolution from O wins cleanly. Without\n>> this, the conflict-marker text would differ between R and N (because\n>> the inner merges would label the conflicts differently), and the\n>> outer merge would itself be unclean even when the user did supply a\n>> clean resolution. Second, an unclean inner merge\n>> (`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in\n>> that case still has well-defined contents (with conflict markers in\n>> the conflicted files) and is a valid input to the outer\n>> non-recursive merge. Only a real error (`< 0`) propagates as\n>> failure.\n>>\n>> The replay propagates the textual diffs the user actually made in M;\n>> it does _not_ extrapolate symbol-level intent. If rewriting the\n>> parents pulls in genuinely new content (for example, a brand-new\n>> caller of a function that the merge renamed), that new content stays\n>> as the rewritten parents have it. Symbol-aware refactoring is out of\n>> scope here, just as it is for plain rebase.\n>>\n>> Octopus merges (more than two parents) and revert-of-merge are not\n>> supported and are surfaced as explicit errors at the dispatch point.\n>> The \"split\" sub-command of `git history` continues to refuse when\n>> the targeted commit is itself a merge: split semantics do not apply\n>> to merges. The pre-walk gate in `builtin/history.c` that previously\n>> rejected any merge in the rewrite path now only rejects octopus\n>> merges; rename it accordingly.\n>>\n>> A small refactor in `create_commit()` makes the merge case possible:\n>> the helper now takes a `struct commit_list *parents` rather than a\n>> single parent pointer and takes ownership of the list. The single\n>> existing caller in `pick_regular_commit()` builds and passes a\n>> one-element list; the new `pick_merge_commit()` builds a two-element\n>> list, with the order of the `from` and `merge` parents preserved.\n>>\n>> Update the negative expectations in t3451, t3452 and t3650 that were\n>> asserting the now-retired \"not supported yet\" message, replacing\n>> them with positive coverage where it fits. Octopus rejection and\n>> revert-of-merge rejection are covered by new positive tests in\n>> t3650. A dedicated test script with merge-replay scenarios driven by\n>> a new test-tool fixture builder will follow in a subsequent commit.\n>>\n>> [1] https://github.com/newren/git/blob/replay/replay-design-notes.txt\n>> [2] https://github.com/newren/git/ \n>> commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45\n>>\n>> Helped-by: Elijah Newren <newren@gmail.com>\n>> Assisted-by: Claude Opus 4.7\n>> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n>> ---\n>>   builtin/history.c         |  16 ++-\n>>   replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--\n>>   t/t3451-history-reword.sh |  21 ++--\n>>   t/t3452-history-split.sh  |   6 +-\n>>   t/t3650-replay-basics.sh  |  46 ++++++++-\n>>   5 files changed, 269 insertions(+), 29 deletions(-)\n>>\n>> diff --git a/builtin/history.c b/builtin/history.c\n>> index 9526938085..00097b2226 100644\n>> --- a/builtin/history.c\n>> +++ b/builtin/history.c\n>> @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option \n>> *opt, const char *value, int uns\n>>       return 0;\n>>   }\n>> -static int revwalk_contains_merges(struct repository *repo,\n>> -                   const struct strvec *revwalk_args)\n>> +static int revwalk_contains_octopus_merges(struct repository *repo,\n>> +                       const struct strvec *revwalk_args)\n>>   {\n>>       struct strvec args = STRVEC_INIT;\n>>       struct rev_info revs;\n>>       int ret;\n>>       strvec_pushv(&args, revwalk_args->v);\n>> -    strvec_push(&args, \"--min-parents=2\");\n>> +    strvec_push(&args, \"--min-parents=3\");\n>>       repo_init_revisions(repo, &revs, NULL);\n>> @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct \n>> repository *repo,\n>>       }\n>>       if (get_revision(&revs)) {\n>> -        ret = error(_(\"replaying merge commits is not supported yet!\"));\n>> +        ret = error(_(\"replaying octopus merges is not supported\"));\n>>           goto out;\n>>       }\n>> @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,\n>>           strvec_push(&args, \"HEAD\");\n>>       }\n>> -    ret = revwalk_contains_merges(repo, &args);\n>> +    ret = revwalk_contains_octopus_merges(repo, &args);\n>>       if (ret < 0)\n>>           goto out;\n>> @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,\n>>       if (ret < 0) {\n>>           ret = error(_(\"failed replaying descendants\"));\n>>           goto out;\n>> +    } else if (ret) {\n>> +        ret = error(_(\"conflict during replay; some descendants were \n>> not rewritten\"));\n>> +        goto out;\n>>       }\n>>       ret = 0;\n>> @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,\n>>       if (ret < 0) {\n>>           ret = error(_(\"failed replaying descendants\"));\n>>           goto out;\n>> +    } else if (ret) {\n>> +        ret = error(_(\"conflict during replay; some descendants were \n>> not rewritten\"));\n>> +        goto out;\n>>       }\n>>       ret = 0;\n>> diff --git a/replay.c b/replay.c\n>> index f96f1f6551..3dbce095f9 100644\n>> --- a/replay.c\n>> +++ b/replay.c\n>> @@ -1,6 +1,7 @@\n>>   #define USE_THE_REPOSITORY_VARIABLE\n>>   #include \"git-compat-util.h\"\n>> +#include \"commit-reach.h\"\n>>   #include \"environment.h\"\n>>   #include \"hex.h\"\n>>   #include \"merge-ort.h\"\n>> @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf \n>> *msg,\n>>       repo_unuse_commit_buffer(repo, commit, message);\n>>   }\n>> +/*\n>> + * Build a new commit with the given tree and parent list, copying \n>> author,\n>> + * extra headers and (for pick mode) the commit message from `based_on`.\n>> + *\n>> + * Takes ownership of `parents`: it will be freed before returning, \n>> even on\n>> + * error. Parent order is preserved as supplied by the caller.\n>> + */\n>>   static struct commit *create_commit(struct repository *repo,\n>>                       struct tree *tree,\n>>                       struct commit *based_on,\n>> -                    struct commit *parent,\n>> +                    struct commit_list *parents,\n>>                       enum replay_mode mode)\n>>   {\n>>       struct object_id ret;\n>>       struct object *obj = NULL;\n>> -    struct commit_list *parents = NULL;\n>>       char *author = NULL;\n>>       char *sign_commit = NULL; /* FIXME: cli users might want to sign \n>> again */\n>>       struct commit_extra_header *extra = NULL;\n>> @@ -96,7 +103,6 @@ static struct commit *create_commit(struct \n>> repository *repo,\n>>       const char *orig_message = NULL;\n>>       const char *exclude_gpgsig[] = { \"gpgsig\", \"gpgsig-sha256\", NULL };\n>> -    commit_list_insert(parent, &parents);\n>>       extra = read_commit_extra_headers(based_on, exclude_gpgsig);\n>>       if (mode == REPLAY_MODE_REVERT) {\n>>           generate_revert_message(&msg, based_on, repo);\n>> @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct \n>> repository *repo,\n>>   {\n>>       struct commit *base, *replayed_base;\n>>       struct tree *pickme_tree, *base_tree, *replayed_base_tree;\n>> +    struct commit_list *parents = NULL;\n>>       if (pickme->parents) {\n>>           base = pickme->parents->item;\n>> @@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct \n>> repository *repo,\n>>       if (oideq(&replayed_base_tree->object.oid, &result->tree- \n>> >object.oid) &&\n>>           !oideq(&pickme_tree->object.oid, &base_tree->object.oid))\n>>           return replayed_base;\n>> -    return create_commit(repo, result->tree, pickme, replayed_base, \n>> mode);\n>> +    commit_list_insert(replayed_base, &parents);\n>> +    return create_commit(repo, result->tree, pickme, parents, mode);\n>> +}\n>> +\n>> +/*\n>> + * Replay a 2-parent merge commit by composing three calls into \n>> merge-ort:\n>> + *\n>> + *   R = recursive merge of pickme's two original parents (auto- \n>> remerge of\n>> + *       the original merge, accepting any conflicts)\n>> + *   N = recursive merge of the (possibly rewritten) parents\n>> + *   O = pickme's tree (the user's actual merge, including any manual\n>> + *       resolutions)\n>> + *\n>> + * The picked tree comes from a non-recursive merge using R as the base,\n>> + * O as side1 and N as side2. `git diff R O` is morally `git show\n>> + * --remerge-diff $oldmerge`, so this layers the user's original manual\n>> + * resolution on top of the freshly auto-merged rewritten parents (see\n>> + * `replay-design-notes.txt` on the `replay` branch of newren/git).\n>> + *\n>> + * If the outer 3-way merge is unclean, propagate the conflict status to\n>> + * the caller via `result->clean = 0` and return NULL. The two inner\n>> + * merges (R and N) being unclean is _not_ fatal: the conflict-markered\n>> + * trees they produce are valid inputs to the outer merge, and using\n>> + * identical labels for both inner merges keeps the marker text\n>> + * byte-equal between R and N so the user's resolution recorded in O\n>> + * collapses the conflict cleanly there. Octopus merges (more than two\n>> + * parents) and revert-of-merge are rejected by the caller before this\n>> + * function is invoked.\n>> + */\n>> +static struct commit *pick_merge_commit(struct repository *repo,\n>> +                    struct commit *pickme,\n>> +                    kh_oid_map_t *replayed_commits,\n>> +                    struct merge_options *merge_opt,\n>> +                    struct merge_result *result)\n>> +{\n>> +    struct commit *parent1, *parent2;\n>> +    struct commit *replayed_par1, *replayed_par2;\n>> +    struct tree *pickme_tree;\n>> +    struct merge_options remerge_opt = { 0 };\n>> +    struct merge_options new_merge_opt = { 0 };\n>> +    struct merge_result remerge_res = { 0 };\n>> +    struct merge_result new_merge_res = { 0 };\n>> +    struct commit_list *parent_bases = NULL;\n>> +    struct commit_list *replayed_bases = NULL;\n>> +    struct commit_list *parents;\n>> +    struct commit *picked = NULL;\n>> +    char *ancestor_name = NULL;\n>> +\n>> +    parent1 = pickme->parents->item;\n>> +    parent2 = pickme->parents->next->item;\n>> +\n>> +    /*\n>> +     * Map the merge's parents to their replayed counterparts. With the\n>> +     * boundary commits pre-seeded into `replayed_commits`, every parent\n>> +     * either has an explicit mapping (rewritten or boundary -> onto) or\n>> +     * sits outside the rewrite range entirely; the latter must stay at\n>> +     * the original parent commit, so use `parent` itself as the \n>> fallback\n>> +     * for both sides.\n>> +     */\n>> +    replayed_par1 = mapped_commit(replayed_commits, parent1, parent1);\n>> +    replayed_par2 = mapped_commit(replayed_commits, parent2, parent2);\n>> +\n>> +    /*\n>> +     * R: auto-remerge of the original parents.\n>> +     *\n>> +     * Use the same branch labels for the inner merges that compute R\n>> +     * and N so conflict markers (if any) are textually identical\n>> +     * between the two; the outer non-recursive merge can then collapse\n>> +     * the manual resolution from O against them.\n>> +     */\n>> +    init_basic_merge_options(&remerge_opt, repo);\n>> +    remerge_opt.show_rename_progress = 0;\n>> +    remerge_opt.branch1 = \"ours\";\n>> +    remerge_opt.branch2 = \"theirs\";\n>> +    if (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < \n>> 0) {\n>> +        result->clean = -1;\n>> +        goto out;\n>> +    }\n>> +    merge_incore_recursive(&remerge_opt, parent_bases,\n>> +                   parent1, parent2, &remerge_res);\n>> +    parent_bases = NULL; /* consumed by merge_incore_recursive */\n>> +    if (remerge_res.clean < 0) {\n>> +        result->clean = remerge_res.clean;\n>> +        goto out;\n>> +    }\n>> +\n>> +    /* N: fresh merge of the (possibly rewritten) parents. */\n>> +    init_basic_merge_options(&new_merge_opt, repo);\n>> +    new_merge_opt.show_rename_progress = 0;\n>> +    new_merge_opt.branch1 = \"ours\";\n>> +    new_merge_opt.branch2 = \"theirs\";\n>> +    if (repo_get_merge_bases(repo, replayed_par1, replayed_par2,\n>> +                 &replayed_bases) < 0) {\n>> +        result->clean = -1;\n>> +        goto out;\n>> +    }\n>> +    merge_incore_recursive(&new_merge_opt, replayed_bases,\n>> +                   replayed_par1, replayed_par2, &new_merge_res);\n>> +    replayed_bases = NULL; /* consumed by merge_incore_recursive */\n>> +    if (new_merge_res.clean < 0) {\n>> +        result->clean = new_merge_res.clean;\n>> +        goto out;\n>> +    }\n>> +\n>> +    /*\n>> +     * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.\n>> +     */\n>> +    pickme_tree = repo_get_commit_tree(repo, pickme);\n>> +    ancestor_name = xstrfmt(\"auto-remerge of %s\",\n>> +                oid_to_hex(&pickme->object.oid));\n>> +    merge_opt->ancestor = ancestor_name;\n>> +    merge_opt->branch1 = short_commit_name(repo, pickme);\n>> +    merge_opt->branch2 = \"merge of replayed parents\";\n>> +    merge_incore_nonrecursive(merge_opt,\n>> +                  remerge_res.tree,\n>> +                  pickme_tree,\n>> +                  new_merge_res.tree,\n>> +                  result);\n>> +    merge_opt->ancestor = NULL;\n>> +    merge_opt->branch1 = NULL;\n>> +    merge_opt->branch2 = NULL;\n>> +    if (!result->clean)\n>> +        goto out;\n>> +\n>> +    parents = NULL;\n>> +    commit_list_insert(replayed_par2, &parents);\n>> +    commit_list_insert(replayed_par1, &parents);\n>> +    picked = create_commit(repo, result->tree, pickme, parents,\n>> +                   REPLAY_MODE_PICK);\n>> +\n>> +out:\n>> +    free(ancestor_name);\n>> +    free_commit_list(parent_bases);\n>> +    free_commit_list(replayed_bases);\n>> +    merge_finalize(&remerge_opt, &remerge_res);\n>> +    merge_finalize(&new_merge_opt, &new_merge_res);\n>> +    return picked;\n>>   }\n>>   void replay_result_release(struct replay_result *result)\n>> @@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,\n>>       merge_opt.show_rename_progress = 0;\n>>       last_commit = onto;\n>>       replayed_commits = kh_init_oid_map();\n>> +\n>> +    /*\n>> +     * Seed the rewritten-commit map with each negative-side (\"BOTTOM\")\n>> +     * cmdline entry pointing at `onto`. This matters for merge replay:\n>> +     * a 2-parent merge whose first parent is the boundary (e.g. the\n>> +     * commit being reworded) must replay onto the rewritten boundary,\n>> +     * yet pick_merge_commit uses a self fallback so the second parent\n>> +     * (a side branch outside the rewrite range) is preserved as-is.\n>> +     * Pre-seeding the boundary disambiguates the two: in the map ->\n>> +     * rewritten, missing -> kept as-is.\n>> +     *\n>> +     * Only do this for the pick path; revert mode chains reverts\n>> +     * through last_commit and a pre-seeded boundary would short-circuit\n>> +     * that chain.\n>> +     */\n>> +    if (mode == REPLAY_MODE_PICK) {\n>> +        for (size_t i = 0; i < revs->cmdline.nr; i++) {\n>> +            struct rev_cmdline_entry *e = &revs->cmdline.rev[i];\n>> +            struct commit *boundary;\n>> +            khint_t pos;\n>> +            int hr;\n>> +\n>> +            if (!(e->flags & BOTTOM))\n>> +                continue;\n>> +            boundary = lookup_commit_reference_gently(revs->repo,\n>> +                                  &e->item->oid, 1);\n>> +            if (!boundary)\n>> +                continue;\n>> +            pos = kh_put_oid_map(replayed_commits,\n>> +                         boundary->object.oid, &hr);\n>> +            if (hr != 0)\n>> +                kh_value(replayed_commits, pos) = onto;\n>> +        }\n>> +    }\n>> +\n>>       while ((commit = get_revision(revs))) {\n>>           const struct name_decoration *decoration;\n>>           khint_t pos;\n>>           int hr;\n>> -        if (commit->parents && commit->parents->next)\n>> -            die(_(\"replaying merge commits is not supported yet!\"));\n>> -\n>> -        last_commit = pick_regular_commit(revs->repo, commit, \n>> replayed_commits,\n>> -                          mode == REPLAY_MODE_REVERT ? last_commit : \n>> onto,\n>> -                          &merge_opt, &result, mode);\n>> +        if (commit->parents && commit->parents->next) {\n>> +            if (commit->parents->next->next) {\n>> +                ret = error(_(\"replaying octopus merges is not \n>> supported\"));\n>> +                goto out;\n>> +            }\n>> +            if (mode == REPLAY_MODE_REVERT) {\n>> +                ret = error(_(\"reverting merge commits is not \n>> supported\"));\n>> +                goto out;\n>> +            }\n>> +            last_commit = pick_merge_commit(revs->repo, commit,\n>> +                            replayed_commits,\n>> +                            &merge_opt, &result);\n>> +        } else {\n>> +            last_commit = pick_regular_commit(revs->repo, commit, \n>> replayed_commits,\n>> +                              mode == REPLAY_MODE_REVERT ? \n>> last_commit : onto,\n>> +                              &merge_opt, &result, mode);\n>> +        }\n>>           if (!last_commit)\n>>               break;\n>> diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh\n>> index de7b357685..d103f866a2 100755\n>> --- a/t/t3451-history-reword.sh\n>> +++ b/t/t3451-history-reword.sh\n>> @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '\n>>           git switch - &&\n>>           git merge theirs &&\n>> -        # It is not possible to replay merge commits embedded in the\n>> -        # history (yet).\n>> -        test_must_fail git -c core.editor=false history reword HEAD~ \n>> 2>err &&\n>> -        test_grep \"replaying merge commits is not supported yet\" err &&\n>> +        # Reword a non-merge commit whose descendants include the\n>> +        # merge: replay carries the merge through.\n>> +        reword_with_message HEAD~ <<-EOF &&\n>> +        ours reworded\n>> +        EOF\n>> +        expect_graph <<-EOF &&\n>> +        *   Merge tag ${SQ}theirs${SQ}\n>> +        |\\\\\n>> +        | * theirs\n>> +        * | ours reworded\n>> +        |/\n>> +        * base\n>> +        EOF\n>> -        # But it is possible to reword a merge commit directly.\n>> +        # And reword a merge commit directly.\n>>           reword_with_message HEAD <<-EOF &&\n>>           Reworded merge commit\n>>           EOF\n>> @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '\n>>           *   Reworded merge commit\n>>           |\\\n>>           | * theirs\n>> -        * | ours\n>> +        * | ours reworded\n>>           |/\n>>           * base\n>>           EOF\n>> diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh\n>> index 8ed0cebb50..ad6309f98b 100755\n>> --- a/t/t3452-history-split.sh\n>> +++ b/t/t3452-history-split.sh\n>> @@ -36,7 +36,7 @@ expect_tree_entries () {\n>>       test_cmp expect actual\n>>   }\n>> -test_expect_success 'refuses to work with merge commits' '\n>> +test_expect_success 'refuses to split a merge commit' '\n>>       test_when_finished \"rm -rf repo\" &&\n>>       git init repo &&\n>>       (\n>> @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge \n>> commits' '\n>>           git switch - &&\n>>           git merge theirs &&\n>>           test_must_fail git history split HEAD 2>err &&\n>> -        test_grep \"cannot split up merge commit\" err &&\n>> -        test_must_fail git history split HEAD~ 2>err &&\n>> -        test_grep \"replaying merge commits is not supported yet\" err\n>> +        test_grep \"cannot split up merge commit\" err\n>>       )\n>>   '\n>> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n>> index 3353bc4a4d..368b1b0f9a 100755\n>> --- a/t/t3650-replay-basics.sh\n>> +++ b/t/t3650-replay-basics.sh\n>> @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ... \n>> ordering would be ill-defined' '\n>>       test_cmp expect actual\n>>   '\n>> -test_expect_success 'replaying merge commits is not supported yet' '\n>> -    echo \"fatal: replaying merge commits is not supported yet!\" \n>> >expect &&\n>> -    test_must_fail git replay --advance=main main..topic-with-merge \n>> 2>actual &&\n>> -    test_cmp expect actual\n>> +test_expect_success 'using replay to rebase a 2-parent merge' '\n>> +    # main..topic-with-merge contains a 2-parent merge (P) introduced\n>> +    # via test_merge. Use --ref-action=print so this test does not\n>> +    # mutate state for subsequent tests in this file.\n>> +    git replay --ref-action=print --onto main main..topic-with-merge \n>> >result &&\n>> +    test_line_count = 1 result &&\n>> +\n>> +    new_tip=$(cut -f 3 -d \" \" result) &&\n>> +\n>> +    # Result is still a 2-parent merge.\n>> +    git cat-file -p $new_tip >cat &&\n>> +    grep -c \"^parent \" cat >count &&\n>> +    echo 2 >expect &&\n>> +    test_cmp expect count &&\n>> +\n>> +    # Merge subject is preserved.\n>> +    echo P >expect &&\n>> +    git log -1 --format=%s $new_tip >actual &&\n>> +    test_cmp expect actual &&\n>> +\n>> +    # The replayed merge sits on top of main: walking back via the\n>> +    # first-parent chain reaches main.\n>> +    git merge-base --is-ancestor main $new_tip\n>> +'\n>> +\n>> +test_expect_success 'replaying an octopus merge is rejected' '\n>> +    # Build an octopus side-branch so the rest of the test state stays\n>> +    # untouched.\n>> +    test_when_finished \"git update-ref -d refs/heads/octopus-tip\" &&\n>> +    octopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \\\n>> +        -m \"octopus\" $(git rev-parse topic4^{tree})) &&\n>> +    git update-ref refs/heads/octopus-tip \"$octopus_tip\" &&\n>> +\n>> +    test_must_fail git replay --ref-action=print --onto main \\\n>> +        topic4..octopus-tip 2>actual &&\n>> +    test_grep \"octopus merges\" actual\n>> +'\n>> +\n>> +test_expect_success 'reverting a merge commit is rejected' '\n>> +    test_must_fail git replay --ref-action=print --revert=topic-with- \n>> merge \\\n>> +        topic4..topic-with-merge 2>actual &&\n>> +    test_grep \"reverting merge commits\" actual\n>>   '\n>>   test_expect_success 'using replay to rebase two branches, one on top \n>> of other' '\n> \n\n"},{"id":"543164","messageId":"87lddooq2t.fsf@toon--20250203-5JQV3.mail-host-address-is-not-set","threadId":"65603","inReplyTo":"72c486312cde9a9fd2dedb60bc43c5c3e40a0d64.1778107405.git.gitgitgadget@gmail.com","subject":"Re: [PATCH/RFC 4/5] test-tool: add a \"historian\" subcommand for building merge fixtures","fromName":"Toon Claes","fromEmail":"toon@iotcl.com","sentAt":"2026-05-12T10:54:50Z","receivedAt":"2026-05-12T10:55:04Z","isPatch":true,"body":"\"Johannes Schindelin via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Johannes Schindelin <johannes.schindelin@gmx.de>\n>\n> The merge-replay tests added in a follow-up commit need a way to set\n> up specific topologies with full control over blob contents, parent\n> order, and per-side trees. Sequencing plumbing commands or driving\n> plain `git fast-import` from shell quickly becomes unreadable for\n> the kinds of scenarios that exercise non-trivial merge resolution\n> (textual conflicts, semantic edits outside the conflict region,\n> intentional limitations such as new content on one side).\n>\n> Add a small `test-tool historian` subcommand that reads a tight,\n> shell-quoted, one-line-per-object DSL and feeds an equivalent stream\n> to a `git fast-import` child process. Each blob and commit is given\n> a logical name; the helper allocates fast-import marks on first use\n> and emits a lightweight tag for every commit so tests can refer to\n> the resulting object via `refs/tags/<name>`.\n>\n> The DSL has just two directives:\n>\n>   blob NAME LINE...\n>   commit NAME BRANCH SUBJECT [from=NAME] [merge=NAME]... [PATH=BLOB]...\n>\n> A blob's content is the listed lines joined with `\\n` (and a final\n> `\\n`); a commit's tree is exactly the listed PATH=BLOB pairs (the\n> helper emits a `deleteall` so nothing leaks in from the implicit\n> parent). Token splitting is delegated to `split_cmdline()` so quoted\n> arguments work as in shell. Marks for parent references and file\n> contents go through the same `strintmap`-backed name resolver, which\n> keeps the helper itself trivially small: blob writing, tree\n> construction, commit creation and merge-base computation are all\n> handled by `git fast-import`.\n>\n> Note that the DSL reserves the names `from` and `merge` (with a\n> trailing `=`) for parent specification; a tree path called `from` or\n> `merge` cannot be expressed via this helper. That is acceptable here\n> because every input is a tightly controlled test fixture and the\n> filenames are chosen by the test author.\n>\n> The helper trusts its caller: malformed input results in a\n> fast-import error rather than a friendly diagnostic.\n>\n> Wire the new subcommand into the Makefile and meson build, register\n> it in `t/helper/test-tool.{c,h}`.\n>\n> Assisted-by: Claude Opus 4.7\n> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> ---\n>  Makefile                  |   1 +\n>  t/helper/meson.build      |   1 +\n>  t/helper/test-historian.c | 189 ++++++++++++++++++++++++++++++++++++++\n>  t/helper/test-tool.c      |   1 +\n>  t/helper/test-tool.h      |   1 +\n>  5 files changed, 193 insertions(+)\n>  create mode 100644 t/helper/test-historian.c\n>\n> diff --git a/Makefile b/Makefile\n> index cedc234173..b38678b484 100644\n> --- a/Makefile\n> +++ b/Makefile\n> @@ -832,6 +832,7 @@ TEST_BUILTINS_OBJS += test-hash-speed.o\n>  TEST_BUILTINS_OBJS += test-hash.o\n>  TEST_BUILTINS_OBJS += test-hashmap.o\n>  TEST_BUILTINS_OBJS += test-hexdump.o\n> +TEST_BUILTINS_OBJS += test-historian.o\n>  TEST_BUILTINS_OBJS += test-json-writer.o\n>  TEST_BUILTINS_OBJS += test-lazy-init-name-hash.o\n>  TEST_BUILTINS_OBJS += test-match-trees.o\n> diff --git a/t/helper/meson.build b/t/helper/meson.build\n> index 675e64c010..704edd1e1f 100644\n> --- a/t/helper/meson.build\n> +++ b/t/helper/meson.build\n> @@ -29,6 +29,7 @@ test_tool_sources = [\n>    'test-hash.c',\n>    'test-hashmap.c',\n>    'test-hexdump.c',\n> +  'test-historian.c',\n>    'test-json-writer.c',\n>    'test-lazy-init-name-hash.c',\n>    'test-match-trees.c',\n> diff --git a/t/helper/test-historian.c b/t/helper/test-historian.c\n> new file mode 100644\n> index 0000000000..2250d420c0\n> --- /dev/null\n> +++ b/t/helper/test-historian.c\n> @@ -0,0 +1,189 @@\n> +/*\n> + * Build a small history out of a tiny declarative input. Used by tests\n> + * that need specific merge topologies without long sequences of\n> + * plumbing commands or fragile shell helpers.\n> + *\n> + * The historian reads stdin line by line and emits an equivalent\n> + * stream to a `git fast-import` child process. It also allocates marks\n> + * for named objects so tests can refer to commits and blobs by name.\n\nReally appreciate you're introducing this command. I'm actually\nsurprised no else did before.\n\n> + *\n> + * Input directives (one per line, shell-style quoting):\n> + *\n> + *\tblob NAME LINE1 LINE2 ...\n> + *\t    Each LINE becomes a content line in the blob; lines are\n> + *\t    joined with '\\n' and the blob ends with a final '\\n'. With\n> + *\t    no LINEs, the blob is empty.\n> + *\n> + *\tcommit NAME BRANCH SUBJECT [from=PARENT] [merge=PARENT]... [PATH=BLOB]...\n\nI'm not sure how I feel about mixing named arguments (like `from=PARENT`) with\nthe `PATH=BLOB` arguments? Obviously this tool isn't made for anything\nthat's even close to production, but still feels strange. How about\nputting a double dash (`--`) before the paths, or using the `PATH:BLOB`\nsyntax instead?\n\n> + *\t    Creates a commit on refs/heads/BRANCH using the listed\n> + *\t    file=blob mappings as the entire tree (no inheritance from\n> + *\t    parents). Up to one `from=` and any number of `merge=`\n> + *\t    parents may be given. `from=` defaults to the current branch\n> + *\t    tip; if BRANCH has no tip yet, the commit becomes a root.\n\nAt GitLab in our Gitaly suite we have a similar tool as what you're\nintroducing here, but there you have to specify the parent(s) for each\ncommit and if you want to assign a ref to a commit, you have to be\nexplicit about it. So I would replace `from=` and `merge=` with\n`parent=` and allow that to be occur zero or more times (this would also\nallow creating unrelated histories). And remove the mandatory argument\nBRANCH, and instead allow the command to accept a `branch=` argument.\n\nIf we'd take an example from the follow-up commit:\n\n        # Setup:\n        #       A (a) --- C (a, h) ----+--- M (a, g, h)\n        #        \\                    /\n        #         +-- B (a, g) ------+\n        #\n        # Topic touches `g` only; main touches `h` only. The auto-merge\n        # at M is clean.\n        blob a \"shared content\"\n        blob g guarded\n        blob h host\n        commit A main \"A\" a=a\n        commit B topic \"B (introduces g)\" from=A a=a g=g\n        commit C main \"C (introduces h)\" a=a h=h\n        commit M main \"Merge topic\" merge=B a=a g=g h=h\n\nI would suggest to rewrite that to:\n\n        blob a \"shared content\"\n        blob g guarded\n        blob h host\n        commit A \"A\" a:a\n        commit B \"B (introduces g)\" parent=A branch=topic a:a g:g\n        commit C \"C (introduces h)\" parent=A a:a h:h\n        commit M \"Merge topic\" parent=A parent=B ref=main a:a g:g h:h\n\nI realize this is less alike to git-fast-import(1), so I'd understand if\nyou'd reject this idea.\n\n\n-- \nCheers,\nToon\n"},{"id":"543475","messageId":"c594ed5c-5c81-c6b7-c660-11b2ce1bb3b5@gmx.de","threadId":"65603","inReplyTo":"21A507D3-1B0D-4404-8AF5-9485B01E63A6@gmail.com","subject":"Re: [PATCH/RFC 0/5] replay: support replaying 2-parent merges","fromName":"Johannes Schindelin","fromEmail":"johannes.schindelin@gmx.de","sentAt":"2026-05-17T11:33:22Z","receivedAt":"2026-05-17T11:34:49Z","isPatch":true,"body":"Hi Ben,\n\nOn Thu, 7 May 2026, Ben Knoble wrote:\n\n> > Le 7 mai 2026 à 11:06, Johannes Schindelin <johannes.schindelin@gmx.de> a écrit :\n> > \n> >> On Thu, 7 May 2026, D. Ben Knoble wrote:\n> >> \n> >>> On Wed, May 6, 2026 at 6:44 PM Johannes Schindelin via GitGitGadget\n> >>> <gitgitgadget@gmail.com> wrote:\n> >>> \n> >>> [...]\n> >>> \n> >>> While I was at it, git history reword had a pre-existing\n> >>> silent-success bug: a positive return from replay_revisions() (which\n> >>> means \"conflict, no updates queued\") was treated as success. Obviously\n> >>> this should never occur, as a reword simply does not change any of the\n> >>> file contents, but bugs do happen. The merge-replay work is complex\n> >>> enough to make that class of bugs more likely, therefore I introduce\n> >>> error messages for those instances.\n> >> \n> >> Fixing this bug sounded interesting; I had a hard time spotting it\n> >> while skimming the first 2 patches.\n> > \n> > It's this part:\n> > \n> > @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,\n> >    if (ret < 0) {\n> >        ret = error(_(\"failed replaying descendants\"));\n> >        goto out;\n> > +    } else if (ret) {\n> > +        ret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n> > +        goto out;\n> >    }\n> > \n> >    ret = 0;\n> > @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,\n> >    if (ret < 0) {\n> >        ret = error(_(\"failed replaying descendants\"));\n> >        goto out;\n> > +    } else if (ret) {\n> > +        ret = error(_(\"conflict during replay; some descendants were not rewritten\"));\n> > +        goto out;\n> >    }\n> > \n> >    ret = 0;\n> \n> Thanks, super helpful.\n> \n> (Perhaps later) if we can say _which_ descendants weren’t rewritten, that might be good.\n\nI am afraid that that particular information is lost at this point, all we\nhave to work with is an `int ret`.\n\nCiao,\nJohannes\n\n> >> Did I just miss it? Is it worth splitting that fix out to a separate patch?\n> > \n> > Well, you _could_ argue that they were not bugs at all: a `git history\n> > reword` isn't supposed to be able to result in merge conflicts, nor is\n> > `git history split` because they leave the respective commits tree-same\n> > (in the `split` case, the second commit).\n> \n> I seem to recall Patrick making a similar argument, but don’t let me put\n> words in anyone’s mouth. \n> \n> > I could see the point were anybody to suggest using `BUG()` instead of\n> > `error()` here, but erred on the \"nicer to the user\" side.\n> > \n> > The only way this _might_ be triggered before this patch series is most\n> > likely by playing games with replace objects. Or maybe you cannot trigger\n> > it at all.\n> > \n> > With the changes in this here patch series, I wasn't so certain that I had\n> > covered all the edge cases (an early iteration of the quick short-cut in\n> > patch 2/5 keyed only on the parent commits' trees, and forgot to verify\n> > the merge _bases_' trees, for example). That's why I think it matters more\n> > now than it did before.\n> > \n> > Ciao,\n> > Johannes\n> \n> Makes sense, thanks.\n"},{"id":"543476","messageId":"13abccc6-60f7-64ea-db38-e61abb42c159@gmx.de","threadId":"65603","inReplyTo":"87lddooq2t.fsf@toon--20250203-5JQV3.mail-host-address-is-not-set","subject":"Re: [PATCH/RFC 4/5] test-tool: add a \"historian\" subcommand for building merge fixtures","fromName":"Johannes Schindelin","fromEmail":"johannes.schindelin@gmx.de","sentAt":"2026-05-17T11:40:03Z","receivedAt":"2026-05-17T11:41:40Z","isPatch":true,"body":"Hi Toon,\n\nOn Tue, 12 May 2026, Toon Claes wrote:\n\n> \"Johannes Schindelin via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n> \n> > diff --git a/t/helper/test-historian.c b/t/helper/test-historian.c\n> > new file mode 100644\n> > index 0000000000..2250d420c0\n> > --- /dev/null\n> > +++ b/t/helper/test-historian.c\n> > @@ -0,0 +1,189 @@\n> > +/*\n> > + * Build a small history out of a tiny declarative input. Used by tests\n> > + * that need specific merge topologies without long sequences of\n> > + * plumbing commands or fragile shell helpers.\n> > + *\n> > + * The historian reads stdin line by line and emits an equivalent\n> > + * stream to a `git fast-import` child process. It also allocates marks\n> > + * for named objects so tests can refer to commits and blobs by name.\n> \n> Really appreciate you're introducing this command. I'm actually\n> surprised no else did before.\n\nHeh. I am not surprised, though. Given that it is _really_ hard to come up\nwith a decent Domain-Specific Language to describe commit history for\ndefining test fixtures (testament to which are your comments below), I was\ndebating whether to go for range-diff's/libgit2's approach and simply\nadding a bare repository or a fast-import script as a fixture. But that\nwould make the test code even harder to follow! And I did not want to add\nto the amount of hard-to-follow test code.\n\n> > + *\n> > + * Input directives (one per line, shell-style quoting):\n> > + *\n> > + *\tblob NAME LINE1 LINE2 ...\n> > + *\t    Each LINE becomes a content line in the blob; lines are\n> > + *\t    joined with '\\n' and the blob ends with a final '\\n'. With\n> > + *\t    no LINEs, the blob is empty.\n> > + *\n> > + *\tcommit NAME BRANCH SUBJECT [from=PARENT] [merge=PARENT]... [PATH=BLOB]...\n> \n> I'm not sure how I feel about mixing named arguments (like `from=PARENT`) with\n> the `PATH=BLOB` arguments? Obviously this tool isn't made for anything\n> that's even close to production, but still feels strange. How about\n> putting a double dash (`--`) before the paths, or using the `PATH:BLOB`\n> syntax instead?\n\nOkay, I can see that's a better design. To be honest, I did not really\noptimize for unambiguity here, but for precision of defining a test\nfixture. As such, I am mostly interested in keeping the definition as\nsmall as possible without losing readability. Changing that `=` to a `:`\nto disambiguate keeps the same length while clarifying the structure. I\nlike it!\n\n> > + *\t    Creates a commit on refs/heads/BRANCH using the listed\n> > + *\t    file=blob mappings as the entire tree (no inheritance from\n> > + *\t    parents). Up to one `from=` and any number of `merge=`\n> > + *\t    parents may be given. `from=` defaults to the current branch\n> > + *\t    tip; if BRANCH has no tip yet, the commit becomes a root.\n> \n> At GitLab in our Gitaly suite we have a similar tool as what you're\n> introducing here, but there you have to specify the parent(s) for each\n> commit and if you want to assign a ref to a commit, you have to be\n> explicit about it. So I would replace `from=` and `merge=` with\n> `parent=` and allow that to be occur zero or more times (this would also\n> allow creating unrelated histories). And remove the mandatory argument\n> BRANCH, and instead allow the command to accept a `branch=` argument.\n> \n> If we'd take an example from the follow-up commit:\n> \n>         # Setup:\n>         #       A (a) --- C (a, h) ----+--- M (a, g, h)\n>         #        \\                    /\n>         #         +-- B (a, g) ------+\n>         #\n>         # Topic touches `g` only; main touches `h` only. The auto-merge\n>         # at M is clean.\n>         blob a \"shared content\"\n>         blob g guarded\n>         blob h host\n>         commit A main \"A\" a=a\n>         commit B topic \"B (introduces g)\" from=A a=a g=g\n>         commit C main \"C (introduces h)\" a=a h=h\n>         commit M main \"Merge topic\" merge=B a=a g=g h=h\n> \n> I would suggest to rewrite that to:\n> \n>         blob a \"shared content\"\n>         blob g guarded\n>         blob h host\n>         commit A \"A\" a:a\n>         commit B \"B (introduces g)\" parent=A branch=topic a:a g:g\n>         commit C \"C (introduces h)\" parent=A a:a h:h\n>         commit M \"Merge topic\" parent=A parent=B ref=main a:a g:g h:h\n> \n> I realize this is less alike to git-fast-import(1), so I'd understand if\n> you'd reject this idea.\n\nThat makes sense to me!\n\nI'm not interested in keeping the `historian` code as small as possible, I\nam interested in reducing the cognitive load required to read and write\nthose test fixtures. Therefore, the `parent=` way is much preferable.\n\nThank you!\nJohannes\n"},{"id":"543478","messageId":"e094879e-ead9-879b-b889-038025e8e822@gmx.de","threadId":"65603","inReplyTo":"72901ee2-1212-46cd-b752-f451cce6e1ff@gmail.com","subject":"Re: [PATCH/RFC 1/5] replay: support replaying 2-parent merges","fromName":"Johannes Schindelin","fromEmail":"johannes.schindelin@gmx.de","sentAt":"2026-05-17T14:32:11Z","receivedAt":"2026-05-17T14:32:17Z","isPatch":true,"body":"Hi Phillip,\n\nOn Fri, 8 May 2026, Phillip Wood wrote:\n\n> On 06/05/2026 23:43, Johannes Schindelin via GitGitGadget wrote:\n> > \n> > Elijah Newren spelled out a way to lift this limitation in his\n> > replay-design-notes [1] and prototyped it in a 2022\n> > work-in-progress sketch [2]. The idea is that a merge commit M on\n> > parents (P1, P2) records both an automatic merge of those parents\n> > AND any manual layer the author put on top of that automatic merge\n> > (textual conflict resolution and any semantic edit outside conflict\n> > markers). Replaying M onto rewritten parents (P1', P2') must\n> > preserve that manual layer, but the rewritten parents change the\n> > automatic merge, so a simple cherry-pick is wrong: the manual layer\n> > would be re-introduced on top of stale auto-merge text.\n> > \n> > What works instead is a three-way merge of three trees the existing\n> > infrastructure already knows how to compute. Let R be the recursive\n> > auto-merge of (P1, P2), O be M's actual tree and N be the recursive\n> > auto-merge of (P1', P2'). Then `git diff R O` is morally\n> > `git show --remerge-diff M`: it captures exactly what the author\n> > added on top of the automatic merge. A non-recursive 3-way merge\n> > with R as the merge base, O as side 1 and N as side 2 layers that\n> > manual contribution onto the freshly auto-merged rewritten parents\n> > (N) and produces the replayed tree.\n> \n> So we cherry-pick the difference between the user's conflict resolution O and\n> the auto-merge M of the original parents onto the auto-merge N of the replayed\n> parents. If we have a topology that looks like\n> \n>         |\n>        A\n>       /|\\\n>      / B \\\n>      E  |  D\n>         C /\n>         |/\n>         O\n> \n> then running\n> \n>     git replay --onto E --ancestry-path B..O\n> \n> will replay C and O onto E. If the changes in E and D conflict but those\n> conflicts do not overlap with the conflicts in M that were resolved to create\n> O then the replayed version of O will contain conflict markers from the\n> conflicting changes in E and D. Because the previous conflict resolution\n> applies to N without conflicts we do not recognize that there are still\n> conflicts in N that need to be resolved.\n\nVery good point, and exactly the kind of feedback I was hoping for when I\nmarked this as an RFC. Thank you!\n\n> Having realized this I went to look at Elijah's notes and they recognize\n> this possibility and suggest extending the xdiff merge code to detect\n> when N has conflicts that do not correspond to the conflicts in M. That\n> sounds like quite a lot of work. I've not put much effort into coming up\n> with a counterexample but think that because \"git replay\" and \"git\n> history\" do not yet allow the commits in the merged branches to be\n> edited we may be able to safely use the implementation proposed in this\n> series if both merge parents have been rebased (or we might want all the\n> merge bases of the new merge to be a descendants of \"--onto\"). In the\n> example above if both the parents were rebased onto E then any new\n> conflicts would happen when picking D rather than when recreating the\n> merge.\n\nRight. I have to admit that I missed this corner-case when I looked at the\noriginal notes.\n\nAnd while `git history`'s `reword` and `split` subcommands won't be\naffected, the upcoming `fixup` subcommand _will_ be affected.\n\nI am reworking the patches as we speak, loosely following Elijah's notes.\nSo far, I'm confident that this will address that problem.\n\nWhat I am not confident at all so far (because I'm still trying to get the\nactual algorithm to work, and haven't had a chance to test this on\nreal-world scenarios) is that the _conflict output_ is helpful. That is,\nwhether the conflict markers in case of corner-cases (merge conflicts in\nR overlapping with merge conflicts in N, but not being identical, for\nexample) are clear enough to act upon, or will only lead to despair in the\nkeen reader.\n\nFor example, I noticed that a merge conflict resolution in O that is no\nlonger necessary in N leads to a quite unhelpful output...\n\nI know that `git replay` is not designed as an interactive tool, but `git\nhistory` is, and will ultimately _have_ to find ways to surface such merge\nconflicts and help the user resolve them and then continue the replay.\n\nFor now, however, I do agree that we need to capture the error modes\ncorrectly.\n\nCiao,\nJohannes\n\n> \n> Thanks\n> \n> Phillip\n> \n> > Implement `pick_merge_commit()` along those lines and dispatch to it\n> > from `replay_revisions()` when the commit being replayed has exactly\n> > two parents. Two specific points (learned the hard way) keep\n> > non-trivial cases working where the WIP sketch [2] bailed out.\n> > First, R and N use identical `merge_options.branch1` and `branch2`\n> > labels (\"ours\"/\"theirs\"). When the original parents conflicted on a\n> > region of a file, both R and N produce textually identical conflict\n> > markers; the outer non-recursive merge then sees N == R in that\n> > region and the user's manual resolution from O wins cleanly. Without\n> > this, the conflict-marker text would differ between R and N (because\n> > the inner merges would label the conflicts differently), and the\n> > outer merge would itself be unclean even when the user did supply a\n> > clean resolution. Second, an unclean inner merge\n> > (`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in\n> > that case still has well-defined contents (with conflict markers in\n> > the conflicted files) and is a valid input to the outer\n> > non-recursive merge. Only a real error (`< 0`) propagates as\n> > failure.\n> > \n> > The replay propagates the textual diffs the user actually made in M;\n> > it does _not_ extrapolate symbol-level intent. If rewriting the\n> > parents pulls in genuinely new content (for example, a brand-new\n> > caller of a function that the merge renamed), that new content stays\n> > as the rewritten parents have it. Symbol-aware refactoring is out of\n> > scope here, just as it is for plain rebase.\n> > \n> > Octopus merges (more than two parents) and revert-of-merge are not\n> > supported and are surfaced as explicit errors at the dispatch point.\n> > The \"split\" sub-command of `git history` continues to refuse when\n> > the targeted commit is itself a merge: split semantics do not apply\n> > to merges. The pre-walk gate in `builtin/history.c` that previously\n> > rejected any merge in the rewrite path now only rejects octopus\n> > merges; rename it accordingly.\n> > \n> > A small refactor in `create_commit()` makes the merge case possible:\n> > the helper now takes a `struct commit_list *parents` rather than a\n> > single parent pointer and takes ownership of the list. The single\n> > existing caller in `pick_regular_commit()` builds and passes a\n> > one-element list; the new `pick_merge_commit()` builds a two-element\n> > list, with the order of the `from` and `merge` parents preserved.\n> > \n> > Update the negative expectations in t3451, t3452 and t3650 that were\n> > asserting the now-retired \"not supported yet\" message, replacing\n> > them with positive coverage where it fits. Octopus rejection and\n> > revert-of-merge rejection are covered by new positive tests in\n> > t3650. A dedicated test script with merge-replay scenarios driven by\n> > a new test-tool fixture builder will follow in a subsequent commit.\n> > \n> > [1] https://github.com/newren/git/blob/replay/replay-design-notes.txt\n> > [2]\n> > https://github.com/newren/git/commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45\n> > \n> > Helped-by: Elijah Newren <newren@gmail.com>\n> > Assisted-by: Claude Opus 4.7\n> > Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> > ---\n> >   builtin/history.c         |  16 ++-\n> >   replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--\n> >   t/t3451-history-reword.sh |  21 ++--\n> >   t/t3452-history-split.sh  |   6 +-\n> >   t/t3650-replay-basics.sh  |  46 ++++++++-\n> >   5 files changed, 269 insertions(+), 29 deletions(-)\n> > \n> > diff --git a/builtin/history.c b/builtin/history.c\n> > index 9526938085..00097b2226 100644\n> > --- a/builtin/history.c\n> > +++ b/builtin/history.c\n> > @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option *opt,\n> > const char *value, int uns\n> >   \treturn 0;\n> >   }\n> >   \n> > -static int revwalk_contains_merges(struct repository *repo,\n> > -\t\t\t\t   const struct strvec *revwalk_args)\n> > +static int revwalk_contains_octopus_merges(struct repository *repo,\n> > +\t\t\t\t\t   const struct strvec *revwalk_args)\n> >   {\n> >    struct strvec args = STRVEC_INIT;\n> >    struct rev_info revs;\n> >    int ret;\n> >   \n> >   \tstrvec_pushv(&args, revwalk_args->v);\n> > -\tstrvec_push(&args, \"--min-parents=2\");\n> > +\tstrvec_push(&args, \"--min-parents=3\");\n> >   \n> >    repo_init_revisions(repo, &revs, NULL);\n> >   @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository\n> > *repo,\n> >    }\n> >   \n> >   \tif (get_revision(&revs)) {\n> > -\t\tret = error(_(\"replaying merge commits is not supported\n> > yet!\"));\n> > +\t\tret = error(_(\"replaying octopus merges is not supported\"));\n> >    \tgoto out;\n> >    }\n> >   @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,\n> >    \tstrvec_push(&args, \"HEAD\");\n> >    }\n> >   -\tret = revwalk_contains_merges(repo, &args);\n> > +\tret = revwalk_contains_octopus_merges(repo, &args);\n> >    if (ret < 0)\n> >     goto out;\n> >   @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,\n> >    if (ret < 0) {\n> >     ret = error(_(\"failed replaying descendants\"));\n> >     goto out;\n> > +\t} else if (ret) {\n> > +\t\tret = error(_(\"conflict during replay; some descendants were\n> > not rewritten\"));\n> > +\t\tgoto out;\n> >    }\n> >   \n> >   \tret = 0;\n> > @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,\n> >    if (ret < 0) {\n> >     ret = error(_(\"failed replaying descendants\"));\n> >     goto out;\n> > +\t} else if (ret) {\n> > +\t\tret = error(_(\"conflict during replay; some descendants were\n> > not rewritten\"));\n> > +\t\tgoto out;\n> >    }\n> >   \n> >   \tret = 0;\n> > diff --git a/replay.c b/replay.c\n> > index f96f1f6551..3dbce095f9 100644\n> > --- a/replay.c\n> > +++ b/replay.c\n> > @@ -1,6 +1,7 @@\n> >   #define USE_THE_REPOSITORY_VARIABLE\n> >   \n> >   #include \"git-compat-util.h\"\n> > +#include \"commit-reach.h\"\n> >   #include \"environment.h\"\n> >   #include \"hex.h\"\n> >   #include \"merge-ort.h\"\n> > @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf *msg,\n> >   \trepo_unuse_commit_buffer(repo, commit, message);\n> >   }\n> >   \n> > +/*\n> > + * Build a new commit with the given tree and parent list, copying author,\n> > + * extra headers and (for pick mode) the commit message from `based_on`.\n> > + *\n> > + * Takes ownership of `parents`: it will be freed before returning, even on\n> > + * error. Parent order is preserved as supplied by the caller.\n> > + */\n> >   static struct commit *create_commit(struct repository *repo,\n> >           struct tree *tree,\n> >           struct commit *based_on,\n> > -\t\t\t\t    struct commit *parent,\n> > +\t\t\t\t    struct commit_list *parents,\n> >   \t\t\t\t    enum replay_mode mode)\n> >   {\n> >    struct object_id ret;\n> >    struct object *obj = NULL;\n> > -\tstruct commit_list *parents = NULL;\n> >    char *author = NULL;\n> >    char *sign_commit = NULL; /* FIXME: cli users might want to sign again */\n> >    struct commit_extra_header *extra = NULL;\n> > @@ -96,7 +103,6 @@ static struct commit *create_commit(struct repository\n> > *repo,\n> >    const char *orig_message = NULL;\n> >    const char *exclude_gpgsig[] = { \"gpgsig\", \"gpgsig-sha256\", NULL };\n> >   -\tcommit_list_insert(parent, &parents);\n> >    extra = read_commit_extra_headers(based_on, exclude_gpgsig);\n> >    if (mode == REPLAY_MODE_REVERT) {\n> >   \t\tgenerate_revert_message(&msg, based_on, repo);\n> > @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct\n> > repository *repo,\n> >   {\n> >    struct commit *base, *replayed_base;\n> >    struct tree *pickme_tree, *base_tree, *replayed_base_tree;\n> > +\tstruct commit_list *parents = NULL;\n> >   \n> >    if (pickme->parents) {\n> >   \t\tbase = pickme->parents->item;\n> > @@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct\n> > repository *repo,\n> >    if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&\n> >        !oideq(&pickme_tree->object.oid, &base_tree->object.oid))\n> >   \t\treturn replayed_base;\n> > -\treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n> > +\tcommit_list_insert(replayed_base, &parents);\n> > +\treturn create_commit(repo, result->tree, pickme, parents, mode);\n> > +}\n> > +\n> > +/*\n> > + * Replay a 2-parent merge commit by composing three calls into merge-ort:\n> > + *\n> > + *   R = recursive merge of pickme's two original parents (auto-remerge of\n> > + *       the original merge, accepting any conflicts)\n> > + *   N = recursive merge of the (possibly rewritten) parents\n> > + *   O = pickme's tree (the user's actual merge, including any manual\n> > + *       resolutions)\n> > + *\n> > + * The picked tree comes from a non-recursive merge using R as the base,\n> > + * O as side1 and N as side2. `git diff R O` is morally `git show\n> > + * --remerge-diff $oldmerge`, so this layers the user's original manual\n> > + * resolution on top of the freshly auto-merged rewritten parents (see\n> > + * `replay-design-notes.txt` on the `replay` branch of newren/git).\n> > + *\n> > + * If the outer 3-way merge is unclean, propagate the conflict status to\n> > + * the caller via `result->clean = 0` and return NULL. The two inner\n> > + * merges (R and N) being unclean is _not_ fatal: the conflict-markered\n> > + * trees they produce are valid inputs to the outer merge, and using\n> > + * identical labels for both inner merges keeps the marker text\n> > + * byte-equal between R and N so the user's resolution recorded in O\n> > + * collapses the conflict cleanly there. Octopus merges (more than two\n> > + * parents) and revert-of-merge are rejected by the caller before this\n> > + * function is invoked.\n> > + */\n> > +static struct commit *pick_merge_commit(struct repository *repo,\n> > +\t\t\t\t\tstruct commit *pickme,\n> > +\t\t\t\t\tkh_oid_map_t *replayed_commits,\n> > +\t\t\t\t\tstruct merge_options *merge_opt,\n> > +\t\t\t\t\tstruct merge_result *result)\n> > +{\n> > +\tstruct commit *parent1, *parent2;\n> > +\tstruct commit *replayed_par1, *replayed_par2;\n> > +\tstruct tree *pickme_tree;\n> > +\tstruct merge_options remerge_opt = { 0 };\n> > +\tstruct merge_options new_merge_opt = { 0 };\n> > +\tstruct merge_result remerge_res = { 0 };\n> > +\tstruct merge_result new_merge_res = { 0 };\n> > +\tstruct commit_list *parent_bases = NULL;\n> > +\tstruct commit_list *replayed_bases = NULL;\n> > +\tstruct commit_list *parents;\n> > +\tstruct commit *picked = NULL;\n> > +\tchar *ancestor_name = NULL;\n> > +\n> > +\tparent1 = pickme->parents->item;\n> > +\tparent2 = pickme->parents->next->item;\n> > +\n> > +\t/*\n> > +\t * Map the merge's parents to their replayed counterparts. With the\n> > +\t * boundary commits pre-seeded into `replayed_commits`, every parent\n> > +\t * either has an explicit mapping (rewritten or boundary -> onto) or\n> > +\t * sits outside the rewrite range entirely; the latter must stay at\n> > +\t * the original parent commit, so use `parent` itself as the fallback\n> > +\t * for both sides.\n> > +\t */\n> > +\treplayed_par1 = mapped_commit(replayed_commits, parent1, parent1);\n> > +\treplayed_par2 = mapped_commit(replayed_commits, parent2, parent2);\n> > +\n> > +\t/*\n> > +\t * R: auto-remerge of the original parents.\n> > +\t *\n> > +\t * Use the same branch labels for the inner merges that compute R\n> > +\t * and N so conflict markers (if any) are textually identical\n> > +\t * between the two; the outer non-recursive merge can then collapse\n> > +\t * the manual resolution from O against them.\n> > +\t */\n> > +\tinit_basic_merge_options(&remerge_opt, repo);\n> > +\tremerge_opt.show_rename_progress = 0;\n> > +\tremerge_opt.branch1 = \"ours\";\n> > +\tremerge_opt.branch2 = \"theirs\";\n> > +\tif (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0) {\n> > +\t\tresult->clean = -1;\n> > +\t\tgoto out;\n> > +\t}\n> > +\tmerge_incore_recursive(&remerge_opt, parent_bases,\n> > +\t\t\t       parent1, parent2, &remerge_res);\n> > +\tparent_bases = NULL; /* consumed by merge_incore_recursive */\n> > +\tif (remerge_res.clean < 0) {\n> > +\t\tresult->clean = remerge_res.clean;\n> > +\t\tgoto out;\n> > +\t}\n> > +\n> > +\t/* N: fresh merge of the (possibly rewritten) parents. */\n> > +\tinit_basic_merge_options(&new_merge_opt, repo);\n> > +\tnew_merge_opt.show_rename_progress = 0;\n> > +\tnew_merge_opt.branch1 = \"ours\";\n> > +\tnew_merge_opt.branch2 = \"theirs\";\n> > +\tif (repo_get_merge_bases(repo, replayed_par1, replayed_par2,\n> > +\t\t\t\t &replayed_bases) < 0) {\n> > +\t\tresult->clean = -1;\n> > +\t\tgoto out;\n> > +\t}\n> > +\tmerge_incore_recursive(&new_merge_opt, replayed_bases,\n> > +\t\t\t       replayed_par1, replayed_par2, &new_merge_res);\n> > +\treplayed_bases = NULL; /* consumed by merge_incore_recursive */\n> > +\tif (new_merge_res.clean < 0) {\n> > +\t\tresult->clean = new_merge_res.clean;\n> > +\t\tgoto out;\n> > +\t}\n> > +\n> > +\t/*\n> > +\t * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.\n> > +\t */\n> > +\tpickme_tree = repo_get_commit_tree(repo, pickme);\n> > +\tancestor_name = xstrfmt(\"auto-remerge of %s\",\n> > +\t\t\t\toid_to_hex(&pickme->object.oid));\n> > +\tmerge_opt->ancestor = ancestor_name;\n> > +\tmerge_opt->branch1 = short_commit_name(repo, pickme);\n> > +\tmerge_opt->branch2 = \"merge of replayed parents\";\n> > +\tmerge_incore_nonrecursive(merge_opt,\n> > +\t\t\t\t  remerge_res.tree,\n> > +\t\t\t\t  pickme_tree,\n> > +\t\t\t\t  new_merge_res.tree,\n> > +\t\t\t\t  result);\n> > +\tmerge_opt->ancestor = NULL;\n> > +\tmerge_opt->branch1 = NULL;\n> > +\tmerge_opt->branch2 = NULL;\n> > +\tif (!result->clean)\n> > +\t\tgoto out;\n> > +\n> > +\tparents = NULL;\n> > +\tcommit_list_insert(replayed_par2, &parents);\n> > +\tcommit_list_insert(replayed_par1, &parents);\n> > +\tpicked = create_commit(repo, result->tree, pickme, parents,\n> > +\t\t\t       REPLAY_MODE_PICK);\n> > +\n> > +out:\n> > +\tfree(ancestor_name);\n> > +\tfree_commit_list(parent_bases);\n> > +\tfree_commit_list(replayed_bases);\n> > +\tmerge_finalize(&remerge_opt, &remerge_res);\n> > +\tmerge_finalize(&new_merge_opt, &new_merge_res);\n> > +\treturn picked;\n> >   }\n> >   \n> >   void replay_result_release(struct replay_result *result)\n> > @@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,\n> >    merge_opt.show_rename_progress = 0;\n> >    last_commit = onto;\n> >    replayed_commits = kh_init_oid_map();\n> > +\n> > +\t/*\n> > +\t * Seed the rewritten-commit map with each negative-side (\"BOTTOM\")\n> > +\t * cmdline entry pointing at `onto`. This matters for merge replay:\n> > +\t * a 2-parent merge whose first parent is the boundary (e.g. the\n> > +\t * commit being reworded) must replay onto the rewritten boundary,\n> > +\t * yet pick_merge_commit uses a self fallback so the second parent\n> > +\t * (a side branch outside the rewrite range) is preserved as-is.\n> > +\t * Pre-seeding the boundary disambiguates the two: in the map ->\n> > +\t * rewritten, missing -> kept as-is.\n> > +\t *\n> > +\t * Only do this for the pick path; revert mode chains reverts\n> > +\t * through last_commit and a pre-seeded boundary would short-circuit\n> > +\t * that chain.\n> > +\t */\n> > +\tif (mode == REPLAY_MODE_PICK) {\n> > +\t\tfor (size_t i = 0; i < revs->cmdline.nr; i++) {\n> > +\t\t\tstruct rev_cmdline_entry *e = &revs->cmdline.rev[i];\n> > +\t\t\tstruct commit *boundary;\n> > +\t\t\tkhint_t pos;\n> > +\t\t\tint hr;\n> > +\n> > +\t\t\tif (!(e->flags & BOTTOM))\n> > +\t\t\t\tcontinue;\n> > +\t\t\tboundary = lookup_commit_reference_gently(revs->repo,\n> > +\n> > &e->item->oid, 1);\n> > +\t\t\tif (!boundary)\n> > +\t\t\t\tcontinue;\n> > +\t\t\tpos = kh_put_oid_map(replayed_commits,\n> > +\t\t\t\t\t     boundary->object.oid, &hr);\n> > +\t\t\tif (hr != 0)\n> > +\t\t\t\tkh_value(replayed_commits, pos) = onto;\n> > +\t\t}\n> > +\t}\n> > +\n> >    while ((commit = get_revision(revs))) {\n> >     const struct name_decoration *decoration;\n> >     khint_t pos;\n> >     int hr;\n> >   -\t\tif (commit->parents && commit->parents->next)\n> > -\t\t\tdie(_(\"replaying merge commits is not supported\n> > yet!\"));\n> > -\n> > -\t\tlast_commit = pick_regular_commit(revs->repo, commit,\n> > replayed_commits,\n> > -\t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ?\n> > last_commit : onto,\n> > -\t\t\t\t\t\t  &merge_opt, &result, mode);\n> > +\t\tif (commit->parents && commit->parents->next) {\n> > +\t\t\tif (commit->parents->next->next) {\n> > +\t\t\t\tret = error(_(\"replaying octopus merges is not\n> > supported\"));\n> > +\t\t\t\tgoto out;\n> > +\t\t\t}\n> > +\t\t\tif (mode == REPLAY_MODE_REVERT) {\n> > +\t\t\t\tret = error(_(\"reverting merge commits is not\n> > supported\"));\n> > +\t\t\t\tgoto out;\n> > +\t\t\t}\n> > +\t\t\tlast_commit = pick_merge_commit(revs->repo, commit,\n> > +\t\t\t\t\t\t\treplayed_commits,\n> > +\t\t\t\t\t\t\t&merge_opt, &result);\n> > +\t\t} else {\n> > +\t\t\tlast_commit = pick_regular_commit(revs->repo, commit,\n> > replayed_commits,\n> > +\t\t\t\t\t\t\t  mode ==\n> > REPLAY_MODE_REVERT ? last_commit : onto,\n> > +\t\t\t\t\t\t\t  &merge_opt, &result,\n> > mode);\n> > +\t\t}\n> >     if (!last_commit)\n> >      break;\n> >   diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh\n> > index de7b357685..d103f866a2 100755\n> > --- a/t/t3451-history-reword.sh\n> > +++ b/t/t3451-history-reword.sh\n> > @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '\n> >     git switch - &&\n> >     git merge theirs &&\n> >   -\t\t# It is not possible to replay merge commits embedded in the\n> > -\t\t# history (yet).\n> > -\t\ttest_must_fail git -c core.editor=false history reword HEAD~\n> > 2>err &&\n> > -\t\ttest_grep \"replaying merge commits is not supported yet\" err\n> > &&\n> > +\t\t# Reword a non-merge commit whose descendants include the\n> > +\t\t# merge: replay carries the merge through.\n> > +\t\treword_with_message HEAD~ <<-EOF &&\n> > +\t\tours reworded\n> > +\t\tEOF\n> > +\t\texpect_graph <<-EOF &&\n> > +\t\t*   Merge tag ${SQ}theirs${SQ}\n> > +\t\t|\\\\\n> > +\t\t| * theirs\n> > +\t\t* | ours reworded\n> > +\t\t|/\n> > +\t\t* base\n> > +\t\tEOF\n> >   -\t\t# But it is possible to reword a merge commit directly.\n> > +\t\t# And reword a merge commit directly.\n> >     reword_with_message HEAD <<-EOF &&\n> >     Reworded merge commit\n> >     EOF\n> > @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '\n> >     *   Reworded merge commit\n> > |\\\n> > | * theirs\n> > -\t\t* | ours\n> > +\t\t* | ours reworded\n> > |/\n> >     * base\n> >     EOF\n> > diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh\n> > index 8ed0cebb50..ad6309f98b 100755\n> > --- a/t/t3452-history-split.sh\n> > +++ b/t/t3452-history-split.sh\n> > @@ -36,7 +36,7 @@ expect_tree_entries () {\n> >   \ttest_cmp expect actual\n> >   }\n> >   \n> > -test_expect_success 'refuses to work with merge commits' '\n> > +test_expect_success 'refuses to split a merge commit' '\n> >    test_when_finished \"rm -rf repo\" &&\n> >    git init repo &&\n> >    (\n> > @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge commits' '\n> >     git switch - &&\n> >     git merge theirs &&\n> >     test_must_fail git history split HEAD 2>err &&\n> > -\t\ttest_grep \"cannot split up merge commit\" err &&\n> > -\t\ttest_must_fail git history split HEAD~ 2>err &&\n> > -\t\ttest_grep \"replaying merge commits is not supported yet\" err\n> > +\t\ttest_grep \"cannot split up merge commit\" err\n> > )\n> >   '\n> >   \n> > diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n> > index 3353bc4a4d..368b1b0f9a 100755\n> > --- a/t/t3650-replay-basics.sh\n> > +++ b/t/t3650-replay-basics.sh\n> > @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ...\n> > ordering would be ill-defined' '\n> >   \ttest_cmp expect actual\n> >   '\n> >   \n> > -test_expect_success 'replaying merge commits is not supported yet' '\n> > -\techo \"fatal: replaying merge commits is not supported yet!\" >expect &&\n> > -\ttest_must_fail git replay --advance=main main..topic-with-merge\n> > 2>actual &&\n> > -\ttest_cmp expect actual\n> > +test_expect_success 'using replay to rebase a 2-parent merge' '\n> > +\t# main..topic-with-merge contains a 2-parent merge (P) introduced\n> > +\t# via test_merge. Use --ref-action=print so this test does not\n> > +\t# mutate state for subsequent tests in this file.\n> > +\tgit replay --ref-action=print --onto main main..topic-with-merge\n> > >result &&\n> > +\ttest_line_count = 1 result &&\n> > +\n> > +\tnew_tip=$(cut -f 3 -d \" \" result) &&\n> > +\n> > +\t# Result is still a 2-parent merge.\n> > +\tgit cat-file -p $new_tip >cat &&\n> > +\tgrep -c \"^parent \" cat >count &&\n> > +\techo 2 >expect &&\n> > +\ttest_cmp expect count &&\n> > +\n> > +\t# Merge subject is preserved.\n> > +\techo P >expect &&\n> > +\tgit log -1 --format=%s $new_tip >actual &&\n> > +\ttest_cmp expect actual &&\n> > +\n> > +\t# The replayed merge sits on top of main: walking back via the\n> > +\t# first-parent chain reaches main.\n> > +\tgit merge-base --is-ancestor main $new_tip\n> > +'\n> > +\n> > +test_expect_success 'replaying an octopus merge is rejected' '\n> > +\t# Build an octopus side-branch so the rest of the test state stays\n> > +\t# untouched.\n> > +\ttest_when_finished \"git update-ref -d refs/heads/octopus-tip\" &&\n> > +\toctopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \\\n> > +\t\t-m \"octopus\" $(git rev-parse topic4^{tree})) &&\n> > +\tgit update-ref refs/heads/octopus-tip \"$octopus_tip\" &&\n> > +\n> > +\ttest_must_fail git replay --ref-action=print --onto main \\\n> > +\t\ttopic4..octopus-tip 2>actual &&\n> > +\ttest_grep \"octopus merges\" actual\n> > +'\n> > +\n> > +test_expect_success 'reverting a merge commit is rejected' '\n> > +\ttest_must_fail git replay --ref-action=print --revert=topic-with-merge\n> > \\\n> > +\t\ttopic4..topic-with-merge 2>actual &&\n> > +\ttest_grep \"reverting merge commits\" actual\n> >   '\n> >   \n> >   test_expect_success 'using replay to rebase two branches, one on top of\n> >   other' '\n> \n> \n"},{"id":"543479","messageId":"cb707cf0-5964-1303-6745-1b504e011792@gmx.de","threadId":"65603","inReplyTo":"3dd21593-9945-4f9c-a9a0-f5c66504da49@gmail.com","subject":"Re: [PATCH/RFC 1/5] replay: support replaying 2-parent merges","fromName":"Johannes Schindelin","fromEmail":"johannes.schindelin@gmx.de","sentAt":"2026-05-17T14:33:15Z","receivedAt":"2026-05-17T14:33:18Z","isPatch":true,"body":"Hi Phillip,\n\nOn Fri, 8 May 2026, Phillip Wood wrote:\n\n> On 08/05/2026 10:36, Phillip Wood wrote:\n> > \n> > On 06/05/2026 23:43, Johannes Schindelin via GitGitGadget wrote:\n> > >\n> > > Elijah Newren spelled out a way to lift this limitation in his\n> > > replay-design-notes [1] and prototyped it in a 2022\n> > > work-in-progress sketch [2]. The idea is that a merge commit M on\n> > > parents (P1, P2) records both an automatic merge of those parents\n> > > AND any manual layer the author put on top of that automatic merge\n> > > (textual conflict resolution and any semantic edit outside conflict\n> > > markers). Replaying M onto rewritten parents (P1', P2') must\n> > > preserve that manual layer, but the rewritten parents change the\n> > > automatic merge, so a simple cherry-pick is wrong: the manual layer\n> > > would be re-introduced on top of stale auto-merge text.\n> > >\n> > > What works instead is a three-way merge of three trees the existing\n> > > infrastructure already knows how to compute. Let R be the recursive\n> > > auto-merge of (P1, P2), O be M's actual tree and N be the recursive\n> > > auto-merge of (P1', P2'). Then `git diff R O` is morally\n> > > `git show --remerge-diff M`: it captures exactly what the author\n> > > added on top of the automatic merge. A non-recursive 3-way merge\n> > > with R as the merge base, O as side 1 and N as side 2 layers that\n> > > manual contribution onto the freshly auto-merged rewritten parents\n> > > (N) and produces the replayed tree.\n> > \n> > So we cherry-pick the difference between the user's conflict resolution O\n> > and the auto-merge M of the original parents onto the auto-merge N of the\n> > replayed parents. If we have a topology that looks like\n> > \n> >          |\n> >          A\n> >         /|\\\n> >        / B \\\n> >       E  |  D\n> >          C /\n> >          |/\n> >          O\n> > \n> > then running\n> > \n> >      git replay --onto E --ancestry-path B..O\n> > \n> > will replay C and O onto E. If the changes in E and D conflict but those\n> > conflicts do not overlap with the conflicts in M that were resolved to\n> > create O then the replayed version of O will contain conflict markers from\n> > the conflicting changes in E and D. Because the previous conflict resolution\n> > applies to N without conflicts we do not recognize that there are still\n> > conflicts in N that need to be resolved.\n> > \n> > Having realized this I went to look at Elijah's notes and they recognize\n> > this possibility and suggest extending the xdiff merge code to detect when N\n> > has conflicts that do not correspond to the conflicts in M. That sounds like\n> > quite a lot of work. I've not put much effort into coming up with a\n> > counterexample but think that because \"git replay\" and \"git history\" do not\n> > yet allow the commits in the merged branches to be edited we may be able to\n> > safely use the implementation proposed in this series if both merge parents\n> > have been rebased (or we might want all the merge bases of the new merge to\n> > be a descendants of \"--onto\"). In the example above if both the parents were\n> > rebased onto E then any new conflicts would happen when picking D rather\n> > than when recreating the merge.\n> \n> One further thought - if only one of the parents has been rebased (i.e. we're\n> replaying O with parents P1' and P2) then can we just cherry-pick the merge -\n> instead of merging P1' and P2, use P1 as the merge-base with O and P1' as the\n> merge heads?\n\nThat's a really good idea! That should _especially_ work well for the\nconflict markers in case of conflicts.\n\nCiao,\nJohannes\n\n> \n> Thanks\n> \n> Phillip\n> \n> > Thanks\n> > \n> > Phillip\n> > \n> > > Implement `pick_merge_commit()` along those lines and dispatch to it\n> > > from `replay_revisions()` when the commit being replayed has exactly\n> > > two parents. Two specific points (learned the hard way) keep\n> > > non-trivial cases working where the WIP sketch [2] bailed out.\n> > > First, R and N use identical `merge_options.branch1` and `branch2`\n> > > labels (\"ours\"/\"theirs\"). When the original parents conflicted on a\n> > > region of a file, both R and N produce textually identical conflict\n> > > markers; the outer non-recursive merge then sees N == R in that\n> > > region and the user's manual resolution from O wins cleanly. Without\n> > > this, the conflict-marker text would differ between R and N (because\n> > > the inner merges would label the conflicts differently), and the\n> > > outer merge would itself be unclean even when the user did supply a\n> > > clean resolution. Second, an unclean inner merge\n> > > (`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in\n> > > that case still has well-defined contents (with conflict markers in\n> > > the conflicted files) and is a valid input to the outer\n> > > non-recursive merge. Only a real error (`< 0`) propagates as\n> > > failure.\n> > >\n> > > The replay propagates the textual diffs the user actually made in M;\n> > > it does _not_ extrapolate symbol-level intent. If rewriting the\n> > > parents pulls in genuinely new content (for example, a brand-new\n> > > caller of a function that the merge renamed), that new content stays\n> > > as the rewritten parents have it. Symbol-aware refactoring is out of\n> > > scope here, just as it is for plain rebase.\n> > >\n> > > Octopus merges (more than two parents) and revert-of-merge are not\n> > > supported and are surfaced as explicit errors at the dispatch point.\n> > > The \"split\" sub-command of `git history` continues to refuse when\n> > > the targeted commit is itself a merge: split semantics do not apply\n> > > to merges. The pre-walk gate in `builtin/history.c` that previously\n> > > rejected any merge in the rewrite path now only rejects octopus\n> > > merges; rename it accordingly.\n> > >\n> > > A small refactor in `create_commit()` makes the merge case possible:\n> > > the helper now takes a `struct commit_list *parents` rather than a\n> > > single parent pointer and takes ownership of the list. The single\n> > > existing caller in `pick_regular_commit()` builds and passes a\n> > > one-element list; the new `pick_merge_commit()` builds a two-element\n> > > list, with the order of the `from` and `merge` parents preserved.\n> > >\n> > > Update the negative expectations in t3451, t3452 and t3650 that were\n> > > asserting the now-retired \"not supported yet\" message, replacing\n> > > them with positive coverage where it fits. Octopus rejection and\n> > > revert-of-merge rejection are covered by new positive tests in\n> > > t3650. A dedicated test script with merge-replay scenarios driven by\n> > > a new test-tool fixture builder will follow in a subsequent commit.\n> > >\n> > > [1] https://github.com/newren/git/blob/replay/replay-design-notes.txt\n> > > [2] https://github.com/newren/git/\n> > > commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45\n> > >\n> > > Helped-by: Elijah Newren <newren@gmail.com>\n> > > Assisted-by: Claude Opus 4.7\n> > > Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n> > > ---\n> > >   builtin/history.c         |  16 ++-\n> > >   replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--\n> > >   t/t3451-history-reword.sh |  21 ++--\n> > >   t/t3452-history-split.sh  |   6 +-\n> > >   t/t3650-replay-basics.sh  |  46 ++++++++-\n> > >   5 files changed, 269 insertions(+), 29 deletions(-)\n> > >\n> > > diff --git a/builtin/history.c b/builtin/history.c\n> > > index 9526938085..00097b2226 100644\n> > > --- a/builtin/history.c\n> > > +++ b/builtin/history.c\n> > > @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option\n> > > *opt, const char *value, int uns\n> > >       return 0;\n> > >   }\n> > > -static int revwalk_contains_merges(struct repository *repo,\n> > > -                   const struct strvec *revwalk_args)\n> > > +static int revwalk_contains_octopus_merges(struct repository *repo,\n> > > +                       const struct strvec *revwalk_args)\n> > >   {\n> > >       struct strvec args = STRVEC_INIT;\n> > >       struct rev_info revs;\n> > >       int ret;\n> > >       strvec_pushv(&args, revwalk_args->v);\n> > > -    strvec_push(&args, \"--min-parents=2\");\n> > > +    strvec_push(&args, \"--min-parents=3\");\n> > >       repo_init_revisions(repo, &revs, NULL);\n> > > @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository\n> > > *repo,\n> > >       }\n> > >       if (get_revision(&revs)) {\n> > > -        ret = error(_(\"replaying merge commits is not supported yet!\"));\n> > > +        ret = error(_(\"replaying octopus merges is not supported\"));\n> > >           goto out;\n> > >       }\n> > > @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,\n> > >           strvec_push(&args, \"HEAD\");\n> > >       }\n> > > -    ret = revwalk_contains_merges(repo, &args);\n> > > +    ret = revwalk_contains_octopus_merges(repo, &args);\n> > >       if (ret < 0)\n> > >           goto out;\n> > > @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,\n> > >       if (ret < 0) {\n> > >           ret = error(_(\"failed replaying descendants\"));\n> > >           goto out;\n> > > +    } else if (ret) {\n> > > +        ret = error(_(\"conflict during replay; some descendants were not\n> > > rewritten\"));\n> > > +        goto out;\n> > >       }\n> > >       ret = 0;\n> > > @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,\n> > >       if (ret < 0) {\n> > >           ret = error(_(\"failed replaying descendants\"));\n> > >           goto out;\n> > > +    } else if (ret) {\n> > > +        ret = error(_(\"conflict during replay; some descendants were not\n> > > rewritten\"));\n> > > +        goto out;\n> > >       }\n> > >       ret = 0;\n> > > diff --git a/replay.c b/replay.c\n> > > index f96f1f6551..3dbce095f9 100644\n> > > --- a/replay.c\n> > > +++ b/replay.c\n> > > @@ -1,6 +1,7 @@\n> > >   #define USE_THE_REPOSITORY_VARIABLE\n> > >   #include \"git-compat-util.h\"\n> > > +#include \"commit-reach.h\"\n> > >   #include \"environment.h\"\n> > >   #include \"hex.h\"\n> > >   #include \"merge-ort.h\"\n> > > @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf\n> > > *msg,\n> > >       repo_unuse_commit_buffer(repo, commit, message);\n> > >   }\n> > > +/*\n> > > + * Build a new commit with the given tree and parent list, copying\n> > > author,\n> > > + * extra headers and (for pick mode) the commit message from `based_on`.\n> > > + *\n> > > + * Takes ownership of `parents`: it will be freed before returning, even\n> > > on\n> > > + * error. Parent order is preserved as supplied by the caller.\n> > > + */\n> > >   static struct commit *create_commit(struct repository *repo,\n> > >                       struct tree *tree,\n> > >                       struct commit *based_on,\n> > > -                    struct commit *parent,\n> > > +                    struct commit_list *parents,\n> > >                       enum replay_mode mode)\n> > >   {\n> > >       struct object_id ret;\n> > >       struct object *obj = NULL;\n> > > -    struct commit_list *parents = NULL;\n> > >       char *author = NULL;\n> > >       char *sign_commit = NULL; /* FIXME: cli users might want to sign\n> > > again */\n> > >       struct commit_extra_header *extra = NULL;\n> > > @@ -96,7 +103,6 @@ static struct commit *create_commit(struct repository\n> > > *repo,\n> > >       const char *orig_message = NULL;\n> > >       const char *exclude_gpgsig[] = { \"gpgsig\", \"gpgsig-sha256\", NULL };\n> > > -    commit_list_insert(parent, &parents);\n> > >       extra = read_commit_extra_headers(based_on, exclude_gpgsig);\n> > >       if (mode == REPLAY_MODE_REVERT) {\n> > >           generate_revert_message(&msg, based_on, repo);\n> > > @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct\n> > > repository *repo,\n> > >   {\n> > >       struct commit *base, *replayed_base;\n> > >       struct tree *pickme_tree, *base_tree, *replayed_base_tree;\n> > > +    struct commit_list *parents = NULL;\n> > >       if (pickme->parents) {\n> > >           base = pickme->parents->item;\n> > > @@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct\n> > > repository *repo,\n> > >       if (oideq(&replayed_base_tree->object.oid, &result->tree- \n> > > >object.oid) &&\n> > >           !oideq(&pickme_tree->object.oid, &base_tree->object.oid))\n> > >           return replayed_base;\n> > > -    return create_commit(repo, result->tree, pickme, replayed_base,\n> > > mode);\n> > > +    commit_list_insert(replayed_base, &parents);\n> > > +    return create_commit(repo, result->tree, pickme, parents, mode);\n> > > +}\n> > > +\n> > > +/*\n> > > + * Replay a 2-parent merge commit by composing three calls into\n> > > merge-ort:\n> > > + *\n> > > + *   R = recursive merge of pickme's two original parents (auto- remerge\n> > > of\n> > > + *       the original merge, accepting any conflicts)\n> > > + *   N = recursive merge of the (possibly rewritten) parents\n> > > + *   O = pickme's tree (the user's actual merge, including any manual\n> > > + *       resolutions)\n> > > + *\n> > > + * The picked tree comes from a non-recursive merge using R as the base,\n> > > + * O as side1 and N as side2. `git diff R O` is morally `git show\n> > > + * --remerge-diff $oldmerge`, so this layers the user's original manual\n> > > + * resolution on top of the freshly auto-merged rewritten parents (see\n> > > + * `replay-design-notes.txt` on the `replay` branch of newren/git).\n> > > + *\n> > > + * If the outer 3-way merge is unclean, propagate the conflict status to\n> > > + * the caller via `result->clean = 0` and return NULL. The two inner\n> > > + * merges (R and N) being unclean is _not_ fatal: the conflict-markered\n> > > + * trees they produce are valid inputs to the outer merge, and using\n> > > + * identical labels for both inner merges keeps the marker text\n> > > + * byte-equal between R and N so the user's resolution recorded in O\n> > > + * collapses the conflict cleanly there. Octopus merges (more than two\n> > > + * parents) and revert-of-merge are rejected by the caller before this\n> > > + * function is invoked.\n> > > + */\n> > > +static struct commit *pick_merge_commit(struct repository *repo,\n> > > +                    struct commit *pickme,\n> > > +                    kh_oid_map_t *replayed_commits,\n> > > +                    struct merge_options *merge_opt,\n> > > +                    struct merge_result *result)\n> > > +{\n> > > +    struct commit *parent1, *parent2;\n> > > +    struct commit *replayed_par1, *replayed_par2;\n> > > +    struct tree *pickme_tree;\n> > > +    struct merge_options remerge_opt = { 0 };\n> > > +    struct merge_options new_merge_opt = { 0 };\n> > > +    struct merge_result remerge_res = { 0 };\n> > > +    struct merge_result new_merge_res = { 0 };\n> > > +    struct commit_list *parent_bases = NULL;\n> > > +    struct commit_list *replayed_bases = NULL;\n> > > +    struct commit_list *parents;\n> > > +    struct commit *picked = NULL;\n> > > +    char *ancestor_name = NULL;\n> > > +\n> > > +    parent1 = pickme->parents->item;\n> > > +    parent2 = pickme->parents->next->item;\n> > > +\n> > > +    /*\n> > > +     * Map the merge's parents to their replayed counterparts. With the\n> > > +     * boundary commits pre-seeded into `replayed_commits`, every parent\n> > > +     * either has an explicit mapping (rewritten or boundary -> onto) or\n> > > +     * sits outside the rewrite range entirely; the latter must stay at\n> > > +     * the original parent commit, so use `parent` itself as the fallback\n> > > +     * for both sides.\n> > > +     */\n> > > +    replayed_par1 = mapped_commit(replayed_commits, parent1, parent1);\n> > > +    replayed_par2 = mapped_commit(replayed_commits, parent2, parent2);\n> > > +\n> > > +    /*\n> > > +     * R: auto-remerge of the original parents.\n> > > +     *\n> > > +     * Use the same branch labels for the inner merges that compute R\n> > > +     * and N so conflict markers (if any) are textually identical\n> > > +     * between the two; the outer non-recursive merge can then collapse\n> > > +     * the manual resolution from O against them.\n> > > +     */\n> > > +    init_basic_merge_options(&remerge_opt, repo);\n> > > +    remerge_opt.show_rename_progress = 0;\n> > > +    remerge_opt.branch1 = \"ours\";\n> > > +    remerge_opt.branch2 = \"theirs\";\n> > > +    if (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0)\n> > > {\n> > > +        result->clean = -1;\n> > > +        goto out;\n> > > +    }\n> > > +    merge_incore_recursive(&remerge_opt, parent_bases,\n> > > +                   parent1, parent2, &remerge_res);\n> > > +    parent_bases = NULL; /* consumed by merge_incore_recursive */\n> > > +    if (remerge_res.clean < 0) {\n> > > +        result->clean = remerge_res.clean;\n> > > +        goto out;\n> > > +    }\n> > > +\n> > > +    /* N: fresh merge of the (possibly rewritten) parents. */\n> > > +    init_basic_merge_options(&new_merge_opt, repo);\n> > > +    new_merge_opt.show_rename_progress = 0;\n> > > +    new_merge_opt.branch1 = \"ours\";\n> > > +    new_merge_opt.branch2 = \"theirs\";\n> > > +    if (repo_get_merge_bases(repo, replayed_par1, replayed_par2,\n> > > +                 &replayed_bases) < 0) {\n> > > +        result->clean = -1;\n> > > +        goto out;\n> > > +    }\n> > > +    merge_incore_recursive(&new_merge_opt, replayed_bases,\n> > > +                   replayed_par1, replayed_par2, &new_merge_res);\n> > > +    replayed_bases = NULL; /* consumed by merge_incore_recursive */\n> > > +    if (new_merge_res.clean < 0) {\n> > > +        result->clean = new_merge_res.clean;\n> > > +        goto out;\n> > > +    }\n> > > +\n> > > +    /*\n> > > +     * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.\n> > > +     */\n> > > +    pickme_tree = repo_get_commit_tree(repo, pickme);\n> > > +    ancestor_name = xstrfmt(\"auto-remerge of %s\",\n> > > +                oid_to_hex(&pickme->object.oid));\n> > > +    merge_opt->ancestor = ancestor_name;\n> > > +    merge_opt->branch1 = short_commit_name(repo, pickme);\n> > > +    merge_opt->branch2 = \"merge of replayed parents\";\n> > > +    merge_incore_nonrecursive(merge_opt,\n> > > +                  remerge_res.tree,\n> > > +                  pickme_tree,\n> > > +                  new_merge_res.tree,\n> > > +                  result);\n> > > +    merge_opt->ancestor = NULL;\n> > > +    merge_opt->branch1 = NULL;\n> > > +    merge_opt->branch2 = NULL;\n> > > +    if (!result->clean)\n> > > +        goto out;\n> > > +\n> > > +    parents = NULL;\n> > > +    commit_list_insert(replayed_par2, &parents);\n> > > +    commit_list_insert(replayed_par1, &parents);\n> > > +    picked = create_commit(repo, result->tree, pickme, parents,\n> > > +                   REPLAY_MODE_PICK);\n> > > +\n> > > +out:\n> > > +    free(ancestor_name);\n> > > +    free_commit_list(parent_bases);\n> > > +    free_commit_list(replayed_bases);\n> > > +    merge_finalize(&remerge_opt, &remerge_res);\n> > > +    merge_finalize(&new_merge_opt, &new_merge_res);\n> > > +    return picked;\n> > >   }\n> > >   void replay_result_release(struct replay_result *result)\n> > > @@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,\n> > >       merge_opt.show_rename_progress = 0;\n> > >       last_commit = onto;\n> > >       replayed_commits = kh_init_oid_map();\n> > > +\n> > > +    /*\n> > > +     * Seed the rewritten-commit map with each negative-side (\"BOTTOM\")\n> > > +     * cmdline entry pointing at `onto`. This matters for merge replay:\n> > > +     * a 2-parent merge whose first parent is the boundary (e.g. the\n> > > +     * commit being reworded) must replay onto the rewritten boundary,\n> > > +     * yet pick_merge_commit uses a self fallback so the second parent\n> > > +     * (a side branch outside the rewrite range) is preserved as-is.\n> > > +     * Pre-seeding the boundary disambiguates the two: in the map ->\n> > > +     * rewritten, missing -> kept as-is.\n> > > +     *\n> > > +     * Only do this for the pick path; revert mode chains reverts\n> > > +     * through last_commit and a pre-seeded boundary would short-circuit\n> > > +     * that chain.\n> > > +     */\n> > > +    if (mode == REPLAY_MODE_PICK) {\n> > > +        for (size_t i = 0; i < revs->cmdline.nr; i++) {\n> > > +            struct rev_cmdline_entry *e = &revs->cmdline.rev[i];\n> > > +            struct commit *boundary;\n> > > +            khint_t pos;\n> > > +            int hr;\n> > > +\n> > > +            if (!(e->flags & BOTTOM))\n> > > +                continue;\n> > > +            boundary = lookup_commit_reference_gently(revs->repo,\n> > > +                                  &e->item->oid, 1);\n> > > +            if (!boundary)\n> > > +                continue;\n> > > +            pos = kh_put_oid_map(replayed_commits,\n> > > +                         boundary->object.oid, &hr);\n> > > +            if (hr != 0)\n> > > +                kh_value(replayed_commits, pos) = onto;\n> > > +        }\n> > > +    }\n> > > +\n> > >       while ((commit = get_revision(revs))) {\n> > >           const struct name_decoration *decoration;\n> > >           khint_t pos;\n> > >           int hr;\n> > > -        if (commit->parents && commit->parents->next)\n> > > -            die(_(\"replaying merge commits is not supported yet!\"));\n> > > -\n> > > -        last_commit = pick_regular_commit(revs->repo, commit,\n> > > replayed_commits,\n> > > -                          mode == REPLAY_MODE_REVERT ? last_commit :\n> > > onto,\n> > > -                          &merge_opt, &result, mode);\n> > > +        if (commit->parents && commit->parents->next) {\n> > > +            if (commit->parents->next->next) {\n> > > +                ret = error(_(\"replaying octopus merges is not\n> > > supported\"));\n> > > +                goto out;\n> > > +            }\n> > > +            if (mode == REPLAY_MODE_REVERT) {\n> > > +                ret = error(_(\"reverting merge commits is not\n> > > supported\"));\n> > > +                goto out;\n> > > +            }\n> > > +            last_commit = pick_merge_commit(revs->repo, commit,\n> > > +                            replayed_commits,\n> > > +                            &merge_opt, &result);\n> > > +        } else {\n> > > +            last_commit = pick_regular_commit(revs->repo, commit,\n> > > replayed_commits,\n> > > +                              mode == REPLAY_MODE_REVERT ? last_commit :\n> > > onto,\n> > > +                              &merge_opt, &result, mode);\n> > > +        }\n> > >           if (!last_commit)\n> > >               break;\n> > > diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh\n> > > index de7b357685..d103f866a2 100755\n> > > --- a/t/t3451-history-reword.sh\n> > > +++ b/t/t3451-history-reword.sh\n> > > @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '\n> > >           git switch - &&\n> > >           git merge theirs &&\n> > > -        # It is not possible to replay merge commits embedded in the\n> > > -        # history (yet).\n> > > -        test_must_fail git -c core.editor=false history reword HEAD~\n> > > 2>err &&\n> > > -        test_grep \"replaying merge commits is not supported yet\" err &&\n> > > +        # Reword a non-merge commit whose descendants include the\n> > > +        # merge: replay carries the merge through.\n> > > +        reword_with_message HEAD~ <<-EOF &&\n> > > +        ours reworded\n> > > +        EOF\n> > > +        expect_graph <<-EOF &&\n> > > +        *   Merge tag ${SQ}theirs${SQ}\n> > > +        |\\\\\n> > > +        | * theirs\n> > > +        * | ours reworded\n> > > +        |/\n> > > +        * base\n> > > +        EOF\n> > > -        # But it is possible to reword a merge commit directly.\n> > > +        # And reword a merge commit directly.\n> > >           reword_with_message HEAD <<-EOF &&\n> > >           Reworded merge commit\n> > >           EOF\n> > > @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '\n> > >           *   Reworded merge commit\n> > >           |\\\n> > >           | * theirs\n> > > -        * | ours\n> > > +        * | ours reworded\n> > >           |/\n> > >           * base\n> > >           EOF\n> > > diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh\n> > > index 8ed0cebb50..ad6309f98b 100755\n> > > --- a/t/t3452-history-split.sh\n> > > +++ b/t/t3452-history-split.sh\n> > > @@ -36,7 +36,7 @@ expect_tree_entries () {\n> > >       test_cmp expect actual\n> > >   }\n> > > -test_expect_success 'refuses to work with merge commits' '\n> > > +test_expect_success 'refuses to split a merge commit' '\n> > >       test_when_finished \"rm -rf repo\" &&\n> > >       git init repo &&\n> > >       (\n> > > @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge commits'\n> > > '\n> > >           git switch - &&\n> > >           git merge theirs &&\n> > >           test_must_fail git history split HEAD 2>err &&\n> > > -        test_grep \"cannot split up merge commit\" err &&\n> > > -        test_must_fail git history split HEAD~ 2>err &&\n> > > -        test_grep \"replaying merge commits is not supported yet\" err\n> > > +        test_grep \"cannot split up merge commit\" err\n> > >       )\n> > >   '\n> > > diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh\n> > > index 3353bc4a4d..368b1b0f9a 100755\n> > > --- a/t/t3650-replay-basics.sh\n> > > +++ b/t/t3650-replay-basics.sh\n> > > @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ...\n> > > ordering would be ill-defined' '\n> > >       test_cmp expect actual\n> > >   '\n> > > -test_expect_success 'replaying merge commits is not supported yet' '\n> > > -    echo \"fatal: replaying merge commits is not supported yet!\" \n> > > >expect &&\n> > > -    test_must_fail git replay --advance=main main..topic-with-merge\n> > > 2>actual &&\n> > > -    test_cmp expect actual\n> > > +test_expect_success 'using replay to rebase a 2-parent merge' '\n> > > +    # main..topic-with-merge contains a 2-parent merge (P) introduced\n> > > +    # via test_merge. Use --ref-action=print so this test does not\n> > > +    # mutate state for subsequent tests in this file.\n> > > +    git replay --ref-action=print --onto main main..topic-with-merge \n> > > >result &&\n> > > +    test_line_count = 1 result &&\n> > > +\n> > > +    new_tip=$(cut -f 3 -d \" \" result) &&\n> > > +\n> > > +    # Result is still a 2-parent merge.\n> > > +    git cat-file -p $new_tip >cat &&\n> > > +    grep -c \"^parent \" cat >count &&\n> > > +    echo 2 >expect &&\n> > > +    test_cmp expect count &&\n> > > +\n> > > +    # Merge subject is preserved.\n> > > +    echo P >expect &&\n> > > +    git log -1 --format=%s $new_tip >actual &&\n> > > +    test_cmp expect actual &&\n> > > +\n> > > +    # The replayed merge sits on top of main: walking back via the\n> > > +    # first-parent chain reaches main.\n> > > +    git merge-base --is-ancestor main $new_tip\n> > > +'\n> > > +\n> > > +test_expect_success 'replaying an octopus merge is rejected' '\n> > > +    # Build an octopus side-branch so the rest of the test state stays\n> > > +    # untouched.\n> > > +    test_when_finished \"git update-ref -d refs/heads/octopus-tip\" &&\n> > > +    octopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \\\n> > > +        -m \"octopus\" $(git rev-parse topic4^{tree})) &&\n> > > +    git update-ref refs/heads/octopus-tip \"$octopus_tip\" &&\n> > > +\n> > > +    test_must_fail git replay --ref-action=print --onto main \\\n> > > +        topic4..octopus-tip 2>actual &&\n> > > +    test_grep \"octopus merges\" actual\n> > > +'\n> > > +\n> > > +test_expect_success 'reverting a merge commit is rejected' '\n> > > +    test_must_fail git replay --ref-action=print --revert=topic-with-\n> > > merge \\\n> > > +        topic4..topic-with-merge 2>actual &&\n> > > +    test_grep \"reverting merge commits\" actual\n> > >   '\n> > >   test_expect_success 'using replay to rebase two branches, one on top of\n> > > other' '\n> > \n> \n> \n> \n"},{"id":"544125","messageId":"920cc022-8b63-4dbb-a41d-957ee01a5efd@app.fastmail.com","threadId":"65603","inReplyTo":"034ab0f83822e6db67baa423d9fcb753b12b5ac8.1778107405.git.gitgitgadget@gmail.com","subject":"Re: [PATCH/RFC 1/5] replay: support replaying 2-parent merges","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-05-26T21:15:52Z","receivedAt":"2026-05-26T21:16:18Z","isPatch":true,"body":"On Thu, May 7, 2026, at 00:43, Johannes Schindelin via GitGitGadget wrote:\n> From: Johannes Schindelin <johannes.schindelin@gmx.de>\n>[snip]\n> diff --git a/replay.c b/replay.c\n>[snip]\n> +out:\n> +\tfree(ancestor_name);\n> +\tfree_commit_list(parent_bases);\n> +\tfree_commit_list(replayed_bases);\n\n`free_commit_list` is deprecated in favor of `commit_list_free` since\n52882024 (Merge branch 'ps/commit-list-functions-renamed', 2026-02-13).\n\n> +\tmerge_finalize(&remerge_opt, &remerge_res);\n> +\tmerge_finalize(&new_merge_opt, &new_merge_res);\n> +\treturn picked;\n>  }\n>[snip]\n"}]}