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
先判断当前代码处于什么状态。
可以把常见场景整理成下面这张表:
| 场景 | 推荐操作 |
|---|---|
修改了文件,但还没有 add | git 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 |
| 本地整理 Commit | reset |
| 公共分支已经推送 | 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 冲突等具体问题,也能够根据当前状态做出正确判断,而不是依赖死记命令。
发表评论