Git:Merge、Rebase 与分支策略

同一批代码改动,merge 合并和 rebase 合并,最终代码结果完全一样——但历史看起来截然不同。这不是两种做法孰优孰劣的问题,是两种不同的历史观:merge 认为历史是客观发生的事实,rebase 认为历史是写给人读的文档。这个分歧往上推一层,就分化出三种分支策略,每种策略背后是对”发布模型是什么”的不同回答。

历史应该是什么

两个人从同一个 commit 出发,分别在自己的分支上工作了三天,各自提交了若干次。现在要把两份工作合并回主干。

merge 的做法是创建一个新的 commit,它有两个父节点,分别指向两条分支的末端。历史图上的分叉和汇合被完整保留——能看出谁在哪一天做了什么、两条开发线在哪里交汇。这份历史是真实发生过的事情的记录。

rebase 的做法是把其中一条分支上的 commit 挪到另一条的末端,重新排成一条直线。最终看起来像是所有人按顺序依次提交,从未分叉过。历史更干净,但它是经过整理的叙事,不是原始事实。

这两种做法都是合理的,但它们在回答不同的问题。merge 回答的是”发生了什么”,rebase 回答的是”应该让读者看到什么”。选哪种,取决于你认为代码历史首要的作用是记录事实还是传递信息。

Merge:保留事实

merge 有两种情况,取决于两条分支有没有分叉。

Fast-forward:如果被合并的分支是从当前分支的最新 commit 往前延伸的,中间没有分叉,Git 可以直接把当前分支的指针向前移动,不需要创建新的 commit。这是最干净的合并,历史保持线性,但代价是失去了”这段工作曾经在独立分支上完成”的记录。

git merge feature          # 如果可以 fast-forward,默认就会 fast-forward
git merge --no-ff feature  # 强制创建 merge commit,即使可以 fast-forward

--no-ff 在团队工作流里很常见,原因是它保留了分支存在过的痕迹:这批提交是作为一个整体被合并进来的,日后可以用一行 git log --merges 看到所有集成点。

3-way merge:如果两条分支都有各自的新提交,Git 找到它们的共同祖先,对比三份快照(共同祖先、当前分支末端、被合并分支末端),推算出合并结果。推算不出来的地方就是冲突,需要手动解决。解决完之后提交,产生一个有两个父节点的 merge commit。

3-way merge 的历史图是真实的,但在一个活跃的多人仓库里,频繁的合并会让历史图变成网状——大量的 merge commit 穿插其中,用 git log 滚动时很难追踪一条功能线的完整脉络。这是 merge 模型的固有代价,不是 bug。

Rebase:重写叙事

rebase 的字面意思是”重新定基”。git rebase main 做的事情,不是把 feature 分支的 commit 搬到 main 上,而是以 main 的最新 commit 为新的起点,把 feature 上每一个 commit 依次在这个起点上重新创建。旧的对象留在 objects/ 里,新的对象有全新的 SHA。结果是线性历史,但这段历史是重建出来的,不是原始的。

git switch feature
git rebase main     # 把 feature 上的 commit 重建到 main 的末端

rebase 过程中如果遇到冲突,Git 会暂停在冲突的那个 commit 处,等待手动解决:

# 解决冲突后
git add <conflicted-file>
git rebase --continue   # 继续处理下一个 commit
git rebase --abort      # 放弃,回到 rebase 前的状态

rebase -i 是 rebase 更常用的形态——交互式 rebase,用来整理还没有 push 出去的本地提交历史:

git rebase -i HEAD~4   # 交互式编辑最近 4 个 commit

打开一个编辑器,列出每个 commit 及其操作指令:pick(保留)、squash(压缩进上一个)、reword(只改提交信息)、drop(删除)。整理完保存,Git 按指令重建这段历史。在提 PR 之前把一堆 WIP commit 压缩成几个语义清晰的提交,是 rebase -i 最典型的用法。

