Files
git-mirror/t/t5412-receive-report-hook.sh
Karthik Nayak 3bbb0864a2 hook: introduce the receive-report hook
When running 'git-receive-pack(1)', there is no way for the server to
intercept and modify the status report before it is sent back to the
client. Servers with custom logic may need to transform or gate the
report based on the outcome of external logic post reference updates.

This is specially needed for our usecase at GitLab where we have custom
MVCC logic on top of Git which creates a new version for each push
operation. The new version is only committed when certain external
operations post reference transaction succeed. So reporting the correct
message based on the outcome of these operations is important.

The outcome of these operations is only known after `execute_commands()`
has returned and before the report is written. There is no point in
receive-pack where the server can act on that.

We cannot use any of the existing hooks as:

  - The pre-receive hook runs too early, as we haven't updated
    references at that point yet and we need to have the full view of
    all resulting updates (both objects and references).

  - The update hook is too inefficient as it runs once per reference,
    and we cannot trivially determine the last update.

  - The reference-transaction hook is not suited for this. It fires from
    within `ref_transaction_commit()`, which is before the outcome we
    need to report is known, so there is no phase at which it could give
    us the answer. It also does not contain any knowledge regarding the
    push and cannot communicate with the clients.

  - The proc-receive hook replaces execute_commands() for references
    matching 'receive.procReceiveRefs'. We need to gate the report for
    the push as a whole.

  - The post-receive and post-update hooks cannot be used as they run
    too late, at the point where we have already reported success to the
    client.

Introduce a new 'receive-report' hook. The hook receives the complete
pkt-line encoded status report on standard input, after all ref updates
have been applied to the repository by execute_commands() but before the
report is sent to the client. See linkgit:gitprotocol-pack[5] details on
the protocol structure.

The hook's stdout fully replaces the report sent to the client.
receive-pack fully buffers the hook's stdout before acting on the exit
status, so the exit code is known before the client receives anything.
This gives two distinct behaviors depending on exit status:

- Exit 0: the hook's stdout is used as the report. The hook can
  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the
  client while receive-pack itself exits cleanly. The client marks
  rejected refs as '[remote rejected]' and exits with a non-zero
  status if any ref is 'ng'.

- Non-zero exit: the hook's stdout is discarded, receive-pack modifies
  all references to be rejected with a 'receive-report hook failed'
  error.

In both cases, any output the hook writes to standard error is
forwarded to the client over the sideband channel and appears as
'remote:' lines on the client terminal. Writing to stderr alone does
not affect the push outcome.

Reference updates applied by execute_commands() are not rolled back in
either failure mode. The hook can cause the client to perceive the push
as failed, but cannot undo server-side changes. This creates a
divergence that the server cannot resolve: the client leaves its
remote-tracking reference at the old value while the update is in fact
applied, and a later fetch may reveal the update that the push reported
as rejected.

The hook is therefore only appropriate for servers which can guarantee
that a rejected update is not observable by any reader. In our case the
transaction committed by execute_commands() produces a candidate version
which is not visible to other readers and is only published once the
subsequent operations succeed, so a report of 'ng' corresponds to a
version that is discarded rather than published. On a repository where a
committed reference update is immediately visible, rejecting a push from
this hook would instead leave the pusher with a view that does not match
the server.

This hook does not use the config-based hook infrastructure, which
supports running multiple scripts per hook event. This hook is a
bidirectional filter: it receives the report on stdin and writes a
modified version to stdout. Running multiple such scripts sequentially
would require piping the output of one into the input of the next,
which the current hook infrastructure does not support. A single-script
design is therefore a natural fit, and is consistent with how
'proc-receive' is structured for the same reason.

Helped-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Karthik Nayak <karthik.188@gmail.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
2026-09-11 14:28:40 -07:00

258 lines
7.4 KiB
Bash
Executable File

