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 仍然在那里等你恢复。