Merge branch 'tc/replay-ref'

The experimental `git replay` command learned the `--ref=<ref>` option
to allow specifying which ref to update, overriding the default behavior.

* tc/replay-ref:
  replay: allow to specify a ref with option --ref
  replay: use stuck form in documentation and help message
  builtin/replay: mark options as not negatable
maint
Junio C Hamano 2026-04-08 10:19:18 -07:00
commit 37a4780f2c
5 changed files with 157 additions and 34 deletions

View File

@ -9,7 +9,8 @@ git-replay - EXPERIMENTAL: Replay commits on a new base, works with bare repos t
SYNOPSIS SYNOPSIS
-------- --------
[verse] [verse]
(EXPERIMENTAL!) 'git replay' ([--contained] --onto <newbase> | --advance <branch> | --revert <branch>) [--ref-action[=<mode>]] <revision-range> (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
[--ref=<ref>] [--ref-action=<mode>] <revision-range>


DESCRIPTION DESCRIPTION
----------- -----------
@ -26,7 +27,7 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
OPTIONS OPTIONS
------- -------


--onto <newbase>:: --onto=<newbase>::
Starting point at which to create the new commits. May be any Starting point at which to create the new commits. May be any
valid commit, and not just an existing branch name. valid commit, and not just an existing branch name.
+ +
@ -34,7 +35,7 @@ When `--onto` is specified, the branch(es) in the revision range will be
updated to point at the new commits, similar to the way `git rebase --update-refs` updated to point at the new commits, similar to the way `git rebase --update-refs`
updates multiple branches in the affected range. updates multiple branches in the affected range.


--advance <branch>:: --advance=<branch>::
Starting point at which to create the new commits; must be a Starting point at which to create the new commits; must be a
branch name. branch name.
+ +
@ -42,7 +43,7 @@ The history is replayed on top of the <branch> and <branch> is updated to
point at the tip of the resulting history. This is different from `--onto`, point at the tip of the resulting history. This is different from `--onto`,
which uses the target only as a starting point without updating it. which uses the target only as a starting point without updating it.


--revert <branch>:: --revert=<branch>::
Starting point at which to create the reverted commits; must be a Starting point at which to create the reverted commits; must be a
branch name. branch name.
+ +
@ -65,6 +66,16 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
Update all branches that point at commits in Update all branches that point at commits in
<revision-range>. Requires `--onto`. <revision-range>. Requires `--onto`.


--ref=<ref>::
Override which reference is updated with the result of the replay.
The ref must be fully qualified.
When used with `--onto`, the `<revision-range>` should have a
single tip and only the specified reference is updated instead of
inferring refs from the revision range.
When used with `--advance` or `--revert`, the specified reference is
updated instead of the branch given to those options.
This option is incompatible with `--contained`.

--ref-action[=<mode>]:: --ref-action[=<mode>]::
Control how references are updated. The mode can be: Control how references are updated. The mode can be:
+ +
@ -79,8 +90,8 @@ The default mode can be configured via the `replay.refAction` configuration vari


<revision-range>:: <revision-range>::
Range of commits to replay; see "Specifying Ranges" in Range of commits to replay; see "Specifying Ranges" in
linkgit:git-rev-parse[1]. In `--advance <branch>` or linkgit:git-rev-parse[1]. In `--advance=<branch>` or
`--revert <branch>` mode, the range should have a single tip, `--revert=<branch>` mode, the range should have a single tip,
so that it's clear to which tip the advanced or reverted so that it's clear to which tip the advanced or reverted
<branch> should point. Any commits in the range whose changes <branch> should point. Any commits in the range whose changes
are already present in the branch the commits are being are already present in the branch the commits are being
@ -127,7 +138,7 @@ EXAMPLES
To simply rebase `mybranch` onto `target`: To simply rebase `mybranch` onto `target`:


------------ ------------
$ git replay --onto target origin/main..mybranch $ git replay --onto=target origin/main..mybranch
------------ ------------


The refs are updated atomically and no output is produced on success. The refs are updated atomically and no output is produced on success.
@ -135,14 +146,14 @@ The refs are updated atomically and no output is produced on success.
To see what would be updated without actually updating: To see what would be updated without actually updating:


------------ ------------
$ git replay --ref-action=print --onto target origin/main..mybranch $ git replay --ref-action=print --onto=target origin/main..mybranch
update refs/heads/mybranch ${NEW_mybranch_HASH} ${OLD_mybranch_HASH} update refs/heads/mybranch ${NEW_mybranch_HASH} ${OLD_mybranch_HASH}
------------ ------------


To cherry-pick the commits from mybranch onto target: To cherry-pick the commits from mybranch onto target:


------------ ------------
$ git replay --advance target origin/main..mybranch $ git replay --advance=target origin/main..mybranch
------------ ------------


Note that the first two examples replay the exact same commits and on Note that the first two examples replay the exact same commits and on
@ -154,7 +165,7 @@ What if you have a stack of branches, one depending upon another, and
you'd really like to rebase the whole set? you'd really like to rebase the whole set?


------------ ------------
$ git replay --contained --onto origin/main origin/main..tipbranch $ git replay --contained --onto=origin/main origin/main..tipbranch
------------ ------------


All three branches (`branch1`, `branch2`, and `tipbranch`) are updated All three branches (`branch1`, `branch2`, and `tipbranch`) are updated
@ -165,7 +176,7 @@ commits to replay using the syntax `A..B`; any range expression will
do: do:


------------ ------------
$ git replay --onto origin/main ^base branch1 branch2 branch3 $ git replay --onto=origin/main ^base branch1 branch2 branch3
------------ ------------


This will simultaneously rebase `branch1`, `branch2`, and `branch3`, This will simultaneously rebase `branch1`, `branch2`, and `branch3`,
@ -176,7 +187,7 @@ that they have in common, but that does not need to be the case.
To revert commits on a branch: To revert commits on a branch:


------------ ------------
$ git replay --revert main topic~2..topic $ git replay --revert=main topic~2..topic
------------ ------------


This reverts the last two commits from `topic`, creating revert commits on This reverts the last two commits from `topic`, creating revert commits on
@ -188,6 +199,16 @@ NOTE: For reverting an entire merge request as a single commit (rather than
commit-by-commit), consider using `git merge-tree --merge-base $TIP HEAD $BASE` commit-by-commit), consider using `git merge-tree --merge-base $TIP HEAD $BASE`
which can avoid unnecessary merge conflicts. which can avoid unnecessary merge conflicts.


