Volume XXII, number 280Wednesday, October 7, 2026Latest message 1 hour ago

The Git List

News and archive of git@vger.kernel.org, since April 2005

patchAdd "preparing" phase to reference-transaction hook

15 messages between Mar 13, 2026 and Mar 17, 2026, from eric.peijian@gmail.com, Junio C Hamano, Justin Tobler, Peijian Ju, Eric Ju, Patrick Steinhardt.

Plain Markdown or JSON for tools and agents. Diffs are folded; open one to read it.

eric.peijian@gmail.comMar 13, 2026, 19:35 UTC on lore
From: Eric Ju <eric.peijian@gmail.com>

The "reference-transaction" hook currently exposes three phases to callers: "prepared", "committed", and "aborted". The earliest of these, "prepared", fires after Git has already acquired exclusive locks on every affected reference. This is well-suited for last-chance validation, but it arrives too late for any use case that requires coordination before locking, such as serializing concurrent transactions across distributed storage nodes.

This series introduces a new "preparing" phase that fires before refs->be->transaction_prepare() is called, that is, before Git takes any reference lock on disk. Hook scripts that handle this phase receive the full list of proposed updates and may reject the transaction by returning a non-zero exit status, causing Git to abort cleanly before any locks are acquired.

The motivating use case is Gitaly/Praefect, GitLab's distributed Git storage layer. Praefect must serialize concurrent writes that target the same references across replicas. With only the "prepared" phase available, by the time Praefect can observe a transaction the locks are already held, making reordering impossible. The "preparing" phase provides the necessary pre-lock window.

Compatibility note: this change is not strictly backwards compatible. Hook scripts that do not expect unknown phase strings may return an error when they encounter "preparing". We consider this acceptable for the same reasons cited when symref support was added to the hook in a8ae923f85 (refs: support symrefs in 'reference-transaction' hook, 2024-05-07): the hook is documented as exposing internal implementation details, and its semantics have been adjusted before. An alternative of introducing a "reference-transaction-v2" hook was considered but rejected as unnecessarily heavyweight.

Eric Ju (1):
  Add preparing state to reference-transaction hook
 Documentation/githooks.adoc      | 19 ++++++++++++-------
 refs.c                           |  9 ++++++++-
 t/t1416-ref-transaction-hooks.sh | 30 ++++++++++++++++++++++++++----
 t/t5510-fetch.sh                 |  7 ++++++-
 4 files changed, 52 insertions(+), 13 deletions(-)
-- 
2.51.0
eric.peijian@gmail.comMar 13, 2026, 19:35 UTC in reply to eric.peijian@gmail.com on lore

[PATCH 1/1] Add preparing state to reference-transaction hook

From: Eric Ju <eric.peijian@gmail.com>
From: Eric Ju <eju@gitlab.com>

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 "commit" phase indicates that all updates have been written to disk.
- The "abort" 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 to handle unknown phases handle the "preparing" state string will encounter an unknown phase, and that might cause them to return an error now. 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>
Signed-off-by: Eric Ju <eric.peijian@gmail.com>
---
 Documentation/githooks.adoc      | 19 ++++++++++++-------
 refs.c                           |  9 ++++++++-
 t/t1416-ref-transaction-hooks.sh | 30 ++++++++++++++++++++++++++----
 t/t5510-fetch.sh                 |  7 ++++++-
 4 files changed, 52 insertions(+), 13 deletions(-)
Show changes to 4 files +52 −13

Documentation/githooks.adoc, refs.c, t/t1416-ref-transaction-hooks.sh, t/t5510-fetch.sh

diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 056553788d..ed045940d1 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -484,13 +484,16 @@ reference-transaction
 ~~~~~~~~~~~~~~~~~~~~~
 
 This hook is invoked by any Git command that performs reference
-updates. It executes whenever a reference transaction is prepared,
-committed or aborted and may thus get called multiple times. The hook
-also supports symbolic reference updates.
+updates. It executes whenever a reference transaction is preparing,
+prepared, committed or aborted and may thus get called multiple times.
+The hook also supports symbolic reference updates.
 
 The hook takes exactly one argument, which is the current state the
 given reference transaction is in:
 
+    - "preparing": All reference updates have been queued to the
+      transaction but references are not yet locked on disk.
+
     - "prepared": All reference updates have been queued to the
       transaction and references were locked on disk.
 
@@ -511,16 +514,18 @@ ref and `<ref-name>` is the full name of the ref. When force updating
 the reference regardless of its current value or when the reference is
 to be created anew, `<old-value>` is the all-zeroes object name. To
 distinguish these cases, you can inspect the current value of
-`<ref-name>` via `git rev-parse`.
+`<ref-name>` via `git rev-parse`. During the "preparing" state, symbolic
+references are not resolved: `<ref-name>` will reflect the symbolic reference
+itself rather than the object it points to.
 
 For symbolic reference updates the `<old_value>` and `<new-value>`
 fields could denote references instead of objects. A reference will be
 denoted with a 'ref:' prefix, like `ref:<ref-target>`.
 
 The exit status of the hook is ignored for any state except for the
-"prepared" state. In the "prepared" state, a non-zero exit status will
-cause the transaction to be aborted. The hook will not be called with
-"aborted" state in that case.
+"preparing" and "prepared" states. In these states, a non-zero exit
+status will cause the transaction to be aborted. The hook will not be
+called with "aborted" state in that case.
 
 push-to-checkout
 ~~~~~~~~~~~~~~~~
diff --git a/refs.c b/refs.c
index 6fb8f9d10c..f1439476d3 100644
--- a/refs.c
+++ b/refs.c
@@ -2655,6 +2655,13 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
 	if (ref_update_reject_duplicates(&transaction->refnames, err))
 		return REF_TRANSACTION_ERROR_GENERIC;
 
+	/* Preparing checks before locking references */
+	ret = run_transaction_hook(transaction, "preparing");
+	if (ret) {
+		ref_transaction_abort(transaction, err);
+		die(_("ref updates aborted by %s hook"), "preparing");
+	}
+
 	ret = refs->be->transaction_prepare(refs, transaction, err);
 	if (ret)
 		return ret;
@@ -2662,7 +2669,7 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
 	ret = run_transaction_hook(transaction, "prepared");
 	if (ret) {
 		ref_transaction_abort(transaction, err);
-		die(_("ref updates aborted by hook"));
+		die(_("ref updates aborted by %s hook"), "prepared");
 	}
 
 	return 0;
diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh
index d91dd3a3b5..2f452049c3 100755
--- a/t/t1416-ref-transaction-hooks.sh
+++ b/t/t1416-ref-transaction-hooks.sh
@@ -20,6 +20,7 @@ test_expect_success 'hook allows updating ref if successful' '
 		echo "$*" >>actual
 	EOF
 	cat >expect <<-EOF &&
