Thread (6 messages) flat view 6 messages, 3 authors, 2d ago
WARM2d

[PATCH] hooks: introduce 'hooks.allowNoVerify' configuration

From: Alessio Attilio via GitGitGadget <hidden>
Date: 2026-09-02 16:17:45
Subsystem: documentation, the rest · Maintainers: Jonathan Corbet, Linus Torvalds

From: Alessio Attilio <redacted>

Introduce 'hooks.allowNoVerify' as an opt-in workflow guardrail to
prevent accidental bypass of hooks with '--no-verify' when set to false.
Authoritative enforcement remains server-side.

Signed-off-by: Alessio Attilio <redacted>
---
    hooks: introduce 'hooks.allowNoVerify' configuration
    
    Introduce 'hooks.allowNoVerify' as an opt-in workflow guardrail
    (default: true) to prevent accidental bypass of hooks via '--no-verify'.
    This setting is intended for workflows and managed environments to avoid
    inadvertent bypasses, without altering Git's server-side security model.

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2215%2Fkairosci%2Fhooks-allownoverify-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2215/kairosci/hooks-allownoverify-v1
Pull-Request: https://github.com/gitgitgadget/git/pull/2215

 Documentation/config.adoc       |   2 +
 Documentation/config/hooks.adoc |  10 +++
 builtin/am.c                    |   5 ++
 builtin/commit.c                |   8 ++
 builtin/merge.c                 |   6 ++
 builtin/push.c                  |   6 ++
 builtin/rebase.c                |  10 +++
 t/meson.build                   |   1 +
 t/t7599-hooks-allownoverify.sh  | 149 ++++++++++++++++++++++++++++++++
 9 files changed, 197 insertions(+)
 create mode 100644 Documentation/config/hooks.adoc
 create mode 100755 t/t7599-hooks-allownoverify.sh
diff --git a/Documentation/config.adoc b/Documentation/config.adoc
index f67dcd2f8e..2ba351e6ee 100644
--- a/Documentation/config.adoc
+++ b/Documentation/config.adoc
@@ -508,6 +508,8 @@ include::config/help.adoc[]
 
 include::config/hook.adoc[]
 
+include::config/hooks.adoc[]
+
 include::config/http.adoc[]
 
 include::config/i18n.adoc[]
