合并的艺术:当平行宇宙交汇
上一章我们学会了创建和管理平行宇宙(分支)。但平行宇宙终究要交汇——功能开发完了要合入主干,bug 修完了要同步到所有分支。怎样让多条时间线优雅地融合在一起,而不是撞出一团乱麻?这就是本章要解决的问题。
开篇自测:你已经知道多少?
git merge有几种不同的合并方式?什么是 fast-forward?- 当两个分支修改了同一个文件的同一行,Git 会怎么处理?冲突标记中的
<<<<<<<、=======、>>>>>>>分别代表什么?git rebase和git merge的核心区别是什么?什么场景下应该用 rebase?- 什么是 “rebase 的黄金法则”?为什么违反它会给团队带来灾难?
git cherry-pick是做什么的?在什么场景下特别有用?
一、merge:让两条时间线汇合
git merge 是最基本也最常用的合并操作。它的核心任务是:把另一个分支的改动整合到当前分支中。
但 merge 并不是只有一种工作方式——根据两个分支的关系不同,Git 会自动选择不同的合并策略。
1.1 Fast-Forward 合并:其实不需要合并
当目标分支从当前分支分出去之后,当前分支没有任何新提交,两者之间不存在分叉——这时 Git 会执行一种叫 fast-forward(快进)的操作。
合并前:
C3 ← C4 ← C5 (feature/login)
/
C1 ← C2 ← C3 (main, HEAD)
合并后(fast-forward):
C1 ← C2 ← C3 ← C4 ← C5 (main, HEAD, feature/login)
Git 只是把 main 的指针”快进”到了 feature/login 所在的位置——根本不需要创建新的合并提交,因为没有什么需要”合并”的,只需要”追上去”就行了。
$ git switch main
$ git merge feature/login
Updating a1b2c3d..e5f6a7b
Fast-forward
src/auth/login.js | 42 +++++++++++++++++++++
1 file changed, 42 insertions(+)
注意输出中的 Fast-forward 字样——这告诉你 Git 执行的是快进合并。
如果你希望即使可以 fast-forward 也要产生一个合并提交(为了保留”这里曾经有一个分支被合并进来”的历史记录),可以使用 --no-ff:
$ git merge --no-ff feature/login
使用 --no-ff 的效果:
C4 ← C5
/ \
C1 ← C2 ← C3 ──────────── M (main, HEAD)
│
合并提交(明确标记了合并点)
1.2 三方合并:真正的合并
当两个分支都有了各自的新提交——也就是出现了真正的分叉——Git 就需要做一次三方合并(three-way merge)。
为什么叫”三方”?因为 Git 需要参考三个版本来决定如何合并:
三方合并的三个参考点:
② 当前分支的最新提交
\
C1 ← C2 ← C4 ← C5 (main, HEAD)
\
C6 ← C7 (feature/cart)
/
① 共同祖先 ③ 目标分支的最新提交
(两个分支的分叉点)
- 共同祖先(C2):两个分支最近一次还在一起时的状态
- 当前分支的最新提交(C5):main 这边的最新改动
- 目标分支的最新提交(C7):feature/cart 那边的最新改动
Git 把共同祖先作为”参照物”,分别比对两个分支各自做了哪些改动,然后把两组改动合并在一起,生成一个新的合并提交(merge commit):
$ git switch main
$ git merge feature/cart
合并后:
C1 ← C2 ← C4 ← C5 ──── M (main, HEAD)
\ /
C6 ← C7 ──┘ (feature/cart)
M 是新创建的合并提交,它有两个父节点:C5 和 C7
合并提交 M 和普通提交不同——它有两个父节点,分别指向两条被合并的时间线。这就是为什么 git log --graph 能画出分叉再合并的图形。
想一想 如果 Git 找不到共同祖先(两个完全不相关的仓库的分支),merge 还能工作吗?试试
git merge --allow-unrelated-histories会发生什么。
二、冲突解决:当两个宇宙改了同一个地方
三方合并中,Git 会自动处理大部分情况:一边改了文件 A,另一边改了文件 B——没问题,两个改动都保留。甚至同一个文件,一边改了第 10 行,另一边改了第 50 行——也没问题,都保留。
但当两个分支修改了同一个文件的同一个区域时,Git 就无法自动判断该保留谁的改动了。这种情况就是冲突(conflict)。
2.1 冲突长什么样?
$ git merge feature/cart
Auto-merging src/config/pricing.js
CONFLICT (content): Merge conflict in src/config/pricing.js
Automatic merge failed; fix conflicts and then commit the result.
打开冲突文件,你会看到 Git 用特殊标记圈出了冲突区域:
function calculateDiscount(price, level) {
<<<<<<< HEAD
// main 分支的版本:按百分比折扣
if (level === 'vip') return price * 0.8;
if (level === 'svip') return price * 0.7;
return price;
=======
// feature/cart 分支的版本:按固定金额减免
if (level === 'vip') return price - 20;
if (level === 'svip') return price - 50;
return price;
>>>>>>> feature/cart
}
三组标记的含义:
<<<<<<< HEAD
当前分支(你正在合并到的分支)的内容
======= (分隔线)
传入分支(你正在合并进来的分支)的内容
>>>>>>> feature/cart
2.2 手动解决冲突
解决冲突就是你来做裁判——决定最终代码应该是什么样子。你有几种选择:
选择一:保留当前分支的版本
function calculateDiscount(price, level) {
if (level === 'vip') return price * 0.8;
if (level === 'svip') return price * 0.7;
return price;
}
选择二:保留传入分支的版本
function calculateDiscount(price, level) {
if (level === 'vip') return price - 20;
if (level === 'svip') return price - 50;
return price;
}
选择三:两者结合,写出全新的版本(这往往是最正确的选择)
function calculateDiscount(price, level) {
// 结合两种折扣方式,取对用户更优惠的那个
if (level === 'vip') return Math.min(price * 0.8, price - 20);
if (level === 'svip') return Math.min(price * 0.7, price - 50);
return price;
}
核心要求:删除掉所有冲突标记(<<<<<<<、=======、>>>>>>>),确保文件是可以正常运行的代码。
2.3 使用 VS Code 解决冲突
VS Code 会自动识别冲突标记,并在冲突区域上方提供快捷操作按钮:
Accept Current Change | Accept Incoming Change | Accept Both Changes | Compare Changes
┌─────────────────────────────────────────────────────────────────────────┐
│ <<<<<<< HEAD │
│ if (level === 'vip') return price * 0.8; ← 绿色高亮(当前) │
│ ======= │
│ if (level === 'vip') return price - 20; ← 蓝色高亮(传入) │
│ >>>>>>> feature/cart │
└─────────────────────────────────────────────────────────────────────────┘
点击对应按钮就能快速选择,省去手动编辑的麻烦。不过对于复杂冲突,还是建议手动编辑,因为”正确答案”往往不是简单地二选一。
2.4 完成冲突解决
解决完所有冲突后,按照以下步骤完成合并:
# 1. 查看哪些文件还有冲突
$ git status
Unmerged paths:
both modified: src/config/pricing.js
# 2. 编辑并解决冲突后,标记为已解决
$ git add src/config/pricing.js
# 3. 完成合并提交
$ git commit
# Git 会自动填写合并提交的信息
# 如果解决到一半不想合并了,可以取消
$ git merge --abort
常见误区
- 误区一:解决冲突时只删除了冲突标记,没有检查代码逻辑是否正确。冲突标记删了不代表代码能跑——解决冲突后一定要测试。
- 误区二:认为冲突是”出了问题”。冲突是正常现象,它只是意味着两个人改了同一个地方,需要人类来做判断。频繁冲突可能暗示团队需要更好的模块划分或更频繁的代码同步。
三、rebase:重写时间线
3.1 rebase 的原理
如果说 merge 是”让两条时间线交汇”,那么 rebase 就是”把一条时间线嫁接到另一条上面”。
看一个具体的例子。假设你从 main 分出 feature/search 开发搜索功能,同时 main 上也有了新提交:
rebase 之前:
C1 ← C2 ← C3 ← C4 (main)
\
C5 ← C6 (feature/search, HEAD)
如果用 merge,会产生一个合并提交,历史中会出现分叉:
merge 的结果:
C1 ← C2 ← C3 ← C4 ──── M (main, HEAD)
\ /
C5 ← C6 ──┘
但如果用 rebase,效果完全不同:
$ git switch feature/search
$ git rebase main
rebase 的结果:
C1 ← C2 ← C3 ← C4 ← C5' ← C6' (feature/search, HEAD)
│
main
rebase 做了什么?它把 C5 和 C6 “摘下来”,在 C4(main 的最新提交)的基础上重新”种”上去,产生了 C5’ 和 C6’。注意这里用了撇号——C5’ 和 C6’ 是全新的提交,它们的内容和原来的 C5、C6 相同,但 SHA-1 哈希值不同,因为它们的父节点变了。
接下来切回 main 做一次 fast-forward 合并:
$ git switch main
$ git merge feature/search
# Fast-forward,main 直接移到 C6'
最终的历史是一条干净的直线,没有分叉。
3.2 rebase 的黄金法则
永远不要对已经推送到公共仓库的提交执行 rebase。
这是使用 rebase 最重要的原则,没有之一。
为什么?因为 rebase 会创建新的提交来替代旧的提交。如果旧提交已经被推送到远程仓库,其他同事可能已经基于这些提交做了开发。你这时在本地 rebase 了——等于把大家共同的基础给换掉了。当同事尝试 push 或 pull 时,就会遭遇一团混乱的冲突。
危险操作的后果:
你的本地(rebase 后): C1 ← C2 ← C4 ← C5' ← C6'
远程仓库(rebase 前): C1 ← C2 ← C5 ← C6
同事的本地: C1 ← C2 ← C5 ← C6 ← C7
同事尝试 push → 失败!基础提交对不上了
安全使用 rebase 的经验总结:
| 场景 | 是否可以 rebase | 原因 |
|---|---|---|
| 本地未推送的功能分支 | 可以 | 只有你自己在用 |
| 已推送但只有你一个人用的分支 | 谨慎使用 | 需要 force push |
| 已推送且多人协作的分支 | 绝对不要 | 会破坏所有人的历史 |
| main / develop 等公共分支 | 绝对不要 | 灾难性后果 |
3.3 merge vs rebase:怎么选?
两者最直观的区别体现在提交历史的形态上:
merge 的历史——保留真实的分支轨迹:
* ─── M (Merge branch 'feature/search')
|\
| * ── C6 实现搜索结果高亮
| * ── C5 添加搜索接口
|/
* ─── C4 更新文档
* ─── C3 修复样式
rebase 的历史——干净的直线:
* ── C6' 实现搜索结果高亮
* ── C5' 添加搜索接口
* ── C4 更新文档
* ── C3 修复样式
选择的维度:
- 想保留完整的分支历史(谁在什么时候开了分支、什么时候合并的)→ 用 merge
- 想要干净的线性历史(便于阅读和回溯)→ 用 rebase
- 公共分支上的操作 → 只用 merge
- 本地功能分支同步主干的最新代码 → 推荐用 rebase
一种常见的团队工作流是:在功能分支上用 rebase 同步主干,合并到主干时用 merge(或 squash merge)。
# 功能分支上同步 main 的最新代码
$ git switch feature/search
$ git rebase main # 用 rebase 保持线性
# 合并回 main 时用 merge
$ git switch main
$ git merge feature/search # 此时是 fast-forward
四、squash 合并:把碎片整理成一块
在功能分支上开发时,你可能会产生很多琐碎的提交:
feature/user-profile 上的提交历史:
* c7f3a2b WIP: 还没写完
* a1b2c3d fix typo
* 9e8d7c6 又忘了加分号
* 5f4e3d2 调整样式
* 1a2b3c4 添加用户资料页
这些提交记录了你的开发过程,但对于 main 分支来说,它们太碎了。--squash 选项可以把整个分支的改动压缩成一个提交:
$ git switch main
$ git merge --squash feature/user-profile
# 这不会立即提交,而是把所有改动放到暂存区
$ git commit -m "feat: 添加用户资料页功能"
squash 合并的效果:
合并前:
C1 ← C2 ← C3 (main)
\
C4 ← C5 ← C6 ← C7 ← C8 (feature/user-profile)
│ │ │ │ │
添加 调整 忘了 fix WIP
资料页 样式 分号 typo
合并后:
C1 ← C2 ← C3 ← S (main)
│
一个干净的提交:
"feat: 添加用户资料页功能"
squash 合并的好处是让 main 的历史保持简洁——每个合并都对应一个完整的功能,而不是一堆 “fix typo”、“WIP” 之类的噪音。
想一想 squash 合并后,原分支上的那些小提交还在吗?如果删除了分支,那些详细的开发历史还能找回来吗?
五、cherry-pick:精确挑选提交
有时候你不想合并整个分支,只想从某个分支上”摘”一两个特定的提交过来。这就是 cherry-pick 的用武之地。
5.1 典型场景:紧急修复的跨分支应用
假设你在 release/v2.0 分支上发现了一个支付相关的严重 bug 并修复了它。这个修复同样需要应用到 main 分支和正在开发中的 release/v2.1 分支:
# 当前在 release/v2.0 分支,已经修复并提交
$ git log --oneline -1
a1b2c3d fix: 修复支付金额精度丢失问题
# 把这个修复应用到 main
$ git switch main
$ git cherry-pick a1b2c3d
# 再应用到 release/v2.1
$ git switch release/v2.1
$ git cherry-pick a1b2c3d
cherry-pick 会把指定提交的改动作为一个新的提交应用到当前分支。新提交的内容和原提交相同,但有不同的 SHA-1 哈希值。
5.2 一次挑选多个提交
# 挑选多个不连续的提交
$ git cherry-pick a1b2c3d e5f6a7b
# 挑选一个范围(不包含起点,包含终点)
$ git cherry-pick A..B
# 挑选一个范围(包含起点和终点)
$ git cherry-pick A^..B
如果 cherry-pick 过程中出现冲突,处理方式和 merge 冲突一样——手动解决后 git add,然后执行 git cherry-pick --continue。如果想放弃,用 git cherry-pick --abort。
六、git rerere:让 Git 记住你的冲突解决方案
如果你经常遇到同样的冲突(比如反复从 main rebase 功能分支),rerere(reuse recorded resolution,重用已记录的解决方案)可以帮你自动处理。
# 开启 rerere 功能
$ git config --global rerere.enabled true
开启之后,Git 会:
- 当你手动解决冲突时,记住你的解决方式
- 下次遇到相同的冲突时,自动应用之前的解决方案
# 第一次遇到冲突:手动解决
$ git merge feature/cart
CONFLICT: src/config/pricing.js
# (手动解决冲突...)
$ git add src/config/pricing.js
Recorded resolution for 'src/config/pricing.js'. # 注意这行!
$ git commit
# 下次遇到同样的冲突(比如 rebase 时)
$ git rebase main
Resolved 'src/config/pricing.js' using previous resolution. # 自动解决!
rerere 在以下场景中特别有用:
- 反复 rebase 长期存在的功能分支
- 合并流程中需要多次解决类似冲突
- 测试不同的合并策略(merge 和 rebase 来回切换尝试)
你可以用 git rerere status 查看当前记录的解决方案,用 git rerere diff 查看细节。
七、实战:一次完整的功能开发到合并流程
让我们走一遍从开始开发到代码合并的完整流程,把本章学到的知识串起来:
# ========== 阶段一:开始开发 ==========
# 从最新的 main 分出功能分支
$ git switch main
$ git pull origin main
$ git switch -c feature/order-export
# 开发过程中的多次提交
$ git add src/services/export.js
$ git commit -m "feat: 实现订单数据查询逻辑"
$ git add src/services/export.js src/utils/csv.js
$ git commit -m "feat: 添加 CSV 格式导出"
$ git add src/services/export.js
$ git commit -m "feat: 添加 Excel 格式导出"
$ git add tests/export.test.js
$ git commit -m "test: 添加导出功能单元测试"
# ========== 阶段二:同步主干代码 ==========
# 开发了两天,main 上已经有了其他同事的新提交
# 用 rebase 同步,保持线性历史
$ git fetch origin
$ git rebase origin/main
# 如果 rebase 遇到冲突,解决后继续
# $ git add <conflicted-file>
# $ git rebase --continue
# ========== 阶段三:整理提交 ==========
# 推送到远程,准备提交 Pull Request
$ git push -u origin feature/order-export
# ========== 阶段四:代码审查 ==========
# 审查人提了修改意见,你做了修改
$ git add src/services/export.js
$ git commit -m "refactor: 根据审查意见优化导出性能"
$ git push
# ========== 阶段五:合并到主干 ==========
# 方案 A:普通 merge(保留分支历史)
$ git switch main
$ git merge feature/order-export
# 方案 B:squash merge(压缩成一个提交)
$ git switch main
$ git merge --squash feature/order-export
$ git commit -m "feat: 添加订单导出功能(支持 CSV 和 Excel)"
# ========== 阶段六:清理 ==========
# 删除已合并的本地分支
$ git branch -d feature/order-export
# 删除远程分支
$ git push origin --delete feature/order-export
# 推送 main
$ git push origin main
在实际的团队协作中,“阶段五”通常不是在本地做的,而是通过 GitHub / GitLab 的 Pull Request(或 Merge Request)界面来完成。平台会提供 “Merge”、“Squash and Merge”、“Rebase and Merge” 三种选项,和我们学到的三种策略一一对应。
GitHub Pull Request 的三种合并策略:
┌──────────────────┬────────────────────────────────────┐
│ Create a merge │ 等同于 git merge --no-ff │
│ commit │ 保留分支历史,创建合并提交 │
├──────────────────┼────────────────────────────────────┤
│ Squash and merge │ 等同于 git merge --squash │
│ │ 所有提交压缩为一个 │
├──────────────────┼────────────────────────────────────┤
│ Rebase and merge │ 等同于 git rebase + merge │
│ │ 线性历史,逐个提交应用到主干 │
└──────────────────┴────────────────────────────────────┘
八、合并策略速查表
最后,把本章涉及的所有合并方式总结成一张速查表:
| 方式 | 命令 | 历史形态 | 适用场景 |
|---|---|---|---|
| Fast-forward | git merge <branch> | 线性 | 目标分支没有分叉 |
| 三方合并 | git merge <branch> | 保留分叉 | 默认的分支合并 |
| No-ff 合并 | git merge --no-ff <branch> | 强制保留分叉 | 想明确标记合并点 |
| Rebase | git rebase <base> | 线性 | 同步主干代码到功能分支 |
| Squash 合并 | git merge --squash <branch> | 线性,单提交 | 合并琐碎提交的功能分支 |
| Cherry-pick | git cherry-pick <commit> | 复制单个提交 | 跨分支应用 hotfix |
常见误区
- 误区一:认为 rebase 比 merge “更高级”所以应该总是用 rebase。两者各有适用场景,盲目使用 rebase 反而会在团队协作中制造麻烦。
- 误区二:冲突解决后忘记测试。解决冲突只保证了文本层面不再矛盾,但逻辑层面可能依然有问题——比如两个分支各自引入了同名但含义不同的变量。
- 误区三:遇到冲突就慌。冲突不是错误,是 Git 在承认”这个决定我做不了,需要你来判断”。深呼吸,看清楚两边改了什么,做出选择就好。
- 误区四:squash merge 后试图继续在原分支上开发。squash merge 不会在历史中记录分支的关联,如果你继续在原分支上开发再次合并,可能会遇到大量重复冲突。squash merge 后应该删除原分支,如需继续开发,基于 main 开一个新分支。
掌握度自测
-
当 main 分支没有新提交,直接合并一个功能分支时,Git 默认执行的是:
- A) 三方合并,创建一个合并提交
- B) Fast-forward,直接移动 main 指针
- C) Rebase,重新排列提交
- D) Squash,压缩所有提交
-
冲突标记
<<<<<<< HEAD和>>>>>>> feature/cart之间被=======分隔的两部分,分别代表:- A) 上方是远程版本,下方是本地版本
- B) 上方是当前分支(HEAD)的内容,下方是传入分支的内容
- C) 上方是旧版本,下方是新版本
- D) 上方是暂存区版本,下方是工作区版本
-
关于 rebase 的黄金法则,以下说法正确的是:
- A) 不要对任何分支执行 rebase
- B) 不要对已推送到公共仓库的提交执行 rebase
- C) rebase 只能在 main 分支上执行
- D) rebase 前必须先执行 merge
-
git merge --squash feature/x的效果是:- A) 把 feature/x 的所有提交压缩成一个,直接提交到当前分支
- B) 把 feature/x 的所有改动合并到暂存区,需要手动 commit
- C) 删除 feature/x 分支上的所有提交
- D) 把当前分支的提交压缩后合并到 feature/x
-
以下哪个场景最适合使用 cherry-pick?
- A) 合并整个功能分支到 main
- B) 把某个分支上的一个 bug 修复提交应用到其他分支
- C) 同步 main 的最新代码到功能分支
- D) 撤销上一次提交
自我评估
- 答对 5 题:合并策略的基础已经非常扎实!你可以在团队中自信地选择合适的合并方式了。
- 答对 3-4 题:核心概念理解不错,建议重点复习 rebase 的原理和使用限制。
- 答对 0-2 题:合并是 Git 协作的核心环节,建议结合实际操作重新理解 merge 和 rebase 的区别,特别是多画几张提交历史的示意图来帮助理解。
参考答案: 1-B, 2-B, 3-B, 4-B, 5-B
购买课程解锁全部内容
版本控制不翻车:Git 从基础到团队协作
¥29.90