不会rebase就等于没学过Git
写在前面:这是 2018 年旧文的彻底重写。开头一节三分钟速查,看完就能正确上手;后面是心智模型 + 实战场景,按需深挖。
0. 三分钟速查
只想正确用 rebase、不出事?看这一节就够。后面所有内容都是为了解释 “ 为什么 “。
0.1 用 Merge 还是 rebase?
先记住一件事:这两个命令都是 “ 把另一条分支接到你当前所在的分支上 “。所以 “ 你站在哪条分支敲命令 “ 决定了方向。
1 | # 你在 feature 上 → 拿 main 的最新代码接过来 |
接下来选 merge 还是 rebase,只看一个问题:你正在操作的那条分支,别人也在用吗?
| 这条分支的状态 | 用什么 | 典型命令(你站在 feature 上) |
|---|---|---|
| 只有自己在用(个人 feature) | rebase(默认) | git rebase main |
| 已 push 给别人 review、或多人协作 | merge | git 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 | # 事前:rebase 前先打 tag,相当于保险绳 |
⚠️
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 | 合并前的状态: |
注意 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 | * 9af3b21 finish feature x |
PR 上去 reviewer 看到 6 个 wip 会想骂人。把它们合并成一两个有意义的 commit:
1 | # 进入交互式 rebase,整理最近 6 个 commit |
编辑器打开后:
1 | pick 4a3b2c1 start feature x |
把它改成:
1 | pick 4a3b2c1 start 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 | edit 4a3b2c1 add user signup endpoint |
保存退出。Git 把 HEAD 移到 4a3b2c1,停下来等你。这时你处在那个 commit 之上,可以做任何事:
1 | # 场景 A:往这个 commit 里追加文件改动 |
后面的 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 | git pull --rebase origin main |
工作区有未提交修改?加 --autostash,Git 自动帮你 stash → rebase → pop,不用手动来回操作:
1 | git pull --rebase --autostash origin main |
强烈建议永久启用:
1 | git config --global pull.rebase true |
设上以后 git pull 默认就走 rebase,不会再产出 “Merge branch ‘main’ of …” 这种垃圾合并 commit;脏工作区也不会再挡路。
⚠️ 如果 feature 分支是给别人共用的(比如和同事在同一条分支上协作),不要无脑
pull --rebase,会改写已 push 的 commit。这就是黄金法则。
冲突解决和普通 rebase 一样:git add → git rebase --continue。
2.4 翻车:冲突解读与决策树
rebase 跑到一半停了:
1 | CONFLICT (content): Merge conflict in src/server.go |
下一步怎么走?
1 | ┌─ 这个冲突我能解 ──→ 编辑文件 → git add → git rebase --continue |
git status 在 rebase 中能告诉你不少信息:
1 | $ git status |
这段输出告诉你:你正在 rebase 中、已经搬过 3 个 commit、还剩 2 个待搬,src/server.go 被两边都改了。
冲突连环爆时,用 git rebase --edit-todo 打开剩余 todo——可以临时把后面几个不重要的 commit 改成 drop 跳过去,或者重排顺序。比一路 --skip 灵活得多。
冲突标记速读:
1 | <<<<<<< HEAD |
⚠️ 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 | $ git push |
因为 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 | # 第一次 commit |
打开编辑器你会看到 Git 已经帮你自动重排好顺序,把每个 fixup 都对到了它要修的 commit 旁边:
1 | pick c1aa11 add user signup 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 | # rebase 前先打个 tag 记下旧 ref |
逐 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)。