三棵树:工作区、索引、HEAD
前两卷讲的是 Git 内部的样子。这一卷回到你每天敲的那些命令——而它们全都是同一件事的变体:在三棵树之间搬东西。这一章之后,git status 那两列字母你就不用再逐句读了。
三棵树是哪三棵
| 树 | 在哪儿 | 是什么 |
|---|---|---|
| 工作区 working tree | 你的项目目录 | 你用编辑器能看见、能改的那些文件。唯一一个 Git 不管的地方 |
| 索引 index / staging area | .git/index 一个文件 | 「下次提交要长什么样」的草稿。第 11 章会把它拆开 |
| HEAD | 对象库里的一棵 tree | 你最后一次提交时的快照(第 7 章的 HEAD 指向的那个提交的树) |
注意它们的性质完全不同:
- 工作区是真的文件,操作系统管着,Git 只是看着。
- 索引是一份清单,记着每个路径对应哪个 blob 哈希。
- HEAD 的树是对象库里已经固化的东西,不可变。
很多人问:为什么不能像别的 VCS 那样,直接「改了就提交」?中间夹一个索引,图什么?
因为索引让你能把「改了什么」和「要提交什么」分开。
你改了 8 个文件,其中 5 个是这个功能的,3 个是顺手修的无关问题。索引让你能只把那 5 个打包成一个提交,剩下 3 个留着下次——甚至能用 git add -p 只提交同一个文件里的某几行。
换句话说:索引是你和历史之间的编辑台。它存在的意义,就是让「提交」成为一个可以精心组织的动作,而不是「把当前状态一股脑扔进去」。
这是 Git 少数几个为了人而不是为了机器的设计。它也确实是新手最不理解、老手最舍不得的那一个。
搬运台
先按顺序点:改 a.txt → git add → git commit,看内容怎么一列一列往右流。
然后重点来了:改 a.txt → git add → 再改一次 a.txt。现在三棵树里 a.txt 有三个不同的值——这正是那个经典困惑的答案。
git status 那两列字母
现在可以把 git status --short 的输出彻底读懂了:
$ git status --short MM a.txt M b.txt M c.txt ?? d.txt
每行开头两个字符,它们说的就是三棵树之间的两道缝:
M M a.txt │ │ │ └─ 右列:工作区 ↔ 索引 的差别 →「改了但还没暂存」 └─── 左列:索引 ↔ HEAD 的差别 →「已暂存,等着提交」
于是那四行的意思是:
| 输出 | 含义 | 怎么发生的 |
|---|---|---|
MM a.txt | 两道缝都有差别 | 改了 → add 了 → 又改了一次 |
M b.txt | 只有左边有差别 | 改了 → add 了,之后没再动 |
M c.txt | 只有右边有差别 | 改了,还没 add |
?? d.txt | 三棵树都不认识它 | 新文件,从没 add 过 —— 「未跟踪」 |
那些长句子——「Changes to be committed」「Changes not staged for commit」「Untracked files」——其实就是这两道缝:
- Changes to be committed = 索引 ≠ HEAD(左列)
- Changes not staged for commit = 工作区 ≠ 索引(右列)
- Untracked files = 索引里压根没这个路径
一旦你这么读,status 就从「一段要理解的英文」变成了「一张两列的表」。
对应地,git diff 的三种形态也是在比不同的缝:
$ git diff # 工作区 ↔ 索引 (右列,「我还没暂存的」) $ git diff --staged # 索引 ↔ HEAD (左列,「我这次要提交的」) $ git diff HEAD # 工作区 ↔ HEAD (两道缝加起来)
--staged 和 --cached 完全等价,只是别名。提交之前用 git diff --staged 复查一遍,看到的就是「这个提交实际会包含什么」——这是个很值得养成的习惯。
那个经典困惑
「我明明 git add 过了,为什么 status 还说我有未暂存的改动?」
如果你刚才在 demo 里点过「改 → add → 再改」,答案已经在你眼前了:
git add a.txt 干的事是:把 a.txt 此刻的内容读出来,写成一个 blob 存进对象库,然后在索引里把 a.txt 那一行指向这个 blob。
它不是「把 a.txt 这个文件标记为需要提交」。它是「给 a.txt 此刻的样子拍了张照,放进草稿」。
照片拍完,你又改了文件——照片不会跟着变。于是工作区和索引又不一样了,右列又亮起来。
一切都非常合理,只要你把 add 读成「拍照」而不是「标记」。
顺带戳破一个常见误解:git add 已经把内容写进对象库了。
不是「等到 commit 才写」。你 add 的那一刻,blob 就已经躺在 .git/objects 里了。commit 只是把索引折成 tree、再造一个 commit 对象而已。
这个事实有个很实用的后果,第 9 章提过:add 过的内容,即使后来被覆盖,也能用 git fsck 捞回来。因为它已经是对象了。
验证「add 立刻写对象库」这件事:
# 记下现在有多少对象 $ git count-objects 6 objects, 24 kilobytes # 改个文件,只 add,不 commit $ echo "新内容" >> a.txt $ git add a.txt # 对象数增加了 —— blob 已经进库了 $ git count-objects 7 objects, 28 kilobytes
还能直接把它读出来。索引里记着哈希:
$ git ls-files --stage
100644 533ba6aae425285855227a4a95c48e95553df491 0 a.txt 100644 626799f0f85326a8c1fc522db584e86cdfccd51f 0 lib/x.txt
格式是:模式、blob 哈希、暂存槽位、路径。
那个「0」是暂存槽位(stage)——平时永远是 0,但合并冲突时会变成 1/2/3,分别装着 base/ours/theirs 三个版本。第 17 章讲冲突时会再见到它,那是理解「冲突时索引里到底有什么」的关键。
为什么是「树」
顺带解释一下这个叫法。称它们「三棵树」,是因为这三者都能被表达成一棵 tree 对象(第 3 章):
- HEAD 本来就指向一棵 tree。
- 索引可以用
git write-tree折成一棵 tree——这正是commit的第一步。 - 工作区可以先全部
add再折成 tree。
所以「比较两棵树」这件事,对三者是同一个操作,可以用同样的 Merkle 剪枝(第 3 章)来加速。Git 内部处理这三者时用的是同一套代码,不是三套。
这也是为什么 git diff、git checkout、git reset 都能接受任意两棵树作为参数——它们根本不在乎你给的是分支、提交、索引还是工作区,反正最后都归结成「比较两棵树」。
中文一般叫「暂存区」,英文原文有三个词混用:index、staging area、cache。
这三个词在 Git 的命令行参数里都留下了痕迹,所以你会见到:
git diff --cached(cache 派)git diff --staged(staging 派,后来加的别名)git update-index、git ls-files --stage(index 派)
它们说的是同一个东西——.git/index 那个文件。
「暂存区」这个译名容易让人以为它是个临时的、放着待处理东西的地方。但它其实是一份完整的清单:它列的不只是你改过的文件,而是项目里的每一个文件。下一章你会亲眼看到这一点,而它正好解释了 .gitignore 为什么有时候会「失灵」。
「我想只提交这个文件里的一部分改动,另一部分留着。」
这正是索引存在的理由。git add -p(--patch)会把改动切成小块,一块块问你要不要:
$ git add -p a.txt
@@ -12,7 +12,9 @@ function login(user, pass) {
- const t = hash(pass)
+ const t = hash(pass, salt) ← 这块是功能改动
return check(user, t)
}
Stage this hunk [y,n,q,a,d,s,e,?]?
常用的几个键:y 要、n 不要、s 把这块再切小一点、e 手工编辑要暂存哪几行、q 退出。
这么做出来的提交,每个都只讲一件事。将来 git log、git bisect(第 15 章)、code review 的人都会感谢你。
但要注意一个真实的坑:这样切出来的提交,可能根本编译不过——因为你只暂存了一半改动。所以提交前值得验一下你即将提交的那个状态,而不是工作区的状态:
$ git stash --keep-index # 把「没暂存的部分」收起来,留下索引的样子 $ npm test # 测的是索引这一版 $ git commit $ git stash pop # 把剩下的改动放回来
这一串操作之所以成立,全靠三棵树是独立的——你能单独测其中一棵。
这一章的一句话
工作区、索引、HEAD 是三棵独立的树,你每天敲的命令全是在它们之间搬运。git status 那两列字母就是三棵树之间的两道缝:左列是索引 ↔ HEAD,右列是工作区 ↔ 索引。而 git add 是「给文件此刻的样子拍照并存进对象库」,不是「标记这个文件」——所以 add 完再改,右列当然又会亮起来。
下一章:把中间那棵树彻底拆开。.git/index 是一个二进制文件,而它的结构比你想的简单得多——简单到能一口气解释「为什么 Git 存不了空目录」「为什么改了 .gitignore 没用」「为什么 git status 在大仓库里会变慢」。