# rebase 到底做了什么：一行代码没改，提交的哈希为什么全变了

rebase 是把提交在新基底上重做一遍，不是搬运——父提交也在哈希里，所以整串哈希全变；也正因为新提交不再是旧提交的后代，推送才必须动用 --force-with-lease

> Git 分支 · Git 远程仓库 · Git 基础操作 · 约 6 分钟 · 10 月 21 日

## 本篇要点

1. rebase 的官方定义是“把一串提交重新应用到另一个基底上”，动作是重做而非移动，N 个提交会产出 N 个新提交。
2. 提交对象的哈希对整块内容计算，父提交是谁也在其中，所以只要换了基底，哪怕改动一字未变，哈希也一定不同；后续每个提交的父提交都变了，于是整串哈希全部改变。
3. committer 时间默认被设为当前时间（author date 保留），这是另一个会变的地方，但即使不看它，父提交变了哈希也必然变。
4. 旧提交没有被删除，只是不再被任何分支或标签引用，reflog 里仍能找到。
5. rebase 之后推被拒不是因为内容冲突，而是因为新提交不以远端那个提交为祖先，不构成快进；这道闸只看祖先关系，不看内容。
6. --force-with-lease 要求远端引用的当前值等于本地 remote-tracking 记录的值，所以 rebase 之后它正好成立；若期间别人推过新提交则拒绝，这正是它比 --force 安全的地方。
7. 能否 rebase 只取决于这条分支上有没有别人在提交：自己的功能分支可以，main 和共享分支不行；merge 不改动任何原有提交的哈希，rebase 则一个都不保留。

---

你照着同事说的做：

```bash
git switch feature
git rebase main
```

屏幕上滚过一句 `Successfully rebased and updated refs/heads/feature.`。你只是想接上 `main` 上那批新提交，自己一行代码都没动。然后：

```bash
git push
! [rejected]  feature -> feature (non-fast-forward)
```

两个地方想不通。第一，我什么都没改，为什么推送会被拒？第二，按提示强推上去以后，PR 页面上那几个提交的哈希全变成新的了，内容看着又和原来一样。

这两个问题有同一个答案：**rebase 并没有把你的提交“搬”过去，它把每个提交在新起点上重做了一遍，产出的是一串全新的提交。**

## 先看清 rebase 到底在做什么动作

