2157 字
约 7 分钟
15
S01-A23 · Git 钩子:为什么"本地能提交、CI 挂了"

一个团队用 pre-commit 钩子做格式检查,规则在 .git/hooks/ 里——而 .git/hooks 不进版本库。于是新人克隆仓库后"钩子不存在",提交了未格式化的代码,CI 才拦下来。

Git 钩子的价值在"把检查提前到提交那一刻",而它有一个结构性陷阱:.git/hooks 是本地的、不可共享的。这个"要么安装、要么不存在"的二分很残酷:钩子没有"半生效"的状态——于是团队里"老人有、新人没有",而问题只在"新人第一次提交"时暴露。
于是"钩子"要么"被安装",要么"等于不存在"。

1. 三类钩子的分工

钩子 时机 用途
pre-commit 提交前 格式/静态检查
commit-msg 提交信息写完 规范(如工单号)
pre-push 推送前 单测/更重的检查

第一项最常用,而它"越重越容易被绕过"——因为"每次提交都等 30 秒"会让人加 --no-verify。这个"越重越容易绕过"是"激励"问题:30 秒的等待会让人学会"绕过",而"绕过"一旦成为习惯,钩子就"永久失效"——比"没有钩子"更糟,因为"以为有"。

2. "钩子不可共享"的三种应对

方式 效果
仓库内 hooks/ 目录 + 安装脚本 需"手动跑一次"
用工具管理(如 husky、pre-commit) 随依赖安装自动生效
服务端钩子(pre-receive) 强制,不可绕过

第三项的"代价"是"反馈晚":推送被拒才发现在"合并时",而此时提交已经写完了——于是"服务端钩子"适合"红线",不适合"格式"。
第三项是"唯一不可绕过"的:服务端钩子在"推送/合并"时执行,而本地钩子只是"体验"。

"钩子脚本"要"可单独运行",否则"调试钩子"会变成"反复提交":

// 一个可独立运行的 pre-commit(两种入口)
#!/usr/bin/env bash
files=$(git diff --cached --name-only --diff-filter=ACM)   # --all 时用 find
./scripts/check.sh "$files"          # 检查逻辑在独立脚本里
// 于是:调试时直接 ./scripts/check.sh a.java,不必"提交一次看结果"

3. "本地 + 服务端"的分工

层 做什么 特点
本地 pre-commit 快速格式、明显错误 可绕过
CI 全量检查、测试 不可绕过(合并门槛)
服务端钩子 关键红线(大文件、密钥) 不可绕过且"推送即拦"

第二项是"主战场":"本地快、CI 全" 是最常见的分工——于是"本地被绕过"不会造成"漏检",只造成"CI 变慢"。

4. "钩子被绕过"的三种原因

原因 表现
太慢(> 5 秒) 加 --no-verify
不可靠(偶发失败) 不信任,直接绕过
未安装 新人根本不知道有

第一项的"量化"是"开发者每提交一次的等待预算":小于 1 秒可接受,3 秒开始烦,10 秒会绕过——而这个阈值与"每天提交多少次"直接相关。
第一项是"设计"问题:pre-commit 应当只做"毫秒级"的事(格式化、空白检查),把"编译/测试"留给 pre-push。

// 一个"毫秒级"的 pre-commit(三件事,总耗时 < 1s)
1 只检查"暂存区文件"(不是全仓库)
2 只做"格式化 + 空白/冲突标记"
3 失败时"打印修复命令",而不是"自动改一堆"
// 反例:pre-commit 里跑 mvn test → 30 秒 → 所有人都加 --no-verify

"钩子该做什么"可以用一张"耗时 vs 覆盖"的表来定:

// 四类检查的归属(按耗时)
格式/空白/冲突标记(< 100ms)→ pre-commit
静态分析(< 3s)            → pre-commit 或 pre-push
单元测试(< 60s)            → pre-push(或 CI)
全量测试/构建(> 2min)      → CI
// 判据:把"检查"放到"最靠前、又不影响节奏"的位置

5. "安装钩子"的三种落地

