hook: add hook.<event>.enabled switch

Add a hook.<event>.enabled config key that disables all hooks for a given event, when set to false, acting as a high-level switch above the existing per-hook hook.<friendly-name>.enabled. Event-disabled hooks are shown in "git hook list" with an "event-disabled" tab-separated prefix before the name: $ git hook list test-hook event-disabled hook-1 event-disabled hook-2 With --show-scope: $ git hook list --show-scope test-hook local event-disabled hook-1 When a hook is both per-hook disabled and event-disabled, only "event-disabled" is shown: the event-level switch is the more relevant piece of information, and the per-hook "disabled" status will surface once the event is re-enabled. Using an event name as a friendly-name (e.g. hook.<event>.enabled) can cause ambiguity, so a fatal error is issued when using a known event name and a warning is issued for unknown event name, since a collision cannot be detected with certainty for unknown events. Suggested-by: Patrick Steinhardt <ps@pks.im> Suggested-by: Junio C Hamano <gitster@pobox.com> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Adrian Ratiu committed Apr 10, 2026 at 12:06 UTC dcfb5af67e7d7156c4d1ede66de18088c990356c
7 files changed +165 -11
Documentation/config/hook.adoc
+20
@@ -15,6 +15,12 @@ hook.<friendly-name>.event::
15 events, specify the key more than once. An empty value resets
16 the list of events, clearing any previously defined events for
17 `hook.<friendly-name>`. See linkgit:git-hook[1].
18 ++
19 +The `<friendly-name>` must not be the same as a known hook event name
20 +(e.g. do not use `hook.pre-commit.event`). Using a known event name as
21 +a friendly-name is a fatal error because it creates an ambiguity with
22 +`hook.<event>.enabled` and `hook.<event>.jobs`. For unknown event names,
23 +a warning is issued when `<friendly-name>` matches the event value.
24
25 hook.<friendly-name>.enabled::
26 Whether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.
@@ -33,6 +39,20 @@ hook.<friendly-name>.parallel::
39 found in the hooks directory do not need to, and run in parallel when
40 the effective job count is greater than 1. See linkgit:git-hook[1].
41
42 +hook.<event>.enabled::
43 + Switch to enable or disable all hooks for the `<event>` hook event.
44 + When set to `false`, no hooks fire for that event, regardless of any
45 + per-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`.
46 + See linkgit:git-hook[1].
47 ++
48 +Note on naming: `<event>` must be the event name (e.g. `pre-commit`),
49 +not a hook friendly-name. Since using a known event name as a
50 +friendly-name is disallowed (see `hook.<friendly-name>.event` above),
51 +there is no ambiguity between event-level and per-hook `.enabled`
52 +settings for known events. For unknown events, if a friendly-name
53 +matches the event name despite the warning, `.enabled` is treated
54 +as per-hook only.
55 +
56 hook.<event>.jobs::
57 Specifies how many hooks can be run simultaneously for the `<event>`
58 hook event (e.g. `hook.post-receive.jobs = 4`). Overrides `hook.jobs`
builtin/hook.c
+14 -6
@@ -87,14 +87,22 @@ static int list(int argc, const char **argv, const char *prefix,
87 const char *name = h->u.configured.friendly_name;
88 const char *scope = show_scope ?
89 config_scope_name(h->u.configured.scope) : NULL;
90 + /*
91 + * Show the most relevant disable reason. Event-level
92 + * takes precedence: if the whole event is off, that
93 + * is what the user needs to know. The per-hook
94 + * "disabled" surfaces once the event is re-enabled.
95 + */
96 + const char *disability =
97 + h->u.configured.event_disabled ? "event-disabled\t" :
98 + h->u.configured.disabled ? "disabled\t" :
99 + "";
100 if (scope)
91 - printf("%s\t%s%s%c", scope,
92 - h->u.configured.disabled ? "disabled\t" : "",
93 - name, line_terminator);
101 + printf("%s\t%s%s%c", scope, disability, name,
102 + line_terminator);
103 else
95 - printf("%s%s%c",
96 - h->u.configured.disabled ? "disabled\t" : "",
97 - name, line_terminator);
104 + printf("%s%s%c", disability, name,
105 + line_terminator);
106 break;
107 }
108 default:
hook.c
+42 -5
@@ -133,7 +133,9 @@ struct hook_config_cache_entry {
133 * Callback struct to collect all hook.* keys in a single config pass.
134 * commands: friendly-name to command map.
135 * event_hooks: event-name to list of friendly-names map.
136 - * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.
136 + * disabled_hooks: set of all names with hook.<name>.enabled = false; after
137 + * parsing, names that are not friendly-names become event-level
138 + * disables stored in r->disabled_events. This collects all.
139 * parallel_hooks: friendly-name to parallel flag.
140 * event_jobs: event-name to per-event jobs count (stored as uintptr_t, NULL == unset).
141 * jobs: value of the global hook.jobs key. Defaults to 0 if unset (stored in r->hook_jobs).
@@ -189,8 +191,21 @@ static int hook_config_lookup_all(const char *key, const char *value,
191 strmap_for_each_entry(&data->event_hooks, &iter, e)
192 unsorted_string_list_remove(e->value, hook_name, 0);
193 } else {
192 - struct string_list *hooks =
193 - strmap_get(&data->event_hooks, value);
194 + struct string_list *hooks;
195 +
196 + if (is_known_hook(hook_name))
197 + die(_("hook friendly-name '%s' collides with "
198 + "a known event name; please choose a "
199 + "different friendly-name"),
200 + hook_name);
201 +
202 + if (!strcmp(hook_name, value))
203 + warning(_("hook friendly-name '%s' is the "
204 + "same as its event; this may cause "
205 + "ambiguity with hook.%s.enabled"),
206 + hook_name, hook_name);
207 +
208 + hooks = strmap_get(&data->event_hooks, value);
209
210 if (!hooks) {
211 CALLOC_ARRAY(hooks, 1);
@@ -345,6 +360,22 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
360
361 warn_jobs_on_friendly_names(&cb_data);
362
363 + /*
364 + * Populate disabled_events: names in disabled_hooks that are not
365 + * friendly-names are event-level switches (hook.<event>.enabled = false).
366 + * Names that are friendly-names are already handled per-hook via the
367 + * hook_config_cache_entry.disabled flag below.
368 + */
369 + if (r) {
370 + string_list_clear(&r->disabled_events, 0);
371 + string_list_init_dup(&r->disabled_events);
372 + for (size_t i = 0; i < cb_data.disabled_hooks.nr; i++) {
373 + const char *n = cb_data.disabled_hooks.items[i].string;
374 + if (!is_friendly_name(&cb_data, n))
375 + string_list_append(&r->disabled_events, n);
376 + }
377 + }
378 +
379 /* Construct the cache from parsed configs. */
380 strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
381 struct string_list *hook_names = e->value;
@@ -446,6 +477,8 @@ static void list_hooks_add_configured(struct repository *r,
477 {
478 struct strmap *cache = get_hook_config_cache(r);
479 struct string_list *configured_hooks = strmap_get(cache, hookname);
480 + bool event_is_disabled = r ? !!unsorted_string_list_lookup(&r->disabled_events,
481 + hookname) : 0;
482
483 /* Iterate through configured hooks and initialize internal states */
484 for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {
@@ -472,6 +505,7 @@ static void list_hooks_add_configured(struct repository *r,
505 entry->command ? xstrdup(entry->command) : NULL;
506 hook->u.configured.scope = entry->scope;
507 hook->u.configured.disabled = entry->disabled;
508 + hook->u.configured.event_disabled = event_is_disabled;
509 hook->parallel = entry->parallel;
510
511 string_list_append(list, friendly_name)->util = hook;
@@ -484,6 +518,8 @@ static void list_hooks_add_configured(struct repository *r,
518 if (!r || !r->gitdir) {
519 hook_cache_clear(cache);
520 free(cache);
521 + if (r)
522 + string_list_clear(&r->disabled_events, 0);
523 }
524 }
525
@@ -515,7 +551,7 @@ int hook_exists(struct repository *r, const char *name)
551 for (size_t i = 0; i < hooks->nr; i++) {
552 struct hook *h = hooks->items[i].util;
553 if (h->kind == HOOK_TRADITIONAL ||
518 - !h->u.configured.disabled) {
554 + (!h->u.configured.disabled && !h->u.configured.event_disabled)) {
555 exists = 1;
556 break;
557 }
@@ -538,7 +574,8 @@ static int pick_next_hook(struct child_process *cp,
574 if (hook_cb->hook_to_run_index >= hook_list->nr)
575 return 0;
576 h = hook_list->items[hook_cb->hook_to_run_index++].util;
541 - } while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled);
577 + } while (h->kind == HOOK_CONFIGURED &&
578 + (h->u.configured.disabled || h->u.configured.event_disabled));
579
580 cp->no_stdin = 1;
581 strvec_pushv(&cp->env, hook_cb->options->env.v);
hook.h
+1
@@ -32,6 +32,7 @@ struct hook {
32 const char *command;
33 enum config_scope scope;
34 bool disabled;
35 + bool event_disabled;
36 } configured;
37 } u;
38
repository.c
+1
@@ -427,6 +427,7 @@ void repo_clear(struct repository *repo)
427 FREE_AND_NULL(repo->hook_config_cache);
428 }
429 strmap_clear(&repo->event_jobs, 0); /* values are uintptr_t, not heap ptrs */
430 + string_list_clear(&repo->disabled_events, 0);
431
432 if (repo->promisor_remote_config) {
433 promisor_remote_clear(repo->promisor_remote_config);
repository.h
+4
@@ -2,6 +2,7 @@
2 #define REPOSITORY_H
3
4 #include "strmap.h"
5 +#include "string-list.h"
6 #include "repo-settings.h"
7 #include "environment.h"
8
@@ -178,6 +179,9 @@ struct repository {
179 /* Cached map of event-name -> jobs count (as uintptr_t) from hook.<event>.jobs. */
180 struct strmap event_jobs;
181
182 + /* Cached list of event names with hook.<event>.enabled = false. */
183 + struct string_list disabled_events;
184 +
185 /* Configurations related to promisor remotes. */
186 char *repository_format_partial_clone;
187 struct promisor_remote_config *promisor_remote_config;
t/t1800-hook.sh
+83
@@ -1058,4 +1058,87 @@ test_expect_success 'hook.<event>.jobs does not warn for a real event name' '
1058 test_grep ! "friendly-name" err
1059 '
1060
1061 +test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '
1062 + test_config hook.hook-1.event test-hook &&
1063 + test_config hook.hook-1.command "echo ran" &&
1064 + test_config hook.test-hook.enabled false &&
1065 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
1066 + test_must_be_empty out
1067 +'
1068 +
1069 +test_expect_success 'hook.<event>.enabled=true does not suppress hooks' '
1070 + test_config hook.hook-1.event test-hook &&
1071 + test_config hook.hook-1.command "echo ran" &&
1072 + test_config hook.test-hook.enabled true &&
1073 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
1074 + test_grep "ran" err
1075 +'
1076 +
1077 +test_expect_success 'hook.<event>.enabled=false does not affect other events' '
1078 + test_config hook.hook-1.event test-hook &&
1079 + test_config hook.hook-1.command "echo ran" &&
1080 + test_config hook.other-event.enabled false &&
1081 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
1082 + test_grep "ran" err
1083 +'
1084 +
1085 +test_expect_success 'hook.<friendly-name>.enabled=false still disables that hook' '
1086 + test_config hook.hook-1.event test-hook &&
1087 + test_config hook.hook-1.command "echo hook-1" &&
1088 + test_config hook.hook-2.event test-hook &&
1089 + test_config hook.hook-2.command "echo hook-2" &&
1090 + test_config hook.hook-1.enabled false &&
1091 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
1092 + test_grep ! "hook-1" err &&
1093 + test_grep "hook-2" err
1094 +'
1095 +
1096 +test_expect_success 'git hook list shows event-disabled hooks as event-disabled' '
1097 + test_config hook.hook-1.event test-hook &&
1098 + test_config hook.hook-1.command "echo ran" &&
1099 + test_config hook.hook-2.event test-hook &&
1100 + test_config hook.hook-2.command "echo ran" &&
1101 + test_config hook.test-hook.enabled false &&
1102 + git hook list --allow-unknown-hook-name test-hook >actual &&
1103 + test_grep "^event-disabled hook-1$" actual &&
1104 + test_grep "^event-disabled hook-2$" actual
1105 +'
1106 +
1107 +test_expect_success 'git hook list shows scope with event-disabled' '
1108 + test_config hook.hook-1.event test-hook &&
1109 + test_config hook.hook-1.command "echo ran" &&
1110 + test_config hook.test-hook.enabled false &&
1111 + git hook list --allow-unknown-hook-name --show-scope test-hook >actual &&
1112 + test_grep "^local event-disabled hook-1$" actual
1113 +'
1114 +
1115 +test_expect_success 'git hook list still shows hooks when event is disabled' '
1116 + test_config hook.hook-1.event test-hook &&
1117 + test_config hook.hook-1.command "echo ran" &&
1118 + test_config hook.test-hook.enabled false &&
1119 + git hook list --allow-unknown-hook-name test-hook >actual &&
1120 + test_grep "event-disabled" actual
1121 +'
1122 +
1123 +test_expect_success 'friendly-name matching known event name is rejected' '
1124 + test_config hook.pre-commit.event pre-commit &&
1125 + test_config hook.pre-commit.command "echo oops" &&
1126 + test_must_fail git hook run pre-commit 2>err &&
1127 + test_grep "collides with a known event name" err
1128 +'
1129 +
1130 +test_expect_success 'friendly-name matching known event name is rejected even for different event' '
1131 + test_config hook.pre-commit.event post-commit &&
1132 + test_config hook.pre-commit.command "echo oops" &&
1133 + test_must_fail git hook run post-commit 2>err &&
1134 + test_grep "collides with a known event name" err
1135 +'
1136 +
1137 +test_expect_success 'friendly-name matching unknown event warns' '
1138 + test_config hook.test-hook.event test-hook &&
1139 + test_config hook.test-hook.command "echo ran" &&
1140 + git hook run --allow-unknown-hook-name test-hook >out 2>err &&
1141 + test_grep "same as its event" err
1142 +'
1143 +
1144 test_done