rebase:不是移动,是重新做一遍
「rebase 就是把我的提交挪到主干后面」——这个说法能让你用对命令,但会让你在出事时完全无法理解发生了什么。rebase 从不移动任何提交。它造了一批新的,然后把老的扔在那儿。这一章把那个连锁反应完整走一遍。
两条路,同一个起点
还是那张分叉的图(第 15 章):
C (main)
/
A ← B
\
D ← E (feat)
你想把 feat 的工作和 main 汇到一起。两条路:
- merge:造一个有两个父提交的 M,把两条线汇进去。D、E 原封不动。
- rebase:把 D、E 的改动在 C 上重做一遍,得到 D′、E′。原来的 D、E 变成孤儿。
三个按钮切着看。重点盯两行统计:「新增提交」和「D、E 的哈希」。
特别注意第三个(rebase)里那两个虚线圈——那是原来的 D 和 E。
rebase 实际执行的步骤
把 git rebase main(站在 feat 上)拆开,它做的是:
- 找出 merge-base(第 16 章)——这里是 B。
- 列出 feat 独有的提交——从 B 到 E,也就是 D、E。
- 把 HEAD 移到 main(C),进入 detached 状态(第 7 章)。
- 对 D:算出 D 相对它父提交的 diff(第 14 章的 Myers),把这个 diff 打在 C 上,造一个新提交 D′。
- 对 E:同理,把 E 的 diff 打在 D′ 上,造出 E′。
- 把 feat 这张贴纸挪到 E′。
rebase 拿到的不是「D 这个对象」,而是「D 带来的改动」——一个补丁。
然后它在新的地基上重新应用这个补丁,产生一个全新的提交对象。
这就是为什么:
- rebase 可能产生冲突——打补丁当然可能打不上(第 17 章那套机制在这里同样起作用)。
- D′ 的 tree 可能和 D 的 tree 不一样——因为地基不同,同样的改动落在不同的内容上,结果自然不同。
- D′ 和 D 是两个不同的提交,尽管它们「带来的改动」一模一样。
那个连锁反应
第 4 章埋的伏笔,现在收:
commit 对象里写着 parent(第 4 章)。而哈希由内容决定(第 2 章)。所以:
D 的 parent 从 B 变成 C
→ D 这个对象的内容变了
→ D 的哈希变了,它现在是 D′
E 的 parent 里写着 D 的哈希,而 D 已经不是那个哈希了
→ E 也必须重建成 E′
→ E 的哈希也变了
…… 一路连锁到分支顶端
「为什么 rebase 之后所有提交号都变了」这个问题,答案不在 rebase 的实现里。
只要满足这两条:哈希由内容决定、内容里包含父提交的哈希——那么改动任何一个提交,它之后的所有提交必然改名。没有任何办法绕过。
换个说法:Git 里根本不存在「修改一个提交」这种操作。
所有看起来像修改的命令——amend、rebase、filter-repo、squash——实际做的都是造一批新提交,然后挪贴纸。老的那些一个都没动,只是失去了引用(第 5 章)。
黄金法则,和它的理由
那条被说烂了的规矩:
不要 rebase 已经推送出去的分支。
现在可以给出精确的理由了:
你 rebase 之后,本地是 D′、E′。服务器上还是 D、E。这两组提交谁也不是谁的祖先(第 15 章的分叉定义),所以:
- push 会被拒绝(non-fast-forward,第 8 章)。
- 你强推上去,服务器上的 D、E 就失去了引用。
- 但同事本地还有 D、E,而且他可能已经基于它们提交了新东西。
- 他下次 pull,会看到一堆内容相同但哈希不同的提交,Git 会试图把两组都合进来——于是同一个改动出现两次,还带着一堆冲突。
问一句:这些提交,别人手里有没有?
没有(只在你本地、或者只有你一个人用这个分支)→ 随便 rebase。历史更干净,没有任何风险。
有(推到共享分支了、别人可能拉过)→ 别 rebase,用 merge。或者用 revert 往前走(第 19 章)。
注意这个判据跟「推没推过」不完全等价——推到一个只有你在用的个人分支上,rebase 完全没问题(--force-with-lease 强推即可)。真正的判据是有没有别人依赖着这段历史。
交互式 rebase
git rebase -i 是同一台引擎,只是让你在重放之前编辑那份清单:
$ git rebase -i HEAD~4
pick 7f2e8b1 加了登录接口 pick a3f21c9 修了个错别字 pick 4d7e8c1 加了测试 pick 9b1c3f5 又修了个错别字 # 可用的命令: # p, pick = 照用这个提交 # r, reword = 用它,但改提交信息 # e, edit = 用它,但停下来让我改内容 # s, squash = 并进上一个提交,两条信息都留着 # f, fixup = 并进上一个提交,丢掉这条信息 # d, drop = 删掉这个提交 # 调整行的顺序 = 调整提交的顺序
把它改成:
pick 7f2e8b1 加了登录接口 fixup a3f21c9 修了个错别字 ← 并进上面那个,丢掉信息 pick 4d7e8c1 加了测试 fixup 9b1c3f5 又修了个错别字 ← 并进「加了测试」
保存退出,四个提交变成两个干净的。
有个能省掉手工编辑的技巧:
# 提交时就标明「这是给某个提交的修补」 $ git commit --fixup 7f2e8b1 # 然后自动排好序并执行 $ git rebase -i --autosquash HEAD~5
--autosquash 会自动把 fixup! 开头的提交排到对应提交下面并标成 fixup。设一次配置就能永久开启:
$ git config --global rebase.autosquash true
rebase 是一个一个提交重放的,中途可能在任何一步卡住(冲突)。这时候你有三条路:
$ git rebase --continue # 解决完冲突(记得 git add),继续下一个 $ git rebase --skip # 跳过这个提交(它的改动会丢失,想清楚) $ git rebase --abort # 全部放弃,干净地回到 rebase 之前
--abort 是完全安全的——它用 ORIG_HEAD(第 7 章)把你送回原点,什么都不会丢。拿不准就 abort,没有任何代价。
如果你已经 rebase 完了才发现搞砸了:
$ git reflog # 找 rebase 开始前的位置 $ git reset --hard ORIG_HEAD # 或者直接用这个
还有一个专门用来验证 rebase 有没有改坏东西的命令,很多人不知道:
$ git range-diff main@{1} main@{0}
# 或者显式给两段
$ git range-diff main old-tip new-tip
它比较的是两组补丁,能告诉你「rebase 前后每个提交的内容有没有变化」。大型 rebase 之后跑一下,比逐个 diff 靠谱得多。
rebase 进行中时,状态全在 .git 下摆着:
$ ls .git/rebase-merge/
git-rebase-todo 还没做的那些(你在 -i 里编辑的就是它) done 已经重放完的 onto 重放到哪个提交上 head-name 原来是哪个分支(结束时贴纸要贴回去) stopped-sha 卡在哪个提交上了
你甚至可以在 rebase 卡住时编辑 git-rebase-todo,改变后面还没执行的计划:
$ git rebase --edit-todo
验证「rebase 造了新对象、老对象还在」:
# rebase 前记下顶端 $ OLD=$(git rev-parse HEAD) $ git rebase main # 新的顶端,哈希完全不同 $ git rev-parse HEAD # 但老的那个还能读出来!它只是没有贴纸了 $ git cat-file -p $OLD $ git log --oneline $OLD | head -5
那段历史完完整整还在库里。这就是第 5 章那句话的又一次印证:「删除」在 Git 里只意味着「失去了通往它的路径」。
这是 Git 社区吵得最久的话题之一。两边都有道理,因为他们在优化不同的东西:
| merge 派 | rebase 派 | |
|---|---|---|
| 主张 | 历史应该如实记录发生了什么 | 历史应该讲一个清晰的故事 |
| 优点 | 不改写任何东西,绝对安全;保留了「这两条线曾经并行」的事实 | 线性历史,git log 好读,bisect 更有效 |
| 缺点 | 合并泡泡多了图很乱;主干上一堆无信息量的 merge 提交 | 改写历史,用错了会伤到别人;丢失了「并行过」的信息 |
一个大多数团队都能接受的折中:
- 功能分支内部:随便 rebase,把混乱的开发过程整理成几个干净的提交(这些提交只有你一个人碰过)。
- 合进主干时:用
merge --no-ff,保留「这是一个功能」的结构(第 16 章)。 - 拉取更新时:
git pull --rebase,避免那些「Merge branch 'main' of ...」的噪音提交。
这个组合让主干的 --first-parent 视图干干净净,同时每个功能内部的历史也是可读的。
「我的 PR 上有 17 个提交,一半是『修一下』『再修一下』。reviewer 让我整理一下。」
这正是 rebase -i 的主场,而且这个分支只有你在用,所以完全安全:
# 1. 先备份一张贴纸(41 字节,不用犹豫) $ git branch backup-before-squash # 2. 整理从主干分出来之后的所有提交 $ git rebase -i main # 3. 在编辑器里把「修一下」那些改成 fixup,调整顺序 # 4. 检查一下内容有没有被改坏 $ git diff backup-before-squash # 应该没有任何输出! # 5. 强推(安全版本) $ git push --force-with-lease
第 4 步是关键。如果 rebase 只是重组了提交、没有改变最终内容,那么 git diff 的结果应该完全为空。有输出就说明哪里出岔子了——这时候还能 git reset --hard backup-before-squash 回去。
第 5 步一定要用 --force-with-lease 而不是 --force(第 8 章):万一有人在你的分支上推了东西,它会拒绝而不是覆盖。
这一章的一句话
rebase 从不移动提交——它算出每个提交的 diff,在新地基上重新应用,造出一批全新的提交对象。父提交变了,哈希必然变,一路连锁到顶端;老的那些成了孤儿,还在库里但没了名字。「不要 rebase 已推送的分支」的真正判据不是「推没推过」,而是「别人手里有没有这段历史」。
下一章:cherry-pick 和 revert。你会发现它们和 rebase 是同一台引擎——rebase 就是「连续 cherry-pick 一串提交」,而 revert 只是「把补丁反过来用」。三个命令,一个原理。