第10章:分支合并 —— 殊途同归的艺术

第10章:分支合并 —— 殊途同归的艺术

分支让代码分道扬镳,合并让它们重新交汇。合并是 Git 中最重要也最复杂的操作之一——做得好,代码完美融合;做不好,冲突让人头大。本章教你成为合并大师。


10.1 git merge:合并的艺术

git merge 是 Git 中用于合并分支的命令。它将两个或多个分支的历史整合在一起,创造出新的提交。简单来说,就是把不同分支上的工作成果汇集到一起。

合并的基本概念

当两个分支从共同的起点分道扬镳,各自发展后,你可能希望将它们重新合并。这就像两条河流在下游汇合一样。

想象一下这样的场景:

  • 你和同事都从 main 分支的提交 B 开始工作
  • 你创建了 feature 分支,做了提交 C 和 D
  • 同事直接在 main 分支上做了提交 E
  • 现在你想把 feature 分支的工作合并回 main
合并前:

main:    A --- B --- E
              ↑
            起点
              \
               C --- D (feature)

合并后:

main:    A --- B --- E --- F
              \         /
               C --- D (feature)

F 是合并提交,它有两个父提交:E 和 D

什么是合并提交(Merge Commit)?

合并提交是一种特殊的提交,它有两个(或多个)父提交。普通的提交只有一个父提交(除了初始提交没有父提交),而合并提交把两条历史线连接在一起。

基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 首先,切换到目标分支(通常是 main)
# 这是你想要把代码合并进来的分支
git switch main

# 然后执行合并命令
# 将 feature 分支的改动合并到当前分支(main)
git merge feature

# 如果没有冲突,Git 会自动创建合并提交
# 如果有冲突,Git 会提示你解决冲突后再提交

合并提交的特点

合并提交有两个重要的特性:

  1. 多父提交:普通提交只有一个父提交,合并提交有两个或多个
  2. 不修改内容:合并提交本身通常不引入新代码,只是把已有的改动整合
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 查看合并提交的详细信息
git log --merges --oneline

# 这会显示所有的合并提交
# 输出示例:
# a1b2c3d Merge branch 'feature'
# e5f6g7h Merge pull request #42

# 查看某个合并提交的详细信息
git show 合并提交哈希

# 你会看到类似这样的输出:
# commit a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9
# Merge: e5f6g7h b2c3d4e
# Author: 张三 <zhangsan@example.com>
# Date:   Mon Mar 16 10:00:00 2026 +0800
#
#     Merge branch 'feature'
#
# 注意 Merge: 后面有两个哈希值,这就是两个父提交

合并时的提交信息

Git 会自动生成合并提交的提交信息,通常是 “Merge branch ‘feature-name’"。你可以修改这个信息:

1
2
3
4
5
6
7
8
# 使用 --edit 选项修改提交信息
git merge --edit feature

# 或者直接使用 --message 指定
git merge --message "合并用户登录功能" feature

# 如果不想编辑,使用 --no-edit
git merge --no-edit feature

10.2 快进合并 vs 三方合并:Git 的两种策略

Git 有两种合并策略:快进合并(Fast-forward)和三方合并(Three-way merge)。理解它们的区别,能帮助你更好地控制项目历史。

快进合并(Fast-forward)

当目标分支没有新的提交时,Git 可以直接"快进"指针,不需要创建新的合并提交。

想象一下:你创建 feature 分支后,main 分支没有任何改动。这时候 feature 分支就是 main 分支的"快进"版本。

快进合并前:

A --- B (main)
      \
       C --- D (feature)

快进合并后:

A --- B --- C --- D (main, feature)

main 指针直接移动到 D,没有创建新的合并提交
整个历史看起来就是一条直线
1
2
3
4
5
6
7
8
9
# 执行快进合并
git switch main
git merge feature

# 输出:
# Updating a1b2c3d..e5f6g7h
# Fast-forward
#  src/feature.js | 10 ++++++++++
#  1 file changed, 10 insertions(+)

快进合并的特点:

  • 不产生新的合并提交
  • 历史记录保持线性,看起来就像直接在 main 上开发
  • 操作简单,历史干净

快进合并的缺点:

  • 看不出曾经用过 feature 分支
  • 如果 feature 分支有很多小提交,main 历史会变得很乱

三方合并(Three-way merge)

当两个分支都有新的提交时,Git 需要创建一个新的合并提交来整合两条历史线。

三方合并前:

