简介
如果看到问题说明你已经不满足“会用”,而是想理解 Git 的模型了
这篇文章会说明几个问题,rebase 的底层原理,为什么它叫“变基”,为什么要变基,为什么不能随便 rebase 公共分支
先看看git rebase的完整命令形式: git rebase <upstream>
<upstream> 可以是:
- 分支名(最常见)
- 远程分支(如 origin/main)
- commit SHA
- tag
本质是一个 commit 指针,也就是所谓的“基”
1.rebase 的底层原理
Git 本质是什么?
Git 是一个 有向无环图(DAG) 。
这是一个图论概念,我们可以把它拆成四个字来理解:
- 图:由节点和边组成的网络。就像地铁图,每个站是一个节点,站与站之间的线路就是边。
- 有向:边是有方向的。就像单行道,你能从A站到B站,但不能从B站直接原路返回A站(除非绕路)。它表示了依赖关系或先后顺序。、
- 无环:顺着方向走,永远走不回同一个节点。你不可能从A站出发,沿着有向的线路,最后又绕回A站。这在 Git 中意味着历史是单向发展的,不会出现“历史循环”的矛盾。
在 Git 的世界里,这三者是这样对应的:
- 节点(提交记录) :Git 中的每一次 Commit(提交) 就是一个节点。每个节点都包含:当前项目的快照(文件内容)、作者信息、提交时间、一个唯一的 SHA-1 哈希值(相当于节点的身份证号)。
- 有向边(父子关系) :每个提交节点都会指向它的父节点(前一个提交)。方向是 从子节点指向父节点,表示“我是从哪个版本演变而来的”。
- 无环:Git 的设计保证了这种父子关系是单向的。你只能从当前提交回溯到历史提交,无法从历史提交“前进”到未来提交(除非你知道未来提交的哈希值去切换)。最重要的是,不可能出现一个提交是它自己的祖先,这样就彻底杜绝了循环依赖。
假设你做了三次提交:
git commit -m "first" # 产生节点 A
git commit -m "second" # 产生节点 B,B 的父节点是 A
git commit -m "third" # 产生节点 C,C 的父节点是 B
这就构成了一个简单的有向无环图:
A <--- B <--- C
(方向永远从后向前指)
就算你创建分支(Branch)或合并(Merge)时,图会变得更复杂,但依然遵守这个规则。
ok,git的本质了解之后,rebase 实际做了什么?
假设当前结构:
A --- B --- C (origin/main)
\
D --- E (你的分支)
执行:
git rebase origin/main
Git 会做三步:
① 找到共同祖先,这里是 B
② 把 D、E 生成 patch(补丁)
Git 会:
- 计算 D 相对 B 的差异
- 计算 E 相对 D 的差异
③ 在 C 后面重新应用这些 patch
它会:
- 以 C 为 base
- 重新应用 D 的修改 → 生成 D'
- 再应用 E 的修改 → 生成 E'
注意:
D' 和 D 不是同一个 commit
它们的 SHA 完全不同
最终结构:
A --- B --- C --- D' --- E'
核心一句话
rebase 本质是:
把当前分支一组 commit 的“补丁”拿下来,换个其他分支的父节点重新应用。
2.为什么叫“变基”?
rebase = re + base
base = 基底、基础
在上面的例子中:
原来的基底是:
B
rebase 之后:
C
你把提交的“基础父节点”换掉了,所以叫“变基”
本质是:
改变 commit 的 parent 指针
3.为什么不能随便 rebase 公共分支?
关键原因:
rebase 会 改写历史
因为:
D ≠ D'
E ≠ E'
它们的 SHA 变了。上文提到,生成patch的操作,不是同一个 commit
每个 commit 都包含:
- 代码内容
- parent 指针
- 作者信息
- 时间
- message
这些信息一起算出一个 SHA 值。只要 parent 变了,SHA 就变。
举个团队事故例子
假设远程 main 是这样:
A --- B --- C (origin/main)
你 pull 下来
你本地:
A --- B --- C (main)
你写了两个提交D、E
A --- B --- C --- D --- E (main)
然后你 push 到远程:
A --- B --- C --- D --- E (origin/main)
现在:
远程 main 包含 D 和 E ,同事也能看到 D 和 E
同事拉取后:基于 E 开发了 F
A --- B --- C --- D --- E --- F (同事本地)
你突然 rebase main
你执行:
git rebase C
Git 做了什么?
它:
- 删除 D
- 删除 E
- 重新生成 D'
- 重新生成 E'
现在你本地是:
A --- B --- C --- D' --- E' (main)
然后你又push到远程
A --- B --- C --- D' --- E' (origin/main)
于是同事的历史是:A --- B --- C --- D --- E --- F
而远程是:A --- B --- C --- D' --- E'
当同事pull,从C之后的历史完全不同
核心问题只有一句话:
你改写了别人正在使用的历史。
公共分支的定义是:
别人已经基于它开发了。
你 rebase 公共分支 = 把地基抽掉,别人盖的楼悬空
只在这两种情况,rebase不会有问题:
✅ 1)还没 push 的本地 commit
别人看不到,你随便改。
✅ 2)你的个人 feature 分支
没人基于它开发。
4.为什么要变基?
很多人学 Git 时都会想:
既然有 merge,为什么还要有 rebase(变基)?
之所以需要 rebase,是因为:
有时候我们不想“保留分叉结构”,而是想“整理历史”。
merge 和 rebase 解决的是 两种不同的问题。
merge 的特点:
假设结构:
A --- B --- C (main)
\
D --- E (你的分支)
如果执行:git merge main
会变成:
A --- B --- C
\ \
D --- E --- M
特点:
- 保留真实分叉历史
- 不改写历史
- 多一个 merge commit
优点:安全
缺点:历史可能很乱
于是相反,不执行git merge main,而是执行git rebase main
会变成:
A --- B --- C --- D' --- E'
特点:
- 历史变成直线
- 没有 merge commit
- 提交更清晰
为什么“直线历史”很重要?
代码审查更清晰:
Review 时看到:
feat: add login API
fix: handle nil pointer
refactor: simplify auth
而不是:
Merge branch 'main'
Merge branch 'main'
Merge branch 'main'
对比:
有很多 merge:
* Merge branch main
|\
| * fix bug
|/
* feature update
直线历史:
feat
fix
refactor
阅读难度完全不同。
提交更“像一个故事”
rebase 让你的 feature:
看起来像是基于最新 main 一次性开发完成的。
而不是:
开发过程中不断被 main 打断。
5.真实历史和逻辑历史
merge 保留“真实历史”
rebase 强调“逻辑历史”
真实历史:
- 你开发过程中不断 pull main
- 出现很多 merge
逻辑历史:
- 功能开发完成
- 基于最新 main
- 整洁提交
很多团队确实只用 merge。
但缺点是:
- 日志越来越乱
- 每天 merge main
- 提交记录噪音多
大型项目中,这会很痛苦。
为什么不能只用 rebase?
因为:
rebase 会改写历史
公共分支必须稳定。
最本质的原因:
Git 是一个 DAG(有向无环图)。
merge = 保留分叉结构
rebase = 改变 parent 指针,让图重新排列
使用场景
场景1:
个人 feature 分支开发完成,准备push时(强烈推荐)
- 你从 main 拉出一个 feature 分支
- 开发几天
- main 有更新
- 你想把自己的提交基于最新 main
结构:
A --- B --- C --- D (main)
\
E --- F --- G (feature)
执行:
git fetch
git rebase origin/main
变成:
A --- B --- C --- D --- E' --- F' --- G'
✔ 提交历史干净
✔ 没有 merge commit
✔ PR 更好看
这是最标准的 rebase 用法。
场景2:
也是在个人分支,但在提交 PR 之前整理历史(强烈推荐)
适用于,该次功能开发过程有很多很杂的commit的情况
开发过程中可能提交很乱,假设你最近 5 个提交是:
C: fix
D: fix2
E: debug
F: again
G: final
你想把它变成:
feat: add payment API
只保留一个干净的提交。
在提 PR 之前: git rebase -i HEAD~5,整理成: feat: add payment API
git rebase -i HEAD~5
含义是:
对最近 5 个提交进行“交互式变基”
HEAD~5 = 当前提交往前数 5 个。
Git 会打开一个编辑界面,内容大概是:
pick C fix
pick D fix2
pick E debug
pick F again
pick G final
pick = 保留这个提交
但你可以改成别的指令:
| 命令 | 作用 |
|---|---|
| pick | 保留 |
| squash | 合并到前一个提交 |
| fixup | 合并但丢弃 message |
| drop | 删除这个提交 |
| reword | 修改提交信息 |
如果你想把 5 个提交合成 1 个
你可以改成:
pick C fix
squash D fix2
drop E debug
squash F again
squash G final
意思是:
- 保留第一个
- 后面全部合并进去
然后 Git 会让你编辑最终 commit message。
你改成:
feat: add payment API
为什么要整理历史,因为提交到主分支的历史应该是:
有意义的、结构清晰的功能提交
开发中随便 commit(不用太在意),准备提 PR 前,git rebase -i HEAD~N,整理干净。
因为 PR 页面会显示:
feat: add payment API
而不是:
C: fix
D: fix2
E: debug
F: again
G: final
这会让人感觉:
✔ 思路清晰
✔ 提交有结构
✔ 代码可维护
场景3:
公共分支开发的提交,但是还没有push(可以,但是不推荐)
远程 master:
A --- B --- C --- E --- F --- G (origin/master)
你本地 master:
A --- B --- C --- H (master)
- 你有一个本地提交 H
- 远程多了 E F G
- 你还没 push
你使用git fetch + git rebase
git fetch
git rebase origin/master
结果:
A --- B --- C --- E --- F --- G --- H'
H 被“挪”到 G 后面。
特点:
- 没有 merge commit
- 历史是直线
- H 变成 H'(新的 SHA)
可以是可以,但是没有必要,很多人只知道“历史干净用 rebase”,但不知道:
有些场景 必须保留 merge commit,否则会丢失关键信息。
因为有一个“保留功能边界”的需要
这部分知识,后续继续在merge相关的文章中再探讨