不会rebase就等于没学过Git

写在前面:这是 2018 年旧文的彻底重写。开头一节三分钟速查,看完就能正确上手;后面是心智模型 + 实战场景,按需深挖。

0. 三分钟速查

只想正确用 rebase、不出事?看这一节就够。后面所有内容都是为了解释 “ 为什么 “。

0.1 用 Merge 还是 rebase?

先记住一件事:这两个命令都是 “ 把另一条分支接到你当前所在的分支上 “。所以 “ 你站在哪条分支敲命令 “ 决定了方向。

1
2
3
4
5
6
7
# 你在 feature 上 → 拿 main 的最新代码接过来
git checkout feature
git rebase main # 或 git merge main

# 你在 main 上 → 把 feature 的成果合并进来
git checkout main
git merge feature # 这个方向不用 rebase——会改写 main 的历史

接下来选 merge 还是 rebase,只看一个问题:你正在操作的那条分支,别人也在用吗?

这条分支的状态用什么典型命令(你站在 feature 上)
只有自己在用(个人 feature)rebase(默认)git rebase main
已 push 给别人 review、或多人协作mergegit merge main

把功能合进主干用 git merge feature(站在 main 上),这一步永远用 merge——不存在 “rebase 一个 feature 到 main 上让 main 前移 “ 这种用法。

记一句:共享的不能 rebase,个人的随便 rebase。

GitHub Flow / Trunk-Based 团队还有一条捷径:feature 短分支,PR 在 GitHub UI 上选 “Squash and merge” 或 “Rebase and merge”,main 始终保持线性历史。本地分支按上面的规则走就行。

0.2 黄金法则

⚠️ 已经 push 给别人 review 或共用的分支,永远不要 rebase。

rebase 会把原来的 commit 换成新的(新 ID、新历史)。别人本地如果还基于旧 commit 在干活,下次 git pull 会撞上一段对不上的历史——轻则出现一堆重复 commit,重则他的修改被覆盖丢失。

判断能不能 rebase,就一句话:有没有别人正基于这条分支的历史干活? 有 → 不能 rebase。没有 → 随便 rebase。

例外:团队明确约定 PR 合并前作者要把分支 rebase 干净(很多 trunk-based 团队的标准流程),这种约定下 force push 自己的 PR 分支是允许的,但仍要 --force-with-lease(见 §2.5)。

0.3 翻车救命三步

Git 几乎没有真正不可逆的操作。rebase 翻车记住这段:

1
2
3
4
5
6
7
8
9
10
11
# 事前:rebase 前先打 tag,相当于保险绳
git tag backup-before-rebase
git rebase main

# 事后翻车:回到 rebase 前
git reset --hard ORIG_HEAD # 最简单,rebase 刚做完时
git reset --hard backup-before-rebase # 或者用刚才打的 tag

# 兜底:上面都不行,用 reflog 找回任何位置
git reflog # 看历史,找到 rebase 前那一行
git reset --hard <那一行的 commit hash> # 比如 abc1234

⚠️ git reset --hard丢掉工作区里所有没 commit 的修改。动手前先 git stash,或者确认工作区干净。

ORIG_HEAD 只保留最近一次会改写历史的操作的起点。rebase 后又跑了 reset/merge 之类,ORIG_HEAD 就被覆盖了——这时只能靠 tag 或 reflog。

口诀:rebase 之前先打 tag,翻车之后再喝茶

1. 心智模型:rebase 到底在做什么

1.1 一句话直觉

merge 是会师,rebase 是重走一遍。

merge 在两条分支之间打一个 “ 我们合并于此 “ 的结点,留下双亲拓扑。rebase 则像把你的分支提起来,沿着新的起点重新走一遍,结果是一条直线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
合并前的状态:

A---B---C feature
/
D---E---F main


git checkout main && git merge feature 之后:

A---B---C
/ \
D---E---F-------M main
↑ 多了一个 merge 节点


git checkout feature && git rebase main 之后:

A'--B'--C' feature
/
D---E---F main
↑ feature 整条被"搬"到 main 顶端,历史是直线

注意 A’ 不是 A——这是 rebase 最容易让人困惑的事,下一节解释。

1.2 为什么 Rebase 后 Commit ID 必然改变

rebase 完,所有被搬的 commit 哈希全变了。代码没动,ID 凭什么不一样?

要回答这个,先把三件事拎清楚。

第一,base(基点)。一条分支的 base 就是它从父分支岔出来的那个 commit。在上图里,feature 的 base 是 E。”rebase” 字面意思就是 “ 换 base “——把基点从 E 换到 F。

第二,一个 commit 存的是快照,不是 diff。可以把它想成那一刻给整个项目拍的一张照片(Git 内部叫 tree 对象)。两次提交之间的 diff 不是事先存好的,是 Git 看着前后两张照片现算的。

第三,所以要 “ 搬 “ 一个 commit 到别处,Git 不能直接抬走照片——照片绑死在原位置上。它得反过来推:这次提交相对上一次改了什么?把这段差异(patch)提出来,到新位置上重做一遍,拍一张新照片。新照片就是新 commit。

到这里直觉版答案已经成立:照片是新的,指纹自然变了。下面是精确版。

commit ID 是这堆内容算出来的 “ 指纹 “(专业说法是 SHA-1 哈希,新仓库是 SHA-256)——内容动一个字节,指纹整个变。一个 commit 的 “ 内容 “ 包括:

  • 它指向的 tree(工作区快照)
  • 父 commit 的 ID
  • 作者、提交者、时间戳
  • 提交信息

关键在第二项:父 commit 的 ID 也算进指纹。rebase 把 A 重放到新 base 得到 A’ 时:

  • 工作区快照一样(patch 应用结果相同)
  • 但父 commit 变了——A 的父是 X,A’ 的父是 Y
  • 父变 → A’ 的指纹跟着变 → commit ID 不同

这是 rebase 一切 “ 魔幻 “ 现象的根源:改写历史 = 造一组新 commit,再把分支指针挪过去。原来那些老 commit 还安安静静躺在对象数据库里,只是没人指着它们了——所以 reflog 才能找回来。

2. 七个高价值场景

按掌握优先级排序:前几个是日常必用,后面是按需翻阅。每个场景给出问题、最小命令序列和易踩的坑。

2.1 压扁本地脏 Commit

开发 feature-x 时你提交了 6 次:

1
2
3
4
5
6
* 9af3b21 finish feature x
* 8b1c4d3 typo fix
* 7e2f9c8 wip
* 6d5e4a1 wip
* 5c4b3a0 try another approach
* 4a3b2c1 start feature x

PR 上去 reviewer 看到 6 个 wip 会想骂人。把它们合并成一两个有意义的 commit:

1
2
# 进入交互式 rebase,整理最近 6 个 commit
git rebase -i HEAD~6

编辑器打开后:

1
2
3
4
5
6
pick 4a3b2c1 start feature x
pick 5c4b3a0 try another approach
pick 6d5e4a1 wip
pick 7e2f9c8 wip
pick 8b1c4d3 typo fix
pick 9af3b21 finish feature x

把它改成:

1
2
3
4
5
6
pick   4a3b2c1 start feature x
fixup 5c4b3a0 try another approach
fixup 6d5e4a1 wip
fixup 7e2f9c8 wip
fixup 8b1c4d3 typo fix
reword 9af3b21 finish feature x

保存退出,Git 会把 4 个 fixup commit 都并入第一个 commit(fixup 丢弃它们自己的 message),最后让你给 9af3b21 改 message。结果是 6 个 commit 压成 2 个。

rebase -i 关键字速查:

关键字行为
pick保留这个 commit
reword保留 commit,但只改 message
edit保留 commit,但停下来让你改文件(见 §2.2)
squash并入上一个 commit,两份 message 都保留(让你编辑)
fixup并入上一个 commit,丢弃本 commit 的 message
drop直接扔掉这个 commit

几个易错点:

  • HEAD~6 数错了不要紧,编辑器里能看到全部待整理的 commit,多余的直接删行(drop 或者把那一行删掉)即可。
  • squash 与 fixup 的差别只在 message。日常 wip 类型的合并几乎永远用 fixup。
  • rebase -i 列表是按时间从老到新显示(与 git log 默认相反)。改顺序时方向别搞反。

2.2 修改某个历史 Commit

“ 我两个 commit 之前忘加一段代码 / message 写错了 / 想把一个 commit 拆成两个 “——这是 rebase 的另一个高频用途。用 edit 关键字。

1
git rebase -i HEAD~3

把那一行的 pick 改成 edit

1
2
3
edit  4a3b2c1 add user signup endpoint
pick 5c4b3a0 add login endpoint
pick 9af3b21 add password reset

保存退出。Git 把 HEAD 移到 4a3b2c1,停下来等你。这时你处在那个 commit 之上,可以做任何事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 场景 A:往这个 commit 里追加文件改动
vim src/server.go
git add .
git commit --amend --no-edit # 内容并入这个 commit,不改 message
# 不加 --no-edit 就同时打开编辑器改 message

# 场景 B:把这个 commit 拆成两个
git reset HEAD^ # 把改动退回工作区
git add src/handler.go
git commit -m "add signup handler"
git add src/validator.go
git commit -m "add signup validator"

# 改完后继续,后续 commit 自动重放到你新历史之上
git rebase --continue

后面的 5c4b3a0 / 9af3b21 会自动接着往下重放,commit ID 全部变化(理由见 §1.2)。

⚠️ 这条分支如果已 push 给别人 review,按黄金法则就别做这种改写。要做也得先沟通,再 --force-with-lease

2.3 上游飘了

你在 feature 分支干活,team 别人合了几个 PR 进 main。你 push 时被拒:

1
! [rejected]  feature -> feature (non-fast-forward)

意思是 main 飘了,需要把你的 feature 接到新的 main 上。一键搞定:

1
2
3
4
5
git pull --rebase origin main

# 等价于:
# git fetch origin
# git rebase origin/main

工作区有未提交修改?加 --autostash,Git 自动帮你 stash → rebase → pop,不用手动来回操作:

1
git pull --rebase --autostash origin main

强烈建议永久启用:

1
2
git config --global pull.rebase true
git config --global rebase.autoStash true

设上以后 git pull 默认就走 rebase,不会再产出 “Merge branch ‘main’ of …” 这种垃圾合并 commit;脏工作区也不会再挡路。

⚠️ 如果 feature 分支是给别人共用的(比如和同事在同一条分支上协作),不要无脑 pull --rebase,会改写已 push 的 commit。这就是黄金法则。

冲突解决和普通 rebase 一样:git addgit rebase --continue

2.4 翻车:冲突解读与决策树

rebase 跑到一半停了:

1
2
CONFLICT (content): Merge conflict in src/server.go
error: could not apply 7e2f9c8... add metrics

下一步怎么走?

1
2
3
4
5
┌─ 这个冲突我能解 ──→ 编辑文件 → git add → git rebase --continue

├─ 这个 commit 不重要、可以丢 ──→ git rebase --skip

└─ 完全搞不定 / 改主意了 ──→ git rebase --abort (回到 rebase 前)

git status 在 rebase 中能告诉你不少信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git status
interactive rebase in progress; onto abc1234
Last commands done (3 commands done):
pick 4a3b2c1 start feature x
pick 5c4b3a0 try another approach
Next commands to do (2 remaining commands):
pick 7e2f9c8 wip
pick 9af3b21 finish feature x
(use "git rebase --edit-todo" to view and edit)
You are currently rebasing branch 'feature' on 'abc1234'.

Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: src/server.go

这段输出告诉你:你正在 rebase 中、已经搬过 3 个 commit、还剩 2 个待搬,src/server.go 被两边都改了。

冲突连环爆时,用 git rebase --edit-todo 打开剩余 todo——可以临时把后面几个不重要的 commit 改成 drop 跳过去,或者重排顺序。比一路 --skip 灵活得多。

冲突标记速读:

1
2
3
4
5
<<<<<<< HEAD
log.Println("[INFO]", msg) ← 已经搬过去的版本(rebase 视角下的 "ours")
=======
log.Printf("[INFO] %s\n", msg) ← 正在重放的、原本属于你的 commit(rebase 视角下的 "theirs")
>>>>>>> 7e2f9c8 (add metrics)

⚠️ rebase 中的 ours/theirs 是反的。

