卷 III · 三棵树CH 13深度 13/24

reset 的三种模式,一张表说完

--soft、默认、--hard——这三个词大概是 Git 里被搜索次数最多的东西之一。但只要你手里有「三棵树」这个模型,它们的区别一眼就能看完:从上往下,每多一个模式就多动一棵树。

reset 三模式soft / mixed / hardreset vs revert

先说 reset 到底在干嘛

git reset <目标> 做的事,最多分三步,按顺序执行,随时可以停

  1. 把 HEAD 指向的贴纸挪到 <目标>--soft 做完这一步就停
  2. 把索引重置成 <目标> 的样子 ← 默认(--mixed)做完这一步就停
  3. 把工作区也重置成 <目标> 的样子--hard 三步全做
◆ 三个模式,就是「停在第几步」

--soft → 只动 HEAD(1 棵树)

默认(--mixed)→ 动 HEAD + 索引(2 棵树)

--hard → 动 HEAD + 索引 + 工作区(3 棵树)

就这么简单。不用记三套语义,记一个「停在第几步」就够了

而且这个顺序正好也是危险程度的顺序——因为越往下动的树越靠外,而最外面那棵(工作区)是唯一没有备份的(第 12 章)。

▶ 动手 · reset 三模式对照

同一个起点(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
⌗ 掀开 .git 看一眼

亲眼看 --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    # 一键后悔
✎ reset 和 revert:往后退 vs 往前走

这两个名字都像「撤销」,但它们在 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」的底气。