diff --git a/Documentation/config/hooks.adoc b/Documentation/config/hooks.adoc
new file mode 100644
index 0000000000..ce46645a1e
--- /dev/null
+++ b/Documentation/config/hooks.adoc
@@ -0,0 +1,10 @@
+`hooks.allowNoVerify`::
+	A boolean to specify whether `--no-verify` (or `-n`) command-line
+	option is permitted in commands such as `git commit` and `git push`.
+	When set to `false`, attempting to bypass hooks with `--no-verify`
+	will cause Git to abort immediately with a fatal error. Defaults to
+	`true`.
++
+Note that this setting serves as an opt-in workflow guardrail against
+accidental bypasses (for example in managed environments or CI runners),
+and does not replace authoritative server-side hook enforcement.
diff --git a/builtin/am.c b/builtin/am.c
index e9623b8307..8b82d4c1b6 100644
--- a/builtin/am.c
+++ b/builtin/am.c
@@ -2320,6 +2320,7 @@ int cmd_am(int argc,
 	int patch_format = PATCH_FORMAT_UNKNOWN;
 	enum resume_type resume_mode = RESUME_FALSE;
 	int in_progress;
+	int allow_no_verify = 1;
 	int ret = 0;
 
 	const char * const usage[] = {
@@ -2448,6 +2449,7 @@ int cmd_am(int argc,
 	show_usage_with_options_if_asked(argc, argv, usage, options);
 
 	repo_config(the_repository, git_default_config, NULL);
+	repo_config_get_bool(the_repository, "hooks.allownoverify", &allow_no_verify);
 
 	am_state_init(&state);
 
@@ -2457,6 +2459,9 @@ int cmd_am(int argc,
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
 
+	if (state.no_verify && !allow_no_verify)
+		die(_("the use of '--no-verify' is disabled by 'hooks.allowNoVerify'"));
+
 	if (binary >= 0)
 		fprintf_ln(stderr, _("The -b/--binary option has been a no-op for long time, and\n"
 				"it will be removed. Please do not use it anymore."));
diff --git a/builtin/commit.c b/builtin/commit.c
index 28f6174503..c59f7ded6e 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -127,6 +127,7 @@ static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int edit_flag = -1; /* unspecified */
 static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
+static int allow_no_verify = 1;
 static int no_post_rewrite, allow_empty_message, pathspec_file_nul;
 static const char *untracked_files_arg, *force_date, *ignore_submodule_arg, *ignored_arg;
 static const char *sign_commit, *pathspec_from_file;
@@ -1316,6 +1317,9 @@ static int parse_and_validate_options(int argc, const char *argv[],
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
 	finalize_deferred_config(s);
 
+	if (no_verify && !allow_no_verify)
+		die(_("the use of '--no-verify' is disabled by 'hooks.allowNoVerify'"));
+
 	if (force_author && !strchr(force_author, '>'))
 		force_author = find_author_by_nickname(force_author);
 
@@ -1691,6 +1695,10 @@ static int git_commit_config(const char *k, const char *v,
 							       &is_bool);
 		return 0;
 	}
+	if (!strcmp(k, "hooks.allownoverify")) {
+		allow_no_verify = git_config_bool(k, v);
+		return 0;
+	}
 
 	return git_status_config(k, v, ctx, s);
 }
diff --git a/builtin/merge.c b/builtin/merge.c
index 5b4eb23a83..77fd6fc57e 100644
--- a/builtin/merge.c
+++ b/builtin/merge.c
@@ -96,6 +96,7 @@ static int signoff;
 static const char *sign_commit;
 static int autostash;
 static int no_verify;
+static int allow_no_verify = 1;
 static char *into_name;
 
 static struct strategy all_strategy[] = {
@@ -727,6 +728,9 @@ static int git_merge_config(const char *k, const char *v,
 	} else if (!strcmp(k, "commit.gpgsign")) {
 		sign_commit = git_config_bool(k, v) ? "" : NULL;
 		return 0;
+	} else if (!strcmp(k, "hooks.allownoverify")) {
+		allow_no_verify = git_config_bool(k, v);
+		return 0;
 	} else if (!strcmp(k, "gpg.mintrustlevel")) {
 		check_trust_level = 0;
 	} else if (!strcmp(k, "merge.autostash")) {
@@ -1408,6 +1412,8 @@ int cmd_merge(int argc,
 		parse_branch_merge_options(branch_mergeoptions);
 	argc = parse_options(argc, argv, prefix, builtin_merge_options,
 			builtin_merge_usage, 0);
+	if (no_verify && !allow_no_verify)
+		die(_("the use of '--no-verify' is disabled by 'hooks.allowNoVerify'"));
 	if (shortlog_len < 0)
 		shortlog_len = (merge_log_config > 0) ? merge_log_config : 0;
 
diff --git a/builtin/push.c b/builtin/push.c
index 2377b5af55..216603bb4f 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -63,6 +63,7 @@ static int verbosity;
 static int progress = -1;
 static int recurse_submodules = RECURSE_SUBMODULES_DEFAULT;
 static enum transport_family family;
+static int allow_no_verify = 1;
 
 static struct push_cas_option cas;
 
@@ -543,6 +544,9 @@ static int git_push_config(const char *k, const char *v,
 		else
 			*flags &= ~TRANSPORT_PUSH_FORCE_IF_INCLUDES;
 		return 0;
+	} else if (!strcmp(k, "hooks.allownoverify")) {
+		allow_no_verify = git_config_bool(k, v);
+		return 0;
 	}
 
 	return git_default_config(k, v, ctx, NULL);
@@ -746,6 +750,8 @@ int cmd_push(int argc,
 	packet_trace_identity("push");
 	repo_config(the_repository, git_push_config, &flags);
 	argc = parse_options(argc, argv, prefix, options, push_usage, 0);
+	if ((flags & TRANSPORT_PUSH_NO_HOOK) && !allow_no_verify)
+		die(_("the use of '--no-verify' is disabled by 'hooks.allowNoVerify'"));
 	push_options = (push_options_cmdline.nr
 		? &push_options_cmdline
 		: &push_options_config);
diff --git a/builtin/rebase.c b/builtin/rebase.c
index 10a306310c..eb996f0aa1 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -790,6 +790,8 @@ static void parse_rebase_merges_value(struct rebase_options *options, const char
 		die(_("Unknown rebase-merges mode: %s"), value);
 }
 
+static int allow_no_verify = 1;
+
 static int rebase_config(const char *var, const char *value,
 			 const struct config_context *ctx, void *data)
 {
@@ -820,6 +822,11 @@ static int rebase_config(const char *var, const char *value,
 		return 0;
 	}
 
+	if (!strcmp(var, "hooks.allownoverify")) {
+		allow_no_verify = git_config_bool(var, value);
+		return 0;
+	}
+
 	if (!strcmp(var, "rebase.rebasemerges")) {
 		opts->config_rebase_merges = git_parse_maybe_bool(value);
 		if (opts->config_rebase_merges < 0) {
@@ -1299,6 +1306,9 @@ int cmd_rebase(int argc,
 			     builtin_rebase_options,
 			     builtin_rebase_usage, 0);
 
+	if (ok_to_skip_pre_rebase && !allow_no_verify)
+		die(_("the use of '--no-verify' is disabled by 'hooks.allowNoVerify'"));
+
 	if (options.trailer_args.nr) {
 		if (validate_trailer_args(&options.trailer_args))
 			die(NULL);
diff --git a/t/meson.build b/t/meson.build
index 7f53cca7d1..ce6ca1f6bf 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -945,6 +945,7 @@ integration_tests = [
   't7526-commit-pathspec-file.sh',
   't7527-builtin-fsmonitor.sh',
   't7528-signed-commit-ssh.sh',
+  't7599-hooks-allownoverify.sh',
   't7600-merge.sh',
   't7601-merge-pull-config.sh',
   't7602-merge-octopus-many.sh',
diff --git a/t/t7599-hooks-allownoverify.sh b/t/t7599-hooks-allownoverify.sh
new file mode 100755
index 0000000000..eed99c128b
--- /dev/null
+++ b/t/t7599-hooks-allownoverify.sh
@@ -0,0 +1,149 @@
+#!/bin/sh
+
+test_description='support hooks.allowNoVerify configuration to disallow --no-verify'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'setup test repository and hooks' '
+	test_commit init &&
+	test_hook --setup pre-commit <<-\HOOK_EOF &&
+	echo "pre-commit executed" >>pre-commit.log
+	if test -f fail-pre-commit
+	then
+		exit 1
+	fi
+	exit 0
+	HOOK_EOF
+	test_hook --setup pre-push <<-\HOOK_EOF &&
+	echo "pre-push executed" >>pre-push.log
+	if test -f fail-pre-push
+	then
+		exit 1
+	fi
+	exit 0
+	HOOK_EOF
+	git init --bare remote.git &&
+	git remote add origin remote.git &&
+	git push -u origin main &&
+	rm -f pre-commit.log pre-push.log
+'
+
+test_expect_success 'default: --no-verify is permitted for git commit' '
+	test_when_finished "rm -f pre-commit.log" &&
+	echo "change1" >>init.t &&
+	git add init.t &&
+	git commit --no-verify -m "commit with no-verify (default)" &&
+	test_path_is_missing pre-commit.log
+'
+
+test_expect_success 'default: -n is permitted for git commit' '
+	test_when_finished "rm -f pre-commit.log" &&
+	echo "change2" >>init.t &&
+	git add init.t &&
+	git commit -n -m "commit with -n (default)" &&
+	test_path_is_missing pre-commit.log
+'
+
+test_expect_success 'default: --no-verify is permitted for git push' '
+	test_when_finished "rm -f pre-push.log" &&
+	rm -f pre-push.log &&
+	git push --no-verify origin main &&
+	test_path_is_missing pre-push.log
+'
+
+test_expect_success 'explicit hooks.allowNoVerify=true allows --no-verify' '
+	test_when_finished "rm -f pre-commit.log" &&
+	test_config hooks.allowNoVerify true &&
+	echo "change3" >>init.t &&
+	git add init.t &&
+	git commit --no-verify -m "commit with no-verify allowed" &&
+	test_path_is_missing pre-commit.log
+'
+
+test_expect_success 'hooks.allowNoVerify=false disallows git commit --no-verify' '
+	test_config hooks.allowNoVerify false &&
+	echo "change4" >>init.t &&
+	git add init.t &&
+	test_must_fail git commit --no-verify -m "should fail" 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=false disallows git commit -n' '
+	test_config hooks.allowNoVerify false &&
+	echo "change5" >>init.t &&
+	git add init.t &&
+	test_must_fail git commit -n -m "should fail" 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=false disallows git push --no-verify' '
+	test_config hooks.allowNoVerify false &&
+	test_must_fail git push --no-verify origin main 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=false disallows git merge --no-verify' '
+	test_config hooks.allowNoVerify false &&
+	git checkout -b branch-merge main &&
+	echo "merge change" >merge_file &&
+	git add merge_file &&
+	git commit -m "merge commit" &&
+	git checkout main &&
+	test_must_fail git merge --no-verify branch-merge -m "merge fail" 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=false disallows git rebase --no-verify' '
+	test_config hooks.allowNoVerify false &&
+	test_must_fail git rebase --no-verify main branch-merge 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=false still runs hooks when --no-verify is not used' '
+	test_when_finished "rm -f pre-commit.log" &&
+	test_config hooks.allowNoVerify false &&
+	echo "change6" >>init.t &&
+	git add init.t &&
+	git commit -m "normal commit" &&
+	test_path_is_file pre-commit.log
+'
+
+test_expect_success 'hooks.allowNoVerify=false enforces hook execution (hook failure prevents commit)' '
+	test_when_finished "rm -f fail-pre-commit pre-commit.log" &&
+	test_config hooks.allowNoVerify false &&
+	touch fail-pre-commit &&
+	echo "change7" >>init.t &&
+	git add init.t &&
+	test_must_fail git commit -m "failing hook" &&
+	test_must_fail git commit --no-verify -m "cannot bypass" 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=false still runs pre-push hook on git push' '
+	test_when_finished "rm -f pre-push.log" &&
+	test_config hooks.allowNoVerify false &&
+	git push origin main &&
+	test_path_is_file pre-push.log
+'
+
+test_expect_success 'CLI -c hooks.allowNoVerify=false overrides local true' '
+	test_config hooks.allowNoVerify true &&
+	echo "change8" >>init.t &&
+	git add init.t &&
+	test_must_fail git -c hooks.allowNoVerify=false commit --no-verify -m "override" 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'local hooks.allowNoVerify=false overrides global true' '
+	test_config_global hooks.allowNoVerify true &&
+	test_config hooks.allowNoVerify false &&
+	echo "change9" >>init.t &&
+	git add init.t &&
+	test_must_fail git commit --no-verify -m "local override" 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_done
base-commit: 1630431f326e15fcde608827b5ff38422528eb59
-- 
gitgitgadget
Keyboard shortcuts
hback out one level
jnext message in thread
kprevious message in thread
ldrill in
Escclose help / fold thread tree
?toggle this help