时光机器:撤销一切错误
写代码最让人安心的一件事是什么?是知道”搞砸了也能回去”。Git 就是你的时光机器——不同的撤销命令就像不同的时间旅行方式:有的能让你倒退几秒修正笔误,有的能让你回到昨天重来,有的甚至能在不改变历史的前提下”抵消”一个错误。掌握了这些工具,你就再也不用害怕犯错了。
📋 开篇自测:你已经知道多少?
git reset --soft、--mixed、--hard三种模式分别影响哪些区域?- 已经 push 到远程的提交发现有 bug,应该用 reset 还是 revert?为什么?
git reflog是做什么的?它能帮你从哪些”灾难”中恢复?git restore和git checkout -- file有什么关系?为什么 Git 2.23 要引入新命令?- 哪些 Git 撤销操作是真正不可逆的?
一、时间旅行的六种方式
在使用 Git 的时光机器之前,先建立一张地图。不同的”错误”需要不同的”时间旅行方式”:
┌─────────────────────────────────────────────────────────────┐
│ 你犯了一个错误 │
│ │ │
│ ┌──────────┼──────────┐ │
│ │ │ │ │
│ 还没提交 刚刚提交 已经推送到远程 │
│ │ │ │ │
│ ┌───────┴───┐ ┌──┴──┐ ┌──┴──────┐ │
│ │ │ │ │ │ │ │
│ 工作区的 暂存区的 想修改 想彻底 不能改 需要在 │
│ 修改想撤销 文件想撤回 提交信息 撤销 历史 新提交中 │
│ │ │ │ │ 修复 │
│ git restore git restore │ git git │
│ <file> --staged │ reset revert │
│ <file> │ │
│ git commit │
│ --amend │
└─────────────────────────────────────────────────────────────┘
让我们逐一深入每种方式。
二、git restore:局部时间修正
Git 2.23 版本引入了 git restore 命令,专门用于恢复文件内容。在此之前,这个功能由 git checkout 兼任,但 checkout 既能切换分支又能恢复文件,语义过于模糊,容易造成误操作。
2.1 丢弃工作区的修改
你改了半天代码,发现改错了方向,想回到最后一次提交的状态:
# 恢复单个文件到最近一次提交的状态
git restore src/config.yaml
# 恢复整个目录
git restore src/
# 恢复所有修改的文件
git restore .
这就像在文档编辑中按 Ctrl+Z 回到上次保存的版本。注意:工作区中未提交的修改会永久丢失,因为它们从未被 Git 记录过。
2.2 从暂存区撤回文件
你用 git add 把文件放进了暂存区,但还没提交,想把它从暂存区撤出来:
# 从暂存区撤回,但保留工作区的修改
git restore --staged src/config.yaml
# 撤回所有暂存的文件
git restore --staged .
文件会从”即将提交”的状态变回”已修改但未暂存”的状态,你的代码改动不会丢失。
2.3 从指定提交恢复文件
你还可以从历史中的任意提交恢复某个文件:
# 把 config.yaml 恢复到 3 个提交之前的版本
git restore --source HEAD~3 src/config.yaml
# 从指定 commit hash 恢复
git restore --source a1b2c3d src/config.yaml
这就像时光机器的精准定位——不用回到过去,只是从过去”取回”一个文件的副本放到现在。
HEAD~3 HEAD~2 HEAD~1 HEAD (当前)
│ │ │ │
●─────────────●─────────────●─────────────●
│ │
│ git restore --source HEAD~3 file │
└────────────────────────────────────────►│
只取回这个文件的旧版本,放到工作区
🤔 想一想
git restore --source HEAD~3 file和git checkout HEAD~3 -- file的效果完全一样。那为什么 Git 团队还要专门创造restore这个命令?这和”单一职责原则”有什么关系?
三、git reset:时间线的重置
如果 restore 是修正个别文件的微调,那 reset 就是重置整条时间线。它移动 HEAD 指针(以及 HEAD 指向的分支),让你的项目”回到”某个历史节点。
3.1 三种模式的本质
理解 reset 的关键在于理解 Git 的三个区域:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 工作区 │ │ 暂存区 │ │ 仓库 │
│ Working Dir │ │ Staging Area│ │ Repository │
│ │ │ │ │ │
│ 你正在编辑 │ │ git add 后 │ │ git commit │
│ 的文件 │ │ 等待提交的 │ │ 后的永久 │
│ │ │ 文件快照 │ │ 记录 │
└──────────────┘ └──────────────┘ └──────────────┘
git reset 的三种模式决定了重置 HEAD 后,这三个区域分别受到什么影响:
假设提交历史为: A ── B ── C ── D (HEAD)
执行 git reset --??? B 后:
工作区 暂存区 HEAD位置
┌───────────┬───────────┬───────────┐
--soft │ D 的内容 │ D 的内容 │ 移到 B │
(最温柔) │ (不变) │ (不变) │ │
├───────────┼───────────┼───────────┤
--mixed (默认) │ D 的内容 │ B 的内容 │ 移到 B │
(中间地带) │ (不变) │ (被重置) │ │
├───────────┼───────────┼───────────┤
--hard │ B 的内容 │ B 的内容 │ 移到 B │
(最彻底) │ (被重置) │ (被重置) │ │
└───────────┴───────────┴───────────┘
让我们用一个具体例子来感受差异:
# 假设你有三个提交
git log --oneline
# d4e5f6g (HEAD -> main) 添加支付模块
# b2c3d4e 添加购物车功能
# a1b2c3d 项目初始化
# 你想撤销最近一次提交 "添加支付模块"
—soft:保留一切,只是”取消提交”
git reset --soft HEAD~1
效果:HEAD 回到”添加购物车功能”,但支付模块的代码还在工作区和暂存区中。就像你把信从邮筒里取出来了——信还在手上,你可以修改后重新投递。
适用场景:想重新组织提交(比如把两个小提交合成一个大提交)。
—mixed:保留工作区,清空暂存区
git reset --mixed HEAD~1
# 或者直接(mixed 是默认模式):
git reset HEAD~1
效果:HEAD 回到”添加购物车功能”,支付模块的代码在工作区中保留,但需要重新 git add。就像你从邮局把包裹取回来了,东西都散落在桌上,需要重新打包。
适用场景:想重新选择哪些修改放入下一次提交。
—hard:一切归零
git reset --hard HEAD~1
效果:HEAD 回到”添加购物车功能”,支付模块的代码从工作区和暂存区中彻底消失。就像时间倒流——那段代码仿佛从未写过。
适用场景:确定要彻底抛弃最近的工作。这是危险操作,未提交的修改会永久丢失(但别慌,已经提交过的还能通过 reflog 找回,后面会讲)。
3.2 reset 的实用技巧
# 撤销最近 3 次提交,但保留代码改动
git reset --soft HEAD~3
# 现在你可以把 3 次提交的内容合并为一次提交
git commit -m "feat: complete user management module"
# 将暂存区的某个文件取消暂存(不影响工作区)
git reset HEAD src/temp.js
# 注意:这等价于 git restore --staged src/temp.js
四、git revert:创建反向时间线
reset 通过”倒退时间线”来撤销,而 revert 的思路完全不同:它不修改历史,而是创建一个新的提交来”抵消”之前的错误提交。
这就像你在日记中写了一句错话。reset 是拿橡皮擦掉那一页(改变了历史),而 revert 是在下一页写上”更正:上面那句话作废”(保留了历史,添加了修正)。
# 撤销最近一次提交
git revert HEAD
# 撤销指定的某次提交
git revert a1b2c3d
# 撤销连续多次提交(从旧到新,左开右闭)
git revert a1b2c3d..d4e5f6g
# 只修改工作区和暂存区,不自动创建新提交
git revert --no-commit HEAD
reset 的效果(改写历史):
A ── B ── C ── D (HEAD)
变成:
A ── B (HEAD) C 和 D "消失"了
revert 的效果(添加新提交):
A ── B ── C ── D ── D' (HEAD)
│
D' 是 D 的反向操作
"抵消"了 D 的改动
4.1 什么时候用 revert 而不是 reset?
黄金法则:已经推送到远程共享分支的提交,用 revert;还在本地没有推送的提交,可以用 reset。
原因很直观:如果你用 reset 回退了已经推送的提交,然后再推送,你就需要 --force 强推。而强推会覆盖其他人已经拉取的历史,导致他们的本地仓库和远程仓库不一致——这在团队协作中是灾难性的。
# 场景:你向 main 推送了一个有 bug 的提交,同事已经基于它开始开发
# 错误做法 ✗
git reset --hard HEAD~1
git push --force # 这会让所有同事的本地仓库陷入混乱
# 正确做法 ✓
git revert HEAD
git push # 正常推送,所有人 pull 后自然获得修复
⚠️ 常见误区
- 以为 revert 会删除原来的提交——不会。revert 只是创建一个新提交来抵消旧提交的改动,原始提交仍在历史中可见。
- revert 合并提交时忘记指定 -m 参数——合并提交有两个父提交,Git 不知道你想保留哪一边,必须用
-m 1或-m 2指定主线。- 连续 revert 多个提交时搞反顺序——如果要 revert 提交 C、D、E,应该从最新的 E 开始往回 revert,否则可能产生不必要的冲突。
五、git commit —amend:修改最近一次提交
--amend 是最轻量的”时间修正”——专门用于修改最近一次提交。
5.1 只修改提交信息
# 刚提交发现信息写错了
git commit -m "fix: user auht bug"
# 修改提交信息
git commit --amend -m "fix: user auth bug"
5.2 修改提交内容
# 刚提交完发现少加了一个文件
git add forgotten-file.js
git commit --amend --no-edit
# --no-edit 表示沿用原来的提交信息
5.3 amend 的本质
--amend 并不是真的”修改”了那个提交。Git 中的提交是不可变的(每个提交的哈希值由其内容决定)。--amend 的实际行为是:丢弃旧提交,创建一个内容更新后的新提交,放在同一位置。
执行 amend 之前:
A ── B ── C (HEAD) C 的信息:"fix: user auht bug"
执行 amend 之后:
A ── B ── C' (HEAD) C' 的信息:"fix: user auth bug"
C 依然存在于 reflog 中,但不在分支历史上了
注意:和 reset 一样,如果已经推送的提交执行 amend,再次推送时需要 force push。因此,--amend 只建议用于尚未推送的提交。
六、reflog:Git 的后悔药
如果说 Git 的提交历史是一本正式出版的书,那么 reflog(引用日志)就是手稿和草稿的合集——它记录了你在本地仓库中对 HEAD 做过的每一次移动。
6.1 reflog 记录什么?
git reflog
# 输出示例:
# d4e5f6g (HEAD -> main) HEAD@{0}: commit: 添加支付模块
# b2c3d4e HEAD@{1}: commit: 添加购物车功能
# f7g8h9i HEAD@{2}: reset: moving to HEAD~2
# c5d6e7f HEAD@{3}: commit: 临时实验代码
# a3b4c5d HEAD@{4}: commit: 数据库连接配置
# 1a2b3c4 HEAD@{5}: checkout: moving from feature to main
每一行都是 HEAD 的一次移动记录。即使你用 reset --hard 把提交”删除”了,reflog 依然记得那些提交的哈希值。
6.2 用 reflog 恢复”丢失”的提交
# 场景:你执行了 git reset --hard HEAD~3,
# 然后发现那 3 个提交其实是需要的!
# 第一步:查看 reflog,找到 reset 之前的 HEAD 位置
git reflog
# 假设输出中能看到:
# a1b2c3d HEAD@{0}: reset: moving to HEAD~3
# d4e5f6g HEAD@{1}: commit: 那个重要的提交
# 第二步:回到 reset 之前的状态
git reset --hard d4e5f6g
# 或者更安全的做法:创建一个新分支来恢复
git branch recovered d4e5f6g
git checkout recovered
# 确认内容无误后再合并回 main
6.3 reflog 的保质期
reflog 条目默认保留 90 天(对于可达的提交)或 30 天(对于不可达的提交,即被 reset 丢弃的提交)。超过这个时间,Git 垃圾回收(git gc)可能会清理掉这些记录。
# 查看 reflog 的过期设置
git config gc.reflogExpire # 默认 90 天
git config gc.reflogExpireUnreachable # 默认 30 天
所以 reflog 是你的后悔药,但这药有保质期。重要的操作,不要拖太久才去恢复。
七、危险操作与安全网
不是所有操作都能撤销。理清”可逆”和”不可逆”的边界,能让你在操作时做出更好的判断。
7.1 可逆性分级
┌───────────────────────────────────────────────────────────┐
│ Git 操作的可逆性 │
├───────────────┬───────────────────────────────────────────┤
│ 完全安全 │ git log, git status, git diff │
│ (只读操作) │ git fetch, git branch -v │
│ │ → 不修改任何东西,随便用 │
├───────────────┼───────────────────────────────────────────┤
│ 容易撤销 │ git commit (可以 amend 或 reset) │
│ (已记录) │ git merge (可以 reset 或 revert) │
│ │ git branch -d (可以通过 reflog 恢复) │
│ │ → 操作已被 Git 记录,通过 reflog 可恢复 │
├───────────────┼───────────────────────────────────────────┤
│ 谨慎操作 │ git reset --hard (已提交的可通过 reflog │
│ (部分可逆) │ 恢复,但未提交的工作区修改永久丢失) │
│ │ git push --force (本地有记录,但会影响 │
│ │ 其他协作者) │
│ │ → 有安全网但会影响他人或丢失未记录的修改 │
├───────────────┼───────────────────────────────────────────┤
│ 不可逆 │ 未提交也未 stash 的工作区修改被覆盖 │
│ (无法恢复) │ git gc 后超期的 reflog 条目被清理 │
│ │ → 从未被 Git 记录过的内容一旦丢失无法找回 │
└───────────────┴───────────────────────────────────────────┘
7.2 安全习惯
# 习惯 1:大操作前先创建备份分支
git branch backup-before-risky-operation
# 习惯 2:用 stash 保存未提交的修改
git stash push -m "保存当前工作进度"
# 习惯 3:使用 --force-with-lease 代替 --force
git push --force-with-lease origin feature/my-branch
# 习惯 4:使用 --dry-run 预览效果
git clean -fd --dry-run # 预览将要删除的未跟踪文件
八、决策树:reset vs revert vs restore
面对具体的撤销场景,用这棵决策树快速做出选择:
你想撤销什么?
│
┌────────────┼────────────┐
│ │ │
文件级别 提交级别 暂存区级别
的修改 的撤销 的撤回
│ │ │
git restore 该提交已 git restore
<file> 推送到远程? --staged <file>
│
┌──────┴──────┐
│ │
是 否
│ │
git revert 想保留改动
<commit> 还是彻底丢弃?
│
┌──────┴──────┐
│ │
保留改动 彻底丢弃
│ │
git reset git reset
--soft/mixed --hard
HEAD~N HEAD~N
九、实战场景演练
场景一:刚提交就发现写错了
# 提交后发现有个文件忘了加,而且提交信息有个错别字
git log --oneline -1
# a1b2c3d feat: 用户注册功能(缺少邮箱验证)
# 补上遗漏的文件
git add src/validators/email.js
# 修改提交信息并补充内容
git commit --amend -m "feat: 用户注册功能(含邮箱验证)"
# 确认结果
git log --oneline -1
# f4e5d6c feat: 用户注册功能(含邮箱验证)
# 注意:commit hash 变了,因为这是一个全新的提交
场景二:代码已经 push 才发现 bug
# 你的提交已经推送到 main 分支,同事已经在上面继续开发了
git log --oneline -3
# c7d8e9f Carol 的新提交
# b5c6d7e Bob 的新提交
# a3b4c5d 你的 bug 提交 ← 这个有问题
# 用 revert 创建一个反向提交
git revert a3b4c5d
# Git 会打开编辑器让你编写 revert 提交的信息
# 默认信息:Revert "你的 bug 提交"
# 正常推送
git push origin main
# 所有人 pull 后就能获得修复,历史完整保留
场景三:误删了分支怎么办
# 不小心删了一个还有未合并工作的分支
git branch -D feature/important-work
# 糟糕!那个分支上有三天的工作!
# 别慌。用 reflog 找到那个分支最后指向的提交
git reflog | head -20
# 在输出中寻找与该分支相关的记录,比如:
# e1f2g3h HEAD@{5}: commit: feature/important-work 的最后一次提交
# 用找到的 hash 重建分支
git branch feature/important-work e1f2g3h
# 验证代码是否完整
git log feature/important-work --oneline -5
# 切换过去确认
git checkout feature/important-work
场景四:reset —hard 后想恢复
# 你手滑执行了 reset --hard,三个提交没了
git reset --hard HEAD~3
# 瞬间后悔
# 立刻查看 reflog
git reflog
# d4e5f6g HEAD@{0}: reset: moving to HEAD~3
# a9b8c7d HEAD@{1}: commit: 第三个被删的提交
# ...
# 方法一:直接 reset 回去(如果你确定要完全恢复)
git reset --hard a9b8c7d
# 方法二:更安全——先在新分支上恢复,确认后再合并
git branch recovery a9b8c7d
git diff main recovery # 查看差异
git merge recovery # 确认后合并
git branch -d recovery # 清理临时分支
🤔 想一想 如果你执行
git reset --hard HEAD~1的时候,工作区还有未提交的修改,那些修改能通过 reflog 找回来吗?为什么?(提示:reflog 记录的是 HEAD 的移动,而工作区的修改从未被 HEAD “记录”过。)
📝 掌握度自测
- restore 基础:你用
git add .把所有文件都加入了暂存区,但其中debug.log不应该被提交。如何只把这个文件从暂存区移除而不丢失文件内容? - reset 辨析:你有 5 个提交 A-B-C-D-E,想把 C、D、E 三个提交合并为一个提交。你会用 reset 的哪种模式?写出完整步骤。
- revert 应用:你在 main 分支上的倒数第 3 个提交引入了一个 bug,但倒数第 2 个和第 1 个提交都是正确的。你如何只撤销那一个有问题的提交而保留其他提交?
- reflog 救援:执行完
git reset --hard HEAD~5之后,你发现搞错了。写出恢复步骤。 - 综合判断:在以下每个场景中选择最合适的命令,并说明理由:
- (a) 修改最近一次提交的提交信息
- (b) 撤销已 push 到 main 的一个提交
- (c) 彻底丢弃本地最近 2 次未 push 的提交
- (d) 把暂存区的 3 个文件中的 1 个撤回到工作区
- (e) 找回 2 天前误删的分支
💡 自我评估
- 答对5题:你已经掌握了 Git 的”时光机器”全套工具。面对任何误操作都能从容应对,是团队中可靠的 Git 急救员。
- 答对3-4题:核心撤销工具已经掌握,建议在本地仓库中刻意练习各种”搞破坏再恢复”的操作,形成肌肉记忆。
- 答对0-2题:撤销操作的种类确实容易混淆。建议回到本章的决策树部分,把每种场景对应的命令抄下来贴在显示器旁边,用几次就记住了。
购买课程解锁全部内容
版本控制不翻车:Git 从基础到团队协作
¥29.90