第十章. Git Rebase 教程:变基与合并(Merge)的本质区别

第十章. Git Rebase 教程:变基与合并(Merge)的本质区别

摘要:Git 的强大不仅在于记录历史,更在于它允许我们要么“像记账员一样如实记录”,要么“像小说家一样编排故事”。本章将深入 Git 的高级核心——变基(Rebase)。我们将揭示 Rebase 的算法本质,学习如何使用交互式模式(Interactive)对外科手术级的提交历史进行重构,并掌握跨分支移植代码的高级技巧,最终打造出清晰、线性的项目演进图谱。

本章学习路径

  1. 算法解构:通过搭建模拟环境,理解 Rebase 的“寻找公共祖先”与“补丁重放”机制。
  2. 交互重塑:掌握 git rebase -i,像编辑文本一样合并、拆分、修改和删除历史提交。
  3. 高级移植:学习 --ontocherry-pick,在不同分支间精确地移动代码片段。

10.1. Rebase 的核心算法与操作

在上一章中,我们处理了远程仓库的同步问题,了解了 git pull 的默认行为是合并(Merge)。但在实际开发中,频繁的合并会产生大量无意义的“合并气泡”(Merge Bubble),导致历史线错综复杂。本节我们将引入 Rebase(变基)作为替代方案,它能够将分叉的历史重新拉直,但同时也伴随着改写历史的高风险。

10.1.1. 变基的物理本质:寻找最近公共祖先 (LCA) 与补丁重放

Rebase 字面意思是“重新设立基底”。它的核心逻辑并非简单的“移动”,而是“复制并再生”。理解这一点,是掌握 Rebase 的关键。

算法流程解析:当我们在 feature 分支上执行 git rebase master 时,Git 实际上执行了以下三步:

  1. 寻找 LCA:找到两个分支的最近公共祖先(Lowest Common Ancestor)提交。
  2. 生成补丁:将 feature 分支上从 LCA 之后的所有差异保存为临时补丁(Patch)。
  3. 基底切换与重放:将当前分支切断,接到 master 的最新提交后面,然后依次应用补丁。

【实战演练】搭建 Rebase 模拟实验室

为了安全地练习这些高危操作,我们不应该直接在生产项目中实验。我们需要构建一个完全本地化的“中央-分布式”模拟环境。

环境架构图

1
2
3
central-repo.git (模拟 GitHub 远程库)
├── dev-alice (模拟你的本地环境)
└── dev-bob (模拟同事的本地环境)

步骤 1:创建模拟环境

我们将创建一个裸仓库作为服务端,并克隆出两个客户端。

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
# 1. 创建实验根目录
mkdir rebase-lab
cd rebase-lab

# 2. 创建中央仓库 (Bare 仓库,无工作区,仅存数据)
git init --bare central-repo.git

# 3. Alice 克隆并初始化项目
git clone central-repo.git dev-alice
cd dev-alice
git config user.name "Alice"
git config user.email "alice@example.com"

# 4. 提交初始代码
echo "Project Init" > README.md
git add README.md
git commit -m "init: project start"
git push origin master

# 5. 回到根目录,Bob 克隆项目
cd ..
git clone central-repo.git dev-bob
cd dev-bob
git config user.name "Bob"
git config user.email "bob@example.com"

步骤 2:制造分叉历史

现在我们让 Alice 和 Bob 同时开发,制造出典型的分叉场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# === Bob 的操作 == =
# Bob 在 master 分支修复 Bug 并推送
echo "Bug Fix" >> README.md
git add README.md
git commit -m "fix: critical bug"
git push origin master

# === Alice 的操作 == =
# Alice 在本地基于旧版本开发新功能
cd ../dev-alice
git checkout -b feature/login
echo "Login Feature" > login.txt
git add login.txt
git commit -m "feat: login module"

此时,历史已经分叉。Bob 的提交超前于 Alice 的基底。

步骤 3:执行变基操作

Alice 想要同步 Bob 的修改,但不想产生合并提交。

1
2
3
4
5
6
7
8
# Alice 切换回 feature 分支(如果不在的话)
git checkout feature/login

# 1. 获取远程最新状态(这步很重要,否则本地不知道 origin/master 变了)
git fetch origin