#!/bin/sh
test_description='test receive-report hook'
GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
. ./test-lib.sh
. "$TEST_DIRECTORY"/t5411/common-functions.sh
URL_PREFIX="\.\."
test_expect_success "setup workbench" '
git init workbench &&
create_commits_in workbench A B
'
test_expect_success "no report hook, push succeeds" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
make_user_friendly_and_stable_output <out >actual &&
cat >expect <<-\EOF &&
To ../upstream
<COMMIT-A>..<COMMIT-B> <COMMIT-B> -> main
EOF
test_cmp expect actual
'
test_expect_success "passthrough does not alter report" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
test_hook -C upstream --setup receive-report <<-\EOF &&
cat
EOF
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
make_user_friendly_and_stable_output <out >actual &&
cat >expect <<-\EOF &&
To ../upstream
<COMMIT-A>..<COMMIT-B> <COMMIT-B> -> main
EOF
test_cmp expect actual
'
test_expect_success "non-zero exit reports as hook failed" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
test_hook -C upstream --setup receive-report <<-\EOF &&
exit 1
EOF
test_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
make_user_friendly_and_stable_output <out >actual &&
cat >expect <<-\EOF &&
To ../upstream
! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)
EOF
test_cmp expect actual
'
test_expect_success "hook is invoked and receives report on stdin" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
test_hook -C upstream --setup receive-report <<-EOF &&
tee raw
EOF
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
make_user_friendly_and_stable_output <out >actual &&
cat >expect <<-EOF &&
To ../upstream
<COMMIT-A>..<COMMIT-B> <COMMIT-B> -> main
EOF
test_cmp expect actual &&
test-tool pkt-line unpack <upstream/raw >actual-report &&
cat >expect-report <<-EOF &&
unpack ok
ok refs/heads/main
0000
EOF
test_cmp expect-report actual-report
'
test_expect_success "hook can modify the report sent to client" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
test_hook -C upstream --setup receive-report <<-\EOF &&
test-tool pkt-line unpack |
sed "s/^ok /ng /" |
test-tool pkt-line pack
EOF
test_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
make_user_friendly_and_stable_output <out >actual &&
cat >expect <<-\EOF &&
To ../upstream
! [remote rejected] <COMMIT-B> -> main (failed)
EOF
test_cmp expect actual
'
test_expect_success "hook can modify the unpack status" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
test_hook -C upstream --setup receive-report <<-\EOF &&
test-tool pkt-line unpack |
sed "s/^unpack ok$/unpack push failed due to server error/" |
test-tool pkt-line pack
EOF
test_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
test_grep "error: remote unpack failed: push failed due to server error" out &&
make_user_friendly_and_stable_output <out >actual &&
cat >expect <<-\EOF &&
To ../upstream
<COMMIT-A>..<COMMIT-B> <COMMIT-B> -> main
EOF
test_cmp expect actual
'
test_expect_success "hook can report a custom failure message" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
test_hook -C upstream --setup receive-report <<-\EOF &&
echo "push rejected: service X is down" >&2
test-tool pkt-line unpack |
sed "s/^ok \(.*\)/ng \1 service-x-is-down/" |
test-tool pkt-line pack |
tee raw
EOF
test_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
test_grep "push rejected: service X is down" out &&
test-tool pkt-line unpack <upstream/raw >actual-report &&
cat >expect-report <<-\EOF &&
unpack ok
ng refs/heads/main service-x-is-down
0000
EOF
test_cmp expect-report actual-report
'
test_expect_success "hook stderr with zero exit status code" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
test_hook -C upstream --setup receive-report <<-\EOF &&
echo "push rejected: service X is down" >&2
tee raw
EOF
git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
test_grep "push rejected: service X is down" out &&
test-tool pkt-line unpack <upstream/raw >actual-report &&
cat >expect-report <<-\EOF &&
unpack ok
ok refs/heads/main
0000
EOF
test_cmp expect-report actual-report
'
test_expect_success "non-zero exit with pre-existing ng from proc-receive" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
git -C upstream config receive.procReceiveRefs refs/for &&
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
# Use a proc-receive hook to generate a dynamic error string.
# This is used to capture any leaks stemming from overriding the
# error message via the receive-report.
test_hook -C upstream --setup proc-receive <<-\EOF &&
test-tool proc-receive -r "ng refs/for/main/topic push-rejected-by-service-x"
EOF
test_hook -C upstream --setup receive-report <<-\EOF &&
tee raw
exit 1
EOF
test_must_fail git -C workbench push origin HEAD:refs/for/main/topic >out 2>&1 &&
test_grep "receive-report hook failed" out &&
test-tool pkt-line unpack <upstream/raw >actual-report &&
cat >expect-report <<-\EOF &&
unpack ok
ng refs/for/main/topic push-rejected-by-service-x
0000
EOF
test_cmp expect-report actual-report
'
test_expect_success "hook stderr is relayed to client via sideband" '
test_when_finished "rm -rf upstream" &&
test_when_finished "git -C workbench remote remove origin" &&
git init --bare upstream &&
git -C workbench remote add origin ../upstream &&
git -C workbench push origin $A:refs/heads/main &&
test_hook -C upstream --setup receive-report <<-\EOF &&
echo "hook-stderr-message" >&2
exit 1
EOF
test_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&
test_grep "remote: hook-stderr-message" out
'
test_done