main:    A --- B --- C
              ↑
            共同祖先
              \
               D --- E (feature)

三方合并后:

main:    A --- B --- C --- F
              \         /
               D --- E (feature)

F 是新的合并提交,有两个父提交:C 和 E
1
2
3
4
5
6
7
8
9
# 执行三方合并
git switch main
git merge feature

# 输出:
# Merge made by the 'ort' strategy.
#  src/feature.js | 10 ++++++++++
#  src/utils.js   |  5 +++++
#  2 files changed, 15 insertions(+)

三方合并的特点:

  • 创建新的合并提交
  • 保留完整的分支历史
  • 清楚地显示分支合并点

三方合并的缺点:

  • 历史图变得复杂(有分叉和合并)
  • 合并提交可能被认为"污染"历史

选择合并策略

策略适用场景优点缺点
Fast-forward简单功能开发、个人项目历史简洁、没有多余的合并提交看不出曾用分支
Three-way复杂功能开发、团队协作保留完整历史、清晰显示合并点历史图复杂

建议:

  • 个人项目或简单改动:使用快进合并
  • 团队协作或复杂功能:使用三方合并,保留历史

10.3 禁止快进合并:--no-ff 保留分支历史

有时候,即使可以快进合并,你也想保留分支信息。这时候 --no-ff 参数就派上用场了。

为什么禁止快进?

快进合并虽然简洁,但会丢失分支信息:

快进合并后:

A --- B --- C --- D --- E --- F (main)

看起来就像直接在 main 上开发的,
根本不知道曾经有个 feature 分支,
也看不出什么时候合并的

禁止快进合并后:

A --- B --------- F (main)
      \         /
       C --- D (feature)

清楚地看到:
1. feature 分支的存在
2. 从 B 点开始开发
3. 在 F 点合并回 main

使用 --no-ff

1
2
3
4
5
6
7
8
# 禁止快进合并,强制创建合并提交
git switch main
git merge --no-ff feature

# 这会强制创建合并提交,即使可以快进
# 输出:
# Merge made by the 'ort' strategy.
# (no-ff)

配置默认禁止快进

如果你希望默认禁止快进合并,可以配置 Git:

1
2
3
4
5
6
7
8
9
# 全局配置,所有仓库生效
git config --global merge.ff false

# 或者只针对特定分支
git config branch.main.mergeoptions "--no-ff"

# 之后执行 git merge 就会自动使用 --no-ff
git merge feature
# 会自动创建合并提交

何时使用 --no-ff

推荐使用 --no-ff 的场景:

  • 功能分支合并到 main:保留功能开发的历史
  • 发布分支合并:标记发布点
  • 任何你想保留分支历史的场景

不推荐使用 --no-ff 的场景:

  • 个人临时分支:没必要保留
  • 简单的快速修复:保持历史简洁
  • 历史已经够复杂了:不要再添加合并提交

实际案例

假设你开发了一个用户登录功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 创建功能分支
git switch -c feature/user-login

# 开发过程中有多个提交
git commit -m "feat: add login form"
git commit -m "feat: add login validation"
git commit -m "feat: add login API integration"

# 完成后合并到 main
git switch main
git merge --no-ff feature/user-login -m "merge: add user login feature"

# 现在历史清楚地显示:
# 1. 有一个 feature/user-login 分支
# 2. 包含 3 个提交
# 3. 合并到了 main

10.4 压缩合并:--squash 把多个提交变成一个

有时候,功能分支有很多"半成品"提交,合并到 main 时你想把它们压缩成一个干净的提交。这就是 --squash 的用武之地。

什么是 Squash 合并?

Squash 合并会把分支上的所有提交"压缩"成一个提交,然后合并到目标分支。

想象一下:你在 feature 分支上开发了 5 天,每天都有提交:

feature 分支历史:

A --- B --- C (main)
      \
       D --- E --- F --- G --- H (feature)
       ↑     ↑     ↑     ↑     ↑
     开始   WIP   修复  优化  完成

这些提交信息可能是:
- D: "开始开发"
- E: "WIP: 添加登录表单"
- F: "修复表单验证"
- G: "优化样式"
- H: "完成登录功能"

Squash 合并后:

main:    A --- B --- C --- I
              \               /
               D --- E --- F --- G --- H (feature,可删除)

I 是一个提交,包含了 D、E、F、G、H 的所有改动
提交信息可能是:"feat: add user login functionality"

feature 分支的历史被"压缩"了,main 分支只有一个干净的提交

