Skip to content

Commit 665c66a

Browse files
edith007gitster
authored andcommitted
replay: make atomic ref updates the default behavior
The git replay command currently outputs update commands that must be piped to git update-ref --stdin to actually update references: git replay --onto main topic1..topic2 | git update-ref --stdin This design has significant limitations for server-side operations. The two-command pipeline creates coordination complexity, provides no atomic transaction guarantees by default, and complicates automation in bare repository environments where git replay is primarily used. During extensive mailing list discussion, multiple maintainers identified that the current approach forces users to opt-in to atomic behavior rather than defaulting to the safer, more reliable option. Elijah Newren noted that the experimental status explicitly allows such behavior changes, while Patrick Steinhardt highlighted performance concerns with individual ref updates in the reftable backend. The core issue is that git replay was designed around command output rather than direct action. This made sense for a plumbing tool, but creates barriers for the primary use case: server-side operations that need reliable, atomic ref updates without pipeline complexity. This patch changes the default behavior to update refs directly using Git's ref transaction API: git replay --onto main topic1..topic2 # No output; all refs updated atomically or none The implementation uses ref_store_transaction_begin() with atomic mode by default, ensuring all ref updates succeed or all fail as a single operation. This leverages git replay's existing server-side strengths (in-memory operation, no work tree requirement) while adding the atomic guarantees that server operations require. For users needing the traditional pipeline workflow, --output-commands preserves the original behavior: git replay --output-commands --onto main topic1..topic2 | git update-ref --stdin The --allow-partial option enables partial failure tolerance. However, following maintainer feedback, it implements a "strict success" model: the command exits with code 0 only if ALL ref updates succeed, and exits with code 1 if ANY updates fail. This ensures that --allow-partial changes error reporting style (warnings vs hard errors) but not success criteria, handling edge cases like "no updates needed" cleanly. Implementation details: - Empty commit ranges now return success (exit code 0) rather than failure, as no commits to replay is a valid successful operation - Added comprehensive test coverage with 12 new tests covering atomic behavior, option validation, bare repository support, and edge cases - Fixed test isolation issues to prevent branch state contamination between tests - Maintains C89 compliance and follows Git's established coding conventions - Refactored option validation to use die_for_incompatible_opt2() for both --advance/--contained and --allow-partial/--output-commands conflicts, providing consistent error reporting - Fixed --allow-partial exit code behavior to implement "strict success" model where any ref update failures result in exit code 1, even with partial tolerance - Updated documentation with proper line wrapping, consistent terminology using "old default behavior", performance context, and reorganized examples for clarity - Eliminates individual ref updates (refs_update_ref calls) that perform poorly with reftable backend - Uses only batched ref transactions for optimal performance across all ref backends - Avoids naming collision with git rebase --update-refs by using distinct option names - Defaults to atomic behavior while preserving pipeline compatibility The result is a command that works better for its primary use case (server-side operations) while maintaining full backward compatibility for existing workflows. Signed-off-by: Siddharth Asthana <[email protected]> Signed-off-by: Junio C Hamano <[email protected]>
1 parent bb69721 commit 665c66a

File tree

3 files changed

+319
-37
lines changed

3 files changed

+319
-37
lines changed

Documentation/git-replay.adoc

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ git-replay - EXPERIMENTAL: Replay commits on a new base, works with bare repos t
99
SYNOPSIS
1010
--------
1111
[verse]
12-
(EXPERIMENTAL!) 'git replay' ([--contained] --onto <newbase> | --advance <branch>) <revision-range>...
12+
(EXPERIMENTAL!) 'git replay' ([--contained] --onto <newbase> | --advance <branch>) [--output-commands | --allow-partial] <revision-range>...
1313

1414
DESCRIPTION
1515
-----------
1616

1717
Takes ranges of commits and replays them onto a new location. Leaves
18-
the working tree and the index untouched, and updates no references.
19-
The output of this command is meant to be used as input to
20-
`git update-ref --stdin`, which would update the relevant branches
21-
(see the OUTPUT section below).
18+
the working tree and the index untouched, and by default updates the
19+
relevant references using atomic transactions. Use `--output-commands`
20+
to get the old default behavior where update commands that can be piped
21+
to `git update-ref --stdin` are emitted (see the OUTPUT section below).
2222

2323
THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
2424

