add / commit / checkout:只是搬运
这一章是全书最实用的一张表。把每条常用命令和「它动了哪几棵树」对起来之后,你会发现大部分 Git 困惑都是同一个问题:你以为它动了 A,其实它动的是 B。
先把「搬运」这个词说准
三棵树之间的搬运有方向,而且方向决定了安全性:
工作区 ──add──→ 索引 ──commit──→ HEAD/对象库
↑ ↑ │
└──── restore ────┴───── reset ────────┘
(往回覆盖,危险的方向在这里)
往右(add、commit):永远安全。
因为它们做的是创建对象。对象只增不改(第 5 章),所以往右搬不会覆盖任何东西——最坏的结果是多了几个没用的对象。
往左(restore、reset、checkout):要看情况。
因为它们做的是用一棵树的内容覆盖另一棵树。被覆盖的东西如果没有在对象库里留过副本,那就是真的没了。
这一条能推出整章的所有结论。
那张表
点上面的命令名,看它动哪几棵树。特别留意第 4 条 git restore <文件>——它在整张表里是独一份的。
逐条拆几个容易搞混的
git restore <文件> vs git restore --staged <文件>
名字只差一个参数,做的事完全不同:
| 拿谁盖谁 | 效果 | 能撤销吗 | |
|---|---|---|---|
git restore a.txt | 索引 → 工作区 | 丢弃你还没暂存的改动 | 不能 |
git restore --staged a.txt | HEAD → 索引 | 取消暂存,工作区一个字节不动 | 能(工作区还在) |
记忆法:--staged 动的是「staged 的那部分」,也就是索引。不带它,动的就是工作区。
(老命令 git checkout -- a.txt 和 git reset HEAD a.txt 分别对应这两个。Git 2.23 引入 restore 和 switch,就是因为 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 的风险按这条判据排一下,就是这样:
| 操作类型 | 例子 | 丢东西的可能 |
|---|---|---|
| 造对象 | add、commit、stash | 零。只增不改 |
| 动贴纸 | branch、reset --soft、checkout 分支 | 零。reflog 记着(第 9 章) |
| 动索引 | restore --staged、reset(默认) | 几乎零。工作区还在,而且 add 过的 blob 也还在 |
| 动工作区 | restore、reset --hard、checkout -f、clean | 真的会丢 |
这张表就是第 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、本地配置、构建缓存一起端掉——这是「清理干净重来」时最容易误伤自己的一条命令。
亲眼验证「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 保管的东西」——从上面那张风险表的最后一行,跳到了第一行。
这一章的一句话
往右搬(add、commit)是创建对象,永远安全;往左搬(restore、reset、checkout)是覆盖,安全性取决于「被覆盖的东西有没有在对象库里留过副本」。整个 Git 里唯一一类真会丢东西的操作,就是那些动了工作区、而那份内容从没被 add 过的命令。
下一章:把 reset 单独拎出来讲。它的三个模式吓过很多人,但看完这张表你会发现,它们的区别只是「动几棵树」——而这正好对应着「能救回来多少」。