Git 中的撤销:reset、revert 和 restore

1. 概述

任何使用 Git 的人都会遇到需要撤销的时刻:修改错了文件、多执行了 git add,或者 commit 了不该 commit 的东西。Git 有三个主要命令来处理这种情况:restore、reset 和 revert。它们的名字听起来很相似,但每个命令作用于不同的区域。

本文通过同一个示例仓库来逐一介绍这些命令。读完之后,你就会知道在不同情况下应该用哪个命令。你还会学到如何在不小心执行 reset --hard 时救回 commit。

2. 准备示例仓库

首先,需要记住 Git 有三个区域:

  • 工作树:你正在磁盘上编辑的文件。
  • 暂存区(index):即将进入下一个 commit 的内容。
  • 提交历史:已保存的 commit,HEAD 指向当前 commit。

我们创建一个包含四个 commit 的小仓库:

mkdir order-service && cd order-service
git init -b main

echo "class Order {}" > Order.java
git add Order.java && git commit -m "添加 Order 类"

echo "class Customer {}" > Customer.java
git add Customer.java && git commit -m "添加 Customer 类"

echo "class Order { int total; }" > Order.java
git commit -am "为 Order 添加 total"

echo "class PaymentService {}" > PaymentService.java
git add PaymentService.java && git commit -m "添加 PaymentService"

git log --oneline

命令 git commit -am 暂存所有已跟踪的文件后直接 commit。git log 的结果会类似于下面这样(你机器上的 hash 会不同):

d4e5f60 (HEAD -> main) 添加 PaymentService
c3d4e5f 为 Order 添加 total
b2c3d4e 添加 Customer 类
a1b2c3d 添加 Order 类

3. git restore:撤销未提交的更改

git restore 从 Git 2.23 开始提供。这个命令只作用于文件。它从不移动 HEAD 或修改提交历史。在较旧的 Git 版本中,这个功能由 git checkout -- <file> 提供。

3.1. 丢弃工作树中的更改

假设你修改了 Order.java 后改主意了:

echo "class Order { int total; String note; }" > Order.java
git status --short
git restore Order.java
cat Order.java
 M Order.java
class Order { int total; }

git status --short 显示 M 意味着文件已修改但未暂存。git restore Order.java 从暂存区复制内容到工作树。因为还没有暂存任何内容,这个内容与 HEAD 相同。

注意: 用 git restore 丢弃的更改是永久丢失的。Git 从未保存过它们,所以 reflog 也救不了。如果还有疑虑,应该运行 git stash 而不是 restore。

3.2. 从暂存区移除文件

你不小心 git add 了一个还不想 commit 的文件:

echo "class Order { int total; String note; }" > Order.java
git add Order.java
git restore --staged Order.java
git status --short
 M Order.java

选项 --staged 从 HEAD 复制内容到暂存区。结果是文件被“取消暂存”,而你刚修改的内容仍然保留在工作树中。等效的旧命令是 git reset HEAD Order.java。

如果想一次性丢弃暂存区和工作树中的内容,可以使用:

git restore --staged --worktree Order.java

3.3. 从旧 commit 中恢复文件

选项 --source 允许从任何 commit 中获取文件内容:

git restore --source=HEAD~2 Order.java
cat Order.java
git status --short
class Order {}
 M Order.java

HEAD~2 是 commit“添加 Customer 类”。在那个 commit 中,Order.java 还没有 total。默认情况下该命令只写入工作树,所以文件现在是 M(未暂存)。你可以检查一下,然后如果想保留就自己执行 git add 和 commit。否则运行 git restore Order.java 来恢复。

4. git reset:移动分支指针

git reset <commit> 将当前分支(以及 HEAD)移动到指定的 commit。三个模式 --soft、--mixed、--hard 的区别在于暂存区和工作树是否被覆盖。

模式 分支/HEAD 暂存区 工作树
--soft 移动到目标 commit 保持不变 保持不变
--mixed(默认) 移动到目标 commit 按目标 commit 重置 保持不变
--hard 移动到目标 commit 按目标 commit 重置 按目标 commit 重置

下面所有的例子都基于在干净的 d4e5f60 commit 状态开始。

4.1. –soft:移除 commit,保留已暂存的更改

git reset --soft HEAD~1
git log --oneline -1
git status --short
c3d4e5f (HEAD -> main) 为 Order 添加 total
A  PaymentService.java

Commit“添加 PaymentService”从分支中消失。文件 PaymentService.java 仍然存在并处于 A(已暂存)状态。这种方式适合你想要修改提交信息或合并几个 commit 的情况:

git commit -m "添加 PaymentService 进行支付处理"

4.2. –mixed:移除 commit 并取消暂存

git reset HEAD~1
git status --short
?? PaymentService.java

这是默认模式。分支回退一个 commit,暂存区被重置。PaymentService.java 是新文件,所以现在变成未跟踪的(??)。当你想要重新分割 commit 时使用 --mixed:逐个暂存部分代码然后 commit 成多个小 commit。

4.3. –hard:丢弃一切

git add PaymentService.java && git commit -m "添加 PaymentService"
git reset --hard HEAD~1
ls
HEAD is now at c3d4e5f 为 Order 添加 total
Customer.java  Order.java

Commit、暂存区和所有已跟踪文件的工作树内容都被恢复到 c3d4e5f。PaymentService.java 消失了。