To replay onto a specific commit while updating a different reference:

------------
$ git replay --onto=112233 --ref=refs/heads/mybranch aabbcc..ddeeff
------------

This replays the range `aabbcc..ddeeff` onto commit `112233` and updates
`refs/heads/mybranch` to point at the result. This can be useful when you want
to use bare commit IDs instead of branch names.

GIT GIT
--- ---
Part of the linkgit:git[1] suite Part of the linkgit:git[1] suite

View File

@ -84,25 +84,33 @@ int cmd_replay(int argc,


const char *const replay_usage[] = { const char *const replay_usage[] = {
N_("(EXPERIMENTAL!) git replay " N_("(EXPERIMENTAL!) git replay "
"([--contained] --onto <newbase> | --advance <branch> | --revert <branch>) " "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
"[--ref-action[=<mode>]] <revision-range>"), "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
NULL NULL
}; };
struct option replay_options[] = { struct option replay_options[] = {
OPT_STRING(0, "advance", &opts.advance,
N_("branch"),
N_("make replay advance given branch")),
OPT_STRING(0, "onto", &opts.onto,
N_("revision"),
N_("replay onto given commit")),
OPT_BOOL(0, "contained", &opts.contained, OPT_BOOL(0, "contained", &opts.contained,
N_("update all branches that point at commits in <revision-range>")), N_("update all branches that point at commits in <revision-range>")),
OPT_STRING(0, "revert", &opts.revert, OPT_STRING_F(0, "onto", &opts.onto,
N_("branch"), N_("revision"),
N_("revert commits onto given branch")), N_("replay onto given commit"),
OPT_STRING(0, "ref-action", &ref_action, PARSE_OPT_NONEG),
N_("mode"), OPT_STRING_F(0, "advance", &opts.advance,
N_("control ref update behavior (update|print)")), N_("branch"),
N_("make replay advance given branch"),
PARSE_OPT_NONEG),
OPT_STRING_F(0, "revert", &opts.revert,
N_("branch"),
N_("revert commits onto given branch"),
PARSE_OPT_NONEG),
OPT_STRING_F(0, "ref", &opts.ref,
N_("branch"),
N_("reference to update with result"),
PARSE_OPT_NONEG),
OPT_STRING_F(0, "ref-action", &ref_action,
N_("mode"),
N_("control ref update behavior (update|print)"),
PARSE_OPT_NONEG),
OPT_END() OPT_END()
}; };


@ -122,6 +130,8 @@ int cmd_replay(int argc,
opts.contained, "--contained"); opts.contained, "--contained");
die_for_incompatible_opt2(!!opts.revert, "--revert", die_for_incompatible_opt2(!!opts.revert, "--revert",
opts.contained, "--contained"); opts.contained, "--contained");
die_for_incompatible_opt2(!!opts.ref, "--ref",
!!opts.contained, "--contained");


/* Parse ref action mode from command line or config */ /* Parse ref action mode from command line or config */
ref_mode = get_ref_action_mode(repo, ref_action); ref_mode = get_ref_action_mode(repo, ref_action);

View File

@ -358,13 +358,15 @@ int replay_revisions(struct rev_info *revs,
struct commit *last_commit = NULL; struct commit *last_commit = NULL;
struct commit *commit; struct commit *commit;
struct commit *onto = NULL; struct commit *onto = NULL;
struct merge_options merge_opt; struct merge_options merge_opt = { 0 };
struct merge_result result = { struct merge_result result = {
.clean = 1, .clean = 1,
}; };
bool detached_head; bool detached_head;
char *advance; char *advance;
char *revert; char *revert;
const char *ref;
struct object_id old_oid;
enum replay_mode mode = REPLAY_MODE_PICK; enum replay_mode mode = REPLAY_MODE_PICK;
int ret; int ret;


@ -375,6 +377,27 @@ int replay_revisions(struct rev_info *revs,
set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
&detached_head, &advance, &revert, &onto, &update_refs); &detached_head, &advance, &revert, &onto, &update_refs);


if (opts->ref) {
struct object_id oid;

if (update_refs && strset_get_size(update_refs) > 1) {
ret = error(_("'--ref' cannot be used with multiple revision ranges"));
goto out;
}
if (check_refname_format(opts->ref, 0) || !starts_with(opts->ref, "refs/")) {
ret = error(_("'%s' is not a valid refname"), opts->ref);
goto out;
}
ref = opts->ref;
if (!refs_read_ref(get_main_ref_store(revs->repo), opts->ref, &oid))
oidcpy(&old_oid, &oid);
else
oidclr(&old_oid, revs->repo->hash_algo);
} else {
ref = advance ? advance : revert;
oidcpy(&old_oid, &onto->object.oid);
}

if (prepare_revision_walk(revs) < 0) { if (prepare_revision_walk(revs) < 0) {
ret = error(_("error preparing revisions")); ret = error(_("error preparing revisions"));
goto out; goto out;
@ -406,7 +429,7 @@ int replay_revisions(struct rev_info *revs,
kh_value(replayed_commits, pos) = last_commit; kh_value(replayed_commits, pos) = last_commit;


/* Update any necessary branches */ /* Update any necessary branches */
if (advance || revert) if (ref)
continue; continue;


for (decoration = get_name_decoration(&commit->object); for (decoration = get_name_decoration(&commit->object);
@ -440,13 +463,9 @@ int replay_revisions(struct rev_info *revs,
goto out; goto out;
} }


/* In --advance or --revert mode, update the target ref */ if (ref)
if (advance || revert) { replay_result_queue_update(out, ref, &old_oid,
const char *ref = advance ? advance : revert;
replay_result_queue_update(out, ref,
&onto->object.oid,
&last_commit->object.oid); &last_commit->object.oid);
}


ret = 0; ret = 0;



View File

@ -24,6 +24,13 @@ struct replay_revisions_options {
*/ */
const char *onto; const char *onto;


/*
* Reference to update with the result of the replay. This will not
* update any refs from `onto`, `advance`, or `revert`. Ignores
* `contained`.
*/
const char *ref;

/* /*
* Starting point at which to create revert commits; must be a branch * Starting point at which to create revert commits; must be a branch
* name. The branch will be updated to point to the revert commits. * name. The branch will be updated to point to the revert commits.

View File

@ -499,4 +499,70 @@ test_expect_success 'git replay --revert incompatible with --advance' '
test_grep "cannot be used together" error test_grep "cannot be used together" error
' '


test_expect_success 'using --onto with --ref' '
git branch test-ref-onto topic2 &&
test_when_finished "git branch -D test-ref-onto" &&

git replay --ref-action=print --onto=main --ref=refs/heads/test-ref-onto topic1..topic2 >result &&

test_line_count = 1 result &&
test_grep "^update refs/heads/test-ref-onto " result &&

git log --format=%s $(cut -f 3 -d " " result) >actual &&
test_write_lines E D M L B A >expect &&
test_cmp expect actual
'

test_expect_success 'using --advance with --ref' '
git branch test-ref-advance main &&
git branch test-ref-target main &&
test_when_finished "git branch -D test-ref-advance test-ref-target" &&

git replay --ref-action=print --advance=test-ref-advance --ref=refs/heads/test-ref-target topic1..topic2 >result &&

test_line_count = 1 result &&
test_grep "^update refs/heads/test-ref-target " result
'

test_expect_success 'using --revert with --ref' '
git branch test-ref-revert topic4 &&
git branch test-ref-revert-target topic4 &&
test_when_finished "git branch -D test-ref-revert test-ref-revert-target" &&

git replay --ref-action=print --revert=test-ref-revert --ref=refs/heads/test-ref-revert-target topic4~1..topic4 >result &&

test_line_count = 1 result &&
test_grep "^update refs/heads/test-ref-revert-target " result
'

test_expect_success '--ref is incompatible with --contained' '
test_must_fail git replay --onto=main --ref=refs/heads/main --contained topic1..topic2 2>err &&
test_grep "cannot be used together" err
'

test_expect_success '--ref with nonexistent fully-qualified ref' '
test_when_finished "git update-ref -d refs/heads/new-branch" &&

git replay --onto=main --ref=refs/heads/new-branch topic1..topic2 &&

git log --format=%s -2 new-branch >actual &&
test_write_lines E D >expect &&
test_cmp expect actual
'

test_expect_success '--ref must be a valid refname' '
test_must_fail git replay --onto=main --ref="refs/heads/bad..ref" topic1..topic2 2>err &&
test_grep "is not a valid refname" err
'

test_expect_success '--ref requires fully qualified ref' '
test_must_fail git replay --onto=main --ref=main topic1..topic2 2>err &&
test_grep "is not a valid refname" err
'

test_expect_success '--onto with --ref rejects multiple revision ranges' '
test_must_fail git replay --onto=main --ref=refs/heads/topic2 ^topic1 topic2 topic4 2>err &&
test_grep "cannot be used with multiple revision ranges" err
'

test_done test_done