跳到主要内容
预计阅读 36 分钟

合并的艺术:当平行宇宙交汇

上一章我们学会了创建和管理平行宇宙(分支)。但平行宇宙终究要交汇——功能开发完了要合入主干,bug 修完了要同步到所有分支。怎样让多条时间线优雅地融合在一起,而不是撞出一团乱麻?这就是本章要解决的问题。

开篇自测:你已经知道多少?

  1. git merge 有几种不同的合并方式?什么是 fast-forward?
  2. 当两个分支修改了同一个文件的同一行,Git 会怎么处理?冲突标记中的 <<<<<<<=======>>>>>>> 分别代表什么?
  3. git rebasegit merge 的核心区别是什么?什么场景下应该用 rebase?
  4. 什么是 “rebase 的黄金法则”?为什么违反它会给团队带来灾难?
  5. 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)
               /
         ① 共同祖先        ③ 目标分支的最新提交
      (两个分支的分叉点)
  1. 共同祖先(C2):两个分支最近一次还在一起时的状态
  2. 当前分支的最新提交(C5):main 这边的最新改动
  3. 目标分支的最新提交(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 会:

  1. 当你手动解决冲突时,记住你的解决方式
  2. 下次遇到相同的冲突时,自动应用之前的解决方案
# 第一次遇到冲突:手动解决
$ 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-forwardgit merge <branch>线性目标分支没有分叉
三方合并git merge <branch>保留分叉默认的分支合并
No-ff 合并git merge --no-ff <branch>强制保留分叉想明确标记合并点
Rebasegit rebase <base>线性同步主干代码到功能分支
Squash 合并git merge --squash <branch>线性,单提交合并琐碎提交的功能分支
Cherry-pickgit cherry-pick <commit>复制单个提交跨分支应用 hotfix

常见误区

  • 误区一:认为 rebase 比 merge “更高级”所以应该总是用 rebase。两者各有适用场景,盲目使用 rebase 反而会在团队协作中制造麻烦。
  • 误区二:冲突解决后忘记测试。解决冲突只保证了文本层面不再矛盾,但逻辑层面可能依然有问题——比如两个分支各自引入了同名但含义不同的变量。
  • 误区三:遇到冲突就慌。冲突不是错误,是 Git 在承认”这个决定我做不了,需要你来判断”。深呼吸,看清楚两边改了什么,做出选择就好。
  • 误区四:squash merge 后试图继续在原分支上开发。squash merge 不会在历史中记录分支的关联,如果你继续在原分支上开发再次合并,可能会遇到大量重复冲突。squash merge 后应该删除原分支,如需继续开发,基于 main 开一个新分支。

掌握度自测

  1. 当 main 分支没有新提交,直接合并一个功能分支时,Git 默认执行的是:

    • A) 三方合并,创建一个合并提交
    • B) Fast-forward,直接移动 main 指针
    • C) Rebase,重新排列提交
    • D) Squash,压缩所有提交
  2. 冲突标记 <<<<<<< HEAD>>>>>>> feature/cart 之间被 ======= 分隔的两部分,分别代表:

    • A) 上方是远程版本,下方是本地版本
    • B) 上方是当前分支(HEAD)的内容,下方是传入分支的内容
    • C) 上方是旧版本,下方是新版本
    • D) 上方是暂存区版本,下方是工作区版本
  3. 关于 rebase 的黄金法则,以下说法正确的是:

    • A) 不要对任何分支执行 rebase
    • B) 不要对已推送到公共仓库的提交执行 rebase
    • C) rebase 只能在 main 分支上执行
    • D) rebase 前必须先执行 merge
  4. git merge --squash feature/x 的效果是:

    • A) 把 feature/x 的所有提交压缩成一个,直接提交到当前分支
    • B) 把 feature/x 的所有改动合并到暂存区,需要手动 commit
    • C) 删除 feature/x 分支上的所有提交
    • D) 把当前分支的提交压缩后合并到 feature/x
  5. 以下哪个场景最适合使用 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