From 9344d718f89792a9aa8c58a4b8ea2ea9f18cf126 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Wed, 2 Sep 2026 12:19:20 +0200 Subject: [PATCH] hooks: introduce 'hook.allowNoVerify' configuration Introduce the 'hook.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 hook.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. In automated or non-interactive environments such as CI/CD runners, 'warn' mode avoids pipeline failures while preserving audit visibility, and 'GIT_ALLOW_NO_VERIFY=1' provides a clean override without modifying configuration files. Follow the canonical 'hook.*' configuration namespace established by modern Git hook commands, while accepting 'hooks.allowNoVerify' as a backward-compatible fallback. Centralize the option verification logic across all affected commands into validate_no_verify() in hook.c. Signed-off-by: Alessio Attilio --- Documentation/config/hook.adoc | 26 ++++ 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 | 12 ++ t/meson.build | 1 + t/t7599-hooks-allownoverify.sh | 224 +++++++++++++++++++++++++++++++++ 11 files changed, 313 insertions(+) create mode 100755 t/t7599-hooks-allownoverify.sh diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc index 083dc60a1325f2..732a742a05d406 100644 --- a/Documentation/config/hook.adoc +++ b/Documentation/config/hook.adoc @@ -4,6 +4,32 @@ endif::git-hook[] ifndef::git-hook[] :see-git-hook: See linkgit:git-hook[1]. endif::git-hook[] +hook.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 hook.allowNoVerify=true` on the command line. +In automated or scripted environments (such as CI/CD runners), `hook.allowNoVerify = warn` +provides visibility on stderr while allowing unattended workflows to complete without failure. ++ +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). hook..command:: The command to execute for `hook.`. `` diff --git a/Documentation/git.adoc b/Documentation/git.adoc index 8a5cdd3b3d22c5..1b3af061a39f25 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 e9623b8307793f..c79b9a82f0e7f5 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 28f61745034506..ef28c2cb9e24d1 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 5b4eb23a833295..0e6c2d43457a89 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 2377b5af554bda..98830da7f73c6f 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 10a306310cd439..dff28f0119394a 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 d10eef4763c679..d48188aa90d42a 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, "hook.allownoverify", &val) && + repo_config_get_value(r, "hooks.allownoverify", &val))) + return; + + maybe_bool = git_parse_maybe_bool(val); + if (maybe_bool == 1) { + return; + } else if (val && !strcasecmp(val, "warn")) { + warning(_("bypassing hooks with '%s' is discouraged by 'hook.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 hook.allowNoVerify=true \n" + "or:\n" + " GIT_ALLOW_NO_VERIFY=1 git "), opt); + die(_("the use of '%s' is disabled by 'hook.allowNoVerify'"), opt); + } else { + warning(_("unknown value for 'hook.allowNoVerify': '%s'"), val); + } +} diff --git a/hook.h b/hook.h index 27bb1aeb2ef465..b9e0b6703c8fd8 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 7f53cca7d1f891..ce6ca1f6bfc5d6 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 00000000000000..1ba419b2e4a3e0 --- /dev/null +++ b/t/t7599-hooks-allownoverify.sh @@ -0,0 +1,224 @@ +#!/bin/sh + +test_description='support hook.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 hook.allowNoVerify=true allows --no-verify' ' + test_when_finished "rm -f pre-commit.log" && + test_config hook.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 'hook.allowNoVerify=false disallows git commit --no-verify' ' + test_config hook.allowNoVerify false && + echo "change4" >>init.t && + git add init.t && + test_must_fail git commit --no-verify -m "should fail" 2>err && + test_grep "hook.allowNoVerify" err +' + +test_expect_success 'hook.allowNoVerify=false disallows git commit -n' ' + test_config hook.allowNoVerify false && + echo "change5" >>init.t && + git add init.t && + test_must_fail git commit -n -m "should fail" 2>err && + test_grep "hook.allowNoVerify" err +' + +test_expect_success 'hook.allowNoVerify=false disallows git push --no-verify' ' + test_config hook.allowNoVerify false && + test_must_fail git push --no-verify origin main 2>err && + test_grep "hook.allowNoVerify" err +' + +test_expect_success 'hook.allowNoVerify=false disallows git merge --no-verify' ' + test_config hook.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 "hook.allowNoVerify" err +' + +test_expect_success 'hook.allowNoVerify=false disallows git rebase --no-verify' ' + test_config hook.allowNoVerify false && + test_must_fail git rebase --no-verify main branch-merge 2>err && + test_grep "hook.allowNoVerify" err +' + +test_expect_success 'hook.allowNoVerify=false disallows git am --no-verify' ' + test_when_finished "rm -f patch && git am --abort || true" && + test_config hook.allowNoVerify false && + git format-patch -1 --stdout branch-merge >patch && + test_must_fail git am --no-verify patch 2>err && + test_grep "hook.allowNoVerify" err +' + +test_expect_success 'hook.allowNoVerify=false still runs hooks when --no-verify is not used' ' + test_when_finished "rm -f pre-commit.log" && + test_config hook.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 'hook.allowNoVerify=false enforces hook execution (hook failure prevents commit)' ' + test_when_finished "rm -f fail-pre-commit pre-commit.log" && + test_config hook.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 "hook.allowNoVerify" err +' + +test_expect_success 'hook.allowNoVerify=false still runs pre-push hook on git push' ' + test_when_finished "rm -f pre-push.log" && + test_config hook.allowNoVerify false && + git push origin main && + test_path_is_file pre-push.log +' + +test_expect_success 'CLI -c hook.allowNoVerify=false overrides local true' ' + test_config hook.allowNoVerify true && + echo "change8" >>init.t && + git add init.t && + test_must_fail git -c hook.allowNoVerify=false commit --no-verify -m "override" 2>err && + test_grep "hook.allowNoVerify" err +' + +test_expect_success 'local hook.allowNoVerify=false overrides global true' ' + test_config_global hook.allowNoVerify true && + test_config hook.allowNoVerify false && + echo "change9" >>init.t && + git add init.t && + test_must_fail git commit --no-verify -m "local override" 2>err && + test_grep "hook.allowNoVerify" err +' + +test_expect_success 'CLI -c hook.allowNoVerify=true overrides local false' ' + test_config hook.allowNoVerify false && + echo "change10" >>init.t && + git add init.t && + git -c hook.allowNoVerify=true commit --no-verify -m "override false with CLI true" +' + +test_expect_success 'hook.allowNoVerify=false provides emergency override advice' ' + test_config hook.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 hook.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 hook.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 hook.allowNoVerify false && + GIT_ALLOW_NO_VERIFY=1 git push --no-verify origin main && + test_path_is_missing pre-push.log +' + +test_expect_success 'hook.allowNoVerify=warn permits --no-verify and warns on stderr' ' + test_when_finished "rm -f pre-commit.log err" && + test_config hook.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 'hook.allowNoVerify=0 disallows --no-verify' ' + test_config hook.allowNoVerify 0 && + echo "change14" >>init.t && + git add init.t && + test_must_fail git commit --no-verify -m "fail 0" 2>err && + test_grep "hook.allowNoVerify" err +' + +test_expect_success 'hook.allowNoVerify=1 allows --no-verify' ' + test_when_finished "rm -f pre-commit.log" && + test_config hook.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_expect_success 'legacy hooks.allowNoVerify (plural) is accepted as fallback' ' + test_config hooks.allowNoVerify false && + echo "change16" >>init.t && + git add init.t && + test_must_fail git commit --no-verify -m "fail fallback" 2>err && + test_grep "hook.allowNoVerify" err +' + +test_done