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

add / commit / checkout:只是搬运

这一章是全书最实用的一张表。把每条常用命令和「它动了哪几棵树」对起来之后,你会发现大部分 Git 困惑都是同一个问题:你以为它动了 A,其实它动的是 B。

命令对照表restore vs reset唯一会丢东西的操作

先把「搬运」这个词说准

三棵树之间的搬运有方向,而且方向决定了安全性:

工作区  ──add──→  索引  ──commit──→  HEAD/对象库
  ↑                 ↑                    │
  └──── restore ────┴───── reset ────────┘
        (往回覆盖,危险的方向在这里)
◆ 两个方向,两种安全性

往右(addcommit):永远安全。

因为它们做的是创建对象。对象只增不改(第 5 章),所以往右搬不会覆盖任何东西——最坏的结果是多了几个没用的对象。

往左(restoreresetcheckout):要看情况。

因为它们做的是用一棵树的内容覆盖另一棵树。被覆盖的东西如果没有在对象库里留过副本,那就是真的没了。

这一条能推出整章的所有结论。

那张表

▶ 动手 · 命令轨迹

点上面的命令名,看它动哪几棵树。特别留意第 4 条 git restore <文件>——它在整张表里是独一份的。

逐条拆几个容易搞混的

git restore <文件> vs git restore --staged <文件>

名字只差一个参数,做的事完全不同

拿谁盖谁效果能撤销吗
git restore a.txt索引 → 工作区丢弃你还没暂存的改动不能
git restore --staged a.txtHEAD → 索引取消暂存,工作区一个字节不动能(工作区还在)

记忆法:--staged 动的是「staged 的那部分」,也就是索引。不带它,动的就是工作区。

