Files
git/t/t1416-ref-transaction-hooks.sh
Eric Ju 60d8c1e97d 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>
2026-03-16 21:00:44 -07:00

232 lines
5.5 KiB
Bash
Executable File

#!/bin/sh
test_description='reference transaction hooks'
GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
. ./test-lib.sh
test_expect_success setup '
test_commit PRE &&
PRE_OID=$(git rev-parse PRE) &&
test_commit POST &&
POST_OID=$(git rev-parse POST)
'
test_expect_success 'hook allows updating ref if successful' '
git reset --hard PRE &&
test_hook reference-transaction <<-\EOF &&
echo "$*" >>actual
EOF
cat >expect <<-EOF &&
preparing
prepared
committed
EOF
git update-ref HEAD POST &&
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 &&
if test "$1" = prepared
then
exit 1
fi
EOF
test_must_fail git update-ref HEAD POST 2>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' '
test_when_finished "rm actual" &&
git reset --hard PRE &&
test_hook reference-transaction <<-\EOF &&
if test "$1" = prepared
then
while read -r line
do
printf "%s\n" "$line"
done >actual
fi
EOF
cat >expect <<-EOF &&
$ZERO_OID $POST_OID refs/heads/main
EOF
git update-ref HEAD POST <<-EOF &&
update HEAD $ZERO_OID $POST_OID
update refs/heads/main $ZERO_OID $POST_OID
EOF
test_cmp expect actual
'
test_expect_success 'hook gets all queued updates in committed state' '
test_when_finished "rm actual" &&
git reset --hard PRE &&
test_hook reference-transaction <<-\EOF &&
if test "$1" = committed
then
while read -r line
do
printf "%s\n" "$line"
done >actual
fi
EOF
cat >expect <<-EOF &&
$ZERO_OID $POST_OID refs/heads/main
EOF
git update-ref HEAD POST &&
test_cmp expect actual
'
test_expect_success 'hook gets all queued updates in aborted state' '
test_when_finished "rm actual" &&
git reset --hard PRE &&
test_hook reference-transaction <<-\EOF &&
if test "$1" = aborted
then
while read -r line
do
printf "%s\n" "$line"
done >actual
fi
EOF
cat >expect <<-EOF &&
$ZERO_OID $POST_OID HEAD
$ZERO_OID $POST_OID refs/heads/main
EOF
git update-ref --stdin <<-EOF &&
start
update HEAD POST $ZERO_OID
update refs/heads/main POST $ZERO_OID
abort
EOF
test_cmp expect actual
'
test_expect_success 'interleaving hook calls succeed' '
test_when_finished "rm -r target-repo.git" &&
git init --bare target-repo.git &&
test_hook -C target-repo.git reference-transaction <<-\EOF &&
echo $0 "$@" >>actual
EOF
test_hook -C target-repo.git update <<-\EOF &&
echo $0 "$@" >>actual
EOF
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
git push ./target-repo.git PRE POST &&
test_cmp expect target-repo.git/actual
'
test_expect_success 'hook captures git-symbolic-ref updates' '
test_when_finished "rm actual" &&
test_hook reference-transaction <<-\EOF &&
echo "$*" >>actual
while read -r line
do
printf "%s\n" "$line"
done >>actual
EOF
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
$ZERO_OID ref:refs/heads/main refs/heads/symref
EOF
test_cmp expect actual
'
test_expect_success 'hook gets all queued symref updates' '
test_when_finished "rm actual" &&
git update-ref refs/heads/branch $POST_OID &&
git symbolic-ref refs/heads/symref refs/heads/main &&
git symbolic-ref refs/heads/symrefd refs/heads/main &&
git symbolic-ref refs/heads/symrefu refs/heads/main &&
test_hook reference-transaction <<-\EOF &&
echo "$*" >>actual
while read -r line
do
printf "%s\n" "$line"
done >>actual
EOF
# 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
aborted
$ZERO_OID $ZERO_OID refs/heads/symrefd
EOF
fi &&
cat >>expect <<-EOF &&
prepared
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
committed
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
git update-ref --no-deref --stdin <<-EOF &&
start
symref-verify refs/heads/symref refs/heads/main
symref-delete refs/heads/symrefd refs/heads/main
symref-create refs/heads/symrefc refs/heads/main
symref-update refs/heads/symrefu refs/heads/branch ref refs/heads/main
prepare
commit
EOF
test_cmp expect actual
'
test_done