今年二月我们把分支模型从 git flow 换成了主干开发。到现在半年,可以说说效果和代价了。
受不了的是什么
git flow 本身没有错,它是为「有明确版本号、需要同时维护多个已发布版本」的软件设计的。问题在于我们做的是一个每周发两三次的内部 SaaS,根本用不上那套结构,却背着它全部的成本:
develop和main长期分叉,谁也说不清线上到底是哪个 commit。- 特性分支平均存活 11 天。合并时冲突量大到需要专人处理,有几次冲突解错,把别人的改动覆盖了。
- release 分支上修的 bug 要往回 cherry-pick 到 develop,经常漏。有个空指针在 release 上修了三次,因为每次都忘了回合。
- 「等下个 release 一起发」变成了默认答案,一个两行的文案修改要等九天才上线。
压垮骆驼的是一次大重构:一条分支开了六周,最后合并花了三天,合完 QA 又跑出十几个回归问题。那之后我们决定换。
主干开发到底约束了什么
名字容易引起误解,它不是「大家都往 main 上直接 push」。真正的约束只有两条:
- 只有一条长期分支(
main),它随时可发布。 - 所有分支的存活时间不超过一到两天,超过就必须先合回去。
第二条是核心。分支越短,冲突越少;冲突越少,合并越轻松;合并越轻松,大家越愿意频繁合——这是个正循环。反过来也成立,这也是 git flow 在我们这里失控的原因。
难点:功能没做完怎么合
这是所有人的第一个疑问,答案是把「代码合入」和「功能可见」拆开。
最轻量的手段是先合基础设施再合业务逻辑。一个新功能通常包含数据库迁移、接口、前端。前两者对用户不可见,可以先合,最后只剩很小的一块开关。
真的需要开关时,我们用了一个很朴素的实现——没有引入任何第三方平台:
// src/flags.ts
type FlagName = 'new-billing-page' | 'batch-export' | 'v2-search';
const defaults: Record<FlagName, boolean> = {
'new-billing-page': false,
'batch-export': false,
'v2-search': true,
};
export function isEnabled(name: FlagName, ctx: { userId?: string } = {}) {
const override = process.env[`FLAG_${name.toUpperCase().replace(/-/g, '_')}`];
if (override === '1') return true;
if (override === '0') return false;
if (ctx.userId && internalUsers.has(ctx.userId)) return defaults[name] || betaFlags.has(name);
return defaults[name];
}
类型约束住了 flag 名字,写错会编译失败。环境变量可以在不发版的情况下临时切换。内部用户能提前看到未发布的功能,这一点意外地好用——产品经理不用再等发布才能验收。
代价是要清理。我们的规矩是开关全量之后两周内必须删掉,代码评审里专门有一条检查项。有一个 flag 拖了两个月没删,后来重构那块代码时果然踩了坑。
发布靠 tag,不靠分支
切换之后,main 上每一个通过 CI 的 commit 都是候选版本。发布只是给某个 commit 打 tag:
git switch main
git pull --ff-only
git tag -a v2025.08.21 -m "批量导出、账单页改版"
git push origin v2025.08.21
CI 检测到 tag,构建镜像并部署。镜像标签直接用 git SHA,任何时候都能反查线上代码:
kubectl get deploy api -o jsonpath='{.spec.template.spec.containers[0].image}'
# registry.example.com/api:9c4f1e2
需要给某个已发布版本打补丁时,才临时从 tag 拉一条分支,修完发新 tag,然后立刻删掉分支:
git switch -c hotfix-v2025.08.21 v2025.08.21
# 修复并提交
git tag -a v2025.08.21.1 -m "修复导出任务重复触发"
git push origin v2025.08.21.1
git switch main && git cherry-pick hotfix-v2025.08.21
git branch -d hotfix-v2025.08.21
关键是 cherry-pick 的方向:从修复分支回到 main,而且当天完成。git flow 时代的痛苦来自反向 cherry-pick 和延迟回合。
CI 门禁必须跟上
主干开发把质量责任全部压在了合入前的检查上。如果 CI 不可靠,main 会天天是坏的。我们做了三件事:
- 把 CI 时间压到 6 分钟以内。 超过 10 分钟,人就会开始并行做别的事,然后忘记回来看结果。主要靠测试并行化和依赖缓存。
- 开启合并队列。 两个 PR 各自都通过、合起来却挂掉的情况不算罕见(典型是一个改了函数签名、另一个新增了调用)。合并队列会按顺序在真实的合并结果上重跑 CI。
- main 挂了就地停车。 群里有机器人播报,谁都不许在红色的 main 上继续合。修复优先,超过 15 分钟修不好就 revert,先绿起来再说。
revert 这件事需要专门做心理建设。一开始大家把它当成事故,觉得丢脸,宁可硬修。后来我们明确说 revert 是常规操作、不进事故统计,才真的用起来。
迁移当天做了什么
其实很简单,花了一个下午:
# 1. 确认 develop 已全部合入 main
git log --oneline main..develop
# 2. 合并并删除 develop
git switch main
git merge --no-ff develop -m "merge develop into main, 切换主干开发"
git push origin main
git push origin --delete develop
# 3. 收紧保护规则(GitHub CLI)
gh api -X PUT repos/:owner/:repo/branches/main/protection \
-f required_status_checks[strict]=true \
-f required_pull_request_reviews[required_approving_review_count]=1 \
-f enforce_admins=true
真正花时间的是之后两周的习惯纠正:拆小 PR、当天合并、别攒着。这个只能靠代码评审时反复提醒。
半年后的数字
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
| 分支平均存活 | 11 天 | 1.3 天 |
| PR 平均改动行数 | 640 | 180 |
| 每月发布次数 | 4 | 23 |
| 提交到上线的中位时间 | 9 天 | 1.5 天 |
| 发布回滚次数(半年) | 3 | 5 |
回滚次数变多了,但每次影响面小得多——因为一次发布只包含少量变更,出问题时定位和回退都很快。故障平均恢复时间从 52 分钟降到 11 分钟。这正是我们想要的交换。
什么情况下不建议这么做
主干开发有明确的前置条件,缺一个都会很难受:
- 需要长期维护多个版本——比如私有化部署、客户还在跑一年前的版本,那你确实需要 release 分支。
- 没有像样的自动化测试——主干开发会把测试缺失的问题放大,而不是掩盖。
- 发布需要人工审批且周期固定——比如受合规约束一个月只能发一次,那频繁合入的收益体现不出来。
我们三条都不占,所以切得比较顺。如果占了其中一条,先解决那个问题,再谈分支模型。