hook: reject unknown hook names in git-hook(1)

Teach "git hook run" and "git hook list" to reject hook event names that are not recognized by Git. This helps catch typos such as "prereceive" when "pre-receive" was intended, since in 99% of the cases users want known (already-existing) hook names. The list of known hooks is derived from the generated hook-list.h (built from Documentation/githooks.adoc). This is why the Makefile is updated, so builtin/hook.c depends on hook-list.h. In meson the header is already a dependency for all builtins, no change required. The "--allow-unknown-hook-name" flag can be used to bypass this check. Suggested-by: Patrick Steinhardt <ps@pks.im> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Adrian Ratiu committed Mar 25, 2026 at 21:55 UTC 5c58dbc887a1f3530cb29c995f63675beebb22e9
4 files changed +100 -31
Documentation/git-hook.adoc
+10 -3
@@ -8,8 +8,8 @@ git-hook - Run git hooks
8 SYNOPSIS
9 --------
10 [verse]
11 -'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
12 -'git hook' list [-z] [--show-scope] <hook-name>
11 +'git hook' run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
12 +'git hook' list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>
13
14 DESCRIPTION
15 -----------
@@ -121,6 +121,13 @@ list [-z] [--show-scope]::
121 OPTIONS
122 -------
123
124 +--allow-unknown-hook-name::
125 + By default `git hook run` and `git hook list` will bail out when
126 + `<hook-name>` is not a hook event known to Git (see linkgit:githooks[5]
127 + for the list of known hooks). This is meant to help catch typos
128 + such as `prereceive` when `pre-receive` was intended. Pass this
129 + flag to allow unknown hook names.
130 +
131 --to-stdin::
132 For "run"; specify a file which will be streamed into the
133 hook's stdin. The hook will receive the entire file from
@@ -159,7 +166,7 @@ Then, in your 'mywrapper' tool, you can invoke any users' configured hooks by
166 running:
167
168 ----
162 -git hook run mywrapper-start-tests \
169 +git hook run --allow-unknown-hook-name mywrapper-start-tests \
170 # providing something to stdin
171 --stdin some-tempfile-123 \
172 # execute hooks in serial
Makefile
+1
@@ -2663,6 +2663,7 @@ git$X: git.o GIT-LDFLAGS $(BUILTIN_OBJS) $(GITLIBS)
2663
2664 help.sp help.s help.o: command-list.h
2665 builtin/bugreport.sp builtin/bugreport.s builtin/bugreport.o: hook-list.h
2666 +builtin/hook.sp builtin/hook.s builtin/hook.o: hook-list.h
2667
2668 builtin/help.sp builtin/help.s builtin/help.o: config-list.h GIT-PREFIX
2669 builtin/help.sp builtin/help.s builtin/help.o: EXTRA_CPPFLAGS = \
builtin/hook.c
+33 -2
@@ -4,12 +4,22 @@
4 #include "environment.h"
5 #include "gettext.h"
6 #include "hook.h"
7 +#include "hook-list.h"
8 #include "parse-options.h"
9
10 #define BUILTIN_HOOK_RUN_USAGE \
10 - N_("git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
11 + N_("git hook run [--allow-unknown-hook-name] [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
12 #define BUILTIN_HOOK_LIST_USAGE \
12 - N_("git hook list [-z] [--show-scope] <hook-name>")
13 + N_("git hook list [--allow-unknown-hook-name] [-z] [--show-scope] <hook-name>")
14 +
15 +static int is_known_hook(const char *name)
16 +{
17 + const char **p;
18 + for (p = hook_name_list; *p; p++)
19 + if (!strcmp(*p, name))
20 + return 1;
21 + return 0;
22 +}
23
24 static const char * const builtin_hook_usage[] = {
25 BUILTIN_HOOK_RUN_USAGE,
@@ -34,6 +44,7 @@ static int list(int argc, const char **argv, const char *prefix,
44 const char *hookname = NULL;
45 int line_terminator = '\n';
46 int show_scope = 0;
47 + int allow_unknown = 0;
48 int ret = 0;
49
50 struct option list_options[] = {
@@ -41,6 +52,8 @@ static int list(int argc, const char **argv, const char *prefix,
52 N_("use NUL as line terminator"), '\0'),
53 OPT_BOOL(0, "show-scope", &show_scope,
54 N_("show the config scope that defined each hook")),
55 + OPT_BOOL(0, "allow-unknown-hook-name", &allow_unknown,
56 + N_("allow running a hook with a non-native hook name")),
57 OPT_END(),
58 };
59
@@ -57,6 +70,13 @@ static int list(int argc, const char **argv, const char *prefix,
70
71 hookname = argv[0];
72
73 + if (!allow_unknown && !is_known_hook(hookname)) {
74 + error(_("unknown hook event '%s';\n"
75 + "use --allow-unknown-hook-name to allow non-native hook names"),
76 + hookname);
77 + return 1;
78 + }
79 +
80 head = list_hooks(repo, hookname, NULL);
81
82 if (!head->nr) {
@@ -103,8 +123,11 @@ static int run(int argc, const char **argv, const char *prefix,
123 int i;
124 struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
125 int ignore_missing = 0;
126 + int allow_unknown = 0;
127 const char *hook_name;
128 struct option run_options[] = {
129 + OPT_BOOL(0, "allow-unknown-hook-name", &allow_unknown,
130 + N_("allow running a hook with a non-native hook name")),
131 OPT_BOOL(0, "ignore-missing", &ignore_missing,
132 N_("silently ignore missing requested <hook-name>")),
133 OPT_STRING(0, "to-stdin", &opt.path_to_stdin, N_("path"),
@@ -136,6 +159,14 @@ static int run(int argc, const char **argv, const char *prefix,
159 repo_config(the_repository, git_default_config, NULL);
160
161 hook_name = argv[0];
162 +
163 + if (!allow_unknown && !is_known_hook(hook_name)) {
164 + error(_("unknown hook event '%s';\n"
165 + "use --allow-unknown-hook-name to allow non-native hook names"),
166 + hook_name);
167 + return 1;
168 + }
169 +
170 if (!ignore_missing)
171 opt.error_if_missing = 1;
172 ret = run_hooks_opt(the_repository, hook_name, &opt);
t/t1800-hook.sh
+56 -26
@@ -31,11 +31,41 @@ test_expect_success 'git hook usage' '
31 grep "unknown option" err
32 '
33
34 +test_expect_success 'git hook list: unknown hook name is rejected' '
35 + test_must_fail git hook list prereceive 2>err &&
36 + test_grep "unknown hook event" err
37 +'
38 +
39 +test_expect_success 'git hook run: unknown hook name is rejected' '
40 + test_must_fail git hook run prereceive 2>err &&
41 + test_grep "unknown hook event" err
42 +'
43 +
44 +test_expect_success 'git hook list: known hook name is accepted' '
45 + test_must_fail git hook list pre-receive 2>err &&
46 + test_grep ! "unknown hook event" err
47 +'
48 +
49 +test_expect_success 'git hook run: known hook name is accepted' '
50 + git hook run --ignore-missing pre-receive 2>err &&
51 + test_grep ! "unknown hook event" err
52 +'
53 +
54 +test_expect_success 'git hook run: --allow-unknown-hook-name overrides rejection' '
55 + git hook run --allow-unknown-hook-name --ignore-missing custom-hook 2>err &&
56 + test_grep ! "unknown hook event" err
57 +'
58 +
59 +test_expect_success 'git hook list: --allow-unknown-hook-name overrides rejection' '
60 + test_must_fail git hook list --allow-unknown-hook-name custom-hook 2>err &&
61 + test_grep ! "unknown hook event" err
62 +'
63 +
64 test_expect_success 'git hook list: nonexistent hook' '
65 cat >stderr.expect <<-\EOF &&
66 warning: no hooks found for event '\''test-hook'\''
67 EOF
38 - test_expect_code 1 git hook list test-hook 2>stderr.actual &&
68 + test_expect_code 1 git hook list --allow-unknown-hook-name test-hook 2>stderr.actual &&
69 test_cmp stderr.expect stderr.actual
70 '
71
@@ -47,7 +77,7 @@ test_expect_success 'git hook list: traditional hook from hookdir' '
77 cat >expect <<-\EOF &&
78 hook from hookdir
79 EOF
50 - git hook list test-hook >actual &&
80 + git hook list --allow-unknown-hook-name test-hook >actual &&
81 test_cmp expect actual
82 '
83
@@ -56,7 +86,7 @@ test_expect_success 'git hook list: configured hook' '
86 test_config hook.myhook.event test-hook --add &&
87
88 echo "myhook" >expect &&
59 - git hook list test-hook >actual &&
89 + git hook list --allow-unknown-hook-name test-hook >actual &&
90 test_cmp expect actual
91 '
92
@@ -68,7 +98,7 @@ test_expect_success 'git hook list: -z shows NUL-terminated output' '
98 test_config hook.myhook.event test-hook --add &&
99
100 printf "myhookQhook from hookdirQ" >expect &&
71 - git hook list -z test-hook >actual.raw &&
101 + git hook list --allow-unknown-hook-name -z test-hook >actual.raw &&
102 nul_to_q <actual.raw >actual &&
103 test_cmp expect actual
104 '
@@ -77,12 +107,12 @@ test_expect_success 'git hook run: nonexistent hook' '
107 cat >stderr.expect <<-\EOF &&
108 error: cannot find a hook named test-hook
109 EOF
80 - test_expect_code 1 git hook run test-hook 2>stderr.actual &&
110 + test_expect_code 1 git hook run --allow-unknown-hook-name test-hook 2>stderr.actual &&
111 test_cmp stderr.expect stderr.actual
112 '
113
114 test_expect_success 'git hook run: nonexistent hook with --ignore-missing' '
85 - git hook run --ignore-missing does-not-exist 2>stderr.actual &&
115 + git hook run --allow-unknown-hook-name --ignore-missing does-not-exist 2>stderr.actual &&
116 test_must_be_empty stderr.actual
117 '
118
@@ -94,7 +124,7 @@ test_expect_success 'git hook run: basic' '
124 cat >expect <<-\EOF &&
125 Test hook
126 EOF
97 - git hook run test-hook 2>actual &&
127 + git hook run --allow-unknown-hook-name test-hook 2>actual &&
128 test_cmp expect actual
129 '
130
@@ -108,7 +138,7 @@ test_expect_success 'git hook run: stdout and stderr both write to our stderr' '
138 Will end up on stderr
139 Will end up on stderr
140 EOF
111 - git hook run test-hook >stdout.actual 2>stderr.actual &&
141 + git hook run --allow-unknown-hook-name test-hook >stdout.actual 2>stderr.actual &&
142 test_cmp stderr.expect stderr.actual &&
143 test_must_be_empty stdout.actual
144 '
@@ -120,12 +150,12 @@ do
150 exit $code
151 EOF
152
123 - test_expect_code $code git hook run test-hook
153 + test_expect_code $code git hook run --allow-unknown-hook-name test-hook
154 '
155 done
156
157 test_expect_success 'git hook run arg u ments without -- is not allowed' '
128 - test_expect_code 129 git hook run test-hook arg u ments
158 + test_expect_code 129 git hook run --allow-unknown-hook-name test-hook arg u ments
159 '
160
161 test_expect_success 'git hook run -- pass arguments' '
@@ -139,7 +169,7 @@ test_expect_success 'git hook run -- pass arguments' '
169 u ments
170 EOF
171
142 - git hook run test-hook -- arg "u ments" 2>actual &&
172 + git hook run --allow-unknown-hook-name test-hook -- arg "u ments" 2>actual &&
173 test_cmp expect actual
174 '
175
@@ -148,12 +178,12 @@ test_expect_success 'git hook run: out-of-repo runs execute global hooks' '
178 test_config_global hook.global-hook.command "echo no repo no problems" --add &&
179
180 echo "global-hook" >expect &&
151 - nongit git hook list test-hook >actual &&
181 + nongit git hook list --allow-unknown-hook-name test-hook >actual &&
182 test_cmp expect actual &&
183
184 echo "no repo no problems" >expect &&
185
156 - nongit git hook run test-hook 2>actual &&
186 + nongit git hook run --allow-unknown-hook-name test-hook 2>actual &&
187 test_cmp expect actual
188 '
189
@@ -178,11 +208,11 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '
208 # Test various ways of specifying the path. See also
209 # t1350-config-hooks-path.sh
210 >actual &&
181 - git hook run test-hook -- ignored 2>>actual &&
182 - git -c core.hooksPath=my-hooks hook run test-hook -- one 2>>actual &&
183 - git -c core.hooksPath=my-hooks/ hook run test-hook -- two 2>>actual &&
184 - git -c core.hooksPath="$PWD/my-hooks" hook run test-hook -- three 2>>actual &&
185 - git -c core.hooksPath="$PWD/my-hooks/" hook run test-hook -- four 2>>actual &&
211 + git hook run --allow-unknown-hook-name test-hook -- ignored 2>>actual &&
212 + git -c core.hooksPath=my-hooks hook run --allow-unknown-hook-name test-hook -- one 2>>actual &&
213 + git -c core.hooksPath=my-hooks/ hook run --allow-unknown-hook-name test-hook -- two 2>>actual &&
214 + git -c core.hooksPath="$PWD/my-hooks" hook run --allow-unknown-hook-name test-hook -- three 2>>actual &&
215 + git -c core.hooksPath="$PWD/my-hooks/" hook run --allow-unknown-hook-name test-hook -- four 2>>actual &&
216 test_cmp expect actual
217 '
218
@@ -262,7 +292,7 @@ test_expect_success 'hook can be configured for multiple events' '
292 # 'ghi' should be included in both 'pre-commit' and 'test-hook'
293 git hook list pre-commit >actual &&
294 grep "ghi" actual &&
265 - git hook list test-hook >actual &&
295 + git hook list --allow-unknown-hook-name test-hook >actual &&
296 grep "ghi" actual
297 '
298
@@ -336,15 +366,15 @@ test_expect_success 'stdin to multiple hooks' '
366 b3
367 EOF
368
339 - git hook run --to-stdin=input test-hook 2>actual &&
369 + git hook run --allow-unknown-hook-name --to-stdin=input test-hook 2>actual &&
370 test_cmp expected actual
371 '
372
373 test_expect_success 'rejects hooks with no commands configured' '
374 test_config hook.broken.event "test-hook" &&
345 - test_must_fail git hook list test-hook 2>actual &&
375 + test_must_fail git hook list --allow-unknown-hook-name test-hook 2>actual &&
376 test_grep "hook.broken.command" actual &&
347 - test_must_fail git hook run test-hook 2>actual &&
377 + test_must_fail git hook run --allow-unknown-hook-name test-hook 2>actual &&
378 test_grep "hook.broken.command" actual
379 '
380
@@ -353,7 +383,7 @@ test_expect_success 'disabled hook is not run' '
383 test_config hook.skipped.command "echo \"Should not run\"" &&
384 test_config hook.skipped.enabled false &&
385
356 - git hook run --ignore-missing test-hook 2>actual &&
386 + git hook run --allow-unknown-hook-name --ignore-missing test-hook 2>actual &&
387 test_must_be_empty actual
388 '
389
@@ -403,7 +433,7 @@ test_expect_success 'globally disabled hook can be re-enabled locally' '
433 test_config hook.global-hook.enabled true &&
434
435 echo "global-hook ran" >expected &&
406 - git hook run test-hook 2>actual &&
436 + git hook run --allow-unknown-hook-name test-hook 2>actual &&
437 test_cmp expected actual
438 '
439
@@ -463,7 +493,7 @@ test_expect_success 'git hook run a hook with a bad shebang' '
493
494 test_expect_code 1 git \
495 -c core.hooksPath=bad-hooks \
466 - hook run test-hook >out 2>err &&
496 + hook run --allow-unknown-hook-name test-hook >out 2>err &&
497 test_must_be_empty out &&
498
499 # TODO: We should emit the same (or at least a more similar)
@@ -487,7 +517,7 @@ test_expect_success 'stdin to hooks' '
517 EOF
518
519 echo hello >input &&
490 - git hook run --to-stdin=input test-hook 2>actual &&
520 + git hook run --allow-unknown-hook-name --to-stdin=input test-hook 2>actual &&
521 test_cmp expect actual
522 '
523