Volume XXII, number 279Tuesday, October 6, 2026Latest message 57 minutes ago

The Git List

News and archive of git@vger.kernel.org, since April 2005

patch, 3 partsTeach git-replay(1) to linearize merge commits

75 messages between Jun 8, 2026 and Sep 1, 2026, from Toon Claes, Junio C Hamano, Justin Tobler, Elijah Newren, Patrick Steinhardt, Phillip Wood, Johannes Schindelin.

Plain Markdown or JSON for tools and agents. Diffs are folded; open one to read it.

Toon ClaesJun 8, 2026, 18:37 UTC on lore

As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics wath git-rebase(1) does too with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. I was kindly helped by dscho to implement this change.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3].

dscho's series to replay merges[1] need a bit of rework to fit on top of this, but I'm happy to help figuring that out.

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>

Signed-off-by: Toon Claes <toon@iotcl.com>
---
Johannes Schindelin (1):
      replay: offer an option to linearize the commit topology
Toon Claes (2):
      replay: refactor enum replay_mode into a bool
      replay: add helper to put entry into mapped_commits
 Documentation/git-replay.adoc |   5 ++
 builtin/replay.c              |   4 ++
 replay.c                      | 109 +++++++++++++++++++++++-------------------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      |  22 +++++++++
 5 files changed, 97 insertions(+), 48 deletions(-)

--- base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesJun 8, 2026, 18:37 UTC in reply to Toon Claes on lore

[PATCH 1/3] replay: refactor enum replay_mode into a bool

In 2760ee4983 (replay: add --revert mode to reverse commit changes, 2026-03-26) the enum `replay_mode` was introduced. This has two possible values:

 - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
   passed to git-replay(1). When using this value the commits are
   possible in reverse order and the inverse of the changes are applied.
 - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
   `--advance` is used. In both cases the commits are pocessed in normal
   order, and the changes are applied as-is.

Since there are only two possible values of this enum, simplify the code by converting the enum into a bool. This avoid adding code paths that check for invalid vaues of the enum, and shortens code where the value is checked with a ternary operator.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 59 +++++++++++++++++++++++++----------------------------------
 1 file changed, 25 insertions(+), 34 deletions(-)
Show changes to replay.c +25 −34
diff --git a/replay.c b/replay.c
index 4ef8abb607..1f8e5b083b 100644
--- a/replay.c
+++ b/replay.c
@@ -18,11 +18,6 @@
  */
 #define the_repository DO_NOT_USE_THE_REPOSITORY
 
-enum replay_mode {
-	REPLAY_MODE_PICK,
-	REPLAY_MODE_REVERT,
-};
-
 static const char *short_commit_name(struct repository *repo,
 				     struct commit *commit)
 {
@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,
 				    struct tree *tree,
 				    struct commit *based_on,
 				    struct commit *parent,
-				    enum replay_mode mode)
+				    bool reverse)
 {
 	struct object_id ret;
 	struct object *obj = NULL;
@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,
 
 	commit_list_insert(parent, &parents);
 	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	if (mode == REPLAY_MODE_REVERT) {
+	if (reverse) {
 		generate_revert_message(&msg, based_on, repo);
 		/* For revert, use current user as author (NULL = use default) */
-	} else if (mode == REPLAY_MODE_PICK) {
+	} else {
 		find_commit_subject(message, &orig_message);
 		strbuf_addstr(&msg, orig_message);
 		author = get_author(message);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	reset_ident_date();
 	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
-					  enum replay_mode mode,
+					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
 	struct commit *base, *replayed_base;
@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
-	if (mode == REPLAY_MODE_PICK) {
+	if (reverse) {
+		/* Revert: swap base and pickme to reverse the diff */
+		const char *pickme_name = short_commit_name(repo, pickme);
+		merge_opt->branch1 = short_commit_name(repo, replayed_base);
+		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
+		merge_opt->ancestor = pickme_name;
+
+		merge_incore_nonrecursive(merge_opt,
+					  pickme_tree,
+					  replayed_base_tree,
+					  base_tree,
+					  result);
+
+		free((char *)merge_opt->branch2);
+	} else {
 		/* Cherry-pick: normal order */
 		merge_opt->branch1 = short_commit_name(repo, replayed_base);
 		merge_opt->branch2 = short_commit_name(repo, pickme);
@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  result);
 
 		free((char *)merge_opt->ancestor);
-	} else if (mode == REPLAY_MODE_REVERT) {
-		/* Revert: swap base and pickme to reverse the diff */
-		const char *pickme_name = short_commit_name(repo, pickme);
-		merge_opt->branch1 = short_commit_name(repo, replayed_base);
-		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
-		merge_opt->ancestor = pickme_name;
-
-		merge_incore_nonrecursive(merge_opt,
-					  pickme_tree,
-					  replayed_base_tree,
-					  base_tree,
-					  result);
-
-		free((char *)merge_opt->branch2);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	merge_opt->ancestor = NULL;
 	merge_opt->branch2 = NULL;
@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		}
 	}
 
-	return create_commit(repo, result->tree, pickme, replayed_base, mode);
+	return create_commit(repo, result->tree, pickme, replayed_base, reverse);
 }
 
 void replay_result_release(struct replay_result *result)
@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,
 	char *revert;
 	const char *ref;
 	struct object_id old_oid;
-	enum replay_mode mode = REPLAY_MODE_PICK;
+	bool reverse;
 	int ret;
 
 	advance = xstrdup_or_null(opts->advance);
 	revert = xstrdup_or_null(opts->revert);
-	if (revert)
-		mode = REPLAY_MODE_REVERT;
+	reverse = !!revert;
+
 	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
 			   &detached_head, &advance, &revert, &onto, &update_refs);
 
@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,
 			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, opts->empty);
+						  reverse ? last_commit : onto,
+						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 8, 2026, 18:37 UTC in reply to Toon Claes on lore

[PATCH 2/3] replay: add helper to put entry into mapped_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put commit entry into mapped_commits into a helper function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index 1f8e5b083b..7921d7dba3 100644
--- a/replay.c
+++ b/replay.c
@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s\n",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 8, 2026, 18:37 UTC in reply to Toon Claes on lore

[PATCH 3/3] replay: offer an option to linearize the commit topology

From: Johannes Schindelin <Johannes.Schindelin@gmx.de>

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearized the commit history into a sequence of the regular (single-parent) commits.

Add option `--linearize` to git-replay(1) do the same.
Co-authored-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  5 +++++
 builtin/replay.c              |  4 ++++
 replay.c                      | 25 +++++++++++++++++++------
 replay.h                      |  5 +++++
 t/t3650-replay-basics.sh      | 22 ++++++++++++++++++++++
 5 files changed, 55 insertions(+), 6 deletions(-)
Show changes to 5 files +55 −6

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..41c96c7061 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
+	i.e. it cherry-picks only non-merge commits, each one on top of the
+	previous one.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..fedfe46dc6 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("ignore merge commits instead of replaying them")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.revert, "--revert",
+				  opts.linearize, "--linearize");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 7921d7dba3..3e36908131 100644
--- a/replay.c
+++ b/replay.c
@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
+					  struct commit *replayed_base,
 					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
+	struct commit *base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
+	if (replayed_base && reverse)
+		BUG("Linearizing commits is not supported when replaying in reverse");
+
 	if (pickme->parents) {
 		base = pickme->parents->item;
 		base_tree = repo_get_commit_tree(repo, base);
@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
+	if (!replayed_base)
+		replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		if (commit->parents && commit->parents->next)
+		if (opts->linearize && (!commit->parents || commit->parents->next))
+			; /* map current commit to the same as the previous commit */
+		else if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
+		else {
+			struct commit *to_pick = reverse ? last_commit : onto;
+			last_commit =
+				pick_regular_commit(revs->repo, commit,
+						    replayed_commits, to_pick,
+						    &merge_opt, &result,
+						    opts->linearize ? last_commit : NULL,
+						    reverse, opts->empty);
+		}
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  reverse ? last_commit : onto,
-						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index 1851a07705..07e6fdcca3 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..c781a3bb1b 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -565,4 +565,26 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'linearize the commit topology' '
+	test_tick &&
+	N=$(git commit-tree -m N -p L -p I L:) &&
+	N=$(git commit-tree -m N-child -p $N L:) &&
+	git update-ref refs/heads/N $N &&
+
+	git replay --ref-action=print --linearize \
+		--onto A B..refs/heads/N >out &&
+
+	test_line_count = 1 out &&
+	read N1 N2 N3 N4 <out &&
+
+	cat >expect <<-EOF &&
+	* N-child
+	* I
+	* L
+	o A
+	EOF
+	git log --format=%s --graph --boundary A...$N3 >actual &&
+	test_cmp expect actual
+'
+
 test_done
-- 
2.53.0.1323.g189a785ab5
Junio C HamanoJun 8, 2026, 19:29 UTC in reply to Toon Claes on lore

Re: [PATCH 3/3] replay: offer an option to linearize the commit topology

Toon Claes <toon@iotcl.com> writes:
Show 9 quoted lines
> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>
>
> One of the stated goals of git-replay(1) is to allow implementing the
> git-rebase(1) functionality on the server side.
>
> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
> was given. This mode drops merge commits instead of replaying them, and
> linearized the commit history into a sequence of the
> regular (single-parent) commits.
"linearized" -> "linearizes"?
>
> Add option `--linearize` to git-replay(1) do the same.
"do the same" -> "to do the same"?
> Co-authored-by: Toon Claes <toon@iotcl.com>
There is no sign-off by any of the authors?
Show 7 quoted lines
> @@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs,
>  	while ((commit = get_revision(revs))) {
>  		const struct name_decoration *decoration;
>  
> -		if (commit->parents && commit->parents->next)
> +		if (opts->linearize && (!commit->parents || commit->parents->next))
> +			; /* map current commit to the same as the previous commit */

This uses the same treatment on either root commits or merge commits? If this were a mistake and this wants to handle merges but not roots, shouldn't it be more like

		if (opts->linearize && (commit->parents && commit->parents->next))
			; /* map the merge to the previous */
> +		else if (commit->parents && commit->parents->next)
>  			die(_("replaying merge commits is not supported yet!"));

And because the next one is also about merges, perhaps the early part of this if/else if cascade can be written

		if (commit->parents && commit->parents->next) {
			/* We have a merge */
			if (!opts->linearize)
				die(_("can't replay a merge (yet)"));
			; /* map current to the previous */
		} else {
			...
wouldn't it?

If the "map current to prev" is applicable to root, any root are mapped to the last_commit in the above, and if we saw a root as the first thing in the loop, last_commit is NULL, we do not do anything here, and after the if/else if/else cascade, we see last_commit is NULL and break out of the loop.

Show 15 quoted lines
> +		else {
> +			struct commit *to_pick = reverse ? last_commit : onto;
> +			last_commit =
> +				pick_regular_commit(revs->repo, commit,
> +						    replayed_commits, to_pick,
> +						    &merge_opt, &result,
> +						    opts->linearize ? last_commit : NULL,
> +						    reverse, opts->empty);
> +		}
>  
> -		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
> -						  reverse ? last_commit : onto,
> -						  &merge_opt, &result, reverse, opts->empty);
>  		if (!last_commit)
>  			break;
Show 45 quoted lines
> diff --git a/replay.h b/replay.h
> index 1851a07705..07e6fdcca3 100644
> --- a/replay.h
> +++ b/replay.h
> @@ -62,6 +62,11 @@ struct replay_revisions_options {
>  	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
>  	 */
>  	enum replay_empty_commit_action empty;
> +
> +	/*
> +	 * Whether to linearize the commits (i.e. drop merge commits).
> +	 */
> +	int linearize;
>  };
>  
>  /* This struct is used as an out-parameter by `replay_revisions()`. */
> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
> index 3353bc4a4d..c781a3bb1b 100755
> --- a/t/t3650-replay-basics.sh
> +++ b/t/t3650-replay-basics.sh
> @@ -565,4 +565,26 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
>  	test_grep "cannot be used with multiple revision ranges" err
>  '
>  
> +test_expect_success 'linearize the commit topology' '
> +	test_tick &&
> +	N=$(git commit-tree -m N -p L -p I L:) &&
> +	N=$(git commit-tree -m N-child -p $N L:) &&
> +	git update-ref refs/heads/N $N &&
> +
> +	git replay --ref-action=print --linearize \
> +		--onto A B..refs/heads/N >out &&
> +
> +	test_line_count = 1 out &&
> +	read N1 N2 N3 N4 <out &&
> +
> +	cat >expect <<-EOF &&
> +	* N-child
> +	* I
> +	* L
> +	o A
> +	EOF
> +	git log --format=%s --graph --boundary A...$N3 >actual &&
> +	test_cmp expect actual
> +'

Perhaps we would want to have a test that replays all the way down to the root commit?

Toon ClaesJun 10, 2026, 14:26 UTC in reply to Junio C Hamano on lore

Re: [PATCH 3/3] replay: offer an option to linearize the commit topology

Junio C Hamano <gitster@pobox.com> writes:
Show 13 quoted lines
> Toon Claes <toon@iotcl.com> writes:
>
>> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>
>>
>> One of the stated goals of git-replay(1) is to allow implementing the
>> git-rebase(1) functionality on the server side.
>>
>> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
>> was given. This mode drops merge commits instead of replaying them, and
>> linearized the commit history into a sequence of the
>> regular (single-parent) commits.
>
> "linearized" -> "linearizes"?
Thanks.
>>
>> Add option `--linearize` to git-replay(1) do the same.
>
> "do the same" -> "to do the same"?
Ack.
>> Co-authored-by: Toon Claes <toon@iotcl.com>
>
> There is no sign-off by any of the authors?
My bad. I'll add mine.

@Johannes, can I re-add yours? I've removed it because I've made some changes on top of the patch you wrote, but if you agree, I'll add your Sign-off back.

Show 30 quoted lines
>> @@ -430,12 +435,20 @@ int replay_revisions(struct rev_info *revs,
>>  	while ((commit = get_revision(revs))) {
>>  		const struct name_decoration *decoration;
>>  
>> -		if (commit->parents && commit->parents->next)
>> +		if (opts->linearize && (!commit->parents || commit->parents->next))
>> +			; /* map current commit to the same as the previous commit */
>
> This uses the same treatment on either root commits or merge
> commits?  If this were a mistake and this wants to handle merges but
> not roots, shouldn't it be more like
>
> 		if (opts->linearize && (commit->parents && commit->parents->next))
> 			; /* map the merge to the previous */
>
>> +		else if (commit->parents && commit->parents->next)
>>  			die(_("replaying merge commits is not supported yet!"));
>
> And because the next one is also about merges, perhaps the early
> part of this if/else if cascade can be written
>
> 		if (commit->parents && commit->parents->next) {
> 			/* We have a merge */
> 			if (!opts->linearize)
> 				die(_("can't replay a merge (yet)"));
> 			; /* map current to the previous */
> 		} else {
> 			...
>
> wouldn't it?

The way it was written in v1 was maybe a bit too smart and hard to follow. I agree with your suggestion and will adopt this (with some tweaks) in the next version.

Show 5 quoted lines
> If the "map current to prev" is applicable to root, any root are
> mapped to the last_commit in the above, and if we saw a root as the
> first thing in the loop, last_commit is NULL, we do not do anything
> here, and after the if/else if/else cascade, we see last_commit is
> NULL and break out of the loop.
Yes, good observation. I did not test this.
> Perhaps we would want to have a test that replays all the way down
> to the root commit?
I'll add it.
-- 
Cheers,
Toon
Toon ClaesJun 10, 2026, 14:49 UTC in reply to Toon Claes on lore

[PATCH v2 0/3] Teach git-replay(1) to linearize merge commits

As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics wath git-rebase(1) does too with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. I was kindly helped by dscho to implement this change.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3].

dscho's series to replay merges[1] need a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>

Signed-off-by: Toon Claes <toon@iotcl.com>
---
Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
---
Johannes Schindelin (1):
      replay: offer an option to linearize the commit topology
Toon Claes (2):
      replay: refactor enum replay_mode into a bool
      replay: add helper to put entry into mapped_commits
 Documentation/git-replay.adoc |   5 ++
 builtin/replay.c              |   4 ++
 replay.c                      | 114 ++++++++++++++++++++++++------------------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      |  26 ++++++++++
 5 files changed, 105 insertions(+), 49 deletions(-)
Range-diff versus v1:
1:  7f3bc6f425 ! 1:  0975b142e3 replay: refactor enum replay_mode into a bool
    @@ Commit message
     
          - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
            passed to git-replay(1). When using this value the commits are
    -       possible in reverse order and the inverse of the changes are applied.
    +       processed in reverse order and the inverse of the changes are
    +       applied.
     
          - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
    -       `--advance` is used. In both cases the commits are pocessed in normal
    -       order, and the changes are applied as-is.
    +       `--advance` is used. In both cases the commits are processed in
    +       normal order, and the changes are applied as-is.
     
         Since there are only two possible values of this enum, simplify the code
    -    by converting the enum into a bool. This avoid adding code paths that
    -    check for invalid vaues of the enum, and shortens code where the value
    +    by converting the enum into a bool. This avoids adding code paths that
    +    check for invalid values of the enum, and shortens code where the value
         is checked with a ternary operator.
     
         Signed-off-by: Toon Claes <toon@iotcl.com>
2:  0868871c78 ! 2:  db88193624 replay: add helper to put entry into mapped_commits
    @@ Commit message
         replay: add helper to put entry into mapped_commits
     
         The function replay_revisions() in replay.c is rather lengthy. Extract
    -    the logic to put commit entry into mapped_commits into a helper
    -    function.
    +    the logic to put a commit entry into mapped_commits into a helper
    +    function put_mapped_commit().
    +
    +    While at it, rename mapped_commit() to get_mapped_commit() to pair with
    +    this new function.
     
         Signed-off-by: Toon Claes <toon@iotcl.com>
     
3:  a432ae753b ! 3:  d0c220ec8e replay: offer an option to linearize the commit topology
    @@ Commit message
     
         The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
         was given. This mode drops merge commits instead of replaying them, and
    -    linearized the commit history into a sequence of the
    +    linearizes the commit history into a sequence of the
         regular (single-parent) commits.
     
    -    Add option `--linearize` to git-replay(1) do the same.
    +    Add option `--linearize` to git-replay(1) to do the same.
     
         Co-authored-by: Toon Claes <toon@iotcl.com>
    +    Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
    +    Signed-off-by: Toon Claes <toon@iotcl.com>
     
      ## Documentation/git-replay.adoc ##
     @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).
    @@ replay.c: int replay_revisions(struct rev_info *revs,
      		const struct name_decoration *decoration;
      
     -		if (commit->parents && commit->parents->next)
    -+		if (opts->linearize && (!commit->parents || commit->parents->next))
    -+			; /* map current commit to the same as the previous commit */
    -+		else if (commit->parents && commit->parents->next)
    - 			die(_("replaying merge commits is not supported yet!"));
    -+		else {
    +-			die(_("replaying merge commits is not supported yet!"));
    ++		if (commit->parents && commit->parents->next) {
    ++			if (!opts->linearize)
    ++				die(_("replaying merge commits is not supported yet!"));
    ++			/*
    ++			 * When linearizing, a merge commit itself is not picked,
    ++			 * but refs that point to it might need updating.
    ++			 */
    ++		} else {
     +			struct commit *to_pick = reverse ? last_commit : onto;
     +			last_commit =
     +				pick_regular_commit(revs->repo, commit,
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
      	test_grep "cannot be used with multiple revision ranges" err
      '
      
    -+test_expect_success 'linearize the commit topology' '
    -+	test_tick &&
    -+	N=$(git commit-tree -m N -p L -p I L:) &&
    -+	N=$(git commit-tree -m N-child -p $N L:) &&
    -+	git update-ref refs/heads/N $N &&
    ++test_expect_success 'replay merge commit fails' '
    ++	echo "fatal: replaying merge commits is not supported yet!" >expect &&
    ++	test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'replay to rebase merge commit with --linearize' '
    ++	git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&
    ++
    ++	test_line_count = 1 result &&
    ++
    ++	git log --format=%s $(cut -f 3 -d " " result) >actual &&
    ++	test_write_lines O N J M L B A >expect &&
    ++	test_cmp expect actual
    ++'
     +
    -+	git replay --ref-action=print --linearize \
    -+		--onto A B..refs/heads/N >out &&
    ++test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '
    ++	git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&
     +
    -+	test_line_count = 1 out &&
    -+	read N1 N2 N3 N4 <out &&
    ++	test_line_count = 1 result &&
     +
    -+	cat >expect <<-EOF &&
    -+	* N-child
    -+	* I
    -+	* L
    -+	o A
    -+	EOF
    -+	git log --format=%s --graph --boundary A...$N3 >actual &&
    ++	git log --format=%s $(cut -f 3 -d " " result) >actual &&
    ++	test_write_lines O N J I M L B A >expect &&
     +	test_cmp expect actual
     +'
     +

--- base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesJun 10, 2026, 14:49 UTC in reply to Toon Claes on lore

[PATCH v2 1/3] replay: refactor enum replay_mode into a bool

In 2760ee4983 (replay: add --revert mode to reverse commit changes, 2026-03-26) the enum `replay_mode` was introduced. This has two possible values:

 - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
   passed to git-replay(1). When using this value the commits are
   processed in reverse order and the inverse of the changes are
   applied.
 - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
   `--advance` is used. In both cases the commits are processed in
   normal order, and the changes are applied as-is.

Since there are only two possible values of this enum, simplify the code by converting the enum into a bool. This avoids adding code paths that check for invalid values of the enum, and shortens code where the value is checked with a ternary operator.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 59 +++++++++++++++++++++++++----------------------------------
 1 file changed, 25 insertions(+), 34 deletions(-)
Show changes to replay.c +25 −34
diff --git a/replay.c b/replay.c
index 4ef8abb607..1f8e5b083b 100644
--- a/replay.c
+++ b/replay.c
@@ -18,11 +18,6 @@
  */
 #define the_repository DO_NOT_USE_THE_REPOSITORY
 
-enum replay_mode {
-	REPLAY_MODE_PICK,
-	REPLAY_MODE_REVERT,
-};
-
 static const char *short_commit_name(struct repository *repo,
 				     struct commit *commit)
 {
@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,
 				    struct tree *tree,
 				    struct commit *based_on,
 				    struct commit *parent,
-				    enum replay_mode mode)
+				    bool reverse)
 {
 	struct object_id ret;
 	struct object *obj = NULL;
@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,
 
 	commit_list_insert(parent, &parents);
 	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	if (mode == REPLAY_MODE_REVERT) {
+	if (reverse) {
 		generate_revert_message(&msg, based_on, repo);
 		/* For revert, use current user as author (NULL = use default) */
-	} else if (mode == REPLAY_MODE_PICK) {
+	} else {
 		find_commit_subject(message, &orig_message);
 		strbuf_addstr(&msg, orig_message);
 		author = get_author(message);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	reset_ident_date();
 	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
-					  enum replay_mode mode,
+					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
 	struct commit *base, *replayed_base;
@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
-	if (mode == REPLAY_MODE_PICK) {
+	if (reverse) {
+		/* Revert: swap base and pickme to reverse the diff */
+		const char *pickme_name = short_commit_name(repo, pickme);
+		merge_opt->branch1 = short_commit_name(repo, replayed_base);
+		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
+		merge_opt->ancestor = pickme_name;
+
+		merge_incore_nonrecursive(merge_opt,
+					  pickme_tree,
+					  replayed_base_tree,
+					  base_tree,
+					  result);
+
+		free((char *)merge_opt->branch2);
+	} else {
 		/* Cherry-pick: normal order */
 		merge_opt->branch1 = short_commit_name(repo, replayed_base);
 		merge_opt->branch2 = short_commit_name(repo, pickme);
@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  result);
 
 		free((char *)merge_opt->ancestor);
-	} else if (mode == REPLAY_MODE_REVERT) {
-		/* Revert: swap base and pickme to reverse the diff */
-		const char *pickme_name = short_commit_name(repo, pickme);
-		merge_opt->branch1 = short_commit_name(repo, replayed_base);
-		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
-		merge_opt->ancestor = pickme_name;
-
-		merge_incore_nonrecursive(merge_opt,
-					  pickme_tree,
-					  replayed_base_tree,
-					  base_tree,
-					  result);
-
-		free((char *)merge_opt->branch2);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	merge_opt->ancestor = NULL;
 	merge_opt->branch2 = NULL;
@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		}
 	}
 
-	return create_commit(repo, result->tree, pickme, replayed_base, mode);
+	return create_commit(repo, result->tree, pickme, replayed_base, reverse);
 }
 
 void replay_result_release(struct replay_result *result)
@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,
 	char *revert;
 	const char *ref;
 	struct object_id old_oid;
-	enum replay_mode mode = REPLAY_MODE_PICK;
+	bool reverse;
 	int ret;
 
 	advance = xstrdup_or_null(opts->advance);
 	revert = xstrdup_or_null(opts->revert);
-	if (revert)
-		mode = REPLAY_MODE_REVERT;
+	reverse = !!revert;
+
 	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
 			   &detached_head, &advance, &revert, &onto, &update_refs);
 
@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,
 			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, opts->empty);
+						  reverse ? last_commit : onto,
+						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 10, 2026, 14:49 UTC in reply to Toon Claes on lore

[PATCH v2 2/3] replay: add helper to put entry into mapped_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index 1f8e5b083b..7921d7dba3 100644
--- a/replay.c
+++ b/replay.c
@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s\n",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 10, 2026, 14:49 UTC in reply to Toon Claes on lore

[PATCH v2 3/3] replay: offer an option to linearize the commit topology

From: Johannes Schindelin <Johannes.Schindelin@gmx.de>

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the commit history into a sequence of the regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same.
Co-authored-by: Toon Claes <toon@iotcl.com>
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  5 +++++
 builtin/replay.c              |  4 ++++
 replay.c                      | 30 +++++++++++++++++++++++-------
 replay.h                      |  5 +++++
 t/t3650-replay-basics.sh      | 26 ++++++++++++++++++++++++++
 5 files changed, 63 insertions(+), 7 deletions(-)
Show changes to 5 files +63 −7

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..41c96c7061 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
+	i.e. it cherry-picks only non-merge commits, each one on top of the
+	previous one.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..fedfe46dc6 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("ignore merge commits instead of replaying them")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.revert, "--revert",
+				  opts.linearize, "--linearize");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 7921d7dba3..81033fb889 100644
--- a/replay.c
+++ b/replay.c
@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
+					  struct commit *replayed_base,
 					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
+	struct commit *base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
+	if (replayed_base && reverse)
+		BUG("Linearizing commits is not supported when replaying in reverse");
+
 	if (pickme->parents) {
 		base = pickme->parents->item;
 		base_tree = repo_get_commit_tree(repo, base);
@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
+	if (!replayed_base)
+		replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * When linearizing, a merge commit itself is not picked,
+			 * but refs that point to it might need updating.
+			 */
+		} else {
+			struct commit *to_pick = reverse ? last_commit : onto;
+			last_commit =
+				pick_regular_commit(revs->repo, commit,
+						    replayed_commits, to_pick,
+						    &merge_opt, &result,
+						    opts->linearize ? last_commit : NULL,
+						    reverse, opts->empty);
+		}
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  reverse ? last_commit : onto,
-						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index 1851a07705..07e6fdcca3 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..64e0731188 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay merge commit fails' '
+	echo "fatal: replaying merge commits is not supported yet!" >expect &&
+	test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '
+	git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I M L B A >expect &&
+	test_cmp expect actual
+'
+
 test_done
-- 
2.53.0.1323.g189a785ab5
Junio C HamanoJun 10, 2026, 17:02 UTC in reply to Toon Claes on lore

Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology

Toon Claes <toon@iotcl.com> writes:
Show 36 quoted lines
> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>
>
> One of the stated goals of git-replay(1) is to allow implementing the
> git-rebase(1) functionality on the server side.
>
> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
> was given. This mode drops merge commits instead of replaying them, and
> linearizes the commit history into a sequence of the
> regular (single-parent) commits.
>
> Add option `--linearize` to git-replay(1) to do the same.
>
> Co-authored-by: Toon Claes <toon@iotcl.com>
> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> Signed-off-by: Toon Claes <toon@iotcl.com>
> ---
>  Documentation/git-replay.adoc |  5 +++++
>  builtin/replay.c              |  4 ++++
>  replay.c                      | 30 +++++++++++++++++++++++-------
>  replay.h                      |  5 +++++
>  t/t3650-replay-basics.sh      | 26 ++++++++++++++++++++++++++
>  5 files changed, 63 insertions(+), 7 deletions(-)
>
> @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs,
>  	while ((commit = get_revision(revs))) {
>  		const struct name_decoration *decoration;
>  
> -		if (commit->parents && commit->parents->next)
> -			die(_("replaying merge commits is not supported yet!"));
> +		if (commit->parents && commit->parents->next) {
> +			if (!opts->linearize)
> +				die(_("replaying merge commits is not supported yet!"));
> +			/*
> +			 * When linearizing, a merge commit itself is not picked,
> +			 * but refs that point to it might need updating.
> +			 */

In the review response during the previous iteration, I commented that (1) the original excluded only merges, but (2) your version excluded both merges and the root commits the same way. Your response was:

    The way it was written in v1 was maybe a bit too smart and hard to
    follow. I agree with your suggestion and will adopt this (with some
    tweaks) in the next version.

which I took as saying "it may be confusing, but it correctly expresses what we want to do", meaning "yes, roots and merges should be handled the same way". But the above no longer treats roots the same way as merges. I think that is intended, but just wanted to double check.

Show 14 quoted lines
> diff --git a/replay.h b/replay.h
> index 1851a07705..07e6fdcca3 100644
> --- a/replay.h
> +++ b/replay.h
> @@ -62,6 +62,11 @@ struct replay_revisions_options {
>  	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
>  	 */
>  	enum replay_empty_commit_action empty;
> +
> +	/*
> +	 * Whether to linearize the commits (i.e. drop merge commits).
> +	 */
> +	int linearize;
>  };
OK.
Show 26 quoted lines
> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
> index 3353bc4a4d..64e0731188 100755
> --- a/t/t3650-replay-basics.sh
> +++ b/t/t3650-replay-basics.sh
> @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
>  	test_grep "cannot be used with multiple revision ranges" err
>  '
>  
> +test_expect_success 'replay merge commit fails' '
> +	echo "fatal: replaying merge commits is not supported yet!" >expect &&
> +	test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'replay to rebase merge commit with --linearize' '
> +	git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&
> +
> +	test_line_count = 1 result &&
> +
> +	git log --format=%s $(cut -f 3 -d " " result) >actual &&
> +	test_write_lines O N J M L B A >expect &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '
> +	git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&

As with other test pieces, this "git replay" command line is overly long and hides the important bit which is that the range being replayed is *not* actually down to the root, which is A (it excludes A). Intended?

Show 9 quoted lines
> +
> +	test_line_count = 1 result &&
> +
> +	git log --format=%s $(cut -f 3 -d " " result) >actual &&
> +	test_write_lines O N J I M L B A >expect &&
> +	test_cmp expect actual
> +'
> +
>  test_done
Thanks.
Justin ToblerJun 11, 2026, 15:09 UTC in reply to Toon Claes on lore

Re: [PATCH v2 1/3] replay: refactor enum replay_mode into a bool

On 26/06/10 04:49PM, Toon Claes wrote:
Show 17 quoted lines
> In 2760ee4983 (replay: add --revert mode to reverse commit changes,
> 2026-03-26) the enum `replay_mode` was introduced. This has two possible
> values:
> 
>  - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
>    passed to git-replay(1). When using this value the commits are
>    processed in reverse order and the inverse of the changes are
>    applied.
> 
>  - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
>    `--advance` is used. In both cases the commits are processed in
>    normal order, and the changes are applied as-is.
> 
> Since there are only two possible values of this enum, simplify the code
> by converting the enum into a bool. This avoids adding code paths that
> check for invalid values of the enum, and shortens code where the value
> is checked with a ternary operator.

Naive question: Do we expect there to only be two replay modes in the forseeable future? I suppose if other modes were added in the future this change would essentially be reverted.

Show 139 quoted lines
> Signed-off-by: Toon Claes <toon@iotcl.com>
> ---
>  replay.c | 59 +++++++++++++++++++++++++----------------------------------
>  1 file changed, 25 insertions(+), 34 deletions(-)
> 
> diff --git a/replay.c b/replay.c
> index 4ef8abb607..1f8e5b083b 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -18,11 +18,6 @@
>   */
>  #define the_repository DO_NOT_USE_THE_REPOSITORY
>  
> -enum replay_mode {
> -	REPLAY_MODE_PICK,
> -	REPLAY_MODE_REVERT,
> -};
> -
>  static const char *short_commit_name(struct repository *repo,
>  				     struct commit *commit)
>  {
> @@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,
>  				    struct tree *tree,
>  				    struct commit *based_on,
>  				    struct commit *parent,
> -				    enum replay_mode mode)
> +				    bool reverse)
>  {
>  	struct object_id ret;
>  	struct object *obj = NULL;
> @@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,
>  
>  	commit_list_insert(parent, &parents);
>  	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
> -	if (mode == REPLAY_MODE_REVERT) {
> +	if (reverse) {
>  		generate_revert_message(&msg, based_on, repo);
>  		/* For revert, use current user as author (NULL = use default) */
> -	} else if (mode == REPLAY_MODE_PICK) {
> +	} else {
>  		find_commit_subject(message, &orig_message);
>  		strbuf_addstr(&msg, orig_message);
>  		author = get_author(message);
> -	} else {
> -		BUG("unexpected replay mode %d", mode);
>  	}
>  	reset_ident_date();
>  	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
> @@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
>  					  struct commit *onto,
>  					  struct merge_options *merge_opt,
>  					  struct merge_result *result,
> -					  enum replay_mode mode,
> +					  bool reverse,
>  					  enum replay_empty_commit_action empty)
>  {
>  	struct commit *base, *replayed_base;
> @@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,
>  	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
>  	pickme_tree = repo_get_commit_tree(repo, pickme);
>  
> -	if (mode == REPLAY_MODE_PICK) {
> +	if (reverse) {
> +		/* Revert: swap base and pickme to reverse the diff */
> +		const char *pickme_name = short_commit_name(repo, pickme);
> +		merge_opt->branch1 = short_commit_name(repo, replayed_base);
> +		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
> +		merge_opt->ancestor = pickme_name;
> +
> +		merge_incore_nonrecursive(merge_opt,
> +					  pickme_tree,
> +					  replayed_base_tree,
> +					  base_tree,
> +					  result);
> +
> +		free((char *)merge_opt->branch2);
> +	} else {
>  		/* Cherry-pick: normal order */
>  		merge_opt->branch1 = short_commit_name(repo, replayed_base);
>  		merge_opt->branch2 = short_commit_name(repo, pickme);
> @@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,
>  					  result);
>  
>  		free((char *)merge_opt->ancestor);
> -	} else if (mode == REPLAY_MODE_REVERT) {
> -		/* Revert: swap base and pickme to reverse the diff */
> -		const char *pickme_name = short_commit_name(repo, pickme);
> -		merge_opt->branch1 = short_commit_name(repo, replayed_base);
> -		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
> -		merge_opt->ancestor = pickme_name;
> -
> -		merge_incore_nonrecursive(merge_opt,
> -					  pickme_tree,
> -					  replayed_base_tree,
> -					  base_tree,
> -					  result);
> -
> -		free((char *)merge_opt->branch2);
> -	} else {
> -		BUG("unexpected replay mode %d", mode);
>  	}
>  	merge_opt->ancestor = NULL;
>  	merge_opt->branch2 = NULL;
> @@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
>  		}
>  	}
>  
> -	return create_commit(repo, result->tree, pickme, replayed_base, mode);
> +	return create_commit(repo, result->tree, pickme, replayed_base, reverse);
>  }
>  
>  void replay_result_release(struct replay_result *result)
> @@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,
>  	char *revert;
>  	const char *ref;
>  	struct object_id old_oid;
> -	enum replay_mode mode = REPLAY_MODE_PICK;
> +	bool reverse;
>  	int ret;
>  
>  	advance = xstrdup_or_null(opts->advance);
>  	revert = xstrdup_or_null(opts->revert);
> -	if (revert)
> -		mode = REPLAY_MODE_REVERT;
> +	reverse = !!revert;
> +
>  	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
>  			   &detached_head, &advance, &revert, &onto, &update_refs);
>  
> @@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,
>  			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, opts->empty);
> +						  reverse ? last_commit : onto,
> +						  &merge_opt, &result, reverse, opts->empty);
>  		if (!last_commit)
>  			break;
The patch itself looks trivially correct.
-Justin
Toon ClaesJun 12, 2026, 08:19 UTC in reply to Justin Tobler on lore

Re: [PATCH v2 1/3] replay: refactor enum replay_mode into a bool

Justin Tobler <jltobler@gmail.com> writes:
> Naive question: Do we expect there to only be two replay modes in the
> forseeable future? I suppose if other modes were added in the future
> this change would essentially be reverted.

The enum was mainly used to determine "direction": PICK to apply commits forward, and REVERT to apply them in opposite order. But it's a bit twofold, because REVERT also applies the inverse change. At the moment --onto and --advance use PICK and --revert uses REVERT. There could be added more options in the future, but I don't expect any of them to add a new mode. And if there is ever a new mode needed, I think it's better to re-add the enum then, or maybe a second bool makes sense then, who knows...

-- 
Cheers,
Toon
Elijah NewrenJun 14, 2026, 06:56 UTC in reply to Toon Claes on lore

Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology

Hi,
On Wed, Jun 10, 2026 at 7:51 AM Toon Claes <toon@iotcl.com> wrote:
Show 12 quoted lines
>
> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>
>
> One of the stated goals of git-replay(1) is to allow implementing the
> git-rebase(1) functionality on the server side.
>
> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
> was given. This mode drops merge commits instead of replaying them, and
> linearizes the commit history into a sequence of the
> regular (single-parent) commits.
>
> Add option `--linearize` to git-replay(1) to do the same.

I think this version is nicer overall than the one from my replay-upstream branch; sorry for repeatedly getting distracted from that, but this does look nice.

A few small comments:
Show 23 quoted lines
> Co-authored-by: Toon Claes <toon@iotcl.com>
> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> Signed-off-by: Toon Claes <toon@iotcl.com>
> ---
>  Documentation/git-replay.adoc |  5 +++++
>  builtin/replay.c              |  4 ++++
>  replay.c                      | 30 +++++++++++++++++++++++-------
>  replay.h                      |  5 +++++
>  t/t3650-replay-basics.sh      | 26 ++++++++++++++++++++++++++
>  5 files changed, 63 insertions(+), 7 deletions(-)
>
> diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
> index a32f72aead..41c96c7061 100644
> --- a/Documentation/git-replay.adoc
> +++ b/Documentation/git-replay.adoc
> @@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
>  +
>  The default mode can be configured via the `replay.refAction` configuration variable.
>
> +--linearize::
> +       In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
> +       i.e. it cherry-picks only non-merge commits, each one on top of the
> +       previous one.
The SYNOPSIS block at the top of the file is missing this new flag.
The replay_usage[] variable in cmd_replay is also missing this new flag.
Show 13 quoted lines
>  <revision-range>::
>         Range of commits to replay; see "Specifying Ranges" in
>         linkgit:git-rev-parse[1]. In `--advance=<branch>` or
> diff --git a/builtin/replay.c b/builtin/replay.c
> index 39e3a86f6c..fedfe46dc6 100644
> --- a/builtin/replay.c
> +++ b/builtin/replay.c
> @@ -111,6 +111,8 @@ int cmd_replay(int argc,
>                              N_("mode"),
>                              N_("control ref update behavior (update|print)"),
>                              PARSE_OPT_NONEG),
> +               OPT_BOOL(0, "linearize", &opts.linearize,
> +                        N_("ignore merge commits instead of replaying them")),

"ignore" feels a bit ambiguous to me. Can we use "drop" instead, matching your commit message?

Show 9 quoted lines
>                 OPT_END()
>         };
>
> @@ -132,6 +134,8 @@ int cmd_replay(int argc,
>                                   opts.contained, "--contained");
>         die_for_incompatible_opt2(!!opts.ref, "--ref",
>                                   !!opts.contained, "--contained");
> +       die_for_incompatible_opt2(!!opts.revert, "--revert",
> +                                 opts.linearize, "--linearize");