落地 说明
core.hooksPath 指向仓库内目录 一条命令(推荐)
工具初始化(husky install) 依赖安装时跑
文档要求"手动复制" 一定会漏

第一项最干净:git config core.hooksPath .githooks第一项的"弥补"是"一条命令写进 README 的"克隆后第一步"":"克隆 → 配置 → 开工"三行——而它比"文档里写十行"更可能被执行。
—— 于是"钩子脚本进版本库、每条克隆只需一条命令"。

"绕过"这件事应当"可见",否则"钩子形同虚设"而无数据:

// 两种"可见"的方式(都很便宜)
1 在钩子里记一行日志:echo "$(date) $(whoami) $0" >> /tmp/hooks.log
2 CI 里检查"是否有提交没经过格式化"(若大量出现 → 有人在绕过)
// 判据:绕过率(未格式化提交数 / 总提交数)> 5% → 钩子设计有问题

6. 排错:五种"钩子相关问题"

现象 成因 处理方向
新人没钩子 未安装 core.hooksPath + 一条命令
提交被钩子卡住 太慢 拆到 pre-push
钩子"失效" 权限位丢失 chmod +x
绕过太多 不可靠 修脚本
服务端钩子拦了合并 红线 提前在本地提示

第三项的"自检"是"ls -l .githooks 看有没有 x":它在 Windows/WSL 混合团队里高频发生,而"钩子不执行"不会报错。
第三项在"跨系统拷贝"后常见:Windows 上打的包丢了可执行位,于是"钩子存在但不执行"——而它没有任何提示。

"服务端钩子"在自建 Git(GitLab/Gitea)上有两种形态,选错会"拦不住":

// 两种"服务端"位置
1 pre-receive(裸仓库的 hooks/):推送即拦,可绕过"网页提交"?不能
2 CI 的"合并门槛"(protected branch):合并即拦,但"直接推送"要禁用
// 判据:是否"有人能绕过 CI 直接推主干"——能,就必须有 pre-receive

7. 边界:什么情况不必用钩子

场景 为什么 做法
单人项目 无协作 不用
已有 IDE 自动格式化 本地已保证 不用
CI 足够快(< 2 分钟) 不必前置 只用 CI
仓库只读(发布分支) 不提交 不用

判断口径一句话:"这个检查,靠"人的自觉"会不会漏"。最后一条与"安全"有关的边界:"密钥扫描"必须放服务端——因为"本地钩子可绕过"意味着"一旦有人故意绕过,密钥就进了历史",而历史里的密钥"删不掉"(要改历史 + 轮换)。
会(格式、冲突标记、密钥),且它"能在毫秒内判定",就该前移到 pre-commit;判定要"几秒以上"或"要跑测试",留给 CI。

最后一个"组织"现实:钩子的"维护者"通常是"最在意代码质量的那个人",而他离职后钩子就没人管。所以钩子要"简单到不用维护"——三条检查、一个独立脚本、一条安装命令,比"二十条规则"活得久。

还有一条"协作"细节:钩子失败时,错误信息要"给命令"(如"运行 make fmt 后重试"),而不是"给堆栈"。因为"钩子拦下的是同事",而"同事只想知道怎么过"。

这一条最容易被忘:"钩子是给人用的,不是给机器用的"——机器只看结果,人看的是"我要不要再提交一次"。

所以"偷懒的设计"往往比"严格的设计"更有效。

这是本轮最想强调的一点。

8. 自测

  1. 三类钩子的分工是什么?为什么 pre-commit "越重越容易被绕过"?
  2. 为什么 .git/hooks 是"结构性陷阱"?"服务端钩子"为什么不可绕过?
  3. "本地快、CI 全"的分工解决了什么?本地被绕过会造成什么后果?
  4. core.hooksPath 解决了什么?它比"手动复制"好在哪?
  5. 为什么"钩子存在但不执行"没有任何提示?
  6. 判断"是否该用钩子"的那个问题是什么?
S01-A23 · Git 钩子:为什么"本地能提交、CI 挂了"
https://www.clxhxhhr.top/posts/6644/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。