hook: allow parallel hook execution

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

Emily Shaffer committed Apr 10, 2026 at 12:05 UTC 680e69f60d2b3838bb98938dbd3e8881bdfde7d6
4 files changed +253 -6
Documentation/config/hook.adoc
+13
@@ -23,6 +23,19 @@ hook.<friendly-name>.enabled::
23 in a system or global config file and needs to be disabled for a
24 specific repository. See linkgit:git-hook[1].
25
26 +hook.<friendly-name>.parallel::
27 + Whether the hook `hook.<friendly-name>` may run in parallel with other hooks
28 + for the same event. Defaults to `false`. Set to `true` only when the
29 + hook script is safe to run concurrently with other hooks for the same
30 + event. If any hook for an event does not have this set to `true`,
31 + all hooks for that event run sequentially regardless of `hook.jobs`.
32 + Only configured (named) hooks need to declare this. Traditional hooks
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.jobs::
37 Specifies how many hooks can be run simultaneously during parallelized
38 hook execution. If unspecified, defaults to 1 (serial execution).
39 ++
40 +This setting has no effect unless all configured hooks for the event have
41 +`hook.<friendly-name>.parallel` set to `true`.
hook.c
+73 -6
@@ -116,6 +116,7 @@ struct hook_config_cache_entry {
116 char *command;
117 enum config_scope scope;
118 bool disabled;
119 + bool parallel;
120 };
121
122 /*
@@ -123,12 +124,14 @@ struct hook_config_cache_entry {
124 * commands: friendly-name to command map.
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 * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).
129 */
130 struct hook_all_config_cb {
131 struct strmap commands;
132 struct strmap event_hooks;
133 struct string_list disabled_hooks;
134 + struct strmap parallel_hooks;
135 unsigned int jobs;
136 };
137
@@ -219,6 +222,15 @@ static int hook_config_lookup_all(const char *key, const char *value,
222 default:
223 break; /* ignore unrecognised values */
224 }
225 + } else if (!strcmp(subkey, "parallel")) {
226 + int v = git_parse_maybe_bool(value);
227 + if (v >= 0)
228 + strmap_put(&data->parallel_hooks, hook_name,
229 + (void *)(uintptr_t)v);
230 + else
231 + warning(_("hook.%s.parallel must be a boolean,"
232 + " ignoring: '%s'"),
233 + hook_name, value);
234 }
235
236 free(hook_name);
@@ -263,6 +275,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
275 strmap_init(&cb_data.commands);
276 strmap_init(&cb_data.event_hooks);
277 string_list_init_dup(&cb_data.disabled_hooks);
278 + strmap_init(&cb_data.parallel_hooks);
279
280 /* Parse all configs in one run, capturing hook.* including hook.jobs. */
281 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)
295 struct hook_config_cache_entry *entry;
296 char *command;
297
298 + bool is_par = !!strmap_get(&cb_data.parallel_hooks, hname);
299 bool is_disabled =
300 !!unsorted_string_list_lookup(
301 &cb_data.disabled_hooks, hname);
@@ -302,6 +316,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
316 entry->command = xstrdup_or_null(command);
317 entry->scope = scope;
318 entry->disabled = is_disabled;
319 + entry->parallel = is_par;
320 string_list_append(hooks, hname)->util = entry;
321 }
322
@@ -312,6 +327,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
327 r->hook_jobs = cb_data.jobs;
328
329 strmap_clear(&cb_data.commands, 1);
330 + strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */
331 string_list_clear(&cb_data.disabled_hooks, 0);
332 strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
333 string_list_clear(e->value, 0);
@@ -389,6 +405,7 @@ static void list_hooks_add_configured(struct repository *r,
405 entry->command ? xstrdup(entry->command) : NULL;
406 hook->u.configured.scope = entry->scope;
407 hook->u.configured.disabled = entry->disabled;
408 + hook->parallel = entry->parallel;
409
410 string_list_append(list, friendly_name)->util = hook;
411 }
@@ -538,21 +555,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)
555 strvec_clear(&options->args);
556 }
557
558 +/* Determine how many jobs to use for hook execution. */
559 +static unsigned int get_hook_jobs(struct repository *r,
560 + struct run_hooks_opt *options,
561 + struct string_list *hook_list)
562 +{
563 + /*
564 + * Hooks needing separate output streams must run sequentially.
565 + * Next commit will allow parallelizing these as well.
566 + */
567 + if (!options->stdout_to_stderr)
568 + return 1;
569 +
570 + /*
571 + * An explicit job count overrides everything else: this covers both
572 + * FORCE_SERIAL callers (for hooks that must never run in parallel)
573 + * and the -j flag from the CLI. The CLI override is intentional: users
574 + * may want to serialize hooks declared parallel or to parallelize more
575 + * aggressively than the default.
576 + */
577 + if (options->jobs)
578 + return options->jobs;
579 +
580 + /*
581 + * Use hook.jobs from the already-parsed config cache (in-repo), or
582 + * fallback to a direct config lookup (out-of-repo).
583 + * Default to 1 (serial execution) on failure.
584 + */
585 + options->jobs = 1;
586 + if (r) {
587 + if (r->gitdir && r->hook_config_cache && r->hook_jobs)
588 + options->jobs = r->hook_jobs;
589 + else
590 + repo_config_get_uint(r, "hook.jobs", &options->jobs);
591 + }
592 +
593 + /*
594 + * Cap to serial any configured hook not marked as parallel = true.
595 + * This enforces the parallel = false default, even for "traditional"
596 + * hooks from the hookdir which cannot be marked parallel = true.
597 + */
598 + for (size_t i = 0; i < hook_list->nr; i++) {
599 + struct hook *h = hook_list->items[i].util;
600 + if (h->kind == HOOK_CONFIGURED && !h->parallel) {
601 + options->jobs = 1;
602 + break;
603 + }
604 + }
605 +
606 + return options->jobs;
607 +}
608 +
609 int run_hooks_opt(struct repository *r, const char *hook_name,
610 struct run_hooks_opt *options)
611 {
612 + struct string_list *hook_list = list_hooks(r, hook_name, options);
613 struct hook_cb_data cb_data = {
614 .rc = 0,
615 .hook_name = hook_name,
616 + .hook_command_list = hook_list,
617 .options = options,
618 };
619 int ret = 0;
620 + unsigned int jobs = get_hook_jobs(r, options, hook_list);
621 const struct run_process_parallel_opts opts = {
622 .tr2_category = "hook",
623 .tr2_label = hook_name,
624
554 - .processes = options->jobs,
555 - .ungroup = options->jobs == 1,
625 + .processes = jobs,
626 + .ungroup = jobs == 1,
627
628 .get_next_task = pick_next_hook,
629 .start_failure = notify_start_failure,
@@ -568,9 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
639 if (options->path_to_stdin && options->feed_pipe)
640 BUG("options path_to_stdin and feed_pipe are mutually exclusive");
641
571 - if (!options->jobs)
572 - BUG("run_hooks_opt must be called with options.jobs >= 1");
573 -
642 /*
643 * Ensure cb_data copy and free functions are either provided together,
644 * or neither one is provided.
@@ -581,7 +649,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
649 if (options->invoked_hook)
650 *options->invoked_hook = 0;
651
584 - cb_data.hook_command_list = list_hooks(r, hook_name, options);
652 if (!cb_data.hook_command_list->nr) {
653 if (options->error_if_missing)
654 ret = error("cannot find a hook named %s", hook_name);
hook.h
+25
@@ -35,6 +35,13 @@ struct hook {
35 } configured;
36 } u;
37
38 + /**
39 + * Whether this hook may run in parallel with other hooks for the same
40 + * event. Only useful for configured (named) hooks. Traditional hooks
41 + * always default to 0 (serial). Set via `hook.<name>.parallel = true`.
42 + */
43 + bool parallel;
44 +
45 /**
46 * Opaque data pointer used to keep internal state across callback calls.
47 *
@@ -72,6 +79,8 @@ struct run_hooks_opt {
79 *
80 * If > 1, output will be buffered and de-interleaved (ungroup=0).
81 * If == 1, output will be real-time (ungroup=1).
82 + * If == 0, the 'hook.jobs' config is used or, if the config is unset,
83 + * defaults to 1 (serial execution).
84 */
85 unsigned int jobs;
86
@@ -152,7 +161,23 @@ struct run_hooks_opt {
161 hook_data_free_fn feed_pipe_cb_data_free;
162 };
163
164 +/**
165 + * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to
166 + * the 'hook.jobs' config, falling back to serial (1) if unset.
167 + */
168 #define RUN_HOOKS_OPT_INIT { \
169 + .env = STRVEC_INIT, \
170 + .args = STRVEC_INIT, \
171 + .stdout_to_stderr = 1, \
172 + .jobs = 0, \
173 +}
174 +
175 +/**
176 + * Initializer for hooks that must always run sequentially regardless of
177 + * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized
178 + * .jobs = 1 is non-overridable.
179 + */
180 +#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \
181 .env = STRVEC_INIT, \
182 .args = STRVEC_INIT, \
183 .stdout_to_stderr = 1, \
t/t1800-hook.sh
+142
@@ -21,6 +21,57 @@ setup_hookdir () {
21 test_when_finished rm -rf .git/hooks
22 }
23
24 +# write_sentinel_hook <path> [sentinel]
25 +#
26 +# Writes a hook that marks itself as started, sleeps for a few seconds, then
27 +# marks itself done. The sleep must be long enough that sentinel_detector can
28 +# observe <sentinel>.started before <sentinel>.done appears when both hooks
29 +# run concurrently in parallel mode.
30 +write_sentinel_hook () {
31 + sentinel="${2:-sentinel}"
32 + write_script "$1" <<-EOF
33 + touch ${sentinel}.started &&
34 + sleep 2 &&
35 + touch ${sentinel}.done
36 + EOF
37 +}
38 +
39 +# sentinel_detector <sentinel> <output>
40 +#
41 +# Returns a shell command string suitable for use as hook.<name>.command.
42 +# The detector must be registered after the sentinel:
43 +# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)
44 +# before the detector starts.
45 +# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared
46 +# yet and the detector just sees <sentinel>.started.
47 +#
48 +# At start, poll until <sentinel>.started exists to absorb startup jitter, then
49 +# write to <output>:
50 +# 1. 'serial' if <sentinel>.done exists (sentinel finished before we started),
51 +# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),
52 +# 3. 'timeout' if <sentinel>.started never appeared.
53 +#
54 +# The command ends with ':' so when git appends "$@" for hooks that receive
55 +# positional arguments (e.g. pre-push), the result ': "$@"' is valid shell
56 +# rather than a syntax error 'fi "$@"'.
57 +sentinel_detector () {
58 + cat <<-EOF
59 + i=0
60 + while ! test -f ${1}.started && test \$i -lt 10; do
61 + sleep 1
62 + i=\$((i+1))
63 + done
64 + if test -f ${1}.done; then
65 + echo serial >${2}
66 + elif test -f ${1}.started; then
67 + echo parallel >${2}
68 + else
69 + echo timeout >${2}
70 + fi
71 + :
72 + EOF
73 +}
74 +
75 test_expect_success 'git hook usage' '
76 test_expect_code 129 git hook &&
77 test_expect_code 129 git hook run &&
@@ -658,4 +709,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s
709 check_stdout_merged_to_stderr push-to-checkout
710 '
711
712 +test_expect_success 'hook.jobs=1 config runs hooks in series' '
713 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
714 +
715 + # Use two configured hooks so the execution order is deterministic:
716 + # hook-1 (sentinel) is listed before hook-2 (detector), so hook-1
717 + # always runs first even in serial mode.
718 + test_config hook.hook-1.event test-hook &&
719 + test_config hook.hook-1.command \
720 + "touch sentinel.started; sleep 2; touch sentinel.done" &&
721 + test_config hook.hook-2.event test-hook &&
722 + test_config hook.hook-2.command \
723 + "$(sentinel_detector sentinel hook.order)" &&
724 +
725 + test_config hook.jobs 1 &&
726 +
727 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
728 + echo serial >expect &&
729 + test_cmp expect hook.order
730 +'
731 +
732 +test_expect_success 'hook.jobs=2 config runs hooks in parallel' '
733 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
734 + test_when_finished "rm -rf .git/hooks" &&
735 +
736 + mkdir -p .git/hooks &&
737 + write_sentinel_hook .git/hooks/test-hook &&
738 +
739 + test_config hook.hook-2.event test-hook &&
740 + test_config hook.hook-2.command \
741 + "$(sentinel_detector sentinel hook.order)" &&
742 + test_config hook.hook-2.parallel true &&
743 +
744 + test_config hook.jobs 2 &&
745 +
746 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
747 + echo parallel >expect &&
748 + test_cmp expect hook.order
749 +'
750 +
751 +test_expect_success 'hook.<name>.parallel=true enables parallel execution' '
752 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
753 + test_config hook.hook-1.event test-hook &&
754 + test_config hook.hook-1.command \
755 + "touch sentinel.started; sleep 2; touch sentinel.done" &&
756 + test_config hook.hook-1.parallel true &&
757 + test_config hook.hook-2.event test-hook &&
758 + test_config hook.hook-2.command \
759 + "$(sentinel_detector sentinel hook.order)" &&
760 + test_config hook.hook-2.parallel true &&
761 +
762 + test_config hook.jobs 2 &&
763 +
764 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
765 + echo parallel >expect &&
766 + test_cmp expect hook.order
767 +'
768 +
769 +test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '
770 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
771 + test_config hook.hook-1.event test-hook &&
772 + test_config hook.hook-1.command \
773 + "touch sentinel.started; sleep 2; touch sentinel.done" &&
774 + test_config hook.hook-2.event test-hook &&
775 + test_config hook.hook-2.command \
776 + "$(sentinel_detector sentinel hook.order)" &&
777 +
778 + test_config hook.jobs 2 &&
779 +
780 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
781 + echo serial >expect &&
782 + test_cmp expect hook.order
783 +'
784 +
785 +test_expect_success 'one non-parallel hook forces the whole event to run serially' '
786 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
787 + test_config hook.hook-1.event test-hook &&
788 + test_config hook.hook-1.command \
789 + "touch sentinel.started; sleep 2; touch sentinel.done" &&
790 + test_config hook.hook-1.parallel true &&
791 + test_config hook.hook-2.event test-hook &&
792 + test_config hook.hook-2.command \
793 + "$(sentinel_detector sentinel hook.order)" &&
794 + # hook-2 has no parallel=true: should force serial for all
795 +
796 + test_config hook.jobs 2 &&
797 +
798 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
799 + echo serial >expect &&
800 + test_cmp expect hook.order
801 +'
802 +
803 test_done