* [PATCH v4 1/3] replay: refactor enum replay_mode into a bool
2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
@ 2026-06-22 12:41 ` Toon Claes
2026-06-22 13:53 ` Patrick Steinhardt
2026-06-22 12:41 ` [PATCH v4 2/3] replay: add helper to put entry into mapped_commits Toon Claes
` (2 subsequent siblings)
3 siblings, 1 reply; 48+ messages in thread
From: Toon Claes @ 2026-06-22 12:41 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool
2026-06-22 12:41 ` [PATCH v4 1/3] replay: refactor enum replay_mode into a bool Toon Claes
@ 2026-06-22 13:53 ` Patrick Steinhardt
2026-06-22 15:43 ` Junio C Hamano
0 siblings, 1 reply; 48+ messages in thread
From: Patrick Steinhardt @ 2026-06-22 13:53 UTC (permalink / raw)
To: Toon Claes; +Cc: git, Elijah Newren
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.
Patrick
^ permalink raw reply [flat|nested] 48+ messages in thread
* Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool
2026-06-22 13:53 ` Patrick Steinhardt
@ 2026-06-22 15:43 ` Junio C Hamano
2026-06-24 19:15 ` Toon Claes
0 siblings, 1 reply; 48+ messages in thread
From: Junio C Hamano @ 2026-06-22 15:43 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: Toon Claes, git, Elijah Newren
Patrick Steinhardt <ps@pks.im> writes:
> 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.
^ permalink raw reply [flat|nested] 48+ messages in thread
* Re: [PATCH v4 1/3] replay: refactor enum replay_mode into a bool
2026-06-22 15:43 ` Junio C Hamano
@ 2026-06-24 19:15 ` Toon Claes
0 siblings, 0 replies; 48+ messages in thread
From: Toon Claes @ 2026-06-24 19:15 UTC (permalink / raw)
To: Junio C Hamano, Patrick Steinhardt; +Cc: git, Elijah Newren
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.
>> 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.
> 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
^ permalink raw reply [flat|nested] 48+ messages in thread
* [PATCH v4 2/3] replay: add helper to put entry into mapped_commits
2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
2026-06-22 12:41 ` [PATCH v4 1/3] replay: refactor enum replay_mode into a bool Toon Claes
@ 2026-06-22 12:41 ` Toon Claes
2026-06-22 13:53 ` Patrick Steinhardt
2026-06-22 12:41 ` [PATCH v4 3/3] replay: offer an option to linearize the commit topology Toon Claes
2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
3 siblings, 1 reply; 48+ messages in thread
From: Toon Claes @ 2026-06-22 12:41 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* Re: [PATCH v4 2/3] replay: add helper to put entry into mapped_commits
2026-06-22 12:41 ` [PATCH v4 2/3] replay: add helper to put entry into mapped_commits Toon Claes
@ 2026-06-22 13:53 ` Patrick Steinhardt
0 siblings, 0 replies; 48+ messages in thread
From: Patrick Steinhardt @ 2026-06-22 13:53 UTC (permalink / raw)
To: Toon Claes; +Cc: git, Elijah Newren
On Mon, Jun 22, 2026 at 02:41:56PM +0200, Toon Claes wrote:
> 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
^ permalink raw reply [flat|nested] 48+ messages in thread
* [PATCH v4 3/3] replay: offer an option to linearize the commit topology
2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
2026-06-22 12:41 ` [PATCH v4 1/3] replay: refactor enum replay_mode into a bool Toon Claes
2026-06-22 12:41 ` [PATCH v4 2/3] replay: add helper to put entry into mapped_commits Toon Claes
@ 2026-06-22 12:41 ` Toon Claes
2026-06-22 13:53 ` Patrick Steinhardt
2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
3 siblings, 1 reply; 48+ messages in thread
From: Toon Claes @ 2026-06-22 12:41 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Johannes Schindelin, Toon Claes,
Johannes Schindelin
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology
2026-06-22 12:41 ` [PATCH v4 3/3] replay: offer an option to linearize the commit topology Toon Claes
@ 2026-06-22 13:53 ` Patrick Steinhardt
2026-06-26 5:36 ` Toon Claes
0 siblings, 1 reply; 48+ messages in thread
From: Patrick Steinhardt @ 2026-06-22 13:53 UTC (permalink / raw)
To: Toon Claes; +Cc: git, Elijah Newren, Johannes Schindelin
On Mon, Jun 22, 2026 at 02:41:57PM +0200, Toon Claes 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.
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.
> 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.
> @@ -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
^ permalink raw reply [flat|nested] 48+ messages in thread* Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology
2026-06-22 13:53 ` Patrick Steinhardt
@ 2026-06-26 5:36 ` Toon Claes
2026-06-29 8:04 ` Patrick Steinhardt
0 siblings, 1 reply; 48+ messages in thread
From: Toon Claes @ 2026-06-26 5:36 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Elijah Newren, Johannes Schindelin
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?
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.
>> 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.
>> @@ -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
^ permalink raw reply [flat|nested] 48+ messages in thread* Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology
2026-06-26 5:36 ` Toon Claes
@ 2026-06-29 8:04 ` Patrick Steinhardt
2026-06-30 9:44 ` Johannes Schindelin
0 siblings, 1 reply; 48+ messages in thread
From: Patrick Steinhardt @ 2026-06-29 8:04 UTC (permalink / raw)
To: Toon Claes; +Cc: git, Elijah Newren, Johannes Schindelin
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.
> 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"
> 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
^ permalink raw reply [flat|nested] 48+ messages in thread* Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology
2026-06-29 8:04 ` Patrick Steinhardt
@ 2026-06-30 9:44 ` Johannes Schindelin
2026-06-30 11:32 ` Patrick Steinhardt
0 siblings, 1 reply; 48+ messages in thread
From: Johannes Schindelin @ 2026-06-30 9:44 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: Toon Claes, git, Elijah Newren
Hi Patrick & Toon,
On Tue, 30 Jun 2026, Patrick Steinhardt wrote:
> 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.
> > 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.
> > 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
> 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
>
>
^ permalink raw reply [flat|nested] 48+ messages in thread
* Re: [PATCH v4 3/3] replay: offer an option to linearize the commit topology
2026-06-30 9:44 ` Johannes Schindelin
@ 2026-06-30 11:32 ` Patrick Steinhardt
0 siblings, 0 replies; 48+ messages in thread
From: Patrick Steinhardt @ 2026-06-30 11:32 UTC (permalink / raw)
To: Johannes Schindelin; +Cc: Toon Claes, git, Elijah Newren
On Tue, Jun 30, 2026 at 11:44:47AM +0200, Johannes Schindelin wrote:
> 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]
> > 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
^ permalink raw reply [flat|nested] 48+ messages in thread
* [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits
2026-06-22 12:41 ` [PATCH v4 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
` (2 preceding siblings ...)
2026-06-22 12:41 ` [PATCH v4 3/3] replay: offer an option to linearize the commit topology Toon Claes
@ 2026-06-26 5:48 ` Toon Claes
2026-06-26 5:48 ` [PATCH v5 1/3] replay: add helper to put entry into mapped_commits Toon Claes
` (4 more replies)
3 siblings, 5 replies; 48+ messages in thread
From: Toon Claes @ 2026-06-26 5:48 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes, Johannes Schindelin,
Johannes Schindelin
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
^ permalink raw reply [flat|nested] 48+ messages in thread* [PATCH v5 1/3] replay: add helper to put entry into mapped_commits
2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
@ 2026-06-26 5:48 ` Toon Claes
2026-06-26 16:50 ` Junio C Hamano
2026-06-26 5:48 ` [PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base Toon Claes
` (3 subsequent siblings)
4 siblings, 1 reply; 48+ messages in thread
From: Toon Claes @ 2026-06-26 5:48 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* Re: [PATCH v5 1/3] replay: add helper to put entry into mapped_commits
2026-06-26 5:48 ` [PATCH v5 1/3] replay: add helper to put entry into mapped_commits Toon Claes
@ 2026-06-26 16:50 ` Junio C Hamano
0 siblings, 0 replies; 48+ messages in thread
From: Junio C Hamano @ 2026-06-26 16:50 UTC (permalink / raw)
To: Toon Claes; +Cc: git, Elijah Newren
Toon Claes <toon@iotcl.com> writes:
> +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.
^ permalink raw reply [flat|nested] 48+ messages in thread
* [PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base
2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
2026-06-26 5:48 ` [PATCH v5 1/3] replay: add helper to put entry into mapped_commits Toon Claes
@ 2026-06-26 5:48 ` Toon Claes
2026-06-26 5:48 ` [PATCH v5 3/3] replay: offer an option to linearize the commit topology Toon Claes
` (2 subsequent siblings)
4 siblings, 0 replies; 48+ messages in thread
From: Toon Claes @ 2026-06-26 5:48 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* [PATCH v5 3/3] replay: offer an option to linearize the commit topology
2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
2026-06-26 5:48 ` [PATCH v5 1/3] replay: add helper to put entry into mapped_commits Toon Claes
2026-06-26 5:48 ` [PATCH v5 2/3] replay: better explain how pick_regular_commit() picks a base Toon Claes
@ 2026-06-26 5:48 ` Toon Claes
2026-06-26 17:10 ` Junio C Hamano
2026-06-28 12:20 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Johannes Schindelin
2026-07-02 17:58 ` [PATCH v6 " Toon Claes
4 siblings, 1 reply; 48+ messages in thread
From: Toon Claes @ 2026-06-26 5:48 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Johannes Schindelin, Toon Claes,
Johannes Schindelin
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology
2026-06-26 5:48 ` [PATCH v5 3/3] replay: offer an option to linearize the commit topology Toon Claes
@ 2026-06-26 17:10 ` Junio C Hamano
2026-06-27 13:44 ` Phillip Wood
2026-07-01 8:50 ` Toon Claes
0 siblings, 2 replies; 48+ messages in thread
From: Junio C Hamano @ 2026-06-26 17:10 UTC (permalink / raw)
To: Toon Claes; +Cc: git, Elijah Newren, Johannes Schindelin
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?
> + 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
^ permalink raw reply related [flat|nested] 48+ messages in thread* Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology
2026-06-26 17:10 ` Junio C Hamano
@ 2026-06-27 13:44 ` Phillip Wood
2026-07-01 8:50 ` Toon Claes
1 sibling, 0 replies; 48+ messages in thread
From: Phillip Wood @ 2026-06-27 13:44 UTC (permalink / raw)
To: Junio C Hamano, Toon Claes; +Cc: git, Elijah Newren, Johannes Schindelin
On 26/06/2026 18:10, Junio C Hamano wrote:
> 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
> 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
>
^ permalink raw reply [flat|nested] 48+ messages in thread* Re: [PATCH v5 3/3] replay: offer an option to linearize the commit topology
2026-06-26 17:10 ` Junio C Hamano
2026-06-27 13:44 ` Phillip Wood
@ 2026-07-01 8:50 ` Toon Claes
1 sibling, 0 replies; 48+ messages in thread
From: Toon Claes @ 2026-07-01 8:50 UTC (permalink / raw)
To: Junio C Hamano; +Cc: git, Elijah Newren, Johannes Schindelin
Junio C Hamano <gitster@pobox.com> writes:
> 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>
>> + 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.
> 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
^ permalink raw reply [flat|nested] 48+ messages in thread
* Re: [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits
2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
` (2 preceding siblings ...)
2026-06-26 5:48 ` [PATCH v5 3/3] replay: offer an option to linearize the commit topology Toon Claes
@ 2026-06-28 12:20 ` Johannes Schindelin
2026-06-30 13:42 ` Toon Claes
2026-07-02 17:58 ` [PATCH v6 " Toon Claes
4 siblings, 1 reply; 48+ messages in thread
From: Johannes Schindelin @ 2026-06-28 12:20 UTC (permalink / raw)
To: Toon Claes; +Cc: git, Elijah Newren
Hi Toon,
On Fri, 26 Jun 2026, Toon Claes wrote:
> - (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
^ permalink raw reply [flat|nested] 48+ messages in thread* Re: [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits
2026-06-28 12:20 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Johannes Schindelin
@ 2026-06-30 13:42 ` Toon Claes
0 siblings, 0 replies; 48+ messages in thread
From: Toon Claes @ 2026-06-30 13:42 UTC (permalink / raw)
To: Johannes Schindelin; +Cc: git, Elijah Newren, Patrick Steinhardt
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.
> 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
^ permalink raw reply [flat|nested] 48+ messages in thread
* [PATCH v6 0/3] Teach git-replay(1) to linearize merge commits
2026-06-26 5:48 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Toon Claes
` (3 preceding siblings ...)
2026-06-28 12:20 ` [PATCH v5 0/3] Teach git-replay(1) to linearize merge commits Johannes Schindelin
@ 2026-07-02 17:58 ` Toon Claes
2026-07-02 17:58 ` [PATCH v6 1/3] replay: add helper to put entry into replayed_commits Toon Claes
` (2 more replies)
4 siblings, 3 replies; 48+ messages in thread
From: Toon Claes @ 2026-07-02 17:58 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes, Johannes Schindelin,
Johannes Schindelin
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
^ permalink raw reply [flat|nested] 48+ messages in thread* [PATCH v6 1/3] replay: add helper to put entry into replayed_commits
2026-07-02 17:58 ` [PATCH v6 " Toon Claes
@ 2026-07-02 17:58 ` Toon Claes
2026-07-02 17:58 ` [PATCH v6 2/3] replay: resolve the replay base outside pick_regular_commit() Toon Claes
2026-07-02 17:58 ` [PATCH v6 3/3] replay: offer an option to linearize the commit topology Toon Claes
2 siblings, 0 replies; 48+ messages in thread
From: Toon Claes @ 2026-07-02 17:58 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes, Johannes Schindelin
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* [PATCH v6 2/3] replay: resolve the replay base outside pick_regular_commit()
2026-07-02 17:58 ` [PATCH v6 " Toon Claes
2026-07-02 17:58 ` [PATCH v6 1/3] replay: add helper to put entry into replayed_commits Toon Claes
@ 2026-07-02 17:58 ` Toon Claes
2026-07-02 17:58 ` [PATCH v6 3/3] replay: offer an option to linearize the commit topology Toon Claes
2 siblings, 0 replies; 48+ messages in thread
From: Toon Claes @ 2026-07-02 17:58 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes, Johannes Schindelin
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* [PATCH v6 3/3] replay: offer an option to linearize the commit topology
2026-07-02 17:58 ` [PATCH v6 " Toon Claes
2026-07-02 17:58 ` [PATCH v6 1/3] replay: add helper to put entry into replayed_commits Toon Claes
2026-07-02 17:58 ` [PATCH v6 2/3] replay: resolve the replay base outside pick_regular_commit() Toon Claes
@ 2026-07-02 17:58 ` Toon Claes
2026-07-03 20:57 ` Junio C Hamano
2 siblings, 1 reply; 48+ messages in thread
From: Toon Claes @ 2026-07-02 17:58 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, Toon Claes, Johannes Schindelin,
Johannes Schindelin
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(-)
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
^ permalink raw reply related [flat|nested] 48+ messages in thread* Re: [PATCH v6 3/3] replay: offer an option to linearize the commit topology
2026-07-02 17:58 ` [PATCH v6 3/3] replay: offer an option to linearize the commit topology Toon Claes
@ 2026-07-03 20:57 ` Junio C Hamano
0 siblings, 0 replies; 48+ messages in thread
From: Junio C Hamano @ 2026-07-03 20:57 UTC (permalink / raw)
To: Toon Claes; +Cc: git, Elijah Newren, Johannes Schindelin
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?
> +--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?
^ permalink raw reply [flat|nested] 48+ messages in thread