replay: add --revert mode to reverse commit changes

Add a `--revert <branch>` mode to git replay that undoes the changes introduced by the specified commits. Like --onto and --advance, --revert is a standalone mode: it takes a branch argument and updates that branch with the newly created revert commits. At GitLab, we need this in Gitaly for reverting commits directly on bare repositories without requiring a working tree checkout. The approach is the same as sequencer.c's do_pick_commit() -- cherry-pick and revert are just the same three-way merge with swapped arguments: - Cherry-pick: merge(ancestor=parent, ours=current, theirs=commit) - Revert: merge(ancestor=commit, ours=current, theirs=parent) We swap the base and pickme trees passed to merge_incore_nonrecursive() to reverse the diff direction. Reverts are processed newest-first (matching git revert behavior) to reduce conflicts by peeling off changes from the top. Each revert builds on the result of the previous one via the last_commit fallback in the main replay loop, rather than relying on the parent-mapping used for cherry-pick. Revert commit messages follow the usual git revert conventions: prefixed with "Revert" (or "Reapply" when reverting a revert), and including "This reverts commit <hash>.". The author is set to the current user rather than preserving the original author, matching git revert behavior. Helped-by: Christian Couder <christian.couder@gmail.com> Helped-by: Patrick Steinhardt <ps@pks.im> Helped-by: Elijah Newren <newren@gmail.com> Helped-by: Phillip Wood <phillip.wood123@gmail.com> Helped-by: Johannes Schindelin <Johannes.Schindelin@gmx.de> Helped-by: Junio C Hamano <gitster@pobox.com> Helped-by: Toon Claes <toon@iotcl.com> Signed-off-by: Siddharth Asthana <siddharthasthana31@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Siddharth Asthana committed Mar 26, 2026 at 01:53 UTC 2760ee49834953c0860fa5d7983a6af4d27cb6a9
5 files changed +302 -69
Documentation/git-replay.adoc
+44 -8
@@ -9,7 +9,7 @@ git-replay - EXPERIMENTAL: Replay commits on a new base, works with bare repos t
9 SYNOPSIS
10 --------
11 [verse]
12 -(EXPERIMENTAL!) 'git replay' ([--contained] --onto <newbase> | --advance <branch>) [--ref-action[=<mode>]] <revision-range>
12 +(EXPERIMENTAL!) 'git replay' ([--contained] --onto <newbase> | --advance <branch> | --revert <branch>) [--ref-action[=<mode>]] <revision-range>
13
14 DESCRIPTION
15 -----------
@@ -42,6 +42,25 @@ The history is replayed on top of the <branch> and <branch> is updated to
42 point at the tip of the resulting history. This is different from `--onto`,
43 which uses the target only as a starting point without updating it.
44
45 +--revert <branch>::
46 + Starting point at which to create the reverted commits; must be a
47 + branch name.
48 ++
49 +When `--revert` is specified, the commits in the revision range are reverted
50 +(their changes are undone) and the reverted commits are created on top of
51 +<branch>. The <branch> is then updated to point at the new commits. This is
52 +the same as running `git revert <revision-range>` but does not update the
53 +working tree.
54 ++
55 +The commit messages follow `git revert` conventions: they are prefixed with
56 +"Revert" and include "This reverts commit <hash>." When reverting a commit
57 +whose message starts with "Revert", the new message uses "Reapply" instead.
58 +Unlike cherry-pick which preserves the original author, revert commits use
59 +the current user as the author, matching the behavior of `git revert`.
60 ++
61 +This option is mutually exclusive with `--onto` and `--advance`. It is also
62 +incompatible with `--contained` (which is a modifier for `--onto` only).
63 +
64 --contained::
65 Update all branches that point at commits in
66 <revision-range>. Requires `--onto`.
@@ -60,10 +79,11 @@ The default mode can be configured via the `replay.refAction` configuration vari
79
80 <revision-range>::
81 Range of commits to replay; see "Specifying Ranges" in
63 - linkgit:git-rev-parse[1]. In `--advance <branch>` mode, the
64 - range should have a single tip, so that it's clear to which tip the
65 - advanced <branch> should point. Any commits in the range whose
66 - changes are already present in the branch the commits are being
82 + linkgit:git-rev-parse[1]. In `--advance <branch>` or
83 + `--revert <branch>` mode, the range should have a single tip,
84 + so that it's clear to which tip the advanced or reverted
85 + <branch> should point. Any commits in the range whose changes
86 + are already present in the branch the commits are being
87 replayed onto will be dropped.
88
89 :git-replay: 1
@@ -84,9 +104,10 @@ When using `--ref-action=print`, the output is usable as input to
104 update refs/heads/branch3 ${NEW_branch3_HASH} ${OLD_branch3_HASH}
105
106 where the number of refs updated depends on the arguments passed and
87 -the shape of the history being replayed. When using `--advance`, the
88 -number of refs updated is always one, but for `--onto`, it can be one
89 -or more (rebasing multiple branches simultaneously is supported).
107 +the shape of the history being replayed. When using `--advance` or
108 +`--revert`, the number of refs updated is always one, but for `--onto`,
109 +it can be one or more (rebasing multiple branches simultaneously is
110 +supported).
111
112 There is no stderr output on conflicts; see the <<exit-status,EXIT
113 STATUS>> section below.
@@ -152,6 +173,21 @@ all commits they have since `base`, playing them on top of
173 `origin/main`. These three branches may have commits on top of `base`
174 that they have in common, but that does not need to be the case.
175
176 +To revert commits on a branch:
177 +
178 +------------
179 +$ git replay --revert main topic~2..topic
180 +------------
181 +
182 +This reverts the last two commits from `topic`, creating revert commits on
183 +top of `main`, and updates `main` to point at the result. This is useful when
184 +commits from `topic` were previously merged or cherry-picked into `main` and
185 +need to be undone.
186 +
187 +NOTE: For reverting an entire merge request as a single commit (rather than
188 +commit-by-commit), consider using `git merge-tree --merge-base $TIP HEAD $BASE`
189 +which can avoid unnecessary merge conflicts.
190 +
191 GIT
192 ---
193 Part of the linkgit:git[1] suite
builtin/replay.c
+27 -9
@@ -79,11 +79,12 @@ int cmd_replay(int argc,
79 struct ref_transaction *transaction = NULL;
80 struct strbuf transaction_err = STRBUF_INIT;
81 struct strbuf reflog_msg = STRBUF_INIT;
82 + int desired_reverse;
83 int ret = 0;
84
85 const char *const replay_usage[] = {
86 N_("(EXPERIMENTAL!) git replay "
86 - "([--contained] --onto <newbase> | --advance <branch>) "
87 + "([--contained] --onto <newbase> | --advance <branch> | --revert <branch>) "
88 "[--ref-action[=<mode>]] <revision-range>"),
89 NULL
90 };
@@ -96,6 +97,9 @@ int cmd_replay(int argc,
97 N_("replay onto given commit")),
98 OPT_BOOL(0, "contained", &opts.contained,
99 N_("update all branches that point at commits in <revision-range>")),
100 + OPT_STRING(0, "revert", &opts.revert,
101 + N_("branch"),
102 + N_("revert commits onto given branch")),
103 OPT_STRING(0, "ref-action", &ref_action,
104 N_("mode"),
105 N_("control ref update behavior (update|print)")),
@@ -105,19 +109,31 @@ int cmd_replay(int argc,
109 argc = parse_options(argc, argv, prefix, replay_options, replay_usage,
110 PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);
111
108 - if (!opts.onto && !opts.advance) {
109 - error(_("option --onto or --advance is mandatory"));
112 + /* Exactly one mode must be specified */
113 + if (!opts.onto && !opts.advance && !opts.revert) {
114 + error(_("exactly one of --onto, --advance, or --revert is required"));
115 usage_with_options(replay_usage, replay_options);
116 }
117
118 + die_for_incompatible_opt3(!!opts.onto, "--onto",
119 + !!opts.advance, "--advance",
120 + !!opts.revert, "--revert");
121 die_for_incompatible_opt2(!!opts.advance, "--advance",
122 opts.contained, "--contained");
115 - die_for_incompatible_opt2(!!opts.advance, "--advance",
116 - !!opts.onto, "--onto");
123 + die_for_incompatible_opt2(!!opts.revert, "--revert",
124 + opts.contained, "--contained");
125
126 /* Parse ref action mode from command line or config */
127 ref_mode = get_ref_action_mode(repo, ref_action);
128
129 + /*
130 + * Cherry-pick/rebase need oldest-first ordering so that each
131 + * replayed commit can build on its already-replayed parent.
132 + * Revert needs newest-first ordering (like git revert) to
133 + * reduce conflicts by peeling off changes from the top.
134 + */
135 + desired_reverse = !opts.revert;
136 +
137 repo_init_revisions(repo, &revs, prefix);
138
139 /*
@@ -129,7 +145,7 @@ int cmd_replay(int argc,
145 * some options changing these values if we think they could
146 * be useful.
147 */
132 - revs.reverse = 1;
148 + revs.reverse = desired_reverse;
149 revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
150 revs.topo_order = 1;
151 revs.simplify_history = 0;
@@ -144,11 +160,11 @@ int cmd_replay(int argc,
160 * Detect and warn if we override some user specified rev
161 * walking options.
162 */
147 - if (revs.reverse != 1) {
163 + if (revs.reverse != desired_reverse) {
164 warning(_("some rev walking options will be overridden as "
165 "'%s' bit in 'struct rev_info' will be forced"),
166 "reverse");
151 - revs.reverse = 1;
167 + revs.reverse = desired_reverse;
168 }
169 if (revs.sort_order != REV_SORT_IN_GRAPH_ORDER) {
170 warning(_("some rev walking options will be overridden as "
@@ -174,7 +190,9 @@ int cmd_replay(int argc,
190 goto cleanup;
191
192 /* Build reflog message */
177 - if (opts.advance) {
193 + if (opts.revert) {
194 + strbuf_addf(&reflog_msg, "replay --revert %s", opts.revert);
195 + } else if (opts.advance) {
196 strbuf_addf(&reflog_msg, "replay --advance %s", opts.advance);
197 } else {
198 struct object_id oid;
replay.c
+118 -43
@@ -8,9 +8,15 @@
8 #include "refs.h"
9 #include "replay.h"
10 #include "revision.h"
11 +#include "sequencer.h"
12 #include "strmap.h"
13 #include "tree.h"
14
15 +enum replay_mode {
16 + REPLAY_MODE_PICK,
17 + REPLAY_MODE_REVERT,
18 +};
19 +
20 static const char *short_commit_name(struct repository *repo,
21 struct commit *commit)
22 {
@@ -44,15 +50,37 @@ static char *get_author(const char *message)
50 return NULL;
51 }
52
53 +static void generate_revert_message(struct strbuf *msg,
54 + struct commit *commit,
55 + struct repository *repo)
56 +{
57 + const char *out_enc = get_commit_output_encoding();
58 + const char *message = repo_logmsg_reencode(repo, commit, NULL, out_enc);
59 + const char *subject_start;
60 + int subject_len;
61 + char *subject;
62 +
63 + subject_len = find_commit_subject(message, &subject_start);
64 + subject = xmemdupz(subject_start, subject_len);
65 +
66 + sequencer_format_revert_message(repo, subject, commit,
67 + commit->parents ? commit->parents->item : NULL,
68 + false, msg);
69 +
70 + free(subject);
71 + repo_unuse_commit_buffer(repo, commit, message);
72 +}
73 +
74 static struct commit *create_commit(struct repository *repo,
75 struct tree *tree,
76 struct commit *based_on,
50 - struct commit *parent)
77 + struct commit *parent,
78 + enum replay_mode mode)
79 {
80 struct object_id ret;
81 struct object *obj = NULL;
82 struct commit_list *parents = NULL;
55 - char *author;
83 + char *author = NULL;
84 char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
85 struct commit_extra_header *extra = NULL;
86 struct strbuf msg = STRBUF_INIT;
@@ -64,9 +92,16 @@ static struct commit *create_commit(struct repository *repo,
92
93 commit_list_insert(parent, &parents);
94 extra = read_commit_extra_headers(based_on, exclude_gpgsig);
67 - find_commit_subject(message, &orig_message);
68 - strbuf_addstr(&msg, orig_message);
69 - author = get_author(message);
95 + if (mode == REPLAY_MODE_REVERT) {
96 + generate_revert_message(&msg, based_on, repo);
97 + /* For revert, use current user as author (NULL = use default) */
98 + } else if (mode == REPLAY_MODE_PICK) {
99 + find_commit_subject(message, &orig_message);
100 + strbuf_addstr(&msg, orig_message);
101 + author = get_author(message);
102 + } else {
103 + BUG("unexpected replay mode %d", mode);
104 + }
105 reset_ident_date();
106 if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
107 &ret, author, NULL, sign_commit, extra)) {
@@ -147,11 +182,35 @@ static void get_ref_information(struct repository *repo,
182 }
183 }
184
185 +static void set_up_branch_mode(struct repository *repo,
186 + char **branch_name,
187 + const char *option_name,
188 + struct ref_info *rinfo,
189 + struct commit **onto)
190 +{
191 + struct object_id oid;
192 + char *fullname = NULL;
193 +
194 + if (repo_dwim_ref(repo, *branch_name, strlen(*branch_name),
195 + &oid, &fullname, 0) == 1) {
196 + free(*branch_name);
197 + *branch_name = fullname;
198 + } else {
199 + die(_("argument to %s must be a reference"), option_name);
200 + }
201 + *onto = peel_committish(repo, *branch_name, option_name);
202 + if (rinfo->positive_refexprs > 1)
203 + die(_("'%s' cannot be used with multiple revision ranges "
204 + "because the ordering would be ill-defined"),
205 + option_name);
206 +}
207 +
208 static void set_up_replay_mode(struct repository *repo,
209 struct rev_cmdline_info *cmd_info,
210 const char *onto_name,
211 bool *detached_head,
212 char **advance_name,
213 + char **revert_name,
214 struct commit **onto,
215 struct strset **update_refs)
216 {
@@ -166,9 +225,6 @@ static void set_up_replay_mode(struct repository *repo,
225 if (!rinfo.positive_refexprs)
226 die(_("need some commits to replay"));
227
169 - if (!onto_name == !*advance_name)
170 - BUG("one and only one of onto_name and *advance_name must be given");
171 -
228 if (onto_name) {
229 *onto = peel_committish(repo, onto_name, "--onto");
230 if (rinfo.positive_refexprs <
@@ -177,23 +233,12 @@ static void set_up_replay_mode(struct repository *repo,
233 *update_refs = xcalloc(1, sizeof(**update_refs));
234 **update_refs = rinfo.positive_refs;
235 memset(&rinfo.positive_refs, 0, sizeof(**update_refs));
236 + } else if (*advance_name) {
237 + set_up_branch_mode(repo, advance_name, "--advance", &rinfo, onto);
238 + } else if (*revert_name) {
239 + set_up_branch_mode(repo, revert_name, "--revert", &rinfo, onto);
240 } else {
181 - struct object_id oid;
182 - char *fullname = NULL;
183 -
184 - if (!*advance_name)
185 - BUG("expected either onto_name or *advance_name in this function");
186 -
187 - if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name),
188 - &oid, &fullname, 0) == 1) {
189 - free(*advance_name);
190 - *advance_name = fullname;
191 - } else {
192 - die(_("argument to --advance must be a reference"));
193 - }
194 - *onto = peel_committish(repo, *advance_name, "--advance");
195 - if (rinfo.positive_refexprs > 1)
196 - die(_("cannot advance target with multiple sources because ordering would be ill-defined"));
241 + BUG("expected one of onto_name, *advance_name, or *revert_name");
242 }
243 strset_clear(&rinfo.negative_refs);
244 strset_clear(&rinfo.positive_refs);
@@ -214,7 +259,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
259 kh_oid_map_t *replayed_commits,
260 struct commit *onto,
261 struct merge_options *merge_opt,
217 - struct merge_result *result)
262 + struct merge_result *result,
263 + enum replay_mode mode)
264 {
265 struct commit *base, *replayed_base;
266 struct tree *pickme_tree, *base_tree, *replayed_base_tree;
@@ -226,25 +272,45 @@ static struct commit *pick_regular_commit(struct repository *repo,
272 pickme_tree = repo_get_commit_tree(repo, pickme);
273 base_tree = repo_get_commit_tree(repo, base);
274
229 - merge_opt->branch1 = short_commit_name(repo, replayed_base);
230 - merge_opt->branch2 = short_commit_name(repo, pickme);
231 - merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
232 -
233 - merge_incore_nonrecursive(merge_opt,
234 - base_tree,
235 - replayed_base_tree,
236 - pickme_tree,
237 - result);
238 -
239 - free((char*)merge_opt->ancestor);
275 + if (mode == REPLAY_MODE_PICK) {
276 + /* Cherry-pick: normal order */
277 + merge_opt->branch1 = short_commit_name(repo, replayed_base);
278 + merge_opt->branch2 = short_commit_name(repo, pickme);
279 + merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
280 +
281 + merge_incore_nonrecursive(merge_opt,
282 + base_tree,
283 + replayed_base_tree,
284 + pickme_tree,
285 + result);
286 +
287 + free((char *)merge_opt->ancestor);
288 + } else if (mode == REPLAY_MODE_REVERT) {
289 + /* Revert: swap base and pickme to reverse the diff */
290 + const char *pickme_name = short_commit_name(repo, pickme);
291 + merge_opt->branch1 = short_commit_name(repo, replayed_base);
292 + merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
293 + merge_opt->ancestor = pickme_name;
294 +
295 + merge_incore_nonrecursive(merge_opt,
296 + pickme_tree,
297 + replayed_base_tree,
298 + base_tree,
299 + result);
300 +
301 + free((char *)merge_opt->branch2);
302 + } else {
303 + BUG("unexpected replay mode %d", mode);
304 + }
305 merge_opt->ancestor = NULL;
306 + merge_opt->branch2 = NULL;
307 if (!result->clean)
308 return NULL;
309 /* Drop commits that become empty */
310 if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&
311 !oideq(&pickme_tree->object.oid, &base_tree->object.oid))
312 return replayed_base;
247 - return create_commit(repo, result->tree, pickme, replayed_base);
313 + return create_commit(repo, result->tree, pickme, replayed_base, mode);
314 }
315
316 void replay_result_release(struct replay_result *result)
@@ -281,11 +347,16 @@ int replay_revisions(struct rev_info *revs,
347 };
348 bool detached_head;
349 char *advance;
350 + char *revert;
351 + enum replay_mode mode = REPLAY_MODE_PICK;
352 int ret;
353
354 advance = xstrdup_or_null(opts->advance);
355 + revert = xstrdup_or_null(opts->revert);
356 + if (revert)
357 + mode = REPLAY_MODE_REVERT;
358 set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
288 - &detached_head, &advance, &onto, &update_refs);
359 + &detached_head, &advance, &revert, &onto, &update_refs);
360
361 /* FIXME: Should allow replaying commits with the first as a root commit */
362
@@ -309,7 +380,8 @@ int replay_revisions(struct rev_info *revs,
380 die(_("replaying merge commits is not supported yet!"));
381
382 last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
312 - onto, &merge_opt, &result);
383 + mode == REPLAY_MODE_REVERT ? last_commit : onto,
384 + &merge_opt, &result, mode);
385 if (!last_commit)
386 break;
387
@@ -321,7 +393,7 @@ int replay_revisions(struct rev_info *revs,
393 kh_value(replayed_commits, pos) = last_commit;
394
395 /* Update any necessary branches */
324 - if (advance)
396 + if (advance || revert)
397 continue;
398
399 for (decoration = get_name_decoration(&commit->object);
@@ -355,11 +427,13 @@ int replay_revisions(struct rev_info *revs,
427 goto out;
428 }
429
358 - /* In --advance mode, advance the target ref */
359 - if (advance)
360 - replay_result_queue_update(out, advance,
430 + /* In --advance or --revert mode, update the target ref */
431 + if (advance || revert) {
432 + const char *ref = advance ? advance : revert;
433 + replay_result_queue_update(out, ref,
434 &onto->object.oid,
435 &last_commit->object.oid);
436 + }
437
438 ret = 0;
439
@@ -371,5 +445,6 @@ out:
445 kh_destroy_oid_map(replayed_commits);
446 merge_finalize(&merge_opt, &result);
447 free(advance);
448 + free(revert);
449 return ret;
450 }
replay.h
+9 -2
@@ -13,7 +13,7 @@ struct replay_revisions_options {
13 /*
14 * Starting point at which to create the new commits; must be a branch
15 * name. The branch will be updated to point to the rewritten commits.
16 - * This option is mutually exclusive with `onto`.
16 + * This option is mutually exclusive with `onto` and `revert`.
17 */
18 const char *advance;
19
@@ -22,7 +22,14 @@ struct replay_revisions_options {
22 * committish. References pointing at decendants of `onto` will be
23 * updated to point to the new commits.
24 */
25 - const char *onto;
25 + const char *onto;
26 +
27 + /*
28 + * Starting point at which to create revert commits; must be a branch
29 + * name. The branch will be updated to point to the revert commits.
30 + * This option is mutually exclusive with `onto` and `advance`.
31 + */
32 + const char *revert;
33
34 /*
35 * Update branches that point at commits in the given revision range.
t/t3650-replay-basics.sh
+104 -7
@@ -74,8 +74,8 @@ test_expect_success '--onto with invalid commit-ish' '
74 test_cmp expect actual
75 '
76
77 -test_expect_success 'option --onto or --advance is mandatory' '
78 - echo "error: option --onto or --advance is mandatory" >expect &&
77 +test_expect_success 'exactly one of --onto, --advance, or --revert is required' '
78 + echo "error: exactly one of --onto, --advance, or --revert is required" >expect &&
79 test_might_fail git replay -h >>expect &&
80 test_must_fail git replay topic1..topic2 2>actual &&
81 test_cmp expect actual
@@ -87,16 +87,14 @@ test_expect_success 'no base or negative ref gives no-replaying down to root err
87 test_cmp expect actual
88 '
89
90 -test_expect_success 'options --advance and --contained cannot be used together' '
91 - printf "fatal: options ${SQ}--advance${SQ} " >expect &&
92 - printf "and ${SQ}--contained${SQ} cannot be used together\n" >>expect &&
90 +test_expect_success '--advance and --contained cannot be used together' '
91 test_must_fail git replay --advance=main --contained \
92 topic1..topic2 2>actual &&
95 - test_cmp expect actual
93 + test_grep "cannot be used together" actual
94 '
95
96 test_expect_success 'cannot advance target ... ordering would be ill-defined' '
99 - echo "fatal: cannot advance target with multiple sources because ordering would be ill-defined" >expect &&
97 + echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
98 test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
99 test_cmp expect actual
100 '
@@ -398,4 +396,103 @@ test_expect_success 'invalid replay.refAction value' '
396 test_grep "invalid.*replay.refAction.*value" error
397 '
398
399 +test_expect_success 'argument to --revert must be a reference' '
400 + echo "fatal: argument to --revert must be a reference" >expect &&
401 + oid=$(git rev-parse main) &&
402 + test_must_fail git replay --revert=$oid topic1..topic2 2>actual &&
403 + test_cmp expect actual
404 +'
405 +
406 +test_expect_success 'cannot revert with multiple sources' '
407 + echo "fatal: ${SQ}--revert${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
408 + test_must_fail git replay --revert main main topic1 topic2 2>actual &&
409 + test_cmp expect actual
410 +'
411 +
412 +test_expect_success 'using replay --revert to revert commits' '
413 + # Reuse existing topic4 branch (has commits I and J on top of main)
414 + START=$(git rev-parse topic4) &&
415 + test_when_finished "git branch -f topic4 $START" &&
416 +
417 + # Revert commits I and J
418 + git replay --revert topic4 topic4~2..topic4 &&
419 +
420 + # Verify the revert commits were created (newest-first ordering
421 + # means J is reverted first, then I on top)
422 + git log --format=%s -4 topic4 >actual &&
423 + cat >expect <<-\EOF &&
424 + Revert "I"
425 + Revert "J"
426 + J
427 + I
428 + EOF
429 + test_cmp expect actual &&
430 +
431 + # Verify commit message format includes hash (tip is Revert "I")
432 + test_commit_message topic4 <<-EOF &&
433 + Revert "I"
434 +
435 + This reverts commit $(git rev-parse I).
436 + EOF
437 +
438 + # Verify reflog message
439 + git reflog topic4 -1 --format=%gs >reflog-msg &&
440 + echo "replay --revert topic4" >expect-reflog &&
441 + test_cmp expect-reflog reflog-msg
442 +'
443 +
444 +test_expect_success 'using replay --revert in bare repo' '
445 + # Reuse existing topic4 in bare repo
446 + START=$(git -C bare rev-parse topic4) &&
447 + test_when_finished "git -C bare update-ref refs/heads/topic4 $START" &&
448 +
449 + # Revert commit J in bare repo
450 + git -C bare replay --revert topic4 topic4~1..topic4 &&
451 +
452 + # Verify revert was created
453 + git -C bare log -1 --format=%s topic4 >actual &&
454 + echo "Revert \"J\"" >expect &&
455 + test_cmp expect actual
456 +'
457 +
458 +test_expect_success 'revert of revert uses Reapply' '
459 + # Use topic4 and first revert J, then revert the revert
460 + START=$(git rev-parse topic4) &&
461 + test_when_finished "git branch -f topic4 $START" &&
462 +
463 + # First revert J
464 + git replay --revert topic4 topic4~1..topic4 &&
465 + REVERT_J=$(git rev-parse topic4) &&
466 +
467 + # Now revert the revert - should become Reapply
468 + git replay --revert topic4 topic4~1..topic4 &&
469 +
470 + # Verify Reapply prefix and message format
471 + test_commit_message topic4 <<-EOF
472 + Reapply "J"
473 +
474 + This reverts commit $REVERT_J.
475 + EOF
476 +'
477 +
478 +test_expect_success 'git replay --revert with conflict' '
479 + # conflict branch has C.conflict which conflicts with topic1s C
480 + test_expect_code 1 git replay --revert conflict B..topic1
481 +'
482 +
483 +test_expect_success 'git replay --revert incompatible with --contained' '
484 + test_must_fail git replay --revert topic4 --contained topic4~1..topic4 2>error &&
485 + test_grep "cannot be used together" error
486 +'
487 +
488 +test_expect_success 'git replay --revert incompatible with --onto' '
489 + test_must_fail git replay --revert topic4 --onto main topic4~1..topic4 2>error &&
490 + test_grep "cannot be used together" error
491 +'
492 +
493 +test_expect_success 'git replay --revert incompatible with --advance' '
494 + test_must_fail git replay --revert topic4 --advance main topic4~1..topic4 2>error &&
495 + test_grep "cannot be used together" error
496 +'
497 +
498 test_done