bigywater.tech
Git 内部原理 · 04

暂存区 .git/index 到底是什么:手动解析这个二进制文件,看 git add 真实写了什么

前三篇讲的 blob/tree/commit/pack 都在 .git/objects 里,唯独漏了"工作目录 / 暂存区 / HEAD"这三个区域里的暂存区——它压根不在 objects 目录下,是单独一个二进制文件 .git/index。真实 git add 两个文件,手动按字节把这个文件解出来,看它到底记了什么。

add 时就写了 blob
真实验证:还没 commit,git add 已经把文件内容真实存成了 .git/objects 里的 blob 对象
3 份内容,3 条 diff
真实让同一个文件在工作目录/暂存区/HEAD 三处内容都不一样,精确对应三种 git diff

前面几篇:对象存储 · 分支的本质 · gc 与 pack

思路:三个区域,暂存区是唯一没在 .git/objects 里的那个

工作目录是磁盘上的真实文件,HEAD 指向的是已经提交过的 commit/tree/blob,暂存区单独存在一个文件里。

日常说的"工作目录 / 暂存区 / HEAD"三个区域,其实是三份不同的文件快照:工作目录就是磁盘上肉眼能看到的文件;HEAD 是上一次提交时的快照,内容全部封在 tree/blob 对象里;暂存区(即 index,也叫 staging area)是"下一次 commit 打算提交的内容",它既不是工作目录、也不是任何一次历史提交,是介于两者之间的一份独立快照。这份快照具体存在哪?就是仓库根目录下的 .git/index——一个二进制文件,不属于 .git/objects 那套内容寻址体系,直接用 cat 打开是一堆乱码。

真实验证:手动解析 .git/index,字段对不对得上 git ls-files

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 字节的整数倍:

真实实测(Python)
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" 真实长度对得上。

真实验证:文件末尾 20 字节是自校验用的 checksum

.git/index 是个自校验文件——末尾 20 字节就是对前面所有字节重新算一遍的 SHA-1。

真实实测(Python)
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 时都会顺手校验一遍这个值,文件如果被意外截断或改坏,校验不通过就会直接报错,不会假装没事地把错误数据当成合法暂存区读进来。

真实验证:commit 之前,blob 对象已经真实写进了 .git/objects

容易以为"暂存区只是个内存态的标记,提交时才真正生成内容对象"——真实检查发现不是这样。

真实实测
$ 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 没有重新生成任何文件内容。

真实验证:三份不同内容,对应三种 git diff

让同一个文件在工作目录/暂存区/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 之后又被改了一次——三个区域各自独立,任何一个区域的内容都可能跟另外两个不一样。

flags 字段里还有几个"stage"位没提到——发生合并冲突时,同一个文件在索引里会同时出现 stage 1/2/3(分别代表共同祖先、当前分支、待合并分支的版本),git ls-files --stage 最前面那列数字平时是 0,冲突时才会看到 1/2/3。这部分涉及三路合并的具体机制,留给系列后面写合并相关的文章。

参考与说明

  • 本文 .git/index 的字节解析、checksum 校验、三份 diff 结果均在本机真实 git add/git commit 后用 Python 标准库(struct/hashlib)和真实 git 命令现场跑出。
  • 对象内容寻址存储参见第一篇;本文用到的 index 格式是当前最常见的版本 2,更高版本(v3/v4)在此基础上加了扩展字段和路径压缩,没有展开。
  • 没有涉及:合并冲突时 index 里 stage 1/2/3 的具体结构、git update-index 手动操作暂存区的用法——留给系列后面按需再写。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号