在分支上还是绿灯,合并之后立刻变成了红灯
目标
把本地裸仓库当作服务器,用接收一方的钩子强制保护分支,用逐提交的检查记录建立必需检查,守住线性历史,测量分支的年龄,重现语义冲突,然后用合并队列把绿灯一直带到合并之后。
为什么重要
分支上的绿灯,是“我的改动加上当时的主干”的检查结果。如果别的分支先进去了,这个结果就不再是对现在主干的保证。所以会出现原本是绿灯的东西,合并之后变成红灯,而这时被破坏的不是某一个人的分支,而是所有人赖以立足的主干。能拦住的地方只有两处。一处是接收一方的规则——放在推送一方的规则不会随克隆而来,谁都能关掉,所以无法成为强制。另一处是合并之前的重新检查——排队,放在现在主干末端之上重新检查,只让通过的进去。本实验要在没有互联网、没有托管服务的 Pod 里,只用 git 亲手把这两者做出来。托管服务的分支保护和合并队列,不过是给这个骨架起了名字,而这些功能保证什么、不保证什么,只有亲手做过一遍的人才知道。
步骤
- 从
/root/trunk开始。先以默认分支main创建扮演“服务器”角色的裸仓库/root/trunk/server.git,并创建用来按提交记录检查结果的空目录/root/trunk/server.git/status。然后把这个服务器克隆到/root/trunk/app,身份设为dev/dev@example.com,创建三个 python 文件和一个测试运行器,提交并直接 push 到main——calc.py(discounted(price, rate)返回price - price * rate)、checkout.py(cart_total(prices, rate)把每个值的折后价相加)、tests.py(断言discounted(100, 0.2) == 80和cart_total([100, 200], 0.2) == 240,并打印ok)、run-tests.sh(切换到自己所在的目录,运行python3 tests.py,需要执行权限)。最后把同一个服务器再克隆一份到/root/trunk/app2,身份设为other/other@example.com,添加README.md,同样直接 push 到main。然后把git --git-dir=/root/trunk/server.git log --format='%h %ae %s' main的输出原样保存到/root/trunk/evidence/01-direct-push.txt。 - 创建服务器钩子
/root/trunk/server.git/hooks/update,并赋予执行权限。参数依次是要更新的引用名称、旧值、新值。如果引用是refs/heads/main,就往标准错误输出一行说明应该做什么的提示并以 1 结束,对其他引用则不作任何输出并以 0 结束。然后创建本地钩子/root/trunk/app/.git/hooks/pre-commit并赋予执行权限——如果暂存的改动中含有DO-NOT-COMMIT就拒绝,否则放行。现在确认三件事,并按<이름> <값>(占位符依次为名称与值)的形式写三行到/root/trunk/evidence/02-hooks.txt。main-push是在/root/trunk/app中创建一个提交并直接 push 到main时的结果(accepted或rejected),same-push-both是用一条 push 命令同时推送main和功能分支时,服务器上收到的内容(all、feature-only、none之一),cloned-hook是把服务器重新克隆到/root/trunk/app3时,该副本中是否有pre-commit钩子(present或absent)。 - 先创建检查运行器
/root/trunk/ci-check.sh <server.git> <커밋SHA>(占位符依次为 server.git 与提交 SHA)并赋予执行权限。从服务器把该提交的树取出到临时目录(git --git-dir=<server> archive <커밋>,占位符依次为服务器与提交),运行run-tests.sh,并把结果以pass或fail写入<server.git>/status/<커밋 40자리 SHA>(占位符为 40 位提交 SHA)文件的第一行。屏幕上输出一行<40자리 SHA> <pass|fail>(占位符为 40 位 SHA),退出码是:通过为 0、失败为 1、参数不足或服务器上没有该提交时为 2。不要留下临时目录。然后修改/root/trunk/server.git/hooks/update,对refs/heads/main施加这些规则——新值只有 0 的更新(删除)要拒绝,旧值不是新值祖先的更新(强制更新)要拒绝,并且对通过git rev-list <옛값>..<새값>(占位符依次为旧值与新值)得到的每一个新提交,如果没有记录就拒绝,第一行不是pass也要拒绝。拒绝信息中要包含出问题的提交的 40 位 SHA。 最后在/root/trunk/app中往服务器上传两个功能分支——一个是测试能通过的改动,一个是在tests.py中故意加入了会失败的断言的改动——对各自运行ci-check.sh之后,把两行green <40자리 SHA> pass和red <40자리 SHA> fail(占位符为 40 位 SHA)写入/root/trunk/evidence/03-status.txt。 - 在
/root/trunk/server.git/hooks/update中再加一条规则——如果进入refs/heads/main的新提交中有父提交为两个以上的提交(合并提交),就往标准错误输出一条同时包含该提交的 40 位 SHA 和词语rebase的提示,并拒绝。即使检查记录全是绿灯,也必须拒绝。然后确认这条规则确实在起作用——在/root/trunk/app中创建从origin/main分出的分支feature/merge-demo,上传一个提交,再用--no-ff把origin/feature/green合并进来,然后手动给所有新提交写入pass记录,并把该分支的末端 push 到main。把拒绝信息的第一行去掉remote:前缀,原样保存到/root/trunk/evidence/04-linear.txt。 - 创建
/root/trunk/branch-age.sh <server.git> [최대일수](占位符依次为 server.git 与最大天数)并赋予执行权限。最大天数的默认值是3。只遍历服务器上的refs/heads/feature/*,按名称升序,每个分支输出一行<브랜치이름> <앞선커밋수> <나이> <OK|STALE>(占位符依次为分支名称、领先提交数、年龄)。分支名称是去掉refs/heads/的部分,领先提交数是main..<브랜치>(占位符为分支)的提交数,年龄是该范围内最旧的提交的提交者时间到现在之间的天数(向下取整)。年龄超过最大天数就是STALE,否则是OK。只要有一个 STALE,退出码就是 1,没有就是 0,参数不足或服务器不存在时是 2。然后在/root/trunk/app中再上传两个功能分支——一个是第一个提交的作者时间和提交者时间都是 40 天前的feature/old-report,一个是刚创建的feature/quick-fix。最后把/root/trunk/branch-age.sh /root/trunk/server.git 3的输出保存到/root/trunk/evidence/05-branch-age.txt。 - 在
/root/trunk/app中创建两个从origin/main分出的功能分支,并上传到服务器。两个分支修改的文件不能重叠——这样才能暴露出“干净地合并了,却坏了”这件事。feature/sem-a只修改calc.py,让discounted用round(...)对结果四舍五入后返回。feature/sem-b在checkout.py中加入coupon_total(price)(=discounted(price, 0.15)),并在tests.py中加入coupon_total(105) == 89.25的断言。对两个分支各自运行/root/trunk/ci-check.sh,留下绿灯记录。然后把服务器克隆到临时目录,试着把feature/sem-brebase 到feature/sem-a之上(不能发生文件冲突),在得到的结果上运行run-tests.sh。把确认的内容写成四行,保存到/root/trunk/evidence/06-semantic.txt——sem-a PASS、sem-b PASS、merged <PASS|FAIL>、textual <clean|conflict>。 - 创建
/root/trunk/merge-queue.sh <server.git> <대기열파일>(占位符依次为 server.git 与队列文件)并赋予执行权限。队列文件每行一个分支名称,空行和以#开头的行要跳过。对每个条目依次这样做——取回现在的main末端,把分支 rebase 到它之上(放不上去就输出REJECTED <브랜치> conflict,占位符为分支),先把放上去的提交推送为refs/queue/<브랜치>(占位符为分支),然后对每一个提交运行同一目录的ci-check.sh(只要有一个失败,就输出REJECTED <브랜치> tests),全部是绿灯的话,就把它的末端 push 到main(被拒绝就输出REJECTED <브랜치> push),并输出MERGED <브랜치> <40자리 SHA>(占位符依次为分支与 40 位 SHA)。服务器上没有的分支是REJECTED <브랜치> missing。失败的条目要排除,并继续处理下一项。结束时不要留下refs/queue/*。退出码是:全部合并则为 0,有被拒绝的则为 3,参数或队列文件有误则为 2。然后在/root/trunk/queue.txt中按这个顺序写入feature/sem-a、feature/sem-b、feature/quick-fix,运行队列,把输出保存到/root/trunk/evidence/07-queue.txt。 - 创建
/root/trunk/protection-report.sh <server.git> <보고서파일>(占位符依次为 server.git 与报告文件)并赋予执行权限。每个探针都重新复制一份服务器副本,原服务器不能有一个字符的变化。按这个顺序,实际推送五种情形——force-main(改写末端提交来强制更新)、no-status(没有记录的新提交)、failed-status(记录为fail的新提交)、merge-commit(所有新提交都记录了pass的合并提交)、queue-merge(所有新提交都记录了pass的线性提交)。每个探针写一行<탐침이름> <BLOCKED|ALLOWED> <거절 메시지 첫 줄 또는 ->(占位符依次为探针名称与拒绝信息的第一行,没有则为 -),最后一行写SUMMARY blocked=<수> allowed=<수>(占位符为个数)。拒绝信息取自 push 的标准错误中以remote:开头的第一行,去掉前缀后的内容。报告既要保存为文件,也要输出到屏幕。上级目录不存在就创建。退出码是:前四种全部被拦住、只有queue-merge通过时为 0,否则为 3,参数有误时为 2。最后运行/root/trunk/protection-report.sh /root/trunk/server.git /root/trunk/report/protection.txt,留下报告。
参考
- 在这个实验 Pod 中无法启动容器——seccomp 阻止创建新的用户命名空间。不要使用
docker、podman run、buildah、unshare -U。使用的是 bash、git 2.43、python3.12 标准库、jq、tar、coreutils。没有yq、make、go,也没有互联网和 GitHub/GitLab 服务器。 - 实测:往本地路径远程(
git remote add origin /root/trunk/server.git)push 时,接收一方的服务器钩子会原样执行。钩子输出的标准错误,会在 push 的一方加上remote:前缀后显示出来。 - 实测:Pod 里没有 git 身份。每次创建仓库时,都要先设置
git config user.name和git config user.email。 - 常见错误:没有给钩子文件赋予执行权限。git 连错误都不报,直接跳过,于是在自以为规则已经开启的状态下,什么也没有被拦住。
- 常见错误:把记录位置写死为绝对路径。在钩子内部要用
git rev-parse --git-dir找到当前的仓库,这样副本里才会运行同样的规则。 - 工作目录统一为
/root/trunk。实验 Pod 没有卷,会话一结束,这个目录就会整个消失。 - githooks(5) · git-receive-pack · Trunk Based Development · About protected branches · Managing a merge queue · GitLab merge trains · Continuous Integration (Fowler)
没有规则,任何人都能直接往主干推送
从 /root/trunk 开始。先以默认分支 main 创建扮演“服务器”角色的裸仓库 /root/trunk/server.git,并创建用来按提交记录检查结果的空目录 /root/trunk/server.git/status。然后把这个服务器克隆到 /root/trunk/app,身份设为 dev / dev@example.com,创建三个 python 文件和一个测试运行器,提交并直接 push 到 main——calc.py(discounted(price, rate) 返回 price - price * rate)、checkout.py(cart_total(prices, rate) 把每个值的折后价相加)、tests.py(断言 discounted(100, 0.2) == 80 和 cart_total([100, 200], 0.2) == 240,并打印 ok)、run-tests.sh(切换到自己所在的目录,运行 python3 tests.py,需要执行权限)。最后把同一个服务器再克隆一份到 /root/trunk/app2,身份设为 other / other@example.com,添加 README.md,同样直接 push 到 main。然后把 git --git-dir=/root/trunk/server.git log --format='%h %ae %s' main 的输出原样保存到 /root/trunk/evidence/01-direct-push.txt。
裸仓库是没有工作树的仓库,很适合作为接收 push 的一方。用 git init --bare -b main 可以连默认分支名称一起指定。Pod 里没有 git 身份,所以每个仓库都必须先设置 git config user.name 和 user.email 才能提交。这一步要确认的事实是:现在没有任何规则,所以两个人可以各自直接修改主干。
把规则放在接收一方,直接 push 就被拦住了
创建服务器钩子 /root/trunk/server.git/hooks/update,并赋予执行权限。参数依次是要更新的引用名称、旧值、新值。如果引用是 refs/heads/main,就往标准错误输出一行说明应该做什么的提示并以 1 结束,对其他引用则不作任何输出并以 0 结束。然后创建本地钩子 /root/trunk/app/.git/hooks/pre-commit 并赋予执行权限——如果暂存的改动中含有 DO-NOT-COMMIT 就拒绝,否则放行。现在确认三件事,并按 <이름> <값>(占位符依次为名称与值)的形式写三行到 /root/trunk/evidence/02-hooks.txt。main-push 是在 /root/trunk/app 中创建一个提交并直接 push 到 main 时的结果(accepted 或 rejected),same-push-both 是用一条 push 命令同时推送 main 和功能分支时,服务器上收到的内容(all、feature-only、none 之一),cloned-hook 是把服务器重新克隆到 /root/trunk/app3 时,该副本中是否有 pre-commit 钩子(present 或 absent)。
钩子没有执行权限的话,git 连错误都不报,直接跳过——在自以为规则已经开启的期间什么也没有被拦住,这种状态就是由此产生的。update 对每个要更新的引用各运行一次,不是 0 就只拦住那个引用。如果是 pre-receive,整个接收操作会被一次性拒绝——把两个引用一起推送试试,两者的区别就会原样显现。本地钩子位于 $GIT_DIR/hooks,不会通过克隆传播。
只是锁住的话谁也进不去——只接收绿灯的提交
先创建检查运行器 /root/trunk/ci-check.sh <server.git> <커밋SHA>(占位符依次为 server.git 与提交 SHA)并赋予执行权限。从服务器把该提交的树取出到临时目录(git --git-dir=<server> archive <커밋>,占位符依次为服务器与提交),运行 run-tests.sh,并把结果以 pass 或 fail 写入 <server.git>/status/<커밋 40자리 SHA>(占位符为 40 位提交 SHA)文件的第一行。屏幕上输出一行 <40자리 SHA> <pass|fail>(占位符为 40 位 SHA),退出码是:通过为 0、失败为 1、参数不足或服务器上没有该提交时为 2。不要留下临时目录。然后修改 /root/trunk/server.git/hooks/update,对 refs/heads/main 施加这些规则——新值只有 0 的更新(删除)要拒绝,旧值不是新值祖先的更新(强制更新)要拒绝,并且对通过 git rev-list <옛값>..<새값>(占位符依次为旧值与新值)得到的每一个新提交,如果没有记录就拒绝,第一行不是 pass 也要拒绝。拒绝信息中要包含出问题的提交的 40 位 SHA。 最后在 /root/trunk/app 中往服务器上传两个功能分支——一个是测试能通过的改动,一个是在 tests.py 中故意加入了会失败的断言的改动——对各自运行 ci-check.sh 之后,把两行 green <40자리 SHA> pass 和 red <40자리 SHA> fail(占位符为 40 位 SHA)写入 /root/trunk/evidence/03-status.txt。
在裸仓库内部运行钩子时,git rev-parse --git-dir 指向的就是那个仓库——不要把记录位置写死为绝对路径。在副本里也必须运行同样的规则。git merge-base --is-ancestor A B 在 A 是 B 的祖先时以 0 结束。假设留下记录的位置只有 CI 能使用——在真实服务中,这个位置就是“必需状态检查”。检查运行器必须查看的是进入服务器的提交,而不是工作副本。还不在服务器上的提交是取不出来的。
把合并提交打回去,要求做 rebase
在 /root/trunk/server.git/hooks/update 中再加一条规则——如果进入 refs/heads/main 的新提交中有父提交为两个以上的提交(合并提交),就往标准错误输出一条同时包含该提交的 40 位 SHA 和词语 rebase 的提示,并拒绝。即使检查记录全是绿灯,也必须拒绝。然后确认这条规则确实在起作用——在 /root/trunk/app 中创建从 origin/main 分出的分支 feature/merge-demo,上传一个提交,再用 --no-ff 把 origin/feature/green 合并进来,然后手动给所有新提交写入 pass 记录,并把该分支的末端 push 到 main。把拒绝信息的第一行去掉 remote: 前缀,原样保存到 /root/trunk/evidence/04-linear.txt。
git rev-list --parents -n 1 <커밋>(占位符为提交)会把该提交和它的父提交输出在一行里——如果词语有三个以上,就是合并提交。要求线性历史的理由不是出于喜好。回退和缩小罪魁范围会明显变得容易,“这个提交在主干上是否通过”这个问题,也可以只用一个提交来回答。拒绝只是拦截工作的一半——另一半是告诉对方现在该做什么。
存活几天的分支造成了大部分问题
创建 /root/trunk/branch-age.sh <server.git> [최대일수](占位符依次为 server.git 与最大天数)并赋予执行权限。最大天数的默认值是 3。只遍历服务器上的 refs/heads/feature/*,按名称升序,每个分支输出一行 <브랜치이름> <앞선커밋수> <나이> <OK|STALE>(占位符依次为分支名称、领先提交数、年龄)。分支名称是去掉 refs/heads/ 的部分,领先提交数是 main..<브랜치>(占位符为分支)的提交数,年龄是该范围内最旧的提交的提交者时间到现在之间的天数(向下取整)。年龄超过最大天数就是 STALE,否则是 OK。只要有一个 STALE,退出码就是 1,没有就是 0,参数不足或服务器不存在时是 2。然后在 /root/trunk/app 中再上传两个功能分支——一个是第一个提交的作者时间和提交者时间都是 40 天前的 feature/old-report,一个是刚创建的 feature/quick-fix。最后把 /root/trunk/branch-age.sh /root/trunk/server.git 3 的输出保存到 /root/trunk/evidence/05-branch-age.txt。
提交时间可以用 GIT_AUTHOR_DATE 和 GIT_COMMITTER_DATE 来指定,它们接受 @<에포크초>(占位符为纪元秒数)的格式。git log -1 --format=%ct 会以纪元秒数输出提交者时间。给 git for-each-ref 传入 refs/heads/feature,就只会输出其下的内容。排序受区域设置影响,所以加上 LC_ALL=C 更稳妥。为什么要测量年龄——开始测量之后,通常会发现,是少数几个存活几天的分支造成了合并事故的大部分。
各自都是绿灯的两个分支,合并之后却变成了红灯
在 /root/trunk/app 中创建两个从 origin/main 分出的功能分支,并上传到服务器。两个分支修改的文件不能重叠——这样才能暴露出“干净地合并了,却坏了”这件事。feature/sem-a 只修改 calc.py,让 discounted 用 round(...) 对结果四舍五入后返回。feature/sem-b 在 checkout.py 中加入 coupon_total(price)(= discounted(price, 0.15)),并在 tests.py 中加入 coupon_total(105) == 89.25 的断言。对两个分支各自运行 /root/trunk/ci-check.sh,留下绿灯记录。然后把服务器克隆到临时目录,试着把 feature/sem-b rebase 到 feature/sem-a 之上(不能发生文件冲突),在得到的结果上运行 run-tests.sh。把确认的内容写成四行,保存到 /root/trunk/evidence/06-semantic.txt——sem-a PASS、sem-b PASS、merged <PASS|FAIL>、textual <clean|conflict>。
文本冲突,工具会告诉你,所以只是费时间,不会漏掉。会漏掉的,是修改了不同的行、干净地合并了,结果却不对的情形。python 的 round 会把小数第一位是 5 的值舍入到最近的偶数——一旦加入四舍五入,原本以 .25 结尾的值就不再是那个值了。在分支上做的检查,看的是“我的改动加上当时的主干”,而不是“合并之后的主干”。为了不弄乱工作副本,试合并要在临时克隆中进行。
队列放在现在的主干末端之上,重新检查
创建 /root/trunk/merge-queue.sh <server.git> <대기열파일>(占位符依次为 server.git 与队列文件)并赋予执行权限。队列文件每行一个分支名称,空行和以 # 开头的行要跳过。对每个条目依次这样做——取回现在的 main 末端,把分支 rebase 到它之上(放不上去就输出 REJECTED <브랜치> conflict,占位符为分支),先把放上去的提交推送为 refs/queue/<브랜치>(占位符为分支),然后对每一个提交运行同一目录的 ci-check.sh(只要有一个失败,就输出 REJECTED <브랜치> tests),全部是绿灯的话,就把它的末端 push 到 main(被拒绝就输出 REJECTED <브랜치> push),并输出 MERGED <브랜치> <40자리 SHA>(占位符依次为分支与 40 位 SHA)。服务器上没有的分支是 REJECTED <브랜치> missing。失败的条目要排除,并继续处理下一项。结束时不要留下 refs/queue/*。退出码是:全部合并则为 0,有被拒绝的则为 3,参数或队列文件有误则为 2。然后在 /root/trunk/queue.txt 中按这个顺序写入 feature/sem-a、feature/sem-b、feature/quick-fix,运行队列,把输出保存到 /root/trunk/evidence/07-queue.txt。
为什么要先推送到 refs/queue/——检查运行器只能取出进入服务器的提交,而叠上去新创建的提交还不在服务器上。受保护的只有 main 一个,所以推送到其他引用是自由的。真实服务的队列,也是这样创建临时分支来做检查。前面的条目一旦进去,后面条目的基准就变了——所以队列的价值在于“重新检查”。如果前面某一项坏了,就让整个队伍都停下,那就没有使用队列的意义了。
违反规则的 push 在哪里、以什么话被拦住,用一页纸说清楚
创建 /root/trunk/protection-report.sh <server.git> <보고서파일>(占位符依次为 server.git 与报告文件)并赋予执行权限。每个探针都重新复制一份服务器副本,原服务器不能有一个字符的变化。按这个顺序,实际推送五种情形——force-main(改写末端提交来强制更新)、no-status(没有记录的新提交)、failed-status(记录为 fail 的新提交)、merge-commit(所有新提交都记录了 pass 的合并提交)、queue-merge(所有新提交都记录了 pass 的线性提交)。每个探针写一行 <탐침이름> <BLOCKED|ALLOWED> <거절 메시지 첫 줄 또는 ->(占位符依次为探针名称与拒绝信息的第一行,没有则为 -),最后一行写 SUMMARY blocked=<수> allowed=<수>(占位符为个数)。拒绝信息取自 push 的标准错误中以 remote: 开头的第一行,去掉前缀后的内容。报告既要保存为文件,也要输出到屏幕。上级目录不存在就创建。退出码是:前四种全部被拦住、只有 queue-merge 通过时为 0,否则为 3,参数有误时为 2。最后运行 /root/trunk/protection-report.sh /root/trunk/server.git /root/trunk/report/protection.txt,留下报告。
报告必须是“试探出来的结果”,而不是“写进去的东西”。对着拿掉了钩子的服务器运行,五行全部翻转为通过,就是它的证据——评分器会真的这样试一遍。裸仓库只要复制目录就成了副本。请利用副本会连钩子和记录一起带过来这一点。制造强制更新最短的办法,是改写末端提交。最后把运行出来的五行,与前面步骤里创建的规则一一对照,就能一眼看出哪条规则拦住了哪种事故。