🛠️ 第7章 Rules、Hooks 与 Approvals:先约束,再放权
📄 一句话概括

四类钩子:格式化能改代码、测试能判对错、审计只记录、阻断直接退。 顺序有意义:能改的放前面,只判的放后面,最后才是阻断。 钩子世界只有一种语言:退出码。零放行,非零拦住。

📝 本章和前面几章的关系

上一篇说钩子靠系统执行。这一篇讲钩子具体怎么写、怎么串。 Demo 写了四个真钩子脚本:去行尾空白、跑测试、记审计日志、拦禁区路径。 然后用一个 runner 按顺序串起来跑两遍——一遍干净、一遍危险。 两遍的输出都在下面:第一遍四个退出码全零,第二遍在最后一个退出码二,整条链断。

一、四类钩子各管一段

类型 干什么 退出码 能不能改代码
格式化 去行尾空白、跑 formatter 一般零 能
测试 跑验证,看结果 失败非零 不能
审计 记一条日志 永远零 不能
阻断 路径与动作黑名单 命中退出二 不能

「能不能改代码」这一列最关键。 只有格式化能改。 这个限制不是技术问题,是逻辑问题: 如果测试钩子会顺手修正代码,那「测试通过」就不再是「代码对」的证据—— 因为它可以把自己测的东西改到通过。 审计钩子的特点是「永远返回零」。 它管的是「有没有发生」,不是「许不许发生」。 Demo 里 audit.log 记了两条,两条都记下来了,包括那条被拦的。 这一点很重要:被拦的动作更该记。

二、钩子的执行时机

时机有三种,作用是递进的:

时机 适合放什么 拦得住吗
改完文件立刻 格式化 不用拦,它只修正
提交之前 测试、阻断 能拦,退出码非零就不提交
推送之前 审计、通知 能拦,但已经晚了一步

越晚越贵。 改完立刻发现问题,成本是一行;提交前发现,成本是一次 amend; 推送后才发现,成本是一次回滚加一次道歉。 所以顺序要这样排: 能修的(格式化)最先跑,因为它让后面看到的都是干净版本; 只判的(测试)其次; 阻断最后,因为它一旦退出,后面都不用跑了。 Demo 第二遍就是这个顺序跑下来的: 格式化退出零、测试退出零、审计退出零、阻断退出二。 前三个都执行了,第四个断掉——这符合预期,因为阻断本来就该在最后。

三、阻断要有明确的退出码

钩子不读日志,只读退出码。 零是放行,非零是拦住——就这么一条规则。 所以自己写的钩子脚本,结束前必须把退出码说清楚。 Python 里是 sys.exit(0) 和 sys.exit(1);shell 里是 exit 0 和 exit 1。 不写的话,退出码是最后一条命令的——那可能是个巧合的零。 退出码还能分级。 Demo 里阻断用二,测试用一。 这个区分在真机上很有用:一表示「检查没过」,二表示「根本不许做」—— 前者可以改了再试,后者应该停下来问人。 还有一个坑:钩子自己崩了怎么办? 崩了就是非零退出码,等于拦住。 这个默认行为是对的——宁可拦住,也不要放一个不知道自己有没有检查的东西过去。

四、动手 Demo:四类钩子:格式化、测试、审计、阻断

四个真脚本加一个 runner,串起来跑两遍。 第一遍是干净的改动,四个全过;第二遍要改 data/,被最后一个拦住。 这个 runner 复刻的是真机上 .git/hooks/pre-commit 的行为: 按顺序执行、任一环非零就停、把退出码传出去。 沙箱里没装 hook,所以用显式 runner 跑——语义一样。

STEPS = [("fmt", ["python", "hooks/fmt.py", "src/main.py"]),
         ("test", ["python", "hooks/test.py"]),
         ("audit", ["python", "hooks/audit.py", msg]),
         ("block", ["python", "hooks/block.py"] + paths)]

for name, cmd in STEPS:
    r = subprocess.run(cmd, capture_output=True)
    if r.returncode != 0:
        rc_all = r.returncode
        break
sys.exit(rc_all)

实跑输出:

