{"thread":{"id":"66186","subject":"[PATCH] hook: introduce the report hook for git-receive-pack(1)","startedAt":"2026-08-18T07:56:00Z","lastAt":"2026-09-15T04:54:30Z","messageCount":98,"participants":["Karthik Nayak","Junio C Hamano","Kristoffer Haugsbakk","Patrick Steinhardt","Phillip Wood","Jeff King","Oswald Buddenhagen"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"550734","messageId":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","threadId":"66186","inReplyTo":null,"subject":"[PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-18T07:55:55Z","receivedAt":"2026-08-18T07:56:00Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is currently no way for the\nserver to intercept and modify the status report before it is sent back\nto the client. This is useful for servers with custom logic that need\nto transform or gate the report based on the outcome of external logic\npost reference updates.\n\nIntroduce a new 'report' hook which receives the pkt-line encoded\nstatus report on stdin and whose stdout replaces the report sent to the\nclient. A non-zero exit status causes `receive-pack` to die and the\nclient to treat the push as failed.\n\nSimilar to the 'proc-receive' hook, this does not use the config-based\nhook infrastructure. That infrastructure is designed for parallelizable\nnotification hooks. As this hook is a bidirectional filter, it would\nrequire significant modifications to that infrastructure and this hook\ncannot be parallelized anyway.\n\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\nTo give some context, we at GitLab are building a custom MVCC around\nGit. Each git-push would initialize a new version which is then\ncommitted as the default post some operations. These operations take\nplace after the reference transaction and based on the output status of\nthose operations, we want to propagate the status to the user. There\ncurrently exists no good mechanism to do so.\n\nHaving a report hook which allows us to modify the report being\npropagated to the user, allows us to modify the report based on the\nstatus of our MVCC commit phase.\n---\n Documentation/githooks.adoc |  23 ++++++\n builtin/receive-pack.c      |  41 +++++++++++\n t/meson.build               |   1 +\n t/t5412-report-hook.sh      | 176 ++++++++++++++++++++++++++++++++++++++++++++\n 4 files changed, 241 insertions(+)\n\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..7e6643ad89 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,29 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+report\n+~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates reference(s) in its repository. It executes on\n+the remote repository once after all refs have been updated, but before\n+the status report is sent back to the client.\n+\n+The hook receives the pkt-line encoded status report on standard input\n+and its standard output replaces the report sent to the client. Any\n+output written to standard error is forwarded to the client over the\n+sideband channel and will appear as `remote:` lines on the client's\n+terminal. To reject individual ref updates, rewrite the corresponding\n+`ok` lines to `ng` lines in the output report (with an explanatory\n+error string) and exit zero; standard error can accompany this to\n+provide a human-readable explanation. A non-zero exit status causes\n+`receive-pack` to die.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. A non-zero exit causes the client to see the\n+push as failed, but does *not* roll back any ref changes that were\n+already committed server-side.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 86933d8d7e..bc22b3ec31 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint code;\n+\n+\thook_path = find_hook(the_repository, \"report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tcode = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t    report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!code)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn code;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2547,6 +2582,9 @@ static void report(struct command *commands, const char *unpack_status)\n \t}\n \tpacket_buf_flush(&buf);\n \n+\tif (run_report_hook(&buf))\n+\t\tdie(\"report hook failed\");\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\n@@ -2592,6 +2630,9 @@ static void report_v2(struct command *commands, const char *unpack_status)\n \t}\n \tpacket_buf_flush(&buf);\n \n+\tif (run_report_hook(&buf))\n+\t\tdie(\"report hook failed\");\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex a25f37d2f5..7056e31326 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -651,6 +651,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\nnew file mode 100755\nindex 0000000000..47f20e8d67\n--- /dev/null\n+++ b/t/t5412-report-hook.sh\n@@ -0,0 +1,176 @@\n+#!/bin/sh\n+\n+test_description='test report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit causes receive-pack to die\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tfatal: report hook failed\n+\tsend-pack: unexpected disconnect while reading sideband packet\n+\tfatal: the remote end hung up unexpectedly\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"hook-stderr-message\" out\n+'\n+\n+test_done\n\n---\nbase-commit: 11c6700f10234578d10523faf35656ca491425c9\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"550777","messageId":"xmqqcxvfw3o5.fsf@gitster.g","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-18T20:54:02Z","receivedAt":"2026-08-18T20:54:05Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> When running 'git-receive-pack(1)', there is currently no way for the\n> server to intercept and modify the status report before it is sent back\n> to the client. This is useful for servers with custom logic that need\n> to transform or gate the report based on the outcome of external logic\n> post reference updates.\n\nOne sentence is missing.  The fact that there is no way for the\nserver to customize the report is not useful, but that is how the\nabove reads.  Drop \"currently\", as the introductory observation is\nalways about the status, explaining what is missing to make\nreaders realize why they may want the new feature introduced by the\nchange.\n\n> Introduce a new 'report' hook which receives the pkt-line encoded\n> status report on stdin and whose stdout replaces the report sent to the\n> client. A non-zero exit status causes `receive-pack` to die and the\n> client to treat the push as failed.\n\nAfter getting asked to accept a push to three refs and receiving\nthe object transfer, the hook can say \"I'll let these two refs be\nupdated, but refuse to update the other one\" and return success by\nexiting 0.  What does the other side of the connection see?  Two\nsuccesses with one rejection, I guess.  If the hook instead rewrites\nthe report to say \"all three ref updates were rejected\" and returns\nsuccess, then what does the other side see?  Failures on all three\nrefs, right?\n\nWhat should happen when the hook says \"all three ref updates were\naccepted and they updated to point at objects X, Y, Z\", but the hook\nitself exits with a non-zero status?  How does the other side tell\nif their push succeeded (as described in the returned report) or\nfailed (as receive-pack(1) noticed the hook's exit status was not\n0)?\n\nHow is the failure due to the hook's exit status propagated back to\nthe other side of the connection?  Does receive-pack(1) hold on to\nthe report until the hook dies, and if it dies with status 0 give\nthat report back to 'git push'?  And if it dies with a non-zero\nstatus, then what?  Ignore the report and send a failure report\ngenerated on its own?\n\nThe observation made in the preceding paragraphs shows that allowing\nthe exit status of the hook to further affect the outcome is a bit\niffy as a design to define what a \"failure\" is, unless it is more\ntightly described.  The hook can signal failure in its report\noutput without exiting with a non-zero status at all, and if\nreceive-pack(1) wants to allow the exit code of the hook to affect\nthe outcome, it cannot stream the report back to 'git push' as it\nreceives it from the hook.\n\n> @@ -2547,6 +2582,9 @@ static void report(struct command *commands, const char *unpack_status)\n>  \t}\n>  \tpacket_buf_flush(&buf);\n>  \n> +\tif (run_report_hook(&buf))\n> +\t\tdie(\"report hook failed\");\n> +\n>  \tif (use_sideband)\n>  \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>  \telse\n> @@ -2592,6 +2630,9 @@ static void report_v2(struct command *commands, const char *unpack_status)\n>  \t}\n>  \tpacket_buf_flush(&buf);\n>  \n> +\tif (run_report_hook(&buf))\n> +\t\tdie(\"report hook failed\");\n> +\n>  \tif (use_sideband)\n>  \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>  \telse\n\nOK.  The other side does not even hear the report if the hook\naborts.  And lack of success report is what the other side\ninterprets as a failure.\n\nUgly, but may work OK.  Needs to be documented a bit more clearly,\nthough.\n\nThanks.\n"},{"id":"550787","messageId":"7dc975d2-324b-46a4-a389-9af96f4d5d57@app.fastmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-08-19T07:03:36Z","receivedAt":"2026-08-19T07:04:01Z","isPatch":true,"body":"On Tue, Aug 18, 2026, at 09:55, Karthik Nayak wrote:\n> When running 'git-receive-pack(1)', there is currently no way for the\n> server to intercept and modify the status report before it is sent back\n> to the client. This is useful for servers with custom logic that need\n> to transform or gate the report based on the outcome of external logic\n> post reference updates.\n>\n> Introduce a new 'report' hook which receives the pkt-line encoded\n> status report on stdin and whose stdout replaces the report sent to the\n> client. A non-zero exit status causes `receive-pack` to die and the\n> client to treat the push as failed.\n>\n> Similar to the 'proc-receive' hook, this does not use the config-based\n> hook infrastructure. That infrastructure is designed for parallelizable\n> notification hooks. As this hook is a bidirectional filter, it would\n> require significant modifications to that infrastructure and this hook\n> cannot be parallelized anyway.\n>\n> Signed-off-by: Karthik Nayak <karthik.188@gmail.com>\n> ---\n> To give some context, we at GitLab are building a custom MVCC around\n> Git. Each git-push would initialize a new version which is then\n> committed as the default post some operations. These operations take\n> place after the reference transaction and based on the output status of\n> those operations, we want to propagate the status to the user. There\n> currently exists no good mechanism to do so.\n>\n> Having a report hook which allows us to modify the report being\n> propagated to the user, allows us to modify the report based on the\n> status of our MVCC commit phase.\n\nPersonally I think understanding concrete things is easier than\nunderstanding general things. And discussing the concrete case in the\ncommit message would help with that as well as provide the context for\ngit-log(1) rather than just the people who have read these emails.\n\n> ---\n>  Documentation/githooks.adoc |  23 ++++++\n>  builtin/receive-pack.c      |  41 +++++++++++\n>  t/meson.build               |   1 +\n>  t/t5412-report-hook.sh      | 176 ++++++++++++++++++++++++++++++++++++++++++++\n>  4 files changed, 241 insertions(+)\n\nShould the git-receive-pack(1) doc be updated to mention that this hook\nexists? I don’t understand the setup here. The existing\ngit-receive-pack(1) doc has sections for these hooks:\n\n• `update`\n• `pre-receive`\n• `post-receive`\n• `post-update`\n\nBut not these:\n\n• `push-to-checkout`\n• `proc-receive`\n\n(referenced against githooks(5))\n\n>\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index ed045940d1..7e6643ad89 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -527,6 +527,29 @@ The exit status of the hook is ignored for any\n> state except for the\n>  status will cause the transaction to be aborted. The hook will not be\n>  called with \"aborted\" state in that case.\n>\n> +report\n> +~~~~~~\n> +\n> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n> +`git push` and updates reference(s) in its repository. It executes on\n> +the remote repository once after all refs have been updated, but before\n> +the status report is sent back to the client.\n> +\n> +The hook receives the pkt-line encoded status report on standard input\n\nAnother naive question (I have never used any of this). Should this link\nto some gitprotocol-X(5) after `pkt-line` in order to have a link that\nexplains what it is? I don’t see any mention of `pkt-line` on\ngit-receive-pack(1) or a mention of a gitprotocol-X(5).\n\n> +and its standard output replaces the report sent to the client. Any\n> +output written to standard error is forwarded to the client over the\n> +sideband channel and will appear as `remote:` lines on the client's\n> +terminal. To reject individual ref updates, rewrite the corresponding\n> +`ok` lines to `ng` lines in the output report (with an explanatory\n> +error string) and exit zero; standard error can accompany this to\n> +provide a human-readable explanation. A non-zero exit status causes\n> +`receive-pack` to die.\n> +\n> +Note that by the time this hook runs, all ref updates have already been\n> +applied to the repository. A non-zero exit causes the client to see the\n> +push as failed, but does *not* roll back any ref changes that were\n> +already committed server-side.\n\nTo my naive eyes this description looks good and without any obvious\nerrors (typos ;) ).\n\n> +\n>  push-to-checkout\n>  ~~~~~~~~~~~~~~~~\n>\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>[snip]\n> @@ -2592,6 +2630,9 @@ static void report_v2(struct command *commands,\n> const char *unpack_status)\n>  \t}\n>  \tpacket_buf_flush(&buf);\n>\n> +\tif (run_report_hook(&buf))\n> +\t\tdie(\"report hook failed\");\n\nOkay, it seems typical for this command to use regular strings (not\ntranslated) for errors. Which makes sense given the application. There\ndoes seem to be translated error strings but one example is “refusing to\nupdate current branch”, which seems to be more of a non-bare, end-user\nerror than a server error.\n\n> +\n>  \tif (use_sideband)\n>  \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>  \telse\n>[snip]\n> diff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\n>[snip]\n> +test_expect_success \"no report hook, push succeeds\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n\nThis teardown routine is common to all the tests. Is it better style\nhere to write it out compared to using a helper function (test code is\ndifferent from “normal” code)?\n\n> +\tgit init --bare upstream &&\n>[snip]\n"},{"id":"550789","messageId":"aoVdlC7myRFenPfV@pks.im","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-08-19T07:39:44Z","receivedAt":"2026-08-19T07:39:56Z","isPatch":true,"body":"On Tue, Aug 18, 2026 at 09:55:55AM +0200, Karthik Nayak wrote:\n> When running 'git-receive-pack(1)', there is currently no way for the\n> server to intercept and modify the status report before it is sent back\n> to the client. This is useful for servers with custom logic that need\n> to transform or gate the report based on the outcome of external logic\n> post reference updates.\n> \n> Introduce a new 'report' hook which receives the pkt-line encoded\n> status report on stdin and whose stdout replaces the report sent to the\n> client. A non-zero exit status causes `receive-pack` to die and the\n> client to treat the push as failed.\n\nI think it would have been useful to add context why none of the\npreexisting hooks work for us:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook cannot be used by us because we care\n    about the phase where it was committed already. And while the hook\n    fires in that phase, it does not allow the caller to modify the\n    result in any capacity.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index ed045940d1..7e6643ad89 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -527,6 +527,29 @@ The exit status of the hook is ignored for any state except for the\n>  status will cause the transaction to be aborted. The hook will not be\n>  called with \"aborted\" state in that case.\n>  \n> +report\n> +~~~~~~\n> +\n> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n> +`git push` and updates reference(s) in its repository. It executes on\n> +the remote repository once after all refs have been updated, but before\n\nI'd drop \"remote\" here -- from the point of view of git-receive-pack(1)\nit really is the local repository.\n\n> +the status report is sent back to the client.\n> +\n> +The hook receives the pkt-line encoded status report on standard input\n> +and its standard output replaces the report sent to the client. Any\n> +output written to standard error is forwarded to the client over the\n> +sideband channel and will appear as `remote:` lines on the client's\n> +terminal.\n\nThis assumes a bit too much about the implementation of the client, as\nit may not even be git-push(1) in the first place. We could still\nmention this, but we should say that this depends on the client.\n\n> To reject individual ref updates, rewrite the corresponding\n> +`ok` lines to `ng` lines in the output report (with an explanatory\n> +error string) and exit zero; standard error can accompany this to\n> +provide a human-readable explanation. A non-zero exit status causes\n> +`receive-pack` to die.\n\nWe should probably document that we expect the hook to never return\nnon-zero, even if it rejects reference updates, and that doing so\nindicates a bug. This is mostly because git-receive-pack(1) shouldn't\never just die on the client without giving it a proper status.\n\n> +Note that by the time this hook runs, all ref updates have already been\n> +applied to the repository. A non-zero exit causes the client to see the\n> +push as failed, but does *not* roll back any ref changes that were\n> +already committed server-side.\n\nGood thing to call out.\n\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index 86933d8d7e..bc22b3ec31 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n>  \treturn code;\n>  }\n>  \n> +static int run_report_hook(struct strbuf *report)\n> +{\n> +\tstruct child_process proc = CHILD_PROCESS_INIT;\n> +\tstruct async sideband_async;\n> +\tint sideband_async_started = 0;\n> +\tint saved_stderr = -1;\n> +\tstruct strbuf out = STRBUF_INIT;\n> +\tconst char *hook_path;\n> +\tint code;\n\nNit: I think it's more commont to call this `ret` rather than `code`.\n\n> diff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\n> new file mode 100755\n> index 0000000000..47f20e8d67\n> --- /dev/null\n> +++ b/t/t5412-report-hook.sh\n> @@ -0,0 +1,176 @@\n> +#!/bin/sh\n> +\n> +test_description='test report hook'\n> +\n> +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n> +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n> +\n> +. ./test-lib.sh\n> +\n> +. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n> +\n> +URL_PREFIX=\"\\.\\.\"\n\nI was about to say that this looks unused, but it's used by\n\"common-functions.sh\".\n\n[snip]\n> +test_expect_success \"hook stderr is relayed to client via sideband\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n> +\n> +\tgit init --bare upstream &&\n> +\tgit -C workbench remote add origin ../upstream &&\n> +\tgit -C workbench push origin $A:refs/heads/main &&\n> +\n> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n> +\techo \"hook-stderr-message\" >&2\n> +\texit 1\n\nShould we maybe not exit abnormally here to see that the push succeeds?\n\n> +\tEOF\n> +\n> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n> +\ttest_grep \"hook-stderr-message\" out\n> +'\n\nThis should have the \"remote: \" prefix, right? If so, should we verify\nthat?\n\nPatrick\n"},{"id":"550802","messageId":"CAOLa=ZSN+h4TkZrqPPRNZ58Pyfamv9_tM=m7W8_RYhUU0p0q0w@mail.gmail.com","threadId":"66186","inReplyTo":"7dc975d2-324b-46a4-a389-9af96f4d5d57@app.fastmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-19T12:11:19Z","receivedAt":"2026-08-19T12:11:21Z","isPatch":true,"body":"\"Kristoffer Haugsbakk\" <kristofferhaugsbakk@fastmail.com> writes:\n\n> On Tue, Aug 18, 2026, at 09:55, Karthik Nayak wrote:\n>> When running 'git-receive-pack(1)', there is currently no way for the\n>> server to intercept and modify the status report before it is sent back\n>> to the client. This is useful for servers with custom logic that need\n>> to transform or gate the report based on the outcome of external logic\n>> post reference updates.\n>>\n>> Introduce a new 'report' hook which receives the pkt-line encoded\n>> status report on stdin and whose stdout replaces the report sent to the\n>> client. A non-zero exit status causes `receive-pack` to die and the\n>> client to treat the push as failed.\n>>\n>> Similar to the 'proc-receive' hook, this does not use the config-based\n>> hook infrastructure. That infrastructure is designed for parallelizable\n>> notification hooks. As this hook is a bidirectional filter, it would\n>> require significant modifications to that infrastructure and this hook\n>> cannot be parallelized anyway.\n>>\n>> Signed-off-by: Karthik Nayak <karthik.188@gmail.com>\n>> ---\n>> To give some context, we at GitLab are building a custom MVCC around\n>> Git. Each git-push would initialize a new version which is then\n>> committed as the default post some operations. These operations take\n>> place after the reference transaction and based on the output status of\n>> those operations, we want to propagate the status to the user. There\n>> currently exists no good mechanism to do so.\n>>\n>> Having a report hook which allows us to modify the report being\n>> propagated to the user, allows us to modify the report based on the\n>> status of our MVCC commit phase.\n>\n> Personally I think understanding concrete things is easier than\n> understanding general things. And discussing the concrete case in the\n> commit message would help with that as well as provide the context for\n> git-log(1) rather than just the people who have read these emails.\n>\n\nI was conflicted about it, since while it does provide some context, it\ndoesn't apply to most usecases. I will add a little more context in the\ncommit message.\n\n>> ---\n>>  Documentation/githooks.adoc |  23 ++++++\n>>  builtin/receive-pack.c      |  41 +++++++++++\n>>  t/meson.build               |   1 +\n>>  t/t5412-report-hook.sh      | 176 ++++++++++++++++++++++++++++++++++++++++++++\n>>  4 files changed, 241 insertions(+)\n>\n> Should the git-receive-pack(1) doc be updated to mention that this hook\n> exists? I don’t understand the setup here. The existing\n> git-receive-pack(1) doc has sections for these hooks:\n>\n> • `update`\n> • `pre-receive`\n> • `post-receive`\n> • `post-update`\n>\n> But not these:\n>\n> • `push-to-checkout`\n> • `proc-receive`\n>\n> (referenced against githooks(5))\n>\n\nI didn't know about this. I wonder why we have two sources of truth for\nthe same. As you see, it's already starting to diverge.\n\nI will add both of them with links to githooks(5), but perhaps a cleanup\nthere is in order. I would say making githooks(5) the canonical location\nwith git-receive-pack(1) referencing it makes sense.\n\n>>\n>> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n>> index ed045940d1..7e6643ad89 100644\n>> --- a/Documentation/githooks.adoc\n>> +++ b/Documentation/githooks.adoc\n>> @@ -527,6 +527,29 @@ The exit status of the hook is ignored for any\n>> state except for the\n>>  status will cause the transaction to be aborted. The hook will not be\n>>  called with \"aborted\" state in that case.\n>>\n>> +report\n>> +~~~~~~\n>> +\n>> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n>> +`git push` and updates reference(s) in its repository. It executes on\n>> +the remote repository once after all refs have been updated, but before\n>> +the status report is sent back to the client.\n>> +\n>> +The hook receives the pkt-line encoded status report on standard input\n>\n> Another naive question (I have never used any of this). Should this link\n> to some gitprotocol-X(5) after `pkt-line` in order to have a link that\n> explains what it is? I don’t see any mention of `pkt-line` on\n> git-receive-pack(1) or a mention of a gitprotocol-X(5).\n>\n\nWe could link to 'Documentation/gitprotocol-common.adoc', but I'm not\nsure if it is erring on the side of being too verbose. I'll leave it out\nsince its already existing and assumed to be common knowledge for users\nof such hooks. But happy to add it in if others disagree :)\n\n>> +and its standard output replaces the report sent to the client. Any\n>> +output written to standard error is forwarded to the client over the\n>> +sideband channel and will appear as `remote:` lines on the client's\n>> +terminal. To reject individual ref updates, rewrite the corresponding\n>> +`ok` lines to `ng` lines in the output report (with an explanatory\n>> +error string) and exit zero; standard error can accompany this to\n>> +provide a human-readable explanation. A non-zero exit status causes\n>> +`receive-pack` to die.\n>> +\n>> +Note that by the time this hook runs, all ref updates have already been\n>> +applied to the repository. A non-zero exit causes the client to see the\n>> +push as failed, but does *not* roll back any ref changes that were\n>> +already committed server-side.\n>\n> To my naive eyes this description looks good and without any obvious\n> errors (typos ;) ).\n>\n\nThanks for reading through\n\n>> +\n>>  push-to-checkout\n>>  ~~~~~~~~~~~~~~~~\n>>\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>>[snip]\n>> @@ -2592,6 +2630,9 @@ static void report_v2(struct command *commands,\n>> const char *unpack_status)\n>>  \t}\n>>  \tpacket_buf_flush(&buf);\n>>\n>> +\tif (run_report_hook(&buf))\n>> +\t\tdie(\"report hook failed\");\n>\n> Okay, it seems typical for this command to use regular strings (not\n> translated) for errors. Which makes sense given the application. There\n> does seem to be translated error strings but one example is “refusing to\n> update current branch”, which seems to be more of a non-bare, end-user\n> error than a server error.\n>\n\nYeah, since these are generally less user-facing (I say less because\nthis can be propagated to the user, if the hook exists with a non-zero\nerror code) I choose not to translate it. As you mentioned, this seems\nto be the way for such error messages.\n\n>> +\n>>  \tif (use_sideband)\n>>  \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>>  \telse\n>>[snip]\n>> diff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\n>>[snip]\n>> +test_expect_success \"no report hook, push succeeds\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>\n> This teardown routine is common to all the tests. Is it better style\n> here to write it out compared to using a helper function (test code is\n> different from “normal” code)?\n>\n\nSince tests are self-contained, I usually keep the teardowns within\nthem if they're simple enough.\n\n>> +\tgit init --bare upstream &&\n>>[snip]\n"},{"id":"550815","messageId":"CAOLa=ZTtOJLXkfZ8jKpuA9REg5CP_xxD8+kDxPAYLeRz_xR1Wg@mail.gmail.com","threadId":"66186","inReplyTo":"aoVdlC7myRFenPfV@pks.im","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-19T13:13:50Z","receivedAt":"2026-08-19T13:13:54Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Tue, Aug 18, 2026 at 09:55:55AM +0200, Karthik Nayak wrote:\n>> When running 'git-receive-pack(1)', there is currently no way for the\n>> server to intercept and modify the status report before it is sent back\n>> to the client. This is useful for servers with custom logic that need\n>> to transform or gate the report based on the outcome of external logic\n>> post reference updates.\n>>\n>> Introduce a new 'report' hook which receives the pkt-line encoded\n>> status report on stdin and whose stdout replaces the report sent to the\n>> client. A non-zero exit status causes `receive-pack` to die and the\n>> client to treat the push as failed.\n>\n> I think it would have been useful to add context why none of the\n> preexisting hooks work for us:\n>\n>   - The pre-receive hook runs too early, as we haven't updated\n>     references at that point yet and we need to have the full view of\n>     all resulting updates (both objects and references).\n>\n>   - The update hook is too inefficient as it runs once per reference,\n>     and we cannot trivially determine the last update.\n>\n>   - The reference-transaction hook cannot be used by us because we care\n>     about the phase where it was committed already. And while the hook\n>     fires in that phase, it does not allow the caller to modify the\n>     result in any capacity.\n>\n>   - The post-receive and post-update hooks cannot be used as they run\n>     too late, at the point where we have already reported success to the\n>     client.\n>\n\nYeah, this is worthwhile mentioning, I already have made the commit\nmessage a lot more descriptive, so it does become bloated. I think it is\njustified though, since more information is always more useful than less.\n\n>> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n>> index ed045940d1..7e6643ad89 100644\n>> --- a/Documentation/githooks.adoc\n>> +++ b/Documentation/githooks.adoc\n>> @@ -527,6 +527,29 @@ The exit status of the hook is ignored for any state except for the\n>>  status will cause the transaction to be aborted. The hook will not be\n>>  called with \"aborted\" state in that case.\n>>\n>> +report\n>> +~~~~~~\n>> +\n>> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n>> +`git push` and updates reference(s) in its repository. It executes on\n>> +the remote repository once after all refs have been updated, but before\n>\n> I'd drop \"remote\" here -- from the point of view of git-receive-pack(1)\n> it really is the local repository.\n>\n\nYeah, makes sense.\n\n>\n>> +the status report is sent back to the client.\n>> +\n>> +The hook receives the pkt-line encoded status report on standard input\n>> +and its standard output replaces the report sent to the client. Any\n>> +output written to standard error is forwarded to the client over the\n>> +sideband channel and will appear as `remote:` lines on the client's\n>> +terminal.\n>\n> This assumes a bit too much about the implementation of the client, as\n> it may not even be git-push(1) in the first place. We could still\n> mention this, but we should say that this depends on the client.\n>\n\nYeah I'll make it specific to git-push(1).\n\n>> To reject individual ref updates, rewrite the corresponding\n>> +`ok` lines to `ng` lines in the output report (with an explanatory\n>> +error string) and exit zero; standard error can accompany this to\n>> +provide a human-readable explanation. A non-zero exit status causes\n>> +`receive-pack` to die.\n>\n> We should probably document that we expect the hook to never return\n> non-zero, even if it rejects reference updates, and that doing so\n> indicates a bug. This is mostly because git-receive-pack(1) shouldn't\n> ever just die on the client without giving it a proper status.\n>\n\nYeah, this is a part I was thinking about but wasn't sure if it should\nbe added in because, we could also do an implementation where we simply\nignore the exit code of the hook.\n\n>> +Note that by the time this hook runs, all ref updates have already been\n>> +applied to the repository. A non-zero exit causes the client to see the\n>> +push as failed, but does *not* roll back any ref changes that were\n>> +already committed server-side.\n>\n> Good thing to call out.\n>\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>> index 86933d8d7e..bc22b3ec31 100644\n>> --- a/builtin/receive-pack.c\n>> +++ b/builtin/receive-pack.c\n>> @@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n>>  \treturn code;\n>>  }\n>>\n>> +static int run_report_hook(struct strbuf *report)\n>> +{\n>> +\tstruct child_process proc = CHILD_PROCESS_INIT;\n>> +\tstruct async sideband_async;\n>> +\tint sideband_async_started = 0;\n>> +\tint saved_stderr = -1;\n>> +\tstruct strbuf out = STRBUF_INIT;\n>> +\tconst char *hook_path;\n>> +\tint code;\n>\n> Nit: I think it's more commont to call this `ret` rather than `code`.\n>\n\nI copied over another hook to start, and left the naming as is. I'm okay\nwith changing it to ret.\n\n>> diff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\n>> new file mode 100755\n>> index 0000000000..47f20e8d67\n>> --- /dev/null\n>> +++ b/t/t5412-report-hook.sh\n>> @@ -0,0 +1,176 @@\n>> +#!/bin/sh\n>> +\n>> +test_description='test report hook'\n>> +\n>> +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n>> +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n>> +\n>> +. ./test-lib.sh\n>> +\n>> +. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n>> +\n>> +URL_PREFIX=\"\\.\\.\"\n>\n> I was about to say that this looks unused, but it's used by\n> \"common-functions.sh\".\n>\n\nI didn't know about this too before.\n\n> [snip]\n>> +test_expect_success \"hook stderr is relayed to client via sideband\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>> +\n>> +\tgit init --bare upstream &&\n>> +\tgit -C workbench remote add origin ../upstream &&\n>> +\tgit -C workbench push origin $A:refs/heads/main &&\n>> +\n>> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n>> +\techo \"hook-stderr-message\" >&2\n>> +\texit 1\n>\n> Should we maybe not exit abnormally here to see that the push succeeds?\n>\n\nThe test right above 'hook can report a custom failure message', does\nthat exactly.\n\n>> +\tEOF\n>> +\n>> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n>> +\ttest_grep \"hook-stderr-message\" out\n>> +'\n>\n> This should have the \"remote: \" prefix, right? If so, should we verify\n> that?\n>\n\nYeah makes sense. let me add that.\n\nThanks for the review.\n\n\n> Patrick\n"},{"id":"550820","messageId":"aoWtgz8wWsb3v6du@pks.im","threadId":"66186","inReplyTo":"CAOLa=ZTtOJLXkfZ8jKpuA9REg5CP_xxD8+kDxPAYLeRz_xR1Wg@mail.gmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-08-19T13:20:03Z","receivedAt":"2026-08-19T13:20:11Z","isPatch":true,"body":"On Wed, Aug 19, 2026 at 03:13:50PM +0200, Karthik Nayak wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> \n> > On Tue, Aug 18, 2026 at 09:55:55AM +0200, Karthik Nayak wrote:\n> >> When running 'git-receive-pack(1)', there is currently no way for the\n> >> server to intercept and modify the status report before it is sent back\n> >> to the client. This is useful for servers with custom logic that need\n> >> to transform or gate the report based on the outcome of external logic\n> >> post reference updates.\n> >>\n> >> Introduce a new 'report' hook which receives the pkt-line encoded\n> >> status report on stdin and whose stdout replaces the report sent to the\n> >> client. A non-zero exit status causes `receive-pack` to die and the\n> >> client to treat the push as failed.\n> >\n> > I think it would have been useful to add context why none of the\n> > preexisting hooks work for us:\n> >\n> >   - The pre-receive hook runs too early, as we haven't updated\n> >     references at that point yet and we need to have the full view of\n> >     all resulting updates (both objects and references).\n> >\n> >   - The update hook is too inefficient as it runs once per reference,\n> >     and we cannot trivially determine the last update.\n> >\n> >   - The reference-transaction hook cannot be used by us because we care\n> >     about the phase where it was committed already. And while the hook\n> >     fires in that phase, it does not allow the caller to modify the\n> >     result in any capacity.\n> >\n> >   - The post-receive and post-update hooks cannot be used as they run\n> >     too late, at the point where we have already reported success to the\n> >     client.\n> >\n> \n> Yeah, this is worthwhile mentioning, I already have made the commit\n> message a lot more descriptive, so it does become bloated. I think it is\n> justified though, since more information is always more useful than less.\n\nWell. Until it isn't anymore :) Just look at the walls of text that AI\nis prone to generate, where one is essentially drowning in information.\nAnd it's the worst kind of information, too: plausibly looking but\ninherently dubious.\n\nAnyway, I digress. I think in this context it's good to have the context\nindeed, and I trust your information more than the one generated by AI.\n\n> >> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> >> index ed045940d1..7e6643ad89 100644\n> >> --- a/Documentation/githooks.adoc\n> >> +++ b/Documentation/githooks.adoc\n> >> @@ -527,6 +527,29 @@ The exit status of the hook is ignored for any state except for the\n> >> To reject individual ref updates, rewrite the corresponding\n> >> +`ok` lines to `ng` lines in the output report (with an explanatory\n> >> +error string) and exit zero; standard error can accompany this to\n> >> +provide a human-readable explanation. A non-zero exit status causes\n> >> +`receive-pack` to die.\n> >\n> > We should probably document that we expect the hook to never return\n> > non-zero, even if it rejects reference updates, and that doing so\n> > indicates a bug. This is mostly because git-receive-pack(1) shouldn't\n> > ever just die on the client without giving it a proper status.\n> >\n> \n> Yeah, this is a part I was thinking about but wasn't sure if it should\n> be added in because, we could also do an implementation where we simply\n> ignore the exit code of the hook.\n\nThere could be cases where just making the whole operation explode is\nthe only remaining option. So I don't think it's necessarily bad to have\nit as the nuclear option.\n\nPatrick\n"},{"id":"550821","messageId":"CAOLa=ZSuzd3FozharJ_1LRgaQdpY+eRZnMOJYoLJNvPXiait1w@mail.gmail.com","threadId":"66186","inReplyTo":"aoWtgz8wWsb3v6du@pks.im","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-19T13:24:21Z","receivedAt":"2026-08-19T13:24:24Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Wed, Aug 19, 2026 at 03:13:50PM +0200, Karthik Nayak wrote:\n>> Patrick Steinhardt <ps@pks.im> writes:\n>>\n>> > On Tue, Aug 18, 2026 at 09:55:55AM +0200, Karthik Nayak wrote:\n>> >> When running 'git-receive-pack(1)', there is currently no way for the\n>> >> server to intercept and modify the status report before it is sent back\n>> >> to the client. This is useful for servers with custom logic that need\n>> >> to transform or gate the report based on the outcome of external logic\n>> >> post reference updates.\n>> >>\n>> >> Introduce a new 'report' hook which receives the pkt-line encoded\n>> >> status report on stdin and whose stdout replaces the report sent to the\n>> >> client. A non-zero exit status causes `receive-pack` to die and the\n>> >> client to treat the push as failed.\n>> >\n>> > I think it would have been useful to add context why none of the\n>> > preexisting hooks work for us:\n>> >\n>> >   - The pre-receive hook runs too early, as we haven't updated\n>> >     references at that point yet and we need to have the full view of\n>> >     all resulting updates (both objects and references).\n>> >\n>> >   - The update hook is too inefficient as it runs once per reference,\n>> >     and we cannot trivially determine the last update.\n>> >\n>> >   - The reference-transaction hook cannot be used by us because we care\n>> >     about the phase where it was committed already. And while the hook\n>> >     fires in that phase, it does not allow the caller to modify the\n>> >     result in any capacity.\n>> >\n>> >   - The post-receive and post-update hooks cannot be used as they run\n>> >     too late, at the point where we have already reported success to the\n>> >     client.\n>> >\n>>\n>> Yeah, this is worthwhile mentioning, I already have made the commit\n>> message a lot more descriptive, so it does become bloated. I think it is\n>> justified though, since more information is always more useful than less.\n>\n> Well. Until it isn't anymore :) Just look at the walls of text that AI\n> is prone to generate, where one is essentially drowning in information.\n> And it's the worst kind of information, too: plausibly looking but\n> inherently dubious.\n>\n> Anyway, I digress. I think in this context it's good to have the context\n> indeed, and I trust your information more than the one generated by AI.\n>\n\nI have been using AI to correct my grammar :D\n\nPoint taken, I'll try to find a balance.\n\n>> >> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n>> >> index ed045940d1..7e6643ad89 100644\n>> >> --- a/Documentation/githooks.adoc\n>> >> +++ b/Documentation/githooks.adoc\n>> >> @@ -527,6 +527,29 @@ The exit status of the hook is ignored for any state except for the\n>> >> To reject individual ref updates, rewrite the corresponding\n>> >> +`ok` lines to `ng` lines in the output report (with an explanatory\n>> >> +error string) and exit zero; standard error can accompany this to\n>> >> +provide a human-readable explanation. A non-zero exit status causes\n>> >> +`receive-pack` to die.\n>> >\n>> > We should probably document that we expect the hook to never return\n>> > non-zero, even if it rejects reference updates, and that doing so\n>> > indicates a bug. This is mostly because git-receive-pack(1) shouldn't\n>> > ever just die on the client without giving it a proper status.\n>> >\n>>\n>> Yeah, this is a part I was thinking about but wasn't sure if it should\n>> be added in because, we could also do an implementation where we simply\n>> ignore the exit code of the hook.\n>\n> There could be cases where just making the whole operation explode is\n> the only remaining option. So I don't think it's necessarily bad to have\n> it as the nuclear option.\n>\n> Patrick\n\nOkay let's settle on that.\n"},{"id":"550822","messageId":"b81a56f8-4bd7-4b48-bac0-f60bb863e5bf@app.fastmail.com","threadId":"66186","inReplyTo":"CAOLa=ZSN+h4TkZrqPPRNZ58Pyfamv9_tM=m7W8_RYhUU0p0q0w@mail.gmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-08-19T14:47:46Z","receivedAt":"2026-08-19T14:48:12Z","isPatch":true,"body":"On Wed, Aug 19, 2026, at 14:11, Karthik Nayak wrote:\n>[snip]\n> I will add both of them with links to githooks(5), but perhaps a cleanup\n> there is in order. I would say making githooks(5) the canonical location\n> with git-receive-pack(1) referencing it makes sense.\n\nSounds excellent—thanks.\n\n>>>[snip]\n"},{"id":"550883","messageId":"29f39d8b-6cf0-4811-afb3-0a1656877f31@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-20T09:50:06Z","receivedAt":"2026-08-20T09:50:12Z","isPatch":true,"body":"Hi Karthik\n\nOn 18/08/2026 08:55, Karthik Nayak wrote:\n> \n> Similar to the 'proc-receive' hook, this does not use the config-based\n> hook infrastructure. That infrastructure is designed for parallelizable\n> notification hooks. As this hook is a bidirectional filter, it would\n> require significant modifications to that infrastructure and this hook\n> cannot be parallelized anyway.\n\nConfig based hooks are about running more than one script to run per \nhook event, they're not about parallel execution per-se. Indeed the \ndocumentation for git hook notes\n\n     Some hooks always run sequentially regardless of this flag or the\n     hook.jobs config, because Git knows they cannot safely run in\n     parallel: applypatch-msg, pre-commit, prepare-commit-msg, commit-\n     msg, post-commit, post-checkout, and push-to-checkout.\n\nI think the question the commit message should be answering is, whether \na design like proc-receive that predates config based hooks and only \nallows a single hook script, makes sense now that we have config based \nhooks, or, if we were adding that functionality now, would we design it \ndifferently? I think the answer for server side hooks is that a design \naround a single script is probably reasonable but it would be worth \ndiscussing that in the commit message.\n\nThanks\n\nPhillip\n\n\n\n> Signed-off-by: Karthik Nayak <karthik.188@gmail.com>\n> ---\n> To give some context, we at GitLab are building a custom MVCC around\n> Git. Each git-push would initialize a new version which is then\n> committed as the default post some operations. These operations take\n> place after the reference transaction and based on the output status of\n> those operations, we want to propagate the status to the user. There\n> currently exists no good mechanism to do so.\n> \n> Having a report hook which allows us to modify the report being\n> propagated to the user, allows us to modify the report based on the\n> status of our MVCC commit phase.\n> ---\n>   Documentation/githooks.adoc |  23 ++++++\n>   builtin/receive-pack.c      |  41 +++++++++++\n>   t/meson.build               |   1 +\n>   t/t5412-report-hook.sh      | 176 ++++++++++++++++++++++++++++++++++++++++++++\n>   4 files changed, 241 insertions(+)\n> \n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index ed045940d1..7e6643ad89 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -527,6 +527,29 @@ The exit status of the hook is ignored for any state except for the\n>   status will cause the transaction to be aborted. The hook will not be\n>   called with \"aborted\" state in that case.\n>   \n> +report\n> +~~~~~~\n> +\n> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n> +`git push` and updates reference(s) in its repository. It executes on\n> +the remote repository once after all refs have been updated, but before\n> +the status report is sent back to the client.\n> +\n> +The hook receives the pkt-line encoded status report on standard input\n> +and its standard output replaces the report sent to the client. Any\n> +output written to standard error is forwarded to the client over the\n> +sideband channel and will appear as `remote:` lines on the client's\n> +terminal. To reject individual ref updates, rewrite the corresponding\n> +`ok` lines to `ng` lines in the output report (with an explanatory\n> +error string) and exit zero; standard error can accompany this to\n> +provide a human-readable explanation. A non-zero exit status causes\n> +`receive-pack` to die.\n> +\n> +Note that by the time this hook runs, all ref updates have already been\n> +applied to the repository. A non-zero exit causes the client to see the\n> +push as failed, but does *not* roll back any ref changes that were\n> +already committed server-side.\n> +\n>   push-to-checkout\n>   ~~~~~~~~~~~~~~~~\n>   \n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index 86933d8d7e..bc22b3ec31 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n>   \treturn code;\n>   }\n>   \n> +static int run_report_hook(struct strbuf *report)\n> +{\n> +\tstruct child_process proc = CHILD_PROCESS_INIT;\n> +\tstruct async sideband_async;\n> +\tint sideband_async_started = 0;\n> +\tint saved_stderr = -1;\n> +\tstruct strbuf out = STRBUF_INIT;\n> +\tconst char *hook_path;\n> +\tint code;\n> +\n> +\thook_path = find_hook(the_repository, \"report\");\n> +\tif (!hook_path)\n> +\t\treturn 0;\n> +\n> +\tstrvec_push(&proc.args, hook_path);\n> +\tproc.trace2_hook_name = \"report\";\n> +\n> +\tprepare_sideband_async(&sideband_async, &saved_stderr,\n> +\t\t\t       &sideband_async_started);\n> +\n> +\tsigchain_push(SIGPIPE, SIG_IGN);\n> +\tcode = pipe_command(&proc, report->buf, report->len, &out,\n> +\t\t\t    report->len, NULL, 0);\n> +\tsigchain_pop(SIGPIPE);\n> +\n> +\tfinish_sideband_async(&sideband_async, saved_stderr,\n> +\t\t\t      sideband_async_started);\n> +\n> +\tif (!code)\n> +\t\tstrbuf_swap(&out, report);\n> +\n> +\tstrbuf_release(&out);\n> +\treturn code;\n> +}\n> +\n>   static struct command *find_command_by_refname(struct command *list,\n>   \t\t\t\t\t       const char *refname)\n>   {\n> @@ -2547,6 +2582,9 @@ static void report(struct command *commands, const char *unpack_status)\n>   \t}\n>   \tpacket_buf_flush(&buf);\n>   \n> +\tif (run_report_hook(&buf))\n> +\t\tdie(\"report hook failed\");\n> +\n>   \tif (use_sideband)\n>   \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>   \telse\n> @@ -2592,6 +2630,9 @@ static void report_v2(struct command *commands, const char *unpack_status)\n>   \t}\n>   \tpacket_buf_flush(&buf);\n>   \n> +\tif (run_report_hook(&buf))\n> +\t\tdie(\"report hook failed\");\n> +\n>   \tif (use_sideband)\n>   \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>   \telse\n> diff --git a/t/meson.build b/t/meson.build\n> index a25f37d2f5..7056e31326 100644\n> --- a/t/meson.build\n> +++ b/t/meson.build\n> @@ -651,6 +651,7 @@ integration_tests = [\n>     't5409-colorize-remote-messages.sh',\n>     't5410-receive-pack.sh',\n>     't5411-proc-receive-hook.sh',\n> +  't5412-report-hook.sh',\n>     't5500-fetch-pack.sh',\n>     't5501-fetch-push-alternates.sh',\n>     't5502-quickfetch.sh',\n> diff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\n> new file mode 100755\n> index 0000000000..47f20e8d67\n> --- /dev/null\n> +++ b/t/t5412-report-hook.sh\n> @@ -0,0 +1,176 @@\n> +#!/bin/sh\n> +\n> +test_description='test report hook'\n> +\n> +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n> +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n> +\n> +. ./test-lib.sh\n> +\n> +. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n> +\n> +URL_PREFIX=\"\\.\\.\"\n> +\n> +test_expect_success \"setup workbench\" '\n> +\tgit init workbench &&\n> +\tcreate_commits_in workbench A B\n> +'\n> +\n> +test_expect_success \"no report hook, push succeeds\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n> +\tgit init --bare upstream &&\n> +\n> +\tgit -C workbench remote add origin ../upstream &&\n> +\tgit -C workbench push origin $A:refs/heads/main &&\n> +\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n> +\n> +\tmake_user_friendly_and_stable_output <out >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tTo ../upstream\n> +\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success \"passthrough does not alter report\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n> +\tgit init --bare upstream &&\n> +\n> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n> +\tcat\n> +\tEOF\n> +\n> +\tgit -C workbench remote add origin ../upstream &&\n> +\tgit -C workbench push origin $A:refs/heads/main &&\n> +\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n> +\n> +\tmake_user_friendly_and_stable_output <out >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tTo ../upstream\n> +\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success \"non-zero exit causes receive-pack to die\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n> +\n> +\tgit init --bare upstream &&\n> +\tgit -C workbench remote add origin ../upstream &&\n> +\tgit -C workbench push origin $A:refs/heads/main &&\n> +\n> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n> +\texit 1\n> +\tEOF\n> +\n> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n> +\tmake_user_friendly_and_stable_output <out >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tfatal: report hook failed\n> +\tsend-pack: unexpected disconnect while reading sideband packet\n> +\tfatal: the remote end hung up unexpectedly\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success \"hook is invoked and receives report on stdin\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n> +\n> +\tgit init --bare upstream &&\n> +\ttest_hook -C upstream --setup report <<-EOF &&\n> +\ttee raw\n> +\tEOF\n> +\n> +\tgit -C workbench remote add origin ../upstream &&\n> +\tgit -C workbench push origin $A:refs/heads/main &&\n> +\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n> +\n> +\tmake_user_friendly_and_stable_output <out >actual &&\n> +\tcat >expect <<-EOF &&\n> +\tTo ../upstream\n> +\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n> +\tEOF\n> +\ttest_cmp expect actual &&\n> +\n> +\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n> +\tcat >expect-report <<-EOF &&\n> +\tunpack ok\n> +\tok refs/heads/main\n> +\t0000\n> +\tEOF\n> +\ttest_cmp expect-report actual-report\n> +'\n> +\n> +test_expect_success \"hook can modify the report sent to client\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n> +\n> +\tgit init --bare upstream &&\n> +\tgit -C workbench remote add origin ../upstream &&\n> +\tgit -C workbench push origin $A:refs/heads/main &&\n> +\n> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n> +\ttest-tool pkt-line unpack |\n> +\tsed \"s/^ok /ng /\" |\n> +\ttest-tool pkt-line pack\n> +\tEOF\n> +\n> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n> +\tmake_user_friendly_and_stable_output <out >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tTo ../upstream\n> +\t ! [remote rejected] <COMMIT-B> -> main (failed)\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success \"hook can report a custom failure message\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n> +\n> +\tgit init --bare upstream &&\n> +\tgit -C workbench remote add origin ../upstream &&\n> +\tgit -C workbench push origin $A:refs/heads/main &&\n> +\n> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n> +\techo \"push rejected: service X is down\" >&2\n> +\ttest-tool pkt-line unpack |\n> +\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n> +\ttest-tool pkt-line pack |\n> +\ttee raw\n> +\tEOF\n> +\n> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n> +\ttest_grep \"push rejected: service X is down\" out &&\n> +\n> +\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n> +\tcat >expect-report <<-\\EOF &&\n> +\tunpack ok\n> +\tng refs/heads/main service-x-is-down\n> +\t0000\n> +\tEOF\n> +\ttest_cmp expect-report actual-report\n> +'\n> +\n> +test_expect_success \"hook stderr is relayed to client via sideband\" '\n> +\ttest_when_finished \"rm -rf upstream\" &&\n> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n> +\n> +\tgit init --bare upstream &&\n> +\tgit -C workbench remote add origin ../upstream &&\n> +\tgit -C workbench push origin $A:refs/heads/main &&\n> +\n> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n> +\techo \"hook-stderr-message\" >&2\n> +\texit 1\n> +\tEOF\n> +\n> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n> +\ttest_grep \"hook-stderr-message\" out\n> +'\n> +\n> +test_done\n> \n> ---\n> base-commit: 11c6700f10234578d10523faf35656ca491425c9\n> change-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n> \n> \n> Thanks\n> - Karthik\n> \n> \n\n"},{"id":"550915","messageId":"xmqqwltksspi.fsf@gitster.g","threadId":"66186","inReplyTo":"29f39d8b-6cf0-4811-afb3-0a1656877f31@gmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-20T15:43:37Z","receivedAt":"2026-08-20T15:43:41Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> I think the question the commit message should be answering is, whether \n> a design like proc-receive that predates config based hooks and only \n> allows a single hook script, makes sense now that we have config based \n> hooks, or, if we were adding that functionality now, would we design it \n> differently?\n\nYeah, I think it is a reasonable way to frame the problem.\n\n> I think the answer for server side hooks is that a design \n> around a single script is probably reasonable but it would be worth \n> discussing that in the commit message.\n\nHmph, I am not sure what the hook being on the server side has to\ndo with the design decision to accept the limitation of allowing\nonly one hook script.  It is not as if a single entity has tighter\ncontrol on the server than on the end-user desktop repository,\nmaking it easier to live with such a limitation on the server side.\n\n\n\n\n"},{"id":"551014","messageId":"CAOLa=ZSH_YEuARVEXTHVfdGFoEP1kbL2d4nYvph3KQx+v-jPTw@mail.gmail.com","threadId":"66186","inReplyTo":"29f39d8b-6cf0-4811-afb3-0a1656877f31@gmail.com","subject":"Re: [PATCH] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-21T12:51:07Z","receivedAt":"2026-08-21T12:51:09Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> Hi Karthik\n>\n> On 18/08/2026 08:55, Karthik Nayak wrote:\n>>\n>> Similar to the 'proc-receive' hook, this does not use the config-based\n>> hook infrastructure. That infrastructure is designed for parallelizable\n>> notification hooks. As this hook is a bidirectional filter, it would\n>> require significant modifications to that infrastructure and this hook\n>> cannot be parallelized anyway.\n>\n> Config based hooks are about running more than one script to run per\n> hook event, they're not about parallel execution per-se. Indeed the\n> documentation for git hook notes\n>\n>      Some hooks always run sequentially regardless of this flag or the\n>      hook.jobs config, because Git knows they cannot safely run in\n>      parallel: applypatch-msg, pre-commit, prepare-commit-msg, commit-\n>      msg, post-commit, post-checkout, and push-to-checkout.\n>\n> I think the question the commit message should be answering is, whether\n> a design like proc-receive that predates config based hooks and only\n> allows a single hook script, makes sense now that we have config based\n> hooks, or, if we were adding that functionality now, would we design it\n> differently? I think the answer for server side hooks is that a design\n> around a single script is probably reasonable but it would be worth\n> discussing that in the commit message.\n>\n> Thanks\n>\n> Phillip\n>\n>\n\nThat's fair.\n\nI did spend some time trying to modify the config based infrastructure\nto work with bi-directional input/output so I could transfer both this\nhook and proc-receive to using it, but I couldn't find a good design\naround it and looked messy.\n\nI do agree with Junio that the condition for being single script is not\na client vs server argument, but rather its more of a one way\nnotification vs bi-directional input/output argument. We could pipe the\noutput of one hook as input to the other for this hook, but that\nwouldn't make sense for proc-receive.\n\n>\n>> Signed-off-by: Karthik Nayak <karthik.188@gmail.com>\n>> ---\n>> To give some context, we at GitLab are building a custom MVCC around\n>> Git. Each git-push would initialize a new version which is then\n>> committed as the default post some operations. These operations take\n>> place after the reference transaction and based on the output status of\n>> those operations, we want to propagate the status to the user. There\n>> currently exists no good mechanism to do so.\n>>\n>> Having a report hook which allows us to modify the report being\n>> propagated to the user, allows us to modify the report based on the\n>> status of our MVCC commit phase.\n>> ---\n>>   Documentation/githooks.adoc |  23 ++++++\n>>   builtin/receive-pack.c      |  41 +++++++++++\n>>   t/meson.build               |   1 +\n>>   t/t5412-report-hook.sh      | 176 ++++++++++++++++++++++++++++++++++++++++++++\n>>   4 files changed, 241 insertions(+)\n>>\n>> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n>> index ed045940d1..7e6643ad89 100644\n>> --- a/Documentation/githooks.adoc\n>> +++ b/Documentation/githooks.adoc\n>> @@ -527,6 +527,29 @@ The exit status of the hook is ignored for any state except for the\n>>   status will cause the transaction to be aborted. The hook will not be\n>>   called with \"aborted\" state in that case.\n>>\n>> +report\n>> +~~~~~~\n>> +\n>> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n>> +`git push` and updates reference(s) in its repository. It executes on\n>> +the remote repository once after all refs have been updated, but before\n>> +the status report is sent back to the client.\n>> +\n>> +The hook receives the pkt-line encoded status report on standard input\n>> +and its standard output replaces the report sent to the client. Any\n>> +output written to standard error is forwarded to the client over the\n>> +sideband channel and will appear as `remote:` lines on the client's\n>> +terminal. To reject individual ref updates, rewrite the corresponding\n>> +`ok` lines to `ng` lines in the output report (with an explanatory\n>> +error string) and exit zero; standard error can accompany this to\n>> +provide a human-readable explanation. A non-zero exit status causes\n>> +`receive-pack` to die.\n>> +\n>> +Note that by the time this hook runs, all ref updates have already been\n>> +applied to the repository. A non-zero exit causes the client to see the\n>> +push as failed, but does *not* roll back any ref changes that were\n>> +already committed server-side.\n>> +\n>>   push-to-checkout\n>>   ~~~~~~~~~~~~~~~~\n>>\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>> index 86933d8d7e..bc22b3ec31 100644\n>> --- a/builtin/receive-pack.c\n>> +++ b/builtin/receive-pack.c\n>> @@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n>>   \treturn code;\n>>   }\n>>\n>> +static int run_report_hook(struct strbuf *report)\n>> +{\n>> +\tstruct child_process proc = CHILD_PROCESS_INIT;\n>> +\tstruct async sideband_async;\n>> +\tint sideband_async_started = 0;\n>> +\tint saved_stderr = -1;\n>> +\tstruct strbuf out = STRBUF_INIT;\n>> +\tconst char *hook_path;\n>> +\tint code;\n>> +\n>> +\thook_path = find_hook(the_repository, \"report\");\n>> +\tif (!hook_path)\n>> +\t\treturn 0;\n>> +\n>> +\tstrvec_push(&proc.args, hook_path);\n>> +\tproc.trace2_hook_name = \"report\";\n>> +\n>> +\tprepare_sideband_async(&sideband_async, &saved_stderr,\n>> +\t\t\t       &sideband_async_started);\n>> +\n>> +\tsigchain_push(SIGPIPE, SIG_IGN);\n>> +\tcode = pipe_command(&proc, report->buf, report->len, &out,\n>> +\t\t\t    report->len, NULL, 0);\n>> +\tsigchain_pop(SIGPIPE);\n>> +\n>> +\tfinish_sideband_async(&sideband_async, saved_stderr,\n>> +\t\t\t      sideband_async_started);\n>> +\n>> +\tif (!code)\n>> +\t\tstrbuf_swap(&out, report);\n>> +\n>> +\tstrbuf_release(&out);\n>> +\treturn code;\n>> +}\n>> +\n>>   static struct command *find_command_by_refname(struct command *list,\n>>   \t\t\t\t\t       const char *refname)\n>>   {\n>> @@ -2547,6 +2582,9 @@ static void report(struct command *commands, const char *unpack_status)\n>>   \t}\n>>   \tpacket_buf_flush(&buf);\n>>\n>> +\tif (run_report_hook(&buf))\n>> +\t\tdie(\"report hook failed\");\n>> +\n>>   \tif (use_sideband)\n>>   \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>>   \telse\n>> @@ -2592,6 +2630,9 @@ static void report_v2(struct command *commands, const char *unpack_status)\n>>   \t}\n>>   \tpacket_buf_flush(&buf);\n>>\n>> +\tif (run_report_hook(&buf))\n>> +\t\tdie(\"report hook failed\");\n>> +\n>>   \tif (use_sideband)\n>>   \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>>   \telse\n>> diff --git a/t/meson.build b/t/meson.build\n>> index a25f37d2f5..7056e31326 100644\n>> --- a/t/meson.build\n>> +++ b/t/meson.build\n>> @@ -651,6 +651,7 @@ integration_tests = [\n>>     't5409-colorize-remote-messages.sh',\n>>     't5410-receive-pack.sh',\n>>     't5411-proc-receive-hook.sh',\n>> +  't5412-report-hook.sh',\n>>     't5500-fetch-pack.sh',\n>>     't5501-fetch-push-alternates.sh',\n>>     't5502-quickfetch.sh',\n>> diff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\n>> new file mode 100755\n>> index 0000000000..47f20e8d67\n>> --- /dev/null\n>> +++ b/t/t5412-report-hook.sh\n>> @@ -0,0 +1,176 @@\n>> +#!/bin/sh\n>> +\n>> +test_description='test report hook'\n>> +\n>> +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n>> +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n>> +\n>> +. ./test-lib.sh\n>> +\n>> +. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n>> +\n>> +URL_PREFIX=\"\\.\\.\"\n>> +\n>> +test_expect_success \"setup workbench\" '\n>> +\tgit init workbench &&\n>> +\tcreate_commits_in workbench A B\n>> +'\n>> +\n>> +test_expect_success \"no report hook, push succeeds\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>> +\tgit init --bare upstream &&\n>> +\n>> +\tgit -C workbench remote add origin ../upstream &&\n>> +\tgit -C workbench push origin $A:refs/heads/main &&\n>> +\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n>> +\n>> +\tmake_user_friendly_and_stable_output <out >actual &&\n>> +\tcat >expect <<-\\EOF &&\n>> +\tTo ../upstream\n>> +\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n>> +\tEOF\n>> +\ttest_cmp expect actual\n>> +'\n>> +\n>> +test_expect_success \"passthrough does not alter report\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>> +\tgit init --bare upstream &&\n>> +\n>> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n>> +\tcat\n>> +\tEOF\n>> +\n>> +\tgit -C workbench remote add origin ../upstream &&\n>> +\tgit -C workbench push origin $A:refs/heads/main &&\n>> +\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n>> +\n>> +\tmake_user_friendly_and_stable_output <out >actual &&\n>> +\tcat >expect <<-\\EOF &&\n>> +\tTo ../upstream\n>> +\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n>> +\tEOF\n>> +\ttest_cmp expect actual\n>> +'\n>> +\n>> +test_expect_success \"non-zero exit causes receive-pack to die\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>> +\n>> +\tgit init --bare upstream &&\n>> +\tgit -C workbench remote add origin ../upstream &&\n>> +\tgit -C workbench push origin $A:refs/heads/main &&\n>> +\n>> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n>> +\texit 1\n>> +\tEOF\n>> +\n>> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n>> +\tmake_user_friendly_and_stable_output <out >actual &&\n>> +\tcat >expect <<-\\EOF &&\n>> +\tfatal: report hook failed\n>> +\tsend-pack: unexpected disconnect while reading sideband packet\n>> +\tfatal: the remote end hung up unexpectedly\n>> +\tEOF\n>> +\ttest_cmp expect actual\n>> +'\n>> +\n>> +test_expect_success \"hook is invoked and receives report on stdin\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>> +\n>> +\tgit init --bare upstream &&\n>> +\ttest_hook -C upstream --setup report <<-EOF &&\n>> +\ttee raw\n>> +\tEOF\n>> +\n>> +\tgit -C workbench remote add origin ../upstream &&\n>> +\tgit -C workbench push origin $A:refs/heads/main &&\n>> +\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n>> +\n>> +\tmake_user_friendly_and_stable_output <out >actual &&\n>> +\tcat >expect <<-EOF &&\n>> +\tTo ../upstream\n>> +\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n>> +\tEOF\n>> +\ttest_cmp expect actual &&\n>> +\n>> +\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n>> +\tcat >expect-report <<-EOF &&\n>> +\tunpack ok\n>> +\tok refs/heads/main\n>> +\t0000\n>> +\tEOF\n>> +\ttest_cmp expect-report actual-report\n>> +'\n>> +\n>> +test_expect_success \"hook can modify the report sent to client\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>> +\n>> +\tgit init --bare upstream &&\n>> +\tgit -C workbench remote add origin ../upstream &&\n>> +\tgit -C workbench push origin $A:refs/heads/main &&\n>> +\n>> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n>> +\ttest-tool pkt-line unpack |\n>> +\tsed \"s/^ok /ng /\" |\n>> +\ttest-tool pkt-line pack\n>> +\tEOF\n>> +\n>> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n>> +\tmake_user_friendly_and_stable_output <out >actual &&\n>> +\tcat >expect <<-\\EOF &&\n>> +\tTo ../upstream\n>> +\t ! [remote rejected] <COMMIT-B> -> main (failed)\n>> +\tEOF\n>> +\ttest_cmp expect actual\n>> +'\n>> +\n>> +test_expect_success \"hook can report a custom failure message\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>> +\n>> +\tgit init --bare upstream &&\n>> +\tgit -C workbench remote add origin ../upstream &&\n>> +\tgit -C workbench push origin $A:refs/heads/main &&\n>> +\n>> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n>> +\techo \"push rejected: service X is down\" >&2\n>> +\ttest-tool pkt-line unpack |\n>> +\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n>> +\ttest-tool pkt-line pack |\n>> +\ttee raw\n>> +\tEOF\n>> +\n>> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n>> +\ttest_grep \"push rejected: service X is down\" out &&\n>> +\n>> +\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n>> +\tcat >expect-report <<-\\EOF &&\n>> +\tunpack ok\n>> +\tng refs/heads/main service-x-is-down\n>> +\t0000\n>> +\tEOF\n>> +\ttest_cmp expect-report actual-report\n>> +'\n>> +\n>> +test_expect_success \"hook stderr is relayed to client via sideband\" '\n>> +\ttest_when_finished \"rm -rf upstream\" &&\n>> +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n>> +\n>> +\tgit init --bare upstream &&\n>> +\tgit -C workbench remote add origin ../upstream &&\n>> +\tgit -C workbench push origin $A:refs/heads/main &&\n>> +\n>> +\ttest_hook -C upstream --setup report <<-\\EOF &&\n>> +\techo \"hook-stderr-message\" >&2\n>> +\texit 1\n>> +\tEOF\n>> +\n>> +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n>> +\ttest_grep \"hook-stderr-message\" out\n>> +'\n>> +\n>> +test_done\n>>\n>> ---\n>> base-commit: 11c6700f10234578d10523faf35656ca491425c9\n>> change-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n>>\n>>\n>> Thanks\n>> - Karthik\n>>\n>>\n"},{"id":"551017","messageId":"20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v2] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-21T13:34:58Z","receivedAt":"2026-08-21T13:35:06Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook cannot be used by us because we care\n    about the phase where it was committed already. And while the hook\n    fires in that phase, it does not allow the caller to modify the\n    result in any capacity.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'report' hook. The hook receives the complete pkt-line\nencoded status report on standard input, after all ref updates have\nbeen applied to the repository by execute_commands() but before the\nreport is sent to the client. The report consists of an 'unpack ok'\nor 'unpack <error>' line, followed by one 'ok <refname>' or\n'ng <refname> <reason>' line per pushed ref, terminated by a flush\npacket.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviours depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack calls\n  die(), and no report is sent to the client at all. The client\n  observes a sideband disconnect and reports 'the remote end hung up\n  unexpectedly', treating the entire push as failed.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nNote that in either failure mode, ref updates already applied by\nexecute_commands() are not rolled back. The hook can cause the client\nto perceive the push as failed, but cannot undo server-side changes.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n---\n Documentation/git-receive-pack.adoc |  15 +++\n Documentation/githooks.adoc         |  51 +++++++++\n builtin/receive-pack.c              |  41 ++++++++\n t/meson.build                       |   1 +\n t/t5412-report-hook.sh              | 201 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 309 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..e6cc0acaaf 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,21 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by 'git-receive-pack' when it processes push\n+requests. It handles refs whose names match the patterns defined by\n+`receive.procReceiveRefs` and executes the actual ref updates. See\n+linkgit:githooks[5] for the full protocol description.\n+\n+REPORT HOOK\n+-----------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client. Allowing the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n \n QUARANTINE ENVIRONMENT\n ----------------------\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..06c9e4b017 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,57 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+report\n+~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after\n+`execute_commands()` has applied all accepted ref changes to the\n+repository, but before the pkt-line encoded status report is sent back\n+to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input. The report begins with an `unpack` line indicating\n+whether the object transfer succeeded (`unpack ok` or\n+`unpack <error>`), followed by one `ok <refname>` or\n+`ng <refname> <reason>` line per ref that was pushed, and is\n+terminated by a flush packet.\n+\n+The hook's standard output entirely replaces the report that is sent\n+to the client. The hook must write a valid pkt-line encoded report in\n+the same format it received. The hook's stdout is fully buffered by\n+`receive-pack` before any data is sent to the client, so the hook's\n+exit status is known before the client receives anything.\n+\n+There are two distinct ways the hook can affect the push outcome:\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname> <reason>` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  calls `die()`, and no report is sent to the client at all. The client\n+  observes an unexpected sideband disconnect, making the entire push\n+  appear to have failed. In general, the hook should never exit with a\n+  non-zero status code and doing so would indicate a bug.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 86933d8d7e..9a0905f67e 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2547,6 +2582,9 @@ static void report(struct command *commands, const char *unpack_status)\n \t}\n \tpacket_buf_flush(&buf);\n \n+\tif (run_report_hook(&buf))\n+\t\tdie(\"report hook failed\");\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\n@@ -2592,6 +2630,9 @@ static void report_v2(struct command *commands, const char *unpack_status)\n \t}\n \tpacket_buf_flush(&buf);\n \n+\tif (run_report_hook(&buf))\n+\t\tdie(\"report hook failed\");\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex a25f37d2f5..7056e31326 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -651,6 +651,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\nnew file mode 100755\nindex 0000000000..62e5174c58\n--- /dev/null\n+++ b/t/t5412-report-hook.sh\n@@ -0,0 +1,201 @@\n+#!/bin/sh\n+\n+test_description='test report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit causes receive-pack to die\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tfatal: report hook failed\n+\tsend-pack: unexpected disconnect while reading sideband packet\n+\tfatal: the remote end hung up unexpectedly\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n---\nbase-commit: 11c6700f10234578d10523faf35656ca491425c9\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"551018","messageId":"aohXatWhxCAUQTcq@pks.im","threadId":"66186","inReplyTo":"20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com","subject":"Re: [PATCH v2] hook: introduce the report hook for git-receive-pack(1)","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-08-21T13:49:30Z","receivedAt":"2026-08-21T13:49:38Z","isPatch":true,"body":"On Fri, Aug 21, 2026 at 03:34:58PM +0200, Karthik Nayak wrote:\n[snip]\n> - Exit 0: the hook's stdout is used as the report. The hook can\n>   rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n>   client while receive-pack itself exits cleanly. The client marks\n>   rejected refs as '[remote rejected]' and exits with a non-zero\n>   status if any ref is 'ng'.\n> \n> - Non-zero exit: the hook's stdout is discarded, receive-pack calls\n>   die(), and no report is sent to the client at all. The client\n>   observes a sideband disconnect and reports 'the remote end hung up\n>   unexpectedly', treating the entire push as failed.\n\nI was thinking about this case a bit more. Should we maybe handle it\nsimilarly to the pre-receive hook instead of dieing? If that hook fails\nwe basically update all references to \"pre-receive hook declined\",\nwhereas we could update all of them to \"report hook failed\". That might\nmake for a better user experience.\n\n> diff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\n> index 0956086d61..e6cc0acaaf 100644\n> --- a/Documentation/git-receive-pack.adoc\n> +++ b/Documentation/git-receive-pack.adoc\n> @@ -236,6 +236,21 @@ if the repository is packed and is served via a dumb transport.\n>  exec git update-server-info\n>  ----\n>  \n> +PROC-RECEIVE HOOK\n> +-----------------\n> +This hook is invoked by 'git-receive-pack' when it processes push\n> +requests. It handles refs whose names match the patterns defined by\n> +`receive.procReceiveRefs` and executes the actual ref updates. See\n> +linkgit:githooks[5] for the full protocol description.\n\nThis feels like it should've been a separate commit.\n\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index ed045940d1..06c9e4b017 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -527,6 +527,57 @@ The exit status of the hook is ignored for any state except for the\n>  status will cause the transaction to be aborted. The hook will not be\n>  called with \"aborted\" state in that case.\n>  \n> +report\n> +~~~~~~\n> +\n> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n> +`git push` and updates references in its repository. It executes on\n> +the repository once after all refs have been updated and after\n> +`execute_commands()` has applied all accepted ref changes to the\n\nNit: I think we shouldn't talk about functions in our documentation, but\nrather about behaviour. Functions are likely to change, and I don't\nthink we should expect our users to read our code.\n\n> +repository, but before the pkt-line encoded status report is sent back\n> +to the client.\n> +\n> +The hook receives the complete pkt-line encoded status report on\n> +standard input. The report begins with an `unpack` line indicating\n> +whether the object transfer succeeded (`unpack ok` or\n> +`unpack <error>`), followed by one `ok <refname>` or\n> +`ng <refname> <reason>` line per ref that was pushed, and is\n> +terminated by a flush packet.\n> +\n> +The hook's standard output entirely replaces the report that is sent\n> +to the client. The hook must write a valid pkt-line encoded report in\n> +the same format it received. The hook's stdout is fully buffered by\n> +`receive-pack` before any data is sent to the client, so the hook's\n> +exit status is known before the client receives anything.\n> +\n> +There are two distinct ways the hook can affect the push outcome:\n> +\n> +* To reject individual ref updates while keeping `receive-pack` alive,\n> +  rewrite the corresponding `ok <refname>` lines to\n> +  `ng <refname> <reason>` lines in the output and exit with status 0.\n\nIt's `ng <refname>[ <reason>]`, right? I think the reason itself is\noptional. We might also want to clarify whether there should be a\ntrailing newline or not.\n\nThanks!\n\nPatrick\n"},{"id":"551030","messageId":"CAOLa=ZTkW14coLA4st-m6B6P-9pUr+Yzh7Ph6nb0ohXJSbTk4A@mail.gmail.com","threadId":"66186","inReplyTo":"aohXatWhxCAUQTcq@pks.im","subject":"Re: [PATCH v2] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-21T16:08:12Z","receivedAt":"2026-08-21T16:08:17Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Fri, Aug 21, 2026 at 03:34:58PM +0200, Karthik Nayak wrote:\n> [snip]\n>> - Exit 0: the hook's stdout is used as the report. The hook can\n>>   rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n>>   client while receive-pack itself exits cleanly. The client marks\n>>   rejected refs as '[remote rejected]' and exits with a non-zero\n>>   status if any ref is 'ng'.\n>>\n>> - Non-zero exit: the hook's stdout is discarded, receive-pack calls\n>>   die(), and no report is sent to the client at all. The client\n>>   observes a sideband disconnect and reports 'the remote end hung up\n>>   unexpectedly', treating the entire push as failed.\n>\n> I was thinking about this case a bit more. Should we maybe handle it\n> similarly to the pre-receive hook instead of dieing? If that hook fails\n> we basically update all references to \"pre-receive hook declined\",\n> whereas we could update all of them to \"report hook failed\". That might\n> make for a better user experience.\n>\n\nI didn't know that. Just had a quick look, It would be a bit awkward,\nsince we send the pkt buf to the report, and if we go this way, we'd\nhave to restructure the output again.\n\n>> diff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\n>> index 0956086d61..e6cc0acaaf 100644\n>> --- a/Documentation/git-receive-pack.adoc\n>> +++ b/Documentation/git-receive-pack.adoc\n>> @@ -236,6 +236,21 @@ if the repository is packed and is served via a dumb transport.\n>>  exec git update-server-info\n>>  ----\n>>\n>> +PROC-RECEIVE HOOK\n>> +-----------------\n>> +This hook is invoked by 'git-receive-pack' when it processes push\n>> +requests. It handles refs whose names match the patterns defined by\n>> +`receive.procReceiveRefs` and executes the actual ref updates. See\n>> +linkgit:githooks[5] for the full protocol description.\n>\n> This feels like it should've been a separate commit.\n>\n\nWill split it out.\n\n>> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n>> index ed045940d1..06c9e4b017 100644\n>> --- a/Documentation/githooks.adoc\n>> +++ b/Documentation/githooks.adoc\n>> @@ -527,6 +527,57 @@ The exit status of the hook is ignored for any state except for the\n>>  status will cause the transaction to be aborted. The hook will not be\n>>  called with \"aborted\" state in that case.\n>>\n>> +report\n>> +~~~~~~\n>> +\n>> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n>> +`git push` and updates references in its repository. It executes on\n>> +the repository once after all refs have been updated and after\n>> +`execute_commands()` has applied all accepted ref changes to the\n>\n> Nit: I think we shouldn't talk about functions in our documentation, but\n> rather about behaviour. Functions are likely to change, and I don't\n> think we should expect our users to read our code.\n>\n\nThat's a good point, will change\n\n>> +repository, but before the pkt-line encoded status report is sent back\n>> +to the client.\n>> +\n>> +The hook receives the complete pkt-line encoded status report on\n>> +standard input. The report begins with an `unpack` line indicating\n>> +whether the object transfer succeeded (`unpack ok` or\n>> +`unpack <error>`), followed by one `ok <refname>` or\n>> +`ng <refname> <reason>` line per ref that was pushed, and is\n>> +terminated by a flush packet.\n>> +\n>> +The hook's standard output entirely replaces the report that is sent\n>> +to the client. The hook must write a valid pkt-line encoded report in\n>> +the same format it received. The hook's stdout is fully buffered by\n>> +`receive-pack` before any data is sent to the client, so the hook's\n>> +exit status is known before the client receives anything.\n>> +\n>> +There are two distinct ways the hook can affect the push outcome:\n>> +\n>> +* To reject individual ref updates while keeping `receive-pack` alive,\n>> +  rewrite the corresponding `ok <refname>` lines to\n>> +  `ng <refname> <reason>` lines in the output and exit with status 0.\n>\n> It's `ng <refname>[ <reason>]`, right? I think the reason itself is\n> optional. We might also want to clarify whether there should be a\n> trailing newline or not.\n>\n\nYou're right, since 'send-pack' will default to 'failed' if there is no\nreason.\n\nWe do say 'terminated by a flush packed'.\n\n> Thanks!\n>\n> Patrick\n\nThanks for the review :)\n"},{"id":"551032","messageId":"xmqqy0dzpg4z.fsf@gitster.g","threadId":"66186","inReplyTo":"aohXatWhxCAUQTcq@pks.im","subject":"Re: [PATCH v2] hook: introduce the report hook for git-receive-pack(1)","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-21T16:55:40Z","receivedAt":"2026-08-21T16:55:44Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Fri, Aug 21, 2026 at 03:34:58PM +0200, Karthik Nayak wrote:\n> [snip]\n>> - Exit 0: the hook's stdout is used as the report. The hook can\n>>   rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n>>   client while receive-pack itself exits cleanly. The client marks\n>>   rejected refs as '[remote rejected]' and exits with a non-zero\n>>   status if any ref is 'ng'.\n>> \n>> - Non-zero exit: the hook's stdout is discarded, receive-pack calls\n>>   die(), and no report is sent to the client at all. The client\n>>   observes a sideband disconnect and reports 'the remote end hung up\n>>   unexpectedly', treating the entire push as failed.\n>\n> I was thinking about this case a bit more. Should we maybe handle it\n> similarly to the pre-receive hook instead of dieing? If that hook fails\n> we basically update all references to \"pre-receive hook declined\",\n> whereas we could update all of them to \"report hook failed\". That might\n> make for a better user experience.\n\nIt is a bit different in that pre-receive is all-or-nothing, but I\nagree that it makes sense to model a failure case after how it\nworks.  In general, it helps to explicitly tell the other end that\ntheir action was declined than let them assume that no news is a bad\nnews.\n\nI also agree with other points in your review, but I consider the\nabove is the most valuable input ;-).\n\nThanks.\n\n"},{"id":"551107","messageId":"aovXcPHCiBPxlLXo@pks.im","threadId":"66186","inReplyTo":"CAOLa=ZTkW14coLA4st-m6B6P-9pUr+Yzh7Ph6nb0ohXJSbTk4A@mail.gmail.com","subject":"Re: [PATCH v2] hook: introduce the report hook for git-receive-pack(1)","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-08-24T05:32:32Z","receivedAt":"2026-08-24T05:32:44Z","isPatch":true,"body":"On Fri, Aug 21, 2026 at 09:08:12AM -0700, Karthik Nayak wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> > On Fri, Aug 21, 2026 at 03:34:58PM +0200, Karthik Nayak wrote:\n[snip]\n> >> +repository, but before the pkt-line encoded status report is sent back\n> >> +to the client.\n> >> +\n> >> +The hook receives the complete pkt-line encoded status report on\n> >> +standard input. The report begins with an `unpack` line indicating\n> >> +whether the object transfer succeeded (`unpack ok` or\n> >> +`unpack <error>`), followed by one `ok <refname>` or\n> >> +`ng <refname> <reason>` line per ref that was pushed, and is\n> >> +terminated by a flush packet.\n> >> +\n> >> +The hook's standard output entirely replaces the report that is sent\n> >> +to the client. The hook must write a valid pkt-line encoded report in\n> >> +the same format it received. The hook's stdout is fully buffered by\n> >> +`receive-pack` before any data is sent to the client, so the hook's\n> >> +exit status is known before the client receives anything.\n> >> +\n> >> +There are two distinct ways the hook can affect the push outcome:\n> >> +\n> >> +* To reject individual ref updates while keeping `receive-pack` alive,\n> >> +  rewrite the corresponding `ok <refname>` lines to\n> >> +  `ng <refname> <reason>` lines in the output and exit with status 0.\n> >\n> > It's `ng <refname>[ <reason>]`, right? I think the reason itself is\n> > optional. We might also want to clarify whether there should be a\n> > trailing newline or not.\n> >\n> \n> You're right, since 'send-pack' will default to 'failed' if there is no\n> reason.\n> \n> We do say 'terminated by a flush packed'.\n\nWe only send the flush packet once donce with all refs though, right?\nI was wondering about each individual reference line: are they supposed\nto end with a newline or not?\n\nPatrick\n"},{"id":"551113","messageId":"CAOLa=ZTPsgfPTaT3L5OPkavfAOWaCS0b3JJa__pFbnC3JZLY4A@mail.gmail.com","threadId":"66186","inReplyTo":"aovXcPHCiBPxlLXo@pks.im","subject":"Re: [PATCH v2] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-24T08:14:47Z","receivedAt":"2026-08-24T08:14:50Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Fri, Aug 21, 2026 at 09:08:12AM -0700, Karthik Nayak wrote:\n>> Patrick Steinhardt <ps@pks.im> writes:\n>> > On Fri, Aug 21, 2026 at 03:34:58PM +0200, Karthik Nayak wrote:\n> [snip]\n>> >> +repository, but before the pkt-line encoded status report is sent back\n>> >> +to the client.\n>> >> +\n>> >> +The hook receives the complete pkt-line encoded status report on\n>> >> +standard input. The report begins with an `unpack` line indicating\n>> >> +whether the object transfer succeeded (`unpack ok` or\n>> >> +`unpack <error>`), followed by one `ok <refname>` or\n>> >> +`ng <refname> <reason>` line per ref that was pushed, and is\n>> >> +terminated by a flush packet.\n>> >> +\n>> >> +The hook's standard output entirely replaces the report that is sent\n>> >> +to the client. The hook must write a valid pkt-line encoded report in\n>> >> +the same format it received. The hook's stdout is fully buffered by\n>> >> +`receive-pack` before any data is sent to the client, so the hook's\n>> >> +exit status is known before the client receives anything.\n>> >> +\n>> >> +There are two distinct ways the hook can affect the push outcome:\n>> >> +\n>> >> +* To reject individual ref updates while keeping `receive-pack` alive,\n>> >> +  rewrite the corresponding `ok <refname>` lines to\n>> >> +  `ng <refname> <reason>` lines in the output and exit with status 0.\n>> >\n>> > It's `ng <refname>[ <reason>]`, right? I think the reason itself is\n>> > optional. We might also want to clarify whether there should be a\n>> > trailing newline or not.\n>> >\n>>\n>> You're right, since 'send-pack' will default to 'failed' if there is no\n>> reason.\n>>\n>> We do say 'terminated by a flush packed'.\n>\n> We only send the flush packet once donce with all refs though, right?\n> I was wondering about each individual reference line: are they supposed\n> to end with a newline or not?\n>\n> Patrick\n\nAh, will clarify that :)\n"},{"id":"551122","messageId":"20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v3 0/3] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-24T10:20:58Z","receivedAt":"2026-08-24T10:21:06Z","isPatch":true,"body":"\n\n---\nChanges in v3:\n- Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n  into a new commit.\n- Add a new commit to move out the response generation in receive-pack\n  to a new function.\n- Instead of die-ing on non-zero exit code, we modify each reference to\n  indicate that the hook failed.\n- Instead of correctly listing out the protocol, link to\n  linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n  and v2.\n- Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n\n---\nKarthik Nayak (3):\n      doc: add proc-receive hook info in 'git-receive-pack.adoc'\n      receive-pack: move message generation to separate function\n      hook: introduce the report hook for git-receive-pack(1)\n\n Documentation/git-receive-pack.adoc |  15 +++\n Documentation/githooks.adoc         |  43 ++++++++\n builtin/receive-pack.c              | 137 ++++++++++++++++--------\n t/meson.build                       |   1 +\n t/t5412-report-hook.sh              | 200 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 356 insertions(+), 40 deletions(-)\n\nRange-diff versus v2:\n\n-:  ---------- > 1:  42aaf10403 doc: add proc-receive hook info in 'git-receive-pack.adoc'\n-:  ---------- > 2:  cb55895d2a receive-pack: move message generation to separate function\n1:  07fa5ba8bb ! 3:  5bfaea5033 hook: introduce the report hook for git-receive-pack(1)\n    @@ Commit message\n         Introduce a new 'report' hook. The hook receives the complete pkt-line\n         encoded status report on standard input, after all ref updates have\n         been applied to the repository by execute_commands() but before the\n    -    report is sent to the client. The report consists of an 'unpack ok'\n    -    or 'unpack <error>' line, followed by one 'ok <refname>' or\n    -    'ng <refname> <reason>' line per pushed ref, terminated by a flush\n    -    packet.\n    +    report is sent to the client. See linkgit:gitprotocol-pack[5] details on\n    +    the protocol structure.\n     \n         The hook's stdout fully replaces the report sent to the client.\n         receive-pack fully buffers the hook's stdout before acting on the exit\n         status, so the exit code is known before the client receives anything.\n    -    This gives two distinct behaviours depending on exit status:\n    +    This gives two distinct behaviors depending on exit status:\n     \n         - Exit 0: the hook's stdout is used as the report. The hook can\n           rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n    @@ Commit message\n           rejected refs as '[remote rejected]' and exits with a non-zero\n           status if any ref is 'ng'.\n     \n    -    - Non-zero exit: the hook's stdout is discarded, receive-pack calls\n    -      die(), and no report is sent to the client at all. The client\n    -      observes a sideband disconnect and reports 'the remote end hung up\n    -      unexpectedly', treating the entire push as failed.\n    +    - Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n    +      all references to be rejected with a 'report hook failed' error.\n     \n         In both cases, any output the hook writes to standard error is\n         forwarded to the client over the sideband channel and appears as\n    @@ Commit message\n         Signed-off-by: Karthik Nayak <karthik.188@gmail.com>\n     \n      ## Documentation/git-receive-pack.adoc ##\n    -@@ Documentation/git-receive-pack.adoc: if the repository is packed and is served via a dumb transport.\n    - exec git update-server-info\n    - ----\n    +@@ Documentation/git-receive-pack.adoc: requests. It handles refs whose names match the patterns defined by\n    + `receive.procReceiveRefs` and executes the actual ref updates. See\n    + linkgit:githooks[5] for the full protocol description.\n      \n    -+PROC-RECEIVE HOOK\n    -+-----------------\n    -+This hook is invoked by 'git-receive-pack' when it processes push\n    -+requests. It handles refs whose names match the patterns defined by\n    -+`receive.procReceiveRefs` and executes the actual ref updates. See\n    -+linkgit:githooks[5] for the full protocol description.\n    -+\n     +REPORT HOOK\n     +-----------\n     +This hook is invoked by 'git-receive-pack' after all the ref updates\n    @@ Documentation/git-receive-pack.adoc: if the repository is packed and is served v\n     +replaces the report sent to the client. Allowing the hook to rewrite\n     +the outcomes or abort the push completely. See linkgit:githooks[5] for\n     +the full protocol description.\n    - \n    ++\n      QUARANTINE ENVIRONMENT\n      ----------------------\n    + \n     \n      ## Documentation/githooks.adoc ##\n     @@ Documentation/githooks.adoc: The exit status of the hook is ignored for any state except for the\n    @@ Documentation/githooks.adoc: The exit status of the hook is ignored for any stat\n     +\n     +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n     +`git push` and updates references in its repository. It executes on\n    -+the repository once after all refs have been updated and after\n    -+`execute_commands()` has applied all accepted ref changes to the\n    -+repository, but before the pkt-line encoded status report is sent back\n    -+to the client.\n    ++the repository once after all refs have been updated and after all\n    ++accepted ref changes are applied to the repository, but before the\n    ++pkt-line encoded status report is sent back to the client.\n     +\n     +The hook receives the complete pkt-line encoded status report on\n    -+standard input. The report begins with an `unpack` line indicating\n    -+whether the object transfer succeeded (`unpack ok` or\n    -+`unpack <error>`), followed by one `ok <refname>` or\n    -+`ng <refname> <reason>` line per ref that was pushed, and is\n    -+terminated by a flush packet.\n    -+\n    -+The hook's standard output entirely replaces the report that is sent\n    -+to the client. The hook must write a valid pkt-line encoded report in\n    -+the same format it received. The hook's stdout is fully buffered by\n    -+`receive-pack` before any data is sent to the client, so the hook's\n    -+exit status is known before the client receives anything.\n    ++standard input, see linkgit:gitprotocol-pack[5] for details on the\n    ++structure. The hook's standard output entirely replaces the report\n    ++that is sent to the client. The hook must write a valid pkt-line\n    ++encoded report in the same format it received. The hook's stdout is\n    ++fully buffered by `receive-pack` before any data is sent to the client,\n    ++so the hook's exit status is known before the client receives anything.\n     +\n     +There are two distinct ways the hook can affect the push outcome:\n     +\n    @@ Documentation/githooks.adoc: The exit status of the hook is ignored for any stat\n     +\n     +* To abort the entire push unconditionally, exit with a non-zero\n     +  status. In this case the hook's stdout is discarded, `receive-pack`\n    -+  calls `die()`, and no report is sent to the client at all. The client\n    -+  observes an unexpected sideband disconnect, making the entire push\n    -+  appear to have failed. In general, the hook should never exit with a\n    -+  non-zero status code and doing so would indicate a bug.\n    ++  modifies all references to be rejected with a 'report hook failed'\n    ++  error.\n     +\n     +Any output written to standard error is forwarded to the client over\n     +the sideband channel and will appear as `remote:` lines on clients\n    @@ builtin/receive-pack.c: static int run_update_hook(struct command *cmd)\n      static struct command *find_command_by_refname(struct command *list,\n      \t\t\t\t\t       const char *refname)\n      {\n    +@@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands,\n    +  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n    +  * For v2 protocol, set `add_reports` to true, which will also add additional\n    +  * report per reference update.\n    ++ * If `ref_error` is set, then all references will be rejected with the given\n    ++ * error message.\n    +  */\n    + static void generate_response(struct strbuf *buf, struct command *commands,\n    +-\t\t\t      const char *unpack_status, bool add_reports)\n    ++\t\t\t      const char *unpack_status, bool add_reports,\n    ++\t\t\t      const char *ref_error)\n    + {\n    + \tstruct command *cmd;\n    + \n    +@@ builtin/receive-pack.c: static void generate_response(struct strbuf *buf, struct command *commands,\n    + \t\tif (cmd->error_string)\n    + \t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n    + \t\t\t\t\t cmd->ref_name, cmd->error_string);\n    ++\t\telse if (ref_error)\n    ++\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n    ++\t\t\t\t\t cmd->ref_name, ref_error);\n    + \t\telse\n    + \t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n    + \n    +-\t\tif (!add_reports || cmd->error_string)\n    ++\t\tif (!add_reports || cmd->error_string || ref_error)\n    + \t\t\tcontinue;\n    + \n    + \t\tfor (report = cmd->report; report; report = report->next) {\n     @@ builtin/receive-pack.c: static void report(struct command *commands, const char *unpack_status)\n    - \t}\n    - \tpacket_buf_flush(&buf);\n    + {\n    + \tstruct strbuf buf = STRBUF_INIT;\n    + \n    +-\tgenerate_response(&buf, commands, unpack_status, false);\n    ++\tgenerate_response(&buf, commands, unpack_status, false, NULL);\n    ++\n    ++\tif (run_report_hook(&buf)) {\n    ++\t\tstrbuf_reset(&buf);\n    ++\t\tgenerate_response(&buf, commands, unpack_status, false,\n    ++\t\t\t\t  \"report hook failed\");\n    ++\t}\n      \n    -+\tif (run_report_hook(&buf))\n    -+\t\tdie(\"report hook failed\");\n    -+\n      \tif (use_sideband)\n      \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    - \telse\n     @@ builtin/receive-pack.c: static void report_v2(struct command *commands, const char *unpack_status)\n    - \t}\n    - \tpacket_buf_flush(&buf);\n    + {\n    + \tstruct strbuf buf = STRBUF_INIT;\n    + \n    +-\tgenerate_response(&buf, commands, unpack_status, true);\n    ++\tgenerate_response(&buf, commands, unpack_status, true, NULL);\n    ++\n    ++\tif (run_report_hook(&buf)) {\n    ++\t\tstrbuf_reset(&buf);\n    ++\t\tgenerate_response(&buf, commands, unpack_status, true,\n    ++\t\t\t  \"report hook failed\");\n    ++\t}\n      \n    -+\tif (run_report_hook(&buf))\n    -+\t\tdie(\"report hook failed\");\n    -+\n      \tif (use_sideband)\n      \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    - \telse\n     \n      ## t/meson.build ##\n     @@ t/meson.build: integration_tests = [\n    @@ t/t5412-report-hook.sh (new)\n     +\ttest_cmp expect actual\n     +'\n     +\n    -+test_expect_success \"non-zero exit causes receive-pack to die\" '\n    ++test_expect_success \"non-zero exit reports as hook failed\" '\n     +\ttest_when_finished \"rm -rf upstream\" &&\n     +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n     +\n    @@ t/t5412-report-hook.sh (new)\n     +\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n     +\tmake_user_friendly_and_stable_output <out >actual &&\n     +\tcat >expect <<-\\EOF &&\n    -+\tfatal: report hook failed\n    -+\tsend-pack: unexpected disconnect while reading sideband packet\n    -+\tfatal: the remote end hung up unexpectedly\n    ++\tTo ../upstream\n    ++\t ! [remote rejected] <COMMIT-B> -> main (report hook failed)\n     +\tEOF\n     +\ttest_cmp expect actual\n     +'\n\n---\nbase-commit: 11c6700f10234578d10523faf35656ca491425c9\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"551124","messageId":"20260824-758-introduce-hook-v3-1-499526f0a062@gmail.com","threadId":"66186","inReplyTo":"20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com","subject":"[PATCH v3 1/3] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-24T10:20:59Z","receivedAt":"2026-08-24T10:21:07Z","isPatch":true,"body":"The 'Documentation/git-receive-pack.adoc' contains documentation about\nhooks which lie in the lifecycle of 'git-receive-pack(1)'. Unfortunately\nit is missing information about the 'proc-receive' hook. Add it.\n\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc | 6 ++++++\n 1 file changed, 6 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..4349487e6a 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,12 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by 'git-receive-pack' when it processes push\n+requests. It handles refs whose names match the patterns defined by\n+`receive.procReceiveRefs` and executes the actual ref updates. See\n+linkgit:githooks[5] for the full protocol description.\n \n QUARANTINE ENVIRONMENT\n ----------------------\n\n-- \n2.55.GIT\n\n"},{"id":"551123","messageId":"20260824-758-introduce-hook-v3-2-499526f0a062@gmail.com","threadId":"66186","inReplyTo":"20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com","subject":"[PATCH v3 2/3] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-24T10:21:00Z","receivedAt":"2026-08-24T10:21:08Z","isPatch":true,"body":"Post the reference transaction, both `report()` and `report_v2()`\ngenerate the message to be sent to the client. In v2, we also add\nreports for each reference if available. Since they share common code,\nmove them to a common function. This will also help the following\ncommit, where we will need to regenerate the message during hook\nfailure.\n\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 84 ++++++++++++++++++++++++++------------------------\n 1 file changed, 44 insertions(+), 40 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 86933d8d7e..70a686c142 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2530,67 +2530,71 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n-static void report(struct command *commands, const char *unpack_status)\n+/*\n+ * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n+ * For v2 protocol, set `add_reports` to true, which will also add additional\n+ * report per reference update.\n+ */\n+static void generate_response(struct strbuf *buf, struct command *commands,\n+\t\t\t      const char *unpack_status, bool add_reports)\n {\n \tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n+\tpacket_buf_write(buf, \"unpack %s\\n\",\n \t\t\t unpack_status ? unpack_status : \"ok\");\n-\tfor (cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (!cmd->error_string)\n-\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t\t cmd->ref_name);\n-\t\telse\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name, cmd->error_string);\n-\t}\n-\tpacket_buf_flush(&buf);\n-\n-\tif (use_sideband)\n-\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n-\telse\n-\t\twrite_or_die(1, buf.buf, buf.len);\n-\tstrbuf_release(&buf);\n-}\n-\n-static void report_v2(struct command *commands, const char *unpack_status)\n-{\n-\tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n-\tstruct ref_push_report *report;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n-\t\t\t unpack_status ? unpack_status : \"ok\");\n \tfor (cmd = commands; cmd; cmd = cmd->next) {\n+\t\tstruct ref_push_report *report;\n \t\tint count = 0;\n \n-\t\tif (cmd->error_string) {\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name,\n-\t\t\t\t\t cmd->error_string);\n+\t\tif (cmd->error_string)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse\n+\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n+\n+\t\tif (!add_reports || cmd->error_string)\n \t\t\tcontinue;\n-\t\t}\n-\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t cmd->ref_name);\n+\n \t\tfor (report = cmd->report; report; report = report->next) {\n \t\t\tif (count++ > 0)\n-\t\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"ok %s\\n\",\n \t\t\t\t\t\t cmd->ref_name);\n \t\t\tif (report->ref_name)\n-\t\t\t\tpacket_buf_write(&buf, \"option refname %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option refname %s\\n\",\n \t\t\t\t\t\t report->ref_name);\n \t\t\tif (report->old_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option old-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option old-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->old_oid));\n \t\t\tif (report->new_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option new-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option new-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->new_oid));\n \t\t\tif (report->forced_update)\n-\t\t\t\tpacket_buf_write(&buf, \"option forced-update\\n\");\n+\t\t\t\tpacket_buf_write(buf, \"option forced-update\\n\");\n \t\t}\n \t}\n-\tpacket_buf_flush(&buf);\n+\n+\tpacket_buf_flush(buf);\n+}\n+\n+static void report(struct command *commands, const char *unpack_status)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_response(&buf, commands, unpack_status, false);\n+\n+\tif (use_sideband)\n+\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n+\telse\n+\t\twrite_or_die(1, buf.buf, buf.len);\n+\tstrbuf_release(&buf);\n+}\n+\n+static void report_v2(struct command *commands, const char *unpack_status)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_response(&buf, commands, unpack_status, true);\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n\n-- \n2.55.GIT\n\n"},{"id":"551125","messageId":"20260824-758-introduce-hook-v3-3-499526f0a062@gmail.com","threadId":"66186","inReplyTo":"20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com","subject":"[PATCH v3 3/3] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-24T10:21:01Z","receivedAt":"2026-08-24T10:21:09Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook cannot be used by us because we care\n    about the phase where it was committed already. And while the hook\n    fires in that phase, it does not allow the caller to modify the\n    result in any capacity.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'report' hook. The hook receives the complete pkt-line\nencoded status report on standard input, after all ref updates have\nbeen applied to the repository by execute_commands() but before the\nreport is sent to the client. See linkgit:gitprotocol-pack[5] details on\nthe protocol structure.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviors depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n  all references to be rejected with a 'report hook failed' error.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nNote that in either failure mode, ref updates already applied by\nexecute_commands() are not rolled back. The hook can cause the client\nto perceive the push as failed, but cannot undo server-side changes.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc |   9 ++\n Documentation/githooks.adoc         |  43 ++++++++\n builtin/receive-pack.c              |  61 ++++++++++-\n t/meson.build                       |   1 +\n t/t5412-report-hook.sh              | 200 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 310 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 4349487e6a..e6cc0acaaf 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -243,6 +243,15 @@ requests. It handles refs whose names match the patterns defined by\n `receive.procReceiveRefs` and executes the actual ref updates. See\n linkgit:githooks[5] for the full protocol description.\n \n+REPORT HOOK\n+-----------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client. Allowing the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n+\n QUARANTINE ENVIRONMENT\n ----------------------\n \ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..d148a4990c 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,49 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+report\n+~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after all\n+accepted ref changes are applied to the repository, but before the\n+pkt-line encoded status report is sent back to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input, see linkgit:gitprotocol-pack[5] for details on the\n+structure. The hook's standard output entirely replaces the report\n+that is sent to the client. The hook must write a valid pkt-line\n+encoded report in the same format it received. The hook's stdout is\n+fully buffered by `receive-pack` before any data is sent to the client,\n+so the hook's exit status is known before the client receives anything.\n+\n+There are two distinct ways the hook can affect the push outcome:\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname> <reason>` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  modifies all references to be rejected with a 'report hook failed'\n+  error.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 70a686c142..138a432c15 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2534,9 +2569,12 @@ static void update_shallow_info(struct command *commands,\n  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n  * For v2 protocol, set `add_reports` to true, which will also add additional\n  * report per reference update.\n+ * If `ref_error` is set, then all references will be rejected with the given\n+ * error message.\n  */\n static void generate_response(struct strbuf *buf, struct command *commands,\n-\t\t\t      const char *unpack_status, bool add_reports)\n+\t\t\t      const char *unpack_status, bool add_reports,\n+\t\t\t      const char *ref_error)\n {\n \tstruct command *cmd;\n \n@@ -2550,10 +2588,13 @@ static void generate_response(struct strbuf *buf, struct command *commands,\n \t\tif (cmd->error_string)\n \t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n \t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse if (ref_error)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, ref_error);\n \t\telse\n \t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n \n-\t\tif (!add_reports || cmd->error_string)\n+\t\tif (!add_reports || cmd->error_string || ref_error)\n \t\t\tcontinue;\n \n \t\tfor (report = cmd->report; report; report = report->next) {\n@@ -2581,7 +2622,13 @@ static void report(struct command *commands, const char *unpack_status)\n {\n \tstruct strbuf buf = STRBUF_INIT;\n \n-\tgenerate_response(&buf, commands, unpack_status, false);\n+\tgenerate_response(&buf, commands, unpack_status, false, NULL);\n+\n+\tif (run_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\tgenerate_response(&buf, commands, unpack_status, false,\n+\t\t\t\t  \"report hook failed\");\n+\t}\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n@@ -2594,7 +2641,13 @@ static void report_v2(struct command *commands, const char *unpack_status)\n {\n \tstruct strbuf buf = STRBUF_INIT;\n \n-\tgenerate_response(&buf, commands, unpack_status, true);\n+\tgenerate_response(&buf, commands, unpack_status, true, NULL);\n+\n+\tif (run_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\tgenerate_response(&buf, commands, unpack_status, true,\n+\t\t\t  \"report hook failed\");\n+\t}\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\ndiff --git a/t/meson.build b/t/meson.build\nindex a25f37d2f5..7056e31326 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -651,6 +651,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-report-hook.sh b/t/t5412-report-hook.sh\nnew file mode 100755\nindex 0000000000..418f4a7155\n--- /dev/null\n+++ b/t/t5412-report-hook.sh\n@@ -0,0 +1,200 @@\n+#!/bin/sh\n+\n+test_description='test report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit reports as hook failed\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (report hook failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n-- \n2.55.GIT\n\n"},{"id":"551138","messageId":"xmqqv78zr0pa.fsf@gitster.g","threadId":"66186","inReplyTo":"20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com","subject":"Re: [PATCH v3 0/3] hook: introduce the report hook for git-receive-pack(1)","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-24T15:35:13Z","receivedAt":"2026-08-24T15:35:16Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> ---\n> Changes in v3:\n> - Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n>   into a new commit.\n> - Add a new commit to move out the response generation in receive-pack\n>   to a new function.\n> - Instead of die-ing on non-zero exit code, we modify each reference to\n>   indicate that the hook failed.\n> - Instead of correctly listing out the protocol, link to\n>   linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n>   and v2.\n> - Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nThis has some interaction with Justin's pluggable writes series.\nPlease help sanity check the conflict resolution I did near the tip\nof 'seen' when I push the integration results out later today.\n\nThanks.\n"},{"id":"551139","messageId":"xmqqo6erqzon.fsf@gitster.g","threadId":"66186","inReplyTo":"xmqqv78zr0pa.fsf@gitster.g","subject":"Re: [PATCH v3 0/3] hook: introduce the report hook for git-receive-pack(1)","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-24T15:57:12Z","receivedAt":"2026-08-24T15:57:15Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> ---\n>> Changes in v3:\n>> - Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n>>   into a new commit.\n>> - Add a new commit to move out the response generation in receive-pack\n>>   to a new function.\n>> - Instead of die-ing on non-zero exit code, we modify each reference to\n>>   indicate that the hook failed.\n>> - Instead of correctly listing out the protocol, link to\n>>   linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n>>   and v2.\n>> - Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n>\n> This has some interaction with Justin's pluggable writes series.\n> Please help sanity check the conflict resolution I did near the tip\n> of 'seen' when I push the integration results out later today.\n\nOne more thing.  'report' is far too generic a name for this.  There\nare other features that plausibly would want to create their own\nreports.  It is understandable that one can be blinded by the\nthought that their invention is more important than everything else,\nbut please resist such temptation.\n\nThanks.\n"},{"id":"551143","messageId":"aox4tYaRQrCyZXnS@pks.im","threadId":"66186","inReplyTo":"xmqqo6erqzon.fsf@gitster.g","subject":"Re: [PATCH v3 0/3] hook: introduce the report hook for git-receive-pack(1)","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-08-24T17:00:37Z","receivedAt":"2026-08-24T17:00:45Z","isPatch":true,"body":"On Mon, Aug 24, 2026 at 08:57:12AM -0700, Junio C Hamano wrote:\n> Junio C Hamano <gitster@pobox.com> writes:\n> \n> > Karthik Nayak <karthik.188@gmail.com> writes:\n> >\n> >> ---\n> >> Changes in v3:\n> >> - Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n> >>   into a new commit.\n> >> - Add a new commit to move out the response generation in receive-pack\n> >>   to a new function.\n> >> - Instead of die-ing on non-zero exit code, we modify each reference to\n> >>   indicate that the hook failed.\n> >> - Instead of correctly listing out the protocol, link to\n> >>   linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n> >>   and v2.\n> >> - Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n> >\n> > This has some interaction with Justin's pluggable writes series.\n> > Please help sanity check the conflict resolution I did near the tip\n> > of 'seen' when I push the integration results out later today.\n> \n> One more thing.  'report' is far too generic a name for this.  There\n> are other features that plausibly would want to create their own\n> reports.  It is understandable that one can be blinded by the\n> thought that their invention is more important than everything else,\n> but please resist such temptation.\n\nThat's probably on me, as I originally suggested \"pre-report\" when the\ndesign was still in an earlier stage. But I agree with you -- both\n\"pre-report\" and \"report\" are awful names. We have others like \"update\",\nwhich sound way more generic than they really are, but that's not a good\nreason to repeat that sin.\n\nA better name might be \"receive-report\" or something like that.\n\nPatrick\n"},{"id":"551266","messageId":"CAOLa=ZTN_95gsySKqA6Tm2daaKYNcM+V-sPeBLup3vDr1BznYw@mail.gmail.com","threadId":"66186","inReplyTo":"xmqqv78zr0pa.fsf@gitster.g","subject":"Re: [PATCH v3 0/3] hook: introduce the report hook for git-receive-pack(1)","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-26T08:35:53Z","receivedAt":"2026-08-26T08:35:55Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> ---\n>> Changes in v3:\n>> - Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n>>   into a new commit.\n>> - Add a new commit to move out the response generation in receive-pack\n>>   to a new function.\n>> - Instead of die-ing on non-zero exit code, we modify each reference to\n>>   indicate that the hook failed.\n>> - Instead of correctly listing out the protocol, link to\n>>   linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n>>   and v2.\n>> - Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n>\n> This has some interaction with Justin's pluggable writes series.\n> Please help sanity check the conflict resolution I did near the tip\n> of 'seen' when I push the integration results out later today.\n>\n> Thanks.\n\nI had a look at the merge and it looks good! Thanks\n"},{"id":"551272","messageId":"20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v4 0/3] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-26T10:19:36Z","receivedAt":"2026-08-26T10:19:56Z","isPatch":true,"body":"Introduce a new receive-report hook which kicks in after the reference\ntransaction is complete, but before the report is sent to the client.\nThe hook receives the pkt-line encoded report in its stdin and its\nstdout replaces the report transferred to the user. If the hook exits\nwith a non-zero exit code, all references are marked as rejected.\n\nThe first patch, adds missing documentation to 'git-receive-pack.adoc'.\nThe second patch refactors code and the third patch contains the new\nhook.\n\n---\nChanges in v4:\n- Change the name of the hook to be 'receive-report' to avoid ambiguity.\n- Link to v3: https://patch.msgid.link/20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com\n\nChanges in v3:\n- Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n  into a new commit.\n- Add a new commit to move out the response generation in receive-pack\n  to a new function.\n- Instead of die-ing on non-zero exit code, we modify each reference to\n  indicate that the hook failed.\n- Instead of correctly listing out the protocol, link to\n  linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n  and v2.\n- Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n\n To: git@vger.kernel.org\n CC: ps@pks.im\n CC: gitster@pobox.com\n CC: jltobler@gmail.com\n CC: kristofferhaugsbakk@fastmail.com\n CC: phillip.wood123@gmail.com\n\n---\nKarthik Nayak (3):\n      doc: add proc-receive hook info in 'git-receive-pack.adoc'\n      receive-pack: move message generation to separate function\n      hook: introduce the receive-report hook\n\n Documentation/git-receive-pack.adoc |  15 +++\n Documentation/githooks.adoc         |  43 ++++++++\n builtin/receive-pack.c              | 137 ++++++++++++++++--------\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 200 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 356 insertions(+), 40 deletions(-)\n\nRange-diff versus v3:\n\n1:  b899f31ffa = 1:  30784c0448 doc: add proc-receive hook info in 'git-receive-pack.adoc'\n2:  335182cd3d = 2:  55d6a46815 receive-pack: move message generation to separate function\n3:  80aa575dab ! 3:  99eeafb537 hook: introduce the report hook for git-receive-pack(1)\n    @@ Metadata\n     Author: Karthik Nayak <karthik.188@gmail.com>\n     \n      ## Commit message ##\n    -    hook: introduce the report hook for git-receive-pack(1)\n    +    hook: introduce the receive-report hook\n     \n         When running 'git-receive-pack(1)', there is no way for the server to\n         intercept and modify the status report before it is sent back to the\n    @@ Commit message\n             too late, at the point where we have already reported success to the\n             client.\n     \n    -    Introduce a new 'report' hook. The hook receives the complete pkt-line\n    -    encoded status report on standard input, after all ref updates have\n    -    been applied to the repository by execute_commands() but before the\n    +    Introduce a new 'receive-report' hook. The hook receives the complete\n    +    pkt-line encoded status report on standard input, after all ref updates\n    +    have been applied to the repository by execute_commands() but before the\n         report is sent to the client. See linkgit:gitprotocol-pack[5] details on\n         the protocol structure.\n     \n    @@ Commit message\n           status if any ref is 'ng'.\n     \n         - Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n    -      all references to be rejected with a 'report hook failed' error.\n    +      all references to be rejected with a 'receive-report hook failed'\n    +      error.\n     \n         In both cases, any output the hook writes to standard error is\n         forwarded to the client over the sideband channel and appears as\n    @@ Documentation/git-receive-pack.adoc: requests. It handles refs whose names match\n      `receive.procReceiveRefs` and executes the actual ref updates. See\n      linkgit:githooks[5] for the full protocol description.\n      \n    -+REPORT HOOK\n    -+-----------\n    ++RECEIVE-REPORT HOOK\n    ++-------------------\n     +This hook is invoked by 'git-receive-pack' after all the ref updates\n     +have been applied but before the report is sent to the client. The hook\n     +receives the complete report in pkt-line format on stdin and its stdout\n    @@ Documentation/githooks.adoc: The exit status of the hook is ignored for any stat\n      status will cause the transaction to be aborted. The hook will not be\n      called with \"aborted\" state in that case.\n      \n    -+report\n    -+~~~~~~\n    ++receive-report\n    ++~~~~~~~~~~~~~~\n     +\n     +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n     +`git push` and updates references in its repository. It executes on\n    @@ Documentation/githooks.adoc: The exit status of the hook is ignored for any stat\n     +\n     +* To abort the entire push unconditionally, exit with a non-zero\n     +  status. In this case the hook's stdout is discarded, `receive-pack`\n    -+  modifies all references to be rejected with a 'report hook failed'\n    -+  error.\n    ++  modifies all references to be rejected with a 'receive-report hook\n    ++  failed' error.\n     +\n     +Any output written to standard error is forwarded to the client over\n     +the sideband channel and will appear as `remote:` lines on clients\n    @@ builtin/receive-pack.c: static int run_update_hook(struct command *cmd)\n      \treturn code;\n      }\n      \n    -+static int run_report_hook(struct strbuf *report)\n    ++static int run_receive_report_hook(struct strbuf *report)\n     +{\n     +\tstruct child_process proc = CHILD_PROCESS_INIT;\n     +\tstruct async sideband_async;\n    @@ builtin/receive-pack.c: static int run_update_hook(struct command *cmd)\n     +\tconst char *hook_path;\n     +\tint ret;\n     +\n    -+\thook_path = find_hook(the_repository, \"report\");\n    ++\thook_path = find_hook(the_repository, \"receive-report\");\n     +\tif (!hook_path)\n     +\t\treturn 0;\n     +\n     +\tstrvec_push(&proc.args, hook_path);\n    -+\tproc.trace2_hook_name = \"report\";\n    ++\tproc.trace2_hook_name = \"receive-report\";\n     +\n     +\tprepare_sideband_async(&sideband_async, &saved_stderr,\n     +\t\t\t       &sideband_async_started);\n    @@ builtin/receive-pack.c: static void report(struct command *commands, const char\n     -\tgenerate_response(&buf, commands, unpack_status, false);\n     +\tgenerate_response(&buf, commands, unpack_status, false, NULL);\n     +\n    -+\tif (run_report_hook(&buf)) {\n    ++\tif (run_receive_report_hook(&buf)) {\n     +\t\tstrbuf_reset(&buf);\n     +\t\tgenerate_response(&buf, commands, unpack_status, false,\n    -+\t\t\t\t  \"report hook failed\");\n    ++\t\t\t\t  \"receive-report hook failed\");\n     +\t}\n      \n      \tif (use_sideband)\n    @@ builtin/receive-pack.c: static void report_v2(struct command *commands, const ch\n     -\tgenerate_response(&buf, commands, unpack_status, true);\n     +\tgenerate_response(&buf, commands, unpack_status, true, NULL);\n     +\n    -+\tif (run_report_hook(&buf)) {\n    ++\tif (run_receive_report_hook(&buf)) {\n     +\t\tstrbuf_reset(&buf);\n     +\t\tgenerate_response(&buf, commands, unpack_status, true,\n    -+\t\t\t  \"report hook failed\");\n    ++\t\t\t  \"receive-report hook failed\");\n     +\t}\n      \n      \tif (use_sideband)\n    @@ t/meson.build: integration_tests = [\n        't5409-colorize-remote-messages.sh',\n        't5410-receive-pack.sh',\n        't5411-proc-receive-hook.sh',\n    -+  't5412-report-hook.sh',\n    ++  't5412-receive-report-hook.sh',\n        't5500-fetch-pack.sh',\n        't5501-fetch-push-alternates.sh',\n        't5502-quickfetch.sh',\n     \n    - ## t/t5412-report-hook.sh (new) ##\n    + ## t/t5412-receive-report-hook.sh (new) ##\n     @@\n     +#!/bin/sh\n     +\n    -+test_description='test report hook'\n    ++test_description='test receive-report hook'\n     +\n     +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n     +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n    @@ t/t5412-report-hook.sh (new)\n     +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n     +\tgit init --bare upstream &&\n     +\n    -+\ttest_hook -C upstream --setup report <<-\\EOF &&\n    ++\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n     +\tcat\n     +\tEOF\n     +\n    @@ t/t5412-report-hook.sh (new)\n     +\tgit -C workbench remote add origin ../upstream &&\n     +\tgit -C workbench push origin $A:refs/heads/main &&\n     +\n    -+\ttest_hook -C upstream --setup report <<-\\EOF &&\n    ++\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n     +\texit 1\n     +\tEOF\n     +\n    @@ t/t5412-report-hook.sh (new)\n     +\tmake_user_friendly_and_stable_output <out >actual &&\n     +\tcat >expect <<-\\EOF &&\n     +\tTo ../upstream\n    -+\t ! [remote rejected] <COMMIT-B> -> main (report hook failed)\n    ++\t ! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)\n     +\tEOF\n     +\ttest_cmp expect actual\n     +'\n    @@ t/t5412-report-hook.sh (new)\n     +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n     +\n     +\tgit init --bare upstream &&\n    -+\ttest_hook -C upstream --setup report <<-EOF &&\n    ++\ttest_hook -C upstream --setup receive-report <<-EOF &&\n     +\ttee raw\n     +\tEOF\n     +\n    @@ t/t5412-report-hook.sh (new)\n     +\tgit -C workbench remote add origin ../upstream &&\n     +\tgit -C workbench push origin $A:refs/heads/main &&\n     +\n    -+\ttest_hook -C upstream --setup report <<-\\EOF &&\n    ++\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n     +\ttest-tool pkt-line unpack |\n     +\tsed \"s/^ok /ng /\" |\n     +\ttest-tool pkt-line pack\n    @@ t/t5412-report-hook.sh (new)\n     +\tgit -C workbench remote add origin ../upstream &&\n     +\tgit -C workbench push origin $A:refs/heads/main &&\n     +\n    -+\ttest_hook -C upstream --setup report <<-\\EOF &&\n    ++\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n     +\techo \"push rejected: service X is down\" >&2\n     +\ttest-tool pkt-line unpack |\n     +\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n    @@ t/t5412-report-hook.sh (new)\n     +\tgit -C workbench remote add origin ../upstream &&\n     +\tgit -C workbench push origin $A:refs/heads/main &&\n     +\n    -+\ttest_hook -C upstream --setup report <<-\\EOF &&\n    ++\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n     +\techo \"push rejected: service X is down\" >&2\n     +\ttee raw\n     +\tEOF\n    @@ t/t5412-report-hook.sh (new)\n     +\tgit -C workbench remote add origin ../upstream &&\n     +\tgit -C workbench push origin $A:refs/heads/main &&\n     +\n    -+\ttest_hook -C upstream --setup report <<-\\EOF &&\n    ++\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n     +\techo \"hook-stderr-message\" >&2\n     +\texit 1\n     +\tEOF\n\n---\nbase-commit: 11c6700f10234578d10523faf35656ca491425c9\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"551273","messageId":"20260826-758-introduce-hook-v4-1-6b14975ad957@gmail.com","threadId":"66186","inReplyTo":"20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com","subject":"[PATCH v4 1/3] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-26T10:19:37Z","receivedAt":"2026-08-26T10:19:56Z","isPatch":true,"body":"The 'Documentation/git-receive-pack.adoc' contains documentation about\nhooks which lie in the lifecycle of 'git-receive-pack(1)'. Unfortunately\nit is missing information about the 'proc-receive' hook. Add it.\n\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc | 6 ++++++\n 1 file changed, 6 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..4349487e6a 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,12 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by 'git-receive-pack' when it processes push\n+requests. It handles refs whose names match the patterns defined by\n+`receive.procReceiveRefs` and executes the actual ref updates. See\n+linkgit:githooks[5] for the full protocol description.\n \n QUARANTINE ENVIRONMENT\n ----------------------\n\n-- \n2.55.GIT\n\n"},{"id":"551274","messageId":"20260826-758-introduce-hook-v4-2-6b14975ad957@gmail.com","threadId":"66186","inReplyTo":"20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com","subject":"[PATCH v4 2/3] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-26T10:19:38Z","receivedAt":"2026-08-26T10:19:57Z","isPatch":true,"body":"Post the reference transaction, both `report()` and `report_v2()`\ngenerate the message to be sent to the client. In v2, we also add\nreports for each reference if available. Since they share common code,\nmove them to a common function. This will also help the following\ncommit, where we will need to regenerate the message during hook\nfailure.\n\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 84 ++++++++++++++++++++++++++------------------------\n 1 file changed, 44 insertions(+), 40 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 86933d8d7e..70a686c142 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2530,67 +2530,71 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n-static void report(struct command *commands, const char *unpack_status)\n+/*\n+ * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n+ * For v2 protocol, set `add_reports` to true, which will also add additional\n+ * report per reference update.\n+ */\n+static void generate_response(struct strbuf *buf, struct command *commands,\n+\t\t\t      const char *unpack_status, bool add_reports)\n {\n \tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n+\tpacket_buf_write(buf, \"unpack %s\\n\",\n \t\t\t unpack_status ? unpack_status : \"ok\");\n-\tfor (cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (!cmd->error_string)\n-\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t\t cmd->ref_name);\n-\t\telse\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name, cmd->error_string);\n-\t}\n-\tpacket_buf_flush(&buf);\n-\n-\tif (use_sideband)\n-\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n-\telse\n-\t\twrite_or_die(1, buf.buf, buf.len);\n-\tstrbuf_release(&buf);\n-}\n-\n-static void report_v2(struct command *commands, const char *unpack_status)\n-{\n-\tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n-\tstruct ref_push_report *report;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n-\t\t\t unpack_status ? unpack_status : \"ok\");\n \tfor (cmd = commands; cmd; cmd = cmd->next) {\n+\t\tstruct ref_push_report *report;\n \t\tint count = 0;\n \n-\t\tif (cmd->error_string) {\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name,\n-\t\t\t\t\t cmd->error_string);\n+\t\tif (cmd->error_string)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse\n+\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n+\n+\t\tif (!add_reports || cmd->error_string)\n \t\t\tcontinue;\n-\t\t}\n-\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t cmd->ref_name);\n+\n \t\tfor (report = cmd->report; report; report = report->next) {\n \t\t\tif (count++ > 0)\n-\t\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"ok %s\\n\",\n \t\t\t\t\t\t cmd->ref_name);\n \t\t\tif (report->ref_name)\n-\t\t\t\tpacket_buf_write(&buf, \"option refname %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option refname %s\\n\",\n \t\t\t\t\t\t report->ref_name);\n \t\t\tif (report->old_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option old-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option old-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->old_oid));\n \t\t\tif (report->new_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option new-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option new-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->new_oid));\n \t\t\tif (report->forced_update)\n-\t\t\t\tpacket_buf_write(&buf, \"option forced-update\\n\");\n+\t\t\t\tpacket_buf_write(buf, \"option forced-update\\n\");\n \t\t}\n \t}\n-\tpacket_buf_flush(&buf);\n+\n+\tpacket_buf_flush(buf);\n+}\n+\n+static void report(struct command *commands, const char *unpack_status)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_response(&buf, commands, unpack_status, false);\n+\n+\tif (use_sideband)\n+\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n+\telse\n+\t\twrite_or_die(1, buf.buf, buf.len);\n+\tstrbuf_release(&buf);\n+}\n+\n+static void report_v2(struct command *commands, const char *unpack_status)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_response(&buf, commands, unpack_status, true);\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n\n-- \n2.55.GIT\n\n"},{"id":"551275","messageId":"20260826-758-introduce-hook-v4-3-6b14975ad957@gmail.com","threadId":"66186","inReplyTo":"20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com","subject":"[PATCH v4 3/3] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-26T10:19:39Z","receivedAt":"2026-08-26T10:19:58Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook cannot be used by us because we care\n    about the phase where it was committed already. And while the hook\n    fires in that phase, it does not allow the caller to modify the\n    result in any capacity.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'receive-report' hook. The hook receives the complete\npkt-line encoded status report on standard input, after all ref updates\nhave been applied to the repository by execute_commands() but before the\nreport is sent to the client. See linkgit:gitprotocol-pack[5] details on\nthe protocol structure.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviors depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n  all references to be rejected with a 'receive-report hook failed'\n  error.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nNote that in either failure mode, ref updates already applied by\nexecute_commands() are not rolled back. The hook can cause the client\nto perceive the push as failed, but cannot undo server-side changes.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc |   9 ++\n Documentation/githooks.adoc         |  43 ++++++++\n builtin/receive-pack.c              |  61 ++++++++++-\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 200 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 310 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 4349487e6a..f2d52b7df2 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -243,6 +243,15 @@ requests. It handles refs whose names match the patterns defined by\n `receive.procReceiveRefs` and executes the actual ref updates. See\n linkgit:githooks[5] for the full protocol description.\n \n+RECEIVE-REPORT HOOK\n+-------------------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client. Allowing the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n+\n QUARANTINE ENVIRONMENT\n ----------------------\n \ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..e83ebde667 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,49 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+receive-report\n+~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after all\n+accepted ref changes are applied to the repository, but before the\n+pkt-line encoded status report is sent back to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input, see linkgit:gitprotocol-pack[5] for details on the\n+structure. The hook's standard output entirely replaces the report\n+that is sent to the client. The hook must write a valid pkt-line\n+encoded report in the same format it received. The hook's stdout is\n+fully buffered by `receive-pack` before any data is sent to the client,\n+so the hook's exit status is known before the client receives anything.\n+\n+There are two distinct ways the hook can affect the push outcome:\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname> <reason>` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  modifies all references to be rejected with a 'receive-report hook\n+  failed' error.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 70a686c142..1358285589 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_receive_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"receive-report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"receive-report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2534,9 +2569,12 @@ static void update_shallow_info(struct command *commands,\n  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n  * For v2 protocol, set `add_reports` to true, which will also add additional\n  * report per reference update.\n+ * If `ref_error` is set, then all references will be rejected with the given\n+ * error message.\n  */\n static void generate_response(struct strbuf *buf, struct command *commands,\n-\t\t\t      const char *unpack_status, bool add_reports)\n+\t\t\t      const char *unpack_status, bool add_reports,\n+\t\t\t      const char *ref_error)\n {\n \tstruct command *cmd;\n \n@@ -2550,10 +2588,13 @@ static void generate_response(struct strbuf *buf, struct command *commands,\n \t\tif (cmd->error_string)\n \t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n \t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse if (ref_error)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, ref_error);\n \t\telse\n \t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n \n-\t\tif (!add_reports || cmd->error_string)\n+\t\tif (!add_reports || cmd->error_string || ref_error)\n \t\t\tcontinue;\n \n \t\tfor (report = cmd->report; report; report = report->next) {\n@@ -2581,7 +2622,13 @@ static void report(struct command *commands, const char *unpack_status)\n {\n \tstruct strbuf buf = STRBUF_INIT;\n \n-\tgenerate_response(&buf, commands, unpack_status, false);\n+\tgenerate_response(&buf, commands, unpack_status, false, NULL);\n+\n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\tgenerate_response(&buf, commands, unpack_status, false,\n+\t\t\t\t  \"receive-report hook failed\");\n+\t}\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n@@ -2594,7 +2641,13 @@ static void report_v2(struct command *commands, const char *unpack_status)\n {\n \tstruct strbuf buf = STRBUF_INIT;\n \n-\tgenerate_response(&buf, commands, unpack_status, true);\n+\tgenerate_response(&buf, commands, unpack_status, true, NULL);\n+\n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\tgenerate_response(&buf, commands, unpack_status, true,\n+\t\t\t  \"receive-report hook failed\");\n+\t}\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\ndiff --git a/t/meson.build b/t/meson.build\nindex a25f37d2f5..7088c2c1c1 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -651,6 +651,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-receive-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-receive-report-hook.sh b/t/t5412-receive-report-hook.sh\nnew file mode 100755\nindex 0000000000..1ba188e964\n--- /dev/null\n+++ b/t/t5412-receive-report-hook.sh\n@@ -0,0 +1,200 @@\n+#!/bin/sh\n+\n+test_description='test receive-report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit reports as hook failed\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup receive-report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n-- \n2.55.GIT\n\n"},{"id":"551288","messageId":"xmqqbjapj69l.fsf@gitster.g","threadId":"66186","inReplyTo":"CAOLa=ZTN_95gsySKqA6Tm2daaKYNcM+V-sPeBLup3vDr1BznYw@mail.gmail.com","subject":"Re: [PATCH v3 0/3] hook: introduce the report hook for git-receive-pack(1)","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-26T14:39:02Z","receivedAt":"2026-08-26T14:39:05Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n>> This has some interaction with Justin's pluggable writes series.\n>> Please help sanity check the conflict resolution I did near the tip\n>> of 'seen' when I push the integration results out later today.\n>>\n>> Thanks.\n>\n> I had a look at the merge and it looks good! Thanks\n\nThanks.\n"},{"id":"551501","messageId":"apUi5iUeNUkqVa1L@pks.im","threadId":"66186","inReplyTo":"20260826-758-introduce-hook-v4-1-6b14975ad957@gmail.com","subject":"Re: [PATCH v4 1/3] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-08-31T06:44:54Z","receivedAt":"2026-08-31T06:45:05Z","isPatch":true,"body":"On Wed, Aug 26, 2026 at 12:19:37PM +0200, Karthik Nayak wrote:\n> The 'Documentation/git-receive-pack.adoc' contains documentation about\n> hooks which lie in the lifecycle of 'git-receive-pack(1)'. Unfortunately\n> it is missing information about the 'proc-receive' hook. Add it.\n\nI think this reads a tiny bit awkward. How about the following instead:\n\n  The manpage of git-receive-pack(1) documents hooks invoked when\n  receiving a push. The manpage doe snot mention the 'proc-receive' hook\n  though, which is also invoked as part of that process. Add a paragraph\n  about this hook to plug that gap.\n\n> diff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\n> index 0956086d61..4349487e6a 100644\n> --- a/Documentation/git-receive-pack.adoc\n> +++ b/Documentation/git-receive-pack.adoc\n> @@ -236,6 +236,12 @@ if the repository is packed and is served via a dumb transport.\n>  exec git update-server-info\n>  ----\n>  \n> +PROC-RECEIVE HOOK\n> +-----------------\n> +This hook is invoked by 'git-receive-pack' when it processes push\n\ns/'git-receive-pack'/linkgit:git-receive-pack[1]/\n\n> +requests. It handles refs whose names match the patterns defined by\n> +`receive.procReceiveRefs` and executes the actual ref updates. See\n> +linkgit:githooks[5] for the full protocol description.\n\nInstead of reinventing the wheel, we could also just copy the first\nparagraph of githooks(5):\n\n  This hook is invoked by git-receive-pack(1). If the server has set the\n  multi-valued config variable receive.procReceiveRefs, and the commands\n  sent to receive-pack have matching reference names, these commands\n  will be executed by this hook, instead of by the internal\n  execute_commands() function. This hook is responsible for updating the\n  relevant references and reporting the results back to receive-pack.\n\nI think this is quite a good summary of what it does, and for everything\nelse we can then still provide the link to the manpage.\n\nPatrick\n"},{"id":"551502","messageId":"apUi62Q_0CFBbBVO@pks.im","threadId":"66186","inReplyTo":"20260826-758-introduce-hook-v4-2-6b14975ad957@gmail.com","subject":"Re: [PATCH v4 2/3] receive-pack: move message generation to separate function","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-08-31T06:44:59Z","receivedAt":"2026-08-31T06:45:07Z","isPatch":true,"body":"On Wed, Aug 26, 2026 at 12:19:38PM +0200, Karthik Nayak wrote:\n> Post the reference transaction, both `report()` and `report_v2()`\n> generate the message to be sent to the client. In v2, we also add\n> reports for each reference if available.\n> \n> Since they share common code,\n> move them to a common function. This will also help the following\n> commit, where we will need to regenerate the message during hook\n> failure.\n\nHow about this instead:\n\n  After git-receive-pack(1) has committed the reference updates, we call\n  either `report()` or `report_v2()` to report to the client which of\n  the references we have updated successfully and which updates have\n  failed. The only difference between those two functions is that the\n  latter also knows to provide a more detailed report about how exactly\n  a given reference was updated.\n\n  In the next commit we're about to add another site that wants to\n  generate these reports. Refactor the logic into a shared function that\n  can easily be reused.\n\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index 86933d8d7e..70a686c142 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -2530,67 +2530,71 @@ static void update_shallow_info(struct command *commands,\n>  \tfree(ref_status);\n>  }\n>  \n> -static void report(struct command *commands, const char *unpack_status)\n> +/*\n> + * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n> + * For v2 protocol, set `add_reports` to true, which will also add additional\n> + * report per reference update.\n> + */\n> +static void generate_response(struct strbuf *buf, struct command *commands,\n> +\t\t\t      const char *unpack_status, bool add_reports)\n\nResponse sounds quite generic, so should this be renamed to\n`generate_report()` instead? If so, we could adapt the parameter to\n`detailed_reports` or somesuch thing.\n\nOther than that this patch looks good to me.\n\nPatrick\n"},{"id":"551503","messageId":"apUi8I-b69XxDAYY@pks.im","threadId":"66186","inReplyTo":"20260826-758-introduce-hook-v4-3-6b14975ad957@gmail.com","subject":"Re: [PATCH v4 3/3] hook: introduce the receive-report hook","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-08-31T06:45:04Z","receivedAt":"2026-08-31T06:45:12Z","isPatch":true,"body":"On Wed, Aug 26, 2026 at 12:19:39PM +0200, Karthik Nayak wrote:\n> diff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\n> index 4349487e6a..f2d52b7df2 100644\n> --- a/Documentation/git-receive-pack.adoc\n> +++ b/Documentation/git-receive-pack.adoc\n> @@ -243,6 +243,15 @@ requests. It handles refs whose names match the patterns defined by\n>  `receive.procReceiveRefs` and executes the actual ref updates. See\n>  linkgit:githooks[5] for the full protocol description.\n>  \n> +RECEIVE-REPORT HOOK\n> +-------------------\n> +This hook is invoked by 'git-receive-pack' after all the ref updates\n> +have been applied but before the report is sent to the client. The hook\n> +receives the complete report in pkt-line format on stdin and its stdout\n> +replaces the report sent to the client. Allowing the hook to rewrite\n\ns/\\. Allowing/, which allows/\n\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index ed045940d1..e83ebde667 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -527,6 +527,49 @@ The exit status of the hook is ignored for any state except for the\n>  status will cause the transaction to be aborted. The hook will not be\n>  called with \"aborted\" state in that case.\n>  \n> +receive-report\n> +~~~~~~~~~~~~~~\n> +\n> +This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n> +`git push` and updates references in its repository. It executes on\n> +the repository once after all refs have been updated and after all\n> +accepted ref changes are applied to the repository, but before the\n> +pkt-line encoded status report is sent back to the client.\n> +\n> +The hook receives the complete pkt-line encoded status report on\n> +standard input, see linkgit:gitprotocol-pack[5] for details on the\n> +structure. The hook's standard output entirely replaces the report\n> +that is sent to the client. The hook must write a valid pkt-line\n> +encoded report in the same format it received. The hook's stdout is\n> +fully buffered by `receive-pack` before any data is sent to the client,\n> +so the hook's exit status is known before the client receives anything.\n> +\n> +There are two distinct ways the hook can affect the push outcome:\n\nAren't there three? The hook can also update the \"unpack\" status to\nindicate failure.\n\n> +* To reject individual ref updates while keeping `receive-pack` alive,\n> +  rewrite the corresponding `ok <refname>` lines to\n> +  `ng <refname> <reason>` lines in the output and exit with status 0.\n\ns/ <reason>/[ <reason>]/\n\n> +  The client will then mark those specific refs as rejected while\n> +  treating any `ok` refs as successful. The push as a whole is\n> +  considered failed if any ref is `ng`, and `git push` will exit with\n> +  a non-zero status on the client side.\n> +\n> +* To abort the entire push unconditionally, exit with a non-zero\n> +  status. In this case the hook's stdout is discarded, `receive-pack`\n> +  modifies all references to be rejected with a 'receive-report hook\n\nYup, I think this is a lot more sensible.\n\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index 70a686c142..1358285589 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -2534,9 +2569,12 @@ static void update_shallow_info(struct command *commands,\n>   * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n>   * For v2 protocol, set `add_reports` to true, which will also add additional\n>   * report per reference update.\n> + * If `ref_error` is set, then all references will be rejected with the given\n> + * error message.\n>   */\n>  static void generate_response(struct strbuf *buf, struct command *commands,\n> -\t\t\t      const char *unpack_status, bool add_reports)\n> +\t\t\t      const char *unpack_status, bool add_reports,\n> +\t\t\t      const char *ref_error)\n>  {\n>  \tstruct command *cmd;\n>  \n> @@ -2550,10 +2588,13 @@ static void generate_response(struct strbuf *buf, struct command *commands,\n>  \t\tif (cmd->error_string)\n>  \t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n>  \t\t\t\t\t cmd->ref_name, cmd->error_string);\n> +\t\telse if (ref_error)\n> +\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n> +\t\t\t\t\t cmd->ref_name, ref_error);\n\nPrecedence is a bit weird here, as I would have expected the explicit\nerror to override the implicit per-command ones. It also raises the\nquestion whether it's correct to retain any populated error strings in\nfavor of updating everything to \"receive-report hook failed\".\n\nThis makes me wonder whetther it would be preferable to update the\n`cmd->error_string`s instead of adding this new parameter?\n\nThanks!\n\nPatrick\n"},{"id":"551589","messageId":"CAOLa=ZQnQ7QWmWtk5Pg6rbkPunDqFYrB_d==kfkpzqt046kCpw@mail.gmail.com","threadId":"66186","inReplyTo":"apUi5iUeNUkqVa1L@pks.im","subject":"Re: [PATCH v4 1/3] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-31T18:22:35Z","receivedAt":"2026-08-31T18:22:38Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Wed, Aug 26, 2026 at 12:19:37PM +0200, Karthik Nayak wrote:\n>> The 'Documentation/git-receive-pack.adoc' contains documentation about\n>> hooks which lie in the lifecycle of 'git-receive-pack(1)'. Unfortunately\n>> it is missing information about the 'proc-receive' hook. Add it.\n>\n> I think this reads a tiny bit awkward. How about the following instead:\n>\n>   The manpage of git-receive-pack(1) documents hooks invoked when\n>   receiving a push. The manpage doe snot mention the 'proc-receive' hook\n>   though, which is also invoked as part of that process. Add a paragraph\n>   about this hook to plug that gap.\n>\n\nThis reads better, thanks\n\n>> diff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\n>> index 0956086d61..4349487e6a 100644\n>> --- a/Documentation/git-receive-pack.adoc\n>> +++ b/Documentation/git-receive-pack.adoc\n>> @@ -236,6 +236,12 @@ if the repository is packed and is served via a dumb transport.\n>>  exec git update-server-info\n>>  ----\n>>\n>> +PROC-RECEIVE HOOK\n>> +-----------------\n>> +This hook is invoked by 'git-receive-pack' when it processes push\n>\n> s/'git-receive-pack'/linkgit:git-receive-pack[1]/\n>\n>> +requests. It handles refs whose names match the patterns defined by\n>> +`receive.procReceiveRefs` and executes the actual ref updates. See\n>> +linkgit:githooks[5] for the full protocol description.\n>\n> Instead of reinventing the wheel, we could also just copy the first\n> paragraph of githooks(5):\n>\n>   This hook is invoked by git-receive-pack(1). If the server has set the\n>   multi-valued config variable receive.procReceiveRefs, and the commands\n>   sent to receive-pack have matching reference names, these commands\n>   will be executed by this hook, instead of by the internal\n>   execute_commands() function. This hook is responsible for updating the\n>   relevant references and reporting the results back to receive-pack.\n>\n> I think this is quite a good summary of what it does, and for everything\n> else we can then still provide the link to the manpage.\n>\n> Patrick\n\nFair enough, I'll just use this. Thanks\n"},{"id":"551595","messageId":"CAOLa=ZTrj9LRFHDXTYw-fNvmA5qOZrrU-8d-3aY1NB2J_zib5g@mail.gmail.com","threadId":"66186","inReplyTo":"apUi62Q_0CFBbBVO@pks.im","subject":"Re: [PATCH v4 2/3] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-08-31T19:05:35Z","receivedAt":"2026-08-31T19:05:41Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Wed, Aug 26, 2026 at 12:19:38PM +0200, Karthik Nayak wrote:\n>> Post the reference transaction, both `report()` and `report_v2()`\n>> generate the message to be sent to the client. In v2, we also add\n>> reports for each reference if available.\n>>\n>> Since they share common code,\n>> move them to a common function. This will also help the following\n>> commit, where we will need to regenerate the message during hook\n>> failure.\n>\n> How about this instead:\n>\n>   After git-receive-pack(1) has committed the reference updates, we call\n>   either `report()` or `report_v2()` to report to the client which of\n>   the references we have updated successfully and which updates have\n>   failed. The only difference between those two functions is that the\n>   latter also knows to provide a more detailed report about how exactly\n>   a given reference was updated.\n>\n>   In the next commit we're about to add another site that wants to\n>   generate these reports. Refactor the logic into a shared function that\n>   can easily be reused.\n>\n\nreads better, will swap in.\n\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>> index 86933d8d7e..70a686c142 100644\n>> --- a/builtin/receive-pack.c\n>> +++ b/builtin/receive-pack.c\n>> @@ -2530,67 +2530,71 @@ static void update_shallow_info(struct command *commands,\n>>  \tfree(ref_status);\n>>  }\n>>\n>> -static void report(struct command *commands, const char *unpack_status)\n>> +/*\n>> + * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n>> + * For v2 protocol, set `add_reports` to true, which will also add additional\n>> + * report per reference update.\n>> + */\n>> +static void generate_response(struct strbuf *buf, struct command *commands,\n>> +\t\t\t      const char *unpack_status, bool add_reports)\n>\n> Response sounds quite generic, so should this be renamed to\n> `generate_report()` instead? If so, we could adapt the parameter to\n> `detailed_reports` or somesuch thing.\n>\n> Other than that this patch looks good to me.\n>\n> Patrick\n\nYeah, I was thinking about it being too generic but didn't pay much\nheed, since you also think the same, I'll change it.\n\nThanks\n"},{"id":"551675","messageId":"20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v5 0/3] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-01T15:19:22Z","receivedAt":"2026-09-01T15:19:31Z","isPatch":true,"body":"Introduce a new receive-report hook which kicks in after the reference\ntransaction is complete, but before the report is sent to the client.\nThe hook receives the pkt-line encoded report in its stdin and its\nstdout replaces the report transferred to the user. If the hook exits\nwith a non-zero exit code, all references are marked as rejected.\n\nThe first patch, adds missing documentation to 'git-receive-pack.adoc'.\nThe second patch refactors code and the third patch contains the new\nhook.\n\n---\nChanges in v5:\n- Rewrote some of the commit messages and documentation.\n- Renamed the function `generate_response` to `generate_report` to avoid\n  ambiguity.\n- We now override the cmd's error_strings, this avoids the whole\n  precedence issue with the earlier series.\n- Also add information about how we can override the unpack status to\n  fail the push and add a corresponding test.\n- Thanks to Patrick for the review!\n- Junio: This causes conflict with next ('jt/receive-pack-pluggable-writes')\n  similar to before, please let me know if its better for me to add that\n  dependency.\n- Link to v4: https://patch.msgid.link/20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com\n\nChanges in v4:\n- Change the name of the hook to be 'receive-report' to avoid ambiguity.\n- Link to v3: https://patch.msgid.link/20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com\n\nChanges in v3:\n- Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n  into a new commit.\n- Add a new commit to move out the response generation in receive-pack\n  to a new function.\n- Instead of die-ing on non-zero exit code, we modify each reference to\n  indicate that the hook failed.\n- Instead of correctly listing out the protocol, link to\n  linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n  and v2.\n- Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n\n To: git@vger.kernel.org\n CC: ps@pks.im\n CC: gitster@pobox.com\n CC: jltobler@gmail.com\n CC: kristofferhaugsbakk@fastmail.com\n CC: phillip.wood123@gmail.com\n\n---\nKarthik Nayak (3):\n      doc: add proc-receive hook info in 'git-receive-pack.adoc'\n      receive-pack: move message generation to separate function\n      hook: introduce the receive-report hook\n\n Documentation/git-receive-pack.adoc |  17 +++\n Documentation/githooks.adoc         |  47 ++++++++\n builtin/receive-pack.c              | 132 +++++++++++++++------\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 384 insertions(+), 37 deletions(-)\n\nRange-diff versus v4:\n\n1:  6b173f391d < -:  ---------- doc: add proc-receive hook info in 'git-receive-pack.adoc'\n-:  ---------- > 1:  cb32302829 doc: add proc-receive hook info in 'git-receive-pack.adoc'\n2:  24e3e651ff ! 2:  ad3394490e receive-pack: move message generation to separate function\n    @@ Metadata\n      ## Commit message ##\n         receive-pack: move message generation to separate function\n     \n    -    Post the reference transaction, both `report()` and `report_v2()`\n    -    generate the message to be sent to the client. In v2, we also add\n    -    reports for each reference if available. Since they share common code,\n    -    move them to a common function. This will also help the following\n    -    commit, where we will need to regenerate the message during hook\n    -    failure.\n    +    After git-receive-pack(1) has committed the reference updates, we call\n    +    either `report()` or `report_v2()` to report to the client which of the\n    +    references we have updated successfully and which updates have failed.\n    +    The only difference between those two functions is that the latter also\n    +    knows to provide a more detailed report about how exactly a given\n    +    reference was updated.\n     \n    +    In the next commit we're about to add another site that wants to\n    +    generate these reports. Refactor the logic into a shared function that\n    +    can easily be reused.\n    +\n    +    Helped-by: Patrick Steinhardt <ps@pks.im>\n         Signed-off-by: Karthik Nayak <karthik.188@gmail.com>\n     \n      ## builtin/receive-pack.c ##\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     -static void report(struct command *commands, const char *unpack_status)\n     +/*\n     + * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n    -+ * For v2 protocol, set `add_reports` to true, which will also add additional\n    ++ * For v2 protocol, set `detailed_report` to true, which will also add detailed\n     + * report per reference update.\n     + */\n    -+static void generate_response(struct strbuf *buf, struct command *commands,\n    -+\t\t\t      const char *unpack_status, bool add_reports)\n    ++static void generate_report(struct strbuf *buf, struct command *commands,\n    ++\t\t\t    const char *unpack_status, bool detailed_report)\n      {\n      \tstruct command *cmd;\n     -\tstruct strbuf buf = STRBUF_INIT;\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     +\t\telse\n     +\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n     +\n    -+\t\tif (!add_reports || cmd->error_string)\n    ++\t\tif (!detailed_report || cmd->error_string)\n      \t\t\tcontinue;\n     -\t\t}\n     -\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     +{\n     +\tstruct strbuf buf = STRBUF_INIT;\n     +\n    -+\tgenerate_response(&buf, commands, unpack_status, false);\n    ++\tgenerate_report(&buf, commands, unpack_status, false);\n     +\n     +\tif (use_sideband)\n     +\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     +{\n     +\tstruct strbuf buf = STRBUF_INIT;\n     +\n    -+\tgenerate_response(&buf, commands, unpack_status, true);\n    ++\tgenerate_report(&buf, commands, unpack_status, true);\n      \n      \tif (use_sideband)\n      \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n3:  d6c98d2693 ! 3:  a24ca1141d hook: introduce the receive-report hook\n    @@ Commit message\n         Signed-off-by: Karthik Nayak <karthik.188@gmail.com>\n     \n      ## Documentation/git-receive-pack.adoc ##\n    -@@ Documentation/git-receive-pack.adoc: requests. It handles refs whose names match the patterns defined by\n    - `receive.procReceiveRefs` and executes the actual ref updates. See\n    - linkgit:githooks[5] for the full protocol description.\n    +@@ Documentation/git-receive-pack.adoc: commands will be executed by this hook, instead of by the internal\n    + `execute_commands()` function.  This hook is responsible for updating\n    + the relevant references and reporting the results back to 'receive-pack'.\n      \n     +RECEIVE-REPORT HOOK\n     +-------------------\n     +This hook is invoked by 'git-receive-pack' after all the ref updates\n     +have been applied but before the report is sent to the client. The hook\n     +receives the complete report in pkt-line format on stdin and its stdout\n    -+replaces the report sent to the client. Allowing the hook to rewrite\n    ++replaces the report sent to the client, which allows the hook to rewrite\n     +the outcomes or abort the push completely. See linkgit:githooks[5] for\n     +the full protocol description.\n     +\n    @@ Documentation/githooks.adoc: The exit status of the hook is ignored for any stat\n     +fully buffered by `receive-pack` before any data is sent to the client,\n     +so the hook's exit status is known before the client receives anything.\n     +\n    -+There are two distinct ways the hook can affect the push outcome:\n    ++There are three distinct ways the hook can affect the push outcome:\n    ++\n    ++* To reject the push, modify the unpack status from `ok` to the required\n    ++  error message. While `git-push` will fail, individual references may\n    ++  still show success messages unless modified.\n     +\n     +* To reject individual ref updates while keeping `receive-pack` alive,\n     +  rewrite the corresponding `ok <refname>` lines to\n    -+  `ng <refname> <reason>` lines in the output and exit with status 0.\n    ++  `ng <refname>[ <reason>]` lines in the output and exit with status 0.\n     +  The client will then mark those specific refs as rejected while\n     +  treating any `ok` refs as successful. The push as a whole is\n     +  considered failed if any ref is `ng`, and `git push` will exit with\n    @@ builtin/receive-pack.c: static int run_update_hook(struct command *cmd)\n      \t\t\t\t\t       const char *refname)\n      {\n     @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands,\n    -  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n    -  * For v2 protocol, set `add_reports` to true, which will also add additional\n    -  * report per reference update.\n    -+ * If `ref_error` is set, then all references will be rejected with the given\n    -+ * error message.\n    -  */\n    - static void generate_response(struct strbuf *buf, struct command *commands,\n    --\t\t\t      const char *unpack_status, bool add_reports)\n    -+\t\t\t      const char *unpack_status, bool add_reports,\n    -+\t\t\t      const char *ref_error)\n    - {\n    - \tstruct command *cmd;\n    - \n    -@@ builtin/receive-pack.c: static void generate_response(struct strbuf *buf, struct command *commands,\n    - \t\tif (cmd->error_string)\n    - \t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n    - \t\t\t\t\t cmd->ref_name, cmd->error_string);\n    -+\t\telse if (ref_error)\n    -+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n    -+\t\t\t\t\t cmd->ref_name, ref_error);\n    - \t\telse\n    - \t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n    - \n    --\t\tif (!add_reports || cmd->error_string)\n    -+\t\tif (!add_reports || cmd->error_string || ref_error)\n    - \t\t\tcontinue;\n    + \tfree(ref_status);\n    + }\n      \n    - \t\tfor (report = cmd->report; report; report = report->next) {\n    ++static void override_cmds_error(struct command *commands, const char *err)\n    ++{\n    ++\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n    ++\t\tcmd->error_string = err;\n    ++\t}\n    ++}\n    ++\n    + /*\n    +  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n    +  * For v2 protocol, set `detailed_report` to true, which will also add detailed\n     @@ builtin/receive-pack.c: static void report(struct command *commands, const char *unpack_status)\n    - {\n    - \tstruct strbuf buf = STRBUF_INIT;\n      \n    --\tgenerate_response(&buf, commands, unpack_status, false);\n    -+\tgenerate_response(&buf, commands, unpack_status, false, NULL);\n    -+\n    + \tgenerate_report(&buf, commands, unpack_status, false);\n    + \n     +\tif (run_receive_report_hook(&buf)) {\n     +\t\tstrbuf_reset(&buf);\n    -+\t\tgenerate_response(&buf, commands, unpack_status, false,\n    -+\t\t\t\t  \"receive-report hook failed\");\n    ++\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n    ++\t\tgenerate_report(&buf, commands, unpack_status, false);\n     +\t}\n    - \n    ++\n      \tif (use_sideband)\n      \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    + \telse\n     @@ builtin/receive-pack.c: static void report_v2(struct command *commands, const char *unpack_status)\n    - {\n    - \tstruct strbuf buf = STRBUF_INIT;\n      \n    --\tgenerate_response(&buf, commands, unpack_status, true);\n    -+\tgenerate_response(&buf, commands, unpack_status, true, NULL);\n    -+\n    + \tgenerate_report(&buf, commands, unpack_status, true);\n    + \n     +\tif (run_receive_report_hook(&buf)) {\n     +\t\tstrbuf_reset(&buf);\n    -+\t\tgenerate_response(&buf, commands, unpack_status, true,\n    -+\t\t\t  \"receive-report hook failed\");\n    ++\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n    ++\t\tgenerate_report(&buf, commands, unpack_status, true);\n     +\t}\n    - \n    ++\n      \tif (use_sideband)\n      \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    + \telse\n     \n      ## t/meson.build ##\n     @@ t/meson.build: integration_tests = [\n    @@ t/t5412-receive-report-hook.sh (new)\n     +\ttest_cmp expect actual\n     +'\n     +\n    ++test_expect_success \"hook can modify the unpack status\" '\n    ++\ttest_when_finished \"rm -rf upstream\" &&\n    ++\ttest_when_finished \"git -C workbench remote remove origin\" &&\n    ++\n    ++\tgit init --bare upstream &&\n    ++\tgit -C workbench remote add origin ../upstream &&\n    ++\tgit -C workbench push origin $A:refs/heads/main &&\n    ++\n    ++\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n    ++\ttest-tool pkt-line unpack |\n    ++\tsed \"s/^unpack ok$/unpack push failed due to server error/\" |\n    ++\ttest-tool pkt-line pack\n    ++\tEOF\n    ++\n    ++\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n    ++\ttest_grep \"error: remote unpack failed: push failed due to server error\" out &&\n    ++\tmake_user_friendly_and_stable_output <out >actual &&\n    ++\tcat >expect <<-\\EOF &&\n    ++\tTo ../upstream\n    ++\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n    ++\tEOF\n    ++\ttest_cmp expect actual\n    ++'\n    ++\n     +test_expect_success \"hook can report a custom failure message\" '\n     +\ttest_when_finished \"rm -rf upstream\" &&\n     +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n\n---\nbase-commit: 11c6700f10234578d10523faf35656ca491425c9\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"551676","messageId":"20260901-758-introduce-hook-v5-1-35cdc6be3cc1@gmail.com","threadId":"66186","inReplyTo":"20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com","subject":"[PATCH v5 1/3] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-01T15:19:23Z","receivedAt":"2026-09-01T15:19:32Z","isPatch":true,"body":"The manpage of git-receive-pack(1) documents hooks invoked when\nreceiving a push. The manpage does not mention the 'proc-receive' hook\nthough, which is also invoked as part of that process. Add a paragraph\nabout this hook to plug that gap.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc | 8 ++++++++\n 1 file changed, 8 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..5806792ba7 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,14 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by linkgit:git-receive-pack[1].  If the server has\n+set the multi-valued config variable `receive.procReceiveRefs`, and the\n+commands sent to 'receive-pack' have matching reference names, these\n+commands will be executed by this hook, instead of by the internal\n+`execute_commands()` function.  This hook is responsible for updating\n+the relevant references and reporting the results back to 'receive-pack'.\n \n QUARANTINE ENVIRONMENT\n ----------------------\n\n-- \n2.55.GIT\n\n"},{"id":"551677","messageId":"20260901-758-introduce-hook-v5-2-35cdc6be3cc1@gmail.com","threadId":"66186","inReplyTo":"20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com","subject":"[PATCH v5 2/3] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-01T15:19:24Z","receivedAt":"2026-09-01T15:19:34Z","isPatch":true,"body":"After git-receive-pack(1) has committed the reference updates, we call\neither `report()` or `report_v2()` to report to the client which of the\nreferences we have updated successfully and which updates have failed.\nThe only difference between those two functions is that the latter also\nknows to provide a more detailed report about how exactly a given\nreference was updated.\n\nIn the next commit we're about to add another site that wants to\ngenerate these reports. Refactor the logic into a shared function that\ncan easily be reused.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 84 ++++++++++++++++++++++++++------------------------\n 1 file changed, 44 insertions(+), 40 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 86933d8d7e..34d5e46097 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2530,67 +2530,71 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n-static void report(struct command *commands, const char *unpack_status)\n+/*\n+ * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n+ * For v2 protocol, set `detailed_report` to true, which will also add detailed\n+ * report per reference update.\n+ */\n+static void generate_report(struct strbuf *buf, struct command *commands,\n+\t\t\t    const char *unpack_status, bool detailed_report)\n {\n \tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n+\tpacket_buf_write(buf, \"unpack %s\\n\",\n \t\t\t unpack_status ? unpack_status : \"ok\");\n-\tfor (cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (!cmd->error_string)\n-\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t\t cmd->ref_name);\n-\t\telse\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name, cmd->error_string);\n-\t}\n-\tpacket_buf_flush(&buf);\n-\n-\tif (use_sideband)\n-\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n-\telse\n-\t\twrite_or_die(1, buf.buf, buf.len);\n-\tstrbuf_release(&buf);\n-}\n-\n-static void report_v2(struct command *commands, const char *unpack_status)\n-{\n-\tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n-\tstruct ref_push_report *report;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n-\t\t\t unpack_status ? unpack_status : \"ok\");\n \tfor (cmd = commands; cmd; cmd = cmd->next) {\n+\t\tstruct ref_push_report *report;\n \t\tint count = 0;\n \n-\t\tif (cmd->error_string) {\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name,\n-\t\t\t\t\t cmd->error_string);\n+\t\tif (cmd->error_string)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse\n+\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n+\n+\t\tif (!detailed_report || cmd->error_string)\n \t\t\tcontinue;\n-\t\t}\n-\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t cmd->ref_name);\n+\n \t\tfor (report = cmd->report; report; report = report->next) {\n \t\t\tif (count++ > 0)\n-\t\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"ok %s\\n\",\n \t\t\t\t\t\t cmd->ref_name);\n \t\t\tif (report->ref_name)\n-\t\t\t\tpacket_buf_write(&buf, \"option refname %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option refname %s\\n\",\n \t\t\t\t\t\t report->ref_name);\n \t\t\tif (report->old_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option old-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option old-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->old_oid));\n \t\t\tif (report->new_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option new-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option new-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->new_oid));\n \t\t\tif (report->forced_update)\n-\t\t\t\tpacket_buf_write(&buf, \"option forced-update\\n\");\n+\t\t\t\tpacket_buf_write(buf, \"option forced-update\\n\");\n \t\t}\n \t}\n-\tpacket_buf_flush(&buf);\n+\n+\tpacket_buf_flush(buf);\n+}\n+\n+static void report(struct command *commands, const char *unpack_status)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_report(&buf, commands, unpack_status, false);\n+\n+\tif (use_sideband)\n+\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n+\telse\n+\t\twrite_or_die(1, buf.buf, buf.len);\n+\tstrbuf_release(&buf);\n+}\n+\n+static void report_v2(struct command *commands, const char *unpack_status)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_report(&buf, commands, unpack_status, true);\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n\n-- \n2.55.GIT\n\n"},{"id":"551678","messageId":"20260901-758-introduce-hook-v5-3-35cdc6be3cc1@gmail.com","threadId":"66186","inReplyTo":"20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com","subject":"[PATCH v5 3/3] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-01T15:19:25Z","receivedAt":"2026-09-01T15:19:35Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook cannot be used by us because we care\n    about the phase where it was committed already. And while the hook\n    fires in that phase, it does not allow the caller to modify the\n    result in any capacity.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'receive-report' hook. The hook receives the complete\npkt-line encoded status report on standard input, after all ref updates\nhave been applied to the repository by execute_commands() but before the\nreport is sent to the client. See linkgit:gitprotocol-pack[5] details on\nthe protocol structure.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviors depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n  all references to be rejected with a 'receive-report hook failed'\n  error.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nNote that in either failure mode, ref updates already applied by\nexecute_commands() are not rolled back. The hook can cause the client\nto perceive the push as failed, but cannot undo server-side changes.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc |   9 ++\n Documentation/githooks.adoc         |  47 ++++++++\n builtin/receive-pack.c              |  54 +++++++++\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 335 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 5806792ba7..ab668ffa0c 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -245,6 +245,15 @@ commands will be executed by this hook, instead of by the internal\n `execute_commands()` function.  This hook is responsible for updating\n the relevant references and reporting the results back to 'receive-pack'.\n \n+RECEIVE-REPORT HOOK\n+-------------------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client, which allows the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n+\n QUARANTINE ENVIRONMENT\n ----------------------\n \ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..5ac5bf9454 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,53 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+receive-report\n+~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after all\n+accepted ref changes are applied to the repository, but before the\n+pkt-line encoded status report is sent back to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input, see linkgit:gitprotocol-pack[5] for details on the\n+structure. The hook's standard output entirely replaces the report\n+that is sent to the client. The hook must write a valid pkt-line\n+encoded report in the same format it received. The hook's stdout is\n+fully buffered by `receive-pack` before any data is sent to the client,\n+so the hook's exit status is known before the client receives anything.\n+\n+There are three distinct ways the hook can affect the push outcome:\n+\n+* To reject the push, modify the unpack status from `ok` to the required\n+  error message. While `git-push` will fail, individual references may\n+  still show success messages unless modified.\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname>[ <reason>]` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  modifies all references to be rejected with a 'receive-report hook\n+  failed' error.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 34d5e46097..c9f1eb3335 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1004,6 +1004,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_receive_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"receive-report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"receive-report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2530,6 +2565,13 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n+static void override_cmds_error(struct command *commands, const char *err)\n+{\n+\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n+\t\tcmd->error_string = err;\n+\t}\n+}\n+\n /*\n  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n  * For v2 protocol, set `detailed_report` to true, which will also add detailed\n@@ -2583,6 +2625,12 @@ static void report(struct command *commands, const char *unpack_status)\n \n \tgenerate_report(&buf, commands, unpack_status, false);\n \n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n+\t\tgenerate_report(&buf, commands, unpack_status, false);\n+\t}\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\n@@ -2596,6 +2644,12 @@ static void report_v2(struct command *commands, const char *unpack_status)\n \n \tgenerate_report(&buf, commands, unpack_status, true);\n \n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n+\t\tgenerate_report(&buf, commands, unpack_status, true);\n+\t}\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex a25f37d2f5..7088c2c1c1 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -651,6 +651,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-receive-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-receive-report-hook.sh b/t/t5412-receive-report-hook.sh\nnew file mode 100755\nindex 0000000000..24679de37b\n--- /dev/null\n+++ b/t/t5412-receive-report-hook.sh\n@@ -0,0 +1,224 @@\n+#!/bin/sh\n+\n+test_description='test receive-report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit reports as hook failed\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup receive-report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can modify the unpack status\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^unpack ok$/unpack push failed due to server error/\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"error: remote unpack failed: push failed due to server error\" out &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n-- \n2.55.GIT\n\n"},{"id":"551686","messageId":"xmqqbjahszxt.fsf@gitster.g","threadId":"66186","inReplyTo":"20260901-758-introduce-hook-v5-2-35cdc6be3cc1@gmail.com","subject":"Re: [PATCH v5 2/3] receive-pack: move message generation to separate function","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-01T16:23:42Z","receivedAt":"2026-09-01T16:23:46Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> After git-receive-pack(1) has committed the reference updates, we call\n> either `report()` or `report_v2()` to report to the client which of the\n> references we have updated successfully and which updates have failed.\n> The only difference between those two functions is that the latter also\n> knows to provide a more detailed report about how exactly a given\n> reference was updated.\n\nI am torn between praising \"bool detailed_report\" and frowning on\nit.  As the above describes, the difference in behaviour between\nreport() and report_v2() is if they emit details of per-command\nupdate status, so in that sense, the word \"detail\" in the name of\nthe parameter that controls how much details the shared helper\nfunction gives sounds very much appropriate.  On the other hand, the\ndifference in purpose in these two functions is which version of the\nreceive-pack protocol they speak, and \"This parameter controls how\nmuch detail the report contains\" may tempt careless developers into\nadding random new pieces of information and break existing clients.\nIt may be more honest to give it a name that hints that it is about\nthe protocol version.\n\nUsing\n\n    enum report_version {\n\treceive_pack_report_v0,\n\treceive_pack_report_v2,\n    };\n\nmight allow future extension, but it may be overkill.  I dunno.\n"},{"id":"551688","messageId":"xmqq4ig8uco1.fsf@gitster.g","threadId":"66186","inReplyTo":"20260901-758-introduce-hook-v5-3-35cdc6be3cc1@gmail.com","subject":"Re: [PATCH v5 3/3] hook: introduce the receive-report hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-01T17:03:26Z","receivedAt":"2026-09-01T17:03:30Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> We cannot use any of the existing hooks as:\n>\n>   - The pre-receive hook runs too early, as we haven't updated\n>     references at that point yet and we need to have the full view of\n>     all resulting updates (both objects and references).\n>\n>   - The update hook is too inefficient as it runs once per reference,\n>     and we cannot trivially determine the last update.\n>\n>   - The reference-transaction hook cannot be used by us because we care\n>     about the phase where it was committed already. And while the hook\n>     fires in that phase, it does not allow the caller to modify the\n>     result in any capacity.\n\nHere you explain that the reason this is not suited for your use\ncase is because it does not allow the caller to modify the result.\nThe transaction hook is notified in what phase of the reference\nupdates we are in, and what updates are planned or have happened.\nBut the hook cannot interfere to change the outcome (except it can\nmake the transaction abort as a whole in preparation phases).\n\n>   - The post-receive and post-update hooks cannot be used as they run\n>     too late, at the point where we have already reported success to the\n>     client.\n>\n> Introduce a new 'receive-report' hook. The hook receives the complete\n> pkt-line encoded status report on standard input, after all ref updates\n> have been applied to the repository by execute_commands() but before the\n> report is sent to the client. See linkgit:gitprotocol-pack[5] details on\n> the protocol structure.\n>\n> The hook's stdout fully replaces the report sent to the client.\n> receive-pack fully buffers the hook's stdout before acting on the exit\n> status, so the exit code is known before the client receives anything.\n> This gives two distinct behaviors depending on exit status:\n>\n> - Exit 0: the hook's stdout is used as the report. The hook can\n>   rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n>   client while receive-pack itself exits cleanly. The client marks\n>   rejected refs as '[remote rejected]' and exits with a non-zero\n>   status if any ref is 'ng'.\n>\n> - Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n>   all references to be rejected with a 'receive-report hook failed'\n>   error.\n\nAnd the new hook lets you pretend to the other side of the\nconnection that ref updates that happened on our side is totally\ndifferent from what actually happened, but ...\n\n> In both cases, any output the hook writes to standard error is\n> forwarded to the client over the sideband channel and appears as\n> 'remote:' lines on the client terminal. Writing to stderr alone does\n> not affect the push outcome.\n>\n> Note that in either failure mode, ref updates already applied by\n> execute_commands() are not rolled back. The hook can cause the client\n> to perceive the push as failed, but cannot undo server-side changes.\n\n... it still cannot interfere to change the outcome.  What has been\ncommitted as reference updates have happened and there is no way to\nchange it.  So the reason to reject reference-transaction hook seems\na bit weak.  The explanation I heard so far makes it sound as if it\nis an equally viable, if not even more viable, alternative to teach\nthe reference-transaction hook at the commit phase to optionally\nallow rejecting the transaction, instead of adding an entirely\ndifferent hook (note: I am not suggesting it as an alternative; I am\njust saying that the explanation is weak to support this design).\n\nIn any case, if the actual ref updates and the reported ref updates\nresult can be made different, somebody then needs to step in and\nreconcile the inconsistencies, no?\n\nThe way pusher perceives the state of their remote repository they\njust pushed to, which they learn from the output of receive-report\nhook, would have no link to reality when this hook is used on the\nremote side.  This may matter because the \"git push\" updates its own\nremote-tracking branches to match what the remote says (i.e.,\npretends as if \"git push\" was immediately followed by \"git fetch\" to\nthe same remote).\n\n"},{"id":"551742","messageId":"CAOLa=ZQavuPbk-2XAGoGxKkq4y0+x2VBQ+r64ZnAL2O3MjtvBw@mail.gmail.com","threadId":"66186","inReplyTo":"xmqqbjahszxt.fsf@gitster.g","subject":"Re: [PATCH v5 2/3] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-02T11:23:49Z","receivedAt":"2026-09-02T11:23:52Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\nr>> After git-receive-pack(1) has committed the reference updates, we call\n>> either `report()` or `report_v2()` to report to the client which of the\n>> references we have updated successfully and which updates have failed.\n>> The only difference between those two functions is that the latter also\n>> knows to provide a more detailed report about how exactly a given\n>> reference was updated.\n>\n> I am torn between praising \"bool detailed_report\" and frowning on\n> it.  As the above describes, the difference in behaviour between\n> report() and report_v2() is if they emit details of per-command\n> update status, so in that sense, the word \"detail\" in the name of\n> the parameter that controls how much details the shared helper\n> function gives sounds very much appropriate.  On the other hand, the\n> difference in purpose in these two functions is which version of the\n> receive-pack protocol they speak, and \"This parameter controls how\n> much detail the report contains\" may tempt careless developers into\n> adding random new pieces of information and break existing clients.\n> It may be more honest to give it a name that hints that it is about\n> the protocol version.\n>\n> Using\n>\n>     enum report_version {\n> \treceive_pack_report_v0,\n> \treceive_pack_report_v2,\n>     };\n>\n> might allow future extension, but it may be overkill.  I dunno.\n\nI think your reasoning does make sense and while the code is _okay_ and\nthis is probably an overkill like you mentioned, it does draw the\ndifferentiation being a protocol level change rather than a mere boolean\nwhich either protocol could use. Let me add it in if I re-roll.\n"},{"id":"551772","messageId":"CAOLa=ZTfPq3r3b7EDOSrG0-uSFQGgu-k3agPJgUV9xao8WsQrw@mail.gmail.com","threadId":"66186","inReplyTo":"xmqq4ig8uco1.fsf@gitster.g","subject":"Re: [PATCH v5 3/3] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-02T14:42:19Z","receivedAt":"2026-09-02T14:42:20Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> We cannot use any of the existing hooks as:\n>>\n>>   - The pre-receive hook runs too early, as we haven't updated\n>>     references at that point yet and we need to have the full view of\n>>     all resulting updates (both objects and references).\n>>\n>>   - The update hook is too inefficient as it runs once per reference,\n>>     and we cannot trivially determine the last update.\n>>\n>>   - The reference-transaction hook cannot be used by us because we care\n>>     about the phase where it was committed already. And while the hook\n>>     fires in that phase, it does not allow the caller to modify the\n>>     result in any capacity.\n>\n> Here you explain that the reason this is not suited for your use\n> case is because it does not allow the caller to modify the result.\n> The transaction hook is notified in what phase of the reference\n> updates we are in, and what updates are planned or have happened.\n> But the hook cannot interfere to change the outcome (except it can\n> make the transaction abort as a whole in preparation phases).\n>\n>>   - The post-receive and post-update hooks cannot be used as they run\n>>     too late, at the point where we have already reported success to the\n>>     client.\n>>\n>> Introduce a new 'receive-report' hook. The hook receives the complete\n>> pkt-line encoded status report on standard input, after all ref updates\n>> have been applied to the repository by execute_commands() but before the\n>> report is sent to the client. See linkgit:gitprotocol-pack[5] details on\n>> the protocol structure.\n>>\n>> The hook's stdout fully replaces the report sent to the client.\n>> receive-pack fully buffers the hook's stdout before acting on the exit\n>> status, so the exit code is known before the client receives anything.\n>> This gives two distinct behaviors depending on exit status:\n>>\n>> - Exit 0: the hook's stdout is used as the report. The hook can\n>>   rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n>>   client while receive-pack itself exits cleanly. The client marks\n>>   rejected refs as '[remote rejected]' and exits with a non-zero\n>>   status if any ref is 'ng'.\n>>\n>> - Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n>>   all references to be rejected with a 'receive-report hook failed'\n>>   error.\n>\n> And the new hook lets you pretend to the other side of the\n> connection that ref updates that happened on our side is totally\n> different from what actually happened, but ...\n>\n>> In both cases, any output the hook writes to standard error is\n>> forwarded to the client over the sideband channel and appears as\n>> 'remote:' lines on the client terminal. Writing to stderr alone does\n>> not affect the push outcome.\n>>\n>> Note that in either failure mode, ref updates already applied by\n>> execute_commands() are not rolled back. The hook can cause the client\n>> to perceive the push as failed, but cannot undo server-side changes.\n>\n> ... it still cannot interfere to change the outcome.  What has been\n> committed as reference updates have happened and there is no way to\n> change it.  So the reason to reject reference-transaction hook seems\n> a bit weak.  The explanation I heard so far makes it sound as if it\n> is an equally viable, if not even more viable, alternative to teach\n> the reference-transaction hook at the commit phase to optionally\n> allow rejecting the transaction, instead of adding an entirely\n> different hook (note: I am not suggesting it as an alternative; I am\n> just saying that the explanation is weak to support this design).\n>\n\nWithout getting into too much details on our specific implementation at\nGitLab, let me state the issue.\n\nThe important distinction is that in our setup a success from\n`ref_transaction_commit()` is not the final step of a push. Further\noperations run afterwards and can still fail, so at the point the\nreference-transaction hook is invoked with \"committed\", the outcome we\nneed to report is not yet known. There is no phase of that hook at which\nit could give us the answer.\n\nApart from the timing, the hook doesn't work because it has no knowledge\nof the push. It cannot express a ref push failure and cannot convey\nmessages to the client via sideband.\n\nAnd rejecting at the commit phase would need a post-commit rollback that\nnone of the backends support, besides changing behaviour for every\n\n> In any case, if the actual ref updates and the reported ref updates\n> result can be made different, somebody then needs to step in and\n> reconcile the inconsistencies, no?\n\nNaturally, the server is in charge of that, this is similar with the\npre-receive or proc-receive hooks. In that aspects this is very similar\nto the proc-receive hook which transfers the responsibility of updating\nrefs to the owner of the hook.\n\n>\n> The way pusher perceives the state of their remote repository they\n> just pushed to, which they learn from the output of receive-report\n> hook, would have no link to reality when this hook is used on the\n> remote side.  This may matter because the \"git push\" updates its own\n> remote-tracking branches to match what the remote says (i.e.,\n> pretends as if \"git push\" was immediately followed by \"git fetch\" to\n> the same remote).\n>\n\nFor remote tracking, that's exactly the conservative behavior we want\nfrom the hook. When the hook reports 'ng', the client does not update\nthe reference to a new value, meaning the push did not happen and that\nis what we want to convey.\n\nWill reroll with the rationale rewritten along these lines.\n"},{"id":"551800","messageId":"xmqqik4nihyd.fsf@gitster.g","threadId":"66186","inReplyTo":"CAOLa=ZTfPq3r3b7EDOSrG0-uSFQGgu-k3agPJgUV9xao8WsQrw@mail.gmail.com","subject":"Re: [PATCH v5 3/3] hook: introduce the receive-report hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-02T19:14:34Z","receivedAt":"2026-09-02T19:14:37Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n>> In any case, if the actual ref updates and the reported ref updates\n>> result can be made different, somebody then needs to step in and\n>> reconcile the inconsistencies, no?\n>\n> Naturally, the server is in charge of that, this is similar with the\n> pre-receive or proc-receive hooks. In that aspects this is very similar\n> to the proc-receive hook which transfers the responsibility of updating\n> refs to the owner of the hook.\n\nI am afraid that my point probably did not come across clearly.\n\nI am talking about the repository on the user's local workstation\nfrom which 'git push' was run.  The server reported that the push\nfailed, so the remote-tracking branches in the local repository\nreflect that the push did not succeed.  In reality, however, the ref\ntransaction was already committed on the server.  When the user runs\n'git fetch' after the failed 'git push' returns, they may see that\nthe server actually accepted the update.  The server cannot be \"in\ncharge\" of that, as it is incapable of resolving this discrepancy.\n\nOnly the user, by choosing to fetch again, can reconcile local state\nwith the server.\n\n>> The way pusher perceives the state of their remote repository they\n>> just pushed to, which they learn from the output of receive-report\n>> hook, would have no link to reality when this hook is used on the\n>> remote side.  This may matter because the \"git push\" updates its own\n>> remote-tracking branches to match what the remote says (i.e.,\n>> pretends as if \"git push\" was immediately followed by \"git fetch\" to\n>> the same remote).\n>\n> For remote tracking, that's exactly the conservative behavior we want\n> from the hook. When the hook reports 'ng', the client does not update\n> the reference to a new value, meaning the push did not happen and that\n> is what we want to convey.\n\nBut still the server side did already commit the ref transaction so\nthe update has been made.  Here is how the proposed commit log\nmessage explained this:\n\n>>> Note that in either failure mode, ref updates already applied by\n>>> execute_commands() are not rolled back. The hook can cause the client\n>>> to perceive the push as failed, but cannot undo server-side changes.\n"},{"id":"551842","messageId":"20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v6 0/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-03T09:27:57Z","receivedAt":"2026-09-03T09:28:11Z","isPatch":true,"body":"Introduce a new receive-report hook which kicks in after the reference\ntransaction is complete, but before the report is sent to the client.\nThe hook receives the pkt-line encoded report in its stdin and its\nstdout replaces the report transferred to the user. If the hook exits\nwith a non-zero exit code, all references are marked as rejected.\n\nThe first patch, adds missing documentation to 'git-receive-pack.adoc'.\nThe second patch refactors code and the third patch contains the new\nhook.\n\n---\nChanges in v6:\n- Introduce a new commit which introduces `enum report_status_version`,\n  use that and drop static variables in the codebase.\n- Reword the commit message and documentation to:\n  - State further why reference-transaction cannot be used.\n  - State the responsibility of the hook owner to undo and reference\n    changes if needed.\n- Link to v5: https://patch.msgid.link/20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com\n\nChanges in v5:\n- Rewrote some of the commit messages and documentation.\n- Renamed the function `generate_response` to `generate_report` to avoid\n  ambiguity.\n- We now override the cmd's error_strings, this avoids the whole\n  precedence issue with the earlier series.\n- Also add information about how we can override the unpack status to\n  fail the push and add a corresponding test.\n- Thanks to Patrick for the review!\n- Junio: This causes conflict with next ('jt/receive-pack-pluggable-writes')\n  similar to before, please let me know if its better for me to add that\n  dependency.\n- Link to v4: https://patch.msgid.link/20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com\n\nChanges in v4:\n- Change the name of the hook to be 'receive-report' to avoid ambiguity.\n- Link to v3: https://patch.msgid.link/20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com\n\nChanges in v3:\n- Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n  into a new commit.\n- Add a new commit to move out the response generation in receive-pack\n  to a new function.\n- Instead of die-ing on non-zero exit code, we modify each reference to\n  indicate that the hook failed.\n- Instead of correctly listing out the protocol, link to\n  linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n  and v2.\n- Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n\n To: git@vger.kernel.org\n CC: ps@pks.im\n CC: gitster@pobox.com\n CC: jltobler@gmail.com\n CC: kristofferhaugsbakk@fastmail.com\n CC: phillip.wood123@gmail.com\n\n---\nKarthik Nayak (4):\n      doc: add proc-receive hook info in 'git-receive-pack.adoc'\n      receive-pack: drop static variables to track report status version\n      receive-pack: move message generation to separate function\n      hook: introduce the receive-report hook\n\n Documentation/git-receive-pack.adoc |  17 +++\n Documentation/githooks.adoc         |  61 ++++++++++\n builtin/receive-pack.c              | 157 +++++++++++++++++--------\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 415 insertions(+), 45 deletions(-)\n\nRange-diff versus v5:\n\n1:  5b55286c7b = 1:  ac4272c0ed doc: add proc-receive hook info in 'git-receive-pack.adoc'\n-:  ---------- > 2:  e635158b10 receive-pack: drop static variables to track report status version\n2:  c4e0e8185a ! 3:  0d588b7e24 receive-pack: move message generation to separate function\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     + * report per reference update.\n     + */\n     +static void generate_report(struct strbuf *buf, struct command *commands,\n    -+\t\t\t    const char *unpack_status, bool detailed_report)\n    ++\t\t\t    const char *unpack_status,\n    ++\t\t\t    enum report_status_version version)\n      {\n      \tstruct command *cmd;\n     -\tstruct strbuf buf = STRBUF_INIT;\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     +\t\telse\n     +\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n     +\n    -+\t\tif (!detailed_report || cmd->error_string)\n    ++\t\tif (version != REPORT_STATUS_V2 || cmd->error_string)\n      \t\t\tcontinue;\n     -\t\t}\n     -\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     +{\n     +\tstruct strbuf buf = STRBUF_INIT;\n     +\n    -+\tgenerate_report(&buf, commands, unpack_status, false);\n    ++\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V0);\n     +\n     +\tif (use_sideband)\n     +\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     +{\n     +\tstruct strbuf buf = STRBUF_INIT;\n     +\n    -+\tgenerate_report(&buf, commands, unpack_status, true);\n    ++\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V2);\n      \n      \tif (use_sideband)\n      \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n3:  750a58166e ! 4:  ee4346b991 hook: introduce the receive-report hook\n    @@ Commit message\n         operations post reference transaction succeed. So reporting the correct\n         message based on the outcome of these operations is important.\n     \n    +    The outcome of these operations is only known after `execute_commands()`\n    +    has returned and before the report is written. There is no point in\n    +    receive-pack where the server can act on that.\n    +\n         We cannot use any of the existing hooks as:\n     \n           - The pre-receive hook runs too early, as we haven't updated\n    @@ Commit message\n           - The update hook is too inefficient as it runs once per reference,\n             and we cannot trivially determine the last update.\n     \n    -      - The reference-transaction hook cannot be used by us because we care\n    -        about the phase where it was committed already. And while the hook\n    -        fires in that phase, it does not allow the caller to modify the\n    -        result in any capacity.\n    +      - The reference-transaction hook is not suited for this. It fires from\n    +        within `ref_transaction_commit()`, which is before the outcome we\n    +        need to report is known, so there is no phase at which it could give\n    +        us the answer. It also does not contain any knowledge regarding the\n    +        push and cannot communicate with the clients.\n    +\n    +      - The proc-receive hook replaces execute_commands() for references\n    +        matching 'receive.procReceiveRefs'. We need to gate the report for\n    +        the push as a whole.\n     \n           - The post-receive and post-update hooks cannot be used as they run\n             too late, at the point where we have already reported success to the\n    @@ Commit message\n         'remote:' lines on the client terminal. Writing to stderr alone does\n         not affect the push outcome.\n     \n    -    Note that in either failure mode, ref updates already applied by\n    -    execute_commands() are not rolled back. The hook can cause the client\n    -    to perceive the push as failed, but cannot undo server-side changes.\n    +    Reference updates applied by execute_commands() are not rolled back in\n    +    either failure mode. The hook can cause the client to perceive the push\n    +    as failed, but cannot undo server-side changes. This creates a\n    +    divergence that the server cannot resolve: the client leaves its\n    +    remote-tracking reference at the old value while the update is in fact\n    +    applied, and a later fetch may reveal the update that the push reported\n    +    as rejected.\n    +\n    +    The hook is therefore only appropriate for servers which can guarantee\n    +    that a rejected update is not observable by any reader. In our case the\n    +    transaction committed by execute_commands() produces a candidate version\n    +    which is not visible to other readers and is only published once the\n    +    subsequent operations succeed, so a report of 'ng' corresponds to a\n    +    version that is discarded rather than published. On a repository where a\n    +    committed reference update is immediately visible, rejecting a push from\n    +    this hook would instead leave the pusher with a view that does not match\n    +    the server.\n     \n         This hook does not use the config-based hook infrastructure, which\n         supports running multiple scripts per hook event. This hook is a\n    @@ Documentation/githooks.adoc: The exit status of the hook is ignored for any stat\n     +to `ng` rolls back any ref changes that were already committed\n     +server-side. The hook can cause the client to perceive the push as\n     +failed, but cannot undo the server-side updates.\n    ++\n    ++This means that reporting a reference as `ng` makes the client believe\n    ++the update did not happen while the server has in fact applied it. The\n    ++client leaves its remote-tracking reference at its old value, and a\n    ++later `git fetch` may reveal the very update that the push reported as\n    ++rejected. Neither Git nor the server can reconcile this; only the user,\n    ++by fetching again, will find out.\n    ++\n    ++This hook is therefore only appropriate for servers which can guarantee\n    ++that a rejected update is not observable by any reader, for example\n    ++because the committed transaction produces a candidate state that is\n    ++discarded rather than published. On a repository where a committed\n    ++reference update is immediately visible, using this hook to reject a\n    ++push will leave the pusher with a view that does not match the server.\n     +\n      push-to-checkout\n      ~~~~~~~~~~~~~~~~\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n       * For v2 protocol, set `detailed_report` to true, which will also add detailed\n     @@ builtin/receive-pack.c: static void report(struct command *commands, const char *unpack_status)\n      \n    - \tgenerate_report(&buf, commands, unpack_status, false);\n    + \tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V0);\n      \n     +\tif (run_receive_report_hook(&buf)) {\n     +\t\tstrbuf_reset(&buf);\n    @@ builtin/receive-pack.c: static void report(struct command *commands, const char\n      \telse\n     @@ builtin/receive-pack.c: static void report_v2(struct command *commands, const char *unpack_status)\n      \n    - \tgenerate_report(&buf, commands, unpack_status, true);\n    + \tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V2);\n      \n     +\tif (run_receive_report_hook(&buf)) {\n     +\t\tstrbuf_reset(&buf);\n\n---\nbase-commit: 11c6700f10234578d10523faf35656ca491425c9\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"551843","messageId":"20260903-758-introduce-hook-v6-1-6283b1fb9b1c@gmail.com","threadId":"66186","inReplyTo":"20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com","subject":"[PATCH v6 1/4] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-03T09:27:58Z","receivedAt":"2026-09-03T09:28:12Z","isPatch":true,"body":"The manpage of git-receive-pack(1) documents hooks invoked when\nreceiving a push. The manpage does not mention the 'proc-receive' hook\nthough, which is also invoked as part of that process. Add a paragraph\nabout this hook to plug that gap.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc | 8 ++++++++\n 1 file changed, 8 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..5806792ba7 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,14 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by linkgit:git-receive-pack[1].  If the server has\n+set the multi-valued config variable `receive.procReceiveRefs`, and the\n+commands sent to 'receive-pack' have matching reference names, these\n+commands will be executed by this hook, instead of by the internal\n+`execute_commands()` function.  This hook is responsible for updating\n+the relevant references and reporting the results back to 'receive-pack'.\n \n QUARANTINE ENVIRONMENT\n ----------------------\n\n-- \n2.55.GIT\n\n"},{"id":"551844","messageId":"20260903-758-introduce-hook-v6-2-6283b1fb9b1c@gmail.com","threadId":"66186","inReplyTo":"20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com","subject":"[PATCH v6 2/4] receive-pack: drop static variables to track report status version","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-03T09:27:59Z","receivedAt":"2026-09-03T09:28:13Z","isPatch":true,"body":"In 'git-receive-pack(1)', to track the report status version, we use the\nstatic variables `report_status` and `report_status_v2`. As the report\nstatus version is mutually exclusive, using an enum better suits the\nrequirement. switch to using a new `enum report_status_version`, while\nalso dropping the static variable to make the flow easier to understand.\n\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 24 ++++++++++++++++--------\n 1 file changed, 16 insertions(+), 8 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 86933d8d7e..a9a3d21c24 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -55,6 +55,12 @@ enum deny_action {\n \tDENY_UPDATE_INSTEAD\n };\n \n+enum report_status_version {\n+\tREPORT_STATUS_UNKOWN = 0,\n+\tREPORT_STATUS_V0,\n+\tREPORT_STATUS_V2,\n+};\n+\n static int deny_deletes;\n static int deny_non_fast_forwards;\n static enum deny_action deny_current_branch = DENY_UNCONFIGURED;\n@@ -69,8 +75,6 @@ static int advertise_push_options;\n static int advertise_sid;\n static int unpack_limit = 100;\n static off_t max_input_size;\n-static int report_status;\n-static int report_status_v2;\n static int use_sideband;\n static int use_atomic;\n static int use_push_options;\n@@ -2207,7 +2211,8 @@ static void queue_commands_from_cert(struct command **tail,\n }\n \n static struct command *read_head_info(struct packet_reader *reader,\n-\t\t\t\t      struct oid_array *shallow)\n+\t\t\t\t      struct oid_array *shallow,\n+\t\t\t\t      enum report_status_version *version)\n {\n \tstruct command *commands = NULL;\n \tstruct command **p = &commands;\n@@ -2233,9 +2238,9 @@ static struct command *read_head_info(struct packet_reader *reader,\n \t\t\tconst char *client_sid;\n \t\t\tsize_t len = 0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status\"))\n-\t\t\t\treport_status = 1;\n+\t\t\t\t*version = REPORT_STATUS_V0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status-v2\"))\n-\t\t\t\treport_status_v2 = 1;\n+\t\t\t\t*version = REPORT_STATUS_V2;\n \t\t\tif (parse_feature_request(feature_list, \"side-band-64k\"))\n \t\t\t\tuse_sideband = LARGE_PACKET_MAX;\n \t\t\tif (parse_feature_request(feature_list, \"quiet\"))\n@@ -2621,6 +2626,7 @@ int cmd_receive_pack(int argc,\n \tstruct shallow_info si;\n \tstruct packet_reader reader;\n \tstruct odb_transaction *transaction = NULL;\n+\tenum report_status_version version = REPORT_STATUS_UNKOWN;\n \n \tstruct option options[] = {\n \t\tOPT__QUIET(&quiet, N_(\"quiet\")),\n@@ -2689,7 +2695,7 @@ int cmd_receive_pack(int argc,\n \t\t\t   PACKET_READ_CHOMP_NEWLINE |\n \t\t\t   PACKET_READ_DIE_ON_ERR_PACKET);\n \n-\tif ((commands = read_head_info(&reader, &shallow))) {\n+\tif ((commands = read_head_info(&reader, &shallow, &version))) {\n \t\tconst char *unpack_status = NULL;\n \t\tstruct string_list push_options = STRING_LIST_INIT_DUP;\n \n@@ -2716,10 +2722,12 @@ int cmd_receive_pack(int argc,\n \t\t\t\t &push_options);\n \t\tdelete_tempfile(&pack_lockfile);\n \t\tsigchain_push(SIGPIPE, SIG_IGN);\n-\t\tif (report_status_v2)\n+\t\tif (version == REPORT_STATUS_V2)\n \t\t\treport_v2(commands, unpack_status);\n-\t\telse if (report_status)\n+\t\telse if (version == REPORT_STATUS_V0)\n \t\t\treport(commands, unpack_status);\n+\t\telse\n+\t\t\tBUG(\"unknown report status version\");\n \t\tsigchain_pop(SIGPIPE);\n \t\trun_receive_hook(commands, \"post-receive\", 1, NULL,\n \t\t\t\t &push_options);\n\n-- \n2.55.GIT\n\n"},{"id":"551845","messageId":"20260903-758-introduce-hook-v6-3-6283b1fb9b1c@gmail.com","threadId":"66186","inReplyTo":"20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com","subject":"[PATCH v6 3/4] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-03T09:28:00Z","receivedAt":"2026-09-03T09:28:15Z","isPatch":true,"body":"After git-receive-pack(1) has committed the reference updates, we call\neither `report()` or `report_v2()` to report to the client which of the\nreferences we have updated successfully and which updates have failed.\nThe only difference between those two functions is that the latter also\nknows to provide a more detailed report about how exactly a given\nreference was updated.\n\nIn the next commit we're about to add another site that wants to\ngenerate these reports. Refactor the logic into a shared function that\ncan easily be reused.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 85 ++++++++++++++++++++++++++------------------------\n 1 file changed, 45 insertions(+), 40 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex a9a3d21c24..9ac10465ac 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2535,67 +2535,72 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n-static void report(struct command *commands, const char *unpack_status)\n+/*\n+ * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n+ * For v2 protocol, set `detailed_report` to true, which will also add detailed\n+ * report per reference update.\n+ */\n+static void generate_report(struct strbuf *buf, struct command *commands,\n+\t\t\t    const char *unpack_status,\n+\t\t\t    enum report_status_version version)\n {\n \tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n+\tpacket_buf_write(buf, \"unpack %s\\n\",\n \t\t\t unpack_status ? unpack_status : \"ok\");\n-\tfor (cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (!cmd->error_string)\n-\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t\t cmd->ref_name);\n-\t\telse\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name, cmd->error_string);\n-\t}\n-\tpacket_buf_flush(&buf);\n-\n-\tif (use_sideband)\n-\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n-\telse\n-\t\twrite_or_die(1, buf.buf, buf.len);\n-\tstrbuf_release(&buf);\n-}\n-\n-static void report_v2(struct command *commands, const char *unpack_status)\n-{\n-\tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n-\tstruct ref_push_report *report;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n-\t\t\t unpack_status ? unpack_status : \"ok\");\n \tfor (cmd = commands; cmd; cmd = cmd->next) {\n+\t\tstruct ref_push_report *report;\n \t\tint count = 0;\n \n-\t\tif (cmd->error_string) {\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name,\n-\t\t\t\t\t cmd->error_string);\n+\t\tif (cmd->error_string)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse\n+\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n+\n+\t\tif (version != REPORT_STATUS_V2 || cmd->error_string)\n \t\t\tcontinue;\n-\t\t}\n-\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t cmd->ref_name);\n+\n \t\tfor (report = cmd->report; report; report = report->next) {\n \t\t\tif (count++ > 0)\n-\t\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"ok %s\\n\",\n \t\t\t\t\t\t cmd->ref_name);\n \t\t\tif (report->ref_name)\n-\t\t\t\tpacket_buf_write(&buf, \"option refname %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option refname %s\\n\",\n \t\t\t\t\t\t report->ref_name);\n \t\t\tif (report->old_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option old-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option old-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->old_oid));\n \t\t\tif (report->new_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option new-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option new-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->new_oid));\n \t\t\tif (report->forced_update)\n-\t\t\t\tpacket_buf_write(&buf, \"option forced-update\\n\");\n+\t\t\t\tpacket_buf_write(buf, \"option forced-update\\n\");\n \t\t}\n \t}\n-\tpacket_buf_flush(&buf);\n+\n+\tpacket_buf_flush(buf);\n+}\n+\n+static void report(struct command *commands, const char *unpack_status)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V0);\n+\n+\tif (use_sideband)\n+\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n+\telse\n+\t\twrite_or_die(1, buf.buf, buf.len);\n+\tstrbuf_release(&buf);\n+}\n+\n+static void report_v2(struct command *commands, const char *unpack_status)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V2);\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n\n-- \n2.55.GIT\n\n"},{"id":"551846","messageId":"20260903-758-introduce-hook-v6-4-6283b1fb9b1c@gmail.com","threadId":"66186","inReplyTo":"20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com","subject":"[PATCH v6 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-03T09:28:01Z","receivedAt":"2026-09-03T09:28:17Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nThe outcome of these operations is only known after `execute_commands()`\nhas returned and before the report is written. There is no point in\nreceive-pack where the server can act on that.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook is not suited for this. It fires from\n    within `ref_transaction_commit()`, which is before the outcome we\n    need to report is known, so there is no phase at which it could give\n    us the answer. It also does not contain any knowledge regarding the\n    push and cannot communicate with the clients.\n\n  - The proc-receive hook replaces execute_commands() for references\n    matching 'receive.procReceiveRefs'. We need to gate the report for\n    the push as a whole.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'receive-report' hook. The hook receives the complete\npkt-line encoded status report on standard input, after all ref updates\nhave been applied to the repository by execute_commands() but before the\nreport is sent to the client. See linkgit:gitprotocol-pack[5] details on\nthe protocol structure.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviors depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n  all references to be rejected with a 'receive-report hook failed'\n  error.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nReference updates applied by execute_commands() are not rolled back in\neither failure mode. The hook can cause the client to perceive the push\nas failed, but cannot undo server-side changes. This creates a\ndivergence that the server cannot resolve: the client leaves its\nremote-tracking reference at the old value while the update is in fact\napplied, and a later fetch may reveal the update that the push reported\nas rejected.\n\nThe hook is therefore only appropriate for servers which can guarantee\nthat a rejected update is not observable by any reader. In our case the\ntransaction committed by execute_commands() produces a candidate version\nwhich is not visible to other readers and is only published once the\nsubsequent operations succeed, so a report of 'ng' corresponds to a\nversion that is discarded rather than published. On a repository where a\ncommitted reference update is immediately visible, rejecting a push from\nthis hook would instead leave the pusher with a view that does not match\nthe server.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc |   9 ++\n Documentation/githooks.adoc         |  61 ++++++++++\n builtin/receive-pack.c              |  54 +++++++++\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 349 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 5806792ba7..ab668ffa0c 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -245,6 +245,15 @@ commands will be executed by this hook, instead of by the internal\n `execute_commands()` function.  This hook is responsible for updating\n the relevant references and reporting the results back to 'receive-pack'.\n \n+RECEIVE-REPORT HOOK\n+-------------------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client, which allows the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n+\n QUARANTINE ENVIRONMENT\n ----------------------\n \ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..145642bf05 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,67 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+receive-report\n+~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after all\n+accepted ref changes are applied to the repository, but before the\n+pkt-line encoded status report is sent back to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input, see linkgit:gitprotocol-pack[5] for details on the\n+structure. The hook's standard output entirely replaces the report\n+that is sent to the client. The hook must write a valid pkt-line\n+encoded report in the same format it received. The hook's stdout is\n+fully buffered by `receive-pack` before any data is sent to the client,\n+so the hook's exit status is known before the client receives anything.\n+\n+There are three distinct ways the hook can affect the push outcome:\n+\n+* To reject the push, modify the unpack status from `ok` to the required\n+  error message. While `git-push` will fail, individual references may\n+  still show success messages unless modified.\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname>[ <reason>]` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  modifies all references to be rejected with a 'receive-report hook\n+  failed' error.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n+This means that reporting a reference as `ng` makes the client believe\n+the update did not happen while the server has in fact applied it. The\n+client leaves its remote-tracking reference at its old value, and a\n+later `git fetch` may reveal the very update that the push reported as\n+rejected. Neither Git nor the server can reconcile this; only the user,\n+by fetching again, will find out.\n+\n+This hook is therefore only appropriate for servers which can guarantee\n+that a rejected update is not observable by any reader, for example\n+because the committed transaction produces a candidate state that is\n+discarded rather than published. On a repository where a committed\n+reference update is immediately visible, using this hook to reject a\n+push will leave the pusher with a view that does not match the server.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 9ac10465ac..edfd5cf9dc 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1008,6 +1008,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_receive_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"receive-report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"receive-report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2535,6 +2570,13 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n+static void override_cmds_error(struct command *commands, const char *err)\n+{\n+\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n+\t\tcmd->error_string = err;\n+\t}\n+}\n+\n /*\n  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n  * For v2 protocol, set `detailed_report` to true, which will also add detailed\n@@ -2589,6 +2631,12 @@ static void report(struct command *commands, const char *unpack_status)\n \n \tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V0);\n \n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n+\t\tgenerate_report(&buf, commands, unpack_status, false);\n+\t}\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\n@@ -2602,6 +2650,12 @@ static void report_v2(struct command *commands, const char *unpack_status)\n \n \tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V2);\n \n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n+\t\tgenerate_report(&buf, commands, unpack_status, true);\n+\t}\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex a25f37d2f5..7088c2c1c1 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -651,6 +651,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-receive-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-receive-report-hook.sh b/t/t5412-receive-report-hook.sh\nnew file mode 100755\nindex 0000000000..24679de37b\n--- /dev/null\n+++ b/t/t5412-receive-report-hook.sh\n@@ -0,0 +1,224 @@\n+#!/bin/sh\n+\n+test_description='test receive-report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit reports as hook failed\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup receive-report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can modify the unpack status\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^unpack ok$/unpack push failed due to server error/\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"error: remote unpack failed: push failed due to server error\" out &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n-- \n2.55.GIT\n\n"},{"id":"551849","messageId":"aplF9d5ajwO9AnG9@pks.im","threadId":"66186","inReplyTo":"20260903-758-introduce-hook-v6-2-6283b1fb9b1c@gmail.com","subject":"Re: [PATCH v6 2/4] receive-pack: drop static variables to track report status version","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-03T10:03:33Z","receivedAt":"2026-09-03T10:03:48Z","isPatch":true,"body":"On Thu, Sep 03, 2026 at 11:27:59AM +0200, Karthik Nayak wrote:\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index 86933d8d7e..a9a3d21c24 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -2716,10 +2722,12 @@ int cmd_receive_pack(int argc,\n>  \t\t\t\t &push_options);\n>  \t\tdelete_tempfile(&pack_lockfile);\n>  \t\tsigchain_push(SIGPIPE, SIG_IGN);\n> -\t\tif (report_status_v2)\n> +\t\tif (version == REPORT_STATUS_V2)\n>  \t\t\treport_v2(commands, unpack_status);\n> -\t\telse if (report_status)\n> +\t\telse if (version == REPORT_STATUS_V0)\n>  \t\t\treport(commands, unpack_status);\n> +\t\telse\n> +\t\t\tBUG(\"unknown report status version\");\n\nNit: I typically prefer switches when we want to handle enums, even\nthough they are more verbose. Please feel free to ignore though, this is\nhighly subjective and it's not worth a reroll.\n\nPatrick\n"},{"id":"551851","messageId":"aplF-zxlGRqZs6tf@pks.im","threadId":"66186","inReplyTo":"20260903-758-introduce-hook-v6-3-6283b1fb9b1c@gmail.com","subject":"Re: [PATCH v6 3/4] receive-pack: move message generation to separate function","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-03T10:03:39Z","receivedAt":"2026-09-03T10:03:53Z","isPatch":true,"body":"On Thu, Sep 03, 2026 at 11:28:00AM +0200, Karthik Nayak wrote:\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index a9a3d21c24..9ac10465ac 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -2535,67 +2535,72 @@ static void update_shallow_info(struct command *commands,\n[snip]\n> +static void report(struct command *commands, const char *unpack_status)\n> +{\n> +\tstruct strbuf buf = STRBUF_INIT;\n> +\n> +\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V0);\n> +\n> +\tif (use_sideband)\n> +\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n> +\telse\n> +\t\twrite_or_die(1, buf.buf, buf.len);\n> +\tstrbuf_release(&buf);\n> +}\n> +\n> +static void report_v2(struct command *commands, const char *unpack_status)\n> +{\n> +\tstruct strbuf buf = STRBUF_INIT;\n> +\n> +\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V2);\n>  \n>  \tif (use_sideband)\n>  \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n\nA bit hard to see, but aren't these two functions now exactly the same\nexcept for the enum passed to `generate_report()`?\n\nPatrick\n"},{"id":"551852","messageId":"aplGBSWKfn02k7Ku@pks.im","threadId":"66186","inReplyTo":"20260903-758-introduce-hook-v6-4-6283b1fb9b1c@gmail.com","subject":"Re: [PATCH v6 4/4] hook: introduce the receive-report hook","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-03T10:03:49Z","receivedAt":"2026-09-03T10:04:05Z","isPatch":true,"body":"On Thu, Sep 03, 2026 at 11:28:01AM +0200, Karthik Nayak wrote:\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index 9ac10465ac..edfd5cf9dc 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -2535,6 +2570,13 @@ static void update_shallow_info(struct command *commands,\n>  \tfree(ref_status);\n>  }\n>  \n> +static void override_cmds_error(struct command *commands, const char *err)\n> +{\n> +\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n> +\t\tcmd->error_string = err;\n> +\t}\n> +}\n\nMicronit: unnecessary curly braces.\n\nPatrick\n"},{"id":"551885","messageId":"CAOLa=ZS8F2dcUx0iyfXivb57B=bfmRZWvZe5_yab1moOs_eEBQ@mail.gmail.com","threadId":"66186","inReplyTo":"aplF9d5ajwO9AnG9@pks.im","subject":"Re: [PATCH v6 2/4] receive-pack: drop static variables to track report status version","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-03T16:31:21Z","receivedAt":"2026-09-03T16:31:25Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Thu, Sep 03, 2026 at 11:27:59AM +0200, Karthik Nayak wrote:\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>> index 86933d8d7e..a9a3d21c24 100644\n>> --- a/builtin/receive-pack.c\n>> +++ b/builtin/receive-pack.c\n>> @@ -2716,10 +2722,12 @@ int cmd_receive_pack(int argc,\n>>  \t\t\t\t &push_options);\n>>  \t\tdelete_tempfile(&pack_lockfile);\n>>  \t\tsigchain_push(SIGPIPE, SIG_IGN);\n>> -\t\tif (report_status_v2)\n>> +\t\tif (version == REPORT_STATUS_V2)\n>>  \t\t\treport_v2(commands, unpack_status);\n>> -\t\telse if (report_status)\n>> +\t\telse if (version == REPORT_STATUS_V0)\n>>  \t\t\treport(commands, unpack_status);\n>> +\t\telse\n>> +\t\t\tBUG(\"unknown report status version\");\n>\n> Nit: I typically prefer switches when we want to handle enums, even\n> though they are more verbose. Please feel free to ignore though, this is\n> highly subjective and it's not worth a reroll.\n>\n> Patrick\n\nI'll add it in, and it'll be part of the reroll (if necessary).\n"},{"id":"551886","messageId":"CAOLa=ZTZeO0DRh67TQ0uY=pWUrePwg09_=D_qyyM7ZigzvZLJg@mail.gmail.com","threadId":"66186","inReplyTo":"aplF-zxlGRqZs6tf@pks.im","subject":"Re: [PATCH v6 3/4] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-03T16:32:00Z","receivedAt":"2026-09-03T16:32:02Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Thu, Sep 03, 2026 at 11:28:00AM +0200, Karthik Nayak wrote:\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>> index a9a3d21c24..9ac10465ac 100644\n>> --- a/builtin/receive-pack.c\n>> +++ b/builtin/receive-pack.c\n>> @@ -2535,67 +2535,72 @@ static void update_shallow_info(struct command *commands,\n> [snip]\n>> +static void report(struct command *commands, const char *unpack_status)\n>> +{\n>> +\tstruct strbuf buf = STRBUF_INIT;\n>> +\n>> +\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V0);\n>> +\n>> +\tif (use_sideband)\n>> +\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>> +\telse\n>> +\t\twrite_or_die(1, buf.buf, buf.len);\n>> +\tstrbuf_release(&buf);\n>> +}\n>> +\n>> +static void report_v2(struct command *commands, const char *unpack_status)\n>> +{\n>> +\tstruct strbuf buf = STRBUF_INIT;\n>> +\n>> +\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V2);\n>>\n>>  \tif (use_sideband)\n>>  \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n>\n> A bit hard to see, but aren't these two functions now exactly the same\n> except for the enum passed to `generate_report()`?\n>\n> Patrick\n\nOh yeah, that's a neat consequence I didn't even see. I'll definitely\nsend in a new version with this change.\n"},{"id":"551887","messageId":"CAOLa=ZR30-=dBrayaWMZAv6Zm=u3w_Va1vNcPGBMmXpVk3V1Ag@mail.gmail.com","threadId":"66186","inReplyTo":"aplGBSWKfn02k7Ku@pks.im","subject":"Re: [PATCH v6 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-03T16:32:21Z","receivedAt":"2026-09-03T16:32:27Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Thu, Sep 03, 2026 at 11:28:01AM +0200, Karthik Nayak wrote:\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>> index 9ac10465ac..edfd5cf9dc 100644\n>> --- a/builtin/receive-pack.c\n>> +++ b/builtin/receive-pack.c\n>> @@ -2535,6 +2570,13 @@ static void update_shallow_info(struct command *commands,\n>>  \tfree(ref_status);\n>>  }\n>>\n>> +static void override_cmds_error(struct command *commands, const char *err)\n>> +{\n>> +\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n>> +\t\tcmd->error_string = err;\n>> +\t}\n>> +}\n>\n> Micronit: unnecessary curly braces.\n>\n> Patrick\n\nYup, will remove. Will send in a new version tomorrow, if there are no\nother reviews.\n"},{"id":"552005","messageId":"20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v7 0/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-04T21:28:48Z","receivedAt":"2026-09-04T21:28:58Z","isPatch":true,"body":"Introduce a new receive-report hook which kicks in after the reference\ntransaction is complete, but before the report is sent to the client.\nThe hook receives the pkt-line encoded report in its stdin and its\nstdout replaces the report transferred to the user. If the hook exits\nwith a non-zero exit code, all references are marked as rejected.\n\nThe first patch, adds missing documentation to 'git-receive-pack.adoc'.\nThe second patch refactors code and the third patch contains the new\nhook.\n\n---\nChanges in v7:\n- Removed report_v2() since it is the same as report() with the new\n  changes.\n- Used a switch statement instead of an if/else for the enum.\n- Removed an unnecessary curly brace.\n- Also rebased on top of latest master (3cb9185f65 (The 22nd batch,\n  2026-09-02) as there were conflicts.\n- Link to v6: https://patch.msgid.link/20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com\n\nChanges in v6:\n- Introduce a new commit which introduces `enum report_status_version`,\n  use that and drop static variables in the codebase.\n- Reword the commit message and documentation to:\n  - State further why reference-transaction cannot be used.\n  - State the responsibility of the hook owner to undo and reference\n    changes if needed.\n- Link to v5: https://patch.msgid.link/20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com\n\nChanges in v5:\n- Rewrote some of the commit messages and documentation.\n- Renamed the function `generate_response` to `generate_report` to avoid\n  ambiguity.\n- We now override the cmd's error_strings, this avoids the whole\n  precedence issue with the earlier series.\n- Also add information about how we can override the unpack status to\n  fail the push and add a corresponding test.\n- Thanks to Patrick for the review!\n- Junio: This causes conflict with next ('jt/receive-pack-pluggable-writes')\n  similar to before, please let me know if its better for me to add that\n  dependency.\n- Link to v4: https://patch.msgid.link/20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com\n\nChanges in v4:\n- Change the name of the hook to be 'receive-report' to avoid ambiguity.\n- Link to v3: https://patch.msgid.link/20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com\n\nChanges in v3:\n- Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n  into a new commit.\n- Add a new commit to move out the response generation in receive-pack\n  to a new function.\n- Instead of die-ing on non-zero exit code, we modify each reference to\n  indicate that the hook failed.\n- Instead of correctly listing out the protocol, link to\n  linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n  and v2.\n- Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n\n To: git@vger.kernel.org\n CC: ps@pks.im\n CC: gitster@pobox.com\n CC: jltobler@gmail.com\n CC: kristofferhaugsbakk@fastmail.com\n CC: phillip.wood123@gmail.com\n\n---\nKarthik Nayak (4):\n      doc: add proc-receive hook info in 'git-receive-pack.adoc'\n      receive-pack: drop static variables to track report status version\n      receive-pack: move message generation to separate function\n      hook: introduce the receive-report hook\n\n Documentation/git-receive-pack.adoc |  17 +++\n Documentation/githooks.adoc         |  61 ++++++++++\n builtin/receive-pack.c              | 148 ++++++++++++++++--------\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 403 insertions(+), 48 deletions(-)\n\nRange-diff versus v6:\n\n1:  97c946bad0 = 1:  efb66eb539 doc: add proc-receive hook info in 'git-receive-pack.adoc'\n2:  0f7738eed0 ! 2:  d8e4830a6f receive-pack: drop static variables to track report status version\n    @@ builtin/receive-pack.c: enum deny_action {\n      static int deny_deletes;\n      static int deny_non_fast_forwards;\n      static enum deny_action deny_current_branch = DENY_UNCONFIGURED;\n    -@@ builtin/receive-pack.c: static int advertise_push_options;\n    +@@ builtin/receive-pack.c: static int advertise_atomic_push = 1;\n    + static int advertise_push_options;\n      static int advertise_sid;\n    - static int unpack_limit = 100;\n      static off_t max_input_size;\n     -static int report_status;\n     -static int report_status_v2;\n    @@ builtin/receive-pack.c: int cmd_receive_pack(int argc,\n      \n     -\tif ((commands = read_head_info(&reader, &shallow))) {\n     +\tif ((commands = read_head_info(&reader, &shallow, &version))) {\n    - \t\tconst char *unpack_status = NULL;\n      \t\tstruct string_list push_options = STRING_LIST_INIT_DUP;\n    + \t\tstruct strbuf unpack_status = STRBUF_INIT;\n      \n     @@ builtin/receive-pack.c: int cmd_receive_pack(int argc,\n      \t\t\t\t &push_options);\n    - \t\tdelete_tempfile(&pack_lockfile);\n    + \t\todb_transaction_finalize(transaction);\n      \t\tsigchain_push(SIGPIPE, SIG_IGN);\n     -\t\tif (report_status_v2)\n    -+\t\tif (version == REPORT_STATUS_V2)\n    - \t\t\treport_v2(commands, unpack_status);\n    ++\n    ++\t\tswitch (version) {\n    ++\t\tcase REPORT_STATUS_V2:\n    + \t\t\treport_v2(commands, &unpack_status);\n     -\t\telse if (report_status)\n    -+\t\telse if (version == REPORT_STATUS_V0)\n    - \t\t\treport(commands, unpack_status);\n    -+\t\telse\n    ++\t\t\tbreak;\n    ++\t\tcase REPORT_STATUS_V0:\n    + \t\t\treport(commands, &unpack_status);\n    ++\t\t\tbreak;\n    ++\t\tdefault:\n     +\t\t\tBUG(\"unknown report status version\");\n    ++\t\t}\n    ++\n      \t\tsigchain_pop(SIGPIPE);\n      \t\trun_receive_hook(commands, \"post-receive\", 1, NULL,\n      \t\t\t\t &push_options);\n3:  8b56349072 ! 3:  d9464d9739 receive-pack: move message generation to separate function\n    @@ Commit message\n         knows to provide a more detailed report about how exactly a given\n         reference was updated.\n     \n    +    With this, also drop `report_v2()` as both report functions now are\n    +    similar in structure with only the `report_status_version`\n    +    differentiating them.\n    +\n         In the next commit we're about to add another site that wants to\n         generate these reports. Refactor the logic into a shared function that\n         can easily be reused.\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n      \tfree(ref_status);\n      }\n      \n    --static void report(struct command *commands, const char *unpack_status)\n    +-static void report(struct command *commands, const struct strbuf *unpack_status)\n     +/*\n     + * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n     + * For v2 protocol, set `detailed_report` to true, which will also add detailed\n     + * report per reference update.\n     + */\n     +static void generate_report(struct strbuf *buf, struct command *commands,\n    -+\t\t\t    const char *unpack_status,\n    ++\t\t\t    const struct strbuf *unpack_status,\n     +\t\t\t    enum report_status_version version)\n      {\n      \tstruct command *cmd;\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n      \n     -\tpacket_buf_write(&buf, \"unpack %s\\n\",\n     +\tpacket_buf_write(buf, \"unpack %s\\n\",\n    - \t\t\t unpack_status ? unpack_status : \"ok\");\n    + \t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n     -\tfor (cmd = commands; cmd; cmd = cmd->next) {\n     -\t\tif (!cmd->error_string)\n     -\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     -\tstrbuf_release(&buf);\n     -}\n     -\n    --static void report_v2(struct command *commands, const char *unpack_status)\n    +-static void report_v2(struct command *commands, const struct strbuf *unpack_status)\n     -{\n     -\tstruct command *cmd;\n     -\tstruct strbuf buf = STRBUF_INIT;\n     -\tstruct ref_push_report *report;\n      \n     -\tpacket_buf_write(&buf, \"unpack %s\\n\",\n    --\t\t\t unpack_status ? unpack_status : \"ok\");\n    +-\t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n      \tfor (cmd = commands; cmd; cmd = cmd->next) {\n     +\t\tstruct ref_push_report *report;\n      \t\tint count = 0;\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     +\tpacket_buf_flush(buf);\n     +}\n     +\n    -+static void report(struct command *commands, const char *unpack_status)\n    -+{\n    -+\tstruct strbuf buf = STRBUF_INIT;\n    -+\n    -+\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V0);\n    -+\n    -+\tif (use_sideband)\n    -+\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    -+\telse\n    -+\t\twrite_or_die(1, buf.buf, buf.len);\n    -+\tstrbuf_release(&buf);\n    -+}\n    -+\n    -+static void report_v2(struct command *commands, const char *unpack_status)\n    ++static void report(struct command *commands, const struct strbuf *unpack_status,\n    ++\t\t   enum report_status_version version)\n     +{\n     +\tstruct strbuf buf = STRBUF_INIT;\n     +\n    -+\tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V2);\n    ++\tgenerate_report(&buf, commands, unpack_status, version);\n      \n      \tif (use_sideband)\n      \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    +@@ builtin/receive-pack.c: int cmd_receive_pack(int argc,\n    + \n    + \t\tswitch (version) {\n    + \t\tcase REPORT_STATUS_V2:\n    +-\t\t\treport_v2(commands, &unpack_status);\n    +-\t\t\tbreak;\n    + \t\tcase REPORT_STATUS_V0:\n    +-\t\t\treport(commands, &unpack_status);\n    ++\t\t\treport(commands, &unpack_status, version);\n    + \t\t\tbreak;\n    + \t\tdefault:\n    + \t\t\tBUG(\"unknown report status version\");\n4:  a3d7576e58 ! 4:  262c8f1708 hook: introduce the receive-report hook\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n      \n     +static void override_cmds_error(struct command *commands, const char *err)\n     +{\n    -+\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n    ++\tfor (struct command *cmd = commands; cmd; cmd = cmd->next)\n     +\t\tcmd->error_string = err;\n    -+\t}\n     +}\n     +\n      /*\n       * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n       * For v2 protocol, set `detailed_report` to true, which will also add detailed\n    -@@ builtin/receive-pack.c: static void report(struct command *commands, const char *unpack_status)\n    +@@ builtin/receive-pack.c: static void report(struct command *commands, const struct strbuf *unpack_status,\n      \n    - \tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V0);\n    + \tgenerate_report(&buf, commands, unpack_status, version);\n      \n     +\tif (run_receive_report_hook(&buf)) {\n     +\t\tstrbuf_reset(&buf);\n     +\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n     +\t\tgenerate_report(&buf, commands, unpack_status, false);\n     +\t}\n    -+\n    - \tif (use_sideband)\n    - \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n    - \telse\n    -@@ builtin/receive-pack.c: static void report_v2(struct command *commands, const char *unpack_status)\n    - \n    - \tgenerate_report(&buf, commands, unpack_status, REPORT_STATUS_V2);\n    - \n    -+\tif (run_receive_report_hook(&buf)) {\n    -+\t\tstrbuf_reset(&buf);\n    -+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n    -+\t\tgenerate_report(&buf, commands, unpack_status, true);\n    -+\t}\n     +\n      \tif (use_sideband)\n      \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n\n---\nbase-commit: 3cb9185f65410273787f74333cc027d2ea5daada\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"552006","messageId":"20260904-758-introduce-hook-v7-1-6c66f0a3a572@gmail.com","threadId":"66186","inReplyTo":"20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com","subject":"[PATCH v7 1/4] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-04T21:28:49Z","receivedAt":"2026-09-04T21:29:00Z","isPatch":true,"body":"The manpage of git-receive-pack(1) documents hooks invoked when\nreceiving a push. The manpage does not mention the 'proc-receive' hook\nthough, which is also invoked as part of that process. Add a paragraph\nabout this hook to plug that gap.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc | 8 ++++++++\n 1 file changed, 8 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..5806792ba7 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,14 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by linkgit:git-receive-pack[1].  If the server has\n+set the multi-valued config variable `receive.procReceiveRefs`, and the\n+commands sent to 'receive-pack' have matching reference names, these\n+commands will be executed by this hook, instead of by the internal\n+`execute_commands()` function.  This hook is responsible for updating\n+the relevant references and reporting the results back to 'receive-pack'.\n \n QUARANTINE ENVIRONMENT\n ----------------------\n\n-- \n2.55.GIT\n\n"},{"id":"552007","messageId":"20260904-758-introduce-hook-v7-2-6c66f0a3a572@gmail.com","threadId":"66186","inReplyTo":"20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com","subject":"[PATCH v7 2/4] receive-pack: drop static variables to track report status version","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-04T21:28:50Z","receivedAt":"2026-09-04T21:29:01Z","isPatch":true,"body":"In 'git-receive-pack(1)', to track the report status version, we use the\nstatic variables `report_status` and `report_status_v2`. As the report\nstatus version is mutually exclusive, using an enum better suits the\nrequirement. switch to using a new `enum report_status_version`, while\nalso dropping the static variable to make the flow easier to understand.\n\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 30 ++++++++++++++++++++++--------\n 1 file changed, 22 insertions(+), 8 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex e6e54ba55f..c356e34cd8 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -53,6 +53,12 @@ enum deny_action {\n \tDENY_UPDATE_INSTEAD\n };\n \n+enum report_status_version {\n+\tREPORT_STATUS_UNKOWN = 0,\n+\tREPORT_STATUS_V0,\n+\tREPORT_STATUS_V2,\n+};\n+\n static int deny_deletes;\n static int deny_non_fast_forwards;\n static enum deny_action deny_current_branch = DENY_UNCONFIGURED;\n@@ -64,8 +70,6 @@ static int advertise_atomic_push = 1;\n static int advertise_push_options;\n static int advertise_sid;\n static off_t max_input_size;\n-static int report_status;\n-static int report_status_v2;\n static int use_sideband;\n static int use_atomic;\n static int use_push_options;\n@@ -2191,7 +2195,8 @@ static void queue_commands_from_cert(struct command **tail,\n }\n \n static struct command *read_head_info(struct packet_reader *reader,\n-\t\t\t\t      struct oid_array *shallow)\n+\t\t\t\t      struct oid_array *shallow,\n+\t\t\t\t      enum report_status_version *version)\n {\n \tstruct command *commands = NULL;\n \tstruct command **p = &commands;\n@@ -2217,9 +2222,9 @@ static struct command *read_head_info(struct packet_reader *reader,\n \t\t\tconst char *client_sid;\n \t\t\tsize_t len = 0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status\"))\n-\t\t\t\treport_status = 1;\n+\t\t\t\t*version = REPORT_STATUS_V0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status-v2\"))\n-\t\t\t\treport_status_v2 = 1;\n+\t\t\t\t*version = REPORT_STATUS_V2;\n \t\t\tif (parse_feature_request(feature_list, \"side-band-64k\"))\n \t\t\t\tuse_sideband = LARGE_PACKET_MAX;\n \t\t\tif (parse_feature_request(feature_list, \"quiet\"))\n@@ -2500,6 +2505,7 @@ int cmd_receive_pack(int argc,\n \tstruct shallow_info si;\n \tstruct packet_reader reader;\n \tstruct odb_transaction *transaction = NULL;\n+\tenum report_status_version version = REPORT_STATUS_UNKOWN;\n \n \tstruct option options[] = {\n \t\tOPT__QUIET(&quiet, N_(\"quiet\")),\n@@ -2563,7 +2569,7 @@ int cmd_receive_pack(int argc,\n \t\t\t   PACKET_READ_CHOMP_NEWLINE |\n \t\t\t   PACKET_READ_DIE_ON_ERR_PACKET);\n \n-\tif ((commands = read_head_info(&reader, &shallow))) {\n+\tif ((commands = read_head_info(&reader, &shallow, &version))) {\n \t\tstruct string_list push_options = STRING_LIST_INIT_DUP;\n \t\tstruct strbuf unpack_status = STRBUF_INIT;\n \n@@ -2596,10 +2602,18 @@ int cmd_receive_pack(int argc,\n \t\t\t\t &push_options);\n \t\todb_transaction_finalize(transaction);\n \t\tsigchain_push(SIGPIPE, SIG_IGN);\n-\t\tif (report_status_v2)\n+\n+\t\tswitch (version) {\n+\t\tcase REPORT_STATUS_V2:\n \t\t\treport_v2(commands, &unpack_status);\n-\t\telse if (report_status)\n+\t\t\tbreak;\n+\t\tcase REPORT_STATUS_V0:\n \t\t\treport(commands, &unpack_status);\n+\t\t\tbreak;\n+\t\tdefault:\n+\t\t\tBUG(\"unknown report status version\");\n+\t\t}\n+\n \t\tsigchain_pop(SIGPIPE);\n \t\trun_receive_hook(commands, \"post-receive\", 1, NULL,\n \t\t\t\t &push_options);\n\n-- \n2.55.GIT\n\n"},{"id":"552008","messageId":"20260904-758-introduce-hook-v7-3-6c66f0a3a572@gmail.com","threadId":"66186","inReplyTo":"20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com","subject":"[PATCH v7 3/4] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-04T21:28:51Z","receivedAt":"2026-09-04T21:29:03Z","isPatch":true,"body":"After git-receive-pack(1) has committed the reference updates, we call\neither `report()` or `report_v2()` to report to the client which of the\nreferences we have updated successfully and which updates have failed.\nThe only difference between those two functions is that the latter also\nknows to provide a more detailed report about how exactly a given\nreference was updated.\n\nWith this, also drop `report_v2()` as both report functions now are\nsimilar in structure with only the `report_status_version`\ndifferentiating them.\n\nIn the next commit we're about to add another site that wants to\ngenerate these reports. Refactor the logic into a shared function that\ncan easily be reused.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 77 ++++++++++++++++++++++----------------------------\n 1 file changed, 34 insertions(+), 43 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex c356e34cd8..9c70da9ba1 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2414,67 +2414,60 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n-static void report(struct command *commands, const struct strbuf *unpack_status)\n+/*\n+ * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n+ * For v2 protocol, set `detailed_report` to true, which will also add detailed\n+ * report per reference update.\n+ */\n+static void generate_report(struct strbuf *buf, struct command *commands,\n+\t\t\t    const struct strbuf *unpack_status,\n+\t\t\t    enum report_status_version version)\n {\n \tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n+\tpacket_buf_write(buf, \"unpack %s\\n\",\n \t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n-\tfor (cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (!cmd->error_string)\n-\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t\t cmd->ref_name);\n-\t\telse\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name, cmd->error_string);\n-\t}\n-\tpacket_buf_flush(&buf);\n-\n-\tif (use_sideband)\n-\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n-\telse\n-\t\twrite_or_die(1, buf.buf, buf.len);\n-\tstrbuf_release(&buf);\n-}\n-\n-static void report_v2(struct command *commands, const struct strbuf *unpack_status)\n-{\n-\tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n-\tstruct ref_push_report *report;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n-\t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n \tfor (cmd = commands; cmd; cmd = cmd->next) {\n+\t\tstruct ref_push_report *report;\n \t\tint count = 0;\n \n-\t\tif (cmd->error_string) {\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name,\n-\t\t\t\t\t cmd->error_string);\n+\t\tif (cmd->error_string)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse\n+\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n+\n+\t\tif (version != REPORT_STATUS_V2 || cmd->error_string)\n \t\t\tcontinue;\n-\t\t}\n-\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t cmd->ref_name);\n+\n \t\tfor (report = cmd->report; report; report = report->next) {\n \t\t\tif (count++ > 0)\n-\t\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"ok %s\\n\",\n \t\t\t\t\t\t cmd->ref_name);\n \t\t\tif (report->ref_name)\n-\t\t\t\tpacket_buf_write(&buf, \"option refname %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option refname %s\\n\",\n \t\t\t\t\t\t report->ref_name);\n \t\t\tif (report->old_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option old-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option old-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->old_oid));\n \t\t\tif (report->new_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option new-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option new-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->new_oid));\n \t\t\tif (report->forced_update)\n-\t\t\t\tpacket_buf_write(&buf, \"option forced-update\\n\");\n+\t\t\t\tpacket_buf_write(buf, \"option forced-update\\n\");\n \t\t}\n \t}\n-\tpacket_buf_flush(&buf);\n+\n+\tpacket_buf_flush(buf);\n+}\n+\n+static void report(struct command *commands, const struct strbuf *unpack_status,\n+\t\t   enum report_status_version version)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_report(&buf, commands, unpack_status, version);\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n@@ -2605,10 +2598,8 @@ int cmd_receive_pack(int argc,\n \n \t\tswitch (version) {\n \t\tcase REPORT_STATUS_V2:\n-\t\t\treport_v2(commands, &unpack_status);\n-\t\t\tbreak;\n \t\tcase REPORT_STATUS_V0:\n-\t\t\treport(commands, &unpack_status);\n+\t\t\treport(commands, &unpack_status, version);\n \t\t\tbreak;\n \t\tdefault:\n \t\t\tBUG(\"unknown report status version\");\n\n-- \n2.55.GIT\n\n"},{"id":"552009","messageId":"20260904-758-introduce-hook-v7-4-6c66f0a3a572@gmail.com","threadId":"66186","inReplyTo":"20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com","subject":"[PATCH v7 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-04T21:28:52Z","receivedAt":"2026-09-04T21:29:04Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nThe outcome of these operations is only known after `execute_commands()`\nhas returned and before the report is written. There is no point in\nreceive-pack where the server can act on that.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook is not suited for this. It fires from\n    within `ref_transaction_commit()`, which is before the outcome we\n    need to report is known, so there is no phase at which it could give\n    us the answer. It also does not contain any knowledge regarding the\n    push and cannot communicate with the clients.\n\n  - The proc-receive hook replaces execute_commands() for references\n    matching 'receive.procReceiveRefs'. We need to gate the report for\n    the push as a whole.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'receive-report' hook. The hook receives the complete\npkt-line encoded status report on standard input, after all ref updates\nhave been applied to the repository by execute_commands() but before the\nreport is sent to the client. See linkgit:gitprotocol-pack[5] details on\nthe protocol structure.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviors depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n  all references to be rejected with a 'receive-report hook failed'\n  error.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nReference updates applied by execute_commands() are not rolled back in\neither failure mode. The hook can cause the client to perceive the push\nas failed, but cannot undo server-side changes. This creates a\ndivergence that the server cannot resolve: the client leaves its\nremote-tracking reference at the old value while the update is in fact\napplied, and a later fetch may reveal the update that the push reported\nas rejected.\n\nThe hook is therefore only appropriate for servers which can guarantee\nthat a rejected update is not observable by any reader. In our case the\ntransaction committed by execute_commands() produces a candidate version\nwhich is not visible to other readers and is only published once the\nsubsequent operations succeed, so a report of 'ng' corresponds to a\nversion that is discarded rather than published. On a repository where a\ncommitted reference update is immediately visible, rejecting a push from\nthis hook would instead leave the pusher with a view that does not match\nthe server.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc |   9 ++\n Documentation/githooks.adoc         |  61 ++++++++++\n builtin/receive-pack.c              |  47 ++++++++\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 342 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 5806792ba7..ab668ffa0c 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -245,6 +245,15 @@ commands will be executed by this hook, instead of by the internal\n `execute_commands()` function.  This hook is responsible for updating\n the relevant references and reporting the results back to 'receive-pack'.\n \n+RECEIVE-REPORT HOOK\n+-------------------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client, which allows the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n+\n QUARANTINE ENVIRONMENT\n ----------------------\n \ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..145642bf05 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,67 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+receive-report\n+~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after all\n+accepted ref changes are applied to the repository, but before the\n+pkt-line encoded status report is sent back to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input, see linkgit:gitprotocol-pack[5] for details on the\n+structure. The hook's standard output entirely replaces the report\n+that is sent to the client. The hook must write a valid pkt-line\n+encoded report in the same format it received. The hook's stdout is\n+fully buffered by `receive-pack` before any data is sent to the client,\n+so the hook's exit status is known before the client receives anything.\n+\n+There are three distinct ways the hook can affect the push outcome:\n+\n+* To reject the push, modify the unpack status from `ok` to the required\n+  error message. While `git-push` will fail, individual references may\n+  still show success messages unless modified.\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname>[ <reason>]` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  modifies all references to be rejected with a 'receive-report hook\n+  failed' error.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n+This means that reporting a reference as `ng` makes the client believe\n+the update did not happen while the server has in fact applied it. The\n+client leaves its remote-tracking reference at its old value, and a\n+later `git fetch` may reveal the very update that the push reported as\n+rejected. Neither Git nor the server can reconcile this; only the user,\n+by fetching again, will find out.\n+\n+This hook is therefore only appropriate for servers which can guarantee\n+that a rejected update is not observable by any reader, for example\n+because the committed transaction produces a candidate state that is\n+discarded rather than published. On a repository where a committed\n+reference update is immediately visible, using this hook to reject a\n+push will leave the pusher with a view that does not match the server.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 9c70da9ba1..533ad26c20 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -992,6 +992,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_receive_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"receive-report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"receive-report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2414,6 +2449,12 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n+static void override_cmds_error(struct command *commands, const char *err)\n+{\n+\tfor (struct command *cmd = commands; cmd; cmd = cmd->next)\n+\t\tcmd->error_string = err;\n+}\n+\n /*\n  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n  * For v2 protocol, set `detailed_report` to true, which will also add detailed\n@@ -2469,6 +2510,12 @@ static void report(struct command *commands, const struct strbuf *unpack_status,\n \n \tgenerate_report(&buf, commands, unpack_status, version);\n \n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n+\t\tgenerate_report(&buf, commands, unpack_status, false);\n+\t}\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 7f53cca7d1..692e6011c5 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -652,6 +652,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-receive-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-receive-report-hook.sh b/t/t5412-receive-report-hook.sh\nnew file mode 100755\nindex 0000000000..24679de37b\n--- /dev/null\n+++ b/t/t5412-receive-report-hook.sh\n@@ -0,0 +1,224 @@\n+#!/bin/sh\n+\n+test_description='test receive-report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit reports as hook failed\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup receive-report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can modify the unpack status\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^unpack ok$/unpack push failed due to server error/\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"error: remote unpack failed: push failed due to server error\" out &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n-- \n2.55.GIT\n\n"},{"id":"552090","messageId":"ap5Ud5OW2NXRuDoO@pks.im","threadId":"66186","inReplyTo":"20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com","subject":"Re: [PATCH v7 0/4] hook: introduce the receive-report hook","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-07T06:06:47Z","receivedAt":"2026-09-07T06:06:53Z","isPatch":true,"body":"On Fri, Sep 04, 2026 at 11:28:48PM +0200, Karthik Nayak wrote:\n> Changes in v7:\n> - Removed report_v2() since it is the same as report() with the new\n>   changes.\n> - Used a switch statement instead of an if/else for the enum.\n> - Removed an unnecessary curly brace.\n> - Also rebased on top of latest master (3cb9185f65 (The 22nd batch,\n>   2026-09-02) as there were conflicts.\n> - Link to v6: https://patch.msgid.link/20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com\n\nThanks, I'm happy with this version.\n\nPatrick\n"},{"id":"552165","messageId":"xmqq7bkw3hk7.fsf@gitster.g","threadId":"66186","inReplyTo":"20260904-758-introduce-hook-v7-4-6c66f0a3a572@gmail.com","subject":"Re: [PATCH v7 4/4] hook: introduce the receive-report hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-07T20:58:00Z","receivedAt":"2026-09-07T20:58:03Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> @@ -2469,6 +2510,12 @@ static void report(struct command *commands, const struct strbuf *unpack_status,\n>  \n>  \tgenerate_report(&buf, commands, unpack_status, version);\n>  \n> +\tif (run_receive_report_hook(&buf)) {\n> +\t\tstrbuf_reset(&buf);\n> +\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n> +\t\tgenerate_report(&buf, commands, unpack_status, false);\n> +\t}\n\nHmph, what does 'false' mean here?  Didn't you mean to use the same\n\"version\" like you used in the previous call in the preimage?\n\n\n"},{"id":"552166","messageId":"xmqq1pb43hjh.fsf@gitster.g","threadId":"66186","inReplyTo":"20260904-758-introduce-hook-v7-3-6c66f0a3a572@gmail.com","subject":"Re: [PATCH v7 3/4] receive-pack: move message generation to separate function","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-07T20:58:26Z","receivedAt":"2026-09-07T20:58:28Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> -static void report(struct command *commands, const struct strbuf *unpack_status)\n> +/*\n> + * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n> + * For v2 protocol, set `detailed_report` to true, which will also add detailed\n> + * report per reference update.\n> + */\n\nThe second sentence is stale and no longer matches the interface.\n\n> +static void generate_report(struct strbuf *buf, struct command *commands,\n> +\t\t\t    const struct strbuf *unpack_status,\n> +\t\t\t    enum report_status_version version)\n>  {\n"},{"id":"552167","messageId":"xmqqv78g22ys.fsf@gitster.g","threadId":"66186","inReplyTo":"20260904-758-introduce-hook-v7-2-6c66f0a3a572@gmail.com","subject":"Re: [PATCH v7 2/4] receive-pack: drop static variables to track report status version","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-07T20:58:35Z","receivedAt":"2026-09-07T20:58:37Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> +enum report_status_version {\n> +\tREPORT_STATUS_UNKOWN = 0,\n\nMissing 'N'?\n\n"},{"id":"552195","messageId":"CAOLa=ZSBqQ2P60VKBcRBekbug=4NiEj2kAmfUcZKK-GDnzSPUw@mail.gmail.com","threadId":"66186","inReplyTo":"xmqqv78g22ys.fsf@gitster.g","subject":"Re: [PATCH v7 2/4] receive-pack: drop static variables to track report status version","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T09:22:08Z","receivedAt":"2026-09-08T09:22:10Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> +enum report_status_version {\n>> +\tREPORT_STATUS_UNKOWN = 0,\n>\n> Missing 'N'?\n\nSigh! Not the first time for me with this word. Will fix.\n"},{"id":"552196","messageId":"CAOLa=ZS__CA3AhBTR+ndW=fJN1bA9VLa0v87eZ4ja+NN6vc9WA@mail.gmail.com","threadId":"66186","inReplyTo":"xmqq1pb43hjh.fsf@gitster.g","subject":"Re: [PATCH v7 3/4] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T09:22:50Z","receivedAt":"2026-09-08T09:22:52Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> -static void report(struct command *commands, const struct strbuf *unpack_status)\n>> +/*\n>> + * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n>> + * For v2 protocol, set `detailed_report` to true, which will also add detailed\n>> + * report per reference update.\n>> + */\n>\n> The second sentence is stale and no longer matches the interface.\n>\n\nWill drop.\n\n>> +static void generate_report(struct strbuf *buf, struct command *commands,\n>> +\t\t\t    const struct strbuf *unpack_status,\n>> +\t\t\t    enum report_status_version version)\n>>  {\n"},{"id":"552197","messageId":"CAOLa=ZTNMwJkXZ_CuKdR0F+_ZAxtr+eK6o=tjgUg4O4vFgPDpg@mail.gmail.com","threadId":"66186","inReplyTo":"xmqq7bkw3hk7.fsf@gitster.g","subject":"Re: [PATCH v7 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T09:57:17Z","receivedAt":"2026-09-08T09:57:18Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> @@ -2469,6 +2510,12 @@ static void report(struct command *commands, const struct strbuf *unpack_status,\n>>\n>>  \tgenerate_report(&buf, commands, unpack_status, version);\n>>\n>> +\tif (run_receive_report_hook(&buf)) {\n>> +\t\tstrbuf_reset(&buf);\n>> +\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n>> +\t\tgenerate_report(&buf, commands, unpack_status, false);\n>> +\t}\n>\n> Hmph, what does 'false' mean here?  Didn't you mean to use the same\n> \"version\" like you used in the previous call in the preimage?\n\nIndeed. It doesn't trip any test as we override the command error, so\nthe version argument passed as `false` here is never used.\n\nWill change.\n"},{"id":"552198","messageId":"20260908-758-introduce-hook-v8-0-be88a671ae1f@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v8 0/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T10:27:00Z","receivedAt":"2026-09-08T10:27:06Z","isPatch":true,"body":"Introduce a new receive-report hook which kicks in after the reference\ntransaction is complete, but before the report is sent to the client.\nThe hook receives the pkt-line encoded report in its stdin and its\nstdout replaces the report transferred to the user. If the hook exits\nwith a non-zero exit code, all references are marked as rejected.\n\nThe first patch, adds missing documentation to 'git-receive-pack.adoc'.\nThe second patch refactors code and the third patch contains the new\nhook.\n\n---\nChanges in v8:\n- Fix spelling mistake s/UNKOWN/UNKNOWN\n- Remove a stale comment from previous version.\n- Fix an argument which wasn't changed with the previous version's\n  changes.\n- Link to v7: https://patch.msgid.link/20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com\n\nChanges in v7:\n- Removed report_v2() since it is the same as report() with the new\n  changes.\n- Used a switch statement instead of an if/else for the enum.\n- Removed an unnecessary curly brace.\n- Also rebased on top of latest master (3cb9185f65 (The 22nd batch,\n  2026-09-02) as there were conflicts.\n- Link to v6: https://patch.msgid.link/20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com\n\nChanges in v6:\n- Introduce a new commit which introduces `enum report_status_version`,\n  use that and drop static variables in the codebase.\n- Reword the commit message and documentation to:\n  - State further why reference-transaction cannot be used.\n  - State the responsibility of the hook owner to undo and reference\n    changes if needed.\n- Link to v5: https://patch.msgid.link/20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com\n\nChanges in v5:\n- Rewrote some of the commit messages and documentation.\n- Renamed the function `generate_response` to `generate_report` to avoid\n  ambiguity.\n- We now override the cmd's error_strings, this avoids the whole\n  precedence issue with the earlier series.\n- Also add information about how we can override the unpack status to\n  fail the push and add a corresponding test.\n- Thanks to Patrick for the review!\n- Junio: This causes conflict with next ('jt/receive-pack-pluggable-writes')\n  similar to before, please let me know if its better for me to add that\n  dependency.\n- Link to v4: https://patch.msgid.link/20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com\n\nChanges in v4:\n- Change the name of the hook to be 'receive-report' to avoid ambiguity.\n- Link to v3: https://patch.msgid.link/20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com\n\nChanges in v3:\n- Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n  into a new commit.\n- Add a new commit to move out the response generation in receive-pack\n  to a new function.\n- Instead of die-ing on non-zero exit code, we modify each reference to\n  indicate that the hook failed.\n- Instead of correctly listing out the protocol, link to\n  linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n  and v2.\n- Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n\n To: git@vger.kernel.org\n CC: ps@pks.im\n CC: gitster@pobox.com\n CC: jltobler@gmail.com\n CC: kristofferhaugsbakk@fastmail.com\n CC: phillip.wood123@gmail.com\n\n---\nKarthik Nayak (4):\n      doc: add proc-receive hook info in 'git-receive-pack.adoc'\n      receive-pack: drop static variables to track report status version\n      receive-pack: move message generation to separate function\n      hook: introduce the receive-report hook\n\n Documentation/git-receive-pack.adoc |  17 +++\n Documentation/githooks.adoc         |  61 ++++++++++\n builtin/receive-pack.c              | 146 +++++++++++++++--------\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 401 insertions(+), 48 deletions(-)\n\nRange-diff versus v7:\n\n1:  a80b77f284 = 1:  55f08ef482 doc: add proc-receive hook info in 'git-receive-pack.adoc'\n2:  116699bd30 ! 2:  df5840c4fa receive-pack: drop static variables to track report status version\n    @@ builtin/receive-pack.c: enum deny_action {\n      };\n      \n     +enum report_status_version {\n    -+\tREPORT_STATUS_UNKOWN = 0,\n    ++\tREPORT_STATUS_UNKNOWN = 0,\n     +\tREPORT_STATUS_V0,\n     +\tREPORT_STATUS_V2,\n     +};\n    @@ builtin/receive-pack.c: int cmd_receive_pack(int argc,\n      \tstruct shallow_info si;\n      \tstruct packet_reader reader;\n      \tstruct odb_transaction *transaction = NULL;\n    -+\tenum report_status_version version = REPORT_STATUS_UNKOWN;\n    ++\tenum report_status_version version = REPORT_STATUS_UNKNOWN;\n      \n      \tstruct option options[] = {\n      \t\tOPT__QUIET(&quiet, N_(\"quiet\")),\n3:  e36b4e7a21 ! 3:  d7c3b7ed55 receive-pack: move message generation to separate function\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     -static void report(struct command *commands, const struct strbuf *unpack_status)\n     +/*\n     + * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n    -+ * For v2 protocol, set `detailed_report` to true, which will also add detailed\n    -+ * report per reference update.\n     + */\n     +static void generate_report(struct strbuf *buf, struct command *commands,\n     +\t\t\t    const struct strbuf *unpack_status,\n4:  e7c1c0d196 ! 4:  d0e632235a hook: introduce the receive-report hook\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n     +\n      /*\n       * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n    -  * For v2 protocol, set `detailed_report` to true, which will also add detailed\n    +  */\n     @@ builtin/receive-pack.c: static void report(struct command *commands, const struct strbuf *unpack_status,\n      \n      \tgenerate_report(&buf, commands, unpack_status, version);\n    @@ builtin/receive-pack.c: static void report(struct command *commands, const struc\n     +\tif (run_receive_report_hook(&buf)) {\n     +\t\tstrbuf_reset(&buf);\n     +\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n    -+\t\tgenerate_report(&buf, commands, unpack_status, false);\n    ++\t\tgenerate_report(&buf, commands, unpack_status, version);\n     +\t}\n     +\n      \tif (use_sideband)\n\n---\nbase-commit: 3cb9185f65410273787f74333cc027d2ea5daada\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"552199","messageId":"20260908-758-introduce-hook-v8-1-be88a671ae1f@gmail.com","threadId":"66186","inReplyTo":"20260908-758-introduce-hook-v8-0-be88a671ae1f@gmail.com","subject":"[PATCH v8 1/4] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T10:27:01Z","receivedAt":"2026-09-08T10:27:07Z","isPatch":true,"body":"The manpage of git-receive-pack(1) documents hooks invoked when\nreceiving a push. The manpage does not mention the 'proc-receive' hook\nthough, which is also invoked as part of that process. Add a paragraph\nabout this hook to plug that gap.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc | 8 ++++++++\n 1 file changed, 8 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..5806792ba7 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,14 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by linkgit:git-receive-pack[1].  If the server has\n+set the multi-valued config variable `receive.procReceiveRefs`, and the\n+commands sent to 'receive-pack' have matching reference names, these\n+commands will be executed by this hook, instead of by the internal\n+`execute_commands()` function.  This hook is responsible for updating\n+the relevant references and reporting the results back to 'receive-pack'.\n \n QUARANTINE ENVIRONMENT\n ----------------------\n\n-- \n2.55.GIT\n\n"},{"id":"552200","messageId":"20260908-758-introduce-hook-v8-2-be88a671ae1f@gmail.com","threadId":"66186","inReplyTo":"20260908-758-introduce-hook-v8-0-be88a671ae1f@gmail.com","subject":"[PATCH v8 2/4] receive-pack: drop static variables to track report status version","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T10:27:02Z","receivedAt":"2026-09-08T10:27:08Z","isPatch":true,"body":"In 'git-receive-pack(1)', to track the report status version, we use the\nstatic variables `report_status` and `report_status_v2`. As the report\nstatus version is mutually exclusive, using an enum better suits the\nrequirement. switch to using a new `enum report_status_version`, while\nalso dropping the static variable to make the flow easier to understand.\n\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 30 ++++++++++++++++++++++--------\n 1 file changed, 22 insertions(+), 8 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex e6e54ba55f..f7ff6a9abe 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -53,6 +53,12 @@ enum deny_action {\n \tDENY_UPDATE_INSTEAD\n };\n \n+enum report_status_version {\n+\tREPORT_STATUS_UNKNOWN = 0,\n+\tREPORT_STATUS_V0,\n+\tREPORT_STATUS_V2,\n+};\n+\n static int deny_deletes;\n static int deny_non_fast_forwards;\n static enum deny_action deny_current_branch = DENY_UNCONFIGURED;\n@@ -64,8 +70,6 @@ static int advertise_atomic_push = 1;\n static int advertise_push_options;\n static int advertise_sid;\n static off_t max_input_size;\n-static int report_status;\n-static int report_status_v2;\n static int use_sideband;\n static int use_atomic;\n static int use_push_options;\n@@ -2191,7 +2195,8 @@ static void queue_commands_from_cert(struct command **tail,\n }\n \n static struct command *read_head_info(struct packet_reader *reader,\n-\t\t\t\t      struct oid_array *shallow)\n+\t\t\t\t      struct oid_array *shallow,\n+\t\t\t\t      enum report_status_version *version)\n {\n \tstruct command *commands = NULL;\n \tstruct command **p = &commands;\n@@ -2217,9 +2222,9 @@ static struct command *read_head_info(struct packet_reader *reader,\n \t\t\tconst char *client_sid;\n \t\t\tsize_t len = 0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status\"))\n-\t\t\t\treport_status = 1;\n+\t\t\t\t*version = REPORT_STATUS_V0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status-v2\"))\n-\t\t\t\treport_status_v2 = 1;\n+\t\t\t\t*version = REPORT_STATUS_V2;\n \t\t\tif (parse_feature_request(feature_list, \"side-band-64k\"))\n \t\t\t\tuse_sideband = LARGE_PACKET_MAX;\n \t\t\tif (parse_feature_request(feature_list, \"quiet\"))\n@@ -2500,6 +2505,7 @@ int cmd_receive_pack(int argc,\n \tstruct shallow_info si;\n \tstruct packet_reader reader;\n \tstruct odb_transaction *transaction = NULL;\n+\tenum report_status_version version = REPORT_STATUS_UNKNOWN;\n \n \tstruct option options[] = {\n \t\tOPT__QUIET(&quiet, N_(\"quiet\")),\n@@ -2563,7 +2569,7 @@ int cmd_receive_pack(int argc,\n \t\t\t   PACKET_READ_CHOMP_NEWLINE |\n \t\t\t   PACKET_READ_DIE_ON_ERR_PACKET);\n \n-\tif ((commands = read_head_info(&reader, &shallow))) {\n+\tif ((commands = read_head_info(&reader, &shallow, &version))) {\n \t\tstruct string_list push_options = STRING_LIST_INIT_DUP;\n \t\tstruct strbuf unpack_status = STRBUF_INIT;\n \n@@ -2596,10 +2602,18 @@ int cmd_receive_pack(int argc,\n \t\t\t\t &push_options);\n \t\todb_transaction_finalize(transaction);\n \t\tsigchain_push(SIGPIPE, SIG_IGN);\n-\t\tif (report_status_v2)\n+\n+\t\tswitch (version) {\n+\t\tcase REPORT_STATUS_V2:\n \t\t\treport_v2(commands, &unpack_status);\n-\t\telse if (report_status)\n+\t\t\tbreak;\n+\t\tcase REPORT_STATUS_V0:\n \t\t\treport(commands, &unpack_status);\n+\t\t\tbreak;\n+\t\tdefault:\n+\t\t\tBUG(\"unknown report status version\");\n+\t\t}\n+\n \t\tsigchain_pop(SIGPIPE);\n \t\trun_receive_hook(commands, \"post-receive\", 1, NULL,\n \t\t\t\t &push_options);\n\n-- \n2.55.GIT\n\n"},{"id":"552201","messageId":"20260908-758-introduce-hook-v8-3-be88a671ae1f@gmail.com","threadId":"66186","inReplyTo":"20260908-758-introduce-hook-v8-0-be88a671ae1f@gmail.com","subject":"[PATCH v8 3/4] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T10:27:03Z","receivedAt":"2026-09-08T10:27:09Z","isPatch":true,"body":"After git-receive-pack(1) has committed the reference updates, we call\neither `report()` or `report_v2()` to report to the client which of the\nreferences we have updated successfully and which updates have failed.\nThe only difference between those two functions is that the latter also\nknows to provide a more detailed report about how exactly a given\nreference was updated.\n\nWith this, also drop `report_v2()` as both report functions now are\nsimilar in structure with only the `report_status_version`\ndifferentiating them.\n\nIn the next commit we're about to add another site that wants to\ngenerate these reports. Refactor the logic into a shared function that\ncan easily be reused.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 75 +++++++++++++++++++++-----------------------------\n 1 file changed, 32 insertions(+), 43 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex f7ff6a9abe..8310844ab1 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2414,67 +2414,58 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n-static void report(struct command *commands, const struct strbuf *unpack_status)\n+/*\n+ * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n+ */\n+static void generate_report(struct strbuf *buf, struct command *commands,\n+\t\t\t    const struct strbuf *unpack_status,\n+\t\t\t    enum report_status_version version)\n {\n \tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n+\tpacket_buf_write(buf, \"unpack %s\\n\",\n \t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n-\tfor (cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (!cmd->error_string)\n-\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t\t cmd->ref_name);\n-\t\telse\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name, cmd->error_string);\n-\t}\n-\tpacket_buf_flush(&buf);\n-\n-\tif (use_sideband)\n-\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n-\telse\n-\t\twrite_or_die(1, buf.buf, buf.len);\n-\tstrbuf_release(&buf);\n-}\n-\n-static void report_v2(struct command *commands, const struct strbuf *unpack_status)\n-{\n-\tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n-\tstruct ref_push_report *report;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n-\t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n \tfor (cmd = commands; cmd; cmd = cmd->next) {\n+\t\tstruct ref_push_report *report;\n \t\tint count = 0;\n \n-\t\tif (cmd->error_string) {\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name,\n-\t\t\t\t\t cmd->error_string);\n+\t\tif (cmd->error_string)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse\n+\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n+\n+\t\tif (version != REPORT_STATUS_V2 || cmd->error_string)\n \t\t\tcontinue;\n-\t\t}\n-\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t cmd->ref_name);\n+\n \t\tfor (report = cmd->report; report; report = report->next) {\n \t\t\tif (count++ > 0)\n-\t\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"ok %s\\n\",\n \t\t\t\t\t\t cmd->ref_name);\n \t\t\tif (report->ref_name)\n-\t\t\t\tpacket_buf_write(&buf, \"option refname %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option refname %s\\n\",\n \t\t\t\t\t\t report->ref_name);\n \t\t\tif (report->old_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option old-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option old-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->old_oid));\n \t\t\tif (report->new_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option new-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option new-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->new_oid));\n \t\t\tif (report->forced_update)\n-\t\t\t\tpacket_buf_write(&buf, \"option forced-update\\n\");\n+\t\t\t\tpacket_buf_write(buf, \"option forced-update\\n\");\n \t\t}\n \t}\n-\tpacket_buf_flush(&buf);\n+\n+\tpacket_buf_flush(buf);\n+}\n+\n+static void report(struct command *commands, const struct strbuf *unpack_status,\n+\t\t   enum report_status_version version)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_report(&buf, commands, unpack_status, version);\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n@@ -2605,10 +2596,8 @@ int cmd_receive_pack(int argc,\n \n \t\tswitch (version) {\n \t\tcase REPORT_STATUS_V2:\n-\t\t\treport_v2(commands, &unpack_status);\n-\t\t\tbreak;\n \t\tcase REPORT_STATUS_V0:\n-\t\t\treport(commands, &unpack_status);\n+\t\t\treport(commands, &unpack_status, version);\n \t\t\tbreak;\n \t\tdefault:\n \t\t\tBUG(\"unknown report status version\");\n\n-- \n2.55.GIT\n\n"},{"id":"552202","messageId":"20260908-758-introduce-hook-v8-4-be88a671ae1f@gmail.com","threadId":"66186","inReplyTo":"20260908-758-introduce-hook-v8-0-be88a671ae1f@gmail.com","subject":"[PATCH v8 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T10:27:04Z","receivedAt":"2026-09-08T10:27:10Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nThe outcome of these operations is only known after `execute_commands()`\nhas returned and before the report is written. There is no point in\nreceive-pack where the server can act on that.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook is not suited for this. It fires from\n    within `ref_transaction_commit()`, which is before the outcome we\n    need to report is known, so there is no phase at which it could give\n    us the answer. It also does not contain any knowledge regarding the\n    push and cannot communicate with the clients.\n\n  - The proc-receive hook replaces execute_commands() for references\n    matching 'receive.procReceiveRefs'. We need to gate the report for\n    the push as a whole.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'receive-report' hook. The hook receives the complete\npkt-line encoded status report on standard input, after all ref updates\nhave been applied to the repository by execute_commands() but before the\nreport is sent to the client. See linkgit:gitprotocol-pack[5] details on\nthe protocol structure.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviors depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n  all references to be rejected with a 'receive-report hook failed'\n  error.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nReference updates applied by execute_commands() are not rolled back in\neither failure mode. The hook can cause the client to perceive the push\nas failed, but cannot undo server-side changes. This creates a\ndivergence that the server cannot resolve: the client leaves its\nremote-tracking reference at the old value while the update is in fact\napplied, and a later fetch may reveal the update that the push reported\nas rejected.\n\nThe hook is therefore only appropriate for servers which can guarantee\nthat a rejected update is not observable by any reader. In our case the\ntransaction committed by execute_commands() produces a candidate version\nwhich is not visible to other readers and is only published once the\nsubsequent operations succeed, so a report of 'ng' corresponds to a\nversion that is discarded rather than published. On a repository where a\ncommitted reference update is immediately visible, rejecting a push from\nthis hook would instead leave the pusher with a view that does not match\nthe server.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc |   9 ++\n Documentation/githooks.adoc         |  61 ++++++++++\n builtin/receive-pack.c              |  47 ++++++++\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 342 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 5806792ba7..ab668ffa0c 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -245,6 +245,15 @@ commands will be executed by this hook, instead of by the internal\n `execute_commands()` function.  This hook is responsible for updating\n the relevant references and reporting the results back to 'receive-pack'.\n \n+RECEIVE-REPORT HOOK\n+-------------------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client, which allows the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n+\n QUARANTINE ENVIRONMENT\n ----------------------\n \ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..145642bf05 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,67 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+receive-report\n+~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after all\n+accepted ref changes are applied to the repository, but before the\n+pkt-line encoded status report is sent back to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input, see linkgit:gitprotocol-pack[5] for details on the\n+structure. The hook's standard output entirely replaces the report\n+that is sent to the client. The hook must write a valid pkt-line\n+encoded report in the same format it received. The hook's stdout is\n+fully buffered by `receive-pack` before any data is sent to the client,\n+so the hook's exit status is known before the client receives anything.\n+\n+There are three distinct ways the hook can affect the push outcome:\n+\n+* To reject the push, modify the unpack status from `ok` to the required\n+  error message. While `git-push` will fail, individual references may\n+  still show success messages unless modified.\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname>[ <reason>]` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  modifies all references to be rejected with a 'receive-report hook\n+  failed' error.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n+This means that reporting a reference as `ng` makes the client believe\n+the update did not happen while the server has in fact applied it. The\n+client leaves its remote-tracking reference at its old value, and a\n+later `git fetch` may reveal the very update that the push reported as\n+rejected. Neither Git nor the server can reconcile this; only the user,\n+by fetching again, will find out.\n+\n+This hook is therefore only appropriate for servers which can guarantee\n+that a rejected update is not observable by any reader, for example\n+because the committed transaction produces a candidate state that is\n+discarded rather than published. On a repository where a committed\n+reference update is immediately visible, using this hook to reject a\n+push will leave the pusher with a view that does not match the server.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 8310844ab1..cadcc0ac8f 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -992,6 +992,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_receive_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"receive-report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"receive-report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2414,6 +2449,12 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n+static void override_cmds_error(struct command *commands, const char *err)\n+{\n+\tfor (struct command *cmd = commands; cmd; cmd = cmd->next)\n+\t\tcmd->error_string = err;\n+}\n+\n /*\n  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n  */\n@@ -2467,6 +2508,12 @@ static void report(struct command *commands, const struct strbuf *unpack_status,\n \n \tgenerate_report(&buf, commands, unpack_status, version);\n \n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n+\t\tgenerate_report(&buf, commands, unpack_status, version);\n+\t}\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 7f53cca7d1..692e6011c5 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -652,6 +652,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-receive-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-receive-report-hook.sh b/t/t5412-receive-report-hook.sh\nnew file mode 100755\nindex 0000000000..24679de37b\n--- /dev/null\n+++ b/t/t5412-receive-report-hook.sh\n@@ -0,0 +1,224 @@\n+#!/bin/sh\n+\n+test_description='test receive-report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit reports as hook failed\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup receive-report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can modify the unpack status\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^unpack ok$/unpack push failed due to server error/\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"error: remote unpack failed: push failed due to server error\" out &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n-- \n2.55.GIT\n\n"},{"id":"552208","messageId":"CAOLa=ZRg2nH8xmjSKEs-hMKcKXPOgo+tqF1VrJx5_APkWeW0dw@mail.gmail.com","threadId":"66186","inReplyTo":"ap5Ud5OW2NXRuDoO@pks.im","subject":"Re: [PATCH v7 0/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-08T11:48:36Z","receivedAt":"2026-09-08T11:48:38Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Fri, Sep 04, 2026 at 11:28:48PM +0200, Karthik Nayak wrote:\n>> Changes in v7:\n>> - Removed report_v2() since it is the same as report() with the new\n>>   changes.\n>> - Used a switch statement instead of an if/else for the enum.\n>> - Removed an unnecessary curly brace.\n>> - Also rebased on top of latest master (3cb9185f65 (The 22nd batch,\n>>   2026-09-02) as there were conflicts.\n>> - Link to v6: https://patch.msgid.link/20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com\n>\n> Thanks, I'm happy with this version.\n>\n> Patrick\n\nThanks for the review!\n"},{"id":"552250","messageId":"xmqqa4prwmja.fsf@gitster.g","threadId":"66186","inReplyTo":"20260908-758-introduce-hook-v8-2-be88a671ae1f@gmail.com","subject":"Re: [PATCH v8 2/4] receive-pack: drop static variables to track report status version","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-08T19:50:01Z","receivedAt":"2026-09-08T19:50:05Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> @@ -2563,7 +2569,7 @@ int cmd_receive_pack(int argc,\n>  \t\t\t   PACKET_READ_CHOMP_NEWLINE |\n>  \t\t\t   PACKET_READ_DIE_ON_ERR_PACKET);\n>  \n> -\tif ((commands = read_head_info(&reader, &shallow))) {\n> +\tif ((commands = read_head_info(&reader, &shallow, &version))) {\n>  \t\tstruct string_list push_options = STRING_LIST_INIT_DUP;\n>  \t\tstruct strbuf unpack_status = STRBUF_INIT;\n>  \n> @@ -2596,10 +2602,18 @@ int cmd_receive_pack(int argc,\n>  \t\t\t\t &push_options);\n>  \t\todb_transaction_finalize(transaction);\n>  \t\tsigchain_push(SIGPIPE, SIG_IGN);\n> -\t\tif (report_status_v2)\n> +\n> +\t\tswitch (version) {\n> +\t\tcase REPORT_STATUS_V2:\n>  \t\t\treport_v2(commands, &unpack_status);\n> -\t\telse if (report_status)\n> +\t\t\tbreak;\n> +\t\tcase REPORT_STATUS_V0:\n>  \t\t\treport(commands, &unpack_status);\n> +\t\t\tbreak;\n> +\t\tdefault:\n> +\t\t\tBUG(\"unknown report status version\");\n> +\t\t}\n\nSorry that I should have noticed earlier, but is this really what we\nwant?  version is read by read_head_info() from the other side, and\nin earlier iterations of this series we used to have something like\n\n\tif (report_status_v2)\n\t\treport_v2(...);\n\telse if (report_status)\n\t\treport(...);\n\nwithout \"else die()\".\n\nIn any case, BUG() here is inappropriate, as setting \"version\" to v0\nor v2 is totally up to what the other side of the connection would\nsay.  BUG() is about a programming error in our code, on _this_ end\nof the connection.\n\nWe probably should have\n\n\t\tcase REPORT_STATUS_UNKNOWN:\n\t\t\tbreak;\n\nto catch the case where the other side did not ask any report-status\nand do nothing about it.\n\n\n"},{"id":"552329","messageId":"20260909-758-introduce-hook-v9-0-3043d417e0ee@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v9 0/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-09T14:51:35Z","receivedAt":"2026-09-09T14:51:41Z","isPatch":true,"body":"Introduce a new receive-report hook which kicks in after the reference\ntransaction is complete, but before the report is sent to the client.\nThe hook receives the pkt-line encoded report in its stdin and its\nstdout replaces the report transferred to the user. If the hook exits\nwith a non-zero exit code, all references are marked as rejected.\n\nThe first patch, adds missing documentation to 'git-receive-pack.adoc'.\nThe second patch refactors code and the third patch contains the new\nhook.\n\n---\nChanges in v9:\n- Fix a bug where we were causing a BUG() when no report was requested.\n  It is perfectly valid for clients to skip the report and we shouldn't\n  fail when they do so. Thanks Junio!\n- Link to v8: https://patch.msgid.link/20260908-758-introduce-hook-v8-0-be88a671ae1f@gmail.com\n\nChanges in v8:\n- Fix spelling mistake s/UNKOWN/UNKNOWN\n- Remove a stale comment from previous version.\n- Fix an argument which wasn't changed with the previous version's\n  changes.\n- Link to v7: https://patch.msgid.link/20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com\n\nChanges in v7:\n- Removed report_v2() since it is the same as report() with the new\n  changes.\n- Used a switch statement instead of an if/else for the enum.\n- Removed an unnecessary curly brace.\n- Also rebased on top of latest master (3cb9185f65 (The 22nd batch,\n  2026-09-02) as there were conflicts.\n- Link to v6: https://patch.msgid.link/20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com\n\nChanges in v6:\n- Introduce a new commit which introduces `enum report_status_version`,\n  use that and drop static variables in the codebase.\n- Reword the commit message and documentation to:\n  - State further why reference-transaction cannot be used.\n  - State the responsibility of the hook owner to undo and reference\n    changes if needed.\n- Link to v5: https://patch.msgid.link/20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com\n\nChanges in v5:\n- Rewrote some of the commit messages and documentation.\n- Renamed the function `generate_response` to `generate_report` to avoid\n  ambiguity.\n- We now override the cmd's error_strings, this avoids the whole\n  precedence issue with the earlier series.\n- Also add information about how we can override the unpack status to\n  fail the push and add a corresponding test.\n- Thanks to Patrick for the review!\n- Junio: This causes conflict with next ('jt/receive-pack-pluggable-writes')\n  similar to before, please let me know if its better for me to add that\n  dependency.\n- Link to v4: https://patch.msgid.link/20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com\n\nChanges in v4:\n- Change the name of the hook to be 'receive-report' to avoid ambiguity.\n- Link to v3: https://patch.msgid.link/20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com\n\nChanges in v3:\n- Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n  into a new commit.\n- Add a new commit to move out the response generation in receive-pack\n  to a new function.\n- Instead of die-ing on non-zero exit code, we modify each reference to\n  indicate that the hook failed.\n- Instead of correctly listing out the protocol, link to\n  linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n  and v2.\n- Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n\n To: git@vger.kernel.org\n CC: ps@pks.im\n CC: gitster@pobox.com\n CC: jltobler@gmail.com\n CC: kristofferhaugsbakk@fastmail.com\n CC: phillip.wood123@gmail.com\n\n---\nKarthik Nayak (4):\n      doc: add proc-receive hook info in 'git-receive-pack.adoc'\n      receive-pack: drop static variables to track report status version\n      receive-pack: move message generation to separate function\n      hook: introduce the receive-report hook\n\n Documentation/git-receive-pack.adoc |  17 +++\n Documentation/githooks.adoc         |  61 ++++++++++\n builtin/receive-pack.c              | 146 +++++++++++++++--------\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 401 insertions(+), 48 deletions(-)\n\nRange-diff versus v8:\n\n1:  c6a3771d9c = 1:  4c9a431c76 doc: add proc-receive hook info in 'git-receive-pack.adoc'\n2:  427d2ac58c ! 2:  2f86f33735 receive-pack: drop static variables to track report status version\n    @@ builtin/receive-pack.c: int cmd_receive_pack(int argc,\n     +\t\tcase REPORT_STATUS_V0:\n      \t\t\treport(commands, &unpack_status);\n     +\t\t\tbreak;\n    -+\t\tdefault:\n    -+\t\t\tBUG(\"unknown report status version\");\n    ++\t\tcase REPORT_STATUS_UNKNOWN:\n    ++\t\t\tbreak;\n     +\t\t}\n     +\n      \t\tsigchain_pop(SIGPIPE);\n3:  b0d2b63432 ! 3:  469d692696 receive-pack: move message generation to separate function\n    @@ builtin/receive-pack.c: int cmd_receive_pack(int argc,\n     -\t\t\treport(commands, &unpack_status);\n     +\t\t\treport(commands, &unpack_status, version);\n      \t\t\tbreak;\n    - \t\tdefault:\n    - \t\t\tBUG(\"unknown report status version\");\n    + \t\tcase REPORT_STATUS_UNKNOWN:\n    + \t\t\tbreak;\n4:  eca9cb06a4 = 4:  0ea2855658 hook: introduce the receive-report hook\n\n---\nbase-commit: 3cb9185f65410273787f74333cc027d2ea5daada\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"552330","messageId":"20260909-758-introduce-hook-v9-1-3043d417e0ee@gmail.com","threadId":"66186","inReplyTo":"20260909-758-introduce-hook-v9-0-3043d417e0ee@gmail.com","subject":"[PATCH v9 1/4] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-09T14:51:36Z","receivedAt":"2026-09-09T14:51:42Z","isPatch":true,"body":"The manpage of git-receive-pack(1) documents hooks invoked when\nreceiving a push. The manpage does not mention the 'proc-receive' hook\nthough, which is also invoked as part of that process. Add a paragraph\nabout this hook to plug that gap.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc | 8 ++++++++\n 1 file changed, 8 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..5806792ba7 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,14 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by linkgit:git-receive-pack[1].  If the server has\n+set the multi-valued config variable `receive.procReceiveRefs`, and the\n+commands sent to 'receive-pack' have matching reference names, these\n+commands will be executed by this hook, instead of by the internal\n+`execute_commands()` function.  This hook is responsible for updating\n+the relevant references and reporting the results back to 'receive-pack'.\n \n QUARANTINE ENVIRONMENT\n ----------------------\n\n-- \n2.55.GIT\n\n"},{"id":"552331","messageId":"20260909-758-introduce-hook-v9-2-3043d417e0ee@gmail.com","threadId":"66186","inReplyTo":"20260909-758-introduce-hook-v9-0-3043d417e0ee@gmail.com","subject":"[PATCH v9 2/4] receive-pack: drop static variables to track report status version","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-09T14:51:37Z","receivedAt":"2026-09-09T14:51:43Z","isPatch":true,"body":"In 'git-receive-pack(1)', to track the report status version, we use the\nstatic variables `report_status` and `report_status_v2`. As the report\nstatus version is mutually exclusive, using an enum better suits the\nrequirement. switch to using a new `enum report_status_version`, while\nalso dropping the static variable to make the flow easier to understand.\n\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 30 ++++++++++++++++++++++--------\n 1 file changed, 22 insertions(+), 8 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex e6e54ba55f..75a1788fa5 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -53,6 +53,12 @@ enum deny_action {\n \tDENY_UPDATE_INSTEAD\n };\n \n+enum report_status_version {\n+\tREPORT_STATUS_UNKNOWN = 0,\n+\tREPORT_STATUS_V0,\n+\tREPORT_STATUS_V2,\n+};\n+\n static int deny_deletes;\n static int deny_non_fast_forwards;\n static enum deny_action deny_current_branch = DENY_UNCONFIGURED;\n@@ -64,8 +70,6 @@ static int advertise_atomic_push = 1;\n static int advertise_push_options;\n static int advertise_sid;\n static off_t max_input_size;\n-static int report_status;\n-static int report_status_v2;\n static int use_sideband;\n static int use_atomic;\n static int use_push_options;\n@@ -2191,7 +2195,8 @@ static void queue_commands_from_cert(struct command **tail,\n }\n \n static struct command *read_head_info(struct packet_reader *reader,\n-\t\t\t\t      struct oid_array *shallow)\n+\t\t\t\t      struct oid_array *shallow,\n+\t\t\t\t      enum report_status_version *version)\n {\n \tstruct command *commands = NULL;\n \tstruct command **p = &commands;\n@@ -2217,9 +2222,9 @@ static struct command *read_head_info(struct packet_reader *reader,\n \t\t\tconst char *client_sid;\n \t\t\tsize_t len = 0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status\"))\n-\t\t\t\treport_status = 1;\n+\t\t\t\t*version = REPORT_STATUS_V0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status-v2\"))\n-\t\t\t\treport_status_v2 = 1;\n+\t\t\t\t*version = REPORT_STATUS_V2;\n \t\t\tif (parse_feature_request(feature_list, \"side-band-64k\"))\n \t\t\t\tuse_sideband = LARGE_PACKET_MAX;\n \t\t\tif (parse_feature_request(feature_list, \"quiet\"))\n@@ -2500,6 +2505,7 @@ int cmd_receive_pack(int argc,\n \tstruct shallow_info si;\n \tstruct packet_reader reader;\n \tstruct odb_transaction *transaction = NULL;\n+\tenum report_status_version version = REPORT_STATUS_UNKNOWN;\n \n \tstruct option options[] = {\n \t\tOPT__QUIET(&quiet, N_(\"quiet\")),\n@@ -2563,7 +2569,7 @@ int cmd_receive_pack(int argc,\n \t\t\t   PACKET_READ_CHOMP_NEWLINE |\n \t\t\t   PACKET_READ_DIE_ON_ERR_PACKET);\n \n-\tif ((commands = read_head_info(&reader, &shallow))) {\n+\tif ((commands = read_head_info(&reader, &shallow, &version))) {\n \t\tstruct string_list push_options = STRING_LIST_INIT_DUP;\n \t\tstruct strbuf unpack_status = STRBUF_INIT;\n \n@@ -2596,10 +2602,18 @@ int cmd_receive_pack(int argc,\n \t\t\t\t &push_options);\n \t\todb_transaction_finalize(transaction);\n \t\tsigchain_push(SIGPIPE, SIG_IGN);\n-\t\tif (report_status_v2)\n+\n+\t\tswitch (version) {\n+\t\tcase REPORT_STATUS_V2:\n \t\t\treport_v2(commands, &unpack_status);\n-\t\telse if (report_status)\n+\t\t\tbreak;\n+\t\tcase REPORT_STATUS_V0:\n \t\t\treport(commands, &unpack_status);\n+\t\t\tbreak;\n+\t\tcase REPORT_STATUS_UNKNOWN:\n+\t\t\tbreak;\n+\t\t}\n+\n \t\tsigchain_pop(SIGPIPE);\n \t\trun_receive_hook(commands, \"post-receive\", 1, NULL,\n \t\t\t\t &push_options);\n\n-- \n2.55.GIT\n\n"},{"id":"552332","messageId":"20260909-758-introduce-hook-v9-3-3043d417e0ee@gmail.com","threadId":"66186","inReplyTo":"20260909-758-introduce-hook-v9-0-3043d417e0ee@gmail.com","subject":"[PATCH v9 3/4] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-09T14:51:38Z","receivedAt":"2026-09-09T14:51:44Z","isPatch":true,"body":"After git-receive-pack(1) has committed the reference updates, we call\neither `report()` or `report_v2()` to report to the client which of the\nreferences we have updated successfully and which updates have failed.\nThe only difference between those two functions is that the latter also\nknows to provide a more detailed report about how exactly a given\nreference was updated.\n\nWith this, also drop `report_v2()` as both report functions now are\nsimilar in structure with only the `report_status_version`\ndifferentiating them.\n\nIn the next commit we're about to add another site that wants to\ngenerate these reports. Refactor the logic into a shared function that\ncan easily be reused.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 75 +++++++++++++++++++++-----------------------------\n 1 file changed, 32 insertions(+), 43 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 75a1788fa5..8b1ae4f7f3 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2414,67 +2414,58 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n-static void report(struct command *commands, const struct strbuf *unpack_status)\n+/*\n+ * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n+ */\n+static void generate_report(struct strbuf *buf, struct command *commands,\n+\t\t\t    const struct strbuf *unpack_status,\n+\t\t\t    enum report_status_version version)\n {\n \tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n+\tpacket_buf_write(buf, \"unpack %s\\n\",\n \t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n-\tfor (cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (!cmd->error_string)\n-\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t\t cmd->ref_name);\n-\t\telse\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name, cmd->error_string);\n-\t}\n-\tpacket_buf_flush(&buf);\n-\n-\tif (use_sideband)\n-\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n-\telse\n-\t\twrite_or_die(1, buf.buf, buf.len);\n-\tstrbuf_release(&buf);\n-}\n-\n-static void report_v2(struct command *commands, const struct strbuf *unpack_status)\n-{\n-\tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n-\tstruct ref_push_report *report;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n-\t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n \tfor (cmd = commands; cmd; cmd = cmd->next) {\n+\t\tstruct ref_push_report *report;\n \t\tint count = 0;\n \n-\t\tif (cmd->error_string) {\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name,\n-\t\t\t\t\t cmd->error_string);\n+\t\tif (cmd->error_string)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse\n+\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n+\n+\t\tif (version != REPORT_STATUS_V2 || cmd->error_string)\n \t\t\tcontinue;\n-\t\t}\n-\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t cmd->ref_name);\n+\n \t\tfor (report = cmd->report; report; report = report->next) {\n \t\t\tif (count++ > 0)\n-\t\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"ok %s\\n\",\n \t\t\t\t\t\t cmd->ref_name);\n \t\t\tif (report->ref_name)\n-\t\t\t\tpacket_buf_write(&buf, \"option refname %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option refname %s\\n\",\n \t\t\t\t\t\t report->ref_name);\n \t\t\tif (report->old_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option old-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option old-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->old_oid));\n \t\t\tif (report->new_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option new-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option new-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->new_oid));\n \t\t\tif (report->forced_update)\n-\t\t\t\tpacket_buf_write(&buf, \"option forced-update\\n\");\n+\t\t\t\tpacket_buf_write(buf, \"option forced-update\\n\");\n \t\t}\n \t}\n-\tpacket_buf_flush(&buf);\n+\n+\tpacket_buf_flush(buf);\n+}\n+\n+static void report(struct command *commands, const struct strbuf *unpack_status,\n+\t\t   enum report_status_version version)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_report(&buf, commands, unpack_status, version);\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n@@ -2605,10 +2596,8 @@ int cmd_receive_pack(int argc,\n \n \t\tswitch (version) {\n \t\tcase REPORT_STATUS_V2:\n-\t\t\treport_v2(commands, &unpack_status);\n-\t\t\tbreak;\n \t\tcase REPORT_STATUS_V0:\n-\t\t\treport(commands, &unpack_status);\n+\t\t\treport(commands, &unpack_status, version);\n \t\t\tbreak;\n \t\tcase REPORT_STATUS_UNKNOWN:\n \t\t\tbreak;\n\n-- \n2.55.GIT\n\n"},{"id":"552333","messageId":"20260909-758-introduce-hook-v9-4-3043d417e0ee@gmail.com","threadId":"66186","inReplyTo":"20260909-758-introduce-hook-v9-0-3043d417e0ee@gmail.com","subject":"[PATCH v9 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-09T14:51:39Z","receivedAt":"2026-09-09T14:51:45Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nThe outcome of these operations is only known after `execute_commands()`\nhas returned and before the report is written. There is no point in\nreceive-pack where the server can act on that.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook is not suited for this. It fires from\n    within `ref_transaction_commit()`, which is before the outcome we\n    need to report is known, so there is no phase at which it could give\n    us the answer. It also does not contain any knowledge regarding the\n    push and cannot communicate with the clients.\n\n  - The proc-receive hook replaces execute_commands() for references\n    matching 'receive.procReceiveRefs'. We need to gate the report for\n    the push as a whole.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'receive-report' hook. The hook receives the complete\npkt-line encoded status report on standard input, after all ref updates\nhave been applied to the repository by execute_commands() but before the\nreport is sent to the client. See linkgit:gitprotocol-pack[5] details on\nthe protocol structure.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviors depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n  all references to be rejected with a 'receive-report hook failed'\n  error.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nReference updates applied by execute_commands() are not rolled back in\neither failure mode. The hook can cause the client to perceive the push\nas failed, but cannot undo server-side changes. This creates a\ndivergence that the server cannot resolve: the client leaves its\nremote-tracking reference at the old value while the update is in fact\napplied, and a later fetch may reveal the update that the push reported\nas rejected.\n\nThe hook is therefore only appropriate for servers which can guarantee\nthat a rejected update is not observable by any reader. In our case the\ntransaction committed by execute_commands() produces a candidate version\nwhich is not visible to other readers and is only published once the\nsubsequent operations succeed, so a report of 'ng' corresponds to a\nversion that is discarded rather than published. On a repository where a\ncommitted reference update is immediately visible, rejecting a push from\nthis hook would instead leave the pusher with a view that does not match\nthe server.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc |   9 ++\n Documentation/githooks.adoc         |  61 ++++++++++\n builtin/receive-pack.c              |  47 ++++++++\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 224 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 342 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 5806792ba7..ab668ffa0c 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -245,6 +245,15 @@ commands will be executed by this hook, instead of by the internal\n `execute_commands()` function.  This hook is responsible for updating\n the relevant references and reporting the results back to 'receive-pack'.\n \n+RECEIVE-REPORT HOOK\n+-------------------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client, which allows the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n+\n QUARANTINE ENVIRONMENT\n ----------------------\n \ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..145642bf05 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,67 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+receive-report\n+~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after all\n+accepted ref changes are applied to the repository, but before the\n+pkt-line encoded status report is sent back to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input, see linkgit:gitprotocol-pack[5] for details on the\n+structure. The hook's standard output entirely replaces the report\n+that is sent to the client. The hook must write a valid pkt-line\n+encoded report in the same format it received. The hook's stdout is\n+fully buffered by `receive-pack` before any data is sent to the client,\n+so the hook's exit status is known before the client receives anything.\n+\n+There are three distinct ways the hook can affect the push outcome:\n+\n+* To reject the push, modify the unpack status from `ok` to the required\n+  error message. While `git-push` will fail, individual references may\n+  still show success messages unless modified.\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname>[ <reason>]` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  modifies all references to be rejected with a 'receive-report hook\n+  failed' error.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n+This means that reporting a reference as `ng` makes the client believe\n+the update did not happen while the server has in fact applied it. The\n+client leaves its remote-tracking reference at its old value, and a\n+later `git fetch` may reveal the very update that the push reported as\n+rejected. Neither Git nor the server can reconcile this; only the user,\n+by fetching again, will find out.\n+\n+This hook is therefore only appropriate for servers which can guarantee\n+that a rejected update is not observable by any reader, for example\n+because the committed transaction produces a candidate state that is\n+discarded rather than published. On a repository where a committed\n+reference update is immediately visible, using this hook to reject a\n+push will leave the pusher with a view that does not match the server.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 8b1ae4f7f3..8bd95c41dd 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -992,6 +992,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_receive_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"receive-report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"receive-report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2414,6 +2449,12 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n+static void override_cmds_error(struct command *commands, const char *err)\n+{\n+\tfor (struct command *cmd = commands; cmd; cmd = cmd->next)\n+\t\tcmd->error_string = err;\n+}\n+\n /*\n  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n  */\n@@ -2467,6 +2508,12 @@ static void report(struct command *commands, const struct strbuf *unpack_status,\n \n \tgenerate_report(&buf, commands, unpack_status, version);\n \n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n+\t\tgenerate_report(&buf, commands, unpack_status, version);\n+\t}\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 7f53cca7d1..692e6011c5 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -652,6 +652,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-receive-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-receive-report-hook.sh b/t/t5412-receive-report-hook.sh\nnew file mode 100755\nindex 0000000000..24679de37b\n--- /dev/null\n+++ b/t/t5412-receive-report-hook.sh\n@@ -0,0 +1,224 @@\n+#!/bin/sh\n+\n+test_description='test receive-report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit reports as hook failed\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup receive-report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can modify the unpack status\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^unpack ok$/unpack push failed due to server error/\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"error: remote unpack failed: push failed due to server error\" out &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n-- \n2.55.GIT\n\n"},{"id":"552334","messageId":"aqF0mbWgYU5rMR-f@pks.im","threadId":"66186","inReplyTo":"20260909-758-introduce-hook-v9-0-3043d417e0ee@gmail.com","subject":"Re: [PATCH v9 0/4] hook: introduce the receive-report hook","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-09T15:00:41Z","receivedAt":"2026-09-09T15:00:49Z","isPatch":true,"body":"On Wed, Sep 09, 2026 at 04:51:35PM +0200, Karthik Nayak wrote:\n> Changes in v9:\n> - Fix a bug where we were causing a BUG() when no report was requested.\n>   It is perfectly valid for clients to skip the report and we shouldn't\n>   fail when they do so. Thanks Junio!\n\nIt's curious that nothing has failed because of this. Are we lacking\ntests here?\n\nPatrick\n"},{"id":"552339","messageId":"xmqqh5jys5mx.fsf@gitster.g","threadId":"66186","inReplyTo":"aqF0mbWgYU5rMR-f@pks.im","subject":"Re: [PATCH v9 0/4] hook: introduce the receive-report hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-09T17:20:54Z","receivedAt":"2026-09-09T17:20:57Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Wed, Sep 09, 2026 at 04:51:35PM +0200, Karthik Nayak wrote:\n>> Changes in v9:\n>> - Fix a bug where we were causing a BUG() when no report was requested.\n>>   It is perfectly valid for clients to skip the report and we shouldn't\n>>   fail when they do so. Thanks Junio!\n>\n> It's curious that nothing has failed because of this. Are we lacking\n> tests here?\n\nThe \"send-pack\" client we have will ask for report if the server\nside advertises report-status or report-status-v2 capabilities, and\nthere is no way to disable it nor there is no practical need to give\na way to do so, so unless we are willing to write a custom client,\nor a configuration to disable server capability advertisement, such\na test is a bit impractical to write.\n\n\n"},{"id":"552390","messageId":"xmqqjyotokyg.fsf@gitster.g","threadId":"66186","inReplyTo":"20260909-758-introduce-hook-v9-4-3043d417e0ee@gmail.com","subject":"Re: [PATCH v9 4/4] hook: introduce the receive-report hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-10T03:15:51Z","receivedAt":"2026-09-10T03:15:54Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> +static void override_cmds_error(struct command *commands, const char *err)\n> +{\n> +\tfor (struct command *cmd = commands; cmd; cmd = cmd->next)\n> +\t\tcmd->error_string = err;\n> +}\n\nDoesn't this leak existing cmd->error_string if it is owned?  In\nother words, something like\n\n\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n\t\tif (cmd->error_string_owned)\n\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n                cmd->error_string = err;\n\t}\n\nis in order, perhaps?\n"},{"id":"552391","messageId":"20260910040214.GA240960@coredump.intra.peff.net","threadId":"66186","inReplyTo":"xmqqh5jys5mx.fsf@gitster.g","subject":"Re: [PATCH v9 0/4] hook: introduce the receive-report hook","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2026-09-10T04:02:14Z","receivedAt":"2026-09-10T04:02:16Z","isPatch":true,"body":"On Wed, Sep 09, 2026 at 10:20:54AM -0700, Junio C Hamano wrote:\n\n> Patrick Steinhardt <ps@pks.im> writes:\n> \n> > On Wed, Sep 09, 2026 at 04:51:35PM +0200, Karthik Nayak wrote:\n> >> Changes in v9:\n> >> - Fix a bug where we were causing a BUG() when no report was requested.\n> >>   It is perfectly valid for clients to skip the report and we shouldn't\n> >>   fail when they do so. Thanks Junio!\n> >\n> > It's curious that nothing has failed because of this. Are we lacking\n> > tests here?\n> \n> The \"send-pack\" client we have will ask for report if the server\n> side advertises report-status or report-status-v2 capabilities, and\n> there is no way to disable it nor there is no practical need to give\n> a way to do so, so unless we are willing to write a custom client,\n> or a configuration to disable server capability advertisement, such\n> a test is a bit impractical to write.\n\nYou can do it with a t/interop test, but we don't have any that push.\nThis triggers the BUG() when HEAD is master plus the v8 patches:\n\ndiff --git a/t/interop/i5800-push.sh b/t/interop/i5800-push.sh\nnew file mode 100755\nindex 0000000000..b3035b555a\n--- /dev/null\n+++ b/t/interop/i5800-push.sh\n@@ -0,0 +1,29 @@\n+#!/bin/sh\n+\n+VERSION_A=.\n+VERSION_B=v1.0.0\n+MAKE_OPTS_B=\"NO_OPENSSL=TooOld\"\n+\n+test_description='push to/from older client'\n+. ./interop-lib.sh\n+\n+test_expect_success \"create repo to be served by $VERSION_A\" '\n+\tgit.a init --bare dst.git\n+'\n+\n+test_expect_success 'create commit in client' '\n+\tgit.b init-db &&\n+\techo content >file &&\n+\tgit.b add file &&\n+\tgit.b commit -m foo\n+'\n+\n+test_expect_success \"push with $VERSION_B\" '\n+\tgit.b push --exec=\"git.a receive-pack\" \\\n+\t\tdst.git HEAD:refs/heads/foo &&\n+\techo foo >expect &&\n+\tgit.a -C dst.git log -1 --format=%s foo >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_done\n\n\nIronically the test succeeds despite the BUG(), because the client isn't\nexpecting a status report, so it happily returns while the server side\ndies.\n\nI think the interop suite is probably more trouble than its worth,\nthough. Nobody really runs it, and there are all kinds of hidden gotchas\nin trying to build old versions of Git. So this is more of a fun answer\nthan a serious suggestion to add to the series.\n\n-Peff\n"},{"id":"552443","messageId":"CAOLa=ZSJOgqiH5wJA7KZ2qPsfyBv21NB6mAwJVX_ZQ7VtWhoAg@mail.gmail.com","threadId":"66186","inReplyTo":"xmqqh5jys5mx.fsf@gitster.g","subject":"Re: [PATCH v9 0/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-10T13:43:01Z","receivedAt":"2026-09-10T13:43:03Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Patrick Steinhardt <ps@pks.im> writes:\n>\n>> On Wed, Sep 09, 2026 at 04:51:35PM +0200, Karthik Nayak wrote:\n>>> Changes in v9:\n>>> - Fix a bug where we were causing a BUG() when no report was requested.\n>>>   It is perfectly valid for clients to skip the report and we shouldn't\n>>>   fail when they do so. Thanks Junio!\n>>\n>> It's curious that nothing has failed because of this. Are we lacking\n>> tests here?\n>\n> The \"send-pack\" client we have will ask for report if the server\n> side advertises report-status or report-status-v2 capabilities, and\n> there is no way to disable it nor there is no practical need to give\n> a way to do so, so unless we are willing to write a custom client,\n> or a configuration to disable server capability advertisement, such\n> a test is a bit impractical to write.\n\nYeah, this is kinda the conclusion I came to. I wanted to add in a test\nbut couldn't see a simple way, so I omitted it.\n"},{"id":"552466","messageId":"CAOLa=ZROrWmr=2O+NrkNsJU8Zyz5rGbH30SRazjv0kzDF1RtTA@mail.gmail.com","threadId":"66186","inReplyTo":"xmqqjyotokyg.fsf@gitster.g","subject":"Re: [PATCH v9 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-10T16:32:52Z","receivedAt":"2026-09-10T16:32:55Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> +static void override_cmds_error(struct command *commands, const char *err)\n>> +{\n>> +\tfor (struct command *cmd = commands; cmd; cmd = cmd->next)\n>> +\t\tcmd->error_string = err;\n>> +}\n>\n> Doesn't this leak existing cmd->error_string if it is owned?  In\n> other words, something like\n>\n> \tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n> \t\tif (cmd->error_string_owned)\n> \t\t\tFREE_AND_NULL(cmd->error_string_owned);\n>                 cmd->error_string = err;\n> \t}\n>\n> is in order, perhaps?\n\nYou're right, I thought of writing a test for this, my idea was to\ncreate a test where we override a pre-allocated string. But, unless we\nalways do `cmd->error_string = cmd->error_string_owned = <string>`,\n`cmd->error_string_owned` can end up pointing to something allocated,\nwhile `cmd->error_string` is replaced. Eventually we'll call\n`free(cmd->error_string_owned)`. So the memory leak is never realized.\n\nEither ways, I'll also add a test which triggers this path.\n"},{"id":"552469","messageId":"CAOLa=ZSnbGf9pf9nPdt6vfje1VzC04Gax=svYBRKDTc_owR6Ng@mail.gmail.com","threadId":"66186","inReplyTo":"20260910040214.GA240960@coredump.intra.peff.net","subject":"Re: [PATCH v9 0/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-10T16:40:07Z","receivedAt":"2026-09-10T16:40:10Z","isPatch":true,"body":"Jeff King <peff@peff.net> writes:\n\n> On Wed, Sep 09, 2026 at 10:20:54AM -0700, Junio C Hamano wrote:\n>\n>> Patrick Steinhardt <ps@pks.im> writes:\n>>\n>> > On Wed, Sep 09, 2026 at 04:51:35PM +0200, Karthik Nayak wrote:\n>> >> Changes in v9:\n>> >> - Fix a bug where we were causing a BUG() when no report was requested.\n>> >>   It is perfectly valid for clients to skip the report and we shouldn't\n>> >>   fail when they do so. Thanks Junio!\n>> >\n>> > It's curious that nothing has failed because of this. Are we lacking\n>> > tests here?\n>>\n>> The \"send-pack\" client we have will ask for report if the server\n>> side advertises report-status or report-status-v2 capabilities, and\n>> there is no way to disable it nor there is no practical need to give\n>> a way to do so, so unless we are willing to write a custom client,\n>> or a configuration to disable server capability advertisement, such\n>> a test is a bit impractical to write.\n>\n> You can do it with a t/interop test, but we don't have any that push.\n> This triggers the BUG() when HEAD is master plus the v8 patches:\n>\n> diff --git a/t/interop/i5800-push.sh b/t/interop/i5800-push.sh\n> new file mode 100755\n> index 0000000000..b3035b555a\n> --- /dev/null\n> +++ b/t/interop/i5800-push.sh\n> @@ -0,0 +1,29 @@\n> +#!/bin/sh\n> +\n> +VERSION_A=.\n> +VERSION_B=v1.0.0\n> +MAKE_OPTS_B=\"NO_OPENSSL=TooOld\"\n> +\n> +test_description='push to/from older client'\n> +. ./interop-lib.sh\n> +\n> +test_expect_success \"create repo to be served by $VERSION_A\" '\n> +\tgit.a init --bare dst.git\n> +'\n> +\n> +test_expect_success 'create commit in client' '\n> +\tgit.b init-db &&\n> +\techo content >file &&\n> +\tgit.b add file &&\n> +\tgit.b commit -m foo\n> +'\n> +\n> +test_expect_success \"push with $VERSION_B\" '\n> +\tgit.b push --exec=\"git.a receive-pack\" \\\n> +\t\tdst.git HEAD:refs/heads/foo &&\n> +\techo foo >expect &&\n> +\tgit.a -C dst.git log -1 --format=%s foo >actual &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_done\n>\n>\n> Ironically the test succeeds despite the BUG(), because the client isn't\n> expecting a status report, so it happily returns while the server side\n> dies.\n>\n> I think the interop suite is probably more trouble than its worth,\n> though. Nobody really runs it, and there are all kinds of hidden gotchas\n> in trying to build old versions of Git. So this is more of a fun answer\n> than a serious suggestion to add to the series.\n>\n> -Peff\n\nThis looks easier than I thought, thanks for this, I will skip adding an\ninterop test for the reasons you've also stated :)\n"},{"id":"552551","messageId":"aqQIA37pZL0TZaDR@ugly.lan","threadId":"66186","inReplyTo":"20260909-758-introduce-hook-v9-4-3043d417e0ee@gmail.com","subject":"Re: [PATCH v9 4/4] hook: introduce the receive-report hook","fromName":"Oswald Buddenhagen","fromEmail":"oswald.buddenhagen@gmx.de","sentAt":"2026-09-11T13:54:11Z","receivedAt":"2026-09-11T13:54:23Z","isPatch":true,"body":"On Wed, Sep 09, 2026 at 04:51:39PM +0200, Karthik Nayak wrote:\n>[...]\n>Introduce a new 'receive-report' hook. The hook receives the complete\n>pkt-line encoded status report on standard input, after all ref updates\n>have been applied to the repository by execute_commands() but before the\n>report is sent to the client. See linkgit:gitprotocol-pack[5] details on\n>the protocol structure.\n\ni suppose it's a matter of taste/policy, but around this point i find \nthe commit message's verbosity to be counter-productive:\n\n>The hook's stdout fully replaces the report sent to the client.\n>[...]\n\ni would cut it down to the parts that aren't redundant with the \"proper\" \ndocumentation in the diff, keeping in mind that the central question to \nbe answered by the commit message is \"why?\".\n\n"},{"id":"552597","messageId":"20260910-758-introduce-hook-v10-0-06f9c506631c@gmail.com","threadId":"66186","inReplyTo":"20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com","subject":"[PATCH v10 0/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-10T21:54:05Z","receivedAt":"2026-09-11T21:21:28Z","isPatch":true,"body":"Introduce a new receive-report hook which kicks in after the reference\ntransaction is complete, but before the report is sent to the client.\nThe hook receives the pkt-line encoded report in its stdin and its\nstdout replaces the report transferred to the user. If the hook exits\nwith a non-zero exit code, all references are marked as rejected.\n\nThe first patch, adds missing documentation to 'git-receive-pack.adoc'.\nThe second patch refactors code and the third patch contains the new\nhook.\n\n---\nChanges in v10:\n- Fix a leak when reassigning the command strings.\n- Link to v9: https://patch.msgid.link/20260909-758-introduce-hook-v9-0-3043d417e0ee@gmail.com\n\nChanges in v9:\n- Fix a bug where we were causing a BUG() when no report was requested.\n  It is perfectly valid for clients to skip the report and we shouldn't\n  fail when they do so. Thanks Junio!\n- Link to v8: https://patch.msgid.link/20260908-758-introduce-hook-v8-0-be88a671ae1f@gmail.com\n\nChanges in v8:\n- Fix spelling mistake s/UNKOWN/UNKNOWN\n- Remove a stale comment from previous version.\n- Fix an argument which wasn't changed with the previous version's\n  changes.\n- Link to v7: https://patch.msgid.link/20260904-758-introduce-hook-v7-0-6c66f0a3a572@gmail.com\n\nChanges in v7:\n- Removed report_v2() since it is the same as report() with the new\n  changes.\n- Used a switch statement instead of an if/else for the enum.\n- Removed an unnecessary curly brace.\n- Also rebased on top of latest master (3cb9185f65 (The 22nd batch,\n  2026-09-02) as there were conflicts.\n- Link to v6: https://patch.msgid.link/20260903-758-introduce-hook-v6-0-6283b1fb9b1c@gmail.com\n\nChanges in v6:\n- Introduce a new commit which introduces `enum report_status_version`,\n  use that and drop static variables in the codebase.\n- Reword the commit message and documentation to:\n  - State further why reference-transaction cannot be used.\n  - State the responsibility of the hook owner to undo and reference\n    changes if needed.\n- Link to v5: https://patch.msgid.link/20260901-758-introduce-hook-v5-0-35cdc6be3cc1@gmail.com\n\nChanges in v5:\n- Rewrote some of the commit messages and documentation.\n- Renamed the function `generate_response` to `generate_report` to avoid\n  ambiguity.\n- We now override the cmd's error_strings, this avoids the whole\n  precedence issue with the earlier series.\n- Also add information about how we can override the unpack status to\n  fail the push and add a corresponding test.\n- Thanks to Patrick for the review!\n- Junio: This causes conflict with next ('jt/receive-pack-pluggable-writes')\n  similar to before, please let me know if its better for me to add that\n  dependency.\n- Link to v4: https://patch.msgid.link/20260826-758-introduce-hook-v4-0-6b14975ad957@gmail.com\n\nChanges in v4:\n- Change the name of the hook to be 'receive-report' to avoid ambiguity.\n- Link to v3: https://patch.msgid.link/20260824-758-introduce-hook-v3-0-499526f0a062@gmail.com\n\nChanges in v3:\n- Move out addition of proc-receive hook doc to 'git-receive-pack.adoc'\n  into a new commit.\n- Add a new commit to move out the response generation in receive-pack\n  to a new function.\n- Instead of die-ing on non-zero exit code, we modify each reference to\n  indicate that the hook failed.\n- Instead of correctly listing out the protocol, link to\n  linkgit:gitprotocol-pack[5], as the protocol also differs between v1\n  and v2.\n- Link to v2: https://patch.msgid.link/20260821-758-introduce-hook-v2-1-e90e2f7ac2cf@gmail.com\n\nChanges in v2:\n- Modify the documentation and commit message to be more verbose.\n- Add documentation to 'git-receive-pack.adoc'\n- Use 'ret' as the variable name for the return code.\n- Modify the test to also check for the 'remote:'.\n- Link to v1: https://patch.msgid.link/20260818-758-introduce-hook-v1-1-8a8d89e65838@gmail.com\n\n To: git@vger.kernel.org\n CC: ps@pks.im\n CC: gitster@pobox.com\n CC: jltobler@gmail.com\n CC: kristofferhaugsbakk@fastmail.com\n CC: phillip.wood123@gmail.com\n\n---\nKarthik Nayak (4):\n      doc: add proc-receive hook info in 'git-receive-pack.adoc'\n      receive-pack: drop static variables to track report status version\n      receive-pack: move message generation to separate function\n      hook: introduce the receive-report hook\n\n Documentation/git-receive-pack.adoc |  17 +++\n Documentation/githooks.adoc         |  61 +++++++++\n builtin/receive-pack.c              | 147 ++++++++++++++-------\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 257 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 436 insertions(+), 47 deletions(-)\n\nRange-diff versus v9:\n\n1:  711a817307 = 1:  c1e4b04739 doc: add proc-receive hook info in 'git-receive-pack.adoc'\n2:  9e2df905d2 = 2:  a722caf2ab receive-pack: drop static variables to track report status version\n3:  b871b95544 = 3:  beff68683c receive-pack: move message generation to separate function\n4:  3d4e967fa5 ! 4:  8819db1ae1 hook: introduce the receive-report hook\n    @@ builtin/receive-pack.c: static void update_shallow_info(struct command *commands\n      \n     +static void override_cmds_error(struct command *commands, const char *err)\n     +{\n    -+\tfor (struct command *cmd = commands; cmd; cmd = cmd->next)\n    ++\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n    ++\t\tif (cmd->error_string_owned)\n    ++\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n     +\t\tcmd->error_string = err;\n    ++\t}\n     +}\n     +\n      /*\n    @@ t/t5412-receive-report-hook.sh (new)\n     +\ttest_cmp expect-report actual-report\n     +'\n     +\n    ++test_expect_success \"non-zero exit with pre-existing ng from proc-receive\" '\n    ++\ttest_when_finished \"rm -rf upstream\" &&\n    ++\ttest_when_finished \"git -C workbench remote remove origin\" &&\n    ++\n    ++\tgit init --bare upstream &&\n    ++\tgit -C upstream config receive.procReceiveRefs refs/for &&\n    ++\tgit -C workbench remote add origin ../upstream &&\n    ++\tgit -C workbench push origin $A:refs/heads/main &&\n    ++\n    ++\t# Use a proc-receive hook to generate a dynamic error string.\n    ++\t# This is used to capture any leaks stemming from overriding the\n    ++\t# error message via the receive-report.\n    ++\ttest_hook -C upstream --setup proc-receive <<-\\EOF &&\n    ++\ttest-tool proc-receive -r \"ng refs/for/main/topic push-rejected-by-service-x\"\n    ++\tEOF\n    ++\n    ++\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n    ++\ttee raw\n    ++\texit 1\n    ++\tEOF\n    ++\n    ++\ttest_must_fail git -C workbench push origin HEAD:refs/for/main/topic >out 2>&1 &&\n    ++\ttest_grep \"receive-report hook failed\" out &&\n    ++\n    ++\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n    ++\tcat >expect-report <<-\\EOF &&\n    ++\tunpack ok\n    ++\tng refs/for/main/topic push-rejected-by-service-x\n    ++\t0000\n    ++\tEOF\n    ++\ttest_cmp expect-report actual-report\n    ++'\n    ++\n     +test_expect_success \"hook stderr is relayed to client via sideband\" '\n     +\ttest_when_finished \"rm -rf upstream\" &&\n     +\ttest_when_finished \"git -C workbench remote remove origin\" &&\n\n---\nbase-commit: 3cb9185f65410273787f74333cc027d2ea5daada\nchange-id: 20260812-758-introduce-hook-5b3af9f1a7e8\n\n\nThanks\n- Karthik\n\n"},{"id":"552598","messageId":"20260910-758-introduce-hook-v10-1-06f9c506631c@gmail.com","threadId":"66186","inReplyTo":"20260910-758-introduce-hook-v10-0-06f9c506631c@gmail.com","subject":"[PATCH v10 1/4] doc: add proc-receive hook info in 'git-receive-pack.adoc'","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-10T21:54:06Z","receivedAt":"2026-09-11T21:21:29Z","isPatch":true,"body":"The manpage of git-receive-pack(1) documents hooks invoked when\nreceiving a push. The manpage does not mention the 'proc-receive' hook\nthough, which is also invoked as part of that process. Add a paragraph\nabout this hook to plug that gap.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc | 8 ++++++++\n 1 file changed, 8 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 0956086d61..5806792ba7 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -236,6 +236,14 @@ if the repository is packed and is served via a dumb transport.\n exec git update-server-info\n ----\n \n+PROC-RECEIVE HOOK\n+-----------------\n+This hook is invoked by linkgit:git-receive-pack[1].  If the server has\n+set the multi-valued config variable `receive.procReceiveRefs`, and the\n+commands sent to 'receive-pack' have matching reference names, these\n+commands will be executed by this hook, instead of by the internal\n+`execute_commands()` function.  This hook is responsible for updating\n+the relevant references and reporting the results back to 'receive-pack'.\n \n QUARANTINE ENVIRONMENT\n ----------------------\n\n-- \n2.55.GIT\n\n"},{"id":"552599","messageId":"20260910-758-introduce-hook-v10-2-06f9c506631c@gmail.com","threadId":"66186","inReplyTo":"20260910-758-introduce-hook-v10-0-06f9c506631c@gmail.com","subject":"[PATCH v10 2/4] receive-pack: drop static variables to track report status version","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-10T21:54:07Z","receivedAt":"2026-09-11T21:21:30Z","isPatch":true,"body":"In 'git-receive-pack(1)', to track the report status version, we use the\nstatic variables `report_status` and `report_status_v2`. As the report\nstatus version is mutually exclusive, using an enum better suits the\nrequirement. switch to using a new `enum report_status_version`, while\nalso dropping the static variable to make the flow easier to understand.\n\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 30 ++++++++++++++++++++++--------\n 1 file changed, 22 insertions(+), 8 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex e6e54ba55f..75a1788fa5 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -53,6 +53,12 @@ enum deny_action {\n \tDENY_UPDATE_INSTEAD\n };\n \n+enum report_status_version {\n+\tREPORT_STATUS_UNKNOWN = 0,\n+\tREPORT_STATUS_V0,\n+\tREPORT_STATUS_V2,\n+};\n+\n static int deny_deletes;\n static int deny_non_fast_forwards;\n static enum deny_action deny_current_branch = DENY_UNCONFIGURED;\n@@ -64,8 +70,6 @@ static int advertise_atomic_push = 1;\n static int advertise_push_options;\n static int advertise_sid;\n static off_t max_input_size;\n-static int report_status;\n-static int report_status_v2;\n static int use_sideband;\n static int use_atomic;\n static int use_push_options;\n@@ -2191,7 +2195,8 @@ static void queue_commands_from_cert(struct command **tail,\n }\n \n static struct command *read_head_info(struct packet_reader *reader,\n-\t\t\t\t      struct oid_array *shallow)\n+\t\t\t\t      struct oid_array *shallow,\n+\t\t\t\t      enum report_status_version *version)\n {\n \tstruct command *commands = NULL;\n \tstruct command **p = &commands;\n@@ -2217,9 +2222,9 @@ static struct command *read_head_info(struct packet_reader *reader,\n \t\t\tconst char *client_sid;\n \t\t\tsize_t len = 0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status\"))\n-\t\t\t\treport_status = 1;\n+\t\t\t\t*version = REPORT_STATUS_V0;\n \t\t\tif (parse_feature_request(feature_list, \"report-status-v2\"))\n-\t\t\t\treport_status_v2 = 1;\n+\t\t\t\t*version = REPORT_STATUS_V2;\n \t\t\tif (parse_feature_request(feature_list, \"side-band-64k\"))\n \t\t\t\tuse_sideband = LARGE_PACKET_MAX;\n \t\t\tif (parse_feature_request(feature_list, \"quiet\"))\n@@ -2500,6 +2505,7 @@ int cmd_receive_pack(int argc,\n \tstruct shallow_info si;\n \tstruct packet_reader reader;\n \tstruct odb_transaction *transaction = NULL;\n+\tenum report_status_version version = REPORT_STATUS_UNKNOWN;\n \n \tstruct option options[] = {\n \t\tOPT__QUIET(&quiet, N_(\"quiet\")),\n@@ -2563,7 +2569,7 @@ int cmd_receive_pack(int argc,\n \t\t\t   PACKET_READ_CHOMP_NEWLINE |\n \t\t\t   PACKET_READ_DIE_ON_ERR_PACKET);\n \n-\tif ((commands = read_head_info(&reader, &shallow))) {\n+\tif ((commands = read_head_info(&reader, &shallow, &version))) {\n \t\tstruct string_list push_options = STRING_LIST_INIT_DUP;\n \t\tstruct strbuf unpack_status = STRBUF_INIT;\n \n@@ -2596,10 +2602,18 @@ int cmd_receive_pack(int argc,\n \t\t\t\t &push_options);\n \t\todb_transaction_finalize(transaction);\n \t\tsigchain_push(SIGPIPE, SIG_IGN);\n-\t\tif (report_status_v2)\n+\n+\t\tswitch (version) {\n+\t\tcase REPORT_STATUS_V2:\n \t\t\treport_v2(commands, &unpack_status);\n-\t\telse if (report_status)\n+\t\t\tbreak;\n+\t\tcase REPORT_STATUS_V0:\n \t\t\treport(commands, &unpack_status);\n+\t\t\tbreak;\n+\t\tcase REPORT_STATUS_UNKNOWN:\n+\t\t\tbreak;\n+\t\t}\n+\n \t\tsigchain_pop(SIGPIPE);\n \t\trun_receive_hook(commands, \"post-receive\", 1, NULL,\n \t\t\t\t &push_options);\n\n-- \n2.55.GIT\n\n"},{"id":"552600","messageId":"20260910-758-introduce-hook-v10-3-06f9c506631c@gmail.com","threadId":"66186","inReplyTo":"20260910-758-introduce-hook-v10-0-06f9c506631c@gmail.com","subject":"[PATCH v10 3/4] receive-pack: move message generation to separate function","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-10T21:54:08Z","receivedAt":"2026-09-11T21:21:30Z","isPatch":true,"body":"After git-receive-pack(1) has committed the reference updates, we call\neither `report()` or `report_v2()` to report to the client which of the\nreferences we have updated successfully and which updates have failed.\nThe only difference between those two functions is that the latter also\nknows to provide a more detailed report about how exactly a given\nreference was updated.\n\nWith this, also drop `report_v2()` as both report functions now are\nsimilar in structure with only the `report_status_version`\ndifferentiating them.\n\nIn the next commit we're about to add another site that wants to\ngenerate these reports. Refactor the logic into a shared function that\ncan easily be reused.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n builtin/receive-pack.c | 75 +++++++++++++++++++++-----------------------------\n 1 file changed, 32 insertions(+), 43 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 75a1788fa5..8b1ae4f7f3 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2414,67 +2414,58 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n-static void report(struct command *commands, const struct strbuf *unpack_status)\n+/*\n+ * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n+ */\n+static void generate_report(struct strbuf *buf, struct command *commands,\n+\t\t\t    const struct strbuf *unpack_status,\n+\t\t\t    enum report_status_version version)\n {\n \tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n+\tpacket_buf_write(buf, \"unpack %s\\n\",\n \t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n-\tfor (cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (!cmd->error_string)\n-\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t\t cmd->ref_name);\n-\t\telse\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name, cmd->error_string);\n-\t}\n-\tpacket_buf_flush(&buf);\n-\n-\tif (use_sideband)\n-\t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n-\telse\n-\t\twrite_or_die(1, buf.buf, buf.len);\n-\tstrbuf_release(&buf);\n-}\n-\n-static void report_v2(struct command *commands, const struct strbuf *unpack_status)\n-{\n-\tstruct command *cmd;\n-\tstruct strbuf buf = STRBUF_INIT;\n-\tstruct ref_push_report *report;\n \n-\tpacket_buf_write(&buf, \"unpack %s\\n\",\n-\t\t\t unpack_status->len ? unpack_status->buf : \"ok\");\n \tfor (cmd = commands; cmd; cmd = cmd->next) {\n+\t\tstruct ref_push_report *report;\n \t\tint count = 0;\n \n-\t\tif (cmd->error_string) {\n-\t\t\tpacket_buf_write(&buf, \"ng %s %s\\n\",\n-\t\t\t\t\t cmd->ref_name,\n-\t\t\t\t\t cmd->error_string);\n+\t\tif (cmd->error_string)\n+\t\t\tpacket_buf_write(buf, \"ng %s %s\\n\",\n+\t\t\t\t\t cmd->ref_name, cmd->error_string);\n+\t\telse\n+\t\t\tpacket_buf_write(buf, \"ok %s\\n\", cmd->ref_name);\n+\n+\t\tif (version != REPORT_STATUS_V2 || cmd->error_string)\n \t\t\tcontinue;\n-\t\t}\n-\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n-\t\t\t\t cmd->ref_name);\n+\n \t\tfor (report = cmd->report; report; report = report->next) {\n \t\t\tif (count++ > 0)\n-\t\t\t\tpacket_buf_write(&buf, \"ok %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"ok %s\\n\",\n \t\t\t\t\t\t cmd->ref_name);\n \t\t\tif (report->ref_name)\n-\t\t\t\tpacket_buf_write(&buf, \"option refname %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option refname %s\\n\",\n \t\t\t\t\t\t report->ref_name);\n \t\t\tif (report->old_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option old-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option old-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->old_oid));\n \t\t\tif (report->new_oid)\n-\t\t\t\tpacket_buf_write(&buf, \"option new-oid %s\\n\",\n+\t\t\t\tpacket_buf_write(buf, \"option new-oid %s\\n\",\n \t\t\t\t\t\t oid_to_hex(report->new_oid));\n \t\t\tif (report->forced_update)\n-\t\t\t\tpacket_buf_write(&buf, \"option forced-update\\n\");\n+\t\t\t\tpacket_buf_write(buf, \"option forced-update\\n\");\n \t\t}\n \t}\n-\tpacket_buf_flush(&buf);\n+\n+\tpacket_buf_flush(buf);\n+}\n+\n+static void report(struct command *commands, const struct strbuf *unpack_status,\n+\t\t   enum report_status_version version)\n+{\n+\tstruct strbuf buf = STRBUF_INIT;\n+\n+\tgenerate_report(&buf, commands, unpack_status, version);\n \n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n@@ -2605,10 +2596,8 @@ int cmd_receive_pack(int argc,\n \n \t\tswitch (version) {\n \t\tcase REPORT_STATUS_V2:\n-\t\t\treport_v2(commands, &unpack_status);\n-\t\t\tbreak;\n \t\tcase REPORT_STATUS_V0:\n-\t\t\treport(commands, &unpack_status);\n+\t\t\treport(commands, &unpack_status, version);\n \t\t\tbreak;\n \t\tcase REPORT_STATUS_UNKNOWN:\n \t\t\tbreak;\n\n-- \n2.55.GIT\n\n"},{"id":"552601","messageId":"20260910-758-introduce-hook-v10-4-06f9c506631c@gmail.com","threadId":"66186","inReplyTo":"20260910-758-introduce-hook-v10-0-06f9c506631c@gmail.com","subject":"[PATCH v10 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-10T21:54:09Z","receivedAt":"2026-09-11T21:21:32Z","isPatch":true,"body":"When running 'git-receive-pack(1)', there is no way for the server to\nintercept and modify the status report before it is sent back to the\nclient. Servers with custom logic may need to transform or gate the\nreport based on the outcome of external logic post reference updates.\n\nThis is specially needed for our usecase at GitLab where we have custom\nMVCC logic on top of Git which creates a new version for each push\noperation. The new version is only committed when certain external\noperations post reference transaction succeed. So reporting the correct\nmessage based on the outcome of these operations is important.\n\nThe outcome of these operations is only known after `execute_commands()`\nhas returned and before the report is written. There is no point in\nreceive-pack where the server can act on that.\n\nWe cannot use any of the existing hooks as:\n\n  - The pre-receive hook runs too early, as we haven't updated\n    references at that point yet and we need to have the full view of\n    all resulting updates (both objects and references).\n\n  - The update hook is too inefficient as it runs once per reference,\n    and we cannot trivially determine the last update.\n\n  - The reference-transaction hook is not suited for this. It fires from\n    within `ref_transaction_commit()`, which is before the outcome we\n    need to report is known, so there is no phase at which it could give\n    us the answer. It also does not contain any knowledge regarding the\n    push and cannot communicate with the clients.\n\n  - The proc-receive hook replaces execute_commands() for references\n    matching 'receive.procReceiveRefs'. We need to gate the report for\n    the push as a whole.\n\n  - The post-receive and post-update hooks cannot be used as they run\n    too late, at the point where we have already reported success to the\n    client.\n\nIntroduce a new 'receive-report' hook. The hook receives the complete\npkt-line encoded status report on standard input, after all ref updates\nhave been applied to the repository by execute_commands() but before the\nreport is sent to the client. See linkgit:gitprotocol-pack[5] details on\nthe protocol structure.\n\nThe hook's stdout fully replaces the report sent to the client.\nreceive-pack fully buffers the hook's stdout before acting on the exit\nstatus, so the exit code is known before the client receives anything.\nThis gives two distinct behaviors depending on exit status:\n\n- Exit 0: the hook's stdout is used as the report. The hook can\n  rewrite 'ok' lines to 'ng' lines to signal per-ref rejection to the\n  client while receive-pack itself exits cleanly. The client marks\n  rejected refs as '[remote rejected]' and exits with a non-zero\n  status if any ref is 'ng'.\n\n- Non-zero exit: the hook's stdout is discarded, receive-pack modifies\n  all references to be rejected with a 'receive-report hook failed'\n  error.\n\nIn both cases, any output the hook writes to standard error is\nforwarded to the client over the sideband channel and appears as\n'remote:' lines on the client terminal. Writing to stderr alone does\nnot affect the push outcome.\n\nReference updates applied by execute_commands() are not rolled back in\neither failure mode. The hook can cause the client to perceive the push\nas failed, but cannot undo server-side changes. This creates a\ndivergence that the server cannot resolve: the client leaves its\nremote-tracking reference at the old value while the update is in fact\napplied, and a later fetch may reveal the update that the push reported\nas rejected.\n\nThe hook is therefore only appropriate for servers which can guarantee\nthat a rejected update is not observable by any reader. In our case the\ntransaction committed by execute_commands() produces a candidate version\nwhich is not visible to other readers and is only published once the\nsubsequent operations succeed, so a report of 'ng' corresponds to a\nversion that is discarded rather than published. On a repository where a\ncommitted reference update is immediately visible, rejecting a push from\nthis hook would instead leave the pusher with a view that does not match\nthe server.\n\nThis hook does not use the config-based hook infrastructure, which\nsupports running multiple scripts per hook event. This hook is a\nbidirectional filter: it receives the report on stdin and writes a\nmodified version to stdout. Running multiple such scripts sequentially\nwould require piping the output of one into the input of the next,\nwhich the current hook infrastructure does not support. A single-script\ndesign is therefore a natural fit, and is consistent with how\n'proc-receive' is structured for the same reason.\n\nHelped-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Karthik Nayak <karthik.188@gmail.com>\n---\n Documentation/git-receive-pack.adoc |   9 ++\n Documentation/githooks.adoc         |  61 +++++++++\n builtin/receive-pack.c              |  50 +++++++\n t/meson.build                       |   1 +\n t/t5412-receive-report-hook.sh      | 257 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 378 insertions(+)\n\ndiff --git a/Documentation/git-receive-pack.adoc b/Documentation/git-receive-pack.adoc\nindex 5806792ba7..ab668ffa0c 100644\n--- a/Documentation/git-receive-pack.adoc\n+++ b/Documentation/git-receive-pack.adoc\n@@ -245,6 +245,15 @@ commands will be executed by this hook, instead of by the internal\n `execute_commands()` function.  This hook is responsible for updating\n the relevant references and reporting the results back to 'receive-pack'.\n \n+RECEIVE-REPORT HOOK\n+-------------------\n+This hook is invoked by 'git-receive-pack' after all the ref updates\n+have been applied but before the report is sent to the client. The hook\n+receives the complete report in pkt-line format on stdin and its stdout\n+replaces the report sent to the client, which allows the hook to rewrite\n+the outcomes or abort the push completely. See linkgit:githooks[5] for\n+the full protocol description.\n+\n QUARANTINE ENVIRONMENT\n ----------------------\n \ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..145642bf05 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -527,6 +527,67 @@ The exit status of the hook is ignored for any state except for the\n status will cause the transaction to be aborted. The hook will not be\n called with \"aborted\" state in that case.\n \n+receive-report\n+~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-receive-pack[1] when it reacts to\n+`git push` and updates references in its repository. It executes on\n+the repository once after all refs have been updated and after all\n+accepted ref changes are applied to the repository, but before the\n+pkt-line encoded status report is sent back to the client.\n+\n+The hook receives the complete pkt-line encoded status report on\n+standard input, see linkgit:gitprotocol-pack[5] for details on the\n+structure. The hook's standard output entirely replaces the report\n+that is sent to the client. The hook must write a valid pkt-line\n+encoded report in the same format it received. The hook's stdout is\n+fully buffered by `receive-pack` before any data is sent to the client,\n+so the hook's exit status is known before the client receives anything.\n+\n+There are three distinct ways the hook can affect the push outcome:\n+\n+* To reject the push, modify the unpack status from `ok` to the required\n+  error message. While `git-push` will fail, individual references may\n+  still show success messages unless modified.\n+\n+* To reject individual ref updates while keeping `receive-pack` alive,\n+  rewrite the corresponding `ok <refname>` lines to\n+  `ng <refname>[ <reason>]` lines in the output and exit with status 0.\n+  The client will then mark those specific refs as rejected while\n+  treating any `ok` refs as successful. The push as a whole is\n+  considered failed if any ref is `ng`, and `git push` will exit with\n+  a non-zero status on the client side.\n+\n+* To abort the entire push unconditionally, exit with a non-zero\n+  status. In this case the hook's stdout is discarded, `receive-pack`\n+  modifies all references to be rejected with a 'receive-report hook\n+  failed' error.\n+\n+Any output written to standard error is forwarded to the client over\n+the sideband channel and will appear as `remote:` lines on clients\n+using 'git-push(1)', regardless of the hook's exit status. Writing to\n+standard error alone does not affect the push outcome.\n+\n+Note that by the time this hook runs, all ref updates have already been\n+applied to the repository. Neither a non-zero exit nor rewriting refs\n+to `ng` rolls back any ref changes that were already committed\n+server-side. The hook can cause the client to perceive the push as\n+failed, but cannot undo the server-side updates.\n+\n+This means that reporting a reference as `ng` makes the client believe\n+the update did not happen while the server has in fact applied it. The\n+client leaves its remote-tracking reference at its old value, and a\n+later `git fetch` may reveal the very update that the push reported as\n+rejected. Neither Git nor the server can reconcile this; only the user,\n+by fetching again, will find out.\n+\n+This hook is therefore only appropriate for servers which can guarantee\n+that a rejected update is not observable by any reader, for example\n+because the committed transaction produces a candidate state that is\n+discarded rather than published. On a repository where a committed\n+reference update is immediately visible, using this hook to reject a\n+push will leave the pusher with a view that does not match the server.\n+\n push-to-checkout\n ~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 8b1ae4f7f3..9ac7717096 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -992,6 +992,41 @@ static int run_update_hook(struct command *cmd)\n \treturn code;\n }\n \n+static int run_receive_report_hook(struct strbuf *report)\n+{\n+\tstruct child_process proc = CHILD_PROCESS_INIT;\n+\tstruct async sideband_async;\n+\tint sideband_async_started = 0;\n+\tint saved_stderr = -1;\n+\tstruct strbuf out = STRBUF_INIT;\n+\tconst char *hook_path;\n+\tint ret;\n+\n+\thook_path = find_hook(the_repository, \"receive-report\");\n+\tif (!hook_path)\n+\t\treturn 0;\n+\n+\tstrvec_push(&proc.args, hook_path);\n+\tproc.trace2_hook_name = \"receive-report\";\n+\n+\tprepare_sideband_async(&sideband_async, &saved_stderr,\n+\t\t\t       &sideband_async_started);\n+\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tret = pipe_command(&proc, report->buf, report->len, &out,\n+\t\t\t   report->len, NULL, 0);\n+\tsigchain_pop(SIGPIPE);\n+\n+\tfinish_sideband_async(&sideband_async, saved_stderr,\n+\t\t\t      sideband_async_started);\n+\n+\tif (!ret)\n+\t\tstrbuf_swap(&out, report);\n+\n+\tstrbuf_release(&out);\n+\treturn ret;\n+}\n+\n static struct command *find_command_by_refname(struct command *list,\n \t\t\t\t\t       const char *refname)\n {\n@@ -2414,6 +2449,15 @@ static void update_shallow_info(struct command *commands,\n \tfree(ref_status);\n }\n \n+static void override_cmds_error(struct command *commands, const char *err)\n+{\n+\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n+\t\tif (cmd->error_string_owned)\n+\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n+\t\tcmd->error_string = err;\n+\t}\n+}\n+\n /*\n  * Generate the response to be sent to the client invoking 'git-receive-pack(1)'.\n  */\n@@ -2467,6 +2511,12 @@ static void report(struct command *commands, const struct strbuf *unpack_status,\n \n \tgenerate_report(&buf, commands, unpack_status, version);\n \n+\tif (run_receive_report_hook(&buf)) {\n+\t\tstrbuf_reset(&buf);\n+\t\toverride_cmds_error(commands, \"receive-report hook failed\");\n+\t\tgenerate_report(&buf, commands, unpack_status, version);\n+\t}\n+\n \tif (use_sideband)\n \t\tsend_sideband(1, 1, buf.buf, buf.len, use_sideband);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 7f53cca7d1..692e6011c5 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -652,6 +652,7 @@ integration_tests = [\n   't5409-colorize-remote-messages.sh',\n   't5410-receive-pack.sh',\n   't5411-proc-receive-hook.sh',\n+  't5412-receive-report-hook.sh',\n   't5500-fetch-pack.sh',\n   't5501-fetch-push-alternates.sh',\n   't5502-quickfetch.sh',\ndiff --git a/t/t5412-receive-report-hook.sh b/t/t5412-receive-report-hook.sh\nnew file mode 100755\nindex 0000000000..2f6515b5f0\n--- /dev/null\n+++ b/t/t5412-receive-report-hook.sh\n@@ -0,0 +1,257 @@\n+#!/bin/sh\n+\n+test_description='test receive-report hook'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/t5411/common-functions.sh\n+\n+URL_PREFIX=\"\\.\\.\"\n+\n+test_expect_success \"setup workbench\" '\n+\tgit init workbench &&\n+\tcreate_commits_in workbench A B\n+'\n+\n+test_expect_success \"no report hook, push succeeds\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"passthrough does not alter report\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\tgit init --bare upstream &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\tcat\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"non-zero exit reports as hook failed\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (receive-report hook failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook is invoked and receives report on stdin\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\ttest_hook -C upstream --setup receive-report <<-EOF &&\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook can modify the report sent to client\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok /ng /\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t ! [remote rejected] <COMMIT-B> -> main (failed)\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can modify the unpack status\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^unpack ok$/unpack push failed due to server error/\" |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"error: remote unpack failed: push failed due to server error\" out &&\n+\tmake_user_friendly_and_stable_output <out >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tTo ../upstream\n+\t   <COMMIT-A>..<COMMIT-B>  <COMMIT-B> -> main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success \"hook can report a custom failure message\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttest-tool pkt-line unpack |\n+\tsed \"s/^ok \\(.*\\)/ng \\1 service-x-is-down/\" |\n+\ttest-tool pkt-line pack |\n+\ttee raw\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/main service-x-is-down\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr with zero exit status code\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"push rejected: service X is down\" >&2\n+\ttee raw\n+\tEOF\n+\n+\tgit -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"push rejected: service X is down\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tok refs/heads/main\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"non-zero exit with pre-existing ng from proc-receive\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C upstream config receive.procReceiveRefs refs/for &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\t# Use a proc-receive hook to generate a dynamic error string.\n+\t# This is used to capture any leaks stemming from overriding the\n+\t# error message via the receive-report.\n+\ttest_hook -C upstream --setup proc-receive <<-\\EOF &&\n+\ttest-tool proc-receive -r \"ng refs/for/main/topic push-rejected-by-service-x\"\n+\tEOF\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\ttee raw\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin HEAD:refs/for/main/topic >out 2>&1 &&\n+\ttest_grep \"receive-report hook failed\" out &&\n+\n+\ttest-tool pkt-line unpack <upstream/raw >actual-report &&\n+\tcat >expect-report <<-\\EOF &&\n+\tunpack ok\n+\tng refs/for/main/topic push-rejected-by-service-x\n+\t0000\n+\tEOF\n+\ttest_cmp expect-report actual-report\n+'\n+\n+test_expect_success \"hook stderr is relayed to client via sideband\" '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\ttest_when_finished \"git -C workbench remote remove origin\" &&\n+\n+\tgit init --bare upstream &&\n+\tgit -C workbench remote add origin ../upstream &&\n+\tgit -C workbench push origin $A:refs/heads/main &&\n+\n+\ttest_hook -C upstream --setup receive-report <<-\\EOF &&\n+\techo \"hook-stderr-message\" >&2\n+\texit 1\n+\tEOF\n+\n+\ttest_must_fail git -C workbench push origin $B:refs/heads/main >out 2>&1 &&\n+\ttest_grep \"remote: hook-stderr-message\" out\n+'\n+\n+test_done\n\n-- \n2.55.GIT\n\n"},{"id":"552602","messageId":"CAOLa=ZTSMgbAZdXv0NoUSjFocOpYVKQavaKyjN9U16OcMNZaGA@mail.gmail.com","threadId":"66186","inReplyTo":"aqQIA37pZL0TZaDR@ugly.lan","subject":"Re: [PATCH v9 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-11T21:23:10Z","receivedAt":"2026-09-11T21:23:12Z","isPatch":true,"body":"Oswald Buddenhagen <oswald.buddenhagen@gmx.de> writes:\n\n> On Wed, Sep 09, 2026 at 04:51:39PM +0200, Karthik Nayak wrote:\n>>[...]\n>>Introduce a new 'receive-report' hook. The hook receives the complete\n>>pkt-line encoded status report on standard input, after all ref updates\n>>have been applied to the repository by execute_commands() but before the\n>>report is sent to the client. See linkgit:gitprotocol-pack[5] details on\n>>the protocol structure.\n>\n> i suppose it's a matter of taste/policy, but around this point i find\n> the commit message's verbosity to be counter-productive:\n>\n>>The hook's stdout fully replaces the report sent to the client.\n>>[...]\n>\n> i would cut it down to the parts that aren't redundant with the \"proper\"\n> documentation in the diff, keeping in mind that the central question to\n> be answered by the commit message is \"why?\".\n\nYeah, that's fair, I think as the versions progressed the commit message\ngot clunkier. I'll leave it as is for now, since I think we're mostly at\nthe end of the series. But will keep this in mind :)\n"},{"id":"552605","messageId":"xmqq33vfa2ny.fsf@gitster.g","threadId":"66186","inReplyTo":"20260910-758-introduce-hook-v10-4-06f9c506631c@gmail.com","subject":"Re: [PATCH v10 4/4] hook: introduce the receive-report hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-11T21:39:13Z","receivedAt":"2026-09-11T21:39:17Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> +static void override_cmds_error(struct command *commands, const char *err)\n> +{\n> +\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n> +\t\tif (cmd->error_string_owned)\n> +\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n> +\t\tcmd->error_string = err;\n> +\t}\n> +}\n\nThis is my fault, but like free(), FREE_AND_NULL() can safely be\ncalled on a variable that already is NULL so we may want to fix up\nafter the dust settles, perhaps?\n\n builtin/receive-pack.c      | 3 +--\n tools/coccinelle/free.cocci | 6 ++++++\n 2 files changed, 7 insertions(+), 2 deletions(-)\n\ndiff --git c/builtin/receive-pack.c w/builtin/receive-pack.c\nindex 9ac7717096..1d5b050beb 100644\n--- c/builtin/receive-pack.c\n+++ w/builtin/receive-pack.c\n@@ -2452,8 +2452,7 @@ static void update_shallow_info(struct command *commands,\n static void override_cmds_error(struct command *commands, const char *err)\n {\n \tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (cmd->error_string_owned)\n-\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n+\t\tFREE_AND_NULL(cmd->error_string_owned);\n \t\tcmd->error_string = err;\n \t}\n }\ndiff --git c/tools/coccinelle/free.cocci w/tools/coccinelle/free.cocci\nindex 03799e1908..c95ffa2a07 100644\n--- c/tools/coccinelle/free.cocci\n+++ w/tools/coccinelle/free.cocci\n@@ -43,3 +43,9 @@ statement S;\n   S\n   commit_list_free(E);\n - }\n+@@\n+expression E;\n+@@\n+- if (E)\n+-  FREE_AND_NULL(E);\n++ FREE_AND_NULL(E);\n"},{"id":"552607","messageId":"CAOLa=ZS0PT4bb+k3HR4F_aOoJ5uUuMFx+Dnte4LpPEekFxs9uA@mail.gmail.com","threadId":"66186","inReplyTo":"xmqq33vfa2ny.fsf@gitster.g","subject":"Re: [PATCH v10 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-11T21:58:15Z","receivedAt":"2026-09-11T21:58:18Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> +static void override_cmds_error(struct command *commands, const char *err)\n>> +{\n>> +\tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n>> +\t\tif (cmd->error_string_owned)\n>> +\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n>> +\t\tcmd->error_string = err;\n>> +\t}\n>> +}\n>\n> This is my fault, but like free(), FREE_AND_NULL() can safely be\n> called on a variable that already is NULL so we may want to fix up\n> after the dust settles, perhaps?\n>\n\nI didn't really think too much about the change. I'll avoid a re-roll\nfor this.\n\n>  builtin/receive-pack.c      | 3 +--\n>  tools/coccinelle/free.cocci | 6 ++++++\n>  2 files changed, 7 insertions(+), 2 deletions(-)\n>\n> diff --git c/builtin/receive-pack.c w/builtin/receive-pack.c\n> index 9ac7717096..1d5b050beb 100644\n> --- c/builtin/receive-pack.c\n> +++ w/builtin/receive-pack.c\n> @@ -2452,8 +2452,7 @@ static void update_shallow_info(struct command *commands,\n>  static void override_cmds_error(struct command *commands, const char *err)\n>  {\n>  \tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n> -\t\tif (cmd->error_string_owned)\n> -\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n> +\t\tFREE_AND_NULL(cmd->error_string_owned);\n>  \t\tcmd->error_string = err;\n>  \t}\n>  }\n> diff --git c/tools/coccinelle/free.cocci w/tools/coccinelle/free.cocci\n> index 03799e1908..c95ffa2a07 100644\n> --- c/tools/coccinelle/free.cocci\n> +++ w/tools/coccinelle/free.cocci\n> @@ -43,3 +43,9 @@ statement S;\n>    S\n>    commit_list_free(E);\n>  - }\n> +@@\n> +expression E;\n> +@@\n> +- if (E)\n> +-  FREE_AND_NULL(E);\n> ++ FREE_AND_NULL(E);\n\nI could send in this patch for coccinelle with the fixup if that's okay\nwith you.\n"},{"id":"552731","messageId":"xmqqwlsn31gq.fsf_-_@gitster.g","threadId":"66186","inReplyTo":"CAOLa=ZS0PT4bb+k3HR4F_aOoJ5uUuMFx+Dnte4LpPEekFxs9uA@mail.gmail.com","subject":"Re* [PATCH v10 4/4] hook: introduce the receive-report hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-14T22:36:05Z","receivedAt":"2026-09-14T22:36:08Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> I could send in this patch for coccinelle with the fixup if that's okay\n> with you.\n\nThis patch until it gets fixed will take the coccinelle updates\nhostage, so let's queue the following on top before merging it down\nto 'next'.\n\n----- >8 -----\nSubject: [PATCH] receive-pack: coccinelle fix\n\nLet's not check the nullness of cmd->error_string_owned before\ncalling FREE_AND_NULL(cmd->error_string_owned).  It is cheap and\nsafe to call FREE_AND_NULL(variable) for a variable that has NULL\nin it.\n\nSigned-off-by: Junio C Hamano <gitster@pobox.com>\n---\n builtin/receive-pack.c | 3 +--\n 1 file changed, 1 insertion(+), 2 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 9ac7717096..1d5b050beb 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -2452,8 +2452,7 @@ static void update_shallow_info(struct command *commands,\n static void override_cmds_error(struct command *commands, const char *err)\n {\n \tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n-\t\tif (cmd->error_string_owned)\n-\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n+\t\tFREE_AND_NULL(cmd->error_string_owned);\n \t\tcmd->error_string = err;\n \t}\n }\n-- \n2.56.0-rc0-195-g1e3108ffbb\n\n"},{"id":"552737","messageId":"CAOLa=ZR6qeiG1Mbq-ui90bRZyFD51J5XVRt_yRM2+PYukOVeAw@mail.gmail.com","threadId":"66186","inReplyTo":"xmqqwlsn31gq.fsf_-_@gitster.g","subject":"Re: Re* [PATCH v10 4/4] hook: introduce the receive-report hook","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-15T04:54:27Z","receivedAt":"2026-09-15T04:54:30Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Karthik Nayak <karthik.188@gmail.com> writes:\n>\n>> I could send in this patch for coccinelle with the fixup if that's okay\n>> with you.\n>\n> This patch until it gets fixed will take the coccinelle updates\n> hostage, so let's queue the following on top before merging it down\n> to 'next'.\n>\n\nThanks Junio, the patch looks good.\n\n> ----- >8 -----\n> Subject: [PATCH] receive-pack: coccinelle fix\n>\n> Let's not check the nullness of cmd->error_string_owned before\n> calling FREE_AND_NULL(cmd->error_string_owned).  It is cheap and\n> safe to call FREE_AND_NULL(variable) for a variable that has NULL\n> in it.\n>\n> Signed-off-by: Junio C Hamano <gitster@pobox.com>\n> ---\n>  builtin/receive-pack.c | 3 +--\n>  1 file changed, 1 insertion(+), 2 deletions(-)\n>\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index 9ac7717096..1d5b050beb 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -2452,8 +2452,7 @@ static void update_shallow_info(struct command *commands,\n>  static void override_cmds_error(struct command *commands, const char *err)\n>  {\n>  \tfor (struct command *cmd = commands; cmd; cmd = cmd->next) {\n> -\t\tif (cmd->error_string_owned)\n> -\t\t\tFREE_AND_NULL(cmd->error_string_owned);\n> +\t\tFREE_AND_NULL(cmd->error_string_owned);\n>  \t\tcmd->error_string = err;\n>  \t}\n>  }\n> --\n> 2.56.0-rc0-195-g1e3108ffbb\n"}]}