(老命令 git checkout -- a.txtgit reset HEAD a.txt 分别对应这两个。Git 2.23 引入 restoreswitch,就是因为 checkout 一个命令干了太多种事,把人都搞晕了。新代码建议一律用 restore / switch

git commit -a 那个陷阱

-a 的意思是「提交前先自动 add 一遍」。但它有个限定:

它只会 add 已经被跟踪的文件。新文件不管。

因为 -a 的实现是「把索引里已有的每一条记录,用工作区的当前内容刷新一遍」(第 11 章:索引是一张已跟踪文件的表)。新文件不在表里,自然轮不到它。

所以经典翻车现场是:你新建了个文件,git commit -am "加了新功能",推上去之后同事说编译不过——因为那个新文件根本没提交

git checkout <分支> 为什么有时候会拒绝

切分支要动三棵树,但 Git 会尽量保住你未提交的改动

  • 你改过的文件,在两个分支之间没有差异带着走,改动保留。
  • 你改过的文件,在两个分支之间有差异 → 切过去就得覆盖它 → 拒绝,报错
error: Your local changes to the following files would be overwritten by checkout:
	a.txt
Please commit your changes or stash them before you switch branches.

这个错误信息其实很贴心——它在说:「我不想帮你做丢东西的决定。」

三条出路,按推荐顺序:

$ git stash              # 收起来,切完再 pop(最安全,改动被写成了对象)
$ git commit             # 提交掉(更安全,直接进历史)
$ git checkout -f 分支    # 强行覆盖 —— 改动没了,别用

那唯一一条真会丢东西的

回看那张表,找一找:哪条命令动了工作区,却没有在对象库里留下任何东西?

答案是 git restore <文件>(以及它的老写法 git checkout -- <文件>,和 git reset --hard 里覆盖工作区的那一部分)。

◆ 为什么只有这一类救不回来

第 9 章那条判据:它有没有被写成过对象?

你在工作区改的内容,在你 git add 之前,只存在于磁盘上那个文件里。Git 的对象库里没有它的任何副本,reflog 里也不会有——reflog 记的是引用的移动,而这份内容从来没被任何引用指向过。

所以当 restore 拿索引的内容盖上去时,那份改动就真的从宇宙里消失了

Git 不是不想救你,是它手上根本没有那份数据

把整个 Git 的风险按这条判据排一下,就是这样:

操作类型例子丢东西的可能
造对象addcommitstash零。只增不改
动贴纸branchreset --softcheckout 分支零。reflog 记着(第 9 章)
动索引restore --stagedreset(默认)几乎零。工作区还在,而且 add 过的 blob 也还在
动工作区restorereset --hardcheckout -fclean真的会丢

这张表就是第 24 章那张决策表的骨架。整本书的风险判断,最后都归结成这四行。

git clean 更狠,而且没有任何安全网

restore 至少还是「用索引里的版本覆盖」——文件还在,只是内容退回去了。

git clean -fd直接删除未跟踪的文件和目录。而「未跟踪」意味着 Git 从来不知道它们存在,所以:

  • 对象库里没有副本
  • reflog 里没有记录
  • fsck 也找不到
  • 它执行的就是一句 rm -rf

所以务必养成先看一眼的习惯:

$ git clean -nd     # -n = dry run,只列出「我要删这些」,不真删
$ git clean -fd     # 确认无误了再来

特别注意:它默认不删被 ignore 的文件(要加 -x)。而 git clean -fdx 会把 .env、本地配置、构建缓存一起端掉——这是「清理干净重来」时最容易误伤自己的一条命令。

⌗ 掀开 .git 看一眼

亲眼验证「add 是往右搬、只增不改」这件事。

做一次会丢东西的操作,然后看对象库里还剩什么:

# 1. 写点东西,add 进去(此刻内容变成了 blob)
$ echo "版本 A" > t.txt && git add t.txt
$ git ls-files --stage t.txt
100644 8a7c3f2... 0	t.txt

# 2. 改成别的,再 add(旧 blob 还在库里,只是索引不指它了)
$ echo "版本 B" > t.txt && git add t.txt

# 3. 旧的那个 blob 依然能读出来!
$ git cat-file -p 8a7c3f2
版本 A

「版本 A」并没有消失——它是一个悬空 blob,git fsck 能找到它。

对照实验,看没 add 过的东西:

# 写点东西,不 add
$ echo "版本 C" > t.txt

# 直接 restore
$ git restore t.txt
$ cat t.txt
版本 B

「版本 C」在对象库里查无此物fsck 也找不到——因为它从来没被写成过对象。

这两个实验放在一起,就是整章的证明。

✎ 为什么 checkout 这么让人困惑

因为它一个命令干了三件不相干的事

  • git checkout 分支 —— 切换 HEAD(动三棵树)
  • git checkout -- 文件 —— 用索引覆盖工作区(危险
  • git checkout 提交 -- 文件 —— 用某个提交的版本覆盖索引和工作区

这三件事的危险程度差了十万八千里,却共用一个命令名,区别只在参数里那个容易被忽略的 --

更要命的是:漏写 -- 而恰好有个分支跟文件同名时,Git 会去切分支,而不是恢复文件。

所以 Git 2.23(2019 年)把它拆成了两个:

  • git switch —— 只管切分支
  • git restore —— 只管恢复文件

建议一律用新的。不是因为老的不能用,而是因为「一个命令只做一件事」能让你在敲下回车前就知道风险等级。git switch 永远不会删你的东西,git restore 你一看就知道要小心。

↩ 回到你的仓库

「我想把工作区完全恢复到最后一次提交的样子,一个改动都不留。」

这个需求本身就分三种,得看你要清到什么程度:

# 1. 已跟踪文件恢复到 HEAD(索引 + 工作区),未跟踪文件保留
$ git reset --hard HEAD

# 2. 再加上删掉未跟踪的文件和目录
$ git reset --hard HEAD && git clean -fd

# 3. 连被 ignore 的也删(.env、构建产物、本地配置全没)
$ git reset --hard HEAD && git clean -fdx

三条的破坏力递增,而且都不可逆。

所以真正的建议是:先 stash 一下再说。

$ git stash --include-untracked    # 把未跟踪的也一起收走
$ git clean -fd
# 确认没问题了,再 git stash drop;发现搞错了,git stash pop

这一步的价值在第 9 章说过:stash 会把你的工作区写成提交对象。那一瞬间,这些改动从「Git 不管的东西」变成了「Git 保管的东西」——从上面那张风险表的最后一行,跳到了第一行。

这一章的一句话

往右搬(addcommit)是创建对象,永远安全;往左搬(restoreresetcheckout)是覆盖,安全性取决于「被覆盖的东西有没有在对象库里留过副本」。整个 Git 里唯一一类真会丢东西的操作,就是那些动了工作区、而那份内容从没被 add 过的命令。

下一章:把 reset 单独拎出来讲。它的三个模式吓过很多人,但看完这张表你会发现,它们的区别只是「动几棵树」——而这正好对应着「能救回来多少」。