你照着同事说的做:

1git switch feature
2git rebase main

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

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

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

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

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

拿合并那篇用过的图,稍微改一下:你从 E 切出 feature,做了 A、B、C 三个提交;与此同时,同事往 main 上又推了 F。

绘制中

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 动的是引用,不是对象本身。

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

现在回到推送被拒。规则在推送那篇讲过:服务端只接受快进——你送来的提交,必须以远端那个提交为祖先。

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 是唯一一种“明确告诉服务器我要替换哪一段历史,同时确认那段历史没被别人动过”的过闸方式。