# 2. 执行变基:将 feature/login 的基底移到 origin/master 的顶端
git rebase origin/master

执行结果分析

1
2
3
4
* 8bff584 (HEAD -> feature/login) feat: login module
* da27a38 (origin/master) fix: critical bug
* 063bda8 (master) init: project start
init -> fix -> feat
  • Before:Alice 的基底是 init,旁边分叉出了 Bob 的 fix
  • After:Alice 的 feat: login module 被 “剪切” 并 “粘贴” 到了 Bob 的 fix: critical bug 后面。此时历史变成了一条直线。

10.1.2. 黄金法则与禁区:为何在公共共享分支执行 Rebase 是团队灾难?

Rebase 虽然能让历史变得整洁,但它有一个致命的副作用:修改历史

每一次 Rebase,Git 都会重新计算提交的 SHA-1 哈希值。这意味着,虽然代码内容没变,但提交的 “身份证号” 变了。对于 Git 来说,它们就是全新的提交。

必须死守的铁律

绝对不要在公共共享分支(如 master, develop)上执行 Rebase,除非你是该分支的唯一维护者。

灾难演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
初始状态(大家都同步):
origin/master: A---B---C
Alice本地: A---B---C
Bob本地: A---B---C

Alice 在本地对 master 执行 rebase 后强制推送:
origin/master: A'--B'--C' (重写的历史,SHA值已变)
Alice本地: A'--B'--C'
Bob本地: A---B---C---D (Bob还基于旧历史开发)

Bob 尝试拉取更新时的灾难:
A'--B'--C' (origin/master)
/
? <- Git无法找到共同祖先
/
A---B---C---D (Bob的本地)

结果:历史分叉,大量冲突,团队陷入混乱

如果 Alice 在本地对 master 分支执行了 Rebase 并强制推送到远程:

  1. 远程的 master 历史被重写,旧的提交 A 被新的提交 A’ 取代。
  2. Bob 的本地 master 分支还是基于旧的提交 A。
  3. 当 Bob 试图拉取代码时,Git 会发现历史不连续,导致大量的冲突甚至代码丢失。

适用场景建议

场景推荐操作原因
本地个人分支Rebase整理思路,保持提交线清晰,便于 Code Review。
拉取公共更新Rebasegit pull --rebase,避免本地产生无意义的合并节点。
合并回主分支Mergegit merge --no-ff,保留 “特性分支存在过” 的真实历史证据。

10.1.3. 冲突中断处理:--continue, --skip, --abort 的状态机流转

Rebase 的冲突处理比 Merge 更繁琐,因为它是 逐个应用补丁。如果有 10 个提交需要 Rebase,理论上你可能需要解决 10 次冲突。

冲突演示

让我们在模拟环境中制造一个冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1. Alice 修改 README(与 Bob 的修改冲突)
# 此时 Alice 在 feature/login 分支,基于 origin/master
echo "Conflict Content" > README.md
git add README.md
git commit -m "feat: update readme"

# 2. 模拟远程又有新更新
# 我们直接在 dev-alice 模拟远程更新(为了演示方便)
git checkout master
echo "Remote Update" > README.md
git add README.md
git commit -m "update: remote changes"

# 3. Alice 再次变基
git checkout feature/login
git rebase master

此时终端会输出报错信息:

1
2
3
4
5
6
7
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
error: could not apply ... feat: update readme
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".

报错翻译
“冲突(内容):README.md 发生合并冲突。错误:无法应用提交 ‘feat: update readme’。提示:手动解决冲突后标记为已解决,然后运行 continue;或者跳过此提交;或者终止变基。”

此时,你进入了一个“Rebase 中间态”。你必须通过以下状态机命令流转出来:

操作流程

  1. 解决冲突:使用 git mergetool,手动保留需要的代码,删除 <<<<<<< 等标记。
  2. 标记解决git add README.md(注意:不要 执行 git commit)。
  3. 继续流程
1
git rebase --continue

其他两个选项的含义

  • git rebase --abort后悔药。完全撤销变基操作,回到变基前的状态。当你发现冲突太复杂搞不定时,用这个。
  • git rebase --skip丢弃。丢弃当前正在应用的这个补丁(提交)。通常用于发现“这个提交的修改已经被包含在上游里了”,或者这个提交本身就是错误的。

