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