ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Git 强制推送远程分支:安全覆盖、命令链路与恢复

2026/9/18 19:30:45 拓冰建站 浏览量
Git 强制推送远程分支:安全覆盖、命令链路与恢复 1. 先把覆盖这个词拆开强推到底覆盖了什么我第一次听到本地强制覆盖远程这个说法时脑子里浮现的画面是把本地文件夹拖到远程服务器上然后点全部替换。后来被现实教育了好几次才明白Git 里根本没有这回事。搞不清这一点后面所有的命令都会用错方向。git push传输的不是文件而是一串提交对象和指向它们的引用。所谓强制覆盖本质是把远程仓库上某个引用最常见的就是refs/heads/main的指针绕过快进检查直接指向你本地那个提交。远程原本那条线上的提交从那一刻起就不再被任何分支指向了——它们并没有被删除只是变成了没人引用的悬空对象。这个区别带来了两个非常实际的后果。第一覆盖之后远程仓库的文件确实变成了你本地的样子但中间的提交历史是断掉的而不是被修改的。第二那些被甩掉的提交还在远端的对象库里躺着直到服务端做垃圾回收这段时间内它们是能捞回来的。很多人以为强推等于删库重来、无法挽回其实没那么绝对反过来也有人以为强推很安全结果被回收之后才发现晚了。1.1 三种被混为一谈的覆盖日常沟通里说我要覆盖远程实际可能指三件完全不同的事说法真实含义涉及命令危险等级覆盖当前分支让远程某个分支指向本地这个提交git push --force中覆盖所有分支和标签让远程的引用集合完全等于本地git push --mirror极高覆盖远程文件内容这不是 Git 的操作模型不存在直接对应命令无—第三种是最容易被误解的。Git 没有把远程某个文件替换成本地版本这种原子操作你要实现远程某个文件变成我想要的样子只能走改文件 → 提交 → 推送提交这条路。如果你不想在历史里留下改动的痕迹那就得靠整理历史再强推或者用反向提交去抵消——这是两条路后面会分别讲。还有一类人问的是我用Sourcetree或者编辑器里的提交按钮能不能强制覆盖。答案是能界面上的强制推送勾选项背后就是--force只不过默认参数往往是最裸的那一种风险比命令行自己敲还高因为你可能根本没意识到自己勾了什么。1.2 push 传的是提交对象不是文件夹把这件事想透后面所有操作都会顺。Git 的仓库是内容寻址的每个提交有自己的哈希提交里记录着父提交的哈希、树对象的哈希、作者和提交信息。推送的时候客户端把远端没有的对象打包传过去然后请求远端把某个引用的值从 A 改成 B。远端收到请求后会做两件事。第一检查这个新值 B 是不是 A 的后代——是的话叫快进直接改不是的话叫非快进默认拒绝。第二如果客户端明确说了我知道这是非快进我就要这么改也就是带了 force 语义远端就跳过第一步的检查除非它自己配了禁止策略。所以强制覆盖这个词里强制是相对于快进检查而言的覆盖是相对于引用当前值而言的。它跟文件级别没有关系。理解了这一层你就知道为什么强推之后同事那边的git pull会报一堆错——因为他们本地那条线的父提交在远端已经找不到了。1.3 什么局面才真的需要强推强推是个核选项用它的场景其实不多我把它归成四类提交信息写错了、提交里混进了不该提交的文件而这条分支只有自己用需要把历史收拾干净再推上去。用reset --hard回退到了旧提交想把远端也一起退回去比如发版之后发现上线的是错误版本。要抹掉某个曾经提交过的敏感内容密钥、口令、大文件并且可以接受历史被重写。仓库迁移或重建希望远端完全以本地某个状态为准比如旧的实验分支要彻底废掉。反过来说如果这条分支上有别人的提交或者远端已经有人基于它开了合并请求、打了标签、签了发布分支那强推就是在给别人制造麻烦。判断标准很简单这条分支的引用被几个人依赖答案是只有我的时候强推成本最低。2.--force和--force-with-lease的区别决定了同事会不会找你算账知道了强推是什么接下来是选哪把刀。很多人一直用git push -f也知道有个--force-with-lease但说不清两者差在哪于是干脆继续用短的。这个选择恰恰决定了强推是可控操作还是盲盒。裸的--force表达的意思是不管远端现在是什么把它改成我这边这个值。注意这句话里的不管。你本地最后一次同步远端是三个小时前这三个小时里同事往同一个分支推了五个提交你这一下按下去那五个提交就从分支上消失了。远端不会拦你因为它只检查你是不是本地最新这件事在 force 模式下根本不检查。2.1 裸 force 的盲区盲区就在我以为我知道远端长什么样这个假设上。假设的来源是本地那份远程跟踪引用也就是refs/remotes/origin/branch。问题是这份引用只在git fetch、git pull、git push之后才更新它随时可能是陈旧的。我踩过的一次坑很典型在笔记本上改完一个分支准备强推中间去开了个会回来后直接从另一台机器上推了一次然后又回到笔记本上推。两台机器的远程跟踪引用一个旧一个新第二次强推把第一次的成果吃掉了。当时没觉得有什么问题直到第二天同事问我昨天的提交怎么没了。2.2 lease 到底比对了什么--force-with-lease在这中间加了一道校验逻辑可以概括成一句话如果远端当前的值和我本地记录的远程跟踪引用的值一致那才允许强制覆盖不一致就报错退出。换句话说它把我知道远端长什么样变成了一个可验证的前置条件。如果在你动手之前有人推过新东西而你本地那份origin/branch还停在旧值两项对不上Git 会直接拒绝git push --force-with-lease origin main # To remote:path # ! [rejected] main - main (stale info) # error: failed to push some refs to remote:path看到stale info就应该明白远端有你没见过的东西。这时候不要下意识地敲git fetch然后重推正确的动作是先 fetch 下来看看对方推了什么评估一下能不能合再决定是合并后正常推、还是确认要覆盖。这是整个流程里最关键的一次判断。2.3 那个经典的自己把租约弄脏的坑这里有个非常隐蔽的陷阱在强推之前执行git fetch会把租约的基准值一起抬高。道理很简单--force-with-lease比的是你本地的远程跟踪引用而git fetch的作用就是更新这些引用。你 fetch 完本地记录变成了远端最新值租约从我上次看到的变成了我刚看到的保护窗口就缩小到 fetch 这一瞬间。如果这期间有人推了一笔你还是会覆盖掉它。所以正确的操作顺序是先看、后推中间不要 fetch。想知道远端有什么变化可以直接看# 看远端引用当前指向哪个提交不更新本地跟踪引用 git ls-remote origin refs/heads/main # 或者先 fetch 到一个临时引用不污染 origin/main git fetch origin main:refs/temp/checkmain git log --oneline -5 refs/temp/checkmain第二种做法我很推荐ls-remote只给哈希看不到提交信息和内容fetch 到临时引用既能看到历史又不会动origin/main这个租约基准。用完删掉就行git update-ref -d refs/temp/checkmain2.4--force-if-includes补最后一道缝即使按上面的顺序做--force-with-lease还有一个理论上的漏洞。它只保证远端和我记录的一致但不保证我曾经看到过的那些远端提交真的被合进了我当前要推的历史里。举个例子你本地origin/main记录停在提交 X。你 fetch 了一下、看都没细看、也没合并就直接reset --hard回到自己那条线然后强推。这时租约校验是能过的远端确实还是 X但你正在覆盖掉 X 这条线上的东西而且你根本没打算保留它。这种情况一般被称为租约被盗用。Git 2.30 之后提供了--force-if-includes来堵这个口子它要求待推送的提交历史中确实包含过远程跟踪引用指向的那个提交。要求和--force-with-lease搭配使用git push --force-with-lease --force-if-includes origin main嫌命令长可以配成默认之后所有强推都自动带上git config --global push.useForceIfIncludes true这个配置我建议长期开着。第一它不会影响普通推送只有在你用 force 语义时才起作用第二它把我确实知道远端长什么样、并且已经把它纳入了我的历史这件事变成了机器可验证的条件比靠人的记忆可靠得多。3. 动手之前把本地整理成你真正想推上去的形态强推只是最后那一按。真正决定结果的是本地现在是什么因为强制覆盖推上去的就是这个状态。所以这一步要先把本地的分支指向整理好整理到位了推的动作反而是最简单的。整理前有一个必须先搞清楚的问题你到底想要哪种最终形态是要回到历史上某个提交中间那些提交全部作废还是文件内容保持现在这样但历史重新从零开始还是只动几个文件历史不要碰。这三种对应三种完全不同的做法搞混了就会做出你根本没想要的东西。3.1 目标态是历史上某个提交reset --hard最常见的需求就是退回去。先找到目标提交的哈希git log --oneline -20 # a1b2c3d (HEAD - main) 加了个没用的日志 # 9f8e7d6 修了订单金额计算 # 4c5b6a1 上线前的最后调整 -- 想回到这里然后git reset --hard 4c5b6a1--hard会同时重置 HEAD、暂存区和工作区。也就是说4c5b6a1之后的改动全部丢弃包括没提交的改动。这是这个命令最大的风险点值得再说一遍未提交的工作区内容reset --hard之后基本救不回来git stash没用过的话也没有备份。我的习惯是执行reset --hard之前先无脑存一次git stash push -u -m reset-hard 前的保险-u会把未跟踪的新文件也一起存进去。这一步几乎零成本但救过我好几次。另外要注意两种情况。一是这条分支上如果有别人的提交reset --hard会把它们一起甩掉这时候强推就是在删别人的工作务必先确认。二是如果分支已经被合并到其他分支比如dev已经合了main的旧提交回退main不会影响dev但后续再把main合过去的时候可能产生意料之外的冲突历史越绕越乱。3.2 目标态是文件保留、历史清零孤儿分支另一个高频需求是历史完全不要了提交信息写得乱七八糟、包含过不该出现的东西、或者仓库是从别的地方拷来的带着一堆没意义的历史。这时候要的不是回退是重开一条没有父提交的线。git checkout --orphan就是干这个的。它会创建一个新的分支当前工作区和索引保持不变但这个新分支还没有任何提交git checkout --orphan clean-main # 清空索引避免把旧分支的暂存状态一起带过来 git rm -r --cached . # 重新按当前工作区内容暂存 git add -A git commit -m chore: 重建仓库历史这里git rm -r --cached .这一步很容易漏。漏了会怎样孤儿分支的索引里还残留着原分支的暂存内容直接git add -A虽然也能把文件加进去但删除过的文件、.gitignore里被忽略的文件处理结果可能跟你预期不一致。加一句清空索引行为就干净了。提交完成后把这个新分支顶到main的名字上git branch -M clean-main main-M是强制重命名会覆盖已有的main。执行完main就指向这个全新的、只有一个提交的分支了旧的那条线还在 reflog 里暂时不会丢。3.3 只是想在远程删几个文件别动历史如果需求只是远程仓库里那几个文件我不想要了那就完全没必要强推。正常提交即可git rm --cached path/to/file # 只从版本控制移除本地文件保留 git rm path/to/file # 连本地文件一起删 git commit -m chore: 移除废弃配置 git push origin main这种改动是快进推送同事拉取的时候也是普通合并没有任何副作用。我见过不少人为了删一个误提交的文件把整个分支历史重写了结果所有人的本地都要处理一遍冲突。能用普通提交表达的变更永远优先用普通提交。不过有个例外如果你不想让这个文件的历史版本被别人翻到那就必须重写历史。这种情况属于下面的场景。3.4 要抹掉某个文件的所有痕迹filter-repo假设一个密钥文件被提交过现在要把它从所有历史提交里彻底抹掉。注意这跟删掉最新提交里的这个文件是两码事——只要历史里还留着翻旧提交依然能看到内容。git filter-repo是目前处理这类需求的主流工具原来的filter-branch性能差、坑多官方也建议换。基本用法# 从所有历史中移除指定文件 git filter-repo --path path/to/secret.env --invert-paths--invert-paths的意思是保留除指定路径以外的所有内容。执行完所有提交的哈希都会变历史被完全重写。接下来就是这条路的必经步骤git remote add origin remote-url # filter-repo 会移除原有 remote需要重新加 git push --force-with-lease origin main这一步必须提醒一句如果这个密钥已经存在过一段时间光重写 Git 历史是不够的密钥本身必须作废重发。因为任何人在这之前 clone 过、CI 缓存里存过、日志里打过都可能还留着副本。重写历史只是把公开可见的那份清掉。4. 按下回车完整命令链路与每一步的验证点本地整理好了接下来是推送。这一步我想把每个检查点都摆出来因为强推最怕的不是命令写错而是没检查就推了。4.1 推之前的三项检查三项检查各花不到十秒但能避免九成的翻车。第一项确认本地和你以为的一致。git status git log --oneline -5 git rev-parse HEADgit status干净很重要带着未提交的改动强推推上去的是已提交的那部分容易产生我明明改了但远程没有的困惑。git rev-parse HEAD拿到的哈希一会儿推完要拿它对着远端再核一遍。第二项确认远端分支当前是什么状态并且顺手做一次差异对比。git fetch origin main:refs/temp/checkmain git log --oneline -5 refs/temp/checkmain git diff refs/temp/checkmain..HEAD --stat最后这条 diff 是重点。它告诉你这一按下去会丢掉哪些改动、多出哪些改动。如果输出的文件列表跟你预期完全不符立刻停手回去查。第三项确认这条分支上有几个人的提交要一起消失。git log --format%an %h %s refs/temp/checkmain | head -20看看作者名。如果出现别人的名字而那个提交又是最近一两天推的八成是对方还在用。这种时候最好在群里问一句比事后解释便宜得多。4.2 备份的三种做法验证完推之前再留一份退路。我按可靠性从低到高排临时分支最轻量适合短期保险git branch backup/main-20250101 git push origin backup/main-20250101:refs/heads/backup/main-20250101注意这里推的是一个新分支不是强推所以远端只需要普通推送权限。备份分支推上去之后即使强推把main冲掉了远端依然留着这份完整历史。打标签适合标记明确的回退点git tag -a backup-before-force-20250101 -m 强推前的备份点 git push origin backup-before-force-20250101标签的好处是不会被普通的fetch --prune清掉坏处是标签通常也会显示在仓库的标签列表里如果这是个公开仓库可能会被看到。bundle 文件适合不想让远端留任何痕迹的场景git bundle create repo-backup-20250101.bundle --all--all会把所有引用和对象打成一个文件这个文件本身就是一个可以 clone 的完整仓库。缺点是它躺在你本地的磁盘上磁盘坏了就没了。三个都做也不费事我一般是临时分支加 bundle 组合前者方便远端回退后者防止本地手滑。4.3 执行强推与确认到这一步命令就一行git push --force-with-lease --force-if-includes origin main如果前面配了push.useForceIfIncludes简写成git push --force-with-lease origin main推完立刻验证不要凭感觉git ls-remote origin refs/heads/main git rev-parse HEAD两条输出应该完全一致。如果ls-remote拿到的哈希和本地 HEAD 对不上说明推的时候出了状况比如钩子改写了内容需要回头查。顺便提一个很短的写法git push origin main那个前缀等价于对这一个引用使用 force。这种写法适合偶尔推一个特定引用但不能用--force-with-lease的租约校验我基本不用。4.4 推所有分支和标签--mirror的适用边界git push --mirror origin的含义是让远端的引用集合完全等于本地的引用集合。这不只是强推它还会删除远端所有本地没有的引用。远端有但本地没有的分支会被干掉。它适合的场景其实很窄仓库整体迁移、本地是权威来源、且远端那个仓库确定没有任何人还在用。绝大多数情况下不该碰它因为一旦执行远端那些别人正在开发的分支、别人打的标签一起消失。而且--mirror不支持--force-with-lease一点保护都没有。如果确实需要让远端和本地保持一致我更倾向于先列清楚两边差在哪再逐个处理git ls-remote --heads origin git branch -a先看清楚有哪些分支是远端独有、哪些是本地独有再决定哪些删、哪些推。多花五分钟比事后恢复一整个仓库便宜太多。5. 报错清单从non-fast-forward到protected branch强推失败是常态关键是看错误信息判断是哪一类问题。我把常见的几类整理成一张表再逐类说处理思路。报错关键字出在哪一侧原因处理方向non-fast-forward远端没带 force 语义加--force-with-leasestale info远端远端值变了租约对不上先看对方推了什么别急着重推remote rejected ... hook declined远端钩子校验没过看钩子输出通常是提交信息或大文件protected branch远端分支被保护临时放开保护规则或走合并请求permission denied/403认证凭据权限不足检查凭据是否有推送权限failed to push some refs泛化上面某一类的笼统包装往上翻具体那一行5.1 只在你本地的报错有一类错误其实根本没到远端。比如fatal: not a git repository (or any of the parent directories): .git说明当前目录不在仓库里或者.git被误删了。这种情况强推无从谈起先确认git rev-parse --show-toplevel能不能输出仓库根目录。还有git: command not found或者 Windows 上那句经典的无法将git项识别为 cmdlet、函数、脚本文件或可运行程序的名称属于环境问题。装好 Git 之后要把安装目录下的cmd或者bin加进环境变量重开终端才生效。这类问题跟强推没关系但确实是新手卡在第一步的高频原因。5.2 远端分支保护protected branch是最常见的命令没错但推不上去。远端平台默认会把主分支设成受保护的禁止强推和删除。处理方式有两种临时在仓库设置里把允许强制推送打开操作完再关掉或者干脆不要动主分支把强推的目标换成一个新分支然后改默认分支。我偏向第二种。具体做法是把本地整理好的历史推到一个新名字上git push --force-with-lease origin main:refs/heads/main-rebuild然后到平台上把默认分支切到main-rebuild再把旧的main删掉最后把main-rebuild改名回main。绕了一点但全程不需要改保护规则审计记录也更清楚——因为多出来的那几步都能在平台上看到。5.3 钩子拦截远端仓库如果配置了pre-receive钩子它会在真正更新引用之前拿到所有要更新的引用做各种校验。常见的有提交信息必须符合规范、提交者邮箱必须属于某个域、单文件大小不能超过阈值、不能删除某个特定分支。这类拦截的表现是remote rejected加上钩子打印的一段说明。钩子的输出一定要完整读它通常会直接告诉你违反了哪条规则。我遇到过最坑的一次是提交信息格式不合规但报错信息被包装得很难懂绕了半小时才反应过来。还有一种情况和钩子有关但不是钩子本身大文件。Git 对大文件很不友好超过一定尺寸会被直接拒绝。这时候正确做法是用git filter-repo把它从历史里彻底去掉而不是想办法绕过限制。5.4 凭据与权限403、permission denied、Authentication failed这一类排查顺序是先确认当前用的凭据对应哪个账号再确认那个账号对目标分支有没有写权限。git remote -v git config --get remote.origin.url看清 remote 地址用的是哪种协议。如果是 HTTPS凭据可能被系统凭据管理器缓存了需要清掉重新输入如果是 SSH 密钥方式可以用ssh -T类似的方式验证密钥是否被远端接受。有些平台的个人访问令牌会过期过期之后所有推送都会失败且报错信息往往不指向令牌过期这个真实原因容易误判成网络问题。6. 强推之后的善后别人怎么恢复历史去哪找强推成功只是完成了你这一侧的动作。真正的影响发生在别人那边而他们多半不知道发生了什么只会看到一堆报错。6.1 同事端最省事的恢复动作同事遇到的典型症状是git pull报错说无法快进或者拉下来了但发现自己的提交不见了一推就报non-fast-forward。最直接的恢复方式是放弃本地那条线直接跟远端对齐git fetch origin git reset --hard origin/main这两条命令的含义是以远端为准把我本地这条分支完全替换掉。执行之前如果本地有还没推的提交一定要先捞出来git branch my-work-20250101 # 把当前 HEAD 存成一个分支 git stash push -u -m 未提交的改动 git reset --hard origin/main把有价值的提交先落到一个本地分支上即使后面强推覆盖了main那个分支还指向原来的提交完全不受影响。这一点很重要强推影响的只是引用不是本地对象库里的提交只要你给那个提交留了个名字分支或标签它就一直都在。6.2 本地和远端的悬空提交窗口被强推甩掉的那些提交在远端并没有立即消失只是没有任何引用指过去。它们会一直留在对象库里直到服务端执行垃圾回收。找回的办法有两个方向。如果你本地还有那个提交的哈希直接git show hash git branch rescue hash如果哈希也忘了本地可以翻 refloggit reflog --dateisoreflog记录了 HEAD 和分支引用的每一次移动包括reset --hard之前的那个位置。找到对应的那行git branch rescue hash就能把它变成一个正常分支然后正常推送或者基于它新建分支。远端侧能不能找回取决于服务商。大多数托管平台在你通过网页访问一个具体提交哈希的时候只要对象还没被回收就还能看到。所以发现被覆盖之后的第一件事是尽快把哈希记下来而不是先去写邮件。6.3 别让 reflog 悄悄过期reflog不是永久的。默认情况下不可达的 reflog 条目会在 90 天后过期可达条目在 30 天后过期。一旦过期又被git gc --prune清理那才是真正找不回来。git reflog expire --expirenow --all # 危险让所有 reflog 立刻过期 git gc --prunenow # 危险立刻清理不可达对象这两条命令除非你非常清楚自己在做什么比如要彻底抹掉历史里的敏感内容否则不要执行。我在处理敏感数据清理时会用到它们但一定是确认所有需要保留的分支都已经推送到远端、且远端也做过清理之后。日常使用中git gc交给 Git 自己按策略触发就好。临时把 reflog 的保留期调长是个稳妥的做法git config gc.reflogExpire 180.days git config gc.reflogExpireUnreachable 180.days7. 能不强推就不强推几个更体面的替代路径写到这里其实我最想说的是一句反向的话大部分被拿来强推的场景都有不需要强推的解法。强推的代价不只是技术上的还有沟通上的——你让所有协作者都要做一次手工恢复这个成本远大于你省下的那几步。7.1revert把错误抵消掉而不是抹掉如果目的是撤销某个提交带来的改动但分支是共享的正确做法是revertgit revert commit-hash它会生成一个新的提交内容是目标提交的反向改动。历史没有被重写所有人正常拉取即可改动的意图也留在历史里可查。缺点也明显历史里那条错误的提交还在能看到。对于提交信息写错了这种需求如果分支是共享的其实用revert加一条说明就行没必要为了措辞整洁让所有人重拉仓库。如果提交还没推上去那git commit --amend就够了git commit --amend -m correct: 修正提交信息--amend只改最近一次提交且只在提交还没推送出去的时候安全。一旦推送过改它就意味着强推。7.2 用新分支顶上而不是覆盖老分支如果确实需要一条干净的历史我推荐的做法是推新分支、切默认、归档旧分支而不是覆盖老分支git checkout --orphan rebuild git rm -r --cached . git add -A git commit -m chore: 重建主分支历史 git branch -M rebuild main git push origin main:refs/heads/main-rebuild推上去之后在平台上把默认分支切成main-rebuild把旧的main重命名成archive/main-20250101或者直接删除。这样做的几个好处旧历史完整保留在archive/main-20250101里任何人都能随时查切换默认分支这个动作在平台的审计日志里留下了记录协作者只需要重新切换一次分支比reset --hard友好。7.3 如果一定要推把它变成一次有记录的操作有些团队对强推是零容忍有些团队是允许但要留记录。如果你是后者我建议固定一套流程别每次都临时决定在推送前把远程当前状态导出留档git ls-remote origin remote-refs-before.txt或者干脆把远端那个分支的哈希记在提交信息里。用--force-with-lease --force-if-includes不要用裸--force。推完在团队的同步渠道里说明一句哪个分支、从哪个哈希变成了哪个哈希、原因是什麼、旧的哈希是什么。哪怕只有一句话也比事后被追问强。旧的哈希对应的提交在主仓库里打成一个archive/前缀的标签推上去让它可以被检索到。第 4 点我觉得最值得做。一个archive/xxx标签的成本几乎为零但它把被覆盖掉的历史从可能随时被 GC 掉的悬空对象变成了有名有姓、随时能翻出来的提交。真要追溯的时候这比什么都管用。说个我自己的教训。前几年接手一个项目前任为了清理一个误提交的配置文件把主分支强推了一遍什么记录都没留。半年后有人发现有个功能行为不一致想看看当初那个配置是什么样结果远端对象早被回收本地也没人留着旧克隆。最后是靠着一台没人动过的旧构建机上残留的 workspace 才勉强还原出来。那次之后我给自己定了个规矩任何一次强推都必须对应一个能找回来的引用。最后再补一个小技巧是关于Sourcetree和各类图形界面的。它们通常把强制推送放在一个不起眼的勾选框里有的还默认使用裸 force。如果你用手工操作更放心可以在图形界面之外单独开一个终端做推的动作让整理和推送分开——整理的时候随便试推送只走一条你已经背下来的命令。这个习惯帮我省掉过至少两次不必要的恢复操作。