{"thread":{"id":"64918","subject":"[PATCH 0/4] Specify hooks via configs","startedAt":"2026-02-04T16:52:00Z","lastAt":"2026-04-08T11:29:18Z","messageCount":71,"participants":["Adrian Ratiu","Junio C Hamano","Patrick Steinhardt","brian m. carlson","SZEDER Gábor"],"isPatch":true,"patchVersion":1,"patchTotal":4},"messages":[{"id":"535168","messageId":"20260204165126.1548805-1-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":null,"subject":"[PATCH 0/4] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T16:51:22Z","receivedAt":"2026-02-04T16:52:00Z","isPatch":true,"body":"Hello everyone,\n\nThis series adds a new feature: the ability to specify commands to run\nfor hook events via config entries (including shell commands).\n\nThe config schema is identical to the one developed by Emily and AEvar\na few years ago [1] though the implementation is significantly different\nbecause it's based on the new / cleaned-up hook.[ch] APIs. [2].\n\nFor simplicity, hooks are still executed sequentially (.jobs == 1) in\nthis series, just like before. Parallel execution will be enabled in\na separate series based on this one.\n\nThe hook execution order is this:\n1. Hooks read from the config. If multiple hook commands are specified\n   for a single event, they are executed in config discovery order.\n2. The default hooks from the hookdir.\n\nThe above order can be changed if necessary.\n\nAgain, this is based on the latest v8 hooks-conversion series [2] which\nhas not yet landed in next or master.\n\nBranch pused to GitHub: [3]\nSuccesful CI run: [4]\n\nMany thanks to all who contributed to this effort up to now, including\nEmily, AEvar, Junio, Patrick, Peff, Kristoffer, Chris and many others.\n\nThank you,\nAdrian\n\n1: https://lore.kernel.org/git/20210715232603.3415111-1-emilyshaffer@google.com/\n2: https://lore.kernel.org/git/20250925125352.1728840-1-adrian.ratiu@collabora.com/T/#m41f793907f46fd04f44ff1b06c53d20af38e6cb2\n3: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/config-hooks-v1\n4: https://github.com/10ne1/git/actions/runs/21676691521\n\nEmily Shaffer (4):\n  hook: run a list of hooks\n  hook: introduce \"git hook list\"\n  hook: include hooks from the config\n  hook: allow out-of-repo 'git hook' invocations\n\n Documentation/config/hook.adoc |  17 +++\n Documentation/git-hook.adoc    | 131 ++++++++++++++++++++++-\n builtin/hook.c                 |  53 ++++++++++\n builtin/receive-pack.c         |  23 +++-\n git.c                          |   2 +-\n hook.c                         | 188 ++++++++++++++++++++++++++++-----\n hook.h                         |  55 +++++++++-\n refs.c                         |  23 +++-\n t/t1800-hook.sh                | 158 +++++++++++++++++++++++++--\n transport.c                    |  23 +++-\n 10 files changed, 633 insertions(+), 40 deletions(-)\n create mode 100644 Documentation/config/hook.adoc\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535169","messageId":"20260204165126.1548805-3-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260204165126.1548805-1-adrian.ratiu@collabora.com","subject":"[PATCH 2/4] hook: introduce \"git hook list\"","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T16:51:24Z","receivedAt":"2026-02-04T16:52:03Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nIf more than one hook will be run, it may be useful to see a list of\nwhich hooks should be run. At very least, it will be useful for us to\ntest the semantics of multihooks ourselves.\n\nFor now, only list the hooks which will run in the order they will run\nin; later, it might be useful to include more information like where the\nhooks were configured and whether or not they will run.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  5 ++++\n builtin/hook.c              | 53 +++++++++++++++++++++++++++++++++++++\n hook.c                      | 15 +----------\n hook.h                      | 17 +++++++++++-\n t/t1800-hook.sh             |  2 ++\n 5 files changed, 77 insertions(+), 15 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex f6cc72d2ca..93d734f687 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -9,6 +9,7 @@ SYNOPSIS\n --------\n [verse]\n 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' list <hook-name>\n \n DESCRIPTION\n -----------\n@@ -28,6 +29,10 @@ Any positional arguments to the hook should be passed after a\n mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See\n linkgit:githooks[5] for arguments hooks might expect (if any).\n \n+list::\n+\tPrint a list of hooks which will be run on `<hook-name>` event. If no\n+\thooks are configured for that event, print nothing and return 1.\n+\n OPTIONS\n -------\n \ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 7afec380d2..4cc6dac45a 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -6,12 +6,16 @@\n #include \"hook.h\"\n #include \"parse-options.h\"\n #include \"strvec.h\"\n+#include \"abspath.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+#define BUILTIN_HOOK_LIST_USAGE \\\n+\tN_(\"git hook list <hook-name>\")\n \n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n+\tBUILTIN_HOOK_LIST_USAGE,\n \tNULL\n };\n \n@@ -20,6 +24,54 @@ static const char * const builtin_hook_run_usage[] = {\n \tNULL\n };\n \n+static const char *const builtin_hook_list_usage[] = {\n+\tBUILTIN_HOOK_LIST_USAGE,\n+\tNULL\n+};\n+\n+static int list(int argc, const char **argv, const char *prefix,\n+\t\t struct repository *repo UNUSED)\n+{\n+\tstruct string_list *head;\n+\tstruct string_list_item *item;\n+\tconst char *hookname = NULL;\n+\tint ret = 0;\n+\n+\tstruct option list_options[] = {\n+\t\tOPT_END(),\n+\t};\n+\n+\targc = parse_options(argc, argv, prefix, list_options,\n+\t\t\t     builtin_hook_list_usage, 0);\n+\n+\t/*\n+\t * The only unnamed argument provided should be the hook-name; if we add\n+\t * arguments later they probably should be caught by parse_options.\n+\t */\n+\tif (argc != 1)\n+\t\tusage_msg_opt(_(\"You must specify a hook event name to list.\"),\n+\t\t\t      builtin_hook_list_usage, list_options);\n+\n+\thookname = argv[0];\n+\n+\thead = list_hooks(the_repository, hookname);\n+\n+\tif (!head->nr) {\n+\t\tret = 1; /* no hooks found */\n+\t\tgoto cleanup;\n+\t}\n+\n+\tfor_each_string_list_item(item, head) {\n+\t\tprintf(\"%s\\n\", *item->string ? item->string\n+\t\t\t     : _(\"hook from hookdir\"));\n+\t}\n+\n+cleanup:\n+\tstring_list_clear(head, 1);\n+\tfree(head);\n+\treturn ret;\n+}\n+\n static int run(int argc, const char **argv, const char *prefix,\n \t       struct repository *repo UNUSED)\n {\n@@ -77,6 +129,7 @@ int cmd_hook(int argc,\n \tparse_opt_subcommand_fn *fn = NULL;\n \tstruct option builtin_hook_options[] = {\n \t\tOPT_SUBCOMMAND(\"run\", &fn, run),\n+\t\tOPT_SUBCOMMAND(\"list\", &fn, list),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/hook.c b/hook.c\nindex fb90f91f3b..949c907b59 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -47,20 +47,7 @@ const char *find_hook(struct repository *r, const char *name)\n \treturn path.buf;\n }\n \n-/*\n- * Provides a list of hook commands to run for the 'hookname' event.\n- *\n- * This function consolidates hooks from two sources:\n- * 1. The config-based hooks (not yet implemented).\n- * 2. The \"traditional\" hook found in the repository hooks directory\n- *    (e.g., .git/hooks/pre-commit).\n- *\n- * The list is ordered by execution priority.\n- *\n- * The caller is responsible for freeing the memory of the returned list\n- * using string_list_clear() and free().\n- */\n-static struct string_list *list_hooks(struct repository *r, const char *hookname)\n+struct string_list *list_hooks(struct repository *r, const char *hookname)\n {\n \tstruct string_list *hook_head;\n \ndiff --git a/hook.h b/hook.h\nindex 33a0e33684..cdbe5a9167 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -143,7 +143,22 @@ struct hook_cb_data {\n \tstruct repository *repository;\n };\n \n-/*\n+/**\n+ * Provides a list of hook commands to run for the 'hookname' event.\n+ *\n+ * This function consolidates hooks from two sources:\n+ * 1. The config-based hooks (not yet implemented).\n+ * 2. The \"traditional\" hook found in the repository hooks directory\n+ *    (e.g., .git/hooks/pre-commit).\n+ *\n+ * The list is ordered by execution priority.\n+ *\n+ * The caller is responsible for freeing the memory of the returned list\n+ * using string_list_clear() and free().\n+ */\n+struct string_list *list_hooks(struct repository *r, const char *hookname);\n+\n+/**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\n  * overwritten by further calls to find_hook and run_hook_*.\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex ed28a2fadb..d2d4a8760c 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -10,6 +10,8 @@ test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook run &&\n \ttest_expect_code 129 git hook run -h &&\n \ttest_expect_code 129 git hook run --unknown 2>err &&\n+\ttest_expect_code 129 git hook list &&\n+\ttest_expect_code 129 git hook list -h &&\n \tgrep \"unknown option\" err\n '\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535170","messageId":"20260204165126.1548805-4-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260204165126.1548805-1-adrian.ratiu@collabora.com","subject":"[PATCH 3/4] hook: include hooks from the config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T16:51:25Z","receivedAt":"2026-02-04T16:52:05Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nTeach the hook.[hc] library to parse configs to populate the list of\nhooks to run for a given event.\n\nMultiple commands can be specified for a given hook by providing\n\"hook.<friendly-name>.command = <path-to-hook>\" and\n\"hook.<friendly-name>.event = <hook-event>\" lines.\n\nHooks will be started in config order of the \"hook.<name>.event\"\nlines and will be run sequentially (.jobs == 1) like before.\nRunning the hooks in parallel will be enabled in a future patch.\n\nExamples:\n\n  $ git config --get-regexp \"^hook\\.\"\n  hook.bar.command=~/bar.sh\n  hook.bar.event=pre-commit\n\n  # Will run ~/bar.sh, then .git/hooks/pre-commit\n  $ git hook run pre-commit\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  17 ++++\n Documentation/git-hook.adoc    | 126 ++++++++++++++++++++++++++++-\n hook.c                         |  78 ++++++++++++++++--\n t/t1800-hook.sh                | 140 ++++++++++++++++++++++++++++++++-\n 4 files changed, 353 insertions(+), 8 deletions(-)\n create mode 100644 Documentation/config/hook.adoc\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nnew file mode 100644\nindex 0000000000..49c7ffd82e\n--- /dev/null\n+++ b/Documentation/config/hook.adoc\n@@ -0,0 +1,17 @@\n+hook.<name>.command::\n+\tA command to execute whenever `hook.<name>` is invoked. `<name>` should\n+\tbe a unique \"friendly\" name which you can use to identify this hook\n+\tcommand. (You can specify when to invoke this command with\n+\t`hook.<name>.event`.) The value can be an executable on your device or a\n+\toneliner for your shell. If more than one value is specified for the\n+\tsame `<name>`, the last value parsed will be the only command executed.\n+\tSee linkgit:git-hook[1].\n+\n+hook.<name>.event::\n+\tThe hook events which should invoke `hook.<name>`. `<name>` should be a\n+\tunique \"friendly\" name which you can use to identify this hook. The\n+\tvalue should be the name of a hook event, like \"pre-commit\" or \"update\".\n+\t(See linkgit:githooks[5] for a complete list of hooks Git knows about.)\n+\tOn the specified event, the associated `hook.<name>.command` will be\n+\texecuted. More than one event can be specified if you wish for\n+\t`hook.<name>` to execute on multiple events. See linkgit:git-hook[1].\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 93d734f687..5f339dc48b 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -17,12 +17,94 @@ DESCRIPTION\n A command interface for running git hooks (see linkgit:githooks[5]),\n for use by other scripted git commands.\n \n+This command parses the default configuration files for sets of configs like\n+so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+\n+In this example, `[hook \"linter\"]` represents one script - `~/bin/linter\n+--cpp20` - which can be shared by many repos, and even by many hook events, if\n+appropriate.\n+\n+To add an unrelated hook which runs on a different event, for example a\n+spell-checker for your commit messages, you would write a configuration like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+  [hook \"spellcheck\"]\n+    event = commit-msg\n+    command = ~/bin/spellchecker\n+\n+With this config, when you run 'git commit', first `~/bin/linter --cpp20` will\n+have a chance to check your files to be committed (during the `pre-commit` hook\n+event`), and then `~/bin/spellchecker` will have a chance to check your commit\n+message (during the `commit-msg` hook event).\n+\n+Commands are run in the order Git encounters their associated\n+`hook.<name>.event` configs during the configuration parse (see\n+linkgit:git-config[1]). Although multiple `hook.linter.event` configs can be\n+added, only one `hook.linter.command` event is valid - Git uses \"last-one-wins\"\n+to determine which command to run.\n+\n+So if you wanted your linter to run when you commit as well as when you push,\n+you would configure it like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    event = pre-push\n+    command = ~/bin/linter --cpp20\n+\n+With this config, `~/bin/linter --cpp20` would be run by Git before a commit is\n+generated (during `pre-commit`) as well as before a push is performed (during\n+`pre-push`).\n+\n+And if you wanted to run your linter as well as a secret-leak detector during\n+only the \"pre-commit\" hook event, you would configure it instead like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+  [hook \"no-leaks\"]\n+    event = pre-commit\n+    command = ~/bin/leak-detector\n+\n+With this config, before a commit is generated (during `pre-commit`), Git would\n+first start `~/bin/linter --cpp20` and second start `~/bin/leak-detector`. It\n+would evaluate the output of each when deciding whether to proceed with the\n+commit.\n+\n+For a full list of hook events which you can set your `hook.<name>.event` to,\n+and how hooks are invoked during those events, see linkgit:githooks[5].\n+\n+Git will ignore any `hook.<name>.event` that specifies an event it doesn't\n+recognize. This is intended so that tools which wrap Git can use the hook\n+infrastructure to run their own hooks; see \"WRAPPERS\" for more guidance.\n+\n+In general, when instructions suggest adding a script to\n+`.git/hooks/<hook-event>`, you can specify it in the config instead by running:\n+\n+----\n+git config hook.<some-name>.command <path-to-script>\n+git config --add hook.<some-name>.event <hook-event>\n+----\n+\n+This way you can share the script between multiple repos. That is, `cp\n+~/my-script.sh ~/project/.git/hooks/pre-commit` would become:\n+\n+----\n+git config hook.my-script.command ~/my-script.sh\n+git config --add hook.my-script.event pre-commit\n+----\n+\n SUBCOMMANDS\n -----------\n \n run::\n-\tRun the `<hook-name>` hook. See linkgit:githooks[5] for\n-\tsupported hook names.\n+\tRuns hooks configured for `<hook-name>`, in the order they are\n+\tdiscovered during the config parse.\n +\n \n Any positional arguments to the hook should be passed after a\n@@ -46,6 +128,46 @@ OPTIONS\n \ttools that want to do a blind one-shot run of a hook that may\n \tor may not be present.\n \n+WRAPPERS\n+--------\n+\n+`git hook run` has been designed to make it easy for tools which wrap Git to\n+configure and execute hooks using the Git hook infrastructure. It is possible to\n+provide arguments and stdin via the command line, as well as specifying parallel\n+or series execution if the user has provided multiple hooks.\n+\n+Assuming your wrapper wants to support a hook named \"mywrapper-start-tests\", you\n+can have your users specify their hooks like so:\n+\n+  [hook \"setup-test-dashboard\"]\n+    event = mywrapper-start-tests\n+    command = ~/mywrapper/setup-dashboard.py --tap\n+\n+Then, in your 'mywrapper' tool, you can invoke any users' configured hooks by\n+running:\n+\n+----\n+git hook run mywrapper-start-tests \\\n+  # providing something to stdin\n+  --stdin some-tempfile-123 \\\n+  # execute hooks in serial\n+  # plus some arguments of your own...\n+  -- \\\n+  --testname bar \\\n+  baz\n+----\n+\n+Take care to name your wrapper's hook events in a way which is unlikely to\n+overlap with Git's native hooks (see linkgit:githooks[5]) - a hook event named\n+`mywrappertool-validate-commit` is much less likely to be added to native Git\n+than a hook event named `validate-commit`. If Git begins to use a hook event\n+named the same thing as your wrapper hook, it may invoke your users' hooks in\n+unintended and unsupported ways.\n+\n+CONFIGURATION\n+-------------\n+include::config/hook.adoc[]\n+\n SEE ALSO\n --------\n linkgit:githooks[5]\ndiff --git a/hook.c b/hook.c\nindex 949c907b59..9f59ebd0bd 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -47,24 +47,74 @@ const char *find_hook(struct repository *r, const char *name)\n \treturn path.buf;\n }\n \n+struct hook_config_cb\n+{\n+\tconst char *hook_event;\n+\tstruct string_list *list;\n+};\n+\n+/*\n+ * Callback for git_config which adds configured hooks to a hook list.  Hooks\n+ * can be configured by specifying both hook.<friendly-name>.command = <path>\n+ * and hook.<friendly-name>.event = <hook-event>.\n+ */\n+static int hook_config_lookup(const char *key, const char *value,\n+\t\t\t      const struct config_context *ctx UNUSED,\n+\t\t\t      void *cb_data)\n+{\n+\tstruct hook_config_cb *data = cb_data;\n+\tconst char *name, *event_key;\n+\tsize_t name_len = 0;\n+\tstruct string_list_item *item;\n+\tchar *hook_name;\n+\n+\t/*\n+\t * Don't bother doing the expensive parse if there's no\n+\t * chance that the config matches 'hook.myhook.event = hook_event'.\n+\t */\n+\tif (!value || strcmp(value, data->hook_event))\n+\t\treturn 0;\n+\n+\t/* Look for \"hook.friendly-name.event = hook_event\" */\n+\tif (parse_config_key(key, \"hook\", &name, &name_len, &event_key) ||\n+\t    strcmp(event_key, \"event\"))\n+\t\treturn 0;\n+\n+\t/* Extract the hook name */\n+\thook_name = xmemdupz(name, name_len);\n+\n+\t/* Remove the hook if already in the list, so we append in config order. */\n+\tif ((item = unsorted_string_list_lookup(data->list, hook_name)))\n+\t\tunsorted_string_list_delete_item(data->list, item - data->list->items, 0);\n+\n+\t/* The list takes ownership of hook_name, so append with nodup */\n+\tstring_list_append_nodup(data->list, hook_name);\n+\n+\treturn 0;\n+}\n+\n struct string_list *list_hooks(struct repository *r, const char *hookname)\n {\n-\tstruct string_list *hook_head;\n+\tstruct hook_config_cb cb_data;\n \n \tif (!hookname)\n \t\tBUG(\"null hookname was provided to hook_list()!\");\n \n-\thook_head = xmalloc(sizeof(struct string_list));\n-\tstring_list_init_dup(hook_head);\n+\tcb_data.hook_event = hookname;\n+\tcb_data.list = xmalloc(sizeof(struct string_list));\n+\tstring_list_init_dup(cb_data.list);\n+\n+\t/* Add the hooks from the config, e.g. hook.myhook.event = pre-commit */\n+\trepo_config(r, hook_config_lookup, &cb_data);\n \n \t/*\n \t * Add the default hook from hookdir. It does not have a friendly name\n \t * like the hooks specified via configs, so add it with an empty name.\n \t */\n \tif (r->gitdir && find_hook(r, hookname))\n-\t\tstring_list_append(hook_head, \"\");\n+\t\tstring_list_append(cb_data.list, \"\");\n \n-\treturn hook_head;\n+\treturn cb_data.list;\n }\n \n int hook_exists(struct repository *r, const char *name)\n@@ -125,6 +175,24 @@ static int pick_next_hook(struct child_process *cp,\n \t\t\thook_path = absolute_path(hook_path);\n \n \t\tstrvec_push(&cp->args, hook_path);\n+\t} else {\n+\t\t/* ...from config */\n+\t\tstruct strbuf cmd_key = STRBUF_INIT;\n+\t\tchar *command = NULL;\n+\n+\t\t/* to enable oneliners, let config-specified hooks run in shell. */\n+\t\tcp->use_shell = true;\n+\n+\t\tstrbuf_addf(&cmd_key, \"hook.%s.command\", to_run->string);\n+\t\tif (repo_config_get_string(hook_cb->repository,\n+\t\t\t\t\t   cmd_key.buf, &command)) {\n+\t\t\tdie(_(\"'hook.%s.command' must be configured or\"\n+\t\t\t      \"'hook.%s.event' must be removed; aborting.\\n\"),\n+\t\t\t    to_run->string, to_run->string);\n+\t\t}\n+\t\tstrbuf_release(&cmd_key);\n+\n+\t\tstrvec_push_nodup(&cp->args, command);\n \t}\n \n \tif (!cp->args.nr)\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex d2d4a8760c..bc4862e982 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1,14 +1,31 @@\n #!/bin/sh\n \n-test_description='git-hook command'\n+test_description='git-hook command and config-managed multihooks'\n \n . ./test-lib.sh\n . \"$TEST_DIRECTORY\"/lib-terminal.sh\n \n+setup_hooks () {\n+\ttest_config hook.ghi.command \"/path/ghi\"\n+\ttest_config hook.ghi.event pre-commit --add\n+\ttest_config hook.ghi.event test-hook --add\n+\ttest_config_global hook.def.command \"/path/def\"\n+\ttest_config_global hook.def.event pre-commit --add\n+}\n+\n+setup_hookdir () {\n+\tmkdir .git/hooks\n+\twrite_script .git/hooks/pre-commit <<-EOF\n+\techo \\\"Legacy Hook\\\"\n+\tEOF\n+\ttest_when_finished rm -rf .git/hooks\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n \ttest_expect_code 129 git hook run -h &&\n+\ttest_expect_code 129 git hook list -h &&\n \ttest_expect_code 129 git hook run --unknown 2>err &&\n \ttest_expect_code 129 git hook list &&\n \ttest_expect_code 129 git hook list -h &&\n@@ -152,6 +169,126 @@ test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n \ttest_hook_tty commit -m\"B.new\"\n '\n \n+test_expect_success 'git hook list orders by config order' '\n+\tsetup_hooks &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tdef\n+\tghi\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook list reorders on duplicate event declarations' '\n+\tsetup_hooks &&\n+\n+\t# 'def' is usually configured globally; move it to the end by\n+\t# configuring it locally.\n+\ttest_config hook.def.event \"pre-commit\" --add &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tghi\n+\tdef\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'hook can be configured for multiple events' '\n+\tsetup_hooks &&\n+\n+\t# 'ghi' should be included in both 'pre-commit' and 'test-hook'\n+\tgit hook list pre-commit >actual &&\n+\tgrep \"ghi\" actual &&\n+\tgit hook list test-hook >actual &&\n+\tgrep \"ghi\" actual\n+'\n+\n+test_expect_success 'git hook list shows hooks from the hookdir' '\n+\tsetup_hookdir &&\n+\n+\tcat >expected <<-\\EOF &&\n+\thook from hookdir\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'inline hook definitions execute oneliners' '\n+\ttest_config hook.oneliner.event \"pre-commit\" &&\n+\ttest_config hook.oneliner.command \"echo \\\"Hello World\\\"\" &&\n+\n+\techo \"Hello World\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'inline hook definitions resolve paths' '\n+\twrite_script sample-hook.sh <<-\\EOF &&\n+\techo \\\"Sample Hook\\\"\n+\tEOF\n+\n+\ttest_when_finished \"rm sample-hook.sh\" &&\n+\n+\ttest_config hook.sample-hook.event pre-commit &&\n+\ttest_config hook.sample-hook.command \"\\\"$(pwd)/sample-hook.sh\\\"\" &&\n+\n+\techo \\\"Sample Hook\\\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'hookdir hook included in git hook run' '\n+\tsetup_hookdir &&\n+\n+\techo \\\"Legacy Hook\\\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'stdin to multiple hooks' '\n+\ttest_config hook.stdin-a.event \"test-hook\" --add &&\n+\ttest_config hook.stdin-a.command \"xargs -P1 -I% echo a%\" --add &&\n+\ttest_config hook.stdin-b.event \"test-hook\" --add &&\n+\ttest_config hook.stdin-b.command \"xargs -P1 -I% echo b%\" --add &&\n+\n+\tcat >input <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\ta1\n+\ta2\n+\ta3\n+\tb1\n+\tb2\n+\tb3\n+\tEOF\n+\n+\tgit hook run --to-stdin=input test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'rejects hooks with no commands configured' '\n+\ttest_config hook.broken.event \"test-hook\" &&\n+\techo broken >expected &&\n+\tgit hook list test-hook >actual &&\n+\ttest_cmp expected actual &&\n+\ttest_must_fail git hook run test-hook\n+'\n+\n test_expect_success 'git hook run a hook with a bad shebang' '\n \ttest_when_finished \"rm -rf bad-hooks\" &&\n \tmkdir bad-hooks &&\n@@ -169,6 +306,7 @@ test_expect_success 'git hook run a hook with a bad shebang' '\n '\n \n test_expect_success 'stdin to hooks' '\n+\tmkdir -p .git/hooks &&\n \twrite_script .git/hooks/test-hook <<-\\EOF &&\n \techo BEGIN stdin\n \tcat\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535171","messageId":"20260204165126.1548805-2-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260204165126.1548805-1-adrian.ratiu@collabora.com","subject":"[PATCH 1/4] hook: run a list of hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T16:51:23Z","receivedAt":"2026-02-04T16:52:15Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nTeach hook.[hc] to run lists of hooks to prepare for multihook support.\n\nCurrently, the hook list contains only one entry representing the\n\"legacy\" hook from the hookdir, but next commits will allow users\nto supply more than one executable/command for a single hook event\nin addition to these default \"legacy\" hooks.\n\nAll hook commands still run sequentially. A further patch series will\nenable running them in parallel by increasing .jobs > 1 where possible.\n\nEach hook command requires its own internal state copy, even when\nrunning sequentially, so add an API to allow hooks to duplicate/free\ntheir internal void *pp_task_cb state.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n builtin/receive-pack.c |  23 ++++++-\n hook.c                 | 133 +++++++++++++++++++++++++++++++++--------\n hook.h                 |  38 +++++++++++-\n refs.c                 |  23 ++++++-\n transport.c            |  23 ++++++-\n 5 files changed, 210 insertions(+), 30 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex b5379a4895..72fde2207c 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -849,7 +849,8 @@ struct receive_hook_feed_state {\n \n static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)\n {\n-\tstruct receive_hook_feed_state *state = pp_task_cb;\n+\tstruct string_list_item *h = pp_task_cb;\n+\tstruct receive_hook_feed_state *state = h->util;\n \tstruct command *cmd = state->cmd;\n \n \tstrbuf_reset(&state->buf);\n@@ -901,6 +902,24 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_\n \treturn state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */\n }\n \n+static void *copy_receive_hook_feed_state(const void *data)\n+{\n+\tconst struct receive_hook_feed_state *orig = data;\n+\tstruct receive_hook_feed_state *new_data = xmalloc(sizeof(*new_data));\n+\tmemcpy(new_data, orig, sizeof(*new_data));\n+\tstrbuf_init(&new_data->buf, 0);\n+\treturn new_data;\n+}\n+\n+static void free_receive_hook_feed_state(void *data)\n+{\n+\tstruct receive_hook_feed_state *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_receive_hook(struct command *commands,\n \t\t\t    const char *hook_name,\n \t\t\t    int skip_broken,\n@@ -944,6 +963,8 @@ static int run_receive_hook(struct command *commands,\n \tstrbuf_init(&feed_state.buf, 0);\n \topt.feed_pipe_cb_data = &feed_state;\n \topt.feed_pipe = feed_receive_hook_cb;\n+\topt.copy_feed_pipe_cb_data = copy_receive_hook_feed_state;\n+\topt.free_feed_pipe_cb_data = free_receive_hook_feed_state;\n \n \tret = run_hooks_opt(the_repository, hook_name, &opt);\n \ndiff --git a/hook.c b/hook.c\nindex cde7198412..fb90f91f3b 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)\n \treturn path.buf;\n }\n \n+/*\n+ * Provides a list of hook commands to run for the 'hookname' event.\n+ *\n+ * This function consolidates hooks from two sources:\n+ * 1. The config-based hooks (not yet implemented).\n+ * 2. The \"traditional\" hook found in the repository hooks directory\n+ *    (e.g., .git/hooks/pre-commit).\n+ *\n+ * The list is ordered by execution priority.\n+ *\n+ * The caller is responsible for freeing the memory of the returned list\n+ * using string_list_clear() and free().\n+ */\n+static struct string_list *list_hooks(struct repository *r, const char *hookname)\n+{\n+\tstruct string_list *hook_head;\n+\n+\tif (!hookname)\n+\t\tBUG(\"null hookname was provided to hook_list()!\");\n+\n+\thook_head = xmalloc(sizeof(struct string_list));\n+\tstring_list_init_dup(hook_head);\n+\n+\t/*\n+\t * Add the default hook from hookdir. It does not have a friendly name\n+\t * like the hooks specified via configs, so add it with an empty name.\n+\t */\n+\tif (r->gitdir && find_hook(r, hookname))\n+\t\tstring_list_append(hook_head, \"\");\n+\n+\treturn hook_head;\n+}\n+\n int hook_exists(struct repository *r, const char *name)\n {\n-\treturn !!find_hook(r, name);\n+\tint exists = 0;\n+\tstruct string_list *hooks = list_hooks(r, name);\n+\n+\texists = hooks->nr > 0;\n+\n+\tstring_list_clear(hooks, 1);\n+\tfree(hooks);\n+\treturn exists;\n }\n \n static int pick_next_hook(struct child_process *cp,\n@@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,\n \t\t\t  void **pp_task_cb)\n {\n \tstruct hook_cb_data *hook_cb = pp_cb;\n-\tconst char *hook_path = hook_cb->hook_path;\n+\tstruct string_list *hook_list = hook_cb->hook_command_list;\n+\tstruct string_list_item *to_run = hook_cb->next_hook++;\n \n-\tif (!hook_path)\n-\t\treturn 0;\n+\tif (!to_run || to_run >= hook_list->items + hook_list->nr)\n+\t\treturn 0; /* no hook left to run */\n \n \tcp->no_stdin = 1;\n \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\n@@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,\n \tcp->trace2_hook_name = hook_cb->hook_name;\n \tcp->dir = hook_cb->options->dir;\n \n-\tstrvec_push(&cp->args, hook_path);\n+\t/* find hook commands */\n+\tif (!*to_run->string) {\n+\t\t/* ...from hookdir signified by empty name */\n+\t\tconst char *hook_path = find_hook(hook_cb->repository,\n+\t\t\t\t\t\t  hook_cb->hook_name);\n+\t\tif (!hook_path)\n+\t\t\tBUG(\"hookdir in hook list but no hook present in filesystem\");\n+\n+\t\tif (hook_cb->options->dir)\n+\t\t\thook_path = absolute_path(hook_path);\n+\n+\t\tstrvec_push(&cp->args, hook_path);\n+\t}\n+\n+\tif (!cp->args.nr)\n+\t\tBUG(\"configured hook must have at least one command\");\n+\n \tstrvec_pushv(&cp->args, hook_cb->options->args.v);\n \n \t/*\n \t * Provide per-hook internal state via task_cb for easy access, so\n \t * hook callbacks don't have to go through hook_cb->options.\n \t */\n-\t*pp_task_cb = hook_cb->options->feed_pipe_cb_data;\n-\n-\t/*\n-\t * This pick_next_hook() will be called again, we're only\n-\t * running one hook, so indicate that no more work will be\n-\t * done.\n-\t */\n-\thook_cb->hook_path = NULL;\n+\t*pp_task_cb = to_run;\n \n \treturn 1;\n }\n \n-static int notify_start_failure(struct strbuf *out UNUSED,\n+static int notify_start_failure(struct strbuf *out,\n \t\t\t\tvoid *pp_cb,\n-\t\t\t\tvoid *pp_task_cp UNUSED)\n+\t\t\t\tvoid *pp_task_cb)\n {\n \tstruct hook_cb_data *hook_cb = pp_cb;\n+\tstruct string_list_item *hook = pp_task_cb;\n \n \thook_cb->rc |= 1;\n \n+\tif (out && hook) {\n+\t\tif (*hook->string)\n+\t\t\tstrbuf_addf(out, _(\"Couldn't start hook '%s'\\n\"), hook->string);\n+\t\telse\n+\t\t\tstrbuf_addstr(out, _(\"Couldn't start hook from hooks directory\\n\"));\n+\t}\n+\n \treturn 1;\n }\n \n@@ -145,8 +203,9 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n \t\t.options = options,\n+\t\t.hook_command_list = list_hooks(r, hook_name),\n+\t\t.repository = r,\n \t};\n-\tconst char *const hook_path = find_hook(r, hook_name);\n \tint ret = 0;\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n@@ -172,26 +231,50 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (!options->jobs)\n \t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n \n+\t/*\n+\t * Ensure cb_data copy and free functions are either provided together,\n+\t * or neither one is provided.\n+\t */\n+\tif ((options->copy_feed_pipe_cb_data && !options->free_feed_pipe_cb_data) ||\n+\t    (!options->copy_feed_pipe_cb_data && options->free_feed_pipe_cb_data))\n+\t\tBUG(\"copy_feed_pipe_cb_data and free_feed_pipe_cb_data must be set together\");\n+\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tif (!hook_path && !options->error_if_missing)\n-\t\tgoto cleanup;\n-\n-\tif (!hook_path) {\n-\t\tret = error(\"cannot find a hook named %s\", hook_name);\n+\tif (!cb_data.hook_command_list->nr) {\n+\t\tif (options->error_if_missing)\n+\t\t\tret = error(\"cannot find a hook named %s\", hook_name);\n \t\tgoto cleanup;\n \t}\n \n-\tcb_data.hook_path = hook_path;\n-\tif (options->dir) {\n-\t\tstrbuf_add_absolute_path(&abs_path, hook_path);\n-\t\tcb_data.hook_path = abs_path.buf;\n+\t/*\n+\t * Initialize the iterator/cursor which holds the next hook to run.\n+\t * run_process_parallel() calls pick_next_hook() which increments it for\n+\t * each hook command in the list until all hooks have been run.\n+\t */\n+\tcb_data.next_hook = cb_data.hook_command_list->items;\n+\n+\t/*\n+\t * Give each hook its own copy of the initial void *pp_task_cb state, if\n+\t * a copy callback was provided.\n+\t */\n+\tif (options->copy_feed_pipe_cb_data) {\n+\t\tstruct string_list_item *item;\n+\t\tfor_each_string_list_item(item, cb_data.hook_command_list)\n+\t\t\titem->util = options->copy_feed_pipe_cb_data(options->feed_pipe_cb_data);\n \t}\n \n \trun_processes_parallel(&opts);\n \tret = cb_data.rc;\n cleanup:\n+\tif (options->free_feed_pipe_cb_data) {\n+\t\tstruct string_list_item *item;\n+\t\tfor_each_string_list_item(item, cb_data.hook_command_list)\n+\t\t\toptions->free_feed_pipe_cb_data(item->util);\n+\t}\n+\tstring_list_clear(cb_data.hook_command_list, 0);\n+\tfree(cb_data.hook_command_list);\n \tstrbuf_release(&abs_path);\n \trun_hooks_opt_clear(options);\n \treturn ret;\ndiff --git a/hook.h b/hook.h\nindex 20eb56fd63..33a0e33684 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -2,6 +2,7 @@\n #define HOOK_H\n #include \"strvec.h\"\n #include \"run-command.h\"\n+#include \"string-list.h\"\n \n struct repository;\n \n@@ -86,12 +87,28 @@ struct run_hooks_opt\n \t * Opaque data pointer used to keep internal state across callback calls.\n \t *\n \t * It can be accessed directly via the third callback arg 'pp_task_cb':\n-\t * struct ... *state = pp_task_cb;\n+\t * struct ... *state = ((struct string_list_item *)pp_task_cb)->util;\n \t *\n \t * The caller is responsible for managing the memory for this data.\n \t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n \t */\n \tvoid *feed_pipe_cb_data;\n+\n+\t/**\n+\t * Some hooks need a copy of the initial `feed_pipe_cb_data` state, so\n+\t * they can keep track of progress without affecting one another.\n+\t *\n+\t * If provided, this function will be called to copy `feed_pipe_cb_data`\n+\t * for each hook.\n+\t */\n+\tvoid *(*copy_feed_pipe_cb_data)(const void *data);\n+\n+\t/**\n+\t * Called to free the memory duplicated by `copy_feed_pipe_cb_data`.\n+\t *\n+\t * Must always be provided when `copy_feed_pipe_cb_data` is provided.\n+\t */\n+\tvoid (*free_feed_pipe_cb_data)(void *data);\n };\n \n #define RUN_HOOKS_OPT_INIT { \\\n@@ -105,8 +122,25 @@ struct hook_cb_data {\n \t/* rc reflects the cumulative failure state */\n \tint rc;\n \tconst char *hook_name;\n-\tconst char *hook_path;\n+\n+\t/**\n+\t * A list of hook commands/paths to run for the 'hook_name' event.\n+\t *\n+\t * The 'string' member of each item contains the executable path (e.g.\n+\t * \"/path/to/.git/hooks/pre-commit\") or command.\n+\t */\n+\tstruct string_list *hook_command_list;\n+\n+\t/**\n+\t * Iterator/cursor for the above list, pointing to the next hook to run.\n+\t *\n+\t * The 'util' member of the string_list_item holds the per-hook state\n+\t * data (feed_pipe_cb_data) which is passed to callbacks via 'pp_task_cb'.\n+\t */\n+\tstruct string_list_item *next_hook;\n+\n \tstruct run_hooks_opt *options;\n+\tstruct repository *repository;\n };\n \n /*\ndiff --git a/refs.c b/refs.c\nindex 1e2ac90018..d1a1ace641 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -2472,7 +2472,8 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n {\n \tstruct hook_cb_data *hook_cb = pp_cb;\n \tstruct ref_transaction *transaction = hook_cb->options->feed_pipe_ctx;\n-\tstruct transaction_feed_cb_data *feed_cb_data = pp_task_cb;\n+\tstruct string_list_item *hook = pp_task_cb;\n+\tstruct transaction_feed_cb_data *feed_cb_data = hook->util;\n \tstruct strbuf *buf = &feed_cb_data->buf;\n \tstruct ref_update *update;\n \tsize_t i = feed_cb_data->index++;\n@@ -2511,6 +2512,24 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n \treturn 0; /* no more input to feed */\n }\n \n+static void *copy_transaction_feed_cb_data(const void *data)\n+{\n+\tstruct transaction_feed_cb_data *new_data = xmalloc(sizeof(struct transaction_feed_cb_data));\n+\tmemcpy(new_data, data, sizeof(struct transaction_feed_cb_data));\n+\tstrbuf_init(&new_data->buf, 0);\n+\tnew_data->index = 0; /* a fresh iterator for each hook */\n+\treturn new_data;\n+}\n+\n+static void free_transaction_feed_cb_data(void *data)\n+{\n+\tstruct transaction_feed_cb_data *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_transaction_hook(struct ref_transaction *transaction,\n \t\t\t\tconst char *state)\n {\n@@ -2523,6 +2542,8 @@ static int run_transaction_hook(struct ref_transaction *transaction,\n \topt.feed_pipe = transaction_hook_feed_stdin;\n \topt.feed_pipe_ctx = transaction;\n \topt.feed_pipe_cb_data = &feed_ctx;\n+\topt.copy_feed_pipe_cb_data = copy_transaction_feed_cb_data;\n+\topt.free_feed_pipe_cb_data = free_transaction_feed_cb_data;\n \n \tstrbuf_init(&feed_ctx.buf, 0);\n \ndiff --git a/transport.c b/transport.c\nindex e876cc9189..176050e663 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1323,7 +1323,8 @@ struct feed_pre_push_hook_data {\n \n static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)\n {\n-\tstruct feed_pre_push_hook_data *data = pp_task_cb;\n+\tstruct string_list_item *h = pp_task_cb;\n+\tstruct feed_pre_push_hook_data *data = h->util;\n \tconst struct ref *r = data->refs;\n \tint ret = 0;\n \n@@ -1357,6 +1358,24 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \treturn 0;\n }\n \n+static void *copy_pre_push_hook_data(const void *data)\n+{\n+\tconst struct feed_pre_push_hook_data *orig = data;\n+\tstruct feed_pre_push_hook_data *new_data = xmalloc(sizeof(*new_data));\n+\tstrbuf_init(&new_data->buf, 0);\n+\tnew_data->refs = orig->refs;\n+\treturn new_data;\n+}\n+\n+static void free_pre_push_hook_data(void *data)\n+{\n+\tstruct feed_pre_push_hook_data *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_pre_push_hook(struct transport *transport,\n \t\t\t     struct ref *remote_refs)\n {\n@@ -1372,6 +1391,8 @@ static int run_pre_push_hook(struct transport *transport,\n \n \topt.feed_pipe = pre_push_hook_feed_stdin;\n \topt.feed_pipe_cb_data = &data;\n+\topt.copy_feed_pipe_cb_data = copy_pre_push_hook_data;\n+\topt.free_feed_pipe_cb_data = free_pre_push_hook_data;\n \n \t/*\n \t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535172","messageId":"20260204165126.1548805-5-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260204165126.1548805-1-adrian.ratiu@collabora.com","subject":"[PATCH 4/4] hook: allow out-of-repo 'git hook' invocations","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-04T16:51:26Z","receivedAt":"2026-02-04T16:52:16Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSince hooks can now be supplied via the config, and a config can be\npresent without a gitdir via the global and system configs, we can start\nto allow 'git hook run' to occur without a gitdir. This enables us to do\nthings like run sendemail-validate hooks when running 'git send-email'\nfrom a nongit directory.\n\nIt still doesn't make sense to look for hooks in the hookdir in nongit\nrepos, though, as there is no hookdir.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\n---\n git.c           |  2 +-\n t/t1800-hook.sh | 16 +++++++++++-----\n 2 files changed, 12 insertions(+), 6 deletions(-)\n\ndiff --git a/git.c b/git.c\nindex c5fad56813..a9e462ee32 100644\n--- a/git.c\n+++ b/git.c\n@@ -586,7 +586,7 @@ static struct cmd_struct commands[] = {\n \t{ \"grep\", cmd_grep, RUN_SETUP_GENTLY },\n \t{ \"hash-object\", cmd_hash_object },\n \t{ \"help\", cmd_help },\n-\t{ \"hook\", cmd_hook, RUN_SETUP },\n+\t{ \"hook\", cmd_hook, RUN_SETUP_GENTLY },\n \t{ \"index-pack\", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex bc4862e982..21ff6a68f0 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -102,12 +102,18 @@ test_expect_success 'git hook run -- pass arguments' '\n \ttest_cmp expect actual\n '\n \n-test_expect_success 'git hook run -- out-of-repo runs excluded' '\n-\ttest_hook test-hook <<-EOF &&\n-\techo Test hook\n-\tEOF\n+test_expect_success 'git hook run: out-of-repo runs execute global hooks' '\n+\ttest_config_global hook.global-hook.event test-hook --add &&\n+\ttest_config_global hook.global-hook.command \"echo no repo no problems\" --add &&\n \n-\tnongit test_must_fail git hook run test-hook\n+\techo \"global-hook\" >expect &&\n+\tnongit git hook list test-hook >actual &&\n+\ttest_cmp expect actual &&\n+\n+\techo \"no repo no problems\" >expect &&\n+\n+\tnongit git hook run test-hook 2>actual &&\n+\ttest_cmp expect actual\n '\n \n test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"535285","messageId":"xmqqqzqykg1c.fsf@gitster.g","threadId":"64918","inReplyTo":"20260204165126.1548805-2-adrian.ratiu@collabora.com","subject":"Re: [PATCH 1/4] hook: run a list of hooks","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-05T21:59:43Z","receivedAt":"2026-02-05T21:59:46Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index b5379a4895..72fde2207c 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -849,7 +849,8 @@ struct receive_hook_feed_state {\n>  \n>  static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)\n>  {\n> -\tstruct receive_hook_feed_state *state = pp_task_cb;\n> +\tstruct string_list_item *h = pp_task_cb;\n> +\tstruct receive_hook_feed_state *state = h->util;\n>  \tstruct command *cmd = state->cmd;\n\nSo the new calling convention is to give a string_list_item to the\ncallback function, whose .util member has the historical \"state\".  OK.\n\n\n> @@ -944,6 +963,8 @@ static int run_receive_hook(struct command *commands,\n>  \tstrbuf_init(&feed_state.buf, 0);\n>  \topt.feed_pipe_cb_data = &feed_state;\n>  \topt.feed_pipe = feed_receive_hook_cb;\n> +\topt.copy_feed_pipe_cb_data = copy_receive_hook_feed_state;\n> +\topt.free_feed_pipe_cb_data = free_receive_hook_feed_state;\n>  \n>  \tret = run_hooks_opt(the_repository, hook_name, &opt);\n>  \n> diff --git a/hook.c b/hook.c\n> index cde7198412..fb90f91f3b 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)\n>  \treturn path.buf;\n>  }\n>  \n> +/*\n> + * Provides a list of hook commands to run for the 'hookname' event.\n> + *\n> + * This function consolidates hooks from two sources:\n> + * 1. The config-based hooks (not yet implemented).\n> + * 2. The \"traditional\" hook found in the repository hooks directory\n> + *    (e.g., .git/hooks/pre-commit).\n> + *\n> + * The list is ordered by execution priority.\n> + *\n> + * The caller is responsible for freeing the memory of the returned list\n> + * using string_list_clear() and free().\n> + */\n> +static struct string_list *list_hooks(struct repository *r, const char *hookname)\n> +{\n> +\tstruct string_list *hook_head;\n> +\n> +\tif (!hookname)\n> +\t\tBUG(\"null hookname was provided to hook_list()!\");\n> +\n> +\thook_head = xmalloc(sizeof(struct string_list));\n> +\tstring_list_init_dup(hook_head);\n> +\n> +\t/*\n> +\t * Add the default hook from hookdir. It does not have a friendly name\n> +\t * like the hooks specified via configs, so add it with an empty name.\n> +\t */\n> +\tif (r->gitdir && find_hook(r, hookname))\n> +\t\tstring_list_append(hook_head, \"\");\n> +\n> +\treturn hook_head;\n> +}\n\nOK.  I would have expected the caller to supply a string_list\n(presumably an empty one would be the most common usage) and the\nonly responsibility for this function would be to stuff found hooks\nto the given string_list.  But this arrangement to give a newly\ncreated string list would work just fine as well.\n\n>  int hook_exists(struct repository *r, const char *name)\n>  {\n> -\treturn !!find_hook(r, name);\n> +\tint exists = 0;\n> +\tstruct string_list *hooks = list_hooks(r, name);\n> +\n> +\texists = hooks->nr > 0;\n> +\n> +\tstring_list_clear(hooks, 1);\n> +\tfree(hooks);\n> +\treturn exists;\n>  }\n\nOK.  It is not just \"git hook list\", but the internal function\nlist_hooks() that is the topic of this step of the series.  So\nanywhere that uses find_hook() in the current codebase is a good\ncandidate to migrate to use the new function when appropriate, and\nthis is one of such places.  I presume that find_hook() will stay to\nbe a \"find the program in $GIT_DIR/hooks/ directory\" function that\nhas a single answer (i.e. pathnmame), but the list_hooks() may\nreturn more than one string_list_item, one of them might be the\ntraditional hook and others may come from somewhere else.  Do they\nneed to be differentiated in some way (i.e., Is \"where did the come\nfrom?\" a legitimate thing for a caller of list_hook() to ask)?\nThere are many unknowns while reading this step because we still\nhaven't seen the other sources of the hooks.\n\n> @@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,\n>  \t\t\t  void **pp_task_cb)\n>  {\n>  \tstruct hook_cb_data *hook_cb = pp_cb;\n> -\tconst char *hook_path = hook_cb->hook_path;\n> +\tstruct string_list *hook_list = hook_cb->hook_command_list;\n> +\tstruct string_list_item *to_run = hook_cb->next_hook++;\n>  \n> -\tif (!hook_path)\n> -\t\treturn 0;\n> +\tif (!to_run || to_run >= hook_list->items + hook_list->nr)\n> +\t\treturn 0; /* no hook left to run */\n>  \n>  \tcp->no_stdin = 1;\n>  \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\n> @@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,\n>  \tcp->trace2_hook_name = hook_cb->hook_name;\n>  \tcp->dir = hook_cb->options->dir;\n>  \n> -\tstrvec_push(&cp->args, hook_path);\n> +\t/* find hook commands */\n> +\tif (!*to_run->string) {\n> +\t\t/* ...from hookdir signified by empty name */\n\nMake a macro or a small helper function that takes a single\nstring_list_item that is an element in the return value of\nlist_hooks(), and tells if it is a program in $GIT_DIR/hooks/\ndirectory.  Writing code that depends on this \"we did not bother to\ninvent a way to allow users to give friendly names to traditional\non-disk hooks, so it is registered with an empty string as its name\"\nconvention and having to document it is inefficient, both for\nwriters of the code and for readers of the code.\n\nOr perhaps the .util member of each of these can be a pointer to\nsomething like:\n\n\tstruct {\n\t\tenum {\n                \tHOOK_TRADITIONAL, HOOK_CONFIGURED,\n\t\t} kind;\n\t\tunion {\n\t\t\tstruct {\n\t\t\t\tconst char *path;\n\t\t\t} traditional;\n\t\t\tstruct {\n\t\t\t\tconst char *friendly_name;\n\t\t\t\tconst char *path;\n\t\t\t\t... maybe others ...\n\t\t\t} configured;\n\t\t} u;\n\t}\n\nso that this codepath does not even have to ...\n\n> +\t\tconst char *hook_path = find_hook(hook_cb->repository,\n> +\t\t\t\t\t\t  hook_cb->hook_name);\n\n... run the same find_hook() again here?\n\n> -static int notify_start_failure(struct strbuf *out UNUSED,\n> +static int notify_start_failure(struct strbuf *out,\n>  \t\t\t\tvoid *pp_cb,\n> -\t\t\t\tvoid *pp_task_cp UNUSED)\n> +\t\t\t\tvoid *pp_task_cb)\n>  {\n>  \tstruct hook_cb_data *hook_cb = pp_cb;\n> +\tstruct string_list_item *hook = pp_task_cb;\n>  \n>  \thook_cb->rc |= 1;\n>  \n> +\tif (out && hook) {\n> +\t\tif (*hook->string)\n\nDitto.\n\n> +\t\t\tstrbuf_addf(out, _(\"Couldn't start hook '%s'\\n\"), hook->string);\n> +\t\telse\n> +\t\t\tstrbuf_addstr(out, _(\"Couldn't start hook from hooks directory\\n\"));\n> +\t}\n> +\n>  \treturn 1;\n>  }\n"},{"id":"535331","messageId":"87ecmy6rsa.fsf@gentoo.mail-host-address-is-not-set","threadId":"64918","inReplyTo":"xmqqqzqykg1c.fsf@gitster.g","subject":"Re: [PATCH 1/4] hook: run a list of hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-06T11:21:57Z","receivedAt":"2026-02-06T11:22:22Z","isPatch":true,"body":"On Thu, 05 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:\n<snip>\n>> diff --git a/hook.c b/hook.c\n>> index cde7198412..fb90f91f3b 100644\n>> --- a/hook.c\n>> +++ b/hook.c\n>> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)\n>>  \treturn path.buf;\n>>  }\n>>  \n>> +/*\n>> + * Provides a list of hook commands to run for the 'hookname' event.\n>> + *\n>> + * This function consolidates hooks from two sources:\n>> + * 1. The config-based hooks (not yet implemented).\n>> + * 2. The \"traditional\" hook found in the repository hooks directory\n>> + *    (e.g., .git/hooks/pre-commit).\n>> + *\n>> + * The list is ordered by execution priority.\n>> + *\n>> + * The caller is responsible for freeing the memory of the returned list\n>> + * using string_list_clear() and free().\n>> + */\n>> +static struct string_list *list_hooks(struct repository *r, const char *hookname)\n>> +{\n>> +\tstruct string_list *hook_head;\n>> +\n>> +\tif (!hookname)\n>> +\t\tBUG(\"null hookname was provided to hook_list()!\");\n>> +\n>> +\thook_head = xmalloc(sizeof(struct string_list));\n>> +\tstring_list_init_dup(hook_head);\n>> +\n>> +\t/*\n>> +\t * Add the default hook from hookdir. It does not have a friendly name\n>> +\t * like the hooks specified via configs, so add it with an empty name.\n>> +\t */\n>> +\tif (r->gitdir && find_hook(r, hookname))\n>> +\t\tstring_list_append(hook_head, \"\");\n>> +\n>> +\treturn hook_head;\n>> +}\n>\n> OK.  I would have expected the caller to supply a string_list\n> (presumably an empty one would be the most common usage) and the\n> only responsibility for this function would be to stuff found hooks\n> to the given string_list.  But this arrangement to give a newly\n> created string list would work just fine as well.\n\nThank you for suggesting this potential alternative.\n\nIt didn't occur to me that we could pass an existing list to update\ninstead of creating a new list each time. :)\n\nI will certainly explore this option, because I don't like how\nlist_hooks() currently returns a new list and the callers have to clear\nit each time, even though it's done in just 2 max 3 locations  and the\nlists are rather small (how many hooks can one have for each event?).\n\n>>  int hook_exists(struct repository *r, const char *name)\n>>  {\n>> -\treturn !!find_hook(r, name);\n>> +\tint exists = 0;\n>> +\tstruct string_list *hooks = list_hooks(r, name);\n>> +\n>> +\texists = hooks->nr > 0;\n>> +\n>> +\tstring_list_clear(hooks, 1);\n>> +\tfree(hooks);\n>> +\treturn exists;\n>>  }\n>\n> OK.  It is not just \"git hook list\", but the internal function\n> list_hooks() that is the topic of this step of the series.  So\n> anywhere that uses find_hook() in the current codebase is a good\n> candidate to migrate to use the new function when appropriate, and\n> this is one of such places.  I presume that find_hook() will stay to\n> be a \"find the program in $GIT_DIR/hooks/ directory\" function that\n> has a single answer (i.e. pathnmame), but the list_hooks() may\n> return more than one string_list_item, one of them might be the\n> traditional hook and others may come from somewhere else.  Do they\n> need to be differentiated in some way (i.e., Is \"where did the come\n> from?\" a legitimate thing for a caller of list_hook() to ask)?\n> There are many unknowns while reading this step because we still\n> haven't seen the other sources of the hooks.\n\nYou correctly understood how it works by the next comment, yes.\n\nWe just relied on the fact that the default hooks in the hookdir do not\nhave a \"friendly name\", so we gave them an empty name to differentiate.\n\nNot a fan of this either, so I will certainly try out your suggestions\nbelow for v2.\n\n>> @@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,\n>>  \t\t\t  void **pp_task_cb)\n>>  {\n>>  \tstruct hook_cb_data *hook_cb = pp_cb;\n>> -\tconst char *hook_path = hook_cb->hook_path;\n>> +\tstruct string_list *hook_list = hook_cb->hook_command_list;\n>> +\tstruct string_list_item *to_run = hook_cb->next_hook++;\n>>  \n>> -\tif (!hook_path)\n>> -\t\treturn 0;\n>> +\tif (!to_run || to_run >= hook_list->items + hook_list->nr)\n>> +\t\treturn 0; /* no hook left to run */\n>>  \n>>  \tcp->no_stdin = 1;\n>>  \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\n>> @@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,\n>>  \tcp->trace2_hook_name = hook_cb->hook_name;\n>>  \tcp->dir = hook_cb->options->dir;\n>>  \n>> -\tstrvec_push(&cp->args, hook_path);\n>> +\t/* find hook commands */\n>> +\tif (!*to_run->string) {\n>> +\t\t/* ...from hookdir signified by empty name */\n>\n> Make a macro or a small helper function that takes a single\n> string_list_item that is an element in the return value of\n> list_hooks(), and tells if it is a program in $GIT_DIR/hooks/\n> directory.  Writing code that depends on this \"we did not bother to\n> invent a way to allow users to give friendly names to traditional\n> on-disk hooks, so it is registered with an empty string as its name\"\n> convention and having to document it is inefficient, both for\n> writers of the code and for readers of the code.\n\n100% agreed.\n\n>\n> Or perhaps the .util member of each of these can be a pointer to\n> something like:\n>\n> \tstruct {\n> \t\tenum {\n>                 \tHOOK_TRADITIONAL, HOOK_CONFIGURED,\n> \t\t} kind;\n> \t\tunion {\n> \t\t\tstruct {\n> \t\t\t\tconst char *path;\n> \t\t\t} traditional;\n> \t\t\tstruct {\n> \t\t\t\tconst char *friendly_name;\n> \t\t\t\tconst char *path;\n> \t\t\t\t... maybe others ...\n> \t\t\t} configured;\n> \t\t} u;\n> \t}\n>\n> so that this codepath does not even have to ...\n>\n>> +\t\tconst char *hook_path = find_hook(hook_cb->repository,\n>> +\t\t\t\t\t\t  hook_cb->hook_name);\n>\n> ... run the same find_hook() again here?\n\nYes, I'll combine all these approaches in v2 to see if I can come up\nwith something more sensible.\n\nThank you so much for the valuable feedback, I really appreciate it,\nAdrian\n"},{"id":"535363","messageId":"xmqqjywphm8a.fsf@gitster.g","threadId":"64918","inReplyTo":"20260204165126.1548805-1-adrian.ratiu@collabora.com","subject":"Re: [PATCH 0/4] Specify hooks via configs","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-06T16:26:29Z","receivedAt":"2026-02-06T16:26:31Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> Hello everyone,\n>\n> This series adds a new feature: the ability to specify commands to run\n> for hook events via config entries (including shell commands).\n>\n> The config schema is identical to the one developed by Emily and AEvar\n> a few years ago [1] though the implementation is significantly different\n> because it's based on the new / cleaned-up hook.[ch] APIs. [2].\n>\n> For simplicity, hooks are still executed sequentially (.jobs == 1) in\n> this series, just like before. Parallel execution will be enabled in\n> a separate series based on this one.\n>\n> The hook execution order is this:\n> 1. Hooks read from the config. If multiple hook commands are specified\n>    for a single event, they are executed in config discovery order.\n> 2. The default hooks from the hookdir.\n>\n> The above order can be changed if necessary.\n>\n> Again, this is based on the latest v8 hooks-conversion series [2] which\n> has not yet landed in next or master.\n\nOne thing missing to help those who haven't seen earlier iterations\nof this topic is why we would want to do this.  Instead of dropping\na shell script or a custom program \"foo\" in .git/hooks/ directory,\nyou can tell the system to run program X when you want to run \"foo\"\nhook.  The reason why somebody may want to do so is ...?\n"},{"id":"535534","messageId":"aYnu7e_hbSBwu9_N@pks.im","threadId":"64918","inReplyTo":"20260204165126.1548805-2-adrian.ratiu@collabora.com","subject":"Re: [PATCH 1/4] hook: run a list of hooks","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-09T14:27:57Z","receivedAt":"2026-02-09T14:28:05Z","isPatch":true,"body":"On Wed, Feb 04, 2026 at 06:51:23PM +0200, Adrian Ratiu wrote:\n> From: Emily Shaffer <emilyshaffer@google.com>\n> \n> Teach hook.[hc] to run lists of hooks to prepare for multihook support.\n> \n> Currently, the hook list contains only one entry representing the\n> \"legacy\" hook from the hookdir, but next commits will allow users\n> to supply more than one executable/command for a single hook event\n> in addition to these default \"legacy\" hooks.\n> \n> All hook commands still run sequentially. A further patch series will\n> enable running them in parallel by increasing .jobs > 1 where possible.\n> \n> Each hook command requires its own internal state copy, even when\n> running sequentially, so add an API to allow hooks to duplicate/free\n> their internal void *pp_task_cb state.\n\nI think the order in this commit message is a bit off. We typically\nfirst state the problem that we aim to solve before presenting the\nsolution.\n\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index b5379a4895..72fde2207c 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -849,7 +849,8 @@ struct receive_hook_feed_state {\n>  \n>  static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)\n>  {\n> -\tstruct receive_hook_feed_state *state = pp_task_cb;\n> +\tstruct string_list_item *h = pp_task_cb;\n> +\tstruct receive_hook_feed_state *state = h->util;\n>  \tstruct command *cmd = state->cmd;\n>  \n>  \tstrbuf_reset(&state->buf);\n> @@ -901,6 +902,24 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_\n>  \treturn state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */\n>  }\n>  \n> +static void *copy_receive_hook_feed_state(const void *data)\n\nNit: our coding guidelines say that the name of the struct should come\nfirst. So this would be `receive_hook_feed_state_copy()`.\n\n> +{\n> +\tconst struct receive_hook_feed_state *orig = data;\n> +\tstruct receive_hook_feed_state *new_data = xmalloc(sizeof(*new_data));\n> +\tmemcpy(new_data, orig, sizeof(*new_data));\n> +\tstrbuf_init(&new_data->buf, 0);\n> +\treturn new_data;\n> +}\n> +\n> +static void free_receive_hook_feed_state(void *data)\n\nLikewise, this would be `receive_hook_feed_state_free()`.\n\n> diff --git a/hook.c b/hook.c\n> index cde7198412..fb90f91f3b 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)\n>  \treturn path.buf;\n>  }\n>  \n> +/*\n> + * Provides a list of hook commands to run for the 'hookname' event.\n> + *\n> + * This function consolidates hooks from two sources:\n> + * 1. The config-based hooks (not yet implemented).\n> + * 2. The \"traditional\" hook found in the repository hooks directory\n> + *    (e.g., .git/hooks/pre-commit).\n> + *\n> + * The list is ordered by execution priority.\n> + *\n> + * The caller is responsible for freeing the memory of the returned list\n> + * using string_list_clear() and free().\n> + */\n> +static struct string_list *list_hooks(struct repository *r, const char *hookname)\n> +{\n> +\tstruct string_list *hook_head;\n> +\n> +\tif (!hookname)\n> +\t\tBUG(\"null hookname was provided to hook_list()!\");\n> +\n> +\thook_head = xmalloc(sizeof(struct string_list));\n> +\tstring_list_init_dup(hook_head);\n> +\n> +\t/*\n> +\t * Add the default hook from hookdir. It does not have a friendly name\n> +\t * like the hooks specified via configs, so add it with an empty name.\n> +\t */\n> +\tif (r->gitdir && find_hook(r, hookname))\n> +\t\tstring_list_append(hook_head, \"\");\n\nWhy is there a check for `r->gitdir` here? Do we ever execute hooks\noutside of a fully-initialized repository?\n\nOther than that, we now insert hooks into the list. It's somewhat\nsurprising that we insert hook \"names\" here, instead of for example\nadding the full hook path to the list. Is there any specific reason for\nthis decision?\n\n> +\treturn hook_head;\n> +}\n> +\n>  int hook_exists(struct repository *r, const char *name)\n>  {\n> -\treturn !!find_hook(r, name);\n> +\tint exists = 0;\n> +\tstruct string_list *hooks = list_hooks(r, name);\n> +\n> +\texists = hooks->nr > 0;\n> +\n> +\tstring_list_clear(hooks, 1);\n> +\tfree(hooks);\n> +\treturn exists;\n>  }\n>  \n>  static int pick_next_hook(struct child_process *cp,\n\nAnd here we verify that there's at least one such hook that we found.\nWhich would currently mean that the hook in \".git/hooks/\" exists.\n\n> @@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,\n>  \t\t\t  void **pp_task_cb)\n>  {\n>  \tstruct hook_cb_data *hook_cb = pp_cb;\n> -\tconst char *hook_path = hook_cb->hook_path;\n> +\tstruct string_list *hook_list = hook_cb->hook_command_list;\n> +\tstruct string_list_item *to_run = hook_cb->next_hook++;\n>  \n> -\tif (!hook_path)\n> -\t\treturn 0;\n> +\tif (!to_run || to_run >= hook_list->items + hook_list->nr)\n> +\t\treturn 0; /* no hook left to run */\n\nHm, okay. Wouldn't it be a bit more ergonomic to instead store the index\nof the current hook instead of storing the string list item itself? In\nthat case we could verify whether `hook_cb->hook_cur < hook_list.nr` or\nsomething like this.\n\n> @@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,\n>  \tcp->trace2_hook_name = hook_cb->hook_name;\n>  \tcp->dir = hook_cb->options->dir;\n>  \n> -\tstrvec_push(&cp->args, hook_path);\n> +\t/* find hook commands */\n> +\tif (!*to_run->string) {\n> +\t\t/* ...from hookdir signified by empty name */\n> +\t\tconst char *hook_path = find_hook(hook_cb->repository,\n> +\t\t\t\t\t\t  hook_cb->hook_name);\n> +\t\tif (!hook_path)\n> +\t\t\tBUG(\"hookdir in hook list but no hook present in filesystem\");\n> +\n> +\t\tif (hook_cb->options->dir)\n> +\t\t\thook_path = absolute_path(hook_path);\n> +\n> +\t\tstrvec_push(&cp->args, hook_path);\n> +\t}\n\nWe could avoid this special casing if we stored the absolute paths in\nthe hook list right away. But maybe there is a good reason you don't.\n\n> +\tif (!cp->args.nr)\n> +\t\tBUG(\"configured hook must have at least one command\");\n> +\n>  \tstrvec_pushv(&cp->args, hook_cb->options->args.v);\n>  \n>  \t/*\n>  \t * Provide per-hook internal state via task_cb for easy access, so\n>  \t * hook callbacks don't have to go through hook_cb->options.\n>  \t */\n> -\t*pp_task_cb = hook_cb->options->feed_pipe_cb_data;\n> -\n> -\t/*\n> -\t * This pick_next_hook() will be called again, we're only\n> -\t * running one hook, so indicate that no more work will be\n> -\t * done.\n> -\t */\n> -\thook_cb->hook_path = NULL;\n> +\t*pp_task_cb = to_run;\n>  \n>  \treturn 1;\n>  }\n>  \n> -static int notify_start_failure(struct strbuf *out UNUSED,\n> +static int notify_start_failure(struct strbuf *out,\n>  \t\t\t\tvoid *pp_cb,\n> -\t\t\t\tvoid *pp_task_cp UNUSED)\n> +\t\t\t\tvoid *pp_task_cb)\n>  {\n>  \tstruct hook_cb_data *hook_cb = pp_cb;\n> +\tstruct string_list_item *hook = pp_task_cb;\n>  \n>  \thook_cb->rc |= 1;\n>  \n> +\tif (out && hook) {\n> +\t\tif (*hook->string)\n> +\t\t\tstrbuf_addf(out, _(\"Couldn't start hook '%s'\\n\"), hook->string);\n> +\t\telse\n> +\t\t\tstrbuf_addstr(out, _(\"Couldn't start hook from hooks directory\\n\"));\n> +\t}\n> +\n>  \treturn 1;\n>  }\n\nOkay, here we can give a bit more detail in the error message. But that\nalone doesn't quite feel worth the additional complexity.\n\n> @@ -172,26 +231,50 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n>  \tif (!options->jobs)\n>  \t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n>  \n> +\t/*\n> +\t * Ensure cb_data copy and free functions are either provided together,\n> +\t * or neither one is provided.\n> +\t */\n> +\tif ((options->copy_feed_pipe_cb_data && !options->free_feed_pipe_cb_data) ||\n> +\t    (!options->copy_feed_pipe_cb_data && options->free_feed_pipe_cb_data))\n> +\t\tBUG(\"copy_feed_pipe_cb_data and free_feed_pipe_cb_data must be set together\");\n\nI wonder whether it would make the commit a bit easier to review if you\nsplit it up into two:\n\n  - One commit that introduces the copy/free callback functions.\n\n  - One commit that introduces the hook list.\n\nThen the changes would be a bit more focussed.\n\n>  \tif (options->invoked_hook)\n>  \t\t*options->invoked_hook = 0;\n>  \n> -\tif (!hook_path && !options->error_if_missing)\n> -\t\tgoto cleanup;\n> -\n> -\tif (!hook_path) {\n> -\t\tret = error(\"cannot find a hook named %s\", hook_name);\n> +\tif (!cb_data.hook_command_list->nr) {\n> +\t\tif (options->error_if_missing)\n> +\t\t\tret = error(\"cannot find a hook named %s\", hook_name);\n>  \t\tgoto cleanup;\n>  \t}\n>  \n> -\tcb_data.hook_path = hook_path;\n> -\tif (options->dir) {\n> -\t\tstrbuf_add_absolute_path(&abs_path, hook_path);\n> -\t\tcb_data.hook_path = abs_path.buf;\n> +\t/*\n> +\t * Initialize the iterator/cursor which holds the next hook to run.\n> +\t * run_process_parallel() calls pick_next_hook() which increments it for\n> +\t * each hook command in the list until all hooks have been run.\n> +\t */\n> +\tcb_data.next_hook = cb_data.hook_command_list->items;\n> +\n> +\t/*\n> +\t * Give each hook its own copy of the initial void *pp_task_cb state, if\n> +\t * a copy callback was provided.\n> +\t */\n> +\tif (options->copy_feed_pipe_cb_data) {\n> +\t\tstruct string_list_item *item;\n> +\t\tfor_each_string_list_item(item, cb_data.hook_command_list)\n> +\t\t\titem->util = options->copy_feed_pipe_cb_data(options->feed_pipe_cb_data);\n>  \t}\n\nMakes sense. We cannot just peform a shallow copy of the callback data\nas it may contain data itself that needs to be copied. So we have to\nhandle the copying ourselves.\n\nOne thing I wondered is whether it would lead to a cleaner design if\nthis was instead provided as a callback to allocate and initialize a\ncompletely _new_ callback data. In that case the caller wouldn't have to\nfirst initialize the data only to copy it in a different place, all the\nlogic would be self-contained in that one callback.\n\nThe downside would of course be that we cannot use for example the\nstack, and it might make it harder to reuse state across multiple hook\ninvocations, if that's a feature we care about.\n\nPatrick\n"},{"id":"535535","messageId":"aYnu8ltBUUflVgm2@pks.im","threadId":"64918","inReplyTo":"20260204165126.1548805-3-adrian.ratiu@collabora.com","subject":"Re: [PATCH 2/4] hook: introduce \"git hook list\"","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-09T14:28:02Z","receivedAt":"2026-02-09T14:28:07Z","isPatch":true,"body":"On Wed, Feb 04, 2026 at 06:51:24PM +0200, Adrian Ratiu wrote:\n> From: Emily Shaffer <emilyshaffer@google.com>\n> \n> If more than one hook will be run, it may be useful to see a list of\n> which hooks should be run. At very least, it will be useful for us to\n> test the semantics of multihooks ourselves.\n> \n> For now, only list the hooks which will run in the order they will run\n> in; later, it might be useful to include more information like where the\n> hooks were configured and whether or not they will run.\n\nI think the commit message could be adapted a bit again to first explain\nthe problem we're about to solve.\n\n> diff --git a/builtin/hook.c b/builtin/hook.c\n> index 7afec380d2..4cc6dac45a 100644\n> --- a/builtin/hook.c\n> +++ b/builtin/hook.c\n> @@ -20,6 +24,54 @@ static const char * const builtin_hook_run_usage[] = {\n>  \tNULL\n>  };\n>  \n> +static const char *const builtin_hook_list_usage[] = {\n> +\tBUILTIN_HOOK_LIST_USAGE,\n> +\tNULL\n> +};\n> +\n\nThis constant can be declared inside `list()`.\n\n> +static int list(int argc, const char **argv, const char *prefix,\n> +\t\t struct repository *repo UNUSED)\n> +{\n> +\tstruct string_list *head;\n> +\tstruct string_list_item *item;\n> +\tconst char *hookname = NULL;\n> +\tint ret = 0;\n> +\n> +\tstruct option list_options[] = {\n> +\t\tOPT_END(),\n> +\t};\n> +\n> +\targc = parse_options(argc, argv, prefix, list_options,\n> +\t\t\t     builtin_hook_list_usage, 0);\n> +\n> +\t/*\n> +\t * The only unnamed argument provided should be the hook-name; if we add\n> +\t * arguments later they probably should be caught by parse_options.\n> +\t */\n> +\tif (argc != 1)\n> +\t\tusage_msg_opt(_(\"You must specify a hook event name to list.\"),\n> +\t\t\t      builtin_hook_list_usage, list_options);\n> +\n> +\thookname = argv[0];\n> +\n> +\thead = list_hooks(the_repository, hookname);\n\nWe can use the `repo` parameter instead. The git-hook(1) command is\ndeclared with `RUN_SETUP`, so it will always be set.\n\n> +\tif (!head->nr) {\n> +\t\tret = 1; /* no hooks found */\n> +\t\tgoto cleanup;\n> +\t}\n\nDo we want to print an error message in this case?\n\n> +\tfor_each_string_list_item(item, head) {\n> +\t\tprintf(\"%s\\n\", *item->string ? item->string\n> +\t\t\t     : _(\"hook from hookdir\"));\n> +\t}\n\nThis is another case where we could avoid special-casing if the string\nlist contained the hook paths.\n\nI also wonder whether we should add a \"-z\" mode to NUL-terminate the\noutput. In theory, hooks may be configured with a newline in their path.\nProbably not all that common, but somehow special cases like this always\nend up being encountered eventually.\n\n> diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\n> index ed28a2fadb..d2d4a8760c 100755\n> --- a/t/t1800-hook.sh\n> +++ b/t/t1800-hook.sh\n> @@ -10,6 +10,8 @@ test_expect_success 'git hook usage' '\n>  \ttest_expect_code 129 git hook run &&\n>  \ttest_expect_code 129 git hook run -h &&\n>  \ttest_expect_code 129 git hook run --unknown 2>err &&\n> +\ttest_expect_code 129 git hook list &&\n> +\ttest_expect_code 129 git hook list -h &&\n>  \tgrep \"unknown option\" err\n>  '\n\nShouldn't we also have some tests that show that this is working as\nexpected with a configured hook in \".git/hooks\"?\n\nPatrick\n"},{"id":"535536","messageId":"aYnu9gJATVJeKEjZ@pks.im","threadId":"64918","inReplyTo":"20260204165126.1548805-4-adrian.ratiu@collabora.com","subject":"Re: [PATCH 3/4] hook: include hooks from the config","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-09T14:28:06Z","receivedAt":"2026-02-09T14:28:12Z","isPatch":true,"body":"On Wed, Feb 04, 2026 at 06:51:25PM +0200, Adrian Ratiu wrote:\n> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n> new file mode 100644\n> index 0000000000..49c7ffd82e\n> --- /dev/null\n> +++ b/Documentation/config/hook.adoc\n> @@ -0,0 +1,17 @@\n> +hook.<name>.command::\n> +\tA command to execute whenever `hook.<name>` is invoked. `<name>` should\n> +\tbe a unique \"friendly\" name which you can use to identify this hook\n> +\tcommand. (You can specify when to invoke this command with\n> +\t`hook.<name>.event`.) The value can be an executable on your device or a\n> +\toneliner for your shell. If more than one value is specified for the\n> +\tsame `<name>`, the last value parsed will be the only command executed.\n> +\tSee linkgit:git-hook[1].\n> +\n> +hook.<name>.event::\n> +\tThe hook events which should invoke `hook.<name>`. `<name>` should be a\n> +\tunique \"friendly\" name which you can use to identify this hook. The\n> +\tvalue should be the name of a hook event, like \"pre-commit\" or \"update\".\n> +\t(See linkgit:githooks[5] for a complete list of hooks Git knows about.)\n> +\tOn the specified event, the associated `hook.<name>.command` will be\n> +\texecuted. More than one event can be specified if you wish for\n> +\t`hook.<name>` to execute on multiple events. See linkgit:git-hook[1].\n\nI'm not exactly sure how you'd execute it on multiple events after\nreading this description. Is this by comma-delimiting them? Or is the\nevent key a multivalue variable? If the latter (which I think would make\nmost sense), can you also reset the value by e.g. saying \"event = \" like\nwe support with other multivalued keys?\n\nI would have also expected a \"hook.<name>.enabled\" option so that you\ncan disable globally configured hooks.\n\n> diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\n> index 93d734f687..5f339dc48b 100644\n> --- a/Documentation/git-hook.adoc\n> +++ b/Documentation/git-hook.adoc\n> @@ -17,12 +17,94 @@ DESCRIPTION\n>  A command interface for running git hooks (see linkgit:githooks[5]),\n>  for use by other scripted git commands.\n>  \n> +This command parses the default configuration files for sets of configs like\n> +so:\n> +\n> +  [hook \"linter\"]\n> +    event = pre-commit\n> +    command = ~/bin/linter --cpp20\n> +\n> +In this example, `[hook \"linter\"]` represents one script - `~/bin/linter\n> +--cpp20` - which can be shared by many repos, and even by many hook events, if\n> +appropriate.\n> +\n> +To add an unrelated hook which runs on a different event, for example a\n> +spell-checker for your commit messages, you would write a configuration like so:\n> +\n> +  [hook \"linter\"]\n> +    event = pre-commit\n> +    command = ~/bin/linter --cpp20\n> +  [hook \"spellcheck\"]\n> +    event = commit-msg\n> +    command = ~/bin/spellchecker\n> +\n> +With this config, when you run 'git commit', first `~/bin/linter --cpp20` will\n> +have a chance to check your files to be committed (during the `pre-commit` hook\n> +event`), and then `~/bin/spellchecker` will have a chance to check your commit\n> +message (during the `commit-msg` hook event).\n> +\n> +Commands are run in the order Git encounters their associated\n> +`hook.<name>.event` configs during the configuration parse (see\n> +linkgit:git-config[1]). Although multiple `hook.linter.event` configs can be\n> +added, only one `hook.linter.command` event is valid - Git uses \"last-one-wins\"\n> +to determine which command to run.\n> +\n> +So if you wanted your linter to run when you commit as well as when you push,\n> +you would configure it like so:\n> +\n> +  [hook \"linter\"]\n> +    event = pre-commit\n> +    event = pre-push\n> +    command = ~/bin/linter --cpp20\n> +\n> +With this config, `~/bin/linter --cpp20` would be run by Git before a commit is\n> +generated (during `pre-commit`) as well as before a push is performed (during\n> +`pre-push`).\n> +\n> +And if you wanted to run your linter as well as a secret-leak detector during\n> +only the \"pre-commit\" hook event, you would configure it instead like so:\n> +\n> +  [hook \"linter\"]\n> +    event = pre-commit\n> +    command = ~/bin/linter --cpp20\n> +  [hook \"no-leaks\"]\n> +    event = pre-commit\n> +    command = ~/bin/leak-detector\n> +\n> +With this config, before a commit is generated (during `pre-commit`), Git would\n> +first start `~/bin/linter --cpp20` and second start `~/bin/leak-detector`. It\n> +would evaluate the output of each when deciding whether to proceed with the\n> +commit.\n> +\n> +For a full list of hook events which you can set your `hook.<name>.event` to,\n> +and how hooks are invoked during those events, see linkgit:githooks[5].\n> +\n> +Git will ignore any `hook.<name>.event` that specifies an event it doesn't\n> +recognize. This is intended so that tools which wrap Git can use the hook\n> +infrastructure to run their own hooks; see \"WRAPPERS\" for more guidance.\n> +\n> +In general, when instructions suggest adding a script to\n> +`.git/hooks/<hook-event>`, you can specify it in the config instead by running:\n> +\n> +----\n> +git config hook.<some-name>.command <path-to-script>\n> +git config --add hook.<some-name>.event <hook-event>\n\nLet's use the modern equivalents:\n\n    git config set hook.<some-name>.command <path-to-script>\n    git config set --append hook.<some-name>.event <hook-event>\n\n> +----\n> +\n> +This way you can share the script between multiple repos. That is, `cp\n> +~/my-script.sh ~/project/.git/hooks/pre-commit` would become:\n> +\n> +----\n> +git config hook.my-script.command ~/my-script.sh\n> +git config --add hook.my-script.event pre-commit\n> +----\n\nLikewise.\n\n> @@ -46,6 +128,46 @@ OPTIONS\n>  \ttools that want to do a blind one-shot run of a hook that may\n>  \tor may not be present.\n>  \n> +WRAPPERS\n> +--------\n> +\n> +`git hook run` has been designed to make it easy for tools which wrap Git to\n> +configure and execute hooks using the Git hook infrastructure. It is possible to\n> +provide arguments and stdin via the command line, as well as specifying parallel\n> +or series execution if the user has provided multiple hooks.\n> +\n> +Assuming your wrapper wants to support a hook named \"mywrapper-start-tests\", you\n> +can have your users specify their hooks like so:\n> +\n> +  [hook \"setup-test-dashboard\"]\n> +    event = mywrapper-start-tests\n> +    command = ~/mywrapper/setup-dashboard.py --tap\n\nAh, interesting. Would that then also look for\n\".git/hooks/mywrapper-start-tests\"?\n\n> diff --git a/hook.c b/hook.c\n> index 949c907b59..9f59ebd0bd 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -47,24 +47,74 @@ const char *find_hook(struct repository *r, const char *name)\n>  \treturn path.buf;\n>  }\n>  \n> +struct hook_config_cb\n> +{\n\nFormatting: the curly brace should not go on a standalone line.\n\n> +\tconst char *hook_event;\n> +\tstruct string_list *list;\n> +};\n> +\n> +/*\n> + * Callback for git_config which adds configured hooks to a hook list.  Hooks\n> + * can be configured by specifying both hook.<friendly-name>.command = <path>\n> + * and hook.<friendly-name>.event = <hook-event>.\n> + */\n> +static int hook_config_lookup(const char *key, const char *value,\n> +\t\t\t      const struct config_context *ctx UNUSED,\n> +\t\t\t      void *cb_data)\n> +{\n> +\tstruct hook_config_cb *data = cb_data;\n> +\tconst char *name, *event_key;\n> +\tsize_t name_len = 0;\n> +\tstruct string_list_item *item;\n> +\tchar *hook_name;\n> +\n> +\t/*\n> +\t * Don't bother doing the expensive parse if there's no\n> +\t * chance that the config matches 'hook.myhook.event = hook_event'.\n> +\t */\n> +\tif (!value || strcmp(value, data->hook_event))\n> +\t\treturn 0;\n\nOkay.\n\n> +\t/* Look for \"hook.friendly-name.event = hook_event\" */\n> +\tif (parse_config_key(key, \"hook\", &name, &name_len, &event_key) ||\n> +\t    strcmp(event_key, \"event\"))\n> +\t\treturn 0;\n\nSo we only care about parsing \"hook.<name>.event\" here, and I assume\nthat we would look up the command at a later point in time? That design\nis somewhat weird -- I would have expected that we scan through all\nconfig keys and already pull out both the command and event so that we\ndon't have to extract the config a second time.\n\nBut I guess this is another consequence of the string list only caring\nabout the name, not about the actual path.\n\n> +\t/* Extract the hook name */\n> +\thook_name = xmemdupz(name, name_len);\n> +\n> +\t/* Remove the hook if already in the list, so we append in config order. */\n> +\tif ((item = unsorted_string_list_lookup(data->list, hook_name)))\n> +\t\tunsorted_string_list_delete_item(data->list, item - data->list->items, 0);\n> +\n> +\t/* The list takes ownership of hook_name, so append with nodup */\n> +\tstring_list_append_nodup(data->list, hook_name);\n> +\n> +\treturn 0;\n> +}\n> +\n>  struct string_list *list_hooks(struct repository *r, const char *hookname)\n>  {\n> -\tstruct string_list *hook_head;\n> +\tstruct hook_config_cb cb_data;\n>  \n>  \tif (!hookname)\n>  \t\tBUG(\"null hookname was provided to hook_list()!\");\n>  \n> -\thook_head = xmalloc(sizeof(struct string_list));\n> -\tstring_list_init_dup(hook_head);\n> +\tcb_data.hook_event = hookname;\n> +\tcb_data.list = xmalloc(sizeof(struct string_list));\n> +\tstring_list_init_dup(cb_data.list);\n> +\n> +\t/* Add the hooks from the config, e.g. hook.myhook.event = pre-commit */\n> +\trepo_config(r, hook_config_lookup, &cb_data);\n\nThere are cases where we invoke the same hook multiple times per\nprocess. One such candidate is for example the \"reference-transaction\"\nhook, which is executed thrice per ref transaction. Would it make sense\nif we cached the value in `r` so that we can avoid repeatedly parsing\nthe configuration for this hook?\n\nWe could even go one step further: instead of caching the value for\n_one_ hook, we could parse _all_ hooks lazily and store them in a map.\n\n> @@ -125,6 +175,24 @@ static int pick_next_hook(struct child_process *cp,\n>  \t\t\thook_path = absolute_path(hook_path);\n>  \n>  \t\tstrvec_push(&cp->args, hook_path);\n> +\t} else {\n> +\t\t/* ...from config */\n> +\t\tstruct strbuf cmd_key = STRBUF_INIT;\n> +\t\tchar *command = NULL;\n> +\n> +\t\t/* to enable oneliners, let config-specified hooks run in shell. */\n> +\t\tcp->use_shell = true;\n> +\n> +\t\tstrbuf_addf(&cmd_key, \"hook.%s.command\", to_run->string);\n> +\t\tif (repo_config_get_string(hook_cb->repository,\n> +\t\t\t\t\t   cmd_key.buf, &command)) {\n> +\t\t\tdie(_(\"'hook.%s.command' must be configured or\"\n> +\t\t\t      \"'hook.%s.event' must be removed; aborting.\\n\"),\n> +\t\t\t    to_run->string, to_run->string);\n> +\t\t}\n> +\t\tstrbuf_release(&cmd_key);\n> +\n> +\t\tstrvec_push_nodup(&cp->args, command);\n>  \t}\n>  \n>  \tif (!cp->args.nr)\n\nOkay, so here we do an extra step to extract the commands. I think this\nwould fit more naturally in `hook_config_lookup`.\n\nPatrick\n"},{"id":"535587","messageId":"878qd1235o.fsf@gentoo.mail-host-address-is-not-set","threadId":"64918","inReplyTo":"aYnu7e_hbSBwu9_N@pks.im","subject":"Re: [PATCH 1/4] hook: run a list of hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-09T18:16:35Z","receivedAt":"2026-02-09T18:16:56Z","isPatch":true,"body":"On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Wed, Feb 04, 2026 at 06:51:23PM +0200, Adrian Ratiu wrote:\n>> From: Emily Shaffer <emilyshaffer@google.com>\n>> \n>> Teach hook.[hc] to run lists of hooks to prepare for multihook support.\n>> \n>> Currently, the hook list contains only one entry representing the\n>> \"legacy\" hook from the hookdir, but next commits will allow users\n>> to supply more than one executable/command for a single hook event\n>> in addition to these default \"legacy\" hooks.\n>> \n>> All hook commands still run sequentially. A further patch series will\n>> enable running them in parallel by increasing .jobs > 1 where possible.\n>> \n>> Each hook command requires its own internal state copy, even when\n>> running sequentially, so add an API to allow hooks to duplicate/free\n>> their internal void *pp_task_cb state.\n>\n> I think the order in this commit message is a bit off. We typically\n> first state the problem that we aim to solve before presenting the\n> solution.\n\nAck. Will fix in v2.\n\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>> index b5379a4895..72fde2207c 100644\n>> --- a/builtin/receive-pack.c\n>> +++ b/builtin/receive-pack.c\n>> @@ -849,7 +849,8 @@ struct receive_hook_feed_state {\n>>  \n>>  static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_task_cb)\n>>  {\n>> -\tstruct receive_hook_feed_state *state = pp_task_cb;\n>> +\tstruct string_list_item *h = pp_task_cb;\n>> +\tstruct receive_hook_feed_state *state = h->util;\n>>  \tstruct command *cmd = state->cmd;\n>>  \n>>  \tstrbuf_reset(&state->buf);\n>> @@ -901,6 +902,24 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_\n>>  \treturn state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */\n>>  }\n>>  \n>> +static void *copy_receive_hook_feed_state(const void *data)\n>\n> Nit: our coding guidelines say that the name of the struct should come\n> first. So this would be `receive_hook_feed_state_copy()`.\n\nAck will fix in v2 together with the below nit.\n\n<snip>\n\n>> diff --git a/hook.c b/hook.c\n>> index cde7198412..fb90f91f3b 100644\n>> --- a/hook.c\n>> +++ b/hook.c\n>> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)\n>>  \treturn path.buf;\n>>  }\n>>  \n>> +/*\n>> + * Provides a list of hook commands to run for the 'hookname' event.\n>> + *\n>> + * This function consolidates hooks from two sources:\n>> + * 1. The config-based hooks (not yet implemented).\n>> + * 2. The \"traditional\" hook found in the repository hooks directory\n>> + *    (e.g., .git/hooks/pre-commit).\n>> + *\n>> + * The list is ordered by execution priority.\n>> + *\n>> + * The caller is responsible for freeing the memory of the returned list\n>> + * using string_list_clear() and free().\n>> + */\n>> +static struct string_list *list_hooks(struct repository *r, const char *hookname)\n>> +{\n>> +\tstruct string_list *hook_head;\n>> +\n>> +\tif (!hookname)\n>> +\t\tBUG(\"null hookname was provided to hook_list()!\");\n>> +\n>> +\thook_head = xmalloc(sizeof(struct string_list));\n>> +\tstring_list_init_dup(hook_head);\n>> +\n>> +\t/*\n>> +\t * Add the default hook from hookdir. It does not have a friendly name\n>> +\t * like the hooks specified via configs, so add it with an empty name.\n>> +\t */\n>> +\tif (r->gitdir && find_hook(r, hookname))\n>> +\t\tstring_list_append(hook_head, \"\");\n>\n> Why is there a check for `r->gitdir` here? Do we ever execute hooks\n> outside of a fully-initialized repository?\n\nNice find. The answer is yes, see the last commit in this series.\n\nI've been cleaning up and untangling this series for quite a while now:\n- It used to be combined with the other parallel hooks series.\n- It used to implement its own linked list abstraction.\n- It used the hook.h string-list APIs AEvar didn't like.\n- and so on.\n\nThis cheeck is just a leftover bit I missed during my cleanups and I\nreally should be moved this to the last commit. I've been moving a lot\nof code around to make it as clear as possible.\n\nI'll do it / test in v2. Many thanks!\n\n>\n> Other than that, we now insert hooks into the list. It's somewhat\n> surprising that we insert hook \"names\" here, instead of for example\n> adding the full hook path to the list. Is there any specific reason for\n> this decision?\n\nI'm also not a fan of this design, as I mentioned in the reply to Junio.\n\nWe just need to differentiate between \"new\" hooks (from config) and\n\"default/legacy\" hooks (from the hookdir) and we kind-of abused the fact\nthat the default hooks have no friendly name, so the name is empty, to\ndifferentiate them. :)\n\nJunio suggested we use a struct/union and a specific type to\ndifferentiate between the \"default/legacy\" hooks (from the hookdir) and\nthe new config-based hooks.\n\nThat way we don't have to rely on the name anymore and we can also use\nthe full path here, as you suggested.\n\nI'll do all this in v2. Thank you!\n\n<snip>\n\n>> @@ -58,10 +98,11 @@ static int pick_next_hook(struct child_process *cp,\n>>  \t\t\t  void **pp_task_cb)\n>>  {\n>>  \tstruct hook_cb_data *hook_cb = pp_cb;\n>> -\tconst char *hook_path = hook_cb->hook_path;\n>> +\tstruct string_list *hook_list = hook_cb->hook_command_list;\n>> +\tstruct string_list_item *to_run = hook_cb->next_hook++;\n>>  \n>> -\tif (!hook_path)\n>> -\t\treturn 0;\n>> +\tif (!to_run || to_run >= hook_list->items + hook_list->nr)\n>> +\t\treturn 0; /* no hook left to run */\n>\n> Hm, okay. Wouldn't it be a bit more ergonomic to instead store the index\n> of the current hook instead of storing the string list item itself? In\n> that case we could verify whether `hook_cb->hook_cur < hook_list.nr` or\n> something like this.\n\nThis is actually a great idea, thanks! I will do it in v2.\n\n(It is yet another leftover from when this series used to roll its own\nlinked-list implementation. :)\n\n>> @@ -85,33 +126,50 @@ static int pick_next_hook(struct child_process *cp,\n>>  \tcp->trace2_hook_name = hook_cb->hook_name;\n>>  \tcp->dir = hook_cb->options->dir;\n>>  \n>> -\tstrvec_push(&cp->args, hook_path);\n>> +\t/* find hook commands */\n>> +\tif (!*to_run->string) {\n>> +\t\t/* ...from hookdir signified by empty name */\n>> +\t\tconst char *hook_path = find_hook(hook_cb->repository,\n>> +\t\t\t\t\t\t  hook_cb->hook_name);\n>> +\t\tif (!hook_path)\n>> +\t\t\tBUG(\"hookdir in hook list but no hook present in filesystem\");\n>> +\n>> +\t\tif (hook_cb->options->dir)\n>> +\t\t\thook_path = absolute_path(hook_path);\n>> +\n>> +\t\tstrvec_push(&cp->args, hook_path);\n>> +\t}\n>\n> We could avoid this special casing if we stored the absolute paths in\n> the hook list right away. But maybe there is a good reason you don't.\n\nYes, will fix this in v2, trying to also use Junio's struct/union\nsuggestion to help make the difference between hook types.\n\n>> +\tif (!cp->args.nr)\n>> +\t\tBUG(\"configured hook must have at least one command\");\n>> +\n>>  \tstrvec_pushv(&cp->args, hook_cb->options->args.v);\n>>  \n>>  \t/*\n>>  \t * Provide per-hook internal state via task_cb for easy access, so\n>>  \t * hook callbacks don't have to go through hook_cb->options.\n>>  \t */\n>> -\t*pp_task_cb = hook_cb->options->feed_pipe_cb_data;\n>> -\n>> -\t/*\n>> -\t * This pick_next_hook() will be called again, we're only\n>> -\t * running one hook, so indicate that no more work will be\n>> -\t * done.\n>> -\t */\n>> -\thook_cb->hook_path = NULL;\n>> +\t*pp_task_cb = to_run;\n>>  \n>>  \treturn 1;\n>>  }\n>>  \n>> -static int notify_start_failure(struct strbuf *out UNUSED,\n>> +static int notify_start_failure(struct strbuf *out,\n>>  \t\t\t\tvoid *pp_cb,\n>> -\t\t\t\tvoid *pp_task_cp UNUSED)\n>> +\t\t\t\tvoid *pp_task_cb)\n>>  {\n>>  \tstruct hook_cb_data *hook_cb = pp_cb;\n>> +\tstruct string_list_item *hook = pp_task_cb;\n>>  \n>>  \thook_cb->rc |= 1;\n>>  \n>> +\tif (out && hook) {\n>> +\t\tif (*hook->string)\n>> +\t\t\tstrbuf_addf(out, _(\"Couldn't start hook '%s'\\n\"), hook->string);\n>> +\t\telse\n>> +\t\t\tstrbuf_addstr(out, _(\"Couldn't start hook from hooks directory\\n\"));\n>> +\t}\n>> +\n>>  \treturn 1;\n>>  }\n>\n> Okay, here we can give a bit more detail in the error message. But that\n> alone doesn't quite feel worth the additional complexity.\n\nNice find. I 100% agree.\n\nI actually asked myself this multiple times before posting the\nseries, because it would significantly simplify the implementation if we\ndon't do this distinction here and just write a single \"generic\" can't\nstart hook message. :)\n\nI'm still uncertain if this is worth it, becaute this is the only\ncallback which uses it and it's the only reason we changed the\n\"pp_task_cb\" convention in ALL hook callbacks.\n\nVery likely I'll dorp this in v2, or at least split it out into its own\ncommit, if possible, so we see the full complexity cost of this one msg.\n\n>> @@ -172,26 +231,50 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n>>  \tif (!options->jobs)\n>>  \t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n>>  \n>> +\t/*\n>> +\t * Ensure cb_data copy and free functions are either provided together,\n>> +\t * or neither one is provided.\n>> +\t */\n>> +\tif ((options->copy_feed_pipe_cb_data && !options->free_feed_pipe_cb_data) ||\n>> +\t    (!options->copy_feed_pipe_cb_data && options->free_feed_pipe_cb_data))\n>> +\t\tBUG(\"copy_feed_pipe_cb_data and free_feed_pipe_cb_data must be set together\");\n>\n> I wonder whether it would make the commit a bit easier to review if you\n> split it up into two:\n>\n>   - One commit that introduces the copy/free callback functions.\n>\n>   - One commit that introduces the hook list.\n>\n> Then the changes would be a bit more focussed.\n\nYes, this makes sense. As I mentioned above, I've been splitting and\nsimplifying these commits for quite a while now.\n\nThis would be very natural continuation.\n\n>>  \tif (options->invoked_hook)\n>>  \t\t*options->invoked_hook = 0;\n>>  \n>> -\tif (!hook_path && !options->error_if_missing)\n>> -\t\tgoto cleanup;\n>> -\n>> -\tif (!hook_path) {\n>> -\t\tret = error(\"cannot find a hook named %s\", hook_name);\n>> +\tif (!cb_data.hook_command_list->nr) {\n>> +\t\tif (options->error_if_missing)\n>> +\t\t\tret = error(\"cannot find a hook named %s\", hook_name);\n>>  \t\tgoto cleanup;\n>>  \t}\n>>  \n>> -\tcb_data.hook_path = hook_path;\n>> -\tif (options->dir) {\n>> -\t\tstrbuf_add_absolute_path(&abs_path, hook_path);\n>> -\t\tcb_data.hook_path = abs_path.buf;\n>> +\t/*\n>> +\t * Initialize the iterator/cursor which holds the next hook to run.\n>> +\t * run_process_parallel() calls pick_next_hook() which increments it for\n>> +\t * each hook command in the list until all hooks have been run.\n>> +\t */\n>> +\tcb_data.next_hook = cb_data.hook_command_list->items;\n>> +\n>> +\t/*\n>> +\t * Give each hook its own copy of the initial void *pp_task_cb state, if\n>> +\t * a copy callback was provided.\n>> +\t */\n>> +\tif (options->copy_feed_pipe_cb_data) {\n>> +\t\tstruct string_list_item *item;\n>> +\t\tfor_each_string_list_item(item, cb_data.hook_command_list)\n>> +\t\t\titem->util = options->copy_feed_pipe_cb_data(options->feed_pipe_cb_data);\n>>  \t}\n>\n> Makes sense. We cannot just peform a shallow copy of the callback data\n> as it may contain data itself that needs to be copied. So we have to\n> handle the copying ourselves.\n\nYes, this is the heart of the problem, especially when we start running\nhooks in parallel, they each really need their own internal states.\n\n> One thing I wondered is whether it would lead to a cleaner design if\n> this was instead provided as a callback to allocate and initialize a\n> completely _new_ callback data. In that case the caller wouldn't have to\n> first initialize the data only to copy it in a different place, all the\n> logic would be self-contained in that one callback.\n\nIt's a very interesting proposal which I will certainly explore for\nv2. Many thanks for this as well!\n\n> The downside would of course be that we cannot use for example the\n> stack, and it might make it harder to reuse state across multiple hook\n> invocations, if that's a feature we care about.\n\nI'm not worried about reusing the internal state (pp_task_cb) accross\nmultiple hook invocations, because that's why the \"context\" is passed\n(pp_cb) to each hook cb. The internal state is never reused. \n\nThanks Patrick, very thoughtful review as always.\n"},{"id":"535589","messageId":"875x8522p5.fsf@collabora.com","threadId":"64918","inReplyTo":"aYnu8ltBUUflVgm2@pks.im","subject":"Re: [PATCH 2/4] hook: introduce \"git hook list\"","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-09T18:26:30Z","receivedAt":"2026-02-09T18:26:49Z","isPatch":true,"body":"On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Wed, Feb 04, 2026 at 06:51:24PM +0200, Adrian Ratiu wrote:\n>> From: Emily Shaffer <emilyshaffer@google.com>\n>> \n>> If more than one hook will be run, it may be useful to see a list of\n>> which hooks should be run. At very least, it will be useful for us to\n>> test the semantics of multihooks ourselves.\n>> \n>> For now, only list the hooks which will run in the order they will run\n>> in; later, it might be useful to include more information like where the\n>> hooks were configured and whether or not they will run.\n>\n> I think the commit message could be adapted a bit again to first explain\n> the problem we're about to solve.\n\nAck, will fix in v2.\n\n>> diff --git a/builtin/hook.c b/builtin/hook.c\n>> index 7afec380d2..4cc6dac45a 100644\n>> --- a/builtin/hook.c\n>> +++ b/builtin/hook.c\n>> @@ -20,6 +24,54 @@ static const char * const builtin_hook_run_usage[] = {\n>>  \tNULL\n>>  };\n>>  \n>> +static const char *const builtin_hook_list_usage[] = {\n>> +\tBUILTIN_HOOK_LIST_USAGE,\n>> +\tNULL\n>> +};\n>> +\n>\n> This constant can be declared inside `list()`.\n\nAck, will fix in v2.\n\n>> +static int list(int argc, const char **argv, const char *prefix,\n>> +\t\t struct repository *repo UNUSED)\n>> +{\n>> +\tstruct string_list *head;\n>> +\tstruct string_list_item *item;\n>> +\tconst char *hookname = NULL;\n>> +\tint ret = 0;\n>> +\n>> +\tstruct option list_options[] = {\n>> +\t\tOPT_END(),\n>> +\t};\n>> +\n>> +\targc = parse_options(argc, argv, prefix, list_options,\n>> +\t\t\t     builtin_hook_list_usage, 0);\n>> +\n>> +\t/*\n>> +\t * The only unnamed argument provided should be the hook-name; if we add\n>> +\t * arguments later they probably should be caught by parse_options.\n>> +\t */\n>> +\tif (argc != 1)\n>> +\t\tusage_msg_opt(_(\"You must specify a hook event name to list.\"),\n>> +\t\t\t      builtin_hook_list_usage, list_options);\n>> +\n>> +\thookname = argv[0];\n>> +\n>> +\thead = list_hooks(the_repository, hookname);\n>\n> We can use the `repo` parameter instead. The git-hook(1) command is\n> declared with `RUN_SETUP`, so it will always be set.\n\nIndeed, it should be possible to avoid using the_repository here. Will\ndo for v2 or document why it can't be done.\n\n>> +\tif (!head->nr) {\n>> +\t\tret = 1; /* no hooks found */\n>> +\t\tgoto cleanup;\n>> +\t}\n>\n> Do we want to print an error message in this case?\n\nGood idea. Will do.\n\n>> +\tfor_each_string_list_item(item, head) {\n>> +\t\tprintf(\"%s\\n\", *item->string ? item->string\n>> +\t\t\t     : _(\"hook from hookdir\"));\n>> +\t}\n>\n> This is another case where we could avoid special-casing if the string\n> list contained the hook paths.\n\nYes, I'll very likely do this in v2, certainly I will replace the\nreliance on the empty hook name.\n\n>\n> I also wonder whether we should add a \"-z\" mode to NUL-terminate the\n> output. In theory, hooks may be configured with a newline in their path.\n> Probably not all that common, but somehow special cases like this always\n> end up being encountered eventually.\n\nI can do this, certainly.\n\n>> diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\n>> index ed28a2fadb..d2d4a8760c 100755\n>> --- a/t/t1800-hook.sh\n>> +++ b/t/t1800-hook.sh\n>> @@ -10,6 +10,8 @@ test_expect_success 'git hook usage' '\n>>  \ttest_expect_code 129 git hook run &&\n>>  \ttest_expect_code 129 git hook run -h &&\n>>  \ttest_expect_code 129 git hook run --unknown 2>err &&\n>> +\ttest_expect_code 129 git hook list &&\n>> +\ttest_expect_code 129 git hook list -h &&\n>>  \tgrep \"unknown option\" err\n>>  '\n>\n> Shouldn't we also have some tests that show that this is working as\n> expected with a configured hook in \".git/hooks\"?\n\nGood idea. Will do.\n"},{"id":"535593","messageId":"87343920ny.fsf@collabora.com","threadId":"64918","inReplyTo":"aYnu9gJATVJeKEjZ@pks.im","subject":"Re: [PATCH 3/4] hook: include hooks from the config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-09T19:10:25Z","receivedAt":"2026-02-09T19:10:50Z","isPatch":true,"body":"On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Wed, Feb 04, 2026 at 06:51:25PM +0200, Adrian Ratiu wrote:\n>> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n>> new file mode 100644\n>> index 0000000000..49c7ffd82e\n>> --- /dev/null\n>> +++ b/Documentation/config/hook.adoc\n>> @@ -0,0 +1,17 @@\n>> +hook.<name>.command::\n>> +\tA command to execute whenever `hook.<name>` is invoked. `<name>` should\n>> +\tbe a unique \"friendly\" name which you can use to identify this hook\n>> +\tcommand. (You can specify when to invoke this command with\n>> +\t`hook.<name>.event`.) The value can be an executable on your device or a\n>> +\toneliner for your shell. If more than one value is specified for the\n>> +\tsame `<name>`, the last value parsed will be the only command executed.\n>> +\tSee linkgit:git-hook[1].\n>> +\n>> +hook.<name>.event::\n>> +\tThe hook events which should invoke `hook.<name>`. `<name>` should be a\n>> +\tunique \"friendly\" name which you can use to identify this hook. The\n>> +\tvalue should be the name of a hook event, like \"pre-commit\" or \"update\".\n>> +\t(See linkgit:githooks[5] for a complete list of hooks Git knows about.)\n>> +\tOn the specified event, the associated `hook.<name>.command` will be\n>> +\texecuted. More than one event can be specified if you wish for\n>> +\t`hook.<name>` to execute on multiple events. See linkgit:git-hook[1].\n>\n> I'm not exactly sure how you'd execute it on multiple events after\n> reading this description. Is this by comma-delimiting them? Or is the\n> event key a multivalue variable? If the latter (which I think would make\n> most sense), can you also reset the value by e.g. saying \"event = \" like\n> we support with other multivalued keys?\n\nYes, event is a multivalue variable, however the current implementation\ndoes not allow resetting using \"event = \".\n\nI'll allow it in v2 and also make the doc clearer.\n\n> I would have also expected a \"hook.<name>.enabled\" option so that you\n> can disable globally configured hooks.\n>\n\nGood idea. I can add that in v2.\n\n<snip>\n\n>> +git config hook.<some-name>.command <path-to-script>\n>> +git config --add hook.<some-name>.event <hook-event>\n>\n> Let's use the modern equivalents:\n>\n>     git config set hook.<some-name>.command <path-to-script>\n>     git config set --append hook.<some-name>.event <hook-event>\n>\n\nAck, will do in v2.\n\n<snip>\n\n>> @@ -46,6 +128,46 @@ OPTIONS\n>>  \ttools that want to do a blind one-shot run of a hook that may\n>>  \tor may not be present.\n>>  \n>> +WRAPPERS\n>> +--------\n>> +\n>> +`git hook run` has been designed to make it easy for tools which wrap Git to\n>> +configure and execute hooks using the Git hook infrastructure. It is possible to\n>> +provide arguments and stdin via the command line, as well as specifying parallel\n>> +or series execution if the user has provided multiple hooks.\n>> +\n>> +Assuming your wrapper wants to support a hook named \"mywrapper-start-tests\", you\n>> +can have your users specify their hooks like so:\n>> +\n>> +  [hook \"setup-test-dashboard\"]\n>> +    event = mywrapper-start-tests\n>> +    command = ~/mywrapper/setup-dashboard.py --tap\n>\n> Ah, interesting. Would that then also look for\n> \".git/hooks/mywrapper-start-tests\"?\n\nYes, because list_hooks() gathers all hooks for the event, including the\n\"legacy/default\" hooks from the hookdir. \n\nI also just noticed this doc mentions parallel execution, which is not\nyet possible (that is a separate series). I fix this and add the parallel\ncomment back in the other series.\n\n>> diff --git a/hook.c b/hook.c\n>> index 949c907b59..9f59ebd0bd 100644\n>> --- a/hook.c\n>> +++ b/hook.c\n>> @@ -47,24 +47,74 @@ const char *find_hook(struct repository *r, const char *name)\n>>  \treturn path.buf;\n>>  }\n>>  \n>> +struct hook_config_cb\n>> +{\n>\n> Formatting: the curly brace should not go on a standalone line.\n\nAck, will fix.\n\n<snip>\n\n>> +\t/* Look for \"hook.friendly-name.event = hook_event\" */\n>> +\tif (parse_config_key(key, \"hook\", &name, &name_len, &event_key) ||\n>> +\t    strcmp(event_key, \"event\"))\n>> +\t\treturn 0;\n>\n> So we only care about parsing \"hook.<name>.event\" here, and I assume\n> that we would look up the command at a later point in time? That design\n> is somewhat weird -- I would have expected that we scan through all\n> config keys and already pull out both the command and event so that we\n> don't have to extract the config a second time.\n>\n> But I guess this is another consequence of the string list only caring\n> about the name, not about the actual path.\n\nThat is correct.\n\nI do plan to change this design in v2 and we might as well also look up\nthe path at the same time.\n\nI see nothing preventing us from doing it.\n\n>> +\t/* Extract the hook name */\n>> +\thook_name = xmemdupz(name, name_len);\n>> +\n>> +\t/* Remove the hook if already in the list, so we append in config order. */\n>> +\tif ((item = unsorted_string_list_lookup(data->list, hook_name)))\n>> +\t\tunsorted_string_list_delete_item(data->list, item - data->list->items, 0);\n>> +\n>> +\t/* The list takes ownership of hook_name, so append with nodup */\n>> +\tstring_list_append_nodup(data->list, hook_name);\n>> +\n>> +\treturn 0;\n>> +}\n>> +\n>>  struct string_list *list_hooks(struct repository *r, const char *hookname)\n>>  {\n>> -\tstruct string_list *hook_head;\n>> +\tstruct hook_config_cb cb_data;\n>>  \n>>  \tif (!hookname)\n>>  \t\tBUG(\"null hookname was provided to hook_list()!\");\n>>  \n>> -\thook_head = xmalloc(sizeof(struct string_list));\n>> -\tstring_list_init_dup(hook_head);\n>> +\tcb_data.hook_event = hookname;\n>> +\tcb_data.list = xmalloc(sizeof(struct string_list));\n>> +\tstring_list_init_dup(cb_data.list);\n>> +\n>> +\t/* Add the hooks from the config, e.g. hook.myhook.event = pre-commit */\n>> +\trepo_config(r, hook_config_lookup, &cb_data);\n>\n> There are cases where we invoke the same hook multiple times per\n> process. One such candidate is for example the \"reference-transaction\"\n> hook, which is executed thrice per ref transaction. Would it make sense\n> if we cached the value in `r` so that we can avoid repeatedly parsing\n> the configuration for this hook?\n>\n> We could even go one step further: instead of caching the value for\n> _one_ hook, we could parse _all_ hooks lazily and store them in a map.\n\nI think this can be done, yes, though I'm unsure of the performance gain\nvs the added complexity. Since after the first lookup the config files\nare hot in the OS page cache, the next lookups are cheap/fast and we'd\nbe shaving off only a few microseconds of parsing.\n\nSo I'm unsure if this added complexity is worth it or not.\n\n>> @@ -125,6 +175,24 @@ static int pick_next_hook(struct child_process *cp,\n>>  \t\t\thook_path = absolute_path(hook_path);\n>>  \n>>  \t\tstrvec_push(&cp->args, hook_path);\n>> +\t} else {\n>> +\t\t/* ...from config */\n>> +\t\tstruct strbuf cmd_key = STRBUF_INIT;\n>> +\t\tchar *command = NULL;\n>> +\n>> +\t\t/* to enable oneliners, let config-specified hooks run in shell. */\n>> +\t\tcp->use_shell = true;\n>> +\n>> +\t\tstrbuf_addf(&cmd_key, \"hook.%s.command\", to_run->string);\n>> +\t\tif (repo_config_get_string(hook_cb->repository,\n>> +\t\t\t\t\t   cmd_key.buf, &command)) {\n>> +\t\t\tdie(_(\"'hook.%s.command' must be configured or\"\n>> +\t\t\t      \"'hook.%s.event' must be removed; aborting.\\n\"),\n>> +\t\t\t    to_run->string, to_run->string);\n>> +\t\t}\n>> +\t\tstrbuf_release(&cmd_key);\n>> +\n>> +\t\tstrvec_push_nodup(&cp->args, command);\n>>  \t}\n>>  \n>>  \tif (!cp->args.nr)\n>\n> Okay, so here we do an extra step to extract the commands. I think this\n> would fit more naturally in `hook_config_lookup`.\n\nAgreed, especially after I implement all other feedback to not rely on\nthe \"empty\" name to distinguish hooks, I think these can be consolidated\nin one place.\n"},{"id":"535673","messageId":"aYs2GGTU6UJWJFOf@pks.im","threadId":"64918","inReplyTo":"87343920ny.fsf@collabora.com","subject":"Re: [PATCH 3/4] hook: include hooks from the config","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-10T13:43:52Z","receivedAt":"2026-02-10T13:43:59Z","isPatch":true,"body":"On Mon, Feb 09, 2026 at 09:10:25PM +0200, Adrian Ratiu wrote:\n> On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> > On Wed, Feb 04, 2026 at 06:51:25PM +0200, Adrian Ratiu wrote:\n> >> +\t/* Extract the hook name */\n> >> +\thook_name = xmemdupz(name, name_len);\n> >> +\n> >> +\t/* Remove the hook if already in the list, so we append in config order. */\n> >> +\tif ((item = unsorted_string_list_lookup(data->list, hook_name)))\n> >> +\t\tunsorted_string_list_delete_item(data->list, item - data->list->items, 0);\n> >> +\n> >> +\t/* The list takes ownership of hook_name, so append with nodup */\n> >> +\tstring_list_append_nodup(data->list, hook_name);\n> >> +\n> >> +\treturn 0;\n> >> +}\n> >> +\n> >>  struct string_list *list_hooks(struct repository *r, const char *hookname)\n> >>  {\n> >> -\tstruct string_list *hook_head;\n> >> +\tstruct hook_config_cb cb_data;\n> >>  \n> >>  \tif (!hookname)\n> >>  \t\tBUG(\"null hookname was provided to hook_list()!\");\n> >>  \n> >> -\thook_head = xmalloc(sizeof(struct string_list));\n> >> -\tstring_list_init_dup(hook_head);\n> >> +\tcb_data.hook_event = hookname;\n> >> +\tcb_data.list = xmalloc(sizeof(struct string_list));\n> >> +\tstring_list_init_dup(cb_data.list);\n> >> +\n> >> +\t/* Add the hooks from the config, e.g. hook.myhook.event = pre-commit */\n> >> +\trepo_config(r, hook_config_lookup, &cb_data);\n> >\n> > There are cases where we invoke the same hook multiple times per\n> > process. One such candidate is for example the \"reference-transaction\"\n> > hook, which is executed thrice per ref transaction. Would it make sense\n> > if we cached the value in `r` so that we can avoid repeatedly parsing\n> > the configuration for this hook?\n> >\n> > We could even go one step further: instead of caching the value for\n> > _one_ hook, we could parse _all_ hooks lazily and store them in a map.\n> \n> I think this can be done, yes, though I'm unsure of the performance gain\n> vs the added complexity. Since after the first lookup the config files\n> are hot in the OS page cache, the next lookups are cheap/fast and we'd\n> be shaving off only a few microseconds of parsing.\n> \n> So I'm unsure if this added complexity is worth it or not.\n\nMaybe I'm naive, but I don't really expect there to be much complexity.\nQuite on the contrary: if we unify all configuration parsing into\n`hook_config_lookup` I would even claim that this cache would almost\ncome as a side effect. We would simply iterate through all hook-related\nconfiguration and build the map of all hooks as we go.\n\nWhether it'd matter performance-wise... I think in certain cases it\nactually would. The mentioned reference-transaction hook for example had\nsome worst cases in the past where it would execute thrice per reference\nupdate in certain code paths. In the worst cases I've seen it execute\ntens of thousands of times in a single process.\n\nSure, these are outliers. But such cases do exist, and I expect that the\nperformance impact could be non-negligible here if combined with\nnon-trivial configuration.\n\nPatrick\n"},{"id":"535674","messageId":"aYs2HiFkEciZkydr@pks.im","threadId":"64918","inReplyTo":"878qd1235o.fsf@gentoo.mail-host-address-is-not-set","subject":"Re: [PATCH 1/4] hook: run a list of hooks","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-10T13:43:58Z","receivedAt":"2026-02-10T13:44:03Z","isPatch":true,"body":"On Mon, Feb 09, 2026 at 08:16:35PM +0200, Adrian Ratiu wrote:\n> On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> > On Wed, Feb 04, 2026 at 06:51:23PM +0200, Adrian Ratiu wrote:\n> >> diff --git a/hook.c b/hook.c\n> >> index cde7198412..fb90f91f3b 100644\n> >> --- a/hook.c\n> >> +++ b/hook.c\n> >> @@ -47,9 +47,49 @@ const char *find_hook(struct repository *r, const char *name)\n> >>  \treturn path.buf;\n> >>  }\n> >>  \n> >> +/*\n> >> + * Provides a list of hook commands to run for the 'hookname' event.\n> >> + *\n> >> + * This function consolidates hooks from two sources:\n> >> + * 1. The config-based hooks (not yet implemented).\n> >> + * 2. The \"traditional\" hook found in the repository hooks directory\n> >> + *    (e.g., .git/hooks/pre-commit).\n> >> + *\n> >> + * The list is ordered by execution priority.\n> >> + *\n> >> + * The caller is responsible for freeing the memory of the returned list\n> >> + * using string_list_clear() and free().\n> >> + */\n> >> +static struct string_list *list_hooks(struct repository *r, const char *hookname)\n> >> +{\n> >> +\tstruct string_list *hook_head;\n> >> +\n> >> +\tif (!hookname)\n> >> +\t\tBUG(\"null hookname was provided to hook_list()!\");\n> >> +\n> >> +\thook_head = xmalloc(sizeof(struct string_list));\n> >> +\tstring_list_init_dup(hook_head);\n> >> +\n> >> +\t/*\n> >> +\t * Add the default hook from hookdir. It does not have a friendly name\n> >> +\t * like the hooks specified via configs, so add it with an empty name.\n> >> +\t */\n> >> +\tif (r->gitdir && find_hook(r, hookname))\n> >> +\t\tstring_list_append(hook_head, \"\");\n> >\n> > Why is there a check for `r->gitdir` here? Do we ever execute hooks\n> > outside of a fully-initialized repository?\n> \n> Nice find. The answer is yes, see the last commit in this series.\n> \n> I've been cleaning up and untangling this series for quite a while now:\n> - It used to be combined with the other parallel hooks series.\n> - It used to implement its own linked list abstraction.\n> - It used the hook.h string-list APIs AEvar didn't like.\n> - and so on.\n> \n> This cheeck is just a leftover bit I missed during my cleanups and I\n> really should be moved this to the last commit. I've been moving a lot\n> of code around to make it as clear as possible.\n> \n> I'll do it / test in v2. Many thanks!\n\nThat should indeed lead to less puzzlement, great!\n\n> > Other than that, we now insert hooks into the list. It's somewhat\n> > surprising that we insert hook \"names\" here, instead of for example\n> > adding the full hook path to the list. Is there any specific reason for\n> > this decision?\n> \n> I'm also not a fan of this design, as I mentioned in the reply to Junio.\n> \n> We just need to differentiate between \"new\" hooks (from config) and\n> \"default/legacy\" hooks (from the hookdir) and we kind-of abused the fact\n> that the default hooks have no friendly name, so the name is empty, to\n> differentiate them. :)\n> \n> Junio suggested we use a struct/union and a specific type to\n> differentiate between the \"default/legacy\" hooks (from the hookdir) and\n> the new config-based hooks.\n\nYeah, that sounds like a reasonable thing to do.\n\n> That way we don't have to rely on the name anymore and we can also use\n> the full path here, as you suggested.\n> \n> I'll do all this in v2. Thank you!\n\nPerfect, thanks!\n\nPatrick\n"},{"id":"535675","messageId":"87zf5gzoqb.fsf@collabora.com","threadId":"64918","inReplyTo":"aYs2GGTU6UJWJFOf@pks.im","subject":"Re: [PATCH 3/4] hook: include hooks from the config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-10T13:56:28Z","receivedAt":"2026-02-10T13:56:48Z","isPatch":true,"body":"On Tue, 10 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Mon, Feb 09, 2026 at 09:10:25PM +0200, Adrian Ratiu wrote:\n>> On Mon, 09 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n>> > On Wed, Feb 04, 2026 at 06:51:25PM +0200, Adrian Ratiu wrote:\n>> >> +\t/* Extract the hook name */\n>> >> +\thook_name = xmemdupz(name, name_len);\n>> >> +\n>> >> +\t/* Remove the hook if already in the list, so we append in config order. */\n>> >> +\tif ((item = unsorted_string_list_lookup(data->list, hook_name)))\n>> >> +\t\tunsorted_string_list_delete_item(data->list, item - data->list->items, 0);\n>> >> +\n>> >> +\t/* The list takes ownership of hook_name, so append with nodup */\n>> >> +\tstring_list_append_nodup(data->list, hook_name);\n>> >> +\n>> >> +\treturn 0;\n>> >> +}\n>> >> +\n>> >>  struct string_list *list_hooks(struct repository *r, const char *hookname)\n>> >>  {\n>> >> -\tstruct string_list *hook_head;\n>> >> +\tstruct hook_config_cb cb_data;\n>> >>  \n>> >>  \tif (!hookname)\n>> >>  \t\tBUG(\"null hookname was provided to hook_list()!\");\n>> >>  \n>> >> -\thook_head = xmalloc(sizeof(struct string_list));\n>> >> -\tstring_list_init_dup(hook_head);\n>> >> +\tcb_data.hook_event = hookname;\n>> >> +\tcb_data.list = xmalloc(sizeof(struct string_list));\n>> >> +\tstring_list_init_dup(cb_data.list);\n>> >> +\n>> >> +\t/* Add the hooks from the config, e.g. hook.myhook.event = pre-commit */\n>> >> +\trepo_config(r, hook_config_lookup, &cb_data);\n>> >\n>> > There are cases where we invoke the same hook multiple times per\n>> > process. One such candidate is for example the \"reference-transaction\"\n>> > hook, which is executed thrice per ref transaction. Would it make sense\n>> > if we cached the value in `r` so that we can avoid repeatedly parsing\n>> > the configuration for this hook?\n>> >\n>> > We could even go one step further: instead of caching the value for\n>> > _one_ hook, we could parse _all_ hooks lazily and store them in a map.\n>> \n>> I think this can be done, yes, though I'm unsure of the performance gain\n>> vs the added complexity. Since after the first lookup the config files\n>> are hot in the OS page cache, the next lookups are cheap/fast and we'd\n>> be shaving off only a few microseconds of parsing.\n>> \n>> So I'm unsure if this added complexity is worth it or not.\n>\n> Maybe I'm naive, but I don't really expect there to be much complexity.\n> Quite on the contrary: if we unify all configuration parsing into\n> `hook_config_lookup` I would even claim that this cache would almost\n> come as a side effect. We would simply iterate through all hook-related\n> configuration and build the map of all hooks as we go.\n>\n> Whether it'd matter performance-wise... I think in certain cases it\n> actually would. The mentioned reference-transaction hook for example had\n> some worst cases in the past where it would execute thrice per reference\n> update in certain code paths. In the worst cases I've seen it execute\n> tens of thousands of times in a single process.\n>\n> Sure, these are outliers. But such cases do exist, and I expect that the\n> performance impact could be non-negligible here if combined with\n> non-trivial configuration.\n\nOk, I'm convinced. :) Will do it in v2.\n"},{"id":"536349","messageId":"20260218222352.55393-3-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:46Z","receivedAt":"2026-02-18T22:24:26Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nHooks are limited to run one command (the default from the hookdir) for\neach event. This limitation makes it impossible to run multiple commands\nvia config files, which the next commits will add.\n\nImplement the ability to run a list of hooks in hook.[ch]. For now, the\nlist contains only one entry representing the \"default\" hook from the\nhookdir, so there is no user-visible change in this commit.\n\nAll hook commands still run sequentially like before. A separate patch\nseries will enable running them in parallel.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n hook.c | 139 ++++++++++++++++++++++++++++++++++++++++++++-------------\n hook.h |  59 ++++++++++++++++++------\n 2 files changed, 153 insertions(+), 45 deletions(-)\n\ndiff --git a/hook.c b/hook.c\nindex 83ff658866..c008a7232d 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -47,9 +47,97 @@ const char *find_hook(struct repository *r, const char *name)\n \treturn path.buf;\n }\n \n+static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n+{\n+\tif (!h)\n+\t\treturn;\n+\n+\tif (h->kind == HOOK_TRADITIONAL)\n+\t\tfree((void *)h->u.traditional.path);\n+\n+\tif (cb_data_free)\n+\t\tcb_data_free(h->feed_pipe_cb_data);\n+\n+\tfree(h);\n+}\n+\n+static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n+{\n+\tstruct string_list_item *item;\n+\n+\tfor_each_string_list_item(item, hooks)\n+\t\thook_clear(item->util, cb_data_free);\n+\n+\tstring_list_clear(hooks, 0);\n+}\n+\n+/* Helper to detect and add default \"traditional\" hooks from the hookdir. */\n+static void list_hooks_add_default(struct repository *r, const char *hookname,\n+\t\t\t\t   struct string_list *hook_list,\n+\t\t\t\t   struct run_hooks_opt *options)\n+{\n+\tconst char *hook_path = find_hook(r, hookname);\n+\tstruct hook *h;\n+\n+\tif (!hook_path)\n+\t\treturn;\n+\n+\th = xcalloc(1, sizeof(struct hook));\n+\n+\t/*\n+\t * If the hook is to run in a specific dir, a relative path can\n+\t * become invalid in that dir, so convert to an absolute path.\n+\t */\n+\tif (options && options->dir)\n+\t\thook_path = absolute_path(hook_path);\n+\n+\t/* Setup per-hook internal state cb data */\n+\tif (options && options->feed_pipe_cb_data_alloc)\n+\t\th->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n+\n+\th->kind = HOOK_TRADITIONAL;\n+\th->u.traditional.path = xstrdup(hook_path);\n+\n+\tstring_list_append(hook_list, hook_path)->util = h;\n+}\n+\n+/*\n+ * Provides a list of hook commands to run for the 'hookname' event.\n+ *\n+ * This function consolidates hooks from two sources:\n+ * 1. The config-based hooks (not yet implemented).\n+ * 2. The \"traditional\" hook found in the repository hooks directory\n+ *    (e.g., .git/hooks/pre-commit).\n+ *\n+ * The list is ordered by execution priority.\n+ *\n+ * The caller is responsible for freeing the memory of the returned list\n+ * using string_list_clear() and free().\n+ */\n+static struct string_list *list_hooks(struct repository *r, const char *hookname,\n+\t\t\t       struct run_hooks_opt *options)\n+{\n+\tstruct string_list *hook_head;\n+\n+\tif (!hookname)\n+\t\tBUG(\"null hookname was provided to hook_list()!\");\n+\n+\thook_head = xmalloc(sizeof(struct string_list));\n+\tstring_list_init_dup(hook_head);\n+\n+\t/* Add the default \"traditional\" hooks from hookdir. */\n+\tlist_hooks_add_default(r, hookname, hook_head, options);\n+\n+\treturn hook_head;\n+}\n+\n int hook_exists(struct repository *r, const char *name)\n {\n-\treturn !!find_hook(r, name);\n+\tstruct string_list *hooks = list_hooks(r, name, NULL);\n+\tint exists = hooks->nr > 0;\n+\thook_list_clear(hooks, NULL);\n+\tfree(hooks);\n+\treturn exists;\n }\n \n static int pick_next_hook(struct child_process *cp,\n@@ -58,11 +146,14 @@ static int pick_next_hook(struct child_process *cp,\n \t\t\t  void **pp_task_cb)\n {\n \tstruct hook_cb_data *hook_cb = pp_cb;\n-\tconst char *hook_path = hook_cb->hook_path;\n+\tstruct string_list *hook_list = hook_cb->hook_command_list;\n+\tstruct hook *h;\n \n-\tif (!hook_path)\n+\tif (hook_cb->hook_to_run_index >= hook_list->nr)\n \t\treturn 0;\n \n+\th = hook_list->items[hook_cb->hook_to_run_index++].util;\n+\n \tcp->no_stdin = 1;\n \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\n \n@@ -85,21 +176,20 @@ static int pick_next_hook(struct child_process *cp,\n \tcp->trace2_hook_name = hook_cb->hook_name;\n \tcp->dir = hook_cb->options->dir;\n \n-\tstrvec_push(&cp->args, hook_path);\n+\t/* Add hook exec paths or commands */\n+\tif (h->kind == HOOK_TRADITIONAL)\n+\t\tstrvec_push(&cp->args, h->u.traditional.path);\n+\n+\tif (!cp->args.nr)\n+\t\tBUG(\"hook must have at least one command or exec path\");\n+\n \tstrvec_pushv(&cp->args, hook_cb->options->args.v);\n \n \t/*\n \t * Provide per-hook internal state via task_cb for easy access, so\n \t * hook callbacks don't have to go through hook_cb->options.\n \t */\n-\t*pp_task_cb = hook_cb->options->feed_pipe_cb_data;\n-\n-\t/*\n-\t * This pick_next_hook() will be called again, we're only\n-\t * running one hook, so indicate that no more work will be\n-\t * done.\n-\t */\n-\thook_cb->hook_path = NULL;\n+\t*pp_task_cb = h->feed_pipe_cb_data;\n \n \treturn 1;\n }\n@@ -133,8 +223,6 @@ static int notify_hook_finished(int result,\n \n static void run_hooks_opt_clear(struct run_hooks_opt *options)\n {\n-\tif (options->feed_pipe_cb_data_free)\n-\t\toptions->feed_pipe_cb_data_free(options->feed_pipe_cb_data);\n \tstrvec_clear(&options->env);\n \tstrvec_clear(&options->args);\n }\n@@ -142,13 +230,11 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n-\tstruct strbuf abs_path = STRBUF_INIT;\n \tstruct hook_cb_data cb_data = {\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n \t\t.options = options,\n \t};\n-\tconst char *const hook_path = find_hook(r, hook_name);\n \tint ret = 0;\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n@@ -182,30 +268,21 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \t    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))\n \t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n \n-\tif (options->feed_pipe_cb_data_alloc)\n-\t\toptions->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n-\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tif (!hook_path && !options->error_if_missing)\n-\t\tgoto cleanup;\n-\n-\tif (!hook_path) {\n-\t\tret = error(\"cannot find a hook named %s\", hook_name);\n+\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n+\tif (!cb_data.hook_command_list->nr) {\n+\t\tif (options->error_if_missing)\n+\t\t\tret = error(\"cannot find a hook named %s\", hook_name);\n \t\tgoto cleanup;\n \t}\n \n-\tcb_data.hook_path = hook_path;\n-\tif (options->dir) {\n-\t\tstrbuf_add_absolute_path(&abs_path, hook_path);\n-\t\tcb_data.hook_path = abs_path.buf;\n-\t}\n-\n \trun_processes_parallel(&opts);\n \tret = cb_data.rc;\n cleanup:\n-\tstrbuf_release(&abs_path);\n+\thook_list_clear(cb_data.hook_command_list, options->feed_pipe_cb_data_free);\n+\tfree(cb_data.hook_command_list);\n \trun_hooks_opt_clear(options);\n \treturn ret;\n }\ndiff --git a/hook.h b/hook.h\nindex a6bdc6f90f..3256d2dddb 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -2,9 +2,41 @@\n #define HOOK_H\n #include \"strvec.h\"\n #include \"run-command.h\"\n+#include \"string-list.h\"\n \n struct repository;\n \n+/**\n+ * Represents a hook command to be run.\n+ * Hooks can be:\n+ * 1. \"traditional\" (found in the hooks directory)\n+ * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n+ * The 'kind' field determines which part of the union 'u' is valid.\n+ */\n+struct hook {\n+\tenum {\n+\t\tHOOK_TRADITIONAL,\n+\t} kind;\n+\tunion {\n+\t\tstruct {\n+\t\t\tconst char *path;\n+\t\t} traditional;\n+\t} u;\n+\n+\t/**\n+\t * Opaque data pointer used to keep internal state across callback calls.\n+\t *\n+\t * It can be accessed directly via the third hook callback arg:\n+\t * struct ... *state = pp_task_cb;\n+\t *\n+\t * The caller is responsible for managing the memory for this data by\n+\t * providing alloc/free callbacks to `run_hooks_opt`.\n+\t *\n+\t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n+\t */\n+\tvoid *feed_pipe_cb_data;\n+};\n+\n typedef void (*cb_data_free_fn)(void *data);\n typedef void *(*cb_data_alloc_fn)(void *init_ctx);\n \n@@ -85,19 +117,6 @@ struct run_hooks_opt\n \t */\n \tvoid *feed_pipe_ctx;\n \n-\t/**\n-\t * Opaque data pointer used to keep internal state across callback calls.\n-\t *\n-\t * It can be accessed directly via the third callback arg 'pp_task_cb':\n-\t * struct ... *state = pp_task_cb;\n-\t *\n-\t * The caller is responsible for managing the memory for this data by\n-\t * providing alloc/free callbacks to `run_hooks_opt`.\n-\t *\n-\t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n-\t */\n-\tvoid *feed_pipe_cb_data;\n-\n \t/**\n \t * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,\n \t * so they can keep track of progress without affecting one another.\n@@ -128,7 +147,19 @@ struct hook_cb_data {\n \t/* rc reflects the cumulative failure state */\n \tint rc;\n \tconst char *hook_name;\n-\tconst char *hook_path;\n+\n+\t/**\n+\t * A list of hook commands/paths to run for the 'hook_name' event.\n+\t *\n+\t * The 'string' member of each item holds the path (for traditional hooks)\n+\t * or the unique friendly-name for hooks specified in configs.\n+\t * The 'util' member of each item points to the corresponding struct hook.\n+\t */\n+\tstruct string_list *hook_command_list;\n+\n+\t/* Iterator/cursor for the above list, pointing to the next hook to run. */\n+\tsize_t hook_to_run_index;\n+\n \tstruct run_hooks_opt *options;\n };\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536350","messageId":"20260218222352.55393-2-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 1/8] hook: add internal state alloc/free callbacks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:45Z","receivedAt":"2026-02-18T22:24:28Z","isPatch":true,"body":"Some hooks use opaque structs to keep internal state between callbacks.\n\nBecause hooks ran sequentially (jobs == 1) with one command per hook,\nthese internal states could be allocated on the stack for each hook run.\n\nNext commits add the ability to run multiple commands for each hook, so\nthe states cannot be shared or stored on the stack anymore, especially\nsince down the line we will also enable parallel execution (jobs > 1).\n\nAdd alloc/free helpers for each hook, doing a \"deep\" alloc/init & free\nof their internal opaque struct.\n\nThe alloc callback takes a context pointer, to initialize the struct at\nat the time of resource acquisition.\n\nThese callbacks must always be provided together: no alloc without free\nand no free without alloc, otherwise a BUG() is triggered.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n builtin/receive-pack.c | 33 ++++++++++++++++++++++++++-------\n hook.c                 | 13 +++++++++++++\n hook.h                 | 25 ++++++++++++++++++++++++-\n refs.c                 | 24 +++++++++++++++++++-----\n transport.c            | 27 ++++++++++++++++++++-------\n 5 files changed, 102 insertions(+), 20 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 2d2b33d73d..f23772bc56 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -901,6 +901,26 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_\n \treturn state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */\n }\n \n+static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)\n+{\n+\tstruct receive_hook_feed_state *init_state = feed_pipe_ctx;\n+\tstruct receive_hook_feed_state *data = xcalloc(1, sizeof(*data));\n+\tdata->report = init_state->report;\n+\tdata->cmd = init_state->cmd;\n+\tdata->skip_broken = init_state->skip_broken;\n+\tstrbuf_init(&data->buf, 0);\n+\treturn data;\n+}\n+\n+static void receive_hook_feed_state_free(void *data)\n+{\n+\tstruct receive_hook_feed_state *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_receive_hook(struct command *commands,\n \t\t\t    const char *hook_name,\n \t\t\t    int skip_broken,\n@@ -908,7 +928,7 @@ static int run_receive_hook(struct command *commands,\n {\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n \tstruct command *iter = commands;\n-\tstruct receive_hook_feed_state feed_state;\n+\tstruct receive_hook_feed_state feed_init_state = { 0 };\n \tstruct async sideband_async;\n \tint sideband_async_started = 0;\n \tint saved_stderr = -1;\n@@ -938,16 +958,15 @@ static int run_receive_hook(struct command *commands,\n \tprepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started);\n \n \t/* set up stdin callback */\n-\tfeed_state.cmd = commands;\n-\tfeed_state.skip_broken = skip_broken;\n-\tfeed_state.report = NULL;\n-\tstrbuf_init(&feed_state.buf, 0);\n-\topt.feed_pipe_cb_data = &feed_state;\n+\tfeed_init_state.cmd = commands;\n+\tfeed_init_state.skip_broken = skip_broken;\n+\topt.feed_pipe_ctx = &feed_init_state;\n \topt.feed_pipe = feed_receive_hook_cb;\n+\topt.feed_pipe_cb_data_alloc = receive_hook_feed_state_alloc;\n+\topt.feed_pipe_cb_data_free = receive_hook_feed_state_free;\n \n \tret = run_hooks_opt(the_repository, hook_name, &opt);\n \n-\tstrbuf_release(&feed_state.buf);\n \tfinish_sideband_async(&sideband_async, saved_stderr, sideband_async_started);\n \n \treturn ret;\ndiff --git a/hook.c b/hook.c\nindex cde7198412..83ff658866 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -133,6 +133,8 @@ static int notify_hook_finished(int result,\n \n static void run_hooks_opt_clear(struct run_hooks_opt *options)\n {\n+\tif (options->feed_pipe_cb_data_free)\n+\t\toptions->feed_pipe_cb_data_free(options->feed_pipe_cb_data);\n \tstrvec_clear(&options->env);\n \tstrvec_clear(&options->args);\n }\n@@ -172,6 +174,17 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (!options->jobs)\n \t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n \n+\t/*\n+\t * Ensure cb_data copy and free functions are either provided together,\n+\t * or neither one is provided.\n+\t */\n+\tif ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||\n+\t    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))\n+\t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n+\n+\tif (options->feed_pipe_cb_data_alloc)\n+\t\toptions->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n+\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \ndiff --git a/hook.h b/hook.h\nindex 20eb56fd63..a6bdc6f90f 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -5,6 +5,9 @@\n \n struct repository;\n \n+typedef void (*cb_data_free_fn)(void *data);\n+typedef void *(*cb_data_alloc_fn)(void *init_ctx);\n+\n struct run_hooks_opt\n {\n \t/* Environment vars to be set for each hook */\n@@ -88,10 +91,30 @@ struct run_hooks_opt\n \t * It can be accessed directly via the third callback arg 'pp_task_cb':\n \t * struct ... *state = pp_task_cb;\n \t *\n-\t * The caller is responsible for managing the memory for this data.\n+\t * The caller is responsible for managing the memory for this data by\n+\t * providing alloc/free callbacks to `run_hooks_opt`.\n+\t *\n \t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n \t */\n \tvoid *feed_pipe_cb_data;\n+\n+\t/**\n+\t * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,\n+\t * so they can keep track of progress without affecting one another.\n+\t *\n+\t * If provided, this function will be called to alloc & initialize the\n+\t * `feed_pipe_cb_data` for each hook.\n+\t *\n+\t * The `feed_pipe_ctx` pointer can be used to pass initialization data.\n+\t */\n+\tcb_data_alloc_fn feed_pipe_cb_data_alloc;\n+\n+\t/**\n+\t * Called to free the memory initialized by `feed_pipe_cb_data_alloc`.\n+\t *\n+\t * Must always be provided when `feed_pipe_cb_data_alloc` is provided.\n+\t */\n+\tcb_data_free_fn feed_pipe_cb_data_free;\n };\n \n #define RUN_HOOKS_OPT_INIT { \\\ndiff --git a/refs.c b/refs.c\nindex d432cfb78f..3c124fab29 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -2514,24 +2514,38 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n \treturn 0; /* no more input to feed */\n }\n \n+static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED)\n+{\n+\tstruct transaction_feed_cb_data *data = xmalloc(sizeof(*data));\n+\tstrbuf_init(&data->buf, 0);\n+\tdata->index = 0;\n+\treturn data;\n+}\n+\n+static void transaction_feed_cb_data_free(void *data)\n+{\n+\tstruct transaction_feed_cb_data *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_transaction_hook(struct ref_transaction *transaction,\n \t\t\t\tconst char *state)\n {\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n-\tstruct transaction_feed_cb_data feed_ctx = { 0 };\n \tint ret = 0;\n \n \tstrvec_push(&opt.args, state);\n \n \topt.feed_pipe = transaction_hook_feed_stdin;\n \topt.feed_pipe_ctx = transaction;\n-\topt.feed_pipe_cb_data = &feed_ctx;\n-\n-\tstrbuf_init(&feed_ctx.buf, 0);\n+\topt.feed_pipe_cb_data_alloc = transaction_feed_cb_data_alloc;\n+\topt.feed_pipe_cb_data_free = transaction_feed_cb_data_free;\n \n \tret = run_hooks_opt(transaction->ref_store->repo, \"reference-transaction\", &opt);\n \n-\tstrbuf_release(&feed_ctx.buf);\n \treturn ret;\n }\n \ndiff --git a/transport.c b/transport.c\nindex ecf9e1f21c..1581aa0886 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1357,21 +1357,36 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \treturn 0;\n }\n \n+static void *pre_push_hook_data_alloc(void *feed_pipe_ctx)\n+{\n+\tstruct feed_pre_push_hook_data *data = xmalloc(sizeof(*data));\n+\tstrbuf_init(&data->buf, 0);\n+\tdata->refs = (struct ref *)feed_pipe_ctx;\n+\treturn data;\n+}\n+\n+static void pre_push_hook_data_free(void *data)\n+{\n+\tstruct feed_pre_push_hook_data *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_pre_push_hook(struct transport *transport,\n \t\t\t     struct ref *remote_refs)\n {\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n-\tstruct feed_pre_push_hook_data data;\n \tint ret = 0;\n \n \tstrvec_push(&opt.args, transport->remote->name);\n \tstrvec_push(&opt.args, transport->url);\n \n-\tstrbuf_init(&data.buf, 0);\n-\tdata.refs = remote_refs;\n-\n \topt.feed_pipe = pre_push_hook_feed_stdin;\n-\topt.feed_pipe_cb_data = &data;\n+\topt.feed_pipe_ctx = remote_refs;\n+\topt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;\n+\topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n \n \t/*\n \t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n@@ -1381,8 +1396,6 @@ static int run_pre_push_hook(struct transport *transport,\n \n \tret = run_hooks_opt(the_repository, \"pre-push\", &opt);\n \n-\tstrbuf_release(&data.buf);\n-\n \treturn ret;\n }\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536351","messageId":"20260218222352.55393-4-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 3/8] hook: add \"git hook list\" command","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:47Z","receivedAt":"2026-02-18T22:24:28Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nThe previous commit introduced an ability to run multiple commands for\nhook events and next commit will introduce the ability to define hooks\nfrom configs, in addition to the \"traditional\" hooks from the hookdir.\n\nIntroduce a new command \"git hook list\" to make inspecting hooks easier\nboth for users and for the tests we will add.\n\nFurther commits will expand on this, e.g. by adding a -z output mode.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  5 ++++\n builtin/hook.c              | 60 +++++++++++++++++++++++++++++++++++++\n hook.c                      | 17 ++---------\n hook.h                      | 24 ++++++++++++++-\n t/t1800-hook.sh             | 22 ++++++++++++++\n 5 files changed, 112 insertions(+), 16 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex f6cc72d2ca..eb0ffcb8a9 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -9,6 +9,7 @@ SYNOPSIS\n --------\n [verse]\n 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' list <hook-name>\n \n DESCRIPTION\n -----------\n@@ -28,6 +29,10 @@ Any positional arguments to the hook should be passed after a\n mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See\n linkgit:githooks[5] for arguments hooks might expect (if any).\n \n+list::\n+\tPrint a list of hooks which will be run on `<hook-name>` event. If no\n+\thooks are configured for that event, print a warning and return 1.\n+\n OPTIONS\n -------\n \ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 7afec380d2..51660c4941 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -6,12 +6,16 @@\n #include \"hook.h\"\n #include \"parse-options.h\"\n #include \"strvec.h\"\n+#include \"abspath.h\"\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+#define BUILTIN_HOOK_LIST_USAGE \\\n+\tN_(\"git hook list <hook-name>\")\n \n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n+\tBUILTIN_HOOK_LIST_USAGE,\n \tNULL\n };\n \n@@ -20,6 +24,61 @@ static const char * const builtin_hook_run_usage[] = {\n \tNULL\n };\n \n+static int list(int argc, const char **argv, const char *prefix,\n+\t\t struct repository *repo)\n+{\n+\tstatic const char *const builtin_hook_list_usage[] = {\n+\t\tBUILTIN_HOOK_LIST_USAGE,\n+\t\tNULL\n+\t};\n+\tstruct string_list *head;\n+\tstruct string_list_item *item;\n+\tconst char *hookname = NULL;\n+\tint ret = 0;\n+\n+\tstruct option list_options[] = {\n+\t\tOPT_END(),\n+\t};\n+\n+\targc = parse_options(argc, argv, prefix, list_options,\n+\t\t\t     builtin_hook_list_usage, 0);\n+\n+\t/*\n+\t * The only unnamed argument provided should be the hook-name; if we add\n+\t * arguments later they probably should be caught by parse_options.\n+\t */\n+\tif (argc != 1)\n+\t\tusage_msg_opt(_(\"You must specify a hook event name to list.\"),\n+\t\t\t      builtin_hook_list_usage, list_options);\n+\n+\thookname = argv[0];\n+\n+\thead = list_hooks(repo, hookname, NULL);\n+\n+\tif (!head->nr) {\n+\t\twarning(_(\"No hooks found for event '%s'\"), hookname);\n+\t\tret = 1; /* no hooks found */\n+\t\tgoto cleanup;\n+\t}\n+\n+\tfor_each_string_list_item(item, head) {\n+\t\tstruct hook *h = item->util;\n+\n+\t\tswitch (h->kind) {\n+\t\tcase HOOK_TRADITIONAL:\n+\t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n+\t\t\tbreak;\n+\t\tdefault:\n+\t\t\tBUG(\"unknown hook kind\");\n+\t\t}\n+\t}\n+\n+cleanup:\n+\thook_list_clear(head, NULL);\n+\tfree(head);\n+\treturn ret;\n+}\n+\n static int run(int argc, const char **argv, const char *prefix,\n \t       struct repository *repo UNUSED)\n {\n@@ -77,6 +136,7 @@ int cmd_hook(int argc,\n \tparse_opt_subcommand_fn *fn = NULL;\n \tstruct option builtin_hook_options[] = {\n \t\tOPT_SUBCOMMAND(\"run\", &fn, run),\n+\t\tOPT_SUBCOMMAND(\"list\", &fn, list),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/hook.c b/hook.c\nindex c008a7232d..979a97a538 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -61,7 +61,7 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n \tfree(h);\n }\n \n-static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n+void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n {\n \tstruct string_list_item *item;\n \n@@ -101,20 +101,7 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n \tstring_list_append(hook_list, hook_path)->util = h;\n }\n \n-/*\n- * Provides a list of hook commands to run for the 'hookname' event.\n- *\n- * This function consolidates hooks from two sources:\n- * 1. The config-based hooks (not yet implemented).\n- * 2. The \"traditional\" hook found in the repository hooks directory\n- *    (e.g., .git/hooks/pre-commit).\n- *\n- * The list is ordered by execution priority.\n- *\n- * The caller is responsible for freeing the memory of the returned list\n- * using string_list_clear() and free().\n- */\n-static struct string_list *list_hooks(struct repository *r, const char *hookname,\n+struct string_list *list_hooks(struct repository *r, const char *hookname,\n \t\t\t       struct run_hooks_opt *options)\n {\n \tstruct string_list *hook_head;\ndiff --git a/hook.h b/hook.h\nindex 3256d2dddb..fea221f87d 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -163,7 +163,29 @@ struct hook_cb_data {\n \tstruct run_hooks_opt *options;\n };\n \n-/*\n+/**\n+ * Provides a list of hook commands to run for the 'hookname' event.\n+ *\n+ * This function consolidates hooks from two sources:\n+ * 1. The config-based hooks (not yet implemented).\n+ * 2. The \"traditional\" hook found in the repository hooks directory\n+ *    (e.g., .git/hooks/pre-commit).\n+ *\n+ * The list is ordered by execution priority.\n+ *\n+ * The caller is responsible for freeing the memory of the returned list\n+ * using string_list_clear() and free().\n+ */\n+struct string_list *list_hooks(struct repository *r, const char *hookname,\n+\t\t\t       struct run_hooks_opt *options);\n+\n+/**\n+ * Frees the memory allocated for the hook list, including the `struct hook`\n+ * items and their internal state.\n+ */\n+void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n+\n+/**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\n  * overwritten by further calls to find_hook and run_hook_*.\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex ed28a2fadb..3ec11f1249 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -10,9 +10,31 @@ test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook run &&\n \ttest_expect_code 129 git hook run -h &&\n \ttest_expect_code 129 git hook run --unknown 2>err &&\n+\ttest_expect_code 129 git hook list &&\n+\ttest_expect_code 129 git hook list -h &&\n \tgrep \"unknown option\" err\n '\n \n+test_expect_success 'git hook list: nonexistent hook' '\n+\tcat >stderr.expect <<-\\EOF &&\n+\twarning: No hooks found for event '\\''test-hook'\\''\n+\tEOF\n+\ttest_expect_code 1 git hook list test-hook 2>stderr.actual &&\n+\ttest_cmp stderr.expect stderr.actual\n+'\n+\n+test_expect_success 'git hook list: traditional hook from hookdir' '\n+\ttest_hook test-hook <<-EOF &&\n+\techo Test hook\n+\tEOF\n+\n+\tcat >expect <<-\\EOF &&\n+\thook from hookdir\n+\tEOF\n+\tgit hook list test-hook >actual &&\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success 'git hook run: nonexistent hook' '\n \tcat >stderr.expect <<-\\EOF &&\n \terror: cannot find a hook named test-hook\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536352","messageId":"20260218222352.55393-6-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 5/8] hook: allow disabling config hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:49Z","receivedAt":"2026-02-18T22:24:31Z","isPatch":true,"body":"Hooks specified via configs are always enabled, however users\nmight want to disable them without removing from the config,\nlike locally disabling a global hook.\n\nAdd a hook.<name>.enabled config which defaults to true and\ncan be optionally set for each configured hook.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  7 +++++++\n hook.c                         | 20 ++++++++++++++++++++\n t/t1800-hook.sh                | 32 ++++++++++++++++++++++++++++++++\n 3 files changed, 59 insertions(+)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 9faafe3016..0cda4745a6 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -13,3 +13,10 @@ hook.<name>.event::\n \tspecified event, the associated `hook.<name>.command` is executed.\n \tThis is a multi-valued key. To run `hook.<name>` on multiple\n \tevents, specify the key more than once. See linkgit:git-hook[1].\n+\n+hook.<name>.enabled::\n+\tWhether the hook `hook.<name>` is enabled. Defaults to `true`.\n+\tSet to `false` to disable the hook without removing its\n+\tconfiguration. This is particularly useful when a hook is defined\n+\tin a system or global config file and needs to be disabled for a\n+\tspecific repository. See linkgit:git-hook[1].\ndiff --git a/hook.c b/hook.c\nindex 8a9b405f76..35c24bf33d 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -164,6 +164,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tchar *old = strmap_put(&data->commands, hook_name,\n \t\t\t\t       xstrdup(value));\n \t\tfree(old);\n+\t} else if (!strcmp(subkey, \"enabled\")) {\n+\t\tswitch (git_parse_maybe_bool(value)) {\n+\t\tcase 0: /* disabled */\n+\t\t\tif (!unsorted_string_list_lookup(&data->disabled_hooks,\n+\t\t\t\t\t\t\t hook_name))\n+\t\t\t\tstring_list_append(&data->disabled_hooks,\n+\t\t\t\t\t\t   hook_name);\n+\t\t\tbreak;\n+\t\tcase 1: /* enabled: undo a prior disabled entry */\n+\t\t\tunsorted_string_list_remove(&data->disabled_hooks,\n+\t\t\t\t\t\t    hook_name);\n+\t\t\tbreak;\n+\t\tdefault:\n+\t\t\tbreak; /* ignore unrecognised values */\n+\t\t}\n \t}\n \n \tfree(hook_name);\n@@ -216,6 +231,11 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tconst char *hname = hook_names->items[i].string;\n \t\t\tchar *command;\n \n+\t\t\t/* filter out disabled hooks */\n+\t\t\tif (unsorted_string_list_lookup(&cb_data.disabled_hooks,\n+\t\t\t\t\t\t\thname))\n+\t\t\t\tcontinue;\n+\n \t\t\tcommand = strmap_get(&cb_data.commands, hname);\n \t\t\tif (!command)\n \t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex f1048a5119..9797802735 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -318,6 +318,38 @@ test_expect_success 'rejects hooks with no commands configured' '\n \ttest_grep \"hook.broken.command\" actual\n '\n \n+test_expect_success 'disabled hook is not run' '\n+\ttest_config hook.skipped.event \"test-hook\" &&\n+\ttest_config hook.skipped.command \"echo \\\"Should not run\\\"\" &&\n+\ttest_config hook.skipped.enabled false &&\n+\n+\tgit hook run --ignore-missing test-hook 2>actual &&\n+\ttest_must_be_empty actual\n+'\n+\n+test_expect_success 'disabled hook does not appear in git hook list' '\n+\ttest_config hook.active.event \"pre-commit\" &&\n+\ttest_config hook.active.command \"echo active\" &&\n+\ttest_config hook.inactive.event \"pre-commit\" &&\n+\ttest_config hook.inactive.command \"echo inactive\" &&\n+\ttest_config hook.inactive.enabled false &&\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_grep \"active\" actual &&\n+\ttest_grep ! \"inactive\" actual\n+'\n+\n+test_expect_success 'globally disabled hook can be re-enabled locally' '\n+\ttest_config_global hook.global-hook.event \"test-hook\" &&\n+\ttest_config_global hook.global-hook.command \"echo \\\"global-hook ran\\\"\" &&\n+\ttest_config_global hook.global-hook.enabled false &&\n+\ttest_config hook.global-hook.enabled true &&\n+\n+\techo \"global-hook ran\" >expected &&\n+\tgit hook run test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n test_expect_success 'git hook run a hook with a bad shebang' '\n \ttest_when_finished \"rm -rf bad-hooks\" &&\n \tmkdir bad-hooks &&\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536353","messageId":"20260218222352.55393-1-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260204165126.1548805-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 0/8] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:44Z","receivedAt":"2026-02-18T22:24:32Z","isPatch":true,"body":"Hello everyone,\n\nv2 addresses all feedback received in v1.\n\nThis series adds a new feature: the ability to specify commands to run\nfor hook events via config entries (including shell commands).\n\nSo instead of dropping a shell script or a custom program in .git/hooks\nyou can now tell git via config files to run a program or shell script\n(can be specified directly in the config) when you run hook \"foo\".\n\nThis also means you can setup global hooks to run in multiple repos via\nglobal configs and there's an option to disable them if necessary.\n\nFor simplicity, because this series is becoming rather big, hooks are\nstill executed sequentially (.jobs == 1). Parallel execution is added\nin another patch series.\n\nThis is based on the latest v8 hooks-conversion series [1] which has\nnot yet landed in next or master.\n\nBranch pused to GitHub: [2]\nSuccesful CI run: [3]\n\nMany thanks to all who contributed to this effort up to now, including\nEmily, AEvar, Junio, Patrick, Peff, Kristoffer, Chris and many others.\n\nThank you,\nAdrian\n\n1: https://lore.kernel.org/git/20250925125352.1728840-1-adrian.ratiu@collabora.com/T/#m41f793907f46fd04f44ff1b06c53d20af38e6cb2\n2: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/config-hooks-v2\n3: https://github.com/10ne1/git/actions/runs/22158690260\n\nChanges in v2:\n* Introduce a \"struct hook\" with typing information & helper fun types (Junio)\n* Detect hook config friendly-name and command in the same place (Junio, Patrick)\n* Simplified run_hooks_opt() to use list index instead of item pointers (Patrick)\n* Simplified pick_next_hook() to only contain next pp child setup logic (Patrick)\n* Reworked git hook list to not rely on empty strings to distinguish hooks (Junio)\n* Added a hook config cache to avoid re-reading cfg on each hook call (Patrick)\n* New commit: Split internal state alloc/free callbacks into own commit (Patrick)\n* New commit: Add hook.<name>.enabled (Patrick)\n* New commit: Add -z option to git hook list (Patrick)\n* New commit: \"event = \" config entries reset the value  (Patrick)\n* Split default hook detection into its own helper from list_hooks (Adrian)\n* Moved hook internal state alloc/free where struct hook is defined (Patrick)\n* Changed semantics of e copy/free cb API to avoid copying (alloc/free) (Patrick)\n* Replace the empty \"friendly-name\" for the legacy hooks with the path (Patrick)\n* Dropped the notify_start_failure() msg because it's not worth the complexity\n  and reverted back to the previous hook cb call convention (Patrick)\n* Fixed codepaths to avoid using the_repository in favor of repo arg (Patrick)\n* Fixed a very small conflict with upstream master branch due to the\n  addition of the new \"history\" command (Adrian)\n* Removed the extra repository pointer added to hook.h (Adrian)\n* Added a more tests exercising git hook list/run (Patrick)\n* Moved r->gitdir check to commit which allows out-of-repo invocations (Patrick)\n* Reworded commit and error messages for better clarity, added warnings (Patrick)\n* Reordered commits for better reading (Adrian)\n* Small documentation improvements and code style fixes (Patrick)\n\nRange-diff v1 -> v2:\n1:  3b9816d835 < -:  ---------- hook: run a list of hooks\n-:  ---------- > 1:  6fe0e2eea4 hook: add internal state alloc/free callbacks\n-:  ---------- > 2:  2917d45a19 hook: run a list of hooks to prepare for multihook support\n2:  fbc84e68ef ! 3:  19d41e85e1 hook: introduce \"git hook list\"\n    @@ Metadata\n     Author: Emily Shaffer <nasamuffin@google.com>\n     \n      ## Commit message ##\n    -    hook: introduce \"git hook list\"\n    +    hook: add \"git hook list\" command\n     \n    -    If more than one hook will be run, it may be useful to see a list of\n    -    which hooks should be run. At very least, it will be useful for us to\n    -    test the semantics of multihooks ourselves.\n    +    The previous commit introduced an ability to run multiple commands for\n    +    hook events and next commit will introduce the ability to define hooks\n    +    from configs, in addition to the \"traditional\" hooks from the hookdir.\n     \n    -    For now, only list the hooks which will run in the order they will run\n    -    in; later, it might be useful to include more information like where the\n    -    hooks were configured and whether or not they will run.\n    +    Introduce a new command \"git hook list\" to make inspecting hooks easier\n    +    both for users and for the tests we will add.\n    +\n    +    Further commits will expand on this, e.g. by adding a -z output mode.\n     \n         Signed-off-by: Emily Shaffer <emilyshaffer@google.com>\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n    @@ Documentation/git-hook.adoc: Any positional arguments to the hook should be pass\n      \n     +list::\n     +\tPrint a list of hooks which will be run on `<hook-name>` event. If no\n    -+\thooks are configured for that event, print nothing and return 1.\n    ++\thooks are configured for that event, print a warning and return 1.\n     +\n      OPTIONS\n      -------\n    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {\n      \tNULL\n      };\n      \n    -+static const char *const builtin_hook_list_usage[] = {\n    -+\tBUILTIN_HOOK_LIST_USAGE,\n    -+\tNULL\n    -+};\n    -+\n     +static int list(int argc, const char **argv, const char *prefix,\n    -+\t\t struct repository *repo UNUSED)\n    ++\t\t struct repository *repo)\n     +{\n    ++\tstatic const char *const builtin_hook_list_usage[] = {\n    ++\t\tBUILTIN_HOOK_LIST_USAGE,\n    ++\t\tNULL\n    ++\t};\n     +\tstruct string_list *head;\n     +\tstruct string_list_item *item;\n     +\tconst char *hookname = NULL;\n    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {\n     +\n     +\thookname = argv[0];\n     +\n    -+\thead = list_hooks(the_repository, hookname);\n    ++\thead = list_hooks(repo, hookname, NULL);\n     +\n     +\tif (!head->nr) {\n    ++\t\twarning(_(\"No hooks found for event '%s'\"), hookname);\n     +\t\tret = 1; /* no hooks found */\n     +\t\tgoto cleanup;\n     +\t}\n     +\n     +\tfor_each_string_list_item(item, head) {\n    -+\t\tprintf(\"%s\\n\", *item->string ? item->string\n    -+\t\t\t     : _(\"hook from hookdir\"));\n    ++\t\tstruct hook *h = item->util;\n    ++\n    ++\t\tswitch (h->kind) {\n    ++\t\tcase HOOK_TRADITIONAL:\n    ++\t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n    ++\t\t\tbreak;\n    ++\t\tdefault:\n    ++\t\t\tBUG(\"unknown hook kind\");\n    ++\t\t}\n     +\t}\n     +\n     +cleanup:\n    -+\tstring_list_clear(head, 1);\n    ++\thook_list_clear(head, NULL);\n     +\tfree(head);\n     +\treturn ret;\n     +}\n    @@ builtin/hook.c: int cmd_hook(int argc,\n      \n     \n      ## hook.c ##\n    -@@ hook.c: const char *find_hook(struct repository *r, const char *name)\n    - \treturn path.buf;\n    +@@ hook.c: static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n    + \tfree(h);\n    + }\n    + \n    +-static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n    ++void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n    + {\n    + \tstruct string_list_item *item;\n    + \n    +@@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,\n    + \tstring_list_append(hook_list, hook_path)->util = h;\n      }\n      \n     -/*\n    @@ hook.c: const char *find_hook(struct repository *r, const char *name)\n     - * The caller is responsible for freeing the memory of the returned list\n     - * using string_list_clear() and free().\n     - */\n    --static struct string_list *list_hooks(struct repository *r, const char *hookname)\n    -+struct string_list *list_hooks(struct repository *r, const char *hookname)\n    +-static struct string_list *list_hooks(struct repository *r, const char *hookname,\n    ++struct string_list *list_hooks(struct repository *r, const char *hookname,\n    + \t\t\t       struct run_hooks_opt *options)\n      {\n      \tstruct string_list *hook_head;\n    - \n     \n      ## hook.h ##\n     @@ hook.h: struct hook_cb_data {\n    - \tstruct repository *repository;\n    + \tstruct run_hooks_opt *options;\n      };\n      \n     -/*\n    @@ hook.h: struct hook_cb_data {\n     + * The caller is responsible for freeing the memory of the returned list\n     + * using string_list_clear() and free().\n     + */\n    -+struct string_list *list_hooks(struct repository *r, const char *hookname);\n    ++struct string_list *list_hooks(struct repository *r, const char *hookname,\n    ++\t\t\t       struct run_hooks_opt *options);\n    ++\n    ++/**\n    ++ * Frees the memory allocated for the hook list, including the `struct hook`\n    ++ * items and their internal state.\n    ++ */\n    ++void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n     +\n     +/**\n       * Returns the path to the hook file, or NULL if the hook is missing\n    @@ t/t1800-hook.sh: test_expect_success 'git hook usage' '\n      \tgrep \"unknown option\" err\n      '\n      \n    ++test_expect_success 'git hook list: nonexistent hook' '\n    ++\tcat >stderr.expect <<-\\EOF &&\n    ++\twarning: No hooks found for event '\\''test-hook'\\''\n    ++\tEOF\n    ++\ttest_expect_code 1 git hook list test-hook 2>stderr.actual &&\n    ++\ttest_cmp stderr.expect stderr.actual\n    ++'\n    ++\n    ++test_expect_success 'git hook list: traditional hook from hookdir' '\n    ++\ttest_hook test-hook <<-EOF &&\n    ++\techo Test hook\n    ++\tEOF\n    ++\n    ++\tcat >expect <<-\\EOF &&\n    ++\thook from hookdir\n    ++\tEOF\n    ++\tgit hook list test-hook >actual &&\n    ++\ttest_cmp expect actual\n    ++'\n    ++\n    + test_expect_success 'git hook run: nonexistent hook' '\n    + \tcat >stderr.expect <<-\\EOF &&\n    + \terror: cannot find a hook named test-hook\n3:  d6b3fbf6b1 ! 4:  0c98b18bf5 hook: include hooks from the config\n    @@\n      ## Metadata ##\n    -Author: Emily Shaffer <nasamuffin@google.com>\n    +Author: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Commit message ##\n         hook: include hooks from the config\n    @@ Commit message\n         lines and will be run sequentially (.jobs == 1) like before.\n         Running the hooks in parallel will be enabled in a future patch.\n     \n    +    The \"traditional\" hook from the hookdir is run last, if present.\n    +\n    +    A strmap cache is added to struct repository to avoid re-reading\n    +    the configs on each rook run. This is useful for hooks like the\n    +    ref-transaction which gets executed multiple times per process.\n    +\n         Examples:\n     \n           $ git config --get-regexp \"^hook\\.\"\n    @@ Commit message\n      ## Documentation/config/hook.adoc (new) ##\n     @@\n     +hook.<name>.command::\n    -+\tA command to execute whenever `hook.<name>` is invoked. `<name>` should\n    -+\tbe a unique \"friendly\" name which you can use to identify this hook\n    -+\tcommand. (You can specify when to invoke this command with\n    -+\t`hook.<name>.event`.) The value can be an executable on your device or a\n    -+\toneliner for your shell. If more than one value is specified for the\n    -+\tsame `<name>`, the last value parsed will be the only command executed.\n    -+\tSee linkgit:git-hook[1].\n    ++\tThe command to execute for `hook.<name>`. `<name>` is a unique\n    ++\t\"friendly\" name that identifies this hook. (The hook events that\n    ++\ttrigger the command are configured with `hook.<name>.event`.) The\n    ++\tvalue can be an executable path or a shell oneliner. If more than\n    ++\tone value is specified for the same `<name>`, only the last value\n    ++\tparsed is used. See linkgit:git-hook[1].\n     +\n     +hook.<name>.event::\n    -+\tThe hook events which should invoke `hook.<name>`. `<name>` should be a\n    -+\tunique \"friendly\" name which you can use to identify this hook. The\n    -+\tvalue should be the name of a hook event, like \"pre-commit\" or \"update\".\n    -+\t(See linkgit:githooks[5] for a complete list of hooks Git knows about.)\n    -+\tOn the specified event, the associated `hook.<name>.command` will be\n    -+\texecuted. More than one event can be specified if you wish for\n    -+\t`hook.<name>` to execute on multiple events. See linkgit:git-hook[1].\n    ++\tThe hook events that trigger `hook.<name>`. The value is the name\n    ++\tof a hook event, like \"pre-commit\" or \"update\". (See\n    ++\tlinkgit:githooks[5] for a complete list of hook events.) On the\n    ++\tspecified event, the associated `hook.<name>.command` is executed.\n    ++\tThis is a multi-valued key. To run `hook.<name>` on multiple\n    ++\tevents, specify the key more than once. See linkgit:git-hook[1].\n     \n      ## Documentation/git-hook.adoc ##\n     @@ Documentation/git-hook.adoc: DESCRIPTION\n    @@ Documentation/git-hook.adoc: DESCRIPTION\n     +`.git/hooks/<hook-event>`, you can specify it in the config instead by running:\n     +\n     +----\n    -+git config hook.<some-name>.command <path-to-script>\n    -+git config --add hook.<some-name>.event <hook-event>\n    ++git config set hook.<some-name>.command <path-to-script>\n    ++git config set --append hook.<some-name>.event <hook-event>\n     +----\n     +\n     +This way you can share the script between multiple repos. That is, `cp\n     +~/my-script.sh ~/project/.git/hooks/pre-commit` would become:\n     +\n     +----\n    -+git config hook.my-script.command ~/my-script.sh\n    -+git config --add hook.my-script.event pre-commit\n    ++git config set hook.my-script.command ~/my-script.sh\n    ++git config set --append hook.my-script.event pre-commit\n     +----\n     +\n      SUBCOMMANDS\n    @@ Documentation/git-hook.adoc: DESCRIPTION\n     -\tRun the `<hook-name>` hook. See linkgit:githooks[5] for\n     -\tsupported hook names.\n     +\tRuns hooks configured for `<hook-name>`, in the order they are\n    -+\tdiscovered during the config parse.\n    ++\tdiscovered during the config parse. The default `<hook-name>` from\n    ++\tthe hookdir is run last. See linkgit:githooks[5] for supported\n    ++\thook names.\n      +\n      \n      Any positional arguments to the hook should be passed after a\n    @@ Documentation/git-hook.adoc: OPTIONS\n      --------\n      linkgit:githooks[5]\n     \n    + ## builtin/hook.c ##\n    +@@ builtin/hook.c: static int list(int argc, const char **argv, const char *prefix,\n    + \t\tcase HOOK_TRADITIONAL:\n    + \t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n    + \t\t\tbreak;\n    ++\t\tcase HOOK_CONFIGURED:\n    ++\t\t\tprintf(\"%s\\n\", h->u.configured.friendly_name);\n    ++\t\t\tbreak;\n    + \t\tdefault:\n    + \t\t\tBUG(\"unknown hook kind\");\n    + \t\t}\n    +\n      ## hook.c ##\n    -@@ hook.c: const char *find_hook(struct repository *r, const char *name)\n    - \treturn path.buf;\n    +@@\n    + #include \"gettext.h\"\n    + #include \"hook.h\"\n    + #include \"path.h\"\n    ++#include \"parse.h\"\n    + #include \"run-command.h\"\n    + #include \"config.h\"\n    + #include \"strbuf.h\"\n    ++#include \"strmap.h\"\n    + #include \"environment.h\"\n    + #include \"setup.h\"\n    + \n    +@@ hook.c: static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n    + \n    + \tif (h->kind == HOOK_TRADITIONAL)\n    + \t\tfree((void *)h->u.traditional.path);\n    ++\telse if (h->kind == HOOK_CONFIGURED) {\n    ++\t\tfree((void *)h->u.configured.friendly_name);\n    ++\t\tfree((void *)h->u.configured.command);\n    ++\t}\n    + \n    + \tif (cb_data_free)\n    + \t\tcb_data_free(h->feed_pipe_cb_data);\n    +@@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,\n    + \tstring_list_append(hook_list, hook_path)->util = h;\n      }\n      \n    -+struct hook_config_cb\n    ++static void unsorted_string_list_remove(struct string_list *list,\n    ++\t\t\t\t\tconst char *str)\n     +{\n    -+\tconst char *hook_event;\n    -+\tstruct string_list *list;\n    -+};\n    ++\tstruct string_list_item *item = unsorted_string_list_lookup(list, str);\n    ++\tif (item)\n    ++\t\tunsorted_string_list_delete_item(list, item - list->items, 0);\n    ++}\n     +\n     +/*\n    -+ * Callback for git_config which adds configured hooks to a hook list.  Hooks\n    -+ * can be configured by specifying both hook.<friendly-name>.command = <path>\n    -+ * and hook.<friendly-name>.event = <hook-event>.\n    ++ * Callback struct to collect all hook.* keys in a single config pass.\n    ++ * commands: friendly-name to command map.\n    ++ * event_hooks: event-name to list of friendly-names map.\n    ++ * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n     + */\n    -+static int hook_config_lookup(const char *key, const char *value,\n    -+\t\t\t      const struct config_context *ctx UNUSED,\n    -+\t\t\t      void *cb_data)\n    ++struct hook_all_config_cb {\n    ++\tstruct strmap commands;\n    ++\tstruct strmap event_hooks;\n    ++\tstruct string_list disabled_hooks;\n    ++};\n    ++\n    ++/* repo_config() callback that collects all hook.* configuration in one pass. */\n    ++static int hook_config_lookup_all(const char *key, const char *value,\n    ++\t\t\t\t  const struct config_context *ctx UNUSED,\n    ++\t\t\t\t  void *cb_data)\n     +{\n    -+\tstruct hook_config_cb *data = cb_data;\n    -+\tconst char *name, *event_key;\n    -+\tsize_t name_len = 0;\n    -+\tstruct string_list_item *item;\n    ++\tstruct hook_all_config_cb *data = cb_data;\n    ++\tconst char *name, *subkey;\n     +\tchar *hook_name;\n    ++\tsize_t name_len = 0;\n     +\n    -+\t/*\n    -+\t * Don't bother doing the expensive parse if there's no\n    -+\t * chance that the config matches 'hook.myhook.event = hook_event'.\n    -+\t */\n    -+\tif (!value || strcmp(value, data->hook_event))\n    ++\tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n     +\t\treturn 0;\n     +\n    -+\t/* Look for \"hook.friendly-name.event = hook_event\" */\n    -+\tif (parse_config_key(key, \"hook\", &name, &name_len, &event_key) ||\n    -+\t    strcmp(event_key, \"event\"))\n    -+\t\treturn 0;\n    ++\tif (!value)\n    ++\t\treturn config_error_nonbool(key);\n     +\n    -+\t/* Extract the hook name */\n    ++\t/* Extract name, ensuring it is null-terminated. */\n     +\thook_name = xmemdupz(name, name_len);\n     +\n    -+\t/* Remove the hook if already in the list, so we append in config order. */\n    -+\tif ((item = unsorted_string_list_lookup(data->list, hook_name)))\n    -+\t\tunsorted_string_list_delete_item(data->list, item - data->list->items, 0);\n    ++\tif (!strcmp(subkey, \"event\")) {\n    ++\t\tstruct string_list *hooks =\n    ++\t\t\tstrmap_get(&data->event_hooks, value);\n     +\n    -+\t/* The list takes ownership of hook_name, so append with nodup */\n    -+\tstring_list_append_nodup(data->list, hook_name);\n    ++\t\tif (!hooks) {\n    ++\t\t\thooks = xcalloc(1, sizeof(*hooks));\n    ++\t\t\tstring_list_init_dup(hooks);\n    ++\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n    ++\t\t}\n     +\n    ++\t\t/* Re-insert if necessary to preserve last-seen order. */\n    ++\t\tunsorted_string_list_remove(hooks, hook_name);\n    ++\t\tstring_list_append(hooks, hook_name);\n    ++\t} else if (!strcmp(subkey, \"command\")) {\n    ++\t\t/* Store command overwriting the old value */\n    ++\t\tchar *old = strmap_put(&data->commands, hook_name,\n    ++\t\t\t\t       xstrdup(value));\n    ++\t\tfree(old);\n    ++\t}\n    ++\n    ++\tfree(hook_name);\n     +\treturn 0;\n     +}\n     +\n    - struct string_list *list_hooks(struct repository *r, const char *hookname)\n    ++/*\n    ++ * The hook config cache maps each hook event name to a string_list where\n    ++ * every item's string is the hook's friendly-name and its util pointer is\n    ++ * the corresponding command string. Both strings are owned by the map.\n    ++ *\n    ++ * Disabled hooks and hooks missing a command are already filtered out at\n    ++ * parse time, so callers can iterate the list directly.\n    ++ */\n    ++void hook_cache_clear(struct strmap *cache)\n    ++{\n    ++\tstruct hashmap_iter iter;\n    ++\tstruct strmap_entry *e;\n    ++\n    ++\tstrmap_for_each_entry(cache, &iter, e) {\n    ++\t\tstruct string_list *hooks = e->value;\n    ++\t\tstring_list_clear(hooks, 1); /* free util (command) pointers */\n    ++\t\tfree(hooks);\n    ++\t}\n    ++\tstrmap_clear(cache, 0);\n    ++}\n    ++\n    ++/* Populate `cache` with the complete hook configuration */\n    ++static void build_hook_config_map(struct repository *r, struct strmap *cache)\n    ++{\n    ++\tstruct hook_all_config_cb cb_data;\n    ++\tstruct hashmap_iter iter;\n    ++\tstruct strmap_entry *e;\n    ++\n    ++\tstrmap_init(&cb_data.commands);\n    ++\tstrmap_init(&cb_data.event_hooks);\n    ++\tstring_list_init_dup(&cb_data.disabled_hooks);\n    ++\n    ++\t/* Parse all configs in one run. */\n    ++\trepo_config(r, hook_config_lookup_all, &cb_data);\n    ++\n    ++\t/* Construct the cache from parsed configs. */\n    ++\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n    ++\t\tstruct string_list *hook_names = e->value;\n    ++\t\tstruct string_list *hooks = xcalloc(1, sizeof(*hooks));\n    ++\n    ++\t\tstring_list_init_dup(hooks);\n    ++\n    ++\t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n    ++\t\t\tconst char *hname = hook_names->items[i].string;\n    ++\t\t\tchar *command;\n    ++\n    ++\t\t\tcommand = strmap_get(&cb_data.commands, hname);\n    ++\t\t\tif (!command)\n    ++\t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n    ++\t\t\t\t      \"'hook.%s.event' must be removed;\"\n    ++\t\t\t\t      \" aborting.\"), hname, hname);\n    ++\n    ++\t\t\t/* util stores the command; owned by the cache. */\n    ++\t\t\tstring_list_append(hooks, hname)->util =\n    ++\t\t\t\txstrdup(command);\n    ++\t\t}\n    ++\n    ++\t\tstrmap_put(cache, e->key, hooks);\n    ++\t}\n    ++\n    ++\tstrmap_clear(&cb_data.commands, 1);\n    ++\tstring_list_clear(&cb_data.disabled_hooks, 0);\n    ++\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n    ++\t\tstring_list_clear(e->value, 0);\n    ++\t\tfree(e->value);\n    ++\t}\n    ++\tstrmap_clear(&cb_data.event_hooks, 0);\n    ++}\n    ++\n    ++/* Return the hook config map for `r`, populating it first if needed. */\n    ++static struct strmap *get_hook_config_cache(struct repository *r)\n    ++{\n    ++\tstruct strmap *cache = NULL;\n    ++\n    ++\tif (r) {\n    ++\t\t/*\n    ++\t\t * For in-repo calls, the map is stored in r->hook_config_cache,\n    ++\t\t * so repeated invocations don't parse the configs, so allocate\n    ++\t\t * it just once on the first call.\n    ++\t\t */\n    ++\t\tif (!r->hook_config_cache) {\n    ++\t\t\tr->hook_config_cache = xcalloc(1, sizeof(*cache));\n    ++\t\t\tstrmap_init(r->hook_config_cache);\n    ++\t\t\tbuild_hook_config_map(r, r->hook_config_cache);\n    ++\t\t}\n    ++\t\tcache = r->hook_config_cache;\n    ++\t}\n    ++\n    ++\treturn cache;\n    ++}\n    ++\n    ++static void list_hooks_add_configured(struct repository *r,\n    ++\t\t\t\t      const char *hookname,\n    ++\t\t\t\t      struct string_list *list,\n    ++\t\t\t\t      struct run_hooks_opt *options)\n    ++{\n    ++\tstruct strmap *cache = get_hook_config_cache(r);\n    ++\tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n    ++\n    ++\t/* Iterate through configured hooks and initialize internal states */\n    ++\tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n    ++\t\tconst char *friendly_name = configured_hooks->items[i].string;\n    ++\t\tconst char *command = configured_hooks->items[i].util;\n    ++\t\tstruct hook *hook = xcalloc(1, sizeof(struct hook));\n    ++\n    ++\t\tif (options && options->feed_pipe_cb_data_alloc)\n    ++\t\t\thook->feed_pipe_cb_data =\n    ++\t\t\t\toptions->feed_pipe_cb_data_alloc(\n    ++\t\t\t\t\toptions->feed_pipe_ctx);\n    ++\n    ++\t\thook->kind = HOOK_CONFIGURED;\n    ++\t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n    ++\t\thook->u.configured.command = xstrdup(command);\n    ++\n    ++\t\tstring_list_append(list, friendly_name)->util = hook;\n    ++\t}\n    ++}\n    ++\n    + struct string_list *list_hooks(struct repository *r, const char *hookname,\n    + \t\t\t       struct run_hooks_opt *options)\n      {\n    --\tstruct string_list *hook_head;\n    -+\tstruct hook_config_cb cb_data;\n    +@@ hook.c: struct string_list *list_hooks(struct repository *r, const char *hookname,\n    + \thook_head = xmalloc(sizeof(struct string_list));\n    + \tstring_list_init_dup(hook_head);\n      \n    - \tif (!hookname)\n    - \t\tBUG(\"null hookname was provided to hook_list()!\");\n    - \n    --\thook_head = xmalloc(sizeof(struct string_list));\n    --\tstring_list_init_dup(hook_head);\n    -+\tcb_data.hook_event = hookname;\n    -+\tcb_data.list = xmalloc(sizeof(struct string_list));\n    -+\tstring_list_init_dup(cb_data.list);\n    -+\n    -+\t/* Add the hooks from the config, e.g. hook.myhook.event = pre-commit */\n    -+\trepo_config(r, hook_config_lookup, &cb_data);\n    - \n    - \t/*\n    - \t * Add the default hook from hookdir. It does not have a friendly name\n    - \t * like the hooks specified via configs, so add it with an empty name.\n    - \t */\n    - \tif (r->gitdir && find_hook(r, hookname))\n    --\t\tstring_list_append(hook_head, \"\");\n    -+\t\tstring_list_append(cb_data.list, \"\");\n    - \n    --\treturn hook_head;\n    -+\treturn cb_data.list;\n    - }\n    ++\t/* Add hooks from the config, e.g. hook.myhook.event = pre-commit */\n    ++\tlist_hooks_add_configured(r, hookname, hook_head, options);\n    ++\n    + \t/* Add the default \"traditional\" hooks from hookdir. */\n    + \tlist_hooks_add_default(r, hookname, hook_head, options);\n      \n    - int hook_exists(struct repository *r, const char *name)\n     @@ hook.c: static int pick_next_hook(struct child_process *cp,\n    - \t\t\thook_path = absolute_path(hook_path);\n    + \tcp->dir = hook_cb->options->dir;\n      \n    - \t\tstrvec_push(&cp->args, hook_path);\n    -+\t} else {\n    -+\t\t/* ...from config */\n    -+\t\tstruct strbuf cmd_key = STRBUF_INIT;\n    -+\t\tchar *command = NULL;\n    -+\n    + \t/* Add hook exec paths or commands */\n    +-\tif (h->kind == HOOK_TRADITIONAL)\n    ++\tif (h->kind == HOOK_TRADITIONAL) {\n    + \t\tstrvec_push(&cp->args, h->u.traditional.path);\n    ++\t} else if (h->kind == HOOK_CONFIGURED) {\n     +\t\t/* to enable oneliners, let config-specified hooks run in shell. */\n     +\t\tcp->use_shell = true;\n    ++\t\tstrvec_push(&cp->args, h->u.configured.command);\n    ++\t}\n    + \n    + \tif (!cp->args.nr)\n    + \t\tBUG(\"hook must have at least one command or exec path\");\n    +\n    + ## hook.h ##\n    +@@\n    + #include \"strvec.h\"\n    + #include \"run-command.h\"\n    + #include \"string-list.h\"\n    ++#include \"strmap.h\"\n    + \n    + struct repository;\n    + \n    +@@ hook.h: struct repository;\n    +  * Represents a hook command to be run.\n    +  * Hooks can be:\n    +  * 1. \"traditional\" (found in the hooks directory)\n    +- * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n    ++ * 2. \"configured\" (defined in Git's configuration via hook.<name>.event).\n    +  * The 'kind' field determines which part of the union 'u' is valid.\n    +  */\n    + struct hook {\n    + \tenum {\n    + \t\tHOOK_TRADITIONAL,\n    ++\t\tHOOK_CONFIGURED,\n    + \t} kind;\n    + \tunion {\n    + \t\tstruct {\n    + \t\t\tconst char *path;\n    + \t\t} traditional;\n    ++\t\tstruct {\n    ++\t\t\tconst char *friendly_name;\n    ++\t\t\tconst char *command;\n    ++\t\t} configured;\n    + \t} u;\n    + \n    + \t/**\n    +@@ hook.h: struct string_list *list_hooks(struct repository *r, const char *hookname,\n    +  */\n    + void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n    + \n    ++/**\n    ++ * Frees the hook configuration cache stored in `struct repository`.\n    ++ * Called by repo_clear().\n    ++ */\n    ++void hook_cache_clear(struct strmap *cache);\n     +\n    -+\t\tstrbuf_addf(&cmd_key, \"hook.%s.command\", to_run->string);\n    -+\t\tif (repo_config_get_string(hook_cb->repository,\n    -+\t\t\t\t\t   cmd_key.buf, &command)) {\n    -+\t\t\tdie(_(\"'hook.%s.command' must be configured or\"\n    -+\t\t\t      \"'hook.%s.event' must be removed; aborting.\\n\"),\n    -+\t\t\t    to_run->string, to_run->string);\n    -+\t\t}\n    -+\t\tstrbuf_release(&cmd_key);\n    -+\n    -+\t\tstrvec_push_nodup(&cp->args, command);\n    + /**\n    +  * Returns the path to the hook file, or NULL if the hook is missing\n    +  * or disabled. Note that this points to static storage that will be\n    +\n    + ## repository.c ##\n    +@@\n    + #include \"git-compat-util.h\"\n    + #include \"abspath.h\"\n    + #include \"repository.h\"\n    ++#include \"hook.h\"\n    + #include \"odb.h\"\n    + #include \"config.h\"\n    + #include \"object.h\"\n    +@@ repository.c: void repo_clear(struct repository *repo)\n    + \t\tFREE_AND_NULL(repo->index);\n      \t}\n      \n    - \tif (!cp->args.nr)\n    ++\tif (repo->hook_config_cache) {\n    ++\t\thook_cache_clear(repo->hook_config_cache);\n    ++\t\tFREE_AND_NULL(repo->hook_config_cache);\n    ++\t}\n    ++\n    + \tif (repo->promisor_remote_config) {\n    + \t\tpromisor_remote_clear(repo->promisor_remote_config);\n    + \t\tFREE_AND_NULL(repo->promisor_remote_config);\n    +\n    + ## repository.h ##\n    +@@ repository.h: struct repository {\n    + \t/* True if commit-graph has been disabled within this process. */\n    + \tint commit_graph_disabled;\n    + \n    ++\t/*\n    ++\t * Lazily-populated cache mapping hook event names to configured hooks.\n    ++\t * NULL until first hook use.\n    ++\t */\n    ++\tstruct strmap *hook_config_cache;\n    ++\n    + \t/* Configurations related to promisor remotes. */\n    + \tchar *repository_format_partial_clone;\n    + \tstruct promisor_remote_config *promisor_remote_config;\n     \n      ## t/t1800-hook.sh ##\n     @@\n    @@ t/t1800-hook.sh\n      \ttest_expect_code 129 git hook run --unknown 2>err &&\n      \ttest_expect_code 129 git hook list &&\n      \ttest_expect_code 129 git hook list -h &&\n    +@@ t/t1800-hook.sh: test_expect_success 'git hook list: traditional hook from hookdir' '\n    + \ttest_cmp expect actual\n    + '\n    + \n    ++test_expect_success 'git hook list: configured hook' '\n    ++\ttest_config hook.myhook.command \"echo Hello\" &&\n    ++\ttest_config hook.myhook.event test-hook --add &&\n    ++\n    ++\techo \"myhook\" >expect &&\n    ++\tgit hook list test-hook >actual &&\n    ++\ttest_cmp expect actual\n    ++'\n    ++\n    + test_expect_success 'git hook run: nonexistent hook' '\n    + \tcat >stderr.expect <<-\\EOF &&\n    + \terror: cannot find a hook named test-hook\n     @@ t/t1800-hook.sh: test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n      \ttest_hook_tty commit -m\"B.new\"\n      '\n    @@ t/t1800-hook.sh: test_expect_success TTY 'git commit: stdout and stderr are conn\n     +'\n     +\n     +test_expect_success 'stdin to multiple hooks' '\n    -+\ttest_config hook.stdin-a.event \"test-hook\" --add &&\n    -+\ttest_config hook.stdin-a.command \"xargs -P1 -I% echo a%\" --add &&\n    -+\ttest_config hook.stdin-b.event \"test-hook\" --add &&\n    -+\ttest_config hook.stdin-b.command \"xargs -P1 -I% echo b%\" --add &&\n    ++\ttest_config hook.stdin-a.event \"test-hook\" &&\n    ++\ttest_config hook.stdin-a.command \"xargs -P1 -I% echo a%\" &&\n    ++\ttest_config hook.stdin-b.event \"test-hook\" &&\n    ++\ttest_config hook.stdin-b.command \"xargs -P1 -I% echo b%\" &&\n     +\n     +\tcat >input <<-\\EOF &&\n     +\t1\n    @@ t/t1800-hook.sh: test_expect_success TTY 'git commit: stdout and stderr are conn\n     +\n     +test_expect_success 'rejects hooks with no commands configured' '\n     +\ttest_config hook.broken.event \"test-hook\" &&\n    -+\techo broken >expected &&\n    -+\tgit hook list test-hook >actual &&\n    -+\ttest_cmp expected actual &&\n    -+\ttest_must_fail git hook run test-hook\n    ++\ttest_must_fail git hook list test-hook 2>actual &&\n    ++\ttest_grep \"hook.broken.command\" actual &&\n    ++\ttest_must_fail git hook run test-hook 2>actual &&\n    ++\ttest_grep \"hook.broken.command\" actual\n     +'\n     +\n      test_expect_success 'git hook run a hook with a bad shebang' '\n4:  d85dd19ffe < -:  ---------- hook: allow out-of-repo 'git hook' invocations\n-:  ---------- > 5:  f71ada4cb8 hook: allow disabling config hooks\n-:  ---------- > 6:  82a7d6167f hook: allow event = \"\" to overwrite previous values\n-:  ---------- > 7:  8d1704384e hook: allow out-of-repo 'git hook' invocations\n-:  ---------- > 8:  7bf527c59e hook: add -z option to \"git hook list\"\n\nAdrian Ratiu (5):\n  hook: add internal state alloc/free callbacks\n  hook: include hooks from the config\n  hook: allow disabling config hooks\n  hook: allow event = \"\" to overwrite previous values\n  hook: add -z option to \"git hook list\"\n\nEmily Shaffer (3):\n  hook: run a list of hooks to prepare for multihook support\n  hook: add \"git hook list\" command\n  hook: allow out-of-repo 'git hook' invocations\n\n Documentation/config/hook.adoc |  24 +++\n Documentation/git-hook.adoc    | 137 +++++++++++-\n builtin/hook.c                 |  66 ++++++\n builtin/receive-pack.c         |  33 ++-\n git.c                          |   2 +-\n hook.c                         | 379 ++++++++++++++++++++++++++++++---\n hook.h                         | 104 ++++++++-\n refs.c                         |  24 ++-\n repository.c                   |   6 +\n repository.h                   |   6 +\n t/t1800-hook.sh                | 244 ++++++++++++++++++++-\n transport.c                    |  27 ++-\n 12 files changed, 990 insertions(+), 62 deletions(-)\n create mode 100644 Documentation/config/hook.adoc\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536354","messageId":"20260218222352.55393-7-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 6/8] hook: allow event = \"\" to overwrite previous values","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:50Z","receivedAt":"2026-02-18T22:24:34Z","isPatch":true,"body":"Add the ability for empty events to clear previously set multivalue\nvariables, so the newly added \"hook.*.event\" behave like the other\nmultivalued keys.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 +++-\n hook.c                         | 29 +++++++++++++++++++----------\n t/t1800-hook.sh                | 12 ++++++++++++\n 3 files changed, 34 insertions(+), 11 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 0cda4745a6..64e845a260 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -12,7 +12,9 @@ hook.<name>.event::\n \tlinkgit:githooks[5] for a complete list of hook events.) On the\n \tspecified event, the associated `hook.<name>.command` is executed.\n \tThis is a multi-valued key. To run `hook.<name>` on multiple\n-\tevents, specify the key more than once. See linkgit:git-hook[1].\n+\tevents, specify the key more than once. An empty value resets\n+\tthe list of events, clearing any previously defined events for\n+\t`hook.<name>`. See linkgit:git-hook[1].\n \n hook.<name>.enabled::\n \tWhether the hook `hook.<name>` is enabled. Defaults to `true`.\ndiff --git a/hook.c b/hook.c\nindex 35c24bf33d..fee0a7ab4f 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -147,18 +147,27 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \thook_name = xmemdupz(name, name_len);\n \n \tif (!strcmp(subkey, \"event\")) {\n-\t\tstruct string_list *hooks =\n-\t\t\tstrmap_get(&data->event_hooks, value);\n+\t\tif (!*value) {\n+\t\t\t/* Empty values reset previous events for this hook. */\n+\t\t\tstruct hashmap_iter iter;\n+\t\t\tstruct strmap_entry *e;\n+\n+\t\t\tstrmap_for_each_entry(&data->event_hooks, &iter, e)\n+\t\t\t\tunsorted_string_list_remove(e->value, hook_name);\n+\t\t} else {\n+\t\t\tstruct string_list *hooks =\n+\t\t\t\tstrmap_get(&data->event_hooks, value);\n+\n+\t\t\tif (!hooks) {\n+\t\t\t\thooks = xcalloc(1, sizeof(*hooks));\n+\t\t\t\tstring_list_init_dup(hooks);\n+\t\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n+\t\t\t}\n \n-\t\tif (!hooks) {\n-\t\t\thooks = xcalloc(1, sizeof(*hooks));\n-\t\t\tstring_list_init_dup(hooks);\n-\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n+\t\t\t/* Re-insert if necessary to preserve last-seen order. */\n+\t\t\tunsorted_string_list_remove(hooks, hook_name);\n+\t\t\tstring_list_append(hooks, hook_name);\n \t\t}\n-\n-\t\t/* Re-insert if necessary to preserve last-seen order. */\n-\t\tunsorted_string_list_remove(hooks, hook_name);\n-\t\tstring_list_append(hooks, hook_name);\n \t} else if (!strcmp(subkey, \"command\")) {\n \t\t/* Store command overwriting the old value */\n \t\tchar *old = strmap_put(&data->commands, hook_name,\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 9797802735..fb6bc554b9 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -226,6 +226,18 @@ test_expect_success 'git hook list reorders on duplicate event declarations' '\n \ttest_cmp expected actual\n '\n \n+test_expect_success 'git hook list: empty event value resets events' '\n+\tsetup_hooks &&\n+\n+\t# ghi is configured for pre-commit; reset it with an empty value\n+\ttest_config hook.ghi.event \"\" --add &&\n+\n+\t# only def should remain for pre-commit\n+\techo \"def\" >expected &&\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n test_expect_success 'hook can be configured for multiple events' '\n \tsetup_hooks &&\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536355","messageId":"20260218222352.55393-5-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 4/8] hook: include hooks from the config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:48Z","receivedAt":"2026-02-18T22:24:34Z","isPatch":true,"body":"Teach the hook.[hc] library to parse configs to populate the list of\nhooks to run for a given event.\n\nMultiple commands can be specified for a given hook by providing\n\"hook.<friendly-name>.command = <path-to-hook>\" and\n\"hook.<friendly-name>.event = <hook-event>\" lines.\n\nHooks will be started in config order of the \"hook.<name>.event\"\nlines and will be run sequentially (.jobs == 1) like before.\nRunning the hooks in parallel will be enabled in a future patch.\n\nThe \"traditional\" hook from the hookdir is run last, if present.\n\nA strmap cache is added to struct repository to avoid re-reading\nthe configs on each rook run. This is useful for hooks like the\nref-transaction which gets executed multiple times per process.\n\nExamples:\n\n  $ git config --get-regexp \"^hook\\.\"\n  hook.bar.command=~/bar.sh\n  hook.bar.event=pre-commit\n\n  # Will run ~/bar.sh, then .git/hooks/pre-commit\n  $ git hook run pre-commit\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  15 +++\n Documentation/git-hook.adoc    | 128 ++++++++++++++++++++-\n builtin/hook.c                 |   3 +\n hook.c                         | 197 ++++++++++++++++++++++++++++++++-\n hook.h                         |  14 ++-\n repository.c                   |   6 +\n repository.h                   |   6 +\n t/t1800-hook.sh                | 149 ++++++++++++++++++++++++-\n 8 files changed, 513 insertions(+), 5 deletions(-)\n create mode 100644 Documentation/config/hook.adoc\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nnew file mode 100644\nindex 0000000000..9faafe3016\n--- /dev/null\n+++ b/Documentation/config/hook.adoc\n@@ -0,0 +1,15 @@\n+hook.<name>.command::\n+\tThe command to execute for `hook.<name>`. `<name>` is a unique\n+\t\"friendly\" name that identifies this hook. (The hook events that\n+\ttrigger the command are configured with `hook.<name>.event`.) The\n+\tvalue can be an executable path or a shell oneliner. If more than\n+\tone value is specified for the same `<name>`, only the last value\n+\tparsed is used. See linkgit:git-hook[1].\n+\n+hook.<name>.event::\n+\tThe hook events that trigger `hook.<name>`. The value is the name\n+\tof a hook event, like \"pre-commit\" or \"update\". (See\n+\tlinkgit:githooks[5] for a complete list of hook events.) On the\n+\tspecified event, the associated `hook.<name>.command` is executed.\n+\tThis is a multi-valued key. To run `hook.<name>` on multiple\n+\tevents, specify the key more than once. See linkgit:git-hook[1].\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex eb0ffcb8a9..7e4259e4f0 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -17,12 +17,96 @@ DESCRIPTION\n A command interface for running git hooks (see linkgit:githooks[5]),\n for use by other scripted git commands.\n \n+This command parses the default configuration files for sets of configs like\n+so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+\n+In this example, `[hook \"linter\"]` represents one script - `~/bin/linter\n+--cpp20` - which can be shared by many repos, and even by many hook events, if\n+appropriate.\n+\n+To add an unrelated hook which runs on a different event, for example a\n+spell-checker for your commit messages, you would write a configuration like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+  [hook \"spellcheck\"]\n+    event = commit-msg\n+    command = ~/bin/spellchecker\n+\n+With this config, when you run 'git commit', first `~/bin/linter --cpp20` will\n+have a chance to check your files to be committed (during the `pre-commit` hook\n+event`), and then `~/bin/spellchecker` will have a chance to check your commit\n+message (during the `commit-msg` hook event).\n+\n+Commands are run in the order Git encounters their associated\n+`hook.<name>.event` configs during the configuration parse (see\n+linkgit:git-config[1]). Although multiple `hook.linter.event` configs can be\n+added, only one `hook.linter.command` event is valid - Git uses \"last-one-wins\"\n+to determine which command to run.\n+\n+So if you wanted your linter to run when you commit as well as when you push,\n+you would configure it like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    event = pre-push\n+    command = ~/bin/linter --cpp20\n+\n+With this config, `~/bin/linter --cpp20` would be run by Git before a commit is\n+generated (during `pre-commit`) as well as before a push is performed (during\n+`pre-push`).\n+\n+And if you wanted to run your linter as well as a secret-leak detector during\n+only the \"pre-commit\" hook event, you would configure it instead like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+  [hook \"no-leaks\"]\n+    event = pre-commit\n+    command = ~/bin/leak-detector\n+\n+With this config, before a commit is generated (during `pre-commit`), Git would\n+first start `~/bin/linter --cpp20` and second start `~/bin/leak-detector`. It\n+would evaluate the output of each when deciding whether to proceed with the\n+commit.\n+\n+For a full list of hook events which you can set your `hook.<name>.event` to,\n+and how hooks are invoked during those events, see linkgit:githooks[5].\n+\n+Git will ignore any `hook.<name>.event` that specifies an event it doesn't\n+recognize. This is intended so that tools which wrap Git can use the hook\n+infrastructure to run their own hooks; see \"WRAPPERS\" for more guidance.\n+\n+In general, when instructions suggest adding a script to\n+`.git/hooks/<hook-event>`, you can specify it in the config instead by running:\n+\n+----\n+git config set hook.<some-name>.command <path-to-script>\n+git config set --append hook.<some-name>.event <hook-event>\n+----\n+\n+This way you can share the script between multiple repos. That is, `cp\n+~/my-script.sh ~/project/.git/hooks/pre-commit` would become:\n+\n+----\n+git config set hook.my-script.command ~/my-script.sh\n+git config set --append hook.my-script.event pre-commit\n+----\n+\n SUBCOMMANDS\n -----------\n \n run::\n-\tRun the `<hook-name>` hook. See linkgit:githooks[5] for\n-\tsupported hook names.\n+\tRuns hooks configured for `<hook-name>`, in the order they are\n+\tdiscovered during the config parse. The default `<hook-name>` from\n+\tthe hookdir is run last. See linkgit:githooks[5] for supported\n+\thook names.\n +\n \n Any positional arguments to the hook should be passed after a\n@@ -46,6 +130,46 @@ OPTIONS\n \ttools that want to do a blind one-shot run of a hook that may\n \tor may not be present.\n \n+WRAPPERS\n+--------\n+\n+`git hook run` has been designed to make it easy for tools which wrap Git to\n+configure and execute hooks using the Git hook infrastructure. It is possible to\n+provide arguments and stdin via the command line, as well as specifying parallel\n+or series execution if the user has provided multiple hooks.\n+\n+Assuming your wrapper wants to support a hook named \"mywrapper-start-tests\", you\n+can have your users specify their hooks like so:\n+\n+  [hook \"setup-test-dashboard\"]\n+    event = mywrapper-start-tests\n+    command = ~/mywrapper/setup-dashboard.py --tap\n+\n+Then, in your 'mywrapper' tool, you can invoke any users' configured hooks by\n+running:\n+\n+----\n+git hook run mywrapper-start-tests \\\n+  # providing something to stdin\n+  --stdin some-tempfile-123 \\\n+  # execute hooks in serial\n+  # plus some arguments of your own...\n+  -- \\\n+  --testname bar \\\n+  baz\n+----\n+\n+Take care to name your wrapper's hook events in a way which is unlikely to\n+overlap with Git's native hooks (see linkgit:githooks[5]) - a hook event named\n+`mywrappertool-validate-commit` is much less likely to be added to native Git\n+than a hook event named `validate-commit`. If Git begins to use a hook event\n+named the same thing as your wrapper hook, it may invoke your users' hooks in\n+unintended and unsupported ways.\n+\n+CONFIGURATION\n+-------------\n+include::config/hook.adoc[]\n+\n SEE ALSO\n --------\n linkgit:githooks[5]\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 51660c4941..e151bb2cd1 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -68,6 +68,9 @@ static int list(int argc, const char **argv, const char *prefix,\n \t\tcase HOOK_TRADITIONAL:\n \t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n \t\t\tbreak;\n+\t\tcase HOOK_CONFIGURED:\n+\t\t\tprintf(\"%s\\n\", h->u.configured.friendly_name);\n+\t\t\tbreak;\n \t\tdefault:\n \t\t\tBUG(\"unknown hook kind\");\n \t\t}\ndiff --git a/hook.c b/hook.c\nindex 979a97a538..8a9b405f76 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -4,9 +4,11 @@\n #include \"gettext.h\"\n #include \"hook.h\"\n #include \"path.h\"\n+#include \"parse.h\"\n #include \"run-command.h\"\n #include \"config.h\"\n #include \"strbuf.h\"\n+#include \"strmap.h\"\n #include \"environment.h\"\n #include \"setup.h\"\n \n@@ -54,6 +56,10 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n \n \tif (h->kind == HOOK_TRADITIONAL)\n \t\tfree((void *)h->u.traditional.path);\n+\telse if (h->kind == HOOK_CONFIGURED) {\n+\t\tfree((void *)h->u.configured.friendly_name);\n+\t\tfree((void *)h->u.configured.command);\n+\t}\n \n \tif (cb_data_free)\n \t\tcb_data_free(h->feed_pipe_cb_data);\n@@ -101,6 +107,187 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n \tstring_list_append(hook_list, hook_path)->util = h;\n }\n \n+static void unsorted_string_list_remove(struct string_list *list,\n+\t\t\t\t\tconst char *str)\n+{\n+\tstruct string_list_item *item = unsorted_string_list_lookup(list, str);\n+\tif (item)\n+\t\tunsorted_string_list_delete_item(list, item - list->items, 0);\n+}\n+\n+/*\n+ * Callback struct to collect all hook.* keys in a single config pass.\n+ * commands: friendly-name to command map.\n+ * event_hooks: event-name to list of friendly-names map.\n+ * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n+ */\n+struct hook_all_config_cb {\n+\tstruct strmap commands;\n+\tstruct strmap event_hooks;\n+\tstruct string_list disabled_hooks;\n+};\n+\n+/* repo_config() callback that collects all hook.* configuration in one pass. */\n+static int hook_config_lookup_all(const char *key, const char *value,\n+\t\t\t\t  const struct config_context *ctx UNUSED,\n+\t\t\t\t  void *cb_data)\n+{\n+\tstruct hook_all_config_cb *data = cb_data;\n+\tconst char *name, *subkey;\n+\tchar *hook_name;\n+\tsize_t name_len = 0;\n+\n+\tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n+\t\treturn 0;\n+\n+\tif (!value)\n+\t\treturn config_error_nonbool(key);\n+\n+\t/* Extract name, ensuring it is null-terminated. */\n+\thook_name = xmemdupz(name, name_len);\n+\n+\tif (!strcmp(subkey, \"event\")) {\n+\t\tstruct string_list *hooks =\n+\t\t\tstrmap_get(&data->event_hooks, value);\n+\n+\t\tif (!hooks) {\n+\t\t\thooks = xcalloc(1, sizeof(*hooks));\n+\t\t\tstring_list_init_dup(hooks);\n+\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n+\t\t}\n+\n+\t\t/* Re-insert if necessary to preserve last-seen order. */\n+\t\tunsorted_string_list_remove(hooks, hook_name);\n+\t\tstring_list_append(hooks, hook_name);\n+\t} else if (!strcmp(subkey, \"command\")) {\n+\t\t/* Store command overwriting the old value */\n+\t\tchar *old = strmap_put(&data->commands, hook_name,\n+\t\t\t\t       xstrdup(value));\n+\t\tfree(old);\n+\t}\n+\n+\tfree(hook_name);\n+\treturn 0;\n+}\n+\n+/*\n+ * The hook config cache maps each hook event name to a string_list where\n+ * every item's string is the hook's friendly-name and its util pointer is\n+ * the corresponding command string. Both strings are owned by the map.\n+ *\n+ * Disabled hooks and hooks missing a command are already filtered out at\n+ * parse time, so callers can iterate the list directly.\n+ */\n+void hook_cache_clear(struct strmap *cache)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tstrmap_for_each_entry(cache, &iter, e) {\n+\t\tstruct string_list *hooks = e->value;\n+\t\tstring_list_clear(hooks, 1); /* free util (command) pointers */\n+\t\tfree(hooks);\n+\t}\n+\tstrmap_clear(cache, 0);\n+}\n+\n+/* Populate `cache` with the complete hook configuration */\n+static void build_hook_config_map(struct repository *r, struct strmap *cache)\n+{\n+\tstruct hook_all_config_cb cb_data;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tstrmap_init(&cb_data.commands);\n+\tstrmap_init(&cb_data.event_hooks);\n+\tstring_list_init_dup(&cb_data.disabled_hooks);\n+\n+\t/* Parse all configs in one run. */\n+\trepo_config(r, hook_config_lookup_all, &cb_data);\n+\n+\t/* Construct the cache from parsed configs. */\n+\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n+\t\tstruct string_list *hook_names = e->value;\n+\t\tstruct string_list *hooks = xcalloc(1, sizeof(*hooks));\n+\n+\t\tstring_list_init_dup(hooks);\n+\n+\t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n+\t\t\tconst char *hname = hook_names->items[i].string;\n+\t\t\tchar *command;\n+\n+\t\t\tcommand = strmap_get(&cb_data.commands, hname);\n+\t\t\tif (!command)\n+\t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n+\t\t\t\t      \"'hook.%s.event' must be removed;\"\n+\t\t\t\t      \" aborting.\"), hname, hname);\n+\n+\t\t\t/* util stores the command; owned by the cache. */\n+\t\t\tstring_list_append(hooks, hname)->util =\n+\t\t\t\txstrdup(command);\n+\t\t}\n+\n+\t\tstrmap_put(cache, e->key, hooks);\n+\t}\n+\n+\tstrmap_clear(&cb_data.commands, 1);\n+\tstring_list_clear(&cb_data.disabled_hooks, 0);\n+\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n+\t\tstring_list_clear(e->value, 0);\n+\t\tfree(e->value);\n+\t}\n+\tstrmap_clear(&cb_data.event_hooks, 0);\n+}\n+\n+/* Return the hook config map for `r`, populating it first if needed. */\n+static struct strmap *get_hook_config_cache(struct repository *r)\n+{\n+\tstruct strmap *cache = NULL;\n+\n+\tif (r) {\n+\t\t/*\n+\t\t * For in-repo calls, the map is stored in r->hook_config_cache,\n+\t\t * so repeated invocations don't parse the configs, so allocate\n+\t\t * it just once on the first call.\n+\t\t */\n+\t\tif (!r->hook_config_cache) {\n+\t\t\tr->hook_config_cache = xcalloc(1, sizeof(*cache));\n+\t\t\tstrmap_init(r->hook_config_cache);\n+\t\t\tbuild_hook_config_map(r, r->hook_config_cache);\n+\t\t}\n+\t\tcache = r->hook_config_cache;\n+\t}\n+\n+\treturn cache;\n+}\n+\n+static void list_hooks_add_configured(struct repository *r,\n+\t\t\t\t      const char *hookname,\n+\t\t\t\t      struct string_list *list,\n+\t\t\t\t      struct run_hooks_opt *options)\n+{\n+\tstruct strmap *cache = get_hook_config_cache(r);\n+\tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n+\n+\t/* Iterate through configured hooks and initialize internal states */\n+\tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n+\t\tconst char *friendly_name = configured_hooks->items[i].string;\n+\t\tconst char *command = configured_hooks->items[i].util;\n+\t\tstruct hook *hook = xcalloc(1, sizeof(struct hook));\n+\n+\t\tif (options && options->feed_pipe_cb_data_alloc)\n+\t\t\thook->feed_pipe_cb_data =\n+\t\t\t\toptions->feed_pipe_cb_data_alloc(\n+\t\t\t\t\toptions->feed_pipe_ctx);\n+\n+\t\thook->kind = HOOK_CONFIGURED;\n+\t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n+\t\thook->u.configured.command = xstrdup(command);\n+\n+\t\tstring_list_append(list, friendly_name)->util = hook;\n+\t}\n+}\n+\n struct string_list *list_hooks(struct repository *r, const char *hookname,\n \t\t\t       struct run_hooks_opt *options)\n {\n@@ -112,6 +299,9 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,\n \thook_head = xmalloc(sizeof(struct string_list));\n \tstring_list_init_dup(hook_head);\n \n+\t/* Add hooks from the config, e.g. hook.myhook.event = pre-commit */\n+\tlist_hooks_add_configured(r, hookname, hook_head, options);\n+\n \t/* Add the default \"traditional\" hooks from hookdir. */\n \tlist_hooks_add_default(r, hookname, hook_head, options);\n \n@@ -164,8 +354,13 @@ static int pick_next_hook(struct child_process *cp,\n \tcp->dir = hook_cb->options->dir;\n \n \t/* Add hook exec paths or commands */\n-\tif (h->kind == HOOK_TRADITIONAL)\n+\tif (h->kind == HOOK_TRADITIONAL) {\n \t\tstrvec_push(&cp->args, h->u.traditional.path);\n+\t} else if (h->kind == HOOK_CONFIGURED) {\n+\t\t/* to enable oneliners, let config-specified hooks run in shell. */\n+\t\tcp->use_shell = true;\n+\t\tstrvec_push(&cp->args, h->u.configured.command);\n+\t}\n \n \tif (!cp->args.nr)\n \t\tBUG(\"hook must have at least one command or exec path\");\ndiff --git a/hook.h b/hook.h\nindex fea221f87d..e949f5d488 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -3,6 +3,7 @@\n #include \"strvec.h\"\n #include \"run-command.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n \n struct repository;\n \n@@ -10,17 +11,22 @@ struct repository;\n  * Represents a hook command to be run.\n  * Hooks can be:\n  * 1. \"traditional\" (found in the hooks directory)\n- * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n+ * 2. \"configured\" (defined in Git's configuration via hook.<name>.event).\n  * The 'kind' field determines which part of the union 'u' is valid.\n  */\n struct hook {\n \tenum {\n \t\tHOOK_TRADITIONAL,\n+\t\tHOOK_CONFIGURED,\n \t} kind;\n \tunion {\n \t\tstruct {\n \t\t\tconst char *path;\n \t\t} traditional;\n+\t\tstruct {\n+\t\t\tconst char *friendly_name;\n+\t\t\tconst char *command;\n+\t\t} configured;\n \t} u;\n \n \t/**\n@@ -185,6 +191,12 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,\n  */\n void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n \n+/**\n+ * Frees the hook configuration cache stored in `struct repository`.\n+ * Called by repo_clear().\n+ */\n+void hook_cache_clear(struct strmap *cache);\n+\n /**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\ndiff --git a/repository.c b/repository.c\nindex 46a7c99930..f27ab29b0e 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -1,6 +1,7 @@\n #include \"git-compat-util.h\"\n #include \"abspath.h\"\n #include \"repository.h\"\n+#include \"hook.h\"\n #include \"odb.h\"\n #include \"config.h\"\n #include \"object.h\"\n@@ -394,6 +395,11 @@ void repo_clear(struct repository *repo)\n \t\tFREE_AND_NULL(repo->index);\n \t}\n \n+\tif (repo->hook_config_cache) {\n+\t\thook_cache_clear(repo->hook_config_cache);\n+\t\tFREE_AND_NULL(repo->hook_config_cache);\n+\t}\n+\n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\n \t\tFREE_AND_NULL(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 7141237f97..25b2801228 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -157,6 +157,12 @@ struct repository {\n \t/* True if commit-graph has been disabled within this process. */\n \tint commit_graph_disabled;\n \n+\t/*\n+\t * Lazily-populated cache mapping hook event names to configured hooks.\n+\t * NULL until first hook use.\n+\t */\n+\tstruct strmap *hook_config_cache;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 3ec11f1249..f1048a5119 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1,14 +1,31 @@\n #!/bin/sh\n \n-test_description='git-hook command'\n+test_description='git-hook command and config-managed multihooks'\n \n . ./test-lib.sh\n . \"$TEST_DIRECTORY\"/lib-terminal.sh\n \n+setup_hooks () {\n+\ttest_config hook.ghi.command \"/path/ghi\"\n+\ttest_config hook.ghi.event pre-commit --add\n+\ttest_config hook.ghi.event test-hook --add\n+\ttest_config_global hook.def.command \"/path/def\"\n+\ttest_config_global hook.def.event pre-commit --add\n+}\n+\n+setup_hookdir () {\n+\tmkdir .git/hooks\n+\twrite_script .git/hooks/pre-commit <<-EOF\n+\techo \\\"Legacy Hook\\\"\n+\tEOF\n+\ttest_when_finished rm -rf .git/hooks\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n \ttest_expect_code 129 git hook run -h &&\n+\ttest_expect_code 129 git hook list -h &&\n \ttest_expect_code 129 git hook run --unknown 2>err &&\n \ttest_expect_code 129 git hook list &&\n \ttest_expect_code 129 git hook list -h &&\n@@ -35,6 +52,15 @@ test_expect_success 'git hook list: traditional hook from hookdir' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success 'git hook list: configured hook' '\n+\ttest_config hook.myhook.command \"echo Hello\" &&\n+\ttest_config hook.myhook.event test-hook --add &&\n+\n+\techo \"myhook\" >expect &&\n+\tgit hook list test-hook >actual &&\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success 'git hook run: nonexistent hook' '\n \tcat >stderr.expect <<-\\EOF &&\n \terror: cannot find a hook named test-hook\n@@ -172,6 +198,126 @@ test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n \ttest_hook_tty commit -m\"B.new\"\n '\n \n+test_expect_success 'git hook list orders by config order' '\n+\tsetup_hooks &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tdef\n+\tghi\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook list reorders on duplicate event declarations' '\n+\tsetup_hooks &&\n+\n+\t# 'def' is usually configured globally; move it to the end by\n+\t# configuring it locally.\n+\ttest_config hook.def.event \"pre-commit\" --add &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tghi\n+\tdef\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'hook can be configured for multiple events' '\n+\tsetup_hooks &&\n+\n+\t# 'ghi' should be included in both 'pre-commit' and 'test-hook'\n+\tgit hook list pre-commit >actual &&\n+\tgrep \"ghi\" actual &&\n+\tgit hook list test-hook >actual &&\n+\tgrep \"ghi\" actual\n+'\n+\n+test_expect_success 'git hook list shows hooks from the hookdir' '\n+\tsetup_hookdir &&\n+\n+\tcat >expected <<-\\EOF &&\n+\thook from hookdir\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'inline hook definitions execute oneliners' '\n+\ttest_config hook.oneliner.event \"pre-commit\" &&\n+\ttest_config hook.oneliner.command \"echo \\\"Hello World\\\"\" &&\n+\n+\techo \"Hello World\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'inline hook definitions resolve paths' '\n+\twrite_script sample-hook.sh <<-\\EOF &&\n+\techo \\\"Sample Hook\\\"\n+\tEOF\n+\n+\ttest_when_finished \"rm sample-hook.sh\" &&\n+\n+\ttest_config hook.sample-hook.event pre-commit &&\n+\ttest_config hook.sample-hook.command \"\\\"$(pwd)/sample-hook.sh\\\"\" &&\n+\n+\techo \\\"Sample Hook\\\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'hookdir hook included in git hook run' '\n+\tsetup_hookdir &&\n+\n+\techo \\\"Legacy Hook\\\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'stdin to multiple hooks' '\n+\ttest_config hook.stdin-a.event \"test-hook\" &&\n+\ttest_config hook.stdin-a.command \"xargs -P1 -I% echo a%\" &&\n+\ttest_config hook.stdin-b.event \"test-hook\" &&\n+\ttest_config hook.stdin-b.command \"xargs -P1 -I% echo b%\" &&\n+\n+\tcat >input <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\ta1\n+\ta2\n+\ta3\n+\tb1\n+\tb2\n+\tb3\n+\tEOF\n+\n+\tgit hook run --to-stdin=input test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'rejects hooks with no commands configured' '\n+\ttest_config hook.broken.event \"test-hook\" &&\n+\ttest_must_fail git hook list test-hook 2>actual &&\n+\ttest_grep \"hook.broken.command\" actual &&\n+\ttest_must_fail git hook run test-hook 2>actual &&\n+\ttest_grep \"hook.broken.command\" actual\n+'\n+\n test_expect_success 'git hook run a hook with a bad shebang' '\n \ttest_when_finished \"rm -rf bad-hooks\" &&\n \tmkdir bad-hooks &&\n@@ -189,6 +335,7 @@ test_expect_success 'git hook run a hook with a bad shebang' '\n '\n \n test_expect_success 'stdin to hooks' '\n+\tmkdir -p .git/hooks &&\n \twrite_script .git/hooks/test-hook <<-\\EOF &&\n \techo BEGIN stdin\n \tcat\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536356","messageId":"20260218222352.55393-8-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 7/8] hook: allow out-of-repo 'git hook' invocations","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:51Z","receivedAt":"2026-02-18T22:24:37Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSince hooks can now be supplied via the config, and a config can be\npresent without a gitdir via the global and system configs, we can start\nto allow 'git hook run' to occur without a gitdir. This enables us to do\nthings like run sendemail-validate hooks when running 'git send-email'\nfrom a nongit directory.\n\nIt still doesn't make sense to look for hooks in the hookdir in nongit\nrepos, though, as there is no hookdir.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n git.c           |  2 +-\n hook.c          | 30 ++++++++++++++++++++++++++++--\n t/t1800-hook.sh | 16 +++++++++++-----\n 3 files changed, 40 insertions(+), 8 deletions(-)\n\ndiff --git a/git.c b/git.c\nindex 744cb6527e..6480ff8373 100644\n--- a/git.c\n+++ b/git.c\n@@ -587,7 +587,7 @@ static struct cmd_struct commands[] = {\n \t{ \"hash-object\", cmd_hash_object },\n \t{ \"help\", cmd_help },\n \t{ \"history\", cmd_history, RUN_SETUP },\n-\t{ \"hook\", cmd_hook, RUN_SETUP },\n+\t{ \"hook\", cmd_hook, RUN_SETUP_GENTLY },\n \t{ \"index-pack\", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\ndiff --git a/hook.c b/hook.c\nindex fee0a7ab4f..2c8252b2c4 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -18,6 +18,9 @@ const char *find_hook(struct repository *r, const char *name)\n \n \tint found_hook;\n \n+\tif (!r || !r->gitdir)\n+\t\treturn NULL;\n+\n \trepo_git_path_replace(r, &path, \"hooks/%s\", name);\n \tfound_hook = access(path.buf, X_OK) >= 0;\n #ifdef STRIP_EXTENSION\n@@ -268,12 +271,18 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_clear(&cb_data.event_hooks, 0);\n }\n \n-/* Return the hook config map for `r`, populating it first if needed. */\n+/*\n+ * Return the hook config map for `r`, populating it first if needed.\n+ *\n+ * Out-of-repo calls (r->gitdir == NULL) allocate and return a temporary\n+ * cache map; the caller is responsible for freeing it with\n+ * hook_cache_clear() + free().\n+ */\n static struct strmap *get_hook_config_cache(struct repository *r)\n {\n \tstruct strmap *cache = NULL;\n \n-\tif (r) {\n+\tif (r && r->gitdir) {\n \t\t/*\n \t\t * For in-repo calls, the map is stored in r->hook_config_cache,\n \t\t * so repeated invocations don't parse the configs, so allocate\n@@ -285,6 +294,14 @@ static struct strmap *get_hook_config_cache(struct repository *r)\n \t\t\tbuild_hook_config_map(r, r->hook_config_cache);\n \t\t}\n \t\tcache = r->hook_config_cache;\n+\t} else {\n+\t\t/*\n+\t\t * Out-of-repo calls (no gitdir) allocate and return a temporary\n+\t\t * map cache which gets free'd immediately by the caller.\n+\t\t */\n+\t\tcache = xcalloc(1, sizeof(*cache));\n+\t\tstrmap_init(cache);\n+\t\tbuild_hook_config_map(r, cache);\n \t}\n \n \treturn cache;\n@@ -315,6 +332,15 @@ static void list_hooks_add_configured(struct repository *r,\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n+\n+\t/*\n+\t * Cleanup temporary cache for out-of-repo calls since they can't be\n+\t * stored persistently. Next out-of-repo calls will have to re-parse.\n+\t */\n+\tif (!r || !r->gitdir) {\n+\t\thook_cache_clear(cache);\n+\t\tfree(cache);\n+\t}\n }\n \n struct string_list *list_hooks(struct repository *r, const char *hookname,\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex fb6bc554b9..e58151e8f8 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -131,12 +131,18 @@ test_expect_success 'git hook run -- pass arguments' '\n \ttest_cmp expect actual\n '\n \n-test_expect_success 'git hook run -- out-of-repo runs excluded' '\n-\ttest_hook test-hook <<-EOF &&\n-\techo Test hook\n-\tEOF\n+test_expect_success 'git hook run: out-of-repo runs execute global hooks' '\n+\ttest_config_global hook.global-hook.event test-hook --add &&\n+\ttest_config_global hook.global-hook.command \"echo no repo no problems\" --add &&\n \n-\tnongit test_must_fail git hook run test-hook\n+\techo \"global-hook\" >expect &&\n+\tnongit git hook list test-hook >actual &&\n+\ttest_cmp expect actual &&\n+\n+\techo \"no repo no problems\" >expect &&\n+\n+\tnongit git hook run test-hook 2>actual &&\n+\ttest_cmp expect actual\n '\n \n test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536357","messageId":"20260218222352.55393-9-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"[PATCH v2 8/8] hook: add -z option to \"git hook list\"","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-18T22:23:52Z","receivedAt":"2026-02-18T22:24:39Z","isPatch":true,"body":"Add a NUL-terminate mode to git hook list, just in case hooks are\nconfigured with weird characters like newlines in their names.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  8 ++++++--\n builtin/hook.c              |  9 ++++++---\n t/t1800-hook.sh             | 13 +++++++++++++\n 3 files changed, 25 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 7e4259e4f0..12d2701b52 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -9,7 +9,7 @@ SYNOPSIS\n --------\n [verse]\n 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n-'git hook' list <hook-name>\n+'git hook' list [-z] <hook-name>\n \n DESCRIPTION\n -----------\n@@ -113,9 +113,10 @@ Any positional arguments to the hook should be passed after a\n mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See\n linkgit:githooks[5] for arguments hooks might expect (if any).\n \n-list::\n+list [-z]::\n \tPrint a list of hooks which will be run on `<hook-name>` event. If no\n \thooks are configured for that event, print a warning and return 1.\n+\tUse `-z` to terminate output lines with NUL instead of newlines.\n \n OPTIONS\n -------\n@@ -130,6 +131,9 @@ OPTIONS\n \ttools that want to do a blind one-shot run of a hook that may\n \tor may not be present.\n \n+-z::\n+\tTerminate \"list\" output lines with NUL instead of newlines.\n+\n WRAPPERS\n --------\n \ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex e151bb2cd1..83020dfb4f 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -11,7 +11,7 @@\n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n-\tN_(\"git hook list <hook-name>\")\n+\tN_(\"git hook list [-z] <hook-name>\")\n \n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n@@ -34,9 +34,12 @@ static int list(int argc, const char **argv, const char *prefix,\n \tstruct string_list *head;\n \tstruct string_list_item *item;\n \tconst char *hookname = NULL;\n+\tint line_terminator = '\\n';\n \tint ret = 0;\n \n \tstruct option list_options[] = {\n+\t\tOPT_SET_INT('z', NULL, &line_terminator,\n+\t\t\t    N_(\"use NUL as line terminator\"), '\\0'),\n \t\tOPT_END(),\n \t};\n \n@@ -66,10 +69,10 @@ static int list(int argc, const char **argv, const char *prefix,\n \n \t\tswitch (h->kind) {\n \t\tcase HOOK_TRADITIONAL:\n-\t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n+\t\t\tprintf(\"%s%c\", _(\"hook from hookdir\"), line_terminator);\n \t\t\tbreak;\n \t\tcase HOOK_CONFIGURED:\n-\t\t\tprintf(\"%s\\n\", h->u.configured.friendly_name);\n+\t\t\tprintf(\"%s%c\", h->u.configured.friendly_name, line_terminator);\n \t\t\tbreak;\n \t\tdefault:\n \t\t\tBUG(\"unknown hook kind\");\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex e58151e8f8..b1583e9ef9 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -61,6 +61,19 @@ test_expect_success 'git hook list: configured hook' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success 'git hook list: -z shows NUL-terminated output' '\n+\ttest_hook test-hook <<-EOF &&\n+\techo Test hook\n+\tEOF\n+\ttest_config hook.myhook.command \"echo Hello\" &&\n+\ttest_config hook.myhook.event test-hook --add &&\n+\n+\tprintf \"myhookQhook from hookdirQ\" >expect &&\n+\tgit hook list -z test-hook >actual.raw &&\n+\tnul_to_q <actual.raw >actual &&\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success 'git hook run: nonexistent hook' '\n \tcat >stderr.expect <<-\\EOF &&\n \terror: cannot find a hook named test-hook\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"536443","messageId":"xmqqcy202z9p.fsf@gitster.g","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-19T21:34:42Z","receivedAt":"2026-02-19T21:34:44Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> v2 addresses all feedback received in v1.\n>\n> This series adds a new feature: the ability to specify commands to run\n> for hook events via config entries (including shell commands).\n>\n> So instead of dropping a shell script or a custom program in .git/hooks\n> you can now tell git via config files to run a program or shell script\n> (can be specified directly in the config) when you run hook \"foo\".\n>\n> This also means you can setup global hooks to run in multiple repos via\n> global configs and there's an option to disable them if necessary.\n>\n> For simplicity, because this series is becoming rather big, hooks are\n> still executed sequentially (.jobs == 1). Parallel execution is added\n> in another patch series.\n>\n> This is based on the latest v8 hooks-conversion series [1] which has\n> not yet landed in next or master.\n\nThanks for a reroll.  I was a bit concerned to allow configuration\nfiles to speicify hooks as it would reduce discoverability (i.e.,\ntoday, we can \"ls .git/hooks/\" to see everything that potentially\nwill be triggered, but now we need to be aware of what your sysadmin\ndropped in /etc/gitconfig to get the whole picture.  \"git hook list\"\nwould solve that issue nicely.\n\nBy the way, the discussion thread for the base topic hasn't seen\nany activity in the latest round after it updated for the comments\nreceived in the previous round.  It appears that it is ready to move\nforward?  Let's mark it for 'next' in that case.\n\nThanks.\n"},{"id":"536444","messageId":"xmqq4inc2yom.fsf@gitster.g","threadId":"64918","inReplyTo":"20260218222352.55393-2-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-19T21:47:21Z","receivedAt":"2026-02-19T21:47:24Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> +\t/*\n> +\t * Ensure cb_data copy and free functions are either provided together,\n> +\t * or neither one is provided.\n> +\t */\n> +\tif ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||\n> +\t    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))\n> +\t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n\nA way to avoid being repetitious may be to say\n\n\tif (!!options->feed_pipe_cb_data_alloc != !!options->feed_pipe_cb_data_free)\n\nor\n\n\tif (!!options->feed_pipe_cb_data_alloc ^ !!options->feed_pipe_cb_data_free)\n\nbut it (especially the latter) might be a bit too cute for some\npeople's taste.\n\n> diff --git a/refs.c b/refs.c\n> index d432cfb78f..3c124fab29 100644\n> --- a/refs.c\n> +++ b/refs.c\n> @@ -2514,24 +2514,38 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n>  \treturn 0; /* no more input to feed */\n>  }\n>  \n> +static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED)\n> +{\n> +\tstruct transaction_feed_cb_data *data = xmalloc(sizeof(*data));\n> +\tstrbuf_init(&data->buf, 0);\n> +\tdata->index = 0;\n> +\treturn data;\n> +}\n> +\n> +static void transaction_feed_cb_data_free(void *data)\n> +{\n> +\tstruct transaction_feed_cb_data *d = data;\n> +\tif (!d)\n> +\t\treturn;\n> +\tstrbuf_release(&d->buf);\n> +\tfree(d);\n> +}\n> +\n>  static int run_transaction_hook(struct ref_transaction *transaction,\n>  \t\t\t\tconst char *state)\n>  {\n>  \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> -\tstruct transaction_feed_cb_data feed_ctx = { 0 };\n>  \tint ret = 0;\n>  \n>  \tstrvec_push(&opt.args, state);\n>  \n>  \topt.feed_pipe = transaction_hook_feed_stdin;\n>  \topt.feed_pipe_ctx = transaction;\n> -\topt.feed_pipe_cb_data = &feed_ctx;\n\nSo, as the proposed log message promised, our stack lost the\ncallback data, and instead uses an allocated piece of memory\npointed by the same opt.feed_pipe_cb_data member as before.\n\n> -\n> -\tstrbuf_init(&feed_ctx.buf, 0);\n> +\topt.feed_pipe_cb_data_alloc = transaction_feed_cb_data_alloc;\n> +\topt.feed_pipe_cb_data_free = transaction_feed_cb_data_free;\n>  \n>  \tret = run_hooks_opt(transaction->ref_store->repo, \"reference-transaction\", &opt);\n>  \n> -\tstrbuf_release(&feed_ctx.buf);\n\nAs the run_hooks_opt() machinery internally manages the\nfeed_pipe_cb_data, starting from the call to the custom alloc\nfunction and concliding with the call to the custom free function,\nthis function no longer need to manage resources.  Instead, the\ncustom alloc/free functions take care of all the associated\nresources, like the feed_ctx.buf strbuf, as we have seen earlier.\n\nNice.\n\n> diff --git a/transport.c b/transport.c\n> index ecf9e1f21c..1581aa0886 100644\n> --- a/transport.c\n> +++ b/transport.c\n> @@ -1357,21 +1357,36 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n>  \treturn 0;\n>  }\n>  \n> +static void *pre_push_hook_data_alloc(void *feed_pipe_ctx)\n> +{\n> +...\n> +}\n> +\n> +static void pre_push_hook_data_free(void *data)\n> +{\n> +...\n> +}\n> +\n>  static int run_pre_push_hook(struct transport *transport,\n>  \t\t\t     struct ref *remote_refs)\n>  {\n>  \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> -\tstruct feed_pre_push_hook_data data;\n>  \tint ret = 0;\n>  \n>  \tstrvec_push(&opt.args, transport->remote->name);\n>  \tstrvec_push(&opt.args, transport->url);\n>  \n> -\tstrbuf_init(&data.buf, 0);\n> -\tdata.refs = remote_refs;\n> -\n>  \topt.feed_pipe = pre_push_hook_feed_stdin;\n> -\topt.feed_pipe_cb_data = &data;\n> +\topt.feed_pipe_ctx = remote_refs;\n> +\topt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;\n> +\topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n\nThe same structure is used here.  Nice.\n"},{"id":"536445","messageId":"xmqqy0ko1iri.fsf@gitster.g","threadId":"64918","inReplyTo":"20260218222352.55393-5-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 4/8] hook: include hooks from the config","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-19T22:16:33Z","receivedAt":"2026-02-19T22:16:36Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> Examples:\n>\n>   $ git config --get-regexp \"^hook\\.\"\n>   hook.bar.command=~/bar.sh\n>   hook.bar.event=pre-commit\n\nThis is all good when you know where you defined your pre-commit\nhook, but you would want to know in which scope the configuration is\nmade, wouldn't you, especially when you are trying to diagnose why\nsome command that you do not necessarily recognise when you run a\nGit command?\n\n> @@ -10,17 +11,22 @@ struct repository;\n>   * Represents a hook command to be run.\n>   * Hooks can be:\n>   * 1. \"traditional\" (found in the hooks directory)\n> - * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n> + * 2. \"configured\" (defined in Git's configuration via hook.<name>.event).\n\nWouldn't it be easier to understand if we do \"<name>\" -> \"<friendly-name>\"\nto match the member name used in the struct below?\n\n>   * The 'kind' field determines which part of the union 'u' is valid.\n>   */\n>  struct hook {\n>  \tenum {\n>  \t\tHOOK_TRADITIONAL,\n> +\t\tHOOK_CONFIGURED,\n>  \t} kind;\n>  \tunion {\n>  \t\tstruct {\n>  \t\t\tconst char *path;\n>  \t\t} traditional;\n> +\t\tstruct {\n> +\t\t\tconst char *friendly_name;\n> +\t\t\tconst char *command;\n\nIf we wanted to report which config scope defined a particular hook\nwe need to record where the configured hook came from in this\nstruct, right?\n\n> +\t\t} configured;\n>  \t} u;\n\n> +\tif (repo->hook_config_cache) {\n> +\t\thook_cache_clear(repo->hook_config_cache);\n> +\t\tFREE_AND_NULL(repo->hook_config_cache);\n> +\t}\n\nIt is a minor point, but after applying the hole series, there are\nonly two calls to hook_cachje_clear(X) and they both are followed by\nfree(X) or FREE_AND_NULL(X).  I wonder if we simply want\nhook_cache_free() instead of _clear()?\n\n"},{"id":"536508","messageId":"87o6ljy4z5.fsf@gentoo.mail-host-address-is-not-set","threadId":"64918","inReplyTo":"xmqqy0ko1iri.fsf@gitster.g","subject":"Re: [PATCH v2 4/8] hook: include hooks from the config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T12:27:58Z","receivedAt":"2026-02-20T12:28:19Z","isPatch":true,"body":"On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> Examples:\n>>\n>>   $ git config --get-regexp \"^hook\\.\"\n>>   hook.bar.command=~/bar.sh\n>>   hook.bar.event=pre-commit\n>\n> This is all good when you know where you defined your pre-commit\n> hook, but you would want to know in which scope the configuration is\n> made, wouldn't you, especially when you are trying to diagnose why\n> some command that you do not necessarily recognise when you run a\n> Git command?\n\nYes, I assumed the user knows where his hooks are defined. :)\nObviously this is not the case.\n\nThis also applies to the \"git hook list\" command added later in this\nseries, we might want to tell users where the hooks come from there as\nwell.\n\n>> @@ -10,17 +11,22 @@ struct repository;\n>>   * Represents a hook command to be run.\n>>   * Hooks can be:\n>>   * 1. \"traditional\" (found in the hooks directory)\n>> - * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n>> + * 2. \"configured\" (defined in Git's configuration via hook.<name>.event).\n>\n> Wouldn't it be easier to understand if we do \"<name>\" -> \"<friendly-name>\"\n> to match the member name used in the struct below?\n\nYes, name here referes to \"friendly-name\" :)\n\nI'll make this consestent in v3 across the patch series, to use\nfriendly-name.\n\n>>   * The 'kind' field determines which part of the union 'u' is valid.\n>>   */\n>>  struct hook {\n>>  \tenum {\n>>  \t\tHOOK_TRADITIONAL,\n>> +\t\tHOOK_CONFIGURED,\n>>  \t} kind;\n>>  \tunion {\n>>  \t\tstruct {\n>>  \t\t\tconst char *path;\n>>  \t\t} traditional;\n>> +\t\tstruct {\n>> +\t\t\tconst char *friendly_name;\n>> +\t\t\tconst char *command;\n>\n> If we wanted to report which config scope defined a particular hook\n> we need to record where the configured hook came from in this\n> struct, right?\n\nYes, this is the place.\n\nWe can do it when parsing the configs (in v2, at Patrick's suggestion we\njust parse once then cache & reuse if possible). \n\n>\n>> +\t\t} configured;\n>>  \t} u;\n>\n>> +\tif (repo->hook_config_cache) {\n>> +\t\thook_cache_clear(repo->hook_config_cache);\n>> +\t\tFREE_AND_NULL(repo->hook_config_cache);\n>> +\t}\n>\n> It is a minor point, but after applying the hole series, there are\n> only two calls to hook_cachje_clear(X) and they both are followed by\n> free(X) or FREE_AND_NULL(X).  I wonder if we simply want\n> hook_cache_free() instead of _clear()?\n\nYes, we can do this.\n\nI think I have another place where I can apply this pattern in addition\nto this, to avoid calling both clear() + free() afterwards.\n\nWill make all these uses consistent on a single _free() in v3.\n\nThanks,\nAdrian\n"},{"id":"536510","messageId":"87ldgny4mm.fsf@collabora.com","threadId":"64918","inReplyTo":"xmqq4inc2yom.fsf@gitster.g","subject":"Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T12:35:29Z","receivedAt":"2026-02-20T12:35:46Z","isPatch":true,"body":"On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> +\t/*\n>> +\t * Ensure cb_data copy and free functions are either provided together,\n>> +\t * or neither one is provided.\n>> +\t */\n>> +\tif ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||\n>> +\t    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))\n>> +\t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n>\n> A way to avoid being repetitious may be to say\n>\n> \tif (!!options->feed_pipe_cb_data_alloc != !!options->feed_pipe_cb_data_free)\n>\n> or\n>\n> \tif (!!options->feed_pipe_cb_data_alloc ^ !!options->feed_pipe_cb_data_free)\n>\n> but it (especially the latter) might be a bit too cute for some\n> people's taste.\n\nThanks for suggesting this. I was actually thinking of ways to simplify\nthis and the double negation didn't occur to me. Will do in v3.\n"},{"id":"536511","messageId":"87ikbry4ay.fsf@collabora.com","threadId":"64918","inReplyTo":"xmqq4inc2yom.fsf@gitster.g","subject":"Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T12:42:29Z","receivedAt":"2026-02-20T12:42:55Z","isPatch":true,"body":"On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n<snip>\n>> -\n>> -\tstrbuf_init(&feed_ctx.buf, 0);\n>> +\topt.feed_pipe_cb_data_alloc = transaction_feed_cb_data_alloc;\n>> +\topt.feed_pipe_cb_data_free = transaction_feed_cb_data_free;\n>>  \n>>  \tret = run_hooks_opt(transaction->ref_store->repo, \"reference-transaction\", &opt);\n>>  \n>> -\tstrbuf_release(&feed_ctx.buf);\n>\n> As the run_hooks_opt() machinery internally manages the\n> feed_pipe_cb_data, starting from the call to the custom alloc\n> function and concliding with the call to the custom free function,\n> this function no longer need to manage resources.  Instead, the\n> custom alloc/free functions take care of all the associated\n> resources, like the feed_ctx.buf strbuf, as we have seen earlier.\n>\n> Nice.\n\nExactly, yes.\n\nThis reminds me: I need to verify the header files and the APIs to\nremove/update any comments about callers having to manage the these\nresources.\n\nWill do in v3.\n"},{"id":"536512","messageId":"aZhXh6aqlY0VMgEG@pks.im","threadId":"64918","inReplyTo":"20260218222352.55393-2-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-20T12:45:59Z","receivedAt":"2026-02-20T12:46:11Z","isPatch":true,"body":"On Thu, Feb 19, 2026 at 12:23:45AM +0200, Adrian Ratiu wrote:\n> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n> index 2d2b33d73d..f23772bc56 100644\n> --- a/builtin/receive-pack.c\n> +++ b/builtin/receive-pack.c\n> @@ -901,6 +901,26 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_\n>  \treturn state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */\n>  }\n>  \n> +static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)\n> +{\n> +\tstruct receive_hook_feed_state *init_state = feed_pipe_ctx;\n> +\tstruct receive_hook_feed_state *data = xcalloc(1, sizeof(*data));\n\nTiny nit, not worth addressing: we often use `CALLOC_ARRAY(data, 1)`\nnowadays.\n\n> +\tdata->report = init_state->report;\n> +\tdata->cmd = init_state->cmd;\n> +\tdata->skip_broken = init_state->skip_broken;\n> +\tstrbuf_init(&data->buf, 0);\n> +\treturn data;\n> +}\n\nOkay, this basically creates the new instance by creating a deep copy of\nthe \"template\" structure.\n\nOne could split this up so that we have a \"configuration\" struct and a\n\"data\" struct, where we then provide a pointer to the configuration into\nthe data structure, as only the buffer needs to change between the\nindividual hook invocations. That would avoid some copying around, but\nit feels a bit unnecessary.\n\n> +static void receive_hook_feed_state_free(void *data)\n> +{\n> +\tstruct receive_hook_feed_state *d = data;\n> +\tif (!d)\n> +\t\treturn;\n> +\tstrbuf_release(&d->buf);\n> +\tfree(d);\n> +}\n\nI would expect that the hook interfaces know to not call `free()` in\ncase `alloc()` wasn't called, but I guess it doesn't hurt to be\ndefensive here anyway.\n\n> @@ -908,7 +928,7 @@ static int run_receive_hook(struct command *commands,\n>  {\n>  \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n>  \tstruct command *iter = commands;\n> -\tstruct receive_hook_feed_state feed_state;\n> +\tstruct receive_hook_feed_state feed_init_state = { 0 };\n>  \tstruct async sideband_async;\n>  \tint sideband_async_started = 0;\n>  \tint saved_stderr = -1;\n> @@ -938,16 +958,15 @@ static int run_receive_hook(struct command *commands,\n>  \tprepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started);\n>  \n>  \t/* set up stdin callback */\n> -\tfeed_state.cmd = commands;\n> -\tfeed_state.skip_broken = skip_broken;\n> -\tfeed_state.report = NULL;\n> -\tstrbuf_init(&feed_state.buf, 0);\n> -\topt.feed_pipe_cb_data = &feed_state;\n> +\tfeed_init_state.cmd = commands;\n> +\tfeed_init_state.skip_broken = skip_broken;\n\nAs far as I can see all of the data that we pass to the state struct is\nstatic, so we might just as well initialize it right away, right?\n\n    struct receive_hook_feed_state feed_init_state = {\n        .cmd = commands,\n        .skip_broken = skip_broken,\n        .buf = STRBUF_INIT,\n    };\n\n> diff --git a/hook.c b/hook.c\n> index cde7198412..83ff658866 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -133,6 +133,8 @@ static int notify_hook_finished(int result,\n>  \n>  static void run_hooks_opt_clear(struct run_hooks_opt *options)\n>  {\n> +\tif (options->feed_pipe_cb_data_free)\n> +\t\toptions->feed_pipe_cb_data_free(options->feed_pipe_cb_data);\n>  \tstrvec_clear(&options->env);\n>  \tstrvec_clear(&options->args);\n>  }\n\nI guess this here would be where we could skip `free` in case the data\nwasn't even allocated. But as I said further up, I don't care all that\nmuch.\n\n> diff --git a/hook.h b/hook.h\n> index 20eb56fd63..a6bdc6f90f 100644\n> --- a/hook.h\n> +++ b/hook.h\n> @@ -5,6 +5,9 @@\n>  \n>  struct repository;\n>  \n> +typedef void (*cb_data_free_fn)(void *data);\n> +typedef void *(*cb_data_alloc_fn)(void *init_ctx);\n> +\n>  struct run_hooks_opt\n>  {\n>  \t/* Environment vars to be set for each hook */\n\nDo we maybe want to scope these function typedefs to the hooks subsystem\nby calling the `hook_data_free_fn` and `hook_data_alloc_fn`, or\nsomething like that?\n\nPatrick\n"},{"id":"536513","messageId":"aZhXkDtNvcNpXgpJ@pks.im","threadId":"64918","inReplyTo":"20260218222352.55393-3-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-20T12:46:08Z","receivedAt":"2026-02-20T12:46:14Z","isPatch":true,"body":"On Thu, Feb 19, 2026 at 12:23:46AM +0200, Adrian Ratiu wrote:\n> diff --git a/hook.c b/hook.c\n> index 83ff658866..c008a7232d 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -47,9 +47,97 @@ const char *find_hook(struct repository *r, const char *name)\n>  \treturn path.buf;\n>  }\n>  \n> +static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n\nNit: this should be called `hook_free()` as it also frees the struct\nitself.\n\n> +{\n> +\tif (!h)\n> +\t\treturn;\n> +\n> +\tif (h->kind == HOOK_TRADITIONAL)\n> +\t\tfree((void *)h->u.traditional.path);\n> +\n> +\tif (cb_data_free)\n> +\t\tcb_data_free(h->feed_pipe_cb_data);\n> +\n> +\tfree(h);\n> +}\n> +\n> +static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n> +{\n> +\tstruct string_list_item *item;\n> +\n> +\tfor_each_string_list_item(item, hooks)\n> +\t\thook_clear(item->util, cb_data_free);\n> +\n> +\tstring_list_clear(hooks, 0);\n\nWe have `string_list_clear_func()` to perform a deep free for you.\n\n> +}\n> +\n> +/* Helper to detect and add default \"traditional\" hooks from the hookdir. */\n> +static void list_hooks_add_default(struct repository *r, const char *hookname,\n> +\t\t\t\t   struct string_list *hook_list,\n> +\t\t\t\t   struct run_hooks_opt *options)\n> +{\n> +\tconst char *hook_path = find_hook(r, hookname);\n> +\tstruct hook *h;\n> +\n> +\tif (!hook_path)\n> +\t\treturn;\n> +\n> +\th = xcalloc(1, sizeof(struct hook));\n\nBetter: CALLOC_ARRAY(h, 1);\n\n> +\t/*\n> +\t * If the hook is to run in a specific dir, a relative path can\n> +\t * become invalid in that dir, so convert to an absolute path.\n> +\t */\n> +\tif (options && options->dir)\n> +\t\thook_path = absolute_path(hook_path);\n> +\n> +\t/* Setup per-hook internal state cb data */\n> +\tif (options && options->feed_pipe_cb_data_alloc)\n> +\t\th->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n> +\n> +\th->kind = HOOK_TRADITIONAL;\n> +\th->u.traditional.path = xstrdup(hook_path);\n> +\n> +\tstring_list_append(hook_list, hook_path)->util = h;\n> +}\n> +\n> +/*\n> + * Provides a list of hook commands to run for the 'hookname' event.\n> + *\n> + * This function consolidates hooks from two sources:\n> + * 1. The config-based hooks (not yet implemented).\n> + * 2. The \"traditional\" hook found in the repository hooks directory\n> + *    (e.g., .git/hooks/pre-commit).\n> + *\n> + * The list is ordered by execution priority.\n> + *\n> + * The caller is responsible for freeing the memory of the returned list\n> + * using string_list_clear() and free().\n> + */\n> +static struct string_list *list_hooks(struct repository *r, const char *hookname,\n> +\t\t\t       struct run_hooks_opt *options)\n> +{\n> +\tstruct string_list *hook_head;\n> +\n> +\tif (!hookname)\n> +\t\tBUG(\"null hookname was provided to hook_list()!\");\n> +\n> +\thook_head = xmalloc(sizeof(struct string_list));\n\nCALLOC_ARRAY(hook_head, 1);\n\n> @@ -85,21 +176,20 @@ static int pick_next_hook(struct child_process *cp,\n>  \tcp->trace2_hook_name = hook_cb->hook_name;\n>  \tcp->dir = hook_cb->options->dir;\n>  \n> -\tstrvec_push(&cp->args, hook_path);\n> +\t/* Add hook exec paths or commands */\n> +\tif (h->kind == HOOK_TRADITIONAL)\n> +\t\tstrvec_push(&cp->args, h->u.traditional.path);\n\nShould we be defensive here and have an `else BUG()`?\n\n> diff --git a/hook.h b/hook.h\n> index a6bdc6f90f..3256d2dddb 100644\n> --- a/hook.h\n> +++ b/hook.h\n> @@ -2,9 +2,41 @@\n>  #define HOOK_H\n>  #include \"strvec.h\"\n>  #include \"run-command.h\"\n> +#include \"string-list.h\"\n>  \n>  struct repository;\n>  \n> +/**\n> + * Represents a hook command to be run.\n> + * Hooks can be:\n> + * 1. \"traditional\" (found in the hooks directory)\n> + * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n> + * The 'kind' field determines which part of the union 'u' is valid.\n> + */\n> +struct hook {\n> +\tenum {\n> +\t\tHOOK_TRADITIONAL,\n> +\t} kind;\n> +\tunion {\n> +\t\tstruct {\n> +\t\t\tconst char *path;\n> +\t\t} traditional;\n> +\t} u;\n> +\n> +\t/**\n> +\t * Opaque data pointer used to keep internal state across callback calls.\n> +\t *\n> +\t * It can be accessed directly via the third hook callback arg:\n> +\t * struct ... *state = pp_task_cb;\n> +\t *\n> +\t * The caller is responsible for managing the memory for this data by\n> +\t * providing alloc/free callbacks to `run_hooks_opt`.\n> +\t *\n> +\t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n> +\t */\n> +\tvoid *feed_pipe_cb_data;\n> +};\n\nMakes sense to wrap all of this in a specific data structure.\n\nPatrick\n"},{"id":"536514","messageId":"aZhXlrWRGDS_Bhvm@pks.im","threadId":"64918","inReplyTo":"20260218222352.55393-4-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 3/8] hook: add \"git hook list\" command","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-20T12:46:14Z","receivedAt":"2026-02-20T12:46:21Z","isPatch":true,"body":"On Thu, Feb 19, 2026 at 12:23:47AM +0200, Adrian Ratiu wrote:\n> diff --git a/builtin/hook.c b/builtin/hook.c\n> index 7afec380d2..51660c4941 100644\n> --- a/builtin/hook.c\n> +++ b/builtin/hook.c\n> @@ -20,6 +24,61 @@ static const char * const builtin_hook_run_usage[] = {\n>  \tNULL\n>  };\n>  \n> +static int list(int argc, const char **argv, const char *prefix,\n> +\t\t struct repository *repo)\n> +{\n> +\tstatic const char *const builtin_hook_list_usage[] = {\n> +\t\tBUILTIN_HOOK_LIST_USAGE,\n> +\t\tNULL\n> +\t};\n> +\tstruct string_list *head;\n> +\tstruct string_list_item *item;\n> +\tconst char *hookname = NULL;\n> +\tint ret = 0;\n> +\n> +\tstruct option list_options[] = {\n> +\t\tOPT_END(),\n> +\t};\n> +\n> +\targc = parse_options(argc, argv, prefix, list_options,\n> +\t\t\t     builtin_hook_list_usage, 0);\n> +\n> +\t/*\n> +\t * The only unnamed argument provided should be the hook-name; if we add\n> +\t * arguments later they probably should be caught by parse_options.\n> +\t */\n> +\tif (argc != 1)\n> +\t\tusage_msg_opt(_(\"You must specify a hook event name to list.\"),\n> +\t\t\t      builtin_hook_list_usage, list_options);\n\nError messages typically start with a lower-case letter.\n\n> +\thookname = argv[0];\n> +\n> +\thead = list_hooks(repo, hookname, NULL);\n> +\n> +\tif (!head->nr) {\n> +\t\twarning(_(\"No hooks found for event '%s'\"), hookname);\n\nWarnings, too.\n\n> +\t\tret = 1; /* no hooks found */\n> +\t\tgoto cleanup;\n> +\t}\n> +\n> +\tfor_each_string_list_item(item, head) {\n> +\t\tstruct hook *h = item->util;\n> +\n> +\t\tswitch (h->kind) {\n> +\t\tcase HOOK_TRADITIONAL:\n> +\t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n> +\t\t\tbreak;\n> +\t\tdefault:\n> +\t\t\tBUG(\"unknown hook kind\");\n> +\t\t}\n\nGood that we're being defensive here.\n\nPatrick\n"},{"id":"536515","messageId":"aZhXm1-jw_Mi8-vL@pks.im","threadId":"64918","inReplyTo":"20260218222352.55393-5-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 4/8] hook: include hooks from the config","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-20T12:46:19Z","receivedAt":"2026-02-20T12:46:25Z","isPatch":true,"body":"On Thu, Feb 19, 2026 at 12:23:48AM +0200, Adrian Ratiu wrote:\n> diff --git a/builtin/hook.c b/builtin/hook.c\n> index 51660c4941..e151bb2cd1 100644\n> --- a/builtin/hook.c\n> +++ b/builtin/hook.c\n> @@ -54,6 +56,10 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n>  \n>  \tif (h->kind == HOOK_TRADITIONAL)\n>  \t\tfree((void *)h->u.traditional.path);\n> +\telse if (h->kind == HOOK_CONFIGURED) {\n> +\t\tfree((void *)h->u.configured.friendly_name);\n> +\t\tfree((void *)h->u.configured.command);\n> +\t}\n\nThe `if` branch also needs curly braces now.\n\n> @@ -101,6 +107,187 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n>  \tstring_list_append(hook_list, hook_path)->util = h;\n>  }\n>  \n> +static void unsorted_string_list_remove(struct string_list *list,\n> +\t\t\t\t\tconst char *str)\n> +{\n> +\tstruct string_list_item *item = unsorted_string_list_lookup(list, str);\n> +\tif (item)\n> +\t\tunsorted_string_list_delete_item(list, item - list->items, 0);\n> +}\n\nThis looks like a function that could reasonably be added to\n\"string-list.{c,h}\".\n\n> +/*\n> + * Callback struct to collect all hook.* keys in a single config pass.\n> + * commands: friendly-name to command map.\n> + * event_hooks: event-name to list of friendly-names map.\n> + * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n> + */\n> +struct hook_all_config_cb {\n> +\tstruct strmap commands;\n> +\tstruct strmap event_hooks;\n\nHm, curious that we've got two maps. I'd have expected to have a single\nmap from \"friendly name\" or \"hook name\" to `struct hook`. But maybe\nwe'll assemble these structs for those maps later on.\n\n> +\tstruct string_list disabled_hooks;\n> +};\n\nWe don't have support for disabled hooks yet. I assume this'll be added\nby a later commit, only.\n\n[snip]\n> +/* Populate `cache` with the complete hook configuration */\n> +static void build_hook_config_map(struct repository *r, struct strmap *cache)\n> +{\n> +\tstruct hook_all_config_cb cb_data;\n> +\tstruct hashmap_iter iter;\n> +\tstruct strmap_entry *e;\n> +\n> +\tstrmap_init(&cb_data.commands);\n> +\tstrmap_init(&cb_data.event_hooks);\n> +\tstring_list_init_dup(&cb_data.disabled_hooks);\n> +\n> +\t/* Parse all configs in one run. */\n> +\trepo_config(r, hook_config_lookup_all, &cb_data);\n> +\n> +\t/* Construct the cache from parsed configs. */\n> +\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n> +\t\tstruct string_list *hook_names = e->value;\n> +\t\tstruct string_list *hooks = xcalloc(1, sizeof(*hooks));\n> +\n> +\t\tstring_list_init_dup(hooks);\n> +\n> +\t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n> +\t\t\tconst char *hname = hook_names->items[i].string;\n> +\t\t\tchar *command;\n> +\n> +\t\t\tcommand = strmap_get(&cb_data.commands, hname);\n> +\t\t\tif (!command)\n> +\t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n> +\t\t\t\t      \"'hook.%s.event' must be removed;\"\n> +\t\t\t\t      \" aborting.\"), hname, hname);\n> +\n> +\t\t\t/* util stores the command; owned by the cache. */\n> +\t\t\tstring_list_append(hooks, hname)->util =\n> +\t\t\t\txstrdup(command);\n> +\t\t}\n> +\n> +\t\tstrmap_put(cache, e->key, hooks);\n> +\t}\n> +\n> +\tstrmap_clear(&cb_data.commands, 1);\n> +\tstring_list_clear(&cb_data.disabled_hooks, 0);\n> +\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n> +\t\tstring_list_clear(e->value, 0);\n> +\t\tfree(e->value);\n> +\t}\n> +\tstrmap_clear(&cb_data.event_hooks, 0);\n> +}\n\nOkay, this is where we assemble the hooks. Still curious that the result\nisn't a `struct hook` for each configured hook.\n\n[snip]\n> +static void list_hooks_add_configured(struct repository *r,\n> +\t\t\t\t      const char *hookname,\n> +\t\t\t\t      struct string_list *list,\n> +\t\t\t\t      struct run_hooks_opt *options)\n> +{\n> +\tstruct strmap *cache = get_hook_config_cache(r);\n> +\tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n> +\n> +\t/* Iterate through configured hooks and initialize internal states */\n> +\tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n> +\t\tconst char *friendly_name = configured_hooks->items[i].string;\n> +\t\tconst char *command = configured_hooks->items[i].util;\n> +\t\tstruct hook *hook = xcalloc(1, sizeof(struct hook));\n> +\n> +\t\tif (options && options->feed_pipe_cb_data_alloc)\n> +\t\t\thook->feed_pipe_cb_data =\n> +\t\t\t\toptions->feed_pipe_cb_data_alloc(\n> +\t\t\t\t\toptions->feed_pipe_ctx);\n> +\n> +\t\thook->kind = HOOK_CONFIGURED;\n> +\t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n> +\t\thook->u.configured.command = xstrdup(command);\n> +\n> +\t\tstring_list_append(list, friendly_name)->util = hook;\n> +\t}\n> +}\n\nOkay, here we finally create the hook structures. Is there any specific\nreason why we don't cache these structures directly?\n\nPatrick\n"},{"id":"536516","messageId":"aZhXom2cD8juWYs4@pks.im","threadId":"64918","inReplyTo":"20260218222352.55393-6-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 5/8] hook: allow disabling config hooks","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-20T12:46:26Z","receivedAt":"2026-02-20T12:46:32Z","isPatch":true,"body":"On Thu, Feb 19, 2026 at 12:23:49AM +0200, Adrian Ratiu wrote:\n> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n> index 9faafe3016..0cda4745a6 100644\n> --- a/Documentation/config/hook.adoc\n> +++ b/Documentation/config/hook.adoc\n> @@ -13,3 +13,10 @@ hook.<name>.event::\n>  \tspecified event, the associated `hook.<name>.command` is executed.\n>  \tThis is a multi-valued key. To run `hook.<name>` on multiple\n>  \tevents, specify the key more than once. See linkgit:git-hook[1].\n> +\n> +hook.<name>.enabled::\n> +\tWhether the hook `hook.<name>` is enabled. Defaults to `true`.\n> +\tSet to `false` to disable the hook without removing its\n> +\tconfiguration. This is particularly useful when a hook is defined\n> +\tin a system or global config file and needs to be disabled for a\n> +\tspecific repository. See linkgit:git-hook[1].\n\nNice.\n\n> diff --git a/hook.c b/hook.c\n> index 8a9b405f76..35c24bf33d 100644\n> --- a/hook.c\n> +++ b/hook.c\n> @@ -164,6 +164,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n>  \t\tchar *old = strmap_put(&data->commands, hook_name,\n>  \t\t\t\t       xstrdup(value));\n>  \t\tfree(old);\n> +\t} else if (!strcmp(subkey, \"enabled\")) {\n> +\t\tswitch (git_parse_maybe_bool(value)) {\n> +\t\tcase 0: /* disabled */\n> +\t\t\tif (!unsorted_string_list_lookup(&data->disabled_hooks,\n> +\t\t\t\t\t\t\t hook_name))\n> +\t\t\t\tstring_list_append(&data->disabled_hooks,\n> +\t\t\t\t\t\t   hook_name);\n> +\t\t\tbreak;\n> +\t\tcase 1: /* enabled: undo a prior disabled entry */\n> +\t\t\tunsorted_string_list_remove(&data->disabled_hooks,\n> +\t\t\t\t\t\t    hook_name);\n> +\t\t\tbreak;\n> +\t\tdefault:\n> +\t\t\tbreak; /* ignore unrecognised values */\n> +\t\t}\n>  \t}\n\nSomewhat similar to my preceding questions: why don't we store the\nenabled state in the `struct hook` structure itself? Like that we can\nfor example even list disabled hooks in `git hooks list --disabled`, if\nwe ever wanted to do something like that.\n\nPatrick\n"},{"id":"536517","messageId":"87fr6vy3vc.fsf@collabora.com","threadId":"64918","inReplyTo":"xmqqcy202z9p.fsf@gitster.g","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T12:51:51Z","receivedAt":"2026-02-20T12:52:08Z","isPatch":true,"body":"On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> v2 addresses all feedback received in v1.\n>>\n>> This series adds a new feature: the ability to specify commands to run\n>> for hook events via config entries (including shell commands).\n>>\n>> So instead of dropping a shell script or a custom program in .git/hooks\n>> you can now tell git via config files to run a program or shell script\n>> (can be specified directly in the config) when you run hook \"foo\".\n>>\n>> This also means you can setup global hooks to run in multiple repos via\n>> global configs and there's an option to disable them if necessary.\n>>\n>> For simplicity, because this series is becoming rather big, hooks are\n>> still executed sequentially (.jobs == 1). Parallel execution is added\n>> in another patch series.\n>>\n>> This is based on the latest v8 hooks-conversion series [1] which has\n>> not yet landed in next or master.\n>\n> Thanks for a reroll.  I was a bit concerned to allow configuration\n> files to speicify hooks as it would reduce discoverability (i.e.,\n> today, we can \"ls .git/hooks/\" to see everything that potentially\n> will be triggered, but now we need to be aware of what your sysadmin\n> dropped in /etc/gitconfig to get the whole picture.  \"git hook list\"\n> would solve that issue nicely.\n>\n> By the way, the discussion thread for the base topic hasn't seen\n> any activity in the latest round after it updated for the comments\n> received in the previous round.  It appears that it is ready to move\n> forward?  Let's mark it for 'next' in that case.\n\nYes, thank you, landing that series will make testing this one easier.\n\nI'm also working on v2 of the parallel series, will send it very soon.\n\nBoth will benefit from landing the base preparatory \"hook conversion\"\nseries.\n"},{"id":"536518","messageId":"87342vy1m9.fsf@gentoo.mail-host-address-is-not-set","threadId":"64918","inReplyTo":"aZhXh6aqlY0VMgEG@pks.im","subject":"Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T13:40:30Z","receivedAt":"2026-02-20T13:40:52Z","isPatch":true,"body":"On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Thu, Feb 19, 2026 at 12:23:45AM +0200, Adrian Ratiu wrote:\n>> diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\n>> index 2d2b33d73d..f23772bc56 100644\n>> --- a/builtin/receive-pack.c\n>> +++ b/builtin/receive-pack.c\n>> @@ -901,6 +901,26 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_\n>>  \treturn state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */\n>>  }\n>>  \n>> +static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)\n>> +{\n>> +\tstruct receive_hook_feed_state *init_state = feed_pipe_ctx;\n>> +\tstruct receive_hook_feed_state *data = xcalloc(1, sizeof(*data));\n>\n> Tiny nit, not worth addressing: we often use `CALLOC_ARRAY(data, 1)`\n> nowadays.\n\nI'll fix this since I'm planning to do a v3 anyway. Thanks for the\npointer.\n\n>\n>> +\tdata->report = init_state->report;\n>> +\tdata->cmd = init_state->cmd;\n>> +\tdata->skip_broken = init_state->skip_broken;\n>> +\tstrbuf_init(&data->buf, 0);\n>> +\treturn data;\n>> +}\n>\n> Okay, this basically creates the new instance by creating a deep copy of\n> the \"template\" structure.\n>\n> One could split this up so that we have a \"configuration\" struct and a\n> \"data\" struct, where we then provide a pointer to the configuration into\n> the data structure, as only the buffer needs to change between the\n> individual hook invocations. That would avoid some copying around, but\n> it feels a bit unnecessary.\n\nYes, I also thought about this and reached the same conclusion.\n\n>\n>> +static void receive_hook_feed_state_free(void *data)\n>> +{\n>> +\tstruct receive_hook_feed_state *d = data;\n>> +\tif (!d)\n>> +\t\treturn;\n>> +\tstrbuf_release(&d->buf);\n>> +\tfree(d);\n>> +}\n>\n> I would expect that the hook interfaces know to not call `free()` in\n> case `alloc()` wasn't called, but I guess it doesn't hurt to be\n> defensive here anyway.\n\nAgreed.\n\n>> @@ -908,7 +928,7 @@ static int run_receive_hook(struct command *commands,\n>>  {\n>>  \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n>>  \tstruct command *iter = commands;\n>> -\tstruct receive_hook_feed_state feed_state;\n>> +\tstruct receive_hook_feed_state feed_init_state = { 0 };\n>>  \tstruct async sideband_async;\n>>  \tint sideband_async_started = 0;\n>>  \tint saved_stderr = -1;\n>> @@ -938,16 +958,15 @@ static int run_receive_hook(struct command *commands,\n>>  \tprepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started);\n>>  \n>>  \t/* set up stdin callback */\n>> -\tfeed_state.cmd = commands;\n>> -\tfeed_state.skip_broken = skip_broken;\n>> -\tfeed_state.report = NULL;\n>> -\tstrbuf_init(&feed_state.buf, 0);\n>> -\topt.feed_pipe_cb_data = &feed_state;\n>> +\tfeed_init_state.cmd = commands;\n>> +\tfeed_init_state.skip_broken = skip_broken;\n>\n> As far as I can see all of the data that we pass to the state struct is\n> static, so we might just as well initialize it right away, right?\n>\n>     struct receive_hook_feed_state feed_init_state = {\n>         .cmd = commands,\n>         .skip_broken = skip_broken,\n>         .buf = STRBUF_INIT,\n>     };\n\nI missed this. Thanks. Will do in v3.\n\n>> diff --git a/hook.c b/hook.c\n>> index cde7198412..83ff658866 100644\n>> --- a/hook.c\n>> +++ b/hook.c\n>> @@ -133,6 +133,8 @@ static int notify_hook_finished(int result,\n>>  \n>>  static void run_hooks_opt_clear(struct run_hooks_opt *options)\n>>  {\n>> +\tif (options->feed_pipe_cb_data_free)\n>> +\t\toptions->feed_pipe_cb_data_free(options->feed_pipe_cb_data);\n>>  \tstrvec_clear(&options->env);\n>>  \tstrvec_clear(&options->args);\n>>  }\n>\n> I guess this here would be where we could skip `free` in case the data\n> wasn't even allocated. But as I said further up, I don't care all that\n> much.\n\nThat is correct. I think I'll just drop that pointer check anyway in v3.\n\n>> diff --git a/hook.h b/hook.h\n>> index 20eb56fd63..a6bdc6f90f 100644\n>> --- a/hook.h\n>> +++ b/hook.h\n>> @@ -5,6 +5,9 @@\n>>  \n>>  struct repository;\n>>  \n>> +typedef void (*cb_data_free_fn)(void *data);\n>> +typedef void *(*cb_data_alloc_fn)(void *init_ctx);\n>> +\n>>  struct run_hooks_opt\n>>  {\n>>  \t/* Environment vars to be set for each hook */\n>\n> Do we maybe want to scope these function typedefs to the hooks subsystem\n> by calling the `hook_data_free_fn` and `hook_data_alloc_fn`, or\n> something like that?\n\nGood idea. I'll rename them in v3.\n"},{"id":"536519","messageId":"87zf53wmj7.fsf@collabora.com","threadId":"64918","inReplyTo":"aZhXkDtNvcNpXgpJ@pks.im","subject":"Re: [PATCH v2 2/8] hook: run a list of hooks to prepare for multihook support","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T13:51:40Z","receivedAt":"2026-02-20T13:51:58Z","isPatch":true,"body":"On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Thu, Feb 19, 2026 at 12:23:46AM +0200, Adrian Ratiu wrote:\n>> diff --git a/hook.c b/hook.c\n>> index 83ff658866..c008a7232d 100644\n>> --- a/hook.c\n>> +++ b/hook.c\n>> @@ -47,9 +47,97 @@ const char *find_hook(struct repository *r, const char *name)\n>>  \treturn path.buf;\n>>  }\n>>  \n>> +static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n>\n> Nit: this should be called `hook_free()` as it also frees the struct\n> itself.\n\nThanks. Junio also told me to prefer the _free() variants for functions\ninstead of _clear(), so I'll do that in v3.\n\nI'll also carefully look so names match the semantics of the functions\nI'm adding.\n\n>> +{\n>> +\tif (!h)\n>> +\t\treturn;\n>> +\n>> +\tif (h->kind == HOOK_TRADITIONAL)\n>> +\t\tfree((void *)h->u.traditional.path);\n>> +\n>> +\tif (cb_data_free)\n>> +\t\tcb_data_free(h->feed_pipe_cb_data);\n>> +\n>> +\tfree(h);\n>> +}\n>> +\n>> +static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n>> +{\n>> +\tstruct string_list_item *item;\n>> +\n>> +\tfor_each_string_list_item(item, hooks)\n>> +\t\thook_clear(item->util, cb_data_free);\n>> +\n>> +\tstring_list_clear(hooks, 0);\n>\n> We have `string_list_clear_func()` to perform a deep free for you.\n\nAck, will use it in v3.\n\n>> +}\n>> +\n>> +/* Helper to detect and add default \"traditional\" hooks from the hookdir. */\n>> +static void list_hooks_add_default(struct repository *r, const char *hookname,\n>> +\t\t\t\t   struct string_list *hook_list,\n>> +\t\t\t\t   struct run_hooks_opt *options)\n>> +{\n>> +\tconst char *hook_path = find_hook(r, hookname);\n>> +\tstruct hook *h;\n>> +\n>> +\tif (!hook_path)\n>> +\t\treturn;\n>> +\n>> +\th = xcalloc(1, sizeof(struct hook));\n>\n> Better: CALLOC_ARRAY(h, 1);\n\nAck, will use it in v3.\n\n>> +\t/*\n>> +\t * If the hook is to run in a specific dir, a relative path can\n>> +\t * become invalid in that dir, so convert to an absolute path.\n>> +\t */\n>> +\tif (options && options->dir)\n>> +\t\thook_path = absolute_path(hook_path);\n>> +\n>> +\t/* Setup per-hook internal state cb data */\n>> +\tif (options && options->feed_pipe_cb_data_alloc)\n>> +\t\th->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n>> +\n>> +\th->kind = HOOK_TRADITIONAL;\n>> +\th->u.traditional.path = xstrdup(hook_path);\n>> +\n>> +\tstring_list_append(hook_list, hook_path)->util = h;\n>> +}\n>> +\n>> +/*\n>> + * Provides a list of hook commands to run for the 'hookname' event.\n>> + *\n>> + * This function consolidates hooks from two sources:\n>> + * 1. The config-based hooks (not yet implemented).\n>> + * 2. The \"traditional\" hook found in the repository hooks directory\n>> + *    (e.g., .git/hooks/pre-commit).\n>> + *\n>> + * The list is ordered by execution priority.\n>> + *\n>> + * The caller is responsible for freeing the memory of the returned list\n>> + * using string_list_clear() and free().\n>> + */\n>> +static struct string_list *list_hooks(struct repository *r, const char *hookname,\n>> +\t\t\t       struct run_hooks_opt *options)\n>> +{\n>> +\tstruct string_list *hook_head;\n>> +\n>> +\tif (!hookname)\n>> +\t\tBUG(\"null hookname was provided to hook_list()!\");\n>> +\n>> +\thook_head = xmalloc(sizeof(struct string_list));\n>\n> CALLOC_ARRAY(hook_head, 1);\n\nAck, will use it in v3.\n\n>> @@ -85,21 +176,20 @@ static int pick_next_hook(struct child_process *cp,\n>>  \tcp->trace2_hook_name = hook_cb->hook_name;\n>>  \tcp->dir = hook_cb->options->dir;\n>>  \n>> -\tstrvec_push(&cp->args, hook_path);\n>> +\t/* Add hook exec paths or commands */\n>> +\tif (h->kind == HOOK_TRADITIONAL)\n>> +\t\tstrvec_push(&cp->args, h->u.traditional.path);\n>\n> Should we be defensive here and have an `else BUG()`?\n\nYes, good catch. I think in another place where I verified this I\nactually added a BUG.\n\nWill add in v3.\n"},{"id":"536520","messageId":"87wm07wmg1.fsf@collabora.com","threadId":"64918","inReplyTo":"aZhXlrWRGDS_Bhvm@pks.im","subject":"Re: [PATCH v2 3/8] hook: add \"git hook list\" command","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T13:53:34Z","receivedAt":"2026-02-20T13:53:52Z","isPatch":true,"body":"On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Thu, Feb 19, 2026 at 12:23:47AM +0200, Adrian Ratiu wrote:\n>> diff --git a/builtin/hook.c b/builtin/hook.c\n>> index 7afec380d2..51660c4941 100644\n>> --- a/builtin/hook.c\n>> +++ b/builtin/hook.c\n>> @@ -20,6 +24,61 @@ static const char * const builtin_hook_run_usage[] = {\n>>  \tNULL\n>>  };\n>>  \n>> +static int list(int argc, const char **argv, const char *prefix,\n>> +\t\t struct repository *repo)\n>> +{\n>> +\tstatic const char *const builtin_hook_list_usage[] = {\n>> +\t\tBUILTIN_HOOK_LIST_USAGE,\n>> +\t\tNULL\n>> +\t};\n>> +\tstruct string_list *head;\n>> +\tstruct string_list_item *item;\n>> +\tconst char *hookname = NULL;\n>> +\tint ret = 0;\n>> +\n>> +\tstruct option list_options[] = {\n>> +\t\tOPT_END(),\n>> +\t};\n>> +\n>> +\targc = parse_options(argc, argv, prefix, list_options,\n>> +\t\t\t     builtin_hook_list_usage, 0);\n>> +\n>> +\t/*\n>> +\t * The only unnamed argument provided should be the hook-name; if we add\n>> +\t * arguments later they probably should be caught by parse_options.\n>> +\t */\n>> +\tif (argc != 1)\n>> +\t\tusage_msg_opt(_(\"You must specify a hook event name to list.\"),\n>> +\t\t\t      builtin_hook_list_usage, list_options);\n>\n> Error messages typically start with a lower-case letter.\n\nAck, will fix.\n\n>> +\thookname = argv[0];\n>> +\n>> +\thead = list_hooks(repo, hookname, NULL);\n>> +\n>> +\tif (!head->nr) {\n>> +\t\twarning(_(\"No hooks found for event '%s'\"), hookname);\n>\n> Warnings, too.\n\nAck, will fix.\n\n>> +\t\tret = 1; /* no hooks found */\n>> +\t\tgoto cleanup;\n>> +\t}\n>> +\n>> +\tfor_each_string_list_item(item, head) {\n>> +\t\tstruct hook *h = item->util;\n>> +\n>> +\t\tswitch (h->kind) {\n>> +\t\tcase HOOK_TRADITIONAL:\n>> +\t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n>> +\t\t\tbreak;\n>> +\t\tdefault:\n>> +\t\t\tBUG(\"unknown hook kind\");\n>> +\t\t}\n>\n> Good that we're being defensive here.\n\nYes, I remembered correctly then, will add a BUG() to the other patch as\nwell.\n"},{"id":"536521","messageId":"87tsvbwkpd.fsf@collabora.com","threadId":"64918","inReplyTo":"aZhXm1-jw_Mi8-vL@pks.im","subject":"Re: [PATCH v2 4/8] hook: include hooks from the config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T14:31:10Z","receivedAt":"2026-02-20T14:31:28Z","isPatch":true,"body":"On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Thu, Feb 19, 2026 at 12:23:48AM +0200, Adrian Ratiu wrote:\n>> diff --git a/builtin/hook.c b/builtin/hook.c\n>> index 51660c4941..e151bb2cd1 100644\n>> --- a/builtin/hook.c\n>> +++ b/builtin/hook.c\n>> @@ -54,6 +56,10 @@ static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n>>  \n>>  \tif (h->kind == HOOK_TRADITIONAL)\n>>  \t\tfree((void *)h->u.traditional.path);\n>> +\telse if (h->kind == HOOK_CONFIGURED) {\n>> +\t\tfree((void *)h->u.configured.friendly_name);\n>> +\t\tfree((void *)h->u.configured.command);\n>> +\t}\n>\n> The `if` branch also needs curly braces now.\n\nAck, will fix.\n\n>> @@ -101,6 +107,187 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n>>  \tstring_list_append(hook_list, hook_path)->util = h;\n>>  }\n>>  \n>> +static void unsorted_string_list_remove(struct string_list *list,\n>> +\t\t\t\t\tconst char *str)\n>> +{\n>> +\tstruct string_list_item *item = unsorted_string_list_lookup(list, str);\n>> +\tif (item)\n>> +\t\tunsorted_string_list_delete_item(list, item - list->items, 0);\n>> +}\n>\n> This looks like a function that could reasonably be added to\n> \"string-list.{c,h}\".\n\nYes, I'll move the helper there.\n\n>> +/*\n>> + * Callback struct to collect all hook.* keys in a single config pass.\n>> + * commands: friendly-name to command map.\n>> + * event_hooks: event-name to list of friendly-names map.\n>> + * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n>> + */\n>> +struct hook_all_config_cb {\n>> +\tstruct strmap commands;\n>> +\tstruct strmap event_hooks;\n>\n> Hm, curious that we've got two maps. I'd have expected to have a single\n> map from \"friendly name\" or \"hook name\" to `struct hook`. But maybe\n> we'll assemble these structs for those maps later on.\n>\n\nExactly. I'll explain below.\n\n>> +\tstruct string_list disabled_hooks;\n>> +};\n>\n> We don't have support for disabled hooks yet. I assume this'll be added\n> by a later commit, only.\n\nGood catch.\n\nI need to move this definition to the later commit.\n\n>\n> [snip]\n>> +/* Populate `cache` with the complete hook configuration */\n>> +static void build_hook_config_map(struct repository *r, struct strmap *cache)\n>> +{\n>> +\tstruct hook_all_config_cb cb_data;\n>> +\tstruct hashmap_iter iter;\n>> +\tstruct strmap_entry *e;\n>> +\n>> +\tstrmap_init(&cb_data.commands);\n>> +\tstrmap_init(&cb_data.event_hooks);\n>> +\tstring_list_init_dup(&cb_data.disabled_hooks);\n>> +\n>> +\t/* Parse all configs in one run. */\n>> +\trepo_config(r, hook_config_lookup_all, &cb_data);\n>> +\n>> +\t/* Construct the cache from parsed configs. */\n>> +\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n>> +\t\tstruct string_list *hook_names = e->value;\n>> +\t\tstruct string_list *hooks = xcalloc(1, sizeof(*hooks));\n>> +\n>> +\t\tstring_list_init_dup(hooks);\n>> +\n>> +\t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n>> +\t\t\tconst char *hname = hook_names->items[i].string;\n>> +\t\t\tchar *command;\n>> +\n>> +\t\t\tcommand = strmap_get(&cb_data.commands, hname);\n>> +\t\t\tif (!command)\n>> +\t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n>> +\t\t\t\t      \"'hook.%s.event' must be removed;\"\n>> +\t\t\t\t      \" aborting.\"), hname, hname);\n>> +\n>> +\t\t\t/* util stores the command; owned by the cache. */\n>> +\t\t\tstring_list_append(hooks, hname)->util =\n>> +\t\t\t\txstrdup(command);\n>> +\t\t}\n>> +\n>> +\t\tstrmap_put(cache, e->key, hooks);\n>> +\t}\n>> +\n>> +\tstrmap_clear(&cb_data.commands, 1);\n>> +\tstring_list_clear(&cb_data.disabled_hooks, 0);\n>> +\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n>> +\t\tstring_list_clear(e->value, 0);\n>> +\t\tfree(e->value);\n>> +\t}\n>> +\tstrmap_clear(&cb_data.event_hooks, 0);\n>> +}\n>\n> Okay, this is where we assemble the hooks. Still curious that the result\n> isn't a `struct hook` for each configured hook.\n>\n> [snip]\n>> +static void list_hooks_add_configured(struct repository *r,\n>> +\t\t\t\t      const char *hookname,\n>> +\t\t\t\t      struct string_list *list,\n>> +\t\t\t\t      struct run_hooks_opt *options)\n>> +{\n>> +\tstruct strmap *cache = get_hook_config_cache(r);\n>> +\tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n>> +\n>> +\t/* Iterate through configured hooks and initialize internal states */\n>> +\tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n>> +\t\tconst char *friendly_name = configured_hooks->items[i].string;\n>> +\t\tconst char *command = configured_hooks->items[i].util;\n>> +\t\tstruct hook *hook = xcalloc(1, sizeof(struct hook));\n>> +\n>> +\t\tif (options && options->feed_pipe_cb_data_alloc)\n>> +\t\t\thook->feed_pipe_cb_data =\n>> +\t\t\t\toptions->feed_pipe_cb_data_alloc(\n>> +\t\t\t\t\toptions->feed_pipe_ctx);\n>> +\n>> +\t\thook->kind = HOOK_CONFIGURED;\n>> +\t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n>> +\t\thook->u.configured.command = xstrdup(command);\n>> +\n>> +\t\tstring_list_append(list, friendly_name)->util = hook;\n>> +\t}\n>> +}\n>\n> Okay, here we finally create the hook structures. Is there any specific\n> reason why we don't cache these structures directly?\n\nThat is exactly what my first local cache implementation attempt did.\n\nI doesn't work because:\n1. struct hook contains the internal hook state which cannot be reused\n   betwee hook calls (nor can it be cached).\n2. struct hook requires the initialization ctx passed to\n   feed_pipe_cb_data_alloc() which is different depending on the hook.\n\nSo 1 and 2 above make struct hook non-cacheable.\n\nMaybe the following explains it better. :)\n\nWe have 3 different operations:\n\n1. Parsing the config file once via hook_config_lookup_all()\n2. Storing/reusing the config cache via build_hook_config_map()\n3. Creating the struct hooks for execution via\n   list_hooks_add_configured().\n\nCaching can only work on the parsed config data, at step 2.\nHope this all makes sense.\n\nMaybe we could rename these steps/functions to better reflect their\npurpouse, or document this better somewhere in the source code?\n"},{"id":"536522","messageId":"87qzqfwjym.fsf@collabora.com","threadId":"64918","inReplyTo":"aZhXom2cD8juWYs4@pks.im","subject":"Re: [PATCH v2 5/8] hook: allow disabling config hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-20T14:47:13Z","receivedAt":"2026-02-20T14:47:30Z","isPatch":true,"body":"On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> On Thu, Feb 19, 2026 at 12:23:49AM +0200, Adrian Ratiu wrote:\n>> diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n>> index 9faafe3016..0cda4745a6 100644\n>> --- a/Documentation/config/hook.adoc\n>> +++ b/Documentation/config/hook.adoc\n>> @@ -13,3 +13,10 @@ hook.<name>.event::\n>>  \tspecified event, the associated `hook.<name>.command` is executed.\n>>  \tThis is a multi-valued key. To run `hook.<name>` on multiple\n>>  \tevents, specify the key more than once. See linkgit:git-hook[1].\n>> +\n>> +hook.<name>.enabled::\n>> +\tWhether the hook `hook.<name>` is enabled. Defaults to `true`.\n>> +\tSet to `false` to disable the hook without removing its\n>> +\tconfiguration. This is particularly useful when a hook is defined\n>> +\tin a system or global config file and needs to be disabled for a\n>> +\tspecific repository. See linkgit:git-hook[1].\n>\n> Nice.\n>\n>> diff --git a/hook.c b/hook.c\n>> index 8a9b405f76..35c24bf33d 100644\n>> --- a/hook.c\n>> +++ b/hook.c\n>> @@ -164,6 +164,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n>>  \t\tchar *old = strmap_put(&data->commands, hook_name,\n>>  \t\t\t\t       xstrdup(value));\n>>  \t\tfree(old);\n>> +\t} else if (!strcmp(subkey, \"enabled\")) {\n>> +\t\tswitch (git_parse_maybe_bool(value)) {\n>> +\t\tcase 0: /* disabled */\n>> +\t\t\tif (!unsorted_string_list_lookup(&data->disabled_hooks,\n>> +\t\t\t\t\t\t\t hook_name))\n>> +\t\t\t\tstring_list_append(&data->disabled_hooks,\n>> +\t\t\t\t\t\t   hook_name);\n>> +\t\t\tbreak;\n>> +\t\tcase 1: /* enabled: undo a prior disabled entry */\n>> +\t\t\tunsorted_string_list_remove(&data->disabled_hooks,\n>> +\t\t\t\t\t\t    hook_name);\n>> +\t\t\tbreak;\n>> +\t\tdefault:\n>> +\t\t\tbreak; /* ignore unrecognised values */\n>> +\t\t}\n>>  \t}\n>\n> Somewhat similar to my preceding questions: why don't we store the\n> enabled state in the `struct hook` structure itself? Like that we can\n> for example even list disabled hooks in `git hooks list --disabled`, if\n> we ever wanted to do something like that.\n\nThe answer to the preceeding question is unfortunately no (I tried it),\nhowever the answer to this question is YES.\n\nI took the design decision to \"filter\" disabled hooks at config parsing\ntime, however I had no idea when doing it that we might want to run\nsomething like \"git hooks list --disabled\".\n\nI'll change this in v3 and cache the disabled config state instead of\nfiltering out disabled hooks completely.\n\nThis way we could still use their information as you suggested and we\ncould even enable/disable config hooks via git commands, however that is\nyet-another-new-feature and this series is big enough and should be just\nfocused on adding the \"base\" functionality.\n\nSo in v3 I will cache the disabled state add the `git hooks list\n--disabled` feature as well, but stop there. :)\n"},{"id":"536541","messageId":"xmqq4inbz5ye.fsf@gitster.g","threadId":"64918","inReplyTo":"87ldgny4mm.fsf@collabora.com","subject":"Re: [PATCH v2 1/8] hook: add internal state alloc/free callbacks","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-20T17:21:29Z","receivedAt":"2026-02-20T17:21:32Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> On Thu, 19 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:\n>> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>>\n>>> +\t/*\n>>> +\t * Ensure cb_data copy and free functions are either provided together,\n>>> +\t * or neither one is provided.\n>>> +\t */\n>>> +\tif ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||\n>>> +\t    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))\n>>> +\t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n>>\n>> A way to avoid being repetitious may be to say\n>>\n>> \tif (!!options->feed_pipe_cb_data_alloc != !!options->feed_pipe_cb_data_free)\n>>\n>> or\n>>\n>> \tif (!!options->feed_pipe_cb_data_alloc ^ !!options->feed_pipe_cb_data_free)\n>>\n>> but it (especially the latter) might be a bit too cute for some\n>> people's taste.\n>\n> Thanks for suggesting this. I was actually thinking of ways to simplify\n> this and the double negation didn't occur to me. Will do in v3.\n\nEmbarrassed.   You do not need !!; (!A != !B) would suffice.\n"},{"id":"536546","messageId":"aZiqkB61YXCAKEWE@pks.im","threadId":"64918","inReplyTo":"87qzqfwjym.fsf@collabora.com","subject":"Re: [PATCH v2 5/8] hook: allow disabling config hooks","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-02-20T18:40:16Z","receivedAt":"2026-02-20T18:40:23Z","isPatch":true,"body":"On Fri, Feb 20, 2026 at 04:47:13PM +0200, Adrian Ratiu wrote:\n> On Fri, 20 Feb 2026, Patrick Steinhardt <ps@pks.im> wrote:\n> > On Thu, Feb 19, 2026 at 12:23:49AM +0200, Adrian Ratiu wrote:\n> >> diff --git a/hook.c b/hook.c\n> >> index 8a9b405f76..35c24bf33d 100644\n> >> --- a/hook.c\n> >> +++ b/hook.c\n> >> @@ -164,6 +164,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n> >>  \t\tchar *old = strmap_put(&data->commands, hook_name,\n> >>  \t\t\t\t       xstrdup(value));\n> >>  \t\tfree(old);\n> >> +\t} else if (!strcmp(subkey, \"enabled\")) {\n> >> +\t\tswitch (git_parse_maybe_bool(value)) {\n> >> +\t\tcase 0: /* disabled */\n> >> +\t\t\tif (!unsorted_string_list_lookup(&data->disabled_hooks,\n> >> +\t\t\t\t\t\t\t hook_name))\n> >> +\t\t\t\tstring_list_append(&data->disabled_hooks,\n> >> +\t\t\t\t\t\t   hook_name);\n> >> +\t\t\tbreak;\n> >> +\t\tcase 1: /* enabled: undo a prior disabled entry */\n> >> +\t\t\tunsorted_string_list_remove(&data->disabled_hooks,\n> >> +\t\t\t\t\t\t    hook_name);\n> >> +\t\t\tbreak;\n> >> +\t\tdefault:\n> >> +\t\t\tbreak; /* ignore unrecognised values */\n> >> +\t\t}\n> >>  \t}\n> >\n> > Somewhat similar to my preceding questions: why don't we store the\n> > enabled state in the `struct hook` structure itself? Like that we can\n> > for example even list disabled hooks in `git hooks list --disabled`, if\n> > we ever wanted to do something like that.\n> \n> The answer to the preceeding question is unfortunately no (I tried it),\n> however the answer to this question is YES.\n> \n> I took the design decision to \"filter\" disabled hooks at config parsing\n> time, however I had no idea when doing it that we might want to run\n> something like \"git hooks list --disabled\".\n> \n> I'll change this in v3 and cache the disabled config state instead of\n> filtering out disabled hooks completely.\n> \n> This way we could still use their information as you suggested and we\n> could even enable/disable config hooks via git commands, however that is\n> yet-another-new-feature and this series is big enough and should be just\n> focused on adding the \"base\" functionality.\n> \n> So in v3 I will cache the disabled state add the `git hooks list\n> --disabled` feature as well, but stop there. :)\n\nNote that the \"--disabled\" flag was just a way to explain why I think\nit's useful to track the disabled state explicitly. I didn't mean to say\nthat it needs to be added :)\n\nPatrick\n"},{"id":"536547","messageId":"xmqq7bs7xnhr.fsf@gitster.g","threadId":"64918","inReplyTo":"aZiqkB61YXCAKEWE@pks.im","subject":"Re: [PATCH v2 5/8] hook: allow disabling config hooks","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-20T18:45:36Z","receivedAt":"2026-02-20T18:45:39Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> Note that the \"--disabled\" flag was just a way to explain why I think\n> it's useful to track the disabled state explicitly. I didn't mean to say\n> that it needs to be added :)\n\nYup.\n\nIf \"hook list\" (possibly with --verbose but there may not be any\nneed for multiple verbosity levels) shows which ones are available\nbut which ones are disabled, that would also be useful.  It would be\nharder to arrange with the approach to filter early.\n\nThanks.\n\n\n"},{"id":"536564","messageId":"aZjuTSopOMvwR4hQ@fruit.crustytoothpaste.net","threadId":"64918","inReplyTo":"20260218222352.55393-1-adrian.ratiu@collabora.com","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"brian m. carlson","fromEmail":"sandals@crustytoothpaste.net","sentAt":"2026-02-20T23:29:17Z","receivedAt":"2026-02-20T23:29:25Z","isPatch":true,"body":"On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:\n> Hello everyone,\n> \n> v2 addresses all feedback received in v1.\n> \n> This series adds a new feature: the ability to specify commands to run\n> for hook events via config entries (including shell commands).\n> \n> So instead of dropping a shell script or a custom program in .git/hooks\n> you can now tell git via config files to run a program or shell script\n> (can be specified directly in the config) when you run hook \"foo\".\n> \n> This also means you can setup global hooks to run in multiple repos via\n> global configs and there's an option to disable them if necessary.\n> \n> For simplicity, because this series is becoming rather big, hooks are\n> still executed sequentially (.jobs == 1). Parallel execution is added\n> in another patch series.\n\nI'm interested in how you plan to make parallel execution work\ngracefully.\n\nWe've already established that it's necessary to preserve stdout and\nstderr (including wiring them up to the TTY) so as to not break\nexisting, widely deployed hooks, such as those in Git LFS.  That means\nthat to get parallel execution where the hooks don't write over each\nother's output and fight for the terminal, you'd need to multiplex each\none, including providing a PTY if the appropriate descriptor already has\na terminal, such that the output is at the very least handled line by\nline and ideally batched into per-hook chunks.  Is that the plan, or do\nyou plan to do it differently?\n\nI ask because situations where the hook output is not handled gracefully\nand hooks fight over output or where the existence of TTY on a file\ndescriptor is not preserved will result in bug reports and broken tests\nfor tools that use Git, which I think we'd all like to avoid.\n\nOf course, that's not in this series, so it may not even be written yet,\nbut if it's not, then this is something to keep in mind for when it gets\nsubmitted.\n-- \nbrian m. carlson (they/them)\nToronto, Ontario, CA\n"},{"id":"536588","messageId":"87o6liw4s1.fsf@collabora.com","threadId":"64918","inReplyTo":"aZjuTSopOMvwR4hQ@fruit.crustytoothpaste.net","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-21T14:27:26Z","receivedAt":"2026-02-21T14:27:53Z","isPatch":true,"body":"On Fri, 20 Feb 2026, \"brian m. carlson\" <sandals@crustytoothpaste.net> wrote:\n> On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:\n>> Hello everyone,\n>> \n>> v2 addresses all feedback received in v1.\n>> \n>> This series adds a new feature: the ability to specify commands to run\n>> for hook events via config entries (including shell commands).\n>> \n>> So instead of dropping a shell script or a custom program in .git/hooks\n>> you can now tell git via config files to run a program or shell script\n>> (can be specified directly in the config) when you run hook \"foo\".\n>> \n>> This also means you can setup global hooks to run in multiple repos via\n>> global configs and there's an option to disable them if necessary.\n>> \n>> For simplicity, because this series is becoming rather big, hooks are\n>> still executed sequentially (.jobs == 1). Parallel execution is added\n>> in another patch series.\n>\n> I'm interested in how you plan to make parallel execution work\n> gracefully.\n>\n> We've already established that it's necessary to preserve stdout and\n> stderr (including wiring them up to the TTY) so as to not break\n> existing, widely deployed hooks, such as those in Git LFS.  That means\n> that to get parallel execution where the hooks don't write over each\n> other's output and fight for the terminal, you'd need to multiplex each\n> one, including providing a PTY if the appropriate descriptor already has\n> a terminal, such that the output is at the very least handled line by\n> line and ideally batched into per-hook chunks.  Is that the plan, or do\n> you plan to do it differently?\n>\n> I ask because situations where the hook output is not handled gracefully\n> and hooks fight over output or where the existence of TTY on a file\n> descriptor is not preserved will result in bug reports and broken tests\n> for tools that use Git, which I think we'd all like to avoid.\n\nHi Brian,\n\nYes, this is all done already. Phillip Wood actually brought this TTY\nissue up in his review of the v1 parallel hooks series (many thanks). :) \n\n\n> Of course, that's not in this series, so it may not even be written yet,\n> but if it's not, then this is something to keep in mind for when it gets\n> submitted.\n\nYes, that's a separate series [1] and I'm about to send v2 very soon.\n\nPlease review v2 directly when I send it because it will contain\nsignificant changes from v1. v1 is not worth reviewing at this point.\n\nTo give you a high level description:\n\nAll hooks continue to run sequentially (serialized) by default, just\nlike before.\n\nTo run some of the hooks in parallel, I had to introduce an extension,\nbecause we need to break backwards compatibility by combining stdout and\nstderr and piping them through run-command's muxer, detached from the\nterminal. There is only 1 known hook requiring this extension (pre-push)\nbut it's trivial to add more, if necessary.\n\nPatrick's idea is to leave it up to the user to decide what to\nparallelize, because the user knows if their hooks are safe or not (eg \nif they write the same file or call the same program), or if it's ok the\nenable the extension or not.\n\nSome hooks are known never to be safe to parallelize, in that case git\nwill always enforce serial execution.\n\nPlease wait for v2 of that series, it's my top priority to get it out,\nAdrian\n\n1:\nhttps://lore.kernel.org/git/20260204173328.1601807-1-adrian.ratiu@collabora.com/n\n"},{"id":"536623","messageId":"87ikbpwr13.fsf@collabora.com","threadId":"64918","inReplyTo":"87o6liw4s1.fsf@collabora.com","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-22T00:39:04Z","receivedAt":"2026-02-22T00:39:15Z","isPatch":true,"body":"On Sat, 21 Feb 2026, Adrian Ratiu <adrian.ratiu@collabora.com> wrote:\n> On Fri, 20 Feb 2026, \"brian m. carlson\" <sandals@crustytoothpaste.net> wrote:\n>> On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:\n>>> Hello everyone,\n>>> \n>>> v2 addresses all feedback received in v1.\n>>> \n>>> This series adds a new feature: the ability to specify commands to run\n>>> for hook events via config entries (including shell commands).\n>>> \n>>> So instead of dropping a shell script or a custom program in .git/hooks\n>>> you can now tell git via config files to run a program or shell script\n>>> (can be specified directly in the config) when you run hook \"foo\".\n>>> \n>>> This also means you can setup global hooks to run in multiple repos via\n>>> global configs and there's an option to disable them if necessary.\n>>> \n>>> For simplicity, because this series is becoming rather big, hooks are\n>>> still executed sequentially (.jobs == 1). Parallel execution is added\n>>> in another patch series.\n>>\n>> I'm interested in how you plan to make parallel execution work\n>> gracefully.\n>>\n>> We've already established that it's necessary to preserve stdout and\n>> stderr (including wiring them up to the TTY) so as to not break\n>> existing, widely deployed hooks, such as those in Git LFS.  That means\n>> that to get parallel execution where the hooks don't write over each\n>> other's output and fight for the terminal, you'd need to multiplex each\n>> one, including providing a PTY if the appropriate descriptor already has\n>> a terminal, such that the output is at the very least handled line by\n>> line and ideally batched into per-hook chunks.  Is that the plan, or do\n>> you plan to do it differently?\n>>\n>> I ask because situations where the hook output is not handled gracefully\n>> and hooks fight over output or where the existence of TTY on a file\n>> descriptor is not preserved will result in bug reports and broken tests\n>> for tools that use Git, which I think we'd all like to avoid.\n>\n> Hi Brian,\n>\n> Yes, this is all done already. Phillip Wood actually brought this TTY\n> issue up in his review of the v1 parallel hooks series (many thanks). :) \n>\n>\n>> Of course, that's not in this series, so it may not even be written yet,\n>> but if it's not, then this is something to keep in mind for when it gets\n>> submitted.\n>\n> Yes, that's a separate series [1] and I'm about to send v2 very soon.\n>\n> Please review v2 directly when I send it because it will contain\n> significant changes from v1. v1 is not worth reviewing at this point.\n>\n> To give you a high level description:\n>\n> All hooks continue to run sequentially (serialized) by default, just\n> like before.\n>\n> To run some of the hooks in parallel, I had to introduce an extension,\n> because we need to break backwards compatibility by combining stdout and\n> stderr and piping them through run-command's muxer, detached from the\n> terminal. There is only 1 known hook requiring this extension (pre-push)\n> but it's trivial to add more, if necessary.\n>\n> Patrick's idea is to leave it up to the user to decide what to\n> parallelize, because the user knows if their hooks are safe or not (eg \n> if they write the same file or call the same program), or if it's ok the\n> enable the extension or not.\n>\n> Some hooks are known never to be safe to parallelize, in that case git\n> will always enforce serial execution.\n>\n> Please wait for v2 of that series, it's my top priority to get it out,\n> Adrian\n>\n> 1:\n> https://lore.kernel.org/git/20260204173328.1601807-1-adrian.ratiu@collabora.com/n\n\nHi again Brian,\n\nv2 of the parallel series is out if you want to review it:\n\nhttps://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u\n\nP.S. I think your spam filter is blocking all my e-mails? I get this\nreply from you:\n\n sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1\n <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:\n CONN:SPAM\n\n   Original-Recipient: rfc822; sandals@crustytoothpaste.net\n   Final-Recipient: rfc822; sandals@crustytoothpaste.net\n   Status: 554\n   Action: failed\n   Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT\n   Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM\n   Remote-MTA: dns; complex.crustytoothpaste.net\n"},{"id":"537121","messageId":"xmqqzf4wacuh.fsf@gitster.g","threadId":"64918","inReplyTo":"87ikbpwr13.fsf@collabora.com","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-25T18:37:42Z","receivedAt":"2026-02-25T18:37:45Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> On Sat, 21 Feb 2026, Adrian Ratiu <adrian.ratiu@collabora.com> wrote:\n>> On Fri, 20 Feb 2026, \"brian m. carlson\" <sandals@crustytoothpaste.net> wrote:\n>>> On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:\n>>>> Hello everyone,\n>>>> \n>>>> v2 addresses all feedback received in v1.\n>>>\n>>> I ask because situations where the hook output is not handled gracefully\n>>> and hooks fight over output or where the existence of TTY on a file\n>>> descriptor is not preserved will result in bug reports and broken tests\n>>> for tools that use Git, which I think we'd all like to avoid.\n>>\n>> Hi Brian,\n>>\n>> Yes, this is all done already. Phillip Wood actually brought this TTY\n>> issue up in his review of the v1 parallel hooks series (many thanks). :) \n>> ...\n>>\n> Hi again Brian,\n>\n> v2 of the parallel series is out if you want to review it:\n>\n> https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u\n>\n> P.S. I think your spam filter is blocking all my e-mails? I get this\n> reply from you:\n>\n>  sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1\n>  <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:\n>  CONN:SPAM\n>\n>    Original-Recipient: rfc822; sandals@crustytoothpaste.net\n>    Final-Recipient: rfc822; sandals@crustytoothpaste.net\n>    Status: 554\n>    Action: failed\n>    Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT\n>    Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM\n>    Remote-MTA: dns; complex.crustytoothpaste.net\n\nSo, shall we make this \"hooks specified by config\" advance, while\nexpecting the parallelized execution to further evolve as a follow\nup series that will still be out of 'next' for now?\n\nThanks.\n\n"},{"id":"537158","messageId":"aZ94HdcOUGp91UBT@fruit.crustytoothpaste.net","threadId":"64918","inReplyTo":"87ikbpwr13.fsf@collabora.com","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"brian m. carlson","fromEmail":"sandals@crustytoothpaste.net","sentAt":"2026-02-25T22:30:53Z","receivedAt":"2026-02-25T22:31:00Z","isPatch":true,"body":"On 2026-02-22 at 00:39:04, Adrian Ratiu wrote:\n> Hi again Brian,\n> \n> v2 of the parallel series is out if you want to review it:\n> \n> https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u\n\nThanks, I'll take a look either today or a little later this week.\n\n> P.S. I think your spam filter is blocking all my e-mails? I get this\n> reply from you:\n> \n>  sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1\n>  <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:\n>  CONN:SPAM\n> \n>    Original-Recipient: rfc822; sandals@crustytoothpaste.net\n>    Final-Recipient: rfc822; sandals@crustytoothpaste.net\n>    Status: 554\n>    Action: failed\n>    Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT\n>    Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM\n\nYes, this is because one of Zoho's customers sent me spam and they\ndidn't act on the spam complaint in a timely manner.  I've removed that\nblock[0] and we'll see if they've fixed that in the past six years or\nso.  If not, I'll re-block them and you can have their postmaster reach\nout to me at my postmaster address to discuss things further.\n\nI regret that this is necessary, but unfortunately when you run your own\nmail server, you have to deal with all the abuse yourself and many\ncompanies choose to ignore abuse complaints.\n\n[0] It may take up to thirty minutes for Puppet to update the mail\nserver configuration.\n-- \nbrian m. carlson (they/them)\nToronto, Ontario, CA\n"},{"id":"537182","messageId":"87cy1rbspp.fsf@gentoo.mail-host-address-is-not-set","threadId":"64918","inReplyTo":"xmqqzf4wacuh.fsf@gitster.g","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-26T12:21:54Z","receivedAt":"2026-02-26T12:22:11Z","isPatch":true,"body":"On Wed, 25 Feb 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> On Sat, 21 Feb 2026, Adrian Ratiu <adrian.ratiu@collabora.com> wrote:\n>>> On Fri, 20 Feb 2026, \"brian m. carlson\" <sandals@crustytoothpaste.net> wrote:\n>>>> On 2026-02-18 at 22:23:44, Adrian Ratiu wrote:\n>>>>> Hello everyone,\n>>>>> \n>>>>> v2 addresses all feedback received in v1.\n>>>>\n>>>> I ask because situations where the hook output is not handled gracefully\n>>>> and hooks fight over output or where the existence of TTY on a file\n>>>> descriptor is not preserved will result in bug reports and broken tests\n>>>> for tools that use Git, which I think we'd all like to avoid.\n>>>\n>>> Hi Brian,\n>>>\n>>> Yes, this is all done already. Phillip Wood actually brought this TTY\n>>> issue up in his review of the v1 parallel hooks series (many thanks). :) \n>>> ...\n>>>\n>> Hi again Brian,\n>>\n>> v2 of the parallel series is out if you want to review it:\n>>\n>> https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u\n>>\n>> P.S. I think your spam filter is blocking all my e-mails? I get this\n>> reply from you:\n>>\n>>  sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1\n>>  <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:\n>>  CONN:SPAM\n>>\n>>    Original-Recipient: rfc822; sandals@crustytoothpaste.net\n>>    Final-Recipient: rfc822; sandals@crustytoothpaste.net\n>>    Status: 554\n>>    Action: failed\n>>    Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT\n>>    Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM\n>>    Remote-MTA: dns; complex.crustytoothpaste.net\n>\n> So, shall we make this \"hooks specified by config\" advance, while\n> expecting the parallelized execution to further evolve as a follow\n> up series that will still be out of 'next' for now?\n\nYes, that is correct.\n\nThese are independent patch series which just happen to depend one upon\nthe other. Other than that, they are completely separate and should be\nreviewed independently, each on its own merits.\n\n(When you have some time please review v2 of the parallel series.)\n\nI do plan to send v3 of this config series, then v3 of the parallel\nseries after it gathers feedback.\n\nThey do not need to evolve in tandem, btw.\n\nWe can even put the parallel series \"on pause\" until this config series\nis done and lands, if it makes things easier.\n\nThanks,\nAdrian\n"},{"id":"537184","messageId":"87a4wvbrsq.fsf@collabora.com","threadId":"64918","inReplyTo":"aZ94HdcOUGp91UBT@fruit.crustytoothpaste.net","subject":"Re: [PATCH v2 0/8] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-02-26T12:41:41Z","receivedAt":"2026-02-26T12:41:52Z","isPatch":true,"body":"On Wed, 25 Feb 2026, \"brian m. carlson\" <sandals@crustytoothpaste.net> wrote:\n> On 2026-02-22 at 00:39:04, Adrian Ratiu wrote:\n>> Hi again Brian,\n>> \n>> v2 of the parallel series is out if you want to review it:\n>> \n>> https://lore.kernel.org/git/20260222002904.1879356-1-adrian.ratiu@collabora.com/T/#u\n>\n> Thanks, I'll take a look either today or a little later this week.\n\nMuch appreciated. I'm in no rush, as I mentioned to Junio we could even\nput the parallel series on pause until we finish and land this config\nseries, which is its dependency.\n\nIt's still good to have all the code out, though, and I do intend to\nperiodically rebase the parallel series on this one.\n\n>> P.S. I think your spam filter is blocking all my e-mails? I get this\n>> reply from you:\n>> \n>>  sandals@crustytoothpaste.net, ERROR CODE :554 - 5.7.1\n>>  <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected:\n>>  CONN:SPAM\n>> \n>>    Original-Recipient: rfc822; sandals@crustytoothpaste.net\n>>    Final-Recipient: rfc822; sandals@crustytoothpaste.net\n>>    Status: 554\n>>    Action: failed\n>>    Last-Attempt-Date: 22 Feb 2026 00:30:10 GMT\n>>    Diagnostic-Code: 5.7.1 <sender4-op-o12.zoho.com[136.143.188.12]>: Client host rejected: CONN:SPAM\n>\n> Yes, this is because one of Zoho's customers sent me spam and they\n> didn't act on the spam complaint in a timely manner.  I've removed that\n> block[0] and we'll see if they've fixed that in the past six years or\n> so.  If not, I'll re-block them and you can have their postmaster reach\n> out to me at my postmaster address to discuss things further.\n>\n> I regret that this is necessary, but unfortunately when you run your own\n> mail server, you have to deal with all the abuse yourself and many\n> companies choose to ignore abuse complaints.\n\nThanks, much appreciated.\n\nCollabora also runs an internal mail-server behind a VPN, so if Zoho\ncontinues being a problem, I'll just switch to sending mails through\nthat server. I mostly use Zoho to avoid dealing with the VPN. :)\n"},{"id":"537454","messageId":"20260301184500.1488433-1-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260204165126.1548805-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 00/12][next] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:48Z","receivedAt":"2026-03-01T18:46:18Z","isPatch":true,"body":"Hello everyone,\n\nv3 addresses all feedback received in v2 (details below, including range-diff).\n\nThis series adds a new feature: the ability to specify commands to run\nfor hook events via config entries (including shell commands).\n\nSo instead of dropping a shell script or a custom program in .git/hooks\nyou can now tell git via config files to run a program or shell script\n(can be specified directly in the config) when you run hook \"foo\".\n\nThis also means you can setup global hooks to run in multiple repos via\nglobal configs and there's an option to disable them if necessary.\n\nFor simplicity, because this series is becoming rather big, hooks are\nstill executed sequentially (.jobs == 1). Parallel execution is added\nin another follow-up patch series.\n\nThis is based on the latest next branch because it depends on some\ncommits which haven't yet landed in master.\n\nBranch pused to GitHub: [1]\nSuccesful CI run: [2]\n\nMany thanks to all who contributed to this effort up to now, including\nEmily, AEvar, Junio, Patrick, Peff, Kristoffer, Chris and many others.\n\nThank you,\nAdrian\n\n1: https://github.com/10ne1/git/tree/refs/heads/dev/aratiu/config-hooks-v3\n2: https://github.com/10ne1/git/actions/runs/22549045872\n\nChanges in v3:\n* Rebased on next (no conflicts) because the hook dep series landed (Junio)\n* Simplify cb data alloc/free function check to use the form (!A != !B) (Junio)\n* New commit: `git hook list` lists disabled hooks (Patrick, Junio)\n* New commit: store hook config scope and list it with git hook list (Junio)\n* New commit: introduce struct hook_config_cache_entry eariler; this used to\n  be done in the parallel hooks series, however we need it earlier in this\n  for the  config scope + disabled hooks caching and listing (Junio, Patrick)\n* Move disable_hooks def to the commit which introduces the feature (Patrick)\n* Add comment about alloc/free cb data ownership to hook.h (Adrian)\n* Replaced xcalloc() and xmalloc() with CALLOC_ARRAY() in all patches (Patrick)\n* Static initialize struct receive_hook_feed_state in receive hook (Patrick)\n* Rename cb_data_free/alloc() -> hook_data_free/alloc() callbacks (Patrick)\n* Rename hook_clear() -> hook_free() (Patrick)\n* Move unsorted_string_list_remove() helper to string-list.{c,h} (Patrick)\n* Always prefer the free() variants instead of clear() (Junio)\n* Use the standard string_list_clear_func() instead of my own hook_list_clear(),\n  to do this I had to store a pointer to the free() cb in struct hook. (Patrick)\n* Drop the use of hook_cache_clear(), it should have been hook_cache_free(),\n  however it's not necessary after the above string_list_clear_func() (Junio)\n* Trigger a BUG() when a hook type is unknown in pick_next_hook() (Patrick)\n* Replaced hook.<name>.* -> hook.<friendly-name>.* (Junio)\n* Removed unnecessary abspath.h include addition in builtin/hook.c (Adrian)\n* Added more tests for various configured hooks corner-cases (Adrian)\n* Minor typos and style fixes (Patrick)\n\n 1:  6fe0e2eea4 !  1:  4c9e2b4c95 hook: add internal state alloc/free callbacks\n    @@ builtin/receive-pack.c: static int feed_receive_hook_cb(int hook_stdin_fd, void\n     +static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)\n     +{\n     +\tstruct receive_hook_feed_state *init_state = feed_pipe_ctx;\n    -+\tstruct receive_hook_feed_state *data = xcalloc(1, sizeof(*data));\n    ++\tstruct receive_hook_feed_state *data;\n    ++\tCALLOC_ARRAY(data, 1);\n     +\tdata->report = init_state->report;\n     +\tdata->cmd = init_state->cmd;\n     +\tdata->skip_broken = init_state->skip_broken;\n    @@ builtin/receive-pack.c: static int run_receive_hook(struct command *commands,\n      \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n      \tstruct command *iter = commands;\n     -\tstruct receive_hook_feed_state feed_state;\n    -+\tstruct receive_hook_feed_state feed_init_state = { 0 };\n    ++\tstruct receive_hook_feed_state feed_init_state = {\n    ++\t\t.cmd = commands,\n    ++\t\t.skip_broken = skip_broken,\n    ++\t\t.buf = STRBUF_INIT,\n    ++\t};\n      \tstruct async sideband_async;\n      \tint sideband_async_started = 0;\n      \tint saved_stderr = -1;\n    @@ builtin/receive-pack.c: static int run_receive_hook(struct command *commands,\n     -\tfeed_state.report = NULL;\n     -\tstrbuf_init(&feed_state.buf, 0);\n     -\topt.feed_pipe_cb_data = &feed_state;\n    -+\tfeed_init_state.cmd = commands;\n    -+\tfeed_init_state.skip_broken = skip_broken;\n     +\topt.feed_pipe_ctx = &feed_init_state;\n      \topt.feed_pipe = feed_receive_hook_cb;\n     +\topt.feed_pipe_cb_data_alloc = receive_hook_feed_state_alloc;\n    @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,\n     +\t * Ensure cb_data copy and free functions are either provided together,\n     +\t * or neither one is provided.\n     +\t */\n    -+\tif ((options->feed_pipe_cb_data_alloc && !options->feed_pipe_cb_data_free) ||\n    -+\t    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))\n    ++\tif (!options->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free)\n     +\t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n     +\n     +\tif (options->feed_pipe_cb_data_alloc)\n    @@ hook.h\n      \n      struct repository;\n      \n    -+typedef void (*cb_data_free_fn)(void *data);\n    -+typedef void *(*cb_data_alloc_fn)(void *init_ctx);\n    ++typedef void (*hook_data_free_fn)(void *data);\n    ++typedef void *(*hook_data_alloc_fn)(void *init_ctx);\n     +\n      struct run_hooks_opt\n      {\n    @@ hook.h: struct run_hooks_opt\n     +\t * If provided, this function will be called to alloc & initialize the\n     +\t * `feed_pipe_cb_data` for each hook.\n     +\t *\n    ++\t * The caller must provide a `feed_pipe_cb_data_free` callback to free\n    ++\t * this memory (missing callback will trigger a bug). Use only the cb to\n    ++\t * free the memory, do not free it manually in the caller.\n    ++\t *\n     +\t * The `feed_pipe_ctx` pointer can be used to pass initialization data.\n     +\t */\n    -+\tcb_data_alloc_fn feed_pipe_cb_data_alloc;\n    ++\thook_data_alloc_fn feed_pipe_cb_data_alloc;\n     +\n     +\t/**\n     +\t * Called to free the memory initialized by `feed_pipe_cb_data_alloc`.\n     +\t *\n     +\t * Must always be provided when `feed_pipe_cb_data_alloc` is provided.\n     +\t */\n    -+\tcb_data_free_fn feed_pipe_cb_data_free;\n    ++\thook_data_free_fn feed_pipe_cb_data_free;\n      };\n      \n      #define RUN_HOOKS_OPT_INIT { \\\n    @@ refs.c: static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, v\n      \n     +static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED)\n     +{\n    -+\tstruct transaction_feed_cb_data *data = xmalloc(sizeof(*data));\n    ++\tstruct transaction_feed_cb_data *data;\n    ++\tCALLOC_ARRAY(data, 1);\n     +\tstrbuf_init(&data->buf, 0);\n     +\tdata->index = 0;\n     +\treturn data;\n    @@ transport.c: static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb\n      \n     +static void *pre_push_hook_data_alloc(void *feed_pipe_ctx)\n     +{\n    -+\tstruct feed_pre_push_hook_data *data = xmalloc(sizeof(*data));\n    ++\tstruct feed_pre_push_hook_data *data;\n    ++\tCALLOC_ARRAY(data, 1);\n     +\tstrbuf_init(&data->buf, 0);\n     +\tdata->refs = (struct ref *)feed_pipe_ctx;\n     +\treturn data;\n 2:  2917d45a19 !  2:  d1579a4435 hook: run a list of hooks to prepare for multihook support\n    @@ hook.c: const char *find_hook(struct repository *r, const char *name)\n      \treturn path.buf;\n      }\n      \n    -+static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n    ++/*\n    ++ * Frees a struct hook stored as the util pointer of a string_list_item.\n    ++ * Suitable for use as a string_list_clear_func_t callback.\n    ++ */\n    ++static void hook_free(void *p, const char *str UNUSED)\n     +{\n    ++\tstruct hook *h = p;\n    ++\n     +\tif (!h)\n     +\t\treturn;\n     +\n     +\tif (h->kind == HOOK_TRADITIONAL)\n     +\t\tfree((void *)h->u.traditional.path);\n     +\n    -+\tif (cb_data_free)\n    -+\t\tcb_data_free(h->feed_pipe_cb_data);\n    ++\tif (h->data_free)\n    ++\t\th->data_free(h->feed_pipe_cb_data);\n     +\n     +\tfree(h);\n     +}\n     +\n    -+static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n    -+{\n    -+\tstruct string_list_item *item;\n    -+\n    -+\tfor_each_string_list_item(item, hooks)\n    -+\t\thook_clear(item->util, cb_data_free);\n    -+\n    -+\tstring_list_clear(hooks, 0);\n    -+}\n    -+\n     +/* Helper to detect and add default \"traditional\" hooks from the hookdir. */\n     +static void list_hooks_add_default(struct repository *r, const char *hookname,\n     +\t\t\t\t   struct string_list *hook_list,\n    @@ hook.c: const char *find_hook(struct repository *r, const char *name)\n     +\tif (!hook_path)\n     +\t\treturn;\n     +\n    -+\th = xcalloc(1, sizeof(struct hook));\n    ++\tCALLOC_ARRAY(h, 1);\n     +\n     +\t/*\n     +\t * If the hook is to run in a specific dir, a relative path can\n    @@ hook.c: const char *find_hook(struct repository *r, const char *name)\n     +\t/* Setup per-hook internal state cb data */\n     +\tif (options && options->feed_pipe_cb_data_alloc)\n     +\t\th->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n    ++\tif (options)\n    ++\t\th->data_free = options->feed_pipe_cb_data_free;\n     +\n     +\th->kind = HOOK_TRADITIONAL;\n     +\th->u.traditional.path = xstrdup(hook_path);\n    @@ hook.c: const char *find_hook(struct repository *r, const char *name)\n     +\tif (!hookname)\n     +\t\tBUG(\"null hookname was provided to hook_list()!\");\n     +\n    -+\thook_head = xmalloc(sizeof(struct string_list));\n    ++\tCALLOC_ARRAY(hook_head, 1);\n     +\tstring_list_init_dup(hook_head);\n     +\n     +\t/* Add the default \"traditional\" hooks from hookdir. */\n    @@ hook.c: const char *find_hook(struct repository *r, const char *name)\n     -\treturn !!find_hook(r, name);\n     +\tstruct string_list *hooks = list_hooks(r, name, NULL);\n     +\tint exists = hooks->nr > 0;\n    -+\thook_list_clear(hooks, NULL);\n    ++\tstring_list_clear_func(hooks, hook_free);\n     +\tfree(hooks);\n     +\treturn exists;\n      }\n    @@ hook.c: static int pick_next_hook(struct child_process *cp,\n     +\t/* Add hook exec paths or commands */\n     +\tif (h->kind == HOOK_TRADITIONAL)\n     +\t\tstrvec_push(&cp->args, h->u.traditional.path);\n    ++\telse\n    ++\t\tBUG(\"unknown hook kind\");\n     +\n     +\tif (!cp->args.nr)\n     +\t\tBUG(\"hook must have at least one command or exec path\");\n    @@ hook.c: static void run_hooks_opt_clear(struct run_hooks_opt *options)\n      \tconst struct run_process_parallel_opts opts = {\n      \t\t.tr2_category = \"hook\",\n     @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,\n    - \t    (!options->feed_pipe_cb_data_alloc && options->feed_pipe_cb_data_free))\n    + \tif (!options->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free)\n      \t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n      \n     -\tif (options->feed_pipe_cb_data_alloc)\n    @@ hook.c: int run_hooks_opt(struct repository *r, const char *hook_name,\n      \t\t*options->invoked_hook = 0;\n      \n     -\tif (!hook_path && !options->error_if_missing)\n    --\t\tgoto cleanup;\n    --\n    --\tif (!hook_path) {\n    --\t\tret = error(\"cannot find a hook named %s\", hook_name);\n     +\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n     +\tif (!cb_data.hook_command_list->nr) {\n     +\t\tif (options->error_if_missing)\n     +\t\t\tret = error(\"cannot find a hook named %s\", hook_name);\n      \t\tgoto cleanup;\n    - \t}\n    - \n    +-\n    +-\tif (!hook_path) {\n    +-\t\tret = error(\"cannot find a hook named %s\", hook_name);\n    +-\t\tgoto cleanup;\n    +-\t}\n    +-\n     -\tcb_data.hook_path = hook_path;\n     -\tif (options->dir) {\n     -\t\tstrbuf_add_absolute_path(&abs_path, hook_path);\n     -\t\tcb_data.hook_path = abs_path.buf;\n    --\t}\n    --\n    + \t}\n    + \n      \trun_processes_parallel(&opts);\n      \tret = cb_data.rc;\n      cleanup:\n     -\tstrbuf_release(&abs_path);\n    -+\thook_list_clear(cb_data.hook_command_list, options->feed_pipe_cb_data_free);\n    ++\tstring_list_clear_func(cb_data.hook_command_list, hook_free);\n     +\tfree(cb_data.hook_command_list);\n      \trun_hooks_opt_clear(options);\n      \treturn ret;\n    @@ hook.h\n      \n      struct repository;\n      \n    + typedef void (*hook_data_free_fn)(void *data);\n    + typedef void *(*hook_data_alloc_fn)(void *init_ctx);\n    + \n     +/**\n     + * Represents a hook command to be run.\n     + * Hooks can be:\n    @@ hook.h\n     +\t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n     +\t */\n     +\tvoid *feed_pipe_cb_data;\n    ++\n    ++\t/**\n    ++\t * Callback to free `feed_pipe_cb_data`.\n    ++\t *\n    ++\t * It is called automatically and points to the `feed_pipe_cb_data_free`\n    ++\t * provided via the `run_hook_opt` parameter.\n    ++\t */\n    ++\thook_data_free_fn data_free;\n     +};\n     +\n    - typedef void (*cb_data_free_fn)(void *data);\n    - typedef void *(*cb_data_alloc_fn)(void *init_ctx);\n    - \n    + struct run_hooks_opt\n    + {\n    + \t/* Environment vars to be set for each hook */\n     @@ hook.h: struct run_hooks_opt\n      \t */\n      \tvoid *feed_pipe_ctx;\n    @@ hook.h: struct hook_cb_data {\n      \tstruct run_hooks_opt *options;\n      };\n      \n    +-/*\n    ++/**\n    +  * Returns the path to the hook file, or NULL if the hook is missing\n    +  * or disabled. Note that this points to static storage that will be\n    +  * overwritten by further calls to find_hook and run_hook_*.\n 3:  19d41e85e1 !  3:  b2ab0d3736 hook: add \"git hook list\" command\n    @@ Documentation/git-hook.adoc: Any positional arguments to the hook should be pass\n     \n      ## builtin/hook.c ##\n     @@\n    - #include \"hook.h\"\n    - #include \"parse-options.h\"\n    - #include \"strvec.h\"\n    -+#include \"abspath.h\"\n      \n      #define BUILTIN_HOOK_RUN_USAGE \\\n      \tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {\n     +\t * arguments later they probably should be caught by parse_options.\n     +\t */\n     +\tif (argc != 1)\n    -+\t\tusage_msg_opt(_(\"You must specify a hook event name to list.\"),\n    ++\t\tusage_msg_opt(_(\"you must specify a hook event name to list.\"),\n     +\t\t\t      builtin_hook_list_usage, list_options);\n     +\n     +\thookname = argv[0];\n    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {\n     +\thead = list_hooks(repo, hookname, NULL);\n     +\n     +\tif (!head->nr) {\n    -+\t\twarning(_(\"No hooks found for event '%s'\"), hookname);\n    ++\t\twarning(_(\"no hooks found for event '%s'\"), hookname);\n     +\t\tret = 1; /* no hooks found */\n     +\t\tgoto cleanup;\n     +\t}\n    @@ builtin/hook.c: static const char * const builtin_hook_run_usage[] = {\n     +\t}\n     +\n     +cleanup:\n    -+\thook_list_clear(head, NULL);\n    ++\tstring_list_clear_func(head, hook_free);\n     +\tfree(head);\n     +\treturn ret;\n     +}\n    @@ builtin/hook.c: int cmd_hook(int argc,\n      \n     \n      ## hook.c ##\n    -@@ hook.c: static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n    - \tfree(h);\n    +@@ hook.c: const char *find_hook(struct repository *r, const char *name)\n    + \treturn path.buf;\n      }\n      \n    --static void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n    -+void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free)\n    +-/*\n    +- * Frees a struct hook stored as the util pointer of a string_list_item.\n    +- * Suitable for use as a string_list_clear_func_t callback.\n    +- */\n    +-static void hook_free(void *p, const char *str UNUSED)\n    ++void hook_free(void *p, const char *str UNUSED)\n      {\n    - \tstruct string_list_item *item;\n    + \tstruct hook *h = p;\n      \n     @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,\n      \tstring_list_append(hook_list, hook_path)->util = h;\n    @@ hook.h: struct hook_cb_data {\n      \tstruct run_hooks_opt *options;\n      };\n      \n    --/*\n    ++/**\n    ++ * Frees a struct hook stored as the util pointer of a string_list_item.\n    ++ * Suitable for use as a string_list_clear_func_t callback.\n    ++ */\n    ++void hook_free(void *p, const char *str);\n    ++\n     +/**\n     + * Provides a list of hook commands to run for the 'hookname' event.\n     + *\n    @@ hook.h: struct hook_cb_data {\n     +struct string_list *list_hooks(struct repository *r, const char *hookname,\n     +\t\t\t       struct run_hooks_opt *options);\n     +\n    -+/**\n    -+ * Frees the memory allocated for the hook list, including the `struct hook`\n    -+ * items and their internal state.\n    -+ */\n    -+void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n    -+\n    -+/**\n    + /**\n       * Returns the path to the hook file, or NULL if the hook is missing\n       * or disabled. Note that this points to static storage that will be\n    -  * overwritten by further calls to find_hook and run_hook_*.\n     \n      ## t/t1800-hook.sh ##\n     @@ t/t1800-hook.sh: test_expect_success 'git hook usage' '\n    @@ t/t1800-hook.sh: test_expect_success 'git hook usage' '\n      \n     +test_expect_success 'git hook list: nonexistent hook' '\n     +\tcat >stderr.expect <<-\\EOF &&\n    -+\twarning: No hooks found for event '\\''test-hook'\\''\n    ++\twarning: no hooks found for event '\\''test-hook'\\''\n     +\tEOF\n     +\ttest_expect_code 1 git hook list test-hook 2>stderr.actual &&\n     +\ttest_cmp stderr.expect stderr.actual\n -:  ---------- >  4:  818bee4066 string-list: add unsorted_string_list_remove()\n 4:  0c98b18bf5 !  5:  1ed0264a9f hook: include hooks from the config\n    @@ Commit message\n         \"hook.<friendly-name>.command = <path-to-hook>\" and\n         \"hook.<friendly-name>.event = <hook-event>\" lines.\n     \n    -    Hooks will be started in config order of the \"hook.<name>.event\"\n    +    Hooks will be started in config order of the \"hook.<friendly-name>.event\"\n         lines and will be run sequentially (.jobs == 1) like before.\n         Running the hooks in parallel will be enabled in a future patch.\n     \n    @@ Commit message\n     \n      ## Documentation/config/hook.adoc (new) ##\n     @@\n    -+hook.<name>.command::\n    -+\tThe command to execute for `hook.<name>`. `<name>` is a unique\n    ++hook.<friendly-name>.command::\n    ++\tThe command to execute for `hook.<friendly-name>`. `<friendly-name>` is a unique\n     +\t\"friendly\" name that identifies this hook. (The hook events that\n    -+\ttrigger the command are configured with `hook.<name>.event`.) The\n    ++\ttrigger the command are configured with `hook.<friendly-name>.event`.) The\n     +\tvalue can be an executable path or a shell oneliner. If more than\n    -+\tone value is specified for the same `<name>`, only the last value\n    ++\tone value is specified for the same `<friendly-name>`, only the last value\n     +\tparsed is used. See linkgit:git-hook[1].\n     +\n    -+hook.<name>.event::\n    -+\tThe hook events that trigger `hook.<name>`. The value is the name\n    ++hook.<friendly-name>.event::\n    ++\tThe hook events that trigger `hook.<friendly-name>`. The value is the name\n     +\tof a hook event, like \"pre-commit\" or \"update\". (See\n     +\tlinkgit:githooks[5] for a complete list of hook events.) On the\n    -+\tspecified event, the associated `hook.<name>.command` is executed.\n    -+\tThis is a multi-valued key. To run `hook.<name>` on multiple\n    ++\tspecified event, the associated `hook.<friendly-name>.command` is executed.\n    ++\tThis is a multi-valued key. To run `hook.<friendly-name>` on multiple\n     +\tevents, specify the key more than once. See linkgit:git-hook[1].\n     \n      ## Documentation/git-hook.adoc ##\n    @@ Documentation/git-hook.adoc: DESCRIPTION\n     +message (during the `commit-msg` hook event).\n     +\n     +Commands are run in the order Git encounters their associated\n    -+`hook.<name>.event` configs during the configuration parse (see\n    ++`hook.<friendly-name>.event` configs during the configuration parse (see\n     +linkgit:git-config[1]). Although multiple `hook.linter.event` configs can be\n     +added, only one `hook.linter.command` event is valid - Git uses \"last-one-wins\"\n     +to determine which command to run.\n    @@ Documentation/git-hook.adoc: DESCRIPTION\n     +would evaluate the output of each when deciding whether to proceed with the\n     +commit.\n     +\n    -+For a full list of hook events which you can set your `hook.<name>.event` to,\n    ++For a full list of hook events which you can set your `hook.<friendly-name>.event` to,\n     +and how hooks are invoked during those events, see linkgit:githooks[5].\n     +\n    -+Git will ignore any `hook.<name>.event` that specifies an event it doesn't\n    ++Git will ignore any `hook.<friendly-name>.event` that specifies an event it doesn't\n     +recognize. This is intended so that tools which wrap Git can use the hook\n     +infrastructure to run their own hooks; see \"WRAPPERS\" for more guidance.\n     +\n    @@ hook.c\n      #include \"environment.h\"\n      #include \"setup.h\"\n      \n    -@@ hook.c: static void hook_clear(struct hook *h, cb_data_free_fn cb_data_free)\n    +@@ hook.c: void hook_free(void *p, const char *str UNUSED)\n    + \tif (!h)\n    + \t\treturn;\n      \n    - \tif (h->kind == HOOK_TRADITIONAL)\n    +-\tif (h->kind == HOOK_TRADITIONAL)\n    ++\tif (h->kind == HOOK_TRADITIONAL) {\n      \t\tfree((void *)h->u.traditional.path);\n    -+\telse if (h->kind == HOOK_CONFIGURED) {\n    ++\t} else if (h->kind == HOOK_CONFIGURED) {\n     +\t\tfree((void *)h->u.configured.friendly_name);\n     +\t\tfree((void *)h->u.configured.command);\n     +\t}\n      \n    - \tif (cb_data_free)\n    - \t\tcb_data_free(h->feed_pipe_cb_data);\n    + \tif (h->data_free)\n    + \t\th->data_free(h->feed_pipe_cb_data);\n     @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,\n      \tstring_list_append(hook_list, hook_path)->util = h;\n      }\n      \n    -+static void unsorted_string_list_remove(struct string_list *list,\n    -+\t\t\t\t\tconst char *str)\n    -+{\n    -+\tstruct string_list_item *item = unsorted_string_list_lookup(list, str);\n    -+\tif (item)\n    -+\t\tunsorted_string_list_delete_item(list, item - list->items, 0);\n    -+}\n    -+\n     +/*\n     + * Callback struct to collect all hook.* keys in a single config pass.\n     + * commands: friendly-name to command map.\n     + * event_hooks: event-name to list of friendly-names map.\n    -+ * disabled_hooks: set of friendly-names with hook.name.enabled = false.\n     + */\n     +struct hook_all_config_cb {\n     +\tstruct strmap commands;\n     +\tstruct strmap event_hooks;\n    -+\tstruct string_list disabled_hooks;\n     +};\n     +\n     +/* repo_config() callback that collects all hook.* configuration in one pass. */\n    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo\n     +\t\t}\n     +\n     +\t\t/* Re-insert if necessary to preserve last-seen order. */\n    -+\t\tunsorted_string_list_remove(hooks, hook_name);\n    ++\t\tunsorted_string_list_remove(hooks, hook_name, 0);\n     +\t\tstring_list_append(hooks, hook_name);\n     +\t} else if (!strcmp(subkey, \"command\")) {\n     +\t\t/* Store command overwriting the old value */\n    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo\n     +\n     +\tstrmap_init(&cb_data.commands);\n     +\tstrmap_init(&cb_data.event_hooks);\n    -+\tstring_list_init_dup(&cb_data.disabled_hooks);\n     +\n     +\t/* Parse all configs in one run. */\n     +\trepo_config(r, hook_config_lookup_all, &cb_data);\n    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo\n     +\t/* Construct the cache from parsed configs. */\n     +\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n     +\t\tstruct string_list *hook_names = e->value;\n    -+\t\tstruct string_list *hooks = xcalloc(1, sizeof(*hooks));\n    ++\t\tstruct string_list *hooks;\n    ++\t\tCALLOC_ARRAY(hooks, 1);\n     +\n     +\t\tstring_list_init_dup(hooks);\n     +\n    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo\n     +\t}\n     +\n     +\tstrmap_clear(&cb_data.commands, 1);\n    -+\tstring_list_clear(&cb_data.disabled_hooks, 0);\n     +\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n     +\t\tstring_list_clear(e->value, 0);\n     +\t\tfree(e->value);\n    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo\n     +\t\t * it just once on the first call.\n     +\t\t */\n     +\t\tif (!r->hook_config_cache) {\n    -+\t\t\tr->hook_config_cache = xcalloc(1, sizeof(*cache));\n    ++\t\t\tCALLOC_ARRAY(r->hook_config_cache, 1);\n     +\t\t\tstrmap_init(r->hook_config_cache);\n     +\t\t\tbuild_hook_config_map(r, r->hook_config_cache);\n     +\t\t}\n    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo\n     +\tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n     +\t\tconst char *friendly_name = configured_hooks->items[i].string;\n     +\t\tconst char *command = configured_hooks->items[i].util;\n    -+\t\tstruct hook *hook = xcalloc(1, sizeof(struct hook));\n    ++\t\tstruct hook *hook;\n    ++\t\tCALLOC_ARRAY(hook, 1);\n     +\n     +\t\tif (options && options->feed_pipe_cb_data_alloc)\n     +\t\t\thook->feed_pipe_cb_data =\n     +\t\t\t\toptions->feed_pipe_cb_data_alloc(\n     +\t\t\t\t\toptions->feed_pipe_ctx);\n    ++\t\tif (options)\n    ++\t\t\thook->data_free = options->feed_pipe_cb_data_free;\n     +\n     +\t\thook->kind = HOOK_CONFIGURED;\n     +\t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n    @@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hoo\n      \t\t\t       struct run_hooks_opt *options)\n      {\n     @@ hook.c: struct string_list *list_hooks(struct repository *r, const char *hookname,\n    - \thook_head = xmalloc(sizeof(struct string_list));\n    + \tCALLOC_ARRAY(hook_head, 1);\n      \tstring_list_init_dup(hook_head);\n      \n     +\t/* Add hooks from the config, e.g. hook.myhook.event = pre-commit */\n    @@ hook.c: static int pick_next_hook(struct child_process *cp,\n      \n      \t/* Add hook exec paths or commands */\n     -\tif (h->kind == HOOK_TRADITIONAL)\n    -+\tif (h->kind == HOOK_TRADITIONAL) {\n    ++\tswitch (h->kind) {\n    ++\tcase HOOK_TRADITIONAL:\n      \t\tstrvec_push(&cp->args, h->u.traditional.path);\n    -+\t} else if (h->kind == HOOK_CONFIGURED) {\n    +-\telse\n    ++\t\tbreak;\n    ++\tcase HOOK_CONFIGURED:\n     +\t\t/* to enable oneliners, let config-specified hooks run in shell. */\n     +\t\tcp->use_shell = true;\n     +\t\tstrvec_push(&cp->args, h->u.configured.command);\n    ++\t\tbreak;\n    ++\tdefault:\n    + \t\tBUG(\"unknown hook kind\");\n     +\t}\n      \n      \tif (!cp->args.nr)\n    @@ hook.h\n      \n      struct repository;\n      \n    -@@ hook.h: struct repository;\n    +@@ hook.h: typedef void *(*hook_data_alloc_fn)(void *init_ctx);\n       * Represents a hook command to be run.\n       * Hooks can be:\n       * 1. \"traditional\" (found in the hooks directory)\n     - * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n    -+ * 2. \"configured\" (defined in Git's configuration via hook.<name>.event).\n    ++ * 2. \"configured\" (defined in Git's configuration via hook.<friendly-name>.event).\n       * The 'kind' field determines which part of the union 'u' is valid.\n       */\n      struct hook {\n    @@ hook.h: struct repository;\n      \t} u;\n      \n      \t/**\n    -@@ hook.h: struct string_list *list_hooks(struct repository *r, const char *hookname,\n    -  */\n    - void hook_list_clear(struct string_list *hooks, cb_data_free_fn cb_data_free);\n    +@@ hook.h: void hook_free(void *p, const char *str);\n    + struct string_list *list_hooks(struct repository *r, const char *hookname,\n    + \t\t\t       struct run_hooks_opt *options);\n      \n     +/**\n     + * Frees the hook configuration cache stored in `struct repository`.\n    @@ t/t1800-hook.sh\n      test_expect_success 'git hook usage' '\n      \ttest_expect_code 129 git hook &&\n      \ttest_expect_code 129 git hook run &&\n    - \ttest_expect_code 129 git hook run -h &&\n    -+\ttest_expect_code 129 git hook list -h &&\n    - \ttest_expect_code 129 git hook run --unknown 2>err &&\n    - \ttest_expect_code 129 git hook list &&\n    - \ttest_expect_code 129 git hook list -h &&\n     @@ t/t1800-hook.sh: test_expect_success 'git hook list: traditional hook from hookdir' '\n      \ttest_cmp expect actual\n      '\n    @@ t/t1800-hook.sh: test_expect_success TTY 'git commit: stdout and stderr are conn\n     +\ttest_cmp expected actual\n     +'\n     +\n    ++test_expect_success 'configured hooks run before hookdir hook' '\n    ++\tsetup_hookdir &&\n    ++\ttest_config hook.first.event \"pre-commit\" &&\n    ++\ttest_config hook.first.command \"echo first\" &&\n    ++\ttest_config hook.second.event \"pre-commit\" &&\n    ++\ttest_config hook.second.command \"echo second\" &&\n    ++\n    ++\tcat >expected <<-\\EOF &&\n    ++\tfirst\n    ++\tsecond\n    ++\thook from hookdir\n    ++\tEOF\n    ++\n    ++\tgit hook list pre-commit >actual &&\n    ++\ttest_cmp expected actual &&\n    ++\n    ++\tcat >expected <<-\\EOF &&\n    ++\tfirst\n    ++\tsecond\n    ++\t\"Legacy Hook\"\n    ++\tEOF\n    ++\n    ++\tgit hook run pre-commit 2>actual &&\n    ++\ttest_cmp expected actual\n    ++'\n    ++\n     +test_expect_success 'stdin to multiple hooks' '\n     +\ttest_config hook.stdin-a.event \"test-hook\" &&\n     +\ttest_config hook.stdin-a.command \"xargs -P1 -I% echo a%\" &&\n 5:  f71ada4cb8 !  6:  260b845b9e hook: allow disabling config hooks\n    @@ Commit message\n         might want to disable them without removing from the config,\n         like locally disabling a global hook.\n     \n    -    Add a hook.<name>.enabled config which defaults to true and\n    +    Add a hook.<friendly-name>.enabled config which defaults to true and\n         can be optionally set for each configured hook.\n     \n         Suggested-by: Patrick Steinhardt <ps@pks.im>\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Documentation/config/hook.adoc ##\n    -@@ Documentation/config/hook.adoc: hook.<name>.event::\n    - \tspecified event, the associated `hook.<name>.command` is executed.\n    - \tThis is a multi-valued key. To run `hook.<name>` on multiple\n    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.event::\n    + \tspecified event, the associated `hook.<friendly-name>.command` is executed.\n    + \tThis is a multi-valued key. To run `hook.<friendly-name>` on multiple\n      \tevents, specify the key more than once. See linkgit:git-hook[1].\n     +\n    -+hook.<name>.enabled::\n    -+\tWhether the hook `hook.<name>` is enabled. Defaults to `true`.\n    ++hook.<friendly-name>.enabled::\n    ++\tWhether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.\n     +\tSet to `false` to disable the hook without removing its\n     +\tconfiguration. This is particularly useful when a hook is defined\n     +\tin a system or global config file and needs to be disabled for a\n     +\tspecific repository. See linkgit:git-hook[1].\n     \n      ## hook.c ##\n    +@@ hook.c: static void list_hooks_add_default(struct repository *r, const char *hookname,\n    +  * Callback struct to collect all hook.* keys in a single config pass.\n    +  * commands: friendly-name to command map.\n    +  * event_hooks: event-name to list of friendly-names map.\n    ++ * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n    +  */\n    + struct hook_all_config_cb {\n    + \tstruct strmap commands;\n    + \tstruct strmap event_hooks;\n    ++\tstruct string_list disabled_hooks;\n    + };\n    + \n    + /* repo_config() callback that collects all hook.* configuration in one pass. */\n     @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n      \t\tchar *old = strmap_put(&data->commands, hook_name,\n      \t\t\t\t       xstrdup(value));\n    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n      \n      \tfree(hook_name);\n     @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n    + \n    + \tstrmap_init(&cb_data.commands);\n    + \tstrmap_init(&cb_data.event_hooks);\n    ++\tstring_list_init_dup(&cb_data.disabled_hooks);\n    + \n    + \t/* Parse all configs in one run. */\n    + \trepo_config(r, hook_config_lookup_all, &cb_data);\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n      \t\t\tconst char *hname = hook_names->items[i].string;\n      \t\t\tchar *command;\n      \n    @@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *c\n      \t\t\tcommand = strmap_get(&cb_data.commands, hname);\n      \t\t\tif (!command)\n      \t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n    +@@ hook.c: static void build_hook_config_map(struct repository *r, struct strmap *cache)\n    + \t}\n    + \n    + \tstrmap_clear(&cb_data.commands, 1);\n    ++\tstring_list_clear(&cb_data.disabled_hooks, 0);\n    + \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n    + \t\tstring_list_clear(e->value, 0);\n    + \t\tfree(e->value);\n     \n      ## t/t1800-hook.sh ##\n     @@ t/t1800-hook.sh: test_expect_success 'rejects hooks with no commands configured' '\n 6:  82a7d6167f !  7:  dcc595751e hook: allow event = \"\" to overwrite previous values\n    @@ Commit message\n         Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n     \n      ## Documentation/config/hook.adoc ##\n    -@@ Documentation/config/hook.adoc: hook.<name>.event::\n    +@@ Documentation/config/hook.adoc: hook.<friendly-name>.event::\n      \tlinkgit:githooks[5] for a complete list of hook events.) On the\n    - \tspecified event, the associated `hook.<name>.command` is executed.\n    - \tThis is a multi-valued key. To run `hook.<name>` on multiple\n    + \tspecified event, the associated `hook.<friendly-name>.command` is executed.\n    + \tThis is a multi-valued key. To run `hook.<friendly-name>` on multiple\n     -\tevents, specify the key more than once. See linkgit:git-hook[1].\n     +\tevents, specify the key more than once. An empty value resets\n     +\tthe list of events, clearing any previously defined events for\n    -+\t`hook.<name>`. See linkgit:git-hook[1].\n    ++\t`hook.<friendly-name>`. See linkgit:git-hook[1].\n      \n    - hook.<name>.enabled::\n    - \tWhether the hook `hook.<name>` is enabled. Defaults to `true`.\n    + hook.<friendly-name>.enabled::\n    + \tWhether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.\n     \n      ## hook.c ##\n     @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n     +\t\t\tstruct strmap_entry *e;\n     +\n     +\t\t\tstrmap_for_each_entry(&data->event_hooks, &iter, e)\n    -+\t\t\t\tunsorted_string_list_remove(e->value, hook_name);\n    ++\t\t\t\tunsorted_string_list_remove(e->value, hook_name, 0);\n     +\t\t} else {\n     +\t\t\tstruct string_list *hooks =\n     +\t\t\t\tstrmap_get(&data->event_hooks, value);\n     +\n     +\t\t\tif (!hooks) {\n    -+\t\t\t\thooks = xcalloc(1, sizeof(*hooks));\n    ++\t\t\t\tCALLOC_ARRAY(hooks, 1);\n     +\t\t\t\tstring_list_init_dup(hooks);\n     +\t\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n     +\t\t\t}\n    @@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n     -\t\t\tstring_list_init_dup(hooks);\n     -\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n     +\t\t\t/* Re-insert if necessary to preserve last-seen order. */\n    -+\t\t\tunsorted_string_list_remove(hooks, hook_name);\n    ++\t\t\tunsorted_string_list_remove(hooks, hook_name, 0);\n     +\t\t\tstring_list_append(hooks, hook_name);\n      \t\t}\n     -\n     -\t\t/* Re-insert if necessary to preserve last-seen order. */\n    --\t\tunsorted_string_list_remove(hooks, hook_name);\n    +-\t\tunsorted_string_list_remove(hooks, hook_name, 0);\n     -\t\tstring_list_append(hooks, hook_name);\n      \t} else if (!strcmp(subkey, \"command\")) {\n      \t\t/* Store command overwriting the old value */\n      \t\tchar *old = strmap_put(&data->commands, hook_name,\n    +@@ hook.c: static int hook_config_lookup_all(const char *key, const char *value,\n    + \t\t\tbreak;\n    + \t\tcase 1: /* enabled: undo a prior disabled entry */\n    + \t\t\tunsorted_string_list_remove(&data->disabled_hooks,\n    +-\t\t\t\t\t\t    hook_name);\n    ++\t\t\t\t\t\t    hook_name, 0);\n    + \t\t\tbreak;\n    + \t\tdefault:\n    + \t\t\tbreak; /* ignore unrecognised values */\n     \n      ## t/t1800-hook.sh ##\n     @@ t/t1800-hook.sh: test_expect_success 'git hook list reorders on duplicate event declarations' '\n 7:  8d1704384e !  8:  c668b2fec1 hook: allow out-of-repo 'git hook' invocations\n    @@ hook.c: static struct strmap *get_hook_config_cache(struct repository *r)\n     +\t\t * Out-of-repo calls (no gitdir) allocate and return a temporary\n     +\t\t * map cache which gets free'd immediately by the caller.\n     +\t\t */\n    -+\t\tcache = xcalloc(1, sizeof(*cache));\n    ++\t\tCALLOC_ARRAY(cache, 1);\n     +\t\tstrmap_init(cache);\n     +\t\tbuild_hook_config_map(r, cache);\n      \t}\n 8:  7bf527c59e =  9:  807116ff79 hook: add -z option to \"git hook list\"\n -:  ---------- > 10:  57a27ad1d6 hook: refactor hook_config_cache from strmap to named struct\n -:  ---------- > 11:  260300c890 hook: store and display scope for configured hooks in git hook list\n -:  ---------- > 12:  7c79801a63 hook: show disabled hooks in \"git hook list\"\n\nAdrian Ratiu (9):\n  hook: add internal state alloc/free callbacks\n  string-list: add unsorted_string_list_remove()\n  hook: include hooks from the config\n  hook: allow disabling config hooks\n  hook: allow event = \"\" to overwrite previous values\n  hook: add -z option to \"git hook list\"\n  hook: refactor hook_config_cache from strmap to named struct\n  hook: store and display scope for configured hooks in git hook list\n  hook: show disabled hooks in \"git hook list\"\n\nEmily Shaffer (3):\n  hook: run a list of hooks to prepare for multihook support\n  hook: add \"git hook list\" command\n  hook: allow out-of-repo 'git hook' invocations\n\n Documentation/config/hook.adoc |  24 ++\n Documentation/git-hook.adoc    | 142 ++++++++++-\n builtin/hook.c                 |  79 ++++++\n builtin/receive-pack.c         |  36 ++-\n git.c                          |   2 +-\n hook.c                         | 423 ++++++++++++++++++++++++++++++---\n hook.h                         | 127 +++++++++-\n refs.c                         |  25 +-\n repository.c                   |   6 +\n repository.h                   |   7 +\n string-list.c                  |   9 +\n string-list.h                  |   8 +\n t/t1800-hook.sh                | 315 +++++++++++++++++++++++-\n transport.c                    |  28 ++-\n 14 files changed, 1168 insertions(+), 63 deletions(-)\n create mode 100644 Documentation/config/hook.adoc\n\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537455","messageId":"20260301184500.1488433-3-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 02/12] hook: run a list of hooks to prepare for multihook support","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:50Z","receivedAt":"2026-03-01T18:46:20Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nHooks are limited to run one command (the default from the hookdir) for\neach event. This limitation makes it impossible to run multiple commands\nvia config files, which the next commits will add.\n\nImplement the ability to run a list of hooks in hook.[ch]. For now, the\nlist contains only one entry representing the \"default\" hook from the\nhookdir, so there is no user-visible change in this commit.\n\nAll hook commands still run sequentially like before. A separate patch\nseries will enable running them in parallel.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n hook.c | 139 ++++++++++++++++++++++++++++++++++++++++++++-------------\n hook.h |  69 +++++++++++++++++++++-------\n 2 files changed, 162 insertions(+), 46 deletions(-)\n\ndiff --git a/hook.c b/hook.c\nindex a9ade11952..eb52d706b8 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -47,9 +47,95 @@ const char *find_hook(struct repository *r, const char *name)\n \treturn path.buf;\n }\n \n+/*\n+ * Frees a struct hook stored as the util pointer of a string_list_item.\n+ * Suitable for use as a string_list_clear_func_t callback.\n+ */\n+static void hook_free(void *p, const char *str UNUSED)\n+{\n+\tstruct hook *h = p;\n+\n+\tif (!h)\n+\t\treturn;\n+\n+\tif (h->kind == HOOK_TRADITIONAL)\n+\t\tfree((void *)h->u.traditional.path);\n+\n+\tif (h->data_free)\n+\t\th->data_free(h->feed_pipe_cb_data);\n+\n+\tfree(h);\n+}\n+\n+/* Helper to detect and add default \"traditional\" hooks from the hookdir. */\n+static void list_hooks_add_default(struct repository *r, const char *hookname,\n+\t\t\t\t   struct string_list *hook_list,\n+\t\t\t\t   struct run_hooks_opt *options)\n+{\n+\tconst char *hook_path = find_hook(r, hookname);\n+\tstruct hook *h;\n+\n+\tif (!hook_path)\n+\t\treturn;\n+\n+\tCALLOC_ARRAY(h, 1);\n+\n+\t/*\n+\t * If the hook is to run in a specific dir, a relative path can\n+\t * become invalid in that dir, so convert to an absolute path.\n+\t */\n+\tif (options && options->dir)\n+\t\thook_path = absolute_path(hook_path);\n+\n+\t/* Setup per-hook internal state cb data */\n+\tif (options && options->feed_pipe_cb_data_alloc)\n+\t\th->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n+\tif (options)\n+\t\th->data_free = options->feed_pipe_cb_data_free;\n+\n+\th->kind = HOOK_TRADITIONAL;\n+\th->u.traditional.path = xstrdup(hook_path);\n+\n+\tstring_list_append(hook_list, hook_path)->util = h;\n+}\n+\n+/*\n+ * Provides a list of hook commands to run for the 'hookname' event.\n+ *\n+ * This function consolidates hooks from two sources:\n+ * 1. The config-based hooks (not yet implemented).\n+ * 2. The \"traditional\" hook found in the repository hooks directory\n+ *    (e.g., .git/hooks/pre-commit).\n+ *\n+ * The list is ordered by execution priority.\n+ *\n+ * The caller is responsible for freeing the memory of the returned list\n+ * using string_list_clear() and free().\n+ */\n+static struct string_list *list_hooks(struct repository *r, const char *hookname,\n+\t\t\t       struct run_hooks_opt *options)\n+{\n+\tstruct string_list *hook_head;\n+\n+\tif (!hookname)\n+\t\tBUG(\"null hookname was provided to hook_list()!\");\n+\n+\tCALLOC_ARRAY(hook_head, 1);\n+\tstring_list_init_dup(hook_head);\n+\n+\t/* Add the default \"traditional\" hooks from hookdir. */\n+\tlist_hooks_add_default(r, hookname, hook_head, options);\n+\n+\treturn hook_head;\n+}\n+\n int hook_exists(struct repository *r, const char *name)\n {\n-\treturn !!find_hook(r, name);\n+\tstruct string_list *hooks = list_hooks(r, name, NULL);\n+\tint exists = hooks->nr > 0;\n+\tstring_list_clear_func(hooks, hook_free);\n+\tfree(hooks);\n+\treturn exists;\n }\n \n static int pick_next_hook(struct child_process *cp,\n@@ -58,11 +144,14 @@ static int pick_next_hook(struct child_process *cp,\n \t\t\t  void **pp_task_cb)\n {\n \tstruct hook_cb_data *hook_cb = pp_cb;\n-\tconst char *hook_path = hook_cb->hook_path;\n+\tstruct string_list *hook_list = hook_cb->hook_command_list;\n+\tstruct hook *h;\n \n-\tif (!hook_path)\n+\tif (hook_cb->hook_to_run_index >= hook_list->nr)\n \t\treturn 0;\n \n+\th = hook_list->items[hook_cb->hook_to_run_index++].util;\n+\n \tcp->no_stdin = 1;\n \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\n \n@@ -85,21 +174,22 @@ static int pick_next_hook(struct child_process *cp,\n \tcp->trace2_hook_name = hook_cb->hook_name;\n \tcp->dir = hook_cb->options->dir;\n \n-\tstrvec_push(&cp->args, hook_path);\n+\t/* Add hook exec paths or commands */\n+\tif (h->kind == HOOK_TRADITIONAL)\n+\t\tstrvec_push(&cp->args, h->u.traditional.path);\n+\telse\n+\t\tBUG(\"unknown hook kind\");\n+\n+\tif (!cp->args.nr)\n+\t\tBUG(\"hook must have at least one command or exec path\");\n+\n \tstrvec_pushv(&cp->args, hook_cb->options->args.v);\n \n \t/*\n \t * Provide per-hook internal state via task_cb for easy access, so\n \t * hook callbacks don't have to go through hook_cb->options.\n \t */\n-\t*pp_task_cb = hook_cb->options->feed_pipe_cb_data;\n-\n-\t/*\n-\t * This pick_next_hook() will be called again, we're only\n-\t * running one hook, so indicate that no more work will be\n-\t * done.\n-\t */\n-\thook_cb->hook_path = NULL;\n+\t*pp_task_cb = h->feed_pipe_cb_data;\n \n \treturn 1;\n }\n@@ -133,8 +223,6 @@ static int notify_hook_finished(int result,\n \n static void run_hooks_opt_clear(struct run_hooks_opt *options)\n {\n-\tif (options->feed_pipe_cb_data_free)\n-\t\toptions->feed_pipe_cb_data_free(options->feed_pipe_cb_data);\n \tstrvec_clear(&options->env);\n \tstrvec_clear(&options->args);\n }\n@@ -142,13 +230,11 @@ static void run_hooks_opt_clear(struct run_hooks_opt *options)\n int run_hooks_opt(struct repository *r, const char *hook_name,\n \t\t  struct run_hooks_opt *options)\n {\n-\tstruct strbuf abs_path = STRBUF_INIT;\n \tstruct hook_cb_data cb_data = {\n \t\t.rc = 0,\n \t\t.hook_name = hook_name,\n \t\t.options = options,\n \t};\n-\tconst char *const hook_path = find_hook(r, hook_name);\n \tint ret = 0;\n \tconst struct run_process_parallel_opts opts = {\n \t\t.tr2_category = \"hook\",\n@@ -181,30 +267,21 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (!options->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free)\n \t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n \n-\tif (options->feed_pipe_cb_data_alloc)\n-\t\toptions->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n-\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \n-\tif (!hook_path && !options->error_if_missing)\n+\tcb_data.hook_command_list = list_hooks(r, hook_name, options);\n+\tif (!cb_data.hook_command_list->nr) {\n+\t\tif (options->error_if_missing)\n+\t\t\tret = error(\"cannot find a hook named %s\", hook_name);\n \t\tgoto cleanup;\n-\n-\tif (!hook_path) {\n-\t\tret = error(\"cannot find a hook named %s\", hook_name);\n-\t\tgoto cleanup;\n-\t}\n-\n-\tcb_data.hook_path = hook_path;\n-\tif (options->dir) {\n-\t\tstrbuf_add_absolute_path(&abs_path, hook_path);\n-\t\tcb_data.hook_path = abs_path.buf;\n \t}\n \n \trun_processes_parallel(&opts);\n \tret = cb_data.rc;\n cleanup:\n-\tstrbuf_release(&abs_path);\n+\tstring_list_clear_func(cb_data.hook_command_list, hook_free);\n+\tfree(cb_data.hook_command_list);\n \trun_hooks_opt_clear(options);\n \treturn ret;\n }\ndiff --git a/hook.h b/hook.h\nindex 630e1a3c85..51fe873298 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -2,12 +2,52 @@\n #define HOOK_H\n #include \"strvec.h\"\n #include \"run-command.h\"\n+#include \"string-list.h\"\n \n struct repository;\n \n typedef void (*hook_data_free_fn)(void *data);\n typedef void *(*hook_data_alloc_fn)(void *init_ctx);\n \n+/**\n+ * Represents a hook command to be run.\n+ * Hooks can be:\n+ * 1. \"traditional\" (found in the hooks directory)\n+ * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n+ * The 'kind' field determines which part of the union 'u' is valid.\n+ */\n+struct hook {\n+\tenum {\n+\t\tHOOK_TRADITIONAL,\n+\t} kind;\n+\tunion {\n+\t\tstruct {\n+\t\t\tconst char *path;\n+\t\t} traditional;\n+\t} u;\n+\n+\t/**\n+\t * Opaque data pointer used to keep internal state across callback calls.\n+\t *\n+\t * It can be accessed directly via the third hook callback arg:\n+\t * struct ... *state = pp_task_cb;\n+\t *\n+\t * The caller is responsible for managing the memory for this data by\n+\t * providing alloc/free callbacks to `run_hooks_opt`.\n+\t *\n+\t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n+\t */\n+\tvoid *feed_pipe_cb_data;\n+\n+\t/**\n+\t * Callback to free `feed_pipe_cb_data`.\n+\t *\n+\t * It is called automatically and points to the `feed_pipe_cb_data_free`\n+\t * provided via the `run_hook_opt` parameter.\n+\t */\n+\thook_data_free_fn data_free;\n+};\n+\n struct run_hooks_opt\n {\n \t/* Environment vars to be set for each hook */\n@@ -85,19 +125,6 @@ struct run_hooks_opt\n \t */\n \tvoid *feed_pipe_ctx;\n \n-\t/**\n-\t * Opaque data pointer used to keep internal state across callback calls.\n-\t *\n-\t * It can be accessed directly via the third callback arg 'pp_task_cb':\n-\t * struct ... *state = pp_task_cb;\n-\t *\n-\t * The caller is responsible for managing the memory for this data by\n-\t * providing alloc/free callbacks to `run_hooks_opt`.\n-\t *\n-\t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n-\t */\n-\tvoid *feed_pipe_cb_data;\n-\n \t/**\n \t * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,\n \t * so they can keep track of progress without affecting one another.\n@@ -132,11 +159,23 @@ struct hook_cb_data {\n \t/* rc reflects the cumulative failure state */\n \tint rc;\n \tconst char *hook_name;\n-\tconst char *hook_path;\n+\n+\t/**\n+\t * A list of hook commands/paths to run for the 'hook_name' event.\n+\t *\n+\t * The 'string' member of each item holds the path (for traditional hooks)\n+\t * or the unique friendly-name for hooks specified in configs.\n+\t * The 'util' member of each item points to the corresponding struct hook.\n+\t */\n+\tstruct string_list *hook_command_list;\n+\n+\t/* Iterator/cursor for the above list, pointing to the next hook to run. */\n+\tsize_t hook_to_run_index;\n+\n \tstruct run_hooks_opt *options;\n };\n \n-/*\n+/**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\n  * overwritten by further calls to find_hook and run_hook_*.\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537456","messageId":"20260301184500.1488433-4-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 03/12] hook: add \"git hook list\" command","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:51Z","receivedAt":"2026-03-01T18:46:21Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nThe previous commit introduced an ability to run multiple commands for\nhook events and next commit will introduce the ability to define hooks\nfrom configs, in addition to the \"traditional\" hooks from the hookdir.\n\nIntroduce a new command \"git hook list\" to make inspecting hooks easier\nboth for users and for the tests we will add.\n\nFurther commits will expand on this, e.g. by adding a -z output mode.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  5 ++++\n builtin/hook.c              | 59 +++++++++++++++++++++++++++++++++++++\n hook.c                      | 21 ++-----------\n hook.h                      | 22 ++++++++++++++\n t/t1800-hook.sh             | 22 ++++++++++++++\n 5 files changed, 110 insertions(+), 19 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex f6cc72d2ca..eb0ffcb8a9 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -9,6 +9,7 @@ SYNOPSIS\n --------\n [verse]\n 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n+'git hook' list <hook-name>\n \n DESCRIPTION\n -----------\n@@ -28,6 +29,10 @@ Any positional arguments to the hook should be passed after a\n mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See\n linkgit:githooks[5] for arguments hooks might expect (if any).\n \n+list::\n+\tPrint a list of hooks which will be run on `<hook-name>` event. If no\n+\thooks are configured for that event, print a warning and return 1.\n+\n OPTIONS\n -------\n \ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 7afec380d2..855116ba8c 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -9,9 +9,12 @@\n \n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n+#define BUILTIN_HOOK_LIST_USAGE \\\n+\tN_(\"git hook list <hook-name>\")\n \n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n+\tBUILTIN_HOOK_LIST_USAGE,\n \tNULL\n };\n \n@@ -20,6 +23,61 @@ static const char * const builtin_hook_run_usage[] = {\n \tNULL\n };\n \n+static int list(int argc, const char **argv, const char *prefix,\n+\t\t struct repository *repo)\n+{\n+\tstatic const char *const builtin_hook_list_usage[] = {\n+\t\tBUILTIN_HOOK_LIST_USAGE,\n+\t\tNULL\n+\t};\n+\tstruct string_list *head;\n+\tstruct string_list_item *item;\n+\tconst char *hookname = NULL;\n+\tint ret = 0;\n+\n+\tstruct option list_options[] = {\n+\t\tOPT_END(),\n+\t};\n+\n+\targc = parse_options(argc, argv, prefix, list_options,\n+\t\t\t     builtin_hook_list_usage, 0);\n+\n+\t/*\n+\t * The only unnamed argument provided should be the hook-name; if we add\n+\t * arguments later they probably should be caught by parse_options.\n+\t */\n+\tif (argc != 1)\n+\t\tusage_msg_opt(_(\"you must specify a hook event name to list.\"),\n+\t\t\t      builtin_hook_list_usage, list_options);\n+\n+\thookname = argv[0];\n+\n+\thead = list_hooks(repo, hookname, NULL);\n+\n+\tif (!head->nr) {\n+\t\twarning(_(\"no hooks found for event '%s'\"), hookname);\n+\t\tret = 1; /* no hooks found */\n+\t\tgoto cleanup;\n+\t}\n+\n+\tfor_each_string_list_item(item, head) {\n+\t\tstruct hook *h = item->util;\n+\n+\t\tswitch (h->kind) {\n+\t\tcase HOOK_TRADITIONAL:\n+\t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n+\t\t\tbreak;\n+\t\tdefault:\n+\t\t\tBUG(\"unknown hook kind\");\n+\t\t}\n+\t}\n+\n+cleanup:\n+\tstring_list_clear_func(head, hook_free);\n+\tfree(head);\n+\treturn ret;\n+}\n+\n static int run(int argc, const char **argv, const char *prefix,\n \t       struct repository *repo UNUSED)\n {\n@@ -77,6 +135,7 @@ int cmd_hook(int argc,\n \tparse_opt_subcommand_fn *fn = NULL;\n \tstruct option builtin_hook_options[] = {\n \t\tOPT_SUBCOMMAND(\"run\", &fn, run),\n+\t\tOPT_SUBCOMMAND(\"list\", &fn, list),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/hook.c b/hook.c\nindex eb52d706b8..20c655918d 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -47,11 +47,7 @@ const char *find_hook(struct repository *r, const char *name)\n \treturn path.buf;\n }\n \n-/*\n- * Frees a struct hook stored as the util pointer of a string_list_item.\n- * Suitable for use as a string_list_clear_func_t callback.\n- */\n-static void hook_free(void *p, const char *str UNUSED)\n+void hook_free(void *p, const char *str UNUSED)\n {\n \tstruct hook *h = p;\n \n@@ -99,20 +95,7 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n \tstring_list_append(hook_list, hook_path)->util = h;\n }\n \n-/*\n- * Provides a list of hook commands to run for the 'hookname' event.\n- *\n- * This function consolidates hooks from two sources:\n- * 1. The config-based hooks (not yet implemented).\n- * 2. The \"traditional\" hook found in the repository hooks directory\n- *    (e.g., .git/hooks/pre-commit).\n- *\n- * The list is ordered by execution priority.\n- *\n- * The caller is responsible for freeing the memory of the returned list\n- * using string_list_clear() and free().\n- */\n-static struct string_list *list_hooks(struct repository *r, const char *hookname,\n+struct string_list *list_hooks(struct repository *r, const char *hookname,\n \t\t\t       struct run_hooks_opt *options)\n {\n \tstruct string_list *hook_head;\ndiff --git a/hook.h b/hook.h\nindex 51fe873298..36d40c98df 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -175,6 +175,28 @@ struct hook_cb_data {\n \tstruct run_hooks_opt *options;\n };\n \n+/**\n+ * Frees a struct hook stored as the util pointer of a string_list_item.\n+ * Suitable for use as a string_list_clear_func_t callback.\n+ */\n+void hook_free(void *p, const char *str);\n+\n+/**\n+ * Provides a list of hook commands to run for the 'hookname' event.\n+ *\n+ * This function consolidates hooks from two sources:\n+ * 1. The config-based hooks (not yet implemented).\n+ * 2. The \"traditional\" hook found in the repository hooks directory\n+ *    (e.g., .git/hooks/pre-commit).\n+ *\n+ * The list is ordered by execution priority.\n+ *\n+ * The caller is responsible for freeing the memory of the returned list\n+ * using string_list_clear() and free().\n+ */\n+struct string_list *list_hooks(struct repository *r, const char *hookname,\n+\t\t\t       struct run_hooks_opt *options);\n+\n /**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex ed28a2fadb..d1380a4f0e 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -10,9 +10,31 @@ test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook run &&\n \ttest_expect_code 129 git hook run -h &&\n \ttest_expect_code 129 git hook run --unknown 2>err &&\n+\ttest_expect_code 129 git hook list &&\n+\ttest_expect_code 129 git hook list -h &&\n \tgrep \"unknown option\" err\n '\n \n+test_expect_success 'git hook list: nonexistent hook' '\n+\tcat >stderr.expect <<-\\EOF &&\n+\twarning: no hooks found for event '\\''test-hook'\\''\n+\tEOF\n+\ttest_expect_code 1 git hook list test-hook 2>stderr.actual &&\n+\ttest_cmp stderr.expect stderr.actual\n+'\n+\n+test_expect_success 'git hook list: traditional hook from hookdir' '\n+\ttest_hook test-hook <<-EOF &&\n+\techo Test hook\n+\tEOF\n+\n+\tcat >expect <<-\\EOF &&\n+\thook from hookdir\n+\tEOF\n+\tgit hook list test-hook >actual &&\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success 'git hook run: nonexistent hook' '\n \tcat >stderr.expect <<-\\EOF &&\n \terror: cannot find a hook named test-hook\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537457","messageId":"20260301184500.1488433-2-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 01/12] hook: add internal state alloc/free callbacks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:49Z","receivedAt":"2026-03-01T18:46:22Z","isPatch":true,"body":"Some hooks use opaque structs to keep internal state between callbacks.\n\nBecause hooks ran sequentially (jobs == 1) with one command per hook,\nthese internal states could be allocated on the stack for each hook run.\n\nNext commits add the ability to run multiple commands for each hook, so\nthe states cannot be shared or stored on the stack anymore, especially\nsince down the line we will also enable parallel execution (jobs > 1).\n\nAdd alloc/free helpers for each hook, doing a \"deep\" alloc/init & free\nof their internal opaque struct.\n\nThe alloc callback takes a context pointer, to initialize the struct at\nat the time of resource acquisition.\n\nThese callbacks must always be provided together: no alloc without free\nand no free without alloc, otherwise a BUG() is triggered.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n builtin/receive-pack.c | 36 +++++++++++++++++++++++++++++-------\n hook.c                 | 12 ++++++++++++\n hook.h                 | 29 ++++++++++++++++++++++++++++-\n refs.c                 | 25 ++++++++++++++++++++-----\n transport.c            | 28 +++++++++++++++++++++-------\n 5 files changed, 110 insertions(+), 20 deletions(-)\n\ndiff --git a/builtin/receive-pack.c b/builtin/receive-pack.c\nindex 2d2b33d73d..0f3ba93e95 100644\n--- a/builtin/receive-pack.c\n+++ b/builtin/receive-pack.c\n@@ -901,6 +901,27 @@ static int feed_receive_hook_cb(int hook_stdin_fd, void *pp_cb UNUSED, void *pp_\n \treturn state->cmd ? 0 : 1;  /* 0 = more to come, 1 = EOF */\n }\n \n+static void *receive_hook_feed_state_alloc(void *feed_pipe_ctx)\n+{\n+\tstruct receive_hook_feed_state *init_state = feed_pipe_ctx;\n+\tstruct receive_hook_feed_state *data;\n+\tCALLOC_ARRAY(data, 1);\n+\tdata->report = init_state->report;\n+\tdata->cmd = init_state->cmd;\n+\tdata->skip_broken = init_state->skip_broken;\n+\tstrbuf_init(&data->buf, 0);\n+\treturn data;\n+}\n+\n+static void receive_hook_feed_state_free(void *data)\n+{\n+\tstruct receive_hook_feed_state *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_receive_hook(struct command *commands,\n \t\t\t    const char *hook_name,\n \t\t\t    int skip_broken,\n@@ -908,7 +929,11 @@ static int run_receive_hook(struct command *commands,\n {\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n \tstruct command *iter = commands;\n-\tstruct receive_hook_feed_state feed_state;\n+\tstruct receive_hook_feed_state feed_init_state = {\n+\t\t.cmd = commands,\n+\t\t.skip_broken = skip_broken,\n+\t\t.buf = STRBUF_INIT,\n+\t};\n \tstruct async sideband_async;\n \tint sideband_async_started = 0;\n \tint saved_stderr = -1;\n@@ -938,16 +963,13 @@ static int run_receive_hook(struct command *commands,\n \tprepare_sideband_async(&sideband_async, &saved_stderr, &sideband_async_started);\n \n \t/* set up stdin callback */\n-\tfeed_state.cmd = commands;\n-\tfeed_state.skip_broken = skip_broken;\n-\tfeed_state.report = NULL;\n-\tstrbuf_init(&feed_state.buf, 0);\n-\topt.feed_pipe_cb_data = &feed_state;\n+\topt.feed_pipe_ctx = &feed_init_state;\n \topt.feed_pipe = feed_receive_hook_cb;\n+\topt.feed_pipe_cb_data_alloc = receive_hook_feed_state_alloc;\n+\topt.feed_pipe_cb_data_free = receive_hook_feed_state_free;\n \n \tret = run_hooks_opt(the_repository, hook_name, &opt);\n \n-\tstrbuf_release(&feed_state.buf);\n \tfinish_sideband_async(&sideband_async, saved_stderr, sideband_async_started);\n \n \treturn ret;\ndiff --git a/hook.c b/hook.c\nindex cde7198412..a9ade11952 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -133,6 +133,8 @@ static int notify_hook_finished(int result,\n \n static void run_hooks_opt_clear(struct run_hooks_opt *options)\n {\n+\tif (options->feed_pipe_cb_data_free)\n+\t\toptions->feed_pipe_cb_data_free(options->feed_pipe_cb_data);\n \tstrvec_clear(&options->env);\n \tstrvec_clear(&options->args);\n }\n@@ -172,6 +174,16 @@ int run_hooks_opt(struct repository *r, const char *hook_name,\n \tif (!options->jobs)\n \t\tBUG(\"run_hooks_opt must be called with options.jobs >= 1\");\n \n+\t/*\n+\t * Ensure cb_data copy and free functions are either provided together,\n+\t * or neither one is provided.\n+\t */\n+\tif (!options->feed_pipe_cb_data_alloc != !options->feed_pipe_cb_data_free)\n+\t\tBUG(\"feed_pipe_cb_data_alloc and feed_pipe_cb_data_free must be set together\");\n+\n+\tif (options->feed_pipe_cb_data_alloc)\n+\t\toptions->feed_pipe_cb_data = options->feed_pipe_cb_data_alloc(options->feed_pipe_ctx);\n+\n \tif (options->invoked_hook)\n \t\t*options->invoked_hook = 0;\n \ndiff --git a/hook.h b/hook.h\nindex 20eb56fd63..630e1a3c85 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -5,6 +5,9 @@\n \n struct repository;\n \n+typedef void (*hook_data_free_fn)(void *data);\n+typedef void *(*hook_data_alloc_fn)(void *init_ctx);\n+\n struct run_hooks_opt\n {\n \t/* Environment vars to be set for each hook */\n@@ -88,10 +91,34 @@ struct run_hooks_opt\n \t * It can be accessed directly via the third callback arg 'pp_task_cb':\n \t * struct ... *state = pp_task_cb;\n \t *\n-\t * The caller is responsible for managing the memory for this data.\n+\t * The caller is responsible for managing the memory for this data by\n+\t * providing alloc/free callbacks to `run_hooks_opt`.\n+\t *\n \t * Only useful when using `run_hooks_opt.feed_pipe`, otherwise ignore it.\n \t */\n \tvoid *feed_pipe_cb_data;\n+\n+\t/**\n+\t * Some hooks need to create a fresh `feed_pipe_cb_data` internal state,\n+\t * so they can keep track of progress without affecting one another.\n+\t *\n+\t * If provided, this function will be called to alloc & initialize the\n+\t * `feed_pipe_cb_data` for each hook.\n+\t *\n+\t * The caller must provide a `feed_pipe_cb_data_free` callback to free\n+\t * this memory (missing callback will trigger a bug). Use only the cb to\n+\t * free the memory, do not free it manually in the caller.\n+\t *\n+\t * The `feed_pipe_ctx` pointer can be used to pass initialization data.\n+\t */\n+\thook_data_alloc_fn feed_pipe_cb_data_alloc;\n+\n+\t/**\n+\t * Called to free the memory initialized by `feed_pipe_cb_data_alloc`.\n+\t *\n+\t * Must always be provided when `feed_pipe_cb_data_alloc` is provided.\n+\t */\n+\thook_data_free_fn feed_pipe_cb_data_free;\n };\n \n #define RUN_HOOKS_OPT_INIT { \\\ndiff --git a/refs.c b/refs.c\nindex 7cfb866aab..bd91c5c882 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -2597,24 +2597,39 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n \treturn 0; /* no more input to feed */\n }\n \n+static void *transaction_feed_cb_data_alloc(void *feed_pipe_ctx UNUSED)\n+{\n+\tstruct transaction_feed_cb_data *data;\n+\tCALLOC_ARRAY(data, 1);\n+\tstrbuf_init(&data->buf, 0);\n+\tdata->index = 0;\n+\treturn data;\n+}\n+\n+static void transaction_feed_cb_data_free(void *data)\n+{\n+\tstruct transaction_feed_cb_data *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_transaction_hook(struct ref_transaction *transaction,\n \t\t\t\tconst char *state)\n {\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n-\tstruct transaction_feed_cb_data feed_ctx = { 0 };\n \tint ret = 0;\n \n \tstrvec_push(&opt.args, state);\n \n \topt.feed_pipe = transaction_hook_feed_stdin;\n \topt.feed_pipe_ctx = transaction;\n-\topt.feed_pipe_cb_data = &feed_ctx;\n-\n-\tstrbuf_init(&feed_ctx.buf, 0);\n+\topt.feed_pipe_cb_data_alloc = transaction_feed_cb_data_alloc;\n+\topt.feed_pipe_cb_data_free = transaction_feed_cb_data_free;\n \n \tret = run_hooks_opt(transaction->ref_store->repo, \"reference-transaction\", &opt);\n \n-\tstrbuf_release(&feed_ctx.buf);\n \treturn ret;\n }\n \ndiff --git a/transport.c b/transport.c\nindex faa166a575..56a4015389 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -1358,21 +1358,37 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \treturn 0;\n }\n \n+static void *pre_push_hook_data_alloc(void *feed_pipe_ctx)\n+{\n+\tstruct feed_pre_push_hook_data *data;\n+\tCALLOC_ARRAY(data, 1);\n+\tstrbuf_init(&data->buf, 0);\n+\tdata->refs = (struct ref *)feed_pipe_ctx;\n+\treturn data;\n+}\n+\n+static void pre_push_hook_data_free(void *data)\n+{\n+\tstruct feed_pre_push_hook_data *d = data;\n+\tif (!d)\n+\t\treturn;\n+\tstrbuf_release(&d->buf);\n+\tfree(d);\n+}\n+\n static int run_pre_push_hook(struct transport *transport,\n \t\t\t     struct ref *remote_refs)\n {\n \tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n-\tstruct feed_pre_push_hook_data data;\n \tint ret = 0;\n \n \tstrvec_push(&opt.args, transport->remote->name);\n \tstrvec_push(&opt.args, transport->url);\n \n-\tstrbuf_init(&data.buf, 0);\n-\tdata.refs = remote_refs;\n-\n \topt.feed_pipe = pre_push_hook_feed_stdin;\n-\topt.feed_pipe_cb_data = &data;\n+\topt.feed_pipe_ctx = remote_refs;\n+\topt.feed_pipe_cb_data_alloc = pre_push_hook_data_alloc;\n+\topt.feed_pipe_cb_data_free = pre_push_hook_data_free;\n \n \t/*\n \t * pre-push hooks expect stdout & stderr to be separate, so don't merge\n@@ -1382,8 +1398,6 @@ static int run_pre_push_hook(struct transport *transport,\n \n \tret = run_hooks_opt(the_repository, \"pre-push\", &opt);\n \n-\tstrbuf_release(&data.buf);\n-\n \treturn ret;\n }\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537458","messageId":"20260301184500.1488433-5-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 04/12] string-list: add unsorted_string_list_remove()","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:52Z","receivedAt":"2026-03-01T18:46:22Z","isPatch":true,"body":"Add a convenience wrapper that combines unsorted_string_list_lookup()\nwith unsorted_string_list_delete_item(), removing the first item\nmatching a given string.\n\nThis is a companion to the existing unsorted string_list helpers and\nwill be used in the next commits.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n string-list.c | 9 +++++++++\n string-list.h | 8 ++++++++\n 2 files changed, 17 insertions(+)\n\ndiff --git a/string-list.c b/string-list.c\nindex fffa2ad4b6..d260b873c8 100644\n--- a/string-list.c\n+++ b/string-list.c\n@@ -281,6 +281,15 @@ void unsorted_string_list_delete_item(struct string_list *list, int i, int free_\n \tlist->nr--;\n }\n \n+void unsorted_string_list_remove(struct string_list *list, const char *str,\n+\t\t\t\t int free_util)\n+{\n+\tstruct string_list_item *item = unsorted_string_list_lookup(list, str);\n+\tif (item)\n+\t\tunsorted_string_list_delete_item(list, item - list->items,\n+\t\t\t\t\t\t free_util);\n+}\n+\n /*\n  * append a substring [p..end] to list; return number of things it\n  * appended to the list.\ndiff --git a/string-list.h b/string-list.h\nindex 3ad862a187..b86ee7c099 100644\n--- a/string-list.h\n+++ b/string-list.h\n@@ -265,6 +265,14 @@ struct string_list_item *unsorted_string_list_lookup(struct string_list *list,\n  */\n void unsorted_string_list_delete_item(struct string_list *list, int i, int free_util);\n \n+/**\n+ * Remove the first item matching `str` from an unsorted string_list.\n+ * No-op if `str` is not found. If `free_util` is non-zero, the `util`\n+ * pointer of the removed item is freed before deletion.\n+ */\n+void unsorted_string_list_remove(struct string_list *list, const char *str,\n+\t\t\t\t int free_util);\n+\n /**\n  * Split string into substrings on characters in `delim` and append the\n  * substrings to `list`.  The input string is not modified.\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537459","messageId":"20260301184500.1488433-7-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 06/12] hook: allow disabling config hooks","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:54Z","receivedAt":"2026-03-01T18:46:25Z","isPatch":true,"body":"Hooks specified via configs are always enabled, however users\nmight want to disable them without removing from the config,\nlike locally disabling a global hook.\n\nAdd a hook.<friendly-name>.enabled config which defaults to true and\ncan be optionally set for each configured hook.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  7 +++++++\n hook.c                         | 24 ++++++++++++++++++++++++\n t/t1800-hook.sh                | 32 ++++++++++++++++++++++++++++++++\n 3 files changed, 63 insertions(+)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 7ac1b079cc..4bbda5636d 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -13,3 +13,10 @@ hook.<friendly-name>.event::\n \tspecified event, the associated `hook.<friendly-name>.command` is executed.\n \tThis is a multi-valued key. To run `hook.<friendly-name>` on multiple\n \tevents, specify the key more than once. See linkgit:git-hook[1].\n+\n+hook.<friendly-name>.enabled::\n+\tWhether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.\n+\tSet to `false` to disable the hook without removing its\n+\tconfiguration. This is particularly useful when a hook is defined\n+\tin a system or global config file and needs to be disabled for a\n+\tspecific repository. See linkgit:git-hook[1].\ndiff --git a/hook.c b/hook.c\nindex b67ec4ff22..24eb330cac 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -105,10 +105,12 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n  * Callback struct to collect all hook.* keys in a single config pass.\n  * commands: friendly-name to command map.\n  * event_hooks: event-name to list of friendly-names map.\n+ * disabled_hooks: set of friendly-names with hook.<friendly-name>.enabled = false.\n  */\n struct hook_all_config_cb {\n \tstruct strmap commands;\n \tstruct strmap event_hooks;\n+\tstruct string_list disabled_hooks;\n };\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n@@ -148,6 +150,21 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\tchar *old = strmap_put(&data->commands, hook_name,\n \t\t\t\t       xstrdup(value));\n \t\tfree(old);\n+\t} else if (!strcmp(subkey, \"enabled\")) {\n+\t\tswitch (git_parse_maybe_bool(value)) {\n+\t\tcase 0: /* disabled */\n+\t\t\tif (!unsorted_string_list_lookup(&data->disabled_hooks,\n+\t\t\t\t\t\t\t hook_name))\n+\t\t\t\tstring_list_append(&data->disabled_hooks,\n+\t\t\t\t\t\t   hook_name);\n+\t\t\tbreak;\n+\t\tcase 1: /* enabled: undo a prior disabled entry */\n+\t\t\tunsorted_string_list_remove(&data->disabled_hooks,\n+\t\t\t\t\t\t    hook_name);\n+\t\t\tbreak;\n+\t\tdefault:\n+\t\t\tbreak; /* ignore unrecognised values */\n+\t\t}\n \t}\n \n \tfree(hook_name);\n@@ -184,6 +201,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \n \tstrmap_init(&cb_data.commands);\n \tstrmap_init(&cb_data.event_hooks);\n+\tstring_list_init_dup(&cb_data.disabled_hooks);\n \n \t/* Parse all configs in one run. */\n \trepo_config(r, hook_config_lookup_all, &cb_data);\n@@ -200,6 +218,11 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\tconst char *hname = hook_names->items[i].string;\n \t\t\tchar *command;\n \n+\t\t\t/* filter out disabled hooks */\n+\t\t\tif (unsorted_string_list_lookup(&cb_data.disabled_hooks,\n+\t\t\t\t\t\t\thname))\n+\t\t\t\tcontinue;\n+\n \t\t\tcommand = strmap_get(&cb_data.commands, hname);\n \t\t\tif (!command)\n \t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n@@ -215,6 +238,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t}\n \n \tstrmap_clear(&cb_data.commands, 1);\n+\tstring_list_clear(&cb_data.disabled_hooks, 0);\n \tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n \t\tstring_list_clear(e->value, 0);\n \t\tfree(e->value);\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 3a95cfe16d..fb8cfc8137 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -343,6 +343,38 @@ test_expect_success 'rejects hooks with no commands configured' '\n \ttest_grep \"hook.broken.command\" actual\n '\n \n+test_expect_success 'disabled hook is not run' '\n+\ttest_config hook.skipped.event \"test-hook\" &&\n+\ttest_config hook.skipped.command \"echo \\\"Should not run\\\"\" &&\n+\ttest_config hook.skipped.enabled false &&\n+\n+\tgit hook run --ignore-missing test-hook 2>actual &&\n+\ttest_must_be_empty actual\n+'\n+\n+test_expect_success 'disabled hook does not appear in git hook list' '\n+\ttest_config hook.active.event \"pre-commit\" &&\n+\ttest_config hook.active.command \"echo active\" &&\n+\ttest_config hook.inactive.event \"pre-commit\" &&\n+\ttest_config hook.inactive.command \"echo inactive\" &&\n+\ttest_config hook.inactive.enabled false &&\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_grep \"active\" actual &&\n+\ttest_grep ! \"inactive\" actual\n+'\n+\n+test_expect_success 'globally disabled hook can be re-enabled locally' '\n+\ttest_config_global hook.global-hook.event \"test-hook\" &&\n+\ttest_config_global hook.global-hook.command \"echo \\\"global-hook ran\\\"\" &&\n+\ttest_config_global hook.global-hook.enabled false &&\n+\ttest_config hook.global-hook.enabled true &&\n+\n+\techo \"global-hook ran\" >expected &&\n+\tgit hook run test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n test_expect_success 'git hook run a hook with a bad shebang' '\n \ttest_when_finished \"rm -rf bad-hooks\" &&\n \tmkdir bad-hooks &&\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537460","messageId":"20260301184500.1488433-6-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 05/12] hook: include hooks from the config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:53Z","receivedAt":"2026-03-01T18:46:26Z","isPatch":true,"body":"Teach the hook.[hc] library to parse configs to populate the list of\nhooks to run for a given event.\n\nMultiple commands can be specified for a given hook by providing\n\"hook.<friendly-name>.command = <path-to-hook>\" and\n\"hook.<friendly-name>.event = <hook-event>\" lines.\n\nHooks will be started in config order of the \"hook.<friendly-name>.event\"\nlines and will be run sequentially (.jobs == 1) like before.\nRunning the hooks in parallel will be enabled in a future patch.\n\nThe \"traditional\" hook from the hookdir is run last, if present.\n\nA strmap cache is added to struct repository to avoid re-reading\nthe configs on each rook run. This is useful for hooks like the\nref-transaction which gets executed multiple times per process.\n\nExamples:\n\n  $ git config --get-regexp \"^hook\\.\"\n  hook.bar.command=~/bar.sh\n  hook.bar.event=pre-commit\n\n  # Will run ~/bar.sh, then .git/hooks/pre-commit\n  $ git hook run pre-commit\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  15 +++\n Documentation/git-hook.adoc    | 128 ++++++++++++++++++++-\n builtin/hook.c                 |   3 +\n hook.c                         | 196 ++++++++++++++++++++++++++++++++-\n hook.h                         |  14 ++-\n repository.c                   |   6 +\n repository.h                   |   6 +\n t/t1800-hook.sh                | 174 ++++++++++++++++++++++++++++-\n 8 files changed, 535 insertions(+), 7 deletions(-)\n create mode 100644 Documentation/config/hook.adoc\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nnew file mode 100644\nindex 0000000000..7ac1b079cc\n--- /dev/null\n+++ b/Documentation/config/hook.adoc\n@@ -0,0 +1,15 @@\n+hook.<friendly-name>.command::\n+\tThe command to execute for `hook.<friendly-name>`. `<friendly-name>` is a unique\n+\t\"friendly\" name that identifies this hook. (The hook events that\n+\ttrigger the command are configured with `hook.<friendly-name>.event`.) The\n+\tvalue can be an executable path or a shell oneliner. If more than\n+\tone value is specified for the same `<friendly-name>`, only the last value\n+\tparsed is used. See linkgit:git-hook[1].\n+\n+hook.<friendly-name>.event::\n+\tThe hook events that trigger `hook.<friendly-name>`. The value is the name\n+\tof a hook event, like \"pre-commit\" or \"update\". (See\n+\tlinkgit:githooks[5] for a complete list of hook events.) On the\n+\tspecified event, the associated `hook.<friendly-name>.command` is executed.\n+\tThis is a multi-valued key. To run `hook.<friendly-name>` on multiple\n+\tevents, specify the key more than once. See linkgit:git-hook[1].\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex eb0ffcb8a9..0eaf864c43 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -17,12 +17,96 @@ DESCRIPTION\n A command interface for running git hooks (see linkgit:githooks[5]),\n for use by other scripted git commands.\n \n+This command parses the default configuration files for sets of configs like\n+so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+\n+In this example, `[hook \"linter\"]` represents one script - `~/bin/linter\n+--cpp20` - which can be shared by many repos, and even by many hook events, if\n+appropriate.\n+\n+To add an unrelated hook which runs on a different event, for example a\n+spell-checker for your commit messages, you would write a configuration like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+  [hook \"spellcheck\"]\n+    event = commit-msg\n+    command = ~/bin/spellchecker\n+\n+With this config, when you run 'git commit', first `~/bin/linter --cpp20` will\n+have a chance to check your files to be committed (during the `pre-commit` hook\n+event`), and then `~/bin/spellchecker` will have a chance to check your commit\n+message (during the `commit-msg` hook event).\n+\n+Commands are run in the order Git encounters their associated\n+`hook.<friendly-name>.event` configs during the configuration parse (see\n+linkgit:git-config[1]). Although multiple `hook.linter.event` configs can be\n+added, only one `hook.linter.command` event is valid - Git uses \"last-one-wins\"\n+to determine which command to run.\n+\n+So if you wanted your linter to run when you commit as well as when you push,\n+you would configure it like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    event = pre-push\n+    command = ~/bin/linter --cpp20\n+\n+With this config, `~/bin/linter --cpp20` would be run by Git before a commit is\n+generated (during `pre-commit`) as well as before a push is performed (during\n+`pre-push`).\n+\n+And if you wanted to run your linter as well as a secret-leak detector during\n+only the \"pre-commit\" hook event, you would configure it instead like so:\n+\n+  [hook \"linter\"]\n+    event = pre-commit\n+    command = ~/bin/linter --cpp20\n+  [hook \"no-leaks\"]\n+    event = pre-commit\n+    command = ~/bin/leak-detector\n+\n+With this config, before a commit is generated (during `pre-commit`), Git would\n+first start `~/bin/linter --cpp20` and second start `~/bin/leak-detector`. It\n+would evaluate the output of each when deciding whether to proceed with the\n+commit.\n+\n+For a full list of hook events which you can set your `hook.<friendly-name>.event` to,\n+and how hooks are invoked during those events, see linkgit:githooks[5].\n+\n+Git will ignore any `hook.<friendly-name>.event` that specifies an event it doesn't\n+recognize. This is intended so that tools which wrap Git can use the hook\n+infrastructure to run their own hooks; see \"WRAPPERS\" for more guidance.\n+\n+In general, when instructions suggest adding a script to\n+`.git/hooks/<hook-event>`, you can specify it in the config instead by running:\n+\n+----\n+git config set hook.<some-name>.command <path-to-script>\n+git config set --append hook.<some-name>.event <hook-event>\n+----\n+\n+This way you can share the script between multiple repos. That is, `cp\n+~/my-script.sh ~/project/.git/hooks/pre-commit` would become:\n+\n+----\n+git config set hook.my-script.command ~/my-script.sh\n+git config set --append hook.my-script.event pre-commit\n+----\n+\n SUBCOMMANDS\n -----------\n \n run::\n-\tRun the `<hook-name>` hook. See linkgit:githooks[5] for\n-\tsupported hook names.\n+\tRuns hooks configured for `<hook-name>`, in the order they are\n+\tdiscovered during the config parse. The default `<hook-name>` from\n+\tthe hookdir is run last. See linkgit:githooks[5] for supported\n+\thook names.\n +\n \n Any positional arguments to the hook should be passed after a\n@@ -46,6 +130,46 @@ OPTIONS\n \ttools that want to do a blind one-shot run of a hook that may\n \tor may not be present.\n \n+WRAPPERS\n+--------\n+\n+`git hook run` has been designed to make it easy for tools which wrap Git to\n+configure and execute hooks using the Git hook infrastructure. It is possible to\n+provide arguments and stdin via the command line, as well as specifying parallel\n+or series execution if the user has provided multiple hooks.\n+\n+Assuming your wrapper wants to support a hook named \"mywrapper-start-tests\", you\n+can have your users specify their hooks like so:\n+\n+  [hook \"setup-test-dashboard\"]\n+    event = mywrapper-start-tests\n+    command = ~/mywrapper/setup-dashboard.py --tap\n+\n+Then, in your 'mywrapper' tool, you can invoke any users' configured hooks by\n+running:\n+\n+----\n+git hook run mywrapper-start-tests \\\n+  # providing something to stdin\n+  --stdin some-tempfile-123 \\\n+  # execute hooks in serial\n+  # plus some arguments of your own...\n+  -- \\\n+  --testname bar \\\n+  baz\n+----\n+\n+Take care to name your wrapper's hook events in a way which is unlikely to\n+overlap with Git's native hooks (see linkgit:githooks[5]) - a hook event named\n+`mywrappertool-validate-commit` is much less likely to be added to native Git\n+than a hook event named `validate-commit`. If Git begins to use a hook event\n+named the same thing as your wrapper hook, it may invoke your users' hooks in\n+unintended and unsupported ways.\n+\n+CONFIGURATION\n+-------------\n+include::config/hook.adoc[]\n+\n SEE ALSO\n --------\n linkgit:githooks[5]\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 855116ba8c..4e49afb4a1 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -67,6 +67,9 @@ static int list(int argc, const char **argv, const char *prefix,\n \t\tcase HOOK_TRADITIONAL:\n \t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n \t\t\tbreak;\n+\t\tcase HOOK_CONFIGURED:\n+\t\t\tprintf(\"%s\\n\", h->u.configured.friendly_name);\n+\t\t\tbreak;\n \t\tdefault:\n \t\t\tBUG(\"unknown hook kind\");\n \t\t}\ndiff --git a/hook.c b/hook.c\nindex 20c655918d..b67ec4ff22 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -4,9 +4,11 @@\n #include \"gettext.h\"\n #include \"hook.h\"\n #include \"path.h\"\n+#include \"parse.h\"\n #include \"run-command.h\"\n #include \"config.h\"\n #include \"strbuf.h\"\n+#include \"strmap.h\"\n #include \"environment.h\"\n #include \"setup.h\"\n \n@@ -54,8 +56,12 @@ void hook_free(void *p, const char *str UNUSED)\n \tif (!h)\n \t\treturn;\n \n-\tif (h->kind == HOOK_TRADITIONAL)\n+\tif (h->kind == HOOK_TRADITIONAL) {\n \t\tfree((void *)h->u.traditional.path);\n+\t} else if (h->kind == HOOK_CONFIGURED) {\n+\t\tfree((void *)h->u.configured.friendly_name);\n+\t\tfree((void *)h->u.configured.command);\n+\t}\n \n \tif (h->data_free)\n \t\th->data_free(h->feed_pipe_cb_data);\n@@ -95,6 +101,179 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n \tstring_list_append(hook_list, hook_path)->util = h;\n }\n \n+/*\n+ * Callback struct to collect all hook.* keys in a single config pass.\n+ * commands: friendly-name to command map.\n+ * event_hooks: event-name to list of friendly-names map.\n+ */\n+struct hook_all_config_cb {\n+\tstruct strmap commands;\n+\tstruct strmap event_hooks;\n+};\n+\n+/* repo_config() callback that collects all hook.* configuration in one pass. */\n+static int hook_config_lookup_all(const char *key, const char *value,\n+\t\t\t\t  const struct config_context *ctx UNUSED,\n+\t\t\t\t  void *cb_data)\n+{\n+\tstruct hook_all_config_cb *data = cb_data;\n+\tconst char *name, *subkey;\n+\tchar *hook_name;\n+\tsize_t name_len = 0;\n+\n+\tif (parse_config_key(key, \"hook\", &name, &name_len, &subkey))\n+\t\treturn 0;\n+\n+\tif (!value)\n+\t\treturn config_error_nonbool(key);\n+\n+\t/* Extract name, ensuring it is null-terminated. */\n+\thook_name = xmemdupz(name, name_len);\n+\n+\tif (!strcmp(subkey, \"event\")) {\n+\t\tstruct string_list *hooks =\n+\t\t\tstrmap_get(&data->event_hooks, value);\n+\n+\t\tif (!hooks) {\n+\t\t\thooks = xcalloc(1, sizeof(*hooks));\n+\t\t\tstring_list_init_dup(hooks);\n+\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n+\t\t}\n+\n+\t\t/* Re-insert if necessary to preserve last-seen order. */\n+\t\tunsorted_string_list_remove(hooks, hook_name, 0);\n+\t\tstring_list_append(hooks, hook_name);\n+\t} else if (!strcmp(subkey, \"command\")) {\n+\t\t/* Store command overwriting the old value */\n+\t\tchar *old = strmap_put(&data->commands, hook_name,\n+\t\t\t\t       xstrdup(value));\n+\t\tfree(old);\n+\t}\n+\n+\tfree(hook_name);\n+\treturn 0;\n+}\n+\n+/*\n+ * The hook config cache maps each hook event name to a string_list where\n+ * every item's string is the hook's friendly-name and its util pointer is\n+ * the corresponding command string. Both strings are owned by the map.\n+ *\n+ * Disabled hooks and hooks missing a command are already filtered out at\n+ * parse time, so callers can iterate the list directly.\n+ */\n+void hook_cache_clear(struct strmap *cache)\n+{\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tstrmap_for_each_entry(cache, &iter, e) {\n+\t\tstruct string_list *hooks = e->value;\n+\t\tstring_list_clear(hooks, 1); /* free util (command) pointers */\n+\t\tfree(hooks);\n+\t}\n+\tstrmap_clear(cache, 0);\n+}\n+\n+/* Populate `cache` with the complete hook configuration */\n+static void build_hook_config_map(struct repository *r, struct strmap *cache)\n+{\n+\tstruct hook_all_config_cb cb_data;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *e;\n+\n+\tstrmap_init(&cb_data.commands);\n+\tstrmap_init(&cb_data.event_hooks);\n+\n+\t/* Parse all configs in one run. */\n+\trepo_config(r, hook_config_lookup_all, &cb_data);\n+\n+\t/* Construct the cache from parsed configs. */\n+\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n+\t\tstruct string_list *hook_names = e->value;\n+\t\tstruct string_list *hooks;\n+\t\tCALLOC_ARRAY(hooks, 1);\n+\n+\t\tstring_list_init_dup(hooks);\n+\n+\t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n+\t\t\tconst char *hname = hook_names->items[i].string;\n+\t\t\tchar *command;\n+\n+\t\t\tcommand = strmap_get(&cb_data.commands, hname);\n+\t\t\tif (!command)\n+\t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n+\t\t\t\t      \"'hook.%s.event' must be removed;\"\n+\t\t\t\t      \" aborting.\"), hname, hname);\n+\n+\t\t\t/* util stores the command; owned by the cache. */\n+\t\t\tstring_list_append(hooks, hname)->util =\n+\t\t\t\txstrdup(command);\n+\t\t}\n+\n+\t\tstrmap_put(cache, e->key, hooks);\n+\t}\n+\n+\tstrmap_clear(&cb_data.commands, 1);\n+\tstrmap_for_each_entry(&cb_data.event_hooks, &iter, e) {\n+\t\tstring_list_clear(e->value, 0);\n+\t\tfree(e->value);\n+\t}\n+\tstrmap_clear(&cb_data.event_hooks, 0);\n+}\n+\n+/* Return the hook config map for `r`, populating it first if needed. */\n+static struct strmap *get_hook_config_cache(struct repository *r)\n+{\n+\tstruct strmap *cache = NULL;\n+\n+\tif (r) {\n+\t\t/*\n+\t\t * For in-repo calls, the map is stored in r->hook_config_cache,\n+\t\t * so repeated invocations don't parse the configs, so allocate\n+\t\t * it just once on the first call.\n+\t\t */\n+\t\tif (!r->hook_config_cache) {\n+\t\t\tCALLOC_ARRAY(r->hook_config_cache, 1);\n+\t\t\tstrmap_init(r->hook_config_cache);\n+\t\t\tbuild_hook_config_map(r, r->hook_config_cache);\n+\t\t}\n+\t\tcache = r->hook_config_cache;\n+\t}\n+\n+\treturn cache;\n+}\n+\n+static void list_hooks_add_configured(struct repository *r,\n+\t\t\t\t      const char *hookname,\n+\t\t\t\t      struct string_list *list,\n+\t\t\t\t      struct run_hooks_opt *options)\n+{\n+\tstruct strmap *cache = get_hook_config_cache(r);\n+\tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n+\n+\t/* Iterate through configured hooks and initialize internal states */\n+\tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n+\t\tconst char *friendly_name = configured_hooks->items[i].string;\n+\t\tconst char *command = configured_hooks->items[i].util;\n+\t\tstruct hook *hook;\n+\t\tCALLOC_ARRAY(hook, 1);\n+\n+\t\tif (options && options->feed_pipe_cb_data_alloc)\n+\t\t\thook->feed_pipe_cb_data =\n+\t\t\t\toptions->feed_pipe_cb_data_alloc(\n+\t\t\t\t\toptions->feed_pipe_ctx);\n+\t\tif (options)\n+\t\t\thook->data_free = options->feed_pipe_cb_data_free;\n+\n+\t\thook->kind = HOOK_CONFIGURED;\n+\t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n+\t\thook->u.configured.command = xstrdup(command);\n+\n+\t\tstring_list_append(list, friendly_name)->util = hook;\n+\t}\n+}\n+\n struct string_list *list_hooks(struct repository *r, const char *hookname,\n \t\t\t       struct run_hooks_opt *options)\n {\n@@ -106,6 +285,9 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,\n \tCALLOC_ARRAY(hook_head, 1);\n \tstring_list_init_dup(hook_head);\n \n+\t/* Add hooks from the config, e.g. hook.myhook.event = pre-commit */\n+\tlist_hooks_add_configured(r, hookname, hook_head, options);\n+\n \t/* Add the default \"traditional\" hooks from hookdir. */\n \tlist_hooks_add_default(r, hookname, hook_head, options);\n \n@@ -158,10 +340,18 @@ static int pick_next_hook(struct child_process *cp,\n \tcp->dir = hook_cb->options->dir;\n \n \t/* Add hook exec paths or commands */\n-\tif (h->kind == HOOK_TRADITIONAL)\n+\tswitch (h->kind) {\n+\tcase HOOK_TRADITIONAL:\n \t\tstrvec_push(&cp->args, h->u.traditional.path);\n-\telse\n+\t\tbreak;\n+\tcase HOOK_CONFIGURED:\n+\t\t/* to enable oneliners, let config-specified hooks run in shell. */\n+\t\tcp->use_shell = true;\n+\t\tstrvec_push(&cp->args, h->u.configured.command);\n+\t\tbreak;\n+\tdefault:\n \t\tBUG(\"unknown hook kind\");\n+\t}\n \n \tif (!cp->args.nr)\n \t\tBUG(\"hook must have at least one command or exec path\");\ndiff --git a/hook.h b/hook.h\nindex 36d40c98df..fa0fdfd691 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -3,6 +3,7 @@\n #include \"strvec.h\"\n #include \"run-command.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n \n struct repository;\n \n@@ -13,17 +14,22 @@ typedef void *(*hook_data_alloc_fn)(void *init_ctx);\n  * Represents a hook command to be run.\n  * Hooks can be:\n  * 1. \"traditional\" (found in the hooks directory)\n- * 2. \"configured\" (defined in Git's configuration, not yet implemented).\n+ * 2. \"configured\" (defined in Git's configuration via hook.<friendly-name>.event).\n  * The 'kind' field determines which part of the union 'u' is valid.\n  */\n struct hook {\n \tenum {\n \t\tHOOK_TRADITIONAL,\n+\t\tHOOK_CONFIGURED,\n \t} kind;\n \tunion {\n \t\tstruct {\n \t\t\tconst char *path;\n \t\t} traditional;\n+\t\tstruct {\n+\t\t\tconst char *friendly_name;\n+\t\t\tconst char *command;\n+\t\t} configured;\n \t} u;\n \n \t/**\n@@ -197,6 +203,12 @@ void hook_free(void *p, const char *str);\n struct string_list *list_hooks(struct repository *r, const char *hookname,\n \t\t\t       struct run_hooks_opt *options);\n \n+/**\n+ * Frees the hook configuration cache stored in `struct repository`.\n+ * Called by repo_clear().\n+ */\n+void hook_cache_clear(struct strmap *cache);\n+\n /**\n  * Returns the path to the hook file, or NULL if the hook is missing\n  * or disabled. Note that this points to static storage that will be\ndiff --git a/repository.c b/repository.c\nindex 44e77cd05a..c86c6457ba 100644\n--- a/repository.c\n+++ b/repository.c\n@@ -1,6 +1,7 @@\n #include \"git-compat-util.h\"\n #include \"abspath.h\"\n #include \"repository.h\"\n+#include \"hook.h\"\n #include \"odb.h\"\n #include \"config.h\"\n #include \"object.h\"\n@@ -399,6 +400,11 @@ void repo_clear(struct repository *repo)\n \t\tFREE_AND_NULL(repo->index);\n \t}\n \n+\tif (repo->hook_config_cache) {\n+\t\thook_cache_clear(repo->hook_config_cache);\n+\t\tFREE_AND_NULL(repo->hook_config_cache);\n+\t}\n+\n \tif (repo->promisor_remote_config) {\n \t\tpromisor_remote_clear(repo->promisor_remote_config);\n \t\tFREE_AND_NULL(repo->promisor_remote_config);\ndiff --git a/repository.h b/repository.h\nindex 72a5e9d410..8f057a241d 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -162,6 +162,12 @@ struct repository {\n \t/* True if commit-graph has been disabled within this process. */\n \tint commit_graph_disabled;\n \n+\t/*\n+\t * Lazily-populated cache mapping hook event names to configured hooks.\n+\t * NULL until first hook use.\n+\t */\n+\tstruct strmap *hook_config_cache;\n+\n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n \tstruct promisor_remote_config *promisor_remote_config;\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex d1380a4f0e..3a95cfe16d 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -1,10 +1,26 @@\n #!/bin/sh\n \n-test_description='git-hook command'\n+test_description='git-hook command and config-managed multihooks'\n \n . ./test-lib.sh\n . \"$TEST_DIRECTORY\"/lib-terminal.sh\n \n+setup_hooks () {\n+\ttest_config hook.ghi.command \"/path/ghi\"\n+\ttest_config hook.ghi.event pre-commit --add\n+\ttest_config hook.ghi.event test-hook --add\n+\ttest_config_global hook.def.command \"/path/def\"\n+\ttest_config_global hook.def.event pre-commit --add\n+}\n+\n+setup_hookdir () {\n+\tmkdir .git/hooks\n+\twrite_script .git/hooks/pre-commit <<-EOF\n+\techo \\\"Legacy Hook\\\"\n+\tEOF\n+\ttest_when_finished rm -rf .git/hooks\n+}\n+\n test_expect_success 'git hook usage' '\n \ttest_expect_code 129 git hook &&\n \ttest_expect_code 129 git hook run &&\n@@ -35,6 +51,15 @@ test_expect_success 'git hook list: traditional hook from hookdir' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success 'git hook list: configured hook' '\n+\ttest_config hook.myhook.command \"echo Hello\" &&\n+\ttest_config hook.myhook.event test-hook --add &&\n+\n+\techo \"myhook\" >expect &&\n+\tgit hook list test-hook >actual &&\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success 'git hook run: nonexistent hook' '\n \tcat >stderr.expect <<-\\EOF &&\n \terror: cannot find a hook named test-hook\n@@ -172,6 +197,152 @@ test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '\n \ttest_hook_tty commit -m\"B.new\"\n '\n \n+test_expect_success 'git hook list orders by config order' '\n+\tsetup_hooks &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tdef\n+\tghi\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'git hook list reorders on duplicate event declarations' '\n+\tsetup_hooks &&\n+\n+\t# 'def' is usually configured globally; move it to the end by\n+\t# configuring it locally.\n+\ttest_config hook.def.event \"pre-commit\" --add &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tghi\n+\tdef\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'hook can be configured for multiple events' '\n+\tsetup_hooks &&\n+\n+\t# 'ghi' should be included in both 'pre-commit' and 'test-hook'\n+\tgit hook list pre-commit >actual &&\n+\tgrep \"ghi\" actual &&\n+\tgit hook list test-hook >actual &&\n+\tgrep \"ghi\" actual\n+'\n+\n+test_expect_success 'git hook list shows hooks from the hookdir' '\n+\tsetup_hookdir &&\n+\n+\tcat >expected <<-\\EOF &&\n+\thook from hookdir\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'inline hook definitions execute oneliners' '\n+\ttest_config hook.oneliner.event \"pre-commit\" &&\n+\ttest_config hook.oneliner.command \"echo \\\"Hello World\\\"\" &&\n+\n+\techo \"Hello World\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'inline hook definitions resolve paths' '\n+\twrite_script sample-hook.sh <<-\\EOF &&\n+\techo \\\"Sample Hook\\\"\n+\tEOF\n+\n+\ttest_when_finished \"rm sample-hook.sh\" &&\n+\n+\ttest_config hook.sample-hook.event pre-commit &&\n+\ttest_config hook.sample-hook.command \"\\\"$(pwd)/sample-hook.sh\\\"\" &&\n+\n+\techo \\\"Sample Hook\\\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'hookdir hook included in git hook run' '\n+\tsetup_hookdir &&\n+\n+\techo \\\"Legacy Hook\\\" >expected &&\n+\n+\t# hooks are run with stdout_to_stderr = 1\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'configured hooks run before hookdir hook' '\n+\tsetup_hookdir &&\n+\ttest_config hook.first.event \"pre-commit\" &&\n+\ttest_config hook.first.command \"echo first\" &&\n+\ttest_config hook.second.event \"pre-commit\" &&\n+\ttest_config hook.second.command \"echo second\" &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tfirst\n+\tsecond\n+\thook from hookdir\n+\tEOF\n+\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tfirst\n+\tsecond\n+\t\"Legacy Hook\"\n+\tEOF\n+\n+\tgit hook run pre-commit 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'stdin to multiple hooks' '\n+\ttest_config hook.stdin-a.event \"test-hook\" &&\n+\ttest_config hook.stdin-a.command \"xargs -P1 -I% echo a%\" &&\n+\ttest_config hook.stdin-b.event \"test-hook\" &&\n+\ttest_config hook.stdin-b.command \"xargs -P1 -I% echo b%\" &&\n+\n+\tcat >input <<-\\EOF &&\n+\t1\n+\t2\n+\t3\n+\tEOF\n+\n+\tcat >expected <<-\\EOF &&\n+\ta1\n+\ta2\n+\ta3\n+\tb1\n+\tb2\n+\tb3\n+\tEOF\n+\n+\tgit hook run --to-stdin=input test-hook 2>actual &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'rejects hooks with no commands configured' '\n+\ttest_config hook.broken.event \"test-hook\" &&\n+\ttest_must_fail git hook list test-hook 2>actual &&\n+\ttest_grep \"hook.broken.command\" actual &&\n+\ttest_must_fail git hook run test-hook 2>actual &&\n+\ttest_grep \"hook.broken.command\" actual\n+'\n+\n test_expect_success 'git hook run a hook with a bad shebang' '\n \ttest_when_finished \"rm -rf bad-hooks\" &&\n \tmkdir bad-hooks &&\n@@ -189,6 +360,7 @@ test_expect_success 'git hook run a hook with a bad shebang' '\n '\n \n test_expect_success 'stdin to hooks' '\n+\tmkdir -p .git/hooks &&\n \twrite_script .git/hooks/test-hook <<-\\EOF &&\n \techo BEGIN stdin\n \tcat\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537461","messageId":"20260301184500.1488433-8-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 07/12] hook: allow event = \"\" to overwrite previous values","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:55Z","receivedAt":"2026-03-01T18:46:28Z","isPatch":true,"body":"Add the ability for empty events to clear previously set multivalue\nvariables, so the newly added \"hook.*.event\" behave like the other\nmultivalued keys.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/config/hook.adoc |  4 +++-\n hook.c                         | 31 ++++++++++++++++++++-----------\n t/t1800-hook.sh                | 12 ++++++++++++\n 3 files changed, 35 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 4bbda5636d..d0023b2deb 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -12,7 +12,9 @@ hook.<friendly-name>.event::\n \tlinkgit:githooks[5] for a complete list of hook events.) On the\n \tspecified event, the associated `hook.<friendly-name>.command` is executed.\n \tThis is a multi-valued key. To run `hook.<friendly-name>` on multiple\n-\tevents, specify the key more than once. See linkgit:git-hook[1].\n+\tevents, specify the key more than once. An empty value resets\n+\tthe list of events, clearing any previously defined events for\n+\t`hook.<friendly-name>`. See linkgit:git-hook[1].\n \n hook.<friendly-name>.enabled::\n \tWhether the hook `hook.<friendly-name>` is enabled. Defaults to `true`.\ndiff --git a/hook.c b/hook.c\nindex 24eb330cac..696919e703 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -133,18 +133,27 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \thook_name = xmemdupz(name, name_len);\n \n \tif (!strcmp(subkey, \"event\")) {\n-\t\tstruct string_list *hooks =\n-\t\t\tstrmap_get(&data->event_hooks, value);\n+\t\tif (!*value) {\n+\t\t\t/* Empty values reset previous events for this hook. */\n+\t\t\tstruct hashmap_iter iter;\n+\t\t\tstruct strmap_entry *e;\n+\n+\t\t\tstrmap_for_each_entry(&data->event_hooks, &iter, e)\n+\t\t\t\tunsorted_string_list_remove(e->value, hook_name, 0);\n+\t\t} else {\n+\t\t\tstruct string_list *hooks =\n+\t\t\t\tstrmap_get(&data->event_hooks, value);\n+\n+\t\t\tif (!hooks) {\n+\t\t\t\tCALLOC_ARRAY(hooks, 1);\n+\t\t\t\tstring_list_init_dup(hooks);\n+\t\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n+\t\t\t}\n \n-\t\tif (!hooks) {\n-\t\t\thooks = xcalloc(1, sizeof(*hooks));\n-\t\t\tstring_list_init_dup(hooks);\n-\t\t\tstrmap_put(&data->event_hooks, value, hooks);\n+\t\t\t/* Re-insert if necessary to preserve last-seen order. */\n+\t\t\tunsorted_string_list_remove(hooks, hook_name, 0);\n+\t\t\tstring_list_append(hooks, hook_name);\n \t\t}\n-\n-\t\t/* Re-insert if necessary to preserve last-seen order. */\n-\t\tunsorted_string_list_remove(hooks, hook_name, 0);\n-\t\tstring_list_append(hooks, hook_name);\n \t} else if (!strcmp(subkey, \"command\")) {\n \t\t/* Store command overwriting the old value */\n \t\tchar *old = strmap_put(&data->commands, hook_name,\n@@ -160,7 +169,7 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \t\t\tbreak;\n \t\tcase 1: /* enabled: undo a prior disabled entry */\n \t\t\tunsorted_string_list_remove(&data->disabled_hooks,\n-\t\t\t\t\t\t    hook_name);\n+\t\t\t\t\t\t    hook_name, 0);\n \t\t\tbreak;\n \t\tdefault:\n \t\t\tbreak; /* ignore unrecognised values */\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex fb8cfc8137..c14ec661b9 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -225,6 +225,18 @@ test_expect_success 'git hook list reorders on duplicate event declarations' '\n \ttest_cmp expected actual\n '\n \n+test_expect_success 'git hook list: empty event value resets events' '\n+\tsetup_hooks &&\n+\n+\t# ghi is configured for pre-commit; reset it with an empty value\n+\ttest_config hook.ghi.event \"\" --add &&\n+\n+\t# only def should remain for pre-commit\n+\techo \"def\" >expected &&\n+\tgit hook list pre-commit >actual &&\n+\ttest_cmp expected actual\n+'\n+\n test_expect_success 'hook can be configured for multiple events' '\n \tsetup_hooks &&\n \n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537462","messageId":"20260301184500.1488433-9-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 08/12] hook: allow out-of-repo 'git hook' invocations","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:56Z","receivedAt":"2026-03-01T18:46:29Z","isPatch":true,"body":"From: Emily Shaffer <emilyshaffer@google.com>\n\nSince hooks can now be supplied via the config, and a config can be\npresent without a gitdir via the global and system configs, we can start\nto allow 'git hook run' to occur without a gitdir. This enables us to do\nthings like run sendemail-validate hooks when running 'git send-email'\nfrom a nongit directory.\n\nIt still doesn't make sense to look for hooks in the hookdir in nongit\nrepos, though, as there is no hookdir.\n\nSigned-off-by: Emily Shaffer <emilyshaffer@google.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n git.c           |  2 +-\n hook.c          | 30 ++++++++++++++++++++++++++++--\n t/t1800-hook.sh | 16 +++++++++++-----\n 3 files changed, 40 insertions(+), 8 deletions(-)\n\ndiff --git a/git.c b/git.c\nindex 744cb6527e..6480ff8373 100644\n--- a/git.c\n+++ b/git.c\n@@ -587,7 +587,7 @@ static struct cmd_struct commands[] = {\n \t{ \"hash-object\", cmd_hash_object },\n \t{ \"help\", cmd_help },\n \t{ \"history\", cmd_history, RUN_SETUP },\n-\t{ \"hook\", cmd_hook, RUN_SETUP },\n+\t{ \"hook\", cmd_hook, RUN_SETUP_GENTLY },\n \t{ \"index-pack\", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },\n \t{ \"init\", cmd_init_db },\n \t{ \"init-db\", cmd_init_db },\ndiff --git a/hook.c b/hook.c\nindex 696919e703..9b97fa641f 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -18,6 +18,9 @@ const char *find_hook(struct repository *r, const char *name)\n \n \tint found_hook;\n \n+\tif (!r || !r->gitdir)\n+\t\treturn NULL;\n+\n \trepo_git_path_replace(r, &path, \"hooks/%s\", name);\n \tfound_hook = access(path.buf, X_OK) >= 0;\n #ifdef STRIP_EXTENSION\n@@ -255,12 +258,18 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \tstrmap_clear(&cb_data.event_hooks, 0);\n }\n \n-/* Return the hook config map for `r`, populating it first if needed. */\n+/*\n+ * Return the hook config map for `r`, populating it first if needed.\n+ *\n+ * Out-of-repo calls (r->gitdir == NULL) allocate and return a temporary\n+ * cache map; the caller is responsible for freeing it with\n+ * hook_cache_clear() + free().\n+ */\n static struct strmap *get_hook_config_cache(struct repository *r)\n {\n \tstruct strmap *cache = NULL;\n \n-\tif (r) {\n+\tif (r && r->gitdir) {\n \t\t/*\n \t\t * For in-repo calls, the map is stored in r->hook_config_cache,\n \t\t * so repeated invocations don't parse the configs, so allocate\n@@ -272,6 +281,14 @@ static struct strmap *get_hook_config_cache(struct repository *r)\n \t\t\tbuild_hook_config_map(r, r->hook_config_cache);\n \t\t}\n \t\tcache = r->hook_config_cache;\n+\t} else {\n+\t\t/*\n+\t\t * Out-of-repo calls (no gitdir) allocate and return a temporary\n+\t\t * map cache which gets free'd immediately by the caller.\n+\t\t */\n+\t\tCALLOC_ARRAY(cache, 1);\n+\t\tstrmap_init(cache);\n+\t\tbuild_hook_config_map(r, cache);\n \t}\n \n \treturn cache;\n@@ -305,6 +322,15 @@ static void list_hooks_add_configured(struct repository *r,\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n+\n+\t/*\n+\t * Cleanup temporary cache for out-of-repo calls since they can't be\n+\t * stored persistently. Next out-of-repo calls will have to re-parse.\n+\t */\n+\tif (!r || !r->gitdir) {\n+\t\thook_cache_clear(cache);\n+\t\tfree(cache);\n+\t}\n }\n \n struct string_list *list_hooks(struct repository *r, const char *hookname,\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex c14ec661b9..856555bce5 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -130,12 +130,18 @@ test_expect_success 'git hook run -- pass arguments' '\n \ttest_cmp expect actual\n '\n \n-test_expect_success 'git hook run -- out-of-repo runs excluded' '\n-\ttest_hook test-hook <<-EOF &&\n-\techo Test hook\n-\tEOF\n+test_expect_success 'git hook run: out-of-repo runs execute global hooks' '\n+\ttest_config_global hook.global-hook.event test-hook --add &&\n+\ttest_config_global hook.global-hook.command \"echo no repo no problems\" --add &&\n \n-\tnongit test_must_fail git hook run test-hook\n+\techo \"global-hook\" >expect &&\n+\tnongit git hook list test-hook >actual &&\n+\ttest_cmp expect actual &&\n+\n+\techo \"no repo no problems\" >expect &&\n+\n+\tnongit git hook run test-hook 2>actual &&\n+\ttest_cmp expect actual\n '\n \n test_expect_success 'git -c core.hooksPath=<PATH> hook run' '\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537463","messageId":"20260301184500.1488433-10-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 09/12] hook: add -z option to \"git hook list\"","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:57Z","receivedAt":"2026-03-01T18:46:32Z","isPatch":true,"body":"Add a NUL-terminate mode to git hook list, just in case hooks are\nconfigured with weird characters like newlines in their names.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  8 ++++++--\n builtin/hook.c              |  9 ++++++---\n t/t1800-hook.sh             | 13 +++++++++++++\n 3 files changed, 25 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 0eaf864c43..966388660a 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -9,7 +9,7 @@ SYNOPSIS\n --------\n [verse]\n 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n-'git hook' list <hook-name>\n+'git hook' list [-z] <hook-name>\n \n DESCRIPTION\n -----------\n@@ -113,9 +113,10 @@ Any positional arguments to the hook should be passed after a\n mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See\n linkgit:githooks[5] for arguments hooks might expect (if any).\n \n-list::\n+list [-z]::\n \tPrint a list of hooks which will be run on `<hook-name>` event. If no\n \thooks are configured for that event, print a warning and return 1.\n+\tUse `-z` to terminate output lines with NUL instead of newlines.\n \n OPTIONS\n -------\n@@ -130,6 +131,9 @@ OPTIONS\n \ttools that want to do a blind one-shot run of a hook that may\n \tor may not be present.\n \n+-z::\n+\tTerminate \"list\" output lines with NUL instead of newlines.\n+\n WRAPPERS\n --------\n \ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 4e49afb4a1..542183795a 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -10,7 +10,7 @@\n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n-\tN_(\"git hook list <hook-name>\")\n+\tN_(\"git hook list [-z] <hook-name>\")\n \n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n@@ -33,9 +33,12 @@ static int list(int argc, const char **argv, const char *prefix,\n \tstruct string_list *head;\n \tstruct string_list_item *item;\n \tconst char *hookname = NULL;\n+\tint line_terminator = '\\n';\n \tint ret = 0;\n \n \tstruct option list_options[] = {\n+\t\tOPT_SET_INT('z', NULL, &line_terminator,\n+\t\t\t    N_(\"use NUL as line terminator\"), '\\0'),\n \t\tOPT_END(),\n \t};\n \n@@ -65,10 +68,10 @@ static int list(int argc, const char **argv, const char *prefix,\n \n \t\tswitch (h->kind) {\n \t\tcase HOOK_TRADITIONAL:\n-\t\t\tprintf(\"%s\\n\", _(\"hook from hookdir\"));\n+\t\t\tprintf(\"%s%c\", _(\"hook from hookdir\"), line_terminator);\n \t\t\tbreak;\n \t\tcase HOOK_CONFIGURED:\n-\t\t\tprintf(\"%s\\n\", h->u.configured.friendly_name);\n+\t\t\tprintf(\"%s%c\", h->u.configured.friendly_name, line_terminator);\n \t\t\tbreak;\n \t\tdefault:\n \t\t\tBUG(\"unknown hook kind\");\ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 856555bce5..0a4b2a9978 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -60,6 +60,19 @@ test_expect_success 'git hook list: configured hook' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success 'git hook list: -z shows NUL-terminated output' '\n+\ttest_hook test-hook <<-EOF &&\n+\techo Test hook\n+\tEOF\n+\ttest_config hook.myhook.command \"echo Hello\" &&\n+\ttest_config hook.myhook.event test-hook --add &&\n+\n+\tprintf \"myhookQhook from hookdirQ\" >expect &&\n+\tgit hook list -z test-hook >actual.raw &&\n+\tnul_to_q <actual.raw >actual &&\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success 'git hook run: nonexistent hook' '\n \tcat >stderr.expect <<-\\EOF &&\n \terror: cannot find a hook named test-hook\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537464","messageId":"20260301184500.1488433-11-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 10/12] hook: refactor hook_config_cache from strmap to named struct","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:58Z","receivedAt":"2026-03-01T18:46:34Z","isPatch":true,"body":"Replace the raw `struct strmap *hook_config_cache` in `struct repository`\nwith a `struct hook_config_cache` which wraps the strmap in a named field.\n\nReplace the bare `char *command` util pointer stored in each string_list\nitem with a heap-allocated `struct hook_config_cache_entry` that carries\nthat command string.\n\nThis is just a refactoring with no behavior changes, to give the cache\nstruct room to grow so it can carry the additional hook metadata we'll\nbe adding in the following commits.\n\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n hook.c       | 63 +++++++++++++++++++++++++++++++++-------------------\n hook.h       | 10 ++++++++-\n repository.h |  3 ++-\n 3 files changed, 51 insertions(+), 25 deletions(-)\n\ndiff --git a/hook.c b/hook.c\nindex 9b97fa641f..0bab491953 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -104,6 +104,15 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n \tstring_list_append(hook_list, hook_path)->util = h;\n }\n \n+/*\n+ * Cache entry stored as the .util pointer of string_list items inside the\n+ * hook config cache. For now carries only the command for the hook. Next\n+ * commits will add more data.\n+ */\n+struct hook_config_cache_entry {\n+\tchar *command;\n+};\n+\n /*\n  * Callback struct to collect all hook.* keys in a single config pass.\n  * commands: friendly-name to command map.\n@@ -186,26 +195,32 @@ static int hook_config_lookup_all(const char *key, const char *value,\n /*\n  * The hook config cache maps each hook event name to a string_list where\n  * every item's string is the hook's friendly-name and its util pointer is\n- * the corresponding command string. Both strings are owned by the map.\n+ * a hook_config_cache_entry. All strings are owned by the map.\n  *\n  * Disabled hooks and hooks missing a command are already filtered out at\n  * parse time, so callers can iterate the list directly.\n  */\n-void hook_cache_clear(struct strmap *cache)\n+void hook_cache_clear(struct hook_config_cache *cache)\n {\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *e;\n \n-\tstrmap_for_each_entry(cache, &iter, e) {\n+\tstrmap_for_each_entry(&cache->hooks, &iter, e) {\n \t\tstruct string_list *hooks = e->value;\n-\t\tstring_list_clear(hooks, 1); /* free util (command) pointers */\n+\t\tfor (size_t i = 0; i < hooks->nr; i++) {\n+\t\t\tstruct hook_config_cache_entry *entry = hooks->items[i].util;\n+\t\t\tfree(entry->command);\n+\t\t\tfree(entry);\n+\t\t}\n+\t\tstring_list_clear(hooks, 0);\n \t\tfree(hooks);\n \t}\n-\tstrmap_clear(cache, 0);\n+\tstrmap_clear(&cache->hooks, 0);\n }\n \n /* Populate `cache` with the complete hook configuration */\n-static void build_hook_config_map(struct repository *r, struct strmap *cache)\n+static void build_hook_config_map(struct repository *r,\n+\t\t\t\t  struct hook_config_cache *cache)\n {\n \tstruct hook_all_config_cb cb_data;\n \tstruct hashmap_iter iter;\n@@ -228,6 +243,7 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \n \t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n \t\t\tconst char *hname = hook_names->items[i].string;\n+\t\t\tstruct hook_config_cache_entry *entry;\n \t\t\tchar *command;\n \n \t\t\t/* filter out disabled hooks */\n@@ -241,12 +257,13 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n \t\t\t\t      \"'hook.%s.event' must be removed;\"\n \t\t\t\t      \" aborting.\"), hname, hname);\n \n-\t\t\t/* util stores the command; owned by the cache. */\n-\t\t\tstring_list_append(hooks, hname)->util =\n-\t\t\t\txstrdup(command);\n+\t\t\t/* util stores a cache entry; owned by the cache. */\n+\t\t\tCALLOC_ARRAY(entry, 1);\n+\t\t\tentry->command = xstrdup(command);\n+\t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n-\t\tstrmap_put(cache, e->key, hooks);\n+\t\tstrmap_put(&cache->hooks, e->key, hooks);\n \t}\n \n \tstrmap_clear(&cb_data.commands, 1);\n@@ -259,35 +276,35 @@ static void build_hook_config_map(struct repository *r, struct strmap *cache)\n }\n \n /*\n- * Return the hook config map for `r`, populating it first if needed.\n+ * Return the hook config cache for `r`, populating it first if needed.\n  *\n  * Out-of-repo calls (r->gitdir == NULL) allocate and return a temporary\n- * cache map; the caller is responsible for freeing it with\n+ * cache; the caller is responsible for freeing it with\n  * hook_cache_clear() + free().\n  */\n-static struct strmap *get_hook_config_cache(struct repository *r)\n+static struct hook_config_cache *get_hook_config_cache(struct repository *r)\n {\n-\tstruct strmap *cache = NULL;\n+\tstruct hook_config_cache *cache = NULL;\n \n \tif (r && r->gitdir) {\n \t\t/*\n-\t\t * For in-repo calls, the map is stored in r->hook_config_cache,\n-\t\t * so repeated invocations don't parse the configs, so allocate\n+\t\t * For in-repo calls, the cache is stored in r->hook_config_cache,\n+\t\t * so repeated invocations don't parse the configs; allocate\n \t\t * it just once on the first call.\n \t\t */\n \t\tif (!r->hook_config_cache) {\n \t\t\tCALLOC_ARRAY(r->hook_config_cache, 1);\n-\t\t\tstrmap_init(r->hook_config_cache);\n+\t\t\tstrmap_init(&r->hook_config_cache->hooks);\n \t\t\tbuild_hook_config_map(r, r->hook_config_cache);\n \t\t}\n \t\tcache = r->hook_config_cache;\n \t} else {\n \t\t/*\n \t\t * Out-of-repo calls (no gitdir) allocate and return a temporary\n-\t\t * map cache which gets free'd immediately by the caller.\n+\t\t * cache which gets freed immediately by the caller.\n \t\t */\n \t\tCALLOC_ARRAY(cache, 1);\n-\t\tstrmap_init(cache);\n+\t\tstrmap_init(&cache->hooks);\n \t\tbuild_hook_config_map(r, cache);\n \t}\n \n@@ -299,13 +316,13 @@ static void list_hooks_add_configured(struct repository *r,\n \t\t\t\t      struct string_list *list,\n \t\t\t\t      struct run_hooks_opt *options)\n {\n-\tstruct strmap *cache = get_hook_config_cache(r);\n-\tstruct string_list *configured_hooks = strmap_get(cache, hookname);\n+\tstruct hook_config_cache *cache = get_hook_config_cache(r);\n+\tstruct string_list *configured_hooks = strmap_get(&cache->hooks, hookname);\n \n \t/* Iterate through configured hooks and initialize internal states */\n \tfor (size_t i = 0; configured_hooks && i < configured_hooks->nr; i++) {\n \t\tconst char *friendly_name = configured_hooks->items[i].string;\n-\t\tconst char *command = configured_hooks->items[i].util;\n+\t\tstruct hook_config_cache_entry *entry = configured_hooks->items[i].util;\n \t\tstruct hook *hook;\n \t\tCALLOC_ARRAY(hook, 1);\n \n@@ -318,7 +335,7 @@ static void list_hooks_add_configured(struct repository *r,\n \n \t\thook->kind = HOOK_CONFIGURED;\n \t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n-\t\thook->u.configured.command = xstrdup(command);\n+\t\thook->u.configured.command = xstrdup(entry->command);\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\ndiff --git a/hook.h b/hook.h\nindex fa0fdfd691..277390b744 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -203,11 +203,19 @@ void hook_free(void *p, const char *str);\n struct string_list *list_hooks(struct repository *r, const char *hookname,\n \t\t\t       struct run_hooks_opt *options);\n \n+/**\n+ * Persistent cache for hook configuration, stored on `struct repository`.\n+ * Populated lazily on first hook use and freed by repo_clear().\n+ */\n+struct hook_config_cache {\n+\tstruct strmap hooks; /* maps event name -> string_list of hooks */\n+};\n+\n /**\n  * Frees the hook configuration cache stored in `struct repository`.\n  * Called by repo_clear().\n  */\n-void hook_cache_clear(struct strmap *cache);\n+void hook_cache_clear(struct hook_config_cache *cache);\n \n /**\n  * Returns the path to the hook file, or NULL if the hook is missing\ndiff --git a/repository.h b/repository.h\nindex 8f057a241d..65748a5283 100644\n--- a/repository.h\n+++ b/repository.h\n@@ -11,6 +11,7 @@ struct lock_file;\n struct pathspec;\n struct object_database;\n struct submodule_cache;\n+struct hook_config_cache;\n struct promisor_remote_config;\n struct remote_state;\n \n@@ -166,7 +167,7 @@ struct repository {\n \t * Lazily-populated cache mapping hook event names to configured hooks.\n \t * NULL until first hook use.\n \t */\n-\tstruct strmap *hook_config_cache;\n+\tstruct hook_config_cache *hook_config_cache;\n \n \t/* Configurations related to promisor remotes. */\n \tchar *repository_format_partial_clone;\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537465","messageId":"20260301184500.1488433-12-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 11/12] hook: store and display scope for configured hooks in git hook list","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:44:59Z","receivedAt":"2026-03-01T18:46:36Z","isPatch":true,"body":"Users running \"git hook list\" can see which hooks are configured but\nhave no way to tell at which config scope (local, global, system...)\neach hook was defined.\n\nStore the scope from ctx->kvi->scope in the single-pass config callback,\nthen carry it through the cache to the hook structs, then expose it to\nthe users via the \"git hook list --show-scope\" flag, which mirrors the\nexisting git config --show-scope convention.\n\nWithout the flag the output is unchanged.\n\n$ git hook list --show-scope pre-commit\nlinter (global)\nno-leaks (local)\nhook from hookdir\n\nTraditional hooks from the hookdir are unaffected by --show-scope since\nthe config scope concept does not apply to them.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n Documentation/git-hook.adoc |  9 +++++++--\n builtin/hook.c              | 14 ++++++++++++--\n hook.c                      | 17 +++++++++++++----\n hook.h                      |  2 ++\n t/t1800-hook.sh             | 19 +++++++++++++++++++\n 5 files changed, 53 insertions(+), 8 deletions(-)\n\ndiff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc\nindex 966388660a..4d4e728327 100644\n--- a/Documentation/git-hook.adoc\n+++ b/Documentation/git-hook.adoc\n@@ -9,7 +9,7 @@ SYNOPSIS\n --------\n [verse]\n 'git hook' run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\n-'git hook' list [-z] <hook-name>\n+'git hook' list [-z] [--show-scope] <hook-name>\n \n DESCRIPTION\n -----------\n@@ -113,7 +113,7 @@ Any positional arguments to the hook should be passed after a\n mandatory `--` (or `--end-of-options`, see linkgit:gitcli[7]). See\n linkgit:githooks[5] for arguments hooks might expect (if any).\n \n-list [-z]::\n+list [-z] [--show-scope]::\n \tPrint a list of hooks which will be run on `<hook-name>` event. If no\n \thooks are configured for that event, print a warning and return 1.\n \tUse `-z` to terminate output lines with NUL instead of newlines.\n@@ -134,6 +134,11 @@ OPTIONS\n -z::\n \tTerminate \"list\" output lines with NUL instead of newlines.\n \n+--show-scope::\n+\tFor \"list\"; print the config scope (e.g. `local`, `global`, `system`)\n+\tin parentheses after the friendly name of each configured hook, to show\n+\twhere it was defined. Traditional hooks from the hookdir are unaffected.\n+\n WRAPPERS\n --------\n \ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex 542183795a..eb8dfcd31b 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -10,7 +10,7 @@\n #define BUILTIN_HOOK_RUN_USAGE \\\n \tN_(\"git hook run [--ignore-missing] [--to-stdin=<path>] <hook-name> [-- <hook-args>]\")\n #define BUILTIN_HOOK_LIST_USAGE \\\n-\tN_(\"git hook list [-z] <hook-name>\")\n+\tN_(\"git hook list [-z] [--show-scope] <hook-name>\")\n \n static const char * const builtin_hook_usage[] = {\n \tBUILTIN_HOOK_RUN_USAGE,\n@@ -34,11 +34,14 @@ static int list(int argc, const char **argv, const char *prefix,\n \tstruct string_list_item *item;\n \tconst char *hookname = NULL;\n \tint line_terminator = '\\n';\n+\tint show_scope = 0;\n \tint ret = 0;\n \n \tstruct option list_options[] = {\n \t\tOPT_SET_INT('z', NULL, &line_terminator,\n \t\t\t    N_(\"use NUL as line terminator\"), '\\0'),\n+\t\tOPT_BOOL(0, \"show-scope\", &show_scope,\n+\t\t\t N_(\"show the config scope that defined each hook\")),\n \t\tOPT_END(),\n \t};\n \n@@ -71,7 +74,14 @@ static int list(int argc, const char **argv, const char *prefix,\n \t\t\tprintf(\"%s%c\", _(\"hook from hookdir\"), line_terminator);\n \t\t\tbreak;\n \t\tcase HOOK_CONFIGURED:\n-\t\t\tprintf(\"%s%c\", h->u.configured.friendly_name, line_terminator);\n+\t\t\tif (show_scope)\n+\t\t\t\tprintf(\"%s (%s)%c\",\n+\t\t\t\t       h->u.configured.friendly_name,\n+\t\t\t\t       config_scope_name(h->u.configured.scope),\n+\t\t\t\t       line_terminator);\n+\t\t\telse\n+\t\t\t\tprintf(\"%s%c\", h->u.configured.friendly_name,\n+\t\t\t\t       line_terminator);\n \t\t\tbreak;\n \t\tdefault:\n \t\t\tBUG(\"unknown hook kind\");\ndiff --git a/hook.c b/hook.c\nindex 0bab491953..a868d8cf17 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -106,11 +106,11 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n \n /*\n  * Cache entry stored as the .util pointer of string_list items inside the\n- * hook config cache. For now carries only the command for the hook. Next\n- * commits will add more data.\n+ * hook config cache.\n  */\n struct hook_config_cache_entry {\n \tchar *command;\n+\tenum config_scope scope;\n };\n \n /*\n@@ -127,7 +127,7 @@ struct hook_all_config_cb {\n \n /* repo_config() callback that collects all hook.* configuration in one pass. */\n static int hook_config_lookup_all(const char *key, const char *value,\n-\t\t\t\t  const struct config_context *ctx UNUSED,\n+\t\t\t\t  const struct config_context *ctx,\n \t\t\t\t  void *cb_data)\n {\n \tstruct hook_all_config_cb *data = cb_data;\n@@ -164,7 +164,12 @@ static int hook_config_lookup_all(const char *key, const char *value,\n \n \t\t\t/* Re-insert if necessary to preserve last-seen order. */\n \t\t\tunsorted_string_list_remove(hooks, hook_name, 0);\n-\t\t\tstring_list_append(hooks, hook_name);\n+\t\t\t/*\n+\t\t\t * Store the config scope in util so callers can\n+\t\t\t * report where each hook was defined.\n+\t\t\t */\n+\t\t\tstring_list_append(hooks, hook_name)->util =\n+\t\t\t\t(void *)(uintptr_t)ctx->kvi->scope;\n \t\t}\n \t} else if (!strcmp(subkey, \"command\")) {\n \t\t/* Store command overwriting the old value */\n@@ -243,6 +248,8 @@ static void build_hook_config_map(struct repository *r,\n \n \t\tfor (size_t i = 0; i < hook_names->nr; i++) {\n \t\t\tconst char *hname = hook_names->items[i].string;\n+\t\t\tenum config_scope scope =\n+\t\t\t\t(enum config_scope)(uintptr_t)hook_names->items[i].util;\n \t\t\tstruct hook_config_cache_entry *entry;\n \t\t\tchar *command;\n \n@@ -260,6 +267,7 @@ static void build_hook_config_map(struct repository *r,\n \t\t\t/* util stores a cache entry; owned by the cache. */\n \t\t\tCALLOC_ARRAY(entry, 1);\n \t\t\tentry->command = xstrdup(command);\n+\t\t\tentry->scope = scope;\n \t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n@@ -336,6 +344,7 @@ static void list_hooks_add_configured(struct repository *r,\n \t\thook->kind = HOOK_CONFIGURED;\n \t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n \t\thook->u.configured.command = xstrdup(entry->command);\n+\t\thook->u.configured.scope = entry->scope;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\ndiff --git a/hook.h b/hook.h\nindex 277390b744..b862cde01b 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -1,5 +1,6 @@\n #ifndef HOOK_H\n #define HOOK_H\n+#include \"config.h\"\n #include \"strvec.h\"\n #include \"run-command.h\"\n #include \"string-list.h\"\n@@ -29,6 +30,7 @@ struct hook {\n \t\tstruct {\n \t\t\tconst char *friendly_name;\n \t\t\tconst char *command;\n+\t\t\tenum config_scope scope;\n \t\t} configured;\n \t} u;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 0a4b2a9978..6e36ac5229 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -406,6 +406,25 @@ test_expect_success 'globally disabled hook can be re-enabled locally' '\n \ttest_cmp expected actual\n '\n \n+test_expect_success 'git hook list --show-scope shows config scope' '\n+\ttest_config_global hook.global-hook.command \"echo global\" &&\n+\ttest_config_global hook.global-hook.event test-hook --add &&\n+\ttest_config hook.local-hook.command \"echo local\" &&\n+\ttest_config hook.local-hook.event test-hook --add &&\n+\n+\tcat >expected <<-\\EOF &&\n+\tglobal-hook (global)\n+\tlocal-hook (local)\n+\tEOF\n+\tgit hook list --show-scope test-hook >actual &&\n+\ttest_cmp expected actual &&\n+\n+\t# without --show-scope the scope must not appear\n+\tgit hook list test-hook >actual &&\n+\ttest_grep ! \"(global)\" actual &&\n+\ttest_grep ! \"(local)\" actual\n+'\n+\n test_expect_success 'git hook run a hook with a bad shebang' '\n \ttest_when_finished \"rm -rf bad-hooks\" &&\n \tmkdir bad-hooks &&\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537466","messageId":"20260301184500.1488433-13-adrian.ratiu@collabora.com","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"[PATCH v3 12/12] hook: show disabled hooks in \"git hook list\"","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-01T18:45:00Z","receivedAt":"2026-03-01T18:46:37Z","isPatch":true,"body":"Disabled hooks were filtered out of the cache entirely, making them\ninvisible to \"git hook list\". Keep them in the cache with a new\n\"disabled\" flag which is propagated to the respective struct hook.\n\n\"git hook list\" now shows disabled hooks annotated with \"(disabled)\"\nin the config order. With --show-scope, it looks like:\n\n$ git hook list --show-scope pre-commit\nlinter (global)\nno-leaks (local, disabled)\nhook from hookdir\n\nA disabled hook without a command issues a warning instead of the\nfatal \"hook.X.command must be configured\" error. We could also throw\nan error, however it seemd a bit excessive to me in this case.\n\nSuggested-by: Patrick Steinhardt <ps@pks.im>\nSigned-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n---\n builtin/hook.c  | 18 +++++++++-------\n hook.c          | 56 ++++++++++++++++++++++++++++++++++---------------\n hook.h          |  1 +\n t/t1800-hook.sh | 33 ++++++++++++++++++++++++++---\n 4 files changed, 81 insertions(+), 27 deletions(-)\n\ndiff --git a/builtin/hook.c b/builtin/hook.c\nindex eb8dfcd31b..ca00a57094 100644\n--- a/builtin/hook.c\n+++ b/builtin/hook.c\n@@ -73,16 +73,20 @@ static int list(int argc, const char **argv, const char *prefix,\n \t\tcase HOOK_TRADITIONAL:\n \t\t\tprintf(\"%s%c\", _(\"hook from hookdir\"), line_terminator);\n \t\t\tbreak;\n-\t\tcase HOOK_CONFIGURED:\n-\t\t\tif (show_scope)\n-\t\t\t\tprintf(\"%s (%s)%c\",\n-\t\t\t\t       h->u.configured.friendly_name,\n-\t\t\t\t       config_scope_name(h->u.configured.scope),\n+\t\tcase HOOK_CONFIGURED: {\n+\t\t\tconst char *name = h->u.configured.friendly_name;\n+\t\t\tconst char *scope = show_scope ?\n+\t\t\t\tconfig_scope_name(h->u.configured.scope) : NULL;\n+\t\t\tif (scope)\n+\t\t\t\tprintf(\"%s (%s%s)%c\", name, scope,\n+\t\t\t\t       h->u.configured.disabled ? \", disabled\" : \"\",\n \t\t\t\t       line_terminator);\n+\t\t\telse if (h->u.configured.disabled)\n+\t\t\t\tprintf(\"%s (disabled)%c\", name, line_terminator);\n \t\t\telse\n-\t\t\t\tprintf(\"%s%c\", h->u.configured.friendly_name,\n-\t\t\t\t       line_terminator);\n+\t\t\t\tprintf(\"%s%c\", name, line_terminator);\n \t\t\tbreak;\n+\t\t}\n \t\tdefault:\n \t\t\tBUG(\"unknown hook kind\");\n \t\t}\ndiff --git a/hook.c b/hook.c\nindex a868d8cf17..5924324129 100644\n--- a/hook.c\n+++ b/hook.c\n@@ -111,6 +111,7 @@ static void list_hooks_add_default(struct repository *r, const char *hookname,\n struct hook_config_cache_entry {\n \tchar *command;\n \tenum config_scope scope;\n+\tint disabled;\n };\n \n /*\n@@ -202,8 +203,10 @@ static int hook_config_lookup_all(const char *key, const char *value,\n  * every item's string is the hook's friendly-name and its util pointer is\n  * a hook_config_cache_entry. All strings are owned by the map.\n  *\n- * Disabled hooks and hooks missing a command are already filtered out at\n- * parse time, so callers can iterate the list directly.\n+ * Disabled hooks are kept in the cache with entry->disabled set, so that\n+ * \"git hook list\" can display them. Hooks missing a command are filtered\n+ * out at build time; if a disabled hook has no command it is silently\n+ * skipped rather than triggering a fatal error.\n  */\n void hook_cache_clear(struct hook_config_cache *cache)\n {\n@@ -253,21 +256,26 @@ static void build_hook_config_map(struct repository *r,\n \t\t\tstruct hook_config_cache_entry *entry;\n \t\t\tchar *command;\n \n-\t\t\t/* filter out disabled hooks */\n-\t\t\tif (unsorted_string_list_lookup(&cb_data.disabled_hooks,\n-\t\t\t\t\t\t\thname))\n-\t\t\t\tcontinue;\n+\t\t\tint is_disabled =\n+\t\t\t\t!!unsorted_string_list_lookup(\n+\t\t\t\t\t&cb_data.disabled_hooks, hname);\n \n \t\t\tcommand = strmap_get(&cb_data.commands, hname);\n-\t\t\tif (!command)\n-\t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n-\t\t\t\t      \"'hook.%s.event' must be removed;\"\n-\t\t\t\t      \" aborting.\"), hname, hname);\n+\t\t\tif (!command) {\n+\t\t\t\tif (is_disabled)\n+\t\t\t\t\twarning(_(\"disabled hook '%s' has no \"\n+\t\t\t\t\t\t  \"command configured\"), hname);\n+\t\t\t\telse\n+\t\t\t\t\tdie(_(\"'hook.%s.command' must be configured or \"\n+\t\t\t\t\t      \"'hook.%s.event' must be removed;\"\n+\t\t\t\t\t      \" aborting.\"), hname, hname);\n+\t\t\t}\n \n \t\t\t/* util stores a cache entry; owned by the cache. */\n \t\t\tCALLOC_ARRAY(entry, 1);\n-\t\t\tentry->command = xstrdup(command);\n+\t\t\tentry->command = command ? xstrdup(command) : NULL;\n \t\t\tentry->scope = scope;\n+\t\t\tentry->disabled = is_disabled;\n \t\t\tstring_list_append(hooks, hname)->util = entry;\n \t\t}\n \n@@ -343,8 +351,10 @@ static void list_hooks_add_configured(struct repository *r,\n \n \t\thook->kind = HOOK_CONFIGURED;\n \t\thook->u.configured.friendly_name = xstrdup(friendly_name);\n-\t\thook->u.configured.command = xstrdup(entry->command);\n+\t\thook->u.configured.command =\n+\t\t\tentry->command ? xstrdup(entry->command) : NULL;\n \t\thook->u.configured.scope = entry->scope;\n+\t\thook->u.configured.disabled = entry->disabled;\n \n \t\tstring_list_append(list, friendly_name)->util = hook;\n \t}\n@@ -382,7 +392,16 @@ struct string_list *list_hooks(struct repository *r, const char *hookname,\n int hook_exists(struct repository *r, const char *name)\n {\n \tstruct string_list *hooks = list_hooks(r, name, NULL);\n-\tint exists = hooks->nr > 0;\n+\tint exists = 0;\n+\n+\tfor (size_t i = 0; i < hooks->nr; i++) {\n+\t\tstruct hook *h = hooks->items[i].util;\n+\t\tif (h->kind == HOOK_TRADITIONAL ||\n+\t\t    !h->u.configured.disabled) {\n+\t\t\texists = 1;\n+\t\t\tbreak;\n+\t\t}\n+\t}\n \tstring_list_clear_func(hooks, hook_free);\n \tfree(hooks);\n \treturn exists;\n@@ -397,10 +416,11 @@ static int pick_next_hook(struct child_process *cp,\n \tstruct string_list *hook_list = hook_cb->hook_command_list;\n \tstruct hook *h;\n \n-\tif (hook_cb->hook_to_run_index >= hook_list->nr)\n-\t\treturn 0;\n-\n-\th = hook_list->items[hook_cb->hook_to_run_index++].util;\n+\tdo {\n+\t\tif (hook_cb->hook_to_run_index >= hook_list->nr)\n+\t\t\treturn 0;\n+\t\th = hook_list->items[hook_cb->hook_to_run_index++].util;\n+\t} while (h->kind == HOOK_CONFIGURED && h->u.configured.disabled);\n \n \tcp->no_stdin = 1;\n \tstrvec_pushv(&cp->env, hook_cb->options->env.v);\n@@ -432,6 +452,8 @@ static int pick_next_hook(struct child_process *cp,\n \tcase HOOK_CONFIGURED:\n \t\t/* to enable oneliners, let config-specified hooks run in shell. */\n \t\tcp->use_shell = true;\n+\t\tif (!h->u.configured.command)\n+\t\t\tBUG(\"non-disabled HOOK_CONFIGURED hook has no command\");\n \t\tstrvec_push(&cp->args, h->u.configured.command);\n \t\tbreak;\n \tdefault:\ndiff --git a/hook.h b/hook.h\nindex b862cde01b..276433f721 100644\n--- a/hook.h\n+++ b/hook.h\n@@ -31,6 +31,7 @@ struct hook {\n \t\t\tconst char *friendly_name;\n \t\t\tconst char *command;\n \t\t\tenum config_scope scope;\n+\t\t\tint disabled;\n \t\t} configured;\n \t} u;\n \ndiff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\nindex 6e36ac5229..3f8c5ff450 100755\n--- a/t/t1800-hook.sh\n+++ b/t/t1800-hook.sh\n@@ -383,7 +383,15 @@ test_expect_success 'disabled hook is not run' '\n \ttest_must_be_empty actual\n '\n \n-test_expect_success 'disabled hook does not appear in git hook list' '\n+test_expect_success 'disabled hook with no command warns' '\n+\ttest_config hook.nocommand.event \"pre-commit\" &&\n+\ttest_config hook.nocommand.enabled false &&\n+\n+\tgit hook list pre-commit 2>actual &&\n+\ttest_grep \"disabled hook.*nocommand.*no command configured\" actual\n+'\n+\n+test_expect_success 'disabled hook appears as disabled in git hook list' '\n \ttest_config hook.active.event \"pre-commit\" &&\n \ttest_config hook.active.command \"echo active\" &&\n \ttest_config hook.inactive.event \"pre-commit\" &&\n@@ -391,8 +399,27 @@ test_expect_success 'disabled hook does not appear in git hook list' '\n \ttest_config hook.inactive.enabled false &&\n \n \tgit hook list pre-commit >actual &&\n-\ttest_grep \"active\" actual &&\n-\ttest_grep ! \"inactive\" actual\n+\ttest_grep \"^active$\" actual &&\n+\ttest_grep \"^inactive (disabled)$\" actual\n+'\n+\n+test_expect_success 'disabled hook shows scope with --show-scope' '\n+\ttest_config hook.myhook.event \"pre-commit\" &&\n+\ttest_config hook.myhook.command \"echo hi\" &&\n+\ttest_config hook.myhook.enabled false &&\n+\n+\tgit hook list --show-scope pre-commit >actual &&\n+\ttest_grep \"myhook (local, disabled)\" actual\n+'\n+\n+test_expect_success 'disabled configured hook is not reported as existing by hook_exists' '\n+\ttest_when_finished \"rm -f git-bugreport-hook-exists-test.txt\" &&\n+\ttest_config hook.linter.event \"pre-commit\" &&\n+\ttest_config hook.linter.command \"echo lint\" &&\n+\ttest_config hook.linter.enabled false &&\n+\n+\tgit bugreport -s hook-exists-test &&\n+\ttest_grep ! \"pre-commit\" git-bugreport-hook-exists-test.txt\n '\n \n test_expect_success 'globally disabled hook can be re-enabled locally' '\n-- \n2.52.0.732.gb351b5166d.dirty\n\n"},{"id":"537551","messageId":"xmqqy0ka2n53.fsf@gitster.g","threadId":"64918","inReplyTo":"20260301184500.1488433-1-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 00/12][next] Specify hooks via configs","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-02T16:48:24Z","receivedAt":"2026-03-02T16:48:28Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> Hello everyone,\n>\n> v3 addresses all feedback received in v2 (details below, including range-diff).\n>\n> This series adds a new feature: the ability to specify commands to run\n> for hook events via config entries (including shell commands).\n>\n> So instead of dropping a shell script or a custom program in .git/hooks\n> you can now tell git via config files to run a program or shell script\n> (can be specified directly in the config) when you run hook \"foo\".\n>\n> This also means you can setup global hooks to run in multiple repos via\n> global configs and there's an option to disable them if necessary.\n>\n> For simplicity, because this series is becoming rather big, hooks are\n> still executed sequentially (.jobs == 1). Parallel execution is added\n> in another follow-up patch series.\n>\n> This is based on the latest next branch because it depends on some\n> commits which haven't yet landed in master.\n\nPlease don't depend a series on 'next'.  That will make your topic\ntaken hostage by _every_ topic there.\n\nBesides, the ar/config-hooks topic has been in 'next' for the last\nfew days already, and it is time to go incremental updates.\n\nThanks.\n"},{"id":"537556","messageId":"87zf4q5fiz.fsf@collabora.com","threadId":"64918","inReplyTo":"xmqqy0ka2n53.fsf@gitster.g","subject":"Re: [PATCH v3 00/12][next] Specify hooks via configs","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-02T17:04:36Z","receivedAt":"2026-03-02T17:05:05Z","isPatch":true,"body":"On Mon, 02 Mar 2026, Junio C Hamano <gitster@pobox.com> wrote:\n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n>\n>> Hello everyone,\n>>\n>> v3 addresses all feedback received in v2 (details below, including range-diff).\n>>\n>> This series adds a new feature: the ability to specify commands to run\n>> for hook events via config entries (including shell commands).\n>>\n>> So instead of dropping a shell script or a custom program in .git/hooks\n>> you can now tell git via config files to run a program or shell script\n>> (can be specified directly in the config) when you run hook \"foo\".\n>>\n>> This also means you can setup global hooks to run in multiple repos via\n>> global configs and there's an option to disable them if necessary.\n>>\n>> For simplicity, because this series is becoming rather big, hooks are\n>> still executed sequentially (.jobs == 1). Parallel execution is added\n>> in another follow-up patch series.\n>>\n>> This is based on the latest next branch because it depends on some\n>> commits which haven't yet landed in master.\n>\n> Please don't depend a series on 'next'.  That will make your topic\n> taken hostage by _every_ topic there.\n>\n> Besides, the ar/config-hooks topic has been in 'next' for the last\n> few days already, and it is time to go incremental updates.\n\nUnderstood. I don't think there is anything breaking in the config topic\nv2 as it landed, so I'll create incremental patches on top of it to\naddress the feedback I've addressed in v3 here.\n\nThanks!\n"},{"id":"537583","messageId":"xmqqbjh612zu.fsf@gitster.g","threadId":"64918","inReplyTo":"87zf4q5fiz.fsf@collabora.com","subject":"Re: [PATCH v3 00/12][next] Specify hooks via configs","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-02T18:48:53Z","receivedAt":"2026-03-02T18:48:55Z","isPatch":true,"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n>> Please don't depend a series on 'next'.  That will make your topic\n>> taken hostage by _every_ topic there.\n>>\n>> Besides, the ar/config-hooks topic has been in 'next' for the last\n>> few days already, and it is time to go incremental updates.\n>\n> Understood. I don't think there is anything breaking in the config topic\n> v2 as it landed, so I'll create incremental patches on top of it to\n> address the feedback I've addressed in v3 here.\n\nThanks.\n"},{"id":"540982","messageId":"adPh1GHnPH034u3V@szeder.dev","threadId":"64918","inReplyTo":"20260301184500.1488433-6-adrian.ratiu@collabora.com","subject":"Re: [PATCH v3 05/12] hook: include hooks from the config","fromName":"SZEDER Gábor","fromEmail":"szeder.dev@gmail.com","sentAt":"2026-04-06T16:39:48Z","receivedAt":"2026-04-06T16:40:01Z","isPatch":true,"body":"On Sun, Mar 01, 2026 at 08:44:53PM +0200, Adrian Ratiu wrote:\n> Teach the hook.[hc] library to parse configs to populate the list of\n> hooks to run for a given event.\n> \n> Multiple commands can be specified for a given hook by providing\n> \"hook.<friendly-name>.command = <path-to-hook>\" and\n> \"hook.<friendly-name>.event = <hook-event>\" lines.\n> \n> Hooks will be started in config order of the \"hook.<friendly-name>.event\"\n> lines and will be run sequentially (.jobs == 1) like before.\n> Running the hooks in parallel will be enabled in a future patch.\n> \n> The \"traditional\" hook from the hookdir is run last, if present.\n> \n> A strmap cache is added to struct repository to avoid re-reading\n> the configs on each rook run. This is useful for hooks like the\n> ref-transaction which gets executed multiple times per process.\n> \n> Examples:\n> \n>   $ git config --get-regexp \"^hook\\.\"\n>   hook.bar.command=~/bar.sh\n>   hook.bar.event=pre-commit\n> \n>   # Will run ~/bar.sh, then .git/hooks/pre-commit\n>   $ git hook run pre-commit\n> \n> Signed-off-by: Emily Shaffer <emilyshaffer@google.com>\n> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n> ---\n\n> diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\n> index d1380a4f0e..3a95cfe16d 100755\n> --- a/t/t1800-hook.sh\n> +++ b/t/t1800-hook.sh\n> @@ -1,10 +1,26 @@\n>  #!/bin/sh\n>  \n> -test_description='git-hook command'\n> +test_description='git-hook command and config-managed multihooks'\n>  \n>  . ./test-lib.sh\n>  . \"$TEST_DIRECTORY\"/lib-terminal.sh\n>  \n> +setup_hooks () {\n> +\ttest_config hook.ghi.command \"/path/ghi\"\n> +\ttest_config hook.ghi.event pre-commit --add\n> +\ttest_config hook.ghi.event test-hook --add\n> +\ttest_config_global hook.def.command \"/path/def\"\n> +\ttest_config_global hook.def.event pre-commit --add\n> +}\n> +\n> +setup_hookdir () {\n> +\tmkdir .git/hooks\n> +\twrite_script .git/hooks/pre-commit <<-EOF\n> +\techo \\\"Legacy Hook\\\"\n> +\tEOF\n> +\ttest_when_finished rm -rf .git/hooks\n> +}\n\nThere is no &&-chain in these test helper functions.\n\n"},{"id":"541130","messageId":"874ill3d3d.fsf@gentoo.mail-host-address-is-not-set","threadId":"64918","inReplyTo":"adPh1GHnPH034u3V@szeder.dev","subject":"Re: [PATCH v3 05/12] hook: include hooks from the config","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-04-08T11:28:54Z","receivedAt":"2026-04-08T11:29:18Z","isPatch":true,"body":"On Mon, 06 Apr 2026, SZEDER Gábor <szeder.dev@gmail.com> wrote:\n> On Sun, Mar 01, 2026 at 08:44:53PM +0200, Adrian Ratiu wrote:\n>> Teach the hook.[hc] library to parse configs to populate the list of\n>> hooks to run for a given event.\n>> \n>> Multiple commands can be specified for a given hook by providing\n>> \"hook.<friendly-name>.command = <path-to-hook>\" and\n>> \"hook.<friendly-name>.event = <hook-event>\" lines.\n>> \n>> Hooks will be started in config order of the \"hook.<friendly-name>.event\"\n>> lines and will be run sequentially (.jobs == 1) like before.\n>> Running the hooks in parallel will be enabled in a future patch.\n>> \n>> The \"traditional\" hook from the hookdir is run last, if present.\n>> \n>> A strmap cache is added to struct repository to avoid re-reading\n>> the configs on each rook run. This is useful for hooks like the\n>> ref-transaction which gets executed multiple times per process.\n>> \n>> Examples:\n>> \n>>   $ git config --get-regexp \"^hook\\.\"\n>>   hook.bar.command=~/bar.sh\n>>   hook.bar.event=pre-commit\n>> \n>>   # Will run ~/bar.sh, then .git/hooks/pre-commit\n>>   $ git hook run pre-commit\n>> \n>> Signed-off-by: Emily Shaffer <emilyshaffer@google.com>\n>> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com>\n>> ---\n>\n>> diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh\n>> index d1380a4f0e..3a95cfe16d 100755\n>> --- a/t/t1800-hook.sh\n>> +++ b/t/t1800-hook.sh\n>> @@ -1,10 +1,26 @@\n>>  #!/bin/sh\n>>  \n>> -test_description='git-hook command'\n>> +test_description='git-hook command and config-managed multihooks'\n>>  \n>>  . ./test-lib.sh\n>>  . \"$TEST_DIRECTORY\"/lib-terminal.sh\n>>  \n>> +setup_hooks () {\n>> +\ttest_config hook.ghi.command \"/path/ghi\"\n>> +\ttest_config hook.ghi.event pre-commit --add\n>> +\ttest_config hook.ghi.event test-hook --add\n>> +\ttest_config_global hook.def.command \"/path/def\"\n>> +\ttest_config_global hook.def.event pre-commit --add\n>> +}\n>> +\n>> +setup_hookdir () {\n>> +\tmkdir .git/hooks\n>> +\twrite_script .git/hooks/pre-commit <<-EOF\n>> +\techo \\\"Legacy Hook\\\"\n>> +\tEOF\n>> +\ttest_when_finished rm -rf .git/hooks\n>> +}\n>\n> There is no &&-chain in these test helper functions.\n\nNice catch, we need the && chains to ensure we propagate any failures.\n\nI'll send a separate patch fixing this since the series landed.\n\nMany thanks, appreciate it.\n"}]}