Skip to content

Commit 680e69f

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 b9a4c9a commit 680e69f

4 files changed

Lines changed: 253 additions & 6 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.<friendly-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.<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+
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.<friendly-name>.parallel` set to `true`.

hook.c

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,19 +116,22 @@ struct hook_config_cache_entry {
116116
char *command;
117117
enum config_scope scope;
118118
bool disabled;
119+
bool parallel;
119120
};
120121

121122
/*
122123
* Callback struct to collect all hook.* keys in a single config pass.
123124
* commands: friendly-name to command map.
124125
* event_hooks: event-name to list of friendly-names map.
125126
* disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.
127+
* parallel_hooks: friendly-name to parallel flag.
126128
* jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).
127129
*/
128130
struct hook_all_config_cb {
129131
struct strmap commands;
130132
struct strmap event_hooks;
131133
struct string_list disabled_hooks;
134+
struct strmap parallel_hooks;
132135
unsigned int jobs;
133136
};
134137

@@ -219,6 +222,15 @@ static int hook_config_lookup_all(const char *key, const char *value,
219222
default:
220223
break; /* ignore unrecognised values */
221224
}
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);
222234
}
223235

224236
free(hook_name);
@@ -263,6 +275,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
263275
strmap_init(&cb_data.commands);
264276
strmap_init(&cb_data.event_hooks);
265277
string_list_init_dup(&cb_data.disabled_hooks);
278+
strmap_init(&cb_data.parallel_hooks);
266279

267280
/* Parse all configs in one run, capturing hook.* including hook.jobs. */
268281
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)
282295
struct hook_config_cache_entry *entry;
283296
char *command;
284297

298+
bool is_par = !!strmap_get(&cb_data.parallel_hooks, hname);
285299
bool is_disabled =
286300
!!unsorted_string_list_lookup(
287301
&cb_data.disabled_hooks, hname);
@@ -302,6 +316,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
302316
entry->command = xstrdup_or_null(command);
303317
entry->scope = scope;
304318
entry->disabled = is_disabled;
319+
entry->parallel = is_par;
305320
string_list_append(hooks, hname)->util = entry;
306321
}
307322

@@ -312,6 +327,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
312327
r->hook_jobs = cb_data.jobs;
313328

314329
strmap_clear(&cb_data.commands, 1);
330+
strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */
315331
string_list_clear(&cb_data.disabled_hooks, 0);
316332
strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
317333
string_list_clear(e->value, 0);
@@ -389,6 +405,7 @@ static void list_hooks_add_configured(struct repository *r,
389405
entry->command ? xstrdup(entry->command) : NULL;
390406
hook->u.configured.scope = entry->scope;
391407
hook->u.configured.disabled = entry->disabled;
408+
hook->parallel = entry->parallel;
392409

393410
string_list_append(list, friendly_name)->util = hook;
394411
}
@@ -538,21 +555,75 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)
538555
strvec_clear(&options->args);
539556
}
540557

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+
541609
int run_hooks_opt(struct repository *r, const char *hook_name,
542610
struct run_hooks_opt *options)
543611
{
612+
struct string_list *hook_list = list_hooks(r, hook_name, options);
544613
struct hook_cb_data cb_data = {
545614
.rc = 0,
546615
.hook_name = hook_name,
616+
.hook_command_list = hook_list,
547617
.options = options,
548618
};
549619
int ret = 0;
620+
unsigned int jobs = get_hook_jobs(r, options, hook_list);
550621
const struct run_process_parallel_opts opts = {
551622
.tr2_category = "hook",
552623
.tr2_label = hook_name,
553624

554-
.processes = options->jobs,
555-
.ungroup = options->jobs == 1,
625+
.processes = jobs,
626+
.ungroup = jobs == 1,
556627

557628
.get_next_task = pick_next_hook,
558629
.start_failure = notify_start_failure,
@@ -568,9 +639,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
568639
if (options->path_to_stdin && options->feed_pipe)
569640
BUG("options path_to_stdin and feed_pipe are mutually exclusive");
570641

571-
if (!options->jobs)
572-
BUG("run_hooks_opt must be called with options.jobs >= 1");
573-
574642
/*
575643
* Ensure cb_data copy and free functions are either provided together,
576644
* or neither one is provided.
@@ -581,7 +649,6 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
581649
if (options->invoked_hook)
582650
*options->invoked_hook = 0;
583651

584-
cb_data.hook_command_list = list_hooks(r, hook_name, options);
585652
if (!cb_data.hook_command_list->nr) {
586653
if (options->error_if_missing)
587654
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
@@ -35,6 +35,13 @@ struct hook {
3535
} configured;
3636
} u;
3737

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+
3845
/**
3946
* Opaque data pointer used to keep internal state across callback calls.
4047
*
@@ -72,6 +79,8 @@ struct run_hooks_opt {
7279
*
7380
* If > 1, output will be buffered and de-interleaved (ungroup=0).
7481
* 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).
7584
*/
7685
unsigned int jobs;
7786

@@ -152,7 +161,23 @@ struct run_hooks_opt {
152161
hook_data_free_fn feed_pipe_cb_data_free;
153162
};
154163

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+
*/
155168
#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 { \
156181
.env = STRVEC_INIT, \
157182
.args = STRVEC_INIT, \
158183
.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 &&
@@ -658,4 +709,95 @@ test_expect_success 'server push-to-checkout hook expects stdout redirected to s
658709
check_stdout_merged_to_stderr push-to-checkout
659710
'
660711

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+
661803
test_done

0 commit comments

Comments
 (0)