Thread (6 messages) flat view 6 messages, 3 authors, 5d ago
COOLING5d

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

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

From: Alessio Attilio <redacted>

Introduce the 'hooks.allowNoVerify' configuration variable to control
whether the '--no-verify' (or '-n') command-line option is permitted
during operations executing client-side hooks (commit, push, merge,
rebase, am).

Client-side hooks execute in the user's local repository and cannot serve
as an authoritative security boundary; authoritative policy enforcement
belongs on the server (such as via pre-receive hooks). However,
developers often invoke '--no-verify' out of habit or muscle memory,
accidentally skipping local checks.

To address both 'security theatre' concerns and avoid breaking legitimate
emergency escape hatches ('big red button'), this implementation:

1. Introduces granular values:
   - 'true' (or 'always', default): '--no-verify' is permitted normally.
   - 'warn': '--no-verify' is permitted, but prints a warning to stderr.
   - 'false' (or 'never', 'error'): '--no-verify' is disallowed by default.

2. Preserves the emergency break-glass escape hatch:
   When configured to 'false', Git does not create a dead-end. It outputs
   actionable advice explaining that the setting is a workflow guardrail
   against accidental bypass, and documents how to override it in an
   emergency via 'GIT_ALLOW_NO_VERIFY=1' or '-c hooks.allowNoVerify=true'.
   This eliminates the need for developers to resort to destructive local
   hacks like deleting hooks or chmod -x.

3. Centralizes the validation logic in 'hook.c' and 'hook.h' via
   validate_no_verify().

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-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2215/kairosci/hooks-allownoverify-v2
Pull-Request: https://github.com/gitgitgadget/git/pull/2215

