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 个老版本

三条判据帮你选:

  1. 能不能做到「主干随时可发」(含数据库变更可回滚)?能 → 主干开发或 GitHub flow;不能 → 先补自动化与开关,别先补分支模型。
  2. 是否需要同时维护多个已发布版本(v1 客户还在用、v2 在开发)?需要 → release 分支(不是 git-flow 全套餐,只留 release/x.y)。
  3. 在途 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 尺寸(降评审吞吐压力),最后才谈加人。

关联