使用 --squash

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 切换到目标分支
git switch main

# 执行 squash 合并
git merge --squash feature

# 这会应用所有改动到工作区,但不会自动提交
# 你会看到类似这样的输出:
# Squash commit -- not updating HEAD
# Automatic merge went well; stopped before committing as requested

# 查看状态
git status
# Changes to be committed:
#   modified:   src/login.js
#   modified:   src/auth.js
#   new file:   src/utils.js

# 所有改动都已暂存,等待提交

# 手动提交,写一个清晰的提交信息
git commit -m "feat: add user login functionality

- Add login form component
- Add form validation
- Integrate with authentication API
- Add unit tests"

Squash 合并的特点

  1. 不产生合并提交:历史保持线性
  2. 丢失分支历史:feature 分支的提交历史不会保留在 main
  3. 改动合并:所有改动压缩成一个提交
  4. 需要手动提交:Git 不会自动创建提交,让你有机会写好的提交信息

Squash 合并 vs 普通合并

特性普通合并Squash 合并
合并提交
分支历史保留丢失
提交数量多个一个
历史图有分叉线性

何时使用 Squash

推荐使用 Squash:

  • 功能分支有很多"WIP”(进行中)提交
  • 想保持 main 分支历史简洁
  • 功能开发过程中提交很乱(“fix”、“update”、“WIP”)
  • 不想暴露开发过程中的试错历史

不推荐使用 Squash:

  • 想保留详细的开发历史
  • 需要回溯功能开发过程
  • 团队协作需要完整历史(调试时需要)
  • 法规要求保留完整开发记录

实际案例

假设你开发一个功能,过程中有很多小提交:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# feature 分支的提交历史
git log --oneline
# a1b2c3d WIP: add login
# b2c3d4e fix: validation bug
# c3d4e5f update: styles
# d4e5f6g fix: another bug
# e5f6g7h refactor: cleanup
# f6g7h8i feat: complete login

# 这些提交信息很乱,不想污染 main 分支

# 使用 squash 合并
git switch main
git merge --squash feature-login
git commit -m "feat: implement user login

- Add login form with email/password fields
- Implement client-side validation
- Integrate with backend authentication API
- Add error handling and user feedback
- Include comprehensive unit tests"

# main 分支只有一个干净的提交

10.5 合并冲突:当两个世界碰撞

合并冲突是 Git 中最让人头疼的问题。当两个分支修改了同一个文件的同一部分,Git 无法自动决定用哪个版本,就会产生冲突。

冲突是如何产生的?

想象这样一个场景:

main 分支修改了 file.txt 的第 10 行:
A --- B --- C (main)
      修改第10行:console.log("Hello, World!");

feature 分支也修改了 file.txt 的第 10 行:
A --- B --- D (feature)
      修改第10行:console.log("Hi, Universe!");

合并时:
Git:"第10行到底用哪个版本?
      main 分支说是 'Hello, World!',
      feature 分支说是 'Hi, Universe!',
      我不知道该听谁的!"

结果:冲突!

冲突的标志

当发生冲突时,Git 会明确告诉你:

1
2
3
4
git merge feature
# Auto-merging file.txt
# CONFLICT (content): Merge conflict in file.txt
# Automatic merge failed; fix conflicts and then commit the result.

冲突的状态

发生冲突后,Git 会进入"未合并"状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 查看冲突状态
git status
# You have unmerged paths.
#   (fix conflicts and run "git commit")
#   (use "git merge --abort" to abort the merge)
#
# Unmerged paths:
#   (use "git add <file>..." to mark resolution)
#
#       both modified:   file.txt
#
# no changes added to commit (use "git add" and/or "git commit -a")

状态解读:

  • “Unmerged paths”:有未合并的文件
  • “both modified: file.txt”:file.txt 在两个分支都被修改了
  • 需要解决冲突后执行 git add 标记为已解决

10.6 冲突标记解读:<<<<<<< HEAD 是什么鬼?

冲突标记看起来吓人,其实很好理解。一旦掌握规律,解决冲突就是小菜一碟。

冲突标记的结构

当 Git 无法自动合并时,它会在文件中插入特殊的标记,把冲突内容展示给你:

<<<<<<< HEAD
当前分支(你所在分支)的内容
=======
被合并分支的内容
>>>>>>> branch-name

各部分含义:

  • <<<<<<< HEAD:当前分支内容的开始标记
  • =======:分隔线,上面是当前分支,下面是对方分支
  • >>>>>>> branch-name:对方分支内容的结束标记