10.1.4. 自动暂存魔法:配置 rebase.autoStash 避免频繁手动 Stash

Git 不允许在工作区有未提交修改(Dirty Working Directory)时执行 Rebase。

痛点:你正在写代码,突然需要同步远程代码。你不得不:

  1. git stash(暂存修改)
  2. git pull --rebase
  3. git stash pop(恢复修改)

解决方案:开启自动暂存配置,让 Git 替你完成这三步。

1
2
# 全局开启自动暂存
git config --global rebase.autoStash true

配置后,当工作区有修改时执行 Rebase,Git 会自动创建一个临时 Stash,变基完成后自动 Pop 出来,大大提升开发流畅度。


10.2. 交互式变基 (Interactive):提交历史的显微外科手术

在上一节中,我们学习了如何通过 Rebase 保持历史线性。但在实际工作中,我们的本地开发过程往往是混乱的:为了保存进度而随意提交的 “wip”、拼写错误的 “fix typo”、甚至是不小心提交的敏感文件。

如果说标准的 Rebase 是将这一团混乱“整体搬迁”,那么交互式变基(Interactive Rebase)就是一场精密的显微外科手术。它允许我们在搬迁之前,对提交记录进行 合并、修改、拆分、甚至删除

10.2.1. 搭建手术台:环境准备与各种脏提交

为了掌握这项技术,我们必须先制造一个“烂摊子”。请在一个空的练习目录下执行以下脚本,它会模拟一个典型的混乱开发场景。

步骤 1:配置 VS Code 为默认编辑器(关键!)

交互式变基需要频繁弹出编辑器来修改指令。为了避免掉入 Vim 的操作陷阱,必须确保 Git 使用我们熟悉的 VS Code,且必须配置 --wait 参数,告诉终端“等待编辑器关闭后再继续”。

1
2
3
4
5
# 配置 VS Code 为默认编辑器
git config --global core.editor "code --wait"

# 验证配置
git config --get core.editor

步骤 2:生成混乱历史

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
28
29
30
31
32
33
# 初始化练习仓库
git init interactive-lab
cd interactive-lab

# 1. 提交初始代码
echo "Core Logic" > app.js
git add app.js
git commit -m "feat: init core logic"

# 2. 开发登录功能(正常提交)
echo "Login Function" >> app.js
git add app.js
git commit -m "feat: add login function"

# 3. 产生一个随意的 WIP (Work In Progress) 提交(需要合并)
echo "WIP: fixing layout" >> style.css
git add style.css
git commit -m "wip: layout work"

# 4. 修复上一个提交的拼写错误(需要合并)
echo "Layout Fixed" > style.css
git add style.css
git commit -m "fix: typo in css"

# 5. 提交信息写错了(需要修改文字)
echo "Logout Function" >> app.js
git add app.js
git commit -m "feat: add logouuuuut function"

# 6. 不小心提交了密码文件(需要彻底删除)
echo "password=123456" > secret.config
git add secret.config
git commit -m "config: database connection"

步骤 3:查看诊断报告

1
git log --oneline

你现在的历史应该是这样的(Hash 值会不同,但顺序一致):

1
2
3
4
5
6
e5d4c3b (HEAD -> master) config: database connection  <-- 危险文件,需删除
a1b2c3d feat: add logouuuuut function <-- 拼写错误,需修改
4f5e6d7 fix: typo in css <-- 冗余提交,需合并
8h9i0j1 wip: layout work <-- 废弃过程,需合并
2a3b4c5 feat: add login function <-- 正常
1x2y3z4 feat: init core logic <-- 正常

10.2.2. 启动手术:指令重排与合并 (Squash/Fixup)

我们的目标是清理最近的 5 个提交(从 configfeat: add login)。