+		preparing
 		prepared
 		committed
 	EOF
@@ -27,6 +28,18 @@ test_expect_success 'hook allows updating ref if successful' '
 	test_cmp expect actual
 '
 
+test_expect_success 'hook aborts updating ref in preparing state' '
+	git reset --hard PRE &&
+	test_hook reference-transaction <<-\EOF &&
+		if test "$1" = preparing
+		then
+			exit 1
+		fi
+	EOF
+	test_must_fail git update-ref HEAD POST 2>err &&
+	test_grep "ref updates aborted by preparing hook" err
+'
+
 test_expect_success 'hook aborts updating ref in prepared state' '
 	git reset --hard PRE &&
 	test_hook reference-transaction <<-\EOF &&
@@ -36,7 +49,7 @@ test_expect_success 'hook aborts updating ref in prepared state' '
 		fi
 	EOF
 	test_must_fail git update-ref HEAD POST 2>err &&
-	test_grep "ref updates aborted by hook" err
+	test_grep "ref updates aborted by prepared hook" err
 '
 
 test_expect_success 'hook gets all queued updates in prepared state' '
@@ -121,6 +134,7 @@ test_expect_success 'interleaving hook calls succeed' '
 	cat >expect <<-EOF &&
 		hooks/update refs/tags/PRE $ZERO_OID $PRE_OID
 		hooks/update refs/tags/POST $ZERO_OID $POST_OID
+		hooks/reference-transaction preparing
 		hooks/reference-transaction prepared
 		hooks/reference-transaction committed
 	EOF
@@ -143,6 +157,8 @@ test_expect_success 'hook captures git-symbolic-ref updates' '
 	git symbolic-ref refs/heads/symref refs/heads/main &&
 
 	cat >expect <<-EOF &&
+	preparing
+	$ZERO_OID ref:refs/heads/main refs/heads/symref
 	prepared
 	$ZERO_OID ref:refs/heads/main refs/heads/symref
 	committed
@@ -171,14 +187,20 @@ test_expect_success 'hook gets all queued symref updates' '
 	# In the files backend, "delete" also triggers an additional transaction
 	# update on the packed-refs backend, which constitutes additional reflog
 	# entries.
+	cat >expect <<-EOF &&
+	preparing
+	ref:refs/heads/main $ZERO_OID refs/heads/symref
+	ref:refs/heads/main $ZERO_OID refs/heads/symrefd
+	$ZERO_OID ref:refs/heads/main refs/heads/symrefc
+	ref:refs/heads/main ref:refs/heads/branch refs/heads/symrefu
+	EOF
+
 	if test_have_prereq REFFILES
 	then
-		cat >expect <<-EOF
+		cat >>expect <<-EOF
 		aborted
 		$ZERO_OID $ZERO_OID refs/heads/symrefd
 		EOF
-	else
-		>expect
 	fi &&
 
 	cat >>expect <<-EOF &&
diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
index 5dcb4b51a4..6fe21e2b3a 100755
--- a/t/t5510-fetch.sh
+++ b/t/t5510-fetch.sh
@@ -469,12 +469,17 @@ test_expect_success 'fetch --atomic executes a single reference transaction only
 	head_oid=$(git rev-parse HEAD) &&
 
 	cat >expected <<-EOF &&
+		preparing
+		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
+		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
 		prepared
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
 		committed
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
+		preparing
+		$ZERO_OID ref:refs/remotes/origin/main refs/remotes/origin/HEAD
 	EOF
 
 	rm -f atomic/actual &&
@@ -497,7 +502,7 @@ test_expect_success 'fetch --atomic aborts all reference updates if hook aborts'
 	head_oid=$(git rev-parse HEAD) &&
 
 	cat >expected <<-EOF &&
-		prepared
+		preparing
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-2
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-3
-- 
2.51.0
Junio C HamanoMar 13, 2026, 21:20 UTC in reply to eric.peijian@gmail.com on lore

Re: [PATCH 1/1] Add preparing state to reference-transaction hook

eric.peijian@gmail.com writes:
> From: Eric Ju <eric.peijian@gmail.com>
>
> From: Eric Ju <eju@gitlab.com>

This is curious. The former matches the sign-off, but I somehow suspect that the @gitlab.com identity may be what you want to use for both of them, if this is a company sponsored work by an employee? I dunno.

Also the commit title deviates from the established "<area>: <what is done>" format.

    Subject: [PATCH] refs: add 'preparing" phase to the transaction hook
or something?

Other than that, both the cover letter and the proposed log message very well explain the motivation behind the new feature. I wish everybody wrote their log messages as clearly as this one.

Show 7 quoted lines
> 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 "commit" phase indicates that all updates have been written to disk.
> - The "abort" phase indicates that the transaction has been aborted and that
>   all changes have been rolled back.

"commit" -> "committed" and "abort" -> "aborted", if the existing documentation is to be trusted.

Show 11 quoted lines
> 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.
I cannot quite picture how "rearrangement" would happen, though.

Would the hook notice "ah there is a preparing hook invocation incoming", stall the caller by not immediately returning and instead wait for a different Git process to invoke the same ref-transaction hook "preparing" invocation, and somehow decide to let the latter go first before releasing the former?

Show 8 quoted lines
> 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 to handle unknown phases handle the "preparing" state
"know to handle unknown phrases handle"?
Show 9 quoted lines
> string will encounter an unknown phase, and that might cause them to return an
> error now. 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.

And documenting the design alternatives and decision like these two paragraphs is very much appreciated.

The insertion of a new hook invocation itself is at a very much expected place in the code path. Well written.

Will queue.  Thanks.
Justin ToblerMar 13, 2026, 23:05 UTC in reply to eric.peijian@gmail.com on lore

Re: [PATCH 1/1] Add preparing state to reference-transaction hook

