合并之后还是绿灯吗
一句话总结
在分支上是绿灯,合并之后却变成红灯,这种事并不少见。因为分支上的检查看的是我的改动加上当时的主干,而不是合并之后的主干。缩小这个差距,正是短生命周期分支和服务器端规则要做的事。
为什么需要它
主干开发(trunk-based development)被定义为“开发者在一个叫作主干的分支上协作,并抵制创建其他长期存活的开发分支的压力的源代码管理模型”。短生命周期分支用于代码评审和构建检查,前提是它很快就会消失。发布分支也是在需要时从主干切出,发布后过一段时间就删除。给发布所起的版本名称,也要求具有同样的特性。语义化版本规范明确规定,一旦发布过的版本,其内容就不得修改,要修改就必须发布新版本。不去动已经发出去的东西,才会为回退留下对象。
为什么要避免长期存活的分支?分支越长,与主干的距离就越远,拉开的距离会在合并时一次性结算。而这张账单上最可怕的不是文本冲突。文本冲突,工具会告诉你。可怕的是语义冲突。如果我在分支上使用的函数,其行为被别人在主干上改了,那么这两处改动位于不同的行,会干净地合并,只是结果不对。版本管理工具察觉不到这一点。
从反馈的角度看,答案也是一样的。集成被拖得越晚,发现问题就越晚,发现得越晚,原因的候选就越多。Kent Beck 的那句话——“任何代码都不会在超过几个小时的时间里不被集成”,把目标线写得最简短。
分支保护必须是服务器端规则
如果把规则寄托在人的约定上,就不会被遵守。在忙碌的日子、处理故障的时候、新人入职的第一周,它都会被打破。所以规则要放在接收的一方,而不是推送的一方。
git 本身就有这种区分。钩子默认位于 $GIT_DIR/hooks,不会随克隆一起带过来。 git init 可以复制模板,但我创建的 pre-commit 钩子,不会自动出现在同事的副本里。也就是说,本地钩子是便利工具,而不是强制手段。 强制由服务器端钩子来做。
pre-receive对整个接收操作只运行一次。如果以非 0 值结束,任何引用都不会被更新。 整个推送被拒绝。update对每个要更新的引用各运行一次。如果不是 0,只有那个引用不会被更新。post-receive在更新结束之后运行。用于通知或后续工作。
托管服务的分支保护,也是位于同一个位置的功能。GitHub 的保护规则包括:合并前要求拉取请求、必需状态检查、要求解决对话、要求已签名的提交、要求线性历史、要求合并队列、要求部署成功、锁定分支、限制可推送的人、允许强制推送、允许删除。
这里有一个必须知道的默认值。保护规则默认不适用于管理员。 文档写明,对拥有仓库管理员权限或绕过保护权限的人,这些限制不适用,并提示要打开“不允许绕过上述设置”,才会对管理员也生效。明明把规则打开了,自以为安心,结果最危险的改动恰好从那条规则旁边溜过去,问题就出在这里。
绿灯在合并之后变成红灯的问题
即使打开了必需状态检查,差距依然存在。分支 A 和分支 B 各自都以主干为基准是绿灯,但先合并了 A 之后,B 的检查结果就不再是针对当前主干的了。 如果就这样合并,主干就会被破坏。
解决的方向有两个。
- 合并之前纳入最新的主干并重新检查。 “保持分支为最新状态”这个要求就是这个意思。做法简单,但如果合并频繁,在重新检查的过程中又有别的合并发生,就会一直落后。
- 使用合并队列。 把要合并的东西排成队,预先造出合并之后的状态并检查,只有通过的才放进主干。做法是把多项合在一起一次检查,失败了就只把罪魁拿掉再重试,所以队伍再长,检查次数也不会爆炸。GitLab 的合并列车(merge train)也是解决同一个问题的功能。
用功能开关取代长分支
“这个功能还没完成,所以不能合并”,是长期存活分支的常见理由。答案是把没有完成的东西放进主干,但不要启用它。 功能开关(feature flag)和抽象分支(branch by abstraction)就是这样做的办法。未完成的代码虽然在主干上,但对用户不可见,所以集成可以每天进行,而公开的时间点则可以另行确定。
不过,开关是有代价的。开关一多,实际运行的组合就增多,会出现测试覆盖不到的路径。创建开关时就一并确定删除它的时间点,是唯一管用的管理办法。
在现场相遇的样子
- “太急了,就直接推了”这种事件,有一半出自管理员账号。因为没有关闭允许绕过的设置。
- 必需状态检查的名称改了,保护规则里的名称却没变,于是在什么检查都没被要求的状态下过了几周。除了亲自试一试规则是否真的拦得住,没有别的办法确认。
- 要求线性历史之后,回退和二分查找都变得容易了。在合并提交交织在一起的历史里缩小罪魁提交的范围,明显更麻烦。
- 开始统计分支的存活时间之后,通常会发现,大部分问题是由存活几天的分支造成的。
参考
- 主干开发:https://trunkbaseddevelopment.com/
- 分支保护:https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
- 合并队列:https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue
- GitLab 合并列车:https://docs.gitlab.com/ci/pipelines/merge_trains/
- githooks:https://git-scm.com/docs/githooks
- 持续集成:https://martinfowler.com/articles/continuousIntegration.html
- 抽象分支:https://martinfowler.com/bliki/BranchByAbstraction.html
- 语义化版本:https://semver.org/
下一项实验要做什么
在本地创建一个裸仓库,亲手建立接收一方的规则。用 pre-receive 和 update 钩子阻止直接向受保护分支推送,并通过退出码确认只拒绝特定引用与拒绝整个推送的区别。然后创建本地钩子,亲眼看到克隆出来的副本里不会带上那个钩子。必需检查用钩子确认附在提交上的检查结果的方式来模拟,语义冲突则用这样的例子来重现:两个分支各自通过之后,合并时却坏了。最后用脚本做出一个在合并之前纳入主干并重新检查的队列模拟,数一数队伍变长时检查次数会如何变化。