Subject: [PATCH v3 08/12] hook: allow out-of-repo 'git hook' invocations

From: Adrian Ratiu <[email-masked]>
Date: Sun, 1 Mar 2026 20:44:56 +0200

From: Emily Shaffer <[email-masked]>

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

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

Signed-off-by: Emily Shaffer <[email-masked]>
Signed-off-by: Adrian Ratiu <[email-masked]>
---
 git.c           |  2 +-
 hook.c          | 30 ++++++++++++++++++++++++++++--
 t/t1800-hook.sh | 16 +++++++++++-----
 3 files changed, 40 insertions(+), 8 deletions(-)

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