注意: reset --hard 会删除已跟踪文件未 commit 的更改。这些更改无法恢复。相反,它不会删除未跟踪的文件。要删除未跟踪的文件,必须使用 git clean -fd,这个命令同样危险。

4.4. 用 reflog 救回 commit

被 reset 移除的 commit 不会立即消失。Git 在 reflog 中记录每一次 HEAD 的移动:

git reflog -3
c3d4e5f HEAD@{0}: reset: moving to HEAD~1
9f8e7d6 HEAD@{1}: commit: 添加 PaymentService
c3d4e5f HEAD@{2}: commit: 添加 PaymentService 进行支付处理

你只需要 reset 到想要恢复的 commit:

git reset --hard 9f8e7d6
git log --oneline -1
9f8e7d6 (HEAD -> main) 添加 PaymentService

Reflog 只存在于你的本地机器。默认情况下,不再被引用的条目会在 30 天后过期,然后被垃圾回收器清理。因此,救援越早越好。

4.5. 何时不应该使用 reset

不应该对已推送到共享分支的 commit 使用 reset。Reset 之后,本地和远程历史会不同步,你被迫使用 git push --force。已 pull 过这些 commit 的同事会面临历史混乱,甚至可能不小心把它们推送回去。

如果必须覆盖,比如在个人分支上,使用更安全的命令:

git push --force-with-lease

--force-with-lease 会拒绝 push 如果远程有你还没 fetch 的新 commit。这样你就不会意外覆盖别人的工作。

5. git revert:用新 commit 进行撤销

git revert 不删除历史。这个命令创建一个新的 commit,其内容与要撤销的 commit 相反。历史只会增长而不会被重写,所以这是共享分支上的安全选择。

5.1. 撤销单个 commit

git revert --no-edit HEAD
git log --oneline -3
ls
1a2b3c4 (HEAD -> main) Revert "添加 PaymentService"
9f8e7d6 添加 PaymentService
c3d4e5f 为 Order 添加 total
Customer.java  Order.java

--no-edit 使用默认提交信息而不打开编辑器。原始 commit 9f8e7d6 仍在历史中。新 commit 1a2b3c4 删除了 PaymentService.java。新 commit 的正文包含 This reverts commit 9f8e7d6... 这样的行,帮助别人追溯来源。

你也可以 revert 历史中间的老 commit,不一定是 HEAD:

git revert --no-edit c3d4e5f
cat Order.java
class Order {}

如果之后的 commit 已经修改了相同的代码行,Git 会报告冲突。你修复冲突,运行 git add,然后执行 git revert --continue。要中途取消可以用 git revert --abort。

5.2. 将多个 revert 合并为一个 commit

默认情况下,每个被 revert 的 commit 都会产生一个独立的 commit。要合并它们,可以使用 --no-commit:

git revert --no-commit HEAD~3..HEAD~1
git commit -m "撤销关于 Customer 和 Order 的更改"

语法 A..B 选择 A 之后直到 B 的所有 commit。Git 将每个 commit 的逆向应用到暂存区,然后你进行一次唯一的 commit。

5.3. Revert 一个 merge commit

Merge commit 有两个父 commit。因此 Git 需要知道你想保留哪个分支作为“主线”:

git revert -m 1 <hash-merge-commit>

-m 1 选择第一个父 commit,通常是你 merge 到的分支(比如 main)。结果是另一个分支带来的所有更改都被撤销。

注意: 撤销 merge 后,如果你再次 merge 同一个分支,Git 会认为那些 commit 已被 merge。这些更改不会回来。要把它们带回,你必须 revert 这个 revert commit。

6. 应该选择哪个命令?

情景 应该使用的命令
丢弃文件未暂存的更改 git restore <file>
取消暂存文件 git restore --staged <file>
获取文件的旧版本 git restore --source=<commit> <file>
修改/合并最后一个 commit,未推送 git reset --soft HEAD~1
重新分割 commit,未推送 git reset HEAD~1
完全丢弃本地 commit git reset --hard <commit>
撤销已推送的 commit git revert <commit>

记住这个简短的规则:

  • restore 作用于文件。
  • reset 作用于分支,用于本地。
  • revert 作用于共享历史。

7. 常见错误

  • 在共享分支上混淆 reset 和 revert。这是最常见的错误。已推送的话就用 revert。
  • 在有未提交更改时运行 reset --hard。运行前检查 git status。有更改的话先执行 git stash。
  • 以为 reset --hard 会清空目录。未跟踪的文件仍然存在。你需要先用 git clean -n 预览,然后用 git clean -fd 删除。
  • 忘记 HEAD~1 和 HEAD^ 在 merge commit 中的区别。HEAD^2 是第二个父 commit,而 HEAD~2 是祖父 commit,即父的父。在线性历史中,HEAD~1 和 HEAD^ 是一样的。
  • 习惯性使用 git checkout <file>。这个命令仍然有效,但 checkout 承担了太多角色,很容易误写成切换分支。在现代 Git 中,restore 和 switch 更清晰。

8. 总结

在本文中,你已经了解了 Git 中三种撤销方式:

  • git restore 恢复工作树或暂存区中的文件内容,不涉及历史。
  • git reset 将分支移动到另一个 commit,有三个级别 --soft、--mixed、--hard。这个命令只应该用于未推送的 commit。
  • git revert 创建一个反向 commit,是共享分支上的安全选择。

当不小心操作时,记住 git reflog。大多数“丢失”的 commit 仍然在那里等你恢复。

gitversion-controlresetrevertrestore