拿[合并那篇](https://pangzhengboyin.com/articles/git-merge-fast-forward-vs-merge-commit-31113dee)用过的图，稍微改一下：你从 `E` 切出 `feature`，做了 A、B、C 三个提交；与此同时，同事往 `main` 上又推了 F。

```mermaid
flowchart LR
  D["D"] --> E["E"]
  E --> F["F（同事新推的）"]
  E --> A["A"] --> B["B"] --> C["C"]
  F --> A2["A′"] --> B2["B′"] --> C2["C′"]
```

`git rebase main` 做的事是：找出 `feature` 上有、`main` 上没有的提交（A、B、C），从 `main` 的头 `F` 开始，把它们挨个重做一遍，最后把 `feature` 指针挪到重做出来的最后一个提交 `C′` 上，`HEAD` 跟着走。

关键词是“重做”。它的官方描述就是 *Reapply commits on top of another base tip*——把一串提交移植到另一个起点上。不是“把 A 摘下来插到 F 后面”，而是“照着 A 的改动，在 F 上重新做一次提交”，得到 A′。B 原本接在 A 后面，现在只能接在 A′ 后面，得到 B′；C 同理得到 C′。

## 为什么“重做”就等于换哈希

要理解这一点，得先知道一个提交到底是什么。

在 Git 里，一次提交是一个实实在在的对象。它的内容主要有几项：这次提交对应的目录树（整个项目此刻长什么样）、**父提交是谁**、作者和作者时间、提交者（committer）和提交者时间、提交信息。

哈希就是对这一整块内容算出来的指纹。内容有任何一个字节不同，指纹就不同——这是哈希的基本性质，不是什么额外机制。

这里最容易卡住的是“父提交是谁也在内容里”。打个比方：你的身份证号里编进了户籍地代码，你搬了家，人还是你，号码却必须换。父提交就是提交的“户籍地”。A′ 的改动和 A 一模一样，但它的父提交是 F，而 A 的父提交是 E——内容不同，指纹自然不同。

还有个次要但确实在变的地方：committer 时间默认被设为当前时间（保留下来的是作者的 author date）。这也参与哈希。不过就算不看它，父提交变了，哈希也必然变。

## 一个变了，后面全跟着变

这是连锁的。B′ 的父提交是 A′，不是 A，所以 B 也必须重新生成；C′ 的父提交是 B′。三个提交进去，三个新对象出来，A、B、C 的哈希全部作废。

所以“rebase N 个提交”不是“N 个提交挪个位置”，而是“造出 N 个新提交”。这也解释了为什么 rebase 之后 `git log` 里作者还是你、作者时间还是原来那几个，哈希却是新的。

## 旧提交没有被删掉

`feature` 指针从 C 挪到了 C′，A、B、C 就不再被任何分支或标签指着了。Git 管这叫不可达（unreachable）。它们仍然躺在 `.git/objects` 里，`git reflog` 还记得 `feature` 曾经指向 C。

rebase 感觉像“把东西改没了”，实际上消失的只是指向旧提交的那个名字。至于怎么用 reflog 把这种状态找回来，后面单独讲；这里只需要知道，rebase 动的是引用，不是对象本身。

## 服务器看到的不是“加了东西”，是“换了东西”

现在回到推送被拒。规则在[推送那篇](https://pangzhengboyin.com/articles/why-git-push-rejected-force-with-lease-f816f580)讲过：服务端只接受快进——你送来的提交，必须以远端那个提交为祖先。

rebase 之后，你本地 `feature` 上是 A′B′C′，远端 `feature` 上还是 ABC。往上走 A′ 的父链，是 F、E、D……里面没有 C。所以在服务器眼里，这不是“在 C 后面又加了三个提交”，而是“你要用别的东西替换掉 C”。两条历史在 C 处分叉，`non-fast-forward`，拒。

注意被拒的原因不是内容冲突。从你这条分支贡献的补丁看（`git show A` 和 `git show A′`），改动一模一样。服务端管的从来不是内容，是祖先关系。很多人在这里卡住，就是因为一直用“内容相同”去推“应该允许”，而这道闸根本不看内容。

## 这样 --force-with-lease 的含义才说得清

`--force` 是“别检查了，照我说的挪指针”。

`--force-with-lease` 是“**如果远端那个指针还停在我见过的地方，就挪；否则拒绝**”。官方文档的说法是：单独写 `--force-with-lease` 时，它会要求远端引用的当前值，等于我们为它保存的 remote-tracking 引用（也就是你本地的 `origin/feature`）。

放到刚才的场景里对一遍：

- rebase 之前你 fetch 过，本地 `origin/feature` 记着 C；
- rebase 只动了 `feature`，`origin/feature` 还是 C；
- 服务器上此刻也确实是 C → lease 有效 → 推成功，远端 `feature` 换成 C′，旧历史被真正替换。

换个情况：你 rebase 的这段时间，同事往 `feature` 推了个新提交，服务器上已经是 X 而不是 C。lease 不成立，被拒——你打算覆盖的东西里，有你没见过的部分。

这就是“rebase 之后必须强推”和“强推很危险”为什么能同时成立：强制推送是替换整段历史的必要手段，而 lease 只替你确认了一件事——**服务器上那段历史仍然是我看到的那一段**。它不能替你判断那段历史该不该被替换，那得你自己知道这条分支上有没有别人。

## 什么时候能 rebase，什么时候不能

判断标准只有一条：这条分支上除了你，还有没有别人在提交。

- **你自己的功能分支**：随便 rebase。改写的是只有你在用的历史，之后用 `--force-with-lease` 推上去，代价由你一个人承担。
- **`main` 或多人共用的开发分支**：不要。不是命令会失败，而是每个已经拉到旧提交的人下次推送都会被拒，然后各自重写、各自强推——一个人整理历史，全队跟着重走一遍。
- **别人已经基于你的提交开了新分支**：同样按共享历史对待。

还有一处可以和 merge 对照着记：**merge 一个原有提交都不改**，A、B、C 的哈希原样保留，只是多出一个合并提交；**rebase 一个原有提交都不保留**，全变成 A′、B′、C′。两者能达到同样的代码内容，留下的历史形状和哈希完全不同。PR 页面上 “Create a merge commit” 和 “Rebase and merge” 两个按钮的差别就是它，只是执行的人换成了服务器。

最后补一句边界：哈希变了不等于内容变了（父提交也算在哈希里）；而强推会丢别人的提交，只在服务器上有你没见过的提交时才可能，这正是 lease 要挡的情况。真正让你丢东西的是 `--force`，或者一条被后台 `fetch` 悄悄更新过记录的 `--force-with-lease`——官方文档专门提醒过，这条命令唯一的线索就是本地那条 remote-tracking 记录。

一句话收尾：rebase 的本质是“照原样重做”，因为父提交是提交内容的一部分，重做必然换哈希；因为换了哈希，新提交不再是旧提交的后代，推送那道“必须快进”的闸就会拦下你；而 `--force-with-lease` 是唯一一种“明确告诉服务器我要替换哪一段历史，同时确认那段历史没被别人动过”的过闸方式。

## 术语表

- 变基（rebase）：把当前分支上一串提交挨个在另一个起点上重做一遍，最后把分支指针移到重做出来的新提交上。
- 提交对象：Git 存下来的一次提交实体，内容包括目录树、父提交、作者与提交者信息、提交信息；哈希正是对它整体计算的。
- 重放（reapply）：把某个提交相对它自己父提交的那份改动，重新应用到新基底上；rebase 就是逐个执行这个动作。
- 不可达提交：曾经属于某个分支、现在没有任何分支或标签指向的提交对象；它仍在本地仓库里，只是失去了名字。

## 来源

1. [Git 官方文档 git-rebase：把一串提交重新应用到另一个基底上（Reapply commits on top of another base tip）、移植示例图，以及 committer 时间默认取当前时间](https://git-scm.com/docs/git-rebase)
2. [Git 官方文档 git-push：--force-with-lease 要求远端引用当前值等于本地 remote-tracking 记录，以及后台自动 fetch 会削弱其保护范围](https://git-scm.com/docs/git-push)

---

原文：https://pangzhengboyin.com/articles/why-git-rebase-rewrites-commit-hashes-d376eabc

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
