hook: add per-event jobs config

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> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Adrian Ratiu committed Apr 10, 2026 at 12:06 UTC 084a55b3adf33f70c84091d5957b8bede9b01174
5 files changed +123 -5
Documentation/config/hook.adoc
+19
@@ -33,9 +33,28 @@ hook.<friendly-name>.parallel::
33 found in the hooks directory do not need to, and run in parallel when
34 the effective job count is greater than 1. See linkgit:git-hook[1].
35
36 +hook.<event>.jobs::
37 + Specifies how many hooks can be run simultaneously for the `<event>`
38 + hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`
39 + for this specific event. The same parallelism restrictions apply: this
40 + setting has no effect unless all configured hooks for the event have
41 + `hook.<friendly-name>.parallel` set to `true`. Must be a positive int,
42 + zero is rejected with a warning. See linkgit:git-hook[1].
43 ++
44 +Note on naming: although this key resembles `hook.<friendly-name>.*`
45 +(a per-hook setting), `<event>` must be the event name, not a hook
46 +friendly name. The key component is stored literally and looked up by
47 +event name at runtime with no translation between the two namespaces.
48 +A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the
49 +lookup at runtime uses the event name (e.g. `"post-receive"`), so
50 +`hook.my-hook.jobs` is silently ignored even when `my-hook` is
51 +registered for that event. Use `hook.post-receive.jobs` or any other
52 +valid event name when setting `hook.<event>.jobs`.
53 +
54 hook.jobs::
55 Specifies how many hooks can be run simultaneously during parallelized
56 hook execution. If unspecified, defaults to 1 (serial execution).
57 + Can be overridden on a per-event basis with `hook.<event>.jobs`.
58 Some hooks always run sequentially regardless of this setting because
59 they operate on shared data and cannot safely be parallelized:
60 +
hook.c
+41 -5
@@ -125,6 +125,7 @@ struct hook_config_cache_entry {
125 * event_hooks: event-name to list of friendly-names map.
126 * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.
127 * parallel_hooks: friendly-name to parallel flag.
128 + * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).
129 * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).
130 */
131 struct hook_all_config_cb {
@@ -132,6 +133,7 @@ struct hook_all_config_cb {
133 struct strmap event_hooks;
134 struct string_list disabled_hooks;
135 struct strmap parallel_hooks;
136 + struct strmap event_jobs;
137 unsigned int jobs;
138 };
139
@@ -231,6 +233,18 @@ static int hook_config_lookup_all(const char *key, const char *value,
233 warning(_("hook.%s.parallel must be a boolean,"
234 " ignoring: '%s'"),
235 hook_name, value);
236 + } else if (!strcmp(subkey, "jobs")) {
237 + unsigned int v;
238 + if (!git_parse_uint(value, &v))
239 + warning(_("hook.%s.jobs must be a positive integer,"
240 + " ignoring: '%s'"),
241 + hook_name, value);
242 + else if (!v)
243 + warning(_("hook.%s.jobs must be positive,"
244 + " ignoring: 0"), hook_name);
245 + else
246 + strmap_put(&data->event_jobs, hook_name,
247 + (void *)(uintptr_t)v);
248 }
249
250 free(hook_name);
@@ -276,6 +290,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
290 strmap_init(&cb_data.event_hooks);
291 string_list_init_dup(&cb_data.disabled_hooks);
292 strmap_init(&cb_data.parallel_hooks);
293 + strmap_init(&cb_data.event_jobs);
294
295 /* Parse all configs in one run, capturing hook.* including hook.jobs. */
296 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)
338 strmap_put(cache, e->key, hooks);
339 }
340
326 - if (r)
341 + if (r) {
342 r->hook_jobs = cb_data.jobs;
343 + r->event_jobs = cb_data.event_jobs;
344 + }
345
346 strmap_clear(&cb_data.commands, 1);
347 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,
604 /* Determine how many jobs to use for hook execution. */
605 static unsigned int get_hook_jobs(struct repository *r,
606 struct run_hooks_opt *options,
607 + const char *hook_name,
608 struct string_list *hook_list)
609 {
610 /*
@@ -606,16 +624,34 @@ static unsigned int get_hook_jobs(struct repository *r,
624 */
625 options->jobs = 1;
626 if (r) {
609 - if (r->gitdir && r->hook_config_cache && r->hook_jobs)
610 - options->jobs = r->hook_jobs;
611 - else
627 + if (r->gitdir && r->hook_config_cache) {
628 + void *event_jobs;
629 +
630 + if (r->hook_jobs)
631 + options->jobs = r->hook_jobs;
632 +
633 + event_jobs = strmap_get(&r->event_jobs, hook_name);
634 + if (event_jobs)
635 + options->jobs = (unsigned int)(uintptr_t)event_jobs;
636 + } else {
637 + unsigned int event_jobs;
638 + char *key;
639 +
640 repo_config_get_uint(r, "hook.jobs", &options->jobs);
641 +
642 + key = xstrfmt("hook.%s.jobs", hook_name);
643 + if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)
644 + options->jobs = event_jobs;
645 + free(key);
646 + }
647 }
648
649 /*
650 * Cap to serial any configured hook not marked as parallel = true.
651 * This enforces the parallel = false default, even for "traditional"
652 * hooks from the hookdir which cannot be marked parallel = true.
653 + * The same restriction applies whether jobs came from hook.jobs or
654 + * hook.<event>.jobs.
655 */
656 for (size_t i = 0; i < hook_list->nr; i++) {
657 struct hook *h = hook_list->items[i].util;
@@ -642,7 +678,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
678 .options = options,
679 };
680 int ret = 0;
645 - unsigned int jobs = get_hook_jobs(r, options, hook_list);
681 + unsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list);
682 const struct run_process_parallel_opts opts = {
683 .tr2_category = "hook",
684 .tr2_label = hook_name,
repository.c
+1
@@ -426,6 +426,7 @@ void repo_clear(struct repository *repo)
426 hook_cache_clear(repo->hook_config_cache);
427 FREE_AND_NULL(repo->hook_config_cache);
428 }
429 + strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */
430
431 if (repo->promisor_remote_config) {
432 promisor_remote_clear(repo->promisor_remote_config);
repository.h
+3
@@ -175,6 +175,9 @@ struct repository {
175 /* Cached value of hook.jobs config (0 if unset, defaults to serial). */
176 unsigned int hook_jobs;
177
178 + /* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */
179 + struct strmap event_jobs;
180 +
181 /* Configurations related to promisor remotes. */
182 char *repository_format_partial_clone;
183 struct promisor_remote_config *promisor_remote_config;
t/t1800-hook.sh
+59
@@ -969,4 +969,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'
969 test_cmp expect hook.order
970 '
971
972 +test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '
973 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
974 + test_config hook.hook-1.event test-hook &&
975 + test_config hook.hook-1.command \
976 + "touch sentinel.started; sleep 2; touch sentinel.done" &&
977 + test_config hook.hook-1.parallel true &&
978 + test_config hook.hook-2.event test-hook &&
979 + test_config hook.hook-2.command \
980 + "$(sentinel_detector sentinel hook.order)" &&
981 + test_config hook.hook-2.parallel true &&
982 +
983 + # Global hook.jobs=1 (serial), but per-event override allows parallel.
984 + test_config hook.jobs 1 &&
985 + test_config hook.test-hook.jobs 2 &&
986 +
987 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
988 + echo parallel >expect &&
989 + test_cmp expect hook.order
990 +'
991 +
992 +test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' '
993 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
994 + test_config hook.hook-1.event test-hook &&
995 + test_config hook.hook-1.command \
996 + "touch sentinel.started; sleep 2; touch sentinel.done" &&
997 + test_config hook.hook-1.parallel true &&
998 + test_config hook.hook-2.event test-hook &&
999 + test_config hook.hook-2.command \
1000 + "$(sentinel_detector sentinel hook.order)" &&
1001 + test_config hook.hook-2.parallel true &&
1002 +
1003 + # Global hook.jobs=4 allows parallel, but per-event override forces serial.
1004 + test_config hook.jobs 4 &&
1005 + test_config hook.test-hook.jobs 1 &&
1006 +
1007 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
1008 + echo serial >expect &&
1009 + test_cmp expect hook.order
1010 +'
1011 +
1012 +test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' '
1013 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
1014 + test_config hook.hook-1.event test-hook &&
1015 + test_config hook.hook-1.command \
1016 + "touch sentinel.started; sleep 2; touch sentinel.done" &&
1017 + # hook-1 intentionally has no parallel=true
1018 + test_config hook.hook-2.event test-hook &&
1019 + test_config hook.hook-2.command \
1020 + "$(sentinel_detector sentinel hook.order)" &&
1021 + # hook-2 also has no parallel=true
1022 +
1023 + # Per-event jobs=2 but no hook has parallel=true: must still run serially.
1024 + test_config hook.test-hook.jobs 2 &&
1025 +
1026 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
1027 + echo serial >expect &&
1028 + test_cmp expect hook.order
1029 +'
1030 +
1031 test_done