卷 IV · 网CH 18深度 18/24

rebase:不是移动,是重新做一遍

「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 变成孤儿
▶ 动手 · merge 与 rebase 对照

三个按钮切着看。重点盯两行统计:「新增提交」和「D、E 的哈希」。

特别注意第三个(rebase)里那两个虚线圈——那是原来的 D 和 E。

rebase 实际执行的步骤

git rebase main(站在 feat 上)拆开,它做的是:

  1. 找出 merge-base(第 16 章)——这里是 B。
  2. 列出 feat 独有的提交——从 B 到 E,也就是 D、E。
  3. 把 HEAD 移到 main(C),进入 detached 状态(第 7 章)。
  4. 对 D:算出 D 相对它父提交的 diff(第 14 章的 Myers),把这个 diff 打在 C 上,造一个新提交 D′
  5. 对 E:同理,把 E 的 diff 打在 D′ 上,造出 E′。
  6. 把 feat 这张贴纸挪到 E′
◆ 关键在第 4 步:它是「重做」,不是「搬运」

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 之后所有提交号都变了」这个问题,答案不在 rebase 的实现里。

只要满足这两条:哈希由内容决定内容里包含父提交的哈希——那么改动任何一个提交,它之后的所有提交必然改名。没有任何办法绕过。

换个说法:Git 里根本不存在「修改一个提交」这种操作。

所有看起来像修改的命令——amendrebasefilter-reposquash——实际做的都是造一批新提交,然后挪贴纸。老的那些一个都没动,只是失去了引用(第 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 中途出事了怎么办

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 靠谱得多。

⌗ 掀开 .git 看一眼

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 里只意味着「失去了通往它的路径」。

✎ merge 还是 rebase:一场没有正确答案的争论

这是 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-pickrevert。你会发现它们和 rebase 是同一台引擎——rebase 就是「连续 cherry-pick 一串提交」,而 revert 只是「把补丁反过来用」。三个命令,一个原理。