{"thread":{"id":"64919","subject":"[PATCH 0/4] Run hooks in parallel","startedAt":"2026-02-04T17:34:07Z","lastAt":"2026-04-10T09:07:41Z","messageCount":113,"participants":["Adrian Ratiu","Patrick Steinhardt","Phillip Wood","Junio C Hamano"],"isPatch":true,"patchVersion":1,"patchTotal":4},"messages":[{"id":"535177","messageId":"20260204173328.1601807-1-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":null,"subject":"[PATCH 0/4] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T17:33:24Z","receivedAt":"2026-02-04T17:34:07Z","isPatch":true,"body":"Hello everyone,\n\nThis enables running hook commands in parallel and is based on the patch\nseries enabling config hooks [1], which added the ability to run a list\nof hooks for each hook event.\n\nFor context, hooks used to run sequentially due to hardcoded .jobs == 1\nin hook.c, leading to .processes == 1 in run-command.c. We're removing\nthat restriction for hooks known to be safe to parallelize.\n\nThe parallelism enabled here is to run multiple hook commands/scripts\nin parallel for a single event, for example the pre-push event might\ntrigger linters / spell checkers / unit tests to run at the same time.\n\nAnother kind of parallelism is to split the hook input to multiple\nchild processes, running the same command in parallel on subsets of\nthe hook input. This series does not do that. It might be a future\naddition on top of this, since it's kind of a lower-level parallelism.\n\nThe pre-push hook is special because it is the only known hook to break\nbackward compatibility when running in parallel, due to run-command\ncollating its outputs via a pipe, so I added an extension for it.\nUsers can opt-in to this extension with a runtime config.\n\nSuggestions for alternative solutions to the extension are welcome.\n\nAgain, this is based on the latest v1 config hooks series [1] which\nhas not yet landed in next or master.\n\nBranch pushed to GitHub containing all dependency patches: [2]\nSuccessful CI run: [3]\n\nMany thanks to all who contributed to this effort up to now, including\nEmily, AEvar, Junio, Patrick, Peff and many others.\n\nThank you,\nAdrian\n\n1: https://lore.kernel.org/git/20260204165126.1548805-1-adrian.ratiu@collabora.com/T/#mdb138a39d332f234bc9068b7f4e05b10c400e572\n2: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/parallel-hooks-v1\n3: https://github.com/10ne1/git/actions/runs/21680184456\n\nAdrian Ratiu (3):\n  config: add a repo_config_get_uint() helper\n  hook: introduce extensions.hookStdoutToStderr\n  hook: allow runtime enabling extensions.hookStdoutToStderr\n\nEmily Shaffer (1):\n  hook: allow parallel hook execution\n\n Documentation/config/extensions.adoc |  15 ++\n Documentation/config/hook.adoc       |  14 ++\n Documentation/git-hook.adoc          |  14 +-\n builtin/am.c                         |  10 +-\n builtin/checkout.c                   |  13 +-\n builtin/clone.c                      |   6 +-\n builtin/hook.c                       |   7 +-\n builtin/receive-pack.c               |   9 +-\n builtin/worktree.c                   |   2 +-\n commit.c                             |   2 +-\n config.c                             |  28 +++\n config.h                             |  13 ++\n hook.c                               |  51 ++++-\n hook.h                               |  20 +-\n parse.c                              |   9 +\n parse.h                              |   1 +\n refs.c                               |   2 +-\n repository.c                         |   1 +\n repository.h                         |   1 +\n sequencer.c                          |   4 +-\n setup.c                              |  17 ++\n setup.h                              |   1 +\n t/t1800-hook.sh                      | 270 ++++++++++++++++++++++++++-\n transport.c                          |   9 +-\n 24 files changed, 476 insertions(+), 43 deletions(-)\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535178","messageId":"20260204173328.1601807-2-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH 1/4] config: add a repo_config_get_uint() helper","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T17:33:25Z","receivedAt":"2026-02-04T17:34:09Z","isPatch":true,"body":"Next commit adds a 'hook.jobs' config option of type 'unsigned int',\nso add a helper to parse it since the API only supports int and ulong.\n\nAn alternative is to make 'hook.jobs' an 'int' or parse it as an 'int'\nthen cast it to unsigned, however it's better to use proper helpers for\nthe type. Using 'ulong' is another option which already has helpers, but\nit's a bit excessive in size for just the jobs number.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n config.c | 28 ++++++++++++++++++++++++++++\n config.h | 13 +++++++++++++\n parse.c  |  9 +++++++++\n parse.h  |  1 +\n 4 files changed, 51 insertions(+)\n\ndiff --git a/config.c b/config.c\nindex 7f6d53b473..f842c31798 100644\n--- a/config.c\n+++ b/config.c\n@@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value,\n \treturn ret;\n }\n \n+unsigned int git_config_uint(const char *name, const char *value,\n+\t\t\t     const struct key_value_info *kvi)\n+{\n+\tunsigned int ret;\n+\tif (!git_parse_uint(value, &ret))\n+\t\tdie_bad_number(name, value, kvi);\n+\treturn ret;\n+}\n+\n int64_t git_config_int64(const char *name, const char *value,\n \t\t\t const struct key_value_info *kvi)\n {\n@@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest)\n \t\treturn 1;\n }\n \n+int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest)\n+{\n+\tconst char *value;\n+\tstruct key_value_info kvi;\n+\n+\tif (!git_configset_get_value(set, key, &value, &kvi)) {\n+\t\t*dest = git_config_uint(key, value, &kvi);\n+\t\treturn 0;\n+\t} else\n+\t\treturn 1;\n+}\n+\n int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest)\n {\n \tconst char *value;\n@@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo,\n \treturn git_configset_get_int(repo->config, key, dest);\n }\n \n+int repo_config_get_uint(struct repository *repo,\n+\t\t\t const char *key, unsigned int *dest)\n+{\n+\tgit_config_check_init(repo);\n+\treturn git_configset_get_uint(repo->config, key, dest);\n+}\n+\n int repo_config_get_ulong(struct repository *repo,\n \t\t\t  const char *key, unsigned long *dest)\n {\ndiff --git a/config.h b/config.h\nindex ba426a960a..bf47fb3afc 100644\n--- a/config.h\n+++ b/config.h\n@@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *);\n int64_t git_config_int64(const char *, const char *,\n \t\t\t const struct key_value_info *);\n \n+/**\n+ * Identical to `git_config_int`, but for unsigned ints.\n+ */\n+unsigned int git_config_uint(const char *, const char *,\n+\t\t\t     const struct key_value_info *);\n+\n /**\n  * Identical to `git_config_int`, but for unsigned longs.\n  */\n@@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key,\n \n int git_configset_get_string(struct config_set *cs, const char *key, char **dest);\n int git_configset_get_int(struct config_set *cs, const char *key, int *dest);\n+int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest);\n int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest);\n int git_configset_get_bool(struct config_set *cs, const char *key, int *dest);\n int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest);\n@@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r,\n  */\n int repo_config_get_int(struct repository *r, const char *key, int *dest);\n \n+/**\n+ * Similar to `repo_config_get_int` but for unsigned ints.\n+ */\n+int repo_config_get_uint(struct repository *r,\n+\t\t\t const char *key, unsigned int *dest);\n+\n /**\n  * Similar to `repo_config_get_int` but for unsigned longs.\n  */\ndiff --git a/parse.c b/parse.c\nindex 48313571aa..d77f28046a 100644\n--- a/parse.c\n+++ b/parse.c\n@@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret)\n \treturn 1;\n }\n \n+int git_parse_uint(const char *value, unsigned int *ret)\n+{\n+\tuintmax_t tmp;\n+\tif (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int)))\n+\t\treturn 0;\n+\t*ret = tmp;\n+\treturn 1;\n+}\n+\n int git_parse_ulong(const char *value, unsigned long *ret)\n {\n \tuintmax_t tmp;\ndiff --git a/parse.h b/parse.h\nindex ea32de9a91..a6dd37c4cb 100644\n--- a/parse.h\n+++ b/parse.h\n@@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max);\n int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max);\n int git_parse_ssize_t(const char *, ssize_t *);\n int git_parse_ulong(const char *, unsigned long *);\n+int git_parse_uint(const char *value, unsigned int *ret);\n int git_parse_int(const char *value, int *ret);\n int git_parse_int64(const char *value, int64_t *ret);\n int git_parse_double(const char *value, double *ret);\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535179","messageId":"20260204173328.1601807-4-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH 3/4] hook: introduce extensions.hookStdoutToStderr","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T17:33:27Z","receivedAt":"2026-02-04T17:34:14Z","isPatch":true,"body":"All hooks already redirect stdout to stderr with the exception of\npre-push which has a known user who depends on the separate stdout\nversus stderr outputs (the git-lfs project).\n\nThe pre-push behavior was a surprise which we found out about after\ncausing a regression for git-lfs. Notably, it might not be the only\nexception (it's the one we know about). There might be more.\n\nThis presents a challenge because stdout_to_stderr is required for\nhook parallelization, so run-command can buffer and de-interleave\nthe hook outputs using ungroup=0, when hook.jobs > 1.\n\nIntroduce an extension to enforce consistency: all hooks merge stdout\ninto stderr and can be safely parallelized. This provides a clean\nseparation and avoids breaking existing stdout vs stderr behavior.\n\nWhen this extension is disabled, the `hook.jobs` config has no\neffect for pre-push, to prevent garbled (interleaved) parallel\noutput, so it runs sequentially like before.\n\nAlternatives I've considered to this extension include:\n1. Allowing pre-push to run in parallel with interleaved output.\n2. Always running pre-push sequentially (no parallel jobs for it).\n3. Making users (only git-lfs? maybe more?) fix their hooks to read\n   stderr not stdout.\n\nOut of all these alternatives, I think this extension is the most\nreasonable compromise, to not break existing users, allow pre-push\nparallel jobs for those who need it (with correct outputs) and also\nfuture-proofing in case there are any more exceptions to be added.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/extensions.adoc | 12 ++++++++\n Documentation/config/hook.adoc       |  3 ++\n repository.c                         |  1 +\n repository.h                         |  1 +\n setup.c                              |  7 +++++\n setup.h                              |  1 +\n t/t1800-hook.sh                      | 42 ++++++++++++++++++++++++++++\n transport.c                          |  7 ++---\n 8 files changed, 69 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc\nindex 532456644b..de47d97f6d 100644\n--- a/Documentation/config/extensions.adoc\n+++ b/Documentation/config/extensions.adoc\n@@ -73,6 +73,18 @@ relativeWorktrees:::\n \trepaired with either the `--relative-paths` option or with the\n \t`worktree.useRelativePaths` config set to `true`.\n \n+hookStdoutToStderr:::\n+\tIf enabled, the stdout of all hooks is redirected to stderr. This\n+\tenforces consistency, since by default most hooks already behave\n+\tthis way, with pre-push being the only known exception.\n++\n+This is useful for parallel hook execution (see the `hook.jobs` config in\n+linkgit:git-config[1]), as it allows the output of multiple hooks running\n+in parallel to be grouped (de-interleaved) correctly.\n++\n+Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push\n+hooks, which will always be run sequentially.\n+\n worktreeConfig:::\n \tIf enabled, then worktrees will load config settings from the\n \t`$GIT_DIR/config.worktree` file in addition to the\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex c394756328..56e6b4e5c3 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -20,3 +20,6 @@ hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to the number of processors on\n \tthe current system.\n++\n+This has no effect for hooks requiring separate output streams (like `pre-push`)\n+unless `extensions.hookStdoutToStderr` is enabled.\ndiff --git a/repository.c b/repository.c\nindex c7e75215ac..36317cafa7 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -281,6 +281,7 @@ int repo_init(struct repository *repo,\n \trepo->repository_format_worktree_config = format.worktree_config;\n \trepo->repository_format_relative_worktrees = format.relative_worktrees;\n \trepo->repository_format_precious_objects = format.precious_objects;\n+\trepo->repository_format_hook_stdout_to_stderr = format.hook_stdout_to_stderr;\n \n \t/* take ownership of format.partial_clone */\n \trepo->repository_format_partial_clone = format.partial_clone;\ndiff --git a/repository.h b/repository.h\nindex 6063c4b846..f358fdc877 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -165,6 +165,7 @@ struct repository {\n \tint repository_format_worktree_config;\n \tint repository_format_relative_worktrees;\n \tint repository_format_precious_objects;\n+\tint repository_format_hook_stdout_to_stderr;\n \n \t/* Indicate if a repository has a different 'commondir' from 'gitdir' */\n \tunsigned different_commondir:1;\ndiff --git a/setup.c b/setup.c\nindex b723f8b339..cf4949c086 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -686,6 +686,9 @@ static enum extension_result handle_extension(const char *var,\n \t} else if (!strcmp(ext, \"relativeworktrees\")) {\n \t\tdata->relative_worktrees = git_config_bool(var, value);\n \t\treturn EXTENSION_OK;\n+\t} else if (!strcmp(ext, \"hookstdouttostderr\")) {\n+\t\tdata->hook_stdout_to_stderr = git_config_bool(var, value);\n+\t\treturn EXTENSION_OK;\n \t}\n \treturn EXTENSION_UNKNOWN;\n }\n@@ -1947,6 +1950,8 @@ const char *setup_git_directory_gently(int *nongit_ok)\n \t\t\t\trepo_fmt.worktree_config;\n \t\t\tthe_repository->repository_format_relative_worktrees =\n \t\t\t\trepo_fmt.relative_worktrees;\n+\t\t\tthe_repository->repository_format_hook_stdout_to_stderr =\n+\t\t\t\trepo_fmt.hook_stdout_to_stderr;\n \t\t\t/* take ownership of repo_fmt.partial_clone */\n \t\t\tthe_repository->repository_format_partial_clone =\n \t\t\t\trepo_fmt.partial_clone;\n@@ -2047,6 +2052,8 @@ void check_repository_format(struct repository_format *fmt)\n \t\tfmt->worktree_config;\n \tthe_repository->repository_format_relative_worktrees =\n \t\tfmt->relative_worktrees;\n+\tthe_repository->repository_format_hook_stdout_to_stderr =\n+\t\tfmt->hook_stdout_to_stderr;\n \tthe_repository->repository_format_partial_clone =\n \t\txstrdup_or_null(fmt->partial_clone);\n \tclear_repository_format(&repo_fmt);\ndiff --git a/setup.h b/setup.h\nindex d55dcc6608..929a2e0ba8 100644\n--- a/setup.h\n+++ b/setup.h\n@@ -167,6 +167,7 @@ struct repository_format {\n \tchar *partial_clone; /* value of extensions.partialclone */\n \tint worktree_config;\n \tint relative_worktrees;\n+\tint hook_stdout_to_stderr;\n \tint is_bare;\n \tint hash_algo;\n \tint compat_hash_algo;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 4db1fac862..bf19579f3a 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -395,6 +395,48 @@ test_expect_success 'client hooks: pre-push expects separate stdout and stderr'\n \tcheck_stdout_separate_from_stderr pre-push\n '\n \n+test_expect_success 'client hooks: extension makes pre-push merge stdout to stderr' '\n+\ttest_when_finished \"rm -f stdout.actual stderr.actual\" &&\n+\tgit init --bare remote2 &&\n+\tgit remote add origin2 remote2 &&\n+\ttest_commit B &&\n+\t# repositoryformatversion=1 might be already set (eg default sha256)\n+\t# so check before using test_config to set it\n+\t{ test \"$(git config core.repositoryformatversion)\" = 1 ||\n+\t  test_config core.repositoryformatversion 1; } &&\n+\tgit config set core.repositoryformatversion 1 &&\n+\ttest_config extensions.hookStdoutToStderr true &&\n+\tsetup_hooks pre-push &&\n+\tgit push origin2 HEAD:main >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push\n+'\n+\n+test_expect_success 'client hooks: pre-push defaults to serial execution' '\n+\ttest_when_finished \"rm -rf repo-serial\" &&\n+\tgit init --bare remote-serial &&\n+\tgit init repo-serial &&\n+\tgit -C repo-serial remote add origin ../remote-serial &&\n+\ttest_commit -C repo-serial A &&\n+\n+\t# Setup 2 pre-push hooks\n+\twrite_script repo-serial/.git/hooks/pre-push <<-EOF &&\n+\tsleep 2\n+\techo \"Hook 1\" >&2\n+\tEOF\n+\tgit -C repo-serial config hook.hook-2.event pre-push &&\n+\tgit -C repo-serial config hook.hook-2.command \"sleep 2; echo Hook 2 >&2\" &&\n+\n+\tgit -C repo-serial config hook.jobs 2 &&\n+\n+\tstart=$(date +%s) &&\n+\tgit -C repo-serial push origin HEAD >out 2>err &&\n+\tend=$(date +%s) &&\n+\n+\tduration=$((end - start)) &&\n+\t# Serial >= 4s, parallel < 4s.\n+\ttest $duration -ge 4\n+'\n+\n test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' '\n \thooks=\"pre-commit prepare-commit-msg \\\n \t\tcommit-msg post-commit \\\ndiff --git a/transport.c b/transport.c\nindex 477a598eec..708118f439 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1394,11 +1394,8 @@ static int run_pre_push_hook(struct transport *transport,\n \topt.copy_feed_pipe_cb_data = copy_pre_push_hook_data;\n \topt.free_feed_pipe_cb_data = free_pre_push_hook_data;\n \n-\t/*\n-\t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n-\t * them to keep backwards compatibility with existing hooks.\n-\t */\n-\topt.stdout_to_stderr = 0;\n+\t/* merge stdout to stderr only when extensions.StdoutToStderr is enabled */\n+\topt.stdout_to_stderr = the_repository->repository_format_hook_stdout_to_stderr;\n \n \tret = run_hooks_opt(the_repository, \"pre-push\", &opt);\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535180","messageId":"20260204173328.1601807-3-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH 2/4] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T17:33:26Z","receivedAt":"2026-02-04T17:34:15Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nIn many cases, there's no reason not to allow hooks to execute in\nparallel, if more than one was provided.\n\nhook.c already calls run_processes_parallel() so all we need to do is\nallow its job count to be greater than 1.\n\nSerial execution is achieved by setting .jobs == 1 at compile time via\nRUN_HOOKS_OPT_INIT_SERIAL or by setting the 'hook.jobs' config to 1.\nThis matches the behavior prior to this commit.\n\nThe compile-time 'struct run_hooks_opt.jobs' parameter has the highest\npriority if non-zero, followed by the 'hook.jobs' user config, then the\nprocessor count from online_cpus() is the last fallback.\n\nThe above ordering ensures hooks unsafe to run in parallel are always\nexecuted sequentially (RUN_HOOKS_OPT_INIT_SERIAL) while allowing users\nto control parallelism with an efficient default.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |   5 ++\n Documentation/git-hook.adoc    |  14 ++-\n builtin/am.c                   |  10 ++-\n builtin/checkout.c             |  13 ++-\n builtin/clone.c                |   6 +-\n builtin/hook.c                 |   7 +-\n builtin/receive-pack.c         |   9 +-\n builtin/worktree.c             |   2 +-\n commit.c                       |   2 +-\n hook.c                         |  41 +++++++--\n hook.h                         |  20 ++++-\n refs.c                         |   2 +-\n sequencer.c                    |   4 +-\n t/t1800-hook.sh                | 154 +++++++++++++++++++++++++++++++--\n transport.c                    |   2 +-\n 15 files changed, 253 insertions(+), 38 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 49c7ffd82e..c394756328 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -15,3 +15,8 @@ hook.<name>.event::\n \tOn the specified event, the associated `hook.<name>.command` will be\n \texecuted. More than one event can be specified if you wish for\n \t`hook.<name>` to execute on multiple events. See linkgit:git-hook[1].\n+\n+hook.jobs::\n+\tSpecifies how many hooks can be run simultaneously during parallelized\n+\thook execution. If unspecified, defaults to the number of processors on\n+\tthe current system.\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 5f339dc48b..72c6c6d1ee 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -8,7 +8,8 @@ git-hook - Run git hooks\n SYNOPSIS\n --------\n [verse]\n-'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n+\t<hook-name> [-- <hook-args>]\n 'git hook' list <hook-name>\n \n DESCRIPTION\n@@ -128,6 +129,16 @@ OPTIONS\n \ttools that want to do a blind one-shot run of a hook that may\n \tor may not be present.\n \n+-j::\n+--jobs::\n+\tOnly valid for `run`.\n++\n+Specify how many hooks to run simultaneously. If this flag is not specified,\n+the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If the\n+config is not specified, the number of CPUs on the current system is used. Some\n+hooks may be ineligible for parallelization: for example, 'commit-msg' hooks\n+typically modify the commit message body and cannot be parallelized.\n+\n WRAPPERS\n --------\n \n@@ -151,6 +162,7 @@ git hook run mywrapper-start-tests \\\n   # providing something to stdin\n   --stdin some-tempfile-123 \\\n   # execute hooks in serial\n+  --jobs 1 \\\n   # plus some arguments of your own...\n   -- \\\n   --testname bar \\\ndiff --git a/builtin/am.c b/builtin/am.c\nindex b66a33d8a8..427e137883 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state)\n \n \tassert(state->msg);\n \n-\tif (!state->no_verify)\n-\t\tret = run_hooks_l(the_repository, \"applypatch-msg\",\n-\t\t\t\t  am_path(state, \"final-commit\"), NULL);\n+\tif (!state->no_verify) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL;\n+\t\tstrvec_push(&opt.args, am_path(state, \"final-commit\"));\n+\t\tret = run_hooks_opt(the_repository, \"applypatch-msg\", &opt);\n+\t}\n \n \tif (!ret) {\n \t\tFREE_AND_NULL(state->msg);\n@@ -509,7 +511,7 @@ static int run_applypatch_msg_hook(struct am_state *state)\n  */\n static int run_post_rewrite_hook(const struct am_state *state)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \n \tstrvec_push(&opt.args, \"rebase\");\n \topt.path_to_stdin = am_path(state, \"rewritten\");\ndiff --git a/builtin/checkout.c b/builtin/checkout.c\nindex 0ba4f03f2e..23833ddfe8 100644\n--- a/builtin/checkout.c\n+++ b/builtin/checkout.c\n@@ -31,6 +31,7 @@\n #include \"resolve-undo.h\"\n #include \"revision.h\"\n #include \"setup.h\"\n+#include \"strvec.h\"\n #include \"submodule.h\"\n #include \"symlinks.h\"\n #include \"trace2.h\"\n@@ -137,13 +138,17 @@ static void branch_info_release(struct branch_info *info)\n static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit,\n \t\t\t      int changed)\n {\n-\treturn run_hooks_l(the_repository, \"post-checkout\",\n-\t\t\t   oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   changed ? \"1\" : \"0\", NULL);\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL;\n+\n \t/* \"new_commit\" can be NULL when checking out from the index before\n \t   a commit exists. */\n+\tstrvec_pushl(&opt.args,\n+\t\t     oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     changed ? \"1\" : \"0\",\n+\t\t     NULL);\n \n+\treturn run_hooks_opt(the_repository, \"post-checkout\", &opt);\n }\n \n static int update_some(const struct object_id *oid, struct strbuf *base,\ndiff --git a/builtin/clone.c b/builtin/clone.c\nindex b40cee5968..2c5ec213a5 100644\n--- a/builtin/clone.c\n+++ b/builtin/clone.c\n@@ -644,6 +644,7 @@ static int checkout(int submodule_progress, int filter_submodules,\n \tstruct tree *tree;\n \tstruct tree_desc t;\n \tint err = 0;\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_SERIAL;\n \n \tif (option_no_checkout)\n \t\treturn 0;\n@@ -694,8 +695,9 @@ static int checkout(int submodule_progress, int filter_submodules,\n \tif (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK))\n \t\tdie(_(\"unable to write new index file\"));\n \n-\terr |= run_hooks_l(the_repository, \"post-checkout\", oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(&oid), \"1\", NULL);\n+\tstrvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(&oid), \"1\", NULL);\n+\terr |= run_hooks_opt(the_repository, \"post-checkout\", &hook_opt);\n \n \tif (!err && (option_recurse_submodules.nr > 0)) {\n \t\tstruct child_process cmd = CHILD_PROCESS_INIT;\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 4cc6dac45a..cd1f4ebe6a 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -9,7 +9,8 @@\n #include \"abspath.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n-\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n+\t   \"<hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list <hook-name>\")\n \n@@ -76,7 +77,7 @@ static int run(int argc, const char **argv, const char *prefix,\n \t       struct repository *repo UNUSED)\n {\n \tint i;\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \tint ignore_missing = 0;\n \tconst char *hook_name;\n \tstruct option run_options[] = {\n@@ -84,6 +85,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n+\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 72fde2207c..6ced7b181c 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -925,7 +925,7 @@ static int run_receive_hook(struct command *commands,\n \t\t\t    int skip_broken,\n \t\t\t    const struct string_list *push_options)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \tstruct command *iter = commands;\n \tstruct receive_hook_feed_state feed_state;\n \tstruct async sideband_async;\n@@ -976,7 +976,7 @@ static int run_receive_hook(struct command *commands,\n \n static int run_update_hook(struct command *cmd)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \tstruct async sideband_async;\n \tint sideband_async_started = 0;\n \tint saved_stderr = -1;\n@@ -1455,7 +1455,8 @@ static const char *push_to_checkout(unsigned char *hash,\n \t\t\t\t    struct strvec *env,\n \t\t\t\t    const char *work_tree)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL;\n+\n \topt.invoked_hook = invoked_hook;\n \n \tstrvec_pushf(env, \"GIT_WORK_TREE=%s\", absolute_path(work_tree));\n@@ -1670,7 +1671,7 @@ static const char *update(struct command *cmd, struct shallow_info *si)\n \n static void run_update_post_hook(struct command *commands)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \tstruct async sideband_async;\n \tstruct command *cmd;\n \tint sideband_async_started = 0;\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex fbdaf2eb2e..b30719124c 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -574,7 +574,7 @@ static int add_worktree(const char *path, const char *refname,\n \t * is_junk is cleared, but do return appropriate code when hook fails.\n \t */\n \tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL;\n \n \t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n \t\tstrvec_pushl(&opt.args,\ndiff --git a/commit.c b/commit.c\nindex 28bb5ce029..e58c020a51 100644\n--- a/commit.c\n+++ b/commit.c\n@@ -1961,7 +1961,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len)\n int run_commit_hook(int editor_is_used, const char *index_file,\n \t\t    int *invoked_hook, const char *name, ...)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL;\n \tva_list args;\n \tconst char *arg;\n \ndiff --git a/hook.c b/hook.c\nindex 9f59ebd0bd..e07e8f4efe 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -250,6 +250,35 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/*\n+ * Determines how many jobs to use for hook execution.\n+ * The priority is as follows:\n+ *   1. Hooks setting jobs=1, either via RUN_HOOKS_OPT_INIT_SERIAL or stdout_to_stderr=0\n+ *      are known to be unsafe to parallelize, so their jobs=1 has precedence.\n+ *   3. The 'hook.jobs' configuration is used if set.\n+ *   4. The number of online CPUs is used as a final fallback.\n+ * Returns:\n+ *   The number of jobs to use for parallel execution, or 1 for serial.\n+ */\n+static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options)\n+{\n+\tunsigned int jobs = options->jobs;\n+\n+\t/*\n+\t * Hooks which configure stdout_to_stderr=0 (like pre-push), expect separate\n+\t * output streams. Unless extensions.StdoutToStderr is enabled (which forces\n+\t * stdout_to_stderr=1), the hook must run sequentially to guarantee output is\n+\t * non-interleaved.\n+\t */\n+\tif (!options->stdout_to_stderr)\n+\t\tjobs = 1;\n+\n+\tif (!jobs && repo_config_get_uint(r, \"hook.jobs\", &jobs))\n+\t\tjobs = online_cpus(); /* fallback if config is unset */\n+\n+\treturn jobs;\n+}\n+\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n@@ -262,12 +291,13 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t.repository = r,\n \t};\n \tint ret = 0;\n+\tunsigned int jobs = get_hook_jobs(r, options);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\n \n-\t\t.processes = options->jobs,\n-\t\t.ungroup = options->jobs == 1,\n+\t\t.processes = jobs,\n+\t\t.ungroup = jobs == 1,\n \n \t\t.get_next_task = pick_next_hook,\n \t\t.start_failure = notify_start_failure,\n@@ -283,9 +313,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->path_to_stdin && options->feed_pipe)\n \t\tBUG(\"options path_to_stdin and feed_pipe are mutually exclusive\");\n \n-\tif (!options->jobs)\n-\t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n-\n \t/*\n \t * Ensure cb_data copy and free functions are either provided together,\n \t * or neither one is provided.\n@@ -337,14 +364,14 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \n int run_hooks(struct repository *r, const char *hook_name)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \n \treturn run_hooks_opt(r, hook_name, &opt);\n }\n \n int run_hooks_l(struct repository *r, const char *hook_name, ...)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \tva_list ap;\n \tconst char *arg;\n \ndiff --git a/hook.h b/hook.h\nindex cdbe5a9167..3a579c19db 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -22,6 +22,8 @@ struct run_hooks_opt\n \t *\n \t * If > 1, output will be buffered and de-interleaved (ungroup=0).\n \t * If == 1, output will be real-time (ungroup=1).\n+\t * If == 0, the 'hook.jobs' config is used or, if the config is unset,\n+\t * the number of online cpus on the system.\n \t */\n \tunsigned int jobs;\n \n@@ -111,13 +113,29 @@ struct run_hooks_opt\n \tvoid (*free_feed_pipe_cb_data)(void *data);\n };\n \n-#define RUN_HOOKS_OPT_INIT { \\\n+/**\n+ * Initializer for hooks capable of running only sequentially.\n+ * .jobs = 1 forces serial execution.\n+ */\n+#define RUN_HOOKS_OPT_INIT_SERIAL { \\\n \t.env = STRVEC_INIT, \\\n \t.args = STRVEC_INIT, \\\n \t.stdout_to_stderr = 1, \\\n \t.jobs = 1, \\\n }\n \n+/**\n+ * Initializer for hooks capable of running in parallel.\n+ * .jobs = 0 means online_cpus() will be called to get the number of jobs, if\n+ * users did not specify a 'hook.jobs' config which has precedence.\n+ */\n+#define RUN_HOOKS_OPT_INIT_PARALLEL { \\\n+\t.env = STRVEC_INIT, \\\n+\t.args = STRVEC_INIT, \\\n+\t.stdout_to_stderr = 1, \\\n+\t.jobs = 0, \\\n+}\n+\n struct hook_cb_data {\n \t/* rc reflects the cumulative failure state */\n \tint rc;\ndiff --git a/refs.c b/refs.c\nindex d1a1ace641..9fba8700b3 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -2533,7 +2533,7 @@ static void free_transaction_feed_cb_data(void *data)\n static int run_transaction_hook(struct ref_transaction *transaction,\n \t\t\t\tconst char *state)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \tstruct transaction_feed_cb_data feed_ctx = { 0 };\n \tint ret = 0;\n \ndiff --git a/sequencer.c b/sequencer.c\nindex cccde58bee..9271a6fa4f 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1311,7 +1311,7 @@ static int pipe_from_strbuf(int hook_stdin_fd, void *pp_cb, void *pp_task_cb UNU\n static int run_rewrite_hook(const struct object_id *oldoid,\n \t\t\t    const struct object_id *newoid)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \tint code;\n \tstruct strbuf sb = STRBUF_INIT;\n \n@@ -5137,7 +5137,7 @@ static int pick_commits(struct repository *r,\n \t\tif (!stat(rebase_path_rewritten_list(), &st) &&\n \t\t\t\tst.st_size > 0) {\n \t\t\tstruct child_process child = CHILD_PROCESS_INIT;\n-\t\t\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT;\n+\t\t\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \n \t\t\tchild.in = open(rebase_path_rewritten_list(), O_RDONLY);\n \t\t\tchild.git_cmd = 1;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 21ff6a68f0..4db1fac862 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -146,10 +146,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n '\n \n test_hook_tty () {\n-\tcat >expect <<-\\EOF\n-\tSTDOUT TTY\n-\tSTDERR TTY\n-\tEOF\n+\texpect_tty=$1\n+\tshift\n+\n+\tif test \"$expect_tty\" != \"no_tty\"; then\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT TTY\n+\t\tSTDERR TTY\n+\t\tEOF\n+\telse\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT NO TTY\n+\t\tSTDERR NO TTY\n+\t\tEOF\n+\tfi\n \n \ttest_when_finished \"rm -rf repo\" &&\n \tgit init repo &&\n@@ -167,12 +177,21 @@ test_hook_tty () {\n \ttest_cmp expect repo/actual\n }\n \n-test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty hook run pre-commit\n+test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '\n+\t# hooks running sequentially (-j1) are always connected to the tty for\n+\t# optimum real-time performance.\n+\ttest_hook_tty tty hook run -j1 pre-commit\n+'\n+\n+test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '\n+\t# Hooks are not connected to the tty when run in parallel, instead they\n+\t# output to a pipe through which run-command collects and de-interlaces\n+\t# their outputs, which then gets passed either to the tty or a sideband.\n+\ttest_hook_tty no_tty hook run -j2 pre-commit\n '\n \n test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty commit -m\"B.new\"\n+\ttest_hook_tty tty commit -m\"B.new\"\n '\n \n test_expect_success 'git hook list orders by config order' '\n@@ -467,4 +486,125 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'parallel hook output is not interleaved' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo \"Hook 1 Start\"\n+\tsleep 1\n+\techo \"Hook 1 End\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t\t    \"echo \\\"Hook 2 Start\\\"; sleep 2; echo \\\"Hook 2 End\\\"\" &&\n+\ttest_config hook.hook-3.event test-hook &&\n+\ttest_config hook.hook-3.command \\\n+\t\t    \"echo \\\"Hook 3 Start\\\"; sleep 3; echo \\\"Hook 3 End\\\"\" &&\n+\n+\tgit hook run -j3 test-hook >out 2>err.parallel &&\n+\n+\t# Verify Hook 1 output is grouped\n+\tsed -n \"/Hook 1 Start/,/Hook 1 End/p\" err.parallel >hook1_out &&\n+\ttest_line_count = 2 hook1_out &&\n+\n+\t# Verify Hook 2 output is grouped\n+\tsed -n \"/Hook 2 Start/,/Hook 2 End/p\" err.parallel >hook2_out &&\n+\ttest_line_count = 2 hook2_out &&\n+\n+\t# Verify Hook 3 output is grouped\n+\tsed -n \"/Hook 3 Start/,/Hook 3 End/p\" err.parallel >hook3_out &&\n+\ttest_line_count = 2 hook3_out\n+'\n+\n+test_expect_success 'git hook run -j1 runs hooks in series' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\ttest_config hook.series-1.event \"test-hook\" &&\n+\ttest_config hook.series-1.command \"echo 1\" --add &&\n+\ttest_config hook.series-2.event \"test-hook\" &&\n+\ttest_config hook.series-2.command \"echo 2\" --add &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo 3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tgit hook run -j1 test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook run -j2 runs hooks in parallel' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\tsleep 2\n+\techo \"Hook 1\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"sleep 2; echo Hook 2\" &&\n+\n+\tstart=$(date +%s) &&\n+\tgit hook run -j2 test-hook >out 2>err &&\n+\tend=$(date +%s) &&\n+\n+\tduration=$((end - start)) &&\n+\t# 2 tasks of 2s. Serial >= 4s. Parallel < 4s.\n+\ttest $duration -lt 4\n+'\n+\n+test_expect_success 'hook.jobs=1 config runs hooks in series' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\tsleep 2\n+\techo \"Hook 1\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"sleep 2; echo Hook 2\" &&\n+\n+\ttest_config hook.jobs 1 &&\n+\n+\tstart=$(date +%s) &&\n+\tgit hook run test-hook >out 2>err &&\n+\tend=$(date +%s) &&\n+\n+\tduration=$((end - start)) &&\n+\t# 2 tasks of 2s. Serial >= 4s. Parallel < 4s.\n+\ttest $duration -ge 4\n+'\n+\n+test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\tsleep 2\n+\techo \"Hook 1\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"sleep 2; echo Hook 2\" &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tstart=$(date +%s) &&\n+\tgit hook run test-hook >out 2>err &&\n+\tend=$(date +%s) &&\n+\n+\tduration=$((end - start)) &&\n+\t# 2 tasks of 2s. Serial >= 4s. Parallel < 4s.\n+\ttest $duration -lt 4\n+'\n+\n test_done\ndiff --git a/transport.c b/transport.c\nindex 176050e663..477a598eec 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1379,7 +1379,7 @@ static void free_pre_push_hook_data(void *data)\n static int run_pre_push_hook(struct transport *transport,\n \t\t\t     struct ref *remote_refs)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n \tstruct feed_pre_push_hook_data data;\n \tint ret = 0;\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535181","messageId":"20260204173328.1601807-5-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH 4/4] hook: allow runtime enabling extensions.hookStdoutToStderr","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T17:33:28Z","receivedAt":"2026-02-04T17:34:16Z","isPatch":true,"body":"Add a new config `hook.forceStdoutToStderr` which allows enabling\nextensions.hookStdoutToStderr by default at runtime, both for new\nand existing repositories.\n\nThis makes it easier for users to enable hook parallelization for\nhooks like pre-push by enforcing output consistency. See previous\ncommit for a more in-depth explanation & alternatives considered.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/extensions.adoc |  3 ++\n Documentation/config/hook.adoc       |  6 +++\n hook.c                               | 10 ++++\n setup.c                              | 10 ++++\n t/t1800-hook.sh                      | 74 ++++++++++++++++++++++++++++\n 5 files changed, 103 insertions(+)\n\ndiff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc\nindex de47d97f6d..0db485dd54 100644\n--- a/Documentation/config/extensions.adoc\n+++ b/Documentation/config/extensions.adoc\n@@ -84,6 +84,9 @@ in parallel to be grouped (de-interleaved) correctly.\n +\n Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push\n hooks, which will always be run sequentially.\n++\n+The extension can also be enabled by setting `hook.forceStdoutToStderr`\n+to `true` in the global configuration.\n \n worktreeConfig:::\n \tIf enabled, then worktrees will load config settings from the\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 56e6b4e5c3..a6a79a1670 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -23,3 +23,9 @@ hook.jobs::\n +\n This has no effect for hooks requiring separate output streams (like `pre-push`)\n unless `extensions.hookStdoutToStderr` is enabled.\n+\n+hook.forceStdoutToStderr::\n+\tA boolean that enables the `extensions.hookStdoutToStderr` behavior\n+\t(merging stdout to stderr for all hooks) globally. This effectively\n+\tforces all hooks to behave as if the extension was enabled, allowing\n+\tparallel execution for hooks like `pre-push`.\ndiff --git a/hook.c b/hook.c\nindex e07e8f4efe..bae6e35943 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -264,6 +264,16 @@ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *op\n {\n \tunsigned int jobs = options->jobs;\n \n+\t/*\n+\t * Allow hook.forceStdoutToStderr to enable extensions.hookStdoutToStderr\n+\t * for existing repositories (runtime override).\n+\t */\n+\tif (!options->stdout_to_stderr) {\n+\t\tint v = 0;\n+\t\trepo_config_get_bool(r, \"hook.forceStdoutToStderr\", &v);\n+\t\toptions->stdout_to_stderr = v;\n+\t}\n+\n \t/*\n \t * Hooks which configure stdout_to_stderr=0 (like pre-push), expect separate\n \t * output streams. Unless extensions.StdoutToStderr is enabled (which forces\ndiff --git a/setup.c b/setup.c\nindex cf4949c086..037ab2e21d 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -2310,6 +2310,7 @@ void initialize_repository_version(int hash_algo,\n {\n \tstruct strbuf repo_version = STRBUF_INIT;\n \tint target_version = GIT_REPO_VERSION;\n+\tint default_hook_stdout_to_stderr = 0;\n \n \t/*\n \t * Note that we initialize the repository version to 1 when the ref\n@@ -2348,6 +2349,15 @@ void initialize_repository_version(int hash_algo,\n \t\tclear_repository_format(&repo_fmt);\n \t}\n \n+\trepo_config_get_bool(the_repository, \"hook.forceStdoutToStderr\",\n+\t\t\t     &default_hook_stdout_to_stderr);\n+\tif (default_hook_stdout_to_stderr) {\n+\t\t/* extensions.hookstdouttostderr requires at least version 1 */\n+\t\tif (target_version == 0)\n+\t\t\ttarget_version = 1;\n+\t\trepo_config_set(the_repository, \"extensions.hookstdouttostderr\", \"true\");\n+\t}\n+\n \tstrbuf_addf(&repo_version, \"%d\", target_version);\n \trepo_config_set(the_repository, \"core.repositoryformatversion\", repo_version.buf);\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex bf19579f3a..db0f96f778 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -649,4 +649,78 @@ test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n \ttest $duration -lt 4\n '\n \n+test_expect_success '`git init` respects hook.forceStdoutToStderr' '\n+\ttest_when_finished \"rm -rf repo-init\" &&\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tgit init repo-init &&\n+\tgit -C repo-init config extensions.hookStdoutToStderr >actual &&\n+\techo true >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '`git init` does not set extensions.hookStdoutToStderr by default' '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\tgit init upstream &&\n+\ttest_must_fail git -C upstream config extensions.hookStdoutToStderr\n+'\n+\n+test_expect_success '`git clone` does not set extensions.hookStdoutToStderr by default' '\n+\ttest_when_finished \"rm -rf upstream repo-clone-no-ext\" &&\n+\tgit init upstream &&\n+\tgit clone upstream repo-clone-no-ext &&\n+\ttest_must_fail git -C repo-clone-no-ext config extensions.hookStdoutToStderr\n+'\n+\n+test_expect_success '`git clone` respects hook.forceStdoutToStderr' '\n+\ttest_when_finished \"rm -rf upstream repo-clone\" &&\n+\tgit init upstream &&\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tgit clone upstream repo-clone &&\n+\tgit -C repo-clone config extensions.hookStdoutToStderr >actual &&\n+\techo true >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'hook.forceStdoutToStderr enables extension for existing repos' '\n+\ttest_when_finished \"rm -rf remote-repo existing-repo\" &&\n+\tgit init --bare remote-repo &&\n+\tgit init -b main existing-repo &&\n+\t# No local extensions.hookStdoutToStderr config set here\n+\t# so global config should apply\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tcd existing-repo &&\n+\ttest_commit A &&\n+\tgit remote add origin ../remote-repo &&\n+\tsetup_hooks pre-push &&\n+\tgit push origin HEAD >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push &&\n+\tcd ..\n+'\n+\n+test_expect_success 'hook.forceStdoutToStderr enables pre-push parallel runs' '\n+\ttest_when_finished \"rm -rf repo-parallel\" &&\n+\tgit init --bare remote-parallel &&\n+\tgit init repo-parallel &&\n+\tgit -C repo-parallel remote add origin ../remote-parallel &&\n+\ttest_commit -C repo-parallel A &&\n+\n+\twrite_script repo-parallel/.git/hooks/pre-push <<-EOF &&\n+\tsleep 2\n+\techo \"Hook 1\" >&2\n+\tEOF\n+\tgit -C repo-parallel config hook.hook-2.event pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.command \"sleep 2; echo Hook 2 >&2\" &&\n+\n+\tgit -C repo-parallel config hook.jobs 2 &&\n+\tgit -C repo-parallel config hook.forceStdoutToStderr true &&\n+\n+\tstart=$(date +%s) &&\n+\tgit -C repo-parallel push origin HEAD >out 2>err &&\n+\tend=$(date +%s) &&\n+\n+\tduration=$((end - start)) &&\n+\t# Serial >= 4s, parallel < 4s.\n+\ttest $duration -lt 4\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535766","messageId":"aYx5B-nf4dlFpw3v@pks.im","threadId":"64919","inReplyTo":"20260204173328.1601807-3-adrian.ratiu@collabora.com","subject":"Re: [PATCH 2/4] hook: allow parallel hook execution","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-11T12:41:43Z","receivedAt":"2026-02-11T12:41:50Z","isPatch":true,"body":"On Wed, Feb 04, 2026 at 07:33:26PM +0200, Adrian Ratiu wrote:\n> From: Emily Shaffer <emilyshaffer@google.com>\n> \n> In many cases, there's no reason not to allow hooks to execute in\n> parallel, if more than one was provided.\n> \n> hook.c already calls run_processes_parallel() so all we need to do is\n> allow its job count to be greater than 1.\n> \n> Serial execution is achieved by setting .jobs == 1 at compile time via\n> RUN_HOOKS_OPT_INIT_SERIAL or by setting the 'hook.jobs' config to 1.\n> This matches the behavior prior to this commit.\n> \n> The compile-time 'struct run_hooks_opt.jobs' parameter has the highest\n> priority if non-zero, followed by the 'hook.jobs' user config, then the\n> processor count from online_cpus() is the last fallback.\n\nWait, the compile-time parameter overrides the user configuration? That\ndoesn't seem right to me.\n\nI'm also a bit sceptical whether we should really default to\n`online_cpus()`. If so, we start to assume semantics of the hooks\nthemselves, and that they cannot conflict with one another. But this is\nnothing we can really guarantee. It might be that multiple hooks want to\nmodify the same data structure, and if so running them in parallel would\nlead to races.\n\nSo I wonder whether we should rather make this behaviour opt-in than\nopt-out.\n\n> The above ordering ensures hooks unsafe to run in parallel are always\n> executed sequentially (RUN_HOOKS_OPT_INIT_SERIAL) while allowing users\n> to control parallelism with an efficient default.\n\nAh, okay, we only let the compile-time parameter override the config in\ncase we know that hooks must run in serial. That makes a bit more sense.\n\n> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n> index 49c7ffd82e..c394756328 100644\n> --- a/Documentation/config/hook.adoc\n> +++ b/Documentation/config/hook.adoc\n> @@ -15,3 +15,8 @@ hook.<name>.event::\n>  \tOn the specified event, the associated `hook.<name>.command` will be\n>  \texecuted. More than one event can be specified if you wish for\n>  \t`hook.<name>` to execute on multiple events. See linkgit:git-hook[1].\n> +\n> +hook.jobs::\n> +\tSpecifies how many hooks can be run simultaneously during parallelized\n> +\thook execution. If unspecified, defaults to the number of processors on\n> +\tthe current system.\n\nWe should probably note that some hooks will run sequentially regardless\nof this setting. Maybe we should even document which ones? I expect it's\nnot going to be that many.\n\n> diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\n> index 5f339dc48b..72c6c6d1ee 100644\n> --- a/Documentation/git-hook.adoc\n> +++ b/Documentation/git-hook.adoc\n> @@ -128,6 +129,16 @@ OPTIONS\n>  \ttools that want to do a blind one-shot run of a hook that may\n>  \tor may not be present.\n>  \n> +-j::\n> +--jobs::\n> +\tOnly valid for `run`.\n> ++\n> +Specify how many hooks to run simultaneously. If this flag is not specified,\n> +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If the\n> +config is not specified, the number of CPUs on the current system is used. Some\n> +hooks may be ineligible for parallelization: for example, 'commit-msg' hooks\n> +typically modify the commit message body and cannot be parallelized.\n\nYeah, this info is probably what I was searching for in the \"hook.jobs\"\ndescription.\n\n> diff --git a/builtin/hook.c b/builtin/hook.c\n> index 4cc6dac45a..cd1f4ebe6a 100644\n> --- a/builtin/hook.c\n> +++ b/builtin/hook.c\n> @@ -76,7 +77,7 @@ static int run(int argc, const char **argv, const char *prefix,\n>  \t       struct repository *repo UNUSED)\n>  {\n>  \tint i;\n> -\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> +\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n>  \tint ignore_missing = 0;\n>  \tconst char *hook_name;\n>  \tstruct option run_options[] = {\n\nHm. Assuming that the user executes `git hooks run prepare-commit-msg`\nwith \"--jobs=2\", should we really honor that request? We know that the\nhook cannot run in parallel, so we might want to refuse such requests.\n\nTaking a step back, I wonder whether it really is sensible to declare\ncomplete classes of hooks as parallelizable or non-parallelizable. We\nhave to assume semantics of the hook scripts themselves to be able to\nanswer whether or not they can be parallelizable. For some classes of\nhooks like \"prepare-commit-msg\" we can assume that it's almost never\ncorrect to serialize them. But for others we cannot assume anything.\n\nWhich makes me wonder whether the design here is really the right one.\nShouldn't we stop worrying about classes of hooks, but rather worry\nabout the user's intent? The user will know whether two hooks can run in\nparallel or not, so let them tell us that this is the case.\n\nI think this could be achieved via the configuration:\n\n    [hook \"my-parallelizable-hook-a\"]\n    path = /some/script-a.sh\n    parallel = true\n\n    [hook \"my-parallelizable-hook\"]\n    path = /some/script-b.sh\n    parallel = true\n\n    [hook \"serial-hook\"]\n    path = /some/script-c.sh\n    parallel = false\n\nThis would tell us that we can safely run two of the hooks in parallel,\nbut not the third one. So we'd then first execute all serial hooks in\nserial, and then in a second phase we'd execute the other hooks in\nparallel.\n\nSure, this puts more responsibility on the user. But I think this is a\nmore flexible approach as it also empowers the user and caters to more\nuse cases.\n\nPlease let me know what you think.\n\nThanks!\n\nPatrick\n"},{"id":"535856","messageId":"eedde9c3-2aff-433b-9dc3-f8593d53dec0@gmail.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"Re: [PATCH 0/4] Run hooks in parallel","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-02-12T10:43:55Z","receivedAt":"2026-02-12T10:44:00Z","isPatch":true,"body":"Hi Adrian\n\nOn 04/02/2026 17:33, Adrian Ratiu wrote:\n> Hello everyone,\n> \n> This enables running hook commands in parallel and is based on the patch\n> series enabling config hooks [1], which added the ability to run a list\n> of hooks for each hook event.\n> \n> For context, hooks used to run sequentially due to hardcoded .jobs == 1\n> in hook.c, leading to .processes == 1 in run-command.c. We're removing\n> that restriction for hooks known to be safe to parallelize.\n> \n> The parallelism enabled here is to run multiple hook commands/scripts\n> in parallel for a single event, for example the pre-push event might\n> trigger linters / spell checkers / unit tests to run at the same time.\n> \n> Another kind of parallelism is to split the hook input to multiple\n> child processes, running the same command in parallel on subsets of\n> the hook input. This series does not do that. It might be a future\n> addition on top of this, since it's kind of a lower-level parallelism.\n\nThere's quite a lot of prior-art on parallelization from the various \nhook managers - is there anything we can learn from them? For example I \nknow some of them serialize the pre-commit hook by default as it may \nupdate the index but allow the user to configure a subset of scripts \nthat can be parallelized. They also allow for parallelization where \ndifferent scripts update different files (e.g. code formatters for \npython and C can run in parallel). We don't need to implement all that \nnow but we should design our config so that we can support it in the future.\n\n> The pre-push hook is special because it is the only known hook to break\n> backward compatibility when running in parallel, due to run-command\n> collating its outputs via a pipe, so I added an extension for it.\n> Users can opt-in to this extension with a runtime config.\n\nIn the past we had a regression report [1] when the pre-commit hook \nstopped having access to the terminal. I've not been following the hook \nchanges, is this series (or any of your preparatory series) in danger of \nreintroducing that regression?\n\nThanks for working on this - both config based hooks and parallel \nexecution are really nice improvements.\n\nPhillip\n\n[1] https://lore.kernel.org/git/xmqqr15rr9k6.fsf@gitster.g/\n\n> Suggestions for alternative solutions to the extension are welcome.\n> \n> Again, this is based on the latest v1 config hooks series [1] which\n> has not yet landed in next or master.\n> \n> Branch pushed to GitHub containing all dependency patches: [2]\n> Successful CI run: [3]\n> \n> Many thanks to all who contributed to this effort up to now, including\n> Emily, AEvar, Junio, Patrick, Peff and many others.\n> \n> Thank you,\n> Adrian\n> \n> 1: https://lore.kernel.org/git/20260204165126.1548805-1-adrian.ratiu@collabora.com/T/#mdb138a39d332f234bc9068b7f4e05b10c400e572\n> 2: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/parallel-hooks-v1\n> 3: https://github.com/10ne1/git/actions/runs/21680184456\n> \n> Adrian Ratiu (3):\n>    config: add a repo_config_get_uint() helper\n>    hook: introduce extensions.hookStdoutToStderr\n>    hook: allow runtime enabling extensions.hookStdoutToStderr\n> \n> Emily Shaffer (1):\n>    hook: allow parallel hook execution\n> \n>   Documentation/config/extensions.adoc |  15 ++\n>   Documentation/config/hook.adoc       |  14 ++\n>   Documentation/git-hook.adoc          |  14 +-\n>   builtin/am.c                         |  10 +-\n>   builtin/checkout.c                   |  13 +-\n>   builtin/clone.c                      |   6 +-\n>   builtin/hook.c                       |   7 +-\n>   builtin/receive-pack.c               |   9 +-\n>   builtin/worktree.c                   |   2 +-\n>   commit.c                             |   2 +-\n>   config.c                             |  28 +++\n>   config.h                             |  13 ++\n>   hook.c                               |  51 ++++-\n>   hook.h                               |  20 +-\n>   parse.c                              |   9 +\n>   parse.h                              |   1 +\n>   refs.c                               |   2 +-\n>   repository.c                         |   1 +\n>   repository.h                         |   1 +\n>   sequencer.c                          |   4 +-\n>   setup.c                              |  17 ++\n>   setup.h                              |   1 +\n>   t/t1800-hook.sh                      | 270 ++++++++++++++++++++++++++-\n>   transport.c                          |   9 +-\n>   24 files changed, 476 insertions(+), 43 deletions(-)\n> \n\n"},{"id":"535858","messageId":"87ms1ep2rq.fsf@gentoo.mail-host-address-is-not-set","threadId":"64919","inReplyTo":"aYx5B-nf4dlFpw3v@pks.im","subject":"Re: [PATCH 2/4] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-12T12:25:29Z","receivedAt":"2026-02-12T12:25:53Z","isPatch":true,"body":"On Wed, 11 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Wed, Feb 04, 2026 at 07:33:26PM +0200, Adrian Ratiu wrote:\n>> From: Emily Shaffer <emilyshaffer@google.com>\n>> \n>> In many cases, there's no reason not to allow hooks to execute in\n>> parallel, if more than one was provided.\n>> \n>> hook.c already calls run_processes_parallel() so all we need to do is\n>> allow its job count to be greater than 1.\n>> \n>> Serial execution is achieved by setting .jobs == 1 at compile time via\n>> RUN_HOOKS_OPT_INIT_SERIAL or by setting the 'hook.jobs' config to 1.\n>> This matches the behavior prior to this commit.\n>> \n>> The compile-time 'struct run_hooks_opt.jobs' parameter has the highest\n>> priority if non-zero, followed by the 'hook.jobs' user config, then the\n>> processor count from online_cpus() is the last fallback.\n>\n> Wait, the compile-time parameter overrides the user configuration? That\n> doesn't seem right to me.\n\nYes, the rationale for doing this is that some hooks are never safe to\nparallelize, so if RUN_HOOKS_OPT_INIT_SERIAL is set at compile-time,\nthen it has the highest prefference, for safety for these hooks and the\nuser cannot override them.\n\nHooks initialized with RUN_HOOKS_OPT_INIT_PARALLEL are known to be safe\nto parallelize, so the user can also overwrite the jobs number.\n\nMaybe we need better names (or code?) to express this?\n\nI am certainly open to suggestions.\n\n>\n> I'm also a bit sceptical whether we should really default to\n> `online_cpus()`. If so, we start to assume semantics of the hooks\n> themselves, and that they cannot conflict with one another. But this is\n> nothing we can really guarantee. It might be that multiple hooks want to\n> modify the same data structure, and if so running them in parallel would\n> lead to races.\n>\n> So I wonder whether we should rather make this behaviour opt-in than\n> opt-out.\n\nWe certainly can. There is nothing set in stone here and we can even\nkeep the previous default to jobs == 1 for all hooks.\n\n>> The above ordering ensures hooks unsafe to run in parallel are always\n>> executed sequentially (RUN_HOOKS_OPT_INIT_SERIAL) while allowing users\n>> to control parallelism with an efficient default.\n>\n> Ah, okay, we only let the compile-time parameter override the config in\n> case we know that hooks must run in serial. That makes a bit more sense.\n\nYes, that is the idea: some hooks must never run in parallel.\n\nOf course, if we can find better ways to express this, I'm all ears. :)\n\n>> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n>> index 49c7ffd82e..c394756328 100644\n>> --- a/Documentation/config/hook.adoc\n>> +++ b/Documentation/config/hook.adoc\n>> @@ -15,3 +15,8 @@ hook.<name>.event::\n>>  \tOn the specified event, the associated `hook.<name>.command` will be\n>>  \texecuted. More than one event can be specified if you wish for\n>>  \t`hook.<name>` to execute on multiple events. See linkgit:git-hook[1].\n>> +\n>> +hook.jobs::\n>> +\tSpecifies how many hooks can be run simultaneously during parallelized\n>> +\thook execution. If unspecified, defaults to the number of processors on\n>> +\tthe current system.\n>\n> We should probably note that some hooks will run sequentially regardless\n> of this setting. Maybe we should even document which ones? I expect it's\n> not going to be that many.\n\nAck, will do in v2.\n\n>> diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\n>> index 5f339dc48b..72c6c6d1ee 100644\n>> --- a/Documentation/git-hook.adoc\n>> +++ b/Documentation/git-hook.adoc\n>> @@ -128,6 +129,16 @@ OPTIONS\n>>  \ttools that want to do a blind one-shot run of a hook that may\n>>  \tor may not be present.\n>>  \n>> +-j::\n>> +--jobs::\n>> +\tOnly valid for `run`.\n>> ++\n>> +Specify how many hooks to run simultaneously. If this flag is not specified,\n>> +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If the\n>> +config is not specified, the number of CPUs on the current system is used. Some\n>> +hooks may be ineligible for parallelization: for example, 'commit-msg' hooks\n>> +typically modify the commit message body and cannot be parallelized.\n>\n> Yeah, this info is probably what I was searching for in the \"hook.jobs\"\n> description.\n\nYes, I'll try to make this clearer.\n\n>> diff --git a/builtin/hook.c b/builtin/hook.c\n>> index 4cc6dac45a..cd1f4ebe6a 100644\n>> --- a/builtin/hook.c\n>> +++ b/builtin/hook.c\n>> @@ -76,7 +77,7 @@ static int run(int argc, const char **argv, const char *prefix,\n>>  \t       struct repository *repo UNUSED)\n>>  {\n>>  \tint i;\n>> -\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n>> +\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL;\n>>  \tint ignore_missing = 0;\n>>  \tconst char *hook_name;\n>>  \tstruct option run_options[] = {\n>\n> Hm. Assuming that the user executes `git hooks run prepare-commit-msg`\n> with \"--jobs=2\", should we really honor that request? We know that the\n> hook cannot run in parallel, so we might want to refuse such requests.\n>\n> Taking a step back, I wonder whether it really is sensible to declare\n> complete classes of hooks as parallelizable or non-parallelizable. We\n> have to assume semantics of the hook scripts themselves to be able to\n> answer whether or not they can be parallelizable. For some classes of\n> hooks like \"prepare-commit-msg\" we can assume that it's almost never\n> correct to serialize them. But for others we cannot assume anything.\n>\n> Which makes me wonder whether the design here is really the right one.\n> Shouldn't we stop worrying about classes of hooks, but rather worry\n> about the user's intent? The user will know whether two hooks can run in\n> parallel or not, so let them tell us that this is the case.\n>\n> I think this could be achieved via the configuration:\n>\n>     [hook \"my-parallelizable-hook-a\"]\n>     path = /some/script-a.sh\n>     parallel = true\n>\n>     [hook \"my-parallelizable-hook\"]\n>     path = /some/script-b.sh\n>     parallel = true\n>\n>     [hook \"serial-hook\"]\n>     path = /some/script-c.sh\n>     parallel = false\n>\n> This would tell us that we can safely run two of the hooks in parallel,\n> but not the third one. So we'd then first execute all serial hooks in\n> serial, and then in a second phase we'd execute the other hooks in\n> parallel.\n>\n> Sure, this puts more responsibility on the user. But I think this is a\n> more flexible approach as it also empowers the user and caters to more\n> use cases.\n>\n> Please let me know what you think.\n\nThose are really good points, thanks.\n\nWe can certainly keep jobs == 1 as a weak default for all hooks, which\ncan be overriden by users, then leave it up to the user to decide what\nis safe or unsafe.\n\nHowever, there are trade-offs...\n\n1. As you said this puts more responsability on the user to know what to\nenable (i.e. don't push the wrong button).\n\n2. It forces manual user intervention if parallelism is desired.\n\n3. We can't have a safe global default like a runtime config because we\nclearly can't eneable all parallelism and would end up keeping a list of\nsafe-vs-unsafe hooks anyway if we decide to add a config like\n\"parallelize-everything-which-can-be-parallelized\". :)\n\nThe current design was thought to provide a safe default with minimal\nuser intervention.\n\nMaybe we can find some middle ground between the two \"extremes\"\n(deciding everything for the user vs pushing all decisions to the user)?\n\nMaybe keep the compile-time safe/unsafe hook list, default all hooks to\n.jobs == 1 (preserve existing behavior) and allow the users to enable\nparallelism individually or, via a runtime config, all known to be safe?\n"},{"id":"535866","messageId":"87jywiox9f.fsf@collabora.com","threadId":"64919","inReplyTo":"eedde9c3-2aff-433b-9dc3-f8593d53dec0@gmail.com","subject":"Re: [PATCH 0/4] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-12T14:24:28Z","receivedAt":"2026-02-12T14:25:01Z","isPatch":true,"body":"On Thu, 12 Feb 2026, Phillip Wood <phillip.wood123@gmail.com> wrote:\n> Hi Adrian\n>\n> On 04/02/2026 17:33, Adrian Ratiu wrote:\n>> Hello everyone,\n>> \n>> This enables running hook commands in parallel and is based on the patch\n>> series enabling config hooks [1], which added the ability to run a list\n>> of hooks for each hook event.\n>> \n>> For context, hooks used to run sequentially due to hardcoded .jobs == 1\n>> in hook.c, leading to .processes == 1 in run-command.c. We're removing\n>> that restriction for hooks known to be safe to parallelize.\n>> \n>> The parallelism enabled here is to run multiple hook commands/scripts\n>> in parallel for a single event, for example the pre-push event might\n>> trigger linters / spell checkers / unit tests to run at the same time.\n>> \n>> Another kind of parallelism is to split the hook input to multiple\n>> child processes, running the same command in parallel on subsets of\n>> the hook input. This series does not do that. It might be a future\n>> addition on top of this, since it's kind of a lower-level parallelism.\n>\n> There's quite a lot of prior-art on parallelization from the various \n> hook managers - is there anything we can learn from them? For example I \n> know some of them serialize the pre-commit hook by default as it may \n> update the index but allow the user to configure a subset of scripts \n> that can be parallelized. They also allow for parallelization where \n> different scripts update different files (e.g. code formatters for \n> python and C can run in parallel). We don't need to implement all that \n> now but we should design our config so that we can support it in the future.\n\nYes, all the prior-art is very useful and it is possible to do\nfiner-grained (or lower-level? :-) ) parallelism further with the\nnew run-command parallelization design, APIs and config.\n\nObviously that will require more work and doing careful analysis on each\nhook-by-hook case. I'm just adding the basic buliding blocks and\nenabling the \"highest-level\" (most... independent?... level between\ntasks) of parallelism here.\n\nMy approach to this big problem was to simplify and break it down into\nsmaller / easier-to-manage chunks. I'm still splitting up commits and\nuntangling logic to ensure each part is done properly (also easier to\nreview) and can work independently (no regresions etc) before building\non top of it.\n\nThank you, and everyone else, so much for all the help and patience.\n\n>> The pre-push hook is special because it is the only known hook to break\n>> backward compatibility when running in parallel, due to run-command\n>> collating its outputs via a pipe, so I added an extension for it.\n>> Users can opt-in to this extension with a runtime config.\n>\n> In the past we had a regression report [1] when the pre-commit hook \n> stopped having access to the terminal. I've not been following the hook \n> changes, is this series (or any of your preparatory series) in danger of \n> reintroducing that regression?\n\nThank you for raising this, it is a very valuable data point!\n\nThe preparatory series 100% will not reintroduce it.\n\nThis series might reintroduce it, depending how we set the defaults.\n\nBy that I mean:\n    -j1 will keep all hooks connected to the tty, just like before.\n    -jN with N>1 will disconnect the hooks from the tty and their\n        outputs will get buffered through run-command's pipes.\n\nThe design I followed (which to be transparent is Peff's design, I just\nimplemented his ideas :), is the keep the original, serialized behavior\nexactly how it was before, to not introduce any regressions and to also\nkeep identical \"real-time\" performance.\n\nTaking this into account, together with Patrick's feedback on this\nseries, I do intend keep the jobs == 1 default for all hooks in v2.\n\n>\n> Thanks for working on this - both config based hooks and parallel \n> execution are really nice improvements.\n\nThank you for the kind words. :)\n"},{"id":"535933","messageId":"1dd09e04-dbdb-4a6c-933a-e4dc451878f9@gmail.com","threadId":"64919","inReplyTo":"87jywiox9f.fsf@collabora.com","subject":"Re: [PATCH 0/4] Run hooks in parallel","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-02-13T14:39:48Z","receivedAt":"2026-02-13T14:39:52Z","isPatch":true,"body":"On 12/02/2026 14:24, Adrian Ratiu wrote:\n> On Thu, 12 Feb 2026, Phillip Wood <phillip.wood123@gmail.com> wrote:\n>> Hi Adrian\n>>\n>> On 04/02/2026 17:33, Adrian Ratiu wrote:\n>>> Hello everyone,\n>>>\n>>> This enables running hook commands in parallel and is based on the patch\n>>> series enabling config hooks [1], which added the ability to run a list\n>>> of hooks for each hook event.\n>>>\n>>> For context, hooks used to run sequentially due to hardcoded .jobs == 1\n>>> in hook.c, leading to .processes == 1 in run-command.c. We're removing\n>>> that restriction for hooks known to be safe to parallelize.\n>>>\n>>> The parallelism enabled here is to run multiple hook commands/scripts\n>>> in parallel for a single event, for example the pre-push event might\n>>> trigger linters / spell checkers / unit tests to run at the same time.\n>>>\n>>> Another kind of parallelism is to split the hook input to multiple\n>>> child processes, running the same command in parallel on subsets of\n>>> the hook input. This series does not do that. It might be a future\n>>> addition on top of this, since it's kind of a lower-level parallelism.\n>>\n>> There's quite a lot of prior-art on parallelization from the various\n>> hook managers - is there anything we can learn from them? For example I\n>> know some of them serialize the pre-commit hook by default as it may\n>> update the index but allow the user to configure a subset of scripts\n>> that can be parallelized. They also allow for parallelization where\n>> different scripts update different files (e.g. code formatters for\n>> python and C can run in parallel). We don't need to implement all that\n>> now but we should design our config so that we can support it in the future.\n> \n> Yes, all the prior-art is very useful and it is possible to do\n> finer-grained (or lower-level? :-) ) parallelism further with the\n> new run-command parallelization design, APIs and config.\n> \n> Obviously that will require more work and doing careful analysis on each\n> hook-by-hook case. I'm just adding the basic buliding blocks and\n> enabling the \"highest-level\" (most... independent?... level between\n> tasks) of parallelism here.\n> \n> My approach to this big problem was to simplify and break it down into\n> smaller / easier-to-manage chunks. I'm still splitting up commits and\n> untangling logic to ensure each part is done properly (also easier to\n> review) and can work independently (no regresions etc) before building\n> on top of it.\n\nThat sounds sensible and should make it easier to review, so long as we \ndesign the configuration in a way that it can be extended as we add more \nfeatures.\n\n> Thank you, and everyone else, so much for all the help and patience.\n> \n>>> The pre-push hook is special because it is the only known hook to break\n>>> backward compatibility when running in parallel, due to run-command\n>>> collating its outputs via a pipe, so I added an extension for it.\n>>> Users can opt-in to this extension with a runtime config.\n>>\n>> In the past we had a regression report [1] when the pre-commit hook\n>> stopped having access to the terminal. I've not been following the hook\n>> changes, is this series (or any of your preparatory series) in danger of\n>> reintroducing that regression?\n> \n> Thank you for raising this, it is a very valuable data point!\n> \n> The preparatory series 100% will not reintroduce it.\n> \n> This series might reintroduce it, depending how we set the defaults.\n> \n> By that I mean:\n>      -j1 will keep all hooks connected to the tty, just like before.\n>      -jN with N>1 will disconnect the hooks from the tty and their\n>          outputs will get buffered through run-command's pipes.\n> \n> The design I followed (which to be transparent is Peff's design, I just\n> implemented his ideas :), is the keep the original, serialized behavior\n> exactly how it was before, to not introduce any regressions and to also\n> keep identical \"real-time\" performance.\n> \n> Taking this into account, together with Patrick's feedback on this\n> series, I do intend keep the jobs == 1 default for all hooks in v2.\n\nI think that would be safer. If we could opt-in to parallel execution on \na per-hook basis would that be a solution for the \"pre-push\" hook? Users \nwho want to keep the current behavior would avoid configuring parallel \nexecution for that hook.\n\nThanks\n\nPhillip\n\n>>\n>> Thanks for working on this - both config based hooks and parallel\n>> execution are really nice improvements.\n> \n> Thank you for the kind words. :)\n\n"},{"id":"535948","messageId":"87h5rkpnk1.fsf@collabora.com","threadId":"64919","inReplyTo":"1dd09e04-dbdb-4a6c-933a-e4dc451878f9@gmail.com","subject":"Re: [PATCH 0/4] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-13T17:21:02Z","receivedAt":"2026-02-13T17:21:36Z","isPatch":true,"body":"On Fri, 13 Feb 2026, Phillip Wood <phillip.wood123@gmail.com> wrote:\n<snip>\n>>>> The pre-push hook is special because it is the only known hook to break\n>>>> backward compatibility when running in parallel, due to run-command\n>>>> collating its outputs via a pipe, so I added an extension for it.\n>>>> Users can opt-in to this extension with a runtime config.\n>>>\n>>> In the past we had a regression report [1] when the pre-commit hook\n>>> stopped having access to the terminal. I've not been following the hook\n>>> changes, is this series (or any of your preparatory series) in danger of\n>>> reintroducing that regression?\n>> \n>> Thank you for raising this, it is a very valuable data point!\n>> \n>> The preparatory series 100% will not reintroduce it.\n>> \n>> This series might reintroduce it, depending how we set the defaults.\n>> \n>> By that I mean:\n>>      -j1 will keep all hooks connected to the tty, just like before.\n>>      -jN with N>1 will disconnect the hooks from the tty and their\n>>          outputs will get buffered through run-command's pipes.\n>> \n>> The design I followed (which to be transparent is Peff's design, I just\n>> implemented his ideas :), is the keep the original, serialized behavior\n>> exactly how it was before, to not introduce any regressions and to also\n>> keep identical \"real-time\" performance.\n>> \n>> Taking this into account, together with Patrick's feedback on this\n>> series, I do intend keep the jobs == 1 default for all hooks in v2.\n>\n> I think that would be safer. If we could opt-in to parallel execution on \n> a per-hook basis would that be a solution for the \"pre-push\" hook? Users \n> who want to keep the current behavior would avoid configuring parallel \n> execution for that hook.\n\nYes, opting in on a per-hook basis is one of the mechanisms I'll be\nadding in v2 (that's what I've been discussing with Patrick via the\nother thread on this series).\n\nHowever we also need a mechanism to enable more than just 1 hook at a\ntime, for users who want to enable by default a known-good set of hooks\nto run in parallel.\n\nThat's what we did with `RUN_HOOKS_OPT_INIT_PARALLEL` at compile-time in\nv1, however that's a dead end and I won't pursue it.\n\nFor v2 I'm thinking of a runtime/global config which can specify a list\nof hook to default for parallel execution. That should be enough to\nreplace RUN_HOOKS_OPT_INIT_PARALLEL.\n\nSuggestions welcome, as always. :)\n"},{"id":"536612","messageId":"20260222002904.1879356-3-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 02/10] config: add a repo_config_get_uint() helper","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:28:56Z","receivedAt":"2026-02-22T00:29:42Z","isPatch":true,"body":"Next commits add a 'hook.jobs' config option of type 'unsigned int',\nso add a helper to parse it since the API only supports int and ulong.\n\nAn alternative is to make 'hook.jobs' an 'int' or parse it as an 'int'\nthen cast it to unsigned, however it's better to use proper helpers for\nthe type. Using 'ulong' is another option which already has helpers, but\nit's a bit excessive in size for just the jobs number.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n config.c | 28 ++++++++++++++++++++++++++++\n config.h | 13 +++++++++++++\n parse.c  |  9 +++++++++\n parse.h  |  1 +\n 4 files changed, 51 insertions(+)\n\ndiff --git a/config.c b/config.c\nindex 156f2a24fa..a1b92fe083 100644\n--- a/config.c\n+++ b/config.c\n@@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value,\n \treturn ret;\n }\n \n+unsigned int git_config_uint(const char *name, const char *value,\n+\t\t\t     const struct key_value_info *kvi)\n+{\n+\tunsigned int ret;\n+\tif (!git_parse_uint(value, &ret))\n+\t\tdie_bad_number(name, value, kvi);\n+\treturn ret;\n+}\n+\n int64_t git_config_int64(const char *name, const char *value,\n \t\t\t const struct key_value_info *kvi)\n {\n@@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest)\n \t\treturn 1;\n }\n \n+int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest)\n+{\n+\tconst char *value;\n+\tstruct key_value_info kvi;\n+\n+\tif (!git_configset_get_value(set, key, &value, &kvi)) {\n+\t\t*dest = git_config_uint(key, value, &kvi);\n+\t\treturn 0;\n+\t} else\n+\t\treturn 1;\n+}\n+\n int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest)\n {\n \tconst char *value;\n@@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo,\n \treturn git_configset_get_int(repo->config, key, dest);\n }\n \n+int repo_config_get_uint(struct repository *repo,\n+\t\t\t const char *key, unsigned int *dest)\n+{\n+\tgit_config_check_init(repo);\n+\treturn git_configset_get_uint(repo->config, key, dest);\n+}\n+\n int repo_config_get_ulong(struct repository *repo,\n \t\t\t  const char *key, unsigned long *dest)\n {\ndiff --git a/config.h b/config.h\nindex ba426a960a..bf47fb3afc 100644\n--- a/config.h\n+++ b/config.h\n@@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *);\n int64_t git_config_int64(const char *, const char *,\n \t\t\t const struct key_value_info *);\n \n+/**\n+ * Identical to `git_config_int`, but for unsigned ints.\n+ */\n+unsigned int git_config_uint(const char *, const char *,\n+\t\t\t     const struct key_value_info *);\n+\n /**\n  * Identical to `git_config_int`, but for unsigned longs.\n  */\n@@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key,\n \n int git_configset_get_string(struct config_set *cs, const char *key, char **dest);\n int git_configset_get_int(struct config_set *cs, const char *key, int *dest);\n+int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest);\n int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest);\n int git_configset_get_bool(struct config_set *cs, const char *key, int *dest);\n int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest);\n@@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r,\n  */\n int repo_config_get_int(struct repository *r, const char *key, int *dest);\n \n+/**\n+ * Similar to `repo_config_get_int` but for unsigned ints.\n+ */\n+int repo_config_get_uint(struct repository *r,\n+\t\t\t const char *key, unsigned int *dest);\n+\n /**\n  * Similar to `repo_config_get_int` but for unsigned longs.\n  */\ndiff --git a/parse.c b/parse.c\nindex 48313571aa..d77f28046a 100644\n--- a/parse.c\n+++ b/parse.c\n@@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret)\n \treturn 1;\n }\n \n+int git_parse_uint(const char *value, unsigned int *ret)\n+{\n+\tuintmax_t tmp;\n+\tif (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int)))\n+\t\treturn 0;\n+\t*ret = tmp;\n+\treturn 1;\n+}\n+\n int git_parse_ulong(const char *value, unsigned long *ret)\n {\n \tuintmax_t tmp;\ndiff --git a/parse.h b/parse.h\nindex ea32de9a91..a6dd37c4cb 100644\n--- a/parse.h\n+++ b/parse.h\n@@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max);\n int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max);\n int git_parse_ssize_t(const char *, ssize_t *);\n int git_parse_ulong(const char *, unsigned long *);\n+int git_parse_uint(const char *value, unsigned int *ret);\n int git_parse_int(const char *value, int *ret);\n int git_parse_int64(const char *value, int64_t *ret);\n int git_parse_double(const char *value, double *ret);\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536613","messageId":"20260222002904.1879356-4-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 03/10] hook: refactor hook_config_cache from strmap to named struct","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:28:57Z","receivedAt":"2026-02-22T00:29:45Z","isPatch":true,"body":"Replace the raw `struct strmap *hook_config_cache` in `struct repository`\nwith a `struct hook_config_cache` which wraps the strmap in a named field.\n\nReplace the bare `char *command` util pointer stored in each string_list\nitem with a heap-allocated `struct hook_config_cache_entry` that carries\nthat command string.\n\nThis is just a refactoring with no behavior changes, to give the cache\nstruct room to grow so it can carry the additional hook metadata we'll\nbe adding in the following commits.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n hook.c       | 65 +++++++++++++++++++++++++++++++++-------------------\n hook.h       | 10 +++++++-\n repository.h |  3 ++-\n 3 files changed, 52 insertions(+), 26 deletions(-)\n\ndiff --git a/hook.c b/hook.c\nindex 2c8252b2c4..89b9948512 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -118,6 +118,15 @@ static void unsorted_string_list_remove(struct string_list *list,\n \t\tunsorted_string_list_delete_item(list, item - list->items, 0);\n }\n \n+/*\n+ * Cache entry stored as the .util pointer of string_list items inside the\n+ * hook config cache. For now carries only the command for the hook. Next\n+ * commits will add more data.\n+ */\n+struct hook_config_cache_entry {\n+\tchar *command;\n+};\n+\n /*\n  * Callback struct to collect all hook.* keys in a single config pass.\n  * commands: friendly-name to command map.\n@@ -205,21 +214,27 @@ static int hook_config_lookup_all(const char *key, const char *value,\n  * Disabled hooks and hooks missing a command are already filtered out at\n  * parse time, so callers can iterate the list directly.\n  */\n-void hook_cache_clear(struct strmap *cache)\n+void hook_cache_clear(struct hook_config_cache *cache)\n {\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *e;\n \n-\tstrmap_for_each_entry(cache, &iter, e) {\n+\tstrmap_for_each_entry(&cache->hooks, &iter, e) {\n \t\tstruct string_list *hooks = e->value;\n-\t\tstring_list_clear(hooks, 1); /* free util (command) pointers */\n+\t\tfor (size_t i = 0; i < hooks->nr; i++) {\n+\t\t\tstruct hook_config_cache_entry *entry = hooks->items[i].util;\n+\t\t\tfree(entry->command);\n+\t\t\tfree(entry);\n+\t\t}\n+\t\tstring_list_clear(hooks, 0);\n \t\tfree(hooks);\n \t}\n-\tstrmap_clear(cache, 0);\n+\tstrmap_clear(&cache->hooks, 0);\n }\n \n /* Populate `cache` with the complete hook configuration */\n-static void build_hook_config_map(struct repository *r, struct strmap *cache)\n+static void build_hook_config_map(struct repository *r,\n+\t\t\t\t  struct hook_config_cache *cache)\n {\n \tstruct hook_all_config_cb cb_data;\n \tstruct hashmap_iter iter;\n@@ -241,6 +256,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \n \t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n \t\t\tconst char *hname = hook_names->items[i].string;\n+\t\t\tstruct hook_config_cache_entry *entry;\n \t\t\tchar *command;\n \n \t\t\t/* filter out disabled hooks */\n@@ -254,12 +270,13 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\t\t      \"'hook.%s.event' must be removed;\"\n \t\t\t\t      \" aborting.\"), hname, hname);\n \n-\t\t\t/* util stores the command; owned by the cache. */\n-\t\t\tstring_list_append(hooks, hname)->util =\n-\t\t\t\txstrdup(command);\n+\t\t\t/* util stores a cache entry; owned by the cache. */\n+\t\t\tCALLOC_ARRAY(entry, 1);\n+\t\t\tentry->command = xstrdup(command);\n+\t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n-\t\tstrmap_put(cache, e->key, hooks);\n+\t\tstrmap_put(&cache->hooks, e->key, hooks);\n \t}\n \n \tstrmap_clear(&cb_data.commands, 1);\n@@ -272,35 +289,35 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n }\n \n /*\n- * Return the hook config map for `r`, populating it first if needed.\n+ * Return the hook config cache for `r`, populating it first if needed.\n  *\n  * Out-of-repo calls (r->gitdir == NULL) allocate and return a temporary\n- * cache map; the caller is responsible for freeing it with\n+ * cache; the caller is responsible for freeing it with\n  * hook_cache_clear() + free().\n  */\n-static struct strmap *get_hook_config_cache(struct repository *r)\n+static struct hook_config_cache *get_hook_config_cache(struct repository *r)\n {\n-\tstruct strmap *cache = NULL;\n+\tstruct hook_config_cache *cache = NULL;\n \n \tif (r && r->gitdir) {\n \t\t/*\n-\t\t * For in-repo calls, the map is stored in r->hook_config_cache,\n-\t\t * so repeated invocations don't parse the configs, so allocate\n+\t\t * For in-repo calls, the cache is stored in r->hook_config_cache,\n+\t\t * so repeated invocations don't parse the configs; allocate\n \t\t * it just once on the first call.\n \t\t */\n \t\tif (!r->hook_config_cache) {\n-\t\t\tr->hook_config_cache = xcalloc(1, sizeof(*cache));\n-\t\t\tstrmap_init(r->hook_config_cache);\n+\t\t\tCALLOC_ARRAY(r->hook_config_cache, 1);\n+\t\t\tstrmap_init(&r->hook_config_cache->hooks);\n \t\t\tbuild_hook_config_map(r, r->hook_config_cache);\n \t\t}\n \t\tcache = r->hook_config_cache;\n \t} else {\n \t\t/*\n \t\t * Out-of-repo calls (no gitdir) allocate and return a temporary\n-\t\t * map cache which gets free'd immediately by the caller.\n+\t\t * cache which gets freed immediately by the caller.\n \t\t */\n-\t\tcache = xcalloc(1, sizeof(*cache));\n-\t\tstrmap_init(cache);\n+\t\tCALLOC_ARRAY(cache, 1);\n+\t\tstrmap_init(&cache->hooks);\n \t\tbuild_hook_config_map(r, cache);\n \t}\n \n@@ -312,13 +329,13 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\t\t      struct string_list *list,\n \t\t\t\t      struct run_hooks_opt *options)\n {\n-\tstruct strmap *cache = get_hook_config_cache(r);\n-\tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n+\tstruct hook_config_cache *cache = get_hook_config_cache(r);\n+\tstruct string_list *configured_hooks = strmap_get(&cache->hooks, hookname);\n \n \t/* Iterate through configured hooks and initialize internal states */\n \tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n \t\tconst char *friendly_name = configured_hooks->items[i].string;\n-\t\tconst char *command = configured_hooks->items[i].util;\n+\t\tstruct hook_config_cache_entry *entry = configured_hooks->items[i].util;\n \t\tstruct hook *hook = xcalloc(1, sizeof(struct hook));\n \n \t\tif (options && options->feed_pipe_cb_data_alloc)\n@@ -328,7 +345,7 @@ static void list_hooks_add_configured(struct repository *r,\n \n \t\thook->kind = HOOK_CONFIGURED;\n \t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n-\t\thook->u.configured.command = xstrdup(command);\n+\t\thook->u.configured.command = xstrdup(entry->command);\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\ndiff --git a/hook.h b/hook.h\nindex e949f5d488..994f15522d 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -191,11 +191,19 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,\n  */\n void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n \n+/**\n+ * Persistent cache for hook configuration, stored on `struct repository`.\n+ * Populated lazily on first hook use and freed by repo_clear().\n+ */\n+struct hook_config_cache {\n+\tstruct strmap hooks; /* maps event name -> string_list of hooks */\n+};\n+\n /**\n  * Frees the hook configuration cache stored in `struct repository`.\n  * Called by repo_clear().\n  */\n-void hook_cache_clear(struct strmap *cache);\n+void hook_cache_clear(struct hook_config_cache *cache);\n \n /**\n  * Returns the path to the hook file, or NULL if the hook is missing\ndiff --git a/repository.h b/repository.h\nindex 25b2801228..2105768b8c 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -11,6 +11,7 @@ struct lock_file;\n struct pathspec;\n struct object_database;\n struct submodule_cache;\n+struct hook_config_cache;\n struct promisor_remote_config;\n struct remote_state;\n \n@@ -161,7 +162,7 @@ struct repository {\n \t * Lazily-populated cache mapping hook event names to configured hooks.\n \t * NULL until first hook use.\n \t */\n-\tstruct strmap *hook_config_cache;\n+\tstruct hook_config_cache *hook_config_cache;\n \n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536614","messageId":"20260222002904.1879356-5-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 04/10] hook: parse the hook.jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:28:58Z","receivedAt":"2026-02-22T00:29:46Z","isPatch":true,"body":"The hook.jobs config is a global way to set hook parallelization for\nall hooks, in the sense that it is not per-event nor per-hook.\n\nFiner-grained configs will be added in later commits which can override\nit, for e.g. via a per-event type job options. Next commits will also\nadd to this item's documentation.\n\nParse hook.jobs config key in hook_config_lookup_all() and store its\nvalue in hook_all_config_cb.jobs, then transfer it into\nhook_config_cache.jobs after the config pass completes.\n\nThis is mostly plumbing and the cached value is not yet used.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 22 ++++++++++++++++++++--\n hook.h                         |  1 +\n 3 files changed, 25 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 64e845a260..c617261c57 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -22,3 +22,7 @@ hook.<name>.enabled::\n \tconfiguration. This is particularly useful when a hook is defined\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n+\n+hook.jobs::\n+\tSpecifies how many hooks can be run simultaneously during parallelized\n+\thook execution. If unspecified, defaults to 1 (serial execution).\ndiff --git a/hook.c b/hook.c\nindex 89b9948512..f4213f5878 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -132,11 +132,13 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n+ * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tunsigned int jobs;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -152,6 +154,20 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n \t\treturn 0;\n \n+\t/* Handle plain hook.<key> entries that have no hook name component. */\n+\tif (!name) {\n+\t\tif (!strcmp(subkey, \"jobs\") && value) {\n+\t\t\tunsigned int v;\n+\t\t\tif (!git_parse_uint(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n+\t\t\telse if (!v)\n+\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n+\t\t\telse\n+\t\t\t\tdata->jobs = v;\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n \tif (!value)\n \t\treturn config_error_nonbool(key);\n \n@@ -236,7 +252,7 @@ void hook_cache_clear(struct hook_config_cache *cache)\n static void build_hook_config_map(struct repository *r,\n \t\t\t\t  struct hook_config_cache *cache)\n {\n-\tstruct hook_all_config_cb cb_data;\n+\tstruct hook_all_config_cb cb_data = { 0 };\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *e;\n \n@@ -244,7 +260,7 @@ static void build_hook_config_map(struct repository *r,\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \n-\t/* Parse all configs in one run. */\n+\t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n \t/* Construct the cache from parsed configs. */\n@@ -279,6 +295,8 @@ static void build_hook_config_map(struct repository *r,\n \t\tstrmap_put(&cache->hooks, e->key, hooks);\n \t}\n \n+\tcache->jobs = cb_data.jobs;\n+\n \tstrmap_clear(&cb_data.commands, 1);\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\ndiff --git a/hook.h b/hook.h\nindex 994f15522d..7e83a3474f 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -197,6 +197,7 @@ void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n  */\n struct hook_config_cache {\n \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n+\tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n };\n \n /**\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536615","messageId":"20260222002904.1879356-7-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 06/10] hook: mark non-parallelizable hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:29:00Z","receivedAt":"2026-02-22T00:29:51Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSeveral hooks are known to be inherently non-parallelizable, so initialize\nthem with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides\nany hook.jobs or runtime -j flags.\n\nThese hooks are:\napplypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit,\npost-checkout, and push-to-checkout.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n builtin/am.c                   |  8 +++++---\n builtin/checkout.c             | 19 +++++++++++++------\n builtin/clone.c                |  6 ++++--\n builtin/receive-pack.c         |  3 ++-\n builtin/worktree.c             |  2 +-\n commit.c                       |  2 +-\n t/t1800-hook.sh                | 16 ++++++++++++++++\n 8 files changed, 46 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex e306ffa80b..8894088bda 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -36,6 +36,10 @@ hook.<name>.parallel::\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSome hooks always run sequentially regardless of this setting because\n+\tgit knows they cannot safely be parallelized: `applypatch-msg`,\n+\t`pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`,\n+\t`post-checkout`, and `push-to-checkout`.\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<name>.parallel` set to `true`.\ndiff --git a/builtin/am.c b/builtin/am.c\nindex e0c767e223..45a8e78d0b 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state)\n \n \tassert(state->msg);\n \n-\tif (!state->no_verify)\n-\t\tret = run_hooks_l(the_repository, \"applypatch-msg\",\n-\t\t\t\t  am_path(state, \"final-commit\"), NULL);\n+\tif (!state->no_verify) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\t\tstrvec_push(&opt.args, am_path(state, \"final-commit\"));\n+\t\tret = run_hooks_opt(the_repository, \"applypatch-msg\", &opt);\n+\t}\n \n \tif (!ret) {\n \t\tFREE_AND_NULL(state->msg);\ndiff --git a/builtin/checkout.c b/builtin/checkout.c\nindex f7b313816e..9f2d84e3fc 100644\n--- a/builtin/checkout.c\n+++ b/builtin/checkout.c\n@@ -31,6 +31,7 @@\n #include \"resolve-undo.h\"\n #include \"revision.h\"\n #include \"setup.h\"\n+#include \"strvec.h\"\n #include \"submodule.h\"\n #include \"symlinks.h\"\n #include \"trace2.h\"\n@@ -137,13 +138,19 @@ static void branch_info_release(struct branch_info *info)\n static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit,\n \t\t\t      int changed)\n {\n-\treturn run_hooks_l(the_repository, \"post-checkout\",\n-\t\t\t   oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   changed ? \"1\" : \"0\", NULL);\n-\t/* \"new_commit\" can be NULL when checking out from the index before\n-\t   a commit exists. */\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n+\t/*\n+\t * \"new_commit\" can be NULL when checking out from the index before\n+\t * a commit exists.\n+\t */\n+\tstrvec_pushl(&opt.args,\n+\t\t     oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     changed ? \"1\" : \"0\",\n+\t\t     NULL);\n+\n+\treturn run_hooks_opt(the_repository, \"post-checkout\", &opt);\n }\n \n static int update_some(const struct object_id *oid, struct strbuf *base,\ndiff --git a/builtin/clone.c b/builtin/clone.c\nindex b14a39a687..bce3f732b9 100644\n--- a/builtin/clone.c\n+++ b/builtin/clone.c\n@@ -644,6 +644,7 @@ static int checkout(int submodule_progress, int filter_submodules,\n \tstruct tree *tree;\n \tstruct tree_desc t;\n \tint err = 0;\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \tif (option_no_checkout)\n \t\treturn 0;\n@@ -694,8 +695,9 @@ static int checkout(int submodule_progress, int filter_submodules,\n \tif (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK))\n \t\tdie(_(\"unable to write new index file\"));\n \n-\terr |= run_hooks_l(the_repository, \"post-checkout\", oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(&oid), \"1\", NULL);\n+\tstrvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(&oid), \"1\", NULL);\n+\terr |= run_hooks_opt(the_repository, \"post-checkout\", &hook_opt);\n \n \tif (!err && (option_recurse_submodules.nr > 0)) {\n \t\tstruct child_process cmd = CHILD_PROCESS_INIT;\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex f23772bc56..5f9335936a 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1453,7 +1453,8 @@ static const char *push_to_checkout(unsigned char *hash,\n \t\t\t\t    struct strvec *env,\n \t\t\t\t    const char *work_tree)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n \topt.invoked_hook = invoked_hook;\n \n \tstrvec_pushf(env, \"GIT_WORK_TREE=%s\", absolute_path(work_tree));\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex 3d6547c23b..af13386697 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -574,7 +574,7 @@ static int add_worktree(const char *path, const char *refname,\n \t * is_junk is cleared, but do return appropriate code when hook fails.\n \t */\n \tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n \t\tstrvec_pushl(&opt.args,\ndiff --git a/commit.c b/commit.c\nindex d16ae73345..5b2276a80e 100644\n--- a/commit.c\n+++ b/commit.c\n@@ -1957,7 +1957,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len)\n int run_commit_hook(int editor_is_used, const char *index_file,\n \t\t    int *invoked_hook, const char *name, ...)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \tva_list args;\n \tconst char *arg;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex f8318c3510..0d17969cdf 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -695,4 +695,20 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event pre-commit &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event pre-commit &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.jobs 2 &&\n+\tgit commit --allow-empty -m \"test: verify force-serial on pre-commit\" &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536616","messageId":"20260222002904.1879356-2-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 01/10] repository: fix repo_init() memleak due to missing _clear()","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:28:55Z","receivedAt":"2026-02-22T00:29:53Z","isPatch":true,"body":"There is an old pre-existing memory leak in repo_init() due to failing\nto call clear_repository_format() in the error case.\n\nIt went undetected because a specific bug is required to trigger it:\nenable a v1 extension in a repository with format v0. Obviously this\ncan only happen in a development environment, so it does not trigger\nin normal usage, however the memleak is real and needs fixing.\n\nFix it by also calling clear_repository_format() in the error case.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n repository.c | 1 +\n 1 file changed, 1 insertion(+)\n\ndiff --git a/repository.c b/repository.c\nindex f27ab29b0e..78aa2d31d0 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -298,6 +298,7 @@ int repo_init(struct repository *repo,\n \treturn 0;\n \n error:\n+\tclear_repository_format(&format);\n \trepo_clear(repo);\n \treturn -1;\n }\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536617","messageId":"20260222002904.1879356-6-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 05/10] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:28:59Z","receivedAt":"2026-02-22T00:29:54Z","isPatch":true,"body":"From: Emily Shaffer <nasamuffin@google.com>\n\nHooks always run in sequential order due to the hardcoded jobs == 1\npassed to run_process_parallel(). Remove that hardcoding to allow\nusers to run hooks in parallel (opt-in).\n\nUsers need to decide which hooks to run in parallel, by specifying\n\"parallel = true\" in the config, because git cannot know if their\nspecific hooks are safe to run or not in parallel (for e.g. two hooks\nmight write to the same file or call the same program).\n\nSome hooks are unsafe to run in parallel by design: these will marked\nin the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL.\n\nThe hook.jobs config specifies the default number of jobs applied to all\nhooks which have parallelism enabled.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  13 +++\n hook.c                         |  69 ++++++++++++++--\n hook.h                         |  25 ++++++\n t/t1800-hook.sh                | 142 +++++++++++++++++++++++++++++++++\n 4 files changed, 241 insertions(+), 8 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex c617261c57..e306ffa80b 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -23,6 +23,19 @@ hook.<name>.enabled::\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n \n+hook.<name>.parallel::\n+\tWhether the hook `hook.<name>` may run in parallel with other hooks\n+\tfor the same event. Defaults to `false`. Set to `true` only when the\n+\thook script is safe to run concurrently with other hooks for the same\n+\tevent. If any hook for an event does not have this set to `true`,\n+\tall hooks for that event run sequentially regardless of `hook.jobs`.\n+\tOnly configured (named) hooks need to declare this. Traditional hooks\n+\tfound in the hooks directory do not need to, and run in parallel when\n+\tthe effective job count is greater than 1. See linkgit:git-hook[1].\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n++\n+This setting has no effect unless all configured hooks for the event have\n+`hook.<name>.parallel` set to `true`.\ndiff --git a/hook.c b/hook.c\nindex f4213f5878..3d624e2bcd 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -120,11 +120,11 @@ static void unsorted_string_list_remove(struct string_list *list,\n \n /*\n  * Cache entry stored as the .util pointer of string_list items inside the\n- * hook config cache. For now carries only the command for the hook. Next\n- * commits will add more data.\n+ * hook config cache. Carries both the resolved command and the parallel flag.\n  */\n struct hook_config_cache_entry {\n \tchar *command;\n+\tunsigned int parallel:1;\n };\n \n /*\n@@ -132,12 +132,14 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n+ * parallel_hooks: friendly-name to parallel flag.\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tstruct strmap parallel_hooks;\n \tunsigned int jobs;\n };\n \n@@ -216,6 +218,10 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tdefault:\n \t\t\tbreak; /* ignore unrecognised values */\n \t\t}\n+\t} else if (!strcmp(subkey, \"parallel\")) {\n+\t\tint v = git_parse_maybe_bool(value);\n+\t\tif (v >= 0)\n+\t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n \t}\n \n \tfree(hook_name);\n@@ -259,6 +265,7 @@ static void build_hook_config_map(struct repository *r,\n \tstrmap_init(&cb_data.commands);\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n+\tstrmap_init(&cb_data.parallel_hooks);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -273,6 +280,7 @@ static void build_hook_config_map(struct repository *r,\n \t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n \t\t\tconst char *hname = hook_names->items[i].string;\n \t\t\tstruct hook_config_cache_entry *entry;\n+\t\t\tvoid *par = strmap_get(&cb_data.parallel_hooks, hname);\n \t\t\tchar *command;\n \n \t\t\t/* filter out disabled hooks */\n@@ -289,6 +297,7 @@ static void build_hook_config_map(struct repository *r,\n \t\t\t/* util stores a cache entry; owned by the cache. */\n \t\t\tCALLOC_ARRAY(entry, 1);\n \t\t\tentry->command = xstrdup(command);\n+\t\t\tentry->parallel = par ? (int)(uintptr_t)par : 0;\n \t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n@@ -298,6 +307,7 @@ static void build_hook_config_map(struct repository *r,\n \tcache->jobs = cb_data.jobs;\n \n \tstrmap_clear(&cb_data.commands, 1);\n+\tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstring_list_clear(e->value, 0);\n@@ -364,6 +374,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\thook->kind = HOOK_CONFIGURED;\n \t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n \t\thook->u.configured.command = xstrdup(entry->command);\n+\t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n@@ -499,21 +510,67 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/* Determine how many jobs to use for hook execution. */\n+static unsigned int get_hook_jobs(struct repository *r,\n+\t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  struct string_list *hook_list)\n+{\n+\tunsigned int jobs;\n+\n+\t/*\n+\t * Hooks needing separate output streams must run sequentially. Next\n+\t * commits will add an extension to allow parallelizing these as well.\n+\t */\n+\tif (!options->stdout_to_stderr)\n+\t\treturn 1;\n+\n+\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n+\tif (options->jobs)\n+\t\treturn options->jobs;\n+\n+\t/*\n+\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n+\t * fall back to a direct config lookup (out-of-repo).  Default to 1.\n+\t */\n+\tif (r && r->gitdir && r->hook_config_cache)\n+\t\t/* Use the already-parsed cache (in-repo) */\n+\t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n+\telse\n+\t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n+\t\tjobs = repo_config_get_uint(r, \"hook.jobs\", &jobs) ? 1 : jobs;\n+\n+\t/*\n+\t * Cap to serial any configured hook not marked as parallel = true.\n+\t * This enforces the parallel = false default, even for \"traditional\"\n+\t * hooks from the hookdir which cannot be marked parallel = true.\n+\t */\n+\tfor (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n+\t\t\tjobs = 1;\n+\t}\n+\n+\treturn jobs;\n+}\n+\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n+\tstruct string_list *hook_list = list_hooks(r, hook_name, options);\n \tstruct hook_cb_data cb_data = {\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n+\t\t.hook_command_list = hook_list,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\n \n-\t\t.processes = options->jobs,\n-\t\t.ungroup = options->jobs == 1,\n+\t\t.processes = jobs,\n+\t\t.ungroup = jobs == 1,\n \n \t\t.get_next_task = pick_next_hook,\n \t\t.start_failure = notify_start_failure,\n@@ -529,9 +586,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->path_to_stdin && options->feed_pipe)\n \t\tBUG(\"options path_to_stdin and feed_pipe are mutually exclusive\");\n \n-\tif (!options->jobs)\n-\t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n-\n \t/*\n \t * Ensure cb_data copy and free functions are either provided together,\n \t * or neither one is provided.\n@@ -543,7 +597,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n \tif (!cb_data.hook_command_list->nr) {\n \t\tif (options->error_if_missing)\n \t\t\tret = error(\"cannot find a hook named %s\", hook_name);\ndiff --git a/hook.h b/hook.h\nindex 7e83a3474f..1f29798a77 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -29,6 +29,13 @@ struct hook {\n \t\t} configured;\n \t} u;\n \n+\t/**\n+\t * Whether this hook may run in parallel with other hooks for the same\n+\t * event. Only useful for configured (named) hooks. Traditional hooks\n+\t * always default to 0 (serial). Set via `hook.<name>.parallel = true`.\n+\t */\n+\tunsigned int parallel:1;\n+\n \t/**\n \t * Opaque data pointer used to keep internal state across callback calls.\n \t *\n@@ -62,6 +69,8 @@ struct run_hooks_opt\n \t *\n \t * If > 1, output will be buffered and de-interleaved (ungroup=0).\n \t * If == 1, output will be real-time (ungroup=1).\n+\t * If == 0, the 'hook.jobs' config is used or, if the config is unset,\n+\t * defaults to 1 (serial execution).\n \t */\n \tunsigned int jobs;\n \n@@ -142,7 +151,23 @@ struct run_hooks_opt\n \tcb_data_free_fn feed_pipe_cb_data_free;\n };\n \n+/**\n+ * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to\n+ * the 'hook.jobs' config, falling back to serial (1) if unset.\n+ */\n #define RUN_HOOKS_OPT_INIT { \\\n+\t.env = STRVEC_INIT, \\\n+\t.args = STRVEC_INIT, \\\n+\t.stdout_to_stderr = 1, \\\n+\t.jobs = 0, \\\n+}\n+\n+/**\n+ * Initializer for hooks that must always run sequentially regardless of\n+ * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized\n+ * .jobs = 1 is non-overridable.\n+ */\n+#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \\\n \t.env = STRVEC_INIT, \\\n \t.args = STRVEC_INIT, \\\n \t.stdout_to_stderr = 1, \\\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex b1583e9ef9..f8318c3510 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -21,6 +21,57 @@ setup_hookdir () {\n \ttest_when_finished rm -rf .git/hooks\n }\n \n+# write_sentinel_hook <path> [sentinel]\n+#\n+# Writes a hook that marks itself as started, sleeps for a few seconds, then\n+# marks itself done. The sleep must be long enough that sentinel_detector can\n+# observe <sentinel>.started before <sentinel>.done appears when both hooks\n+# run concurrently in parallel mode.\n+write_sentinel_hook () {\n+\tsentinel=\"${2:-sentinel}\"\n+\twrite_script \"$1\" <<-EOF\n+\ttouch ${sentinel}.started &&\n+\tsleep 2 &&\n+\ttouch ${sentinel}.done\n+\tEOF\n+}\n+\n+# sentinel_detector <sentinel> <output>\n+#\n+# Returns a shell command string suitable for use as hook.<name>.command.\n+# The detector must be registered after the sentinel:\n+# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)\n+#    before the detector starts.\n+# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared\n+#    yet and the detector just sees <sentinel>.started.\n+#\n+# At start, poll until <sentinel>.started exists to absorb startup jitter, then\n+# write to <output>:\n+# 1. 'serial'   if <sentinel>.done exists (sentinel finished before we started),\n+# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),\n+# 3. 'timeout'  if <sentinel>.started never appeared.\n+#\n+# The command ends with ':' so when git appends \"$@\" for hooks that receive\n+# positional arguments (e.g. pre-push), the result ': \"$@\"' is valid shell\n+# rather than a syntax error 'fi \"$@\"'.\n+sentinel_detector () {\n+\tcat <<-EOF\n+\ti=0\n+\twhile ! test -f ${1}.started && test \\$i -lt 10; do\n+\t    sleep 1\n+\t    i=\\$((i+1))\n+\tdone\n+\tif test -f ${1}.done; then\n+\t    echo serial >${2}\n+\telif test -f ${1}.started; then\n+\t    echo parallel >${2}\n+\telse\n+\t    echo timeout >${2}\n+\tfi\n+\t:\n+\tEOF\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n@@ -553,4 +604,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'hook.jobs=1 config runs hooks in series' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\n+\t# Use two configured hooks so the execution order is deterministic:\n+\t# hook-1 (sentinel) is listed before hook-2 (detector), so hook-1\n+\t# always runs first even in serial mode.\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 1 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=true enables parallel execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'one non-parallel hook forces the whole event to run serially' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 has no parallel=true: should force serial for all\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536618","messageId":"20260222002904.1879356-9-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 08/10] hook: add per-event jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:29:02Z","receivedAt":"2026-02-22T00:29:57Z","isPatch":true,"body":"Add a hook.<event>.jobs count config that allows users to override the\nglobal hook.jobs setting for specific hook events.\n\nThis allows finer-grained control over parallelism on a per-event basis.\n\nFor example, to run `post-receive` hooks with up to 4 parallel jobs\nwhile keeping other events at their global default:\n\n[hook]\n    post-receive.jobs = 4\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 19 +++++++++++\n hook.c                         | 47 +++++++++++++++++++++++----\n hook.h                         |  1 +\n t/t1800-hook.sh                | 59 ++++++++++++++++++++++++++++++++++\n 4 files changed, 120 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 8894088bda..6ad23ac71d 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -33,9 +33,28 @@ hook.<name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.jobs::\n+\tSpecifies how many hooks can be run simultaneously for the `<event>`\n+\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n+\tfor this specific event. The same parallelism restrictions apply: this\n+\tsetting has no effect unless all configured hooks for the event have\n+\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\tzero is rejected with a warning. See linkgit:git-hook[1].\n++\n+Note on naming: although this key resembles `hook.<friendly-name>.*`\n+(a per-hook setting), `<event>` must be the event name, not a hook\n+friendly name. The key component is stored literally and looked up by\n+event name at runtime with no translation between the two namespaces.\n+A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n+lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n+`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n+registered for that event. Use `hook.post-receive.jobs` or any other\n+valid event name when setting `hook.<event>.jobs`.\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tgit knows they cannot safely be parallelized: `applypatch-msg`,\n \t`pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`,\ndiff --git a/hook.c b/hook.c\nindex 6214276e3d..013e41a8d6 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -133,6 +133,8 @@ struct hook_config_cache_entry {\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n  * parallel_hooks: friendly-name to parallel flag.\n+ * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *,\n+ *             where NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n  */\n struct hook_all_config_cb {\n@@ -140,6 +142,7 @@ struct hook_all_config_cb {\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n \tstruct strmap parallel_hooks;\n+\tstruct strmap event_jobs;\n \tunsigned int jobs;\n };\n \n@@ -222,6 +225,20 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tint v = git_parse_maybe_bool(value);\n \t\tif (v >= 0)\n \t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n+\t} else if (!strcmp(subkey, \"jobs\")) {\n+\t\tunsigned int v;\n+\t\tif (!git_parse_uint(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive integer, ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n+\t\telse if (!v)\n+\t\t\twarning(_(\"hook.%s.jobs must be positive, ignoring: 0\"), hook_name);\n+\t\telse {\n+\t\t\tunsigned int *old;\n+\t\t\tunsigned int *p = xmalloc(sizeof(*p));\n+\t\t\t*p = v;\n+\t\t\told = strmap_put(&data->event_jobs, hook_name, p);\n+\t\t\tfree(old);\n+\t\t}\n \t}\n \n \tfree(hook_name);\n@@ -252,6 +269,7 @@ void hook_cache_clear(struct hook_config_cache *cache)\n \t\tfree(hooks);\n \t}\n \tstrmap_clear(&cache->hooks, 0);\n+\tstrmap_clear(&cache->event_jobs, 1); /* free heap-allocated unsigned int * values */\n }\n \n /* Populate `cache` with the complete hook configuration */\n@@ -266,6 +284,7 @@ static void build_hook_config_map(struct repository *r,\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \tstrmap_init(&cb_data.parallel_hooks);\n+\tstrmap_init(&cb_data.event_jobs);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -305,6 +324,7 @@ static void build_hook_config_map(struct repository *r,\n \t}\n \n \tcache->jobs = cb_data.jobs;\n+\tcache->event_jobs = cb_data.event_jobs;\n \n \tstrmap_clear(&cb_data.commands, 1);\n \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n@@ -513,6 +533,7 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  const char *hook_name,\n \t\t\t\t  struct string_list *hook_list)\n {\n \tunsigned int jobs;\n@@ -529,22 +550,36 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\treturn 1;\n \n \t/*\n-\t * Resolve effective job count: -jN (when given) overrides config.\n-\t * Default to 1 when both config an -jN are missing.\n+\t * Resolve effective job count: -j N (when given) overrides config.\n+\t * hook.<event>.jobs overrides hook.jobs.\n+\t * Unset configs and -jN default to 1.\n \t */\n-\tif (options->jobs > 1)\n+\tif (options->jobs > 1) {\n \t\tjobs = options->jobs;\n-\telse if (r && r->gitdir && r->hook_config_cache)\n+\t} else if (r && r->gitdir && r->hook_config_cache) {\n \t\t/* Use the already-parsed cache (in-repo) */\n+\t\tunsigned int *event_jobs = strmap_get(&r->hook_config_cache->event_jobs,\n+\t\t\t\t\t\t      hook_name);\n \t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n-\telse\n+\t\tif (event_jobs)\n+\t\t\tjobs = *event_jobs;\n+\t} else {\n \t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n+\t\tunsigned int event_jobs;\n+\t\tchar *key;\n \t\tjobs = repo_config_get_uint(r, \"hook.jobs\", &jobs) ? 1 : jobs;\n+\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n+\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n+\t\t\tjobs = event_jobs;\n+\t\tfree(key);\n+\t}\n \n \t/*\n \t * Cap to serial any configured hook not marked as parallel = true.\n \t * This enforces the parallel = false default, even for \"traditional\"\n \t * hooks from the hookdir which cannot be marked parallel = true.\n+\t * The same restriction applies whether jobs came from hook.jobs or\n+\t * hook.<event>.jobs.\n \t */\n \tfor (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {\n \t\tstruct hook *h = hook_list->items[i].util;\n@@ -566,7 +601,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n-\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\ndiff --git a/hook.h b/hook.h\nindex 1f29798a77..22fc59e67a 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -222,6 +222,7 @@ void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n  */\n struct hook_config_cache {\n \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n+\tstruct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */\n \tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n };\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex a6913b8c62..f5c0655adb 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -820,4 +820,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=1 (serial), but per-event override allows parallel.\n+\ttest_config hook.jobs 1 &&\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=4 allows parallel, but per-event override forces serial.\n+\ttest_config hook.jobs 4 &&\n+\ttest_config hook.test-hook.jobs 1 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# Per-event jobs=2 but no hook has parallel=true: must still run serially.\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536619","messageId":"20260222002904.1879356-10-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 09/10] hook: introduce extensions.hookStdoutToStderr","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:29:03Z","receivedAt":"2026-02-22T00:29:59Z","isPatch":true,"body":"All hooks already redirect stdout to stderr with the exception of\npre-push which has a known user who depends on the separate stdout\nversus stderr outputs (the git-lfs project).\n\nThe pre-push behavior was a surprise which we found out about after\ncausing a regression for git-lfs. Notably, it might not be the only\nexception (it's the one we know about). There might be more.\n\nThis presents a challenge because stdout_to_stderr is required for\nhook parallelization, so run-command can buffer and de-interleave\nthe hook outputs using ungroup=0, when hook.jobs > 1.\n\nIntroduce an extension to enforce consistency: all hooks merge stdout\ninto stderr and can be safely parallelized. This provides a clean\nseparation and avoids breaking existing stdout vs stderr behavior.\n\nWhen this extension is disabled, the `hook.jobs` config has no\neffect for pre-push, to prevent garbled (interleaved) parallel\noutput, so it runs sequentially like before.\n\nAlternatives I've considered to this extension include:\n1. Allowing pre-push to run in parallel with interleaved output.\n2. Always running pre-push sequentially (no parallel jobs for it).\n3. Making users (only git-lfs? maybe more?) fix their hooks to read\n   stderr not stdout.\n\nOut of all these alternatives, I think this extension is the most\nreasonable compromise, to not break existing users, allow pre-push\nparallel jobs for those who need it (with correct outputs) and also\nfuture-proofing in case there are any more exceptions to be added.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/extensions.adoc | 12 +++++++++\n Documentation/config/hook.adoc       |  3 +++\n repository.c                         |  1 +\n repository.h                         |  1 +\n setup.c                              |  7 ++++++\n setup.h                              |  1 +\n t/t1800-hook.sh                      | 37 ++++++++++++++++++++++++++++\n transport.c                          |  7 ++----\n 8 files changed, 64 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc\nindex 2aef3315b1..342734668d 100644\n--- a/Documentation/config/extensions.adoc\n+++ b/Documentation/config/extensions.adoc\n@@ -102,6 +102,18 @@ The extension can be enabled automatically for new repositories by setting\n `init.defaultSubmodulePathConfig` to `true`, for example by running\n `git config --global init.defaultSubmodulePathConfig true`.\n \n+hookStdoutToStderr:::\n+\tIf enabled, the stdout of all hooks is redirected to stderr. This\n+\tenforces consistency, since by default most hooks already behave\n+\tthis way, with pre-push being the only known exception.\n++\n+This is useful for parallel hook execution (see the `hook.jobs` config in\n+linkgit:git-config[1]), as it allows the output of multiple hooks running\n+in parallel to be grouped (de-interleaved) correctly.\n++\n+Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push\n+hooks, which will always be run sequentially.\n+\n worktreeConfig:::\n \tIf enabled, then worktrees will load config settings from the\n \t`$GIT_DIR/config.worktree` file in addition to the\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 6ad23ac71d..aa8a949a36 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -62,3 +62,6 @@ hook.jobs::\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<name>.parallel` set to `true`.\n++\n+This has no effect for hooks requiring separate output streams (like `pre-push`)\n+unless `extensions.hookStdoutToStderr` is enabled.\ndiff --git a/repository.c b/repository.c\nindex 78aa2d31d0..d7f247b676 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -283,6 +283,7 @@ int repo_init(struct repository *repo,\n \trepo->repository_format_relative_worktrees = format.relative_worktrees;\n \trepo->repository_format_precious_objects = format.precious_objects;\n \trepo->repository_format_submodule_path_cfg = format.submodule_path_cfg;\n+\trepo->repository_format_hook_stdout_to_stderr = format.hook_stdout_to_stderr;\n \n \t/* take ownership of format.partial_clone */\n \trepo->repository_format_partial_clone = format.partial_clone;\ndiff --git a/repository.h b/repository.h\nindex 2105768b8c..a4d7f129ad 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -173,6 +173,7 @@ struct repository {\n \tint repository_format_relative_worktrees;\n \tint repository_format_precious_objects;\n \tint repository_format_submodule_path_cfg;\n+\tint repository_format_hook_stdout_to_stderr;\n \n \t/* Indicate if a repository has a different 'commondir' from 'gitdir' */\n \tunsigned different_commondir:1;\ndiff --git a/setup.c b/setup.c\nindex c8336eb20e..75f115faba 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -688,6 +688,9 @@ static enum extension_result handle_extension(const char *var,\n \t} else if (!strcmp(ext, \"submodulepathconfig\")) {\n \t\tdata->submodule_path_cfg = git_config_bool(var, value);\n \t\treturn EXTENSION_OK;\n+\t} else if (!strcmp(ext, \"hookstdouttostderr\")) {\n+\t\tdata->hook_stdout_to_stderr = git_config_bool(var, value);\n+\t\treturn EXTENSION_OK;\n \t}\n \treturn EXTENSION_UNKNOWN;\n }\n@@ -1951,6 +1954,8 @@ const char *setup_git_directory_gently(int *nongit_ok)\n \t\t\t\trepo_fmt.relative_worktrees;\n \t\t\tthe_repository->repository_format_submodule_path_cfg =\n \t\t\t\trepo_fmt.submodule_path_cfg;\n+\t\t\tthe_repository->repository_format_hook_stdout_to_stderr =\n+\t\t\t\trepo_fmt.hook_stdout_to_stderr;\n \t\t\t/* take ownership of repo_fmt.partial_clone */\n \t\t\tthe_repository->repository_format_partial_clone =\n \t\t\t\trepo_fmt.partial_clone;\n@@ -2053,6 +2058,8 @@ void check_repository_format(struct repository_format *fmt)\n \t\tfmt->submodule_path_cfg;\n \tthe_repository->repository_format_relative_worktrees =\n \t\tfmt->relative_worktrees;\n+\tthe_repository->repository_format_hook_stdout_to_stderr =\n+\t\tfmt->hook_stdout_to_stderr;\n \tthe_repository->repository_format_partial_clone =\n \t\txstrdup_or_null(fmt->partial_clone);\n \tclear_repository_format(&repo_fmt);\ndiff --git a/setup.h b/setup.h\nindex 0738dec244..9de6e9b59c 100644\n--- a/setup.h\n+++ b/setup.h\n@@ -168,6 +168,7 @@ struct repository_format {\n \tint worktree_config;\n \tint relative_worktrees;\n \tint submodule_path_cfg;\n+\tint hook_stdout_to_stderr;\n \tint is_bare;\n \tint hash_algo;\n \tint compat_hash_algo;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex f5c0655adb..d1a0d0a3d4 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -532,6 +532,43 @@ test_expect_success 'client hooks: pre-push expects separate stdout and stderr'\n \tcheck_stdout_separate_from_stderr pre-push\n '\n \n+test_expect_success 'client hooks: extension makes pre-push merge stdout to stderr' '\n+\ttest_when_finished \"rm -rf remote2 stdout.actual stderr.actual\" &&\n+\tgit init --bare remote2 &&\n+\tgit remote add origin2 remote2 &&\n+\ttest_commit B &&\n+\tgit config set core.repositoryformatversion 1 &&\n+\ttest_config extensions.hookStdoutToStderr true &&\n+\tsetup_hooks pre-push &&\n+\tgit push origin2 HEAD:main >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push\n+'\n+\n+test_expect_success 'client hooks: pre-push defaults to serial execution' '\n+\ttest_when_finished \"rm -rf remote-serial repo-serial\" &&\n+\tgit init --bare remote-serial &&\n+\tgit init repo-serial &&\n+\tgit -C repo-serial remote add origin ../remote-serial &&\n+\ttest_commit -C repo-serial A &&\n+\n+\t# Setup 2 pre-push hooks; no parallel=true so they must run serially.\n+\t# Use sentinel/detector pattern: hook-1 (sentinel, configured) runs first\n+\t# because configured hooks precede traditional hooks in list order; hook-2\n+\t# (detector) runs second and checks whether hook-1 has finished.\n+\tgit -C repo-serial config hook.hook-1.event pre-push &&\n+\tgit -C repo-serial config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\tgit -C repo-serial config hook.hook-2.event pre-push &&\n+\tgit -C repo-serial config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\tgit -C repo-serial config hook.jobs 2 &&\n+\n+\tgit -C repo-serial push origin HEAD >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect repo-serial/hook.order\n+'\n+\n test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' '\n \thooks=\"pre-commit prepare-commit-msg \\\n \t\tcommit-msg post-commit \\\ndiff --git a/transport.c b/transport.c\nindex 1581aa0886..97a9c89e16 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1388,11 +1388,8 @@ static int run_pre_push_hook(struct transport *transport,\n \topt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;\n \topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n \n-\t/*\n-\t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n-\t * them to keep backwards compatibility with existing hooks.\n-\t */\n-\topt.stdout_to_stderr = 0;\n+\t/* merge stdout to stderr only when extensions.hookStdoutToStderr is enabled */\n+\topt.stdout_to_stderr = the_repository->repository_format_hook_stdout_to_stderr;\n \n \tret = run_hooks_opt(the_repository, \"pre-push\", &opt);\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536620","messageId":"20260222002904.1879356-1-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 00/10] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:28:54Z","receivedAt":"2026-02-22T00:30:00Z","isPatch":true,"body":"Hello everyone,\n\nThis series enables running hook commands in parallel and is based on\nthe v2 series enabling config hooks [1], which added the ability to run\na list of hooks for each event.\n\nIn v2 I've addressed all the feedback received in v1, the biggest design\nchange being that it's all disabled by default and users need to enable\nparallelism via the documented mechanisms.\n\nI also split the logic into more commits to make things easier to review,\nthat is the main reason why this grew from 4 to 10 patches.\n\nBranch pushed to GitHub containing all dependency patches: [2]\nSuccessful CI run: [3]\n\nMany thanks to all who contributed to this effort up to now, including\nEmily, AEvar, Junio, Patrick, Phillip, Brian, Peff and many others.\n\nThank you,\nAdrian\n\n1: https://lore.kernel.org/git/20260204165126.1548805-1-adrian.ratiu@collabora.com/T/#m8ceab5819683cd70473055f780444ad7bccf7284\n2: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/parallel-hooks-v2\n3: https://github.com/10ne1/git/actions/runs/22266919210\n\nChanges in v2:\n* Default hooks to sequential execution (jobs == 1) like before (Patrick, Phillip)\n* Parallel execution becomes an opt-in feature (Patrick, Phillip)\n* Reworked RUN_HOOKS_OPT_INIT macros to reflect the above design (Adrian)\n* Simplify get_hook_jobs() after the above redesign decisions (Adrian)\n* Documented hooks which are always run sequentially (Patrick)\n* New commits: split the big \"add parallel hooks\" commit into smaller ones (Adrian)\n* New commit: add a memleak fix (Adrian)\n* Added a struct hook_config_cache_entry because we're storing more\n  than 1 command per hook inthe cache now (Adrian)\n* Cache is used for all hook.* configs which are read in 1 pass (Adrian)\n* Improved parallel tests to use sentinel files instead of plain sleeps\n  to make them more reliable (Adrian)\n* Added more hook tests to improve coverage (Adrian)\n* Rebased on latest master and fixed trivial conficts with a newly added\n  submodule extension in the master branch (Adrian)\n* Reworded commit messages to reflect the new design (Patrick)\n* Minor style cleanups, improve code comments (Patrick, Adrian)\n\nRange-diff v1 -> v2:\n -:  ---------- >  1:  f28b0270f9 repository: fix repo_init() memleak due to missing _clear()\n 1:  a632a9b937 !  2:  c7cc106224 config: add a repo_config_get_uint() helper\n    @@ Metadata\n      ## Commit message ##\n         config: add a repo_config_get_uint() helper\n     \n    -    Next commit adds a 'hook.jobs' config option of type 'unsigned int',\n    +    Next commits add a 'hook.jobs' config option of type 'unsigned int',\n         so add a helper to parse it since the API only supports int and ulong.\n     \n         An alternative is to make 'hook.jobs' an 'int' or parse it as an 'int'\n 2:  724e8403e0 <  -:  ---------- hook: allow parallel hook execution\n -:  ---------- >  3:  2fe5aa34d6 hook: refactor hook_config_cache from strmap to named struct\n -:  ---------- >  4:  23853aa170 hook: parse the hook.jobs config\n -:  ---------- >  5:  71380942dc hook: allow parallel hook execution\n -:  ---------- >  6:  820064f1c9 hook: mark non-parallelizable hooks\n -:  ---------- >  7:  b328f3451f hook: add -j/--jobs option to git hook run\n -:  ---------- >  8:  ff27b34a8d hook: add per-event jobs config\n 3:  edea5408e1 !  9:  2bc572e46e hook: introduce extensions.hookStdoutToStderr\n    @@ Commit message\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Documentation/config/extensions.adoc ##\n    -@@ Documentation/config/extensions.adoc: relativeWorktrees:::\n    - \trepaired with either the `--relative-paths` option or with the\n    - \t`worktree.useRelativePaths` config set to `true`.\n    +@@ Documentation/config/extensions.adoc: The extension can be enabled automatically for new repositories by setting\n    + `init.defaultSubmodulePathConfig` to `true`, for example by running\n    + `git config --global init.defaultSubmodulePathConfig true`.\n      \n     +hookStdoutToStderr:::\n     +\tIf enabled, the stdout of all hooks is redirected to stderr. This\n    @@ Documentation/config/extensions.adoc: relativeWorktrees:::\n     \n      ## Documentation/config/hook.adoc ##\n     @@ Documentation/config/hook.adoc: hook.jobs::\n    - \tSpecifies how many hooks can be run simultaneously during parallelized\n    - \thook execution. If unspecified, defaults to the number of processors on\n    - \tthe current system.\n    + +\n    + This setting has no effect unless all configured hooks for the event have\n    + `hook.<name>.parallel` set to `true`.\n     ++\n     +This has no effect for hooks requiring separate output streams (like `pre-push`)\n     +unless `extensions.hookStdoutToStderr` is enabled.\n     \n      ## repository.c ##\n     @@ repository.c: int repo_init(struct repository *repo,\n    - \trepo->repository_format_worktree_config = format.worktree_config;\n      \trepo->repository_format_relative_worktrees = format.relative_worktrees;\n      \trepo->repository_format_precious_objects = format.precious_objects;\n    + \trepo->repository_format_submodule_path_cfg = format.submodule_path_cfg;\n     +\trepo->repository_format_hook_stdout_to_stderr = format.hook_stdout_to_stderr;\n      \n      \t/* take ownership of format.partial_clone */\n    @@ repository.c: int repo_init(struct repository *repo,\n     \n      ## repository.h ##\n     @@ repository.h: struct repository {\n    - \tint repository_format_worktree_config;\n      \tint repository_format_relative_worktrees;\n      \tint repository_format_precious_objects;\n    + \tint repository_format_submodule_path_cfg;\n     +\tint repository_format_hook_stdout_to_stderr;\n      \n      \t/* Indicate if a repository has a different 'commondir' from 'gitdir' */\n    @@ repository.h: struct repository {\n     \n      ## setup.c ##\n     @@ setup.c: static enum extension_result handle_extension(const char *var,\n    - \t} else if (!strcmp(ext, \"relativeworktrees\")) {\n    - \t\tdata->relative_worktrees = git_config_bool(var, value);\n    + \t} else if (!strcmp(ext, \"submodulepathconfig\")) {\n    + \t\tdata->submodule_path_cfg = git_config_bool(var, value);\n      \t\treturn EXTENSION_OK;\n     +\t} else if (!strcmp(ext, \"hookstdouttostderr\")) {\n     +\t\tdata->hook_stdout_to_stderr = git_config_bool(var, value);\n    @@ setup.c: static enum extension_result handle_extension(const char *var,\n      \treturn EXTENSION_UNKNOWN;\n      }\n     @@ setup.c: const char *setup_git_directory_gently(int *nongit_ok)\n    - \t\t\t\trepo_fmt.worktree_config;\n    - \t\t\tthe_repository->repository_format_relative_worktrees =\n      \t\t\t\trepo_fmt.relative_worktrees;\n    + \t\t\tthe_repository->repository_format_submodule_path_cfg =\n    + \t\t\t\trepo_fmt.submodule_path_cfg;\n     +\t\t\tthe_repository->repository_format_hook_stdout_to_stderr =\n     +\t\t\t\trepo_fmt.hook_stdout_to_stderr;\n      \t\t\t/* take ownership of repo_fmt.partial_clone */\n      \t\t\tthe_repository->repository_format_partial_clone =\n      \t\t\t\trepo_fmt.partial_clone;\n     @@ setup.c: void check_repository_format(struct repository_format *fmt)\n    - \t\tfmt->worktree_config;\n    + \t\tfmt->submodule_path_cfg;\n      \tthe_repository->repository_format_relative_worktrees =\n      \t\tfmt->relative_worktrees;\n     +\tthe_repository->repository_format_hook_stdout_to_stderr =\n    @@ setup.c: void check_repository_format(struct repository_format *fmt)\n     \n      ## setup.h ##\n     @@ setup.h: struct repository_format {\n    - \tchar *partial_clone; /* value of extensions.partialclone */\n      \tint worktree_config;\n      \tint relative_worktrees;\n    + \tint submodule_path_cfg;\n     +\tint hook_stdout_to_stderr;\n      \tint is_bare;\n      \tint hash_algo;\n    @@ t/t1800-hook.sh: test_expect_success 'client hooks: pre-push expects separate st\n      '\n      \n     +test_expect_success 'client hooks: extension makes pre-push merge stdout to stderr' '\n    -+\ttest_when_finished \"rm -f stdout.actual stderr.actual\" &&\n    ++\ttest_when_finished \"rm -rf remote2 stdout.actual stderr.actual\" &&\n     +\tgit init --bare remote2 &&\n     +\tgit remote add origin2 remote2 &&\n     +\ttest_commit B &&\n    -+\t# repositoryformatversion=1 might be already set (eg default sha256)\n    -+\t# so check before using test_config to set it\n    -+\t{ test \"$(git config core.repositoryformatversion)\" = 1 ||\n    -+\t  test_config core.repositoryformatversion 1; } &&\n     +\tgit config set core.repositoryformatversion 1 &&\n     +\ttest_config extensions.hookStdoutToStderr true &&\n     +\tsetup_hooks pre-push &&\n    @@ t/t1800-hook.sh: test_expect_success 'client hooks: pre-push expects separate st\n     +'\n     +\n     +test_expect_success 'client hooks: pre-push defaults to serial execution' '\n    -+\ttest_when_finished \"rm -rf repo-serial\" &&\n    ++\ttest_when_finished \"rm -rf remote-serial repo-serial\" &&\n     +\tgit init --bare remote-serial &&\n     +\tgit init repo-serial &&\n     +\tgit -C repo-serial remote add origin ../remote-serial &&\n     +\ttest_commit -C repo-serial A &&\n     +\n    -+\t# Setup 2 pre-push hooks\n    -+\twrite_script repo-serial/.git/hooks/pre-push <<-EOF &&\n    -+\tsleep 2\n    -+\techo \"Hook 1\" >&2\n    -+\tEOF\n    ++\t# Setup 2 pre-push hooks; no parallel=true so they must run serially.\n    ++\t# Use sentinel/detector pattern: hook-1 (sentinel, configured) runs first\n    ++\t# because configured hooks precede traditional hooks in list order; hook-2\n    ++\t# (detector) runs second and checks whether hook-1 has finished.\n    ++\tgit -C repo-serial config hook.hook-1.event pre-push &&\n    ++\tgit -C repo-serial config hook.hook-1.command \\\n    ++\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n     +\tgit -C repo-serial config hook.hook-2.event pre-push &&\n    -+\tgit -C repo-serial config hook.hook-2.command \"sleep 2; echo Hook 2 >&2\" &&\n    ++\tgit -C repo-serial config hook.hook-2.command \\\n    ++\t    \"$(sentinel_detector sentinel hook.order)\" &&\n     +\n     +\tgit -C repo-serial config hook.jobs 2 &&\n     +\n    -+\tstart=$(date +%s) &&\n     +\tgit -C repo-serial push origin HEAD >out 2>err &&\n    -+\tend=$(date +%s) &&\n    -+\n    -+\tduration=$((end - start)) &&\n    -+\t# Serial >= 4s, parallel < 4s.\n    -+\ttest $duration -ge 4\n    ++\techo serial >expect &&\n    ++\ttest_cmp expect repo-serial/hook.order\n     +'\n     +\n      test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' '\n    @@ t/t1800-hook.sh: test_expect_success 'client hooks: pre-push expects separate st\n     \n      ## transport.c ##\n     @@ transport.c: static int run_pre_push_hook(struct transport *transport,\n    - \topt.copy_feed_pipe_cb_data = copy_pre_push_hook_data;\n    - \topt.free_feed_pipe_cb_data = free_pre_push_hook_data;\n    + \topt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;\n    + \topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n      \n     -\t/*\n     -\t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n     -\t * them to keep backwards compatibility with existing hooks.\n     -\t */\n     -\topt.stdout_to_stderr = 0;\n    -+\t/* merge stdout to stderr only when extensions.StdoutToStderr is enabled */\n    ++\t/* merge stdout to stderr only when extensions.hookStdoutToStderr is enabled */\n     +\topt.stdout_to_stderr = the_repository->repository_format_hook_stdout_to_stderr;\n      \n      \tret = run_hooks_opt(the_repository, \"pre-push\", &opt);\n 4:  4c85640615 ! 10:  35dbc4a6c5 hook: allow runtime enabling extensions.hookStdoutToStderr\n    @@ Documentation/config/extensions.adoc: in parallel to be grouped (de-interleaved)\n      \tIf enabled, then worktrees will load config settings from the\n     \n      ## Documentation/config/hook.adoc ##\n    -@@ Documentation/config/hook.adoc: hook.jobs::\n    +@@ Documentation/config/hook.adoc: This setting has no effect unless all configured hooks for the event have\n      +\n      This has no effect for hooks requiring separate output streams (like `pre-push`)\n      unless `extensions.hookStdoutToStderr` is enabled.\n    @@ Documentation/config/hook.adoc: hook.jobs::\n     +\tparallel execution for hooks like `pre-push`.\n     \n      ## hook.c ##\n    -@@ hook.c: static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *op\n    - {\n    - \tunsigned int jobs = options->jobs;\n    +@@ hook.c: struct hook_config_cache_entry {\n    +  * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *,\n    +  *             where NULL == unset).\n    +  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n    ++ * force_stdout_to_stderr: value of hook.forceStdoutToStderr. Defaults to 0.\n    +  */\n    + struct hook_all_config_cb {\n    + \tstruct strmap commands;\n    +@@ hook.c: struct hook_all_config_cb {\n    + \tstruct strmap parallel_hooks;\n    + \tstruct strmap event_jobs;\n    + \tunsigned int jobs;\n    ++\tint force_stdout_to_stderr;\n    + };\n      \n    -+\t/*\n    -+\t * Allow hook.forceStdoutToStderr to enable extensions.hookStdoutToStderr\n    -+\t * for existing repositories (runtime override).\n    -+\t */\n    -+\tif (!options->stdout_to_stderr) {\n    -+\t\tint v = 0;\n    -+\t\trepo_config_get_bool(r, \"hook.forceStdoutToStderr\", &v);\n    -+\t\toptions->stdout_to_stderr = v;\n    -+\t}\n    + /* repo_config() callback that collects all hook.* configuration in one pass. */\n    +@@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n    + \t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n    + \t\t\telse\n    + \t\t\t\tdata->jobs = v;\n    ++\t\t} else if (!strcmp(subkey, \"forcestdouttostderr\") && value) {\n    ++\t\t\tint v = git_parse_maybe_bool(value);\n    ++\t\t\tif (v >= 0)\n    ++\t\t\t\tdata->force_stdout_to_stderr = v;\n    + \t\t}\n    + \t\treturn 0;\n    + \t}\n    +@@ hook.c: static void build_hook_config_map(struct repository *r,\n    + \n    + \tcache->jobs = cb_data.jobs;\n    + \tcache->event_jobs = cb_data.event_jobs;\n    ++\tcache->force_stdout_to_stderr = cb_data.force_stdout_to_stderr;\n    + \n    + \tstrmap_clear(&cb_data.commands, 1);\n    + \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n    +@@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options)\n    + \tstrvec_clear(&options->args);\n    + }\n    + \n    ++static void hook_force_apply_stdout_to_stderr(struct repository *r,\n    ++\t\t\t\t\t      struct run_hooks_opt *options)\n    ++{\n    ++\tint force = 0;\n    ++\n    ++\tif (r && r->gitdir && r->hook_config_cache)\n    ++\t\tforce = r->hook_config_cache->force_stdout_to_stderr;\n    ++\telse\n    ++\t\trepo_config_get_bool(r, \"hook.forceStdoutToStderr\", &force);\n    ++\n    ++\tif (force)\n    ++\t\toptions->stdout_to_stderr = 1;\n    ++}\n     +\n    + /* Determine how many jobs to use for hook execution. */\n    + static unsigned int get_hook_jobs(struct repository *r,\n    + \t\t\t\t  struct run_hooks_opt *options,\n    +@@ hook.c: static unsigned int get_hook_jobs(struct repository *r,\n    + \tunsigned int jobs;\n    + \n      \t/*\n    - \t * Hooks which configure stdout_to_stderr=0 (like pre-push), expect separate\n    - \t * output streams. Unless extensions.StdoutToStderr is enabled (which forces\n    +-\t * Hooks needing separate output streams must run sequentially. Next\n    +-\t * commits will add an extension to allow parallelizing these as well.\n    ++\t * Apply hook.forceStdoutToStderr before anything else: it affects\n    ++\t * whether we can run in parallel or not.\n    + \t */\n    ++\thook_force_apply_stdout_to_stderr(r, options);\n    ++\n    ++\t/* Hooks needing separate output streams must run sequentially. */\n    + \tif (!options->stdout_to_stderr)\n    + \t\treturn 1;\n    + \n    +\n    + ## hook.h ##\n    +@@ hook.h: struct hook_config_cache {\n    + \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n    + \tstruct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */\n    + \tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n    ++\tint force_stdout_to_stderr; /* hook.forceStdoutToStderr config value */\n    + };\n    + \n    + /**\n     \n      ## setup.c ##\n     @@ setup.c: void initialize_repository_version(int hash_algo,\n    - {\n      \tstruct strbuf repo_version = STRBUF_INIT;\n      \tint target_version = GIT_REPO_VERSION;\n    + \tint default_submodule_path_config = 0;\n     +\tint default_hook_stdout_to_stderr = 0;\n      \n      \t/*\n      \t * Note that we initialize the repository version to 1 when the ref\n     @@ setup.c: void initialize_repository_version(int hash_algo,\n    - \t\tclear_repository_format(&repo_fmt);\n    + \t\trepo_config_set(the_repository, \"extensions.submodulepathconfig\", \"true\");\n      \t}\n      \n     +\trepo_config_get_bool(the_repository, \"hook.forceStdoutToStderr\",\n    @@ setup.c: void initialize_repository_version(int hash_algo,\n      \n     \n      ## t/t1800-hook.sh ##\n    -@@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n    - \ttest $duration -lt 4\n    +@@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true'\n    + \ttest_cmp expect hook.order\n      '\n      \n     +test_expect_success '`git init` respects hook.forceStdoutToStderr' '\n    @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 config runs hooks in parallel'\n     +'\n     +\n     +test_expect_success 'hook.forceStdoutToStderr enables pre-push parallel runs' '\n    -+\ttest_when_finished \"rm -rf repo-parallel\" &&\n    ++\ttest_when_finished \"rm -rf repo-parallel remote-parallel\" &&\n     +\tgit init --bare remote-parallel &&\n     +\tgit init repo-parallel &&\n     +\tgit -C repo-parallel remote add origin ../remote-parallel &&\n     +\ttest_commit -C repo-parallel A &&\n     +\n    -+\twrite_script repo-parallel/.git/hooks/pre-push <<-EOF &&\n    -+\tsleep 2\n    -+\techo \"Hook 1\" >&2\n    -+\tEOF\n    ++\twrite_sentinel_hook repo-parallel/.git/hooks/pre-push &&\n     +\tgit -C repo-parallel config hook.hook-2.event pre-push &&\n    -+\tgit -C repo-parallel config hook.hook-2.command \"sleep 2; echo Hook 2 >&2\" &&\n    ++\tgit -C repo-parallel config hook.hook-2.command \\\n    ++\t    \"$(sentinel_detector sentinel hook.order)\" &&\n    ++\tgit -C repo-parallel config hook.hook-2.parallel true &&\n     +\n     +\tgit -C repo-parallel config hook.jobs 2 &&\n     +\tgit -C repo-parallel config hook.forceStdoutToStderr true &&\n     +\n    -+\tstart=$(date +%s) &&\n     +\tgit -C repo-parallel push origin HEAD >out 2>err &&\n    -+\tend=$(date +%s) &&\n    -+\n    -+\tduration=$((end - start)) &&\n    -+\t# Serial >= 4s, parallel < 4s.\n    -+\ttest $duration -lt 4\n    ++\techo parallel >expect &&\n    ++\ttest_cmp expect repo-parallel/hook.order\n     +'\n     +\n      test_done\n\nAdrian Ratiu (7):\n  repository: fix repo_init() memleak due to missing _clear()\n  config: add a repo_config_get_uint() helper\n  hook: refactor hook_config_cache from strmap to named struct\n  hook: parse the hook.jobs config\n  hook: add per-event jobs config\n  hook: introduce extensions.hookStdoutToStderr\n  hook: allow runtime enabling extensions.hookStdoutToStderr\n\nEmily Shaffer (3):\n  hook: allow parallel hook execution\n  hook: mark non-parallelizable hooks\n  hook: add -j/--jobs option to git hook run\n\n Documentation/config/extensions.adoc |  15 +\n Documentation/config/hook.adoc       |  49 +++\n Documentation/git-hook.adoc          |  18 +-\n builtin/am.c                         |   8 +-\n builtin/checkout.c                   |  19 +-\n builtin/clone.c                      |   6 +-\n builtin/hook.c                       |   5 +-\n builtin/receive-pack.c               |   3 +-\n builtin/worktree.c                   |   2 +-\n commit.c                             |   2 +-\n config.c                             |  28 ++\n config.h                             |  13 +\n hook.c                               | 213 +++++++++++--\n hook.h                               |  38 ++-\n parse.c                              |   9 +\n parse.h                              |   1 +\n repository.c                         |   2 +\n repository.h                         |   4 +-\n setup.c                              |  17 +\n setup.h                              |   1 +\n t/t1800-hook.sh                      | 446 ++++++++++++++++++++++++++-\n transport.c                          |   7 +-\n 22 files changed, 843 insertions(+), 63 deletions(-)\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536621","messageId":"20260222002904.1879356-11-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 10/10] hook: allow runtime enabling extensions.hookStdoutToStderr","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:29:04Z","receivedAt":"2026-02-22T00:30:08Z","isPatch":true,"body":"Add a new config `hook.forceStdoutToStderr` which allows enabling\nextensions.hookStdoutToStderr by default at runtime, both for new\nand existing repositories.\n\nThis makes it easier for users to enable hook parallelization for\nhooks like pre-push by enforcing output consistency. See previous\ncommit for a more in-depth explanation & alternatives considered.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/extensions.adoc |  3 ++\n Documentation/config/hook.adoc       |  6 +++\n hook.c                               | 28 ++++++++++-\n hook.h                               |  1 +\n setup.c                              | 10 ++++\n t/t1800-hook.sh                      | 69 ++++++++++++++++++++++++++++\n 6 files changed, 115 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc\nindex 342734668d..b152c6f681 100644\n--- a/Documentation/config/extensions.adoc\n+++ b/Documentation/config/extensions.adoc\n@@ -113,6 +113,9 @@ in parallel to be grouped (de-interleaved) correctly.\n +\n Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push\n hooks, which will always be run sequentially.\n++\n+The extension can also be enabled by setting `hook.forceStdoutToStderr`\n+to `true` in the global configuration.\n \n worktreeConfig:::\n \tIf enabled, then worktrees will load config settings from the\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex aa8a949a36..7b81d2b615 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -65,3 +65,9 @@ This setting has no effect unless all configured hooks for the event have\n +\n This has no effect for hooks requiring separate output streams (like `pre-push`)\n unless `extensions.hookStdoutToStderr` is enabled.\n+\n+hook.forceStdoutToStderr::\n+\tA boolean that enables the `extensions.hookStdoutToStderr` behavior\n+\t(merging stdout to stderr for all hooks) globally. This effectively\n+\tforces all hooks to behave as if the extension was enabled, allowing\n+\tparallel execution for hooks like `pre-push`.\ndiff --git a/hook.c b/hook.c\nindex 013e41a8d6..d5675df238 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -136,6 +136,7 @@ struct hook_config_cache_entry {\n  * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *,\n  *             where NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n+ * force_stdout_to_stderr: value of hook.forceStdoutToStderr. Defaults to 0.\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n@@ -144,6 +145,7 @@ struct hook_all_config_cb {\n \tstruct strmap parallel_hooks;\n \tstruct strmap event_jobs;\n \tunsigned int jobs;\n+\tint force_stdout_to_stderr;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -169,6 +171,10 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n \t\t\telse\n \t\t\t\tdata->jobs = v;\n+\t\t} else if (!strcmp(subkey, \"forcestdouttostderr\") && value) {\n+\t\t\tint v = git_parse_maybe_bool(value);\n+\t\t\tif (v >= 0)\n+\t\t\t\tdata->force_stdout_to_stderr = v;\n \t\t}\n \t\treturn 0;\n \t}\n@@ -325,6 +331,7 @@ static void build_hook_config_map(struct repository *r,\n \n \tcache->jobs = cb_data.jobs;\n \tcache->event_jobs = cb_data.event_jobs;\n+\tcache->force_stdout_to_stderr = cb_data.force_stdout_to_stderr;\n \n \tstrmap_clear(&cb_data.commands, 1);\n \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n@@ -530,6 +537,20 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+static void hook_force_apply_stdout_to_stderr(struct repository *r,\n+\t\t\t\t\t      struct run_hooks_opt *options)\n+{\n+\tint force = 0;\n+\n+\tif (r && r->gitdir && r->hook_config_cache)\n+\t\tforce = r->hook_config_cache->force_stdout_to_stderr;\n+\telse\n+\t\trepo_config_get_bool(r, \"hook.forceStdoutToStderr\", &force);\n+\n+\tif (force)\n+\t\toptions->stdout_to_stderr = 1;\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -539,9 +560,12 @@ static unsigned int get_hook_jobs(struct repository *r,\n \tunsigned int jobs;\n \n \t/*\n-\t * Hooks needing separate output streams must run sequentially. Next\n-\t * commits will add an extension to allow parallelizing these as well.\n+\t * Apply hook.forceStdoutToStderr before anything else: it affects\n+\t * whether we can run in parallel or not.\n \t */\n+\thook_force_apply_stdout_to_stderr(r, options);\n+\n+\t/* Hooks needing separate output streams must run sequentially. */\n \tif (!options->stdout_to_stderr)\n \t\treturn 1;\n \ndiff --git a/hook.h b/hook.h\nindex 22fc59e67a..6ea6dbca40 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -224,6 +224,7 @@ struct hook_config_cache {\n \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n \tstruct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */\n \tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n+\tint force_stdout_to_stderr; /* hook.forceStdoutToStderr config value */\n };\n \n /**\ndiff --git a/setup.c b/setup.c\nindex 75f115faba..c64496adac 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -2317,6 +2317,7 @@ void initialize_repository_version(int hash_algo,\n \tstruct strbuf repo_version = STRBUF_INIT;\n \tint target_version = GIT_REPO_VERSION;\n \tint default_submodule_path_config = 0;\n+\tint default_hook_stdout_to_stderr = 0;\n \n \t/*\n \t * Note that we initialize the repository version to 1 when the ref\n@@ -2364,6 +2365,15 @@ void initialize_repository_version(int hash_algo,\n \t\trepo_config_set(the_repository, \"extensions.submodulepathconfig\", \"true\");\n \t}\n \n+\trepo_config_get_bool(the_repository, \"hook.forceStdoutToStderr\",\n+\t\t\t     &default_hook_stdout_to_stderr);\n+\tif (default_hook_stdout_to_stderr) {\n+\t\t/* extensions.hookstdouttostderr requires at least version 1 */\n+\t\tif (target_version == 0)\n+\t\t\ttarget_version = 1;\n+\t\trepo_config_set(the_repository, \"extensions.hookstdouttostderr\", \"true\");\n+\t}\n+\n \tstrbuf_addf(&repo_version, \"%d\", target_version);\n \trepo_config_set(the_repository, \"core.repositoryformatversion\", repo_version.buf);\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex d1a0d0a3d4..d8f60f2ced 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -916,4 +916,73 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success '`git init` respects hook.forceStdoutToStderr' '\n+\ttest_when_finished \"rm -rf repo-init\" &&\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tgit init repo-init &&\n+\tgit -C repo-init config extensions.hookStdoutToStderr >actual &&\n+\techo true >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '`git init` does not set extensions.hookStdoutToStderr by default' '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\tgit init upstream &&\n+\ttest_must_fail git -C upstream config extensions.hookStdoutToStderr\n+'\n+\n+test_expect_success '`git clone` does not set extensions.hookStdoutToStderr by default' '\n+\ttest_when_finished \"rm -rf upstream repo-clone-no-ext\" &&\n+\tgit init upstream &&\n+\tgit clone upstream repo-clone-no-ext &&\n+\ttest_must_fail git -C repo-clone-no-ext config extensions.hookStdoutToStderr\n+'\n+\n+test_expect_success '`git clone` respects hook.forceStdoutToStderr' '\n+\ttest_when_finished \"rm -rf upstream repo-clone\" &&\n+\tgit init upstream &&\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tgit clone upstream repo-clone &&\n+\tgit -C repo-clone config extensions.hookStdoutToStderr >actual &&\n+\techo true >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'hook.forceStdoutToStderr enables extension for existing repos' '\n+\ttest_when_finished \"rm -rf remote-repo existing-repo\" &&\n+\tgit init --bare remote-repo &&\n+\tgit init -b main existing-repo &&\n+\t# No local extensions.hookStdoutToStderr config set here\n+\t# so global config should apply\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tcd existing-repo &&\n+\ttest_commit A &&\n+\tgit remote add origin ../remote-repo &&\n+\tsetup_hooks pre-push &&\n+\tgit push origin HEAD >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push &&\n+\tcd ..\n+'\n+\n+test_expect_success 'hook.forceStdoutToStderr enables pre-push parallel runs' '\n+\ttest_when_finished \"rm -rf repo-parallel remote-parallel\" &&\n+\tgit init --bare remote-parallel &&\n+\tgit init repo-parallel &&\n+\tgit -C repo-parallel remote add origin ../remote-parallel &&\n+\ttest_commit -C repo-parallel A &&\n+\n+\twrite_sentinel_hook repo-parallel/.git/hooks/pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.event pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\tgit -C repo-parallel config hook.hook-2.parallel true &&\n+\n+\tgit -C repo-parallel config hook.jobs 2 &&\n+\tgit -C repo-parallel config hook.forceStdoutToStderr true &&\n+\n+\tgit -C repo-parallel push origin HEAD >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect repo-parallel/hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536622","messageId":"20260222002904.1879356-8-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260222002904.1879356-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 07/10] hook: add -j/--jobs option to git hook run","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:29:01Z","receivedAt":"2026-02-22T00:30:09Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nExpose the parallel job count as a command-line flag so callers can\nrequest parallelism without relying only on the hook.jobs config.\n\nAdd tests covering serial/parallel execution and TTY behaviour under\n-j1 vs -jN.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  18 +++++-\n builtin/hook.c              |   5 +-\n hook.c                      |  14 ++--\n t/t1800-hook.sh             | 123 ++++++++++++++++++++++++++++++++++--\n 4 files changed, 144 insertions(+), 16 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 12d2701b52..c9492f6f8e 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -8,7 +8,8 @@ git-hook - Run git hooks\n SYNOPSIS\n --------\n [verse]\n-'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n+\t<hook-name> [-- <hook-args>]\n 'git hook' list [-z] <hook-name>\n \n DESCRIPTION\n@@ -134,6 +135,18 @@ OPTIONS\n -z::\n \tTerminate \"list\" output lines with NUL instead of newlines.\n \n+-j::\n+--jobs::\n+\tOnly valid for `run`.\n++\n+Specify how many hooks to run simultaneously. If this flag is not specified,\n+the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n+neither is specified, defaults to 1 (serial execution). Some hooks always run\n+sequentially regardless of this flag or the `hook.jobs` config, because git\n+knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`,\n+`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and\n+`push-to-checkout`.\n+\n WRAPPERS\n --------\n \n@@ -156,7 +169,8 @@ running:\n git hook run mywrapper-start-tests \\\n   # providing something to stdin\n   --stdin some-tempfile-123 \\\n-  # execute hooks in serial\n+  # execute multiple hooks in parallel\n+  --jobs 3 \\\n   # plus some arguments of your own...\n   -- \\\n   --testname bar \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 83020dfb4f..1fdde80113 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -9,7 +9,8 @@\n #include \"abspath.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n-\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n+\t   \"<hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [-z] <hook-name>\")\n \n@@ -97,6 +98,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n+\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\ndiff --git a/hook.c b/hook.c\nindex 3d624e2bcd..6214276e3d 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -524,15 +524,17 @@ static unsigned int get_hook_jobs(struct repository *r,\n \tif (!options->stdout_to_stderr)\n \t\treturn 1;\n \n-\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n-\tif (options->jobs)\n-\t\treturn options->jobs;\n+\t/* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */\n+\tif (options->jobs == 1)\n+\t\treturn 1;\n \n \t/*\n-\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n-\t * fall back to a direct config lookup (out-of-repo).  Default to 1.\n+\t * Resolve effective job count: -jN (when given) overrides config.\n+\t * Default to 1 when both config an -jN are missing.\n \t */\n-\tif (r && r->gitdir && r->hook_config_cache)\n+\tif (options->jobs > 1)\n+\t\tjobs = options->jobs;\n+\telse if (r && r->gitdir && r->hook_config_cache)\n \t\t/* Use the already-parsed cache (in-repo) */\n \t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n \telse\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 0d17969cdf..a6913b8c62 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -239,10 +239,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n '\n \n test_hook_tty () {\n-\tcat >expect <<-\\EOF\n-\tSTDOUT TTY\n-\tSTDERR TTY\n-\tEOF\n+\texpect_tty=$1\n+\tshift\n+\n+\tif test \"$expect_tty\" != \"no_tty\"; then\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT TTY\n+\t\tSTDERR TTY\n+\t\tEOF\n+\telse\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT NO TTY\n+\t\tSTDERR NO TTY\n+\t\tEOF\n+\tfi\n \n \ttest_when_finished \"rm -rf repo\" &&\n \tgit init repo &&\n@@ -260,12 +270,21 @@ test_hook_tty () {\n \ttest_cmp expect repo/actual\n }\n \n-test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty hook run pre-commit\n+test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '\n+\t# hooks running sequentially (-j1) are always connected to the tty for\n+\t# optimum real-time performance.\n+\ttest_hook_tty tty hook run -j1 pre-commit\n+'\n+\n+test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '\n+\t# Hooks are not connected to the tty when run in parallel, instead they\n+\t# output to a pipe through which run-command collects and de-interlaces\n+\t# their outputs, which then gets passed either to the tty or a sideband.\n+\ttest_hook_tty no_tty hook run -j2 pre-commit\n '\n \n test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty commit -m\"B.new\"\n+\ttest_hook_tty tty commit -m\"B.new\"\n '\n \n test_expect_success 'git hook list orders by config order' '\n@@ -604,6 +623,96 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'parallel hook output is not interleaved' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo \"Hook 1 Start\"\n+\tsleep 1\n+\techo \"Hook 1 End\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t\t    \"echo \\\"Hook 2 Start\\\"; sleep 2; echo \\\"Hook 2 End\\\"\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.hook-3.event test-hook &&\n+\ttest_config hook.hook-3.command \\\n+\t\t    \"echo \\\"Hook 3 Start\\\"; sleep 3; echo \\\"Hook 3 End\\\"\" &&\n+\ttest_config hook.hook-3.parallel true &&\n+\n+\tgit hook run -j3 test-hook >out 2>err.parallel &&\n+\n+\t# Verify Hook 1 output is grouped\n+\tsed -n \"/Hook 1 Start/,/Hook 1 End/p\" err.parallel >hook1_out &&\n+\ttest_line_count = 2 hook1_out &&\n+\n+\t# Verify Hook 2 output is grouped\n+\tsed -n \"/Hook 2 Start/,/Hook 2 End/p\" err.parallel >hook2_out &&\n+\ttest_line_count = 2 hook2_out &&\n+\n+\t# Verify Hook 3 output is grouped\n+\tsed -n \"/Hook 3 Start/,/Hook 3 End/p\" err.parallel >hook3_out &&\n+\ttest_line_count = 2 hook3_out\n+'\n+\n+test_expect_success 'git hook run -j1 runs hooks in series' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\ttest_config hook.series-1.event \"test-hook\" &&\n+\ttest_config hook.series-1.command \"echo 1\" --add &&\n+\ttest_config hook.series-2.event \"test-hook\" &&\n+\ttest_config hook.series-2.command \"echo 2\" --add &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo 3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tgit hook run -j1 test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook run -j2 runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\tgit hook run -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 is blocked by parallel=false' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# -j2 must not override parallel=false on configured hooks.\n+\tgit hook run -j2 test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_expect_success 'hook.jobs=1 config runs hooks in series' '\n \ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538265","messageId":"20260309133739.294555-3-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 2/9] config: add a repo_config_get_uint() helper","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:32Z","receivedAt":"2026-03-09T13:38:52Z","isPatch":true,"body":"Next commits add a 'hook.jobs' config option of type 'unsigned int',\nso add a helper to parse it since the API only supports int and ulong.\n\nAn alternative is to make 'hook.jobs' an 'int' or parse it as an 'int'\nthen cast it to unsigned, however it's better to use proper helpers for\nthe type. Using 'ulong' is another option which already has helpers, but\nit's a bit excessive in size for just the jobs number.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n config.c | 28 ++++++++++++++++++++++++++++\n config.h | 13 +++++++++++++\n parse.c  |  9 +++++++++\n parse.h  |  1 +\n 4 files changed, 51 insertions(+)\n\ndiff --git a/config.c b/config.c\nindex 156f2a24fa..a1b92fe083 100644\n--- a/config.c\n+++ b/config.c\n@@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value,\n \treturn ret;\n }\n \n+unsigned int git_config_uint(const char *name, const char *value,\n+\t\t\t     const struct key_value_info *kvi)\n+{\n+\tunsigned int ret;\n+\tif (!git_parse_uint(value, &ret))\n+\t\tdie_bad_number(name, value, kvi);\n+\treturn ret;\n+}\n+\n int64_t git_config_int64(const char *name, const char *value,\n \t\t\t const struct key_value_info *kvi)\n {\n@@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest)\n \t\treturn 1;\n }\n \n+int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest)\n+{\n+\tconst char *value;\n+\tstruct key_value_info kvi;\n+\n+\tif (!git_configset_get_value(set, key, &value, &kvi)) {\n+\t\t*dest = git_config_uint(key, value, &kvi);\n+\t\treturn 0;\n+\t} else\n+\t\treturn 1;\n+}\n+\n int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest)\n {\n \tconst char *value;\n@@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo,\n \treturn git_configset_get_int(repo->config, key, dest);\n }\n \n+int repo_config_get_uint(struct repository *repo,\n+\t\t\t const char *key, unsigned int *dest)\n+{\n+\tgit_config_check_init(repo);\n+\treturn git_configset_get_uint(repo->config, key, dest);\n+}\n+\n int repo_config_get_ulong(struct repository *repo,\n \t\t\t  const char *key, unsigned long *dest)\n {\ndiff --git a/config.h b/config.h\nindex ba426a960a..bf47fb3afc 100644\n--- a/config.h\n+++ b/config.h\n@@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *);\n int64_t git_config_int64(const char *, const char *,\n \t\t\t const struct key_value_info *);\n \n+/**\n+ * Identical to `git_config_int`, but for unsigned ints.\n+ */\n+unsigned int git_config_uint(const char *, const char *,\n+\t\t\t     const struct key_value_info *);\n+\n /**\n  * Identical to `git_config_int`, but for unsigned longs.\n  */\n@@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key,\n \n int git_configset_get_string(struct config_set *cs, const char *key, char **dest);\n int git_configset_get_int(struct config_set *cs, const char *key, int *dest);\n+int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest);\n int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest);\n int git_configset_get_bool(struct config_set *cs, const char *key, int *dest);\n int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest);\n@@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r,\n  */\n int repo_config_get_int(struct repository *r, const char *key, int *dest);\n \n+/**\n+ * Similar to `repo_config_get_int` but for unsigned ints.\n+ */\n+int repo_config_get_uint(struct repository *r,\n+\t\t\t const char *key, unsigned int *dest);\n+\n /**\n  * Similar to `repo_config_get_int` but for unsigned longs.\n  */\ndiff --git a/parse.c b/parse.c\nindex 48313571aa..d77f28046a 100644\n--- a/parse.c\n+++ b/parse.c\n@@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret)\n \treturn 1;\n }\n \n+int git_parse_uint(const char *value, unsigned int *ret)\n+{\n+\tuintmax_t tmp;\n+\tif (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int)))\n+\t\treturn 0;\n+\t*ret = tmp;\n+\treturn 1;\n+}\n+\n int git_parse_ulong(const char *value, unsigned long *ret)\n {\n \tuintmax_t tmp;\ndiff --git a/parse.h b/parse.h\nindex ea32de9a91..a6dd37c4cb 100644\n--- a/parse.h\n+++ b/parse.h\n@@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max);\n int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max);\n int git_parse_ssize_t(const char *, ssize_t *);\n int git_parse_ulong(const char *, unsigned long *);\n+int git_parse_uint(const char *value, unsigned int *ret);\n int git_parse_int(const char *value, int *ret);\n int git_parse_int64(const char *value, int64_t *ret);\n int git_parse_double(const char *value, double *ret);\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538266","messageId":"20260309133739.294555-1-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 0/9] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:30Z","receivedAt":"2026-03-09T13:38:52Z","isPatch":true,"body":"Hello everyone,\n\nThis series enables running hook commands in parallel and is based on\nthe latest config hooks cleanup series [1].\n\nv3 is just a small rebase re-roll to fix conflicts and drop a commit which\nis now part of the base series. The logic in unchanged since v2.\n\nBranch pushed to GitHub: [2]\nSuccessful CI run: [3]\n\nMany thanks to all who contributed to this effort up to now, including\nEmily, AEvar, Junio, Patrick, Phillip, Brian, Peff and many others.\n\nThank you,\nAdrian\n\n1: https://lore.kernel.org/git/20260309005416.2760030-1-adrian.ratiu@collabora.com/T/#m0b740e28d4fd06104777a5ceb645d3205450b9c9\n2: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v3\n3: https://github.com/10ne1/git/actions/runs/22854112520\n\nChanges in v3:\n* Rebased on the new config cleanup series, fixed minor conflicts (Adrian)\n* Dropped refactor commit which is now part of the base series (Adrian)\n* Simplified an entry->parallel asignment to remove shorthand if (Adrian)\n\nRange-diff v2 -> v3:\n 1:  f28b0270f9 =  1:  6686d92867 repository: fix repo_init() memleak due to missing _clear()\n 2:  c7cc106224 =  2:  61250bdd91 config: add a repo_config_get_uint() helper\n 3:  2fe5aa34d6 <  -:  ---------- hook: refactor hook_config_cache from strmap to named struct\n 4:  23853aa170 !  3:  2c49e2a523 hook: parse the hook.jobs config\n    @@ Commit message\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Documentation/config/hook.adoc ##\n    -@@ Documentation/config/hook.adoc: hook.<name>.enabled::\n    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.enabled::\n      \tconfiguration. This is particularly useful when a hook is defined\n      \tin a system or global config file and needs to be disabled for a\n      \tspecific repository. See linkgit:git-hook[1].\n    @@ hook.c\n     @@ hook.c: struct hook_config_cache_entry {\n       * commands: friendly-name to command map.\n       * event_hooks: event-name to list of friendly-names map.\n    -  * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n    +  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n     + * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n       */\n      struct hook_all_config_cb {\n    @@ hook.c: static void build_hook_config_map(struct repository *r,\n      \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n     \n      ## hook.h ##\n    -@@ hook.h: void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n    +@@ hook.h: void hook_free(void *p, const char *str UNUSED);\n       */\n      struct hook_config_cache {\n      \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n 5:  71380942dc !  4:  3c206fac62 hook: allow parallel hook execution\n    @@ Commit message\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Documentation/config/hook.adoc ##\n    -@@ Documentation/config/hook.adoc: hook.<name>.enabled::\n    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.enabled::\n      \tin a system or global config file and needs to be disabled for a\n      \tspecific repository. See linkgit:git-hook[1].\n      \n    @@ Documentation/config/hook.adoc: hook.<name>.enabled::\n     +`hook.<name>.parallel` set to `true`.\n     \n      ## hook.c ##\n    -@@ hook.c: static void unsorted_string_list_remove(struct string_list *list,\n    - \n    - /*\n    -  * Cache entry stored as the .util pointer of string_list items inside the\n    -- * hook config cache. For now carries only the command for the hook. Next\n    -- * commits will add more data.\n    -+ * hook config cache. Carries both the resolved command and the parallel flag.\n    -  */\n    - struct hook_config_cache_entry {\n    +@@ hook.c: struct hook_config_cache_entry {\n      \tchar *command;\n    + \tenum config_scope scope;\n    + \tint disabled;\n     +\tunsigned int parallel:1;\n      };\n      \n    @@ hook.c: static void unsorted_string_list_remove(struct string_list *list,\n     @@ hook.c: struct hook_config_cache_entry {\n       * commands: friendly-name to command map.\n       * event_hooks: event-name to list of friendly-names map.\n    -  * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n    +  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n     + * parallel_hooks: friendly-name to parallel flag.\n       * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n       */\n    @@ hook.c: static void build_hook_config_map(struct repository *r,\n      \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n      \trepo_config(r, hook_config_lookup_all, &cb_data);\n     @@ hook.c: static void build_hook_config_map(struct repository *r,\n    - \t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n    - \t\t\tconst char *hname = hook_names->items[i].string;\n    + \t\t\tenum config_scope scope =\n    + \t\t\t\t(enum config_scope)(uintptr_t)hook_names->items[i].util;\n      \t\t\tstruct hook_config_cache_entry *entry;\n     +\t\t\tvoid *par = strmap_get(&cb_data.parallel_hooks, hname);\n      \t\t\tchar *command;\n      \n    - \t\t\t/* filter out disabled hooks */\n    + \t\t\tint is_disabled =\n     @@ hook.c: static void build_hook_config_map(struct repository *r,\n    - \t\t\t/* util stores a cache entry; owned by the cache. */\n    - \t\t\tCALLOC_ARRAY(entry, 1);\n    - \t\t\tentry->command = xstrdup(command);\n    -+\t\t\tentry->parallel = par ? (int)(uintptr_t)par : 0;\n    + \t\t\tentry->command = command ? xstrdup(command) : NULL;\n    + \t\t\tentry->scope = scope;\n    + \t\t\tentry->disabled = is_disabled;\n    ++\t\t\tentry->parallel = (int)(uintptr_t)par;\n      \t\t\tstring_list_append(hooks, hname)->util = entry;\n      \t\t}\n      \n    @@ hook.c: static void build_hook_config_map(struct repository *r,\n      \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n      \t\tstring_list_clear(e->value, 0);\n     @@ hook.c: static void list_hooks_add_configured(struct repository *r,\n    - \t\thook->kind = HOOK_CONFIGURED;\n    - \t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n    - \t\thook->u.configured.command = xstrdup(entry->command);\n    + \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n    + \t\thook->u.configured.scope = entry->scope;\n    + \t\thook->u.configured.disabled = entry->disabled;\n     +\t\thook->parallel = entry->parallel;\n      \n      \t\tstring_list_append(list, friendly_name)->util = hook;\n    @@ hook.h: struct run_hooks_opt\n      \tunsigned int jobs;\n      \n     @@ hook.h: struct run_hooks_opt\n    - \tcb_data_free_fn feed_pipe_cb_data_free;\n    + \thook_data_free_fn feed_pipe_cb_data_free;\n      };\n      \n     +/**\n 6:  820064f1c9 !  5:  2385cb3bc8 hook: mark non-parallelizable hooks\n    @@ builtin/checkout.c: static void branch_info_release(struct branch_info *info)\n      static int update_some(const struct object_id *oid, struct strbuf *base,\n     \n      ## builtin/clone.c ##\n    -@@ builtin/clone.c: static int checkout(int submodule_progress, int filter_submodules,\n    +@@ builtin/clone.c: static int checkout(int submodule_progress,\n      \tstruct tree *tree;\n      \tstruct tree_desc t;\n      \tint err = 0;\n    @@ builtin/clone.c: static int checkout(int submodule_progress, int filter_submodul\n      \n      \tif (option_no_checkout)\n      \t\treturn 0;\n    -@@ builtin/clone.c: static int checkout(int submodule_progress, int filter_submodules,\n    +@@ builtin/clone.c: static int checkout(int submodule_progress,\n      \tif (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK))\n      \t\tdie(_(\"unable to write new index file\"));\n      \n 7:  b328f3451f !  6:  c4f92834a7 hook: add -j/--jobs option to git hook run\n    @@ Documentation/git-hook.adoc: git-hook - Run git hooks\n     -'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n     +'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n     +\t<hook-name> [-- <hook-args>]\n    - 'git hook' list [-z] <hook-name>\n    + 'git hook' list [-z] [--show-scope] <hook-name>\n      \n      DESCRIPTION\n     @@ Documentation/git-hook.adoc: OPTIONS\n    - -z::\n    - \tTerminate \"list\" output lines with NUL instead of newlines.\n    + \tin parentheses after the friendly name of each configured hook, to show\n    + \twhere it was defined. Traditional hooks from the hookdir are unaffected.\n      \n     +-j::\n     +--jobs::\n    @@ Documentation/git-hook.adoc: running:\n     \n      ## builtin/hook.c ##\n     @@\n    - #include \"abspath.h\"\n    + #include \"parse-options.h\"\n      \n      #define BUILTIN_HOOK_RUN_USAGE \\\n     -\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n     +\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n     +\t   \"<hook-name> [-- <hook-args>]\")\n      #define BUILTIN_HOOK_LIST_USAGE \\\n    - \tN_(\"git hook list [-z] <hook-name>\")\n    + \tN_(\"git hook list [-z] [--show-scope] <hook-name>\")\n      \n     @@ builtin/hook.c: static int run(int argc, const char **argv, const char *prefix,\n      \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n 8:  ff27b34a8d !  7:  7b3ea03bd3 hook: add per-event jobs config\n    @@ Documentation/config/hook.adoc: hook.<name>.parallel::\n      ## hook.c ##\n     @@ hook.c: struct hook_config_cache_entry {\n       * event_hooks: event-name to list of friendly-names map.\n    -  * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n    +  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n       * parallel_hooks: friendly-name to parallel flag.\n     + * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *,\n     + *             where NULL == unset).\n    @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,\n      \t\t.tr2_label = hook_name,\n     \n      ## hook.h ##\n    -@@ hook.h: void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n    +@@ hook.h: void hook_free(void *p, const char *str UNUSED);\n       */\n      struct hook_config_cache {\n      \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n 9:  2bc572e46e =  8:  d5cf01444f hook: introduce extensions.hookStdoutToStderr\n10:  35dbc4a6c5 =  9:  1cef7b5e22 hook: allow runtime enabling extensions.hookStdoutToStderr\n\nAdrian Ratiu (6):\n  repository: fix repo_init() memleak due to missing _clear()\n  config: add a repo_config_get_uint() helper\n  hook: parse the hook.jobs config\n  hook: add per-event jobs config\n  hook: introduce extensions.hookStdoutToStderr\n  hook: allow runtime enabling extensions.hookStdoutToStderr\n\nEmily Shaffer (3):\n  hook: allow parallel hook execution\n  hook: mark non-parallelizable hooks\n  hook: add -j/--jobs option to git hook run\n\n Documentation/config/extensions.adoc |  15 +\n Documentation/config/hook.adoc       |  49 +++\n Documentation/git-hook.adoc          |  18 +-\n builtin/am.c                         |   8 +-\n builtin/checkout.c                   |  19 +-\n builtin/clone.c                      |   6 +-\n builtin/hook.c                       |   5 +-\n builtin/receive-pack.c               |   3 +-\n builtin/worktree.c                   |   2 +-\n commit.c                             |   2 +-\n config.c                             |  28 ++\n config.h                             |  13 +\n hook.c                               | 149 ++++++++-\n hook.h                               |  28 ++\n parse.c                              |   9 +\n parse.h                              |   1 +\n repository.c                         |   2 +\n repository.h                         |   1 +\n setup.c                              |  17 +\n setup.h                              |   1 +\n t/t1800-hook.sh                      | 446 ++++++++++++++++++++++++++-\n transport.c                          |   7 +-\n 22 files changed, 792 insertions(+), 37 deletions(-)\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538267","messageId":"20260309133739.294555-2-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear()","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:31Z","receivedAt":"2026-03-09T13:38:53Z","isPatch":true,"body":"There is an old pre-existing memory leak in repo_init() due to failing\nto call clear_repository_format() in the error case.\n\nIt went undetected because a specific bug is required to trigger it:\nenable a v1 extension in a repository with format v0. Obviously this\ncan only happen in a development environment, so it does not trigger\nin normal usage, however the memleak is real and needs fixing.\n\nFix it by also calling clear_repository_format() in the error case.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n repository.c | 1 +\n 1 file changed, 1 insertion(+)\n\ndiff --git a/repository.c b/repository.c\nindex 0b8f7ec200..fb4356ca55 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -322,6 +322,7 @@ int repo_init(struct repository *repo,\n \treturn 0;\n \n error:\n+\tclear_repository_format(&format);\n \trepo_clear(repo);\n \treturn -1;\n }\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538268","messageId":"20260309133739.294555-4-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 3/9] hook: parse the hook.jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:33Z","receivedAt":"2026-03-09T13:38:55Z","isPatch":true,"body":"The hook.jobs config is a global way to set hook parallelization for\nall hooks, in the sense that it is not per-event nor per-hook.\n\nFiner-grained configs will be added in later commits which can override\nit, for e.g. via a per-event type job options. Next commits will also\nadd to this item's documentation.\n\nParse hook.jobs config key in hook_config_lookup_all() and store its\nvalue in hook_all_config_cb.jobs, then transfer it into\nhook_config_cache.jobs after the config pass completes.\n\nThis is mostly plumbing and the cached value is not yet used.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 22 ++++++++++++++++++++--\n hook.h                         |  1 +\n 3 files changed, 25 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 9e78f26439..b7847f9338 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -22,3 +22,7 @@ hook.<friendly-name>.enabled::\n \tconfiguration. This is particularly useful when a hook is defined\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n+\n+hook.jobs::\n+\tSpecifies how many hooks can be run simultaneously during parallelized\n+\thook execution. If unspecified, defaults to 1 (serial execution).\ndiff --git a/hook.c b/hook.c\nindex 4f4f060156..e6e44a5fcb 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -127,11 +127,13 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tunsigned int jobs;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -147,6 +149,20 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n \t\treturn 0;\n \n+\t/* Handle plain hook.<key> entries that have no hook name component. */\n+\tif (!name) {\n+\t\tif (!strcmp(subkey, \"jobs\") && value) {\n+\t\t\tunsigned int v;\n+\t\t\tif (!git_parse_uint(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n+\t\t\telse if (!v)\n+\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n+\t\t\telse\n+\t\t\t\tdata->jobs = v;\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n \tif (!value)\n \t\treturn config_error_nonbool(key);\n \n@@ -245,7 +261,7 @@ void hook_cache_clear(struct hook_config_cache *cache)\n static void build_hook_config_map(struct repository *r,\n \t\t\t\t  struct hook_config_cache *cache)\n {\n-\tstruct hook_all_config_cb cb_data;\n+\tstruct hook_all_config_cb cb_data = { 0 };\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *e;\n \n@@ -253,7 +269,7 @@ static void build_hook_config_map(struct repository *r,\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \n-\t/* Parse all configs in one run. */\n+\t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n \t/* Construct the cache from parsed configs. */\n@@ -297,6 +313,8 @@ static void build_hook_config_map(struct repository *r,\n \t\tstrmap_put(&cache->hooks, e->key, hooks);\n \t}\n \n+\tcache->jobs = cb_data.jobs;\n+\n \tstrmap_clear(&cb_data.commands, 1);\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\ndiff --git a/hook.h b/hook.h\nindex 0432df963f..a7eab00480 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -208,6 +208,7 @@ void hook_free(void *p, const char *str UNUSED);\n  */\n struct hook_config_cache {\n \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n+\tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n };\n \n /**\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538269","messageId":"20260309133739.294555-6-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 5/9] hook: mark non-parallelizable hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:35Z","receivedAt":"2026-03-09T13:38:59Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSeveral hooks are known to be inherently non-parallelizable, so initialize\nthem with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides\nany hook.jobs or runtime -j flags.\n\nThese hooks are:\napplypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit,\npost-checkout, and push-to-checkout.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n builtin/am.c                   |  8 +++++---\n builtin/checkout.c             | 19 +++++++++++++------\n builtin/clone.c                |  6 ++++--\n builtin/receive-pack.c         |  3 ++-\n builtin/worktree.c             |  2 +-\n commit.c                       |  2 +-\n t/t1800-hook.sh                | 16 ++++++++++++++++\n 8 files changed, 46 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 45811d1032..d2e4b33240 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -36,6 +36,10 @@ hook.<name>.parallel::\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSome hooks always run sequentially regardless of this setting because\n+\tgit knows they cannot safely be parallelized: `applypatch-msg`,\n+\t`pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`,\n+\t`post-checkout`, and `push-to-checkout`.\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<name>.parallel` set to `true`.\ndiff --git a/builtin/am.c b/builtin/am.c\nindex e0c767e223..45a8e78d0b 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state)\n \n \tassert(state->msg);\n \n-\tif (!state->no_verify)\n-\t\tret = run_hooks_l(the_repository, \"applypatch-msg\",\n-\t\t\t\t  am_path(state, \"final-commit\"), NULL);\n+\tif (!state->no_verify) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\t\tstrvec_push(&opt.args, am_path(state, \"final-commit\"));\n+\t\tret = run_hooks_opt(the_repository, \"applypatch-msg\", &opt);\n+\t}\n \n \tif (!ret) {\n \t\tFREE_AND_NULL(state->msg);\ndiff --git a/builtin/checkout.c b/builtin/checkout.c\nindex 1d1667fa4c..ddbe8474d2 100644\n--- a/builtin/checkout.c\n+++ b/builtin/checkout.c\n@@ -31,6 +31,7 @@\n #include \"resolve-undo.h\"\n #include \"revision.h\"\n #include \"setup.h\"\n+#include \"strvec.h\"\n #include \"submodule.h\"\n #include \"symlinks.h\"\n #include \"trace2.h\"\n@@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info)\n static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit,\n \t\t\t      int changed)\n {\n-\treturn run_hooks_l(the_repository, \"post-checkout\",\n-\t\t\t   oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   changed ? \"1\" : \"0\", NULL);\n-\t/* \"new_commit\" can be NULL when checking out from the index before\n-\t   a commit exists. */\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n+\t/*\n+\t * \"new_commit\" can be NULL when checking out from the index before\n+\t * a commit exists.\n+\t */\n+\tstrvec_pushl(&opt.args,\n+\t\t     oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     changed ? \"1\" : \"0\",\n+\t\t     NULL);\n+\n+\treturn run_hooks_opt(the_repository, \"post-checkout\", &opt);\n }\n \n static int update_some(const struct object_id *oid, struct strbuf *base,\ndiff --git a/builtin/clone.c b/builtin/clone.c\nindex fba3c9c508..d23b0cafcf 100644\n--- a/builtin/clone.c\n+++ b/builtin/clone.c\n@@ -647,6 +647,7 @@ static int checkout(int submodule_progress,\n \tstruct tree *tree;\n \tstruct tree_desc t;\n \tint err = 0;\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \tif (option_no_checkout)\n \t\treturn 0;\n@@ -697,8 +698,9 @@ static int checkout(int submodule_progress,\n \tif (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK))\n \t\tdie(_(\"unable to write new index file\"));\n \n-\terr |= run_hooks_l(the_repository, \"post-checkout\", oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(&oid), \"1\", NULL);\n+\tstrvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(&oid), \"1\", NULL);\n+\terr |= run_hooks_opt(the_repository, \"post-checkout\", &hook_opt);\n \n \tif (!err && (option_recurse_submodules.nr > 0)) {\n \t\tstruct child_process cmd = CHILD_PROCESS_INIT;\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 4b63ccdfa3..37086a41e7 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1463,7 +1463,8 @@ static const char *push_to_checkout(unsigned char *hash,\n \t\t\t\t    struct strvec *env,\n \t\t\t\t    const char *work_tree)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n \topt.invoked_hook = invoked_hook;\n \n \tstrvec_pushf(env, \"GIT_WORK_TREE=%s\", absolute_path(work_tree));\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex bc2d0d645b..d4e7c33205 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname,\n \t * is_junk is cleared, but do return appropriate code when hook fails.\n \t */\n \tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n \t\tstrvec_pushl(&opt.args,\ndiff --git a/commit.c b/commit.c\nindex 0ffdd6679e..a2c4ffaac5 100644\n--- a/commit.c\n+++ b/commit.c\n@@ -1979,7 +1979,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len)\n int run_commit_hook(int editor_is_used, const char *index_file,\n \t\t    int *invoked_hook, const char *name, ...)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \tva_list args;\n \tconst char *arg;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 57733e8a73..dad7583f3a 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -768,4 +768,20 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event pre-commit &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event pre-commit &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.jobs 2 &&\n+\tgit commit --allow-empty -m \"test: verify force-serial on pre-commit\" &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538270","messageId":"20260309133739.294555-7-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 6/9] hook: add -j/--jobs option to git hook run","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:36Z","receivedAt":"2026-03-09T13:39:02Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nExpose the parallel job count as a command-line flag so callers can\nrequest parallelism without relying only on the hook.jobs config.\n\nAdd tests covering serial/parallel execution and TTY behaviour under\n-j1 vs -jN.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  18 +++++-\n builtin/hook.c              |   5 +-\n hook.c                      |  14 ++--\n t/t1800-hook.sh             | 123 ++++++++++++++++++++++++++++++++++--\n 4 files changed, 144 insertions(+), 16 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 4d4e728327..17105cc729 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -8,7 +8,8 @@ git-hook - Run git hooks\n SYNOPSIS\n --------\n [verse]\n-'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n+\t<hook-name> [-- <hook-args>]\n 'git hook' list [-z] [--show-scope] <hook-name>\n \n DESCRIPTION\n@@ -139,6 +140,18 @@ OPTIONS\n \tin parentheses after the friendly name of each configured hook, to show\n \twhere it was defined. Traditional hooks from the hookdir are unaffected.\n \n+-j::\n+--jobs::\n+\tOnly valid for `run`.\n++\n+Specify how many hooks to run simultaneously. If this flag is not specified,\n+the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n+neither is specified, defaults to 1 (serial execution). Some hooks always run\n+sequentially regardless of this flag or the `hook.jobs` config, because git\n+knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`,\n+`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and\n+`push-to-checkout`.\n+\n WRAPPERS\n --------\n \n@@ -161,7 +174,8 @@ running:\n git hook run mywrapper-start-tests \\\n   # providing something to stdin\n   --stdin some-tempfile-123 \\\n-  # execute hooks in serial\n+  # execute multiple hooks in parallel\n+  --jobs 3 \\\n   # plus some arguments of your own...\n   -- \\\n   --testname bar \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex ff446948fa..6ec0319cc5 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -7,7 +7,8 @@\n #include \"parse-options.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n-\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n+\t   \"<hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [-z] [--show-scope] <hook-name>\")\n \n@@ -109,6 +110,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n+\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\ndiff --git a/hook.c b/hook.c\nindex 815b299bf8..299cbf9e97 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -567,15 +567,17 @@ static unsigned int get_hook_jobs(struct repository *r,\n \tif (!options->stdout_to_stderr)\n \t\treturn 1;\n \n-\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n-\tif (options->jobs)\n-\t\treturn options->jobs;\n+\t/* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */\n+\tif (options->jobs == 1)\n+\t\treturn 1;\n \n \t/*\n-\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n-\t * fall back to a direct config lookup (out-of-repo).  Default to 1.\n+\t * Resolve effective job count: -jN (when given) overrides config.\n+\t * Default to 1 when both config an -jN are missing.\n \t */\n-\tif (r && r->gitdir && r->hook_config_cache)\n+\tif (options->jobs > 1)\n+\t\tjobs = options->jobs;\n+\telse if (r && r->gitdir && r->hook_config_cache)\n \t\t/* Use the already-parsed cache (in-repo) */\n \t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n \telse\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex dad7583f3a..fbe8be25c8 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -238,10 +238,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n '\n \n test_hook_tty () {\n-\tcat >expect <<-\\EOF\n-\tSTDOUT TTY\n-\tSTDERR TTY\n-\tEOF\n+\texpect_tty=$1\n+\tshift\n+\n+\tif test \"$expect_tty\" != \"no_tty\"; then\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT TTY\n+\t\tSTDERR TTY\n+\t\tEOF\n+\telse\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT NO TTY\n+\t\tSTDERR NO TTY\n+\t\tEOF\n+\tfi\n \n \ttest_when_finished \"rm -rf repo\" &&\n \tgit init repo &&\n@@ -259,12 +269,21 @@ test_hook_tty () {\n \ttest_cmp expect repo/actual\n }\n \n-test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty hook run pre-commit\n+test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '\n+\t# hooks running sequentially (-j1) are always connected to the tty for\n+\t# optimum real-time performance.\n+\ttest_hook_tty tty hook run -j1 pre-commit\n+'\n+\n+test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '\n+\t# Hooks are not connected to the tty when run in parallel, instead they\n+\t# output to a pipe through which run-command collects and de-interlaces\n+\t# their outputs, which then gets passed either to the tty or a sideband.\n+\ttest_hook_tty no_tty hook run -j2 pre-commit\n '\n \n test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty commit -m\"B.new\"\n+\ttest_hook_tty tty commit -m\"B.new\"\n '\n \n test_expect_success 'git hook list orders by config order' '\n@@ -677,6 +696,96 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'parallel hook output is not interleaved' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo \"Hook 1 Start\"\n+\tsleep 1\n+\techo \"Hook 1 End\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t\t    \"echo \\\"Hook 2 Start\\\"; sleep 2; echo \\\"Hook 2 End\\\"\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.hook-3.event test-hook &&\n+\ttest_config hook.hook-3.command \\\n+\t\t    \"echo \\\"Hook 3 Start\\\"; sleep 3; echo \\\"Hook 3 End\\\"\" &&\n+\ttest_config hook.hook-3.parallel true &&\n+\n+\tgit hook run -j3 test-hook >out 2>err.parallel &&\n+\n+\t# Verify Hook 1 output is grouped\n+\tsed -n \"/Hook 1 Start/,/Hook 1 End/p\" err.parallel >hook1_out &&\n+\ttest_line_count = 2 hook1_out &&\n+\n+\t# Verify Hook 2 output is grouped\n+\tsed -n \"/Hook 2 Start/,/Hook 2 End/p\" err.parallel >hook2_out &&\n+\ttest_line_count = 2 hook2_out &&\n+\n+\t# Verify Hook 3 output is grouped\n+\tsed -n \"/Hook 3 Start/,/Hook 3 End/p\" err.parallel >hook3_out &&\n+\ttest_line_count = 2 hook3_out\n+'\n+\n+test_expect_success 'git hook run -j1 runs hooks in series' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\ttest_config hook.series-1.event \"test-hook\" &&\n+\ttest_config hook.series-1.command \"echo 1\" --add &&\n+\ttest_config hook.series-2.event \"test-hook\" &&\n+\ttest_config hook.series-2.command \"echo 2\" --add &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo 3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tgit hook run -j1 test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook run -j2 runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\tgit hook run -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 is blocked by parallel=false' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# -j2 must not override parallel=false on configured hooks.\n+\tgit hook run -j2 test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_expect_success 'hook.jobs=1 config runs hooks in series' '\n \ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538271","messageId":"20260309133739.294555-5-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 4/9] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:34Z","receivedAt":"2026-03-09T13:39:02Z","isPatch":true,"body":"From: Emily Shaffer <nasamuffin@google.com>\n\nHooks always run in sequential order due to the hardcoded jobs == 1\npassed to run_process_parallel(). Remove that hardcoding to allow\nusers to run hooks in parallel (opt-in).\n\nUsers need to decide which hooks to run in parallel, by specifying\n\"parallel = true\" in the config, because git cannot know if their\nspecific hooks are safe to run or not in parallel (for e.g. two hooks\nmight write to the same file or call the same program).\n\nSome hooks are unsafe to run in parallel by design: these will marked\nin the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL.\n\nThe hook.jobs config specifies the default number of jobs applied to all\nhooks which have parallelism enabled.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  13 +++\n hook.c                         |  66 +++++++++++++--\n hook.h                         |  25 ++++++\n t/t1800-hook.sh                | 142 +++++++++++++++++++++++++++++++++\n 4 files changed, 240 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex b7847f9338..45811d1032 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -23,6 +23,19 @@ hook.<friendly-name>.enabled::\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n \n+hook.<name>.parallel::\n+\tWhether the hook `hook.<name>` may run in parallel with other hooks\n+\tfor the same event. Defaults to `false`. Set to `true` only when the\n+\thook script is safe to run concurrently with other hooks for the same\n+\tevent. If any hook for an event does not have this set to `true`,\n+\tall hooks for that event run sequentially regardless of `hook.jobs`.\n+\tOnly configured (named) hooks need to declare this. Traditional hooks\n+\tfound in the hooks directory do not need to, and run in parallel when\n+\tthe effective job count is greater than 1. See linkgit:git-hook[1].\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n++\n+This setting has no effect unless all configured hooks for the event have\n+`hook.<name>.parallel` set to `true`.\ndiff --git a/hook.c b/hook.c\nindex e6e44a5fcb..815b299bf8 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -120,6 +120,7 @@ struct hook_config_cache_entry {\n \tchar *command;\n \tenum config_scope scope;\n \tint disabled;\n+\tunsigned int parallel:1;\n };\n \n /*\n@@ -127,12 +128,14 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * parallel_hooks: friendly-name to parallel flag.\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tstruct strmap parallel_hooks;\n \tunsigned int jobs;\n };\n \n@@ -223,6 +226,10 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tdefault:\n \t\t\tbreak; /* ignore unrecognised values */\n \t\t}\n+\t} else if (!strcmp(subkey, \"parallel\")) {\n+\t\tint v = git_parse_maybe_bool(value);\n+\t\tif (v >= 0)\n+\t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n \t}\n \n \tfree(hook_name);\n@@ -268,6 +275,7 @@ static void build_hook_config_map(struct repository *r,\n \tstrmap_init(&cb_data.commands);\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n+\tstrmap_init(&cb_data.parallel_hooks);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -285,6 +293,7 @@ static void build_hook_config_map(struct repository *r,\n \t\t\tenum config_scope scope =\n \t\t\t\t(enum config_scope)(uintptr_t)hook_names->items[i].util;\n \t\t\tstruct hook_config_cache_entry *entry;\n+\t\t\tvoid *par = strmap_get(&cb_data.parallel_hooks, hname);\n \t\t\tchar *command;\n \n \t\t\tint is_disabled =\n@@ -307,6 +316,7 @@ static void build_hook_config_map(struct repository *r,\n \t\t\tentry->command = command ? xstrdup(command) : NULL;\n \t\t\tentry->scope = scope;\n \t\t\tentry->disabled = is_disabled;\n+\t\t\tentry->parallel = (int)(uintptr_t)par;\n \t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n@@ -316,6 +326,7 @@ static void build_hook_config_map(struct repository *r,\n \tcache->jobs = cb_data.jobs;\n \n \tstrmap_clear(&cb_data.commands, 1);\n+\tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstring_list_clear(e->value, 0);\n@@ -392,6 +403,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n@@ -541,21 +553,67 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/* Determine how many jobs to use for hook execution. */\n+static unsigned int get_hook_jobs(struct repository *r,\n+\t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  struct string_list *hook_list)\n+{\n+\tunsigned int jobs;\n+\n+\t/*\n+\t * Hooks needing separate output streams must run sequentially. Next\n+\t * commits will add an extension to allow parallelizing these as well.\n+\t */\n+\tif (!options->stdout_to_stderr)\n+\t\treturn 1;\n+\n+\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n+\tif (options->jobs)\n+\t\treturn options->jobs;\n+\n+\t/*\n+\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n+\t * fall back to a direct config lookup (out-of-repo).  Default to 1.\n+\t */\n+\tif (r && r->gitdir && r->hook_config_cache)\n+\t\t/* Use the already-parsed cache (in-repo) */\n+\t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n+\telse\n+\t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n+\t\tjobs = repo_config_get_uint(r, \"hook.jobs\", &jobs) ? 1 : jobs;\n+\n+\t/*\n+\t * Cap to serial any configured hook not marked as parallel = true.\n+\t * This enforces the parallel = false default, even for \"traditional\"\n+\t * hooks from the hookdir which cannot be marked parallel = true.\n+\t */\n+\tfor (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n+\t\t\tjobs = 1;\n+\t}\n+\n+\treturn jobs;\n+}\n+\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n+\tstruct string_list *hook_list = list_hooks(r, hook_name, options);\n \tstruct hook_cb_data cb_data = {\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n+\t\t.hook_command_list = hook_list,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\n \n-\t\t.processes = options->jobs,\n-\t\t.ungroup = options->jobs == 1,\n+\t\t.processes = jobs,\n+\t\t.ungroup = jobs == 1,\n \n \t\t.get_next_task = pick_next_hook,\n \t\t.start_failure = notify_start_failure,\n@@ -571,9 +629,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->path_to_stdin && options->feed_pipe)\n \t\tBUG(\"options path_to_stdin and feed_pipe are mutually exclusive\");\n \n-\tif (!options->jobs)\n-\t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n-\n \t/*\n \t * Ensure cb_data copy and free functions are either provided together,\n \t * or neither one is provided.\n@@ -584,7 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n \tif (!cb_data.hook_command_list->nr) {\n \t\tif (options->error_if_missing)\n \t\t\tret = error(\"cannot find a hook named %s\", hook_name);\ndiff --git a/hook.h b/hook.h\nindex a7eab00480..a0f6a9db47 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -35,6 +35,13 @@ struct hook {\n \t\t} configured;\n \t} u;\n \n+\t/**\n+\t * Whether this hook may run in parallel with other hooks for the same\n+\t * event. Only useful for configured (named) hooks. Traditional hooks\n+\t * always default to 0 (serial). Set via `hook.<name>.parallel = true`.\n+\t */\n+\tunsigned int parallel:1;\n+\n \t/**\n \t * Opaque data pointer used to keep internal state across callback calls.\n \t *\n@@ -73,6 +80,8 @@ struct run_hooks_opt\n \t *\n \t * If > 1, output will be buffered and de-interleaved (ungroup=0).\n \t * If == 1, output will be real-time (ungroup=1).\n+\t * If == 0, the 'hook.jobs' config is used or, if the config is unset,\n+\t * defaults to 1 (serial execution).\n \t */\n \tunsigned int jobs;\n \n@@ -153,7 +162,23 @@ struct run_hooks_opt\n \thook_data_free_fn feed_pipe_cb_data_free;\n };\n \n+/**\n+ * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to\n+ * the 'hook.jobs' config, falling back to serial (1) if unset.\n+ */\n #define RUN_HOOKS_OPT_INIT { \\\n+\t.env = STRVEC_INIT, \\\n+\t.args = STRVEC_INIT, \\\n+\t.stdout_to_stderr = 1, \\\n+\t.jobs = 0, \\\n+}\n+\n+/**\n+ * Initializer for hooks that must always run sequentially regardless of\n+ * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized\n+ * .jobs = 1 is non-overridable.\n+ */\n+#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \\\n \t.env = STRVEC_INIT, \\\n \t.args = STRVEC_INIT, \\\n \t.stdout_to_stderr = 1, \\\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex cbf7d2bf80..57733e8a73 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -21,6 +21,57 @@ setup_hookdir () {\n \ttest_when_finished rm -rf .git/hooks\n }\n \n+# write_sentinel_hook <path> [sentinel]\n+#\n+# Writes a hook that marks itself as started, sleeps for a few seconds, then\n+# marks itself done. The sleep must be long enough that sentinel_detector can\n+# observe <sentinel>.started before <sentinel>.done appears when both hooks\n+# run concurrently in parallel mode.\n+write_sentinel_hook () {\n+\tsentinel=\"${2:-sentinel}\"\n+\twrite_script \"$1\" <<-EOF\n+\ttouch ${sentinel}.started &&\n+\tsleep 2 &&\n+\ttouch ${sentinel}.done\n+\tEOF\n+}\n+\n+# sentinel_detector <sentinel> <output>\n+#\n+# Returns a shell command string suitable for use as hook.<name>.command.\n+# The detector must be registered after the sentinel:\n+# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)\n+#    before the detector starts.\n+# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared\n+#    yet and the detector just sees <sentinel>.started.\n+#\n+# At start, poll until <sentinel>.started exists to absorb startup jitter, then\n+# write to <output>:\n+# 1. 'serial'   if <sentinel>.done exists (sentinel finished before we started),\n+# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),\n+# 3. 'timeout'  if <sentinel>.started never appeared.\n+#\n+# The command ends with ':' so when git appends \"$@\" for hooks that receive\n+# positional arguments (e.g. pre-push), the result ': \"$@\"' is valid shell\n+# rather than a syntax error 'fi \"$@\"'.\n+sentinel_detector () {\n+\tcat <<-EOF\n+\ti=0\n+\twhile ! test -f ${1}.started && test \\$i -lt 10; do\n+\t    sleep 1\n+\t    i=\\$((i+1))\n+\tdone\n+\tif test -f ${1}.done; then\n+\t    echo serial >${2}\n+\telif test -f ${1}.started; then\n+\t    echo parallel >${2}\n+\telse\n+\t    echo timeout >${2}\n+\tfi\n+\t:\n+\tEOF\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n@@ -626,4 +677,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'hook.jobs=1 config runs hooks in series' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\n+\t# Use two configured hooks so the execution order is deterministic:\n+\t# hook-1 (sentinel) is listed before hook-2 (detector), so hook-1\n+\t# always runs first even in serial mode.\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 1 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=true enables parallel execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'one non-parallel hook forces the whole event to run serially' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 has no parallel=true: should force serial for all\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538272","messageId":"20260309133739.294555-8-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 7/9] hook: add per-event jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:37Z","receivedAt":"2026-03-09T13:39:06Z","isPatch":true,"body":"Add a hook.<event>.jobs count config that allows users to override the\nglobal hook.jobs setting for specific hook events.\n\nThis allows finer-grained control over parallelism on a per-event basis.\n\nFor example, to run `post-receive` hooks with up to 4 parallel jobs\nwhile keeping other events at their global default:\n\n[hook]\n    post-receive.jobs = 4\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 19 +++++++++++\n hook.c                         | 47 +++++++++++++++++++++++----\n hook.h                         |  1 +\n t/t1800-hook.sh                | 59 ++++++++++++++++++++++++++++++++++\n 4 files changed, 120 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex d2e4b33240..ca97ea6f1e 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -33,9 +33,28 @@ hook.<name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.jobs::\n+\tSpecifies how many hooks can be run simultaneously for the `<event>`\n+\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n+\tfor this specific event. The same parallelism restrictions apply: this\n+\tsetting has no effect unless all configured hooks for the event have\n+\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\tzero is rejected with a warning. See linkgit:git-hook[1].\n++\n+Note on naming: although this key resembles `hook.<friendly-name>.*`\n+(a per-hook setting), `<event>` must be the event name, not a hook\n+friendly name. The key component is stored literally and looked up by\n+event name at runtime with no translation between the two namespaces.\n+A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n+lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n+`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n+registered for that event. Use `hook.post-receive.jobs` or any other\n+valid event name when setting `hook.<event>.jobs`.\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tgit knows they cannot safely be parallelized: `applypatch-msg`,\n \t`pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`,\ndiff --git a/hook.c b/hook.c\nindex 299cbf9e97..b70c4c15ec 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -129,6 +129,8 @@ struct hook_config_cache_entry {\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n  * parallel_hooks: friendly-name to parallel flag.\n+ * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *,\n+ *             where NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n  */\n struct hook_all_config_cb {\n@@ -136,6 +138,7 @@ struct hook_all_config_cb {\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n \tstruct strmap parallel_hooks;\n+\tstruct strmap event_jobs;\n \tunsigned int jobs;\n };\n \n@@ -230,6 +233,20 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tint v = git_parse_maybe_bool(value);\n \t\tif (v >= 0)\n \t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n+\t} else if (!strcmp(subkey, \"jobs\")) {\n+\t\tunsigned int v;\n+\t\tif (!git_parse_uint(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive integer, ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n+\t\telse if (!v)\n+\t\t\twarning(_(\"hook.%s.jobs must be positive, ignoring: 0\"), hook_name);\n+\t\telse {\n+\t\t\tunsigned int *old;\n+\t\t\tunsigned int *p = xmalloc(sizeof(*p));\n+\t\t\t*p = v;\n+\t\t\told = strmap_put(&data->event_jobs, hook_name, p);\n+\t\t\tfree(old);\n+\t\t}\n \t}\n \n \tfree(hook_name);\n@@ -262,6 +279,7 @@ void hook_cache_clear(struct hook_config_cache *cache)\n \t\tfree(hooks);\n \t}\n \tstrmap_clear(&cache->hooks, 0);\n+\tstrmap_clear(&cache->event_jobs, 1); /* free heap-allocated unsigned int * values */\n }\n \n /* Populate `cache` with the complete hook configuration */\n@@ -276,6 +294,7 @@ static void build_hook_config_map(struct repository *r,\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \tstrmap_init(&cb_data.parallel_hooks);\n+\tstrmap_init(&cb_data.event_jobs);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -324,6 +343,7 @@ static void build_hook_config_map(struct repository *r,\n \t}\n \n \tcache->jobs = cb_data.jobs;\n+\tcache->event_jobs = cb_data.event_jobs;\n \n \tstrmap_clear(&cb_data.commands, 1);\n \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n@@ -556,6 +576,7 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  const char *hook_name,\n \t\t\t\t  struct string_list *hook_list)\n {\n \tunsigned int jobs;\n@@ -572,22 +593,36 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\treturn 1;\n \n \t/*\n-\t * Resolve effective job count: -jN (when given) overrides config.\n-\t * Default to 1 when both config an -jN are missing.\n+\t * Resolve effective job count: -j N (when given) overrides config.\n+\t * hook.<event>.jobs overrides hook.jobs.\n+\t * Unset configs and -jN default to 1.\n \t */\n-\tif (options->jobs > 1)\n+\tif (options->jobs > 1) {\n \t\tjobs = options->jobs;\n-\telse if (r && r->gitdir && r->hook_config_cache)\n+\t} else if (r && r->gitdir && r->hook_config_cache) {\n \t\t/* Use the already-parsed cache (in-repo) */\n+\t\tunsigned int *event_jobs = strmap_get(&r->hook_config_cache->event_jobs,\n+\t\t\t\t\t\t      hook_name);\n \t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n-\telse\n+\t\tif (event_jobs)\n+\t\t\tjobs = *event_jobs;\n+\t} else {\n \t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n+\t\tunsigned int event_jobs;\n+\t\tchar *key;\n \t\tjobs = repo_config_get_uint(r, \"hook.jobs\", &jobs) ? 1 : jobs;\n+\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n+\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n+\t\t\tjobs = event_jobs;\n+\t\tfree(key);\n+\t}\n \n \t/*\n \t * Cap to serial any configured hook not marked as parallel = true.\n \t * This enforces the parallel = false default, even for \"traditional\"\n \t * hooks from the hookdir which cannot be marked parallel = true.\n+\t * The same restriction applies whether jobs came from hook.jobs or\n+\t * hook.<event>.jobs.\n \t */\n \tfor (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {\n \t\tstruct hook *h = hook_list->items[i].util;\n@@ -609,7 +644,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n-\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\ndiff --git a/hook.h b/hook.h\nindex a0f6a9db47..e8603c4370 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -233,6 +233,7 @@ void hook_free(void *p, const char *str UNUSED);\n  */\n struct hook_config_cache {\n \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n+\tstruct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */\n \tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n };\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex fbe8be25c8..195cc5333e 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -893,4 +893,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=1 (serial), but per-event override allows parallel.\n+\ttest_config hook.jobs 1 &&\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=4 allows parallel, but per-event override forces serial.\n+\ttest_config hook.jobs 4 &&\n+\ttest_config hook.test-hook.jobs 1 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# Per-event jobs=2 but no hook has parallel=true: must still run serially.\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538273","messageId":"20260309133739.294555-9-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:38Z","receivedAt":"2026-03-09T13:39:07Z","isPatch":true,"body":"All hooks already redirect stdout to stderr with the exception of\npre-push which has a known user who depends on the separate stdout\nversus stderr outputs (the git-lfs project).\n\nThe pre-push behavior was a surprise which we found out about after\ncausing a regression for git-lfs. Notably, it might not be the only\nexception (it's the one we know about). There might be more.\n\nThis presents a challenge because stdout_to_stderr is required for\nhook parallelization, so run-command can buffer and de-interleave\nthe hook outputs using ungroup=0, when hook.jobs > 1.\n\nIntroduce an extension to enforce consistency: all hooks merge stdout\ninto stderr and can be safely parallelized. This provides a clean\nseparation and avoids breaking existing stdout vs stderr behavior.\n\nWhen this extension is disabled, the `hook.jobs` config has no\neffect for pre-push, to prevent garbled (interleaved) parallel\noutput, so it runs sequentially like before.\n\nAlternatives I've considered to this extension include:\n1. Allowing pre-push to run in parallel with interleaved output.\n2. Always running pre-push sequentially (no parallel jobs for it).\n3. Making users (only git-lfs? maybe more?) fix their hooks to read\n   stderr not stdout.\n\nOut of all these alternatives, I think this extension is the most\nreasonable compromise, to not break existing users, allow pre-push\nparallel jobs for those who need it (with correct outputs) and also\nfuture-proofing in case there are any more exceptions to be added.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/extensions.adoc | 12 +++++++++\n Documentation/config/hook.adoc       |  3 +++\n repository.c                         |  1 +\n repository.h                         |  1 +\n setup.c                              |  7 ++++++\n setup.h                              |  1 +\n t/t1800-hook.sh                      | 37 ++++++++++++++++++++++++++++\n transport.c                          |  7 ++----\n 8 files changed, 64 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc\nindex be6678bb5b..1a189ff045 100644\n--- a/Documentation/config/extensions.adoc\n+++ b/Documentation/config/extensions.adoc\n@@ -116,6 +116,18 @@ The extension can be enabled automatically for new repositories by setting\n `init.defaultSubmodulePathConfig` to `true`, for example by running\n `git config --global init.defaultSubmodulePathConfig true`.\n \n+hookStdoutToStderr:::\n+\tIf enabled, the stdout of all hooks is redirected to stderr. This\n+\tenforces consistency, since by default most hooks already behave\n+\tthis way, with pre-push being the only known exception.\n++\n+This is useful for parallel hook execution (see the `hook.jobs` config in\n+linkgit:git-config[1]), as it allows the output of multiple hooks running\n+in parallel to be grouped (de-interleaved) correctly.\n++\n+Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push\n+hooks, which will always be run sequentially.\n+\n worktreeConfig:::\n \tIf enabled, then worktrees will load config settings from the\n \t`$GIT_DIR/config.worktree` file in addition to the\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex ca97ea6f1e..5e74fdce65 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -62,3 +62,6 @@ hook.jobs::\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<name>.parallel` set to `true`.\n++\n+This has no effect for hooks requiring separate output streams (like `pre-push`)\n+unless `extensions.hookStdoutToStderr` is enabled.\ndiff --git a/repository.c b/repository.c\nindex fb4356ca55..aecd1cbb0b 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -307,6 +307,7 @@ int repo_init(struct repository *repo,\n \trepo->repository_format_relative_worktrees = format.relative_worktrees;\n \trepo->repository_format_precious_objects = format.precious_objects;\n \trepo->repository_format_submodule_path_cfg = format.submodule_path_cfg;\n+\trepo->repository_format_hook_stdout_to_stderr = format.hook_stdout_to_stderr;\n \n \t/* take ownership of format.partial_clone */\n \trepo->repository_format_partial_clone = format.partial_clone;\ndiff --git a/repository.h b/repository.h\nindex 3fd73d2c54..daa7ae7d16 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -182,6 +182,7 @@ struct repository {\n \tint repository_format_relative_worktrees;\n \tint repository_format_precious_objects;\n \tint repository_format_submodule_path_cfg;\n+\tint repository_format_hook_stdout_to_stderr;\n \n \t/* Indicate if a repository has a different 'commondir' from 'gitdir' */\n \tunsigned different_commondir:1;\ndiff --git a/setup.c b/setup.c\nindex 393b970ae4..b12c5ca313 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -710,6 +710,9 @@ static enum extension_result handle_extension(const char *var,\n \t} else if (!strcmp(ext, \"submodulepathconfig\")) {\n \t\tdata->submodule_path_cfg = git_config_bool(var, value);\n \t\treturn EXTENSION_OK;\n+\t} else if (!strcmp(ext, \"hookstdouttostderr\")) {\n+\t\tdata->hook_stdout_to_stderr = git_config_bool(var, value);\n+\t\treturn EXTENSION_OK;\n \t}\n \treturn EXTENSION_UNKNOWN;\n }\n@@ -1976,6 +1979,8 @@ const char *setup_git_directory_gently(int *nongit_ok)\n \t\t\t\trepo_fmt.relative_worktrees;\n \t\t\tthe_repository->repository_format_submodule_path_cfg =\n \t\t\t\trepo_fmt.submodule_path_cfg;\n+\t\t\tthe_repository->repository_format_hook_stdout_to_stderr =\n+\t\t\t\trepo_fmt.hook_stdout_to_stderr;\n \t\t\t/* take ownership of repo_fmt.partial_clone */\n \t\t\tthe_repository->repository_format_partial_clone =\n \t\t\t\trepo_fmt.partial_clone;\n@@ -2098,6 +2103,8 @@ void check_repository_format(struct repository_format *fmt)\n \t\tfmt->submodule_path_cfg;\n \tthe_repository->repository_format_relative_worktrees =\n \t\tfmt->relative_worktrees;\n+\tthe_repository->repository_format_hook_stdout_to_stderr =\n+\t\tfmt->hook_stdout_to_stderr;\n \tthe_repository->repository_format_partial_clone =\n \t\txstrdup_or_null(fmt->partial_clone);\n \tclear_repository_format(&repo_fmt);\ndiff --git a/setup.h b/setup.h\nindex bcb16b0b4a..6d7ddb3d02 100644\n--- a/setup.h\n+++ b/setup.h\n@@ -168,6 +168,7 @@ struct repository_format {\n \tint worktree_config;\n \tint relative_worktrees;\n \tint submodule_path_cfg;\n+\tint hook_stdout_to_stderr;\n \tint is_bare;\n \tint hash_algo;\n \tint compat_hash_algo;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 195cc5333e..8f2f5e40bc 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -605,6 +605,43 @@ test_expect_success 'client hooks: pre-push expects separate stdout and stderr'\n \tcheck_stdout_separate_from_stderr pre-push\n '\n \n+test_expect_success 'client hooks: extension makes pre-push merge stdout to stderr' '\n+\ttest_when_finished \"rm -rf remote2 stdout.actual stderr.actual\" &&\n+\tgit init --bare remote2 &&\n+\tgit remote add origin2 remote2 &&\n+\ttest_commit B &&\n+\tgit config set core.repositoryformatversion 1 &&\n+\ttest_config extensions.hookStdoutToStderr true &&\n+\tsetup_hooks pre-push &&\n+\tgit push origin2 HEAD:main >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push\n+'\n+\n+test_expect_success 'client hooks: pre-push defaults to serial execution' '\n+\ttest_when_finished \"rm -rf remote-serial repo-serial\" &&\n+\tgit init --bare remote-serial &&\n+\tgit init repo-serial &&\n+\tgit -C repo-serial remote add origin ../remote-serial &&\n+\ttest_commit -C repo-serial A &&\n+\n+\t# Setup 2 pre-push hooks; no parallel=true so they must run serially.\n+\t# Use sentinel/detector pattern: hook-1 (sentinel, configured) runs first\n+\t# because configured hooks precede traditional hooks in list order; hook-2\n+\t# (detector) runs second and checks whether hook-1 has finished.\n+\tgit -C repo-serial config hook.hook-1.event pre-push &&\n+\tgit -C repo-serial config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\tgit -C repo-serial config hook.hook-2.event pre-push &&\n+\tgit -C repo-serial config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\tgit -C repo-serial config hook.jobs 2 &&\n+\n+\tgit -C repo-serial push origin HEAD >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect repo-serial/hook.order\n+'\n+\n test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' '\n \thooks=\"pre-commit prepare-commit-msg \\\n \t\tcommit-msg post-commit \\\ndiff --git a/transport.c b/transport.c\nindex 56a4015389..e25a94dc92 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1390,11 +1390,8 @@ static int run_pre_push_hook(struct transport *transport,\n \topt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;\n \topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n \n-\t/*\n-\t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n-\t * them to keep backwards compatibility with existing hooks.\n-\t */\n-\topt.stdout_to_stderr = 0;\n+\t/* merge stdout to stderr only when extensions.hookStdoutToStderr is enabled */\n+\topt.stdout_to_stderr = the_repository->repository_format_hook_stdout_to_stderr;\n \n \tret = run_hooks_opt(the_repository, \"pre-push\", &opt);\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"538274","messageId":"20260309133739.294555-10-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260309133739.294555-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 9/9] hook: allow runtime enabling extensions.hookStdoutToStderr","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-09T13:37:39Z","receivedAt":"2026-03-09T13:39:09Z","isPatch":true,"body":"Add a new config `hook.forceStdoutToStderr` which allows enabling\nextensions.hookStdoutToStderr by default at runtime, both for new\nand existing repositories.\n\nThis makes it easier for users to enable hook parallelization for\nhooks like pre-push by enforcing output consistency. See previous\ncommit for a more in-depth explanation & alternatives considered.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/extensions.adoc |  3 ++\n Documentation/config/hook.adoc       |  6 +++\n hook.c                               | 28 ++++++++++-\n hook.h                               |  1 +\n setup.c                              | 10 ++++\n t/t1800-hook.sh                      | 69 ++++++++++++++++++++++++++++\n 6 files changed, 115 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc\nindex 1a189ff045..dd0a59de4d 100644\n--- a/Documentation/config/extensions.adoc\n+++ b/Documentation/config/extensions.adoc\n@@ -127,6 +127,9 @@ in parallel to be grouped (de-interleaved) correctly.\n +\n Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push\n hooks, which will always be run sequentially.\n++\n+The extension can also be enabled by setting `hook.forceStdoutToStderr`\n+to `true` in the global configuration.\n \n worktreeConfig:::\n \tIf enabled, then worktrees will load config settings from the\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 5e74fdce65..1e88c0fb25 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -65,3 +65,9 @@ This setting has no effect unless all configured hooks for the event have\n +\n This has no effect for hooks requiring separate output streams (like `pre-push`)\n unless `extensions.hookStdoutToStderr` is enabled.\n+\n+hook.forceStdoutToStderr::\n+\tA boolean that enables the `extensions.hookStdoutToStderr` behavior\n+\t(merging stdout to stderr for all hooks) globally. This effectively\n+\tforces all hooks to behave as if the extension was enabled, allowing\n+\tparallel execution for hooks like `pre-push`.\ndiff --git a/hook.c b/hook.c\nindex b70c4c15ec..7bf7831645 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -132,6 +132,7 @@ struct hook_config_cache_entry {\n  * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *,\n  *             where NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n+ * force_stdout_to_stderr: value of hook.forceStdoutToStderr. Defaults to 0.\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n@@ -140,6 +141,7 @@ struct hook_all_config_cb {\n \tstruct strmap parallel_hooks;\n \tstruct strmap event_jobs;\n \tunsigned int jobs;\n+\tint force_stdout_to_stderr;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -165,6 +167,10 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n \t\t\telse\n \t\t\t\tdata->jobs = v;\n+\t\t} else if (!strcmp(subkey, \"forcestdouttostderr\") && value) {\n+\t\t\tint v = git_parse_maybe_bool(value);\n+\t\t\tif (v >= 0)\n+\t\t\t\tdata->force_stdout_to_stderr = v;\n \t\t}\n \t\treturn 0;\n \t}\n@@ -344,6 +350,7 @@ static void build_hook_config_map(struct repository *r,\n \n \tcache->jobs = cb_data.jobs;\n \tcache->event_jobs = cb_data.event_jobs;\n+\tcache->force_stdout_to_stderr = cb_data.force_stdout_to_stderr;\n \n \tstrmap_clear(&cb_data.commands, 1);\n \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n@@ -573,6 +580,20 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+static void hook_force_apply_stdout_to_stderr(struct repository *r,\n+\t\t\t\t\t      struct run_hooks_opt *options)\n+{\n+\tint force = 0;\n+\n+\tif (r && r->gitdir && r->hook_config_cache)\n+\t\tforce = r->hook_config_cache->force_stdout_to_stderr;\n+\telse\n+\t\trepo_config_get_bool(r, \"hook.forceStdoutToStderr\", &force);\n+\n+\tif (force)\n+\t\toptions->stdout_to_stderr = 1;\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -582,9 +603,12 @@ static unsigned int get_hook_jobs(struct repository *r,\n \tunsigned int jobs;\n \n \t/*\n-\t * Hooks needing separate output streams must run sequentially. Next\n-\t * commits will add an extension to allow parallelizing these as well.\n+\t * Apply hook.forceStdoutToStderr before anything else: it affects\n+\t * whether we can run in parallel or not.\n \t */\n+\thook_force_apply_stdout_to_stderr(r, options);\n+\n+\t/* Hooks needing separate output streams must run sequentially. */\n \tif (!options->stdout_to_stderr)\n \t\treturn 1;\n \ndiff --git a/hook.h b/hook.h\nindex e8603c4370..d4da78f277 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -235,6 +235,7 @@ struct hook_config_cache {\n \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n \tstruct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */\n \tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n+\tint force_stdout_to_stderr; /* hook.forceStdoutToStderr config value */\n };\n \n /**\ndiff --git a/setup.c b/setup.c\nindex b12c5ca313..bc4e95b522 100644\n--- a/setup.c\n+++ b/setup.c\n@@ -2362,6 +2362,7 @@ void initialize_repository_version(int hash_algo,\n \tstruct strbuf repo_version = STRBUF_INIT;\n \tint target_version = GIT_REPO_VERSION;\n \tint default_submodule_path_config = 0;\n+\tint default_hook_stdout_to_stderr = 0;\n \n \t/*\n \t * Note that we initialize the repository version to 1 when the ref\n@@ -2419,6 +2420,15 @@ void initialize_repository_version(int hash_algo,\n \t\trepo_config_set(the_repository, \"extensions.submodulepathconfig\", \"true\");\n \t}\n \n+\trepo_config_get_bool(the_repository, \"hook.forceStdoutToStderr\",\n+\t\t\t     &default_hook_stdout_to_stderr);\n+\tif (default_hook_stdout_to_stderr) {\n+\t\t/* extensions.hookstdouttostderr requires at least version 1 */\n+\t\tif (target_version == 0)\n+\t\t\ttarget_version = 1;\n+\t\trepo_config_set(the_repository, \"extensions.hookstdouttostderr\", \"true\");\n+\t}\n+\n \tstrbuf_addf(&repo_version, \"%d\", target_version);\n \trepo_config_set(the_repository, \"core.repositoryformatversion\", repo_version.buf);\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 8f2f5e40bc..4fdd06072d 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -989,4 +989,73 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success '`git init` respects hook.forceStdoutToStderr' '\n+\ttest_when_finished \"rm -rf repo-init\" &&\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tgit init repo-init &&\n+\tgit -C repo-init config extensions.hookStdoutToStderr >actual &&\n+\techo true >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '`git init` does not set extensions.hookStdoutToStderr by default' '\n+\ttest_when_finished \"rm -rf upstream\" &&\n+\tgit init upstream &&\n+\ttest_must_fail git -C upstream config extensions.hookStdoutToStderr\n+'\n+\n+test_expect_success '`git clone` does not set extensions.hookStdoutToStderr by default' '\n+\ttest_when_finished \"rm -rf upstream repo-clone-no-ext\" &&\n+\tgit init upstream &&\n+\tgit clone upstream repo-clone-no-ext &&\n+\ttest_must_fail git -C repo-clone-no-ext config extensions.hookStdoutToStderr\n+'\n+\n+test_expect_success '`git clone` respects hook.forceStdoutToStderr' '\n+\ttest_when_finished \"rm -rf upstream repo-clone\" &&\n+\tgit init upstream &&\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tgit clone upstream repo-clone &&\n+\tgit -C repo-clone config extensions.hookStdoutToStderr >actual &&\n+\techo true >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'hook.forceStdoutToStderr enables extension for existing repos' '\n+\ttest_when_finished \"rm -rf remote-repo existing-repo\" &&\n+\tgit init --bare remote-repo &&\n+\tgit init -b main existing-repo &&\n+\t# No local extensions.hookStdoutToStderr config set here\n+\t# so global config should apply\n+\ttest_config_global hook.forceStdoutToStderr true &&\n+\tcd existing-repo &&\n+\ttest_commit A &&\n+\tgit remote add origin ../remote-repo &&\n+\tsetup_hooks pre-push &&\n+\tgit push origin HEAD >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push &&\n+\tcd ..\n+'\n+\n+test_expect_success 'hook.forceStdoutToStderr enables pre-push parallel runs' '\n+\ttest_when_finished \"rm -rf repo-parallel remote-parallel\" &&\n+\tgit init --bare remote-parallel &&\n+\tgit init repo-parallel &&\n+\tgit -C repo-parallel remote add origin ../remote-parallel &&\n+\ttest_commit -C repo-parallel A &&\n+\n+\twrite_sentinel_hook repo-parallel/.git/hooks/pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.event pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\tgit -C repo-parallel config hook.hook-2.parallel true &&\n+\n+\tgit -C repo-parallel config hook.jobs 2 &&\n+\tgit -C repo-parallel config hook.forceStdoutToStderr true &&\n+\n+\tgit -C repo-parallel push origin HEAD >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect repo-parallel/hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539010","messageId":"xmqqbjgpvgh5.fsf@gitster.g","threadId":"64919","inReplyTo":"20260309133739.294555-2-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-15T04:55:50Z","receivedAt":"2026-03-15T04:55:53Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> There is an old pre-existing memory leak in repo_init() due to failing\n> to call clear_repository_format() in the error case.\n>\n> It went undetected because a specific bug is required to trigger it:\n> enable a v1 extension in a repository with format v0. Obviously this\n> can only happen in a development environment, so it does not trigger\n> in normal usage, however the memleak is real and needs fixing.\n>\n> Fix it by also calling clear_repository_format() in the error case.\n>\n> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n> ---\n>  repository.c | 1 +\n>  1 file changed, 1 insertion(+)\n>\n> diff --git a/repository.c b/repository.c\n> index 0b8f7ec200..fb4356ca55 100644\n> --- a/repository.c\n> +++ b/repository.c\n> @@ -322,6 +322,7 @@ int repo_init(struct repository *repo,\n>  \treturn 0;\n>  \n>  error:\n> +\tclear_repository_format(&format);\n>  \trepo_clear(repo);\n>  \treturn -1;\n>  }\n\nIt is arguable if the fault is on the caller, or the callee which is\nread_and_verify_repository_format() that answers the caller \"hey,\nyou do not have a valid format to work with\" without releasing the\nthing *it* sample-read.  As you said, this only triggers in a broken\nenvironment, and there is just a single caller-callee involved, so I\nam fine fixing it on the caller side like this patch does.\n\nThanks.\n"},{"id":"539011","messageId":"xmqq7brdvg0h.fsf@gitster.g","threadId":"64919","inReplyTo":"20260309133739.294555-2-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-15T05:05:50Z","receivedAt":"2026-03-15T05:05:52Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> There is an old pre-existing memory leak in repo_init() due to failing\n> to call clear_repository_format() in the error case.\n>\n> It went undetected because a specific bug is required to trigger it:\n> enable a v1 extension in a repository with format v0. Obviously this\n> can only happen in a development environment, so it does not trigger\n> in normal usage, however the memleak is real and needs fixing.\n>\n> Fix it by also calling clear_repository_format() in the error case.\n>\n> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n> ---\n>  repository.c | 1 +\n>  1 file changed, 1 insertion(+)\n>\n> diff --git a/repository.c b/repository.c\n> index 0b8f7ec200..fb4356ca55 100644\n> --- a/repository.c\n> +++ b/repository.c\n> @@ -322,6 +322,7 @@ int repo_init(struct repository *repo,\n>  \treturn 0;\n>  \n>  error:\n> +\tclear_repository_format(&format);\n>  \trepo_clear(repo);\n>  \treturn -1;\n>  }\n\nIt is arguable if the fault is on the caller, or the callee which is\nread_and_verify_repository_format() that answers the caller \"hey,\nyou do not have a valid format to work with\" without releasing the\nthing *it* sample-read.  As you said, this only triggers in a broken\nenvironment, and there is just a single caller-callee involved, so I\nam fine fixing it on the caller side like this patch does.\n\nThanks.\n"},{"id":"539036","messageId":"xmqqtsuhrryy.fsf@gitster.g","threadId":"64919","inReplyTo":"20260309133739.294555-4-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 3/9] hook: parse the hook.jobs config","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-15T16:13:25Z","receivedAt":"2026-03-15T16:13:28Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> The hook.jobs config is a global way to set hook parallelization for\n> all hooks, in the sense that it is not per-event nor per-hook.\n>\n> Finer-grained configs will be added in later commits which can override\n> it, for e.g. via a per-event type job options. Next commits will also\n> add to this item's documentation.\n>\n> Parse hook.jobs config key in hook_config_lookup_all() and store its\n> value in hook_all_config_cb.jobs, then transfer it into\n> hook_config_cache.jobs after the config pass completes.\n>\n> This is mostly plumbing and the cached value is not yet used.\n>\n> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n> ---\n>  Documentation/config/hook.adoc |  4 ++++\n>  hook.c                         | 22 ++++++++++++++++++++--\n>  hook.h                         |  1 +\n>  3 files changed, 25 insertions(+), 2 deletions(-)\n\nOK.  The previous step is a good preliminary step to prepare for\nthis.\n"},{"id":"539046","messageId":"xmqqjyvcstvi.fsf@gitster.g","threadId":"64919","inReplyTo":"20260309133739.294555-5-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 4/9] hook: allow parallel hook execution","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-15T20:46:57Z","receivedAt":"2026-03-15T20:47:00Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> +hook.<name>.parallel::\n> +\tWhether the hook `hook.<name>` may run in parallel with other hooks\n> +\tfor the same event. Defaults to `false`. Set to `true` only when the\n> +\thook script is safe to run concurrently with other hooks for the same\n> +\tevent. If any hook for an event does not have this set to `true`,\n> +\tall hooks for that event run sequentially regardless of `hook.jobs`.\n\nThis is very conservative and safe default.\n\n> @@ -307,6 +316,7 @@ static void build_hook_config_map(struct repository *r,\n>  \t\t\tentry->command = command ? xstrdup(command) : NULL;\n>  \t\t\tentry->scope = scope;\n>  \t\t\tentry->disabled = is_disabled;\n> +\t\t\tentry->parallel = (int)(uintptr_t)par;\n\nHmm.  The source \"par\" is (void *) and the destination .parallel\nmember is a single bit, so would this\n\n\t\t\tentry->parallel = !!par;\n\nbe the same?  A cast first to uintptr_t, presumably not to lose\nbits, and then casting it down to potentially narrower int made me\nwonder what else is going on here that is tricky.\n\n> +/* Determine how many jobs to use for hook execution. */\n> +static unsigned int get_hook_jobs(struct repository *r,\n> +\t\t\t\t  struct run_hooks_opt *options,\n> +\t\t\t\t  struct string_list *hook_list)\n> +{\n> +\tunsigned int jobs;\n> +\n> +\t/*\n> +\t * Hooks needing separate output streams must run sequentially. Next\n> +\t * commits will add an extension to allow parallelizing these as well.\n> +\t */\n> +\tif (!options->stdout_to_stderr)\n> +\t\treturn 1;\n> +\n> +\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n> +\tif (options->jobs)\n> +\t\treturn options->jobs;\n\nThis could be risky for hooks that claim they do not want to run\nwith others at the same time, but the CLI user ought to know what\nthey are using, so this override is very much appreciated.  After\nall, the override may be serializing an overly optimisitic set of\nhooks that want to run in parallel to avoid interaction between\nthem.\n\n> +\t/*\n> +\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n> +\t * fall back to a direct config lookup (out-of-repo).  Default to 1.\n> +\t */\n> +\tif (r && r->gitdir && r->hook_config_cache)\n> +\t\t/* Use the already-parsed cache (in-repo) */\n> +\t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n> +\telse\n> +\t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n> +\t\tjobs = repo_config_get_uint(r, \"hook.jobs\", &jobs) ? 1 : jobs;\n> +\n> +\t/*\n> +\t * Cap to serial any configured hook not marked as parallel = true.\n> +\t * This enforces the parallel = false default, even for \"traditional\"\n> +\t * hooks from the hookdir which cannot be marked parallel = true.\n> +\t */\n> +\tfor (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {\n> +\t\tstruct hook *h = hook_list->items[i].util;\n> +\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n> +\t\t\tjobs = 1;\n> +\t}\n\nLosing \"jobs > 1 &&\" from the termination condition and instead\nexplicitly \"break;\" out when we demote jobs to 1 would be easier to\nread, even though it would spend two more lines, i.e.,\n\n\tfor (size_t i = 0; i < hook_list->nr; i++) {\n        \tstruct hook *h = ...;\n\t\tif (...) {\n\t\t\tjobs = 1;\n\t\t\tbreak;\n\t\t}\n\t}\n\n\nOther than that, very cleanly written.\n\nThanks.\n"},{"id":"539047","messageId":"xmqqcy14stf7.fsf@gitster.g","threadId":"64919","inReplyTo":"20260309133739.294555-6-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 5/9] hook: mark non-parallelizable hooks","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-15T20:56:44Z","receivedAt":"2026-03-15T20:56:47Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n>  hook.jobs::\n>  \tSpecifies how many hooks can be run simultaneously during parallelized\n>  \thook execution. If unspecified, defaults to 1 (serial execution).\n> +\tSome hooks always run sequentially regardless of this setting because\n> +\tgit knows they cannot safely be parallelized: `applypatch-msg`,\n> +\t`pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`,\n> +\t`post-checkout`, and `push-to-checkout`.\n\nIf there is a simple rule that can be used to decide hooks with what\ncharacteristics can and cannot be run in parallel, near the \"because\ngit knows\" sentence is where we want to write it down.  It would\nhelp new developers decide if their newly invented hook should be\nforced serial execution.\n\nFor example, applypatch-msg is given a file and is allowed to modify\nthe file (perhaps reformat or typofix), so two of them competing to\nedit that single file would be a nonsense.  Letting them edit the\nfile one after the other would make much more sense.  So one of the\nrules may be \"a hook that is given a file and expected to edit it\".\nother two hooks with -msg suffix may fall into the same category.\nWhat are the rules behind the decision for others?  Are they also\nexplained with simple rules?\n\n"},{"id":"539048","messageId":"xmqq7brcst9o.fsf@gitster.g","threadId":"64919","inReplyTo":"20260309133739.294555-7-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 6/9] hook: add -j/--jobs option to git hook run","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-15T21:00:03Z","receivedAt":"2026-03-15T21:00:10Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> diff --git a/hook.c b/hook.c\n> index 815b299bf8..299cbf9e97 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -567,15 +567,17 @@ static unsigned int get_hook_jobs(struct repository *r,\n>  \tif (!options->stdout_to_stderr)\n>  \t\treturn 1;\n>  \n> -\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n> -\tif (options->jobs)\n> -\t\treturn options->jobs;\n> +\t/* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */\n> +\tif (options->jobs == 1)\n> +\t\treturn 1;\n\nHmph, puzzled.  \n\nShouldn't just -j1 but -j12 from CLI also trump configured\nparallelism?  Which was what the code before this step already did,\nno?\n\n>  \t/*\n> +\t * Resolve effective job count: -jN (when given) overrides config.\n> +\t * Default to 1 when both config an -jN are missing.\n>  \t */\n> -\tif (r && r->gitdir && r->hook_config_cache)\n> +\tif (options->jobs > 1)\n> +\t\tjobs = options->jobs;\n> +\telse if (r && r->gitdir && r->hook_config_cache)\n>  \t\t/* Use the already-parsed cache (in-repo) */\n>  \t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n>  \telse\n\n"},{"id":"539144","messageId":"xmqq341zpqhv.fsf@gitster.g","threadId":"64919","inReplyTo":"20260309133739.294555-8-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 7/9] hook: add per-event jobs config","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-16T18:40:28Z","receivedAt":"2026-03-16T18:40:32Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> +hook.<event>.jobs::\n> +\tSpecifies how many hooks can be run simultaneously for the `<event>`\n> +\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n> +\tfor this specific event. The same parallelism restrictions apply: this\n> +\tsetting has no effect unless all configured hooks for the event have\n> +\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n> +\tzero is rejected with a warning. See linkgit:git-hook[1].\n> ++\n> +Note on naming: although this key resembles `hook.<friendly-name>.*`\n> +(a per-hook setting), `<event>` must be the event name, not a hook\n> +friendly name. The key component is stored literally and looked up by\n> +event name at runtime with no translation between the two namespaces.\n> +A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n> +lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n> +`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n> +registered for that event. Use `hook.post-receive.jobs` or any other\n> +valid event name when setting `hook.<event>.jobs`.\n\nThis design is unfortunate but cannot be avoided, as we do not want\ntwo hooks (i.e., two different names) that react to a single event\nspecify .jobs value differently.  Naturally, we need to worry about\nwhat happens when somebody gives the name \"foo\" to their hook that\nreacts to \"foo\" event, but that would probably be benign if there is\nno other hook that reacts to \"foo\" event.  I also wonder if we want\nto sanity check and complain upon seeing hook.foo.jobs set when\n\"foo\" is not a known event type.\n\nPerhaps anything that appears with one of the .command, .event, or\n.parallel are likely to be <friendly-name>, so having .jobs under\nsuch configuration key is safe to flag as a mistake, or something?\n\nA careful reader who is reading this message from the sideline may\nnotice that I specifically omitted .enabled from the above \"clues\nfor friendly-name key\".  I think hook.<event>.enabled that acts as a\nmaster switch to prevents all hooks from firing for a particular\nevent (when set to 'false') may be something people eventually want,\nin addition to per-hook command hook.<friendly-name>.enabled switch\nthat can override it (or do we want to forbid overriding it?  I\ndunno)..\n\nOther than that, looking quite well done.\n\nThanks.\n"},{"id":"539145","messageId":"xmqqy0jrobqn.fsf@gitster.g","threadId":"64919","inReplyTo":"20260309133739.294555-9-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-16T18:44:32Z","receivedAt":"2026-03-16T18:44:35Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> All hooks already redirect stdout to stderr with the exception of\n> pre-push which has a known user who depends on the separate stdout\n> versus stderr outputs (the git-lfs project).\n>\n> The pre-push behavior was a surprise which we found out about after\n> causing a regression for git-lfs. Notably, it might not be the only\n> exception (it's the one we know about). There might be more.\n>\n> This presents a challenge because stdout_to_stderr is required for\n> hook parallelization, so run-command can buffer and de-interleave\n> the hook outputs using ungroup=0, when hook.jobs > 1.\n>\n> Introduce an extension to enforce consistency: all hooks merge stdout\n> into stderr and can be safely parallelized. This provides a clean\n> separation and avoids breaking existing stdout vs stderr behavior.\n>\n> When this extension is disabled, the `hook.jobs` config has no\n> effect for pre-push, to prevent garbled (interleaved) parallel\n> output, so it runs sequentially like before.\n>\n> Alternatives I've considered to this extension include:\n> 1. Allowing pre-push to run in parallel with interleaved output.\n> 2. Always running pre-push sequentially (no parallel jobs for it).\n> 3. Making users (only git-lfs? maybe more?) fix their hooks to read\n>    stderr not stdout.\n>\n> Out of all these alternatives, I think this extension is the most\n> reasonable compromise, to not break existing users, allow pre-push\n> parallel jobs for those who need it (with correct outputs) and also\n> future-proofing in case there are any more exceptions to be added.\n\nHmph, I am a bit surprised that this is not hook.<name>.stdoutToStderr\ncontrolled per hook process.\n\nIf we already have consensus that giving output to stdout is a\nhistorical wart that we would rather want to fix, then this\nconfiguration is probably good enough.  It is certainly a much\nsimpler approach, and there is no need to make finer-grained\ncustomization available when nobody wants it ;-).\n\nWe may also want to consider \"fixing\" it at Git 3.0 boundary, if\nthat is the case, though?  I dunno.  I weren't following the\ndiscussion closely enough to tell myself.\n\nThanks.\n\n"},{"id":"539303","messageId":"871phh2ez4.fsf@gentoo.mail-host-address-is-not-set","threadId":"64919","inReplyTo":"xmqqjyvcstvi.fsf@gitster.g","subject":"Re: [PATCH v3 4/9] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-18T18:02:23Z","receivedAt":"2026-03-18T18:03:00Z","isPatch":true,"body":"On Sun, 15 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> +hook.<name>.parallel::\n>> +\tWhether the hook `hook.<name>` may run in parallel with other hooks\n>> +\tfor the same event. Defaults to `false`. Set to `true` only when the\n>> +\thook script is safe to run concurrently with other hooks for the same\n>> +\tevent. If any hook for an event does not have this set to `true`,\n>> +\tall hooks for that event run sequentially regardless of `hook.jobs`.\n>\n> This is very conservative and safe default.\n>\n>> @@ -307,6 +316,7 @@ static void build_hook_config_map(struct repository *r,\n>>  \t\t\tentry->command = command ? xstrdup(command) : NULL;\n>>  \t\t\tentry->scope = scope;\n>>  \t\t\tentry->disabled = is_disabled;\n>> +\t\t\tentry->parallel = (int)(uintptr_t)par;\n>\n> Hmm.  The source \"par\" is (void *) and the destination .parallel\n> member is a single bit, so would this\n>\n> \t\t\tentry->parallel = !!par;\n>\n> be the same?  A cast first to uintptr_t, presumably not to lose\n> bits, and then casting it down to potentially narrower int made me\n> wonder what else is going on here that is tricky.\n\nYes, I will use the double negation here on the re-roll, it's the same\nand much cleaner (thank you), since it's a simple on/off toggle.\n\nit'sAs you saw on other series, double negation just doesn't occur to me. :)\n\n>> +/* Determine how many jobs to use for hook execution. */\n>> +static unsigned int get_hook_jobs(struct repository *r,\n>> +\t\t\t\t  struct run_hooks_opt *options,\n>> +\t\t\t\t  struct string_list *hook_list)\n>> +{\n>> +\tunsigned int jobs;\n>> +\n>> +\t/*\n>> +\t * Hooks needing separate output streams must run sequentially. Next\n>> +\t * commits will add an extension to allow parallelizing these as well.\n>> +\t */\n>> +\tif (!options->stdout_to_stderr)\n>> +\t\treturn 1;\n>> +\n>> +\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n>> +\tif (options->jobs)\n>> +\t\treturn options->jobs;\n>\n> This could be risky for hooks that claim they do not want to run\n> with others at the same time, but the CLI user ought to know what\n> they are using, so this override is very much appreciated.  After\n> all, the override may be serializing an overly optimisitic set of\n> hooks that want to run in parallel to avoid interaction between\n> them.\n\nYes, that was my reasoning as well.\n\nI'll expand the comment to explain a bit better why we're doing it.\n\n>> +\t/*\n>> +\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n>> +\t * fall back to a direct config lookup (out-of-repo).  Default to 1.\n>> +\t */\n>> +\tif (r && r->gitdir && r->hook_config_cache)\n>> +\t\t/* Use the already-parsed cache (in-repo) */\n>> +\t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n>> +\telse\n>> +\t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n>> +\t\tjobs = repo_config_get_uint(r, \"hook.jobs\", &jobs) ? 1 : jobs;\n>> +\n>> +\t/*\n>> +\t * Cap to serial any configured hook not marked as parallel = true.\n>> +\t * This enforces the parallel = false default, even for \"traditional\"\n>> +\t * hooks from the hookdir which cannot be marked parallel = true.\n>> +\t */\n>> +\tfor (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {\n>> +\t\tstruct hook *h = hook_list->items[i].util;\n>> +\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n>> +\t\t\tjobs = 1;\n>> +\t}\n>\n> Losing \"jobs > 1 &&\" from the termination condition and instead\n> explicitly \"break;\" out when we demote jobs to 1 would be easier to\n> read, even though it would spend two more lines, i.e.,\n>\n> \tfor (size_t i = 0; i < hook_list->nr; i++) {\n>         \tstruct hook *h = ...;\n> \t\tif (...) {\n> \t\t\tjobs = 1;\n> \t\t\tbreak;\n> \t\t}\n> \t}\n\nIndeed, that is much easier to read, will do it in the next re-roll,\nthanks again.\n\nI was thiking in pseudo-code something like this when I wrote it:\n\nif (jobs > 1); then\n   for each hook in list do\n       if hook is configured and not parallel then\n          jobs = 1;\n\nThen just integrated the (jobs > 1) condition into the for loop below\nit, the break short-circuit didn't occur to me. :)\n\n>\n>\n> Other than that, very cleanly written.\n\nMany thanks again, this function I think is the \"hairiest\" part of the\nseries, at least it's what gave me the most trouble and also the part\nwhere I spent most time until I could convince myself it's correct.\n"},{"id":"539306","messageId":"87y0jp0ymp.fsf@collabora.com","threadId":"64919","inReplyTo":"xmqqcy14stf7.fsf@gitster.g","subject":"Re: [PATCH v3 5/9] hook: mark non-parallelizable hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-18T18:40:46Z","receivedAt":"2026-03-18T18:41:13Z","isPatch":true,"body":"On Sun, 15 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>>  hook.jobs::\n>>  \tSpecifies how many hooks can be run simultaneously during parallelized\n>>  \thook execution. If unspecified, defaults to 1 (serial execution).\n>> +\tSome hooks always run sequentially regardless of this setting because\n>> +\tgit knows they cannot safely be parallelized: `applypatch-msg`,\n>> +\t`pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`,\n>> +\t`post-checkout`, and `push-to-checkout`.\n>\n> If there is a simple rule that can be used to decide hooks with what\n> characteristics can and cannot be run in parallel, near the \"because\n> git knows\" sentence is where we want to write it down.  It would\n> help new developers decide if their newly invented hook should be\n> forced serial execution.\n>\n> For example, applypatch-msg is given a file and is allowed to modify\n> the file (perhaps reformat or typofix), so two of them competing to\n> edit that single file would be a nonsense.  Letting them edit the\n> file one after the other would make much more sense.  So one of the\n> rules may be \"a hook that is given a file and expected to edit it\".\n> other two hooks with -msg suffix may fall into the same category.\n> What are the rules behind the decision for others?  Are they also\n> explained with simple rules?\n\nThe simplest and highest level rule I can think of is that these hooks\noperate on shared data, so they cannot be safely parallelized.\n\nI'll make this rule clearer in the re-roll.\n\nFor the *-msg hooks, as you mentioned, it's a file.\nFor *-commit and *-checkout hooks, it's the worktree or index.\n\npost-commit is a bit special because it typically invokes git commands\nthat contend on lock files. Happy to drop this from the list if I've\nbeen too conservative on it (it's a 1 liner change + this doc) and allow\nit to be parallelized.\n\nSuggestions are welcome, as always.\n"},{"id":"539309","messageId":"87v7et0xq9.fsf@collabora.com","threadId":"64919","inReplyTo":"xmqq7brcst9o.fsf@gitster.g","subject":"Re: [PATCH v3 6/9] hook: add -j/--jobs option to git hook run","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-18T19:00:14Z","receivedAt":"2026-03-18T19:00:42Z","isPatch":true,"body":"On Sun, 15 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> diff --git a/hook.c b/hook.c\n>> index 815b299bf8..299cbf9e97 100644\n>> --- a/hook.c\n>> +++ b/hook.c\n>> @@ -567,15 +567,17 @@ static unsigned int get_hook_jobs(struct repository *r,\n>>  \tif (!options->stdout_to_stderr)\n>>  \t\treturn 1;\n>>  \n>> -\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n>> -\tif (options->jobs)\n>> -\t\treturn options->jobs;\n>> +\t/* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */\n>> +\tif (options->jobs == 1)\n>> +\t\treturn 1;\n>\n> Hmph, puzzled.  \n>\n> Shouldn't just -j1 but -j12 from CLI also trump configured\n> parallelism?  Which was what the code before this step already did,\n> no?\n\nYes. I think I was a bit unsure of the -jN priority when writing this,\nwhether the -jN arg is stronger than for e.g. if the hook is marked as\nparallel = false. What to do in this case? :) \n\nAs you noted on the other patch, the user's intention is clear when\npassing -jN, so maybe we could bring back the old code and issue a\nwarning, something like:\n\n\"hook X is not marked as parallel=true, running in parallel anyway due\nto the -jN flag\".\n"},{"id":"539313","messageId":"87se9x0wqa.fsf@collabora.com","threadId":"64919","inReplyTo":"xmqq341zpqhv.fsf@gitster.g","subject":"Re: [PATCH v3 7/9] hook: add per-event jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-18T19:21:49Z","receivedAt":"2026-03-18T19:22:14Z","isPatch":true,"body":"On Mon, 16 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> +hook.<event>.jobs::\n>> +\tSpecifies how many hooks can be run simultaneously for the `<event>`\n>> +\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n>> +\tfor this specific event. The same parallelism restrictions apply: this\n>> +\tsetting has no effect unless all configured hooks for the event have\n>> +\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n>> +\tzero is rejected with a warning. See linkgit:git-hook[1].\n>> ++\n>> +Note on naming: although this key resembles `hook.<friendly-name>.*`\n>> +(a per-hook setting), `<event>` must be the event name, not a hook\n>> +friendly name. The key component is stored literally and looked up by\n>> +event name at runtime with no translation between the two namespaces.\n>> +A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n>> +lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n>> +`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n>> +registered for that event. Use `hook.post-receive.jobs` or any other\n>> +valid event name when setting `hook.<event>.jobs`.\n>\n> This design is unfortunate but cannot be avoided, as we do not want\n> two hooks (i.e., two different names) that react to a single event\n> specify .jobs value differently.  Naturally, we need to worry about\n> what happens when somebody gives the name \"foo\" to their hook that\n> reacts to \"foo\" event, but that would probably be benign if there is\n> no other hook that reacts to \"foo\" event.  I also wonder if we want\n> to sanity check and complain upon seeing hook.foo.jobs set when\n> \"foo\" is not a known event type.\n>\n> Perhaps anything that appears with one of the .command, .event, or\n> .parallel are likely to be <friendly-name>, so having .jobs under\n> such configuration key is safe to flag as a mistake, or something?\n\nI think this is reasonable, yes, and it helps avoid confusion between\nper-hook friendly-names and per-event \"name\" values.\n\nWill do in the re-roll.\n\n>\n> A careful reader who is reading this message from the sideline may\n> notice that I specifically omitted .enabled from the above \"clues\n> for friendly-name key\".  I think hook.<event>.enabled that acts as a\n> master switch to prevents all hooks from firing for a particular\n> event (when set to 'false') may be something people eventually want,\n> in addition to per-hook command hook.<friendly-name>.enabled switch\n> that can override it (or do we want to forbid overriding it?  I\n> dunno)..\n\nI think this is reasonable as well.\n\nHaving a higher-level switch for all hooks in an event should be useful,\nin addition to the already implemented lower-level per-hook\n\"friendly-name\" enabled key.\n\nI can add a new commit for this in the re-roll.\n"},{"id":"539316","messageId":"87pl5029zj.fsf@collabora.com","threadId":"64919","inReplyTo":"xmqqy0jrobqn.fsf@gitster.g","subject":"Re: [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-18T19:50:08Z","receivedAt":"2026-03-18T19:50:34Z","isPatch":true,"body":"On Mon, 16 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> All hooks already redirect stdout to stderr with the exception of\n>> pre-push which has a known user who depends on the separate stdout\n>> versus stderr outputs (the git-lfs project).\n>>\n>> The pre-push behavior was a surprise which we found out about after\n>> causing a regression for git-lfs. Notably, it might not be the only\n>> exception (it's the one we know about). There might be more.\n>>\n>> This presents a challenge because stdout_to_stderr is required for\n>> hook parallelization, so run-command can buffer and de-interleave\n>> the hook outputs using ungroup=0, when hook.jobs > 1.\n>>\n>> Introduce an extension to enforce consistency: all hooks merge stdout\n>> into stderr and can be safely parallelized. This provides a clean\n>> separation and avoids breaking existing stdout vs stderr behavior.\n>>\n>> When this extension is disabled, the `hook.jobs` config has no\n>> effect for pre-push, to prevent garbled (interleaved) parallel\n>> output, so it runs sequentially like before.\n>>\n>> Alternatives I've considered to this extension include:\n>> 1. Allowing pre-push to run in parallel with interleaved output.\n>> 2. Always running pre-push sequentially (no parallel jobs for it).\n>> 3. Making users (only git-lfs? maybe more?) fix their hooks to read\n>>    stderr not stdout.\n>>\n>> Out of all these alternatives, I think this extension is the most\n>> reasonable compromise, to not break existing users, allow pre-push\n>> parallel jobs for those who need it (with correct outputs) and also\n>> future-proofing in case there are any more exceptions to be added.\n>\n> Hmph, I am a bit surprised that this is not hook.<name>.stdoutToStderr\n> controlled per hook process.\n\nYou may laugh at me, but making this a per hook process setting (or\nper-event) didn't even occur to me until now. :)\n\nI think we could do this and let the user decide if their hooks are safe\nor not (i.e. do they expect output on stdout?)\n\nOr even better:\n\nSince we now default to jobs == 1, that already keeps backwards\ncompatibility  (initially in v1 I turned parallelism on by default,\nusing the number of cpus, so this extension was unavoidable).\n\nTherefore if the user opts-in to parallelism (via config or -jN), we can\njust document that output will go to stderr instead of stdout.\n\n>\n> If we already have consensus that giving output to stdout is a\n> historical wart that we would rather want to fix, then this\n> configuration is probably good enough.  It is certainly a much\n> simpler approach, and there is no need to make finer-grained\n> customization available when nobody wants it ;-).\n\nAgreed. I don't think anybody (including myself) wants this extension\nand if we do what I suggested above, we can drop it and don't even need\na hook.<name>.stdoutToStderr config.\n\nWill drop this patch and the next one in the re-roll.\n\n>\n> We may also want to consider \"fixing\" it at Git 3.0 boundary, if\n> that is the case, though?  I dunno.  I weren't following the\n> discussion closely enough to tell myself.\n\nThere's only 1 known breaking hook, however now I'm convinced we don't\nneed to break backwards compatibility with my \"even better\" approach\nsuggested above.\n"},{"id":"539547","messageId":"20260320135311.331463-2-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 1/9] config: add a repo_config_get_uint() helper","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:03Z","receivedAt":"2026-03-20T13:53:59Z","isPatch":true,"body":"Next commits add a 'hook.jobs' config option of type 'unsigned int',\nso add a helper to parse it since the API only supports int and ulong.\n\nAn alternative is to make 'hook.jobs' an 'int' or parse it as an 'int'\nthen cast it to unsigned, however it's better to use proper helpers for\nthe type. Using 'ulong' is another option which already has helpers, but\nit's a bit excessive in size for just the jobs number.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n config.c | 28 ++++++++++++++++++++++++++++\n config.h | 13 +++++++++++++\n parse.c  |  9 +++++++++\n parse.h  |  1 +\n 4 files changed, 51 insertions(+)\n\ndiff --git a/config.c b/config.c\nindex 156f2a24fa..a1b92fe083 100644\n--- a/config.c\n+++ b/config.c\n@@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value,\n \treturn ret;\n }\n \n+unsigned int git_config_uint(const char *name, const char *value,\n+\t\t\t     const struct key_value_info *kvi)\n+{\n+\tunsigned int ret;\n+\tif (!git_parse_uint(value, &ret))\n+\t\tdie_bad_number(name, value, kvi);\n+\treturn ret;\n+}\n+\n int64_t git_config_int64(const char *name, const char *value,\n \t\t\t const struct key_value_info *kvi)\n {\n@@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest)\n \t\treturn 1;\n }\n \n+int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest)\n+{\n+\tconst char *value;\n+\tstruct key_value_info kvi;\n+\n+\tif (!git_configset_get_value(set, key, &value, &kvi)) {\n+\t\t*dest = git_config_uint(key, value, &kvi);\n+\t\treturn 0;\n+\t} else\n+\t\treturn 1;\n+}\n+\n int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest)\n {\n \tconst char *value;\n@@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo,\n \treturn git_configset_get_int(repo->config, key, dest);\n }\n \n+int repo_config_get_uint(struct repository *repo,\n+\t\t\t const char *key, unsigned int *dest)\n+{\n+\tgit_config_check_init(repo);\n+\treturn git_configset_get_uint(repo->config, key, dest);\n+}\n+\n int repo_config_get_ulong(struct repository *repo,\n \t\t\t  const char *key, unsigned long *dest)\n {\ndiff --git a/config.h b/config.h\nindex ba426a960a..bf47fb3afc 100644\n--- a/config.h\n+++ b/config.h\n@@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *);\n int64_t git_config_int64(const char *, const char *,\n \t\t\t const struct key_value_info *);\n \n+/**\n+ * Identical to `git_config_int`, but for unsigned ints.\n+ */\n+unsigned int git_config_uint(const char *, const char *,\n+\t\t\t     const struct key_value_info *);\n+\n /**\n  * Identical to `git_config_int`, but for unsigned longs.\n  */\n@@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key,\n \n int git_configset_get_string(struct config_set *cs, const char *key, char **dest);\n int git_configset_get_int(struct config_set *cs, const char *key, int *dest);\n+int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest);\n int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest);\n int git_configset_get_bool(struct config_set *cs, const char *key, int *dest);\n int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest);\n@@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r,\n  */\n int repo_config_get_int(struct repository *r, const char *key, int *dest);\n \n+/**\n+ * Similar to `repo_config_get_int` but for unsigned ints.\n+ */\n+int repo_config_get_uint(struct repository *r,\n+\t\t\t const char *key, unsigned int *dest);\n+\n /**\n  * Similar to `repo_config_get_int` but for unsigned longs.\n  */\ndiff --git a/parse.c b/parse.c\nindex 48313571aa..d77f28046a 100644\n--- a/parse.c\n+++ b/parse.c\n@@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret)\n \treturn 1;\n }\n \n+int git_parse_uint(const char *value, unsigned int *ret)\n+{\n+\tuintmax_t tmp;\n+\tif (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int)))\n+\t\treturn 0;\n+\t*ret = tmp;\n+\treturn 1;\n+}\n+\n int git_parse_ulong(const char *value, unsigned long *ret)\n {\n \tuintmax_t tmp;\ndiff --git a/parse.h b/parse.h\nindex ea32de9a91..a6dd37c4cb 100644\n--- a/parse.h\n+++ b/parse.h\n@@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max);\n int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max);\n int git_parse_ssize_t(const char *, ssize_t *);\n int git_parse_ulong(const char *, unsigned long *);\n+int git_parse_uint(const char *value, unsigned int *ret);\n int git_parse_int(const char *value, int *ret);\n int git_parse_int64(const char *value, int64_t *ret);\n int git_parse_double(const char *value, double *ret);\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539548","messageId":"20260320135311.331463-3-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 2/9] hook: parse the hook.jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:04Z","receivedAt":"2026-03-20T13:54:00Z","isPatch":true,"body":"The hook.jobs config is a global way to set hook parallelization for\nall hooks, in the sense that it is not per-event nor per-hook.\n\nFiner-grained configs will be added in later commits which can override\nit, for e.g. via a per-event type job options. Next commits will also\nadd to this item's documentation.\n\nParse hook.jobs config key in hook_config_lookup_all() and store its\nvalue in hook_all_config_cb.jobs, then transfer it into r->jobs after\nthe config pass completes.\n\nThis is mostly plumbing and the cached value is not yet used.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 23 +++++++++++++++++++++--\n repository.h                   |  3 +++\n 3 files changed, 28 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 9e78f26439..b7847f9338 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -22,3 +22,7 @@ hook.<friendly-name>.enabled::\n \tconfiguration. This is particularly useful when a hook is defined\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n+\n+hook.jobs::\n+\tSpecifies how many hooks can be run simultaneously during parallelized\n+\thook execution. If unspecified, defaults to 1 (serial execution).\ndiff --git a/hook.c b/hook.c\nindex 0e09b9a2bb..c4872d8707 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -127,11 +127,13 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tunsigned int jobs;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -147,6 +149,20 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n \t\treturn 0;\n \n+\t/* Handle plain hook.<key> entries that have no hook name component. */\n+\tif (!name) {\n+\t\tif (!strcmp(subkey, \"jobs\") && value) {\n+\t\t\tunsigned int v;\n+\t\t\tif (!git_parse_uint(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n+\t\t\telse if (!v)\n+\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n+\t\t\telse\n+\t\t\t\tdata->jobs = v;\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n \tif (!value)\n \t\treturn config_error_nonbool(key);\n \n@@ -244,7 +260,7 @@ void hook_cache_clear(struct strmap *cache)\n /* Populate `cache` with the complete hook configuration */\n static void build_hook_config_map(struct repository *r, struct strmap *cache)\n {\n-\tstruct hook_all_config_cb cb_data;\n+\tstruct hook_all_config_cb cb_data = { 0 };\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *e;\n \n@@ -252,7 +268,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \n-\t/* Parse all configs in one run. */\n+\t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n \t/* Construct the cache from parsed configs. */\n@@ -296,6 +312,9 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tstrmap_put(cache, e->key, hooks);\n \t}\n \n+\tif (r)\n+\t\tr->hook_jobs = cb_data.jobs;\n+\n \tstrmap_clear(&cb_data.commands, 1);\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\ndiff --git a/repository.h b/repository.h\nindex 078059a6e0..58e46853d0 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -172,6 +172,9 @@ struct repository {\n \t */\n \tstruct strmap *hook_config_cache;\n \n+\t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n+\tunsigned int hook_jobs;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539549","messageId":"20260320135311.331463-1-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 0/9] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:02Z","receivedAt":"2026-03-20T13:54:00Z","isPatch":true,"body":"Hello everyone,\n\nThis series enables running hook commands in parallel and is based on\nthe latest config hooks cleanup series v2 [1].\n\nv4 has an important design change and addresses all feedback received\nin v3. The design change is that I dropped the extension introduced in\nprevious patches for the following reasoning:\n\nHook stdout -> stderr backwards compatibility is preserved by default\nwhen running sequentially (only known hook is pre-push which requires\ndifferent stdout vs stderr streams), however when users opt-in to run\nhooks in parallel, hook stdout is redirected to stderr, so run-command\ncan buffer and de-interlace the parallel hook output.\n\nSimpler said:\nIf you opt-in to parallel execution, parallel hook output goes to stderr.\n\nBased on all discussions we had, I think this is the best compromise\nbetween preserving backwards compatibility, avoiding extensions nobody\nwants and also providing an incentive for users to migrate their hooks\nby opting-in, without forcing them via breakage.\n\nBranch pushed to GitHub: [2]\nSuccessful CI run: [3] with the known MacOS REG_ENHANCED breakage exception.\n\nMany thanks to all who contributed up to now,\nAdrian\n\n1: https://lore.kernel.org/git/20260309005416.2760030-1-adrian.ratiu@collabora.com/T/#m1827a9302ecfdf2c07e2f0158cd24b4eb435aa37\n2: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v4\n3: https://github.com/10ne1/git/actions/runs/23342695565\n\nChanges in v4:\n* Rebased on config-cleanups-v2 with a few small conflicts due to\n  removing the redundant struct cache wrapping the strmap (Junio)\n* Simplified parallel toggle strmap_get by using double negation (Junio)\n* Simplified parallel = true loop detection early exit (Junio)\n* Made the options->jobs override stronger and issued a warning when\n  forcing a parallel=false hook to run in parallel via -jN (Junio)\n* Added a new test for the above warning and updated the blocking test (Adrian)\n* Dropped the extension since it's not necessary anymore (Junio, Adrian)\n* New commit: users who opt-in to run hooks in parallel automatically get\n  all the hook output on stderr. jobs == 1 is bacward compatible. (Adrian)\n* New commit: warn when user mixes event and hook namespaces (Junio)\n* New Commit: add hook.<event>.enabled switch (Junio)\n* Simplified get_hook_jobs logic by setting options->jobs directly,\n  so we don't need the extra jobs variable and shorthand ifs (Adrian)\n* Removed heap allocations from event level .jobs config (Adrian)\n* Minor documentation & code comment improvements, commit messages (Junio)\n\nRange-diff v3 -> v4:\n 1:  6686d92867 =  1:  ec274c24e5 repository: fix repo_init() memleak due to missing _clear()\n 2:  61250bdd91 =  2:  81d92a4465 config: add a repo_config_get_uint() helper\n 3:  2c49e2a523 !  3:  3bc27f6997 hook: parse the hook.jobs config\n    @@ Commit message\n         add to this item's documentation.\n     \n         Parse hook.jobs config key in hook_config_lookup_all() and store its\n    -    value in hook_all_config_cb.jobs, then transfer it into\n    -    hook_config_cache.jobs after the config pass completes.\n    +    value in hook_all_config_cb.jobs, then transfer it into r->jobs after\n    +    the config pass completes.\n     \n         This is mostly plumbing and the cached value is not yet used.\n     \n    @@ hook.c: struct hook_config_cache_entry {\n       * commands: friendly-name to command map.\n       * event_hooks: event-name to list of friendly-names map.\n       * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n    -+ * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n    ++ * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n       */\n      struct hook_all_config_cb {\n      \tstruct strmap commands;\n    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n      \tif (!value)\n      \t\treturn config_error_nonbool(key);\n      \n    -@@ hook.c: void hook_cache_clear(struct hook_config_cache *cache)\n    - static void build_hook_config_map(struct repository *r,\n    - \t\t\t\t  struct hook_config_cache *cache)\n    +@@ hook.c: void hook_cache_clear(struct strmap *cache)\n    + /* Populate `cache` with the complete hook configuration */\n    + static void build_hook_config_map(struct repository *r, struct strmap *cache)\n      {\n     -\tstruct hook_all_config_cb cb_data;\n     +\tstruct hook_all_config_cb cb_data = { 0 };\n      \tstruct hashmap_iter iter;\n      \tstruct strmap_entry *e;\n      \n    -@@ hook.c: static void build_hook_config_map(struct repository *r,\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n      \tstrmap_init(&cb_data.event_hooks);\n      \tstring_list_init_dup(&cb_data.disabled_hooks);\n      \n    @@ hook.c: static void build_hook_config_map(struct repository *r,\n      \trepo_config(r, hook_config_lookup_all, &cb_data);\n      \n      \t/* Construct the cache from parsed configs. */\n    -@@ hook.c: static void build_hook_config_map(struct repository *r,\n    - \t\tstrmap_put(&cache->hooks, e->key, hooks);\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n    + \t\tstrmap_put(cache, e->key, hooks);\n      \t}\n      \n    -+\tcache->jobs = cb_data.jobs;\n    ++\tif (r)\n    ++\t\tr->hook_jobs = cb_data.jobs;\n     +\n      \tstrmap_clear(&cb_data.commands, 1);\n      \tstring_list_clear(&cb_data.disabled_hooks, 0);\n      \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n     \n    - ## hook.h ##\n    -@@ hook.h: void hook_free(void *p, const char *str UNUSED);\n    -  */\n    - struct hook_config_cache {\n    - \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n    -+\tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n    - };\n    + ## repository.h ##\n    +@@ repository.h: struct repository {\n    + \t */\n    + \tstruct strmap *hook_config_cache;\n      \n    - /**\n    ++\t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n    ++\tunsigned int hook_jobs;\n    ++\n    + \t/* Configurations related to promisor remotes. */\n    + \tchar *repository_format_partial_clone;\n    + \tstruct promisor_remote_config *promisor_remote_config;\n 4:  3c206fac62 !  4:  74e6f8689a hook: allow parallel hook execution\n    @@ Documentation/config/hook.adoc: hook.<friendly-name>.enabled::\n      \tin a system or global config file and needs to be disabled for a\n      \tspecific repository. See linkgit:git-hook[1].\n      \n    -+hook.<name>.parallel::\n    -+\tWhether the hook `hook.<name>` may run in parallel with other hooks\n    ++hook.<friendly-name>.parallel::\n    ++\tWhether the hook `hook.<friendly-name>` may run in parallel with other hooks\n     +\tfor the same event. Defaults to `false`. Set to `true` only when the\n     +\thook script is safe to run concurrently with other hooks for the same\n     +\tevent. If any hook for an event does not have this set to `true`,\n    @@ Documentation/config/hook.adoc: hook.<friendly-name>.enabled::\n      \thook execution. If unspecified, defaults to 1 (serial execution).\n     ++\n     +This setting has no effect unless all configured hooks for the event have\n    -+`hook.<name>.parallel` set to `true`.\n    ++`hook.<friendly-name>.parallel` set to `true`.\n     \n      ## hook.c ##\n     @@ hook.c: struct hook_config_cache_entry {\n      \tchar *command;\n      \tenum config_scope scope;\n    - \tint disabled;\n    + \tunsigned int disabled:1;\n     +\tunsigned int parallel:1;\n      };\n      \n    @@ hook.c: struct hook_config_cache_entry {\n       * event_hooks: event-name to list of friendly-names map.\n       * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n     + * parallel_hooks: friendly-name to parallel flag.\n    -  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n    +  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n       */\n      struct hook_all_config_cb {\n      \tstruct strmap commands;\n    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n      \t}\n      \n      \tfree(hook_name);\n    -@@ hook.c: static void build_hook_config_map(struct repository *r,\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n      \tstrmap_init(&cb_data.commands);\n      \tstrmap_init(&cb_data.event_hooks);\n      \tstring_list_init_dup(&cb_data.disabled_hooks);\n    @@ hook.c: static void build_hook_config_map(struct repository *r,\n      \n      \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n      \trepo_config(r, hook_config_lookup_all, &cb_data);\n    -@@ hook.c: static void build_hook_config_map(struct repository *r,\n    - \t\t\tenum config_scope scope =\n    - \t\t\t\t(enum config_scope)(uintptr_t)hook_names->items[i].util;\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n      \t\t\tstruct hook_config_cache_entry *entry;\n    -+\t\t\tvoid *par = strmap_get(&cb_data.parallel_hooks, hname);\n      \t\t\tchar *command;\n      \n    ++\t\t\tint is_par = !!strmap_get(&cb_data.parallel_hooks, hname);\n      \t\t\tint is_disabled =\n    -@@ hook.c: static void build_hook_config_map(struct repository *r,\n    - \t\t\tentry->command = command ? xstrdup(command) : NULL;\n    + \t\t\t\t!!unsorted_string_list_lookup(\n    + \t\t\t\t\t&cb_data.disabled_hooks, hname);\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n    + \t\t\tentry->command = xstrdup_or_null(command);\n      \t\t\tentry->scope = scope;\n      \t\t\tentry->disabled = is_disabled;\n    -+\t\t\tentry->parallel = (int)(uintptr_t)par;\n    ++\t\t\tentry->parallel = is_par;\n      \t\t\tstring_list_append(hooks, hname)->util = entry;\n      \t\t}\n      \n    -@@ hook.c: static void build_hook_config_map(struct repository *r,\n    - \tcache->jobs = cb_data.jobs;\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n    + \t\tr->hook_jobs = cb_data.jobs;\n      \n      \tstrmap_clear(&cb_data.commands, 1);\n     +\tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n    @@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options)\n     +\t\t\t\t  struct run_hooks_opt *options,\n     +\t\t\t\t  struct string_list *hook_list)\n     +{\n    -+\tunsigned int jobs;\n    -+\n     +\t/*\n    -+\t * Hooks needing separate output streams must run sequentially. Next\n    -+\t * commits will add an extension to allow parallelizing these as well.\n    ++\t * Hooks needing separate output streams must run sequentially.\n    ++\t * Next commit will allow parallelizing these as well.\n     +\t */\n     +\tif (!options->stdout_to_stderr)\n     +\t\treturn 1;\n     +\n    -+\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n    ++\t/*\n    ++\t * An explicit job count overrides everything else: this covers both\n    ++\t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n    ++\t * and the -j flag from the CLI. The CLI override is intentional: users\n    ++\t * may want to serialize hooks declared parallel or to parallelize more\n    ++\t * aggressively than the default.\n    ++\t */\n     +\tif (options->jobs)\n     +\t\treturn options->jobs;\n     +\n     +\t/*\n     +\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n    -+\t * fall back to a direct config lookup (out-of-repo).  Default to 1.\n    ++\t * fallback to a direct config lookup (out-of-repo).\n    ++\t * Default to 1 (serial execution) on failure.\n     +\t */\n     +\tif (r && r->gitdir && r->hook_config_cache)\n     +\t\t/* Use the already-parsed cache (in-repo) */\n    -+\t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n    ++\t\toptions->jobs = r->hook_jobs ? r->hook_jobs : 1;\n     +\telse\n     +\t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n    -+\t\tjobs = repo_config_get_uint(r, \"hook.jobs\", &jobs) ? 1 : jobs;\n    ++\t\tif (repo_config_get_uint(r, \"hook.jobs\", &options->jobs))\n    ++\t\t\toptions->jobs = 1;\n     +\n     +\t/*\n     +\t * Cap to serial any configured hook not marked as parallel = true.\n     +\t * This enforces the parallel = false default, even for \"traditional\"\n     +\t * hooks from the hookdir which cannot be marked parallel = true.\n     +\t */\n    -+\tfor (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {\n    ++\tfor (size_t i = 0; i < hook_list->nr; i++) {\n     +\t\tstruct hook *h = hook_list->items[i].util;\n    -+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n    -+\t\t\tjobs = 1;\n    ++\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel) {\n    ++\t\t\toptions->jobs = 1;\n    ++\t\t\tbreak;\n    ++\t\t}\n     +\t}\n     +\n    -+\treturn jobs;\n    ++\treturn options->jobs;\n     +}\n     +\n      int run_hooks_opt(struct repository *r, const char *hook_name,\n    @@ hook.h: struct hook {\n      \t/**\n      \t * Opaque data pointer used to keep internal state across callback calls.\n      \t *\n    -@@ hook.h: struct run_hooks_opt\n    +@@ hook.h: struct run_hooks_opt {\n      \t *\n      \t * If > 1, output will be buffered and de-interleaved (ungroup=0).\n      \t * If == 1, output will be real-time (ungroup=1).\n    @@ hook.h: struct run_hooks_opt\n      \t */\n      \tunsigned int jobs;\n      \n    -@@ hook.h: struct run_hooks_opt\n    +@@ hook.h: struct run_hooks_opt {\n      \thook_data_free_fn feed_pipe_cb_data_free;\n      };\n      \n -:  ---------- >  5:  508d6476c6 hook: allow pre-push parallel execution\n 5:  2385cb3bc8 !  6:  2d7b3d6d83 hook: mark non-parallelizable hooks\n    @@ Commit message\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Documentation/config/hook.adoc ##\n    -@@ Documentation/config/hook.adoc: hook.<name>.parallel::\n    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.parallel::\n      hook.jobs::\n      \tSpecifies how many hooks can be run simultaneously during parallelized\n      \thook execution. If unspecified, defaults to 1 (serial execution).\n     +\tSome hooks always run sequentially regardless of this setting because\n    -+\tgit knows they cannot safely be parallelized: `applypatch-msg`,\n    -+\t`pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`,\n    -+\t`post-checkout`, and `push-to-checkout`.\n    ++\tthey operate on shared data and cannot safely be parallelized:\n    +++\n    ++--\n    ++`applypatch-msg`;;\n    ++`prepare-commit-msg`;;\n    ++`commit-msg`;;\n    ++\tReceive a commit message file and may rewrite it in place.\n    ++`pre-commit`;;\n    ++`post-checkout`;;\n    ++`push-to-checkout`;;\n    ++`post-commit`;;\n    ++\tAccess the working tree, index, or repository state.\n    ++--\n      +\n      This setting has no effect unless all configured hooks for the event have\n    - `hook.<name>.parallel` set to `true`.\n    + `hook.<friendly-name>.parallel` set to `true`.\n     \n      ## builtin/am.c ##\n     @@ builtin/am.c: static int run_applypatch_msg_hook(struct am_state *state)\n    @@ commit.c: size_t ignored_log_message_bytes(const char *buf, size_t len)\n      \n     \n      ## t/t1800-hook.sh ##\n    -@@ t/t1800-hook.sh: test_expect_success 'one non-parallel hook forces the whole event to run seriall\n    - \ttest_cmp expect hook.order\n    +@@ t/t1800-hook.sh: test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1'\n    + \ttest_cmp expect repo-parallel/hook.order\n      '\n      \n     +test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' '\n 6:  c4f92834a7 !  7:  97507d8d31 hook: add -j/--jobs option to git hook run\n    @@ Documentation/git-hook.adoc: git-hook - Run git hooks\n      \n      DESCRIPTION\n     @@ Documentation/git-hook.adoc: OPTIONS\n    - \tin parentheses after the friendly name of each configured hook, to show\n    - \twhere it was defined. Traditional hooks from the hookdir are unaffected.\n    + \tmirroring the output style of `git config --show-scope`. Traditional\n    + \thooks from the hookdir are unaffected.\n      \n     +-j::\n     +--jobs::\n    @@ builtin/hook.c: static int run(int argc, const char **argv, const char *prefix,\n      \tint ret;\n     \n      ## hook.c ##\n    +@@ hook.c: static void merge_output_if_parallel(struct run_hooks_opt *options)\n    + \t\toptions->stdout_to_stderr = 1;\n    + }\n    + \n    ++static void warn_non_parallel_hooks_override(unsigned int jobs,\n    ++\t\t\t\t\t     struct string_list *hook_list)\n    ++{\n    ++\t/* Don't warn for hooks running sequentially. */\n    ++\tif (jobs == 1)\n    ++\t\treturn;\n    ++\n    ++\tfor (size_t i = 0; i < hook_list->nr; i++) {\n    ++\t\tstruct hook *h = hook_list->items[i].util;\n    ++\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n    ++\t\t\twarning(_(\"hook '%s' is not marked as parallel=true, \"\n    ++\t\t\t\t  \"running in parallel anyway due to -j%u\"),\n    ++\t\t\t\th->u.configured.friendly_name, jobs);\n    ++\t}\n    ++}\n    ++\n    + /* Determine how many jobs to use for hook execution. */\n    + static unsigned int get_hook_jobs(struct repository *r,\n    + \t\t\t\t  struct run_hooks_opt *options,\n     @@ hook.c: static unsigned int get_hook_jobs(struct repository *r,\n    - \tif (!options->stdout_to_stderr)\n    - \t\treturn 1;\n      \n    --\t/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */\n    --\tif (options->jobs)\n    --\t\treturn options->jobs;\n    -+\t/* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */\n    -+\tif (options->jobs == 1)\n    -+\t\treturn 1;\n    + cleanup:\n    + \tmerge_output_if_parallel(options);\n    ++\twarn_non_parallel_hooks_override(options->jobs, hook_list);\n    + \treturn options->jobs;\n    + }\n      \n    - \t/*\n    --\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n    --\t * fall back to a direct config lookup (out-of-repo).  Default to 1.\n    -+\t * Resolve effective job count: -jN (when given) overrides config.\n    -+\t * Default to 1 when both config an -jN are missing.\n    - \t */\n    --\tif (r && r->gitdir && r->hook_config_cache)\n    -+\tif (options->jobs > 1)\n    -+\t\tjobs = options->jobs;\n    -+\telse if (r && r->gitdir && r->hook_config_cache)\n    - \t\t/* Use the already-parsed cache (in-repo) */\n    - \t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n    - \telse\n     \n      ## t/t1800-hook.sh ##\n     @@ t/t1800-hook.sh: test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\ttest_cmp expect hook.order\n     +'\n     +\n    -+test_expect_success 'git hook run -j2 is blocked by parallel=false' '\n    ++test_expect_success 'git hook run -j2 overrides parallel=false' '\n     +\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n     +\ttest_config hook.hook-1.event test-hook &&\n     +\ttest_config hook.hook-1.command \\\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\t    \"$(sentinel_detector sentinel hook.order)\" &&\n     +\t# hook-2 also has no parallel=true\n     +\n    -+\t# -j2 must not override parallel=false on configured hooks.\n    ++\t# -j2 overrides parallel=false; hooks run in parallel with a warning.\n     +\tgit hook run -j2 test-hook >out 2>err &&\n    -+\techo serial >expect &&\n    ++\techo parallel >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    ++\n    ++test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' '\n    ++\ttest_config hook.hook-1.event test-hook &&\n    ++\ttest_config hook.hook-1.command \"true\" &&\n    ++\ttest_config hook.hook-2.event test-hook &&\n    ++\ttest_config hook.hook-2.command \"true\" &&\n    ++\t# neither hook has parallel=true\n    ++\n    ++\tgit hook run -j2 test-hook >out 2>err &&\n    ++\tgrep \"hook .hook-1. is not marked as parallel=true\" err &&\n    ++\tgrep \"hook .hook-2. is not marked as parallel=true\" err\n    ++'\n     +\n      test_expect_success 'hook.jobs=1 config runs hooks in series' '\n      \ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n 7:  7b3ea03bd3 !  8:  734adfad1b hook: add per-event jobs config\n    @@ Commit message\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Documentation/config/hook.adoc ##\n    -@@ Documentation/config/hook.adoc: hook.<name>.parallel::\n    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.parallel::\n      \tfound in the hooks directory do not need to, and run in parallel when\n      \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n      \n    @@ Documentation/config/hook.adoc: hook.<name>.parallel::\n      \thook execution. If unspecified, defaults to 1 (serial execution).\n     +\tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n      \tSome hooks always run sequentially regardless of this setting because\n    - \tgit knows they cannot safely be parallelized: `applypatch-msg`,\n    - \t`pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`,\n    + \tthey operate on shared data and cannot safely be parallelized:\n    + +\n     \n      ## hook.c ##\n     @@ hook.c: struct hook_config_cache_entry {\n       * event_hooks: event-name to list of friendly-names map.\n       * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n       * parallel_hooks: friendly-name to parallel flag.\n    -+ * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *,\n    -+ *             where NULL == unset).\n    -  * jobs: value of the global hook.jobs key. Defaults to 0 if unset.\n    ++ * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n    +  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n       */\n      struct hook_all_config_cb {\n     @@ hook.c: struct hook_all_config_cb {\n    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n     +\t\t\t\thook_name, value);\n     +\t\telse if (!v)\n     +\t\t\twarning(_(\"hook.%s.jobs must be positive, ignoring: 0\"), hook_name);\n    -+\t\telse {\n    -+\t\t\tunsigned int *old;\n    -+\t\t\tunsigned int *p = xmalloc(sizeof(*p));\n    -+\t\t\t*p = v;\n    -+\t\t\told = strmap_put(&data->event_jobs, hook_name, p);\n    -+\t\t\tfree(old);\n    -+\t\t}\n    ++\t\telse\n    ++\t\t\tstrmap_put(&data->event_jobs, hook_name,\n    ++\t\t\t\t   (void *)(uintptr_t)v);\n      \t}\n      \n      \tfree(hook_name);\n    -@@ hook.c: void hook_cache_clear(struct hook_config_cache *cache)\n    - \t\tfree(hooks);\n    - \t}\n    - \tstrmap_clear(&cache->hooks, 0);\n    -+\tstrmap_clear(&cache->event_jobs, 1); /* free heap-allocated unsigned int * values */\n    - }\n    - \n    - /* Populate `cache` with the complete hook configuration */\n    -@@ hook.c: static void build_hook_config_map(struct repository *r,\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n      \tstrmap_init(&cb_data.event_hooks);\n      \tstring_list_init_dup(&cb_data.disabled_hooks);\n      \tstrmap_init(&cb_data.parallel_hooks);\n    @@ hook.c: static void build_hook_config_map(struct repository *r,\n      \n      \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n      \trepo_config(r, hook_config_lookup_all, &cb_data);\n    -@@ hook.c: static void build_hook_config_map(struct repository *r,\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n    + \t\tstrmap_put(cache, e->key, hooks);\n      \t}\n      \n    - \tcache->jobs = cb_data.jobs;\n    -+\tcache->event_jobs = cb_data.event_jobs;\n    +-\tif (r)\n    ++\tif (r) {\n    + \t\tr->hook_jobs = cb_data.jobs;\n    ++\t\tr->event_jobs = cb_data.event_jobs;\n    ++\t}\n      \n      \tstrmap_clear(&cb_data.commands, 1);\n      \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n    -@@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options)\n    +@@ hook.c: static void warn_non_parallel_hooks_override(unsigned int jobs,\n      /* Determine how many jobs to use for hook execution. */\n      static unsigned int get_hook_jobs(struct repository *r,\n      \t\t\t\t  struct run_hooks_opt *options,\n     +\t\t\t\t  const char *hook_name,\n      \t\t\t\t  struct string_list *hook_list)\n      {\n    - \tunsigned int jobs;\n    -@@ hook.c: static unsigned int get_hook_jobs(struct repository *r,\n    - \t\treturn 1;\n    - \n      \t/*\n    --\t * Resolve effective job count: -jN (when given) overrides config.\n    --\t * Default to 1 when both config an -jN are missing.\n    -+\t * Resolve effective job count: -j N (when given) overrides config.\n    -+\t * hook.<event>.jobs overrides hook.jobs.\n    -+\t * Unset configs and -jN default to 1.\n    +@@ hook.c: static unsigned int get_hook_jobs(struct repository *r,\n    + \t * fallback to a direct config lookup (out-of-repo).\n    + \t * Default to 1 (serial execution) on failure.\n      \t */\n    --\tif (options->jobs > 1)\n    -+\tif (options->jobs > 1) {\n    - \t\tjobs = options->jobs;\n    --\telse if (r && r->gitdir && r->hook_config_cache)\n    -+\t} else if (r && r->gitdir && r->hook_config_cache) {\n    +-\tif (r && r->gitdir && r->hook_config_cache)\n    ++\tif (r && r->gitdir && r->hook_config_cache) {\n      \t\t/* Use the already-parsed cache (in-repo) */\n    -+\t\tunsigned int *event_jobs = strmap_get(&r->hook_config_cache->event_jobs,\n    -+\t\t\t\t\t\t      hook_name);\n    - \t\tjobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;\n    ++\t\tvoid *event_jobs = strmap_get(&r->event_jobs, hook_name);\n    + \t\toptions->jobs = r->hook_jobs ? r->hook_jobs : 1;\n     -\telse\n     +\t\tif (event_jobs)\n    -+\t\t\tjobs = *event_jobs;\n    ++\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n     +\t} else {\n      \t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n     +\t\tunsigned int event_jobs;\n     +\t\tchar *key;\n    - \t\tjobs = repo_config_get_uint(r, \"hook.jobs\", &jobs) ? 1 : jobs;\n    ++\n    + \t\tif (repo_config_get_uint(r, \"hook.jobs\", &options->jobs))\n    + \t\t\toptions->jobs = 1;\n    + \n     +\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n     +\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n    -+\t\t\tjobs = event_jobs;\n    ++\t\t\toptions->jobs = event_jobs;\n     +\t\tfree(key);\n     +\t}\n    - \n    ++\n      \t/*\n      \t * Cap to serial any configured hook not marked as parallel = true.\n      \t * This enforces the parallel = false default, even for \"traditional\"\n    @@ hook.c: static unsigned int get_hook_jobs(struct repository *r,\n     +\t * The same restriction applies whether jobs came from hook.jobs or\n     +\t * hook.<event>.jobs.\n      \t */\n    - \tfor (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {\n    + \tfor (size_t i = 0; i < hook_list->nr; i++) {\n      \t\tstruct hook *h = hook_list->items[i].util;\n     @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,\n      \t\t.options = options,\n    @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,\n      \t\t.tr2_category = \"hook\",\n      \t\t.tr2_label = hook_name,\n     \n    - ## hook.h ##\n    -@@ hook.h: void hook_free(void *p, const char *str UNUSED);\n    -  */\n    - struct hook_config_cache {\n    - \tstruct strmap hooks; /* maps event name -> string_list of hooks */\n    -+\tstruct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */\n    - \tunsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */\n    - };\n    + ## repository.c ##\n    +@@ repository.c: void repo_clear(struct repository *repo)\n    + \t\thook_cache_clear(repo->hook_config_cache);\n    + \t\tFREE_AND_NULL(repo->hook_config_cache);\n    + \t}\n    ++\tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n      \n    + \tif (repo->promisor_remote_config) {\n    + \t\tpromisor_remote_clear(repo->promisor_remote_config);\n    +\n    + ## repository.h ##\n    +@@ repository.h: struct repository {\n    + \t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n    + \tunsigned int hook_jobs;\n    + \n    ++\t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n    ++\tstruct strmap event_jobs;\n    ++\n    + \t/* Configurations related to promisor remotes. */\n    + \tchar *repository_format_partial_clone;\n    + \tstruct promisor_remote_config *promisor_remote_config;\n     \n      ## t/t1800-hook.sh ##\n     @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'\n 8:  d5cf01444f <  -:  ---------- hook: introduce extensions.hookStdoutToStderr\n 9:  1cef7b5e22 <  -:  ---------- hook: allow runtime enabling extensions.hookStdoutToStderr\n -:  ---------- >  9:  d6d3196b17 hook: warn when hook.<friendly-name>.jobs is set\n -:  ---------- > 10:  b64689d0c8 hook: add hook.<event>.enabled switch\n\nAdrian Ratiu (6):\n  config: add a repo_config_get_uint() helper\n  hook: parse the hook.jobs config\n  hook: allow pre-push parallel execution\n  hook: add per-event jobs config\n  hook: warn when hook.<friendly-name>.jobs is set\n  hook: add hook.<event>.enabled switch\n\nEmily Shaffer (3):\n  hook: allow parallel hook execution\n  hook: mark non-parallelizable hooks\n  hook: add -j/--jobs option to git hook run\n\n Documentation/config/hook.adoc |  66 +++++\n Documentation/git-hook.adoc    |  18 +-\n builtin/am.c                   |   8 +-\n builtin/checkout.c             |  19 +-\n builtin/clone.c                |   6 +-\n builtin/hook.c                 |  25 +-\n builtin/receive-pack.c         |   3 +-\n builtin/worktree.c             |   2 +-\n commit.c                       |   2 +-\n config.c                       |  28 ++\n config.h                       |  13 +\n hook.c                         | 222 ++++++++++++++-\n hook.h                         |  32 ++-\n parse.c                        |   9 +\n parse.h                        |   1 +\n repository.c                   |   2 +\n repository.h                   |  10 +\n t/t1800-hook.sh                | 476 ++++++++++++++++++++++++++++++++-\n transport.c                    |   6 +-\n 19 files changed, 903 insertions(+), 45 deletions(-)\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539550","messageId":"20260320135311.331463-5-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 4/9] hook: allow pre-push parallel execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:06Z","receivedAt":"2026-03-20T13:54:05Z","isPatch":true,"body":"pre-push is the only hook that keeps stdout and stderr separate (for\nbackwards compatibility with git-lfs and potentially other users). This\nprevents parallelizing it because run-command needs stdout_to_stderr=1\nto buffer and de-interleave parallel outputs.\n\nSince we now default to jobs=1, backwards compatibility is maintained\nwithout needing any extension or extra config: when no parallelism is\nrequested, pre-push behaves exactly as before.\n\nWhen the user explicitly opts into parallelism via hook.jobs > 1,\nhook.<event>.jobs > 1, or -jN, they accept the changed output behavior.\n\nDocument this and let get_hook_jobs() set stdout_to_stderr=1 automatically\nwhen jobs > 1, removing the need for any extension infrastructure.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 24 ++++++++++++++++--------\n hook.h                         |  6 ++++--\n t/t1800-hook.sh                | 32 ++++++++++++++++++++++++++++++++\n transport.c                    |  6 ++++--\n 5 files changed, 60 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 21800db648..94c7a9808e 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -39,3 +39,7 @@ hook.jobs::\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<friendly-name>.parallel` set to `true`.\n++\n+For `pre-push` hooks, which normally keep stdout and stderr separate,\n+setting this to a value greater than 1 (or passing `-j`) will merge stdout\n+into stderr to allow correct de-interleaving of parallel output.\ndiff --git a/hook.c b/hook.c\nindex a60dac5a60..b7cf844c19 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -553,18 +553,24 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/*\n+ * When running in parallel, stdout must be merged into stderr so\n+ * run-command can buffer and de-interleave outputs correctly. This\n+ * applies even to hooks like pre-push that normally keep stdout and\n+ * stderr separate: the user has opted into parallelism, so the output\n+ * stream behavior changes accordingly.\n+ */\n+static void merge_output_if_parallel(struct run_hooks_opt *options)\n+{\n+\tif (options->jobs > 1)\n+\t\toptions->stdout_to_stderr = 1;\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n \t\t\t\t  struct string_list *hook_list)\n {\n-\t/*\n-\t * Hooks needing separate output streams must run sequentially.\n-\t * Next commit will allow parallelizing these as well.\n-\t */\n-\tif (!options->stdout_to_stderr)\n-\t\treturn 1;\n-\n \t/*\n \t * An explicit job count overrides everything else: this covers both\n \t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n@@ -573,7 +579,7 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t * aggressively than the default.\n \t */\n \tif (options->jobs)\n-\t\treturn options->jobs;\n+\t\tgoto cleanup;\n \n \t/*\n \t * Use hook.jobs from the already-parsed config cache (in-repo), or\n@@ -601,6 +607,8 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\t}\n \t}\n \n+cleanup:\n+\tmerge_output_if_parallel(options);\n \treturn options->jobs;\n }\n \ndiff --git a/hook.h b/hook.h\nindex 494f74345f..fefcd004c0 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -106,8 +106,10 @@ struct run_hooks_opt {\n \t * Send the hook's stdout to stderr.\n \t *\n \t * This is the default behavior for all hooks except pre-push,\n-\t * which has separate stdout and stderr streams for backwards\n-\t * compatibility reasons.\n+\t * which keeps stdout and stderr separate for backwards compatibility.\n+\t * When parallel execution is requested (jobs > 1), get_hook_jobs()\n+\t * overrides this to 1 for all hooks so run-command can de-interleave\n+\t * their outputs correctly.\n \t */\n \tunsigned int stdout_to_stderr:1;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex b1049a296d..a016ab0798 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -768,4 +768,36 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'client hooks: pre-push parallel execution merges stdout to stderr' '\n+\ttest_when_finished \"rm -rf remote-par stdout.actual stderr.actual\" &&\n+\tgit init --bare remote-par &&\n+\tgit remote add origin-par remote-par &&\n+\ttest_commit par-commit &&\n+\tmkdir -p .git/hooks &&\n+\tsetup_hooks pre-push &&\n+\ttest_config hook.jobs 2 &&\n+\tgit push origin-par HEAD:main >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push\n+'\n+\n+test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' '\n+\ttest_when_finished \"rm -rf repo-parallel remote-parallel\" &&\n+\tgit init --bare remote-parallel &&\n+\tgit init repo-parallel &&\n+\tgit -C repo-parallel remote add origin ../remote-parallel &&\n+\ttest_commit -C repo-parallel A &&\n+\n+\twrite_sentinel_hook repo-parallel/.git/hooks/pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.event pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\tgit -C repo-parallel config hook.hook-2.parallel true &&\n+\n+\tgit -C repo-parallel config hook.jobs 2 &&\n+\n+\tgit -C repo-parallel push origin HEAD >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect repo-parallel/hook.order\n+'\n+\n test_done\ndiff --git a/transport.c b/transport.c\nindex 56a4015389..0c0be55b2f 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1391,8 +1391,10 @@ static int run_pre_push_hook(struct transport *transport,\n \topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n \n \t/*\n-\t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n-\t * them to keep backwards compatibility with existing hooks.\n+\t * pre-push hooks keep stdout and stderr separate by default for\n+\t * backwards compatibility. When the user opts into parallel execution\n+\t * via hook.jobs > 1 or -j, get_hook_jobs() will set stdout_to_stderr=1\n+\t * automatically so run-command can de-interleave the outputs.\n \t */\n \topt.stdout_to_stderr = 0;\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539551","messageId":"20260320135311.331463-4-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 3/9] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:05Z","receivedAt":"2026-03-20T13:54:05Z","isPatch":true,"body":"From: Emily Shaffer <nasamuffin@google.com>\n\nHooks always run in sequential order due to the hardcoded jobs == 1\npassed to run_process_parallel(). Remove that hardcoding to allow\nusers to run hooks in parallel (opt-in).\n\nUsers need to decide which hooks to run in parallel, by specifying\n\"parallel = true\" in the config, because git cannot know if their\nspecific hooks are safe to run or not in parallel (for e.g. two hooks\nmight write to the same file or call the same program).\n\nSome hooks are unsafe to run in parallel by design: these will marked\nin the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL.\n\nThe hook.jobs config specifies the default number of jobs applied to all\nhooks which have parallelism enabled.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  13 +++\n hook.c                         |  74 +++++++++++++++--\n hook.h                         |  25 ++++++\n t/t1800-hook.sh                | 142 +++++++++++++++++++++++++++++++++\n 4 files changed, 248 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex b7847f9338..21800db648 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -23,6 +23,19 @@ hook.<friendly-name>.enabled::\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n \n+hook.<friendly-name>.parallel::\n+\tWhether the hook `hook.<friendly-name>` may run in parallel with other hooks\n+\tfor the same event. Defaults to `false`. Set to `true` only when the\n+\thook script is safe to run concurrently with other hooks for the same\n+\tevent. If any hook for an event does not have this set to `true`,\n+\tall hooks for that event run sequentially regardless of `hook.jobs`.\n+\tOnly configured (named) hooks need to declare this. Traditional hooks\n+\tfound in the hooks directory do not need to, and run in parallel when\n+\tthe effective job count is greater than 1. See linkgit:git-hook[1].\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n++\n+This setting has no effect unless all configured hooks for the event have\n+`hook.<friendly-name>.parallel` set to `true`.\ndiff --git a/hook.c b/hook.c\nindex c4872d8707..a60dac5a60 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -120,6 +120,7 @@ struct hook_config_cache_entry {\n \tchar *command;\n \tenum config_scope scope;\n \tunsigned int disabled:1;\n+\tunsigned int parallel:1;\n };\n \n /*\n@@ -127,12 +128,14 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * parallel_hooks: friendly-name to parallel flag.\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tstruct strmap parallel_hooks;\n \tunsigned int jobs;\n };\n \n@@ -223,6 +226,10 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tdefault:\n \t\t\tbreak; /* ignore unrecognised values */\n \t\t}\n+\t} else if (!strcmp(subkey, \"parallel\")) {\n+\t\tint v = git_parse_maybe_bool(value);\n+\t\tif (v >= 0)\n+\t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n \t}\n \n \tfree(hook_name);\n@@ -267,6 +274,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.commands);\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n+\tstrmap_init(&cb_data.parallel_hooks);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -286,6 +294,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tstruct hook_config_cache_entry *entry;\n \t\t\tchar *command;\n \n+\t\t\tint is_par = !!strmap_get(&cb_data.parallel_hooks, hname);\n \t\t\tint is_disabled =\n \t\t\t\t!!unsorted_string_list_lookup(\n \t\t\t\t\t&cb_data.disabled_hooks, hname);\n@@ -306,6 +315,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tentry->command = xstrdup_or_null(command);\n \t\t\tentry->scope = scope;\n \t\t\tentry->disabled = is_disabled;\n+\t\t\tentry->parallel = is_par;\n \t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n@@ -316,6 +326,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tr->hook_jobs = cb_data.jobs;\n \n \tstrmap_clear(&cb_data.commands, 1);\n+\tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstring_list_clear(e->value, 0);\n@@ -392,6 +403,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n@@ -541,21 +553,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/* Determine how many jobs to use for hook execution. */\n+static unsigned int get_hook_jobs(struct repository *r,\n+\t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  struct string_list *hook_list)\n+{\n+\t/*\n+\t * Hooks needing separate output streams must run sequentially.\n+\t * Next commit will allow parallelizing these as well.\n+\t */\n+\tif (!options->stdout_to_stderr)\n+\t\treturn 1;\n+\n+\t/*\n+\t * An explicit job count overrides everything else: this covers both\n+\t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n+\t * and the -j flag from the CLI. The CLI override is intentional: users\n+\t * may want to serialize hooks declared parallel or to parallelize more\n+\t * aggressively than the default.\n+\t */\n+\tif (options->jobs)\n+\t\treturn options->jobs;\n+\n+\t/*\n+\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n+\t * fallback to a direct config lookup (out-of-repo).\n+\t * Default to 1 (serial execution) on failure.\n+\t */\n+\tif (r && r->gitdir && r->hook_config_cache)\n+\t\t/* Use the already-parsed cache (in-repo) */\n+\t\toptions->jobs = r->hook_jobs ? r->hook_jobs : 1;\n+\telse\n+\t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n+\t\tif (repo_config_get_uint(r, \"hook.jobs\", &options->jobs))\n+\t\t\toptions->jobs = 1;\n+\n+\t/*\n+\t * Cap to serial any configured hook not marked as parallel = true.\n+\t * This enforces the parallel = false default, even for \"traditional\"\n+\t * hooks from the hookdir which cannot be marked parallel = true.\n+\t */\n+\tfor (size_t i = 0; i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel) {\n+\t\t\toptions->jobs = 1;\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+\n+\treturn options->jobs;\n+}\n+\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n+\tstruct string_list *hook_list = list_hooks(r, hook_name, options);\n \tstruct hook_cb_data cb_data = {\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n+\t\t.hook_command_list = hook_list,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\n \n-\t\t.processes = options->jobs,\n-\t\t.ungroup = options->jobs == 1,\n+\t\t.processes = jobs,\n+\t\t.ungroup = jobs == 1,\n \n \t\t.get_next_task = pick_next_hook,\n \t\t.start_failure = notify_start_failure,\n@@ -571,9 +637,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->path_to_stdin && options->feed_pipe)\n \t\tBUG(\"options path_to_stdin and feed_pipe are mutually exclusive\");\n \n-\tif (!options->jobs)\n-\t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n-\n \t/*\n \t * Ensure cb_data copy and free functions are either provided together,\n \t * or neither one is provided.\n@@ -584,7 +647,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n \tif (!cb_data.hook_command_list->nr) {\n \t\tif (options->error_if_missing)\n \t\t\tret = error(\"cannot find a hook named %s\", hook_name);\ndiff --git a/hook.h b/hook.h\nindex 7c8c3d471e..494f74345f 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -35,6 +35,13 @@ struct hook {\n \t\t} configured;\n \t} u;\n \n+\t/**\n+\t * Whether this hook may run in parallel with other hooks for the same\n+\t * event. Only useful for configured (named) hooks. Traditional hooks\n+\t * always default to 0 (serial). Set via `hook.<name>.parallel = true`.\n+\t */\n+\tunsigned int parallel:1;\n+\n \t/**\n \t * Opaque data pointer used to keep internal state across callback calls.\n \t *\n@@ -72,6 +79,8 @@ struct run_hooks_opt {\n \t *\n \t * If > 1, output will be buffered and de-interleaved (ungroup=0).\n \t * If == 1, output will be real-time (ungroup=1).\n+\t * If == 0, the 'hook.jobs' config is used or, if the config is unset,\n+\t * defaults to 1 (serial execution).\n \t */\n \tunsigned int jobs;\n \n@@ -152,7 +161,23 @@ struct run_hooks_opt {\n \thook_data_free_fn feed_pipe_cb_data_free;\n };\n \n+/**\n+ * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to\n+ * the 'hook.jobs' config, falling back to serial (1) if unset.\n+ */\n #define RUN_HOOKS_OPT_INIT { \\\n+\t.env = STRVEC_INIT, \\\n+\t.args = STRVEC_INIT, \\\n+\t.stdout_to_stderr = 1, \\\n+\t.jobs = 0, \\\n+}\n+\n+/**\n+ * Initializer for hooks that must always run sequentially regardless of\n+ * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized\n+ * .jobs = 1 is non-overridable.\n+ */\n+#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \\\n \t.env = STRVEC_INIT, \\\n \t.args = STRVEC_INIT, \\\n \t.stdout_to_stderr = 1, \\\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 6f6fe88bea..b1049a296d 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -21,6 +21,57 @@ setup_hookdir () {\n \ttest_when_finished rm -rf .git/hooks\n }\n \n+# write_sentinel_hook <path> [sentinel]\n+#\n+# Writes a hook that marks itself as started, sleeps for a few seconds, then\n+# marks itself done. The sleep must be long enough that sentinel_detector can\n+# observe <sentinel>.started before <sentinel>.done appears when both hooks\n+# run concurrently in parallel mode.\n+write_sentinel_hook () {\n+\tsentinel=\"${2:-sentinel}\"\n+\twrite_script \"$1\" <<-EOF\n+\ttouch ${sentinel}.started &&\n+\tsleep 2 &&\n+\ttouch ${sentinel}.done\n+\tEOF\n+}\n+\n+# sentinel_detector <sentinel> <output>\n+#\n+# Returns a shell command string suitable for use as hook.<name>.command.\n+# The detector must be registered after the sentinel:\n+# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)\n+#    before the detector starts.\n+# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared\n+#    yet and the detector just sees <sentinel>.started.\n+#\n+# At start, poll until <sentinel>.started exists to absorb startup jitter, then\n+# write to <output>:\n+# 1. 'serial'   if <sentinel>.done exists (sentinel finished before we started),\n+# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),\n+# 3. 'timeout'  if <sentinel>.started never appeared.\n+#\n+# The command ends with ':' so when git appends \"$@\" for hooks that receive\n+# positional arguments (e.g. pre-push), the result ': \"$@\"' is valid shell\n+# rather than a syntax error 'fi \"$@\"'.\n+sentinel_detector () {\n+\tcat <<-EOF\n+\ti=0\n+\twhile ! test -f ${1}.started && test \\$i -lt 10; do\n+\t    sleep 1\n+\t    i=\\$((i+1))\n+\tdone\n+\tif test -f ${1}.done; then\n+\t    echo serial >${2}\n+\telif test -f ${1}.started; then\n+\t    echo parallel >${2}\n+\telse\n+\t    echo timeout >${2}\n+\tfi\n+\t:\n+\tEOF\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n@@ -626,4 +677,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'hook.jobs=1 config runs hooks in series' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\n+\t# Use two configured hooks so the execution order is deterministic:\n+\t# hook-1 (sentinel) is listed before hook-2 (detector), so hook-1\n+\t# always runs first even in serial mode.\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 1 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=true enables parallel execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'one non-parallel hook forces the whole event to run serially' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 has no parallel=true: should force serial for all\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539552","messageId":"20260320135311.331463-6-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 5/9] hook: mark non-parallelizable hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:07Z","receivedAt":"2026-03-20T13:54:10Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSeveral hooks are known to be inherently non-parallelizable, so initialize\nthem with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides\nany hook.jobs or runtime -j flags.\n\nThese hooks are:\napplypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit,\npost-checkout, and push-to-checkout.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 14 ++++++++++++++\n builtin/am.c                   |  8 +++++---\n builtin/checkout.c             | 19 +++++++++++++------\n builtin/clone.c                |  6 ++++--\n builtin/receive-pack.c         |  3 ++-\n builtin/worktree.c             |  2 +-\n commit.c                       |  2 +-\n t/t1800-hook.sh                | 16 ++++++++++++++++\n 8 files changed, 56 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 94c7a9808e..6f60775c28 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -36,6 +36,20 @@ hook.<friendly-name>.parallel::\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSome hooks always run sequentially regardless of this setting because\n+\tthey operate on shared data and cannot safely be parallelized:\n++\n+--\n+`applypatch-msg`;;\n+`prepare-commit-msg`;;\n+`commit-msg`;;\n+\tReceive a commit message file and may rewrite it in place.\n+`pre-commit`;;\n+`post-checkout`;;\n+`push-to-checkout`;;\n+`post-commit`;;\n+\tAccess the working tree, index, or repository state.\n+--\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<friendly-name>.parallel` set to `true`.\ndiff --git a/builtin/am.c b/builtin/am.c\nindex 9d0b51c651..271b23160b 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state)\n \n \tassert(state->msg);\n \n-\tif (!state->no_verify)\n-\t\tret = run_hooks_l(the_repository, \"applypatch-msg\",\n-\t\t\t\t  am_path(state, \"final-commit\"), NULL);\n+\tif (!state->no_verify) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\t\tstrvec_push(&opt.args, am_path(state, \"final-commit\"));\n+\t\tret = run_hooks_opt(the_repository, \"applypatch-msg\", &opt);\n+\t}\n \n \tif (!ret) {\n \t\tFREE_AND_NULL(state->msg);\ndiff --git a/builtin/checkout.c b/builtin/checkout.c\nindex 1d1667fa4c..ddbe8474d2 100644\n--- a/builtin/checkout.c\n+++ b/builtin/checkout.c\n@@ -31,6 +31,7 @@\n #include \"resolve-undo.h\"\n #include \"revision.h\"\n #include \"setup.h\"\n+#include \"strvec.h\"\n #include \"submodule.h\"\n #include \"symlinks.h\"\n #include \"trace2.h\"\n@@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info)\n static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit,\n \t\t\t      int changed)\n {\n-\treturn run_hooks_l(the_repository, \"post-checkout\",\n-\t\t\t   oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   changed ? \"1\" : \"0\", NULL);\n-\t/* \"new_commit\" can be NULL when checking out from the index before\n-\t   a commit exists. */\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n+\t/*\n+\t * \"new_commit\" can be NULL when checking out from the index before\n+\t * a commit exists.\n+\t */\n+\tstrvec_pushl(&opt.args,\n+\t\t     oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     changed ? \"1\" : \"0\",\n+\t\t     NULL);\n+\n+\treturn run_hooks_opt(the_repository, \"post-checkout\", &opt);\n }\n \n static int update_some(const struct object_id *oid, struct strbuf *base,\ndiff --git a/builtin/clone.c b/builtin/clone.c\nindex fba3c9c508..d23b0cafcf 100644\n--- a/builtin/clone.c\n+++ b/builtin/clone.c\n@@ -647,6 +647,7 @@ static int checkout(int submodule_progress,\n \tstruct tree *tree;\n \tstruct tree_desc t;\n \tint err = 0;\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \tif (option_no_checkout)\n \t\treturn 0;\n@@ -697,8 +698,9 @@ static int checkout(int submodule_progress,\n \tif (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK))\n \t\tdie(_(\"unable to write new index file\"));\n \n-\terr |= run_hooks_l(the_repository, \"post-checkout\", oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(&oid), \"1\", NULL);\n+\tstrvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(&oid), \"1\", NULL);\n+\terr |= run_hooks_opt(the_repository, \"post-checkout\", &hook_opt);\n \n \tif (!err && (option_recurse_submodules.nr > 0)) {\n \t\tstruct child_process cmd = CHILD_PROCESS_INIT;\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 991d6ca7d5..3c5ba66dd6 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1463,7 +1463,8 @@ static const char *push_to_checkout(unsigned char *hash,\n \t\t\t\t    struct strvec *env,\n \t\t\t\t    const char *work_tree)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n \topt.invoked_hook = invoked_hook;\n \n \tstrvec_pushf(env, \"GIT_WORK_TREE=%s\", absolute_path(work_tree));\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex 4035b1cb06..b6831c19b5 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname,\n \t * is_junk is cleared, but do return appropriate code when hook fails.\n \t */\n \tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n \t\tstrvec_pushl(&opt.args,\ndiff --git a/commit.c b/commit.c\nindex 0ffdd6679e..a2c4ffaac5 100644\n--- a/commit.c\n+++ b/commit.c\n@@ -1979,7 +1979,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len)\n int run_commit_hook(int editor_is_used, const char *index_file,\n \t\t    int *invoked_hook, const char *name, ...)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \tva_list args;\n \tconst char *arg;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex a016ab0798..ad03b3fb78 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -800,4 +800,20 @@ test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1'\n \ttest_cmp expect repo-parallel/hook.order\n '\n \n+test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event pre-commit &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event pre-commit &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.jobs 2 &&\n+\tgit commit --allow-empty -m \"test: verify force-serial on pre-commit\" &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539553","messageId":"20260320135311.331463-7-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 6/9] hook: add -j/--jobs option to git hook run","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:08Z","receivedAt":"2026-03-20T13:54:11Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nExpose the parallel job count as a command-line flag so callers can\nrequest parallelism without relying only on the hook.jobs config.\n\nAdd tests covering serial/parallel execution and TTY behaviour under\n-j1 vs -jN.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  18 ++++-\n builtin/hook.c              |   5 +-\n hook.c                      |  17 +++++\n t/t1800-hook.sh             | 135 ++++++++++++++++++++++++++++++++++--\n 4 files changed, 165 insertions(+), 10 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex e7d399ae57..b4c95a31a8 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -8,7 +8,8 @@ git-hook - Run git hooks\n SYNOPSIS\n --------\n [verse]\n-'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n+\t<hook-name> [-- <hook-args>]\n 'git hook' list [-z] [--show-scope] <hook-name>\n \n DESCRIPTION\n@@ -140,6 +141,18 @@ OPTIONS\n \tmirroring the output style of `git config --show-scope`. Traditional\n \thooks from the hookdir are unaffected.\n \n+-j::\n+--jobs::\n+\tOnly valid for `run`.\n++\n+Specify how many hooks to run simultaneously. If this flag is not specified,\n+the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n+neither is specified, defaults to 1 (serial execution). Some hooks always run\n+sequentially regardless of this flag or the `hook.jobs` config, because git\n+knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`,\n+`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and\n+`push-to-checkout`.\n+\n WRAPPERS\n --------\n \n@@ -162,7 +175,8 @@ running:\n git hook run mywrapper-start-tests \\\n   # providing something to stdin\n   --stdin some-tempfile-123 \\\n-  # execute hooks in serial\n+  # execute multiple hooks in parallel\n+  --jobs 3 \\\n   # plus some arguments of your own...\n   -- \\\n   --testname bar \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex f671e7f91a..4baf60bf36 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -7,7 +7,8 @@\n #include \"parse-options.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n-\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n+\t   \"<hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [-z] [--show-scope] <hook-name>\")\n \n@@ -109,6 +110,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n+\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\ndiff --git a/hook.c b/hook.c\nindex b7cf844c19..0b581a6c43 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -566,6 +566,22 @@ static void merge_output_if_parallel(struct run_hooks_opt *options)\n \t\toptions->stdout_to_stderr = 1;\n }\n \n+static void warn_non_parallel_hooks_override(unsigned int jobs,\n+\t\t\t\t\t     struct string_list *hook_list)\n+{\n+\t/* Don't warn for hooks running sequentially. */\n+\tif (jobs == 1)\n+\t\treturn;\n+\n+\tfor (size_t i = 0; i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n+\t\t\twarning(_(\"hook '%s' is not marked as parallel=true, \"\n+\t\t\t\t  \"running in parallel anyway due to -j%u\"),\n+\t\t\t\th->u.configured.friendly_name, jobs);\n+\t}\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -609,6 +625,7 @@ static unsigned int get_hook_jobs(struct repository *r,\n \n cleanup:\n \tmerge_output_if_parallel(options);\n+\twarn_non_parallel_hooks_override(options->jobs, hook_list);\n \treturn options->jobs;\n }\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex ad03b3fb78..a0a7301701 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -238,10 +238,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n '\n \n test_hook_tty () {\n-\tcat >expect <<-\\EOF\n-\tSTDOUT TTY\n-\tSTDERR TTY\n-\tEOF\n+\texpect_tty=$1\n+\tshift\n+\n+\tif test \"$expect_tty\" != \"no_tty\"; then\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT TTY\n+\t\tSTDERR TTY\n+\t\tEOF\n+\telse\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT NO TTY\n+\t\tSTDERR NO TTY\n+\t\tEOF\n+\tfi\n \n \ttest_when_finished \"rm -rf repo\" &&\n \tgit init repo &&\n@@ -259,12 +269,21 @@ test_hook_tty () {\n \ttest_cmp expect repo/actual\n }\n \n-test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty hook run pre-commit\n+test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '\n+\t# hooks running sequentially (-j1) are always connected to the tty for\n+\t# optimum real-time performance.\n+\ttest_hook_tty tty hook run -j1 pre-commit\n+'\n+\n+test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '\n+\t# Hooks are not connected to the tty when run in parallel, instead they\n+\t# output to a pipe through which run-command collects and de-interlaces\n+\t# their outputs, which then gets passed either to the tty or a sideband.\n+\ttest_hook_tty no_tty hook run -j2 pre-commit\n '\n \n test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty commit -m\"B.new\"\n+\ttest_hook_tty tty commit -m\"B.new\"\n '\n \n test_expect_success 'git hook list orders by config order' '\n@@ -677,6 +696,108 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'parallel hook output is not interleaved' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo \"Hook 1 Start\"\n+\tsleep 1\n+\techo \"Hook 1 End\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t\t    \"echo \\\"Hook 2 Start\\\"; sleep 2; echo \\\"Hook 2 End\\\"\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.hook-3.event test-hook &&\n+\ttest_config hook.hook-3.command \\\n+\t\t    \"echo \\\"Hook 3 Start\\\"; sleep 3; echo \\\"Hook 3 End\\\"\" &&\n+\ttest_config hook.hook-3.parallel true &&\n+\n+\tgit hook run -j3 test-hook >out 2>err.parallel &&\n+\n+\t# Verify Hook 1 output is grouped\n+\tsed -n \"/Hook 1 Start/,/Hook 1 End/p\" err.parallel >hook1_out &&\n+\ttest_line_count = 2 hook1_out &&\n+\n+\t# Verify Hook 2 output is grouped\n+\tsed -n \"/Hook 2 Start/,/Hook 2 End/p\" err.parallel >hook2_out &&\n+\ttest_line_count = 2 hook2_out &&\n+\n+\t# Verify Hook 3 output is grouped\n+\tsed -n \"/Hook 3 Start/,/Hook 3 End/p\" err.parallel >hook3_out &&\n+\ttest_line_count = 2 hook3_out\n+'\n+\n+test_expect_success 'git hook run -j1 runs hooks in series' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\ttest_config hook.series-1.event \"test-hook\" &&\n+\ttest_config hook.series-1.command \"echo 1\" --add &&\n+\ttest_config hook.series-2.event \"test-hook\" &&\n+\ttest_config hook.series-2.command \"echo 2\" --add &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo 3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tgit hook run -j1 test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook run -j2 runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\tgit hook run -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 overrides parallel=false' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# -j2 overrides parallel=false; hooks run in parallel with a warning.\n+\tgit hook run -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"true\" &&\n+\t# neither hook has parallel=true\n+\n+\tgit hook run -j2 test-hook >out 2>err &&\n+\tgrep \"hook .hook-1. is not marked as parallel=true\" err &&\n+\tgrep \"hook .hook-2. is not marked as parallel=true\" err\n+'\n+\n test_expect_success 'hook.jobs=1 config runs hooks in series' '\n \ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539554","messageId":"20260320135311.331463-9-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 8/9] hook: warn when hook.<friendly-name>.jobs is set","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:10Z","receivedAt":"2026-03-20T13:54:16Z","isPatch":true,"body":"Issue a warning when the user confuses the hook process and event\nnamespaces by setting hook.<friendly-name>.jobs.\n\nDetect this by checking whether the name carrying .jobs also has\n.command, .event, or .parallel configured.  Extract is_friendly_name()\nas a helper for this check, to be reused by future per-event config\nhandling.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n hook.c          | 40 ++++++++++++++++++++++++++++++++++++++++\n t/t1800-hook.sh | 30 ++++++++++++++++++++++++++++++\n 2 files changed, 70 insertions(+)\n\ndiff --git a/hook.c b/hook.c\nindex e40c1f3a85..3d73447086 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -276,6 +276,44 @@ void hook_cache_clear(struct strmap *cache)\n \tstrmap_clear(cache, 0);\n }\n \n+/*\n+ * Return true if `name` is a hook friendly-name, i.e. it has at least one of\n+ * .command, .event, or .parallel configured. These are the reliable clues\n+ * that distinguish a friendly-name from an event name. Note: .enabled is\n+ * deliberately excluded because it can appear under both namespaces.\n+ */\n+static int is_friendly_name(struct hook_all_config_cb *cb, const char *name)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tif (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name))\n+\t\treturn 1;\n+\n+\tstrmap_for_each_entry(&cb->event_hooks, &iter, e) {\n+\t\tif (unsorted_string_list_lookup(e->value, name))\n+\t\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+/* Warn if any name in event_jobs is also a hook friendly-name. */\n+static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tstrmap_for_each_entry(&cb_data->event_jobs, &iter, e) {\n+\t\tif (is_friendly_name(cb_data, e->key))\n+\t\t\twarning(_(\"hook.%s.jobs is set but '%s' looks like a \"\n+\t\t\t\t  \"hook friendly-name, not an event name; \"\n+\t\t\t\t  \"hook.<event>.jobs uses the event name \"\n+\t\t\t\t  \"(e.g. hook.post-receive.jobs), so this \"\n+\t\t\t\t  \"setting will be ignored\"), e->key, e->key);\n+\t}\n+}\n+\n /* Populate `cache` with the complete hook configuration */\n static void build_hook_config_map(struct repository *r, struct strmap *cache)\n {\n@@ -292,6 +330,8 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n+\twarn_jobs_on_friendly_names(&cb_data);\n+\n \t/* Construct the cache from parsed configs. */\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstruct string_list *hook_names = e->value;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex cf4dc1ce6f..e8005199c7 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -996,4 +996,34 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' '\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .event' '\n+\ttest_config hook.my-hook.event test-hook &&\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .parallel' '\n+\ttest_config hook.my-hook.event test-hook &&\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.parallel true &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n+\ttest_config hook.test-hook.jobs 2 &&\n+\tgit hook run --ignore-missing test-hook >out 2>err &&\n+\ttest_grep ! \"friendly-name\" err\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539555","messageId":"20260320135311.331463-8-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 7/9] hook: add per-event jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:09Z","receivedAt":"2026-03-20T13:54:16Z","isPatch":true,"body":"Add a hook.<event>.jobs count config that allows users to override the\nglobal hook.jobs setting for specific hook events.\n\nThis allows finer-grained control over parallelism on a per-event basis.\n\nFor example, to run `post-receive` hooks with up to 4 parallel jobs\nwhile keeping other events at their global default:\n\n[hook]\n    post-receive.jobs = 4\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 19 +++++++++++\n hook.c                         | 38 +++++++++++++++++++---\n repository.c                   |  1 +\n repository.h                   |  3 ++\n t/t1800-hook.sh                | 59 ++++++++++++++++++++++++++++++++++\n 5 files changed, 116 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 6f60775c28..d4fa29d936 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -33,9 +33,28 @@ hook.<friendly-name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.jobs::\n+\tSpecifies how many hooks can be run simultaneously for the `<event>`\n+\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n+\tfor this specific event. The same parallelism restrictions apply: this\n+\tsetting has no effect unless all configured hooks for the event have\n+\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\tzero is rejected with a warning. See linkgit:git-hook[1].\n++\n+Note on naming: although this key resembles `hook.<friendly-name>.*`\n+(a per-hook setting), `<event>` must be the event name, not a hook\n+friendly name. The key component is stored literally and looked up by\n+event name at runtime with no translation between the two namespaces.\n+A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n+lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n+`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n+registered for that event. Use `hook.post-receive.jobs` or any other\n+valid event name when setting `hook.<event>.jobs`.\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tthey operate on shared data and cannot safely be parallelized:\n +\ndiff --git a/hook.c b/hook.c\nindex 0b581a6c43..e40c1f3a85 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -129,6 +129,7 @@ struct hook_config_cache_entry {\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n  * parallel_hooks: friendly-name to parallel flag.\n+ * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n@@ -136,6 +137,7 @@ struct hook_all_config_cb {\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n \tstruct strmap parallel_hooks;\n+\tstruct strmap event_jobs;\n \tunsigned int jobs;\n };\n \n@@ -230,6 +232,16 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tint v = git_parse_maybe_bool(value);\n \t\tif (v >= 0)\n \t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n+\t} else if (!strcmp(subkey, \"jobs\")) {\n+\t\tunsigned int v;\n+\t\tif (!git_parse_uint(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive integer, ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n+\t\telse if (!v)\n+\t\t\twarning(_(\"hook.%s.jobs must be positive, ignoring: 0\"), hook_name);\n+\t\telse\n+\t\t\tstrmap_put(&data->event_jobs, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)v);\n \t}\n \n \tfree(hook_name);\n@@ -275,6 +287,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \tstrmap_init(&cb_data.parallel_hooks);\n+\tstrmap_init(&cb_data.event_jobs);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -322,8 +335,10 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tstrmap_put(cache, e->key, hooks);\n \t}\n \n-\tif (r)\n+\tif (r) {\n \t\tr->hook_jobs = cb_data.jobs;\n+\t\tr->event_jobs = cb_data.event_jobs;\n+\t}\n \n \tstrmap_clear(&cb_data.commands, 1);\n \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n@@ -585,6 +600,7 @@ static void warn_non_parallel_hooks_override(unsigned int jobs,\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  const char *hook_name,\n \t\t\t\t  struct string_list *hook_list)\n {\n \t/*\n@@ -602,18 +618,32 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t * fallback to a direct config lookup (out-of-repo).\n \t * Default to 1 (serial execution) on failure.\n \t */\n-\tif (r && r->gitdir && r->hook_config_cache)\n+\tif (r && r->gitdir && r->hook_config_cache) {\n \t\t/* Use the already-parsed cache (in-repo) */\n+\t\tvoid *event_jobs = strmap_get(&r->event_jobs, hook_name);\n \t\toptions->jobs = r->hook_jobs ? r->hook_jobs : 1;\n-\telse\n+\t\tif (event_jobs)\n+\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n+\t} else {\n \t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n+\t\tunsigned int event_jobs;\n+\t\tchar *key;\n+\n \t\tif (repo_config_get_uint(r, \"hook.jobs\", &options->jobs))\n \t\t\toptions->jobs = 1;\n \n+\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n+\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n+\t\t\toptions->jobs = event_jobs;\n+\t\tfree(key);\n+\t}\n+\n \t/*\n \t * Cap to serial any configured hook not marked as parallel = true.\n \t * This enforces the parallel = false default, even for \"traditional\"\n \t * hooks from the hookdir which cannot be marked parallel = true.\n+\t * The same restriction applies whether jobs came from hook.jobs or\n+\t * hook.<event>.jobs.\n \t */\n \tfor (size_t i = 0; i < hook_list->nr; i++) {\n \t\tstruct hook *h = hook_list->items[i].util;\n@@ -640,7 +670,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n-\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\ndiff --git a/repository.c b/repository.c\nindex fb4356ca55..ff3c357dfc 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -425,6 +425,7 @@ void repo_clear(struct repository *repo)\n \t\thook_cache_clear(repo->hook_config_cache);\n \t\tFREE_AND_NULL(repo->hook_config_cache);\n \t}\n+\tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n \n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 58e46853d0..6b67ec02e2 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -175,6 +175,9 @@ struct repository {\n \t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n \tunsigned int hook_jobs;\n \n+\t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n+\tstruct strmap event_jobs;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex a0a7301701..cf4dc1ce6f 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -937,4 +937,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=1 (serial), but per-event override allows parallel.\n+\ttest_config hook.jobs 1 &&\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=4 allows parallel, but per-event override forces serial.\n+\ttest_config hook.jobs 4 &&\n+\ttest_config hook.test-hook.jobs 1 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# Per-event jobs=2 but no hook has parallel=true: must still run serially.\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539556","messageId":"20260320135311.331463-10-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"[PATCH v4 9/9] hook: add hook.<event>.enabled switch","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-20T13:53:11Z","receivedAt":"2026-03-20T13:54:20Z","isPatch":true,"body":"Add a hook.<event>.enabled config key that disables all hooks for\na given event, when set to false, acting as a high-level switch\nabove the existing per-hook hook.<friendly-name>.enabled.\n\nEvent-disabled hooks are shown in \"git hook list\" with an\n\"event-disabled\" tab-separated prefix before the name:\n\n$ git hook list test-hook\nevent-disabled  hook-1\nevent-disabled  hook-2\n\nWith --show-scope:\n\n$ git hook list --show-scope test-hook\nlocal   event-disabled  hook-1\n\nWhen a hook is both per-hook disabled and event-disabled, only\n\"event-disabled\" is shown: the event-level switch is the more\nrelevant piece of information, and the per-hook \"disabled\" status\nwill surface once the event is re-enabled.\n\nReuses is_friendly_name() from the previous commit to distinguish\nevent names from friendly-names when processing .enabled settings.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 12 +++++++\n builtin/hook.c                 | 20 +++++++----\n hook.c                         | 30 ++++++++++++++--\n hook.h                         |  1 +\n repository.c                   |  1 +\n repository.h                   |  4 +++\n t/t1800-hook.sh                | 62 ++++++++++++++++++++++++++++++++++\n 7 files changed, 121 insertions(+), 9 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex d4fa29d936..0a9f04b154 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -33,6 +33,18 @@ hook.<friendly-name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.enabled::\n+\tSwitch to enable or disable all hooks for the `<event>` hook event.\n+\tWhen set to `false`, no hooks fire for that event, regardless of any\n+\tper-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`.\n+\tSee linkgit:git-hook[1].\n++\n+Note on naming: `<event>` must be the event name (e.g. `pre-commit`),\n+not a hook friendly-name. A name that also carries `.command`, `.event`,\n+or `.parallel` is treated as a friendly-name and its `.enabled` value\n+applies only to that individual hook. See `hook.<friendly-name>.enabled`\n+above.\n+\n hook.<event>.jobs::\n \tSpecifies how many hooks can be run simultaneously for the `<event>`\n \thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 4baf60bf36..0def50bcac 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -77,14 +77,22 @@ static int list(int argc, const char **argv, const char *prefix,\n \t\t\tconst char *name = h->u.configured.friendly_name;\n \t\t\tconst char *scope = show_scope ?\n \t\t\t\tconfig_scope_name(h->u.configured.scope) : NULL;\n+\t\t\t/*\n+\t\t\t * Show the most relevant disable reason. Event-level\n+\t\t\t * takes precedence: if the whole event is off, that\n+\t\t\t * is what the user needs to know. The per-hook\n+\t\t\t * \"disabled\" surfaces once the event is re-enabled.\n+\t\t\t */\n+\t\t\tconst char *disability =\n+\t\t\t\th->u.configured.event_disabled ? \"event-disabled\\t\" :\n+\t\t\t\th->u.configured.disabled       ? \"disabled\\t\"       :\n+\t\t\t\t\t\t\t\t \"\";\n \t\t\tif (scope)\n-\t\t\t\tprintf(\"%s\\t%s%s%c\", scope,\n-\t\t\t\t       h->u.configured.disabled ? \"disabled\\t\" : \"\",\n-\t\t\t\t       name, line_terminator);\n+\t\t\t\tprintf(\"%s\\t%s%s%c\", scope, disability, name,\n+\t\t\t\t       line_terminator);\n \t\t\telse\n-\t\t\t\tprintf(\"%s%s%c\",\n-\t\t\t\t       h->u.configured.disabled ? \"disabled\\t\" : \"\",\n-\t\t\t\t       name, line_terminator);\n+\t\t\t\tprintf(\"%s%s%c\", disability, name,\n+\t\t\t\t       line_terminator);\n \t\t\tbreak;\n \t\t}\n \t\tdefault:\ndiff --git a/hook.c b/hook.c\nindex 3d73447086..a3abe89777 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -127,7 +127,9 @@ struct hook_config_cache_entry {\n  * Callback struct to collect all hook.* keys in a single config pass.\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n- * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * disabled_hooks: set of all names with hook.<name>.enabled = false; after\n+ *                 parsing, names that are not friendly-names become event-level\n+ *                 disables stored in cache->event_disabled. This collects all.\n  * parallel_hooks: friendly-name to parallel flag.\n  * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n@@ -332,6 +334,22 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \n \twarn_jobs_on_friendly_names(&cb_data);\n \n+\t/*\n+\t * Populate event_disabled: names in disabled_hooks that are not\n+\t * friendly-names are event-level switches (hook.<event>.enabled = false).\n+\t * Names that are friendly-names are already handled per-hook via the\n+\t * hook_config_cache_entry.disabled flag below.\n+\t */\n+\tif (r) {\n+\t\tstring_list_clear(&r->event_disabled, 0);\n+\t\tstring_list_init_dup(&r->event_disabled);\n+\t\tfor (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) {\n+\t\t\tconst char *n = cb_data.disabled_hooks.items[i].string;\n+\t\t\tif (!is_friendly_name(&cb_data, n))\n+\t\t\t\tstring_list_append(&r->event_disabled, n);\n+\t\t}\n+\t}\n+\n \t/* Construct the cache from parsed configs. */\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstruct string_list *hook_names = e->value;\n@@ -433,6 +451,8 @@ static void list_hooks_add_configured(struct repository *r,\n {\n \tstruct strmap *cache = get_hook_config_cache(r);\n \tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n+\tint event_is_disabled = r ? !!unsorted_string_list_lookup(&r->event_disabled,\n+\t\t\t\t\t\t\t\t   hookname) : 0;\n \n \t/* Iterate through configured hooks and initialize internal states */\n \tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n@@ -458,6 +478,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->u.configured.event_disabled = event_is_disabled;\n \t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n@@ -470,6 +491,8 @@ static void list_hooks_add_configured(struct repository *r,\n \tif (!r || !r->gitdir) {\n \t\thook_cache_clear(cache);\n \t\tfree(cache);\n+\t\tif (r)\n+\t\t\tstring_list_clear(&r->event_disabled, 0);\n \t}\n }\n \n@@ -501,7 +524,7 @@ int hook_exists(struct repository *r, const char *name)\n \tfor (size_t i = 0; i < hooks->nr; i++) {\n \t\tstruct hook *h = hooks->items[i].util;\n \t\tif (h->kind == HOOK_TRADITIONAL ||\n-\t\t    !h->u.configured.disabled) {\n+\t\t    (!h->u.configured.disabled && !h->u.configured.event_disabled)) {\n \t\t\texists = 1;\n \t\t\tbreak;\n \t\t}\n@@ -524,7 +547,8 @@ static int pick_next_hook(struct child_process *cp,\n \t\tif (hook_cb->hook_to_run_index >= hook_list->nr)\n \t\t\treturn 0;\n \t\th = hook_list->items[hook_cb->hook_to_run_index++].util;\n-\t} while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled);\n+\t} while (h->kind == HOOK_CONFIGURED &&\n+\t\t (h->u.configured.disabled || h->u.configured.event_disabled));\n \n \tcp->no_stdin = 1;\n \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\ndiff --git a/hook.h b/hook.h\nindex fefcd004c0..6bff3d15e4 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -32,6 +32,7 @@ struct hook {\n \t\t\tconst char *command;\n \t\t\tenum config_scope scope;\n \t\t\tunsigned int disabled:1;\n+\t\t\tunsigned int event_disabled:1;\n \t\t} configured;\n \t} u;\n \ndiff --git a/repository.c b/repository.c\nindex ff3c357dfc..c4468e29c1 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -426,6 +426,7 @@ void repo_clear(struct repository *repo)\n \t\tFREE_AND_NULL(repo->hook_config_cache);\n \t}\n \tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n+\tstring_list_clear(&repo->event_disabled, 0);\n \n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 6b67ec02e2..745af10842 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -2,6 +2,7 @@\n #define REPOSITORY_H\n \n #include \"strmap.h\"\n+#include \"string-list.h\"\n #include \"repo-settings.h\"\n #include \"environment.h\"\n \n@@ -178,6 +179,9 @@ struct repository {\n \t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n \tstruct strmap event_jobs;\n \n+\t/* Cached list of event names with hook.<event>.enabled = false. */\n+\tstruct string_list event_disabled;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex e8005199c7..44355b8bd5 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1026,4 +1026,66 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n \ttest_grep ! \"friendly-name\" err\n '\n \n+test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook run test-hook >out 2>err &&\n+\ttest_must_be_empty out\n+'\n+\n+test_expect_success 'hook.<event>.enabled=true does not suppress hooks' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled true &&\n+\tgit hook run test-hook >out 2>err &&\n+\ttest_grep \"ran\" err\n+'\n+\n+test_expect_success 'hook.<event>.enabled=false does not affect other events' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.other-event.enabled false &&\n+\tgit hook run test-hook >out 2>err &&\n+\ttest_grep \"ran\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.enabled=false still disables that hook' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo hook-1\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"echo hook-2\" &&\n+\ttest_config hook.hook-1.enabled false &&\n+\tgit hook run test-hook >out 2>err &&\n+\ttest_grep ! \"hook-1\" err &&\n+\ttest_grep \"hook-2\" err\n+'\n+\n+test_expect_success 'git hook list shows event-disabled hooks as event-disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list test-hook >actual &&\n+\ttest_grep \"^event-disabled\thook-1$\" actual &&\n+\ttest_grep \"^event-disabled\thook-2$\" actual\n+'\n+\n+test_expect_success 'git hook list shows scope with event-disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --show-scope test-hook >actual &&\n+\ttest_grep \"^local\tevent-disabled\thook-1$\" actual\n+'\n+\n+test_expect_success 'git hook list still shows hooks when event is disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list test-hook >actual &&\n+\ttest_grep \"event-disabled\" actual\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"539573","messageId":"xmqqtsuae7nt.fsf@gitster.g","threadId":"64919","inReplyTo":"20260320135311.331463-1-adrian.ratiu@collabora.com","subject":"Re: [PATCH v4 0/9] Run hooks in parallel","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-20T17:24:06Z","receivedAt":"2026-03-20T17:24:09Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> Range-diff v3 -> v4:\n>  1:  6686d92867 =  1:  ec274c24e5 repository: fix repo_init() memleak due to missing _clear()\n\nThis one is not included in the set.  This cover letter identifies\nitself as [0/9], but the range-diff implies it should have 10\npatches.\n\nCurious.\n\nIn the meantime, let me resurrect the corresponding patch from the\nprevious, trusting that range-diff deems them identical.\n\n"},{"id":"539747","messageId":"87h5q660un.fsf@gentoo.mail-host-address-is-not-set","threadId":"64919","inReplyTo":"xmqqtsuae7nt.fsf@gitster.g","subject":"Re: [PATCH v4 0/9] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-23T15:07:28Z","receivedAt":"2026-03-23T15:07:56Z","isPatch":true,"body":"On Fri, 20 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> Range-diff v3 -> v4:\n>>  1:  6686d92867 =  1:  ec274c24e5 repository: fix repo_init() memleak due to missing _clear()\n>\n> This one is not included in the set.  This cover letter identifies\n> itself as [0/9], but the range-diff implies it should have 10\n> patches.\n>\n> Curious.\n>\n> In the meantime, let me resurrect the corresponding patch from the\n> previous, trusting that range-diff deems them identical.\n\nYes, it's identical. Sorry for missing that 1 patch.\n\nI mis-typed git format-patch HEAD~9 instead of HEAD~10. :)\n"},{"id":"539819","messageId":"acJUWcNwNOIFDL9B@pks.im","threadId":"64919","inReplyTo":"87h5q660un.fsf@gentoo.mail-host-address-is-not-set","subject":"Re: [PATCH v4 0/9] Run hooks in parallel","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-24T09:07:37Z","receivedAt":"2026-03-24T09:07:44Z","isPatch":true,"body":"On Mon, Mar 23, 2026 at 05:07:28PM +0200, Adrian Ratiu wrote:\n> On Fri, 20 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> > Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n> >\n> >> Range-diff v3 -> v4:\n> >>  1:  6686d92867 =  1:  ec274c24e5 repository: fix repo_init() memleak due to missing _clear()\n> >\n> > This one is not included in the set.  This cover letter identifies\n> > itself as [0/9], but the range-diff implies it should have 10\n> > patches.\n> >\n> > Curious.\n> >\n> > In the meantime, let me resurrect the corresponding patch from the\n> > previous, trusting that range-diff deems them identical.\n> \n> Yes, it's identical. Sorry for missing that 1 patch.\n> \n> I mis-typed git format-patch HEAD~9 instead of HEAD~10. :)\n\nI can only recommend the use of b4. It will make your life a ton easier\nwith mailing list based workflows as you don't have to manually keep\ntrack of shenanigans like that.\n\nPatrick\n"},{"id":"539820","messageId":"acJUXq7cMKG6KExe@pks.im","threadId":"64919","inReplyTo":"20260320135311.331463-3-adrian.ratiu@collabora.com","subject":"Re: [PATCH v4 2/9] hook: parse the hook.jobs config","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-24T09:07:42Z","receivedAt":"2026-03-24T09:07:48Z","isPatch":true,"body":"On Fri, Mar 20, 2026 at 03:53:04PM +0200, Adrian Ratiu wrote:\n> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n> index 9e78f26439..b7847f9338 100644\n> --- a/Documentation/config/hook.adoc\n> +++ b/Documentation/config/hook.adoc\n> @@ -22,3 +22,7 @@ hook.<friendly-name>.enabled::\n>  \tconfiguration. This is particularly useful when a hook is defined\n>  \tin a system or global config file and needs to be disabled for a\n>  \tspecific repository. See linkgit:git-hook[1].\n> +\n> +hook.jobs::\n> +\tSpecifies how many hooks can be run simultaneously during parallelized\n> +\thook execution. If unspecified, defaults to 1 (serial execution).\n\nI was wondering whether we also want to allow -1 as a way to say \"use as\nmany jobs as I have CPU cores\". We also do this in some other places.\n\nTotally fine to ignore this for now though, we can still add it at a\nlater point in time once somebody complains.\n\nPatrick\n"},{"id":"539821","messageId":"acJUY9N4oplsTgFY@pks.im","threadId":"64919","inReplyTo":"20260320135311.331463-4-adrian.ratiu@collabora.com","subject":"Re: [PATCH v4 3/9] hook: allow parallel hook execution","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-24T09:07:47Z","receivedAt":"2026-03-24T09:07:53Z","isPatch":true,"body":"On Fri, Mar 20, 2026 at 03:53:05PM +0200, Adrian Ratiu wrote:\n> From: Emily Shaffer <nasamuffin@google.com>\n> \n> Hooks always run in sequential order due to the hardcoded jobs == 1\n> passed to run_process_parallel(). Remove that hardcoding to allow\n> users to run hooks in parallel (opt-in).\n> \n> Users need to decide which hooks to run in parallel, by specifying\n> \"parallel = true\" in the config, because git cannot know if their\n\ns/git/Git/\n\nSorry to be pedantic :)\n\n> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n> index b7847f9338..21800db648 100644\n> --- a/Documentation/config/hook.adoc\n> +++ b/Documentation/config/hook.adoc\n> @@ -23,6 +23,19 @@ hook.<friendly-name>.enabled::\n>  \tin a system or global config file and needs to be disabled for a\n>  \tspecific repository. See linkgit:git-hook[1].\n>  \n> +hook.<friendly-name>.parallel::\n> +\tWhether the hook `hook.<friendly-name>` may run in parallel with other hooks\n> +\tfor the same event. Defaults to `false`. Set to `true` only when the\n> +\thook script is safe to run concurrently with other hooks for the same\n> +\tevent. If any hook for an event does not have this set to `true`,\n> +\tall hooks for that event run sequentially regardless of `hook.jobs`.\n> +\tOnly configured (named) hooks need to declare this. Traditional hooks\n> +\tfound in the hooks directory do not need to, and run in parallel when\n> +\tthe effective job count is greater than 1. See linkgit:git-hook[1].\n\nThanks for adding this setting, this addresses my most important concern\nwith this patch series.\n\n>  hook.jobs::\n>  \tSpecifies how many hooks can be run simultaneously during parallelized\n>  \thook execution. If unspecified, defaults to 1 (serial execution).\n> ++\n> +This setting has no effect unless all configured hooks for the event have\n> +`hook.<friendly-name>.parallel` set to `true`.\n\nI guess that's a fair constraint for now. We can still iterate going\nforward and have those marked as parallelizable run in parallel, while\nrunning the others sequentially.\n\n> diff --git a/hook.c b/hook.c\n> index c4872d8707..a60dac5a60 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -120,6 +120,7 @@ struct hook_config_cache_entry {\n>  \tchar *command;\n>  \tenum config_scope scope;\n>  \tunsigned int disabled:1;\n> +\tunsigned int parallel:1;\n>  };\n\nI'd recommend to use a proper bool here.\n\n> @@ -223,6 +226,10 @@ static int hook_config_lookup_all(const char *key, const char *value,\n>  \t\tdefault:\n>  \t\t\tbreak; /* ignore unrecognised values */\n>  \t\t}\n> +\t} else if (!strcmp(subkey, \"parallel\")) {\n> +\t\tint v = git_parse_maybe_bool(value);\n> +\t\tif (v >= 0)\n> +\t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n\nDo we want to warn on unparseable values?\n\n> @@ -541,21 +553,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n>  \tstrvec_clear(&options->args);\n>  }\n>  \n> +/* Determine how many jobs to use for hook execution. */\n> +static unsigned int get_hook_jobs(struct repository *r,\n> +\t\t\t\t  struct run_hooks_opt *options,\n> +\t\t\t\t  struct string_list *hook_list)\n> +{\n> +\t/*\n> +\t * Hooks needing separate output streams must run sequentially.\n> +\t * Next commit will allow parallelizing these as well.\n> +\t */\n> +\tif (!options->stdout_to_stderr)\n> +\t\treturn 1;\n> +\n> +\t/*\n> +\t * An explicit job count overrides everything else: this covers both\n> +\t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n> +\t * and the -j flag from the CLI. The CLI override is intentional: users\n> +\t * may want to serialize hooks declared parallel or to parallelize more\n> +\t * aggressively than the default.\n> +\t */\n> +\tif (options->jobs)\n> +\t\treturn options->jobs;\n\nHm, okay. I feel like this behaviour is somewhat surprising given that\nit now operates different compared to the config value. But arguably,\nthere is no reason why a caller of git-hook(1) should explicitly set\nthis flag as it should be under control of the user, unless they have a\nvery good reason to override the number of jobs.\n\nSo maybe this is fine, but I think it needs to be called out explicitly\nin our docs.\n\n> +\t/*\n> +\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n> +\t * fallback to a direct config lookup (out-of-repo).\n> +\t * Default to 1 (serial execution) on failure.\n> +\t */\n> +\tif (r && r->gitdir && r->hook_config_cache)\n> +\t\t/* Use the already-parsed cache (in-repo) */\n> +\t\toptions->jobs = r->hook_jobs ? r->hook_jobs : 1;\n> +\telse\n> +\t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n> +\t\tif (repo_config_get_uint(r, \"hook.jobs\", &options->jobs))\n> +\t\t\toptions->jobs = 1;\n\nWe first have a check for `r != NULL`, but if I'm not mistaken\n`repo_config_get_uint()` will cause us to unconditionally deref `r`.\n\n> diff --git a/hook.h b/hook.h\n> index 7c8c3d471e..494f74345f 100644\n> --- a/hook.h\n> +++ b/hook.h\n> @@ -35,6 +35,13 @@ struct hook {\n>  \t\t} configured;\n>  \t} u;\n>  \n> +\t/**\n> +\t * Whether this hook may run in parallel with other hooks for the same\n> +\t * event. Only useful for configured (named) hooks. Traditional hooks\n> +\t * always default to 0 (serial). Set via `hook.<name>.parallel = true`.\n> +\t */\n> +\tunsigned int parallel:1;\n> +\n>  \t/**\n>  \t * Opaque data pointer used to keep internal state across callback calls.\n>  \t *\n\nThis should likely also be a boolean.\n\nPatrick\n"},{"id":"539822","messageId":"acJUaVquXMhYn8CD@pks.im","threadId":"64919","inReplyTo":"20260320135311.331463-7-adrian.ratiu@collabora.com","subject":"Re: [PATCH v4 6/9] hook: add -j/--jobs option to git hook run","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-24T09:07:53Z","receivedAt":"2026-03-24T09:07:59Z","isPatch":true,"body":"On Fri, Mar 20, 2026 at 03:53:08PM +0200, Adrian Ratiu wrote:\n> diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\n> index e7d399ae57..b4c95a31a8 100644\n> --- a/Documentation/git-hook.adoc\n> +++ b/Documentation/git-hook.adoc\n> @@ -140,6 +141,18 @@ OPTIONS\n>  \tmirroring the output style of `git config --show-scope`. Traditional\n>  \thooks from the hookdir are unaffected.\n>  \n> +-j::\n> +--jobs::\n> +\tOnly valid for `run`.\n> ++\n> +Specify how many hooks to run simultaneously. If this flag is not specified,\n> +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n> +neither is specified, defaults to 1 (serial execution). Some hooks always run\n> +sequentially regardless of this flag or the `hook.jobs` config, because git\n> +knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`,\n> +`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and\n> +`push-to-checkout`.\n\nI guess this is the place where we need to point out that this will also\noverride the `hook.<friendly-name>.parallel` setting.\n\nPatrick\n"},{"id":"539823","messageId":"acJUcIa9JerWgxI2@pks.im","threadId":"64919","inReplyTo":"20260320135311.331463-8-adrian.ratiu@collabora.com","subject":"Re: [PATCH v4 7/9] hook: add per-event jobs config","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-24T09:08:00Z","receivedAt":"2026-03-24T09:08:06Z","isPatch":true,"body":"On Fri, Mar 20, 2026 at 03:53:09PM +0200, Adrian Ratiu wrote:\n> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n> index 6f60775c28..d4fa29d936 100644\n> --- a/Documentation/config/hook.adoc\n> +++ b/Documentation/config/hook.adoc\n> @@ -33,9 +33,28 @@ hook.<friendly-name>.parallel::\n>  \tfound in the hooks directory do not need to, and run in parallel when\n>  \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n>  \n> +hook.<event>.jobs::\n> +\tSpecifies how many hooks can be run simultaneously for the `<event>`\n> +\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n> +\tfor this specific event. The same parallelism restrictions apply: this\n> +\tsetting has no effect unless all configured hooks for the event have\n> +\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n> +\tzero is rejected with a warning. See linkgit:git-hook[1].\n> ++\n> +Note on naming: although this key resembles `hook.<friendly-name>.*`\n> +(a per-hook setting), `<event>` must be the event name, not a hook\n> +friendly name. The key component is stored literally and looked up by\n> +event name at runtime with no translation between the two namespaces.\n> +A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n> +lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n> +`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n> +registered for that event. Use `hook.post-receive.jobs` or any other\n> +valid event name when setting `hook.<event>.jobs`.\n\nThis makes sense of course, but it feels like something that might be a\nbit confusing for end users. It would be great to have an explicit check\nfor whether or not \"hook.<friendly-name>.jobs\" exists so that we can\nprint a warning if so.\n\nPatrick\n"},{"id":"539824","messageId":"acJUdkaddmaSeJJb@pks.im","threadId":"64919","inReplyTo":"20260320135311.331463-9-adrian.ratiu@collabora.com","subject":"Re: [PATCH v4 8/9] hook: warn when hook.<friendly-name>.jobs is set","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-24T09:08:06Z","receivedAt":"2026-03-24T09:08:11Z","isPatch":true,"body":"On Fri, Mar 20, 2026 at 03:53:10PM +0200, Adrian Ratiu wrote:\n> Issue a warning when the user confuses the hook process and event\n> namespaces by setting hook.<friendly-name>.jobs.\n> \n> Detect this by checking whether the name carrying .jobs also has\n> .command, .event, or .parallel configured.  Extract is_friendly_name()\n> as a helper for this check, to be reused by future per-event config\n> handling.\n> \n> Suggested-by: Junio C Hamano <gitster@pobox.com>\n> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n\nOh, you already do so. Great :)\n\nPatrick\n"},{"id":"539825","messageId":"acJUfVjby_QyZvj1@pks.im","threadId":"64919","inReplyTo":"20260320135311.331463-10-adrian.ratiu@collabora.com","subject":"Re: [PATCH v4 9/9] hook: add hook.<event>.enabled switch","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-24T09:08:13Z","receivedAt":"2026-03-24T09:08:18Z","isPatch":true,"body":"On Fri, Mar 20, 2026 at 03:53:11PM +0200, Adrian Ratiu wrote:\n> Add a hook.<event>.enabled config key that disables all hooks for\n> a given event, when set to false, acting as a high-level switch\n> above the existing per-hook hook.<friendly-name>.enabled.\n> \n> Event-disabled hooks are shown in \"git hook list\" with an\n> \"event-disabled\" tab-separated prefix before the name:\n> \n> $ git hook list test-hook\n> event-disabled  hook-1\n> event-disabled  hook-2\n> \n> With --show-scope:\n> \n> $ git hook list --show-scope test-hook\n> local   event-disabled  hook-1\n> \n> When a hook is both per-hook disabled and event-disabled, only\n> \"event-disabled\" is shown: the event-level switch is the more\n> relevant piece of information, and the per-hook \"disabled\" status\n> will surface once the event is re-enabled.\n> \n> Reuses is_friendly_name() from the previous commit to distinguish\n> event names from friendly-names when processing .enabled settings.\n\nI think having this makes sense in general. But what about the case\nwhere I have configured a hook where the friendly name matches the event\nname? Is that now forbidden, or would such a hook silently also disable\nall the other hooks?\n\n> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n> index d4fa29d936..0a9f04b154 100644\n> --- a/Documentation/config/hook.adoc\n> +++ b/Documentation/config/hook.adoc\n> @@ -33,6 +33,18 @@ hook.<friendly-name>.parallel::\n>  \tfound in the hooks directory do not need to, and run in parallel when\n>  \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n>  \n> +hook.<event>.enabled::\n> +\tSwitch to enable or disable all hooks for the `<event>` hook event.\n> +\tWhen set to `false`, no hooks fire for that event, regardless of any\n> +\tper-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`.\n> +\tSee linkgit:git-hook[1].\n> ++\n> +Note on naming: `<event>` must be the event name (e.g. `pre-commit`),\n> +not a hook friendly-name. A name that also carries `.command`, `.event`,\n> +or `.parallel` is treated as a friendly-name and its `.enabled` value\n> +applies only to that individual hook. See `hook.<friendly-name>.enabled`\n> +above.\n\nAh, okay, so you've thought about that already. I wonder whether this\nbehaviour is okay in general or whether it is going to be confusing. An\nalternative would be to disallow configuring hooks where the event name\nmatches the friendly name, which would fix the ambiguity that we now\nhave.\n\nPatrick\n"},{"id":"539869","messageId":"87jyv1cau6.fsf@collabora.com","threadId":"64919","inReplyTo":"acJUXq7cMKG6KExe@pks.im","subject":"Re: [PATCH v4 2/9] hook: parse the hook.jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-24T18:59:45Z","receivedAt":"2026-03-24T19:00:17Z","isPatch":true,"body":"On Tue, 24 Mar 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Fri, Mar 20, 2026 at 03:53:04PM +0200, Adrian Ratiu wrote:\n>> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n>> index 9e78f26439..b7847f9338 100644\n>> --- a/Documentation/config/hook.adoc\n>> +++ b/Documentation/config/hook.adoc\n>> @@ -22,3 +22,7 @@ hook.<friendly-name>.enabled::\n>>  \tconfiguration. This is particularly useful when a hook is defined\n>>  \tin a system or global config file and needs to be disabled for a\n>>  \tspecific repository. See linkgit:git-hook[1].\n>> +\n>> +hook.jobs::\n>> +\tSpecifies how many hooks can be run simultaneously during parallelized\n>> +\thook execution. If unspecified, defaults to 1 (serial execution).\n>\n> I was wondering whether we also want to allow -1 as a way to say \"use as\n> many jobs as I have CPU cores\". We also do this in some other places.\n>\n> Totally fine to ignore this for now though, we can still add it at a\n> later point in time once somebody complains.\n\nYes, we can do this.\n\nI already had the \"use as many cpu cores as availble\" logic in v1,\nwithin a different context which I dropped, so it shouldn't be too hard\nto add it back when hook.jobs == -1.\n\nThis is actually much cleaner than what we had in v1.\n\nI'll do this in the next re-roll. Thanks!\n"},{"id":"539981","messageId":"87mrzvda1d.fsf@gentoo.mail-host-address-is-not-set","threadId":"64919","inReplyTo":"acJUfVjby_QyZvj1@pks.im","subject":"Re: [PATCH v4 9/9] hook: add hook.<event>.enabled switch","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-25T18:43:58Z","receivedAt":"2026-03-25T18:44:22Z","isPatch":true,"body":"On Tue, 24 Mar 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Fri, Mar 20, 2026 at 03:53:11PM +0200, Adrian Ratiu wrote:\n>> Add a hook.<event>.enabled config key that disables all hooks for\n>> a given event, when set to false, acting as a high-level switch\n>> above the existing per-hook hook.<friendly-name>.enabled.\n>> \n>> Event-disabled hooks are shown in \"git hook list\" with an\n>> \"event-disabled\" tab-separated prefix before the name:\n>> \n>> $ git hook list test-hook\n>> event-disabled  hook-1\n>> event-disabled  hook-2\n>> \n>> With --show-scope:\n>> \n>> $ git hook list --show-scope test-hook\n>> local   event-disabled  hook-1\n>> \n>> When a hook is both per-hook disabled and event-disabled, only\n>> \"event-disabled\" is shown: the event-level switch is the more\n>> relevant piece of information, and the per-hook \"disabled\" status\n>> will surface once the event is re-enabled.\n>> \n>> Reuses is_friendly_name() from the previous commit to distinguish\n>> event names from friendly-names when processing .enabled settings.\n>\n> I think having this makes sense in general. But what about the case\n> where I have configured a hook where the friendly name matches the event\n> name? Is that now forbidden, or would such a hook silently also disable\n> all the other hooks?\n\nIn this current patch, if (friendly-name == event-name), then\nhook.*.enabled = false only disables that one hook and there's no way to\ndisable the entire event... (see below)\n\n>> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n>> index d4fa29d936..0a9f04b154 100644\n>> --- a/Documentation/config/hook.adoc\n>> +++ b/Documentation/config/hook.adoc\n>> @@ -33,6 +33,18 @@ hook.<friendly-name>.parallel::\n>>  \tfound in the hooks directory do not need to, and run in parallel when\n>>  \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n>>  \n>> +hook.<event>.enabled::\n>> +\tSwitch to enable or disable all hooks for the `<event>` hook event.\n>> +\tWhen set to `false`, no hooks fire for that event, regardless of any\n>> +\tper-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`.\n>> +\tSee linkgit:git-hook[1].\n>> ++\n>> +Note on naming: `<event>` must be the event name (e.g. `pre-commit`),\n>> +not a hook friendly-name. A name that also carries `.command`, `.event`,\n>> +or `.parallel` is treated as a friendly-name and its `.enabled` value\n>> +applies only to that individual hook. See `hook.<friendly-name>.enabled`\n>> +above.\n>\n> Ah, okay, so you've thought about that already. I wonder whether this\n> behaviour is okay in general or whether it is going to be confusing. An\n> alternative would be to disallow configuring hooks where the event name\n> matches the friendly name, which would fix the ambiguity that we now\n> have.\n\n... It is rather confusing, yes.\n\nI think disallowing the collision, as you suggested, is the better\napproach, so I will do this in v5.\n\nI think it can be done at parse time and rather easy to check because\nwe have hook.<name>.event = <value>, where name == value results in a\ncollision.\n\nOr even simpler: reject if name is in hook_name_list! I already\nimplemented this check for the `--allow-unknown-hook-name` arg you\nsuggested in the other \"cleanup\" series.\n\nMany thanks as always, your careful feedback is very valuable,\nAdrian\n"},{"id":"540041","messageId":"20260326101819.1307742-1-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 00/12] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:07Z","receivedAt":"2026-03-26T10:20:27Z","isPatch":true,"body":"Hello everyone,\n\nThis series enables running hook commands in parallel and is based on\nthe latest config-hooks cleanup series v3 [1].\n\nv5 addresses all the feedback received in v4 and adds a few minor new\nfeatures. That's the main reason this series now increased to 12 patches.\n\nI kindly ask the community to stop proposing new features and focus on\npolishing / cleaning up the existing ones. New features can be added\nin subsequent patches. :)\n\nBranch pushed to GitHub: [2]\nSuccessful CI run: [3]\n\n1: https://lore.kernel.org/git/20260309005416.2760030-1-adrian.ratiu@collabora.com/T/#m78d2c342f05524bf8a8e2c4d0d4be12b599c5e5b\n2: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v5\n3: https://github.com/10ne1/git/actions/runs/23587892089\n\nChanges in v5:\n* Rebased on config-cleanups-v3, fixed minor conflicts and test failures\n  due to the new --allow-unknown-hook-name check (Adrian)\n* Added back the first commit which I accidentally dropped in v4 (Junio)\n* Replaced unsigned int:1 with a proper bool in all places (Patrick)\n* Renamed the caching struct event_disabled -> disabled_events (Junio)\n* New commit: exposed is_known_hook() via hook.h so it can be reused (Adrian)\n* New commit: jobs = -1 now defaults to online_cpus() (Patrick)\n* Added is_known_hook(), introduced in commit above, to hook.h because it\n  will be used by another commit later in this series (Adrian)\n* Rework hook name and event name collision to die() for known hooks and\n  warn() for unknown hooks instead of assuming they're hook names (Patrick)\n* Avoid potential r == NULL dereference in repo_config_get_uint (Patrick)\n* Simplify options->jobs = 1; default setting in get_hook_jobs() (Adrian)\n* Warn when hook.*.parallel is unparseable (Patrick)\n* Mention in the hook run CLI -j doc that it overrides the config (Patrick)\n* Minor whitespace, capitalization, commit msg fixes (Patrick)\n\nRange-diff v4 -> v5:\n 1:  ec274c24e5 =  1:  f5624a2a1f repository: fix repo_init() memleak due to missing _clear()\n 2:  81d92a4465 =  2:  8ae5eec9c9 config: add a repo_config_get_uint() helper\n 3:  3bc27f6997 =  3:  f9278a22ea hook: parse the hook.jobs config\n 4:  74e6f8689a !  4:  7cbe7d2f7c hook: allow parallel hook execution\n    @@ Commit message\n         users to run hooks in parallel (opt-in).\n     \n         Users need to decide which hooks to run in parallel, by specifying\n    -    \"parallel = true\" in the config, because git cannot know if their\n    +    \"parallel = true\" in the config, because Git cannot know if their\n         specific hooks are safe to run or not in parallel (for e.g. two hooks\n         might write to the same file or call the same program).\n     \n    @@ hook.c\n     @@ hook.c: struct hook_config_cache_entry {\n      \tchar *command;\n      \tenum config_scope scope;\n    - \tunsigned int disabled:1;\n    -+\tunsigned int parallel:1;\n    + \tbool disabled;\n    ++\tbool parallel;\n      };\n      \n      /*\n    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n     +\t} else if (!strcmp(subkey, \"parallel\")) {\n     +\t\tint v = git_parse_maybe_bool(value);\n     +\t\tif (v >= 0)\n    -+\t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n    ++\t\t\tstrmap_put(&data->parallel_hooks, hook_name,\n    ++\t\t\t\t   (void *)(uintptr_t)v);\n    ++\t\telse\n    ++\t\t\twarning(_(\"hook.%s.parallel must be a boolean,\"\n    ++\t\t\t\t  \" ignoring: '%s'\"),\n    ++\t\t\t\thook_name, value);\n      \t}\n      \n      \tfree(hook_name);\n    @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *c\n      \t\t\tstruct hook_config_cache_entry *entry;\n      \t\t\tchar *command;\n      \n    -+\t\t\tint is_par = !!strmap_get(&cb_data.parallel_hooks, hname);\n    - \t\t\tint is_disabled =\n    ++\t\t\tbool is_par = !!strmap_get(&cb_data.parallel_hooks, hname);\n    + \t\t\tbool is_disabled =\n      \t\t\t\t!!unsorted_string_list_lookup(\n      \t\t\t\t\t&cb_data.disabled_hooks, hname);\n     @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n    @@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options)\n     +\t * fallback to a direct config lookup (out-of-repo).\n     +\t * Default to 1 (serial execution) on failure.\n     +\t */\n    -+\tif (r && r->gitdir && r->hook_config_cache)\n    -+\t\t/* Use the already-parsed cache (in-repo) */\n    -+\t\toptions->jobs = r->hook_jobs ? r->hook_jobs : 1;\n    -+\telse\n    -+\t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n    -+\t\tif (repo_config_get_uint(r, \"hook.jobs\", &options->jobs))\n    -+\t\t\toptions->jobs = 1;\n    ++\toptions->jobs = 1;\n    ++\tif (r) {\n    ++\t\tif (r->gitdir && r->hook_config_cache && r->hook_jobs)\n    ++\t\t\toptions->jobs = r->hook_jobs;\n    ++\t\telse\n    ++\t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n    ++\t}\n     +\n     +\t/*\n     +\t * Cap to serial any configured hook not marked as parallel = true.\n    @@ hook.h: struct hook {\n     +\t * event. Only useful for configured (named) hooks. Traditional hooks\n     +\t * always default to 0 (serial). Set via `hook.<name>.parallel = true`.\n     +\t */\n    -+\tunsigned int parallel:1;\n    ++\tbool parallel;\n     +\n      \t/**\n      \t * Opaque data pointer used to keep internal state across callback calls.\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\n     +\ttest_config hook.jobs 1 &&\n     +\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\techo serial >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\n     +\ttest_config hook.jobs 2 &&\n     +\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\techo parallel >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\n     +\ttest_config hook.jobs 2 &&\n     +\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\techo parallel >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\n     +\ttest_config hook.jobs 2 &&\n     +\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\techo serial >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\n     +\ttest_config hook.jobs 2 &&\n     +\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\techo serial >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n 5:  508d6476c6 =  5:  fd39771388 hook: allow pre-push parallel execution\n 6:  2d7b3d6d83 =  6:  54c08cb72b hook: mark non-parallelizable hooks\n 7:  97507d8d31 !  7:  5957cd9c72 hook: add -j/--jobs option to git hook run\n    @@ Documentation/git-hook.adoc: git-hook - Run git hooks\n      SYNOPSIS\n      --------\n      [verse]\n    --'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n    -+'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n    +-'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n    ++'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n     +\t<hook-name> [-- <hook-args>]\n    - 'git hook' list [-z] [--show-scope] <hook-name>\n    + 'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\n      \n      DESCRIPTION\n     @@ Documentation/git-hook.adoc: OPTIONS\n    @@ Documentation/git-hook.adoc: OPTIONS\n     ++\n     +Specify how many hooks to run simultaneously. If this flag is not specified,\n     +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n    -+neither is specified, defaults to 1 (serial execution). Some hooks always run\n    -+sequentially regardless of this flag or the `hook.jobs` config, because git\n    -+knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`,\n    -+`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and\n    -+`push-to-checkout`.\n    ++neither is specified, defaults to 1 (serial execution).\n    +++\n    ++When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel`\n    ++setting, allowing all hooks for the event to run concurrently, even if they\n    ++are not individually marked as parallel.\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 parallel:\n    ++`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`,\n    ++`post-commit`, `post-checkout`, and `push-to-checkout`.\n     +\n      WRAPPERS\n      --------\n      \n     @@ Documentation/git-hook.adoc: running:\n    - git hook run mywrapper-start-tests \\\n    + git hook run --allow-unknown-hook-name mywrapper-start-tests \\\n        # providing something to stdin\n        --stdin some-tempfile-123 \\\n     -  # execute hooks in serial\n    @@ builtin/hook.c\n      #include \"parse-options.h\"\n      \n      #define BUILTIN_HOOK_RUN_USAGE \\\n    --\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n    -+\tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n    +-\tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n    ++\tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n     +\t   \"<hook-name> [-- <hook-args>]\")\n      #define BUILTIN_HOOK_LIST_USAGE \\\n    - \tN_(\"git hook list [-z] [--show-scope] <hook-name>\")\n    + \tN_(\"git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\")\n      \n     @@ builtin/hook.c: static int run(int argc, const char **argv, const char *prefix,\n      \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\t\t    \"echo \\\"Hook 3 Start\\\"; sleep 3; echo \\\"Hook 3 End\\\"\" &&\n     +\ttest_config hook.hook-3.parallel true &&\n     +\n    -+\tgit hook run -j3 test-hook >out 2>err.parallel &&\n    ++\tgit hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel &&\n     +\n     +\t# Verify Hook 1 output is grouped\n     +\tsed -n \"/Hook 1 Start/,/Hook 1 End/p\" err.parallel >hook1_out &&\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\t3\n     +\tEOF\n     +\n    -+\tgit hook run -j1 test-hook 2>actual &&\n    ++\tgit hook run --allow-unknown-hook-name -j1 test-hook 2>actual &&\n     +\ttest_cmp expected actual\n     +'\n     +\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\t    \"$(sentinel_detector sentinel hook.order)\" &&\n     +\ttest_config hook.hook-2.parallel true &&\n     +\n    -+\tgit hook run -j2 test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n     +\techo parallel >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\t# hook-2 also has no parallel=true\n     +\n     +\t# -j2 overrides parallel=false; hooks run in parallel with a warning.\n    -+\tgit hook run -j2 test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n     +\techo parallel >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou\n     +\ttest_config hook.hook-2.command \"true\" &&\n     +\t# neither hook has parallel=true\n     +\n    -+\tgit hook run -j2 test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n     +\tgrep \"hook .hook-1. is not marked as parallel=true\" err &&\n     +\tgrep \"hook .hook-2. is not marked as parallel=true\" err\n     +'\n 8:  734adfad1b !  8:  a58f05e9d5 hook: add per-event jobs config\n    @@ hook.c: struct hook_all_config_cb {\n      };\n      \n     @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n    - \t\tint v = git_parse_maybe_bool(value);\n    - \t\tif (v >= 0)\n    - \t\t\tstrmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);\n    + \t\t\twarning(_(\"hook.%s.parallel must be a boolean,\"\n    + \t\t\t\t  \" ignoring: '%s'\"),\n    + \t\t\t\thook_name, value);\n     +\t} else if (!strcmp(subkey, \"jobs\")) {\n     +\t\tunsigned int v;\n     +\t\tif (!git_parse_uint(value, &v))\n    -+\t\t\twarning(_(\"hook.%s.jobs must be a positive integer, ignoring: '%s'\"),\n    ++\t\t\twarning(_(\"hook.%s.jobs must be a positive integer,\"\n    ++\t\t\t\t  \" ignoring: '%s'\"),\n     +\t\t\t\thook_name, value);\n     +\t\telse if (!v)\n    -+\t\t\twarning(_(\"hook.%s.jobs must be positive, ignoring: 0\"), hook_name);\n    ++\t\t\twarning(_(\"hook.%s.jobs must be positive,\"\n    ++\t\t\t\t  \" ignoring: 0\"), hook_name);\n     +\t\telse\n     +\t\t\tstrmap_put(&data->event_jobs, hook_name,\n     +\t\t\t\t   (void *)(uintptr_t)v);\n    @@ hook.c: static void warn_non_parallel_hooks_override(unsigned int jobs,\n      {\n      \t/*\n     @@ hook.c: static unsigned int get_hook_jobs(struct repository *r,\n    - \t * fallback to a direct config lookup (out-of-repo).\n    - \t * Default to 1 (serial execution) on failure.\n      \t */\n    --\tif (r && r->gitdir && r->hook_config_cache)\n    -+\tif (r && r->gitdir && r->hook_config_cache) {\n    - \t\t/* Use the already-parsed cache (in-repo) */\n    -+\t\tvoid *event_jobs = strmap_get(&r->event_jobs, hook_name);\n    - \t\toptions->jobs = r->hook_jobs ? r->hook_jobs : 1;\n    --\telse\n    -+\t\tif (event_jobs)\n    -+\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n    -+\t} else {\n    - \t\t/* No cache present (out-of-repo call), use direct cfg lookup */\n    -+\t\tunsigned int event_jobs;\n    -+\t\tchar *key;\n    + \toptions->jobs = 1;\n    + \tif (r) {\n    +-\t\tif (r->gitdir && r->hook_config_cache && r->hook_jobs)\n    +-\t\t\toptions->jobs = r->hook_jobs;\n    +-\t\telse\n    ++\t\tif (r->gitdir && r->hook_config_cache) {\n    ++\t\t\tvoid *event_jobs;\n     +\n    - \t\tif (repo_config_get_uint(r, \"hook.jobs\", &options->jobs))\n    - \t\t\toptions->jobs = 1;\n    - \n    -+\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n    -+\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n    -+\t\t\toptions->jobs = event_jobs;\n    -+\t\tfree(key);\n    -+\t}\n    ++\t\t\tif (r->hook_jobs)\n    ++\t\t\t\toptions->jobs = r->hook_jobs;\n    ++\n    ++\t\t\tevent_jobs = strmap_get(&r->event_jobs, hook_name);\n    ++\t\t\tif (event_jobs)\n    ++\t\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n    ++\t\t} else {\n    ++\t\t\tunsigned int event_jobs;\n    ++\t\t\tchar *key;\n     +\n    + \t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n    ++\n    ++\t\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n    ++\t\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n    ++\t\t\t\toptions->jobs = event_jobs;\n    ++\t\t\tfree(key);\n    ++\t\t}\n    + \t}\n    + \n      \t/*\n      \t * Cap to serial any configured hook not marked as parallel = true.\n      \t * This enforces the parallel = false default, even for \"traditional\"\n    @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 is ignored for force-serial ho\n     +\ttest_config hook.jobs 1 &&\n     +\ttest_config hook.test-hook.jobs 2 &&\n     +\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\techo parallel >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 is ignored for force-serial ho\n     +\ttest_config hook.jobs 4 &&\n     +\ttest_config hook.test-hook.jobs 1 &&\n     +\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\techo serial >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 is ignored for force-serial ho\n     +\t# Per-event jobs=2 but no hook has parallel=true: must still run serially.\n     +\ttest_config hook.test-hook.jobs 2 &&\n     +\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\techo serial >expect &&\n     +\ttest_cmp expect hook.order\n     +'\n 9:  d6d3196b17 !  9:  4fb39ad98d hook: warn when hook.<friendly-name>.jobs is set\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs still requires hook.<nam\n     +test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' '\n     +\ttest_config hook.my-hook.command \"true\" &&\n     +\ttest_config hook.my-hook.jobs 2 &&\n    -+\tgit hook run --ignore-missing test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n     +\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n     +'\n     +\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs still requires hook.<nam\n     +\ttest_config hook.my-hook.event test-hook &&\n     +\ttest_config hook.my-hook.command \"true\" &&\n     +\ttest_config hook.my-hook.jobs 2 &&\n    -+\tgit hook run --ignore-missing test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n     +\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n     +'\n     +\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs still requires hook.<nam\n     +\ttest_config hook.my-hook.command \"true\" &&\n     +\ttest_config hook.my-hook.parallel true &&\n     +\ttest_config hook.my-hook.jobs 2 &&\n    -+\tgit hook run --ignore-missing test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n     +\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n     +'\n     +\n     +test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n     +\ttest_config hook.test-hook.jobs 2 &&\n    -+\tgit hook run --ignore-missing test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n     +\ttest_grep ! \"friendly-name\" err\n     +'\n     +\n -:  ---------- > 10:  d3c6e8f3e2 hook: move is_known_hook() to hook.c for wider use\n10:  b64689d0c8 ! 11:  312acd15b4 hook: add hook.<event>.enabled switch\n    @@ Commit message\n         relevant piece of information, and the per-hook \"disabled\" status\n         will surface once the event is re-enabled.\n     \n    -    Reuses is_friendly_name() from the previous commit to distinguish\n    -    event names from friendly-names when processing .enabled settings.\n    +    Using an event name as a friendly-name (e.g. hook.<event>.enabled)\n    +    can cause ambiguity, so a fatal error is issued when using a known\n    +    event name and a warning is issued for unknown event name, since\n    +    a collision cannot be detected with certainty for unknown events.\n     \n    +    Suggested-by: Patrick Steinhardt <ps@pks.im>\n         Suggested-by: Junio C Hamano <gitster@pobox.com>\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Documentation/config/hook.adoc ##\n    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.event::\n    + \tevents, specify the key more than once. An empty value resets\n    + \tthe list of events, clearing any previously defined events for\n    + \t`hook.<friendly-name>`. See linkgit:git-hook[1].\n    +++\n    ++The `<friendly-name>` must not be the same as a known hook event name\n    ++(e.g. do not use `hook.pre-commit.event`). Using a known event name as\n    ++a friendly-name is a fatal error because it creates an ambiguity with\n    ++`hook.<event>.enabled` and `hook.<event>.jobs`. For unknown event names,\n    ++a warning is issued when `<friendly-name>` matches the event value.\n    + \n    + hook.<friendly-name>.enabled::\n    + \tWhether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.\n     @@ Documentation/config/hook.adoc: hook.<friendly-name>.parallel::\n      \tfound in the hooks directory do not need to, and run in parallel when\n      \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n    @@ Documentation/config/hook.adoc: hook.<friendly-name>.parallel::\n     +\tSee linkgit:git-hook[1].\n     ++\n     +Note on naming: `<event>` must be the event name (e.g. `pre-commit`),\n    -+not a hook friendly-name. A name that also carries `.command`, `.event`,\n    -+or `.parallel` is treated as a friendly-name and its `.enabled` value\n    -+applies only to that individual hook. See `hook.<friendly-name>.enabled`\n    -+above.\n    ++not a hook friendly-name. Since using a known event name as a\n    ++friendly-name is disallowed (see `hook.<friendly-name>.event` above),\n    ++there is no ambiguity between event-level and per-hook `.enabled`\n    ++settings for known events. For unknown events, if a friendly-name\n    ++matches the event name despite the warning, `.enabled` is treated\n    ++as per-hook only.\n     +\n      hook.<event>.jobs::\n      \tSpecifies how many hooks can be run simultaneously for the `<event>`\n    @@ hook.c: struct hook_config_cache_entry {\n     - * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n     + * disabled_hooks: set of all names with hook.<name>.enabled = false; after\n     + *                 parsing, names that are not friendly-names become event-level\n    -+ *                 disables stored in cache->event_disabled. This collects all.\n    ++ *                 disables stored in r->disabled_events. This collects all.\n       * parallel_hooks: friendly-name to parallel flag.\n       * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n       * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n    +@@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n    + \t\t\tstrmap_for_each_entry(&data->event_hooks, &iter, e)\n    + \t\t\t\tunsorted_string_list_remove(e->value, hook_name, 0);\n    + \t\t} else {\n    +-\t\t\tstruct string_list *hooks =\n    +-\t\t\t\tstrmap_get(&data->event_hooks, value);\n    ++\t\t\tstruct string_list *hooks;\n    ++\n    ++\t\t\tif (is_known_hook(hook_name))\n    ++\t\t\t\tdie(_(\"hook friendly-name '%s' collides with \"\n    ++\t\t\t\t      \"a known event name; please choose a \"\n    ++\t\t\t\t      \"different friendly-name\"),\n    ++\t\t\t\t    hook_name);\n    ++\n    ++\t\t\tif (!strcmp(hook_name, value))\n    ++\t\t\t\twarning(_(\"hook friendly-name '%s' is the \"\n    ++\t\t\t\t\t  \"same as its event; this may cause \"\n    ++\t\t\t\t\t  \"ambiguity with hook.%s.enabled\"),\n    ++\t\t\t\t\thook_name, hook_name);\n    ++\n    ++\t\t\thooks = strmap_get(&data->event_hooks, value);\n    + \n    + \t\t\tif (!hooks) {\n    + \t\t\t\tCALLOC_ARRAY(hooks, 1);\n     @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n      \n      \twarn_jobs_on_friendly_names(&cb_data);\n      \n     +\t/*\n    -+\t * Populate event_disabled: names in disabled_hooks that are not\n    ++\t * Populate disabled_events: names in disabled_hooks that are not\n     +\t * friendly-names are event-level switches (hook.<event>.enabled = false).\n     +\t * Names that are friendly-names are already handled per-hook via the\n     +\t * hook_config_cache_entry.disabled flag below.\n     +\t */\n     +\tif (r) {\n    -+\t\tstring_list_clear(&r->event_disabled, 0);\n    -+\t\tstring_list_init_dup(&r->event_disabled);\n    ++\t\tstring_list_clear(&r->disabled_events, 0);\n    ++\t\tstring_list_init_dup(&r->disabled_events);\n     +\t\tfor (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) {\n     +\t\t\tconst char *n = cb_data.disabled_hooks.items[i].string;\n     +\t\t\tif (!is_friendly_name(&cb_data, n))\n    -+\t\t\t\tstring_list_append(&r->event_disabled, n);\n    ++\t\t\t\tstring_list_append(&r->disabled_events, n);\n     +\t\t}\n     +\t}\n     +\n    @@ hook.c: static void list_hooks_add_configured(struct repository *r,\n      {\n      \tstruct strmap *cache = get_hook_config_cache(r);\n      \tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n    -+\tint event_is_disabled = r ? !!unsorted_string_list_lookup(&r->event_disabled,\n    ++\tbool event_is_disabled = r ? !!unsorted_string_list_lookup(&r->disabled_events,\n     +\t\t\t\t\t\t\t\t   hookname) : 0;\n      \n      \t/* Iterate through configured hooks and initialize internal states */\n    @@ hook.c: static void list_hooks_add_configured(struct repository *r,\n      \t\thook_cache_clear(cache);\n      \t\tfree(cache);\n     +\t\tif (r)\n    -+\t\t\tstring_list_clear(&r->event_disabled, 0);\n    ++\t\t\tstring_list_clear(&r->disabled_events, 0);\n      \t}\n      }\n      \n    @@ hook.h\n     @@ hook.h: struct hook {\n      \t\t\tconst char *command;\n      \t\t\tenum config_scope scope;\n    - \t\t\tunsigned int disabled:1;\n    -+\t\t\tunsigned int event_disabled:1;\n    + \t\t\tbool disabled;\n    ++\t\t\tbool event_disabled;\n      \t\t} configured;\n      \t} u;\n      \n    @@ repository.c: void repo_clear(struct repository *repo)\n      \t\tFREE_AND_NULL(repo->hook_config_cache);\n      \t}\n      \tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n    -+\tstring_list_clear(&repo->event_disabled, 0);\n    ++\tstring_list_clear(&repo->disabled_events, 0);\n      \n      \tif (repo->promisor_remote_config) {\n      \t\tpromisor_remote_clear(repo->promisor_remote_config);\n    @@ repository.h: struct repository {\n      \tstruct strmap event_jobs;\n      \n     +\t/* Cached list of event names with hook.<event>.enabled = false. */\n    -+\tstruct string_list event_disabled;\n    ++\tstruct string_list disabled_events;\n     +\n      \t/* Configurations related to promisor remotes. */\n      \tchar *repository_format_partial_clone;\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real\n     +\ttest_config hook.hook-1.event test-hook &&\n     +\ttest_config hook.hook-1.command \"echo ran\" &&\n     +\ttest_config hook.test-hook.enabled false &&\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\ttest_must_be_empty out\n     +'\n     +\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real\n     +\ttest_config hook.hook-1.event test-hook &&\n     +\ttest_config hook.hook-1.command \"echo ran\" &&\n     +\ttest_config hook.test-hook.enabled true &&\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\ttest_grep \"ran\" err\n     +'\n     +\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real\n     +\ttest_config hook.hook-1.event test-hook &&\n     +\ttest_config hook.hook-1.command \"echo ran\" &&\n     +\ttest_config hook.other-event.enabled false &&\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\ttest_grep \"ran\" err\n     +'\n     +\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real\n     +\ttest_config hook.hook-2.event test-hook &&\n     +\ttest_config hook.hook-2.command \"echo hook-2\" &&\n     +\ttest_config hook.hook-1.enabled false &&\n    -+\tgit hook run test-hook >out 2>err &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n     +\ttest_grep ! \"hook-1\" err &&\n     +\ttest_grep \"hook-2\" err\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real\n     +\ttest_config hook.hook-2.event test-hook &&\n     +\ttest_config hook.hook-2.command \"echo ran\" &&\n     +\ttest_config hook.test-hook.enabled false &&\n    -+\tgit hook list test-hook >actual &&\n    ++\tgit hook list --allow-unknown-hook-name test-hook >actual &&\n     +\ttest_grep \"^event-disabled\thook-1$\" actual &&\n     +\ttest_grep \"^event-disabled\thook-2$\" actual\n     +'\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real\n     +\ttest_config hook.hook-1.event test-hook &&\n     +\ttest_config hook.hook-1.command \"echo ran\" &&\n     +\ttest_config hook.test-hook.enabled false &&\n    -+\tgit hook list --show-scope test-hook >actual &&\n    ++\tgit hook list --allow-unknown-hook-name --show-scope test-hook >actual &&\n     +\ttest_grep \"^local\tevent-disabled\thook-1$\" actual\n     +'\n     +\n    @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real\n     +\ttest_config hook.hook-1.event test-hook &&\n     +\ttest_config hook.hook-1.command \"echo ran\" &&\n     +\ttest_config hook.test-hook.enabled false &&\n    -+\tgit hook list test-hook >actual &&\n    ++\tgit hook list --allow-unknown-hook-name test-hook >actual &&\n     +\ttest_grep \"event-disabled\" actual\n     +'\n    ++\n    ++test_expect_success 'friendly-name matching known event name is rejected' '\n    ++\ttest_config hook.pre-commit.event pre-commit &&\n    ++\ttest_config hook.pre-commit.command \"echo oops\" &&\n    ++\ttest_must_fail git hook run pre-commit 2>err &&\n    ++\ttest_grep \"collides with a known event name\" err\n    ++'\n    ++\n    ++test_expect_success 'friendly-name matching known event name is rejected even for different event' '\n    ++\ttest_config hook.pre-commit.event post-commit &&\n    ++\ttest_config hook.pre-commit.command \"echo oops\" &&\n    ++\ttest_must_fail git hook run post-commit 2>err &&\n    ++\ttest_grep \"collides with a known event name\" err\n    ++'\n    ++\n    ++test_expect_success 'friendly-name matching unknown event warns' '\n    ++\ttest_config hook.test-hook.event test-hook &&\n    ++\ttest_config hook.test-hook.command \"echo ran\" &&\n    ++\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n    ++\ttest_grep \"same as its event\" err\n    ++'\n     +\n      test_done\n -:  ---------- > 12:  f54844d13e hook: allow hook.jobs=-1 to use all available CPU cores\n\nAdrian Ratiu (9):\n  repository: fix repo_init() memleak due to missing _clear()\n  config: add a repo_config_get_uint() helper\n  hook: parse the hook.jobs config\n  hook: allow pre-push parallel execution\n  hook: add per-event jobs config\n  hook: warn when hook.<friendly-name>.jobs is set\n  hook: move is_known_hook() to hook.c for wider use\n  hook: add hook.<event>.enabled switch\n  hook: allow hook.jobs=-1 to use all available CPU cores\n\nEmily Shaffer (3):\n  hook: allow parallel hook execution\n  hook: mark non-parallelizable hooks\n  hook: add -j/--jobs option to git hook run\n\n Documentation/config/hook.adoc |  76 +++++\n Documentation/git-hook.adoc    |  23 +-\n Makefile                       |   2 +-\n builtin/am.c                   |   8 +-\n builtin/checkout.c             |  19 +-\n builtin/clone.c                |   6 +-\n builtin/hook.c                 |  46 ++-\n builtin/receive-pack.c         |   3 +-\n builtin/worktree.c             |   2 +-\n commit.c                       |   2 +-\n config.c                       |  28 ++\n config.h                       |  13 +\n hook.c                         | 286 ++++++++++++++++-\n hook.h                         |  38 ++-\n parse.c                        |   9 +\n parse.h                        |   1 +\n repository.c                   |   3 +\n repository.h                   |  10 +\n t/t1800-hook.sh                | 546 ++++++++++++++++++++++++++++++++-\n transport.c                    |   6 +-\n 20 files changed, 1069 insertions(+), 58 deletions(-)\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540040","messageId":"20260326101819.1307742-2-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 01/12] repository: fix repo_init() memleak due to missing _clear()","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:08Z","receivedAt":"2026-03-26T10:20:28Z","isPatch":true,"body":"There is an old pre-existing memory leak in repo_init() due to failing\nto call clear_repository_format() in the error case.\n\nIt went undetected because a specific bug is required to trigger it:\nenable a v1 extension in a repository with format v0. Obviously this\ncan only happen in a development environment, so it does not trigger\nin normal usage, however the memleak is real and needs fixing.\n\nFix it by also calling clear_repository_format() in the error case.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n repository.c | 1 +\n 1 file changed, 1 insertion(+)\n\ndiff --git a/repository.c b/repository.c\nindex 0b8f7ec200..fb4356ca55 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -322,6 +322,7 @@ int repo_init(struct repository *repo,\n \treturn 0;\n \n error:\n+\tclear_repository_format(&format);\n \trepo_clear(repo);\n \treturn -1;\n }\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540042","messageId":"20260326101819.1307742-3-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 02/12] config: add a repo_config_get_uint() helper","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:09Z","receivedAt":"2026-03-26T10:20:32Z","isPatch":true,"body":"Next commits add a 'hook.jobs' config option of type 'unsigned int',\nso add a helper to parse it since the API only supports int and ulong.\n\nAn alternative is to make 'hook.jobs' an 'int' or parse it as an 'int'\nthen cast it to unsigned, however it's better to use proper helpers for\nthe type. Using 'ulong' is another option which already has helpers, but\nit's a bit excessive in size for just the jobs number.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n config.c | 28 ++++++++++++++++++++++++++++\n config.h | 13 +++++++++++++\n parse.c  |  9 +++++++++\n parse.h  |  1 +\n 4 files changed, 51 insertions(+)\n\ndiff --git a/config.c b/config.c\nindex 156f2a24fa..a1b92fe083 100644\n--- a/config.c\n+++ b/config.c\n@@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value,\n \treturn ret;\n }\n \n+unsigned int git_config_uint(const char *name, const char *value,\n+\t\t\t     const struct key_value_info *kvi)\n+{\n+\tunsigned int ret;\n+\tif (!git_parse_uint(value, &ret))\n+\t\tdie_bad_number(name, value, kvi);\n+\treturn ret;\n+}\n+\n int64_t git_config_int64(const char *name, const char *value,\n \t\t\t const struct key_value_info *kvi)\n {\n@@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest)\n \t\treturn 1;\n }\n \n+int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest)\n+{\n+\tconst char *value;\n+\tstruct key_value_info kvi;\n+\n+\tif (!git_configset_get_value(set, key, &value, &kvi)) {\n+\t\t*dest = git_config_uint(key, value, &kvi);\n+\t\treturn 0;\n+\t} else\n+\t\treturn 1;\n+}\n+\n int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest)\n {\n \tconst char *value;\n@@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo,\n \treturn git_configset_get_int(repo->config, key, dest);\n }\n \n+int repo_config_get_uint(struct repository *repo,\n+\t\t\t const char *key, unsigned int *dest)\n+{\n+\tgit_config_check_init(repo);\n+\treturn git_configset_get_uint(repo->config, key, dest);\n+}\n+\n int repo_config_get_ulong(struct repository *repo,\n \t\t\t  const char *key, unsigned long *dest)\n {\ndiff --git a/config.h b/config.h\nindex ba426a960a..bf47fb3afc 100644\n--- a/config.h\n+++ b/config.h\n@@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *);\n int64_t git_config_int64(const char *, const char *,\n \t\t\t const struct key_value_info *);\n \n+/**\n+ * Identical to `git_config_int`, but for unsigned ints.\n+ */\n+unsigned int git_config_uint(const char *, const char *,\n+\t\t\t     const struct key_value_info *);\n+\n /**\n  * Identical to `git_config_int`, but for unsigned longs.\n  */\n@@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key,\n \n int git_configset_get_string(struct config_set *cs, const char *key, char **dest);\n int git_configset_get_int(struct config_set *cs, const char *key, int *dest);\n+int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest);\n int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest);\n int git_configset_get_bool(struct config_set *cs, const char *key, int *dest);\n int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest);\n@@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r,\n  */\n int repo_config_get_int(struct repository *r, const char *key, int *dest);\n \n+/**\n+ * Similar to `repo_config_get_int` but for unsigned ints.\n+ */\n+int repo_config_get_uint(struct repository *r,\n+\t\t\t const char *key, unsigned int *dest);\n+\n /**\n  * Similar to `repo_config_get_int` but for unsigned longs.\n  */\ndiff --git a/parse.c b/parse.c\nindex 48313571aa..d77f28046a 100644\n--- a/parse.c\n+++ b/parse.c\n@@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret)\n \treturn 1;\n }\n \n+int git_parse_uint(const char *value, unsigned int *ret)\n+{\n+\tuintmax_t tmp;\n+\tif (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int)))\n+\t\treturn 0;\n+\t*ret = tmp;\n+\treturn 1;\n+}\n+\n int git_parse_ulong(const char *value, unsigned long *ret)\n {\n \tuintmax_t tmp;\ndiff --git a/parse.h b/parse.h\nindex ea32de9a91..a6dd37c4cb 100644\n--- a/parse.h\n+++ b/parse.h\n@@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max);\n int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max);\n int git_parse_ssize_t(const char *, ssize_t *);\n int git_parse_ulong(const char *, unsigned long *);\n+int git_parse_uint(const char *value, unsigned int *ret);\n int git_parse_int(const char *value, int *ret);\n int git_parse_int64(const char *value, int64_t *ret);\n int git_parse_double(const char *value, double *ret);\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540043","messageId":"20260326101819.1307742-4-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 03/12] hook: parse the hook.jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:10Z","receivedAt":"2026-03-26T10:20:41Z","isPatch":true,"body":"The hook.jobs config is a global way to set hook parallelization for\nall hooks, in the sense that it is not per-event nor per-hook.\n\nFiner-grained configs will be added in later commits which can override\nit, for e.g. via a per-event type job options. Next commits will also\nadd to this item's documentation.\n\nParse hook.jobs config key in hook_config_lookup_all() and store its\nvalue in hook_all_config_cb.jobs, then transfer it into r->jobs after\nthe config pass completes.\n\nThis is mostly plumbing and the cached value is not yet used.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 23 +++++++++++++++++++++--\n repository.h                   |  3 +++\n 3 files changed, 28 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 9e78f26439..b7847f9338 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -22,3 +22,7 @@ hook.<friendly-name>.enabled::\n \tconfiguration. This is particularly useful when a hook is defined\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n+\n+hook.jobs::\n+\tSpecifies how many hooks can be run simultaneously during parallelized\n+\thook execution. If unspecified, defaults to 1 (serial execution).\ndiff --git a/hook.c b/hook.c\nindex cc23276d27..b8cce00e57 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -123,11 +123,13 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tunsigned int jobs;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -143,6 +145,20 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n \t\treturn 0;\n \n+\t/* Handle plain hook.<key> entries that have no hook name component. */\n+\tif (!name) {\n+\t\tif (!strcmp(subkey, \"jobs\") && value) {\n+\t\t\tunsigned int v;\n+\t\t\tif (!git_parse_uint(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n+\t\t\telse if (!v)\n+\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n+\t\t\telse\n+\t\t\t\tdata->jobs = v;\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n \tif (!value)\n \t\treturn config_error_nonbool(key);\n \n@@ -240,7 +256,7 @@ void hook_cache_clear(struct strmap *cache)\n /* Populate `cache` with the complete hook configuration */\n static void build_hook_config_map(struct repository *r, struct strmap *cache)\n {\n-\tstruct hook_all_config_cb cb_data;\n+\tstruct hook_all_config_cb cb_data = { 0 };\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *e;\n \n@@ -248,7 +264,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \n-\t/* Parse all configs in one run. */\n+\t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n \t/* Construct the cache from parsed configs. */\n@@ -292,6 +308,9 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tstrmap_put(cache, e->key, hooks);\n \t}\n \n+\tif (r)\n+\t\tr->hook_jobs = cb_data.jobs;\n+\n \tstrmap_clear(&cb_data.commands, 1);\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\ndiff --git a/repository.h b/repository.h\nindex 078059a6e0..58e46853d0 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -172,6 +172,9 @@ struct repository {\n \t */\n \tstruct strmap *hook_config_cache;\n \n+\t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n+\tunsigned int hook_jobs;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540044","messageId":"20260326101819.1307742-7-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 06/12] hook: mark non-parallelizable hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:13Z","receivedAt":"2026-03-26T10:20:41Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSeveral hooks are known to be inherently non-parallelizable, so initialize\nthem with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides\nany hook.jobs or runtime -j flags.\n\nThese hooks are:\napplypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit,\npost-checkout, and push-to-checkout.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 14 ++++++++++++++\n builtin/am.c                   |  8 +++++---\n builtin/checkout.c             | 19 +++++++++++++------\n builtin/clone.c                |  6 ++++--\n builtin/receive-pack.c         |  3 ++-\n builtin/worktree.c             |  2 +-\n commit.c                       |  2 +-\n t/t1800-hook.sh                | 16 ++++++++++++++++\n 8 files changed, 56 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 94c7a9808e..6f60775c28 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -36,6 +36,20 @@ hook.<friendly-name>.parallel::\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSome hooks always run sequentially regardless of this setting because\n+\tthey operate on shared data and cannot safely be parallelized:\n++\n+--\n+`applypatch-msg`;;\n+`prepare-commit-msg`;;\n+`commit-msg`;;\n+\tReceive a commit message file and may rewrite it in place.\n+`pre-commit`;;\n+`post-checkout`;;\n+`push-to-checkout`;;\n+`post-commit`;;\n+\tAccess the working tree, index, or repository state.\n+--\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<friendly-name>.parallel` set to `true`.\ndiff --git a/builtin/am.c b/builtin/am.c\nindex 9d0b51c651..271b23160b 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state)\n \n \tassert(state->msg);\n \n-\tif (!state->no_verify)\n-\t\tret = run_hooks_l(the_repository, \"applypatch-msg\",\n-\t\t\t\t  am_path(state, \"final-commit\"), NULL);\n+\tif (!state->no_verify) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\t\tstrvec_push(&opt.args, am_path(state, \"final-commit\"));\n+\t\tret = run_hooks_opt(the_repository, \"applypatch-msg\", &opt);\n+\t}\n \n \tif (!ret) {\n \t\tFREE_AND_NULL(state->msg);\ndiff --git a/builtin/checkout.c b/builtin/checkout.c\nindex e031e61886..ac0186a33e 100644\n--- a/builtin/checkout.c\n+++ b/builtin/checkout.c\n@@ -31,6 +31,7 @@\n #include \"resolve-undo.h\"\n #include \"revision.h\"\n #include \"setup.h\"\n+#include \"strvec.h\"\n #include \"submodule.h\"\n #include \"symlinks.h\"\n #include \"trace2.h\"\n@@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info)\n static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit,\n \t\t\t      int changed)\n {\n-\treturn run_hooks_l(the_repository, \"post-checkout\",\n-\t\t\t   oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   changed ? \"1\" : \"0\", NULL);\n-\t/* \"new_commit\" can be NULL when checking out from the index before\n-\t   a commit exists. */\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n+\t/*\n+\t * \"new_commit\" can be NULL when checking out from the index before\n+\t * a commit exists.\n+\t */\n+\tstrvec_pushl(&opt.args,\n+\t\t     oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     changed ? \"1\" : \"0\",\n+\t\t     NULL);\n+\n+\treturn run_hooks_opt(the_repository, \"post-checkout\", &opt);\n }\n \n static int update_some(const struct object_id *oid, struct strbuf *base,\ndiff --git a/builtin/clone.c b/builtin/clone.c\nindex fba3c9c508..d23b0cafcf 100644\n--- a/builtin/clone.c\n+++ b/builtin/clone.c\n@@ -647,6 +647,7 @@ static int checkout(int submodule_progress,\n \tstruct tree *tree;\n \tstruct tree_desc t;\n \tint err = 0;\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \tif (option_no_checkout)\n \t\treturn 0;\n@@ -697,8 +698,9 @@ static int checkout(int submodule_progress,\n \tif (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK))\n \t\tdie(_(\"unable to write new index file\"));\n \n-\terr |= run_hooks_l(the_repository, \"post-checkout\", oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(&oid), \"1\", NULL);\n+\tstrvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(&oid), \"1\", NULL);\n+\terr |= run_hooks_opt(the_repository, \"post-checkout\", &hook_opt);\n \n \tif (!err && (option_recurse_submodules.nr > 0)) {\n \t\tstruct child_process cmd = CHILD_PROCESS_INIT;\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex cb3656a034..1d9eb2e1a6 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1464,7 +1464,8 @@ static const char *push_to_checkout(unsigned char *hash,\n \t\t\t\t    struct strvec *env,\n \t\t\t\t    const char *work_tree)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n \topt.invoked_hook = invoked_hook;\n \n \tstrvec_pushf(env, \"GIT_WORK_TREE=%s\", absolute_path(work_tree));\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex 4035b1cb06..b6831c19b5 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname,\n \t * is_junk is cleared, but do return appropriate code when hook fails.\n \t */\n \tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n \t\tstrvec_pushl(&opt.args,\ndiff --git a/commit.c b/commit.c\nindex 80d8d07875..4385ae4329 100644\n--- a/commit.c\n+++ b/commit.c\n@@ -1970,7 +1970,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len)\n int run_commit_hook(int editor_is_used, const char *index_file,\n \t\t    int *invoked_hook, const char *name, ...)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \tva_list args;\n \tconst char *arg;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 9476a97ca5..6e8b1ad588 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -832,4 +832,20 @@ test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1'\n \ttest_cmp expect repo-parallel/hook.order\n '\n \n+test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event pre-commit &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event pre-commit &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.jobs 2 &&\n+\tgit commit --allow-empty -m \"test: verify force-serial on pre-commit\" &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540045","messageId":"20260326101819.1307742-8-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 07/12] hook: add -j/--jobs option to git hook run","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:14Z","receivedAt":"2026-03-26T10:20:48Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nExpose the parallel job count as a command-line flag so callers can\nrequest parallelism without relying only on the hook.jobs config.\n\nAdd tests covering serial/parallel execution and TTY behaviour under\n-j1 vs -jN.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  23 +++++-\n builtin/hook.c              |   5 +-\n hook.c                      |  17 +++++\n t/t1800-hook.sh             | 135 ++++++++++++++++++++++++++++++++++--\n 4 files changed, 170 insertions(+), 10 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 318c637bd8..46ea52db55 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -8,7 +8,8 @@ git-hook - Run git hooks\n SYNOPSIS\n --------\n [verse]\n-'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n+\t<hook-name> [-- <hook-args>]\n 'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\n \n DESCRIPTION\n@@ -147,6 +148,23 @@ OPTIONS\n \tmirroring the output style of `git config --show-scope`. Traditional\n \thooks from the hookdir are unaffected.\n \n+-j::\n+--jobs::\n+\tOnly valid for `run`.\n++\n+Specify how many hooks to run simultaneously. If this flag is not specified,\n+the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n+neither is specified, defaults to 1 (serial execution).\n++\n+When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel`\n+setting, allowing all hooks for the event to run concurrently, even if they\n+are not individually marked as parallel.\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 parallel:\n+`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`,\n+`post-commit`, `post-checkout`, and `push-to-checkout`.\n+\n WRAPPERS\n --------\n \n@@ -169,7 +187,8 @@ running:\n git hook run --allow-unknown-hook-name mywrapper-start-tests \\\n   # providing something to stdin\n   --stdin some-tempfile-123 \\\n-  # execute hooks in serial\n+  # execute multiple hooks in parallel\n+  --jobs 3 \\\n   # plus some arguments of your own...\n   -- \\\n   --testname bar \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex c0585587e5..bea0668b47 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -8,7 +8,8 @@\n #include \"parse-options.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n-\tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+\tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n+\t   \"<hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\")\n \n@@ -132,6 +133,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n+\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\ndiff --git a/hook.c b/hook.c\nindex 25762b6c8d..c0b71322cf 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -568,6 +568,22 @@ static void merge_output_if_parallel(struct run_hooks_opt *options)\n \t\toptions->stdout_to_stderr = 1;\n }\n \n+static void warn_non_parallel_hooks_override(unsigned int jobs,\n+\t\t\t\t\t     struct string_list *hook_list)\n+{\n+\t/* Don't warn for hooks running sequentially. */\n+\tif (jobs == 1)\n+\t\treturn;\n+\n+\tfor (size_t i = 0; i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n+\t\t\twarning(_(\"hook '%s' is not marked as parallel=true, \"\n+\t\t\t\t  \"running in parallel anyway due to -j%u\"),\n+\t\t\t\th->u.configured.friendly_name, jobs);\n+\t}\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -611,6 +627,7 @@ static unsigned int get_hook_jobs(struct repository *r,\n \n cleanup:\n \tmerge_output_if_parallel(options);\n+\twarn_non_parallel_hooks_override(options->jobs, hook_list);\n \treturn options->jobs;\n }\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 6e8b1ad588..a1734fd628 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -268,10 +268,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n '\n \n test_hook_tty () {\n-\tcat >expect <<-\\EOF\n-\tSTDOUT TTY\n-\tSTDERR TTY\n-\tEOF\n+\texpect_tty=$1\n+\tshift\n+\n+\tif test \"$expect_tty\" != \"no_tty\"; then\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT TTY\n+\t\tSTDERR TTY\n+\t\tEOF\n+\telse\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT NO TTY\n+\t\tSTDERR NO TTY\n+\t\tEOF\n+\tfi\n \n \ttest_when_finished \"rm -rf repo\" &&\n \tgit init repo &&\n@@ -289,12 +299,21 @@ test_hook_tty () {\n \ttest_cmp expect repo/actual\n }\n \n-test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty hook run pre-commit\n+test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '\n+\t# hooks running sequentially (-j1) are always connected to the tty for\n+\t# optimum real-time performance.\n+\ttest_hook_tty tty hook run -j1 pre-commit\n+'\n+\n+test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '\n+\t# Hooks are not connected to the tty when run in parallel, instead they\n+\t# output to a pipe through which run-command collects and de-interlaces\n+\t# their outputs, which then gets passed either to the tty or a sideband.\n+\ttest_hook_tty no_tty hook run -j2 pre-commit\n '\n \n test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty commit -m\"B.new\"\n+\ttest_hook_tty tty commit -m\"B.new\"\n '\n \n test_expect_success 'git hook list orders by config order' '\n@@ -709,6 +728,108 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'parallel hook output is not interleaved' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo \"Hook 1 Start\"\n+\tsleep 1\n+\techo \"Hook 1 End\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t\t    \"echo \\\"Hook 2 Start\\\"; sleep 2; echo \\\"Hook 2 End\\\"\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.hook-3.event test-hook &&\n+\ttest_config hook.hook-3.command \\\n+\t\t    \"echo \\\"Hook 3 Start\\\"; sleep 3; echo \\\"Hook 3 End\\\"\" &&\n+\ttest_config hook.hook-3.parallel true &&\n+\n+\tgit hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel &&\n+\n+\t# Verify Hook 1 output is grouped\n+\tsed -n \"/Hook 1 Start/,/Hook 1 End/p\" err.parallel >hook1_out &&\n+\ttest_line_count = 2 hook1_out &&\n+\n+\t# Verify Hook 2 output is grouped\n+\tsed -n \"/Hook 2 Start/,/Hook 2 End/p\" err.parallel >hook2_out &&\n+\ttest_line_count = 2 hook2_out &&\n+\n+\t# Verify Hook 3 output is grouped\n+\tsed -n \"/Hook 3 Start/,/Hook 3 End/p\" err.parallel >hook3_out &&\n+\ttest_line_count = 2 hook3_out\n+'\n+\n+test_expect_success 'git hook run -j1 runs hooks in series' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\ttest_config hook.series-1.event \"test-hook\" &&\n+\ttest_config hook.series-1.command \"echo 1\" --add &&\n+\ttest_config hook.series-2.event \"test-hook\" &&\n+\ttest_config hook.series-2.command \"echo 2\" --add &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo 3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tgit hook run --allow-unknown-hook-name -j1 test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook run -j2 runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 overrides parallel=false' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# -j2 overrides parallel=false; hooks run in parallel with a warning.\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"true\" &&\n+\t# neither hook has parallel=true\n+\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\tgrep \"hook .hook-1. is not marked as parallel=true\" err &&\n+\tgrep \"hook .hook-2. is not marked as parallel=true\" err\n+'\n+\n test_expect_success 'hook.jobs=1 config runs hooks in series' '\n \ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540046","messageId":"20260326101819.1307742-6-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 05/12] hook: allow pre-push parallel execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:12Z","receivedAt":"2026-03-26T10:20:49Z","isPatch":true,"body":"pre-push is the only hook that keeps stdout and stderr separate (for\nbackwards compatibility with git-lfs and potentially other users). This\nprevents parallelizing it because run-command needs stdout_to_stderr=1\nto buffer and de-interleave parallel outputs.\n\nSince we now default to jobs=1, backwards compatibility is maintained\nwithout needing any extension or extra config: when no parallelism is\nrequested, pre-push behaves exactly as before.\n\nWhen the user explicitly opts into parallelism via hook.jobs > 1,\nhook.<event>.jobs > 1, or -jN, they accept the changed output behavior.\n\nDocument this and let get_hook_jobs() set stdout_to_stderr=1 automatically\nwhen jobs > 1, removing the need for any extension infrastructure.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 24 ++++++++++++++++--------\n hook.h                         |  6 ++++--\n t/t1800-hook.sh                | 32 ++++++++++++++++++++++++++++++++\n transport.c                    |  6 ++++--\n 5 files changed, 60 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 21800db648..94c7a9808e 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -39,3 +39,7 @@ hook.jobs::\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<friendly-name>.parallel` set to `true`.\n++\n+For `pre-push` hooks, which normally keep stdout and stderr separate,\n+setting this to a value greater than 1 (or passing `-j`) will merge stdout\n+into stderr to allow correct de-interleaving of parallel output.\ndiff --git a/hook.c b/hook.c\nindex 85c0de5e47..25762b6c8d 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -555,18 +555,24 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/*\n+ * When running in parallel, stdout must be merged into stderr so\n+ * run-command can buffer and de-interleave outputs correctly. This\n+ * applies even to hooks like pre-push that normally keep stdout and\n+ * stderr separate: the user has opted into parallelism, so the output\n+ * stream behavior changes accordingly.\n+ */\n+static void merge_output_if_parallel(struct run_hooks_opt *options)\n+{\n+\tif (options->jobs > 1)\n+\t\toptions->stdout_to_stderr = 1;\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n \t\t\t\t  struct string_list *hook_list)\n {\n-\t/*\n-\t * Hooks needing separate output streams must run sequentially.\n-\t * Next commit will allow parallelizing these as well.\n-\t */\n-\tif (!options->stdout_to_stderr)\n-\t\treturn 1;\n-\n \t/*\n \t * An explicit job count overrides everything else: this covers both\n \t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n@@ -575,7 +581,7 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t * aggressively than the default.\n \t */\n \tif (options->jobs)\n-\t\treturn options->jobs;\n+\t\tgoto cleanup;\n \n \t/*\n \t * Use hook.jobs from the already-parsed config cache (in-repo), or\n@@ -603,6 +609,8 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\t}\n \t}\n \n+cleanup:\n+\tmerge_output_if_parallel(options);\n \treturn options->jobs;\n }\n \ndiff --git a/hook.h b/hook.h\nindex ba7056f872..01db4226a6 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -106,8 +106,10 @@ struct run_hooks_opt {\n \t * Send the hook's stdout to stderr.\n \t *\n \t * This is the default behavior for all hooks except pre-push,\n-\t * which has separate stdout and stderr streams for backwards\n-\t * compatibility reasons.\n+\t * which keeps stdout and stderr separate for backwards compatibility.\n+\t * When parallel execution is requested (jobs > 1), get_hook_jobs()\n+\t * overrides this to 1 for all hooks so run-command can de-interleave\n+\t * their outputs correctly.\n \t */\n \tunsigned int stdout_to_stderr:1;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 3774a6c2e1..9476a97ca5 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -800,4 +800,36 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'client hooks: pre-push parallel execution merges stdout to stderr' '\n+\ttest_when_finished \"rm -rf remote-par stdout.actual stderr.actual\" &&\n+\tgit init --bare remote-par &&\n+\tgit remote add origin-par remote-par &&\n+\ttest_commit par-commit &&\n+\tmkdir -p .git/hooks &&\n+\tsetup_hooks pre-push &&\n+\ttest_config hook.jobs 2 &&\n+\tgit push origin-par HEAD:main >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push\n+'\n+\n+test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' '\n+\ttest_when_finished \"rm -rf repo-parallel remote-parallel\" &&\n+\tgit init --bare remote-parallel &&\n+\tgit init repo-parallel &&\n+\tgit -C repo-parallel remote add origin ../remote-parallel &&\n+\ttest_commit -C repo-parallel A &&\n+\n+\twrite_sentinel_hook repo-parallel/.git/hooks/pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.event pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\tgit -C repo-parallel config hook.hook-2.parallel true &&\n+\n+\tgit -C repo-parallel config hook.jobs 2 &&\n+\n+\tgit -C repo-parallel push origin HEAD >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect repo-parallel/hook.order\n+'\n+\n test_done\ndiff --git a/transport.c b/transport.c\nindex e53936d87b..9406ec4f2d 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1391,8 +1391,10 @@ static int run_pre_push_hook(struct transport *transport,\n \topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n \n \t/*\n-\t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n-\t * them to keep backwards compatibility with existing hooks.\n+\t * pre-push hooks keep stdout and stderr separate by default for\n+\t * backwards compatibility. When the user opts into parallel execution\n+\t * via hook.jobs > 1 or -j, get_hook_jobs() will set stdout_to_stderr=1\n+\t * automatically so run-command can de-interleave the outputs.\n \t */\n \topt.stdout_to_stderr = 0;\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540047","messageId":"20260326101819.1307742-9-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 08/12] hook: add per-event jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:15Z","receivedAt":"2026-03-26T10:20:49Z","isPatch":true,"body":"Add a hook.<event>.jobs count config that allows users to override the\nglobal hook.jobs setting for specific hook events.\n\nThis allows finer-grained control over parallelism on a per-event basis.\n\nFor example, to run `post-receive` hooks with up to 4 parallel jobs\nwhile keeping other events at their global default:\n\n[hook]\n    post-receive.jobs = 4\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 19 +++++++++++\n hook.c                         | 46 +++++++++++++++++++++++---\n repository.c                   |  1 +\n repository.h                   |  3 ++\n t/t1800-hook.sh                | 59 ++++++++++++++++++++++++++++++++++\n 5 files changed, 123 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 6f60775c28..d4fa29d936 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -33,9 +33,28 @@ hook.<friendly-name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.jobs::\n+\tSpecifies how many hooks can be run simultaneously for the `<event>`\n+\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n+\tfor this specific event. The same parallelism restrictions apply: this\n+\tsetting has no effect unless all configured hooks for the event have\n+\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\tzero is rejected with a warning. See linkgit:git-hook[1].\n++\n+Note on naming: although this key resembles `hook.<friendly-name>.*`\n+(a per-hook setting), `<event>` must be the event name, not a hook\n+friendly name. The key component is stored literally and looked up by\n+event name at runtime with no translation between the two namespaces.\n+A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n+lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n+`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n+registered for that event. Use `hook.post-receive.jobs` or any other\n+valid event name when setting `hook.<event>.jobs`.\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tthey operate on shared data and cannot safely be parallelized:\n +\ndiff --git a/hook.c b/hook.c\nindex c0b71322cf..d98b011563 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -125,6 +125,7 @@ struct hook_config_cache_entry {\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n  * parallel_hooks: friendly-name to parallel flag.\n+ * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n@@ -132,6 +133,7 @@ struct hook_all_config_cb {\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n \tstruct strmap parallel_hooks;\n+\tstruct strmap event_jobs;\n \tunsigned int jobs;\n };\n \n@@ -231,6 +233,18 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\twarning(_(\"hook.%s.parallel must be a boolean,\"\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n+\t} else if (!strcmp(subkey, \"jobs\")) {\n+\t\tunsigned int v;\n+\t\tif (!git_parse_uint(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive integer,\"\n+\t\t\t\t  \" ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n+\t\telse if (!v)\n+\t\t\twarning(_(\"hook.%s.jobs must be positive,\"\n+\t\t\t\t  \" ignoring: 0\"), hook_name);\n+\t\telse\n+\t\t\tstrmap_put(&data->event_jobs, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)v);\n \t}\n \n \tfree(hook_name);\n@@ -276,6 +290,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \tstrmap_init(&cb_data.parallel_hooks);\n+\tstrmap_init(&cb_data.event_jobs);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -323,8 +338,10 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tstrmap_put(cache, e->key, hooks);\n \t}\n \n-\tif (r)\n+\tif (r) {\n \t\tr->hook_jobs = cb_data.jobs;\n+\t\tr->event_jobs = cb_data.event_jobs;\n+\t}\n \n \tstrmap_clear(&cb_data.commands, 1);\n \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n@@ -587,6 +604,7 @@ static void warn_non_parallel_hooks_override(unsigned int jobs,\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  const char *hook_name,\n \t\t\t\t  struct string_list *hook_list)\n {\n \t/*\n@@ -606,16 +624,34 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t */\n \toptions->jobs = 1;\n \tif (r) {\n-\t\tif (r->gitdir && r->hook_config_cache && r->hook_jobs)\n-\t\t\toptions->jobs = r->hook_jobs;\n-\t\telse\n+\t\tif (r->gitdir && r->hook_config_cache) {\n+\t\t\tvoid *event_jobs;\n+\n+\t\t\tif (r->hook_jobs)\n+\t\t\t\toptions->jobs = r->hook_jobs;\n+\n+\t\t\tevent_jobs = strmap_get(&r->event_jobs, hook_name);\n+\t\t\tif (event_jobs)\n+\t\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n+\t\t} else {\n+\t\t\tunsigned int event_jobs;\n+\t\t\tchar *key;\n+\n \t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\n+\t\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n+\t\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n+\t\t\t\toptions->jobs = event_jobs;\n+\t\t\tfree(key);\n+\t\t}\n \t}\n \n \t/*\n \t * Cap to serial any configured hook not marked as parallel = true.\n \t * This enforces the parallel = false default, even for \"traditional\"\n \t * hooks from the hookdir which cannot be marked parallel = true.\n+\t * The same restriction applies whether jobs came from hook.jobs or\n+\t * hook.<event>.jobs.\n \t */\n \tfor (size_t i = 0; i < hook_list->nr; i++) {\n \t\tstruct hook *h = hook_list->items[i].util;\n@@ -642,7 +678,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n-\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\ndiff --git a/repository.c b/repository.c\nindex fb4356ca55..ff3c357dfc 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -425,6 +425,7 @@ void repo_clear(struct repository *repo)\n \t\thook_cache_clear(repo->hook_config_cache);\n \t\tFREE_AND_NULL(repo->hook_config_cache);\n \t}\n+\tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n \n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 58e46853d0..6b67ec02e2 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -175,6 +175,9 @@ struct repository {\n \t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n \tunsigned int hook_jobs;\n \n+\t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n+\tstruct strmap event_jobs;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex a1734fd628..ab2b52bec6 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -969,4 +969,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=1 (serial), but per-event override allows parallel.\n+\ttest_config hook.jobs 1 &&\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=4 allows parallel, but per-event override forces serial.\n+\ttest_config hook.jobs 4 &&\n+\ttest_config hook.test-hook.jobs 1 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# Per-event jobs=2 but no hook has parallel=true: must still run serially.\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540048","messageId":"20260326101819.1307742-5-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 04/12] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:11Z","receivedAt":"2026-03-26T10:20:51Z","isPatch":true,"body":"From: Emily Shaffer <nasamuffin@google.com>\n\nHooks always run in sequential order due to the hardcoded jobs == 1\npassed to run_process_parallel(). Remove that hardcoding to allow\nusers to run hooks in parallel (opt-in).\n\nUsers need to decide which hooks to run in parallel, by specifying\n\"parallel = true\" in the config, because Git cannot know if their\nspecific hooks are safe to run or not in parallel (for e.g. two hooks\nmight write to the same file or call the same program).\n\nSome hooks are unsafe to run in parallel by design: these will marked\nin the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL.\n\nThe hook.jobs config specifies the default number of jobs applied to all\nhooks which have parallelism enabled.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  13 +++\n hook.c                         |  79 ++++++++++++++++--\n hook.h                         |  25 ++++++\n t/t1800-hook.sh                | 142 +++++++++++++++++++++++++++++++++\n 4 files changed, 253 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex b7847f9338..21800db648 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -23,6 +23,19 @@ hook.<friendly-name>.enabled::\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n \n+hook.<friendly-name>.parallel::\n+\tWhether the hook `hook.<friendly-name>` may run in parallel with other hooks\n+\tfor the same event. Defaults to `false`. Set to `true` only when the\n+\thook script is safe to run concurrently with other hooks for the same\n+\tevent. If any hook for an event does not have this set to `true`,\n+\tall hooks for that event run sequentially regardless of `hook.jobs`.\n+\tOnly configured (named) hooks need to declare this. Traditional hooks\n+\tfound in the hooks directory do not need to, and run in parallel when\n+\tthe effective job count is greater than 1. See linkgit:git-hook[1].\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n++\n+This setting has no effect unless all configured hooks for the event have\n+`hook.<friendly-name>.parallel` set to `true`.\ndiff --git a/hook.c b/hook.c\nindex b8cce00e57..85c0de5e47 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -116,6 +116,7 @@ struct hook_config_cache_entry {\n \tchar *command;\n \tenum config_scope scope;\n \tbool disabled;\n+\tbool parallel;\n };\n \n /*\n@@ -123,12 +124,14 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * parallel_hooks: friendly-name to parallel flag.\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tstruct strmap parallel_hooks;\n \tunsigned int jobs;\n };\n \n@@ -219,6 +222,15 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tdefault:\n \t\t\tbreak; /* ignore unrecognised values */\n \t\t}\n+\t} else if (!strcmp(subkey, \"parallel\")) {\n+\t\tint v = git_parse_maybe_bool(value);\n+\t\tif (v >= 0)\n+\t\t\tstrmap_put(&data->parallel_hooks, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)v);\n+\t\telse\n+\t\t\twarning(_(\"hook.%s.parallel must be a boolean,\"\n+\t\t\t\t  \" ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n \t}\n \n \tfree(hook_name);\n@@ -263,6 +275,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.commands);\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n+\tstrmap_init(&cb_data.parallel_hooks);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -282,6 +295,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tstruct hook_config_cache_entry *entry;\n \t\t\tchar *command;\n \n+\t\t\tbool is_par = !!strmap_get(&cb_data.parallel_hooks, hname);\n \t\t\tbool is_disabled =\n \t\t\t\t!!unsorted_string_list_lookup(\n \t\t\t\t\t&cb_data.disabled_hooks, hname);\n@@ -302,6 +316,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tentry->command = xstrdup_or_null(command);\n \t\t\tentry->scope = scope;\n \t\t\tentry->disabled = is_disabled;\n+\t\t\tentry->parallel = is_par;\n \t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n@@ -312,6 +327,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tr->hook_jobs = cb_data.jobs;\n \n \tstrmap_clear(&cb_data.commands, 1);\n+\tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstring_list_clear(e->value, 0);\n@@ -389,6 +405,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n@@ -538,21 +555,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/* Determine how many jobs to use for hook execution. */\n+static unsigned int get_hook_jobs(struct repository *r,\n+\t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  struct string_list *hook_list)\n+{\n+\t/*\n+\t * Hooks needing separate output streams must run sequentially.\n+\t * Next commit will allow parallelizing these as well.\n+\t */\n+\tif (!options->stdout_to_stderr)\n+\t\treturn 1;\n+\n+\t/*\n+\t * An explicit job count overrides everything else: this covers both\n+\t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n+\t * and the -j flag from the CLI. The CLI override is intentional: users\n+\t * may want to serialize hooks declared parallel or to parallelize more\n+\t * aggressively than the default.\n+\t */\n+\tif (options->jobs)\n+\t\treturn options->jobs;\n+\n+\t/*\n+\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n+\t * fallback to a direct config lookup (out-of-repo).\n+\t * Default to 1 (serial execution) on failure.\n+\t */\n+\toptions->jobs = 1;\n+\tif (r) {\n+\t\tif (r->gitdir && r->hook_config_cache && r->hook_jobs)\n+\t\t\toptions->jobs = r->hook_jobs;\n+\t\telse\n+\t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\t}\n+\n+\t/*\n+\t * Cap to serial any configured hook not marked as parallel = true.\n+\t * This enforces the parallel = false default, even for \"traditional\"\n+\t * hooks from the hookdir which cannot be marked parallel = true.\n+\t */\n+\tfor (size_t i = 0; i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel) {\n+\t\t\toptions->jobs = 1;\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+\n+\treturn options->jobs;\n+}\n+\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n+\tstruct string_list *hook_list = list_hooks(r, hook_name, options);\n \tstruct hook_cb_data cb_data = {\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n+\t\t.hook_command_list = hook_list,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\n \n-\t\t.processes = options->jobs,\n-\t\t.ungroup = options->jobs == 1,\n+\t\t.processes = jobs,\n+\t\t.ungroup = jobs == 1,\n \n \t\t.get_next_task = pick_next_hook,\n \t\t.start_failure = notify_start_failure,\n@@ -568,9 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->path_to_stdin && options->feed_pipe)\n \t\tBUG(\"options path_to_stdin and feed_pipe are mutually exclusive\");\n \n-\tif (!options->jobs)\n-\t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n-\n \t/*\n \t * Ensure cb_data copy and free functions are either provided together,\n \t * or neither one is provided.\n@@ -581,7 +649,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n \tif (!cb_data.hook_command_list->nr) {\n \t\tif (options->error_if_missing)\n \t\t\tret = error(\"cannot find a hook named %s\", hook_name);\ndiff --git a/hook.h b/hook.h\nindex 5c5628dd1f..ba7056f872 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -35,6 +35,13 @@ struct hook {\n \t\t} configured;\n \t} u;\n \n+\t/**\n+\t * Whether this hook may run in parallel with other hooks for the same\n+\t * event. Only useful for configured (named) hooks. Traditional hooks\n+\t * always default to 0 (serial). Set via `hook.<name>.parallel = true`.\n+\t */\n+\tbool parallel;\n+\n \t/**\n \t * Opaque data pointer used to keep internal state across callback calls.\n \t *\n@@ -72,6 +79,8 @@ struct run_hooks_opt {\n \t *\n \t * If > 1, output will be buffered and de-interleaved (ungroup=0).\n \t * If == 1, output will be real-time (ungroup=1).\n+\t * If == 0, the 'hook.jobs' config is used or, if the config is unset,\n+\t * defaults to 1 (serial execution).\n \t */\n \tunsigned int jobs;\n \n@@ -152,7 +161,23 @@ struct run_hooks_opt {\n \thook_data_free_fn feed_pipe_cb_data_free;\n };\n \n+/**\n+ * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to\n+ * the 'hook.jobs' config, falling back to serial (1) if unset.\n+ */\n #define RUN_HOOKS_OPT_INIT { \\\n+\t.env = STRVEC_INIT, \\\n+\t.args = STRVEC_INIT, \\\n+\t.stdout_to_stderr = 1, \\\n+\t.jobs = 0, \\\n+}\n+\n+/**\n+ * Initializer for hooks that must always run sequentially regardless of\n+ * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized\n+ * .jobs = 1 is non-overridable.\n+ */\n+#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \\\n \t.env = STRVEC_INIT, \\\n \t.args = STRVEC_INIT, \\\n \t.stdout_to_stderr = 1, \\\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 96749fc06d..3774a6c2e1 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -21,6 +21,57 @@ setup_hookdir () {\n \ttest_when_finished rm -rf .git/hooks\n }\n \n+# write_sentinel_hook <path> [sentinel]\n+#\n+# Writes a hook that marks itself as started, sleeps for a few seconds, then\n+# marks itself done. The sleep must be long enough that sentinel_detector can\n+# observe <sentinel>.started before <sentinel>.done appears when both hooks\n+# run concurrently in parallel mode.\n+write_sentinel_hook () {\n+\tsentinel=\"${2:-sentinel}\"\n+\twrite_script \"$1\" <<-EOF\n+\ttouch ${sentinel}.started &&\n+\tsleep 2 &&\n+\ttouch ${sentinel}.done\n+\tEOF\n+}\n+\n+# sentinel_detector <sentinel> <output>\n+#\n+# Returns a shell command string suitable for use as hook.<name>.command.\n+# The detector must be registered after the sentinel:\n+# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)\n+#    before the detector starts.\n+# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared\n+#    yet and the detector just sees <sentinel>.started.\n+#\n+# At start, poll until <sentinel>.started exists to absorb startup jitter, then\n+# write to <output>:\n+# 1. 'serial'   if <sentinel>.done exists (sentinel finished before we started),\n+# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),\n+# 3. 'timeout'  if <sentinel>.started never appeared.\n+#\n+# The command ends with ':' so when git appends \"$@\" for hooks that receive\n+# positional arguments (e.g. pre-push), the result ': \"$@\"' is valid shell\n+# rather than a syntax error 'fi \"$@\"'.\n+sentinel_detector () {\n+\tcat <<-EOF\n+\ti=0\n+\twhile ! test -f ${1}.started && test \\$i -lt 10; do\n+\t    sleep 1\n+\t    i=\\$((i+1))\n+\tdone\n+\tif test -f ${1}.done; then\n+\t    echo serial >${2}\n+\telif test -f ${1}.started; then\n+\t    echo parallel >${2}\n+\telse\n+\t    echo timeout >${2}\n+\tfi\n+\t:\n+\tEOF\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n@@ -658,4 +709,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'hook.jobs=1 config runs hooks in series' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\n+\t# Use two configured hooks so the execution order is deterministic:\n+\t# hook-1 (sentinel) is listed before hook-2 (detector), so hook-1\n+\t# always runs first even in serial mode.\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 1 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=true enables parallel execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'one non-parallel hook forces the whole event to run serially' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 has no parallel=true: should force serial for all\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540049","messageId":"20260326101819.1307742-10-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 09/12] hook: warn when hook.<friendly-name>.jobs is set","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:16Z","receivedAt":"2026-03-26T10:20:51Z","isPatch":true,"body":"Issue a warning when the user confuses the hook process and event\nnamespaces by setting hook.<friendly-name>.jobs.\n\nDetect this by checking whether the name carrying .jobs also has\n.command, .event, or .parallel configured.  Extract is_friendly_name()\nas a helper for this check, to be reused by future per-event config\nhandling.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n hook.c          | 40 ++++++++++++++++++++++++++++++++++++++++\n t/t1800-hook.sh | 30 ++++++++++++++++++++++++++++++\n 2 files changed, 70 insertions(+)\n\ndiff --git a/hook.c b/hook.c\nindex d98b011563..0493993bbe 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -279,6 +279,44 @@ void hook_cache_clear(struct strmap *cache)\n \tstrmap_clear(cache, 0);\n }\n \n+/*\n+ * Return true if `name` is a hook friendly-name, i.e. it has at least one of\n+ * .command, .event, or .parallel configured. These are the reliable clues\n+ * that distinguish a friendly-name from an event name. Note: .enabled is\n+ * deliberately excluded because it can appear under both namespaces.\n+ */\n+static int is_friendly_name(struct hook_all_config_cb *cb, const char *name)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tif (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name))\n+\t\treturn 1;\n+\n+\tstrmap_for_each_entry(&cb->event_hooks, &iter, e) {\n+\t\tif (unsorted_string_list_lookup(e->value, name))\n+\t\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+/* Warn if any name in event_jobs is also a hook friendly-name. */\n+static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tstrmap_for_each_entry(&cb_data->event_jobs, &iter, e) {\n+\t\tif (is_friendly_name(cb_data, e->key))\n+\t\t\twarning(_(\"hook.%s.jobs is set but '%s' looks like a \"\n+\t\t\t\t  \"hook friendly-name, not an event name; \"\n+\t\t\t\t  \"hook.<event>.jobs uses the event name \"\n+\t\t\t\t  \"(e.g. hook.post-receive.jobs), so this \"\n+\t\t\t\t  \"setting will be ignored\"), e->key, e->key);\n+\t}\n+}\n+\n /* Populate `cache` with the complete hook configuration */\n static void build_hook_config_map(struct repository *r, struct strmap *cache)\n {\n@@ -295,6 +333,8 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n+\twarn_jobs_on_friendly_names(&cb_data);\n+\n \t/* Construct the cache from parsed configs. */\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstruct string_list *hook_names = e->value;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex ab2b52bec6..85b055a897 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1028,4 +1028,34 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' '\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .event' '\n+\ttest_config hook.my-hook.event test-hook &&\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .parallel' '\n+\ttest_config hook.my-hook.event test-hook &&\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.parallel true &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n+\ttest_config hook.test-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep ! \"friendly-name\" err\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540050","messageId":"20260326101819.1307742-12-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 11/12] hook: add hook.<event>.enabled switch","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:18Z","receivedAt":"2026-03-26T10:21:00Z","isPatch":true,"body":"Add a hook.<event>.enabled config key that disables all hooks for\na given event, when set to false, acting as a high-level switch\nabove the existing per-hook hook.<friendly-name>.enabled.\n\nEvent-disabled hooks are shown in \"git hook list\" with an\n\"event-disabled\" tab-separated prefix before the name:\n\n$ git hook list test-hook\nevent-disabled  hook-1\nevent-disabled  hook-2\n\nWith --show-scope:\n\n$ git hook list --show-scope test-hook\nlocal   event-disabled  hook-1\n\nWhen a hook is both per-hook disabled and event-disabled, only\n\"event-disabled\" is shown: the event-level switch is the more\nrelevant piece of information, and the per-hook \"disabled\" status\nwill surface once the event is re-enabled.\n\nUsing an event name as a friendly-name (e.g. hook.<event>.enabled)\ncan cause ambiguity, so a fatal error is issued when using a known\nevent name and a warning is issued for unknown event name, since\na collision cannot be detected with certainty for unknown events.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 20 ++++++++\n builtin/hook.c                 | 20 +++++---\n hook.c                         | 47 +++++++++++++++++--\n hook.h                         |  1 +\n repository.c                   |  1 +\n repository.h                   |  4 ++\n t/t1800-hook.sh                | 83 ++++++++++++++++++++++++++++++++++\n 7 files changed, 165 insertions(+), 11 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex d4fa29d936..e0db3afa19 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -15,6 +15,12 @@ hook.<friendly-name>.event::\n \tevents, specify the key more than once. An empty value resets\n \tthe list of events, clearing any previously defined events for\n \t`hook.<friendly-name>`. See linkgit:git-hook[1].\n++\n+The `<friendly-name>` must not be the same as a known hook event name\n+(e.g. do not use `hook.pre-commit.event`). Using a known event name as\n+a friendly-name is a fatal error because it creates an ambiguity with\n+`hook.<event>.enabled` and `hook.<event>.jobs`. For unknown event names,\n+a warning is issued when `<friendly-name>` matches the event value.\n \n hook.<friendly-name>.enabled::\n \tWhether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.\n@@ -33,6 +39,20 @@ hook.<friendly-name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.enabled::\n+\tSwitch to enable or disable all hooks for the `<event>` hook event.\n+\tWhen set to `false`, no hooks fire for that event, regardless of any\n+\tper-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`.\n+\tSee linkgit:git-hook[1].\n++\n+Note on naming: `<event>` must be the event name (e.g. `pre-commit`),\n+not a hook friendly-name. Since using a known event name as a\n+friendly-name is disallowed (see `hook.<friendly-name>.event` above),\n+there is no ambiguity between event-level and per-hook `.enabled`\n+settings for known events. For unknown events, if a friendly-name\n+matches the event name despite the warning, `.enabled` is treated\n+as per-hook only.\n+\n hook.<event>.jobs::\n \tSpecifies how many hooks can be run simultaneously for the `<event>`\n \thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 1839412dca..8e47e22e2a 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -87,14 +87,22 @@ static int list(int argc, const char **argv, const char *prefix,\n \t\t\tconst char *name = h->u.configured.friendly_name;\n \t\t\tconst char *scope = show_scope ?\n \t\t\t\tconfig_scope_name(h->u.configured.scope) : NULL;\n+\t\t\t/*\n+\t\t\t * Show the most relevant disable reason. Event-level\n+\t\t\t * takes precedence: if the whole event is off, that\n+\t\t\t * is what the user needs to know. The per-hook\n+\t\t\t * \"disabled\" surfaces once the event is re-enabled.\n+\t\t\t */\n+\t\t\tconst char *disability =\n+\t\t\t\th->u.configured.event_disabled ? \"event-disabled\\t\" :\n+\t\t\t\th->u.configured.disabled       ? \"disabled\\t\"       :\n+\t\t\t\t\t\t\t\t \"\";\n \t\t\tif (scope)\n-\t\t\t\tprintf(\"%s\\t%s%s%c\", scope,\n-\t\t\t\t       h->u.configured.disabled ? \"disabled\\t\" : \"\",\n-\t\t\t\t       name, line_terminator);\n+\t\t\t\tprintf(\"%s\\t%s%s%c\", scope, disability, name,\n+\t\t\t\t       line_terminator);\n \t\t\telse\n-\t\t\t\tprintf(\"%s%s%c\",\n-\t\t\t\t       h->u.configured.disabled ? \"disabled\\t\" : \"\",\n-\t\t\t\t       name, line_terminator);\n+\t\t\t\tprintf(\"%s%s%c\", disability, name,\n+\t\t\t\t       line_terminator);\n \t\t\tbreak;\n \t\t}\n \t\tdefault:\ndiff --git a/hook.c b/hook.c\nindex 19076f8f2b..bc990d4ed4 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -133,7 +133,9 @@ struct hook_config_cache_entry {\n  * Callback struct to collect all hook.* keys in a single config pass.\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n- * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * disabled_hooks: set of all names with hook.<name>.enabled = false; after\n+ *                 parsing, names that are not friendly-names become event-level\n+ *                 disables stored in r->disabled_events. This collects all.\n  * parallel_hooks: friendly-name to parallel flag.\n  * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n@@ -189,8 +191,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\tstrmap_for_each_entry(&data->event_hooks, &iter, e)\n \t\t\t\tunsorted_string_list_remove(e->value, hook_name, 0);\n \t\t} else {\n-\t\t\tstruct string_list *hooks =\n-\t\t\t\tstrmap_get(&data->event_hooks, value);\n+\t\t\tstruct string_list *hooks;\n+\n+\t\t\tif (is_known_hook(hook_name))\n+\t\t\t\tdie(_(\"hook friendly-name '%s' collides with \"\n+\t\t\t\t      \"a known event name; please choose a \"\n+\t\t\t\t      \"different friendly-name\"),\n+\t\t\t\t    hook_name);\n+\n+\t\t\tif (!strcmp(hook_name, value))\n+\t\t\t\twarning(_(\"hook friendly-name '%s' is the \"\n+\t\t\t\t\t  \"same as its event; this may cause \"\n+\t\t\t\t\t  \"ambiguity with hook.%s.enabled\"),\n+\t\t\t\t\thook_name, hook_name);\n+\n+\t\t\thooks = strmap_get(&data->event_hooks, value);\n \n \t\t\tif (!hooks) {\n \t\t\t\tCALLOC_ARRAY(hooks, 1);\n@@ -345,6 +360,22 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \n \twarn_jobs_on_friendly_names(&cb_data);\n \n+\t/*\n+\t * Populate disabled_events: names in disabled_hooks that are not\n+\t * friendly-names are event-level switches (hook.<event>.enabled = false).\n+\t * Names that are friendly-names are already handled per-hook via the\n+\t * hook_config_cache_entry.disabled flag below.\n+\t */\n+\tif (r) {\n+\t\tstring_list_clear(&r->disabled_events, 0);\n+\t\tstring_list_init_dup(&r->disabled_events);\n+\t\tfor (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) {\n+\t\t\tconst char *n = cb_data.disabled_hooks.items[i].string;\n+\t\t\tif (!is_friendly_name(&cb_data, n))\n+\t\t\t\tstring_list_append(&r->disabled_events, n);\n+\t\t}\n+\t}\n+\n \t/* Construct the cache from parsed configs. */\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstruct string_list *hook_names = e->value;\n@@ -446,6 +477,8 @@ static void list_hooks_add_configured(struct repository *r,\n {\n \tstruct strmap *cache = get_hook_config_cache(r);\n \tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n+\tbool event_is_disabled = r ? !!unsorted_string_list_lookup(&r->disabled_events,\n+\t\t\t\t\t\t\t\t   hookname) : 0;\n \n \t/* Iterate through configured hooks and initialize internal states */\n \tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n@@ -472,6 +505,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->u.configured.event_disabled = event_is_disabled;\n \t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n@@ -484,6 +518,8 @@ static void list_hooks_add_configured(struct repository *r,\n \tif (!r || !r->gitdir) {\n \t\thook_cache_clear(cache);\n \t\tfree(cache);\n+\t\tif (r)\n+\t\t\tstring_list_clear(&r->disabled_events, 0);\n \t}\n }\n \n@@ -515,7 +551,7 @@ int hook_exists(struct repository *r, const char *name)\n \tfor (size_t i = 0; i < hooks->nr; i++) {\n \t\tstruct hook *h = hooks->items[i].util;\n \t\tif (h->kind == HOOK_TRADITIONAL ||\n-\t\t    !h->u.configured.disabled) {\n+\t\t    (!h->u.configured.disabled && !h->u.configured.event_disabled)) {\n \t\t\texists = 1;\n \t\t\tbreak;\n \t\t}\n@@ -538,7 +574,8 @@ static int pick_next_hook(struct child_process *cp,\n \t\tif (hook_cb->hook_to_run_index >= hook_list->nr)\n \t\t\treturn 0;\n \t\th = hook_list->items[hook_cb->hook_to_run_index++].util;\n-\t} while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled);\n+\t} while (h->kind == HOOK_CONFIGURED &&\n+\t\t (h->u.configured.disabled || h->u.configured.event_disabled));\n \n \tcp->no_stdin = 1;\n \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\ndiff --git a/hook.h b/hook.h\nindex 5a93f56618..b4372b636f 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -32,6 +32,7 @@ struct hook {\n \t\t\tconst char *command;\n \t\t\tenum config_scope scope;\n \t\t\tbool disabled;\n+\t\t\tbool event_disabled;\n \t\t} configured;\n \t} u;\n \ndiff --git a/repository.c b/repository.c\nindex ff3c357dfc..983febd277 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -426,6 +426,7 @@ void repo_clear(struct repository *repo)\n \t\tFREE_AND_NULL(repo->hook_config_cache);\n \t}\n \tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n+\tstring_list_clear(&repo->disabled_events, 0);\n \n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 6b67ec02e2..4969d8b8eb 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -2,6 +2,7 @@\n #define REPOSITORY_H\n \n #include \"strmap.h\"\n+#include \"string-list.h\"\n #include \"repo-settings.h\"\n #include \"environment.h\"\n \n@@ -178,6 +179,9 @@ struct repository {\n \t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n \tstruct strmap event_jobs;\n \n+\t/* Cached list of event names with hook.<event>.enabled = false. */\n+\tstruct string_list disabled_events;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 85b055a897..273588e4d4 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1058,4 +1058,87 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n \ttest_grep ! \"friendly-name\" err\n '\n \n+test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_must_be_empty out\n+'\n+\n+test_expect_success 'hook.<event>.enabled=true does not suppress hooks' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled true &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"ran\" err\n+'\n+\n+test_expect_success 'hook.<event>.enabled=false does not affect other events' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.other-event.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"ran\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.enabled=false still disables that hook' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo hook-1\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"echo hook-2\" &&\n+\ttest_config hook.hook-1.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep ! \"hook-1\" err &&\n+\ttest_grep \"hook-2\" err\n+'\n+\n+test_expect_success 'git hook list shows event-disabled hooks as event-disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name test-hook >actual &&\n+\ttest_grep \"^event-disabled\thook-1$\" actual &&\n+\ttest_grep \"^event-disabled\thook-2$\" actual\n+'\n+\n+test_expect_success 'git hook list shows scope with event-disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name --show-scope test-hook >actual &&\n+\ttest_grep \"^local\tevent-disabled\thook-1$\" actual\n+'\n+\n+test_expect_success 'git hook list still shows hooks when event is disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name test-hook >actual &&\n+\ttest_grep \"event-disabled\" actual\n+'\n+\n+test_expect_success 'friendly-name matching known event name is rejected' '\n+\ttest_config hook.pre-commit.event pre-commit &&\n+\ttest_config hook.pre-commit.command \"echo oops\" &&\n+\ttest_must_fail git hook run pre-commit 2>err &&\n+\ttest_grep \"collides with a known event name\" err\n+'\n+\n+test_expect_success 'friendly-name matching known event name is rejected even for different event' '\n+\ttest_config hook.pre-commit.event post-commit &&\n+\ttest_config hook.pre-commit.command \"echo oops\" &&\n+\ttest_must_fail git hook run post-commit 2>err &&\n+\ttest_grep \"collides with a known event name\" err\n+'\n+\n+test_expect_success 'friendly-name matching unknown event warns' '\n+\ttest_config hook.test-hook.event test-hook &&\n+\ttest_config hook.test-hook.command \"echo ran\" &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"same as its event\" err\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540051","messageId":"20260326101819.1307742-11-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:17Z","receivedAt":"2026-03-26T10:21:04Z","isPatch":true,"body":"Move is_known_hook() from builtin/hook.c (static) into hook.c and\nexport it via hook.h so it can be reused.\n\nMake it return bool and the iterator `h` for clarity (iterate hooks).\n\nThe next commit will use this to reject hook friendly-names that\ncollide with known event names.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Makefile       |  2 +-\n builtin/hook.c | 10 ----------\n hook.c         | 10 ++++++++++\n hook.h         |  6 ++++++\n 4 files changed, 17 insertions(+), 11 deletions(-)\n\ndiff --git a/Makefile b/Makefile\nindex 6d64431219..4b3a7bdce5 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -2673,7 +2673,7 @@ git$X: git.o GIT-LDFLAGS $(BUILTIN_OBJS) $(GITLIBS)\n \n help.sp help.s help.o: command-list.h\n builtin/bugreport.sp builtin/bugreport.s builtin/bugreport.o: hook-list.h\n-builtin/hook.sp builtin/hook.s builtin/hook.o: hook-list.h\n+hook.sp hook.s hook.o: hook-list.h\n \n builtin/help.sp builtin/help.s builtin/help.o: config-list.h GIT-PREFIX\n builtin/help.sp builtin/help.s builtin/help.o: EXTRA_CPPFLAGS = \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex bea0668b47..1839412dca 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -4,7 +4,6 @@\n #include \"environment.h\"\n #include \"gettext.h\"\n #include \"hook.h\"\n-#include \"hook-list.h\"\n #include \"parse-options.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n@@ -13,15 +12,6 @@\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\")\n \n-static int is_known_hook(const char *name)\n-{\n-\tconst char **p;\n-\tfor (p = hook_name_list; *p; p++)\n-\t\tif (!strcmp(*p, name))\n-\t\t\treturn 1;\n-\treturn 0;\n-}\n-\n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n \tBUILTIN_HOOK_LIST_USAGE,\ndiff --git a/hook.c b/hook.c\nindex 0493993bbe..19076f8f2b 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -5,6 +5,7 @@\n #include \"environment.h\"\n #include \"gettext.h\"\n #include \"hook.h\"\n+#include \"hook-list.h\"\n #include \"parse.h\"\n #include \"path.h\"\n #include \"run-command.h\"\n@@ -12,6 +13,15 @@\n #include \"strbuf.h\"\n #include \"strmap.h\"\n \n+bool is_known_hook(const char *name)\n+{\n+\tconst char **h;\n+\tfor (h = hook_name_list; *h; h++)\n+\t\tif (!strcmp(*h, name))\n+\t\t\treturn true;\n+\treturn false;\n+}\n+\n const char *find_hook(struct repository *r, const char *name)\n {\n \tstatic struct strbuf path = STRBUF_INIT;\ndiff --git a/hook.h b/hook.h\nindex 01db4226a6..5a93f56618 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -234,6 +234,12 @@ void hook_free(void *p, const char *str);\n  */\n void hook_cache_clear(struct strmap *cache);\n \n+/**\n+ * Returns true if `name` is a recognized hook event name\n+ * (e.g. \"pre-commit\", \"post-receive\").\n+ */\n+bool is_known_hook(const char *name);\n+\n /**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540052","messageId":"20260326101819.1307742-13-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260326101819.1307742-1-adrian.ratiu@collabora.com","subject":"[PATCH v5 12/12] hook: allow hook.jobs=-1 to use all available CPU cores","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-26T10:18:19Z","receivedAt":"2026-03-26T10:21:12Z","isPatch":true,"body":"Allow -1 as a value for hook.jobs, hook.<event>.jobs, and the -j\nCLI flag to mean \"use as many jobs as there are CPU cores\", matching\nthe convention used by fetch.parallel and other Git subsystems.\n\nThe value is resolved to online_cpus() at parse time so the rest\nof the code always works with a positive resolved count.\n\nOther non-positive values (0, -2, etc) are rejected with a warning\n(config) or die (CLI).\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++-\n builtin/hook.c                 | 15 +++++++--\n hook.c                         | 60 ++++++++++++++++++++++++----------\n t/t1800-hook.sh                | 49 +++++++++++++++++++++++++++\n 4 files changed, 108 insertions(+), 20 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex e0db3afa19..a9dc0063c1 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -58,7 +58,8 @@ hook.<event>.jobs::\n \thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n \tfor this specific event. The same parallelism restrictions apply: this\n \tsetting has no effect unless all configured hooks for the event have\n-\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\t`hook.<friendly-name>.parallel` set to `true`. Set to `-1` to use the\n+\tnumber of available CPU cores. Must be a positive integer or `-1`;\n \tzero is rejected with a warning. See linkgit:git-hook[1].\n +\n Note on naming: although this key resembles `hook.<friendly-name>.*`\n@@ -74,6 +75,7 @@ valid event name when setting `hook.<event>.jobs`.\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSet to `-1` to use the number of available CPU cores.\n \tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tthey operate on shared data and cannot safely be parallelized:\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 8e47e22e2a..cceeb3586e 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -5,6 +5,7 @@\n #include \"gettext.h\"\n #include \"hook.h\"\n #include \"parse-options.h\"\n+#include \"thread-utils.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n@@ -123,6 +124,7 @@ static int run(int argc, const char **argv, const char *prefix,\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n \tint ignore_missing = 0;\n \tint allow_unknown = 0;\n+\tint jobs = 0;\n \tconst char *hook_name;\n \tstruct option run_options[] = {\n \t\tOPT_BOOL(0, \"allow-unknown-hook-name\", &allow_unknown,\n@@ -131,8 +133,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n-\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n-\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n+\t\tOPT_INTEGER('j', \"jobs\", &jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously (-1 for CPU count)\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\n@@ -141,6 +143,15 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t     builtin_hook_run_usage,\n \t\t\t     PARSE_OPT_KEEP_DASHDASH);\n \n+\tif (jobs == -1)\n+\t\topt.jobs = online_cpus();\n+\telse if (jobs < 0)\n+\t\tdie(_(\"invalid value for -j: %d\"\n+\t\t     \" (use -1 for CPU count or a\"\n+\t\t     \" positive integer)\"), jobs);\n+\telse\n+\t\topt.jobs = jobs;\n+\n \tif (!argc)\n \t\tgoto usage;\n \ndiff --git a/hook.c b/hook.c\nindex bc990d4ed4..d10eef4763 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -12,6 +12,7 @@\n #include \"setup.h\"\n #include \"strbuf.h\"\n #include \"strmap.h\"\n+#include \"thread-utils.h\"\n \n bool is_known_hook(const char *name)\n {\n@@ -165,13 +166,17 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t/* Handle plain hook.<key> entries that have no hook name component. */\n \tif (!name) {\n \t\tif (!strcmp(subkey, \"jobs\") && value) {\n-\t\t\tunsigned int v;\n-\t\t\tif (!git_parse_uint(value, &v))\n-\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n-\t\t\telse if (!v)\n-\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n-\t\t\telse\n+\t\t\tint v;\n+\t\t\tif (!git_parse_int(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be an integer, ignoring: '%s'\"), value);\n+\t\t\telse if (v == -1)\n+\t\t\t\tdata->jobs = online_cpus();\n+\t\t\telse if (v > 0)\n \t\t\t\tdata->jobs = v;\n+\t\t\telse\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer\"\n+\t\t\t\t\t  \" or -1, ignoring: '%s'\"),\n+\t\t\t\t\tvalue);\n \t\t}\n \t\treturn 0;\n \t}\n@@ -259,17 +264,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n \t} else if (!strcmp(subkey, \"jobs\")) {\n-\t\tunsigned int v;\n-\t\tif (!git_parse_uint(value, &v))\n-\t\t\twarning(_(\"hook.%s.jobs must be a positive integer,\"\n+\t\tint v;\n+\t\tif (!git_parse_int(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be an integer,\"\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n-\t\telse if (!v)\n-\t\t\twarning(_(\"hook.%s.jobs must be positive,\"\n-\t\t\t\t  \" ignoring: 0\"), hook_name);\n-\t\telse\n+\t\telse if (v == -1)\n+\t\t\tstrmap_put(&data->event_jobs, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)online_cpus());\n+\t\telse if (v > 0)\n \t\t\tstrmap_put(&data->event_jobs, hook_name,\n \t\t\t\t   (void *)(uintptr_t)v);\n+\t\telse\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive\"\n+\t\t\t\t  \" integer or -1, ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n \t}\n \n \tfree(hook_name);\n@@ -688,6 +697,25 @@ static void warn_non_parallel_hooks_override(unsigned int jobs,\n \t}\n }\n \n+/* Resolve a hook.jobs config key, handling -1 as online_cpus(). */\n+static void resolve_hook_config_jobs(struct repository *r,\n+\t\t\t\t     const char *key,\n+\t\t\t\t     unsigned int *jobs)\n+{\n+\tint v;\n+\n+\tif (repo_config_get_int(r, key, &v))\n+\t\treturn;\n+\n+\tif (v == -1)\n+\t\t*jobs = online_cpus();\n+\telse if (v > 0)\n+\t\t*jobs = v;\n+\telse\n+\t\twarning(_(\"%s must be a positive integer or -1,\"\n+\t\t\t  \" ignoring: %d\"), key, v);\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -721,14 +749,12 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\t\tif (event_jobs)\n \t\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n \t\t} else {\n-\t\t\tunsigned int event_jobs;\n \t\t\tchar *key;\n \n-\t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\t\t\tresolve_hook_config_jobs(r, \"hook.jobs\", &options->jobs);\n \n \t\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n-\t\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n-\t\t\t\toptions->jobs = event_jobs;\n+\t\t\tresolve_hook_config_jobs(r, key, &options->jobs);\n \t\t\tfree(key);\n \t\t}\n \t}\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 273588e4d4..dbd5299d92 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1058,6 +1058,55 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n \ttest_grep ! \"friendly-name\" err\n '\n \n+test_expect_success 'hook.jobs=-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\ttest_config hook.jobs -1 &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'hook.<event>.jobs=-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\ttest_config hook.test-hook.jobs -1 &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'git hook run -j-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name -j-1 test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'hook.jobs rejects values less than -1' '\n+\ttest_config hook.jobs -2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.jobs must be a positive integer or -1\" err\n+'\n+\n+test_expect_success 'hook.<event>.jobs rejects values less than -1' '\n+\ttest_config hook.test-hook.jobs -5 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.test-hook.jobs must be a positive integer or -1\" err\n+'\n+\n test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '\n \ttest_config hook.hook-1.event test-hook &&\n \ttest_config hook.hook-1.command \"echo ran\" &&\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540179","messageId":"acaYQC8d97p2qzhw@pks.im","threadId":"64919","inReplyTo":"20260326101819.1307742-8-adrian.ratiu@collabora.com","subject":"Re: [PATCH v5 07/12] hook: add -j/--jobs option to git hook run","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-27T14:46:24Z","receivedAt":"2026-03-27T14:46:36Z","isPatch":true,"body":"On Thu, Mar 26, 2026 at 12:18:14PM +0200, Adrian Ratiu wrote:\n> diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\n> index 318c637bd8..46ea52db55 100644\n> --- a/Documentation/git-hook.adoc\n> +++ b/Documentation/git-hook.adoc\n> @@ -147,6 +148,23 @@ OPTIONS\n>  \tmirroring the output style of `git config --show-scope`. Traditional\n>  \thooks from the hookdir are unaffected.\n>  \n> +-j::\n> +--jobs::\n> +\tOnly valid for `run`.\n> ++\n> +Specify how many hooks to run simultaneously. If this flag is not specified,\n> +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n> +neither is specified, defaults to 1 (serial execution).\n> ++\n> +When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel`\n> +setting, allowing all hooks for the event to run concurrently, even if they\n> +are not individually marked as parallel.\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 parallel:\n> +`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`,\n> +`post-commit`, `post-checkout`, and `push-to-checkout`.\n> +\n>  WRAPPERS\n>  --------\n>  \n\nGreat, this is now where we explicitly call out that \"-j\" overrides the\nconfiguration.\n\nPatrick\n"},{"id":"540180","messageId":"acaYS-p1muabeUqX@pks.im","threadId":"64919","inReplyTo":"20260326101819.1307742-10-adrian.ratiu@collabora.com","subject":"Re: [PATCH v5 09/12] hook: warn when hook.<friendly-name>.jobs is set","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-27T14:46:35Z","receivedAt":"2026-03-27T14:46:40Z","isPatch":true,"body":"On Thu, Mar 26, 2026 at 12:18:16PM +0200, Adrian Ratiu wrote:\n> diff --git a/hook.c b/hook.c\n> index d98b011563..0493993bbe 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -279,6 +279,44 @@ void hook_cache_clear(struct strmap *cache)\n>  \tstrmap_clear(cache, 0);\n>  }\n>  \n> +/*\n> + * Return true if `name` is a hook friendly-name, i.e. it has at least one of\n> + * .command, .event, or .parallel configured. These are the reliable clues\n> + * that distinguish a friendly-name from an event name. Note: .enabled is\n> + * deliberately excluded because it can appear under both namespaces.\n> + */\n> +static int is_friendly_name(struct hook_all_config_cb *cb, const char *name)\n> +{\n> +\tstruct hashmap_iter iter;\n> +\tstruct strmap_entry *e;\n> +\n> +\tif (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name))\n> +\t\treturn 1;\n> +\n> +\tstrmap_for_each_entry(&cb->event_hooks, &iter, e) {\n> +\t\tif (unsorted_string_list_lookup(e->value, name))\n> +\t\t\treturn 1;\n> +\t}\n> +\n> +\treturn 0;\n> +}\n> +\n> +/* Warn if any name in event_jobs is also a hook friendly-name. */\n> +static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data)\n> +{\n> +\tstruct hashmap_iter iter;\n> +\tstruct strmap_entry *e;\n> +\n> +\tstrmap_for_each_entry(&cb_data->event_jobs, &iter, e) {\n> +\t\tif (is_friendly_name(cb_data, e->key))\n> +\t\t\twarning(_(\"hook.%s.jobs is set but '%s' looks like a \"\n> +\t\t\t\t  \"hook friendly-name, not an event name; \"\n> +\t\t\t\t  \"hook.<event>.jobs uses the event name \"\n> +\t\t\t\t  \"(e.g. hook.post-receive.jobs), so this \"\n> +\t\t\t\t  \"setting will be ignored\"), e->key, e->key);\n> +\t}\n> +}\n\nMakes sense. The bigger question of course is whether we should properly\nseparate those namespaces, so that this confusion cannot even happen in\nthe first place. I won't push for such a change though.\n\nPatrick\n"},{"id":"540181","messageId":"acaYUU4FvEwWTaNu@pks.im","threadId":"64919","inReplyTo":"20260326101819.1307742-11-adrian.ratiu@collabora.com","subject":"Re: [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-03-27T14:46:41Z","receivedAt":"2026-03-27T14:46:46Z","isPatch":true,"body":"On Thu, Mar 26, 2026 at 12:18:17PM +0200, Adrian Ratiu wrote:\n> Move is_known_hook() from builtin/hook.c (static) into hook.c and\n> export it via hook.h so it can be reused.\n> \n> Make it return bool and the iterator `h` for clarity (iterate hooks).\n> \n> The next commit will use this to reject hook friendly-names that\n> collide with known event names.\n> \n> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n> ---\n>  Makefile       |  2 +-\n>  builtin/hook.c | 10 ----------\n>  hook.c         | 10 ++++++++++\n>  hook.h         |  6 ++++++\n>  4 files changed, 17 insertions(+), 11 deletions(-)\n\nThis needs to also be changed in Meson now, as we're adding\n\"hook-list.h\" as a dependency for the builtin sources, not for the\nlibgit sources. Something like the below patch.\n\nPatrick\n\ndiff --git a/meson.build b/meson.build\nindex 1b0e431d5f..2536ea80ae 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -560,6 +560,18 @@ libgit_sources += custom_target(\n   env: script_environment,\n )\n \n+libgit_sources += custom_target(\n+  input: 'Documentation/githooks.adoc',\n+  output: 'hook-list.h',\n+  command: [\n+    shell,\n+    meson.current_source_dir() + '/generate-hooklist.sh',\n+    meson.current_source_dir(),\n+    '@OUTPUT@',\n+  ],\n+  env: script_environment,\n+)\n+\n builtin_sources = [\n   'builtin/add.c',\n   'builtin/am.c',\n@@ -736,18 +748,6 @@ builtin_sources += custom_target(\n   env: script_environment,\n )\n \n-builtin_sources += custom_target(\n-  input: 'Documentation/githooks.adoc',\n-  output: 'hook-list.h',\n-  command: [\n-    shell,\n-    meson.current_source_dir() + '/generate-hooklist.sh',\n-    meson.current_source_dir(),\n-    '@OUTPUT@',\n-  ],\n-  env: script_environment,\n-)\n-\n # This contains the variables for GIT-BUILD-OPTIONS, which we use to propagate\n # build options to our tests.\n build_options_config = configuration_data()\n"},{"id":"540186","messageId":"87v7ehl0uv.fsf@gentoo.mail-host-address-is-not-set","threadId":"64919","inReplyTo":"acaYUU4FvEwWTaNu@pks.im","subject":"Re: [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-27T15:59:36Z","receivedAt":"2026-03-27T16:00:03Z","isPatch":true,"body":"On Fri, 27 Mar 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Thu, Mar 26, 2026 at 12:18:17PM +0200, Adrian Ratiu wrote:\n>> Move is_known_hook() from builtin/hook.c (static) into hook.c and\n>> export it via hook.h so it can be reused.\n>> \n>> Make it return bool and the iterator `h` for clarity (iterate hooks).\n>> \n>> The next commit will use this to reject hook friendly-names that\n>> collide with known event names.\n>> \n>> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n>> ---\n>>  Makefile       |  2 +-\n>>  builtin/hook.c | 10 ----------\n>>  hook.c         | 10 ++++++++++\n>>  hook.h         |  6 ++++++\n>>  4 files changed, 17 insertions(+), 11 deletions(-)\n>\n> This needs to also be changed in Meson now, as we're adding\n> \"hook-list.h\" as a dependency for the builtin sources, not for the\n> libgit sources. Something like the below patch.\n\nNice catch, yes, though we might need the generated .h both for the\nbuiltin and libgit sources. Will double check this.\n\nI also wonder why meson does not fail ... Likely it's because the\nbuiltins are built before libgit.\n\n(the makefile did fail, because I didn't move that dep there as well\ninitially)\n\n> diff --git a/meson.build b/meson.build\n> index 1b0e431d5f..2536ea80ae 100644\n> --- a/meson.build\n> +++ b/meson.build\n> @@ -560,6 +560,18 @@ libgit_sources += custom_target(\n>    env: script_environment,\n>  )\n>  \n> +libgit_sources += custom_target(\n> +  input: 'Documentation/githooks.adoc',\n> +  output: 'hook-list.h',\n> +  command: [\n> +    shell,\n> +    meson.current_source_dir() + '/generate-hooklist.sh',\n> +    meson.current_source_dir(),\n> +    '@OUTPUT@',\n> +  ],\n> +  env: script_environment,\n> +)\n> +\n>  builtin_sources = [\n>    'builtin/add.c',\n>    'builtin/am.c',\n> @@ -736,18 +748,6 @@ builtin_sources += custom_target(\n>    env: script_environment,\n>  )\n>  \n> -builtin_sources += custom_target(\n> -  input: 'Documentation/githooks.adoc',\n> -  output: 'hook-list.h',\n> -  command: [\n> -    shell,\n> -    meson.current_source_dir() + '/generate-hooklist.sh',\n> -    meson.current_source_dir(),\n> -    '@OUTPUT@',\n> -  ],\n> -  env: script_environment,\n> -)\n> -\n>  # This contains the variables for GIT-BUILD-OPTIONS, which we use to propagate\n>  # build options to our tests.\n>  build_options_config = configuration_data()\n\nThanks for the patch, will do something similar in the next re-roll and\ncredit you.\n\nWill wait about 1 week in case there is more feedback.\n"},{"id":"540870","messageId":"20260404082934.173788-3-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 02/12] config: add a repo_config_get_uint() helper","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:24Z","receivedAt":"2026-04-04T08:30:21Z","isPatch":true,"body":"Next commits add a 'hook.jobs' config option of type 'unsigned int',\nso add a helper to parse it since the API only supports int and ulong.\n\nAn alternative is to make 'hook.jobs' an 'int' or parse it as an 'int'\nthen cast it to unsigned, however it's better to use proper helpers for\nthe type. Using 'ulong' is another option which already has helpers, but\nit's a bit excessive in size for just the jobs number.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n config.c | 28 ++++++++++++++++++++++++++++\n config.h | 13 +++++++++++++\n parse.c  |  9 +++++++++\n parse.h  |  1 +\n 4 files changed, 51 insertions(+)\n\ndiff --git a/config.c b/config.c\nindex 156f2a24fa..a1b92fe083 100644\n--- a/config.c\n+++ b/config.c\n@@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value,\n \treturn ret;\n }\n \n+unsigned int git_config_uint(const char *name, const char *value,\n+\t\t\t     const struct key_value_info *kvi)\n+{\n+\tunsigned int ret;\n+\tif (!git_parse_uint(value, &ret))\n+\t\tdie_bad_number(name, value, kvi);\n+\treturn ret;\n+}\n+\n int64_t git_config_int64(const char *name, const char *value,\n \t\t\t const struct key_value_info *kvi)\n {\n@@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest)\n \t\treturn 1;\n }\n \n+int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest)\n+{\n+\tconst char *value;\n+\tstruct key_value_info kvi;\n+\n+\tif (!git_configset_get_value(set, key, &value, &kvi)) {\n+\t\t*dest = git_config_uint(key, value, &kvi);\n+\t\treturn 0;\n+\t} else\n+\t\treturn 1;\n+}\n+\n int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest)\n {\n \tconst char *value;\n@@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo,\n \treturn git_configset_get_int(repo->config, key, dest);\n }\n \n+int repo_config_get_uint(struct repository *repo,\n+\t\t\t const char *key, unsigned int *dest)\n+{\n+\tgit_config_check_init(repo);\n+\treturn git_configset_get_uint(repo->config, key, dest);\n+}\n+\n int repo_config_get_ulong(struct repository *repo,\n \t\t\t  const char *key, unsigned long *dest)\n {\ndiff --git a/config.h b/config.h\nindex ba426a960a..bf47fb3afc 100644\n--- a/config.h\n+++ b/config.h\n@@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *);\n int64_t git_config_int64(const char *, const char *,\n \t\t\t const struct key_value_info *);\n \n+/**\n+ * Identical to `git_config_int`, but for unsigned ints.\n+ */\n+unsigned int git_config_uint(const char *, const char *,\n+\t\t\t     const struct key_value_info *);\n+\n /**\n  * Identical to `git_config_int`, but for unsigned longs.\n  */\n@@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key,\n \n int git_configset_get_string(struct config_set *cs, const char *key, char **dest);\n int git_configset_get_int(struct config_set *cs, const char *key, int *dest);\n+int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest);\n int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest);\n int git_configset_get_bool(struct config_set *cs, const char *key, int *dest);\n int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest);\n@@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r,\n  */\n int repo_config_get_int(struct repository *r, const char *key, int *dest);\n \n+/**\n+ * Similar to `repo_config_get_int` but for unsigned ints.\n+ */\n+int repo_config_get_uint(struct repository *r,\n+\t\t\t const char *key, unsigned int *dest);\n+\n /**\n  * Similar to `repo_config_get_int` but for unsigned longs.\n  */\ndiff --git a/parse.c b/parse.c\nindex 48313571aa..d77f28046a 100644\n--- a/parse.c\n+++ b/parse.c\n@@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret)\n \treturn 1;\n }\n \n+int git_parse_uint(const char *value, unsigned int *ret)\n+{\n+\tuintmax_t tmp;\n+\tif (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int)))\n+\t\treturn 0;\n+\t*ret = tmp;\n+\treturn 1;\n+}\n+\n int git_parse_ulong(const char *value, unsigned long *ret)\n {\n \tuintmax_t tmp;\ndiff --git a/parse.h b/parse.h\nindex ea32de9a91..a6dd37c4cb 100644\n--- a/parse.h\n+++ b/parse.h\n@@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max);\n int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max);\n int git_parse_ssize_t(const char *, ssize_t *);\n int git_parse_ulong(const char *, unsigned long *);\n+int git_parse_uint(const char *value, unsigned int *ret);\n int git_parse_int(const char *value, int *ret);\n int git_parse_int64(const char *value, int64_t *ret);\n int git_parse_double(const char *value, double *ret);\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540871","messageId":"20260404082934.173788-1-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 00/12] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:22Z","receivedAt":"2026-04-04T08:30:21Z","isPatch":true,"body":"Hello everyone,\n\nThis series enables running hook commands in parallel and is now\nbased on the master branch because all prerequisites have landed.\n\nv6 is just a minor refresh which includes a meson build fix provided\nby Patrick and fixes a trivial conflict with the master branch for\nthat specific patch.\n\nBranch is pushed to GitHub: [1]\nSuccessful CI run: [2]\n\n1: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v6\n2: https://github.com/10ne1/git/actions/runs/23974475271\n\nChanges in v6:\n* Included the meson.build patch fix supplied by Patrick and added\nhim as co-author on patch 10 (Patrick)\n* Fixed a trivial code conflict on the master branch due to build\nscripts having moved into tools/ for the above patch (Adrian)\n\nRange-diff v5 -> v6:\n 1:  f5624a2a1f =  1:  b79b6d4d92 repository: fix repo_init() memleak due to missing _clear()\n 2:  8ae5eec9c9 =  2:  04d70c5fe6 config: add a repo_config_get_uint() helper\n 3:  f9278a22ea =  3:  9d52e73211 hook: parse the hook.jobs config\n 4:  7cbe7d2f7c =  4:  fa52e229e6 hook: allow parallel hook execution\n 5:  fd39771388 =  5:  255e87fe42 hook: allow pre-push parallel execution\n 6:  54c08cb72b =  6:  eb60a9effe hook: mark non-parallelizable hooks\n 7:  5957cd9c72 =  7:  44eaa1b5eb hook: add -j/--jobs option to git hook run\n 8:  a58f05e9d5 =  8:  dcd7f91d96 hook: add per-event jobs config\n 9:  4fb39ad98d =  9:  7b1513b648 hook: warn when hook.<friendly-name>.jobs is set\n10:  d3c6e8f3e2 ! 10:  1a808c35c3 hook: move is_known_hook() to hook.c for wider use\n    @@ Commit message\n     \n         Make it return bool and the iterator `h` for clarity (iterate hooks).\n     \n    +    Both meson.build and the Makefile are updated to reflect that the\n    +    header is now used by libgit, not the builtin sources.\n    +\n         The next commit will use this to reject hook friendly-names that\n         collide with known event names.\n     \n    +    Co-authored-by: Patrick Steinhardt <ps@pks.im>\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Makefile ##\n    @@ hook.h: void hook_free(void *p, const char *str);\n      /**\n       * Returns the path to the hook file, or NULL if the hook is missing\n       * or disabled. Note that this points to static storage that will be\n    +\n    + ## meson.build ##\n    +@@ meson.build: libgit_sources += custom_target(\n    +   env: script_environment,\n    + )\n    + \n    ++libgit_sources += custom_target(\n    ++  input: 'Documentation/githooks.adoc',\n    ++  output: 'hook-list.h',\n    ++  command: [\n    ++    shell,\n    ++    meson.current_source_dir() + '/tools/generate-hooklist.sh',\n    ++    meson.current_source_dir(),\n    ++    '@OUTPUT@',\n    ++  ],\n    ++  env: script_environment,\n    ++)\n    ++\n    + builtin_sources = [\n    +   'builtin/add.c',\n    +   'builtin/am.c',\n    +@@ meson.build: builtin_sources += custom_target(\n    +   env: script_environment,\n    + )\n    + \n    +-builtin_sources += custom_target(\n    +-  input: 'Documentation/githooks.adoc',\n    +-  output: 'hook-list.h',\n    +-  command: [\n    +-    shell,\n    +-    meson.current_source_dir() + '/tools/generate-hooklist.sh',\n    +-    meson.current_source_dir(),\n    +-    '@OUTPUT@',\n    +-  ],\n    +-  env: script_environment,\n    +-)\n    +-\n    + # This contains the variables for GIT-BUILD-OPTIONS, which we use to propagate\n    + # build options to our tests.\n    + build_options_config = configuration_data()\n11:  312acd15b4 = 11:  a5d37a0ea1 hook: add hook.<event>.enabled switch\n12:  f54844d13e = 12:  136cd50cea hook: allow hook.jobs=-1 to use all available CPU cores\n\nAdrian Ratiu (9):\n  repository: fix repo_init() memleak due to missing _clear()\n  config: add a repo_config_get_uint() helper\n  hook: parse the hook.jobs config\n  hook: allow pre-push parallel execution\n  hook: add per-event jobs config\n  hook: warn when hook.<friendly-name>.jobs is set\n  hook: move is_known_hook() to hook.c for wider use\n  hook: add hook.<event>.enabled switch\n  hook: allow hook.jobs=-1 to use all available CPU cores\n\nEmily Shaffer (3):\n  hook: allow parallel hook execution\n  hook: mark non-parallelizable hooks\n  hook: add -j/--jobs option to git hook run\n\n Documentation/config/hook.adoc |  76 +++++\n Documentation/git-hook.adoc    |  23 +-\n Makefile                       |   2 +-\n builtin/am.c                   |   8 +-\n builtin/checkout.c             |  19 +-\n builtin/clone.c                |   6 +-\n builtin/hook.c                 |  46 ++-\n builtin/receive-pack.c         |   3 +-\n builtin/worktree.c             |   2 +-\n commit.c                       |   2 +-\n config.c                       |  28 ++\n config.h                       |  13 +\n hook.c                         | 286 ++++++++++++++++-\n hook.h                         |  38 ++-\n meson.build                    |  24 +-\n parse.c                        |   9 +\n parse.h                        |   1 +\n repository.c                   |   3 +\n repository.h                   |  10 +\n t/t1800-hook.sh                | 546 ++++++++++++++++++++++++++++++++-\n transport.c                    |   6 +-\n 21 files changed, 1081 insertions(+), 70 deletions(-)\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540872","messageId":"20260404082934.173788-2-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 01/12] repository: fix repo_init() memleak due to missing _clear()","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:23Z","receivedAt":"2026-04-04T08:30:22Z","isPatch":true,"body":"There is an old pre-existing memory leak in repo_init() due to failing\nto call clear_repository_format() in the error case.\n\nIt went undetected because a specific bug is required to trigger it:\nenable a v1 extension in a repository with format v0. Obviously this\ncan only happen in a development environment, so it does not trigger\nin normal usage, however the memleak is real and needs fixing.\n\nFix it by also calling clear_repository_format() in the error case.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n repository.c | 1 +\n 1 file changed, 1 insertion(+)\n\ndiff --git a/repository.c b/repository.c\nindex 9e5537f539..192d6dc9c4 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -323,6 +323,7 @@ int repo_init(struct repository *repo,\n \treturn 0;\n \n error:\n+\tclear_repository_format(&format);\n \trepo_clear(repo);\n \treturn -1;\n }\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540873","messageId":"20260404082934.173788-4-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 03/12] hook: parse the hook.jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:25Z","receivedAt":"2026-04-04T08:30:22Z","isPatch":true,"body":"The hook.jobs config is a global way to set hook parallelization for\nall hooks, in the sense that it is not per-event nor per-hook.\n\nFiner-grained configs will be added in later commits which can override\nit, for e.g. via a per-event type job options. Next commits will also\nadd to this item's documentation.\n\nParse hook.jobs config key in hook_config_lookup_all() and store its\nvalue in hook_all_config_cb.jobs, then transfer it into r->jobs after\nthe config pass completes.\n\nThis is mostly plumbing and the cached value is not yet used.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 23 +++++++++++++++++++++--\n repository.h                   |  3 +++\n 3 files changed, 28 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 9e78f26439..b7847f9338 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -22,3 +22,7 @@ hook.<friendly-name>.enabled::\n \tconfiguration. This is particularly useful when a hook is defined\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n+\n+hook.jobs::\n+\tSpecifies how many hooks can be run simultaneously during parallelized\n+\thook execution. If unspecified, defaults to 1 (serial execution).\ndiff --git a/hook.c b/hook.c\nindex cc23276d27..b8cce00e57 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -123,11 +123,13 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tunsigned int jobs;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -143,6 +145,20 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n \t\treturn 0;\n \n+\t/* Handle plain hook.<key> entries that have no hook name component. */\n+\tif (!name) {\n+\t\tif (!strcmp(subkey, \"jobs\") && value) {\n+\t\t\tunsigned int v;\n+\t\t\tif (!git_parse_uint(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n+\t\t\telse if (!v)\n+\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n+\t\t\telse\n+\t\t\t\tdata->jobs = v;\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n \tif (!value)\n \t\treturn config_error_nonbool(key);\n \n@@ -240,7 +256,7 @@ void hook_cache_clear(struct strmap *cache)\n /* Populate `cache` with the complete hook configuration */\n static void build_hook_config_map(struct repository *r, struct strmap *cache)\n {\n-\tstruct hook_all_config_cb cb_data;\n+\tstruct hook_all_config_cb cb_data = { 0 };\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *e;\n \n@@ -248,7 +264,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \n-\t/* Parse all configs in one run. */\n+\t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n \t/* Construct the cache from parsed configs. */\n@@ -292,6 +308,9 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tstrmap_put(cache, e->key, hooks);\n \t}\n \n+\tif (r)\n+\t\tr->hook_jobs = cb_data.jobs;\n+\n \tstrmap_clear(&cb_data.commands, 1);\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\ndiff --git a/repository.h b/repository.h\nindex 078059a6e0..58e46853d0 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -172,6 +172,9 @@ struct repository {\n \t */\n \tstruct strmap *hook_config_cache;\n \n+\t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n+\tunsigned int hook_jobs;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540874","messageId":"20260404082934.173788-5-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 04/12] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:26Z","receivedAt":"2026-04-04T08:30:25Z","isPatch":true,"body":"From: Emily Shaffer <nasamuffin@google.com>\n\nHooks always run in sequential order due to the hardcoded jobs == 1\npassed to run_process_parallel(). Remove that hardcoding to allow\nusers to run hooks in parallel (opt-in).\n\nUsers need to decide which hooks to run in parallel, by specifying\n\"parallel = true\" in the config, because Git cannot know if their\nspecific hooks are safe to run or not in parallel (for e.g. two hooks\nmight write to the same file or call the same program).\n\nSome hooks are unsafe to run in parallel by design: these will marked\nin the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL.\n\nThe hook.jobs config specifies the default number of jobs applied to all\nhooks which have parallelism enabled.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  13 +++\n hook.c                         |  79 ++++++++++++++++--\n hook.h                         |  25 ++++++\n t/t1800-hook.sh                | 142 +++++++++++++++++++++++++++++++++\n 4 files changed, 253 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex b7847f9338..21800db648 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -23,6 +23,19 @@ hook.<friendly-name>.enabled::\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n \n+hook.<friendly-name>.parallel::\n+\tWhether the hook `hook.<friendly-name>` may run in parallel with other hooks\n+\tfor the same event. Defaults to `false`. Set to `true` only when the\n+\thook script is safe to run concurrently with other hooks for the same\n+\tevent. If any hook for an event does not have this set to `true`,\n+\tall hooks for that event run sequentially regardless of `hook.jobs`.\n+\tOnly configured (named) hooks need to declare this. Traditional hooks\n+\tfound in the hooks directory do not need to, and run in parallel when\n+\tthe effective job count is greater than 1. See linkgit:git-hook[1].\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n++\n+This setting has no effect unless all configured hooks for the event have\n+`hook.<friendly-name>.parallel` set to `true`.\ndiff --git a/hook.c b/hook.c\nindex b8cce00e57..85c0de5e47 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -116,6 +116,7 @@ struct hook_config_cache_entry {\n \tchar *command;\n \tenum config_scope scope;\n \tbool disabled;\n+\tbool parallel;\n };\n \n /*\n@@ -123,12 +124,14 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * parallel_hooks: friendly-name to parallel flag.\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tstruct strmap parallel_hooks;\n \tunsigned int jobs;\n };\n \n@@ -219,6 +222,15 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tdefault:\n \t\t\tbreak; /* ignore unrecognised values */\n \t\t}\n+\t} else if (!strcmp(subkey, \"parallel\")) {\n+\t\tint v = git_parse_maybe_bool(value);\n+\t\tif (v >= 0)\n+\t\t\tstrmap_put(&data->parallel_hooks, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)v);\n+\t\telse\n+\t\t\twarning(_(\"hook.%s.parallel must be a boolean,\"\n+\t\t\t\t  \" ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n \t}\n \n \tfree(hook_name);\n@@ -263,6 +275,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.commands);\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n+\tstrmap_init(&cb_data.parallel_hooks);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -282,6 +295,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tstruct hook_config_cache_entry *entry;\n \t\t\tchar *command;\n \n+\t\t\tbool is_par = !!strmap_get(&cb_data.parallel_hooks, hname);\n \t\t\tbool is_disabled =\n \t\t\t\t!!unsorted_string_list_lookup(\n \t\t\t\t\t&cb_data.disabled_hooks, hname);\n@@ -302,6 +316,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tentry->command = xstrdup_or_null(command);\n \t\t\tentry->scope = scope;\n \t\t\tentry->disabled = is_disabled;\n+\t\t\tentry->parallel = is_par;\n \t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n@@ -312,6 +327,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tr->hook_jobs = cb_data.jobs;\n \n \tstrmap_clear(&cb_data.commands, 1);\n+\tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstring_list_clear(e->value, 0);\n@@ -389,6 +405,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n@@ -538,21 +555,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/* Determine how many jobs to use for hook execution. */\n+static unsigned int get_hook_jobs(struct repository *r,\n+\t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  struct string_list *hook_list)\n+{\n+\t/*\n+\t * Hooks needing separate output streams must run sequentially.\n+\t * Next commit will allow parallelizing these as well.\n+\t */\n+\tif (!options->stdout_to_stderr)\n+\t\treturn 1;\n+\n+\t/*\n+\t * An explicit job count overrides everything else: this covers both\n+\t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n+\t * and the -j flag from the CLI. The CLI override is intentional: users\n+\t * may want to serialize hooks declared parallel or to parallelize more\n+\t * aggressively than the default.\n+\t */\n+\tif (options->jobs)\n+\t\treturn options->jobs;\n+\n+\t/*\n+\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n+\t * fallback to a direct config lookup (out-of-repo).\n+\t * Default to 1 (serial execution) on failure.\n+\t */\n+\toptions->jobs = 1;\n+\tif (r) {\n+\t\tif (r->gitdir && r->hook_config_cache && r->hook_jobs)\n+\t\t\toptions->jobs = r->hook_jobs;\n+\t\telse\n+\t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\t}\n+\n+\t/*\n+\t * Cap to serial any configured hook not marked as parallel = true.\n+\t * This enforces the parallel = false default, even for \"traditional\"\n+\t * hooks from the hookdir which cannot be marked parallel = true.\n+\t */\n+\tfor (size_t i = 0; i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel) {\n+\t\t\toptions->jobs = 1;\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+\n+\treturn options->jobs;\n+}\n+\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n+\tstruct string_list *hook_list = list_hooks(r, hook_name, options);\n \tstruct hook_cb_data cb_data = {\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n+\t\t.hook_command_list = hook_list,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\n \n-\t\t.processes = options->jobs,\n-\t\t.ungroup = options->jobs == 1,\n+\t\t.processes = jobs,\n+\t\t.ungroup = jobs == 1,\n \n \t\t.get_next_task = pick_next_hook,\n \t\t.start_failure = notify_start_failure,\n@@ -568,9 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->path_to_stdin && options->feed_pipe)\n \t\tBUG(\"options path_to_stdin and feed_pipe are mutually exclusive\");\n \n-\tif (!options->jobs)\n-\t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n-\n \t/*\n \t * Ensure cb_data copy and free functions are either provided together,\n \t * or neither one is provided.\n@@ -581,7 +649,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n \tif (!cb_data.hook_command_list->nr) {\n \t\tif (options->error_if_missing)\n \t\t\tret = error(\"cannot find a hook named %s\", hook_name);\ndiff --git a/hook.h b/hook.h\nindex 5c5628dd1f..ba7056f872 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -35,6 +35,13 @@ struct hook {\n \t\t} configured;\n \t} u;\n \n+\t/**\n+\t * Whether this hook may run in parallel with other hooks for the same\n+\t * event. Only useful for configured (named) hooks. Traditional hooks\n+\t * always default to 0 (serial). Set via `hook.<name>.parallel = true`.\n+\t */\n+\tbool parallel;\n+\n \t/**\n \t * Opaque data pointer used to keep internal state across callback calls.\n \t *\n@@ -72,6 +79,8 @@ struct run_hooks_opt {\n \t *\n \t * If > 1, output will be buffered and de-interleaved (ungroup=0).\n \t * If == 1, output will be real-time (ungroup=1).\n+\t * If == 0, the 'hook.jobs' config is used or, if the config is unset,\n+\t * defaults to 1 (serial execution).\n \t */\n \tunsigned int jobs;\n \n@@ -152,7 +161,23 @@ struct run_hooks_opt {\n \thook_data_free_fn feed_pipe_cb_data_free;\n };\n \n+/**\n+ * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to\n+ * the 'hook.jobs' config, falling back to serial (1) if unset.\n+ */\n #define RUN_HOOKS_OPT_INIT { \\\n+\t.env = STRVEC_INIT, \\\n+\t.args = STRVEC_INIT, \\\n+\t.stdout_to_stderr = 1, \\\n+\t.jobs = 0, \\\n+}\n+\n+/**\n+ * Initializer for hooks that must always run sequentially regardless of\n+ * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized\n+ * .jobs = 1 is non-overridable.\n+ */\n+#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \\\n \t.env = STRVEC_INIT, \\\n \t.args = STRVEC_INIT, \\\n \t.stdout_to_stderr = 1, \\\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 96749fc06d..3774a6c2e1 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -21,6 +21,57 @@ setup_hookdir () {\n \ttest_when_finished rm -rf .git/hooks\n }\n \n+# write_sentinel_hook <path> [sentinel]\n+#\n+# Writes a hook that marks itself as started, sleeps for a few seconds, then\n+# marks itself done. The sleep must be long enough that sentinel_detector can\n+# observe <sentinel>.started before <sentinel>.done appears when both hooks\n+# run concurrently in parallel mode.\n+write_sentinel_hook () {\n+\tsentinel=\"${2:-sentinel}\"\n+\twrite_script \"$1\" <<-EOF\n+\ttouch ${sentinel}.started &&\n+\tsleep 2 &&\n+\ttouch ${sentinel}.done\n+\tEOF\n+}\n+\n+# sentinel_detector <sentinel> <output>\n+#\n+# Returns a shell command string suitable for use as hook.<name>.command.\n+# The detector must be registered after the sentinel:\n+# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)\n+#    before the detector starts.\n+# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared\n+#    yet and the detector just sees <sentinel>.started.\n+#\n+# At start, poll until <sentinel>.started exists to absorb startup jitter, then\n+# write to <output>:\n+# 1. 'serial'   if <sentinel>.done exists (sentinel finished before we started),\n+# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),\n+# 3. 'timeout'  if <sentinel>.started never appeared.\n+#\n+# The command ends with ':' so when git appends \"$@\" for hooks that receive\n+# positional arguments (e.g. pre-push), the result ': \"$@\"' is valid shell\n+# rather than a syntax error 'fi \"$@\"'.\n+sentinel_detector () {\n+\tcat <<-EOF\n+\ti=0\n+\twhile ! test -f ${1}.started && test \\$i -lt 10; do\n+\t    sleep 1\n+\t    i=\\$((i+1))\n+\tdone\n+\tif test -f ${1}.done; then\n+\t    echo serial >${2}\n+\telif test -f ${1}.started; then\n+\t    echo parallel >${2}\n+\telse\n+\t    echo timeout >${2}\n+\tfi\n+\t:\n+\tEOF\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n@@ -658,4 +709,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'hook.jobs=1 config runs hooks in series' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\n+\t# Use two configured hooks so the execution order is deterministic:\n+\t# hook-1 (sentinel) is listed before hook-2 (detector), so hook-1\n+\t# always runs first even in serial mode.\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 1 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=true enables parallel execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'one non-parallel hook forces the whole event to run serially' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 has no parallel=true: should force serial for all\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540875","messageId":"20260404082934.173788-6-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 05/12] hook: allow pre-push parallel execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:27Z","receivedAt":"2026-04-04T08:30:26Z","isPatch":true,"body":"pre-push is the only hook that keeps stdout and stderr separate (for\nbackwards compatibility with git-lfs and potentially other users). This\nprevents parallelizing it because run-command needs stdout_to_stderr=1\nto buffer and de-interleave parallel outputs.\n\nSince we now default to jobs=1, backwards compatibility is maintained\nwithout needing any extension or extra config: when no parallelism is\nrequested, pre-push behaves exactly as before.\n\nWhen the user explicitly opts into parallelism via hook.jobs > 1,\nhook.<event>.jobs > 1, or -jN, they accept the changed output behavior.\n\nDocument this and let get_hook_jobs() set stdout_to_stderr=1 automatically\nwhen jobs > 1, removing the need for any extension infrastructure.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 24 ++++++++++++++++--------\n hook.h                         |  6 ++++--\n t/t1800-hook.sh                | 32 ++++++++++++++++++++++++++++++++\n transport.c                    |  6 ++++--\n 5 files changed, 60 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 21800db648..94c7a9808e 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -39,3 +39,7 @@ hook.jobs::\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<friendly-name>.parallel` set to `true`.\n++\n+For `pre-push` hooks, which normally keep stdout and stderr separate,\n+setting this to a value greater than 1 (or passing `-j`) will merge stdout\n+into stderr to allow correct de-interleaving of parallel output.\ndiff --git a/hook.c b/hook.c\nindex 85c0de5e47..25762b6c8d 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -555,18 +555,24 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/*\n+ * When running in parallel, stdout must be merged into stderr so\n+ * run-command can buffer and de-interleave outputs correctly. This\n+ * applies even to hooks like pre-push that normally keep stdout and\n+ * stderr separate: the user has opted into parallelism, so the output\n+ * stream behavior changes accordingly.\n+ */\n+static void merge_output_if_parallel(struct run_hooks_opt *options)\n+{\n+\tif (options->jobs > 1)\n+\t\toptions->stdout_to_stderr = 1;\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n \t\t\t\t  struct string_list *hook_list)\n {\n-\t/*\n-\t * Hooks needing separate output streams must run sequentially.\n-\t * Next commit will allow parallelizing these as well.\n-\t */\n-\tif (!options->stdout_to_stderr)\n-\t\treturn 1;\n-\n \t/*\n \t * An explicit job count overrides everything else: this covers both\n \t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n@@ -575,7 +581,7 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t * aggressively than the default.\n \t */\n \tif (options->jobs)\n-\t\treturn options->jobs;\n+\t\tgoto cleanup;\n \n \t/*\n \t * Use hook.jobs from the already-parsed config cache (in-repo), or\n@@ -603,6 +609,8 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\t}\n \t}\n \n+cleanup:\n+\tmerge_output_if_parallel(options);\n \treturn options->jobs;\n }\n \ndiff --git a/hook.h b/hook.h\nindex ba7056f872..01db4226a6 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -106,8 +106,10 @@ struct run_hooks_opt {\n \t * Send the hook's stdout to stderr.\n \t *\n \t * This is the default behavior for all hooks except pre-push,\n-\t * which has separate stdout and stderr streams for backwards\n-\t * compatibility reasons.\n+\t * which keeps stdout and stderr separate for backwards compatibility.\n+\t * When parallel execution is requested (jobs > 1), get_hook_jobs()\n+\t * overrides this to 1 for all hooks so run-command can de-interleave\n+\t * their outputs correctly.\n \t */\n \tunsigned int stdout_to_stderr:1;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 3774a6c2e1..9476a97ca5 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -800,4 +800,36 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'client hooks: pre-push parallel execution merges stdout to stderr' '\n+\ttest_when_finished \"rm -rf remote-par stdout.actual stderr.actual\" &&\n+\tgit init --bare remote-par &&\n+\tgit remote add origin-par remote-par &&\n+\ttest_commit par-commit &&\n+\tmkdir -p .git/hooks &&\n+\tsetup_hooks pre-push &&\n+\ttest_config hook.jobs 2 &&\n+\tgit push origin-par HEAD:main >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push\n+'\n+\n+test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' '\n+\ttest_when_finished \"rm -rf repo-parallel remote-parallel\" &&\n+\tgit init --bare remote-parallel &&\n+\tgit init repo-parallel &&\n+\tgit -C repo-parallel remote add origin ../remote-parallel &&\n+\ttest_commit -C repo-parallel A &&\n+\n+\twrite_sentinel_hook repo-parallel/.git/hooks/pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.event pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\tgit -C repo-parallel config hook.hook-2.parallel true &&\n+\n+\tgit -C repo-parallel config hook.jobs 2 &&\n+\n+\tgit -C repo-parallel push origin HEAD >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect repo-parallel/hook.order\n+'\n+\n test_done\ndiff --git a/transport.c b/transport.c\nindex e53936d87b..9406ec4f2d 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1391,8 +1391,10 @@ static int run_pre_push_hook(struct transport *transport,\n \topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n \n \t/*\n-\t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n-\t * them to keep backwards compatibility with existing hooks.\n+\t * pre-push hooks keep stdout and stderr separate by default for\n+\t * backwards compatibility. When the user opts into parallel execution\n+\t * via hook.jobs > 1 or -j, get_hook_jobs() will set stdout_to_stderr=1\n+\t * automatically so run-command can de-interleave the outputs.\n \t */\n \topt.stdout_to_stderr = 0;\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540876","messageId":"20260404082934.173788-7-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 06/12] hook: mark non-parallelizable hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:28Z","receivedAt":"2026-04-04T08:30:27Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSeveral hooks are known to be inherently non-parallelizable, so initialize\nthem with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides\nany hook.jobs or runtime -j flags.\n\nThese hooks are:\napplypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit,\npost-checkout, and push-to-checkout.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 14 ++++++++++++++\n builtin/am.c                   |  8 +++++---\n builtin/checkout.c             | 19 +++++++++++++------\n builtin/clone.c                |  6 ++++--\n builtin/receive-pack.c         |  3 ++-\n builtin/worktree.c             |  2 +-\n commit.c                       |  2 +-\n t/t1800-hook.sh                | 16 ++++++++++++++++\n 8 files changed, 56 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 94c7a9808e..6f60775c28 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -36,6 +36,20 @@ hook.<friendly-name>.parallel::\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSome hooks always run sequentially regardless of this setting because\n+\tthey operate on shared data and cannot safely be parallelized:\n++\n+--\n+`applypatch-msg`;;\n+`prepare-commit-msg`;;\n+`commit-msg`;;\n+\tReceive a commit message file and may rewrite it in place.\n+`pre-commit`;;\n+`post-checkout`;;\n+`push-to-checkout`;;\n+`post-commit`;;\n+\tAccess the working tree, index, or repository state.\n+--\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<friendly-name>.parallel` set to `true`.\ndiff --git a/builtin/am.c b/builtin/am.c\nindex fe6e087eee..e9623b8307 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state)\n \n \tassert(state->msg);\n \n-\tif (!state->no_verify)\n-\t\tret = run_hooks_l(the_repository, \"applypatch-msg\",\n-\t\t\t\t  am_path(state, \"final-commit\"), NULL);\n+\tif (!state->no_verify) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\t\tstrvec_push(&opt.args, am_path(state, \"final-commit\"));\n+\t\tret = run_hooks_opt(the_repository, \"applypatch-msg\", &opt);\n+\t}\n \n \tif (!ret) {\n \t\tFREE_AND_NULL(state->msg);\ndiff --git a/builtin/checkout.c b/builtin/checkout.c\nindex e031e61886..ac0186a33e 100644\n--- a/builtin/checkout.c\n+++ b/builtin/checkout.c\n@@ -31,6 +31,7 @@\n #include \"resolve-undo.h\"\n #include \"revision.h\"\n #include \"setup.h\"\n+#include \"strvec.h\"\n #include \"submodule.h\"\n #include \"symlinks.h\"\n #include \"trace2.h\"\n@@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info)\n static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit,\n \t\t\t      int changed)\n {\n-\treturn run_hooks_l(the_repository, \"post-checkout\",\n-\t\t\t   oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   changed ? \"1\" : \"0\", NULL);\n-\t/* \"new_commit\" can be NULL when checking out from the index before\n-\t   a commit exists. */\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n+\t/*\n+\t * \"new_commit\" can be NULL when checking out from the index before\n+\t * a commit exists.\n+\t */\n+\tstrvec_pushl(&opt.args,\n+\t\t     oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     changed ? \"1\" : \"0\",\n+\t\t     NULL);\n+\n+\treturn run_hooks_opt(the_repository, \"post-checkout\", &opt);\n }\n \n static int update_some(const struct object_id *oid, struct strbuf *base,\ndiff --git a/builtin/clone.c b/builtin/clone.c\nindex fba3c9c508..d23b0cafcf 100644\n--- a/builtin/clone.c\n+++ b/builtin/clone.c\n@@ -647,6 +647,7 @@ static int checkout(int submodule_progress,\n \tstruct tree *tree;\n \tstruct tree_desc t;\n \tint err = 0;\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \tif (option_no_checkout)\n \t\treturn 0;\n@@ -697,8 +698,9 @@ static int checkout(int submodule_progress,\n \tif (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK))\n \t\tdie(_(\"unable to write new index file\"));\n \n-\terr |= run_hooks_l(the_repository, \"post-checkout\", oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(&oid), \"1\", NULL);\n+\tstrvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(&oid), \"1\", NULL);\n+\terr |= run_hooks_opt(the_repository, \"post-checkout\", &hook_opt);\n \n \tif (!err && (option_recurse_submodules.nr > 0)) {\n \t\tstruct child_process cmd = CHILD_PROCESS_INIT;\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex cb3656a034..1d9eb2e1a6 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1464,7 +1464,8 @@ static const char *push_to_checkout(unsigned char *hash,\n \t\t\t\t    struct strvec *env,\n \t\t\t\t    const char *work_tree)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n \topt.invoked_hook = invoked_hook;\n \n \tstrvec_pushf(env, \"GIT_WORK_TREE=%s\", absolute_path(work_tree));\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex 4fd6f7575f..d21c43fde3 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname,\n \t * is_junk is cleared, but do return appropriate code when hook fails.\n \t */\n \tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n \t\tstrvec_pushl(&opt.args,\ndiff --git a/commit.c b/commit.c\nindex 80d8d07875..4385ae4329 100644\n--- a/commit.c\n+++ b/commit.c\n@@ -1970,7 +1970,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len)\n int run_commit_hook(int editor_is_used, const char *index_file,\n \t\t    int *invoked_hook, const char *name, ...)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \tva_list args;\n \tconst char *arg;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 9476a97ca5..6e8b1ad588 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -832,4 +832,20 @@ test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1'\n \ttest_cmp expect repo-parallel/hook.order\n '\n \n+test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event pre-commit &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event pre-commit &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.jobs 2 &&\n+\tgit commit --allow-empty -m \"test: verify force-serial on pre-commit\" &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540877","messageId":"20260404082934.173788-8-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 07/12] hook: add -j/--jobs option to git hook run","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:29Z","receivedAt":"2026-04-04T08:30:28Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nExpose the parallel job count as a command-line flag so callers can\nrequest parallelism without relying only on the hook.jobs config.\n\nAdd tests covering serial/parallel execution and TTY behaviour under\n-j1 vs -jN.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  23 +++++-\n builtin/hook.c              |   5 +-\n hook.c                      |  17 +++++\n t/t1800-hook.sh             | 135 ++++++++++++++++++++++++++++++++++--\n 4 files changed, 170 insertions(+), 10 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 318c637bd8..46ea52db55 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -8,7 +8,8 @@ git-hook - Run git hooks\n SYNOPSIS\n --------\n [verse]\n-'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n+\t<hook-name> [-- <hook-args>]\n 'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\n \n DESCRIPTION\n@@ -147,6 +148,23 @@ OPTIONS\n \tmirroring the output style of `git config --show-scope`. Traditional\n \thooks from the hookdir are unaffected.\n \n+-j::\n+--jobs::\n+\tOnly valid for `run`.\n++\n+Specify how many hooks to run simultaneously. If this flag is not specified,\n+the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n+neither is specified, defaults to 1 (serial execution).\n++\n+When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel`\n+setting, allowing all hooks for the event to run concurrently, even if they\n+are not individually marked as parallel.\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 parallel:\n+`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`,\n+`post-commit`, `post-checkout`, and `push-to-checkout`.\n+\n WRAPPERS\n --------\n \n@@ -169,7 +187,8 @@ running:\n git hook run --allow-unknown-hook-name mywrapper-start-tests \\\n   # providing something to stdin\n   --stdin some-tempfile-123 \\\n-  # execute hooks in serial\n+  # execute multiple hooks in parallel\n+  --jobs 3 \\\n   # plus some arguments of your own...\n   -- \\\n   --testname bar \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex c0585587e5..bea0668b47 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -8,7 +8,8 @@\n #include \"parse-options.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n-\tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+\tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n+\t   \"<hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\")\n \n@@ -132,6 +133,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n+\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\ndiff --git a/hook.c b/hook.c\nindex 25762b6c8d..c0b71322cf 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -568,6 +568,22 @@ static void merge_output_if_parallel(struct run_hooks_opt *options)\n \t\toptions->stdout_to_stderr = 1;\n }\n \n+static void warn_non_parallel_hooks_override(unsigned int jobs,\n+\t\t\t\t\t     struct string_list *hook_list)\n+{\n+\t/* Don't warn for hooks running sequentially. */\n+\tif (jobs == 1)\n+\t\treturn;\n+\n+\tfor (size_t i = 0; i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n+\t\t\twarning(_(\"hook '%s' is not marked as parallel=true, \"\n+\t\t\t\t  \"running in parallel anyway due to -j%u\"),\n+\t\t\t\th->u.configured.friendly_name, jobs);\n+\t}\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -611,6 +627,7 @@ static unsigned int get_hook_jobs(struct repository *r,\n \n cleanup:\n \tmerge_output_if_parallel(options);\n+\twarn_non_parallel_hooks_override(options->jobs, hook_list);\n \treturn options->jobs;\n }\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 6e8b1ad588..a1734fd628 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -268,10 +268,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n '\n \n test_hook_tty () {\n-\tcat >expect <<-\\EOF\n-\tSTDOUT TTY\n-\tSTDERR TTY\n-\tEOF\n+\texpect_tty=$1\n+\tshift\n+\n+\tif test \"$expect_tty\" != \"no_tty\"; then\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT TTY\n+\t\tSTDERR TTY\n+\t\tEOF\n+\telse\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT NO TTY\n+\t\tSTDERR NO TTY\n+\t\tEOF\n+\tfi\n \n \ttest_when_finished \"rm -rf repo\" &&\n \tgit init repo &&\n@@ -289,12 +299,21 @@ test_hook_tty () {\n \ttest_cmp expect repo/actual\n }\n \n-test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty hook run pre-commit\n+test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '\n+\t# hooks running sequentially (-j1) are always connected to the tty for\n+\t# optimum real-time performance.\n+\ttest_hook_tty tty hook run -j1 pre-commit\n+'\n+\n+test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '\n+\t# Hooks are not connected to the tty when run in parallel, instead they\n+\t# output to a pipe through which run-command collects and de-interlaces\n+\t# their outputs, which then gets passed either to the tty or a sideband.\n+\ttest_hook_tty no_tty hook run -j2 pre-commit\n '\n \n test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty commit -m\"B.new\"\n+\ttest_hook_tty tty commit -m\"B.new\"\n '\n \n test_expect_success 'git hook list orders by config order' '\n@@ -709,6 +728,108 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'parallel hook output is not interleaved' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo \"Hook 1 Start\"\n+\tsleep 1\n+\techo \"Hook 1 End\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t\t    \"echo \\\"Hook 2 Start\\\"; sleep 2; echo \\\"Hook 2 End\\\"\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.hook-3.event test-hook &&\n+\ttest_config hook.hook-3.command \\\n+\t\t    \"echo \\\"Hook 3 Start\\\"; sleep 3; echo \\\"Hook 3 End\\\"\" &&\n+\ttest_config hook.hook-3.parallel true &&\n+\n+\tgit hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel &&\n+\n+\t# Verify Hook 1 output is grouped\n+\tsed -n \"/Hook 1 Start/,/Hook 1 End/p\" err.parallel >hook1_out &&\n+\ttest_line_count = 2 hook1_out &&\n+\n+\t# Verify Hook 2 output is grouped\n+\tsed -n \"/Hook 2 Start/,/Hook 2 End/p\" err.parallel >hook2_out &&\n+\ttest_line_count = 2 hook2_out &&\n+\n+\t# Verify Hook 3 output is grouped\n+\tsed -n \"/Hook 3 Start/,/Hook 3 End/p\" err.parallel >hook3_out &&\n+\ttest_line_count = 2 hook3_out\n+'\n+\n+test_expect_success 'git hook run -j1 runs hooks in series' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\ttest_config hook.series-1.event \"test-hook\" &&\n+\ttest_config hook.series-1.command \"echo 1\" --add &&\n+\ttest_config hook.series-2.event \"test-hook\" &&\n+\ttest_config hook.series-2.command \"echo 2\" --add &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo 3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tgit hook run --allow-unknown-hook-name -j1 test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook run -j2 runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 overrides parallel=false' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# -j2 overrides parallel=false; hooks run in parallel with a warning.\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"true\" &&\n+\t# neither hook has parallel=true\n+\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\tgrep \"hook .hook-1. is not marked as parallel=true\" err &&\n+\tgrep \"hook .hook-2. is not marked as parallel=true\" err\n+'\n+\n test_expect_success 'hook.jobs=1 config runs hooks in series' '\n \ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540879","messageId":"20260404082934.173788-9-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 08/12] hook: add per-event jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:30Z","receivedAt":"2026-04-04T08:30:31Z","isPatch":true,"body":"Add a hook.<event>.jobs count config that allows users to override the\nglobal hook.jobs setting for specific hook events.\n\nThis allows finer-grained control over parallelism on a per-event basis.\n\nFor example, to run `post-receive` hooks with up to 4 parallel jobs\nwhile keeping other events at their global default:\n\n[hook]\n    post-receive.jobs = 4\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 19 +++++++++++\n hook.c                         | 46 +++++++++++++++++++++++---\n repository.c                   |  1 +\n repository.h                   |  3 ++\n t/t1800-hook.sh                | 59 ++++++++++++++++++++++++++++++++++\n 5 files changed, 123 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 6f60775c28..d4fa29d936 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -33,9 +33,28 @@ hook.<friendly-name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.jobs::\n+\tSpecifies how many hooks can be run simultaneously for the `<event>`\n+\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n+\tfor this specific event. The same parallelism restrictions apply: this\n+\tsetting has no effect unless all configured hooks for the event have\n+\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\tzero is rejected with a warning. See linkgit:git-hook[1].\n++\n+Note on naming: although this key resembles `hook.<friendly-name>.*`\n+(a per-hook setting), `<event>` must be the event name, not a hook\n+friendly name. The key component is stored literally and looked up by\n+event name at runtime with no translation between the two namespaces.\n+A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n+lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n+`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n+registered for that event. Use `hook.post-receive.jobs` or any other\n+valid event name when setting `hook.<event>.jobs`.\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tthey operate on shared data and cannot safely be parallelized:\n +\ndiff --git a/hook.c b/hook.c\nindex c0b71322cf..d98b011563 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -125,6 +125,7 @@ struct hook_config_cache_entry {\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n  * parallel_hooks: friendly-name to parallel flag.\n+ * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n@@ -132,6 +133,7 @@ struct hook_all_config_cb {\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n \tstruct strmap parallel_hooks;\n+\tstruct strmap event_jobs;\n \tunsigned int jobs;\n };\n \n@@ -231,6 +233,18 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\twarning(_(\"hook.%s.parallel must be a boolean,\"\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n+\t} else if (!strcmp(subkey, \"jobs\")) {\n+\t\tunsigned int v;\n+\t\tif (!git_parse_uint(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive integer,\"\n+\t\t\t\t  \" ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n+\t\telse if (!v)\n+\t\t\twarning(_(\"hook.%s.jobs must be positive,\"\n+\t\t\t\t  \" ignoring: 0\"), hook_name);\n+\t\telse\n+\t\t\tstrmap_put(&data->event_jobs, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)v);\n \t}\n \n \tfree(hook_name);\n@@ -276,6 +290,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \tstrmap_init(&cb_data.parallel_hooks);\n+\tstrmap_init(&cb_data.event_jobs);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -323,8 +338,10 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tstrmap_put(cache, e->key, hooks);\n \t}\n \n-\tif (r)\n+\tif (r) {\n \t\tr->hook_jobs = cb_data.jobs;\n+\t\tr->event_jobs = cb_data.event_jobs;\n+\t}\n \n \tstrmap_clear(&cb_data.commands, 1);\n \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n@@ -587,6 +604,7 @@ static void warn_non_parallel_hooks_override(unsigned int jobs,\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  const char *hook_name,\n \t\t\t\t  struct string_list *hook_list)\n {\n \t/*\n@@ -606,16 +624,34 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t */\n \toptions->jobs = 1;\n \tif (r) {\n-\t\tif (r->gitdir && r->hook_config_cache && r->hook_jobs)\n-\t\t\toptions->jobs = r->hook_jobs;\n-\t\telse\n+\t\tif (r->gitdir && r->hook_config_cache) {\n+\t\t\tvoid *event_jobs;\n+\n+\t\t\tif (r->hook_jobs)\n+\t\t\t\toptions->jobs = r->hook_jobs;\n+\n+\t\t\tevent_jobs = strmap_get(&r->event_jobs, hook_name);\n+\t\t\tif (event_jobs)\n+\t\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n+\t\t} else {\n+\t\t\tunsigned int event_jobs;\n+\t\t\tchar *key;\n+\n \t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\n+\t\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n+\t\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n+\t\t\t\toptions->jobs = event_jobs;\n+\t\t\tfree(key);\n+\t\t}\n \t}\n \n \t/*\n \t * Cap to serial any configured hook not marked as parallel = true.\n \t * This enforces the parallel = false default, even for \"traditional\"\n \t * hooks from the hookdir which cannot be marked parallel = true.\n+\t * The same restriction applies whether jobs came from hook.jobs or\n+\t * hook.<event>.jobs.\n \t */\n \tfor (size_t i = 0; i < hook_list->nr; i++) {\n \t\tstruct hook *h = hook_list->items[i].util;\n@@ -642,7 +678,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n-\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\ndiff --git a/repository.c b/repository.c\nindex 192d6dc9c4..4030db4460 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -426,6 +426,7 @@ void repo_clear(struct repository *repo)\n \t\thook_cache_clear(repo->hook_config_cache);\n \t\tFREE_AND_NULL(repo->hook_config_cache);\n \t}\n+\tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n \n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 58e46853d0..6b67ec02e2 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -175,6 +175,9 @@ struct repository {\n \t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n \tunsigned int hook_jobs;\n \n+\t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n+\tstruct strmap event_jobs;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex a1734fd628..ab2b52bec6 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -969,4 +969,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=1 (serial), but per-event override allows parallel.\n+\ttest_config hook.jobs 1 &&\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=4 allows parallel, but per-event override forces serial.\n+\ttest_config hook.jobs 4 &&\n+\ttest_config hook.test-hook.jobs 1 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# Per-event jobs=2 but no hook has parallel=true: must still run serially.\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540878","messageId":"20260404082934.173788-10-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 09/12] hook: warn when hook.<friendly-name>.jobs is set","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:31Z","receivedAt":"2026-04-04T08:30:32Z","isPatch":true,"body":"Issue a warning when the user confuses the hook process and event\nnamespaces by setting hook.<friendly-name>.jobs.\n\nDetect this by checking whether the name carrying .jobs also has\n.command, .event, or .parallel configured.  Extract is_friendly_name()\nas a helper for this check, to be reused by future per-event config\nhandling.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n hook.c          | 40 ++++++++++++++++++++++++++++++++++++++++\n t/t1800-hook.sh | 30 ++++++++++++++++++++++++++++++\n 2 files changed, 70 insertions(+)\n\ndiff --git a/hook.c b/hook.c\nindex d98b011563..0493993bbe 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -279,6 +279,44 @@ void hook_cache_clear(struct strmap *cache)\n \tstrmap_clear(cache, 0);\n }\n \n+/*\n+ * Return true if `name` is a hook friendly-name, i.e. it has at least one of\n+ * .command, .event, or .parallel configured. These are the reliable clues\n+ * that distinguish a friendly-name from an event name. Note: .enabled is\n+ * deliberately excluded because it can appear under both namespaces.\n+ */\n+static int is_friendly_name(struct hook_all_config_cb *cb, const char *name)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tif (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name))\n+\t\treturn 1;\n+\n+\tstrmap_for_each_entry(&cb->event_hooks, &iter, e) {\n+\t\tif (unsorted_string_list_lookup(e->value, name))\n+\t\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+/* Warn if any name in event_jobs is also a hook friendly-name. */\n+static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tstrmap_for_each_entry(&cb_data->event_jobs, &iter, e) {\n+\t\tif (is_friendly_name(cb_data, e->key))\n+\t\t\twarning(_(\"hook.%s.jobs is set but '%s' looks like a \"\n+\t\t\t\t  \"hook friendly-name, not an event name; \"\n+\t\t\t\t  \"hook.<event>.jobs uses the event name \"\n+\t\t\t\t  \"(e.g. hook.post-receive.jobs), so this \"\n+\t\t\t\t  \"setting will be ignored\"), e->key, e->key);\n+\t}\n+}\n+\n /* Populate `cache` with the complete hook configuration */\n static void build_hook_config_map(struct repository *r, struct strmap *cache)\n {\n@@ -295,6 +333,8 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n+\twarn_jobs_on_friendly_names(&cb_data);\n+\n \t/* Construct the cache from parsed configs. */\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstruct string_list *hook_names = e->value;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex ab2b52bec6..85b055a897 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1028,4 +1028,34 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' '\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .event' '\n+\ttest_config hook.my-hook.event test-hook &&\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .parallel' '\n+\ttest_config hook.my-hook.event test-hook &&\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.parallel true &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n+\ttest_config hook.test-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep ! \"friendly-name\" err\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540880","messageId":"20260404082934.173788-11-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 10/12] hook: move is_known_hook() to hook.c for wider use","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:32Z","receivedAt":"2026-04-04T08:30:34Z","isPatch":true,"body":"Move is_known_hook() from builtin/hook.c (static) into hook.c and\nexport it via hook.h so it can be reused.\n\nMake it return bool and the iterator `h` for clarity (iterate hooks).\n\nBoth meson.build and the Makefile are updated to reflect that the\nheader is now used by libgit, not the builtin sources.\n\nThe next commit will use this to reject hook friendly-names that\ncollide with known event names.\n\nCo-authored-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Makefile       |  2 +-\n builtin/hook.c | 10 ----------\n hook.c         | 10 ++++++++++\n hook.h         |  6 ++++++\n meson.build    | 24 ++++++++++++------------\n 5 files changed, 29 insertions(+), 23 deletions(-)\n\ndiff --git a/Makefile b/Makefile\nindex 5d22394c2e..c4e83823e4 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -2675,7 +2675,7 @@ git$X: git.o GIT-LDFLAGS $(BUILTIN_OBJS) $(GITLIBS)\n \n help.sp help.s help.o: command-list.h\n builtin/bugreport.sp builtin/bugreport.s builtin/bugreport.o: hook-list.h\n-builtin/hook.sp builtin/hook.s builtin/hook.o: hook-list.h\n+hook.sp hook.s hook.o: hook-list.h\n \n builtin/help.sp builtin/help.s builtin/help.o: config-list.h GIT-PREFIX\n builtin/help.sp builtin/help.s builtin/help.o: EXTRA_CPPFLAGS = \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex bea0668b47..1839412dca 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -4,7 +4,6 @@\n #include \"environment.h\"\n #include \"gettext.h\"\n #include \"hook.h\"\n-#include \"hook-list.h\"\n #include \"parse-options.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n@@ -13,15 +12,6 @@\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\")\n \n-static int is_known_hook(const char *name)\n-{\n-\tconst char **p;\n-\tfor (p = hook_name_list; *p; p++)\n-\t\tif (!strcmp(*p, name))\n-\t\t\treturn 1;\n-\treturn 0;\n-}\n-\n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n \tBUILTIN_HOOK_LIST_USAGE,\ndiff --git a/hook.c b/hook.c\nindex 0493993bbe..19076f8f2b 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -5,6 +5,7 @@\n #include \"environment.h\"\n #include \"gettext.h\"\n #include \"hook.h\"\n+#include \"hook-list.h\"\n #include \"parse.h\"\n #include \"path.h\"\n #include \"run-command.h\"\n@@ -12,6 +13,15 @@\n #include \"strbuf.h\"\n #include \"strmap.h\"\n \n+bool is_known_hook(const char *name)\n+{\n+\tconst char **h;\n+\tfor (h = hook_name_list; *h; h++)\n+\t\tif (!strcmp(*h, name))\n+\t\t\treturn true;\n+\treturn false;\n+}\n+\n const char *find_hook(struct repository *r, const char *name)\n {\n \tstatic struct strbuf path = STRBUF_INIT;\ndiff --git a/hook.h b/hook.h\nindex 01db4226a6..5a93f56618 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -234,6 +234,12 @@ void hook_free(void *p, const char *str);\n  */\n void hook_cache_clear(struct strmap *cache);\n \n+/**\n+ * Returns true if `name` is a recognized hook event name\n+ * (e.g. \"pre-commit\", \"post-receive\").\n+ */\n+bool is_known_hook(const char *name);\n+\n /**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\ndiff --git a/meson.build b/meson.build\nindex 8309942d18..f438d5545d 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -563,6 +563,18 @@ libgit_sources += custom_target(\n   env: script_environment,\n )\n \n+libgit_sources += custom_target(\n+  input: 'Documentation/githooks.adoc',\n+  output: 'hook-list.h',\n+  command: [\n+    shell,\n+    meson.current_source_dir() + '/tools/generate-hooklist.sh',\n+    meson.current_source_dir(),\n+    '@OUTPUT@',\n+  ],\n+  env: script_environment,\n+)\n+\n builtin_sources = [\n   'builtin/add.c',\n   'builtin/am.c',\n@@ -739,18 +751,6 @@ builtin_sources += custom_target(\n   env: script_environment,\n )\n \n-builtin_sources += custom_target(\n-  input: 'Documentation/githooks.adoc',\n-  output: 'hook-list.h',\n-  command: [\n-    shell,\n-    meson.current_source_dir() + '/tools/generate-hooklist.sh',\n-    meson.current_source_dir(),\n-    '@OUTPUT@',\n-  ],\n-  env: script_environment,\n-)\n-\n # This contains the variables for GIT-BUILD-OPTIONS, which we use to propagate\n # build options to our tests.\n build_options_config = configuration_data()\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540881","messageId":"20260404082934.173788-12-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 11/12] hook: add hook.<event>.enabled switch","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:33Z","receivedAt":"2026-04-04T08:30:37Z","isPatch":true,"body":"Add a hook.<event>.enabled config key that disables all hooks for\na given event, when set to false, acting as a high-level switch\nabove the existing per-hook hook.<friendly-name>.enabled.\n\nEvent-disabled hooks are shown in \"git hook list\" with an\n\"event-disabled\" tab-separated prefix before the name:\n\n$ git hook list test-hook\nevent-disabled  hook-1\nevent-disabled  hook-2\n\nWith --show-scope:\n\n$ git hook list --show-scope test-hook\nlocal   event-disabled  hook-1\n\nWhen a hook is both per-hook disabled and event-disabled, only\n\"event-disabled\" is shown: the event-level switch is the more\nrelevant piece of information, and the per-hook \"disabled\" status\nwill surface once the event is re-enabled.\n\nUsing an event name as a friendly-name (e.g. hook.<event>.enabled)\ncan cause ambiguity, so a fatal error is issued when using a known\nevent name and a warning is issued for unknown event name, since\na collision cannot be detected with certainty for unknown events.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 20 ++++++++\n builtin/hook.c                 | 20 +++++---\n hook.c                         | 47 +++++++++++++++++--\n hook.h                         |  1 +\n repository.c                   |  1 +\n repository.h                   |  4 ++\n t/t1800-hook.sh                | 83 ++++++++++++++++++++++++++++++++++\n 7 files changed, 165 insertions(+), 11 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex d4fa29d936..e0db3afa19 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -15,6 +15,12 @@ hook.<friendly-name>.event::\n \tevents, specify the key more than once. An empty value resets\n \tthe list of events, clearing any previously defined events for\n \t`hook.<friendly-name>`. See linkgit:git-hook[1].\n++\n+The `<friendly-name>` must not be the same as a known hook event name\n+(e.g. do not use `hook.pre-commit.event`). Using a known event name as\n+a friendly-name is a fatal error because it creates an ambiguity with\n+`hook.<event>.enabled` and `hook.<event>.jobs`. For unknown event names,\n+a warning is issued when `<friendly-name>` matches the event value.\n \n hook.<friendly-name>.enabled::\n \tWhether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.\n@@ -33,6 +39,20 @@ hook.<friendly-name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.enabled::\n+\tSwitch to enable or disable all hooks for the `<event>` hook event.\n+\tWhen set to `false`, no hooks fire for that event, regardless of any\n+\tper-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`.\n+\tSee linkgit:git-hook[1].\n++\n+Note on naming: `<event>` must be the event name (e.g. `pre-commit`),\n+not a hook friendly-name. Since using a known event name as a\n+friendly-name is disallowed (see `hook.<friendly-name>.event` above),\n+there is no ambiguity between event-level and per-hook `.enabled`\n+settings for known events. For unknown events, if a friendly-name\n+matches the event name despite the warning, `.enabled` is treated\n+as per-hook only.\n+\n hook.<event>.jobs::\n \tSpecifies how many hooks can be run simultaneously for the `<event>`\n \thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 1839412dca..8e47e22e2a 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -87,14 +87,22 @@ static int list(int argc, const char **argv, const char *prefix,\n \t\t\tconst char *name = h->u.configured.friendly_name;\n \t\t\tconst char *scope = show_scope ?\n \t\t\t\tconfig_scope_name(h->u.configured.scope) : NULL;\n+\t\t\t/*\n+\t\t\t * Show the most relevant disable reason. Event-level\n+\t\t\t * takes precedence: if the whole event is off, that\n+\t\t\t * is what the user needs to know. The per-hook\n+\t\t\t * \"disabled\" surfaces once the event is re-enabled.\n+\t\t\t */\n+\t\t\tconst char *disability =\n+\t\t\t\th->u.configured.event_disabled ? \"event-disabled\\t\" :\n+\t\t\t\th->u.configured.disabled       ? \"disabled\\t\"       :\n+\t\t\t\t\t\t\t\t \"\";\n \t\t\tif (scope)\n-\t\t\t\tprintf(\"%s\\t%s%s%c\", scope,\n-\t\t\t\t       h->u.configured.disabled ? \"disabled\\t\" : \"\",\n-\t\t\t\t       name, line_terminator);\n+\t\t\t\tprintf(\"%s\\t%s%s%c\", scope, disability, name,\n+\t\t\t\t       line_terminator);\n \t\t\telse\n-\t\t\t\tprintf(\"%s%s%c\",\n-\t\t\t\t       h->u.configured.disabled ? \"disabled\\t\" : \"\",\n-\t\t\t\t       name, line_terminator);\n+\t\t\t\tprintf(\"%s%s%c\", disability, name,\n+\t\t\t\t       line_terminator);\n \t\t\tbreak;\n \t\t}\n \t\tdefault:\ndiff --git a/hook.c b/hook.c\nindex 19076f8f2b..bc990d4ed4 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -133,7 +133,9 @@ struct hook_config_cache_entry {\n  * Callback struct to collect all hook.* keys in a single config pass.\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n- * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * disabled_hooks: set of all names with hook.<name>.enabled = false; after\n+ *                 parsing, names that are not friendly-names become event-level\n+ *                 disables stored in r->disabled_events. This collects all.\n  * parallel_hooks: friendly-name to parallel flag.\n  * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n@@ -189,8 +191,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\tstrmap_for_each_entry(&data->event_hooks, &iter, e)\n \t\t\t\tunsorted_string_list_remove(e->value, hook_name, 0);\n \t\t} else {\n-\t\t\tstruct string_list *hooks =\n-\t\t\t\tstrmap_get(&data->event_hooks, value);\n+\t\t\tstruct string_list *hooks;\n+\n+\t\t\tif (is_known_hook(hook_name))\n+\t\t\t\tdie(_(\"hook friendly-name '%s' collides with \"\n+\t\t\t\t      \"a known event name; please choose a \"\n+\t\t\t\t      \"different friendly-name\"),\n+\t\t\t\t    hook_name);\n+\n+\t\t\tif (!strcmp(hook_name, value))\n+\t\t\t\twarning(_(\"hook friendly-name '%s' is the \"\n+\t\t\t\t\t  \"same as its event; this may cause \"\n+\t\t\t\t\t  \"ambiguity with hook.%s.enabled\"),\n+\t\t\t\t\thook_name, hook_name);\n+\n+\t\t\thooks = strmap_get(&data->event_hooks, value);\n \n \t\t\tif (!hooks) {\n \t\t\t\tCALLOC_ARRAY(hooks, 1);\n@@ -345,6 +360,22 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \n \twarn_jobs_on_friendly_names(&cb_data);\n \n+\t/*\n+\t * Populate disabled_events: names in disabled_hooks that are not\n+\t * friendly-names are event-level switches (hook.<event>.enabled = false).\n+\t * Names that are friendly-names are already handled per-hook via the\n+\t * hook_config_cache_entry.disabled flag below.\n+\t */\n+\tif (r) {\n+\t\tstring_list_clear(&r->disabled_events, 0);\n+\t\tstring_list_init_dup(&r->disabled_events);\n+\t\tfor (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) {\n+\t\t\tconst char *n = cb_data.disabled_hooks.items[i].string;\n+\t\t\tif (!is_friendly_name(&cb_data, n))\n+\t\t\t\tstring_list_append(&r->disabled_events, n);\n+\t\t}\n+\t}\n+\n \t/* Construct the cache from parsed configs. */\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstruct string_list *hook_names = e->value;\n@@ -446,6 +477,8 @@ static void list_hooks_add_configured(struct repository *r,\n {\n \tstruct strmap *cache = get_hook_config_cache(r);\n \tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n+\tbool event_is_disabled = r ? !!unsorted_string_list_lookup(&r->disabled_events,\n+\t\t\t\t\t\t\t\t   hookname) : 0;\n \n \t/* Iterate through configured hooks and initialize internal states */\n \tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n@@ -472,6 +505,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->u.configured.event_disabled = event_is_disabled;\n \t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n@@ -484,6 +518,8 @@ static void list_hooks_add_configured(struct repository *r,\n \tif (!r || !r->gitdir) {\n \t\thook_cache_clear(cache);\n \t\tfree(cache);\n+\t\tif (r)\n+\t\t\tstring_list_clear(&r->disabled_events, 0);\n \t}\n }\n \n@@ -515,7 +551,7 @@ int hook_exists(struct repository *r, const char *name)\n \tfor (size_t i = 0; i < hooks->nr; i++) {\n \t\tstruct hook *h = hooks->items[i].util;\n \t\tif (h->kind == HOOK_TRADITIONAL ||\n-\t\t    !h->u.configured.disabled) {\n+\t\t    (!h->u.configured.disabled && !h->u.configured.event_disabled)) {\n \t\t\texists = 1;\n \t\t\tbreak;\n \t\t}\n@@ -538,7 +574,8 @@ static int pick_next_hook(struct child_process *cp,\n \t\tif (hook_cb->hook_to_run_index >= hook_list->nr)\n \t\t\treturn 0;\n \t\th = hook_list->items[hook_cb->hook_to_run_index++].util;\n-\t} while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled);\n+\t} while (h->kind == HOOK_CONFIGURED &&\n+\t\t (h->u.configured.disabled || h->u.configured.event_disabled));\n \n \tcp->no_stdin = 1;\n \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\ndiff --git a/hook.h b/hook.h\nindex 5a93f56618..b4372b636f 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -32,6 +32,7 @@ struct hook {\n \t\t\tconst char *command;\n \t\t\tenum config_scope scope;\n \t\t\tbool disabled;\n+\t\t\tbool event_disabled;\n \t\t} configured;\n \t} u;\n \ndiff --git a/repository.c b/repository.c\nindex 4030db4460..db57b8308b 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -427,6 +427,7 @@ void repo_clear(struct repository *repo)\n \t\tFREE_AND_NULL(repo->hook_config_cache);\n \t}\n \tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n+\tstring_list_clear(&repo->disabled_events, 0);\n \n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 6b67ec02e2..4969d8b8eb 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -2,6 +2,7 @@\n #define REPOSITORY_H\n \n #include \"strmap.h\"\n+#include \"string-list.h\"\n #include \"repo-settings.h\"\n #include \"environment.h\"\n \n@@ -178,6 +179,9 @@ struct repository {\n \t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n \tstruct strmap event_jobs;\n \n+\t/* Cached list of event names with hook.<event>.enabled = false. */\n+\tstruct string_list disabled_events;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 85b055a897..273588e4d4 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1058,4 +1058,87 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n \ttest_grep ! \"friendly-name\" err\n '\n \n+test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_must_be_empty out\n+'\n+\n+test_expect_success 'hook.<event>.enabled=true does not suppress hooks' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled true &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"ran\" err\n+'\n+\n+test_expect_success 'hook.<event>.enabled=false does not affect other events' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.other-event.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"ran\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.enabled=false still disables that hook' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo hook-1\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"echo hook-2\" &&\n+\ttest_config hook.hook-1.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep ! \"hook-1\" err &&\n+\ttest_grep \"hook-2\" err\n+'\n+\n+test_expect_success 'git hook list shows event-disabled hooks as event-disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name test-hook >actual &&\n+\ttest_grep \"^event-disabled\thook-1$\" actual &&\n+\ttest_grep \"^event-disabled\thook-2$\" actual\n+'\n+\n+test_expect_success 'git hook list shows scope with event-disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name --show-scope test-hook >actual &&\n+\ttest_grep \"^local\tevent-disabled\thook-1$\" actual\n+'\n+\n+test_expect_success 'git hook list still shows hooks when event is disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name test-hook >actual &&\n+\ttest_grep \"event-disabled\" actual\n+'\n+\n+test_expect_success 'friendly-name matching known event name is rejected' '\n+\ttest_config hook.pre-commit.event pre-commit &&\n+\ttest_config hook.pre-commit.command \"echo oops\" &&\n+\ttest_must_fail git hook run pre-commit 2>err &&\n+\ttest_grep \"collides with a known event name\" err\n+'\n+\n+test_expect_success 'friendly-name matching known event name is rejected even for different event' '\n+\ttest_config hook.pre-commit.event post-commit &&\n+\ttest_config hook.pre-commit.command \"echo oops\" &&\n+\ttest_must_fail git hook run post-commit 2>err &&\n+\ttest_grep \"collides with a known event name\" err\n+'\n+\n+test_expect_success 'friendly-name matching unknown event warns' '\n+\ttest_config hook.test-hook.event test-hook &&\n+\ttest_config hook.test-hook.command \"echo ran\" &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"same as its event\" err\n+'\n+\n test_done\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540882","messageId":"20260404082934.173788-13-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"[PATCH v6 12/12] hook: allow hook.jobs=-1 to use all available CPU cores","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-04T08:29:34Z","receivedAt":"2026-04-04T08:30:39Z","isPatch":true,"body":"Allow -1 as a value for hook.jobs, hook.<event>.jobs, and the -j\nCLI flag to mean \"use as many jobs as there are CPU cores\", matching\nthe convention used by fetch.parallel and other Git subsystems.\n\nThe value is resolved to online_cpus() at parse time so the rest\nof the code always works with a positive resolved count.\n\nOther non-positive values (0, -2, etc) are rejected with a warning\n(config) or die (CLI).\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++-\n builtin/hook.c                 | 15 +++++++--\n hook.c                         | 60 ++++++++++++++++++++++++----------\n t/t1800-hook.sh                | 49 +++++++++++++++++++++++++++\n 4 files changed, 108 insertions(+), 20 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex e0db3afa19..a9dc0063c1 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -58,7 +58,8 @@ hook.<event>.jobs::\n \thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n \tfor this specific event. The same parallelism restrictions apply: this\n \tsetting has no effect unless all configured hooks for the event have\n-\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\t`hook.<friendly-name>.parallel` set to `true`. Set to `-1` to use the\n+\tnumber of available CPU cores. Must be a positive integer or `-1`;\n \tzero is rejected with a warning. See linkgit:git-hook[1].\n +\n Note on naming: although this key resembles `hook.<friendly-name>.*`\n@@ -74,6 +75,7 @@ valid event name when setting `hook.<event>.jobs`.\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSet to `-1` to use the number of available CPU cores.\n \tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tthey operate on shared data and cannot safely be parallelized:\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 8e47e22e2a..cceeb3586e 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -5,6 +5,7 @@\n #include \"gettext.h\"\n #include \"hook.h\"\n #include \"parse-options.h\"\n+#include \"thread-utils.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n@@ -123,6 +124,7 @@ static int run(int argc, const char **argv, const char *prefix,\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n \tint ignore_missing = 0;\n \tint allow_unknown = 0;\n+\tint jobs = 0;\n \tconst char *hook_name;\n \tstruct option run_options[] = {\n \t\tOPT_BOOL(0, \"allow-unknown-hook-name\", &allow_unknown,\n@@ -131,8 +133,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n-\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n-\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n+\t\tOPT_INTEGER('j', \"jobs\", &jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously (-1 for CPU count)\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\n@@ -141,6 +143,15 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t     builtin_hook_run_usage,\n \t\t\t     PARSE_OPT_KEEP_DASHDASH);\n \n+\tif (jobs == -1)\n+\t\topt.jobs = online_cpus();\n+\telse if (jobs < 0)\n+\t\tdie(_(\"invalid value for -j: %d\"\n+\t\t     \" (use -1 for CPU count or a\"\n+\t\t     \" positive integer)\"), jobs);\n+\telse\n+\t\topt.jobs = jobs;\n+\n \tif (!argc)\n \t\tgoto usage;\n \ndiff --git a/hook.c b/hook.c\nindex bc990d4ed4..d10eef4763 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -12,6 +12,7 @@\n #include \"setup.h\"\n #include \"strbuf.h\"\n #include \"strmap.h\"\n+#include \"thread-utils.h\"\n \n bool is_known_hook(const char *name)\n {\n@@ -165,13 +166,17 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t/* Handle plain hook.<key> entries that have no hook name component. */\n \tif (!name) {\n \t\tif (!strcmp(subkey, \"jobs\") && value) {\n-\t\t\tunsigned int v;\n-\t\t\tif (!git_parse_uint(value, &v))\n-\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n-\t\t\telse if (!v)\n-\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n-\t\t\telse\n+\t\t\tint v;\n+\t\t\tif (!git_parse_int(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be an integer, ignoring: '%s'\"), value);\n+\t\t\telse if (v == -1)\n+\t\t\t\tdata->jobs = online_cpus();\n+\t\t\telse if (v > 0)\n \t\t\t\tdata->jobs = v;\n+\t\t\telse\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer\"\n+\t\t\t\t\t  \" or -1, ignoring: '%s'\"),\n+\t\t\t\t\tvalue);\n \t\t}\n \t\treturn 0;\n \t}\n@@ -259,17 +264,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n \t} else if (!strcmp(subkey, \"jobs\")) {\n-\t\tunsigned int v;\n-\t\tif (!git_parse_uint(value, &v))\n-\t\t\twarning(_(\"hook.%s.jobs must be a positive integer,\"\n+\t\tint v;\n+\t\tif (!git_parse_int(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be an integer,\"\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n-\t\telse if (!v)\n-\t\t\twarning(_(\"hook.%s.jobs must be positive,\"\n-\t\t\t\t  \" ignoring: 0\"), hook_name);\n-\t\telse\n+\t\telse if (v == -1)\n+\t\t\tstrmap_put(&data->event_jobs, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)online_cpus());\n+\t\telse if (v > 0)\n \t\t\tstrmap_put(&data->event_jobs, hook_name,\n \t\t\t\t   (void *)(uintptr_t)v);\n+\t\telse\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive\"\n+\t\t\t\t  \" integer or -1, ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n \t}\n \n \tfree(hook_name);\n@@ -688,6 +697,25 @@ static void warn_non_parallel_hooks_override(unsigned int jobs,\n \t}\n }\n \n+/* Resolve a hook.jobs config key, handling -1 as online_cpus(). */\n+static void resolve_hook_config_jobs(struct repository *r,\n+\t\t\t\t     const char *key,\n+\t\t\t\t     unsigned int *jobs)\n+{\n+\tint v;\n+\n+\tif (repo_config_get_int(r, key, &v))\n+\t\treturn;\n+\n+\tif (v == -1)\n+\t\t*jobs = online_cpus();\n+\telse if (v > 0)\n+\t\t*jobs = v;\n+\telse\n+\t\twarning(_(\"%s must be a positive integer or -1,\"\n+\t\t\t  \" ignoring: %d\"), key, v);\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -721,14 +749,12 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\t\tif (event_jobs)\n \t\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n \t\t} else {\n-\t\t\tunsigned int event_jobs;\n \t\t\tchar *key;\n \n-\t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\t\t\tresolve_hook_config_jobs(r, \"hook.jobs\", &options->jobs);\n \n \t\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n-\t\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n-\t\t\t\toptions->jobs = event_jobs;\n+\t\t\tresolve_hook_config_jobs(r, key, &options->jobs);\n \t\t\tfree(key);\n \t\t}\n \t}\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 273588e4d4..dbd5299d92 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1058,6 +1058,55 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n \ttest_grep ! \"friendly-name\" err\n '\n \n+test_expect_success 'hook.jobs=-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\ttest_config hook.jobs -1 &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'hook.<event>.jobs=-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\ttest_config hook.test-hook.jobs -1 &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'git hook run -j-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name -j-1 test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'hook.jobs rejects values less than -1' '\n+\ttest_config hook.jobs -2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.jobs must be a positive integer or -1\" err\n+'\n+\n+test_expect_success 'hook.<event>.jobs rejects values less than -1' '\n+\ttest_config hook.test-hook.jobs -5 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.test-hook.jobs must be a positive integer or -1\" err\n+'\n+\n test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '\n \ttest_config hook.hook-1.event test-hook &&\n \ttest_config hook.hook-1.command \"echo ran\" &&\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"540979","messageId":"xmqqo6jwcb09.fsf@gitster.g","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"Re: [PATCH v6 00/12] Run hooks in parallel","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-04-06T16:24:38Z","receivedAt":"2026-04-06T16:24:41Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> Hello everyone,\n>\n> This series enables running hook commands in parallel and is now\n> based on the master branch because all prerequisites have landed.\n>\n> v6 is just a minor refresh which includes a meson build fix provided\n> by Patrick and fixes a trivial conflict with the master branch for\n> that specific patch.\n>\n> Branch is pushed to GitHub: [1]\n> Successful CI run: [2]\n>\n> 1: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v6\n> 2: https://github.com/10ne1/git/actions/runs/23974475271\n>\n> Changes in v6:\n> * Included the meson.build patch fix supplied by Patrick and added\n> him as co-author on patch 10 (Patrick)\n> * Fixed a trivial code conflict on the master branch due to build\n> scripts having moved into tools/ for the above patch (Adrian)\n\nYup, I see this requires updating the base to more recent 'master';\nI used to queue these patches on v2.54.0-rc0~107 but now I updated\nthe base to v2.54.0-rc0~26 to include /tools/ update from Patrick.\n\nThanks, both.\n"},{"id":"541128","messageId":"adYrKgEo24M3MUSd@pks.im","threadId":"64919","inReplyTo":"20260404082934.173788-1-adrian.ratiu@collabora.com","subject":"Re: [PATCH v6 00/12] Run hooks in parallel","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-08T10:17:14Z","receivedAt":"2026-04-08T10:17:21Z","isPatch":true,"body":"On Sat, Apr 04, 2026 at 11:29:22AM +0300, Adrian Ratiu wrote:\n> Hello everyone,\n> \n> This series enables running hook commands in parallel and is now\n> based on the master branch because all prerequisites have landed.\n> \n> v6 is just a minor refresh which includes a meson build fix provided\n> by Patrick and fixes a trivial conflict with the master branch for\n> that specific patch.\n> \n> Branch is pushed to GitHub: [1]\n> Successful CI run: [2]\n> \n> 1: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v6\n> 2: https://github.com/10ne1/git/actions/runs/23974475271\n> \n> Changes in v6:\n> * Included the meson.build patch fix supplied by Patrick and added\n> him as co-author on patch 10 (Patrick)\n> * Fixed a trivial code conflict on the master branch due to build\n> scripts having moved into tools/ for the above patch (Adrian)\n\nThanks, this looks good to me.\n\nPatrick\n"},{"id":"541148","messageId":"xmqq34151jbm.fsf@gitster.g","threadId":"64919","inReplyTo":"adYrKgEo24M3MUSd@pks.im","subject":"Re: [PATCH v6 00/12] Run hooks in parallel","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-04-08T16:57:17Z","receivedAt":"2026-04-08T16:57:20Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Sat, Apr 04, 2026 at 11:29:22AM +0300, Adrian Ratiu wrote:\n>> Hello everyone,\n>> \n>> This series enables running hook commands in parallel and is now\n>> based on the master branch because all prerequisites have landed.\n>> \n>> v6 is just a minor refresh which includes a meson build fix provided\n>> by Patrick and fixes a trivial conflict with the master branch for\n>> that specific patch.\n>> \n>> Branch is pushed to GitHub: [1]\n>> Successful CI run: [2]\n>> \n>> 1: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v6\n>> 2: https://github.com/10ne1/git/actions/runs/23974475271\n>> \n>> Changes in v6:\n>> * Included the meson.build patch fix supplied by Patrick and added\n>> him as co-author on patch 10 (Patrick)\n>> * Fixed a trivial code conflict on the master branch due to build\n>> scripts having moved into tools/ for the above patch (Adrian)\n>\n> Thanks, this looks good to me.\n>\n> Patrick\n\nThanks.\n"},{"id":"541331","messageId":"20260410090608.75283-1-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260204173328.1601807-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 00/13] Run hooks in parallel","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:05:55Z","receivedAt":"2026-04-10T09:06:55Z","isPatch":true,"body":"Hello everyone,\n\nThis series enables running hook commands in parallel and is based\non the latest master branch. It requires commit 2226ffaacd\n(run_processes_parallel(): fix order of sigpipe handling, 2026-04-08)\nfrom master.\n\nv7 is a minor refresh which includes a new parallel test contributed\nby Jeff King to test the bugfix above which landed recently on master.\n\nBranch is pushed to GitHub:\nhttps://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v7\n\nSuccessful CI run:\nhttps://github.com/10ne1/git/actions/runs/24234022244\n\nChanges in v7:\n* Apply Jeff's patch as the 13'th commit in this series, slightly\n  rewording it and moving the test at Jeff's suggestion (Adrian)\n\nRange-diff v6 -> v7:\n 1:  b79b6d4d92 =  1:  c361429e4d repository: fix repo_init() memleak due to missing _clear()\n 2:  04d70c5fe6 =  2:  96d11e8a82 config: add a repo_config_get_uint() helper\n 3:  9d52e73211 =  3:  d7972c7557 hook: parse the hook.jobs config\n 4:  fa52e229e6 =  4:  6fb33026da hook: allow parallel hook execution\n 5:  255e87fe42 =  5:  d5d56c2753 hook: allow pre-push parallel execution\n 6:  eb60a9effe =  6:  48584cf296 hook: mark non-parallelizable hooks\n 7:  44eaa1b5eb =  7:  2606af97ad hook: add -j/--jobs option to git hook run\n 8:  dcd7f91d96 =  8:  950f4dca9f hook: add per-event jobs config\n 9:  7b1513b648 =  9:  4a9e0ec914 hook: warn when hook.<friendly-name>.jobs is set\n10:  1a808c35c3 = 10:  aa1583ba65 hook: move is_known_hook() to hook.c for wider use\n11:  a5d37a0ea1 = 11:  3f70f2c2bf hook: add hook.<event>.enabled switch\n12:  136cd50cea = 12:  56e66c409d hook: allow hook.jobs=-1 to use all available CPU cores\n -:  ---------- > 13:  044996431a t1800: test SIGPIPE with parallel hooks\n\nAdrian Ratiu (9):\n  repository: fix repo_init() memleak due to missing _clear()\n  config: add a repo_config_get_uint() helper\n  hook: parse the hook.jobs config\n  hook: allow pre-push parallel execution\n  hook: add per-event jobs config\n  hook: warn when hook.<friendly-name>.jobs is set\n  hook: move is_known_hook() to hook.c for wider use\n  hook: add hook.<event>.enabled switch\n  hook: allow hook.jobs=-1 to use all available CPU cores\n\nEmily Shaffer (3):\n  hook: allow parallel hook execution\n  hook: mark non-parallelizable hooks\n  hook: add -j/--jobs option to git hook run\n\nJeff King (1):\n  t1800: test SIGPIPE with parallel hooks\n\n Documentation/config/hook.adoc |  76 +++++\n Documentation/git-hook.adoc    |  23 +-\n Makefile                       |   2 +-\n builtin/am.c                   |   8 +-\n builtin/checkout.c             |  19 +-\n builtin/clone.c                |   6 +-\n builtin/hook.c                 |  46 ++-\n builtin/receive-pack.c         |   3 +-\n builtin/worktree.c             |   2 +-\n commit.c                       |   2 +-\n config.c                       |  28 ++\n config.h                       |  13 +\n hook.c                         | 286 +++++++++++++++-\n hook.h                         |  38 ++-\n meson.build                    |  24 +-\n parse.c                        |   9 +\n parse.h                        |   1 +\n repository.c                   |   3 +\n repository.h                   |  10 +\n t/t1800-hook.sh                | 584 ++++++++++++++++++++++++++++++++-\n transport.c                    |   6 +-\n 21 files changed, 1119 insertions(+), 70 deletions(-)\n\n-- \n2.52.0\n\n"},{"id":"541332","messageId":"20260410090608.75283-2-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 01/13] repository: fix repo_init() memleak due to missing _clear()","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:05:56Z","receivedAt":"2026-04-10T09:06:58Z","isPatch":true,"body":"There is an old pre-existing memory leak in repo_init() due to failing\nto call clear_repository_format() in the error case.\n\nIt went undetected because a specific bug is required to trigger it:\nenable a v1 extension in a repository with format v0. Obviously this\ncan only happen in a development environment, so it does not trigger\nin normal usage, however the memleak is real and needs fixing.\n\nFix it by also calling clear_repository_format() in the error case.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n repository.c | 1 +\n 1 file changed, 1 insertion(+)\n\ndiff --git a/repository.c b/repository.c\nindex 9e5537f539..192d6dc9c4 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -323,6 +323,7 @@ int repo_init(struct repository *repo,\n \treturn 0;\n \n error:\n+\tclear_repository_format(&format);\n \trepo_clear(repo);\n \treturn -1;\n }\n-- \n2.52.0\n\n"},{"id":"541333","messageId":"20260410090608.75283-3-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 02/13] config: add a repo_config_get_uint() helper","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:05:57Z","receivedAt":"2026-04-10T09:06:59Z","isPatch":true,"body":"Next commits add a 'hook.jobs' config option of type 'unsigned int',\nso add a helper to parse it since the API only supports int and ulong.\n\nAn alternative is to make 'hook.jobs' an 'int' or parse it as an 'int'\nthen cast it to unsigned, however it's better to use proper helpers for\nthe type. Using 'ulong' is another option which already has helpers, but\nit's a bit excessive in size for just the jobs number.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n config.c | 28 ++++++++++++++++++++++++++++\n config.h | 13 +++++++++++++\n parse.c  |  9 +++++++++\n parse.h  |  1 +\n 4 files changed, 51 insertions(+)\n\ndiff --git a/config.c b/config.c\nindex 156f2a24fa..a1b92fe083 100644\n--- a/config.c\n+++ b/config.c\n@@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value,\n \treturn ret;\n }\n \n+unsigned int git_config_uint(const char *name, const char *value,\n+\t\t\t     const struct key_value_info *kvi)\n+{\n+\tunsigned int ret;\n+\tif (!git_parse_uint(value, &ret))\n+\t\tdie_bad_number(name, value, kvi);\n+\treturn ret;\n+}\n+\n int64_t git_config_int64(const char *name, const char *value,\n \t\t\t const struct key_value_info *kvi)\n {\n@@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest)\n \t\treturn 1;\n }\n \n+int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest)\n+{\n+\tconst char *value;\n+\tstruct key_value_info kvi;\n+\n+\tif (!git_configset_get_value(set, key, &value, &kvi)) {\n+\t\t*dest = git_config_uint(key, value, &kvi);\n+\t\treturn 0;\n+\t} else\n+\t\treturn 1;\n+}\n+\n int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest)\n {\n \tconst char *value;\n@@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo,\n \treturn git_configset_get_int(repo->config, key, dest);\n }\n \n+int repo_config_get_uint(struct repository *repo,\n+\t\t\t const char *key, unsigned int *dest)\n+{\n+\tgit_config_check_init(repo);\n+\treturn git_configset_get_uint(repo->config, key, dest);\n+}\n+\n int repo_config_get_ulong(struct repository *repo,\n \t\t\t  const char *key, unsigned long *dest)\n {\ndiff --git a/config.h b/config.h\nindex ba426a960a..bf47fb3afc 100644\n--- a/config.h\n+++ b/config.h\n@@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *);\n int64_t git_config_int64(const char *, const char *,\n \t\t\t const struct key_value_info *);\n \n+/**\n+ * Identical to `git_config_int`, but for unsigned ints.\n+ */\n+unsigned int git_config_uint(const char *, const char *,\n+\t\t\t     const struct key_value_info *);\n+\n /**\n  * Identical to `git_config_int`, but for unsigned longs.\n  */\n@@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key,\n \n int git_configset_get_string(struct config_set *cs, const char *key, char **dest);\n int git_configset_get_int(struct config_set *cs, const char *key, int *dest);\n+int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest);\n int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest);\n int git_configset_get_bool(struct config_set *cs, const char *key, int *dest);\n int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest);\n@@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r,\n  */\n int repo_config_get_int(struct repository *r, const char *key, int *dest);\n \n+/**\n+ * Similar to `repo_config_get_int` but for unsigned ints.\n+ */\n+int repo_config_get_uint(struct repository *r,\n+\t\t\t const char *key, unsigned int *dest);\n+\n /**\n  * Similar to `repo_config_get_int` but for unsigned longs.\n  */\ndiff --git a/parse.c b/parse.c\nindex 48313571aa..d77f28046a 100644\n--- a/parse.c\n+++ b/parse.c\n@@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret)\n \treturn 1;\n }\n \n+int git_parse_uint(const char *value, unsigned int *ret)\n+{\n+\tuintmax_t tmp;\n+\tif (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int)))\n+\t\treturn 0;\n+\t*ret = tmp;\n+\treturn 1;\n+}\n+\n int git_parse_ulong(const char *value, unsigned long *ret)\n {\n \tuintmax_t tmp;\ndiff --git a/parse.h b/parse.h\nindex ea32de9a91..a6dd37c4cb 100644\n--- a/parse.h\n+++ b/parse.h\n@@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max);\n int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max);\n int git_parse_ssize_t(const char *, ssize_t *);\n int git_parse_ulong(const char *, unsigned long *);\n+int git_parse_uint(const char *value, unsigned int *ret);\n int git_parse_int(const char *value, int *ret);\n int git_parse_int64(const char *value, int64_t *ret);\n int git_parse_double(const char *value, double *ret);\n-- \n2.52.0\n\n"},{"id":"541334","messageId":"20260410090608.75283-4-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 03/13] hook: parse the hook.jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:05:58Z","receivedAt":"2026-04-10T09:07:04Z","isPatch":true,"body":"The hook.jobs config is a global way to set hook parallelization for\nall hooks, in the sense that it is not per-event nor per-hook.\n\nFiner-grained configs will be added in later commits which can override\nit, for e.g. via a per-event type job options. Next commits will also\nadd to this item's documentation.\n\nParse hook.jobs config key in hook_config_lookup_all() and store its\nvalue in hook_all_config_cb.jobs, then transfer it into r->jobs after\nthe config pass completes.\n\nThis is mostly plumbing and the cached value is not yet used.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 23 +++++++++++++++++++++--\n repository.h                   |  3 +++\n 3 files changed, 28 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 9e78f26439..b7847f9338 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -22,3 +22,7 @@ hook.<friendly-name>.enabled::\n \tconfiguration. This is particularly useful when a hook is defined\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n+\n+hook.jobs::\n+\tSpecifies how many hooks can be run simultaneously during parallelized\n+\thook execution. If unspecified, defaults to 1 (serial execution).\ndiff --git a/hook.c b/hook.c\nindex cc23276d27..b8cce00e57 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -123,11 +123,13 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tunsigned int jobs;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -143,6 +145,20 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n \t\treturn 0;\n \n+\t/* Handle plain hook.<key> entries that have no hook name component. */\n+\tif (!name) {\n+\t\tif (!strcmp(subkey, \"jobs\") && value) {\n+\t\t\tunsigned int v;\n+\t\t\tif (!git_parse_uint(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n+\t\t\telse if (!v)\n+\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n+\t\t\telse\n+\t\t\t\tdata->jobs = v;\n+\t\t}\n+\t\treturn 0;\n+\t}\n+\n \tif (!value)\n \t\treturn config_error_nonbool(key);\n \n@@ -240,7 +256,7 @@ void hook_cache_clear(struct strmap *cache)\n /* Populate `cache` with the complete hook configuration */\n static void build_hook_config_map(struct repository *r, struct strmap *cache)\n {\n-\tstruct hook_all_config_cb cb_data;\n+\tstruct hook_all_config_cb cb_data = { 0 };\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *e;\n \n@@ -248,7 +264,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \n-\t/* Parse all configs in one run. */\n+\t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n \t/* Construct the cache from parsed configs. */\n@@ -292,6 +308,9 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tstrmap_put(cache, e->key, hooks);\n \t}\n \n+\tif (r)\n+\t\tr->hook_jobs = cb_data.jobs;\n+\n \tstrmap_clear(&cb_data.commands, 1);\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\ndiff --git a/repository.h b/repository.h\nindex 078059a6e0..58e46853d0 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -172,6 +172,9 @@ struct repository {\n \t */\n \tstruct strmap *hook_config_cache;\n \n+\t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n+\tunsigned int hook_jobs;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\n-- \n2.52.0\n\n"},{"id":"541335","messageId":"20260410090608.75283-5-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 04/13] hook: allow parallel hook execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:05:59Z","receivedAt":"2026-04-10T09:07:09Z","isPatch":true,"body":"From: Emily Shaffer <nasamuffin@google.com>\n\nHooks always run in sequential order due to the hardcoded jobs == 1\npassed to run_process_parallel(). Remove that hardcoding to allow\nusers to run hooks in parallel (opt-in).\n\nUsers need to decide which hooks to run in parallel, by specifying\n\"parallel = true\" in the config, because Git cannot know if their\nspecific hooks are safe to run or not in parallel (for e.g. two hooks\nmight write to the same file or call the same program).\n\nSome hooks are unsafe to run in parallel by design: these will marked\nin the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL.\n\nThe hook.jobs config specifies the default number of jobs applied to all\nhooks which have parallelism enabled.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  13 +++\n hook.c                         |  79 ++++++++++++++++--\n hook.h                         |  25 ++++++\n t/t1800-hook.sh                | 142 +++++++++++++++++++++++++++++++++\n 4 files changed, 253 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex b7847f9338..21800db648 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -23,6 +23,19 @@ hook.<friendly-name>.enabled::\n \tin a system or global config file and needs to be disabled for a\n \tspecific repository. See linkgit:git-hook[1].\n \n+hook.<friendly-name>.parallel::\n+\tWhether the hook `hook.<friendly-name>` may run in parallel with other hooks\n+\tfor the same event. Defaults to `false`. Set to `true` only when the\n+\thook script is safe to run concurrently with other hooks for the same\n+\tevent. If any hook for an event does not have this set to `true`,\n+\tall hooks for that event run sequentially regardless of `hook.jobs`.\n+\tOnly configured (named) hooks need to declare this. Traditional hooks\n+\tfound in the hooks directory do not need to, and run in parallel when\n+\tthe effective job count is greater than 1. See linkgit:git-hook[1].\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n++\n+This setting has no effect unless all configured hooks for the event have\n+`hook.<friendly-name>.parallel` set to `true`.\ndiff --git a/hook.c b/hook.c\nindex b8cce00e57..85c0de5e47 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -116,6 +116,7 @@ struct hook_config_cache_entry {\n \tchar *command;\n \tenum config_scope scope;\n \tbool disabled;\n+\tbool parallel;\n };\n \n /*\n@@ -123,12 +124,14 @@ struct hook_config_cache_entry {\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * parallel_hooks: friendly-name to parallel flag.\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n+\tstruct strmap parallel_hooks;\n \tunsigned int jobs;\n };\n \n@@ -219,6 +222,15 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tdefault:\n \t\t\tbreak; /* ignore unrecognised values */\n \t\t}\n+\t} else if (!strcmp(subkey, \"parallel\")) {\n+\t\tint v = git_parse_maybe_bool(value);\n+\t\tif (v >= 0)\n+\t\t\tstrmap_put(&data->parallel_hooks, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)v);\n+\t\telse\n+\t\t\twarning(_(\"hook.%s.parallel must be a boolean,\"\n+\t\t\t\t  \" ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n \t}\n \n \tfree(hook_name);\n@@ -263,6 +275,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.commands);\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n+\tstrmap_init(&cb_data.parallel_hooks);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -282,6 +295,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tstruct hook_config_cache_entry *entry;\n \t\t\tchar *command;\n \n+\t\t\tbool is_par = !!strmap_get(&cb_data.parallel_hooks, hname);\n \t\t\tbool is_disabled =\n \t\t\t\t!!unsorted_string_list_lookup(\n \t\t\t\t\t&cb_data.disabled_hooks, hname);\n@@ -302,6 +316,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tentry->command = xstrdup_or_null(command);\n \t\t\tentry->scope = scope;\n \t\t\tentry->disabled = is_disabled;\n+\t\t\tentry->parallel = is_par;\n \t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n@@ -312,6 +327,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tr->hook_jobs = cb_data.jobs;\n \n \tstrmap_clear(&cb_data.commands, 1);\n+\tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n \tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstring_list_clear(e->value, 0);\n@@ -389,6 +405,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n@@ -538,21 +555,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/* Determine how many jobs to use for hook execution. */\n+static unsigned int get_hook_jobs(struct repository *r,\n+\t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  struct string_list *hook_list)\n+{\n+\t/*\n+\t * Hooks needing separate output streams must run sequentially.\n+\t * Next commit will allow parallelizing these as well.\n+\t */\n+\tif (!options->stdout_to_stderr)\n+\t\treturn 1;\n+\n+\t/*\n+\t * An explicit job count overrides everything else: this covers both\n+\t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n+\t * and the -j flag from the CLI. The CLI override is intentional: users\n+\t * may want to serialize hooks declared parallel or to parallelize more\n+\t * aggressively than the default.\n+\t */\n+\tif (options->jobs)\n+\t\treturn options->jobs;\n+\n+\t/*\n+\t * Use hook.jobs from the already-parsed config cache (in-repo), or\n+\t * fallback to a direct config lookup (out-of-repo).\n+\t * Default to 1 (serial execution) on failure.\n+\t */\n+\toptions->jobs = 1;\n+\tif (r) {\n+\t\tif (r->gitdir && r->hook_config_cache && r->hook_jobs)\n+\t\t\toptions->jobs = r->hook_jobs;\n+\t\telse\n+\t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\t}\n+\n+\t/*\n+\t * Cap to serial any configured hook not marked as parallel = true.\n+\t * This enforces the parallel = false default, even for \"traditional\"\n+\t * hooks from the hookdir which cannot be marked parallel = true.\n+\t */\n+\tfor (size_t i = 0; i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel) {\n+\t\t\toptions->jobs = 1;\n+\t\t\tbreak;\n+\t\t}\n+\t}\n+\n+\treturn options->jobs;\n+}\n+\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n+\tstruct string_list *hook_list = list_hooks(r, hook_name, options);\n \tstruct hook_cb_data cb_data = {\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n+\t\t.hook_command_list = hook_list,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\n \n-\t\t.processes = options->jobs,\n-\t\t.ungroup = options->jobs == 1,\n+\t\t.processes = jobs,\n+\t\t.ungroup = jobs == 1,\n \n \t\t.get_next_task = pick_next_hook,\n \t\t.start_failure = notify_start_failure,\n@@ -568,9 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->path_to_stdin && options->feed_pipe)\n \t\tBUG(\"options path_to_stdin and feed_pipe are mutually exclusive\");\n \n-\tif (!options->jobs)\n-\t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n-\n \t/*\n \t * Ensure cb_data copy and free functions are either provided together,\n \t * or neither one is provided.\n@@ -581,7 +649,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n \tif (!cb_data.hook_command_list->nr) {\n \t\tif (options->error_if_missing)\n \t\t\tret = error(\"cannot find a hook named %s\", hook_name);\ndiff --git a/hook.h b/hook.h\nindex 5c5628dd1f..ba7056f872 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -35,6 +35,13 @@ struct hook {\n \t\t} configured;\n \t} u;\n \n+\t/**\n+\t * Whether this hook may run in parallel with other hooks for the same\n+\t * event. Only useful for configured (named) hooks. Traditional hooks\n+\t * always default to 0 (serial). Set via `hook.<name>.parallel = true`.\n+\t */\n+\tbool parallel;\n+\n \t/**\n \t * Opaque data pointer used to keep internal state across callback calls.\n \t *\n@@ -72,6 +79,8 @@ struct run_hooks_opt {\n \t *\n \t * If > 1, output will be buffered and de-interleaved (ungroup=0).\n \t * If == 1, output will be real-time (ungroup=1).\n+\t * If == 0, the 'hook.jobs' config is used or, if the config is unset,\n+\t * defaults to 1 (serial execution).\n \t */\n \tunsigned int jobs;\n \n@@ -152,7 +161,23 @@ struct run_hooks_opt {\n \thook_data_free_fn feed_pipe_cb_data_free;\n };\n \n+/**\n+ * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to\n+ * the 'hook.jobs' config, falling back to serial (1) if unset.\n+ */\n #define RUN_HOOKS_OPT_INIT { \\\n+\t.env = STRVEC_INIT, \\\n+\t.args = STRVEC_INIT, \\\n+\t.stdout_to_stderr = 1, \\\n+\t.jobs = 0, \\\n+}\n+\n+/**\n+ * Initializer for hooks that must always run sequentially regardless of\n+ * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized\n+ * .jobs = 1 is non-overridable.\n+ */\n+#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \\\n \t.env = STRVEC_INIT, \\\n \t.args = STRVEC_INIT, \\\n \t.stdout_to_stderr = 1, \\\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 33decc66c0..a3011a01ca 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -21,6 +21,57 @@ setup_hookdir () {\n \ttest_when_finished rm -rf .git/hooks\n }\n \n+# write_sentinel_hook <path> [sentinel]\n+#\n+# Writes a hook that marks itself as started, sleeps for a few seconds, then\n+# marks itself done. The sleep must be long enough that sentinel_detector can\n+# observe <sentinel>.started before <sentinel>.done appears when both hooks\n+# run concurrently in parallel mode.\n+write_sentinel_hook () {\n+\tsentinel=\"${2:-sentinel}\"\n+\twrite_script \"$1\" <<-EOF\n+\ttouch ${sentinel}.started &&\n+\tsleep 2 &&\n+\ttouch ${sentinel}.done\n+\tEOF\n+}\n+\n+# sentinel_detector <sentinel> <output>\n+#\n+# Returns a shell command string suitable for use as hook.<name>.command.\n+# The detector must be registered after the sentinel:\n+# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)\n+#    before the detector starts.\n+# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared\n+#    yet and the detector just sees <sentinel>.started.\n+#\n+# At start, poll until <sentinel>.started exists to absorb startup jitter, then\n+# write to <output>:\n+# 1. 'serial'   if <sentinel>.done exists (sentinel finished before we started),\n+# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),\n+# 3. 'timeout'  if <sentinel>.started never appeared.\n+#\n+# The command ends with ':' so when git appends \"$@\" for hooks that receive\n+# positional arguments (e.g. pre-push), the result ': \"$@\"' is valid shell\n+# rather than a syntax error 'fi \"$@\"'.\n+sentinel_detector () {\n+\tcat <<-EOF\n+\ti=0\n+\twhile ! test -f ${1}.started && test \\$i -lt 10; do\n+\t    sleep 1\n+\t    i=\\$((i+1))\n+\tdone\n+\tif test -f ${1}.done; then\n+\t    echo serial >${2}\n+\telif test -f ${1}.started; then\n+\t    echo parallel >${2}\n+\telse\n+\t    echo timeout >${2}\n+\tfi\n+\t:\n+\tEOF\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n@@ -658,4 +709,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'hook.jobs=1 config runs hooks in series' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\n+\t# Use two configured hooks so the execution order is deterministic:\n+\t# hook-1 (sentinel) is listed before hook-2 (detector), so hook-1\n+\t# always runs first even in serial mode.\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 1 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.jobs=2 config runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=true enables parallel execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'one non-parallel hook forces the whole event to run serially' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 has no parallel=true: should force serial for all\n+\n+\ttest_config hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0\n\n"},{"id":"541336","messageId":"20260410090608.75283-6-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 05/13] hook: allow pre-push parallel execution","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:00Z","receivedAt":"2026-04-10T09:07:12Z","isPatch":true,"body":"pre-push is the only hook that keeps stdout and stderr separate (for\nbackwards compatibility with git-lfs and potentially other users). This\nprevents parallelizing it because run-command needs stdout_to_stderr=1\nto buffer and de-interleave parallel outputs.\n\nSince we now default to jobs=1, backwards compatibility is maintained\nwithout needing any extension or extra config: when no parallelism is\nrequested, pre-push behaves exactly as before.\n\nWhen the user explicitly opts into parallelism via hook.jobs > 1,\nhook.<event>.jobs > 1, or -jN, they accept the changed output behavior.\n\nDocument this and let get_hook_jobs() set stdout_to_stderr=1 automatically\nwhen jobs > 1, removing the need for any extension infrastructure.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++++\n hook.c                         | 24 ++++++++++++++++--------\n hook.h                         |  6 ++++--\n t/t1800-hook.sh                | 32 ++++++++++++++++++++++++++++++++\n transport.c                    |  6 ++++--\n 5 files changed, 60 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 21800db648..94c7a9808e 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -39,3 +39,7 @@ hook.jobs::\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<friendly-name>.parallel` set to `true`.\n++\n+For `pre-push` hooks, which normally keep stdout and stderr separate,\n+setting this to a value greater than 1 (or passing `-j`) will merge stdout\n+into stderr to allow correct de-interleaving of parallel output.\ndiff --git a/hook.c b/hook.c\nindex 85c0de5e47..25762b6c8d 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -555,18 +555,24 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n \tstrvec_clear(&options->args);\n }\n \n+/*\n+ * When running in parallel, stdout must be merged into stderr so\n+ * run-command can buffer and de-interleave outputs correctly. This\n+ * applies even to hooks like pre-push that normally keep stdout and\n+ * stderr separate: the user has opted into parallelism, so the output\n+ * stream behavior changes accordingly.\n+ */\n+static void merge_output_if_parallel(struct run_hooks_opt *options)\n+{\n+\tif (options->jobs > 1)\n+\t\toptions->stdout_to_stderr = 1;\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n \t\t\t\t  struct string_list *hook_list)\n {\n-\t/*\n-\t * Hooks needing separate output streams must run sequentially.\n-\t * Next commit will allow parallelizing these as well.\n-\t */\n-\tif (!options->stdout_to_stderr)\n-\t\treturn 1;\n-\n \t/*\n \t * An explicit job count overrides everything else: this covers both\n \t * FORCE_SERIAL callers (for hooks that must never run in parallel)\n@@ -575,7 +581,7 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t * aggressively than the default.\n \t */\n \tif (options->jobs)\n-\t\treturn options->jobs;\n+\t\tgoto cleanup;\n \n \t/*\n \t * Use hook.jobs from the already-parsed config cache (in-repo), or\n@@ -603,6 +609,8 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\t}\n \t}\n \n+cleanup:\n+\tmerge_output_if_parallel(options);\n \treturn options->jobs;\n }\n \ndiff --git a/hook.h b/hook.h\nindex ba7056f872..01db4226a6 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -106,8 +106,10 @@ struct run_hooks_opt {\n \t * Send the hook's stdout to stderr.\n \t *\n \t * This is the default behavior for all hooks except pre-push,\n-\t * which has separate stdout and stderr streams for backwards\n-\t * compatibility reasons.\n+\t * which keeps stdout and stderr separate for backwards compatibility.\n+\t * When parallel execution is requested (jobs > 1), get_hook_jobs()\n+\t * overrides this to 1 for all hooks so run-command can de-interleave\n+\t * their outputs correctly.\n \t */\n \tunsigned int stdout_to_stderr:1;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex a3011a01ca..4a978aff5e 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -800,4 +800,36 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'client hooks: pre-push parallel execution merges stdout to stderr' '\n+\ttest_when_finished \"rm -rf remote-par stdout.actual stderr.actual\" &&\n+\tgit init --bare remote-par &&\n+\tgit remote add origin-par remote-par &&\n+\ttest_commit par-commit &&\n+\tmkdir -p .git/hooks &&\n+\tsetup_hooks pre-push &&\n+\ttest_config hook.jobs 2 &&\n+\tgit push origin-par HEAD:main >stdout.actual 2>stderr.actual &&\n+\tcheck_stdout_merged_to_stderr pre-push\n+'\n+\n+test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' '\n+\ttest_when_finished \"rm -rf repo-parallel remote-parallel\" &&\n+\tgit init --bare remote-parallel &&\n+\tgit init repo-parallel &&\n+\tgit -C repo-parallel remote add origin ../remote-parallel &&\n+\ttest_commit -C repo-parallel A &&\n+\n+\twrite_sentinel_hook repo-parallel/.git/hooks/pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.event pre-push &&\n+\tgit -C repo-parallel config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\tgit -C repo-parallel config hook.hook-2.parallel true &&\n+\n+\tgit -C repo-parallel config hook.jobs 2 &&\n+\n+\tgit -C repo-parallel push origin HEAD >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect repo-parallel/hook.order\n+'\n+\n test_done\ndiff --git a/transport.c b/transport.c\nindex e53936d87b..9406ec4f2d 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1391,8 +1391,10 @@ static int run_pre_push_hook(struct transport *transport,\n \topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n \n \t/*\n-\t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n-\t * them to keep backwards compatibility with existing hooks.\n+\t * pre-push hooks keep stdout and stderr separate by default for\n+\t * backwards compatibility. When the user opts into parallel execution\n+\t * via hook.jobs > 1 or -j, get_hook_jobs() will set stdout_to_stderr=1\n+\t * automatically so run-command can de-interleave the outputs.\n \t */\n \topt.stdout_to_stderr = 0;\n \n-- \n2.52.0\n\n"},{"id":"541337","messageId":"20260410090608.75283-7-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 06/13] hook: mark non-parallelizable hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:01Z","receivedAt":"2026-04-10T09:07:16Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSeveral hooks are known to be inherently non-parallelizable, so initialize\nthem with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides\nany hook.jobs or runtime -j flags.\n\nThese hooks are:\napplypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit,\npost-checkout, and push-to-checkout.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 14 ++++++++++++++\n builtin/am.c                   |  8 +++++---\n builtin/checkout.c             | 19 +++++++++++++------\n builtin/clone.c                |  6 ++++--\n builtin/receive-pack.c         |  3 ++-\n builtin/worktree.c             |  2 +-\n commit.c                       |  2 +-\n t/t1800-hook.sh                | 16 ++++++++++++++++\n 8 files changed, 56 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 94c7a9808e..6f60775c28 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -36,6 +36,20 @@ hook.<friendly-name>.parallel::\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSome hooks always run sequentially regardless of this setting because\n+\tthey operate on shared data and cannot safely be parallelized:\n++\n+--\n+`applypatch-msg`;;\n+`prepare-commit-msg`;;\n+`commit-msg`;;\n+\tReceive a commit message file and may rewrite it in place.\n+`pre-commit`;;\n+`post-checkout`;;\n+`push-to-checkout`;;\n+`post-commit`;;\n+\tAccess the working tree, index, or repository state.\n+--\n +\n This setting has no effect unless all configured hooks for the event have\n `hook.<friendly-name>.parallel` set to `true`.\ndiff --git a/builtin/am.c b/builtin/am.c\nindex fe6e087eee..e9623b8307 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state)\n \n \tassert(state->msg);\n \n-\tif (!state->no_verify)\n-\t\tret = run_hooks_l(the_repository, \"applypatch-msg\",\n-\t\t\t\t  am_path(state, \"final-commit\"), NULL);\n+\tif (!state->no_verify) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\t\tstrvec_push(&opt.args, am_path(state, \"final-commit\"));\n+\t\tret = run_hooks_opt(the_repository, \"applypatch-msg\", &opt);\n+\t}\n \n \tif (!ret) {\n \t\tFREE_AND_NULL(state->msg);\ndiff --git a/builtin/checkout.c b/builtin/checkout.c\nindex e031e61886..ac0186a33e 100644\n--- a/builtin/checkout.c\n+++ b/builtin/checkout.c\n@@ -31,6 +31,7 @@\n #include \"resolve-undo.h\"\n #include \"revision.h\"\n #include \"setup.h\"\n+#include \"strvec.h\"\n #include \"submodule.h\"\n #include \"symlinks.h\"\n #include \"trace2.h\"\n@@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info)\n static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit,\n \t\t\t      int changed)\n {\n-\treturn run_hooks_l(the_repository, \"post-checkout\",\n-\t\t\t   oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n-\t\t\t   changed ? \"1\" : \"0\", NULL);\n-\t/* \"new_commit\" can be NULL when checking out from the index before\n-\t   a commit exists. */\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n+\t/*\n+\t * \"new_commit\" can be NULL when checking out from the index before\n+\t * a commit exists.\n+\t */\n+\tstrvec_pushl(&opt.args,\n+\t\t     oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)),\n+\t\t     changed ? \"1\" : \"0\",\n+\t\t     NULL);\n+\n+\treturn run_hooks_opt(the_repository, \"post-checkout\", &opt);\n }\n \n static int update_some(const struct object_id *oid, struct strbuf *base,\ndiff --git a/builtin/clone.c b/builtin/clone.c\nindex fba3c9c508..d23b0cafcf 100644\n--- a/builtin/clone.c\n+++ b/builtin/clone.c\n@@ -647,6 +647,7 @@ static int checkout(int submodule_progress,\n \tstruct tree *tree;\n \tstruct tree_desc t;\n \tint err = 0;\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \tif (option_no_checkout)\n \t\treturn 0;\n@@ -697,8 +698,9 @@ static int checkout(int submodule_progress,\n \tif (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK))\n \t\tdie(_(\"unable to write new index file\"));\n \n-\terr |= run_hooks_l(the_repository, \"post-checkout\", oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t   oid_to_hex(&oid), \"1\", NULL);\n+\tstrvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)),\n+\t\t     oid_to_hex(&oid), \"1\", NULL);\n+\terr |= run_hooks_opt(the_repository, \"post-checkout\", &hook_opt);\n \n \tif (!err && (option_recurse_submodules.nr > 0)) {\n \t\tstruct child_process cmd = CHILD_PROCESS_INIT;\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 878aa7f0ed..f0771590a7 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -1456,7 +1456,8 @@ static const char *push_to_checkout(unsigned char *hash,\n \t\t\t\t    struct strvec *env,\n \t\t\t\t    const char *work_tree)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n \topt.invoked_hook = invoked_hook;\n \n \tstrvec_pushf(env, \"GIT_WORK_TREE=%s\", absolute_path(work_tree));\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex 4fd6f7575f..d21c43fde3 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname,\n \t * is_junk is cleared, but do return appropriate code when hook fails.\n \t */\n \tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \n \t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n \t\tstrvec_pushl(&opt.args,\ndiff --git a/commit.c b/commit.c\nindex 80d8d07875..4385ae4329 100644\n--- a/commit.c\n+++ b/commit.c\n@@ -1970,7 +1970,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len)\n int run_commit_hook(int editor_is_used, const char *index_file,\n \t\t    int *invoked_hook, const char *name, ...)\n {\n-\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n \tva_list args;\n \tconst char *arg;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 4a978aff5e..63fa25bca2 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -832,4 +832,20 @@ test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1'\n \ttest_cmp expect repo-parallel/hook.order\n '\n \n+test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event pre-commit &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event pre-commit &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.jobs 2 &&\n+\tgit commit --allow-empty -m \"test: verify force-serial on pre-commit\" &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0\n\n"},{"id":"541338","messageId":"20260410090608.75283-8-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 07/13] hook: add -j/--jobs option to git hook run","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:02Z","receivedAt":"2026-04-10T09:07:20Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nExpose the parallel job count as a command-line flag so callers can\nrequest parallelism without relying only on the hook.jobs config.\n\nAdd tests covering serial/parallel execution and TTY behaviour under\n-j1 vs -jN.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nHelped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  23 +++++-\n builtin/hook.c              |   5 +-\n hook.c                      |  17 +++++\n t/t1800-hook.sh             | 135 ++++++++++++++++++++++++++++++++++--\n 4 files changed, 170 insertions(+), 10 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 318c637bd8..46ea52db55 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -8,7 +8,8 @@ git-hook - Run git hooks\n SYNOPSIS\n --------\n [verse]\n-'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n+\t<hook-name> [-- <hook-args>]\n 'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\n \n DESCRIPTION\n@@ -147,6 +148,23 @@ OPTIONS\n \tmirroring the output style of `git config --show-scope`. Traditional\n \thooks from the hookdir are unaffected.\n \n+-j::\n+--jobs::\n+\tOnly valid for `run`.\n++\n+Specify how many hooks to run simultaneously. If this flag is not specified,\n+the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If\n+neither is specified, defaults to 1 (serial execution).\n++\n+When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel`\n+setting, allowing all hooks for the event to run concurrently, even if they\n+are not individually marked as parallel.\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 parallel:\n+`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`,\n+`post-commit`, `post-checkout`, and `push-to-checkout`.\n+\n WRAPPERS\n --------\n \n@@ -169,7 +187,8 @@ running:\n git hook run --allow-unknown-hook-name mywrapper-start-tests \\\n   # providing something to stdin\n   --stdin some-tempfile-123 \\\n-  # execute hooks in serial\n+  # execute multiple hooks in parallel\n+  --jobs 3 \\\n   # plus some arguments of your own...\n   -- \\\n   --testname bar \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex c0585587e5..bea0668b47 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -8,7 +8,8 @@\n #include \"parse-options.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n-\tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+\tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n+\t   \"<hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\")\n \n@@ -132,6 +133,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n+\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\ndiff --git a/hook.c b/hook.c\nindex 25762b6c8d..c0b71322cf 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -568,6 +568,22 @@ static void merge_output_if_parallel(struct run_hooks_opt *options)\n \t\toptions->stdout_to_stderr = 1;\n }\n \n+static void warn_non_parallel_hooks_override(unsigned int jobs,\n+\t\t\t\t\t     struct string_list *hook_list)\n+{\n+\t/* Don't warn for hooks running sequentially. */\n+\tif (jobs == 1)\n+\t\treturn;\n+\n+\tfor (size_t i = 0; i < hook_list->nr; i++) {\n+\t\tstruct hook *h = hook_list->items[i].util;\n+\t\tif (h->kind == HOOK_CONFIGURED && !h->parallel)\n+\t\t\twarning(_(\"hook '%s' is not marked as parallel=true, \"\n+\t\t\t\t  \"running in parallel anyway due to -j%u\"),\n+\t\t\t\th->u.configured.friendly_name, jobs);\n+\t}\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -611,6 +627,7 @@ static unsigned int get_hook_jobs(struct repository *r,\n \n cleanup:\n \tmerge_output_if_parallel(options);\n+\twarn_non_parallel_hooks_override(options->jobs, hook_list);\n \treturn options->jobs;\n }\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 63fa25bca2..aa37a5181a 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -268,10 +268,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n '\n \n test_hook_tty () {\n-\tcat >expect <<-\\EOF\n-\tSTDOUT TTY\n-\tSTDERR TTY\n-\tEOF\n+\texpect_tty=$1\n+\tshift\n+\n+\tif test \"$expect_tty\" != \"no_tty\"; then\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT TTY\n+\t\tSTDERR TTY\n+\t\tEOF\n+\telse\n+\t\tcat >expect <<-\\EOF\n+\t\tSTDOUT NO TTY\n+\t\tSTDERR NO TTY\n+\t\tEOF\n+\tfi\n \n \ttest_when_finished \"rm -rf repo\" &&\n \tgit init repo &&\n@@ -289,12 +299,21 @@ test_hook_tty () {\n \ttest_cmp expect repo/actual\n }\n \n-test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty hook run pre-commit\n+test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '\n+\t# hooks running sequentially (-j1) are always connected to the tty for\n+\t# optimum real-time performance.\n+\ttest_hook_tty tty hook run -j1 pre-commit\n+'\n+\n+test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '\n+\t# Hooks are not connected to the tty when run in parallel, instead they\n+\t# output to a pipe through which run-command collects and de-interlaces\n+\t# their outputs, which then gets passed either to the tty or a sideband.\n+\ttest_hook_tty no_tty hook run -j2 pre-commit\n '\n \n test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n-\ttest_hook_tty commit -m\"B.new\"\n+\ttest_hook_tty tty commit -m\"B.new\"\n '\n \n test_expect_success 'git hook list orders by config order' '\n@@ -709,6 +728,108 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s\n \tcheck_stdout_merged_to_stderr push-to-checkout\n '\n \n+test_expect_success 'parallel hook output is not interleaved' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo \"Hook 1 Start\"\n+\tsleep 1\n+\techo \"Hook 1 End\"\n+\tEOF\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t\t    \"echo \\\"Hook 2 Start\\\"; sleep 2; echo \\\"Hook 2 End\\\"\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\ttest_config hook.hook-3.event test-hook &&\n+\ttest_config hook.hook-3.command \\\n+\t\t    \"echo \\\"Hook 3 Start\\\"; sleep 3; echo \\\"Hook 3 End\\\"\" &&\n+\ttest_config hook.hook-3.parallel true &&\n+\n+\tgit hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel &&\n+\n+\t# Verify Hook 1 output is grouped\n+\tsed -n \"/Hook 1 Start/,/Hook 1 End/p\" err.parallel >hook1_out &&\n+\ttest_line_count = 2 hook1_out &&\n+\n+\t# Verify Hook 2 output is grouped\n+\tsed -n \"/Hook 2 Start/,/Hook 2 End/p\" err.parallel >hook2_out &&\n+\ttest_line_count = 2 hook2_out &&\n+\n+\t# Verify Hook 3 output is grouped\n+\tsed -n \"/Hook 3 Start/,/Hook 3 End/p\" err.parallel >hook3_out &&\n+\ttest_line_count = 2 hook3_out\n+'\n+\n+test_expect_success 'git hook run -j1 runs hooks in series' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\ttest_config hook.series-1.event \"test-hook\" &&\n+\ttest_config hook.series-1.command \"echo 1\" --add &&\n+\ttest_config hook.series-2.event \"test-hook\" &&\n+\ttest_config hook.series-2.command \"echo 2\" --add &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_script .git/hooks/test-hook <<-EOF &&\n+\techo 3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tgit hook run --allow-unknown-hook-name -j1 test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook run -j2 runs hooks in parallel' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\n+\tmkdir -p .git/hooks &&\n+\twrite_sentinel_hook .git/hooks/test-hook &&\n+\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 overrides parallel=false' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# -j2 overrides parallel=false; hooks run in parallel with a warning.\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"true\" &&\n+\t# neither hook has parallel=true\n+\n+\tgit hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&\n+\tgrep \"hook .hook-1. is not marked as parallel=true\" err &&\n+\tgrep \"hook .hook-2. is not marked as parallel=true\" err\n+'\n+\n test_expect_success 'hook.jobs=1 config runs hooks in series' '\n \ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n \n-- \n2.52.0\n\n"},{"id":"541339","messageId":"20260410090608.75283-9-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 08/13] hook: add per-event jobs config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:03Z","receivedAt":"2026-04-10T09:07:23Z","isPatch":true,"body":"Add a hook.<event>.jobs count config that allows users to override the\nglobal hook.jobs setting for specific hook events.\n\nThis allows finer-grained control over parallelism on a per-event basis.\n\nFor example, to run `post-receive` hooks with up to 4 parallel jobs\nwhile keeping other events at their global default:\n\n[hook]\n    post-receive.jobs = 4\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 19 +++++++++++\n hook.c                         | 46 +++++++++++++++++++++++---\n repository.c                   |  1 +\n repository.h                   |  3 ++\n t/t1800-hook.sh                | 59 ++++++++++++++++++++++++++++++++++\n 5 files changed, 123 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 6f60775c28..d4fa29d936 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -33,9 +33,28 @@ hook.<friendly-name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.jobs::\n+\tSpecifies how many hooks can be run simultaneously for the `<event>`\n+\thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n+\tfor this specific event. The same parallelism restrictions apply: this\n+\tsetting has no effect unless all configured hooks for the event have\n+\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\tzero is rejected with a warning. See linkgit:git-hook[1].\n++\n+Note on naming: although this key resembles `hook.<friendly-name>.*`\n+(a per-hook setting), `<event>` must be the event name, not a hook\n+friendly name. The key component is stored literally and looked up by\n+event name at runtime with no translation between the two namespaces.\n+A key like `hook.my-hook.jobs` is stored under `\"my-hook\"` but the\n+lookup at runtime uses the event name (e.g. `\"post-receive\"`), so\n+`hook.my-hook.jobs` is silently ignored even when `my-hook` is\n+registered for that event. Use `hook.post-receive.jobs` or any other\n+valid event name when setting `hook.<event>.jobs`.\n+\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tthey operate on shared data and cannot safely be parallelized:\n +\ndiff --git a/hook.c b/hook.c\nindex c0b71322cf..d98b011563 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -125,6 +125,7 @@ struct hook_config_cache_entry {\n  * event_hooks: event-name to list of friendly-names map.\n  * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n  * parallel_hooks: friendly-name to parallel flag.\n+ * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n  */\n struct hook_all_config_cb {\n@@ -132,6 +133,7 @@ struct hook_all_config_cb {\n \tstruct strmap event_hooks;\n \tstruct string_list disabled_hooks;\n \tstruct strmap parallel_hooks;\n+\tstruct strmap event_jobs;\n \tunsigned int jobs;\n };\n \n@@ -231,6 +233,18 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\twarning(_(\"hook.%s.parallel must be a boolean,\"\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n+\t} else if (!strcmp(subkey, \"jobs\")) {\n+\t\tunsigned int v;\n+\t\tif (!git_parse_uint(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive integer,\"\n+\t\t\t\t  \" ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n+\t\telse if (!v)\n+\t\t\twarning(_(\"hook.%s.jobs must be positive,\"\n+\t\t\t\t  \" ignoring: 0\"), hook_name);\n+\t\telse\n+\t\t\tstrmap_put(&data->event_jobs, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)v);\n \t}\n \n \tfree(hook_name);\n@@ -276,6 +290,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_init(&cb_data.event_hooks);\n \tstring_list_init_dup(&cb_data.disabled_hooks);\n \tstrmap_init(&cb_data.parallel_hooks);\n+\tstrmap_init(&cb_data.event_jobs);\n \n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -323,8 +338,10 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\tstrmap_put(cache, e->key, hooks);\n \t}\n \n-\tif (r)\n+\tif (r) {\n \t\tr->hook_jobs = cb_data.jobs;\n+\t\tr->event_jobs = cb_data.event_jobs;\n+\t}\n \n \tstrmap_clear(&cb_data.commands, 1);\n \tstrmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */\n@@ -587,6 +604,7 @@ static void warn_non_parallel_hooks_override(unsigned int jobs,\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n+\t\t\t\t  const char *hook_name,\n \t\t\t\t  struct string_list *hook_list)\n {\n \t/*\n@@ -606,16 +624,34 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t */\n \toptions->jobs = 1;\n \tif (r) {\n-\t\tif (r->gitdir && r->hook_config_cache && r->hook_jobs)\n-\t\t\toptions->jobs = r->hook_jobs;\n-\t\telse\n+\t\tif (r->gitdir && r->hook_config_cache) {\n+\t\t\tvoid *event_jobs;\n+\n+\t\t\tif (r->hook_jobs)\n+\t\t\t\toptions->jobs = r->hook_jobs;\n+\n+\t\t\tevent_jobs = strmap_get(&r->event_jobs, hook_name);\n+\t\t\tif (event_jobs)\n+\t\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n+\t\t} else {\n+\t\t\tunsigned int event_jobs;\n+\t\t\tchar *key;\n+\n \t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\n+\t\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n+\t\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n+\t\t\t\toptions->jobs = event_jobs;\n+\t\t\tfree(key);\n+\t\t}\n \t}\n \n \t/*\n \t * Cap to serial any configured hook not marked as parallel = true.\n \t * This enforces the parallel = false default, even for \"traditional\"\n \t * hooks from the hookdir which cannot be marked parallel = true.\n+\t * The same restriction applies whether jobs came from hook.jobs or\n+\t * hook.<event>.jobs.\n \t */\n \tfor (size_t i = 0; i < hook_list->nr; i++) {\n \t\tstruct hook *h = hook_list->items[i].util;\n@@ -642,7 +678,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t.options = options,\n \t};\n \tint ret = 0;\n-\tunsigned int jobs = get_hook_jobs(r, options, hook_list);\n+\tunsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list);\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n \t\t.tr2_label = hook_name,\ndiff --git a/repository.c b/repository.c\nindex 192d6dc9c4..4030db4460 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -426,6 +426,7 @@ void repo_clear(struct repository *repo)\n \t\thook_cache_clear(repo->hook_config_cache);\n \t\tFREE_AND_NULL(repo->hook_config_cache);\n \t}\n+\tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n \n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 58e46853d0..6b67ec02e2 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -175,6 +175,9 @@ struct repository {\n \t/* Cached value of hook.jobs config (0 if unset, defaults to serial). */\n \tunsigned int hook_jobs;\n \n+\t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n+\tstruct strmap event_jobs;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex aa37a5181a..24a3c92b6d 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -969,4 +969,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=1 (serial), but per-event override allows parallel.\n+\ttest_config hook.jobs 1 &&\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo parallel >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\ttest_config hook.hook-2.parallel true &&\n+\n+\t# Global hook.jobs=4 allows parallel, but per-event override forces serial.\n+\ttest_config hook.jobs 4 &&\n+\ttest_config hook.test-hook.jobs 1 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n+test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' '\n+\ttest_when_finished \"rm -f sentinel.started sentinel.done hook.order\" &&\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \\\n+\t    \"touch sentinel.started; sleep 2; touch sentinel.done\" &&\n+\t# hook-1 intentionally has no parallel=true\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \\\n+\t    \"$(sentinel_detector sentinel hook.order)\" &&\n+\t# hook-2 also has no parallel=true\n+\n+\t# Per-event jobs=2 but no hook has parallel=true: must still run serially.\n+\ttest_config hook.test-hook.jobs 2 &&\n+\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\techo serial >expect &&\n+\ttest_cmp expect hook.order\n+'\n+\n test_done\n-- \n2.52.0\n\n"},{"id":"541340","messageId":"20260410090608.75283-10-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 09/13] hook: warn when hook.<friendly-name>.jobs is set","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:04Z","receivedAt":"2026-04-10T09:07:26Z","isPatch":true,"body":"Issue a warning when the user confuses the hook process and event\nnamespaces by setting hook.<friendly-name>.jobs.\n\nDetect this by checking whether the name carrying .jobs also has\n.command, .event, or .parallel configured.  Extract is_friendly_name()\nas a helper for this check, to be reused by future per-event config\nhandling.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n hook.c          | 40 ++++++++++++++++++++++++++++++++++++++++\n t/t1800-hook.sh | 30 ++++++++++++++++++++++++++++++\n 2 files changed, 70 insertions(+)\n\ndiff --git a/hook.c b/hook.c\nindex d98b011563..0493993bbe 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -279,6 +279,44 @@ void hook_cache_clear(struct strmap *cache)\n \tstrmap_clear(cache, 0);\n }\n \n+/*\n+ * Return true if `name` is a hook friendly-name, i.e. it has at least one of\n+ * .command, .event, or .parallel configured. These are the reliable clues\n+ * that distinguish a friendly-name from an event name. Note: .enabled is\n+ * deliberately excluded because it can appear under both namespaces.\n+ */\n+static int is_friendly_name(struct hook_all_config_cb *cb, const char *name)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tif (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name))\n+\t\treturn 1;\n+\n+\tstrmap_for_each_entry(&cb->event_hooks, &iter, e) {\n+\t\tif (unsorted_string_list_lookup(e->value, name))\n+\t\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+/* Warn if any name in event_jobs is also a hook friendly-name. */\n+static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tstrmap_for_each_entry(&cb_data->event_jobs, &iter, e) {\n+\t\tif (is_friendly_name(cb_data, e->key))\n+\t\t\twarning(_(\"hook.%s.jobs is set but '%s' looks like a \"\n+\t\t\t\t  \"hook friendly-name, not an event name; \"\n+\t\t\t\t  \"hook.<event>.jobs uses the event name \"\n+\t\t\t\t  \"(e.g. hook.post-receive.jobs), so this \"\n+\t\t\t\t  \"setting will be ignored\"), e->key, e->key);\n+\t}\n+}\n+\n /* Populate `cache` with the complete hook configuration */\n static void build_hook_config_map(struct repository *r, struct strmap *cache)\n {\n@@ -295,6 +333,8 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t/* Parse all configs in one run, capturing hook.* including hook.jobs. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n \n+\twarn_jobs_on_friendly_names(&cb_data);\n+\n \t/* Construct the cache from parsed configs. */\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstruct string_list *hook_names = e->value;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 24a3c92b6d..89fedc48ff 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1028,4 +1028,34 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true'\n \ttest_cmp expect hook.order\n '\n \n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' '\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .event' '\n+\ttest_config hook.my-hook.event test-hook &&\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.jobs warns when name has .parallel' '\n+\ttest_config hook.my-hook.event test-hook &&\n+\ttest_config hook.my-hook.command \"true\" &&\n+\ttest_config hook.my-hook.parallel true &&\n+\ttest_config hook.my-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.my-hook.jobs.*friendly-name\" err\n+'\n+\n+test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n+\ttest_config hook.test-hook.jobs 2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep ! \"friendly-name\" err\n+'\n+\n test_done\n-- \n2.52.0\n\n"},{"id":"541341","messageId":"20260410090608.75283-11-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 10/13] hook: move is_known_hook() to hook.c for wider use","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:05Z","receivedAt":"2026-04-10T09:07:31Z","isPatch":true,"body":"Move is_known_hook() from builtin/hook.c (static) into hook.c and\nexport it via hook.h so it can be reused.\n\nMake it return bool and the iterator `h` for clarity (iterate hooks).\n\nBoth meson.build and the Makefile are updated to reflect that the\nheader is now used by libgit, not the builtin sources.\n\nThe next commit will use this to reject hook friendly-names that\ncollide with known event names.\n\nCo-authored-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Makefile       |  2 +-\n builtin/hook.c | 10 ----------\n hook.c         | 10 ++++++++++\n hook.h         |  6 ++++++\n meson.build    | 24 ++++++++++++------------\n 5 files changed, 29 insertions(+), 23 deletions(-)\n\ndiff --git a/Makefile b/Makefile\nindex 5d22394c2e..c4e83823e4 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -2675,7 +2675,7 @@ git$X: git.o GIT-LDFLAGS $(BUILTIN_OBJS) $(GITLIBS)\n \n help.sp help.s help.o: command-list.h\n builtin/bugreport.sp builtin/bugreport.s builtin/bugreport.o: hook-list.h\n-builtin/hook.sp builtin/hook.s builtin/hook.o: hook-list.h\n+hook.sp hook.s hook.o: hook-list.h\n \n builtin/help.sp builtin/help.s builtin/help.o: config-list.h GIT-PREFIX\n builtin/help.sp builtin/help.s builtin/help.o: EXTRA_CPPFLAGS = \\\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex bea0668b47..1839412dca 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -4,7 +4,6 @@\n #include \"environment.h\"\n #include \"gettext.h\"\n #include \"hook.h\"\n-#include \"hook-list.h\"\n #include \"parse-options.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n@@ -13,15 +12,6 @@\n #define BUILTIN_HOOK_LIST_USAGE \\\n \tN_(\"git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>\")\n \n-static int is_known_hook(const char *name)\n-{\n-\tconst char **p;\n-\tfor (p = hook_name_list; *p; p++)\n-\t\tif (!strcmp(*p, name))\n-\t\t\treturn 1;\n-\treturn 0;\n-}\n-\n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n \tBUILTIN_HOOK_LIST_USAGE,\ndiff --git a/hook.c b/hook.c\nindex 0493993bbe..19076f8f2b 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -5,6 +5,7 @@\n #include \"environment.h\"\n #include \"gettext.h\"\n #include \"hook.h\"\n+#include \"hook-list.h\"\n #include \"parse.h\"\n #include \"path.h\"\n #include \"run-command.h\"\n@@ -12,6 +13,15 @@\n #include \"strbuf.h\"\n #include \"strmap.h\"\n \n+bool is_known_hook(const char *name)\n+{\n+\tconst char **h;\n+\tfor (h = hook_name_list; *h; h++)\n+\t\tif (!strcmp(*h, name))\n+\t\t\treturn true;\n+\treturn false;\n+}\n+\n const char *find_hook(struct repository *r, const char *name)\n {\n \tstatic struct strbuf path = STRBUF_INIT;\ndiff --git a/hook.h b/hook.h\nindex 01db4226a6..5a93f56618 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -234,6 +234,12 @@ void hook_free(void *p, const char *str);\n  */\n void hook_cache_clear(struct strmap *cache);\n \n+/**\n+ * Returns true if `name` is a recognized hook event name\n+ * (e.g. \"pre-commit\", \"post-receive\").\n+ */\n+bool is_known_hook(const char *name);\n+\n /**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\ndiff --git a/meson.build b/meson.build\nindex 8309942d18..f438d5545d 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -563,6 +563,18 @@ libgit_sources += custom_target(\n   env: script_environment,\n )\n \n+libgit_sources += custom_target(\n+  input: 'Documentation/githooks.adoc',\n+  output: 'hook-list.h',\n+  command: [\n+    shell,\n+    meson.current_source_dir() + '/tools/generate-hooklist.sh',\n+    meson.current_source_dir(),\n+    '@OUTPUT@',\n+  ],\n+  env: script_environment,\n+)\n+\n builtin_sources = [\n   'builtin/add.c',\n   'builtin/am.c',\n@@ -739,18 +751,6 @@ builtin_sources += custom_target(\n   env: script_environment,\n )\n \n-builtin_sources += custom_target(\n-  input: 'Documentation/githooks.adoc',\n-  output: 'hook-list.h',\n-  command: [\n-    shell,\n-    meson.current_source_dir() + '/tools/generate-hooklist.sh',\n-    meson.current_source_dir(),\n-    '@OUTPUT@',\n-  ],\n-  env: script_environment,\n-)\n-\n # This contains the variables for GIT-BUILD-OPTIONS, which we use to propagate\n # build options to our tests.\n build_options_config = configuration_data()\n-- \n2.52.0\n\n"},{"id":"541342","messageId":"20260410090608.75283-12-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 11/13] hook: add hook.<event>.enabled switch","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:06Z","receivedAt":"2026-04-10T09:07:38Z","isPatch":true,"body":"Add a hook.<event>.enabled config key that disables all hooks for\na given event, when set to false, acting as a high-level switch\nabove the existing per-hook hook.<friendly-name>.enabled.\n\nEvent-disabled hooks are shown in \"git hook list\" with an\n\"event-disabled\" tab-separated prefix before the name:\n\n$ git hook list test-hook\nevent-disabled  hook-1\nevent-disabled  hook-2\n\nWith --show-scope:\n\n$ git hook list --show-scope test-hook\nlocal   event-disabled  hook-1\n\nWhen a hook is both per-hook disabled and event-disabled, only\n\"event-disabled\" is shown: the event-level switch is the more\nrelevant piece of information, and the per-hook \"disabled\" status\nwill surface once the event is re-enabled.\n\nUsing an event name as a friendly-name (e.g. hook.<event>.enabled)\ncan cause ambiguity, so a fatal error is issued when using a known\nevent name and a warning is issued for unknown event name, since\na collision cannot be detected with certainty for unknown events.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc | 20 ++++++++\n builtin/hook.c                 | 20 +++++---\n hook.c                         | 47 +++++++++++++++++--\n hook.h                         |  1 +\n repository.c                   |  1 +\n repository.h                   |  4 ++\n t/t1800-hook.sh                | 83 ++++++++++++++++++++++++++++++++++\n 7 files changed, 165 insertions(+), 11 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex d4fa29d936..e0db3afa19 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -15,6 +15,12 @@ hook.<friendly-name>.event::\n \tevents, specify the key more than once. An empty value resets\n \tthe list of events, clearing any previously defined events for\n \t`hook.<friendly-name>`. See linkgit:git-hook[1].\n++\n+The `<friendly-name>` must not be the same as a known hook event name\n+(e.g. do not use `hook.pre-commit.event`). Using a known event name as\n+a friendly-name is a fatal error because it creates an ambiguity with\n+`hook.<event>.enabled` and `hook.<event>.jobs`. For unknown event names,\n+a warning is issued when `<friendly-name>` matches the event value.\n \n hook.<friendly-name>.enabled::\n \tWhether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.\n@@ -33,6 +39,20 @@ hook.<friendly-name>.parallel::\n \tfound in the hooks directory do not need to, and run in parallel when\n \tthe effective job count is greater than 1. See linkgit:git-hook[1].\n \n+hook.<event>.enabled::\n+\tSwitch to enable or disable all hooks for the `<event>` hook event.\n+\tWhen set to `false`, no hooks fire for that event, regardless of any\n+\tper-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`.\n+\tSee linkgit:git-hook[1].\n++\n+Note on naming: `<event>` must be the event name (e.g. `pre-commit`),\n+not a hook friendly-name. Since using a known event name as a\n+friendly-name is disallowed (see `hook.<friendly-name>.event` above),\n+there is no ambiguity between event-level and per-hook `.enabled`\n+settings for known events. For unknown events, if a friendly-name\n+matches the event name despite the warning, `.enabled` is treated\n+as per-hook only.\n+\n hook.<event>.jobs::\n \tSpecifies how many hooks can be run simultaneously for the `<event>`\n \thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 1839412dca..8e47e22e2a 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -87,14 +87,22 @@ static int list(int argc, const char **argv, const char *prefix,\n \t\t\tconst char *name = h->u.configured.friendly_name;\n \t\t\tconst char *scope = show_scope ?\n \t\t\t\tconfig_scope_name(h->u.configured.scope) : NULL;\n+\t\t\t/*\n+\t\t\t * Show the most relevant disable reason. Event-level\n+\t\t\t * takes precedence: if the whole event is off, that\n+\t\t\t * is what the user needs to know. The per-hook\n+\t\t\t * \"disabled\" surfaces once the event is re-enabled.\n+\t\t\t */\n+\t\t\tconst char *disability =\n+\t\t\t\th->u.configured.event_disabled ? \"event-disabled\\t\" :\n+\t\t\t\th->u.configured.disabled       ? \"disabled\\t\"       :\n+\t\t\t\t\t\t\t\t \"\";\n \t\t\tif (scope)\n-\t\t\t\tprintf(\"%s\\t%s%s%c\", scope,\n-\t\t\t\t       h->u.configured.disabled ? \"disabled\\t\" : \"\",\n-\t\t\t\t       name, line_terminator);\n+\t\t\t\tprintf(\"%s\\t%s%s%c\", scope, disability, name,\n+\t\t\t\t       line_terminator);\n \t\t\telse\n-\t\t\t\tprintf(\"%s%s%c\",\n-\t\t\t\t       h->u.configured.disabled ? \"disabled\\t\" : \"\",\n-\t\t\t\t       name, line_terminator);\n+\t\t\t\tprintf(\"%s%s%c\", disability, name,\n+\t\t\t\t       line_terminator);\n \t\t\tbreak;\n \t\t}\n \t\tdefault:\ndiff --git a/hook.c b/hook.c\nindex 19076f8f2b..bc990d4ed4 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -133,7 +133,9 @@ struct hook_config_cache_entry {\n  * Callback struct to collect all hook.* keys in a single config pass.\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n- * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n+ * disabled_hooks: set of all names with hook.<name>.enabled = false; after\n+ *                 parsing, names that are not friendly-names become event-level\n+ *                 disables stored in r->disabled_events. This collects all.\n  * parallel_hooks: friendly-name to parallel flag.\n  * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).\n  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).\n@@ -189,8 +191,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\tstrmap_for_each_entry(&data->event_hooks, &iter, e)\n \t\t\t\tunsorted_string_list_remove(e->value, hook_name, 0);\n \t\t} else {\n-\t\t\tstruct string_list *hooks =\n-\t\t\t\tstrmap_get(&data->event_hooks, value);\n+\t\t\tstruct string_list *hooks;\n+\n+\t\t\tif (is_known_hook(hook_name))\n+\t\t\t\tdie(_(\"hook friendly-name '%s' collides with \"\n+\t\t\t\t      \"a known event name; please choose a \"\n+\t\t\t\t      \"different friendly-name\"),\n+\t\t\t\t    hook_name);\n+\n+\t\t\tif (!strcmp(hook_name, value))\n+\t\t\t\twarning(_(\"hook friendly-name '%s' is the \"\n+\t\t\t\t\t  \"same as its event; this may cause \"\n+\t\t\t\t\t  \"ambiguity with hook.%s.enabled\"),\n+\t\t\t\t\thook_name, hook_name);\n+\n+\t\t\thooks = strmap_get(&data->event_hooks, value);\n \n \t\t\tif (!hooks) {\n \t\t\t\tCALLOC_ARRAY(hooks, 1);\n@@ -345,6 +360,22 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \n \twarn_jobs_on_friendly_names(&cb_data);\n \n+\t/*\n+\t * Populate disabled_events: names in disabled_hooks that are not\n+\t * friendly-names are event-level switches (hook.<event>.enabled = false).\n+\t * Names that are friendly-names are already handled per-hook via the\n+\t * hook_config_cache_entry.disabled flag below.\n+\t */\n+\tif (r) {\n+\t\tstring_list_clear(&r->disabled_events, 0);\n+\t\tstring_list_init_dup(&r->disabled_events);\n+\t\tfor (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) {\n+\t\t\tconst char *n = cb_data.disabled_hooks.items[i].string;\n+\t\t\tif (!is_friendly_name(&cb_data, n))\n+\t\t\t\tstring_list_append(&r->disabled_events, n);\n+\t\t}\n+\t}\n+\n \t/* Construct the cache from parsed configs. */\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstruct string_list *hook_names = e->value;\n@@ -446,6 +477,8 @@ static void list_hooks_add_configured(struct repository *r,\n {\n \tstruct strmap *cache = get_hook_config_cache(r);\n \tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n+\tbool event_is_disabled = r ? !!unsorted_string_list_lookup(&r->disabled_events,\n+\t\t\t\t\t\t\t\t   hookname) : 0;\n \n \t/* Iterate through configured hooks and initialize internal states */\n \tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n@@ -472,6 +505,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n \t\thook->u.configured.disabled = entry->disabled;\n+\t\thook->u.configured.event_disabled = event_is_disabled;\n \t\thook->parallel = entry->parallel;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n@@ -484,6 +518,8 @@ static void list_hooks_add_configured(struct repository *r,\n \tif (!r || !r->gitdir) {\n \t\thook_cache_clear(cache);\n \t\tfree(cache);\n+\t\tif (r)\n+\t\t\tstring_list_clear(&r->disabled_events, 0);\n \t}\n }\n \n@@ -515,7 +551,7 @@ int hook_exists(struct repository *r, const char *name)\n \tfor (size_t i = 0; i < hooks->nr; i++) {\n \t\tstruct hook *h = hooks->items[i].util;\n \t\tif (h->kind == HOOK_TRADITIONAL ||\n-\t\t    !h->u.configured.disabled) {\n+\t\t    (!h->u.configured.disabled && !h->u.configured.event_disabled)) {\n \t\t\texists = 1;\n \t\t\tbreak;\n \t\t}\n@@ -538,7 +574,8 @@ static int pick_next_hook(struct child_process *cp,\n \t\tif (hook_cb->hook_to_run_index >= hook_list->nr)\n \t\t\treturn 0;\n \t\th = hook_list->items[hook_cb->hook_to_run_index++].util;\n-\t} while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled);\n+\t} while (h->kind == HOOK_CONFIGURED &&\n+\t\t (h->u.configured.disabled || h->u.configured.event_disabled));\n \n \tcp->no_stdin = 1;\n \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\ndiff --git a/hook.h b/hook.h\nindex 5a93f56618..b4372b636f 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -32,6 +32,7 @@ struct hook {\n \t\t\tconst char *command;\n \t\t\tenum config_scope scope;\n \t\t\tbool disabled;\n+\t\t\tbool event_disabled;\n \t\t} configured;\n \t} u;\n \ndiff --git a/repository.c b/repository.c\nindex 4030db4460..db57b8308b 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -427,6 +427,7 @@ void repo_clear(struct repository *repo)\n \t\tFREE_AND_NULL(repo->hook_config_cache);\n \t}\n \tstrmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */\n+\tstring_list_clear(&repo->disabled_events, 0);\n \n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 6b67ec02e2..4969d8b8eb 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -2,6 +2,7 @@\n #define REPOSITORY_H\n \n #include \"strmap.h\"\n+#include \"string-list.h\"\n #include \"repo-settings.h\"\n #include \"environment.h\"\n \n@@ -178,6 +179,9 @@ struct repository {\n \t/* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */\n \tstruct strmap event_jobs;\n \n+\t/* Cached list of event names with hook.<event>.enabled = false. */\n+\tstruct string_list disabled_events;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 89fedc48ff..c4ff25f6b0 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1058,4 +1058,87 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n \ttest_grep ! \"friendly-name\" err\n '\n \n+test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_must_be_empty out\n+'\n+\n+test_expect_success 'hook.<event>.enabled=true does not suppress hooks' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled true &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"ran\" err\n+'\n+\n+test_expect_success 'hook.<event>.enabled=false does not affect other events' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.other-event.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"ran\" err\n+'\n+\n+test_expect_success 'hook.<friendly-name>.enabled=false still disables that hook' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo hook-1\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"echo hook-2\" &&\n+\ttest_config hook.hook-1.enabled false &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep ! \"hook-1\" err &&\n+\ttest_grep \"hook-2\" err\n+'\n+\n+test_expect_success 'git hook list shows event-disabled hooks as event-disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.hook-2.event test-hook &&\n+\ttest_config hook.hook-2.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name test-hook >actual &&\n+\ttest_grep \"^event-disabled\thook-1$\" actual &&\n+\ttest_grep \"^event-disabled\thook-2$\" actual\n+'\n+\n+test_expect_success 'git hook list shows scope with event-disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name --show-scope test-hook >actual &&\n+\ttest_grep \"^local\tevent-disabled\thook-1$\" actual\n+'\n+\n+test_expect_success 'git hook list still shows hooks when event is disabled' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"echo ran\" &&\n+\ttest_config hook.test-hook.enabled false &&\n+\tgit hook list --allow-unknown-hook-name test-hook >actual &&\n+\ttest_grep \"event-disabled\" actual\n+'\n+\n+test_expect_success 'friendly-name matching known event name is rejected' '\n+\ttest_config hook.pre-commit.event pre-commit &&\n+\ttest_config hook.pre-commit.command \"echo oops\" &&\n+\ttest_must_fail git hook run pre-commit 2>err &&\n+\ttest_grep \"collides with a known event name\" err\n+'\n+\n+test_expect_success 'friendly-name matching known event name is rejected even for different event' '\n+\ttest_config hook.pre-commit.event post-commit &&\n+\ttest_config hook.pre-commit.command \"echo oops\" &&\n+\ttest_must_fail git hook run post-commit 2>err &&\n+\ttest_grep \"collides with a known event name\" err\n+'\n+\n+test_expect_success 'friendly-name matching unknown event warns' '\n+\ttest_config hook.test-hook.event test-hook &&\n+\ttest_config hook.test-hook.command \"echo ran\" &&\n+\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\ttest_grep \"same as its event\" err\n+'\n+\n test_done\n-- \n2.52.0\n\n"},{"id":"541343","messageId":"20260410090608.75283-13-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 12/13] hook: allow hook.jobs=-1 to use all available CPU cores","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:07Z","receivedAt":"2026-04-10T09:07:40Z","isPatch":true,"body":"Allow -1 as a value for hook.jobs, hook.<event>.jobs, and the -j\nCLI flag to mean \"use as many jobs as there are CPU cores\", matching\nthe convention used by fetch.parallel and other Git subsystems.\n\nThe value is resolved to online_cpus() at parse time so the rest\nof the code always works with a positive resolved count.\n\nOther non-positive values (0, -2, etc) are rejected with a warning\n(config) or die (CLI).\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 ++-\n builtin/hook.c                 | 15 +++++++--\n hook.c                         | 60 ++++++++++++++++++++++++----------\n t/t1800-hook.sh                | 49 +++++++++++++++++++++++++++\n 4 files changed, 108 insertions(+), 20 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex e0db3afa19..a9dc0063c1 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -58,7 +58,8 @@ hook.<event>.jobs::\n \thook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`\n \tfor this specific event. The same parallelism restrictions apply: this\n \tsetting has no effect unless all configured hooks for the event have\n-\t`hook.<friendly-name>.parallel` set to `true`. Must be a positive int,\n+\t`hook.<friendly-name>.parallel` set to `true`. Set to `-1` to use the\n+\tnumber of available CPU cores. Must be a positive integer or `-1`;\n \tzero is rejected with a warning. See linkgit:git-hook[1].\n +\n Note on naming: although this key resembles `hook.<friendly-name>.*`\n@@ -74,6 +75,7 @@ valid event name when setting `hook.<event>.jobs`.\n hook.jobs::\n \tSpecifies how many hooks can be run simultaneously during parallelized\n \thook execution. If unspecified, defaults to 1 (serial execution).\n+\tSet to `-1` to use the number of available CPU cores.\n \tCan be overridden on a per-event basis with `hook.<event>.jobs`.\n \tSome hooks always run sequentially regardless of this setting because\n \tthey operate on shared data and cannot safely be parallelized:\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 8e47e22e2a..cceeb3586e 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -5,6 +5,7 @@\n #include \"gettext.h\"\n #include \"hook.h\"\n #include \"parse-options.h\"\n+#include \"thread-utils.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\\n\" \\\n@@ -123,6 +124,7 @@ static int run(int argc, const char **argv, const char *prefix,\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n \tint ignore_missing = 0;\n \tint allow_unknown = 0;\n+\tint jobs = 0;\n \tconst char *hook_name;\n \tstruct option run_options[] = {\n \t\tOPT_BOOL(0, \"allow-unknown-hook-name\", &allow_unknown,\n@@ -131,8 +133,8 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t N_(\"silently ignore missing requested <hook-name>\")),\n \t\tOPT_STRING(0, \"to-stdin\", &opt.path_to_stdin, N_(\"path\"),\n \t\t\t   N_(\"file to read into hooks' stdin\")),\n-\t\tOPT_UNSIGNED('j', \"jobs\", &opt.jobs,\n-\t\t\t    N_(\"run up to <n> hooks simultaneously\")),\n+\t\tOPT_INTEGER('j', \"jobs\", &jobs,\n+\t\t\t    N_(\"run up to <n> hooks simultaneously (-1 for CPU count)\")),\n \t\tOPT_END(),\n \t};\n \tint ret;\n@@ -141,6 +143,15 @@ static int run(int argc, const char **argv, const char *prefix,\n \t\t\t     builtin_hook_run_usage,\n \t\t\t     PARSE_OPT_KEEP_DASHDASH);\n \n+\tif (jobs == -1)\n+\t\topt.jobs = online_cpus();\n+\telse if (jobs < 0)\n+\t\tdie(_(\"invalid value for -j: %d\"\n+\t\t     \" (use -1 for CPU count or a\"\n+\t\t     \" positive integer)\"), jobs);\n+\telse\n+\t\topt.jobs = jobs;\n+\n \tif (!argc)\n \t\tgoto usage;\n \ndiff --git a/hook.c b/hook.c\nindex bc990d4ed4..d10eef4763 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -12,6 +12,7 @@\n #include \"setup.h\"\n #include \"strbuf.h\"\n #include \"strmap.h\"\n+#include \"thread-utils.h\"\n \n bool is_known_hook(const char *name)\n {\n@@ -165,13 +166,17 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t/* Handle plain hook.<key> entries that have no hook name component. */\n \tif (!name) {\n \t\tif (!strcmp(subkey, \"jobs\") && value) {\n-\t\t\tunsigned int v;\n-\t\t\tif (!git_parse_uint(value, &v))\n-\t\t\t\twarning(_(\"hook.jobs must be a positive integer, ignoring: '%s'\"), value);\n-\t\t\telse if (!v)\n-\t\t\t\twarning(_(\"hook.jobs must be positive, ignoring: 0\"));\n-\t\t\telse\n+\t\t\tint v;\n+\t\t\tif (!git_parse_int(value, &v))\n+\t\t\t\twarning(_(\"hook.jobs must be an integer, ignoring: '%s'\"), value);\n+\t\t\telse if (v == -1)\n+\t\t\t\tdata->jobs = online_cpus();\n+\t\t\telse if (v > 0)\n \t\t\t\tdata->jobs = v;\n+\t\t\telse\n+\t\t\t\twarning(_(\"hook.jobs must be a positive integer\"\n+\t\t\t\t\t  \" or -1, ignoring: '%s'\"),\n+\t\t\t\t\tvalue);\n \t\t}\n \t\treturn 0;\n \t}\n@@ -259,17 +264,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n \t} else if (!strcmp(subkey, \"jobs\")) {\n-\t\tunsigned int v;\n-\t\tif (!git_parse_uint(value, &v))\n-\t\t\twarning(_(\"hook.%s.jobs must be a positive integer,\"\n+\t\tint v;\n+\t\tif (!git_parse_int(value, &v))\n+\t\t\twarning(_(\"hook.%s.jobs must be an integer,\"\n \t\t\t\t  \" ignoring: '%s'\"),\n \t\t\t\thook_name, value);\n-\t\telse if (!v)\n-\t\t\twarning(_(\"hook.%s.jobs must be positive,\"\n-\t\t\t\t  \" ignoring: 0\"), hook_name);\n-\t\telse\n+\t\telse if (v == -1)\n+\t\t\tstrmap_put(&data->event_jobs, hook_name,\n+\t\t\t\t   (void *)(uintptr_t)online_cpus());\n+\t\telse if (v > 0)\n \t\t\tstrmap_put(&data->event_jobs, hook_name,\n \t\t\t\t   (void *)(uintptr_t)v);\n+\t\telse\n+\t\t\twarning(_(\"hook.%s.jobs must be a positive\"\n+\t\t\t\t  \" integer or -1, ignoring: '%s'\"),\n+\t\t\t\thook_name, value);\n \t}\n \n \tfree(hook_name);\n@@ -688,6 +697,25 @@ static void warn_non_parallel_hooks_override(unsigned int jobs,\n \t}\n }\n \n+/* Resolve a hook.jobs config key, handling -1 as online_cpus(). */\n+static void resolve_hook_config_jobs(struct repository *r,\n+\t\t\t\t     const char *key,\n+\t\t\t\t     unsigned int *jobs)\n+{\n+\tint v;\n+\n+\tif (repo_config_get_int(r, key, &v))\n+\t\treturn;\n+\n+\tif (v == -1)\n+\t\t*jobs = online_cpus();\n+\telse if (v > 0)\n+\t\t*jobs = v;\n+\telse\n+\t\twarning(_(\"%s must be a positive integer or -1,\"\n+\t\t\t  \" ignoring: %d\"), key, v);\n+}\n+\n /* Determine how many jobs to use for hook execution. */\n static unsigned int get_hook_jobs(struct repository *r,\n \t\t\t\t  struct run_hooks_opt *options,\n@@ -721,14 +749,12 @@ static unsigned int get_hook_jobs(struct repository *r,\n \t\t\tif (event_jobs)\n \t\t\t\toptions->jobs = (unsigned int)(uintptr_t)event_jobs;\n \t\t} else {\n-\t\t\tunsigned int event_jobs;\n \t\t\tchar *key;\n \n-\t\t\trepo_config_get_uint(r, \"hook.jobs\", &options->jobs);\n+\t\t\tresolve_hook_config_jobs(r, \"hook.jobs\", &options->jobs);\n \n \t\t\tkey = xstrfmt(\"hook.%s.jobs\", hook_name);\n-\t\t\tif (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)\n-\t\t\t\toptions->jobs = event_jobs;\n+\t\t\tresolve_hook_config_jobs(r, key, &options->jobs);\n \t\t\tfree(key);\n \t\t}\n \t}\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex c4ff25f6b0..41b2b2c746 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1058,6 +1058,55 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' '\n \ttest_grep ! \"friendly-name\" err\n '\n \n+test_expect_success 'hook.jobs=-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\ttest_config hook.jobs -1 &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'hook.<event>.jobs=-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\ttest_config hook.test-hook.jobs -1 &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'git hook run -j-1 resolves to online_cpus()' '\n+\ttest_config hook.hook-1.event test-hook &&\n+\ttest_config hook.hook-1.command \"true\" &&\n+\ttest_config hook.hook-1.parallel true &&\n+\n+\tcpus=$(test-tool online-cpus) &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace.txt\" \\\n+\t\tgit hook run --allow-unknown-hook-name -j-1 test-hook >out 2>err &&\n+\tgrep \"\\\"region_enter\\\".*\\\"hook\\\".*\\\"test-hook\\\".*\\\"max:$cpus\\\"\" trace.txt\n+'\n+\n+test_expect_success 'hook.jobs rejects values less than -1' '\n+\ttest_config hook.jobs -2 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.jobs must be a positive integer or -1\" err\n+'\n+\n+test_expect_success 'hook.<event>.jobs rejects values less than -1' '\n+\ttest_config hook.test-hook.jobs -5 &&\n+\tgit hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&\n+\ttest_grep \"hook.test-hook.jobs must be a positive integer or -1\" err\n+'\n+\n test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '\n \ttest_config hook.hook-1.event test-hook &&\n \ttest_config hook.hook-1.command \"echo ran\" &&\n-- \n2.52.0\n\n"},{"id":"541344","messageId":"20260410090608.75283-14-adrian.ratiu@collabora.com","threadId":"64919","inReplyTo":"20260410090608.75283-1-adrian.ratiu@collabora.com","subject":"[PATCH v7 13/13] t1800: test SIGPIPE with parallel hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-10T09:06:08Z","receivedAt":"2026-04-10T09:07:41Z","isPatch":true,"body":"From: Jeff King <peff@peff.net>\n\nWe recently fixed a bug in commit 2226ffaacd (run_processes_parallel():\nfix order of sigpipe handling, 2026-04-08) where a hook that caused us\nto get SIGPIPE would accidentally trigger the run_processes_parallel()\ncleanup handler killing the child processes.\n\nFor a single hook, this meant killing the already-exited hook. This case\nwas triggered by our tests, but was only a problem on some platforms.\n\nBut if you have multiple hooks running in parallel, this causes a\nproblem everywhere, since one hook failing to read its input would take\ndown all hooks. Now that we have parallel hook support, we can add a\ntest for this case. It should pass already, due to the existing fix.\n\nSigned-off-by: Jeff King <peff@peff.net>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n t/t1800-hook.sh | 38 ++++++++++++++++++++++++++++++++++++++\n 1 file changed, 38 insertions(+)\n\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 41b2b2c746..0132e772e4 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1190,4 +1190,42 @@ test_expect_success 'friendly-name matching unknown event warns' '\n \ttest_grep \"same as its event\" err\n '\n \n+test_expect_success 'hooks in parallel that do not read input' '\n+\t# Add this to our $PATH to avoid having to write the whole trash\n+\t# directory into our config options, which would require quoting.\n+\tmkdir bin &&\n+\tPATH=$PWD/bin:$PATH &&\n+\n+\twrite_script bin/hook-fast <<-\\EOF &&\n+\t# This hook does not read its input, so the parent process\n+\t# may see SIGPIPE if it is not ignored. It should happen\n+\t# relatively quickly.\n+\texit 0\n+\tEOF\n+\n+\twrite_script bin/hook-slow <<-\\EOF &&\n+\t# This hook is slow, so we expect it to still be running\n+\t# when the other hook has exited (and the parent has a pipe error\n+\t# writing to it).\n+\t#\n+\t# So we want to be slow enough that we expect this to happen, but not\n+\t# so slow that the test takes forever. 1 second is probably enough\n+\t# in practice (and if it is occasionally not on a loaded system, we\n+\t# will err on the side of having the test pass).\n+\tsleep 1\n+\texit 0\n+\tEOF\n+\n+\tgit init --bare parallel.git &&\n+\tgit -C parallel.git config hook.fast.command \"hook-fast\" &&\n+\tgit -C parallel.git config hook.fast.event pre-receive &&\n+\tgit -C parallel.git config hook.fast.parallel true &&\n+\tgit -C parallel.git config hook.slow.command \"hook-slow\" &&\n+\tgit -C parallel.git config hook.slow.event pre-receive &&\n+\tgit -C parallel.git config hook.slow.parallel true &&\n+\tgit -C parallel.git config hook.jobs 2 &&\n+\n+\tgit push ./parallel.git \"+refs/heads/*:refs/heads/*\"\n+'\n+\n test_done\n-- \n2.52.0\n\n"}]}