On 26/03/13 03:35PM, eric.peijian@gmail.com wrote:
Show 13 quoted lines
> diff --git a/refs.c b/refs.c
> index 6fb8f9d10c..f1439476d3 100644
> --- a/refs.c
> +++ b/refs.c
> @@ -2655,6 +2655,13 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
>  	if (ref_update_reject_duplicates(&transaction->refnames, err))
>  		return REF_TRANSACTION_ERROR_GENERIC;
>  
> +	/* Preparing checks before locking references */
> +	ret = run_transaction_hook(transaction, "preparing");
> +	if (ret) {
> +		ref_transaction_abort(transaction, err);
> +		die(_("ref updates aborted by %s hook"), "preparing");
Should "preparing" be marked for translation here?
Show 11 quoted lines
> +	}
> +
>  	ret = refs->be->transaction_prepare(refs, transaction, err);
>  	if (ret)
>  		return ret;
> @@ -2662,7 +2669,7 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
>  	ret = run_transaction_hook(transaction, "prepared");
>  	if (ret) {
>  		ref_transaction_abort(transaction, err);
> -		die(_("ref updates aborted by hook"));
> +		die(_("ref updates aborted by %s hook"), "prepared");
Same question here for "prepared"?

Thanks, -Justin

Junio C HamanoMar 13, 2026, 23:09 UTC in reply to Justin Tobler on lore

Re: [PATCH 1/1] Add preparing state to reference-transaction hook

Justin Tobler <jltobler@gmail.com> writes:
Show 16 quoted lines
> On 26/03/13 03:35PM, eric.peijian@gmail.com wrote:
>> diff --git a/refs.c b/refs.c
>> index 6fb8f9d10c..f1439476d3 100644
>> --- a/refs.c
>> +++ b/refs.c
>> @@ -2655,6 +2655,13 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
>>  	if (ref_update_reject_duplicates(&transaction->refnames, err))
>>  		return REF_TRANSACTION_ERROR_GENERIC;
>>  
>> +	/* Preparing checks before locking references */
>> +	ret = run_transaction_hook(transaction, "preparing");
>> +	if (ret) {
>> +		ref_transaction_abort(transaction, err);
>> +		die(_("ref updates aborted by %s hook"), "preparing");
>
> Should "preparing" be marked for translation here?

It literally is one of the possible tokens reference-transaction hook is given as its argument, so no, I do not think "preparing" should be translated.

But the hook that interrupted the ref update is not "preparing" hook. It is the "reference-transaction" hook. So the message probably should say something like

    the reference-transaction hook rejected ref updates at its
    preparing phase
or something.
Show 13 quoted lines
>> +	}
>> +
>>  	ret = refs->be->transaction_prepare(refs, transaction, err);
>>  	if (ret)
>>  		return ret;
>> @@ -2662,7 +2669,7 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
>>  	ret = run_transaction_hook(transaction, "prepared");
>>  	if (ret) {
>>  		ref_transaction_abort(transaction, err);
>> -		die(_("ref updates aborted by hook"));
>> +		die(_("ref updates aborted by %s hook"), "prepared");
>
> Same question here for "prepared"?
Ditto.
Peijian JuMar 16, 2026, 03:09 UTC in reply to Junio C Hamano on lore

Re: [PATCH 1/1] Add preparing state to reference-transaction hook

On Fri, Mar 13, 2026 at 5:20 PM Junio C Hamano <gitster@pobox.com> wrote:
Show 12 quoted lines
>
> eric.peijian@gmail.com writes:
>
> > From: Eric Ju <eric.peijian@gmail.com>
> >
> > From: Eric Ju <eju@gitlab.com>
>
> This is curious.  The former matches the sign-off, but I somehow
> suspect that the @gitlab.com identity may be what you want to use
> for both of them, if this is a company sponsored work by an
> employee?  I dunno.
>

Thank you for pointing this out. During internal review, I used eju@gitlab.com, but I intended to use eric.peijian@gmail.com for the mailing list submission. The two `From:` lines got out of sync as a result. Fixed.

Show 7 quoted lines
> Also the commit title deviates from the established "<area>: <what
> is done>" format.
>
>     Subject: [PATCH] refs: add 'preparing" phase to the transaction hook
>
> or something?
>
Thank you. Fixed.
> Other than that, both the cover letter and the proposed log message
> very well explain the motivation behind the new feature.  I wish
> everybody wrote their log messages as clearly as this one.
>

Thank you. Much of the credit goes to Patrick (ps@pks.im), who helped shape the log message.

Show 11 quoted lines
> > 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 "commit" phase indicates that all updates have been written to disk.
> > - The "abort" phase indicates that the transaction has been aborted and that
> >   all changes have been rolled back.
>
> "commit" -> "committed" and "abort" -> "aborted", if the existing
> documentation is to be trusted.
>
Thank you. Fixed.
Show 20 quoted lines
> > 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.
>
> I cannot quite picture how "rearrangement" would happen, though.
>
> Would the hook notice "ah there is a preparing hook invocation
> incoming", stall the caller by not immediately returning and instead
> wait for a different Git process to invoke the same ref-transaction
> hook "preparing" invocation, and somehow decide to let the latter go
> first before releasing the former?
>

Thank you for asking, happy to clarify. The intended use case is serializing concurrent write calls in Gitaly/Praefect. When the hook fires in the "preparing" state, the hook handler contacts Praefect asking "can I proceed with these ref updates?" Praefect coordinates across multiple concurrent hook callbacks and uses this window to determine ordering: if all callers vote for the same write, they are allowed to proceed; other write requests are held or aborted until the current one completes.

Show 11 quoted lines
> > 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 to handle unknown phases handle the "preparing" state
>
> "know to handle unknown phrases handle"?
>
Fixed.
Show 17 quoted lines
> > string will encounter an unknown phase, and that might cause them to return an
> > error now. 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.
>
> And documenting the design alternatives and decision like these two
> paragraphs is very much appreciated.
>
> The insertion of a new hook invocation itself is at a very much
> expected place in the code path.  Well written.
>
> Will queue.  Thanks.
Thank you.
- Eric
Peijian JuMar 16, 2026, 03:09 UTC in reply to Junio C Hamano on lore

Re: [PATCH 1/1] Add preparing state to reference-transaction hook

On Fri, Mar 13, 2026 at 7:10 PM Junio C Hamano <gitster@pobox.com> wrote:
Show 33 quoted lines
>
> Justin Tobler <jltobler@gmail.com> writes:
>
> > On 26/03/13 03:35PM, eric.peijian@gmail.com wrote:
> >> diff --git a/refs.c b/refs.c
> >> index 6fb8f9d10c..f1439476d3 100644
> >> --- a/refs.c
> >> +++ b/refs.c
> >> @@ -2655,6 +2655,13 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
> >>      if (ref_update_reject_duplicates(&transaction->refnames, err))
> >>              return REF_TRANSACTION_ERROR_GENERIC;
> >>
> >> +    /* Preparing checks before locking references */
> >> +    ret = run_transaction_hook(transaction, "preparing");
> >> +    if (ret) {
> >> +            ref_transaction_abort(transaction, err);
> >> +            die(_("ref updates aborted by %s hook"), "preparing");
> >
> > Should "preparing" be marked for translation here?
>
> It literally is one of the possible tokens reference-transaction
> hook is given as its argument, so no, I do not think "preparing"
> should be translated.
>
> But the hook that interrupted the ref update is not "preparing"
> hook.  It is the "reference-transaction" hook.  So the message
> probably should say something like
>
>     the reference-transaction hook rejected ref updates at its
>     preparing phase
>
> or something.
>

Thanks for the clarification. Fixed, the message now reads: "ref updates aborted by the reference-transaction hook at its preparing phase" (and likewise for "prepared").

Show 15 quoted lines
> >> +    }
> >> +
> >>      ret = refs->be->transaction_prepare(refs, transaction, err);
> >>      if (ret)
> >>              return ret;
> >> @@ -2662,7 +2669,7 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
> >>      ret = run_transaction_hook(transaction, "prepared");
> >>      if (ret) {
> >>              ref_transaction_abort(transaction, err);
> >> -            die(_("ref updates aborted by hook"));
> >> +            die(_("ref updates aborted by %s hook"), "prepared");
> >
> > Same question here for "prepared"?
>
> Ditto.
Ditto.
Thank you.
- Eric
Eric JuMar 16, 2026, 04:51 UTC in reply to eric.peijian@gmail.com on lore

[PATCH v2 0/1] refs: add 'preparing' phase to the reference-transaction hook

The "reference-transaction" hook currently exposes three phases to callers: "prepared", "committed", and "aborted". The earliest of these, "prepared", fires after Git has already acquired exclusive locks on every affected reference. This is well-suited for last-chance validation, but it arrives too late for any use case that requires coordination before locking, such as serializing concurrent transactions across distributed storage nodes.

This series introduces a new "preparing" phase that fires before refs->be->transaction_prepare() is called, that is, before Git takes any reference lock on disk. Hook scripts that handle this phase receive the full list of proposed updates and may reject the transaction by returning a non-zero exit status, causing Git to abort cleanly before any locks are acquired.

The motivating use case is Gitaly/Praefect, GitLab's distributed Git storage layer. Praefect must serialize concurrent writes that target the same references across replicas. With only the "prepared" phase available, by the time Praefect can observe a transaction the locks are already held, making reordering impossible. The "preparing" phase provides the necessary pre-lock window.

Compatibility note: this change is not strictly backwards compatible. Hook scripts that do not expect unknown phase strings may return an error when they encounter "preparing". We consider this acceptable for the same reasons cited when symref support was added to the hook in a8ae923f85 (refs: support symrefs in 'reference-transaction' hook, 2024-05-07): the hook is documented as exposing internal implementation details, and its semantics have been adjusted before. An alternative of introducing a "reference-transaction-v2" hook was considered but rejected as unnecessarily heavyweight.

--- Changes since v1:

- Fix commit title to follow "area: description" convention
  ("refs: add 'preparing' phase to reference-transaction hook")
- Correct phase names in documentation to past tense
  ("committed", "aborted")
- Fix the sentence about backwards compatibility with unknown phases
- Update die() messages to identify the hook by full name and phase
  ("ref updates rejected by the reference-transaction hook at its
  preparing/prepared phase")
- Consolidate author identity to eric.peijian@gmail.com
- Add clarification in reply to the question about how to use the preparing
  phase for write serialization
Eric Ju (1):
  refs: add 'preparing' phase to the reference-transaction hook
 Documentation/githooks.adoc      | 19 ++++++++++++-------
 refs.c                           |  9 ++++++++-
 t/t1416-ref-transaction-hooks.sh | 30 ++++++++++++++++++++++++++----
 t/t5510-fetch.sh                 |  7 ++++++-
 4 files changed, 52 insertions(+), 13 deletions(-)
Range-diff against v1:
1:  5f9f13a84d ! 1:  fb74f21d98 Add preparing state to reference-transaction hook
    @@
      ## Metadata ##
    -Author: Eric Ju <eju@gitlab.com>
    +Author: Eric Ju <eric.peijian@gmail.com>
     
      ## Commit message ##
    -    Add preparing state to reference-transaction hook
    +    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 "commit" phase indicates that all updates have been written to disk.
    -    - The "abort" phase indicates that the transaction has been aborted and that
    +    - 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.
    @@ Commit message
         interfering with the locking state.
     
         This change is strictly speaking not backwards compatible. Existing hook
    -    scripts that do not know to handle unknown phases handle the "preparing" state
    -    string will encounter an unknown phase, and that might cause them to return an
    -    error now. But the hook is considered to expose internal implementation details
    +    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).
    @@ Commit message
     
         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>
     
      ## Documentation/githooks.adoc ##
    @@ refs.c: int ref_transaction_prepare(struct ref_transaction *transaction,
     +	ret = run_transaction_hook(transaction, "preparing");
     +	if (ret) {
     +		ref_transaction_abort(transaction, err);
    -+		die(_("ref updates aborted by %s hook"), "preparing");
    ++		die(_("ref updates aborted by the reference-transaction hook at its %s state"), "preparing");
     +	}
     +
      	ret = refs->be->transaction_prepare(refs, transaction, err);
    @@ refs.c: int ref_transaction_prepare(struct ref_transaction *transaction,
      	if (ret) {
      		ref_transaction_abort(transaction, err);
     -		die(_("ref updates aborted by hook"));
    -+		die(_("ref updates aborted by %s hook"), "prepared");
    ++		die(_("ref updates aborted by the reference-transaction hook at its %s state"), "prepared");
      	}
      
      	return 0;
    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'hook allows updating ref
     +		fi
     +	EOF
     +	test_must_fail git update-ref HEAD POST 2>err &&
    -+	test_grep "ref updates aborted by preparing hook" err
    ++	test_grep "ref updates aborted by the reference-transaction hook at its preparing state" err
     +'
     +
      test_expect_success 'hook aborts updating ref in prepared state' '
    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'hook aborts updating ref
      	EOF
      	test_must_fail git update-ref HEAD POST 2>err &&
     -	test_grep "ref updates aborted by hook" err
    -+	test_grep "ref updates aborted by prepared hook" err
    ++	test_grep "ref updates aborted by the reference-transaction hook at its prepared state" err
      '
      
      test_expect_success 'hook gets all queued updates in prepared state' '
-- 
2.51.0
Eric JuMar 16, 2026, 04:51 UTC in reply to Eric Ju on lore

[PATCH v2 1/1] 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>
---
 Documentation/githooks.adoc      | 19 ++++++++++++-------
 refs.c                           |  9 ++++++++-
 t/t1416-ref-transaction-hooks.sh | 30 ++++++++++++++++++++++++++----
 t/t5510-fetch.sh                 |  7 ++++++-
 4 files changed, 52 insertions(+), 13 deletions(-)
Show changes to 4 files +52 −13

Documentation/githooks.adoc, refs.c, t/t1416-ref-transaction-hooks.sh, t/t5510-fetch.sh

diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 056553788d..ed045940d1 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -484,13 +484,16 @@ reference-transaction
 ~~~~~~~~~~~~~~~~~~~~~
 
 This hook is invoked by any Git command that performs reference
-updates. It executes whenever a reference transaction is prepared,
-committed or aborted and may thus get called multiple times. The hook
-also supports symbolic reference updates.
+updates. It executes whenever a reference transaction is preparing,
+prepared, committed or aborted and may thus get called multiple times.
+The hook also supports symbolic reference updates.
 
 The hook takes exactly one argument, which is the current state the
 given reference transaction is in:
 
+    - "preparing": All reference updates have been queued to the
+      transaction but references are not yet locked on disk.
+
     - "prepared": All reference updates have been queued to the
       transaction and references were locked on disk.
 
@@ -511,16 +514,18 @@ ref and `<ref-name>` is the full name of the ref. When force updating
 the reference regardless of its current value or when the reference is
 to be created anew, `<old-value>` is the all-zeroes object name. To
 distinguish these cases, you can inspect the current value of
-`<ref-name>` via `git rev-parse`.
+`<ref-name>` via `git rev-parse`. During the "preparing" state, symbolic
+references are not resolved: `<ref-name>` will reflect the symbolic reference
+itself rather than the object it points to.
 
 For symbolic reference updates the `<old_value>` and `<new-value>`
 fields could denote references instead of objects. A reference will be
 denoted with a 'ref:' prefix, like `ref:<ref-target>`.
 
 The exit status of the hook is ignored for any state except for the
-"prepared" state. In the "prepared" state, a non-zero exit status will
-cause the transaction to be aborted. The hook will not be called with
-"aborted" state in that case.
+"preparing" and "prepared" states. In these states, a non-zero exit
+status will cause the transaction to be aborted. The hook will not be
+called with "aborted" state in that case.
 
 push-to-checkout
 ~~~~~~~~~~~~~~~~
diff --git a/refs.c b/refs.c
index 6fb8f9d10c..7da37bbb71 100644
--- a/refs.c
+++ b/refs.c
@@ -2655,6 +2655,13 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
 	if (ref_update_reject_duplicates(&transaction->refnames, err))
 		return REF_TRANSACTION_ERROR_GENERIC;
 
+	/* Preparing checks before locking references */
+	ret = run_transaction_hook(transaction, "preparing");
+	if (ret) {
+		ref_transaction_abort(transaction, err);
+		die(_("ref updates aborted by the reference-transaction hook at its %s state"), "preparing");
+	}
+
 	ret = refs->be->transaction_prepare(refs, transaction, err);
 	if (ret)
 		return ret;
@@ -2662,7 +2669,7 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
 	ret = run_transaction_hook(transaction, "prepared");
 	if (ret) {
 		ref_transaction_abort(transaction, err);
-		die(_("ref updates aborted by hook"));
+		die(_("ref updates aborted by the reference-transaction hook at its %s state"), "prepared");
 	}
 
 	return 0;
diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh
index d91dd3a3b5..c3b1a3c735 100755
--- a/t/t1416-ref-transaction-hooks.sh
+++ b/t/t1416-ref-transaction-hooks.sh
@@ -20,6 +20,7 @@ test_expect_success 'hook allows updating ref if successful' '
 		echo "$*" >>actual
 	EOF
 	cat >expect <<-EOF &&
+		preparing
 		prepared
 		committed
 	EOF
@@ -27,6 +28,18 @@ test_expect_success 'hook allows updating ref if successful' '
 	test_cmp expect actual
 '
 
+test_expect_success 'hook aborts updating ref in preparing state' '
+	git reset --hard PRE &&
+	test_hook reference-transaction <<-\EOF &&
+		if test "$1" = preparing
+		then
+			exit 1
+		fi
+	EOF
+	test_must_fail git update-ref HEAD POST 2>err &&
+	test_grep "ref updates aborted by the reference-transaction hook at its preparing state" err
+'
+
 test_expect_success 'hook aborts updating ref in prepared state' '
 	git reset --hard PRE &&
 	test_hook reference-transaction <<-\EOF &&
@@ -36,7 +49,7 @@ test_expect_success 'hook aborts updating ref in prepared state' '
 		fi
 	EOF
 	test_must_fail git update-ref HEAD POST 2>err &&
-	test_grep "ref updates aborted by hook" err
+	test_grep "ref updates aborted by the reference-transaction hook at its prepared state" err
 '
 
 test_expect_success 'hook gets all queued updates in prepared state' '
@@ -121,6 +134,7 @@ test_expect_success 'interleaving hook calls succeed' '
 	cat >expect <<-EOF &&
 		hooks/update refs/tags/PRE $ZERO_OID $PRE_OID
 		hooks/update refs/tags/POST $ZERO_OID $POST_OID
+		hooks/reference-transaction preparing
 		hooks/reference-transaction prepared
 		hooks/reference-transaction committed
 	EOF
@@ -143,6 +157,8 @@ test_expect_success 'hook captures git-symbolic-ref updates' '
 	git symbolic-ref refs/heads/symref refs/heads/main &&
 
 	cat >expect <<-EOF &&
+	preparing
+	$ZERO_OID ref:refs/heads/main refs/heads/symref
 	prepared
 	$ZERO_OID ref:refs/heads/main refs/heads/symref
 	committed
@@ -171,14 +187,20 @@ test_expect_success 'hook gets all queued symref updates' '
 	# In the files backend, "delete" also triggers an additional transaction
 	# update on the packed-refs backend, which constitutes additional reflog
 	# entries.
+	cat >expect <<-EOF &&
+	preparing
+	ref:refs/heads/main $ZERO_OID refs/heads/symref
+	ref:refs/heads/main $ZERO_OID refs/heads/symrefd
+	$ZERO_OID ref:refs/heads/main refs/heads/symrefc
+	ref:refs/heads/main ref:refs/heads/branch refs/heads/symrefu
+	EOF
+
 	if test_have_prereq REFFILES
 	then
-		cat >expect <<-EOF
+		cat >>expect <<-EOF
 		aborted
 		$ZERO_OID $ZERO_OID refs/heads/symrefd
 		EOF
-	else
-		>expect
 	fi &&
 
 	cat >>expect <<-EOF &&
diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
index 5dcb4b51a4..6fe21e2b3a 100755
--- a/t/t5510-fetch.sh
+++ b/t/t5510-fetch.sh
@@ -469,12 +469,17 @@ test_expect_success 'fetch --atomic executes a single reference transaction only
 	head_oid=$(git rev-parse HEAD) &&
 
 	cat >expected <<-EOF &&
+		preparing
+		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
+		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
 		prepared
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
 		committed
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
+		preparing
+		$ZERO_OID ref:refs/remotes/origin/main refs/remotes/origin/HEAD
 	EOF
 
 	rm -f atomic/actual &&
@@ -497,7 +502,7 @@ test_expect_success 'fetch --atomic aborts all reference updates if hook aborts'
 	head_oid=$(git rev-parse HEAD) &&
 
 	cat >expected <<-EOF &&
-		prepared
+		preparing
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-2
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-3
-- 
2.51.0
Patrick SteinhardtMar 16, 2026, 07:03 UTC in reply to Eric Ju on lore

Re: [PATCH v2 0/1] refs: add 'preparing' phase to the reference-transaction hook

On Mon, Mar 16, 2026 at 12:51:01AM -0400, Eric Ju wrote:
Show 13 quoted lines
> Changes since v1:
> 
> - Fix commit title to follow "area: description" convention
>   ("refs: add 'preparing' phase to reference-transaction hook")
> - Correct phase names in documentation to past tense
>   ("committed", "aborted")
> - Fix the sentence about backwards compatibility with unknown phases
> - Update die() messages to identify the hook by full name and phase
>   ("ref updates rejected by the reference-transaction hook at its
>   preparing/prepared phase")
> - Consolidate author identity to eric.peijian@gmail.com
> - Add clarification in reply to the question about how to use the preparing
>   phase for write serialization

All of these changes look good to me, thanks. This patch already looks good to me, but I'm of course biased as I have been helping out behind the scenes before the first version of this patch landed on the mailing list.

Show 15 quoted lines
> Range-diff against v1:
> 1:  5f9f13a84d ! 1:  fb74f21d98 Add preparing state to reference-transaction hook
>     @@ Commit message
>          interfering with the locking state.
>      
>          This change is strictly speaking not backwards compatible. Existing hook
>     -    scripts that do not know to handle unknown phases handle the "preparing" state
>     -    string will encounter an unknown phase, and that might cause them to return an
>     -    error now. But the hook is considered to expose internal implementation details
>     +    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).

One micro-nit: this paragraph could use some reflowing. But I don't think it's worth a reroll.

Thanks!
Patrick
Junio C HamanoMar 16, 2026, 16:24 UTC in reply to Eric Ju on lore

Re: [PATCH v2 1/1] refs: add 'preparing' phase to the reference-transaction hook

Eric Ju <eric.peijian@gmail.com> writes:
Show 6 quoted lines
> +	/* Preparing checks before locking references */
> +	ret = run_transaction_hook(transaction, "preparing");
> +	if (ret) {
> +		ref_transaction_abort(transaction, err);
> +		die(_("ref updates aborted by the reference-transaction hook at its %s state"), "preparing");
> +	}
On end-user's terminal, the above should look like
fatal: ref updates aborted by the reference-transaction hook at its parparing state

consuming more than 80 columns and having the varying part of the message at the very end. Can we shorten this and highlight the more important bits? Here is my attempt

		die(_("in '%s' phase, update aborted by the reference-transaction hook"),
			"preparing");

Enclosing the phase name in 'quotes' and moving it near the beginning are both my attempt to make it stand out more.

Another thing you may want to consider is to extract the message to a separate constant, i.e.,

	const char *abort_by_ref_transaction_hook[] =
	N_("in '%s' phase, update aborted by the reference-transaction hook");
and reuse at two places, perhaps?
		die(_(abort_by_ref_transaction_hook), "preparing");
Peijian JuMar 16, 2026, 23:08 UTC in reply to Patrick Steinhardt on lore

Re: [PATCH v2 0/1] refs: add 'preparing' phase to the reference-transaction hook

On Mon, Mar 16, 2026 at 3:03 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 43 quoted lines
>
> On Mon, Mar 16, 2026 at 12:51:01AM -0400, Eric Ju wrote:
> > Changes since v1:
> >
> > - Fix commit title to follow "area: description" convention
> >   ("refs: add 'preparing' phase to reference-transaction hook")
> > - Correct phase names in documentation to past tense
> >   ("committed", "aborted")
> > - Fix the sentence about backwards compatibility with unknown phases
> > - Update die() messages to identify the hook by full name and phase
> >   ("ref updates rejected by the reference-transaction hook at its
> >   preparing/prepared phase")
> > - Consolidate author identity to eric.peijian@gmail.com
> > - Add clarification in reply to the question about how to use the preparing
> >   phase for write serialization
>
> All of these changes look good to me, thanks. This patch already looks
> good to me, but I'm of course biased as I have been helping out behind
> the scenes before the first version of this patch landed on the mailing
> list.
>
> > Range-diff against v1:
> > 1:  5f9f13a84d ! 1:  fb74f21d98 Add preparing state to reference-transaction hook
> >     @@ Commit message
> >          interfering with the locking state.
> >
> >          This change is strictly speaking not backwards compatible. Existing hook
> >     -    scripts that do not know to handle unknown phases handle the "preparing" state
> >     -    string will encounter an unknown phase, and that might cause them to return an
> >     -    error now. But the hook is considered to expose internal implementation details
> >     +    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).
>
> One micro-nit: this paragraph could use some reflowing. But I don't
> think it's worth a reroll.
>
> Thanks!
>
> Patrick

Thank you. I will reflow the paragraph in v3, which I am already planning to send for the error message and string constant changes.

- Eric
Peijian JuMar 16, 2026, 23:08 UTC in reply to Junio C Hamano on lore

Re: [PATCH v2 1/1] refs: add 'preparing' phase to the reference-transaction hook

On Mon, Mar 16, 2026 at 12:24 PM Junio C Hamano <gitster@pobox.com> wrote:
Show 34 quoted lines
>
> Eric Ju <eric.peijian@gmail.com> writes:
>
> > +     /* Preparing checks before locking references */
> > +     ret = run_transaction_hook(transaction, "preparing");
> > +     if (ret) {
> > +             ref_transaction_abort(transaction, err);
> > +             die(_("ref updates aborted by the reference-transaction hook at its %s state"), "preparing");
> > +     }
>
> On end-user's terminal, the above should look like
>
> fatal: ref updates aborted by the reference-transaction hook at its parparing state
>
> consuming more than 80 columns and having the varying part of the
> message at the very end.  Can we shorten this and highlight the more
> important bits?  Here is my attempt
>
>                 die(_("in '%s' phase, update aborted by the reference-transaction hook"),
>                         "preparing");
>
> Enclosing the phase name in 'quotes' and moving it near the
> beginning are both my attempt to make it stand out more.
>
> Another thing you may want to consider is to extract the message to
> a separate constant, i.e.,
>
>         const char *abort_by_ref_transaction_hook[] =
>         N_("in '%s' phase, update aborted by the reference-transaction hook");
>
> and reuse at two places, perhaps?
>
>                 die(_(abort_by_ref_transaction_hook), "preparing");
>
Thank you. Fixed in V3.
- Eric
Eric JuMar 17, 2026, 02:36 UTC in reply to eric.peijian@gmail.com on lore

[PATCH v3 0/1] refs: add 'preparing' phase to the reference-transaction hook

The "reference-transaction" hook currently exposes three phases to callers: "prepared", "committed", and "aborted". The earliest of these, "prepared", fires after Git has already acquired exclusive locks on every affected reference. This is well-suited for last-chance validation, but it arrives too late for any use case that requires coordination before locking, such as serializing concurrent transactions across distributed storage nodes.

This series introduces a new "preparing" phase that fires before refs->be->transaction_prepare() is called, that is, before Git takes any reference lock on disk. Hook scripts that handle this phase receive the full list of proposed updates and may reject the transaction by returning a non-zero exit status, causing Git to abort cleanly before any locks are acquired.

The motivating use case is Gitaly/Praefect, GitLab's distributed Git storage layer. Praefect must serialize concurrent writes that target the same references across replicas. With only the "prepared" phase available, by the time Praefect can observe a transaction the locks are already held, making reordering impossible. The "preparing" phase provides the necessary pre-lock window.

Compatibility note: this change is not strictly backwards compatible. Hook scripts that do not expect unknown phase strings may return an error when they encounter "preparing". We consider this acceptable for the same reasons cited when symref support was added to the hook in a8ae923f85 (refs: support symrefs in 'reference-transaction' hook, 2024-05-07): the hook is documented as exposing internal implementation details, and its semantics have been adjusted before. An alternative of introducing a "reference-transaction-v2" hook was considered but rejected as unnecessarily heavyweight.

--- Changes since v2:

- Shorten and reorder die() message to highlight the phase name early
- Extract the error message into a file-scope static constant to avoid
  duplication across the "preparing" and "prepared" call sites
- Reflow the backwards compatibility paragraph in the commit message
Eric Ju (1):
  refs: add 'preparing' phase to the reference-transaction hook
 Documentation/githooks.adoc      | 19 ++++++++++++-------
 refs.c                           | 12 +++++++++++-
 t/t1416-ref-transaction-hooks.sh | 30 ++++++++++++++++++++++++++----
 t/t5510-fetch.sh                 |  7 ++++++-
 4 files changed, 55 insertions(+), 13 deletions(-)
Range-diff against v2:
1:  4fff10e694 ! 1:  39f7a2bc4b refs: add 'preparing' phase to the reference-transaction hook
    @@ Commit message
         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).
    +    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,
    @@ Commit message
     
         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>
     
      ## Documentation/githooks.adoc ##
    @@ Documentation/githooks.adoc: ref and `<ref-name>` is the full name of the ref. W
      ~~~~~~~~~~~~~~~~
     
      ## refs.c ##
    +@@ refs.c: const char *ref_storage_format_to_name(enum ref_storage_format ref_storage_forma
    + 	return be->name;
    + }
    + 
    ++static const char *abort_by_ref_transaction_hook =
    ++	N_("in '%s' phase, update aborted by the reference-transaction hook");
    ++
    + /*
    +  * How to handle various characters in refnames:
    +  * 0: An acceptable character for refs
     @@ refs.c: int ref_transaction_prepare(struct ref_transaction *transaction,
      	if (ref_update_reject_duplicates(&transaction->refnames, err))
      		return REF_TRANSACTION_ERROR_GENERIC;
    @@ refs.c: int ref_transaction_prepare(struct ref_transaction *transaction,
     +	ret = run_transaction_hook(transaction, "preparing");
     +	if (ret) {
     +		ref_transaction_abort(transaction, err);
    -+		die(_("ref updates aborted by the reference-transaction hook at its %s state"), "preparing");
    ++		die(_(abort_by_ref_transaction_hook), "preparing");
     +	}
     +
      	ret = refs->be->transaction_prepare(refs, transaction, err);
    @@ refs.c: int ref_transaction_prepare(struct ref_transaction *transaction,
      	if (ret) {
      		ref_transaction_abort(transaction, err);
     -		die(_("ref updates aborted by hook"));
    -+		die(_("ref updates aborted by the reference-transaction hook at its %s state"), "prepared");
    ++		die(_(abort_by_ref_transaction_hook), "prepared");
      	}
      
      	return 0;
    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'hook allows updating ref
     +		fi
     +	EOF
     +	test_must_fail git update-ref HEAD POST 2>err &&
    -+	test_grep "ref updates aborted by the reference-transaction hook at its preparing state" err
    ++	test_grep "in '\''preparing'\'' phase, update aborted by the reference-transaction hook" err
     +'
     +
      test_expect_success 'hook aborts updating ref in prepared state' '
    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'hook aborts updating ref
      	EOF
      	test_must_fail git update-ref HEAD POST 2>err &&
     -	test_grep "ref updates aborted by hook" err
    -+	test_grep "ref updates aborted by the reference-transaction hook at its prepared state" err
    ++	test_grep "in '\''prepared'\'' phase, update aborted by the reference-transaction hook" err
      '
      
      test_expect_success 'hook gets all queued updates in prepared state' '
-- 
2.51.0
Eric JuMar 17, 2026, 02:36 UTC in reply to Eric Ju on lore

[PATCH v3 1/1] 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>
---
 Documentation/githooks.adoc      | 19 ++++++++++++-------
 refs.c                           | 12 +++++++++++-
 t/t1416-ref-transaction-hooks.sh | 30 ++++++++++++++++++++++++++----
 t/t5510-fetch.sh                 |  7 ++++++-
 4 files changed, 55 insertions(+), 13 deletions(-)
Show changes to 4 files +55 −13

Documentation/githooks.adoc, refs.c, t/t1416-ref-transaction-hooks.sh, t/t5510-fetch.sh

diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 056553788d..ed045940d1 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -484,13 +484,16 @@ reference-transaction
 ~~~~~~~~~~~~~~~~~~~~~
 
 This hook is invoked by any Git command that performs reference
-updates. It executes whenever a reference transaction is prepared,
-committed or aborted and may thus get called multiple times. The hook
-also supports symbolic reference updates.
+updates. It executes whenever a reference transaction is preparing,
+prepared, committed or aborted and may thus get called multiple times.
+The hook also supports symbolic reference updates.
 
 The hook takes exactly one argument, which is the current state the
 given reference transaction is in:
 
+    - "preparing": All reference updates have been queued to the
+      transaction but references are not yet locked on disk.
+
     - "prepared": All reference updates have been queued to the
       transaction and references were locked on disk.
 
@@ -511,16 +514,18 @@ ref and `<ref-name>` is the full name of the ref. When force updating
 the reference regardless of its current value or when the reference is
 to be created anew, `<old-value>` is the all-zeroes object name. To
 distinguish these cases, you can inspect the current value of
-`<ref-name>` via `git rev-parse`.
+`<ref-name>` via `git rev-parse`. During the "preparing" state, symbolic
+references are not resolved: `<ref-name>` will reflect the symbolic reference
+itself rather than the object it points to.
 
 For symbolic reference updates the `<old_value>` and `<new-value>`
 fields could denote references instead of objects. A reference will be
 denoted with a 'ref:' prefix, like `ref:<ref-target>`.
 
 The exit status of the hook is ignored for any state except for the
-"prepared" state. In the "prepared" state, a non-zero exit status will
-cause the transaction to be aborted. The hook will not be called with
-"aborted" state in that case.
+"preparing" and "prepared" states. In these states, a non-zero exit
+status will cause the transaction to be aborted. The hook will not be
+called with "aborted" state in that case.
 
 push-to-checkout
 ~~~~~~~~~~~~~~~~
diff --git a/refs.c b/refs.c
index 6fb8f9d10c..e66cf4861d 100644
--- a/refs.c
+++ b/refs.c
@@ -64,6 +64,9 @@ const char *ref_storage_format_to_name(enum ref_storage_format ref_storage_forma
 	return be->name;
 }
 
+static const char *abort_by_ref_transaction_hook =
+	N_("in '%s' phase, update aborted by the reference-transaction hook");
+
 /*
  * How to handle various characters in refnames:
  * 0: An acceptable character for refs
@@ -2655,6 +2658,13 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
 	if (ref_update_reject_duplicates(&transaction->refnames, err))
 		return REF_TRANSACTION_ERROR_GENERIC;
 
+	/* Preparing checks before locking references */
+	ret = run_transaction_hook(transaction, "preparing");
+	if (ret) {
+		ref_transaction_abort(transaction, err);
+		die(_(abort_by_ref_transaction_hook), "preparing");
+	}
+
 	ret = refs->be->transaction_prepare(refs, transaction, err);
 	if (ret)
 		return ret;
@@ -2662,7 +2672,7 @@ int ref_transaction_prepare(struct ref_transaction *transaction,
 	ret = run_transaction_hook(transaction, "prepared");
 	if (ret) {
 		ref_transaction_abort(transaction, err);
-		die(_("ref updates aborted by hook"));
+		die(_(abort_by_ref_transaction_hook), "prepared");
 	}
 
 	return 0;
diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh
index d91dd3a3b5..4fe9d9b234 100755
--- a/t/t1416-ref-transaction-hooks.sh
+++ b/t/t1416-ref-transaction-hooks.sh
@@ -20,6 +20,7 @@ test_expect_success 'hook allows updating ref if successful' '
 		echo "$*" >>actual
 	EOF
 	cat >expect <<-EOF &&
+		preparing
 		prepared
 		committed
 	EOF
@@ -27,6 +28,18 @@ test_expect_success 'hook allows updating ref if successful' '
 	test_cmp expect actual
 '
 
+test_expect_success 'hook aborts updating ref in preparing state' '
+	git reset --hard PRE &&
+	test_hook reference-transaction <<-\EOF &&
+		if test "$1" = preparing
+		then
+			exit 1
+		fi
+	EOF
+	test_must_fail git update-ref HEAD POST 2>err &&
+	test_grep "in '\''preparing'\'' phase, update aborted by the reference-transaction hook" err
+'
+
 test_expect_success 'hook aborts updating ref in prepared state' '
 	git reset --hard PRE &&
 	test_hook reference-transaction <<-\EOF &&
@@ -36,7 +49,7 @@ test_expect_success 'hook aborts updating ref in prepared state' '
 		fi
 	EOF
 	test_must_fail git update-ref HEAD POST 2>err &&
-	test_grep "ref updates aborted by hook" err
+	test_grep "in '\''prepared'\'' phase, update aborted by the reference-transaction hook" err
 '
 
 test_expect_success 'hook gets all queued updates in prepared state' '
@@ -121,6 +134,7 @@ test_expect_success 'interleaving hook calls succeed' '
 	cat >expect <<-EOF &&
 		hooks/update refs/tags/PRE $ZERO_OID $PRE_OID
 		hooks/update refs/tags/POST $ZERO_OID $POST_OID
+		hooks/reference-transaction preparing
 		hooks/reference-transaction prepared
 		hooks/reference-transaction committed
 	EOF
@@ -143,6 +157,8 @@ test_expect_success 'hook captures git-symbolic-ref updates' '
 	git symbolic-ref refs/heads/symref refs/heads/main &&
 
 	cat >expect <<-EOF &&
+	preparing
+	$ZERO_OID ref:refs/heads/main refs/heads/symref
 	prepared
 	$ZERO_OID ref:refs/heads/main refs/heads/symref
 	committed
@@ -171,14 +187,20 @@ test_expect_success 'hook gets all queued symref updates' '
 	# In the files backend, "delete" also triggers an additional transaction
 	# update on the packed-refs backend, which constitutes additional reflog
 	# entries.
+	cat >expect <<-EOF &&
+	preparing
+	ref:refs/heads/main $ZERO_OID refs/heads/symref
+	ref:refs/heads/main $ZERO_OID refs/heads/symrefd
+	$ZERO_OID ref:refs/heads/main refs/heads/symrefc
+	ref:refs/heads/main ref:refs/heads/branch refs/heads/symrefu
+	EOF
+
 	if test_have_prereq REFFILES
 	then
-		cat >expect <<-EOF
+		cat >>expect <<-EOF
 		aborted
 		$ZERO_OID $ZERO_OID refs/heads/symrefd
 		EOF
-	else
-		>expect
 	fi &&
 
 	cat >>expect <<-EOF &&
diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
index 5dcb4b51a4..6fe21e2b3a 100755
--- a/t/t5510-fetch.sh
+++ b/t/t5510-fetch.sh
@@ -469,12 +469,17 @@ test_expect_success 'fetch --atomic executes a single reference transaction only
 	head_oid=$(git rev-parse HEAD) &&
 
 	cat >expected <<-EOF &&
+		preparing
+		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
+		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
 		prepared
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
 		committed
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-2
+		preparing
+		$ZERO_OID ref:refs/remotes/origin/main refs/remotes/origin/HEAD
 	EOF
 
 	rm -f atomic/actual &&
@@ -497,7 +502,7 @@ test_expect_success 'fetch --atomic aborts all reference updates if hook aborts'
 	head_oid=$(git rev-parse HEAD) &&
 
 	cat >expected <<-EOF &&
-		prepared
+		preparing
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-1
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-2
 		$ZERO_OID $head_oid refs/remotes/origin/atomic-hooks-abort-3
-- 
2.51.0

Back to recent threads