Sensible; should the docs mention this incompatibility? (I'm not sure myself; just throwing it out as food for thought.)

Show 22 quoted lines
>
>         /* Parse ref action mode from command line or config */
>         ref_mode = get_ref_action_mode(repo, ref_action);
> diff --git a/replay.c b/replay.c
> index 7921d7dba3..81033fb889 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
>                                           struct commit *onto,
>                                           struct merge_options *merge_opt,
>                                           struct merge_result *result,
> +                                         struct commit *replayed_base,
>                                           bool reverse,
>                                           enum replay_empty_commit_action empty)
>  {
> -       struct commit *base, *replayed_base;
> +       struct commit *base;
>         struct tree *pickme_tree, *base_tree, *replayed_base_tree;
>
> +       if (replayed_base && reverse)
> +               BUG("Linearizing commits is not supported when replaying in reverse");
> +

This is dead code given the die_for_incompatible_opt2 check above, right? Just extra defense in depth?

Show 26 quoted lines
>         if (pickme->parents) {
>                 base = pickme->parents->item;
>                 base_tree = repo_get_commit_tree(repo, base);
> @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
>                 base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
>         }
>
> -       replayed_base = get_mapped_commit(replayed_commits, base, onto);
> +       if (!replayed_base)
> +               replayed_base = get_mapped_commit(replayed_commits, base, onto);
>         replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
>         pickme_tree = repo_get_commit_tree(repo, pickme);
>
> @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs,
>         while ((commit = get_revision(revs))) {
>                 const struct name_decoration *decoration;
>
> -               if (commit->parents && commit->parents->next)
> -                       die(_("replaying merge commits is not supported yet!"));
> +               if (commit->parents && commit->parents->next) {
> +                       if (!opts->linearize)
> +                               die(_("replaying merge commits is not supported yet!"));
> +                       /*
> +                        * When linearizing, a merge commit itself is not picked,
> +                        * but refs that point to it might need updating.
> +                        */

Is it worth pointing out that last_commit is intentionally not updated by this code path? That is implied by your comment, but it takes a bit of reasoning to get there, and I think it might help future readers to just explicitly state it.

Show 58 quoted lines
> +               } else {
> +                       struct commit *to_pick = reverse ? last_commit : onto;
> +                       last_commit =
> +                               pick_regular_commit(revs->repo, commit,
> +                                                   replayed_commits, to_pick,
> +                                                   &merge_opt, &result,
> +                                                   opts->linearize ? last_commit : NULL,
> +                                                   reverse, opts->empty);
> +               }
>
> -               last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
> -                                                 reverse ? last_commit : onto,
> -                                                 &merge_opt, &result, reverse, opts->empty);
>                 if (!last_commit)
>                         break;
>
> diff --git a/replay.h b/replay.h
> index 1851a07705..07e6fdcca3 100644
> --- a/replay.h
> +++ b/replay.h
> @@ -62,6 +62,11 @@ struct replay_revisions_options {
>          * Defaults to REPLAY_EMPTY_COMMIT_DROP.
>          */
>         enum replay_empty_commit_action empty;
> +
> +       /*
> +        * Whether to linearize the commits (i.e. drop merge commits).
> +        */
> +       int linearize;
>  };
>
>  /* This struct is used as an out-parameter by `replay_revisions()`. */
> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
> index 3353bc4a4d..64e0731188 100755
> --- a/t/t3650-replay-basics.sh
> +++ b/t/t3650-replay-basics.sh
> @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
>         test_grep "cannot be used with multiple revision ranges" err
>  '
>
> +test_expect_success 'replay merge commit fails' '
> +       echo "fatal: replaying merge commits is not supported yet!" >expect &&
> +       test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&
> +       test_cmp expect actual
> +'
> +
> +test_expect_success 'replay to rebase merge commit with --linearize' '
> +       git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&
> +
> +       test_line_count = 1 result &&
> +
> +       git log --format=%s $(cut -f 3 -d " " result) >actual &&
> +       test_write_lines O N J M L B A >expect &&
> +       test_cmp expect actual
> +'
> +
> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '
> +       git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&

You'd need to drop "A.." to have it go down to the root commit, as Junio mentioned elsewhere.

Show 9 quoted lines
> +
> +       test_line_count = 1 result &&
> +
> +       git log --format=%s $(cut -f 3 -d " " result) >actual &&
> +       test_write_lines O N J I M L B A >expect &&
> +       test_cmp expect actual
> +'
> +
>  test_done
Should there also be a testcase combining --linearize and --advance?

Should there be a test with the incompatibility of --revert & --linearize? I think we have a few other tests for incompatible options.

One additional testing idea, borrowed from an older variant of this patch I had sitting in a local branch (dscho's original linearize patch, adapted): in addition to checking specific commit subjects, it's worth verifying that the linearized chain produces the *same patches* as the original. Something along the lines of:

        test_expect_success '--linearize preserves patches' '
                test_when_finished "git update-ref -d refs/heads/merge_I_L" &&
                test_tick &&
                git checkout -b merge_I_L I &&
                git merge --no-edit L &&
                git replay --linearize --onto A B..merge_I_L &&
                # range-diff ignores merges, so the original
                # {I, L, merge} reduces to {I, L} on the LHS,
                # and the replayed chain on the RHS should match.
                git range-diff B..merge_I_L@{1} B..merge_I_L >out &&
                ! test_grep -v "=" out &&
                git log --oneline A..merge_I_L >out &&
                test_line_count = 2 out
        '

The range-diff check is nice because it asserts patch equivalence rather than tying the test to a particular replay ordering, which makes the test less brittle if the rev-walk order ever changes. Feel free to take, adapt, or ignore.

Anyway, thanks for working on this; looking good.
Elijah
Toon ClaesJun 16, 2026, 07:09 UTC in reply to Elijah Newren on lore

Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology

Elijah Newren <newren@gmail.com> writes:
Show 19 quoted lines
> Hi,
>
> On Wed, Jun 10, 2026 at 7:51 AM Toon Claes <toon@iotcl.com> wrote:
>>
>> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>
>>
>> One of the stated goals of git-replay(1) is to allow implementing the
>> git-rebase(1) functionality on the server side.
>>
>> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
>> was given. This mode drops merge commits instead of replaying them, and
>> linearizes the commit history into a sequence of the
>> regular (single-parent) commits.
>>
>> Add option `--linearize` to git-replay(1) to do the same.
>
> I think this version is nicer overall than the one from my
> replay-upstream branch; sorry for repeatedly getting distracted from
> that, but this does look nice.

Dscho gets most of the credit here. And don't worry about being distracted, we know how things go around here. I appreciate the review!

Show 47 quoted lines
>
> A few small comments:
>
>> Co-authored-by: Toon Claes <toon@iotcl.com>
>> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
>> Signed-off-by: Toon Claes <toon@iotcl.com>
>> ---
>>  Documentation/git-replay.adoc |  5 +++++
>>  builtin/replay.c              |  4 ++++
>>  replay.c                      | 30 +++++++++++++++++++++++-------
>>  replay.h                      |  5 +++++
>>  t/t3650-replay-basics.sh      | 26 ++++++++++++++++++++++++++
>>  5 files changed, 63 insertions(+), 7 deletions(-)
>>
>> diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
>> index a32f72aead..41c96c7061 100644
>> --- a/Documentation/git-replay.adoc
>> +++ b/Documentation/git-replay.adoc
>> @@ -88,6 +88,11 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
>>  +
>>  The default mode can be configured via the `replay.refAction` configuration variable.
>>
>> +--linearize::
>> +       In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
>> +       i.e. it cherry-picks only non-merge commits, each one on top of the
>> +       previous one.
>
> The SYNOPSIS block at the top of the file is missing this new flag.
>
> The replay_usage[] variable in cmd_replay is also missing this new flag.
>
>>  <revision-range>::
>>         Range of commits to replay; see "Specifying Ranges" in
>>         linkgit:git-rev-parse[1]. In `--advance=<branch>` or
>> diff --git a/builtin/replay.c b/builtin/replay.c
>> index 39e3a86f6c..fedfe46dc6 100644
>> --- a/builtin/replay.c
>> +++ b/builtin/replay.c
>> @@ -111,6 +111,8 @@ int cmd_replay(int argc,
>>                              N_("mode"),
>>                              N_("control ref update behavior (update|print)"),
>>                              PARSE_OPT_NONEG),
>> +               OPT_BOOL(0, "linearize", &opts.linearize,
>> +                        N_("ignore merge commits instead of replaying them")),
>
> "ignore" feels a bit ambiguous to me.  Can we use "drop" instead,
> matching your commit message?
Agreed, I don't like it too. "drop" sounds better.
Show 12 quoted lines
>>                 OPT_END()
>>         };
>>
>> @@ -132,6 +134,8 @@ int cmd_replay(int argc,
>>                                   opts.contained, "--contained");
>>         die_for_incompatible_opt2(!!opts.ref, "--ref",
>>                                   !!opts.contained, "--contained");
>> +       die_for_incompatible_opt2(!!opts.revert, "--revert",
>> +                                 opts.linearize, "--linearize");
>
> Sensible; should the docs mention this incompatibility?  (I'm not sure
> myself; just throwing it out as food for thought.)
Let's add it.
Show 25 quoted lines
>>
>>         /* Parse ref action mode from command line or config */
>>         ref_mode = get_ref_action_mode(repo, ref_action);
>> diff --git a/replay.c b/replay.c
>> index 7921d7dba3..81033fb889 100644
>> --- a/replay.c
>> +++ b/replay.c
>> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
>>                                           struct commit *onto,
>>                                           struct merge_options *merge_opt,
>>                                           struct merge_result *result,
>> +                                         struct commit *replayed_base,
>>                                           bool reverse,
>>                                           enum replay_empty_commit_action empty)
>>  {
>> -       struct commit *base, *replayed_base;
>> +       struct commit *base;
>>         struct tree *pickme_tree, *base_tree, *replayed_base_tree;
>>
>> +       if (replayed_base && reverse)
>> +               BUG("Linearizing commits is not supported when replaying in reverse");
>> +
>
> This is dead code given the die_for_incompatible_opt2 check above,
> right?  Just extra defense in depth?
We also have another defense-in-depth for --onto/--advance/--revert:
    BUG("expected one of onto_name, *advance_name, or *revert_name");
I don't mind having it for --linearize too.
Show 31 quoted lines
>>         if (pickme->parents) {
>>                 base = pickme->parents->item;
>>                 base_tree = repo_get_commit_tree(repo, base);
>> @@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
>>                 base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
>>         }
>>
>> -       replayed_base = get_mapped_commit(replayed_commits, base, onto);
>> +       if (!replayed_base)
>> +               replayed_base = get_mapped_commit(replayed_commits, base, onto);
>>         replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
>>         pickme_tree = repo_get_commit_tree(repo, pickme);
>>
>> @@ -430,12 +435,23 @@ int replay_revisions(struct rev_info *revs,
>>         while ((commit = get_revision(revs))) {
>>                 const struct name_decoration *decoration;
>>
>> -               if (commit->parents && commit->parents->next)
>> -                       die(_("replaying merge commits is not supported yet!"));
>> +               if (commit->parents && commit->parents->next) {
>> +                       if (!opts->linearize)
>> +                               die(_("replaying merge commits is not supported yet!"));
>> +                       /*
>> +                        * When linearizing, a merge commit itself is not picked,
>> +                        * but refs that point to it might need updating.
>> +                        */
>
> Is it worth pointing out that last_commit is intentionally not updated
> by this code path?  That is implied by your comment, but it takes a
> bit of reasoning to get there, and I think it might help future
> readers to just explicitly state it.

Ah yes, I didn't realize that, but you make a good point. I'll rephrase the comment a bit.

Show 61 quoted lines
>> +               } else {
>> +                       struct commit *to_pick = reverse ? last_commit : onto;
>> +                       last_commit =
>> +                               pick_regular_commit(revs->repo, commit,
>> +                                                   replayed_commits, to_pick,
>> +                                                   &merge_opt, &result,
>> +                                                   opts->linearize ? last_commit : NULL,
>> +                                                   reverse, opts->empty);
>> +               }
>>
>> -               last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
>> -                                                 reverse ? last_commit : onto,
>> -                                                 &merge_opt, &result, reverse, opts->empty);
>>                 if (!last_commit)
>>                         break;
>>
>> diff --git a/replay.h b/replay.h
>> index 1851a07705..07e6fdcca3 100644
>> --- a/replay.h
>> +++ b/replay.h
>> @@ -62,6 +62,11 @@ struct replay_revisions_options {
>>          * Defaults to REPLAY_EMPTY_COMMIT_DROP.
>>          */
>>         enum replay_empty_commit_action empty;
>> +
>> +       /*
>> +        * Whether to linearize the commits (i.e. drop merge commits).
>> +        */
>> +       int linearize;
>>  };
>>
>>  /* This struct is used as an out-parameter by `replay_revisions()`. */
>> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
>> index 3353bc4a4d..64e0731188 100755
>> --- a/t/t3650-replay-basics.sh
>> +++ b/t/t3650-replay-basics.sh
>> @@ -565,4 +565,30 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
>>         test_grep "cannot be used with multiple revision ranges" err
>>  '
>>
>> +test_expect_success 'replay merge commit fails' '
>> +       echo "fatal: replaying merge commits is not supported yet!" >expect &&
>> +       test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&
>> +       test_cmp expect actual
>> +'
>> +
>> +test_expect_success 'replay to rebase merge commit with --linearize' '
>> +       git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&
>> +
>> +       test_line_count = 1 result &&
>> +
>> +       git log --format=%s $(cut -f 3 -d " " result) >actual &&
>> +       test_write_lines O N J M L B A >expect &&
>> +       test_cmp expect actual
>> +'
>> +
>> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '
>> +       git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&
>
> You'd need to drop "A.." to have it go down to the root commit, as
> Junio mentioned elsewhere.
Yes, thanks for double confirmation.
Show 11 quoted lines
>> +
>> +       test_line_count = 1 result &&
>> +
>> +       git log --format=%s $(cut -f 3 -d " " result) >actual &&
>> +       test_write_lines O N J I M L B A >expect &&
>> +       test_cmp expect actual
>> +'
>> +
>>  test_done
>
> Should there also be a testcase combining --linearize and --advance?
Sure.
> Should there be a test with the incompatibility of --revert &
> --linearize?  I think we have a few other tests for incompatible
> options.
I was already about to add that.
Show 28 quoted lines
> One additional testing idea, borrowed from an older variant of
> this patch I had sitting in a local branch (dscho's original
> linearize patch, adapted): in addition to checking specific commit
> subjects, it's worth verifying that the linearized chain produces
> the *same patches* as the original.  Something along the lines of:
>
>         test_expect_success '--linearize preserves patches' '
>                 test_when_finished "git update-ref -d refs/heads/merge_I_L" &&
>                 test_tick &&
>                 git checkout -b merge_I_L I &&
>                 git merge --no-edit L &&
>
>                 git replay --linearize --onto A B..merge_I_L &&
>
>                 # range-diff ignores merges, so the original
>                 # {I, L, merge} reduces to {I, L} on the LHS,
>                 # and the replayed chain on the RHS should match.
>                 git range-diff B..merge_I_L@{1} B..merge_I_L >out &&
>                 ! test_grep -v "=" out &&
>
>                 git log --oneline A..merge_I_L >out &&
>                 test_line_count = 2 out
>         '
>
> The range-diff check is nice because it asserts patch equivalence
> rather than tying the test to a particular replay ordering, which
> makes the test less brittle if the rev-walk order ever changes.
> Feel free to take, adapt, or ignore.
Interesting idea and I like it. Lemme add it.
> Anyway, thanks for working on this; looking good.
Thanks!
-- 
Cheers,
Toon
Toon ClaesJun 16, 2026, 08:38 UTC in reply to Junio C Hamano on lore

Re: [PATCH v2 3/3] replay: offer an option to linearize the commit topology

Junio C Hamano <gitster@pobox.com> writes:
Show 14 quoted lines
> In the review response during the previous iteration, I commented
> that (1) the original excluded only merges, but (2) your version
> excluded both merges and the root commits the same way.  Your
> response was:
>
>     The way it was written in v1 was maybe a bit too smart and hard to
>     follow. I agree with your suggestion and will adopt this (with some
>     tweaks) in the next version.
>
> which I took as saying "it may be confusing, but it correctly
> expresses what we want to do", meaning "yes, roots and merges should
> be handled the same way".  But the above no longer treats roots the
> same way as merges.  I think that is intended, but just wanted to
> double check.

Great callout. I was running the "replay down to root" test with v1 vs v2, but as you pointed out, the test I wrote doesn't actually replay down to root. Now I've fixed the test and reran the test against both versions and verified what you're saying.

So to answer your question, yes this change is intentional and shout out to you for requesting to add this test (properly) so we actually catch this.

Show 7 quoted lines
>> +test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '
>> +	git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&
>
> As with other test pieces, this "git replay" command line is overly
> long and hides the important bit which is that the range being
> replayed is *not* actually down to the root, which is A (it excludes
> A).  Intended?

No, not intended. And while at it, I'll split up the command on two lines.

-- 
Cheers,
Toon
Toon ClaesJun 16, 2026, 09:26 UTC in reply to Toon Claes on lore

[PATCH v3 0/3] Teach git-replay(1) to linearize merge commits

As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does too with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. This patch was kindly provided by Dscho, which I've tweaked to be upstreamed.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3].

dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>

Signed-off-by: Toon Claes <toon@iotcl.com>
---
Changes in v3:
- Add --linearize to Documentation SYNOPSIS, and mention it's
  incompatible with --revert.
- Small language change in help message for --linearize.
- Rephrase comment to include last_commit isn't modified when
  linearizing merges.
- Remove test that was added in earlier versions, but actually is
  a duplicate of 'replaying merge commits is not supported yet'.
- Add test to verify --revert and --linearize are incompatible.
- Properly test that replaying down to root with --linearize works.
- Add test for --linearize with --advance.
- Add test that uses git-range-diff(1) to verify the patches created by
  --linearize are correct.
- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com
Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
---
Johannes Schindelin (1):
      replay: offer an option to linearize the commit topology
Toon Claes (2):
      replay: refactor enum replay_mode into a bool
      replay: add helper to put entry into mapped_commits
 Documentation/git-replay.adoc |   8 ++-
 builtin/replay.c              |   6 ++-
 replay.c                      | 116 ++++++++++++++++++++++++------------------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      |  68 ++++++++++++++++++++++++-
 5 files changed, 151 insertions(+), 52 deletions(-)
Range-diff versus v2:
1:  2075988ef1 = 1:  542b1c9267 replay: refactor enum replay_mode into a bool
2:  93ff03be65 = 2:  62f6df8375 replay: add helper to put entry into mapped_commits
3:  ef56010c96 ! 3:  768646ee24 replay: offer an option to linearize the commit topology
    @@ Commit message
         Signed-off-by: Toon Claes <toon@iotcl.com>
     
      ## Documentation/git-replay.adoc ##
    +@@ Documentation/git-replay.adoc: SYNOPSIS
    + --------
    + [verse]
    + (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
    +-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
    ++			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
    + 
    + DESCRIPTION
    + -----------
     @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).
      +
      The default mode can be configured via the `replay.refAction` configuration variable.
    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif
     +	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
     +	i.e. it cherry-picks only non-merge commits, each one on top of the
     +	previous one.
    ++	This option is incompatible with `--revert`.
     +
      <revision-range>::
      	Range of commits to replay; see "Specifying Ranges" in
      	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
     
      ## builtin/replay.c ##
    +@@ builtin/replay.c: int cmd_replay(int argc,
    + 	const char *const replay_usage[] = {
    + 		N_("(EXPERIMENTAL!) git replay "
    + 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
    +-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
    ++		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
    + 		NULL
    + 	};
    + 	struct option replay_options[] = {
     @@ builtin/replay.c: int cmd_replay(int argc,
      			     N_("mode"),
      			     N_("control ref update behavior (update|print)"),
      			     PARSE_OPT_NONEG),
     +		OPT_BOOL(0, "linearize", &opts.linearize,
    -+			 N_("ignore merge commits instead of replaying them")),
    ++			 N_("drop merge commits, replaying only non-merge commits")),
      		OPT_END()
      	};
      
    @@ replay.c: int replay_revisions(struct rev_info *revs,
     +			if (!opts->linearize)
     +				die(_("replaying merge commits is not supported yet!"));
     +			/*
    -+			 * When linearizing, a merge commit itself is not picked,
    -+			 * but refs that point to it might need updating.
    ++			 * Drop the merge commit: do not pick it and leave
    ++			 * last_commit unchanged, so its children (and any ref
    ++			 * pointing at it) are reparented onto the previous
    ++			 * non-merge commit, which the ref-update loop below uses.
     +			 */
     +		} else {
     +			struct commit *to_pick = reverse ? last_commit : onto;
    @@ replay.h: struct replay_revisions_options {
      /* This struct is used as an out-parameter by `replay_revisions()`. */
     
      ## t/t3650-replay-basics.sh ##
    -@@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' '
    - 	test_grep "cannot be used with multiple revision ranges" err
    +@@ t/t3650-replay-basics.sh: test_expect_success 'setup' '
    + 	test_merge P O --no-ff &&
    + 	git switch main &&
    + 
    ++	git switch --orphan unrelated &&
    ++	test_commit unrelated-root &&
    ++
    + 	git switch -c conflict B &&
    +-	test_commit C.conflict C.t conflict
    ++	test_commit C.conflict C.t conflict &&
    ++	git branch -D unrelated
      '
      
    -+test_expect_success 'replay merge commit fails' '
    -+	echo "fatal: replaying merge commits is not supported yet!" >expect &&
    -+	test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&
    -+	test_cmp expect actual
    + test_expect_success 'setup bare' '
    +@@ t/t3650-replay-basics.sh: test_expect_success '--advance and --contained cannot be used together' '
    + 	test_grep "cannot be used together" actual
    + '
    + 
    ++test_expect_success '--revert and --linearize cannot be used together' '
    ++	test_must_fail git replay --revert=main --linearize \
    ++		topic1..topic2 2>actual &&
    ++	test_grep "cannot be used together" actual
     +'
     +
    + test_expect_success 'cannot advance target ... ordering would be ill-defined' '
    + 	echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
    + 	test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
    +@@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' '
    + 	test_grep "cannot be used with multiple revision ranges" err
    + '
    + 
     +test_expect_success 'replay to rebase merge commit with --linearize' '
    -+	git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&
    ++	git replay --ref-action=print --linearize \
    ++		--onto main I..topic-with-merge >result &&
     +
     +	test_line_count = 1 result &&
     +
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	test_cmp expect actual
     +'
     +
    -+test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '
    -+	git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&
    ++test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
    ++	git replay --ref-action=print --linearize \
    ++		--onto unrelated-root topic-with-merge >result &&
     +
     +	test_line_count = 1 result &&
     +
     +	git log --format=%s $(cut -f 3 -d " " result) >actual &&
    -+	test_write_lines O N J I M L B A >expect &&
    ++	test_write_lines O N J I B A unrelated-root >expect &&
     +	test_cmp expect actual
     +'
    ++
    ++test_expect_success 'replay to cherry-pick merge commit with --linearize' '
    ++	git replay --ref-action=print --linearize \
    ++		--advance main I..topic-with-merge >result &&
    ++
    ++	test_line_count = 1 result &&
    ++
    ++	git log --format=%s $(cut -f 3 -d " " result) >actual &&
    ++	test_write_lines O N J M L B A >expect &&
    ++	test_cmp expect actual &&
    ++
    ++	printf "update refs/heads/main " >expect &&
    ++	printf "%s " $(cut -f 3 -d " " result) >>expect &&
    ++	git rev-parse main >>expect &&
    ++	test_cmp expect result
    ++'
    ++
    ++test_expect_success 'replay --linearize produces the same patches' '
    ++	git replay --ref-action=print --linearize \
    ++		--onto main I..topic-with-merge >result &&
    ++
    ++	test_line_count = 1 result &&
    ++	tip=$(cut -f 3 -d " " result) &&
    ++
    ++	# range-diff does not care about the dropped merge,
    ++	# so the original commits (I..topic-with-merge)
    ++	# and the replayed chain (main..tip) must produce identical patches.
    ++	git range-diff I..topic-with-merge main..$tip >out &&
    ++	test_file_not_empty out &&
    ++	! grep -v "=" out &&
    ++
    ++	git log --oneline main..$tip >out &&
    ++	test_line_count = 3 out
    ++'
     +
      test_done

--- base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesJun 16, 2026, 09:26 UTC in reply to Toon Claes on lore

[PATCH v3 1/3] replay: refactor enum replay_mode into a bool

In 2760ee4983 (replay: add --revert mode to reverse commit changes, 2026-03-26) the enum `replay_mode` was introduced. This has two possible values:

 - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
   passed to git-replay(1). When using this value the commits are
   processed in reverse order and the inverse of the changes are
   applied.
 - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
   `--advance` is used. In both cases the commits are processed in
   normal order, and the changes are applied as-is.

Since there are only two possible values of this enum, simplify the code by converting the enum into a bool. This avoids adding code paths that check for invalid values of the enum, and shortens code where the value is checked with a ternary operator.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 59 +++++++++++++++++++++++++----------------------------------
 1 file changed, 25 insertions(+), 34 deletions(-)
Show changes to replay.c +25 −34
diff --git a/replay.c b/replay.c
index 4ef8abb607..1f8e5b083b 100644
--- a/replay.c
+++ b/replay.c
@@ -18,11 +18,6 @@
  */
 #define the_repository DO_NOT_USE_THE_REPOSITORY
 
-enum replay_mode {
-	REPLAY_MODE_PICK,
-	REPLAY_MODE_REVERT,
-};
-
 static const char *short_commit_name(struct repository *repo,
 				     struct commit *commit)
 {
@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,
 				    struct tree *tree,
 				    struct commit *based_on,
 				    struct commit *parent,
-				    enum replay_mode mode)
+				    bool reverse)
 {
 	struct object_id ret;
 	struct object *obj = NULL;
@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,
 
 	commit_list_insert(parent, &parents);
 	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	if (mode == REPLAY_MODE_REVERT) {
+	if (reverse) {
 		generate_revert_message(&msg, based_on, repo);
 		/* For revert, use current user as author (NULL = use default) */
-	} else if (mode == REPLAY_MODE_PICK) {
+	} else {
 		find_commit_subject(message, &orig_message);
 		strbuf_addstr(&msg, orig_message);
 		author = get_author(message);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	reset_ident_date();
 	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
-					  enum replay_mode mode,
+					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
 	struct commit *base, *replayed_base;
@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
-	if (mode == REPLAY_MODE_PICK) {
+	if (reverse) {
+		/* Revert: swap base and pickme to reverse the diff */
+		const char *pickme_name = short_commit_name(repo, pickme);
+		merge_opt->branch1 = short_commit_name(repo, replayed_base);
+		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
+		merge_opt->ancestor = pickme_name;
+
+		merge_incore_nonrecursive(merge_opt,
+					  pickme_tree,
+					  replayed_base_tree,
+					  base_tree,
+					  result);
+
+		free((char *)merge_opt->branch2);
+	} else {
 		/* Cherry-pick: normal order */
 		merge_opt->branch1 = short_commit_name(repo, replayed_base);
 		merge_opt->branch2 = short_commit_name(repo, pickme);
@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  result);
 
 		free((char *)merge_opt->ancestor);
-	} else if (mode == REPLAY_MODE_REVERT) {
-		/* Revert: swap base and pickme to reverse the diff */
-		const char *pickme_name = short_commit_name(repo, pickme);
-		merge_opt->branch1 = short_commit_name(repo, replayed_base);
-		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
-		merge_opt->ancestor = pickme_name;
-
-		merge_incore_nonrecursive(merge_opt,
-					  pickme_tree,
-					  replayed_base_tree,
-					  base_tree,
-					  result);
-
-		free((char *)merge_opt->branch2);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	merge_opt->ancestor = NULL;
 	merge_opt->branch2 = NULL;
@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		}
 	}
 
-	return create_commit(repo, result->tree, pickme, replayed_base, mode);
+	return create_commit(repo, result->tree, pickme, replayed_base, reverse);
 }
 
 void replay_result_release(struct replay_result *result)
@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,
 	char *revert;
 	const char *ref;
 	struct object_id old_oid;
-	enum replay_mode mode = REPLAY_MODE_PICK;
+	bool reverse;
 	int ret;
 
 	advance = xstrdup_or_null(opts->advance);
 	revert = xstrdup_or_null(opts->revert);
-	if (revert)
-		mode = REPLAY_MODE_REVERT;
+	reverse = !!revert;
+
 	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
 			   &detached_head, &advance, &revert, &onto, &update_refs);
 
@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,
 			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, opts->empty);
+						  reverse ? last_commit : onto,
+						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 16, 2026, 09:26 UTC in reply to Toon Claes on lore

[PATCH v3 2/3] replay: add helper to put entry into mapped_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index 1f8e5b083b..7921d7dba3 100644
--- a/replay.c
+++ b/replay.c
@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s\n",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 16, 2026, 09:26 UTC in reply to Toon Claes on lore

[PATCH v3 3/3] replay: offer an option to linearize the commit topology

From: Johannes Schindelin <Johannes.Schindelin@gmx.de>

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the commit history into a sequence of the regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same.
Co-authored-by: Toon Claes <toon@iotcl.com>
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  8 ++++-
 builtin/replay.c              |  6 +++-
 replay.c                      | 32 +++++++++++++++-----
 replay.h                      |  5 ++++
 t/t3650-replay-basics.sh      | 68 ++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 109 insertions(+), 10 deletions(-)
Show changes to 5 files +109 −10

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..ef56ee0f1b 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 --------
 [verse]
 (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
+			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
 
 DESCRIPTION
 -----------
@@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
+	i.e. it cherry-picks only non-merge commits, each one on top of the
+	previous one.
+	This option is incompatible with `--revert`.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..62962c73c7 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -85,7 +85,7 @@ int cmd_replay(int argc,
 	const char *const replay_usage[] = {
 		N_("(EXPERIMENTAL!) git replay "
 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
+		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
 		NULL
 	};
 	struct option replay_options[] = {
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("drop merge commits, replaying only non-merge commits")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.revert, "--revert",
+				  opts.linearize, "--linearize");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 7921d7dba3..5539daff00 100644
--- a/replay.c
+++ b/replay.c
@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
+					  struct commit *replayed_base,
 					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
+	struct commit *base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
+	if (replayed_base && reverse)
+		BUG("Linearizing commits is not supported when replaying in reverse");
+
 	if (pickme->parents) {
 		base = pickme->parents->item;
 		base_tree = repo_get_commit_tree(repo, base);
@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
+	if (!replayed_base)
+		replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * Drop the merge commit: do not pick it and leave
+			 * last_commit unchanged, so its children (and any ref
+			 * pointing at it) are reparented onto the previous
+			 * non-merge commit, which the ref-update loop below uses.
+			 */
+		} else {
+			struct commit *to_pick = reverse ? last_commit : onto;
+			last_commit =
+				pick_regular_commit(revs->repo, commit,
+						    replayed_commits, to_pick,
+						    &merge_opt, &result,
+						    opts->linearize ? last_commit : NULL,
+						    reverse, opts->empty);
+		}
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  reverse ? last_commit : onto,
-						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index 1851a07705..07e6fdcca3 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..1874d06769 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -52,8 +52,12 @@ test_expect_success 'setup' '
 	test_merge P O --no-ff &&
 	git switch main &&
 
+	git switch --orphan unrelated &&
+	test_commit unrelated-root &&
+
 	git switch -c conflict B &&
-	test_commit C.conflict C.t conflict
+	test_commit C.conflict C.t conflict &&
+	git branch -D unrelated
 '
 
 test_expect_success 'setup bare' '
@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '
 	test_grep "cannot be used together" actual
 '
 
+test_expect_success '--revert and --linearize cannot be used together' '
+	test_must_fail git replay --revert=main --linearize \
+		topic1..topic2 2>actual &&
+	test_grep "cannot be used together" actual
+'
+
 test_expect_success 'cannot advance target ... ordering would be ill-defined' '
 	echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
 	test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
@@ -565,4 +575,60 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
+	git replay --ref-action=print --linearize \
+		--onto unrelated-root topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I B A unrelated-root >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to cherry-pick merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--advance main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual &&
+
+	printf "update refs/heads/main " >expect &&
+	printf "%s " $(cut -f 3 -d " " result) >>expect &&
+	git rev-parse main >>expect &&
+	test_cmp expect result
+'
+
+test_expect_success 'replay --linearize produces the same patches' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# range-diff does not care about the dropped merge,
+	# so the original commits (I..topic-with-merge)
+	# and the replayed chain (main..tip) must produce identical patches.
+	git range-diff I..topic-with-merge main..$tip >out &&
+	test_file_not_empty out &&
+	! grep -v "=" out &&
+
+	git log --oneline main..$tip >out &&
+	test_line_count = 3 out
+'
+
 test_done
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 22, 2026, 12:41 UTC in reply to Toon Claes on lore

[PATCH v4 0/3] Teach git-replay(1) to linearize merge commits

As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does too with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. This patch was kindly provided by Dscho, which I've tweaked to be upstreamed.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3].

dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>

---
Changes in v4:
- Use test_grep instead of a bare grep in the range-diff test, to
  prepare for mm/test-grep-lint.
- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com
Changes in v3:
- Add --linearize to Documentation SYNOPSIS, and mention it's
  incompatible with --revert.
- Small language change in help message for --linearize.
- Rephrase comment to include last_commit isn't modified when
  linearizing merges.
- Remove test that was added in earlier versions, but actually is
  a duplicate of 'replaying merge commits is not supported yet'.
- Add test to verify --revert and --linearize are incompatible.
- Properly test that replaying down to root with --linearize works.
- Add test for --linearize with --advance.
- Add test that uses git-range-diff(1) to verify the patches created by
  --linearize are correct.
- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com
Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
---
Johannes Schindelin (1):
      replay: offer an option to linearize the commit topology
Toon Claes (2):
      replay: refactor enum replay_mode into a bool
      replay: add helper to put entry into mapped_commits
 Documentation/git-replay.adoc |   8 ++-
 builtin/replay.c              |   6 ++-
 replay.c                      | 116 ++++++++++++++++++++++++------------------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      |  68 ++++++++++++++++++++++++-
 5 files changed, 151 insertions(+), 52 deletions(-)
Range-diff versus v3:
1:  759fa1b52c = 1:  0f0e50c67f replay: refactor enum replay_mode into a bool
2:  68dd5ad77c = 2:  919a6495ee replay: add helper to put entry into mapped_commits
3:  f99aeb3887 ! 3:  bb03e78210 replay: offer an option to linearize the commit topology
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	# and the replayed chain (main..tip) must produce identical patches.
     +	git range-diff I..topic-with-merge main..$tip >out &&
     +	test_file_not_empty out &&
    -+	! grep -v "=" out &&
    ++	test_grep ! -v "=" out &&
     +
     +	git log --oneline main..$tip >out &&
     +	test_line_count = 3 out

--- base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesJun 22, 2026, 12:41 UTC in reply to Toon Claes on lore

[PATCH v4 1/3] replay: refactor enum replay_mode into a bool

In 2760ee4983 (replay: add --revert mode to reverse commit changes, 2026-03-26) the enum `replay_mode` was introduced. This has two possible values:

 - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
   passed to git-replay(1). When using this value the commits are
   processed in reverse order and the inverse of the changes are
   applied.
 - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
   `--advance` is used. In both cases the commits are processed in
   normal order, and the changes are applied as-is.

Since there are only two possible values of this enum, simplify the code by converting the enum into a bool. This avoids adding code paths that check for invalid values of the enum, and shortens code where the value is checked with a ternary operator.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 59 +++++++++++++++++++++++++----------------------------------
 1 file changed, 25 insertions(+), 34 deletions(-)
Show changes to replay.c +25 −34
diff --git a/replay.c b/replay.c
index 4ef8abb607..1f8e5b083b 100644
--- a/replay.c
+++ b/replay.c
@@ -18,11 +18,6 @@
  */
 #define the_repository DO_NOT_USE_THE_REPOSITORY
 
-enum replay_mode {
-	REPLAY_MODE_PICK,
-	REPLAY_MODE_REVERT,
-};
-
 static const char *short_commit_name(struct repository *repo,
 				     struct commit *commit)
 {
@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,
 				    struct tree *tree,
 				    struct commit *based_on,
 				    struct commit *parent,
-				    enum replay_mode mode)
+				    bool reverse)
 {
 	struct object_id ret;
 	struct object *obj = NULL;
@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,
 
 	commit_list_insert(parent, &parents);
 	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	if (mode == REPLAY_MODE_REVERT) {
+	if (reverse) {
 		generate_revert_message(&msg, based_on, repo);
 		/* For revert, use current user as author (NULL = use default) */
-	} else if (mode == REPLAY_MODE_PICK) {
+	} else {
 		find_commit_subject(message, &orig_message);
 		strbuf_addstr(&msg, orig_message);
 		author = get_author(message);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	reset_ident_date();
 	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
-					  enum replay_mode mode,
+					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
 	struct commit *base, *replayed_base;
@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
-	if (mode == REPLAY_MODE_PICK) {
+	if (reverse) {
+		/* Revert: swap base and pickme to reverse the diff */
+		const char *pickme_name = short_commit_name(repo, pickme);
+		merge_opt->branch1 = short_commit_name(repo, replayed_base);
+		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
+		merge_opt->ancestor = pickme_name;
+
+		merge_incore_nonrecursive(merge_opt,
+					  pickme_tree,
+					  replayed_base_tree,
+					  base_tree,
+					  result);
+
+		free((char *)merge_opt->branch2);
+	} else {
 		/* Cherry-pick: normal order */
 		merge_opt->branch1 = short_commit_name(repo, replayed_base);
 		merge_opt->branch2 = short_commit_name(repo, pickme);
@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  result);
 
 		free((char *)merge_opt->ancestor);
-	} else if (mode == REPLAY_MODE_REVERT) {
-		/* Revert: swap base and pickme to reverse the diff */
-		const char *pickme_name = short_commit_name(repo, pickme);
-		merge_opt->branch1 = short_commit_name(repo, replayed_base);
-		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
-		merge_opt->ancestor = pickme_name;
-
-		merge_incore_nonrecursive(merge_opt,
-					  pickme_tree,
-					  replayed_base_tree,
-					  base_tree,
-					  result);
-
-		free((char *)merge_opt->branch2);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	merge_opt->ancestor = NULL;
 	merge_opt->branch2 = NULL;
@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		}
 	}
 
-	return create_commit(repo, result->tree, pickme, replayed_base, mode);
+	return create_commit(repo, result->tree, pickme, replayed_base, reverse);
 }
 
 void replay_result_release(struct replay_result *result)
@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,
 	char *revert;
 	const char *ref;
 	struct object_id old_oid;
-	enum replay_mode mode = REPLAY_MODE_PICK;
+	bool reverse;
 	int ret;
 
 	advance = xstrdup_or_null(opts->advance);
 	revert = xstrdup_or_null(opts->revert);
-	if (revert)
-		mode = REPLAY_MODE_REVERT;
+	reverse = !!revert;
+
 	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
 			   &detached_head, &advance, &revert, &onto, &update_refs);
 
@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,
 			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, opts->empty);
+						  reverse ? last_commit : onto,
+						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 22, 2026, 12:41 UTC in reply to Toon Claes on lore

[PATCH v4 2/3] replay: add helper to put entry into mapped_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index 1f8e5b083b..7921d7dba3 100644
--- a/replay.c
+++ b/replay.c
@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s\n",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 22, 2026, 12:41 UTC in reply to Toon Claes on lore

[PATCH v4 3/3] replay: offer an option to linearize the commit topology

From: Johannes Schindelin <Johannes.Schindelin@gmx.de>

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the commit history into a sequence of the regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same.
Co-authored-by: Toon Claes <toon@iotcl.com>
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  8 ++++-
 builtin/replay.c              |  6 +++-
 replay.c                      | 32 +++++++++++++++-----
 replay.h                      |  5 ++++
 t/t3650-replay-basics.sh      | 68 ++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 109 insertions(+), 10 deletions(-)
Show changes to 5 files +109 −10

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..ef56ee0f1b 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 --------
 [verse]
 (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
+			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
 
 DESCRIPTION
 -----------
@@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
+	i.e. it cherry-picks only non-merge commits, each one on top of the
+	previous one.
+	This option is incompatible with `--revert`.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..62962c73c7 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -85,7 +85,7 @@ int cmd_replay(int argc,
 	const char *const replay_usage[] = {
 		N_("(EXPERIMENTAL!) git replay "
 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
+		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
 		NULL
 	};
 	struct option replay_options[] = {
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("drop merge commits, replaying only non-merge commits")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.revert, "--revert",
+				  opts.linearize, "--linearize");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 7921d7dba3..5539daff00 100644
--- a/replay.c
+++ b/replay.c
@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
+					  struct commit *replayed_base,
 					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
+	struct commit *base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
+	if (replayed_base && reverse)
+		BUG("Linearizing commits is not supported when replaying in reverse");
+
 	if (pickme->parents) {
 		base = pickme->parents->item;
 		base_tree = repo_get_commit_tree(repo, base);
@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
+	if (!replayed_base)
+		replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * Drop the merge commit: do not pick it and leave
+			 * last_commit unchanged, so its children (and any ref
+			 * pointing at it) are reparented onto the previous
+			 * non-merge commit, which the ref-update loop below uses.
+			 */
+		} else {
+			struct commit *to_pick = reverse ? last_commit : onto;
+			last_commit =
+				pick_regular_commit(revs->repo, commit,
+						    replayed_commits, to_pick,
+						    &merge_opt, &result,
+						    opts->linearize ? last_commit : NULL,
+						    reverse, opts->empty);
+		}
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  reverse ? last_commit : onto,
-						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index 1851a07705..07e6fdcca3 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..b9ce6c4868 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -52,8 +52,12 @@ test_expect_success 'setup' '
 	test_merge P O --no-ff &&
 	git switch main &&
 
+	git switch --orphan unrelated &&
+	test_commit unrelated-root &&
+
 	git switch -c conflict B &&
-	test_commit C.conflict C.t conflict
+	test_commit C.conflict C.t conflict &&
+	git branch -D unrelated
 '
 
 test_expect_success 'setup bare' '
@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '
 	test_grep "cannot be used together" actual
 '
 
+test_expect_success '--revert and --linearize cannot be used together' '
+	test_must_fail git replay --revert=main --linearize \
+		topic1..topic2 2>actual &&
+	test_grep "cannot be used together" actual
+'
+
 test_expect_success 'cannot advance target ... ordering would be ill-defined' '
 	echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
 	test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
@@ -565,4 +575,60 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
+	git replay --ref-action=print --linearize \
+		--onto unrelated-root topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I B A unrelated-root >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to cherry-pick merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--advance main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual &&
+
+	printf "update refs/heads/main " >expect &&
+	printf "%s " $(cut -f 3 -d " " result) >>expect &&
+	git rev-parse main >>expect &&
+	test_cmp expect result
+'
+
+test_expect_success 'replay --linearize produces the same patches' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# range-diff does not care about the dropped merge,
+	# so the original commits (I..topic-with-merge)
+	# and the replayed chain (main..tip) must produce identical patches.
+	git range-diff I..topic-with-merge main..$tip >out &&
+	test_file_not_empty out &&
+	test_grep ! -v "=" out &&
+
+	git log --oneline main..$tip >out &&
+	test_line_count = 3 out
+'
+
 test_done
-- 
2.53.0.1323.g189a785ab5
Patrick SteinhardtJun 22, 2026, 13:53 UTC in reply to Toon Claes on lore

Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool

On Mon, Jun 22, 2026 at 02:41:55PM +0200, Toon Claes wrote:
Show 17 quoted lines
> In 2760ee4983 (replay: add --revert mode to reverse commit changes,
> 2026-03-26) the enum `replay_mode` was introduced. This has two possible
> values:
> 
>  - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
>    passed to git-replay(1). When using this value the commits are
>    processed in reverse order and the inverse of the changes are
>    applied.
> 
>  - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
>    `--advance` is used. In both cases the commits are processed in
>    normal order, and the changes are applied as-is.
> 
> Since there are only two possible values of this enum, simplify the code
> by converting the enum into a bool. This avoids adding code paths that
> check for invalid values of the enum, and shortens code where the value
> is checked with a ternary operator.

That's fair, and the result is easier to write. But is it really easier to read? And what if we ever have to create a third mode going forward?

I'm generally no fan of booleans as parameters as they basically give you no information at all at the callsite, except if you're lucky and you already have an aptly-named variable available that you can pass. Which seems to be the case here, but I'm still not sure whether this change really improves the code.

Patrick
Patrick SteinhardtJun 22, 2026, 13:53 UTC in reply to Toon Claes on lore

Re: [PATCH v4 2/3] replay: add helper to put entry into mapped_commits

On Mon, Jun 22, 2026 at 02:41:56PM +0200, Toon Claes wrote:
Show 22 quoted lines
> diff --git a/replay.c b/replay.c
> index 1f8e5b083b..7921d7dba3 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
>  	return kh_value(replayed_commits, pos);
>  }
>  
> +static void put_mapped_commit(kh_oid_map_t *replayed_commits,
> +			      struct commit *commit,
> +			      struct commit *new_commit)
> +{
> +	khint_t pos;
> +	int ret;
> +
> +	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
> +	if (ret == 0)
> +		BUG("Duplicate rewritten commit: %s\n",
> +		    oid_to_hex(&commit->object.oid));
> +
> +	kh_value(replayed_commits, pos) = new_commit;
> +}

The khash map interfaces are quite awkward to use, so having a small wrapper feels sensible to me. It is one of those interfaces that really make you wish for generics in C.

Patrick
Patrick SteinhardtJun 22, 2026, 13:53 UTC in reply to Toon Claes on lore

Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology

On Mon, Jun 22, 2026 at 02:41:57PM +0200, Toon Claes wrote:
Show 11 quoted lines
> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>
> 
> One of the stated goals of git-replay(1) is to allow implementing the
> git-rebase(1) functionality on the server side.
> 
> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
> was given. This mode drops merge commits instead of replaying them, and
> linearizes the commit history into a sequence of the
> regular (single-parent) commits.
> 
> Add option `--linearize` to git-replay(1) to do the same.
git-rebase(1) essentially knows about three different modes:
  - "--no-rebase-merges", which is the default and maps to your
    "--linearize".
  - "--rebase-merges", which by default doesn't rebase cousins by using
    "--ancestry-path" internally.
  - "--rebase-merges=rebase-cousins", which doesn't pass the above
    option.

So it's not a simple boolean there, which makes me wonder whether we should mirror the same interface so that all of git-rebase(1)'s modes can be represented, as well.

Show 18 quoted lines
> diff --git a/replay.c b/replay.c
> index 7921d7dba3..5539daff00 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
>  					  struct commit *onto,
>  					  struct merge_options *merge_opt,
>  					  struct merge_result *result,
> +					  struct commit *replayed_base,
>  					  bool reverse,
>  					  enum replay_empty_commit_action empty)
>  {
> -	struct commit *base, *replayed_base;
> +	struct commit *base;
>  	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
>  
> +	if (replayed_base && reverse)
> +		BUG("Linearizing commits is not supported when replaying in reverse");
Nit: Error messages should typically start with a lower-case letter.
Show 15 quoted lines
> @@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,
>  	while ((commit = get_revision(revs))) {
>  		const struct name_decoration *decoration;
>  
> -		if (commit->parents && commit->parents->next)
> -			die(_("replaying merge commits is not supported yet!"));
> +		if (commit->parents && commit->parents->next) {
> +			if (!opts->linearize)
> +				die(_("replaying merge commits is not supported yet!"));
> +			/*
> +			 * Drop the merge commit: do not pick it and leave
> +			 * last_commit unchanged, so its children (and any ref
> +			 * pointing at it) are reparented onto the previous
> +			 * non-merge commit, which the ref-update loop below uses.
> +			 */

One could add a hint here that tells the user to pass the option. But I guess that might be somewhat weird, as we cannot assume that we're called by git-replay(1) here.

In any case, this here is the core of the change where we stop dying in case "--linearize" was passed, and instead we simply skip the commit altogether. Makes sense.

Thanks!
Patrick
Junio C HamanoJun 22, 2026, 15:43 UTC in reply to Patrick Steinhardt on lore

Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool

Patrick Steinhardt <ps@pks.im> writes:
Show 27 quoted lines
> On Mon, Jun 22, 2026 at 02:41:55PM +0200, Toon Claes wrote:
>> In 2760ee4983 (replay: add --revert mode to reverse commit changes,
>> 2026-03-26) the enum `replay_mode` was introduced. This has two possible
>> values:
>> 
>>  - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
>>    passed to git-replay(1). When using this value the commits are
>>    processed in reverse order and the inverse of the changes are
>>    applied.
>> 
>>  - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
>>    `--advance` is used. In both cases the commits are processed in
>>    normal order, and the changes are applied as-is.
>> 
>> Since there are only two possible values of this enum, simplify the code
>> by converting the enum into a bool. This avoids adding code paths that
>> check for invalid values of the enum, and shortens code where the value
>> is checked with a ternary operator.
>
> That's fair, and the result is easier to write. But is it really easier
> to read? And what if we ever have to create a third mode going forward?
>
> I'm generally no fan of booleans as parameters as they basically give
> you no information at all at the callsite, except if you're lucky and
> you already have an aptly-named variable available that you can pass.
> Which seems to be the case here, but I'm still not sure whether this
> change really improves the code.

I tend to agree with you on both counts. The "what happens when somebody else wants a third choice?" is a quesiton I would ask the first thing as the maintainer of a project.

Even if the boolean parameter is so obviously named, the callsite can only say "true" or "false", unlike some other popular languages that lets you say

	my_function(use_revert_mode=true, verbose=false);

and you cannot tell what effect the author wanted out of that "true" if all you can write were

	my_function(true, false);
Of course, we could go ultra verbose, like
	my_function(true, /* use_revert_mode */
		    false, /* verbose */);
but then we are often better off writing:
	my_function(REPLAY_MODE_REVERT, REPLAY_QUIET);
Thanks.
Toon ClaesJun 24, 2026, 19:15 UTC in reply to Junio C Hamano on lore

Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool

Junio C Hamano <gitster@pobox.com> writes:
> Patrick Steinhardt <ps@pks.im> writes:
>
>> That's fair, and the result is easier to write. But is it really easier
>> to read?

You're bringing up a very valid point there, and not directly in what you're saying, but how it makes me reconsider.

So we're comparing:
    pick_regular_commit(revs->repo, commit, replayed_commits,
                        mode == REPLAY_MODE_REVERT ? last_commit : onto,
                        &merge_opt, &result, mode, opts->empty);
with:
    pick_regular_commit(revs->repo, commit, replayed_commits,
                        reverse ? last_commit : onto,
                        &merge_opt, &result, reverse, opts->empty);

You can argue which of both is easier to read, but the problem isn't really whether it's a bool or an enum, but the ternary operator in this lengthy function call is. That is the problem I was trying to solve, and converting enum to bool isn't really the solution.

>> And what if we ever have to create a third mode going forward?

Personally I find this weak argument. As far as I know we most of the time do not write code in a way so "it will be ready to add X in the future". In my personal experience, I'm always wrong in predicting what might be added in the future. Although I must say this case is different, because we're not adding something new, no this commit was dumbing down something existing. So I'll revisit this commit in the next iteration.

Show 5 quoted lines
>> I'm generally no fan of booleans as parameters as they basically give
>> you no information at all at the callsite, except if you're lucky and
>> you already have an aptly-named variable available that you can pass.
>> Which seems to be the case here, but I'm still not sure whether this
>> change really improves the code.
That's also a very valid argument, which I didn't take in mind.
Show 23 quoted lines
> I tend to agree with you on both counts.  The "what happens when
> somebody else wants a third choice?" is a quesiton I would ask the
> first thing as the maintainer of a project.
>
> Even if the boolean parameter is so obviously named, the callsite
> can only say "true" or "false", unlike some other popular languages
> that lets you say
>
> 	my_function(use_revert_mode=true, verbose=false);
>
> and you cannot tell what effect the author wanted out of that "true"
> if all you can write were
>
> 	my_function(true, false);
>
> Of course, we could go ultra verbose, like
>
> 	my_function(true, /* use_revert_mode */
> 		    false, /* verbose */);
>
> but then we are often better off writing:
>
> 	my_function(REPLAY_MODE_REVERT, REPLAY_QUIET);

Thanks for bringing in this illustrative example. Point made, I'll revisit.

-- 
Cheers,
Toon
Toon ClaesJun 26, 2026, 05:36 UTC in reply to Patrick Steinhardt on lore

Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology

Patrick Steinhardt <ps@pks.im> writes:
Show 14 quoted lines
> git-rebase(1) essentially knows about three different modes:
>
>   - "--no-rebase-merges", which is the default and maps to your
>     "--linearize".
>
>   - "--rebase-merges", which by default doesn't rebase cousins by using
>     "--ancestry-path" internally.
>
>   - "--rebase-merges=rebase-cousins", which doesn't pass the above
>     option.
>
> So it's not a simple boolean there, which makes me wonder whether we
> should mirror the same interface so that all of git-rebase(1)'s modes
> can be represented, as well.
That's a valid question, although I don't know a good answer to that.

Basically you're asking for what the command line options will look like? Allow me to think out loud.

In this series I'm adding --linearize to git-replay(1). As mentioned, I don't think it makes sense to add it to git-history(1) as well. Without this option, the process aborts when it encounters a merge.

Dscho sent a patch series to properly replay (2-way) merges. I think this should become the default for both git-replay(1) and git-history(1).

But then, do we want to have an option that brings back the current behavior of aborting at merges? Maybe with --no-merges?

Then there's the option of rebasing cousins left. That's something that isn't covered by Dscho's series yet. Maybe --replay-cousins?

To reiterate what the final design could look like:
 * <nothing>: replay merges preserving topology.
 * "--linearize": flattens merges (only git-replay(1)).
 * "--no-merges": dies when the process tries to replay a merge.
 * "--replay-cousins": does what --rebase-merges=rebase-cousins does.

Now, all these options are (I think) mutually exclusive, so we could consider an option "--replay-merges=<mode>", but personally I find "--<option>=<value>" arguments harder to use than specifying separate options.

I think I'm avoiding your question, because the design of the command line parameters doesn't need tot 1-on-1 correlate to the internal datastructure. And I agree the mode isn't a boolean, but does that mean we want to use an enum internally? Well, I don't know. And I also don't think that matters right now. Code is easy to change, I think the command line options should be designed with the future in mind, which I believe we do with "--linearize".

Sorry for this long-winded rambling, but bottom line I think it's fine to add --linearize and in the future add more options and see how the code should evolve to support those.

Show 20 quoted lines
>> diff --git a/replay.c b/replay.c
>> index 7921d7dba3..5539daff00 100644
>> --- a/replay.c
>> +++ b/replay.c
>> @@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
>>  					  struct commit *onto,
>>  					  struct merge_options *merge_opt,
>>  					  struct merge_result *result,
>> +					  struct commit *replayed_base,
>>  					  bool reverse,
>>  					  enum replay_empty_commit_action empty)
>>  {
>> -	struct commit *base, *replayed_base;
>> +	struct commit *base;
>>  	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
>>  
>> +	if (replayed_base && reverse)
>> +		BUG("Linearizing commits is not supported when replaying in reverse");
>
> Nit: Error messages should typically start with a lower-case letter.
Thanks.
Show 19 quoted lines
>> @@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,
>>  	while ((commit = get_revision(revs))) {
>>  		const struct name_decoration *decoration;
>>  
>> -		if (commit->parents && commit->parents->next)
>> -			die(_("replaying merge commits is not supported yet!"));
>> +		if (commit->parents && commit->parents->next) {
>> +			if (!opts->linearize)
>> +				die(_("replaying merge commits is not supported yet!"));
>> +			/*
>> +			 * Drop the merge commit: do not pick it and leave
>> +			 * last_commit unchanged, so its children (and any ref
>> +			 * pointing at it) are reparented onto the previous
>> +			 * non-merge commit, which the ref-update loop below uses.
>> +			 */
>
> One could add a hint here that tells the user to pass the option. But I
> guess that might be somewhat weird, as we cannot assume that we're
> called by git-replay(1) here.
Yeah, true...
-- 
Cheers,
Toon
Toon ClaesJun 26, 2026, 05:48 UTC in reply to Toon Claes on lore

[PATCH v5 0/3] Teach git-replay(1) to linearize merge commits

As an alternative to dscho's patch series to replay merges[1], add option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does too with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. This patch was kindly provided by Dscho, which I've tweaked to be upstreamed.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3].

dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>

---
Changes in v5:
- Dropped the enum->bool patch and instead added a patch that better
  explains how pick_regular_commit() picks a base.
- Order of commits is shuffled.
- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool
  patch, I extended the code comments to explain how things work. This
  made me realize the use of the "replayed_base" was incorrect when
  multiple branches are rebased with --onto. This is fixed now and a
  test is added for this scenario.
- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com
Changes in v4:
- Use test_grep instead of a bare grep in the range-diff test, to
  prepare for mm/test-grep-lint.
- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com
Changes in v3:
- Add --linearize to Documentation SYNOPSIS, and mention it's
  incompatible with --revert.
- Small language change in help message for --linearize.
- Rephrase comment to include last_commit isn't modified when
  linearizing merges.
- Remove test that was added in earlier versions, but actually is
  a duplicate of 'replaying merge commits is not supported yet'.
- Add test to verify --revert and --linearize are incompatible.
- Properly test that replaying down to root with --linearize works.
- Add test for --linearize with --advance.
- Add test that uses git-range-diff(1) to verify the patches created by
  --linearize are correct.
- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com
Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
---
Johannes Schindelin (1):
      replay: offer an option to linearize the commit topology
Toon Claes (2):
      replay: add helper to put entry into mapped_commits
      replay: better explain how pick_regular_commit() picks a base
 Documentation/git-replay.adoc |  8 ++++-
 builtin/replay.c              |  6 +++-
 replay.c                      | 69 ++++++++++++++++++++++++++---------
 replay.h                      |  5 +++
 t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 152 insertions(+), 20 deletions(-)
Range-diff versus v4:
1:  a08bc22330 < -:  ---------- replay: refactor enum replay_mode into a bool
2:  3117fddcc5 = 1:  bbd5a710bd replay: add helper to put entry into mapped_commits
-:  ---------- > 2:  e08c7b46c0 replay: better explain how pick_regular_commit() picks a base
3:  acbb1df6a9 ! 3:  043cf63c1c replay: offer an option to linearize the commit topology
    @@ builtin/replay.c: int cmd_replay(int argc,
      	ref_mode = get_ref_action_mode(repo, ref_action);
     
      ## replay.c ##
    -@@ replay.c: static struct commit *pick_regular_commit(struct repository *repo,
    - 					  struct commit *onto,
    - 					  struct merge_options *merge_opt,
    - 					  struct merge_result *result,
    -+					  struct commit *replayed_base,
    - 					  bool reverse,
    - 					  enum replay_empty_commit_action empty)
    - {
    --	struct commit *base, *replayed_base;
    -+	struct commit *base;
    - 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
    - 
    -+	if (replayed_base && reverse)
    -+		BUG("Linearizing commits is not supported when replaying in reverse");
    -+
    - 	if (pickme->parents) {
    - 		base = pickme->parents->item;
    - 		base_tree = repo_get_commit_tree(repo, base);
    -@@ replay.c: static struct commit *pick_regular_commit(struct repository *repo,
    - 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
    - 	}
    - 
    --	replayed_base = get_mapped_commit(replayed_commits, base, onto);
    -+	if (!replayed_base)
    -+		replayed_base = get_mapped_commit(replayed_commits, base, onto);
    - 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
    - 	pickme_tree = repo_get_commit_tree(repo, pickme);
    - 
     @@ replay.c: int replay_revisions(struct rev_info *revs,
      	while ((commit = get_revision(revs))) {
      		const struct name_decoration *decoration;
      
    +-		/*
    +-		 * pick_regular_commit() looks up the parent of `commit` in
    +-		 * `replayed_commits` to determine the ancestor to replay onto.
    +-		 * The `default_base` parameter is used when no ancestor is found,
    +-		 * which happens for the first commit in the revision range.
    +-		 * When reverting, commits are replayed in reverse order, so the
    +-		 * lookup never succeeds, and we need to pass `last_commit`.
    +-		 */
    +-		struct commit *base = onto;
    +-		if (mode == REPLAY_MODE_REVERT)
    +-			base = last_commit;
    +-
     -		if (commit->parents && commit->parents->next)
     -			die(_("replaying merge commits is not supported yet!"));
    +-
    +-		last_commit = pick_regular_commit(revs->repo, commit, base,
    +-						  replayed_commits,
    +-						  &merge_opt, &result, mode, opts->empty);
     +		if (commit->parents && commit->parents->next) {
     +			if (!opts->linearize)
     +				die(_("replaying merge commits is not supported yet!"));
     +			/*
    -+			 * Drop the merge commit: do not pick it and leave
    -+			 * last_commit unchanged, so its children (and any ref
    -+			 * pointing at it) are reparented onto the previous
    -+			 * non-merge commit, which the ref-update loop below uses.
    ++			 * Drop the merge commit: do not pick it, leave
    ++			 * `last_commit` unchanged, and fall through to the
    ++			 * rest of the loop. As a result:
    ++			 * - the merge commit is mapped to `last_commit` in
    ++			 *   `replayed_commits`, this will become the parent for
    ++			 *   the child commits.
    ++			 * - refs previously pointing to the merge commit are
    ++			 *   rewritten to point to the previous non-merge commit.
     +			 */
     +		} else {
    -+			struct commit *to_pick = reverse ? last_commit : onto;
    -+			last_commit =
    -+				pick_regular_commit(revs->repo, commit,
    -+						    replayed_commits, to_pick,
    -+						    &merge_opt, &result,
    -+						    opts->linearize ? last_commit : NULL,
    -+						    reverse, opts->empty);
    ++			/*
    ++			 * pick_regular_commit() looks up the parent of `commit` in
    ++			 * `replayed_commits` to determine the ancestor to replay onto.
    ++			 * The `default_base` parameter is used when no ancestor is found,
    ++			 * which happens for the first commit in the revision range.
    ++			 * When reverting, commits are replayed in reverse order, so the
    ++			 * lookup never succeeds, and we need to pass `last_commit`.
    ++			 */
    ++			struct commit *base = onto;
    ++			if (mode == REPLAY_MODE_REVERT)
    ++				base = last_commit;
    ++
    ++			last_commit = pick_regular_commit(revs->repo, commit, base,
    ++							  replayed_commits,
    ++							  &merge_opt, &result,
    ++							  mode, opts->empty);
     +		}
    - 
    --		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
    --						  reverse ? last_commit : onto,
    --						  &merge_opt, &result, reverse, opts->empty);
    ++
      		if (!last_commit)
      			break;
      
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	git log --oneline main..$tip >out &&
     +	test_line_count = 3 out
     +'
    ++
    ++test_expect_success 'replay with --linearize to rebase multiple divergent branches' '
    ++	git replay --ref-action=print --linearize \
    ++		--onto main ^B topic2 topic-with-merge >result &&
    ++
    ++	test_line_count = 2 result &&
    ++	cut -f 3 -d " " result >new-branch-tips &&
    ++
    ++	git log --format=%s $(head -n 1 new-branch-tips) >actual &&
    ++	test_write_lines E D C M L B A >expect &&
    ++	test_cmp expect actual &&
    ++
    ++	git log --format=%s $(tail -n 1 new-branch-tips) >actual &&
    ++	test_write_lines O N J I M L B A >expect &&
    ++	test_cmp expect actual
    ++'
     +
      test_done

--- base-commit: ab776a62a78576513ee121424adb19597fbb7613 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesJun 26, 2026, 05:48 UTC in reply to Toon Claes on lore

[PATCH v5 1/3] replay: add helper to put entry into mapped_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index da531d5bc6..7bde1c7e93 100644
--- a/replay.c
+++ b/replay.c
@@ -250,9 +250,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -263,6 +263,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s\n",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -283,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -423,8 +438,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -436,11 +449,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 26, 2026, 05:48 UTC in reply to Toon Claes on lore

[PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base

The function pick_regular_commit() will replay the `pickme` commit. To determine the ancestor where to replay this commit on, it takes the parent of the commit and looks up its replayed result in `replayed_commits`. If no ancestor is found, the `onto` parameter is used as fallback.

The name `onto` is rather confusing, so rename it to `default_base`. And while at it, shuffle the function parameters so `struct commit` parameters are immediate siblings.

When in mode REPLAY_MODE_REVERT, the fallback `default_base` will always be used. This happens because commits are replayed in reverse order, so looking up the `pickme`'s parent in `replayed_commits` will always return empty. And to make these commits stack on top of each other, we need to pass in `last_commit`.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 20 ++++++++++++++++----
 1 file changed, 16 insertions(+), 4 deletions(-)
Show changes to replay.c +16 −4
diff --git a/replay.c b/replay.c
index 7bde1c7e93..86fba47fb9 100644
--- a/replay.c
+++ b/replay.c
@@ -280,8 +280,8 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,
 
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
+					  struct commit *default_base,
 					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
 					  enum replay_mode mode,
@@ -298,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, default_base);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -439,11 +439,23 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
+		/*
+		 * pick_regular_commit() looks up the parent of `commit` in
+		 * `replayed_commits` to determine the ancestor to replay onto.
+		 * The `default_base` parameter is used when no ancestor is found,
+		 * which happens for the first commit in the revision range.
+		 * When reverting, commits are replayed in reverse order, so the
+		 * lookup never succeeds, and we need to pass `last_commit`.
+		 */
+		struct commit *base = onto;
+		if (mode == REPLAY_MODE_REVERT)
+			base = last_commit;
+
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  mode == REPLAY_MODE_REVERT ? last_commit : onto,
+		last_commit = pick_regular_commit(revs->repo, commit, base,
+						  replayed_commits,
 						  &merge_opt, &result, mode, opts->empty);
 		if (!last_commit)
 			break;
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJun 26, 2026, 05:48 UTC in reply to Toon Claes on lore

[PATCH v5 3/3] replay: offer an option to linearize the commit topology

From: Johannes Schindelin <Johannes.Schindelin@gmx.de>

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the commit history into a sequence of the regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same.
Co-authored-by: Toon Claes <toon@iotcl.com>
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  8 ++++-
 builtin/replay.c              |  6 +++-
 replay.c                      | 50 ++++++++++++++++----------
 replay.h                      |  5 +++
 t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 132 insertions(+), 21 deletions(-)
Show changes to 5 files +132 −21

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..ef56ee0f1b 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 --------
 [verse]
 (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
+			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
 
 DESCRIPTION
 -----------
@@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
+	i.e. it cherry-picks only non-merge commits, each one on top of the
+	previous one.
+	This option is incompatible with `--revert`.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..62962c73c7 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -85,7 +85,7 @@ int cmd_replay(int argc,
 	const char *const replay_usage[] = {
 		N_("(EXPERIMENTAL!) git replay "
 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
+		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
 		NULL
 	};
 	struct option replay_options[] = {
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("drop merge commits, replaying only non-merge commits")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.revert, "--revert",
+				  opts.linearize, "--linearize");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 86fba47fb9..d803e0312f 100644
--- a/replay.c
+++ b/replay.c
@@ -439,24 +439,38 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		/*
-		 * pick_regular_commit() looks up the parent of `commit` in
-		 * `replayed_commits` to determine the ancestor to replay onto.
-		 * The `default_base` parameter is used when no ancestor is found,
-		 * which happens for the first commit in the revision range.
-		 * When reverting, commits are replayed in reverse order, so the
-		 * lookup never succeeds, and we need to pass `last_commit`.
-		 */
-		struct commit *base = onto;
-		if (mode == REPLAY_MODE_REVERT)
-			base = last_commit;
-
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(revs->repo, commit, base,
-						  replayed_commits,
-						  &merge_opt, &result, mode, opts->empty);
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * Drop the merge commit: do not pick it, leave
+			 * `last_commit` unchanged, and fall through to the
+			 * rest of the loop. As a result:
+			 * - the merge commit is mapped to `last_commit` in
+			 *   `replayed_commits`, this will become the parent for
+			 *   the child commits.
+			 * - refs previously pointing to the merge commit are
+			 *   rewritten to point to the previous non-merge commit.
+			 */
+		} else {
+			/*
+			 * pick_regular_commit() looks up the parent of `commit` in
+			 * `replayed_commits` to determine the ancestor to replay onto.
+			 * The `default_base` parameter is used when no ancestor is found,
+			 * which happens for the first commit in the revision range.
+			 * When reverting, commits are replayed in reverse order, so the
+			 * lookup never succeeds, and we need to pass `last_commit`.
+			 */
+			struct commit *base = onto;
+			if (mode == REPLAY_MODE_REVERT)
+				base = last_commit;
+
+			last_commit = pick_regular_commit(revs->repo, commit, base,
+							  replayed_commits,
+							  &merge_opt, &result,
+							  mode, opts->empty);
+		}
+
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index faf95c7459..64f42b6512 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..34c038eab9 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -52,8 +52,12 @@ test_expect_success 'setup' '
 	test_merge P O --no-ff &&
 	git switch main &&
 
+	git switch --orphan unrelated &&
+	test_commit unrelated-root &&
+
 	git switch -c conflict B &&
-	test_commit C.conflict C.t conflict
+	test_commit C.conflict C.t conflict &&
+	git branch -D unrelated
 '
 
 test_expect_success 'setup bare' '
@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '
 	test_grep "cannot be used together" actual
 '
 
+test_expect_success '--revert and --linearize cannot be used together' '
+	test_must_fail git replay --revert=main --linearize \
+		topic1..topic2 2>actual &&
+	test_grep "cannot be used together" actual
+'
+
 test_expect_success 'cannot advance target ... ordering would be ill-defined' '
 	echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
 	test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
@@ -565,4 +575,76 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
+	git replay --ref-action=print --linearize \
+		--onto unrelated-root topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I B A unrelated-root >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to cherry-pick merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--advance main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual &&
+
+	printf "update refs/heads/main " >expect &&
+	printf "%s " $(cut -f 3 -d " " result) >>expect &&
+	git rev-parse main >>expect &&
+	test_cmp expect result
+'
+
+test_expect_success 'replay --linearize produces the same patches' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# range-diff does not care about the dropped merge,
+	# so the original commits (I..topic-with-merge)
+	# and the replayed chain (main..tip) must produce identical patches.
+	git range-diff I..topic-with-merge main..$tip >out &&
+	test_file_not_empty out &&
+	test_grep ! -v "=" out &&
+
+	git log --oneline main..$tip >out &&
+	test_line_count = 3 out
+'
+
+test_expect_success 'replay with --linearize to rebase multiple divergent branches' '
+	git replay --ref-action=print --linearize \
+		--onto main ^B topic2 topic-with-merge >result &&
+
+	test_line_count = 2 result &&
+	cut -f 3 -d " " result >new-branch-tips &&
+
+	git log --format=%s $(head -n 1 new-branch-tips) >actual &&
+	test_write_lines E D C M L B A >expect &&
+	test_cmp expect actual &&
+
+	git log --format=%s $(tail -n 1 new-branch-tips) >actual &&
+	test_write_lines O N J I M L B A >expect &&
+	test_cmp expect actual
+'
+
 test_done
-- 
2.53.0.1323.g189a785ab5
Junio C HamanoJun 26, 2026, 16:50 UTC in reply to Toon Claes on lore

Re: [PATCH v5 1/3] replay: add helper to put entry into mapped_commits

Toon Claes <toon@iotcl.com> writes:
Show 10 quoted lines
> +static void put_mapped_commit(kh_oid_map_t *replayed_commits,
> +			      struct commit *commit,
> +			      struct commit *new_commit)
> +{
> +	khint_t pos;
> +	int ret;
> +
> +	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
> +	if (ret == 0)
> +		BUG("Duplicate rewritten commit: %s\n",

Please do not add terminating LF at the end of single-liner messages that use our print infrastructure, like BUG(), warning(), error(), and die(), as the machinery adds one for you.

Other than that, looking good.
Junio C HamanoJun 26, 2026, 17:10 UTC in reply to Toon Claes on lore

Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology

Toon Claes <toon@iotcl.com> writes:
Show 6 quoted lines
>  Documentation/git-replay.adoc |  8 ++++-
>  builtin/replay.c              |  6 +++-
>  replay.c                      | 50 ++++++++++++++++----------
>  replay.h                      |  5 +++
>  t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-
>  5 files changed, 132 insertions(+), 21 deletions(-)

"replay --linearize" behaves differently from the flattening rebase in a case where X and Y that forked from A are merged at Z, and we ask to flatten the history leading to Z, doesn't it?

     A----X
      \    \
       Y----Z (tip)

A typical flattening rebase would rewrite X to X', Y to Y', while dropping Z, and would leave us a flattened history, like

     A---X'---Y' (updated tip, the order of X' and Y' may be swapped)

I may be misreading the logic, but doesn't "replay --linearize" instead produce

     A----X' (dangling)
      \ 
       Y' (tip -- Z is dropped and gets mapped)

and leave X' dangling (or Y'; the point is that only one of them will survive), never incorporating it in the resulting history?

Show 34 quoted lines
> +		if (commit->parents && commit->parents->next) {
> +			if (!opts->linearize)
> +				die(_("replaying merge commits is not supported yet!"));
> +			/*
> +			 * Drop the merge commit: do not pick it, leave
> +			 * `last_commit` unchanged, and fall through to the
> +			 * rest of the loop. As a result:
> +			 * - the merge commit is mapped to `last_commit` in
> +			 *   `replayed_commits`, this will become the parent for
> +			 *   the child commits.
> +			 * - refs previously pointing to the merge commit are
> +			 *   rewritten to point to the previous non-merge commit.
> +			 */
> +		} else {
> +			/*
> +			 * pick_regular_commit() looks up the parent of `commit` in
> +			 * `replayed_commits` to determine the ancestor to replay onto.
> +			 * The `default_base` parameter is used when no ancestor is found,
> +			 * which happens for the first commit in the revision range.
> +			 * When reverting, commits are replayed in reverse order, so the
> +			 * lookup never succeeds, and we need to pass `last_commit`.
> +			 */
> +			struct commit *base = onto;
> +			if (mode == REPLAY_MODE_REVERT)
> +				base = last_commit;
> +
> +			last_commit = pick_regular_commit(revs->repo, commit, base,
> +							  replayed_commits,
> +							  &merge_opt, &result,
> +							  mode, opts->empty);
> +		}
> +
>  		if (!last_commit)
>  			break;
Immediately after this hunk beyond the post-context are these lines.
		/* Record commit -> last_commit mapping */
		put_mapped_commit(replayed_commits, commit, last_commit);

Let's imagine X gets processed first. X (and other commits on its branch) gets replayed, last_commit is set to X' (which is the rewritten X). replayed_commits mapping holds X->X' mapping.

Then let's imagine the history leading to Y is replayed next. last_commit becomes Y', and Y->Y' mapping is stored in replayed_commits.

Finally, we see Z. We are going to _drop_ it. last_commit is left unchanged, pointing at Y'. Then last_commit (i.e., Y') is used as the merge commit Z maps to (i.e., correctly dropping Z).

Any descendants of Z, if any, will be grafted as descendants of Y'. If X did not have any descendants other than Z in the rewritten part of the history, then X' (and commits leading to it) would be lost, no?

This "loss of the other branch" may be an inherent characteristic of this feature (i.e., I do not think it is necessarily a bug, and it may even be that the "bug" is in the way I am reading the patch), but then I wonder if the user may want to have control over which side branch should survive, perhaps? It would probably need to be documented, and a test or two to cast this behaviour in stone.

Show 5 quoted lines
> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
> index 3353bc4a4d..34c038eab9 100755
> --- a/t/t3650-replay-basics.sh
> +++ b/t/t3650-replay-basics.sh
> @@ -52,8 +52,12 @@ test_expect_success 'setup' '
The pre-context here has
	git switch --detach topic4 &&
	test_commit N &&
	test_commit O &&
	git switch -c topic-with-merge topic4 &&
>  	test_merge P O --no-ff &&
>  	git switch main &&
The above does prepare topic-with-merge branch, but ...
> +test_expect_success 'replay to rebase merge commit with --linearize' '
> +	git replay --ref-action=print --linearize \
> +		--onto main I..topic-with-merge >result &&

... this does not really exersize linearizing replay in a typical mergy history. P merges O with --no-ff because otherwise there won't be a merge, since O is a descendant of the commit "test_merge P O" runs on (i.e., topic4 == topic-with-merge).

    topic4 --- N --- O
          \           \
           .-----------P

So, as long as O is replayed later than the parent of N (which is true), O' will be the surviving tip (corresponds to Y' that the dropped Z was mapped to in the earlier example), and nothing gets orphaned, I think.

Perhaps a test to try a real merge may look something like this.
Show changes to diff +33 −0
diff --git c/t/t3650-replay-basics.sh w/t/t3650-replay-basics.sh
index 34c038eab9..bb737f729a 100755
--- c/t/t3650-replay-basics.sh
+++ w/t/t3650-replay-basics.sh
@@ -647,4 +647,37 @@ test_expect_success 'replay with --linearize to rebase multiple divergent branch
 	test_cmp expect actual
 '
 
+test_expect_success 'replay with --linearize of a divergent merge drops one branch' '
+	git switch -c topic-divergent-base main &&
+	test_commit base &&
+	# Fork 1: base -> X
+	git switch -c topic-divergent-x &&
+	test_commit X &&
+	# Fork 2: base -> Y
+	git switch topic-divergent-base &&
+	git switch -c topic-divergent-y &&
+	test_commit Y &&
+	# Merge them at Z
+	git switch topic-divergent-x &&
+	test_merge Z topic-divergent-y --no-ff &&
+
+	# History is now:
+	#
+	#       X - Z (topic-divergent-x)
+	#      /   /
+	#  base - Y
+	#
+
+	git replay --ref-action=print --linearize \
+		--onto main topic-divergent-base..topic-divergent-x >result &&
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+	# Get the commits replayed onto main
+	git log --format=%s main..$tip >actual &&
+	# We expect exactly one commit to be replayed (either X or Y)
+	# because the other one is left dangling due to the merge being dropped.
+	test_line_count = 1 actual &&
+	test_grep "^[XY]$" actual
+'
+
 test_done
Phillip WoodJun 27, 2026, 13:44 UTC in reply to Junio C Hamano on lore

Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology

On 26/06/2026 18:10, Junio C Hamano wrote:
Show 12 quoted lines
> Toon Claes <toon@iotcl.com> writes:
> 
>>   Documentation/git-replay.adoc |  8 ++++-
>>   builtin/replay.c              |  6 +++-
>>   replay.c                      | 50 ++++++++++++++++----------
>>   replay.h                      |  5 +++
>>   t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-
>>   5 files changed, 132 insertions(+), 21 deletions(-)
> 
> "replay --linearize" behaves differently from the flattening rebase
> in a case where X and Y that forked from A are merged at Z, and we
> ask to flatten the history leading to Z, doesn't it?

That's a good point. rebase takes the list of commits given by "git rev-list --reverse --no-merges" and cherry-picks each on on top of the previous one. In contrast replay cherry-picks each commit on top of its rewritten parent so it does not flatten the topology.

Thanks
Phillip
Show 164 quoted lines
>       A----X
>        \    \
>         Y----Z (tip)
> 
> A typical flattening rebase would rewrite X to X', Y to Y', while
> dropping Z, and would leave us a flattened history, like
> 
>       A---X'---Y' (updated tip, the order of X' and Y' may be swapped)
> 
> I may be misreading the logic, but doesn't "replay --linearize"
> instead produce
> 
>       A----X' (dangling)
>        \
>         Y' (tip -- Z is dropped and gets mapped)
> 
> and leave X' dangling (or Y'; the point is that only one of them
> will survive), never incorporating it in the resulting history?
> 
>> +		if (commit->parents && commit->parents->next) {
>> +			if (!opts->linearize)
>> +				die(_("replaying merge commits is not supported yet!"));
>> +			/*
>> +			 * Drop the merge commit: do not pick it, leave
>> +			 * `last_commit` unchanged, and fall through to the
>> +			 * rest of the loop. As a result:
>> +			 * - the merge commit is mapped to `last_commit` in
>> +			 *   `replayed_commits`, this will become the parent for
>> +			 *   the child commits.
>> +			 * - refs previously pointing to the merge commit are
>> +			 *   rewritten to point to the previous non-merge commit.
>> +			 */
>> +		} else {
>> +			/*
>> +			 * pick_regular_commit() looks up the parent of `commit` in
>> +			 * `replayed_commits` to determine the ancestor to replay onto.
>> +			 * The `default_base` parameter is used when no ancestor is found,
>> +			 * which happens for the first commit in the revision range.
>> +			 * When reverting, commits are replayed in reverse order, so the
>> +			 * lookup never succeeds, and we need to pass `last_commit`.
>> +			 */
>> +			struct commit *base = onto;
>> +			if (mode == REPLAY_MODE_REVERT)
>> +				base = last_commit;
>> +
>> +			last_commit = pick_regular_commit(revs->repo, commit, base,
>> +							  replayed_commits,
>> +							  &merge_opt, &result,
>> +							  mode, opts->empty);
>> +		}
>> +
>>   		if (!last_commit)
>>   			break;
> 
> Immediately after this hunk beyond the post-context are these lines.
> 
> 		/* Record commit -> last_commit mapping */
> 		put_mapped_commit(replayed_commits, commit, last_commit);
> 
> Let's imagine X gets processed first. X (and other commits on its
> branch) gets replayed, last_commit is set to X' (which is the
> rewritten X).  replayed_commits mapping holds X->X' mapping.
> 
> Then let's imagine the history leading to Y is replayed next.
> last_commit becomes Y', and Y->Y' mapping is stored in
> replayed_commits.
> 
> Finally, we see Z.  We are going to _drop_ it.  last_commit is left
> unchanged, pointing at Y'.  Then last_commit (i.e., Y') is used as
> the merge commit Z maps to (i.e., correctly dropping Z).
> 
> Any descendants of Z, if any, will be grafted as descendants of Y'.
> If X did not have any descendants other than Z in the rewritten part
> of the history, then X' (and commits leading to it) would be lost,
> no?
> 
> This "loss of the other branch" may be an inherent characteristic of
> this feature (i.e., I do not think it is necessarily a bug, and it
> may even be that the "bug" is in the way I am reading the patch),
> but then I wonder if the user may want to have control over which
> side branch should survive, perhaps?  It would probably need to be
> documented, and a test or two to cast this behaviour in stone.
> 
>> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
>> index 3353bc4a4d..34c038eab9 100755
>> --- a/t/t3650-replay-basics.sh
>> +++ b/t/t3650-replay-basics.sh
>> @@ -52,8 +52,12 @@ test_expect_success 'setup' '
> 
> The pre-context here has
> 
> 	git switch --detach topic4 &&
> 	test_commit N &&
> 	test_commit O &&
> 	git switch -c topic-with-merge topic4 &&
> 
>>   	test_merge P O --no-ff &&
>>   	git switch main &&
> 
> The above does prepare topic-with-merge branch, but ...
> 
>> +test_expect_success 'replay to rebase merge commit with --linearize' '
>> +	git replay --ref-action=print --linearize \
>> +		--onto main I..topic-with-merge >result &&
> 
> ... this does not really exersize linearizing replay in a typical
> mergy history.  P merges O with --no-ff because otherwise there
> won't be a merge, since O is a descendant of the commit "test_merge
> P O" runs on (i.e., topic4 == topic-with-merge).
> 
>      topic4 --- N --- O
>            \           \
>             .-----------P
> 
> So, as long as O is replayed later than the parent of N (which is
> true), O' will be the surviving tip (corresponds to Y' that the
> dropped Z was mapped to in the earlier example), and nothing gets
> orphaned, I think.
> 
> Perhaps a test to try a real merge may look something like this.
> 
> diff --git c/t/t3650-replay-basics.sh w/t/t3650-replay-basics.sh
> index 34c038eab9..bb737f729a 100755
> --- c/t/t3650-replay-basics.sh
> +++ w/t/t3650-replay-basics.sh
> @@ -647,4 +647,37 @@ test_expect_success 'replay with --linearize to rebase multiple divergent branch
>   	test_cmp expect actual
>   '
>   
> +test_expect_success 'replay with --linearize of a divergent merge drops one branch' '
> +	git switch -c topic-divergent-base main &&
> +	test_commit base &&
> +	# Fork 1: base -> X
> +	git switch -c topic-divergent-x &&
> +	test_commit X &&
> +	# Fork 2: base -> Y
> +	git switch topic-divergent-base &&
> +	git switch -c topic-divergent-y &&
> +	test_commit Y &&
> +	# Merge them at Z
> +	git switch topic-divergent-x &&
> +	test_merge Z topic-divergent-y --no-ff &&
> +
> +	# History is now:
> +	#
> +	#       X - Z (topic-divergent-x)
> +	#      /   /
> +	#  base - Y
> +	#
> +
> +	git replay --ref-action=print --linearize \
> +		--onto main topic-divergent-base..topic-divergent-x >result &&
> +	test_line_count = 1 result &&
> +	tip=$(cut -f 3 -d " " result) &&
> +	# Get the commits replayed onto main
> +	git log --format=%s main..$tip >actual &&
> +	# We expect exactly one commit to be replayed (either X or Y)
> +	# because the other one is left dangling due to the merge being dropped.
> +	test_line_count = 1 actual &&
> +	test_grep "^[XY]$" actual
> +'
> +
>   test_done
> 
Johannes SchindelinJun 28, 2026, 12:20 UTC in reply to Toon Claes on lore

Re: [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits

Hi Toon,
On Fri, 26 Jun 2026, Toon Claes wrote:
Show 5 quoted lines
> - (BIGGEST CHANGE) When working on a refactor to undo the enum->bool
>   patch, I extended the code comments to explain how things work. This
>   made me realize the use of the "replayed_base" was incorrect when
>   multiple branches are rebased with --onto. This is fixed now and a
>   test is added for this scenario.

I am not quite certain that this results in the desired outcome when working with a single branch that contains a merge commit. Take for example this topology (master~2..master at the time of writing):

  *   6c3d7b73556d Merge branch 'ps/t4216-tap-fix'
  |\
  | * f0411a4c717e t4216: fix no-op test that breaks TAP output
  * | ab776a62a785 Git 2.55-rc2
  o | 1ea786d14a1b Merge branch 'hn/macos-linker-warning'
   /
  o 08b6ae38c602 t4216: test changed path filters with high bit paths

Running `git replay --linearize --onto master~2 master~2..master` used to result in this:

  * 3ec7cc3e73c0 t4216: fix no-op test that breaks TAP output
  * 8dca9f98dc05 Git 2.55-rc2
  o 1ea786d14a1b Merge branch 'hn/macos-linker-warning'

which is what I would expect. But now, due to the dropped `replayed_base`, that tip commit is replayed directly on top of `onto` and the first replayed commit ("Git 2.55-rc2") is simply (and inadvertently) dropped:

  * 5e4899a3e03c t4216: fix no-op test that breaks TAP output
  o 1ea786d14a1b Merge branch 'hn/macos-linker-warning'

I had originally introduced that `replayed_base` specifically to prevent this commit-dropping.

As to the question what should happen if multiple branches are replayed at the same time with `--linearize`: This is a very tricky problem. Naively, one would want all of those branches to be linearized _individually_. But that idea breaks down when you replay three branches, two of them with distinct commits, and the third branch a merge of the first two:

  * Branch C: merge branches A and B
  |\
  | * Branch B
  * | Branch A
  |/
  o onto

What should the replayed branch C look like? Should it have A' and B' in that order? I.e. share the rewritten commit with the replayed branch A? But then B' could not be the replayed B because that needs to be directly on top of onto.

So I fear that the `replayed_base` design _is_ needed, and the only way `git replay --linearize` can work with multiple branches is by linearizing all of the replayed commits into one single, linear commit topology.

Obviously, there are ways one could _try_ to rescue the previous idea, so that at least replaying just branches A and B would keep the replayed commits non-reachable from each other, but I strongly suspect that any such design will invariably surprise users in nasty ways when the logic has to fall back to the simple idea I outlined anyway.

Ciao, Johannes

Patrick SteinhardtJun 29, 2026, 08:04 UTC in reply to Toon Claes on lore

Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology

On Fri, Jun 26, 2026 at 07:36:31AM +0200, Toon Claes wrote:
Show 32 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > git-rebase(1) essentially knows about three different modes:
> >
> >   - "--no-rebase-merges", which is the default and maps to your
> >     "--linearize".
> >
> >   - "--rebase-merges", which by default doesn't rebase cousins by using
> >     "--ancestry-path" internally.
> >
> >   - "--rebase-merges=rebase-cousins", which doesn't pass the above
> >     option.
> >
> > So it's not a simple boolean there, which makes me wonder whether we
> > should mirror the same interface so that all of git-rebase(1)'s modes
> > can be represented, as well.
> 
> That's a valid question, although I don't know a good answer to that.
> 
> Basically you're asking for what the command line options will look
> like? Allow me to think out loud.
> 
> In this series I'm adding --linearize to git-replay(1). As mentioned, I
> don't think it makes sense to add it to git-history(1) as well. Without
> this option, the process aborts when it encounters a merge.
> 
> Dscho sent a patch series to properly replay (2-way) merges. I think
> this should become the default for both git-replay(1) and
> git-history(1).
> 
> But then, do we want to have an option that brings back the current
> behavior of aborting at merges? Maybe with --no-merges?
I think that would be a sensible option to have.
Show 9 quoted lines
> Then there's the option of rebasing cousins left. That's something that
> isn't covered by Dscho's series yet. Maybe --replay-cousins?
> 
> To reiterate what the final design could look like:
> 
>  * <nothing>: replay merges preserving topology.
>  * "--linearize": flattens merges (only git-replay(1)).
>  * "--no-merges": dies when the process tries to replay a merge.
>  * "--replay-cousins": does what --rebase-merges=rebase-cousins does.

Right. And if we tried to be consistent with git-rebase(1), then this could be done as:

  - "--rebase-merges" to replay merges preserving topology, which is the
    default once we support replaying them.
  - "--no-rebase-merges" to flatten commits.
  - "--rebase-merges=abort" to explicitly die when seeing merges.
  - "--rebase-merges=rebase-cousins"
Show 16 quoted lines
> Now, all these options are (I think) mutually exclusive, so we could
> consider an option "--replay-merges=<mode>", but personally I find
> "--<option>=<value>" arguments harder to use than specifying separate
> options.
> 
> I think I'm avoiding your question, because the design of the command
> line parameters doesn't need tot 1-on-1 correlate to the internal
> datastructure. And I agree the mode isn't a boolean, but does that mean
> we want to use an enum internally? Well, I don't know. And I also don't
> think that matters right now. Code is easy to change, I think the
> command line options should be designed with the future in mind, which I
> believe we do with "--linearize".
> 
> Sorry for this long-winded rambling, but bottom line I think it's fine
> to add --linearize and in the future add more options and see how the
> code should evolve to support those.

Hm, I dunno. You basically reasoned that we potentially want to have all of the same options that git-rebase(1)'s "--rebase-merges=" already supports. So that begs the question why we need to reinvent the wheel then and not just use the same syntax.

Note that I'm not arguing that we should support all of these options now. I'm merely arguing that we should try to be consistent, unless there is a good argument not to do that. I'm fine with the interface if there indeed is a good argument, but if so we should document why we think that the current interface in git-rebase(1) is not a good fit for this command.

Thanks!
Patrick
Johannes SchindelinJun 30, 2026, 09:44 UTC in reply to Patrick Steinhardt on lore

Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology

Hi Patrick & Toon,
On Tue, 30 Jun 2026, Patrick Steinhardt wrote:
Show 35 quoted lines
> On Fri, Jun 26, 2026 at 07:36:31AM +0200, Toon Claes wrote:
> > Patrick Steinhardt <ps@pks.im> writes:
> > 
> > > git-rebase(1) essentially knows about three different modes:
> > >
> > >   - "--no-rebase-merges", which is the default and maps to your
> > >     "--linearize".
> > >
> > >   - "--rebase-merges", which by default doesn't rebase cousins by using
> > >     "--ancestry-path" internally.
> > >
> > >   - "--rebase-merges=rebase-cousins", which doesn't pass the above
> > >     option.
> > >
> > > So it's not a simple boolean there, which makes me wonder whether we
> > > should mirror the same interface so that all of git-rebase(1)'s modes
> > > can be represented, as well.
> > 
> > That's a valid question, although I don't know a good answer to that.
> > 
> > Basically you're asking for what the command line options will look
> > like? Allow me to think out loud.
> > 
> > In this series I'm adding --linearize to git-replay(1). As mentioned, I
> > don't think it makes sense to add it to git-history(1) as well. Without
> > this option, the process aborts when it encounters a merge.
> > 
> > Dscho sent a patch series to properly replay (2-way) merges. I think
> > this should become the default for both git-replay(1) and
> > git-history(1).
> > 
> > But then, do we want to have an option that brings back the current
> > behavior of aborting at merges? Maybe with --no-merges?
> 
> I think that would be a sensible option to have.

I also think that we'll need a way to abort at merges because linearizing commits is a relatively common operation.

Show 21 quoted lines
> > Then there's the option of rebasing cousins left. That's something that
> > isn't covered by Dscho's series yet. Maybe --replay-cousins?
> > 
> > To reiterate what the final design could look like:
> > 
> >  * <nothing>: replay merges preserving topology.
> >  * "--linearize": flattens merges (only git-replay(1)).
> >  * "--no-merges": dies when the process tries to replay a merge.
> >  * "--replay-cousins": does what --rebase-merges=rebase-cousins does.
> 
> Right. And if we tried to be consistent with git-rebase(1), then this
> could be done as:
> 
>   - "--rebase-merges" to replay merges preserving topology, which is the
>     default once we support replaying them.
> 
>   - "--no-rebase-merges" to flatten commits.
> 
>   - "--rebase-merges=abort" to explicitly die when seeing merges.
> 
>   - "--rebase-merges=rebase-cousins"

The `git rebase` options are unlikely to be a good precedent to follow. Their history is full of usability warts, and in hindsight, I would really have loved a more steady hand in developing and maintaining a good UX. The fact alone that this is called `rebase` speaks volumes about how hostile of a user experience this command surfaces.

In any case, these options should use the much more natural term "replay" instead of "rebase".

But then: you said that `--no-rebase-merges` should flatten the commits? That's not what this option name conveys to me; It would convey to me that the operation would _abort_ on encountering merge commits.

In other words, I do think that the --linearize option is conceptually quite distinct from the different modes in which merge commits could be handled. As such, this option should probably not be conflated with the various `--replay-merges=<mode>` modes.

Show 21 quoted lines
> > Now, all these options are (I think) mutually exclusive, so we could
> > consider an option "--replay-merges=<mode>", but personally I find
> > "--<option>=<value>" arguments harder to use than specifying separate
> > options.
> > 
> > I think I'm avoiding your question, because the design of the command
> > line parameters doesn't need tot 1-on-1 correlate to the internal
> > datastructure. And I agree the mode isn't a boolean, but does that mean
> > we want to use an enum internally? Well, I don't know. And I also don't
> > think that matters right now. Code is easy to change, I think the
> > command line options should be designed with the future in mind, which I
> > believe we do with "--linearize".
> > 
> > Sorry for this long-winded rambling, but bottom line I think it's fine
> > to add --linearize and in the future add more options and see how the
> > code should evolve to support those.
> 
> Hm, I dunno. You basically reasoned that we potentially want to have all
> of the same options that git-rebase(1)'s "--rebase-merges=" already
> supports. So that begs the question why we need to reinvent the wheel
> then and not just use the same syntax.

I would strongly caution against repeating the same UX mistakes as `git rebase` has to live with.

The _functionality_, yes, I think that'd be good to have in `git replay`. But we can surface that functionality in much better ways, with option names that reflect the concepts much more intuitively.

Ciao, Johannes

Show 12 quoted lines
> Note that I'm not arguing that we should support all of these options
> now. I'm merely arguing that we should try to be consistent, unless
> there is a good argument not to do that. I'm fine with the interface if
> there indeed is a good argument, but if so we should document why we
> think that the current interface in git-rebase(1) is not a good fit for
> this command.
> 
> Thanks!
> 
> Patrick
> 
> 
Patrick SteinhardtJun 30, 2026, 11:32 UTC in reply to Johannes Schindelin on lore

Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology

On Tue, Jun 30, 2026 at 11:44:47AM +0200, Johannes Schindelin wrote:
Show 41 quoted lines
> On Tue, 30 Jun 2026, Patrick Steinhardt wrote:
> > On Fri, Jun 26, 2026 at 07:36:31AM +0200, Toon Claes wrote:
> > > Then there's the option of rebasing cousins left. That's something that
> > > isn't covered by Dscho's series yet. Maybe --replay-cousins?
> > > 
> > > To reiterate what the final design could look like:
> > > 
> > >  * <nothing>: replay merges preserving topology.
> > >  * "--linearize": flattens merges (only git-replay(1)).
> > >  * "--no-merges": dies when the process tries to replay a merge.
> > >  * "--replay-cousins": does what --rebase-merges=rebase-cousins does.
> > 
> > Right. And if we tried to be consistent with git-rebase(1), then this
> > could be done as:
> > 
> >   - "--rebase-merges" to replay merges preserving topology, which is the
> >     default once we support replaying them.
> > 
> >   - "--no-rebase-merges" to flatten commits.
> > 
> >   - "--rebase-merges=abort" to explicitly die when seeing merges.
> > 
> >   - "--rebase-merges=rebase-cousins"
> 
> The `git rebase` options are unlikely to be a good precedent to follow.
> Their history is full of usability warts, and in hindsight, I would really
> have loved a more steady hand in developing and maintaining a good UX. The
> fact alone that this is called `rebase` speaks volumes about how hostile
> of a user experience this command surfaces.
> 
> In any case, these options should use the much more natural term "replay"
> instead of "rebase".
> 
> But then: you said that `--no-rebase-merges` should flatten the commits?
> That's not what this option name conveys to me; It would convey to me that
> the operation would _abort_ on encountering merge commits.
> 
> In other words, I do think that the --linearize option is conceptually
> quite distinct from the different modes in which merge commits could be
> handled. As such, this option should probably not be conflated with
> the various `--replay-merges=<mode>` modes.

Fair enough. Arguments like this are basically what I want to read in the commit message. As said in the below snippet: I'm not against diverging from the git-rebase(1) interface, but if we do that we should document why we think that the current interface is bad.

[snip]
Show 6 quoted lines
> > Note that I'm not arguing that we should support all of these options
> > now. I'm merely arguing that we should try to be consistent, unless
> > there is a good argument not to do that. I'm fine with the interface if
> > there indeed is a good argument, but if so we should document why we
> > think that the current interface in git-rebase(1) is not a good fit for
> > this command.
Thanks!
Patrick
Toon ClaesJun 30, 2026, 13:42 UTC in reply to Johannes Schindelin on lore

Re: [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits

Johannes Schindelin <Johannes.Schindelin@gmx.de> writes:
> So I fear that the `replayed_base` design _is_ needed, and the only way
> `git replay --linearize` can work with multiple branches is by linearizing
> all of the replayed commits into one single, linear commit topology.

Well, I'm getting the feeling you're right here. But then this change is no longer related to merge commits only. Replaying multiple branches with --onto and --linearize would always replay them into a single line hiearchy?

Personally, I would be totally fine with that. We need to lay that out very clearly in the docs, but that is in my humble opinion also a strong argument to name it `--linearize` and not `--replay-merge=linearize` or whatever we've been discussing.

Show 5 quoted lines
> Obviously, there are ways one could _try_ to rescue the previous idea, so
> that at least replaying just branches A and B would keep the replayed
> commits non-reachable from each other, but I strongly suspect that any
> such design will invariably surprise users in nasty ways when the logic
> has to fall back to the simple idea I outlined anyway.

I don't like the "try" in there. I think it's better to have predictable behavior. Users always have the choice to replay branches in separate git-replay(1) calls, although that comes with a downside that commits shared by those branches will be replayed separately and will get duplicated, unless they fiddle with the COMMITTER_DATE.

-- 
Cheers,
Toon
Toon ClaesJul 1, 2026, 08:50 UTC in reply to Junio C Hamano on lore

Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology

Junio C Hamano <gitster@pobox.com> writes:
Show 31 quoted lines
> Toon Claes <toon@iotcl.com> writes:
>
>>  Documentation/git-replay.adoc |  8 ++++-
>>  builtin/replay.c              |  6 +++-
>>  replay.c                      | 50 ++++++++++++++++----------
>>  replay.h                      |  5 +++
>>  t/t3650-replay-basics.sh      | 84 ++++++++++++++++++++++++++++++++++++++++++-
>>  5 files changed, 132 insertions(+), 21 deletions(-)
>
> "replay --linearize" behaves differently from the flattening rebase
> in a case where X and Y that forked from A are merged at Z, and we
> ask to flatten the history leading to Z, doesn't it?
>
>      A----X
>       \    \
>        Y----Z (tip)
>
> A typical flattening rebase would rewrite X to X', Y to Y', while
> dropping Z, and would leave us a flattened history, like
>
>      A---X'---Y' (updated tip, the order of X' and Y' may be swapped)
>
> I may be misreading the logic, but doesn't "replay --linearize"
> instead produce
>
>      A----X' (dangling)
>       \ 
>        Y' (tip -- Z is dropped and gets mapped)
>
> and leave X' dangling (or Y'; the point is that only one of them
> will survive), never incorporating it in the resulting history?

You bring up a good point here, and it is very similar to what Dscho brought up[1].

When I started working on v5, I realized multiple tips can be passed to git-replay(1) and the code in v1-v4 would replay all commits into a single linear history. I assumed that's not what we want.

In my mind, replaying unrelated histories with --onto and --linearize should remain unrelated. Also assuming the --linearize option would only linearize merge commits.

Looking at less obvious situations, like your example above, things aren't really that simple. And I agree the new Y' tip is not correct and X' shouldn't be dangling.

On the other hand though, if there was a branch pointing to X, we still need a piece of history that has X', but doesn't have Y'. In your flattened history X' isn't a descendant of Y', but the order may be swapped and we would need to create something like:

      A----X' (other tip)
       \
        Y'----X' (tip)

That's quite complex trying to achieve something like that in code. In short, --linearize will change the topology of the (merge) commits, and we have to do this in a predictable way. Thus I'm currently leaning toward bringing v1-v4 behavior back and linearize all commits in to a single line when using --linearize. Meaning:

        B----C (other tip)
       /
      A----X
       \
        Y----Z (tip)
  $ git replay --onto X --linearize Z C
Would result into:
      A----X----Y----Z----B----C
                               ^ (new other tip)
                     ^ (new tip)

This might not always be the expected behavior, especially when replaying multiple branches at once. But to those I would suggest: don't replay multiple branches at once.

But then again: Given the above example, you want to replay Z and C on top of eachother, but don't want to rewrite the "(other tip)"? They have I think two options:

  $ git replay --onto X --linearize --ref=tip Z C

By passing --ref we could tell git-replay(1) to only update that ref. (sidenote: --ref currently cannot be combined with multiple revision ranges, because that normally produces multiple tips and it would be ambiguous which one --ref should point at. But --linearize collapses everything into a single tip, so that ambiguity goes away; maybe we should loosen the constraint in that case.)

Or:
  $ git replay --onto X --linearize Z^{commit} C

By peeling Z to a commit, git-replay(1) doesn't see it as a ref to update.

Anyhow, a lot to unpack and I'll try to do my best in the next version to cover that in the commit message, docs and test cases.

[1]: <f8b520d1-edeb-9e45-c503-025c8b5833c3@gmx.de>
Show 56 quoted lines
>> +		if (commit->parents && commit->parents->next) {
>> +			if (!opts->linearize)
>> +				die(_("replaying merge commits is not supported yet!"));
>> +			/*
>> +			 * Drop the merge commit: do not pick it, leave
>> +			 * `last_commit` unchanged, and fall through to the
>> +			 * rest of the loop. As a result:
>> +			 * - the merge commit is mapped to `last_commit` in
>> +			 *   `replayed_commits`, this will become the parent for
>> +			 *   the child commits.
>> +			 * - refs previously pointing to the merge commit are
>> +			 *   rewritten to point to the previous non-merge commit.
>> +			 */
>> +		} else {
>> +			/*
>> +			 * pick_regular_commit() looks up the parent of `commit` in
>> +			 * `replayed_commits` to determine the ancestor to replay onto.
>> +			 * The `default_base` parameter is used when no ancestor is found,
>> +			 * which happens for the first commit in the revision range.
>> +			 * When reverting, commits are replayed in reverse order, so the
>> +			 * lookup never succeeds, and we need to pass `last_commit`.
>> +			 */
>> +			struct commit *base = onto;
>> +			if (mode == REPLAY_MODE_REVERT)
>> +				base = last_commit;
>> +
>> +			last_commit = pick_regular_commit(revs->repo, commit, base,
>> +							  replayed_commits,
>> +							  &merge_opt, &result,
>> +							  mode, opts->empty);
>> +		}
>> +
>>  		if (!last_commit)
>>  			break;
>
> Immediately after this hunk beyond the post-context are these lines.
>
> 		/* Record commit -> last_commit mapping */
> 		put_mapped_commit(replayed_commits, commit, last_commit);
>
> Let's imagine X gets processed first. X (and other commits on its
> branch) gets replayed, last_commit is set to X' (which is the
> rewritten X).  replayed_commits mapping holds X->X' mapping.
>
> Then let's imagine the history leading to Y is replayed next.
> last_commit becomes Y', and Y->Y' mapping is stored in
> replayed_commits.
>
> Finally, we see Z.  We are going to _drop_ it.  last_commit is left
> unchanged, pointing at Y'.  Then last_commit (i.e., Y') is used as
> the merge commit Z maps to (i.e., correctly dropping Z).
>
> Any descendants of Z, if any, will be grafted as descendants of Y'.
> If X did not have any descendants other than Z in the rewritten part
> of the history, then X' (and commits leading to it) would be lost,
> no?
I appreciate you're breaking down the code here.
Show 87 quoted lines
> This "loss of the other branch" may be an inherent characteristic of
> this feature (i.e., I do not think it is necessarily a bug, and it
> may even be that the "bug" is in the way I am reading the patch),
> but then I wonder if the user may want to have control over which
> side branch should survive, perhaps?  It would probably need to be
> documented, and a test or two to cast this behaviour in stone.
>
>> diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
>> index 3353bc4a4d..34c038eab9 100755
>> --- a/t/t3650-replay-basics.sh
>> +++ b/t/t3650-replay-basics.sh
>> @@ -52,8 +52,12 @@ test_expect_success 'setup' '
>
> The pre-context here has
>
> 	git switch --detach topic4 &&
> 	test_commit N &&
> 	test_commit O &&
> 	git switch -c topic-with-merge topic4 &&
>
>>  	test_merge P O --no-ff &&
>>  	git switch main &&
>
> The above does prepare topic-with-merge branch, but ...
>
>> +test_expect_success 'replay to rebase merge commit with --linearize' '
>> +	git replay --ref-action=print --linearize \
>> +		--onto main I..topic-with-merge >result &&
>
> ... this does not really exersize linearizing replay in a typical
> mergy history.  P merges O with --no-ff because otherwise there
> won't be a merge, since O is a descendant of the commit "test_merge
> P O" runs on (i.e., topic4 == topic-with-merge).
>
>     topic4 --- N --- O
>           \           \
>            .-----------P
>
> So, as long as O is replayed later than the parent of N (which is
> true), O' will be the surviving tip (corresponds to Y' that the
> dropped Z was mapped to in the earlier example), and nothing gets
> orphaned, I think.
>
> Perhaps a test to try a real merge may look something like this.
>
> diff --git c/t/t3650-replay-basics.sh w/t/t3650-replay-basics.sh
> index 34c038eab9..bb737f729a 100755
> --- c/t/t3650-replay-basics.sh
> +++ w/t/t3650-replay-basics.sh
> @@ -647,4 +647,37 @@ test_expect_success 'replay with --linearize to rebase multiple divergent branch
>  	test_cmp expect actual
>  '
>  
> +test_expect_success 'replay with --linearize of a divergent merge drops one branch' '
> +	git switch -c topic-divergent-base main &&
> +	test_commit base &&
> +	# Fork 1: base -> X
> +	git switch -c topic-divergent-x &&
> +	test_commit X &&
> +	# Fork 2: base -> Y
> +	git switch topic-divergent-base &&
> +	git switch -c topic-divergent-y &&
> +	test_commit Y &&
> +	# Merge them at Z
> +	git switch topic-divergent-x &&
> +	test_merge Z topic-divergent-y --no-ff &&
> +
> +	# History is now:
> +	#
> +	#       X - Z (topic-divergent-x)
> +	#      /   /
> +	#  base - Y
> +	#
> +
> +	git replay --ref-action=print --linearize \
> +		--onto main topic-divergent-base..topic-divergent-x >result &&
> +	test_line_count = 1 result &&
> +	tip=$(cut -f 3 -d " " result) &&
> +	# Get the commits replayed onto main
> +	git log --format=%s main..$tip >actual &&
> +	# We expect exactly one commit to be replayed (either X or Y)
> +	# because the other one is left dangling due to the merge being dropped.
> +	test_line_count = 1 actual &&
> +	test_grep "^[XY]$" actual
> +'
> +
>  test_done
Thank you for providing this test case.
-- 
Cheers,
Toon
Toon ClaesJul 2, 2026, 17:58 UTC in reply to Toon Claes on lore

[PATCH v6 0/3] Teach git-replay(1) to linearize merge commits

As an alternative to dscho's patch series to replay merges[1], add an option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. This patch was kindly provided by Dscho, which I've tweaked to be upstreamed.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3].

dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V2_CV_doc_replay_config.767@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>

---
Changes in v6:
- Reworked the second commit that moves picking the base completely
  outside pick_regular_commit(), instead of adding more explanation.
- Drastically extended the commit message on commit #3.
- Extended docs on flattening multiple revision ranges and how it's
  different from git-rebase(1)'s --no-rebase-merges.
- Added a bunch of tests to cover various scenarios.
- Remove newline from BUG() message.
- Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com
Changes in v5:
- Dropped the enum->bool patch and instead added a patch that better
  explains how pick_regular_commit() picks a base.
- Order of commits is shuffled.
- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool
  patch, I extended the code comments to explain how things work. This
  made me realize the use of the "replayed_base" was incorrect when
  multiple branches are rebased with --onto. This is fixed now and a
  test is added for this scenario.
- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com
Changes in v4:
- Use test_grep instead of a bare grep in the range-diff test, to
  prepare for mm/test-grep-lint.
- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com
Changes in v3:
- Add --linearize to Documentation SYNOPSIS, and mention it's
  incompatible with --revert.
- Small language change in help message for --linearize.
- Rephrase comment to include last_commit isn't modified when
  linearizing merges.
- Remove test that was added in earlier versions, but actually is
  a duplicate of 'replaying merge commits is not supported yet'.
- Add test to verify --revert and --linearize are incompatible.
- Properly test that replaying down to root with --linearize works.
- Add test for --linearize with --advance.
- Add test that uses git-range-diff(1) to verify the patches created by
  --linearize are correct.
- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com
Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
---
Johannes Schindelin (1):
      replay: offer an option to linearize the commit topology
Toon Claes (2):
      replay: add helper to put entry into replayed_commits
      replay: resolve the replay base outside pick_regular_commit()
 Documentation/git-replay.adoc |  21 ++++++-
 builtin/replay.c              |   6 +-
 replay.c                      |  81 ++++++++++++++++--------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 225 insertions(+), 28 deletions(-)
Range-diff versus v5:
1:  b4512eb233 ! 1:  b957989fd9 replay: add helper to put entry into mapped_commits
    @@ Metadata
     Author: Toon Claes <toon@iotcl.com>
     
      ## Commit message ##
    -    replay: add helper to put entry into mapped_commits
    +    replay: add helper to put entry into replayed_commits
     
         The function replay_revisions() in replay.c is rather lengthy. Extract
         the logic to put a commit entry into mapped_commits into a helper
    @@ replay.c: static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
     +
     +	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
     +	if (ret == 0)
    -+		BUG("Duplicate rewritten commit: %s\n",
    ++		BUG("Duplicate rewritten commit: %s",
     +		    oid_to_hex(&commit->object.oid));
     +
     +	kh_value(replayed_commits, pos) = new_commit;
2:  91ed61bafd < -:  ---------- replay: better explain how pick_regular_commit() picks a base
-:  ---------- > 2:  6d457e8c39 replay: resolve the replay base outside pick_regular_commit()
3:  eb6a3b0d72 ! 3:  af39c0ae44 replay: offer an option to linearize the commit topology
    @@ Commit message
     
         The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
         was given. This mode drops merge commits instead of replaying them, and
    -    linearizes the commit history into a sequence of the
    -    regular (single-parent) commits.
    +    linearizes the history into a sequence of regular (single-parent)
    +    commits.
     
    -    Add option `--linearize` to git-replay(1) to do the same.
    +    Add option `--linearize` to git-replay(1) to do the same. Each replayed
    +    commit is stacked on top of the previously replayed one. When a merge is
    +    encountered, the commits reachable from all of its sides are replayed
    +    into the single line and the merge itself is dropped.
    +
    +    If a ref was pointing to a merge commit, that ref is updated to the
    +    merge's last replayed ancestor.
    +
    +    git-replay(1) accepts multiple revision ranges, for example:
    +
    +        $ git replay --onto main topic1 topic2
    +
    +    Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
    +    independently and updates both refs.
    +
    +    With `--linearize` the whole set is flattened into one line: the ranges
    +    are stacked on top of each other rather than replayed side by side, so
    +    both refs end up pointing at different points along that single history.
    +
    +    Replaying all revision ranges into one single linear history is
    +    intentional and it's the only way to ensure predictable results. A user
    +    who wants to linearize ranges independently is advised to use separate
    +    git-replay(1) invocations.
    +
    +    Linearizing is a distinct operation, and flattening merge commits is
    +    just one aspect of that. Recreating merges would be a separate mode, so
    +    rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,
    +    git-replay(1) uses its own `--linearize` option.
     
         Co-authored-by: Toon Claes <toon@iotcl.com>
         Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif
      The default mode can be configured via the `replay.refAction` configuration variable.
      
     +--linearize::
    -+	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
    -+	i.e. it cherry-picks only non-merge commits, each one on top of the
    -+	previous one.
    -+	This option is incompatible with `--revert`.
    ++	In this mode, each replayed commit is stacked on top of the
    ++	previously replayed one, so all replayed commits are flattened into
    ++	a single linear history.
    +++
    ++When a merge commit is encountered, the behavior of git-rebase(1)'s
    ++option `--no-rebase-merges` is imitated. All commits in the range
    ++reachable from the merge commit are replayed into a linear history, and
    ++the merge commit itself is dropped. A ref that pointed to a merge commit
    ++is updated to the merge's last replayed ancestor.
    +++
    ++This flattens the `<revision-range>` as a whole. When multiple revision
    ++ranges are given they are stacked on top of each other into one linear
    ++history. Each of their refs is updated to point to its position in that
    ++history. To linearize ranges separately, replay them in separate `git
    ++replay` invocations.
    +++
    ++This option is incompatible with `--revert`.
     +
      <revision-range>::
      	Range of commits to replay; see "Specifying Ranges" in
    @@ replay.c: int replay_revisions(struct rev_info *revs,
      		const struct name_decoration *decoration;
      
     -		/*
    --		 * pick_regular_commit() looks up the parent of `commit` in
    --		 * `replayed_commits` to determine the ancestor to replay onto.
    --		 * The `default_base` parameter is used when no ancestor is found,
    --		 * which happens for the first commit in the revision range.
    --		 * When reverting, commits are replayed in reverse order, so the
    --		 * lookup never succeeds, and we need to pass `last_commit`.
    +-		 * Decide where to replay this commit on.
    +-		 * If the parent commit was replayed already, the replayed result
    +-		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
    +-		 * When reverting, commits are replayed in reverse order and thus
    +-		 * its parent isn't replayed yet. Therefore revert commits are
    +-		 * always replayed onto `last_commit`.
     -		 */
    --		struct commit *base = onto;
    +-		struct commit *parent = commit->parents ? commit->parents->item : NULL;
    +-		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
    +-
     -		if (mode == REPLAY_MODE_REVERT)
     -			base = last_commit;
     -
    @@ replay.c: int replay_revisions(struct rev_info *revs,
     -			die(_("replaying merge commits is not supported yet!"));
     -
     -		last_commit = pick_regular_commit(revs->repo, commit, base,
    --						  replayed_commits,
    --						  &merge_opt, &result, mode, opts->empty);
    +-						  &merge_opt, &result,
    +-						  mode, opts->empty);
     +		if (commit->parents && commit->parents->next) {
     +			if (!opts->linearize)
     +				die(_("replaying merge commits is not supported yet!"));
    @@ replay.c: int replay_revisions(struct rev_info *revs,
     +			 * Drop the merge commit: do not pick it, leave
     +			 * `last_commit` unchanged, and fall through to the
     +			 * rest of the loop. As a result:
    -+			 * - the merge commit is mapped to `last_commit` in
    -+			 *   `replayed_commits`, this will become the parent for
    -+			 *   the child commits.
    -+			 * - refs previously pointing to the merge commit are
    -+			 *   rewritten to point to the previous non-merge commit.
    ++			 * - refs pointing to the merge commit will be updated
    ++			 *   to `last_commit`.
    ++			 * - the next replayed commit uses `last_commit` as its
    ++			 *   `base`.
     +			 */
     +		} else {
     +			/*
    -+			 * pick_regular_commit() looks up the parent of `commit` in
    -+			 * `replayed_commits` to determine the ancestor to replay onto.
    -+			 * The `default_base` parameter is used when no ancestor is found,
    -+			 * which happens for the first commit in the revision range.
    -+			 * When reverting, commits are replayed in reverse order, so the
    -+			 * lookup never succeeds, and we need to pass `last_commit`.
    ++			 * Decide where to replay this commit onto.
    ++			 * If the parent commit was replayed already, the replayed result
    ++			 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
    ++			 * When reverting, commits are replayed in reverse order and thus
    ++			 * its parent isn't replayed yet. Therefore revert commits are
    ++			 * always replayed onto `last_commit`.
    ++			 * Also when opts->linearize is true, set the base to
    ++			 * `last_commit` to create a single linear history.
     +			 */
    -+			struct commit *base = onto;
    -+			if (mode == REPLAY_MODE_REVERT)
    ++			struct commit *parent = commit->parents ? commit->parents->item : NULL;
    ++			struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
    ++
    ++			if (opts->linearize || mode == REPLAY_MODE_REVERT)
     +				base = last_commit;
     +
     +			last_commit = pick_regular_commit(revs->repo, commit, base,
    -+							  replayed_commits,
     +							  &merge_opt, &result,
     +							  mode, opts->empty);
     +		}
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	test_line_count = 3 out
     +'
     +
    -+test_expect_success 'replay with --linearize to rebase multiple divergent branches' '
    ++test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' '
     +	git replay --ref-action=print --linearize \
    -+		--onto main ^B topic2 topic-with-merge >result &&
    ++		--onto main ^B topic2 topic3 topic4 >result &&
     +
    -+	test_line_count = 2 result &&
    ++	test_line_count = 3 result &&
     +	cut -f 3 -d " " result >new-branch-tips &&
     +
    -+	git log --format=%s $(head -n 1 new-branch-tips) >actual &&
    -+	test_write_lines E D C M L B A >expect &&
    ++	>expect &&
    ++	for i in 2 3 4
    ++	do
    ++		printf "update refs/heads/topic$i " >>expect &&
    ++		printf "%s " $(grep topic$i result | cut -f 3 -d " ") >>expect &&
    ++		git rev-parse topic$i >>expect || return 1
    ++	done &&
    ++
    ++	test_cmp expect result &&
    ++
    ++	test_write_lines           E D C M L B A >expect2 &&
    ++	test_write_lines     H G F E D C M L B A >expect3 &&
    ++	test_write_lines J I H G F E D C M L B A >expect4 &&
    ++
    ++	for i in 2 3 4
    ++	do
    ++		git log --format=%s $(grep topic$i result | cut -f 3 -d " ") >actual &&
    ++		test_cmp expect$i actual || return 1
    ++	done
    ++'
    ++
    ++test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '
    ++	test_when_finished "git update-ref -d refs/heads/divergent-x" &&
    ++	test_when_finished "git update-ref -d refs/heads/divergent-y" &&
    ++
    ++	# Build a real merge of two commits that diverged from a common base:
    ++	#
    ++	#       X - Z (divergent-x)
    ++	#      /   /
    ++	#  M  -  Y (divergent-y)
    ++	#
    ++	git switch -c divergent-x main &&
    ++	test_commit X &&
    ++	git switch -c divergent-y main &&
    ++	test_commit Y &&
    ++	git switch divergent-x &&
    ++	test_merge Z divergent-y --no-ff &&
    ++
    ++	git replay --ref-action=print --linearize \
    ++		--onto main main..divergent-x >result &&
    ++	test_line_count = 1 result &&
    ++	tip=$(cut -f 3 -d " " result) &&
    ++
    ++	# The merge Z is dropped, but both X and Y are linearized onto main;
    ++	# neither side is lost.
    ++	git log --format=%s main..$tip >actual &&
    ++	test_write_lines Y X >expect &&
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success '--linearize with --contained updates contained refs' '
    ++	git replay --ref-action=print --linearize --contained \
    ++		--onto main ^B topic-with-merge >result &&
    ++
    ++	test_line_count = 2 result &&
    ++
    ++	git log --format=%s $(head -n 1 result | cut -f 3 -d " ") >actual &&
    ++	test_write_lines J I M L B A >expect &&
     +	test_cmp expect actual &&
     +
    -+	git log --format=%s $(tail -n 1 new-branch-tips) >actual &&
    ++	git log --format=%s $(tail -n 1 result | cut -f 3 -d " ") >actual &&
     +	test_write_lines O N J I M L B A >expect &&
     +	test_cmp expect actual
     +'

--- base-commit: ab776a62a78576513ee121424adb19597fbb7613 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesJul 2, 2026, 17:58 UTC in reply to Toon Claes on lore

[PATCH v6 1/3] replay: add helper to put entry into replayed_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into mapped_commits into a helper function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index da531d5bc6..b9f8fc47ce 100644
--- a/replay.c
+++ b/replay.c
@@ -250,9 +250,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -263,6 +263,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -283,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -423,8 +438,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -436,11 +449,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJul 2, 2026, 17:58 UTC in reply to Toon Claes on lore

[PATCH v6 2/3] replay: resolve the replay base outside pick_regular_commit()

Depending on what gets passed into the function pick_regular_commit(), it decides the new base for the replayed commit. It first tries to find the replayed results of `pickme`'s parent in the `replayed_commits` map. If not found, it falls back to `onto`.

When using git-replay(1) with --onto, the fallback is the revision passed in with this option, but when using --revert, the fallback is `last_commit`.

It's rather confusing the base is decided partly inside pick_regular_commit() and partly by its caller.

Move the base selection completely into the caller: replay_revisions(). This bundles all the logic of deciding on the base together. Also, this reduces the number of parameters of pick_regular_commit(), making it's interface cleaner.

This refactoring doesn't bring any behavior changes.
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 34 +++++++++++++++++++++-------------
 1 file changed, 21 insertions(+), 13 deletions(-)
Show changes to replay.c +21 −13
diff --git a/replay.c b/replay.c
index b9f8fc47ce..5aee0eafbc 100644
--- a/replay.c
+++ b/replay.c
@@ -280,25 +280,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,
 
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
+					  struct commit *replayed_base,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
 					  enum replay_mode mode,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
-	if (pickme->parents) {
-		base = pickme->parents->item;
-		base_tree = repo_get_commit_tree(repo, base);
-	} else {
-		base = NULL;
+	if (pickme->parents)
+		base_tree = repo_get_commit_tree(repo, pickme->parents->item);
+	else
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
-	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -439,12 +433,26 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
+		/*
+		 * Decide where to replay this commit on.
+		 * If the parent commit was replayed already, the replayed result
+		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
+		 * When reverting, commits are replayed in reverse order and thus
+		 * its parent isn't replayed yet. Therefore revert commits are
+		 * always replayed onto `last_commit`.
+		 */
+		struct commit *parent = commit->parents ? commit->parents->item : NULL;
+		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
+
+		if (mode == REPLAY_MODE_REVERT)
+			base = last_commit;
+
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  mode == REPLAY_MODE_REVERT ? last_commit : onto,
-						  &merge_opt, &result, mode, opts->empty);
+		last_commit = pick_regular_commit(revs->repo, commit, base,
+						  &merge_opt, &result,
+						  mode, opts->empty);
 		if (!last_commit)
 			break;
 
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJul 2, 2026, 17:58 UTC in reply to Toon Claes on lore

[PATCH v6 3/3] replay: offer an option to linearize the commit topology

From: Johannes Schindelin <Johannes.Schindelin@gmx.de>

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the history into a sequence of regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same. Each replayed commit is stacked on top of the previously replayed one. When a merge is encountered, the commits reachable from all of its sides are replayed into the single line and the merge itself is dropped.

If a ref was pointing to a merge commit, that ref is updated to the merge's last replayed ancestor.

git-replay(1) accepts multiple revision ranges, for example:
    $ git replay --onto main topic1 topic2

Without `--linearize` this replays 'topic1' and 'topic2' onto 'main' independently and updates both refs.

With `--linearize` the whole set is flattened into one line: the ranges are stacked on top of each other rather than replayed side by side, so both refs end up pointing at different points along that single history.

Replaying all revision ranges into one single linear history is intentional and it's the only way to ensure predictable results. A user who wants to linearize ranges independently is advised to use separate git-replay(1) invocations.

Linearizing is a distinct operation, and flattening merge commits is just one aspect of that. Recreating merges would be a separate mode, so rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface, git-replay(1) uses its own `--linearize` option.

Co-authored-by: Toon Claes <toon@iotcl.com>
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  21 ++++++-
 builtin/replay.c              |   6 +-
 replay.c                      |  54 ++++++++++------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 203 insertions(+), 23 deletions(-)
Show changes to 5 files +203 −23

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..cc1d2bd251 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 --------
 [verse]
 (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
+			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
 
 DESCRIPTION
 -----------
@@ -88,6 +88,25 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, each replayed commit is stacked on top of the
+	previously replayed one, so all replayed commits are flattened into
+	a single linear history.
++
+When a merge commit is encountered, the behavior of git-rebase(1)'s
+option `--no-rebase-merges` is imitated. All commits in the range
+reachable from the merge commit are replayed into a linear history, and
+the merge commit itself is dropped. A ref that pointed to a merge commit
+is updated to the merge's last replayed ancestor.
++
+This flattens the `<revision-range>` as a whole. When multiple revision
+ranges are given they are stacked on top of each other into one linear
+history. Each of their refs is updated to point to its position in that
+history. To linearize ranges separately, replay them in separate `git
+replay` invocations.
++
+This option is incompatible with `--revert`.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..62962c73c7 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -85,7 +85,7 @@ int cmd_replay(int argc,
 	const char *const replay_usage[] = {
 		N_("(EXPERIMENTAL!) git replay "
 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
+		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
 		NULL
 	};
 	struct option replay_options[] = {
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("drop merge commits, replaying only non-merge commits")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.revert, "--revert",
+				  opts.linearize, "--linearize");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 5aee0eafbc..bd1f3bb898 100644
--- a/replay.c
+++ b/replay.c
@@ -433,26 +433,40 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		/*
-		 * Decide where to replay this commit on.
-		 * If the parent commit was replayed already, the replayed result
-		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
-		 * When reverting, commits are replayed in reverse order and thus
-		 * its parent isn't replayed yet. Therefore revert commits are
-		 * always replayed onto `last_commit`.
-		 */
-		struct commit *parent = commit->parents ? commit->parents->item : NULL;
-		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
-
-		if (mode == REPLAY_MODE_REVERT)
-			base = last_commit;
-
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(revs->repo, commit, base,
-						  &merge_opt, &result,
-						  mode, opts->empty);
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * Drop the merge commit: do not pick it, leave
+			 * `last_commit` unchanged, and fall through to the
+			 * rest of the loop. As a result:
+			 * - refs pointing to the merge commit will be updated
+			 *   to `last_commit`.
+			 * - the next replayed commit uses `last_commit` as its
+			 *   `base`.
+			 */
+		} else {
+			/*
+			 * Decide where to replay this commit onto.
+			 * If the parent commit was replayed already, the replayed result
+			 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
+			 * When reverting, commits are replayed in reverse order and thus
+			 * its parent isn't replayed yet. Therefore revert commits are
+			 * always replayed onto `last_commit`.
+			 * Also when opts->linearize is true, set the base to
+			 * `last_commit` to create a single linear history.
+			 */
+			struct commit *parent = commit->parents ? commit->parents->item : NULL;
+			struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
+
+			if (opts->linearize || mode == REPLAY_MODE_REVERT)
+				base = last_commit;
+
+			last_commit = pick_regular_commit(revs->repo, commit, base,
+							  &merge_opt, &result,
+							  mode, opts->empty);
+		}
+
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index faf95c7459..64f42b6512 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..e832e2c93d 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -52,8 +52,12 @@ test_expect_success 'setup' '
 	test_merge P O --no-ff &&
 	git switch main &&
 
+	git switch --orphan unrelated &&
+	test_commit unrelated-root &&
+
 	git switch -c conflict B &&
-	test_commit C.conflict C.t conflict
+	test_commit C.conflict C.t conflict &&
+	git branch -D unrelated
 '
 
 test_expect_success 'setup bare' '
@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '
 	test_grep "cannot be used together" actual
 '
 
+test_expect_success '--revert and --linearize cannot be used together' '
+	test_must_fail git replay --revert=main --linearize \
+		topic1..topic2 2>actual &&
+	test_grep "cannot be used together" actual
+'
+
 test_expect_success 'cannot advance target ... ordering would be ill-defined' '
 	echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
 	test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
@@ -565,4 +575,132 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
+	git replay --ref-action=print --linearize \
+		--onto unrelated-root topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I B A unrelated-root >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to cherry-pick merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--advance main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual &&
+
+	printf "update refs/heads/main " >expect &&
+	printf "%s " $(cut -f 3 -d " " result) >>expect &&
+	git rev-parse main >>expect &&
+	test_cmp expect result
+'
+
+test_expect_success 'replay --linearize produces the same patches' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# range-diff does not care about the dropped merge,
+	# so the original commits (I..topic-with-merge)
+	# and the replayed chain (main..tip) must produce identical patches.
+	git range-diff I..topic-with-merge main..$tip >out &&
+	test_file_not_empty out &&
+	test_grep ! -v "=" out &&
+
+	git log --oneline main..$tip >out &&
+	test_line_count = 3 out
+'
+
+test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' '
+	git replay --ref-action=print --linearize \
+		--onto main ^B topic2 topic3 topic4 >result &&
+
+	test_line_count = 3 result &&
+	cut -f 3 -d " " result >new-branch-tips &&
+
+	>expect &&
+	for i in 2 3 4
+	do
+		printf "update refs/heads/topic$i " >>expect &&
+		printf "%s " $(grep topic$i result | cut -f 3 -d " ") >>expect &&
+		git rev-parse topic$i >>expect || return 1
+	done &&
+
+	test_cmp expect result &&
+
+	test_write_lines           E D C M L B A >expect2 &&
+	test_write_lines     H G F E D C M L B A >expect3 &&
+	test_write_lines J I H G F E D C M L B A >expect4 &&
+
+	for i in 2 3 4
+	do
+		git log --format=%s $(grep topic$i result | cut -f 3 -d " ") >actual &&
+		test_cmp expect$i actual || return 1
+	done
+'
+
+test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '
+	test_when_finished "git update-ref -d refs/heads/divergent-x" &&
+	test_when_finished "git update-ref -d refs/heads/divergent-y" &&
+
+	# Build a real merge of two commits that diverged from a common base:
+	#
+	#       X - Z (divergent-x)
+	#      /   /
+	#  M  -  Y (divergent-y)
+	#
+	git switch -c divergent-x main &&
+	test_commit X &&
+	git switch -c divergent-y main &&
+	test_commit Y &&
+	git switch divergent-x &&
+	test_merge Z divergent-y --no-ff &&
+
+	git replay --ref-action=print --linearize \
+		--onto main main..divergent-x >result &&
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# The merge Z is dropped, but both X and Y are linearized onto main;
+	# neither side is lost.
+	git log --format=%s main..$tip >actual &&
+	test_write_lines Y X >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--linearize with --contained updates contained refs' '
+	git replay --ref-action=print --linearize --contained \
+		--onto main ^B topic-with-merge >result &&
+
+	test_line_count = 2 result &&
+
+	git log --format=%s $(head -n 1 result | cut -f 3 -d " ") >actual &&
+	test_write_lines J I M L B A >expect &&
+	test_cmp expect actual &&
+
+	git log --format=%s $(tail -n 1 result | cut -f 3 -d " ") >actual &&
+	test_write_lines O N J I M L B A >expect &&
+	test_cmp expect actual
+'
+
 test_done
-- 
2.53.0.1323.g189a785ab5
Junio C HamanoJul 3, 2026, 20:57 UTC in reply to Toon Claes on lore

Re: [PATCH v6 3/3] replay: offer an option to linearize the commit topology

Toon Claes <toon@iotcl.com> writes:
Show 17 quoted lines
> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>
> ...
> Linearizing is a distinct operation, and flattening merge commits is
> just one aspect of that. Recreating merges would be a separate mode, so
> rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,
> git-replay(1) uses its own `--linearize` option.
>
> Co-authored-by: Toon Claes <toon@iotcl.com>
> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> Signed-off-by: Toon Claes <toon@iotcl.com>
> ---
>  Documentation/git-replay.adoc |  21 ++++++-
>  builtin/replay.c              |   6 +-
>  replay.c                      |  54 ++++++++++------
>  replay.h                      |   5 ++
>  t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-
>  5 files changed, 203 insertions(+), 23 deletions(-)

With such an extensive change in behaviour, I wonder if Dscho is still responsible for latent bugs in this round of implementation and documentation, or should you take the responsibility over?

Show 16 quoted lines
> +--linearize::
> +	In this mode, each replayed commit is stacked on top of the
> +	previously replayed one, so all replayed commits are flattened into
> +	a single linear history.
> ++
> +When a merge commit is encountered, the behavior of git-rebase(1)'s
> +option `--no-rebase-merges` is imitated. All commits in the range
> +reachable from the merge commit are replayed into a linear history, and
> +the merge commit itself is dropped. A ref that pointed to a merge commit
> +is updated to the merge's last replayed ancestor.
> ++
> +This flattens the `<revision-range>` as a whole. When multiple revision
> +ranges are given they are stacked on top of each other into one linear
> +history. Each of their refs is updated to point to its position in that
> +history. To linearize ranges separately, replay them in separate `git
> +replay` invocations.
OK, very much understandable.
> +This option is incompatible with `--revert`.

Definitely it is OK to leave it outside the scope, but I am not sure if reverting a group of commits that happens to be "closed" and happens to contain merges, is inherently incompatible with flattening. If you have

    ----O--A
         \  \
          B--M--C

and you want to revert what happened while the history advanced from O to M, I would naïvely expect that I can arrive at

    ----O--A
         \  \
          B--M--C-B'-A'
by linearly applying the inverse of A and B (in either order).

If it is an inherent limitation, then the sentence may want "because ..." at the end. Otherwise, it would make more sense to strike the sentence from the main text, and have BUGS (or LIMITATIONS) section at the end of the page, perhaps?

Toon ClaesJul 7, 2026, 15:09 UTC in reply to Junio C Hamano on lore

Re: [PATCH v6 3/3] replay: offer an option to linearize the commit topology

Junio C Hamano <gitster@pobox.com> writes:
Show 23 quoted lines
> Toon Claes <toon@iotcl.com> writes:
>
>> From: Johannes Schindelin <Johannes.Schindelin@gmx.de>
>> ...
>> Linearizing is a distinct operation, and flattening merge commits is
>> just one aspect of that. Recreating merges would be a separate mode, so
>> rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,
>> git-replay(1) uses its own `--linearize` option.
>>
>> Co-authored-by: Toon Claes <toon@iotcl.com>
>> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
>> Signed-off-by: Toon Claes <toon@iotcl.com>
>> ---
>>  Documentation/git-replay.adoc |  21 ++++++-
>>  builtin/replay.c              |   6 +-
>>  replay.c                      |  54 ++++++++++------
>>  replay.h                      |   5 ++
>>  t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-
>>  5 files changed, 203 insertions(+), 23 deletions(-)
>
> With such an extensive change in behaviour, I wonder if Dscho is
> still responsible for latent bugs in this round of implementation
> and documentation, or should you take the responsibility over?
I don't mind. But I don't want to steal credit.

Initially, when Dscho shared the patch with me, he already informed me he doesn't really care about authorship. So in the next version I'll be taking over authorship and add a Based-on-patches-by trailer.

@Dscho, let me know if you disagree?
Show 38 quoted lines
>> +--linearize::
>> +	In this mode, each replayed commit is stacked on top of the
>> +	previously replayed one, so all replayed commits are flattened into
>> +	a single linear history.
>> ++
>> +When a merge commit is encountered, the behavior of git-rebase(1)'s
>> +option `--no-rebase-merges` is imitated. All commits in the range
>> +reachable from the merge commit are replayed into a linear history, and
>> +the merge commit itself is dropped. A ref that pointed to a merge commit
>> +is updated to the merge's last replayed ancestor.
>> ++
>> +This flattens the `<revision-range>` as a whole. When multiple revision
>> +ranges are given they are stacked on top of each other into one linear
>> +history. Each of their refs is updated to point to its position in that
>> +history. To linearize ranges separately, replay them in separate `git
>> +replay` invocations.
>
> OK, very much understandable.
>
>> +This option is incompatible with `--revert`.
>
> Definitely it is OK to leave it outside the scope, but I am not sure
> if reverting a group of commits that happens to be "closed" and
> happens to contain merges, is inherently incompatible with
> flattening.  If you have
>
>     ----O--A
>          \  \
>           B--M--C
>
> and you want to revert what happened while the history advanced from
> O to M, I would naïvely expect that I can arrive at
>
>     ----O--A
>          \  \
>           B--M--C-B'-A'
>
> by linearly applying the inverse of A and B (in either order).

You're absolutely right. Personally I'm not sure why the limitation was introduced. I've done some testing and I cannot see why we wouldn't allow --revert and --linearize to be combined. So I'll be submitting v7 without this restriction.

-- 
Cheers,
Toon
Toon ClaesJul 7, 2026, 19:07 UTC in reply to Toon Claes on lore

[PATCH v7 0/3] Teach git-replay(1) to linearize merge commits

As an alternative to dscho's patch series to replay merges[1], add an option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. This patch was kindly provided by Dscho, which I've tweaked to be upstreamed.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

This series might conflict with Kristoffer's series to make documentation changes[2], but should be trivial to resolve. And I don't think there's a conflict with Patrick's series on adding "drop" to git-history(1)[3].

dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com> [2]: <V3_CV_doc_replay_config.780@msgid.xyz> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>

---
Changes in v7:
- Allow --revert and --linearize to be used together.
- Because quite a lot of changes have been made since the original
  patch, change author from Johannes to Toon for the last commit.
  Johannes already told me he doesn't really care about authorship when
  he initially shared the patch with me.
- Link to v6: https://patch.msgid.link/20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com
Changes in v6:
- Reworked the second commit that moves picking the base completely
  outside pick_regular_commit(), instead of adding more explanation.
- Drastically extended the commit message on commit #3.
- Extended docs on flattening multiple revision ranges and how it's
  different from git-rebase(1)'s --no-rebase-merges.
- Added a bunch of tests to cover various scenarios.
- Remove newline from BUG() message.
- Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com
Changes in v5:
- Dropped the enum->bool patch and instead added a patch that better
  explains how pick_regular_commit() picks a base.
- Order of commits is shuffled.
- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool
  patch, I extended the code comments to explain how things work. This
  made me realize the use of the "replayed_base" was incorrect when
  multiple branches are rebased with --onto. This is fixed now and a
  test is added for this scenario.
- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com
Changes in v4:
- Use test_grep instead of a bare grep in the range-diff test, to
  prepare for mm/test-grep-lint.
- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com
Changes in v3:
- Add --linearize to Documentation SYNOPSIS, and mention it's
  incompatible with --revert.
- Small language change in help message for --linearize.
- Rephrase comment to include last_commit isn't modified when
  linearizing merges.
- Remove test that was added in earlier versions, but actually is
  a duplicate of 'replaying merge commits is not supported yet'.
- Add test to verify --revert and --linearize are incompatible.
- Properly test that replaying down to root with --linearize works.
- Add test for --linearize with --advance.
- Add test that uses git-range-diff(1) to verify the patches created by
  --linearize are correct.
- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com
Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
---
Toon Claes (3):
      replay: add helper to put entry into replayed_commits
      replay: resolve the replay base outside pick_regular_commit()
      replay: offer an option to linearize the commit topology
 Documentation/git-replay.adoc |  19 +++++-
 builtin/replay.c              |   4 +-
 replay.c                      |  81 ++++++++++++++++--------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 221 insertions(+), 28 deletions(-)
Range-diff versus v6:
1:  96637c42a9 ! 1:  ce24fba6d6 replay: add helper to put entry into replayed_commits
    @@ Commit message
         replay: add helper to put entry into replayed_commits
     
         The function replay_revisions() in replay.c is rather lengthy. Extract
    -    the logic to put a commit entry into mapped_commits into a helper
    -    function put_mapped_commit().
    +    the logic to put a commit entry into a `struct mapped_commits` into a
    +    helper function put_mapped_commit().
     
         While at it, rename mapped_commit() to get_mapped_commit() to pair with
         this new function.
2:  ae6c27aee6 ! 2:  6a39274c1c replay: resolve the replay base outside pick_regular_commit()
    @@ Commit message
     
         Move the base selection completely into the caller: replay_revisions().
         This bundles all the logic of deciding on the base together. Also, this
    -    reduces the number of parameters of pick_regular_commit(), making it's
    +    reduces the number of parameters of pick_regular_commit(), making its
         interface cleaner.
     
         This refactoring doesn't bring any behavior changes.
3:  0208101e9b ! 3:  2960b9fdaf replay: offer an option to linearize the commit topology
    @@
      ## Metadata ##
    -Author: Johannes Schindelin <Johannes.Schindelin@gmx.de>
    +Author: Toon Claes <toon@iotcl.com>
     
      ## Commit message ##
         replay: offer an option to linearize the commit topology
    @@ Commit message
         rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,
         git-replay(1) uses its own `--linearize` option.
     
    -    Co-authored-by: Toon Claes <toon@iotcl.com>
    -    Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
    +    Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
         Signed-off-by: Toon Claes <toon@iotcl.com>
     
      ## Documentation/git-replay.adoc ##
    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif
     +history. Each of their refs is updated to point to its position in that
     +history. To linearize ranges separately, replay them in separate `git
     +replay` invocations.
    -++
    -+This option is incompatible with `--revert`.
     +
      <revision-range>::
      	Range of commits to replay; see "Specifying Ranges" in
    @@ builtin/replay.c: int cmd_replay(int argc,
      		OPT_END()
      	};
      
    -@@ builtin/replay.c: int cmd_replay(int argc,
    - 				  opts.contained, "--contained");
    - 	die_for_incompatible_opt2(!!opts.ref, "--ref",
    - 				  !!opts.contained, "--contained");
    -+	die_for_incompatible_opt2(!!opts.revert, "--revert",
    -+				  opts.linearize, "--linearize");
    - 
    - 	/* Parse ref action mode from command line or config */
    - 	ref_mode = get_ref_action_mode(repo, ref_action);
     
      ## replay.c ##
     @@ replay.c: int replay_revisions(struct rev_info *revs,
    @@ t/t3650-replay-basics.sh: test_expect_success 'setup' '
      	git switch -c conflict B &&
     -	test_commit C.conflict C.t conflict
     +	test_commit C.conflict C.t conflict &&
    -+	git branch -D unrelated
    ++	git branch -D unrelated &&
    ++
    ++	git switch -c divergent-x main &&
    ++	test_commit X &&
    ++	git switch -c divergent-y main &&
    ++	test_commit Y &&
    ++	git switch divergent-x &&
    ++	test_merge Z divergent-y --no-ff
      '
      
      test_expect_success 'setup bare' '
    -@@ t/t3650-replay-basics.sh: test_expect_success '--advance and --contained cannot be used together' '
    - 	test_grep "cannot be used together" actual
    - '
    - 
    -+test_expect_success '--revert and --linearize cannot be used together' '
    -+	test_must_fail git replay --revert=main --linearize \
    -+		topic1..topic2 2>actual &&
    -+	test_grep "cannot be used together" actual
    -+'
    -+
    - test_expect_success 'cannot advance target ... ordering would be ill-defined' '
    - 	echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
    - 	test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
     @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' '
      	test_grep "cannot be used with multiple revision ranges" err
      '
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +'
     +
     +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '
    -+	test_when_finished "git update-ref -d refs/heads/divergent-x" &&
    -+	test_when_finished "git update-ref -d refs/heads/divergent-y" &&
    -+
    -+	# Build a real merge of two commits that diverged from a common base:
    -+	#
    -+	#       X - Z (divergent-x)
    -+	#      /   /
    -+	#  M  -  Y (divergent-y)
    -+	#
    -+	git switch -c divergent-x main &&
    -+	test_commit X &&
    -+	git switch -c divergent-y main &&
    -+	test_commit Y &&
    -+	git switch divergent-x &&
    -+	test_merge Z divergent-y --no-ff &&
    -+
     +	git replay --ref-action=print --linearize \
     +		--onto main main..divergent-x >result &&
     +	test_line_count = 1 result &&
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	test_write_lines O N J I M L B A >expect &&
     +	test_cmp expect actual
     +'
    ++
    ++test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '
    ++	git replay --ref-action=print --revert=divergent-x --linearize \
    ++		main..divergent-x >result &&
    ++	test_line_count = 1 result &&
    ++	tip=$(cut -f 3 -d " " result) &&
    ++
    ++	git log --format=%s $tip >actual &&
    ++	test_write_lines \
    ++		"Revert \"X\"" "Revert \"Y\"" Z Y X M L B A >expect &&
    ++	test_cmp expect actual &&
    ++
    ++	test_must_fail git cat-file -e $tip:X.t &&
    ++	test_must_fail git cat-file -e $tip:Y.t
    ++'
     +
      test_done

--- base-commit: ab776a62a78576513ee121424adb19597fbb7613 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesJul 7, 2026, 19:07 UTC in reply to Toon Claes on lore

[PATCH v7 1/3] replay: add helper to put entry into replayed_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into a `struct mapped_commits` into a helper function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index da531d5bc6..b9f8fc47ce 100644
--- a/replay.c
+++ b/replay.c
@@ -250,9 +250,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -263,6 +263,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -283,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -423,8 +438,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -436,11 +449,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJul 7, 2026, 19:07 UTC in reply to Toon Claes on lore

[PATCH v7 2/3] replay: resolve the replay base outside pick_regular_commit()

Depending on what gets passed into the function pick_regular_commit(), it decides the new base for the replayed commit. It first tries to find the replayed results of `pickme`'s parent in the `replayed_commits` map. If not found, it falls back to `onto`.

When using git-replay(1) with --onto, the fallback is the revision passed in with this option, but when using --revert, the fallback is `last_commit`.

It's rather confusing the base is decided partly inside pick_regular_commit() and partly by its caller.

Move the base selection completely into the caller: replay_revisions(). This bundles all the logic of deciding on the base together. Also, this reduces the number of parameters of pick_regular_commit(), making its interface cleaner.

This refactoring doesn't bring any behavior changes.
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 34 +++++++++++++++++++++-------------
 1 file changed, 21 insertions(+), 13 deletions(-)
Show changes to replay.c +21 −13
diff --git a/replay.c b/replay.c
index b9f8fc47ce..5aee0eafbc 100644
--- a/replay.c
+++ b/replay.c
@@ -280,25 +280,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,
 
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
+					  struct commit *replayed_base,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
 					  enum replay_mode mode,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
-	if (pickme->parents) {
-		base = pickme->parents->item;
-		base_tree = repo_get_commit_tree(repo, base);
-	} else {
-		base = NULL;
+	if (pickme->parents)
+		base_tree = repo_get_commit_tree(repo, pickme->parents->item);
+	else
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
-	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -439,12 +433,26 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
+		/*
+		 * Decide where to replay this commit on.
+		 * If the parent commit was replayed already, the replayed result
+		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
+		 * When reverting, commits are replayed in reverse order and thus
+		 * its parent isn't replayed yet. Therefore revert commits are
+		 * always replayed onto `last_commit`.
+		 */
+		struct commit *parent = commit->parents ? commit->parents->item : NULL;
+		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
+
+		if (mode == REPLAY_MODE_REVERT)
+			base = last_commit;
+
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  mode == REPLAY_MODE_REVERT ? last_commit : onto,
-						  &merge_opt, &result, mode, opts->empty);
+		last_commit = pick_regular_commit(revs->repo, commit, base,
+						  &merge_opt, &result,
+						  mode, opts->empty);
 		if (!last_commit)
 			break;
 
-- 
2.53.0.1323.g189a785ab5
Toon ClaesJul 7, 2026, 19:07 UTC in reply to Toon Claes on lore

[PATCH v7 3/3] replay: offer an option to linearize the commit topology

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the history into a sequence of regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same. Each replayed commit is stacked on top of the previously replayed one. When a merge is encountered, the commits reachable from all of its sides are replayed into the single line and the merge itself is dropped.

If a ref was pointing to a merge commit, that ref is updated to the merge's last replayed ancestor.

git-replay(1) accepts multiple revision ranges, for example:
    $ git replay --onto main topic1 topic2

Without `--linearize` this replays 'topic1' and 'topic2' onto 'main' independently and updates both refs.

With `--linearize` the whole set is flattened into one line: the ranges are stacked on top of each other rather than replayed side by side, so both refs end up pointing at different points along that single history.

Replaying all revision ranges into one single linear history is intentional and it's the only way to ensure predictable results. A user who wants to linearize ranges independently is advised to use separate git-replay(1) invocations.

Linearizing is a distinct operation, and flattening merge commits is just one aspect of that. Recreating merges would be a separate mode, so rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface, git-replay(1) uses its own `--linearize` option.

Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  19 +++++-
 builtin/replay.c              |   4 +-
 replay.c                      |  54 ++++++++++------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      | 140 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 199 insertions(+), 23 deletions(-)
Show changes to 5 files +199 −23

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..98e20c1c6e 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 --------
 [verse]
 (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
+			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
 
 DESCRIPTION
 -----------
@@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, each replayed commit is stacked on top of the
+	previously replayed one, so all replayed commits are flattened into
+	a single linear history.
++
+When a merge commit is encountered, the behavior of git-rebase(1)'s
+option `--no-rebase-merges` is imitated. All commits in the range
+reachable from the merge commit are replayed into a linear history, and
+the merge commit itself is dropped. A ref that pointed to a merge commit
+is updated to the merge's last replayed ancestor.
++
+This flattens the `<revision-range>` as a whole. When multiple revision
+ranges are given they are stacked on top of each other into one linear
+history. Each of their refs is updated to point to its position in that
+history. To linearize ranges separately, replay them in separate `git
+replay` invocations.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..5e6ff4191a 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -85,7 +85,7 @@ int cmd_replay(int argc,
 	const char *const replay_usage[] = {
 		N_("(EXPERIMENTAL!) git replay "
 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
+		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
 		NULL
 	};
 	struct option replay_options[] = {
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("drop merge commits, replaying only non-merge commits")),
 		OPT_END()
 	};
 
diff --git a/replay.c b/replay.c
index 5aee0eafbc..bd1f3bb898 100644
--- a/replay.c
+++ b/replay.c
@@ -433,26 +433,40 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		/*
-		 * Decide where to replay this commit on.
-		 * If the parent commit was replayed already, the replayed result
-		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
-		 * When reverting, commits are replayed in reverse order and thus
-		 * its parent isn't replayed yet. Therefore revert commits are
-		 * always replayed onto `last_commit`.
-		 */
-		struct commit *parent = commit->parents ? commit->parents->item : NULL;
-		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
-
-		if (mode == REPLAY_MODE_REVERT)
-			base = last_commit;
-
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(revs->repo, commit, base,
-						  &merge_opt, &result,
-						  mode, opts->empty);
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * Drop the merge commit: do not pick it, leave
+			 * `last_commit` unchanged, and fall through to the
+			 * rest of the loop. As a result:
+			 * - refs pointing to the merge commit will be updated
+			 *   to `last_commit`.
+			 * - the next replayed commit uses `last_commit` as its
+			 *   `base`.
+			 */
+		} else {
+			/*
+			 * Decide where to replay this commit onto.
+			 * If the parent commit was replayed already, the replayed result
+			 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
+			 * When reverting, commits are replayed in reverse order and thus
+			 * its parent isn't replayed yet. Therefore revert commits are
+			 * always replayed onto `last_commit`.
+			 * Also when opts->linearize is true, set the base to
+			 * `last_commit` to create a single linear history.
+			 */
+			struct commit *parent = commit->parents ? commit->parents->item : NULL;
+			struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
+
+			if (opts->linearize || mode == REPLAY_MODE_REVERT)
+				base = last_commit;
+
+			last_commit = pick_regular_commit(revs->repo, commit, base,
+							  &merge_opt, &result,
+							  mode, opts->empty);
+		}
+
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index faf95c7459..64f42b6512 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..4d3d442e8a 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -52,8 +52,19 @@ test_expect_success 'setup' '
 	test_merge P O --no-ff &&
 	git switch main &&
 
+	git switch --orphan unrelated &&
+	test_commit unrelated-root &&
+
 	git switch -c conflict B &&
-	test_commit C.conflict C.t conflict
+	test_commit C.conflict C.t conflict &&
+	git branch -D unrelated &&
+
+	git switch -c divergent-x main &&
+	test_commit X &&
+	git switch -c divergent-y main &&
+	test_commit Y &&
+	git switch divergent-x &&
+	test_merge Z divergent-y --no-ff
 '
 
 test_expect_success 'setup bare' '
@@ -565,4 +576,131 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
+	git replay --ref-action=print --linearize \
+		--onto unrelated-root topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I B A unrelated-root >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to cherry-pick merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--advance main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual &&
+
+	printf "update refs/heads/main " >expect &&
+	printf "%s " $(cut -f 3 -d " " result) >>expect &&
+	git rev-parse main >>expect &&
+	test_cmp expect result
+'
+
+test_expect_success 'replay --linearize produces the same patches' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# range-diff does not care about the dropped merge,
+	# so the original commits (I..topic-with-merge)
+	# and the replayed chain (main..tip) must produce identical patches.
+	git range-diff I..topic-with-merge main..$tip >out &&
+	test_file_not_empty out &&
+	test_grep ! -v "=" out &&
+
+	git log --oneline main..$tip >out &&
+	test_line_count = 3 out
+'
+
+test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' '
+	git replay --ref-action=print --linearize \
+		--onto main ^B topic2 topic3 topic4 >result &&
+
+	test_line_count = 3 result &&
+	cut -f 3 -d " " result >new-branch-tips &&
+
+	>expect &&
+	for i in 2 3 4
+	do
+		printf "update refs/heads/topic$i " >>expect &&
+		printf "%s " $(grep topic$i result | cut -f 3 -d " ") >>expect &&
+		git rev-parse topic$i >>expect || return 1
+	done &&
+
+	test_cmp expect result &&
+
+	test_write_lines           E D C M L B A >expect2 &&
+	test_write_lines     H G F E D C M L B A >expect3 &&
+	test_write_lines J I H G F E D C M L B A >expect4 &&
+
+	for i in 2 3 4
+	do
+		git log --format=%s $(grep topic$i result | cut -f 3 -d " ") >actual &&
+		test_cmp expect$i actual || return 1
+	done
+'
+
+test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '
+	git replay --ref-action=print --linearize \
+		--onto main main..divergent-x >result &&
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# The merge Z is dropped, but both X and Y are linearized onto main;
+	# neither side is lost.
+	git log --format=%s main..$tip >actual &&
+	test_write_lines Y X >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--linearize with --contained updates contained refs' '
+	git replay --ref-action=print --linearize --contained \
+		--onto main ^B topic-with-merge >result &&
+
+	test_line_count = 2 result &&
+
+	git log --format=%s $(head -n 1 result | cut -f 3 -d " ") >actual &&
+	test_write_lines J I M L B A >expect &&
+	test_cmp expect actual &&
+
+	git log --format=%s $(tail -n 1 result | cut -f 3 -d " ") >actual &&
+	test_write_lines O N J I M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '
+	git replay --ref-action=print --revert=divergent-x --linearize \
+		main..divergent-x >result &&
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	git log --format=%s $tip >actual &&
+	test_write_lines \
+		"Revert \"X\"" "Revert \"Y\"" Z Y X M L B A >expect &&
+	test_cmp expect actual &&
+
+	test_must_fail git cat-file -e $tip:X.t &&
+	test_must_fail git cat-file -e $tip:Y.t
+'
+
 test_done
-- 
2.53.0.1323.g189a785ab5
Junio C HamanoJul 7, 2026, 19:35 UTC in reply to Toon Claes on lore

Re: [PATCH v6 3/3] replay: offer an option to linearize the commit topology

Toon Claes <toon@iotcl.com> writes:
Show 24 quoted lines
> Junio C Hamano <gitster@pobox.com> writes:
>
>> Definitely it is OK to leave it outside the scope, but I am not sure
>> if reverting a group of commits that happens to be "closed" and
>> happens to contain merges, is inherently incompatible with
>> flattening.  If you have
>>
>>     ----O--A
>>          \  \
>>           B--M--C
>>
>> and you want to revert what happened while the history advanced from
>> O to M, I would naïvely expect that I can arrive at
>>
>>     ----O--A
>>          \  \
>>           B--M--C-B'-A'
>>
>> by linearly applying the inverse of A and B (in either order).
>
> You're absolutely right. Personally I'm not sure why the limitation was
> introduced. I've done some testing and I cannot see why we wouldn't
> allow --revert and --linearize to be combined. So I'll be submitting v7
> without this restriction.

Of course, postponing this is a safe option (at least for our initial effort) *if* we cannot reliably detect the good case.

For example, it is unclear what happens if the linearized range in the diagram above contains M and A, but not B or O. We might want to distinguish that scenario from the depicted case, where all of A, B, and O, as well as M, are in the range, but the current code may not be able to do so reliably. However, if we can consistently provide behavior that is logical and easy to explain, it would be ideal to lift this artificial restriction.

Thanks.
Junio C HamanoJul 8, 2026, 01:02 UTC in reply to Toon Claes on lore

Re: [PATCH v7 0/3] Teach git-replay(1) to linearize merge commits

Toon Claes <toon@iotcl.com> writes:
Show 22 quoted lines
> This series might conflict with Kristoffer's series to make
> documentation changes[2], but should be trivial to resolve. And I don't
> think there's a conflict with Patrick's series on adding "drop" to
> git-history(1)[3].
>
> dscho's series to replay merges[1] needs a bit of rework to fit on top
> of this, but I'm happy to help figuring that out. We've been discussing
> to either name the option --flatten or --linearize, but I've decided on
> "linearize" because the documentation of git-rebase(1) also mentions
> "linearize".
>
> [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>
> [2]: <V3_CV_doc_replay_config.780@msgid.xyz>
> [3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>
>
> ---
> Changes in v7:
> - Allow --revert and --linearize to be used together.
> - Because quite a lot of changes have been made since the original
>   patch, change author from Johannes to Toon for the last commit.
>   Johannes already told me he doesn't really care about authorship when
>   he initially shared the patch with me.

Looks like all the previous review comments have been answered and the topic is in a good shape to be merged to 'next' (and allow us to polish incrementally as needed)?

Thanks for working on the topic.  Let me mark it for 'next'.
Elijah NewrenJul 10, 2026, 03:47 UTC in reply to Toon Claes on lore

Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology

Hi Toon!

Thanks for continuing to work on the series. Sorry that I've been out on vacation for 3+ weeks and then playing catch up. You addressed all my v2 feedback, and most things in this latest v7 look good. I do have one substantive concern with this patch, which I'll cover in detail below.

On Tue, Jul 7, 2026 at 12:07 PM Toon Claes <toon@iotcl.com> wrote:
Show 10 quoted lines
>
> One of the stated goals of git-replay(1) is to allow implementing the
> git-rebase(1) functionality on the server side.
>
> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
> was given. This mode drops merge commits instead of replaying them, and
> linearizes the history into a sequence of regular (single-parent)
> commits.
>
> Add option `--linearize` to git-replay(1) to do the same.

Right, `--linearize` exists to change how merges are handled. I'd argue that if there are no merges, then you should get the same behavior whether or not --linearize appears on your command line.

Show 7 quoted lines
> Each replayed
> commit is stacked on top of the previously replayed one. When a merge is
> encountered, the commits reachable from all of its sides are replayed
> into the single line and the merge itself is dropped.
>
> If a ref was pointing to a merge commit, that ref is updated to the
> merge's last replayed ancestor.

This is a good description of the net effect of linearizing a single branch. I think it describes rebasing multiple branches at once much less well -- see below.

> git-replay(1) accepts multiple revision ranges, for example:

I think I know what you mean, but this isn't quite right: git-replay(1) only ever accepts a single revision range. From gitrevisions(7) (also in git-rev-parse(1)):

       Commands that are specifically designed to take two distinct ranges
       (e.g. "git range-diff R1 R2" to compare two ranges) do exist, but they
       are exceptions. Unless otherwise noted, all "git" commands that operate
       on a set of commits work on a single revision range. In other words,
       writing two "two-dot range notation" next to each other, e.g.
           $ git log A..B C..D
       does not specify two revision ranges for most commands. Instead it will
       name a single connected set of commits, i.e. those that are reachable
       from either B or D but are reachable from neither A or C.

You could say that replay accepts multiple branches (references) within its revision range -- but even then that comes with an "in some cases" qualifier: `--advance` (and more recently, `--revert`) specifically reject multiple positive refs, precisely because (a) simply concatenating branches is surprising, and (b) the resulting order is ill-defined (or at least looks arbitrary to the user).

>     $ git replay --onto main topic1 topic2
>
> Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
> independently and updates both refs.

And, if there are no merges anywhere in the range, I'd argue that adding --linearize either ought to do the same thing -- or else error out that multiple positive refs are not allowed with `--linearize`, the way `--advance` and `--revert` already do.

> With `--linearize` the whole set is flattened into one line: the ranges
> are stacked on top of each other rather than replayed side by side, so
> both refs end up pointing at different points along that single history.
To me, this is a significant principle of least astonishment violation.
> Replaying all revision ranges into one single linear history is
> intentional and it's the only way to ensure predictable results.
I have to push back on both "only" and "predictable".
Regarding "only", there are at least two other choices:
  * make --linearize incompatible with multiple positive refs
  * More involved implementation (quick sketch): (a) Track a
last_commit per branch specified on the command line, (b) Make the
revision walk keep track of which branches each walked commit is
reachable from, (c) for each commit to be replayed, for each branch
it's reachable from, update the appropriate last_commit[branch].
(Except that when last_commit[branchA] == last_commit[branchB] and a
commit is reachable from both branchA & branchB, you only replay the
commit once.)
Regarding "predictable", I'd like to split predictability into two
pieces: guessable by the user, and consistent with other replay
commands.  This behavior gives us neither:
  * guessable by the user:
    * which of the multiple branches specified on the command line is
first in your concatenated linearization?  It's decided by rev-walk,
not what the user wrote.
  * consistent:
    * why does a merge-free topology behave differently with
--linearize than without it?
    * why do `--advance` and `--revert` both refuse multiple positive
refs to avoid exactly this "which branch first" concatenation, while
`--onto --linearize` embraces it?

For what it's worth, looking back at the v5 thread, it seems the `base = last_commit` rule came in to fix the real bug Junio and Phillip pointed out there -- that without it, only one side of a linearized merge survived. That fix is clearly correct for the single-branch case. My worry is only that applying it unconditionally reintroduces the multiple-positive-refs ordering problem we deliberately avoid elsewhere. Making `--linearize` reject multiple positive refs would keep the merge-flattening fix while sidestepping this entirely.

> A user
> who wants to linearize ranges independently is advised to use separate
> git-replay(1) invocations.

Which, to me, is another argument for just disallowing multiple positive refs under `--linearize`: if the recommended way to do it is separate invocations anyway, we may as well require them.

> Linearizing is a distinct operation, and flattening merge commits is
> just one aspect of that. Recreating merges would be a separate mode, so
> rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,
> git-replay(1) uses its own `--linearize` option.
No disagreement here on this point.

Thanks, Elijah

Junio C HamanoJul 13, 2026, 22:09 UTC in reply to Elijah Newren on lore

Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology

Elijah Newren <newren@gmail.com> writes:
Show 16 quoted lines
> For what it's worth, looking back at the v5 thread, it seems the `base
> = last_commit` rule came in to fix the real bug Junio and Phillip
> pointed out there -- that without it, only one side of a linearized
> merge survived.  That fix is clearly correct for the single-branch
> case.  My worry is only that applying it unconditionally reintroduces
> the multiple-positive-refs ordering problem we deliberately avoid
> elsewhere.  Making `--linearize` reject multiple positive refs would
> keep the merge-flattening fix while sidestepping this entirely.
>
>> A user
>> who wants to linearize ranges independently is advised to use separate
>> git-replay(1) invocations.
>
> Which, to me, is another argument for just disallowing multiple
> positive refs under `--linearize`: if the recommended way to do it is
> separate invocations anyway, we may as well require them.

Hmph. To me, this is slightly different. It acts more like an escape hatch: "if you really do not want to mix unrelated things into a single linear history, you can do this other thing."

Stepping back, the unpredictable order of multiple merged lines of history exists even without multiple positive refs. If you have independent lines of development that were merged and you linearize them, someone must choose which line comes first. If you let the machinery make that decision, the resulting commit order may not reflect your preferences.

While I rarely perform octopus merges anymore, in situations where an octopus merge is appropriate (e.g., when you have N independent branches and their merge order does not matter), linearizing such a history into a random sequence of N segments, built on top of one another in an unspecified order, could actually be considered a feature. You do not have to make a decision about something that is inconsequential.

So, I am not convinced we should forbid this behavior to avoid dealing with a history containing merges or multiple positive tips.

When achieving a strictly linear history is the user's goal under the "--linearize" option, is it not inherent that there is no single "correct" order for these independent segments of history to appear in the final linear result?

Perhaps I am not reading you correctly, but that is how I read that escape hatch explanation.

Thanks.
Elijah NewrenJul 15, 2026, 07:34 UTC in reply to Junio C Hamano on lore

Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology

On Mon, Jul 13, 2026 at 3:09 PM Junio C Hamano <gitster@pobox.com> wrote:
Show 38 quoted lines
>
> Elijah Newren <newren@gmail.com> writes:
>
> > For what it's worth, looking back at the v5 thread, it seems the `base
> > = last_commit` rule came in to fix the real bug Junio and Phillip
> > pointed out there -- that without it, only one side of a linearized
> > merge survived.  That fix is clearly correct for the single-branch
> > case.  My worry is only that applying it unconditionally reintroduces
> > the multiple-positive-refs ordering problem we deliberately avoid
> > elsewhere.  Making `--linearize` reject multiple positive refs would
> > keep the merge-flattening fix while sidestepping this entirely.
> >
> >> A user
> >> who wants to linearize ranges independently is advised to use separate
> >> git-replay(1) invocations.
> >
> > Which, to me, is another argument for just disallowing multiple
> > positive refs under `--linearize`: if the recommended way to do it is
> > separate invocations anyway, we may as well require them.
>
> Hmph.  To me, this is slightly different.  It acts more like an
> escape hatch: "if you really do not want to mix unrelated things
> into a single linear history, you can do this other thing."
>
> Stepping back, the unpredictable order of multiple merged lines of
> history exists even without multiple positive refs.  If you have
> independent lines of development that were merged and you linearize
> them, someone must choose which line comes first.  If you let the
> machinery make that decision, the resulting commit order may not
> reflect your preferences.
>
> While I rarely perform octopus merges anymore, in situations where an
> octopus merge is appropriate (e.g., when you have N independent
> branches and their merge order does not matter), linearizing such
> a history into a random sequence of N segments, built on top of
> one another in an unspecified order, could actually be considered a
> feature.  You do not have to make a decision about something that is
> inconsequential.

You're right that when flattening merges within a single branch, the machinery must pick an order, and that's fine — unavoidable, even. My objection isn't that; it's primarily the concatenation of distinct branches named on the command line into one chain, and, as a secondary point, the ignoring of the order of branches explicitly specified by the user on the command line.

Concretely: I have three branches to rebase onto master; one of them
happens to contain a merge I'd like flattened. I add  --linearize  for
that one merge — and now all three branches are silently concatenated
into a single chain.  That makes no sense to me, and I think won't to
most users.

Anyway, I think I must have explained my position rather poorly; your response suggests I buried my main points, so let me try to restate them:

TL;DR version; my problems with the current implementation of
`--linearize` are that it:
  * Makes the rare usecase easy, and ignores the common usecase
  * Makes it asymmetrically difficult to recover for those that wanted
the common usecase instead of the easy
  * Makes `--linearize` mean something other than "remove non-linearity"
  * Turns multiple branches into one, but updates several branches anyway
  * Ignores order specified by the user on the command line
  * Introduces an inconsistency within git-replay between `--advance`
and `--linearize --onto`
(The last three items being minor compared to the first three.)
Longer version:
Consider the following history
M1  M2  M3  M4  M5
*---*---*---*---* <- master
    \   \
     \   \  A1  A2  A3  A4
      \   \-*---*---*---* <- branchA
       \        \
        \        -*---* <- branchC
         \        C1  C2
          \
           \-*---*---* <- branchB
            B1  B2  B3
git replay was designed to allow you to update all your branches at once.
For example, with this above history, running
    git replay --onto master branchA branchB branchC
will rebase all three branches onto master (and handles the shared portion
of history between branchA and branchC in the obvious way):
M1  M2  M3  M4  M5
*---*---*---*---* <- master
                |
                |  A1  A2  A3  A4
                |--*---*---*---* <- branchA
                |      \
                |       -*---* <- branchC
                |        C1  C2
                |
                \-*---*---* <- branchB
                  B1  B2  B3
With the current implementation of --linearize, adding that flag, i.e.
    git replay --linearize --onto master branchA branchB branchC
would instead give something like:
M1  M2  M3  M4  M5  B1  B2  B3  A1  A2  C1  C2  A3  A4
*---*---*---*---*---*---*---*---*---*---*---*---*---*
                ^           ^               ^       ^
                |           |               |       |
              master     branchB         branchC  branchA
This topology strikes me as something that users would very rarely ever
want.  Further, it:
  * Makes one question why branchB and branchC were kept instead of
    deleted; if the whole point is to concatenate the branches, then
    since whichever branch lands on top contains the other two, why not
    just get rid of the others?
  * Makes the command behave differently on *already linear* history
    when --linearize is added, which makes no sense to me.
  * (Minor point, but still confusing to me) Ignores the order of
    branches the user employed on the command line

Of course, the above involves no merges, so let's introduce one; consider the following alternate initial history:

M1  M2  M3  M4  M5
*---*---*---*---* <- master
    |   \
    |    \  A1  A2  A4  A6  A7  A8
    |     \-*---*---*---*---*---* <- branchA
    \            \     /    \
     \            *---*      -*---* <- branchC
      \           A3  A5      C1  C2
       \
        \-*---* <- branchB
          B1  B2
Replaying the three branches,
    git replay --onto master branchA branchB branchC
we would expect the base of the branches to simply be updated to current
master:
M1  M2  M3  M4  M5
*---*---*---*---* <- master
                |
                |   A1  A2  A4  A6  A7  A8
                |---*---*---*---*---*---* <- branchA
                |        \     /    \
                |         *---*      -*---* <- branchC
                |         A3  A5      C1  C2
                |
                \-*---* <- branchB
                  B1  B2
If you were to add --linearize, i.e.
    git replay --linearize --onto master branchA branchB branchC
I personally would expect:
M1  M2  M3  M4  M5
*---*---*---*---* <- master
                |
                |   A1  A2  A4  A3  A5  A7  A8
                |---*---*---*---*---*---*---* <- branchA
                |                       \
                |                        -*---* <- branchC
                |                         C1  C2
                |
                \-*---* <- branchB
                  B1  B2

In other words, `--linearize` should remove the non-linearity in the graph. Instead, the current implementation will return something like:

M1  M2  M3  M4  M5  A1  A2  A4  A3  A5  A7  C1  C2  B1  B2  A8
*---*---*---*---*---*---*---*---*---*---*---*---*---*---*---*
                ^                               ^       ^   ^
                |                               |       |    \
              master                         branchC branchB branchA
I can only imagine this rarely being useful to the user.
But to make it worse, please consider the difficulty of someone who
wanted the bottom graph but got the top one, vs. the difficulty of
someone who wanted the top graph but got the bottom one:
  * (wanted bottom, got top) Just rebase branchB and branchA again; easy
  * (wanted top, got bottom) You need to meticulously figure out the common
    points of history and which sets of commits belong to each branch in
    order to sequentially rebase each branch into the expected result.
In particular, the need to meticulously track start and endpoints with
individual
rebases was one of the reasons that led to `git replay` rather than improvements
to `git rebase`; the latter was so focused on single branches, that it
wasn't really
possible to extend to multiple branches.  It's thus rather
disappointing to see new
flags for `git replay` that make handling multiple branches more painful.

There's actually one more (admittedly minor) issue as well: it creates an inconsistency within git-replay itself. The `--advance` flag has a check to error out when multiple positive refs are specified solely because I thought it was weird to override the order of branches the user specified on the command line (and didn't want to implement something that could force the ordering of the revision walk); the error message even states "because the ordering would be ill-defined". For consistency, either both should be fine with ignoring the order of revisions specified by the user, or neither should be.

So, what to do?

Both paths I have in mind end at the same place; the only real question is whether the desired behavior lands in this series or as follow-up.

The minimal move is to make --linearize reject multiple positive refs for now (exactly as --advance and --revert already do), unblocking this series so it can merge down nearly as-is, and leave per-branch linearization as future work.

The complete move is to implement that desired behavior now, by tracking a last_commit per command-line branch so each branch is linearized independently.

The reason I am comfortable with erroring out as a stopgap: turning an error into working behavior later never breaks anyone, whereas letting the current concatenation semantics reach 'master' risks users coming to depend on them, which would make switching to the better behavior a compatibility break. Erroring now keeps our options open; merging as-is quietly closes them. (git-replay is still EXPERIMENTAL, so this is not fatal either way, but it seems better not to paint ourselves into a corner.)

For this series I would be perfectly happy with just the error; the per-branch last_commit tracking can come later.

Junio C HamanoJul 15, 2026, 18:49 UTC in reply to Elijah Newren on lore

Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology

Elijah Newren <newren@gmail.com> writes:
Show 6 quoted lines
> You're right that when flattening merges within a single branch, the
> machinery must pick an order, and that's fine — unavoidable, even.  My
> objection isn't that; it's primarily the concatenation of distinct
> branches named on the command line into one chain, and, as a secondary
> point, the ignoring of the order of branches explicitly specified by
> the user on the command line.

That is true, but a user who wishes to avoid flattening in an unspecified order can always choose to supply only one branch at a time on the command line.

Show 5 quoted lines
> Concretely: I have three branches to rebase onto master; one of them
> happens to contain a merge I'd like flattened. I add  --linearize  for
> that one merge — and now all three branches are silently concatenated
> into a single chain.  That makes no sense to me, and I think won't to
> most users.

But if that is not the outcome they wanted, I fail to see why they would feed all three branches to a single invocation of --linearize in the first place. After all, the command is only doing what it was asked to do.

Show 25 quoted lines
> Consider the following history
>
> M1  M2  M3  M4  M5
> *---*---*---*---* <- master
>     \   \
>      \   \  A1  A2  A3  A4
>       \   \-*---*---*---* <- branchA
>        \        \
>         \        -*---* <- branchC
>          \        C1  C2
>           \
>            \-*---*---* <- branchB
>             B1  B2  B3
>
> git replay was designed to allow you to update all your branches at once.
> For example, with this above history, running
>     git replay --onto master branchA branchB branchC
> will rebase all three branches onto master (and handles the shared portion
> of history between branchA and branchC in the obvious way):
> ...
> M1  M2  M3  M4  M5  B1  B2  B3  A1  A2  C1  C2  A3  A4
> *---*---*---*---*---*---*---*---*---*---*---*---*---*
>                 ^           ^               ^       ^
>                 |           |               |       |
>               master     branchB         branchC  branchA

If that is not what you want, why did you give all three to the single invocation? If you want A's and B's all consecutive, linearlize branchA on top of 'master', and brnachB on top of it, and branch C on top, perhaps?

If that breaks because by the time you feed branchC to the machinery nobody remembers that A1 and A2 were already handled, _that_ is the problem the command needs to solve, no? I am confused.

Or do you want to be able to tell "linearlize B, A, and C in this turn on top of 'master'" and M1..M5..B1'..B3'..A1'..A4'..C1'..C2' as the result? That would mean the command line syntax cannot be an arbitrary rev list range, but limited to a single negative plus one or more positive revision, which may be very limited but is much less error prone for casual users.

Elijah NewrenJul 16, 2026, 03:53 UTC in reply to Junio C Hamano on lore

Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology

On Wed, Jul 15, 2026 at 11:49 AM Junio C Hamano <gitster@pobox.com> wrote:
Show 13 quoted lines
>
> Elijah Newren <newren@gmail.com> writes:
>
> > Concretely: I have three branches to rebase onto master; one of them
> > happens to contain a merge I'd like flattened. I add  --linearize  for
> > that one merge — and now all three branches are silently concatenated
> > into a single chain.  That makes no sense to me, and I think won't to
> > most users.
>
> But if that is not the outcome they wanted, I fail to see why they
> would feed all three branches to a single invocation of --linearize
> in the first place.  After all, the command is only doing what it
> was asked to do.

Passing several branches isn't the user asking for concatenation; it's the user asking for replay's core feature: update many branches at once. Adding --linearize to flatten a merge does have to join the lines which that merge combined, but it shouldn't also weld together branches that were never merged in the first place. The user is combining two intended features, and the concatenation is an emergent third behavior that neither of them implies.

(Also, please note that I'm aware of the bug you raised earlier about dropped lines of history; my suggestion(s) don't reintroduce that bug.)

> If that breaks because by the time you feed branchC to the machinery
> nobody remembers that A1 and A2 were already handled, _that_ is the
> problem the command needs to solve, no?  I am confused.

Yes, precisely! That is the problem I want to be able to solve: updating multiple branches which may have shared history. The current proposed behavior feels hostile towards that. Concretely, I want to be able to update this history:

M1  M2  M3  M4  M5
*---*---*---*---* <- main
    |   \
    |    \  A1  A2  A4  A6  A7  A8
    |     \-*---*---*---*---*---* <- branchA
    \            \     /    \
     \            *---*      -*---* <- branchC
      \           A3  A5      C1  C2
       \
        \-*---* <- branchB
          B1  B2

via `git replay --linearize --onto main branchA branchB branchC` to (depending on where A4 is ordered relative to A3 & A5):

M1  M2  M3  M4  M5
*---*---*---*---* <- main
                |
                |   A1  A2  A4  A3  A5  A7  A8
                |---*---*---*---*---*---*---* <- branchA
                |                       \
                |                        -*---* <- branchC
                |                         C1  C2
                |
                \-*---* <- branchB
                  B1  B2

(note that both branchA and branchC become linear with the merge commit A6 being dropped)

In this graph:
  * branchA and branchC cannot easily be replayed with separate
commands (it requires tracking starting and stopping points and
figuring out shared history).
  * branchB could be done with a separate command from replaying the
other two, but _only if_ the user first verifies that it has no common
history with the other branches, and I think that's not useful
cognitive load to place on the user.

If concatenation really is the intended behavior for this patch series, then --linearize seems like the wrong name for it: the surprising part isn't that each branch becomes linear, it's that the option also chains together branches that were never related.

> Or do you want to be able to tell "linearlize B, A, and C in this
> turn on top of 'master'" and M1..M5..B1'..B3'..A1'..A4'..C1'..C2' as
> the result?

No, ordered-concatenation is not something I'm interested in. I want separate branches to stay separate, as in the second graph above. I almost wish I hadn't even mentioned ordering, even though I labelled it a "minor" point in my last email, because it seems to have distracted from the real issue.

As I proposed last time, I'd be fine with erroring on multiple positive refs as an interim step (plus associated documentation and commit message updates) so this series lands, with per-branch linearization as the real fix later.

Junio C HamanoJul 17, 2026, 14:57 UTC in reply to Elijah Newren on lore

Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology

Elijah Newren <newren@gmail.com> writes:
Show 16 quoted lines
> On Wed, Jul 15, 2026 at 11:49 AM Junio C Hamano <gitster@pobox.com> wrote:
>>
>> Elijah Newren <newren@gmail.com> writes:
>>
>> But if that is not the outcome they wanted, I fail to see why they
>> would feed all three branches to a single invocation of --linearize
>> in the first place.  After all, the command is only doing what it
>> was asked to do.
>
> Passing several branches isn't the user asking for concatenation; it's
> the user asking for replay's core feature: update many branches at
> once. Adding --linearize  to flatten a merge does have to join the
> lines which that merge combined, but it shouldn't also weld together
> branches that were never merged in the first place.  The user is
> combining two intended features, and the concatenation is an emergent
> third behavior that neither of them implies.
I am not yet convinced by the above.
 * The fact that the user ran 'git replay' indicates that they
   want the command's core feature of updating multiple
   branches.
 * The fact that the user specified '--linearize' indicates that
   they want a linear history, regardless of the number of positive
   branch tips they gave.

So from that point of view, I still think it reasonable to expect such a history to be linearized.

In any case, I am not the primary audience for this new feature, and I have no desire to dictate the design one way or the other. Let us hear what the topic author has to say.

I will mark the topic as "On hold, waiting for response".
Thanks.
Toon ClaesJul 27, 2026, 13:07 UTC in reply to Elijah Newren on lore

Re: [PATCH v7 3/3] replay: offer an option to linearize the commit topology

Elijah Newren <newren@gmail.com> writes:
> As I proposed last time, I'd be fine with erroring on multiple
> positive refs as an interim step (plus associated documentation and
> commit message updates) so this series lands, with per-branch
> linearization as the real fix later.

I appreciate you're open to this interim step, but I would like to understand the end goal better before we continue.

> TL;DR version; my problems with the current implementation of
> `--linearize` are that it:
>   * Makes the rare usecase easy, and ignores the common usecase

Cannot deny that, although I'm not sure git-replay(1) is a popular end-user command.

>   * Makes it asymmetrically difficult to recover for those that wanted
> the common usecase instead of the easy

I don't think many users would use --linearize anyway. I'm guessing properly replaying merges would be far more useful to most people. I'm adding it mostly to scratch my own itch: do a server-side non-interactive rebase that's identical git-rebase(1)'s --no-rebase-merges.

>   * Makes `--linearize` mean something other than "remove non-linearity"

It's debatable what it means, because you can think of it linearizing all reachable commits (see also below what you said context of gitrevisions(7)).

Show 5 quoted lines
>   * Turns multiple branches into one, but updates several branches anyway
>   * Ignores order specified by the user on the command line
>   * Introduces an inconsistency within git-replay between `--advance`
> and `--linearize --onto`
> (The last three items being minor compared to the first three.)

I'm surprised you consider these three more minor, because I have more issues with them personally (the ordering in particular).

I don't have a feasible example, but as I understand from your argumentation, v7 might make commits reachable from a branch where they weren't before:

Show 21 quoted lines
> M1  M2  M3  M4  M5
> *---*---*---*---* <- master
>                 |
>                 |  A1  A2  A3  A4
>                 |--*---*---*---* <- branchA
>                 |      \
>                 |       -*---* <- branchC
>                 |        C1  C2
>                 |
>                 \-*---*---* <- branchB
>                   B1  B2  B3
> 
> With the current implementation of --linearize, adding that flag, i.e.
>     git replay --linearize --onto master branchA branchB branchC
> would instead give something like:
> 
> M1  M2  M3  M4  M5  B1  B2  B3  A1  A2  C1  C2  A3  A4
> *---*---*---*---*---*---*---*---*---*---*---*---*---*
>                 ^           ^               ^       ^
>                 |           |               |       |
>               master     branchB         branchC  branchA

Before the replay, branchC didn't reach any commits in branchB, while it does now. It kind of makes sense though, because branchC is specified after branchB. But then again, why does now branchA contain branchB and branchC? That's the problem I have with the ordering.

Show 15 quoted lines
> I think I know what you mean, but this isn't quite right:
> git-replay(1) only ever accepts a single revision range.  From
> gitrevisions(7) (also in git-rev-parse(1)):
> 
>        Commands that are specifically designed to take two distinct ranges
>        (e.g. "git range-diff R1 R2" to compare two ranges) do exist, but they
>        are exceptions. Unless otherwise noted, all "git" commands that operate
>        on a set of commits work on a single revision range. In other words,
>        writing two "two-dot range notation" next to each other, e.g.
> 
>            $ git log A..B C..D
> 
>        does not specify two revision ranges for most commands. Instead it will
>        name a single connected set of commits, i.e. those that are reachable
>        from either B or D but are reachable from neither A or C.

You could think v7's implementation of --linearize converts the "distinct ranges" into a "single connected set of commits", but then the option name isn't very good.

Show 5 quoted lines
> The reason I am comfortable with erroring out as a stopgap: turning an
> error into working behavior later never breaks anyone, whereas letting the
> current concatenation semantics reach 'master' risks users coming to
> depend on them, which would make switching to the better behavior a
> compatibility break.
I absolutely agree with that approach.
> Erroring now keeps our options open; merging as-is
> quietly closes them.  (git-replay is still EXPERIMENTAL, so this is not
> fatal either way, but it seems better not to paint ourselves into a
> corner.)

Being EXPERIMENTAL allows us to break things if we discover we didn't think about before, that's not the case here.

But then again, what do we do about --contained?
M1  M2  M3  M4  M5
*---*---*---*---* <- master
     \
      \  A1  A2  A3  A4  A5  A6
       \-*---*---*---*---*---* <- branchA
          \   \     /   /
           \   *---*   /  <- branchB
            \  B1  B2 /
             \---*---/  <- branchC
                 C1
This would end up into something like:
M1  M2  M3  M4  M5
*---*---*---*---* <- master
                |
                |   A1  A2  A3  B1  B2  C1 A6
                \---*---*---*---*---*---*---* <- branchA
                           branchB -^   ^- branchC
Same issue, branchC suddenly contains the commits of branchB.

The only way we can linearize (as in flatten merges) these branches is by replaying some commits twice:

M1  M2  M3  M4  M5
*---*---*---*---* <- master
                |
                |   A1  A2  A3  B1  B2  C1 A6
                \---*---*---*---*---*---*---* <- branchA
                     \   \
                      \   \---*---*           <- branchB
                       \     B1'  B2'
                        \---*                 <- branchC
                            C1'

But is that what the user wants? They could achieve that with running git-replay(1) once for every single branch separately (let's assume they set COMMITTER_DATE). Is this the end goal we want for --linearize with multiple revision ranges? I don't think that's doable with the last_commit per branch.

But for now, I would say --contained is not allowed with --linearize as well.

And maybe, maybe we should make --ref required when --linearize is given. Then the user would do something like:

    $ git replay --onto master branchA branchB branchC --ref branchA

This makes the end result unambiguous: take all commits reachable from these 3 branches, replay them linearly onto 'master' and *only* update ref 'branchA'.

-- 
Cheers,
Toon
Toon ClaesJul 28, 2026, 15:45 UTC in reply to Toon Claes on lore

[PATCH v8 0/3] Teach git-replay(1) to linearize merge commits

As an alternative to dscho's patch series to replay merges[1], add an option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. The original patch was kindly provided by Dscho, which I've tweaked to be upstreamed.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

Dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>
---
Changes in v8:
- Disallow multiple revision ranges with --linearize.
- Disallow --contained with --linearize.
- Link to v7: https://patch.msgid.link/20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com
Changes in v7:
- Allow --revert and --linearize to be used together.
- Because quite a lot of changes have been made since the original
  patch, change author from Johannes to Toon for the last commit.
  Johannes already told me he doesn't really care about authorship when
  he initially shared the patch with me.
- Link to v6: https://patch.msgid.link/20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com
Changes in v6:
- Reworked the second commit that moves picking the base completely
  outside pick_regular_commit(), instead of adding more explanation.
- Drastically extended the commit message on commit #3.
- Extended docs on flattening multiple revision ranges and how it's
  different from git-rebase(1)'s --no-rebase-merges.
- Added a bunch of tests to cover various scenarios.
- Remove newline from BUG() message.
- Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com
Changes in v5:
- Dropped the enum->bool patch and instead added a patch that better
  explains how pick_regular_commit() picks a base.
- Order of commits is shuffled.
- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool
  patch, I extended the code comments to explain how things work. This
  made me realize the use of the "replayed_base" was incorrect when
  multiple branches are rebased with --onto. This is fixed now and a
  test is added for this scenario.
- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com
Changes in v4:
- Use test_grep instead of a bare grep in the range-diff test, to
  prepare for mm/test-grep-lint.
- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com
Changes in v3:
- Add --linearize to Documentation SYNOPSIS, and mention it's
  incompatible with --revert.
- Small language change in help message for --linearize.
- Rephrase comment to include last_commit isn't modified when
  linearizing merges.
- Remove test that was added in earlier versions, but actually is
  a duplicate of 'replaying merge commits is not supported yet'.
- Add test to verify --revert and --linearize are incompatible.
- Properly test that replaying down to root with --linearize works.
- Add test for --linearize with --advance.
- Add test that uses git-range-diff(1) to verify the patches created by
  --linearize are correct.
- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com
Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
---
Toon Claes (3):
      replay: add helper to put entry into replayed_commits
      replay: resolve the replay base outside pick_regular_commit()
      replay: offer an option to linearize the commit topology
 Documentation/git-replay.adoc |  19 +++++++-
 builtin/replay.c              |   6 ++-
 replay.c                      |  87 +++++++++++++++++++++++----------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 198 insertions(+), 28 deletions(-)
Range-diff versus v7:
1:  4faca15008 = 1:  0dc78a2a4b replay: add helper to put entry into replayed_commits
2:  a32f28e1ec = 2:  7522f5940f replay: resolve the replay base outside pick_regular_commit()
3:  2a689c90eb ! 3:  6f27442866 replay: offer an option to linearize the commit topology
    @@ Commit message
         Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
         independently and updates both refs.
     
    -    With `--linearize` the whole set is flattened into one line: the ranges
    -    are stacked on top of each other rather than replayed side by side, so
    -    both refs end up pointing at different points along that single history.
    +    For now this is disallowed with option `--linearize`. Linearizing more
    +    than one branch at once would concatenate unrelated histories into a
    +    single line, and update each branch to some point in that line. That
    +    won't be the result most users want, especially because the order
    +    depends on the order of the revision walk, not the order of the branch
    +    names on the command line.
     
    -    Replaying all revision ranges into one single linear history is
    -    intentional and it's the only way to ensure predictable results. A user
    -    who wants to linearize ranges independently is advised to use separate
    -    git-replay(1) invocations.
    +    For the same reason disallow the use of `--contained` with
    +    `--linearize`.
     
    -    Linearizing is a distinct operation, and flattening merge commits is
    -    just one aspect of that. Recreating merges would be a separate mode, so
    -    rather than mirror git-rebase(1)'s `--rebase-merges[=<mode>]` interface,
    -    git-replay(1) uses its own `--linearize` option.
    +    Users who want to linearize multiple branches are advised to do this in
    +    separate git-replay(1) invocations. Linearizing multiple branches at
    +    once might be added later.
    +
    +    Note that `--linearize` is not modeled after git-rebase(1)'s
    +    `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving
    +    their topology, is a distinct operation that would be a separate mode.
    +    `--linearize` only drops merges and replays commits linearly. So
    +    git-replay(1) uses its own option rather than reusing that interface.
     
         Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
         Signed-off-by: Toon Claes <toon@iotcl.com>
    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif
     +the merge commit itself is dropped. A ref that pointed to a merge commit
     +is updated to the merge's last replayed ancestor.
     ++
    -+This flattens the `<revision-range>` as a whole. When multiple revision
    -+ranges are given they are stacked on top of each other into one linear
    -+history. Each of their refs is updated to point to its position in that
    -+history. To linearize ranges separately, replay them in separate `git
    ++Only a single branch can be linearized at a time: `--linearize` cannot
    ++be combined with multiple positive revisions or with `--contained`,
    ++because that would concatenate otherwise unrelated histories into one
    ++line. To linearize several branches, replay them in separate `git
     +replay` invocations.
     +
      <revision-range>::
    @@ builtin/replay.c: int cmd_replay(int argc,
      		OPT_END()
      	};
      
    +@@ builtin/replay.c: int cmd_replay(int argc,
    + 				  opts.contained, "--contained");
    + 	die_for_incompatible_opt2(!!opts.ref, "--ref",
    + 				  !!opts.contained, "--contained");
    ++	die_for_incompatible_opt2(opts.linearize, "--linearize",
    ++				  !!opts.contained, "--contained");
    + 
    + 	/* Parse ref action mode from command line or config */
    + 	ref_mode = get_ref_action_mode(repo, ref_action);
     
      ## replay.c ##
    +@@ replay.c: int replay_revisions(struct rev_info *revs,
    + 	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
    + 			   &detached_head, &advance, &revert, &onto, &update_refs);
    + 
    ++	if (opts->linearize &&
    ++	    update_refs && strset_get_size(update_refs) > 1) {
    ++		ret = error(_("'--linearize' cannot be used with multiple revision ranges"));
    ++		goto out;
    ++	}
    ++
    + 	if (opts->ref) {
    + 		struct object_id oid;
    + 
     @@ replay.c: int replay_revisions(struct rev_info *revs,
      	while ((commit = get_revision(revs))) {
      		const struct name_decoration *decoration;
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	test_line_count = 3 out
     +'
     +
    -+test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' '
    -+	git replay --ref-action=print --linearize \
    -+		--onto main ^B topic2 topic3 topic4 >result &&
    -+
    -+	test_line_count = 3 result &&
    -+	cut -f 3 -d " " result >new-branch-tips &&
    -+
    -+	>expect &&
    -+	for i in 2 3 4
    -+	do
    -+		printf "update refs/heads/topic$i " >>expect &&
    -+		printf "%s " $(grep topic$i result | cut -f 3 -d " ") >>expect &&
    -+		git rev-parse topic$i >>expect || return 1
    -+	done &&
    -+
    -+	test_cmp expect result &&
    -+
    -+	test_write_lines           E D C M L B A >expect2 &&
    -+	test_write_lines     H G F E D C M L B A >expect3 &&
    -+	test_write_lines J I H G F E D C M L B A >expect4 &&
    -+
    -+	for i in 2 3 4
    -+	do
    -+		git log --format=%s $(grep topic$i result | cut -f 3 -d " ") >actual &&
    -+		test_cmp expect$i actual || return 1
    -+	done
    ++test_expect_success '--linearize rejects multiple revision ranges' '
    ++	test_must_fail git replay --ref-action=print --linearize \
    ++		--onto main ^B topic2 topic3 topic4 2>err &&
    ++	test_grep "cannot be used with multiple revision ranges" err
     +'
     +
     +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	test_cmp expect actual
     +'
     +
    -+test_expect_success '--linearize with --contained updates contained refs' '
    -+	git replay --ref-action=print --linearize --contained \
    -+		--onto main ^B topic-with-merge >result &&
    -+
    -+	test_line_count = 2 result &&
    -+
    -+	git log --format=%s $(head -n 1 result | cut -f 3 -d " ") >actual &&
    -+	test_write_lines J I M L B A >expect &&
    -+	test_cmp expect actual &&
    -+
    -+	git log --format=%s $(tail -n 1 result | cut -f 3 -d " ") >actual &&
    -+	test_write_lines O N J I M L B A >expect &&
    -+	test_cmp expect actual
    ++test_expect_success '--linearize and --contained cannot be used together' '
    ++	test_must_fail git replay --ref-action=print --linearize --contained \
    ++		--onto main ^B topic-with-merge 2>err &&
    ++	test_grep "cannot be used together" err
     +'
     +
     +test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '

--- base-commit: 13c7afec212fc97ce257d15601659314c6673d6c change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesJul 28, 2026, 15:45 UTC in reply to Toon Claes on lore

[PATCH v8 1/3] replay: add helper to put entry into replayed_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into a `struct mapped_commits` into a helper function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index 463c900d6c..860e194ba0 100644
--- a/replay.c
+++ b/replay.c
@@ -254,9 +254,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -267,6 +267,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -287,7 +302,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -427,8 +442,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -440,11 +453,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.55.0.424.g13c7afec21
Toon ClaesJul 28, 2026, 15:45 UTC in reply to Toon Claes on lore

[PATCH v8 2/3] replay: resolve the replay base outside pick_regular_commit()

Depending on what gets passed into the function pick_regular_commit(), it decides the new base for the replayed commit. It first tries to find the replayed results of `pickme`'s parent in the `replayed_commits` map. If not found, it falls back to `onto`.

When using git-replay(1) with --onto, the fallback is the revision passed in with this option, but when using --revert, the fallback is `last_commit`.

It's rather confusing the base is decided partly inside pick_regular_commit() and partly by its caller.

Move the base selection completely into the caller: replay_revisions(). This bundles all the logic of deciding on the base together. Also, this reduces the number of parameters of pick_regular_commit(), making its interface cleaner.

This refactoring doesn't bring any behavior changes.
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 34 +++++++++++++++++++++-------------
 1 file changed, 21 insertions(+), 13 deletions(-)
Show changes to replay.c +21 −13
diff --git a/replay.c b/replay.c
index 860e194ba0..7e35f40d37 100644
--- a/replay.c
+++ b/replay.c
@@ -284,25 +284,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,
 
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
+					  struct commit *replayed_base,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
 					  enum replay_mode mode,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
-	if (pickme->parents) {
-		base = pickme->parents->item;
-		base_tree = repo_get_commit_tree(repo, base);
-	} else {
-		base = NULL;
+	if (pickme->parents)
+		base_tree = repo_get_commit_tree(repo, pickme->parents->item);
+	else
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
-	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -443,12 +437,26 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
+		/*
+		 * Decide where to replay this commit on.
+		 * If the parent commit was replayed already, the replayed result
+		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
+		 * When reverting, commits are replayed in reverse order and thus
+		 * its parent isn't replayed yet. Therefore revert commits are
+		 * always replayed onto `last_commit`.
+		 */
+		struct commit *parent = commit->parents ? commit->parents->item : NULL;
+		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
+
+		if (mode == REPLAY_MODE_REVERT)
+			base = last_commit;
+
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  mode == REPLAY_MODE_REVERT ? last_commit : onto,
-						  &merge_opt, &result, mode, opts->empty);
+		last_commit = pick_regular_commit(revs->repo, commit, base,
+						  &merge_opt, &result,
+						  mode, opts->empty);
 		if (!last_commit)
 			break;
 
-- 
2.55.0.424.g13c7afec21
Toon ClaesJul 28, 2026, 15:45 UTC in reply to Toon Claes on lore

[PATCH v8 3/3] replay: offer an option to linearize the commit topology

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the history into a sequence of regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same. Each replayed commit is stacked on top of the previously replayed one. When a merge is encountered, the commits reachable from all of its sides are replayed into the single line and the merge itself is dropped.

If a ref was pointing to a merge commit, that ref is updated to the merge's last replayed ancestor.

git-replay(1) accepts multiple revision ranges, for example:
    $ git replay --onto main topic1 topic2

Without `--linearize` this replays 'topic1' and 'topic2' onto 'main' independently and updates both refs.

For now this is disallowed with option `--linearize`. Linearizing more than one branch at once would concatenate unrelated histories into a single line, and update each branch to some point in that line. That won't be the result most users want, especially because the order depends on the order of the revision walk, not the order of the branch names on the command line.

For the same reason disallow the use of `--contained` with `--linearize`.

Users who want to linearize multiple branches are advised to do this in separate git-replay(1) invocations. Linearizing multiple branches at once might be added later.

Note that `--linearize` is not modeled after git-rebase(1)'s `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving their topology, is a distinct operation that would be a separate mode. `--linearize` only drops merges and replays commits linearly. So git-replay(1) uses its own option rather than reusing that interface.

Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  19 +++++++-
 builtin/replay.c              |   6 ++-
 replay.c                      |  60 +++++++++++++++--------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 176 insertions(+), 23 deletions(-)
Show changes to 5 files +176 −23

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..656a6924d9 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 --------
 [verse]
 (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
+			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
 
 DESCRIPTION
 -----------
@@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, each replayed commit is stacked on top of the
+	previously replayed one, so all replayed commits are flattened into
+	a single linear history.
++
+When a merge commit is encountered, the behavior of git-rebase(1)'s
+option `--no-rebase-merges` is imitated. All commits in the range
+reachable from the merge commit are replayed into a linear history, and
+the merge commit itself is dropped. A ref that pointed to a merge commit
+is updated to the merge's last replayed ancestor.
++
+Only a single branch can be linearized at a time: `--linearize` cannot
+be combined with multiple positive revisions or with `--contained`,
+because that would concatenate otherwise unrelated histories into one
+line. To linearize several branches, replay them in separate `git
+replay` invocations.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..d39626a37d 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -85,7 +85,7 @@ int cmd_replay(int argc,
 	const char *const replay_usage[] = {
 		N_("(EXPERIMENTAL!) git replay "
 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
+		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
 		NULL
 	};
 	struct option replay_options[] = {
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("drop merge commits, replaying only non-merge commits")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(opts.linearize, "--linearize",
+				  !!opts.contained, "--contained");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 7e35f40d37..1e1bc7c10a 100644
--- a/replay.c
+++ b/replay.c
@@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,
 	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
 			   &detached_head, &advance, &revert, &onto, &update_refs);
 
+	if (opts->linearize &&
+	    update_refs && strset_get_size(update_refs) > 1) {
+		ret = error(_("'--linearize' cannot be used with multiple revision ranges"));
+		goto out;
+	}
+
 	if (opts->ref) {
 		struct object_id oid;
 
@@ -437,26 +443,40 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		/*
-		 * Decide where to replay this commit on.
-		 * If the parent commit was replayed already, the replayed result
-		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
-		 * When reverting, commits are replayed in reverse order and thus
-		 * its parent isn't replayed yet. Therefore revert commits are
-		 * always replayed onto `last_commit`.
-		 */
-		struct commit *parent = commit->parents ? commit->parents->item : NULL;
-		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
-
-		if (mode == REPLAY_MODE_REVERT)
-			base = last_commit;
-
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(revs->repo, commit, base,
-						  &merge_opt, &result,
-						  mode, opts->empty);
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * Drop the merge commit: do not pick it, leave
+			 * `last_commit` unchanged, and fall through to the
+			 * rest of the loop. As a result:
+			 * - refs pointing to the merge commit will be updated
+			 *   to `last_commit`.
+			 * - the next replayed commit uses `last_commit` as its
+			 *   `base`.
+			 */
+		} else {
+			/*
+			 * Decide where to replay this commit onto.
+			 * If the parent commit was replayed already, the replayed result
+			 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
+			 * When reverting, commits are replayed in reverse order and thus
+			 * its parent isn't replayed yet. Therefore revert commits are
+			 * always replayed onto `last_commit`.
+			 * Also when opts->linearize is true, set the base to
+			 * `last_commit` to create a single linear history.
+			 */
+			struct commit *parent = commit->parents ? commit->parents->item : NULL;
+			struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
+
+			if (opts->linearize || mode == REPLAY_MODE_REVERT)
+				base = last_commit;
+
+			last_commit = pick_regular_commit(revs->repo, commit, base,
+							  &merge_opt, &result,
+							  mode, opts->empty);
+		}
+
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index 491db145e3..2c71afbfde 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..255bae5846 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -52,8 +52,19 @@ test_expect_success 'setup' '
 	test_merge P O --no-ff &&
 	git switch main &&
 
+	git switch --orphan unrelated &&
+	test_commit unrelated-root &&
+
 	git switch -c conflict B &&
-	test_commit C.conflict C.t conflict
+	test_commit C.conflict C.t conflict &&
+	git branch -D unrelated &&
+
+	git switch -c divergent-x main &&
+	test_commit X &&
+	git switch -c divergent-y main &&
+	test_commit Y &&
+	git switch divergent-x &&
+	test_merge Z divergent-y --no-ff
 '
 
 test_expect_success 'setup bare' '
@@ -565,4 +576,100 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
+	git replay --ref-action=print --linearize \
+		--onto unrelated-root topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I B A unrelated-root >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to cherry-pick merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--advance main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual &&
+
+	printf "update refs/heads/main " >expect &&
+	printf "%s " $(cut -f 3 -d " " result) >>expect &&
+	git rev-parse main >>expect &&
+	test_cmp expect result
+'
+
+test_expect_success 'replay --linearize produces the same patches' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# range-diff does not care about the dropped merge,
+	# so the original commits (I..topic-with-merge)
+	# and the replayed chain (main..tip) must produce identical patches.
+	git range-diff I..topic-with-merge main..$tip >out &&
+	test_file_not_empty out &&
+	test_grep ! -v "=" out &&
+
+	git log --oneline main..$tip >out &&
+	test_line_count = 3 out
+'
+
+test_expect_success '--linearize rejects multiple revision ranges' '
+	test_must_fail git replay --ref-action=print --linearize \
+		--onto main ^B topic2 topic3 topic4 2>err &&
+	test_grep "cannot be used with multiple revision ranges" err
+'
+
+test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '
+	git replay --ref-action=print --linearize \
+		--onto main main..divergent-x >result &&
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# The merge Z is dropped, but both X and Y are linearized onto main;
+	# neither side is lost.
+	git log --format=%s main..$tip >actual &&
+	test_write_lines Y X >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--linearize and --contained cannot be used together' '
+	test_must_fail git replay --ref-action=print --linearize --contained \
+		--onto main ^B topic-with-merge 2>err &&
+	test_grep "cannot be used together" err
+'
+
+test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '
+	git replay --ref-action=print --revert=divergent-x --linearize \
+		main..divergent-x >result &&
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	git log --format=%s $tip >actual &&
+	test_write_lines \
+		"Revert \"X\"" "Revert \"Y\"" Z Y X M L B A >expect &&
+	test_cmp expect actual &&
+
+	test_must_fail git cat-file -e $tip:X.t &&
+	test_must_fail git cat-file -e $tip:Y.t
+'
+
 test_done
-- 
2.55.0.424.g13c7afec21
Junio C HamanoJul 28, 2026, 18:26 UTC in reply to Toon Claes on lore

Re: [PATCH v8 0/3] Teach git-replay(1) to linearize merge commits

Toon Claes <toon@iotcl.com> writes:
Show 25 quoted lines
> As an alternative to dscho's patch series to replay merges[1], add
> an option to git-replay(1) to linearize merges. This mimics what
> git-rebase(1) does with --no-rebase-merges (the default).
>
> The first two patches do some refactoring. The third patch implements
> the actual change. The original patch was kindly provided by Dscho,
> which I've tweaked to be upstreamed.
>
> The --linearize option is only added to git-replay(1) and not to
> git-history(1) because in my opinion it doesn't make much sense to do
> so, but I'm happy to hear if anyone disagrees.
>
> Dscho's series to replay merges[1] needs a bit of rework to fit on top
> of this, but I'm happy to help figuring that out. We've been discussing
> to either name the option --flatten or --linearize, but I've decided on
> "linearize" because the documentation of git-rebase(1) also mentions
> "linearize".
>
> [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>
>
> ---
> Changes in v8:
> - Disallow multiple revision ranges with --linearize.
> - Disallow --contained with --linearize.
> - Link to v7: https://patch.msgid.link/20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com

The topic has been cooking in 'next' since Jul 9th, so I'll revert the merge and queue this iteration instead, making sure I do not accidentally merge it down to 'next' prematurely.

Thanks.
Justin ToblerAug 7, 2026, 17:34 UTC in reply to Toon Claes on lore

Re: [PATCH v8 3/3] replay: offer an option to linearize the commit topology

On 26/07/28 05:45PM, Toon Claes wrote:
Show 15 quoted lines
> One of the stated goals of git-replay(1) is to allow implementing the
> git-rebase(1) functionality on the server side.
> 
> The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
> was given. This mode drops merge commits instead of replaying them, and
> linearizes the history into a sequence of regular (single-parent)
> commits.
> 
> Add option `--linearize` to git-replay(1) to do the same. Each replayed
> commit is stacked on top of the previously replayed one. When a merge is
> encountered, the commits reachable from all of its sides are replayed
> into the single line and the merge itself is dropped.
> 
> If a ref was pointing to a merge commit, that ref is updated to the
> merge's last replayed ancestor.

Just to clarify, does it really matter if the ref was pointing to the merge commit directly? I assume it is just "flattening" the merge commits in the revision range.

> git-replay(1) accepts multiple revision ranges, for example:
> 
>     $ git replay --onto main topic1 topic2

Per some discussion earlier in the thread, is "accepts multiple revision ranges" the correct wording here? Would it be more correct to say multiple branches instead?

> Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
> independently and updates both refs.
Ok, so git-replay(1) updates each branch sepecified separately.
Show 6 quoted lines
> For now this is disallowed with option `--linearize`. Linearizing more
> than one branch at once would concatenate unrelated histories into a
> single line, and update each branch to some point in that line. That
> won't be the result most users want, especially because the order
> depends on the order of the revision walk, not the order of the branch
> names on the command line.

I'm not quite sure I follow. Why would the inclusion of the `--linearize` option force concatenation of multiple references? Is it mot possible to linearize each of the branches in isolation and update the reference accordingly?

Show 6 quoted lines
> For the same reason disallow the use of `--contained` with
> `--linearize`.
> 
> Users who want to linearize multiple branches are advised to do this in
> separate git-replay(1) invocations. Linearizing multiple branches at
> once might be added later.
Ok.
Show 49 quoted lines
> Note that `--linearize` is not modeled after git-rebase(1)'s
> `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving
> their topology, is a distinct operation that would be a separate mode.
> `--linearize` only drops merges and replays commits linearly. So
> git-replay(1) uses its own option rather than reusing that interface.
> 
> Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> Signed-off-by: Toon Claes <toon@iotcl.com>
> ---
>  Documentation/git-replay.adoc |  19 +++++++-
>  builtin/replay.c              |   6 ++-
>  replay.c                      |  60 +++++++++++++++--------
>  replay.h                      |   5 ++
>  t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-
>  5 files changed, 176 insertions(+), 23 deletions(-)
> 
> diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
> index a32f72aead..656a6924d9 100644
> --- a/Documentation/git-replay.adoc
> +++ b/Documentation/git-replay.adoc
> @@ -10,7 +10,7 @@ SYNOPSIS
>  --------
>  [verse]
>  (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
> -			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
> +			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
>  
>  DESCRIPTION
>  -----------
> @@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
>  +
>  The default mode can be configured via the `replay.refAction` configuration variable.
>  
> +--linearize::
> +	In this mode, each replayed commit is stacked on top of the
> +	previously replayed one, so all replayed commits are flattened into
> +	a single linear history.
> ++
> +When a merge commit is encountered, the behavior of git-rebase(1)'s
> +option `--no-rebase-merges` is imitated. All commits in the range
> +reachable from the merge commit are replayed into a linear history, and
> +the merge commit itself is dropped. A ref that pointed to a merge commit
> +is updated to the merge's last replayed ancestor.
> ++
> +Only a single branch can be linearized at a time: `--linearize` cannot
> +be combined with multiple positive revisions or with `--contained`,
> +because that would concatenate otherwise unrelated histories into one
> +line. To linearize several branches, replay them in separate `git
> +replay` invocations.

I still don't fully understand the justification here. I'm not sure it really needs to be in the documentation though. It may be fine to just say "multiple branches are not supported with this option" or something along those lines.

Show 46 quoted lines
> +
>  <revision-range>::
>  	Range of commits to replay; see "Specifying Ranges" in
>  	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
> diff --git a/builtin/replay.c b/builtin/replay.c
> index 39e3a86f6c..d39626a37d 100644
> --- a/builtin/replay.c
> +++ b/builtin/replay.c
> @@ -85,7 +85,7 @@ int cmd_replay(int argc,
>  	const char *const replay_usage[] = {
>  		N_("(EXPERIMENTAL!) git replay "
>  		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
> -		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
> +		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
>  		NULL
>  	};
>  	struct option replay_options[] = {
> @@ -111,6 +111,8 @@ int cmd_replay(int argc,
>  			     N_("mode"),
>  			     N_("control ref update behavior (update|print)"),
>  			     PARSE_OPT_NONEG),
> +		OPT_BOOL(0, "linearize", &opts.linearize,
> +			 N_("drop merge commits, replaying only non-merge commits")),
>  		OPT_END()
>  	};
>  
> @@ -132,6 +134,8 @@ int cmd_replay(int argc,
>  				  opts.contained, "--contained");
>  	die_for_incompatible_opt2(!!opts.ref, "--ref",
>  				  !!opts.contained, "--contained");
> +	die_for_incompatible_opt2(opts.linearize, "--linearize",
> +				  !!opts.contained, "--contained");
>  
>  	/* Parse ref action mode from command line or config */
>  	ref_mode = get_ref_action_mode(repo, ref_action);
> diff --git a/replay.c b/replay.c
> index 7e35f40d37..1e1bc7c10a 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,
>  	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
>  			   &detached_head, &advance, &revert, &onto, &update_refs);
>  
> +	if (opts->linearize &&
> +	    update_refs && strset_get_size(update_refs) > 1) {
> +		ret = error(_("'--linearize' cannot be used with multiple revision ranges"));
Should this say "multiple branches" instead?
Show 42 quoted lines
> +		goto out;
> +	}
> +
>  	if (opts->ref) {
>  		struct object_id oid;
>  
> @@ -437,26 +443,40 @@ int replay_revisions(struct rev_info *revs,
>  	while ((commit = get_revision(revs))) {
>  		const struct name_decoration *decoration;
>  
> -		/*
> -		 * Decide where to replay this commit on.
> -		 * If the parent commit was replayed already, the replayed result
> -		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
> -		 * When reverting, commits are replayed in reverse order and thus
> -		 * its parent isn't replayed yet. Therefore revert commits are
> -		 * always replayed onto `last_commit`.
> -		 */
> -		struct commit *parent = commit->parents ? commit->parents->item : NULL;
> -		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
> -
> -		if (mode == REPLAY_MODE_REVERT)
> -			base = last_commit;
> -
> -		if (commit->parents && commit->parents->next)
> -			die(_("replaying merge commits is not supported yet!"));
> -
> -		last_commit = pick_regular_commit(revs->repo, commit, base,
> -						  &merge_opt, &result,
> -						  mode, opts->empty);
> +		if (commit->parents && commit->parents->next) {
> +			if (!opts->linearize)
> +				die(_("replaying merge commits is not supported yet!"));
> +			/*
> +			 * Drop the merge commit: do not pick it, leave
> +			 * `last_commit` unchanged, and fall through to the
> +			 * rest of the loop. As a result:
> +			 * - refs pointing to the merge commit will be updated
> +			 *   to `last_commit`.
> +			 * - the next replayed commit uses `last_commit` as its
> +			 *   `base`.
> +			 */

Ok, when the linearize option is provided, we now drop the merge commit and continue on.

Show 16 quoted lines
> +		} else {
> +			/*
> +			 * Decide where to replay this commit onto.
> +			 * If the parent commit was replayed already, the replayed result
> +			 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
> +			 * When reverting, commits are replayed in reverse order and thus
> +			 * its parent isn't replayed yet. Therefore revert commits are
> +			 * always replayed onto `last_commit`.
> +			 * Also when opts->linearize is true, set the base to
> +			 * `last_commit` to create a single linear history.
> +			 */
> +			struct commit *parent = commit->parents ? commit->parents->item : NULL;
> +			struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
> +
> +			if (opts->linearize || mode == REPLAY_MODE_REVERT)
> +				base = last_commit;

Ok IIUC, when we are linearizing commits we are just replaying them onto the most recently replayed commit. Makes sense.

-Justin
Elijah NewrenAug 8, 2026, 09:17 UTC in reply to Justin Tobler on lore

Re: [PATCH v8 3/3] replay: offer an option to linearize the commit topology

Adding my comments in response to Justin's, since I think he highlights some good points.

First of all, Toon, thanks for making --linearize and multiple branches incompatible to avoid concatenating histories. I really appreciate it.

On Fri, Aug 7, 2026 at 10:34 AM Justin Tobler <jltobler@gmail.com> wrote:
Show 21 quoted lines
>
> On 26/07/28 05:45PM, Toon Claes wrote:
> > One of the stated goals of git-replay(1) is to allow implementing the
> > git-rebase(1) functionality on the server side.
> >
> > The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
> > was given. This mode drops merge commits instead of replaying them, and
> > linearizes the history into a sequence of regular (single-parent)
> > commits.
> >
> > Add option `--linearize` to git-replay(1) to do the same. Each replayed
> > commit is stacked on top of the previously replayed one. When a merge is
> > encountered, the commits reachable from all of its sides are replayed
> > into the single line and the merge itself is dropped.
> >
> > If a ref was pointing to a merge commit, that ref is updated to the
> > merge's last replayed ancestor.
>
> Just to clarify, does it really matter if the ref was pointing to the
> merge commit directly? I assume it is just "flattening" the merge
> commits in the revision range.
Toon's clarification is important, though depending on your mental
model it might _appear_ to be an unnecessary clarification.  I think
there are two mental models:
  - Each commit of the branch is replayed and the ref is updated as it
goes.  (This matches underlying implementation mechanics for `git
rebase`, but not for `git replay`.)
  - Each commit of the branch is replayed.  The ref is updated at the
end to the corresponding replay of the final commit.  (Matches
underlying implementation mechanics for `git replay`.)

Readers could possibly assume either mental model without knowing the underlying mechanics. Toon's clarification doesn't hurt those who assume the first style, but is an important clarification for those who assume the second style.

Show 7 quoted lines
> > git-replay(1) accepts multiple revision ranges, for example:
> >
> >     $ git replay --onto main topic1 topic2
>
> Per some discussion earlier in the thread, is "accepts multiple revision
> ranges" the correct wording here? Would it be more correct to say
> multiple branches instead?
Yes, please; it would be nice to see this fixed.
> > Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
> > independently and updates both refs.
>
> Ok, so git-replay(1) updates each branch sepecified separately.

Oh, that's a good callout. The word "independently" and "separately" here may well mislead users. If branches "topic1" and "topic2" share some history, claiming they are replayed "independently" or "separately" may cause people to assume the shared history becomes copied and no longer shared. I think the word "independently" should be dropped. If wanted, we could word this to something like:

Without `--linearize` this replays 'topic1' and 'topic2' onto 'main' (keeping shared portions of history shared and keeping divergent parts divergent), and updates both refs.

Show 11 quoted lines
> > For now this is disallowed with option `--linearize`. Linearizing more
> > than one branch at once would concatenate unrelated histories into a
> > single line, and update each branch to some point in that line. That
> > won't be the result most users want, especially because the order
> > depends on the order of the revision walk, not the order of the branch
> > names on the command line.
>
> I'm not quite sure I follow. Why would the inclusion of the
> `--linearize` option force concatenation of multiple references? Is it
> mot possible to linearize each of the branches in isolation and update
> the reference accordingly?
Maybe:

Due to current implementation limitations, replaying multiple branches with `--linearize` is disallowed to avoid concatenating unrelated histories into a single line...

?
> > For the same reason disallow the use of `--contained` with
> > `--linearize`.
Good catch and callout, Toon.
Show 46 quoted lines
> > Users who want to linearize multiple branches are advised to do this in
> > separate git-replay(1) invocations. Linearizing multiple branches at
> > once might be added later.
>
> Ok.
>
> > Note that `--linearize` is not modeled after git-rebase(1)'s
> > `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving
> > their topology, is a distinct operation that would be a separate mode.
> > `--linearize` only drops merges and replays commits linearly. So
> > git-replay(1) uses its own option rather than reusing that interface.
> >
> > Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> > Signed-off-by: Toon Claes <toon@iotcl.com>
> > ---
> >  Documentation/git-replay.adoc |  19 +++++++-
> >  builtin/replay.c              |   6 ++-
> >  replay.c                      |  60 +++++++++++++++--------
> >  replay.h                      |   5 ++
> >  t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-
> >  5 files changed, 176 insertions(+), 23 deletions(-)
> >
> > diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
> > index a32f72aead..656a6924d9 100644
> > --- a/Documentation/git-replay.adoc
> > +++ b/Documentation/git-replay.adoc
> > @@ -10,7 +10,7 @@ SYNOPSIS
> >  --------
> >  [verse]
> >  (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
> > -                          [--ref=<ref>] [--ref-action=<mode>] <revision-range>
> > +                          [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
> >
> >  DESCRIPTION
> >  -----------
> > @@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
> >  +
> >  The default mode can be configured via the `replay.refAction` configuration variable.
> >
> > +--linearize::
> > +     In this mode, each replayed commit is stacked on top of the
> > +     previously replayed one, so all replayed commits are flattened into
> > +     a single linear history.
> > ++
> > +When a merge commit is encountered, the behavior of git-rebase(1)'s
> > +option `--no-rebase-merges` is imitated. All commits in the range

I dislike pointing new users to read a big chunk of another manual page to understand an option. I have a personal gripe against the manual for `git merge-base`, in particular, which feels like in order to understand various flags you have to understand what at first looks like an unrelated command and multiple of its options first. The rest of your paragraph is a good self-standing description; can you just move your first sentence to the end of the paragraph and make it a parenthetical pointing out the similarity of the two options of the two commands?

Show 14 quoted lines
> > +reachable from the merge commit are replayed into a linear history, and
> > +the merge commit itself is dropped. A ref that pointed to a merge commit
> > +is updated to the merge's last replayed ancestor.
> > ++
> > +Only a single branch can be linearized at a time: `--linearize` cannot
> > +be combined with multiple positive revisions or with `--contained`,
> > +because that would concatenate otherwise unrelated histories into one
> > +line. To linearize several branches, replay them in separate `git
> > +replay` invocations.
>
> I still don't fully understand the justification here. I'm not sure it
> really needs to be in the documentation though. It may be fine to just
> say "multiple branches are not supported with this option" or something
> along those lines.

It does feel like this unnecessarily explains implementation shortcomings, and further tries to list them as fundamental limitations. (If each commit were tagged with all branches it was reachable from, then as the replay walked over the commits and replayed each, it could simply track the last commit for each branch rather than an overall last commit, and at the end update each branch to its corresponding last seen commit. That would allow us to lift the limitation.) So, I agree with Justin's suggestion here to just more simply state that the combination isn't supported.

Show 48 quoted lines
> > +
> >  <revision-range>::
> >       Range of commits to replay; see "Specifying Ranges" in
> >       linkgit:git-rev-parse[1]. In `--advance=<branch>` or
> > diff --git a/builtin/replay.c b/builtin/replay.c
> > index 39e3a86f6c..d39626a37d 100644
> > --- a/builtin/replay.c
> > +++ b/builtin/replay.c
> > @@ -85,7 +85,7 @@ int cmd_replay(int argc,
> >       const char *const replay_usage[] = {
> >               N_("(EXPERIMENTAL!) git replay "
> >                  "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
> > -                "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
> > +                "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
> >               NULL
> >       };
> >       struct option replay_options[] = {
> > @@ -111,6 +111,8 @@ int cmd_replay(int argc,
> >                            N_("mode"),
> >                            N_("control ref update behavior (update|print)"),
> >                            PARSE_OPT_NONEG),
> > +             OPT_BOOL(0, "linearize", &opts.linearize,
> > +                      N_("drop merge commits, replaying only non-merge commits")),
> >               OPT_END()
> >       };
> >
> > @@ -132,6 +134,8 @@ int cmd_replay(int argc,
> >                                 opts.contained, "--contained");
> >       die_for_incompatible_opt2(!!opts.ref, "--ref",
> >                                 !!opts.contained, "--contained");
> > +     die_for_incompatible_opt2(opts.linearize, "--linearize",
> > +                               !!opts.contained, "--contained");
> >
> >       /* Parse ref action mode from command line or config */
> >       ref_mode = get_ref_action_mode(repo, ref_action);
> > diff --git a/replay.c b/replay.c
> > index 7e35f40d37..1e1bc7c10a 100644
> > --- a/replay.c
> > +++ b/replay.c
> > @@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,
> >       set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
> >                          &detached_head, &advance, &revert, &onto, &update_refs);
> >
> > +     if (opts->linearize &&
> > +         update_refs && strset_get_size(update_refs) > 1) {
> > +             ret = error(_("'--linearize' cannot be used with multiple revision ranges"));
>
> Should this say "multiple branches" instead?
Yes, please.

Also, should we replace opts->linearize with (opts->linearize || mode == REPLAY_MODE_REVERT) ? The reason being this line of code below:

> > +                     if (opts->linearize || mode == REPLAY_MODE_REVERT)
> > +                             base = last_commit;

Trying to revert with multiple branches will (a) concatenate the reverts into a single branch (which probably *is* what is wanted) and (b) do so in revision walking order instead of the order of branches specified by the user on the command line. That ordering could have bisection or conflict ramifications that may surprise the user. Also, cherry-pick disallows multiple branches (even though concatenation would be wanted there too) because of this ignore-user's-command-line-order issue.

Toon ClaesAug 31, 2026, 13:01 UTC in reply to Elijah Newren on lore

Re: [PATCH v8 3/3] replay: offer an option to linearize the commit topology

Elijah Newren <newren@gmail.com> writes:
Show 6 quoted lines
> Adding my comments in response to Justin's, since I think he
> highlights some good points.
>
> First of all, Toon, thanks for making --linearize and multiple
> branches incompatible to avoid concatenating histories.  I really
> appreciate it.

To be honest, I would rather not to. But for now I think it's the best we can do. We could dive deeper into the "last_commit per command-line" suggestion you've made, but I think it would be more complex to implement it correctly than you made it appear to be. So let's leave that out now. And perhaps we can share this when we implement real replaying of commits at some point. So I'm leaving that out for now

Show 37 quoted lines
> On Fri, Aug 7, 2026 at 10:34 AM Justin Tobler <jltobler@gmail.com> wrote:
>>
>> On 26/07/28 05:45PM, Toon Claes wrote:
>> > One of the stated goals of git-replay(1) is to allow implementing the
>> > git-rebase(1) functionality on the server side.
>> >
>> > The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
>> > was given. This mode drops merge commits instead of replaying them, and
>> > linearizes the history into a sequence of regular (single-parent)
>> > commits.
>> >
>> > Add option `--linearize` to git-replay(1) to do the same. Each replayed
>> > commit is stacked on top of the previously replayed one. When a merge is
>> > encountered, the commits reachable from all of its sides are replayed
>> > into the single line and the merge itself is dropped.
>> >
>> > If a ref was pointing to a merge commit, that ref is updated to the
>> > merge's last replayed ancestor.
>>
>> Just to clarify, does it really matter if the ref was pointing to the
>> merge commit directly? I assume it is just "flattening" the merge
>> commits in the revision range.
>
> Toon's clarification is important, though depending on your mental
> model it might _appear_ to be an unnecessary clarification.  I think
> there are two mental models:
>   - Each commit of the branch is replayed and the ref is updated as it
> goes.  (This matches underlying implementation mechanics for `git
> rebase`, but not for `git replay`.)
>   - Each commit of the branch is replayed.  The ref is updated at the
> end to the corresponding replay of the final commit.  (Matches
> underlying implementation mechanics for `git replay`.)
>
> Readers could possibly assume either mental model without knowing the
> underlying mechanics.  Toon's clarification doesn't hurt those who
> assume the first style, but is an important clarification for those
> who assume the second style.
@Justin, what I intended to explain is the following scenario:
   A---B---C
        \
         D---E---F---G
              \     /
               X---Y
If `my-branch` points to G, replaying that branch onto C ends up into:
   A---B---C---D'---E'---F'---X'---Y'  (order not guaranteed)

`my-branch` will now point to Y', because G is dropped the ref is "moved".

Show 9 quoted lines
>> > git-replay(1) accepts multiple revision ranges, for example:
>> >
>> >     $ git replay --onto main topic1 topic2
>>
>> Per some discussion earlier in the thread, is "accepts multiple revision
>> ranges" the correct wording here? Would it be more correct to say
>> multiple branches instead?
>
> Yes, please; it would be nice to see this fixed.
Will do.
Show 15 quoted lines
>> > Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
>> > independently and updates both refs.
>>
>> Ok, so git-replay(1) updates each branch sepecified separately.
>
> Oh, that's a good callout.  The word "independently" and "separately"
> here may well mislead users.  If branches "topic1" and "topic2" share
> some history, claiming they are replayed "independently" or
> "separately" may cause people to assume the shared history becomes
> copied and no longer shared.  I think the word "independently" should
> be dropped.  If wanted, we could word this to something like:
>
> Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
> (keeping shared portions of history shared and keeping divergent parts
> divergent), and updates both refs.
Okay, let's take this.
Show 17 quoted lines
>> > For now this is disallowed with option `--linearize`. Linearizing more
>> > than one branch at once would concatenate unrelated histories into a
>> > single line, and update each branch to some point in that line. That
>> > won't be the result most users want, especially because the order
>> > depends on the order of the revision walk, not the order of the branch
>> > names on the command line.
>>
>> I'm not quite sure I follow. Why would the inclusion of the
>> `--linearize` option force concatenation of multiple references? Is it
>> mot possible to linearize each of the branches in isolation and update
>> the reference accordingly?
>
> Maybe:
>
> Due to current implementation limitations, replaying multiple branches
> with `--linearize` is disallowed to avoid concatenating unrelated
> histories into a single line...
Works for me.

@Justin there's a whole history of mails preceding this issue, but to give you the short summary: I think in v5 I've made a change because Junio and Philip noticed a bug where only one side would get replayed. But the "fix" I made caused every branch to be replayed together in one signle long history. This is probably unwanted behavior to most users, so Elijah suggested to disallow multiple branches with --linearize, at least for now.

>> > For the same reason disallow the use of `--contained` with
>> > `--linearize`.
>
> Good catch and callout, Toon.
<3
Show 56 quoted lines
>> > Users who want to linearize multiple branches are advised to do this in
>> > separate git-replay(1) invocations. Linearizing multiple branches at
>> > once might be added later.
>>
>> Ok.
>>
>> > Note that `--linearize` is not modeled after git-rebase(1)'s
>> > `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving
>> > their topology, is a distinct operation that would be a separate mode.
>> > `--linearize` only drops merges and replays commits linearly. So
>> > git-replay(1) uses its own option rather than reusing that interface.
>> >
>> > Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
>> > Signed-off-by: Toon Claes <toon@iotcl.com>
>> > ---
>> >  Documentation/git-replay.adoc |  19 +++++++-
>> >  builtin/replay.c              |   6 ++-
>> >  replay.c                      |  60 +++++++++++++++--------
>> >  replay.h                      |   5 ++
>> >  t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-
>> >  5 files changed, 176 insertions(+), 23 deletions(-)
>> >
>> > diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
>> > index a32f72aead..656a6924d9 100644
>> > --- a/Documentation/git-replay.adoc
>> > +++ b/Documentation/git-replay.adoc
>> > @@ -10,7 +10,7 @@ SYNOPSIS
>> >  --------
>> >  [verse]
>> >  (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
>> > -                          [--ref=<ref>] [--ref-action=<mode>] <revision-range>
>> > +                          [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
>> >
>> >  DESCRIPTION
>> >  -----------
>> > @@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
>> >  +
>> >  The default mode can be configured via the `replay.refAction` configuration variable.
>> >
>> > +--linearize::
>> > +     In this mode, each replayed commit is stacked on top of the
>> > +     previously replayed one, so all replayed commits are flattened into
>> > +     a single linear history.
>> > ++
>> > +When a merge commit is encountered, the behavior of git-rebase(1)'s
>> > +option `--no-rebase-merges` is imitated. All commits in the range
>
> I dislike pointing new users to read a big chunk of another manual
> page to understand an option.  I have a personal gripe against the
> manual for `git merge-base`, in particular, which feels like in order
> to understand various flags you have to understand what at first looks
> like an unrelated command and multiple of its options first.  The rest
> of your paragraph is a good self-standing description; can you just
> move your first sentence to the end of the paragraph and make it a
> parenthetical pointing out the similarity of the two options of the
> two commands?
I'll rephrase it.
Show 24 quoted lines
>> > +reachable from the merge commit are replayed into a linear history, and
>> > +the merge commit itself is dropped. A ref that pointed to a merge commit
>> > +is updated to the merge's last replayed ancestor.
>> > ++
>> > +Only a single branch can be linearized at a time: `--linearize` cannot
>> > +be combined with multiple positive revisions or with `--contained`,
>> > +because that would concatenate otherwise unrelated histories into one
>> > +line. To linearize several branches, replay them in separate `git
>> > +replay` invocations.
>>
>> I still don't fully understand the justification here. I'm not sure it
>> really needs to be in the documentation though. It may be fine to just
>> say "multiple branches are not supported with this option" or something
>> along those lines.
>
> It does feel like this unnecessarily explains implementation
> shortcomings, and further tries to list them as fundamental
> limitations.  (If each commit were tagged with all branches it was
> reachable from, then as the replay walked over the commits and
> replayed each, it could simply track the last commit for each branch
> rather than an overall last commit, and at the end update each branch
> to its corresponding last seen commit.  That would allow us to lift
> the limitation.)  So, I agree with Justin's suggestion here to just
> more simply state that the combination isn't supported.
Sure.
Show 50 quoted lines
>> > +
>> >  <revision-range>::
>> >       Range of commits to replay; see "Specifying Ranges" in
>> >       linkgit:git-rev-parse[1]. In `--advance=<branch>` or
>> > diff --git a/builtin/replay.c b/builtin/replay.c
>> > index 39e3a86f6c..d39626a37d 100644
>> > --- a/builtin/replay.c
>> > +++ b/builtin/replay.c
>> > @@ -85,7 +85,7 @@ int cmd_replay(int argc,
>> >       const char *const replay_usage[] = {
>> >               N_("(EXPERIMENTAL!) git replay "
>> >                  "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
>> > -                "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
>> > +                "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
>> >               NULL
>> >       };
>> >       struct option replay_options[] = {
>> > @@ -111,6 +111,8 @@ int cmd_replay(int argc,
>> >                            N_("mode"),
>> >                            N_("control ref update behavior (update|print)"),
>> >                            PARSE_OPT_NONEG),
>> > +             OPT_BOOL(0, "linearize", &opts.linearize,
>> > +                      N_("drop merge commits, replaying only non-merge commits")),
>> >               OPT_END()
>> >       };
>> >
>> > @@ -132,6 +134,8 @@ int cmd_replay(int argc,
>> >                                 opts.contained, "--contained");
>> >       die_for_incompatible_opt2(!!opts.ref, "--ref",
>> >                                 !!opts.contained, "--contained");
>> > +     die_for_incompatible_opt2(opts.linearize, "--linearize",
>> > +                               !!opts.contained, "--contained");
>> >
>> >       /* Parse ref action mode from command line or config */
>> >       ref_mode = get_ref_action_mode(repo, ref_action);
>> > diff --git a/replay.c b/replay.c
>> > index 7e35f40d37..1e1bc7c10a 100644
>> > --- a/replay.c
>> > +++ b/replay.c
>> > @@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,
>> >       set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
>> >                          &detached_head, &advance, &revert, &onto, &update_refs);
>> >
>> > +     if (opts->linearize &&
>> > +         update_refs && strset_get_size(update_refs) > 1) {
>> > +             ret = error(_("'--linearize' cannot be used with multiple revision ranges"));
>>
>> Should this say "multiple branches" instead?
>
> Yes, please.
Ack.
Show 14 quoted lines
> Also, should we replace opts->linearize with (opts->linearize || mode
> == REPLAY_MODE_REVERT) ?  The reason being this line of code below:
>
>> > +                     if (opts->linearize || mode == REPLAY_MODE_REVERT)
>> > +                             base = last_commit;
>
> Trying to revert with multiple branches will (a) concatenate the
> reverts into a single branch (which probably *is* what is wanted) and
> (b) do so in revision walking order instead of the order of branches
> specified by the user on the command line.  That ordering could have
> bisection or conflict ramifications that may surprise the user.  Also,
> cherry-pick disallows multiple branches (even though concatenation
> would be wanted there too) because of this
> ignore-user's-command-line-order issue.

This is already covered in set_up_branch_mode(), used by both --revert and --advance.

-- 
Laters,
Toon
Toon ClaesAug 31, 2026, 13:13 UTC in reply to Toon Claes on lore

[PATCH v9 0/3] Teach git-replay(1) to linearize merge commits

Hi,

I'm back with another iteration of the patch series to implement --linearize into git-replay(1). My apologies for the long period of radio silence, but this topic was stalled on reviews for a while, and when I got some feedback I was on leave, so I'm finally back.

As far as I could tell there weren't any remaining comments on the implementation itself, only on commit message and docs.

Laters, Toon

Original cover letter: ===

As an alternative to dscho's patch series to replay merges[1], add an option to git-replay(1) to linearize merges. This mimics what git-rebase(1) does with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements the actual change. The original patch was kindly provided by Dscho, which I've tweaked to be upstreamed.

The --linearize option is only added to git-replay(1) and not to git-history(1) because in my opinion it doesn't make much sense to do so, but I'm happy to hear if anyone disagrees.

Dscho's series to replay merges[1] needs a bit of rework to fit on top of this, but I'm happy to help figuring that out. We've been discussing to either name the option --flatten or --linearize, but I've decided on "linearize" because the documentation of git-rebase(1) also mentions "linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>
---
Changes in v9:
- Rephrase "multiple revision ranges" to "multiple branches".
- Reword some things in the commit message.
- Tweak wording in replay.adoc.
- Link to v8: https://patch.msgid.link/20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com
Changes in v8:
- Disallow multiple revision ranges with --linearize.
- Disallow --contained with --linearize.
- Link to v7: https://patch.msgid.link/20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com
Changes in v7:
- Allow --revert and --linearize to be used together.
- Because quite a lot of changes have been made since the original
  patch, change author from Johannes to Toon for the last commit.
  Johannes already told me he doesn't really care about authorship when
  he initially shared the patch with me.
- Link to v6: https://patch.msgid.link/20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com
Changes in v6:
- Reworked the second commit that moves picking the base completely
  outside pick_regular_commit(), instead of adding more explanation.
- Drastically extended the commit message on commit #3.
- Extended docs on flattening multiple revision ranges and how it's
  different from git-rebase(1)'s --no-rebase-merges.
- Added a bunch of tests to cover various scenarios.
- Remove newline from BUG() message.
- Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com
Changes in v5:
- Dropped the enum->bool patch and instead added a patch that better
  explains how pick_regular_commit() picks a base.
- Order of commits is shuffled.
- (BIGGEST CHANGE) When working on a refactor to undo the enum->bool
  patch, I extended the code comments to explain how things work. This
  made me realize the use of the "replayed_base" was incorrect when
  multiple branches are rebased with --onto. This is fixed now and a
  test is added for this scenario.
- Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com
Changes in v4:
- Use test_grep instead of a bare grep in the range-diff test, to
  prepare for mm/test-grep-lint.
- Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com
Changes in v3:
- Add --linearize to Documentation SYNOPSIS, and mention it's
  incompatible with --revert.
- Small language change in help message for --linearize.
- Rephrase comment to include last_commit isn't modified when
  linearizing merges.
- Remove test that was added in earlier versions, but actually is
  a duplicate of 'replaying merge commits is not supported yet'.
- Add test to verify --revert and --linearize are incompatible.
- Properly test that replaying down to root with --linearize works.
- Add test for --linearize with --advance.
- Add test that uses git-range-diff(1) to verify the patches created by
  --linearize are correct.
- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com
Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
---
Toon Claes (3):
      replay: add helper to put entry into replayed_commits
      replay: resolve the replay base outside pick_regular_commit()
      replay: offer an option to linearize the commit topology
 Documentation/git-replay.adoc |  17 ++++++-
 builtin/replay.c              |   6 ++-
 replay.c                      |  87 +++++++++++++++++++++++----------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 196 insertions(+), 28 deletions(-)
Range-diff versus v8:
1:  9fd3641eaf = 1:  3ea73d7c98 replay: add helper to put entry into replayed_commits
2:  0bd4cd9b9c = 2:  4a40c42684 replay: resolve the replay base outside pick_regular_commit()
3:  45faa926f8 ! 3:  d6247ea743 replay: offer an option to linearize the commit topology
    @@ Commit message
         If a ref was pointing to a merge commit, that ref is updated to the
         merge's last replayed ancestor.
     
    -    git-replay(1) accepts multiple revision ranges, for example:
    +    git-replay(1) accepts multiple branches, for example:
     
             $ git replay --onto main topic1 topic2
     
         Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
    -    independently and updates both refs.
    +    (keeping shared portions of history shared and divergent parts
    +    divergent) and updates both refs.
     
    -    For now this is disallowed with option `--linearize`. Linearizing more
    -    than one branch at once would concatenate unrelated histories into a
    -    single line, and update each branch to some point in that line. That
    -    won't be the result most users want, especially because the order
    -    depends on the order of the revision walk, not the order of the branch
    -    names on the command line.
    -
    -    For the same reason disallow the use of `--contained` with
    -    `--linearize`.
    +    Due to current implementation limitations, replaying multiple branches
    +    with `--linearize` is disallowed to avoid concatenating unrelated
    +    histories into a single line. For the same reason disallow the use of
    +    `--contained` with `--linearize`.
     
         Users who want to linearize multiple branches are advised to do this in
         separate git-replay(1) invocations. Linearizing multiple branches at
    @@ Documentation/git-replay.adoc: SYNOPSIS
      
      DESCRIPTION
      -----------
    -@@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).
    +@@ Documentation/git-replay.adoc: Expanded description list compared to 'replay.refAction'.
      +
      The default mode can be configured via the `replay.refAction` configuration variable.
      
    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif
     +	previously replayed one, so all replayed commits are flattened into
     +	a single linear history.
     ++
    -+When a merge commit is encountered, the behavior of git-rebase(1)'s
    -+option `--no-rebase-merges` is imitated. All commits in the range
    -+reachable from the merge commit are replayed into a linear history, and
    -+the merge commit itself is dropped. A ref that pointed to a merge commit
    -+is updated to the merge's last replayed ancestor.
    ++When a merge commit is encountered, all commits in the range reachable
    ++from the merge commit are replayed into the linear history, and the
    ++merge commit itself is dropped. A ref that pointed to a merge commit is
    ++updated to the merge's last replayed ancestor. (This matches the
    ++behavior of git-rebase(1)'s `--no-rebase-merges` option.)
     ++
    -+Only a single branch can be linearized at a time: `--linearize` cannot
    -+be combined with multiple positive revisions or with `--contained`,
    -+because that would concatenate otherwise unrelated histories into one
    -+line. To linearize several branches, replay them in separate `git
    -+replay` invocations.
    ++`--linearize` cannot be combined with multiple branches or with
    ++`--contained`. To linearize several branches, replay them in separate
    ++`git replay` invocations.
     +
      <revision-range>::
      	Range of commits to replay; see "Specifying Ranges" in
    @@ replay.c: int replay_revisions(struct rev_info *revs,
      
     +	if (opts->linearize &&
     +	    update_refs && strset_get_size(update_refs) > 1) {
    -+		ret = error(_("'--linearize' cannot be used with multiple revision ranges"));
    ++		ret = error(_("'--linearize' cannot be used with multiple branches"));
     +		goto out;
     +	}
     +
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	test_line_count = 3 out
     +'
     +
    -+test_expect_success '--linearize rejects multiple revision ranges' '
    ++test_expect_success '--linearize rejects multiple branches' '
     +	test_must_fail git replay --ref-action=print --linearize \
     +		--onto main ^B topic2 topic3 topic4 2>err &&
    -+	test_grep "cannot be used with multiple revision ranges" err
    ++	test_grep "cannot be used with multiple branches" err
     +'
     +
     +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '

--- base-commit: c73e85354c275c9d409b26445089bc16940fc527 change-id: 20260604-toon-git-replay-drop-merges-807fa008d395

Toon ClaesAug 31, 2026, 13:13 UTC in reply to Toon Claes on lore

[PATCH v9 1/3] replay: add helper to put entry into replayed_commits

The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into a `struct mapped_commits` into a helper function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)
Show changes to replay.c +20 −11
diff --git a/replay.c b/replay.c
index 463c900d6c..860e194ba0 100644
--- a/replay.c
+++ b/replay.c
@@ -254,9 +254,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -267,6 +267,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -287,7 +302,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -427,8 +442,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -440,11 +453,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)
-- 
2.55.0.679.g6767b8d81c
Toon ClaesAug 31, 2026, 13:13 UTC in reply to Toon Claes on lore

[PATCH v9 2/3] replay: resolve the replay base outside pick_regular_commit()

Depending on what gets passed into the function pick_regular_commit(), it decides the new base for the replayed commit. It first tries to find the replayed results of `pickme`'s parent in the `replayed_commits` map. If not found, it falls back to `onto`.

When using git-replay(1) with --onto, the fallback is the revision passed in with this option, but when using --revert, the fallback is `last_commit`.

It's rather confusing the base is decided partly inside pick_regular_commit() and partly by its caller.

Move the base selection completely into the caller: replay_revisions(). This bundles all the logic of deciding on the base together. Also, this reduces the number of parameters of pick_regular_commit(), making its interface cleaner.

This refactoring doesn't bring any behavior changes.
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 34 +++++++++++++++++++++-------------
 1 file changed, 21 insertions(+), 13 deletions(-)
Show changes to replay.c +21 −13
diff --git a/replay.c b/replay.c
index 860e194ba0..7e35f40d37 100644
--- a/replay.c
+++ b/replay.c
@@ -284,25 +284,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits,
 
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
+					  struct commit *replayed_base,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
 					  enum replay_mode mode,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
-	if (pickme->parents) {
-		base = pickme->parents->item;
-		base_tree = repo_get_commit_tree(repo, base);
-	} else {
-		base = NULL;
+	if (pickme->parents)
+		base_tree = repo_get_commit_tree(repo, pickme->parents->item);
+	else
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
-	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -443,12 +437,26 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
+		/*
+		 * Decide where to replay this commit on.
+		 * If the parent commit was replayed already, the replayed result
+		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
+		 * When reverting, commits are replayed in reverse order and thus
+		 * its parent isn't replayed yet. Therefore revert commits are
+		 * always replayed onto `last_commit`.
+		 */
+		struct commit *parent = commit->parents ? commit->parents->item : NULL;
+		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
+
+		if (mode == REPLAY_MODE_REVERT)
+			base = last_commit;
+
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  mode == REPLAY_MODE_REVERT ? last_commit : onto,
-						  &merge_opt, &result, mode, opts->empty);
+		last_commit = pick_regular_commit(revs->repo, commit, base,
+						  &merge_opt, &result,
+						  mode, opts->empty);
 		if (!last_commit)
 			break;
 
-- 
2.55.0.679.g6767b8d81c
Toon ClaesAug 31, 2026, 13:13 UTC in reply to Toon Claes on lore

[PATCH v9 3/3] replay: offer an option to linearize the commit topology

One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the history into a sequence of regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same. Each replayed commit is stacked on top of the previously replayed one. When a merge is encountered, the commits reachable from all of its sides are replayed into the single line and the merge itself is dropped.

If a ref was pointing to a merge commit, that ref is updated to the merge's last replayed ancestor.

git-replay(1) accepts multiple branches, for example:
    $ git replay --onto main topic1 topic2

Without `--linearize` this replays 'topic1' and 'topic2' onto 'main' (keeping shared portions of history shared and divergent parts divergent) and updates both refs.

Due to current implementation limitations, replaying multiple branches with `--linearize` is disallowed to avoid concatenating unrelated histories into a single line. For the same reason disallow the use of `--contained` with `--linearize`.

Users who want to linearize multiple branches are advised to do this in separate git-replay(1) invocations. Linearizing multiple branches at once might be added later.

Note that `--linearize` is not modeled after git-rebase(1)'s `--rebase-merges[=<mode>]` interface. Recreating merges, by preserving their topology, is a distinct operation that would be a separate mode. `--linearize` only drops merges and replays commits linearly. So git-replay(1) uses its own option rather than reusing that interface.

Based-on-patches-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  17 ++++++-
 builtin/replay.c              |   6 ++-
 replay.c                      |  60 +++++++++++++++--------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 174 insertions(+), 23 deletions(-)
Show changes to 5 files +174 −23

Documentation/git-replay.adoc, builtin/replay.c, replay.c, replay.h, t/t3650-replay-basics.sh

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index ea4d14badd..58b4c0c470 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 --------
 [verse]
 (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
+			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
 
 DESCRIPTION
 -----------
@@ -91,6 +91,21 @@ Expanded description list compared to 'replay.refAction'.
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, each replayed commit is stacked on top of the
+	previously replayed one, so all replayed commits are flattened into
+	a single linear history.
++
+When a merge commit is encountered, all commits in the range reachable
+from the merge commit are replayed into the linear history, and the
+merge commit itself is dropped. A ref that pointed to a merge commit is
+updated to the merge's last replayed ancestor. (This matches the
+behavior of git-rebase(1)'s `--no-rebase-merges` option.)
++
+`--linearize` cannot be combined with multiple branches or with
+`--contained`. To linearize several branches, replay them in separate
+`git replay` invocations.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..d39626a37d 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -85,7 +85,7 @@ int cmd_replay(int argc,
 	const char *const replay_usage[] = {
 		N_("(EXPERIMENTAL!) git replay "
 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
+		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
 		NULL
 	};
 	struct option replay_options[] = {
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("drop merge commits, replaying only non-merge commits")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(opts.linearize, "--linearize",
+				  !!opts.contained, "--contained");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 7e35f40d37..6565e4d215 100644
--- a/replay.c
+++ b/replay.c
@@ -404,6 +404,12 @@ int replay_revisions(struct rev_info *revs,
 	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
 			   &detached_head, &advance, &revert, &onto, &update_refs);
 
+	if (opts->linearize &&
+	    update_refs && strset_get_size(update_refs) > 1) {
+		ret = error(_("'--linearize' cannot be used with multiple branches"));
+		goto out;
+	}
+
 	if (opts->ref) {
 		struct object_id oid;
 
@@ -437,26 +443,40 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		/*
-		 * Decide where to replay this commit on.
-		 * If the parent commit was replayed already, the replayed result
-		 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
-		 * When reverting, commits are replayed in reverse order and thus
-		 * its parent isn't replayed yet. Therefore revert commits are
-		 * always replayed onto `last_commit`.
-		 */
-		struct commit *parent = commit->parents ? commit->parents->item : NULL;
-		struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
-
-		if (mode == REPLAY_MODE_REVERT)
-			base = last_commit;
-
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(revs->repo, commit, base,
-						  &merge_opt, &result,
-						  mode, opts->empty);
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * Drop the merge commit: do not pick it, leave
+			 * `last_commit` unchanged, and fall through to the
+			 * rest of the loop. As a result:
+			 * - refs pointing to the merge commit will be updated
+			 *   to `last_commit`.
+			 * - the next replayed commit uses `last_commit` as its
+			 *   `base`.
+			 */
+		} else {
+			/*
+			 * Decide where to replay this commit onto.
+			 * If the parent commit was replayed already, the replayed result
+			 * can be found in `replayed_commits`. Otherwise fall back to `onto`.
+			 * When reverting, commits are replayed in reverse order and thus
+			 * its parent isn't replayed yet. Therefore revert commits are
+			 * always replayed onto `last_commit`.
+			 * Also when opts->linearize is true, set the base to
+			 * `last_commit` to create a single linear history.
+			 */
+			struct commit *parent = commit->parents ? commit->parents->item : NULL;
+			struct commit *base = get_mapped_commit(replayed_commits, parent, onto);
+
+			if (opts->linearize || mode == REPLAY_MODE_REVERT)
+				base = last_commit;
+
+			last_commit = pick_regular_commit(revs->repo, commit, base,
+							  &merge_opt, &result,
+							  mode, opts->empty);
+		}
+
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index 491db145e3..2c71afbfde 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..d3409b9bb1 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -52,8 +52,19 @@ test_expect_success 'setup' '
 	test_merge P O --no-ff &&
 	git switch main &&
 
+	git switch --orphan unrelated &&
+	test_commit unrelated-root &&
+
 	git switch -c conflict B &&
-	test_commit C.conflict C.t conflict
+	test_commit C.conflict C.t conflict &&
+	git branch -D unrelated &&
+
+	git switch -c divergent-x main &&
+	test_commit X &&
+	git switch -c divergent-y main &&
+	test_commit Y &&
+	git switch divergent-x &&
+	test_merge Z divergent-y --no-ff
 '
 
 test_expect_success 'setup bare' '
@@ -565,4 +576,100 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
+	git replay --ref-action=print --linearize \
+		--onto unrelated-root topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I B A unrelated-root >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to cherry-pick merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--advance main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual &&
+
+	printf "update refs/heads/main " >expect &&
+	printf "%s " $(cut -f 3 -d " " result) >>expect &&
+	git rev-parse main >>expect &&
+	test_cmp expect result
+'
+
+test_expect_success 'replay --linearize produces the same patches' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# range-diff does not care about the dropped merge,
+	# so the original commits (I..topic-with-merge)
+	# and the replayed chain (main..tip) must produce identical patches.
+	git range-diff I..topic-with-merge main..$tip >out &&
+	test_file_not_empty out &&
+	test_grep ! -v "=" out &&
+
+	git log --oneline main..$tip >out &&
+	test_line_count = 3 out
+'
+
+test_expect_success '--linearize rejects multiple branches' '
+	test_must_fail git replay --ref-action=print --linearize \
+		--onto main ^B topic2 topic3 topic4 2>err &&
+	test_grep "cannot be used with multiple branches" err
+'
+
+test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '
+	git replay --ref-action=print --linearize \
+		--onto main main..divergent-x >result &&
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# The merge Z is dropped, but both X and Y are linearized onto main;
+	# neither side is lost.
+	git log --format=%s main..$tip >actual &&
+	test_write_lines Y X >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--linearize and --contained cannot be used together' '
+	test_must_fail git replay --ref-action=print --linearize --contained \
+		--onto main ^B topic-with-merge 2>err &&
+	test_grep "cannot be used together" err
+'
+
+test_expect_success 'replay --revert with --linearize reverts a range containing a merge' '
+	git replay --ref-action=print --revert=divergent-x --linearize \
+		main..divergent-x >result &&
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	git log --format=%s $tip >actual &&
+	test_write_lines \
+		"Revert \"X\"" "Revert \"Y\"" Z Y X M L B A >expect &&
+	test_cmp expect actual &&
+
+	test_must_fail git cat-file -e $tip:X.t &&
+	test_must_fail git cat-file -e $tip:Y.t
+'
+
 test_done
-- 
2.55.0.679.g6767b8d81c
Elijah NewrenSep 1, 2026, 22:15 UTC in reply to Toon Claes on lore

Re: [PATCH v9 0/3] Teach git-replay(1) to linearize merge commits

On Mon, Aug 31, 2026 at 6:14 AM Toon Claes <toon@iotcl.com> wrote:
Show 213 quoted lines
>
> Hi,
>
> I'm back with another iteration of the patch series to implement
> --linearize into git-replay(1). My apologies for the long period of
> radio silence, but this topic was stalled on reviews for a while, and
> when I got some feedback I was on leave, so I'm finally back.
>
> As far as I could tell there weren't any remaining comments on the
> implementation itself, only on commit message and docs.
>
> Laters,
> Toon
>
> Original cover letter:
> ===
>
> As an alternative to dscho's patch series to replay merges[1], add
> an option to git-replay(1) to linearize merges. This mimics what
> git-rebase(1) does with --no-rebase-merges (the default).
>
> The first two patches do some refactoring. The third patch implements
> the actual change. The original patch was kindly provided by Dscho,
> which I've tweaked to be upstreamed.
>
> The --linearize option is only added to git-replay(1) and not to
> git-history(1) because in my opinion it doesn't make much sense to do
> so, but I'm happy to hear if anyone disagrees.
>
> Dscho's series to replay merges[1] needs a bit of rework to fit on top
> of this, but I'm happy to help figuring that out. We've been discussing
> to either name the option --flatten or --linearize, but I've decided on
> "linearize" because the documentation of git-rebase(1) also mentions
> "linearize".
>
> [1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>
>
> ---
> Changes in v9:
> - Rephrase "multiple revision ranges" to "multiple branches".
> - Reword some things in the commit message.
> - Tweak wording in replay.adoc.
> - Link to v8: https://patch.msgid.link/20260728-toon-git-replay-drop-merges-v8-0-ced11dffe749@iotcl.com
>
> Changes in v8:
> - Disallow multiple revision ranges with --linearize.
> - Disallow --contained with --linearize.
> - Link to v7: https://patch.msgid.link/20260707-toon-git-replay-drop-merges-v7-0-808ab9b4afa6@iotcl.com
>
> Changes in v7:
> - Allow --revert and --linearize to be used together.
> - Because quite a lot of changes have been made since the original
>   patch, change author from Johannes to Toon for the last commit.
>   Johannes already told me he doesn't really care about authorship when
>   he initially shared the patch with me.
> - Link to v6: https://patch.msgid.link/20260702-toon-git-replay-drop-merges-v6-0-78a07cdd0382@iotcl.com
>
> Changes in v6:
> - Reworked the second commit that moves picking the base completely
>   outside pick_regular_commit(), instead of adding more explanation.
> - Drastically extended the commit message on commit #3.
> - Extended docs on flattening multiple revision ranges and how it's
>   different from git-rebase(1)'s --no-rebase-merges.
> - Added a bunch of tests to cover various scenarios.
> - Remove newline from BUG() message.
> - Link to v5: https://patch.msgid.link/20260626-toon-git-replay-drop-merges-v5-0-5e120738b9d0@iotcl.com
>
> Changes in v5:
> - Dropped the enum->bool patch and instead added a patch that better
>   explains how pick_regular_commit() picks a base.
> - Order of commits is shuffled.
> - (BIGGEST CHANGE) When working on a refactor to undo the enum->bool
>   patch, I extended the code comments to explain how things work. This
>   made me realize the use of the "replayed_base" was incorrect when
>   multiple branches are rebased with --onto. This is fixed now and a
>   test is added for this scenario.
> - Link to v4: https://patch.msgid.link/20260622-toon-git-replay-drop-merges-v4-0-ff257f534319@iotcl.com
>
> Changes in v4:
> - Use test_grep instead of a bare grep in the range-diff test, to
>   prepare for mm/test-grep-lint.
> - Link to v3: https://patch.msgid.link/20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com
>
> Changes in v3:
> - Add --linearize to Documentation SYNOPSIS, and mention it's
>   incompatible with --revert.
> - Small language change in help message for --linearize.
> - Rephrase comment to include last_commit isn't modified when
>   linearizing merges.
> - Remove test that was added in earlier versions, but actually is
>   a duplicate of 'replaying merge commits is not supported yet'.
> - Add test to verify --revert and --linearize are incompatible.
> - Properly test that replaying down to root with --linearize works.
> - Add test for --linearize with --advance.
> - Add test that uses git-range-diff(1) to verify the patches created by
>   --linearize are correct.
> - Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com
>
> Changes in v2:
> - Restructured the conditions to detect merge commits and added a line
>   of comment why the loop continues.
> - Rewrote tests to use the history from the setup step and added a few
>   test cases.
> - Re-added Johannes's Signed-off-by trailer. Johannes gave me the
>   patches with this trailer, and if I understand correctly, I can keep
>   it. Please let me know if that wrong.
> - Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com
>
> ---
> Toon Claes (3):
>       replay: add helper to put entry into replayed_commits
>       replay: resolve the replay base outside pick_regular_commit()
>       replay: offer an option to linearize the commit topology
>
>  Documentation/git-replay.adoc |  17 ++++++-
>  builtin/replay.c              |   6 ++-
>  replay.c                      |  87 +++++++++++++++++++++++----------
>  replay.h                      |   5 ++
>  t/t3650-replay-basics.sh      | 109 +++++++++++++++++++++++++++++++++++++++++-
>  5 files changed, 196 insertions(+), 28 deletions(-)
>
> Range-diff versus v8:
>
> 1:  9fd3641eaf = 1:  3ea73d7c98 replay: add helper to put entry into replayed_commits
> 2:  0bd4cd9b9c = 2:  4a40c42684 replay: resolve the replay base outside pick_regular_commit()
> 3:  45faa926f8 ! 3:  d6247ea743 replay: offer an option to linearize the commit topology
>     @@ Commit message
>          If a ref was pointing to a merge commit, that ref is updated to the
>          merge's last replayed ancestor.
>
>     -    git-replay(1) accepts multiple revision ranges, for example:
>     +    git-replay(1) accepts multiple branches, for example:
>
>              $ git replay --onto main topic1 topic2
>
>          Without `--linearize` this replays 'topic1' and 'topic2' onto 'main'
>     -    independently and updates both refs.
>     +    (keeping shared portions of history shared and divergent parts
>     +    divergent) and updates both refs.
>
>     -    For now this is disallowed with option `--linearize`. Linearizing more
>     -    than one branch at once would concatenate unrelated histories into a
>     -    single line, and update each branch to some point in that line. That
>     -    won't be the result most users want, especially because the order
>     -    depends on the order of the revision walk, not the order of the branch
>     -    names on the command line.
>     -
>     -    For the same reason disallow the use of `--contained` with
>     -    `--linearize`.
>     +    Due to current implementation limitations, replaying multiple branches
>     +    with `--linearize` is disallowed to avoid concatenating unrelated
>     +    histories into a single line. For the same reason disallow the use of
>     +    `--contained` with `--linearize`.
>
>          Users who want to linearize multiple branches are advised to do this in
>          separate git-replay(1) invocations. Linearizing multiple branches at
>     @@ Documentation/git-replay.adoc: SYNOPSIS
>
>       DESCRIPTION
>       -----------
>     -@@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).
>     +@@ Documentation/git-replay.adoc: Expanded description list compared to 'replay.refAction'.
>       +
>       The default mode can be configured via the `replay.refAction` configuration variable.
>
>     @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif
>      +  previously replayed one, so all replayed commits are flattened into
>      +  a single linear history.
>      ++
>     -+When a merge commit is encountered, the behavior of git-rebase(1)'s
>     -+option `--no-rebase-merges` is imitated. All commits in the range
>     -+reachable from the merge commit are replayed into a linear history, and
>     -+the merge commit itself is dropped. A ref that pointed to a merge commit
>     -+is updated to the merge's last replayed ancestor.
>     ++When a merge commit is encountered, all commits in the range reachable
>     ++from the merge commit are replayed into the linear history, and the
>     ++merge commit itself is dropped. A ref that pointed to a merge commit is
>     ++updated to the merge's last replayed ancestor. (This matches the
>     ++behavior of git-rebase(1)'s `--no-rebase-merges` option.)
>      ++
>     -+Only a single branch can be linearized at a time: `--linearize` cannot
>     -+be combined with multiple positive revisions or with `--contained`,
>     -+because that would concatenate otherwise unrelated histories into one
>     -+line. To linearize several branches, replay them in separate `git
>     -+replay` invocations.
>     ++`--linearize` cannot be combined with multiple branches or with
>     ++`--contained`. To linearize several branches, replay them in separate
>     ++`git replay` invocations.
>      +
>       <revision-range>::
>         Range of commits to replay; see "Specifying Ranges" in
>     @@ replay.c: int replay_revisions(struct rev_info *revs,
>
>      +  if (opts->linearize &&
>      +      update_refs && strset_get_size(update_refs) > 1) {
>     -+          ret = error(_("'--linearize' cannot be used with multiple revision ranges"));
>     ++          ret = error(_("'--linearize' cannot be used with multiple branches"));
>      +          goto out;
>      +  }
>      +
>     @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
>      +  test_line_count = 3 out
>      +'
>      +
>     -+test_expect_success '--linearize rejects multiple revision ranges' '
>     ++test_expect_success '--linearize rejects multiple branches' '
>      +  test_must_fail git replay --ref-action=print --linearize \
>      +          --onto main ^B topic2 topic3 topic4 2>err &&
>     -+  test_grep "cannot be used with multiple revision ranges" err
>     ++  test_grep "cannot be used with multiple branches" err
>      +'
>      +
>      +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' '
Thanks for making all these changes!  I think this version is good to go.
Reviewed-by: Elijah Newren <newren@gmail.com>

Back to recent threads