早上把代码提交完,git push,回来的是一段红字:

1 ! [rejected]        main -> main (fetch first)
2hint: Updates were rejected because the remote contains work that you do
3hint: not have locally. ...

同事扫了一眼说“加个 --force 就好了”。如果加了没事,Git 为什么要拦?如果加了有事,会丢什么?这一篇回答这两个问题,而 --force 和 --force-with-lease 的差别,正好是这条规则被改动的方式不同。

push 只是请求挪一下远端的指针

前面讲过,分支不过是一个写着提交哈希的文件;远端那个 main 概念上一样,服务端也有一个 refs/heads/main,里面也写着一个哈希。

git push 干的事可以一句话说完:把你本地缺的提交传上去,然后请求服务端把这个指针挪到我这个提交上。

注意“请求”两个字。服务端不是无条件照办,它先核对:你要挪到的这个提交,是不是从它现在指着的那个提交一路长出来的?

共享的 main 像一份大家轮流改的稿子,我的 push 等于说“把稿子换成我这一版”。只要我这一版是在你那一版上继续改的,换过去什么都不会少;但如果里面没有你昨天写的那段,换过去就是把你那部分抹掉——我甚至不知道自己抹掉了什么。

所以服务端只接受一种更新:新位置必须是旧位置的后代,这叫 fast-forward(快进)。第三篇讲合并时说过,能快进时 git merge 只是挪一下指针,这里是同一个概念;origin/main 的 refspec 前面那个 + 允许非快进更新,也只是因为它是个镜像书签。push 方向默认没有这个 +,是故意的。

被拒的三种说法

  • (fetch first):远端有你本地根本没有的提交,你连判断的材料都没有;
  • (non-fast-forward):你 fetch 过了,那些提交你有,但你的分支没法从远端位置直线走过去;
  • (stale info):只出现在 --force-with-lease 上,后面再说。

前两个意思一样,处理方式也一样:先把远端的东西取下来看清楚。

1git fetch origin
2git log --oneline main..origin/main   # 远端多的
3git log --oneline origin/main..main   # 我这边多的
4git merge origin/main                 # 或 git rebase origin/main
5git push

合并之后,你的新提交包含了远端那些提交,指针能直线前移,push 就通过了。

强推丢的是什么

两人都从 X 出发,同事推了 A,你推 B:

1      B
2     /
3---X---A

远端 main 已在 A 上。若它接受你把指针挪到 B,A 就不再有引用指着。提交对象还在磁盘上——Git 不会立刻删,普通 Git 服务器通常也还有 reflog——但仓库里已没有一条路能走到它:git log 看不见,git clone 拿不到。

这里有个容易推过头的边界:强推没有删除任何提交,它只是把指针挪走,让那些提交失去引用。所以如果历史还留在你自己机器上(reflog、旧分支、别人的本地副本),技术上还能救。但托管平台的界面通常不给你这个入口,已经 clone 过的人也不会自动知道发生了什么。拿“还能找回”给强推兜底,是不成立的。

--force 关掉的是整道闸

git push --force 粗暴地把这条检查去掉,远端指针你说挪哪儿就挪哪儿。官方文档写得很直白:它关掉快进检查、PUSH RULES 里的其他安全检查,以及 --force-with-lease 的检查,可能让远程仓库丢失提交 1。

两个容易忽略的细节。

其一,--force 作用于这次推送的所有 ref。如果你配置了一次推多个分支(remote.<name>.push 或 push.default=matching),它会连你没在意的分支一起覆盖。只想强推一个分支,用 refspec 里的 +:

1git push origin +main

其二,强推不一定成功。[rejected] 和 [remote rejected] 是两件不同的事:前者是你的 Git 在本地就判定不行,根本没发出去;后者是发出去了被服务端拒绝,常见原因是服务端配了 receive.denyNonFastForwards 2,或者托管平台的保护分支禁止强推,或者有 hook 拦下。所以在 main 这类共享分支上,问题往往不是“要不要强推”,而是“强推根本推不上去”。

--force-with-lease 换了一个问题

强推确实有正当用途。最常见的一种:你把自己那条功能分支 rebase 了一遍,提交哈希全变了,这对远端是彻头彻尾的非快进——但你要替换的本来就是自己的历史,没人在上面踩。

--force 的问题不在于“强制”,而在于它完全不问对方现在是什么状态。你 rebase 花了二十分钟,期间同事往这条分支补了个修复,你一个 --force,他的提交就失去引用了。

--force-with-lease 不关检查,而是换一个问题:不问“这是不是快进”,而问“远端的指针是不是还停在我上次看到的那个位置?”

那个位置就是你本地的 origin/main——上一篇讲过,它记的是“我上次和远端通信时远端的样子”。--force-with-lease 把它当作期望值(lease,租约)发给服务端:远端现在还是这个哈希,说明这期间没人动过,放行;已经不是了,说明有人推过东西,拒绝并报 (stale info)。

“租约”的比喻要收着用:它不像锁那样能阻止别人写,只是在你写的一瞬间对照一下,我以为的旧值还算数吗。所以它的保护范围恰好等于“你看到过的历史”。

由此有两个边界:

  1. 它依赖你先 fetch。手上的 origin/main 太旧时,你是在拿过期信息对照,一般会直接失败(远端值不等于期望值),属于该拒的拒了,不会造成损失。
  2. 反过来,后台自动 fetch 会让它失效。编辑器、IDE 或定时任务在后台跑 git fetch,会把你本地的 origin/main 悄悄刷新成最新,租约里的期望值也跟着变最新,保护形同虚设。官方文档专门写了这个坑 1,并给了两条路:另建一个专用于推送的 remote(如 origin-push),让后台 fetch 不去动它;或者加 --force-if-includes(Git 2.30 起),放行前额外确认远端最新的那个提交我本地确实已经包含。

要最保险就把期望值自己写出来:

1git push --force-with-lease=main:3f9c2a5 origin main

这时意思不再是“我相信我本地记的值”,而是“只有当远端 main 还停在 3f9c2a5 时才允许替换”。哈希从 git log --oneline origin/main -1 看一眼就有。

一条可以带走的判断

压成一句:被拒是在说“远端有你没有的提交”;--force 是不解决问题而把问题转给别人;--force-with-lease 是把“别覆盖我没见过的东西”写进命令里。

所以再被拒时先问:我在推共享的 main,还是只有我在用的功能分支?远端多出来的提交我看过了吗?我是真要替换历史(比如刚 rebase 完),还是只想让错误消失?前两个问题的答案通常是“先合并再推”;只有第三个答案是“是”,才轮到带 lease 的强推,而这条分支上只要有别人在动,连它也不该用。