一、一个钩子救回的误提交
某次给一台服务器改防火墙规则,本地仓库里顺手把 config/app.yaml 里的数据库密码改成了生产值以便测试,提交、推送,一切顺利。十分钟后 CI 部署到预发,再十分钟后整站 502。事后复盘,问题不在"改错了一个文件",而在于一条链路上没有任何一个环节拦住了它——从提交到上线,七八个动作全部是"相信操作者"。
后来我在这个仓库上加了一个 pre-commit 钩子,二十行左右,做的事情很朴素:检查本次暂存区里有没有出现敏感文件名、有没有明显的硬编码密钥、有没有超过一兆的二进制文件。这条钩子此后拦下过三次同类事故,每次都是在本地就被挡住,一次都没有上过服务器。
这篇文章讲的不是"钩子怎么写才优雅",而是"部署闸门应该放在哪几层、每层负责拦什么"。钩子只是其中最便宜的一层,因为它在你手最热的时候给反馈。
二、两层钩子:本地与服务端的分工
Git 钩子分客户端钩子与服务端钩子。客户端钩子放在 .git/hooks/ 里,不随仓库分发,新克隆的仓库默认只有 .sample 文件,需要手动安装或修改 core.hooksPath。服务端钩子放在裸仓库的 hooks/ 里,所有人推送时都会执行,无法被本地绕过。
正确分工是这样:
- 客户端 pre-commit:只做快而确定的检查,格式化、语法、敏感文件、大文件。目标是把问题挡在提交之前,反馈在 2 秒内。
- 客户端 pre-push:跑稍重的检查,比如单元测试的子集、配置文件的 schema 校验。
- 服务端 pre-receive:最后一道,做无法绕过的硬规则,比如禁止直接推 master、禁止推送含私钥的提交、校验提交信息格式。
关键认知是:客户端钩子可以被 git commit --no-verify 跳过,所以任何"必须拦住"的规则都不能只靠客户端。而服务端钩子反馈慢、改起来要重来,所以也不适合放琐碎的格式检查。两层各管一段,才是最省摩擦的组合。
三、用 core.hooksPath 统一管理钩子
默认情况下 .git/hooks/ 不入版本控制,每个人自己建,结果就是"我这儿有钩子,你那儿没有"。标准解法是把钩子放进仓库里,然后通过 core.hooksPath 指过去:
mkdir -p .githooks
cp .git/hooks/pre-commit.sample .githooks/pre-commit
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks最后一行只在本地生效。为了让新克隆的人自动拥有它,在仓库根目录加一个 setup 脚本,并在 README 第一屏写明要执行它:
#!/bin/bash
# setup-hooks.sh
set -e
git config core.hooksPath .githooks
chmod +x .githooks/*
echo "hooks installed"比起引入 Husky 之类的工具链,这种纯 Git 的方案没有依赖、跨平台、在任何语言的项目里都能用。唯一要注意的是 Windows 上的换行符:钩子脚本必须是 LF 结尾,否则 bash 会报 /bin/bash^M: bad interpreter。在仓库里加一个 .gitattributes,写上 .githooks/* text eol=lf 就能治住。
四、pre-commit 该拦什么
我把检查分成四类,按"拦不拦得住"排序,优先做拦得住且误报率低的。
第一类,文件名与路径。任何叫 .env、id_rsa、*.pem、*.key、credentials.json 的文件都不该进仓库。这一条几乎不会误报,收益极高。
第二类,明显的密钥特征。不是做完整的密钥扫描,而是匹配几个高置信度的前缀,比如常见云厂商的访问密钥前缀、以 -----BEGIN 开头的私钥块、形如 password = "非空字符串" 的赋值。这一条会有误报,所以要给它一个白名单机制:文件里出现 # hook-allow-secret 注释的行跳过。
第三类,体积与二进制。超过 1MB 的文件、扩展名不在文本白名单里的文件,一律拦下让人确认。数据库导出文件、压缩包、剪辑素材经常被误提交,一旦进了历史,清理起来要重写提交。
第四类,语法与格式。只在本机可用的前提下检查,比如 nginx -t -c、php -l、python -m json.tool。这类检查误报最少,而且失败信息直接指出行号。
一个可以直接用的骨架:
#!/bin/bash
# .githooks/pre-commit
set -u
fail=0
# 1) 暂存区文件名检查
while IFS= read -r f; do
case "$f" in
*.pem|*.key|id_rsa*|*/.env|.env|credentials.json)
echo "拒绝提交敏感文件: $f"; fail=1 ;;
esac
sz=$(wc -c < "$f" 2>/dev/null || echo 0)
if [ "$sz" -gt 1048576 ]; then
echo "文件过大(${sz}B): $f"; fail=1
fi
done < <(git diff --cached --name-only --diff-filter=ACM)
# 2) 密钥特征扫描(只看新增行)
if git diff --cached -U0 | grep -E '^\+' \
| grep -vE 'hook-allow-secret' \
| grep -qE 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY|AKIA[0-9A-Z]{16}'; then
echo "疑似密钥被提交"; fail=1
fi
# 3) JSON 语法
for f in $(git diff --cached --name-only --diff-filter=ACM | grep -E '\.json$'); do
python3 -m json.tool "$f" >/dev/null 2>&1 || { echo "JSON 语法错误: $f"; fail=1; }
done
exit $fail注意第一段用了 process substitution 读取文件列表,而不是 for 循环套 $(git ...),因为文件名里可能有空格。这个细节在中文文件名或带空格的项目里会直接导致钩子报错退出。
五、pre-receive:不可绕过的最后一道
服务端钩子运行在裸仓库上,输入是标准输入里的三列:旧提交、新提交、引用名。它不需要工作区,只读对象库,所以速度很快。最小可用的版本拦截两件事:禁止直接推主分支、禁止推送含私钥的提交。
#!/bin/bash
# hooks/pre-receive
zero=0000000000000000000000000000000000000000
while read old new ref; do
if [ "$ref" = "refs/heads/master" ] && [ "$old" != "$zero" ]; then
echo "禁止直接推送 master,请走合并请求" >&2
exit 1
fi
if [ "$old" = "$zero" ]; then
range="$new --not --all"
else
range="$old..$new"
fi
if git log -p --format=%H $range \
| grep -qE 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY'; then
echo "提交里包含私钥,已拒绝" >&2
exit 1
fi
done
exit 0这里有个容易搞错的点:删除分支时 new 是全零,新建分支时 old 是全零。如果不处理 old 为零的情况,直接对 $old..$new 做范围会失败或漏检。上面这段用 --not --all 处理新分支,比较稳妥。
另外要注意 git log -p 在大仓库上可能很慢。生产环境更好的做法是只扫新增的 blob:git rev-list --objects $range 拿到对象列表后过滤出 blob,用 git cat-file --batch 批量读取。这种写法能让一次推送的检查从十几秒降到一秒以内。
六、钩子之外:部署前的四道闸门
钩子只能管住"进仓库的东西",管不住"上的东西对不对"。完整的闸门链应该包含四层:
第一层,提交前。钩子拦敏感文件、语法错误。作用是把错误暴露得最早、成本最低。
第二层,合并前。CI 上跑完整测试与构建,配置类项目额外跑一次 nginx -t 或 docker compose config。这一层不通过就不允许合并,规则由平台强制,比钩子更硬。
第三层,部署前。在目标机器上先生成待部署的配置,跑一次语法校验,通过才替换。这一步的价值在于校验的是"真正的目标环境",而不是开发机的环境。
第四层,部署后。健康检查加自动回滚。部署脚本把当前版本做个软链快照,健康检查连续三次失败就切回上一个软链。这层不是钩子,但它是唯一能在事故发生后把你救回来的东西。
四层里最容易缺的是第三层。我见过一个项目,CI 全绿,部署脚本直接 rsync 覆盖 nginx 配置,结果一次少写了一个分号,reload 失败,整个站点用到旧配置勉强活着,直到下次重启才彻底挂掉。如果在覆盖前加一句 nginx -t -c /tmp/new.conf,这个问题根本不会发生。
七、常见坑与缓解
钩子不执行。先确认文件有执行权限,再确认 shebang 写的是 #!/bin/bash 而不是 #!/bin/sh——很多检查用到了 process substitution 和 [[ ]],在 dash 下会直接语法错误。最后确认 core.hooksPath 没有指向一个不存在的目录,那会让所有钩子静默失效。
钩子太慢。开发者会用 --no-verify 绕过它。经验阈值是 2 秒,超过就该把检查移到 pre-push 或 CI。宁可少检查两项,也不要让钩子变成"每次都要等半分钟"的东西。
钩子误报太多。误报比漏报更致命,因为它会训练所有人养成加 --no-verify 的习惯,等于把整条链废掉。规则一定要有白名单出口,且白名单要能在代码里就近标注,不要搞成集中的配置文件。
在钩子里改文件。可以,但改完必须 git add 回去,否则改动只在工作区、没进本次提交,下次提交时又冒出来。更稳的做法是钩子只检查不修改,让人自己去改。
依赖本地工具。钩子里调用的命令在别人机器上可能不存在,比如 python3、jq、ripgrep。用 command -v 先判断,不存在时打印警告并跳过该项检查,而不是直接失败退出。
八、小结
把钩子理解成"部署闸门的第一道"比理解成"提交时的格式工具"更有用。它的真正价值不是让代码更漂亮,而是让那些"一不小心就上生产"的操作在本地就被拦住。二十行的 pre-commit 能挡住敏感文件、大文件和语法错误;十行的 pre-receive 能挡住直推主分支和私钥入库。加起来不到一分钟的配置,能省掉一次深夜回滚。
再往后,CI 与部署前校验才是决定"配置能不能上线"的关键。钩子负责早,CI 负责全,部署前校验负责准,部署后健康检查负责兜。四层都到位,才谈得上部署流程可靠。