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