有人本地 rebase 之后执行了 git push --force origin main,分支指针一下退回了两天前。现在 GitHub 上的历史停在了周二那条 commit,从那之后合并的每个 PR 在分支顶端都不见了。看着像是天塌了,但几乎总能完整恢复:force push 只是移动了一个 ref 指针,并没有删除 commit 对象。那些提交还在仓库里,只是变成了”不可达”(unreachable)状态。
最快的修复办法:先找到被覆盖前的 SHA(从队友的 git reflog,或 GitHub 的 Activity 视图拿),让一个恢复分支指向它,merge 回去,再用 git push --force-with-lease 推上去。动手之前,先让所有人停止往这个分支推。完整步骤见下文。
动手前第一件事:在团队频道里喊一句”先别往 <分支> 推任何东西”,避免有人在已经损坏的状态上再来一次 force push。在被截断的新顶端上再推一次,才是把 10 分钟的恢复变成真正的数据丢失事故的根源。
常见原因
按命中率从高到低排列。
1. rebase 本地分支后直接 force push 到共享分支
最常见。有人在本地对 develop 执行 git rebase main,然后 git push --force origin develop,没意识到队友在他上次 fetch 之后又往 origin/develop 推了 commit。
怎么判断:打开仓库的 Activity 视图(github.com/OWNER/REPO/activity,或文件列表旁边的 pulse 图标),筛选 “Force pushes”,点开事件里的 “Compare changes”。其中 before 的 SHA 就是最后一个完好的顶端,after 的 SHA 是被截断后的顶端。
2. 用了 git push --force 而不是 --force-with-lease
某个脚本或 CI 任务配置成了 --force(而非 --force-with-lease),延迟后重跑,把这期间推上去的 commit 覆盖了。裸 --force 从不检查远端顶端,所以会无条件覆盖。
怎么判断:在 CI 配置里搜(.github/workflows/、.gitlab-ci.yml)有没有 git push --force 或 git push -f。流水线的运行时间会和 Activity 视图里丢失 commit 的时间对得上。
3. amend 后 force push 去改一个 commit message 错字
开发者 amend 了最后一条 commit,然后 force push。如果远端此前刚好多了一条(几秒前合进来的)commit,那条就被孤立了。
怎么判断:force push 事件之后,远端分支的 commit 数正好少了一条。
4. 自动化发布脚本里的 force push
发布工具会 bump 版本号、commit、打 tag,再 force push main。如果脚本 git fetch 到 git push --force 之间的窗口足够长,期间落地的一个 hotfix 就会被孤立。
怎么判断:把发布脚本的运行时间和 Activity 视图里 before SHA 的时间戳对一下。
5. force push 命令里分支名或 refspec 打错了
本想 git push --force origin feature/my-fix,结果手滑写成了 local-experiment:main 之类的 refspec,把 main 用毫不相干的历史覆盖了。
怎么判断:新顶端的内容和这个分支该做的事完全无关。Activity 日志里会出现一条孤零零的 force push 事件,其新内容刚好对应某个别的本地分支。
先判断你属于哪一类
| 判断 | 是 | 否 |
|---|---|---|
| force push 之前有队友 fetch 过这个分支吗? | 从他的 reflog 恢复(Step 1A)——最快 | 用 GitHub Activity / API(Step 1B) |
| 托管在 GitHub 或 GitLab 上吗? | Activity 视图直接给出旧 SHA | 自建服务:查服务端 reflog/fsck |
| 距离事故是否已超过约 30 天? | 对象可能已被 GC,尽快开托管方支持工单 | 还能恢复,按常规流程走 |
| 事故之后有没有人又 force push 了一次? | 让所有人停手,恢复最早那个完好 SHA,而非最新的 | 只覆盖了一次,处理简单 |
最短修复路径
Step 1A:从队友的本地仓库拿到最后完好的 SHA(最快)
任何在 force push 之前 fetch 过这个分支的队友,本地对象库和 reflog 里都还留着那些旧 commit。让其中一人执行:
git reflog show origin/main | head -20 # remote-tracking reflog
git log --oneline origin/main | head -20 # 他缓存的、被覆盖前的顶端
记下被覆盖前最后一条 commit 的 SHA,叫它 GOOD_SHA。
Step 1B:如果没人有,就从托管方读出 SHA
在 GitHub 上打开 github.com/OWNER/REPO/activity,筛选 “Force pushes”,点开事件里的 “Compare changes”。其中 before 的 SHA 就是 GOOD_SHA。(在 GitLab 上,“Repository → Commits” 配合 Audit Events / push 事件能看到同样的信息。)也可以用 API 枚举这些过期的 ref:
curl -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/OWNER/REPO/activity?activity_type=force_push"
如果 commit 对象已经从你本地仓库里消失,GitHub 服务端可能还留着:打开 github.com/OWNER/REPO/tree/GOOD_SHA,如果页面能正常加载,点分支按钮就能直接在网页上从这个 SHA 创建一个恢复分支。
Step 2:把 GOOD_SHA 取到你的仓库里
如果本地已经有(来自 Step 1A 的队友),从他的仓库拉过来:
# 把队友的仓库加成临时 remote 再 fetch
git remote add teammate /path/to/their/local/clone # 或者他 fork 的 URL
git fetch teammate
如果 GitHub 服务端还能提供这个对象(Step 1B),直接按 SHA fetch:
git fetch origin GOOD_SHA
Step 3:在最后完好的 SHA 上建一个恢复分支
git branch recovery/main-preforce GOOD_SHA
git log --oneline recovery/main-preforce | head -20
确认恢复分支里包含了丢失的 commit 和对应作者。
Step 4:先备份当前(损坏的)顶端,再 merge 恢复分支
git tag backup/post-forcepush origin/main # 动 main 之前先留一道保险
git checkout main
git merge recovery/main-preforce --no-ff \
-m "recover: restore commits lost in force-push on $(date -u +%Y-%m-%d)"
用 --no-ff merge 能同时保住丢失的工作和事故之后合法推上去的内容。如果事故之后没有任何有效提交落地,也可以直接 git reset --hard GOOD_SHA。
Step 5:安全地推送恢复后的分支
# --force-with-lease 在你 fetch 之后若远端顶端又变了,会拒绝这次推送
git push --force-with-lease origin main
Step 6:核对恢复后的历史
git log --oneline -20
git shortlog -s -n GOOD_SHA~1..HEAD # 确认每位作者的 commit 都回来了
Step 7:让所有协作者重新同步
任何拉过损坏状态的人,都必须 reset 而不是 merge,否则会把被截断的历史又带回来:
git fetch origin
git reset --hard origin/main
怎么确认已经修好了
- Activity 视图里,你那次
--force-with-lease推送是最新的事件,其 after SHA 与你恢复后的HEAD一致。 git shortlog -s -n GOOD_SHA~1..HEAD列出了在丢失区间里有过 commit 的每一位作者。- 之前”消失”的那些 PR,其 merge commit 重新出现在分支历史里(
git log --merges)。 - 全新克隆(
git clone <url> /tmp/verify)里包含被恢复的 commit——证明修复落在了远端,而不只是你本地。
预防建议
- 把脚本和文档里每一处
git push --force换成git push --force-with-lease。lease 会在远端有你尚未 fetch 的 commit 时拒绝推送。想要更强的防护(Git 2.30+,2020 年底发布),设置git config --global push.useForceIfIncludes true,这样 lease 推送还会额外要求远端的新 commit 已经被整合进你的本地历史,而不只是被 fetch 下来。 - 配一个 shell 别名,让”安全的那个”成为你手指的默认动作:
alias gpf='git push --force-with-lease --force-if-includes'。 - 给
main/develop开启分支保护(GitHub:Settings → Branches → Branch protection rules / Rulesets)。禁止 force push,并打开 “Do not allow bypassing the above settings”,让管理员也一并受约束。 - 加一个 pre-push hook,拦截对受保护分支名的 force push:
# .git/hooks/pre-push (chmod +x)
protected="refs/heads/main refs/heads/develop"
while read -r local_ref local_sha remote_ref remote_sha; do
for p in $protected; do
if [ "$remote_ref" = "$p" ] && [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then
# remote_sha 非全零 + 这个 hook 在非 fast-forward 推送上触发 => 拦下
echo "ERROR: force-push to ${remote_ref#refs/heads/} blocked. Open a PR."
exit 1
fi
done
done
exit 0
- 在自建 Git 服务器上设置
receive.denyNonFastForwards = true,从服务端层面拦掉所有非 fast-forward 推送。 - 任何后面要 push 的 rebase,之前先
git fetch --all,并确认git log HEAD..origin/<branch>为空再推。 - 自动化发布脚本跑在一个 bot 账号上,把它的推送权限限定到 tag,而不是
main。 - 把本地保险拉长:
git config --global gc.reflogExpireUnreachable 90.days让不可达的 reflog 条目保留更久(默认只有 30 天;可达条目默认 90 天)。
常见问答 (FAQ)
Q:GitHub 在 PR 时间线里显示 “force-pushed”,能直接从 GitHub 恢复吗?
A:能。最快的是仓库 Activity 视图(/activity,筛 “Force pushes”)——“Compare changes” 链接里就能看到被覆盖前的 SHA。然后打开 github.com/OWNER/REPO/tree/<old-sha>,如果页面能加载,说明对象还在,可以在网页上从它创建分支。PR 时间线里那条 “force-pushed” 也会打印出旧顶端的 SHA。
Q:GitHub 会把被覆盖的 commit 保留多久?
A:没有合同式的确切数字,但实际情况是:只要还有 ref 或 PR 引用这条 commit,它就会一直在;不可达对象则会保留到垃圾回收(GC)发生为止。在本地,Git 的默认值是可达 reflog 条目 90 天(gc.reflogExpire)、不可达条目 30 天(gc.reflogExpireUnreachable)。对于一条没人引用的 commit,把约 30 天当作安全窗口、并尽早动手;万一已经没了,带上仓库、分支和大致时间开一张 GitHub Support 工单。
Q:--force、--force-with-lease 和 --force-if-includes 有什么区别?
A:--force 无条件推送。--force-with-lease 会先检查远端顶端是否仍与你缓存的 origin/<branch> 一致——若期间有人推过就拒绝。--force-if-includes(配合 lease 使用)更进一步:它确认远端的新 commit 确实能从你的本地 reflog 到达,而不只是被 fetch 进了某个 tracking ref。把 --force-with-lease --force-if-includes 一起用。
Q:Git 能不能在全局层面把我的 --force 自动变成 --force-with-lease?
A:没法直接改写这个 flag 本身,但 push.useForceIfIncludes=true 会强化 lease 推送,加上一个 shell 别名(上面的 gpf)就有了安全默认值。有些团队还会把 git 包一层函数,直接拒绝裸 --force。
Q:分支保护开着,怎么还有人 force push 成功了? A:除非你明确禁止,仓库管理员(以及带 bypass 权限的 app/token)可以跳过保护。在分支 ruleset 里打开 “Do not allow bypassing the above settings”(旧版界面叫 “Include administrators”)来去掉管理员的逃生口,并在 Settings → Rules → Rulesets 里审计谁手上有 bypass 权限。
相关阅读
- Rebase 之后 commit 消失了
- git pull —rebase 让冲突历史变乱
- 分支保护规则让我的 PR 合不了
- AI 帮你回滚代码改动
- 我在 detached HEAD 上提交了,怎么办
- AI 意外修改了 Git 历史
标签: #git #version-control #排查