Skip to content

Commit b72153d

Browse files
Emily Shaffergitster
authored andcommitted
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>
1 parent aae91aa commit b72153d

4 files changed

Lines changed: 241 additions & 8 deletions

File tree

Documentation/config/hook.adoc

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ hook.<name>.enabled::
2323
in a system or global config file and needs to be disabled for a
2424
specific repository. See linkgit:git-hook[1].
2525

26+
hook.<name>.parallel::
27+
Whether the hook `hook.<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+
2636
hook.jobs::
2737
Specifies how many hooks can be run simultaneously during parallelized
2838
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.<name>.parallel` set to `true`.

hook.c

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,24 +120,26 @@ static void unsorted_string_list_remove(struct string_list *list,
120120

121121
/*
122122
* Cache entry stored as the .util pointer of string_list items inside the
123-
* hook config cache. For now carries only the command for the hook. Next
124-
* commits will add more data.
123+
* hook config cache. Carries both the resolved command and the parallel flag.
125124
*/
126125
struct hook_config_cache_entry {
127126
char *command;
127+
unsigned int parallel:1;
128128
};
129129

130130
/*
131131
* Callback struct to collect all hook.* keys in a single config pass.
132132
* commands: friendly-name to command map.
133133
* event_hooks: event-name to list of friendly-names map.
134134
* disabled_hooks: set of friendly-names with hook.name.enabled = false.
135+
* parallel_hooks: friendly-name to parallel flag.
135136
* jobs: value of the global hook.jobs key. Defaults to 0 if unset.
136137
*/
137138
struct hook_all_config_cb {
138139
struct strmap commands;
139140
struct strmap event_hooks;
140141
struct string_list disabled_hooks;
142+
struct strmap parallel_hooks;
141143
unsigned int jobs;
142144
};
143145

@@ -216,6 +218,10 @@ static int hook_config_lookup_all(const char *key, const char *value,
216218
default:
217219
break; /* ignore unrecognised values */
218220
}
221+
} else if (!strcmp(subkey, "parallel")) {
222+
int v = git_parse_maybe_bool(value);
223+
if (v >= 0)
224+
strmap_put(&data->parallel_hooks, hook_name, (void *)(uintptr_t)v);
219225
}
220226