黄金法则:只 rebase 本地还没有 push 的提交。一旦 commit 推到了远程,其他人可能已经在它的基础上工作。rebase 之后这批 commit 的 SHA 全变了,强推会让其他人的历史出现分歧——他们本地的分支还指向旧的 SHA,而远程已经是新的。这不是规范问题,是对象模型决定的:rebase 创建了新对象,旧对象和新对象在数学上是两批不同的东西。

选择的依据

merge 和 rebase 不是互斥的。大多数工作流在不同的边界上同时使用两者:

私有的本地分支,还没有推到远程,只有自己在用——可以随意 rebase,整理成干净的线性历史再推出去。

共享分支,已经有其他人在基础上工作——用 merge,不改写任何已有历史。

PR 合并进主干这个动作本身,不同团队有不同偏好:squash merge(把整个 PR 压成一个 commit)、rebase merge(线性追加,不产生 merge commit)、普通 merge commit(保留分支记录)。这三种选择在 GitHub 和 GitLab 的合并界面上都有按钮,本质上是三种对”主干历史应该长什么样”的不同答案。

三种分支策略

merge 和 rebase 是操作层面的选择,分支策略是工程层面的决策——团队应该怎么组织分支、什么时候合并、谁有权合并到哪里。分支策略决定了开发、测试、发布之间的协作边界。

Git Flow:管理版本

Git Flow 的核心是两条长生命周期的主干:main(或 master)永远指向最新的生产版本,develop 是集成分支。功能开发在 feature/* 上进行,完成后合并回 develop;发布时从 develop 拉出 release/* 分支做最后的测试和修复,完成后同时合并进 maindevelop;生产紧急修复在 hotfix/* 上进行,完成后同时合并进两条主干。

这个模型适合有明确版本号的软件——桌面应用、SDK、固件、游戏。版本之间有清晰的边界,不同版本可能需要并行维护。代价是分支多、合并频繁、发布流程重,任何改动都要在多条分支之间同步。对于每天部署的 Web 服务,这套流程会成为明显的摩擦。

GitHub Flow:管理部署

GitHub Flow 极简:只有 main 一条长生命周期分支。功能开发在短生命周期的 feature 分支上进行,完成后提 PR,代码审查通过、CI 绿了,合并进 main,自动触发部署。没有 develop,没有 releasemain 始终是生产就绪的状态。

这个模型的前提是:有可靠的 CI、快速的测试覆盖、部署流程自动化。如果合并进 main 不能立刻自动部署,或者测试跑一次要半小时,GitHub Flow 的节奏就很难维持。它适合持续交付的 Web 服务,不适合需要显式版本管理的产品。

Trunk-Based Development(TBD):管理集成

TBD 把集成的粒度推到极限:所有开发者直接向主干(maintrunk)提交,或者使用极短生命周期的分支(通常不超过一天)。功能开关(Feature Flag)控制未完成的功能对用户不可见,而不是靠分支隔离。

TBD 解决的问题是”集成地狱”:分支存在越久,合并时的冲突越大,集成的代价越高。让所有人在同一棵树上工作,集成变成了持续发生的小事,而不是阶段性的大事。代价是对工程能力的要求很高:特性开关的管理、每次提交都要能通过 CI、开发者需要习惯用未完成的代码和别人的工作共存。Google、Meta 的大型单体仓库用的是这个模型的变体。

怎么选

这三种策略没有绝对的优劣,约束决定选择:

发布频率低、需要管理多个并行版本——Git Flow。发布频率高、基础设施成熟、团队有 DevOps 能力——GitHub Flow。规模很大、追求极快的集成速度、有能力管理 Feature Flag——TBD。

大多数中小团队从 GitHub Flow 开始是合理的:足够简单,门槛不高,能随着团队能力的增长逐步向 TBD 靠近。Git Flow 在明确需要版本管理之前不必引入,它的复杂度在不需要它的场景里只是噪音。

几个在这个层面常见的操作:

# cherry-pick:把某个 commit 单独摘到当前分支
git cherry-pick <commit-sha>
git cherry-pick A..B       # 摘取一段范围(不含 A,含 B)
 
# 查看哪些 commit 还没有合并进 main
git log main..feature --oneline
 
# 查看两条分支的分叉点
git merge-base main feature

关联