操作步骤

  1. 执行命令

    1
    2
    # 对最近的 5 个提交进行交互式变基
    git rebase -i HEAD~5
  2. VS Code 界面交互
    Git 会自动打开一个名为 git-rebase-todo 的文件。内容如下:

    1
    2
    3
    4
    5
    pick 2a3b4c5 feat: add login function
    pick 8h9i0j1 wip: layout work
    pick 4f5e6d7 fix: typo in css
    pick a1b2c3d feat: add logouuuuut function
    pick e5d4c3b config: database connection

    注意:这里的顺序是 从旧到新 排列的(与 git log 相反)。最上面的是最早的提交。

  3. 编写手术脚本:我们需要修改每行开头的 pick 关键字。请直接在 VS Code 中修改为以下内容:

    1
    2
    3
    4
    5
    pick 2a3b4c5 feat: add login function            <-- 保持不变
    squash 8h9i0j1 wip: layout work <-- 合并到上一个提交
    fixup 4f5e6d7 fix: typo in css <-- 合并并丢弃日志
    reword a1b2c3d feat: add logouuuuut function <-- 稍后修改提交信息
    drop e5d4c3b config: database connection <-- 直接删除该提交

    术语解析

    • squash (s):将当前提交合并到 上一个 提交中,并保留两条提交的日志,稍后会弹出合并窗口让你整理。
    • fixup (f):将当前提交合并到 上一个 提交中,但 直接丢弃 当前提交的日志,仅保留上一个提交的信息。适合处理 “fix typo” 这类无意义记录。
    • reword ®:保留提交内容,但暂停 Rebase 让我们修改提交信息。
    • drop (d):直接从历史中抹除该提交和对应的文件修改。
  4. 执行第一阶段:保存文件(Ctrl+S)并关闭编辑器窗口(Ctrl+W 或点击关闭按钮)。终端会检测到编辑器关闭,开始执行脚本。

10.2.3. 过程干预:修改日志与解决合并

Rebase 脚本开始执行后,会根据我们的指令多次暂停。

干预 1:处理 Squash (合并日志)

Git 遇到 squash 指令后,会再次打开 VS Code,展示合并后的日志预览:

1
2
3
4
5
6
7
8
# This is a combination of 2 commits.
# This is the 1st commit message:

feat: add login function

# This is the commit message #2:

wip: layout work

我们需要将其整理为一个干净的提交信息。修改为:

1
feat: add login function and layout styles

保存并关闭编辑器。

干预 2:处理 Reword (修正拼写)

紧接着,Git 遇到 reword 指令,再次打开 VS Code。这次是为了那个拼写错误的提交。

将:

1
feat: add logouuuuut function

修改为:

1
feat: add logout function

保存并关闭编辑器。

干预 3:处理 Drop (可能的冲突)

Git 遇到 drop 指令时,会尝试删除 config: database connection。如果该提交引入的文件(secret.config)在后续没有被其他提交修改,Git 会静默删除成功。

最终验证

1
git log --oneline

输出将变得非常清爽:

1
2
3
[新Hash] feat: add logout function                <-- 拼写已修正
[新Hash] feat: add login function and layout styles <-- 3个提交合并成了1个
1x2y3z4 feat: init core logic

那个危险的 secret.config 提交也彻底消失了。


10.3. 高级移植技巧:Onto

有时我们需要在不同分支之间精准地“剪切”或“复制”代码片段,而不是合并整个分支。

10.3.1. 手术刀式移植:git rebase --onto

这是一个极易被忽视但极其强大的命令,专门解决“依赖剥离”问题。

场景描述:你基于 main 开发了 feature-A,然后为了省事,直接基于 feature-A 开发了 feature-B。现在的需求是:feature-A 代码质量太差被废弃了,但 feature-B 是好的,需要单独合入 main

文件位置演示

1
2
3
src/
├── A.txt (feature-A 的垃圾代码)
└── B.txt (feature-B 的核心代码)

我们希望把 B.txt 带到 main,同时丢掉 A.txt

步骤 1:构建依赖环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 回到主分支
git checkout master

# 1. 创建 feature-A 并提交垃圾代码
git checkout -b feature-A
echo "Garbage Code" > A.txt
git add A.txt
git commit -m "feat: A garbage"

# 2. 基于 feature-A 创建 feature-B 并提交好代码
git checkout -b feature-B
echo "Good Code" > B.txt
git add B.txt
git commit -m "feat: B good code"

此时的拓扑结构:
master -> feature-A -> feature-B

步骤 2:执行精准移植

我们需要告诉 Git:“请把 feature-B 上面,不包含 feature-A 的那部分提交,剪切下来,移植到 master 上。”

1
2
# 语法:git rebase --onto <目标基底> <旧父分支> <要移动的分支>
git rebase --onto master feature-A feature-B

