跳到主要内容
预计阅读 33 分钟

时光机器:撤销一切错误

写代码最让人安心的一件事是什么?是知道”搞砸了也能回去”。Git 就是你的时光机器——不同的撤销命令就像不同的时间旅行方式:有的能让你倒退几秒修正笔误,有的能让你回到昨天重来,有的甚至能在不改变历史的前提下”抵消”一个错误。掌握了这些工具,你就再也不用害怕犯错了。

📋 开篇自测:你已经知道多少?

  1. git reset --soft--mixed--hard 三种模式分别影响哪些区域?
  2. 已经 push 到远程的提交发现有 bug,应该用 reset 还是 revert?为什么?
  3. git reflog 是做什么的?它能帮你从哪些”灾难”中恢复?
  4. git restoregit checkout -- file 有什么关系?为什么 Git 2.23 要引入新命令?
  5. 哪些 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 filegit 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 后自然获得修复

⚠️ 常见误区

  1. 以为 revert 会删除原来的提交——不会。revert 只是创建一个新提交来抵消旧提交的改动,原始提交仍在历史中可见。
  2. revert 合并提交时忘记指定 -m 参数——合并提交有两个父提交,Git 不知道你想保留哪一边,必须用 -m 1-m 2 指定主线。
  3. 连续 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 “记录”过。)


📝 掌握度自测

  1. restore 基础:你用 git add . 把所有文件都加入了暂存区,但其中 debug.log 不应该被提交。如何只把这个文件从暂存区移除而不丢失文件内容?
  2. reset 辨析:你有 5 个提交 A-B-C-D-E,想把 C、D、E 三个提交合并为一个提交。你会用 reset 的哪种模式?写出完整步骤。
  3. revert 应用:你在 main 分支上的倒数第 3 个提交引入了一个 bug,但倒数第 2 个和第 1 个提交都是正确的。你如何只撤销那一个有问题的提交而保留其他提交?
  4. reflog 救援:执行完 git reset --hard HEAD~5 之后,你发现搞错了。写出恢复步骤。
  5. 综合判断:在以下每个场景中选择最合适的命令,并说明理由:
    • (a) 修改最近一次提交的提交信息
    • (b) 撤销已 push 到 main 的一个提交
    • (c) 彻底丢弃本地最近 2 次未 push 的提交
    • (d) 把暂存区的 3 个文件中的 1 个撤回到工作区
    • (e) 找回 2 天前误删的分支

💡 自我评估

  • 答对5题:你已经掌握了 Git 的”时光机器”全套工具。面对任何误操作都能从容应对,是团队中可靠的 Git 急救员。
  • 答对3-4题:核心撤销工具已经掌握,建议在本地仓库中刻意练习各种”搞破坏再恢复”的操作,形成肌肉记忆。
  • 答对0-2题:撤销操作的种类确实容易混淆。建议回到本章的决策树部分,把每种场景对应的命令抄下来贴在显示器旁边,用几次就记住了。

购买课程解锁全部内容

版本控制不翻车:Git 从基础到团队协作

¥29.90