Merge branch 'ar/config-hooks' into seen

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
This commit is contained in:
Junio C Hamano
2026-02-23 16:25:41 -08:00
12 changed files with 990 additions and 62 deletions

View File

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

View File

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

View File

@@ -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(),
};

View File

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

2
git.c
View File

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

379
hook.c
View File

@@ -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)
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);
free(cb_data.hook_command_list);
run_hooks_opt_clear(options);
return ret;
}

104
hook.h
View File

@@ -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_*.

24
refs.c
View File

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

View File

@@ -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 "gettext.h"
@@ -419,6 +420,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);

View File

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

View File

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

View File

@@ -1361,21 +1361,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
@@ -1385,8 +1400,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;
}