hook: add -j/--jobs option to git hook run

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

Emily Shaffer committed Apr 10, 2026 at 12:06 UTC 091d2dbeb452b2c8223c622b54e96ebd273b5a78
4 files changed +170 -10
Documentation/git-hook.adoc
+21 -2
@@ -8,7 +8,8 @@ git-hook - Run git hooks
8 SYNOPSIS
9 --------
10 [verse]
11 -'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
11 +'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]
12 + <hook-name> [-- <hook-args>]
13 'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>
14
15 DESCRIPTION
@@ -147,6 +148,23 @@ OPTIONS
148 mirroring the output style of `git config --show-scope`. Traditional
149 hooks from the hookdir are unaffected.
150
151 +-j::
152 +--jobs::
153 + Only valid for `run`.
154 ++
155 +Specify how many hooks to run simultaneously. If this flag is not specified,
156 +the value of the `hook.jobs` config is used, see linkgit:git-config[1]. If
157 +neither is specified, defaults to 1 (serial execution).
158 ++
159 +When greater than 1, it overrides the per-hook `hook.<friendly-name>.parallel`
160 +setting, allowing all hooks for the event to run concurrently, even if they
161 +are not individually marked as parallel.
162 ++
163 +Some hooks always run sequentially regardless of this flag or the
164 +`hook.jobs` config, because git knows they cannot safely run in parallel:
165 +`applypatch-msg`, `pre-commit`, `prepare-commit-msg`, `commit-msg`,
166 +`post-commit`, `post-checkout`, and `push-to-checkout`.
167 +
168 WRAPPERS
169 --------
170
@@ -169,7 +187,8 @@ running:
187 git hook run --allow-unknown-hook-name mywrapper-start-tests \
188 # providing something to stdin
189 --stdin some-tempfile-123 \
172 - # execute hooks in serial
190 + # execute multiple hooks in parallel
191 + --jobs 3 \
192 # plus some arguments of your own...
193 -- \
194 --testname bar \
builtin/hook.c
+4 -1
@@ -8,7 +8,8 @@
8 #include "parse-options.h"
9
10 #define BUILTIN_HOOK_RUN_USAGE \
11 - N_("git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
11 + N_("git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] [(-j|--jobs) <n>]\n" \
12 + "<hook-name> [-- <hook-args>]")
13 #define BUILTIN_HOOK_LIST_USAGE \
14 N_("git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>")
15
@@ -132,6 +133,8 @@ static int run(int argc, const char **argv, const char *prefix,
133 N_("silently ignore missing requested <hook-name>")),
134 OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"),
135 N_("file to read into hooks' stdin")),
136 + OPT_UNSIGNED('j', "jobs", &opt.jobs,
137 + N_("run up to <n> hooks simultaneously")),
138 OPT_END(),
139 };
140 int ret;
hook.c
+17
@@ -568,6 +568,22 @@ static void merge_output_if_parallel(struct run_hooks_opt *options)
568 options->stdout_to_stderr = 1;
569 }
570
571 +static void warn_non_parallel_hooks_override(unsigned int jobs,
572 + struct string_list *hook_list)
573 +{
574 + /* Don't warn for hooks running sequentially. */
575 + if (jobs == 1)
576 + return;
577 +
578 + for (size_t i = 0; i < hook_list->nr; i++) {
579 + struct hook *h = hook_list->items[i].util;
580 + if (h->kind == HOOK_CONFIGURED && !h->parallel)
581 + warning(_("hook '%s' is not marked as parallel=true, "
582 + "running in parallel anyway due to -j%u"),
583 + h->u.configured.friendly_name, jobs);
584 + }
585 +}
586 +
587 /* Determine how many jobs to use for hook execution. */
588 static unsigned int get_hook_jobs(struct repository *r,
589 struct run_hooks_opt *options,
@@ -611,6 +627,7 @@ static unsigned int get_hook_jobs(struct repository *r,
627
628 cleanup:
629 merge_output_if_parallel(options);
630 + warn_non_parallel_hooks_override(options->jobs, hook_list);
631 return options->jobs;
632 }
633
t/t1800-hook.sh
+128 -7
@@ -268,10 +268,20 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '
268 '
269
270 test_hook_tty () {
271 - cat >expect <<-\EOF
272 - STDOUT TTY
273 - STDERR TTY
274 - EOF
271 + expect_tty=$1
272 + shift
273 +
274 + if test "$expect_tty" != "no_tty"; then
275 + cat >expect <<-\EOF
276 + STDOUT TTY
277 + STDERR TTY
278 + EOF
279 + else
280 + cat >expect <<-\EOF
281 + STDOUT NO TTY
282 + STDERR NO TTY
283 + EOF
284 + fi
285
286 test_when_finished "rm -rf repo" &&
287 git init repo &&
@@ -289,12 +299,21 @@ test_hook_tty () {
299 test_cmp expect repo/actual
300 }
301
292 -test_expect_success TTY 'git hook run: stdout and stderr are connected to a TTY' '
293 - test_hook_tty hook run pre-commit
302 +test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '
303 + # hooks running sequentially (-j1) are always connected to the tty for
304 + # optimum real-time performance.
305 + test_hook_tty tty hook run -j1 pre-commit
306 +'
307 +
308 +test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '
309 + # Hooks are not connected to the tty when run in parallel, instead they
310 + # output to a pipe through which run-command collects and de-interlaces
311 + # their outputs, which then gets passed either to the tty or a sideband.
312 + test_hook_tty no_tty hook run -j2 pre-commit
313 '
314
315 test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '
297 - test_hook_tty commit -m"B.new"
316 + test_hook_tty tty commit -m"B.new"
317 '
318
319 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
728 check_stdout_merged_to_stderr push-to-checkout
729 '
730
731 +test_expect_success 'parallel hook output is not interleaved' '
732 + test_when_finished "rm -rf .git/hooks" &&
733 +
734 + write_script .git/hooks/test-hook <<-EOF &&
735 + echo "Hook 1 Start"
736 + sleep 1
737 + echo "Hook 1 End"
738 + EOF
739 +
740 + test_config hook.hook-2.event test-hook &&
741 + test_config hook.hook-2.command \
742 + "echo \"Hook 2 Start\"; sleep 2; echo \"Hook 2 End\"" &&
743 + test_config hook.hook-2.parallel true &&
744 + test_config hook.hook-3.event test-hook &&
745 + test_config hook.hook-3.command \
746 + "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" &&
747 + test_config hook.hook-3.parallel true &&
748 +
749 + git hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel &&
750 +
751 + # Verify Hook 1 output is grouped
752 + sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out &&
753 + test_line_count = 2 hook1_out &&
754 +
755 + # Verify Hook 2 output is grouped
756 + sed -n "/Hook 2 Start/,/Hook 2 End/p" err.parallel >hook2_out &&
757 + test_line_count = 2 hook2_out &&
758 +
759 + # Verify Hook 3 output is grouped
760 + sed -n "/Hook 3 Start/,/Hook 3 End/p" err.parallel >hook3_out &&
761 + test_line_count = 2 hook3_out
762 +'
763 +
764 +test_expect_success 'git hook run -j1 runs hooks in series' '
765 + test_when_finished "rm -rf .git/hooks" &&
766 +
767 + test_config hook.series-1.event "test-hook" &&
768 + test_config hook.series-1.command "echo 1" --add &&
769 + test_config hook.series-2.event "test-hook" &&
770 + test_config hook.series-2.command "echo 2" --add &&
771 +
772 + mkdir -p .git/hooks &&
773 + write_script .git/hooks/test-hook <<-EOF &&
774 + echo 3
775 + EOF
776 +
777 + cat >expected <<-\EOF &&
778 + 1
779 + 2
780 + 3
781 + EOF
782 +
783 + git hook run --allow-unknown-hook-name -j1 test-hook 2>actual &&
784 + test_cmp expected actual
785 +'
786 +
787 +test_expect_success 'git hook run -j2 runs hooks in parallel' '
788 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
789 + test_when_finished "rm -rf .git/hooks" &&
790 +
791 + mkdir -p .git/hooks &&
792 + write_sentinel_hook .git/hooks/test-hook &&
793 +
794 + test_config hook.hook-2.event test-hook &&
795 + test_config hook.hook-2.command \
796 + "$(sentinel_detector sentinel hook.order)" &&
797 + test_config hook.hook-2.parallel true &&
798 +
799 + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&
800 + echo parallel >expect &&
801 + test_cmp expect hook.order
802 +'
803 +
804 +test_expect_success 'git hook run -j2 overrides parallel=false' '
805 + test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
806 + test_config hook.hook-1.event test-hook &&
807 + test_config hook.hook-1.command \
808 + "touch sentinel.started; sleep 2; touch sentinel.done" &&
809 + # hook-1 intentionally has no parallel=true
810 + test_config hook.hook-2.event test-hook &&
811 + test_config hook.hook-2.command \
812 + "$(sentinel_detector sentinel hook.order)" &&
813 + # hook-2 also has no parallel=true
814 +
815 + # -j2 overrides parallel=false; hooks run in parallel with a warning.
816 + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&
817 + echo parallel >expect &&
818 + test_cmp expect hook.order
819 +'
820 +
821 +test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' '
822 + test_config hook.hook-1.event test-hook &&
823 + test_config hook.hook-1.command "true" &&
824 + test_config hook.hook-2.event test-hook &&
825 + test_config hook.hook-2.command "true" &&
826 + # neither hook has parallel=true
827 +
828 + git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&
829 + grep "hook .hook-1. is not marked as parallel=true" err &&
830 + grep "hook .hook-2. is not marked as parallel=true" err
831 +'
832 +
833 test_expect_success 'hook.jobs=1 config runs hooks in series' '
834 test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
835