选战略只需问一个问题
一句话总结
选择分支策略时要问的,不是“哪种图形漂亮”,而是“发生故障时,计划如何在这个 repository 中找到致因 commit”。
为什么需要了解这一点
Git Flow、GitHub Flow 和 Trunk-Based 分别以不同的部署周期为前提。忽视前提只照搬名称,每天都会产生摩擦。
| 策略 | 分支类型 | 合并频率 | 适用场景 |
|---|---|---|---|
| Git Flow | 5 种以上 | 每周 1~2 次 | 定期发布、移动应用、package |
| GitHub Flow | 2 种 | 每天 1~3 次 | 实践 CD 的 SaaS |
| Trunk-Based | 1~2 种 | 每天 5~10 次以上 | 分支寿命不超过 1 天,必须使用 Feature Flag |
Trunk-Based 对 CI 的要求非常高。没有 Feature Flag 就开始使用,未完成的代码会原样部署。相反,在 GitOps 环境中,Git Flow 的 release branch 与自动同步模式摩擦很大。因此,使用 GitOps 时建议选择 GitHub Flow 或 Trunk-Based。
有一条通用规则在任何地方都成立:存活超过 3 天的 feature branch 是 merge conflict 的温床。
工作原理
合并方式有三种,各自留下不同的信息。
--no-ff合并:生成一个有两个 parent 的 commit,在历史中保留“这一组变更何时集成”的信息。- 快进(fast-forward):不创建 merge commit,只把 branch pointer 向前移动。历史成为直线,但不会保留集成时刻。
- squash 合并:把多个 commit 压成一个。看起来整洁,却会带走三样东西:bisect 的分辨率固定为 PR 大小,失去 cherry-pick 精度,而且从 Git 的视角看,该 branch 从未合并过(因为新 commit 的 parent 不是原始 branch)。所以使用 squash 合并的团队,必须同时规定合并后立即删除 source branch。
cherry-pick 不是剪下已保存的 diff 再粘贴。Git 本来就不存储 diff,只存储 snapshot。因此,它会把目标 commit 的 parent tree 当作共同祖先,执行 three-way merge。这正是 cherry-pick 会发生冲突的原因。
实际工作中的表现
在 release branch 中修复 hotfix 却没有反映到 main,导致同一缺陷在下次 release 时复活,几乎每个团队都至少经历过一次。运营 release branch 时,必须把“hotfix 也必须带回 main”明确写成 policy。
tag 也是如此。lightweight tag 只是 pointer,不会记录由谁、何时、为何添加。release tag 必须用 -a 添加 annotation,之后才能用于调查。
历史是为调查而保留的
分支策略的价值不在平时,而会在事故发生时显现。面对“昨天还正常,今天却不行”的情况,查找致因 commit 的标准工具是 bisect。Git 通过 git bisect 半自动完成这项工作。告诉它一个正常 commit 和一个故障 commit,它会 checkout 中间点;人只需回答好或坏,范围便会逐次减半。在 1,000 个 commit 中寻找原因,十次就足够了。
要让 bisect 有效,历史必须满足两个条件:每个 commit 都能构建并运行,而且 commit 要小。 如果中间夹有无法运行的 commit,人就不能回答好或坏,只能跳过该点;如果通过 squash 合并让整个 PR 变成一个 commit,搜索会停在这个 PR 面前。也就是说,只能查到“就在这个 PR 中某处”,余下内容仍要人工阅读。前面所说 squash 合并会把 bisect 分辨率固定为 PR 大小,指的就是这里。
找到致因 commit 后,也要预先确定下一步选择。回退方法有两种,性质完全不同。
git revert会创建一个新的 commit来撤销该变更。历史得以保留,在已经共享的 branch 上也安全。生产 branch 只应使用这种方式。git reset会把 branch pointer 向后移动。在共享 branch 上使用,会让其他人的 repository 与其历史分叉,之后所有人都会饱受 force push 和冲突之苦。
回退 merge commit 时还需要额外一步。因为它有两个 parent,必须用 -m 1 指定保留哪一条主线,通常 1 号是接收合并的一方(main)。而且,回退合并后再次合并同一个 branch,不会带入任何变更。 因为在 Git 看来,这些 commit 已经合并过。要重新引入已回退的内容,必须再次回退那个 revert commit;最好不要在紧急时刻才第一次知道这一点。
下个练习将做什么
从头创建 repository,用 --no-ff 合并 feature branch 以保留集成记录,通过 fast-forward 提交小修改,添加 release tag,把 release branch 中的 hotfix 通过 cherry-pick 带回 main,然后只选择已合并 branch 进行清理。最后统计这个 repository 的实际数字,编写策略对比表。