@@ -42,6 +42,20 @@ When `--advance` is specified, the update-ref command(s) in the output
4242
will update the branch passed as an argument to `--advance` to point at
4343
the new commits (in other words, this mimics a cherry-pick operation).
4444

45+
--output-commands::
46+
Output update-ref commands instead of updating refs directly.
47+
When this option is used, the output can be piped to `git update-ref --stdin`
48+
for successive, relatively slow, ref updates. This is equivalent to the
49+
old default behavior.
50+
51+
--allow-partial::
52+
Allow some ref updates to succeed even if others fail. By default,
53+
ref updates are atomic (all succeed or all fail). With this option,
54+
failed updates are reported as warnings rather than causing the entire
55+
command to fail. The command exits with code 0 only if all updates
56+
succeed; any failures result in exit code 1. Cannot be used with
57+
`--output-commands`.
58+
4559
<revision-range>::
4660
Range of commits to replay. More than one <revision-range> can
4761
be passed, but in `--advance <branch>` mode, they should have
@@ -54,15 +68,20 @@ include::rev-list-options.adoc[]
5468
OUTPUT
5569
------
5670

57-
When there are no conflicts, the output of this command is usable as
58-
input to `git update-ref --stdin`. It is of the form:
71+
By default, when there are no conflicts, this command updates the relevant
72+
references using atomic transactions and produces no output. All ref updates
73+
succeed or all fail (atomic behavior). Use `--allow-partial` to allow some
74+
updates to succeed while others fail.
75+
76+
When `--output-commands` is used, the output is usable as input to
77+
`git update-ref --stdin`. It is of the form:
5978

6079
update refs/heads/branch1 ${NEW_branch1_HASH} ${OLD_branch1_HASH}
6180
update refs/heads/branch2 ${NEW_branch2_HASH} ${OLD_branch2_HASH}
6281
update refs/heads/branch3 ${NEW_branch3_HASH} ${OLD_branch3_HASH}
6382

6483
where the number of refs updated depends on the arguments passed and
65-
the shape of the history being replayed. When using `--advance`, the
84+
the shape of the history being replayed. When using `--advance`, the
6685
number of refs updated is always one, but for `--onto`, it can be one
6786
or more (rebasing multiple branches simultaneously is supported).
6887

@@ -77,41 +96,70 @@ is something other than 0 or 1.
7796
EXAMPLES
7897
--------
7998

80-
To simply rebase `mybranch` onto `target`:
99+
To simply rebase `mybranch` onto `target` (default behavior):
81100

82101
------------
83102
$ git replay --onto target origin/main..mybranch
84-
update refs/heads/mybranch ${NEW_mybranch_HASH} ${OLD_mybranch_HASH}
85103
------------
86104

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

89107
------------
90108
$ git replay --advance target origin/main..mybranch
91-
update refs/heads/target ${NEW_target_HASH} ${OLD_target_HASH}
92109
------------
93110

94111
Note that the first two examples replay the exact same commits and on
95112
top of the exact same new base, they only differ in that the first
96-
provides instructions to make mybranch point at the new commits and
97-
the second provides instructions to make target point at them.
113+
updates mybranch to point at the new commits and the second updates
114+
target to point at them.
115+
116+
To get the old default behavior where update commands are emitted:
117+
118+
------------
119+
$ git replay --output-commands --onto target origin/main..mybranch
120+
update refs/heads/mybranch ${NEW_mybranch_HASH} ${OLD_mybranch_HASH}
121+
------------
122+
123+
To rebase multiple branches with partial failure tolerance:
124+
125+
------------
126+
$ git replay --allow-partial --contained --onto origin/main origin/main..tipbranch
127+
------------
98128

99129
What if you have a stack of branches, one depending upon another, and
100130
you'd really like to rebase the whole set?
101131

102132
------------
103133
$ git replay --contained --onto origin/main origin/main..tipbranch
134+
------------
135+
136+
This automatically finds and rebases all branches contained within the
137+
`origin/main..tipbranch` range.
138+
139+
Or if you want to see the old default behavior where update commands are emitted:
140+
141+
------------
142+
$ git replay --output-commands --contained --onto origin/main origin/main..tipbranch
104143
update refs/heads/branch1 ${NEW_branch1_HASH} ${OLD_branch1_HASH}
105144
update refs/heads/branch2 ${NEW_branch2_HASH} ${OLD_branch2_HASH}
106145
update refs/heads/tipbranch ${NEW_tipbranch_HASH} ${OLD_tipbranch_HASH}
107146
------------
108147

