git/list[1] front-page[2] threads[3] people[4] search[5] about
 

[PATCH 3/4] hook: include hooks from the config

From
Adrian Ratiu <adrian.ratiu@collabora.com>
Date
Feb 4, 2026, 16:51 UTC
Message-ID
<20260204165126.1548805-4-adrian.ratiu@collabora.com>
In-Reply-To
<20260204165126.1548805-1-adrian.ratiu@collabora.com>
From: Emily Shaffer <emilyshaffer@google.com>

Teach the hook.[hc] library to parse configs to populate the list of hooks to run for a given event.

Multiple commands can be specified for a given hook by providing "hook.<friendly-name>.command = <path-to-hook>" and "hook.<friendly-name>.event = <hook-event>" lines.

Hooks will be started in config order of the "hook.<name>.event" lines and will be run sequentially (.jobs == 1) like before. Running the hooks in parallel will be enabled in a future patch.

Examples:
  $ git config --get-regexp "^hook\."
  hook.bar.command=~/bar.sh
  hook.bar.event=pre-commit
  # Will run ~/bar.sh, then .git/hooks/pre-commit
  $ git hook run pre-commit
Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/config/hook.adoc |  17 ++++
 Documentation/git-hook.adoc    | 126 ++++++++++++++++++++++++++++-
 hook.c                         |  78 ++++++++++++++++--
 t/t1800-hook.sh                | 140 ++++++++++++++++++++++++++++++++-
 4 files changed, 353 insertions(+), 8 deletions(-)
 create mode 100644 Documentation/config/hook.adoc
diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
new file mode 100644
index 0000000000..49c7ffd82e
--- /dev/null
+++ b/Documentation/config/hook.adoc
@@ -0,0 +1,17 @@
+hook.<name>.command::
+	A command to execute whenever `hook.<name>` is invoked. `<name>` should
+	be a unique "friendly" name which you can use to identify this hook
+	command. (You can specify when to invoke this command with
+	`hook.<name>.event`.) The value can be an executable on your device or a
+	oneliner for your shell. If more than one value is specified for the
+	same `<name>`, the last value parsed will be the only command executed.
+	See linkgit:git-hook[1].
+
+hook.<name>.event::
+	The hook events which should invoke `hook.<name>`. `<name>` should be a
+	unique "friendly" name which you can use to identify this hook. The
+	value should be the name of a hook event, like "pre-commit" or "update".
+	(See linkgit:githooks[5] for a complete list of hooks Git knows about.)
+	On the specified event, the associated `hook.<name>.command` will be
+	executed. More than one event can be specified if you wish for
+	`hook.<name>` to execute on multiple events. See linkgit:git-hook[1].
diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index 93d734f687..5f339dc48b 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -17,12 +17,94 @@ DESCRIPTION
 A command interface for running git hooks (see linkgit:githooks[5]),
 for use by other scripted git commands.
 
