in a system or global config file and needs to be disabled for a
specific repository. See linkgit:git-hook[1].
+hook.<friendly-name>.parallel::
+ Whether the hook `hook.<friendly-name>` may run in parallel with other hooks
+ for the same event. Defaults to `false`. Set to `true` only when the
+ hook script is safe to run concurrently with other hooks for the same
+ event. If any hook for an event does not have this set to `true`,
+ all hooks for that event run sequentially regardless of `hook.jobs`.
+ Only configured (named) hooks need to declare this. Traditional hooks
+ found in the hooks directory do not need to, and run in parallel when
+ the effective job count is greater than 1. See linkgit:git-hook[1].
+
hook.jobs::
Specifies how many hooks can be run simultaneously during parallelized
hook execution. If unspecified, defaults to 1 (serial execution).
++
+This setting has no effect unless all configured hooks for the event have
+`hook.<friendly-name>.parallel` set to `true`.
char *command;
enum config_scope scope;
bool disabled;
+ bool parallel;
};
/*
* commands: friendly-name to command map.
* event_hooks: event-name to list of friendly-names map.
* disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.
+ * parallel_hooks: friendly-name to parallel flag.
* jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).
*/
struct hook_all_config_cb {
struct strmap commands;
struct strmap event_hooks;
struct string_list disabled_hooks;
+ struct strmap parallel_hooks;
unsigned int jobs;
};
default:
break; /* ignore unrecognised values */
}
+ } else if (!strcmp(subkey, "parallel")) {
+ int v = git_parse_maybe_bool(value);
+ if (v >= 0)
+ strmap_put(&data->parallel_hooks, hook_name,
+ (void *)(uintptr_t)v);
+ else
+ warning(_("hook.%s.parallel must be a boolean,"
+ " ignoring: '%s'"),
+ hook_name, value);
}
free(hook_name);
strmap_init(&cb_data.commands);
strmap_init(&cb_data.event_hooks);
string_list_init_dup(&cb_data.disabled_hooks);
+ strmap_init(&cb_data.parallel_hooks);
/* Parse all configs in one run, capturing hook.* including hook.jobs. */
repo_config(r, hook_config_lookup_all, &cb_data);
struct hook_config_cache_entry *entry;
char *command;
+ bool is_par = !!strmap_get(&cb_data.parallel_hooks, hname);
bool is_disabled =
!!unsorted_string_list_lookup(
&cb_data.disabled_hooks, hname);
entry->command = xstrdup_or_null(command);
entry->scope = scope;
entry->disabled = is_disabled;
+ entry->parallel = is_par;
string_list_append(hooks, hname)->util = entry;
}
r->hook_jobs = cb_data.jobs;
strmap_clear(&cb_data.commands, 1);
+ strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */
string_list_clear(&cb_data.disabled_hooks, 0);
strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
string_list_clear(e->value, 0);
entry->command ? xstrdup(entry->command) : NULL;
hook->u.configured.scope = entry->scope;
hook->u.configured.disabled = entry->disabled;
+ hook->parallel = entry->parallel;
string_list_append(list, friendly_name)->util = hook;
}
strvec_clear(&options->args);
}
+/* Determine how many jobs to use for hook execution. */
+static unsigned int get_hook_jobs(struct repository *r,
+ struct run_hooks_opt *options,
+ struct string_list *hook_list)
+{
+ /*
+ * Hooks needing separate output streams must run sequentially.
+ * Next commit will allow parallelizing these as well.
+ */
+ if (!options->stdout_to_stderr)
+ return 1;
+
+ /*
+ * An explicit job count overrides everything else: this covers both
+ * FORCE_SERIAL callers (for hooks that must never run in parallel)
+ * and the -j flag from the CLI. The CLI override is intentional: users
+ * may want to serialize hooks declared parallel or to parallelize more
+ * aggressively than the default.
+ */
+ if (options->jobs)
+ return options->jobs;
+
+ /*
+ * Use hook.jobs from the already-parsed config cache (in-repo), or
+ * fallback to a direct config lookup (out-of-repo).
+ * Default to 1 (serial execution) on failure.
+ */
+ options->jobs = 1;
+ if (r) {
+ if (r->gitdir && r->hook_config_cache && r->hook_jobs)
+ options->jobs = r->hook_jobs;
+ else
+ repo_config_get_uint(r, "hook.jobs", &options->jobs);
+ }
+
+ /*
+ * Cap to serial any configured hook not marked as parallel = true.
+ * This enforces the parallel = false default, even for "traditional"
+ * hooks from the hookdir which cannot be marked parallel = true.
+ */
+ for (size_t i = 0; i < hook_list->nr; i++) {
+ struct hook *h = hook_list->items[i].util;
+ if (h->kind == HOOK_CONFIGURED && !h->parallel) {
+ options->jobs = 1;
+ break;
+ }
+ }
+
+ return options->jobs;
+}
+
int run_hooks_opt(struct repository *r, const char *hook_name,
struct run_hooks_opt *options)
{
+ struct string_list *hook_list = list_hooks(r, hook_name, options);
struct hook_cb_data cb_data = {
.rc = 0,
.hook_name = hook_name,
+ .hook_command_list = hook_list,
.options = options,
};
int ret = 0;
+ unsigned int jobs = get_hook_jobs(r, options, hook_list);
const struct run_process_parallel_opts opts = {
.tr2_category = "hook",
.tr2_label = hook_name,
- .processes = options->jobs,
- .ungroup = options->jobs == 1,
+ .processes = jobs,
+ .ungroup = jobs == 1,
.get_next_task = pick_next_hook,
.start_failure = notify_start_failure,
if (options->path_to_stdin && options->feed_pipe)
BUG("options path_to_stdin and feed_pipe are mutually exclusive");
- if (!options->jobs)
- BUG("run_hooks_opt must be called with options.jobs >= 1");
-
/*
* Ensure cb_data copy and free functions are either provided together,
* or neither one is provided.
if (options->invoked_hook)
*options->invoked_hook = 0;
- cb_data.hook_command_list = list_hooks(r, hook_name, options);
if (!cb_data.hook_command_list->nr) {
if (options->error_if_missing)
ret = error("cannot find a hook named %s", hook_name);
} configured;
} u;
+ /**
+ * Whether this hook may run in parallel with other hooks for the same
+ * event. Only useful for configured (named) hooks. Traditional hooks
+ * always default to 0 (serial). Set via `hook.<name>.parallel = true`.
+ */
+ bool parallel;
+
/**
* Opaque data pointer used to keep internal state across callback calls.
*
*
* If > 1, output will be buffered and de-interleaved (ungroup=0).
* If == 1, output will be real-time (ungroup=1).
+ * If == 0, the 'hook.jobs' config is used or, if the config is unset,
+ * defaults to 1 (serial execution).
*/
unsigned int jobs;
hook_data_free_fn feed_pipe_cb_data_free;
};
+/**
+ * Default initializer for hooks. Parallelism is opt-in: .jobs = 0 defers to
+ * the 'hook.jobs' config, falling back to serial (1) if unset.
+ */
#define RUN_HOOKS_OPT_INIT { \
+ .env = STRVEC_INIT, \
+ .args = STRVEC_INIT, \
+ .stdout_to_stderr = 1, \
+ .jobs = 0, \
+}
+
+/**
+ * Initializer for hooks that must always run sequentially regardless of
+ * 'hook.jobs'. Use this when git knows the hook cannot safely be parallelized
+ * .jobs = 1 is non-overridable.
+ */
+#define RUN_HOOKS_OPT_INIT_FORCE_SERIAL { \
.env = STRVEC_INIT, \
.args = STRVEC_INIT, \
.stdout_to_stderr = 1, \
test_when_finished rm -rf .git/hooks
}
+# write_sentinel_hook <path> [sentinel]
+#
+# Writes a hook that marks itself as started, sleeps for a few seconds, then
+# marks itself done. The sleep must be long enough that sentinel_detector can
+# observe <sentinel>.started before <sentinel>.done appears when both hooks
+# run concurrently in parallel mode.
+write_sentinel_hook () {
+ sentinel="${2:-sentinel}"
+ write_script "$1" <<-EOF
+ touch ${sentinel}.started &&
+ sleep 2 &&
+ touch ${sentinel}.done
+ EOF
+}
+
+# sentinel_detector <sentinel> <output>
+#
+# Returns a shell command string suitable for use as hook.<name>.command.
+# The detector must be registered after the sentinel:
+# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)
+# before the detector starts.
+# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared
+# yet and the detector just sees <sentinel>.started.
+#
+# At start, poll until <sentinel>.started exists to absorb startup jitter, then
+# write to <output>:
+# 1. 'serial' if <sentinel>.done exists (sentinel finished before we started),
+# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),
+# 3. 'timeout' if <sentinel>.started never appeared.
+#
+# The command ends with ':' so when git appends "$@" for hooks that receive
+# positional arguments (e.g. pre-push), the result ': "$@"' is valid shell
+# rather than a syntax error 'fi "$@"'.
+sentinel_detector () {
+ cat <<-EOF
+ i=0
+ while ! test -f ${1}.started && test \$i -lt 10; do
+ sleep 1
+ i=\$((i+1))
+ done
+ if test -f ${1}.done; then
+ echo serial >${2}
+ elif test -f ${1}.started; then
+ echo parallel >${2}
+ else
+ echo timeout >${2}
+ fi
+ :
+ EOF
+}
+
test_expect_success 'git hook usage' '
test_expect_code 129 git hook &&
test_expect_code 129 git hook run &&
check_stdout_merged_to_stderr push-to-checkout
'
+test_expect_success 'hook.jobs=1 config runs hooks in series' '
+ test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
+
+ # Use two configured hooks so the execution order is deterministic:
+ # hook-1 (sentinel) is listed before hook-2 (detector), so hook-1
+ # always runs first even in serial mode.
+ test_config hook.hook-1.event test-hook &&
+ test_config hook.hook-1.command \
+ "touch sentinel.started; sleep 2; touch sentinel.done" &&
+ test_config hook.hook-2.event test-hook &&
+ test_config hook.hook-2.command \
+ "$(sentinel_detector sentinel hook.order)" &&
+
+ test_config hook.jobs 1 &&
+
+ git hook run --allow-unknown-hook-name test-hook >out 2>err &&
+ echo serial >expect &&
+ test_cmp expect hook.order
+'
+
+test_expect_success 'hook.jobs=2 config runs hooks in parallel' '
+ test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
+ test_when_finished "rm -rf .git/hooks" &&
+
+ mkdir -p .git/hooks &&
+ write_sentinel_hook .git/hooks/test-hook &&
+
+ test_config hook.hook-2.event test-hook &&
+ test_config hook.hook-2.command \
+ "$(sentinel_detector sentinel hook.order)" &&
+ test_config hook.hook-2.parallel true &&
+
+ test_config hook.jobs 2 &&
+
+ git hook run --allow-unknown-hook-name test-hook >out 2>err &&
+ echo parallel >expect &&
+ test_cmp expect hook.order
+'
+
+test_expect_success 'hook.<name>.parallel=true enables parallel execution' '
+ test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
+ test_config hook.hook-1.event test-hook &&
+ test_config hook.hook-1.command \
+ "touch sentinel.started; sleep 2; touch sentinel.done" &&
+ test_config hook.hook-1.parallel true &&
+ test_config hook.hook-2.event test-hook &&
+ test_config hook.hook-2.command \
+ "$(sentinel_detector sentinel hook.order)" &&
+ test_config hook.hook-2.parallel true &&
+
+ test_config hook.jobs 2 &&
+
+ git hook run --allow-unknown-hook-name test-hook >out 2>err &&
+ echo parallel >expect &&
+ test_cmp expect hook.order
+'
+
+test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '
+ test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
+ test_config hook.hook-1.event test-hook &&
+ test_config hook.hook-1.command \
+ "touch sentinel.started; sleep 2; touch sentinel.done" &&
+ test_config hook.hook-2.event test-hook &&
+ test_config hook.hook-2.command \
+ "$(sentinel_detector sentinel hook.order)" &&
+
+ test_config hook.jobs 2 &&
+
+ git hook run --allow-unknown-hook-name test-hook >out 2>err &&
+ echo serial >expect &&
+ test_cmp expect hook.order
+'
+
+test_expect_success 'one non-parallel hook forces the whole event to run serially' '
+ test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
+ test_config hook.hook-1.event test-hook &&
+ test_config hook.hook-1.command \
+ "touch sentinel.started; sleep 2; touch sentinel.done" &&
+ test_config hook.hook-1.parallel true &&
+ test_config hook.hook-2.event test-hook &&
+ test_config hook.hook-2.command \
+ "$(sentinel_detector sentinel hook.order)" &&
+ # hook-2 has no parallel=true: should force serial for all
+
+ test_config hook.jobs 2 &&
+
+ git hook run --allow-unknown-hook-name test-hook >out 2>err &&
+ echo serial >expect &&
+ test_cmp expect hook.order
+'
+
test_done