“我刚才那步搞砸了,怎么撤?”

这是 Git 里最容易问出错误答案的一句话。因为“撤”对应着好几条不同的路:git restore、git reset --soft、git reset --mixed、git reset --hard、git revert。它们看起来都是“回去”,实际动的却是三个不同的地方。选错一个,轻则改动没了,重则提交历史被改掉,再往外推还会把别人的分支搞乱。

所以这篇不给一张“遇到情况就查”的对照表——那种表换个场景就失效。要讲的是能推出来的那条规则:每条撤销命令都由两件事确定——它动哪个区域,以及它把那个区域的内容换成谁。

三个区域,一条单向的链

前面讲暂存区那篇时摆过这三个区域,这里直接用:

  • 工作区:你编辑器里看到的文件。
  • 暂存区(index):下次提交要装进去的内容,git add 写入这里。
  • 提交历史:git commit 把暂存区的内容固化成提交,并让当前分支指针往前走一格。

信息单向流过去:工作区 →add→ 暂存区 →commit→ 提交。三个区域各存着一份“项目此刻的样子”;你改了文件,工作区先变;add 了,暂存区跟上;commit 了,历史才跟上。

看懂这条链,“撤销”就有准确定义了:把某一环的内容,换成它上游某一环的内容。 往回拨几环,就意味着后面几环的差异会失去依托——这就是各条命令危险程度不同的原因。

restore:只动文件,历史一个字都不碰

git restore <文件> 的两端是固定的:改工作区,内容从暂存区取。 它把文件恢复成“你上次 add 时的样子”。你 add 之后又乱改一通,用它退回去;add 之前的改动找不回来,因为暂存区里从来没有过它。

git restore --staged <文件> 换了一端:改暂存区,内容从当前提交(HEAD)取。 效果是“取消 add”:文件内容原封不动还在工作区,只是不再算作已暂存,git status 里从“要提交的改动”挪到了“未暂存的改动”。

这两条命令都 不碰提交历史,只在你和暂存区之间搬内容。所以它们永远不会让推送被拒——服务器那边看到的东西一点没变。

reset 的三种模式:同一件事,往回退几格

git reset <某个提交> 的核心动作只有一个:把当前分支指针挪到那个提交上(HEAD 通过分支间接指过去,指针一动,HEAD 就跟着动)。三种模式的区别不在指针——指针一定会动——而在 退到哪一步停手:

  • --soft:只挪指针,暂存区和工作区一概不动。于是“目标提交之后的全部改动”原封不动躺在暂存区里。
  • --mixed(默认,不写就是它):挪指针,再把暂存区换成目标提交的内容。工作区依然不动。于是那些改动从“已暂存”退回成“未暂存”——文件还是你改过的样子,只是提交的登记被撤销了。
  • --hard:挪指针,暂存区和工作区都换成目标提交的内容,三个区域重新对齐,那段时间的改动从工作区里真的消失了。

回到那条链上看,三种模式就是“往回拨几环”:

命令分支指针暂存区工作区差异去哪了
restore <文件>不动不动换成暂存区的内容丢弃(工作区那一份)
restore --staged <文件>不动换成 HEAD 的内容不动保留在工作区
reset --soft <提交>挪不动不动全部留在暂存区
reset --mixed <提交>挪换成目标提交的内容不动全部留在工作区
reset --hard <提交>挪换成目标提交的内容换成目标提交的内容丢弃
revert <提交>往前走一格(新提交)不动不动被新提交抵消

有了“退几格”这个依据,几个常见用法就不用背:刚 commit 完发现漏了文件,git reset --soft HEAD~1 把那次提交拆回“改动都在暂存区”,改好再 commit 一次(--amend 做的就是这件事);三个小提交想合成一个,git reset --soft HEAD~3,三条改动全回到暂存区,一次 commit 交代清楚。

还有一条最常被误用:只想丢掉工作区的改动,却打了 git reset --hard HEAD~1。 前者只动工作区(git restore .),后者连分支指针一起往回挪了一格,等于顺手把一个提交也撤了。--hard 的危险不在于它覆盖文件,而在于它一次拨了三环。

已经推送出去的提交:为什么该用 revert

前面已经建立过一个事实:推送这道闸只看祖先关系,服务端只接受快进(推送那篇)。现在把它和 reset 接上。

reset 做的是 让分支指针往回走。假设远端 main 停在 C,你本地 reset --hard 回到 B,然后想推上去——C 不是你新历史的祖先,non-fast-forward,被拒。想推只能强推,而强推等于要求服务端 丢掉 C。可 C 已经被同事拉到本地了,他们下次推送时又会把它带回来,或者你们的仓库从此分成两条不同的历史。这就是“不要对共享分支改写历史”这条规矩的来历。

revert 走的是相反方向:它读一个已有提交的改动,把这份改动反着做一遍,生成一个新提交。C 里加了一行 timeout = 30,git revert C 得到的提交就是把这一行删掉,而它的父提交是 C。指针没有往回走,是往前走了一格。于是:

  • 推送是快进,不需要任何 --force;
  • 同事 git pull 就能拿到这次撤销,谁的历史都没被打乱;
  • 历史里同时留着“当初为什么加”和“后来为什么撤”,排查问题时通常比干净的假历史更有用。

代价也要说清:revert 只是 在效果上抵消,并不删除那个提交。项目里确实出现过那段错误代码,git log 里也还看得见。要的是“这个提交从没存在过”,就只能 reset 或 rebase,前提是它还没推出去,或者那条分支只有你一个人在用——判断标准和rebase 那篇结尾那条一样。

操作上有两个细节:revert 要求工作区干净,它会先算出反向补丁,改不动的地方和合并一样会停下来等你处理;它对合并提交要多给一个参数说明按哪边撤,这条知道有边界就行,等碰到再查。

一个容易搞反的地方

git reset --hard 常被当成“恢复出厂设置”,其实作用范围很窄:它 不删未跟踪文件(从没 add 过的新文件还在磁盘上,清掉要用 git clean);它 不动远端,你本地回到 B,服务器上还是 C;它 也没有真的销毁提交,指针挪开后那些提交只是不再被任何分支指着,git reflog 还记得指针挪动前的样子——这也是后面要单独讲的一篇。

一句话的选择依据

  • 想改 文件、不碰历史 → restore;带 --staged 就是取消 add。
  • 想改 历史,而且这条分支只有你在用 → reset,按“改动要留在暂存区 / 留在工作区 / 彻底不要”选 --soft、--mixed、--hard。
  • 想撤掉 已经推出去 的东西 → revert,新增一个反向提交,让它能快进推上去。

遇到没见过的场景,先问自己:我打算动的到底是工作区、暂存区,还是那条指针?答案出来,命令基本就定了。