按 Enter 键跳转到正文

Git 撤销与恢复实战:Reset、Restore、Revert、Stash 与 Reflog

Git 开发过程中最常见的问题之一就是「改错了怎么办」。

代码还没有提交,想撤销修改;已经 commit,想重新整理;代码已经推送到远程仓库,想安全撤销;临时需要切换分支,但当前代码又不想提交;甚至执行了一次 reset --hard 后发现重要 Commit 不见了。

这些问题看起来都是「撤销」,但对应的 Git 操作完全不同。

Git 官方将 reset、restore 和 revert 明确区分为三个不同的命令:

  • restore 主要恢复工作区或暂存区中的文件。

  • reset 可以移动当前分支的 HEAD,因此会改变当前分支历史。

  • revert 不删除原来的 Commit,而是创建新的 Commit 来抵消之前的修改。

再结合 stash 和 reflog,基本可以覆盖日常开发中绝大多数「撤销与恢复」场景。

先建立一个判断标准

遇到问题时,不要第一时间执行:

git reset --hard

先判断当前代码处于什么状态。

可以把常见场景整理成下面这张表:

场景推荐操作
修改了文件,但还没有 addgit restore
已经 add,想取消暂存git restore --staged
已经 Commit,想修改最近一次提交git commit --amend
本地 Commit 想回退git reset
已经推送的 Commit 想撤销git revert
临时保存当前未完成代码git stash
reset --hard 后发现 Commit 不见了git reflog
误删本地分支git reflog

理解这张表,比记住几十个 Git 参数更重要。


Git 中的三个区域

要真正理解 reset 和 restore,首先需要理解 Git 的三个状态区域:

工作区 Working Tree
        │
        │ git add
        ▼
暂存区 Index
        │
        │ git commit
        ▼
本地仓库 Repository

例如修改:

src/User.php

刚修改完成:

Working Tree
    ↓
git add
    ↓
Index
    ↓
git commit
    ↓
Repository

所以「撤销」实际上可能是在撤销不同阶段的操作。

例如:

修改文件
   ↓
撤销工作区修改

git add
   ↓
取消暂存

git commit
   ↓
回退 Commit

git push
   ↓
需要考虑公共历史,通常使用 revert

这也是 restore、reset 和 revert 容易混淆的根本原因。


Restore:撤销文件修改

git restore 主要处理文件内容,不会移动当前分支。

Git 官方定义中,restore 用于从 Index 或其他 Commit 恢复工作区文件,也可以恢复暂存区中的文件。

撤销工作区修改

假设:

vim src/User.php

修改了代码,但还没有:

git add

现在不想保留修改:

git restore src/User.php

Git 会把文件恢复到当前 HEAD 对应的版本。

也就是说:

修改前
   ↓
src/User.php
   ↓
修改
   ↓
git restore
   ↓
恢复到 HEAD

恢复所有未提交文件

如果确定当前工作区所有修改都不要了:

git restore .

这个操作具有破坏性。

如果文件中存在没有保存到 Commit 的重要代码,执行之后可能无法通过普通 Git 历史恢复。

因此生产项目或者大型改动中,不建议习惯性执行:

git restore .

已经 Git Add,如何取消暂存

假设:

git add src/User.php

此时:

Working Tree
      │
      ▼
   Index

如果只是想取消 git add,但保留文件修改:

git restore --staged src/User.php

结果:

修改仍然存在
    ↓
只是从 Index 移回 Working Tree

可以理解为:

git add
    ↑
    │
git restore --staged

所以:

git restore --staged .

可以取消当前目录下所有文件的暂存状态。

这个操作不会删除代码。


Reset:回退 Commit

git reset 是 Git 中最容易被误用的命令之一。

因为它既可以处理暂存区,也可以移动当前分支的 HEAD。

Git 官方文档明确说明:

git reset <commit> 会修改当前分支指向的 Commit,根据不同模式还可能修改 Index 和工作区。

假设当前历史:

A --- B --- C --- D
                  ↑
                 HEAD

现在想回到 C:

git reset C

分支变成:

A --- B --- C
          ↑
         HEAD

D 不再属于当前分支历史。

但 reset 的行为取决于模式。


Reset 的三个核心模式

最重要的三个参数:

--soft
--mixed
--hard

可以简单理解成:

                 HEAD     Index     Working Tree

--soft             ✓         ×           ×
--mixed            ✓         ✓           ×
--hard             ✓         ✓           ✓

其中 ✓ 表示会被同步到目标 Commit。

reset --soft

例如:

git reset --soft HEAD~1

它会:

  • 回退 HEAD

  • 保留 Index

  • 保留工作区

例如:

A --- B --- C
          ↑
        HEAD

执行 reset --soft HEAD~1

A --- B
      ↑
     HEAD

但是原来 C 中的修改仍然处于暂存状态。

这非常适合:

刚刚提交了一个 Commit,但发现 Commit 拆分不合理,想重新整理。

例如:

git reset --soft HEAD~1

然后:

git status

可以重新组织:

git add src/User.php
git commit -m "feat: add user"