平时 merge 时 --ours 指你的分支,--theirs 指对方分支。但 rebase 工作方式是 “ 把你的分支搬到对方头上 “,所以 rebase 视角下 --ours 反而指那个新基点(对方的最新代码),--theirs 指正在重放的、原本属于你的 commit。这是 Git 历史包袱里最反直觉的一处,记住就好。

可视化合并工具救场:

1
git mergetool   # 打开你配置的 vimdiff / meld / VSCode

解完后照样 git add + git rebase --continue

2.5 安全推回远程

你已经 push 过的 feature 分支,rebase 后再 push:

1
2
3
$ git push
! [rejected] feature -> feature (non-fast-forward)
hint: Updates were rejected because the tip of your current branch is behind

因为 rebase 改写了 commit ID,远程那条分支不再是你本地的祖先。需要 force push。--force-with-lease,别用 --force

1
git push --force-with-lease

--force-with-lease 在做什么:它把 “ 你以为远程是什么状态 “ 作为一个租约(lease)附在 push 上。push 时 Git 检查远程的真实状态是否与你以为的一致:

  • 一致 → 允许覆盖(说明这段时间没人动过)
  • 不一致 → 拒绝(有人 push 过新 commit,覆盖会毁人家工作)

更稳的做法是再加 --force-if-includes,防止本地长时间没 fetch 时 lease 误判。建议设成 alias:

1
git config --global alias.pushf 'push --force-with-lease --force-if-includes'

之后 rebase 完直接 git pushf

⚠️ 永远不要在共享分支(main / develop / release/*)上 force push,无论加什么保护选项。

2.6 边写边修:Autosquash 工作流

-2.1 的整理是事后做的。但如果你边开发边知道 “ 这个 commit 是为了修上一次的 typo”,能不能让 Git 自动帮你归位?能。

1
2
3
4
5
6
7
8
9
10
11
12
# 第一次 commit
git commit -m "add user signup endpoint"

# 半小时后发现拼错了,但不想新开一个 commit
# 用 --fixup 标记:这是用来修上面那个 commit 的
git commit --fixup <那个 commit 的哈希>

# 又过一会,又要修同一个 commit
git commit --fixup <还是那个哈希>

# 最后整理时一键合并:
git rebase -i --autosquash <分支基点>

打开编辑器你会看到 Git 已经帮你自动重排好顺序,把每个 fixup 都对到了它要修的 commit 旁边:

1
2
3
4
pick   c1aa11 add user signup endpoint
fixup c2bb22 fixup! add user signup endpoint
fixup c3cc33 fixup! add user signup endpoint
pick c4dd44 add user login endpoint

直接 :wq 保存,rebase 自动跑完,三个 commit 合成一个,message 干净如初。

永久开启 autosquash:

1
git config --global rebase.autoSquash true

之后 git rebase -i 默认带 autosquash 行为。

注意 --fixup 的目标 commit 必须还在你 “ 将来要 rebase 的范围 “ 里。如果它已经在远程公共分支上、又不能 force push,那就别这么干。

2.7 验证 Rebase 没改错

rebase 完最大的不安:我是不是把代码搞坏了?两个命令立刻见分晓。

最终代码状态对比——rebase 不应改变最终 diff,只是换了 base:

1
2
3
4
5
6
7
# rebase 前先打个 tag 记下旧 ref
git tag before-rebase
git rebase main

# rebase 后对比:feature 相对 main 的最终改动是不是一样?
git diff before-rebase..main feature..main
# 没输出就说明完全一致,rebase 没改坏代码

逐 commit 的 patch 对比——这个更精细,能告诉你 rebase 过程中是否解错了某个冲突:

1
git range-diff before-rebase main feature

range-diff 把两段历史按 patch 相似度配对,逐 commit 显示差异。理想情况下每行都是 =(patch 完全一致);出现 ! 才需要细看——通常对应你解冲突时手动改过的地方,确认那次改动是有意为之就行。

确认无误后删 tag:git tag -d before-rebase

3. 什么时候用 Cherry-pick 而不是 Rebase

想 “ 搬 “ 东西时,按搬的量选工具:

我想做的事用什么
把分支 A 的全部新 commit 接到 B 上站在 A 上 git rebase B
把分支 A 上的一两个 commit 复制到 B 上git cherry-pick <hash>

cherry-pick 同样会改 commit ID(道理同 §1.2)。