rebase

8 阅读8分钟

简介

如果看到问题说明你已经不满足“会用”,而是想理解 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相关的文章中再探讨