Range-diff vs v1:

 1:  853636dad0 ! 1:  4e594568de hooks: introduce 'hooks.allowNoVerify' configuration
     @@ Metadata
       ## Commit message ##
          hooks: introduce 'hooks.allowNoVerify' configuration
      
     -    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.
     +    Introduce the 'hooks.allowNoVerify' configuration variable to control
     +    whether the '--no-verify' (or '-n') command-line option is permitted
     +    during operations executing client-side hooks (commit, push, merge,
     +    rebase, am).
     +
     +    Client-side hooks execute in the user's local repository and cannot serve
     +    as an authoritative security boundary; authoritative policy enforcement
     +    belongs on the server (such as via pre-receive hooks). However,
     +    developers often invoke '--no-verify' out of habit or muscle memory,
     +    accidentally skipping local checks.
     +
     +    To address both 'security theatre' concerns and avoid breaking legitimate
     +    emergency escape hatches ('big red button'), this implementation:
     +
     +    1. Introduces granular values:
     +       - 'true' (or 'always', default): '--no-verify' is permitted normally.
     +       - 'warn': '--no-verify' is permitted, but prints a warning to stderr.
     +       - 'false' (or 'never', 'error'): '--no-verify' is disallowed by default.
     +
     +    2. Preserves the emergency break-glass escape hatch:
     +       When configured to 'false', Git does not create a dead-end. It outputs
     +       actionable advice explaining that the setting is a workflow guardrail
     +       against accidental bypass, and documents how to override it in an
     +       emergency via 'GIT_ALLOW_NO_VERIFY=1' or '-c hooks.allowNoVerify=true'.
     +       This eliminates the need for developers to resort to destructive local
     +       hacks like deleting hooks or chmod -x.
     +
     +    3. Centralizes the validation logic in 'hook.c' and 'hook.h' via
     +       validate_no_verify().
      
          Signed-off-by: Alessio Attilio [off-list ref]
      
     @@ Documentation/config.adoc: include::config/help.adoc[]
       ## Documentation/config/hooks.adoc (new) ##
      @@
      +`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`.
     ++	Specifies whether the `--no-verify` (or `-n`) command-line option
     ++	is permitted in commands that run client-side hooks, such as `git commit`,
     ++	`git push`, `git merge`, `git rebase`, and `git am`.
      ++
     -+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.
     ++Allowed values are:
     +++
     ++--
     ++* `true` (or `always`): `--no-verify` is permitted normally. This is the default.
     ++* `warn`: `--no-verify` is permitted, but Git prints a warning on stderr.
     ++* `false` (or `never`, `error`): `--no-verify` is disallowed and Git aborts
     ++  with a fatal error accompanied by advice explaining how to override it.
     ++--
     +++
     ++In an emergency (for example, when a local hook crashes or during a critical
     ++production hotfix), this guardrail can be overridden without modifying
     ++configuration files by setting the `GIT_ALLOW_NO_VERIFY=1` environment variable
     ++or by passing `-c hooks.allowNoVerify=true` on the command line.
     +++
     ++NOTE: Client-side hooks execute in the developer's environment and belong to
     ++the user. This configuration serves strictly as an ergonomic workflow guardrail
     ++against accidental bypasses (such as muscle-memory `-n` or automated scripts),
     ++and must not be relied upon as a security boundary. Authoritative enforcement
     ++must always be implemented server-side (for example, via `pre-receive` hooks).
      
     - ## builtin/am.c ##
     -@@ builtin/am.c: 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[] = {
     -@@ builtin/am.c: 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);
     + ## Documentation/git.adoc ##
     +@@ Documentation/git.adoc: on some performance improvements or features). This variable currently
     + only affects clones and fetches; it is not yet used for pushes (but may
     + be in the future).
       
     ++`GIT_ALLOW_NO_VERIFY`::
     ++	If this Boolean environment variable is set to true (e.g. `1`), permits the use
     ++	of `--no-verify` (or `-n`) even when `hooks.allowNoVerify` is set to `false`.
     ++	This serves as an emergency override mechanism for workflows when hooks fail unexpectedly.
     ++
     + `GIT_OPTIONAL_LOCKS`::
     + 	If this Boolean environment variable is set to false, Git will complete any requested operation without
     + 	performing any optional sub-operations that require taking a lock.
     +
     + ## builtin/am.c ##
      @@ builtin/am.c: 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 (state.no_verify)
     ++		validate_no_verify(the_repository, "--no-verify");
      +
       	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."));
      
       ## builtin/commit.c ##
     -@@ builtin/commit.c: 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;
     +@@
     + #include "environment.h"
     + #include "diff.h"
     + #include "commit.h"
     ++#include "hook.h"
     + #include "add-interactive.h"
     + #include "gettext.h"
     + #include "revision.h"
      @@ builtin/commit.c: 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 (no_verify)
     ++		validate_no_verify(the_repository, "--no-verify");
      +
       	if (force_author && !strchr(force_author, '>'))
       		force_author = find_author_by_nickname(force_author);
       
     -@@ builtin/commit.c: 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);
     - }
      
       ## builtin/merge.c ##
     -@@ builtin/merge.c: 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[] = {
     -@@ builtin/merge.c: 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")) {
      @@ builtin/merge.c: 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 (no_verify)
     ++		validate_no_verify(the_repository, "--no-verify");
       	if (shortlog_len < 0)
       		shortlog_len = (merge_log_config > 0) ? merge_log_config : 0;
       
      
       ## builtin/push.c ##
     -@@ builtin/push.c: 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;
     - 
     -@@ builtin/push.c: 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);
     +@@
     + #include "environment.h"
     + #include "gettext.h"
     + #include "hex.h"
     ++#include "hook.h"
     + #include "refspec.h"
     + #include "run-command.h"
     + #include "remote.h"
      @@ builtin/push.c: 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'"));
     ++	if (flags & TRANSPORT_PUSH_NO_HOOK)
     ++		validate_no_verify(the_repository, "--no-verify");
       	push_options = (push_options_cmdline.nr
       		? &push_options_cmdline
       		: &push_options_config);
      
       ## builtin/rebase.c ##
     -@@ builtin/rebase.c: 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)
     - {
     -@@ builtin/rebase.c: 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) {
      @@ builtin/rebase.c: 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 (ok_to_skip_pre_rebase)
     ++		validate_no_verify(the_repository, "--no-verify");
      +
       	if (options.trailer_args.nr) {
       		if (validate_trailer_args(&options.trailer_args))
       			die(NULL);
      
     + ## hook.c ##
     +@@ hook.c: int run_hooks_l(struct repository *r, const char *hook_name, ...)
     + 
     + 	return run_hooks_opt(r, hook_name, &opt);
     + }
     ++
     ++void validate_no_verify(struct repository *r, const char *opt)
     ++{
     ++	const char *val = NULL;
     ++	int maybe_bool;
     ++
     ++	if (git_env_bool("GIT_ALLOW_NO_VERIFY", 0))
     ++		return;
     ++
     ++	if (!r || repo_config_get_value(r, "hooks.allownoverify", &val))
     ++		return;
     ++
     ++	maybe_bool = git_parse_maybe_bool(val);
     ++	if (maybe_bool == 1 || !strcasecmp(val, "always")) {
     ++		return;
     ++	} else if (!strcasecmp(val, "warn")) {
     ++		warning(_("bypassing hooks with '%s' is discouraged by 'hooks.allowNoVerify'"), opt);
     ++		return;
     ++	} else if (maybe_bool == 0 || !strcasecmp(val, "never") || !strcasecmp(val, "error")) {
     ++		advise(_("this repository disallows '%s' as a workflow guardrail against accidental bypass.\n"
     ++			 "In an emergency (e.g. broken hook or urgent hotfix), you can override it with:\n"
     ++			 "  git -c hooks.allowNoVerify=true <command>\n"
     ++			 "or:\n"
     ++			 "  GIT_ALLOW_NO_VERIFY=1 git <command>"), opt);
     ++		die(_("the use of '%s' is disabled by 'hooks.allowNoVerify'"), opt);
     ++	} else {
     ++		warning(_("unknown value for 'hooks.allowNoVerify': '%s'"), val);
     ++	}
     ++}
     ++
     +
     + ## hook.h ##
     +@@ hook.h: int run_hooks(struct repository *r, const char *hook_name);
     +  */
     + LAST_ARG_MUST_BE_NULL
     + int run_hooks_l(struct repository *r, const char *hook_name, ...);
     ++
     ++/**
     ++ * Check if the use of '--no-verify' (or '-n') is permitted according to
     ++ * the 'hooks.allowNoVerify' configuration and 'GIT_ALLOW_NO_VERIFY' environment
     ++ * variable.
     ++ *
     ++ * If permitted, this function returns normally (or emits a warning if configured
     ++ * to 'warn'). If disallowed, it outputs advice on how to override the workflow
     ++ * guardrail in an emergency, then aborts with die().
     ++ */
     ++void validate_no_verify(struct repository *r, const char *opt);
     ++
     + #endif
     ++
     +
       ## t/meson.build ##
      @@ t/meson.build: integration_tests = [
         't7526-commit-pathspec-file.sh',
     @@ t/t7599-hooks-allownoverify.sh (new)
      +	test_grep "hooks.allowNoVerify" err
      +'
      +
     ++test_expect_success 'CLI -c hooks.allowNoVerify=true overrides local false' '
     ++	test_config hooks.allowNoVerify false &&
     ++	echo "change10" >>init.t &&
     ++	git add init.t &&
     ++	git -c hooks.allowNoVerify=true commit --no-verify -m "override false with CLI true"
     ++'
     ++
     ++test_expect_success 'hooks.allowNoVerify=false provides emergency override advice' '
     ++	test_config hooks.allowNoVerify false &&
     ++	echo "change11" >>init.t &&
     ++	git add init.t &&
     ++	test_must_fail git commit --no-verify -m "fail advice" 2>err &&
     ++	test_grep "GIT_ALLOW_NO_VERIFY=1" err &&
     ++	test_grep "git -c hooks.allowNoVerify=true" err
     ++'
     ++
     ++test_expect_success 'GIT_ALLOW_NO_VERIFY=1 permits git commit --no-verify even when configured to false' '
     ++	test_when_finished "rm -f pre-commit.log" &&
     ++	test_config hooks.allowNoVerify false &&
     ++	echo "change12" >>init.t &&
     ++	git add init.t &&
     ++	GIT_ALLOW_NO_VERIFY=1 git commit --no-verify -m "emergency commit" &&
     ++	test_path_is_missing pre-commit.log
     ++'
     ++
     ++test_expect_success 'GIT_ALLOW_NO_VERIFY=1 permits git push --no-verify even when configured to false' '
     ++	test_when_finished "rm -f pre-push.log" &&
     ++	test_config hooks.allowNoVerify false &&
     ++	GIT_ALLOW_NO_VERIFY=1 git push --no-verify origin main &&
     ++	test_path_is_missing pre-push.log
     ++'
     ++
     ++test_expect_success 'hooks.allowNoVerify=warn permits --no-verify and warns on stderr' '
     ++	test_when_finished "rm -f pre-commit.log err" &&
     ++	test_config hooks.allowNoVerify warn &&
     ++	echo "change13" >>init.t &&
     ++	git add init.t &&
     ++	git commit --no-verify -m "commit with warn" 2>err &&
     ++	test_path_is_missing pre-commit.log &&
     ++	test_grep "bypassing hooks with .--no-verify. is discouraged" err
     ++'
     ++
     ++test_expect_success 'hooks.allowNoVerify=never disallows --no-verify' '
     ++	test_config hooks.allowNoVerify never &&
     ++	echo "change14" >>init.t &&
     ++	git add init.t &&
     ++	test_must_fail git commit --no-verify -m "fail never" 2>err &&
     ++	test_grep "hooks.allowNoVerify" err
     ++'
     ++
     ++test_expect_success 'hooks.allowNoVerify=error disallows --no-verify' '
     ++	test_config hooks.allowNoVerify error &&
     ++	echo "change15" >>init.t &&
     ++	git add init.t &&
     ++	test_must_fail git commit --no-verify -m "fail error" 2>err &&
     ++	test_grep "hooks.allowNoVerify" err
     ++'
     ++
     ++test_expect_success 'hooks.allowNoVerify=always allows --no-verify' '
     ++	test_when_finished "rm -f pre-commit.log" &&
     ++	test_config hooks.allowNoVerify always &&
     ++	echo "change16" >>init.t &&
     ++	git add init.t &&
     ++	git commit --no-verify -m "commit always" &&
     ++	test_path_is_missing pre-commit.log
     ++'
     ++
      +test_done
     ++


 Documentation/config.adoc       |   2 +
 Documentation/config/hooks.adoc |  24 ++++
 Documentation/git.adoc          |   5 +
 builtin/am.c                    |   3 +
 builtin/commit.c                |   4 +
 builtin/merge.c                 |   2 +
 builtin/push.c                  |   3 +
 builtin/rebase.c                |   3 +
 hook.c                          |  30 +++++
 hook.h                          |  13 ++
 t/meson.build                   |   1 +
 t/t7599-hooks-allownoverify.sh  | 217 ++++++++++++++++++++++++++++++++
 12 files changed, 307 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..07830c5147
--- /dev/null
+++ b/Documentation/config/hooks.adoc
@@ -0,0 +1,24 @@
+`hooks.allowNoVerify`::
+	Specifies whether the `--no-verify` (or `-n`) command-line option
+	is permitted in commands that run client-side hooks, such as `git commit`,
+	`git push`, `git merge`, `git rebase`, and `git am`.
++
+Allowed values are:
++
+--
+* `true` (or `always`): `--no-verify` is permitted normally. This is the default.
+* `warn`: `--no-verify` is permitted, but Git prints a warning on stderr.
+* `false` (or `never`, `error`): `--no-verify` is disallowed and Git aborts
+  with a fatal error accompanied by advice explaining how to override it.
+--
++
+In an emergency (for example, when a local hook crashes or during a critical
+production hotfix), this guardrail can be overridden without modifying
+configuration files by setting the `GIT_ALLOW_NO_VERIFY=1` environment variable
+or by passing `-c hooks.allowNoVerify=true` on the command line.
++
+NOTE: Client-side hooks execute in the developer's environment and belong to
+the user. This configuration serves strictly as an ergonomic workflow guardrail
+against accidental bypasses (such as muscle-memory `-n` or automated scripts),
+and must not be relied upon as a security boundary. Authoritative enforcement
+must always be implemented server-side (for example, via `pre-receive` hooks).
diff --git a/Documentation/git.adoc b/Documentation/git.adoc
index 8a5cdd3b3d..1b3af061a3 100644
--- a/Documentation/git.adoc
+++ b/Documentation/git.adoc
@@ -1018,6 +1018,11 @@ on some performance improvements or features). This variable currently
 only affects clones and fetches; it is not yet used for pushes (but may
 be in the future).
 
+`GIT_ALLOW_NO_VERIFY`::
+	If this Boolean environment variable is set to true (e.g. `1`), permits the use
+	of `--no-verify` (or `-n`) even when `hooks.allowNoVerify` is set to `false`.
+	This serves as an emergency override mechanism for workflows when hooks fail unexpectedly.
+
 `GIT_OPTIONAL_LOCKS`::
 	If this Boolean environment variable is set to false, Git will complete any requested operation without
 	performing any optional sub-operations that require taking a lock.
diff --git a/builtin/am.c b/builtin/am.c
index e9623b8307..c79b9a82f0 100644
--- a/builtin/am.c
+++ b/builtin/am.c
@@ -2457,6 +2457,9 @@ int cmd_am(int argc,
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
 
+	if (state.no_verify)
+		validate_no_verify(the_repository, "--no-verify");
+
 	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..ef28c2cb9e 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -19,6 +19,7 @@
 #include "environment.h"
 #include "diff.h"
 #include "commit.h"
+#include "hook.h"
 #include "add-interactive.h"
 #include "gettext.h"
 #include "revision.h"
@@ -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)
+		validate_no_verify(the_repository, "--no-verify");
+
 	if (force_author && !strchr(force_author, '>'))
 		force_author = find_author_by_nickname(force_author);
 
diff --git a/builtin/merge.c b/builtin/merge.c
index 5b4eb23a83..0e6c2d4345 100644
--- a/builtin/merge.c
+++ b/builtin/merge.c
@@ -1408,6 +1408,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)
+		validate_no_verify(the_repository, "--no-verify");
 	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..98830da7f7 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -12,6 +12,7 @@
 #include "environment.h"
 #include "gettext.h"
 #include "hex.h"
+#include "hook.h"
 #include "refspec.h"
 #include "run-command.h"
 #include "remote.h"
@@ -746,6 +747,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)
+		validate_no_verify(the_repository, "--no-verify");
 	push_options = (push_options_cmdline.nr
 		? &push_options_cmdline
 		: &push_options_config);
diff --git a/builtin/rebase.c b/builtin/rebase.c
index 10a306310c..dff28f0119 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -1299,6 +1299,9 @@ int cmd_rebase(int argc,
 			     builtin_rebase_options,
 			     builtin_rebase_usage, 0);
 
+	if (ok_to_skip_pre_rebase)
+		validate_no_verify(the_repository, "--no-verify");
+
 	if (options.trailer_args.nr) {
 		if (validate_trailer_args(&options.trailer_args))
 			die(NULL);
diff --git a/hook.c b/hook.c
index d10eef4763..daed1b1c4b 100644
--- a/hook.c
+++ b/hook.c
@@ -858,3 +858,33 @@ int run_hooks_l(struct repository *r, const char *hook_name, ...)
 
 	return run_hooks_opt(r, hook_name, &opt);
 }
+
+void validate_no_verify(struct repository *r, const char *opt)
+{
+	const char *val = NULL;
+	int maybe_bool;
+
+	if (git_env_bool("GIT_ALLOW_NO_VERIFY", 0))
+		return;
+
+	if (!r || repo_config_get_value(r, "hooks.allownoverify", &val))
+		return;
+
+	maybe_bool = git_parse_maybe_bool(val);
+	if (maybe_bool == 1 || !strcasecmp(val, "always")) {
+		return;
+	} else if (!strcasecmp(val, "warn")) {
+		warning(_("bypassing hooks with '%s' is discouraged by 'hooks.allowNoVerify'"), opt);
+		return;
+	} else if (maybe_bool == 0 || !strcasecmp(val, "never") || !strcasecmp(val, "error")) {
+		advise(_("this repository disallows '%s' as a workflow guardrail against accidental bypass.\n"
+			 "In an emergency (e.g. broken hook or urgent hotfix), you can override it with:\n"
+			 "  git -c hooks.allowNoVerify=true <command>\n"
+			 "or:\n"
+			 "  GIT_ALLOW_NO_VERIFY=1 git <command>"), opt);
+		die(_("the use of '%s' is disabled by 'hooks.allowNoVerify'"), opt);
+	} else {
+		warning(_("unknown value for 'hooks.allowNoVerify': '%s'"), val);
+	}
+}
+
diff --git a/hook.h b/hook.h
index 27bb1aeb2e..0590ceee2d 100644
--- a/hook.h
+++ b/hook.h
@@ -280,4 +280,17 @@ int run_hooks(struct repository *r, const char *hook_name);
  */
 LAST_ARG_MUST_BE_NULL
 int run_hooks_l(struct repository *r, const char *hook_name, ...);