221227
free(hook_name);
@@ -259,6 +265,7 @@ static void build_hook_config_map(struct repository *r,
259265
strmap_init(&cb_data.commands);
260266
strmap_init(&cb_data.event_hooks);
261267
string_list_init_dup(&cb_data.disabled_hooks);
268+
strmap_init(&cb_data.parallel_hooks);
262269

263270
/* Parse all configs in one run, capturing hook.* including hook.jobs. */
264271
repo_config(r, hook_config_lookup_all, &cb_data);
@@ -273,6 +280,7 @@ static void build_hook_config_map(struct repository *r,
273280
for (size_t i = 0; i < hook_names->nr; i++) {
274281
const char *hname = hook_names->items[i].string;
275282
struct hook_config_cache_entry *entry;
283+
void *par = strmap_get(&cb_data.parallel_hooks, hname);
276284
char *command;
277285

278286
/* filter out disabled hooks */
@@ -289,6 +297,7 @@ static void build_hook_config_map(struct repository *r,
289297
/* util stores a cache entry; owned by the cache. */
290298
CALLOC_ARRAY(entry, 1);
291299
entry->command = xstrdup(command);
300+
entry->parallel = par ? (int)(uintptr_t)par : 0;
292301
string_list_append(hooks, hname)->util = entry;
293302
}
294303

@@ -298,6 +307,7 @@ static void build_hook_config_map(struct repository *r,
298307
cache->jobs = cb_data.jobs;
299308

300309
strmap_clear(&cb_data.commands, 1);
310+
strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */
301311
string_list_clear(&cb_data.disabled_hooks, 0);
302312
strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
303313
string_list_clear(e->value, 0);
@@ -364,6 +374,7 @@ static void list_hooks_add_configured(struct repository *r,
364374
hook->kind = HOOK_CONFIGURED;
365375
hook->u.configured.friendly_name = xstrdup(friendly_name);
366376
hook->u.configured.command = xstrdup(entry->command);
377+
hook->parallel = entry->parallel;
367378

368379
string_list_append(list, friendly_name)->util = hook;
369380
}
@@ -499,21 +510,67 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)
499510
strvec_clear(&options->args);
500511
}
501512

513+
/* Determine how many jobs to use for hook execution. */
514+
static unsigned int get_hook_jobs(struct repository *r,
515+
struct run_hooks_opt *options,
516+
struct string_list *hook_list)
517+
{
518+
unsigned int jobs;
519+
520+
/*
521+
* Hooks needing separate output streams must run sequentially. Next
522+
* commits will add an extension to allow parallelizing these as well.
523+
*/
524+
if (!options->stdout_to_stderr)
525+
return 1;
526+
527+
/* An explicit job count (FORCE_SERIAL jobs=1, or -j from CLI). */
528+
if (options->jobs)
529+
return options->jobs;
530+
531+
/*
532+
* Use hook.jobs from the already-parsed config cache (in-repo), or
533+
* fall back to a direct config lookup (out-of-repo). Default to 1.
534+
*/
535+
if (r && r->gitdir && r->hook_config_cache)
536+
/* Use the already-parsed cache (in-repo) */
537+
jobs = r->hook_config_cache->jobs ? r->hook_config_cache->jobs : 1;
538+
else
539+
/* No cache present (out-of-repo call), use direct cfg lookup */
540+
jobs = repo_config_get_uint(r, "hook.jobs", &jobs) ? 1 : jobs;
541+
542+
/*
543+
* Cap to serial any configured hook not marked as parallel = true.
544+
* This enforces the parallel = false default, even for "traditional"
545+
* hooks from the hookdir which cannot be marked parallel = true.
546+
*/
547+
for (size_t i = 0; jobs > 1 && i < hook_list->nr; i++) {
548+
struct hook *h = hook_list->items[i].util;
549+
if (h->kind == HOOK_CONFIGURED && !h->parallel)
550+
jobs = 1;
551+
}
552+
553+
return jobs;
554+
}
555+
502556
int run_hooks_opt(struct repository *r, const char *hook_name,
503557
struct run_hooks_opt *options)
504558
{
559+
struct string_list *hook_list = list_hooks(r, hook_name, options);
505560
struct hook_cb_data cb_data = {
506561
.rc = 0,
507562
.hook_name = hook_name,
563+
.hook_command_list = hook_list,
508564
.options = options,
509565
};
510566
int ret = 0;
567+
unsigned int jobs = get_hook_jobs(r, options, hook_list);
511568
const struct run_process_parallel_opts opts = {
512569
.tr2_category = "hook",
513570
.tr2_label = hook_name,
514571

515-
.processes = options->jobs,
516-
.ungroup = options->jobs == 1,
572+
.processes = jobs,
573+
.ungroup = jobs == 1,
517574

518575
.get_next_task = pick_next_hook,
519576
.start_failure = notify_start_failure,
@@ -529,9 +586,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
529586
if (options->path_to_stdin && options->feed_pipe)
530587
BUG("options path_to_stdin and feed_pipe are mutually exclusive");
531588

532-
if (!options->jobs)
533-
BUG("run_hooks_opt must be called with options.jobs >= 1");
534-
535589
/*
536590
* Ensure cb_data copy and free functions are either provided together,
537591
* or neither one is provided.
@@ -543,7 +597,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
543597
if (options->invoked_hook)
544598
*options->invoked_hook = 0;
545599

546-
cb_data.hook_command_list = list_hooks(r, hook_name, options);
547600
if (!cb_data.hook_command_list->nr) {
548601
if (options->error_if_missing)
549602
ret = error("cannot find a hook named %s", hook_name);

hook.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ struct hook {
2929
} configured;
3030
} u;
3131

32+
/**
33+
* Whether this hook may run in parallel with other hooks for the same
34+
* event. Only useful for configured (named) hooks. Traditional hooks
35+
* always default to 0 (serial). Set via `hook.<name>.parallel = true`.
36+
*/
37+
unsigned int parallel:1;
38+
3239
/**
3340
* Opaque data pointer used to keep internal state across callback calls.
3441
*
@@ -62,6 +69,8 @@ struct run_hooks_opt
6269
*
6370
* If > 1, output will be buffered and de-interleaved (ungroup=0).
6471
* If == 1, output will be real-time (ungroup=1).
72+
* If == 0, the 'hook.jobs' config is used or, if the config is unset,
73+
* defaults to 1 (serial execution).
6574
*/
6675
unsigned int jobs;
6776

@@ -142,7 +151,23 @@ struct run_hooks_opt
142151
cb_data_free_fn feed_pipe_cb_data_free;
143152
};
144153

154+
/**
155+
* Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to
156+
* the 'hook.jobs' config, falling back to serial (1) if unset.
157+
*/
145158
#define RUN_HOOKS_OPT_INIT { \
159+
.env = STRVEC_INIT, \
160+
.args = STRVEC_INIT, \
161+
.stdout_to_stderr = 1, \
162+
.jobs = 0, \
163+
}
164+
165+
/**
166+
* Initializer for hooks that must always run sequentially regardless of
167+
* 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized
168+
* .jobs = 1 is non-overridable.
169+
*/
170+
#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \
146171
.env = STRVEC_INIT, \
147172
.args = STRVEC_INIT, \
148173
.stdout_to_stderr = 1, \

t/t1800-hook.sh

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,57 @@ setup_hookdir () {
2121
test_when_finished rm -rf .git/hooks
2222
}
2323

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+
2475
test_expect_success 'git hook usage' '
2576
test_expect_code 129 git hook &&
2677
test_expect_code 129 git hook run &&
@@ -553,4 +604,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s
553604
check_stdout_merged_to_stderr push-to-checkout
554605
'
555606

607+
test_expect_success 'hook.jobs=1 config runs hooks in series' '
608+
test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
609+
610+
# Use two configured hooks so the execution order is deterministic:
611+
# hook-1 (sentinel) is listed before hook-2 (detector), so hook-1
612+
# always runs first even in serial mode.
613+
test_config hook.hook-1.event test-hook &&
614+
test_config hook.hook-1.command \
615+
"touch sentinel.started; sleep 2; touch sentinel.done" &&
616+
test_config hook.hook-2.event test-hook &&
617+
test_config hook.hook-2.command \
618+
"$(sentinel_detector sentinel hook.order)" &&
619+
620+
test_config hook.jobs 1 &&
621+
622+
git hook run test-hook >out 2>err &&
623+
echo serial >expect &&
624+
test_cmp expect hook.order
625+
'
626+
627+
test_expect_success 'hook.jobs=2 config runs hooks in parallel' '
628+
test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
629+
test_when_finished "rm -rf .git/hooks" &&
630+
631+
mkdir -p .git/hooks &&
632+
write_sentinel_hook .git/hooks/test-hook &&
633+
634+
test_config hook.hook-2.event test-hook &&
635+
test_config hook.hook-2.command \
636+
"$(sentinel_detector sentinel hook.order)" &&
637+
test_config hook.hook-2.parallel true &&
638+
639+
test_config hook.jobs 2 &&
640+
641+
git hook run test-hook >out 2>err &&
642+
echo parallel >expect &&
643+
test_cmp expect hook.order
644+
'
645+
646+
test_expect_success 'hook.<name>.parallel=true enables parallel execution' '
647+
test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
648+
test_config hook.hook-1.event test-hook &&
649+
test_config hook.hook-1.command \
650+
"touch sentinel.started; sleep 2; touch sentinel.done" &&
651+
test_config hook.hook-1.parallel true &&
652+
test_config hook.hook-2.event test-hook &&
653+
test_config hook.hook-2.command \
654+
"$(sentinel_detector sentinel hook.order)" &&
655+
test_config hook.hook-2.parallel true &&
656+
657+
test_config hook.jobs 2 &&
658+
659+
git hook run test-hook >out 2>err &&
660+
echo parallel >expect &&
661+
test_cmp expect hook.order
662+
'
663+
664+
test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '
665+
test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
666+
test_config hook.hook-1.event test-hook &&
667+
test_config hook.hook-1.command \
668+
"touch sentinel.started; sleep 2; touch sentinel.done" &&
669+
test_config hook.hook-2.event test-hook &&
670+
test_config hook.hook-2.command \
671+
"$(sentinel_detector sentinel hook.order)" &&
672+
673+
test_config hook.jobs 2 &&
674+
675+
git hook run test-hook >out 2>err &&
676+
echo serial >expect &&
677+
test_cmp expect hook.order
678+
'
679+
680+
test_expect_success 'one non-parallel hook forces the whole event to run serially' '
681+
test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
682+
test_config hook.hook-1.event test-hook &&
683+
test_config hook.hook-1.command \
684+
"touch sentinel.started; sleep 2; touch sentinel.done" &&
685+
test_config hook.hook-1.parallel true &&
686+
test_config hook.hook-2.event test-hook &&
687+
test_config hook.hook-2.command \
688+
"$(sentinel_detector sentinel hook.order)" &&
689+
# hook-2 has no parallel=true: should force serial for all
690+
691+
test_config hook.jobs 2 &&
692+
693+
git hook run test-hook >out 2>err &&
694+
echo serial >expect &&
695+
test_cmp expect hook.order
696+
'
697+
556698
test_done

0 commit comments

Comments
 (0)