reset 的三种模式,一张表说完
--soft、默认、--hard——这三个词大概是 Git 里被搜索次数最多的东西之一。但只要你手里有「三棵树」这个模型,它们的区别一眼就能看完:从上往下,每多一个模式就多动一棵树。
先说 reset 到底在干嘛
git reset <目标> 做的事,最多分三步,按顺序执行,随时可以停:
- 把 HEAD 指向的贴纸挪到
<目标>←--soft做完这一步就停 - 把索引重置成
<目标>的样子 ← 默认(--mixed)做完这一步就停 - 把工作区也重置成
<目标>的样子 ←--hard三步全做
--soft → 只动 HEAD(1 棵树)
默认(--mixed)→ 动 HEAD + 索引(2 棵树)
--hard → 动 HEAD + 索引 + 工作区(3 棵树)
就这么简单。不用记三套语义,记一个「停在第几步」就够了。
而且这个顺序正好也是危险程度的顺序——因为越往下动的树越靠外,而最外面那棵(工作区)是唯一没有备份的(第 12 章)。
同一个起点(HEAD 在 C2,索引有暂存改动,工作区还有未暂存改动),三种模式跑一遍。
盯着右边那一栏哪几行变红——变红就是「被覆盖了」。
各自的正经用途
--soft:重新打包最近几个提交
它只挪贴纸,你的改动全都还在,而且还是暂存状态。所以:
# 把最近 3 个提交合成一个 $ git reset --soft HEAD~3 $ git commit -m "一个干净的提交"
发生了什么?贴纸退回三格,但索引和工作区纹丝不动——它们还是那三个提交做完之后的样子。于是 git commit 一下,那三次的改动就合成了一个新提交。
这是 squash 最朴素的实现,也是「提交信息写错了想重来」的标准做法。
注意:那三个老提交并没有消失,只是没人指着它们了(第 5 章、第 9 章)。reflog 里还留着。
默认(--mixed):我 add 错了
不带任何参数的 git reset 就是这个模式。它重置索引但不碰工作区:
$ git reset # 把所有暂存的都取消暂存(改动仍在工作区) $ git reset HEAD~2 # 退回两个提交,改动都退回未暂存状态
典型场景:你 git add . 了一堆东西,突然发现里面混进了不该提交的文件。git reset 一下,全部退回未暂存,然后重新挑着 add。
(顺带一提,Git 2.23 之后取消暂存单个文件更推荐用 git restore --staged <文件>,语义清楚得多。第 12 章讲过为什么。)
--hard:真的想全部推倒
$ git reset --hard HEAD # 丢弃所有未提交的改动,回到最后一次提交 $ git reset --hard origin/main # 彻底放弃本地,跟服务器一致
这是唯一一个会覆盖工作区的模式,所以也是唯一一个可能让你真的丢东西的模式。
--hard 之后,什么能救什么不能救把丢掉的东西分成两类,命运完全不同:
那些已经 commit 的提交 → 一定能救。
它们是对象,还在库里,reflog 记着它们的位置(第 9 章):
$ git reflog
$ git reset --hard HEAD@{1}
那些没暂存过的工作区改动 → 是真的没了。
它们从来没被写成对象,Git 里没有任何地方存过它们(第 12 章)。
中间态:add 过但没 commit 的 → 能救,但费劲。
blob 已经进库了,用 git fsck --lost-found 能捞出来,但没有文件名(blob 不知道自己叫什么,第 3 章),得按内容认。
所以那条建议——跑 --hard 之前先 git stash——的技术含义就很清楚了:它把上面第二类(真会丢)变成了第一类(一定能救)。
一个语义分裂:带路径的 reset
这是 reset 最容易让人困惑的地方,值得单独说:
$ git reset HEAD~1 # 不带路径:挪贴纸(可能还动索引和工作区) $ git reset HEAD~1 -- a.txt # 带路径:完全不同的另一件事
带了路径之后,reset 根本不会挪贴纸。它变成了「把索引里 a.txt 那一条,改成 HEAD~1 里的版本」。
而且带路径时不能用 --hard——Git 会直接报错。
| 动 HEAD | 动索引 | 动工作区 | |
|---|---|---|---|
git reset <提交> | ✓ | 看模式 | 看模式 |
git reset <提交> -- <路径> | ✗ 不动 | ✓ 只动这个路径 | ✗ 不动 |
为什么设计得这么分裂?历史原因。而这正是 Git 2.23 引入 git restore 的动机之一:
# 老写法(语义靠参数里有没有路径来切换,容易搞错) $ git reset HEAD -- a.txt # 新写法(一看就知道在干嘛) $ git restore --staged a.txt
亲眼看 --soft 到底动了多少东西。跑之前先记下三样:
$ git rev-parse HEAD # 贴纸在哪 $ git write-tree # 索引折成的树 $ git status --short # 工作区状态 $ git reset --soft HEAD~1 $ git rev-parse HEAD # 变了 ← 唯一变的东西 $ git write-tree # 没变 $ git status --short # 没变(只是现在显示为「已暂存」)
只有一个 41 字节的文件被改写了。你可以直接确认:
$ cat .git/refs/heads/main
再看 reflog,它诚实地记下了这一步:
$ git reflog -2
06b431f HEAD@{0}: reset: moving to HEAD~1
1bada99 HEAD@{1}: commit: second
HEAD@{1} 就是你的后悔药。
顺带看一眼 ORIG_HEAD——reset 之前 Git 自动存的(第 7 章):
$ cat .git/ORIG_HEAD 1bada9988e4c142f884f13990c310e024c36f1ea $ git reset --hard ORIG_HEAD # 一键后悔
这两个名字都像「撤销」,但它们在 DAG 上的动作方向相反:
reset: A ← B ← C 贴纸从 C 撕下来贴回 A
↑ B、C 变成孤儿(历史被改写了)
main → A
revert: A ← B ← C ← ~C 造一个新提交 ~C,内容是「把 C 的改动反过来做一遍」
↑ B、C 一个没动,历史完整保留
main
由此得出一条不用记、可以推的规矩:
- 提交还没推出去 → 两个都行。
reset更干净,历史里不留痕迹。 - 提交已经推出去了 → 只能用
revert。
为什么?因为 reset 会让服务器上那些提交变成孤儿,而别人可能已经基于它们工作了。你一强推,他们下次 pull 会陷入一团乱麻(第 8 章的 non-fast-forward,第 20 章的救援)。
revert 则不改写任何已存在的历史——它只是往前加了一个提交。别人拉下来只是多一个提交,什么都不会乱。
第 19 章会讲 revert 的实现,你会发现它和 cherry-pick 是同一台引擎,只是把补丁反过来用。
git reset --hard 不会删未跟踪的文件一个常被误会的点:--hard 重置的是已跟踪的文件(索引里有条目的那些,第 11 章)。
未跟踪的文件它一个都不碰。所以你以为「彻底干净了」,结果 git status 里还挂着一堆 ??。
要连未跟踪的一起清,得配 git clean:
$ git reset --hard HEAD && git clean -fd
但记住第 12 章那个警告:clean 是真删,没有任何安全网。先 git clean -nd 看一眼要删什么。
反过来说,这个「不碰未跟踪文件」的行为其实是保护你:你的 .env、本地配置、编辑器设置不会被 --hard 误伤。
「我在 main 上直接改了半天,才想起来应该开个分支。」
这是 --soft 最漂亮的用法之一。分两种情况:
如果还没 commit(改动都在工作区)—— 直接开分支就行,改动会跟着走(第 12 章:没差异的文件会被带过去):
$ git switch -c 新分支
如果已经在 main 上 commit 了几个:
# 1. 先在当前位置贴一张新贴纸(提交都还在,只是多了个名字) $ git branch 新分支 # 2. 把 main 退回到该在的地方 —— 提交没丢,它们被「新分支」指着 $ git reset --hard origin/main # 3. 切过去继续干活 $ git switch 新分支
第 2 步敢用 --hard,是因为那些提交已经被「新分支」这张贴纸保住了——不存在丢失的可能。这就是先做第 1 步的意义。
「先贴一张贴纸,再动手」——这个习惯能让绝大多数危险操作变成零风险操作。贴纸只要 41 个字节,贴之前不用犹豫。
这一章的一句话
reset 最多做三步:挪贴纸 → 重置索引 → 重置工作区,而 --soft/默认/--hard 就是「停在第几步」。动的树越多,能救回来的越少——前两步动的都是有备份的东西(reflog、对象库),只有第三步碰的工作区是没有备份的。带路径的 reset 是另一回事,那种情况用 git restore --staged 更清楚。
下一章:卷 III 的最后一个问题,也是回到全书起点的那个——既然对象库里一条 diff 都没有,git diff 是怎么给你看 diff 的?我们会跑一台真的 Myers 算法引擎,而它算出的那个数字,正是 Git 敢于「不存 diff」的底气。