public inbox for git@vger.kernel.org 
 help / color / mirror / Atom feed
From: Johannes Schindelin <Johannes.Schindelin@gmx•de>
To: Phillip Wood <phillip.wood123@gmail•com>
Cc: Johannes Schindelin via GitGitGadget <gitgitgadget@gmail•com>,
	 git@vger•kernel.org, Elijah Newren <newren@gmail•com>,
	 Patrick Steinhardt <ps@pks•im>
Subject: Re: [PATCH/RFC 1/5] replay: support replaying 2-parent merges
Date: Sun, 17 May 2026 16:32:11 +0200 (CEST)	[thread overview]
Message-ID: <e094879e-ead9-879b-b889-038025e8e822@gmx.de> (raw)
In-Reply-To: <72901ee2-1212-46cd-b752-f451cce6e1ff@gmail.com>

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' '
> 
> 

  parent reply	other threads:[~2026-05-17 14:32 UTC|newest]

Thread overview: 17+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-06 22:43 [PATCH/RFC 0/5] replay: support replaying 2-parent merges Johannes Schindelin via GitGitGadget
2026-05-06 22:43 ` [PATCH/RFC 1/5] " Johannes Schindelin via GitGitGadget
2026-05-08  9:36   ` Phillip Wood
2026-05-08 10:05     ` Phillip Wood
2026-05-17 14:33       ` Johannes Schindelin
2026-05-17 14:32     ` Johannes Schindelin [this message]
2026-05-26 21:15   ` Kristoffer Haugsbakk
2026-05-06 22:43 ` [PATCH/RFC 2/5] replay: short-circuit merge replay when parent and base trees are unchanged Johannes Schindelin via GitGitGadget
2026-05-06 22:43 ` [PATCH/RFC 3/5] history.adoc: describe merge-replay support and its limits Johannes Schindelin via GitGitGadget
2026-05-06 22:43 ` [PATCH/RFC 4/5] test-tool: add a "historian" subcommand for building merge fixtures Johannes Schindelin via GitGitGadget
2026-05-12 10:54   ` Toon Claes
2026-05-17 11:40     ` Johannes Schindelin
2026-05-06 22:43 ` [PATCH/RFC 5/5] t3454: cover merge-replay scenarios with the historian helper Johannes Schindelin via GitGitGadget
2026-05-07 14:14 ` [PATCH/RFC 0/5] replay: support replaying 2-parent merges D. Ben Knoble
2026-05-07 15:06   ` Johannes Schindelin
2026-05-07 15:39     ` Ben Knoble
2026-05-17 11:33       ` Johannes Schindelin

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=e094879e-ead9-879b-b889-038025e8e822@gmx.de \
    --to=johannes.schindelin@gmx$(echo .)de \
    --cc=git@vger$(echo .)kernel.org \
    --cc=gitgitgadget@gmail$(echo .)com \
    --cc=newren@gmail$(echo .)com \
    --cc=phillip.wood123@gmail$(echo .)com \
    --cc=ps@pks$(echo .)im \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox