* [PATCH] hooks: introduce 'hooks.allowNoVerify' configuration
@ 2026-09-02 16:17 Alessio Attilio via GitGitGadget
2026-09-02 16:35 ` [PATCH v2] " Alessio Attilio via GitGitGadget
` (3 more replies)
0 siblings, 4 replies; 6+ messages in thread
From: Alessio Attilio via GitGitGadget @ 2026-09-02 16:17 UTC (permalink / raw)
To: git; +Cc: Alessio Attilio, Alessio Attilio
From: Alessio Attilio <alessio.attilio@protonmail.com>
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 <alessio.attilio@protonmail.com>
---
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
^ permalink raw reply related [flat|nested] 6+ messages in thread
* [PATCH v2] hooks: introduce 'hooks.allowNoVerify' configuration
2026-09-02 16:17 [PATCH] hooks: introduce 'hooks.allowNoVerify' configuration Alessio Attilio via GitGitGadget
@ 2026-09-02 16:35 ` Alessio Attilio via GitGitGadget
2026-09-02 17:23 ` [PATCH v3] " Alessio Attilio via GitGitGadget
` (2 subsequent siblings)
3 siblings, 0 replies; 6+ messages in thread
From: Alessio Attilio via GitGitGadget @ 2026-09-02 16:35 UTC (permalink / raw)
To: git; +Cc: Alessio Attilio, Alessio Attilio
From: Alessio Attilio <alessio.attilio@protonmail.com>
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 <alessio.attilio@protonmail.com>
---
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 <alessio.attilio@protonmail.com>
@@ 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
^ permalink raw reply related [flat|nested] 6+ messages in thread
* [PATCH v3] hooks: introduce 'hooks.allowNoVerify' configuration
2026-09-02 16:17 [PATCH] hooks: introduce 'hooks.allowNoVerify' configuration Alessio Attilio via GitGitGadget
2026-09-02 16:35 ` [PATCH v2] " Alessio Attilio via GitGitGadget
@ 2026-09-02 17:23 ` Alessio Attilio via GitGitGadget
2026-09-02 22:14 ` brian m. carlson
2026-09-02 17:45 ` [PATCH v4] " Alessio Attilio via GitGitGadget
2026-09-02 19:21 ` [PATCH] " Junio C Hamano
3 siblings, 1 reply; 6+ messages in thread
From: Alessio Attilio via GitGitGadget @ 2026-09-02 17:23 UTC (permalink / raw)
To: git; +Cc: Alessio Attilio, Alessio Attilio
From: Alessio Attilio <alessio.attilio@protonmail.com>
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,
inadvertently skipping local checks.
To address concerns regarding false senses of security without breaking
legitimate emergency escape hatches, allow configuring the variable to
'true' (the default), 'warn', or 'false'. In 'warn' mode, Git permits
the bypass while emitting a warning to standard error, ensuring
visibility without interrupting urgent workflows.
When set to 'false', Git aborts execution and provides actionable advice
explaining that the setting is an ergonomic workflow guardrail. To avoid
trapping developers during broken hook scripts or critical hotfixes,
the guardrail can be overridden by passing '-c hooks.allowNoVerify=true'
or by setting the 'GIT_ALLOW_NO_VERIFY=1' environment variable. This
prevents developers from having to resort to destructive workarounds
such as removing hook files or clearing execute permissions.
Centralize the option verification logic across all affected commands into
validate_no_verify() in hook.c.
Signed-off-by: Alessio Attilio <alessio.attilio@protonmail.com>
---
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-v3
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2215/kairosci/hooks-allownoverify-v3
Pull-Request: https://github.com/gitgitgadget/git/pull/2215
Range-diff vs v2:
1: 4e594568de ! 1: cc98af8a9a hooks: introduce 'hooks.allowNoVerify' configuration
@@ Commit message
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.
+ inadvertently skipping local checks.
- To address both 'security theatre' concerns and avoid breaking legitimate
- emergency escape hatches ('big red button'), this implementation:
+ To address concerns regarding false senses of security without breaking
+ legitimate emergency escape hatches, allow configuring the variable to
+ 'true' (the default), 'warn', or 'false'. In 'warn' mode, Git permits
+ the bypass while emitting a warning to standard error, ensuring
+ visibility without interrupting urgent workflows.
- 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.
+ When set to 'false', Git aborts execution and provides actionable advice
+ explaining that the setting is an ergonomic workflow guardrail. To avoid
+ trapping developers during broken hook scripts or critical hotfixes,
+ the guardrail can be overridden by passing '-c hooks.allowNoVerify=true'
+ or by setting the 'GIT_ALLOW_NO_VERIFY=1' environment variable. This
+ prevents developers from having to resort to destructive workarounds
+ such as removing hook files or clearing execute permissions.
- 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().
+ Centralize the option verification logic across all affected commands into
+ validate_no_verify() in hook.c.
Signed-off-by: Alessio Attilio <alessio.attilio@protonmail.com>
@@ Documentation/config/hooks.adoc (new)
+Allowed values are:
++
+--
-+* `true` (or `always`): `--no-verify` is permitted normally. This is the default.
++* `true`: `--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
++* `false`: `--no-verify` is disallowed and Git aborts
+ with a fatal error accompanied by advice explaining how to override it.
+--
++
@@ hook.c: int run_hooks_l(struct repository *r, const char *hook_name, ...)
+ return;
+
+ maybe_bool = git_parse_maybe_bool(val);
-+ if (maybe_bool == 1 || !strcasecmp(val, "always")) {
++ if (maybe_bool == 1) {
+ 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")) {
++ } else if (maybe_bool == 0) {
+ 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"
@@ hook.c: int run_hooks_l(struct repository *r, const char *hook_name, ...)
+ warning(_("unknown value for 'hooks.allowNoVerify': '%s'"), val);
+ }
+}
-+
## hook.h ##
@@ hook.h: int run_hooks(struct repository *r, const char *hook_name);
@@ hook.h: int run_hooks(struct repository *r, const char *hook_name);
+void validate_no_verify(struct repository *r, const char *opt);
+
#endif
-+
## t/meson.build ##
@@ t/meson.build: integration_tests = [
@@ t/t7599-hooks-allownoverify.sh (new)
+ test_grep "bypassing hooks with .--no-verify. is discouraged" err
+'
+
-+test_expect_success 'hooks.allowNoVerify=never disallows --no-verify' '
-+ test_config hooks.allowNoVerify never &&
++test_expect_success 'hooks.allowNoVerify=0 disallows --no-verify' '
++ test_config hooks.allowNoVerify 0 &&
+ 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_must_fail git commit --no-verify -m "fail 0" 2>err &&
+ test_grep "hooks.allowNoVerify" err
+'
+
-+test_expect_success 'hooks.allowNoVerify=always allows --no-verify' '
++test_expect_success 'hooks.allowNoVerify=1 allows --no-verify' '
+ test_when_finished "rm -f pre-commit.log" &&
-+ test_config hooks.allowNoVerify always &&
-+ echo "change16" >>init.t &&
++ test_config hooks.allowNoVerify 1 &&
++ echo "change15" >>init.t &&
+ git add init.t &&
-+ git commit --no-verify -m "commit always" &&
++ git commit --no-verify -m "commit 1" &&
+ 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 | 29 +++++
hook.h | 12 ++
t/meson.build | 1 +
t/t7599-hooks-allownoverify.sh | 208 ++++++++++++++++++++++++++++++++
12 files changed, 296 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..d94ac4b9b5
--- /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`: `--no-verify` is permitted normally. This is the default.
+* `warn`: `--no-verify` is permitted, but Git prints a warning on stderr.
+* `false`: `--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..f972c66bd2 100644
--- a/hook.c
+++ b/hook.c
@@ -858,3 +858,32 @@ 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) {
+ return;
+ } else if (!strcasecmp(val, "warn")) {
+ warning(_("bypassing hooks with '%s' is discouraged by 'hooks.allowNoVerify'"), opt);
+ return;
+ } else if (maybe_bool == 0) {
+ 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..b9e0b6703c 100644
--- a/hook.h
+++ b/hook.h
@@ -280,4 +280,16 @@ 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..f02c0b1709
--- /dev/null
+++ b/t/t7599-hooks-allownoverify.sh
@@ -0,0 +1,208 @@
+#!/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=0 disallows --no-verify' '
+ test_config hooks.allowNoVerify 0 &&
+ echo "change14" >>init.t &&
+ git add init.t &&
+ test_must_fail git commit --no-verify -m "fail 0" 2>err &&
+ test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=1 allows --no-verify' '
+ test_when_finished "rm -f pre-commit.log" &&
+ test_config hooks.allowNoVerify 1 &&
+ echo "change15" >>init.t &&
+ git add init.t &&
+ git commit --no-verify -m "commit 1" &&
+ test_path_is_missing pre-commit.log
+'
+
+test_done
base-commit: 1630431f326e15fcde608827b5ff38422528eb59
--
gitgitgadget
^ permalink raw reply related [flat|nested] 6+ messages in thread
* [PATCH v4] hooks: introduce 'hooks.allowNoVerify' configuration
2026-09-02 16:17 [PATCH] hooks: introduce 'hooks.allowNoVerify' configuration Alessio Attilio via GitGitGadget
2026-09-02 16:35 ` [PATCH v2] " Alessio Attilio via GitGitGadget
2026-09-02 17:23 ` [PATCH v3] " Alessio Attilio via GitGitGadget
@ 2026-09-02 17:45 ` Alessio Attilio via GitGitGadget
2026-09-02 19:21 ` [PATCH] " Junio C Hamano
3 siblings, 0 replies; 6+ messages in thread
From: Alessio Attilio via GitGitGadget @ 2026-09-02 17:45 UTC (permalink / raw)
To: git; +Cc: Alessio Attilio, Alessio Attilio
From: Alessio Attilio <alessio.attilio@protonmail.com>
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,
inadvertently skipping local checks.
To address concerns regarding false senses of security without breaking
legitimate emergency escape hatches, allow configuring the variable to
'true' (the default), 'warn', or 'false'. In 'warn' mode, Git permits
the bypass while emitting a warning to standard error, ensuring
visibility without interrupting urgent workflows.
When set to 'false', Git aborts execution and provides actionable advice
explaining that the setting is an ergonomic workflow guardrail. To avoid
trapping developers during broken hook scripts or critical hotfixes,
the guardrail can be overridden by passing '-c hooks.allowNoVerify=true'
or by setting the 'GIT_ALLOW_NO_VERIFY=1' environment variable. This
prevents developers from having to resort to destructive workarounds
such as removing hook files or clearing execute permissions.
Centralize the option verification logic across all affected commands into
validate_no_verify() in hook.c.
Signed-off-by: Alessio Attilio <alessio.attilio@protonmail.com>
---
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-v4
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2215/kairosci/hooks-allownoverify-v4
Pull-Request: https://github.com/gitgitgadget/git/pull/2215
Range-diff vs v3:
1: cc98af8a9a ! 1: a9f75413d3 hooks: introduce 'hooks.allowNoVerify' configuration
@@ t/t7599-hooks-allownoverify.sh (new)
+ test_grep "hooks.allowNoVerify" err
+'
+
++test_expect_success 'hooks.allowNoVerify=false disallows git am --no-verify' '
++ test_when_finished "rm -f patch && git am --abort || true" &&
++ test_config hooks.allowNoVerify false &&
++ git format-patch -1 --stdout branch-merge >patch &&
++ test_must_fail git am --no-verify patch 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 &&
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 | 29 +++++
hook.h | 12 ++
t/meson.build | 1 +
t/t7599-hooks-allownoverify.sh | 216 ++++++++++++++++++++++++++++++++
12 files changed, 304 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..d94ac4b9b5
--- /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`: `--no-verify` is permitted normally. This is the default.
+* `warn`: `--no-verify` is permitted, but Git prints a warning on stderr.
+* `false`: `--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..f972c66bd2 100644
--- a/hook.c
+++ b/hook.c
@@ -858,3 +858,32 @@ 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) {
+ return;
+ } else if (!strcasecmp(val, "warn")) {
+ warning(_("bypassing hooks with '%s' is discouraged by 'hooks.allowNoVerify'"), opt);
+ return;
+ } else if (maybe_bool == 0) {
+ 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..b9e0b6703c 100644
--- a/hook.h
+++ b/hook.h
@@ -280,4 +280,16 @@ 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..75e6e65ef0
--- /dev/null
+++ b/t/t7599-hooks-allownoverify.sh
@@ -0,0 +1,216 @@
+#!/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 disallows git am --no-verify' '
+ test_when_finished "rm -f patch && git am --abort || true" &&
+ test_config hooks.allowNoVerify false &&
+ git format-patch -1 --stdout branch-merge >patch &&
+ test_must_fail git am --no-verify patch 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=0 disallows --no-verify' '
+ test_config hooks.allowNoVerify 0 &&
+ echo "change14" >>init.t &&
+ git add init.t &&
+ test_must_fail git commit --no-verify -m "fail 0" 2>err &&
+ test_grep "hooks.allowNoVerify" err
+'
+
+test_expect_success 'hooks.allowNoVerify=1 allows --no-verify' '
+ test_when_finished "rm -f pre-commit.log" &&
+ test_config hooks.allowNoVerify 1 &&
+ echo "change15" >>init.t &&
+ git add init.t &&
+ git commit --no-verify -m "commit 1" &&
+ test_path_is_missing pre-commit.log
+'
+
+test_done
base-commit: 1630431f326e15fcde608827b5ff38422528eb59
--
gitgitgadget
^ permalink raw reply related [flat|nested] 6+ messages in thread
* Re: [PATCH] hooks: introduce 'hooks.allowNoVerify' configuration
2026-09-02 16:17 [PATCH] hooks: introduce 'hooks.allowNoVerify' configuration Alessio Attilio via GitGitGadget
` (2 preceding siblings ...)
2026-09-02 17:45 ` [PATCH v4] " Alessio Attilio via GitGitGadget
@ 2026-09-02 19:21 ` Junio C Hamano
3 siblings, 0 replies; 6+ messages in thread
From: Junio C Hamano @ 2026-09-02 19:21 UTC (permalink / raw)
To: Alessio Attilio via GitGitGadget; +Cc: git, Alessio Attilio, Alessio Attilio
"Alessio Attilio via GitGitGadget" <gitgitgadget@gmail.com> writes:
> From: Alessio Attilio <alessio.attilio@protonmail.com>
>
> 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.
Accidental
$ git foo --no-verify
may be prevented by setting this configuration variable, but then
would we need another layer of protection to prevent accidental
$ git -c hooks.allownoverify foo --no-verify
by introducing another configuration variable to forbid
hooks.allownoverify to be overriden?
I do not think we want to go into this slipperly slope. Thanks for
sending a patch, but I am personally not interested.
Besides, verifications that users may be tempted to bypass, but want
to instill discipline to prevent bypassing, may not be implemented
as hooks, and the way they are bypassed may not be "--[no-]verify"
command line option. When one wants a way to prevent such
verifications from getting disabled, the mechanism should also allow
forbidding verification that is built into the system from getting
disabled. Limiting a settings to hooks is probably not a good idea,
and introducing a new "hooks." hierarchy for this setting is not
something we want to see.
^ permalink raw reply [flat|nested] 6+ messages in thread
* Re: [PATCH v3] hooks: introduce 'hooks.allowNoVerify' configuration
2026-09-02 17:23 ` [PATCH v3] " Alessio Attilio via GitGitGadget
@ 2026-09-02 22:14 ` brian m. carlson
0 siblings, 0 replies; 6+ messages in thread
From: brian m. carlson @ 2026-09-02 22:14 UTC (permalink / raw)
To: Alessio Attilio via GitGitGadget; +Cc: git, Alessio Attilio, Alessio Attilio
[-- Attachment #1: Type: text/plain, Size: 1926 bytes --]
On 2026-09-02 at 17:23:14, Alessio Attilio via GitGitGadget wrote:
> From: Alessio Attilio <alessio.attilio@protonmail.com>
>
> 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,
> inadvertently skipping local checks.
I agree with Junio that this doesn't seem like a good idea. It's up to
the user whether they want to install or use hooks and they are free to
disable them or override them as they see fit. If the user doesn't want
to use local hooks on an individual case basis, then `--no-verify` is
the right option.
In my case, I use hooks for Git LFS when I use that software, but I
never use repository owner-provided hooks, although I may use my own. As
a result, I almost never use `--no-verify`.
If in your environment you are trying to force developers to use local
hooks, the Git FAQ mentions that this is not an effective control and
you should stop trying to do that. In fact, I would even argue that you
should simply not install hooks by default for repositories using your
scripts or build tools because it's up to users whether those are useful
for them. The Git FAQ mentions reasons why forced installation of hooks
is harmful for many workflows and I don't think we should be hassling
users like that.
In any event, it's trivial to simply bypass all hooks by setting
`core.hooksPath` to `/dev/null` using a variety of different techniques.
--
brian m. carlson (they/them)
Toronto, Ontario, CA
[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 325 bytes --]
^ permalink raw reply [flat|nested] 6+ messages in thread
end of thread, other threads:[~2026-09-02 22:22 UTC | newest]
Thread overview: 6+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-02 16:17 [PATCH] hooks: introduce 'hooks.allowNoVerify' configuration Alessio Attilio via GitGitGadget
2026-09-02 16:35 ` [PATCH v2] " Alessio Attilio via GitGitGadget
2026-09-02 17:23 ` [PATCH v3] " Alessio Attilio via GitGitGadget
2026-09-02 22:14 ` brian m. carlson
2026-09-02 17:45 ` [PATCH v4] " Alessio Attilio via GitGitGadget
2026-09-02 19:21 ` [PATCH] " Junio C Hamano
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for NNTP newsgroup(s).