refs: add 'preparing' phase to the reference-transaction hook

The "reference-transaction" hook is invoked multiple times during a ref transaction. Each invocation corresponds to a different phase: - The "prepared" phase indicates that references have been locked. - The "committed" phase indicates that all updates have been written to disk. - The "aborted" phase indicates that the transaction has been aborted and that all changes have been rolled back. This hook can be used to learn about the updates that Git wants to perform. For example, forges use it to coordinate reference updates across multiple nodes. However, the phases are insufficient for some specific use cases. The earliest observable phase in the "reference-transaction" hook is "prepared", at which point Git has already taken exclusive locks on every affected reference. This makes it suitable for last-chance validation, but not for serialization. So by the time a hook sees the "prepared" phase, it has no way to defer locking, and thus it cannot rearrange multiple concurrent ref transactions relative to one another. Introduce a new "preparing" phase that runs before the "prepared" phase, that is before Git acquires any reference lock on disk. This gives callers a well-defined window to perform validation, enable higher-level ordering of concurrent transactions, or reject the transaction entirely, all without interfering with the locking state. This change is strictly speaking not backwards compatible. Existing hook scripts that do not know how to handle unknown phases may treat 'preparing' as an error and return non-zero. But the hook is considered to expose internal implementation details of how Git works, and as such we have been a bit more lenient with changing its exact semantics, like for example in a8ae923f85 (refs: support symrefs in 'reference-transaction' hook, 2024-05-07). An alternative would be to introduce a "reference-transaction-v2" hook that knows about the new phase. This feels like a rather heavy-weight option though, and was thus discarded. Helped-by: Patrick Steinhardt <ps@pks.im> Helped-by: Justin Tobler <jltobler@gmail.com> Helped-by: Karthik Nayak <karthik.188@gmail.com> Signed-off-by: Eric Ju <eric.peijian@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Eric Ju committed Mar 16, 2026 at 22:36 UTC 60d8c1e97d62c27ef60db0bc3d5deadd6dfdb98d
4 files changed +55 -13
Documentation/githooks.adoc
+12 -7
@@ -484,13 +484,16 @@ reference-transaction
484 ~~~~~~~~~~~~~~~~~~~~~
485
486 This hook is invoked by any Git command that performs reference
487 -updates. It executes whenever a reference transaction is prepared,
488 -committed or aborted and may thus get called multiple times. The hook
489 -also supports symbolic reference updates.
487 +updates. It executes whenever a reference transaction is preparing,
488 +prepared, committed or aborted and may thus get called multiple times.
489 +The hook also supports symbolic reference updates.
490
491 The hook takes exactly one argument, which is the current state the
492 given reference transaction is in:
493
494 + - "preparing": All reference updates have been queued to the
495 + transaction but references are not yet locked on disk.
496 +
497 - "prepared": All reference updates have been queued to the
498 transaction and references were locked on disk.
499
@@ -511,16 +514,18 @@ ref and `<ref-name>` is the full name of the ref. When force updating
514 the reference regardless of its current value or when the reference is
515 to be created anew, `<old-value>` is the all-zeroes object name. To
516 distinguish these cases, you can inspect the current value of
514 -`<ref-name>` via `git rev-parse`.
517 +`<ref-name>` via `git rev-parse`. During the "preparing" state, symbolic
518 +references are not resolved: `<ref-name>` will reflect the symbolic reference
519 +itself rather than the object it points to.
520
521 For symbolic reference updates the `<old_value>` and `<new-value>`
522 fields could denote references instead of objects. A reference will be
523 denoted with a 'ref:' prefix, like `ref:<ref-target>`.
524
525 The exit status of the hook is ignored for any state except for the
521 -"prepared" state. In the "prepared" state, a non-zero exit status will
522 -cause the transaction to be aborted. The hook will not be called with
523 -"aborted" state in that case.
526 +"preparing" and "prepared" states. In these states, a non-zero exit
527 +status will cause the transaction to be aborted. The hook will not be
528 +called with "aborted" state in that case.
529
530 push-to-checkout
531 ~~~~~~~~~~~~~~~~
refs.c
+11 -1
@@ -64,6 +64,9 @@ const char *ref_storage_format_to_name(enum ref_storage_format ref_storage_forma
64 return be->name;
65 }
66
67 +static const char *abort_by_ref_transaction_hook =
68 + N_("in '%s' phase, update aborted by the reference-transaction hook");
69 +
70 /*
71 * How to handle various characters in refnames:
72 * 0: An acceptable character for refs
@@ -2655,6 +2658,13 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
2658 if (ref_update_reject_duplicates(&transaction->refnames, err))
2659 return REF_TRANSACTION_ERROR_GENERIC;
2660
2661 + /* Preparing checks before locking references */
2662 + ret = run_transaction_hook(transaction, "preparing");
2663 + if (ret) {
2664 + ref_transaction_abort(transaction, err);
2665 + die(_(abort_by_ref_transaction_hook), "preparing");
2666 + }
2667 +
2668 ret = refs->be->transaction_prepare(refs, transaction, err);
2669 if (ret)
2670 return ret;
@@ -2662,7 +2672,7 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
2672 ret = run_transaction_hook(transaction, "prepared");
2673 if (ret) {
2674 ref_transaction_abort(transaction, err);
2665 - die(_("ref updates aborted by hook"));
2675 + die(_(abort_by_ref_transaction_hook), "prepared");
2676 }
2677
2678 return 0;
t/t1416-ref-transaction-hooks.sh
+26 -4
@@ -20,6 +20,7 @@ test_expect_success 'hook allows updating ref if successful' '
20 echo "$*" >>actual
21 EOF
22 cat >expect <<-EOF &&
23 + preparing
24 prepared
25 committed
26 EOF
@@ -27,6 +28,18 @@ test_expect_success 'hook allows updating ref if successful' '
28 test_cmp expect actual
29 '
30
31 +test_expect_success 'hook aborts updating ref in preparing state' '
32 + git reset --hard PRE &&
33 + test_hook reference-transaction <<-\EOF &&
34 + if test "$1" = preparing
35 + then
36 + exit 1
37 + fi
38 + EOF
39 + test_must_fail git update-ref HEAD POST 2>err &&
40 + test_grep "in '\''preparing'\'' phase, update aborted by the reference-transaction hook" err
41 +'
42 +
43 test_expect_success 'hook aborts updating ref in prepared state' '
44 git reset --hard PRE &&
45 test_hook reference-transaction <<-\EOF &&
@@ -36,7 +49,7 @@ test_expect_success 'hook aborts updating ref in prepared state' '
49 fi
50 EOF
51 test_must_fail git update-ref HEAD POST 2>err &&
39 - test_grep "ref updates aborted by hook" err
52 + test_grep "in '\''prepared'\'' phase, update aborted by the reference-transaction hook" err
53 '
54
55 test_expect_success 'hook gets all queued updates in prepared state' '
@@ -121,6 +134,7 @@ test_expect_success 'interleaving hook calls succeed' '
134 cat >expect <<-EOF &&
135 hooks/update refs/tags/PRE $ZERO_OID $PRE_OID
136 hooks/update refs/tags/POST $ZERO_OID $POST_OID
137 + hooks/reference-transaction preparing
138 hooks/reference-transaction prepared
139 hooks/reference-transaction committed
140 EOF
@@ -143,6 +157,8 @@ test_expect_success 'hook captures git-symbolic-ref updates' '
157 git symbolic-ref refs/heads/symref refs/heads/main &&
158
159 cat >expect <<-EOF &&
160 + preparing
161 + $ZERO_OID ref:refs/heads/main refs/heads/symref
162 prepared
163 $ZERO_OID ref:refs/heads/main refs/heads/symref
164 committed
@@ -171,14 +187,20 @@ test_expect_success 'hook gets all queued symref updates' '
187 # In the files backend, "delete" also triggers an additional transaction
188 # update on the packed-refs backend, which constitutes additional reflog
189 # entries.
190 + cat >expect <<-EOF &&
191 + preparing
192 + ref:refs/heads/main $ZERO_OID refs/heads/symref
193 + ref:refs/heads/main $ZERO_OID refs/heads/symrefd
194 + $ZERO_OID ref:refs/heads/main refs/heads/symrefc
195 + ref:refs/heads/main ref:refs/heads/branch refs/heads/symrefu
196 + EOF
197 +
198 if test_have_prereq REFFILES
199 then
176 - cat >expect <<-EOF
200 + cat >>expect <<-EOF
201 aborted
202 $ZERO_OID $ZERO_OID refs/heads/symrefd
203 EOF
180 - else
181 - >expect
204 fi &&
205
206 cat >>expect <<-EOF &&
t/t5510-fetch.sh
+6 -1
@@ -469,12 +469,17 @@ test_expect_success 'fetch --atomic executes a single reference transaction only
469 head_oid=$(git rev-parse HEAD) &&
470
471 cat >expected <<-EOF &&
472 + preparing
473 + $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
474 + $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
475 prepared
476 $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
477 $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
478 committed
479 $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
480 $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
481 + preparing
482 + $ZERO_OID ref:refs/remotes/origin/main refs/remotes/origin/HEAD
483 EOF
484
485 rm -f atomic/actual &&
@@ -497,7 +502,7 @@ test_expect_success 'fetch --atomic aborts all reference updates if hook aborts'
502 head_oid=$(git rev-parse HEAD) &&
503
504 cat >expected <<-EOF &&
500 - prepared
505 + preparing
506 $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-1
507 $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-2
508 $ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-3