Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions Documentation/git-history.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,39 @@ at once.
LIMITATIONS
-----------

This command does not (yet) work with histories that contain merges. You
should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.

Furthermore, the command does not support operations that can result in merge
conflicts. This limitation is by design as history rewrites are not intended to
be stateful operations. The limitation can be lifted once (if) Git learns about
first-class conflicts.
This command supports two-parent merge commits in the rewrite path:
the auto-remerged tree of the original parents, the merge commit
itself, and the auto-merged tree of the rewritten parents are
combined so that the user's manual conflict resolution (textual or
semantic) is preserved through the replay. Octopus merges (more than
two parents) are not supported and are rejected with an error.

When only one of the two parents of a merge has been rewritten, it
is possible for the rewritten parent and the unchanged parent to
conflict on a region that the original parents did not conflict on.
The user's original resolution of the merge cannot speak to such a
fresh conflict because it never existed at the time the merge was
made. In that situation the replay stops and reports a conflict on
the affected path rather than silently committing a tree whose
content is taken from the rewritten side. Resolve the new conflict
the same way you would resolve any other merge conflict, then
continue. Paths managed by a binary, custom, or other non-textual
merge driver are treated the same way: a fresh inner conflict on
such a path is surfaced, never absorbed.

The replay propagates the textual diffs the user actually made in
the merge commit. It does _not_ extrapolate symbol-level intent: if
rewriting the parents pulls in genuinely new content (for example, a
new caller of a function that the merge renamed), that new content
is _not_ rewritten by the replay and may need a follow-up edit.
Symbol-aware refactoring is out of scope here, just as it is for
plain rebase.

The command does not support operations that can result in merge
conflicts on the replayed merge itself. This limitation is by design
as history rewrites are not intended to be stateful operations. Use
linkgit:git-rebase[1] with the `--rebase-merges` flag when the
rewrite is expected to require interactive conflict resolution.

COMMANDS
--------
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ TEST_BUILTINS_OBJS += test-hash-speed.o
TEST_BUILTINS_OBJS += test-hash.o
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Toon Claes wrote on the Git mailing list (how to reply to this email):

"Johannes Schindelin via GitGitGadget" <gitgitgadget@gmail.com> writes:

> From: Johannes Schindelin <johannes.schindelin@gmx.de>
>
> The merge-replay tests added in a follow-up commit need a way to set
> up specific topologies with full control over blob contents, parent
> order, and per-side trees. Sequencing plumbing commands or driving
> plain `git fast-import` from shell quickly becomes unreadable for
> the kinds of scenarios that exercise non-trivial merge resolution
> (textual conflicts, semantic edits outside the conflict region,
> intentional limitations such as new content on one side).
>
> Add a small `test-tool historian` subcommand that reads a tight,
> shell-quoted, one-line-per-object DSL and feeds an equivalent stream
> to a `git fast-import` child process. Each blob and commit is given
> a logical name; the helper allocates fast-import marks on first use
> and emits a lightweight tag for every commit so tests can refer to
> the resulting object via `refs/tags/<name>`.
>
> The DSL has just two directives:
>
>   blob NAME LINE...
>   commit NAME BRANCH SUBJECT [from=NAME] [merge=NAME]... [PATH=BLOB]...
>
> A blob's content is the listed lines joined with `\n` (and a final
> `\n`); a commit's tree is exactly the listed PATH=BLOB pairs (the
> helper emits a `deleteall` so nothing leaks in from the implicit
> parent). Token splitting is delegated to `split_cmdline()` so quoted
> arguments work as in shell. Marks for parent references and file
> contents go through the same `strintmap`-backed name resolver, which
> keeps the helper itself trivially small: blob writing, tree
> construction, commit creation and merge-base computation are all
> handled by `git fast-import`.
>
> Note that the DSL reserves the names `from` and `merge` (with a
> trailing `=`) for parent specification; a tree path called `from` or
> `merge` cannot be expressed via this helper. That is acceptable here
> because every input is a tightly controlled test fixture and the
> filenames are chosen by the test author.
>
> The helper trusts its caller: malformed input results in a
> fast-import error rather than a friendly diagnostic.
>
> Wire the new subcommand into the Makefile and meson build, register
> it in `t/helper/test-tool.{c,h}`.
>
> Assisted-by: Claude Opus 4.7
> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> ---
>  Makefile                  |   1 +
>  t/helper/meson.build      |   1 +
>  t/helper/test-historian.c | 189 ++++++++++++++++++++++++++++++++++++++
>  t/helper/test-tool.c      |   1 +
>  t/helper/test-tool.h      |   1 +
>  5 files changed, 193 insertions(+)
>  create mode 100644 t/helper/test-historian.c
>
> diff --git a/Makefile b/Makefile
> index cedc234173..b38678b484 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -832,6 +832,7 @@ TEST_BUILTINS_OBJS += test-hash-speed.o
>  TEST_BUILTINS_OBJS += test-hash.o
>  TEST_BUILTINS_OBJS += test-hashmap.o
>  TEST_BUILTINS_OBJS += test-hexdump.o
> +TEST_BUILTINS_OBJS += test-historian.o
>  TEST_BUILTINS_OBJS += test-json-writer.o
>  TEST_BUILTINS_OBJS += test-lazy-init-name-hash.o
>  TEST_BUILTINS_OBJS += test-match-trees.o
> diff --git a/t/helper/meson.build b/t/helper/meson.build
> index 675e64c010..704edd1e1f 100644
> --- a/t/helper/meson.build
> +++ b/t/helper/meson.build
> @@ -29,6 +29,7 @@ test_tool_sources = [
>    'test-hash.c',
>    'test-hashmap.c',
>    'test-hexdump.c',
> +  'test-historian.c',
>    'test-json-writer.c',
>    'test-lazy-init-name-hash.c',
>    'test-match-trees.c',
> diff --git a/t/helper/test-historian.c b/t/helper/test-historian.c
> new file mode 100644
> index 0000000000..2250d420c0
> --- /dev/null
> +++ b/t/helper/test-historian.c
> @@ -0,0 +1,189 @@
> +/*
> + * Build a small history out of a tiny declarative input. Used by tests
> + * that need specific merge topologies without long sequences of
> + * plumbing commands or fragile shell helpers.
> + *
> + * The historian reads stdin line by line and emits an equivalent
> + * stream to a `git fast-import` child process. It also allocates marks
> + * for named objects so tests can refer to commits and blobs by name.

Really appreciate you're introducing this command. I'm actually
surprised no else did before.

> + *
> + * Input directives (one per line, shell-style quoting):
> + *
> + *	blob NAME LINE1 LINE2 ...
> + *	    Each LINE becomes a content line in the blob; lines are
> + *	    joined with '\n' and the blob ends with a final '\n'. With
> + *	    no LINEs, the blob is empty.
> + *
> + *	commit NAME BRANCH SUBJECT [from=PARENT] [merge=PARENT]... [PATH=BLOB]...

I'm not sure how I feel about mixing named arguments (like `from=PARENT`) with
the `PATH=BLOB` arguments? Obviously this tool isn't made for anything
that's even close to production, but still feels strange. How about
putting a double dash (`--`) before the paths, or using the `PATH:BLOB`
syntax instead?

> + *	    Creates a commit on refs/heads/BRANCH using the listed
> + *	    file=blob mappings as the entire tree (no inheritance from
> + *	    parents). Up to one `from=` and any number of `merge=`
> + *	    parents may be given. `from=` defaults to the current branch
> + *	    tip; if BRANCH has no tip yet, the commit becomes a root.

At GitLab in our Gitaly suite we have a similar tool as what you're
introducing here, but there you have to specify the parent(s) for each
commit and if you want to assign a ref to a commit, you have to be
explicit about it. So I would replace `from=` and `merge=` with
`parent=` and allow that to be occur zero or more times (this would also
allow creating unrelated histories). And remove the mandatory argument
BRANCH, and instead allow the command to accept a `branch=` argument.

If we'd take an example from the follow-up commit:

        # Setup:
        #       A (a) --- C (a, h) ----+--- M (a, g, h)
        #        \                    /
        #         +-- B (a, g) ------+
        #
        # Topic touches `g` only; main touches `h` only. The auto-merge
        # at M is clean.
        blob a "shared content"
        blob g guarded
        blob h host
        commit A main "A" a=a
        commit B topic "B (introduces g)" from=A a=a g=g
        commit C main "C (introduces h)" a=a h=h
        commit M main "Merge topic" merge=B a=a g=g h=h

I would suggest to rewrite that to:

        blob a "shared content"
        blob g guarded
        blob h host
        commit A "A" a:a
        commit B "B (introduces g)" parent=A branch=topic a:a g:g
        commit C "C (introduces h)" parent=A a:a h:h
        commit M "Merge topic" parent=A parent=B ref=main a:a g:g h:h

I realize this is less alike to git-fast-import(1), so I'd understand if
you'd reject this idea.


-- 
Cheers,
Toon

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Johannes Schindelin wrote on the Git mailing list (how to reply to this email):

Hi Toon,

On Tue, 12 May 2026, Toon Claes wrote:

> "Johannes Schindelin via GitGitGadget" <gitgitgadget@gmail.com> writes:
> 
> > diff --git a/t/helper/test-historian.c b/t/helper/test-historian.c
> > new file mode 100644
> > index 0000000000..2250d420c0
> > --- /dev/null
> > +++ b/t/helper/test-historian.c
> > @@ -0,0 +1,189 @@
> > +/*
> > + * Build a small history out of a tiny declarative input. Used by tests
> > + * that need specific merge topologies without long sequences of
> > + * plumbing commands or fragile shell helpers.
> > + *
> > + * The historian reads stdin line by line and emits an equivalent
> > + * stream to a `git fast-import` child process. It also allocates marks
> > + * for named objects so tests can refer to commits and blobs by name.
> 
> Really appreciate you're introducing this command. I'm actually
> surprised no else did before.

Heh. I am not surprised, though. Given that it is _really_ hard to come up
with a decent Domain-Specific Language to describe commit history for
defining test fixtures (testament to which are your comments below), I was
debating whether to go for range-diff's/libgit2's approach and simply
adding a bare repository or a fast-import script as a fixture. But that
would make the test code even harder to follow! And I did not want to add
to the amount of hard-to-follow test code.

> > + *
> > + * Input directives (one per line, shell-style quoting):
> > + *
> > + *	blob NAME LINE1 LINE2 ...
> > + *	    Each LINE becomes a content line in the blob; lines are
> > + *	    joined with '\n' and the blob ends with a final '\n'. With
> > + *	    no LINEs, the blob is empty.
> > + *
> > + *	commit NAME BRANCH SUBJECT [from=PARENT] [merge=PARENT]... [PATH=BLOB]...
> 
> I'm not sure how I feel about mixing named arguments (like `from=PARENT`) with
> the `PATH=BLOB` arguments? Obviously this tool isn't made for anything
> that's even close to production, but still feels strange. How about
> putting a double dash (`--`) before the paths, or using the `PATH:BLOB`
> syntax instead?

Okay, I can see that's a better design. To be honest, I did not really
optimize for unambiguity here, but for precision of defining a test
fixture. As such, I am mostly interested in keeping the definition as
small as possible without losing readability. Changing that `=` to a `:`
to disambiguate keeps the same length while clarifying the structure. I
like it!

> > + *	    Creates a commit on refs/heads/BRANCH using the listed
> > + *	    file=blob mappings as the entire tree (no inheritance from
> > + *	    parents). Up to one `from=` and any number of `merge=`
> > + *	    parents may be given. `from=` defaults to the current branch
> > + *	    tip; if BRANCH has no tip yet, the commit becomes a root.
> 
> At GitLab in our Gitaly suite we have a similar tool as what you're
> introducing here, but there you have to specify the parent(s) for each
> commit and if you want to assign a ref to a commit, you have to be
> explicit about it. So I would replace `from=` and `merge=` with
> `parent=` and allow that to be occur zero or more times (this would also
> allow creating unrelated histories). And remove the mandatory argument
> BRANCH, and instead allow the command to accept a `branch=` argument.
> 
> If we'd take an example from the follow-up commit:
> 
>         # Setup:
>         #       A (a) --- C (a, h) ----+--- M (a, g, h)
>         #        \                    /
>         #         +-- B (a, g) ------+
>         #
>         # Topic touches `g` only; main touches `h` only. The auto-merge
>         # at M is clean.
>         blob a "shared content"
>         blob g guarded
>         blob h host
>         commit A main "A" a=a
>         commit B topic "B (introduces g)" from=A a=a g=g
>         commit C main "C (introduces h)" a=a h=h
>         commit M main "Merge topic" merge=B a=a g=g h=h
> 
> I would suggest to rewrite that to:
> 
>         blob a "shared content"
>         blob g guarded
>         blob h host
>         commit A "A" a:a
>         commit B "B (introduces g)" parent=A branch=topic a:a g:g
>         commit C "C (introduces h)" parent=A a:a h:h
>         commit M "Merge topic" parent=A parent=B ref=main a:a g:g h:h
> 
> I realize this is less alike to git-fast-import(1), so I'd understand if
> you'd reject this idea.

That makes sense to me!

I'm not interested in keeping the `historian` code as small as possible, I
am interested in reducing the cognitive load required to read and write
those test fixtures. Therefore, the `parent=` way is much preferable.

Thank you!
Johannes

TEST_BUILTINS_OBJS += test-hashmap.o
TEST_BUILTINS_OBJS += test-hexdump.o
TEST_BUILTINS_OBJS += test-historian.o
TEST_BUILTINS_OBJS += test-json-writer.o
TEST_BUILTINS_OBJS += test-lazy-init-name-hash.o
TEST_BUILTINS_OBJS += test-match-trees.o
Expand Down
4 changes: 4 additions & 0 deletions TODO
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
if only one of the parents has been rebased (i.e. we're
replaying O with parents P1' and P2) then can we just cherry-pick the merge -
instead of merging P1' and P2, use P1 as the merge-base with O and P1' as the
merge heads?
16 changes: 11 additions & 5 deletions builtin/history.c
Original file line number Diff line number Diff line change
Expand Up @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option *opt, const char *value, int uns
return 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phillip Wood wrote on the Git mailing list (how to reply to this email):

Hi Johannes

On 06/05/2026 23:43, Johannes Schindelin via GitGitGadget wrote:
> > Elijah Newren spelled out a way to lift this limitation in his
> replay-design-notes [1] and prototyped it in a 2022
> work-in-progress sketch [2]. The idea is that a merge commit M on
> parents (P1, P2) records both an automatic merge of those parents
> AND any manual layer the author put on top of that automatic merge
> (textual conflict resolution and any semantic edit outside conflict
> markers). Replaying M onto rewritten parents (P1', P2') must
> preserve that manual layer, but the rewritten parents change the
> automatic merge, so a simple cherry-pick is wrong: the manual layer
> would be re-introduced on top of stale auto-merge text.
> > What works instead is a three-way merge of three trees the existing
> infrastructure already knows how to compute. Let R be the recursive
> auto-merge of (P1, P2), O be M's actual tree and N be the recursive
> auto-merge of (P1', P2'). Then `git diff R O` is morally
> `git show --remerge-diff M`: it captures exactly what the author
> added on top of the automatic merge. A non-recursive 3-way merge
> with R as the merge base, O as side 1 and N as side 2 layers that
> manual contribution onto the freshly auto-merged rewritten parents
> (N) and produces the replayed tree.

So we cherry-pick the difference between the user's conflict resolution O and the auto-merge M of the original parents onto the auto-merge N of the replayed parents. If we have a topology that looks like

        |
        A
       /|\
      / B \
     E  |  D
        C /
        |/
        O

then running

    git replay --onto E --ancestry-path B..O

will replay C and O onto E. If the changes in E and D conflict but those conflicts do not overlap with the conflicts in M that were resolved to create O then the replayed version of O will contain conflict markers from the conflicting changes in E and D. Because the previous conflict resolution applies to N without conflicts we do not recognize that there are still conflicts in N that need to be resolved.

Having realized this I went to look at Elijah's notes and they recognize this possibility and suggest extending the xdiff merge code to detect when N has conflicts that do not correspond to the conflicts in M. That sounds like quite a lot of work. I've not put much effort into coming up with a counterexample but think that because "git replay" and "git history" do not yet allow the commits in the merged branches to be edited we may be able to safely use the implementation proposed in this series if both merge parents have been rebased (or we might want all the merge bases of the new merge to be a descendants of "--onto"). In the example above if both the parents were rebased onto E then any new conflicts would happen when picking D rather than when recreating the merge.

Thanks

Phillip

> Implement `pick_merge_commit()` along those lines and dispatch to it
> from `replay_revisions()` when the commit being replayed has exactly
> two parents. Two specific points (learned the hard way) keep
> non-trivial cases working where the WIP sketch [2] bailed out.
> First, R and N use identical `merge_options.branch1` and `branch2`
> labels ("ours"/"theirs"). When the original parents conflicted on a
> region of a file, both R and N produce textually identical conflict
> markers; the outer non-recursive merge then sees N == R in that
> region and the user's manual resolution from O wins cleanly. Without
> this, the conflict-marker text would differ between R and N (because
> the inner merges would label the conflicts differently), and the
> outer merge would itself be unclean even when the user did supply a
> clean resolution. Second, an unclean inner merge
> (`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in
> that case still has well-defined contents (with conflict markers in
> the conflicted files) and is a valid input to the outer
> non-recursive merge. Only a real error (`< 0`) propagates as
> failure.
> > The replay propagates the textual diffs the user actually made in M;
> it does _not_ extrapolate symbol-level intent. If rewriting the
> parents pulls in genuinely new content (for example, a brand-new
> caller of a function that the merge renamed), that new content stays
> as the rewritten parents have it. Symbol-aware refactoring is out of
> scope here, just as it is for plain rebase.
> > Octopus merges (more than two parents) and revert-of-merge are not
> supported and are surfaced as explicit errors at the dispatch point.
> The "split" sub-command of `git history` continues to refuse when
> the targeted commit is itself a merge: split semantics do not apply
> to merges. The pre-walk gate in `builtin/history.c` that previously
> rejected any merge in the rewrite path now only rejects octopus
> merges; rename it accordingly.
> > A small refactor in `create_commit()` makes the merge case possible:
> the helper now takes a `struct commit_list *parents` rather than a
> single parent pointer and takes ownership of the list. The single
> existing caller in `pick_regular_commit()` builds and passes a
> one-element list; the new `pick_merge_commit()` builds a two-element
> list, with the order of the `from` and `merge` parents preserved.
> > Update the negative expectations in t3451, t3452 and t3650 that were
> asserting the now-retired "not supported yet" message, replacing
> them with positive coverage where it fits. Octopus rejection and
> revert-of-merge rejection are covered by new positive tests in
> t3650. A dedicated test script with merge-replay scenarios driven by
> a new test-tool fixture builder will follow in a subsequent commit.
> > [1] https://github.com/newren/git/blob/replay/replay-design-notes.txt
> [2] https://github.com/newren/git/commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45
> > Helped-by: Elijah Newren <newren@gmail.com>
> Assisted-by: Claude Opus 4.7
> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> ---
>   builtin/history.c         |  16 ++-
>   replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--
>   t/t3451-history-reword.sh |  21 ++--
>   t/t3452-history-split.sh  |   6 +-
>   t/t3650-replay-basics.sh  |  46 ++++++++-
>   5 files changed, 269 insertions(+), 29 deletions(-)
> > diff --git a/builtin/history.c b/builtin/history.c
> index 9526938085..00097b2226 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option *opt, const char *value, int uns
>   	return 0;
>   }
>   > -static int revwalk_contains_merges(struct repository *repo,
> -				   const struct strvec *revwalk_args)
> +static int revwalk_contains_octopus_merges(struct repository *repo,
> +					   const struct strvec *revwalk_args)
>   {
>   	struct strvec args = STRVEC_INIT;
>   	struct rev_info revs;
>   	int ret;
>   >   	strvec_pushv(&args, revwalk_args->v);
> -	strvec_push(&args, "--min-parents=2");
> +	strvec_push(&args, "--min-parents=3");
>   >   	repo_init_revisions(repo, &revs, NULL);
>   > @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository *repo,
>   	}
>   >   	if (get_revision(&revs)) {
> -		ret = error(_("replaying merge commits is not supported yet!"));
> +		ret = error(_("replaying octopus merges is not supported"));
>   		goto out;
>   	}
>   > @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,
>   		strvec_push(&args, "HEAD");
>   	}
>   > -	ret = revwalk_contains_merges(repo, &args);
> +	ret = revwalk_contains_octopus_merges(repo, &args);
>   	if (ret < 0)
>   		goto out;
>   > @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,
>   	if (ret < 0) {
>   		ret = error(_("failed replaying descendants"));
>   		goto out;
> +	} else if (ret) {
> +		ret = error(_("conflict during replay; some descendants were not rewritten"));
> +		goto out;
>   	}
>   >   	ret = 0;
> @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,
>   	if (ret < 0) {
>   		ret = error(_("failed replaying descendants"));
>   		goto out;
> +	} else if (ret) {
> +		ret = error(_("conflict during replay; some descendants were not rewritten"));
> +		goto out;
>   	}
>   >   	ret = 0;
> diff --git a/replay.c b/replay.c
> index f96f1f6551..3dbce095f9 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -1,6 +1,7 @@
>   #define USE_THE_REPOSITORY_VARIABLE
>   >   #include "git-compat-util.h"
> +#include "commit-reach.h"
>   #include "environment.h"
>   #include "hex.h"
>   #include "merge-ort.h"
> @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf *msg,
>   	repo_unuse_commit_buffer(repo, commit, message);
>   }
>   > +/*
> + * Build a new commit with the given tree and parent list, copying author,
> + * extra headers and (for pick mode) the commit message from `based_on`.
> + *
> + * Takes ownership of `parents`: it will be freed before returning, even on
> + * error. Parent order is preserved as supplied by the caller.
> + */
>   static struct commit *create_commit(struct repository *repo,
>   				    struct tree *tree,
>   				    struct commit *based_on,
> -				    struct commit *parent,
> +				    struct commit_list *parents,
>   				    enum replay_mode mode)
>   {
>   	struct object_id ret;
>   	struct object *obj = NULL;
> -	struct commit_list *parents = NULL;
>   	char *author = NULL;
>   	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
>   	struct commit_extra_header *extra = NULL;
> @@ -96,7 +103,6 @@ static struct commit *create_commit(struct repository *repo,
>   	const char *orig_message = NULL;
>   	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
>   > -	commit_list_insert(parent, &parents);
>   	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
>   	if (mode == REPLAY_MODE_REVERT) {
>   		generate_revert_message(&msg, based_on, repo);
> @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
>   {
>   	struct commit *base, *replayed_base;
>   	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
> +	struct commit_list *parents = NULL;
>   >   	if (pickme->parents) {
>   		base = pickme->parents->item;
> @@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct repository *repo,
>   	if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&
>   	    !oideq(&pickme_tree->object.oid, &base_tree->object.oid))
>   		return replayed_base;
> -	return create_commit(repo, result->tree, pickme, replayed_base, mode);
> +	commit_list_insert(replayed_base, &parents);
> +	return create_commit(repo, result->tree, pickme, parents, mode);
> +}
> +
> +/*
> + * Replay a 2-parent merge commit by composing three calls into merge-ort:
> + *
> + *   R = recursive merge of pickme's two original parents (auto-remerge of
> + *       the original merge, accepting any conflicts)
> + *   N = recursive merge of the (possibly rewritten) parents
> + *   O = pickme's tree (the user's actual merge, including any manual
> + *       resolutions)
> + *
> + * The picked tree comes from a non-recursive merge using R as the base,
> + * O as side1 and N as side2. `git diff R O` is morally `git show
> + * --remerge-diff $oldmerge`, so this layers the user's original manual
> + * resolution on top of the freshly auto-merged rewritten parents (see
> + * `replay-design-notes.txt` on the `replay` branch of newren/git).
> + *
> + * If the outer 3-way merge is unclean, propagate the conflict status to
> + * the caller via `result->clean = 0` and return NULL. The two inner
> + * merges (R and N) being unclean is _not_ fatal: the conflict-markered
> + * trees they produce are valid inputs to the outer merge, and using
> + * identical labels for both inner merges keeps the marker text
> + * byte-equal between R and N so the user's resolution recorded in O
> + * collapses the conflict cleanly there. Octopus merges (more than two
> + * parents) and revert-of-merge are rejected by the caller before this
> + * function is invoked.
> + */
> +static struct commit *pick_merge_commit(struct repository *repo,
> +					struct commit *pickme,
> +					kh_oid_map_t *replayed_commits,
> +					struct merge_options *merge_opt,
> +					struct merge_result *result)
> +{
> +	struct commit *parent1, *parent2;
> +	struct commit *replayed_par1, *replayed_par2;
> +	struct tree *pickme_tree;
> +	struct merge_options remerge_opt = { 0 };
> +	struct merge_options new_merge_opt = { 0 };
> +	struct merge_result remerge_res = { 0 };
> +	struct merge_result new_merge_res = { 0 };
> +	struct commit_list *parent_bases = NULL;
> +	struct commit_list *replayed_bases = NULL;
> +	struct commit_list *parents;
> +	struct commit *picked = NULL;
> +	char *ancestor_name = NULL;
> +
> +	parent1 = pickme->parents->item;
> +	parent2 = pickme->parents->next->item;
> +
> +	/*
> +	 * Map the merge's parents to their replayed counterparts. With the
> +	 * boundary commits pre-seeded into `replayed_commits`, every parent
> +	 * either has an explicit mapping (rewritten or boundary -> onto) or
> +	 * sits outside the rewrite range entirely; the latter must stay at
> +	 * the original parent commit, so use `parent` itself as the fallback
> +	 * for both sides.
> +	 */
> +	replayed_par1 = mapped_commit(replayed_commits, parent1, parent1);
> +	replayed_par2 = mapped_commit(replayed_commits, parent2, parent2);
> +
> +	/*
> +	 * R: auto-remerge of the original parents.
> +	 *
> +	 * Use the same branch labels for the inner merges that compute R
> +	 * and N so conflict markers (if any) are textually identical
> +	 * between the two; the outer non-recursive merge can then collapse
> +	 * the manual resolution from O against them.
> +	 */
> +	init_basic_merge_options(&remerge_opt, repo);
> +	remerge_opt.show_rename_progress = 0;
> +	remerge_opt.branch1 = "ours";
> +	remerge_opt.branch2 = "theirs";
> +	if (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0) {
> +		result->clean = -1;
> +		goto out;
> +	}
> +	merge_incore_recursive(&remerge_opt, parent_bases,
> +			       parent1, parent2, &remerge_res);
> +	parent_bases = NULL; /* consumed by merge_incore_recursive */
> +	if (remerge_res.clean < 0) {
> +		result->clean = remerge_res.clean;
> +		goto out;
> +	}
> +
> +	/* N: fresh merge of the (possibly rewritten) parents. */
> +	init_basic_merge_options(&new_merge_opt, repo);
> +	new_merge_opt.show_rename_progress = 0;
> +	new_merge_opt.branch1 = "ours";
> +	new_merge_opt.branch2 = "theirs";
> +	if (repo_get_merge_bases(repo, replayed_par1, replayed_par2,
> +				 &replayed_bases) < 0) {
> +		result->clean = -1;
> +		goto out;
> +	}
> +	merge_incore_recursive(&new_merge_opt, replayed_bases,
> +			       replayed_par1, replayed_par2, &new_merge_res);
> +	replayed_bases = NULL; /* consumed by merge_incore_recursive */
> +	if (new_merge_res.clean < 0) {
> +		result->clean = new_merge_res.clean;
> +		goto out;
> +	}
> +
> +	/*
> +	 * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.
> +	 */
> +	pickme_tree = repo_get_commit_tree(repo, pickme);
> +	ancestor_name = xstrfmt("auto-remerge of %s",
> +				oid_to_hex(&pickme->object.oid));
> +	merge_opt->ancestor = ancestor_name;
> +	merge_opt->branch1 = short_commit_name(repo, pickme);
> +	merge_opt->branch2 = "merge of replayed parents";
> +	merge_incore_nonrecursive(merge_opt,
> +				  remerge_res.tree,
> +				  pickme_tree,
> +				  new_merge_res.tree,
> +				  result);
> +	merge_opt->ancestor = NULL;
> +	merge_opt->branch1 = NULL;
> +	merge_opt->branch2 = NULL;
> +	if (!result->clean)
> +		goto out;
> +
> +	parents = NULL;
> +	commit_list_insert(replayed_par2, &parents);
> +	commit_list_insert(replayed_par1, &parents);
> +	picked = create_commit(repo, result->tree, pickme, parents,
> +			       REPLAY_MODE_PICK);
> +
> +out:
> +	free(ancestor_name);
> +	free_commit_list(parent_bases);
> +	free_commit_list(replayed_bases);
> +	merge_finalize(&remerge_opt, &remerge_res);
> +	merge_finalize(&new_merge_opt, &new_merge_res);
> +	return picked;
>   }
>   >   void replay_result_release(struct replay_result *result)
> @@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,
>   	merge_opt.show_rename_progress = 0;
>   	last_commit = onto;
>   	replayed_commits = kh_init_oid_map();
> +
> +	/*
> +	 * Seed the rewritten-commit map with each negative-side ("BOTTOM")
> +	 * cmdline entry pointing at `onto`. This matters for merge replay:
> +	 * a 2-parent merge whose first parent is the boundary (e.g. the
> +	 * commit being reworded) must replay onto the rewritten boundary,
> +	 * yet pick_merge_commit uses a self fallback so the second parent
> +	 * (a side branch outside the rewrite range) is preserved as-is.
> +	 * Pre-seeding the boundary disambiguates the two: in the map ->
> +	 * rewritten, missing -> kept as-is.
> +	 *
> +	 * Only do this for the pick path; revert mode chains reverts
> +	 * through last_commit and a pre-seeded boundary would short-circuit
> +	 * that chain.
> +	 */
> +	if (mode == REPLAY_MODE_PICK) {
> +		for (size_t i = 0; i < revs->cmdline.nr; i++) {
> +			struct rev_cmdline_entry *e = &revs->cmdline.rev[i];
> +			struct commit *boundary;
> +			khint_t pos;
> +			int hr;
> +
> +			if (!(e->flags & BOTTOM))
> +				continue;
> +			boundary = lookup_commit_reference_gently(revs->repo,
> +								  &e->item->oid, 1);
> +			if (!boundary)
> +				continue;
> +			pos = kh_put_oid_map(replayed_commits,
> +					     boundary->object.oid, &hr);
> +			if (hr != 0)
> +				kh_value(replayed_commits, pos) = onto;
> +		}
> +	}
> +
>   	while ((commit = get_revision(revs))) {
>   		const struct name_decoration *decoration;
>   		khint_t pos;
>   		int hr;
>   > -		if (commit->parents && commit->parents->next)
> -			die(_("replaying merge commits is not supported yet!"));
> -
> -		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
> -						  mode == REPLAY_MODE_REVERT ? last_commit : onto,
> -						  &merge_opt, &result, mode);
> +		if (commit->parents && commit->parents->next) {
> +			if (commit->parents->next->next) {
> +				ret = error(_("replaying octopus merges is not supported"));
> +				goto out;
> +			}
> +			if (mode == REPLAY_MODE_REVERT) {
> +				ret = error(_("reverting merge commits is not supported"));
> +				goto out;
> +			}
> +			last_commit = pick_merge_commit(revs->repo, commit,
> +							replayed_commits,
> +							&merge_opt, &result);
> +		} else {
> +			last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
> +							  mode == REPLAY_MODE_REVERT ? last_commit : onto,
> +							  &merge_opt, &result, mode);
> +		}
>   		if (!last_commit)
>   			break;
>   > diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
> index de7b357685..d103f866a2 100755
> --- a/t/t3451-history-reword.sh
> +++ b/t/t3451-history-reword.sh
> @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '
>   		git switch - &&
>   		git merge theirs &&
>   > -		# It is not possible to replay merge commits embedded in the
> -		# history (yet).
> -		test_must_fail git -c core.editor=false history reword HEAD~ 2>err &&
> -		test_grep "replaying merge commits is not supported yet" err &&
> +		# Reword a non-merge commit whose descendants include the
> +		# merge: replay carries the merge through.
> +		reword_with_message HEAD~ <<-EOF &&
> +		ours reworded
> +		EOF
> +		expect_graph <<-EOF &&
> +		*   Merge tag ${SQ}theirs${SQ}
> +		|\\
> +		| * theirs
> +		* | ours reworded
> +		|/
> +		* base
> +		EOF
>   > -		# But it is possible to reword a merge commit directly.
> +		# And reword a merge commit directly.
>   		reword_with_message HEAD <<-EOF &&
>   		Reworded merge commit
>   		EOF
> @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '
>   		*   Reworded merge commit
>   		|\
>   		| * theirs
> -		* | ours
> +		* | ours reworded
>   		|/
>   		* base
>   		EOF
> diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
> index 8ed0cebb50..ad6309f98b 100755
> --- a/t/t3452-history-split.sh
> +++ b/t/t3452-history-split.sh
> @@ -36,7 +36,7 @@ expect_tree_entries () {
>   	test_cmp expect actual
>   }
>   > -test_expect_success 'refuses to work with merge commits' '
> +test_expect_success 'refuses to split a merge commit' '
>   	test_when_finished "rm -rf repo" &&
>   	git init repo &&
>   	(
> @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge commits' '
>   		git switch - &&
>   		git merge theirs &&
>   		test_must_fail git history split HEAD 2>err &&
> -		test_grep "cannot split up merge commit" err &&
> -		test_must_fail git history split HEAD~ 2>err &&
> -		test_grep "replaying merge commits is not supported yet" err
> +		test_grep "cannot split up merge commit" err
>   	)
>   '
>   > diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
> index 3353bc4a4d..368b1b0f9a 100755
> --- a/t/t3650-replay-basics.sh
> +++ b/t/t3650-replay-basics.sh
> @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ... ordering would be ill-defined' '
>   	test_cmp expect actual
>   '
>   > -test_expect_success 'replaying merge commits is not supported yet' '
> -	echo "fatal: replaying merge commits is not supported yet!" >expect &&
> -	test_must_fail git replay --advance=main main..topic-with-merge 2>actual &&
> -	test_cmp expect actual
> +test_expect_success 'using replay to rebase a 2-parent merge' '
> +	# main..topic-with-merge contains a 2-parent merge (P) introduced
> +	# via test_merge. Use --ref-action=print so this test does not
> +	# mutate state for subsequent tests in this file.
> +	git replay --ref-action=print --onto main main..topic-with-merge >result &&
> +	test_line_count = 1 result &&
> +
> +	new_tip=$(cut -f 3 -d " " result) &&
> +
> +	# Result is still a 2-parent merge.
> +	git cat-file -p $new_tip >cat &&
> +	grep -c "^parent " cat >count &&
> +	echo 2 >expect &&
> +	test_cmp expect count &&
> +
> +	# Merge subject is preserved.
> +	echo P >expect &&
> +	git log -1 --format=%s $new_tip >actual &&
> +	test_cmp expect actual &&
> +
> +	# The replayed merge sits on top of main: walking back via the
> +	# first-parent chain reaches main.
> +	git merge-base --is-ancestor main $new_tip
> +'
> +
> +test_expect_success 'replaying an octopus merge is rejected' '
> +	# Build an octopus side-branch so the rest of the test state stays
> +	# untouched.
> +	test_when_finished "git update-ref -d refs/heads/octopus-tip" &&
> +	octopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \
> +		-m "octopus" $(git rev-parse topic4^{tree})) &&
> +	git update-ref refs/heads/octopus-tip "$octopus_tip" &&
> +
> +	test_must_fail git replay --ref-action=print --onto main \
> +		topic4..octopus-tip 2>actual &&
> +	test_grep "octopus merges" actual
> +'
> +
> +test_expect_success 'reverting a merge commit is rejected' '
> +	test_must_fail git replay --ref-action=print --revert=topic-with-merge \
> +		topic4..topic-with-merge 2>actual &&
> +	test_grep "reverting merge commits" actual
>   '
>   >   test_expect_success 'using replay to rebase two branches, one on top of other' '

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phillip Wood wrote on the Git mailing list (how to reply to this email):

On 08/05/2026 10:36, Phillip Wood wrote:
> Hi Johannes
> > On 06/05/2026 23:43, Johannes Schindelin via GitGitGadget wrote:
>>
>> Elijah Newren spelled out a way to lift this limitation in his
>> replay-design-notes [1] and prototyped it in a 2022
>> work-in-progress sketch [2]. The idea is that a merge commit M on
>> parents (P1, P2) records both an automatic merge of those parents
>> AND any manual layer the author put on top of that automatic merge
>> (textual conflict resolution and any semantic edit outside conflict
>> markers). Replaying M onto rewritten parents (P1', P2') must
>> preserve that manual layer, but the rewritten parents change the
>> automatic merge, so a simple cherry-pick is wrong: the manual layer
>> would be re-introduced on top of stale auto-merge text.
>>
>> What works instead is a three-way merge of three trees the existing
>> infrastructure already knows how to compute. Let R be the recursive
>> auto-merge of (P1, P2), O be M's actual tree and N be the recursive
>> auto-merge of (P1', P2'). Then `git diff R O` is morally
>> `git show --remerge-diff M`: it captures exactly what the author
>> added on top of the automatic merge. A non-recursive 3-way merge
>> with R as the merge base, O as side 1 and N as side 2 layers that
>> manual contribution onto the freshly auto-merged rewritten parents
>> (N) and produces the replayed tree.
> > So we cherry-pick the difference between the user's conflict resolution > O and the auto-merge M of the original parents onto the auto-merge N of > the replayed parents. If we have a topology that looks like
> >          |
>          A
>         /|\
>        / B \
>       E  |  D
>          C /
>          |/
>          O
> > then running
> >      git replay --onto E --ancestry-path B..O
> > will replay C and O onto E. If the changes in E and D conflict but those > conflicts do not overlap with the conflicts in M that were resolved to > create O then the replayed version of O will contain conflict markers > from the conflicting changes in E and D. Because the previous conflict > resolution applies to N without conflicts we do not recognize that there > are still conflicts in N that need to be resolved.
> > Having realized this I went to look at Elijah's notes and they recognize > this possibility and suggest extending the xdiff merge code to detect > when N has conflicts that do not correspond to the conflicts in M. That > sounds like quite a lot of work. I've not put much effort into coming up > with a counterexample but think that because "git replay" and "git > history" do not yet allow the commits in the merged branches to be > edited we may be able to safely use the implementation proposed in this > series if both merge parents have been rebased (or we might want all the > merge bases of the new merge to be a descendants of "--onto"). In the > example above if both the parents were rebased onto E then any new > conflicts would happen when picking D rather than when recreating the > merge.

One further thought - if only one of the parents has been rebased (i.e. we're replaying O with parents P1' and P2) then can we just cherry-pick the merge - instead of merging P1' and P2, use P1 as the merge-base with O and P1' as the merge heads?

Thanks

Phillip

> Thanks
> > Phillip
> >> Implement `pick_merge_commit()` along those lines and dispatch to it
>> from `replay_revisions()` when the commit being replayed has exactly
>> two parents. Two specific points (learned the hard way) keep
>> non-trivial cases working where the WIP sketch [2] bailed out.
>> First, R and N use identical `merge_options.branch1` and `branch2`
>> labels ("ours"/"theirs"). When the original parents conflicted on a
>> region of a file, both R and N produce textually identical conflict
>> markers; the outer non-recursive merge then sees N == R in that
>> region and the user's manual resolution from O wins cleanly. Without
>> this, the conflict-marker text would differ between R and N (because
>> the inner merges would label the conflicts differently), and the
>> outer merge would itself be unclean even when the user did supply a
>> clean resolution. Second, an unclean inner merge
>> (`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in
>> that case still has well-defined contents (with conflict markers in
>> the conflicted files) and is a valid input to the outer
>> non-recursive merge. Only a real error (`< 0`) propagates as
>> failure.
>>
>> The replay propagates the textual diffs the user actually made in M;
>> it does _not_ extrapolate symbol-level intent. If rewriting the
>> parents pulls in genuinely new content (for example, a brand-new
>> caller of a function that the merge renamed), that new content stays
>> as the rewritten parents have it. Symbol-aware refactoring is out of
>> scope here, just as it is for plain rebase.
>>
>> Octopus merges (more than two parents) and revert-of-merge are not
>> supported and are surfaced as explicit errors at the dispatch point.
>> The "split" sub-command of `git history` continues to refuse when
>> the targeted commit is itself a merge: split semantics do not apply
>> to merges. The pre-walk gate in `builtin/history.c` that previously
>> rejected any merge in the rewrite path now only rejects octopus
>> merges; rename it accordingly.
>>
>> A small refactor in `create_commit()` makes the merge case possible:
>> the helper now takes a `struct commit_list *parents` rather than a
>> single parent pointer and takes ownership of the list. The single
>> existing caller in `pick_regular_commit()` builds and passes a
>> one-element list; the new `pick_merge_commit()` builds a two-element
>> list, with the order of the `from` and `merge` parents preserved.
>>
>> Update the negative expectations in t3451, t3452 and t3650 that were
>> asserting the now-retired "not supported yet" message, replacing
>> them with positive coverage where it fits. Octopus rejection and
>> revert-of-merge rejection are covered by new positive tests in
>> t3650. A dedicated test script with merge-replay scenarios driven by
>> a new test-tool fixture builder will follow in a subsequent commit.
>>
>> [1] https://github.com/newren/git/blob/replay/replay-design-notes.txt
>> [2] https://github.com/newren/git/ >> commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45
>>
>> Helped-by: Elijah Newren <newren@gmail.com>
>> Assisted-by: Claude Opus 4.7
>> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
>> ---
>>   builtin/history.c         |  16 ++-
>>   replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--
>>   t/t3451-history-reword.sh |  21 ++--
>>   t/t3452-history-split.sh  |   6 +-
>>   t/t3650-replay-basics.sh  |  46 ++++++++-
>>   5 files changed, 269 insertions(+), 29 deletions(-)
>>
>> diff --git a/builtin/history.c b/builtin/history.c
>> index 9526938085..00097b2226 100644
>> --- a/builtin/history.c
>> +++ b/builtin/history.c
>> @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option >> *opt, const char *value, int uns
>>       return 0;
>>   }
>> -static int revwalk_contains_merges(struct repository *repo,
>> -                   const struct strvec *revwalk_args)
>> +static int revwalk_contains_octopus_merges(struct repository *repo,
>> +                       const struct strvec *revwalk_args)
>>   {
>>       struct strvec args = STRVEC_INIT;
>>       struct rev_info revs;
>>       int ret;
>>       strvec_pushv(&args, revwalk_args->v);
>> -    strvec_push(&args, "--min-parents=2");
>> +    strvec_push(&args, "--min-parents=3");
>>       repo_init_revisions(repo, &revs, NULL);
>> @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct >> repository *repo,
>>       }
>>       if (get_revision(&revs)) {
>> -        ret = error(_("replaying merge commits is not supported yet!"));
>> +        ret = error(_("replaying octopus merges is not supported"));
>>           goto out;
>>       }
>> @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,
>>           strvec_push(&args, "HEAD");
>>       }
>> -    ret = revwalk_contains_merges(repo, &args);
>> +    ret = revwalk_contains_octopus_merges(repo, &args);
>>       if (ret < 0)
>>           goto out;
>> @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,
>>       if (ret < 0) {
>>           ret = error(_("failed replaying descendants"));
>>           goto out;
>> +    } else if (ret) {
>> +        ret = error(_("conflict during replay; some descendants were >> not rewritten"));
>> +        goto out;
>>       }
>>       ret = 0;
>> @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,
>>       if (ret < 0) {
>>           ret = error(_("failed replaying descendants"));
>>           goto out;
>> +    } else if (ret) {
>> +        ret = error(_("conflict during replay; some descendants were >> not rewritten"));
>> +        goto out;
>>       }
>>       ret = 0;
>> diff --git a/replay.c b/replay.c
>> index f96f1f6551..3dbce095f9 100644
>> --- a/replay.c
>> +++ b/replay.c
>> @@ -1,6 +1,7 @@
>>   #define USE_THE_REPOSITORY_VARIABLE
>>   #include "git-compat-util.h"
>> +#include "commit-reach.h"
>>   #include "environment.h"
>>   #include "hex.h"
>>   #include "merge-ort.h"
>> @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf >> *msg,
>>       repo_unuse_commit_buffer(repo, commit, message);
>>   }
>> +/*
>> + * Build a new commit with the given tree and parent list, copying >> author,
>> + * extra headers and (for pick mode) the commit message from `based_on`.
>> + *
>> + * Takes ownership of `parents`: it will be freed before returning, >> even on
>> + * error. Parent order is preserved as supplied by the caller.
>> + */
>>   static struct commit *create_commit(struct repository *repo,
>>                       struct tree *tree,
>>                       struct commit *based_on,
>> -                    struct commit *parent,
>> +                    struct commit_list *parents,
>>                       enum replay_mode mode)
>>   {
>>       struct object_id ret;
>>       struct object *obj = NULL;
>> -    struct commit_list *parents = NULL;
>>       char *author = NULL;
>>       char *sign_commit = NULL; /* FIXME: cli users might want to sign >> again */
>>       struct commit_extra_header *extra = NULL;
>> @@ -96,7 +103,6 @@ static struct commit *create_commit(struct >> repository *repo,
>>       const char *orig_message = NULL;
>>       const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
>> -    commit_list_insert(parent, &parents);
>>       extra = read_commit_extra_headers(based_on, exclude_gpgsig);
>>       if (mode == REPLAY_MODE_REVERT) {
>>           generate_revert_message(&msg, based_on, repo);
>> @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct >> repository *repo,
>>   {
>>       struct commit *base, *replayed_base;
>>       struct tree *pickme_tree, *base_tree, *replayed_base_tree;
>> +    struct commit_list *parents = NULL;
>>       if (pickme->parents) {
>>           base = pickme->parents->item;
>> @@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct >> repository *repo,
>>       if (oideq(&replayed_base_tree->object.oid, &result->tree- >> >object.oid) &&
>>           !oideq(&pickme_tree->object.oid, &base_tree->object.oid))
>>           return replayed_base;
>> -    return create_commit(repo, result->tree, pickme, replayed_base, >> mode);
>> +    commit_list_insert(replayed_base, &parents);
>> +    return create_commit(repo, result->tree, pickme, parents, mode);
>> +}
>> +
>> +/*
>> + * Replay a 2-parent merge commit by composing three calls into >> merge-ort:
>> + *
>> + *   R = recursive merge of pickme's two original parents (auto- >> remerge of
>> + *       the original merge, accepting any conflicts)
>> + *   N = recursive merge of the (possibly rewritten) parents
>> + *   O = pickme's tree (the user's actual merge, including any manual
>> + *       resolutions)
>> + *
>> + * The picked tree comes from a non-recursive merge using R as the base,
>> + * O as side1 and N as side2. `git diff R O` is morally `git show
>> + * --remerge-diff $oldmerge`, so this layers the user's original manual
>> + * resolution on top of the freshly auto-merged rewritten parents (see
>> + * `replay-design-notes.txt` on the `replay` branch of newren/git).
>> + *
>> + * If the outer 3-way merge is unclean, propagate the conflict status to
>> + * the caller via `result->clean = 0` and return NULL. The two inner
>> + * merges (R and N) being unclean is _not_ fatal: the conflict-markered
>> + * trees they produce are valid inputs to the outer merge, and using
>> + * identical labels for both inner merges keeps the marker text
>> + * byte-equal between R and N so the user's resolution recorded in O
>> + * collapses the conflict cleanly there. Octopus merges (more than two
>> + * parents) and revert-of-merge are rejected by the caller before this
>> + * function is invoked.
>> + */
>> +static struct commit *pick_merge_commit(struct repository *repo,
>> +                    struct commit *pickme,
>> +                    kh_oid_map_t *replayed_commits,
>> +                    struct merge_options *merge_opt,
>> +                    struct merge_result *result)
>> +{
>> +    struct commit *parent1, *parent2;
>> +    struct commit *replayed_par1, *replayed_par2;
>> +    struct tree *pickme_tree;
>> +    struct merge_options remerge_opt = { 0 };
>> +    struct merge_options new_merge_opt = { 0 };
>> +    struct merge_result remerge_res = { 0 };
>> +    struct merge_result new_merge_res = { 0 };
>> +    struct commit_list *parent_bases = NULL;
>> +    struct commit_list *replayed_bases = NULL;
>> +    struct commit_list *parents;
>> +    struct commit *picked = NULL;
>> +    char *ancestor_name = NULL;
>> +
>> +    parent1 = pickme->parents->item;
>> +    parent2 = pickme->parents->next->item;
>> +
>> +    /*
>> +     * Map the merge's parents to their replayed counterparts. With the
>> +     * boundary commits pre-seeded into `replayed_commits`, every parent
>> +     * either has an explicit mapping (rewritten or boundary -> onto) or
>> +     * sits outside the rewrite range entirely; the latter must stay at
>> +     * the original parent commit, so use `parent` itself as the >> fallback
>> +     * for both sides.
>> +     */
>> +    replayed_par1 = mapped_commit(replayed_commits, parent1, parent1);
>> +    replayed_par2 = mapped_commit(replayed_commits, parent2, parent2);
>> +
>> +    /*
>> +     * R: auto-remerge of the original parents.
>> +     *
>> +     * Use the same branch labels for the inner merges that compute R
>> +     * and N so conflict markers (if any) are textually identical
>> +     * between the two; the outer non-recursive merge can then collapse
>> +     * the manual resolution from O against them.
>> +     */
>> +    init_basic_merge_options(&remerge_opt, repo);
>> +    remerge_opt.show_rename_progress = 0;
>> +    remerge_opt.branch1 = "ours";
>> +    remerge_opt.branch2 = "theirs";
>> +    if (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < >> 0) {
>> +        result->clean = -1;
>> +        goto out;
>> +    }
>> +    merge_incore_recursive(&remerge_opt, parent_bases,
>> +                   parent1, parent2, &remerge_res);
>> +    parent_bases = NULL; /* consumed by merge_incore_recursive */
>> +    if (remerge_res.clean < 0) {
>> +        result->clean = remerge_res.clean;
>> +        goto out;
>> +    }
>> +
>> +    /* N: fresh merge of the (possibly rewritten) parents. */
>> +    init_basic_merge_options(&new_merge_opt, repo);
>> +    new_merge_opt.show_rename_progress = 0;
>> +    new_merge_opt.branch1 = "ours";
>> +    new_merge_opt.branch2 = "theirs";
>> +    if (repo_get_merge_bases(repo, replayed_par1, replayed_par2,
>> +                 &replayed_bases) < 0) {
>> +        result->clean = -1;
>> +        goto out;
>> +    }
>> +    merge_incore_recursive(&new_merge_opt, replayed_bases,
>> +                   replayed_par1, replayed_par2, &new_merge_res);
>> +    replayed_bases = NULL; /* consumed by merge_incore_recursive */
>> +    if (new_merge_res.clean < 0) {
>> +        result->clean = new_merge_res.clean;
>> +        goto out;
>> +    }
>> +
>> +    /*
>> +     * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.
>> +     */
>> +    pickme_tree = repo_get_commit_tree(repo, pickme);
>> +    ancestor_name = xstrfmt("auto-remerge of %s",
>> +                oid_to_hex(&pickme->object.oid));
>> +    merge_opt->ancestor = ancestor_name;
>> +    merge_opt->branch1 = short_commit_name(repo, pickme);
>> +    merge_opt->branch2 = "merge of replayed parents";
>> +    merge_incore_nonrecursive(merge_opt,
>> +                  remerge_res.tree,
>> +                  pickme_tree,
>> +                  new_merge_res.tree,
>> +                  result);
>> +    merge_opt->ancestor = NULL;
>> +    merge_opt->branch1 = NULL;
>> +    merge_opt->branch2 = NULL;
>> +    if (!result->clean)
>> +        goto out;
>> +
>> +    parents = NULL;
>> +    commit_list_insert(replayed_par2, &parents);
>> +    commit_list_insert(replayed_par1, &parents);
>> +    picked = create_commit(repo, result->tree, pickme, parents,
>> +                   REPLAY_MODE_PICK);
>> +
>> +out:
>> +    free(ancestor_name);
>> +    free_commit_list(parent_bases);
>> +    free_commit_list(replayed_bases);
>> +    merge_finalize(&remerge_opt, &remerge_res);
>> +    merge_finalize(&new_merge_opt, &new_merge_res);
>> +    return picked;
>>   }
>>   void replay_result_release(struct replay_result *result)
>> @@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,
>>       merge_opt.show_rename_progress = 0;
>>       last_commit = onto;
>>       replayed_commits = kh_init_oid_map();
>> +
>> +    /*
>> +     * Seed the rewritten-commit map with each negative-side ("BOTTOM")
>> +     * cmdline entry pointing at `onto`. This matters for merge replay:
>> +     * a 2-parent merge whose first parent is the boundary (e.g. the
>> +     * commit being reworded) must replay onto the rewritten boundary,
>> +     * yet pick_merge_commit uses a self fallback so the second parent
>> +     * (a side branch outside the rewrite range) is preserved as-is.
>> +     * Pre-seeding the boundary disambiguates the two: in the map ->
>> +     * rewritten, missing -> kept as-is.
>> +     *
>> +     * Only do this for the pick path; revert mode chains reverts
>> +     * through last_commit and a pre-seeded boundary would short-circuit
>> +     * that chain.
>> +     */
>> +    if (mode == REPLAY_MODE_PICK) {
>> +        for (size_t i = 0; i < revs->cmdline.nr; i++) {
>> +            struct rev_cmdline_entry *e = &revs->cmdline.rev[i];
>> +            struct commit *boundary;
>> +            khint_t pos;
>> +            int hr;
>> +
>> +            if (!(e->flags & BOTTOM))
>> +                continue;
>> +            boundary = lookup_commit_reference_gently(revs->repo,
>> +                                  &e->item->oid, 1);
>> +            if (!boundary)
>> +                continue;
>> +            pos = kh_put_oid_map(replayed_commits,
>> +                         boundary->object.oid, &hr);
>> +            if (hr != 0)
>> +                kh_value(replayed_commits, pos) = onto;
>> +        }
>> +    }
>> +
>>       while ((commit = get_revision(revs))) {
>>           const struct name_decoration *decoration;
>>           khint_t pos;
>>           int hr;
>> -        if (commit->parents && commit->parents->next)
>> -            die(_("replaying merge commits is not supported yet!"));
>> -
>> -        last_commit = pick_regular_commit(revs->repo, commit, >> replayed_commits,
>> -                          mode == REPLAY_MODE_REVERT ? last_commit : >> onto,
>> -                          &merge_opt, &result, mode);
>> +        if (commit->parents && commit->parents->next) {
>> +            if (commit->parents->next->next) {
>> +                ret = error(_("replaying octopus merges is not >> supported"));
>> +                goto out;
>> +            }
>> +            if (mode == REPLAY_MODE_REVERT) {
>> +                ret = error(_("reverting merge commits is not >> supported"));
>> +                goto out;
>> +            }
>> +            last_commit = pick_merge_commit(revs->repo, commit,
>> +                            replayed_commits,
>> +                            &merge_opt, &result);
>> +        } else {
>> +            last_commit = pick_regular_commit(revs->repo, commit, >> replayed_commits,
>> +                              mode == REPLAY_MODE_REVERT ? >> last_commit : onto,
>> +                              &merge_opt, &result, mode);
>> +        }
>>           if (!last_commit)
>>               break;
>> diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
>> index de7b357685..d103f866a2 100755
>> --- a/t/t3451-history-reword.sh
>> +++ b/t/t3451-history-reword.sh
>> @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '
>>           git switch - &&
>>           git merge theirs &&
>> -        # It is not possible to replay merge commits embedded in the
>> -        # history (yet).
>> -        test_must_fail git -c core.editor=false history reword HEAD~ >> 2>err &&
>> -        test_grep "replaying merge commits is not supported yet" err &&
>> +        # Reword a non-merge commit whose descendants include the
>> +        # merge: replay carries the merge through.
>> +        reword_with_message HEAD~ <<-EOF &&
>> +        ours reworded
>> +        EOF
>> +        expect_graph <<-EOF &&
>> +        *   Merge tag ${SQ}theirs${SQ}
>> +        |\\
>> +        | * theirs
>> +        * | ours reworded
>> +        |/
>> +        * base
>> +        EOF
>> -        # But it is possible to reword a merge commit directly.
>> +        # And reword a merge commit directly.
>>           reword_with_message HEAD <<-EOF &&
>>           Reworded merge commit
>>           EOF
>> @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '
>>           *   Reworded merge commit
>>           |\
>>           | * theirs
>> -        * | ours
>> +        * | ours reworded
>>           |/
>>           * base
>>           EOF
>> diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
>> index 8ed0cebb50..ad6309f98b 100755
>> --- a/t/t3452-history-split.sh
>> +++ b/t/t3452-history-split.sh
>> @@ -36,7 +36,7 @@ expect_tree_entries () {
>>       test_cmp expect actual
>>   }
>> -test_expect_success 'refuses to work with merge commits' '
>> +test_expect_success 'refuses to split a merge commit' '
>>       test_when_finished "rm -rf repo" &&
>>       git init repo &&
>>       (
>> @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge >> commits' '
>>           git switch - &&
>>           git merge theirs &&
>>           test_must_fail git history split HEAD 2>err &&
>> -        test_grep "cannot split up merge commit" err &&
>> -        test_must_fail git history split HEAD~ 2>err &&
>> -        test_grep "replaying merge commits is not supported yet" err
>> +        test_grep "cannot split up merge commit" err
>>       )
>>   '
>> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
>> index 3353bc4a4d..368b1b0f9a 100755
>> --- a/t/t3650-replay-basics.sh
>> +++ b/t/t3650-replay-basics.sh
>> @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ... >> ordering would be ill-defined' '
>>       test_cmp expect actual
>>   '
>> -test_expect_success 'replaying merge commits is not supported yet' '
>> -    echo "fatal: replaying merge commits is not supported yet!" >> >expect &&
>> -    test_must_fail git replay --advance=main main..topic-with-merge >> 2>actual &&
>> -    test_cmp expect actual
>> +test_expect_success 'using replay to rebase a 2-parent merge' '
>> +    # main..topic-with-merge contains a 2-parent merge (P) introduced
>> +    # via test_merge. Use --ref-action=print so this test does not
>> +    # mutate state for subsequent tests in this file.
>> +    git replay --ref-action=print --onto main main..topic-with-merge >> >result &&
>> +    test_line_count = 1 result &&
>> +
>> +    new_tip=$(cut -f 3 -d " " result) &&
>> +
>> +    # Result is still a 2-parent merge.
>> +    git cat-file -p $new_tip >cat &&
>> +    grep -c "^parent " cat >count &&
>> +    echo 2 >expect &&
>> +    test_cmp expect count &&
>> +
>> +    # Merge subject is preserved.
>> +    echo P >expect &&
>> +    git log -1 --format=%s $new_tip >actual &&
>> +    test_cmp expect actual &&
>> +
>> +    # The replayed merge sits on top of main: walking back via the
>> +    # first-parent chain reaches main.
>> +    git merge-base --is-ancestor main $new_tip
>> +'
>> +
>> +test_expect_success 'replaying an octopus merge is rejected' '
>> +    # Build an octopus side-branch so the rest of the test state stays
>> +    # untouched.
>> +    test_when_finished "git update-ref -d refs/heads/octopus-tip" &&
>> +    octopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \
>> +        -m "octopus" $(git rev-parse topic4^{tree})) &&
>> +    git update-ref refs/heads/octopus-tip "$octopus_tip" &&
>> +
>> +    test_must_fail git replay --ref-action=print --onto main \
>> +        topic4..octopus-tip 2>actual &&
>> +    test_grep "octopus merges" actual
>> +'
>> +
>> +test_expect_success 'reverting a merge commit is rejected' '
>> +    test_must_fail git replay --ref-action=print --revert=topic-with- >> merge \
>> +        topic4..topic-with-merge 2>actual &&
>> +    test_grep "reverting merge commits" actual
>>   '
>>   test_expect_success 'using replay to rebase two branches, one on top >> of other' '
> 

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Johannes Schindelin wrote on the Git mailing list (how to reply to this email):

Hi Phillip,

On Fri, 8 May 2026, Phillip Wood wrote:

> On 06/05/2026 23:43, Johannes Schindelin via GitGitGadget wrote:
> > 
> > Elijah Newren spelled out a way to lift this limitation in his
> > replay-design-notes [1] and prototyped it in a 2022
> > work-in-progress sketch [2]. The idea is that a merge commit M on
> > parents (P1, P2) records both an automatic merge of those parents
> > AND any manual layer the author put on top of that automatic merge
> > (textual conflict resolution and any semantic edit outside conflict
> > markers). Replaying M onto rewritten parents (P1', P2') must
> > preserve that manual layer, but the rewritten parents change the
> > automatic merge, so a simple cherry-pick is wrong: the manual layer
> > would be re-introduced on top of stale auto-merge text.
> > 
> > What works instead is a three-way merge of three trees the existing
> > infrastructure already knows how to compute. Let R be the recursive
> > auto-merge of (P1, P2), O be M's actual tree and N be the recursive
> > auto-merge of (P1', P2'). Then `git diff R O` is morally
> > `git show --remerge-diff M`: it captures exactly what the author
> > added on top of the automatic merge. A non-recursive 3-way merge
> > with R as the merge base, O as side 1 and N as side 2 layers that
> > manual contribution onto the freshly auto-merged rewritten parents
> > (N) and produces the replayed tree.
> 
> So we cherry-pick the difference between the user's conflict resolution O and
> the auto-merge M of the original parents onto the auto-merge N of the replayed
> parents. If we have a topology that looks like
> 
>         |
>        A
>       /|\
>      / B \
>      E  |  D
>         C /
>         |/
>         O
> 
> then running
> 
>     git replay --onto E --ancestry-path B..O
> 
> will replay C and O onto E. If the changes in E and D conflict but those
> conflicts do not overlap with the conflicts in M that were resolved to create
> O then the replayed version of O will contain conflict markers from the
> conflicting changes in E and D. Because the previous conflict resolution
> applies to N without conflicts we do not recognize that there are still
> conflicts in N that need to be resolved.

Very good point, and exactly the kind of feedback I was hoping for when I
marked this as an RFC. Thank you!

> Having realized this I went to look at Elijah's notes and they recognize
> this possibility and suggest extending the xdiff merge code to detect
> when N has conflicts that do not correspond to the conflicts in M. That
> sounds like quite a lot of work. I've not put much effort into coming up
> with a counterexample but think that because "git replay" and "git
> history" do not yet allow the commits in the merged branches to be
> edited we may be able to safely use the implementation proposed in this
> series if both merge parents have been rebased (or we might want all the
> merge bases of the new merge to be a descendants of "--onto"). In the
> example above if both the parents were rebased onto E then any new
> conflicts would happen when picking D rather than when recreating the
> merge.

Right. I have to admit that I missed this corner-case when I looked at the
original notes.

And while `git history`'s `reword` and `split` subcommands won't be
affected, the upcoming `fixup` subcommand _will_ be affected.

I am reworking the patches as we speak, loosely following Elijah's notes.
So far, I'm confident that this will address that problem.

What I am not confident at all so far (because I'm still trying to get the
actual algorithm to work, and haven't had a chance to test this on
real-world scenarios) is that the _conflict output_ is helpful. That is,
whether the conflict markers in case of corner-cases (merge conflicts in
R overlapping with merge conflicts in N, but not being identical, for
example) are clear enough to act upon, or will only lead to despair in the
keen reader.

For example, I noticed that a merge conflict resolution in O that is no
longer necessary in N leads to a quite unhelpful output...

I know that `git replay` is not designed as an interactive tool, but `git
history` is, and will ultimately _have_ to find ways to surface such merge
conflicts and help the user resolve them and then continue the replay.

For now, however, I do agree that we need to capture the error modes
correctly.

Ciao,
Johannes

> 
> Thanks
> 
> Phillip
> 
> > Implement `pick_merge_commit()` along those lines and dispatch to it
> > from `replay_revisions()` when the commit being replayed has exactly
> > two parents. Two specific points (learned the hard way) keep
> > non-trivial cases working where the WIP sketch [2] bailed out.
> > First, R and N use identical `merge_options.branch1` and `branch2`
> > labels ("ours"/"theirs"). When the original parents conflicted on a
> > region of a file, both R and N produce textually identical conflict
> > markers; the outer non-recursive merge then sees N == R in that
> > region and the user's manual resolution from O wins cleanly. Without
> > this, the conflict-marker text would differ between R and N (because
> > the inner merges would label the conflicts differently), and the
> > outer merge would itself be unclean even when the user did supply a
> > clean resolution. Second, an unclean inner merge
> > (`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in
> > that case still has well-defined contents (with conflict markers in
> > the conflicted files) and is a valid input to the outer
> > non-recursive merge. Only a real error (`< 0`) propagates as
> > failure.
> > 
> > The replay propagates the textual diffs the user actually made in M;
> > it does _not_ extrapolate symbol-level intent. If rewriting the
> > parents pulls in genuinely new content (for example, a brand-new
> > caller of a function that the merge renamed), that new content stays
> > as the rewritten parents have it. Symbol-aware refactoring is out of
> > scope here, just as it is for plain rebase.
> > 
> > Octopus merges (more than two parents) and revert-of-merge are not
> > supported and are surfaced as explicit errors at the dispatch point.
> > The "split" sub-command of `git history` continues to refuse when
> > the targeted commit is itself a merge: split semantics do not apply
> > to merges. The pre-walk gate in `builtin/history.c` that previously
> > rejected any merge in the rewrite path now only rejects octopus
> > merges; rename it accordingly.
> > 
> > A small refactor in `create_commit()` makes the merge case possible:
> > the helper now takes a `struct commit_list *parents` rather than a
> > single parent pointer and takes ownership of the list. The single
> > existing caller in `pick_regular_commit()` builds and passes a
> > one-element list; the new `pick_merge_commit()` builds a two-element
> > list, with the order of the `from` and `merge` parents preserved.
> > 
> > Update the negative expectations in t3451, t3452 and t3650 that were
> > asserting the now-retired "not supported yet" message, replacing
> > them with positive coverage where it fits. Octopus rejection and
> > revert-of-merge rejection are covered by new positive tests in
> > t3650. A dedicated test script with merge-replay scenarios driven by
> > a new test-tool fixture builder will follow in a subsequent commit.
> > 
> > [1] https://github.com/newren/git/blob/replay/replay-design-notes.txt
> > [2]
> > https://github.com/newren/git/commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45
> > 
> > Helped-by: Elijah Newren <newren@gmail.com>
> > Assisted-by: Claude Opus 4.7
> > Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> > ---
> >   builtin/history.c         |  16 ++-
> >   replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--
> >   t/t3451-history-reword.sh |  21 ++--
> >   t/t3452-history-split.sh  |   6 +-
> >   t/t3650-replay-basics.sh  |  46 ++++++++-
> >   5 files changed, 269 insertions(+), 29 deletions(-)
> > 
> > diff --git a/builtin/history.c b/builtin/history.c
> > index 9526938085..00097b2226 100644
> > --- a/builtin/history.c
> > +++ b/builtin/history.c
> > @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option *opt,
> > const char *value, int uns
> >   	return 0;
> >   }
> >   
> > -static int revwalk_contains_merges(struct repository *repo,
> > -				   const struct strvec *revwalk_args)
> > +static int revwalk_contains_octopus_merges(struct repository *repo,
> > +					   const struct strvec *revwalk_args)
> >   {
> >    struct strvec args = STRVEC_INIT;
> >    struct rev_info revs;
> >    int ret;
> >   
> >   	strvec_pushv(&args, revwalk_args->v);
> > -	strvec_push(&args, "--min-parents=2");
> > +	strvec_push(&args, "--min-parents=3");
> >   
> >    repo_init_revisions(repo, &revs, NULL);
> >   @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository
> > *repo,
> >    }
> >   
> >   	if (get_revision(&revs)) {
> > -		ret = error(_("replaying merge commits is not supported
> > yet!"));
> > +		ret = error(_("replaying octopus merges is not supported"));
> >    	goto out;
> >    }
> >   @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,
> >    	strvec_push(&args, "HEAD");
> >    }
> >   -	ret = revwalk_contains_merges(repo, &args);
> > +	ret = revwalk_contains_octopus_merges(repo, &args);
> >    if (ret < 0)
> >     goto out;
> >   @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,
> >    if (ret < 0) {
> >     ret = error(_("failed replaying descendants"));
> >     goto out;
> > +	} else if (ret) {
> > +		ret = error(_("conflict during replay; some descendants were
> > not rewritten"));
> > +		goto out;
> >    }
> >   
> >   	ret = 0;
> > @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,
> >    if (ret < 0) {
> >     ret = error(_("failed replaying descendants"));
> >     goto out;
> > +	} else if (ret) {
> > +		ret = error(_("conflict during replay; some descendants were
> > not rewritten"));
> > +		goto out;
> >    }
> >   
> >   	ret = 0;
> > diff --git a/replay.c b/replay.c
> > index f96f1f6551..3dbce095f9 100644
> > --- a/replay.c
> > +++ b/replay.c
> > @@ -1,6 +1,7 @@
> >   #define USE_THE_REPOSITORY_VARIABLE
> >   
> >   #include "git-compat-util.h"
> > +#include "commit-reach.h"
> >   #include "environment.h"
> >   #include "hex.h"
> >   #include "merge-ort.h"
> > @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf *msg,
> >   	repo_unuse_commit_buffer(repo, commit, message);
> >   }
> >   
> > +/*
> > + * Build a new commit with the given tree and parent list, copying author,
> > + * extra headers and (for pick mode) the commit message from `based_on`.
> > + *
> > + * Takes ownership of `parents`: it will be freed before returning, even on
> > + * error. Parent order is preserved as supplied by the caller.
> > + */
> >   static struct commit *create_commit(struct repository *repo,
> >           struct tree *tree,
> >           struct commit *based_on,
> > -				    struct commit *parent,
> > +				    struct commit_list *parents,
> >   				    enum replay_mode mode)
> >   {
> >    struct object_id ret;
> >    struct object *obj = NULL;
> > -	struct commit_list *parents = NULL;
> >    char *author = NULL;
> >    char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
> >    struct commit_extra_header *extra = NULL;
> > @@ -96,7 +103,6 @@ static struct commit *create_commit(struct repository
> > *repo,
> >    const char *orig_message = NULL;
> >    const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
> >   -	commit_list_insert(parent, &parents);
> >    extra = read_commit_extra_headers(based_on, exclude_gpgsig);
> >    if (mode == REPLAY_MODE_REVERT) {
> >   		generate_revert_message(&msg, based_on, repo);
> > @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct
> > repository *repo,
> >   {
> >    struct commit *base, *replayed_base;
> >    struct tree *pickme_tree, *base_tree, *replayed_base_tree;
> > +	struct commit_list *parents = NULL;
> >   
> >    if (pickme->parents) {
> >   		base = pickme->parents->item;
> > @@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct
> > repository *repo,
> >    if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&
> >        !oideq(&pickme_tree->object.oid, &base_tree->object.oid))
> >   		return replayed_base;
> > -	return create_commit(repo, result->tree, pickme, replayed_base, mode);
> > +	commit_list_insert(replayed_base, &parents);
> > +	return create_commit(repo, result->tree, pickme, parents, mode);
> > +}
> > +
> > +/*
> > + * Replay a 2-parent merge commit by composing three calls into merge-ort:
> > + *
> > + *   R = recursive merge of pickme's two original parents (auto-remerge of
> > + *       the original merge, accepting any conflicts)
> > + *   N = recursive merge of the (possibly rewritten) parents
> > + *   O = pickme's tree (the user's actual merge, including any manual
> > + *       resolutions)
> > + *
> > + * The picked tree comes from a non-recursive merge using R as the base,
> > + * O as side1 and N as side2. `git diff R O` is morally `git show
> > + * --remerge-diff $oldmerge`, so this layers the user's original manual
> > + * resolution on top of the freshly auto-merged rewritten parents (see
> > + * `replay-design-notes.txt` on the `replay` branch of newren/git).
> > + *
> > + * If the outer 3-way merge is unclean, propagate the conflict status to
> > + * the caller via `result->clean = 0` and return NULL. The two inner
> > + * merges (R and N) being unclean is _not_ fatal: the conflict-markered
> > + * trees they produce are valid inputs to the outer merge, and using
> > + * identical labels for both inner merges keeps the marker text
> > + * byte-equal between R and N so the user's resolution recorded in O
> > + * collapses the conflict cleanly there. Octopus merges (more than two
> > + * parents) and revert-of-merge are rejected by the caller before this
> > + * function is invoked.
> > + */
> > +static struct commit *pick_merge_commit(struct repository *repo,
> > +					struct commit *pickme,
> > +					kh_oid_map_t *replayed_commits,
> > +					struct merge_options *merge_opt,
> > +					struct merge_result *result)
> > +{
> > +	struct commit *parent1, *parent2;
> > +	struct commit *replayed_par1, *replayed_par2;
> > +	struct tree *pickme_tree;
> > +	struct merge_options remerge_opt = { 0 };
> > +	struct merge_options new_merge_opt = { 0 };
> > +	struct merge_result remerge_res = { 0 };
> > +	struct merge_result new_merge_res = { 0 };
> > +	struct commit_list *parent_bases = NULL;
> > +	struct commit_list *replayed_bases = NULL;
> > +	struct commit_list *parents;
> > +	struct commit *picked = NULL;
> > +	char *ancestor_name = NULL;
> > +
> > +	parent1 = pickme->parents->item;
> > +	parent2 = pickme->parents->next->item;
> > +
> > +	/*
> > +	 * Map the merge's parents to their replayed counterparts. With the
> > +	 * boundary commits pre-seeded into `replayed_commits`, every parent
> > +	 * either has an explicit mapping (rewritten or boundary -> onto) or
> > +	 * sits outside the rewrite range entirely; the latter must stay at
> > +	 * the original parent commit, so use `parent` itself as the fallback
> > +	 * for both sides.
> > +	 */
> > +	replayed_par1 = mapped_commit(replayed_commits, parent1, parent1);
> > +	replayed_par2 = mapped_commit(replayed_commits, parent2, parent2);
> > +
> > +	/*
> > +	 * R: auto-remerge of the original parents.
> > +	 *
> > +	 * Use the same branch labels for the inner merges that compute R
> > +	 * and N so conflict markers (if any) are textually identical
> > +	 * between the two; the outer non-recursive merge can then collapse
> > +	 * the manual resolution from O against them.
> > +	 */
> > +	init_basic_merge_options(&remerge_opt, repo);
> > +	remerge_opt.show_rename_progress = 0;
> > +	remerge_opt.branch1 = "ours";
> > +	remerge_opt.branch2 = "theirs";
> > +	if (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0) {
> > +		result->clean = -1;
> > +		goto out;
> > +	}
> > +	merge_incore_recursive(&remerge_opt, parent_bases,
> > +			       parent1, parent2, &remerge_res);
> > +	parent_bases = NULL; /* consumed by merge_incore_recursive */
> > +	if (remerge_res.clean < 0) {
> > +		result->clean = remerge_res.clean;
> > +		goto out;
> > +	}
> > +
> > +	/* N: fresh merge of the (possibly rewritten) parents. */
> > +	init_basic_merge_options(&new_merge_opt, repo);
> > +	new_merge_opt.show_rename_progress = 0;
> > +	new_merge_opt.branch1 = "ours";
> > +	new_merge_opt.branch2 = "theirs";
> > +	if (repo_get_merge_bases(repo, replayed_par1, replayed_par2,
> > +				 &replayed_bases) < 0) {
> > +		result->clean = -1;
> > +		goto out;
> > +	}
> > +	merge_incore_recursive(&new_merge_opt, replayed_bases,
> > +			       replayed_par1, replayed_par2, &new_merge_res);
> > +	replayed_bases = NULL; /* consumed by merge_incore_recursive */
> > +	if (new_merge_res.clean < 0) {
> > +		result->clean = new_merge_res.clean;
> > +		goto out;
> > +	}
> > +
> > +	/*
> > +	 * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.
> > +	 */
> > +	pickme_tree = repo_get_commit_tree(repo, pickme);
> > +	ancestor_name = xstrfmt("auto-remerge of %s",
> > +				oid_to_hex(&pickme->object.oid));
> > +	merge_opt->ancestor = ancestor_name;
> > +	merge_opt->branch1 = short_commit_name(repo, pickme);
> > +	merge_opt->branch2 = "merge of replayed parents";
> > +	merge_incore_nonrecursive(merge_opt,
> > +				  remerge_res.tree,
> > +				  pickme_tree,
> > +				  new_merge_res.tree,
> > +				  result);
> > +	merge_opt->ancestor = NULL;
> > +	merge_opt->branch1 = NULL;
> > +	merge_opt->branch2 = NULL;
> > +	if (!result->clean)
> > +		goto out;
> > +
> > +	parents = NULL;
> > +	commit_list_insert(replayed_par2, &parents);
> > +	commit_list_insert(replayed_par1, &parents);
> > +	picked = create_commit(repo, result->tree, pickme, parents,
> > +			       REPLAY_MODE_PICK);
> > +
> > +out:
> > +	free(ancestor_name);
> > +	free_commit_list(parent_bases);
> > +	free_commit_list(replayed_bases);
> > +	merge_finalize(&remerge_opt, &remerge_res);
> > +	merge_finalize(&new_merge_opt, &new_merge_res);
> > +	return picked;
> >   }
> >   
> >   void replay_result_release(struct replay_result *result)
> > @@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,
> >    merge_opt.show_rename_progress = 0;
> >    last_commit = onto;
> >    replayed_commits = kh_init_oid_map();
> > +
> > +	/*
> > +	 * Seed the rewritten-commit map with each negative-side ("BOTTOM")
> > +	 * cmdline entry pointing at `onto`. This matters for merge replay:
> > +	 * a 2-parent merge whose first parent is the boundary (e.g. the
> > +	 * commit being reworded) must replay onto the rewritten boundary,
> > +	 * yet pick_merge_commit uses a self fallback so the second parent
> > +	 * (a side branch outside the rewrite range) is preserved as-is.
> > +	 * Pre-seeding the boundary disambiguates the two: in the map ->
> > +	 * rewritten, missing -> kept as-is.
> > +	 *
> > +	 * Only do this for the pick path; revert mode chains reverts
> > +	 * through last_commit and a pre-seeded boundary would short-circuit
> > +	 * that chain.
> > +	 */
> > +	if (mode == REPLAY_MODE_PICK) {
> > +		for (size_t i = 0; i < revs->cmdline.nr; i++) {
> > +			struct rev_cmdline_entry *e = &revs->cmdline.rev[i];
> > +			struct commit *boundary;
> > +			khint_t pos;
> > +			int hr;
> > +
> > +			if (!(e->flags & BOTTOM))
> > +				continue;
> > +			boundary = lookup_commit_reference_gently(revs->repo,
> > +
> > &e->item->oid, 1);
> > +			if (!boundary)
> > +				continue;
> > +			pos = kh_put_oid_map(replayed_commits,
> > +					     boundary->object.oid, &hr);
> > +			if (hr != 0)
> > +				kh_value(replayed_commits, pos) = onto;
> > +		}
> > +	}
> > +
> >    while ((commit = get_revision(revs))) {
> >     const struct name_decoration *decoration;
> >     khint_t pos;
> >     int hr;
> >   -		if (commit->parents && commit->parents->next)
> > -			die(_("replaying merge commits is not supported
> > yet!"));
> > -
> > -		last_commit = pick_regular_commit(revs->repo, commit,
> > replayed_commits,
> > -						  mode == REPLAY_MODE_REVERT ?
> > last_commit : onto,
> > -						  &merge_opt, &result, mode);
> > +		if (commit->parents && commit->parents->next) {
> > +			if (commit->parents->next->next) {
> > +				ret = error(_("replaying octopus merges is not
> > supported"));
> > +				goto out;
> > +			}
> > +			if (mode == REPLAY_MODE_REVERT) {
> > +				ret = error(_("reverting merge commits is not
> > supported"));
> > +				goto out;
> > +			}
> > +			last_commit = pick_merge_commit(revs->repo, commit,
> > +							replayed_commits,
> > +							&merge_opt, &result);
> > +		} else {
> > +			last_commit = pick_regular_commit(revs->repo, commit,
> > replayed_commits,
> > +							  mode ==
> > REPLAY_MODE_REVERT ? last_commit : onto,
> > +							  &merge_opt, &result,
> > mode);
> > +		}
> >     if (!last_commit)
> >      break;
> >   diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
> > index de7b357685..d103f866a2 100755
> > --- a/t/t3451-history-reword.sh
> > +++ b/t/t3451-history-reword.sh
> > @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '
> >     git switch - &&
> >     git merge theirs &&
> >   -		# It is not possible to replay merge commits embedded in the
> > -		# history (yet).
> > -		test_must_fail git -c core.editor=false history reword HEAD~
> > 2>err &&
> > -		test_grep "replaying merge commits is not supported yet" err
> > &&
> > +		# Reword a non-merge commit whose descendants include the
> > +		# merge: replay carries the merge through.
> > +		reword_with_message HEAD~ <<-EOF &&
> > +		ours reworded
> > +		EOF
> > +		expect_graph <<-EOF &&
> > +		*   Merge tag ${SQ}theirs${SQ}
> > +		|\\
> > +		| * theirs
> > +		* | ours reworded
> > +		|/
> > +		* base
> > +		EOF
> >   -		# But it is possible to reword a merge commit directly.
> > +		# And reword a merge commit directly.
> >     reword_with_message HEAD <<-EOF &&
> >     Reworded merge commit
> >     EOF
> > @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '
> >     *   Reworded merge commit
> > |\
> > | * theirs
> > -		* | ours
> > +		* | ours reworded
> > |/
> >     * base
> >     EOF
> > diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
> > index 8ed0cebb50..ad6309f98b 100755
> > --- a/t/t3452-history-split.sh
> > +++ b/t/t3452-history-split.sh
> > @@ -36,7 +36,7 @@ expect_tree_entries () {
> >   	test_cmp expect actual
> >   }
> >   
> > -test_expect_success 'refuses to work with merge commits' '
> > +test_expect_success 'refuses to split a merge commit' '
> >    test_when_finished "rm -rf repo" &&
> >    git init repo &&
> >    (
> > @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge commits' '
> >     git switch - &&
> >     git merge theirs &&
> >     test_must_fail git history split HEAD 2>err &&
> > -		test_grep "cannot split up merge commit" err &&
> > -		test_must_fail git history split HEAD~ 2>err &&
> > -		test_grep "replaying merge commits is not supported yet" err
> > +		test_grep "cannot split up merge commit" err
> > )
> >   '
> >   
> > diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
> > index 3353bc4a4d..368b1b0f9a 100755
> > --- a/t/t3650-replay-basics.sh
> > +++ b/t/t3650-replay-basics.sh
> > @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ...
> > ordering would be ill-defined' '
> >   	test_cmp expect actual
> >   '
> >   
> > -test_expect_success 'replaying merge commits is not supported yet' '
> > -	echo "fatal: replaying merge commits is not supported yet!" >expect &&
> > -	test_must_fail git replay --advance=main main..topic-with-merge
> > 2>actual &&
> > -	test_cmp expect actual
> > +test_expect_success 'using replay to rebase a 2-parent merge' '
> > +	# main..topic-with-merge contains a 2-parent merge (P) introduced
> > +	# via test_merge. Use --ref-action=print so this test does not
> > +	# mutate state for subsequent tests in this file.
> > +	git replay --ref-action=print --onto main main..topic-with-merge
> > >result &&
> > +	test_line_count = 1 result &&
> > +
> > +	new_tip=$(cut -f 3 -d " " result) &&
> > +
> > +	# Result is still a 2-parent merge.
> > +	git cat-file -p $new_tip >cat &&
> > +	grep -c "^parent " cat >count &&
> > +	echo 2 >expect &&
> > +	test_cmp expect count &&
> > +
> > +	# Merge subject is preserved.
> > +	echo P >expect &&
> > +	git log -1 --format=%s $new_tip >actual &&
> > +	test_cmp expect actual &&
> > +
> > +	# The replayed merge sits on top of main: walking back via the
> > +	# first-parent chain reaches main.
> > +	git merge-base --is-ancestor main $new_tip
> > +'
> > +
> > +test_expect_success 'replaying an octopus merge is rejected' '
> > +	# Build an octopus side-branch so the rest of the test state stays
> > +	# untouched.
> > +	test_when_finished "git update-ref -d refs/heads/octopus-tip" &&
> > +	octopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \
> > +		-m "octopus" $(git rev-parse topic4^{tree})) &&
> > +	git update-ref refs/heads/octopus-tip "$octopus_tip" &&
> > +
> > +	test_must_fail git replay --ref-action=print --onto main \
> > +		topic4..octopus-tip 2>actual &&
> > +	test_grep "octopus merges" actual
> > +'
> > +
> > +test_expect_success 'reverting a merge commit is rejected' '
> > +	test_must_fail git replay --ref-action=print --revert=topic-with-merge
> > \
> > +		topic4..topic-with-merge 2>actual &&
> > +	test_grep "reverting merge commits" actual
> >   '
> >   
> >   test_expect_success 'using replay to rebase two branches, one on top of
> >   other' '
> 
> 

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Johannes Schindelin wrote on the Git mailing list (how to reply to this email):

Hi Phillip,

On Fri, 8 May 2026, Phillip Wood wrote:

> On 08/05/2026 10:36, Phillip Wood wrote:
> > 
> > On 06/05/2026 23:43, Johannes Schindelin via GitGitGadget wrote:
> > >
> > > Elijah Newren spelled out a way to lift this limitation in his
> > > replay-design-notes [1] and prototyped it in a 2022
> > > work-in-progress sketch [2]. The idea is that a merge commit M on
> > > parents (P1, P2) records both an automatic merge of those parents
> > > AND any manual layer the author put on top of that automatic merge
> > > (textual conflict resolution and any semantic edit outside conflict
> > > markers). Replaying M onto rewritten parents (P1', P2') must
> > > preserve that manual layer, but the rewritten parents change the
> > > automatic merge, so a simple cherry-pick is wrong: the manual layer
> > > would be re-introduced on top of stale auto-merge text.
> > >
> > > What works instead is a three-way merge of three trees the existing
> > > infrastructure already knows how to compute. Let R be the recursive
> > > auto-merge of (P1, P2), O be M's actual tree and N be the recursive
> > > auto-merge of (P1', P2'). Then `git diff R O` is morally
> > > `git show --remerge-diff M`: it captures exactly what the author
> > > added on top of the automatic merge. A non-recursive 3-way merge
> > > with R as the merge base, O as side 1 and N as side 2 layers that
> > > manual contribution onto the freshly auto-merged rewritten parents
> > > (N) and produces the replayed tree.
> > 
> > So we cherry-pick the difference between the user's conflict resolution O
> > and the auto-merge M of the original parents onto the auto-merge N of the
> > replayed parents. If we have a topology that looks like
> > 
> >          |
> >          A
> >         /|\
> >        / B \
> >       E  |  D
> >          C /
> >          |/
> >          O
> > 
> > then running
> > 
> >      git replay --onto E --ancestry-path B..O
> > 
> > will replay C and O onto E. If the changes in E and D conflict but those
> > conflicts do not overlap with the conflicts in M that were resolved to
> > create O then the replayed version of O will contain conflict markers from
> > the conflicting changes in E and D. Because the previous conflict resolution
> > applies to N without conflicts we do not recognize that there are still
> > conflicts in N that need to be resolved.
> > 
> > Having realized this I went to look at Elijah's notes and they recognize
> > this possibility and suggest extending the xdiff merge code to detect when N
> > has conflicts that do not correspond to the conflicts in M. That sounds like
> > quite a lot of work. I've not put much effort into coming up with a
> > counterexample but think that because "git replay" and "git history" do not
> > yet allow the commits in the merged branches to be edited we may be able to
> > safely use the implementation proposed in this series if both merge parents
> > have been rebased (or we might want all the merge bases of the new merge to
> > be a descendants of "--onto"). In the example above if both the parents were
> > rebased onto E then any new conflicts would happen when picking D rather
> > than when recreating the merge.
> 
> One further thought - if only one of the parents has been rebased (i.e. we're
> replaying O with parents P1' and P2) then can we just cherry-pick the merge -
> instead of merging P1' and P2, use P1 as the merge-base with O and P1' as the
> merge heads?

That's a really good idea! That should _especially_ work well for the
conflict markers in case of conflicts.

Ciao,
Johannes

> 
> Thanks
> 
> Phillip
> 
> > Thanks
> > 
> > Phillip
> > 
> > > Implement `pick_merge_commit()` along those lines and dispatch to it
> > > from `replay_revisions()` when the commit being replayed has exactly
> > > two parents. Two specific points (learned the hard way) keep
> > > non-trivial cases working where the WIP sketch [2] bailed out.
> > > First, R and N use identical `merge_options.branch1` and `branch2`
> > > labels ("ours"/"theirs"). When the original parents conflicted on a
> > > region of a file, both R and N produce textually identical conflict
> > > markers; the outer non-recursive merge then sees N == R in that
> > > region and the user's manual resolution from O wins cleanly. Without
> > > this, the conflict-marker text would differ between R and N (because
> > > the inner merges would label the conflicts differently), and the
> > > outer merge would itself be unclean even when the user did supply a
> > > clean resolution. Second, an unclean inner merge
> > > (`result.clean == 0`) is _not_ fatal: the tree merge-ort produces in
> > > that case still has well-defined contents (with conflict markers in
> > > the conflicted files) and is a valid input to the outer
> > > non-recursive merge. Only a real error (`< 0`) propagates as
> > > failure.
> > >
> > > The replay propagates the textual diffs the user actually made in M;
> > > it does _not_ extrapolate symbol-level intent. If rewriting the
> > > parents pulls in genuinely new content (for example, a brand-new
> > > caller of a function that the merge renamed), that new content stays
> > > as the rewritten parents have it. Symbol-aware refactoring is out of
> > > scope here, just as it is for plain rebase.
> > >
> > > Octopus merges (more than two parents) and revert-of-merge are not
> > > supported and are surfaced as explicit errors at the dispatch point.
> > > The "split" sub-command of `git history` continues to refuse when
> > > the targeted commit is itself a merge: split semantics do not apply
> > > to merges. The pre-walk gate in `builtin/history.c` that previously
> > > rejected any merge in the rewrite path now only rejects octopus
> > > merges; rename it accordingly.
> > >
> > > A small refactor in `create_commit()` makes the merge case possible:
> > > the helper now takes a `struct commit_list *parents` rather than a
> > > single parent pointer and takes ownership of the list. The single
> > > existing caller in `pick_regular_commit()` builds and passes a
> > > one-element list; the new `pick_merge_commit()` builds a two-element
> > > list, with the order of the `from` and `merge` parents preserved.
> > >
> > > Update the negative expectations in t3451, t3452 and t3650 that were
> > > asserting the now-retired "not supported yet" message, replacing
> > > them with positive coverage where it fits. Octopus rejection and
> > > revert-of-merge rejection are covered by new positive tests in
> > > t3650. A dedicated test script with merge-replay scenarios driven by
> > > a new test-tool fixture builder will follow in a subsequent commit.
> > >
> > > [1] https://github.com/newren/git/blob/replay/replay-design-notes.txt
> > > [2] https://github.com/newren/git/
> > > commit/4c45e8955ef9bf7d01fd15d9106b3bdb8ea91b45
> > >
> > > Helped-by: Elijah Newren <newren@gmail.com>
> > > Assisted-by: Claude Opus 4.7
> > > Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> > > ---
> > >   builtin/history.c         |  16 ++-
> > >   replay.c                  | 209 ++++++++++++++++++++++++++++++++++++--
> > >   t/t3451-history-reword.sh |  21 ++--
> > >   t/t3452-history-split.sh  |   6 +-
> > >   t/t3650-replay-basics.sh  |  46 ++++++++-
> > >   5 files changed, 269 insertions(+), 29 deletions(-)
> > >
> > > diff --git a/builtin/history.c b/builtin/history.c
> > > index 9526938085..00097b2226 100644
> > > --- a/builtin/history.c
> > > +++ b/builtin/history.c
> > > @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option
> > > *opt, const char *value, int uns
> > >       return 0;
> > >   }
> > > -static int revwalk_contains_merges(struct repository *repo,
> > > -                   const struct strvec *revwalk_args)
> > > +static int revwalk_contains_octopus_merges(struct repository *repo,
> > > +                       const struct strvec *revwalk_args)
> > >   {
> > >       struct strvec args = STRVEC_INIT;
> > >       struct rev_info revs;
> > >       int ret;
> > >       strvec_pushv(&args, revwalk_args->v);
> > > -    strvec_push(&args, "--min-parents=2");
> > > +    strvec_push(&args, "--min-parents=3");
> > >       repo_init_revisions(repo, &revs, NULL);
> > > @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository
> > > *repo,
> > >       }
> > >       if (get_revision(&revs)) {
> > > -        ret = error(_("replaying merge commits is not supported yet!"));
> > > +        ret = error(_("replaying octopus merges is not supported"));
> > >           goto out;
> > >       }
> > > @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,
> > >           strvec_push(&args, "HEAD");
> > >       }
> > > -    ret = revwalk_contains_merges(repo, &args);
> > > +    ret = revwalk_contains_octopus_merges(repo, &args);
> > >       if (ret < 0)
> > >           goto out;
> > > @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,
> > >       if (ret < 0) {
> > >           ret = error(_("failed replaying descendants"));
> > >           goto out;
> > > +    } else if (ret) {
> > > +        ret = error(_("conflict during replay; some descendants were not
> > > rewritten"));
> > > +        goto out;
> > >       }
> > >       ret = 0;
> > > @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,
> > >       if (ret < 0) {
> > >           ret = error(_("failed replaying descendants"));
> > >           goto out;
> > > +    } else if (ret) {
> > > +        ret = error(_("conflict during replay; some descendants were not
> > > rewritten"));
> > > +        goto out;
> > >       }
> > >       ret = 0;
> > > diff --git a/replay.c b/replay.c
> > > index f96f1f6551..3dbce095f9 100644
> > > --- a/replay.c
> > > +++ b/replay.c
> > > @@ -1,6 +1,7 @@
> > >   #define USE_THE_REPOSITORY_VARIABLE
> > >   #include "git-compat-util.h"
> > > +#include "commit-reach.h"
> > >   #include "environment.h"
> > >   #include "hex.h"
> > >   #include "merge-ort.h"
> > > @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf
> > > *msg,
> > >       repo_unuse_commit_buffer(repo, commit, message);
> > >   }
> > > +/*
> > > + * Build a new commit with the given tree and parent list, copying
> > > author,
> > > + * extra headers and (for pick mode) the commit message from `based_on`.
> > > + *
> > > + * Takes ownership of `parents`: it will be freed before returning, even
> > > on
> > > + * error. Parent order is preserved as supplied by the caller.
> > > + */
> > >   static struct commit *create_commit(struct repository *repo,
> > >                       struct tree *tree,
> > >                       struct commit *based_on,
> > > -                    struct commit *parent,
> > > +                    struct commit_list *parents,
> > >                       enum replay_mode mode)
> > >   {
> > >       struct object_id ret;
> > >       struct object *obj = NULL;
> > > -    struct commit_list *parents = NULL;
> > >       char *author = NULL;
> > >       char *sign_commit = NULL; /* FIXME: cli users might want to sign
> > > again */
> > >       struct commit_extra_header *extra = NULL;
> > > @@ -96,7 +103,6 @@ static struct commit *create_commit(struct repository
> > > *repo,
> > >       const char *orig_message = NULL;
> > >       const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
> > > -    commit_list_insert(parent, &parents);
> > >       extra = read_commit_extra_headers(based_on, exclude_gpgsig);
> > >       if (mode == REPLAY_MODE_REVERT) {
> > >           generate_revert_message(&msg, based_on, repo);
> > > @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct
> > > repository *repo,
> > >   {
> > >       struct commit *base, *replayed_base;
> > >       struct tree *pickme_tree, *base_tree, *replayed_base_tree;
> > > +    struct commit_list *parents = NULL;
> > >       if (pickme->parents) {
> > >           base = pickme->parents->item;
> > > @@ -327,7 +334,143 @@ static struct commit *pick_regular_commit(struct
> > > repository *repo,
> > >       if (oideq(&replayed_base_tree->object.oid, &result->tree- 
> > > >object.oid) &&
> > >           !oideq(&pickme_tree->object.oid, &base_tree->object.oid))
> > >           return replayed_base;
> > > -    return create_commit(repo, result->tree, pickme, replayed_base,
> > > mode);
> > > +    commit_list_insert(replayed_base, &parents);
> > > +    return create_commit(repo, result->tree, pickme, parents, mode);
> > > +}
> > > +
> > > +/*
> > > + * Replay a 2-parent merge commit by composing three calls into
> > > merge-ort:
> > > + *
> > > + *   R = recursive merge of pickme's two original parents (auto- remerge
> > > of
> > > + *       the original merge, accepting any conflicts)
> > > + *   N = recursive merge of the (possibly rewritten) parents
> > > + *   O = pickme's tree (the user's actual merge, including any manual
> > > + *       resolutions)
> > > + *
> > > + * The picked tree comes from a non-recursive merge using R as the base,
> > > + * O as side1 and N as side2. `git diff R O` is morally `git show
> > > + * --remerge-diff $oldmerge`, so this layers the user's original manual
> > > + * resolution on top of the freshly auto-merged rewritten parents (see
> > > + * `replay-design-notes.txt` on the `replay` branch of newren/git).
> > > + *
> > > + * If the outer 3-way merge is unclean, propagate the conflict status to
> > > + * the caller via `result->clean = 0` and return NULL. The two inner
> > > + * merges (R and N) being unclean is _not_ fatal: the conflict-markered
> > > + * trees they produce are valid inputs to the outer merge, and using
> > > + * identical labels for both inner merges keeps the marker text
> > > + * byte-equal between R and N so the user's resolution recorded in O
> > > + * collapses the conflict cleanly there. Octopus merges (more than two
> > > + * parents) and revert-of-merge are rejected by the caller before this
> > > + * function is invoked.
> > > + */
> > > +static struct commit *pick_merge_commit(struct repository *repo,
> > > +                    struct commit *pickme,
> > > +                    kh_oid_map_t *replayed_commits,
> > > +                    struct merge_options *merge_opt,
> > > +                    struct merge_result *result)
> > > +{
> > > +    struct commit *parent1, *parent2;
> > > +    struct commit *replayed_par1, *replayed_par2;
> > > +    struct tree *pickme_tree;
> > > +    struct merge_options remerge_opt = { 0 };
> > > +    struct merge_options new_merge_opt = { 0 };
> > > +    struct merge_result remerge_res = { 0 };
> > > +    struct merge_result new_merge_res = { 0 };
> > > +    struct commit_list *parent_bases = NULL;
> > > +    struct commit_list *replayed_bases = NULL;
> > > +    struct commit_list *parents;
> > > +    struct commit *picked = NULL;
> > > +    char *ancestor_name = NULL;
> > > +
> > > +    parent1 = pickme->parents->item;
> > > +    parent2 = pickme->parents->next->item;
> > > +
> > > +    /*
> > > +     * Map the merge's parents to their replayed counterparts. With the
> > > +     * boundary commits pre-seeded into `replayed_commits`, every parent
> > > +     * either has an explicit mapping (rewritten or boundary -> onto) or
> > > +     * sits outside the rewrite range entirely; the latter must stay at
> > > +     * the original parent commit, so use `parent` itself as the fallback
> > > +     * for both sides.
> > > +     */
> > > +    replayed_par1 = mapped_commit(replayed_commits, parent1, parent1);
> > > +    replayed_par2 = mapped_commit(replayed_commits, parent2, parent2);
> > > +
> > > +    /*
> > > +     * R: auto-remerge of the original parents.
> > > +     *
> > > +     * Use the same branch labels for the inner merges that compute R
> > > +     * and N so conflict markers (if any) are textually identical
> > > +     * between the two; the outer non-recursive merge can then collapse
> > > +     * the manual resolution from O against them.
> > > +     */
> > > +    init_basic_merge_options(&remerge_opt, repo);
> > > +    remerge_opt.show_rename_progress = 0;
> > > +    remerge_opt.branch1 = "ours";
> > > +    remerge_opt.branch2 = "theirs";
> > > +    if (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0)
> > > {
> > > +        result->clean = -1;
> > > +        goto out;
> > > +    }
> > > +    merge_incore_recursive(&remerge_opt, parent_bases,
> > > +                   parent1, parent2, &remerge_res);
> > > +    parent_bases = NULL; /* consumed by merge_incore_recursive */
> > > +    if (remerge_res.clean < 0) {
> > > +        result->clean = remerge_res.clean;
> > > +        goto out;
> > > +    }
> > > +
> > > +    /* N: fresh merge of the (possibly rewritten) parents. */
> > > +    init_basic_merge_options(&new_merge_opt, repo);
> > > +    new_merge_opt.show_rename_progress = 0;
> > > +    new_merge_opt.branch1 = "ours";
> > > +    new_merge_opt.branch2 = "theirs";
> > > +    if (repo_get_merge_bases(repo, replayed_par1, replayed_par2,
> > > +                 &replayed_bases) < 0) {
> > > +        result->clean = -1;
> > > +        goto out;
> > > +    }
> > > +    merge_incore_recursive(&new_merge_opt, replayed_bases,
> > > +                   replayed_par1, replayed_par2, &new_merge_res);
> > > +    replayed_bases = NULL; /* consumed by merge_incore_recursive */
> > > +    if (new_merge_res.clean < 0) {
> > > +        result->clean = new_merge_res.clean;
> > > +        goto out;
> > > +    }
> > > +
> > > +    /*
> > > +     * Outer non-recursive merge: base=R, side1=O (pickme), side2=N.
> > > +     */
> > > +    pickme_tree = repo_get_commit_tree(repo, pickme);
> > > +    ancestor_name = xstrfmt("auto-remerge of %s",
> > > +                oid_to_hex(&pickme->object.oid));
> > > +    merge_opt->ancestor = ancestor_name;
> > > +    merge_opt->branch1 = short_commit_name(repo, pickme);
> > > +    merge_opt->branch2 = "merge of replayed parents";
> > > +    merge_incore_nonrecursive(merge_opt,
> > > +                  remerge_res.tree,
> > > +                  pickme_tree,
> > > +                  new_merge_res.tree,
> > > +                  result);
> > > +    merge_opt->ancestor = NULL;
> > > +    merge_opt->branch1 = NULL;
> > > +    merge_opt->branch2 = NULL;
> > > +    if (!result->clean)
> > > +        goto out;
> > > +
> > > +    parents = NULL;
> > > +    commit_list_insert(replayed_par2, &parents);
> > > +    commit_list_insert(replayed_par1, &parents);
> > > +    picked = create_commit(repo, result->tree, pickme, parents,
> > > +                   REPLAY_MODE_PICK);
> > > +
> > > +out:
> > > +    free(ancestor_name);
> > > +    free_commit_list(parent_bases);
> > > +    free_commit_list(replayed_bases);
> > > +    merge_finalize(&remerge_opt, &remerge_res);
> > > +    merge_finalize(&new_merge_opt, &new_merge_res);
> > > +    return picked;
> > >   }
> > >   void replay_result_release(struct replay_result *result)
> > > @@ -407,17 +550,63 @@ int replay_revisions(struct rev_info *revs,
> > >       merge_opt.show_rename_progress = 0;
> > >       last_commit = onto;
> > >       replayed_commits = kh_init_oid_map();
> > > +
> > > +    /*
> > > +     * Seed the rewritten-commit map with each negative-side ("BOTTOM")
> > > +     * cmdline entry pointing at `onto`. This matters for merge replay:
> > > +     * a 2-parent merge whose first parent is the boundary (e.g. the
> > > +     * commit being reworded) must replay onto the rewritten boundary,
> > > +     * yet pick_merge_commit uses a self fallback so the second parent
> > > +     * (a side branch outside the rewrite range) is preserved as-is.
> > > +     * Pre-seeding the boundary disambiguates the two: in the map ->
> > > +     * rewritten, missing -> kept as-is.
> > > +     *
> > > +     * Only do this for the pick path; revert mode chains reverts
> > > +     * through last_commit and a pre-seeded boundary would short-circuit
> > > +     * that chain.
> > > +     */
> > > +    if (mode == REPLAY_MODE_PICK) {
> > > +        for (size_t i = 0; i < revs->cmdline.nr; i++) {
> > > +            struct rev_cmdline_entry *e = &revs->cmdline.rev[i];
> > > +            struct commit *boundary;
> > > +            khint_t pos;
> > > +            int hr;
> > > +
> > > +            if (!(e->flags & BOTTOM))
> > > +                continue;
> > > +            boundary = lookup_commit_reference_gently(revs->repo,
> > > +                                  &e->item->oid, 1);
> > > +            if (!boundary)
> > > +                continue;
> > > +            pos = kh_put_oid_map(replayed_commits,
> > > +                         boundary->object.oid, &hr);
> > > +            if (hr != 0)
> > > +                kh_value(replayed_commits, pos) = onto;
> > > +        }
> > > +    }
> > > +
> > >       while ((commit = get_revision(revs))) {
> > >           const struct name_decoration *decoration;
> > >           khint_t pos;
> > >           int hr;
> > > -        if (commit->parents && commit->parents->next)
> > > -            die(_("replaying merge commits is not supported yet!"));
> > > -
> > > -        last_commit = pick_regular_commit(revs->repo, commit,
> > > replayed_commits,
> > > -                          mode == REPLAY_MODE_REVERT ? last_commit :
> > > onto,
> > > -                          &merge_opt, &result, mode);
> > > +        if (commit->parents && commit->parents->next) {
> > > +            if (commit->parents->next->next) {
> > > +                ret = error(_("replaying octopus merges is not
> > > supported"));
> > > +                goto out;
> > > +            }
> > > +            if (mode == REPLAY_MODE_REVERT) {
> > > +                ret = error(_("reverting merge commits is not
> > > supported"));
> > > +                goto out;
> > > +            }
> > > +            last_commit = pick_merge_commit(revs->repo, commit,
> > > +                            replayed_commits,
> > > +                            &merge_opt, &result);
> > > +        } else {
> > > +            last_commit = pick_regular_commit(revs->repo, commit,
> > > replayed_commits,
> > > +                              mode == REPLAY_MODE_REVERT ? last_commit :
> > > onto,
> > > +                              &merge_opt, &result, mode);
> > > +        }
> > >           if (!last_commit)
> > >               break;
> > > diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
> > > index de7b357685..d103f866a2 100755
> > > --- a/t/t3451-history-reword.sh
> > > +++ b/t/t3451-history-reword.sh
> > > @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' '
> > >           git switch - &&
> > >           git merge theirs &&
> > > -        # It is not possible to replay merge commits embedded in the
> > > -        # history (yet).
> > > -        test_must_fail git -c core.editor=false history reword HEAD~
> > > 2>err &&
> > > -        test_grep "replaying merge commits is not supported yet" err &&
> > > +        # Reword a non-merge commit whose descendants include the
> > > +        # merge: replay carries the merge through.
> > > +        reword_with_message HEAD~ <<-EOF &&
> > > +        ours reworded
> > > +        EOF
> > > +        expect_graph <<-EOF &&
> > > +        *   Merge tag ${SQ}theirs${SQ}
> > > +        |\\
> > > +        | * theirs
> > > +        * | ours reworded
> > > +        |/
> > > +        * base
> > > +        EOF
> > > -        # But it is possible to reword a merge commit directly.
> > > +        # And reword a merge commit directly.
> > >           reword_with_message HEAD <<-EOF &&
> > >           Reworded merge commit
> > >           EOF
> > > @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' '
> > >           *   Reworded merge commit
> > >           |\
> > >           | * theirs
> > > -        * | ours
> > > +        * | ours reworded
> > >           |/
> > >           * base
> > >           EOF
> > > diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
> > > index 8ed0cebb50..ad6309f98b 100755
> > > --- a/t/t3452-history-split.sh
> > > +++ b/t/t3452-history-split.sh
> > > @@ -36,7 +36,7 @@ expect_tree_entries () {
> > >       test_cmp expect actual
> > >   }
> > > -test_expect_success 'refuses to work with merge commits' '
> > > +test_expect_success 'refuses to split a merge commit' '
> > >       test_when_finished "rm -rf repo" &&
> > >       git init repo &&
> > >       (
> > > @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge commits'
> > > '
> > >           git switch - &&
> > >           git merge theirs &&
> > >           test_must_fail git history split HEAD 2>err &&
> > > -        test_grep "cannot split up merge commit" err &&
> > > -        test_must_fail git history split HEAD~ 2>err &&
> > > -        test_grep "replaying merge commits is not supported yet" err
> > > +        test_grep "cannot split up merge commit" err
> > >       )
> > >   '
> > > diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
> > > index 3353bc4a4d..368b1b0f9a 100755
> > > --- a/t/t3650-replay-basics.sh
> > > +++ b/t/t3650-replay-basics.sh
> > > @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ...
> > > ordering would be ill-defined' '
> > >       test_cmp expect actual
> > >   '
> > > -test_expect_success 'replaying merge commits is not supported yet' '
> > > -    echo "fatal: replaying merge commits is not supported yet!" 
> > > >expect &&
> > > -    test_must_fail git replay --advance=main main..topic-with-merge
> > > 2>actual &&
> > > -    test_cmp expect actual
> > > +test_expect_success 'using replay to rebase a 2-parent merge' '
> > > +    # main..topic-with-merge contains a 2-parent merge (P) introduced
> > > +    # via test_merge. Use --ref-action=print so this test does not
> > > +    # mutate state for subsequent tests in this file.
> > > +    git replay --ref-action=print --onto main main..topic-with-merge 
> > > >result &&
> > > +    test_line_count = 1 result &&
> > > +
> > > +    new_tip=$(cut -f 3 -d " " result) &&
> > > +
> > > +    # Result is still a 2-parent merge.
> > > +    git cat-file -p $new_tip >cat &&
> > > +    grep -c "^parent " cat >count &&
> > > +    echo 2 >expect &&
> > > +    test_cmp expect count &&
> > > +
> > > +    # Merge subject is preserved.
> > > +    echo P >expect &&
> > > +    git log -1 --format=%s $new_tip >actual &&
> > > +    test_cmp expect actual &&
> > > +
> > > +    # The replayed merge sits on top of main: walking back via the
> > > +    # first-parent chain reaches main.
> > > +    git merge-base --is-ancestor main $new_tip
> > > +'
> > > +
> > > +test_expect_success 'replaying an octopus merge is rejected' '
> > > +    # Build an octopus side-branch so the rest of the test state stays
> > > +    # untouched.
> > > +    test_when_finished "git update-ref -d refs/heads/octopus-tip" &&
> > > +    octopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \
> > > +        -m "octopus" $(git rev-parse topic4^{tree})) &&
> > > +    git update-ref refs/heads/octopus-tip "$octopus_tip" &&
> > > +
> > > +    test_must_fail git replay --ref-action=print --onto main \
> > > +        topic4..octopus-tip 2>actual &&
> > > +    test_grep "octopus merges" actual
> > > +'
> > > +
> > > +test_expect_success 'reverting a merge commit is rejected' '
> > > +    test_must_fail git replay --ref-action=print --revert=topic-with-
> > > merge \
> > > +        topic4..topic-with-merge 2>actual &&
> > > +    test_grep "reverting merge commits" actual
> > >   '
> > >   test_expect_success 'using replay to rebase two branches, one on top of
> > > other' '
> > 
> 
> 
> 

}

static int revwalk_contains_merges(struct repository *repo,
const struct strvec *revwalk_args)
static int revwalk_contains_octopus_merges(struct repository *repo,
const struct strvec *revwalk_args)
{
struct strvec args = STRVEC_INIT;
struct rev_info revs;
int ret;

strvec_pushv(&args, revwalk_args->v);
strvec_push(&args, "--min-parents=2");
strvec_push(&args, "--min-parents=3");

repo_init_revisions(repo, &revs, NULL);

Expand All @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository *repo,
}

if (get_revision(&revs)) {
ret = error(_("replaying merge commits is not supported yet!"));
ret = error(_("replaying octopus merges is not supported"));
goto out;
}

Expand Down Expand Up @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo,
strvec_push(&args, "HEAD");
}

ret = revwalk_contains_merges(repo, &args);
ret = revwalk_contains_octopus_merges(repo, &args);
if (ret < 0)
goto out;

Expand Down Expand Up @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc,
if (ret < 0) {
ret = error(_("failed replaying descendants"));
goto out;
} else if (ret) {
ret = error(_("conflict during replay; some descendants were not rewritten"));
goto out;
}

ret = 0;
Expand Down Expand Up @@ -721,6 +724,9 @@ static int cmd_history_split(int argc,
if (ret < 0) {
ret = error(_("failed replaying descendants"));
goto out;
} else if (ret) {
ret = error(_("conflict during replay; some descendants were not rewritten"));
goto out;
}

ret = 0;
Expand Down
3 changes: 3 additions & 0 deletions merge-ll.c
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ static enum ll_merge_result ll_xdl_merge(const struct ll_merge_driver *drv_unuse
xmp.ancestor = orig_name;
xmp.file1 = name1;
xmp.file2 = name2;
xmp.out_intervals = opts->out_intervals;
xmp.in_orig_intervals = opts->in_orig_intervals;
xmp.in_side2_intervals = opts->in_side2_intervals;
status = xdl_merge(orig, src1, src2, &xmp, result);
ret = (status > 0) ? LL_MERGE_CONFLICT : status;
return ret;
Expand Down
14 changes: 14 additions & 0 deletions merge-ll.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ struct ll_merge_options {

/* Extra xpparam_t flags as defined in xdiff/xdiff.h. */
long xdl_opts;

/*
* Forwarded onto xmparam_t. See xdiff/xdiff.h:s_xmparam.
* `out_intervals`, when set, receives the byte ranges of
* every conflict-marker hunk xdl_merge writes into the
* result buffer. `in_orig_intervals` and
* `in_side2_intervals` request the newly-introduced-conflict
* detection in the outer merge of a replayed merge commit;
* setting `in_side2_intervals` to a non-NULL pointer is the
* opt-in.
*/
struct xdl_conflict_intervals *out_intervals;
struct xdl_conflict_intervals *in_orig_intervals;
struct xdl_conflict_intervals *in_side2_intervals;
};

#define LL_MERGE_OPTIONS_INIT { .conflict_style = -1 }
Expand Down
81 changes: 74 additions & 7 deletions merge-ort.c
Original file line number Diff line number Diff line change
Expand Up @@ -1371,13 +1371,31 @@ static int collect_merge_info_callback(int n,
* files, then we can use side2 as the resolution. We cannot
* necessarily do so this for trees, because there may be rename
* destinations within side2.
*
* Under the replay-merge interval side channel, however, this
* shortcut would silently install side2's blob even when an
* earlier inner merge left an unresolved conflict on the path:
* the conflict-marker bytes (or, for non-textual drivers, the
* unresolved binary blob) would land in the result verbatim.
* Skip the shortcut when a side-channel entry says side2 is
* unresolved for this path; the path then falls through to the
* full content merge below, where xdl_merge or the loud-gate
* for non-xdiff drivers will surface the conflict.
*/
if (side1_matches_mbase && filemask == 0x07) {
/* use side2 version as resolution */
setup_path_info(opt, &pi, dirname, info->pathlen, fullpath,
names, names+2, side2_null, 0,
filemask, dirmask, 1);
return mask;
int side2_unresolved = 0;

if (opt->in_replay_side2_intervals &&
strmap_get(opt->in_replay_side2_intervals, fullpath))
side2_unresolved = 1;

if (!side2_unresolved) {
/* use side2 version as resolution */
setup_path_info(opt, &pi, dirname, info->pathlen, fullpath,
names, names+2, side2_null, 0,
filemask, dirmask, 1);
return mask;
}
}

/* Similar to above but swapping sides 1 and 2 */
Expand Down Expand Up @@ -2105,6 +2123,7 @@ static int merge_3way(struct merge_options *opt,
{
mmfile_t orig, src1, src2;
struct ll_merge_options ll_opts = LL_MERGE_OPTIONS_INIT;
struct xdl_conflict_intervals out_intervals = { 0 };
char *base, *name1, *name2;
enum ll_merge_result merge_status;

Expand All @@ -2115,6 +2134,14 @@ static int merge_3way(struct merge_options *opt,
ll_opts.extra_marker_size = extra_marker_size;
ll_opts.xdl_opts = opt->xdl_opts;
ll_opts.conflict_style = opt->conflict_style;
if (opt->out_replay_intervals)
ll_opts.out_intervals = &out_intervals;
if (opt->in_replay_orig_intervals)
ll_opts.in_orig_intervals =
strmap_get(opt->in_replay_orig_intervals, path);
if (opt->in_replay_side2_intervals)
ll_opts.in_side2_intervals =
strmap_get(opt->in_replay_side2_intervals, path);

if (opt->priv->call_depth) {
ll_opts.virtual_ancestor = 1;
Expand Down Expand Up @@ -2157,6 +2184,28 @@ static int merge_3way(struct merge_options *opt,
"warning: Cannot merge binary files: %s (%s vs. %s)",
path, name1, name2);

if (opt->out_replay_intervals &&
(out_intervals.nr > 0 ||
merge_status == LL_MERGE_CONFLICT ||
merge_status == LL_MERGE_BINARY_CONFLICT)) {
/*
* Inner content merge for this path emitted markers
* (recorded as intervals) or otherwise failed to
* resolve (binary driver or other non-textual
* driver). Either way, the path carries an
* unresolved inner conflict that the outer merge
* needs to know about; transfer ownership of the
* recorded intervals (possibly empty) into the
* caller-owned strmap.
*/
struct xdl_conflict_intervals *stored;
stored = xmalloc(sizeof(*stored));
*stored = out_intervals;
strmap_put(opt->out_replay_intervals, path, stored);
} else {
xdl_conflict_intervals_release(&out_intervals);
}

free(base);
free(name1);
free(name2);
Expand Down Expand Up @@ -4216,6 +4265,26 @@ static int process_entry(struct merge_options *opt,
assert(othermask == 2 || othermask == 4);
assert(ci->merged.is_null ==
(ci->filemask == ci->match_mask));

/*
* Replay-merge side channel: when this match_mask
* shortcut would have taken side2's blob verbatim
* (match_mask == 3 means stages 0 and 1 agree,
* stage 2 differs), but an earlier inner merge
* recorded that side2 is unresolved for this
* path, surface a content conflict instead of
* silently committing the inner-conflict blob.
*/
if (ci->merged.clean && side == 2 &&
!ci->merged.is_null &&
opt->in_replay_side2_intervals &&
strmap_get(opt->in_replay_side2_intervals, path)) {
ci->merged.clean = 0;
path_msg(opt, CONFLICT_CONTENTS, 0,
path, NULL, NULL, NULL,
_("CONFLICT (content): Merge conflict in %s"),
path);
}
}
} else if (ci->filemask >= 6 &&
(S_IFMT & ci->stages[1].mode) !=
Expand Down Expand Up @@ -5433,8 +5502,6 @@ void merge_incore_recursive(struct merge_options *opt,
* allow one exception through so that builtin/am can override
* with its constructed fake ancestor.
*/
assert(opt->ancestor == NULL ||
(merge_bases && !merge_bases->next));

trace2_region_enter("merge", "merge_start", opt->repo);
merge_start(opt, result);
Expand Down
33 changes: 33 additions & 0 deletions merge-ort.h
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,39 @@ struct merge_options {
unsigned record_conflict_msgs_as_headers : 1;
const char *msg_header_prefix;

/*
* Conflict-interval side channel for replay-merge. Each strmap,
* when non-NULL, maps a path to a heap-allocated
* `struct xdl_conflict_intervals *`. The caller owns both the
* strmaps and the value pointers.
*
* out_replay_intervals: populated by the per-path content
* merge. After ll_merge returns for a path, the recorded
* intervals are moved into a freshly allocated struct
* stored under the path. Paths whose inner content merge
* was clean do not appear; paths whose ll_merge call
* produced conflict markers (text driver) or returned a
* non-clean status (binary, custom, union, ours/theirs)
* do.
*
* in_replay_orig_intervals, in_replay_side2_intervals:
* looked up per path during the outer merge and forwarded
* into ll_merge_options.in_orig_intervals /
* ll_merge_options.in_side2_intervals so that xdl_merge
* can detect a conflict in mf2 (side2) that has no
* counterpart in orig.
*
* The fast paths in collect_merge_info_callback() and
* process_entry() that would otherwise take side2's blob
* verbatim also consult `in_replay_side2_intervals`: a
* present entry forces the path through the full content
* merge (or, when the entry was recorded for a non-xdiff
* driver, surfaces an explicit conflict).
*/
struct strmap *out_replay_intervals;
struct strmap *in_replay_orig_intervals;
struct strmap *in_replay_side2_intervals;

/* internal fields used by the implementation */
struct merge_options_internal *priv;
};
Expand Down
Loading
Loading