实际例子

假设你在合并 feature 分支时发生冲突:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// file.js 在 main 分支被改成:
function greet() {
    console.log("Hello, World!");
}

// file.js 在 feature 分支被改成:
function greet() {
    console.log("Hi, Universe!");
}

// 合并后的冲突文件:
function greet() {
<<<<<<< HEAD
    console.log("Hello, World!");
=======
    console.log("Hi, Universe!");
>>>>>>> feature
}

解读:

  • <<<<<<< HEAD======= 之间:是 main 分支的内容
  • =======>>>>>>> feature 之间:是 feature 分支的内容
  • 你需要决定保留哪个,或者合并两个版本

解决冲突的步骤

  1. 打开冲突文件,找到冲突标记
  2. 决定保留哪个版本,或者合并两个版本
  3. 删除冲突标记<<<<<<<=======>>>>>>>
  4. 保存文件
  5. 标记为已解决git add file.js
  6. 完成合并git commit

解决后的文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 选择保留 main 分支的版本:
function greet() {
    console.log("Hello, World!");
}

// 或者选择保留 feature 分支的版本:
function greet() {
    console.log("Hi, Universe!");
}

// 或者合并两个版本:
function greet(name) {
    if (name) {
        console.log(`Hello, ${name}!`);
    } else {
        console.log("Hello, World!");
    }
}

10.7 手动解决冲突:三种方式任你选

解决冲突有三种基本策略:保留当前、保留对方、合并双方。选择哪种取决于具体情况。

方式1:保留当前分支的版本

如果你确定当前分支的版本是正确的:

冲突前:
<<<<<<< HEAD
当前版本
=======
对方版本
>>>>>>> feature

解决后(保留当前):
当前版本
1
2
3
4
5
6
7
# 使用 Git 的命令行工具保留当前版本
git checkout --ours file.txt

# --ours 表示保留当前分支(HEAD)的版本

# 标记为已解决
git add file.txt

适用场景:

  • 你确定当前分支的版本是正确的
  • 对方的改动是过时的
  • 对方的改动与当前改动冲突,但当前改动更重要

方式2:保留对方分支的版本

如果你确定对方分支的版本更好:

冲突前:
<<<<<<< HEAD
当前版本
=======
对方版本
>>>>>>> feature

解决后(保留对方):
对方版本
1
2
3
4
5
6
7
# 使用 Git 的命令行工具保留对方版本
git checkout --theirs file.txt

# --theirs 表示保留被合并分支的版本

# 标记为已解决
git add file.txt

适用场景:

  • 对方分支的版本是更新的
  • 当前分支的版本是过时的
  • 对方修复了某个问题,你没有

方式3:合并两个版本

有时候,两个版本都有价值,需要合并:

冲突前:
<<<<<<< HEAD
function calculate(a, b) {
    return a + b;
}
=======
function calculate(x, y) {
    return x * y;
}
>>>>>>> feature

解决后(合并两个功能):
function calculate(a, b, operation = 'add') {
    if (operation === 'add') {
        return a + b;
    } else if (operation === 'multiply') {
        return a * b;
    }
}

适用场景:

  • 两个版本都实现了有用的功能
  • 可以整合两个版本的优点
  • 删除任何一个都会导致功能丢失

批量解决冲突

如果有多个文件的冲突,你想统一处理:

1
2
3
4
5
6
7
8
9
# 保留所有"我们的"版本
git checkout --ours .
git add .

# 保留所有"他们的"版本
git checkout --theirs .
git add .

# ⚠️ 警告:批量操作要小心,确保你知道自己在做什么!

10.8 VS Code 冲突解决:图形化真香

手动编辑冲突文件容易出错,VS Code 提供了图形化的冲突解决工具,让解决冲突变得轻松愉快。

VS Code 的冲突界面

当打开冲突文件时,VS Code 会显示友好的界面:

<<<<<<< HEAD (Current Change)
当前分支的内容
=======
Incoming Change
对方分支的内容
>>>>>>> feature

[Accept Current Change] [Accept Incoming Change] [Accept Both Changes] [Compare Changes]

操作按钮

  • Accept Current Change:保留当前分支的版本
  • Accept Incoming Change:保留对方分支的版本
  • Accept Both Changes:保留两个版本(当前在前,对方在后)
  • Compare Changes:对比两个版本的差异

使用步骤

  1. 打开冲突文件
  2. 点击相应的按钮选择解决方案
  3. 保存文件
  4. 在终端执行 git add .
  5. 执行 git commit 完成合并

