从 git flow 切到主干开发的半年

·约 1600 字 · Git工程实践

今年二月我们把分支模型从 git flow 换成了主干开发。到现在半年,可以说说效果和代价了。

受不了的是什么

git flow 本身没有错,它是为「有明确版本号、需要同时维护多个已发布版本」的软件设计的。问题在于我们做的是一个每周发两三次的内部 SaaS,根本用不上那套结构,却背着它全部的成本:

  • developmain 长期分叉,谁也说不清线上到底是哪个 commit。
  • 特性分支平均存活 11 天。合并时冲突量大到需要专人处理,有几次冲突解错,把别人的改动覆盖了。
  • release 分支上修的 bug 要往回 cherry-pick 到 develop,经常漏。有个空指针在 release 上修了三次,因为每次都忘了回合。
  • 「等下个 release 一起发」变成了默认答案,一个两行的文案修改要等九天才上线。

压垮骆驼的是一次大重构:一条分支开了六周,最后合并花了三天,合完 QA 又跑出十几个回归问题。那之后我们决定换。

主干开发到底约束了什么

名字容易引起误解,它不是「大家都往 main 上直接 push」。真正的约束只有两条:

  1. 只有一条长期分支main),它随时可发布。
  2. 所有分支的存活时间不超过一到两天,超过就必须先合回去。

第二条是核心。分支越短,冲突越少;冲突越少,合并越轻松;合并越轻松,大家越愿意频繁合——这是个正循环。反过来也成立,这也是 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 平均改动行数640180
每月发布次数423
提交到上线的中位时间9 天1.5 天
发布回滚次数(半年)35

回滚次数变多了,但每次影响面小得多——因为一次发布只包含少量变更,出问题时定位和回退都很快。故障平均恢复时间从 52 分钟降到 11 分钟。这正是我们想要的交换。

什么情况下不建议这么做

主干开发有明确的前置条件,缺一个都会很难受:

  • 需要长期维护多个版本——比如私有化部署、客户还在跑一年前的版本,那你确实需要 release 分支。
  • 没有像样的自动化测试——主干开发会把测试缺失的问题放大,而不是掩盖。
  • 发布需要人工审批且周期固定——比如受合规约束一个月只能发一次,那频繁合入的收益体现不出来。

我们三条都不占,所以切得比较顺。如果占了其中一条,先解决那个问题,再谈分支模型。

← 返回首页