git add tests/
git commit -m "test: add user tests"

Reset –mixed

--mixed 是 git reset 默认模式。

例如:

git reset HEAD~1

等价于:

git reset --mixed HEAD~1

它会:

  • 移动 HEAD

  • 重置 Index

  • 保留工作区文件修改

结果:

Commit 被撤销
    ↓
文件修改仍然存在
    ↓
但不再处于 staged 状态

这是日常开发中比较常见的用法。

例如:

git add .
git commit -m "temp"

后来发现这个 Commit 不应该提交:

git reset HEAD~1

代码不会消失,只是 Commit 和暂存状态被撤销。


Reset –hard

最需要谨慎的是:

git reset --hard HEAD~1

它会:

  • 移动 HEAD

  • 重置 Index

  • 重置工作区

也就是说,当前工作区中没有提交的修改也可能直接被覆盖。

例如:

A --- B --- C
          ↑
         HEAD

执行:

git reset --hard B

变成:

A --- B
      ↑
     HEAD

C 不再属于当前分支。

同时工作区也会恢复到 B 的状态。

所以:

reset --hard 不是普通的「撤销按钮」,而是一个可能直接丢弃未提交修改的破坏性操作。

Git 官方文档也明确指出,--hard 会让工作区和 Index 匹配目标 Commit,并可能丢弃工作区中的修改。


Reset 最常见的几个场景

撤销最近一次 Commit,但保留代码

git reset --soft HEAD~1

撤销最近一次 Commit,并取消暂存

git reset HEAD~1

连同工作区一起回退

git reset --hard HEAD~1

回退到指定 Commit

git reset --hard <commit-id>

取消某个文件的暂存

虽然现在更推荐:

git restore --staged src/User.php

但你仍然会在老项目中看到:

git reset HEAD src/User.php

两者在这个场景下作用相近。


Revert:安全撤销已经提交的代码

如果 Commit 已经推送到远程仓库,通常不要直接:

git reset --hard
git push --force

尤其是公共分支。

更推荐:

git revert <commit>

假设:

A --- B --- C --- D

其中 D 引入了错误代码。

执行:

git revert D

Git 不会删除 D。

而是创建新的 Commit:

A --- B --- C --- D --- R

其中:

D = 错误修改
R = 撤销 D

这就是 revert 与 reset 最核心的区别。

Git 官方文档明确说明,revert 会创建新的 Commit,用来反转之前 Commit 引入的修改。


Reset 和 Revert 怎么选

可以记住一句话:

Reset 是移动历史,Revert 是新增一个 Commit 来抵消历史。

例如:

Reset

A --- B --- C --- D
              ↓
A --- B --- C

而:

Revert

A --- B --- C --- D --- R

所以:

场景推荐
本地还没推送reset
本地整理 Commitreset
公共分支已经推送revert
不希望修改共享历史revert
需要保留完整操作记录revert

这条原则在团队开发中非常重要。


Stash:临时保存未完成代码

另一个非常常见的问题:

正在开发功能 A,突然需要切换到分支 B 修复 Bug,但当前代码又不适合 Commit。

这时可以使用:

git stash

Git 会保存当前工作区和 Index 的修改,并将工作区恢复到干净状态。官方文档也将 stash 定义为「保存当前工作目录和 Index 的状态,以便恢复干净工作区」。

例如:

feature/login
      ↓
正在开发
      ↓
代码还没完成
      ↓
突然需要修 Bug

执行:

git stash push -m "login feature wip"

然后:

git switch fix/login

完成 Bug 修复:

git add .
git commit -m "fix: fix login issue"

再回到原来的分支:

git switch feature/login

恢复:

git stash pop

代码就回来了。


Stash 常用操作

查看:

git stash list

例如:

stash@{0}: On feature/login: login feature wip
stash@{1}: On feature/payment: payment api wip

查看某个 Stash:

git stash show stash@{0}

恢复但保留 Stash:

git stash apply stash@{0}

恢复并删除对应 Stash:

git stash pop

删除:

git stash drop stash@{0}

删除所有:

git stash clear

建议在重要项目中尽量使用有意义的描述:

git stash push -m "login api development"

而不是简单:

git stash

因为项目复杂以后:

stash@{0}
stash@{1}
stash@{2}
stash@{3}

很快就很难判断每个 Stash 到底是什么。


Stash 不是长期代码存储方案

stash 适合:

临时保存

不适合:

长期保存

如果一段代码已经开发了很长时间,不应该一直放在:

git stash

里面。

更合理的方式是创建一个临时 Commit:

git add .
git commit -m "wip: login development"

如果需要整理历史,后续再通过:

git rebase -i

进行处理。

因此:

stash 解决的是「我现在需要暂时离开当前工作区」,而不是「我要保存一个长期开发中的功能」。


Reflog:Git 的后悔药

如果说 Git 中有一个真正值得掌握的恢复工具,那就是:

git reflog

很多人以为:

git reset --hard

之后 Commit 就彻底消失了。

实际上,很多情况下它只是:

不再被当前分支引用。

