51.1 分支策略
一句话
分支策略的唯一产出是「改动从提交到进主干要等多久」:等待越久,冲突量与 CI 重跑量随分叉宽度一起涨;主干开发把这个等待压到小时级,代价是半成品必须被特性开关和自动化测试接住。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “分支开得多 = 管理规范” | 决定成本的是分支寿命,不是分支数量。寿命 20 天、只改 6 个文件的分支,比寿命 1 天、改 200 个文件的分支更贵——它逼所有人替它付 rebase 与重跑 CI 的钱 |
| “git-flow 是标准做法” | git-flow(2010,Vincent Driessen)是为「盒装软件 + 多版本并行维护」设计的。Driessen 本人在 2020 年给原文加了注:对持续交付的 Web 应用,它太复杂了,GitHub flow 更合适 |
| “合并前必须 rebase 到最新” | 这句话等价于「每合并一次,所有在途 PR 全部失效」。在途 20 个 PR、CI 15 分钟时,一次合并会引发 19 次重跑 = 285 分钟机时(算账见本节「“合并前必须 rebase 到最新”怎样把吞吐打回去」) |
| “主干开发 = 直接 push main” | 主干开发(trunk-based)是短分支 + PR + 自动化门禁 + 特性开关。没有后两者,它就是“天天往主干扔半成品”,事故率反而更高 |
四种模型,一张表看全
| 模型 | 分支寿命 | 发布方式 | 在途分支 | 前提 | 适合 |
|---|---|---|---|---|---|
| 主干开发 trunk-based | < 1 天(多数当天合) | 主干随时可发 | 每人 0–1 条 | 特性开关、自动化测试、快速回滚 | 持续交付、SaaS、> 10 人团队 |
| GitHub flow | 1–3 天 | 合入主干即部署 | 每人 1 条 | CI 全绿才能合 | 大多数产品团队,与主干开发只差“开关纪律” |
| GitLab flow(环境分支) | 1–7 天 | main → pre → prod 逐级合并 | 每人 1–2 条 | 环境与分支一一对应 | 需要人工卡点、合规审批的发布 |
| git-flow | 数周–数月 | release 分支 + 多版本维护 | 每人 2–5 条 | 专职发布经理 | 客户端 / 固件 / 需同时维护 3 个老版本 |
三条判据帮你选:
- 能不能做到「主干随时可发」(含数据库变更可回滚)?能 → 主干开发或 GitHub flow;不能 → 先补自动化与开关,别先补分支模型。
- 是否需要同时维护多个已发布版本(v1 客户还在用、v2 在开发)?需要 → release 分支(不是 git-flow 全套餐,只留
release/x.y)。 - 在途 PR 数。超过 10 条,任何“合并前 rebase 到最新”的规则都会自我锁死,必须配 merge queue。
🔑 分支模型不是团队成熟度的奖杯,而是自动化水平的函数:测试越可信、回滚越快,分支就该越短。
冲突量随分叉宽度增长(本机实测)
合成仓库:200 个包,主干每天改 10 个热点包里的 3 个,功能分支改自己负责的 6 个包里的 2 个,两边并行推进 N 天后合并(seed 固定,可复现)。冲突文件数:
| 分支挂着的天数 | 主干新增提交 | 合并冲突文件数 | 合并结果 |
|---|---|---|---|
| 1 | 3 | 0 | 自动合并 |
| 2 | 6 | 1 | 冲突,需人工裁决 |
| 3 | 9 | 1 | 冲突,需人工裁决 |
| 5 | 15 | 2 | 冲突,需人工裁决 |
| 10 | 30 | 2 | 冲突,需人工裁决 |
| 20 | 60 | 6 | 冲突,需人工裁决 |
冲突数 ≈ 分支碰过的文件 ∩ 主干碰过的文件。主干改动量随时间线性增长,交集跟着涨,所以冲突量大致是“分支寿命 × 热点重合度”的函数——热点重合度高的分支(改公共库、改配置、改 schema)寿命必须按小时算,不能按天算。
比冲突文件数更贵的是冲突的语义:你解决的是“别人 20 天前的假设”。文本能自动合并、代码却不能一起跑的(语义冲突)不会被 git 标红,它只会在发布后表现为“某个功能悄悄坏了”。
返工模型的数字:同一份改动,挂 1 天 vs 挂 8 天
把上面的实测冲突数乘上单次冲突处理成本(定位 + 裁决 + 跑测试 + 再评审,假定 15 分钟/次),再乘上团队 PR 流量:
package main
import "fmt"
// 冲突数与分支挂着天数的关系,来自上面那张本机合成实验表。
// 真实仓库的绝对值会不同,趋势一致:分叉越宽,冲突越多。
var conflictsByAge = map[int]float64{1: 0, 2: 1, 3: 1, 5: 2, 10: 2, 20: 6}
func conflictsFor(age int) float64 {
if v, ok := conflictsByAge[age]; ok {
return v
}
best, bd := 0.0, 99
for k, v := range conflictsByAge {
d := k - age
if d < 0 {
d = -d
}
if d < bd {
best, bd = v, d
}
}
return best
}
// 单次冲突的处理成本:定位、裁决、跑测试、再评审。这是可替换的假设,不是实测值。
const minutesPerConflict = 15.0
const minutesPerRebase = 5.0
func perPR(ages []int) float64 {
t := 0.0
for _, a := range ages {
t += conflictsFor(a)*minutesPerConflict + minutesPerRebase
}
return t / float64(len(ages))
}
func avg(xs []int) float64 {
s := 0
for _, x := range xs {
s += x
}
return float64(s) / float64(len(xs))
}
func main() {
const prsPerMonth = 12 * 2 * 21 // 12 人 × 每天 2 个 PR × 21 个工作日 = 504
short := []int{1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 2} // 主干开发:多数当天合,少数隔夜
long := []int{1, 2, 5, 10, 10, 10, 20, 5, 3, 10, 2, 20} // 半年没清理的分支模型
for _, c := range []struct {
name string
ages []int
}{{"主干开发(分支 < 2 天)", short}, {"长分支(平均挂一周以上)", long}} {
p := perPR(c.ages)
fmt.Printf("%-26s 平均挂 %4.1f 天 · 每个 PR %2.0f 分钟返工 · 每月 %d 个 → %3.0f 小时\n",
c.name, avg(c.ages), p, prsPerMonth, p*prsPerMonth/60)
}
diff := perPR(long) - perPR(short)
fmt.Printf("\n每月 %d 个 PR 的前提下,两者返工差 = %.0f 人时/月(≈ %.1f 个全职人力)\n",
prsPerMonth, diff*prsPerMonth/60, diff*prsPerMonth/60/168)
}
输出(本机实测):
主干开发(分支 < 2 天) 平均挂 1.2 天 · 每个 PR 9 分钟返工 · 每月 504 个 → 74 小时
长分支(平均挂一周以上) 平均挂 8.2 天 · 每个 PR 39 分钟返工 · 每月 504 个 → 326 小时
每月 504 个 PR 的前提下,两者返工差 = 252 人时/月(≈ 1.5 个全职人力)
参数随时可替换:把 15 分钟改成你团队的真实处理时长,把 504 换成你的 PR 流量,结论的形状不变——分支寿命是这条公式里唯一能靠纪律和工具改小的因子,改动量和团队规模都不由你选。
“合并前必须 rebase 到最新”怎样把吞吐打回去
分支保护的 strict 模式(GitHub 的 “Require branches to be up to date before merging”,GitLab 的 “Pipelines must succeed” + semi-linear)本意是防止“在过期主干上测出的绿”。它的代价是一条乘法:
- 在途 PR = 20,每个 PR 的 CI = 15 分钟
- 每合并 1 个 PR,其余 19 个立刻变成“过期”,必须 rebase + 重跑
- 每天合 8 个 → 额外 19 × 8 = 152 次全量 CI = 38 小时机时/天
而这些重跑里,绝大多数 PR 的代码一行没变。合并队列(merge queue)就是为这件事发明的:它把要合并的 PR 排成一批,在最新主干上按顺序一次性验证整批,每个 PR 只测一次。
| 机制 | 每个 PR 的验证次数 | 20 个在途 PR、每天 8 次合并的日机时 | 失效方式 |
|---|---|---|---|
| strict(各自 rebase 到最新) | ~1 + 19 次无效重跑 | ≈ 38 小时 | rebase 风暴,CI 饱和,人开始点“重试”而不是看日志 |
| 合并队列(GitHub merge queue / GitLab merge trains) | 1(批次内连锁验证) | ≈ 2 小时 | 队列本身是单点,要监控队列深度 |
🔑 判据:在途 PR < 5 时用 strict 就够了;≥ 10 必须上合并队列。小团队硬上队列是纯负担(配置、权限、队列排障),大团队只靠 strict 必然堵死。
三个可观测指标,别靠感觉判断“分支策略好不好”
| 指标 | 怎么算 | 健康阈值 | 恶化意味着 |
|---|---|---|---|
| 分支寿命中位数 | 合并的 PR 从其首个提交到合入的时长 | < 1 天 | 需求切太大 / 评审排队太长(见 51.2 代码评审) |
| 在途 PR 数 | 打开的、待评审的 PR 数 | 每人 ≤ 1 | WIP 超载,返工与冲突同步上升 |
| 主干提交频率 | 每日合入主干的提交数 | ≥ 团队人数 | 集成风险在积压,发布时会集中爆发 |
这三个数都能从 git 与代码托管平台直接算出来,不需要额外埋点:git log --first-parent --format=%cI 给出主干节奏,PR 的 created_at / merged_at 给出寿命。
落地:指名 / 可抄 / 可验 / 可退
指名:git(≥ 2.38 才有 merge-tree --write-tree)、GitHub merge queue / GitLab merge trains(合并队列)、OpenFeature / Unleash(特性开关,主干开发的配套件)。版本化发布仍要用 release 分支的场景,见 55.1 发布策略。
可抄:
# ① 让 git 记住冲突解法:同一冲突第二次出现时自动复用(同一次 rebase 反复做时最值钱)
git config --global rerere.enabled true
# ② 禁止意外产生合并提交:非快进时直接失败,逼你 rebase(本地开发环境)
git config --global pull.ff only
# ③ CI 里预判冲突,不碰工作区、不产生合并提交(退出码 1 = 有冲突)
git fetch origin main
git merge-tree --write-tree origin/main HEAD
可验(本机真实输出,git 2.56.0):
$ git merge-tree --write-tree main feature # 有冲突:退出码 1
bb3ffdbceaba77a2f0dc678e3107ff715c099104
100644 a7334fdda5570f14c8fc1ed77d7f3da96f35ccc5 1 f1.txt
100644 f89eeac9e1ccae95b2defd668e25e9e0e3ce6a2c 2 f1.txt
100644 f24cd23a2da077383d93698e590bab52a5ffe08 3 f1.txt
自动合并 f1.txt
冲突(内容):合并冲突于 f1.txt
$ echo $?
1
$ git merge-tree --write-tree main clean # 无冲突:只输出一个 tree,退出码 0
27b239d73b2289b2f4d3e541d468f91f5e6588af
$ echo $?
0
$ git config pull.ff only && git pull --no-edit origin feature
致命错误:无法快进,中止。
$ echo $?
128
$ git merge hotfix # 第二次撞上同一个冲突
自动合并 config.ini
冲突(内容):合并冲突于 config.ini
使用之前的解决方案解决 'config.ini'。 # ← rerere 命中,内容已写好
$ cat config.ini
timeout = 400 # 人工裁决:300 的诉求 + 200 的基线
$ git status --short
UU config.ini # ← 仍要 git add:rerere 只写内容,不替你签收
两个细节别漏:merge-tree 的退出码就是“能不能自动合并”的布尔值,可以直接当 CI 门禁;rerere 命中后文件仍是 UU(未解决)状态,必须 git add 走一遍人工确认——它省的是“想”,不是“看”。
可退:主干开发的退路不是“回滚代码”,而是三件事各就各位——① 功能级:开关一键关(kill switch),代码留在主干不留故障;② 提交级:git revert --no-edit -m 1 <merge-sha> 撤掉整次合并(本机验证:revert 后文件回到合并前内容),比 reset --hard 安全,因为它不改写历史;③ 发布级:从 tag 拉 hotfix/x.y 分支,修完同时回灌主干(发布级回滚见 55.3 回滚设计)。
反模式
| 反模式 | 后果 |
|---|---|
| 按需求开分支、需求做完才合(寿命数周) | 每个 PR 都在替别人付 rebase + 重跑 CI 的钱,冲突裁决基于过期假设 |
strict + 20 个在途 PR,没有合并队列 | CI 被无效重跑打满,红灯变成噪音,人开始无脑重试 |
用 git merge 合并主干到功能分支(反向合并) | 历史里出现大量“合并 main into feature”,git log 失真,回滚粒度变糊 |
| 长分支 + 每周一次“大合并日” | 冲突集中爆发,集成风险变成发布风险;出问题时不知道是哪次改动 |
| 主干开发却没有特性开关 | 半成品直接对用户可见,等于把未完成功能当故障发布 |
用 reset --hard 回滚已推送的主干 | 其他人的本地历史被改写,下一次 pull 制造重复提交 |
| 分支命名当规范、分支寿命没人管 | 命名规范做到了,成本一分没省 |
自检
Q1 团队里有人说:“我们分支规范很严,每个需求一条 feature/xxx,合并前必须 rebase 到最新。”这条规则在什么规模下会开始伤团队?怎么算?
▸ 答案:受伤点不在规范本身,而在“rebase 到最新 + 全量 CI”与在途 PR 数的乘法。每合并 1 个 PR,其余 N-1 个全部失效;日重跑机时 ≈ (N-1) × 每日合并数 × CI 时长。N=20、每天合 8 个、CI 15 分钟时,一天多烧 38 小时机时,且这些重跑里代码一行没变。判据:在途 PR ≥ 10 就该上合并队列,把“每个 PR 各自验证”换成“整批连锁验证”。规则本身没错,错在没有配套机制。
Q2 实测里“分支挂 20 天”冲突 6 个,“挂 1 天”冲突 0 个。为什么冲突数不是随天数线性增长?
▸ 答案:因为冲突数的上界是交集,不是时间:冲突文件数 ≤ min(分支改过的文件数, 主干改过的文件数)。分支只碰 6 个包,所以冲突数封顶在 6;天数的作用是把“主干恰好改到这 6 个包”的概率从 0 推到 1。这也解释了为什么改公共库/公共 schema 的分支必须按小时算寿命——它们的分母(自己碰的文件数)大,交集很快饱和。反过来,只改一个新目录的分支,挂久一点冲突也不多,但它的语义风险(别人基于旧假设写的代码)仍在涨。
Q3 主干开发和 GitHub flow 的差别到底在哪?只差一个名字吗?
▸ 答案:机制上几乎一样(短分支 + PR + CI 门禁),差别在**“主干是否随时可发”这条不变量由谁保证**。GitHub flow 允许“合并即部署”,主干短暂处于不可发状态是可接受的;主干开发要求每个提交都可发,因此未完成的功能必须藏在开关后,数据库变更必须 Expand-Contract 可回滚。换句话说:GitHub flow 靠发布流程兜底,主干开发靠代码结构兜底。团队做不到“提交即安全”,就别自称主干开发。
Q4 rerere 明明记住了冲突解法,为什么 git status 还是显示 UU?
▸ 答案:rerere 只负责把“上次的解决结果”写回工作区文件,它不替你做决定——冲突是否真的按同样方式解决,需要人再看一眼(尤其是上游语义变了的时候)。所以文件保持未解决状态,必须 git add 才进入暂存。这个设计是对的:自动复用解法能省 80% 的机械劳动,但不该省掉“确认”这一步,否则错误解法会被无声地复制到更多分支。
Q5 一个 40 人的团队,主干每天合 30 次,突然要求“所有 PR 必须由两人评审 + 必须 rebase 到最新”。最可能先崩的是哪个环节?给出你的判断依据。
▸ 答案:先崩的是 CI 与评审队列的交叉点,不是评审本身。30 次合并/天 × 平均 20 个在途 PR,意味着每个 PR 每天被“作废”约 30 次中的若干次;重跑 CI 的机时很快超过可用 runner,红灯率上升,评审者开始对“又红了”脱敏,转而点重试。判据是两个比值:CI 需求机时 / 可用机时 与 评审到达率 / 评审吞吐(后者见 51.2 代码评审 的排队模型)。修法有顺序:先上合并队列(消除无效重跑),再降 PR 尺寸(降评审吞吐压力),最后才谈加人。