前三篇讲的 blob/tree/commit/pack 都在 .git/objects 里,唯独漏了"工作目录 / 暂存区 / HEAD"这三个区域里的暂存区——它压根不在 objects 目录下,是单独一个二进制文件 .git/index。真实 git add 两个文件,手动按字节把这个文件解出来,看它到底记了什么。
工作目录是磁盘上的真实文件,HEAD 指向的是已经提交过的 commit/tree/blob,暂存区单独存在一个文件里。
日常说的"工作目录 / 暂存区 / HEAD"三个区域,其实是三份不同的文件快照:工作目录就是磁盘上肉眼能看到的文件;HEAD 是上一次提交时的快照,内容全部封在 tree/blob 对象里;暂存区(即 index,也叫 staging area)是"下一次 commit 打算提交的内容",它既不是工作目录、也不是任何一次历史提交,是介于两者之间的一份独立快照。这份快照具体存在哪?就是仓库根目录下的 .git/index——一个二进制文件,不属于 .git/objects 那套内容寻址体系,直接用 cat 打开是一堆乱码。
git add 两个文件,不 commit,直接按字节把 .git/index 解出来。
造数据 echo "hello index" > a.txt echo "another file here" > b.txt git add a.txt b.txt
$ git ls-files --stage 100644 eb2fc3ca2f129a710df1a6c0fd5ebfd088a10bfd 0 a.txt 100644 0f3e019c47668711f39499b2607e014c62b8047d 0 b.txt
这是 git 自己格式化好的结果,真实按 .git/index 的二进制格式手动解一遍,看解出来的是不是同一份数据。格式并不复杂:开头 12 字节是文件头(4 字节签名 "DIRC" + 4 字节版本号 + 4 字节条目数),后面跟着 N 条定长为主的记录,每条记录固定部分 62 字节(时间戳、设备号、inode、文件权限 mode、uid/gid、文件大小、20 字节 SHA-1、2 字节 flags),再接一个变长的文件名(以 \0 结尾),整条记录再补齐到 8 字节的整数倍:
sig, version, n = struct.unpack(">4sII", data[0:12])
# sig=b'DIRC' version=2 n=2
pos = 12
for i in range(n):
(ctime_s, ctime_ns, mtime_s, mtime_ns, dev, ino,
mode, uid, gid, size, sha1, flags) = struct.unpack(">10I20sH", data[pos:pos+62])
pos += 62
name_end = data.index(b'\x00', pos)
name = data[pos:name_end].decode()
# ...条目再按 8 字节对齐跳到下一条
条目 0: name='a.txt' mode=0o100644 size=12 sha1=eb2fc3ca2f129a710df1a6c0fd5ebfd088a10bfd
条目 1: name='b.txt' mode=0o100644 size=18 sha1=0f3e019c47668711f39499b2607e014c62b8047d
手动解出来的 mode/size/sha1/文件名,跟 git ls-files --stage 的结果逐字段完全一致。flags 字段的低 12 位记录着文件名长度,解出来也是 5,跟 "a.txt" 真实长度对得上。
.git/index 是个自校验文件——末尾 20 字节就是对前面所有字节重新算一遍的 SHA-1。
body, checksum = data[:-20], data[-20:] recomputed = hashlib.sha1(body).digest() print(checksum.hex()) print(recomputed.hex()) print(checksum == recomputed)6c7bf978f3d2d33fe34f55b9a54d8fffe2ee8fae 6c7bf978f3d2d33fe34f55b9a54d8fffe2ee8fae True —— 完全一致
这跟 blob/tree/commit 按内容算哈希是同一套思路的延伸,只是这里哈希的对象是"整个索引文件自己",git 每次读 .git/index 时都会顺手校验一遍这个值,文件如果被意外截断或改坏,校验不通过就会直接报错,不会假装没事地把错误数据当成合法暂存区读进来。
容易以为"暂存区只是个内存态的标记,提交时才真正生成内容对象"——真实检查发现不是这样。
$ git add a.txt b.txt # 还没 commit $ find .git/objects -type f -not -path '*/pack/*' -not -path '*/info/*' .git/objects/eb/2fc3ca2f129a710df1a6c0fd5ebfd088a10bfd .git/objects/0f/3e019c47668711f39499b2607e014c62b8047d哈希跟 index 里记录的 sha1 一模一样——commit 命令都还没敲,这两个 blob 对象就已经真实存在于 .git/objects 了
git add 这一步,不只是在 .git/index 里记一笔"这个文件被暂存了",而是立刻把文件当前内容按内容寻址规则真实压缩存成一个 blob 对象。git commit 要做的事,只是拿 .git/index 里已经就位的这些 (文件名, mode, sha1) 记录,组装成一个新的 tree 对象,再包一层 commit 对象——文件内容本身在 add 那一刻就已经落盘了,commit 没有重新生成任何文件内容。
让同一个文件在工作目录/暂存区/HEAD 三处内容都不一样,验证三条 diff 命令到底各自在比哪两份。
先提交一次,再制造三份不同内容 git commit -m "commit 1: add a.txt b.txt" echo "v1-staged" > a.txt git add a.txt # 此刻 index 里是 v1-staged echo "v2-working" > a.txt # 工作目录又改了一次,但没有再 add
工作目录: v2-working 暂存区(git ls-files --stage a.txt): bbc5e5a... HEAD(git rev-parse HEAD:a.txt): eb2fc3c... # 内容是最早的 "hello index"
现在三处内容真的两两不同,分别跑三条 diff 命令:
$ git diff -- a.txt # 工作目录 vs 暂存区 -v1-staged +v2-working $ git diff --cached -- a.txt # 暂存区 vs HEAD -hello index +v1-staged $ git diff HEAD -- a.txt # 工作目录 vs HEAD(前两者叠加) -hello index +v2-working
三条命令,三个完全不同的结果,精确对应三对区域:git diff 不带参数比的是"工作目录相对暂存区改了什么"(还没 add 的部分);git diff --cached 比的是"暂存区相对上次提交改了什么"(已经 add、还没 commit 的部分);git diff HEAD 相当于把前两段改动加在一起,直接对比工作目录和上次提交。平时"明明 add 了,git diff 却还显示有改动"的疑惑,根源都是工作目录在 add 之后又被改了一次——三个区域各自独立,任何一个区域的内容都可能跟另外两个不一样。