Git 合并这件事,十个人里有八个是瞎用的。我说的瞎用,不是说不会敲命令,而是压根没搞懂 merge 和 rebase 背后的逻辑差异。有人看到分支就 rebase,把别人的提交历史搅成一团乱麻;有人永远只用 merge,几条分支合下来,git log --graph画出来像一张蜘蛛网。这篇文章把 Merge 和 Rebase 的底层原理、适用场景、冲突处理和回退操作一次讲透。刚入门的同学可以借它建立正确的认知框架;已经在这两个命令上踩过坑的,也能从后面的零坑操作流程里找到自己一直缺的那块拼图。
1. 先搞清楚提交历史的真实结构
1.1 commit 不是快照,而是一条带指针的链
很多人对 Git 的第一个误解,就是把 commit 当成"文件的快照"。
实际上,一个 commit 对象里保存的内容包括:当前版本树(tree)的哈希、作者信息(author)、提交者信息(committer)、提交说明(message),以及最关键的一样东西——父提交(parent)的哈希。每个 commit 都指向它的父提交,多个 commit 通过这种父子关系连成一条单向链,这条链就是所谓的"历史"。
值得多说一句的是,commit 哈希的计算范围包含了父提交的哈希。也就是说,只要父提交变了,当前提交的哈希就一定会跟着变。这个特性是理解 rebase"重写历史"的钥匙,后面我会反复用到它。
既然提交之间是靠引用连接的,Git 的真实数据结构就远不是"若干个文件版本"那么简单,而是一张有向无环图(DAG)。平时最常见的直线历史只是这张图的一个特例:每个节点只有一个父节点。一旦两个分支合并,Git 会生成一个有两个父节点的 merge commit,历史就从直线变成了分叉再汇合的形状。理解了 DAG 这个概念,Merge 和 Rebase 到底在折腾什么,就成功了一半。
1.2 分支的本质:一个会移动的指针
再往深挖一层。分支本质上不是一个文件夹,也不是一份代码副本,它只是一个 40 位的 SHA-1 哈希指针,存放在.git/refs/heads/目录下,指向某个 commit。
你切到 dev 分支开始干活,每提交一次,dev 这个指针就往前挪一格,指向最新的提交。真正的提交对象都躺在.git/objects里,分支只是帮你"记住"当前推进到了哪个位置。HEAD 则是指向当前分支的一个符号引用,表示"我现在在哪个分支上"。
这个认知的意义在于:既然分支本身这么轻量,"合并两个分支"这件事,本质上就是在问——我要往这张 DAG 里加一个什么样的节点,才能把两条线的内容汇合到一起?Merge 和 Rebase 给出的答案完全不同,这正是它们产生分歧的根本原因。
1.3 为什么历史形态会成为团队争论的焦点
提交历史在团队协作里的价值,远比很多人想的重要。
它是代码审查(code review)的依据。Reviewer 看一个 MR 时,希望看到的是"这个功能分了几步实现、每步改了什么",而不是几十个写着"update""fix"的提交堆在一起。它也是版本回退的路线图。线上出了问题,你得靠历史定位"哪次提交引入了这个 bug"。它还是新人理解项目演进的索引,一条清晰的历史能在几分钟内讲明白项目的来龙去脉。
所以 merge 派和 rebase 派的争论,本质上是两种价值观的碰撞:一派认为"历史必须真实记录发生过的一切,包括分支何时分叉、何时合流";另一派认为"历史应该是干净的线性叙事,中间的分叉信息对后来者没有价值"。两种说法都有道理,但没有任何一种能放之四海而皆准。下面的章节我会把两者的机制讲透,再结合场景给出选择建议。
2. Merge 的底层逻辑:三方合并与合流节点
2.1 三方合并是怎么算出来的
Merge 的核心机制是三方合并(three-way merge)。
假设 main 和 feature 两个分支在 commit C 处分离,之后 main 上多了 M1、M2,feature 上多了 F1、F2。现在要把 feature 合并回 main,Git 不会简单粗暴地把两边文件"拼在一起",而是需要三个状态:
- 共同祖先 C 时的文件版本(base)
- main 当前最新版本 M2(ours)
- feature 当前最新版本 F2(theirs)
Git 以 base 为基准,分别算出"从 base 到 M2 改了什么"和"从 base 到 F2 改了什么",然后把两边不冲突的改动都应用到最终结果上。这就是三方合并名字的由来——它靠的是三方对比,而不是两方覆盖。
实际操作里最常见的麻烦是:同一块代码在两个分支上都被改了,而且改的内容不一样。这时候 Git 无法判断谁的改动才是正确的,只好停下来,把冲突标记直接写进文件,等你手工裁决。这种冲突不是"合并失败了",而是"自动合并遇到了无法自动决策的边界情况",需要人工介入。
2.2 merge commit 的意义:把合流这个事实写进历史
合并过程顺利结束时,Git 会生成一个新的提交,这个提交有两个父节点:一个指向合并前 main 的 tip,一个指向 feature 的 tip。这个 merge commit 记录了"两个分支曾经合流过,合流后的代码状态是什么样"。
这正是 merge 被"历史真实派"拥护的原因。后人在翻看git log --graph时,能清晰看到合流点,顺着任何一个父节点都能追溯某段代码到底是从哪条线进来的。对于需要长期维护的分支、需要支撑多个发布版本的项目,这种可追溯性非常宝贵。
代价也很明显:当合流频繁发生时,提交历史会充满分叉和交叉。几十个 merge commit 叠加起来,图形像一张蜘蛛网,普通开发者很难快速抓住主干脉络。
2.3 快进合并与 --no-ff 的正确选择
如果合并时目标分支没有产生任何新提交,Git 会执行快进合并(fast-forward)。
所谓快进,就是把 main 的指针直接挪到 feature 的 tip 上。这个过程中不产生 merge commit,历史保持直线,看起来就像 feature 的提交直接长在 main 上。这个模式的优点是历史整洁;缺点是合流的事实消失了——后人无法从历史里判断这些提交究竟是直接提交在主线上,还是从一个分支合进来的。
所以很多团队对公共分支合并强制使用git merge --no-ff,刻意生成一个 merge commit,把分支存在的痕迹保留下来。比如发布分支合并回 main、大型 feature 分支合并回 develop,都建议加--no-ff。
实践建议:合入公共分支时用--no-ff,保留合流节点;合入个人临时分支时,用不用--no-ff差别不大,跟团队规范走就行。
2.4 merge 的可重复性与安全性
Merge 还有一个容易被忽略但很重要的特性:操作可重复、可恢复。
在执行git merge的过程中途,任何一步出错,都可以用git merge --abort干净利落地回到合并前的状态。因为 merge 不会改动任何已有 commit,它只是试图创建一个新节点。这一点让 merge 成为默认"更安全"的那种操作,尤其是对新手来说。
3. Rebase 的底层逻辑:变基与重放
3.1 rebase 到底做了什么
Rebase 的中文翻译是"变基",意思是改变基点。它和 merge 解决问题的思路完全不同。
假设 feature 是从 main 的 C 点分出来的,之后 main 上产生了新提交 M1、M2。你执行git rebase main,Git 会做三件事:
- 找到 feature 和 main 的共同祖先 C;
- 把 C 到 feature 最新提交之间的所有提交"摘下来",暂存在一个临时区域;
- 以 main 当前最新的 M2 为新的基点,把这些提交一个一个重新应用到 M2 后面。
注意"重新应用"这个词——它不是搬运,而是基于每个提交的补丁(patch)在新基点上重新生成一次提交。这个动作的代价是:feature 上每个提交都会产生新的哈希值。你原来的提交对象还在仓库里,只是没有任何分支指向它们了,过一段时间会被 Git 的垃圾回收机制清理掉。
3.2 为什么 rebase 会改写历史
前面提过,commit 哈希的组成部分包括父提交哈希、树对象哈希、作者信息和提交信息。rebse 重放每一个提交时,父提交变成了新基点(比如 M2),树对象也会因为冲突处理而发生变化。新的哈希和原来的哈希必定不同。
所以"rebase 改写历史"不是一种夸张的说法,而是一个技术事实。这带来一个很重要的推论:你只在自己的私有分支上 rebase,历史重写完全没问题,反而能得到干净的线性历史。但如果这个分支已经被推送到了远程、被同事拉取过、甚至有人基于它开了新分支,rebase 之后,同事的本地历史和你远程的新历史会对不上,别人再 push 就会被拒绝,而你只能通过强推覆盖别人的提交来"解决",这就把个人操作升级成了团队事故。
3.3 交互式 rebase:整理提交的利器
Rebase 真正的威力在交互式模式:git rebase -i。
它允许你在重放之前编辑"待办清单",常用的指令包括:
pick:保留这个提交;squash:把这个提交合并到上一个提交里;fixup:类似 squash,但丢弃这个提交的提交信息;reword:修改提交信息;edit:停下来让你修改内容或拆分提交;drop:彻底丢弃这个提交。
一个典型场景:你开发一个功能,本地提交了 8 次,提交信息分别是"wip""改一下""再改一下""终于好了"。这种历史直接推到远程,reviewer 会崩溃。用git rebase -i HEAD~8,把 8 个提交压缩成 2-3 个逻辑完整的提交,再推上去,审查体验完全不同。
另外,git commit --amend也是整理历史的常用工具。它能把当前工作区的改动并入最近一次提交,同时允许修改提交信息。注意 amend 同样会生成新的哈希,已经推送过的提交千万不要 amend,道理和 rebase 一样,会产生历史分叉。
3.4 黄金法则:绝不对公共分支执行 rebase
这条规则是整个 rebase 话题里最重要的一条,没有之一。
判断标准非常简单:只要某个分支上还有别人在基于它工作,就不要对它执行 rebase,更不要对它的远程版本做 force push。公共分支的历史代表的是团队协作的共识,你一改,所有人的共识就崩了。GitHub 官方文档里那句"从 rebase 中恢复比从 merge 中恢复难得多",我实际操作过之后才真正理解。
4. 场景对比:什么时候用 merge,什么时候用 rebase
4.1 功能分支开发:rebase 让历史保持线性
如果你自己维护一个 feature 分支,基点是 main,那么开发期间周期性执行git rebase main,能让你的分支始终保持基于最新代码的状态,同时提交历史是一条干净的直线。合并回 main 时可以快进,整个协作过程没有任何分叉。
这种模式是 GitHub Flow 和 GitLab Flow 里最主流的做法。代码审查时,reviewer 看到的提交差异是明确的功能增量,而不是一条被各种合并行为搞碎的碎片历史。
我个人的建议是:feature 分支 rebase 到 main 的频率不要低于每天一次。频率太低,意味着一次性重放大量提交,冲突会堆成一座山;一天一次能把冲突控制在几个文件以内,处理起来很轻松。
4.2 公共分支合流:merge --no-ff 是标准姿势
只要涉及 main、develop、release 这类公共分支,merge 永远比 rebase 安全。
公共分支的历史是整个团队共同维护的资产,随意改写会带来灾难性的协作成本。用git merge --no-ff生成明确的合流节点,既保留了"合流这个事实",回退时也能准确定位"合流造成的变更范围"。
这里要特别区分两种操作:把 feature 合并进 main,和把 main 合并进 feature,是完全不同的两件事。前者是发布行为,后者是同步行为。发布行为适合 merge,同步行为更适合 rebase——我早期经常混用这两者,吃了不少亏才把逻辑理清。
4.3 本地提交整理:rebase -i 是零风险操作
对于"还没推到远程的本地提交",任何整理动作都是零风险的。压缩、拆分、改信息、排序、丢弃,都可以放心交给git rebase -i。
这也解释了为什么 rebase 在社区里口碑两极分化:用对了场景,它是提升历史质量的神器;用错了场景(动的公共分支),它就是制造混乱的元凶。工具无罪,场景判断才是关键。
4.4 主流工作流的选择逻辑
不同团队对历史形态的偏好,直接决定了 merge 和 rebase 的使用配比。
| 工作流 | 历史风格 | 合并策略 | 适合团队 |
|---|---|---|---|
| Git Flow | 重真实,保留所有合流节点 | 处处merge --no-ff | 需要多版本并行发布的团队 |
| GitHub Flow | 重简洁,feature 历史尽量收敛 | rebase 保持最新,合并用 squash | 快速迭代的互联网团队 |
| Trunk-Based Development | 重短反馈,几乎不保留 feature 分支 | 小步 rebase,频繁合入主干 | 追求持续交付的团队 |
没有哪个工作流是"标准答案"。核心问题是:你的团队更需要可追溯的真实历史,还是更易读的线性历史?想清楚这一点,merge/rebase 的用法就自然定下来了。
5. 零坑操作指南:从合并前到回退
5.1 合并前必须做的三件事
不管走 merge 还是 rebase,动手之前先确认三件事,能挡掉 80% 的意外:
第一,工作区必须干净。git status里不能有未提交的改动。否则 rebase 会直接拒绝执行(提示 you have unstaged changes),merge 则可能把你正在改的文件卷进冲突里。第二,目标分支要最新。先git fetch,再确认你要合并或变基到的分支不是过时版本。第三,提前看看分叉规模。用git merge-base A B查看两个分支的共同祖先,再对比两边的提交量,能大致预判冲突规模。
还有一个成本极低但价值极高的习惯:开始大型操作之前,先建一个备份分支,比如git branch backup/feature-xxx。这把你的当前状态完整"拍照"了一份,操作翻车时能秒级恢复,比操作完再后悔强一百倍。
5.2 冲突处理:一套不慌的完整流程
冲突发生并不可怕,可怕的是没有一套固定流程,全靠现场瞎试。我的固定流程是这样的:
- 执行
git status,查看所有处于冲突状态的文件; - 逐个打开,搜索
<<<<<<<、=======、>>>>>>>三组标记; - 理解上下两段分别来自哪条分支、各自是什么逻辑;
- 决定保留哪一边,或者手动把两边逻辑融合到一起;
- 处理完一个文件就
git add一个; - 全部处理完,执行
git merge --continue或git rebase --continue; - 最后执行
git status确认没有遗留,再用git log看一遍新生成的历史。
几个容易踩的坑值得单独提醒:第一,冲突标记里的代码不是简单的二选一,经常需要你两边都保留一部分,再补上新逻辑。第二,别用git checkout --ours或--theirs无脑选一边,除非你确认另一边确实不需要。第三,rebase 冲突处理完后忘记git add,continue 时报错会让你瞬间怀疑人生,实际上只是没暂存而已。
5.3 合并或变基之后,如何回退到安全状态
方向搞错了怎么办?关键看操作是否已经推送到远程。
如果是纯本地操作,git reflog是你最强的后悔药。它记录了本地每一次分支移动,哪怕 rebase 重写了整个历史,reflog 里依然能找到操作之前的那个 commit。找到之后,git reset --hard <hash>就能完整回到过去。千万不要小看这条命令,我见过太多人因为不知道 reflog 而在 reset 之后彻底丢失工作。
如果合并已经推送到了远程分支,处理方式完全不同。对公共分支绝不能reset后强推,正确做法是git revert -m 1 <merge-commit-hash>。-m 1表示以 merge commit 的第一父节点为基础,也就是保留主线的历史,把合入的内容撤销掉。这个操作会生成一个新的"撤销提交",合流记录被保留,历史没有被改写,协作不会崩坏。
顺带说一句 IDEA 里的操作。很多人在 IDE 里迷路,是因为不理解 IDE 按钮背后调用的就是这些命令。IDEA 的 Git 面板里,Log 标签页右键任意提交,可以选择 Revert Commit 或 Reset Current Branch to Here。Reset 有三种模式:Soft(保留改动)、Mixed(保留工作区改动但清空暂存区)、Hard(彻底丢弃)。处理 merge commit 时,直接用 Revert 更安全。还有一些人想要"回退 merge 操作",在 IDEA 里点了 Undo Commit 发现没用——因为 Undo 只能撤销最近一次普通提交,对 merge commit 无能为力,还是要靠 Revert 或 Reset。
5.4 常见报错速查表
我在各个团队里见过太多人卡在同一批报错上,整理成一张速查表给大家。
| 报错信息 | 含义 | 解决方案 |
|---|---|---|
| remote rejected: pre-receive hook declined | 服务端钩子拦截了 push,常见原因是提交信息不合规、文件过大、CI 校验失败 | 看服务端日志,修正提交信息或提交内容,再重新 push,不要 force push |
| something went wrong during merge pre-receive hook | GitLab 合并请求在服务端被钩子拦截 | 检查代码质量插件、提交规范、以及是否有超大文件进入仓库 |
| your merge request is almost ready! 也没问题,但一直停在提示页 | GitLab 提示当前分支已经准备好创建 MR,通常是你还没填写标题或选择目标分支 | 在页面右上角补全 MR 信息后创建 |
| another open merge request already exists for this source | 该源分支已经有一个未关闭的 MR,GitLab 不允许重复创建 | 去关闭或合并已有的 MR,不要在同一个分支上反复新建 |
| rebase the current branch | 平台或 IDE 提示当前分支落后于目标分支,建议变基后再合入 | 用git rebase同步目标分支,或者用 merge 同步,二选一即可 |
这些报错本身不可怕,可怕的是不看提示就瞎操作。尤其是"pre-receive hook"类的报错,本质上是在告诉你"服务端规则拒绝了这次提交",重点要回到规则本身去解决。
6. 我的实操心得与团队规范建议
6.1 我踩过的几个坑
第一个坑,是我职业生涯里交过最贵的一笔学费:feature 分支脱离 main 太久没同步,我一次性执行 rebase,结果三十多个提交全部冲突。那天下午我几乎什么都没干,就坐在电脑前解冲突,解到最后已经分不清哪段代码是哪个分支的。之后我养成了每天至少同步一次 main 的习惯,冲突规模始终控制在几个文件以内。
第二个坑,是 force push。有一次我把已经推送到远程、同事已经拉取过的 feature 分支做了 rebase,然后顺手强推。同事下一次 push 直接失败,项目组又花了一个小时从我的新历史里找回他的提交。那次事故让我把"绝不对共享分支 rebase、绝不 force push 共享分支"写成了团队红线,不是建议,是红线。
第三个坑发生在 IDEA 里。我误点了 Hard Reset,一整天的工作当场清零。当时还不知道 reflog 的存在,整个人是懵的。后来查出git reflog一条命令就能找回,我意识到:教人 Git,第一课应该是 reflog 和 reset 的三种模式,而不是 merge 和 rebase 的命令语法。工具再强大,不知道后悔药在哪,都不敢大胆操作。
6.2 一套可以直接抄的团队规范
最后分享一套我在多个项目里验证过的组合规范,可以直接抄进团队文档:
- main 分支只接受
merge --no-ff,禁止直接 push,禁止 rebase; - feature 分支开发期间周期性 rebase main,保持线性历史;
- feature 合入 main 走 MR/PR,合并方式统一为 squash merge 或
merge --no-ff,由团队选定一种; - 公共分支严禁 force push,确有需要必须全员确认后再执行;
- 提交信息遵守统一格式(比如 Conventional Commits),由服务端 pre-receive hook 强制检查;
- 大型操作前,先建备份分支,把后悔成本降到最低。
我发现,规范的执行比规范本身更重要。建议把规则写进 CI/CD 脚本或 Git hook,让机器去卡人,不要靠人自觉。服务端钩子配好之后,"提交信息不合格""重复创建 MR""超大文件进仓库"这类低级问题根本到不了 reviewer 手里。
Merge 和 rebase 本身没有高低之分,它们只是操作同一张 DAG 的两种不同工具。搞懂了底层机制,再结合团队场景做选择,每一次合并就不再是赌运气。我现在的个人工作流是这样的:开发期间用 rebase 保持分支与主干同步,提交前用rebase -i整理历史,合入公共分支统一走merge --no-ff,回退公共历史一律用 revert 而不是 reset。这套组合用了很长时间没有出过事故,也推荐你从这一套开始落地。