+This command parses the default configuration files for sets of configs like
+so:
+
+  [hook "linter"]
+    event = pre-commit
+    command = ~/bin/linter --cpp20
+
+In this example, `[hook "linter"]` represents one script - `~/bin/linter
+--cpp20` - which can be shared by many repos, and even by many hook events, if
+appropriate.
+
+To add an unrelated hook which runs on a different event, for example a
+spell-checker for your commit messages, you would write a configuration like so:
+
+  [hook "linter"]
+    event = pre-commit
+    command = ~/bin/linter --cpp20
+  [hook "spellcheck"]
+    event = commit-msg
+    command = ~/bin/spellchecker
+
+With this config, when you run 'git commit', first `~/bin/linter --cpp20` will
+have a chance to check your files to be committed (during the `pre-commit` hook
+event`), and then `~/bin/spellchecker` will have a chance to check your commit
+message (during the `commit-msg` hook event).
+
+Commands are run in the order Git encounters their associated
+`hook.<name>.event` configs during the configuration parse (see
+linkgit:git-config[1]). Although multiple `hook.linter.event` configs can be
+added, only one `hook.linter.command` event is valid - Git uses "last-one-wins"
+to determine which command to run.
+
+So if you wanted your linter to run when you commit as well as when you push,
+you would configure it like so:
+
+  [hook "linter"]
+    event = pre-commit
+    event = pre-push
+    command = ~/bin/linter --cpp20
+
+With this config, `~/bin/linter --cpp20` would be run by Git before a commit is
+generated (during `pre-commit`) as well as before a push is performed (during
+`pre-push`).
+
+And if you wanted to run your linter as well as a secret-leak detector during
+only the "pre-commit" hook event, you would configure it instead like so:
+
+  [hook "linter"]
+    event = pre-commit
+    command = ~/bin/linter --cpp20
+  [hook "no-leaks"]
+    event = pre-commit
+    command = ~/bin/leak-detector
+
+With this config, before a commit is generated (during `pre-commit`), Git would
+first start `~/bin/linter --cpp20` and second start `~/bin/leak-detector`. It
+would evaluate the output of each when deciding whether to proceed with the
+commit.
+
+For a full list of hook events which you can set your `hook.<name>.event` to,
+and how hooks are invoked during those events, see linkgit:githooks[5].
+
+Git will ignore any `hook.<name>.event` that specifies an event it doesn't
+recognize. This is intended so that tools which wrap Git can use the hook
+infrastructure to run their own hooks; see "WRAPPERS" for more guidance.
+
+In general, when instructions suggest adding a script to
+`.git/hooks/<hook-event>`, you can specify it in the config instead by running:
+
+----
+git config hook.<some-name>.command <path-to-script>
+git config --add hook.<some-name>.event <hook-event>
+----
+
+This way you can share the script between multiple repos. That is, `cp
+~/my-script.sh ~/project/.git/hooks/pre-commit` would become:
+
+----
+git config hook.my-script.command ~/my-script.sh
+git config --add hook.my-script.event pre-commit
+----
+
 SUBCOMMANDS
 -----------
 
 run::
-	Run the `<hook-name>` hook. See linkgit:githooks[5] for
-	supported hook names.
+	Runs hooks configured for `<hook-name>`, in the order they are
+	discovered during the config parse.
 +
 
 Any positional arguments to the hook should be passed after a
@@ -46,6 +128,46 @@ OPTIONS
 	tools that want to do a blind one-shot run of a hook that may
 	or may not be present.
 
+WRAPPERS
+--------
+
+`git hook run` has been designed to make it easy for tools which wrap Git to
+configure and execute hooks using the Git hook infrastructure. It is possible to
+provide arguments and stdin via the command line, as well as specifying parallel
+or series execution if the user has provided multiple hooks.
+
+Assuming your wrapper wants to support a hook named "mywrapper-start-tests", you
+can have your users specify their hooks like so:
+
+  [hook "setup-test-dashboard"]
+    event = mywrapper-start-tests
+    command = ~/mywrapper/setup-dashboard.py --tap
+
+Then, in your 'mywrapper' tool, you can invoke any users' configured hooks by
+running:
+
+----
+git hook run mywrapper-start-tests \
+  # providing something to stdin
+  --stdin some-tempfile-123 \
+  # execute hooks in serial
+  # plus some arguments of your own...
+  -- \
+  --testname bar \
+  baz
+----
+
+Take care to name your wrapper's hook events in a way which is unlikely to
+overlap with Git's native hooks (see linkgit:githooks[5]) - a hook event named
+`mywrappertool-validate-commit` is much less likely to be added to native Git
+than a hook event named `validate-commit`. If Git begins to use a hook event
+named the same thing as your wrapper hook, it may invoke your users' hooks in
+unintended and unsupported ways.
+
+CONFIGURATION
+-------------
+include::config/hook.adoc[]
+
 SEE ALSO
 --------
 linkgit:githooks[5]
diff --git a/hook.c b/hook.c
index 949c907b59..9f59ebd0bd 100644
--- a/hook.c
+++ b/hook.c
@@ -47,24 +47,74 @@ const char *find_hook(struct repository *r, const char *name)
 	return path.buf;
 }
 
+struct hook_config_cb
+{
+	const char *hook_event;
+	struct string_list *list;
+};
+
+/*
+ * Callback for git_config which adds configured hooks to a hook list.  Hooks
+ * can be configured by specifying both hook.<friendly-name>.command = <path>
+ * and hook.<friendly-name>.event = <hook-event>.
+ */
+static int hook_config_lookup(const char *key, const char *value,
+			      const struct config_context *ctx UNUSED,
+			      void *cb_data)
+{
+	struct hook_config_cb *data = cb_data;
+	const char *name, *event_key;
+	size_t name_len = 0;
+	struct string_list_item *item;
+	char *hook_name;
+
+	/*
+	 * Don't bother doing the expensive parse if there's no
+	 * chance that the config matches 'hook.myhook.event = hook_event'.
+	 */
+	if (!value || strcmp(value, data->hook_event))
+		return 0;
+
+	/* Look for "hook.friendly-name.event = hook_event" */
+	if (parse_config_key(key, "hook", &name, &name_len, &event_key) ||
+	    strcmp(event_key, "event"))
+		return 0;
+
+	/* Extract the hook name */
+	hook_name = xmemdupz(name, name_len);
+
+	/* Remove the hook if already in the list, so we append in config order. */
+	if ((item = unsorted_string_list_lookup(data->list, hook_name)))
+		unsorted_string_list_delete_item(data->list, item - data->list->items, 0);
+
+	/* The list takes ownership of hook_name, so append with nodup */
+	string_list_append_nodup(data->list, hook_name);
+
+	return 0;
+}
+
 struct string_list *list_hooks(struct repository *r, const char *hookname)
 {
-	struct string_list *hook_head;
+	struct hook_config_cb cb_data;
 
 	if (!hookname)
 		BUG("null hookname was provided to hook_list()!");
 
-	hook_head = xmalloc(sizeof(struct string_list));
-	string_list_init_dup(hook_head);
+	cb_data.hook_event = hookname;
+	cb_data.list = xmalloc(sizeof(struct string_list));
+	string_list_init_dup(cb_data.list);
+
+	/* Add the hooks from the config, e.g. hook.myhook.event = pre-commit */
+	repo_config(r, hook_config_lookup, &cb_data);
 
 	/*
 	 * Add the default hook from hookdir. It does not have a friendly name
 	 * like the hooks specified via configs, so add it with an empty name.
 	 */
 	if (r->gitdir && find_hook(r, hookname))
-		string_list_append(hook_head, "");
+		string_list_append(cb_data.list, "");
 
-	return hook_head;
+	return cb_data.list;
 }
 
 int hook_exists(struct repository *r, const char *name)
@@ -125,6 +175,24 @@ static int pick_next_hook(struct child_process *cp,
 			hook_path = absolute_path(hook_path);
 
 		strvec_push(&cp->args, hook_path);
+	} else {
+		/* ...from config */
+		struct strbuf cmd_key = STRBUF_INIT;
+		char *command = NULL;
+
+		/* to enable oneliners, let config-specified hooks run in shell. */
+		cp->use_shell = true;
+
+		strbuf_addf(&cmd_key, "hook.%s.command", to_run->string);
+		if (repo_config_get_string(hook_cb->repository,
+					   cmd_key.buf, &command)) {
+			die(_("'hook.%s.command' must be configured or"
+			      "'hook.%s.event' must be removed; aborting.\n"),
+			    to_run->string, to_run->string);
+		}
+		strbuf_release(&cmd_key);
+
+		strvec_push_nodup(&cp->args, command);
 	}
 
 	if (!cp->args.nr)
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index d2d4a8760c..bc4862e982 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -1,14 +1,31 @@
 #!/bin/sh
 
-test_description='git-hook command'
+test_description='git-hook command and config-managed multihooks'
 
 . ./test-lib.sh
 . "$TEST_DIRECTORY"/lib-terminal.sh
 
+setup_hooks () {
+	test_config hook.ghi.command "/path/ghi"
+	test_config hook.ghi.event pre-commit --add
+	test_config hook.ghi.event test-hook --add
+	test_config_global hook.def.command "/path/def"
+	test_config_global hook.def.event pre-commit --add
+}
+
+setup_hookdir () {
+	mkdir .git/hooks
+	write_script .git/hooks/pre-commit <<-EOF
+	echo \"Legacy Hook\"
+	EOF
+	test_when_finished rm -rf .git/hooks
+}
+
 test_expect_success 'git hook usage' '
 	test_expect_code 129 git hook &&
 	test_expect_code 129 git hook run &&
 	test_expect_code 129 git hook run -h &&
+	test_expect_code 129 git hook list -h &&
 	test_expect_code 129 git hook run --unknown 2>err &&
 	test_expect_code 129 git hook list &&
 	test_expect_code 129 git hook list -h &&
@@ -152,6 +169,126 @@ test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '
 	test_hook_tty commit -m"B.new"
 '
 
+test_expect_success 'git hook list orders by config order' '
+	setup_hooks &&
+
+	cat >expected <<-\EOF &&
+	def
+	ghi
+	EOF
+
+	git hook list pre-commit >actual &&
+	test_cmp expected actual
+'
+
+test_expect_success 'git hook list reorders on duplicate event declarations' '
+	setup_hooks &&
+
+	# 'def' is usually configured globally; move it to the end by
+	# configuring it locally.
+	test_config hook.def.event "pre-commit" --add &&
+
+	cat >expected <<-\EOF &&
+	ghi
+	def
+	EOF
+
+	git hook list pre-commit >actual &&
+	test_cmp expected actual
+'
+
+test_expect_success 'hook can be configured for multiple events' '
+	setup_hooks &&
+
+	# 'ghi' should be included in both 'pre-commit' and 'test-hook'
+	git hook list pre-commit >actual &&
+	grep "ghi" actual &&
+	git hook list test-hook >actual &&
+	grep "ghi" actual
+'
+
+test_expect_success 'git hook list shows hooks from the hookdir' '
+	setup_hookdir &&
+
+	cat >expected <<-\EOF &&
+	hook from hookdir
+	EOF
+
+	git hook list pre-commit >actual &&
+	test_cmp expected actual
+'
+
+test_expect_success 'inline hook definitions execute oneliners' '
+	test_config hook.oneliner.event "pre-commit" &&
+	test_config hook.oneliner.command "echo \"Hello World\"" &&
+
+	echo "Hello World" >expected &&
+
+	# hooks are run with stdout_to_stderr = 1
+	git hook run pre-commit 2>actual &&
+	test_cmp expected actual
+'
+
+test_expect_success 'inline hook definitions resolve paths' '
+	write_script sample-hook.sh <<-\EOF &&
+	echo \"Sample Hook\"
+	EOF
+
+	test_when_finished "rm sample-hook.sh" &&
+
+	test_config hook.sample-hook.event pre-commit &&
+	test_config hook.sample-hook.command "\"$(pwd)/sample-hook.sh\"" &&
+
+	echo \"Sample Hook\" >expected &&
+
+	# hooks are run with stdout_to_stderr = 1
+	git hook run pre-commit 2>actual &&
+	test_cmp expected actual
+'
+
+test_expect_success 'hookdir hook included in git hook run' '
+	setup_hookdir &&
+
+	echo \"Legacy Hook\" >expected &&
+
+	# hooks are run with stdout_to_stderr = 1
+	git hook run pre-commit 2>actual &&
+	test_cmp expected actual
+'
+
+test_expect_success 'stdin to multiple hooks' '
+	test_config hook.stdin-a.event "test-hook" --add &&
+	test_config hook.stdin-a.command "xargs -P1 -I% echo a%" --add &&
+	test_config hook.stdin-b.event "test-hook" --add &&
+	test_config hook.stdin-b.command "xargs -P1 -I% echo b%" --add &&
+
+	cat >input <<-\EOF &&
+	1
+	2
+	3
+	EOF
+
+	cat >expected <<-\EOF &&
+	a1
+	a2
+	a3
+	b1
+	b2
+	b3
+	EOF
+
+	git hook run --to-stdin=input test-hook 2>actual &&
+	test_cmp expected actual
+'
+
+test_expect_success 'rejects hooks with no commands configured' '
+	test_config hook.broken.event "test-hook" &&
+	echo broken >expected &&
+	git hook list test-hook >actual &&
+	test_cmp expected actual &&
+	test_must_fail git hook run test-hook
+'
+
 test_expect_success 'git hook run a hook with a bad shebang' '
 	test_when_finished "rm -rf bad-hooks" &&
 	mkdir bad-hooks &&
@@ -169,6 +306,7 @@ test_expect_success 'git hook run a hook with a bad shebang' '
 '
 
 test_expect_success 'stdin to hooks' '
+	mkdir -p .git/hooks &&
 	write_script .git/hooks/test-hook <<-\EOF &&
 	echo BEGIN stdin
 	cat
-- 
2.52.0.732.gb351b5166d.dirty
Previous: Adrian RatiuNext: Patrick Steinhardt
Message 5 of 71 in “Specify hooks via configs”
  1. 0/4 Specify hooks via configsAdrian Ratiu, Feb 4, 2026
  2. 2/4 hook: introduce "git hook list"Adrian Ratiu, Feb 4, 2026
  3. Patrick SteinhardtFeb 9, 2026
  4. Adrian RatiuFeb 9, 2026
  5. 3/4 hook: include hooks from the configAdrian Ratiu, Feb 4, 2026
  6. Patrick SteinhardtFeb 9, 2026
  7. Adrian RatiuFeb 9, 2026
  8. Patrick SteinhardtFeb 10, 2026
  9. Adrian RatiuFeb 10, 2026
  10. 1/4 hook: run a list of hooksAdrian Ratiu, Feb 4, 2026
  11. Junio C HamanoFeb 5, 2026
  12. Adrian RatiuFeb 6, 2026
  13. Patrick SteinhardtFeb 9, 2026
  14. Adrian RatiuFeb 9, 2026
  15. Patrick SteinhardtFeb 10, 2026
  16. 4/4 hook: allow out-of-repo 'git hook' invocationsAdrian Ratiu, Feb 4, 2026
  17. Junio C HamanoFeb 6, 2026
  18. 0/8 Specify hooks via configsAdrian Ratiu, Feb 18, 2026
  19. 2/8 hook: run a list of hooks to prepare for multihook supportAdrian Ratiu, Feb 18, 2026
  20. Patrick SteinhardtFeb 20, 2026
  21. Adrian RatiuFeb 20, 2026
  22. 1/8 hook: add internal state alloc/free callbacksAdrian Ratiu, Feb 18, 2026
  23. Junio C HamanoFeb 19, 2026
  24. Adrian RatiuFeb 20, 2026
  25. Junio C HamanoFeb 20, 2026
  26. Adrian RatiuFeb 20, 2026
  27. Patrick SteinhardtFeb 20, 2026
  28. Adrian RatiuFeb 20, 2026
  29. 3/8 hook: add "git hook list" commandAdrian Ratiu, Feb 18, 2026
  30. Patrick SteinhardtFeb 20, 2026
  31. Adrian RatiuFeb 20, 2026
  32. 5/8 hook: allow disabling config hooksAdrian Ratiu, Feb 18, 2026
  33. Patrick SteinhardtFeb 20, 2026
  34. Adrian RatiuFeb 20, 2026
  35. Patrick SteinhardtFeb 20, 2026
  36. Junio C HamanoFeb 20, 2026
  37. 6/8 hook: allow event = "" to overwrite previous valuesAdrian Ratiu, Feb 18, 2026
  38. 4/8 hook: include hooks from the configAdrian Ratiu, Feb 18, 2026
  39. Junio C HamanoFeb 19, 2026
  40. Adrian RatiuFeb 20, 2026
  41. Patrick SteinhardtFeb 20, 2026
  42. Adrian RatiuFeb 20, 2026
  43. 7/8 hook: allow out-of-repo 'git hook' invocationsAdrian Ratiu, Feb 18, 2026
  44. 8/8 hook: add -z option to "git hook list"Adrian Ratiu, Feb 18, 2026
  45. Junio C HamanoFeb 19, 2026
  46. Adrian RatiuFeb 20, 2026
  47. brian m. carlsonFeb 20, 2026
  48. Adrian RatiuFeb 21, 2026
  49. Adrian RatiuFeb 22, 2026
  50. Junio C HamanoFeb 25, 2026
  51. Adrian RatiuFeb 26, 2026
  52. brian m. carlsonFeb 25, 2026
  53. Adrian RatiuFeb 26, 2026
  54. 00/12 [next] Specify hooks via configsAdrian Ratiu, Mar 1, 2026
  55. 02/12 hook: run a list of hooks to prepare for multihook supportAdrian Ratiu, Mar 1, 2026
  56. 03/12 hook: add "git hook list" commandAdrian Ratiu, Mar 1, 2026
  57. 01/12 hook: add internal state alloc/free callbacksAdrian Ratiu, Mar 1, 2026
  58. 04/12 string-list: add unsorted_string_list_remove()Adrian Ratiu, Mar 1, 2026
  59. 06/12 hook: allow disabling config hooksAdrian Ratiu, Mar 1, 2026
  60. 05/12 hook: include hooks from the configAdrian Ratiu, Mar 1, 2026
  61. SZEDER GáborApr 6, 2026
  62. Adrian RatiuApr 8, 2026
  63. 07/12 hook: allow event = "" to overwrite previous valuesAdrian Ratiu, Mar 1, 2026
  64. 08/12 hook: allow out-of-repo 'git hook' invocationsAdrian Ratiu, Mar 1, 2026
  65. 09/12 hook: add -z option to "git hook list"Adrian Ratiu, Mar 1, 2026
  66. 10/12 hook: refactor hook_config_cache from strmap to named structAdrian Ratiu, Mar 1, 2026
  67. 11/12 hook: store and display scope for configured hooks in git hook listAdrian Ratiu, Mar 1, 2026
  68. 12/12 hook: show disabled hooks in "git hook list"Adrian Ratiu, Mar 1, 2026
  69. Junio C HamanoMar 2, 2026
  70. Adrian RatiuMar 2, 2026
  71. Junio C HamanoMar 2, 2026

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.