public inbox for git@vger.kernel.org
 help / color / mirror / Atom feed
* [PATCH 0/4] Specify hooks via configs
@ 2026-02-04 16:51 Adrian Ratiu
  2026-02-04 16:51 ` [PATCH 1/4] hook: run a list of hooks Adrian Ratiu
                   ` (6 more replies)
  0 siblings, 7 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-04 16:51 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Hello everyone,

This series adds a new feature: the ability to specify commands to run
for hook events via config entries (including shell commands).

The config schema is identical to the one developed by Emily and AEvar
a few years ago [1] though the implementation is significantly different
because it's based on the new / cleaned-up hook.[ch] APIs. [2].

For simplicity, hooks are still executed sequentially (.jobs == 1) in
this series, just like before. Parallel execution will be enabled in
a separate series based on this one.

The hook execution order is this:
1. Hooks read from the config. If multiple hook commands are specified
   for a single event, they are executed in config discovery order.
2. The default hooks from the hookdir.

The above order can be changed if necessary.

Again, this is based on the latest v8 hooks-conversion series [2] which
has not yet landed in next or master.

Branch pused to GitHub: [3]
Succesful CI run: [4]

Many thanks to all who contributed to this effort up to now, including
Emily, AEvar, Junio, Patrick, Peff, Kristoffer, Chris and many others.

Thank you,
Adrian

1: https://lore.kernel.org/git/20210715232603.3415111-1-emilyshaffer@google.com/
2: https://lore.kernel.org/git/20250925125352.1728840-1-adrian.ratiu@collabora.com/T/#m41f793907f46fd04f44ff1b06c53d20af38e6cb2
3: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/config-hooks-v1
4: https://github.com/10ne1/git/actions/runs/21676691521

Emily Shaffer (4):
  hook: run a list of hooks
  hook: introduce "git hook list"
  hook: include hooks from the config
  hook: allow out-of-repo 'git hook' invocations

 Documentation/config/hook.adoc |  17 +++
 Documentation/git-hook.adoc    | 131 ++++++++++++++++++++++-
 builtin/hook.c                 |  53 ++++++++++
 builtin/receive-pack.c         |  23 +++-
 git.c                          |   2 +-
 hook.c                         | 188 ++++++++++++++++++++++++++++-----
 hook.h                         |  55 +++++++++-
 refs.c                         |  23 +++-
 t/t1800-hook.sh                | 158 +++++++++++++++++++++++++--
 transport.c                    |  23 +++-
 10 files changed, 633 insertions(+), 40 deletions(-)
 create mode 100644 Documentation/config/hook.adoc

-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply	[flat|nested] 69+ messages in thread

* [PATCH 1/4] hook: run a list of hooks
  2026-02-04 16:51 [PATCH 0/4] Specify hooks via configs Adrian Ratiu
@ 2026-02-04 16:51 ` Adrian Ratiu
  2026-02-05 21:59   ` Junio C Hamano
  2026-02-09 14:27   ` Patrick Steinhardt
  2026-02-04 16:51 ` [PATCH 2/4] hook: introduce "git hook list" Adrian Ratiu
                   ` (5 subsequent siblings)
  6 siblings, 2 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-04 16:51 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

From: Emily Shaffer <emilyshaffer@google.com>

Teach hook.[hc] to run lists of hooks to prepare for multihook support.

Currently, the hook list contains only one entry representing the
"legacy" hook from the hookdir, but next commits will allow users
to supply more than one executable/command for a single hook event
in addition to these default "legacy" hooks.

All hook commands still run sequentially. A further patch series will
enable running them in parallel by increasing .jobs > 1 where possible.

Each hook command requires its own internal state copy, even when
running sequentially, so add an API to allow hooks to duplicate/free
their internal void *pp_task_cb state.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 builtin/receive-pack.c |  23 ++++++-
 hook.c                 | 133 +++++++++++++++++++++++++++++++++--------
 hook.h                 |  38 +++++++++++-
 refs.c                 |  23 ++++++-
 transport.c            |  23 ++++++-
 5 files changed, 210 insertions(+), 30 deletions(-)

diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
index b5379a4895..72fde2207c 100644
--- a/builtin/receive-pack.c
+++ b/builtin/receive-pack.c
@@ -849,7 +849,8 @@ struct receive_hook_feed_state {
 
 static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)
 {
-	struct receive_hook_feed_state *state = pp_task_cb;
+	struct string_list_item *h = pp_task_cb;
+	struct receive_hook_feed_state *state = h->util;
 	struct command *cmd = state->cmd;
 
 	strbuf_reset(&state->buf);
@@ -901,6 +902,24 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_
 	return state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */
 }
 
+static void *copy_receive_hook_feed_state(const void *data)
+{
+	const struct receive_hook_feed_state *orig = data;
+	struct receive_hook_feed_state *new_data = xmalloc(sizeof(*new_data));
+	memcpy(new_data, orig, sizeof(*new_data));
+	strbuf_init(&new_data->buf, 0);
+	return new_data;
+}
+
+static void free_receive_hook_feed_state(void *data)
+{
+	struct receive_hook_feed_state *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_receive_hook(struct command *commands,
 			    const char *hook_name,
 			    int skip_broken,
@@ -944,6 +963,8 @@ static int run_receive_hook(struct command *commands,
 	strbuf_init(&feed_state.buf, 0);
 	opt.feed_pipe_cb_data = &feed_state;
 	opt.feed_pipe = feed_receive_hook_cb;
+	opt.copy_feed_pipe_cb_data = copy_receive_hook_feed_state;
+	opt.free_feed_pipe_cb_data = free_receive_hook_feed_state;
 
 	ret = run_hooks_opt(the_repository, hook_name, &opt);
 
diff --git a/hook.c b/hook.c
index cde7198412..fb90f91f3b 100644
--- a/hook.c
+++ b/hook.c
@@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)
 	return path.buf;
 }
 
+/*
+ * Provides a list of hook commands to run for the 'hookname' event.
+ *
+ * This function consolidates hooks from two sources:
+ * 1. The config-based hooks (not yet implemented).
+ * 2. The "traditional" hook found in the repository hooks directory
+ *    (e.g., .git/hooks/pre-commit).
+ *
+ * The list is ordered by execution priority.
+ *
+ * The caller is responsible for freeing the memory of the returned list
+ * using string_list_clear() and free().
+ */
+static struct string_list *list_hooks(struct repository *r, const char *hookname)
+{
+	struct string_list *hook_head;
+
+	if (!hookname)
+		BUG("null hookname was provided to hook_list()!");
+
+	hook_head = xmalloc(sizeof(struct string_list));
+	string_list_init_dup(hook_head);
+
+	/*
+	 * 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, "");
+
+	return hook_head;
+}
+
 int hook_exists(struct repository *r, const char *name)
 {
-	return !!find_hook(r, name);
+	int exists = 0;
+	struct string_list *hooks = list_hooks(r, name);
+
+	exists = hooks->nr > 0;
+
+	string_list_clear(hooks, 1);
+	free(hooks);
+	return exists;
 }
 
 static int pick_next_hook(struct child_process *cp,
@@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,
 			  void **pp_task_cb)
 {
 	struct hook_cb_data *hook_cb = pp_cb;
-	const char *hook_path = hook_cb->hook_path;
+	struct string_list *hook_list = hook_cb->hook_command_list;
+	struct string_list_item *to_run = hook_cb->next_hook++;
 
-	if (!hook_path)
-		return 0;
+	if (!to_run || to_run >= hook_list->items + hook_list->nr)
+		return 0; /* no hook left to run */
 
 	cp->no_stdin = 1;
 	strvec_pushv(&cp->env, hook_cb->options->env.v);
@@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,
 	cp->trace2_hook_name = hook_cb->hook_name;
 	cp->dir = hook_cb->options->dir;
 
-	strvec_push(&cp->args, hook_path);
+	/* find hook commands */
+	if (!*to_run->string) {
+		/* ...from hookdir signified by empty name */
+		const char *hook_path = find_hook(hook_cb->repository,
+						  hook_cb->hook_name);
+		if (!hook_path)
+			BUG("hookdir in hook list but no hook present in filesystem");
+
+		if (hook_cb->options->dir)
+			hook_path = absolute_path(hook_path);
+
+		strvec_push(&cp->args, hook_path);
+	}
+
+	if (!cp->args.nr)
+		BUG("configured hook must have at least one command");
+
 	strvec_pushv(&cp->args, hook_cb->options->args.v);
 
 	/*
 	 * Provide per-hook internal state via task_cb for easy access, so
 	 * hook callbacks don't have to go through hook_cb->options.
 	 */
-	*pp_task_cb = hook_cb->options->feed_pipe_cb_data;
-
-	/*
-	 * This pick_next_hook() will be called again, we're only
-	 * running one hook, so indicate that no more work will be
-	 * done.
-	 */
-	hook_cb->hook_path = NULL;
+	*pp_task_cb = to_run;
 
 	return 1;
 }
 
-static int notify_start_failure(struct strbuf *out UNUSED,
+static int notify_start_failure(struct strbuf *out,
 				void *pp_cb,
-				void *pp_task_cp UNUSED)
+				void *pp_task_cb)
 {
 	struct hook_cb_data *hook_cb = pp_cb;
+	struct string_list_item *hook = pp_task_cb;
 
 	hook_cb->rc |= 1;
 
+	if (out && hook) {
+		if (*hook->string)
+			strbuf_addf(out, _("Couldn't start hook '%s'\n"), hook->string);
+		else
+			strbuf_addstr(out, _("Couldn't start hook from hooks directory\n"));
+	}
+
 	return 1;
 }
 
@@ -145,8 +203,9 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
 		.rc = 0,
 		.hook_name = hook_name,
 		.options = options,
+		.hook_command_list = list_hooks(r, hook_name),
+		.repository = r,
 	};
-	const char *const hook_path = find_hook(r, hook_name);
 	int ret = 0;
 	const struct run_process_parallel_opts opts = {
 		.tr2_category = "hook",
@@ -172,26 +231,50 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
 	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->copy_feed_pipe_cb_data && !options->free_feed_pipe_cb_data) ||
+	    (!options->copy_feed_pipe_cb_data && options->free_feed_pipe_cb_data))
+		BUG("copy_feed_pipe_cb_data and free_feed_pipe_cb_data must be set together");
+
 	if (options->invoked_hook)
 		*options->invoked_hook = 0;
 
-	if (!hook_path && !options->error_if_missing)
-		goto cleanup;
-
-	if (!hook_path) {
-		ret = error("cannot find a hook named %s", hook_name);
+	if (!cb_data.hook_command_list->nr) {
+		if (options->error_if_missing)
+			ret = error("cannot find a hook named %s", hook_name);
 		goto cleanup;
 	}
 
-	cb_data.hook_path = hook_path;
-	if (options->dir) {
-		strbuf_add_absolute_path(&abs_path, hook_path);
-		cb_data.hook_path = abs_path.buf;
+	/*
+	 * Initialize the iterator/cursor which holds the next hook to run.
+	 * run_process_parallel() calls pick_next_hook() which increments it for
+	 * each hook command in the list until all hooks have been run.
+	 */
+	cb_data.next_hook = cb_data.hook_command_list->items;
+
+	/*
+	 * Give each hook its own copy of the initial void *pp_task_cb state, if
+	 * a copy callback was provided.
+	 */
+	if (options->copy_feed_pipe_cb_data) {
+		struct string_list_item *item;
+		for_each_string_list_item(item, cb_data.hook_command_list)
+			item->util = options->copy_feed_pipe_cb_data(options->feed_pipe_cb_data);
 	}
 
 	run_processes_parallel(&opts);
 	ret = cb_data.rc;
 cleanup:
+	if (options->free_feed_pipe_cb_data) {
+		struct string_list_item *item;
+		for_each_string_list_item(item, cb_data.hook_command_list)
+			options->free_feed_pipe_cb_data(item->util);
+	}
+	string_list_clear(cb_data.hook_command_list, 0);
+	free(cb_data.hook_command_list);
 	strbuf_release(&abs_path);
 	run_hooks_opt_clear(options);
 	return ret;
diff --git a/hook.h b/hook.h
index 20eb56fd63..33a0e33684 100644
--- a/hook.h
+++ b/hook.h
@@ -2,6 +2,7 @@
 #define HOOK_H
 #include "strvec.h"
 #include "run-command.h"
+#include "string-list.h"
 
 struct repository;
 
@@ -86,12 +87,28 @@ struct run_hooks_opt
 	 * Opaque data pointer used to keep internal state across callback calls.
 	 *
 	 * It can be accessed directly via the third callback arg 'pp_task_cb':
-	 * struct ... *state = pp_task_cb;
+	 * struct ... *state = ((struct string_list_item *)pp_task_cb)->util;
 	 *
 	 * The caller is responsible for managing the memory for this data.
 	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
 	 */
 	void *feed_pipe_cb_data;
+
+	/**
+	 * Some hooks need a copy of the initial `feed_pipe_cb_data` state, so
+	 * they can keep track of progress without affecting one another.
+	 *
+	 * If provided, this function will be called to copy `feed_pipe_cb_data`
+	 * for each hook.
+	 */
+	void *(*copy_feed_pipe_cb_data)(const void *data);
+
+	/**
+	 * Called to free the memory duplicated by `copy_feed_pipe_cb_data`.
+	 *
+	 * Must always be provided when `copy_feed_pipe_cb_data` is provided.
+	 */
+	void (*free_feed_pipe_cb_data)(void *data);
 };
 
 #define RUN_HOOKS_OPT_INIT { \
@@ -105,8 +122,25 @@ struct hook_cb_data {
 	/* rc reflects the cumulative failure state */
 	int rc;
 	const char *hook_name;
-	const char *hook_path;
+
+	/**
+	 * A list of hook commands/paths to run for the 'hook_name' event.
+	 *
+	 * The 'string' member of each item contains the executable path (e.g.
+	 * "/path/to/.git/hooks/pre-commit") or command.
+	 */
+	struct string_list *hook_command_list;
+
+	/**
+	 * Iterator/cursor for the above list, pointing to the next hook to run.
+	 *
+	 * The 'util' member of the string_list_item holds the per-hook state
+	 * data (feed_pipe_cb_data) which is passed to callbacks via 'pp_task_cb'.
+	 */
+	struct string_list_item *next_hook;
+
 	struct run_hooks_opt *options;
+	struct repository *repository;
 };
 
 /*
diff --git a/refs.c b/refs.c
index 1e2ac90018..d1a1ace641 100644
--- a/refs.c
+++ b/refs.c
@@ -2472,7 +2472,8 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_
 {
 	struct hook_cb_data *hook_cb = pp_cb;
 	struct ref_transaction *transaction = hook_cb->options->feed_pipe_ctx;
-	struct transaction_feed_cb_data *feed_cb_data = pp_task_cb;
+	struct string_list_item *hook = pp_task_cb;
+	struct transaction_feed_cb_data *feed_cb_data = hook->util;
 	struct strbuf *buf = &feed_cb_data->buf;
 	struct ref_update *update;
 	size_t i = feed_cb_data->index++;
@@ -2511,6 +2512,24 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_
 	return 0; /* no more input to feed */
 }
 
+static void *copy_transaction_feed_cb_data(const void *data)
+{
+	struct transaction_feed_cb_data *new_data = xmalloc(sizeof(struct transaction_feed_cb_data));
+	memcpy(new_data, data, sizeof(struct transaction_feed_cb_data));
+	strbuf_init(&new_data->buf, 0);
+	new_data->index = 0; /* a fresh iterator for each hook */
+	return new_data;
+}
+
+static void free_transaction_feed_cb_data(void *data)
+{
+	struct transaction_feed_cb_data *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_transaction_hook(struct ref_transaction *transaction,
 				const char *state)
 {
@@ -2523,6 +2542,8 @@ static int run_transaction_hook(struct ref_transaction *transaction,
 	opt.feed_pipe = transaction_hook_feed_stdin;
 	opt.feed_pipe_ctx = transaction;
 	opt.feed_pipe_cb_data = &feed_ctx;
+	opt.copy_feed_pipe_cb_data = copy_transaction_feed_cb_data;
+	opt.free_feed_pipe_cb_data = free_transaction_feed_cb_data;
 
 	strbuf_init(&feed_ctx.buf, 0);
 
diff --git a/transport.c b/transport.c
index e876cc9189..176050e663 100644
--- a/transport.c
+++ b/transport.c
@@ -1323,7 +1323,8 @@ struct feed_pre_push_hook_data {
 
 static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)
 {
-	struct feed_pre_push_hook_data *data = pp_task_cb;
+	struct string_list_item *h = pp_task_cb;
+	struct feed_pre_push_hook_data *data = h->util;
 	const struct ref *r = data->refs;
 	int ret = 0;
 
@@ -1357,6 +1358,24 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void
 	return 0;
 }
 
+static void *copy_pre_push_hook_data(const void *data)
+{
+	const struct feed_pre_push_hook_data *orig = data;
+	struct feed_pre_push_hook_data *new_data = xmalloc(sizeof(*new_data));
+	strbuf_init(&new_data->buf, 0);
+	new_data->refs = orig->refs;
+	return new_data;
+}
+
+static void free_pre_push_hook_data(void *data)
+{
+	struct feed_pre_push_hook_data *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_pre_push_hook(struct transport *transport,
 			     struct ref *remote_refs)
 {
@@ -1372,6 +1391,8 @@ static int run_pre_push_hook(struct transport *transport,
 
 	opt.feed_pipe = pre_push_hook_feed_stdin;
 	opt.feed_pipe_cb_data = &data;
+	opt.copy_feed_pipe_cb_data = copy_pre_push_hook_data;
+	opt.free_feed_pipe_cb_data = free_pre_push_hook_data;
 
 	/*
 	 * pre-push hooks expect stdout & stderr to be separate, so don't merge
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH 2/4] hook: introduce "git hook list"
  2026-02-04 16:51 [PATCH 0/4] Specify hooks via configs Adrian Ratiu
  2026-02-04 16:51 ` [PATCH 1/4] hook: run a list of hooks Adrian Ratiu
@ 2026-02-04 16:51 ` Adrian Ratiu
  2026-02-09 14:28   ` Patrick Steinhardt
  2026-02-04 16:51 ` [PATCH 3/4] hook: include hooks from the config Adrian Ratiu
                   ` (4 subsequent siblings)
  6 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-04 16:51 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

From: Emily Shaffer <emilyshaffer@google.com>

If more than one hook will be run, it may be useful to see a list of
which hooks should be run. At very least, it will be useful for us to
test the semantics of multihooks ourselves.

For now, only list the hooks which will run in the order they will run
in; later, it might be useful to include more information like where the
hooks were configured and whether or not they will run.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/git-hook.adoc |  5 ++++
 builtin/hook.c              | 53 +++++++++++++++++++++++++++++++++++++
 hook.c                      | 15 +----------
 hook.h                      | 17 +++++++++++-
 t/t1800-hook.sh             |  2 ++
 5 files changed, 77 insertions(+), 15 deletions(-)

diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index f6cc72d2ca..93d734f687 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -9,6 +9,7 @@ SYNOPSIS
 --------
 [verse]
 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
+'git hook' list <hook-name>
 
 DESCRIPTION
 -----------
@@ -28,6 +29,10 @@ Any positional arguments to the hook should be passed after a
 mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See
 linkgit:githooks[5] for arguments hooks might expect (if any).
 
+list::
+	Print a list of hooks which will be run on `<hook-name>` event. If no
+	hooks are configured for that event, print nothing and return 1.
+
 OPTIONS
 -------
 
diff --git a/builtin/hook.c b/builtin/hook.c
index 7afec380d2..4cc6dac45a 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -6,12 +6,16 @@
 #include "hook.h"
 #include "parse-options.h"
 #include "strvec.h"
+#include "abspath.h"
 
 #define BUILTIN_HOOK_RUN_USAGE \
 	N_("git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
+#define BUILTIN_HOOK_LIST_USAGE \
+	N_("git hook list <hook-name>")
 
 static const char * const builtin_hook_usage[] = {
 	BUILTIN_HOOK_RUN_USAGE,
+	BUILTIN_HOOK_LIST_USAGE,
 	NULL
 };
 
@@ -20,6 +24,54 @@ static const char * const builtin_hook_run_usage[] = {
 	NULL
 };
 
+static const char *const builtin_hook_list_usage[] = {
+	BUILTIN_HOOK_LIST_USAGE,
+	NULL
+};
+
+static int list(int argc, const char **argv, const char *prefix,
+		 struct repository *repo UNUSED)
+{
+	struct string_list *head;
+	struct string_list_item *item;
+	const char *hookname = NULL;
+	int ret = 0;
+
+	struct option list_options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, list_options,
+			     builtin_hook_list_usage, 0);
+
+	/*
+	 * The only unnamed argument provided should be the hook-name; if we add
+	 * arguments later they probably should be caught by parse_options.
+	 */
+	if (argc != 1)
+		usage_msg_opt(_("You must specify a hook event name to list."),
+			      builtin_hook_list_usage, list_options);
+
+	hookname = argv[0];
+
+	head = list_hooks(the_repository, hookname);
+
+	if (!head->nr) {
+		ret = 1; /* no hooks found */
+		goto cleanup;
+	}
+
+	for_each_string_list_item(item, head) {
+		printf("%s\n", *item->string ? item->string
+			     : _("hook from hookdir"));
+	}
+
+cleanup:
+	string_list_clear(head, 1);
+	free(head);
+	return ret;
+}
+
 static int run(int argc, const char **argv, const char *prefix,
 	       struct repository *repo UNUSED)
 {
@@ -77,6 +129,7 @@ int cmd_hook(int argc,
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option builtin_hook_options[] = {
 		OPT_SUBCOMMAND("run", &fn, run),
+		OPT_SUBCOMMAND("list", &fn, list),
 		OPT_END(),
 	};
 
diff --git a/hook.c b/hook.c
index fb90f91f3b..949c907b59 100644
--- a/hook.c
+++ b/hook.c
@@ -47,20 +47,7 @@ const char *find_hook(struct repository *r, const char *name)
 	return path.buf;
 }
 
-/*
- * Provides a list of hook commands to run for the 'hookname' event.
- *
- * This function consolidates hooks from two sources:
- * 1. The config-based hooks (not yet implemented).
- * 2. The "traditional" hook found in the repository hooks directory
- *    (e.g., .git/hooks/pre-commit).
- *
- * The list is ordered by execution priority.
- *
- * The caller is responsible for freeing the memory of the returned list
- * using string_list_clear() and free().
- */
-static struct string_list *list_hooks(struct repository *r, const char *hookname)
+struct string_list *list_hooks(struct repository *r, const char *hookname)
 {
 	struct string_list *hook_head;
 
diff --git a/hook.h b/hook.h
index 33a0e33684..cdbe5a9167 100644
--- a/hook.h
+++ b/hook.h
@@ -143,7 +143,22 @@ struct hook_cb_data {
 	struct repository *repository;
 };
 
-/*
+/**
+ * Provides a list of hook commands to run for the 'hookname' event.
+ *
+ * This function consolidates hooks from two sources:
+ * 1. The config-based hooks (not yet implemented).
+ * 2. The "traditional" hook found in the repository hooks directory
+ *    (e.g., .git/hooks/pre-commit).
+ *
+ * The list is ordered by execution priority.
+ *
+ * The caller is responsible for freeing the memory of the returned list
+ * using string_list_clear() and free().
+ */
+struct string_list *list_hooks(struct repository *r, const char *hookname);
+
+/**
  * Returns the path to the hook file, or NULL if the hook is missing
  * or disabled. Note that this points to static storage that will be
  * overwritten by further calls to find_hook and run_hook_*.
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index ed28a2fadb..d2d4a8760c 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -10,6 +10,8 @@ test_expect_success 'git hook usage' '
 	test_expect_code 129 git hook run &&
 	test_expect_code 129 git hook run -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 &&
 	grep "unknown option" err
 '
 
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH 3/4] hook: include hooks from the config
  2026-02-04 16:51 [PATCH 0/4] Specify hooks via configs Adrian Ratiu
  2026-02-04 16:51 ` [PATCH 1/4] hook: run a list of hooks Adrian Ratiu
  2026-02-04 16:51 ` [PATCH 2/4] hook: introduce "git hook list" Adrian Ratiu
@ 2026-02-04 16:51 ` Adrian Ratiu
  2026-02-09 14:28   ` Patrick Steinhardt
  2026-02-04 16:51 ` [PATCH 4/4] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
                   ` (3 subsequent siblings)
  6 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-04 16:51 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

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


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH 4/4] hook: allow out-of-repo 'git hook' invocations
  2026-02-04 16:51 [PATCH 0/4] Specify hooks via configs Adrian Ratiu
                   ` (2 preceding siblings ...)
  2026-02-04 16:51 ` [PATCH 3/4] hook: include hooks from the config Adrian Ratiu
@ 2026-02-04 16:51 ` Adrian Ratiu
  2026-02-06 16:26 ` [PATCH 0/4] Specify hooks via configs Junio C Hamano
                   ` (2 subsequent siblings)
  6 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-04 16:51 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk

From: Emily Shaffer <emilyshaffer@google.com>

Since hooks can now be supplied via the config, and a config can be
present without a gitdir via the global and system configs, we can start
to allow 'git hook run' to occur without a gitdir. This enables us to do
things like run sendemail-validate hooks when running 'git send-email'
from a nongit directory.

It still doesn't make sense to look for hooks in the hookdir in nongit
repos, though, as there is no hookdir.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
---
 git.c           |  2 +-
 t/t1800-hook.sh | 16 +++++++++++-----
 2 files changed, 12 insertions(+), 6 deletions(-)

diff --git a/git.c b/git.c
index c5fad56813..a9e462ee32 100644
--- a/git.c
+++ b/git.c
@@ -586,7 +586,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
-	{ "hook", cmd_hook, RUN_SETUP },
+	{ "hook", cmd_hook, RUN_SETUP_GENTLY },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
 	{ "init-db", cmd_init_db },
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index bc4862e982..21ff6a68f0 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -102,12 +102,18 @@ test_expect_success 'git hook run -- pass arguments' '
 	test_cmp expect actual
 '
 
-test_expect_success 'git hook run -- out-of-repo runs excluded' '
-	test_hook test-hook <<-EOF &&
-	echo Test hook
-	EOF
+test_expect_success 'git hook run: out-of-repo runs execute global hooks' '
+	test_config_global hook.global-hook.event test-hook --add &&
+	test_config_global hook.global-hook.command "echo no repo no problems" --add &&
 
-	nongit test_must_fail git hook run test-hook
+	echo "global-hook" >expect &&
+	nongit git hook list test-hook >actual &&
+	test_cmp expect actual &&
+
+	echo "no repo no problems" >expect &&
+
+	nongit git hook run test-hook 2>actual &&
+	test_cmp expect actual
 '
 
 test_expect_success 'git -c core.hooksPath=<PATH> hook run' '
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* Re: [PATCH 1/4] hook: run a list of hooks
  2026-02-04 16:51 ` [PATCH 1/4] hook: run a list of hooks Adrian Ratiu
@ 2026-02-05 21:59   ` Junio C Hamano
  2026-02-06 11:21     ` Adrian Ratiu
  2026-02-09 14:27   ` Patrick Steinhardt
  1 sibling, 1 reply; 69+ messages in thread
From: Junio C Hamano @ 2026-02-05 21:59 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
> index b5379a4895..72fde2207c 100644
> --- a/builtin/receive-pack.c
> +++ b/builtin/receive-pack.c
> @@ -849,7 +849,8 @@ struct receive_hook_feed_state {
>  
>  static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)
>  {
> -	struct receive_hook_feed_state *state = pp_task_cb;
> +	struct string_list_item *h = pp_task_cb;
> +	struct receive_hook_feed_state *state = h->util;
>  	struct command *cmd = state->cmd;

So the new calling convention is to give a string_list_item to the
callback function, whose .util member has the historical "state".  OK.


> @@ -944,6 +963,8 @@ static int run_receive_hook(struct command *commands,
>  	strbuf_init(&feed_state.buf, 0);
>  	opt.feed_pipe_cb_data = &feed_state;
>  	opt.feed_pipe = feed_receive_hook_cb;
> +	opt.copy_feed_pipe_cb_data = copy_receive_hook_feed_state;
> +	opt.free_feed_pipe_cb_data = free_receive_hook_feed_state;
>  
>  	ret = run_hooks_opt(the_repository, hook_name, &opt);
>  
> diff --git a/hook.c b/hook.c
> index cde7198412..fb90f91f3b 100644
> --- a/hook.c
> +++ b/hook.c
> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)
>  	return path.buf;
>  }
>  
> +/*
> + * Provides a list of hook commands to run for the 'hookname' event.
> + *
> + * This function consolidates hooks from two sources:
> + * 1. The config-based hooks (not yet implemented).
> + * 2. The "traditional" hook found in the repository hooks directory
> + *    (e.g., .git/hooks/pre-commit).
> + *
> + * The list is ordered by execution priority.
> + *
> + * The caller is responsible for freeing the memory of the returned list
> + * using string_list_clear() and free().
> + */
> +static struct string_list *list_hooks(struct repository *r, const char *hookname)
> +{
> +	struct string_list *hook_head;
> +
> +	if (!hookname)
> +		BUG("null hookname was provided to hook_list()!");
> +
> +	hook_head = xmalloc(sizeof(struct string_list));
> +	string_list_init_dup(hook_head);
> +
> +	/*
> +	 * 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, "");
> +
> +	return hook_head;
> +}

OK.  I would have expected the caller to supply a string_list
(presumably an empty one would be the most common usage) and the
only responsibility for this function would be to stuff found hooks
to the given string_list.  But this arrangement to give a newly
created string list would work just fine as well.

>  int hook_exists(struct repository *r, const char *name)
>  {
> -	return !!find_hook(r, name);
> +	int exists = 0;
> +	struct string_list *hooks = list_hooks(r, name);
> +
> +	exists = hooks->nr > 0;
> +
> +	string_list_clear(hooks, 1);
> +	free(hooks);
> +	return exists;
>  }

OK.  It is not just "git hook list", but the internal function
list_hooks() that is the topic of this step of the series.  So
anywhere that uses find_hook() in the current codebase is a good
candidate to migrate to use the new function when appropriate, and
this is one of such places.  I presume that find_hook() will stay to
be a "find the program in $GIT_DIR/hooks/ directory" function that
has a single answer (i.e. pathnmame), but the list_hooks() may
return more than one string_list_item, one of them might be the
traditional hook and others may come from somewhere else.  Do they
need to be differentiated in some way (i.e., Is "where did the come
from?" a legitimate thing for a caller of list_hook() to ask)?
There are many unknowns while reading this step because we still
haven't seen the other sources of the hooks.

> @@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,
>  			  void **pp_task_cb)
>  {
>  	struct hook_cb_data *hook_cb = pp_cb;
> -	const char *hook_path = hook_cb->hook_path;
> +	struct string_list *hook_list = hook_cb->hook_command_list;
> +	struct string_list_item *to_run = hook_cb->next_hook++;
>  
> -	if (!hook_path)
> -		return 0;
> +	if (!to_run || to_run >= hook_list->items + hook_list->nr)
> +		return 0; /* no hook left to run */
>  
>  	cp->no_stdin = 1;
>  	strvec_pushv(&cp->env, hook_cb->options->env.v);
> @@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,
>  	cp->trace2_hook_name = hook_cb->hook_name;
>  	cp->dir = hook_cb->options->dir;
>  
> -	strvec_push(&cp->args, hook_path);
> +	/* find hook commands */
> +	if (!*to_run->string) {
> +		/* ...from hookdir signified by empty name */

Make a macro or a small helper function that takes a single
string_list_item that is an element in the return value of
list_hooks(), and tells if it is a program in $GIT_DIR/hooks/
directory.  Writing code that depends on this "we did not bother to
invent a way to allow users to give friendly names to traditional
on-disk hooks, so it is registered with an empty string as its name"
convention and having to document it is inefficient, both for
writers of the code and for readers of the code.

Or perhaps the .util member of each of these can be a pointer to
something like:

	struct {
		enum {
                	HOOK_TRADITIONAL, HOOK_CONFIGURED,
		} kind;
		union {
			struct {
				const char *path;
			} traditional;
			struct {
				const char *friendly_name;
				const char *path;
				... maybe others ...
			} configured;
		} u;
	}

so that this codepath does not even have to ...

> +		const char *hook_path = find_hook(hook_cb->repository,
> +						  hook_cb->hook_name);

... run the same find_hook() again here?

> -static int notify_start_failure(struct strbuf *out UNUSED,
> +static int notify_start_failure(struct strbuf *out,
>  				void *pp_cb,
> -				void *pp_task_cp UNUSED)
> +				void *pp_task_cb)
>  {
>  	struct hook_cb_data *hook_cb = pp_cb;
> +	struct string_list_item *hook = pp_task_cb;
>  
>  	hook_cb->rc |= 1;
>  
> +	if (out && hook) {
> +		if (*hook->string)

Ditto.

> +			strbuf_addf(out, _("Couldn't start hook '%s'\n"), hook->string);
> +		else
> +			strbuf_addstr(out, _("Couldn't start hook from hooks directory\n"));
> +	}
> +
>  	return 1;
>  }

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 1/4] hook: run a list of hooks
  2026-02-05 21:59   ` Junio C Hamano
@ 2026-02-06 11:21     ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-06 11:21 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, 05 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:
<snip>
>> diff --git a/hook.c b/hook.c
>> index cde7198412..fb90f91f3b 100644
>> --- a/hook.c
>> +++ b/hook.c
>> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)
>>  	return path.buf;
>>  }
>>  
>> +/*
>> + * Provides a list of hook commands to run for the 'hookname' event.
>> + *
>> + * This function consolidates hooks from two sources:
>> + * 1. The config-based hooks (not yet implemented).
>> + * 2. The "traditional" hook found in the repository hooks directory
>> + *    (e.g., .git/hooks/pre-commit).
>> + *
>> + * The list is ordered by execution priority.
>> + *
>> + * The caller is responsible for freeing the memory of the returned list
>> + * using string_list_clear() and free().
>> + */
>> +static struct string_list *list_hooks(struct repository *r, const char *hookname)
>> +{
>> +	struct string_list *hook_head;
>> +
>> +	if (!hookname)
>> +		BUG("null hookname was provided to hook_list()!");
>> +
>> +	hook_head = xmalloc(sizeof(struct string_list));
>> +	string_list_init_dup(hook_head);
>> +
>> +	/*
>> +	 * 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, "");
>> +
>> +	return hook_head;
>> +}
>
> OK.  I would have expected the caller to supply a string_list
> (presumably an empty one would be the most common usage) and the
> only responsibility for this function would be to stuff found hooks
> to the given string_list.  But this arrangement to give a newly
> created string list would work just fine as well.

Thank you for suggesting this potential alternative.

It didn't occur to me that we could pass an existing list to update
instead of creating a new list each time. :)

I will certainly explore this option, because I don't like how
list_hooks() currently returns a new list and the callers have to clear
it each time, even though it's done in just 2 max 3 locations  and the
lists are rather small (how many hooks can one have for each event?).

>>  int hook_exists(struct repository *r, const char *name)
>>  {
>> -	return !!find_hook(r, name);
>> +	int exists = 0;
>> +	struct string_list *hooks = list_hooks(r, name);
>> +
>> +	exists = hooks->nr > 0;
>> +
>> +	string_list_clear(hooks, 1);
>> +	free(hooks);
>> +	return exists;
>>  }
>
> OK.  It is not just "git hook list", but the internal function
> list_hooks() that is the topic of this step of the series.  So
> anywhere that uses find_hook() in the current codebase is a good
> candidate to migrate to use the new function when appropriate, and
> this is one of such places.  I presume that find_hook() will stay to
> be a "find the program in $GIT_DIR/hooks/ directory" function that
> has a single answer (i.e. pathnmame), but the list_hooks() may
> return more than one string_list_item, one of them might be the
> traditional hook and others may come from somewhere else.  Do they
> need to be differentiated in some way (i.e., Is "where did the come
> from?" a legitimate thing for a caller of list_hook() to ask)?
> There are many unknowns while reading this step because we still
> haven't seen the other sources of the hooks.

You correctly understood how it works by the next comment, yes.

We just relied on the fact that the default hooks in the hookdir do not
have a "friendly name", so we gave them an empty name to differentiate.

Not a fan of this either, so I will certainly try out your suggestions
below for v2.

>> @@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,
>>  			  void **pp_task_cb)
>>  {
>>  	struct hook_cb_data *hook_cb = pp_cb;
>> -	const char *hook_path = hook_cb->hook_path;
>> +	struct string_list *hook_list = hook_cb->hook_command_list;
>> +	struct string_list_item *to_run = hook_cb->next_hook++;
>>  
>> -	if (!hook_path)
>> -		return 0;
>> +	if (!to_run || to_run >= hook_list->items + hook_list->nr)
>> +		return 0; /* no hook left to run */
>>  
>>  	cp->no_stdin = 1;
>>  	strvec_pushv(&cp->env, hook_cb->options->env.v);
>> @@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,
>>  	cp->trace2_hook_name = hook_cb->hook_name;
>>  	cp->dir = hook_cb->options->dir;
>>  
>> -	strvec_push(&cp->args, hook_path);
>> +	/* find hook commands */
>> +	if (!*to_run->string) {
>> +		/* ...from hookdir signified by empty name */
>
> Make a macro or a small helper function that takes a single
> string_list_item that is an element in the return value of
> list_hooks(), and tells if it is a program in $GIT_DIR/hooks/
> directory.  Writing code that depends on this "we did not bother to
> invent a way to allow users to give friendly names to traditional
> on-disk hooks, so it is registered with an empty string as its name"
> convention and having to document it is inefficient, both for
> writers of the code and for readers of the code.

100% agreed.

>
> Or perhaps the .util member of each of these can be a pointer to
> something like:
>
> 	struct {
> 		enum {
>                 	HOOK_TRADITIONAL, HOOK_CONFIGURED,
> 		} kind;
> 		union {
> 			struct {
> 				const char *path;
> 			} traditional;
> 			struct {
> 				const char *friendly_name;
> 				const char *path;
> 				... maybe others ...
> 			} configured;
> 		} u;
> 	}
>
> so that this codepath does not even have to ...
>
>> +		const char *hook_path = find_hook(hook_cb->repository,
>> +						  hook_cb->hook_name);
>
> ... run the same find_hook() again here?

Yes, I'll combine all these approaches in v2 to see if I can come up
with something more sensible.

Thank you so much for the valuable feedback, I really appreciate it,
Adrian

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 0/4] Specify hooks via configs
  2026-02-04 16:51 [PATCH 0/4] Specify hooks via configs Adrian Ratiu
                   ` (3 preceding siblings ...)
  2026-02-04 16:51 ` [PATCH 4/4] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
@ 2026-02-06 16:26 ` Junio C Hamano
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
  6 siblings, 0 replies; 69+ messages in thread
From: Junio C Hamano @ 2026-02-06 16:26 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

> Hello everyone,
>
> This series adds a new feature: the ability to specify commands to run
> for hook events via config entries (including shell commands).
>
> The config schema is identical to the one developed by Emily and AEvar
> a few years ago [1] though the implementation is significantly different
> because it's based on the new / cleaned-up hook.[ch] APIs. [2].
>
> For simplicity, hooks are still executed sequentially (.jobs == 1) in
> this series, just like before. Parallel execution will be enabled in
> a separate series based on this one.
>
> The hook execution order is this:
> 1. Hooks read from the config. If multiple hook commands are specified
>    for a single event, they are executed in config discovery order.
> 2. The default hooks from the hookdir.
>
> The above order can be changed if necessary.
>
> Again, this is based on the latest v8 hooks-conversion series [2] which
> has not yet landed in next or master.

One thing missing to help those who haven't seen earlier iterations
of this topic is why we would want to do this.  Instead of dropping
a shell script or a custom program "foo" in .git/hooks/ directory,
you can tell the system to run program X when you want to run "foo"
hook.  The reason why somebody may want to do so is ...?

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 1/4] hook: run a list of hooks
  2026-02-04 16:51 ` [PATCH 1/4] hook: run a list of hooks Adrian Ratiu
  2026-02-05 21:59   ` Junio C Hamano
@ 2026-02-09 14:27   ` Patrick Steinhardt
  2026-02-09 18:16     ` Adrian Ratiu
  1 sibling, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-09 14:27 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Wed, Feb 04, 2026 at 06:51:23PM +0200, Adrian Ratiu wrote:
> From: Emily Shaffer <emilyshaffer@google.com>
> 
> Teach hook.[hc] to run lists of hooks to prepare for multihook support.
> 
> Currently, the hook list contains only one entry representing the
> "legacy" hook from the hookdir, but next commits will allow users
> to supply more than one executable/command for a single hook event
> in addition to these default "legacy" hooks.
> 
> All hook commands still run sequentially. A further patch series will
> enable running them in parallel by increasing .jobs > 1 where possible.
> 
> Each hook command requires its own internal state copy, even when
> running sequentially, so add an API to allow hooks to duplicate/free
> their internal void *pp_task_cb state.

I think the order in this commit message is a bit off. We typically
first state the problem that we aim to solve before presenting the
solution.

> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
> index b5379a4895..72fde2207c 100644
> --- a/builtin/receive-pack.c
> +++ b/builtin/receive-pack.c
> @@ -849,7 +849,8 @@ struct receive_hook_feed_state {
>  
>  static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)
>  {
> -	struct receive_hook_feed_state *state = pp_task_cb;
> +	struct string_list_item *h = pp_task_cb;
> +	struct receive_hook_feed_state *state = h->util;
>  	struct command *cmd = state->cmd;
>  
>  	strbuf_reset(&state->buf);
> @@ -901,6 +902,24 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_
>  	return state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */
>  }
>  
> +static void *copy_receive_hook_feed_state(const void *data)

Nit: our coding guidelines say that the name of the struct should come
first. So this would be `receive_hook_feed_state_copy()`.

> +{
> +	const struct receive_hook_feed_state *orig = data;
> +	struct receive_hook_feed_state *new_data = xmalloc(sizeof(*new_data));
> +	memcpy(new_data, orig, sizeof(*new_data));
> +	strbuf_init(&new_data->buf, 0);
> +	return new_data;
> +}
> +
> +static void free_receive_hook_feed_state(void *data)

Likewise, this would be `receive_hook_feed_state_free()`.

> diff --git a/hook.c b/hook.c
> index cde7198412..fb90f91f3b 100644
> --- a/hook.c
> +++ b/hook.c
> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)
>  	return path.buf;
>  }
>  
> +/*
> + * Provides a list of hook commands to run for the 'hookname' event.
> + *
> + * This function consolidates hooks from two sources:
> + * 1. The config-based hooks (not yet implemented).
> + * 2. The "traditional" hook found in the repository hooks directory
> + *    (e.g., .git/hooks/pre-commit).
> + *
> + * The list is ordered by execution priority.
> + *
> + * The caller is responsible for freeing the memory of the returned list
> + * using string_list_clear() and free().
> + */
> +static struct string_list *list_hooks(struct repository *r, const char *hookname)
> +{
> +	struct string_list *hook_head;
> +
> +	if (!hookname)
> +		BUG("null hookname was provided to hook_list()!");
> +
> +	hook_head = xmalloc(sizeof(struct string_list));
> +	string_list_init_dup(hook_head);
> +
> +	/*
> +	 * 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, "");

Why is there a check for `r->gitdir` here? Do we ever execute hooks
outside of a fully-initialized repository?

Other than that, we now insert hooks into the list. It's somewhat
surprising that we insert hook "names" here, instead of for example
adding the full hook path to the list. Is there any specific reason for
this decision?

> +	return hook_head;
> +}
> +
>  int hook_exists(struct repository *r, const char *name)
>  {
> -	return !!find_hook(r, name);
> +	int exists = 0;
> +	struct string_list *hooks = list_hooks(r, name);
> +
> +	exists = hooks->nr > 0;
> +
> +	string_list_clear(hooks, 1);
> +	free(hooks);
> +	return exists;
>  }
>  
>  static int pick_next_hook(struct child_process *cp,

And here we verify that there's at least one such hook that we found.
Which would currently mean that the hook in ".git/hooks/" exists.

> @@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,
>  			  void **pp_task_cb)
>  {
>  	struct hook_cb_data *hook_cb = pp_cb;
> -	const char *hook_path = hook_cb->hook_path;
> +	struct string_list *hook_list = hook_cb->hook_command_list;
> +	struct string_list_item *to_run = hook_cb->next_hook++;
>  
> -	if (!hook_path)
> -		return 0;
> +	if (!to_run || to_run >= hook_list->items + hook_list->nr)
> +		return 0; /* no hook left to run */

Hm, okay. Wouldn't it be a bit more ergonomic to instead store the index
of the current hook instead of storing the string list item itself? In
that case we could verify whether `hook_cb->hook_cur < hook_list.nr` or
something like this.

> @@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,
>  	cp->trace2_hook_name = hook_cb->hook_name;
>  	cp->dir = hook_cb->options->dir;
>  
> -	strvec_push(&cp->args, hook_path);
> +	/* find hook commands */
> +	if (!*to_run->string) {
> +		/* ...from hookdir signified by empty name */
> +		const char *hook_path = find_hook(hook_cb->repository,
> +						  hook_cb->hook_name);
> +		if (!hook_path)
> +			BUG("hookdir in hook list but no hook present in filesystem");
> +
> +		if (hook_cb->options->dir)
> +			hook_path = absolute_path(hook_path);
> +
> +		strvec_push(&cp->args, hook_path);
> +	}

We could avoid this special casing if we stored the absolute paths in
the hook list right away. But maybe there is a good reason you don't.

> +	if (!cp->args.nr)
> +		BUG("configured hook must have at least one command");
> +
>  	strvec_pushv(&cp->args, hook_cb->options->args.v);
>  
>  	/*
>  	 * Provide per-hook internal state via task_cb for easy access, so
>  	 * hook callbacks don't have to go through hook_cb->options.
>  	 */
> -	*pp_task_cb = hook_cb->options->feed_pipe_cb_data;
> -
> -	/*
> -	 * This pick_next_hook() will be called again, we're only
> -	 * running one hook, so indicate that no more work will be
> -	 * done.
> -	 */
> -	hook_cb->hook_path = NULL;
> +	*pp_task_cb = to_run;
>  
>  	return 1;
>  }
>  
> -static int notify_start_failure(struct strbuf *out UNUSED,
> +static int notify_start_failure(struct strbuf *out,
>  				void *pp_cb,
> -				void *pp_task_cp UNUSED)
> +				void *pp_task_cb)
>  {
>  	struct hook_cb_data *hook_cb = pp_cb;
> +	struct string_list_item *hook = pp_task_cb;
>  
>  	hook_cb->rc |= 1;
>  
> +	if (out && hook) {
> +		if (*hook->string)
> +			strbuf_addf(out, _("Couldn't start hook '%s'\n"), hook->string);
> +		else
> +			strbuf_addstr(out, _("Couldn't start hook from hooks directory\n"));
> +	}
> +
>  	return 1;
>  }

Okay, here we can give a bit more detail in the error message. But that
alone doesn't quite feel worth the additional complexity.

> @@ -172,26 +231,50 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
>  	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->copy_feed_pipe_cb_data && !options->free_feed_pipe_cb_data) ||
> +	    (!options->copy_feed_pipe_cb_data && options->free_feed_pipe_cb_data))
> +		BUG("copy_feed_pipe_cb_data and free_feed_pipe_cb_data must be set together");

I wonder whether it would make the commit a bit easier to review if you
split it up into two:

  - One commit that introduces the copy/free callback functions.

  - One commit that introduces the hook list.

Then the changes would be a bit more focussed.

>  	if (options->invoked_hook)
>  		*options->invoked_hook = 0;
>  
> -	if (!hook_path && !options->error_if_missing)
> -		goto cleanup;
> -
> -	if (!hook_path) {
> -		ret = error("cannot find a hook named %s", hook_name);
> +	if (!cb_data.hook_command_list->nr) {
> +		if (options->error_if_missing)
> +			ret = error("cannot find a hook named %s", hook_name);
>  		goto cleanup;
>  	}
>  
> -	cb_data.hook_path = hook_path;
> -	if (options->dir) {
> -		strbuf_add_absolute_path(&abs_path, hook_path);
> -		cb_data.hook_path = abs_path.buf;
> +	/*
> +	 * Initialize the iterator/cursor which holds the next hook to run.
> +	 * run_process_parallel() calls pick_next_hook() which increments it for
> +	 * each hook command in the list until all hooks have been run.
> +	 */
> +	cb_data.next_hook = cb_data.hook_command_list->items;
> +
> +	/*
> +	 * Give each hook its own copy of the initial void *pp_task_cb state, if
> +	 * a copy callback was provided.
> +	 */
> +	if (options->copy_feed_pipe_cb_data) {
> +		struct string_list_item *item;
> +		for_each_string_list_item(item, cb_data.hook_command_list)
> +			item->util = options->copy_feed_pipe_cb_data(options->feed_pipe_cb_data);
>  	}

Makes sense. We cannot just peform a shallow copy of the callback data
as it may contain data itself that needs to be copied. So we have to
handle the copying ourselves.

One thing I wondered is whether it would lead to a cleaner design if
this was instead provided as a callback to allocate and initialize a
completely _new_ callback data. In that case the caller wouldn't have to
first initialize the data only to copy it in a different place, all the
logic would be self-contained in that one callback.

The downside would of course be that we cannot use for example the
stack, and it might make it harder to reuse state across multiple hook
invocations, if that's a feature we care about.

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 2/4] hook: introduce "git hook list"
  2026-02-04 16:51 ` [PATCH 2/4] hook: introduce "git hook list" Adrian Ratiu
@ 2026-02-09 14:28   ` Patrick Steinhardt
  2026-02-09 18:26     ` Adrian Ratiu
  0 siblings, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-09 14:28 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Wed, Feb 04, 2026 at 06:51:24PM +0200, Adrian Ratiu wrote:
> From: Emily Shaffer <emilyshaffer@google.com>
> 
> If more than one hook will be run, it may be useful to see a list of
> which hooks should be run. At very least, it will be useful for us to
> test the semantics of multihooks ourselves.
> 
> For now, only list the hooks which will run in the order they will run
> in; later, it might be useful to include more information like where the
> hooks were configured and whether or not they will run.

I think the commit message could be adapted a bit again to first explain
the problem we're about to solve.

> diff --git a/builtin/hook.c b/builtin/hook.c
> index 7afec380d2..4cc6dac45a 100644
> --- a/builtin/hook.c
> +++ b/builtin/hook.c
> @@ -20,6 +24,54 @@ static const char * const builtin_hook_run_usage[] = {
>  	NULL
>  };
>  
> +static const char *const builtin_hook_list_usage[] = {
> +	BUILTIN_HOOK_LIST_USAGE,
> +	NULL
> +};
> +

This constant can be declared inside `list()`.

> +static int list(int argc, const char **argv, const char *prefix,
> +		 struct repository *repo UNUSED)
> +{
> +	struct string_list *head;
> +	struct string_list_item *item;
> +	const char *hookname = NULL;
> +	int ret = 0;
> +
> +	struct option list_options[] = {
> +		OPT_END(),
> +	};
> +
> +	argc = parse_options(argc, argv, prefix, list_options,
> +			     builtin_hook_list_usage, 0);
> +
> +	/*
> +	 * The only unnamed argument provided should be the hook-name; if we add
> +	 * arguments later they probably should be caught by parse_options.
> +	 */
> +	if (argc != 1)
> +		usage_msg_opt(_("You must specify a hook event name to list."),
> +			      builtin_hook_list_usage, list_options);
> +
> +	hookname = argv[0];
> +
> +	head = list_hooks(the_repository, hookname);

We can use the `repo` parameter instead. The git-hook(1) command is
declared with `RUN_SETUP`, so it will always be set.

> +	if (!head->nr) {
> +		ret = 1; /* no hooks found */
> +		goto cleanup;
> +	}

Do we want to print an error message in this case?

> +	for_each_string_list_item(item, head) {
> +		printf("%s\n", *item->string ? item->string
> +			     : _("hook from hookdir"));
> +	}

This is another case where we could avoid special-casing if the string
list contained the hook paths.

I also wonder whether we should add a "-z" mode to NUL-terminate the
output. In theory, hooks may be configured with a newline in their path.
Probably not all that common, but somehow special cases like this always
end up being encountered eventually.

> diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
> index ed28a2fadb..d2d4a8760c 100755
> --- a/t/t1800-hook.sh
> +++ b/t/t1800-hook.sh
> @@ -10,6 +10,8 @@ test_expect_success 'git hook usage' '
>  	test_expect_code 129 git hook run &&
>  	test_expect_code 129 git hook run -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 &&
>  	grep "unknown option" err
>  '

Shouldn't we also have some tests that show that this is working as
expected with a configured hook in ".git/hooks"?

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 3/4] hook: include hooks from the config
  2026-02-04 16:51 ` [PATCH 3/4] hook: include hooks from the config Adrian Ratiu
@ 2026-02-09 14:28   ` Patrick Steinhardt
  2026-02-09 19:10     ` Adrian Ratiu
  0 siblings, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-09 14:28 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Wed, Feb 04, 2026 at 06:51:25PM +0200, Adrian Ratiu wrote:
> 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].

I'm not exactly sure how you'd execute it on multiple events after
reading this description. Is this by comma-delimiting them? Or is the
event key a multivalue variable? If the latter (which I think would make
most sense), can you also reset the value by e.g. saying "event = " like
we support with other multivalued keys?

I would have also expected a "hook.<name>.enabled" option so that you
can disable globally configured hooks.

> 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>

Let's use the modern equivalents:

    git config set hook.<some-name>.command <path-to-script>
    git config set --append 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
> +----

Likewise.

> @@ -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

Ah, interesting. Would that then also look for
".git/hooks/mywrapper-start-tests"?

> 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
> +{

Formatting: the curly brace should not go on a standalone line.

> +	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;

Okay.

> +	/* 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;

So we only care about parsing "hook.<name>.event" here, and I assume
that we would look up the command at a later point in time? That design
is somewhat weird -- I would have expected that we scan through all
config keys and already pull out both the command and event so that we
don't have to extract the config a second time.

But I guess this is another consequence of the string list only caring
about the name, not about the actual path.

> +	/* 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);

There are cases where we invoke the same hook multiple times per
process. One such candidate is for example the "reference-transaction"
hook, which is executed thrice per ref transaction. Would it make sense
if we cached the value in `r` so that we can avoid repeatedly parsing
the configuration for this hook?

We could even go one step further: instead of caching the value for
_one_ hook, we could parse _all_ hooks lazily and store them in a map.

> @@ -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)

Okay, so here we do an extra step to extract the commands. I think this
would fit more naturally in `hook_config_lookup`.

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 1/4] hook: run a list of hooks
  2026-02-09 14:27   ` Patrick Steinhardt
@ 2026-02-09 18:16     ` Adrian Ratiu
  2026-02-10 13:43       ` Patrick Steinhardt
  0 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-09 18:16 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Wed, Feb 04, 2026 at 06:51:23PM +0200, Adrian Ratiu wrote:
>> From: Emily Shaffer <emilyshaffer@google.com>
>> 
>> Teach hook.[hc] to run lists of hooks to prepare for multihook support.
>> 
>> Currently, the hook list contains only one entry representing the
>> "legacy" hook from the hookdir, but next commits will allow users
>> to supply more than one executable/command for a single hook event
>> in addition to these default "legacy" hooks.
>> 
>> All hook commands still run sequentially. A further patch series will
>> enable running them in parallel by increasing .jobs > 1 where possible.
>> 
>> Each hook command requires its own internal state copy, even when
>> running sequentially, so add an API to allow hooks to duplicate/free
>> their internal void *pp_task_cb state.
>
> I think the order in this commit message is a bit off. We typically
> first state the problem that we aim to solve before presenting the
> solution.

Ack. Will fix in v2.

>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
>> index b5379a4895..72fde2207c 100644
>> --- a/builtin/receive-pack.c
>> +++ b/builtin/receive-pack.c
>> @@ -849,7 +849,8 @@ struct receive_hook_feed_state {
>>  
>>  static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)
>>  {
>> -	struct receive_hook_feed_state *state = pp_task_cb;
>> +	struct string_list_item *h = pp_task_cb;
>> +	struct receive_hook_feed_state *state = h->util;
>>  	struct command *cmd = state->cmd;
>>  
>>  	strbuf_reset(&state->buf);
>> @@ -901,6 +902,24 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_
>>  	return state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */
>>  }
>>  
>> +static void *copy_receive_hook_feed_state(const void *data)
>
> Nit: our coding guidelines say that the name of the struct should come
> first. So this would be `receive_hook_feed_state_copy()`.

Ack will fix in v2 together with the below nit.

<snip>

>> diff --git a/hook.c b/hook.c
>> index cde7198412..fb90f91f3b 100644
>> --- a/hook.c
>> +++ b/hook.c
>> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)
>>  	return path.buf;
>>  }
>>  
>> +/*
>> + * Provides a list of hook commands to run for the 'hookname' event.
>> + *
>> + * This function consolidates hooks from two sources:
>> + * 1. The config-based hooks (not yet implemented).
>> + * 2. The "traditional" hook found in the repository hooks directory
>> + *    (e.g., .git/hooks/pre-commit).
>> + *
>> + * The list is ordered by execution priority.
>> + *
>> + * The caller is responsible for freeing the memory of the returned list
>> + * using string_list_clear() and free().
>> + */
>> +static struct string_list *list_hooks(struct repository *r, const char *hookname)
>> +{
>> +	struct string_list *hook_head;
>> +
>> +	if (!hookname)
>> +		BUG("null hookname was provided to hook_list()!");
>> +
>> +	hook_head = xmalloc(sizeof(struct string_list));
>> +	string_list_init_dup(hook_head);
>> +
>> +	/*
>> +	 * 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, "");
>
> Why is there a check for `r->gitdir` here? Do we ever execute hooks
> outside of a fully-initialized repository?

Nice find. The answer is yes, see the last commit in this series.

I've been cleaning up and untangling this series for quite a while now:
- It used to be combined with the other parallel hooks series.
- It used to implement its own linked list abstraction.
- It used the hook.h string-list APIs AEvar didn't like.
- and so on.

This cheeck is just a leftover bit I missed during my cleanups and I
really should be moved this to the last commit. I've been moving a lot
of code around to make it as clear as possible.

I'll do it / test in v2. Many thanks!

>
> Other than that, we now insert hooks into the list. It's somewhat
> surprising that we insert hook "names" here, instead of for example
> adding the full hook path to the list. Is there any specific reason for
> this decision?

I'm also not a fan of this design, as I mentioned in the reply to Junio.

We just need to differentiate between "new" hooks (from config) and
"default/legacy" hooks (from the hookdir) and we kind-of abused the fact
that the default hooks have no friendly name, so the name is empty, to
differentiate them. :)

Junio suggested we use a struct/union and a specific type to
differentiate between the "default/legacy" hooks (from the hookdir) and
the new config-based hooks.

That way we don't have to rely on the name anymore and we can also use
the full path here, as you suggested.

I'll do all this in v2. Thank you!

<snip>

>> @@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,
>>  			  void **pp_task_cb)
>>  {
>>  	struct hook_cb_data *hook_cb = pp_cb;
>> -	const char *hook_path = hook_cb->hook_path;
>> +	struct string_list *hook_list = hook_cb->hook_command_list;
>> +	struct string_list_item *to_run = hook_cb->next_hook++;
>>  
>> -	if (!hook_path)
>> -		return 0;
>> +	if (!to_run || to_run >= hook_list->items + hook_list->nr)
>> +		return 0; /* no hook left to run */
>
> Hm, okay. Wouldn't it be a bit more ergonomic to instead store the index
> of the current hook instead of storing the string list item itself? In
> that case we could verify whether `hook_cb->hook_cur < hook_list.nr` or
> something like this.

This is actually a great idea, thanks! I will do it in v2.

(It is yet another leftover from when this series used to roll its own
linked-list implementation. :)

>> @@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,
>>  	cp->trace2_hook_name = hook_cb->hook_name;
>>  	cp->dir = hook_cb->options->dir;
>>  
>> -	strvec_push(&cp->args, hook_path);
>> +	/* find hook commands */
>> +	if (!*to_run->string) {
>> +		/* ...from hookdir signified by empty name */
>> +		const char *hook_path = find_hook(hook_cb->repository,
>> +						  hook_cb->hook_name);
>> +		if (!hook_path)
>> +			BUG("hookdir in hook list but no hook present in filesystem");
>> +
>> +		if (hook_cb->options->dir)
>> +			hook_path = absolute_path(hook_path);
>> +
>> +		strvec_push(&cp->args, hook_path);
>> +	}
>
> We could avoid this special casing if we stored the absolute paths in
> the hook list right away. But maybe there is a good reason you don't.

Yes, will fix this in v2, trying to also use Junio's struct/union
suggestion to help make the difference between hook types.

>> +	if (!cp->args.nr)
>> +		BUG("configured hook must have at least one command");
>> +
>>  	strvec_pushv(&cp->args, hook_cb->options->args.v);
>>  
>>  	/*
>>  	 * Provide per-hook internal state via task_cb for easy access, so
>>  	 * hook callbacks don't have to go through hook_cb->options.
>>  	 */
>> -	*pp_task_cb = hook_cb->options->feed_pipe_cb_data;
>> -
>> -	/*
>> -	 * This pick_next_hook() will be called again, we're only
>> -	 * running one hook, so indicate that no more work will be
>> -	 * done.
>> -	 */
>> -	hook_cb->hook_path = NULL;
>> +	*pp_task_cb = to_run;
>>  
>>  	return 1;
>>  }
>>  
>> -static int notify_start_failure(struct strbuf *out UNUSED,
>> +static int notify_start_failure(struct strbuf *out,
>>  				void *pp_cb,
>> -				void *pp_task_cp UNUSED)
>> +				void *pp_task_cb)
>>  {
>>  	struct hook_cb_data *hook_cb = pp_cb;
>> +	struct string_list_item *hook = pp_task_cb;
>>  
>>  	hook_cb->rc |= 1;
>>  
>> +	if (out && hook) {
>> +		if (*hook->string)
>> +			strbuf_addf(out, _("Couldn't start hook '%s'\n"), hook->string);
>> +		else
>> +			strbuf_addstr(out, _("Couldn't start hook from hooks directory\n"));
>> +	}
>> +
>>  	return 1;
>>  }
>
> Okay, here we can give a bit more detail in the error message. But that
> alone doesn't quite feel worth the additional complexity.

Nice find. I 100% agree.

I actually asked myself this multiple times before posting the
series, because it would significantly simplify the implementation if we
don't do this distinction here and just write a single "generic" can't
start hook message. :)

I'm still uncertain if this is worth it, becaute this is the only
callback which uses it and it's the only reason we changed the
"pp_task_cb" convention in ALL hook callbacks.

Very likely I'll dorp this in v2, or at least split it out into its own
commit, if possible, so we see the full complexity cost of this one msg.

>> @@ -172,26 +231,50 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
>>  	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->copy_feed_pipe_cb_data && !options->free_feed_pipe_cb_data) ||
>> +	    (!options->copy_feed_pipe_cb_data && options->free_feed_pipe_cb_data))
>> +		BUG("copy_feed_pipe_cb_data and free_feed_pipe_cb_data must be set together");
>
> I wonder whether it would make the commit a bit easier to review if you
> split it up into two:
>
>   - One commit that introduces the copy/free callback functions.
>
>   - One commit that introduces the hook list.
>
> Then the changes would be a bit more focussed.

Yes, this makes sense. As I mentioned above, I've been splitting and
simplifying these commits for quite a while now.

This would be very natural continuation.

>>  	if (options->invoked_hook)
>>  		*options->invoked_hook = 0;
>>  
>> -	if (!hook_path && !options->error_if_missing)
>> -		goto cleanup;
>> -
>> -	if (!hook_path) {
>> -		ret = error("cannot find a hook named %s", hook_name);
>> +	if (!cb_data.hook_command_list->nr) {
>> +		if (options->error_if_missing)
>> +			ret = error("cannot find a hook named %s", hook_name);
>>  		goto cleanup;
>>  	}
>>  
>> -	cb_data.hook_path = hook_path;
>> -	if (options->dir) {
>> -		strbuf_add_absolute_path(&abs_path, hook_path);
>> -		cb_data.hook_path = abs_path.buf;
>> +	/*
>> +	 * Initialize the iterator/cursor which holds the next hook to run.
>> +	 * run_process_parallel() calls pick_next_hook() which increments it for
>> +	 * each hook command in the list until all hooks have been run.
>> +	 */
>> +	cb_data.next_hook = cb_data.hook_command_list->items;
>> +
>> +	/*
>> +	 * Give each hook its own copy of the initial void *pp_task_cb state, if
>> +	 * a copy callback was provided.
>> +	 */
>> +	if (options->copy_feed_pipe_cb_data) {
>> +		struct string_list_item *item;
>> +		for_each_string_list_item(item, cb_data.hook_command_list)
>> +			item->util = options->copy_feed_pipe_cb_data(options->feed_pipe_cb_data);
>>  	}
>
> Makes sense. We cannot just peform a shallow copy of the callback data
> as it may contain data itself that needs to be copied. So we have to
> handle the copying ourselves.

Yes, this is the heart of the problem, especially when we start running
hooks in parallel, they each really need their own internal states.

> One thing I wondered is whether it would lead to a cleaner design if
> this was instead provided as a callback to allocate and initialize a
> completely _new_ callback data. In that case the caller wouldn't have to
> first initialize the data only to copy it in a different place, all the
> logic would be self-contained in that one callback.

It's a very interesting proposal which I will certainly explore for
v2. Many thanks for this as well!

> The downside would of course be that we cannot use for example the
> stack, and it might make it harder to reuse state across multiple hook
> invocations, if that's a feature we care about.

I'm not worried about reusing the internal state (pp_task_cb) accross
multiple hook invocations, because that's why the "context" is passed
(pp_cb) to each hook cb. The internal state is never reused. 

Thanks Patrick, very thoughtful review as always.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 2/4] hook: introduce "git hook list"
  2026-02-09 14:28   ` Patrick Steinhardt
@ 2026-02-09 18:26     ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-09 18:26 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Wed, Feb 04, 2026 at 06:51:24PM +0200, Adrian Ratiu wrote:
>> From: Emily Shaffer <emilyshaffer@google.com>
>> 
>> If more than one hook will be run, it may be useful to see a list of
>> which hooks should be run. At very least, it will be useful for us to
>> test the semantics of multihooks ourselves.
>> 
>> For now, only list the hooks which will run in the order they will run
>> in; later, it might be useful to include more information like where the
>> hooks were configured and whether or not they will run.
>
> I think the commit message could be adapted a bit again to first explain
> the problem we're about to solve.

Ack, will fix in v2.

>> diff --git a/builtin/hook.c b/builtin/hook.c
>> index 7afec380d2..4cc6dac45a 100644
>> --- a/builtin/hook.c
>> +++ b/builtin/hook.c
>> @@ -20,6 +24,54 @@ static const char * const builtin_hook_run_usage[] = {
>>  	NULL
>>  };
>>  
>> +static const char *const builtin_hook_list_usage[] = {
>> +	BUILTIN_HOOK_LIST_USAGE,
>> +	NULL
>> +};
>> +
>
> This constant can be declared inside `list()`.

Ack, will fix in v2.

>> +static int list(int argc, const char **argv, const char *prefix,
>> +		 struct repository *repo UNUSED)
>> +{
>> +	struct string_list *head;
>> +	struct string_list_item *item;
>> +	const char *hookname = NULL;
>> +	int ret = 0;
>> +
>> +	struct option list_options[] = {
>> +		OPT_END(),
>> +	};
>> +
>> +	argc = parse_options(argc, argv, prefix, list_options,
>> +			     builtin_hook_list_usage, 0);
>> +
>> +	/*
>> +	 * The only unnamed argument provided should be the hook-name; if we add
>> +	 * arguments later they probably should be caught by parse_options.
>> +	 */
>> +	if (argc != 1)
>> +		usage_msg_opt(_("You must specify a hook event name to list."),
>> +			      builtin_hook_list_usage, list_options);
>> +
>> +	hookname = argv[0];
>> +
>> +	head = list_hooks(the_repository, hookname);
>
> We can use the `repo` parameter instead. The git-hook(1) command is
> declared with `RUN_SETUP`, so it will always be set.

Indeed, it should be possible to avoid using the_repository here. Will
do for v2 or document why it can't be done.

>> +	if (!head->nr) {
>> +		ret = 1; /* no hooks found */
>> +		goto cleanup;
>> +	}
>
> Do we want to print an error message in this case?

Good idea. Will do.

>> +	for_each_string_list_item(item, head) {
>> +		printf("%s\n", *item->string ? item->string
>> +			     : _("hook from hookdir"));
>> +	}
>
> This is another case where we could avoid special-casing if the string
> list contained the hook paths.

Yes, I'll very likely do this in v2, certainly I will replace the
reliance on the empty hook name.

>
> I also wonder whether we should add a "-z" mode to NUL-terminate the
> output. In theory, hooks may be configured with a newline in their path.
> Probably not all that common, but somehow special cases like this always
> end up being encountered eventually.

I can do this, certainly.

>> diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
>> index ed28a2fadb..d2d4a8760c 100755
>> --- a/t/t1800-hook.sh
>> +++ b/t/t1800-hook.sh
>> @@ -10,6 +10,8 @@ test_expect_success 'git hook usage' '
>>  	test_expect_code 129 git hook run &&
>>  	test_expect_code 129 git hook run -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 &&
>>  	grep "unknown option" err
>>  '
>
> Shouldn't we also have some tests that show that this is working as
> expected with a configured hook in ".git/hooks"?

Good idea. Will do.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 3/4] hook: include hooks from the config
  2026-02-09 14:28   ` Patrick Steinhardt
@ 2026-02-09 19:10     ` Adrian Ratiu
  2026-02-10 13:43       ` Patrick Steinhardt
  0 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-09 19:10 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Wed, Feb 04, 2026 at 06:51:25PM +0200, Adrian Ratiu wrote:
>> 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].
>
> I'm not exactly sure how you'd execute it on multiple events after
> reading this description. Is this by comma-delimiting them? Or is the
> event key a multivalue variable? If the latter (which I think would make
> most sense), can you also reset the value by e.g. saying "event = " like
> we support with other multivalued keys?

Yes, event is a multivalue variable, however the current implementation
does not allow resetting using "event = ".

I'll allow it in v2 and also make the doc clearer.

> I would have also expected a "hook.<name>.enabled" option so that you
> can disable globally configured hooks.
>

Good idea. I can add that in v2.

<snip>

>> +git config hook.<some-name>.command <path-to-script>
>> +git config --add hook.<some-name>.event <hook-event>
>
> Let's use the modern equivalents:
>
>     git config set hook.<some-name>.command <path-to-script>
>     git config set --append hook.<some-name>.event <hook-event>
>

Ack, will do in v2.

<snip>

>> @@ -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
>
> Ah, interesting. Would that then also look for
> ".git/hooks/mywrapper-start-tests"?

Yes, because list_hooks() gathers all hooks for the event, including the
"legacy/default" hooks from the hookdir. 

I also just noticed this doc mentions parallel execution, which is not
yet possible (that is a separate series). I fix this and add the parallel
comment back in the other series.

>> 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
>> +{
>
> Formatting: the curly brace should not go on a standalone line.

Ack, will fix.

<snip>

>> +	/* 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;
>
> So we only care about parsing "hook.<name>.event" here, and I assume
> that we would look up the command at a later point in time? That design
> is somewhat weird -- I would have expected that we scan through all
> config keys and already pull out both the command and event so that we
> don't have to extract the config a second time.
>
> But I guess this is another consequence of the string list only caring
> about the name, not about the actual path.

That is correct.

I do plan to change this design in v2 and we might as well also look up
the path at the same time.

I see nothing preventing us from doing it.

>> +	/* 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);
>
> There are cases where we invoke the same hook multiple times per
> process. One such candidate is for example the "reference-transaction"
> hook, which is executed thrice per ref transaction. Would it make sense
> if we cached the value in `r` so that we can avoid repeatedly parsing
> the configuration for this hook?
>
> We could even go one step further: instead of caching the value for
> _one_ hook, we could parse _all_ hooks lazily and store them in a map.

I think this can be done, yes, though I'm unsure of the performance gain
vs the added complexity. Since after the first lookup the config files
are hot in the OS page cache, the next lookups are cheap/fast and we'd
be shaving off only a few microseconds of parsing.

So I'm unsure if this added complexity is worth it or not.

>> @@ -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)
>
> Okay, so here we do an extra step to extract the commands. I think this
> would fit more naturally in `hook_config_lookup`.

Agreed, especially after I implement all other feedback to not rely on
the "empty" name to distinguish hooks, I think these can be consolidated
in one place.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 3/4] hook: include hooks from the config
  2026-02-09 19:10     ` Adrian Ratiu
@ 2026-02-10 13:43       ` Patrick Steinhardt
  2026-02-10 13:56         ` Adrian Ratiu
  0 siblings, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-10 13:43 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Mon, Feb 09, 2026 at 09:10:25PM +0200, Adrian Ratiu wrote:
> On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> > On Wed, Feb 04, 2026 at 06:51:25PM +0200, Adrian Ratiu wrote:
> >> +	/* 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);
> >
> > There are cases where we invoke the same hook multiple times per
> > process. One such candidate is for example the "reference-transaction"
> > hook, which is executed thrice per ref transaction. Would it make sense
> > if we cached the value in `r` so that we can avoid repeatedly parsing
> > the configuration for this hook?
> >
> > We could even go one step further: instead of caching the value for
> > _one_ hook, we could parse _all_ hooks lazily and store them in a map.
> 
> I think this can be done, yes, though I'm unsure of the performance gain
> vs the added complexity. Since after the first lookup the config files
> are hot in the OS page cache, the next lookups are cheap/fast and we'd
> be shaving off only a few microseconds of parsing.
> 
> So I'm unsure if this added complexity is worth it or not.

Maybe I'm naive, but I don't really expect there to be much complexity.
Quite on the contrary: if we unify all configuration parsing into
`hook_config_lookup` I would even claim that this cache would almost
come as a side effect. We would simply iterate through all hook-related
configuration and build the map of all hooks as we go.

Whether it'd matter performance-wise... I think in certain cases it
actually would. The mentioned reference-transaction hook for example had
some worst cases in the past where it would execute thrice per reference
update in certain code paths. In the worst cases I've seen it execute
tens of thousands of times in a single process.

Sure, these are outliers. But such cases do exist, and I expect that the
performance impact could be non-negligible here if combined with
non-trivial configuration.

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 1/4] hook: run a list of hooks
  2026-02-09 18:16     ` Adrian Ratiu
@ 2026-02-10 13:43       ` Patrick Steinhardt
  0 siblings, 0 replies; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-10 13:43 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Mon, Feb 09, 2026 at 08:16:35PM +0200, Adrian Ratiu wrote:
> On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> > On Wed, Feb 04, 2026 at 06:51:23PM +0200, Adrian Ratiu wrote:
> >> diff --git a/hook.c b/hook.c
> >> index cde7198412..fb90f91f3b 100644
> >> --- a/hook.c
> >> +++ b/hook.c
> >> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)
> >>  	return path.buf;
> >>  }
> >>  
> >> +/*
> >> + * Provides a list of hook commands to run for the 'hookname' event.
> >> + *
> >> + * This function consolidates hooks from two sources:
> >> + * 1. The config-based hooks (not yet implemented).
> >> + * 2. The "traditional" hook found in the repository hooks directory
> >> + *    (e.g., .git/hooks/pre-commit).
> >> + *
> >> + * The list is ordered by execution priority.
> >> + *
> >> + * The caller is responsible for freeing the memory of the returned list
> >> + * using string_list_clear() and free().
> >> + */
> >> +static struct string_list *list_hooks(struct repository *r, const char *hookname)
> >> +{
> >> +	struct string_list *hook_head;
> >> +
> >> +	if (!hookname)
> >> +		BUG("null hookname was provided to hook_list()!");
> >> +
> >> +	hook_head = xmalloc(sizeof(struct string_list));
> >> +	string_list_init_dup(hook_head);
> >> +
> >> +	/*
> >> +	 * 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, "");
> >
> > Why is there a check for `r->gitdir` here? Do we ever execute hooks
> > outside of a fully-initialized repository?
> 
> Nice find. The answer is yes, see the last commit in this series.
> 
> I've been cleaning up and untangling this series for quite a while now:
> - It used to be combined with the other parallel hooks series.
> - It used to implement its own linked list abstraction.
> - It used the hook.h string-list APIs AEvar didn't like.
> - and so on.
> 
> This cheeck is just a leftover bit I missed during my cleanups and I
> really should be moved this to the last commit. I've been moving a lot
> of code around to make it as clear as possible.
> 
> I'll do it / test in v2. Many thanks!

That should indeed lead to less puzzlement, great!

> > Other than that, we now insert hooks into the list. It's somewhat
> > surprising that we insert hook "names" here, instead of for example
> > adding the full hook path to the list. Is there any specific reason for
> > this decision?
> 
> I'm also not a fan of this design, as I mentioned in the reply to Junio.
> 
> We just need to differentiate between "new" hooks (from config) and
> "default/legacy" hooks (from the hookdir) and we kind-of abused the fact
> that the default hooks have no friendly name, so the name is empty, to
> differentiate them. :)
> 
> Junio suggested we use a struct/union and a specific type to
> differentiate between the "default/legacy" hooks (from the hookdir) and
> the new config-based hooks.

Yeah, that sounds like a reasonable thing to do.

> That way we don't have to rely on the name anymore and we can also use
> the full path here, as you suggested.
> 
> I'll do all this in v2. Thank you!

Perfect, thanks!

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH 3/4] hook: include hooks from the config
  2026-02-10 13:43       ` Patrick Steinhardt
@ 2026-02-10 13:56         ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-10 13:56 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Tue, 10 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Mon, Feb 09, 2026 at 09:10:25PM +0200, Adrian Ratiu wrote:
>> On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
>> > On Wed, Feb 04, 2026 at 06:51:25PM +0200, Adrian Ratiu wrote:
>> >> +	/* 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);
>> >
>> > There are cases where we invoke the same hook multiple times per
>> > process. One such candidate is for example the "reference-transaction"
>> > hook, which is executed thrice per ref transaction. Would it make sense
>> > if we cached the value in `r` so that we can avoid repeatedly parsing
>> > the configuration for this hook?
>> >
>> > We could even go one step further: instead of caching the value for
>> > _one_ hook, we could parse _all_ hooks lazily and store them in a map.
>> 
>> I think this can be done, yes, though I'm unsure of the performance gain
>> vs the added complexity. Since after the first lookup the config files
>> are hot in the OS page cache, the next lookups are cheap/fast and we'd
>> be shaving off only a few microseconds of parsing.
>> 
>> So I'm unsure if this added complexity is worth it or not.
>
> Maybe I'm naive, but I don't really expect there to be much complexity.
> Quite on the contrary: if we unify all configuration parsing into
> `hook_config_lookup` I would even claim that this cache would almost
> come as a side effect. We would simply iterate through all hook-related
> configuration and build the map of all hooks as we go.
>
> Whether it'd matter performance-wise... I think in certain cases it
> actually would. The mentioned reference-transaction hook for example had
> some worst cases in the past where it would execute thrice per reference
> update in certain code paths. In the worst cases I've seen it execute
> tens of thousands of times in a single process.
>
> Sure, these are outliers. But such cases do exist, and I expect that the
> performance impact could be non-negligible here if combined with
> non-trivial configuration.

Ok, I'm convinced. :) Will do it in v2.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* [PATCH v2 0/8] Specify hooks via configs
  2026-02-04 16:51 [PATCH 0/4] Specify hooks via configs Adrian Ratiu
                   ` (4 preceding siblings ...)
  2026-02-06 16:26 ` [PATCH 0/4] Specify hooks via configs Junio C Hamano
@ 2026-02-18 22:23 ` Adrian Ratiu
  2026-02-18 22:23   ` [PATCH v2 1/8] hook: add internal state alloc/free callbacks Adrian Ratiu
                     ` (9 more replies)
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
  6 siblings, 10 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Hello everyone,

v2 addresses all feedback received in v1.

This series adds a new feature: the ability to specify commands to run
for hook events via config entries (including shell commands).

So instead of dropping a shell script or a custom program in .git/hooks
you can now tell git via config files to run a program or shell script
(can be specified directly in the config) when you run hook "foo".

This also means you can setup global hooks to run in multiple repos via
global configs and there's an option to disable them if necessary.

For simplicity, because this series is becoming rather big, hooks are
still executed sequentially (.jobs == 1). Parallel execution is added
in another patch series.

This is based on the latest v8 hooks-conversion series [1] which has
not yet landed in next or master.

Branch pused to GitHub: [2]
Succesful CI run: [3]

Many thanks to all who contributed to this effort up to now, including
Emily, AEvar, Junio, Patrick, Peff, Kristoffer, Chris and many others.

Thank you,
Adrian

1: https://lore.kernel.org/git/20250925125352.1728840-1-adrian.ratiu@collabora.com/T/#m41f793907f46fd04f44ff1b06c53d20af38e6cb2
2: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/config-hooks-v2
3: https://github.com/10ne1/git/actions/runs/22158690260

Changes in v2:
* Introduce a "struct hook" with typing information & helper fun types (Junio)
* Detect hook config friendly-name and command in the same place (Junio, Patrick)
* Simplified run_hooks_opt() to use list index instead of item pointers (Patrick)
* Simplified pick_next_hook() to only contain next pp child setup logic (Patrick)
* Reworked git hook list to not rely on empty strings to distinguish hooks (Junio)
* Added a hook config cache to avoid re-reading cfg on each hook call (Patrick)
* New commit: Split internal state alloc/free callbacks into own commit (Patrick)
* New commit: Add hook.<name>.enabled (Patrick)
* New commit: Add -z option to git hook list (Patrick)
* New commit: "event = " config entries reset the value  (Patrick)
* Split default hook detection into its own helper from list_hooks (Adrian)
* Moved hook internal state alloc/free where struct hook is defined (Patrick)
* Changed semantics of e copy/free cb API to avoid copying (alloc/free) (Patrick)
* Replace the empty "friendly-name" for the legacy hooks with the path (Patrick)
* Dropped the notify_start_failure() msg because it's not worth the complexity
  and reverted back to the previous hook cb call convention (Patrick)
* Fixed codepaths to avoid using the_repository in favor of repo arg (Patrick)
* Fixed a very small conflict with upstream master branch due to the
  addition of the new "history" command (Adrian)
* Removed the extra repository pointer added to hook.h (Adrian)
* Added a more tests exercising git hook list/run (Patrick)
* Moved r->gitdir check to commit which allows out-of-repo invocations (Patrick)
* Reworded commit and error messages for better clarity, added warnings (Patrick)
* Reordered commits for better reading (Adrian)
* Small documentation improvements and code style fixes (Patrick)

Range-diff v1 -> v2:
1:  3b9816d835 < -:  ---------- hook: run a list of hooks
-:  ---------- > 1:  6fe0e2eea4 hook: add internal state alloc/free callbacks
-:  ---------- > 2:  2917d45a19 hook: run a list of hooks to prepare for multihook support
2:  fbc84e68ef ! 3:  19d41e85e1 hook: introduce "git hook list"
    @@ Metadata
     Author: Emily Shaffer <nasamuffin@google.com>
     
      ## Commit message ##
    -    hook: introduce "git hook list"
    +    hook: add "git hook list" command
     
    -    If more than one hook will be run, it may be useful to see a list of
    -    which hooks should be run. At very least, it will be useful for us to
    -    test the semantics of multihooks ourselves.
    +    The previous commit introduced an ability to run multiple commands for
    +    hook events and next commit will introduce the ability to define hooks
    +    from configs, in addition to the "traditional" hooks from the hookdir.
     
    -    For now, only list the hooks which will run in the order they will run
    -    in; later, it might be useful to include more information like where the
    -    hooks were configured and whether or not they will run.
    +    Introduce a new command "git hook list" to make inspecting hooks easier
    +    both for users and for the tests we will add.
    +
    +    Further commits will expand on this, e.g. by adding a -z output mode.
     
         Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
    @@ Documentation/git-hook.adoc: Any positional arguments to the hook should be pass
      
     +list::
     +	Print a list of hooks which will be run on `<hook-name>` event. If no
    -+	hooks are configured for that event, print nothing and return 1.
    ++	hooks are configured for that event, print a warning and return 1.
     +
      OPTIONS
      -------
    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {
      	NULL
      };
      
    -+static const char *const builtin_hook_list_usage[] = {
    -+	BUILTIN_HOOK_LIST_USAGE,
    -+	NULL
    -+};
    -+
     +static int list(int argc, const char **argv, const char *prefix,
    -+		 struct repository *repo UNUSED)
    ++		 struct repository *repo)
     +{
    ++	static const char *const builtin_hook_list_usage[] = {
    ++		BUILTIN_HOOK_LIST_USAGE,
    ++		NULL
    ++	};
     +	struct string_list *head;
     +	struct string_list_item *item;
     +	const char *hookname = NULL;
    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {
     +
     +	hookname = argv[0];
     +
    -+	head = list_hooks(the_repository, hookname);
    ++	head = list_hooks(repo, hookname, NULL);
     +
     +	if (!head->nr) {
    ++		warning(_("No hooks found for event '%s'"), hookname);
     +		ret = 1; /* no hooks found */
     +		goto cleanup;
     +	}
     +
     +	for_each_string_list_item(item, head) {
    -+		printf("%s\n", *item->string ? item->string
    -+			     : _("hook from hookdir"));
    ++		struct hook *h = item->util;
    ++
    ++		switch (h->kind) {
    ++		case HOOK_TRADITIONAL:
    ++			printf("%s\n", _("hook from hookdir"));
    ++			break;
    ++		default:
    ++			BUG("unknown hook kind");
    ++		}
     +	}
     +
     +cleanup:
    -+	string_list_clear(head, 1);
    ++	hook_list_clear(head, NULL);
     +	free(head);
     +	return ret;
     +}
    @@ builtin/hook.c: int cmd_hook(int argc,
      
     
      ## hook.c ##
    -@@ hook.c: const char *find_hook(struct repository *r, const char *name)
    - 	return path.buf;
    +@@ hook.c: static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
    + 	free(h);
    + }
    + 
    +-static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
    ++void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
    + {
    + 	struct string_list_item *item;
    + 
    +@@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,
    + 	string_list_append(hook_list, hook_path)->util = h;
      }
      
     -/*
    @@ hook.c: const char *find_hook(struct repository *r, const char *name)
     - * The caller is responsible for freeing the memory of the returned list
     - * using string_list_clear() and free().
     - */
    --static struct string_list *list_hooks(struct repository *r, const char *hookname)
    -+struct string_list *list_hooks(struct repository *r, const char *hookname)
    +-static struct string_list *list_hooks(struct repository *r, const char *hookname,
    ++struct string_list *list_hooks(struct repository *r, const char *hookname,
    + 			       struct run_hooks_opt *options)
      {
      	struct string_list *hook_head;
    - 
     
      ## hook.h ##
     @@ hook.h: struct hook_cb_data {
    - 	struct repository *repository;
    + 	struct run_hooks_opt *options;
      };
      
     -/*
    @@ hook.h: struct hook_cb_data {
     + * The caller is responsible for freeing the memory of the returned list
     + * using string_list_clear() and free().
     + */
    -+struct string_list *list_hooks(struct repository *r, const char *hookname);
    ++struct string_list *list_hooks(struct repository *r, const char *hookname,
    ++			       struct run_hooks_opt *options);
    ++
    ++/**
    ++ * Frees the memory allocated for the hook list, including the `struct hook`
    ++ * items and their internal state.
    ++ */
    ++void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);
     +
     +/**
       * Returns the path to the hook file, or NULL if the hook is missing
    @@ t/t1800-hook.sh: test_expect_success 'git hook usage' '
      	grep "unknown option" err
      '
      
    ++test_expect_success 'git hook list: nonexistent hook' '
    ++	cat >stderr.expect <<-\EOF &&
    ++	warning: No hooks found for event '\''test-hook'\''
    ++	EOF
    ++	test_expect_code 1 git hook list test-hook 2>stderr.actual &&
    ++	test_cmp stderr.expect stderr.actual
    ++'
    ++
    ++test_expect_success 'git hook list: traditional hook from hookdir' '
    ++	test_hook test-hook <<-EOF &&
    ++	echo Test hook
    ++	EOF
    ++
    ++	cat >expect <<-\EOF &&
    ++	hook from hookdir
    ++	EOF
    ++	git hook list test-hook >actual &&
    ++	test_cmp expect actual
    ++'
    ++
    + test_expect_success 'git hook run: nonexistent hook' '
    + 	cat >stderr.expect <<-\EOF &&
    + 	error: cannot find a hook named test-hook
3:  d6b3fbf6b1 ! 4:  0c98b18bf5 hook: include hooks from the config
    @@
      ## Metadata ##
    -Author: Emily Shaffer <nasamuffin@google.com>
    +Author: Adrian Ratiu <adrian.ratiu@collabora.com>
     
      ## Commit message ##
         hook: include hooks from the config
    @@ Commit message
         lines and will be run sequentially (.jobs == 1) like before.
         Running the hooks in parallel will be enabled in a future patch.
     
    +    The "traditional" hook from the hookdir is run last, if present.
    +
    +    A strmap cache is added to struct repository to avoid re-reading
    +    the configs on each rook run. This is useful for hooks like the
    +    ref-transaction which gets executed multiple times per process.
    +
         Examples:
     
           $ git config --get-regexp "^hook\."
    @@ Commit message
      ## Documentation/config/hook.adoc (new) ##
     @@
     +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].
    ++	The command to execute for `hook.<name>`. `<name>` is a unique
    ++	"friendly" name that identifies this hook. (The hook events that
    ++	trigger the command are configured with `hook.<name>.event`.) The
    ++	value can be an executable path or a shell oneliner. If more than
    ++	one value is specified for the same `<name>`, only the last value
    ++	parsed is used. 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].
    ++	The hook events that trigger `hook.<name>`. The value is the name
    ++	of a hook event, like "pre-commit" or "update". (See
    ++	linkgit:githooks[5] for a complete list of hook events.) On the
    ++	specified event, the associated `hook.<name>.command` is executed.
    ++	This is a multi-valued key. To run `hook.<name>` on multiple
    ++	events, specify the key more than once. See linkgit:git-hook[1].
     
      ## Documentation/git-hook.adoc ##
     @@ Documentation/git-hook.adoc: DESCRIPTION
    @@ Documentation/git-hook.adoc: DESCRIPTION
     +`.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>
    ++git config set hook.<some-name>.command <path-to-script>
    ++git config set --append 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
    ++git config set hook.my-script.command ~/my-script.sh
    ++git config set --append hook.my-script.event pre-commit
     +----
     +
      SUBCOMMANDS
    @@ Documentation/git-hook.adoc: DESCRIPTION
     -	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.
    ++	discovered during the config parse. The default `<hook-name>` from
    ++	the hookdir is run last. See linkgit:githooks[5] for supported
    ++	hook names.
      +
      
      Any positional arguments to the hook should be passed after a
    @@ Documentation/git-hook.adoc: OPTIONS
      --------
      linkgit:githooks[5]
     
    + ## builtin/hook.c ##
    +@@ builtin/hook.c: static int list(int argc, const char **argv, const char *prefix,
    + 		case HOOK_TRADITIONAL:
    + 			printf("%s\n", _("hook from hookdir"));
    + 			break;
    ++		case HOOK_CONFIGURED:
    ++			printf("%s\n", h->u.configured.friendly_name);
    ++			break;
    + 		default:
    + 			BUG("unknown hook kind");
    + 		}
    +
      ## hook.c ##
    -@@ hook.c: const char *find_hook(struct repository *r, const char *name)
    - 	return path.buf;
    +@@
    + #include "gettext.h"
    + #include "hook.h"
    + #include "path.h"
    ++#include "parse.h"
    + #include "run-command.h"
    + #include "config.h"
    + #include "strbuf.h"
    ++#include "strmap.h"
    + #include "environment.h"
    + #include "setup.h"
    + 
    +@@ hook.c: static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
    + 
    + 	if (h->kind == HOOK_TRADITIONAL)
    + 		free((void *)h->u.traditional.path);
    ++	else if (h->kind == HOOK_CONFIGURED) {
    ++		free((void *)h->u.configured.friendly_name);
    ++		free((void *)h->u.configured.command);
    ++	}
    + 
    + 	if (cb_data_free)
    + 		cb_data_free(h->feed_pipe_cb_data);
    +@@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,
    + 	string_list_append(hook_list, hook_path)->util = h;
      }
      
    -+struct hook_config_cb
    ++static void unsorted_string_list_remove(struct string_list *list,
    ++					const char *str)
     +{
    -+	const char *hook_event;
    -+	struct string_list *list;
    -+};
    ++	struct string_list_item *item = unsorted_string_list_lookup(list, str);
    ++	if (item)
    ++		unsorted_string_list_delete_item(list, item - list->items, 0);
    ++}
     +
     +/*
    -+ * 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>.
    ++ * Callback struct to collect all hook.* keys in a single config pass.
    ++ * commands: friendly-name to command map.
    ++ * event_hooks: event-name to list of friendly-names map.
    ++ * disabled_hooks: set of friendly-names with hook.name.enabled = false.
     + */
    -+static int hook_config_lookup(const char *key, const char *value,
    -+			      const struct config_context *ctx UNUSED,
    -+			      void *cb_data)
    ++struct hook_all_config_cb {
    ++	struct strmap commands;
    ++	struct strmap event_hooks;
    ++	struct string_list disabled_hooks;
    ++};
    ++
    ++/* repo_config() callback that collects all hook.* configuration in one pass. */
    ++static int hook_config_lookup_all(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;
    ++	struct hook_all_config_cb *data = cb_data;
    ++	const char *name, *subkey;
     +	char *hook_name;
    ++	size_t name_len = 0;
     +
    -+	/*
    -+	 * 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))
    ++	if (parse_config_key(key, "hook", &name, &name_len, &subkey))
     +		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;
    ++	if (!value)
    ++		return config_error_nonbool(key);
     +
    -+	/* Extract the hook name */
    ++	/* Extract name, ensuring it is null-terminated. */
     +	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);
    ++	if (!strcmp(subkey, "event")) {
    ++		struct string_list *hooks =
    ++			strmap_get(&data->event_hooks, value);
     +
    -+	/* The list takes ownership of hook_name, so append with nodup */
    -+	string_list_append_nodup(data->list, hook_name);
    ++		if (!hooks) {
    ++			hooks = xcalloc(1, sizeof(*hooks));
    ++			string_list_init_dup(hooks);
    ++			strmap_put(&data->event_hooks, value, hooks);
    ++		}
     +
    ++		/* Re-insert if necessary to preserve last-seen order. */
    ++		unsorted_string_list_remove(hooks, hook_name);
    ++		string_list_append(hooks, hook_name);
    ++	} else if (!strcmp(subkey, "command")) {
    ++		/* Store command overwriting the old value */
    ++		char *old = strmap_put(&data->commands, hook_name,
    ++				       xstrdup(value));
    ++		free(old);
    ++	}
    ++
    ++	free(hook_name);
     +	return 0;
     +}
     +
    - struct string_list *list_hooks(struct repository *r, const char *hookname)
    ++/*
    ++ * The hook config cache maps each hook event name to a string_list where
    ++ * every item's string is the hook's friendly-name and its util pointer is
    ++ * the corresponding command string. Both strings are owned by the map.
    ++ *
    ++ * Disabled hooks and hooks missing a command are already filtered out at
    ++ * parse time, so callers can iterate the list directly.
    ++ */
    ++void hook_cache_clear(struct strmap *cache)
    ++{
    ++	struct hashmap_iter iter;
    ++	struct strmap_entry *e;
    ++
    ++	strmap_for_each_entry(cache, &iter, e) {
    ++		struct string_list *hooks = e->value;
    ++		string_list_clear(hooks, 1); /* free util (command) pointers */
    ++		free(hooks);
    ++	}
    ++	strmap_clear(cache, 0);
    ++}
    ++
    ++/* Populate `cache` with the complete hook configuration */
    ++static void build_hook_config_map(struct repository *r, struct strmap *cache)
    ++{
    ++	struct hook_all_config_cb cb_data;
    ++	struct hashmap_iter iter;
    ++	struct strmap_entry *e;
    ++
    ++	strmap_init(&cb_data.commands);
    ++	strmap_init(&cb_data.event_hooks);
    ++	string_list_init_dup(&cb_data.disabled_hooks);
    ++
    ++	/* Parse all configs in one run. */
    ++	repo_config(r, hook_config_lookup_all, &cb_data);
    ++
    ++	/* Construct the cache from parsed configs. */
    ++	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
    ++		struct string_list *hook_names = e->value;
    ++		struct string_list *hooks = xcalloc(1, sizeof(*hooks));
    ++
    ++		string_list_init_dup(hooks);
    ++
    ++		for (size_t i = 0; i < hook_names->nr; i++) {
    ++			const char *hname = hook_names->items[i].string;
    ++			char *command;
    ++
    ++			command = strmap_get(&cb_data.commands, hname);
    ++			if (!command)
    ++				die(_("'hook.%s.command' must be configured or "
    ++				      "'hook.%s.event' must be removed;"
    ++				      " aborting."), hname, hname);
    ++
    ++			/* util stores the command; owned by the cache. */
    ++			string_list_append(hooks, hname)->util =
    ++				xstrdup(command);
    ++		}
    ++
    ++		strmap_put(cache, e->key, hooks);
    ++	}
    ++
    ++	strmap_clear(&cb_data.commands, 1);
    ++	string_list_clear(&cb_data.disabled_hooks, 0);
    ++	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
    ++		string_list_clear(e->value, 0);
    ++		free(e->value);
    ++	}
    ++	strmap_clear(&cb_data.event_hooks, 0);
    ++}
    ++
    ++/* Return the hook config map for `r`, populating it first if needed. */
    ++static struct strmap *get_hook_config_cache(struct repository *r)
    ++{
    ++	struct strmap *cache = NULL;
    ++
    ++	if (r) {
    ++		/*
    ++		 * For in-repo calls, the map is stored in r->hook_config_cache,
    ++		 * so repeated invocations don't parse the configs, so allocate
    ++		 * it just once on the first call.
    ++		 */
    ++		if (!r->hook_config_cache) {
    ++			r->hook_config_cache = xcalloc(1, sizeof(*cache));
    ++			strmap_init(r->hook_config_cache);
    ++			build_hook_config_map(r, r->hook_config_cache);
    ++		}
    ++		cache = r->hook_config_cache;
    ++	}
    ++
    ++	return cache;
    ++}
    ++
    ++static void list_hooks_add_configured(struct repository *r,
    ++				      const char *hookname,
    ++				      struct string_list *list,
    ++				      struct run_hooks_opt *options)
    ++{
    ++	struct strmap *cache = get_hook_config_cache(r);
    ++	struct string_list *configured_hooks = strmap_get(cache, hookname);
    ++
    ++	/* Iterate through configured hooks and initialize internal states */
    ++	for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {
    ++		const char *friendly_name = configured_hooks->items[i].string;
    ++		const char *command = configured_hooks->items[i].util;
    ++		struct hook *hook = xcalloc(1, sizeof(struct hook));
    ++
    ++		if (options && options->feed_pipe_cb_data_alloc)
    ++			hook->feed_pipe_cb_data =
    ++				options->feed_pipe_cb_data_alloc(
    ++					options->feed_pipe_ctx);
    ++
    ++		hook->kind = HOOK_CONFIGURED;
    ++		hook->u.configured.friendly_name = xstrdup(friendly_name);
    ++		hook->u.configured.command = xstrdup(command);
    ++
    ++		string_list_append(list, friendly_name)->util = hook;
    ++	}
    ++}
    ++
    + struct string_list *list_hooks(struct repository *r, const char *hookname,
    + 			       struct run_hooks_opt *options)
      {
    --	struct string_list *hook_head;
    -+	struct hook_config_cb cb_data;
    +@@ hook.c: struct string_list *list_hooks(struct repository *r, const char *hookname,
    + 	hook_head = xmalloc(sizeof(struct string_list));
    + 	string_list_init_dup(hook_head);
      
    - 	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;
    - }
    ++	/* Add hooks from the config, e.g. hook.myhook.event = pre-commit */
    ++	list_hooks_add_configured(r, hookname, hook_head, options);
    ++
    + 	/* Add the default "traditional" hooks from hookdir. */
    + 	list_hooks_add_default(r, hookname, hook_head, options);
      
    - int hook_exists(struct repository *r, const char *name)
     @@ hook.c: static int pick_next_hook(struct child_process *cp,
    - 			hook_path = absolute_path(hook_path);
    + 	cp->dir = hook_cb->options->dir;
      
    - 		strvec_push(&cp->args, hook_path);
    -+	} else {
    -+		/* ...from config */
    -+		struct strbuf cmd_key = STRBUF_INIT;
    -+		char *command = NULL;
    -+
    + 	/* Add hook exec paths or commands */
    +-	if (h->kind == HOOK_TRADITIONAL)
    ++	if (h->kind == HOOK_TRADITIONAL) {
    + 		strvec_push(&cp->args, h->u.traditional.path);
    ++	} else if (h->kind == HOOK_CONFIGURED) {
     +		/* to enable oneliners, let config-specified hooks run in shell. */
     +		cp->use_shell = true;
    ++		strvec_push(&cp->args, h->u.configured.command);
    ++	}
    + 
    + 	if (!cp->args.nr)
    + 		BUG("hook must have at least one command or exec path");
    +
    + ## hook.h ##
    +@@
    + #include "strvec.h"
    + #include "run-command.h"
    + #include "string-list.h"
    ++#include "strmap.h"
    + 
    + struct repository;
    + 
    +@@ hook.h: struct repository;
    +  * Represents a hook command to be run.
    +  * Hooks can be:
    +  * 1. "traditional" (found in the hooks directory)
    +- * 2. "configured" (defined in Git's configuration, not yet implemented).
    ++ * 2. "configured" (defined in Git's configuration via hook.<name>.event).
    +  * The 'kind' field determines which part of the union 'u' is valid.
    +  */
    + struct hook {
    + 	enum {
    + 		HOOK_TRADITIONAL,
    ++		HOOK_CONFIGURED,
    + 	} kind;
    + 	union {
    + 		struct {
    + 			const char *path;
    + 		} traditional;
    ++		struct {
    ++			const char *friendly_name;
    ++			const char *command;
    ++		} configured;
    + 	} u;
    + 
    + 	/**
    +@@ hook.h: struct string_list *list_hooks(struct repository *r, const char *hookname,
    +  */
    + void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);
    + 
    ++/**
    ++ * Frees the hook configuration cache stored in `struct repository`.
    ++ * Called by repo_clear().
    ++ */
    ++void hook_cache_clear(struct strmap *cache);
     +
    -+		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);
    + /**
    +  * Returns the path to the hook file, or NULL if the hook is missing
    +  * or disabled. Note that this points to static storage that will be
    +
    + ## repository.c ##
    +@@
    + #include "git-compat-util.h"
    + #include "abspath.h"
    + #include "repository.h"
    ++#include "hook.h"
    + #include "odb.h"
    + #include "config.h"
    + #include "object.h"
    +@@ repository.c: void repo_clear(struct repository *repo)
    + 		FREE_AND_NULL(repo->index);
      	}
      
    - 	if (!cp->args.nr)
    ++	if (repo->hook_config_cache) {
    ++		hook_cache_clear(repo->hook_config_cache);
    ++		FREE_AND_NULL(repo->hook_config_cache);
    ++	}
    ++
    + 	if (repo->promisor_remote_config) {
    + 		promisor_remote_clear(repo->promisor_remote_config);
    + 		FREE_AND_NULL(repo->promisor_remote_config);
    +
    + ## repository.h ##
    +@@ repository.h: struct repository {
    + 	/* True if commit-graph has been disabled within this process. */
    + 	int commit_graph_disabled;
    + 
    ++	/*
    ++	 * Lazily-populated cache mapping hook event names to configured hooks.
    ++	 * NULL until first hook use.
    ++	 */
    ++	struct strmap *hook_config_cache;
    ++
    + 	/* Configurations related to promisor remotes. */
    + 	char *repository_format_partial_clone;
    + 	struct promisor_remote_config *promisor_remote_config;
     
      ## t/t1800-hook.sh ##
     @@
    @@ t/t1800-hook.sh
      	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 &&
    +@@ t/t1800-hook.sh: test_expect_success 'git hook list: traditional hook from hookdir' '
    + 	test_cmp expect actual
    + '
    + 
    ++test_expect_success 'git hook list: configured hook' '
    ++	test_config hook.myhook.command "echo Hello" &&
    ++	test_config hook.myhook.event test-hook --add &&
    ++
    ++	echo "myhook" >expect &&
    ++	git hook list test-hook >actual &&
    ++	test_cmp expect actual
    ++'
    ++
    + test_expect_success 'git hook run: nonexistent hook' '
    + 	cat >stderr.expect <<-\EOF &&
    + 	error: cannot find a hook named test-hook
     @@ t/t1800-hook.sh: test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '
      	test_hook_tty commit -m"B.new"
      '
    @@ t/t1800-hook.sh: test_expect_success TTY 'git commit: stdout and stderr are conn
     +'
     +
     +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 &&
    ++	test_config hook.stdin-a.event "test-hook" &&
    ++	test_config hook.stdin-a.command "xargs -P1 -I% echo a%" &&
    ++	test_config hook.stdin-b.event "test-hook" &&
    ++	test_config hook.stdin-b.command "xargs -P1 -I% echo b%" &&
     +
     +	cat >input <<-\EOF &&
     +	1
    @@ t/t1800-hook.sh: test_expect_success TTY 'git commit: stdout and stderr are conn
     +
     +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_must_fail git hook list test-hook 2>actual &&
    ++	test_grep "hook.broken.command" actual &&
    ++	test_must_fail git hook run test-hook 2>actual &&
    ++	test_grep "hook.broken.command" actual
     +'
     +
      test_expect_success 'git hook run a hook with a bad shebang' '
4:  d85dd19ffe < -:  ---------- hook: allow out-of-repo 'git hook' invocations
-:  ---------- > 5:  f71ada4cb8 hook: allow disabling config hooks
-:  ---------- > 6:  82a7d6167f hook: allow event = "" to overwrite previous values
-:  ---------- > 7:  8d1704384e hook: allow out-of-repo 'git hook' invocations
-:  ---------- > 8:  7bf527c59e hook: add -z option to "git hook list"

Adrian Ratiu (5):
  hook: add internal state alloc/free callbacks
  hook: include hooks from the config
  hook: allow disabling config hooks
  hook: allow event = "" to overwrite previous values
  hook: add -z option to "git hook list"

Emily Shaffer (3):
  hook: run a list of hooks to prepare for multihook support
  hook: add "git hook list" command
  hook: allow out-of-repo 'git hook' invocations

 Documentation/config/hook.adoc |  24 +++
 Documentation/git-hook.adoc    | 137 +++++++++++-
 builtin/hook.c                 |  66 ++++++
 builtin/receive-pack.c         |  33 ++-
 git.c                          |   2 +-
 hook.c                         | 379 ++++++++++++++++++++++++++++++---
 hook.h                         | 104 ++++++++-
 refs.c                         |  24 ++-
 repository.c                   |   6 +
 repository.h                   |   6 +
 t/t1800-hook.sh                | 244 ++++++++++++++++++++-
 transport.c                    |  27 ++-
 12 files changed, 990 insertions(+), 62 deletions(-)
 create mode 100644 Documentation/config/hook.adoc

-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply	[flat|nested] 69+ messages in thread

* [PATCH v2 1/8] hook: add internal state alloc/free callbacks
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
@ 2026-02-18 22:23   ` Adrian Ratiu
  2026-02-19 21:47     ` Junio C Hamano
  2026-02-20 12:45     ` Patrick Steinhardt
  2026-02-18 22:23   ` [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support Adrian Ratiu
                     ` (8 subsequent siblings)
  9 siblings, 2 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Some hooks use opaque structs to keep internal state between callbacks.

Because hooks ran sequentially (jobs == 1) with one command per hook,
these internal states could be allocated on the stack for each hook run.

Next commits add the ability to run multiple commands for each hook, so
the states cannot be shared or stored on the stack anymore, especially
since down the line we will also enable parallel execution (jobs > 1).

Add alloc/free helpers for each hook, doing a "deep" alloc/init & free
of their internal opaque struct.

The alloc callback takes a context pointer, to initialize the struct at
at the time of resource acquisition.

These callbacks must always be provided together: no alloc without free
and no free without alloc, otherwise a BUG() is triggered.

Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 builtin/receive-pack.c | 33 ++++++++++++++++++++++++++-------
 hook.c                 | 13 +++++++++++++
 hook.h                 | 25 ++++++++++++++++++++++++-
 refs.c                 | 24 +++++++++++++++++++-----
 transport.c            | 27 ++++++++++++++++++++-------
 5 files changed, 102 insertions(+), 20 deletions(-)

diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
index 2d2b33d73d..f23772bc56 100644
--- a/builtin/receive-pack.c
+++ b/builtin/receive-pack.c
@@ -901,6 +901,26 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_
 	return state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */
 }
 
+static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)
+{
+	struct receive_hook_feed_state *init_state = feed_pipe_ctx;
+	struct receive_hook_feed_state *data = xcalloc(1, sizeof(*data));
+	data->report = init_state->report;
+	data->cmd = init_state->cmd;
+	data->skip_broken = init_state->skip_broken;
+	strbuf_init(&data->buf, 0);
+	return data;
+}
+
+static void receive_hook_feed_state_free(void *data)
+{
+	struct receive_hook_feed_state *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_receive_hook(struct command *commands,
 			    const char *hook_name,
 			    int skip_broken,
@@ -908,7 +928,7 @@ static int run_receive_hook(struct command *commands,
 {
 	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
 	struct command *iter = commands;
-	struct receive_hook_feed_state feed_state;
+	struct receive_hook_feed_state feed_init_state = { 0 };
 	struct async sideband_async;
 	int sideband_async_started = 0;
 	int saved_stderr = -1;
@@ -938,16 +958,15 @@ static int run_receive_hook(struct command *commands,
 	prepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started);
 
 	/* set up stdin callback */
-	feed_state.cmd = commands;
-	feed_state.skip_broken = skip_broken;
-	feed_state.report = NULL;
-	strbuf_init(&feed_state.buf, 0);
-	opt.feed_pipe_cb_data = &feed_state;
+	feed_init_state.cmd = commands;
+	feed_init_state.skip_broken = skip_broken;
+	opt.feed_pipe_ctx = &feed_init_state;
 	opt.feed_pipe = feed_receive_hook_cb;
+	opt.feed_pipe_cb_data_alloc = receive_hook_feed_state_alloc;
+	opt.feed_pipe_cb_data_free = receive_hook_feed_state_free;
 
 	ret = run_hooks_opt(the_repository, hook_name, &opt);
 
-	strbuf_release(&feed_state.buf);
 	finish_sideband_async(&sideband_async, saved_stderr, sideband_async_started);
 
 	return ret;
diff --git a/hook.c b/hook.c
index cde7198412..83ff658866 100644
--- a/hook.c
+++ b/hook.c
@@ -133,6 +133,8 @@ static int notify_hook_finished(int result,
 
 static void run_hooks_opt_clear(struct run_hooks_opt *options)
 {
+	if (options->feed_pipe_cb_data_free)
+		options->feed_pipe_cb_data_free(options->feed_pipe_cb_data);
 	strvec_clear(&options->env);
 	strvec_clear(&options->args);
 }
@@ -172,6 +174,17 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
 	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->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||
+	    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))
+		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");
+
+	if (options->feed_pipe_cb_data_alloc)
+		options->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
+
 	if (options->invoked_hook)
 		*options->invoked_hook = 0;
 
diff --git a/hook.h b/hook.h
index 20eb56fd63..a6bdc6f90f 100644
--- a/hook.h
+++ b/hook.h
@@ -5,6 +5,9 @@
 
 struct repository;
 
+typedef void (*cb_data_free_fn)(void *data);
+typedef void *(*cb_data_alloc_fn)(void *init_ctx);
+
 struct run_hooks_opt
 {
 	/* Environment vars to be set for each hook */
@@ -88,10 +91,30 @@ struct run_hooks_opt
 	 * It can be accessed directly via the third callback arg 'pp_task_cb':
 	 * struct ... *state = pp_task_cb;
 	 *
-	 * The caller is responsible for managing the memory for this data.
+	 * The caller is responsible for managing the memory for this data by
+	 * providing alloc/free callbacks to `run_hooks_opt`.
+	 *
 	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
 	 */
 	void *feed_pipe_cb_data;
+
+	/**
+	 * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,
+	 * so they can keep track of progress without affecting one another.
+	 *
+	 * If provided, this function will be called to alloc & initialize the
+	 * `feed_pipe_cb_data` for each hook.
+	 *
+	 * The `feed_pipe_ctx` pointer can be used to pass initialization data.
+	 */
+	cb_data_alloc_fn feed_pipe_cb_data_alloc;
+
+	/**
+	 * Called to free the memory initialized by `feed_pipe_cb_data_alloc`.
+	 *
+	 * Must always be provided when `feed_pipe_cb_data_alloc` is provided.
+	 */
+	cb_data_free_fn feed_pipe_cb_data_free;
 };
 
 #define RUN_HOOKS_OPT_INIT { \
diff --git a/refs.c b/refs.c
index d432cfb78f..3c124fab29 100644
--- a/refs.c
+++ b/refs.c
@@ -2514,24 +2514,38 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_
 	return 0; /* no more input to feed */
 }
 
+static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED)
+{
+	struct transaction_feed_cb_data *data = xmalloc(sizeof(*data));
+	strbuf_init(&data->buf, 0);
+	data->index = 0;
+	return data;
+}
+
+static void transaction_feed_cb_data_free(void *data)
+{
+	struct transaction_feed_cb_data *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_transaction_hook(struct ref_transaction *transaction,
 				const char *state)
 {
 	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
-	struct transaction_feed_cb_data feed_ctx = { 0 };
 	int ret = 0;
 
 	strvec_push(&opt.args, state);
 
 	opt.feed_pipe = transaction_hook_feed_stdin;
 	opt.feed_pipe_ctx = transaction;
-	opt.feed_pipe_cb_data = &feed_ctx;
-
-	strbuf_init(&feed_ctx.buf, 0);
+	opt.feed_pipe_cb_data_alloc = transaction_feed_cb_data_alloc;
+	opt.feed_pipe_cb_data_free = transaction_feed_cb_data_free;
 
 	ret = run_hooks_opt(transaction->ref_store->repo, "reference-transaction", &opt);
 
-	strbuf_release(&feed_ctx.buf);
 	return ret;
 }
 
diff --git a/transport.c b/transport.c
index ecf9e1f21c..1581aa0886 100644
--- a/transport.c
+++ b/transport.c
@@ -1357,21 +1357,36 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void
 	return 0;
 }
 
+static void *pre_push_hook_data_alloc(void *feed_pipe_ctx)
+{
+	struct feed_pre_push_hook_data *data = xmalloc(sizeof(*data));
+	strbuf_init(&data->buf, 0);
+	data->refs = (struct ref *)feed_pipe_ctx;
+	return data;
+}
+
+static void pre_push_hook_data_free(void *data)
+{
+	struct feed_pre_push_hook_data *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_pre_push_hook(struct transport *transport,
 			     struct ref *remote_refs)
 {
 	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
-	struct feed_pre_push_hook_data data;
 	int ret = 0;
 
 	strvec_push(&opt.args, transport->remote->name);
 	strvec_push(&opt.args, transport->url);
 
-	strbuf_init(&data.buf, 0);
-	data.refs = remote_refs;
-
 	opt.feed_pipe = pre_push_hook_feed_stdin;
-	opt.feed_pipe_cb_data = &data;
+	opt.feed_pipe_ctx = remote_refs;
+	opt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;
+	opt.feed_pipe_cb_data_free = pre_push_hook_data_free;
 
 	/*
 	 * pre-push hooks expect stdout & stderr to be separate, so don't merge
@@ -1381,8 +1396,6 @@ static int run_pre_push_hook(struct transport *transport,
 
 	ret = run_hooks_opt(the_repository, "pre-push", &opt);
 
-	strbuf_release(&data.buf);
-
 	return ret;
 }
 
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
  2026-02-18 22:23   ` [PATCH v2 1/8] hook: add internal state alloc/free callbacks Adrian Ratiu
@ 2026-02-18 22:23   ` Adrian Ratiu
  2026-02-20 12:46     ` Patrick Steinhardt
  2026-02-18 22:23   ` [PATCH v2 3/8] hook: add "git hook list" command Adrian Ratiu
                     ` (7 subsequent siblings)
  9 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

From: Emily Shaffer <emilyshaffer@google.com>

Hooks are limited to run one command (the default from the hookdir) for
each event. This limitation makes it impossible to run multiple commands
via config files, which the next commits will add.

Implement the ability to run a list of hooks in hook.[ch]. For now, the
list contains only one entry representing the "default" hook from the
hookdir, so there is no user-visible change in this commit.

All hook commands still run sequentially like before. A separate patch
series will enable running them in parallel.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 hook.c | 139 ++++++++++++++++++++++++++++++++++++++++++++-------------
 hook.h |  59 ++++++++++++++++++------
 2 files changed, 153 insertions(+), 45 deletions(-)

diff --git a/hook.c b/hook.c
index 83ff658866..c008a7232d 100644
--- a/hook.c
+++ b/hook.c
@@ -47,9 +47,97 @@ const char *find_hook(struct repository *r, const char *name)
 	return path.buf;
 }
 
+static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
+{
+	if (!h)
+		return;
+
+	if (h->kind == HOOK_TRADITIONAL)
+		free((void *)h->u.traditional.path);
+
+	if (cb_data_free)
+		cb_data_free(h->feed_pipe_cb_data);
+
+	free(h);
+}
+
+static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
+{
+	struct string_list_item *item;
+
+	for_each_string_list_item(item, hooks)
+		hook_clear(item->util, cb_data_free);
+
+	string_list_clear(hooks, 0);
+}
+
+/* Helper to detect and add default "traditional" hooks from the hookdir. */
+static void list_hooks_add_default(struct repository *r, const char *hookname,
+				   struct string_list *hook_list,
+				   struct run_hooks_opt *options)
+{
+	const char *hook_path = find_hook(r, hookname);
+	struct hook *h;
+
+	if (!hook_path)
+		return;
+
+	h = xcalloc(1, sizeof(struct hook));
+
+	/*
+	 * If the hook is to run in a specific dir, a relative path can
+	 * become invalid in that dir, so convert to an absolute path.
+	 */
+	if (options && options->dir)
+		hook_path = absolute_path(hook_path);
+
+	/* Setup per-hook internal state cb data */
+	if (options && options->feed_pipe_cb_data_alloc)
+		h->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
+
+	h->kind = HOOK_TRADITIONAL;
+	h->u.traditional.path = xstrdup(hook_path);
+
+	string_list_append(hook_list, hook_path)->util = h;
+}
+
+/*
+ * Provides a list of hook commands to run for the 'hookname' event.
+ *
+ * This function consolidates hooks from two sources:
+ * 1. The config-based hooks (not yet implemented).
+ * 2. The "traditional" hook found in the repository hooks directory
+ *    (e.g., .git/hooks/pre-commit).
+ *
+ * The list is ordered by execution priority.
+ *
+ * The caller is responsible for freeing the memory of the returned list
+ * using string_list_clear() and free().
+ */
+static struct string_list *list_hooks(struct repository *r, const char *hookname,
+			       struct run_hooks_opt *options)
+{
+	struct string_list *hook_head;
+
+	if (!hookname)
+		BUG("null hookname was provided to hook_list()!");
+
+	hook_head = xmalloc(sizeof(struct string_list));
+	string_list_init_dup(hook_head);
+
+	/* Add the default "traditional" hooks from hookdir. */
+	list_hooks_add_default(r, hookname, hook_head, options);
+
+	return hook_head;
+}
+
 int hook_exists(struct repository *r, const char *name)
 {
-	return !!find_hook(r, name);
+	struct string_list *hooks = list_hooks(r, name, NULL);
+	int exists = hooks->nr > 0;
+	hook_list_clear(hooks, NULL);
+	free(hooks);
+	return exists;
 }
 
 static int pick_next_hook(struct child_process *cp,
@@ -58,11 +146,14 @@ static int pick_next_hook(struct child_process *cp,
 			  void **pp_task_cb)
 {
 	struct hook_cb_data *hook_cb = pp_cb;
-	const char *hook_path = hook_cb->hook_path;
+	struct string_list *hook_list = hook_cb->hook_command_list;
+	struct hook *h;
 
-	if (!hook_path)
+	if (hook_cb->hook_to_run_index >= hook_list->nr)
 		return 0;
 
+	h = hook_list->items[hook_cb->hook_to_run_index++].util;
+
 	cp->no_stdin = 1;
 	strvec_pushv(&cp->env, hook_cb->options->env.v);
 
@@ -85,21 +176,20 @@ static int pick_next_hook(struct child_process *cp,
 	cp->trace2_hook_name = hook_cb->hook_name;
 	cp->dir = hook_cb->options->dir;
 
-	strvec_push(&cp->args, hook_path);
+	/* Add hook exec paths or commands */
+	if (h->kind == HOOK_TRADITIONAL)
+		strvec_push(&cp->args, h->u.traditional.path);
+
+	if (!cp->args.nr)
+		BUG("hook must have at least one command or exec path");
+
 	strvec_pushv(&cp->args, hook_cb->options->args.v);
 
 	/*
 	 * Provide per-hook internal state via task_cb for easy access, so
 	 * hook callbacks don't have to go through hook_cb->options.
 	 */
-	*pp_task_cb = hook_cb->options->feed_pipe_cb_data;
-
-	/*
-	 * This pick_next_hook() will be called again, we're only
-	 * running one hook, so indicate that no more work will be
-	 * done.
-	 */
-	hook_cb->hook_path = NULL;
+	*pp_task_cb = h->feed_pipe_cb_data;
 
 	return 1;
 }
@@ -133,8 +223,6 @@ static int notify_hook_finished(int result,
 
 static void run_hooks_opt_clear(struct run_hooks_opt *options)
 {
-	if (options->feed_pipe_cb_data_free)
-		options->feed_pipe_cb_data_free(options->feed_pipe_cb_data);
 	strvec_clear(&options->env);
 	strvec_clear(&options->args);
 }
@@ -142,13 +230,11 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)
 int run_hooks_opt(struct repository *r, const char *hook_name,
 		  struct run_hooks_opt *options)
 {
-	struct strbuf abs_path = STRBUF_INIT;
 	struct hook_cb_data cb_data = {
 		.rc = 0,
 		.hook_name = hook_name,
 		.options = options,
 	};
-	const char *const hook_path = find_hook(r, hook_name);
 	int ret = 0;
 	const struct run_process_parallel_opts opts = {
 		.tr2_category = "hook",
@@ -182,30 +268,21 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
 	    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))
 		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");
 
-	if (options->feed_pipe_cb_data_alloc)
-		options->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
-
 	if (options->invoked_hook)
 		*options->invoked_hook = 0;
 
-	if (!hook_path && !options->error_if_missing)
-		goto cleanup;
-
-	if (!hook_path) {
-		ret = error("cannot find a hook named %s", hook_name);
+	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);
 		goto cleanup;
 	}
 
-	cb_data.hook_path = hook_path;
-	if (options->dir) {
-		strbuf_add_absolute_path(&abs_path, hook_path);
-		cb_data.hook_path = abs_path.buf;
-	}
-
 	run_processes_parallel(&opts);
 	ret = cb_data.rc;
 cleanup:
-	strbuf_release(&abs_path);
+	hook_list_clear(cb_data.hook_command_list, options->feed_pipe_cb_data_free);
+	free(cb_data.hook_command_list);
 	run_hooks_opt_clear(options);
 	return ret;
 }
diff --git a/hook.h b/hook.h
index a6bdc6f90f..3256d2dddb 100644
--- a/hook.h
+++ b/hook.h
@@ -2,9 +2,41 @@
 #define HOOK_H
 #include "strvec.h"
 #include "run-command.h"
+#include "string-list.h"
 
 struct repository;
 
+/**
+ * Represents a hook command to be run.
+ * Hooks can be:
+ * 1. "traditional" (found in the hooks directory)
+ * 2. "configured" (defined in Git's configuration, not yet implemented).
+ * The 'kind' field determines which part of the union 'u' is valid.
+ */
+struct hook {
+	enum {
+		HOOK_TRADITIONAL,
+	} kind;
+	union {
+		struct {
+			const char *path;
+		} traditional;
+	} u;
+
+	/**
+	 * Opaque data pointer used to keep internal state across callback calls.
+	 *
+	 * It can be accessed directly via the third hook callback arg:
+	 * struct ... *state = pp_task_cb;
+	 *
+	 * The caller is responsible for managing the memory for this data by
+	 * providing alloc/free callbacks to `run_hooks_opt`.
+	 *
+	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
+	 */
+	void *feed_pipe_cb_data;
+};
+
 typedef void (*cb_data_free_fn)(void *data);
 typedef void *(*cb_data_alloc_fn)(void *init_ctx);
 
@@ -85,19 +117,6 @@ struct run_hooks_opt
 	 */
 	void *feed_pipe_ctx;
 
-	/**
-	 * Opaque data pointer used to keep internal state across callback calls.
-	 *
-	 * It can be accessed directly via the third callback arg 'pp_task_cb':
-	 * struct ... *state = pp_task_cb;
-	 *
-	 * The caller is responsible for managing the memory for this data by
-	 * providing alloc/free callbacks to `run_hooks_opt`.
-	 *
-	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
-	 */
-	void *feed_pipe_cb_data;
-
 	/**
 	 * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,
 	 * so they can keep track of progress without affecting one another.
@@ -128,7 +147,19 @@ struct hook_cb_data {
 	/* rc reflects the cumulative failure state */
 	int rc;
 	const char *hook_name;
-	const char *hook_path;
+
+	/**
+	 * A list of hook commands/paths to run for the 'hook_name' event.
+	 *
+	 * The 'string' member of each item holds the path (for traditional hooks)
+	 * or the unique friendly-name for hooks specified in configs.
+	 * The 'util' member of each item points to the corresponding struct hook.
+	 */
+	struct string_list *hook_command_list;
+
+	/* Iterator/cursor for the above list, pointing to the next hook to run. */
+	size_t hook_to_run_index;
+
 	struct run_hooks_opt *options;
 };
 
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v2 3/8] hook: add "git hook list" command
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
  2026-02-18 22:23   ` [PATCH v2 1/8] hook: add internal state alloc/free callbacks Adrian Ratiu
  2026-02-18 22:23   ` [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support Adrian Ratiu
@ 2026-02-18 22:23   ` Adrian Ratiu
  2026-02-20 12:46     ` Patrick Steinhardt
  2026-02-18 22:23   ` [PATCH v2 4/8] hook: include hooks from the config Adrian Ratiu
                     ` (6 subsequent siblings)
  9 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

From: Emily Shaffer <emilyshaffer@google.com>

The previous commit introduced an ability to run multiple commands for
hook events and next commit will introduce the ability to define hooks
from configs, in addition to the "traditional" hooks from the hookdir.

Introduce a new command "git hook list" to make inspecting hooks easier
both for users and for the tests we will add.

Further commits will expand on this, e.g. by adding a -z output mode.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/git-hook.adoc |  5 ++++
 builtin/hook.c              | 60 +++++++++++++++++++++++++++++++++++++
 hook.c                      | 17 ++---------
 hook.h                      | 24 ++++++++++++++-
 t/t1800-hook.sh             | 22 ++++++++++++++
 5 files changed, 112 insertions(+), 16 deletions(-)

diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index f6cc72d2ca..eb0ffcb8a9 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -9,6 +9,7 @@ SYNOPSIS
 --------
 [verse]
 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
+'git hook' list <hook-name>
 
 DESCRIPTION
 -----------
@@ -28,6 +29,10 @@ Any positional arguments to the hook should be passed after a
 mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See
 linkgit:githooks[5] for arguments hooks might expect (if any).
 
+list::
+	Print a list of hooks which will be run on `<hook-name>` event. If no
+	hooks are configured for that event, print a warning and return 1.
+
 OPTIONS
 -------
 
diff --git a/builtin/hook.c b/builtin/hook.c
index 7afec380d2..51660c4941 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -6,12 +6,16 @@
 #include "hook.h"
 #include "parse-options.h"
 #include "strvec.h"
+#include "abspath.h"
 
 #define BUILTIN_HOOK_RUN_USAGE \
 	N_("git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
+#define BUILTIN_HOOK_LIST_USAGE \
+	N_("git hook list <hook-name>")
 
 static const char * const builtin_hook_usage[] = {
 	BUILTIN_HOOK_RUN_USAGE,
+	BUILTIN_HOOK_LIST_USAGE,
 	NULL
 };
 
@@ -20,6 +24,61 @@ static const char * const builtin_hook_run_usage[] = {
 	NULL
 };
 
+static int list(int argc, const char **argv, const char *prefix,
+		 struct repository *repo)
+{
+	static const char *const builtin_hook_list_usage[] = {
+		BUILTIN_HOOK_LIST_USAGE,
+		NULL
+	};
+	struct string_list *head;
+	struct string_list_item *item;
+	const char *hookname = NULL;
+	int ret = 0;
+
+	struct option list_options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, list_options,
+			     builtin_hook_list_usage, 0);
+
+	/*
+	 * The only unnamed argument provided should be the hook-name; if we add
+	 * arguments later they probably should be caught by parse_options.
+	 */
+	if (argc != 1)
+		usage_msg_opt(_("You must specify a hook event name to list."),
+			      builtin_hook_list_usage, list_options);
+
+	hookname = argv[0];
+
+	head = list_hooks(repo, hookname, NULL);
+
+	if (!head->nr) {
+		warning(_("No hooks found for event '%s'"), hookname);
+		ret = 1; /* no hooks found */
+		goto cleanup;
+	}
+
+	for_each_string_list_item(item, head) {
+		struct hook *h = item->util;
+
+		switch (h->kind) {
+		case HOOK_TRADITIONAL:
+			printf("%s\n", _("hook from hookdir"));
+			break;
+		default:
+			BUG("unknown hook kind");
+		}
+	}
+
+cleanup:
+	hook_list_clear(head, NULL);
+	free(head);
+	return ret;
+}
+
 static int run(int argc, const char **argv, const char *prefix,
 	       struct repository *repo UNUSED)
 {
@@ -77,6 +136,7 @@ int cmd_hook(int argc,
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option builtin_hook_options[] = {
 		OPT_SUBCOMMAND("run", &fn, run),
+		OPT_SUBCOMMAND("list", &fn, list),
 		OPT_END(),
 	};
 
diff --git a/hook.c b/hook.c
index c008a7232d..979a97a538 100644
--- a/hook.c
+++ b/hook.c
@@ -61,7 +61,7 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
 	free(h);
 }
 
-static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
+void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
 {
 	struct string_list_item *item;
 
@@ -101,20 +101,7 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
 	string_list_append(hook_list, hook_path)->util = h;
 }
 
-/*
- * Provides a list of hook commands to run for the 'hookname' event.
- *
- * This function consolidates hooks from two sources:
- * 1. The config-based hooks (not yet implemented).
- * 2. The "traditional" hook found in the repository hooks directory
- *    (e.g., .git/hooks/pre-commit).
- *
- * The list is ordered by execution priority.
- *
- * The caller is responsible for freeing the memory of the returned list
- * using string_list_clear() and free().
- */
-static struct string_list *list_hooks(struct repository *r, const char *hookname,
+struct string_list *list_hooks(struct repository *r, const char *hookname,
 			       struct run_hooks_opt *options)
 {
 	struct string_list *hook_head;
diff --git a/hook.h b/hook.h
index 3256d2dddb..fea221f87d 100644
--- a/hook.h
+++ b/hook.h
@@ -163,7 +163,29 @@ struct hook_cb_data {
 	struct run_hooks_opt *options;
 };
 
-/*
+/**
+ * Provides a list of hook commands to run for the 'hookname' event.
+ *
+ * This function consolidates hooks from two sources:
+ * 1. The config-based hooks (not yet implemented).
+ * 2. The "traditional" hook found in the repository hooks directory
+ *    (e.g., .git/hooks/pre-commit).
+ *
+ * The list is ordered by execution priority.
+ *
+ * The caller is responsible for freeing the memory of the returned list
+ * using string_list_clear() and free().
+ */
+struct string_list *list_hooks(struct repository *r, const char *hookname,
+			       struct run_hooks_opt *options);
+
+/**
+ * Frees the memory allocated for the hook list, including the `struct hook`
+ * items and their internal state.
+ */
+void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);
+
+/**
  * Returns the path to the hook file, or NULL if the hook is missing
  * or disabled. Note that this points to static storage that will be
  * overwritten by further calls to find_hook and run_hook_*.
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index ed28a2fadb..3ec11f1249 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -10,9 +10,31 @@ test_expect_success 'git hook usage' '
 	test_expect_code 129 git hook run &&
 	test_expect_code 129 git hook run -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 &&
 	grep "unknown option" err
 '
 
+test_expect_success 'git hook list: nonexistent hook' '
+	cat >stderr.expect <<-\EOF &&
+	warning: No hooks found for event '\''test-hook'\''
+	EOF
+	test_expect_code 1 git hook list test-hook 2>stderr.actual &&
+	test_cmp stderr.expect stderr.actual
+'
+
+test_expect_success 'git hook list: traditional hook from hookdir' '
+	test_hook test-hook <<-EOF &&
+	echo Test hook
+	EOF
+
+	cat >expect <<-\EOF &&
+	hook from hookdir
+	EOF
+	git hook list test-hook >actual &&
+	test_cmp expect actual
+'
+
 test_expect_success 'git hook run: nonexistent hook' '
 	cat >stderr.expect <<-\EOF &&
 	error: cannot find a hook named test-hook
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v2 4/8] hook: include hooks from the config
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
                     ` (2 preceding siblings ...)
  2026-02-18 22:23   ` [PATCH v2 3/8] hook: add "git hook list" command Adrian Ratiu
@ 2026-02-18 22:23   ` Adrian Ratiu
  2026-02-19 22:16     ` Junio C Hamano
  2026-02-20 12:46     ` Patrick Steinhardt
  2026-02-18 22:23   ` [PATCH v2 5/8] hook: allow disabling config hooks Adrian Ratiu
                     ` (5 subsequent siblings)
  9 siblings, 2 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

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.

The "traditional" hook from the hookdir is run last, if present.

A strmap cache is added to struct repository to avoid re-reading
the configs on each rook run. This is useful for hooks like the
ref-transaction which gets executed multiple times per process.

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 |  15 +++
 Documentation/git-hook.adoc    | 128 ++++++++++++++++++++-
 builtin/hook.c                 |   3 +
 hook.c                         | 197 ++++++++++++++++++++++++++++++++-
 hook.h                         |  14 ++-
 repository.c                   |   6 +
 repository.h                   |   6 +
 t/t1800-hook.sh                | 149 ++++++++++++++++++++++++-
 8 files changed, 513 insertions(+), 5 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..9faafe3016
--- /dev/null
+++ b/Documentation/config/hook.adoc
@@ -0,0 +1,15 @@
+hook.<name>.command::
+	The command to execute for `hook.<name>`. `<name>` is a unique
+	"friendly" name that identifies this hook. (The hook events that
+	trigger the command are configured with `hook.<name>.event`.) The
+	value can be an executable path or a shell oneliner. If more than
+	one value is specified for the same `<name>`, only the last value
+	parsed is used. See linkgit:git-hook[1].
+
+hook.<name>.event::
+	The hook events that trigger `hook.<name>`. The value is the name
+	of a hook event, like "pre-commit" or "update". (See
+	linkgit:githooks[5] for a complete list of hook events.) On the
+	specified event, the associated `hook.<name>.command` is executed.
+	This is a multi-valued key. To run `hook.<name>` on multiple
+	events, specify the key more than once. See linkgit:git-hook[1].
diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index eb0ffcb8a9..7e4259e4f0 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -17,12 +17,96 @@ 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 set hook.<some-name>.command <path-to-script>
+git config set --append 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 set hook.my-script.command ~/my-script.sh
+git config set --append 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. The default `<hook-name>` from
+	the hookdir is run last. See linkgit:githooks[5] for supported
+	hook names.
 +
 
 Any positional arguments to the hook should be passed after a
@@ -46,6 +130,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/builtin/hook.c b/builtin/hook.c
index 51660c4941..e151bb2cd1 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -68,6 +68,9 @@ static int list(int argc, const char **argv, const char *prefix,
 		case HOOK_TRADITIONAL:
 			printf("%s\n", _("hook from hookdir"));
 			break;
+		case HOOK_CONFIGURED:
+			printf("%s\n", h->u.configured.friendly_name);
+			break;
 		default:
 			BUG("unknown hook kind");
 		}
diff --git a/hook.c b/hook.c
index 979a97a538..8a9b405f76 100644
--- a/hook.c
+++ b/hook.c
@@ -4,9 +4,11 @@
 #include "gettext.h"
 #include "hook.h"
 #include "path.h"
+#include "parse.h"
 #include "run-command.h"
 #include "config.h"
 #include "strbuf.h"
+#include "strmap.h"
 #include "environment.h"
 #include "setup.h"
 
@@ -54,6 +56,10 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
 
 	if (h->kind == HOOK_TRADITIONAL)
 		free((void *)h->u.traditional.path);
+	else if (h->kind == HOOK_CONFIGURED) {
+		free((void *)h->u.configured.friendly_name);
+		free((void *)h->u.configured.command);
+	}
 
 	if (cb_data_free)
 		cb_data_free(h->feed_pipe_cb_data);
@@ -101,6 +107,187 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
 	string_list_append(hook_list, hook_path)->util = h;
 }
 
+static void unsorted_string_list_remove(struct string_list *list,
+					const char *str)
+{
+	struct string_list_item *item = unsorted_string_list_lookup(list, str);
+	if (item)
+		unsorted_string_list_delete_item(list, item - list->items, 0);
+}
+
+/*
+ * Callback struct to collect all hook.* keys in a single config pass.
+ * commands: friendly-name to command map.
+ * event_hooks: event-name to list of friendly-names map.
+ * disabled_hooks: set of friendly-names with hook.name.enabled = false.
+ */
+struct hook_all_config_cb {
+	struct strmap commands;
+	struct strmap event_hooks;
+	struct string_list disabled_hooks;
+};
+
+/* repo_config() callback that collects all hook.* configuration in one pass. */
+static int hook_config_lookup_all(const char *key, const char *value,
+				  const struct config_context *ctx UNUSED,
+				  void *cb_data)
+{
+	struct hook_all_config_cb *data = cb_data;
+	const char *name, *subkey;
+	char *hook_name;
+	size_t name_len = 0;
+
+	if (parse_config_key(key, "hook", &name, &name_len, &subkey))
+		return 0;
+
+	if (!value)
+		return config_error_nonbool(key);
+
+	/* Extract name, ensuring it is null-terminated. */
+	hook_name = xmemdupz(name, name_len);
+
+	if (!strcmp(subkey, "event")) {
+		struct string_list *hooks =
+			strmap_get(&data->event_hooks, value);
+
+		if (!hooks) {
+			hooks = xcalloc(1, sizeof(*hooks));
+			string_list_init_dup(hooks);
+			strmap_put(&data->event_hooks, value, hooks);
+		}
+
+		/* Re-insert if necessary to preserve last-seen order. */
+		unsorted_string_list_remove(hooks, hook_name);
+		string_list_append(hooks, hook_name);
+	} else if (!strcmp(subkey, "command")) {
+		/* Store command overwriting the old value */
+		char *old = strmap_put(&data->commands, hook_name,
+				       xstrdup(value));
+		free(old);
+	}
+
+	free(hook_name);
+	return 0;
+}
+
+/*
+ * The hook config cache maps each hook event name to a string_list where
+ * every item's string is the hook's friendly-name and its util pointer is
+ * the corresponding command string. Both strings are owned by the map.
+ *
+ * Disabled hooks and hooks missing a command are already filtered out at
+ * parse time, so callers can iterate the list directly.
+ */
+void hook_cache_clear(struct strmap *cache)
+{
+	struct hashmap_iter iter;
+	struct strmap_entry *e;
+
+	strmap_for_each_entry(cache, &iter, e) {
+		struct string_list *hooks = e->value;
+		string_list_clear(hooks, 1); /* free util (command) pointers */
+		free(hooks);
+	}
+	strmap_clear(cache, 0);
+}
+
+/* Populate `cache` with the complete hook configuration */
+static void build_hook_config_map(struct repository *r, struct strmap *cache)
+{
+	struct hook_all_config_cb cb_data;
+	struct hashmap_iter iter;
+	struct strmap_entry *e;
+
+	strmap_init(&cb_data.commands);
+	strmap_init(&cb_data.event_hooks);
+	string_list_init_dup(&cb_data.disabled_hooks);
+
+	/* Parse all configs in one run. */
+	repo_config(r, hook_config_lookup_all, &cb_data);
+
+	/* Construct the cache from parsed configs. */
+	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
+		struct string_list *hook_names = e->value;
+		struct string_list *hooks = xcalloc(1, sizeof(*hooks));
+
+		string_list_init_dup(hooks);
+
+		for (size_t i = 0; i < hook_names->nr; i++) {
+			const char *hname = hook_names->items[i].string;
+			char *command;
+
+			command = strmap_get(&cb_data.commands, hname);
+			if (!command)
+				die(_("'hook.%s.command' must be configured or "
+				      "'hook.%s.event' must be removed;"
+				      " aborting."), hname, hname);
+
+			/* util stores the command; owned by the cache. */
+			string_list_append(hooks, hname)->util =
+				xstrdup(command);
+		}
+
+		strmap_put(cache, e->key, hooks);
+	}
+
+	strmap_clear(&cb_data.commands, 1);
+	string_list_clear(&cb_data.disabled_hooks, 0);
+	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
+		string_list_clear(e->value, 0);
+		free(e->value);
+	}
+	strmap_clear(&cb_data.event_hooks, 0);
+}
+
+/* Return the hook config map for `r`, populating it first if needed. */
+static struct strmap *get_hook_config_cache(struct repository *r)
+{
+	struct strmap *cache = NULL;
+
+	if (r) {
+		/*
+		 * For in-repo calls, the map is stored in r->hook_config_cache,
+		 * so repeated invocations don't parse the configs, so allocate
+		 * it just once on the first call.
+		 */
+		if (!r->hook_config_cache) {
+			r->hook_config_cache = xcalloc(1, sizeof(*cache));
+			strmap_init(r->hook_config_cache);
+			build_hook_config_map(r, r->hook_config_cache);
+		}
+		cache = r->hook_config_cache;
+	}
+
+	return cache;
+}
+
+static void list_hooks_add_configured(struct repository *r,
+				      const char *hookname,
+				      struct string_list *list,
+				      struct run_hooks_opt *options)
+{
+	struct strmap *cache = get_hook_config_cache(r);
+	struct string_list *configured_hooks = strmap_get(cache, hookname);
+
+	/* Iterate through configured hooks and initialize internal states */
+	for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {
+		const char *friendly_name = configured_hooks->items[i].string;
+		const char *command = configured_hooks->items[i].util;
+		struct hook *hook = xcalloc(1, sizeof(struct hook));
+
+		if (options && options->feed_pipe_cb_data_alloc)
+			hook->feed_pipe_cb_data =
+				options->feed_pipe_cb_data_alloc(
+					options->feed_pipe_ctx);
+
+		hook->kind = HOOK_CONFIGURED;
+		hook->u.configured.friendly_name = xstrdup(friendly_name);
+		hook->u.configured.command = xstrdup(command);
+
+		string_list_append(list, friendly_name)->util = hook;
+	}
+}
+
 struct string_list *list_hooks(struct repository *r, const char *hookname,
 			       struct run_hooks_opt *options)
 {
@@ -112,6 +299,9 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,
 	hook_head = xmalloc(sizeof(struct string_list));
 	string_list_init_dup(hook_head);
 
+	/* Add hooks from the config, e.g. hook.myhook.event = pre-commit */
+	list_hooks_add_configured(r, hookname, hook_head, options);
+
 	/* Add the default "traditional" hooks from hookdir. */
 	list_hooks_add_default(r, hookname, hook_head, options);
 
@@ -164,8 +354,13 @@ static int pick_next_hook(struct child_process *cp,
 	cp->dir = hook_cb->options->dir;
 
 	/* Add hook exec paths or commands */
-	if (h->kind == HOOK_TRADITIONAL)
+	if (h->kind == HOOK_TRADITIONAL) {
 		strvec_push(&cp->args, h->u.traditional.path);
+	} else if (h->kind == HOOK_CONFIGURED) {
+		/* to enable oneliners, let config-specified hooks run in shell. */
+		cp->use_shell = true;
+		strvec_push(&cp->args, h->u.configured.command);
+	}
 
 	if (!cp->args.nr)
 		BUG("hook must have at least one command or exec path");
diff --git a/hook.h b/hook.h
index fea221f87d..e949f5d488 100644
--- a/hook.h
+++ b/hook.h
@@ -3,6 +3,7 @@
 #include "strvec.h"
 #include "run-command.h"
 #include "string-list.h"
+#include "strmap.h"
 
 struct repository;
 
@@ -10,17 +11,22 @@ struct repository;
  * Represents a hook command to be run.
  * Hooks can be:
  * 1. "traditional" (found in the hooks directory)
- * 2. "configured" (defined in Git's configuration, not yet implemented).
+ * 2. "configured" (defined in Git's configuration via hook.<name>.event).
  * The 'kind' field determines which part of the union 'u' is valid.
  */
 struct hook {
 	enum {
 		HOOK_TRADITIONAL,
+		HOOK_CONFIGURED,
 	} kind;
 	union {
 		struct {
 			const char *path;
 		} traditional;
+		struct {
+			const char *friendly_name;
+			const char *command;
+		} configured;
 	} u;
 
 	/**
@@ -185,6 +191,12 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,
  */
 void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);
 
+/**
+ * Frees the hook configuration cache stored in `struct repository`.
+ * Called by repo_clear().
+ */
+void hook_cache_clear(struct strmap *cache);
+
 /**
  * Returns the path to the hook file, or NULL if the hook is missing
  * or disabled. Note that this points to static storage that will be
diff --git a/repository.c b/repository.c
index 46a7c99930..f27ab29b0e 100644
--- a/repository.c
+++ b/repository.c
@@ -1,6 +1,7 @@
 #include "git-compat-util.h"
 #include "abspath.h"
 #include "repository.h"
+#include "hook.h"
 #include "odb.h"
 #include "config.h"
 #include "object.h"
@@ -394,6 +395,11 @@ void repo_clear(struct repository *repo)
 		FREE_AND_NULL(repo->index);
 	}
 
+	if (repo->hook_config_cache) {
+		hook_cache_clear(repo->hook_config_cache);
+		FREE_AND_NULL(repo->hook_config_cache);
+	}
+
 	if (repo->promisor_remote_config) {
 		promisor_remote_clear(repo->promisor_remote_config);
 		FREE_AND_NULL(repo->promisor_remote_config);
diff --git a/repository.h b/repository.h
index 7141237f97..25b2801228 100644
--- a/repository.h
+++ b/repository.h
@@ -157,6 +157,12 @@ struct repository {
 	/* True if commit-graph has been disabled within this process. */
 	int commit_graph_disabled;
 
+	/*
+	 * Lazily-populated cache mapping hook event names to configured hooks.
+	 * NULL until first hook use.
+	 */
+	struct strmap *hook_config_cache;
+
 	/* Configurations related to promisor remotes. */
 	char *repository_format_partial_clone;
 	struct promisor_remote_config *promisor_remote_config;
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index 3ec11f1249..f1048a5119 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 &&
@@ -35,6 +52,15 @@ test_expect_success 'git hook list: traditional hook from hookdir' '
 	test_cmp expect actual
 '
 
+test_expect_success 'git hook list: configured hook' '
+	test_config hook.myhook.command "echo Hello" &&
+	test_config hook.myhook.event test-hook --add &&
+
+	echo "myhook" >expect &&
+	git hook list test-hook >actual &&
+	test_cmp expect actual
+'
+
 test_expect_success 'git hook run: nonexistent hook' '
 	cat >stderr.expect <<-\EOF &&
 	error: cannot find a hook named test-hook
@@ -172,6 +198,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" &&
+	test_config hook.stdin-a.command "xargs -P1 -I% echo a%" &&
+	test_config hook.stdin-b.event "test-hook" &&
+	test_config hook.stdin-b.command "xargs -P1 -I% echo b%" &&
+
+	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" &&
+	test_must_fail git hook list test-hook 2>actual &&
+	test_grep "hook.broken.command" actual &&
+	test_must_fail git hook run test-hook 2>actual &&
+	test_grep "hook.broken.command" actual
+'
+
 test_expect_success 'git hook run a hook with a bad shebang' '
 	test_when_finished "rm -rf bad-hooks" &&
 	mkdir bad-hooks &&
@@ -189,6 +335,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


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v2 5/8] hook: allow disabling config hooks
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
                     ` (3 preceding siblings ...)
  2026-02-18 22:23   ` [PATCH v2 4/8] hook: include hooks from the config Adrian Ratiu
@ 2026-02-18 22:23   ` Adrian Ratiu
  2026-02-20 12:46     ` Patrick Steinhardt
  2026-02-18 22:23   ` [PATCH v2 6/8] hook: allow event = "" to overwrite previous values Adrian Ratiu
                     ` (4 subsequent siblings)
  9 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Hooks specified via configs are always enabled, however users
might want to disable them without removing from the config,
like locally disabling a global hook.

Add a hook.<name>.enabled config which defaults to true and
can be optionally set for each configured hook.

Suggested-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/config/hook.adoc |  7 +++++++
 hook.c                         | 20 ++++++++++++++++++++
 t/t1800-hook.sh                | 32 ++++++++++++++++++++++++++++++++
 3 files changed, 59 insertions(+)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index 9faafe3016..0cda4745a6 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -13,3 +13,10 @@ hook.<name>.event::
 	specified event, the associated `hook.<name>.command` is executed.
 	This is a multi-valued key. To run `hook.<name>` on multiple
 	events, specify the key more than once. See linkgit:git-hook[1].
+
+hook.<name>.enabled::
+	Whether the hook `hook.<name>` is enabled. Defaults to `true`.
+	Set to `false` to disable the hook without removing its
+	configuration. This is particularly useful when a hook is defined
+	in a system or global config file and needs to be disabled for a
+	specific repository. See linkgit:git-hook[1].
diff --git a/hook.c b/hook.c
index 8a9b405f76..35c24bf33d 100644
--- a/hook.c
+++ b/hook.c
@@ -164,6 +164,21 @@ static int hook_config_lookup_all(const char *key, const char *value,
 		char *old = strmap_put(&data->commands, hook_name,
 				       xstrdup(value));
 		free(old);
+	} else if (!strcmp(subkey, "enabled")) {
+		switch (git_parse_maybe_bool(value)) {
+		case 0: /* disabled */
+			if (!unsorted_string_list_lookup(&data->disabled_hooks,
+							 hook_name))
+				string_list_append(&data->disabled_hooks,
+						   hook_name);
+			break;
+		case 1: /* enabled: undo a prior disabled entry */
+			unsorted_string_list_remove(&data->disabled_hooks,
+						    hook_name);
+			break;
+		default:
+			break; /* ignore unrecognised values */
+		}
 	}
 
 	free(hook_name);
@@ -216,6 +231,11 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 			const char *hname = hook_names->items[i].string;
 			char *command;
 
+			/* filter out disabled hooks */
+			if (unsorted_string_list_lookup(&cb_data.disabled_hooks,
+							hname))
+				continue;
+
 			command = strmap_get(&cb_data.commands, hname);
 			if (!command)
 				die(_("'hook.%s.command' must be configured or "
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index f1048a5119..9797802735 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -318,6 +318,38 @@ test_expect_success 'rejects hooks with no commands configured' '
 	test_grep "hook.broken.command" actual
 '
 
+test_expect_success 'disabled hook is not run' '
+	test_config hook.skipped.event "test-hook" &&
+	test_config hook.skipped.command "echo \"Should not run\"" &&
+	test_config hook.skipped.enabled false &&
+
+	git hook run --ignore-missing test-hook 2>actual &&
+	test_must_be_empty actual
+'
+
+test_expect_success 'disabled hook does not appear in git hook list' '
+	test_config hook.active.event "pre-commit" &&
+	test_config hook.active.command "echo active" &&
+	test_config hook.inactive.event "pre-commit" &&
+	test_config hook.inactive.command "echo inactive" &&
+	test_config hook.inactive.enabled false &&
+
+	git hook list pre-commit >actual &&
+	test_grep "active" actual &&
+	test_grep ! "inactive" actual
+'
+
+test_expect_success 'globally disabled hook can be re-enabled locally' '
+	test_config_global hook.global-hook.event "test-hook" &&
+	test_config_global hook.global-hook.command "echo \"global-hook ran\"" &&
+	test_config_global hook.global-hook.enabled false &&
+	test_config hook.global-hook.enabled true &&
+
+	echo "global-hook ran" >expected &&
+	git hook run test-hook 2>actual &&
+	test_cmp expected actual
+'
+
 test_expect_success 'git hook run a hook with a bad shebang' '
 	test_when_finished "rm -rf bad-hooks" &&
 	mkdir bad-hooks &&
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v2 6/8] hook: allow event = "" to overwrite previous values
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
                     ` (4 preceding siblings ...)
  2026-02-18 22:23   ` [PATCH v2 5/8] hook: allow disabling config hooks Adrian Ratiu
@ 2026-02-18 22:23   ` Adrian Ratiu
  2026-02-18 22:23   ` [PATCH v2 7/8] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
                     ` (3 subsequent siblings)
  9 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Add the ability for empty events to clear previously set multivalue
variables, so the newly added "hook.*.event" behave like the other
multivalued keys.

Suggested-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/config/hook.adoc |  4 +++-
 hook.c                         | 29 +++++++++++++++++++----------
 t/t1800-hook.sh                | 12 ++++++++++++
 3 files changed, 34 insertions(+), 11 deletions(-)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index 0cda4745a6..64e845a260 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -12,7 +12,9 @@ hook.<name>.event::
 	linkgit:githooks[5] for a complete list of hook events.) On the
 	specified event, the associated `hook.<name>.command` is executed.
 	This is a multi-valued key. To run `hook.<name>` on multiple
-	events, specify the key more than once. See linkgit:git-hook[1].
+	events, specify the key more than once. An empty value resets
+	the list of events, clearing any previously defined events for
+	`hook.<name>`. See linkgit:git-hook[1].
 
 hook.<name>.enabled::
 	Whether the hook `hook.<name>` is enabled. Defaults to `true`.
diff --git a/hook.c b/hook.c
index 35c24bf33d..fee0a7ab4f 100644
--- a/hook.c
+++ b/hook.c
@@ -147,18 +147,27 @@ static int hook_config_lookup_all(const char *key, const char *value,
 	hook_name = xmemdupz(name, name_len);
 
 	if (!strcmp(subkey, "event")) {
-		struct string_list *hooks =
-			strmap_get(&data->event_hooks, value);
+		if (!*value) {
+			/* Empty values reset previous events for this hook. */
+			struct hashmap_iter iter;
+			struct strmap_entry *e;
+
+			strmap_for_each_entry(&data->event_hooks, &iter, e)
+				unsorted_string_list_remove(e->value, hook_name);
+		} else {
+			struct string_list *hooks =
+				strmap_get(&data->event_hooks, value);
+
+			if (!hooks) {
+				hooks = xcalloc(1, sizeof(*hooks));
+				string_list_init_dup(hooks);
+				strmap_put(&data->event_hooks, value, hooks);
+			}
 
-		if (!hooks) {
-			hooks = xcalloc(1, sizeof(*hooks));
-			string_list_init_dup(hooks);
-			strmap_put(&data->event_hooks, value, hooks);
+			/* Re-insert if necessary to preserve last-seen order. */
+			unsorted_string_list_remove(hooks, hook_name);
+			string_list_append(hooks, hook_name);
 		}
-
-		/* Re-insert if necessary to preserve last-seen order. */
-		unsorted_string_list_remove(hooks, hook_name);
-		string_list_append(hooks, hook_name);
 	} else if (!strcmp(subkey, "command")) {
 		/* Store command overwriting the old value */
 		char *old = strmap_put(&data->commands, hook_name,
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index 9797802735..fb6bc554b9 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -226,6 +226,18 @@ test_expect_success 'git hook list reorders on duplicate event declarations' '
 	test_cmp expected actual
 '
 
+test_expect_success 'git hook list: empty event value resets events' '
+	setup_hooks &&
+
+	# ghi is configured for pre-commit; reset it with an empty value
+	test_config hook.ghi.event "" --add &&
+
+	# only def should remain for pre-commit
+	echo "def" >expected &&
+	git hook list pre-commit >actual &&
+	test_cmp expected actual
+'
+
 test_expect_success 'hook can be configured for multiple events' '
 	setup_hooks &&
 
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v2 7/8] hook: allow out-of-repo 'git hook' invocations
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
                     ` (5 preceding siblings ...)
  2026-02-18 22:23   ` [PATCH v2 6/8] hook: allow event = "" to overwrite previous values Adrian Ratiu
@ 2026-02-18 22:23   ` Adrian Ratiu
  2026-02-18 22:23   ` [PATCH v2 8/8] hook: add -z option to "git hook list" Adrian Ratiu
                     ` (2 subsequent siblings)
  9 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

From: Emily Shaffer <emilyshaffer@google.com>

Since hooks can now be supplied via the config, and a config can be
present without a gitdir via the global and system configs, we can start
to allow 'git hook run' to occur without a gitdir. This enables us to do
things like run sendemail-validate hooks when running 'git send-email'
from a nongit directory.

It still doesn't make sense to look for hooks in the hookdir in nongit
repos, though, as there is no hookdir.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 git.c           |  2 +-
 hook.c          | 30 ++++++++++++++++++++++++++++--
 t/t1800-hook.sh | 16 +++++++++++-----
 3 files changed, 40 insertions(+), 8 deletions(-)

diff --git a/git.c b/git.c
index 744cb6527e..6480ff8373 100644
--- a/git.c
+++ b/git.c
@@ -587,7 +587,7 @@ static struct cmd_struct commands[] = {
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
 	{ "history", cmd_history, RUN_SETUP },
-	{ "hook", cmd_hook, RUN_SETUP },
+	{ "hook", cmd_hook, RUN_SETUP_GENTLY },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
 	{ "init-db", cmd_init_db },
diff --git a/hook.c b/hook.c
index fee0a7ab4f..2c8252b2c4 100644
--- a/hook.c
+++ b/hook.c
@@ -18,6 +18,9 @@ const char *find_hook(struct repository *r, const char *name)
 
 	int found_hook;
 
+	if (!r || !r->gitdir)
+		return NULL;
+
 	repo_git_path_replace(r, &path, "hooks/%s", name);
 	found_hook = access(path.buf, X_OK) >= 0;
 #ifdef STRIP_EXTENSION
@@ -268,12 +271,18 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 	strmap_clear(&cb_data.event_hooks, 0);
 }
 
-/* Return the hook config map for `r`, populating it first if needed. */
+/*
+ * Return the hook config map for `r`, populating it first if needed.
+ *
+ * Out-of-repo calls (r->gitdir == NULL) allocate and return a temporary
+ * cache map; the caller is responsible for freeing it with
+ * hook_cache_clear() + free().
+ */
 static struct strmap *get_hook_config_cache(struct repository *r)
 {
 	struct strmap *cache = NULL;
 
-	if (r) {
+	if (r && r->gitdir) {
 		/*
 		 * For in-repo calls, the map is stored in r->hook_config_cache,
 		 * so repeated invocations don't parse the configs, so allocate
@@ -285,6 +294,14 @@ static struct strmap *get_hook_config_cache(struct repository *r)
 			build_hook_config_map(r, r->hook_config_cache);
 		}
 		cache = r->hook_config_cache;
+	} else {
+		/*
+		 * Out-of-repo calls (no gitdir) allocate and return a temporary
+		 * map cache which gets free'd immediately by the caller.
+		 */
+		cache = xcalloc(1, sizeof(*cache));
+		strmap_init(cache);
+		build_hook_config_map(r, cache);
 	}
 
 	return cache;
@@ -315,6 +332,15 @@ static void list_hooks_add_configured(struct repository *r,
 
 		string_list_append(list, friendly_name)->util = hook;
 	}
+
+	/*
+	 * Cleanup temporary cache for out-of-repo calls since they can't be
+	 * stored persistently. Next out-of-repo calls will have to re-parse.
+	 */
+	if (!r || !r->gitdir) {
+		hook_cache_clear(cache);
+		free(cache);
+	}
 }
 
 struct string_list *list_hooks(struct repository *r, const char *hookname,
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index fb6bc554b9..e58151e8f8 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -131,12 +131,18 @@ test_expect_success 'git hook run -- pass arguments' '
 	test_cmp expect actual
 '
 
-test_expect_success 'git hook run -- out-of-repo runs excluded' '
-	test_hook test-hook <<-EOF &&
-	echo Test hook
-	EOF
+test_expect_success 'git hook run: out-of-repo runs execute global hooks' '
+	test_config_global hook.global-hook.event test-hook --add &&
+	test_config_global hook.global-hook.command "echo no repo no problems" --add &&
 
-	nongit test_must_fail git hook run test-hook
+	echo "global-hook" >expect &&
+	nongit git hook list test-hook >actual &&
+	test_cmp expect actual &&
+
+	echo "no repo no problems" >expect &&
+
+	nongit git hook run test-hook 2>actual &&
+	test_cmp expect actual
 '
 
 test_expect_success 'git -c core.hooksPath=<PATH> hook run' '
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v2 8/8] hook: add -z option to "git hook list"
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
                     ` (6 preceding siblings ...)
  2026-02-18 22:23   ` [PATCH v2 7/8] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
@ 2026-02-18 22:23   ` Adrian Ratiu
  2026-02-19 21:34   ` [PATCH v2 0/8] Specify hooks via configs Junio C Hamano
  2026-02-20 23:29   ` brian m. carlson
  9 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-18 22:23 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Add a NUL-terminate mode to git hook list, just in case hooks are
configured with weird characters like newlines in their names.

Suggested-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/git-hook.adoc |  8 ++++++--
 builtin/hook.c              |  9 ++++++---
 t/t1800-hook.sh             | 13 +++++++++++++
 3 files changed, 25 insertions(+), 5 deletions(-)

diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index 7e4259e4f0..12d2701b52 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -9,7 +9,7 @@ SYNOPSIS
 --------
 [verse]
 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
-'git hook' list <hook-name>
+'git hook' list [-z] <hook-name>
 
 DESCRIPTION
 -----------
@@ -113,9 +113,10 @@ Any positional arguments to the hook should be passed after a
 mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See
 linkgit:githooks[5] for arguments hooks might expect (if any).
 
-list::
+list [-z]::
 	Print a list of hooks which will be run on `<hook-name>` event. If no
 	hooks are configured for that event, print a warning and return 1.
+	Use `-z` to terminate output lines with NUL instead of newlines.
 
 OPTIONS
 -------
@@ -130,6 +131,9 @@ OPTIONS
 	tools that want to do a blind one-shot run of a hook that may
 	or may not be present.
 
+-z::
+	Terminate "list" output lines with NUL instead of newlines.
+
 WRAPPERS
 --------
 
diff --git a/builtin/hook.c b/builtin/hook.c
index e151bb2cd1..83020dfb4f 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -11,7 +11,7 @@
 #define BUILTIN_HOOK_RUN_USAGE \
 	N_("git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
 #define BUILTIN_HOOK_LIST_USAGE \
-	N_("git hook list <hook-name>")
+	N_("git hook list [-z] <hook-name>")
 
 static const char * const builtin_hook_usage[] = {
 	BUILTIN_HOOK_RUN_USAGE,
@@ -34,9 +34,12 @@ static int list(int argc, const char **argv, const char *prefix,
 	struct string_list *head;
 	struct string_list_item *item;
 	const char *hookname = NULL;
+	int line_terminator = '\n';
 	int ret = 0;
 
 	struct option list_options[] = {
+		OPT_SET_INT('z', NULL, &line_terminator,
+			    N_("use NUL as line terminator"), '\0'),
 		OPT_END(),
 	};
 
@@ -66,10 +69,10 @@ static int list(int argc, const char **argv, const char *prefix,
 
 		switch (h->kind) {
 		case HOOK_TRADITIONAL:
-			printf("%s\n", _("hook from hookdir"));
+			printf("%s%c", _("hook from hookdir"), line_terminator);
 			break;
 		case HOOK_CONFIGURED:
-			printf("%s\n", h->u.configured.friendly_name);
+			printf("%s%c", h->u.configured.friendly_name, line_terminator);
 			break;
 		default:
 			BUG("unknown hook kind");
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index e58151e8f8..b1583e9ef9 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -61,6 +61,19 @@ test_expect_success 'git hook list: configured hook' '
 	test_cmp expect actual
 '
 
+test_expect_success 'git hook list: -z shows NUL-terminated output' '
+	test_hook test-hook <<-EOF &&
+	echo Test hook
+	EOF
+	test_config hook.myhook.command "echo Hello" &&
+	test_config hook.myhook.event test-hook --add &&
+
+	printf "myhookQhook from hookdirQ" >expect &&
+	git hook list -z test-hook >actual.raw &&
+	nul_to_q <actual.raw >actual &&
+	test_cmp expect actual
+'
+
 test_expect_success 'git hook run: nonexistent hook' '
 	cat >stderr.expect <<-\EOF &&
 	error: cannot find a hook named test-hook
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
                     ` (7 preceding siblings ...)
  2026-02-18 22:23   ` [PATCH v2 8/8] hook: add -z option to "git hook list" Adrian Ratiu
@ 2026-02-19 21:34   ` Junio C Hamano
  2026-02-20 12:51     ` Adrian Ratiu
  2026-02-20 23:29   ` brian m. carlson
  9 siblings, 1 reply; 69+ messages in thread
From: Junio C Hamano @ 2026-02-19 21:34 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

> v2 addresses all feedback received in v1.
>
> This series adds a new feature: the ability to specify commands to run
> for hook events via config entries (including shell commands).
>
> So instead of dropping a shell script or a custom program in .git/hooks
> you can now tell git via config files to run a program or shell script
> (can be specified directly in the config) when you run hook "foo".
>
> This also means you can setup global hooks to run in multiple repos via
> global configs and there's an option to disable them if necessary.
>
> For simplicity, because this series is becoming rather big, hooks are
> still executed sequentially (.jobs == 1). Parallel execution is added
> in another patch series.
>
> This is based on the latest v8 hooks-conversion series [1] which has
> not yet landed in next or master.

Thanks for a reroll.  I was a bit concerned to allow configuration
files to speicify hooks as it would reduce discoverability (i.e.,
today, we can "ls .git/hooks/" to see everything that potentially
will be triggered, but now we need to be aware of what your sysadmin
dropped in /etc/gitconfig to get the whole picture.  "git hook list"
would solve that issue nicely.

By the way, the discussion thread for the base topic hasn't seen
any activity in the latest round after it updated for the comments
received in the previous round.  It appears that it is ready to move
forward?  Let's mark it for 'next' in that case.

Thanks.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks
  2026-02-18 22:23   ` [PATCH v2 1/8] hook: add internal state alloc/free callbacks Adrian Ratiu
@ 2026-02-19 21:47     ` Junio C Hamano
  2026-02-20 12:35       ` Adrian Ratiu
  2026-02-20 12:42       ` Adrian Ratiu
  2026-02-20 12:45     ` Patrick Steinhardt
  1 sibling, 2 replies; 69+ messages in thread
From: Junio C Hamano @ 2026-02-19 21:47 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

> +	/*
> +	 * Ensure cb_data copy and free functions are either provided together,
> +	 * or neither one is provided.
> +	 */
> +	if ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||
> +	    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))
> +		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");

A way to avoid being repetitious may be to say

	if (!!options->feed_pipe_cb_data_alloc != !!options->feed_pipe_cb_data_free)

or

	if (!!options->feed_pipe_cb_data_alloc ^ !!options->feed_pipe_cb_data_free)

but it (especially the latter) might be a bit too cute for some
people's taste.

> diff --git a/refs.c b/refs.c
> index d432cfb78f..3c124fab29 100644
> --- a/refs.c
> +++ b/refs.c
> @@ -2514,24 +2514,38 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_
>  	return 0; /* no more input to feed */
>  }
>  
> +static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED)
> +{
> +	struct transaction_feed_cb_data *data = xmalloc(sizeof(*data));
> +	strbuf_init(&data->buf, 0);
> +	data->index = 0;
> +	return data;
> +}
> +
> +static void transaction_feed_cb_data_free(void *data)
> +{
> +	struct transaction_feed_cb_data *d = data;
> +	if (!d)
> +		return;
> +	strbuf_release(&d->buf);
> +	free(d);
> +}
> +
>  static int run_transaction_hook(struct ref_transaction *transaction,
>  				const char *state)
>  {
>  	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
> -	struct transaction_feed_cb_data feed_ctx = { 0 };
>  	int ret = 0;
>  
>  	strvec_push(&opt.args, state);
>  
>  	opt.feed_pipe = transaction_hook_feed_stdin;
>  	opt.feed_pipe_ctx = transaction;
> -	opt.feed_pipe_cb_data = &feed_ctx;

So, as the proposed log message promised, our stack lost the
callback data, and instead uses an allocated piece of memory
pointed by the same opt.feed_pipe_cb_data member as before.

> -
> -	strbuf_init(&feed_ctx.buf, 0);
> +	opt.feed_pipe_cb_data_alloc = transaction_feed_cb_data_alloc;
> +	opt.feed_pipe_cb_data_free = transaction_feed_cb_data_free;
>  
>  	ret = run_hooks_opt(transaction->ref_store->repo, "reference-transaction", &opt);
>  
> -	strbuf_release(&feed_ctx.buf);

As the run_hooks_opt() machinery internally manages the
feed_pipe_cb_data, starting from the call to the custom alloc
function and concliding with the call to the custom free function,
this function no longer need to manage resources.  Instead, the
custom alloc/free functions take care of all the associated
resources, like the feed_ctx.buf strbuf, as we have seen earlier.

Nice.

> diff --git a/transport.c b/transport.c
> index ecf9e1f21c..1581aa0886 100644
> --- a/transport.c
> +++ b/transport.c
> @@ -1357,21 +1357,36 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void
>  	return 0;
>  }
>  
> +static void *pre_push_hook_data_alloc(void *feed_pipe_ctx)
> +{
> +...
> +}
> +
> +static void pre_push_hook_data_free(void *data)
> +{
> +...
> +}
> +
>  static int run_pre_push_hook(struct transport *transport,
>  			     struct ref *remote_refs)
>  {
>  	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
> -	struct feed_pre_push_hook_data data;
>  	int ret = 0;
>  
>  	strvec_push(&opt.args, transport->remote->name);
>  	strvec_push(&opt.args, transport->url);
>  
> -	strbuf_init(&data.buf, 0);
> -	data.refs = remote_refs;
> -
>  	opt.feed_pipe = pre_push_hook_feed_stdin;
> -	opt.feed_pipe_cb_data = &data;
> +	opt.feed_pipe_ctx = remote_refs;
> +	opt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;
> +	opt.feed_pipe_cb_data_free = pre_push_hook_data_free;

The same structure is used here.  Nice.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 4/8] hook: include hooks from the config
  2026-02-18 22:23   ` [PATCH v2 4/8] hook: include hooks from the config Adrian Ratiu
@ 2026-02-19 22:16     ` Junio C Hamano
  2026-02-20 12:27       ` Adrian Ratiu
  2026-02-20 12:46     ` Patrick Steinhardt
  1 sibling, 1 reply; 69+ messages in thread
From: Junio C Hamano @ 2026-02-19 22:16 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

> Examples:
>
>   $ git config --get-regexp "^hook\."
>   hook.bar.command=~/bar.sh
>   hook.bar.event=pre-commit

This is all good when you know where you defined your pre-commit
hook, but you would want to know in which scope the configuration is
made, wouldn't you, especially when you are trying to diagnose why
some command that you do not necessarily recognise when you run a
Git command?

> @@ -10,17 +11,22 @@ struct repository;
>   * Represents a hook command to be run.
>   * Hooks can be:
>   * 1. "traditional" (found in the hooks directory)
> - * 2. "configured" (defined in Git's configuration, not yet implemented).
> + * 2. "configured" (defined in Git's configuration via hook.<name>.event).

Wouldn't it be easier to understand if we do "<name>" -> "<friendly-name>"
to match the member name used in the struct below?

>   * The 'kind' field determines which part of the union 'u' is valid.
>   */
>  struct hook {
>  	enum {
>  		HOOK_TRADITIONAL,
> +		HOOK_CONFIGURED,
>  	} kind;
>  	union {
>  		struct {
>  			const char *path;
>  		} traditional;
> +		struct {
> +			const char *friendly_name;
> +			const char *command;

If we wanted to report which config scope defined a particular hook
we need to record where the configured hook came from in this
struct, right?

> +		} configured;
>  	} u;

> +	if (repo->hook_config_cache) {
> +		hook_cache_clear(repo->hook_config_cache);
> +		FREE_AND_NULL(repo->hook_config_cache);
> +	}

It is a minor point, but after applying the hole series, there are
only two calls to hook_cachje_clear(X) and they both are followed by
free(X) or FREE_AND_NULL(X).  I wonder if we simply want
hook_cache_free() instead of _clear()?


^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 4/8] hook: include hooks from the config
  2026-02-19 22:16     ` Junio C Hamano
@ 2026-02-20 12:27       ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 12:27 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:
> Adrian Ratiu <adrian.ratiu@collabora.com> writes:
>
>> Examples:
>>
>>   $ git config --get-regexp "^hook\."
>>   hook.bar.command=~/bar.sh
>>   hook.bar.event=pre-commit
>
> This is all good when you know where you defined your pre-commit
> hook, but you would want to know in which scope the configuration is
> made, wouldn't you, especially when you are trying to diagnose why
> some command that you do not necessarily recognise when you run a
> Git command?

Yes, I assumed the user knows where his hooks are defined. :)
Obviously this is not the case.

This also applies to the "git hook list" command added later in this
series, we might want to tell users where the hooks come from there as
well.

>> @@ -10,17 +11,22 @@ struct repository;
>>   * Represents a hook command to be run.
>>   * Hooks can be:
>>   * 1. "traditional" (found in the hooks directory)
>> - * 2. "configured" (defined in Git's configuration, not yet implemented).
>> + * 2. "configured" (defined in Git's configuration via hook.<name>.event).
>
> Wouldn't it be easier to understand if we do "<name>" -> "<friendly-name>"
> to match the member name used in the struct below?

Yes, name here referes to "friendly-name" :)

I'll make this consestent in v3 across the patch series, to use
friendly-name.

>>   * The 'kind' field determines which part of the union 'u' is valid.
>>   */
>>  struct hook {
>>  	enum {
>>  		HOOK_TRADITIONAL,
>> +		HOOK_CONFIGURED,
>>  	} kind;
>>  	union {
>>  		struct {
>>  			const char *path;
>>  		} traditional;
>> +		struct {
>> +			const char *friendly_name;
>> +			const char *command;
>
> If we wanted to report which config scope defined a particular hook
> we need to record where the configured hook came from in this
> struct, right?

Yes, this is the place.

We can do it when parsing the configs (in v2, at Patrick's suggestion we
just parse once then cache & reuse if possible). 

>
>> +		} configured;
>>  	} u;
>
>> +	if (repo->hook_config_cache) {
>> +		hook_cache_clear(repo->hook_config_cache);
>> +		FREE_AND_NULL(repo->hook_config_cache);
>> +	}
>
> It is a minor point, but after applying the hole series, there are
> only two calls to hook_cachje_clear(X) and they both are followed by
> free(X) or FREE_AND_NULL(X).  I wonder if we simply want
> hook_cache_free() instead of _clear()?

Yes, we can do this.

I think I have another place where I can apply this pattern in addition
to this, to avoid calling both clear() + free() afterwards.

Will make all these uses consistent on a single _free() in v3.

Thanks,
Adrian

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks
  2026-02-19 21:47     ` Junio C Hamano
@ 2026-02-20 12:35       ` Adrian Ratiu
  2026-02-20 17:21         ` Junio C Hamano
  2026-02-20 12:42       ` Adrian Ratiu
  1 sibling, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 12:35 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:
> Adrian Ratiu <adrian.ratiu@collabora.com> writes:
>
>> +	/*
>> +	 * Ensure cb_data copy and free functions are either provided together,
>> +	 * or neither one is provided.
>> +	 */
>> +	if ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||
>> +	    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))
>> +		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");
>
> A way to avoid being repetitious may be to say
>
> 	if (!!options->feed_pipe_cb_data_alloc != !!options->feed_pipe_cb_data_free)
>
> or
>
> 	if (!!options->feed_pipe_cb_data_alloc ^ !!options->feed_pipe_cb_data_free)
>
> but it (especially the latter) might be a bit too cute for some
> people's taste.

Thanks for suggesting this. I was actually thinking of ways to simplify
this and the double negation didn't occur to me. Will do in v3.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks
  2026-02-19 21:47     ` Junio C Hamano
  2026-02-20 12:35       ` Adrian Ratiu
@ 2026-02-20 12:42       ` Adrian Ratiu
  1 sibling, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 12:42 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:
> Adrian Ratiu <adrian.ratiu@collabora.com> writes:
<snip>
>> -
>> -	strbuf_init(&feed_ctx.buf, 0);
>> +	opt.feed_pipe_cb_data_alloc = transaction_feed_cb_data_alloc;
>> +	opt.feed_pipe_cb_data_free = transaction_feed_cb_data_free;
>>  
>>  	ret = run_hooks_opt(transaction->ref_store->repo, "reference-transaction", &opt);
>>  
>> -	strbuf_release(&feed_ctx.buf);
>
> As the run_hooks_opt() machinery internally manages the
> feed_pipe_cb_data, starting from the call to the custom alloc
> function and concliding with the call to the custom free function,
> this function no longer need to manage resources.  Instead, the
> custom alloc/free functions take care of all the associated
> resources, like the feed_ctx.buf strbuf, as we have seen earlier.
>
> Nice.

Exactly, yes.

This reminds me: I need to verify the header files and the APIs to
remove/update any comments about callers having to manage the these
resources.

Will do in v3.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks
  2026-02-18 22:23   ` [PATCH v2 1/8] hook: add internal state alloc/free callbacks Adrian Ratiu
  2026-02-19 21:47     ` Junio C Hamano
@ 2026-02-20 12:45     ` Patrick Steinhardt
  2026-02-20 13:40       ` Adrian Ratiu
  1 sibling, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-20 12:45 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, Feb 19, 2026 at 12:23:45AM +0200, Adrian Ratiu wrote:
> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
> index 2d2b33d73d..f23772bc56 100644
> --- a/builtin/receive-pack.c
> +++ b/builtin/receive-pack.c
> @@ -901,6 +901,26 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_
>  	return state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */
>  }
>  
> +static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)
> +{
> +	struct receive_hook_feed_state *init_state = feed_pipe_ctx;
> +	struct receive_hook_feed_state *data = xcalloc(1, sizeof(*data));

Tiny nit, not worth addressing: we often use `CALLOC_ARRAY(data, 1)`
nowadays.

> +	data->report = init_state->report;
> +	data->cmd = init_state->cmd;
> +	data->skip_broken = init_state->skip_broken;
> +	strbuf_init(&data->buf, 0);
> +	return data;
> +}

Okay, this basically creates the new instance by creating a deep copy of
the "template" structure.

One could split this up so that we have a "configuration" struct and a
"data" struct, where we then provide a pointer to the configuration into
the data structure, as only the buffer needs to change between the
individual hook invocations. That would avoid some copying around, but
it feels a bit unnecessary.

> +static void receive_hook_feed_state_free(void *data)
> +{
> +	struct receive_hook_feed_state *d = data;
> +	if (!d)
> +		return;
> +	strbuf_release(&d->buf);
> +	free(d);
> +}

I would expect that the hook interfaces know to not call `free()` in
case `alloc()` wasn't called, but I guess it doesn't hurt to be
defensive here anyway.

> @@ -908,7 +928,7 @@ static int run_receive_hook(struct command *commands,
>  {
>  	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
>  	struct command *iter = commands;
> -	struct receive_hook_feed_state feed_state;
> +	struct receive_hook_feed_state feed_init_state = { 0 };
>  	struct async sideband_async;
>  	int sideband_async_started = 0;
>  	int saved_stderr = -1;
> @@ -938,16 +958,15 @@ static int run_receive_hook(struct command *commands,
>  	prepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started);
>  
>  	/* set up stdin callback */
> -	feed_state.cmd = commands;
> -	feed_state.skip_broken = skip_broken;
> -	feed_state.report = NULL;
> -	strbuf_init(&feed_state.buf, 0);
> -	opt.feed_pipe_cb_data = &feed_state;
> +	feed_init_state.cmd = commands;
> +	feed_init_state.skip_broken = skip_broken;

As far as I can see all of the data that we pass to the state struct is
static, so we might just as well initialize it right away, right?

    struct receive_hook_feed_state feed_init_state = {
        .cmd = commands,
        .skip_broken = skip_broken,
        .buf = STRBUF_INIT,
    };

> diff --git a/hook.c b/hook.c
> index cde7198412..83ff658866 100644
> --- a/hook.c
> +++ b/hook.c
> @@ -133,6 +133,8 @@ static int notify_hook_finished(int result,
>  
>  static void run_hooks_opt_clear(struct run_hooks_opt *options)
>  {
> +	if (options->feed_pipe_cb_data_free)
> +		options->feed_pipe_cb_data_free(options->feed_pipe_cb_data);
>  	strvec_clear(&options->env);
>  	strvec_clear(&options->args);
>  }

I guess this here would be where we could skip `free` in case the data
wasn't even allocated. But as I said further up, I don't care all that
much.

> diff --git a/hook.h b/hook.h
> index 20eb56fd63..a6bdc6f90f 100644
> --- a/hook.h
> +++ b/hook.h
> @@ -5,6 +5,9 @@
>  
>  struct repository;
>  
> +typedef void (*cb_data_free_fn)(void *data);
> +typedef void *(*cb_data_alloc_fn)(void *init_ctx);
> +
>  struct run_hooks_opt
>  {
>  	/* Environment vars to be set for each hook */

Do we maybe want to scope these function typedefs to the hooks subsystem
by calling the `hook_data_free_fn` and `hook_data_alloc_fn`, or
something like that?

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support
  2026-02-18 22:23   ` [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support Adrian Ratiu
@ 2026-02-20 12:46     ` Patrick Steinhardt
  2026-02-20 13:51       ` Adrian Ratiu
  0 siblings, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-20 12:46 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, Feb 19, 2026 at 12:23:46AM +0200, Adrian Ratiu wrote:
> diff --git a/hook.c b/hook.c
> index 83ff658866..c008a7232d 100644
> --- a/hook.c
> +++ b/hook.c
> @@ -47,9 +47,97 @@ const char *find_hook(struct repository *r, const char *name)
>  	return path.buf;
>  }
>  
> +static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)

Nit: this should be called `hook_free()` as it also frees the struct
itself.

> +{
> +	if (!h)
> +		return;
> +
> +	if (h->kind == HOOK_TRADITIONAL)
> +		free((void *)h->u.traditional.path);
> +
> +	if (cb_data_free)
> +		cb_data_free(h->feed_pipe_cb_data);
> +
> +	free(h);
> +}
> +
> +static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
> +{
> +	struct string_list_item *item;
> +
> +	for_each_string_list_item(item, hooks)
> +		hook_clear(item->util, cb_data_free);
> +
> +	string_list_clear(hooks, 0);

We have `string_list_clear_func()` to perform a deep free for you.

> +}
> +
> +/* Helper to detect and add default "traditional" hooks from the hookdir. */
> +static void list_hooks_add_default(struct repository *r, const char *hookname,
> +				   struct string_list *hook_list,
> +				   struct run_hooks_opt *options)
> +{
> +	const char *hook_path = find_hook(r, hookname);
> +	struct hook *h;
> +
> +	if (!hook_path)
> +		return;
> +
> +	h = xcalloc(1, sizeof(struct hook));

Better: CALLOC_ARRAY(h, 1);

> +	/*
> +	 * If the hook is to run in a specific dir, a relative path can
> +	 * become invalid in that dir, so convert to an absolute path.
> +	 */
> +	if (options && options->dir)
> +		hook_path = absolute_path(hook_path);
> +
> +	/* Setup per-hook internal state cb data */
> +	if (options && options->feed_pipe_cb_data_alloc)
> +		h->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
> +
> +	h->kind = HOOK_TRADITIONAL;
> +	h->u.traditional.path = xstrdup(hook_path);
> +
> +	string_list_append(hook_list, hook_path)->util = h;
> +}
> +
> +/*
> + * Provides a list of hook commands to run for the 'hookname' event.
> + *
> + * This function consolidates hooks from two sources:
> + * 1. The config-based hooks (not yet implemented).
> + * 2. The "traditional" hook found in the repository hooks directory
> + *    (e.g., .git/hooks/pre-commit).
> + *
> + * The list is ordered by execution priority.
> + *
> + * The caller is responsible for freeing the memory of the returned list
> + * using string_list_clear() and free().
> + */
> +static struct string_list *list_hooks(struct repository *r, const char *hookname,
> +			       struct run_hooks_opt *options)
> +{
> +	struct string_list *hook_head;
> +
> +	if (!hookname)
> +		BUG("null hookname was provided to hook_list()!");
> +
> +	hook_head = xmalloc(sizeof(struct string_list));

CALLOC_ARRAY(hook_head, 1);

> @@ -85,21 +176,20 @@ static int pick_next_hook(struct child_process *cp,
>  	cp->trace2_hook_name = hook_cb->hook_name;
>  	cp->dir = hook_cb->options->dir;
>  
> -	strvec_push(&cp->args, hook_path);
> +	/* Add hook exec paths or commands */
> +	if (h->kind == HOOK_TRADITIONAL)
> +		strvec_push(&cp->args, h->u.traditional.path);

Should we be defensive here and have an `else BUG()`?

> diff --git a/hook.h b/hook.h
> index a6bdc6f90f..3256d2dddb 100644
> --- a/hook.h
> +++ b/hook.h
> @@ -2,9 +2,41 @@
>  #define HOOK_H
>  #include "strvec.h"
>  #include "run-command.h"
> +#include "string-list.h"
>  
>  struct repository;
>  
> +/**
> + * Represents a hook command to be run.
> + * Hooks can be:
> + * 1. "traditional" (found in the hooks directory)
> + * 2. "configured" (defined in Git's configuration, not yet implemented).
> + * The 'kind' field determines which part of the union 'u' is valid.
> + */
> +struct hook {
> +	enum {
> +		HOOK_TRADITIONAL,
> +	} kind;
> +	union {
> +		struct {
> +			const char *path;
> +		} traditional;
> +	} u;
> +
> +	/**
> +	 * Opaque data pointer used to keep internal state across callback calls.
> +	 *
> +	 * It can be accessed directly via the third hook callback arg:
> +	 * struct ... *state = pp_task_cb;
> +	 *
> +	 * The caller is responsible for managing the memory for this data by
> +	 * providing alloc/free callbacks to `run_hooks_opt`.
> +	 *
> +	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
> +	 */
> +	void *feed_pipe_cb_data;
> +};

Makes sense to wrap all of this in a specific data structure.

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 3/8] hook: add "git hook list" command
  2026-02-18 22:23   ` [PATCH v2 3/8] hook: add "git hook list" command Adrian Ratiu
@ 2026-02-20 12:46     ` Patrick Steinhardt
  2026-02-20 13:53       ` Adrian Ratiu
  0 siblings, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-20 12:46 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, Feb 19, 2026 at 12:23:47AM +0200, Adrian Ratiu wrote:
> diff --git a/builtin/hook.c b/builtin/hook.c
> index 7afec380d2..51660c4941 100644
> --- a/builtin/hook.c
> +++ b/builtin/hook.c
> @@ -20,6 +24,61 @@ static const char * const builtin_hook_run_usage[] = {
>  	NULL
>  };
>  
> +static int list(int argc, const char **argv, const char *prefix,
> +		 struct repository *repo)
> +{
> +	static const char *const builtin_hook_list_usage[] = {
> +		BUILTIN_HOOK_LIST_USAGE,
> +		NULL
> +	};
> +	struct string_list *head;
> +	struct string_list_item *item;
> +	const char *hookname = NULL;
> +	int ret = 0;
> +
> +	struct option list_options[] = {
> +		OPT_END(),
> +	};
> +
> +	argc = parse_options(argc, argv, prefix, list_options,
> +			     builtin_hook_list_usage, 0);
> +
> +	/*
> +	 * The only unnamed argument provided should be the hook-name; if we add
> +	 * arguments later they probably should be caught by parse_options.
> +	 */
> +	if (argc != 1)
> +		usage_msg_opt(_("You must specify a hook event name to list."),
> +			      builtin_hook_list_usage, list_options);

Error messages typically start with a lower-case letter.

> +	hookname = argv[0];
> +
> +	head = list_hooks(repo, hookname, NULL);
> +
> +	if (!head->nr) {
> +		warning(_("No hooks found for event '%s'"), hookname);

Warnings, too.

> +		ret = 1; /* no hooks found */
> +		goto cleanup;
> +	}
> +
> +	for_each_string_list_item(item, head) {
> +		struct hook *h = item->util;
> +
> +		switch (h->kind) {
> +		case HOOK_TRADITIONAL:
> +			printf("%s\n", _("hook from hookdir"));
> +			break;
> +		default:
> +			BUG("unknown hook kind");
> +		}

Good that we're being defensive here.

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 4/8] hook: include hooks from the config
  2026-02-18 22:23   ` [PATCH v2 4/8] hook: include hooks from the config Adrian Ratiu
  2026-02-19 22:16     ` Junio C Hamano
@ 2026-02-20 12:46     ` Patrick Steinhardt
  2026-02-20 14:31       ` Adrian Ratiu
  1 sibling, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-20 12:46 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, Feb 19, 2026 at 12:23:48AM +0200, Adrian Ratiu wrote:
> diff --git a/builtin/hook.c b/builtin/hook.c
> index 51660c4941..e151bb2cd1 100644
> --- a/builtin/hook.c
> +++ b/builtin/hook.c
> @@ -54,6 +56,10 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
>  
>  	if (h->kind == HOOK_TRADITIONAL)
>  		free((void *)h->u.traditional.path);
> +	else if (h->kind == HOOK_CONFIGURED) {
> +		free((void *)h->u.configured.friendly_name);
> +		free((void *)h->u.configured.command);
> +	}

The `if` branch also needs curly braces now.

> @@ -101,6 +107,187 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
>  	string_list_append(hook_list, hook_path)->util = h;
>  }
>  
> +static void unsorted_string_list_remove(struct string_list *list,
> +					const char *str)
> +{
> +	struct string_list_item *item = unsorted_string_list_lookup(list, str);
> +	if (item)
> +		unsorted_string_list_delete_item(list, item - list->items, 0);
> +}

This looks like a function that could reasonably be added to
"string-list.{c,h}".

> +/*
> + * Callback struct to collect all hook.* keys in a single config pass.
> + * commands: friendly-name to command map.
> + * event_hooks: event-name to list of friendly-names map.
> + * disabled_hooks: set of friendly-names with hook.name.enabled = false.
> + */
> +struct hook_all_config_cb {
> +	struct strmap commands;
> +	struct strmap event_hooks;

Hm, curious that we've got two maps. I'd have expected to have a single
map from "friendly name" or "hook name" to `struct hook`. But maybe
we'll assemble these structs for those maps later on.

> +	struct string_list disabled_hooks;
> +};

We don't have support for disabled hooks yet. I assume this'll be added
by a later commit, only.

[snip]
> +/* Populate `cache` with the complete hook configuration */
> +static void build_hook_config_map(struct repository *r, struct strmap *cache)
> +{
> +	struct hook_all_config_cb cb_data;
> +	struct hashmap_iter iter;
> +	struct strmap_entry *e;
> +
> +	strmap_init(&cb_data.commands);
> +	strmap_init(&cb_data.event_hooks);
> +	string_list_init_dup(&cb_data.disabled_hooks);
> +
> +	/* Parse all configs in one run. */
> +	repo_config(r, hook_config_lookup_all, &cb_data);
> +
> +	/* Construct the cache from parsed configs. */
> +	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
> +		struct string_list *hook_names = e->value;
> +		struct string_list *hooks = xcalloc(1, sizeof(*hooks));
> +
> +		string_list_init_dup(hooks);
> +
> +		for (size_t i = 0; i < hook_names->nr; i++) {
> +			const char *hname = hook_names->items[i].string;
> +			char *command;
> +
> +			command = strmap_get(&cb_data.commands, hname);
> +			if (!command)
> +				die(_("'hook.%s.command' must be configured or "
> +				      "'hook.%s.event' must be removed;"
> +				      " aborting."), hname, hname);
> +
> +			/* util stores the command; owned by the cache. */
> +			string_list_append(hooks, hname)->util =
> +				xstrdup(command);
> +		}
> +
> +		strmap_put(cache, e->key, hooks);
> +	}
> +
> +	strmap_clear(&cb_data.commands, 1);
> +	string_list_clear(&cb_data.disabled_hooks, 0);
> +	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
> +		string_list_clear(e->value, 0);
> +		free(e->value);
> +	}
> +	strmap_clear(&cb_data.event_hooks, 0);
> +}

Okay, this is where we assemble the hooks. Still curious that the result
isn't a `struct hook` for each configured hook.

[snip]
> +static void list_hooks_add_configured(struct repository *r,
> +				      const char *hookname,
> +				      struct string_list *list,
> +				      struct run_hooks_opt *options)
> +{
> +	struct strmap *cache = get_hook_config_cache(r);
> +	struct string_list *configured_hooks = strmap_get(cache, hookname);
> +
> +	/* Iterate through configured hooks and initialize internal states */
> +	for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {
> +		const char *friendly_name = configured_hooks->items[i].string;
> +		const char *command = configured_hooks->items[i].util;
> +		struct hook *hook = xcalloc(1, sizeof(struct hook));
> +
> +		if (options && options->feed_pipe_cb_data_alloc)
> +			hook->feed_pipe_cb_data =
> +				options->feed_pipe_cb_data_alloc(
> +					options->feed_pipe_ctx);
> +
> +		hook->kind = HOOK_CONFIGURED;
> +		hook->u.configured.friendly_name = xstrdup(friendly_name);
> +		hook->u.configured.command = xstrdup(command);
> +
> +		string_list_append(list, friendly_name)->util = hook;
> +	}
> +}

Okay, here we finally create the hook structures. Is there any specific
reason why we don't cache these structures directly?

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 5/8] hook: allow disabling config hooks
  2026-02-18 22:23   ` [PATCH v2 5/8] hook: allow disabling config hooks Adrian Ratiu
@ 2026-02-20 12:46     ` Patrick Steinhardt
  2026-02-20 14:47       ` Adrian Ratiu
  0 siblings, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-20 12:46 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, Feb 19, 2026 at 12:23:49AM +0200, Adrian Ratiu wrote:
> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
> index 9faafe3016..0cda4745a6 100644
> --- a/Documentation/config/hook.adoc
> +++ b/Documentation/config/hook.adoc
> @@ -13,3 +13,10 @@ hook.<name>.event::
>  	specified event, the associated `hook.<name>.command` is executed.
>  	This is a multi-valued key. To run `hook.<name>` on multiple
>  	events, specify the key more than once. See linkgit:git-hook[1].
> +
> +hook.<name>.enabled::
> +	Whether the hook `hook.<name>` is enabled. Defaults to `true`.
> +	Set to `false` to disable the hook without removing its
> +	configuration. This is particularly useful when a hook is defined
> +	in a system or global config file and needs to be disabled for a
> +	specific repository. See linkgit:git-hook[1].

Nice.

> diff --git a/hook.c b/hook.c
> index 8a9b405f76..35c24bf33d 100644
> --- a/hook.c
> +++ b/hook.c
> @@ -164,6 +164,21 @@ static int hook_config_lookup_all(const char *key, const char *value,
>  		char *old = strmap_put(&data->commands, hook_name,
>  				       xstrdup(value));
>  		free(old);
> +	} else if (!strcmp(subkey, "enabled")) {
> +		switch (git_parse_maybe_bool(value)) {
> +		case 0: /* disabled */
> +			if (!unsorted_string_list_lookup(&data->disabled_hooks,
> +							 hook_name))
> +				string_list_append(&data->disabled_hooks,
> +						   hook_name);
> +			break;
> +		case 1: /* enabled: undo a prior disabled entry */
> +			unsorted_string_list_remove(&data->disabled_hooks,
> +						    hook_name);
> +			break;
> +		default:
> +			break; /* ignore unrecognised values */
> +		}
>  	}

Somewhat similar to my preceding questions: why don't we store the
enabled state in the `struct hook` structure itself? Like that we can
for example even list disabled hooks in `git hooks list --disabled`, if
we ever wanted to do something like that.

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-19 21:34   ` [PATCH v2 0/8] Specify hooks via configs Junio C Hamano
@ 2026-02-20 12:51     ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 12:51 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:
> Adrian Ratiu <adrian.ratiu@collabora.com> writes:
>
>> v2 addresses all feedback received in v1.
>>
>> This series adds a new feature: the ability to specify commands to run
>> for hook events via config entries (including shell commands).
>>
>> So instead of dropping a shell script or a custom program in .git/hooks
>> you can now tell git via config files to run a program or shell script
>> (can be specified directly in the config) when you run hook "foo".
>>
>> This also means you can setup global hooks to run in multiple repos via
>> global configs and there's an option to disable them if necessary.
>>
>> For simplicity, because this series is becoming rather big, hooks are
>> still executed sequentially (.jobs == 1). Parallel execution is added
>> in another patch series.
>>
>> This is based on the latest v8 hooks-conversion series [1] which has
>> not yet landed in next or master.
>
> Thanks for a reroll.  I was a bit concerned to allow configuration
> files to speicify hooks as it would reduce discoverability (i.e.,
> today, we can "ls .git/hooks/" to see everything that potentially
> will be triggered, but now we need to be aware of what your sysadmin
> dropped in /etc/gitconfig to get the whole picture.  "git hook list"
> would solve that issue nicely.
>
> By the way, the discussion thread for the base topic hasn't seen
> any activity in the latest round after it updated for the comments
> received in the previous round.  It appears that it is ready to move
> forward?  Let's mark it for 'next' in that case.

Yes, thank you, landing that series will make testing this one easier.

I'm also working on v2 of the parallel series, will send it very soon.

Both will benefit from landing the base preparatory "hook conversion"
series.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks
  2026-02-20 12:45     ` Patrick Steinhardt
@ 2026-02-20 13:40       ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 13:40 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Thu, Feb 19, 2026 at 12:23:45AM +0200, Adrian Ratiu wrote:
>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
>> index 2d2b33d73d..f23772bc56 100644
>> --- a/builtin/receive-pack.c
>> +++ b/builtin/receive-pack.c
>> @@ -901,6 +901,26 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_
>>  	return state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */
>>  }
>>  
>> +static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)
>> +{
>> +	struct receive_hook_feed_state *init_state = feed_pipe_ctx;
>> +	struct receive_hook_feed_state *data = xcalloc(1, sizeof(*data));
>
> Tiny nit, not worth addressing: we often use `CALLOC_ARRAY(data, 1)`
> nowadays.

I'll fix this since I'm planning to do a v3 anyway. Thanks for the
pointer.

>
>> +	data->report = init_state->report;
>> +	data->cmd = init_state->cmd;
>> +	data->skip_broken = init_state->skip_broken;
>> +	strbuf_init(&data->buf, 0);
>> +	return data;
>> +}
>
> Okay, this basically creates the new instance by creating a deep copy of
> the "template" structure.
>
> One could split this up so that we have a "configuration" struct and a
> "data" struct, where we then provide a pointer to the configuration into
> the data structure, as only the buffer needs to change between the
> individual hook invocations. That would avoid some copying around, but
> it feels a bit unnecessary.

Yes, I also thought about this and reached the same conclusion.

>
>> +static void receive_hook_feed_state_free(void *data)
>> +{
>> +	struct receive_hook_feed_state *d = data;
>> +	if (!d)
>> +		return;
>> +	strbuf_release(&d->buf);
>> +	free(d);
>> +}
>
> I would expect that the hook interfaces know to not call `free()` in
> case `alloc()` wasn't called, but I guess it doesn't hurt to be
> defensive here anyway.

Agreed.

>> @@ -908,7 +928,7 @@ static int run_receive_hook(struct command *commands,
>>  {
>>  	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
>>  	struct command *iter = commands;
>> -	struct receive_hook_feed_state feed_state;
>> +	struct receive_hook_feed_state feed_init_state = { 0 };
>>  	struct async sideband_async;
>>  	int sideband_async_started = 0;
>>  	int saved_stderr = -1;
>> @@ -938,16 +958,15 @@ static int run_receive_hook(struct command *commands,
>>  	prepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started);
>>  
>>  	/* set up stdin callback */
>> -	feed_state.cmd = commands;
>> -	feed_state.skip_broken = skip_broken;
>> -	feed_state.report = NULL;
>> -	strbuf_init(&feed_state.buf, 0);
>> -	opt.feed_pipe_cb_data = &feed_state;
>> +	feed_init_state.cmd = commands;
>> +	feed_init_state.skip_broken = skip_broken;
>
> As far as I can see all of the data that we pass to the state struct is
> static, so we might just as well initialize it right away, right?
>
>     struct receive_hook_feed_state feed_init_state = {
>         .cmd = commands,
>         .skip_broken = skip_broken,
>         .buf = STRBUF_INIT,
>     };

I missed this. Thanks. Will do in v3.

>> diff --git a/hook.c b/hook.c
>> index cde7198412..83ff658866 100644
>> --- a/hook.c
>> +++ b/hook.c
>> @@ -133,6 +133,8 @@ static int notify_hook_finished(int result,
>>  
>>  static void run_hooks_opt_clear(struct run_hooks_opt *options)
>>  {
>> +	if (options->feed_pipe_cb_data_free)
>> +		options->feed_pipe_cb_data_free(options->feed_pipe_cb_data);
>>  	strvec_clear(&options->env);
>>  	strvec_clear(&options->args);
>>  }
>
> I guess this here would be where we could skip `free` in case the data
> wasn't even allocated. But as I said further up, I don't care all that
> much.

That is correct. I think I'll just drop that pointer check anyway in v3.

>> diff --git a/hook.h b/hook.h
>> index 20eb56fd63..a6bdc6f90f 100644
>> --- a/hook.h
>> +++ b/hook.h
>> @@ -5,6 +5,9 @@
>>  
>>  struct repository;
>>  
>> +typedef void (*cb_data_free_fn)(void *data);
>> +typedef void *(*cb_data_alloc_fn)(void *init_ctx);
>> +
>>  struct run_hooks_opt
>>  {
>>  	/* Environment vars to be set for each hook */
>
> Do we maybe want to scope these function typedefs to the hooks subsystem
> by calling the `hook_data_free_fn` and `hook_data_alloc_fn`, or
> something like that?

Good idea. I'll rename them in v3.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support
  2026-02-20 12:46     ` Patrick Steinhardt
@ 2026-02-20 13:51       ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 13:51 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Thu, Feb 19, 2026 at 12:23:46AM +0200, Adrian Ratiu wrote:
>> diff --git a/hook.c b/hook.c
>> index 83ff658866..c008a7232d 100644
>> --- a/hook.c
>> +++ b/hook.c
>> @@ -47,9 +47,97 @@ const char *find_hook(struct repository *r, const char *name)
>>  	return path.buf;
>>  }
>>  
>> +static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
>
> Nit: this should be called `hook_free()` as it also frees the struct
> itself.

Thanks. Junio also told me to prefer the _free() variants for functions
instead of _clear(), so I'll do that in v3.

I'll also carefully look so names match the semantics of the functions
I'm adding.

>> +{
>> +	if (!h)
>> +		return;
>> +
>> +	if (h->kind == HOOK_TRADITIONAL)
>> +		free((void *)h->u.traditional.path);
>> +
>> +	if (cb_data_free)
>> +		cb_data_free(h->feed_pipe_cb_data);
>> +
>> +	free(h);
>> +}
>> +
>> +static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
>> +{
>> +	struct string_list_item *item;
>> +
>> +	for_each_string_list_item(item, hooks)
>> +		hook_clear(item->util, cb_data_free);
>> +
>> +	string_list_clear(hooks, 0);
>
> We have `string_list_clear_func()` to perform a deep free for you.

Ack, will use it in v3.

>> +}
>> +
>> +/* Helper to detect and add default "traditional" hooks from the hookdir. */
>> +static void list_hooks_add_default(struct repository *r, const char *hookname,
>> +				   struct string_list *hook_list,
>> +				   struct run_hooks_opt *options)
>> +{
>> +	const char *hook_path = find_hook(r, hookname);
>> +	struct hook *h;
>> +
>> +	if (!hook_path)
>> +		return;
>> +
>> +	h = xcalloc(1, sizeof(struct hook));
>
> Better: CALLOC_ARRAY(h, 1);

Ack, will use it in v3.

>> +	/*
>> +	 * If the hook is to run in a specific dir, a relative path can
>> +	 * become invalid in that dir, so convert to an absolute path.
>> +	 */
>> +	if (options && options->dir)
>> +		hook_path = absolute_path(hook_path);
>> +
>> +	/* Setup per-hook internal state cb data */
>> +	if (options && options->feed_pipe_cb_data_alloc)
>> +		h->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
>> +
>> +	h->kind = HOOK_TRADITIONAL;
>> +	h->u.traditional.path = xstrdup(hook_path);
>> +
>> +	string_list_append(hook_list, hook_path)->util = h;
>> +}
>> +
>> +/*
>> + * Provides a list of hook commands to run for the 'hookname' event.
>> + *
>> + * This function consolidates hooks from two sources:
>> + * 1. The config-based hooks (not yet implemented).
>> + * 2. The "traditional" hook found in the repository hooks directory
>> + *    (e.g., .git/hooks/pre-commit).
>> + *
>> + * The list is ordered by execution priority.
>> + *
>> + * The caller is responsible for freeing the memory of the returned list
>> + * using string_list_clear() and free().
>> + */
>> +static struct string_list *list_hooks(struct repository *r, const char *hookname,
>> +			       struct run_hooks_opt *options)
>> +{
>> +	struct string_list *hook_head;
>> +
>> +	if (!hookname)
>> +		BUG("null hookname was provided to hook_list()!");
>> +
>> +	hook_head = xmalloc(sizeof(struct string_list));
>
> CALLOC_ARRAY(hook_head, 1);

Ack, will use it in v3.

>> @@ -85,21 +176,20 @@ static int pick_next_hook(struct child_process *cp,
>>  	cp->trace2_hook_name = hook_cb->hook_name;
>>  	cp->dir = hook_cb->options->dir;
>>  
>> -	strvec_push(&cp->args, hook_path);
>> +	/* Add hook exec paths or commands */
>> +	if (h->kind == HOOK_TRADITIONAL)
>> +		strvec_push(&cp->args, h->u.traditional.path);
>
> Should we be defensive here and have an `else BUG()`?

Yes, good catch. I think in another place where I verified this I
actually added a BUG.

Will add in v3.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 3/8] hook: add "git hook list" command
  2026-02-20 12:46     ` Patrick Steinhardt
@ 2026-02-20 13:53       ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 13:53 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Thu, Feb 19, 2026 at 12:23:47AM +0200, Adrian Ratiu wrote:
>> diff --git a/builtin/hook.c b/builtin/hook.c
>> index 7afec380d2..51660c4941 100644
>> --- a/builtin/hook.c
>> +++ b/builtin/hook.c
>> @@ -20,6 +24,61 @@ static const char * const builtin_hook_run_usage[] = {
>>  	NULL
>>  };
>>  
>> +static int list(int argc, const char **argv, const char *prefix,
>> +		 struct repository *repo)
>> +{
>> +	static const char *const builtin_hook_list_usage[] = {
>> +		BUILTIN_HOOK_LIST_USAGE,
>> +		NULL
>> +	};
>> +	struct string_list *head;
>> +	struct string_list_item *item;
>> +	const char *hookname = NULL;
>> +	int ret = 0;
>> +
>> +	struct option list_options[] = {
>> +		OPT_END(),
>> +	};
>> +
>> +	argc = parse_options(argc, argv, prefix, list_options,
>> +			     builtin_hook_list_usage, 0);
>> +
>> +	/*
>> +	 * The only unnamed argument provided should be the hook-name; if we add
>> +	 * arguments later they probably should be caught by parse_options.
>> +	 */
>> +	if (argc != 1)
>> +		usage_msg_opt(_("You must specify a hook event name to list."),
>> +			      builtin_hook_list_usage, list_options);
>
> Error messages typically start with a lower-case letter.

Ack, will fix.

>> +	hookname = argv[0];
>> +
>> +	head = list_hooks(repo, hookname, NULL);
>> +
>> +	if (!head->nr) {
>> +		warning(_("No hooks found for event '%s'"), hookname);
>
> Warnings, too.

Ack, will fix.

>> +		ret = 1; /* no hooks found */
>> +		goto cleanup;
>> +	}
>> +
>> +	for_each_string_list_item(item, head) {
>> +		struct hook *h = item->util;
>> +
>> +		switch (h->kind) {
>> +		case HOOK_TRADITIONAL:
>> +			printf("%s\n", _("hook from hookdir"));
>> +			break;
>> +		default:
>> +			BUG("unknown hook kind");
>> +		}
>
> Good that we're being defensive here.

Yes, I remembered correctly then, will add a BUG() to the other patch as
well.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 4/8] hook: include hooks from the config
  2026-02-20 12:46     ` Patrick Steinhardt
@ 2026-02-20 14:31       ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 14:31 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Thu, Feb 19, 2026 at 12:23:48AM +0200, Adrian Ratiu wrote:
>> diff --git a/builtin/hook.c b/builtin/hook.c
>> index 51660c4941..e151bb2cd1 100644
>> --- a/builtin/hook.c
>> +++ b/builtin/hook.c
>> @@ -54,6 +56,10 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
>>  
>>  	if (h->kind == HOOK_TRADITIONAL)
>>  		free((void *)h->u.traditional.path);
>> +	else if (h->kind == HOOK_CONFIGURED) {
>> +		free((void *)h->u.configured.friendly_name);
>> +		free((void *)h->u.configured.command);
>> +	}
>
> The `if` branch also needs curly braces now.

Ack, will fix.

>> @@ -101,6 +107,187 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
>>  	string_list_append(hook_list, hook_path)->util = h;
>>  }
>>  
>> +static void unsorted_string_list_remove(struct string_list *list,
>> +					const char *str)
>> +{
>> +	struct string_list_item *item = unsorted_string_list_lookup(list, str);
>> +	if (item)
>> +		unsorted_string_list_delete_item(list, item - list->items, 0);
>> +}
>
> This looks like a function that could reasonably be added to
> "string-list.{c,h}".

Yes, I'll move the helper there.

>> +/*
>> + * Callback struct to collect all hook.* keys in a single config pass.
>> + * commands: friendly-name to command map.
>> + * event_hooks: event-name to list of friendly-names map.
>> + * disabled_hooks: set of friendly-names with hook.name.enabled = false.
>> + */
>> +struct hook_all_config_cb {
>> +	struct strmap commands;
>> +	struct strmap event_hooks;
>
> Hm, curious that we've got two maps. I'd have expected to have a single
> map from "friendly name" or "hook name" to `struct hook`. But maybe
> we'll assemble these structs for those maps later on.
>

Exactly. I'll explain below.

>> +	struct string_list disabled_hooks;
>> +};
>
> We don't have support for disabled hooks yet. I assume this'll be added
> by a later commit, only.

Good catch.

I need to move this definition to the later commit.

>
> [snip]
>> +/* Populate `cache` with the complete hook configuration */
>> +static void build_hook_config_map(struct repository *r, struct strmap *cache)
>> +{
>> +	struct hook_all_config_cb cb_data;
>> +	struct hashmap_iter iter;
>> +	struct strmap_entry *e;
>> +
>> +	strmap_init(&cb_data.commands);
>> +	strmap_init(&cb_data.event_hooks);
>> +	string_list_init_dup(&cb_data.disabled_hooks);
>> +
>> +	/* Parse all configs in one run. */
>> +	repo_config(r, hook_config_lookup_all, &cb_data);
>> +
>> +	/* Construct the cache from parsed configs. */
>> +	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
>> +		struct string_list *hook_names = e->value;
>> +		struct string_list *hooks = xcalloc(1, sizeof(*hooks));
>> +
>> +		string_list_init_dup(hooks);
>> +
>> +		for (size_t i = 0; i < hook_names->nr; i++) {
>> +			const char *hname = hook_names->items[i].string;
>> +			char *command;
>> +
>> +			command = strmap_get(&cb_data.commands, hname);
>> +			if (!command)
>> +				die(_("'hook.%s.command' must be configured or "
>> +				      "'hook.%s.event' must be removed;"
>> +				      " aborting."), hname, hname);
>> +
>> +			/* util stores the command; owned by the cache. */
>> +			string_list_append(hooks, hname)->util =
>> +				xstrdup(command);
>> +		}
>> +
>> +		strmap_put(cache, e->key, hooks);
>> +	}
>> +
>> +	strmap_clear(&cb_data.commands, 1);
>> +	string_list_clear(&cb_data.disabled_hooks, 0);
>> +	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
>> +		string_list_clear(e->value, 0);
>> +		free(e->value);
>> +	}
>> +	strmap_clear(&cb_data.event_hooks, 0);
>> +}
>
> Okay, this is where we assemble the hooks. Still curious that the result
> isn't a `struct hook` for each configured hook.
>
> [snip]
>> +static void list_hooks_add_configured(struct repository *r,
>> +				      const char *hookname,
>> +				      struct string_list *list,
>> +				      struct run_hooks_opt *options)
>> +{
>> +	struct strmap *cache = get_hook_config_cache(r);
>> +	struct string_list *configured_hooks = strmap_get(cache, hookname);
>> +
>> +	/* Iterate through configured hooks and initialize internal states */
>> +	for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {
>> +		const char *friendly_name = configured_hooks->items[i].string;
>> +		const char *command = configured_hooks->items[i].util;
>> +		struct hook *hook = xcalloc(1, sizeof(struct hook));
>> +
>> +		if (options && options->feed_pipe_cb_data_alloc)
>> +			hook->feed_pipe_cb_data =
>> +				options->feed_pipe_cb_data_alloc(
>> +					options->feed_pipe_ctx);
>> +
>> +		hook->kind = HOOK_CONFIGURED;
>> +		hook->u.configured.friendly_name = xstrdup(friendly_name);
>> +		hook->u.configured.command = xstrdup(command);
>> +
>> +		string_list_append(list, friendly_name)->util = hook;
>> +	}
>> +}
>
> Okay, here we finally create the hook structures. Is there any specific
> reason why we don't cache these structures directly?

That is exactly what my first local cache implementation attempt did.

I doesn't work because:
1. struct hook contains the internal hook state which cannot be reused
   betwee hook calls (nor can it be cached).
2. struct hook requires the initialization ctx passed to
   feed_pipe_cb_data_alloc() which is different depending on the hook.

So 1 and 2 above make struct hook non-cacheable.

Maybe the following explains it better. :)

We have 3 different operations:

1. Parsing the config file once via hook_config_lookup_all()
2. Storing/reusing the config cache via build_hook_config_map()
3. Creating the struct hooks for execution via
   list_hooks_add_configured().

Caching can only work on the parsed config data, at step 2.
Hope this all makes sense.

Maybe we could rename these steps/functions to better reflect their
purpouse, or document this better somewhere in the source code?

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 5/8] hook: allow disabling config hooks
  2026-02-20 12:46     ` Patrick Steinhardt
@ 2026-02-20 14:47       ` Adrian Ratiu
  2026-02-20 18:40         ` Patrick Steinhardt
  0 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-20 14:47 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> On Thu, Feb 19, 2026 at 12:23:49AM +0200, Adrian Ratiu wrote:
>> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
>> index 9faafe3016..0cda4745a6 100644
>> --- a/Documentation/config/hook.adoc
>> +++ b/Documentation/config/hook.adoc
>> @@ -13,3 +13,10 @@ hook.<name>.event::
>>  	specified event, the associated `hook.<name>.command` is executed.
>>  	This is a multi-valued key. To run `hook.<name>` on multiple
>>  	events, specify the key more than once. See linkgit:git-hook[1].
>> +
>> +hook.<name>.enabled::
>> +	Whether the hook `hook.<name>` is enabled. Defaults to `true`.
>> +	Set to `false` to disable the hook without removing its
>> +	configuration. This is particularly useful when a hook is defined
>> +	in a system or global config file and needs to be disabled for a
>> +	specific repository. See linkgit:git-hook[1].
>
> Nice.
>
>> diff --git a/hook.c b/hook.c
>> index 8a9b405f76..35c24bf33d 100644
>> --- a/hook.c
>> +++ b/hook.c
>> @@ -164,6 +164,21 @@ static int hook_config_lookup_all(const char *key, const char *value,
>>  		char *old = strmap_put(&data->commands, hook_name,
>>  				       xstrdup(value));
>>  		free(old);
>> +	} else if (!strcmp(subkey, "enabled")) {
>> +		switch (git_parse_maybe_bool(value)) {
>> +		case 0: /* disabled */
>> +			if (!unsorted_string_list_lookup(&data->disabled_hooks,
>> +							 hook_name))
>> +				string_list_append(&data->disabled_hooks,
>> +						   hook_name);
>> +			break;
>> +		case 1: /* enabled: undo a prior disabled entry */
>> +			unsorted_string_list_remove(&data->disabled_hooks,
>> +						    hook_name);
>> +			break;
>> +		default:
>> +			break; /* ignore unrecognised values */
>> +		}
>>  	}
>
> Somewhat similar to my preceding questions: why don't we store the
> enabled state in the `struct hook` structure itself? Like that we can
> for example even list disabled hooks in `git hooks list --disabled`, if
> we ever wanted to do something like that.

The answer to the preceeding question is unfortunately no (I tried it),
however the answer to this question is YES.

I took the design decision to "filter" disabled hooks at config parsing
time, however I had no idea when doing it that we might want to run
something like "git hooks list --disabled".

I'll change this in v3 and cache the disabled config state instead of
filtering out disabled hooks completely.

This way we could still use their information as you suggested and we
could even enable/disable config hooks via git commands, however that is
yet-another-new-feature and this series is big enough and should be just
focused on adding the "base" functionality.

So in v3 I will cache the disabled state add the `git hooks list
--disabled` feature as well, but stop there. :)

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks
  2026-02-20 12:35       ` Adrian Ratiu
@ 2026-02-20 17:21         ` Junio C Hamano
  0 siblings, 0 replies; 69+ messages in thread
From: Junio C Hamano @ 2026-02-20 17:21 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

> On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:
>> Adrian Ratiu <adrian.ratiu@collabora.com> writes:
>>
>>> +	/*
>>> +	 * Ensure cb_data copy and free functions are either provided together,
>>> +	 * or neither one is provided.
>>> +	 */
>>> +	if ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||
>>> +	    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))
>>> +		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");
>>
>> A way to avoid being repetitious may be to say
>>
>> 	if (!!options->feed_pipe_cb_data_alloc != !!options->feed_pipe_cb_data_free)
>>
>> or
>>
>> 	if (!!options->feed_pipe_cb_data_alloc ^ !!options->feed_pipe_cb_data_free)
>>
>> but it (especially the latter) might be a bit too cute for some
>> people's taste.
>
> Thanks for suggesting this. I was actually thinking of ways to simplify
> this and the double negation didn't occur to me. Will do in v3.

Embarrassed.   You do not need !!; (!A != !B) would suffice.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 5/8] hook: allow disabling config hooks
  2026-02-20 14:47       ` Adrian Ratiu
@ 2026-02-20 18:40         ` Patrick Steinhardt
  2026-02-20 18:45           ` Junio C Hamano
  0 siblings, 1 reply; 69+ messages in thread
From: Patrick Steinhardt @ 2026-02-20 18:40 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Josh Steadmon,
	Kristoffer Haugsbakk

On Fri, Feb 20, 2026 at 04:47:13PM +0200, Adrian Ratiu wrote:
> On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:
> > On Thu, Feb 19, 2026 at 12:23:49AM +0200, Adrian Ratiu wrote:
> >> diff --git a/hook.c b/hook.c
> >> index 8a9b405f76..35c24bf33d 100644
> >> --- a/hook.c
> >> +++ b/hook.c
> >> @@ -164,6 +164,21 @@ static int hook_config_lookup_all(const char *key, const char *value,
> >>  		char *old = strmap_put(&data->commands, hook_name,
> >>  				       xstrdup(value));
> >>  		free(old);
> >> +	} else if (!strcmp(subkey, "enabled")) {
> >> +		switch (git_parse_maybe_bool(value)) {
> >> +		case 0: /* disabled */
> >> +			if (!unsorted_string_list_lookup(&data->disabled_hooks,
> >> +							 hook_name))
> >> +				string_list_append(&data->disabled_hooks,
> >> +						   hook_name);
> >> +			break;
> >> +		case 1: /* enabled: undo a prior disabled entry */
> >> +			unsorted_string_list_remove(&data->disabled_hooks,
> >> +						    hook_name);
> >> +			break;
> >> +		default:
> >> +			break; /* ignore unrecognised values */
> >> +		}
> >>  	}
> >
> > Somewhat similar to my preceding questions: why don't we store the
> > enabled state in the `struct hook` structure itself? Like that we can
> > for example even list disabled hooks in `git hooks list --disabled`, if
> > we ever wanted to do something like that.
> 
> The answer to the preceeding question is unfortunately no (I tried it),
> however the answer to this question is YES.
> 
> I took the design decision to "filter" disabled hooks at config parsing
> time, however I had no idea when doing it that we might want to run
> something like "git hooks list --disabled".
> 
> I'll change this in v3 and cache the disabled config state instead of
> filtering out disabled hooks completely.
> 
> This way we could still use their information as you suggested and we
> could even enable/disable config hooks via git commands, however that is
> yet-another-new-feature and this series is big enough and should be just
> focused on adding the "base" functionality.
> 
> So in v3 I will cache the disabled state add the `git hooks list
> --disabled` feature as well, but stop there. :)

Note that the "--disabled" flag was just a way to explain why I think
it's useful to track the disabled state explicitly. I didn't mean to say
that it needs to be added :)

Patrick

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 5/8] hook: allow disabling config hooks
  2026-02-20 18:40         ` Patrick Steinhardt
@ 2026-02-20 18:45           ` Junio C Hamano
  0 siblings, 0 replies; 69+ messages in thread
From: Junio C Hamano @ 2026-02-20 18:45 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: Adrian Ratiu, git, Jeff King, Emily Shaffer, Josh Steadmon,
	Kristoffer Haugsbakk

Patrick Steinhardt <ps@pks.im> writes:

> Note that the "--disabled" flag was just a way to explain why I think
> it's useful to track the disabled state explicitly. I didn't mean to say
> that it needs to be added :)

Yup.

If "hook list" (possibly with --verbose but there may not be any
need for multiple verbosity levels) shows which ones are available
but which ones are disabled, that would also be useful.  It would be
harder to arrange with the approach to filter early.

Thanks.



^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
                     ` (8 preceding siblings ...)
  2026-02-19 21:34   ` [PATCH v2 0/8] Specify hooks via configs Junio C Hamano
@ 2026-02-20 23:29   ` brian m. carlson
  2026-02-21 14:27     ` Adrian Ratiu
  9 siblings, 1 reply; 69+ messages in thread
From: brian m. carlson @ 2026-02-20 23:29 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk

[-- Attachment #1: Type: text/plain, Size: 1991 bytes --]

On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:
> Hello everyone,
> 
> v2 addresses all feedback received in v1.
> 
> This series adds a new feature: the ability to specify commands to run
> for hook events via config entries (including shell commands).
> 
> So instead of dropping a shell script or a custom program in .git/hooks
> you can now tell git via config files to run a program or shell script
> (can be specified directly in the config) when you run hook "foo".
> 
> This also means you can setup global hooks to run in multiple repos via
> global configs and there's an option to disable them if necessary.
> 
> For simplicity, because this series is becoming rather big, hooks are
> still executed sequentially (.jobs == 1). Parallel execution is added
> in another patch series.

I'm interested in how you plan to make parallel execution work
gracefully.

We've already established that it's necessary to preserve stdout and
stderr (including wiring them up to the TTY) so as to not break
existing, widely deployed hooks, such as those in Git LFS.  That means
that to get parallel execution where the hooks don't write over each
other's output and fight for the terminal, you'd need to multiplex each
one, including providing a PTY if the appropriate descriptor already has
a terminal, such that the output is at the very least handled line by
line and ideally batched into per-hook chunks.  Is that the plan, or do
you plan to do it differently?

I ask because situations where the hook output is not handled gracefully
and hooks fight over output or where the existence of TTY on a file
descriptor is not preserved will result in bug reports and broken tests
for tools that use Git, which I think we'd all like to avoid.

Of course, that's not in this series, so it may not even be written yet,
but if it's not, then this is something to keep in mind for when it gets
submitted.
-- 
brian m. carlson (they/them)
Toronto, Ontario, CA

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 262 bytes --]

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-20 23:29   ` brian m. carlson
@ 2026-02-21 14:27     ` Adrian Ratiu
  2026-02-22  0:39       ` Adrian Ratiu
  0 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-21 14:27 UTC (permalink / raw)
  To: brian m. carlson
  Cc: git, Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk

On Fri, 20 Feb 2026, "brian m. carlson" <sandals@crustytoothpaste.net> wrote:
> On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:
>> Hello everyone,
>> 
>> v2 addresses all feedback received in v1.
>> 
>> This series adds a new feature: the ability to specify commands to run
>> for hook events via config entries (including shell commands).
>> 
>> So instead of dropping a shell script or a custom program in .git/hooks
>> you can now tell git via config files to run a program or shell script
>> (can be specified directly in the config) when you run hook "foo".
>> 
>> This also means you can setup global hooks to run in multiple repos via
>> global configs and there's an option to disable them if necessary.
>> 
>> For simplicity, because this series is becoming rather big, hooks are
>> still executed sequentially (.jobs == 1). Parallel execution is added
>> in another patch series.
>
> I'm interested in how you plan to make parallel execution work
> gracefully.
>
> We've already established that it's necessary to preserve stdout and
> stderr (including wiring them up to the TTY) so as to not break
> existing, widely deployed hooks, such as those in Git LFS.  That means
> that to get parallel execution where the hooks don't write over each
> other's output and fight for the terminal, you'd need to multiplex each
> one, including providing a PTY if the appropriate descriptor already has
> a terminal, such that the output is at the very least handled line by
> line and ideally batched into per-hook chunks.  Is that the plan, or do
> you plan to do it differently?
>
> I ask because situations where the hook output is not handled gracefully
> and hooks fight over output or where the existence of TTY on a file
> descriptor is not preserved will result in bug reports and broken tests
> for tools that use Git, which I think we'd all like to avoid.

Hi Brian,

Yes, this is all done already. Phillip Wood actually brought this TTY
issue up in his review of the v1 parallel hooks series (many thanks). :) 


> Of course, that's not in this series, so it may not even be written yet,
> but if it's not, then this is something to keep in mind for when it gets
> submitted.

Yes, that's a separate series [1] and I'm about to send v2 very soon.

Please review v2 directly when I send it because it will contain
significant changes from v1. v1 is not worth reviewing at this point.

To give you a high level description:

All hooks continue to run sequentially (serialized) by default, just
like before.

To run some of the hooks in parallel, I had to introduce an extension,
because we need to break backwards compatibility by combining stdout and
stderr and piping them through run-command's muxer, detached from the
terminal. There is only 1 known hook requiring this extension (pre-push)
but it's trivial to add more, if necessary.

Patrick's idea is to leave it up to the user to decide what to
parallelize, because the user knows if their hooks are safe or not (eg 
if they write the same file or call the same program), or if it's ok the
enable the extension or not.

Some hooks are known never to be safe to parallelize, in that case git
will always enforce serial execution.

Please wait for v2 of that series, it's my top priority to get it out,
Adrian

1:
https://lore.kernel.org/git/20260204173328.1601807-1-adrian.ratiu@collabora.com/n

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-21 14:27     ` Adrian Ratiu
@ 2026-02-22  0:39       ` Adrian Ratiu
  2026-02-25 18:37         ` Junio C Hamano
  2026-02-25 22:30         ` brian m. carlson
  0 siblings, 2 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-22  0:39 UTC (permalink / raw)
  To: brian m. carlson; +Cc: git

On Sat, 21 Feb 2026, Adrian Ratiu <adrian.ratiu@collabora.com> wrote:
> On Fri, 20 Feb 2026, "brian m. carlson" <sandals@crustytoothpaste.net> wrote:
>> On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:
>>> Hello everyone,
>>> 
>>> v2 addresses all feedback received in v1.
>>> 
>>> This series adds a new feature: the ability to specify commands to run
>>> for hook events via config entries (including shell commands).
>>> 
>>> So instead of dropping a shell script or a custom program in .git/hooks
>>> you can now tell git via config files to run a program or shell script
>>> (can be specified directly in the config) when you run hook "foo".
>>> 
>>> This also means you can setup global hooks to run in multiple repos via
>>> global configs and there's an option to disable them if necessary.
>>> 
>>> For simplicity, because this series is becoming rather big, hooks are
>>> still executed sequentially (.jobs == 1). Parallel execution is added
>>> in another patch series.
>>
>> I'm interested in how you plan to make parallel execution work
>> gracefully.
>>
>> We've already established that it's necessary to preserve stdout and
>> stderr (including wiring them up to the TTY) so as to not break
>> existing, widely deployed hooks, such as those in Git LFS.  That means
>> that to get parallel execution where the hooks don't write over each
>> other's output and fight for the terminal, you'd need to multiplex each
>> one, including providing a PTY if the appropriate descriptor already has
>> a terminal, such that the output is at the very least handled line by
>> line and ideally batched into per-hook chunks.  Is that the plan, or do
>> you plan to do it differently?
>>
>> I ask because situations where the hook output is not handled gracefully
>> and hooks fight over output or where the existence of TTY on a file
>> descriptor is not preserved will result in bug reports and broken tests
>> for tools that use Git, which I think we'd all like to avoid.
>
> Hi Brian,
>
> Yes, this is all done already. Phillip Wood actually brought this TTY
> issue up in his review of the v1 parallel hooks series (many thanks). :) 
>
>
>> Of course, that's not in this series, so it may not even be written yet,
>> but if it's not, then this is something to keep in mind for when it gets
>> submitted.
>
> Yes, that's a separate series [1] and I'm about to send v2 very soon.
>
> Please review v2 directly when I send it because it will contain
> significant changes from v1. v1 is not worth reviewing at this point.
>
> To give you a high level description:
>
> All hooks continue to run sequentially (serialized) by default, just
> like before.
>
> To run some of the hooks in parallel, I had to introduce an extension,
> because we need to break backwards compatibility by combining stdout and
> stderr and piping them through run-command's muxer, detached from the
> terminal. There is only 1 known hook requiring this extension (pre-push)
> but it's trivial to add more, if necessary.
>
> Patrick's idea is to leave it up to the user to decide what to
> parallelize, because the user knows if their hooks are safe or not (eg 
> if they write the same file or call the same program), or if it's ok the
> enable the extension or not.
>
> Some hooks are known never to be safe to parallelize, in that case git
> will always enforce serial execution.
>
> Please wait for v2 of that series, it's my top priority to get it out,
> Adrian
>
> 1:
> https://lore.kernel.org/git/20260204173328.1601807-1-adrian.ratiu@collabora.com/n

Hi again Brian,

v2 of the parallel series is out if you want to review it:

https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u

P.S. I think your spam filter is blocking all my e-mails? I get this
reply from you:

 sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1
 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:
 CONN:SPAM

   Original-Recipient: rfc822; sandals@crustytoothpaste.net
   Final-Recipient: rfc822; sandals@crustytoothpaste.net
   Status: 554
   Action: failed
   Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT
   Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM
   Remote-MTA: dns; complex.crustytoothpaste.net

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-22  0:39       ` Adrian Ratiu
@ 2026-02-25 18:37         ` Junio C Hamano
  2026-02-26 12:21           ` Adrian Ratiu
  2026-02-25 22:30         ` brian m. carlson
  1 sibling, 1 reply; 69+ messages in thread
From: Junio C Hamano @ 2026-02-25 18:37 UTC (permalink / raw)
  To: Adrian Ratiu; +Cc: brian m. carlson, git

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

> On Sat, 21 Feb 2026, Adrian Ratiu <adrian.ratiu@collabora.com> wrote:
>> On Fri, 20 Feb 2026, "brian m. carlson" <sandals@crustytoothpaste.net> wrote:
>>> On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:
>>>> Hello everyone,
>>>> 
>>>> v2 addresses all feedback received in v1.
>>>
>>> I ask because situations where the hook output is not handled gracefully
>>> and hooks fight over output or where the existence of TTY on a file
>>> descriptor is not preserved will result in bug reports and broken tests
>>> for tools that use Git, which I think we'd all like to avoid.
>>
>> Hi Brian,
>>
>> Yes, this is all done already. Phillip Wood actually brought this TTY
>> issue up in his review of the v1 parallel hooks series (many thanks). :) 
>> ...
>>
> Hi again Brian,
>
> v2 of the parallel series is out if you want to review it:
>
> https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u
>
> P.S. I think your spam filter is blocking all my e-mails? I get this
> reply from you:
>
>  sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1
>  <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:
>  CONN:SPAM
>
>    Original-Recipient: rfc822; sandals@crustytoothpaste.net
>    Final-Recipient: rfc822; sandals@crustytoothpaste.net
>    Status: 554
>    Action: failed
>    Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT
>    Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM
>    Remote-MTA: dns; complex.crustytoothpaste.net

So, shall we make this "hooks specified by config" advance, while
expecting the parallelized execution to further evolve as a follow
up series that will still be out of 'next' for now?

Thanks.


^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-22  0:39       ` Adrian Ratiu
  2026-02-25 18:37         ` Junio C Hamano
@ 2026-02-25 22:30         ` brian m. carlson
  2026-02-26 12:41           ` Adrian Ratiu
  1 sibling, 1 reply; 69+ messages in thread
From: brian m. carlson @ 2026-02-25 22:30 UTC (permalink / raw)
  To: Adrian Ratiu; +Cc: git

[-- Attachment #1: Type: text/plain, Size: 1543 bytes --]

On 2026-02-22 at 00:39:04, Adrian Ratiu wrote:
> Hi again Brian,
> 
> v2 of the parallel series is out if you want to review it:
> 
> https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u

Thanks, I'll take a look either today or a little later this week.

> P.S. I think your spam filter is blocking all my e-mails? I get this
> reply from you:
> 
>  sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1
>  <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:
>  CONN:SPAM
> 
>    Original-Recipient: rfc822; sandals@crustytoothpaste.net
>    Final-Recipient: rfc822; sandals@crustytoothpaste.net
>    Status: 554
>    Action: failed
>    Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT
>    Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM

Yes, this is because one of Zoho's customers sent me spam and they
didn't act on the spam complaint in a timely manner.  I've removed that
block[0] and we'll see if they've fixed that in the past six years or
so.  If not, I'll re-block them and you can have their postmaster reach
out to me at my postmaster address to discuss things further.

I regret that this is necessary, but unfortunately when you run your own
mail server, you have to deal with all the abuse yourself and many
companies choose to ignore abuse complaints.

[0] It may take up to thirty minutes for Puppet to update the mail
server configuration.
-- 
brian m. carlson (they/them)
Toronto, Ontario, CA

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 262 bytes --]

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-25 18:37         ` Junio C Hamano
@ 2026-02-26 12:21           ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-26 12:21 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: brian m. carlson, git

On Wed, 25 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:
> Adrian Ratiu <adrian.ratiu@collabora.com> writes:
>
>> On Sat, 21 Feb 2026, Adrian Ratiu <adrian.ratiu@collabora.com> wrote:
>>> On Fri, 20 Feb 2026, "brian m. carlson" <sandals@crustytoothpaste.net> wrote:
>>>> On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:
>>>>> Hello everyone,
>>>>> 
>>>>> v2 addresses all feedback received in v1.
>>>>
>>>> I ask because situations where the hook output is not handled gracefully
>>>> and hooks fight over output or where the existence of TTY on a file
>>>> descriptor is not preserved will result in bug reports and broken tests
>>>> for tools that use Git, which I think we'd all like to avoid.
>>>
>>> Hi Brian,
>>>
>>> Yes, this is all done already. Phillip Wood actually brought this TTY
>>> issue up in his review of the v1 parallel hooks series (many thanks). :) 
>>> ...
>>>
>> Hi again Brian,
>>
>> v2 of the parallel series is out if you want to review it:
>>
>> https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u
>>
>> P.S. I think your spam filter is blocking all my e-mails? I get this
>> reply from you:
>>
>>  sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1
>>  <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:
>>  CONN:SPAM
>>
>>    Original-Recipient: rfc822; sandals@crustytoothpaste.net
>>    Final-Recipient: rfc822; sandals@crustytoothpaste.net
>>    Status: 554
>>    Action: failed
>>    Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT
>>    Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM
>>    Remote-MTA: dns; complex.crustytoothpaste.net
>
> So, shall we make this "hooks specified by config" advance, while
> expecting the parallelized execution to further evolve as a follow
> up series that will still be out of 'next' for now?

Yes, that is correct.

These are independent patch series which just happen to depend one upon
the other. Other than that, they are completely separate and should be
reviewed independently, each on its own merits.

(When you have some time please review v2 of the parallel series.)

I do plan to send v3 of this config series, then v3 of the parallel
series after it gathers feedback.

They do not need to evolve in tandem, btw.

We can even put the parallel series "on pause" until this config series
is done and lands, if it makes things easier.

Thanks,
Adrian

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v2 0/8] Specify hooks via configs
  2026-02-25 22:30         ` brian m. carlson
@ 2026-02-26 12:41           ` Adrian Ratiu
  0 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-02-26 12:41 UTC (permalink / raw)
  To: brian m. carlson; +Cc: git

On Wed, 25 Feb 2026, "brian m. carlson" <sandals@crustytoothpaste.net> wrote:
> On 2026-02-22 at 00:39:04, Adrian Ratiu wrote:
>> Hi again Brian,
>> 
>> v2 of the parallel series is out if you want to review it:
>> 
>> https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u
>
> Thanks, I'll take a look either today or a little later this week.

Much appreciated. I'm in no rush, as I mentioned to Junio we could even
put the parallel series on pause until we finish and land this config
series, which is its dependency.

It's still good to have all the code out, though, and I do intend to
periodically rebase the parallel series on this one.

>> P.S. I think your spam filter is blocking all my e-mails? I get this
>> reply from you:
>> 
>>  sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1
>>  <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:
>>  CONN:SPAM
>> 
>>    Original-Recipient: rfc822; sandals@crustytoothpaste.net
>>    Final-Recipient: rfc822; sandals@crustytoothpaste.net
>>    Status: 554
>>    Action: failed
>>    Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT
>>    Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM
>
> Yes, this is because one of Zoho's customers sent me spam and they
> didn't act on the spam complaint in a timely manner.  I've removed that
> block[0] and we'll see if they've fixed that in the past six years or
> so.  If not, I'll re-block them and you can have their postmaster reach
> out to me at my postmaster address to discuss things further.
>
> I regret that this is necessary, but unfortunately when you run your own
> mail server, you have to deal with all the abuse yourself and many
> companies choose to ignore abuse complaints.

Thanks, much appreciated.

Collabora also runs an internal mail-server behind a VPN, so if Zoho
continues being a problem, I'll just switch to sending mails through
that server. I mostly use Zoho to avoid dealing with the VPN. :)

^ permalink raw reply	[flat|nested] 69+ messages in thread

* [PATCH v3 00/12][next] Specify hooks via configs
  2026-02-04 16:51 [PATCH 0/4] Specify hooks via configs Adrian Ratiu
                   ` (5 preceding siblings ...)
  2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
@ 2026-03-01 18:44 ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 01/12] hook: add internal state alloc/free callbacks Adrian Ratiu
                     ` (12 more replies)
  6 siblings, 13 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Hello everyone,

v3 addresses all feedback received in v2 (details below, including range-diff).

This series adds a new feature: the ability to specify commands to run
for hook events via config entries (including shell commands).

So instead of dropping a shell script or a custom program in .git/hooks
you can now tell git via config files to run a program or shell script
(can be specified directly in the config) when you run hook "foo".

This also means you can setup global hooks to run in multiple repos via
global configs and there's an option to disable them if necessary.

For simplicity, because this series is becoming rather big, hooks are
still executed sequentially (.jobs == 1). Parallel execution is added
in another follow-up patch series.

This is based on the latest next branch because it depends on some
commits which haven't yet landed in master.

Branch pused to GitHub: [1]
Succesful CI run: [2]

Many thanks to all who contributed to this effort up to now, including
Emily, AEvar, Junio, Patrick, Peff, Kristoffer, Chris and many others.

Thank you,
Adrian

1: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/config-hooks-v3
2: https://github.com/10ne1/git/actions/runs/22549045872

Changes in v3:
* Rebased on next (no conflicts) because the hook dep series landed (Junio)
* Simplify cb data alloc/free function check to use the form (!A != !B) (Junio)
* New commit: `git hook list` lists disabled hooks (Patrick, Junio)
* New commit: store hook config scope and list it with git hook list (Junio)
* New commit: introduce struct hook_config_cache_entry eariler; this used to
  be done in the parallel hooks series, however we need it earlier in this
  for the  config scope + disabled hooks caching and listing (Junio, Patrick)
* Move disable_hooks def to the commit which introduces the feature (Patrick)
* Add comment about alloc/free cb data ownership to hook.h (Adrian)
* Replaced xcalloc() and xmalloc() with CALLOC_ARRAY() in all patches (Patrick)
* Static initialize struct receive_hook_feed_state in receive hook (Patrick)
* Rename cb_data_free/alloc() -> hook_data_free/alloc() callbacks (Patrick)
* Rename hook_clear() -> hook_free() (Patrick)
* Move unsorted_string_list_remove() helper to string-list.{c,h} (Patrick)
* Always prefer the free() variants instead of clear() (Junio)
* Use the standard string_list_clear_func() instead of my own hook_list_clear(),
  to do this I had to store a pointer to the free() cb in struct hook. (Patrick)
* Drop the use of hook_cache_clear(), it should have been hook_cache_free(),
  however it's not necessary after the above string_list_clear_func() (Junio)
* Trigger a BUG() when a hook type is unknown in pick_next_hook() (Patrick)
* Replaced hook.<name>.* -> hook.<friendly-name>.* (Junio)
* Removed unnecessary abspath.h include addition in builtin/hook.c (Adrian)
* Added more tests for various configured hooks corner-cases (Adrian)
* Minor typos and style fixes (Patrick)

 1:  6fe0e2eea4 !  1:  4c9e2b4c95 hook: add internal state alloc/free callbacks
    @@ builtin/receive-pack.c: static int feed_receive_hook_cb(int hook_stdin_fd, void
     +static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)
     +{
     +	struct receive_hook_feed_state *init_state = feed_pipe_ctx;
    -+	struct receive_hook_feed_state *data = xcalloc(1, sizeof(*data));
    ++	struct receive_hook_feed_state *data;
    ++	CALLOC_ARRAY(data, 1);
     +	data->report = init_state->report;
     +	data->cmd = init_state->cmd;
     +	data->skip_broken = init_state->skip_broken;
    @@ builtin/receive-pack.c: static int run_receive_hook(struct command *commands,
      	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
      	struct command *iter = commands;
     -	struct receive_hook_feed_state feed_state;
    -+	struct receive_hook_feed_state feed_init_state = { 0 };
    ++	struct receive_hook_feed_state feed_init_state = {
    ++		.cmd = commands,
    ++		.skip_broken = skip_broken,
    ++		.buf = STRBUF_INIT,
    ++	};
      	struct async sideband_async;
      	int sideband_async_started = 0;
      	int saved_stderr = -1;
    @@ builtin/receive-pack.c: static int run_receive_hook(struct command *commands,
     -	feed_state.report = NULL;
     -	strbuf_init(&feed_state.buf, 0);
     -	opt.feed_pipe_cb_data = &feed_state;
    -+	feed_init_state.cmd = commands;
    -+	feed_init_state.skip_broken = skip_broken;
     +	opt.feed_pipe_ctx = &feed_init_state;
      	opt.feed_pipe = feed_receive_hook_cb;
     +	opt.feed_pipe_cb_data_alloc = receive_hook_feed_state_alloc;
    @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,
     +	 * Ensure cb_data copy and free functions are either provided together,
     +	 * or neither one is provided.
     +	 */
    -+	if ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||
    -+	    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))
    ++	if (!options->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free)
     +		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");
     +
     +	if (options->feed_pipe_cb_data_alloc)
    @@ hook.h
      
      struct repository;
      
    -+typedef void (*cb_data_free_fn)(void *data);
    -+typedef void *(*cb_data_alloc_fn)(void *init_ctx);
    ++typedef void (*hook_data_free_fn)(void *data);
    ++typedef void *(*hook_data_alloc_fn)(void *init_ctx);
     +
      struct run_hooks_opt
      {
    @@ hook.h: struct run_hooks_opt
     +	 * If provided, this function will be called to alloc & initialize the
     +	 * `feed_pipe_cb_data` for each hook.
     +	 *
    ++	 * The caller must provide a `feed_pipe_cb_data_free` callback to free
    ++	 * this memory (missing callback will trigger a bug). Use only the cb to
    ++	 * free the memory, do not free it manually in the caller.
    ++	 *
     +	 * The `feed_pipe_ctx` pointer can be used to pass initialization data.
     +	 */
    -+	cb_data_alloc_fn feed_pipe_cb_data_alloc;
    ++	hook_data_alloc_fn feed_pipe_cb_data_alloc;
     +
     +	/**
     +	 * Called to free the memory initialized by `feed_pipe_cb_data_alloc`.
     +	 *
     +	 * Must always be provided when `feed_pipe_cb_data_alloc` is provided.
     +	 */
    -+	cb_data_free_fn feed_pipe_cb_data_free;
    ++	hook_data_free_fn feed_pipe_cb_data_free;
      };
      
      #define RUN_HOOKS_OPT_INIT { \
    @@ refs.c: static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, v
      
     +static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED)
     +{
    -+	struct transaction_feed_cb_data *data = xmalloc(sizeof(*data));
    ++	struct transaction_feed_cb_data *data;
    ++	CALLOC_ARRAY(data, 1);
     +	strbuf_init(&data->buf, 0);
     +	data->index = 0;
     +	return data;
    @@ transport.c: static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb
      
     +static void *pre_push_hook_data_alloc(void *feed_pipe_ctx)
     +{
    -+	struct feed_pre_push_hook_data *data = xmalloc(sizeof(*data));
    ++	struct feed_pre_push_hook_data *data;
    ++	CALLOC_ARRAY(data, 1);
     +	strbuf_init(&data->buf, 0);
     +	data->refs = (struct ref *)feed_pipe_ctx;
     +	return data;
 2:  2917d45a19 !  2:  d1579a4435 hook: run a list of hooks to prepare for multihook support
    @@ hook.c: const char *find_hook(struct repository *r, const char *name)
      	return path.buf;
      }
      
    -+static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
    ++/*
    ++ * Frees a struct hook stored as the util pointer of a string_list_item.
    ++ * Suitable for use as a string_list_clear_func_t callback.
    ++ */
    ++static void hook_free(void *p, const char *str UNUSED)
     +{
    ++	struct hook *h = p;
    ++
     +	if (!h)
     +		return;
     +
     +	if (h->kind == HOOK_TRADITIONAL)
     +		free((void *)h->u.traditional.path);
     +
    -+	if (cb_data_free)
    -+		cb_data_free(h->feed_pipe_cb_data);
    ++	if (h->data_free)
    ++		h->data_free(h->feed_pipe_cb_data);
     +
     +	free(h);
     +}
     +
    -+static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
    -+{
    -+	struct string_list_item *item;
    -+
    -+	for_each_string_list_item(item, hooks)
    -+		hook_clear(item->util, cb_data_free);
    -+
    -+	string_list_clear(hooks, 0);
    -+}
    -+
     +/* Helper to detect and add default "traditional" hooks from the hookdir. */
     +static void list_hooks_add_default(struct repository *r, const char *hookname,
     +				   struct string_list *hook_list,
    @@ hook.c: const char *find_hook(struct repository *r, const char *name)
     +	if (!hook_path)
     +		return;
     +
    -+	h = xcalloc(1, sizeof(struct hook));
    ++	CALLOC_ARRAY(h, 1);
     +
     +	/*
     +	 * If the hook is to run in a specific dir, a relative path can
    @@ hook.c: const char *find_hook(struct repository *r, const char *name)
     +	/* Setup per-hook internal state cb data */
     +	if (options && options->feed_pipe_cb_data_alloc)
     +		h->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
    ++	if (options)
    ++		h->data_free = options->feed_pipe_cb_data_free;
     +
     +	h->kind = HOOK_TRADITIONAL;
     +	h->u.traditional.path = xstrdup(hook_path);
    @@ hook.c: const char *find_hook(struct repository *r, const char *name)
     +	if (!hookname)
     +		BUG("null hookname was provided to hook_list()!");
     +
    -+	hook_head = xmalloc(sizeof(struct string_list));
    ++	CALLOC_ARRAY(hook_head, 1);
     +	string_list_init_dup(hook_head);
     +
     +	/* Add the default "traditional" hooks from hookdir. */
    @@ hook.c: const char *find_hook(struct repository *r, const char *name)
     -	return !!find_hook(r, name);
     +	struct string_list *hooks = list_hooks(r, name, NULL);
     +	int exists = hooks->nr > 0;
    -+	hook_list_clear(hooks, NULL);
    ++	string_list_clear_func(hooks, hook_free);
     +	free(hooks);
     +	return exists;
      }
    @@ hook.c: static int pick_next_hook(struct child_process *cp,
     +	/* Add hook exec paths or commands */
     +	if (h->kind == HOOK_TRADITIONAL)
     +		strvec_push(&cp->args, h->u.traditional.path);
    ++	else
    ++		BUG("unknown hook kind");
     +
     +	if (!cp->args.nr)
     +		BUG("hook must have at least one command or exec path");
    @@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options)
      	const struct run_process_parallel_opts opts = {
      		.tr2_category = "hook",
     @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,
    - 	    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))
    + 	if (!options->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free)
      		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");
      
     -	if (options->feed_pipe_cb_data_alloc)
    @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,
      		*options->invoked_hook = 0;
      
     -	if (!hook_path && !options->error_if_missing)
    --		goto cleanup;
    --
    --	if (!hook_path) {
    --		ret = error("cannot find a hook named %s", hook_name);
     +	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);
      		goto cleanup;
    - 	}
    - 
    +-
    +-	if (!hook_path) {
    +-		ret = error("cannot find a hook named %s", hook_name);
    +-		goto cleanup;
    +-	}
    +-
     -	cb_data.hook_path = hook_path;
     -	if (options->dir) {
     -		strbuf_add_absolute_path(&abs_path, hook_path);
     -		cb_data.hook_path = abs_path.buf;
    --	}
    --
    + 	}
    + 
      	run_processes_parallel(&opts);
      	ret = cb_data.rc;
      cleanup:
     -	strbuf_release(&abs_path);
    -+	hook_list_clear(cb_data.hook_command_list, options->feed_pipe_cb_data_free);
    ++	string_list_clear_func(cb_data.hook_command_list, hook_free);
     +	free(cb_data.hook_command_list);
      	run_hooks_opt_clear(options);
      	return ret;
    @@ hook.h
      
      struct repository;
      
    + typedef void (*hook_data_free_fn)(void *data);
    + typedef void *(*hook_data_alloc_fn)(void *init_ctx);
    + 
     +/**
     + * Represents a hook command to be run.
     + * Hooks can be:
    @@ hook.h
     +	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
     +	 */
     +	void *feed_pipe_cb_data;
    ++
    ++	/**
    ++	 * Callback to free `feed_pipe_cb_data`.
    ++	 *
    ++	 * It is called automatically and points to the `feed_pipe_cb_data_free`
    ++	 * provided via the `run_hook_opt` parameter.
    ++	 */
    ++	hook_data_free_fn data_free;
     +};
     +
    - typedef void (*cb_data_free_fn)(void *data);
    - typedef void *(*cb_data_alloc_fn)(void *init_ctx);
    - 
    + struct run_hooks_opt
    + {
    + 	/* Environment vars to be set for each hook */
     @@ hook.h: struct run_hooks_opt
      	 */
      	void *feed_pipe_ctx;
    @@ hook.h: struct hook_cb_data {
      	struct run_hooks_opt *options;
      };
      
    +-/*
    ++/**
    +  * Returns the path to the hook file, or NULL if the hook is missing
    +  * or disabled. Note that this points to static storage that will be
    +  * overwritten by further calls to find_hook and run_hook_*.
 3:  19d41e85e1 !  3:  b2ab0d3736 hook: add "git hook list" command
    @@ Documentation/git-hook.adoc: Any positional arguments to the hook should be pass
     
      ## builtin/hook.c ##
     @@
    - #include "hook.h"
    - #include "parse-options.h"
    - #include "strvec.h"
    -+#include "abspath.h"
      
      #define BUILTIN_HOOK_RUN_USAGE \
      	N_("git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {
     +	 * arguments later they probably should be caught by parse_options.
     +	 */
     +	if (argc != 1)
    -+		usage_msg_opt(_("You must specify a hook event name to list."),
    ++		usage_msg_opt(_("you must specify a hook event name to list."),
     +			      builtin_hook_list_usage, list_options);
     +
     +	hookname = argv[0];
    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {
     +	head = list_hooks(repo, hookname, NULL);
     +
     +	if (!head->nr) {
    -+		warning(_("No hooks found for event '%s'"), hookname);
    ++		warning(_("no hooks found for event '%s'"), hookname);
     +		ret = 1; /* no hooks found */
     +		goto cleanup;
     +	}
    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {
     +	}
     +
     +cleanup:
    -+	hook_list_clear(head, NULL);
    ++	string_list_clear_func(head, hook_free);
     +	free(head);
     +	return ret;
     +}
    @@ builtin/hook.c: int cmd_hook(int argc,
      
     
      ## hook.c ##
    -@@ hook.c: static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
    - 	free(h);
    +@@ hook.c: const char *find_hook(struct repository *r, const char *name)
    + 	return path.buf;
      }
      
    --static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
    -+void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)
    +-/*
    +- * Frees a struct hook stored as the util pointer of a string_list_item.
    +- * Suitable for use as a string_list_clear_func_t callback.
    +- */
    +-static void hook_free(void *p, const char *str UNUSED)
    ++void hook_free(void *p, const char *str UNUSED)
      {
    - 	struct string_list_item *item;
    + 	struct hook *h = p;
      
     @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,
      	string_list_append(hook_list, hook_path)->util = h;
    @@ hook.h: struct hook_cb_data {
      	struct run_hooks_opt *options;
      };
      
    --/*
    ++/**
    ++ * Frees a struct hook stored as the util pointer of a string_list_item.
    ++ * Suitable for use as a string_list_clear_func_t callback.
    ++ */
    ++void hook_free(void *p, const char *str);
    ++
     +/**
     + * Provides a list of hook commands to run for the 'hookname' event.
     + *
    @@ hook.h: struct hook_cb_data {
     +struct string_list *list_hooks(struct repository *r, const char *hookname,
     +			       struct run_hooks_opt *options);
     +
    -+/**
    -+ * Frees the memory allocated for the hook list, including the `struct hook`
    -+ * items and their internal state.
    -+ */
    -+void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);
    -+
    -+/**
    + /**
       * Returns the path to the hook file, or NULL if the hook is missing
       * or disabled. Note that this points to static storage that will be
    -  * overwritten by further calls to find_hook and run_hook_*.
     
      ## t/t1800-hook.sh ##
     @@ t/t1800-hook.sh: test_expect_success 'git hook usage' '
    @@ t/t1800-hook.sh: test_expect_success 'git hook usage' '
      
     +test_expect_success 'git hook list: nonexistent hook' '
     +	cat >stderr.expect <<-\EOF &&
    -+	warning: No hooks found for event '\''test-hook'\''
    ++	warning: no hooks found for event '\''test-hook'\''
     +	EOF
     +	test_expect_code 1 git hook list test-hook 2>stderr.actual &&
     +	test_cmp stderr.expect stderr.actual
 -:  ---------- >  4:  818bee4066 string-list: add unsorted_string_list_remove()
 4:  0c98b18bf5 !  5:  1ed0264a9f hook: include hooks from the config
    @@ Commit message
         "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"
    +    Hooks will be started in config order of the "hook.<friendly-name>.event"
         lines and will be run sequentially (.jobs == 1) like before.
         Running the hooks in parallel will be enabled in a future patch.
     
    @@ Commit message
     
      ## Documentation/config/hook.adoc (new) ##
     @@
    -+hook.<name>.command::
    -+	The command to execute for `hook.<name>`. `<name>` is a unique
    ++hook.<friendly-name>.command::
    ++	The command to execute for `hook.<friendly-name>`. `<friendly-name>` is a unique
     +	"friendly" name that identifies this hook. (The hook events that
    -+	trigger the command are configured with `hook.<name>.event`.) The
    ++	trigger the command are configured with `hook.<friendly-name>.event`.) The
     +	value can be an executable path or a shell oneliner. If more than
    -+	one value is specified for the same `<name>`, only the last value
    ++	one value is specified for the same `<friendly-name>`, only the last value
     +	parsed is used. See linkgit:git-hook[1].
     +
    -+hook.<name>.event::
    -+	The hook events that trigger `hook.<name>`. The value is the name
    ++hook.<friendly-name>.event::
    ++	The hook events that trigger `hook.<friendly-name>`. The value is the name
     +	of a hook event, like "pre-commit" or "update". (See
     +	linkgit:githooks[5] for a complete list of hook events.) On the
    -+	specified event, the associated `hook.<name>.command` is executed.
    -+	This is a multi-valued key. To run `hook.<name>` on multiple
    ++	specified event, the associated `hook.<friendly-name>.command` is executed.
    ++	This is a multi-valued key. To run `hook.<friendly-name>` on multiple
     +	events, specify the key more than once. See linkgit:git-hook[1].
     
      ## Documentation/git-hook.adoc ##
    @@ Documentation/git-hook.adoc: DESCRIPTION
     +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
    ++`hook.<friendly-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.
    @@ Documentation/git-hook.adoc: DESCRIPTION
     +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,
    ++For a full list of hook events which you can set your `hook.<friendly-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
    ++Git will ignore any `hook.<friendly-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.
     +
    @@ hook.c
      #include "environment.h"
      #include "setup.h"
      
    -@@ hook.c: static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)
    +@@ hook.c: void hook_free(void *p, const char *str UNUSED)
    + 	if (!h)
    + 		return;
      
    - 	if (h->kind == HOOK_TRADITIONAL)
    +-	if (h->kind == HOOK_TRADITIONAL)
    ++	if (h->kind == HOOK_TRADITIONAL) {
      		free((void *)h->u.traditional.path);
    -+	else if (h->kind == HOOK_CONFIGURED) {
    ++	} else if (h->kind == HOOK_CONFIGURED) {
     +		free((void *)h->u.configured.friendly_name);
     +		free((void *)h->u.configured.command);
     +	}
      
    - 	if (cb_data_free)
    - 		cb_data_free(h->feed_pipe_cb_data);
    + 	if (h->data_free)
    + 		h->data_free(h->feed_pipe_cb_data);
     @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,
      	string_list_append(hook_list, hook_path)->util = h;
      }
      
    -+static void unsorted_string_list_remove(struct string_list *list,
    -+					const char *str)
    -+{
    -+	struct string_list_item *item = unsorted_string_list_lookup(list, str);
    -+	if (item)
    -+		unsorted_string_list_delete_item(list, item - list->items, 0);
    -+}
    -+
     +/*
     + * Callback struct to collect all hook.* keys in a single config pass.
     + * commands: friendly-name to command map.
     + * event_hooks: event-name to list of friendly-names map.
    -+ * disabled_hooks: set of friendly-names with hook.name.enabled = false.
     + */
     +struct hook_all_config_cb {
     +	struct strmap commands;
     +	struct strmap event_hooks;
    -+	struct string_list disabled_hooks;
     +};
     +
     +/* repo_config() callback that collects all hook.* configuration in one pass. */
    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo
     +		}
     +
     +		/* Re-insert if necessary to preserve last-seen order. */
    -+		unsorted_string_list_remove(hooks, hook_name);
    ++		unsorted_string_list_remove(hooks, hook_name, 0);
     +		string_list_append(hooks, hook_name);
     +	} else if (!strcmp(subkey, "command")) {
     +		/* Store command overwriting the old value */
    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo
     +
     +	strmap_init(&cb_data.commands);
     +	strmap_init(&cb_data.event_hooks);
    -+	string_list_init_dup(&cb_data.disabled_hooks);
     +
     +	/* Parse all configs in one run. */
     +	repo_config(r, hook_config_lookup_all, &cb_data);
    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo
     +	/* Construct the cache from parsed configs. */
     +	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
     +		struct string_list *hook_names = e->value;
    -+		struct string_list *hooks = xcalloc(1, sizeof(*hooks));
    ++		struct string_list *hooks;
    ++		CALLOC_ARRAY(hooks, 1);
     +
     +		string_list_init_dup(hooks);
     +
    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo
     +	}
     +
     +	strmap_clear(&cb_data.commands, 1);
    -+	string_list_clear(&cb_data.disabled_hooks, 0);
     +	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
     +		string_list_clear(e->value, 0);
     +		free(e->value);
    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo
     +		 * it just once on the first call.
     +		 */
     +		if (!r->hook_config_cache) {
    -+			r->hook_config_cache = xcalloc(1, sizeof(*cache));
    ++			CALLOC_ARRAY(r->hook_config_cache, 1);
     +			strmap_init(r->hook_config_cache);
     +			build_hook_config_map(r, r->hook_config_cache);
     +		}
    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo
     +	for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {
     +		const char *friendly_name = configured_hooks->items[i].string;
     +		const char *command = configured_hooks->items[i].util;
    -+		struct hook *hook = xcalloc(1, sizeof(struct hook));
    ++		struct hook *hook;
    ++		CALLOC_ARRAY(hook, 1);
     +
     +		if (options && options->feed_pipe_cb_data_alloc)
     +			hook->feed_pipe_cb_data =
     +				options->feed_pipe_cb_data_alloc(
     +					options->feed_pipe_ctx);
    ++		if (options)
    ++			hook->data_free = options->feed_pipe_cb_data_free;
     +
     +		hook->kind = HOOK_CONFIGURED;
     +		hook->u.configured.friendly_name = xstrdup(friendly_name);
    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo
      			       struct run_hooks_opt *options)
      {
     @@ hook.c: struct string_list *list_hooks(struct repository *r, const char *hookname,
    - 	hook_head = xmalloc(sizeof(struct string_list));
    + 	CALLOC_ARRAY(hook_head, 1);
      	string_list_init_dup(hook_head);
      
     +	/* Add hooks from the config, e.g. hook.myhook.event = pre-commit */
    @@ hook.c: static int pick_next_hook(struct child_process *cp,
      
      	/* Add hook exec paths or commands */
     -	if (h->kind == HOOK_TRADITIONAL)
    -+	if (h->kind == HOOK_TRADITIONAL) {
    ++	switch (h->kind) {
    ++	case HOOK_TRADITIONAL:
      		strvec_push(&cp->args, h->u.traditional.path);
    -+	} else if (h->kind == HOOK_CONFIGURED) {
    +-	else
    ++		break;
    ++	case HOOK_CONFIGURED:
     +		/* to enable oneliners, let config-specified hooks run in shell. */
     +		cp->use_shell = true;
     +		strvec_push(&cp->args, h->u.configured.command);
    ++		break;
    ++	default:
    + 		BUG("unknown hook kind");
     +	}
      
      	if (!cp->args.nr)
    @@ hook.h
      
      struct repository;
      
    -@@ hook.h: struct repository;
    +@@ hook.h: typedef void *(*hook_data_alloc_fn)(void *init_ctx);
       * Represents a hook command to be run.
       * Hooks can be:
       * 1. "traditional" (found in the hooks directory)
     - * 2. "configured" (defined in Git's configuration, not yet implemented).
    -+ * 2. "configured" (defined in Git's configuration via hook.<name>.event).
    ++ * 2. "configured" (defined in Git's configuration via hook.<friendly-name>.event).
       * The 'kind' field determines which part of the union 'u' is valid.
       */
      struct hook {
    @@ hook.h: struct repository;
      	} u;
      
      	/**
    -@@ hook.h: struct string_list *list_hooks(struct repository *r, const char *hookname,
    -  */
    - void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);
    +@@ hook.h: void hook_free(void *p, const char *str);
    + struct string_list *list_hooks(struct repository *r, const char *hookname,
    + 			       struct run_hooks_opt *options);
      
     +/**
     + * Frees the hook configuration cache stored in `struct repository`.
    @@ t/t1800-hook.sh
      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 &&
     @@ t/t1800-hook.sh: test_expect_success 'git hook list: traditional hook from hookdir' '
      	test_cmp expect actual
      '
    @@ t/t1800-hook.sh: test_expect_success TTY 'git commit: stdout and stderr are conn
     +	test_cmp expected actual
     +'
     +
    ++test_expect_success 'configured hooks run before hookdir hook' '
    ++	setup_hookdir &&
    ++	test_config hook.first.event "pre-commit" &&
    ++	test_config hook.first.command "echo first" &&
    ++	test_config hook.second.event "pre-commit" &&
    ++	test_config hook.second.command "echo second" &&
    ++
    ++	cat >expected <<-\EOF &&
    ++	first
    ++	second
    ++	hook from hookdir
    ++	EOF
    ++
    ++	git hook list pre-commit >actual &&
    ++	test_cmp expected actual &&
    ++
    ++	cat >expected <<-\EOF &&
    ++	first
    ++	second
    ++	"Legacy Hook"
    ++	EOF
    ++
    ++	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" &&
     +	test_config hook.stdin-a.command "xargs -P1 -I% echo a%" &&
 5:  f71ada4cb8 !  6:  260b845b9e hook: allow disabling config hooks
    @@ Commit message
         might want to disable them without removing from the config,
         like locally disabling a global hook.
     
    -    Add a hook.<name>.enabled config which defaults to true and
    +    Add a hook.<friendly-name>.enabled config which defaults to true and
         can be optionally set for each configured hook.
     
         Suggested-by: Patrick Steinhardt <ps@pks.im>
         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
     
      ## Documentation/config/hook.adoc ##
    -@@ Documentation/config/hook.adoc: hook.<name>.event::
    - 	specified event, the associated `hook.<name>.command` is executed.
    - 	This is a multi-valued key. To run `hook.<name>` on multiple
    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.event::
    + 	specified event, the associated `hook.<friendly-name>.command` is executed.
    + 	This is a multi-valued key. To run `hook.<friendly-name>` on multiple
      	events, specify the key more than once. See linkgit:git-hook[1].
     +
    -+hook.<name>.enabled::
    -+	Whether the hook `hook.<name>` is enabled. Defaults to `true`.
    ++hook.<friendly-name>.enabled::
    ++	Whether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.
     +	Set to `false` to disable the hook without removing its
     +	configuration. This is particularly useful when a hook is defined
     +	in a system or global config file and needs to be disabled for a
     +	specific repository. See linkgit:git-hook[1].
     
      ## hook.c ##
    +@@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,
    +  * Callback struct to collect all hook.* keys in a single config pass.
    +  * 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.
    +  */
    + struct hook_all_config_cb {
    + 	struct strmap commands;
    + 	struct strmap event_hooks;
    ++	struct string_list disabled_hooks;
    + };
    + 
    + /* repo_config() callback that collects all hook.* configuration in one pass. */
     @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,
      		char *old = strmap_put(&data->commands, hook_name,
      				       xstrdup(value));
    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,
      
      	free(hook_name);
     @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)
    + 
    + 	strmap_init(&cb_data.commands);
    + 	strmap_init(&cb_data.event_hooks);
    ++	string_list_init_dup(&cb_data.disabled_hooks);
    + 
    + 	/* Parse all configs in one run. */
    + 	repo_config(r, hook_config_lookup_all, &cb_data);
    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)
      			const char *hname = hook_names->items[i].string;
      			char *command;
      
    @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *c
      			command = strmap_get(&cb_data.commands, hname);
      			if (!command)
      				die(_("'hook.%s.command' must be configured or "
    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)
    + 	}
    + 
    + 	strmap_clear(&cb_data.commands, 1);
    ++	string_list_clear(&cb_data.disabled_hooks, 0);
    + 	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
    + 		string_list_clear(e->value, 0);
    + 		free(e->value);
     
      ## t/t1800-hook.sh ##
     @@ t/t1800-hook.sh: test_expect_success 'rejects hooks with no commands configured' '
 6:  82a7d6167f !  7:  dcc595751e hook: allow event = "" to overwrite previous values
    @@ Commit message
         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
     
      ## Documentation/config/hook.adoc ##
    -@@ Documentation/config/hook.adoc: hook.<name>.event::
    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.event::
      	linkgit:githooks[5] for a complete list of hook events.) On the
    - 	specified event, the associated `hook.<name>.command` is executed.
    - 	This is a multi-valued key. To run `hook.<name>` on multiple
    + 	specified event, the associated `hook.<friendly-name>.command` is executed.
    + 	This is a multi-valued key. To run `hook.<friendly-name>` on multiple
     -	events, specify the key more than once. See linkgit:git-hook[1].
     +	events, specify the key more than once. An empty value resets
     +	the list of events, clearing any previously defined events for
    -+	`hook.<name>`. See linkgit:git-hook[1].
    ++	`hook.<friendly-name>`. See linkgit:git-hook[1].
      
    - hook.<name>.enabled::
    - 	Whether the hook `hook.<name>` is enabled. Defaults to `true`.
    + hook.<friendly-name>.enabled::
    + 	Whether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.
     
      ## hook.c ##
     @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,
    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,
     +			struct strmap_entry *e;
     +
     +			strmap_for_each_entry(&data->event_hooks, &iter, e)
    -+				unsorted_string_list_remove(e->value, hook_name);
    ++				unsorted_string_list_remove(e->value, hook_name, 0);
     +		} else {
     +			struct string_list *hooks =
     +				strmap_get(&data->event_hooks, value);
     +
     +			if (!hooks) {
    -+				hooks = xcalloc(1, sizeof(*hooks));
    ++				CALLOC_ARRAY(hooks, 1);
     +				string_list_init_dup(hooks);
     +				strmap_put(&data->event_hooks, value, hooks);
     +			}
    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,
     -			string_list_init_dup(hooks);
     -			strmap_put(&data->event_hooks, value, hooks);
     +			/* Re-insert if necessary to preserve last-seen order. */
    -+			unsorted_string_list_remove(hooks, hook_name);
    ++			unsorted_string_list_remove(hooks, hook_name, 0);
     +			string_list_append(hooks, hook_name);
      		}
     -
     -		/* Re-insert if necessary to preserve last-seen order. */
    --		unsorted_string_list_remove(hooks, hook_name);
    +-		unsorted_string_list_remove(hooks, hook_name, 0);
     -		string_list_append(hooks, hook_name);
      	} else if (!strcmp(subkey, "command")) {
      		/* Store command overwriting the old value */
      		char *old = strmap_put(&data->commands, hook_name,
    +@@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,
    + 			break;
    + 		case 1: /* enabled: undo a prior disabled entry */
    + 			unsorted_string_list_remove(&data->disabled_hooks,
    +-						    hook_name);
    ++						    hook_name, 0);
    + 			break;
    + 		default:
    + 			break; /* ignore unrecognised values */
     
      ## t/t1800-hook.sh ##
     @@ t/t1800-hook.sh: test_expect_success 'git hook list reorders on duplicate event declarations' '
 7:  8d1704384e !  8:  c668b2fec1 hook: allow out-of-repo 'git hook' invocations
    @@ hook.c: static struct strmap *get_hook_config_cache(struct repository *r)
     +		 * Out-of-repo calls (no gitdir) allocate and return a temporary
     +		 * map cache which gets free'd immediately by the caller.
     +		 */
    -+		cache = xcalloc(1, sizeof(*cache));
    ++		CALLOC_ARRAY(cache, 1);
     +		strmap_init(cache);
     +		build_hook_config_map(r, cache);
      	}
 8:  7bf527c59e =  9:  807116ff79 hook: add -z option to "git hook list"
 -:  ---------- > 10:  57a27ad1d6 hook: refactor hook_config_cache from strmap to named struct
 -:  ---------- > 11:  260300c890 hook: store and display scope for configured hooks in git hook list
 -:  ---------- > 12:  7c79801a63 hook: show disabled hooks in "git hook list"

Adrian Ratiu (9):
  hook: add internal state alloc/free callbacks
  string-list: add unsorted_string_list_remove()
  hook: include hooks from the config
  hook: allow disabling config hooks
  hook: allow event = "" to overwrite previous values
  hook: add -z option to "git hook list"
  hook: refactor hook_config_cache from strmap to named struct
  hook: store and display scope for configured hooks in git hook list
  hook: show disabled hooks in "git hook list"

Emily Shaffer (3):
  hook: run a list of hooks to prepare for multihook support
  hook: add "git hook list" command
  hook: allow out-of-repo 'git hook' invocations

 Documentation/config/hook.adoc |  24 ++
 Documentation/git-hook.adoc    | 142 ++++++++++-
 builtin/hook.c                 |  79 ++++++
 builtin/receive-pack.c         |  36 ++-
 git.c                          |   2 +-
 hook.c                         | 423 ++++++++++++++++++++++++++++++---
 hook.h                         | 127 +++++++++-
 refs.c                         |  25 +-
 repository.c                   |   6 +
 repository.h                   |   7 +
 string-list.c                  |   9 +
 string-list.h                  |   8 +
 t/t1800-hook.sh                | 315 +++++++++++++++++++++++-
 transport.c                    |  28 ++-
 14 files changed, 1168 insertions(+), 63 deletions(-)
 create mode 100644 Documentation/config/hook.adoc

-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply	[flat|nested] 69+ messages in thread

* [PATCH v3 01/12] hook: add internal state alloc/free callbacks
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 02/12] hook: run a list of hooks to prepare for multihook support Adrian Ratiu
                     ` (11 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Some hooks use opaque structs to keep internal state between callbacks.

Because hooks ran sequentially (jobs == 1) with one command per hook,
these internal states could be allocated on the stack for each hook run.

Next commits add the ability to run multiple commands for each hook, so
the states cannot be shared or stored on the stack anymore, especially
since down the line we will also enable parallel execution (jobs > 1).

Add alloc/free helpers for each hook, doing a "deep" alloc/init & free
of their internal opaque struct.

The alloc callback takes a context pointer, to initialize the struct at
at the time of resource acquisition.

These callbacks must always be provided together: no alloc without free
and no free without alloc, otherwise a BUG() is triggered.

Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 builtin/receive-pack.c | 36 +++++++++++++++++++++++++++++-------
 hook.c                 | 12 ++++++++++++
 hook.h                 | 29 ++++++++++++++++++++++++++++-
 refs.c                 | 25 ++++++++++++++++++++-----
 transport.c            | 28 +++++++++++++++++++++-------
 5 files changed, 110 insertions(+), 20 deletions(-)

diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
index 2d2b33d73d..0f3ba93e95 100644
--- a/builtin/receive-pack.c
+++ b/builtin/receive-pack.c
@@ -901,6 +901,27 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_
 	return state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */
 }
 
+static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)
+{
+	struct receive_hook_feed_state *init_state = feed_pipe_ctx;
+	struct receive_hook_feed_state *data;
+	CALLOC_ARRAY(data, 1);
+	data->report = init_state->report;
+	data->cmd = init_state->cmd;
+	data->skip_broken = init_state->skip_broken;
+	strbuf_init(&data->buf, 0);
+	return data;
+}
+
+static void receive_hook_feed_state_free(void *data)
+{
+	struct receive_hook_feed_state *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_receive_hook(struct command *commands,
 			    const char *hook_name,
 			    int skip_broken,
@@ -908,7 +929,11 @@ static int run_receive_hook(struct command *commands,
 {
 	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
 	struct command *iter = commands;
-	struct receive_hook_feed_state feed_state;
+	struct receive_hook_feed_state feed_init_state = {
+		.cmd = commands,
+		.skip_broken = skip_broken,
+		.buf = STRBUF_INIT,
+	};
 	struct async sideband_async;
 	int sideband_async_started = 0;
 	int saved_stderr = -1;
@@ -938,16 +963,13 @@ static int run_receive_hook(struct command *commands,
 	prepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started);
 
 	/* set up stdin callback */
-	feed_state.cmd = commands;
-	feed_state.skip_broken = skip_broken;
-	feed_state.report = NULL;
-	strbuf_init(&feed_state.buf, 0);
-	opt.feed_pipe_cb_data = &feed_state;
+	opt.feed_pipe_ctx = &feed_init_state;
 	opt.feed_pipe = feed_receive_hook_cb;
+	opt.feed_pipe_cb_data_alloc = receive_hook_feed_state_alloc;
+	opt.feed_pipe_cb_data_free = receive_hook_feed_state_free;
 
 	ret = run_hooks_opt(the_repository, hook_name, &opt);
 
-	strbuf_release(&feed_state.buf);
 	finish_sideband_async(&sideband_async, saved_stderr, sideband_async_started);
 
 	return ret;
diff --git a/hook.c b/hook.c
index cde7198412..a9ade11952 100644
--- a/hook.c
+++ b/hook.c
@@ -133,6 +133,8 @@ static int notify_hook_finished(int result,
 
 static void run_hooks_opt_clear(struct run_hooks_opt *options)
 {
+	if (options->feed_pipe_cb_data_free)
+		options->feed_pipe_cb_data_free(options->feed_pipe_cb_data);
 	strvec_clear(&options->env);
 	strvec_clear(&options->args);
 }
@@ -172,6 +174,16 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
 	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->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free)
+		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");
+
+	if (options->feed_pipe_cb_data_alloc)
+		options->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
+
 	if (options->invoked_hook)
 		*options->invoked_hook = 0;
 
diff --git a/hook.h b/hook.h
index 20eb56fd63..630e1a3c85 100644
--- a/hook.h
+++ b/hook.h
@@ -5,6 +5,9 @@
 
 struct repository;
 
+typedef void (*hook_data_free_fn)(void *data);
+typedef void *(*hook_data_alloc_fn)(void *init_ctx);
+
 struct run_hooks_opt
 {
 	/* Environment vars to be set for each hook */
@@ -88,10 +91,34 @@ struct run_hooks_opt
 	 * It can be accessed directly via the third callback arg 'pp_task_cb':
 	 * struct ... *state = pp_task_cb;
 	 *
-	 * The caller is responsible for managing the memory for this data.
+	 * The caller is responsible for managing the memory for this data by
+	 * providing alloc/free callbacks to `run_hooks_opt`.
+	 *
 	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
 	 */
 	void *feed_pipe_cb_data;
+
+	/**
+	 * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,
+	 * so they can keep track of progress without affecting one another.
+	 *
+	 * If provided, this function will be called to alloc & initialize the
+	 * `feed_pipe_cb_data` for each hook.
+	 *
+	 * The caller must provide a `feed_pipe_cb_data_free` callback to free
+	 * this memory (missing callback will trigger a bug). Use only the cb to
+	 * free the memory, do not free it manually in the caller.
+	 *
+	 * The `feed_pipe_ctx` pointer can be used to pass initialization data.
+	 */
+	hook_data_alloc_fn feed_pipe_cb_data_alloc;
+
+	/**
+	 * Called to free the memory initialized by `feed_pipe_cb_data_alloc`.
+	 *
+	 * Must always be provided when `feed_pipe_cb_data_alloc` is provided.
+	 */
+	hook_data_free_fn feed_pipe_cb_data_free;
 };
 
 #define RUN_HOOKS_OPT_INIT { \
diff --git a/refs.c b/refs.c
index 7cfb866aab..bd91c5c882 100644
--- a/refs.c
+++ b/refs.c
@@ -2597,24 +2597,39 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_
 	return 0; /* no more input to feed */
 }
 
+static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED)
+{
+	struct transaction_feed_cb_data *data;
+	CALLOC_ARRAY(data, 1);
+	strbuf_init(&data->buf, 0);
+	data->index = 0;
+	return data;
+}
+
+static void transaction_feed_cb_data_free(void *data)
+{
+	struct transaction_feed_cb_data *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_transaction_hook(struct ref_transaction *transaction,
 				const char *state)
 {
 	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
-	struct transaction_feed_cb_data feed_ctx = { 0 };
 	int ret = 0;
 
 	strvec_push(&opt.args, state);
 
 	opt.feed_pipe = transaction_hook_feed_stdin;
 	opt.feed_pipe_ctx = transaction;
-	opt.feed_pipe_cb_data = &feed_ctx;
-
-	strbuf_init(&feed_ctx.buf, 0);
+	opt.feed_pipe_cb_data_alloc = transaction_feed_cb_data_alloc;
+	opt.feed_pipe_cb_data_free = transaction_feed_cb_data_free;
 
 	ret = run_hooks_opt(transaction->ref_store->repo, "reference-transaction", &opt);
 
-	strbuf_release(&feed_ctx.buf);
 	return ret;
 }
 
diff --git a/transport.c b/transport.c
index faa166a575..56a4015389 100644
--- a/transport.c
+++ b/transport.c
@@ -1358,21 +1358,37 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void
 	return 0;
 }
 
+static void *pre_push_hook_data_alloc(void *feed_pipe_ctx)
+{
+	struct feed_pre_push_hook_data *data;
+	CALLOC_ARRAY(data, 1);
+	strbuf_init(&data->buf, 0);
+	data->refs = (struct ref *)feed_pipe_ctx;
+	return data;
+}
+
+static void pre_push_hook_data_free(void *data)
+{
+	struct feed_pre_push_hook_data *d = data;
+	if (!d)
+		return;
+	strbuf_release(&d->buf);
+	free(d);
+}
+
 static int run_pre_push_hook(struct transport *transport,
 			     struct ref *remote_refs)
 {
 	struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
-	struct feed_pre_push_hook_data data;
 	int ret = 0;
 
 	strvec_push(&opt.args, transport->remote->name);
 	strvec_push(&opt.args, transport->url);
 
-	strbuf_init(&data.buf, 0);
-	data.refs = remote_refs;
-
 	opt.feed_pipe = pre_push_hook_feed_stdin;
-	opt.feed_pipe_cb_data = &data;
+	opt.feed_pipe_ctx = remote_refs;
+	opt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;
+	opt.feed_pipe_cb_data_free = pre_push_hook_data_free;
 
 	/*
 	 * pre-push hooks expect stdout & stderr to be separate, so don't merge
@@ -1382,8 +1398,6 @@ static int run_pre_push_hook(struct transport *transport,
 
 	ret = run_hooks_opt(the_repository, "pre-push", &opt);
 
-	strbuf_release(&data.buf);
-
 	return ret;
 }
 
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 02/12] hook: run a list of hooks to prepare for multihook support
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 01/12] hook: add internal state alloc/free callbacks Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 03/12] hook: add "git hook list" command Adrian Ratiu
                     ` (10 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

From: Emily Shaffer <emilyshaffer@google.com>

Hooks are limited to run one command (the default from the hookdir) for
each event. This limitation makes it impossible to run multiple commands
via config files, which the next commits will add.

Implement the ability to run a list of hooks in hook.[ch]. For now, the
list contains only one entry representing the "default" hook from the
hookdir, so there is no user-visible change in this commit.

All hook commands still run sequentially like before. A separate patch
series will enable running them in parallel.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 hook.c | 139 ++++++++++++++++++++++++++++++++++++++++++++-------------
 hook.h |  69 +++++++++++++++++++++-------
 2 files changed, 162 insertions(+), 46 deletions(-)

diff --git a/hook.c b/hook.c
index a9ade11952..eb52d706b8 100644
--- a/hook.c
+++ b/hook.c
@@ -47,9 +47,95 @@ const char *find_hook(struct repository *r, const char *name)
 	return path.buf;
 }
 
+/*
+ * Frees a struct hook stored as the util pointer of a string_list_item.
+ * Suitable for use as a string_list_clear_func_t callback.
+ */
+static void hook_free(void *p, const char *str UNUSED)
+{
+	struct hook *h = p;
+
+	if (!h)
+		return;
+
+	if (h->kind == HOOK_TRADITIONAL)
+		free((void *)h->u.traditional.path);
+
+	if (h->data_free)
+		h->data_free(h->feed_pipe_cb_data);
+
+	free(h);
+}
+
+/* Helper to detect and add default "traditional" hooks from the hookdir. */
+static void list_hooks_add_default(struct repository *r, const char *hookname,
+				   struct string_list *hook_list,
+				   struct run_hooks_opt *options)
+{
+	const char *hook_path = find_hook(r, hookname);
+	struct hook *h;
+
+	if (!hook_path)
+		return;
+
+	CALLOC_ARRAY(h, 1);
+
+	/*
+	 * If the hook is to run in a specific dir, a relative path can
+	 * become invalid in that dir, so convert to an absolute path.
+	 */
+	if (options && options->dir)
+		hook_path = absolute_path(hook_path);
+
+	/* Setup per-hook internal state cb data */
+	if (options && options->feed_pipe_cb_data_alloc)
+		h->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
+	if (options)
+		h->data_free = options->feed_pipe_cb_data_free;
+
+	h->kind = HOOK_TRADITIONAL;
+	h->u.traditional.path = xstrdup(hook_path);
+
+	string_list_append(hook_list, hook_path)->util = h;
+}
+
+/*
+ * Provides a list of hook commands to run for the 'hookname' event.
+ *
+ * This function consolidates hooks from two sources:
+ * 1. The config-based hooks (not yet implemented).
+ * 2. The "traditional" hook found in the repository hooks directory
+ *    (e.g., .git/hooks/pre-commit).
+ *
+ * The list is ordered by execution priority.
+ *
+ * The caller is responsible for freeing the memory of the returned list
+ * using string_list_clear() and free().
+ */
+static struct string_list *list_hooks(struct repository *r, const char *hookname,
+			       struct run_hooks_opt *options)
+{
+	struct string_list *hook_head;
+
+	if (!hookname)
+		BUG("null hookname was provided to hook_list()!");
+
+	CALLOC_ARRAY(hook_head, 1);
+	string_list_init_dup(hook_head);
+
+	/* Add the default "traditional" hooks from hookdir. */
+	list_hooks_add_default(r, hookname, hook_head, options);
+
+	return hook_head;
+}
+
 int hook_exists(struct repository *r, const char *name)
 {
-	return !!find_hook(r, name);
+	struct string_list *hooks = list_hooks(r, name, NULL);
+	int exists = hooks->nr > 0;
+	string_list_clear_func(hooks, hook_free);
+	free(hooks);
+	return exists;
 }
 
 static int pick_next_hook(struct child_process *cp,
@@ -58,11 +144,14 @@ static int pick_next_hook(struct child_process *cp,
 			  void **pp_task_cb)
 {
 	struct hook_cb_data *hook_cb = pp_cb;
-	const char *hook_path = hook_cb->hook_path;
+	struct string_list *hook_list = hook_cb->hook_command_list;
+	struct hook *h;
 
-	if (!hook_path)
+	if (hook_cb->hook_to_run_index >= hook_list->nr)
 		return 0;
 
+	h = hook_list->items[hook_cb->hook_to_run_index++].util;
+
 	cp->no_stdin = 1;
 	strvec_pushv(&cp->env, hook_cb->options->env.v);
 
@@ -85,21 +174,22 @@ static int pick_next_hook(struct child_process *cp,
 	cp->trace2_hook_name = hook_cb->hook_name;
 	cp->dir = hook_cb->options->dir;
 
-	strvec_push(&cp->args, hook_path);
+	/* Add hook exec paths or commands */
+	if (h->kind == HOOK_TRADITIONAL)
+		strvec_push(&cp->args, h->u.traditional.path);
+	else
+		BUG("unknown hook kind");
+
+	if (!cp->args.nr)
+		BUG("hook must have at least one command or exec path");
+
 	strvec_pushv(&cp->args, hook_cb->options->args.v);
 
 	/*
 	 * Provide per-hook internal state via task_cb for easy access, so
 	 * hook callbacks don't have to go through hook_cb->options.
 	 */
-	*pp_task_cb = hook_cb->options->feed_pipe_cb_data;
-
-	/*
-	 * This pick_next_hook() will be called again, we're only
-	 * running one hook, so indicate that no more work will be
-	 * done.
-	 */
-	hook_cb->hook_path = NULL;
+	*pp_task_cb = h->feed_pipe_cb_data;
 
 	return 1;
 }
@@ -133,8 +223,6 @@ static int notify_hook_finished(int result,
 
 static void run_hooks_opt_clear(struct run_hooks_opt *options)
 {
-	if (options->feed_pipe_cb_data_free)
-		options->feed_pipe_cb_data_free(options->feed_pipe_cb_data);
 	strvec_clear(&options->env);
 	strvec_clear(&options->args);
 }
@@ -142,13 +230,11 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)
 int run_hooks_opt(struct repository *r, const char *hook_name,
 		  struct run_hooks_opt *options)
 {
-	struct strbuf abs_path = STRBUF_INIT;
 	struct hook_cb_data cb_data = {
 		.rc = 0,
 		.hook_name = hook_name,
 		.options = options,
 	};
-	const char *const hook_path = find_hook(r, hook_name);
 	int ret = 0;
 	const struct run_process_parallel_opts opts = {
 		.tr2_category = "hook",
@@ -181,30 +267,21 @@ int run_hooks_opt(struct repository *r, const char *hook_name,
 	if (!options->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free)
 		BUG("feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together");
 
-	if (options->feed_pipe_cb_data_alloc)
-		options->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);
-
 	if (options->invoked_hook)
 		*options->invoked_hook = 0;
 
-	if (!hook_path && !options->error_if_missing)
+	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);
 		goto cleanup;
-
-	if (!hook_path) {
-		ret = error("cannot find a hook named %s", hook_name);
-		goto cleanup;
-	}
-
-	cb_data.hook_path = hook_path;
-	if (options->dir) {
-		strbuf_add_absolute_path(&abs_path, hook_path);
-		cb_data.hook_path = abs_path.buf;
 	}
 
 	run_processes_parallel(&opts);
 	ret = cb_data.rc;
 cleanup:
-	strbuf_release(&abs_path);
+	string_list_clear_func(cb_data.hook_command_list, hook_free);
+	free(cb_data.hook_command_list);
 	run_hooks_opt_clear(options);
 	return ret;
 }
diff --git a/hook.h b/hook.h
index 630e1a3c85..51fe873298 100644
--- a/hook.h
+++ b/hook.h
@@ -2,12 +2,52 @@
 #define HOOK_H
 #include "strvec.h"
 #include "run-command.h"
+#include "string-list.h"
 
 struct repository;
 
 typedef void (*hook_data_free_fn)(void *data);
 typedef void *(*hook_data_alloc_fn)(void *init_ctx);
 
+/**
+ * Represents a hook command to be run.
+ * Hooks can be:
+ * 1. "traditional" (found in the hooks directory)
+ * 2. "configured" (defined in Git's configuration, not yet implemented).
+ * The 'kind' field determines which part of the union 'u' is valid.
+ */
+struct hook {
+	enum {
+		HOOK_TRADITIONAL,
+	} kind;
+	union {
+		struct {
+			const char *path;
+		} traditional;
+	} u;
+
+	/**
+	 * Opaque data pointer used to keep internal state across callback calls.
+	 *
+	 * It can be accessed directly via the third hook callback arg:
+	 * struct ... *state = pp_task_cb;
+	 *
+	 * The caller is responsible for managing the memory for this data by
+	 * providing alloc/free callbacks to `run_hooks_opt`.
+	 *
+	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
+	 */
+	void *feed_pipe_cb_data;
+
+	/**
+	 * Callback to free `feed_pipe_cb_data`.
+	 *
+	 * It is called automatically and points to the `feed_pipe_cb_data_free`
+	 * provided via the `run_hook_opt` parameter.
+	 */
+	hook_data_free_fn data_free;
+};
+
 struct run_hooks_opt
 {
 	/* Environment vars to be set for each hook */
@@ -85,19 +125,6 @@ struct run_hooks_opt
 	 */
 	void *feed_pipe_ctx;
 
-	/**
-	 * Opaque data pointer used to keep internal state across callback calls.
-	 *
-	 * It can be accessed directly via the third callback arg 'pp_task_cb':
-	 * struct ... *state = pp_task_cb;
-	 *
-	 * The caller is responsible for managing the memory for this data by
-	 * providing alloc/free callbacks to `run_hooks_opt`.
-	 *
-	 * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
-	 */
-	void *feed_pipe_cb_data;
-
 	/**
 	 * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,
 	 * so they can keep track of progress without affecting one another.
@@ -132,11 +159,23 @@ struct hook_cb_data {
 	/* rc reflects the cumulative failure state */
 	int rc;
 	const char *hook_name;
-	const char *hook_path;
+
+	/**
+	 * A list of hook commands/paths to run for the 'hook_name' event.
+	 *
+	 * The 'string' member of each item holds the path (for traditional hooks)
+	 * or the unique friendly-name for hooks specified in configs.
+	 * The 'util' member of each item points to the corresponding struct hook.
+	 */
+	struct string_list *hook_command_list;
+
+	/* Iterator/cursor for the above list, pointing to the next hook to run. */
+	size_t hook_to_run_index;
+
 	struct run_hooks_opt *options;
 };
 
-/*
+/**
  * Returns the path to the hook file, or NULL if the hook is missing
  * or disabled. Note that this points to static storage that will be
  * overwritten by further calls to find_hook and run_hook_*.
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 03/12] hook: add "git hook list" command
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 01/12] hook: add internal state alloc/free callbacks Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 02/12] hook: run a list of hooks to prepare for multihook support Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 04/12] string-list: add unsorted_string_list_remove() Adrian Ratiu
                     ` (9 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

From: Emily Shaffer <emilyshaffer@google.com>

The previous commit introduced an ability to run multiple commands for
hook events and next commit will introduce the ability to define hooks
from configs, in addition to the "traditional" hooks from the hookdir.

Introduce a new command "git hook list" to make inspecting hooks easier
both for users and for the tests we will add.

Further commits will expand on this, e.g. by adding a -z output mode.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/git-hook.adoc |  5 ++++
 builtin/hook.c              | 59 +++++++++++++++++++++++++++++++++++++
 hook.c                      | 21 ++-----------
 hook.h                      | 22 ++++++++++++++
 t/t1800-hook.sh             | 22 ++++++++++++++
 5 files changed, 110 insertions(+), 19 deletions(-)

diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index f6cc72d2ca..eb0ffcb8a9 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -9,6 +9,7 @@ SYNOPSIS
 --------
 [verse]
 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
+'git hook' list <hook-name>
 
 DESCRIPTION
 -----------
@@ -28,6 +29,10 @@ Any positional arguments to the hook should be passed after a
 mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See
 linkgit:githooks[5] for arguments hooks might expect (if any).
 
+list::
+	Print a list of hooks which will be run on `<hook-name>` event. If no
+	hooks are configured for that event, print a warning and return 1.
+
 OPTIONS
 -------
 
diff --git a/builtin/hook.c b/builtin/hook.c
index 7afec380d2..855116ba8c 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -9,9 +9,12 @@
 
 #define BUILTIN_HOOK_RUN_USAGE \
 	N_("git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
+#define BUILTIN_HOOK_LIST_USAGE \
+	N_("git hook list <hook-name>")
 
 static const char * const builtin_hook_usage[] = {
 	BUILTIN_HOOK_RUN_USAGE,
+	BUILTIN_HOOK_LIST_USAGE,
 	NULL
 };
 
@@ -20,6 +23,61 @@ static const char * const builtin_hook_run_usage[] = {
 	NULL
 };
 
+static int list(int argc, const char **argv, const char *prefix,
+		 struct repository *repo)
+{
+	static const char *const builtin_hook_list_usage[] = {
+		BUILTIN_HOOK_LIST_USAGE,
+		NULL
+	};
+	struct string_list *head;
+	struct string_list_item *item;
+	const char *hookname = NULL;
+	int ret = 0;
+
+	struct option list_options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, list_options,
+			     builtin_hook_list_usage, 0);
+
+	/*
+	 * The only unnamed argument provided should be the hook-name; if we add
+	 * arguments later they probably should be caught by parse_options.
+	 */
+	if (argc != 1)
+		usage_msg_opt(_("you must specify a hook event name to list."),
+			      builtin_hook_list_usage, list_options);
+
+	hookname = argv[0];
+
+	head = list_hooks(repo, hookname, NULL);
+
+	if (!head->nr) {
+		warning(_("no hooks found for event '%s'"), hookname);
+		ret = 1; /* no hooks found */
+		goto cleanup;
+	}
+
+	for_each_string_list_item(item, head) {
+		struct hook *h = item->util;
+
+		switch (h->kind) {
+		case HOOK_TRADITIONAL:
+			printf("%s\n", _("hook from hookdir"));
+			break;
+		default:
+			BUG("unknown hook kind");
+		}
+	}
+
+cleanup:
+	string_list_clear_func(head, hook_free);
+	free(head);
+	return ret;
+}
+
 static int run(int argc, const char **argv, const char *prefix,
 	       struct repository *repo UNUSED)
 {
@@ -77,6 +135,7 @@ int cmd_hook(int argc,
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option builtin_hook_options[] = {
 		OPT_SUBCOMMAND("run", &fn, run),
+		OPT_SUBCOMMAND("list", &fn, list),
 		OPT_END(),
 	};
 
diff --git a/hook.c b/hook.c
index eb52d706b8..20c655918d 100644
--- a/hook.c
+++ b/hook.c
@@ -47,11 +47,7 @@ const char *find_hook(struct repository *r, const char *name)
 	return path.buf;
 }
 
-/*
- * Frees a struct hook stored as the util pointer of a string_list_item.
- * Suitable for use as a string_list_clear_func_t callback.
- */
-static void hook_free(void *p, const char *str UNUSED)
+void hook_free(void *p, const char *str UNUSED)
 {
 	struct hook *h = p;
 
@@ -99,20 +95,7 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
 	string_list_append(hook_list, hook_path)->util = h;
 }
 
-/*
- * Provides a list of hook commands to run for the 'hookname' event.
- *
- * This function consolidates hooks from two sources:
- * 1. The config-based hooks (not yet implemented).
- * 2. The "traditional" hook found in the repository hooks directory
- *    (e.g., .git/hooks/pre-commit).
- *
- * The list is ordered by execution priority.
- *
- * The caller is responsible for freeing the memory of the returned list
- * using string_list_clear() and free().
- */
-static struct string_list *list_hooks(struct repository *r, const char *hookname,
+struct string_list *list_hooks(struct repository *r, const char *hookname,
 			       struct run_hooks_opt *options)
 {
 	struct string_list *hook_head;
diff --git a/hook.h b/hook.h
index 51fe873298..36d40c98df 100644
--- a/hook.h
+++ b/hook.h
@@ -175,6 +175,28 @@ struct hook_cb_data {
 	struct run_hooks_opt *options;
 };
 
+/**
+ * Frees a struct hook stored as the util pointer of a string_list_item.
+ * Suitable for use as a string_list_clear_func_t callback.
+ */
+void hook_free(void *p, const char *str);
+
+/**
+ * Provides a list of hook commands to run for the 'hookname' event.
+ *
+ * This function consolidates hooks from two sources:
+ * 1. The config-based hooks (not yet implemented).
+ * 2. The "traditional" hook found in the repository hooks directory
+ *    (e.g., .git/hooks/pre-commit).
+ *
+ * The list is ordered by execution priority.
+ *
+ * The caller is responsible for freeing the memory of the returned list
+ * using string_list_clear() and free().
+ */
+struct string_list *list_hooks(struct repository *r, const char *hookname,
+			       struct run_hooks_opt *options);
+
 /**
  * Returns the path to the hook file, or NULL if the hook is missing
  * or disabled. Note that this points to static storage that will be
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index ed28a2fadb..d1380a4f0e 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -10,9 +10,31 @@ test_expect_success 'git hook usage' '
 	test_expect_code 129 git hook run &&
 	test_expect_code 129 git hook run -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 &&
 	grep "unknown option" err
 '
 
+test_expect_success 'git hook list: nonexistent hook' '
+	cat >stderr.expect <<-\EOF &&
+	warning: no hooks found for event '\''test-hook'\''
+	EOF
+	test_expect_code 1 git hook list test-hook 2>stderr.actual &&
+	test_cmp stderr.expect stderr.actual
+'
+
+test_expect_success 'git hook list: traditional hook from hookdir' '
+	test_hook test-hook <<-EOF &&
+	echo Test hook
+	EOF
+
+	cat >expect <<-\EOF &&
+	hook from hookdir
+	EOF
+	git hook list test-hook >actual &&
+	test_cmp expect actual
+'
+
 test_expect_success 'git hook run: nonexistent hook' '
 	cat >stderr.expect <<-\EOF &&
 	error: cannot find a hook named test-hook
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 04/12] string-list: add unsorted_string_list_remove()
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (2 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 03/12] hook: add "git hook list" command Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 05/12] hook: include hooks from the config Adrian Ratiu
                     ` (8 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Add a convenience wrapper that combines unsorted_string_list_lookup()
with unsorted_string_list_delete_item(), removing the first item
matching a given string.

This is a companion to the existing unsorted string_list helpers and
will be used in the next commits.

Suggested-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 string-list.c | 9 +++++++++
 string-list.h | 8 ++++++++
 2 files changed, 17 insertions(+)

diff --git a/string-list.c b/string-list.c
index fffa2ad4b6..d260b873c8 100644
--- a/string-list.c
+++ b/string-list.c
@@ -281,6 +281,15 @@ void unsorted_string_list_delete_item(struct string_list *list, int i, int free_
 	list->nr--;
 }
 
+void unsorted_string_list_remove(struct string_list *list, const char *str,
+				 int free_util)
+{
+	struct string_list_item *item = unsorted_string_list_lookup(list, str);
+	if (item)
+		unsorted_string_list_delete_item(list, item - list->items,
+						 free_util);
+}
+
 /*
  * append a substring [p..end] to list; return number of things it
  * appended to the list.
diff --git a/string-list.h b/string-list.h
index 3ad862a187..b86ee7c099 100644
--- a/string-list.h
+++ b/string-list.h
@@ -265,6 +265,14 @@ struct string_list_item *unsorted_string_list_lookup(struct string_list *list,
  */
 void unsorted_string_list_delete_item(struct string_list *list, int i, int free_util);
 
+/**
+ * Remove the first item matching `str` from an unsorted string_list.
+ * No-op if `str` is not found. If `free_util` is non-zero, the `util`
+ * pointer of the removed item is freed before deletion.
+ */
+void unsorted_string_list_remove(struct string_list *list, const char *str,
+				 int free_util);
+
 /**
  * Split string into substrings on characters in `delim` and append the
  * substrings to `list`.  The input string is not modified.
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 05/12] hook: include hooks from the config
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (3 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 04/12] string-list: add unsorted_string_list_remove() Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 06/12] hook: allow disabling config hooks Adrian Ratiu
                     ` (7 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

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.<friendly-name>.event"
lines and will be run sequentially (.jobs == 1) like before.
Running the hooks in parallel will be enabled in a future patch.

The "traditional" hook from the hookdir is run last, if present.

A strmap cache is added to struct repository to avoid re-reading
the configs on each rook run. This is useful for hooks like the
ref-transaction which gets executed multiple times per process.

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 |  15 +++
 Documentation/git-hook.adoc    | 128 ++++++++++++++++++++-
 builtin/hook.c                 |   3 +
 hook.c                         | 196 ++++++++++++++++++++++++++++++++-
 hook.h                         |  14 ++-
 repository.c                   |   6 +
 repository.h                   |   6 +
 t/t1800-hook.sh                | 174 ++++++++++++++++++++++++++++-
 8 files changed, 535 insertions(+), 7 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..7ac1b079cc
--- /dev/null
+++ b/Documentation/config/hook.adoc
@@ -0,0 +1,15 @@
+hook.<friendly-name>.command::
+	The command to execute for `hook.<friendly-name>`. `<friendly-name>` is a unique
+	"friendly" name that identifies this hook. (The hook events that
+	trigger the command are configured with `hook.<friendly-name>.event`.) The
+	value can be an executable path or a shell oneliner. If more than
+	one value is specified for the same `<friendly-name>`, only the last value
+	parsed is used. See linkgit:git-hook[1].
+
+hook.<friendly-name>.event::
+	The hook events that trigger `hook.<friendly-name>`. The value is the name
+	of a hook event, like "pre-commit" or "update". (See
+	linkgit:githooks[5] for a complete list of hook events.) On the
+	specified event, the associated `hook.<friendly-name>.command` is executed.
+	This is a multi-valued key. To run `hook.<friendly-name>` on multiple
+	events, specify the key more than once. See linkgit:git-hook[1].
diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index eb0ffcb8a9..0eaf864c43 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -17,12 +17,96 @@ 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.<friendly-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.<friendly-name>.event` to,
+and how hooks are invoked during those events, see linkgit:githooks[5].
+
+Git will ignore any `hook.<friendly-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 set hook.<some-name>.command <path-to-script>
+git config set --append 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 set hook.my-script.command ~/my-script.sh
+git config set --append 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. The default `<hook-name>` from
+	the hookdir is run last. See linkgit:githooks[5] for supported
+	hook names.
 +
 
 Any positional arguments to the hook should be passed after a
@@ -46,6 +130,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/builtin/hook.c b/builtin/hook.c
index 855116ba8c..4e49afb4a1 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -67,6 +67,9 @@ static int list(int argc, const char **argv, const char *prefix,
 		case HOOK_TRADITIONAL:
 			printf("%s\n", _("hook from hookdir"));
 			break;
+		case HOOK_CONFIGURED:
+			printf("%s\n", h->u.configured.friendly_name);
+			break;
 		default:
 			BUG("unknown hook kind");
 		}
diff --git a/hook.c b/hook.c
index 20c655918d..b67ec4ff22 100644
--- a/hook.c
+++ b/hook.c
@@ -4,9 +4,11 @@
 #include "gettext.h"
 #include "hook.h"
 #include "path.h"
+#include "parse.h"
 #include "run-command.h"
 #include "config.h"
 #include "strbuf.h"
+#include "strmap.h"
 #include "environment.h"
 #include "setup.h"
 
@@ -54,8 +56,12 @@ void hook_free(void *p, const char *str UNUSED)
 	if (!h)
 		return;
 
-	if (h->kind == HOOK_TRADITIONAL)
+	if (h->kind == HOOK_TRADITIONAL) {
 		free((void *)h->u.traditional.path);
+	} else if (h->kind == HOOK_CONFIGURED) {
+		free((void *)h->u.configured.friendly_name);
+		free((void *)h->u.configured.command);
+	}
 
 	if (h->data_free)
 		h->data_free(h->feed_pipe_cb_data);
@@ -95,6 +101,179 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
 	string_list_append(hook_list, hook_path)->util = h;
 }
 
+/*
+ * Callback struct to collect all hook.* keys in a single config pass.
+ * commands: friendly-name to command map.
+ * event_hooks: event-name to list of friendly-names map.
+ */
+struct hook_all_config_cb {
+	struct strmap commands;
+	struct strmap event_hooks;
+};
+
+/* repo_config() callback that collects all hook.* configuration in one pass. */
+static int hook_config_lookup_all(const char *key, const char *value,
+				  const struct config_context *ctx UNUSED,
+				  void *cb_data)
+{
+	struct hook_all_config_cb *data = cb_data;
+	const char *name, *subkey;
+	char *hook_name;
+	size_t name_len = 0;
+
+	if (parse_config_key(key, "hook", &name, &name_len, &subkey))
+		return 0;
+
+	if (!value)
+		return config_error_nonbool(key);
+
+	/* Extract name, ensuring it is null-terminated. */
+	hook_name = xmemdupz(name, name_len);
+
+	if (!strcmp(subkey, "event")) {
+		struct string_list *hooks =
+			strmap_get(&data->event_hooks, value);
+
+		if (!hooks) {
+			hooks = xcalloc(1, sizeof(*hooks));
+			string_list_init_dup(hooks);
+			strmap_put(&data->event_hooks, value, hooks);
+		}
+
+		/* Re-insert if necessary to preserve last-seen order. */
+		unsorted_string_list_remove(hooks, hook_name, 0);
+		string_list_append(hooks, hook_name);
+	} else if (!strcmp(subkey, "command")) {
+		/* Store command overwriting the old value */
+		char *old = strmap_put(&data->commands, hook_name,
+				       xstrdup(value));
+		free(old);
+	}
+
+	free(hook_name);
+	return 0;
+}
+
+/*
+ * The hook config cache maps each hook event name to a string_list where
+ * every item's string is the hook's friendly-name and its util pointer is
+ * the corresponding command string. Both strings are owned by the map.
+ *
+ * Disabled hooks and hooks missing a command are already filtered out at
+ * parse time, so callers can iterate the list directly.
+ */
+void hook_cache_clear(struct strmap *cache)
+{
+	struct hashmap_iter iter;
+	struct strmap_entry *e;
+
+	strmap_for_each_entry(cache, &iter, e) {
+		struct string_list *hooks = e->value;
+		string_list_clear(hooks, 1); /* free util (command) pointers */
+		free(hooks);
+	}
+	strmap_clear(cache, 0);
+}
+
+/* Populate `cache` with the complete hook configuration */
+static void build_hook_config_map(struct repository *r, struct strmap *cache)
+{
+	struct hook_all_config_cb cb_data;
+	struct hashmap_iter iter;
+	struct strmap_entry *e;
+
+	strmap_init(&cb_data.commands);
+	strmap_init(&cb_data.event_hooks);
+
+	/* Parse all configs in one run. */
+	repo_config(r, hook_config_lookup_all, &cb_data);
+
+	/* Construct the cache from parsed configs. */
+	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
+		struct string_list *hook_names = e->value;
+		struct string_list *hooks;
+		CALLOC_ARRAY(hooks, 1);
+
+		string_list_init_dup(hooks);
+
+		for (size_t i = 0; i < hook_names->nr; i++) {
+			const char *hname = hook_names->items[i].string;
+			char *command;
+
+			command = strmap_get(&cb_data.commands, hname);
+			if (!command)
+				die(_("'hook.%s.command' must be configured or "
+				      "'hook.%s.event' must be removed;"
+				      " aborting."), hname, hname);
+
+			/* util stores the command; owned by the cache. */
+			string_list_append(hooks, hname)->util =
+				xstrdup(command);
+		}
+
+		strmap_put(cache, e->key, hooks);
+	}
+
+	strmap_clear(&cb_data.commands, 1);
+	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
+		string_list_clear(e->value, 0);
+		free(e->value);
+	}
+	strmap_clear(&cb_data.event_hooks, 0);
+}
+
+/* Return the hook config map for `r`, populating it first if needed. */
+static struct strmap *get_hook_config_cache(struct repository *r)
+{
+	struct strmap *cache = NULL;
+
+	if (r) {
+		/*
+		 * For in-repo calls, the map is stored in r->hook_config_cache,
+		 * so repeated invocations don't parse the configs, so allocate
+		 * it just once on the first call.
+		 */
+		if (!r->hook_config_cache) {
+			CALLOC_ARRAY(r->hook_config_cache, 1);
+			strmap_init(r->hook_config_cache);
+			build_hook_config_map(r, r->hook_config_cache);
+		}
+		cache = r->hook_config_cache;
+	}
+
+	return cache;
+}
+
+static void list_hooks_add_configured(struct repository *r,
+				      const char *hookname,
+				      struct string_list *list,
+				      struct run_hooks_opt *options)
+{
+	struct strmap *cache = get_hook_config_cache(r);
+	struct string_list *configured_hooks = strmap_get(cache, hookname);
+
+	/* Iterate through configured hooks and initialize internal states */
+	for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {
+		const char *friendly_name = configured_hooks->items[i].string;
+		const char *command = configured_hooks->items[i].util;
+		struct hook *hook;
+		CALLOC_ARRAY(hook, 1);
+
+		if (options && options->feed_pipe_cb_data_alloc)
+			hook->feed_pipe_cb_data =
+				options->feed_pipe_cb_data_alloc(
+					options->feed_pipe_ctx);
+		if (options)
+			hook->data_free = options->feed_pipe_cb_data_free;
+
+		hook->kind = HOOK_CONFIGURED;
+		hook->u.configured.friendly_name = xstrdup(friendly_name);
+		hook->u.configured.command = xstrdup(command);
+
+		string_list_append(list, friendly_name)->util = hook;
+	}
+}
+
 struct string_list *list_hooks(struct repository *r, const char *hookname,
 			       struct run_hooks_opt *options)
 {
@@ -106,6 +285,9 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,
 	CALLOC_ARRAY(hook_head, 1);
 	string_list_init_dup(hook_head);
 
+	/* Add hooks from the config, e.g. hook.myhook.event = pre-commit */
+	list_hooks_add_configured(r, hookname, hook_head, options);
+
 	/* Add the default "traditional" hooks from hookdir. */
 	list_hooks_add_default(r, hookname, hook_head, options);
 
@@ -158,10 +340,18 @@ static int pick_next_hook(struct child_process *cp,
 	cp->dir = hook_cb->options->dir;
 
 	/* Add hook exec paths or commands */
-	if (h->kind == HOOK_TRADITIONAL)
+	switch (h->kind) {
+	case HOOK_TRADITIONAL:
 		strvec_push(&cp->args, h->u.traditional.path);
-	else
+		break;
+	case HOOK_CONFIGURED:
+		/* to enable oneliners, let config-specified hooks run in shell. */
+		cp->use_shell = true;
+		strvec_push(&cp->args, h->u.configured.command);
+		break;
+	default:
 		BUG("unknown hook kind");
+	}
 
 	if (!cp->args.nr)
 		BUG("hook must have at least one command or exec path");
diff --git a/hook.h b/hook.h
index 36d40c98df..fa0fdfd691 100644
--- a/hook.h
+++ b/hook.h
@@ -3,6 +3,7 @@
 #include "strvec.h"
 #include "run-command.h"
 #include "string-list.h"
+#include "strmap.h"
 
 struct repository;
 
@@ -13,17 +14,22 @@ typedef void *(*hook_data_alloc_fn)(void *init_ctx);
  * Represents a hook command to be run.
  * Hooks can be:
  * 1. "traditional" (found in the hooks directory)
- * 2. "configured" (defined in Git's configuration, not yet implemented).
+ * 2. "configured" (defined in Git's configuration via hook.<friendly-name>.event).
  * The 'kind' field determines which part of the union 'u' is valid.
  */
 struct hook {
 	enum {
 		HOOK_TRADITIONAL,
+		HOOK_CONFIGURED,
 	} kind;
 	union {
 		struct {
 			const char *path;
 		} traditional;
+		struct {
+			const char *friendly_name;
+			const char *command;
+		} configured;
 	} u;
 
 	/**
@@ -197,6 +203,12 @@ void hook_free(void *p, const char *str);
 struct string_list *list_hooks(struct repository *r, const char *hookname,
 			       struct run_hooks_opt *options);
 
+/**
+ * Frees the hook configuration cache stored in `struct repository`.
+ * Called by repo_clear().
+ */
+void hook_cache_clear(struct strmap *cache);
+
 /**
  * Returns the path to the hook file, or NULL if the hook is missing
  * or disabled. Note that this points to static storage that will be
diff --git a/repository.c b/repository.c
index 44e77cd05a..c86c6457ba 100644
--- a/repository.c
+++ b/repository.c
@@ -1,6 +1,7 @@
 #include "git-compat-util.h"
 #include "abspath.h"
 #include "repository.h"
+#include "hook.h"
 #include "odb.h"
 #include "config.h"
 #include "object.h"
@@ -399,6 +400,11 @@ void repo_clear(struct repository *repo)
 		FREE_AND_NULL(repo->index);
 	}
 
+	if (repo->hook_config_cache) {
+		hook_cache_clear(repo->hook_config_cache);
+		FREE_AND_NULL(repo->hook_config_cache);
+	}
+
 	if (repo->promisor_remote_config) {
 		promisor_remote_clear(repo->promisor_remote_config);
 		FREE_AND_NULL(repo->promisor_remote_config);
diff --git a/repository.h b/repository.h
index 72a5e9d410..8f057a241d 100644
--- a/repository.h
+++ b/repository.h
@@ -162,6 +162,12 @@ struct repository {
 	/* True if commit-graph has been disabled within this process. */
 	int commit_graph_disabled;
 
+	/*
+	 * Lazily-populated cache mapping hook event names to configured hooks.
+	 * NULL until first hook use.
+	 */
+	struct strmap *hook_config_cache;
+
 	/* Configurations related to promisor remotes. */
 	char *repository_format_partial_clone;
 	struct promisor_remote_config *promisor_remote_config;
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index d1380a4f0e..3a95cfe16d 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -1,10 +1,26 @@
 #!/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 &&
@@ -35,6 +51,15 @@ test_expect_success 'git hook list: traditional hook from hookdir' '
 	test_cmp expect actual
 '
 
+test_expect_success 'git hook list: configured hook' '
+	test_config hook.myhook.command "echo Hello" &&
+	test_config hook.myhook.event test-hook --add &&
+
+	echo "myhook" >expect &&
+	git hook list test-hook >actual &&
+	test_cmp expect actual
+'
+
 test_expect_success 'git hook run: nonexistent hook' '
 	cat >stderr.expect <<-\EOF &&
 	error: cannot find a hook named test-hook
@@ -172,6 +197,152 @@ 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 'configured hooks run before hookdir hook' '
+	setup_hookdir &&
+	test_config hook.first.event "pre-commit" &&
+	test_config hook.first.command "echo first" &&
+	test_config hook.second.event "pre-commit" &&
+	test_config hook.second.command "echo second" &&
+
+	cat >expected <<-\EOF &&
+	first
+	second
+	hook from hookdir
+	EOF
+
+	git hook list pre-commit >actual &&
+	test_cmp expected actual &&
+
+	cat >expected <<-\EOF &&
+	first
+	second
+	"Legacy Hook"
+	EOF
+
+	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" &&
+	test_config hook.stdin-a.command "xargs -P1 -I% echo a%" &&
+	test_config hook.stdin-b.event "test-hook" &&
+	test_config hook.stdin-b.command "xargs -P1 -I% echo b%" &&
+
+	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" &&
+	test_must_fail git hook list test-hook 2>actual &&
+	test_grep "hook.broken.command" actual &&
+	test_must_fail git hook run test-hook 2>actual &&
+	test_grep "hook.broken.command" actual
+'
+
 test_expect_success 'git hook run a hook with a bad shebang' '
 	test_when_finished "rm -rf bad-hooks" &&
 	mkdir bad-hooks &&
@@ -189,6 +360,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


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 06/12] hook: allow disabling config hooks
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (4 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 05/12] hook: include hooks from the config Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 07/12] hook: allow event = "" to overwrite previous values Adrian Ratiu
                     ` (6 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Hooks specified via configs are always enabled, however users
might want to disable them without removing from the config,
like locally disabling a global hook.

Add a hook.<friendly-name>.enabled config which defaults to true and
can be optionally set for each configured hook.

Suggested-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/config/hook.adoc |  7 +++++++
 hook.c                         | 24 ++++++++++++++++++++++++
 t/t1800-hook.sh                | 32 ++++++++++++++++++++++++++++++++
 3 files changed, 63 insertions(+)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index 7ac1b079cc..4bbda5636d 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -13,3 +13,10 @@ hook.<friendly-name>.event::
 	specified event, the associated `hook.<friendly-name>.command` is executed.
 	This is a multi-valued key. To run `hook.<friendly-name>` on multiple
 	events, specify the key more than once. See linkgit:git-hook[1].
+
+hook.<friendly-name>.enabled::
+	Whether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.
+	Set to `false` to disable the hook without removing its
+	configuration. This is particularly useful when a hook is defined
+	in a system or global config file and needs to be disabled for a
+	specific repository. See linkgit:git-hook[1].
diff --git a/hook.c b/hook.c
index b67ec4ff22..24eb330cac 100644
--- a/hook.c
+++ b/hook.c
@@ -105,10 +105,12 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
  * Callback struct to collect all hook.* keys in a single config pass.
  * 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.
  */
 struct hook_all_config_cb {
 	struct strmap commands;
 	struct strmap event_hooks;
+	struct string_list disabled_hooks;
 };
 
 /* repo_config() callback that collects all hook.* configuration in one pass. */
@@ -148,6 +150,21 @@ static int hook_config_lookup_all(const char *key, const char *value,
 		char *old = strmap_put(&data->commands, hook_name,
 				       xstrdup(value));
 		free(old);
+	} else if (!strcmp(subkey, "enabled")) {
+		switch (git_parse_maybe_bool(value)) {
+		case 0: /* disabled */
+			if (!unsorted_string_list_lookup(&data->disabled_hooks,
+							 hook_name))
+				string_list_append(&data->disabled_hooks,
+						   hook_name);
+			break;
+		case 1: /* enabled: undo a prior disabled entry */
+			unsorted_string_list_remove(&data->disabled_hooks,
+						    hook_name);
+			break;
+		default:
+			break; /* ignore unrecognised values */
+		}
 	}
 
 	free(hook_name);
@@ -184,6 +201,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 
 	strmap_init(&cb_data.commands);
 	strmap_init(&cb_data.event_hooks);
+	string_list_init_dup(&cb_data.disabled_hooks);
 
 	/* Parse all configs in one run. */
 	repo_config(r, hook_config_lookup_all, &cb_data);
@@ -200,6 +218,11 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 			const char *hname = hook_names->items[i].string;
 			char *command;
 
+			/* filter out disabled hooks */
+			if (unsorted_string_list_lookup(&cb_data.disabled_hooks,
+							hname))
+				continue;
+
 			command = strmap_get(&cb_data.commands, hname);
 			if (!command)
 				die(_("'hook.%s.command' must be configured or "
@@ -215,6 +238,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 	}
 
 	strmap_clear(&cb_data.commands, 1);
+	string_list_clear(&cb_data.disabled_hooks, 0);
 	strmap_for_each_entry(&cb_data.event_hooks, &iter, e) {
 		string_list_clear(e->value, 0);
 		free(e->value);
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index 3a95cfe16d..fb8cfc8137 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -343,6 +343,38 @@ test_expect_success 'rejects hooks with no commands configured' '
 	test_grep "hook.broken.command" actual
 '
 
+test_expect_success 'disabled hook is not run' '
+	test_config hook.skipped.event "test-hook" &&
+	test_config hook.skipped.command "echo \"Should not run\"" &&
+	test_config hook.skipped.enabled false &&
+
+	git hook run --ignore-missing test-hook 2>actual &&
+	test_must_be_empty actual
+'
+
+test_expect_success 'disabled hook does not appear in git hook list' '
+	test_config hook.active.event "pre-commit" &&
+	test_config hook.active.command "echo active" &&
+	test_config hook.inactive.event "pre-commit" &&
+	test_config hook.inactive.command "echo inactive" &&
+	test_config hook.inactive.enabled false &&
+
+	git hook list pre-commit >actual &&
+	test_grep "active" actual &&
+	test_grep ! "inactive" actual
+'
+
+test_expect_success 'globally disabled hook can be re-enabled locally' '
+	test_config_global hook.global-hook.event "test-hook" &&
+	test_config_global hook.global-hook.command "echo \"global-hook ran\"" &&
+	test_config_global hook.global-hook.enabled false &&
+	test_config hook.global-hook.enabled true &&
+
+	echo "global-hook ran" >expected &&
+	git hook run test-hook 2>actual &&
+	test_cmp expected actual
+'
+
 test_expect_success 'git hook run a hook with a bad shebang' '
 	test_when_finished "rm -rf bad-hooks" &&
 	mkdir bad-hooks &&
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 07/12] hook: allow event = "" to overwrite previous values
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (5 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 06/12] hook: allow disabling config hooks Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 08/12] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
                     ` (5 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Add the ability for empty events to clear previously set multivalue
variables, so the newly added "hook.*.event" behave like the other
multivalued keys.

Suggested-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/config/hook.adoc |  4 +++-
 hook.c                         | 31 ++++++++++++++++++++-----------
 t/t1800-hook.sh                | 12 ++++++++++++
 3 files changed, 35 insertions(+), 12 deletions(-)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index 4bbda5636d..d0023b2deb 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -12,7 +12,9 @@ hook.<friendly-name>.event::
 	linkgit:githooks[5] for a complete list of hook events.) On the
 	specified event, the associated `hook.<friendly-name>.command` is executed.
 	This is a multi-valued key. To run `hook.<friendly-name>` on multiple
-	events, specify the key more than once. See linkgit:git-hook[1].
+	events, specify the key more than once. An empty value resets
+	the list of events, clearing any previously defined events for
+	`hook.<friendly-name>`. See linkgit:git-hook[1].
 
 hook.<friendly-name>.enabled::
 	Whether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.
diff --git a/hook.c b/hook.c
index 24eb330cac..696919e703 100644
--- a/hook.c
+++ b/hook.c
@@ -133,18 +133,27 @@ static int hook_config_lookup_all(const char *key, const char *value,
 	hook_name = xmemdupz(name, name_len);
 
 	if (!strcmp(subkey, "event")) {
-		struct string_list *hooks =
-			strmap_get(&data->event_hooks, value);
+		if (!*value) {
+			/* Empty values reset previous events for this hook. */
+			struct hashmap_iter iter;
+			struct strmap_entry *e;
+
+			strmap_for_each_entry(&data->event_hooks, &iter, e)
+				unsorted_string_list_remove(e->value, hook_name, 0);
+		} else {
+			struct string_list *hooks =
+				strmap_get(&data->event_hooks, value);
+
+			if (!hooks) {
+				CALLOC_ARRAY(hooks, 1);
+				string_list_init_dup(hooks);
+				strmap_put(&data->event_hooks, value, hooks);
+			}
 
-		if (!hooks) {
-			hooks = xcalloc(1, sizeof(*hooks));
-			string_list_init_dup(hooks);
-			strmap_put(&data->event_hooks, value, hooks);
+			/* Re-insert if necessary to preserve last-seen order. */
+			unsorted_string_list_remove(hooks, hook_name, 0);
+			string_list_append(hooks, hook_name);
 		}
-
-		/* Re-insert if necessary to preserve last-seen order. */
-		unsorted_string_list_remove(hooks, hook_name, 0);
-		string_list_append(hooks, hook_name);
 	} else if (!strcmp(subkey, "command")) {
 		/* Store command overwriting the old value */
 		char *old = strmap_put(&data->commands, hook_name,
@@ -160,7 +169,7 @@ static int hook_config_lookup_all(const char *key, const char *value,
 			break;
 		case 1: /* enabled: undo a prior disabled entry */
 			unsorted_string_list_remove(&data->disabled_hooks,
-						    hook_name);
+						    hook_name, 0);
 			break;
 		default:
 			break; /* ignore unrecognised values */
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index fb8cfc8137..c14ec661b9 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -225,6 +225,18 @@ test_expect_success 'git hook list reorders on duplicate event declarations' '
 	test_cmp expected actual
 '
 
+test_expect_success 'git hook list: empty event value resets events' '
+	setup_hooks &&
+
+	# ghi is configured for pre-commit; reset it with an empty value
+	test_config hook.ghi.event "" --add &&
+
+	# only def should remain for pre-commit
+	echo "def" >expected &&
+	git hook list pre-commit >actual &&
+	test_cmp expected actual
+'
+
 test_expect_success 'hook can be configured for multiple events' '
 	setup_hooks &&
 
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 08/12] hook: allow out-of-repo 'git hook' invocations
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (6 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 07/12] hook: allow event = "" to overwrite previous values Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 09/12] hook: add -z option to "git hook list" Adrian Ratiu
                     ` (4 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

From: Emily Shaffer <emilyshaffer@google.com>

Since hooks can now be supplied via the config, and a config can be
present without a gitdir via the global and system configs, we can start
to allow 'git hook run' to occur without a gitdir. This enables us to do
things like run sendemail-validate hooks when running 'git send-email'
from a nongit directory.

It still doesn't make sense to look for hooks in the hookdir in nongit
repos, though, as there is no hookdir.

Signed-off-by: Emily Shaffer <emilyshaffer@google.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 git.c           |  2 +-
 hook.c          | 30 ++++++++++++++++++++++++++++--
 t/t1800-hook.sh | 16 +++++++++++-----
 3 files changed, 40 insertions(+), 8 deletions(-)

diff --git a/git.c b/git.c
index 744cb6527e..6480ff8373 100644
--- a/git.c
+++ b/git.c
@@ -587,7 +587,7 @@ static struct cmd_struct commands[] = {
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
 	{ "history", cmd_history, RUN_SETUP },
-	{ "hook", cmd_hook, RUN_SETUP },
+	{ "hook", cmd_hook, RUN_SETUP_GENTLY },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
 	{ "init-db", cmd_init_db },
diff --git a/hook.c b/hook.c
index 696919e703..9b97fa641f 100644
--- a/hook.c
+++ b/hook.c
@@ -18,6 +18,9 @@ const char *find_hook(struct repository *r, const char *name)
 
 	int found_hook;
 
+	if (!r || !r->gitdir)
+		return NULL;
+
 	repo_git_path_replace(r, &path, "hooks/%s", name);
 	found_hook = access(path.buf, X_OK) >= 0;
 #ifdef STRIP_EXTENSION
@@ -255,12 +258,18 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 	strmap_clear(&cb_data.event_hooks, 0);
 }
 
-/* Return the hook config map for `r`, populating it first if needed. */
+/*
+ * Return the hook config map for `r`, populating it first if needed.
+ *
+ * Out-of-repo calls (r->gitdir == NULL) allocate and return a temporary
+ * cache map; the caller is responsible for freeing it with
+ * hook_cache_clear() + free().
+ */
 static struct strmap *get_hook_config_cache(struct repository *r)
 {
 	struct strmap *cache = NULL;
 
-	if (r) {
+	if (r && r->gitdir) {
 		/*
 		 * For in-repo calls, the map is stored in r->hook_config_cache,
 		 * so repeated invocations don't parse the configs, so allocate
@@ -272,6 +281,14 @@ static struct strmap *get_hook_config_cache(struct repository *r)
 			build_hook_config_map(r, r->hook_config_cache);
 		}
 		cache = r->hook_config_cache;
+	} else {
+		/*
+		 * Out-of-repo calls (no gitdir) allocate and return a temporary
+		 * map cache which gets free'd immediately by the caller.
+		 */
+		CALLOC_ARRAY(cache, 1);
+		strmap_init(cache);
+		build_hook_config_map(r, cache);
 	}
 
 	return cache;
@@ -305,6 +322,15 @@ static void list_hooks_add_configured(struct repository *r,
 
 		string_list_append(list, friendly_name)->util = hook;
 	}
+
+	/*
+	 * Cleanup temporary cache for out-of-repo calls since they can't be
+	 * stored persistently. Next out-of-repo calls will have to re-parse.
+	 */
+	if (!r || !r->gitdir) {
+		hook_cache_clear(cache);
+		free(cache);
+	}
 }
 
 struct string_list *list_hooks(struct repository *r, const char *hookname,
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index c14ec661b9..856555bce5 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -130,12 +130,18 @@ test_expect_success 'git hook run -- pass arguments' '
 	test_cmp expect actual
 '
 
-test_expect_success 'git hook run -- out-of-repo runs excluded' '
-	test_hook test-hook <<-EOF &&
-	echo Test hook
-	EOF
+test_expect_success 'git hook run: out-of-repo runs execute global hooks' '
+	test_config_global hook.global-hook.event test-hook --add &&
+	test_config_global hook.global-hook.command "echo no repo no problems" --add &&
 
-	nongit test_must_fail git hook run test-hook
+	echo "global-hook" >expect &&
+	nongit git hook list test-hook >actual &&
+	test_cmp expect actual &&
+
+	echo "no repo no problems" >expect &&
+
+	nongit git hook run test-hook 2>actual &&
+	test_cmp expect actual
 '
 
 test_expect_success 'git -c core.hooksPath=<PATH> hook run' '
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 09/12] hook: add -z option to "git hook list"
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (7 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 08/12] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 10/12] hook: refactor hook_config_cache from strmap to named struct Adrian Ratiu
                     ` (3 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Add a NUL-terminate mode to git hook list, just in case hooks are
configured with weird characters like newlines in their names.

Suggested-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/git-hook.adoc |  8 ++++++--
 builtin/hook.c              |  9 ++++++---
 t/t1800-hook.sh             | 13 +++++++++++++
 3 files changed, 25 insertions(+), 5 deletions(-)

diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index 0eaf864c43..966388660a 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -9,7 +9,7 @@ SYNOPSIS
 --------
 [verse]
 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
-'git hook' list <hook-name>
+'git hook' list [-z] <hook-name>
 
 DESCRIPTION
 -----------
@@ -113,9 +113,10 @@ Any positional arguments to the hook should be passed after a
 mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See
 linkgit:githooks[5] for arguments hooks might expect (if any).
 
-list::
+list [-z]::
 	Print a list of hooks which will be run on `<hook-name>` event. If no
 	hooks are configured for that event, print a warning and return 1.
+	Use `-z` to terminate output lines with NUL instead of newlines.
 
 OPTIONS
 -------
@@ -130,6 +131,9 @@ OPTIONS
 	tools that want to do a blind one-shot run of a hook that may
 	or may not be present.
 
+-z::
+	Terminate "list" output lines with NUL instead of newlines.
+
 WRAPPERS
 --------
 
diff --git a/builtin/hook.c b/builtin/hook.c
index 4e49afb4a1..542183795a 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -10,7 +10,7 @@
 #define BUILTIN_HOOK_RUN_USAGE \
 	N_("git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
 #define BUILTIN_HOOK_LIST_USAGE \
-	N_("git hook list <hook-name>")
+	N_("git hook list [-z] <hook-name>")
 
 static const char * const builtin_hook_usage[] = {
 	BUILTIN_HOOK_RUN_USAGE,
@@ -33,9 +33,12 @@ static int list(int argc, const char **argv, const char *prefix,
 	struct string_list *head;
 	struct string_list_item *item;
 	const char *hookname = NULL;
+	int line_terminator = '\n';
 	int ret = 0;
 
 	struct option list_options[] = {
+		OPT_SET_INT('z', NULL, &line_terminator,
+			    N_("use NUL as line terminator"), '\0'),
 		OPT_END(),
 	};
 
@@ -65,10 +68,10 @@ static int list(int argc, const char **argv, const char *prefix,
 
 		switch (h->kind) {
 		case HOOK_TRADITIONAL:
-			printf("%s\n", _("hook from hookdir"));
+			printf("%s%c", _("hook from hookdir"), line_terminator);
 			break;
 		case HOOK_CONFIGURED:
-			printf("%s\n", h->u.configured.friendly_name);
+			printf("%s%c", h->u.configured.friendly_name, line_terminator);
 			break;
 		default:
 			BUG("unknown hook kind");
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index 856555bce5..0a4b2a9978 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -60,6 +60,19 @@ test_expect_success 'git hook list: configured hook' '
 	test_cmp expect actual
 '
 
+test_expect_success 'git hook list: -z shows NUL-terminated output' '
+	test_hook test-hook <<-EOF &&
+	echo Test hook
+	EOF
+	test_config hook.myhook.command "echo Hello" &&
+	test_config hook.myhook.event test-hook --add &&
+
+	printf "myhookQhook from hookdirQ" >expect &&
+	git hook list -z test-hook >actual.raw &&
+	nul_to_q <actual.raw >actual &&
+	test_cmp expect actual
+'
+
 test_expect_success 'git hook run: nonexistent hook' '
 	cat >stderr.expect <<-\EOF &&
 	error: cannot find a hook named test-hook
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 10/12] hook: refactor hook_config_cache from strmap to named struct
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (8 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 09/12] hook: add -z option to "git hook list" Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:44   ` [PATCH v3 11/12] hook: store and display scope for configured hooks in git hook list Adrian Ratiu
                     ` (2 subsequent siblings)
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Replace the raw `struct strmap *hook_config_cache` in `struct repository`
with a `struct hook_config_cache` which wraps the strmap in a named field.

Replace the bare `char *command` util pointer stored in each string_list
item with a heap-allocated `struct hook_config_cache_entry` that carries
that command string.

This is just a refactoring with no behavior changes, to give the cache
struct room to grow so it can carry the additional hook metadata we'll
be adding in the following commits.

Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 hook.c       | 63 +++++++++++++++++++++++++++++++++-------------------
 hook.h       | 10 ++++++++-
 repository.h |  3 ++-
 3 files changed, 51 insertions(+), 25 deletions(-)

diff --git a/hook.c b/hook.c
index 9b97fa641f..0bab491953 100644
--- a/hook.c
+++ b/hook.c
@@ -104,6 +104,15 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
 	string_list_append(hook_list, hook_path)->util = h;
 }
 
+/*
+ * Cache entry stored as the .util pointer of string_list items inside the
+ * hook config cache. For now carries only the command for the hook. Next
+ * commits will add more data.
+ */
+struct hook_config_cache_entry {
+	char *command;
+};
+
 /*
  * Callback struct to collect all hook.* keys in a single config pass.
  * commands: friendly-name to command map.
@@ -186,26 +195,32 @@ static int hook_config_lookup_all(const char *key, const char *value,
 /*
  * The hook config cache maps each hook event name to a string_list where
  * every item's string is the hook's friendly-name and its util pointer is
- * the corresponding command string. Both strings are owned by the map.
+ * a hook_config_cache_entry. All strings are owned by the map.
  *
  * Disabled hooks and hooks missing a command are already filtered out at
  * parse time, so callers can iterate the list directly.
  */
-void hook_cache_clear(struct strmap *cache)
+void hook_cache_clear(struct hook_config_cache *cache)
 {
 	struct hashmap_iter iter;
 	struct strmap_entry *e;
 
-	strmap_for_each_entry(cache, &iter, e) {
+	strmap_for_each_entry(&cache->hooks, &iter, e) {
 		struct string_list *hooks = e->value;
-		string_list_clear(hooks, 1); /* free util (command) pointers */
+		for (size_t i = 0; i < hooks->nr; i++) {
+			struct hook_config_cache_entry *entry = hooks->items[i].util;
+			free(entry->command);
+			free(entry);
+		}
+		string_list_clear(hooks, 0);
 		free(hooks);
 	}
-	strmap_clear(cache, 0);
+	strmap_clear(&cache->hooks, 0);
 }
 
 /* Populate `cache` with the complete hook configuration */
-static void build_hook_config_map(struct repository *r, struct strmap *cache)
+static void build_hook_config_map(struct repository *r,
+				  struct hook_config_cache *cache)
 {
 	struct hook_all_config_cb cb_data;
 	struct hashmap_iter iter;
@@ -228,6 +243,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 
 		for (size_t i = 0; i < hook_names->nr; i++) {
 			const char *hname = hook_names->items[i].string;
+			struct hook_config_cache_entry *entry;
 			char *command;
 
 			/* filter out disabled hooks */
@@ -241,12 +257,13 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 				      "'hook.%s.event' must be removed;"
 				      " aborting."), hname, hname);
 
-			/* util stores the command; owned by the cache. */
-			string_list_append(hooks, hname)->util =
-				xstrdup(command);
+			/* util stores a cache entry; owned by the cache. */
+			CALLOC_ARRAY(entry, 1);
+			entry->command = xstrdup(command);
+			string_list_append(hooks, hname)->util = entry;
 		}
 
-		strmap_put(cache, e->key, hooks);
+		strmap_put(&cache->hooks, e->key, hooks);
 	}
 
 	strmap_clear(&cb_data.commands, 1);
@@ -259,35 +276,35 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)
 }
 
 /*
- * Return the hook config map for `r`, populating it first if needed.
+ * Return the hook config cache for `r`, populating it first if needed.
  *
  * Out-of-repo calls (r->gitdir == NULL) allocate and return a temporary
- * cache map; the caller is responsible for freeing it with
+ * cache; the caller is responsible for freeing it with
  * hook_cache_clear() + free().
  */
-static struct strmap *get_hook_config_cache(struct repository *r)
+static struct hook_config_cache *get_hook_config_cache(struct repository *r)
 {
-	struct strmap *cache = NULL;
+	struct hook_config_cache *cache = NULL;
 
 	if (r && r->gitdir) {
 		/*
-		 * For in-repo calls, the map is stored in r->hook_config_cache,
-		 * so repeated invocations don't parse the configs, so allocate
+		 * For in-repo calls, the cache is stored in r->hook_config_cache,
+		 * so repeated invocations don't parse the configs; allocate
 		 * it just once on the first call.
 		 */
 		if (!r->hook_config_cache) {
 			CALLOC_ARRAY(r->hook_config_cache, 1);
-			strmap_init(r->hook_config_cache);
+			strmap_init(&r->hook_config_cache->hooks);
 			build_hook_config_map(r, r->hook_config_cache);
 		}
 		cache = r->hook_config_cache;
 	} else {
 		/*
 		 * Out-of-repo calls (no gitdir) allocate and return a temporary
-		 * map cache which gets free'd immediately by the caller.
+		 * cache which gets freed immediately by the caller.
 		 */
 		CALLOC_ARRAY(cache, 1);
-		strmap_init(cache);
+		strmap_init(&cache->hooks);
 		build_hook_config_map(r, cache);
 	}
 
@@ -299,13 +316,13 @@ static void list_hooks_add_configured(struct repository *r,
 				      struct string_list *list,
 				      struct run_hooks_opt *options)
 {
-	struct strmap *cache = get_hook_config_cache(r);
-	struct string_list *configured_hooks = strmap_get(cache, hookname);
+	struct hook_config_cache *cache = get_hook_config_cache(r);
+	struct string_list *configured_hooks = strmap_get(&cache->hooks, hookname);
 
 	/* Iterate through configured hooks and initialize internal states */
 	for (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {
 		const char *friendly_name = configured_hooks->items[i].string;
-		const char *command = configured_hooks->items[i].util;
+		struct hook_config_cache_entry *entry = configured_hooks->items[i].util;
 		struct hook *hook;
 		CALLOC_ARRAY(hook, 1);
 
@@ -318,7 +335,7 @@ static void list_hooks_add_configured(struct repository *r,
 
 		hook->kind = HOOK_CONFIGURED;
 		hook->u.configured.friendly_name = xstrdup(friendly_name);
-		hook->u.configured.command = xstrdup(command);
+		hook->u.configured.command = xstrdup(entry->command);
 
 		string_list_append(list, friendly_name)->util = hook;
 	}
diff --git a/hook.h b/hook.h
index fa0fdfd691..277390b744 100644
--- a/hook.h
+++ b/hook.h
@@ -203,11 +203,19 @@ void hook_free(void *p, const char *str);
 struct string_list *list_hooks(struct repository *r, const char *hookname,
 			       struct run_hooks_opt *options);
 
+/**
+ * Persistent cache for hook configuration, stored on `struct repository`.
+ * Populated lazily on first hook use and freed by repo_clear().
+ */
+struct hook_config_cache {
+	struct strmap hooks; /* maps event name -> string_list of hooks */
+};
+
 /**
  * Frees the hook configuration cache stored in `struct repository`.
  * Called by repo_clear().
  */
-void hook_cache_clear(struct strmap *cache);
+void hook_cache_clear(struct hook_config_cache *cache);
 
 /**
  * Returns the path to the hook file, or NULL if the hook is missing
diff --git a/repository.h b/repository.h
index 8f057a241d..65748a5283 100644
--- a/repository.h
+++ b/repository.h
@@ -11,6 +11,7 @@ struct lock_file;
 struct pathspec;
 struct object_database;
 struct submodule_cache;
+struct hook_config_cache;
 struct promisor_remote_config;
 struct remote_state;
 
@@ -166,7 +167,7 @@ struct repository {
 	 * Lazily-populated cache mapping hook event names to configured hooks.
 	 * NULL until first hook use.
 	 */
-	struct strmap *hook_config_cache;
+	struct hook_config_cache *hook_config_cache;
 
 	/* Configurations related to promisor remotes. */
 	char *repository_format_partial_clone;
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 11/12] hook: store and display scope for configured hooks in git hook list
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (9 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 10/12] hook: refactor hook_config_cache from strmap to named struct Adrian Ratiu
@ 2026-03-01 18:44   ` Adrian Ratiu
  2026-03-01 18:45   ` [PATCH v3 12/12] hook: show disabled hooks in "git hook list" Adrian Ratiu
  2026-03-02 16:48   ` [PATCH v3 00/12][next] Specify hooks via configs Junio C Hamano
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:44 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Users running "git hook list" can see which hooks are configured but
have no way to tell at which config scope (local, global, system...)
each hook was defined.

Store the scope from ctx->kvi->scope in the single-pass config callback,
then carry it through the cache to the hook structs, then expose it to
the users via the "git hook list --show-scope" flag, which mirrors the
existing git config --show-scope convention.

Without the flag the output is unchanged.

$ git hook list --show-scope pre-commit
linter (global)
no-leaks (local)
hook from hookdir

Traditional hooks from the hookdir are unaffected by --show-scope since
the config scope concept does not apply to them.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 Documentation/git-hook.adoc |  9 +++++++--
 builtin/hook.c              | 14 ++++++++++++--
 hook.c                      | 17 +++++++++++++----
 hook.h                      |  2 ++
 t/t1800-hook.sh             | 19 +++++++++++++++++++
 5 files changed, 53 insertions(+), 8 deletions(-)

diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index 966388660a..4d4e728327 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -9,7 +9,7 @@ SYNOPSIS
 --------
 [verse]
 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]
-'git hook' list [-z] <hook-name>
+'git hook' list [-z] [--show-scope] <hook-name>
 
 DESCRIPTION
 -----------
@@ -113,7 +113,7 @@ Any positional arguments to the hook should be passed after a
 mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See
 linkgit:githooks[5] for arguments hooks might expect (if any).
 
-list [-z]::
+list [-z] [--show-scope]::
 	Print a list of hooks which will be run on `<hook-name>` event. If no
 	hooks are configured for that event, print a warning and return 1.
 	Use `-z` to terminate output lines with NUL instead of newlines.
@@ -134,6 +134,11 @@ OPTIONS
 -z::
 	Terminate "list" output lines with NUL instead of newlines.
 
+--show-scope::
+	For "list"; print the config scope (e.g. `local`, `global`, `system`)
+	in parentheses after the friendly name of each configured hook, to show
+	where it was defined. Traditional hooks from the hookdir are unaffected.
+
 WRAPPERS
 --------
 
diff --git a/builtin/hook.c b/builtin/hook.c
index 542183795a..eb8dfcd31b 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -10,7 +10,7 @@
 #define BUILTIN_HOOK_RUN_USAGE \
 	N_("git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]")
 #define BUILTIN_HOOK_LIST_USAGE \
-	N_("git hook list [-z] <hook-name>")
+	N_("git hook list [-z] [--show-scope] <hook-name>")
 
 static const char * const builtin_hook_usage[] = {
 	BUILTIN_HOOK_RUN_USAGE,
@@ -34,11 +34,14 @@ static int list(int argc, const char **argv, const char *prefix,
 	struct string_list_item *item;
 	const char *hookname = NULL;
 	int line_terminator = '\n';
+	int show_scope = 0;
 	int ret = 0;
 
 	struct option list_options[] = {
 		OPT_SET_INT('z', NULL, &line_terminator,
 			    N_("use NUL as line terminator"), '\0'),
+		OPT_BOOL(0, "show-scope", &show_scope,
+			 N_("show the config scope that defined each hook")),
 		OPT_END(),
 	};
 
@@ -71,7 +74,14 @@ static int list(int argc, const char **argv, const char *prefix,
 			printf("%s%c", _("hook from hookdir"), line_terminator);
 			break;
 		case HOOK_CONFIGURED:
-			printf("%s%c", h->u.configured.friendly_name, line_terminator);
+			if (show_scope)
+				printf("%s (%s)%c",
+				       h->u.configured.friendly_name,
+				       config_scope_name(h->u.configured.scope),
+				       line_terminator);
+			else
+				printf("%s%c", h->u.configured.friendly_name,
+				       line_terminator);
 			break;
 		default:
 			BUG("unknown hook kind");
diff --git a/hook.c b/hook.c
index 0bab491953..a868d8cf17 100644
--- a/hook.c
+++ b/hook.c
@@ -106,11 +106,11 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
 
 /*
  * Cache entry stored as the .util pointer of string_list items inside the
- * hook config cache. For now carries only the command for the hook. Next
- * commits will add more data.
+ * hook config cache.
  */
 struct hook_config_cache_entry {
 	char *command;
+	enum config_scope scope;
 };
 
 /*
@@ -127,7 +127,7 @@ struct hook_all_config_cb {
 
 /* repo_config() callback that collects all hook.* configuration in one pass. */
 static int hook_config_lookup_all(const char *key, const char *value,
-				  const struct config_context *ctx UNUSED,
+				  const struct config_context *ctx,
 				  void *cb_data)
 {
 	struct hook_all_config_cb *data = cb_data;
@@ -164,7 +164,12 @@ static int hook_config_lookup_all(const char *key, const char *value,
 
 			/* Re-insert if necessary to preserve last-seen order. */
 			unsorted_string_list_remove(hooks, hook_name, 0);
-			string_list_append(hooks, hook_name);
+			/*
+			 * Store the config scope in util so callers can
+			 * report where each hook was defined.
+			 */
+			string_list_append(hooks, hook_name)->util =
+				(void *)(uintptr_t)ctx->kvi->scope;
 		}
 	} else if (!strcmp(subkey, "command")) {
 		/* Store command overwriting the old value */
@@ -243,6 +248,8 @@ static void build_hook_config_map(struct repository *r,
 
 		for (size_t i = 0; i < hook_names->nr; i++) {
 			const char *hname = hook_names->items[i].string;
+			enum config_scope scope =
+				(enum config_scope)(uintptr_t)hook_names->items[i].util;
 			struct hook_config_cache_entry *entry;
 			char *command;
 
@@ -260,6 +267,7 @@ static void build_hook_config_map(struct repository *r,
 			/* util stores a cache entry; owned by the cache. */
 			CALLOC_ARRAY(entry, 1);
 			entry->command = xstrdup(command);
+			entry->scope = scope;
 			string_list_append(hooks, hname)->util = entry;
 		}
 
@@ -336,6 +344,7 @@ static void list_hooks_add_configured(struct repository *r,
 		hook->kind = HOOK_CONFIGURED;
 		hook->u.configured.friendly_name = xstrdup(friendly_name);
 		hook->u.configured.command = xstrdup(entry->command);
+		hook->u.configured.scope = entry->scope;
 
 		string_list_append(list, friendly_name)->util = hook;
 	}
diff --git a/hook.h b/hook.h
index 277390b744..b862cde01b 100644
--- a/hook.h
+++ b/hook.h
@@ -1,5 +1,6 @@
 #ifndef HOOK_H
 #define HOOK_H
+#include "config.h"
 #include "strvec.h"
 #include "run-command.h"
 #include "string-list.h"
@@ -29,6 +30,7 @@ struct hook {
 		struct {
 			const char *friendly_name;
 			const char *command;
+			enum config_scope scope;
 		} configured;
 	} u;
 
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index 0a4b2a9978..6e36ac5229 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -406,6 +406,25 @@ test_expect_success 'globally disabled hook can be re-enabled locally' '
 	test_cmp expected actual
 '
 
+test_expect_success 'git hook list --show-scope shows config scope' '
+	test_config_global hook.global-hook.command "echo global" &&
+	test_config_global hook.global-hook.event test-hook --add &&
+	test_config hook.local-hook.command "echo local" &&
+	test_config hook.local-hook.event test-hook --add &&
+
+	cat >expected <<-\EOF &&
+	global-hook (global)
+	local-hook (local)
+	EOF
+	git hook list --show-scope test-hook >actual &&
+	test_cmp expected actual &&
+
+	# without --show-scope the scope must not appear
+	git hook list test-hook >actual &&
+	test_grep ! "(global)" actual &&
+	test_grep ! "(local)" actual
+'
+
 test_expect_success 'git hook run a hook with a bad shebang' '
 	test_when_finished "rm -rf bad-hooks" &&
 	mkdir bad-hooks &&
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* [PATCH v3 12/12] hook: show disabled hooks in "git hook list"
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (10 preceding siblings ...)
  2026-03-01 18:44   ` [PATCH v3 11/12] hook: store and display scope for configured hooks in git hook list Adrian Ratiu
@ 2026-03-01 18:45   ` Adrian Ratiu
  2026-03-02 16:48   ` [PATCH v3 00/12][next] Specify hooks via configs Junio C Hamano
  12 siblings, 0 replies; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-01 18:45 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Emily Shaffer, Junio C Hamano, Patrick Steinhardt,
	Josh Steadmon, Kristoffer Haugsbakk, Adrian Ratiu

Disabled hooks were filtered out of the cache entirely, making them
invisible to "git hook list". Keep them in the cache with a new
"disabled" flag which is propagated to the respective struct hook.

"git hook list" now shows disabled hooks annotated with "(disabled)"
in the config order. With --show-scope, it looks like:

$ git hook list --show-scope pre-commit
linter (global)
no-leaks (local, disabled)
hook from hookdir

A disabled hook without a command issues a warning instead of the
fatal "hook.X.command must be configured" error. We could also throw
an error, however it seemd a bit excessive to me in this case.

Suggested-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>
---
 builtin/hook.c  | 18 +++++++++-------
 hook.c          | 56 ++++++++++++++++++++++++++++++++++---------------
 hook.h          |  1 +
 t/t1800-hook.sh | 33 ++++++++++++++++++++++++++---
 4 files changed, 81 insertions(+), 27 deletions(-)

diff --git a/builtin/hook.c b/builtin/hook.c
index eb8dfcd31b..ca00a57094 100644
--- a/builtin/hook.c
+++ b/builtin/hook.c
@@ -73,16 +73,20 @@ static int list(int argc, const char **argv, const char *prefix,
 		case HOOK_TRADITIONAL:
 			printf("%s%c", _("hook from hookdir"), line_terminator);
 			break;
-		case HOOK_CONFIGURED:
-			if (show_scope)
-				printf("%s (%s)%c",
-				       h->u.configured.friendly_name,
-				       config_scope_name(h->u.configured.scope),
+		case HOOK_CONFIGURED: {
+			const char *name = h->u.configured.friendly_name;
+			const char *scope = show_scope ?
+				config_scope_name(h->u.configured.scope) : NULL;
+			if (scope)
+				printf("%s (%s%s)%c", name, scope,
+				       h->u.configured.disabled ? ", disabled" : "",
 				       line_terminator);
+			else if (h->u.configured.disabled)
+				printf("%s (disabled)%c", name, line_terminator);
 			else
-				printf("%s%c", h->u.configured.friendly_name,
-				       line_terminator);
+				printf("%s%c", name, line_terminator);
 			break;
+		}
 		default:
 			BUG("unknown hook kind");
 		}
diff --git a/hook.c b/hook.c
index a868d8cf17..5924324129 100644
--- a/hook.c
+++ b/hook.c
@@ -111,6 +111,7 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,
 struct hook_config_cache_entry {
 	char *command;
 	enum config_scope scope;
+	int disabled;
 };
 
 /*
@@ -202,8 +203,10 @@ static int hook_config_lookup_all(const char *key, const char *value,
  * every item's string is the hook's friendly-name and its util pointer is
  * a hook_config_cache_entry. All strings are owned by the map.
  *
- * Disabled hooks and hooks missing a command are already filtered out at
- * parse time, so callers can iterate the list directly.
+ * Disabled hooks are kept in the cache with entry->disabled set, so that
+ * "git hook list" can display them. Hooks missing a command are filtered
+ * out at build time; if a disabled hook has no command it is silently
+ * skipped rather than triggering a fatal error.
  */
 void hook_cache_clear(struct hook_config_cache *cache)
 {
@@ -253,21 +256,26 @@ static void build_hook_config_map(struct repository *r,
 			struct hook_config_cache_entry *entry;
 			char *command;
 
-			/* filter out disabled hooks */
-			if (unsorted_string_list_lookup(&cb_data.disabled_hooks,
-							hname))
-				continue;
+			int is_disabled =
+				!!unsorted_string_list_lookup(
+					&cb_data.disabled_hooks, hname);
 
 			command = strmap_get(&cb_data.commands, hname);
-			if (!command)
-				die(_("'hook.%s.command' must be configured or "
-				      "'hook.%s.event' must be removed;"
-				      " aborting."), hname, hname);
+			if (!command) {
+				if (is_disabled)
+					warning(_("disabled hook '%s' has no "
+						  "command configured"), hname);
+				else
+					die(_("'hook.%s.command' must be configured or "
+					      "'hook.%s.event' must be removed;"
+					      " aborting."), hname, hname);
+			}
 
 			/* util stores a cache entry; owned by the cache. */
 			CALLOC_ARRAY(entry, 1);
-			entry->command = xstrdup(command);
+			entry->command = command ? xstrdup(command) : NULL;
 			entry->scope = scope;
+			entry->disabled = is_disabled;
 			string_list_append(hooks, hname)->util = entry;
 		}
 
@@ -343,8 +351,10 @@ static void list_hooks_add_configured(struct repository *r,
 
 		hook->kind = HOOK_CONFIGURED;
 		hook->u.configured.friendly_name = xstrdup(friendly_name);
-		hook->u.configured.command = xstrdup(entry->command);
+		hook->u.configured.command =
+			entry->command ? xstrdup(entry->command) : NULL;
 		hook->u.configured.scope = entry->scope;
+		hook->u.configured.disabled = entry->disabled;
 
 		string_list_append(list, friendly_name)->util = hook;
 	}
@@ -382,7 +392,16 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,
 int hook_exists(struct repository *r, const char *name)
 {
 	struct string_list *hooks = list_hooks(r, name, NULL);
-	int exists = hooks->nr > 0;
+	int exists = 0;
+
+	for (size_t i = 0; i < hooks->nr; i++) {
+		struct hook *h = hooks->items[i].util;
+		if (h->kind == HOOK_TRADITIONAL ||
+		    !h->u.configured.disabled) {
+			exists = 1;
+			break;
+		}
+	}
 	string_list_clear_func(hooks, hook_free);
 	free(hooks);
 	return exists;
@@ -397,10 +416,11 @@ static int pick_next_hook(struct child_process *cp,
 	struct string_list *hook_list = hook_cb->hook_command_list;
 	struct hook *h;
 
-	if (hook_cb->hook_to_run_index >= hook_list->nr)
-		return 0;
-
-	h = hook_list->items[hook_cb->hook_to_run_index++].util;
+	do {
+		if (hook_cb->hook_to_run_index >= hook_list->nr)
+			return 0;
+		h = hook_list->items[hook_cb->hook_to_run_index++].util;
+	} while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled);
 
 	cp->no_stdin = 1;
 	strvec_pushv(&cp->env, hook_cb->options->env.v);
@@ -432,6 +452,8 @@ static int pick_next_hook(struct child_process *cp,
 	case HOOK_CONFIGURED:
 		/* to enable oneliners, let config-specified hooks run in shell. */
 		cp->use_shell = true;
+		if (!h->u.configured.command)
+			BUG("non-disabled HOOK_CONFIGURED hook has no command");
 		strvec_push(&cp->args, h->u.configured.command);
 		break;
 	default:
diff --git a/hook.h b/hook.h
index b862cde01b..276433f721 100644
--- a/hook.h
+++ b/hook.h
@@ -31,6 +31,7 @@ struct hook {
 			const char *friendly_name;
 			const char *command;
 			enum config_scope scope;
+			int disabled;
 		} configured;
 	} u;
 
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index 6e36ac5229..3f8c5ff450 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -383,7 +383,15 @@ test_expect_success 'disabled hook is not run' '
 	test_must_be_empty actual
 '
 
-test_expect_success 'disabled hook does not appear in git hook list' '
+test_expect_success 'disabled hook with no command warns' '
+	test_config hook.nocommand.event "pre-commit" &&
+	test_config hook.nocommand.enabled false &&
+
+	git hook list pre-commit 2>actual &&
+	test_grep "disabled hook.*nocommand.*no command configured" actual
+'
+
+test_expect_success 'disabled hook appears as disabled in git hook list' '
 	test_config hook.active.event "pre-commit" &&
 	test_config hook.active.command "echo active" &&
 	test_config hook.inactive.event "pre-commit" &&
@@ -391,8 +399,27 @@ test_expect_success 'disabled hook does not appear in git hook list' '
 	test_config hook.inactive.enabled false &&
 
 	git hook list pre-commit >actual &&
-	test_grep "active" actual &&
-	test_grep ! "inactive" actual
+	test_grep "^active$" actual &&
+	test_grep "^inactive (disabled)$" actual
+'
+
+test_expect_success 'disabled hook shows scope with --show-scope' '
+	test_config hook.myhook.event "pre-commit" &&
+	test_config hook.myhook.command "echo hi" &&
+	test_config hook.myhook.enabled false &&
+
+	git hook list --show-scope pre-commit >actual &&
+	test_grep "myhook (local, disabled)" actual
+'
+
+test_expect_success 'disabled configured hook is not reported as existing by hook_exists' '
+	test_when_finished "rm -f git-bugreport-hook-exists-test.txt" &&
+	test_config hook.linter.event "pre-commit" &&
+	test_config hook.linter.command "echo lint" &&
+	test_config hook.linter.enabled false &&
+
+	git bugreport -s hook-exists-test &&
+	test_grep ! "pre-commit" git-bugreport-hook-exists-test.txt
 '
 
 test_expect_success 'globally disabled hook can be re-enabled locally' '
-- 
2.52.0.732.gb351b5166d.dirty


^ permalink raw reply related	[flat|nested] 69+ messages in thread

* Re: [PATCH v3 00/12][next] Specify hooks via configs
  2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
                     ` (11 preceding siblings ...)
  2026-03-01 18:45   ` [PATCH v3 12/12] hook: show disabled hooks in "git hook list" Adrian Ratiu
@ 2026-03-02 16:48   ` Junio C Hamano
  2026-03-02 17:04     ` Adrian Ratiu
  12 siblings, 1 reply; 69+ messages in thread
From: Junio C Hamano @ 2026-03-02 16:48 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

> Hello everyone,
>
> v3 addresses all feedback received in v2 (details below, including range-diff).
>
> This series adds a new feature: the ability to specify commands to run
> for hook events via config entries (including shell commands).
>
> So instead of dropping a shell script or a custom program in .git/hooks
> you can now tell git via config files to run a program or shell script
> (can be specified directly in the config) when you run hook "foo".
>
> This also means you can setup global hooks to run in multiple repos via
> global configs and there's an option to disable them if necessary.
>
> For simplicity, because this series is becoming rather big, hooks are
> still executed sequentially (.jobs == 1). Parallel execution is added
> in another follow-up patch series.
>
> This is based on the latest next branch because it depends on some
> commits which haven't yet landed in master.

Please don't depend a series on 'next'.  That will make your topic
taken hostage by _every_ topic there.

Besides, the ar/config-hooks topic has been in 'next' for the last
few days already, and it is time to go incremental updates.

Thanks.

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v3 00/12][next] Specify hooks via configs
  2026-03-02 16:48   ` [PATCH v3 00/12][next] Specify hooks via configs Junio C Hamano
@ 2026-03-02 17:04     ` Adrian Ratiu
  2026-03-02 18:48       ` Junio C Hamano
  0 siblings, 1 reply; 69+ messages in thread
From: Adrian Ratiu @ 2026-03-02 17:04 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

On Mon, 02 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:
> Adrian Ratiu <adrian.ratiu@collabora.com> writes:
>
>> Hello everyone,
>>
>> v3 addresses all feedback received in v2 (details below, including range-diff).
>>
>> This series adds a new feature: the ability to specify commands to run
>> for hook events via config entries (including shell commands).
>>
>> So instead of dropping a shell script or a custom program in .git/hooks
>> you can now tell git via config files to run a program or shell script
>> (can be specified directly in the config) when you run hook "foo".
>>
>> This also means you can setup global hooks to run in multiple repos via
>> global configs and there's an option to disable them if necessary.
>>
>> For simplicity, because this series is becoming rather big, hooks are
>> still executed sequentially (.jobs == 1). Parallel execution is added
>> in another follow-up patch series.
>>
>> This is based on the latest next branch because it depends on some
>> commits which haven't yet landed in master.
>
> Please don't depend a series on 'next'.  That will make your topic
> taken hostage by _every_ topic there.
>
> Besides, the ar/config-hooks topic has been in 'next' for the last
> few days already, and it is time to go incremental updates.

Understood. I don't think there is anything breaking in the config topic
v2 as it landed, so I'll create incremental patches on top of it to
address the feedback I've addressed in v3 here.

Thanks!

^ permalink raw reply	[flat|nested] 69+ messages in thread

* Re: [PATCH v3 00/12][next] Specify hooks via configs
  2026-03-02 17:04     ` Adrian Ratiu
@ 2026-03-02 18:48       ` Junio C Hamano
  0 siblings, 0 replies; 69+ messages in thread
From: Junio C Hamano @ 2026-03-02 18:48 UTC (permalink / raw)
  To: Adrian Ratiu
  Cc: git, Jeff King, Emily Shaffer, Patrick Steinhardt, Josh Steadmon,
	Kristoffer Haugsbakk

Adrian Ratiu <adrian.ratiu@collabora.com> writes:

>> Please don't depend a series on 'next'.  That will make your topic
>> taken hostage by _every_ topic there.
>>
>> Besides, the ar/config-hooks topic has been in 'next' for the last
>> few days already, and it is time to go incremental updates.
>
> Understood. I don't think there is anything breaking in the config topic
> v2 as it landed, so I'll create incremental patches on top of it to
> address the feedback I've addressed in v3 here.

Thanks.

^ permalink raw reply	[flat|nested] 69+ messages in thread

end of thread, other threads:[~2026-03-02 18:48 UTC | newest]

Thread overview: 69+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-02-04 16:51 [PATCH 0/4] Specify hooks via configs Adrian Ratiu
2026-02-04 16:51 ` [PATCH 1/4] hook: run a list of hooks Adrian Ratiu
2026-02-05 21:59   ` Junio C Hamano
2026-02-06 11:21     ` Adrian Ratiu
2026-02-09 14:27   ` Patrick Steinhardt
2026-02-09 18:16     ` Adrian Ratiu
2026-02-10 13:43       ` Patrick Steinhardt
2026-02-04 16:51 ` [PATCH 2/4] hook: introduce "git hook list" Adrian Ratiu
2026-02-09 14:28   ` Patrick Steinhardt
2026-02-09 18:26     ` Adrian Ratiu
2026-02-04 16:51 ` [PATCH 3/4] hook: include hooks from the config Adrian Ratiu
2026-02-09 14:28   ` Patrick Steinhardt
2026-02-09 19:10     ` Adrian Ratiu
2026-02-10 13:43       ` Patrick Steinhardt
2026-02-10 13:56         ` Adrian Ratiu
2026-02-04 16:51 ` [PATCH 4/4] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
2026-02-06 16:26 ` [PATCH 0/4] Specify hooks via configs Junio C Hamano
2026-02-18 22:23 ` [PATCH v2 0/8] " Adrian Ratiu
2026-02-18 22:23   ` [PATCH v2 1/8] hook: add internal state alloc/free callbacks Adrian Ratiu
2026-02-19 21:47     ` Junio C Hamano
2026-02-20 12:35       ` Adrian Ratiu
2026-02-20 17:21         ` Junio C Hamano
2026-02-20 12:42       ` Adrian Ratiu
2026-02-20 12:45     ` Patrick Steinhardt
2026-02-20 13:40       ` Adrian Ratiu
2026-02-18 22:23   ` [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support Adrian Ratiu
2026-02-20 12:46     ` Patrick Steinhardt
2026-02-20 13:51       ` Adrian Ratiu
2026-02-18 22:23   ` [PATCH v2 3/8] hook: add "git hook list" command Adrian Ratiu
2026-02-20 12:46     ` Patrick Steinhardt
2026-02-20 13:53       ` Adrian Ratiu
2026-02-18 22:23   ` [PATCH v2 4/8] hook: include hooks from the config Adrian Ratiu
2026-02-19 22:16     ` Junio C Hamano
2026-02-20 12:27       ` Adrian Ratiu
2026-02-20 12:46     ` Patrick Steinhardt
2026-02-20 14:31       ` Adrian Ratiu
2026-02-18 22:23   ` [PATCH v2 5/8] hook: allow disabling config hooks Adrian Ratiu
2026-02-20 12:46     ` Patrick Steinhardt
2026-02-20 14:47       ` Adrian Ratiu
2026-02-20 18:40         ` Patrick Steinhardt
2026-02-20 18:45           ` Junio C Hamano
2026-02-18 22:23   ` [PATCH v2 6/8] hook: allow event = "" to overwrite previous values Adrian Ratiu
2026-02-18 22:23   ` [PATCH v2 7/8] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
2026-02-18 22:23   ` [PATCH v2 8/8] hook: add -z option to "git hook list" Adrian Ratiu
2026-02-19 21:34   ` [PATCH v2 0/8] Specify hooks via configs Junio C Hamano
2026-02-20 12:51     ` Adrian Ratiu
2026-02-20 23:29   ` brian m. carlson
2026-02-21 14:27     ` Adrian Ratiu
2026-02-22  0:39       ` Adrian Ratiu
2026-02-25 18:37         ` Junio C Hamano
2026-02-26 12:21           ` Adrian Ratiu
2026-02-25 22:30         ` brian m. carlson
2026-02-26 12:41           ` Adrian Ratiu
2026-03-01 18:44 ` [PATCH v3 00/12][next] " Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 01/12] hook: add internal state alloc/free callbacks Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 02/12] hook: run a list of hooks to prepare for multihook support Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 03/12] hook: add "git hook list" command Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 04/12] string-list: add unsorted_string_list_remove() Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 05/12] hook: include hooks from the config Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 06/12] hook: allow disabling config hooks Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 07/12] hook: allow event = "" to overwrite previous values Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 08/12] hook: allow out-of-repo 'git hook' invocations Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 09/12] hook: add -z option to "git hook list" Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 10/12] hook: refactor hook_config_cache from strmap to named struct Adrian Ratiu
2026-03-01 18:44   ` [PATCH v3 11/12] hook: store and display scope for configured hooks in git hook list Adrian Ratiu
2026-03-01 18:45   ` [PATCH v3 12/12] hook: show disabled hooks in "git hook list" Adrian Ratiu
2026-03-02 16:48   ` [PATCH v3 00/12][next] Specify hooks via configs Junio C Hamano
2026-03-02 17:04     ` Adrian Ratiu
2026-03-02 18:48       ` Junio C Hamano

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox