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