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

三棵树:工作区、索引、HEAD

前两卷讲的是 Git 内部的样子。这一卷回到你每天敲的那些命令——而它们全都是同一件事的变体:在三棵树之间搬东西。这一章之后,git status 那两列字母你就不用再逐句读了。

工作区索引HEADgit 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 过 —— 「未跟踪」
◆ 把 status 读成「两道缝的报告」

那些长句子——「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 → 再改」,答案已经在你眼前了:

◆ 你 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 捞回来。因为它已经是对象了。

⌗ 掀开 .git 看一眼

验证「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 diffgit checkoutgit reset 都能接受任意两棵树作为参数——它们根本不在乎你给的是分支、提交、索引还是工作区,反正最后都归结成「比较两棵树」。

✎ 「暂存区」这个译名有点误导

中文一般叫「暂存区」,英文原文有三个词混用:indexstaging areacache

这三个词在 Git 的命令行参数里都留下了痕迹,所以你会见到:

  • git diff --cached(cache 派)
  • git diff --staged(staging 派,后来加的别名)
  • git update-indexgit 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 loggit 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 在大仓库里会变慢」。