# 推送为什么会被拒绝：fast-forward 这道闸，以及 --force 与 --force-with-lease 差在哪

push 只是请求挪远端指针，服务端只接受快进；--force 关掉整道闸，--force-with-lease 则把“别覆盖我没见过的东西”写进命令里

> Git 远程仓库 · Git 基础操作 · HEAD 与引用 · 约 6 分钟 · 10 月 21 日

## 本篇要点

1. push 的本质是请求服务端把一个分支指针挪到你的提交上，服务端只接受快进：新位置必须是旧位置的后代，否则远端原有的提交会失去所有引用。
2. `(fetch first)` 和 `(non-fast-forward)` 含义相同，都表示远端有你没有的提交；区别只在于你本地是否已经下载了那些提交。
3. 强推不删除提交，只是把指针挪走让它们失去引用，所以别人和后续 clone 都看不到；不要在共享分支上做。
4. `--force` 去掉全部安全检查，并且作用于本次推送的所有 ref，只想强推一个分支应改用 `git push origin +branch`。
5. `--force-with-lease` 不取消检查，而是把“必须快进”换成“远端必须还停在我上次看到的那个位置”，失败时报 `(stale info)`。
6. 它依赖你先 fetch，也会被后台自动 fetch 削弱，最稳妥的写法是显式给出期望哈希，或配合 `--force-if-includes`。
7. 即使加了强推参数也可能被服务端拒绝，此时显示 `[remote rejected]`，原因是 `receive.denyNonFastForwards`、保护分支或 hook。

---

早上把代码提交完，`git push`，回来的是一段红字：

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

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

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

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

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

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

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

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

## 被拒的三种说法

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

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

```bash
git fetch origin
git log --oneline main..origin/main   # 远端多的
git log --oneline origin/main..main   # 我这边多的
git merge origin/main                 # 或 git rebase origin/main
git push
```

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

## 强推丢的是什么

两人都从 X 出发，同事推了 A，你推 B：

```text
      B
     /
---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 里的 `+`：

```bash
git push origin +main
```

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

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

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

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

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

那个位置就是你本地的 `origin/main`——[上一篇](https://pangzhengboyin.com/articles/git-remote-origin-fetch-vs-pull-9d6d92b7)讲过，它记的是“我上次和远端通信时远端的样子”。`--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 起），放行前额外确认远端最新的那个提交我本地确实已经包含。

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

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

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

## 一条可以带走的判断

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

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

## 术语表

- fast-forward 更新：远端指针能挪到的位置必须是它原来位置的后代，等价于“只往前走、不回头”，这样不会让任何已有提交失去引用。
- non-fast-forward 拒绝：`git push` 在本地发现新位置不是旧位置的后代时直接拒绝，命令根本没发出去，状态记为 `[rejected]`。
- `[remote rejected]`：推送发出去了但被服务端打回，通常由服务端配置、保护分支规则或 hook 造成，强推参数对它无效。
- 租约（lease）：`--force-with-lease` 附带的一个期望值，表示“我认为远端现在是这个哈希”，只有对得上才允许覆盖。
- `--force-if-includes`：配合 `--force-with-lease` 使用的附加检查，确认远端最新提交已被你本地包含，用来堵住后台自动 fetch 造成的漏洞。

## 来源

1. [Git 官方文档 git-push — PUSH RULES、NOTE ABOUT FAST-FORWARDS，以及 --force、--force-with-lease、--force-if-includes 的说明](https://git-scm.com/docs/git-push)
2. [Git 官方文档 git-config — receive.denyNonFastForwards 等接收端限制，解释强推为何仍会被服务端拒绝](https://git-scm.com/docs/git-config)

---

原文：https://pangzhengboyin.com/articles/why-git-push-rejected-force-with-lease-f816f580

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