+
+/**
+ * Check if the use of '--no-verify' (or '-n') is permitted according to
+ * the 'hooks.allowNoVerify' configuration and 'GIT_ALLOW_NO_VERIFY' environment
+ * variable.
+ *
+ * If permitted, this function returns normally (or emits a warning if configured
+ * to 'warn'). If disallowed, it outputs advice on how to override the workflow
+ * guardrail in an emergency, then aborts with die().
+ */
+void validate_no_verify(struct repository *r, const char *opt);
+
 #endif
+
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..5a7dcf1d6e
--- /dev/null
+++ b/t/t7599-hooks-allownoverify.sh
@@ -0,0 +1,217 @@
+#!/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_expect_success 'CLI -c hooks.allowNoVerify=true overrides local false' '
+	test_config hooks.allowNoVerify false &&
+	echo "change10" >>init.t &&
+	git add init.t &&
+	git -c hooks.allowNoVerify=true commit --no-verify -m "override false with CLI true"
+'
+
+test_expect_success 'hooks.allowNoVerify=false provides emergency override advice' '
+	test_config hooks.allowNoVerify false &&
+	echo "change11" >>init.t &&
+	git add init.t &&
+	test_must_fail git commit --no-verify -m "fail advice" 2>err &&
+	test_grep "GIT_ALLOW_NO_VERIFY=1" err &&
+	test_grep "git -c hooks.allowNoVerify=true" err
+'
+
+test_expect_success 'GIT_ALLOW_NO_VERIFY=1 permits git commit --no-verify even when configured to false' '
+	test_when_finished "rm -f pre-commit.log" &&
+	test_config hooks.allowNoVerify false &&
+	echo "change12" >>init.t &&
+	git add init.t &&
+	GIT_ALLOW_NO_VERIFY=1 git commit --no-verify -m "emergency commit" &&
+	test_path_is_missing pre-commit.log
+'
+
+test_expect_success 'GIT_ALLOW_NO_VERIFY=1 permits git push --no-verify even when configured to false' '
+	test_when_finished "rm -f pre-push.log" &&
+	test_config hooks.allowNoVerify false &&
+	GIT_ALLOW_NO_VERIFY=1 git push --no-verify origin main &&
+	test_path_is_missing pre-push.log
+'
+
+test_expect_success 'hooks.allowNoVerify=warn permits --no-verify and warns on stderr' '
+	test_when_finished "rm -f pre-commit.log err" &&
+	test_config hooks.allowNoVerify warn &&
+	echo "change13" >>init.t &&
+	git add init.t &&
+	git commit --no-verify -m "commit with warn" 2>err &&
+	test_path_is_missing pre-commit.log &&
+	test_grep "bypassing hooks with .--no-verify. is discouraged" err
+'
+
+test_expect_success 'hooks.allowNoVerify=never disallows --no-verify' '
+	test_config hooks.allowNoVerify never &&
+	echo "change14" >>init.t &&
+	git add init.t &&
+	test_must_fail git commit --no-verify -m "fail never" 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=error disallows --no-verify' '
+	test_config hooks.allowNoVerify error &&
+	echo "change15" >>init.t &&
+	git add init.t &&
+	test_must_fail git commit --no-verify -m "fail error" 2>err &&
+	test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=always allows --no-verify' '
+	test_when_finished "rm -f pre-commit.log" &&
+	test_config hooks.allowNoVerify always &&
+	echo "change16" >>init.t &&
+	git add init.t &&
+	git commit --no-verify -m "commit always" &&
+	test_path_is_missing pre-commit.log
+'
+
+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