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