其他图形化工具

工具平台特点
VS Code跨平台内置,免费,功能强大
SourceTreeWin/Mac可视化分支图,操作简单
GitKraken跨平台漂亮的界面,功能丰富
IntelliJ IDEA跨平台强大的 IDE 集成
Beyond CompareWin/Mac专业的对比工具,功能强大

10.9 合并最佳实践:避免冲突的秘诀

冲突虽然可以解决,但最好避免。以下是一些最佳实践,帮助你减少冲突的发生。

1. 频繁同步

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 每天开始工作前,先拉取最新代码
git switch main
git pull origin main

# 开发过程中,每隔几小时同步一次
git pull origin main

# 推送前,先拉取更新
git pull origin main
git push origin feature

为什么有效:

  • 减少与主分支的差异
  • 及早发现和解决冲突
  • 避免最后合并时的"大爆炸"

2. 小步提交

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 不好的做法:
# 开发一周,修改了 50 个文件,一次性合并
# 结果:大量冲突,难以解决

# 好的做法:
# 每天提交,小步前进
git add .
git commit -m "feat: progress on feature X"

# 改动小,冲突少,容易解决

3. 分工明确

1
2
3
4
5
6
7
# 避免多人同时修改同一个文件
# 如果必须修改,提前沟通

# 好的分工:
# 张三:负责用户模块
# 李四:负责订单模块
# 王五:负责支付模块

4. 使用特性开关(Feature Flag)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 而不是用分支开发长周期功能
// 使用特性开关控制功能是否可用

if (featureFlags.newFeature) {
    // 新功能代码
    showNewFeature();
} else {
    // 旧代码
    showOldFeature();
}

// 这样可以在 main 分支上开发,
// 通过开关控制功能是否对用户可见

5. 及时删除已合并分支

1
2
3
# 减少分支数量,降低合并复杂度
git branch -d feature-x
git push origin --delete feature-x

6. 代码审查

1
2
# 通过 Pull Request 合并
# 让其他人帮忙检查潜在的冲突

10.10 合并后后悔了?怎么撤销?

合并后发现有问题?别担心,可以撤销。

场景1:还没 push

1
2
3
4
5
6
7
8
9
# 如果合并提交还没 push,直接 reset
git reset --hard HEAD~1

# 这会回到合并前的状态
# 就像合并从未发生过

# 或者保留改动,撤销合并提交
git reset --soft HEAD~1
# 改动会保留在暂存区

场景2:已经 push

1
2
3
4
5
6
7
# 使用 revert 撤销合并
git revert -m 1 HEAD

# -m 1 表示保留第一个父提交(main)的改动
# 撤销第二个父提交(feature)引入的改动

# 这会创建一个新的提交,抵消合并的影响

场景3:想重新合并

1
2
3
4
5
6
7
8
# 1. 撤销合并提交
git revert -m 1 HEAD

# 2. 修复 feature 分支的问题
# ...

# 3. 重新合并
git merge feature

场景4:合并过程中想放弃

1
2
3
4
5
6
7
8
# 合并过程中遇到太多冲突,想放弃
git merge --abort

# 这会回到合并前的状态
# 所有改动保持原样

# 注意:只能在合并过程中使用
# 如果合并已经完成,不能用这个命令

本章小结

本章我们学习了分支合并的各种知识:

主题要点
git merge合并分支的基本命令
快进合并不产生合并提交,历史线性
三方合并创建合并提交,保留分支历史
--no-ff强制创建合并提交
--squash压缩多个提交为一个
冲突解决手动编辑或使用图形化工具
最佳实践频繁同步、小步提交、分工明确

关键要点:

  1. 快进合适合简单场景,三方合并保留完整历史
  2. --no-ff 可以强制保留分支信息
  3. --squash 可以保持主分支历史简洁
  4. 冲突不可怕,理解冲突标记的结构就能解决
  5. 预防胜于治疗:频繁同步、小步提交

记忆口诀:

  • 简单合并 → 快进
  • 保留历史 → --no-ff
  • 历史简洁 → --squash
  • 有冲突 → 找 <<<<<<<

练习建议:

  1. 创建两个分支,故意制造冲突并解决
  2. 尝试快进合并、三方合并、Squash 合并
  3. 使用 VS Code 的冲突解决工具

下一章,我们将进入远程仓库的世界!

最后修改 April 3, 2026: 新增 git 教程 (c56c038)