--------------------------------------------------------------------
四类钩子:格式化、测试、审计、阻断
--------------------------------------------------------------------


  ① 四个钩子,各管一段
  --------------------------------------------------------------------
  hooks/fmt.py
  hooks/test.py
  hooks/audit.py
  hooks/block.py
  四类钩子的共同点:都是脚本,都用退出码说话。零放行,非零拦住——钩子世界只有这一种语言。

  ② 一次干净的提交:四个钩子全过
  --------------------------------------------------------------------
  [fmt] 退出码 0 | 格式化:去掉行尾空白 2 行
  [test] 退出码 0 | 测试:通过 2 / 失败 0
  [audit] 退出码 0 | 审计:已记一条
  [block] 退出码 0 | 阻断:检查通过
  汇总退出码 0

  ③ 一次危险的提交:被最后一个钩子拦住
  --------------------------------------------------------------------
  [fmt] 退出码 0 | 格式化:去掉行尾空白 0 行
  [test] 退出码 0 | 测试:通过 2 / 失败 0
  [audit] 退出码 0 | 审计:已记一条
  [block] 退出码 2 | 阻断:data/stopwords.txt 在禁区里
  汇总退出码 2
  前两个跑完了,第三个记了审计,第四个退出码二,整条链断。
  这是钩子链的默认语义:任一环非零,后面不再执行。
  ! 所以顺序有意义:格式化和测试要跑在前面——它们能「改」代码,让后面的检查看到干净的版本。
  阻断放最后,因为它一旦退出,前面都不用跑了。

  ④ 审计钩子真写了一条日志
  --------------------------------------------------------------------
  audit.log 里 2 条:
    {"when": "13:43:24", "what": "feat: 加一个分支"}
    {"when": "13:43:28", "what": "chore: 顺手改数据"}
  审计钩子的作用是「事后能查」。
  它永远返回零,从不拦人——因为它管的是「有没有发生」,不是「许不许发生」。

  ⑤ 四类钩子各管什么
  --------------------------------------------------------------------
| 类型 | 干什么 | 退出码 | 能不能改代码 |
|---|---|---|:---:|
| 格式化 | 去行尾空白、跑 formatter | 一般零 | 能 |
| 测试 | 跑验证,看结果 | 失败非零 | 不能 |
| 审计 | 记一条日志 | 永远零 | 不能 |
| 阻断 | 路径与动作白名单 | 命中退出二 | 不能 |

  ! 只有第一个能改代码。
  这个区别很重要:格式化可以悄悄地改,测试和阻断只能判——所以别把「修正」写进测试钩子,
  那会让结果不可复现。

第一遍四个零,第二遍最后一个二。 注意第二遍的格式化报「去掉行尾空白 0 行」—— 因为第一遍已经去过了。这说明钩子是幂等的:跑两遍和跑一遍一样。 audit.log 两条都记下来了,包括那条被拦的。 被拦的动作更该记——因为「有人试图改数据」这件事,比「改了数据」更值得知道。 第二遍整条链在第四个断掉,但前三个都跑完了。 这是有意的顺序:格式化和测试应该在阻断之前跑完, 因为你要看的是「代码在干净状态下测过没有」,不是「它有没有碰禁区」。 最后那张表只有一行值得背:只有格式化能改代码。 测试和阻断只能判——判和改分开,证据才成立。

30 秒实验

  1. 写一个最小的钩子:只做一件事,只返回一个退出码。 先跑通它,再加第二件事。
  2. 看一下你的钩子有没有显式 exit。 没有的话,它的退出码是碰运气。
  3. 给「不许做」和「做得不对」用不同的退出码。 前者该停下来问人,后者可以改了再试。

常见误区

  1. 「钩子里顺手把问题修了就完了。」 只有格式化能修。 测试钩子修代码,等于自己给自己判卷。
  2. 「审计钩子没必要,出事了看 git log。」 git log 里没有「试图做但被拦住」的记录。 而那恰恰是最该看的。
  3. 「钩子慢,跑一次要十几秒。」 那就只跑相关的。 慢到被人跳过,比没有更糟——因为你会以为它在跑。
自测三问(点开对答案) 1. 四类钩子里哪一类能改代码?为什么其他三类不能? —— **只有格式化能。** **如果测试钩子会修代码,「测试通过」就不再是「代码对」的证据**—— **它可以把自己测的东西改到通过。** 2. 钩子的执行顺序为什么是「格式化、测试、审计、阻断」? —— **能改的放前面,让后面看到干净版本;只判的其次;阻断最后,因为它一旦退出后面都不用跑了。** 3. 钩子崩了会怎样?这个默认行为合理吗? —— **崩了就是非零退出码,等于拦住。** **合理**——宁可拦住,也不要放一个不知道自己有没有检查的东西过去。

下一篇 7.3 讲最后一道闸:审批点怎么设计,以及为什么审批点太多等于没有。

相关笔记