109148
When calling `git replay`, one does not need to specify a range of
110149
commits to replay using the syntax `A..B`; any range expression will
111-
do:
150+
do. Here's an example where you explicitly specify which branches to rebase:
112151

113152
------------
114153
$ git replay --onto origin/main ^base branch1 branch2 branch3
154+
------------
155+
156+
This gives you explicit control over exactly which branches are rebased,
157+
unlike the previous `--contained` example which automatically discovers them.
158+
159+
To see the update commands that would be executed:
160+
161+
------------
162+
$ git replay --output-commands --onto origin/main ^base branch1 branch2 branch3
115163
update refs/heads/branch1 ${NEW_branch1_HASH} ${OLD_branch1_HASH}
116164
update refs/heads/branch2 ${NEW_branch2_HASH} ${OLD_branch2_HASH}
117165
update refs/heads/branch3 ${NEW_branch3_HASH} ${OLD_branch3_HASH}

builtin/replay.c

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,28 @@ static struct commit *pick_regular_commit(struct repository *repo,
284284
return create_commit(repo, result->tree, pickme, replayed_base);
285285
}
286286

287+
static int add_ref_to_transaction(struct ref_transaction *transaction,
288+
const char *refname,
289+
const struct object_id *new_oid,
290+
const struct object_id *old_oid,
291+
struct strbuf *err)
292+
{
293+
return ref_transaction_update(transaction, refname, new_oid, old_oid,
294+
NULL, NULL, 0, "git replay", err);
295+
}
296+
297+
static void print_rejected_update(const char *refname,
298+
const struct object_id *old_oid UNUSED,
299+
const struct object_id *new_oid UNUSED,
300+
const char *old_target UNUSED,
301+
const char *new_target UNUSED,
302+
enum ref_transaction_error err,
303+
void *cb_data UNUSED)
304+
{
305+
const char *reason = ref_transaction_error_msg(err);
306+
warning(_("failed to update %s: %s"), refname, reason);
307+
}
308+
287309
int cmd_replay(int argc,
288310
const char **argv,
289311
const char *prefix,
@@ -294,6 +316,8 @@ int cmd_replay(int argc,
294316
struct commit *onto = NULL;
295317
const char *onto_name = NULL;
296318
int contained = 0;
319+
int output_commands = 0;
320+
int allow_partial = 0;
297321

298322
struct rev_info revs;
299323
struct commit *last_commit = NULL;
@@ -302,12 +326,15 @@ int cmd_replay(int argc,
302326
struct merge_result result;
303327
struct strset *update_refs = NULL;
304328
kh_oid_map_t *replayed_commits;
329+
struct ref_transaction *transaction = NULL;
330+
struct strbuf transaction_err = STRBUF_INIT;
331+
int commits_processed = 0;
305332
int ret = 0;
306333

307-
const char * const replay_usage[] = {
334+
const char *const replay_usage[] = {
308335
N_("(EXPERIMENTAL!) git replay "
309336
"([--contained] --onto <newbase> | --advance <branch>) "
310-
"<revision-range>..."),
337+
"[--output-commands | --allow-partial] <revision-range>..."),
311338
NULL
312339
};
313340
struct option replay_options[] = {
@@ -319,6 +346,10 @@ int cmd_replay(int argc,
319346
N_("replay onto given commit")),
320347
OPT_BOOL(0, "contained", &contained,
321348
N_("advance all branches contained in revision-range")),
349+
OPT_BOOL(0, "output-commands", &output_commands,
350+
N_("output update commands instead of updating refs")),
351+
OPT_BOOL(0, "allow-partial", &allow_partial,
352+
N_("allow some ref updates to succeed even if others fail")),
322353
OPT_END()
323354
};
324355

@@ -330,9 +361,12 @@ int cmd_replay(int argc,
330361
usage_with_options(replay_usage, replay_options);
331362
}
332363

333-
if (advance_name_opt && contained)
334-
die(_("options '%s' and '%s' cannot be used together"),
335-
"--advance", "--contained");
364+
die_for_incompatible_opt2(!!advance_name_opt, "--advance",
365+
contained, "--contained");
366+
367+
die_for_incompatible_opt2(allow_partial, "--allow-partial",
368+
output_commands, "--output-commands");
369+
336370
advance_name = xstrdup_or_null(advance_name_opt);
337371

338372
repo_init_revisions(repo, &revs, prefix);
@@ -389,6 +423,17 @@ int cmd_replay(int argc,
389423
determine_replay_mode(repo, &revs.cmdline, onto_name, &advance_name,
390424
&onto, &update_refs);
391425

426+
if (!output_commands) {
427+
unsigned int transaction_flags = allow_partial ? REF_TRANSACTION_ALLOW_FAILURE : 0;
428+
transaction = ref_store_transaction_begin(get_main_ref_store(repo),
429+
transaction_flags,
430+
&transaction_err);
431+
if (!transaction) {
432+
ret = error(_("failed to begin ref transaction: %s"), transaction_err.buf);
433+
goto cleanup;
434+
}
435+
}
436+
392437
if (!onto) /* FIXME: Should handle replaying down to root commit */
393438
die("Replaying down to root commit is not supported yet!");
394439

@@ -407,6 +452,8 @@ int cmd_replay(int argc,
407452
khint_t pos;
408453
int hr;
409454

455+
commits_processed = 1;
456+
410457
if (!commit->parents)
411458
die(_("replaying down to root commit is not supported yet!"));
412459
if (commit->parents->next)
@@ -434,21 +481,52 @@ int cmd_replay(int argc,
434481
if (decoration->type == DECORATION_REF_LOCAL &&
435482
(contained || strset_contains(update_refs,
436483
decoration->name))) {
437-
printf("update %s %s %s\n",
438-
decoration->name,
439-
oid_to_hex(&last_commit->object.oid),
440-
oid_to_hex(&commit->object.oid));
484+
if (output_commands) {
485+
printf("update %s %s %s\n",
486+
decoration->name,
487+
oid_to_hex(&last_commit->object.oid),
488+
oid_to_hex(&commit->object.oid));
489+
} else if (add_ref_to_transaction(transaction, decoration->name,
490+
&last_commit->object.oid,
491+
&commit->object.oid,
492+
&transaction_err) < 0) {
493+
ret = error(_("failed to add ref update to transaction: %s"), transaction_err.buf);
494+
goto cleanup;
495+
}
441496
}
442497
decoration = decoration->next;
443498
}
444499
}
445500

446501
/* In --advance mode, advance the target ref */
447502
if (result.clean == 1 && advance_name) {
448-
printf("update %s %s %s\n",
449-
advance_name,
450-
oid_to_hex(&last_commit->object.oid),
451-
oid_to_hex(&onto->object.oid));
503+
if (output_commands) {
504+
printf("update %s %s %s\n",
505+
advance_name,
506+
oid_to_hex(&last_commit->object.oid),
507+
oid_to_hex(&onto->object.oid));
508+
} else if (add_ref_to_transaction(transaction, advance_name,
509+
&last_commit->object.oid,
510+
&onto->object.oid,
511+
&transaction_err) < 0) {
512+
ret = error(_("failed to add ref update to transaction: %s"), transaction_err.buf);
513+
goto cleanup;
514+
}
515+
}
516+
517+
/* Commit the ref transaction if we have one */
518+
if (transaction && result.clean == 1) {
519+
if (ref_transaction_commit(transaction, &transaction_err)) {
520+
if (allow_partial) {
521+
warning(_("some ref updates failed: %s"), transaction_err.buf);
522+
ref_transaction_for_each_rejected_update(transaction,
523+
print_rejected_update, NULL);
524+
ret = 0; /* Set failure even with allow_partial */
525+
} else {
526+
ret = error(_("failed to update refs: %s"), transaction_err.buf);
527+
goto cleanup;
528+
}
529+
}
452530
}
453531

454532
merge_finalize(&merge_opt, &result);
@@ -457,9 +535,17 @@ int cmd_replay(int argc,
457535
strset_clear(update_refs);
458536
free(update_refs);
459537
}
460-
ret = result.clean;
538+
539+
/* Handle empty ranges: if no commits were processed, treat as success */
540+
if (!commits_processed)
541+
ret = 1; /* Success - no commits to replay is not an error */
542+
else
543+
ret = result.clean;
461544

462545
cleanup:
546+
if (transaction)
547+
ref_transaction_free(transaction);
548+
strbuf_release(&transaction_err);
463549
release_revisions(&revs);
464550
free(advance_name);
465551

0 commit comments

Comments
 (0)