* [PATCH 0/4] Run hooks in parallel
@ 2026-02-04 17:33 Adrian Ratiu
2026-02-04 17:33 ` [PATCH 1/4] config: add a repo_config_get_uint() helper Adrian Ratiu
` (8 more replies)
0 siblings, 9 replies; 83+ messages in thread
From: Adrian Ratiu @ 2026-02-04 17:33 UTC (permalink / raw)
To: git
Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu
Hello everyone,
This enables running hook commands in parallel and is based on the patch
series enabling config hooks [1], which added the ability to run a list
of hooks for each hook event.
For context, hooks used to run sequentially due to hardcoded .jobs == 1
in hook.c, leading to .processes == 1 in run-command.c. We're removing
that restriction for hooks known to be safe to parallelize.
The parallelism enabled here is to run multiple hook commands/scripts
in parallel for a single event, for example the pre-push event might
trigger linters / spell checkers / unit tests to run at the same time.
Another kind of parallelism is to split the hook input to multiple
child processes, running the same command in parallel on subsets of
the hook input. This series does not do that. It might be a future
addition on top of this, since it's kind of a lower-level parallelism.
The pre-push hook is special because it is the only known hook to break
backward compatibility when running in parallel, due to run-command
collating its outputs via a pipe, so I added an extension for it.
Users can opt-in to this extension with a runtime config.
Suggestions for alternative solutions to the extension are welcome.
Again, this is based on the latest v1 config hooks series [1] which
has not yet landed in next or master.
Branch pushed to GitHub containing all dependency patches: [2]
Successful CI run: [3]
Many thanks to all who contributed to this effort up to now, including
Emily, AEvar, Junio, Patrick, Peff and many others.
Thank you,
Adrian
1: https://lore.kernel.org/git/20260204165126.1548805-1-adrian.ratiu@collabora.com/T/#mdb138a39d332f234bc9068b7f4e05b10c400e572
2: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/parallel-hooks-v1
3: https://github.com/10ne1/git/actions/runs/21680184456
Adrian Ratiu (3):
config: add a repo_config_get_uint() helper
hook: introduce extensions.hookStdoutToStderr
hook: allow runtime enabling extensions.hookStdoutToStderr
Emily Shaffer (1):
hook: allow parallel hook execution
Documentation/config/extensions.adoc | 15 ++
Documentation/config/hook.adoc | 14 ++
Documentation/git-hook.adoc | 14 +-
builtin/am.c | 10 +-
builtin/checkout.c | 13 +-
builtin/clone.c | 6 +-
builtin/hook.c | 7 +-
builtin/receive-pack.c | 9 +-
builtin/worktree.c | 2 +-
commit.c | 2 +-
config.c | 28 +++
config.h | 13 ++
hook.c | 51 ++++-
hook.h | 20 +-
parse.c | 9 +
parse.h | 1 +
refs.c | 2 +-
repository.c | 1 +
repository.h | 1 +
sequencer.c | 4 +-
setup.c | 17 ++
setup.h | 1 +
t/t1800-hook.sh | 270 ++++++++++++++++++++++++++-
transport.c | 9 +-
24 files changed, 476 insertions(+), 43 deletions(-)
--
2.52.0.732.gb351b5166d.dirty
^ permalink raw reply [flat|nested] 83+ messages in thread* [PATCH 1/4] config: add a repo_config_get_uint() helper 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu @ 2026-02-04 17:33 ` Adrian Ratiu 2026-02-04 17:33 ` [PATCH 2/4] hook: allow parallel hook execution Adrian Ratiu ` (7 subsequent siblings) 8 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-04 17:33 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu Next commit adds a 'hook.jobs' config option of type 'unsigned int', so add a helper to parse it since the API only supports int and ulong. An alternative is to make 'hook.jobs' an 'int' or parse it as an 'int' then cast it to unsigned, however it's better to use proper helpers for the type. Using 'ulong' is another option which already has helpers, but it's a bit excessive in size for just the jobs number. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- config.c | 28 ++++++++++++++++++++++++++++ config.h | 13 +++++++++++++ parse.c | 9 +++++++++ parse.h | 1 + 4 files changed, 51 insertions(+) diff --git a/config.c b/config.c index 7f6d53b473..f842c31798 100644 --- a/config.c +++ b/config.c @@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value, return ret; } +unsigned int git_config_uint(const char *name, const char *value, + const struct key_value_info *kvi) +{ + unsigned int ret; + if (!git_parse_uint(value, &ret)) + die_bad_number(name, value, kvi); + return ret; +} + int64_t git_config_int64(const char *name, const char *value, const struct key_value_info *kvi) { @@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest) return 1; } +int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest) +{ + const char *value; + struct key_value_info kvi; + + if (!git_configset_get_value(set, key, &value, &kvi)) { + *dest = git_config_uint(key, value, &kvi); + return 0; + } else + return 1; +} + int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest) { const char *value; @@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo, return git_configset_get_int(repo->config, key, dest); } +int repo_config_get_uint(struct repository *repo, + const char *key, unsigned int *dest) +{ + git_config_check_init(repo); + return git_configset_get_uint(repo->config, key, dest); +} + int repo_config_get_ulong(struct repository *repo, const char *key, unsigned long *dest) { diff --git a/config.h b/config.h index ba426a960a..bf47fb3afc 100644 --- a/config.h +++ b/config.h @@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *); int64_t git_config_int64(const char *, const char *, const struct key_value_info *); +/** + * Identical to `git_config_int`, but for unsigned ints. + */ +unsigned int git_config_uint(const char *, const char *, + const struct key_value_info *); + /** * Identical to `git_config_int`, but for unsigned longs. */ @@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key, int git_configset_get_string(struct config_set *cs, const char *key, char **dest); int git_configset_get_int(struct config_set *cs, const char *key, int *dest); +int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest); int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest); int git_configset_get_bool(struct config_set *cs, const char *key, int *dest); int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest); @@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r, */ int repo_config_get_int(struct repository *r, const char *key, int *dest); +/** + * Similar to `repo_config_get_int` but for unsigned ints. + */ +int repo_config_get_uint(struct repository *r, + const char *key, unsigned int *dest); + /** * Similar to `repo_config_get_int` but for unsigned longs. */ diff --git a/parse.c b/parse.c index 48313571aa..d77f28046a 100644 --- a/parse.c +++ b/parse.c @@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret) return 1; } +int git_parse_uint(const char *value, unsigned int *ret) +{ + uintmax_t tmp; + if (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int))) + return 0; + *ret = tmp; + return 1; +} + int git_parse_ulong(const char *value, unsigned long *ret) { uintmax_t tmp; diff --git a/parse.h b/parse.h index ea32de9a91..a6dd37c4cb 100644 --- a/parse.h +++ b/parse.h @@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max); int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max); int git_parse_ssize_t(const char *, ssize_t *); int git_parse_ulong(const char *, unsigned long *); +int git_parse_uint(const char *value, unsigned int *ret); int git_parse_int(const char *value, int *ret); int git_parse_int64(const char *value, int64_t *ret); int git_parse_double(const char *value, double *ret); -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH 2/4] hook: allow parallel hook execution 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu 2026-02-04 17:33 ` [PATCH 1/4] config: add a repo_config_get_uint() helper Adrian Ratiu @ 2026-02-04 17:33 ` Adrian Ratiu 2026-02-11 12:41 ` Patrick Steinhardt 2026-02-04 17:33 ` [PATCH 3/4] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu ` (6 subsequent siblings) 8 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-02-04 17:33 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> In many cases, there's no reason not to allow hooks to execute in parallel, if more than one was provided. hook.c already calls run_processes_parallel() so all we need to do is allow its job count to be greater than 1. Serial execution is achieved by setting .jobs == 1 at compile time via RUN_HOOKS_OPT_INIT_SERIAL or by setting the 'hook.jobs' config to 1. This matches the behavior prior to this commit. The compile-time 'struct run_hooks_opt.jobs' parameter has the highest priority if non-zero, followed by the 'hook.jobs' user config, then the processor count from online_cpus() is the last fallback. The above ordering ensures hooks unsafe to run in parallel are always executed sequentially (RUN_HOOKS_OPT_INIT_SERIAL) while allowing users to control parallelism with an efficient default. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 5 ++ Documentation/git-hook.adoc | 14 ++- builtin/am.c | 10 ++- builtin/checkout.c | 13 ++- builtin/clone.c | 6 +- builtin/hook.c | 7 +- builtin/receive-pack.c | 9 +- builtin/worktree.c | 2 +- commit.c | 2 +- hook.c | 41 +++++++-- hook.h | 20 ++++- refs.c | 2 +- sequencer.c | 4 +- t/t1800-hook.sh | 154 +++++++++++++++++++++++++++++++-- transport.c | 2 +- 15 files changed, 253 insertions(+), 38 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 49c7ffd82e..c394756328 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -15,3 +15,8 @@ hook.<name>.event:: On the specified event, the associated `hook.<name>.command` will be executed. More than one event can be specified if you wish for `hook.<name>` to execute on multiple events. See linkgit:git-hook[1]. + +hook.jobs:: + Specifies how many hooks can be run simultaneously during parallelized + hook execution. If unspecified, defaults to the number of processors on + the current system. diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc index 5f339dc48b..72c6c6d1ee 100644 --- a/Documentation/git-hook.adoc +++ b/Documentation/git-hook.adoc @@ -8,7 +8,8 @@ git-hook - Run git hooks SYNOPSIS -------- [verse] -'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>] +'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>] + <hook-name> [-- <hook-args>] 'git hook' list <hook-name> DESCRIPTION @@ -128,6 +129,16 @@ OPTIONS tools that want to do a blind one-shot run of a hook that may or may not be present. +-j:: +--jobs:: + Only valid for `run`. ++ +Specify how many hooks to run simultaneously. If this flag is not specified, +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If the +config is not specified, the number of CPUs on the current system is used. Some +hooks may be ineligible for parallelization: for example, 'commit-msg' hooks +typically modify the commit message body and cannot be parallelized. + WRAPPERS -------- @@ -151,6 +162,7 @@ git hook run mywrapper-start-tests \ # providing something to stdin --stdin some-tempfile-123 \ # execute hooks in serial + --jobs 1 \ # plus some arguments of your own... -- \ --testname bar \ diff --git a/builtin/am.c b/builtin/am.c index b66a33d8a8..427e137883 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state) assert(state->msg); - if (!state->no_verify) - ret = run_hooks_l(the_repository, "applypatch-msg", - am_path(state, "final-commit"), NULL); + if (!state->no_verify) { + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL; + strvec_push(&opt.args, am_path(state, "final-commit")); + ret = run_hooks_opt(the_repository, "applypatch-msg", &opt); + } if (!ret) { FREE_AND_NULL(state->msg); @@ -509,7 +511,7 @@ static int run_applypatch_msg_hook(struct am_state *state) */ static int run_post_rewrite_hook(const struct am_state *state) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; strvec_push(&opt.args, "rebase"); opt.path_to_stdin = am_path(state, "rewritten"); diff --git a/builtin/checkout.c b/builtin/checkout.c index 0ba4f03f2e..23833ddfe8 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -31,6 +31,7 @@ #include "resolve-undo.h" #include "revision.h" #include "setup.h" +#include "strvec.h" #include "submodule.h" #include "symlinks.h" #include "trace2.h" @@ -137,13 +138,17 @@ static void branch_info_release(struct branch_info *info) static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit, int changed) { - return run_hooks_l(the_repository, "post-checkout", - oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), - oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), - changed ? "1" : "0", NULL); + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL; + /* "new_commit" can be NULL when checking out from the index before a commit exists. */ + strvec_pushl(&opt.args, + oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), + oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), + changed ? "1" : "0", + NULL); + return run_hooks_opt(the_repository, "post-checkout", &opt); } static int update_some(const struct object_id *oid, struct strbuf *base, diff --git a/builtin/clone.c b/builtin/clone.c index b40cee5968..2c5ec213a5 100644 --- a/builtin/clone.c +++ b/builtin/clone.c @@ -644,6 +644,7 @@ static int checkout(int submodule_progress, int filter_submodules, struct tree *tree; struct tree_desc t; int err = 0; + struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_SERIAL; if (option_no_checkout) return 0; @@ -694,8 +695,9 @@ static int checkout(int submodule_progress, int filter_submodules, if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); - err |= run_hooks_l(the_repository, "post-checkout", oid_to_hex(null_oid(the_hash_algo)), - oid_to_hex(&oid), "1", NULL); + strvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)), + oid_to_hex(&oid), "1", NULL); + err |= run_hooks_opt(the_repository, "post-checkout", &hook_opt); if (!err && (option_recurse_submodules.nr > 0)) { struct child_process cmd = CHILD_PROCESS_INIT; diff --git a/builtin/hook.c b/builtin/hook.c index 4cc6dac45a..cd1f4ebe6a 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -9,7 +9,8 @@ #include "abspath.h" #define BUILTIN_HOOK_RUN_USAGE \ - 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" \ + "<hook-name> [-- <hook-args>]") #define BUILTIN_HOOK_LIST_USAGE \ N_("git hook list <hook-name>") @@ -76,7 +77,7 @@ static int run(int argc, const char **argv, const char *prefix, struct repository *repo UNUSED) { int i; - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; int ignore_missing = 0; const char *hook_name; struct option run_options[] = { @@ -84,6 +85,8 @@ static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested <hook-name>")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), N_("file to read into hooks' stdin")), + OPT_UNSIGNED('j', "jobs", &opt.jobs, + N_("run up to <n> hooks simultaneously")), OPT_END(), }; int ret; diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index 72fde2207c..6ced7b181c 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -925,7 +925,7 @@ static int run_receive_hook(struct command *commands, int skip_broken, const struct string_list *push_options) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; struct command *iter = commands; struct receive_hook_feed_state feed_state; struct async sideband_async; @@ -976,7 +976,7 @@ static int run_receive_hook(struct command *commands, static int run_update_hook(struct command *cmd) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; struct async sideband_async; int sideband_async_started = 0; int saved_stderr = -1; @@ -1455,7 +1455,8 @@ static const char *push_to_checkout(unsigned char *hash, struct strvec *env, const char *work_tree) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL; + opt.invoked_hook = invoked_hook; strvec_pushf(env, "GIT_WORK_TREE=%s", absolute_path(work_tree)); @@ -1670,7 +1671,7 @@ static const char *update(struct command *cmd, struct shallow_info *si) static void run_update_post_hook(struct command *commands) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; struct async sideband_async; struct command *cmd; int sideband_async_started = 0; diff --git a/builtin/worktree.c b/builtin/worktree.c index fbdaf2eb2e..b30719124c 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -574,7 +574,7 @@ static int add_worktree(const char *path, const char *refname, * is_junk is cleared, but do return appropriate code when hook fails. */ if (!ret && opts->checkout && !opts->orphan) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL; strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL); strvec_pushl(&opt.args, diff --git a/commit.c b/commit.c index 28bb5ce029..e58c020a51 100644 --- a/commit.c +++ b/commit.c @@ -1961,7 +1961,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len) int run_commit_hook(int editor_is_used, const char *index_file, int *invoked_hook, const char *name, ...) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_SERIAL; va_list args; const char *arg; diff --git a/hook.c b/hook.c index 9f59ebd0bd..e07e8f4efe 100644 --- a/hook.c +++ b/hook.c @@ -250,6 +250,35 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* + * Determines how many jobs to use for hook execution. + * The priority is as follows: + * 1. Hooks setting jobs=1, either via RUN_HOOKS_OPT_INIT_SERIAL or stdout_to_stderr=0 + * are known to be unsafe to parallelize, so their jobs=1 has precedence. + * 3. The 'hook.jobs' configuration is used if set. + * 4. The number of online CPUs is used as a final fallback. + * Returns: + * The number of jobs to use for parallel execution, or 1 for serial. + */ +static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options) +{ + unsigned int jobs = options->jobs; + + /* + * Hooks which configure stdout_to_stderr=0 (like pre-push), expect separate + * output streams. Unless extensions.StdoutToStderr is enabled (which forces + * stdout_to_stderr=1), the hook must run sequentially to guarantee output is + * non-interleaved. + */ + if (!options->stdout_to_stderr) + jobs = 1; + + if (!jobs && repo_config_get_uint(r, "hook.jobs", &jobs)) + jobs = online_cpus(); /* fallback if config is unset */ + + return jobs; +} + int run_hooks_opt(struct repository *r, const char *hook_name, struct run_hooks_opt *options) { @@ -262,12 +291,13 @@ int run_hooks_opt(struct repository *r, const char *hook_name, .repository = r, }; int ret = 0; + unsigned int jobs = get_hook_jobs(r, options); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, - .processes = options->jobs, - .ungroup = options->jobs == 1, + .processes = jobs, + .ungroup = jobs == 1, .get_next_task = pick_next_hook, .start_failure = notify_start_failure, @@ -283,9 +313,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->path_to_stdin && options->feed_pipe) BUG("options path_to_stdin and feed_pipe are mutually exclusive"); - if (!options->jobs) - BUG("run_hooks_opt must be called with options.jobs >= 1"); - /* * Ensure cb_data copy and free functions are either provided together, * or neither one is provided. @@ -337,14 +364,14 @@ int run_hooks_opt(struct repository *r, const char *hook_name, int run_hooks(struct repository *r, const char *hook_name) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; return run_hooks_opt(r, hook_name, &opt); } int run_hooks_l(struct repository *r, const char *hook_name, ...) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; va_list ap; const char *arg; diff --git a/hook.h b/hook.h index cdbe5a9167..3a579c19db 100644 --- a/hook.h +++ b/hook.h @@ -22,6 +22,8 @@ struct run_hooks_opt * * If > 1, output will be buffered and de-interleaved (ungroup=0). * If == 1, output will be real-time (ungroup=1). + * If == 0, the 'hook.jobs' config is used or, if the config is unset, + * the number of online cpus on the system. */ unsigned int jobs; @@ -111,13 +113,29 @@ struct run_hooks_opt void (*free_feed_pipe_cb_data)(void *data); }; -#define RUN_HOOKS_OPT_INIT { \ +/** + * Initializer for hooks capable of running only sequentially. + * .jobs = 1 forces serial execution. + */ +#define RUN_HOOKS_OPT_INIT_SERIAL { \ .env = STRVEC_INIT, \ .args = STRVEC_INIT, \ .stdout_to_stderr = 1, \ .jobs = 1, \ } +/** + * Initializer for hooks capable of running in parallel. + * .jobs = 0 means online_cpus() will be called to get the number of jobs, if + * users did not specify a 'hook.jobs' config which has precedence. + */ +#define RUN_HOOKS_OPT_INIT_PARALLEL { \ + .env = STRVEC_INIT, \ + .args = STRVEC_INIT, \ + .stdout_to_stderr = 1, \ + .jobs = 0, \ +} + struct hook_cb_data { /* rc reflects the cumulative failure state */ int rc; diff --git a/refs.c b/refs.c index d1a1ace641..9fba8700b3 100644 --- a/refs.c +++ b/refs.c @@ -2533,7 +2533,7 @@ static void free_transaction_feed_cb_data(void *data) static int run_transaction_hook(struct ref_transaction *transaction, const char *state) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; struct transaction_feed_cb_data feed_ctx = { 0 }; int ret = 0; diff --git a/sequencer.c b/sequencer.c index cccde58bee..9271a6fa4f 100644 --- a/sequencer.c +++ b/sequencer.c @@ -1311,7 +1311,7 @@ static int pipe_from_strbuf(int hook_stdin_fd, void *pp_cb, void *pp_task_cb UNU static int run_rewrite_hook(const struct object_id *oldoid, const struct object_id *newoid) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; int code; struct strbuf sb = STRBUF_INIT; @@ -5137,7 +5137,7 @@ static int pick_commits(struct repository *r, if (!stat(rebase_path_rewritten_list(), &st) && st.st_size > 0) { struct child_process child = CHILD_PROCESS_INIT; - struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_PARALLEL; child.in = open(rebase_path_rewritten_list(), O_RDONLY); child.git_cmd = 1; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 21ff6a68f0..4db1fac862 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -146,10 +146,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' ' ' test_hook_tty () { - cat >expect <<-\EOF - STDOUT TTY - STDERR TTY - EOF + expect_tty=$1 + shift + + if test "$expect_tty" != "no_tty"; then + cat >expect <<-\EOF + STDOUT TTY + STDERR TTY + EOF + else + cat >expect <<-\EOF + STDOUT NO TTY + STDERR NO TTY + EOF + fi test_when_finished "rm -rf repo" && git init repo && @@ -167,12 +177,21 @@ test_hook_tty () { test_cmp expect repo/actual } -test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' ' - test_hook_tty hook run pre-commit +test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' ' + # hooks running sequentially (-j1) are always connected to the tty for + # optimum real-time performance. + test_hook_tty tty hook run -j1 pre-commit +' + +test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' ' + # Hooks are not connected to the tty when run in parallel, instead they + # output to a pipe through which run-command collects and de-interlaces + # their outputs, which then gets passed either to the tty or a sideband. + test_hook_tty no_tty hook run -j2 pre-commit ' test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' ' - test_hook_tty commit -m"B.new" + test_hook_tty tty commit -m"B.new" ' test_expect_success 'git hook list orders by config order' ' @@ -467,4 +486,125 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'parallel hook output is not interleaved' ' + test_when_finished "rm -rf .git/hooks" && + + write_script .git/hooks/test-hook <<-EOF && + echo "Hook 1 Start" + sleep 1 + echo "Hook 1 End" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "echo \"Hook 2 Start\"; sleep 2; echo \"Hook 2 End\"" && + test_config hook.hook-3.event test-hook && + test_config hook.hook-3.command \ + "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" && + + git hook run -j3 test-hook >out 2>err.parallel && + + # Verify Hook 1 output is grouped + sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out && + test_line_count = 2 hook1_out && + + # Verify Hook 2 output is grouped + sed -n "/Hook 2 Start/,/Hook 2 End/p" err.parallel >hook2_out && + test_line_count = 2 hook2_out && + + # Verify Hook 3 output is grouped + sed -n "/Hook 3 Start/,/Hook 3 End/p" err.parallel >hook3_out && + test_line_count = 2 hook3_out +' + +test_expect_success 'git hook run -j1 runs hooks in series' ' + test_when_finished "rm -rf .git/hooks" && + + test_config hook.series-1.event "test-hook" && + test_config hook.series-1.command "echo 1" --add && + test_config hook.series-2.event "test-hook" && + test_config hook.series-2.command "echo 2" --add && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + echo 3 + EOF + + cat >expected <<-\EOF && + 1 + 2 + 3 + EOF + + git hook run -j1 test-hook 2>actual && + test_cmp expected actual +' + +test_expect_success 'git hook run -j2 runs hooks in parallel' ' + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + sleep 2 + echo "Hook 1" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "sleep 2; echo Hook 2" && + + start=$(date +%s) && + git hook run -j2 test-hook >out 2>err && + end=$(date +%s) && + + duration=$((end - start)) && + # 2 tasks of 2s. Serial >= 4s. Parallel < 4s. + test $duration -lt 4 +' + +test_expect_success 'hook.jobs=1 config runs hooks in series' ' + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + sleep 2 + echo "Hook 1" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "sleep 2; echo Hook 2" && + + test_config hook.jobs 1 && + + start=$(date +%s) && + git hook run test-hook >out 2>err && + end=$(date +%s) && + + duration=$((end - start)) && + # 2 tasks of 2s. Serial >= 4s. Parallel < 4s. + test $duration -ge 4 +' + +test_expect_success 'hook.jobs=2 config runs hooks in parallel' ' + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + sleep 2 + echo "Hook 1" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "sleep 2; echo Hook 2" && + + test_config hook.jobs 2 && + + start=$(date +%s) && + git hook run test-hook >out 2>err && + end=$(date +%s) && + + duration=$((end - start)) && + # 2 tasks of 2s. Serial >= 4s. Parallel < 4s. + test $duration -lt 4 +' + test_done diff --git a/transport.c b/transport.c index 176050e663..477a598eec 100644 --- a/transport.c +++ b/transport.c @@ -1379,7 +1379,7 @@ static void free_pre_push_hook_data(void *data) static int run_pre_push_hook(struct transport *transport, struct ref *remote_refs) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; struct feed_pre_push_hook_data data; int ret = 0; -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH 2/4] hook: allow parallel hook execution 2026-02-04 17:33 ` [PATCH 2/4] hook: allow parallel hook execution Adrian Ratiu @ 2026-02-11 12:41 ` Patrick Steinhardt 2026-02-12 12:25 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Patrick Steinhardt @ 2026-02-11 12:41 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, Ævar Arnfjörð Bjarmason On Wed, Feb 04, 2026 at 07:33:26PM +0200, Adrian Ratiu wrote: > From: Emily Shaffer <emilyshaffer@google.com> > > In many cases, there's no reason not to allow hooks to execute in > parallel, if more than one was provided. > > hook.c already calls run_processes_parallel() so all we need to do is > allow its job count to be greater than 1. > > Serial execution is achieved by setting .jobs == 1 at compile time via > RUN_HOOKS_OPT_INIT_SERIAL or by setting the 'hook.jobs' config to 1. > This matches the behavior prior to this commit. > > The compile-time 'struct run_hooks_opt.jobs' parameter has the highest > priority if non-zero, followed by the 'hook.jobs' user config, then the > processor count from online_cpus() is the last fallback. Wait, the compile-time parameter overrides the user configuration? That doesn't seem right to me. I'm also a bit sceptical whether we should really default to `online_cpus()`. If so, we start to assume semantics of the hooks themselves, and that they cannot conflict with one another. But this is nothing we can really guarantee. It might be that multiple hooks want to modify the same data structure, and if so running them in parallel would lead to races. So I wonder whether we should rather make this behaviour opt-in than opt-out. > The above ordering ensures hooks unsafe to run in parallel are always > executed sequentially (RUN_HOOKS_OPT_INIT_SERIAL) while allowing users > to control parallelism with an efficient default. Ah, okay, we only let the compile-time parameter override the config in case we know that hooks must run in serial. That makes a bit more sense. > diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc > index 49c7ffd82e..c394756328 100644 > --- a/Documentation/config/hook.adoc > +++ b/Documentation/config/hook.adoc > @@ -15,3 +15,8 @@ hook.<name>.event:: > On the specified event, the associated `hook.<name>.command` will be > executed. More than one event can be specified if you wish for > `hook.<name>` to execute on multiple events. See linkgit:git-hook[1]. > + > +hook.jobs:: > + Specifies how many hooks can be run simultaneously during parallelized > + hook execution. If unspecified, defaults to the number of processors on > + the current system. We should probably note that some hooks will run sequentially regardless of this setting. Maybe we should even document which ones? I expect it's not going to be that many. > diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc > index 5f339dc48b..72c6c6d1ee 100644 > --- a/Documentation/git-hook.adoc > +++ b/Documentation/git-hook.adoc > @@ -128,6 +129,16 @@ OPTIONS > tools that want to do a blind one-shot run of a hook that may > or may not be present. > > +-j:: > +--jobs:: > + Only valid for `run`. > ++ > +Specify how many hooks to run simultaneously. If this flag is not specified, > +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If the > +config is not specified, the number of CPUs on the current system is used. Some > +hooks may be ineligible for parallelization: for example, 'commit-msg' hooks > +typically modify the commit message body and cannot be parallelized. Yeah, this info is probably what I was searching for in the "hook.jobs" description. > diff --git a/builtin/hook.c b/builtin/hook.c > index 4cc6dac45a..cd1f4ebe6a 100644 > --- a/builtin/hook.c > +++ b/builtin/hook.c > @@ -76,7 +77,7 @@ static int run(int argc, const char **argv, const char *prefix, > struct repository *repo UNUSED) > { > int i; > - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; > + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; > int ignore_missing = 0; > const char *hook_name; > struct option run_options[] = { Hm. Assuming that the user executes `git hooks run prepare-commit-msg` with "--jobs=2", should we really honor that request? We know that the hook cannot run in parallel, so we might want to refuse such requests. Taking a step back, I wonder whether it really is sensible to declare complete classes of hooks as parallelizable or non-parallelizable. We have to assume semantics of the hook scripts themselves to be able to answer whether or not they can be parallelizable. For some classes of hooks like "prepare-commit-msg" we can assume that it's almost never correct to serialize them. But for others we cannot assume anything. Which makes me wonder whether the design here is really the right one. Shouldn't we stop worrying about classes of hooks, but rather worry about the user's intent? The user will know whether two hooks can run in parallel or not, so let them tell us that this is the case. I think this could be achieved via the configuration: [hook "my-parallelizable-hook-a"] path = /some/script-a.sh parallel = true [hook "my-parallelizable-hook"] path = /some/script-b.sh parallel = true [hook "serial-hook"] path = /some/script-c.sh parallel = false This would tell us that we can safely run two of the hooks in parallel, but not the third one. So we'd then first execute all serial hooks in serial, and then in a second phase we'd execute the other hooks in parallel. Sure, this puts more responsibility on the user. But I think this is a more flexible approach as it also empowers the user and caters to more use cases. Please let me know what you think. Thanks! Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH 2/4] hook: allow parallel hook execution 2026-02-11 12:41 ` Patrick Steinhardt @ 2026-02-12 12:25 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-12 12:25 UTC (permalink / raw) To: Patrick Steinhardt Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, Ævar Arnfjörð Bjarmason On Wed, 11 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote: > On Wed, Feb 04, 2026 at 07:33:26PM +0200, Adrian Ratiu wrote: >> From: Emily Shaffer <emilyshaffer@google.com> >> >> In many cases, there's no reason not to allow hooks to execute in >> parallel, if more than one was provided. >> >> hook.c already calls run_processes_parallel() so all we need to do is >> allow its job count to be greater than 1. >> >> Serial execution is achieved by setting .jobs == 1 at compile time via >> RUN_HOOKS_OPT_INIT_SERIAL or by setting the 'hook.jobs' config to 1. >> This matches the behavior prior to this commit. >> >> The compile-time 'struct run_hooks_opt.jobs' parameter has the highest >> priority if non-zero, followed by the 'hook.jobs' user config, then the >> processor count from online_cpus() is the last fallback. > > Wait, the compile-time parameter overrides the user configuration? That > doesn't seem right to me. Yes, the rationale for doing this is that some hooks are never safe to parallelize, so if RUN_HOOKS_OPT_INIT_SERIAL is set at compile-time, then it has the highest prefference, for safety for these hooks and the user cannot override them. Hooks initialized with RUN_HOOKS_OPT_INIT_PARALLEL are known to be safe to parallelize, so the user can also overwrite the jobs number. Maybe we need better names (or code?) to express this? I am certainly open to suggestions. > > I'm also a bit sceptical whether we should really default to > `online_cpus()`. If so, we start to assume semantics of the hooks > themselves, and that they cannot conflict with one another. But this is > nothing we can really guarantee. It might be that multiple hooks want to > modify the same data structure, and if so running them in parallel would > lead to races. > > So I wonder whether we should rather make this behaviour opt-in than > opt-out. We certainly can. There is nothing set in stone here and we can even keep the previous default to jobs == 1 for all hooks. >> The above ordering ensures hooks unsafe to run in parallel are always >> executed sequentially (RUN_HOOKS_OPT_INIT_SERIAL) while allowing users >> to control parallelism with an efficient default. > > Ah, okay, we only let the compile-time parameter override the config in > case we know that hooks must run in serial. That makes a bit more sense. Yes, that is the idea: some hooks must never run in parallel. Of course, if we can find better ways to express this, I'm all ears. :) >> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc >> index 49c7ffd82e..c394756328 100644 >> --- a/Documentation/config/hook.adoc >> +++ b/Documentation/config/hook.adoc >> @@ -15,3 +15,8 @@ hook.<name>.event:: >> On the specified event, the associated `hook.<name>.command` will be >> executed. More than one event can be specified if you wish for >> `hook.<name>` to execute on multiple events. See linkgit:git-hook[1]. >> + >> +hook.jobs:: >> + Specifies how many hooks can be run simultaneously during parallelized >> + hook execution. If unspecified, defaults to the number of processors on >> + the current system. > > We should probably note that some hooks will run sequentially regardless > of this setting. Maybe we should even document which ones? I expect it's > not going to be that many. Ack, will do in v2. >> diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc >> index 5f339dc48b..72c6c6d1ee 100644 >> --- a/Documentation/git-hook.adoc >> +++ b/Documentation/git-hook.adoc >> @@ -128,6 +129,16 @@ OPTIONS >> tools that want to do a blind one-shot run of a hook that may >> or may not be present. >> >> +-j:: >> +--jobs:: >> + Only valid for `run`. >> ++ >> +Specify how many hooks to run simultaneously. If this flag is not specified, >> +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If the >> +config is not specified, the number of CPUs on the current system is used. Some >> +hooks may be ineligible for parallelization: for example, 'commit-msg' hooks >> +typically modify the commit message body and cannot be parallelized. > > Yeah, this info is probably what I was searching for in the "hook.jobs" > description. Yes, I'll try to make this clearer. >> diff --git a/builtin/hook.c b/builtin/hook.c >> index 4cc6dac45a..cd1f4ebe6a 100644 >> --- a/builtin/hook.c >> +++ b/builtin/hook.c >> @@ -76,7 +77,7 @@ static int run(int argc, const char **argv, const char *prefix, >> struct repository *repo UNUSED) >> { >> int i; >> - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; >> + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_PARALLEL; >> int ignore_missing = 0; >> const char *hook_name; >> struct option run_options[] = { > > Hm. Assuming that the user executes `git hooks run prepare-commit-msg` > with "--jobs=2", should we really honor that request? We know that the > hook cannot run in parallel, so we might want to refuse such requests. > > Taking a step back, I wonder whether it really is sensible to declare > complete classes of hooks as parallelizable or non-parallelizable. We > have to assume semantics of the hook scripts themselves to be able to > answer whether or not they can be parallelizable. For some classes of > hooks like "prepare-commit-msg" we can assume that it's almost never > correct to serialize them. But for others we cannot assume anything. > > Which makes me wonder whether the design here is really the right one. > Shouldn't we stop worrying about classes of hooks, but rather worry > about the user's intent? The user will know whether two hooks can run in > parallel or not, so let them tell us that this is the case. > > I think this could be achieved via the configuration: > > [hook "my-parallelizable-hook-a"] > path = /some/script-a.sh > parallel = true > > [hook "my-parallelizable-hook"] > path = /some/script-b.sh > parallel = true > > [hook "serial-hook"] > path = /some/script-c.sh > parallel = false > > This would tell us that we can safely run two of the hooks in parallel, > but not the third one. So we'd then first execute all serial hooks in > serial, and then in a second phase we'd execute the other hooks in > parallel. > > Sure, this puts more responsibility on the user. But I think this is a > more flexible approach as it also empowers the user and caters to more > use cases. > > Please let me know what you think. Those are really good points, thanks. We can certainly keep jobs == 1 as a weak default for all hooks, which can be overriden by users, then leave it up to the user to decide what is safe or unsafe. However, there are trade-offs... 1. As you said this puts more responsability on the user to know what to enable (i.e. don't push the wrong button). 2. It forces manual user intervention if parallelism is desired. 3. We can't have a safe global default like a runtime config because we clearly can't eneable all parallelism and would end up keeping a list of safe-vs-unsafe hooks anyway if we decide to add a config like "parallelize-everything-which-can-be-parallelized". :) The current design was thought to provide a safe default with minimal user intervention. Maybe we can find some middle ground between the two "extremes" (deciding everything for the user vs pushing all decisions to the user)? Maybe keep the compile-time safe/unsafe hook list, default all hooks to .jobs == 1 (preserve existing behavior) and allow the users to enable parallelism individually or, via a runtime config, all known to be safe? ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH 3/4] hook: introduce extensions.hookStdoutToStderr 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu 2026-02-04 17:33 ` [PATCH 1/4] config: add a repo_config_get_uint() helper Adrian Ratiu 2026-02-04 17:33 ` [PATCH 2/4] hook: allow parallel hook execution Adrian Ratiu @ 2026-02-04 17:33 ` Adrian Ratiu 2026-02-04 17:33 ` [PATCH 4/4] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu ` (5 subsequent siblings) 8 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-04 17:33 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu All hooks already redirect stdout to stderr with the exception of pre-push which has a known user who depends on the separate stdout versus stderr outputs (the git-lfs project). The pre-push behavior was a surprise which we found out about after causing a regression for git-lfs. Notably, it might not be the only exception (it's the one we know about). There might be more. This presents a challenge because stdout_to_stderr is required for hook parallelization, so run-command can buffer and de-interleave the hook outputs using ungroup=0, when hook.jobs > 1. Introduce an extension to enforce consistency: all hooks merge stdout into stderr and can be safely parallelized. This provides a clean separation and avoids breaking existing stdout vs stderr behavior. When this extension is disabled, the `hook.jobs` config has no effect for pre-push, to prevent garbled (interleaved) parallel output, so it runs sequentially like before. Alternatives I've considered to this extension include: 1. Allowing pre-push to run in parallel with interleaved output. 2. Always running pre-push sequentially (no parallel jobs for it). 3. Making users (only git-lfs? maybe more?) fix their hooks to read stderr not stdout. Out of all these alternatives, I think this extension is the most reasonable compromise, to not break existing users, allow pre-push parallel jobs for those who need it (with correct outputs) and also future-proofing in case there are any more exceptions to be added. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/extensions.adoc | 12 ++++++++ Documentation/config/hook.adoc | 3 ++ repository.c | 1 + repository.h | 1 + setup.c | 7 +++++ setup.h | 1 + t/t1800-hook.sh | 42 ++++++++++++++++++++++++++++ transport.c | 7 ++--- 8 files changed, 69 insertions(+), 5 deletions(-) diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index 532456644b..de47d97f6d 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -73,6 +73,18 @@ relativeWorktrees::: repaired with either the `--relative-paths` option or with the `worktree.useRelativePaths` config set to `true`. +hookStdoutToStderr::: + If enabled, the stdout of all hooks is redirected to stderr. This + enforces consistency, since by default most hooks already behave + this way, with pre-push being the only known exception. ++ +This is useful for parallel hook execution (see the `hook.jobs` config in +linkgit:git-config[1]), as it allows the output of multiple hooks running +in parallel to be grouped (de-interleaved) correctly. ++ +Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push +hooks, which will always be run sequentially. + worktreeConfig::: If enabled, then worktrees will load config settings from the `$GIT_DIR/config.worktree` file in addition to the diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index c394756328..56e6b4e5c3 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -20,3 +20,6 @@ hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to the number of processors on the current system. ++ +This has no effect for hooks requiring separate output streams (like `pre-push`) +unless `extensions.hookStdoutToStderr` is enabled. diff --git a/repository.c b/repository.c index c7e75215ac..36317cafa7 100644 --- a/repository.c +++ b/repository.c @@ -281,6 +281,7 @@ int repo_init(struct repository *repo, repo->repository_format_worktree_config = format.worktree_config; repo->repository_format_relative_worktrees = format.relative_worktrees; repo->repository_format_precious_objects = format.precious_objects; + repo->repository_format_hook_stdout_to_stderr = format.hook_stdout_to_stderr; /* take ownership of format.partial_clone */ repo->repository_format_partial_clone = format.partial_clone; diff --git a/repository.h b/repository.h index 6063c4b846..f358fdc877 100644 --- a/repository.h +++ b/repository.h @@ -165,6 +165,7 @@ struct repository { int repository_format_worktree_config; int repository_format_relative_worktrees; int repository_format_precious_objects; + int repository_format_hook_stdout_to_stderr; /* Indicate if a repository has a different 'commondir' from 'gitdir' */ unsigned different_commondir:1; diff --git a/setup.c b/setup.c index b723f8b339..cf4949c086 100644 --- a/setup.c +++ b/setup.c @@ -686,6 +686,9 @@ static enum extension_result handle_extension(const char *var, } else if (!strcmp(ext, "relativeworktrees")) { data->relative_worktrees = git_config_bool(var, value); return EXTENSION_OK; + } else if (!strcmp(ext, "hookstdouttostderr")) { + data->hook_stdout_to_stderr = git_config_bool(var, value); + return EXTENSION_OK; } return EXTENSION_UNKNOWN; } @@ -1947,6 +1950,8 @@ const char *setup_git_directory_gently(int *nongit_ok) repo_fmt.worktree_config; the_repository->repository_format_relative_worktrees = repo_fmt.relative_worktrees; + the_repository->repository_format_hook_stdout_to_stderr = + repo_fmt.hook_stdout_to_stderr; /* take ownership of repo_fmt.partial_clone */ the_repository->repository_format_partial_clone = repo_fmt.partial_clone; @@ -2047,6 +2052,8 @@ void check_repository_format(struct repository_format *fmt) fmt->worktree_config; the_repository->repository_format_relative_worktrees = fmt->relative_worktrees; + the_repository->repository_format_hook_stdout_to_stderr = + fmt->hook_stdout_to_stderr; the_repository->repository_format_partial_clone = xstrdup_or_null(fmt->partial_clone); clear_repository_format(&repo_fmt); diff --git a/setup.h b/setup.h index d55dcc6608..929a2e0ba8 100644 --- a/setup.h +++ b/setup.h @@ -167,6 +167,7 @@ struct repository_format { char *partial_clone; /* value of extensions.partialclone */ int worktree_config; int relative_worktrees; + int hook_stdout_to_stderr; int is_bare; int hash_algo; int compat_hash_algo; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 4db1fac862..bf19579f3a 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -395,6 +395,48 @@ test_expect_success 'client hooks: pre-push expects separate stdout and stderr' check_stdout_separate_from_stderr pre-push ' +test_expect_success 'client hooks: extension makes pre-push merge stdout to stderr' ' + test_when_finished "rm -f stdout.actual stderr.actual" && + git init --bare remote2 && + git remote add origin2 remote2 && + test_commit B && + # repositoryformatversion=1 might be already set (eg default sha256) + # so check before using test_config to set it + { test "$(git config core.repositoryformatversion)" = 1 || + test_config core.repositoryformatversion 1; } && + git config set core.repositoryformatversion 1 && + test_config extensions.hookStdoutToStderr true && + setup_hooks pre-push && + git push origin2 HEAD:main >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push +' + +test_expect_success 'client hooks: pre-push defaults to serial execution' ' + test_when_finished "rm -rf repo-serial" && + git init --bare remote-serial && + git init repo-serial && + git -C repo-serial remote add origin ../remote-serial && + test_commit -C repo-serial A && + + # Setup 2 pre-push hooks + write_script repo-serial/.git/hooks/pre-push <<-EOF && + sleep 2 + echo "Hook 1" >&2 + EOF + git -C repo-serial config hook.hook-2.event pre-push && + git -C repo-serial config hook.hook-2.command "sleep 2; echo Hook 2 >&2" && + + git -C repo-serial config hook.jobs 2 && + + start=$(date +%s) && + git -C repo-serial push origin HEAD >out 2>err && + end=$(date +%s) && + + duration=$((end - start)) && + # Serial >= 4s, parallel < 4s. + test $duration -ge 4 +' + test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' ' hooks="pre-commit prepare-commit-msg \ commit-msg post-commit \ diff --git a/transport.c b/transport.c index 477a598eec..708118f439 100644 --- a/transport.c +++ b/transport.c @@ -1394,11 +1394,8 @@ static int run_pre_push_hook(struct transport *transport, opt.copy_feed_pipe_cb_data = copy_pre_push_hook_data; opt.free_feed_pipe_cb_data = free_pre_push_hook_data; - /* - * pre-push hooks expect stdout & stderr to be separate, so don't merge - * them to keep backwards compatibility with existing hooks. - */ - opt.stdout_to_stderr = 0; + /* merge stdout to stderr only when extensions.StdoutToStderr is enabled */ + opt.stdout_to_stderr = the_repository->repository_format_hook_stdout_to_stderr; ret = run_hooks_opt(the_repository, "pre-push", &opt); -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH 4/4] hook: allow runtime enabling extensions.hookStdoutToStderr 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu ` (2 preceding siblings ...) 2026-02-04 17:33 ` [PATCH 3/4] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu @ 2026-02-04 17:33 ` Adrian Ratiu 2026-02-12 10:43 ` [PATCH 0/4] Run hooks in parallel Phillip Wood ` (4 subsequent siblings) 8 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-04 17:33 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu Add a new config `hook.forceStdoutToStderr` which allows enabling extensions.hookStdoutToStderr by default at runtime, both for new and existing repositories. This makes it easier for users to enable hook parallelization for hooks like pre-push by enforcing output consistency. See previous commit for a more in-depth explanation & alternatives considered. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/extensions.adoc | 3 ++ Documentation/config/hook.adoc | 6 +++ hook.c | 10 ++++ setup.c | 10 ++++ t/t1800-hook.sh | 74 ++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+) diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index de47d97f6d..0db485dd54 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -84,6 +84,9 @@ in parallel to be grouped (de-interleaved) correctly. + Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push hooks, which will always be run sequentially. ++ +The extension can also be enabled by setting `hook.forceStdoutToStderr` +to `true` in the global configuration. worktreeConfig::: If enabled, then worktrees will load config settings from the diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 56e6b4e5c3..a6a79a1670 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -23,3 +23,9 @@ hook.jobs:: + This has no effect for hooks requiring separate output streams (like `pre-push`) unless `extensions.hookStdoutToStderr` is enabled. + +hook.forceStdoutToStderr:: + A boolean that enables the `extensions.hookStdoutToStderr` behavior + (merging stdout to stderr for all hooks) globally. This effectively + forces all hooks to behave as if the extension was enabled, allowing + parallel execution for hooks like `pre-push`. diff --git a/hook.c b/hook.c index e07e8f4efe..bae6e35943 100644 --- a/hook.c +++ b/hook.c @@ -264,6 +264,16 @@ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *op { unsigned int jobs = options->jobs; + /* + * Allow hook.forceStdoutToStderr to enable extensions.hookStdoutToStderr + * for existing repositories (runtime override). + */ + if (!options->stdout_to_stderr) { + int v = 0; + repo_config_get_bool(r, "hook.forceStdoutToStderr", &v); + options->stdout_to_stderr = v; + } + /* * Hooks which configure stdout_to_stderr=0 (like pre-push), expect separate * output streams. Unless extensions.StdoutToStderr is enabled (which forces diff --git a/setup.c b/setup.c index cf4949c086..037ab2e21d 100644 --- a/setup.c +++ b/setup.c @@ -2310,6 +2310,7 @@ void initialize_repository_version(int hash_algo, { struct strbuf repo_version = STRBUF_INIT; int target_version = GIT_REPO_VERSION; + int default_hook_stdout_to_stderr = 0; /* * Note that we initialize the repository version to 1 when the ref @@ -2348,6 +2349,15 @@ void initialize_repository_version(int hash_algo, clear_repository_format(&repo_fmt); } + repo_config_get_bool(the_repository, "hook.forceStdoutToStderr", + &default_hook_stdout_to_stderr); + if (default_hook_stdout_to_stderr) { + /* extensions.hookstdouttostderr requires at least version 1 */ + if (target_version == 0) + target_version = 1; + repo_config_set(the_repository, "extensions.hookstdouttostderr", "true"); + } + strbuf_addf(&repo_version, "%d", target_version); repo_config_set(the_repository, "core.repositoryformatversion", repo_version.buf); diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index bf19579f3a..db0f96f778 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -649,4 +649,78 @@ test_expect_success 'hook.jobs=2 config runs hooks in parallel' ' test $duration -lt 4 ' +test_expect_success '`git init` respects hook.forceStdoutToStderr' ' + test_when_finished "rm -rf repo-init" && + test_config_global hook.forceStdoutToStderr true && + git init repo-init && + git -C repo-init config extensions.hookStdoutToStderr >actual && + echo true >expect && + test_cmp expect actual +' + +test_expect_success '`git init` does not set extensions.hookStdoutToStderr by default' ' + test_when_finished "rm -rf upstream" && + git init upstream && + test_must_fail git -C upstream config extensions.hookStdoutToStderr +' + +test_expect_success '`git clone` does not set extensions.hookStdoutToStderr by default' ' + test_when_finished "rm -rf upstream repo-clone-no-ext" && + git init upstream && + git clone upstream repo-clone-no-ext && + test_must_fail git -C repo-clone-no-ext config extensions.hookStdoutToStderr +' + +test_expect_success '`git clone` respects hook.forceStdoutToStderr' ' + test_when_finished "rm -rf upstream repo-clone" && + git init upstream && + test_config_global hook.forceStdoutToStderr true && + git clone upstream repo-clone && + git -C repo-clone config extensions.hookStdoutToStderr >actual && + echo true >expect && + test_cmp expect actual +' + +test_expect_success 'hook.forceStdoutToStderr enables extension for existing repos' ' + test_when_finished "rm -rf remote-repo existing-repo" && + git init --bare remote-repo && + git init -b main existing-repo && + # No local extensions.hookStdoutToStderr config set here + # so global config should apply + test_config_global hook.forceStdoutToStderr true && + cd existing-repo && + test_commit A && + git remote add origin ../remote-repo && + setup_hooks pre-push && + git push origin HEAD >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push && + cd .. +' + +test_expect_success 'hook.forceStdoutToStderr enables pre-push parallel runs' ' + test_when_finished "rm -rf repo-parallel" && + git init --bare remote-parallel && + git init repo-parallel && + git -C repo-parallel remote add origin ../remote-parallel && + test_commit -C repo-parallel A && + + write_script repo-parallel/.git/hooks/pre-push <<-EOF && + sleep 2 + echo "Hook 1" >&2 + EOF + git -C repo-parallel config hook.hook-2.event pre-push && + git -C repo-parallel config hook.hook-2.command "sleep 2; echo Hook 2 >&2" && + + git -C repo-parallel config hook.jobs 2 && + git -C repo-parallel config hook.forceStdoutToStderr true && + + start=$(date +%s) && + git -C repo-parallel push origin HEAD >out 2>err && + end=$(date +%s) && + + duration=$((end - start)) && + # Serial >= 4s, parallel < 4s. + test $duration -lt 4 +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH 0/4] Run hooks in parallel 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu ` (3 preceding siblings ...) 2026-02-04 17:33 ` [PATCH 4/4] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu @ 2026-02-12 10:43 ` Phillip Wood 2026-02-12 14:24 ` Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu ` (3 subsequent siblings) 8 siblings, 1 reply; 83+ messages in thread From: Phillip Wood @ 2026-02-12 10:43 UTC (permalink / raw) To: Adrian Ratiu, git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk Hi Adrian On 04/02/2026 17:33, Adrian Ratiu wrote: > Hello everyone, > > This enables running hook commands in parallel and is based on the patch > series enabling config hooks [1], which added the ability to run a list > of hooks for each hook event. > > For context, hooks used to run sequentially due to hardcoded .jobs == 1 > in hook.c, leading to .processes == 1 in run-command.c. We're removing > that restriction for hooks known to be safe to parallelize. > > The parallelism enabled here is to run multiple hook commands/scripts > in parallel for a single event, for example the pre-push event might > trigger linters / spell checkers / unit tests to run at the same time. > > Another kind of parallelism is to split the hook input to multiple > child processes, running the same command in parallel on subsets of > the hook input. This series does not do that. It might be a future > addition on top of this, since it's kind of a lower-level parallelism. There's quite a lot of prior-art on parallelization from the various hook managers - is there anything we can learn from them? For example I know some of them serialize the pre-commit hook by default as it may update the index but allow the user to configure a subset of scripts that can be parallelized. They also allow for parallelization where different scripts update different files (e.g. code formatters for python and C can run in parallel). We don't need to implement all that now but we should design our config so that we can support it in the future. > The pre-push hook is special because it is the only known hook to break > backward compatibility when running in parallel, due to run-command > collating its outputs via a pipe, so I added an extension for it. > Users can opt-in to this extension with a runtime config. In the past we had a regression report [1] when the pre-commit hook stopped having access to the terminal. I've not been following the hook changes, is this series (or any of your preparatory series) in danger of reintroducing that regression? Thanks for working on this - both config based hooks and parallel execution are really nice improvements. Phillip [1] https://lore.kernel.org/git/xmqqr15rr9k6.fsf@gitster.g/ > Suggestions for alternative solutions to the extension are welcome. > > Again, this is based on the latest v1 config hooks series [1] which > has not yet landed in next or master. > > Branch pushed to GitHub containing all dependency patches: [2] > Successful CI run: [3] > > Many thanks to all who contributed to this effort up to now, including > Emily, AEvar, Junio, Patrick, Peff and many others. > > Thank you, > Adrian > > 1: https://lore.kernel.org/git/20260204165126.1548805-1-adrian.ratiu@collabora.com/T/#mdb138a39d332f234bc9068b7f4e05b10c400e572 > 2: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/parallel-hooks-v1 > 3: https://github.com/10ne1/git/actions/runs/21680184456 > > Adrian Ratiu (3): > config: add a repo_config_get_uint() helper > hook: introduce extensions.hookStdoutToStderr > hook: allow runtime enabling extensions.hookStdoutToStderr > > Emily Shaffer (1): > hook: allow parallel hook execution > > Documentation/config/extensions.adoc | 15 ++ > Documentation/config/hook.adoc | 14 ++ > Documentation/git-hook.adoc | 14 +- > builtin/am.c | 10 +- > builtin/checkout.c | 13 +- > builtin/clone.c | 6 +- > builtin/hook.c | 7 +- > builtin/receive-pack.c | 9 +- > builtin/worktree.c | 2 +- > commit.c | 2 +- > config.c | 28 +++ > config.h | 13 ++ > hook.c | 51 ++++- > hook.h | 20 +- > parse.c | 9 + > parse.h | 1 + > refs.c | 2 +- > repository.c | 1 + > repository.h | 1 + > sequencer.c | 4 +- > setup.c | 17 ++ > setup.h | 1 + > t/t1800-hook.sh | 270 ++++++++++++++++++++++++++- > transport.c | 9 +- > 24 files changed, 476 insertions(+), 43 deletions(-) > ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH 0/4] Run hooks in parallel 2026-02-12 10:43 ` [PATCH 0/4] Run hooks in parallel Phillip Wood @ 2026-02-12 14:24 ` Adrian Ratiu 2026-02-13 14:39 ` Phillip Wood 0 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-02-12 14:24 UTC (permalink / raw) To: phillip.wood, git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk On Thu, 12 Feb 2026, Phillip Wood <phillip.wood123@gmail.com> wrote: > Hi Adrian > > On 04/02/2026 17:33, Adrian Ratiu wrote: >> Hello everyone, >> >> This enables running hook commands in parallel and is based on the patch >> series enabling config hooks [1], which added the ability to run a list >> of hooks for each hook event. >> >> For context, hooks used to run sequentially due to hardcoded .jobs == 1 >> in hook.c, leading to .processes == 1 in run-command.c. We're removing >> that restriction for hooks known to be safe to parallelize. >> >> The parallelism enabled here is to run multiple hook commands/scripts >> in parallel for a single event, for example the pre-push event might >> trigger linters / spell checkers / unit tests to run at the same time. >> >> Another kind of parallelism is to split the hook input to multiple >> child processes, running the same command in parallel on subsets of >> the hook input. This series does not do that. It might be a future >> addition on top of this, since it's kind of a lower-level parallelism. > > There's quite a lot of prior-art on parallelization from the various > hook managers - is there anything we can learn from them? For example I > know some of them serialize the pre-commit hook by default as it may > update the index but allow the user to configure a subset of scripts > that can be parallelized. They also allow for parallelization where > different scripts update different files (e.g. code formatters for > python and C can run in parallel). We don't need to implement all that > now but we should design our config so that we can support it in the future. Yes, all the prior-art is very useful and it is possible to do finer-grained (or lower-level? :-) ) parallelism further with the new run-command parallelization design, APIs and config. Obviously that will require more work and doing careful analysis on each hook-by-hook case. I'm just adding the basic buliding blocks and enabling the "highest-level" (most... independent?... level between tasks) of parallelism here. My approach to this big problem was to simplify and break it down into smaller / easier-to-manage chunks. I'm still splitting up commits and untangling logic to ensure each part is done properly (also easier to review) and can work independently (no regresions etc) before building on top of it. Thank you, and everyone else, so much for all the help and patience. >> The pre-push hook is special because it is the only known hook to break >> backward compatibility when running in parallel, due to run-command >> collating its outputs via a pipe, so I added an extension for it. >> Users can opt-in to this extension with a runtime config. > > In the past we had a regression report [1] when the pre-commit hook > stopped having access to the terminal. I've not been following the hook > changes, is this series (or any of your preparatory series) in danger of > reintroducing that regression? Thank you for raising this, it is a very valuable data point! The preparatory series 100% will not reintroduce it. This series might reintroduce it, depending how we set the defaults. By that I mean: -j1 will keep all hooks connected to the tty, just like before. -jN with N>1 will disconnect the hooks from the tty and their outputs will get buffered through run-command's pipes. The design I followed (which to be transparent is Peff's design, I just implemented his ideas :), is the keep the original, serialized behavior exactly how it was before, to not introduce any regressions and to also keep identical "real-time" performance. Taking this into account, together with Patrick's feedback on this series, I do intend keep the jobs == 1 default for all hooks in v2. > > Thanks for working on this - both config based hooks and parallel > execution are really nice improvements. Thank you for the kind words. :) ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH 0/4] Run hooks in parallel 2026-02-12 14:24 ` Adrian Ratiu @ 2026-02-13 14:39 ` Phillip Wood 2026-02-13 17:21 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Phillip Wood @ 2026-02-13 14:39 UTC (permalink / raw) To: Adrian Ratiu, phillip.wood, git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk On 12/02/2026 14:24, Adrian Ratiu wrote: > On Thu, 12 Feb 2026, Phillip Wood <phillip.wood123@gmail.com> wrote: >> Hi Adrian >> >> On 04/02/2026 17:33, Adrian Ratiu wrote: >>> Hello everyone, >>> >>> This enables running hook commands in parallel and is based on the patch >>> series enabling config hooks [1], which added the ability to run a list >>> of hooks for each hook event. >>> >>> For context, hooks used to run sequentially due to hardcoded .jobs == 1 >>> in hook.c, leading to .processes == 1 in run-command.c. We're removing >>> that restriction for hooks known to be safe to parallelize. >>> >>> The parallelism enabled here is to run multiple hook commands/scripts >>> in parallel for a single event, for example the pre-push event might >>> trigger linters / spell checkers / unit tests to run at the same time. >>> >>> Another kind of parallelism is to split the hook input to multiple >>> child processes, running the same command in parallel on subsets of >>> the hook input. This series does not do that. It might be a future >>> addition on top of this, since it's kind of a lower-level parallelism. >> >> There's quite a lot of prior-art on parallelization from the various >> hook managers - is there anything we can learn from them? For example I >> know some of them serialize the pre-commit hook by default as it may >> update the index but allow the user to configure a subset of scripts >> that can be parallelized. They also allow for parallelization where >> different scripts update different files (e.g. code formatters for >> python and C can run in parallel). We don't need to implement all that >> now but we should design our config so that we can support it in the future. > > Yes, all the prior-art is very useful and it is possible to do > finer-grained (or lower-level? :-) ) parallelism further with the > new run-command parallelization design, APIs and config. > > Obviously that will require more work and doing careful analysis on each > hook-by-hook case. I'm just adding the basic buliding blocks and > enabling the "highest-level" (most... independent?... level between > tasks) of parallelism here. > > My approach to this big problem was to simplify and break it down into > smaller / easier-to-manage chunks. I'm still splitting up commits and > untangling logic to ensure each part is done properly (also easier to > review) and can work independently (no regresions etc) before building > on top of it. That sounds sensible and should make it easier to review, so long as we design the configuration in a way that it can be extended as we add more features. > Thank you, and everyone else, so much for all the help and patience. > >>> The pre-push hook is special because it is the only known hook to break >>> backward compatibility when running in parallel, due to run-command >>> collating its outputs via a pipe, so I added an extension for it. >>> Users can opt-in to this extension with a runtime config. >> >> In the past we had a regression report [1] when the pre-commit hook >> stopped having access to the terminal. I've not been following the hook >> changes, is this series (or any of your preparatory series) in danger of >> reintroducing that regression? > > Thank you for raising this, it is a very valuable data point! > > The preparatory series 100% will not reintroduce it. > > This series might reintroduce it, depending how we set the defaults. > > By that I mean: > -j1 will keep all hooks connected to the tty, just like before. > -jN with N>1 will disconnect the hooks from the tty and their > outputs will get buffered through run-command's pipes. > > The design I followed (which to be transparent is Peff's design, I just > implemented his ideas :), is the keep the original, serialized behavior > exactly how it was before, to not introduce any regressions and to also > keep identical "real-time" performance. > > Taking this into account, together with Patrick's feedback on this > series, I do intend keep the jobs == 1 default for all hooks in v2. I think that would be safer. If we could opt-in to parallel execution on a per-hook basis would that be a solution for the "pre-push" hook? Users who want to keep the current behavior would avoid configuring parallel execution for that hook. Thanks Phillip >> >> Thanks for working on this - both config based hooks and parallel >> execution are really nice improvements. > > Thank you for the kind words. :) ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH 0/4] Run hooks in parallel 2026-02-13 14:39 ` Phillip Wood @ 2026-02-13 17:21 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-13 17:21 UTC (permalink / raw) To: Phillip Wood, phillip.wood, git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk On Fri, 13 Feb 2026, Phillip Wood <phillip.wood123@gmail.com> wrote: <snip> >>>> The pre-push hook is special because it is the only known hook to break >>>> backward compatibility when running in parallel, due to run-command >>>> collating its outputs via a pipe, so I added an extension for it. >>>> Users can opt-in to this extension with a runtime config. >>> >>> In the past we had a regression report [1] when the pre-commit hook >>> stopped having access to the terminal. I've not been following the hook >>> changes, is this series (or any of your preparatory series) in danger of >>> reintroducing that regression? >> >> Thank you for raising this, it is a very valuable data point! >> >> The preparatory series 100% will not reintroduce it. >> >> This series might reintroduce it, depending how we set the defaults. >> >> By that I mean: >> -j1 will keep all hooks connected to the tty, just like before. >> -jN with N>1 will disconnect the hooks from the tty and their >> outputs will get buffered through run-command's pipes. >> >> The design I followed (which to be transparent is Peff's design, I just >> implemented his ideas :), is the keep the original, serialized behavior >> exactly how it was before, to not introduce any regressions and to also >> keep identical "real-time" performance. >> >> Taking this into account, together with Patrick's feedback on this >> series, I do intend keep the jobs == 1 default for all hooks in v2. > > I think that would be safer. If we could opt-in to parallel execution on > a per-hook basis would that be a solution for the "pre-push" hook? Users > who want to keep the current behavior would avoid configuring parallel > execution for that hook. Yes, opting in on a per-hook basis is one of the mechanisms I'll be adding in v2 (that's what I've been discussing with Patrick via the other thread on this series). However we also need a mechanism to enable more than just 1 hook at a time, for users who want to enable by default a known-good set of hooks to run in parallel. That's what we did with `RUN_HOOKS_OPT_INIT_PARALLEL` at compile-time in v1, however that's a dead end and I won't pursue it. For v2 I'm thinking of a runtime/global config which can specify a list of hook to default for parallel execution. That should be enough to replace RUN_HOOKS_OPT_INIT_PARALLEL. Suggestions welcome, as always. :) ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v2 00/10] Run hooks in parallel 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu ` (4 preceding siblings ...) 2026-02-12 10:43 ` [PATCH 0/4] Run hooks in parallel Phillip Wood @ 2026-02-22 0:28 ` Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 01/10] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu ` (9 more replies) 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu ` (2 subsequent siblings) 8 siblings, 10 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:28 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Hello everyone, This series enables running hook commands in parallel and is based on the v2 series enabling config hooks [1], which added the ability to run a list of hooks for each event. In v2 I've addressed all the feedback received in v1, the biggest design change being that it's all disabled by default and users need to enable parallelism via the documented mechanisms. I also split the logic into more commits to make things easier to review, that is the main reason why this grew from 4 to 10 patches. Branch pushed to GitHub containing all dependency patches: [2] Successful CI run: [3] Many thanks to all who contributed to this effort up to now, including Emily, AEvar, Junio, Patrick, Phillip, Brian, Peff and many others. Thank you, Adrian 1: https://lore.kernel.org/git/20260204165126.1548805-1-adrian.ratiu@collabora.com/T/#m8ceab5819683cd70473055f780444ad7bccf7284 2: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/parallel-hooks-v2 3: https://github.com/10ne1/git/actions/runs/22266919210 Changes in v2: * Default hooks to sequential execution (jobs == 1) like before (Patrick, Phillip) * Parallel execution becomes an opt-in feature (Patrick, Phillip) * Reworked RUN_HOOKS_OPT_INIT macros to reflect the above design (Adrian) * Simplify get_hook_jobs() after the above redesign decisions (Adrian) * Documented hooks which are always run sequentially (Patrick) * New commits: split the big "add parallel hooks" commit into smaller ones (Adrian) * New commit: add a memleak fix (Adrian) * Added a struct hook_config_cache_entry because we're storing more than 1 command per hook inthe cache now (Adrian) * Cache is used for all hook.* configs which are read in 1 pass (Adrian) * Improved parallel tests to use sentinel files instead of plain sleeps to make them more reliable (Adrian) * Added more hook tests to improve coverage (Adrian) * Rebased on latest master and fixed trivial conficts with a newly added submodule extension in the master branch (Adrian) * Reworded commit messages to reflect the new design (Patrick) * Minor style cleanups, improve code comments (Patrick, Adrian) Range-diff v1 -> v2: -: ---------- > 1: f28b0270f9 repository: fix repo_init() memleak due to missing _clear() 1: a632a9b937 ! 2: c7cc106224 config: add a repo_config_get_uint() helper @@ Metadata ## Commit message ## config: add a repo_config_get_uint() helper - Next commit adds a 'hook.jobs' config option of type 'unsigned int', + Next commits add a 'hook.jobs' config option of type 'unsigned int', so add a helper to parse it since the API only supports int and ulong. An alternative is to make 'hook.jobs' an 'int' or parse it as an 'int' 2: 724e8403e0 < -: ---------- hook: allow parallel hook execution -: ---------- > 3: 2fe5aa34d6 hook: refactor hook_config_cache from strmap to named struct -: ---------- > 4: 23853aa170 hook: parse the hook.jobs config -: ---------- > 5: 71380942dc hook: allow parallel hook execution -: ---------- > 6: 820064f1c9 hook: mark non-parallelizable hooks -: ---------- > 7: b328f3451f hook: add -j/--jobs option to git hook run -: ---------- > 8: ff27b34a8d hook: add per-event jobs config 3: edea5408e1 ! 9: 2bc572e46e hook: introduce extensions.hookStdoutToStderr @@ Commit message Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> ## Documentation/config/extensions.adoc ## -@@ Documentation/config/extensions.adoc: relativeWorktrees::: - repaired with either the `--relative-paths` option or with the - `worktree.useRelativePaths` config set to `true`. +@@ Documentation/config/extensions.adoc: The extension can be enabled automatically for new repositories by setting + `init.defaultSubmodulePathConfig` to `true`, for example by running + `git config --global init.defaultSubmodulePathConfig true`. +hookStdoutToStderr::: + If enabled, the stdout of all hooks is redirected to stderr. This @@ Documentation/config/extensions.adoc: relativeWorktrees::: ## Documentation/config/hook.adoc ## @@ Documentation/config/hook.adoc: hook.jobs:: - Specifies how many hooks can be run simultaneously during parallelized - hook execution. If unspecified, defaults to the number of processors on - the current system. + + + This setting has no effect unless all configured hooks for the event have + `hook.<name>.parallel` set to `true`. ++ +This has no effect for hooks requiring separate output streams (like `pre-push`) +unless `extensions.hookStdoutToStderr` is enabled. ## repository.c ## @@ repository.c: int repo_init(struct repository *repo, - repo->repository_format_worktree_config = format.worktree_config; repo->repository_format_relative_worktrees = format.relative_worktrees; repo->repository_format_precious_objects = format.precious_objects; + repo->repository_format_submodule_path_cfg = format.submodule_path_cfg; + repo->repository_format_hook_stdout_to_stderr = format.hook_stdout_to_stderr; /* take ownership of format.partial_clone */ @@ repository.c: int repo_init(struct repository *repo, ## repository.h ## @@ repository.h: struct repository { - int repository_format_worktree_config; int repository_format_relative_worktrees; int repository_format_precious_objects; + int repository_format_submodule_path_cfg; + int repository_format_hook_stdout_to_stderr; /* Indicate if a repository has a different 'commondir' from 'gitdir' */ @@ repository.h: struct repository { ## setup.c ## @@ setup.c: static enum extension_result handle_extension(const char *var, - } else if (!strcmp(ext, "relativeworktrees")) { - data->relative_worktrees = git_config_bool(var, value); + } else if (!strcmp(ext, "submodulepathconfig")) { + data->submodule_path_cfg = git_config_bool(var, value); return EXTENSION_OK; + } else if (!strcmp(ext, "hookstdouttostderr")) { + data->hook_stdout_to_stderr = git_config_bool(var, value); @@ setup.c: static enum extension_result handle_extension(const char *var, return EXTENSION_UNKNOWN; } @@ setup.c: const char *setup_git_directory_gently(int *nongit_ok) - repo_fmt.worktree_config; - the_repository->repository_format_relative_worktrees = repo_fmt.relative_worktrees; + the_repository->repository_format_submodule_path_cfg = + repo_fmt.submodule_path_cfg; + the_repository->repository_format_hook_stdout_to_stderr = + repo_fmt.hook_stdout_to_stderr; /* take ownership of repo_fmt.partial_clone */ the_repository->repository_format_partial_clone = repo_fmt.partial_clone; @@ setup.c: void check_repository_format(struct repository_format *fmt) - fmt->worktree_config; + fmt->submodule_path_cfg; the_repository->repository_format_relative_worktrees = fmt->relative_worktrees; + the_repository->repository_format_hook_stdout_to_stderr = @@ setup.c: void check_repository_format(struct repository_format *fmt) ## setup.h ## @@ setup.h: struct repository_format { - char *partial_clone; /* value of extensions.partialclone */ int worktree_config; int relative_worktrees; + int submodule_path_cfg; + int hook_stdout_to_stderr; int is_bare; int hash_algo; @@ t/t1800-hook.sh: test_expect_success 'client hooks: pre-push expects separate st ' +test_expect_success 'client hooks: extension makes pre-push merge stdout to stderr' ' -+ test_when_finished "rm -f stdout.actual stderr.actual" && ++ test_when_finished "rm -rf remote2 stdout.actual stderr.actual" && + git init --bare remote2 && + git remote add origin2 remote2 && + test_commit B && -+ # repositoryformatversion=1 might be already set (eg default sha256) -+ # so check before using test_config to set it -+ { test "$(git config core.repositoryformatversion)" = 1 || -+ test_config core.repositoryformatversion 1; } && + git config set core.repositoryformatversion 1 && + test_config extensions.hookStdoutToStderr true && + setup_hooks pre-push && @@ t/t1800-hook.sh: test_expect_success 'client hooks: pre-push expects separate st +' + +test_expect_success 'client hooks: pre-push defaults to serial execution' ' -+ test_when_finished "rm -rf repo-serial" && ++ test_when_finished "rm -rf remote-serial repo-serial" && + git init --bare remote-serial && + git init repo-serial && + git -C repo-serial remote add origin ../remote-serial && + test_commit -C repo-serial A && + -+ # Setup 2 pre-push hooks -+ write_script repo-serial/.git/hooks/pre-push <<-EOF && -+ sleep 2 -+ echo "Hook 1" >&2 -+ EOF ++ # Setup 2 pre-push hooks; no parallel=true so they must run serially. ++ # Use sentinel/detector pattern: hook-1 (sentinel, configured) runs first ++ # because configured hooks precede traditional hooks in list order; hook-2 ++ # (detector) runs second and checks whether hook-1 has finished. ++ git -C repo-serial config hook.hook-1.event pre-push && ++ git -C repo-serial config hook.hook-1.command \ ++ "touch sentinel.started; sleep 2; touch sentinel.done" && + git -C repo-serial config hook.hook-2.event pre-push && -+ git -C repo-serial config hook.hook-2.command "sleep 2; echo Hook 2 >&2" && ++ git -C repo-serial config hook.hook-2.command \ ++ "$(sentinel_detector sentinel hook.order)" && + + git -C repo-serial config hook.jobs 2 && + -+ start=$(date +%s) && + git -C repo-serial push origin HEAD >out 2>err && -+ end=$(date +%s) && -+ -+ duration=$((end - start)) && -+ # Serial >= 4s, parallel < 4s. -+ test $duration -ge 4 ++ echo serial >expect && ++ test_cmp expect repo-serial/hook.order +' + test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' ' @@ t/t1800-hook.sh: test_expect_success 'client hooks: pre-push expects separate st ## transport.c ## @@ transport.c: static int run_pre_push_hook(struct transport *transport, - opt.copy_feed_pipe_cb_data = copy_pre_push_hook_data; - opt.free_feed_pipe_cb_data = free_pre_push_hook_data; + opt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc; + opt.feed_pipe_cb_data_free = pre_push_hook_data_free; - /* - * pre-push hooks expect stdout & stderr to be separate, so don't merge - * them to keep backwards compatibility with existing hooks. - */ - opt.stdout_to_stderr = 0; -+ /* merge stdout to stderr only when extensions.StdoutToStderr is enabled */ ++ /* merge stdout to stderr only when extensions.hookStdoutToStderr is enabled */ + opt.stdout_to_stderr = the_repository->repository_format_hook_stdout_to_stderr; ret = run_hooks_opt(the_repository, "pre-push", &opt); 4: 4c85640615 ! 10: 35dbc4a6c5 hook: allow runtime enabling extensions.hookStdoutToStderr @@ Documentation/config/extensions.adoc: in parallel to be grouped (de-interleaved) If enabled, then worktrees will load config settings from the ## Documentation/config/hook.adoc ## -@@ Documentation/config/hook.adoc: hook.jobs:: +@@ Documentation/config/hook.adoc: This setting has no effect unless all configured hooks for the event have + This has no effect for hooks requiring separate output streams (like `pre-push`) unless `extensions.hookStdoutToStderr` is enabled. @@ Documentation/config/hook.adoc: hook.jobs:: + parallel execution for hooks like `pre-push`. ## hook.c ## -@@ hook.c: static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *op - { - unsigned int jobs = options->jobs; +@@ hook.c: struct hook_config_cache_entry { + * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *, + * where NULL == unset). + * jobs: value of the global hook.jobs key. Defaults to 0 if unset. ++ * force_stdout_to_stderr: value of hook.forceStdoutToStderr. Defaults to 0. + */ + struct hook_all_config_cb { + struct strmap commands; +@@ hook.c: struct hook_all_config_cb { + struct strmap parallel_hooks; + struct strmap event_jobs; + unsigned int jobs; ++ int force_stdout_to_stderr; + }; -+ /* -+ * Allow hook.forceStdoutToStderr to enable extensions.hookStdoutToStderr -+ * for existing repositories (runtime override). -+ */ -+ if (!options->stdout_to_stderr) { -+ int v = 0; -+ repo_config_get_bool(r, "hook.forceStdoutToStderr", &v); -+ options->stdout_to_stderr = v; -+ } + /* repo_config() callback that collects all hook.* configuration in one pass. */ +@@ hook.c: static int hook_config_lookup_all(const char *key, const char *value, + warning(_("hook.jobs must be positive, ignoring: 0")); + else + data->jobs = v; ++ } else if (!strcmp(subkey, "forcestdouttostderr") && value) { ++ int v = git_parse_maybe_bool(value); ++ if (v >= 0) ++ data->force_stdout_to_stderr = v; + } + return 0; + } +@@ hook.c: static void build_hook_config_map(struct repository *r, + + cache->jobs = cb_data.jobs; + cache->event_jobs = cb_data.event_jobs; ++ cache->force_stdout_to_stderr = cb_data.force_stdout_to_stderr; + + strmap_clear(&cb_data.commands, 1); + strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ +@@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options) + strvec_clear(&options->args); + } + ++static void hook_force_apply_stdout_to_stderr(struct repository *r, ++ struct run_hooks_opt *options) ++{ ++ int force = 0; ++ ++ if (r && r->gitdir && r->hook_config_cache) ++ force = r->hook_config_cache->force_stdout_to_stderr; ++ else ++ repo_config_get_bool(r, "hook.forceStdoutToStderr", &force); ++ ++ if (force) ++ options->stdout_to_stderr = 1; ++} + + /* Determine how many jobs to use for hook execution. */ + static unsigned int get_hook_jobs(struct repository *r, + struct run_hooks_opt *options, +@@ hook.c: static unsigned int get_hook_jobs(struct repository *r, + unsigned int jobs; + /* - * Hooks which configure stdout_to_stderr=0 (like pre-push), expect separate - * output streams. Unless extensions.StdoutToStderr is enabled (which forces +- * Hooks needing separate output streams must run sequentially. Next +- * commits will add an extension to allow parallelizing these as well. ++ * Apply hook.forceStdoutToStderr before anything else: it affects ++ * whether we can run in parallel or not. + */ ++ hook_force_apply_stdout_to_stderr(r, options); ++ ++ /* Hooks needing separate output streams must run sequentially. */ + if (!options->stdout_to_stderr) + return 1; + + + ## hook.h ## +@@ hook.h: struct hook_config_cache { + struct strmap hooks; /* maps event name -> string_list of hooks */ + struct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */ + unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ ++ int force_stdout_to_stderr; /* hook.forceStdoutToStderr config value */ + }; + + /** ## setup.c ## @@ setup.c: void initialize_repository_version(int hash_algo, - { struct strbuf repo_version = STRBUF_INIT; int target_version = GIT_REPO_VERSION; + int default_submodule_path_config = 0; + int default_hook_stdout_to_stderr = 0; /* * Note that we initialize the repository version to 1 when the ref @@ setup.c: void initialize_repository_version(int hash_algo, - clear_repository_format(&repo_fmt); + repo_config_set(the_repository, "extensions.submodulepathconfig", "true"); } + repo_config_get_bool(the_repository, "hook.forceStdoutToStderr", @@ setup.c: void initialize_repository_version(int hash_algo, ## t/t1800-hook.sh ## -@@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 config runs hooks in parallel' ' - test $duration -lt 4 +@@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' + test_cmp expect hook.order ' +test_expect_success '`git init` respects hook.forceStdoutToStderr' ' @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 config runs hooks in parallel' +' + +test_expect_success 'hook.forceStdoutToStderr enables pre-push parallel runs' ' -+ test_when_finished "rm -rf repo-parallel" && ++ test_when_finished "rm -rf repo-parallel remote-parallel" && + git init --bare remote-parallel && + git init repo-parallel && + git -C repo-parallel remote add origin ../remote-parallel && + test_commit -C repo-parallel A && + -+ write_script repo-parallel/.git/hooks/pre-push <<-EOF && -+ sleep 2 -+ echo "Hook 1" >&2 -+ EOF ++ write_sentinel_hook repo-parallel/.git/hooks/pre-push && + git -C repo-parallel config hook.hook-2.event pre-push && -+ git -C repo-parallel config hook.hook-2.command "sleep 2; echo Hook 2 >&2" && ++ git -C repo-parallel config hook.hook-2.command \ ++ "$(sentinel_detector sentinel hook.order)" && ++ git -C repo-parallel config hook.hook-2.parallel true && + + git -C repo-parallel config hook.jobs 2 && + git -C repo-parallel config hook.forceStdoutToStderr true && + -+ start=$(date +%s) && + git -C repo-parallel push origin HEAD >out 2>err && -+ end=$(date +%s) && -+ -+ duration=$((end - start)) && -+ # Serial >= 4s, parallel < 4s. -+ test $duration -lt 4 ++ echo parallel >expect && ++ test_cmp expect repo-parallel/hook.order +' + test_done Adrian Ratiu (7): repository: fix repo_init() memleak due to missing _clear() config: add a repo_config_get_uint() helper hook: refactor hook_config_cache from strmap to named struct hook: parse the hook.jobs config hook: add per-event jobs config hook: introduce extensions.hookStdoutToStderr hook: allow runtime enabling extensions.hookStdoutToStderr Emily Shaffer (3): hook: allow parallel hook execution hook: mark non-parallelizable hooks hook: add -j/--jobs option to git hook run Documentation/config/extensions.adoc | 15 + Documentation/config/hook.adoc | 49 +++ Documentation/git-hook.adoc | 18 +- builtin/am.c | 8 +- builtin/checkout.c | 19 +- builtin/clone.c | 6 +- builtin/hook.c | 5 +- builtin/receive-pack.c | 3 +- builtin/worktree.c | 2 +- commit.c | 2 +- config.c | 28 ++ config.h | 13 + hook.c | 213 +++++++++++-- hook.h | 38 ++- parse.c | 9 + parse.h | 1 + repository.c | 2 + repository.h | 4 +- setup.c | 17 + setup.h | 1 + t/t1800-hook.sh | 446 ++++++++++++++++++++++++++- transport.c | 7 +- 22 files changed, 843 insertions(+), 63 deletions(-) -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v2 01/10] repository: fix repo_init() memleak due to missing _clear() 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu @ 2026-02-22 0:28 ` Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 02/10] config: add a repo_config_get_uint() helper Adrian Ratiu ` (8 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:28 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu There is an old pre-existing memory leak in repo_init() due to failing to call clear_repository_format() in the error case. It went undetected because a specific bug is required to trigger it: enable a v1 extension in a repository with format v0. Obviously this can only happen in a development environment, so it does not trigger in normal usage, however the memleak is real and needs fixing. Fix it by also calling clear_repository_format() in the error case. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- repository.c | 1 + 1 file changed, 1 insertion(+) diff --git a/repository.c b/repository.c index f27ab29b0e..78aa2d31d0 100644 --- a/repository.c +++ b/repository.c @@ -298,6 +298,7 @@ int repo_init(struct repository *repo, return 0; error: + clear_repository_format(&format); repo_clear(repo); return -1; } -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 02/10] config: add a repo_config_get_uint() helper 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 01/10] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu @ 2026-02-22 0:28 ` Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 03/10] hook: refactor hook_config_cache from strmap to named struct Adrian Ratiu ` (7 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:28 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Next commits add a 'hook.jobs' config option of type 'unsigned int', so add a helper to parse it since the API only supports int and ulong. An alternative is to make 'hook.jobs' an 'int' or parse it as an 'int' then cast it to unsigned, however it's better to use proper helpers for the type. Using 'ulong' is another option which already has helpers, but it's a bit excessive in size for just the jobs number. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- config.c | 28 ++++++++++++++++++++++++++++ config.h | 13 +++++++++++++ parse.c | 9 +++++++++ parse.h | 1 + 4 files changed, 51 insertions(+) diff --git a/config.c b/config.c index 156f2a24fa..a1b92fe083 100644 --- a/config.c +++ b/config.c @@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value, return ret; } +unsigned int git_config_uint(const char *name, const char *value, + const struct key_value_info *kvi) +{ + unsigned int ret; + if (!git_parse_uint(value, &ret)) + die_bad_number(name, value, kvi); + return ret; +} + int64_t git_config_int64(const char *name, const char *value, const struct key_value_info *kvi) { @@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest) return 1; } +int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest) +{ + const char *value; + struct key_value_info kvi; + + if (!git_configset_get_value(set, key, &value, &kvi)) { + *dest = git_config_uint(key, value, &kvi); + return 0; + } else + return 1; +} + int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest) { const char *value; @@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo, return git_configset_get_int(repo->config, key, dest); } +int repo_config_get_uint(struct repository *repo, + const char *key, unsigned int *dest) +{ + git_config_check_init(repo); + return git_configset_get_uint(repo->config, key, dest); +} + int repo_config_get_ulong(struct repository *repo, const char *key, unsigned long *dest) { diff --git a/config.h b/config.h index ba426a960a..bf47fb3afc 100644 --- a/config.h +++ b/config.h @@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *); int64_t git_config_int64(const char *, const char *, const struct key_value_info *); +/** + * Identical to `git_config_int`, but for unsigned ints. + */ +unsigned int git_config_uint(const char *, const char *, + const struct key_value_info *); + /** * Identical to `git_config_int`, but for unsigned longs. */ @@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key, int git_configset_get_string(struct config_set *cs, const char *key, char **dest); int git_configset_get_int(struct config_set *cs, const char *key, int *dest); +int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest); int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest); int git_configset_get_bool(struct config_set *cs, const char *key, int *dest); int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest); @@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r, */ int repo_config_get_int(struct repository *r, const char *key, int *dest); +/** + * Similar to `repo_config_get_int` but for unsigned ints. + */ +int repo_config_get_uint(struct repository *r, + const char *key, unsigned int *dest); + /** * Similar to `repo_config_get_int` but for unsigned longs. */ diff --git a/parse.c b/parse.c index 48313571aa..d77f28046a 100644 --- a/parse.c +++ b/parse.c @@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret) return 1; } +int git_parse_uint(const char *value, unsigned int *ret) +{ + uintmax_t tmp; + if (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int))) + return 0; + *ret = tmp; + return 1; +} + int git_parse_ulong(const char *value, unsigned long *ret) { uintmax_t tmp; diff --git a/parse.h b/parse.h index ea32de9a91..a6dd37c4cb 100644 --- a/parse.h +++ b/parse.h @@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max); int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max); int git_parse_ssize_t(const char *, ssize_t *); int git_parse_ulong(const char *, unsigned long *); +int git_parse_uint(const char *value, unsigned int *ret); int git_parse_int(const char *value, int *ret); int git_parse_int64(const char *value, int64_t *ret); int git_parse_double(const char *value, double *ret); -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 03/10] hook: refactor hook_config_cache from strmap to named struct 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 01/10] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 02/10] config: add a repo_config_get_uint() helper Adrian Ratiu @ 2026-02-22 0:28 ` Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 04/10] hook: parse the hook.jobs config Adrian Ratiu ` (6 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:28 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Replace the raw `struct strmap *hook_config_cache` in `struct repository` with a `struct hook_config_cache` which wraps the strmap in a named field. Replace the bare `char *command` util pointer stored in each string_list item with a heap-allocated `struct hook_config_cache_entry` that carries that command string. This is just a refactoring with no behavior changes, to give the cache struct room to grow so it can carry the additional hook metadata we'll be adding in the following commits. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- hook.c | 65 +++++++++++++++++++++++++++++++++------------------- hook.h | 10 +++++++- repository.h | 3 ++- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/hook.c b/hook.c index 2c8252b2c4..89b9948512 100644 --- a/hook.c +++ b/hook.c @@ -118,6 +118,15 @@ static void unsorted_string_list_remove(struct string_list *list, unsorted_string_list_delete_item(list, item - list->items, 0); } +/* + * Cache entry stored as the .util pointer of string_list items inside the + * hook config cache. For now carries only the command for the hook. Next + * commits will add more data. + */ +struct hook_config_cache_entry { + char *command; +}; + /* * Callback struct to collect all hook.* keys in a single config pass. * commands: friendly-name to command map. @@ -205,21 +214,27 @@ static int hook_config_lookup_all(const char *key, const char *value, * Disabled hooks and hooks missing a command are already filtered out at * parse time, so callers can iterate the list directly. */ -void hook_cache_clear(struct strmap *cache) +void hook_cache_clear(struct hook_config_cache *cache) { struct hashmap_iter iter; struct strmap_entry *e; - strmap_for_each_entry(cache, &iter, e) { + strmap_for_each_entry(&cache->hooks, &iter, e) { struct string_list *hooks = e->value; - string_list_clear(hooks, 1); /* free util (command) pointers */ + for (size_t i = 0; i < hooks->nr; i++) { + struct hook_config_cache_entry *entry = hooks->items[i].util; + free(entry->command); + free(entry); + } + string_list_clear(hooks, 0); free(hooks); } - strmap_clear(cache, 0); + strmap_clear(&cache->hooks, 0); } /* Populate `cache` with the complete hook configuration */ -static void build_hook_config_map(struct repository *r, struct strmap *cache) +static void build_hook_config_map(struct repository *r, + struct hook_config_cache *cache) { struct hook_all_config_cb cb_data; struct hashmap_iter iter; @@ -241,6 +256,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) for (size_t i = 0; i < hook_names->nr; i++) { const char *hname = hook_names->items[i].string; + struct hook_config_cache_entry *entry; char *command; /* filter out disabled hooks */ @@ -254,12 +270,13 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) "'hook.%s.event' must be removed;" " aborting."), hname, hname); - /* util stores the command; owned by the cache. */ - string_list_append(hooks, hname)->util = - xstrdup(command); + /* util stores a cache entry; owned by the cache. */ + CALLOC_ARRAY(entry, 1); + entry->command = xstrdup(command); + string_list_append(hooks, hname)->util = entry; } - strmap_put(cache, e->key, hooks); + strmap_put(&cache->hooks, e->key, hooks); } strmap_clear(&cb_data.commands, 1); @@ -272,35 +289,35 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) } /* - * Return the hook config map for `r`, populating it first if needed. + * Return the hook config cache for `r`, populating it first if needed. * * Out-of-repo calls (r->gitdir == NULL) allocate and return a temporary - * cache map; the caller is responsible for freeing it with + * cache; the caller is responsible for freeing it with * hook_cache_clear() + free(). */ -static struct strmap *get_hook_config_cache(struct repository *r) +static struct hook_config_cache *get_hook_config_cache(struct repository *r) { - struct strmap *cache = NULL; + struct hook_config_cache *cache = NULL; if (r && r->gitdir) { /* - * For in-repo calls, the map is stored in r->hook_config_cache, - * so repeated invocations don't parse the configs, so allocate + * For in-repo calls, the cache is stored in r->hook_config_cache, + * so repeated invocations don't parse the configs; allocate * it just once on the first call. */ if (!r->hook_config_cache) { - r->hook_config_cache = xcalloc(1, sizeof(*cache)); - strmap_init(r->hook_config_cache); + CALLOC_ARRAY(r->hook_config_cache, 1); + strmap_init(&r->hook_config_cache->hooks); build_hook_config_map(r, r->hook_config_cache); } cache = r->hook_config_cache; } else { /* * Out-of-repo calls (no gitdir) allocate and return a temporary - * map cache which gets free'd immediately by the caller. + * cache which gets freed immediately by the caller. */ - cache = xcalloc(1, sizeof(*cache)); - strmap_init(cache); + CALLOC_ARRAY(cache, 1); + strmap_init(&cache->hooks); build_hook_config_map(r, cache); } @@ -312,13 +329,13 @@ static void list_hooks_add_configured(struct repository *r, struct string_list *list, struct run_hooks_opt *options) { - struct strmap *cache = get_hook_config_cache(r); - struct string_list *configured_hooks = strmap_get(cache, hookname); + struct hook_config_cache *cache = get_hook_config_cache(r); + struct string_list *configured_hooks = strmap_get(&cache->hooks, hookname); /* Iterate through configured hooks and initialize internal states */ for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) { const char *friendly_name = configured_hooks->items[i].string; - const char *command = configured_hooks->items[i].util; + struct hook_config_cache_entry *entry = configured_hooks->items[i].util; struct hook *hook = xcalloc(1, sizeof(struct hook)); if (options && options->feed_pipe_cb_data_alloc) @@ -328,7 +345,7 @@ static void list_hooks_add_configured(struct repository *r, hook->kind = HOOK_CONFIGURED; hook->u.configured.friendly_name = xstrdup(friendly_name); - hook->u.configured.command = xstrdup(command); + hook->u.configured.command = xstrdup(entry->command); string_list_append(list, friendly_name)->util = hook; } diff --git a/hook.h b/hook.h index e949f5d488..994f15522d 100644 --- a/hook.h +++ b/hook.h @@ -191,11 +191,19 @@ struct string_list *list_hooks(struct repository *r, const char *hookname, */ void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free); +/** + * Persistent cache for hook configuration, stored on `struct repository`. + * Populated lazily on first hook use and freed by repo_clear(). + */ +struct hook_config_cache { + struct strmap hooks; /* maps event name -> string_list of hooks */ +}; + /** * Frees the hook configuration cache stored in `struct repository`. * Called by repo_clear(). */ -void hook_cache_clear(struct strmap *cache); +void hook_cache_clear(struct hook_config_cache *cache); /** * Returns the path to the hook file, or NULL if the hook is missing diff --git a/repository.h b/repository.h index 25b2801228..2105768b8c 100644 --- a/repository.h +++ b/repository.h @@ -11,6 +11,7 @@ struct lock_file; struct pathspec; struct object_database; struct submodule_cache; +struct hook_config_cache; struct promisor_remote_config; struct remote_state; @@ -161,7 +162,7 @@ struct repository { * Lazily-populated cache mapping hook event names to configured hooks. * NULL until first hook use. */ - struct strmap *hook_config_cache; + struct hook_config_cache *hook_config_cache; /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 04/10] hook: parse the hook.jobs config 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu ` (2 preceding siblings ...) 2026-02-22 0:28 ` [PATCH v2 03/10] hook: refactor hook_config_cache from strmap to named struct Adrian Ratiu @ 2026-02-22 0:28 ` Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 05/10] hook: allow parallel hook execution Adrian Ratiu ` (5 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:28 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu The hook.jobs config is a global way to set hook parallelization for all hooks, in the sense that it is not per-event nor per-hook. Finer-grained configs will be added in later commits which can override it, for e.g. via a per-event type job options. Next commits will also add to this item's documentation. Parse hook.jobs config key in hook_config_lookup_all() and store its value in hook_all_config_cb.jobs, then transfer it into hook_config_cache.jobs after the config pass completes. This is mostly plumbing and the cached value is not yet used. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++++ hook.c | 22 ++++++++++++++++++++-- hook.h | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 64e845a260..c617261c57 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -22,3 +22,7 @@ hook.<name>.enabled:: configuration. This is particularly useful when a hook is defined in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. + +hook.jobs:: + Specifies how many hooks can be run simultaneously during parallelized + hook execution. If unspecified, defaults to 1 (serial execution). diff --git a/hook.c b/hook.c index 89b9948512..f4213f5878 100644 --- a/hook.c +++ b/hook.c @@ -132,11 +132,13 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.name.enabled = false. + * jobs: value of the global hook.jobs key. Defaults to 0 if unset. */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + unsigned int jobs; }; /* repo_config() callback that collects all hook.* configuration in one pass. */ @@ -152,6 +154,20 @@ static int hook_config_lookup_all(const char *key, const char *value, if (parse_config_key(key, "hook", &name, &name_len, &subkey)) return 0; + /* Handle plain hook.<key> entries that have no hook name component. */ + if (!name) { + if (!strcmp(subkey, "jobs") && value) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.jobs must be a positive integer, ignoring: '%s'"), value); + else if (!v) + warning(_("hook.jobs must be positive, ignoring: 0")); + else + data->jobs = v; + } + return 0; + } + if (!value) return config_error_nonbool(key); @@ -236,7 +252,7 @@ void hook_cache_clear(struct hook_config_cache *cache) static void build_hook_config_map(struct repository *r, struct hook_config_cache *cache) { - struct hook_all_config_cb cb_data; + struct hook_all_config_cb cb_data = { 0 }; struct hashmap_iter iter; struct strmap_entry *e; @@ -244,7 +260,7 @@ static void build_hook_config_map(struct repository *r, strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); - /* Parse all configs in one run. */ + /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); /* Construct the cache from parsed configs. */ @@ -279,6 +295,8 @@ static void build_hook_config_map(struct repository *r, strmap_put(&cache->hooks, e->key, hooks); } + cache->jobs = cb_data.jobs; + strmap_clear(&cb_data.commands, 1); string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { diff --git a/hook.h b/hook.h index 994f15522d..7e83a3474f 100644 --- a/hook.h +++ b/hook.h @@ -197,6 +197,7 @@ void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free); */ struct hook_config_cache { struct strmap hooks; /* maps event name -> string_list of hooks */ + unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ }; /** -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 05/10] hook: allow parallel hook execution 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu ` (3 preceding siblings ...) 2026-02-22 0:28 ` [PATCH v2 04/10] hook: parse the hook.jobs config Adrian Ratiu @ 2026-02-22 0:28 ` Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 06/10] hook: mark non-parallelizable hooks Adrian Ratiu ` (4 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:28 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Emily Shaffer, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <nasamuffin@google.com> Hooks always run in sequential order due to the hardcoded jobs == 1 passed to run_process_parallel(). Remove that hardcoding to allow users to run hooks in parallel (opt-in). Users need to decide which hooks to run in parallel, by specifying "parallel = true" in the config, because git cannot know if their specific hooks are safe to run or not in parallel (for e.g. two hooks might write to the same file or call the same program). Some hooks are unsafe to run in parallel by design: these will marked in the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL. The hook.jobs config specifies the default number of jobs applied to all hooks which have parallelism enabled. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 13 +++ hook.c | 69 ++++++++++++++-- hook.h | 25 ++++++ t/t1800-hook.sh | 142 +++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 8 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index c617261c57..e306ffa80b 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -23,6 +23,19 @@ hook.<name>.enabled:: in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. +hook.<name>.parallel:: + Whether the hook `hook.<name>` may run in parallel with other hooks + for the same event. Defaults to `false`. Set to `true` only when the + hook script is safe to run concurrently with other hooks for the same + event. If any hook for an event does not have this set to `true`, + all hooks for that event run sequentially regardless of `hook.jobs`. + Only configured (named) hooks need to declare this. Traditional hooks + found in the hooks directory do not need to, and run in parallel when + the effective job count is greater than 1. See linkgit:git-hook[1]. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). ++ +This setting has no effect unless all configured hooks for the event have +`hook.<name>.parallel` set to `true`. diff --git a/hook.c b/hook.c index f4213f5878..3d624e2bcd 100644 --- a/hook.c +++ b/hook.c @@ -120,11 +120,11 @@ static void unsorted_string_list_remove(struct string_list *list, /* * Cache entry stored as the .util pointer of string_list items inside the - * hook config cache. For now carries only the command for the hook. Next - * commits will add more data. + * hook config cache. Carries both the resolved command and the parallel flag. */ struct hook_config_cache_entry { char *command; + unsigned int parallel:1; }; /* @@ -132,12 +132,14 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.name.enabled = false. + * parallel_hooks: friendly-name to parallel flag. * jobs: value of the global hook.jobs key. Defaults to 0 if unset. */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + struct strmap parallel_hooks; unsigned int jobs; }; @@ -216,6 +218,10 @@ static int hook_config_lookup_all(const char *key, const char *value, default: break; /* ignore unrecognised values */ } + } else if (!strcmp(subkey, "parallel")) { + int v = git_parse_maybe_bool(value); + if (v >= 0) + strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); } free(hook_name); @@ -259,6 +265,7 @@ static void build_hook_config_map(struct repository *r, strmap_init(&cb_data.commands); strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); + strmap_init(&cb_data.parallel_hooks); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -273,6 +280,7 @@ static void build_hook_config_map(struct repository *r, for (size_t i = 0; i < hook_names->nr; i++) { const char *hname = hook_names->items[i].string; struct hook_config_cache_entry *entry; + void *par = strmap_get(&cb_data.parallel_hooks, hname); char *command; /* filter out disabled hooks */ @@ -289,6 +297,7 @@ static void build_hook_config_map(struct repository *r, /* util stores a cache entry; owned by the cache. */ CALLOC_ARRAY(entry, 1); entry->command = xstrdup(command); + entry->parallel = par ? (int)(uintptr_t)par : 0; string_list_append(hooks, hname)->util = entry; } @@ -298,6 +307,7 @@ static void build_hook_config_map(struct repository *r, cache->jobs = cb_data.jobs; strmap_clear(&cb_data.commands, 1); + strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { string_list_clear(e->value, 0); @@ -364,6 +374,7 @@ static void list_hooks_add_configured(struct repository *r, hook->kind = HOOK_CONFIGURED; hook->u.configured.friendly_name = xstrdup(friendly_name); hook->u.configured.command = xstrdup(entry->command); + hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; } @@ -499,21 +510,67 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* Determine how many jobs to use for hook execution. */ +static unsigned int get_hook_jobs(struct repository *r, + struct run_hooks_opt *options, + struct string_list *hook_list) +{ + unsigned int jobs; + + /* + * Hooks needing separate output streams must run sequentially. Next + * commits will add an extension to allow parallelizing these as well. + */ + if (!options->stdout_to_stderr) + return 1; + + /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ + if (options->jobs) + return options->jobs; + + /* + * Use hook.jobs from the already-parsed config cache (in-repo), or + * fall back to a direct config lookup (out-of-repo). Default to 1. + */ + if (r && r->gitdir && r->hook_config_cache) + /* Use the already-parsed cache (in-repo) */ + jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; + else + /* No cache present (out-of-repo call), use direct cfg lookup */ + jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs; + + /* + * Cap to serial any configured hook not marked as parallel = true. + * This enforces the parallel = false default, even for "traditional" + * hooks from the hookdir which cannot be marked parallel = true. + */ + for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; + if (h->kind == HOOK_CONFIGURED && !h->parallel) + jobs = 1; + } + + return jobs; +} + int run_hooks_opt(struct repository *r, const char *hook_name, struct run_hooks_opt *options) { + struct string_list *hook_list = list_hooks(r, hook_name, options); struct hook_cb_data cb_data = { .rc = 0, .hook_name = hook_name, + .hook_command_list = hook_list, .options = options, }; int ret = 0; + unsigned int jobs = get_hook_jobs(r, options, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, - .processes = options->jobs, - .ungroup = options->jobs == 1, + .processes = jobs, + .ungroup = jobs == 1, .get_next_task = pick_next_hook, .start_failure = notify_start_failure, @@ -529,9 +586,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->path_to_stdin && options->feed_pipe) BUG("options path_to_stdin and feed_pipe are mutually exclusive"); - if (!options->jobs) - BUG("run_hooks_opt must be called with options.jobs >= 1"); - /* * Ensure cb_data copy and free functions are either provided together, * or neither one is provided. @@ -543,7 +597,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->invoked_hook) *options->invoked_hook = 0; - cb_data.hook_command_list = list_hooks(r, hook_name, options); if (!cb_data.hook_command_list->nr) { if (options->error_if_missing) ret = error("cannot find a hook named %s", hook_name); diff --git a/hook.h b/hook.h index 7e83a3474f..1f29798a77 100644 --- a/hook.h +++ b/hook.h @@ -29,6 +29,13 @@ struct hook { } configured; } u; + /** + * Whether this hook may run in parallel with other hooks for the same + * event. Only useful for configured (named) hooks. Traditional hooks + * always default to 0 (serial). Set via `hook.<name>.parallel = true`. + */ + unsigned int parallel:1; + /** * Opaque data pointer used to keep internal state across callback calls. * @@ -62,6 +69,8 @@ struct run_hooks_opt * * If > 1, output will be buffered and de-interleaved (ungroup=0). * If == 1, output will be real-time (ungroup=1). + * If == 0, the 'hook.jobs' config is used or, if the config is unset, + * defaults to 1 (serial execution). */ unsigned int jobs; @@ -142,7 +151,23 @@ struct run_hooks_opt cb_data_free_fn feed_pipe_cb_data_free; }; +/** + * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to + * the 'hook.jobs' config, falling back to serial (1) if unset. + */ #define RUN_HOOKS_OPT_INIT { \ + .env = STRVEC_INIT, \ + .args = STRVEC_INIT, \ + .stdout_to_stderr = 1, \ + .jobs = 0, \ +} + +/** + * Initializer for hooks that must always run sequentially regardless of + * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized + * .jobs = 1 is non-overridable. + */ +#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \ .env = STRVEC_INIT, \ .args = STRVEC_INIT, \ .stdout_to_stderr = 1, \ diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index b1583e9ef9..f8318c3510 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -21,6 +21,57 @@ setup_hookdir () { test_when_finished rm -rf .git/hooks } +# write_sentinel_hook <path> [sentinel] +# +# Writes a hook that marks itself as started, sleeps for a few seconds, then +# marks itself done. The sleep must be long enough that sentinel_detector can +# observe <sentinel>.started before <sentinel>.done appears when both hooks +# run concurrently in parallel mode. +write_sentinel_hook () { + sentinel="${2:-sentinel}" + write_script "$1" <<-EOF + touch ${sentinel}.started && + sleep 2 && + touch ${sentinel}.done + EOF +} + +# sentinel_detector <sentinel> <output> +# +# Returns a shell command string suitable for use as hook.<name>.command. +# The detector must be registered after the sentinel: +# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists) +# before the detector starts. +# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared +# yet and the detector just sees <sentinel>.started. +# +# At start, poll until <sentinel>.started exists to absorb startup jitter, then +# write to <output>: +# 1. 'serial' if <sentinel>.done exists (sentinel finished before we started), +# 2. 'parallel' if only <sentinel>.started exists (sentinel still running), +# 3. 'timeout' if <sentinel>.started never appeared. +# +# The command ends with ':' so when git appends "$@" for hooks that receive +# positional arguments (e.g. pre-push), the result ': "$@"' is valid shell +# rather than a syntax error 'fi "$@"'. +sentinel_detector () { + cat <<-EOF + i=0 + while ! test -f ${1}.started && test \$i -lt 10; do + sleep 1 + i=\$((i+1)) + done + if test -f ${1}.done; then + echo serial >${2} + elif test -f ${1}.started; then + echo parallel >${2} + else + echo timeout >${2} + fi + : + EOF +} + test_expect_success 'git hook usage' ' test_expect_code 129 git hook && test_expect_code 129 git hook run && @@ -553,4 +604,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'hook.jobs=1 config runs hooks in series' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + + # Use two configured hooks so the execution order is deterministic: + # hook-1 (sentinel) is listed before hook-2 (detector), so hook-1 + # always runs first even in serial mode. + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 1 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.jobs=2 config runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<name>.parallel=true enables parallel execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'one non-parallel hook forces the whole event to run serially' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 has no parallel=true: should force serial for all + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 06/10] hook: mark non-parallelizable hooks 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu ` (4 preceding siblings ...) 2026-02-22 0:28 ` [PATCH v2 05/10] hook: allow parallel hook execution Adrian Ratiu @ 2026-02-22 0:29 ` Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 07/10] hook: add -j/--jobs option to git hook run Adrian Ratiu ` (3 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:29 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> Several hooks are known to be inherently non-parallelizable, so initialize them with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides any hook.jobs or runtime -j flags. These hooks are: applypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout, and push-to-checkout. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++++ builtin/am.c | 8 +++++--- builtin/checkout.c | 19 +++++++++++++------ builtin/clone.c | 6 ++++-- builtin/receive-pack.c | 3 ++- builtin/worktree.c | 2 +- commit.c | 2 +- t/t1800-hook.sh | 16 ++++++++++++++++ 8 files changed, 46 insertions(+), 14 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index e306ffa80b..8894088bda 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -36,6 +36,10 @@ hook.<name>.parallel:: hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Some hooks always run sequentially regardless of this setting because + git knows they cannot safely be parallelized: `applypatch-msg`, + `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, + `post-checkout`, and `push-to-checkout`. + This setting has no effect unless all configured hooks for the event have `hook.<name>.parallel` set to `true`. diff --git a/builtin/am.c b/builtin/am.c index e0c767e223..45a8e78d0b 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state) assert(state->msg); - if (!state->no_verify) - ret = run_hooks_l(the_repository, "applypatch-msg", - am_path(state, "final-commit"), NULL); + if (!state->no_verify) { + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + strvec_push(&opt.args, am_path(state, "final-commit")); + ret = run_hooks_opt(the_repository, "applypatch-msg", &opt); + } if (!ret) { FREE_AND_NULL(state->msg); diff --git a/builtin/checkout.c b/builtin/checkout.c index f7b313816e..9f2d84e3fc 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -31,6 +31,7 @@ #include "resolve-undo.h" #include "revision.h" #include "setup.h" +#include "strvec.h" #include "submodule.h" #include "symlinks.h" #include "trace2.h" @@ -137,13 +138,19 @@ static void branch_info_release(struct branch_info *info) static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit, int changed) { - return run_hooks_l(the_repository, "post-checkout", - oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), - oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), - changed ? "1" : "0", NULL); - /* "new_commit" can be NULL when checking out from the index before - a commit exists. */ + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + /* + * "new_commit" can be NULL when checking out from the index before + * a commit exists. + */ + strvec_pushl(&opt.args, + oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), + oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), + changed ? "1" : "0", + NULL); + + return run_hooks_opt(the_repository, "post-checkout", &opt); } static int update_some(const struct object_id *oid, struct strbuf *base, diff --git a/builtin/clone.c b/builtin/clone.c index b14a39a687..bce3f732b9 100644 --- a/builtin/clone.c +++ b/builtin/clone.c @@ -644,6 +644,7 @@ static int checkout(int submodule_progress, int filter_submodules, struct tree *tree; struct tree_desc t; int err = 0; + struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; if (option_no_checkout) return 0; @@ -694,8 +695,9 @@ static int checkout(int submodule_progress, int filter_submodules, if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); - err |= run_hooks_l(the_repository, "post-checkout", oid_to_hex(null_oid(the_hash_algo)), - oid_to_hex(&oid), "1", NULL); + strvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)), + oid_to_hex(&oid), "1", NULL); + err |= run_hooks_opt(the_repository, "post-checkout", &hook_opt); if (!err && (option_recurse_submodules.nr > 0)) { struct child_process cmd = CHILD_PROCESS_INIT; diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index f23772bc56..5f9335936a 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -1453,7 +1453,8 @@ static const char *push_to_checkout(unsigned char *hash, struct strvec *env, const char *work_tree) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + opt.invoked_hook = invoked_hook; strvec_pushf(env, "GIT_WORK_TREE=%s", absolute_path(work_tree)); diff --git a/builtin/worktree.c b/builtin/worktree.c index 3d6547c23b..af13386697 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -574,7 +574,7 @@ static int add_worktree(const char *path, const char *refname, * is_junk is cleared, but do return appropriate code when hook fails. */ if (!ret && opts->checkout && !opts->orphan) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL); strvec_pushl(&opt.args, diff --git a/commit.c b/commit.c index d16ae73345..5b2276a80e 100644 --- a/commit.c +++ b/commit.c @@ -1957,7 +1957,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len) int run_commit_hook(int editor_is_used, const char *index_file, int *invoked_hook, const char *name, ...) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; va_list args; const char *arg; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index f8318c3510..0d17969cdf 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -695,4 +695,20 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall test_cmp expect hook.order ' +test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event pre-commit && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event pre-commit && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + test_config hook.jobs 2 && + git commit --allow-empty -m "test: verify force-serial on pre-commit" && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 07/10] hook: add -j/--jobs option to git hook run 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu ` (5 preceding siblings ...) 2026-02-22 0:29 ` [PATCH v2 06/10] hook: mark non-parallelizable hooks Adrian Ratiu @ 2026-02-22 0:29 ` Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 08/10] hook: add per-event jobs config Adrian Ratiu ` (2 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:29 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> Expose the parallel job count as a command-line flag so callers can request parallelism without relying only on the hook.jobs config. Add tests covering serial/parallel execution and TTY behaviour under -j1 vs -jN. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/git-hook.adoc | 18 +++++- builtin/hook.c | 5 +- hook.c | 14 ++-- t/t1800-hook.sh | 123 ++++++++++++++++++++++++++++++++++-- 4 files changed, 144 insertions(+), 16 deletions(-) diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc index 12d2701b52..c9492f6f8e 100644 --- a/Documentation/git-hook.adoc +++ b/Documentation/git-hook.adoc @@ -8,7 +8,8 @@ git-hook - Run git hooks SYNOPSIS -------- [verse] -'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>] +'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>] + <hook-name> [-- <hook-args>] 'git hook' list [-z] <hook-name> DESCRIPTION @@ -134,6 +135,18 @@ OPTIONS -z:: Terminate "list" output lines with NUL instead of newlines. +-j:: +--jobs:: + Only valid for `run`. ++ +Specify how many hooks to run simultaneously. If this flag is not specified, +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If +neither is specified, defaults to 1 (serial execution). Some hooks always run +sequentially regardless of this flag or the `hook.jobs` config, because git +knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`, +`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and +`push-to-checkout`. + WRAPPERS -------- @@ -156,7 +169,8 @@ running: git hook run mywrapper-start-tests \ # providing something to stdin --stdin some-tempfile-123 \ - # execute hooks in serial + # execute multiple hooks in parallel + --jobs 3 \ # plus some arguments of your own... -- \ --testname bar \ diff --git a/builtin/hook.c b/builtin/hook.c index 83020dfb4f..1fdde80113 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -9,7 +9,8 @@ #include "abspath.h" #define BUILTIN_HOOK_RUN_USAGE \ - 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" \ + "<hook-name> [-- <hook-args>]") #define BUILTIN_HOOK_LIST_USAGE \ N_("git hook list [-z] <hook-name>") @@ -97,6 +98,8 @@ static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested <hook-name>")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), N_("file to read into hooks' stdin")), + OPT_UNSIGNED('j', "jobs", &opt.jobs, + N_("run up to <n> hooks simultaneously")), OPT_END(), }; int ret; diff --git a/hook.c b/hook.c index 3d624e2bcd..6214276e3d 100644 --- a/hook.c +++ b/hook.c @@ -524,15 +524,17 @@ static unsigned int get_hook_jobs(struct repository *r, if (!options->stdout_to_stderr) return 1; - /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ - if (options->jobs) - return options->jobs; + /* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */ + if (options->jobs == 1) + return 1; /* - * Use hook.jobs from the already-parsed config cache (in-repo), or - * fall back to a direct config lookup (out-of-repo). Default to 1. + * Resolve effective job count: -jN (when given) overrides config. + * Default to 1 when both config an -jN are missing. */ - if (r && r->gitdir && r->hook_config_cache) + if (options->jobs > 1) + jobs = options->jobs; + else if (r && r->gitdir && r->hook_config_cache) /* Use the already-parsed cache (in-repo) */ jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; else diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 0d17969cdf..a6913b8c62 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -239,10 +239,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' ' ' test_hook_tty () { - cat >expect <<-\EOF - STDOUT TTY - STDERR TTY - EOF + expect_tty=$1 + shift + + if test "$expect_tty" != "no_tty"; then + cat >expect <<-\EOF + STDOUT TTY + STDERR TTY + EOF + else + cat >expect <<-\EOF + STDOUT NO TTY + STDERR NO TTY + EOF + fi test_when_finished "rm -rf repo" && git init repo && @@ -260,12 +270,21 @@ test_hook_tty () { test_cmp expect repo/actual } -test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' ' - test_hook_tty hook run pre-commit +test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' ' + # hooks running sequentially (-j1) are always connected to the tty for + # optimum real-time performance. + test_hook_tty tty hook run -j1 pre-commit +' + +test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' ' + # Hooks are not connected to the tty when run in parallel, instead they + # output to a pipe through which run-command collects and de-interlaces + # their outputs, which then gets passed either to the tty or a sideband. + test_hook_tty no_tty hook run -j2 pre-commit ' test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' ' - test_hook_tty commit -m"B.new" + test_hook_tty tty commit -m"B.new" ' test_expect_success 'git hook list orders by config order' ' @@ -604,6 +623,96 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'parallel hook output is not interleaved' ' + test_when_finished "rm -rf .git/hooks" && + + write_script .git/hooks/test-hook <<-EOF && + echo "Hook 1 Start" + sleep 1 + echo "Hook 1 End" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "echo \"Hook 2 Start\"; sleep 2; echo \"Hook 2 End\"" && + test_config hook.hook-2.parallel true && + test_config hook.hook-3.event test-hook && + test_config hook.hook-3.command \ + "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" && + test_config hook.hook-3.parallel true && + + git hook run -j3 test-hook >out 2>err.parallel && + + # Verify Hook 1 output is grouped + sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out && + test_line_count = 2 hook1_out && + + # Verify Hook 2 output is grouped + sed -n "/Hook 2 Start/,/Hook 2 End/p" err.parallel >hook2_out && + test_line_count = 2 hook2_out && + + # Verify Hook 3 output is grouped + sed -n "/Hook 3 Start/,/Hook 3 End/p" err.parallel >hook3_out && + test_line_count = 2 hook3_out +' + +test_expect_success 'git hook run -j1 runs hooks in series' ' + test_when_finished "rm -rf .git/hooks" && + + test_config hook.series-1.event "test-hook" && + test_config hook.series-1.command "echo 1" --add && + test_config hook.series-2.event "test-hook" && + test_config hook.series-2.command "echo 2" --add && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + echo 3 + EOF + + cat >expected <<-\EOF && + 1 + 2 + 3 + EOF + + git hook run -j1 test-hook 2>actual && + test_cmp expected actual +' + +test_expect_success 'git hook run -j2 runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + git hook run -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'git hook run -j2 is blocked by parallel=false' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # -j2 must not override parallel=false on configured hooks. + git hook run -j2 test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_expect_success 'hook.jobs=1 config runs hooks in series' ' test_when_finished "rm -f sentinel.started sentinel.done hook.order" && -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 08/10] hook: add per-event jobs config 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu ` (6 preceding siblings ...) 2026-02-22 0:29 ` [PATCH v2 07/10] hook: add -j/--jobs option to git hook run Adrian Ratiu @ 2026-02-22 0:29 ` Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 09/10] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 10/10] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:29 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Add a hook.<event>.jobs count config that allows users to override the global hook.jobs setting for specific hook events. This allows finer-grained control over parallelism on a per-event basis. For example, to run `post-receive` hooks with up to 4 parallel jobs while keeping other events at their global default: [hook] post-receive.jobs = 4 Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 19 +++++++++++ hook.c | 47 +++++++++++++++++++++++---- hook.h | 1 + t/t1800-hook.sh | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 8894088bda..6ad23ac71d 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -33,9 +33,28 @@ hook.<name>.parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. +hook.<event>.jobs:: + Specifies how many hooks can be run simultaneously for the `<event>` + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` + for this specific event. The same parallelism restrictions apply: this + setting has no effect unless all configured hooks for the event have + `hook.<friendly-name>.parallel` set to `true`. Must be a positive int, + zero is rejected with a warning. See linkgit:git-hook[1]. ++ +Note on naming: although this key resembles `hook.<friendly-name>.*` +(a per-hook setting), `<event>` must be the event name, not a hook +friendly name. The key component is stored literally and looked up by +event name at runtime with no translation between the two namespaces. +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the +lookup at runtime uses the event name (e.g. `"post-receive"`), so +`hook.my-hook.jobs` is silently ignored even when `my-hook` is +registered for that event. Use `hook.post-receive.jobs` or any other +valid event name when setting `hook.<event>.jobs`. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Can be overridden on a per-event basis with `hook.<event>.jobs`. Some hooks always run sequentially regardless of this setting because git knows they cannot safely be parallelized: `applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, diff --git a/hook.c b/hook.c index 6214276e3d..013e41a8d6 100644 --- a/hook.c +++ b/hook.c @@ -133,6 +133,8 @@ struct hook_config_cache_entry { * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.name.enabled = false. * parallel_hooks: friendly-name to parallel flag. + * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *, + * where NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset. */ struct hook_all_config_cb { @@ -140,6 +142,7 @@ struct hook_all_config_cb { struct strmap event_hooks; struct string_list disabled_hooks; struct strmap parallel_hooks; + struct strmap event_jobs; unsigned int jobs; }; @@ -222,6 +225,20 @@ static int hook_config_lookup_all(const char *key, const char *value, int v = git_parse_maybe_bool(value); if (v >= 0) strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); + } else if (!strcmp(subkey, "jobs")) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.%s.jobs must be a positive integer, ignoring: '%s'"), + hook_name, value); + else if (!v) + warning(_("hook.%s.jobs must be positive, ignoring: 0"), hook_name); + else { + unsigned int *old; + unsigned int *p = xmalloc(sizeof(*p)); + *p = v; + old = strmap_put(&data->event_jobs, hook_name, p); + free(old); + } } free(hook_name); @@ -252,6 +269,7 @@ void hook_cache_clear(struct hook_config_cache *cache) free(hooks); } strmap_clear(&cache->hooks, 0); + strmap_clear(&cache->event_jobs, 1); /* free heap-allocated unsigned int * values */ } /* Populate `cache` with the complete hook configuration */ @@ -266,6 +284,7 @@ static void build_hook_config_map(struct repository *r, strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); strmap_init(&cb_data.parallel_hooks); + strmap_init(&cb_data.event_jobs); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -305,6 +324,7 @@ static void build_hook_config_map(struct repository *r, } cache->jobs = cb_data.jobs; + cache->event_jobs = cb_data.event_jobs; strmap_clear(&cb_data.commands, 1); strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ @@ -513,6 +533,7 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, + const char *hook_name, struct string_list *hook_list) { unsigned int jobs; @@ -529,22 +550,36 @@ static unsigned int get_hook_jobs(struct repository *r, return 1; /* - * Resolve effective job count: -jN (when given) overrides config. - * Default to 1 when both config an -jN are missing. + * Resolve effective job count: -j N (when given) overrides config. + * hook.<event>.jobs overrides hook.jobs. + * Unset configs and -jN default to 1. */ - if (options->jobs > 1) + if (options->jobs > 1) { jobs = options->jobs; - else if (r && r->gitdir && r->hook_config_cache) + } else if (r && r->gitdir && r->hook_config_cache) { /* Use the already-parsed cache (in-repo) */ + unsigned int *event_jobs = strmap_get(&r->hook_config_cache->event_jobs, + hook_name); jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; - else + if (event_jobs) + jobs = *event_jobs; + } else { /* No cache present (out-of-repo call), use direct cfg lookup */ + unsigned int event_jobs; + char *key; jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs; + key = xstrfmt("hook.%s.jobs", hook_name); + if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) + jobs = event_jobs; + free(key); + } /* * Cap to serial any configured hook not marked as parallel = true. * This enforces the parallel = false default, even for "traditional" * hooks from the hookdir which cannot be marked parallel = true. + * The same restriction applies whether jobs came from hook.jobs or + * hook.<event>.jobs. */ for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) { struct hook *h = hook_list->items[i].util; @@ -566,7 +601,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name, .options = options, }; int ret = 0; - unsigned int jobs = get_hook_jobs(r, options, hook_list); + unsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, diff --git a/hook.h b/hook.h index 1f29798a77..22fc59e67a 100644 --- a/hook.h +++ b/hook.h @@ -222,6 +222,7 @@ void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free); */ struct hook_config_cache { struct strmap hooks; /* maps event name -> string_list of hooks */ + struct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */ unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ }; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index a6913b8c62..f5c0655adb 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -820,4 +820,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' test_cmp expect hook.order ' +test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=1 (serial), but per-event override allows parallel. + test_config hook.jobs 1 && + test_config hook.test-hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=4 allows parallel, but per-event override forces serial. + test_config hook.jobs 4 && + test_config hook.test-hook.jobs 1 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # Per-event jobs=2 but no hook has parallel=true: must still run serially. + test_config hook.test-hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 09/10] hook: introduce extensions.hookStdoutToStderr 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu ` (7 preceding siblings ...) 2026-02-22 0:29 ` [PATCH v2 08/10] hook: add per-event jobs config Adrian Ratiu @ 2026-02-22 0:29 ` Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 10/10] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:29 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu All hooks already redirect stdout to stderr with the exception of pre-push which has a known user who depends on the separate stdout versus stderr outputs (the git-lfs project). The pre-push behavior was a surprise which we found out about after causing a regression for git-lfs. Notably, it might not be the only exception (it's the one we know about). There might be more. This presents a challenge because stdout_to_stderr is required for hook parallelization, so run-command can buffer and de-interleave the hook outputs using ungroup=0, when hook.jobs > 1. Introduce an extension to enforce consistency: all hooks merge stdout into stderr and can be safely parallelized. This provides a clean separation and avoids breaking existing stdout vs stderr behavior. When this extension is disabled, the `hook.jobs` config has no effect for pre-push, to prevent garbled (interleaved) parallel output, so it runs sequentially like before. Alternatives I've considered to this extension include: 1. Allowing pre-push to run in parallel with interleaved output. 2. Always running pre-push sequentially (no parallel jobs for it). 3. Making users (only git-lfs? maybe more?) fix their hooks to read stderr not stdout. Out of all these alternatives, I think this extension is the most reasonable compromise, to not break existing users, allow pre-push parallel jobs for those who need it (with correct outputs) and also future-proofing in case there are any more exceptions to be added. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/extensions.adoc | 12 +++++++++ Documentation/config/hook.adoc | 3 +++ repository.c | 1 + repository.h | 1 + setup.c | 7 ++++++ setup.h | 1 + t/t1800-hook.sh | 37 ++++++++++++++++++++++++++++ transport.c | 7 ++---- 8 files changed, 64 insertions(+), 5 deletions(-) diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index 2aef3315b1..342734668d 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -102,6 +102,18 @@ The extension can be enabled automatically for new repositories by setting `init.defaultSubmodulePathConfig` to `true`, for example by running `git config --global init.defaultSubmodulePathConfig true`. +hookStdoutToStderr::: + If enabled, the stdout of all hooks is redirected to stderr. This + enforces consistency, since by default most hooks already behave + this way, with pre-push being the only known exception. ++ +This is useful for parallel hook execution (see the `hook.jobs` config in +linkgit:git-config[1]), as it allows the output of multiple hooks running +in parallel to be grouped (de-interleaved) correctly. ++ +Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push +hooks, which will always be run sequentially. + worktreeConfig::: If enabled, then worktrees will load config settings from the `$GIT_DIR/config.worktree` file in addition to the diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 6ad23ac71d..aa8a949a36 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -62,3 +62,6 @@ hook.jobs:: + This setting has no effect unless all configured hooks for the event have `hook.<name>.parallel` set to `true`. ++ +This has no effect for hooks requiring separate output streams (like `pre-push`) +unless `extensions.hookStdoutToStderr` is enabled. diff --git a/repository.c b/repository.c index 78aa2d31d0..d7f247b676 100644 --- a/repository.c +++ b/repository.c @@ -283,6 +283,7 @@ int repo_init(struct repository *repo, repo->repository_format_relative_worktrees = format.relative_worktrees; repo->repository_format_precious_objects = format.precious_objects; repo->repository_format_submodule_path_cfg = format.submodule_path_cfg; + repo->repository_format_hook_stdout_to_stderr = format.hook_stdout_to_stderr; /* take ownership of format.partial_clone */ repo->repository_format_partial_clone = format.partial_clone; diff --git a/repository.h b/repository.h index 2105768b8c..a4d7f129ad 100644 --- a/repository.h +++ b/repository.h @@ -173,6 +173,7 @@ struct repository { int repository_format_relative_worktrees; int repository_format_precious_objects; int repository_format_submodule_path_cfg; + int repository_format_hook_stdout_to_stderr; /* Indicate if a repository has a different 'commondir' from 'gitdir' */ unsigned different_commondir:1; diff --git a/setup.c b/setup.c index c8336eb20e..75f115faba 100644 --- a/setup.c +++ b/setup.c @@ -688,6 +688,9 @@ static enum extension_result handle_extension(const char *var, } else if (!strcmp(ext, "submodulepathconfig")) { data->submodule_path_cfg = git_config_bool(var, value); return EXTENSION_OK; + } else if (!strcmp(ext, "hookstdouttostderr")) { + data->hook_stdout_to_stderr = git_config_bool(var, value); + return EXTENSION_OK; } return EXTENSION_UNKNOWN; } @@ -1951,6 +1954,8 @@ const char *setup_git_directory_gently(int *nongit_ok) repo_fmt.relative_worktrees; the_repository->repository_format_submodule_path_cfg = repo_fmt.submodule_path_cfg; + the_repository->repository_format_hook_stdout_to_stderr = + repo_fmt.hook_stdout_to_stderr; /* take ownership of repo_fmt.partial_clone */ the_repository->repository_format_partial_clone = repo_fmt.partial_clone; @@ -2053,6 +2058,8 @@ void check_repository_format(struct repository_format *fmt) fmt->submodule_path_cfg; the_repository->repository_format_relative_worktrees = fmt->relative_worktrees; + the_repository->repository_format_hook_stdout_to_stderr = + fmt->hook_stdout_to_stderr; the_repository->repository_format_partial_clone = xstrdup_or_null(fmt->partial_clone); clear_repository_format(&repo_fmt); diff --git a/setup.h b/setup.h index 0738dec244..9de6e9b59c 100644 --- a/setup.h +++ b/setup.h @@ -168,6 +168,7 @@ struct repository_format { int worktree_config; int relative_worktrees; int submodule_path_cfg; + int hook_stdout_to_stderr; int is_bare; int hash_algo; int compat_hash_algo; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index f5c0655adb..d1a0d0a3d4 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -532,6 +532,43 @@ test_expect_success 'client hooks: pre-push expects separate stdout and stderr' check_stdout_separate_from_stderr pre-push ' +test_expect_success 'client hooks: extension makes pre-push merge stdout to stderr' ' + test_when_finished "rm -rf remote2 stdout.actual stderr.actual" && + git init --bare remote2 && + git remote add origin2 remote2 && + test_commit B && + git config set core.repositoryformatversion 1 && + test_config extensions.hookStdoutToStderr true && + setup_hooks pre-push && + git push origin2 HEAD:main >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push +' + +test_expect_success 'client hooks: pre-push defaults to serial execution' ' + test_when_finished "rm -rf remote-serial repo-serial" && + git init --bare remote-serial && + git init repo-serial && + git -C repo-serial remote add origin ../remote-serial && + test_commit -C repo-serial A && + + # Setup 2 pre-push hooks; no parallel=true so they must run serially. + # Use sentinel/detector pattern: hook-1 (sentinel, configured) runs first + # because configured hooks precede traditional hooks in list order; hook-2 + # (detector) runs second and checks whether hook-1 has finished. + git -C repo-serial config hook.hook-1.event pre-push && + git -C repo-serial config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + git -C repo-serial config hook.hook-2.event pre-push && + git -C repo-serial config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + git -C repo-serial config hook.jobs 2 && + + git -C repo-serial push origin HEAD >out 2>err && + echo serial >expect && + test_cmp expect repo-serial/hook.order +' + test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' ' hooks="pre-commit prepare-commit-msg \ commit-msg post-commit \ diff --git a/transport.c b/transport.c index 1581aa0886..97a9c89e16 100644 --- a/transport.c +++ b/transport.c @@ -1388,11 +1388,8 @@ static int run_pre_push_hook(struct transport *transport, opt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc; opt.feed_pipe_cb_data_free = pre_push_hook_data_free; - /* - * pre-push hooks expect stdout & stderr to be separate, so don't merge - * them to keep backwards compatibility with existing hooks. - */ - opt.stdout_to_stderr = 0; + /* merge stdout to stderr only when extensions.hookStdoutToStderr is enabled */ + opt.stdout_to_stderr = the_repository->repository_format_hook_stdout_to_stderr; ret = run_hooks_opt(the_repository, "pre-push", &opt); -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v2 10/10] hook: allow runtime enabling extensions.hookStdoutToStderr 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu ` (8 preceding siblings ...) 2026-02-22 0:29 ` [PATCH v2 09/10] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu @ 2026-02-22 0:29 ` Adrian Ratiu 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-02-22 0:29 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Add a new config `hook.forceStdoutToStderr` which allows enabling extensions.hookStdoutToStderr by default at runtime, both for new and existing repositories. This makes it easier for users to enable hook parallelization for hooks like pre-push by enforcing output consistency. See previous commit for a more in-depth explanation & alternatives considered. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/extensions.adoc | 3 ++ Documentation/config/hook.adoc | 6 +++ hook.c | 28 ++++++++++- hook.h | 1 + setup.c | 10 ++++ t/t1800-hook.sh | 69 ++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 2 deletions(-) diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index 342734668d..b152c6f681 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -113,6 +113,9 @@ in parallel to be grouped (de-interleaved) correctly. + Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push hooks, which will always be run sequentially. ++ +The extension can also be enabled by setting `hook.forceStdoutToStderr` +to `true` in the global configuration. worktreeConfig::: If enabled, then worktrees will load config settings from the diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index aa8a949a36..7b81d2b615 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -65,3 +65,9 @@ This setting has no effect unless all configured hooks for the event have + This has no effect for hooks requiring separate output streams (like `pre-push`) unless `extensions.hookStdoutToStderr` is enabled. + +hook.forceStdoutToStderr:: + A boolean that enables the `extensions.hookStdoutToStderr` behavior + (merging stdout to stderr for all hooks) globally. This effectively + forces all hooks to behave as if the extension was enabled, allowing + parallel execution for hooks like `pre-push`. diff --git a/hook.c b/hook.c index 013e41a8d6..d5675df238 100644 --- a/hook.c +++ b/hook.c @@ -136,6 +136,7 @@ struct hook_config_cache_entry { * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *, * where NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset. + * force_stdout_to_stderr: value of hook.forceStdoutToStderr. Defaults to 0. */ struct hook_all_config_cb { struct strmap commands; @@ -144,6 +145,7 @@ struct hook_all_config_cb { struct strmap parallel_hooks; struct strmap event_jobs; unsigned int jobs; + int force_stdout_to_stderr; }; /* repo_config() callback that collects all hook.* configuration in one pass. */ @@ -169,6 +171,10 @@ static int hook_config_lookup_all(const char *key, const char *value, warning(_("hook.jobs must be positive, ignoring: 0")); else data->jobs = v; + } else if (!strcmp(subkey, "forcestdouttostderr") && value) { + int v = git_parse_maybe_bool(value); + if (v >= 0) + data->force_stdout_to_stderr = v; } return 0; } @@ -325,6 +331,7 @@ static void build_hook_config_map(struct repository *r, cache->jobs = cb_data.jobs; cache->event_jobs = cb_data.event_jobs; + cache->force_stdout_to_stderr = cb_data.force_stdout_to_stderr; strmap_clear(&cb_data.commands, 1); strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ @@ -530,6 +537,20 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +static void hook_force_apply_stdout_to_stderr(struct repository *r, + struct run_hooks_opt *options) +{ + int force = 0; + + if (r && r->gitdir && r->hook_config_cache) + force = r->hook_config_cache->force_stdout_to_stderr; + else + repo_config_get_bool(r, "hook.forceStdoutToStderr", &force); + + if (force) + options->stdout_to_stderr = 1; +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, @@ -539,9 +560,12 @@ static unsigned int get_hook_jobs(struct repository *r, unsigned int jobs; /* - * Hooks needing separate output streams must run sequentially. Next - * commits will add an extension to allow parallelizing these as well. + * Apply hook.forceStdoutToStderr before anything else: it affects + * whether we can run in parallel or not. */ + hook_force_apply_stdout_to_stderr(r, options); + + /* Hooks needing separate output streams must run sequentially. */ if (!options->stdout_to_stderr) return 1; diff --git a/hook.h b/hook.h index 22fc59e67a..6ea6dbca40 100644 --- a/hook.h +++ b/hook.h @@ -224,6 +224,7 @@ struct hook_config_cache { struct strmap hooks; /* maps event name -> string_list of hooks */ struct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */ unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ + int force_stdout_to_stderr; /* hook.forceStdoutToStderr config value */ }; /** diff --git a/setup.c b/setup.c index 75f115faba..c64496adac 100644 --- a/setup.c +++ b/setup.c @@ -2317,6 +2317,7 @@ void initialize_repository_version(int hash_algo, struct strbuf repo_version = STRBUF_INIT; int target_version = GIT_REPO_VERSION; int default_submodule_path_config = 0; + int default_hook_stdout_to_stderr = 0; /* * Note that we initialize the repository version to 1 when the ref @@ -2364,6 +2365,15 @@ void initialize_repository_version(int hash_algo, repo_config_set(the_repository, "extensions.submodulepathconfig", "true"); } + repo_config_get_bool(the_repository, "hook.forceStdoutToStderr", + &default_hook_stdout_to_stderr); + if (default_hook_stdout_to_stderr) { + /* extensions.hookstdouttostderr requires at least version 1 */ + if (target_version == 0) + target_version = 1; + repo_config_set(the_repository, "extensions.hookstdouttostderr", "true"); + } + strbuf_addf(&repo_version, "%d", target_version); repo_config_set(the_repository, "core.repositoryformatversion", repo_version.buf); diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index d1a0d0a3d4..d8f60f2ced 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -916,4 +916,73 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' test_cmp expect hook.order ' +test_expect_success '`git init` respects hook.forceStdoutToStderr' ' + test_when_finished "rm -rf repo-init" && + test_config_global hook.forceStdoutToStderr true && + git init repo-init && + git -C repo-init config extensions.hookStdoutToStderr >actual && + echo true >expect && + test_cmp expect actual +' + +test_expect_success '`git init` does not set extensions.hookStdoutToStderr by default' ' + test_when_finished "rm -rf upstream" && + git init upstream && + test_must_fail git -C upstream config extensions.hookStdoutToStderr +' + +test_expect_success '`git clone` does not set extensions.hookStdoutToStderr by default' ' + test_when_finished "rm -rf upstream repo-clone-no-ext" && + git init upstream && + git clone upstream repo-clone-no-ext && + test_must_fail git -C repo-clone-no-ext config extensions.hookStdoutToStderr +' + +test_expect_success '`git clone` respects hook.forceStdoutToStderr' ' + test_when_finished "rm -rf upstream repo-clone" && + git init upstream && + test_config_global hook.forceStdoutToStderr true && + git clone upstream repo-clone && + git -C repo-clone config extensions.hookStdoutToStderr >actual && + echo true >expect && + test_cmp expect actual +' + +test_expect_success 'hook.forceStdoutToStderr enables extension for existing repos' ' + test_when_finished "rm -rf remote-repo existing-repo" && + git init --bare remote-repo && + git init -b main existing-repo && + # No local extensions.hookStdoutToStderr config set here + # so global config should apply + test_config_global hook.forceStdoutToStderr true && + cd existing-repo && + test_commit A && + git remote add origin ../remote-repo && + setup_hooks pre-push && + git push origin HEAD >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push && + cd .. +' + +test_expect_success 'hook.forceStdoutToStderr enables pre-push parallel runs' ' + test_when_finished "rm -rf repo-parallel remote-parallel" && + git init --bare remote-parallel && + git init repo-parallel && + git -C repo-parallel remote add origin ../remote-parallel && + test_commit -C repo-parallel A && + + write_sentinel_hook repo-parallel/.git/hooks/pre-push && + git -C repo-parallel config hook.hook-2.event pre-push && + git -C repo-parallel config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + git -C repo-parallel config hook.hook-2.parallel true && + + git -C repo-parallel config hook.jobs 2 && + git -C repo-parallel config hook.forceStdoutToStderr true && + + git -C repo-parallel push origin HEAD >out 2>err && + echo parallel >expect && + test_cmp expect repo-parallel/hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v3 0/9] Run hooks in parallel 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu ` (5 preceding siblings ...) 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu ` (8 more replies) 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu 8 siblings, 9 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Hello everyone, This series enables running hook commands in parallel and is based on the latest config hooks cleanup series [1]. v3 is just a small rebase re-roll to fix conflicts and drop a commit which is now part of the base series. The logic in unchanged since v2. Branch pushed to GitHub: [2] Successful CI run: [3] Many thanks to all who contributed to this effort up to now, including Emily, AEvar, Junio, Patrick, Phillip, Brian, Peff and many others. Thank you, Adrian 1: https://lore.kernel.org/git/20260309005416.2760030-1-adrian.ratiu@collabora.com/T/#m0b740e28d4fd06104777a5ceb645d3205450b9c9 2: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v3 3: https://github.com/10ne1/git/actions/runs/22854112520 Changes in v3: * Rebased on the new config cleanup series, fixed minor conflicts (Adrian) * Dropped refactor commit which is now part of the base series (Adrian) * Simplified an entry->parallel asignment to remove shorthand if (Adrian) Range-diff v2 -> v3: 1: f28b0270f9 = 1: 6686d92867 repository: fix repo_init() memleak due to missing _clear() 2: c7cc106224 = 2: 61250bdd91 config: add a repo_config_get_uint() helper 3: 2fe5aa34d6 < -: ---------- hook: refactor hook_config_cache from strmap to named struct 4: 23853aa170 ! 3: 2c49e2a523 hook: parse the hook.jobs config @@ Commit message Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> ## Documentation/config/hook.adoc ## -@@ Documentation/config/hook.adoc: hook.<name>.enabled:: +@@ Documentation/config/hook.adoc: hook.<friendly-name>.enabled:: configuration. This is particularly useful when a hook is defined in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. @@ hook.c @@ hook.c: struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. - * disabled_hooks: set of friendly-names with hook.name.enabled = false. + * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * jobs: value of the global hook.jobs key. Defaults to 0 if unset. */ struct hook_all_config_cb { @@ hook.c: static void build_hook_config_map(struct repository *r, strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { ## hook.h ## -@@ hook.h: void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free); +@@ hook.h: void hook_free(void *p, const char *str UNUSED); */ struct hook_config_cache { struct strmap hooks; /* maps event name -> string_list of hooks */ 5: 71380942dc ! 4: 3c206fac62 hook: allow parallel hook execution @@ Commit message Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> ## Documentation/config/hook.adoc ## -@@ Documentation/config/hook.adoc: hook.<name>.enabled:: +@@ Documentation/config/hook.adoc: hook.<friendly-name>.enabled:: in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. @@ Documentation/config/hook.adoc: hook.<name>.enabled:: +`hook.<name>.parallel` set to `true`. ## hook.c ## -@@ hook.c: static void unsorted_string_list_remove(struct string_list *list, - - /* - * Cache entry stored as the .util pointer of string_list items inside the -- * hook config cache. For now carries only the command for the hook. Next -- * commits will add more data. -+ * hook config cache. Carries both the resolved command and the parallel flag. - */ - struct hook_config_cache_entry { +@@ hook.c: struct hook_config_cache_entry { char *command; + enum config_scope scope; + int disabled; + unsigned int parallel:1; }; @@ hook.c: static void unsorted_string_list_remove(struct string_list *list, @@ hook.c: struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. - * disabled_hooks: set of friendly-names with hook.name.enabled = false. + * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * parallel_hooks: friendly-name to parallel flag. * jobs: value of the global hook.jobs key. Defaults to 0 if unset. */ @@ hook.c: static void build_hook_config_map(struct repository *r, /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ hook.c: static void build_hook_config_map(struct repository *r, - for (size_t i = 0; i < hook_names->nr; i++) { - const char *hname = hook_names->items[i].string; + enum config_scope scope = + (enum config_scope)(uintptr_t)hook_names->items[i].util; struct hook_config_cache_entry *entry; + void *par = strmap_get(&cb_data.parallel_hooks, hname); char *command; - /* filter out disabled hooks */ + int is_disabled = @@ hook.c: static void build_hook_config_map(struct repository *r, - /* util stores a cache entry; owned by the cache. */ - CALLOC_ARRAY(entry, 1); - entry->command = xstrdup(command); -+ entry->parallel = par ? (int)(uintptr_t)par : 0; + entry->command = command ? xstrdup(command) : NULL; + entry->scope = scope; + entry->disabled = is_disabled; ++ entry->parallel = (int)(uintptr_t)par; string_list_append(hooks, hname)->util = entry; } @@ hook.c: static void build_hook_config_map(struct repository *r, strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { string_list_clear(e->value, 0); @@ hook.c: static void list_hooks_add_configured(struct repository *r, - hook->kind = HOOK_CONFIGURED; - hook->u.configured.friendly_name = xstrdup(friendly_name); - hook->u.configured.command = xstrdup(entry->command); + entry->command ? xstrdup(entry->command) : NULL; + hook->u.configured.scope = entry->scope; + hook->u.configured.disabled = entry->disabled; + hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; @@ hook.h: struct run_hooks_opt unsigned int jobs; @@ hook.h: struct run_hooks_opt - cb_data_free_fn feed_pipe_cb_data_free; + hook_data_free_fn feed_pipe_cb_data_free; }; +/** 6: 820064f1c9 ! 5: 2385cb3bc8 hook: mark non-parallelizable hooks @@ builtin/checkout.c: static void branch_info_release(struct branch_info *info) static int update_some(const struct object_id *oid, struct strbuf *base, ## builtin/clone.c ## -@@ builtin/clone.c: static int checkout(int submodule_progress, int filter_submodules, +@@ builtin/clone.c: static int checkout(int submodule_progress, struct tree *tree; struct tree_desc t; int err = 0; @@ builtin/clone.c: static int checkout(int submodule_progress, int filter_submodul if (option_no_checkout) return 0; -@@ builtin/clone.c: static int checkout(int submodule_progress, int filter_submodules, +@@ builtin/clone.c: static int checkout(int submodule_progress, if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); 7: b328f3451f ! 6: c4f92834a7 hook: add -j/--jobs option to git hook run @@ Documentation/git-hook.adoc: git-hook - Run git hooks -'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>] +'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>] + <hook-name> [-- <hook-args>] - 'git hook' list [-z] <hook-name> + 'git hook' list [-z] [--show-scope] <hook-name> DESCRIPTION @@ Documentation/git-hook.adoc: OPTIONS - -z:: - Terminate "list" output lines with NUL instead of newlines. + in parentheses after the friendly name of each configured hook, to show + where it was defined. Traditional hooks from the hookdir are unaffected. +-j:: +--jobs:: @@ Documentation/git-hook.adoc: running: ## builtin/hook.c ## @@ - #include "abspath.h" + #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ - 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" \ + "<hook-name> [-- <hook-args>]") #define BUILTIN_HOOK_LIST_USAGE \ - N_("git hook list [-z] <hook-name>") + N_("git hook list [-z] [--show-scope] <hook-name>") @@ builtin/hook.c: static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested <hook-name>")), 8: ff27b34a8d ! 7: 7b3ea03bd3 hook: add per-event jobs config @@ Documentation/config/hook.adoc: hook.<name>.parallel:: ## hook.c ## @@ hook.c: struct hook_config_cache_entry { * event_hooks: event-name to list of friendly-names map. - * disabled_hooks: set of friendly-names with hook.name.enabled = false. + * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. * parallel_hooks: friendly-name to parallel flag. + * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *, + * where NULL == unset). @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name, .tr2_label = hook_name, ## hook.h ## -@@ hook.h: void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free); +@@ hook.h: void hook_free(void *p, const char *str UNUSED); */ struct hook_config_cache { struct strmap hooks; /* maps event name -> string_list of hooks */ 9: 2bc572e46e = 8: d5cf01444f hook: introduce extensions.hookStdoutToStderr 10: 35dbc4a6c5 = 9: 1cef7b5e22 hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu (6): repository: fix repo_init() memleak due to missing _clear() config: add a repo_config_get_uint() helper hook: parse the hook.jobs config hook: add per-event jobs config hook: introduce extensions.hookStdoutToStderr hook: allow runtime enabling extensions.hookStdoutToStderr Emily Shaffer (3): hook: allow parallel hook execution hook: mark non-parallelizable hooks hook: add -j/--jobs option to git hook run Documentation/config/extensions.adoc | 15 + Documentation/config/hook.adoc | 49 +++ Documentation/git-hook.adoc | 18 +- builtin/am.c | 8 +- builtin/checkout.c | 19 +- builtin/clone.c | 6 +- builtin/hook.c | 5 +- builtin/receive-pack.c | 3 +- builtin/worktree.c | 2 +- commit.c | 2 +- config.c | 28 ++ config.h | 13 + hook.c | 149 ++++++++- hook.h | 28 ++ parse.c | 9 + parse.h | 1 + repository.c | 2 + repository.h | 1 + setup.c | 17 + setup.h | 1 + t/t1800-hook.sh | 446 ++++++++++++++++++++++++++- transport.c | 7 +- 22 files changed, 792 insertions(+), 37 deletions(-) -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-15 4:55 ` Junio C Hamano 2026-03-15 5:05 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 2/9] config: add a repo_config_get_uint() helper Adrian Ratiu ` (7 subsequent siblings) 8 siblings, 2 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu There is an old pre-existing memory leak in repo_init() due to failing to call clear_repository_format() in the error case. It went undetected because a specific bug is required to trigger it: enable a v1 extension in a repository with format v0. Obviously this can only happen in a development environment, so it does not trigger in normal usage, however the memleak is real and needs fixing. Fix it by also calling clear_repository_format() in the error case. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- repository.c | 1 + 1 file changed, 1 insertion(+) diff --git a/repository.c b/repository.c index 0b8f7ec200..fb4356ca55 100644 --- a/repository.c +++ b/repository.c @@ -322,6 +322,7 @@ int repo_init(struct repository *repo, return 0; error: + clear_repository_format(&format); repo_clear(repo); return -1; } -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() 2026-03-09 13:37 ` [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu @ 2026-03-15 4:55 ` Junio C Hamano 2026-03-15 5:05 ` Junio C Hamano 1 sibling, 0 replies; 83+ messages in thread From: Junio C Hamano @ 2026-03-15 4:55 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson Adrian Ratiu <adrian.ratiu@collabora.com> writes: > There is an old pre-existing memory leak in repo_init() due to failing > to call clear_repository_format() in the error case. > > It went undetected because a specific bug is required to trigger it: > enable a v1 extension in a repository with format v0. Obviously this > can only happen in a development environment, so it does not trigger > in normal usage, however the memleak is real and needs fixing. > > Fix it by also calling clear_repository_format() in the error case. > > Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> > --- > repository.c | 1 + > 1 file changed, 1 insertion(+) > > diff --git a/repository.c b/repository.c > index 0b8f7ec200..fb4356ca55 100644 > --- a/repository.c > +++ b/repository.c > @@ -322,6 +322,7 @@ int repo_init(struct repository *repo, > return 0; > > error: > + clear_repository_format(&format); > repo_clear(repo); > return -1; > } It is arguable if the fault is on the caller, or the callee which is read_and_verify_repository_format() that answers the caller "hey, you do not have a valid format to work with" without releasing the thing *it* sample-read. As you said, this only triggers in a broken environment, and there is just a single caller-callee involved, so I am fine fixing it on the caller side like this patch does. Thanks. ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() 2026-03-09 13:37 ` [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu 2026-03-15 4:55 ` Junio C Hamano @ 2026-03-15 5:05 ` Junio C Hamano 1 sibling, 0 replies; 83+ messages in thread From: Junio C Hamano @ 2026-03-15 5:05 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson Adrian Ratiu <adrian.ratiu@collabora.com> writes: > There is an old pre-existing memory leak in repo_init() due to failing > to call clear_repository_format() in the error case. > > It went undetected because a specific bug is required to trigger it: > enable a v1 extension in a repository with format v0. Obviously this > can only happen in a development environment, so it does not trigger > in normal usage, however the memleak is real and needs fixing. > > Fix it by also calling clear_repository_format() in the error case. > > Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> > --- > repository.c | 1 + > 1 file changed, 1 insertion(+) > > diff --git a/repository.c b/repository.c > index 0b8f7ec200..fb4356ca55 100644 > --- a/repository.c > +++ b/repository.c > @@ -322,6 +322,7 @@ int repo_init(struct repository *repo, > return 0; > > error: > + clear_repository_format(&format); > repo_clear(repo); > return -1; > } It is arguable if the fault is on the caller, or the callee which is read_and_verify_repository_format() that answers the caller "hey, you do not have a valid format to work with" without releasing the thing *it* sample-read. As you said, this only triggers in a broken environment, and there is just a single caller-callee involved, so I am fine fixing it on the caller side like this patch does. Thanks. ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v3 2/9] config: add a repo_config_get_uint() helper 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 3/9] hook: parse the hook.jobs config Adrian Ratiu ` (6 subsequent siblings) 8 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Next commits add a 'hook.jobs' config option of type 'unsigned int', so add a helper to parse it since the API only supports int and ulong. An alternative is to make 'hook.jobs' an 'int' or parse it as an 'int' then cast it to unsigned, however it's better to use proper helpers for the type. Using 'ulong' is another option which already has helpers, but it's a bit excessive in size for just the jobs number. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- config.c | 28 ++++++++++++++++++++++++++++ config.h | 13 +++++++++++++ parse.c | 9 +++++++++ parse.h | 1 + 4 files changed, 51 insertions(+) diff --git a/config.c b/config.c index 156f2a24fa..a1b92fe083 100644 --- a/config.c +++ b/config.c @@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value, return ret; } +unsigned int git_config_uint(const char *name, const char *value, + const struct key_value_info *kvi) +{ + unsigned int ret; + if (!git_parse_uint(value, &ret)) + die_bad_number(name, value, kvi); + return ret; +} + int64_t git_config_int64(const char *name, const char *value, const struct key_value_info *kvi) { @@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest) return 1; } +int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest) +{ + const char *value; + struct key_value_info kvi; + + if (!git_configset_get_value(set, key, &value, &kvi)) { + *dest = git_config_uint(key, value, &kvi); + return 0; + } else + return 1; +} + int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest) { const char *value; @@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo, return git_configset_get_int(repo->config, key, dest); } +int repo_config_get_uint(struct repository *repo, + const char *key, unsigned int *dest) +{ + git_config_check_init(repo); + return git_configset_get_uint(repo->config, key, dest); +} + int repo_config_get_ulong(struct repository *repo, const char *key, unsigned long *dest) { diff --git a/config.h b/config.h index ba426a960a..bf47fb3afc 100644 --- a/config.h +++ b/config.h @@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *); int64_t git_config_int64(const char *, const char *, const struct key_value_info *); +/** + * Identical to `git_config_int`, but for unsigned ints. + */ +unsigned int git_config_uint(const char *, const char *, + const struct key_value_info *); + /** * Identical to `git_config_int`, but for unsigned longs. */ @@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key, int git_configset_get_string(struct config_set *cs, const char *key, char **dest); int git_configset_get_int(struct config_set *cs, const char *key, int *dest); +int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest); int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest); int git_configset_get_bool(struct config_set *cs, const char *key, int *dest); int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest); @@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r, */ int repo_config_get_int(struct repository *r, const char *key, int *dest); +/** + * Similar to `repo_config_get_int` but for unsigned ints. + */ +int repo_config_get_uint(struct repository *r, + const char *key, unsigned int *dest); + /** * Similar to `repo_config_get_int` but for unsigned longs. */ diff --git a/parse.c b/parse.c index 48313571aa..d77f28046a 100644 --- a/parse.c +++ b/parse.c @@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret) return 1; } +int git_parse_uint(const char *value, unsigned int *ret) +{ + uintmax_t tmp; + if (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int))) + return 0; + *ret = tmp; + return 1; +} + int git_parse_ulong(const char *value, unsigned long *ret) { uintmax_t tmp; diff --git a/parse.h b/parse.h index ea32de9a91..a6dd37c4cb 100644 --- a/parse.h +++ b/parse.h @@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max); int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max); int git_parse_ssize_t(const char *, ssize_t *); int git_parse_ulong(const char *, unsigned long *); +int git_parse_uint(const char *value, unsigned int *ret); int git_parse_int(const char *value, int *ret); int git_parse_int64(const char *value, int64_t *ret); int git_parse_double(const char *value, double *ret); -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v3 3/9] hook: parse the hook.jobs config 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 2/9] config: add a repo_config_get_uint() helper Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-15 16:13 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 4/9] hook: allow parallel hook execution Adrian Ratiu ` (5 subsequent siblings) 8 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu The hook.jobs config is a global way to set hook parallelization for all hooks, in the sense that it is not per-event nor per-hook. Finer-grained configs will be added in later commits which can override it, for e.g. via a per-event type job options. Next commits will also add to this item's documentation. Parse hook.jobs config key in hook_config_lookup_all() and store its value in hook_all_config_cb.jobs, then transfer it into hook_config_cache.jobs after the config pass completes. This is mostly plumbing and the cached value is not yet used. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++++ hook.c | 22 ++++++++++++++++++++-- hook.h | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 9e78f26439..b7847f9338 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -22,3 +22,7 @@ hook.<friendly-name>.enabled:: configuration. This is particularly useful when a hook is defined in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. + +hook.jobs:: + Specifies how many hooks can be run simultaneously during parallelized + hook execution. If unspecified, defaults to 1 (serial execution). diff --git a/hook.c b/hook.c index 4f4f060156..e6e44a5fcb 100644 --- a/hook.c +++ b/hook.c @@ -127,11 +127,13 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * jobs: value of the global hook.jobs key. Defaults to 0 if unset. */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + unsigned int jobs; }; /* repo_config() callback that collects all hook.* configuration in one pass. */ @@ -147,6 +149,20 @@ static int hook_config_lookup_all(const char *key, const char *value, if (parse_config_key(key, "hook", &name, &name_len, &subkey)) return 0; + /* Handle plain hook.<key> entries that have no hook name component. */ + if (!name) { + if (!strcmp(subkey, "jobs") && value) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.jobs must be a positive integer, ignoring: '%s'"), value); + else if (!v) + warning(_("hook.jobs must be positive, ignoring: 0")); + else + data->jobs = v; + } + return 0; + } + if (!value) return config_error_nonbool(key); @@ -245,7 +261,7 @@ void hook_cache_clear(struct hook_config_cache *cache) static void build_hook_config_map(struct repository *r, struct hook_config_cache *cache) { - struct hook_all_config_cb cb_data; + struct hook_all_config_cb cb_data = { 0 }; struct hashmap_iter iter; struct strmap_entry *e; @@ -253,7 +269,7 @@ static void build_hook_config_map(struct repository *r, strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); - /* Parse all configs in one run. */ + /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); /* Construct the cache from parsed configs. */ @@ -297,6 +313,8 @@ static void build_hook_config_map(struct repository *r, strmap_put(&cache->hooks, e->key, hooks); } + cache->jobs = cb_data.jobs; + strmap_clear(&cb_data.commands, 1); string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { diff --git a/hook.h b/hook.h index 0432df963f..a7eab00480 100644 --- a/hook.h +++ b/hook.h @@ -208,6 +208,7 @@ void hook_free(void *p, const char *str UNUSED); */ struct hook_config_cache { struct strmap hooks; /* maps event name -> string_list of hooks */ + unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ }; /** -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v3 3/9] hook: parse the hook.jobs config 2026-03-09 13:37 ` [PATCH v3 3/9] hook: parse the hook.jobs config Adrian Ratiu @ 2026-03-15 16:13 ` Junio C Hamano 0 siblings, 0 replies; 83+ messages in thread From: Junio C Hamano @ 2026-03-15 16:13 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson Adrian Ratiu <adrian.ratiu@collabora.com> writes: > The hook.jobs config is a global way to set hook parallelization for > all hooks, in the sense that it is not per-event nor per-hook. > > Finer-grained configs will be added in later commits which can override > it, for e.g. via a per-event type job options. Next commits will also > add to this item's documentation. > > Parse hook.jobs config key in hook_config_lookup_all() and store its > value in hook_all_config_cb.jobs, then transfer it into > hook_config_cache.jobs after the config pass completes. > > This is mostly plumbing and the cached value is not yet used. > > Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> > --- > Documentation/config/hook.adoc | 4 ++++ > hook.c | 22 ++++++++++++++++++++-- > hook.h | 1 + > 3 files changed, 25 insertions(+), 2 deletions(-) OK. The previous step is a good preliminary step to prepare for this. ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v3 4/9] hook: allow parallel hook execution 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu ` (2 preceding siblings ...) 2026-03-09 13:37 ` [PATCH v3 3/9] hook: parse the hook.jobs config Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-15 20:46 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 5/9] hook: mark non-parallelizable hooks Adrian Ratiu ` (4 subsequent siblings) 8 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Emily Shaffer, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <nasamuffin@google.com> Hooks always run in sequential order due to the hardcoded jobs == 1 passed to run_process_parallel(). Remove that hardcoding to allow users to run hooks in parallel (opt-in). Users need to decide which hooks to run in parallel, by specifying "parallel = true" in the config, because git cannot know if their specific hooks are safe to run or not in parallel (for e.g. two hooks might write to the same file or call the same program). Some hooks are unsafe to run in parallel by design: these will marked in the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL. The hook.jobs config specifies the default number of jobs applied to all hooks which have parallelism enabled. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 13 +++ hook.c | 66 +++++++++++++-- hook.h | 25 ++++++ t/t1800-hook.sh | 142 +++++++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 6 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index b7847f9338..45811d1032 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -23,6 +23,19 @@ hook.<friendly-name>.enabled:: in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. +hook.<name>.parallel:: + Whether the hook `hook.<name>` may run in parallel with other hooks + for the same event. Defaults to `false`. Set to `true` only when the + hook script is safe to run concurrently with other hooks for the same + event. If any hook for an event does not have this set to `true`, + all hooks for that event run sequentially regardless of `hook.jobs`. + Only configured (named) hooks need to declare this. Traditional hooks + found in the hooks directory do not need to, and run in parallel when + the effective job count is greater than 1. See linkgit:git-hook[1]. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). ++ +This setting has no effect unless all configured hooks for the event have +`hook.<name>.parallel` set to `true`. diff --git a/hook.c b/hook.c index e6e44a5fcb..815b299bf8 100644 --- a/hook.c +++ b/hook.c @@ -120,6 +120,7 @@ struct hook_config_cache_entry { char *command; enum config_scope scope; int disabled; + unsigned int parallel:1; }; /* @@ -127,12 +128,14 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * parallel_hooks: friendly-name to parallel flag. * jobs: value of the global hook.jobs key. Defaults to 0 if unset. */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + struct strmap parallel_hooks; unsigned int jobs; }; @@ -223,6 +226,10 @@ static int hook_config_lookup_all(const char *key, const char *value, default: break; /* ignore unrecognised values */ } + } else if (!strcmp(subkey, "parallel")) { + int v = git_parse_maybe_bool(value); + if (v >= 0) + strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); } free(hook_name); @@ -268,6 +275,7 @@ static void build_hook_config_map(struct repository *r, strmap_init(&cb_data.commands); strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); + strmap_init(&cb_data.parallel_hooks); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -285,6 +293,7 @@ static void build_hook_config_map(struct repository *r, enum config_scope scope = (enum config_scope)(uintptr_t)hook_names->items[i].util; struct hook_config_cache_entry *entry; + void *par = strmap_get(&cb_data.parallel_hooks, hname); char *command; int is_disabled = @@ -307,6 +316,7 @@ static void build_hook_config_map(struct repository *r, entry->command = command ? xstrdup(command) : NULL; entry->scope = scope; entry->disabled = is_disabled; + entry->parallel = (int)(uintptr_t)par; string_list_append(hooks, hname)->util = entry; } @@ -316,6 +326,7 @@ static void build_hook_config_map(struct repository *r, cache->jobs = cb_data.jobs; strmap_clear(&cb_data.commands, 1); + strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { string_list_clear(e->value, 0); @@ -392,6 +403,7 @@ static void list_hooks_add_configured(struct repository *r, entry->command ? xstrdup(entry->command) : NULL; hook->u.configured.scope = entry->scope; hook->u.configured.disabled = entry->disabled; + hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; } @@ -541,21 +553,67 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* Determine how many jobs to use for hook execution. */ +static unsigned int get_hook_jobs(struct repository *r, + struct run_hooks_opt *options, + struct string_list *hook_list) +{ + unsigned int jobs; + + /* + * Hooks needing separate output streams must run sequentially. Next + * commits will add an extension to allow parallelizing these as well. + */ + if (!options->stdout_to_stderr) + return 1; + + /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ + if (options->jobs) + return options->jobs; + + /* + * Use hook.jobs from the already-parsed config cache (in-repo), or + * fall back to a direct config lookup (out-of-repo). Default to 1. + */ + if (r && r->gitdir && r->hook_config_cache) + /* Use the already-parsed cache (in-repo) */ + jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; + else + /* No cache present (out-of-repo call), use direct cfg lookup */ + jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs; + + /* + * Cap to serial any configured hook not marked as parallel = true. + * This enforces the parallel = false default, even for "traditional" + * hooks from the hookdir which cannot be marked parallel = true. + */ + for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; + if (h->kind == HOOK_CONFIGURED && !h->parallel) + jobs = 1; + } + + return jobs; +} + int run_hooks_opt(struct repository *r, const char *hook_name, struct run_hooks_opt *options) { + struct string_list *hook_list = list_hooks(r, hook_name, options); struct hook_cb_data cb_data = { .rc = 0, .hook_name = hook_name, + .hook_command_list = hook_list, .options = options, }; int ret = 0; + unsigned int jobs = get_hook_jobs(r, options, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, - .processes = options->jobs, - .ungroup = options->jobs == 1, + .processes = jobs, + .ungroup = jobs == 1, .get_next_task = pick_next_hook, .start_failure = notify_start_failure, @@ -571,9 +629,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->path_to_stdin && options->feed_pipe) BUG("options path_to_stdin and feed_pipe are mutually exclusive"); - if (!options->jobs) - BUG("run_hooks_opt must be called with options.jobs >= 1"); - /* * Ensure cb_data copy and free functions are either provided together, * or neither one is provided. @@ -584,7 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->invoked_hook) *options->invoked_hook = 0; - cb_data.hook_command_list = list_hooks(r, hook_name, options); if (!cb_data.hook_command_list->nr) { if (options->error_if_missing) ret = error("cannot find a hook named %s", hook_name); diff --git a/hook.h b/hook.h index a7eab00480..a0f6a9db47 100644 --- a/hook.h +++ b/hook.h @@ -35,6 +35,13 @@ struct hook { } configured; } u; + /** + * Whether this hook may run in parallel with other hooks for the same + * event. Only useful for configured (named) hooks. Traditional hooks + * always default to 0 (serial). Set via `hook.<name>.parallel = true`. + */ + unsigned int parallel:1; + /** * Opaque data pointer used to keep internal state across callback calls. * @@ -73,6 +80,8 @@ struct run_hooks_opt * * If > 1, output will be buffered and de-interleaved (ungroup=0). * If == 1, output will be real-time (ungroup=1). + * If == 0, the 'hook.jobs' config is used or, if the config is unset, + * defaults to 1 (serial execution). */ unsigned int jobs; @@ -153,7 +162,23 @@ struct run_hooks_opt hook_data_free_fn feed_pipe_cb_data_free; }; +/** + * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to + * the 'hook.jobs' config, falling back to serial (1) if unset. + */ #define RUN_HOOKS_OPT_INIT { \ + .env = STRVEC_INIT, \ + .args = STRVEC_INIT, \ + .stdout_to_stderr = 1, \ + .jobs = 0, \ +} + +/** + * Initializer for hooks that must always run sequentially regardless of + * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized + * .jobs = 1 is non-overridable. + */ +#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \ .env = STRVEC_INIT, \ .args = STRVEC_INIT, \ .stdout_to_stderr = 1, \ diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index cbf7d2bf80..57733e8a73 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -21,6 +21,57 @@ setup_hookdir () { test_when_finished rm -rf .git/hooks } +# write_sentinel_hook <path> [sentinel] +# +# Writes a hook that marks itself as started, sleeps for a few seconds, then +# marks itself done. The sleep must be long enough that sentinel_detector can +# observe <sentinel>.started before <sentinel>.done appears when both hooks +# run concurrently in parallel mode. +write_sentinel_hook () { + sentinel="${2:-sentinel}" + write_script "$1" <<-EOF + touch ${sentinel}.started && + sleep 2 && + touch ${sentinel}.done + EOF +} + +# sentinel_detector <sentinel> <output> +# +# Returns a shell command string suitable for use as hook.<name>.command. +# The detector must be registered after the sentinel: +# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists) +# before the detector starts. +# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared +# yet and the detector just sees <sentinel>.started. +# +# At start, poll until <sentinel>.started exists to absorb startup jitter, then +# write to <output>: +# 1. 'serial' if <sentinel>.done exists (sentinel finished before we started), +# 2. 'parallel' if only <sentinel>.started exists (sentinel still running), +# 3. 'timeout' if <sentinel>.started never appeared. +# +# The command ends with ':' so when git appends "$@" for hooks that receive +# positional arguments (e.g. pre-push), the result ': "$@"' is valid shell +# rather than a syntax error 'fi "$@"'. +sentinel_detector () { + cat <<-EOF + i=0 + while ! test -f ${1}.started && test \$i -lt 10; do + sleep 1 + i=\$((i+1)) + done + if test -f ${1}.done; then + echo serial >${2} + elif test -f ${1}.started; then + echo parallel >${2} + else + echo timeout >${2} + fi + : + EOF +} + test_expect_success 'git hook usage' ' test_expect_code 129 git hook && test_expect_code 129 git hook run && @@ -626,4 +677,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'hook.jobs=1 config runs hooks in series' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + + # Use two configured hooks so the execution order is deterministic: + # hook-1 (sentinel) is listed before hook-2 (detector), so hook-1 + # always runs first even in serial mode. + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 1 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.jobs=2 config runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<name>.parallel=true enables parallel execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'one non-parallel hook forces the whole event to run serially' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 has no parallel=true: should force serial for all + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v3 4/9] hook: allow parallel hook execution 2026-03-09 13:37 ` [PATCH v3 4/9] hook: allow parallel hook execution Adrian Ratiu @ 2026-03-15 20:46 ` Junio C Hamano 2026-03-18 18:02 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Junio C Hamano @ 2026-03-15 20:46 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Emily Shaffer, Ævar Arnfjörð Bjarmason Adrian Ratiu <adrian.ratiu@collabora.com> writes: > +hook.<name>.parallel:: > + Whether the hook `hook.<name>` may run in parallel with other hooks > + for the same event. Defaults to `false`. Set to `true` only when the > + hook script is safe to run concurrently with other hooks for the same > + event. If any hook for an event does not have this set to `true`, > + all hooks for that event run sequentially regardless of `hook.jobs`. This is very conservative and safe default. > @@ -307,6 +316,7 @@ static void build_hook_config_map(struct repository *r, > entry->command = command ? xstrdup(command) : NULL; > entry->scope = scope; > entry->disabled = is_disabled; > + entry->parallel = (int)(uintptr_t)par; Hmm. The source "par" is (void *) and the destination .parallel member is a single bit, so would this entry->parallel = !!par; be the same? A cast first to uintptr_t, presumably not to lose bits, and then casting it down to potentially narrower int made me wonder what else is going on here that is tricky. > +/* Determine how many jobs to use for hook execution. */ > +static unsigned int get_hook_jobs(struct repository *r, > + struct run_hooks_opt *options, > + struct string_list *hook_list) > +{ > + unsigned int jobs; > + > + /* > + * Hooks needing separate output streams must run sequentially. Next > + * commits will add an extension to allow parallelizing these as well. > + */ > + if (!options->stdout_to_stderr) > + return 1; > + > + /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ > + if (options->jobs) > + return options->jobs; This could be risky for hooks that claim they do not want to run with others at the same time, but the CLI user ought to know what they are using, so this override is very much appreciated. After all, the override may be serializing an overly optimisitic set of hooks that want to run in parallel to avoid interaction between them. > + /* > + * Use hook.jobs from the already-parsed config cache (in-repo), or > + * fall back to a direct config lookup (out-of-repo). Default to 1. > + */ > + if (r && r->gitdir && r->hook_config_cache) > + /* Use the already-parsed cache (in-repo) */ > + jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; > + else > + /* No cache present (out-of-repo call), use direct cfg lookup */ > + jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs; > + > + /* > + * Cap to serial any configured hook not marked as parallel = true. > + * This enforces the parallel = false default, even for "traditional" > + * hooks from the hookdir which cannot be marked parallel = true. > + */ > + for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) { > + struct hook *h = hook_list->items[i].util; > + if (h->kind == HOOK_CONFIGURED && !h->parallel) > + jobs = 1; > + } Losing "jobs > 1 &&" from the termination condition and instead explicitly "break;" out when we demote jobs to 1 would be easier to read, even though it would spend two more lines, i.e., for (size_t i = 0; i < hook_list->nr; i++) { struct hook *h = ...; if (...) { jobs = 1; break; } } Other than that, very cleanly written. Thanks. ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v3 4/9] hook: allow parallel hook execution 2026-03-15 20:46 ` Junio C Hamano @ 2026-03-18 18:02 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-18 18:02 UTC (permalink / raw) To: Junio C Hamano Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Emily Shaffer, Ævar Arnfjörð Bjarmason On Sun, 15 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote: > Adrian Ratiu <adrian.ratiu@collabora.com> writes: > >> +hook.<name>.parallel:: >> + Whether the hook `hook.<name>` may run in parallel with other hooks >> + for the same event. Defaults to `false`. Set to `true` only when the >> + hook script is safe to run concurrently with other hooks for the same >> + event. If any hook for an event does not have this set to `true`, >> + all hooks for that event run sequentially regardless of `hook.jobs`. > > This is very conservative and safe default. > >> @@ -307,6 +316,7 @@ static void build_hook_config_map(struct repository *r, >> entry->command = command ? xstrdup(command) : NULL; >> entry->scope = scope; >> entry->disabled = is_disabled; >> + entry->parallel = (int)(uintptr_t)par; > > Hmm. The source "par" is (void *) and the destination .parallel > member is a single bit, so would this > > entry->parallel = !!par; > > be the same? A cast first to uintptr_t, presumably not to lose > bits, and then casting it down to potentially narrower int made me > wonder what else is going on here that is tricky. Yes, I will use the double negation here on the re-roll, it's the same and much cleaner (thank you), since it's a simple on/off toggle. it'sAs you saw on other series, double negation just doesn't occur to me. :) >> +/* Determine how many jobs to use for hook execution. */ >> +static unsigned int get_hook_jobs(struct repository *r, >> + struct run_hooks_opt *options, >> + struct string_list *hook_list) >> +{ >> + unsigned int jobs; >> + >> + /* >> + * Hooks needing separate output streams must run sequentially. Next >> + * commits will add an extension to allow parallelizing these as well. >> + */ >> + if (!options->stdout_to_stderr) >> + return 1; >> + >> + /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ >> + if (options->jobs) >> + return options->jobs; > > This could be risky for hooks that claim they do not want to run > with others at the same time, but the CLI user ought to know what > they are using, so this override is very much appreciated. After > all, the override may be serializing an overly optimisitic set of > hooks that want to run in parallel to avoid interaction between > them. Yes, that was my reasoning as well. I'll expand the comment to explain a bit better why we're doing it. >> + /* >> + * Use hook.jobs from the already-parsed config cache (in-repo), or >> + * fall back to a direct config lookup (out-of-repo). Default to 1. >> + */ >> + if (r && r->gitdir && r->hook_config_cache) >> + /* Use the already-parsed cache (in-repo) */ >> + jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; >> + else >> + /* No cache present (out-of-repo call), use direct cfg lookup */ >> + jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs; >> + >> + /* >> + * Cap to serial any configured hook not marked as parallel = true. >> + * This enforces the parallel = false default, even for "traditional" >> + * hooks from the hookdir which cannot be marked parallel = true. >> + */ >> + for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) { >> + struct hook *h = hook_list->items[i].util; >> + if (h->kind == HOOK_CONFIGURED && !h->parallel) >> + jobs = 1; >> + } > > Losing "jobs > 1 &&" from the termination condition and instead > explicitly "break;" out when we demote jobs to 1 would be easier to > read, even though it would spend two more lines, i.e., > > for (size_t i = 0; i < hook_list->nr; i++) { > struct hook *h = ...; > if (...) { > jobs = 1; > break; > } > } Indeed, that is much easier to read, will do it in the next re-roll, thanks again. I was thiking in pseudo-code something like this when I wrote it: if (jobs > 1); then for each hook in list do if hook is configured and not parallel then jobs = 1; Then just integrated the (jobs > 1) condition into the for loop below it, the break short-circuit didn't occur to me. :) > > > Other than that, very cleanly written. Many thanks again, this function I think is the "hairiest" part of the series, at least it's what gave me the most trouble and also the part where I spent most time until I could convince myself it's correct. ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v3 5/9] hook: mark non-parallelizable hooks 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu ` (3 preceding siblings ...) 2026-03-09 13:37 ` [PATCH v3 4/9] hook: allow parallel hook execution Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-15 20:56 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 6/9] hook: add -j/--jobs option to git hook run Adrian Ratiu ` (3 subsequent siblings) 8 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> Several hooks are known to be inherently non-parallelizable, so initialize them with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides any hook.jobs or runtime -j flags. These hooks are: applypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout, and push-to-checkout. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++++ builtin/am.c | 8 +++++--- builtin/checkout.c | 19 +++++++++++++------ builtin/clone.c | 6 ++++-- builtin/receive-pack.c | 3 ++- builtin/worktree.c | 2 +- commit.c | 2 +- t/t1800-hook.sh | 16 ++++++++++++++++ 8 files changed, 46 insertions(+), 14 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 45811d1032..d2e4b33240 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -36,6 +36,10 @@ hook.<name>.parallel:: hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Some hooks always run sequentially regardless of this setting because + git knows they cannot safely be parallelized: `applypatch-msg`, + `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, + `post-checkout`, and `push-to-checkout`. + This setting has no effect unless all configured hooks for the event have `hook.<name>.parallel` set to `true`. diff --git a/builtin/am.c b/builtin/am.c index e0c767e223..45a8e78d0b 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state) assert(state->msg); - if (!state->no_verify) - ret = run_hooks_l(the_repository, "applypatch-msg", - am_path(state, "final-commit"), NULL); + if (!state->no_verify) { + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + strvec_push(&opt.args, am_path(state, "final-commit")); + ret = run_hooks_opt(the_repository, "applypatch-msg", &opt); + } if (!ret) { FREE_AND_NULL(state->msg); diff --git a/builtin/checkout.c b/builtin/checkout.c index 1d1667fa4c..ddbe8474d2 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -31,6 +31,7 @@ #include "resolve-undo.h" #include "revision.h" #include "setup.h" +#include "strvec.h" #include "submodule.h" #include "symlinks.h" #include "trace2.h" @@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info) static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit, int changed) { - return run_hooks_l(the_repository, "post-checkout", - oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), - oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), - changed ? "1" : "0", NULL); - /* "new_commit" can be NULL when checking out from the index before - a commit exists. */ + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + /* + * "new_commit" can be NULL when checking out from the index before + * a commit exists. + */ + strvec_pushl(&opt.args, + oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), + oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), + changed ? "1" : "0", + NULL); + + return run_hooks_opt(the_repository, "post-checkout", &opt); } static int update_some(const struct object_id *oid, struct strbuf *base, diff --git a/builtin/clone.c b/builtin/clone.c index fba3c9c508..d23b0cafcf 100644 --- a/builtin/clone.c +++ b/builtin/clone.c @@ -647,6 +647,7 @@ static int checkout(int submodule_progress, struct tree *tree; struct tree_desc t; int err = 0; + struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; if (option_no_checkout) return 0; @@ -697,8 +698,9 @@ static int checkout(int submodule_progress, if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); - err |= run_hooks_l(the_repository, "post-checkout", oid_to_hex(null_oid(the_hash_algo)), - oid_to_hex(&oid), "1", NULL); + strvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)), + oid_to_hex(&oid), "1", NULL); + err |= run_hooks_opt(the_repository, "post-checkout", &hook_opt); if (!err && (option_recurse_submodules.nr > 0)) { struct child_process cmd = CHILD_PROCESS_INIT; diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index 4b63ccdfa3..37086a41e7 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -1463,7 +1463,8 @@ static const char *push_to_checkout(unsigned char *hash, struct strvec *env, const char *work_tree) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + opt.invoked_hook = invoked_hook; strvec_pushf(env, "GIT_WORK_TREE=%s", absolute_path(work_tree)); diff --git a/builtin/worktree.c b/builtin/worktree.c index bc2d0d645b..d4e7c33205 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname, * is_junk is cleared, but do return appropriate code when hook fails. */ if (!ret && opts->checkout && !opts->orphan) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL); strvec_pushl(&opt.args, diff --git a/commit.c b/commit.c index 0ffdd6679e..a2c4ffaac5 100644 --- a/commit.c +++ b/commit.c @@ -1979,7 +1979,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len) int run_commit_hook(int editor_is_used, const char *index_file, int *invoked_hook, const char *name, ...) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; va_list args; const char *arg; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 57733e8a73..dad7583f3a 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -768,4 +768,20 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall test_cmp expect hook.order ' +test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event pre-commit && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event pre-commit && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + test_config hook.jobs 2 && + git commit --allow-empty -m "test: verify force-serial on pre-commit" && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v3 5/9] hook: mark non-parallelizable hooks 2026-03-09 13:37 ` [PATCH v3 5/9] hook: mark non-parallelizable hooks Adrian Ratiu @ 2026-03-15 20:56 ` Junio C Hamano 2026-03-18 18:40 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Junio C Hamano @ 2026-03-15 20:56 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson Adrian Ratiu <adrian.ratiu@collabora.com> writes: > hook.jobs:: > Specifies how many hooks can be run simultaneously during parallelized > hook execution. If unspecified, defaults to 1 (serial execution). > + Some hooks always run sequentially regardless of this setting because > + git knows they cannot safely be parallelized: `applypatch-msg`, > + `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, > + `post-checkout`, and `push-to-checkout`. If there is a simple rule that can be used to decide hooks with what characteristics can and cannot be run in parallel, near the "because git knows" sentence is where we want to write it down. It would help new developers decide if their newly invented hook should be forced serial execution. For example, applypatch-msg is given a file and is allowed to modify the file (perhaps reformat or typofix), so two of them competing to edit that single file would be a nonsense. Letting them edit the file one after the other would make much more sense. So one of the rules may be "a hook that is given a file and expected to edit it". other two hooks with -msg suffix may fall into the same category. What are the rules behind the decision for others? Are they also explained with simple rules? ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v3 5/9] hook: mark non-parallelizable hooks 2026-03-15 20:56 ` Junio C Hamano @ 2026-03-18 18:40 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-18 18:40 UTC (permalink / raw) To: Junio C Hamano Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Sun, 15 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote: > Adrian Ratiu <adrian.ratiu@collabora.com> writes: > >> hook.jobs:: >> Specifies how many hooks can be run simultaneously during parallelized >> hook execution. If unspecified, defaults to 1 (serial execution). >> + Some hooks always run sequentially regardless of this setting because >> + git knows they cannot safely be parallelized: `applypatch-msg`, >> + `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, >> + `post-checkout`, and `push-to-checkout`. > > If there is a simple rule that can be used to decide hooks with what > characteristics can and cannot be run in parallel, near the "because > git knows" sentence is where we want to write it down. It would > help new developers decide if their newly invented hook should be > forced serial execution. > > For example, applypatch-msg is given a file and is allowed to modify > the file (perhaps reformat or typofix), so two of them competing to > edit that single file would be a nonsense. Letting them edit the > file one after the other would make much more sense. So one of the > rules may be "a hook that is given a file and expected to edit it". > other two hooks with -msg suffix may fall into the same category. > What are the rules behind the decision for others? Are they also > explained with simple rules? The simplest and highest level rule I can think of is that these hooks operate on shared data, so they cannot be safely parallelized. I'll make this rule clearer in the re-roll. For the *-msg hooks, as you mentioned, it's a file. For *-commit and *-checkout hooks, it's the worktree or index. post-commit is a bit special because it typically invokes git commands that contend on lock files. Happy to drop this from the list if I've been too conservative on it (it's a 1 liner change + this doc) and allow it to be parallelized. Suggestions are welcome, as always. ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v3 6/9] hook: add -j/--jobs option to git hook run 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu ` (4 preceding siblings ...) 2026-03-09 13:37 ` [PATCH v3 5/9] hook: mark non-parallelizable hooks Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-15 21:00 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 7/9] hook: add per-event jobs config Adrian Ratiu ` (2 subsequent siblings) 8 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> Expose the parallel job count as a command-line flag so callers can request parallelism without relying only on the hook.jobs config. Add tests covering serial/parallel execution and TTY behaviour under -j1 vs -jN. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/git-hook.adoc | 18 +++++- builtin/hook.c | 5 +- hook.c | 14 ++-- t/t1800-hook.sh | 123 ++++++++++++++++++++++++++++++++++-- 4 files changed, 144 insertions(+), 16 deletions(-) diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc index 4d4e728327..17105cc729 100644 --- a/Documentation/git-hook.adoc +++ b/Documentation/git-hook.adoc @@ -8,7 +8,8 @@ git-hook - Run git hooks SYNOPSIS -------- [verse] -'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>] +'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>] + <hook-name> [-- <hook-args>] 'git hook' list [-z] [--show-scope] <hook-name> DESCRIPTION @@ -139,6 +140,18 @@ OPTIONS in parentheses after the friendly name of each configured hook, to show where it was defined. Traditional hooks from the hookdir are unaffected. +-j:: +--jobs:: + Only valid for `run`. ++ +Specify how many hooks to run simultaneously. If this flag is not specified, +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If +neither is specified, defaults to 1 (serial execution). Some hooks always run +sequentially regardless of this flag or the `hook.jobs` config, because git +knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`, +`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and +`push-to-checkout`. + WRAPPERS -------- @@ -161,7 +174,8 @@ running: git hook run mywrapper-start-tests \ # providing something to stdin --stdin some-tempfile-123 \ - # execute hooks in serial + # execute multiple hooks in parallel + --jobs 3 \ # plus some arguments of your own... -- \ --testname bar \ diff --git a/builtin/hook.c b/builtin/hook.c index ff446948fa..6ec0319cc5 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -7,7 +7,8 @@ #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ - 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" \ + "<hook-name> [-- <hook-args>]") #define BUILTIN_HOOK_LIST_USAGE \ N_("git hook list [-z] [--show-scope] <hook-name>") @@ -109,6 +110,8 @@ static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested <hook-name>")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), N_("file to read into hooks' stdin")), + OPT_UNSIGNED('j', "jobs", &opt.jobs, + N_("run up to <n> hooks simultaneously")), OPT_END(), }; int ret; diff --git a/hook.c b/hook.c index 815b299bf8..299cbf9e97 100644 --- a/hook.c +++ b/hook.c @@ -567,15 +567,17 @@ static unsigned int get_hook_jobs(struct repository *r, if (!options->stdout_to_stderr) return 1; - /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ - if (options->jobs) - return options->jobs; + /* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */ + if (options->jobs == 1) + return 1; /* - * Use hook.jobs from the already-parsed config cache (in-repo), or - * fall back to a direct config lookup (out-of-repo). Default to 1. + * Resolve effective job count: -jN (when given) overrides config. + * Default to 1 when both config an -jN are missing. */ - if (r && r->gitdir && r->hook_config_cache) + if (options->jobs > 1) + jobs = options->jobs; + else if (r && r->gitdir && r->hook_config_cache) /* Use the already-parsed cache (in-repo) */ jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; else diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index dad7583f3a..fbe8be25c8 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -238,10 +238,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' ' ' test_hook_tty () { - cat >expect <<-\EOF - STDOUT TTY - STDERR TTY - EOF + expect_tty=$1 + shift + + if test "$expect_tty" != "no_tty"; then + cat >expect <<-\EOF + STDOUT TTY + STDERR TTY + EOF + else + cat >expect <<-\EOF + STDOUT NO TTY + STDERR NO TTY + EOF + fi test_when_finished "rm -rf repo" && git init repo && @@ -259,12 +269,21 @@ test_hook_tty () { test_cmp expect repo/actual } -test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' ' - test_hook_tty hook run pre-commit +test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' ' + # hooks running sequentially (-j1) are always connected to the tty for + # optimum real-time performance. + test_hook_tty tty hook run -j1 pre-commit +' + +test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' ' + # Hooks are not connected to the tty when run in parallel, instead they + # output to a pipe through which run-command collects and de-interlaces + # their outputs, which then gets passed either to the tty or a sideband. + test_hook_tty no_tty hook run -j2 pre-commit ' test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' ' - test_hook_tty commit -m"B.new" + test_hook_tty tty commit -m"B.new" ' test_expect_success 'git hook list orders by config order' ' @@ -677,6 +696,96 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'parallel hook output is not interleaved' ' + test_when_finished "rm -rf .git/hooks" && + + write_script .git/hooks/test-hook <<-EOF && + echo "Hook 1 Start" + sleep 1 + echo "Hook 1 End" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "echo \"Hook 2 Start\"; sleep 2; echo \"Hook 2 End\"" && + test_config hook.hook-2.parallel true && + test_config hook.hook-3.event test-hook && + test_config hook.hook-3.command \ + "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" && + test_config hook.hook-3.parallel true && + + git hook run -j3 test-hook >out 2>err.parallel && + + # Verify Hook 1 output is grouped + sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out && + test_line_count = 2 hook1_out && + + # Verify Hook 2 output is grouped + sed -n "/Hook 2 Start/,/Hook 2 End/p" err.parallel >hook2_out && + test_line_count = 2 hook2_out && + + # Verify Hook 3 output is grouped + sed -n "/Hook 3 Start/,/Hook 3 End/p" err.parallel >hook3_out && + test_line_count = 2 hook3_out +' + +test_expect_success 'git hook run -j1 runs hooks in series' ' + test_when_finished "rm -rf .git/hooks" && + + test_config hook.series-1.event "test-hook" && + test_config hook.series-1.command "echo 1" --add && + test_config hook.series-2.event "test-hook" && + test_config hook.series-2.command "echo 2" --add && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + echo 3 + EOF + + cat >expected <<-\EOF && + 1 + 2 + 3 + EOF + + git hook run -j1 test-hook 2>actual && + test_cmp expected actual +' + +test_expect_success 'git hook run -j2 runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + git hook run -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'git hook run -j2 is blocked by parallel=false' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # -j2 must not override parallel=false on configured hooks. + git hook run -j2 test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_expect_success 'hook.jobs=1 config runs hooks in series' ' test_when_finished "rm -f sentinel.started sentinel.done hook.order" && -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v3 6/9] hook: add -j/--jobs option to git hook run 2026-03-09 13:37 ` [PATCH v3 6/9] hook: add -j/--jobs option to git hook run Adrian Ratiu @ 2026-03-15 21:00 ` Junio C Hamano 2026-03-18 19:00 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Junio C Hamano @ 2026-03-15 21:00 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Ævar Arnfjörð Bjarmason Adrian Ratiu <adrian.ratiu@collabora.com> writes: > diff --git a/hook.c b/hook.c > index 815b299bf8..299cbf9e97 100644 > --- a/hook.c > +++ b/hook.c > @@ -567,15 +567,17 @@ static unsigned int get_hook_jobs(struct repository *r, > if (!options->stdout_to_stderr) > return 1; > > - /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ > - if (options->jobs) > - return options->jobs; > + /* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */ > + if (options->jobs == 1) > + return 1; Hmph, puzzled. Shouldn't just -j1 but -j12 from CLI also trump configured parallelism? Which was what the code before this step already did, no? > /* > + * Resolve effective job count: -jN (when given) overrides config. > + * Default to 1 when both config an -jN are missing. > */ > - if (r && r->gitdir && r->hook_config_cache) > + if (options->jobs > 1) > + jobs = options->jobs; > + else if (r && r->gitdir && r->hook_config_cache) > /* Use the already-parsed cache (in-repo) */ > jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; > else ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v3 6/9] hook: add -j/--jobs option to git hook run 2026-03-15 21:00 ` Junio C Hamano @ 2026-03-18 19:00 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-18 19:00 UTC (permalink / raw) To: Junio C Hamano Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Ævar Arnfjörð Bjarmason On Sun, 15 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote: > Adrian Ratiu <adrian.ratiu@collabora.com> writes: > >> diff --git a/hook.c b/hook.c >> index 815b299bf8..299cbf9e97 100644 >> --- a/hook.c >> +++ b/hook.c >> @@ -567,15 +567,17 @@ static unsigned int get_hook_jobs(struct repository *r, >> if (!options->stdout_to_stderr) >> return 1; >> >> - /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ >> - if (options->jobs) >> - return options->jobs; >> + /* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */ >> + if (options->jobs == 1) >> + return 1; > > Hmph, puzzled. > > Shouldn't just -j1 but -j12 from CLI also trump configured > parallelism? Which was what the code before this step already did, > no? Yes. I think I was a bit unsure of the -jN priority when writing this, whether the -jN arg is stronger than for e.g. if the hook is marked as parallel = false. What to do in this case? :) As you noted on the other patch, the user's intention is clear when passing -jN, so maybe we could bring back the old code and issue a warning, something like: "hook X is not marked as parallel=true, running in parallel anyway due to the -jN flag". ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v3 7/9] hook: add per-event jobs config 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu ` (5 preceding siblings ...) 2026-03-09 13:37 ` [PATCH v3 6/9] hook: add -j/--jobs option to git hook run Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-16 18:40 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 9/9] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu 8 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Add a hook.<event>.jobs count config that allows users to override the global hook.jobs setting for specific hook events. This allows finer-grained control over parallelism on a per-event basis. For example, to run `post-receive` hooks with up to 4 parallel jobs while keeping other events at their global default: [hook] post-receive.jobs = 4 Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 19 +++++++++++ hook.c | 47 +++++++++++++++++++++++---- hook.h | 1 + t/t1800-hook.sh | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index d2e4b33240..ca97ea6f1e 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -33,9 +33,28 @@ hook.<name>.parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. +hook.<event>.jobs:: + Specifies how many hooks can be run simultaneously for the `<event>` + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` + for this specific event. The same parallelism restrictions apply: this + setting has no effect unless all configured hooks for the event have + `hook.<friendly-name>.parallel` set to `true`. Must be a positive int, + zero is rejected with a warning. See linkgit:git-hook[1]. ++ +Note on naming: although this key resembles `hook.<friendly-name>.*` +(a per-hook setting), `<event>` must be the event name, not a hook +friendly name. The key component is stored literally and looked up by +event name at runtime with no translation between the two namespaces. +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the +lookup at runtime uses the event name (e.g. `"post-receive"`), so +`hook.my-hook.jobs` is silently ignored even when `my-hook` is +registered for that event. Use `hook.post-receive.jobs` or any other +valid event name when setting `hook.<event>.jobs`. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Can be overridden on a per-event basis with `hook.<event>.jobs`. Some hooks always run sequentially regardless of this setting because git knows they cannot safely be parallelized: `applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, diff --git a/hook.c b/hook.c index 299cbf9e97..b70c4c15ec 100644 --- a/hook.c +++ b/hook.c @@ -129,6 +129,8 @@ struct hook_config_cache_entry { * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. * parallel_hooks: friendly-name to parallel flag. + * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *, + * where NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset. */ struct hook_all_config_cb { @@ -136,6 +138,7 @@ struct hook_all_config_cb { struct strmap event_hooks; struct string_list disabled_hooks; struct strmap parallel_hooks; + struct strmap event_jobs; unsigned int jobs; }; @@ -230,6 +233,20 @@ static int hook_config_lookup_all(const char *key, const char *value, int v = git_parse_maybe_bool(value); if (v >= 0) strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); + } else if (!strcmp(subkey, "jobs")) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.%s.jobs must be a positive integer, ignoring: '%s'"), + hook_name, value); + else if (!v) + warning(_("hook.%s.jobs must be positive, ignoring: 0"), hook_name); + else { + unsigned int *old; + unsigned int *p = xmalloc(sizeof(*p)); + *p = v; + old = strmap_put(&data->event_jobs, hook_name, p); + free(old); + } } free(hook_name); @@ -262,6 +279,7 @@ void hook_cache_clear(struct hook_config_cache *cache) free(hooks); } strmap_clear(&cache->hooks, 0); + strmap_clear(&cache->event_jobs, 1); /* free heap-allocated unsigned int * values */ } /* Populate `cache` with the complete hook configuration */ @@ -276,6 +294,7 @@ static void build_hook_config_map(struct repository *r, strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); strmap_init(&cb_data.parallel_hooks); + strmap_init(&cb_data.event_jobs); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -324,6 +343,7 @@ static void build_hook_config_map(struct repository *r, } cache->jobs = cb_data.jobs; + cache->event_jobs = cb_data.event_jobs; strmap_clear(&cb_data.commands, 1); strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ @@ -556,6 +576,7 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, + const char *hook_name, struct string_list *hook_list) { unsigned int jobs; @@ -572,22 +593,36 @@ static unsigned int get_hook_jobs(struct repository *r, return 1; /* - * Resolve effective job count: -jN (when given) overrides config. - * Default to 1 when both config an -jN are missing. + * Resolve effective job count: -j N (when given) overrides config. + * hook.<event>.jobs overrides hook.jobs. + * Unset configs and -jN default to 1. */ - if (options->jobs > 1) + if (options->jobs > 1) { jobs = options->jobs; - else if (r && r->gitdir && r->hook_config_cache) + } else if (r && r->gitdir && r->hook_config_cache) { /* Use the already-parsed cache (in-repo) */ + unsigned int *event_jobs = strmap_get(&r->hook_config_cache->event_jobs, + hook_name); jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; - else + if (event_jobs) + jobs = *event_jobs; + } else { /* No cache present (out-of-repo call), use direct cfg lookup */ + unsigned int event_jobs; + char *key; jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs; + key = xstrfmt("hook.%s.jobs", hook_name); + if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) + jobs = event_jobs; + free(key); + } /* * Cap to serial any configured hook not marked as parallel = true. * This enforces the parallel = false default, even for "traditional" * hooks from the hookdir which cannot be marked parallel = true. + * The same restriction applies whether jobs came from hook.jobs or + * hook.<event>.jobs. */ for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) { struct hook *h = hook_list->items[i].util; @@ -609,7 +644,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name, .options = options, }; int ret = 0; - unsigned int jobs = get_hook_jobs(r, options, hook_list); + unsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, diff --git a/hook.h b/hook.h index a0f6a9db47..e8603c4370 100644 --- a/hook.h +++ b/hook.h @@ -233,6 +233,7 @@ void hook_free(void *p, const char *str UNUSED); */ struct hook_config_cache { struct strmap hooks; /* maps event name -> string_list of hooks */ + struct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */ unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ }; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index fbe8be25c8..195cc5333e 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -893,4 +893,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' test_cmp expect hook.order ' +test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=1 (serial), but per-event override allows parallel. + test_config hook.jobs 1 && + test_config hook.test-hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=4 allows parallel, but per-event override forces serial. + test_config hook.jobs 4 && + test_config hook.test-hook.jobs 1 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # Per-event jobs=2 but no hook has parallel=true: must still run serially. + test_config hook.test-hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v3 7/9] hook: add per-event jobs config 2026-03-09 13:37 ` [PATCH v3 7/9] hook: add per-event jobs config Adrian Ratiu @ 2026-03-16 18:40 ` Junio C Hamano 2026-03-18 19:21 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Junio C Hamano @ 2026-03-16 18:40 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson Adrian Ratiu <adrian.ratiu@collabora.com> writes: > +hook.<event>.jobs:: > + Specifies how many hooks can be run simultaneously for the `<event>` > + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` > + for this specific event. The same parallelism restrictions apply: this > + setting has no effect unless all configured hooks for the event have > + `hook.<friendly-name>.parallel` set to `true`. Must be a positive int, > + zero is rejected with a warning. See linkgit:git-hook[1]. > ++ > +Note on naming: although this key resembles `hook.<friendly-name>.*` > +(a per-hook setting), `<event>` must be the event name, not a hook > +friendly name. The key component is stored literally and looked up by > +event name at runtime with no translation between the two namespaces. > +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the > +lookup at runtime uses the event name (e.g. `"post-receive"`), so > +`hook.my-hook.jobs` is silently ignored even when `my-hook` is > +registered for that event. Use `hook.post-receive.jobs` or any other > +valid event name when setting `hook.<event>.jobs`. This design is unfortunate but cannot be avoided, as we do not want two hooks (i.e., two different names) that react to a single event specify .jobs value differently. Naturally, we need to worry about what happens when somebody gives the name "foo" to their hook that reacts to "foo" event, but that would probably be benign if there is no other hook that reacts to "foo" event. I also wonder if we want to sanity check and complain upon seeing hook.foo.jobs set when "foo" is not a known event type. Perhaps anything that appears with one of the .command, .event, or .parallel are likely to be <friendly-name>, so having .jobs under such configuration key is safe to flag as a mistake, or something? A careful reader who is reading this message from the sideline may notice that I specifically omitted .enabled from the above "clues for friendly-name key". I think hook.<event>.enabled that acts as a master switch to prevents all hooks from firing for a particular event (when set to 'false') may be something people eventually want, in addition to per-hook command hook.<friendly-name>.enabled switch that can override it (or do we want to forbid overriding it? I dunno).. Other than that, looking quite well done. Thanks. ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v3 7/9] hook: add per-event jobs config 2026-03-16 18:40 ` Junio C Hamano @ 2026-03-18 19:21 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-18 19:21 UTC (permalink / raw) To: Junio C Hamano Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Mon, 16 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote: > Adrian Ratiu <adrian.ratiu@collabora.com> writes: > >> +hook.<event>.jobs:: >> + Specifies how many hooks can be run simultaneously for the `<event>` >> + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` >> + for this specific event. The same parallelism restrictions apply: this >> + setting has no effect unless all configured hooks for the event have >> + `hook.<friendly-name>.parallel` set to `true`. Must be a positive int, >> + zero is rejected with a warning. See linkgit:git-hook[1]. >> ++ >> +Note on naming: although this key resembles `hook.<friendly-name>.*` >> +(a per-hook setting), `<event>` must be the event name, not a hook >> +friendly name. The key component is stored literally and looked up by >> +event name at runtime with no translation between the two namespaces. >> +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the >> +lookup at runtime uses the event name (e.g. `"post-receive"`), so >> +`hook.my-hook.jobs` is silently ignored even when `my-hook` is >> +registered for that event. Use `hook.post-receive.jobs` or any other >> +valid event name when setting `hook.<event>.jobs`. > > This design is unfortunate but cannot be avoided, as we do not want > two hooks (i.e., two different names) that react to a single event > specify .jobs value differently. Naturally, we need to worry about > what happens when somebody gives the name "foo" to their hook that > reacts to "foo" event, but that would probably be benign if there is > no other hook that reacts to "foo" event. I also wonder if we want > to sanity check and complain upon seeing hook.foo.jobs set when > "foo" is not a known event type. > > Perhaps anything that appears with one of the .command, .event, or > .parallel are likely to be <friendly-name>, so having .jobs under > such configuration key is safe to flag as a mistake, or something? I think this is reasonable, yes, and it helps avoid confusion between per-hook friendly-names and per-event "name" values. Will do in the re-roll. > > A careful reader who is reading this message from the sideline may > notice that I specifically omitted .enabled from the above "clues > for friendly-name key". I think hook.<event>.enabled that acts as a > master switch to prevents all hooks from firing for a particular > event (when set to 'false') may be something people eventually want, > in addition to per-hook command hook.<friendly-name>.enabled switch > that can override it (or do we want to forbid overriding it? I > dunno).. I think this is reasonable as well. Having a higher-level switch for all hooks in an event should be useful, in addition to the already implemented lower-level per-hook "friendly-name" enabled key. I can add a new commit for this in the re-roll. ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu ` (6 preceding siblings ...) 2026-03-09 13:37 ` [PATCH v3 7/9] hook: add per-event jobs config Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 2026-03-16 18:44 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 9/9] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu 8 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu All hooks already redirect stdout to stderr with the exception of pre-push which has a known user who depends on the separate stdout versus stderr outputs (the git-lfs project). The pre-push behavior was a surprise which we found out about after causing a regression for git-lfs. Notably, it might not be the only exception (it's the one we know about). There might be more. This presents a challenge because stdout_to_stderr is required for hook parallelization, so run-command can buffer and de-interleave the hook outputs using ungroup=0, when hook.jobs > 1. Introduce an extension to enforce consistency: all hooks merge stdout into stderr and can be safely parallelized. This provides a clean separation and avoids breaking existing stdout vs stderr behavior. When this extension is disabled, the `hook.jobs` config has no effect for pre-push, to prevent garbled (interleaved) parallel output, so it runs sequentially like before. Alternatives I've considered to this extension include: 1. Allowing pre-push to run in parallel with interleaved output. 2. Always running pre-push sequentially (no parallel jobs for it). 3. Making users (only git-lfs? maybe more?) fix their hooks to read stderr not stdout. Out of all these alternatives, I think this extension is the most reasonable compromise, to not break existing users, allow pre-push parallel jobs for those who need it (with correct outputs) and also future-proofing in case there are any more exceptions to be added. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/extensions.adoc | 12 +++++++++ Documentation/config/hook.adoc | 3 +++ repository.c | 1 + repository.h | 1 + setup.c | 7 ++++++ setup.h | 1 + t/t1800-hook.sh | 37 ++++++++++++++++++++++++++++ transport.c | 7 ++---- 8 files changed, 64 insertions(+), 5 deletions(-) diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index be6678bb5b..1a189ff045 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -116,6 +116,18 @@ The extension can be enabled automatically for new repositories by setting `init.defaultSubmodulePathConfig` to `true`, for example by running `git config --global init.defaultSubmodulePathConfig true`. +hookStdoutToStderr::: + If enabled, the stdout of all hooks is redirected to stderr. This + enforces consistency, since by default most hooks already behave + this way, with pre-push being the only known exception. ++ +This is useful for parallel hook execution (see the `hook.jobs` config in +linkgit:git-config[1]), as it allows the output of multiple hooks running +in parallel to be grouped (de-interleaved) correctly. ++ +Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push +hooks, which will always be run sequentially. + worktreeConfig::: If enabled, then worktrees will load config settings from the `$GIT_DIR/config.worktree` file in addition to the diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index ca97ea6f1e..5e74fdce65 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -62,3 +62,6 @@ hook.jobs:: + This setting has no effect unless all configured hooks for the event have `hook.<name>.parallel` set to `true`. ++ +This has no effect for hooks requiring separate output streams (like `pre-push`) +unless `extensions.hookStdoutToStderr` is enabled. diff --git a/repository.c b/repository.c index fb4356ca55..aecd1cbb0b 100644 --- a/repository.c +++ b/repository.c @@ -307,6 +307,7 @@ int repo_init(struct repository *repo, repo->repository_format_relative_worktrees = format.relative_worktrees; repo->repository_format_precious_objects = format.precious_objects; repo->repository_format_submodule_path_cfg = format.submodule_path_cfg; + repo->repository_format_hook_stdout_to_stderr = format.hook_stdout_to_stderr; /* take ownership of format.partial_clone */ repo->repository_format_partial_clone = format.partial_clone; diff --git a/repository.h b/repository.h index 3fd73d2c54..daa7ae7d16 100644 --- a/repository.h +++ b/repository.h @@ -182,6 +182,7 @@ struct repository { int repository_format_relative_worktrees; int repository_format_precious_objects; int repository_format_submodule_path_cfg; + int repository_format_hook_stdout_to_stderr; /* Indicate if a repository has a different 'commondir' from 'gitdir' */ unsigned different_commondir:1; diff --git a/setup.c b/setup.c index 393b970ae4..b12c5ca313 100644 --- a/setup.c +++ b/setup.c @@ -710,6 +710,9 @@ static enum extension_result handle_extension(const char *var, } else if (!strcmp(ext, "submodulepathconfig")) { data->submodule_path_cfg = git_config_bool(var, value); return EXTENSION_OK; + } else if (!strcmp(ext, "hookstdouttostderr")) { + data->hook_stdout_to_stderr = git_config_bool(var, value); + return EXTENSION_OK; } return EXTENSION_UNKNOWN; } @@ -1976,6 +1979,8 @@ const char *setup_git_directory_gently(int *nongit_ok) repo_fmt.relative_worktrees; the_repository->repository_format_submodule_path_cfg = repo_fmt.submodule_path_cfg; + the_repository->repository_format_hook_stdout_to_stderr = + repo_fmt.hook_stdout_to_stderr; /* take ownership of repo_fmt.partial_clone */ the_repository->repository_format_partial_clone = repo_fmt.partial_clone; @@ -2098,6 +2103,8 @@ void check_repository_format(struct repository_format *fmt) fmt->submodule_path_cfg; the_repository->repository_format_relative_worktrees = fmt->relative_worktrees; + the_repository->repository_format_hook_stdout_to_stderr = + fmt->hook_stdout_to_stderr; the_repository->repository_format_partial_clone = xstrdup_or_null(fmt->partial_clone); clear_repository_format(&repo_fmt); diff --git a/setup.h b/setup.h index bcb16b0b4a..6d7ddb3d02 100644 --- a/setup.h +++ b/setup.h @@ -168,6 +168,7 @@ struct repository_format { int worktree_config; int relative_worktrees; int submodule_path_cfg; + int hook_stdout_to_stderr; int is_bare; int hash_algo; int compat_hash_algo; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 195cc5333e..8f2f5e40bc 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -605,6 +605,43 @@ test_expect_success 'client hooks: pre-push expects separate stdout and stderr' check_stdout_separate_from_stderr pre-push ' +test_expect_success 'client hooks: extension makes pre-push merge stdout to stderr' ' + test_when_finished "rm -rf remote2 stdout.actual stderr.actual" && + git init --bare remote2 && + git remote add origin2 remote2 && + test_commit B && + git config set core.repositoryformatversion 1 && + test_config extensions.hookStdoutToStderr true && + setup_hooks pre-push && + git push origin2 HEAD:main >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push +' + +test_expect_success 'client hooks: pre-push defaults to serial execution' ' + test_when_finished "rm -rf remote-serial repo-serial" && + git init --bare remote-serial && + git init repo-serial && + git -C repo-serial remote add origin ../remote-serial && + test_commit -C repo-serial A && + + # Setup 2 pre-push hooks; no parallel=true so they must run serially. + # Use sentinel/detector pattern: hook-1 (sentinel, configured) runs first + # because configured hooks precede traditional hooks in list order; hook-2 + # (detector) runs second and checks whether hook-1 has finished. + git -C repo-serial config hook.hook-1.event pre-push && + git -C repo-serial config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + git -C repo-serial config hook.hook-2.event pre-push && + git -C repo-serial config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + git -C repo-serial config hook.jobs 2 && + + git -C repo-serial push origin HEAD >out 2>err && + echo serial >expect && + test_cmp expect repo-serial/hook.order +' + test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' ' hooks="pre-commit prepare-commit-msg \ commit-msg post-commit \ diff --git a/transport.c b/transport.c index 56a4015389..e25a94dc92 100644 --- a/transport.c +++ b/transport.c @@ -1390,11 +1390,8 @@ static int run_pre_push_hook(struct transport *transport, opt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc; opt.feed_pipe_cb_data_free = pre_push_hook_data_free; - /* - * pre-push hooks expect stdout & stderr to be separate, so don't merge - * them to keep backwards compatibility with existing hooks. - */ - opt.stdout_to_stderr = 0; + /* merge stdout to stderr only when extensions.hookStdoutToStderr is enabled */ + opt.stdout_to_stderr = the_repository->repository_format_hook_stdout_to_stderr; ret = run_hooks_opt(the_repository, "pre-push", &opt); -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr 2026-03-09 13:37 ` [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu @ 2026-03-16 18:44 ` Junio C Hamano 2026-03-18 19:50 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Junio C Hamano @ 2026-03-16 18:44 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson Adrian Ratiu <adrian.ratiu@collabora.com> writes: > All hooks already redirect stdout to stderr with the exception of > pre-push which has a known user who depends on the separate stdout > versus stderr outputs (the git-lfs project). > > The pre-push behavior was a surprise which we found out about after > causing a regression for git-lfs. Notably, it might not be the only > exception (it's the one we know about). There might be more. > > This presents a challenge because stdout_to_stderr is required for > hook parallelization, so run-command can buffer and de-interleave > the hook outputs using ungroup=0, when hook.jobs > 1. > > Introduce an extension to enforce consistency: all hooks merge stdout > into stderr and can be safely parallelized. This provides a clean > separation and avoids breaking existing stdout vs stderr behavior. > > When this extension is disabled, the `hook.jobs` config has no > effect for pre-push, to prevent garbled (interleaved) parallel > output, so it runs sequentially like before. > > Alternatives I've considered to this extension include: > 1. Allowing pre-push to run in parallel with interleaved output. > 2. Always running pre-push sequentially (no parallel jobs for it). > 3. Making users (only git-lfs? maybe more?) fix their hooks to read > stderr not stdout. > > Out of all these alternatives, I think this extension is the most > reasonable compromise, to not break existing users, allow pre-push > parallel jobs for those who need it (with correct outputs) and also > future-proofing in case there are any more exceptions to be added. Hmph, I am a bit surprised that this is not hook.<name>.stdoutToStderr controlled per hook process. If we already have consensus that giving output to stdout is a historical wart that we would rather want to fix, then this configuration is probably good enough. It is certainly a much simpler approach, and there is no need to make finer-grained customization available when nobody wants it ;-). We may also want to consider "fixing" it at Git 3.0 boundary, if that is the case, though? I dunno. I weren't following the discussion closely enough to tell myself. Thanks. ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr 2026-03-16 18:44 ` Junio C Hamano @ 2026-03-18 19:50 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-18 19:50 UTC (permalink / raw) To: Junio C Hamano Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Mon, 16 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote: > Adrian Ratiu <adrian.ratiu@collabora.com> writes: > >> All hooks already redirect stdout to stderr with the exception of >> pre-push which has a known user who depends on the separate stdout >> versus stderr outputs (the git-lfs project). >> >> The pre-push behavior was a surprise which we found out about after >> causing a regression for git-lfs. Notably, it might not be the only >> exception (it's the one we know about). There might be more. >> >> This presents a challenge because stdout_to_stderr is required for >> hook parallelization, so run-command can buffer and de-interleave >> the hook outputs using ungroup=0, when hook.jobs > 1. >> >> Introduce an extension to enforce consistency: all hooks merge stdout >> into stderr and can be safely parallelized. This provides a clean >> separation and avoids breaking existing stdout vs stderr behavior. >> >> When this extension is disabled, the `hook.jobs` config has no >> effect for pre-push, to prevent garbled (interleaved) parallel >> output, so it runs sequentially like before. >> >> Alternatives I've considered to this extension include: >> 1. Allowing pre-push to run in parallel with interleaved output. >> 2. Always running pre-push sequentially (no parallel jobs for it). >> 3. Making users (only git-lfs? maybe more?) fix their hooks to read >> stderr not stdout. >> >> Out of all these alternatives, I think this extension is the most >> reasonable compromise, to not break existing users, allow pre-push >> parallel jobs for those who need it (with correct outputs) and also >> future-proofing in case there are any more exceptions to be added. > > Hmph, I am a bit surprised that this is not hook.<name>.stdoutToStderr > controlled per hook process. You may laugh at me, but making this a per hook process setting (or per-event) didn't even occur to me until now. :) I think we could do this and let the user decide if their hooks are safe or not (i.e. do they expect output on stdout?) Or even better: Since we now default to jobs == 1, that already keeps backwards compatibility (initially in v1 I turned parallelism on by default, using the number of cpus, so this extension was unavoidable). Therefore if the user opts-in to parallelism (via config or -jN), we can just document that output will go to stderr instead of stdout. > > If we already have consensus that giving output to stdout is a > historical wart that we would rather want to fix, then this > configuration is probably good enough. It is certainly a much > simpler approach, and there is no need to make finer-grained > customization available when nobody wants it ;-). Agreed. I don't think anybody (including myself) wants this extension and if we do what I suggested above, we can drop it and don't even need a hook.<name>.stdoutToStderr config. Will drop this patch and the next one in the re-roll. > > We may also want to consider "fixing" it at Git 3.0 boundary, if > that is the case, though? I dunno. I weren't following the > discussion closely enough to tell myself. There's only 1 known breaking hook, however now I'm convinced we don't need to break backwards compatibility with my "even better" approach suggested above. ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v3 9/9] hook: allow runtime enabling extensions.hookStdoutToStderr 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu ` (7 preceding siblings ...) 2026-03-09 13:37 ` [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu @ 2026-03-09 13:37 ` Adrian Ratiu 8 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-09 13:37 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Add a new config `hook.forceStdoutToStderr` which allows enabling extensions.hookStdoutToStderr by default at runtime, both for new and existing repositories. This makes it easier for users to enable hook parallelization for hooks like pre-push by enforcing output consistency. See previous commit for a more in-depth explanation & alternatives considered. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/extensions.adoc | 3 ++ Documentation/config/hook.adoc | 6 +++ hook.c | 28 ++++++++++- hook.h | 1 + setup.c | 10 ++++ t/t1800-hook.sh | 69 ++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 2 deletions(-) diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index 1a189ff045..dd0a59de4d 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -127,6 +127,9 @@ in parallel to be grouped (de-interleaved) correctly. + Defaults to disabled. When disabled, `hook.jobs` has no effect for pre-push hooks, which will always be run sequentially. ++ +The extension can also be enabled by setting `hook.forceStdoutToStderr` +to `true` in the global configuration. worktreeConfig::: If enabled, then worktrees will load config settings from the diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 5e74fdce65..1e88c0fb25 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -65,3 +65,9 @@ This setting has no effect unless all configured hooks for the event have + This has no effect for hooks requiring separate output streams (like `pre-push`) unless `extensions.hookStdoutToStderr` is enabled. + +hook.forceStdoutToStderr:: + A boolean that enables the `extensions.hookStdoutToStderr` behavior + (merging stdout to stderr for all hooks) globally. This effectively + forces all hooks to behave as if the extension was enabled, allowing + parallel execution for hooks like `pre-push`. diff --git a/hook.c b/hook.c index b70c4c15ec..7bf7831645 100644 --- a/hook.c +++ b/hook.c @@ -132,6 +132,7 @@ struct hook_config_cache_entry { * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *, * where NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset. + * force_stdout_to_stderr: value of hook.forceStdoutToStderr. Defaults to 0. */ struct hook_all_config_cb { struct strmap commands; @@ -140,6 +141,7 @@ struct hook_all_config_cb { struct strmap parallel_hooks; struct strmap event_jobs; unsigned int jobs; + int force_stdout_to_stderr; }; /* repo_config() callback that collects all hook.* configuration in one pass. */ @@ -165,6 +167,10 @@ static int hook_config_lookup_all(const char *key, const char *value, warning(_("hook.jobs must be positive, ignoring: 0")); else data->jobs = v; + } else if (!strcmp(subkey, "forcestdouttostderr") && value) { + int v = git_parse_maybe_bool(value); + if (v >= 0) + data->force_stdout_to_stderr = v; } return 0; } @@ -344,6 +350,7 @@ static void build_hook_config_map(struct repository *r, cache->jobs = cb_data.jobs; cache->event_jobs = cb_data.event_jobs; + cache->force_stdout_to_stderr = cb_data.force_stdout_to_stderr; strmap_clear(&cb_data.commands, 1); strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ @@ -573,6 +580,20 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +static void hook_force_apply_stdout_to_stderr(struct repository *r, + struct run_hooks_opt *options) +{ + int force = 0; + + if (r && r->gitdir && r->hook_config_cache) + force = r->hook_config_cache->force_stdout_to_stderr; + else + repo_config_get_bool(r, "hook.forceStdoutToStderr", &force); + + if (force) + options->stdout_to_stderr = 1; +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, @@ -582,9 +603,12 @@ static unsigned int get_hook_jobs(struct repository *r, unsigned int jobs; /* - * Hooks needing separate output streams must run sequentially. Next - * commits will add an extension to allow parallelizing these as well. + * Apply hook.forceStdoutToStderr before anything else: it affects + * whether we can run in parallel or not. */ + hook_force_apply_stdout_to_stderr(r, options); + + /* Hooks needing separate output streams must run sequentially. */ if (!options->stdout_to_stderr) return 1; diff --git a/hook.h b/hook.h index e8603c4370..d4da78f277 100644 --- a/hook.h +++ b/hook.h @@ -235,6 +235,7 @@ struct hook_config_cache { struct strmap hooks; /* maps event name -> string_list of hooks */ struct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */ unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ + int force_stdout_to_stderr; /* hook.forceStdoutToStderr config value */ }; /** diff --git a/setup.c b/setup.c index b12c5ca313..bc4e95b522 100644 --- a/setup.c +++ b/setup.c @@ -2362,6 +2362,7 @@ void initialize_repository_version(int hash_algo, struct strbuf repo_version = STRBUF_INIT; int target_version = GIT_REPO_VERSION; int default_submodule_path_config = 0; + int default_hook_stdout_to_stderr = 0; /* * Note that we initialize the repository version to 1 when the ref @@ -2419,6 +2420,15 @@ void initialize_repository_version(int hash_algo, repo_config_set(the_repository, "extensions.submodulepathconfig", "true"); } + repo_config_get_bool(the_repository, "hook.forceStdoutToStderr", + &default_hook_stdout_to_stderr); + if (default_hook_stdout_to_stderr) { + /* extensions.hookstdouttostderr requires at least version 1 */ + if (target_version == 0) + target_version = 1; + repo_config_set(the_repository, "extensions.hookstdouttostderr", "true"); + } + strbuf_addf(&repo_version, "%d", target_version); repo_config_set(the_repository, "core.repositoryformatversion", repo_version.buf); diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 8f2f5e40bc..4fdd06072d 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -989,4 +989,73 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' test_cmp expect hook.order ' +test_expect_success '`git init` respects hook.forceStdoutToStderr' ' + test_when_finished "rm -rf repo-init" && + test_config_global hook.forceStdoutToStderr true && + git init repo-init && + git -C repo-init config extensions.hookStdoutToStderr >actual && + echo true >expect && + test_cmp expect actual +' + +test_expect_success '`git init` does not set extensions.hookStdoutToStderr by default' ' + test_when_finished "rm -rf upstream" && + git init upstream && + test_must_fail git -C upstream config extensions.hookStdoutToStderr +' + +test_expect_success '`git clone` does not set extensions.hookStdoutToStderr by default' ' + test_when_finished "rm -rf upstream repo-clone-no-ext" && + git init upstream && + git clone upstream repo-clone-no-ext && + test_must_fail git -C repo-clone-no-ext config extensions.hookStdoutToStderr +' + +test_expect_success '`git clone` respects hook.forceStdoutToStderr' ' + test_when_finished "rm -rf upstream repo-clone" && + git init upstream && + test_config_global hook.forceStdoutToStderr true && + git clone upstream repo-clone && + git -C repo-clone config extensions.hookStdoutToStderr >actual && + echo true >expect && + test_cmp expect actual +' + +test_expect_success 'hook.forceStdoutToStderr enables extension for existing repos' ' + test_when_finished "rm -rf remote-repo existing-repo" && + git init --bare remote-repo && + git init -b main existing-repo && + # No local extensions.hookStdoutToStderr config set here + # so global config should apply + test_config_global hook.forceStdoutToStderr true && + cd existing-repo && + test_commit A && + git remote add origin ../remote-repo && + setup_hooks pre-push && + git push origin HEAD >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push && + cd .. +' + +test_expect_success 'hook.forceStdoutToStderr enables pre-push parallel runs' ' + test_when_finished "rm -rf repo-parallel remote-parallel" && + git init --bare remote-parallel && + git init repo-parallel && + git -C repo-parallel remote add origin ../remote-parallel && + test_commit -C repo-parallel A && + + write_sentinel_hook repo-parallel/.git/hooks/pre-push && + git -C repo-parallel config hook.hook-2.event pre-push && + git -C repo-parallel config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + git -C repo-parallel config hook.hook-2.parallel true && + + git -C repo-parallel config hook.jobs 2 && + git -C repo-parallel config hook.forceStdoutToStderr true && + + git -C repo-parallel push origin HEAD >out 2>err && + echo parallel >expect && + test_cmp expect repo-parallel/hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v4 0/9] Run hooks in parallel 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu ` (6 preceding siblings ...) 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 1/9] config: add a repo_config_get_uint() helper Adrian Ratiu ` (9 more replies) 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu 8 siblings, 10 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Hello everyone, This series enables running hook commands in parallel and is based on the latest config hooks cleanup series v2 [1]. v4 has an important design change and addresses all feedback received in v3. The design change is that I dropped the extension introduced in previous patches for the following reasoning: Hook stdout -> stderr backwards compatibility is preserved by default when running sequentially (only known hook is pre-push which requires different stdout vs stderr streams), however when users opt-in to run hooks in parallel, hook stdout is redirected to stderr, so run-command can buffer and de-interlace the parallel hook output. Simpler said: If you opt-in to parallel execution, parallel hook output goes to stderr. Based on all discussions we had, I think this is the best compromise between preserving backwards compatibility, avoiding extensions nobody wants and also providing an incentive for users to migrate their hooks by opting-in, without forcing them via breakage. Branch pushed to GitHub: [2] Successful CI run: [3] with the known MacOS REG_ENHANCED breakage exception. Many thanks to all who contributed up to now, Adrian 1: https://lore.kernel.org/git/20260309005416.2760030-1-adrian.ratiu@collabora.com/T/#m1827a9302ecfdf2c07e2f0158cd24b4eb435aa37 2: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v4 3: https://github.com/10ne1/git/actions/runs/23342695565 Changes in v4: * Rebased on config-cleanups-v2 with a few small conflicts due to removing the redundant struct cache wrapping the strmap (Junio) * Simplified parallel toggle strmap_get by using double negation (Junio) * Simplified parallel = true loop detection early exit (Junio) * Made the options->jobs override stronger and issued a warning when forcing a parallel=false hook to run in parallel via -jN (Junio) * Added a new test for the above warning and updated the blocking test (Adrian) * Dropped the extension since it's not necessary anymore (Junio, Adrian) * New commit: users who opt-in to run hooks in parallel automatically get all the hook output on stderr. jobs == 1 is bacward compatible. (Adrian) * New commit: warn when user mixes event and hook namespaces (Junio) * New Commit: add hook.<event>.enabled switch (Junio) * Simplified get_hook_jobs logic by setting options->jobs directly, so we don't need the extra jobs variable and shorthand ifs (Adrian) * Removed heap allocations from event level .jobs config (Adrian) * Minor documentation & code comment improvements, commit messages (Junio) Range-diff v3 -> v4: 1: 6686d92867 = 1: ec274c24e5 repository: fix repo_init() memleak due to missing _clear() 2: 61250bdd91 = 2: 81d92a4465 config: add a repo_config_get_uint() helper 3: 2c49e2a523 ! 3: 3bc27f6997 hook: parse the hook.jobs config @@ Commit message add to this item's documentation. Parse hook.jobs config key in hook_config_lookup_all() and store its - value in hook_all_config_cb.jobs, then transfer it into - hook_config_cache.jobs after the config pass completes. + value in hook_all_config_cb.jobs, then transfer it into r->jobs after + the config pass completes. This is mostly plumbing and the cached value is not yet used. @@ hook.c: struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. -+ * jobs: value of the global hook.jobs key. Defaults to 0 if unset. ++ * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { struct strmap commands; @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value, if (!value) return config_error_nonbool(key); -@@ hook.c: void hook_cache_clear(struct hook_config_cache *cache) - static void build_hook_config_map(struct repository *r, - struct hook_config_cache *cache) +@@ hook.c: void hook_cache_clear(struct strmap *cache) + /* Populate `cache` with the complete hook configuration */ + static void build_hook_config_map(struct repository *r, struct strmap *cache) { - struct hook_all_config_cb cb_data; + struct hook_all_config_cb cb_data = { 0 }; struct hashmap_iter iter; struct strmap_entry *e; -@@ hook.c: static void build_hook_config_map(struct repository *r, +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); @@ hook.c: static void build_hook_config_map(struct repository *r, repo_config(r, hook_config_lookup_all, &cb_data); /* Construct the cache from parsed configs. */ -@@ hook.c: static void build_hook_config_map(struct repository *r, - strmap_put(&cache->hooks, e->key, hooks); +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) + strmap_put(cache, e->key, hooks); } -+ cache->jobs = cb_data.jobs; ++ if (r) ++ r->hook_jobs = cb_data.jobs; + strmap_clear(&cb_data.commands, 1); string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { - ## hook.h ## -@@ hook.h: void hook_free(void *p, const char *str UNUSED); - */ - struct hook_config_cache { - struct strmap hooks; /* maps event name -> string_list of hooks */ -+ unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ - }; + ## repository.h ## +@@ repository.h: struct repository { + */ + struct strmap *hook_config_cache; - /** ++ /* Cached value of hook.jobs config (0 if unset, defaults to serial). */ ++ unsigned int hook_jobs; ++ + /* Configurations related to promisor remotes. */ + char *repository_format_partial_clone; + struct promisor_remote_config *promisor_remote_config; 4: 3c206fac62 ! 4: 74e6f8689a hook: allow parallel hook execution @@ Documentation/config/hook.adoc: hook.<friendly-name>.enabled:: in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. -+hook.<name>.parallel:: -+ Whether the hook `hook.<name>` may run in parallel with other hooks ++hook.<friendly-name>.parallel:: ++ Whether the hook `hook.<friendly-name>` may run in parallel with other hooks + for the same event. Defaults to `false`. Set to `true` only when the + hook script is safe to run concurrently with other hooks for the same + event. If any hook for an event does not have this set to `true`, @@ Documentation/config/hook.adoc: hook.<friendly-name>.enabled:: hook execution. If unspecified, defaults to 1 (serial execution). ++ +This setting has no effect unless all configured hooks for the event have -+`hook.<name>.parallel` set to `true`. ++`hook.<friendly-name>.parallel` set to `true`. ## hook.c ## @@ hook.c: struct hook_config_cache_entry { char *command; enum config_scope scope; - int disabled; + unsigned int disabled:1; + unsigned int parallel:1; }; @@ hook.c: struct hook_config_cache_entry { * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * parallel_hooks: friendly-name to parallel flag. - * jobs: value of the global hook.jobs key. Defaults to 0 if unset. + * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { struct strmap commands; @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value, } free(hook_name); -@@ hook.c: static void build_hook_config_map(struct repository *r, +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.commands); strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); @@ hook.c: static void build_hook_config_map(struct repository *r, /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); -@@ hook.c: static void build_hook_config_map(struct repository *r, - enum config_scope scope = - (enum config_scope)(uintptr_t)hook_names->items[i].util; +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) struct hook_config_cache_entry *entry; -+ void *par = strmap_get(&cb_data.parallel_hooks, hname); char *command; ++ int is_par = !!strmap_get(&cb_data.parallel_hooks, hname); int is_disabled = -@@ hook.c: static void build_hook_config_map(struct repository *r, - entry->command = command ? xstrdup(command) : NULL; + !!unsorted_string_list_lookup( + &cb_data.disabled_hooks, hname); +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) + entry->command = xstrdup_or_null(command); entry->scope = scope; entry->disabled = is_disabled; -+ entry->parallel = (int)(uintptr_t)par; ++ entry->parallel = is_par; string_list_append(hooks, hname)->util = entry; } -@@ hook.c: static void build_hook_config_map(struct repository *r, - cache->jobs = cb_data.jobs; +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) + r->hook_jobs = cb_data.jobs; strmap_clear(&cb_data.commands, 1); + strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ @@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options) + struct run_hooks_opt *options, + struct string_list *hook_list) +{ -+ unsigned int jobs; -+ + /* -+ * Hooks needing separate output streams must run sequentially. Next -+ * commits will add an extension to allow parallelizing these as well. ++ * Hooks needing separate output streams must run sequentially. ++ * Next commit will allow parallelizing these as well. + */ + if (!options->stdout_to_stderr) + return 1; + -+ /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ ++ /* ++ * An explicit job count overrides everything else: this covers both ++ * FORCE_SERIAL callers (for hooks that must never run in parallel) ++ * and the -j flag from the CLI. The CLI override is intentional: users ++ * may want to serialize hooks declared parallel or to parallelize more ++ * aggressively than the default. ++ */ + if (options->jobs) + return options->jobs; + + /* + * Use hook.jobs from the already-parsed config cache (in-repo), or -+ * fall back to a direct config lookup (out-of-repo). Default to 1. ++ * fallback to a direct config lookup (out-of-repo). ++ * Default to 1 (serial execution) on failure. + */ + if (r && r->gitdir && r->hook_config_cache) + /* Use the already-parsed cache (in-repo) */ -+ jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; ++ options->jobs = r->hook_jobs ? r->hook_jobs : 1; + else + /* No cache present (out-of-repo call), use direct cfg lookup */ -+ jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs; ++ if (repo_config_get_uint(r, "hook.jobs", &options->jobs)) ++ options->jobs = 1; + + /* + * Cap to serial any configured hook not marked as parallel = true. + * This enforces the parallel = false default, even for "traditional" + * hooks from the hookdir which cannot be marked parallel = true. + */ -+ for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) { ++ for (size_t i = 0; i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; -+ if (h->kind == HOOK_CONFIGURED && !h->parallel) -+ jobs = 1; ++ if (h->kind == HOOK_CONFIGURED && !h->parallel) { ++ options->jobs = 1; ++ break; ++ } + } + -+ return jobs; ++ return options->jobs; +} + int run_hooks_opt(struct repository *r, const char *hook_name, @@ hook.h: struct hook { /** * Opaque data pointer used to keep internal state across callback calls. * -@@ hook.h: struct run_hooks_opt +@@ hook.h: struct run_hooks_opt { * * If > 1, output will be buffered and de-interleaved (ungroup=0). * If == 1, output will be real-time (ungroup=1). @@ hook.h: struct run_hooks_opt */ unsigned int jobs; -@@ hook.h: struct run_hooks_opt +@@ hook.h: struct run_hooks_opt { hook_data_free_fn feed_pipe_cb_data_free; }; -: ---------- > 5: 508d6476c6 hook: allow pre-push parallel execution 5: 2385cb3bc8 ! 6: 2d7b3d6d83 hook: mark non-parallelizable hooks @@ Commit message Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> ## Documentation/config/hook.adoc ## -@@ Documentation/config/hook.adoc: hook.<name>.parallel:: +@@ Documentation/config/hook.adoc: hook.<friendly-name>.parallel:: hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Some hooks always run sequentially regardless of this setting because -+ git knows they cannot safely be parallelized: `applypatch-msg`, -+ `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, -+ `post-checkout`, and `push-to-checkout`. ++ they operate on shared data and cannot safely be parallelized: +++ ++-- ++`applypatch-msg`;; ++`prepare-commit-msg`;; ++`commit-msg`;; ++ Receive a commit message file and may rewrite it in place. ++`pre-commit`;; ++`post-checkout`;; ++`push-to-checkout`;; ++`post-commit`;; ++ Access the working tree, index, or repository state. ++-- + This setting has no effect unless all configured hooks for the event have - `hook.<name>.parallel` set to `true`. + `hook.<friendly-name>.parallel` set to `true`. ## builtin/am.c ## @@ builtin/am.c: static int run_applypatch_msg_hook(struct am_state *state) @@ commit.c: size_t ignored_log_message_bytes(const char *buf, size_t len) ## t/t1800-hook.sh ## -@@ t/t1800-hook.sh: test_expect_success 'one non-parallel hook forces the whole event to run seriall - test_cmp expect hook.order +@@ t/t1800-hook.sh: test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' + test_cmp expect repo-parallel/hook.order ' +test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' ' 6: c4f92834a7 ! 7: 97507d8d31 hook: add -j/--jobs option to git hook run @@ Documentation/git-hook.adoc: git-hook - Run git hooks DESCRIPTION @@ Documentation/git-hook.adoc: OPTIONS - in parentheses after the friendly name of each configured hook, to show - where it was defined. Traditional hooks from the hookdir are unaffected. + mirroring the output style of `git config --show-scope`. Traditional + hooks from the hookdir are unaffected. +-j:: +--jobs:: @@ builtin/hook.c: static int run(int argc, const char **argv, const char *prefix, int ret; ## hook.c ## +@@ hook.c: static void merge_output_if_parallel(struct run_hooks_opt *options) + options->stdout_to_stderr = 1; + } + ++static void warn_non_parallel_hooks_override(unsigned int jobs, ++ struct string_list *hook_list) ++{ ++ /* Don't warn for hooks running sequentially. */ ++ if (jobs == 1) ++ return; ++ ++ for (size_t i = 0; i < hook_list->nr; i++) { ++ struct hook *h = hook_list->items[i].util; ++ if (h->kind == HOOK_CONFIGURED && !h->parallel) ++ warning(_("hook '%s' is not marked as parallel=true, " ++ "running in parallel anyway due to -j%u"), ++ h->u.configured.friendly_name, jobs); ++ } ++} ++ + /* Determine how many jobs to use for hook execution. */ + static unsigned int get_hook_jobs(struct repository *r, + struct run_hooks_opt *options, @@ hook.c: static unsigned int get_hook_jobs(struct repository *r, - if (!options->stdout_to_stderr) - return 1; -- /* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */ -- if (options->jobs) -- return options->jobs; -+ /* Pinned serial: FORCE_SERIAL (internal) or explicit -j1 from CLI. */ -+ if (options->jobs == 1) -+ return 1; + cleanup: + merge_output_if_parallel(options); ++ warn_non_parallel_hooks_override(options->jobs, hook_list); + return options->jobs; + } - /* -- * Use hook.jobs from the already-parsed config cache (in-repo), or -- * fall back to a direct config lookup (out-of-repo). Default to 1. -+ * Resolve effective job count: -jN (when given) overrides config. -+ * Default to 1 when both config an -jN are missing. - */ -- if (r && r->gitdir && r->hook_config_cache) -+ if (options->jobs > 1) -+ jobs = options->jobs; -+ else if (r && r->gitdir && r->hook_config_cache) - /* Use the already-parsed cache (in-repo) */ - jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; - else ## t/t1800-hook.sh ## @@ t/t1800-hook.sh: test_expect_success 'git -c core.hooksPath=<PATH> hook run' ' @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + test_cmp expect hook.order +' + -+test_expect_success 'git hook run -j2 is blocked by parallel=false' ' ++test_expect_success 'git hook run -j2 overrides parallel=false' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + -+ # -j2 must not override parallel=false on configured hooks. ++ # -j2 overrides parallel=false; hooks run in parallel with a warning. + git hook run -j2 test-hook >out 2>err && -+ echo serial >expect && ++ echo parallel >expect && + test_cmp expect hook.order +' ++ ++test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' ' ++ test_config hook.hook-1.event test-hook && ++ test_config hook.hook-1.command "true" && ++ test_config hook.hook-2.event test-hook && ++ test_config hook.hook-2.command "true" && ++ # neither hook has parallel=true ++ ++ git hook run -j2 test-hook >out 2>err && ++ grep "hook .hook-1. is not marked as parallel=true" err && ++ grep "hook .hook-2. is not marked as parallel=true" err ++' + test_expect_success 'hook.jobs=1 config runs hooks in series' ' test_when_finished "rm -f sentinel.started sentinel.done hook.order" && 7: 7b3ea03bd3 ! 8: 734adfad1b hook: add per-event jobs config @@ Commit message Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> ## Documentation/config/hook.adoc ## -@@ Documentation/config/hook.adoc: hook.<name>.parallel:: +@@ Documentation/config/hook.adoc: hook.<friendly-name>.parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. @@ Documentation/config/hook.adoc: hook.<name>.parallel:: hook execution. If unspecified, defaults to 1 (serial execution). + Can be overridden on a per-event basis with `hook.<event>.jobs`. Some hooks always run sequentially regardless of this setting because - git knows they cannot safely be parallelized: `applypatch-msg`, - `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, + they operate on shared data and cannot safely be parallelized: + + ## hook.c ## @@ hook.c: struct hook_config_cache_entry { * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. * parallel_hooks: friendly-name to parallel flag. -+ * event_jobs: event-name to per-event jobs count (heap-allocated unsigned int *, -+ * where NULL == unset). - * jobs: value of the global hook.jobs key. Defaults to 0 if unset. ++ * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset). + * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { @@ hook.c: struct hook_all_config_cb { @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value, + hook_name, value); + else if (!v) + warning(_("hook.%s.jobs must be positive, ignoring: 0"), hook_name); -+ else { -+ unsigned int *old; -+ unsigned int *p = xmalloc(sizeof(*p)); -+ *p = v; -+ old = strmap_put(&data->event_jobs, hook_name, p); -+ free(old); -+ } ++ else ++ strmap_put(&data->event_jobs, hook_name, ++ (void *)(uintptr_t)v); } free(hook_name); -@@ hook.c: void hook_cache_clear(struct hook_config_cache *cache) - free(hooks); - } - strmap_clear(&cache->hooks, 0); -+ strmap_clear(&cache->event_jobs, 1); /* free heap-allocated unsigned int * values */ - } - - /* Populate `cache` with the complete hook configuration */ -@@ hook.c: static void build_hook_config_map(struct repository *r, +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); strmap_init(&cb_data.parallel_hooks); @@ hook.c: static void build_hook_config_map(struct repository *r, /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); -@@ hook.c: static void build_hook_config_map(struct repository *r, +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) + strmap_put(cache, e->key, hooks); } - cache->jobs = cb_data.jobs; -+ cache->event_jobs = cb_data.event_jobs; +- if (r) ++ if (r) { + r->hook_jobs = cb_data.jobs; ++ r->event_jobs = cb_data.event_jobs; ++ } strmap_clear(&cb_data.commands, 1); strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ -@@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options) +@@ hook.c: static void warn_non_parallel_hooks_override(unsigned int jobs, /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, + const char *hook_name, struct string_list *hook_list) { - unsigned int jobs; -@@ hook.c: static unsigned int get_hook_jobs(struct repository *r, - return 1; - /* -- * Resolve effective job count: -jN (when given) overrides config. -- * Default to 1 when both config an -jN are missing. -+ * Resolve effective job count: -j N (when given) overrides config. -+ * hook.<event>.jobs overrides hook.jobs. -+ * Unset configs and -jN default to 1. +@@ hook.c: static unsigned int get_hook_jobs(struct repository *r, + * fallback to a direct config lookup (out-of-repo). + * Default to 1 (serial execution) on failure. */ -- if (options->jobs > 1) -+ if (options->jobs > 1) { - jobs = options->jobs; -- else if (r && r->gitdir && r->hook_config_cache) -+ } else if (r && r->gitdir && r->hook_config_cache) { +- if (r && r->gitdir && r->hook_config_cache) ++ if (r && r->gitdir && r->hook_config_cache) { /* Use the already-parsed cache (in-repo) */ -+ unsigned int *event_jobs = strmap_get(&r->hook_config_cache->event_jobs, -+ hook_name); - jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1; ++ void *event_jobs = strmap_get(&r->event_jobs, hook_name); + options->jobs = r->hook_jobs ? r->hook_jobs : 1; - else + if (event_jobs) -+ jobs = *event_jobs; ++ options->jobs = (unsigned int)(uintptr_t)event_jobs; + } else { /* No cache present (out-of-repo call), use direct cfg lookup */ + unsigned int event_jobs; + char *key; - jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs; ++ + if (repo_config_get_uint(r, "hook.jobs", &options->jobs)) + options->jobs = 1; + + key = xstrfmt("hook.%s.jobs", hook_name); + if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) -+ jobs = event_jobs; ++ options->jobs = event_jobs; + free(key); + } - ++ /* * Cap to serial any configured hook not marked as parallel = true. * This enforces the parallel = false default, even for "traditional" @@ hook.c: static unsigned int get_hook_jobs(struct repository *r, + * The same restriction applies whether jobs came from hook.jobs or + * hook.<event>.jobs. */ - for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) { + for (size_t i = 0; i < hook_list->nr; i++) { struct hook *h = hook_list->items[i].util; @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name, .options = options, @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name, .tr2_category = "hook", .tr2_label = hook_name, - ## hook.h ## -@@ hook.h: void hook_free(void *p, const char *str UNUSED); - */ - struct hook_config_cache { - struct strmap hooks; /* maps event name -> string_list of hooks */ -+ struct strmap event_jobs; /* maps event name -> heap-allocated unsigned int * */ - unsigned int jobs; /* hook.jobs config value; 0 if unset (defaults to serial) */ - }; + ## repository.c ## +@@ repository.c: void repo_clear(struct repository *repo) + hook_cache_clear(repo->hook_config_cache); + FREE_AND_NULL(repo->hook_config_cache); + } ++ strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */ + if (repo->promisor_remote_config) { + promisor_remote_clear(repo->promisor_remote_config); + + ## repository.h ## +@@ repository.h: struct repository { + /* Cached value of hook.jobs config (0 if unset, defaults to serial). */ + unsigned int hook_jobs; + ++ /* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */ ++ struct strmap event_jobs; ++ + /* Configurations related to promisor remotes. */ + char *repository_format_partial_clone; + struct promisor_remote_config *promisor_remote_config; ## t/t1800-hook.sh ## @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' 8: d5cf01444f < -: ---------- hook: introduce extensions.hookStdoutToStderr 9: 1cef7b5e22 < -: ---------- hook: allow runtime enabling extensions.hookStdoutToStderr -: ---------- > 9: d6d3196b17 hook: warn when hook.<friendly-name>.jobs is set -: ---------- > 10: b64689d0c8 hook: add hook.<event>.enabled switch Adrian Ratiu (6): config: add a repo_config_get_uint() helper hook: parse the hook.jobs config hook: allow pre-push parallel execution hook: add per-event jobs config hook: warn when hook.<friendly-name>.jobs is set hook: add hook.<event>.enabled switch Emily Shaffer (3): hook: allow parallel hook execution hook: mark non-parallelizable hooks hook: add -j/--jobs option to git hook run Documentation/config/hook.adoc | 66 +++++ Documentation/git-hook.adoc | 18 +- builtin/am.c | 8 +- builtin/checkout.c | 19 +- builtin/clone.c | 6 +- builtin/hook.c | 25 +- builtin/receive-pack.c | 3 +- builtin/worktree.c | 2 +- commit.c | 2 +- config.c | 28 ++ config.h | 13 + hook.c | 222 ++++++++++++++- hook.h | 32 ++- parse.c | 9 + parse.h | 1 + repository.c | 2 + repository.h | 10 + t/t1800-hook.sh | 476 ++++++++++++++++++++++++++++++++- transport.c | 6 +- 19 files changed, 903 insertions(+), 45 deletions(-) -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v4 1/9] config: add a repo_config_get_uint() helper 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 2/9] hook: parse the hook.jobs config Adrian Ratiu ` (8 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Next commits add a 'hook.jobs' config option of type 'unsigned int', so add a helper to parse it since the API only supports int and ulong. An alternative is to make 'hook.jobs' an 'int' or parse it as an 'int' then cast it to unsigned, however it's better to use proper helpers for the type. Using 'ulong' is another option which already has helpers, but it's a bit excessive in size for just the jobs number. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- config.c | 28 ++++++++++++++++++++++++++++ config.h | 13 +++++++++++++ parse.c | 9 +++++++++ parse.h | 1 + 4 files changed, 51 insertions(+) diff --git a/config.c b/config.c index 156f2a24fa..a1b92fe083 100644 --- a/config.c +++ b/config.c @@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value, return ret; } +unsigned int git_config_uint(const char *name, const char *value, + const struct key_value_info *kvi) +{ + unsigned int ret; + if (!git_parse_uint(value, &ret)) + die_bad_number(name, value, kvi); + return ret; +} + int64_t git_config_int64(const char *name, const char *value, const struct key_value_info *kvi) { @@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest) return 1; } +int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest) +{ + const char *value; + struct key_value_info kvi; + + if (!git_configset_get_value(set, key, &value, &kvi)) { + *dest = git_config_uint(key, value, &kvi); + return 0; + } else + return 1; +} + int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest) { const char *value; @@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo, return git_configset_get_int(repo->config, key, dest); } +int repo_config_get_uint(struct repository *repo, + const char *key, unsigned int *dest) +{ + git_config_check_init(repo); + return git_configset_get_uint(repo->config, key, dest); +} + int repo_config_get_ulong(struct repository *repo, const char *key, unsigned long *dest) { diff --git a/config.h b/config.h index ba426a960a..bf47fb3afc 100644 --- a/config.h +++ b/config.h @@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *); int64_t git_config_int64(const char *, const char *, const struct key_value_info *); +/** + * Identical to `git_config_int`, but for unsigned ints. + */ +unsigned int git_config_uint(const char *, const char *, + const struct key_value_info *); + /** * Identical to `git_config_int`, but for unsigned longs. */ @@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key, int git_configset_get_string(struct config_set *cs, const char *key, char **dest); int git_configset_get_int(struct config_set *cs, const char *key, int *dest); +int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest); int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest); int git_configset_get_bool(struct config_set *cs, const char *key, int *dest); int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest); @@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r, */ int repo_config_get_int(struct repository *r, const char *key, int *dest); +/** + * Similar to `repo_config_get_int` but for unsigned ints. + */ +int repo_config_get_uint(struct repository *r, + const char *key, unsigned int *dest); + /** * Similar to `repo_config_get_int` but for unsigned longs. */ diff --git a/parse.c b/parse.c index 48313571aa..d77f28046a 100644 --- a/parse.c +++ b/parse.c @@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret) return 1; } +int git_parse_uint(const char *value, unsigned int *ret) +{ + uintmax_t tmp; + if (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int))) + return 0; + *ret = tmp; + return 1; +} + int git_parse_ulong(const char *value, unsigned long *ret) { uintmax_t tmp; diff --git a/parse.h b/parse.h index ea32de9a91..a6dd37c4cb 100644 --- a/parse.h +++ b/parse.h @@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max); int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max); int git_parse_ssize_t(const char *, ssize_t *); int git_parse_ulong(const char *, unsigned long *); +int git_parse_uint(const char *value, unsigned int *ret); int git_parse_int(const char *value, int *ret); int git_parse_int64(const char *value, int64_t *ret); int git_parse_double(const char *value, double *ret); -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v4 2/9] hook: parse the hook.jobs config 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 1/9] config: add a repo_config_get_uint() helper Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-24 9:07 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 3/9] hook: allow parallel hook execution Adrian Ratiu ` (7 subsequent siblings) 9 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu The hook.jobs config is a global way to set hook parallelization for all hooks, in the sense that it is not per-event nor per-hook. Finer-grained configs will be added in later commits which can override it, for e.g. via a per-event type job options. Next commits will also add to this item's documentation. Parse hook.jobs config key in hook_config_lookup_all() and store its value in hook_all_config_cb.jobs, then transfer it into r->jobs after the config pass completes. This is mostly plumbing and the cached value is not yet used. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++++ hook.c | 23 +++++++++++++++++++++-- repository.h | 3 +++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 9e78f26439..b7847f9338 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -22,3 +22,7 @@ hook.<friendly-name>.enabled:: configuration. This is particularly useful when a hook is defined in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. + +hook.jobs:: + Specifies how many hooks can be run simultaneously during parallelized + hook execution. If unspecified, defaults to 1 (serial execution). diff --git a/hook.c b/hook.c index 0e09b9a2bb..c4872d8707 100644 --- a/hook.c +++ b/hook.c @@ -127,11 +127,13 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + unsigned int jobs; }; /* repo_config() callback that collects all hook.* configuration in one pass. */ @@ -147,6 +149,20 @@ static int hook_config_lookup_all(const char *key, const char *value, if (parse_config_key(key, "hook", &name, &name_len, &subkey)) return 0; + /* Handle plain hook.<key> entries that have no hook name component. */ + if (!name) { + if (!strcmp(subkey, "jobs") && value) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.jobs must be a positive integer, ignoring: '%s'"), value); + else if (!v) + warning(_("hook.jobs must be positive, ignoring: 0")); + else + data->jobs = v; + } + return 0; + } + if (!value) return config_error_nonbool(key); @@ -244,7 +260,7 @@ void hook_cache_clear(struct strmap *cache) /* Populate `cache` with the complete hook configuration */ static void build_hook_config_map(struct repository *r, struct strmap *cache) { - struct hook_all_config_cb cb_data; + struct hook_all_config_cb cb_data = { 0 }; struct hashmap_iter iter; struct strmap_entry *e; @@ -252,7 +268,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); - /* Parse all configs in one run. */ + /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); /* Construct the cache from parsed configs. */ @@ -296,6 +312,9 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_put(cache, e->key, hooks); } + if (r) + r->hook_jobs = cb_data.jobs; + strmap_clear(&cb_data.commands, 1); string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { diff --git a/repository.h b/repository.h index 078059a6e0..58e46853d0 100644 --- a/repository.h +++ b/repository.h @@ -172,6 +172,9 @@ struct repository { */ struct strmap *hook_config_cache; + /* Cached value of hook.jobs config (0 if unset, defaults to serial). */ + unsigned int hook_jobs; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v4 2/9] hook: parse the hook.jobs config 2026-03-20 13:53 ` [PATCH v4 2/9] hook: parse the hook.jobs config Adrian Ratiu @ 2026-03-24 9:07 ` Patrick Steinhardt 2026-03-24 18:59 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-24 9:07 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Fri, Mar 20, 2026 at 03:53:04PM +0200, Adrian Ratiu wrote: > diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc > index 9e78f26439..b7847f9338 100644 > --- a/Documentation/config/hook.adoc > +++ b/Documentation/config/hook.adoc > @@ -22,3 +22,7 @@ hook.<friendly-name>.enabled:: > configuration. This is particularly useful when a hook is defined > in a system or global config file and needs to be disabled for a > specific repository. See linkgit:git-hook[1]. > + > +hook.jobs:: > + Specifies how many hooks can be run simultaneously during parallelized > + hook execution. If unspecified, defaults to 1 (serial execution). I was wondering whether we also want to allow -1 as a way to say "use as many jobs as I have CPU cores". We also do this in some other places. Totally fine to ignore this for now though, we can still add it at a later point in time once somebody complains. Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v4 2/9] hook: parse the hook.jobs config 2026-03-24 9:07 ` Patrick Steinhardt @ 2026-03-24 18:59 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-24 18:59 UTC (permalink / raw) To: Patrick Steinhardt Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Tue, 24 Mar 2026, Patrick Steinhardt <ps@pks.im> wrote: > On Fri, Mar 20, 2026 at 03:53:04PM +0200, Adrian Ratiu wrote: >> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc >> index 9e78f26439..b7847f9338 100644 >> --- a/Documentation/config/hook.adoc >> +++ b/Documentation/config/hook.adoc >> @@ -22,3 +22,7 @@ hook.<friendly-name>.enabled:: >> configuration. This is particularly useful when a hook is defined >> in a system or global config file and needs to be disabled for a >> specific repository. See linkgit:git-hook[1]. >> + >> +hook.jobs:: >> + Specifies how many hooks can be run simultaneously during parallelized >> + hook execution. If unspecified, defaults to 1 (serial execution). > > I was wondering whether we also want to allow -1 as a way to say "use as > many jobs as I have CPU cores". We also do this in some other places. > > Totally fine to ignore this for now though, we can still add it at a > later point in time once somebody complains. Yes, we can do this. I already had the "use as many cpu cores as availble" logic in v1, within a different context which I dropped, so it shouldn't be too hard to add it back when hook.jobs == -1. This is actually much cleaner than what we had in v1. I'll do this in the next re-roll. Thanks! ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v4 3/9] hook: allow parallel hook execution 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 1/9] config: add a repo_config_get_uint() helper Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 2/9] hook: parse the hook.jobs config Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-24 9:07 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 4/9] hook: allow pre-push parallel execution Adrian Ratiu ` (6 subsequent siblings) 9 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Emily Shaffer, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <nasamuffin@google.com> Hooks always run in sequential order due to the hardcoded jobs == 1 passed to run_process_parallel(). Remove that hardcoding to allow users to run hooks in parallel (opt-in). Users need to decide which hooks to run in parallel, by specifying "parallel = true" in the config, because git cannot know if their specific hooks are safe to run or not in parallel (for e.g. two hooks might write to the same file or call the same program). Some hooks are unsafe to run in parallel by design: these will marked in the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL. The hook.jobs config specifies the default number of jobs applied to all hooks which have parallelism enabled. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 13 +++ hook.c | 74 +++++++++++++++-- hook.h | 25 ++++++ t/t1800-hook.sh | 142 +++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 6 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index b7847f9338..21800db648 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -23,6 +23,19 @@ hook.<friendly-name>.enabled:: in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. +hook.<friendly-name>.parallel:: + Whether the hook `hook.<friendly-name>` may run in parallel with other hooks + for the same event. Defaults to `false`. Set to `true` only when the + hook script is safe to run concurrently with other hooks for the same + event. If any hook for an event does not have this set to `true`, + all hooks for that event run sequentially regardless of `hook.jobs`. + Only configured (named) hooks need to declare this. Traditional hooks + found in the hooks directory do not need to, and run in parallel when + the effective job count is greater than 1. See linkgit:git-hook[1]. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). ++ +This setting has no effect unless all configured hooks for the event have +`hook.<friendly-name>.parallel` set to `true`. diff --git a/hook.c b/hook.c index c4872d8707..a60dac5a60 100644 --- a/hook.c +++ b/hook.c @@ -120,6 +120,7 @@ struct hook_config_cache_entry { char *command; enum config_scope scope; unsigned int disabled:1; + unsigned int parallel:1; }; /* @@ -127,12 +128,14 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * parallel_hooks: friendly-name to parallel flag. * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + struct strmap parallel_hooks; unsigned int jobs; }; @@ -223,6 +226,10 @@ static int hook_config_lookup_all(const char *key, const char *value, default: break; /* ignore unrecognised values */ } + } else if (!strcmp(subkey, "parallel")) { + int v = git_parse_maybe_bool(value); + if (v >= 0) + strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); } free(hook_name); @@ -267,6 +274,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.commands); strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); + strmap_init(&cb_data.parallel_hooks); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -286,6 +294,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) struct hook_config_cache_entry *entry; char *command; + int is_par = !!strmap_get(&cb_data.parallel_hooks, hname); int is_disabled = !!unsorted_string_list_lookup( &cb_data.disabled_hooks, hname); @@ -306,6 +315,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) entry->command = xstrdup_or_null(command); entry->scope = scope; entry->disabled = is_disabled; + entry->parallel = is_par; string_list_append(hooks, hname)->util = entry; } @@ -316,6 +326,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) r->hook_jobs = cb_data.jobs; strmap_clear(&cb_data.commands, 1); + strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { string_list_clear(e->value, 0); @@ -392,6 +403,7 @@ static void list_hooks_add_configured(struct repository *r, entry->command ? xstrdup(entry->command) : NULL; hook->u.configured.scope = entry->scope; hook->u.configured.disabled = entry->disabled; + hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; } @@ -541,21 +553,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* Determine how many jobs to use for hook execution. */ +static unsigned int get_hook_jobs(struct repository *r, + struct run_hooks_opt *options, + struct string_list *hook_list) +{ + /* + * Hooks needing separate output streams must run sequentially. + * Next commit will allow parallelizing these as well. + */ + if (!options->stdout_to_stderr) + return 1; + + /* + * An explicit job count overrides everything else: this covers both + * FORCE_SERIAL callers (for hooks that must never run in parallel) + * and the -j flag from the CLI. The CLI override is intentional: users + * may want to serialize hooks declared parallel or to parallelize more + * aggressively than the default. + */ + if (options->jobs) + return options->jobs; + + /* + * Use hook.jobs from the already-parsed config cache (in-repo), or + * fallback to a direct config lookup (out-of-repo). + * Default to 1 (serial execution) on failure. + */ + if (r && r->gitdir && r->hook_config_cache) + /* Use the already-parsed cache (in-repo) */ + options->jobs = r->hook_jobs ? r->hook_jobs : 1; + else + /* No cache present (out-of-repo call), use direct cfg lookup */ + if (repo_config_get_uint(r, "hook.jobs", &options->jobs)) + options->jobs = 1; + + /* + * Cap to serial any configured hook not marked as parallel = true. + * This enforces the parallel = false default, even for "traditional" + * hooks from the hookdir which cannot be marked parallel = true. + */ + for (size_t i = 0; i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; + if (h->kind == HOOK_CONFIGURED && !h->parallel) { + options->jobs = 1; + break; + } + } + + return options->jobs; +} + int run_hooks_opt(struct repository *r, const char *hook_name, struct run_hooks_opt *options) { + struct string_list *hook_list = list_hooks(r, hook_name, options); struct hook_cb_data cb_data = { .rc = 0, .hook_name = hook_name, + .hook_command_list = hook_list, .options = options, }; int ret = 0; + unsigned int jobs = get_hook_jobs(r, options, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, - .processes = options->jobs, - .ungroup = options->jobs == 1, + .processes = jobs, + .ungroup = jobs == 1, .get_next_task = pick_next_hook, .start_failure = notify_start_failure, @@ -571,9 +637,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->path_to_stdin && options->feed_pipe) BUG("options path_to_stdin and feed_pipe are mutually exclusive"); - if (!options->jobs) - BUG("run_hooks_opt must be called with options.jobs >= 1"); - /* * Ensure cb_data copy and free functions are either provided together, * or neither one is provided. @@ -584,7 +647,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->invoked_hook) *options->invoked_hook = 0; - cb_data.hook_command_list = list_hooks(r, hook_name, options); if (!cb_data.hook_command_list->nr) { if (options->error_if_missing) ret = error("cannot find a hook named %s", hook_name); diff --git a/hook.h b/hook.h index 7c8c3d471e..494f74345f 100644 --- a/hook.h +++ b/hook.h @@ -35,6 +35,13 @@ struct hook { } configured; } u; + /** + * Whether this hook may run in parallel with other hooks for the same + * event. Only useful for configured (named) hooks. Traditional hooks + * always default to 0 (serial). Set via `hook.<name>.parallel = true`. + */ + unsigned int parallel:1; + /** * Opaque data pointer used to keep internal state across callback calls. * @@ -72,6 +79,8 @@ struct run_hooks_opt { * * If > 1, output will be buffered and de-interleaved (ungroup=0). * If == 1, output will be real-time (ungroup=1). + * If == 0, the 'hook.jobs' config is used or, if the config is unset, + * defaults to 1 (serial execution). */ unsigned int jobs; @@ -152,7 +161,23 @@ struct run_hooks_opt { hook_data_free_fn feed_pipe_cb_data_free; }; +/** + * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to + * the 'hook.jobs' config, falling back to serial (1) if unset. + */ #define RUN_HOOKS_OPT_INIT { \ + .env = STRVEC_INIT, \ + .args = STRVEC_INIT, \ + .stdout_to_stderr = 1, \ + .jobs = 0, \ +} + +/** + * Initializer for hooks that must always run sequentially regardless of + * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized + * .jobs = 1 is non-overridable. + */ +#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \ .env = STRVEC_INIT, \ .args = STRVEC_INIT, \ .stdout_to_stderr = 1, \ diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 6f6fe88bea..b1049a296d 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -21,6 +21,57 @@ setup_hookdir () { test_when_finished rm -rf .git/hooks } +# write_sentinel_hook <path> [sentinel] +# +# Writes a hook that marks itself as started, sleeps for a few seconds, then +# marks itself done. The sleep must be long enough that sentinel_detector can +# observe <sentinel>.started before <sentinel>.done appears when both hooks +# run concurrently in parallel mode. +write_sentinel_hook () { + sentinel="${2:-sentinel}" + write_script "$1" <<-EOF + touch ${sentinel}.started && + sleep 2 && + touch ${sentinel}.done + EOF +} + +# sentinel_detector <sentinel> <output> +# +# Returns a shell command string suitable for use as hook.<name>.command. +# The detector must be registered after the sentinel: +# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists) +# before the detector starts. +# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared +# yet and the detector just sees <sentinel>.started. +# +# At start, poll until <sentinel>.started exists to absorb startup jitter, then +# write to <output>: +# 1. 'serial' if <sentinel>.done exists (sentinel finished before we started), +# 2. 'parallel' if only <sentinel>.started exists (sentinel still running), +# 3. 'timeout' if <sentinel>.started never appeared. +# +# The command ends with ':' so when git appends "$@" for hooks that receive +# positional arguments (e.g. pre-push), the result ': "$@"' is valid shell +# rather than a syntax error 'fi "$@"'. +sentinel_detector () { + cat <<-EOF + i=0 + while ! test -f ${1}.started && test \$i -lt 10; do + sleep 1 + i=\$((i+1)) + done + if test -f ${1}.done; then + echo serial >${2} + elif test -f ${1}.started; then + echo parallel >${2} + else + echo timeout >${2} + fi + : + EOF +} + test_expect_success 'git hook usage' ' test_expect_code 129 git hook && test_expect_code 129 git hook run && @@ -626,4 +677,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'hook.jobs=1 config runs hooks in series' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + + # Use two configured hooks so the execution order is deterministic: + # hook-1 (sentinel) is listed before hook-2 (detector), so hook-1 + # always runs first even in serial mode. + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 1 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.jobs=2 config runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<name>.parallel=true enables parallel execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'one non-parallel hook forces the whole event to run serially' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 has no parallel=true: should force serial for all + + test_config hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v4 3/9] hook: allow parallel hook execution 2026-03-20 13:53 ` [PATCH v4 3/9] hook: allow parallel hook execution Adrian Ratiu @ 2026-03-24 9:07 ` Patrick Steinhardt 0 siblings, 0 replies; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-24 9:07 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Emily Shaffer, Ævar Arnfjörð Bjarmason On Fri, Mar 20, 2026 at 03:53:05PM +0200, Adrian Ratiu wrote: > From: Emily Shaffer <nasamuffin@google.com> > > Hooks always run in sequential order due to the hardcoded jobs == 1 > passed to run_process_parallel(). Remove that hardcoding to allow > users to run hooks in parallel (opt-in). > > Users need to decide which hooks to run in parallel, by specifying > "parallel = true" in the config, because git cannot know if their s/git/Git/ Sorry to be pedantic :) > diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc > index b7847f9338..21800db648 100644 > --- a/Documentation/config/hook.adoc > +++ b/Documentation/config/hook.adoc > @@ -23,6 +23,19 @@ hook.<friendly-name>.enabled:: > in a system or global config file and needs to be disabled for a > specific repository. See linkgit:git-hook[1]. > > +hook.<friendly-name>.parallel:: > + Whether the hook `hook.<friendly-name>` may run in parallel with other hooks > + for the same event. Defaults to `false`. Set to `true` only when the > + hook script is safe to run concurrently with other hooks for the same > + event. If any hook for an event does not have this set to `true`, > + all hooks for that event run sequentially regardless of `hook.jobs`. > + Only configured (named) hooks need to declare this. Traditional hooks > + found in the hooks directory do not need to, and run in parallel when > + the effective job count is greater than 1. See linkgit:git-hook[1]. Thanks for adding this setting, this addresses my most important concern with this patch series. > hook.jobs:: > Specifies how many hooks can be run simultaneously during parallelized > hook execution. If unspecified, defaults to 1 (serial execution). > ++ > +This setting has no effect unless all configured hooks for the event have > +`hook.<friendly-name>.parallel` set to `true`. I guess that's a fair constraint for now. We can still iterate going forward and have those marked as parallelizable run in parallel, while running the others sequentially. > diff --git a/hook.c b/hook.c > index c4872d8707..a60dac5a60 100644 > --- a/hook.c > +++ b/hook.c > @@ -120,6 +120,7 @@ struct hook_config_cache_entry { > char *command; > enum config_scope scope; > unsigned int disabled:1; > + unsigned int parallel:1; > }; I'd recommend to use a proper bool here. > @@ -223,6 +226,10 @@ static int hook_config_lookup_all(const char *key, const char *value, > default: > break; /* ignore unrecognised values */ > } > + } else if (!strcmp(subkey, "parallel")) { > + int v = git_parse_maybe_bool(value); > + if (v >= 0) > + strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); Do we want to warn on unparseable values? > @@ -541,21 +553,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) > strvec_clear(&options->args); > } > > +/* Determine how many jobs to use for hook execution. */ > +static unsigned int get_hook_jobs(struct repository *r, > + struct run_hooks_opt *options, > + struct string_list *hook_list) > +{ > + /* > + * Hooks needing separate output streams must run sequentially. > + * Next commit will allow parallelizing these as well. > + */ > + if (!options->stdout_to_stderr) > + return 1; > + > + /* > + * An explicit job count overrides everything else: this covers both > + * FORCE_SERIAL callers (for hooks that must never run in parallel) > + * and the -j flag from the CLI. The CLI override is intentional: users > + * may want to serialize hooks declared parallel or to parallelize more > + * aggressively than the default. > + */ > + if (options->jobs) > + return options->jobs; Hm, okay. I feel like this behaviour is somewhat surprising given that it now operates different compared to the config value. But arguably, there is no reason why a caller of git-hook(1) should explicitly set this flag as it should be under control of the user, unless they have a very good reason to override the number of jobs. So maybe this is fine, but I think it needs to be called out explicitly in our docs. > + /* > + * Use hook.jobs from the already-parsed config cache (in-repo), or > + * fallback to a direct config lookup (out-of-repo). > + * Default to 1 (serial execution) on failure. > + */ > + if (r && r->gitdir && r->hook_config_cache) > + /* Use the already-parsed cache (in-repo) */ > + options->jobs = r->hook_jobs ? r->hook_jobs : 1; > + else > + /* No cache present (out-of-repo call), use direct cfg lookup */ > + if (repo_config_get_uint(r, "hook.jobs", &options->jobs)) > + options->jobs = 1; We first have a check for `r != NULL`, but if I'm not mistaken `repo_config_get_uint()` will cause us to unconditionally deref `r`. > diff --git a/hook.h b/hook.h > index 7c8c3d471e..494f74345f 100644 > --- a/hook.h > +++ b/hook.h > @@ -35,6 +35,13 @@ struct hook { > } configured; > } u; > > + /** > + * Whether this hook may run in parallel with other hooks for the same > + * event. Only useful for configured (named) hooks. Traditional hooks > + * always default to 0 (serial). Set via `hook.<name>.parallel = true`. > + */ > + unsigned int parallel:1; > + > /** > * Opaque data pointer used to keep internal state across callback calls. > * This should likely also be a boolean. Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v4 4/9] hook: allow pre-push parallel execution 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu ` (2 preceding siblings ...) 2026-03-20 13:53 ` [PATCH v4 3/9] hook: allow parallel hook execution Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 5/9] hook: mark non-parallelizable hooks Adrian Ratiu ` (5 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu pre-push is the only hook that keeps stdout and stderr separate (for backwards compatibility with git-lfs and potentially other users). This prevents parallelizing it because run-command needs stdout_to_stderr=1 to buffer and de-interleave parallel outputs. Since we now default to jobs=1, backwards compatibility is maintained without needing any extension or extra config: when no parallelism is requested, pre-push behaves exactly as before. When the user explicitly opts into parallelism via hook.jobs > 1, hook.<event>.jobs > 1, or -jN, they accept the changed output behavior. Document this and let get_hook_jobs() set stdout_to_stderr=1 automatically when jobs > 1, removing the need for any extension infrastructure. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++++ hook.c | 24 ++++++++++++++++-------- hook.h | 6 ++++-- t/t1800-hook.sh | 32 ++++++++++++++++++++++++++++++++ transport.c | 6 ++++-- 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 21800db648..94c7a9808e 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -39,3 +39,7 @@ hook.jobs:: + This setting has no effect unless all configured hooks for the event have `hook.<friendly-name>.parallel` set to `true`. ++ +For `pre-push` hooks, which normally keep stdout and stderr separate, +setting this to a value greater than 1 (or passing `-j`) will merge stdout +into stderr to allow correct de-interleaving of parallel output. diff --git a/hook.c b/hook.c index a60dac5a60..b7cf844c19 100644 --- a/hook.c +++ b/hook.c @@ -553,18 +553,24 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* + * When running in parallel, stdout must be merged into stderr so + * run-command can buffer and de-interleave outputs correctly. This + * applies even to hooks like pre-push that normally keep stdout and + * stderr separate: the user has opted into parallelism, so the output + * stream behavior changes accordingly. + */ +static void merge_output_if_parallel(struct run_hooks_opt *options) +{ + if (options->jobs > 1) + options->stdout_to_stderr = 1; +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, struct string_list *hook_list) { - /* - * Hooks needing separate output streams must run sequentially. - * Next commit will allow parallelizing these as well. - */ - if (!options->stdout_to_stderr) - return 1; - /* * An explicit job count overrides everything else: this covers both * FORCE_SERIAL callers (for hooks that must never run in parallel) @@ -573,7 +579,7 @@ static unsigned int get_hook_jobs(struct repository *r, * aggressively than the default. */ if (options->jobs) - return options->jobs; + goto cleanup; /* * Use hook.jobs from the already-parsed config cache (in-repo), or @@ -601,6 +607,8 @@ static unsigned int get_hook_jobs(struct repository *r, } } +cleanup: + merge_output_if_parallel(options); return options->jobs; } diff --git a/hook.h b/hook.h index 494f74345f..fefcd004c0 100644 --- a/hook.h +++ b/hook.h @@ -106,8 +106,10 @@ struct run_hooks_opt { * Send the hook's stdout to stderr. * * This is the default behavior for all hooks except pre-push, - * which has separate stdout and stderr streams for backwards - * compatibility reasons. + * which keeps stdout and stderr separate for backwards compatibility. + * When parallel execution is requested (jobs > 1), get_hook_jobs() + * overrides this to 1 for all hooks so run-command can de-interleave + * their outputs correctly. */ unsigned int stdout_to_stderr:1; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index b1049a296d..a016ab0798 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -768,4 +768,36 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall test_cmp expect hook.order ' +test_expect_success 'client hooks: pre-push parallel execution merges stdout to stderr' ' + test_when_finished "rm -rf remote-par stdout.actual stderr.actual" && + git init --bare remote-par && + git remote add origin-par remote-par && + test_commit par-commit && + mkdir -p .git/hooks && + setup_hooks pre-push && + test_config hook.jobs 2 && + git push origin-par HEAD:main >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push +' + +test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' ' + test_when_finished "rm -rf repo-parallel remote-parallel" && + git init --bare remote-parallel && + git init repo-parallel && + git -C repo-parallel remote add origin ../remote-parallel && + test_commit -C repo-parallel A && + + write_sentinel_hook repo-parallel/.git/hooks/pre-push && + git -C repo-parallel config hook.hook-2.event pre-push && + git -C repo-parallel config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + git -C repo-parallel config hook.hook-2.parallel true && + + git -C repo-parallel config hook.jobs 2 && + + git -C repo-parallel push origin HEAD >out 2>err && + echo parallel >expect && + test_cmp expect repo-parallel/hook.order +' + test_done diff --git a/transport.c b/transport.c index 56a4015389..0c0be55b2f 100644 --- a/transport.c +++ b/transport.c @@ -1391,8 +1391,10 @@ static int run_pre_push_hook(struct transport *transport, opt.feed_pipe_cb_data_free = pre_push_hook_data_free; /* - * pre-push hooks expect stdout & stderr to be separate, so don't merge - * them to keep backwards compatibility with existing hooks. + * pre-push hooks keep stdout and stderr separate by default for + * backwards compatibility. When the user opts into parallel execution + * via hook.jobs > 1 or -j, get_hook_jobs() will set stdout_to_stderr=1 + * automatically so run-command can de-interleave the outputs. */ opt.stdout_to_stderr = 0; -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v4 5/9] hook: mark non-parallelizable hooks 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu ` (3 preceding siblings ...) 2026-03-20 13:53 ` [PATCH v4 4/9] hook: allow pre-push parallel execution Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 6/9] hook: add -j/--jobs option to git hook run Adrian Ratiu ` (4 subsequent siblings) 9 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> Several hooks are known to be inherently non-parallelizable, so initialize them with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides any hook.jobs or runtime -j flags. These hooks are: applypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout, and push-to-checkout. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 14 ++++++++++++++ builtin/am.c | 8 +++++--- builtin/checkout.c | 19 +++++++++++++------ builtin/clone.c | 6 ++++-- builtin/receive-pack.c | 3 ++- builtin/worktree.c | 2 +- commit.c | 2 +- t/t1800-hook.sh | 16 ++++++++++++++++ 8 files changed, 56 insertions(+), 14 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 94c7a9808e..6f60775c28 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -36,6 +36,20 @@ hook.<friendly-name>.parallel:: hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Some hooks always run sequentially regardless of this setting because + they operate on shared data and cannot safely be parallelized: ++ +-- +`applypatch-msg`;; +`prepare-commit-msg`;; +`commit-msg`;; + Receive a commit message file and may rewrite it in place. +`pre-commit`;; +`post-checkout`;; +`push-to-checkout`;; +`post-commit`;; + Access the working tree, index, or repository state. +-- + This setting has no effect unless all configured hooks for the event have `hook.<friendly-name>.parallel` set to `true`. diff --git a/builtin/am.c b/builtin/am.c index 9d0b51c651..271b23160b 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state) assert(state->msg); - if (!state->no_verify) - ret = run_hooks_l(the_repository, "applypatch-msg", - am_path(state, "final-commit"), NULL); + if (!state->no_verify) { + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + strvec_push(&opt.args, am_path(state, "final-commit")); + ret = run_hooks_opt(the_repository, "applypatch-msg", &opt); + } if (!ret) { FREE_AND_NULL(state->msg); diff --git a/builtin/checkout.c b/builtin/checkout.c index 1d1667fa4c..ddbe8474d2 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -31,6 +31,7 @@ #include "resolve-undo.h" #include "revision.h" #include "setup.h" +#include "strvec.h" #include "submodule.h" #include "symlinks.h" #include "trace2.h" @@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info) static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit, int changed) { - return run_hooks_l(the_repository, "post-checkout", - oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), - oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), - changed ? "1" : "0", NULL); - /* "new_commit" can be NULL when checking out from the index before - a commit exists. */ + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + /* + * "new_commit" can be NULL when checking out from the index before + * a commit exists. + */ + strvec_pushl(&opt.args, + oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), + oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), + changed ? "1" : "0", + NULL); + + return run_hooks_opt(the_repository, "post-checkout", &opt); } static int update_some(const struct object_id *oid, struct strbuf *base, diff --git a/builtin/clone.c b/builtin/clone.c index fba3c9c508..d23b0cafcf 100644 --- a/builtin/clone.c +++ b/builtin/clone.c @@ -647,6 +647,7 @@ static int checkout(int submodule_progress, struct tree *tree; struct tree_desc t; int err = 0; + struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; if (option_no_checkout) return 0; @@ -697,8 +698,9 @@ static int checkout(int submodule_progress, if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); - err |= run_hooks_l(the_repository, "post-checkout", oid_to_hex(null_oid(the_hash_algo)), - oid_to_hex(&oid), "1", NULL); + strvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)), + oid_to_hex(&oid), "1", NULL); + err |= run_hooks_opt(the_repository, "post-checkout", &hook_opt); if (!err && (option_recurse_submodules.nr > 0)) { struct child_process cmd = CHILD_PROCESS_INIT; diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index 991d6ca7d5..3c5ba66dd6 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -1463,7 +1463,8 @@ static const char *push_to_checkout(unsigned char *hash, struct strvec *env, const char *work_tree) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + opt.invoked_hook = invoked_hook; strvec_pushf(env, "GIT_WORK_TREE=%s", absolute_path(work_tree)); diff --git a/builtin/worktree.c b/builtin/worktree.c index 4035b1cb06..b6831c19b5 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname, * is_junk is cleared, but do return appropriate code when hook fails. */ if (!ret && opts->checkout && !opts->orphan) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL); strvec_pushl(&opt.args, diff --git a/commit.c b/commit.c index 0ffdd6679e..a2c4ffaac5 100644 --- a/commit.c +++ b/commit.c @@ -1979,7 +1979,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len) int run_commit_hook(int editor_is_used, const char *index_file, int *invoked_hook, const char *name, ...) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; va_list args; const char *arg; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index a016ab0798..ad03b3fb78 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -800,4 +800,20 @@ test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' test_cmp expect repo-parallel/hook.order ' +test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event pre-commit && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event pre-commit && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + test_config hook.jobs 2 && + git commit --allow-empty -m "test: verify force-serial on pre-commit" && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v4 6/9] hook: add -j/--jobs option to git hook run 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu ` (4 preceding siblings ...) 2026-03-20 13:53 ` [PATCH v4 5/9] hook: mark non-parallelizable hooks Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-24 9:07 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 7/9] hook: add per-event jobs config Adrian Ratiu ` (3 subsequent siblings) 9 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> Expose the parallel job count as a command-line flag so callers can request parallelism without relying only on the hook.jobs config. Add tests covering serial/parallel execution and TTY behaviour under -j1 vs -jN. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/git-hook.adoc | 18 ++++- builtin/hook.c | 5 +- hook.c | 17 +++++ t/t1800-hook.sh | 135 ++++++++++++++++++++++++++++++++++-- 4 files changed, 165 insertions(+), 10 deletions(-) diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc index e7d399ae57..b4c95a31a8 100644 --- a/Documentation/git-hook.adoc +++ b/Documentation/git-hook.adoc @@ -8,7 +8,8 @@ git-hook - Run git hooks SYNOPSIS -------- [verse] -'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>] +'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>] + <hook-name> [-- <hook-args>] 'git hook' list [-z] [--show-scope] <hook-name> DESCRIPTION @@ -140,6 +141,18 @@ OPTIONS mirroring the output style of `git config --show-scope`. Traditional hooks from the hookdir are unaffected. +-j:: +--jobs:: + Only valid for `run`. ++ +Specify how many hooks to run simultaneously. If this flag is not specified, +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If +neither is specified, defaults to 1 (serial execution). Some hooks always run +sequentially regardless of this flag or the `hook.jobs` config, because git +knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`, +`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and +`push-to-checkout`. + WRAPPERS -------- @@ -162,7 +175,8 @@ running: git hook run mywrapper-start-tests \ # providing something to stdin --stdin some-tempfile-123 \ - # execute hooks in serial + # execute multiple hooks in parallel + --jobs 3 \ # plus some arguments of your own... -- \ --testname bar \ diff --git a/builtin/hook.c b/builtin/hook.c index f671e7f91a..4baf60bf36 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -7,7 +7,8 @@ #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ - 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" \ + "<hook-name> [-- <hook-args>]") #define BUILTIN_HOOK_LIST_USAGE \ N_("git hook list [-z] [--show-scope] <hook-name>") @@ -109,6 +110,8 @@ static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested <hook-name>")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), N_("file to read into hooks' stdin")), + OPT_UNSIGNED('j', "jobs", &opt.jobs, + N_("run up to <n> hooks simultaneously")), OPT_END(), }; int ret; diff --git a/hook.c b/hook.c index b7cf844c19..0b581a6c43 100644 --- a/hook.c +++ b/hook.c @@ -566,6 +566,22 @@ static void merge_output_if_parallel(struct run_hooks_opt *options) options->stdout_to_stderr = 1; } +static void warn_non_parallel_hooks_override(unsigned int jobs, + struct string_list *hook_list) +{ + /* Don't warn for hooks running sequentially. */ + if (jobs == 1) + return; + + for (size_t i = 0; i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; + if (h->kind == HOOK_CONFIGURED && !h->parallel) + warning(_("hook '%s' is not marked as parallel=true, " + "running in parallel anyway due to -j%u"), + h->u.configured.friendly_name, jobs); + } +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, @@ -609,6 +625,7 @@ static unsigned int get_hook_jobs(struct repository *r, cleanup: merge_output_if_parallel(options); + warn_non_parallel_hooks_override(options->jobs, hook_list); return options->jobs; } diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index ad03b3fb78..a0a7301701 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -238,10 +238,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' ' ' test_hook_tty () { - cat >expect <<-\EOF - STDOUT TTY - STDERR TTY - EOF + expect_tty=$1 + shift + + if test "$expect_tty" != "no_tty"; then + cat >expect <<-\EOF + STDOUT TTY + STDERR TTY + EOF + else + cat >expect <<-\EOF + STDOUT NO TTY + STDERR NO TTY + EOF + fi test_when_finished "rm -rf repo" && git init repo && @@ -259,12 +269,21 @@ test_hook_tty () { test_cmp expect repo/actual } -test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' ' - test_hook_tty hook run pre-commit +test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' ' + # hooks running sequentially (-j1) are always connected to the tty for + # optimum real-time performance. + test_hook_tty tty hook run -j1 pre-commit +' + +test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' ' + # Hooks are not connected to the tty when run in parallel, instead they + # output to a pipe through which run-command collects and de-interlaces + # their outputs, which then gets passed either to the tty or a sideband. + test_hook_tty no_tty hook run -j2 pre-commit ' test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' ' - test_hook_tty commit -m"B.new" + test_hook_tty tty commit -m"B.new" ' test_expect_success 'git hook list orders by config order' ' @@ -677,6 +696,108 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'parallel hook output is not interleaved' ' + test_when_finished "rm -rf .git/hooks" && + + write_script .git/hooks/test-hook <<-EOF && + echo "Hook 1 Start" + sleep 1 + echo "Hook 1 End" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "echo \"Hook 2 Start\"; sleep 2; echo \"Hook 2 End\"" && + test_config hook.hook-2.parallel true && + test_config hook.hook-3.event test-hook && + test_config hook.hook-3.command \ + "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" && + test_config hook.hook-3.parallel true && + + git hook run -j3 test-hook >out 2>err.parallel && + + # Verify Hook 1 output is grouped + sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out && + test_line_count = 2 hook1_out && + + # Verify Hook 2 output is grouped + sed -n "/Hook 2 Start/,/Hook 2 End/p" err.parallel >hook2_out && + test_line_count = 2 hook2_out && + + # Verify Hook 3 output is grouped + sed -n "/Hook 3 Start/,/Hook 3 End/p" err.parallel >hook3_out && + test_line_count = 2 hook3_out +' + +test_expect_success 'git hook run -j1 runs hooks in series' ' + test_when_finished "rm -rf .git/hooks" && + + test_config hook.series-1.event "test-hook" && + test_config hook.series-1.command "echo 1" --add && + test_config hook.series-2.event "test-hook" && + test_config hook.series-2.command "echo 2" --add && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + echo 3 + EOF + + cat >expected <<-\EOF && + 1 + 2 + 3 + EOF + + git hook run -j1 test-hook 2>actual && + test_cmp expected actual +' + +test_expect_success 'git hook run -j2 runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + git hook run -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'git hook run -j2 overrides parallel=false' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # -j2 overrides parallel=false; hooks run in parallel with a warning. + git hook run -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "true" && + # neither hook has parallel=true + + git hook run -j2 test-hook >out 2>err && + grep "hook .hook-1. is not marked as parallel=true" err && + grep "hook .hook-2. is not marked as parallel=true" err +' + test_expect_success 'hook.jobs=1 config runs hooks in series' ' test_when_finished "rm -f sentinel.started sentinel.done hook.order" && -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v4 6/9] hook: add -j/--jobs option to git hook run 2026-03-20 13:53 ` [PATCH v4 6/9] hook: add -j/--jobs option to git hook run Adrian Ratiu @ 2026-03-24 9:07 ` Patrick Steinhardt 0 siblings, 0 replies; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-24 9:07 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Ævar Arnfjörð Bjarmason On Fri, Mar 20, 2026 at 03:53:08PM +0200, Adrian Ratiu wrote: > diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc > index e7d399ae57..b4c95a31a8 100644 > --- a/Documentation/git-hook.adoc > +++ b/Documentation/git-hook.adoc > @@ -140,6 +141,18 @@ OPTIONS > mirroring the output style of `git config --show-scope`. Traditional > hooks from the hookdir are unaffected. > > +-j:: > +--jobs:: > + Only valid for `run`. > ++ > +Specify how many hooks to run simultaneously. If this flag is not specified, > +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If > +neither is specified, defaults to 1 (serial execution). Some hooks always run > +sequentially regardless of this flag or the `hook.jobs` config, because git > +knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`, > +`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and > +`push-to-checkout`. I guess this is the place where we need to point out that this will also override the `hook.<friendly-name>.parallel` setting. Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v4 7/9] hook: add per-event jobs config 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu ` (5 preceding siblings ...) 2026-03-20 13:53 ` [PATCH v4 6/9] hook: add -j/--jobs option to git hook run Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-24 9:08 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 8/9] hook: warn when hook.<friendly-name>.jobs is set Adrian Ratiu ` (2 subsequent siblings) 9 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Add a hook.<event>.jobs count config that allows users to override the global hook.jobs setting for specific hook events. This allows finer-grained control over parallelism on a per-event basis. For example, to run `post-receive` hooks with up to 4 parallel jobs while keeping other events at their global default: [hook] post-receive.jobs = 4 Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 19 +++++++++++ hook.c | 38 +++++++++++++++++++--- repository.c | 1 + repository.h | 3 ++ t/t1800-hook.sh | 59 ++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 4 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 6f60775c28..d4fa29d936 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -33,9 +33,28 @@ hook.<friendly-name>.parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. +hook.<event>.jobs:: + Specifies how many hooks can be run simultaneously for the `<event>` + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` + for this specific event. The same parallelism restrictions apply: this + setting has no effect unless all configured hooks for the event have + `hook.<friendly-name>.parallel` set to `true`. Must be a positive int, + zero is rejected with a warning. See linkgit:git-hook[1]. ++ +Note on naming: although this key resembles `hook.<friendly-name>.*` +(a per-hook setting), `<event>` must be the event name, not a hook +friendly name. The key component is stored literally and looked up by +event name at runtime with no translation between the two namespaces. +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the +lookup at runtime uses the event name (e.g. `"post-receive"`), so +`hook.my-hook.jobs` is silently ignored even when `my-hook` is +registered for that event. Use `hook.post-receive.jobs` or any other +valid event name when setting `hook.<event>.jobs`. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Can be overridden on a per-event basis with `hook.<event>.jobs`. Some hooks always run sequentially regardless of this setting because they operate on shared data and cannot safely be parallelized: + diff --git a/hook.c b/hook.c index 0b581a6c43..e40c1f3a85 100644 --- a/hook.c +++ b/hook.c @@ -129,6 +129,7 @@ struct hook_config_cache_entry { * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. * parallel_hooks: friendly-name to parallel flag. + * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { @@ -136,6 +137,7 @@ struct hook_all_config_cb { struct strmap event_hooks; struct string_list disabled_hooks; struct strmap parallel_hooks; + struct strmap event_jobs; unsigned int jobs; }; @@ -230,6 +232,16 @@ static int hook_config_lookup_all(const char *key, const char *value, int v = git_parse_maybe_bool(value); if (v >= 0) strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); + } else if (!strcmp(subkey, "jobs")) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.%s.jobs must be a positive integer, ignoring: '%s'"), + hook_name, value); + else if (!v) + warning(_("hook.%s.jobs must be positive, ignoring: 0"), hook_name); + else + strmap_put(&data->event_jobs, hook_name, + (void *)(uintptr_t)v); } free(hook_name); @@ -275,6 +287,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); strmap_init(&cb_data.parallel_hooks); + strmap_init(&cb_data.event_jobs); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -322,8 +335,10 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_put(cache, e->key, hooks); } - if (r) + if (r) { r->hook_jobs = cb_data.jobs; + r->event_jobs = cb_data.event_jobs; + } strmap_clear(&cb_data.commands, 1); strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ @@ -585,6 +600,7 @@ static void warn_non_parallel_hooks_override(unsigned int jobs, /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, + const char *hook_name, struct string_list *hook_list) { /* @@ -602,18 +618,32 @@ static unsigned int get_hook_jobs(struct repository *r, * fallback to a direct config lookup (out-of-repo). * Default to 1 (serial execution) on failure. */ - if (r && r->gitdir && r->hook_config_cache) + if (r && r->gitdir && r->hook_config_cache) { /* Use the already-parsed cache (in-repo) */ + void *event_jobs = strmap_get(&r->event_jobs, hook_name); options->jobs = r->hook_jobs ? r->hook_jobs : 1; - else + if (event_jobs) + options->jobs = (unsigned int)(uintptr_t)event_jobs; + } else { /* No cache present (out-of-repo call), use direct cfg lookup */ + unsigned int event_jobs; + char *key; + if (repo_config_get_uint(r, "hook.jobs", &options->jobs)) options->jobs = 1; + key = xstrfmt("hook.%s.jobs", hook_name); + if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) + options->jobs = event_jobs; + free(key); + } + /* * Cap to serial any configured hook not marked as parallel = true. * This enforces the parallel = false default, even for "traditional" * hooks from the hookdir which cannot be marked parallel = true. + * The same restriction applies whether jobs came from hook.jobs or + * hook.<event>.jobs. */ for (size_t i = 0; i < hook_list->nr; i++) { struct hook *h = hook_list->items[i].util; @@ -640,7 +670,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name, .options = options, }; int ret = 0; - unsigned int jobs = get_hook_jobs(r, options, hook_list); + unsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, diff --git a/repository.c b/repository.c index fb4356ca55..ff3c357dfc 100644 --- a/repository.c +++ b/repository.c @@ -425,6 +425,7 @@ void repo_clear(struct repository *repo) hook_cache_clear(repo->hook_config_cache); FREE_AND_NULL(repo->hook_config_cache); } + strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */ if (repo->promisor_remote_config) { promisor_remote_clear(repo->promisor_remote_config); diff --git a/repository.h b/repository.h index 58e46853d0..6b67ec02e2 100644 --- a/repository.h +++ b/repository.h @@ -175,6 +175,9 @@ struct repository { /* Cached value of hook.jobs config (0 if unset, defaults to serial). */ unsigned int hook_jobs; + /* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */ + struct strmap event_jobs; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index a0a7301701..cf4dc1ce6f 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -937,4 +937,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' test_cmp expect hook.order ' +test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=1 (serial), but per-event override allows parallel. + test_config hook.jobs 1 && + test_config hook.test-hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=4 allows parallel, but per-event override forces serial. + test_config hook.jobs 4 && + test_config hook.test-hook.jobs 1 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # Per-event jobs=2 but no hook has parallel=true: must still run serially. + test_config hook.test-hook.jobs 2 && + + git hook run test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v4 7/9] hook: add per-event jobs config 2026-03-20 13:53 ` [PATCH v4 7/9] hook: add per-event jobs config Adrian Ratiu @ 2026-03-24 9:08 ` Patrick Steinhardt 0 siblings, 0 replies; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-24 9:08 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Fri, Mar 20, 2026 at 03:53:09PM +0200, Adrian Ratiu wrote: > diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc > index 6f60775c28..d4fa29d936 100644 > --- a/Documentation/config/hook.adoc > +++ b/Documentation/config/hook.adoc > @@ -33,9 +33,28 @@ hook.<friendly-name>.parallel:: > found in the hooks directory do not need to, and run in parallel when > the effective job count is greater than 1. See linkgit:git-hook[1]. > > +hook.<event>.jobs:: > + Specifies how many hooks can be run simultaneously for the `<event>` > + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` > + for this specific event. The same parallelism restrictions apply: this > + setting has no effect unless all configured hooks for the event have > + `hook.<friendly-name>.parallel` set to `true`. Must be a positive int, > + zero is rejected with a warning. See linkgit:git-hook[1]. > ++ > +Note on naming: although this key resembles `hook.<friendly-name>.*` > +(a per-hook setting), `<event>` must be the event name, not a hook > +friendly name. The key component is stored literally and looked up by > +event name at runtime with no translation between the two namespaces. > +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the > +lookup at runtime uses the event name (e.g. `"post-receive"`), so > +`hook.my-hook.jobs` is silently ignored even when `my-hook` is > +registered for that event. Use `hook.post-receive.jobs` or any other > +valid event name when setting `hook.<event>.jobs`. This makes sense of course, but it feels like something that might be a bit confusing for end users. It would be great to have an explicit check for whether or not "hook.<friendly-name>.jobs" exists so that we can print a warning if so. Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v4 8/9] hook: warn when hook.<friendly-name>.jobs is set 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu ` (6 preceding siblings ...) 2026-03-20 13:53 ` [PATCH v4 7/9] hook: add per-event jobs config Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-24 9:08 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 9/9] hook: add hook.<event>.enabled switch Adrian Ratiu 2026-03-20 17:24 ` [PATCH v4 0/9] Run hooks in parallel Junio C Hamano 9 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Issue a warning when the user confuses the hook process and event namespaces by setting hook.<friendly-name>.jobs. Detect this by checking whether the name carrying .jobs also has .command, .event, or .parallel configured. Extract is_friendly_name() as a helper for this check, to be reused by future per-event config handling. Suggested-by: Junio C Hamano <gitster@pobox.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- hook.c | 40 ++++++++++++++++++++++++++++++++++++++++ t/t1800-hook.sh | 30 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/hook.c b/hook.c index e40c1f3a85..3d73447086 100644 --- a/hook.c +++ b/hook.c @@ -276,6 +276,44 @@ void hook_cache_clear(struct strmap *cache) strmap_clear(cache, 0); } +/* + * Return true if `name` is a hook friendly-name, i.e. it has at least one of + * .command, .event, or .parallel configured. These are the reliable clues + * that distinguish a friendly-name from an event name. Note: .enabled is + * deliberately excluded because it can appear under both namespaces. + */ +static int is_friendly_name(struct hook_all_config_cb *cb, const char *name) +{ + struct hashmap_iter iter; + struct strmap_entry *e; + + if (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name)) + return 1; + + strmap_for_each_entry(&cb->event_hooks, &iter, e) { + if (unsorted_string_list_lookup(e->value, name)) + return 1; + } + + return 0; +} + +/* Warn if any name in event_jobs is also a hook friendly-name. */ +static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data) +{ + struct hashmap_iter iter; + struct strmap_entry *e; + + strmap_for_each_entry(&cb_data->event_jobs, &iter, e) { + if (is_friendly_name(cb_data, e->key)) + warning(_("hook.%s.jobs is set but '%s' looks like a " + "hook friendly-name, not an event name; " + "hook.<event>.jobs uses the event name " + "(e.g. hook.post-receive.jobs), so this " + "setting will be ignored"), e->key, e->key); + } +} + /* Populate `cache` with the complete hook configuration */ static void build_hook_config_map(struct repository *r, struct strmap *cache) { @@ -292,6 +330,8 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); + warn_jobs_on_friendly_names(&cb_data); + /* Construct the cache from parsed configs. */ strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { struct string_list *hook_names = e->value; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index cf4dc1ce6f..e8005199c7 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -996,4 +996,34 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' test_cmp expect hook.order ' +test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' ' + test_config hook.my-hook.command "true" && + test_config hook.my-hook.jobs 2 && + git hook run --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook.<friendly-name>.jobs warns when name has .event' ' + test_config hook.my-hook.event test-hook && + test_config hook.my-hook.command "true" && + test_config hook.my-hook.jobs 2 && + git hook run --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook.<friendly-name>.jobs warns when name has .parallel' ' + test_config hook.my-hook.event test-hook && + test_config hook.my-hook.command "true" && + test_config hook.my-hook.parallel true && + test_config hook.my-hook.jobs 2 && + git hook run --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook.<event>.jobs does not warn for a real event name' ' + test_config hook.test-hook.jobs 2 && + git hook run --ignore-missing test-hook >out 2>err && + test_grep ! "friendly-name" err +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v4 8/9] hook: warn when hook.<friendly-name>.jobs is set 2026-03-20 13:53 ` [PATCH v4 8/9] hook: warn when hook.<friendly-name>.jobs is set Adrian Ratiu @ 2026-03-24 9:08 ` Patrick Steinhardt 0 siblings, 0 replies; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-24 9:08 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Fri, Mar 20, 2026 at 03:53:10PM +0200, Adrian Ratiu wrote: > Issue a warning when the user confuses the hook process and event > namespaces by setting hook.<friendly-name>.jobs. > > Detect this by checking whether the name carrying .jobs also has > .command, .event, or .parallel configured. Extract is_friendly_name() > as a helper for this check, to be reused by future per-event config > handling. > > Suggested-by: Junio C Hamano <gitster@pobox.com> > Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> Oh, you already do so. Great :) Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v4 9/9] hook: add hook.<event>.enabled switch 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu ` (7 preceding siblings ...) 2026-03-20 13:53 ` [PATCH v4 8/9] hook: warn when hook.<friendly-name>.jobs is set Adrian Ratiu @ 2026-03-20 13:53 ` Adrian Ratiu 2026-03-24 9:08 ` Patrick Steinhardt 2026-03-20 17:24 ` [PATCH v4 0/9] Run hooks in parallel Junio C Hamano 9 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-20 13:53 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Add a hook.<event>.enabled config key that disables all hooks for a given event, when set to false, acting as a high-level switch above the existing per-hook hook.<friendly-name>.enabled. Event-disabled hooks are shown in "git hook list" with an "event-disabled" tab-separated prefix before the name: $ git hook list test-hook event-disabled hook-1 event-disabled hook-2 With --show-scope: $ git hook list --show-scope test-hook local event-disabled hook-1 When a hook is both per-hook disabled and event-disabled, only "event-disabled" is shown: the event-level switch is the more relevant piece of information, and the per-hook "disabled" status will surface once the event is re-enabled. Reuses is_friendly_name() from the previous commit to distinguish event names from friendly-names when processing .enabled settings. Suggested-by: Junio C Hamano <gitster@pobox.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 12 +++++++ builtin/hook.c | 20 +++++++---- hook.c | 30 ++++++++++++++-- hook.h | 1 + repository.c | 1 + repository.h | 4 +++ t/t1800-hook.sh | 62 ++++++++++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 9 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index d4fa29d936..0a9f04b154 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -33,6 +33,18 @@ hook.<friendly-name>.parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. +hook.<event>.enabled:: + Switch to enable or disable all hooks for the `<event>` hook event. + When set to `false`, no hooks fire for that event, regardless of any + per-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`. + See linkgit:git-hook[1]. ++ +Note on naming: `<event>` must be the event name (e.g. `pre-commit`), +not a hook friendly-name. A name that also carries `.command`, `.event`, +or `.parallel` is treated as a friendly-name and its `.enabled` value +applies only to that individual hook. See `hook.<friendly-name>.enabled` +above. + hook.<event>.jobs:: Specifies how many hooks can be run simultaneously for the `<event>` hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` diff --git a/builtin/hook.c b/builtin/hook.c index 4baf60bf36..0def50bcac 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -77,14 +77,22 @@ static int list(int argc, const char **argv, const char *prefix, const char *name = h->u.configured.friendly_name; const char *scope = show_scope ? config_scope_name(h->u.configured.scope) : NULL; + /* + * Show the most relevant disable reason. Event-level + * takes precedence: if the whole event is off, that + * is what the user needs to know. The per-hook + * "disabled" surfaces once the event is re-enabled. + */ + const char *disability = + h->u.configured.event_disabled ? "event-disabled\t" : + h->u.configured.disabled ? "disabled\t" : + ""; if (scope) - printf("%s\t%s%s%c", scope, - h->u.configured.disabled ? "disabled\t" : "", - name, line_terminator); + printf("%s\t%s%s%c", scope, disability, name, + line_terminator); else - printf("%s%s%c", - h->u.configured.disabled ? "disabled\t" : "", - name, line_terminator); + printf("%s%s%c", disability, name, + line_terminator); break; } default: diff --git a/hook.c b/hook.c index 3d73447086..a3abe89777 100644 --- a/hook.c +++ b/hook.c @@ -127,7 +127,9 @@ struct hook_config_cache_entry { * Callback struct to collect all hook.* keys in a single config pass. * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. - * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * disabled_hooks: set of all names with hook.<name>.enabled = false; after + * parsing, names that are not friendly-names become event-level + * disables stored in cache->event_disabled. This collects all. * parallel_hooks: friendly-name to parallel flag. * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). @@ -332,6 +334,22 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) warn_jobs_on_friendly_names(&cb_data); + /* + * Populate event_disabled: names in disabled_hooks that are not + * friendly-names are event-level switches (hook.<event>.enabled = false). + * Names that are friendly-names are already handled per-hook via the + * hook_config_cache_entry.disabled flag below. + */ + if (r) { + string_list_clear(&r->event_disabled, 0); + string_list_init_dup(&r->event_disabled); + for (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) { + const char *n = cb_data.disabled_hooks.items[i].string; + if (!is_friendly_name(&cb_data, n)) + string_list_append(&r->event_disabled, n); + } + } + /* Construct the cache from parsed configs. */ strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { struct string_list *hook_names = e->value; @@ -433,6 +451,8 @@ static void list_hooks_add_configured(struct repository *r, { struct strmap *cache = get_hook_config_cache(r); struct string_list *configured_hooks = strmap_get(cache, hookname); + int event_is_disabled = r ? !!unsorted_string_list_lookup(&r->event_disabled, + hookname) : 0; /* Iterate through configured hooks and initialize internal states */ for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) { @@ -458,6 +478,7 @@ static void list_hooks_add_configured(struct repository *r, entry->command ? xstrdup(entry->command) : NULL; hook->u.configured.scope = entry->scope; hook->u.configured.disabled = entry->disabled; + hook->u.configured.event_disabled = event_is_disabled; hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; @@ -470,6 +491,8 @@ static void list_hooks_add_configured(struct repository *r, if (!r || !r->gitdir) { hook_cache_clear(cache); free(cache); + if (r) + string_list_clear(&r->event_disabled, 0); } } @@ -501,7 +524,7 @@ int hook_exists(struct repository *r, const char *name) for (size_t i = 0; i < hooks->nr; i++) { struct hook *h = hooks->items[i].util; if (h->kind == HOOK_TRADITIONAL || - !h->u.configured.disabled) { + (!h->u.configured.disabled && !h->u.configured.event_disabled)) { exists = 1; break; } @@ -524,7 +547,8 @@ static int pick_next_hook(struct child_process *cp, if (hook_cb->hook_to_run_index >= hook_list->nr) return 0; h = hook_list->items[hook_cb->hook_to_run_index++].util; - } while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled); + } while (h->kind == HOOK_CONFIGURED && + (h->u.configured.disabled || h->u.configured.event_disabled)); cp->no_stdin = 1; strvec_pushv(&cp->env, hook_cb->options->env.v); diff --git a/hook.h b/hook.h index fefcd004c0..6bff3d15e4 100644 --- a/hook.h +++ b/hook.h @@ -32,6 +32,7 @@ struct hook { const char *command; enum config_scope scope; unsigned int disabled:1; + unsigned int event_disabled:1; } configured; } u; diff --git a/repository.c b/repository.c index ff3c357dfc..c4468e29c1 100644 --- a/repository.c +++ b/repository.c @@ -426,6 +426,7 @@ void repo_clear(struct repository *repo) FREE_AND_NULL(repo->hook_config_cache); } strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */ + string_list_clear(&repo->event_disabled, 0); if (repo->promisor_remote_config) { promisor_remote_clear(repo->promisor_remote_config); diff --git a/repository.h b/repository.h index 6b67ec02e2..745af10842 100644 --- a/repository.h +++ b/repository.h @@ -2,6 +2,7 @@ #define REPOSITORY_H #include "strmap.h" +#include "string-list.h" #include "repo-settings.h" #include "environment.h" @@ -178,6 +179,9 @@ struct repository { /* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */ struct strmap event_jobs; + /* Cached list of event names with hook.<event>.enabled = false. */ + struct string_list event_disabled; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index e8005199c7..44355b8bd5 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -1026,4 +1026,66 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' ' test_grep ! "friendly-name" err ' +test_expect_success 'hook.<event>.enabled=false skips all hooks for event' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook run test-hook >out 2>err && + test_must_be_empty out +' + +test_expect_success 'hook.<event>.enabled=true does not suppress hooks' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled true && + git hook run test-hook >out 2>err && + test_grep "ran" err +' + +test_expect_success 'hook.<event>.enabled=false does not affect other events' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.other-event.enabled false && + git hook run test-hook >out 2>err && + test_grep "ran" err +' + +test_expect_success 'hook.<friendly-name>.enabled=false still disables that hook' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo hook-1" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "echo hook-2" && + test_config hook.hook-1.enabled false && + git hook run test-hook >out 2>err && + test_grep ! "hook-1" err && + test_grep "hook-2" err +' + +test_expect_success 'git hook list shows event-disabled hooks as event-disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list test-hook >actual && + test_grep "^event-disabled hook-1$" actual && + test_grep "^event-disabled hook-2$" actual +' + +test_expect_success 'git hook list shows scope with event-disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list --show-scope test-hook >actual && + test_grep "^local event-disabled hook-1$" actual +' + +test_expect_success 'git hook list still shows hooks when event is disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list test-hook >actual && + test_grep "event-disabled" actual +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v4 9/9] hook: add hook.<event>.enabled switch 2026-03-20 13:53 ` [PATCH v4 9/9] hook: add hook.<event>.enabled switch Adrian Ratiu @ 2026-03-24 9:08 ` Patrick Steinhardt 2026-03-25 18:43 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-24 9:08 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Fri, Mar 20, 2026 at 03:53:11PM +0200, Adrian Ratiu wrote: > Add a hook.<event>.enabled config key that disables all hooks for > a given event, when set to false, acting as a high-level switch > above the existing per-hook hook.<friendly-name>.enabled. > > Event-disabled hooks are shown in "git hook list" with an > "event-disabled" tab-separated prefix before the name: > > $ git hook list test-hook > event-disabled hook-1 > event-disabled hook-2 > > With --show-scope: > > $ git hook list --show-scope test-hook > local event-disabled hook-1 > > When a hook is both per-hook disabled and event-disabled, only > "event-disabled" is shown: the event-level switch is the more > relevant piece of information, and the per-hook "disabled" status > will surface once the event is re-enabled. > > Reuses is_friendly_name() from the previous commit to distinguish > event names from friendly-names when processing .enabled settings. I think having this makes sense in general. But what about the case where I have configured a hook where the friendly name matches the event name? Is that now forbidden, or would such a hook silently also disable all the other hooks? > diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc > index d4fa29d936..0a9f04b154 100644 > --- a/Documentation/config/hook.adoc > +++ b/Documentation/config/hook.adoc > @@ -33,6 +33,18 @@ hook.<friendly-name>.parallel:: > found in the hooks directory do not need to, and run in parallel when > the effective job count is greater than 1. See linkgit:git-hook[1]. > > +hook.<event>.enabled:: > + Switch to enable or disable all hooks for the `<event>` hook event. > + When set to `false`, no hooks fire for that event, regardless of any > + per-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`. > + See linkgit:git-hook[1]. > ++ > +Note on naming: `<event>` must be the event name (e.g. `pre-commit`), > +not a hook friendly-name. A name that also carries `.command`, `.event`, > +or `.parallel` is treated as a friendly-name and its `.enabled` value > +applies only to that individual hook. See `hook.<friendly-name>.enabled` > +above. Ah, okay, so you've thought about that already. I wonder whether this behaviour is okay in general or whether it is going to be confusing. An alternative would be to disallow configuring hooks where the event name matches the friendly name, which would fix the ambiguity that we now have. Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v4 9/9] hook: add hook.<event>.enabled switch 2026-03-24 9:08 ` Patrick Steinhardt @ 2026-03-25 18:43 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-25 18:43 UTC (permalink / raw) To: Patrick Steinhardt Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Tue, 24 Mar 2026, Patrick Steinhardt <ps@pks.im> wrote: > On Fri, Mar 20, 2026 at 03:53:11PM +0200, Adrian Ratiu wrote: >> Add a hook.<event>.enabled config key that disables all hooks for >> a given event, when set to false, acting as a high-level switch >> above the existing per-hook hook.<friendly-name>.enabled. >> >> Event-disabled hooks are shown in "git hook list" with an >> "event-disabled" tab-separated prefix before the name: >> >> $ git hook list test-hook >> event-disabled hook-1 >> event-disabled hook-2 >> >> With --show-scope: >> >> $ git hook list --show-scope test-hook >> local event-disabled hook-1 >> >> When a hook is both per-hook disabled and event-disabled, only >> "event-disabled" is shown: the event-level switch is the more >> relevant piece of information, and the per-hook "disabled" status >> will surface once the event is re-enabled. >> >> Reuses is_friendly_name() from the previous commit to distinguish >> event names from friendly-names when processing .enabled settings. > > I think having this makes sense in general. But what about the case > where I have configured a hook where the friendly name matches the event > name? Is that now forbidden, or would such a hook silently also disable > all the other hooks? In this current patch, if (friendly-name == event-name), then hook.*.enabled = false only disables that one hook and there's no way to disable the entire event... (see below) >> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc >> index d4fa29d936..0a9f04b154 100644 >> --- a/Documentation/config/hook.adoc >> +++ b/Documentation/config/hook.adoc >> @@ -33,6 +33,18 @@ hook.<friendly-name>.parallel:: >> found in the hooks directory do not need to, and run in parallel when >> the effective job count is greater than 1. See linkgit:git-hook[1]. >> >> +hook.<event>.enabled:: >> + Switch to enable or disable all hooks for the `<event>` hook event. >> + When set to `false`, no hooks fire for that event, regardless of any >> + per-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`. >> + See linkgit:git-hook[1]. >> ++ >> +Note on naming: `<event>` must be the event name (e.g. `pre-commit`), >> +not a hook friendly-name. A name that also carries `.command`, `.event`, >> +or `.parallel` is treated as a friendly-name and its `.enabled` value >> +applies only to that individual hook. See `hook.<friendly-name>.enabled` >> +above. > > Ah, okay, so you've thought about that already. I wonder whether this > behaviour is okay in general or whether it is going to be confusing. An > alternative would be to disallow configuring hooks where the event name > matches the friendly name, which would fix the ambiguity that we now > have. ... It is rather confusing, yes. I think disallowing the collision, as you suggested, is the better approach, so I will do this in v5. I think it can be done at parse time and rather easy to check because we have hook.<name>.event = <value>, where name == value results in a collision. Or even simpler: reject if name is in hook_name_list! I already implemented this check for the `--allow-unknown-hook-name` arg you suggested in the other "cleanup" series. Many thanks as always, your careful feedback is very valuable, Adrian ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v4 0/9] Run hooks in parallel 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu ` (8 preceding siblings ...) 2026-03-20 13:53 ` [PATCH v4 9/9] hook: add hook.<event>.enabled switch Adrian Ratiu @ 2026-03-20 17:24 ` Junio C Hamano 2026-03-23 15:07 ` Adrian Ratiu 9 siblings, 1 reply; 83+ messages in thread From: Junio C Hamano @ 2026-03-20 17:24 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson Adrian Ratiu <adrian.ratiu@collabora.com> writes: > Range-diff v3 -> v4: > 1: 6686d92867 = 1: ec274c24e5 repository: fix repo_init() memleak due to missing _clear() This one is not included in the set. This cover letter identifies itself as [0/9], but the range-diff implies it should have 10 patches. Curious. In the meantime, let me resurrect the corresponding patch from the previous, trusting that range-diff deems them identical. ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v4 0/9] Run hooks in parallel 2026-03-20 17:24 ` [PATCH v4 0/9] Run hooks in parallel Junio C Hamano @ 2026-03-23 15:07 ` Adrian Ratiu 2026-03-24 9:07 ` Patrick Steinhardt 0 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-23 15:07 UTC (permalink / raw) To: Junio C Hamano Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Fri, 20 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote: > Adrian Ratiu <adrian.ratiu@collabora.com> writes: > >> Range-diff v3 -> v4: >> 1: 6686d92867 = 1: ec274c24e5 repository: fix repo_init() memleak due to missing _clear() > > This one is not included in the set. This cover letter identifies > itself as [0/9], but the range-diff implies it should have 10 > patches. > > Curious. > > In the meantime, let me resurrect the corresponding patch from the > previous, trusting that range-diff deems them identical. Yes, it's identical. Sorry for missing that 1 patch. I mis-typed git format-patch HEAD~9 instead of HEAD~10. :) ^ permalink raw reply [flat|nested] 83+ messages in thread
* Re: [PATCH v4 0/9] Run hooks in parallel 2026-03-23 15:07 ` Adrian Ratiu @ 2026-03-24 9:07 ` Patrick Steinhardt 0 siblings, 0 replies; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-24 9:07 UTC (permalink / raw) To: Adrian Ratiu Cc: Junio C Hamano, git, Jeff King, Emily Shaffer, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Mon, Mar 23, 2026 at 05:07:28PM +0200, Adrian Ratiu wrote: > On Fri, 20 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote: > > Adrian Ratiu <adrian.ratiu@collabora.com> writes: > > > >> Range-diff v3 -> v4: > >> 1: 6686d92867 = 1: ec274c24e5 repository: fix repo_init() memleak due to missing _clear() > > > > This one is not included in the set. This cover letter identifies > > itself as [0/9], but the range-diff implies it should have 10 > > patches. > > > > Curious. > > > > In the meantime, let me resurrect the corresponding patch from the > > previous, trusting that range-diff deems them identical. > > Yes, it's identical. Sorry for missing that 1 patch. > > I mis-typed git format-patch HEAD~9 instead of HEAD~10. :) I can only recommend the use of b4. It will make your life a ton easier with mailing list based workflows as you don't have to manually keep track of shenanigans like that. Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v5 00/12] Run hooks in parallel 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu ` (7 preceding siblings ...) 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 01/12] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu ` (11 more replies) 8 siblings, 12 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Hello everyone, This series enables running hook commands in parallel and is based on the latest config-hooks cleanup series v3 [1]. v5 addresses all the feedback received in v4 and adds a few minor new features. That's the main reason this series now increased to 12 patches. I kindly ask the community to stop proposing new features and focus on polishing / cleaning up the existing ones. New features can be added in subsequent patches. :) Branch pushed to GitHub: [2] Successful CI run: [3] 1: https://lore.kernel.org/git/20260309005416.2760030-1-adrian.ratiu@collabora.com/T/#m78d2c342f05524bf8a8e2c4d0d4be12b599c5e5b 2: https://github.com/10ne1/git/tree/dev/aratiu/parallel-hooks-v5 3: https://github.com/10ne1/git/actions/runs/23587892089 Changes in v5: * Rebased on config-cleanups-v3, fixed minor conflicts and test failures due to the new --allow-unknown-hook-name check (Adrian) * Added back the first commit which I accidentally dropped in v4 (Junio) * Replaced unsigned int:1 with a proper bool in all places (Patrick) * Renamed the caching struct event_disabled -> disabled_events (Junio) * New commit: exposed is_known_hook() via hook.h so it can be reused (Adrian) * New commit: jobs = -1 now defaults to online_cpus() (Patrick) * Added is_known_hook(), introduced in commit above, to hook.h because it will be used by another commit later in this series (Adrian) * Rework hook name and event name collision to die() for known hooks and warn() for unknown hooks instead of assuming they're hook names (Patrick) * Avoid potential r == NULL dereference in repo_config_get_uint (Patrick) * Simplify options->jobs = 1; default setting in get_hook_jobs() (Adrian) * Warn when hook.*.parallel is unparseable (Patrick) * Mention in the hook run CLI -j doc that it overrides the config (Patrick) * Minor whitespace, capitalization, commit msg fixes (Patrick) Range-diff v4 -> v5: 1: ec274c24e5 = 1: f5624a2a1f repository: fix repo_init() memleak due to missing _clear() 2: 81d92a4465 = 2: 8ae5eec9c9 config: add a repo_config_get_uint() helper 3: 3bc27f6997 = 3: f9278a22ea hook: parse the hook.jobs config 4: 74e6f8689a ! 4: 7cbe7d2f7c hook: allow parallel hook execution @@ Commit message users to run hooks in parallel (opt-in). Users need to decide which hooks to run in parallel, by specifying - "parallel = true" in the config, because git cannot know if their + "parallel = true" in the config, because Git cannot know if their specific hooks are safe to run or not in parallel (for e.g. two hooks might write to the same file or call the same program). @@ hook.c @@ hook.c: struct hook_config_cache_entry { char *command; enum config_scope scope; - unsigned int disabled:1; -+ unsigned int parallel:1; + bool disabled; ++ bool parallel; }; /* @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value, + } else if (!strcmp(subkey, "parallel")) { + int v = git_parse_maybe_bool(value); + if (v >= 0) -+ strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); ++ strmap_put(&data->parallel_hooks, hook_name, ++ (void *)(uintptr_t)v); ++ else ++ warning(_("hook.%s.parallel must be a boolean," ++ " ignoring: '%s'"), ++ hook_name, value); } free(hook_name); @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *c struct hook_config_cache_entry *entry; char *command; -+ int is_par = !!strmap_get(&cb_data.parallel_hooks, hname); - int is_disabled = ++ bool is_par = !!strmap_get(&cb_data.parallel_hooks, hname); + bool is_disabled = !!unsorted_string_list_lookup( &cb_data.disabled_hooks, hname); @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) @@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options) + * fallback to a direct config lookup (out-of-repo). + * Default to 1 (serial execution) on failure. + */ -+ if (r && r->gitdir && r->hook_config_cache) -+ /* Use the already-parsed cache (in-repo) */ -+ options->jobs = r->hook_jobs ? r->hook_jobs : 1; -+ else -+ /* No cache present (out-of-repo call), use direct cfg lookup */ -+ if (repo_config_get_uint(r, "hook.jobs", &options->jobs)) -+ options->jobs = 1; ++ options->jobs = 1; ++ if (r) { ++ if (r->gitdir && r->hook_config_cache && r->hook_jobs) ++ options->jobs = r->hook_jobs; ++ else ++ repo_config_get_uint(r, "hook.jobs", &options->jobs); ++ } + + /* + * Cap to serial any configured hook not marked as parallel = true. @@ hook.h: struct hook { + * event. Only useful for configured (named) hooks. Traditional hooks + * always default to 0 (serial). Set via `hook.<name>.parallel = true`. + */ -+ unsigned int parallel:1; ++ bool parallel; + /** * Opaque data pointer used to keep internal state across callback calls. @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + + test_config hook.jobs 1 && + -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + + test_config hook.jobs 2 && + -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + + test_config hook.jobs 2 && + -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + + test_config hook.jobs 2 && + -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + + test_config hook.jobs 2 && + -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' 5: 508d6476c6 = 5: fd39771388 hook: allow pre-push parallel execution 6: 2d7b3d6d83 = 6: 54c08cb72b hook: mark non-parallelizable hooks 7: 97507d8d31 ! 7: 5957cd9c72 hook: add -j/--jobs option to git hook run @@ Documentation/git-hook.adoc: git-hook - Run git hooks SYNOPSIS -------- [verse] --'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>] -+'git hook' run [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>] +-'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>] ++'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>] + <hook-name> [-- <hook-args>] - 'git hook' list [-z] [--show-scope] <hook-name> + 'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name> DESCRIPTION @@ Documentation/git-hook.adoc: OPTIONS @@ Documentation/git-hook.adoc: OPTIONS ++ +Specify how many hooks to run simultaneously. If this flag is not specified, +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If -+neither is specified, defaults to 1 (serial execution). Some hooks always run -+sequentially regardless of this flag or the `hook.jobs` config, because git -+knows they cannot safely run in parallel: `applypatch-msg`, `pre-commit`, -+`prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`, and -+`push-to-checkout`. ++neither is specified, defaults to 1 (serial execution). +++ ++When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel` ++setting, allowing all hooks for the event to run concurrently, even if they ++are not individually marked as parallel. +++ ++Some hooks always run sequentially regardless of this flag or the ++`hook.jobs` config, because git knows they cannot safely run in parallel: ++`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`, ++`post-commit`, `post-checkout`, and `push-to-checkout`. + WRAPPERS -------- @@ Documentation/git-hook.adoc: running: - git hook run mywrapper-start-tests \ + git hook run --allow-unknown-hook-name mywrapper-start-tests \ # providing something to stdin --stdin some-tempfile-123 \ - # execute hooks in serial @@ builtin/hook.c #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ -- 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" \ +- 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" \ + "<hook-name> [-- <hook-args>]") #define BUILTIN_HOOK_LIST_USAGE \ - N_("git hook list [-z] [--show-scope] <hook-name>") + N_("git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>") @@ builtin/hook.c: static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested <hook-name>")), @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" && + test_config hook.hook-3.parallel true && + -+ git hook run -j3 test-hook >out 2>err.parallel && ++ git hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel && + + # Verify Hook 1 output is grouped + sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out && @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + 3 + EOF + -+ git hook run -j1 test-hook 2>actual && ++ git hook run --allow-unknown-hook-name -j1 test-hook 2>actual && + test_cmp expected actual +' + @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + -+ git hook run -j2 test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + # hook-2 also has no parallel=true + + # -j2 overrides parallel=false; hooks run in parallel with a warning. -+ git hook run -j2 test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' @@ t/t1800-hook.sh: test_expect_success 'server push-to-checkout hook expects stdou + test_config hook.hook-2.command "true" && + # neither hook has parallel=true + -+ git hook run -j2 test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + grep "hook .hook-1. is not marked as parallel=true" err && + grep "hook .hook-2. is not marked as parallel=true" err +' 8: 734adfad1b ! 8: a58f05e9d5 hook: add per-event jobs config @@ hook.c: struct hook_all_config_cb { }; @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value, - int v = git_parse_maybe_bool(value); - if (v >= 0) - strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v); + warning(_("hook.%s.parallel must be a boolean," + " ignoring: '%s'"), + hook_name, value); + } else if (!strcmp(subkey, "jobs")) { + unsigned int v; + if (!git_parse_uint(value, &v)) -+ warning(_("hook.%s.jobs must be a positive integer, ignoring: '%s'"), ++ warning(_("hook.%s.jobs must be a positive integer," ++ " ignoring: '%s'"), + hook_name, value); + else if (!v) -+ warning(_("hook.%s.jobs must be positive, ignoring: 0"), hook_name); ++ warning(_("hook.%s.jobs must be positive," ++ " ignoring: 0"), hook_name); + else + strmap_put(&data->event_jobs, hook_name, + (void *)(uintptr_t)v); @@ hook.c: static void warn_non_parallel_hooks_override(unsigned int jobs, { /* @@ hook.c: static unsigned int get_hook_jobs(struct repository *r, - * fallback to a direct config lookup (out-of-repo). - * Default to 1 (serial execution) on failure. */ -- if (r && r->gitdir && r->hook_config_cache) -+ if (r && r->gitdir && r->hook_config_cache) { - /* Use the already-parsed cache (in-repo) */ -+ void *event_jobs = strmap_get(&r->event_jobs, hook_name); - options->jobs = r->hook_jobs ? r->hook_jobs : 1; -- else -+ if (event_jobs) -+ options->jobs = (unsigned int)(uintptr_t)event_jobs; -+ } else { - /* No cache present (out-of-repo call), use direct cfg lookup */ -+ unsigned int event_jobs; -+ char *key; + options->jobs = 1; + if (r) { +- if (r->gitdir && r->hook_config_cache && r->hook_jobs) +- options->jobs = r->hook_jobs; +- else ++ if (r->gitdir && r->hook_config_cache) { ++ void *event_jobs; + - if (repo_config_get_uint(r, "hook.jobs", &options->jobs)) - options->jobs = 1; - -+ key = xstrfmt("hook.%s.jobs", hook_name); -+ if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) -+ options->jobs = event_jobs; -+ free(key); -+ } ++ if (r->hook_jobs) ++ options->jobs = r->hook_jobs; ++ ++ event_jobs = strmap_get(&r->event_jobs, hook_name); ++ if (event_jobs) ++ options->jobs = (unsigned int)(uintptr_t)event_jobs; ++ } else { ++ unsigned int event_jobs; ++ char *key; + + repo_config_get_uint(r, "hook.jobs", &options->jobs); ++ ++ key = xstrfmt("hook.%s.jobs", hook_name); ++ if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) ++ options->jobs = event_jobs; ++ free(key); ++ } + } + /* * Cap to serial any configured hook not marked as parallel = true. * This enforces the parallel = false default, even for "traditional" @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 is ignored for force-serial ho + test_config hook.jobs 1 && + test_config hook.test-hook.jobs 2 && + -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 is ignored for force-serial ho + test_config hook.jobs 4 && + test_config hook.test-hook.jobs 1 && + -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' @@ t/t1800-hook.sh: test_expect_success 'hook.jobs=2 is ignored for force-serial ho + # Per-event jobs=2 but no hook has parallel=true: must still run serially. + test_config hook.test-hook.jobs 2 && + -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' 9: d6d3196b17 ! 9: 4fb39ad98d hook: warn when hook.<friendly-name>.jobs is set @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs still requires hook.<nam +test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' ' + test_config hook.my-hook.command "true" && + test_config hook.my-hook.jobs 2 && -+ git hook run --ignore-missing test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs still requires hook.<nam + test_config hook.my-hook.event test-hook && + test_config hook.my-hook.command "true" && + test_config hook.my-hook.jobs 2 && -+ git hook run --ignore-missing test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs still requires hook.<nam + test_config hook.my-hook.command "true" && + test_config hook.my-hook.parallel true && + test_config hook.my-hook.jobs 2 && -+ git hook run --ignore-missing test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook.<event>.jobs does not warn for a real event name' ' + test_config hook.test-hook.jobs 2 && -+ git hook run --ignore-missing test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep ! "friendly-name" err +' + -: ---------- > 10: d3c6e8f3e2 hook: move is_known_hook() to hook.c for wider use 10: b64689d0c8 ! 11: 312acd15b4 hook: add hook.<event>.enabled switch @@ Commit message relevant piece of information, and the per-hook "disabled" status will surface once the event is re-enabled. - Reuses is_friendly_name() from the previous commit to distinguish - event names from friendly-names when processing .enabled settings. + Using an event name as a friendly-name (e.g. hook.<event>.enabled) + can cause ambiguity, so a fatal error is issued when using a known + event name and a warning is issued for unknown event name, since + a collision cannot be detected with certainty for unknown events. + Suggested-by: Patrick Steinhardt <ps@pks.im> Suggested-by: Junio C Hamano <gitster@pobox.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> ## Documentation/config/hook.adoc ## +@@ Documentation/config/hook.adoc: hook.<friendly-name>.event:: + events, specify the key more than once. An empty value resets + the list of events, clearing any previously defined events for + `hook.<friendly-name>`. See linkgit:git-hook[1]. +++ ++The `<friendly-name>` must not be the same as a known hook event name ++(e.g. do not use `hook.pre-commit.event`). Using a known event name as ++a friendly-name is a fatal error because it creates an ambiguity with ++`hook.<event>.enabled` and `hook.<event>.jobs`. For unknown event names, ++a warning is issued when `<friendly-name>` matches the event value. + + hook.<friendly-name>.enabled:: + Whether the hook `hook.<friendly-name>` is enabled. Defaults to `true`. @@ Documentation/config/hook.adoc: hook.<friendly-name>.parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. @@ Documentation/config/hook.adoc: hook.<friendly-name>.parallel:: + See linkgit:git-hook[1]. ++ +Note on naming: `<event>` must be the event name (e.g. `pre-commit`), -+not a hook friendly-name. A name that also carries `.command`, `.event`, -+or `.parallel` is treated as a friendly-name and its `.enabled` value -+applies only to that individual hook. See `hook.<friendly-name>.enabled` -+above. ++not a hook friendly-name. Since using a known event name as a ++friendly-name is disallowed (see `hook.<friendly-name>.event` above), ++there is no ambiguity between event-level and per-hook `.enabled` ++settings for known events. For unknown events, if a friendly-name ++matches the event name despite the warning, `.enabled` is treated ++as per-hook only. + hook.<event>.jobs:: Specifies how many hooks can be run simultaneously for the `<event>` @@ hook.c: struct hook_config_cache_entry { - * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * disabled_hooks: set of all names with hook.<name>.enabled = false; after + * parsing, names that are not friendly-names become event-level -+ * disables stored in cache->event_disabled. This collects all. ++ * disables stored in r->disabled_events. This collects all. * parallel_hooks: friendly-name to parallel flag. * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). +@@ hook.c: static int hook_config_lookup_all(const char *key, const char *value, + strmap_for_each_entry(&data->event_hooks, &iter, e) + unsorted_string_list_remove(e->value, hook_name, 0); + } else { +- struct string_list *hooks = +- strmap_get(&data->event_hooks, value); ++ struct string_list *hooks; ++ ++ if (is_known_hook(hook_name)) ++ die(_("hook friendly-name '%s' collides with " ++ "a known event name; please choose a " ++ "different friendly-name"), ++ hook_name); ++ ++ if (!strcmp(hook_name, value)) ++ warning(_("hook friendly-name '%s' is the " ++ "same as its event; this may cause " ++ "ambiguity with hook.%s.enabled"), ++ hook_name, hook_name); ++ ++ hooks = strmap_get(&data->event_hooks, value); + + if (!hooks) { + CALLOC_ARRAY(hooks, 1); @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache) warn_jobs_on_friendly_names(&cb_data); + /* -+ * Populate event_disabled: names in disabled_hooks that are not ++ * Populate disabled_events: names in disabled_hooks that are not + * friendly-names are event-level switches (hook.<event>.enabled = false). + * Names that are friendly-names are already handled per-hook via the + * hook_config_cache_entry.disabled flag below. + */ + if (r) { -+ string_list_clear(&r->event_disabled, 0); -+ string_list_init_dup(&r->event_disabled); ++ string_list_clear(&r->disabled_events, 0); ++ string_list_init_dup(&r->disabled_events); + for (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) { + const char *n = cb_data.disabled_hooks.items[i].string; + if (!is_friendly_name(&cb_data, n)) -+ string_list_append(&r->event_disabled, n); ++ string_list_append(&r->disabled_events, n); + } + } + @@ hook.c: static void list_hooks_add_configured(struct repository *r, { struct strmap *cache = get_hook_config_cache(r); struct string_list *configured_hooks = strmap_get(cache, hookname); -+ int event_is_disabled = r ? !!unsorted_string_list_lookup(&r->event_disabled, ++ bool event_is_disabled = r ? !!unsorted_string_list_lookup(&r->disabled_events, + hookname) : 0; /* Iterate through configured hooks and initialize internal states */ @@ hook.c: static void list_hooks_add_configured(struct repository *r, hook_cache_clear(cache); free(cache); + if (r) -+ string_list_clear(&r->event_disabled, 0); ++ string_list_clear(&r->disabled_events, 0); } } @@ hook.h @@ hook.h: struct hook { const char *command; enum config_scope scope; - unsigned int disabled:1; -+ unsigned int event_disabled:1; + bool disabled; ++ bool event_disabled; } configured; } u; @@ repository.c: void repo_clear(struct repository *repo) FREE_AND_NULL(repo->hook_config_cache); } strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */ -+ string_list_clear(&repo->event_disabled, 0); ++ string_list_clear(&repo->disabled_events, 0); if (repo->promisor_remote_config) { promisor_remote_clear(repo->promisor_remote_config); @@ repository.h: struct repository { struct strmap event_jobs; + /* Cached list of event names with hook.<event>.enabled = false. */ -+ struct string_list event_disabled; ++ struct string_list disabled_events; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_must_be_empty out +' + @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled true && -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep "ran" err +' + @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.other-event.enabled false && -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep "ran" err +' + @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "echo hook-2" && + test_config hook.hook-1.enabled false && -+ git hook run test-hook >out 2>err && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep ! "hook-1" err && + test_grep "hook-2" err +' @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "echo ran" && + test_config hook.test-hook.enabled false && -+ git hook list test-hook >actual && ++ git hook list --allow-unknown-hook-name test-hook >actual && + test_grep "^event-disabled hook-1$" actual && + test_grep "^event-disabled hook-2$" actual +' @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && -+ git hook list --show-scope test-hook >actual && ++ git hook list --allow-unknown-hook-name --show-scope test-hook >actual && + test_grep "^local event-disabled hook-1$" actual +' + @@ t/t1800-hook.sh: test_expect_success 'hook.<event>.jobs does not warn for a real + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && -+ git hook list test-hook >actual && ++ git hook list --allow-unknown-hook-name test-hook >actual && + test_grep "event-disabled" actual +' ++ ++test_expect_success 'friendly-name matching known event name is rejected' ' ++ test_config hook.pre-commit.event pre-commit && ++ test_config hook.pre-commit.command "echo oops" && ++ test_must_fail git hook run pre-commit 2>err && ++ test_grep "collides with a known event name" err ++' ++ ++test_expect_success 'friendly-name matching known event name is rejected even for different event' ' ++ test_config hook.pre-commit.event post-commit && ++ test_config hook.pre-commit.command "echo oops" && ++ test_must_fail git hook run post-commit 2>err && ++ test_grep "collides with a known event name" err ++' ++ ++test_expect_success 'friendly-name matching unknown event warns' ' ++ test_config hook.test-hook.event test-hook && ++ test_config hook.test-hook.command "echo ran" && ++ git hook run --allow-unknown-hook-name test-hook >out 2>err && ++ test_grep "same as its event" err ++' + test_done -: ---------- > 12: f54844d13e hook: allow hook.jobs=-1 to use all available CPU cores Adrian Ratiu (9): repository: fix repo_init() memleak due to missing _clear() config: add a repo_config_get_uint() helper hook: parse the hook.jobs config hook: allow pre-push parallel execution hook: add per-event jobs config hook: warn when hook.<friendly-name>.jobs is set hook: move is_known_hook() to hook.c for wider use hook: add hook.<event>.enabled switch hook: allow hook.jobs=-1 to use all available CPU cores Emily Shaffer (3): hook: allow parallel hook execution hook: mark non-parallelizable hooks hook: add -j/--jobs option to git hook run Documentation/config/hook.adoc | 76 +++++ Documentation/git-hook.adoc | 23 +- Makefile | 2 +- builtin/am.c | 8 +- builtin/checkout.c | 19 +- builtin/clone.c | 6 +- builtin/hook.c | 46 ++- builtin/receive-pack.c | 3 +- builtin/worktree.c | 2 +- commit.c | 2 +- config.c | 28 ++ config.h | 13 + hook.c | 286 ++++++++++++++++- hook.h | 38 ++- parse.c | 9 + parse.h | 1 + repository.c | 3 + repository.h | 10 + t/t1800-hook.sh | 546 ++++++++++++++++++++++++++++++++- transport.c | 6 +- 20 files changed, 1069 insertions(+), 58 deletions(-) -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v5 01/12] repository: fix repo_init() memleak due to missing _clear() 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 02/12] config: add a repo_config_get_uint() helper Adrian Ratiu ` (10 subsequent siblings) 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu There is an old pre-existing memory leak in repo_init() due to failing to call clear_repository_format() in the error case. It went undetected because a specific bug is required to trigger it: enable a v1 extension in a repository with format v0. Obviously this can only happen in a development environment, so it does not trigger in normal usage, however the memleak is real and needs fixing. Fix it by also calling clear_repository_format() in the error case. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- repository.c | 1 + 1 file changed, 1 insertion(+) diff --git a/repository.c b/repository.c index 0b8f7ec200..fb4356ca55 100644 --- a/repository.c +++ b/repository.c @@ -322,6 +322,7 @@ int repo_init(struct repository *repo, return 0; error: + clear_repository_format(&format); repo_clear(repo); return -1; } -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v5 02/12] config: add a repo_config_get_uint() helper 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 01/12] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 03/12] hook: parse the hook.jobs config Adrian Ratiu ` (9 subsequent siblings) 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Next commits add a 'hook.jobs' config option of type 'unsigned int', so add a helper to parse it since the API only supports int and ulong. An alternative is to make 'hook.jobs' an 'int' or parse it as an 'int' then cast it to unsigned, however it's better to use proper helpers for the type. Using 'ulong' is another option which already has helpers, but it's a bit excessive in size for just the jobs number. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- config.c | 28 ++++++++++++++++++++++++++++ config.h | 13 +++++++++++++ parse.c | 9 +++++++++ parse.h | 1 + 4 files changed, 51 insertions(+) diff --git a/config.c b/config.c index 156f2a24fa..a1b92fe083 100644 --- a/config.c +++ b/config.c @@ -1212,6 +1212,15 @@ int git_config_int(const char *name, const char *value, return ret; } +unsigned int git_config_uint(const char *name, const char *value, + const struct key_value_info *kvi) +{ + unsigned int ret; + if (!git_parse_uint(value, &ret)) + die_bad_number(name, value, kvi); + return ret; +} + int64_t git_config_int64(const char *name, const char *value, const struct key_value_info *kvi) { @@ -1907,6 +1916,18 @@ int git_configset_get_int(struct config_set *set, const char *key, int *dest) return 1; } +int git_configset_get_uint(struct config_set *set, const char *key, unsigned int *dest) +{ + const char *value; + struct key_value_info kvi; + + if (!git_configset_get_value(set, key, &value, &kvi)) { + *dest = git_config_uint(key, value, &kvi); + return 0; + } else + return 1; +} + int git_configset_get_ulong(struct config_set *set, const char *key, unsigned long *dest) { const char *value; @@ -2356,6 +2377,13 @@ int repo_config_get_int(struct repository *repo, return git_configset_get_int(repo->config, key, dest); } +int repo_config_get_uint(struct repository *repo, + const char *key, unsigned int *dest) +{ + git_config_check_init(repo); + return git_configset_get_uint(repo->config, key, dest); +} + int repo_config_get_ulong(struct repository *repo, const char *key, unsigned long *dest) { diff --git a/config.h b/config.h index ba426a960a..bf47fb3afc 100644 --- a/config.h +++ b/config.h @@ -267,6 +267,12 @@ int git_config_int(const char *, const char *, const struct key_value_info *); int64_t git_config_int64(const char *, const char *, const struct key_value_info *); +/** + * Identical to `git_config_int`, but for unsigned ints. + */ +unsigned int git_config_uint(const char *, const char *, + const struct key_value_info *); + /** * Identical to `git_config_int`, but for unsigned longs. */ @@ -560,6 +566,7 @@ int git_configset_get_value(struct config_set *cs, const char *key, int git_configset_get_string(struct config_set *cs, const char *key, char **dest); int git_configset_get_int(struct config_set *cs, const char *key, int *dest); +int git_configset_get_uint(struct config_set *cs, const char *key, unsigned int *dest); int git_configset_get_ulong(struct config_set *cs, const char *key, unsigned long *dest); int git_configset_get_bool(struct config_set *cs, const char *key, int *dest); int git_configset_get_bool_or_int(struct config_set *cs, const char *key, int *is_bool, int *dest); @@ -650,6 +657,12 @@ int repo_config_get_string_tmp(struct repository *r, */ int repo_config_get_int(struct repository *r, const char *key, int *dest); +/** + * Similar to `repo_config_get_int` but for unsigned ints. + */ +int repo_config_get_uint(struct repository *r, + const char *key, unsigned int *dest); + /** * Similar to `repo_config_get_int` but for unsigned longs. */ diff --git a/parse.c b/parse.c index 48313571aa..d77f28046a 100644 --- a/parse.c +++ b/parse.c @@ -107,6 +107,15 @@ int git_parse_int64(const char *value, int64_t *ret) return 1; } +int git_parse_uint(const char *value, unsigned int *ret) +{ + uintmax_t tmp; + if (!git_parse_unsigned(value, &tmp, maximum_unsigned_value_of_type(unsigned int))) + return 0; + *ret = tmp; + return 1; +} + int git_parse_ulong(const char *value, unsigned long *ret) { uintmax_t tmp; diff --git a/parse.h b/parse.h index ea32de9a91..a6dd37c4cb 100644 --- a/parse.h +++ b/parse.h @@ -5,6 +5,7 @@ int git_parse_signed(const char *value, intmax_t *ret, intmax_t max); int git_parse_unsigned(const char *value, uintmax_t *ret, uintmax_t max); int git_parse_ssize_t(const char *, ssize_t *); int git_parse_ulong(const char *, unsigned long *); +int git_parse_uint(const char *value, unsigned int *ret); int git_parse_int(const char *value, int *ret); int git_parse_int64(const char *value, int64_t *ret); int git_parse_double(const char *value, double *ret); -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v5 03/12] hook: parse the hook.jobs config 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 01/12] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 02/12] config: add a repo_config_get_uint() helper Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 04/12] hook: allow parallel hook execution Adrian Ratiu ` (8 subsequent siblings) 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu The hook.jobs config is a global way to set hook parallelization for all hooks, in the sense that it is not per-event nor per-hook. Finer-grained configs will be added in later commits which can override it, for e.g. via a per-event type job options. Next commits will also add to this item's documentation. Parse hook.jobs config key in hook_config_lookup_all() and store its value in hook_all_config_cb.jobs, then transfer it into r->jobs after the config pass completes. This is mostly plumbing and the cached value is not yet used. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++++ hook.c | 23 +++++++++++++++++++++-- repository.h | 3 +++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 9e78f26439..b7847f9338 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -22,3 +22,7 @@ hook.<friendly-name>.enabled:: configuration. This is particularly useful when a hook is defined in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. + +hook.jobs:: + Specifies how many hooks can be run simultaneously during parallelized + hook execution. If unspecified, defaults to 1 (serial execution). diff --git a/hook.c b/hook.c index cc23276d27..b8cce00e57 100644 --- a/hook.c +++ b/hook.c @@ -123,11 +123,13 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + unsigned int jobs; }; /* repo_config() callback that collects all hook.* configuration in one pass. */ @@ -143,6 +145,20 @@ static int hook_config_lookup_all(const char *key, const char *value, if (parse_config_key(key, "hook", &name, &name_len, &subkey)) return 0; + /* Handle plain hook.<key> entries that have no hook name component. */ + if (!name) { + if (!strcmp(subkey, "jobs") && value) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.jobs must be a positive integer, ignoring: '%s'"), value); + else if (!v) + warning(_("hook.jobs must be positive, ignoring: 0")); + else + data->jobs = v; + } + return 0; + } + if (!value) return config_error_nonbool(key); @@ -240,7 +256,7 @@ void hook_cache_clear(struct strmap *cache) /* Populate `cache` with the complete hook configuration */ static void build_hook_config_map(struct repository *r, struct strmap *cache) { - struct hook_all_config_cb cb_data; + struct hook_all_config_cb cb_data = { 0 }; struct hashmap_iter iter; struct strmap_entry *e; @@ -248,7 +264,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); - /* Parse all configs in one run. */ + /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); /* Construct the cache from parsed configs. */ @@ -292,6 +308,9 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_put(cache, e->key, hooks); } + if (r) + r->hook_jobs = cb_data.jobs; + strmap_clear(&cb_data.commands, 1); string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { diff --git a/repository.h b/repository.h index 078059a6e0..58e46853d0 100644 --- a/repository.h +++ b/repository.h @@ -172,6 +172,9 @@ struct repository { */ struct strmap *hook_config_cache; + /* Cached value of hook.jobs config (0 if unset, defaults to serial). */ + unsigned int hook_jobs; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v5 04/12] hook: allow parallel hook execution 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (2 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 03/12] hook: parse the hook.jobs config Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 05/12] hook: allow pre-push parallel execution Adrian Ratiu ` (7 subsequent siblings) 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Emily Shaffer, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <nasamuffin@google.com> Hooks always run in sequential order due to the hardcoded jobs == 1 passed to run_process_parallel(). Remove that hardcoding to allow users to run hooks in parallel (opt-in). Users need to decide which hooks to run in parallel, by specifying "parallel = true" in the config, because Git cannot know if their specific hooks are safe to run or not in parallel (for e.g. two hooks might write to the same file or call the same program). Some hooks are unsafe to run in parallel by design: these will marked in the next commit using RUN_HOOKS_OPT_INIT_FORCE_SERIAL. The hook.jobs config specifies the default number of jobs applied to all hooks which have parallelism enabled. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 13 +++ hook.c | 79 ++++++++++++++++-- hook.h | 25 ++++++ t/t1800-hook.sh | 142 +++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 6 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index b7847f9338..21800db648 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -23,6 +23,19 @@ hook.<friendly-name>.enabled:: in a system or global config file and needs to be disabled for a specific repository. See linkgit:git-hook[1]. +hook.<friendly-name>.parallel:: + Whether the hook `hook.<friendly-name>` may run in parallel with other hooks + for the same event. Defaults to `false`. Set to `true` only when the + hook script is safe to run concurrently with other hooks for the same + event. If any hook for an event does not have this set to `true`, + all hooks for that event run sequentially regardless of `hook.jobs`. + Only configured (named) hooks need to declare this. Traditional hooks + found in the hooks directory do not need to, and run in parallel when + the effective job count is greater than 1. See linkgit:git-hook[1]. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). ++ +This setting has no effect unless all configured hooks for the event have +`hook.<friendly-name>.parallel` set to `true`. diff --git a/hook.c b/hook.c index b8cce00e57..85c0de5e47 100644 --- a/hook.c +++ b/hook.c @@ -116,6 +116,7 @@ struct hook_config_cache_entry { char *command; enum config_scope scope; bool disabled; + bool parallel; }; /* @@ -123,12 +124,14 @@ struct hook_config_cache_entry { * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * parallel_hooks: friendly-name to parallel flag. * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { struct strmap commands; struct strmap event_hooks; struct string_list disabled_hooks; + struct strmap parallel_hooks; unsigned int jobs; }; @@ -219,6 +222,15 @@ static int hook_config_lookup_all(const char *key, const char *value, default: break; /* ignore unrecognised values */ } + } else if (!strcmp(subkey, "parallel")) { + int v = git_parse_maybe_bool(value); + if (v >= 0) + strmap_put(&data->parallel_hooks, hook_name, + (void *)(uintptr_t)v); + else + warning(_("hook.%s.parallel must be a boolean," + " ignoring: '%s'"), + hook_name, value); } free(hook_name); @@ -263,6 +275,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.commands); strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); + strmap_init(&cb_data.parallel_hooks); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -282,6 +295,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) struct hook_config_cache_entry *entry; char *command; + bool is_par = !!strmap_get(&cb_data.parallel_hooks, hname); bool is_disabled = !!unsorted_string_list_lookup( &cb_data.disabled_hooks, hname); @@ -302,6 +316,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) entry->command = xstrdup_or_null(command); entry->scope = scope; entry->disabled = is_disabled; + entry->parallel = is_par; string_list_append(hooks, hname)->util = entry; } @@ -312,6 +327,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) r->hook_jobs = cb_data.jobs; strmap_clear(&cb_data.commands, 1); + strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ string_list_clear(&cb_data.disabled_hooks, 0); strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { string_list_clear(e->value, 0); @@ -389,6 +405,7 @@ static void list_hooks_add_configured(struct repository *r, entry->command ? xstrdup(entry->command) : NULL; hook->u.configured.scope = entry->scope; hook->u.configured.disabled = entry->disabled; + hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; } @@ -538,21 +555,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* Determine how many jobs to use for hook execution. */ +static unsigned int get_hook_jobs(struct repository *r, + struct run_hooks_opt *options, + struct string_list *hook_list) +{ + /* + * Hooks needing separate output streams must run sequentially. + * Next commit will allow parallelizing these as well. + */ + if (!options->stdout_to_stderr) + return 1; + + /* + * An explicit job count overrides everything else: this covers both + * FORCE_SERIAL callers (for hooks that must never run in parallel) + * and the -j flag from the CLI. The CLI override is intentional: users + * may want to serialize hooks declared parallel or to parallelize more + * aggressively than the default. + */ + if (options->jobs) + return options->jobs; + + /* + * Use hook.jobs from the already-parsed config cache (in-repo), or + * fallback to a direct config lookup (out-of-repo). + * Default to 1 (serial execution) on failure. + */ + options->jobs = 1; + if (r) { + if (r->gitdir && r->hook_config_cache && r->hook_jobs) + options->jobs = r->hook_jobs; + else + repo_config_get_uint(r, "hook.jobs", &options->jobs); + } + + /* + * Cap to serial any configured hook not marked as parallel = true. + * This enforces the parallel = false default, even for "traditional" + * hooks from the hookdir which cannot be marked parallel = true. + */ + for (size_t i = 0; i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; + if (h->kind == HOOK_CONFIGURED && !h->parallel) { + options->jobs = 1; + break; + } + } + + return options->jobs; +} + int run_hooks_opt(struct repository *r, const char *hook_name, struct run_hooks_opt *options) { + struct string_list *hook_list = list_hooks(r, hook_name, options); struct hook_cb_data cb_data = { .rc = 0, .hook_name = hook_name, + .hook_command_list = hook_list, .options = options, }; int ret = 0; + unsigned int jobs = get_hook_jobs(r, options, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, - .processes = options->jobs, - .ungroup = options->jobs == 1, + .processes = jobs, + .ungroup = jobs == 1, .get_next_task = pick_next_hook, .start_failure = notify_start_failure, @@ -568,9 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->path_to_stdin && options->feed_pipe) BUG("options path_to_stdin and feed_pipe are mutually exclusive"); - if (!options->jobs) - BUG("run_hooks_opt must be called with options.jobs >= 1"); - /* * Ensure cb_data copy and free functions are either provided together, * or neither one is provided. @@ -581,7 +649,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name, if (options->invoked_hook) *options->invoked_hook = 0; - cb_data.hook_command_list = list_hooks(r, hook_name, options); if (!cb_data.hook_command_list->nr) { if (options->error_if_missing) ret = error("cannot find a hook named %s", hook_name); diff --git a/hook.h b/hook.h index 5c5628dd1f..ba7056f872 100644 --- a/hook.h +++ b/hook.h @@ -35,6 +35,13 @@ struct hook { } configured; } u; + /** + * Whether this hook may run in parallel with other hooks for the same + * event. Only useful for configured (named) hooks. Traditional hooks + * always default to 0 (serial). Set via `hook.<name>.parallel = true`. + */ + bool parallel; + /** * Opaque data pointer used to keep internal state across callback calls. * @@ -72,6 +79,8 @@ struct run_hooks_opt { * * If > 1, output will be buffered and de-interleaved (ungroup=0). * If == 1, output will be real-time (ungroup=1). + * If == 0, the 'hook.jobs' config is used or, if the config is unset, + * defaults to 1 (serial execution). */ unsigned int jobs; @@ -152,7 +161,23 @@ struct run_hooks_opt { hook_data_free_fn feed_pipe_cb_data_free; }; +/** + * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to + * the 'hook.jobs' config, falling back to serial (1) if unset. + */ #define RUN_HOOKS_OPT_INIT { \ + .env = STRVEC_INIT, \ + .args = STRVEC_INIT, \ + .stdout_to_stderr = 1, \ + .jobs = 0, \ +} + +/** + * Initializer for hooks that must always run sequentially regardless of + * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized + * .jobs = 1 is non-overridable. + */ +#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \ .env = STRVEC_INIT, \ .args = STRVEC_INIT, \ .stdout_to_stderr = 1, \ diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 96749fc06d..3774a6c2e1 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -21,6 +21,57 @@ setup_hookdir () { test_when_finished rm -rf .git/hooks } +# write_sentinel_hook <path> [sentinel] +# +# Writes a hook that marks itself as started, sleeps for a few seconds, then +# marks itself done. The sleep must be long enough that sentinel_detector can +# observe <sentinel>.started before <sentinel>.done appears when both hooks +# run concurrently in parallel mode. +write_sentinel_hook () { + sentinel="${2:-sentinel}" + write_script "$1" <<-EOF + touch ${sentinel}.started && + sleep 2 && + touch ${sentinel}.done + EOF +} + +# sentinel_detector <sentinel> <output> +# +# Returns a shell command string suitable for use as hook.<name>.command. +# The detector must be registered after the sentinel: +# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists) +# before the detector starts. +# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared +# yet and the detector just sees <sentinel>.started. +# +# At start, poll until <sentinel>.started exists to absorb startup jitter, then +# write to <output>: +# 1. 'serial' if <sentinel>.done exists (sentinel finished before we started), +# 2. 'parallel' if only <sentinel>.started exists (sentinel still running), +# 3. 'timeout' if <sentinel>.started never appeared. +# +# The command ends with ':' so when git appends "$@" for hooks that receive +# positional arguments (e.g. pre-push), the result ': "$@"' is valid shell +# rather than a syntax error 'fi "$@"'. +sentinel_detector () { + cat <<-EOF + i=0 + while ! test -f ${1}.started && test \$i -lt 10; do + sleep 1 + i=\$((i+1)) + done + if test -f ${1}.done; then + echo serial >${2} + elif test -f ${1}.started; then + echo parallel >${2} + else + echo timeout >${2} + fi + : + EOF +} + test_expect_success 'git hook usage' ' test_expect_code 129 git hook && test_expect_code 129 git hook run && @@ -658,4 +709,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'hook.jobs=1 config runs hooks in series' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + + # Use two configured hooks so the execution order is deterministic: + # hook-1 (sentinel) is listed before hook-2 (detector), so hook-1 + # always runs first even in serial mode. + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 1 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.jobs=2 config runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<name>.parallel=true enables parallel execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + test_config hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + + test_config hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'one non-parallel hook forces the whole event to run serially' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 has no parallel=true: should force serial for all + + test_config hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v5 05/12] hook: allow pre-push parallel execution 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (3 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 04/12] hook: allow parallel hook execution Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 06/12] hook: mark non-parallelizable hooks Adrian Ratiu ` (6 subsequent siblings) 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu pre-push is the only hook that keeps stdout and stderr separate (for backwards compatibility with git-lfs and potentially other users). This prevents parallelizing it because run-command needs stdout_to_stderr=1 to buffer and de-interleave parallel outputs. Since we now default to jobs=1, backwards compatibility is maintained without needing any extension or extra config: when no parallelism is requested, pre-push behaves exactly as before. When the user explicitly opts into parallelism via hook.jobs > 1, hook.<event>.jobs > 1, or -jN, they accept the changed output behavior. Document this and let get_hook_jobs() set stdout_to_stderr=1 automatically when jobs > 1, removing the need for any extension infrastructure. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++++ hook.c | 24 ++++++++++++++++-------- hook.h | 6 ++++-- t/t1800-hook.sh | 32 ++++++++++++++++++++++++++++++++ transport.c | 6 ++++-- 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 21800db648..94c7a9808e 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -39,3 +39,7 @@ hook.jobs:: + This setting has no effect unless all configured hooks for the event have `hook.<friendly-name>.parallel` set to `true`. ++ +For `pre-push` hooks, which normally keep stdout and stderr separate, +setting this to a value greater than 1 (or passing `-j`) will merge stdout +into stderr to allow correct de-interleaving of parallel output. diff --git a/hook.c b/hook.c index 85c0de5e47..25762b6c8d 100644 --- a/hook.c +++ b/hook.c @@ -555,18 +555,24 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options) strvec_clear(&options->args); } +/* + * When running in parallel, stdout must be merged into stderr so + * run-command can buffer and de-interleave outputs correctly. This + * applies even to hooks like pre-push that normally keep stdout and + * stderr separate: the user has opted into parallelism, so the output + * stream behavior changes accordingly. + */ +static void merge_output_if_parallel(struct run_hooks_opt *options) +{ + if (options->jobs > 1) + options->stdout_to_stderr = 1; +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, struct string_list *hook_list) { - /* - * Hooks needing separate output streams must run sequentially. - * Next commit will allow parallelizing these as well. - */ - if (!options->stdout_to_stderr) - return 1; - /* * An explicit job count overrides everything else: this covers both * FORCE_SERIAL callers (for hooks that must never run in parallel) @@ -575,7 +581,7 @@ static unsigned int get_hook_jobs(struct repository *r, * aggressively than the default. */ if (options->jobs) - return options->jobs; + goto cleanup; /* * Use hook.jobs from the already-parsed config cache (in-repo), or @@ -603,6 +609,8 @@ static unsigned int get_hook_jobs(struct repository *r, } } +cleanup: + merge_output_if_parallel(options); return options->jobs; } diff --git a/hook.h b/hook.h index ba7056f872..01db4226a6 100644 --- a/hook.h +++ b/hook.h @@ -106,8 +106,10 @@ struct run_hooks_opt { * Send the hook's stdout to stderr. * * This is the default behavior for all hooks except pre-push, - * which has separate stdout and stderr streams for backwards - * compatibility reasons. + * which keeps stdout and stderr separate for backwards compatibility. + * When parallel execution is requested (jobs > 1), get_hook_jobs() + * overrides this to 1 for all hooks so run-command can de-interleave + * their outputs correctly. */ unsigned int stdout_to_stderr:1; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 3774a6c2e1..9476a97ca5 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -800,4 +800,36 @@ test_expect_success 'one non-parallel hook forces the whole event to run seriall test_cmp expect hook.order ' +test_expect_success 'client hooks: pre-push parallel execution merges stdout to stderr' ' + test_when_finished "rm -rf remote-par stdout.actual stderr.actual" && + git init --bare remote-par && + git remote add origin-par remote-par && + test_commit par-commit && + mkdir -p .git/hooks && + setup_hooks pre-push && + test_config hook.jobs 2 && + git push origin-par HEAD:main >stdout.actual 2>stderr.actual && + check_stdout_merged_to_stderr pre-push +' + +test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' ' + test_when_finished "rm -rf repo-parallel remote-parallel" && + git init --bare remote-parallel && + git init repo-parallel && + git -C repo-parallel remote add origin ../remote-parallel && + test_commit -C repo-parallel A && + + write_sentinel_hook repo-parallel/.git/hooks/pre-push && + git -C repo-parallel config hook.hook-2.event pre-push && + git -C repo-parallel config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + git -C repo-parallel config hook.hook-2.parallel true && + + git -C repo-parallel config hook.jobs 2 && + + git -C repo-parallel push origin HEAD >out 2>err && + echo parallel >expect && + test_cmp expect repo-parallel/hook.order +' + test_done diff --git a/transport.c b/transport.c index e53936d87b..9406ec4f2d 100644 --- a/transport.c +++ b/transport.c @@ -1391,8 +1391,10 @@ static int run_pre_push_hook(struct transport *transport, opt.feed_pipe_cb_data_free = pre_push_hook_data_free; /* - * pre-push hooks expect stdout & stderr to be separate, so don't merge - * them to keep backwards compatibility with existing hooks. + * pre-push hooks keep stdout and stderr separate by default for + * backwards compatibility. When the user opts into parallel execution + * via hook.jobs > 1 or -j, get_hook_jobs() will set stdout_to_stderr=1 + * automatically so run-command can de-interleave the outputs. */ opt.stdout_to_stderr = 0; -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v5 06/12] hook: mark non-parallelizable hooks 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (4 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 05/12] hook: allow pre-push parallel execution Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 07/12] hook: add -j/--jobs option to git hook run Adrian Ratiu ` (5 subsequent siblings) 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> Several hooks are known to be inherently non-parallelizable, so initialize them with RUN_HOOKS_OPT_INIT_FORCE_SERIAL. This pins jobs=1 and overrides any hook.jobs or runtime -j flags. These hooks are: applypatch-msg, pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout, and push-to-checkout. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 14 ++++++++++++++ builtin/am.c | 8 +++++--- builtin/checkout.c | 19 +++++++++++++------ builtin/clone.c | 6 ++++-- builtin/receive-pack.c | 3 ++- builtin/worktree.c | 2 +- commit.c | 2 +- t/t1800-hook.sh | 16 ++++++++++++++++ 8 files changed, 56 insertions(+), 14 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 94c7a9808e..6f60775c28 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -36,6 +36,20 @@ hook.<friendly-name>.parallel:: hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Some hooks always run sequentially regardless of this setting because + they operate on shared data and cannot safely be parallelized: ++ +-- +`applypatch-msg`;; +`prepare-commit-msg`;; +`commit-msg`;; + Receive a commit message file and may rewrite it in place. +`pre-commit`;; +`post-checkout`;; +`push-to-checkout`;; +`post-commit`;; + Access the working tree, index, or repository state. +-- + This setting has no effect unless all configured hooks for the event have `hook.<friendly-name>.parallel` set to `true`. diff --git a/builtin/am.c b/builtin/am.c index 9d0b51c651..271b23160b 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -490,9 +490,11 @@ static int run_applypatch_msg_hook(struct am_state *state) assert(state->msg); - if (!state->no_verify) - ret = run_hooks_l(the_repository, "applypatch-msg", - am_path(state, "final-commit"), NULL); + if (!state->no_verify) { + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + strvec_push(&opt.args, am_path(state, "final-commit")); + ret = run_hooks_opt(the_repository, "applypatch-msg", &opt); + } if (!ret) { FREE_AND_NULL(state->msg); diff --git a/builtin/checkout.c b/builtin/checkout.c index e031e61886..ac0186a33e 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -31,6 +31,7 @@ #include "resolve-undo.h" #include "revision.h" #include "setup.h" +#include "strvec.h" #include "submodule.h" #include "symlinks.h" #include "trace2.h" @@ -123,13 +124,19 @@ static void branch_info_release(struct branch_info *info) static int post_checkout_hook(struct commit *old_commit, struct commit *new_commit, int changed) { - return run_hooks_l(the_repository, "post-checkout", - oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), - oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), - changed ? "1" : "0", NULL); - /* "new_commit" can be NULL when checking out from the index before - a commit exists. */ + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + /* + * "new_commit" can be NULL when checking out from the index before + * a commit exists. + */ + strvec_pushl(&opt.args, + oid_to_hex(old_commit ? &old_commit->object.oid : null_oid(the_hash_algo)), + oid_to_hex(new_commit ? &new_commit->object.oid : null_oid(the_hash_algo)), + changed ? "1" : "0", + NULL); + + return run_hooks_opt(the_repository, "post-checkout", &opt); } static int update_some(const struct object_id *oid, struct strbuf *base, diff --git a/builtin/clone.c b/builtin/clone.c index fba3c9c508..d23b0cafcf 100644 --- a/builtin/clone.c +++ b/builtin/clone.c @@ -647,6 +647,7 @@ static int checkout(int submodule_progress, struct tree *tree; struct tree_desc t; int err = 0; + struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; if (option_no_checkout) return 0; @@ -697,8 +698,9 @@ static int checkout(int submodule_progress, if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); - err |= run_hooks_l(the_repository, "post-checkout", oid_to_hex(null_oid(the_hash_algo)), - oid_to_hex(&oid), "1", NULL); + strvec_pushl(&hook_opt.args, oid_to_hex(null_oid(the_hash_algo)), + oid_to_hex(&oid), "1", NULL); + err |= run_hooks_opt(the_repository, "post-checkout", &hook_opt); if (!err && (option_recurse_submodules.nr > 0)) { struct child_process cmd = CHILD_PROCESS_INIT; diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index cb3656a034..1d9eb2e1a6 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -1464,7 +1464,8 @@ static const char *push_to_checkout(unsigned char *hash, struct strvec *env, const char *work_tree) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; + opt.invoked_hook = invoked_hook; strvec_pushf(env, "GIT_WORK_TREE=%s", absolute_path(work_tree)); diff --git a/builtin/worktree.c b/builtin/worktree.c index 4035b1cb06..b6831c19b5 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -609,7 +609,7 @@ static int add_worktree(const char *path, const char *refname, * is_junk is cleared, but do return appropriate code when hook fails. */ if (!ret && opts->checkout && !opts->orphan) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL); strvec_pushl(&opt.args, diff --git a/commit.c b/commit.c index 80d8d07875..4385ae4329 100644 --- a/commit.c +++ b/commit.c @@ -1970,7 +1970,7 @@ size_t ignored_log_message_bytes(const char *buf, size_t len) int run_commit_hook(int editor_is_used, const char *index_file, int *invoked_hook, const char *name, ...) { - struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; + struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL; va_list args; const char *arg; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 9476a97ca5..6e8b1ad588 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -832,4 +832,20 @@ test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' test_cmp expect repo-parallel/hook.order ' +test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event pre-commit && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event pre-commit && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + test_config hook.jobs 2 && + git commit --allow-empty -m "test: verify force-serial on pre-commit" && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v5 07/12] hook: add -j/--jobs option to git hook run 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (5 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 06/12] hook: mark non-parallelizable hooks Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-27 14:46 ` Patrick Steinhardt 2026-03-26 10:18 ` [PATCH v5 08/12] hook: add per-event jobs config Adrian Ratiu ` (4 subsequent siblings) 11 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Ævar Arnfjörð Bjarmason, Adrian Ratiu From: Emily Shaffer <emilyshaffer@google.com> Expose the parallel job count as a command-line flag so callers can request parallelism without relying only on the hook.jobs config. Add tests covering serial/parallel execution and TTY behaviour under -j1 vs -jN. Signed-off-by: Emily Shaffer <emilyshaffer@google.com> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/git-hook.adoc | 23 +++++- builtin/hook.c | 5 +- hook.c | 17 +++++ t/t1800-hook.sh | 135 ++++++++++++++++++++++++++++++++++-- 4 files changed, 170 insertions(+), 10 deletions(-) diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc index 318c637bd8..46ea52db55 100644 --- a/Documentation/git-hook.adoc +++ b/Documentation/git-hook.adoc @@ -8,7 +8,8 @@ git-hook - Run git hooks SYNOPSIS -------- [verse] -'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>] +'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>] + <hook-name> [-- <hook-args>] 'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name> DESCRIPTION @@ -147,6 +148,23 @@ OPTIONS mirroring the output style of `git config --show-scope`. Traditional hooks from the hookdir are unaffected. +-j:: +--jobs:: + Only valid for `run`. ++ +Specify how many hooks to run simultaneously. If this flag is not specified, +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If +neither is specified, defaults to 1 (serial execution). ++ +When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel` +setting, allowing all hooks for the event to run concurrently, even if they +are not individually marked as parallel. ++ +Some hooks always run sequentially regardless of this flag or the +`hook.jobs` config, because git knows they cannot safely run in parallel: +`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`, +`post-commit`, `post-checkout`, and `push-to-checkout`. + WRAPPERS -------- @@ -169,7 +187,8 @@ running: git hook run --allow-unknown-hook-name mywrapper-start-tests \ # providing something to stdin --stdin some-tempfile-123 \ - # execute hooks in serial + # execute multiple hooks in parallel + --jobs 3 \ # plus some arguments of your own... -- \ --testname bar \ diff --git a/builtin/hook.c b/builtin/hook.c index c0585587e5..bea0668b47 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -8,7 +8,8 @@ #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ - 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" \ + "<hook-name> [-- <hook-args>]") #define BUILTIN_HOOK_LIST_USAGE \ N_("git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>") @@ -132,6 +133,8 @@ static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested <hook-name>")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), N_("file to read into hooks' stdin")), + OPT_UNSIGNED('j', "jobs", &opt.jobs, + N_("run up to <n> hooks simultaneously")), OPT_END(), }; int ret; diff --git a/hook.c b/hook.c index 25762b6c8d..c0b71322cf 100644 --- a/hook.c +++ b/hook.c @@ -568,6 +568,22 @@ static void merge_output_if_parallel(struct run_hooks_opt *options) options->stdout_to_stderr = 1; } +static void warn_non_parallel_hooks_override(unsigned int jobs, + struct string_list *hook_list) +{ + /* Don't warn for hooks running sequentially. */ + if (jobs == 1) + return; + + for (size_t i = 0; i < hook_list->nr; i++) { + struct hook *h = hook_list->items[i].util; + if (h->kind == HOOK_CONFIGURED && !h->parallel) + warning(_("hook '%s' is not marked as parallel=true, " + "running in parallel anyway due to -j%u"), + h->u.configured.friendly_name, jobs); + } +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, @@ -611,6 +627,7 @@ static unsigned int get_hook_jobs(struct repository *r, cleanup: merge_output_if_parallel(options); + warn_non_parallel_hooks_override(options->jobs, hook_list); return options->jobs; } diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 6e8b1ad588..a1734fd628 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -268,10 +268,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' ' ' test_hook_tty () { - cat >expect <<-\EOF - STDOUT TTY - STDERR TTY - EOF + expect_tty=$1 + shift + + if test "$expect_tty" != "no_tty"; then + cat >expect <<-\EOF + STDOUT TTY + STDERR TTY + EOF + else + cat >expect <<-\EOF + STDOUT NO TTY + STDERR NO TTY + EOF + fi test_when_finished "rm -rf repo" && git init repo && @@ -289,12 +299,21 @@ test_hook_tty () { test_cmp expect repo/actual } -test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' ' - test_hook_tty hook run pre-commit +test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' ' + # hooks running sequentially (-j1) are always connected to the tty for + # optimum real-time performance. + test_hook_tty tty hook run -j1 pre-commit +' + +test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' ' + # Hooks are not connected to the tty when run in parallel, instead they + # output to a pipe through which run-command collects and de-interlaces + # their outputs, which then gets passed either to the tty or a sideband. + test_hook_tty no_tty hook run -j2 pre-commit ' test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' ' - test_hook_tty commit -m"B.new" + test_hook_tty tty commit -m"B.new" ' test_expect_success 'git hook list orders by config order' ' @@ -709,6 +728,108 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s check_stdout_merged_to_stderr push-to-checkout ' +test_expect_success 'parallel hook output is not interleaved' ' + test_when_finished "rm -rf .git/hooks" && + + write_script .git/hooks/test-hook <<-EOF && + echo "Hook 1 Start" + sleep 1 + echo "Hook 1 End" + EOF + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "echo \"Hook 2 Start\"; sleep 2; echo \"Hook 2 End\"" && + test_config hook.hook-2.parallel true && + test_config hook.hook-3.event test-hook && + test_config hook.hook-3.command \ + "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" && + test_config hook.hook-3.parallel true && + + git hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel && + + # Verify Hook 1 output is grouped + sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out && + test_line_count = 2 hook1_out && + + # Verify Hook 2 output is grouped + sed -n "/Hook 2 Start/,/Hook 2 End/p" err.parallel >hook2_out && + test_line_count = 2 hook2_out && + + # Verify Hook 3 output is grouped + sed -n "/Hook 3 Start/,/Hook 3 End/p" err.parallel >hook3_out && + test_line_count = 2 hook3_out +' + +test_expect_success 'git hook run -j1 runs hooks in series' ' + test_when_finished "rm -rf .git/hooks" && + + test_config hook.series-1.event "test-hook" && + test_config hook.series-1.command "echo 1" --add && + test_config hook.series-2.event "test-hook" && + test_config hook.series-2.command "echo 2" --add && + + mkdir -p .git/hooks && + write_script .git/hooks/test-hook <<-EOF && + echo 3 + EOF + + cat >expected <<-\EOF && + 1 + 2 + 3 + EOF + + git hook run --allow-unknown-hook-name -j1 test-hook 2>actual && + test_cmp expected actual +' + +test_expect_success 'git hook run -j2 runs hooks in parallel' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_when_finished "rm -rf .git/hooks" && + + mkdir -p .git/hooks && + write_sentinel_hook .git/hooks/test-hook && + + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'git hook run -j2 overrides parallel=false' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # -j2 overrides parallel=false; hooks run in parallel with a warning. + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "true" && + # neither hook has parallel=true + + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err && + grep "hook .hook-1. is not marked as parallel=true" err && + grep "hook .hook-2. is not marked as parallel=true" err +' + test_expect_success 'hook.jobs=1 config runs hooks in series' ' test_when_finished "rm -f sentinel.started sentinel.done hook.order" && -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v5 07/12] hook: add -j/--jobs option to git hook run 2026-03-26 10:18 ` [PATCH v5 07/12] hook: add -j/--jobs option to git hook run Adrian Ratiu @ 2026-03-27 14:46 ` Patrick Steinhardt 0 siblings, 0 replies; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-27 14:46 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Ævar Arnfjörð Bjarmason On Thu, Mar 26, 2026 at 12:18:14PM +0200, Adrian Ratiu wrote: > diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc > index 318c637bd8..46ea52db55 100644 > --- a/Documentation/git-hook.adoc > +++ b/Documentation/git-hook.adoc > @@ -147,6 +148,23 @@ OPTIONS > mirroring the output style of `git config --show-scope`. Traditional > hooks from the hookdir are unaffected. > > +-j:: > +--jobs:: > + Only valid for `run`. > ++ > +Specify how many hooks to run simultaneously. If this flag is not specified, > +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If > +neither is specified, defaults to 1 (serial execution). > ++ > +When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel` > +setting, allowing all hooks for the event to run concurrently, even if they > +are not individually marked as parallel. > ++ > +Some hooks always run sequentially regardless of this flag or the > +`hook.jobs` config, because git knows they cannot safely run in parallel: > +`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`, > +`post-commit`, `post-checkout`, and `push-to-checkout`. > + > WRAPPERS > -------- > Great, this is now where we explicitly call out that "-j" overrides the configuration. Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v5 08/12] hook: add per-event jobs config 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (6 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 07/12] hook: add -j/--jobs option to git hook run Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 09/12] hook: warn when hook.<friendly-name>.jobs is set Adrian Ratiu ` (3 subsequent siblings) 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Add a hook.<event>.jobs count config that allows users to override the global hook.jobs setting for specific hook events. This allows finer-grained control over parallelism on a per-event basis. For example, to run `post-receive` hooks with up to 4 parallel jobs while keeping other events at their global default: [hook] post-receive.jobs = 4 Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 19 +++++++++++ hook.c | 46 +++++++++++++++++++++++--- repository.c | 1 + repository.h | 3 ++ t/t1800-hook.sh | 59 ++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 5 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 6f60775c28..d4fa29d936 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -33,9 +33,28 @@ hook.<friendly-name>.parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. +hook.<event>.jobs:: + Specifies how many hooks can be run simultaneously for the `<event>` + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` + for this specific event. The same parallelism restrictions apply: this + setting has no effect unless all configured hooks for the event have + `hook.<friendly-name>.parallel` set to `true`. Must be a positive int, + zero is rejected with a warning. See linkgit:git-hook[1]. ++ +Note on naming: although this key resembles `hook.<friendly-name>.*` +(a per-hook setting), `<event>` must be the event name, not a hook +friendly name. The key component is stored literally and looked up by +event name at runtime with no translation between the two namespaces. +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the +lookup at runtime uses the event name (e.g. `"post-receive"`), so +`hook.my-hook.jobs` is silently ignored even when `my-hook` is +registered for that event. Use `hook.post-receive.jobs` or any other +valid event name when setting `hook.<event>.jobs`. + hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Can be overridden on a per-event basis with `hook.<event>.jobs`. Some hooks always run sequentially regardless of this setting because they operate on shared data and cannot safely be parallelized: + diff --git a/hook.c b/hook.c index c0b71322cf..d98b011563 100644 --- a/hook.c +++ b/hook.c @@ -125,6 +125,7 @@ struct hook_config_cache_entry { * event_hooks: event-name to list of friendly-names map. * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. * parallel_hooks: friendly-name to parallel flag. + * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). */ struct hook_all_config_cb { @@ -132,6 +133,7 @@ struct hook_all_config_cb { struct strmap event_hooks; struct string_list disabled_hooks; struct strmap parallel_hooks; + struct strmap event_jobs; unsigned int jobs; }; @@ -231,6 +233,18 @@ static int hook_config_lookup_all(const char *key, const char *value, warning(_("hook.%s.parallel must be a boolean," " ignoring: '%s'"), hook_name, value); + } else if (!strcmp(subkey, "jobs")) { + unsigned int v; + if (!git_parse_uint(value, &v)) + warning(_("hook.%s.jobs must be a positive integer," + " ignoring: '%s'"), + hook_name, value); + else if (!v) + warning(_("hook.%s.jobs must be positive," + " ignoring: 0"), hook_name); + else + strmap_put(&data->event_jobs, hook_name, + (void *)(uintptr_t)v); } free(hook_name); @@ -276,6 +290,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_init(&cb_data.event_hooks); string_list_init_dup(&cb_data.disabled_hooks); strmap_init(&cb_data.parallel_hooks); + strmap_init(&cb_data.event_jobs); /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); @@ -323,8 +338,10 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) strmap_put(cache, e->key, hooks); } - if (r) + if (r) { r->hook_jobs = cb_data.jobs; + r->event_jobs = cb_data.event_jobs; + } strmap_clear(&cb_data.commands, 1); strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */ @@ -587,6 +604,7 @@ static void warn_non_parallel_hooks_override(unsigned int jobs, /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, + const char *hook_name, struct string_list *hook_list) { /* @@ -606,16 +624,34 @@ static unsigned int get_hook_jobs(struct repository *r, */ options->jobs = 1; if (r) { - if (r->gitdir && r->hook_config_cache && r->hook_jobs) - options->jobs = r->hook_jobs; - else + if (r->gitdir && r->hook_config_cache) { + void *event_jobs; + + if (r->hook_jobs) + options->jobs = r->hook_jobs; + + event_jobs = strmap_get(&r->event_jobs, hook_name); + if (event_jobs) + options->jobs = (unsigned int)(uintptr_t)event_jobs; + } else { + unsigned int event_jobs; + char *key; + repo_config_get_uint(r, "hook.jobs", &options->jobs); + + key = xstrfmt("hook.%s.jobs", hook_name); + if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) + options->jobs = event_jobs; + free(key); + } } /* * Cap to serial any configured hook not marked as parallel = true. * This enforces the parallel = false default, even for "traditional" * hooks from the hookdir which cannot be marked parallel = true. + * The same restriction applies whether jobs came from hook.jobs or + * hook.<event>.jobs. */ for (size_t i = 0; i < hook_list->nr; i++) { struct hook *h = hook_list->items[i].util; @@ -642,7 +678,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name, .options = options, }; int ret = 0; - unsigned int jobs = get_hook_jobs(r, options, hook_list); + unsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list); const struct run_process_parallel_opts opts = { .tr2_category = "hook", .tr2_label = hook_name, diff --git a/repository.c b/repository.c index fb4356ca55..ff3c357dfc 100644 --- a/repository.c +++ b/repository.c @@ -425,6 +425,7 @@ void repo_clear(struct repository *repo) hook_cache_clear(repo->hook_config_cache); FREE_AND_NULL(repo->hook_config_cache); } + strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */ if (repo->promisor_remote_config) { promisor_remote_clear(repo->promisor_remote_config); diff --git a/repository.h b/repository.h index 58e46853d0..6b67ec02e2 100644 --- a/repository.h +++ b/repository.h @@ -175,6 +175,9 @@ struct repository { /* Cached value of hook.jobs config (0 if unset, defaults to serial). */ unsigned int hook_jobs; + /* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */ + struct strmap event_jobs; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index a1734fd628..ab2b52bec6 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -969,4 +969,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' test_cmp expect hook.order ' +test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=1 (serial), but per-event override allows parallel. + test_config hook.jobs 1 && + test_config hook.test-hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo parallel >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + test_config hook.hook-1.parallel true && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + test_config hook.hook-2.parallel true && + + # Global hook.jobs=4 allows parallel, but per-event override forces serial. + test_config hook.jobs 4 && + test_config hook.test-hook.jobs 1 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + +test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' ' + test_when_finished "rm -f sentinel.started sentinel.done hook.order" && + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command \ + "touch sentinel.started; sleep 2; touch sentinel.done" && + # hook-1 intentionally has no parallel=true + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command \ + "$(sentinel_detector sentinel hook.order)" && + # hook-2 also has no parallel=true + + # Per-event jobs=2 but no hook has parallel=true: must still run serially. + test_config hook.test-hook.jobs 2 && + + git hook run --allow-unknown-hook-name test-hook >out 2>err && + echo serial >expect && + test_cmp expect hook.order +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v5 09/12] hook: warn when hook.<friendly-name>.jobs is set 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (7 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 08/12] hook: add per-event jobs config Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-27 14:46 ` Patrick Steinhardt 2026-03-26 10:18 ` [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use Adrian Ratiu ` (2 subsequent siblings) 11 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Issue a warning when the user confuses the hook process and event namespaces by setting hook.<friendly-name>.jobs. Detect this by checking whether the name carrying .jobs also has .command, .event, or .parallel configured. Extract is_friendly_name() as a helper for this check, to be reused by future per-event config handling. Suggested-by: Junio C Hamano <gitster@pobox.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- hook.c | 40 ++++++++++++++++++++++++++++++++++++++++ t/t1800-hook.sh | 30 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/hook.c b/hook.c index d98b011563..0493993bbe 100644 --- a/hook.c +++ b/hook.c @@ -279,6 +279,44 @@ void hook_cache_clear(struct strmap *cache) strmap_clear(cache, 0); } +/* + * Return true if `name` is a hook friendly-name, i.e. it has at least one of + * .command, .event, or .parallel configured. These are the reliable clues + * that distinguish a friendly-name from an event name. Note: .enabled is + * deliberately excluded because it can appear under both namespaces. + */ +static int is_friendly_name(struct hook_all_config_cb *cb, const char *name) +{ + struct hashmap_iter iter; + struct strmap_entry *e; + + if (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name)) + return 1; + + strmap_for_each_entry(&cb->event_hooks, &iter, e) { + if (unsorted_string_list_lookup(e->value, name)) + return 1; + } + + return 0; +} + +/* Warn if any name in event_jobs is also a hook friendly-name. */ +static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data) +{ + struct hashmap_iter iter; + struct strmap_entry *e; + + strmap_for_each_entry(&cb_data->event_jobs, &iter, e) { + if (is_friendly_name(cb_data, e->key)) + warning(_("hook.%s.jobs is set but '%s' looks like a " + "hook friendly-name, not an event name; " + "hook.<event>.jobs uses the event name " + "(e.g. hook.post-receive.jobs), so this " + "setting will be ignored"), e->key, e->key); + } +} + /* Populate `cache` with the complete hook configuration */ static void build_hook_config_map(struct repository *r, struct strmap *cache) { @@ -295,6 +333,8 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) /* Parse all configs in one run, capturing hook.* including hook.jobs. */ repo_config(r, hook_config_lookup_all, &cb_data); + warn_jobs_on_friendly_names(&cb_data); + /* Construct the cache from parsed configs. */ strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { struct string_list *hook_names = e->value; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index ab2b52bec6..85b055a897 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -1028,4 +1028,34 @@ test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' test_cmp expect hook.order ' +test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' ' + test_config hook.my-hook.command "true" && + test_config hook.my-hook.jobs 2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook.<friendly-name>.jobs warns when name has .event' ' + test_config hook.my-hook.event test-hook && + test_config hook.my-hook.command "true" && + test_config hook.my-hook.jobs 2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook.<friendly-name>.jobs warns when name has .parallel' ' + test_config hook.my-hook.event test-hook && + test_config hook.my-hook.command "true" && + test_config hook.my-hook.parallel true && + test_config hook.my-hook.jobs 2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.my-hook.jobs.*friendly-name" err +' + +test_expect_success 'hook.<event>.jobs does not warn for a real event name' ' + test_config hook.test-hook.jobs 2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep ! "friendly-name" err +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v5 09/12] hook: warn when hook.<friendly-name>.jobs is set 2026-03-26 10:18 ` [PATCH v5 09/12] hook: warn when hook.<friendly-name>.jobs is set Adrian Ratiu @ 2026-03-27 14:46 ` Patrick Steinhardt 0 siblings, 0 replies; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-27 14:46 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Thu, Mar 26, 2026 at 12:18:16PM +0200, Adrian Ratiu wrote: > diff --git a/hook.c b/hook.c > index d98b011563..0493993bbe 100644 > --- a/hook.c > +++ b/hook.c > @@ -279,6 +279,44 @@ void hook_cache_clear(struct strmap *cache) > strmap_clear(cache, 0); > } > > +/* > + * Return true if `name` is a hook friendly-name, i.e. it has at least one of > + * .command, .event, or .parallel configured. These are the reliable clues > + * that distinguish a friendly-name from an event name. Note: .enabled is > + * deliberately excluded because it can appear under both namespaces. > + */ > +static int is_friendly_name(struct hook_all_config_cb *cb, const char *name) > +{ > + struct hashmap_iter iter; > + struct strmap_entry *e; > + > + if (strmap_get(&cb->commands, name) || strmap_get(&cb->parallel_hooks, name)) > + return 1; > + > + strmap_for_each_entry(&cb->event_hooks, &iter, e) { > + if (unsorted_string_list_lookup(e->value, name)) > + return 1; > + } > + > + return 0; > +} > + > +/* Warn if any name in event_jobs is also a hook friendly-name. */ > +static void warn_jobs_on_friendly_names(struct hook_all_config_cb *cb_data) > +{ > + struct hashmap_iter iter; > + struct strmap_entry *e; > + > + strmap_for_each_entry(&cb_data->event_jobs, &iter, e) { > + if (is_friendly_name(cb_data, e->key)) > + warning(_("hook.%s.jobs is set but '%s' looks like a " > + "hook friendly-name, not an event name; " > + "hook.<event>.jobs uses the event name " > + "(e.g. hook.post-receive.jobs), so this " > + "setting will be ignored"), e->key, e->key); > + } > +} Makes sense. The bigger question of course is whether we should properly separate those namespaces, so that this confusion cannot even happen in the first place. I won't push for such a change though. Patrick ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (8 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 09/12] hook: warn when hook.<friendly-name>.jobs is set Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-27 14:46 ` Patrick Steinhardt 2026-03-26 10:18 ` [PATCH v5 11/12] hook: add hook.<event>.enabled switch Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 12/12] hook: allow hook.jobs=-1 to use all available CPU cores Adrian Ratiu 11 siblings, 1 reply; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Move is_known_hook() from builtin/hook.c (static) into hook.c and export it via hook.h so it can be reused. Make it return bool and the iterator `h` for clarity (iterate hooks). The next commit will use this to reject hook friendly-names that collide with known event names. Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Makefile | 2 +- builtin/hook.c | 10 ---------- hook.c | 10 ++++++++++ hook.h | 6 ++++++ 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 6d64431219..4b3a7bdce5 100644 --- a/Makefile +++ b/Makefile @@ -2673,7 +2673,7 @@ git$X: git.o GIT-LDFLAGS $(BUILTIN_OBJS) $(GITLIBS) help.sp help.s help.o: command-list.h builtin/bugreport.sp builtin/bugreport.s builtin/bugreport.o: hook-list.h -builtin/hook.sp builtin/hook.s builtin/hook.o: hook-list.h +hook.sp hook.s hook.o: hook-list.h builtin/help.sp builtin/help.s builtin/help.o: config-list.h GIT-PREFIX builtin/help.sp builtin/help.s builtin/help.o: EXTRA_CPPFLAGS = \ diff --git a/builtin/hook.c b/builtin/hook.c index bea0668b47..1839412dca 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -4,7 +4,6 @@ #include "environment.h" #include "gettext.h" #include "hook.h" -#include "hook-list.h" #include "parse-options.h" #define BUILTIN_HOOK_RUN_USAGE \ @@ -13,15 +12,6 @@ #define BUILTIN_HOOK_LIST_USAGE \ N_("git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>") -static int is_known_hook(const char *name) -{ - const char **p; - for (p = hook_name_list; *p; p++) - if (!strcmp(*p, name)) - return 1; - return 0; -} - static const char * const builtin_hook_usage[] = { BUILTIN_HOOK_RUN_USAGE, BUILTIN_HOOK_LIST_USAGE, diff --git a/hook.c b/hook.c index 0493993bbe..19076f8f2b 100644 --- a/hook.c +++ b/hook.c @@ -5,6 +5,7 @@ #include "environment.h" #include "gettext.h" #include "hook.h" +#include "hook-list.h" #include "parse.h" #include "path.h" #include "run-command.h" @@ -12,6 +13,15 @@ #include "strbuf.h" #include "strmap.h" +bool is_known_hook(const char *name) +{ + const char **h; + for (h = hook_name_list; *h; h++) + if (!strcmp(*h, name)) + return true; + return false; +} + const char *find_hook(struct repository *r, const char *name) { static struct strbuf path = STRBUF_INIT; diff --git a/hook.h b/hook.h index 01db4226a6..5a93f56618 100644 --- a/hook.h +++ b/hook.h @@ -234,6 +234,12 @@ void hook_free(void *p, const char *str); */ void hook_cache_clear(struct strmap *cache); +/** + * Returns true if `name` is a recognized hook event name + * (e.g. "pre-commit", "post-receive"). + */ +bool is_known_hook(const char *name); + /** * Returns the path to the hook file, or NULL if the hook is missing * or disabled. Note that this points to static storage that will be -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use 2026-03-26 10:18 ` [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use Adrian Ratiu @ 2026-03-27 14:46 ` Patrick Steinhardt 2026-03-27 15:59 ` Adrian Ratiu 0 siblings, 1 reply; 83+ messages in thread From: Patrick Steinhardt @ 2026-03-27 14:46 UTC (permalink / raw) To: Adrian Ratiu Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Thu, Mar 26, 2026 at 12:18:17PM +0200, Adrian Ratiu wrote: > Move is_known_hook() from builtin/hook.c (static) into hook.c and > export it via hook.h so it can be reused. > > Make it return bool and the iterator `h` for clarity (iterate hooks). > > The next commit will use this to reject hook friendly-names that > collide with known event names. > > Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> > --- > Makefile | 2 +- > builtin/hook.c | 10 ---------- > hook.c | 10 ++++++++++ > hook.h | 6 ++++++ > 4 files changed, 17 insertions(+), 11 deletions(-) This needs to also be changed in Meson now, as we're adding "hook-list.h" as a dependency for the builtin sources, not for the libgit sources. Something like the below patch. Patrick diff --git a/meson.build b/meson.build index 1b0e431d5f..2536ea80ae 100644 --- a/meson.build +++ b/meson.build @@ -560,6 +560,18 @@ libgit_sources += custom_target( env: script_environment, ) +libgit_sources += custom_target( + input: 'Documentation/githooks.adoc', + output: 'hook-list.h', + command: [ + shell, + meson.current_source_dir() + '/generate-hooklist.sh', + meson.current_source_dir(), + '@OUTPUT@', + ], + env: script_environment, +) + builtin_sources = [ 'builtin/add.c', 'builtin/am.c', @@ -736,18 +748,6 @@ builtin_sources += custom_target( env: script_environment, ) -builtin_sources += custom_target( - input: 'Documentation/githooks.adoc', - output: 'hook-list.h', - command: [ - shell, - meson.current_source_dir() + '/generate-hooklist.sh', - meson.current_source_dir(), - '@OUTPUT@', - ], - env: script_environment, -) - # This contains the variables for GIT-BUILD-OPTIONS, which we use to propagate # build options to our tests. build_options_config = configuration_data() ^ permalink raw reply related [flat|nested] 83+ messages in thread
* Re: [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use 2026-03-27 14:46 ` Patrick Steinhardt @ 2026-03-27 15:59 ` Adrian Ratiu 0 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-27 15:59 UTC (permalink / raw) To: Patrick Steinhardt Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson On Fri, 27 Mar 2026, Patrick Steinhardt <ps@pks.im> wrote: > On Thu, Mar 26, 2026 at 12:18:17PM +0200, Adrian Ratiu wrote: >> Move is_known_hook() from builtin/hook.c (static) into hook.c and >> export it via hook.h so it can be reused. >> >> Make it return bool and the iterator `h` for clarity (iterate hooks). >> >> The next commit will use this to reject hook friendly-names that >> collide with known event names. >> >> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> >> --- >> Makefile | 2 +- >> builtin/hook.c | 10 ---------- >> hook.c | 10 ++++++++++ >> hook.h | 6 ++++++ >> 4 files changed, 17 insertions(+), 11 deletions(-) > > This needs to also be changed in Meson now, as we're adding > "hook-list.h" as a dependency for the builtin sources, not for the > libgit sources. Something like the below patch. Nice catch, yes, though we might need the generated .h both for the builtin and libgit sources. Will double check this. I also wonder why meson does not fail ... Likely it's because the builtins are built before libgit. (the makefile did fail, because I didn't move that dep there as well initially) > diff --git a/meson.build b/meson.build > index 1b0e431d5f..2536ea80ae 100644 > --- a/meson.build > +++ b/meson.build > @@ -560,6 +560,18 @@ libgit_sources += custom_target( > env: script_environment, > ) > > +libgit_sources += custom_target( > + input: 'Documentation/githooks.adoc', > + output: 'hook-list.h', > + command: [ > + shell, > + meson.current_source_dir() + '/generate-hooklist.sh', > + meson.current_source_dir(), > + '@OUTPUT@', > + ], > + env: script_environment, > +) > + > builtin_sources = [ > 'builtin/add.c', > 'builtin/am.c', > @@ -736,18 +748,6 @@ builtin_sources += custom_target( > env: script_environment, > ) > > -builtin_sources += custom_target( > - input: 'Documentation/githooks.adoc', > - output: 'hook-list.h', > - command: [ > - shell, > - meson.current_source_dir() + '/generate-hooklist.sh', > - meson.current_source_dir(), > - '@OUTPUT@', > - ], > - env: script_environment, > -) > - > # This contains the variables for GIT-BUILD-OPTIONS, which we use to propagate > # build options to our tests. > build_options_config = configuration_data() Thanks for the patch, will do something similar in the next re-roll and credit you. Will wait about 1 week in case there is more feedback. ^ permalink raw reply [flat|nested] 83+ messages in thread
* [PATCH v5 11/12] hook: add hook.<event>.enabled switch 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (9 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 12/12] hook: allow hook.jobs=-1 to use all available CPU cores Adrian Ratiu 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Add a hook.<event>.enabled config key that disables all hooks for a given event, when set to false, acting as a high-level switch above the existing per-hook hook.<friendly-name>.enabled. Event-disabled hooks are shown in "git hook list" with an "event-disabled" tab-separated prefix before the name: $ git hook list test-hook event-disabled hook-1 event-disabled hook-2 With --show-scope: $ git hook list --show-scope test-hook local event-disabled hook-1 When a hook is both per-hook disabled and event-disabled, only "event-disabled" is shown: the event-level switch is the more relevant piece of information, and the per-hook "disabled" status will surface once the event is re-enabled. Using an event name as a friendly-name (e.g. hook.<event>.enabled) can cause ambiguity, so a fatal error is issued when using a known event name and a warning is issued for unknown event name, since a collision cannot be detected with certainty for unknown events. Suggested-by: Patrick Steinhardt <ps@pks.im> Suggested-by: Junio C Hamano <gitster@pobox.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 20 ++++++++ builtin/hook.c | 20 +++++--- hook.c | 47 +++++++++++++++++-- hook.h | 1 + repository.c | 1 + repository.h | 4 ++ t/t1800-hook.sh | 83 ++++++++++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 11 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index d4fa29d936..e0db3afa19 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -15,6 +15,12 @@ hook.<friendly-name>.event:: events, specify the key more than once. An empty value resets the list of events, clearing any previously defined events for `hook.<friendly-name>`. See linkgit:git-hook[1]. ++ +The `<friendly-name>` must not be the same as a known hook event name +(e.g. do not use `hook.pre-commit.event`). Using a known event name as +a friendly-name is a fatal error because it creates an ambiguity with +`hook.<event>.enabled` and `hook.<event>.jobs`. For unknown event names, +a warning is issued when `<friendly-name>` matches the event value. hook.<friendly-name>.enabled:: Whether the hook `hook.<friendly-name>` is enabled. Defaults to `true`. @@ -33,6 +39,20 @@ hook.<friendly-name>.parallel:: found in the hooks directory do not need to, and run in parallel when the effective job count is greater than 1. See linkgit:git-hook[1]. +hook.<event>.enabled:: + Switch to enable or disable all hooks for the `<event>` hook event. + When set to `false`, no hooks fire for that event, regardless of any + per-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`. + See linkgit:git-hook[1]. ++ +Note on naming: `<event>` must be the event name (e.g. `pre-commit`), +not a hook friendly-name. Since using a known event name as a +friendly-name is disallowed (see `hook.<friendly-name>.event` above), +there is no ambiguity between event-level and per-hook `.enabled` +settings for known events. For unknown events, if a friendly-name +matches the event name despite the warning, `.enabled` is treated +as per-hook only. + hook.<event>.jobs:: Specifies how many hooks can be run simultaneously for the `<event>` hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` diff --git a/builtin/hook.c b/builtin/hook.c index 1839412dca..8e47e22e2a 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -87,14 +87,22 @@ static int list(int argc, const char **argv, const char *prefix, const char *name = h->u.configured.friendly_name; const char *scope = show_scope ? config_scope_name(h->u.configured.scope) : NULL; + /* + * Show the most relevant disable reason. Event-level + * takes precedence: if the whole event is off, that + * is what the user needs to know. The per-hook + * "disabled" surfaces once the event is re-enabled. + */ + const char *disability = + h->u.configured.event_disabled ? "event-disabled\t" : + h->u.configured.disabled ? "disabled\t" : + ""; if (scope) - printf("%s\t%s%s%c", scope, - h->u.configured.disabled ? "disabled\t" : "", - name, line_terminator); + printf("%s\t%s%s%c", scope, disability, name, + line_terminator); else - printf("%s%s%c", - h->u.configured.disabled ? "disabled\t" : "", - name, line_terminator); + printf("%s%s%c", disability, name, + line_terminator); break; } default: diff --git a/hook.c b/hook.c index 19076f8f2b..bc990d4ed4 100644 --- a/hook.c +++ b/hook.c @@ -133,7 +133,9 @@ struct hook_config_cache_entry { * Callback struct to collect all hook.* keys in a single config pass. * commands: friendly-name to command map. * event_hooks: event-name to list of friendly-names map. - * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false. + * disabled_hooks: set of all names with hook.<name>.enabled = false; after + * parsing, names that are not friendly-names become event-level + * disables stored in r->disabled_events. This collects all. * parallel_hooks: friendly-name to parallel flag. * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset). * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs). @@ -189,8 +191,21 @@ static int hook_config_lookup_all(const char *key, const char *value, strmap_for_each_entry(&data->event_hooks, &iter, e) unsorted_string_list_remove(e->value, hook_name, 0); } else { - struct string_list *hooks = - strmap_get(&data->event_hooks, value); + struct string_list *hooks; + + if (is_known_hook(hook_name)) + die(_("hook friendly-name '%s' collides with " + "a known event name; please choose a " + "different friendly-name"), + hook_name); + + if (!strcmp(hook_name, value)) + warning(_("hook friendly-name '%s' is the " + "same as its event; this may cause " + "ambiguity with hook.%s.enabled"), + hook_name, hook_name); + + hooks = strmap_get(&data->event_hooks, value); if (!hooks) { CALLOC_ARRAY(hooks, 1); @@ -345,6 +360,22 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache) warn_jobs_on_friendly_names(&cb_data); + /* + * Populate disabled_events: names in disabled_hooks that are not + * friendly-names are event-level switches (hook.<event>.enabled = false). + * Names that are friendly-names are already handled per-hook via the + * hook_config_cache_entry.disabled flag below. + */ + if (r) { + string_list_clear(&r->disabled_events, 0); + string_list_init_dup(&r->disabled_events); + for (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) { + const char *n = cb_data.disabled_hooks.items[i].string; + if (!is_friendly_name(&cb_data, n)) + string_list_append(&r->disabled_events, n); + } + } + /* Construct the cache from parsed configs. */ strmap_for_each_entry(&cb_data.event_hooks, &iter, e) { struct string_list *hook_names = e->value; @@ -446,6 +477,8 @@ static void list_hooks_add_configured(struct repository *r, { struct strmap *cache = get_hook_config_cache(r); struct string_list *configured_hooks = strmap_get(cache, hookname); + bool event_is_disabled = r ? !!unsorted_string_list_lookup(&r->disabled_events, + hookname) : 0; /* Iterate through configured hooks and initialize internal states */ for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) { @@ -472,6 +505,7 @@ static void list_hooks_add_configured(struct repository *r, entry->command ? xstrdup(entry->command) : NULL; hook->u.configured.scope = entry->scope; hook->u.configured.disabled = entry->disabled; + hook->u.configured.event_disabled = event_is_disabled; hook->parallel = entry->parallel; string_list_append(list, friendly_name)->util = hook; @@ -484,6 +518,8 @@ static void list_hooks_add_configured(struct repository *r, if (!r || !r->gitdir) { hook_cache_clear(cache); free(cache); + if (r) + string_list_clear(&r->disabled_events, 0); } } @@ -515,7 +551,7 @@ int hook_exists(struct repository *r, const char *name) for (size_t i = 0; i < hooks->nr; i++) { struct hook *h = hooks->items[i].util; if (h->kind == HOOK_TRADITIONAL || - !h->u.configured.disabled) { + (!h->u.configured.disabled && !h->u.configured.event_disabled)) { exists = 1; break; } @@ -538,7 +574,8 @@ static int pick_next_hook(struct child_process *cp, if (hook_cb->hook_to_run_index >= hook_list->nr) return 0; h = hook_list->items[hook_cb->hook_to_run_index++].util; - } while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled); + } while (h->kind == HOOK_CONFIGURED && + (h->u.configured.disabled || h->u.configured.event_disabled)); cp->no_stdin = 1; strvec_pushv(&cp->env, hook_cb->options->env.v); diff --git a/hook.h b/hook.h index 5a93f56618..b4372b636f 100644 --- a/hook.h +++ b/hook.h @@ -32,6 +32,7 @@ struct hook { const char *command; enum config_scope scope; bool disabled; + bool event_disabled; } configured; } u; diff --git a/repository.c b/repository.c index ff3c357dfc..983febd277 100644 --- a/repository.c +++ b/repository.c @@ -426,6 +426,7 @@ void repo_clear(struct repository *repo) FREE_AND_NULL(repo->hook_config_cache); } strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */ + string_list_clear(&repo->disabled_events, 0); if (repo->promisor_remote_config) { promisor_remote_clear(repo->promisor_remote_config); diff --git a/repository.h b/repository.h index 6b67ec02e2..4969d8b8eb 100644 --- a/repository.h +++ b/repository.h @@ -2,6 +2,7 @@ #define REPOSITORY_H #include "strmap.h" +#include "string-list.h" #include "repo-settings.h" #include "environment.h" @@ -178,6 +179,9 @@ struct repository { /* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */ struct strmap event_jobs; + /* Cached list of event names with hook.<event>.enabled = false. */ + struct string_list disabled_events; + /* Configurations related to promisor remotes. */ char *repository_format_partial_clone; struct promisor_remote_config *promisor_remote_config; diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 85b055a897..273588e4d4 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -1058,4 +1058,87 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' ' test_grep ! "friendly-name" err ' +test_expect_success 'hook.<event>.enabled=false skips all hooks for event' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_must_be_empty out +' + +test_expect_success 'hook.<event>.enabled=true does not suppress hooks' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled true && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep "ran" err +' + +test_expect_success 'hook.<event>.enabled=false does not affect other events' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.other-event.enabled false && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep "ran" err +' + +test_expect_success 'hook.<friendly-name>.enabled=false still disables that hook' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo hook-1" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "echo hook-2" && + test_config hook.hook-1.enabled false && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep ! "hook-1" err && + test_grep "hook-2" err +' + +test_expect_success 'git hook list shows event-disabled hooks as event-disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.hook-2.event test-hook && + test_config hook.hook-2.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list --allow-unknown-hook-name test-hook >actual && + test_grep "^event-disabled hook-1$" actual && + test_grep "^event-disabled hook-2$" actual +' + +test_expect_success 'git hook list shows scope with event-disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list --allow-unknown-hook-name --show-scope test-hook >actual && + test_grep "^local event-disabled hook-1$" actual +' + +test_expect_success 'git hook list still shows hooks when event is disabled' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "echo ran" && + test_config hook.test-hook.enabled false && + git hook list --allow-unknown-hook-name test-hook >actual && + test_grep "event-disabled" actual +' + +test_expect_success 'friendly-name matching known event name is rejected' ' + test_config hook.pre-commit.event pre-commit && + test_config hook.pre-commit.command "echo oops" && + test_must_fail git hook run pre-commit 2>err && + test_grep "collides with a known event name" err +' + +test_expect_success 'friendly-name matching known event name is rejected even for different event' ' + test_config hook.pre-commit.event post-commit && + test_config hook.pre-commit.command "echo oops" && + test_must_fail git hook run post-commit 2>err && + test_grep "collides with a known event name" err +' + +test_expect_success 'friendly-name matching unknown event warns' ' + test_config hook.test-hook.event test-hook && + test_config hook.test-hook.command "echo ran" && + git hook run --allow-unknown-hook-name test-hook >out 2>err && + test_grep "same as its event" err +' + test_done -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
* [PATCH v5 12/12] hook: allow hook.jobs=-1 to use all available CPU cores 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu ` (10 preceding siblings ...) 2026-03-26 10:18 ` [PATCH v5 11/12] hook: add hook.<event>.enabled switch Adrian Ratiu @ 2026-03-26 10:18 ` Adrian Ratiu 11 siblings, 0 replies; 83+ messages in thread From: Adrian Ratiu @ 2026-03-26 10:18 UTC (permalink / raw) To: git Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt, Josh Steadmon, Kristoffer Haugsbakk, brian m . carlson, Adrian Ratiu Allow -1 as a value for hook.jobs, hook.<event>.jobs, and the -j CLI flag to mean "use as many jobs as there are CPU cores", matching the convention used by fetch.parallel and other Git subsystems. The value is resolved to online_cpus() at parse time so the rest of the code always works with a positive resolved count. Other non-positive values (0, -2, etc) are rejected with a warning (config) or die (CLI). Suggested-by: Patrick Steinhardt <ps@pks.im> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> --- Documentation/config/hook.adoc | 4 ++- builtin/hook.c | 15 +++++++-- hook.c | 60 ++++++++++++++++++++++++---------- t/t1800-hook.sh | 49 +++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index e0db3afa19..a9dc0063c1 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -58,7 +58,8 @@ hook.<event>.jobs:: hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs` for this specific event. The same parallelism restrictions apply: this setting has no effect unless all configured hooks for the event have - `hook.<friendly-name>.parallel` set to `true`. Must be a positive int, + `hook.<friendly-name>.parallel` set to `true`. Set to `-1` to use the + number of available CPU cores. Must be a positive integer or `-1`; zero is rejected with a warning. See linkgit:git-hook[1]. + Note on naming: although this key resembles `hook.<friendly-name>.*` @@ -74,6 +75,7 @@ valid event name when setting `hook.<event>.jobs`. hook.jobs:: Specifies how many hooks can be run simultaneously during parallelized hook execution. If unspecified, defaults to 1 (serial execution). + Set to `-1` to use the number of available CPU cores. Can be overridden on a per-event basis with `hook.<event>.jobs`. Some hooks always run sequentially regardless of this setting because they operate on shared data and cannot safely be parallelized: diff --git a/builtin/hook.c b/builtin/hook.c index 8e47e22e2a..cceeb3586e 100644 --- a/builtin/hook.c +++ b/builtin/hook.c @@ -5,6 +5,7 @@ #include "gettext.h" #include "hook.h" #include "parse-options.h" +#include "thread-utils.h" #define BUILTIN_HOOK_RUN_USAGE \ N_("git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n" \ @@ -123,6 +124,7 @@ static int run(int argc, const char **argv, const char *prefix, struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT; int ignore_missing = 0; int allow_unknown = 0; + int jobs = 0; const char *hook_name; struct option run_options[] = { OPT_BOOL(0, "allow-unknown-hook-name", &allow_unknown, @@ -131,8 +133,8 @@ static int run(int argc, const char **argv, const char *prefix, N_("silently ignore missing requested <hook-name>")), OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"), N_("file to read into hooks' stdin")), - OPT_UNSIGNED('j', "jobs", &opt.jobs, - N_("run up to <n> hooks simultaneously")), + OPT_INTEGER('j', "jobs", &jobs, + N_("run up to <n> hooks simultaneously (-1 for CPU count)")), OPT_END(), }; int ret; @@ -141,6 +143,15 @@ static int run(int argc, const char **argv, const char *prefix, builtin_hook_run_usage, PARSE_OPT_KEEP_DASHDASH); + if (jobs == -1) + opt.jobs = online_cpus(); + else if (jobs < 0) + die(_("invalid value for -j: %d" + " (use -1 for CPU count or a" + " positive integer)"), jobs); + else + opt.jobs = jobs; + if (!argc) goto usage; diff --git a/hook.c b/hook.c index bc990d4ed4..d10eef4763 100644 --- a/hook.c +++ b/hook.c @@ -12,6 +12,7 @@ #include "setup.h" #include "strbuf.h" #include "strmap.h" +#include "thread-utils.h" bool is_known_hook(const char *name) { @@ -165,13 +166,17 @@ static int hook_config_lookup_all(const char *key, const char *value, /* Handle plain hook.<key> entries that have no hook name component. */ if (!name) { if (!strcmp(subkey, "jobs") && value) { - unsigned int v; - if (!git_parse_uint(value, &v)) - warning(_("hook.jobs must be a positive integer, ignoring: '%s'"), value); - else if (!v) - warning(_("hook.jobs must be positive, ignoring: 0")); - else + int v; + if (!git_parse_int(value, &v)) + warning(_("hook.jobs must be an integer, ignoring: '%s'"), value); + else if (v == -1) + data->jobs = online_cpus(); + else if (v > 0) data->jobs = v; + else + warning(_("hook.jobs must be a positive integer" + " or -1, ignoring: '%s'"), + value); } return 0; } @@ -259,17 +264,21 @@ static int hook_config_lookup_all(const char *key, const char *value, " ignoring: '%s'"), hook_name, value); } else if (!strcmp(subkey, "jobs")) { - unsigned int v; - if (!git_parse_uint(value, &v)) - warning(_("hook.%s.jobs must be a positive integer," + int v; + if (!git_parse_int(value, &v)) + warning(_("hook.%s.jobs must be an integer," " ignoring: '%s'"), hook_name, value); - else if (!v) - warning(_("hook.%s.jobs must be positive," - " ignoring: 0"), hook_name); - else + else if (v == -1) + strmap_put(&data->event_jobs, hook_name, + (void *)(uintptr_t)online_cpus()); + else if (v > 0) strmap_put(&data->event_jobs, hook_name, (void *)(uintptr_t)v); + else + warning(_("hook.%s.jobs must be a positive" + " integer or -1, ignoring: '%s'"), + hook_name, value); } free(hook_name); @@ -688,6 +697,25 @@ static void warn_non_parallel_hooks_override(unsigned int jobs, } } +/* Resolve a hook.jobs config key, handling -1 as online_cpus(). */ +static void resolve_hook_config_jobs(struct repository *r, + const char *key, + unsigned int *jobs) +{ + int v; + + if (repo_config_get_int(r, key, &v)) + return; + + if (v == -1) + *jobs = online_cpus(); + else if (v > 0) + *jobs = v; + else + warning(_("%s must be a positive integer or -1," + " ignoring: %d"), key, v); +} + /* Determine how many jobs to use for hook execution. */ static unsigned int get_hook_jobs(struct repository *r, struct run_hooks_opt *options, @@ -721,14 +749,12 @@ static unsigned int get_hook_jobs(struct repository *r, if (event_jobs) options->jobs = (unsigned int)(uintptr_t)event_jobs; } else { - unsigned int event_jobs; char *key; - repo_config_get_uint(r, "hook.jobs", &options->jobs); + resolve_hook_config_jobs(r, "hook.jobs", &options->jobs); key = xstrfmt("hook.%s.jobs", hook_name); - if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs) - options->jobs = event_jobs; + resolve_hook_config_jobs(r, key, &options->jobs); free(key); } } diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh index 273588e4d4..dbd5299d92 100755 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@ -1058,6 +1058,55 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' ' test_grep ! "friendly-name" err ' +test_expect_success 'hook.jobs=-1 resolves to online_cpus()' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-1.parallel true && + + test_config hook.jobs -1 && + + cpus=$(test-tool online-cpus) && + GIT_TRACE2_EVENT="$(pwd)/trace.txt" \ + git hook run --allow-unknown-hook-name test-hook >out 2>err && + grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt +' + +test_expect_success 'hook.<event>.jobs=-1 resolves to online_cpus()' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-1.parallel true && + + test_config hook.test-hook.jobs -1 && + + cpus=$(test-tool online-cpus) && + GIT_TRACE2_EVENT="$(pwd)/trace.txt" \ + git hook run --allow-unknown-hook-name test-hook >out 2>err && + grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt +' + +test_expect_success 'git hook run -j-1 resolves to online_cpus()' ' + test_config hook.hook-1.event test-hook && + test_config hook.hook-1.command "true" && + test_config hook.hook-1.parallel true && + + cpus=$(test-tool online-cpus) && + GIT_TRACE2_EVENT="$(pwd)/trace.txt" \ + git hook run --allow-unknown-hook-name -j-1 test-hook >out 2>err && + grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt +' + +test_expect_success 'hook.jobs rejects values less than -1' ' + test_config hook.jobs -2 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.jobs must be a positive integer or -1" err +' + +test_expect_success 'hook.<event>.jobs rejects values less than -1' ' + test_config hook.test-hook.jobs -5 && + git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err && + test_grep "hook.test-hook.jobs must be a positive integer or -1" err +' + test_expect_success 'hook.<event>.enabled=false skips all hooks for event' ' test_config hook.hook-1.event test-hook && test_config hook.hook-1.command "echo ran" && -- 2.52.0.732.gb351b5166d.dirty ^ permalink raw reply related [flat|nested] 83+ messages in thread
end of thread, other threads:[~2026-03-27 16:00 UTC | newest] Thread overview: 83+ messages (download: mbox.gz follow: Atom feed -- links below jump to the message on this page -- 2026-02-04 17:33 [PATCH 0/4] Run hooks in parallel Adrian Ratiu 2026-02-04 17:33 ` [PATCH 1/4] config: add a repo_config_get_uint() helper Adrian Ratiu 2026-02-04 17:33 ` [PATCH 2/4] hook: allow parallel hook execution Adrian Ratiu 2026-02-11 12:41 ` Patrick Steinhardt 2026-02-12 12:25 ` Adrian Ratiu 2026-02-04 17:33 ` [PATCH 3/4] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu 2026-02-04 17:33 ` [PATCH 4/4] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu 2026-02-12 10:43 ` [PATCH 0/4] Run hooks in parallel Phillip Wood 2026-02-12 14:24 ` Adrian Ratiu 2026-02-13 14:39 ` Phillip Wood 2026-02-13 17:21 ` Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 00/10] " Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 01/10] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 02/10] config: add a repo_config_get_uint() helper Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 03/10] hook: refactor hook_config_cache from strmap to named struct Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 04/10] hook: parse the hook.jobs config Adrian Ratiu 2026-02-22 0:28 ` [PATCH v2 05/10] hook: allow parallel hook execution Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 06/10] hook: mark non-parallelizable hooks Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 07/10] hook: add -j/--jobs option to git hook run Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 08/10] hook: add per-event jobs config Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 09/10] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu 2026-02-22 0:29 ` [PATCH v2 10/10] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 0/9] Run hooks in parallel Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 1/9] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu 2026-03-15 4:55 ` Junio C Hamano 2026-03-15 5:05 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 2/9] config: add a repo_config_get_uint() helper Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 3/9] hook: parse the hook.jobs config Adrian Ratiu 2026-03-15 16:13 ` Junio C Hamano 2026-03-09 13:37 ` [PATCH v3 4/9] hook: allow parallel hook execution Adrian Ratiu 2026-03-15 20:46 ` Junio C Hamano 2026-03-18 18:02 ` Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 5/9] hook: mark non-parallelizable hooks Adrian Ratiu 2026-03-15 20:56 ` Junio C Hamano 2026-03-18 18:40 ` Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 6/9] hook: add -j/--jobs option to git hook run Adrian Ratiu 2026-03-15 21:00 ` Junio C Hamano 2026-03-18 19:00 ` Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 7/9] hook: add per-event jobs config Adrian Ratiu 2026-03-16 18:40 ` Junio C Hamano 2026-03-18 19:21 ` Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 8/9] hook: introduce extensions.hookStdoutToStderr Adrian Ratiu 2026-03-16 18:44 ` Junio C Hamano 2026-03-18 19:50 ` Adrian Ratiu 2026-03-09 13:37 ` [PATCH v3 9/9] hook: allow runtime enabling extensions.hookStdoutToStderr Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 0/9] Run hooks in parallel Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 1/9] config: add a repo_config_get_uint() helper Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 2/9] hook: parse the hook.jobs config Adrian Ratiu 2026-03-24 9:07 ` Patrick Steinhardt 2026-03-24 18:59 ` Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 3/9] hook: allow parallel hook execution Adrian Ratiu 2026-03-24 9:07 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 4/9] hook: allow pre-push parallel execution Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 5/9] hook: mark non-parallelizable hooks Adrian Ratiu 2026-03-20 13:53 ` [PATCH v4 6/9] hook: add -j/--jobs option to git hook run Adrian Ratiu 2026-03-24 9:07 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 7/9] hook: add per-event jobs config Adrian Ratiu 2026-03-24 9:08 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 8/9] hook: warn when hook.<friendly-name>.jobs is set Adrian Ratiu 2026-03-24 9:08 ` Patrick Steinhardt 2026-03-20 13:53 ` [PATCH v4 9/9] hook: add hook.<event>.enabled switch Adrian Ratiu 2026-03-24 9:08 ` Patrick Steinhardt 2026-03-25 18:43 ` Adrian Ratiu 2026-03-20 17:24 ` [PATCH v4 0/9] Run hooks in parallel Junio C Hamano 2026-03-23 15:07 ` Adrian Ratiu 2026-03-24 9:07 ` Patrick Steinhardt 2026-03-26 10:18 ` [PATCH v5 00/12] " Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 01/12] repository: fix repo_init() memleak due to missing _clear() Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 02/12] config: add a repo_config_get_uint() helper Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 03/12] hook: parse the hook.jobs config Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 04/12] hook: allow parallel hook execution Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 05/12] hook: allow pre-push parallel execution Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 06/12] hook: mark non-parallelizable hooks Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 07/12] hook: add -j/--jobs option to git hook run Adrian Ratiu 2026-03-27 14:46 ` Patrick Steinhardt 2026-03-26 10:18 ` [PATCH v5 08/12] hook: add per-event jobs config Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 09/12] hook: warn when hook.<friendly-name>.jobs is set Adrian Ratiu 2026-03-27 14:46 ` Patrick Steinhardt 2026-03-26 10:18 ` [PATCH v5 10/12] hook: move is_known_hook() to hook.c for wider use Adrian Ratiu 2026-03-27 14:46 ` Patrick Steinhardt 2026-03-27 15:59 ` Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 11/12] hook: add hook.<event>.enabled switch Adrian Ratiu 2026-03-26 10:18 ` [PATCH v5 12/12] hook: allow hook.jobs=-1 to use all available CPU cores Adrian Ratiu
This is a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox