aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJunio C Hamano <gitster@pobox.com>2026-03-10 14:23:18 -0700
committerJunio C Hamano <gitster@pobox.com>2026-03-10 14:23:18 -0700
commitf330d46deeb143b6109143f37a47d025475d11d6 (patch)
treec2b93a2ec12c0e9c8227be1a362c0fd261d6376a
parent9a8aebae972de22ecd5adb92fec9d77147949c8a (diff)
parentec1c4d974ac74afb4f0574d29f7bbb30c1c46431 (diff)
downloadgit-f330d46deeb143b6109143f37a47d025475d11d6.tar.xz
Merge branch 'ar/config-hooks'
Allow hook commands to be defined (possibly centrally) in the configuration files, and run multiple of them for the same hook event. * ar/config-hooks: hook: add -z option to "git hook list" hook: allow out-of-repo 'git hook' invocations hook: allow event = "" to overwrite previous values hook: allow disabling config hooks hook: include hooks from the config hook: add "git hook list" command hook: run a list of hooks to prepare for multihook support hook: add internal state alloc/free callbacks
-rw-r--r--Documentation/config/hook.adoc24
-rw-r--r--Documentation/git-hook.adoc137
-rw-r--r--builtin/hook.c66
-rw-r--r--builtin/receive-pack.c33
-rw-r--r--git.c2
-rw-r--r--hook.c379
-rw-r--r--hook.h104
-rw-r--r--refs.c24
-rw-r--r--repository.c6
-rw-r--r--repository.h6
-rwxr-xr-xt/t1800-hook.sh244
-rw-r--r--transport.c27
12 files changed, 990 insertions, 62 deletions
diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
new file mode 100644
index 0000000000..64e845a260
--- /dev/null
+++ b/Documentation/config/hook.adoc
@@ -0,0 +1,24 @@
+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. 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`.
+ 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/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index f6cc72d2ca..12d2701b52 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 [-z] <hook-name>
DESCRIPTION
-----------
@@ -16,18 +17,107 @@ 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
mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See
linkgit:githooks[5] for arguments hooks might expect (if any).
+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
-------
@@ -41,6 +131,49 @@ 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
+--------
+
+`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 7afec380d2..83020dfb4f 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 [-z] <hook-name>")
static const char * const builtin_hook_usage[] = {
BUILTIN_HOOK_RUN_USAGE,
+ BUILTIN_HOOK_LIST_USAGE,
NULL
};
@@ -20,6 +24,67 @@ 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 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(),
+ };
+
+ 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%c", _("hook from hookdir"), line_terminator);
+ break;
+ case HOOK_CONFIGURED:
+ printf("%s%c", h->u.configured.friendly_name, line_terminator);
+ 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 +142,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/builtin/receive-pack.c b/builtin/receive-pack.c
index 55d39daa62..d6225df890 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;
@@ -941,16 +961,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/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 cde7198412..2c8252b2c4 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"
@@ -16,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
@@ -47,9 +52,324 @@ 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);
+ 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);
+
+ free(h);
+}
+
+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;
+}
+
+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")) {
+ 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);
+ }
+
+ /* 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);
+ } 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);
+ 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;
+
+ /* 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 "
+ "'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.
+ *
+ * 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 && 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
+ * 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;
+ } 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;
+}
+
+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;
+ }
+
+ /*
+ * 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,
+ 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 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);
+
+ 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 +378,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 +408,25 @@ 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 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");
+
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;
}
@@ -140,13 +467,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",
@@ -172,27 +497,29 @@ 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->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 20eb56fd63..e949f5d488 100644
--- a/hook.h
+++ b/hook.h
@@ -2,9 +2,50 @@
#define HOOK_H
#include "strvec.h"
#include "run-command.h"
+#include "string-list.h"
+#include "strmap.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 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;
+
+ /**
+ * 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);
+
struct run_hooks_opt
{
/* Environment vars to be set for each hook */
@@ -83,15 +124,22 @@ struct run_hooks_opt
void *feed_pipe_ctx;
/**
- * Opaque data pointer used to keep internal state across callback calls.
+ * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,
+ * so they can keep track of progress without affecting one another.
*
- * It can be accessed directly via the third callback arg 'pp_task_cb':
- * struct ... *state = pp_task_cb;
+ * If provided, this function will be called to alloc & initialize the
+ * `feed_pipe_cb_data` for each hook.
*
- * The caller is responsible for managing the memory for this data.
- * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.
+ * The `feed_pipe_ctx` pointer can be used to pass initialization data.
*/
- void *feed_pipe_cb_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 { \
@@ -105,11 +153,51 @@ 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;
};
-/*
+/**
+ * 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);
+
+/**
+ * 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
* overwritten by further calls to find_hook and run_hook_*.
diff --git a/refs.c b/refs.c
index 15069b5d19..6fb8f9d10c 100644
--- a/refs.c
+++ b/refs.c
@@ -2589,24 +2589,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/repository.c b/repository.c
index e7fa42c14f..8717a1693a 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"
@@ -413,6 +414,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 9ad6520c37..7830eb7d4b 100644
--- a/repository.h
+++ b/repository.h
@@ -166,6 +166,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 ed28a2fadb..b1583e9ef9 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -1,18 +1,79 @@
#!/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 &&
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 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 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
@@ -83,12 +144,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 &&
+
+ echo "global-hook" >expect &&
+ nongit git hook list test-hook >actual &&
+ test_cmp expect actual &&
+
+ echo "no repo no problems" >expect &&
- nongit test_must_fail git hook run test-hook
+ nongit git hook run test-hook 2>actual &&
+ test_cmp expect actual
'
test_expect_success 'git -c core.hooksPath=<PATH> hook run' '
@@ -150,6 +217,170 @@ 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 '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 &&
+
+ # '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 '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 &&
@@ -167,6 +398,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
diff --git a/transport.c b/transport.c
index faa166a575..107f4fa5dc 100644
--- a/transport.c
+++ b/transport.c
@@ -1358,21 +1358,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
@@ -1382,8 +1397,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;
}