]> git.ipfire.org Git - thirdparty/git.git/commitdiff
hook: add per-event jobs config
authorAdrian Ratiu <adrian.ratiu@collabora.com>
Fri, 10 Apr 2026 09:06:03 +0000 (12:06 +0300)
committerJunio C Hamano <gitster@pobox.com>
Fri, 10 Apr 2026 14:58:54 +0000 (07:58 -0700)
Add a hook.<event>.jobs count config that allows users to override the
global hook.jobs setting for specific hook events.

This allows finer-grained control over parallelism on a per-event basis.

For example, to run `post-receive` hooks with up to 4 parallel jobs
while keeping other events at their global default:

[hook]
    post-receive.jobs = 4

Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
Documentation/config/hook.adoc
hook.c
repository.c
repository.h
t/t1800-hook.sh

index 6f60775c28a90218efeae2447d90ab6381998c15..d4fa29d936d6e2fa0a4d8b637293e8e0c8f373ab 100644 (file)
@@ -33,9 +33,28 @@ hook.<friendly-name>.parallel::
        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.<event>.jobs::
+       Specifies how many hooks can be run simultaneously for the `<event>`
+       hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`
+       for this specific event. The same parallelism restrictions apply: this
+       setting has no effect unless all configured hooks for the event have
+       `hook.<friendly-name>.parallel` set to `true`. Must be a positive int,
+       zero is rejected with a warning. See linkgit:git-hook[1].
++
+Note on naming: although this key resembles `hook.<friendly-name>.*`
+(a per-hook setting), `<event>` must be the event name, not a hook
+friendly name. The key component is stored literally and looked up by
+event name at runtime with no translation between the two namespaces.
+A key like `hook.my-hook.jobs` is stored under `"my-hook"` but the
+lookup at runtime uses the event name (e.g. `"post-receive"`), so
+`hook.my-hook.jobs` is silently ignored even when `my-hook` is
+registered for that event. Use `hook.post-receive.jobs` or any other
+valid event name when setting `hook.<event>.jobs`.
+
 hook.jobs::
        Specifies how many hooks can be run simultaneously during parallelized
        hook execution. If unspecified, defaults to 1 (serial execution).
+       Can be overridden on a per-event basis with `hook.<event>.jobs`.
        Some hooks always run sequentially regardless of this setting because
        they operate on shared data and cannot safely be parallelized:
 +
diff --git a/hook.c b/hook.c
index c0b71322cf2ef6a644cf3563d1575fe2c47a743c..d98b01156366a4b2d7895deacbcfc8dcad3b82e1 100644 (file)
--- a/hook.c
+++ b/hook.c
@@ -125,6 +125,7 @@ struct hook_config_cache_entry {
  * 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.
+ * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).
  * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).
  */
 struct hook_all_config_cb {
@@ -132,6 +133,7 @@ struct hook_all_config_cb {
        struct strmap event_hooks;
        struct string_list disabled_hooks;
        struct strmap parallel_hooks;
+       struct strmap event_jobs;
        unsigned int jobs;
 };
 
@@ -231,6 +233,18 @@ static int hook_config_lookup_all(const char *key, const char *value,
                        warning(_("hook.%s.parallel must be a boolean,"
                                  " ignoring: '%s'"),
                                hook_name, value);
+       } else if (!strcmp(subkey, "jobs")) {
+               unsigned int v;
+               if (!git_parse_uint(value, &v))
+                       warning(_("hook.%s.jobs must be a positive integer,"
+                                 " ignoring: '%s'"),
+                               hook_name, value);
+               else if (!v)
+                       warning(_("hook.%s.jobs must be positive,"
+                                 " ignoring: 0"), hook_name);
+               else
+                       strmap_put(&data->event_jobs, hook_name,
+                                  (void *)(uintptr_t)v);
        }
 
        free(hook_name);
@@ -276,6 +290,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
        strmap_init(&cb_data.event_hooks);
        string_list_init_dup(&cb_data.disabled_hooks);
        strmap_init(&cb_data.parallel_hooks);
+       strmap_init(&cb_data.event_jobs);
 
        /* Parse all configs in one run, capturing hook.* including hook.jobs. */
        repo_config(r, hook_config_lookup_all, &cb_data);
@@ -323,8 +338,10 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
                strmap_put(cache, e->key, hooks);
        }
 
-       if (r)
+       if (r) {
                r->hook_jobs = cb_data.jobs;
+               r->event_jobs = cb_data.event_jobs;
+       }
 
        strmap_clear(&cb_data.commands, 1);
        strmap_clear(&cb_data.parallel_hooks, 0); /* values are uintptr_t, not heap ptrs */
@@ -587,6 +604,7 @@ static void warn_non_parallel_hooks_override(unsigned int jobs,
 /* Determine how many jobs to use for hook execution. */
 static unsigned int get_hook_jobs(struct repository *r,
                                  struct run_hooks_opt *options,
+                                 const char *hook_name,
                                  struct string_list *hook_list)
 {
        /*
@@ -606,16 +624,34 @@ static unsigned int get_hook_jobs(struct repository *r,
         */
        options->jobs = 1;
        if (r) {
-               if (r->gitdir && r->hook_config_cache && r->hook_jobs)
-                       options->jobs = r->hook_jobs;
-               else
+               if (r->gitdir && r->hook_config_cache) {
+                       void *event_jobs;
+
+                       if (r->hook_jobs)
+                               options->jobs = r->hook_jobs;
+
+                       event_jobs = strmap_get(&r->event_jobs, hook_name);
+                       if (event_jobs)
+                               options->jobs = (unsigned int)(uintptr_t)event_jobs;
+               } else {
+                       unsigned int event_jobs;
+                       char *key;
+
                        repo_config_get_uint(r, "hook.jobs", &options->jobs);
+
+                       key = xstrfmt("hook.%s.jobs", hook_name);
+                       if (!repo_config_get_uint(r, key, &event_jobs) && event_jobs)
+                               options->jobs = event_jobs;
+                       free(key);
+               }
        }
 
        /*
         * 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.
+        * The same restriction applies whether jobs came from hook.jobs or
+        * hook.<event>.jobs.
         */
        for (size_t i = 0; i < hook_list->nr; i++) {
                struct hook *h = hook_list->items[i].util;
@@ -642,7 +678,7 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
                .options = options,
        };
        int ret = 0;
-       unsigned int jobs = get_hook_jobs(r, options, hook_list);
+       unsigned int jobs = get_hook_jobs(r, options, hook_name, hook_list);
        const struct run_process_parallel_opts opts = {
                .tr2_category = "hook",
                .tr2_label = hook_name,
index 192d6dc9c477fa44538fe4d5c701b89e7c760e35..4030db4460714d681ad0c3674b8dcf50deefaf0d 100644 (file)
@@ -426,6 +426,7 @@ void repo_clear(struct repository *repo)
                hook_cache_clear(repo->hook_config_cache);
                FREE_AND_NULL(repo->hook_config_cache);
        }
+       strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */
 
        if (repo->promisor_remote_config) {
                promisor_remote_clear(repo->promisor_remote_config);
index 58e46853d089bf60d9456360c3127d94fd166a6f..6b67ec02e2984c18bb4cea6a81834ab894a1dd7f 100644 (file)
@@ -175,6 +175,9 @@ struct repository {
        /* Cached value of hook.jobs config (0 if unset, defaults to serial). */
        unsigned int hook_jobs;
 
+       /* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */
+       struct strmap event_jobs;
+
        /* Configurations related to promisor remotes. */
        char *repository_format_partial_clone;
        struct promisor_remote_config *promisor_remote_config;
index aa37a5181a0e0e34c078e76f6b906607d70d68b7..24a3c92b6deb8043ef913593966b98d54d6cfb97 100755 (executable)
@@ -969,4 +969,63 @@ test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)'
        test_cmp expect hook.order
 '
 
+test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '
+       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 &&
+
+       # Global hook.jobs=1 (serial), but per-event override allows parallel.
+       test_config hook.jobs 1 &&
+       test_config hook.test-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.<event>.jobs=1 forces serial even when hook.jobs>1' '
+       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 &&
+
+       # Global hook.jobs=4 allows parallel, but per-event override forces serial.
+       test_config hook.jobs 4 &&
+       test_config hook.test-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.<event>.jobs still requires hook.<name>.parallel=true' '
+       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" &&
+       # hook-1 intentionally has no parallel=true
+       test_config hook.hook-2.event test-hook &&
+       test_config hook.hook-2.command \
+           "$(sentinel_detector sentinel hook.order)" &&
+       # hook-2 also has no parallel=true
+
+       # Per-event jobs=2 but no hook has parallel=true: must still run serially.
+       test_config hook.test-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