16 — 改写历史 rebase:搬家到新楼层,不是合并
写在前面:这一章要解决什么
如果你已经会了分支和合并,心里可能还有一个更具体的困惑:每次 merge 都会多出一个合并提交,分支线缠成一团。有没有办法把代码改动「理成一条直线」?
学完后,你应该能:
- 说清楚 rebase 和 merge 的区别(用「搬家」的比喻)
- 用
git rebase把分支线整理成直线 - 用
git rebase -i压缩、改写、重排提交记录 - 遇到 rebase 冲突时冷静解决
- 牢记「已推送的提交绝对不 rebase」这条铁律
读者设定:大一同学,已经学过分支和合并,知道 commit、branch、merge 是什么,但看到分叉的历史线会头疼。
1. 定位:为什么要讲 rebase
1.1 一句话先记住
rebase = 把你的改动搬家到别人最新代码的上面,让历史变成一条直线。
不是把两份代码「和稀泥」混在一起(那是 merge),而是把你的提交一个一个「搬到」新基线的楼上。
1.2 没有 rebase 会怎样(痛点场景表)
| 场景 | 没有 rebase 的痛 | 有了 rebase 之后 |
|---|---|---|
| 功能分支开发三天,主分支已更新 | merge 产生合并提交,历史线分叉又汇合 | rebase 让你的提交变成一条直线 |
| 提交了 5 次小修改,全是「修 typo」 | 历史全是「修 typo」「又修 typo」,丢人 | 交互式 rebase 把 5 个 squash 成 1 个 |
| 想调整提交顺序 | 只能删了重来 | git rebase -i拖一拖顺序就行 |
| 写错提交信息 | 改不了 | git rebase -i选 reword,只改文字 |
| 代码审查时历史太乱 | 审查的人看得眼花 | rebase 之后一条线,审查更轻松 |
1.3 和你已经会的事对比
你已经会了git merge:把两个分支的代码合到一起,会产生一个合并提交,历史会分叉再汇合。
| 对比项 | merge(你已经会的) | rebase(这一章要学的) |
|---|---|---|
| 历史线形状 | 分叉 + 汇合,有合并提交 | 一条直线,没有合并提交 |
| 本质 | 两个分支「和稀泥」 | 你的提交「搬家」到新基线上面 |
| 结果代码 | 一样 | 一样 |
| 安全性 | 对已推送分支安全 | 对已推送分支危险 |
| 适用场景 | 合并公共分支 | 整理自己的功能分支 |
小提示:最终代码内容是一样的,只是历史的书写方式不同。merge 保留了「分叉过」这个事实,rebase 改写了历史让你假装「一切都是在最新代码上顺滑开发的」。
2. 本质:rebase 到底是什么(白话 + 比喻 + 图解)
2.1 搬家到新楼层
- 主分支 main是一楼,最近有人翻修了,地面换成了新地板。
- 你的功能分支是二楼,你在旧地板上装修了自己的房间。
现在你想让你的房间建在新地板上面。怎么办?
- merge:在一楼和二楼之间搭一个空中走廊,两个楼层都保留,走廊就是合并提交。
- rebase:把你的整个房间搬到新地板上面。你的房间不变,但下面的地基变成了最新的。
用 Git 的话说:
- 找到两个分支的分叉点(共同祖先)
- 把你的分支上每个提交的改动「记忆」下来
- 先把你的分支指针移到目标分支的最新位置(新基线)
- 把记忆下来的改动一个一个重新应用上去
- 每重新应用一次,就产生一个新的提交(哈希值不同)
2.2 看图说话
图:merge 保留分叉,rebase 变成直线。
merge 之后的历史:
* 5a3b1c2 Merge branch 'feature' |\ | * 45b9122 feat B | * f3b398d feat A * | 15c7f3e main fix |/ * 812a70d baserebase 之后的历史:
* 214be71 feat B(新哈希!) * a937ce2 feat A(新哈希!) * 15c7f3e main fix * 812a70d base白话翻译:
- merge 后有个菱形分叉,
5a3b1c2是合并提交 - rebase 后一条直线,但
45b9122变成了214be71,f3b398d变成了a937ce2——同样的代码改动,但哈希值不同了
2.3 关键认知:rebase 会产生新提交
rebase 不是「移动」提交,而是「拆掉重建」。
每个新提交的哈希值都和原来不同,因为:
- 父提交变了(从旧基线变成新基线)→ 整个提交的哈希就变了
- 哈希变了 → 这就是一个全新的提交
这也是为什么已推送的提交不能 rebase的根本原因:别人已经基于旧哈希在干活了,你偷偷换成新哈希,别人的世界就崩了。
2.4 新手最常踩的坑
| 坑 | 后果 | 怎么避 |
|---|---|---|
| 对已推送的分支执行 rebase | 别人拉代码时冲突地狱 | 铁律:只对自己的本地分支 rebase |
| rebase 过程中慌了不知道怎么退 | 卡在中间状态 | git rebase --abort一键回到 rebase 之前 |
| rebase 冲突时没搞清要保留什么 | 改错了更麻烦 | 冲突标记和 merge 一样,先看懂再改 |
| 以为 rebase 能代替一切 | 不该 rebase 的地方乱用 | 公共分支用 merge,私人的功能分支才用 rebase |
3. 建议学习顺序
- 先看第 2 节搞懂「搬家」比喻
- 跟着第 5 节做一遍基础 rebase
- 再学交互式 rebase
- 最后学冲突处理和安全规矩
4. 动手准备(建可丢弃目录)
请找一个可以随便删的练习目录,不要用正在交的作业仓库练手。
mkdirlab-rebasecdlab-rebasegitinit-bmaingitconfig user.name"Ada Example"gitconfig user.email"ada@example.com"验证版本:
git--versiongit version 2.43.05. 跟着做:一次完整的 rebase 实验
5.1 先准备两条分叉的分支
# 在 main 上建基线echo"base content">file.txtgitaddfile.txt&&gitcommit-m"base: 初始文件"# main 继续前进:修一个 bugecho"bug fix from main">>file.txtgitaddfile.txt&&gitcommit-m"main: 修复一个 bug"# 回到分叉点,创建功能分支gitcheckout-bfeature 812a70d白话翻译:-b feature创建并切换到 feature 分支,812a70d是分叉点。
# 在 feature 分支上做两个功能提交echo"feature A">feature-a.txtgitaddfeature-a.txt&&gitcommit-m"feature: 功能 A"echo"feature B">feature-b.txtgitaddfeature-b.txt&&gitcommit-m"feature: 功能 B"现在看看历史线:
gitlog--graph--all--oneline* 45b9122 feature: 功能 B * f3b398d feature: 功能 A | * 15c7f3e main: 修复一个 bug |/ * 812a70d base: 初始文件白话翻译:|/就是分叉点,两条线从812a70d分开。
5.2 用 merge 合并(先看看老办法)
gitcheckout main&&gitmerge featuregitlog--graph--all--oneline* 5a3b1c2 Merge branch 'feature' |\ | * 45b9122 feature: 功能 B | * f3b398d feature: 功能 A * | 15c7f3e main: 修复一个 bug |/ * 812a70d base: 初始文件白话翻译:merge 产生了一个合并提交,历史线变成了菱形。
5.3 撤销 merge,改用 rebase 试试
# 退回去gitreset--hard15c7f3e# 切到功能分支并 rebase 到 maingitcheckout feature&&gitrebase mainSuccessfully rebased and updated refs/heads/feature.看看现在的历史线:
gitlog--graph--all--oneline* 7d4e8a3 feature: 功能 B * a937ce2 feature: 功能 A * 15c7f3e main: 修复一个 bug * 812a70d base: 初始文件白话翻译:一条直线!功能 A 和 B 的哈希都变了,是「新楼层上重建的新提交」。
5.4 最后把 feature 合并进 main(快进合并)
gitcheckout main&&gitmerge featureUpdating 15c7f3e..7d4e8a3 Fast-forward白话翻译:Fast-forward表示快进合并,没有合并提交,历史线完全是一条直线。
6. 命令分组(按场景分组)
6.1 基本 rebase(日常最常用)
| 命令 | 干什么 |
|---|---|
git rebase main | 把当前分支的提交搬到 main 的最新代码上面 |
git rebase main feature | 不管当前在哪个分支,把 feature 搬到 main 上面 |
git rebase --abort | 放弃整个 rebase,回到 rebase 之前 |
git rebase --continue | 解决冲突后,继续执行剩余的 rebase 步骤 |
6.2 交互式 rebase(改写提交历史)
| 命令 | 干什么 |
|---|---|
git rebase -i HEAD~3 | 交互式重做最近 3 个提交 |
git rebase -i main | 交互式把当前分支搬到 main 上面,同时可以改写每个提交 |
交互式 rebase 里可以用的动作:
| 动作 | 缩写 | 干什么 |
|---|---|---|
| pick | p | 保留这个提交,原样应用 |
| reword | r | 保留代码改动,但修改提交信息 |
| squash | s | 把这个提交和前一个合并成一条 |
| fixup | f | 和 squash 一样,但丢弃这个提交的信息 |
| drop | d | 直接扔掉这个提交 |
| edit | e | 在这个提交处暂停,你可以改代码后再继续 |
6.3 取消和挽救
| 命令 | 干什么 |
|---|---|
git rebase --abort | 放弃本次 rebase,回到 rebase 之前 |
git rebase --skip | 跳过当前正在处理的提交(慎用) |
git reflog | 查看 HEAD 的移动历史,找回 rebase 之前的旧提交 |
git reset --hard 旧哈希 | 用 reflog 找到旧哈希后,回退到 rebase 之前 |
7. 对照表(前后对比 / 选项对比)
7.1 rebase vs merge:什么时候用哪个
| 场景 | 用 rebase | 用 merge |
|---|---|---|
| 自己的功能分支要同步主分支的最新代码 | 推荐——历史更干净 | 也行,但会有合并提交 |
| 把功能分支合进 main | 先 rebase 再快进合并 | 直接 merge 也完全可以 |
| 多人同时在用的公共分支 | 绝对不要 | 用 merge |
| 想保留「同时开发了两个功能」这一事实 | rebase 会丢掉这个信息 | merge 保留了分叉 |
| 开源项目提交 PR | 很多项目要求先 rebase | 看项目规定 |
7.2 交互式 rebase 的动作对照
| 你想干什么 | 选什么动作 | 举个例子 |
|---|---|---|
| 这个提交没问题,原样保留 | pick | 默认就是 pick |
| 提交信息写错了想改 | reword | 「fix bug」→「修复登录页面验证码不显示的 bug」 |
| 3 个小提交想合成 1 个 | squash | 「修 typo」「又修」「再修」→「修复文档中的拼写错误」 |
| 和前一个合并,但不要这个提交的信息 | fixup | 中间步骤的提交信息没价值 |
| 这个提交不要了 | drop | 试试看的代码,后来没用上 |
| 想修改这个提交的代码 | edit | 提交里少改了一个文件,补上 |
7.3 冲突标记对照
图:冲突标记长这样。
| 标记 | 含义 | 白话解释 |
|---|---|---|
<<<<<<< HEAD | 当前分支(rebase 时是你正在重放的提交) | 「你」的版本 |
======= | 分隔线 | 上面是你的,下面是别人的 |
>>>>>>> feature | 被合并进来的分支 | 「别人」的版本 |
8. 安全习惯(硬规矩 + 踩坑提醒)
8.1 建议这样做
| 习惯 | 原因 |
|---|---|
| 只对自己的、还没推送的分支做 rebase | 没人基于你的旧提交在干活,改写历史不会影响别人 |
rebase 之前先git status确认工作区干净 | 脏工作区会导致 rebase 出错或中途卡住 |
rebase 之前先看一下分支线git log --graph | 确认你知道要 rebase 的范围 |
| 冲突时冷静看标记,想清楚要保留什么 | 别急着删,先理解 |
rebase 失败了先--abort | 回到原点重新来,比硬着头皮走下去安全 |
8.2 绝对不要这样做
| 禁止操作 | 后果 |
|---|---|
| 对已推送到远程的公共分支执行 rebase | 别人的本地历史和你不一样了,拉代码时冲突地狱 |
在 rebase 中途用--skip随意跳过提交 | 你跳过的那个提交的代码改动就丢了 |
rebase 时遇到冲突直接git add .+git rebase --continue | 不看冲突内容就全盘接受,可能覆盖掉别人的代码 |
| 同时开两个终端对同一个仓库做 rebase | Git 会打架,状态乱成一锅粥 |
铁律:永远不要 rebase 已经推送到共享分支上的提交。违反这条规则的后果是:团队成员拉代码时会发现历史对不上,要么合不出来,要么重复提交满天飞。如果一定要改已推送的历史,必须和所有相关的人协调好,而且要用
--force-with-lease而不是--force。
8.3 推荐的最小流程(背下来)
# 1. 确认在功能分支上gitbranch# 2. 确认工作区干净gitstatus# 3. 看一眼当前历史gitlog--oneline-5# 4. 执行 rebasegitrebase main# 5. 如果有冲突:# - 打开冲突文件,看 <<<<<<< 和 >>>>>>> 之间的内容# - 手动改成你想要的结果# - git add 冲突文件# - git rebase --continue# 6. 如果搞砸了:# - git rebase --abort(回到第 3 步的状态)# 7. 确认结果gitlog--graph--oneline-109. 真实场景速览
9.1 课程大作业:功能分支要同步主分支
你和组员协作写大作业,你的feature-login分支写了两天,Meanwhile 组员已经把注册功能合并到 main 了。
gitcheckout feature-login&&gitrebase main你的登录功能就「搬」到包含注册功能的最新代码上面了。如果冲突,按第 8 节的流程解决。
9.2 提交太多太碎,提交前想整理
你写代码时习惯每改一点就 commit 一次,现在git log一看有 8 个提交,信息全是「改了点东西」「再改改」「应该好了」。
gitrebase-iHEAD~8在编辑器里把 8 个提交的pick改成:
pick a1b2c3d 实现登录验证 squash e4f5g6h 改了点东西 squash i7j8k9l 再改改 squash m0n1o2p 应该好了 pick q3r4s5t 实现注册页面 squash t5u6v7w 修了注册的 typo squash w8x9y0z 又修 drop a1b2c3e 这次改没用保存退出后,Git 会依次处理:前 4 个合成 1 个,第 5-7 个合成 1 个,第 8 个直接删掉。最终 8 个提交变成 2 个,干干净净。
9.3 代码审查前整理历史
提 PR 之前,用git rebase -i把零碎的提交压缩成几个有意义的提交。改完之后 force push 到你自己的 fork。
gitrebase-imain# 整理好之后gitpush --force-with-lease origin feature-branch
--force-with-lease比--force安全——如果别人在你之后也推了代码,它会拒绝推送,不会覆盖别人的工作。
10. 进阶补充(选读)
- rebase 的底层原理:rebase 其实是一连串
git cherry-pick。它找到分叉点后,依次把你分支上的每个提交「摘」下来,在新基线上重新「种」上去。 - onto 参数:
git rebase --onto new-base upstream branch可以把 branch 上从 upstream 之后的提交搬到 new-base 上,适合从一个大功能分支里「切」出一部分提交。 - Autosquash:如果你提交时用了
git commit --fixup=哈希或--squash=哈希,rebase 时加--autosquash参数,Git 会自动把 fixup 提交放到对应的目标提交旁边。 - rebase 和 merge 可以共存:团队里常用的工作流是「本地用 rebase 保持直线,合进 main 时用 merge 保留分叉记录」。不用非此即彼。
11. 小实验(动手练习 + 通过标准)
实验甲:基础 rebase——把分叉变成直线
- 建一个可丢弃目录,在 main 上提交一个基线文件
- main 上再提交一次(比如修个 bug)
- 从基线处创建 feature 分支,做两个功能提交
- 用
git log --graph --all --oneline确认看到分叉 - 在 feature 分支上执行
git rebase main - 再看
git log --graph --all --oneline,确认变成一条直线
通过标准:rebase 后历史线是一条直线,没有合并提交,功能提交的哈希值变了。
实验乙:交互式 rebase——压缩提交
- 在一个分支上连续提交 3 次,信息分别是「步骤一」「步骤二」「步骤三」
- 执行
git rebase -i HEAD~3 - 把第二个和第三个的
pick改成squash - 在弹出的编辑器里编辑合并后的提交信息
- 确认
git log --oneline只剩 1 个提交
通过标准:3 个提交压缩成 1 个,代码内容不变。
实验丙:rebase 冲突解决
- 在 main 和 feature 分支上修改同一个文件的同一行,写不同内容
- 在 feature 分支上执行
git rebase main - 看到冲突提示后,打开文件查看
<<<<<<<标记 - 手动解决冲突(保留你想要的内容,删掉标记)
git add冲突文件,然后git rebase --continue- 确认 rebase 完成,历史线是直线
通过标准:能独立从冲突状态走到 rebase 完成,最终代码是你期望的内容。
实验丁:rebase abort——随时能跑
- 制造一个 rebase 冲突(同实验丙前两步)
- 看到冲突后,不解决,直接执行
git rebase --abort - 确认
git log --graph --oneline和 rebase 之前一模一样
通过标准:abort 后仓库完整回到 rebase 之前的状态,没有任何残留。
12. 常见问题 FAQ
问 1:rebase 和 merge 到底哪个更好?
没有绝对的好坏。merge 保留真实历史,rebase 让历史更整洁。自己的功能分支用 rebase 整理,合进公共分支用 merge 记录。两条路最终的代码是一样的。
问 2:为什么 rebase 后提交的哈希值变了?
因为提交的哈希是根据「内容 + 父提交 + 作者信息 + 时间」算出来的。rebase 改变了父提交,所以哈希必然不同。代码改动不变,但提交对象是全新的。
问 3:rebase 到一半不想做了怎么办?git rebase --abort,一秒回到 rebase 之前。这是 rebase 的安全网,大胆用。
问 4:我在 rebase 时冲突了,怎么知道该保留谁的?<<<<<<< HEAD和=======之间是你当前正在重放的提交的代码,=======和>>>>>>>之间是新基线上的代码。根据实际情况决定保留哪部分。
问 5:交互式 rebase 弹出的编辑器我不会用怎么办?
默认编辑器可能是 vim。进去后按i进入编辑模式,改完按Esc,输入:wq保存退出。不习惯的话可以设置编辑器:git config core.editor "code --wait"(用 VS Code)或git config core.editor "nano"。
问 6:squash 和 fixup 有什么区别?
squash 会把两个提交的提交信息都保留,让你编辑合并后的信息。fixup 直接丢弃被合并提交的信息,只保留第一个提交的信息。
13. 总结
13.1 一页速记
| 点 | 记住什么 |
|---|---|
| rebase 是什么 | 把你的提交「搬家」到新基线上面,历史变直线 |
| 和 merge 的区别 | merge 保留分叉,rebase 改写成直线;最终代码一样 |
| 哈希会变 | 搬家 = 拆掉重建,提交是新的,哈希不同 |
| 交互式 rebase | -i参数,可以 squash / reword / drop / reorder |
| 冲突处理 | 和 merge 一样看标记,解决后add+--continue |
| 放弃 rebase | git rebase --abort随时退回 |
| 铁律 | 永远不要 rebase 已推送到共享分支的提交 |
| 什么时候用 | 自己的本地功能分支同步主分支 / 整理提交 / 代码审查前 |
| 找回旧提交 | git reflog查看,git reset --hard回退 |
13.2 思维升华
rebase 改写的是「历史怎么讲」,不是「代码是什么」。
同样的代码,merge 讲了一个「两条路汇合」的故事,rebase 讲了一个「一路向上」的故事。故事不同,结局一样。但记住:公共的历史不能你一个人偷偷改写——改故事之前,先确认只有你一个人在读这本书。
13.3 延伸阅读
- Pro Git 中文版 — 变基
- Git 官方文档 — git-rebase
- Git 术语表
命令输出样例验证环境:Git 2.43.0;演示作者信息为虚构:Ada Example <ada@example.com>。
13.4 检查清单
- 能用「搬家到新楼层」的比喻解释 rebase 给同学听
- 能说出 rebase 和 merge 的 3 个区别
- 能独立完成一次基础 rebase(不分叉变成直线)
- 能用交互式 rebase 压缩多个提交
- 遇到 rebase 冲突不慌,知道如何解决
- 知道
--abort可以随时放弃 rebase - 牢记铁律:不 rebase 已推送到共享分支的提交
- 完成 4 个小实验
rebase 给了你改写历史的超能力,但「能力越大,责任越大」——只改自己的历史,不要动别人的。下一章我们讲 reflog:就算 rebase 翻车了,也还有办法救人。