Git 会记录本地引用发生变化的历史,这就是 Reflog。

Git 官方文档明确说明,Reflog 会记录 Branch、HEAD 等引用之前指向过的位置,例如:

HEAD@{0}
HEAD@{1}
HEAD@{2}

因此可以利用这些历史位置找回误操作前的 Commit。


使用 Reflog 找回误删的 Commit

假设原来:

A --- B --- C --- D
                  ↑
                 HEAD

执行:

git reset --hard B

现在:

A --- B
      ↑
     HEAD

看起来:

C
D

不见了。

先不要慌。

执行:

git reflog

可能看到:

b123456 HEAD@{0}: reset: moving to B
d789012 HEAD@{1}: commit: add payment api

其中:

d789012

就是之前的 Commit。

先查看:

git show d789012

确认没问题以后,可以创建恢复分支:

git switch -c recover-payment d789012

于是:

A --- B --- C --- D
          ↑
     recover-payment

代码恢复。


Reflog 恢复误删分支

假设:

git branch -D feature/payment

误删了一个本地分支。

如果该分支的 Commit 仍然存在,可以:

git reflog

找到删除之前的 Commit:

abc1234 HEAD@{5}: checkout: moving from feature/payment to main

然后:

git switch -c feature/payment abc1234

重新创建:

feature/payment
       ↓
    abc1234

原来的代码就恢复了。


Reflog 与 Git Log 的区别

这是一个非常重要的区别。

git log 查看的是:

当前可达的 Commit 历史。

而:

git reflog

查看的是:

当前本地仓库中引用曾经发生过什么变化。

例如:

git log

A --- B --- C --- D

而:

git reflog

D
C
B
reset
rebase
checkout
merge
...

Reflog 是本地仓库的引用操作历史,不是项目共享的 Commit 历史。Git 官方也特别强调,Reflog 不会随着 Git 仓库共享给其他开发者。

因此:

你电脑上的 Reflog 能看到的内容,别人电脑上不一定存在。


一个完整的误操作恢复案例

假设你刚刚完成:

git commit -m "feat: add payment"

然后发现提交历史不对,于是执行:

git reset --hard HEAD~1

发现代码没了。

第一步:

git reflog

找到:

abc1234 HEAD@{1}: commit: feat: add payment

第二步:

git show abc1234

确认 Commit。

第三步:

git switch -c recover-payment abc1234

第四步:

检查:

git status

确认代码完整。

第五步:

如果确认恢复正确,再决定是否重新整理到原来的开发分支。

整个过程不需要盲目执行:

git reset --hard HEAD@{1}

先创建恢复分支通常更加安全。


Reset、Restore、Revert、Stash、Reflog 对比

最后可以用一张表彻底区分这几个命令:

命令主要作用是否创建新 Commit是否修改分支历史
restore恢复文件否否
reset移动 HEAD、回退历史否是
revert创建反向 Commit是否
stash临时保存工作区否否
reflog查看引用历史并辅助恢复否否

可以进一步记成:

文件改错了
    ↓
restore

Commit 提交错了
    ↓
reset

公共分支的 Commit 错了
    ↓
revert

代码没完成,但必须切换任务
    ↓
stash

reset / rebase / 删除分支后后悔了
    ↓
reflog

Git 撤销与恢复的推荐原则

实际工作中可以遵循下面这套规则:

未提交的代码

优先考虑:

git restore

已提交但没有推送

可以考虑:

git reset

尤其是需要整理本地提交历史时:

git reset --soft

已经推送到公共分支

优先:

git revert

避免直接重写公共历史。

临时离开当前开发任务

使用:

git stash

但不要把 Stash 当长期代码仓库。

执行了危险操作

先:

git reflog

再决定如何恢复。


一个最值得记住的决策流程

以后遇到 Git 撤销问题,可以直接按照这个顺序判断:

             需要撤销什么?
                    │
          ┌─────────┴─────────┐
          │                   │
       文件修改             Commit
          │                   │
          ▼                   ▼
       restore         是否已经推送?
                              │
                    ┌─────────┴─────────┐
                    │                   │
                   否                    是
                    │                   │
                    ▼                   ▼
                  reset               revert


当前代码暂时不想提交?
          │
          ▼
        stash


已经 reset / rebase / 删除分支?
          │
          ▼
        reflog

这套判断逻辑比记忆命令参数更加重要。

总结

Git 的撤销操作并不存在一个万能命令。

不同阶段应该使用不同工具:

restore
    文件级恢复

reset
    本地历史回退

revert
    公共历史安全撤销

stash
    临时保存工作区

reflog
    找回引用曾经指向过的状态

其中最需要建立的认知是:

reset 是修改历史,revert 是通过新 Commit 撤销历史;restore 主要处理文件状态;stash 保存临时工作;reflog 则负责在误操作后寻找曾经存在过的引用状态。

只要掌握这几个概念,即使以后遇到 reset --soft、reset --mixed、reset --hard、stash apply、revert 冲突等具体问题,也能够根据当前状态做出正确判断,而不是依赖死记命令。

官方参考


发表评论