执行结果
Git 自动完成了移植。

1
2
git log --oneline
ls

你会发现当前分支只有 feat: B good code,且目录下只有 B.txt(以及 master 原有的文件),A.txt 及其提交彻底消失了。


10.3.2. 跨分支的点对点复制:git cherry-pick

如果说 rebase --onto 是大范围的“剪切并搬迁”,那么 cherry-pick 就是精细化的“复制并粘贴”。

场景描述:你正在 dev 分支开发下一代的大版本功能,期间发现了一个严重的 Bug。你在 dev 分支上顺手提交了一个修复(Commit X)。此时,生产环境的 master 分支报警了,需要立即上线这个修复。
困境:你不能合并 devmaster,因为 dev 里还有一大堆没写完的实验性代码。
解法:就像在水果篮里只挑那一颗樱桃一样,我们只把 Commit X “摘”出来,应用到 master 上。

步骤 1:准备模拟场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 回到主分支,模拟生产环境
git checkout master
echo "Stable v1.0" > app.js
git add app.js
git commit -m "release: v1.0"

# 切到开发分支
git checkout -b dev
# 1. 开发一些新功能(不能上线)
echo "New Feature Alpha" >> app.js
git add app.js
git commit -m "feat: experimental feature"

# 2. 突然发现并修复了一个 Bug(这是我们要摘的樱桃)
echo "Critical Bug Fix" >> README.md
git add README.md
git commit -m "fix: null pointer exception"

# 3. 继续开发其他功能(不能上线)
echo "New Feature Beta" >> app.js
git add app.js
git commit -m "feat: another feature"

步骤 2:定位目标

我们需要找到那个修复 Bug 的提交哈希值。

1
git log --oneline

假设输出如下(请记录下 fix: ... 这一行的 Hash,例如 a1b2c3d):

1
2
3
4
9f8e7d6 (HEAD -> dev) feat: another feature
a1b2c3d fix: null pointer exception <-- 目标!
5g4h3i2 feat: experimental feature
...

步骤 3:执行“摘樱桃”

1
2
3
4
5
6
7
# 1. 切换到接收端分支(master)
git checkout master

# 2. 执行复制操作 (请替换为你实际的 Hash)
# 建议加上 -x 参数,它会在提交信息里自动附加一行 "cherry picked from commit ..."
# 方便日后追溯来源
git cherry-pick -x a1b2c3d

执行结果分析

此时 master 分支上会多出一个新的提交。

  • 内容:完全包含了 fix: null pointer exception 的修改。
  • 哈希:这是一个 全新 的提交,有新的 Hash 值,与 dev 分支上的那个互不影响。
  • 状态dev 分支依然保持原样(复制而非剪切)。

【进阶】批量摘取与冲突处理

  1. 摘取多个不连续提交

    1
    git cherry-pick <Hash A> <Hash B>
  2. 摘取连续的一段提交

    1
    2
    3
    4
    5
    # 复制从 A 到 B 的所有提交(不包含 A,包含 B)
    git cherry-pick A..B

    # 如果要包含 A
    git cherry-pick A^..B
  3. 遇到冲突:与 Rebase 类似,Cherry-pick 可能会遇到冲突。处理逻辑完全一致:

    1. 手动解决代码冲突。
    2. git add <file>
    3. git cherry-pick --continue (注意不是 commit)

10.4 本节小结

核心要点

  • 交互式 Rebase 的本质:它是一个脚本执行器。通过修改 git-rebase-todo 文件,我们可以重新编排 Git 的执行逻辑。
  • VS Code 集成关键:必须配置 core.editor "code --wait",否则 Rebase 会在编辑器打开的瞬间直接结束并报错。
  • Onto 的价值--onto 是解决分支依赖污染的唯一解法,能实现“隔山打牛”式的代码移植。

速查代码

1
2
3
4
5
6
7
8
9
10
11
# 1. 交互式变基(最近 N 个提交)
git rebase -i HEAD~N

# 2. 跨分支移植 (将 C 接到 A 上,抛弃 B)
git rebase --onto BranchA BranchB BranchC

# 3. 单个提交复制 (带来源标记)
git cherry-pick -x <CommitHash>

# 4. 批量复制提交 (区间复制)
git cherry-pick <StartHash>^..<EndHash>