本文完整阅读约需 31 分钟,如时间较长请考虑收藏后慢慢阅读~

上一篇文章中,我们了解了索引的基本概念,也明白了索引存储着所有文件与对象的关联以及所有文件的基础信息。这篇文章我们来深入Git,从二进制的角度了解其索引结构。

0x01

什么是Git索引?

这个问题在之前的文章我已经给出了解答,为便于阅读,本篇文章对Git索引再进行一次解释:

Git索引是一个在你的工作目录和项目仓库间的暂存区(staging area). 有了它, 你可以把许多内容的修改一起提交(commit). 如果你创建了一个提交(commit), 那么提交的是当前索引(index)里的内容, 而不是工作目录中的内容.

因此只要执行了git add,就算工作目录中的文件被误删除,也不会引起文件的丢失,因为文件已经被存入Git对象,并使用索引进行定位。

我们可以通过git ls-files --stage命令看到仓库中每一个文件及其所对应的文件对象,也可以直接通过查看二进制索引文件的方式来了解更多信息(尽管过于硬核)。

0x02

索引文件存储在.git/index中,.git目录存储有该仓库所有的文件,而其他目录只是根据该目录所重建出的镜像。

.git/index是一个二进制文件,如果直接使用文本编辑器打开会发现全是乱码。在Linux/MacOS中,我们可以使用hexdump来以十六进制方式显示该文件:

#  hexdump -C .git/index
00000000  44 49 52 43 00 00 00 02  00 00 00 34 5d 45 6b 72  |DIRC.......4]Ekr|
00000010  00 00 00 00 5d 45 6b 72  00 00 00 00 00 00 00 00  |....]Ekr........|
00000020  04 51 c4 fe 00 00 81 a4  00 00 01 f5 00 00 00 14  |.Q..............|
00000030  00 00 00 78 d7 17 f9 f6  5d 8c 51 af e0 74 d2 f9  |...x....].Q..t..|
00000040  c7 99 e3 43 6a d1 72 fd  80 0a 2e 67 69 74 69 67  |...Cj.r....gitig|
00000050  6e 6f 72 65 00 00 00 00  00 00 00 00 5c 16 88 81  |nore........\...|
00000060  00 00 00 00 5b bd a4 c6  00 00 00 00 01 00 00 04  |....[...........|
00000070  00 0e 74 81 00 00 81 a4  00 00 01 f5 00 00 00 14  |..t.............|
00000080  00 00 02 dd 7a 4e b6 57  21 37 f4 60 11 ca 25 6a  |....zN.W!7.`..%j|
00000090  ba 51 4c b7 18 78 f7 3a  00 0c 43 48 41 4e 47 45  |.QL..x.:..CHANGE|
000000a0  4c 4f 47 2e 6d 64 00 00  00 00 00 00 5c 16 88 7e  |LOG.md......\..~|
000000b0  00 00 00 00 5b f5 4c bf  00 00 00 00 01 00 00 04  |....[.L.........|
......

篇幅所限,只能显示较少一部分,这一部分包含了关于.gitignore文件的元数据,足够我们分析。

1. 索引识别符

索引识别符占用了索引文件的开头12字节:

44 49 52 43 00 00 00 02  00 00 00 34

其中前四字节包含了『DIRC』(0x44495243),指『DirCache』,用于标识该文件是否是合法的索引文件,中间四字节包含了索引文件的版本,当前版本为『2』(0x00000002),最后四字节为32位无符号整数,标识了索引的文件数目,该仓库内存储有52个文件,即文件数目为52(0x00000034)。

2. 状态数据

该部分存储与文件相关的状态。在分析前,我们首先对.gitignore文件做一次Linux下的stat命令:

# stat -f 'mtime=%m ctime=%c' .gitignore | tr ' ' '\n'
mtime=1564830578
ctime=1564830578
# stat -t '%FT%T' .gitignore
0 12957369598 -rw-r--r-- 1 lurenjiasworld staff 0 120 "2019-08-03T21:17:23" "2019-08-03T19:09:38" "2019-08-03T19:09:38" "2019-08-03T19:09:33" 4096 8 0 .gitignore

了解stat命令的输出,有助于我们接下来的分析。

2.1 64位文件创建时间

索引识别符接下来的8字节包含了文件的创建时间,即『2019-08-03T19:09:38』或『1564830578』(0x5d456b7200000000),由于MacOS所使用的HFS+文件系统不支持纳秒精度时间戳,因此后四个字节被置0,以保障与其他文件系统的兼容性。

2.2 64位文件修改时间

在创建时间后面所存储的是文件修改时间,即『2019-08-03T19:09:38』或『1564830578』(0x5d456b7200000000),其格式与创建时间相同。

2.3 存储设备编号与inode编号

设备编号与inode编号各占四个字节。
前四个字节为设备编号,由于MacOS不支持设备编号,被置空为0(0x00000000)。
后四个字节为inode编号,溢出的bit被裁切:

原inode编号:`00 03 04 51 c4 fe` -> 0000 0000 0000 0011 | 0000 0100 0101 0001 | 1100 0100 1111 1110
Git索引中存储的inode编号:`04 51 c4 fe` -> 0000 0100 0101 0001 | 1100 0100 1111 1110

此处可能会令读者非常费解,为什么不直接使用64位inode编号?但其实inode编号在Git中唯一的作用只是检测文件是否修改,因此使用64位inode编号意义不大,可参考以下相关代码了解其唯一用途。

read-cache.c更多源码

sd->sd_mtime.nsec = ST_MTIME_NSEC(*st);
sd->sd_dev = st->st_dev;
sd->sd_ino = st->st_ino;
sd->sd_uid = st->st_uid;
......
        changed |= OWNER_CHANGED;
    if (sd->sd_ino != (unsigned int) st->st_ino)
        changed |= INODE_CHANGED;
}

2.4 32位文件模式

接下来的四个字节是文件模式,从之前的stat我们可以得知文件的模式为-rw-r--r--,即八进制中的100644(0x000081a4)。

2.5 32位所属用户UID

接下来的四个字节是所属用户UID,在MacOS中执行id -u命令,获取当前的用户UID为501(0x000001f5),与索引中一致。

2.6 32位所属用户组

接下里的四个字节是所属用户组,在MacOS中执行id -g命令,获取当前的用户GID为20(0x00000014),同样与索引中一致。

2.7 32位文件大小

接下来的四个字节是文件大小,从stat中可以看到文件大小为120字节(0x00000078),与索引中一致。

2.8 160位SHA-1格式对象ID

接下来的20个字节是文件所对应的Git对象文件的SHA-1哈希,即.gitignore文件的ID,该ID可以通过# 0x01中所提到的git ls-files --stage命令获取。

2.9 16位对象状态

接下来的两个字节1000 0000 0000 1010(0x800a)包含了大量内容:

2.9.1 1位假定不变标识

在《如何让GIT忽略本地对已入库文件的修改》一文中,我提到过『假定不变』标识的概念,当时我们所使用的命令为git update-index,事实上就是update到了索引文件的该标识符。

其中,0 表示跟踪所有变更,1 表示忽略所有变更。事实上我们对该文件做了『假定不变』配置,与索引中一致。

2.9.1 1位扩展标识

该标识在当前索引文件版本(版本2)中无意义,被置0。

2.9.2 2位阶段标识

该标识用于在合并分支的时候使用,可以通过# 0x01中所提到的git ls-files --stage命令获取。普通(未合并)文件通常为0。

2.9.3 12位文件名长度

该标识存储了文件名的长度,最长支持到4095位(0xFFF),溢出部分将被忽视。

2.10 可变长文件目录

实际上我们的文件.gitinore目录长度只有72位,即9字节,此处为了对齐,在其后填充了一个字节。文件目录长度可变,不固定。

2.11 32位分隔符

分隔符长度为8字节(32位),通常为0x00000000,以分隔不同的文件。

将以上所有内容综合,我们可以画出以下图表来直观表示文件索引的结构:

  | 0           | 4            | 8           | C              |
  |-------------|--------------|-------------|----------------|
0 | DIRC        | Version      | File count  | ctime       ...| 0
  | ...         | mtime                      | device         |
2 | inode       | mode         | UID         | GID            | 2
  | File size   | Entry SHA-1                              ...|
4 | ...                        | Flags       | Index SHA-1 ...| 4
  | ...                                                       |

3 目录索引

对于有目录的Git仓库,通常还会额外引入目录索引,以实现更快的工作目录重建。
该部分将以下面的索引文件片段作为例子:

......
000012a0  46 6e 13 7c f7 e9 2c 9f  00 0f 75 74 69 6c 2f 74  |Fn.|..,...util/t|
000012b0  72 61 63 6b 65 72 2e 67  6f 00 00 00 54 52 45 45  |racker.go...TREE|
000012c0  00 00 01 af 00 2d 31 20  38 0a 64 62 00 33 20 30  |.....-1 8.db.3 0|
000012d0  0a 91 ca 3b 67 51 7e bf  1f 44 cd 5b 50 5f d0 42  |...;gQ~..D.[P_.B|
000012e0  2e 8f fa ab 31 61 70 70  00 35 20 30 0a 9a e2 bb  |....1app.5 0....|
000012f0  7d 86 10 40 bc 94 ec e7  a9 eb f9 04 47 d5 31 2f  |}[email protected]/|
00001300  98 63 6f 72 65 00 31 20  30 0a 50 4e e2 1e a0 aa  |.core.1 0.PN....|
00001310  a8 ca 68 ea 75 a0 90 ae  0a e4 45 e7 27 f1 75 74  |..h.u.....E.'.ut|
......

目录索引的格式如下所示:

3.1 32位识别符

该识别符包含四个字节,即TREE(0x54524545)。

3.2 32位目录索引长度

该部分总长四个字节,值为接下来目录索引的长度,该处长度为431字节(0x000001af),与实际情况一致。

以下为每个目录节点的结构,以树形方式组织

3.3 每个目录节点

3.3.1 可变长目录名

目录索引中的目录名与文件索引的文件名有所区别,使用NUL字符(即0x00\0)结尾(与字符串一致)。目录名相对于该目录的父目录定位。若当前节点是根目录,则只包含一个NUL

3.3.2 索引中该树所包含的节点数(即叶子节点)

该值格式为ASCII数字,由于目录索引只是缓存,因此若其值为-10x2d31),说明并未缓存叶子节点数量,若为其他值,则其值为索引中该树所包含的节点数。

3.3.3 一字节的ASCII空格

该空格(0x20)用于分隔叶子节点数与子树数。

3.3.4 索引中该树所包含的子树数

该值格式同样是ASCII数字,如数字8(0x38),通常准确,否则无法正确生成缓存树(即根据子树数,将接下来的若干个节点作为当前节点的子树处理)。

3.3.5 一字节的ASCII换行符

该换行符(0x0a)用于分隔子树数与接下来的校验值

3.3.6 160位SHA-1校验值

该校验值为节点对应对象的校验值(根节点没有校验值),例如上述例子中util目录的校验值为0x17765ec6df577218aefc197ca6349b80c3ed539c。我们可以通过git rev-parse master:util命令来获取该目录对应的对象,经查询与索引文件一致。

4 160位文件校验值

该校验值为不包含该校验值剩下部分的SHA-1值,总长20字节。如果索引文件被破坏,Git会提示如下错误:

# git status
error: bad index file sha1 signature
fatal: index file corrupt

索引文件被破坏后,仓库并未受损,但未commit的数据将会在重建仓库索引后消失(实际上也并未消失,只是存储于.git/object/中,但没有指向它的指针)。也正因为如此,就算索引文件被破坏,只要破坏不是很彻底,也可以从中恢复出对应的对象文件。

重建索引需要使用以下两个命令:

mv .git/index .git/index.corrupt
git reset
git add -u

0x03

事实上Git的索引文件相当复杂,除了以上提到的文件索引(一定有)和目录索引(可能有)以外,还会有以下若干种索引:
– REUC – Resolve Undo 用于解决冲突后复原冲突,可以先手动触发一个冲突,解决之后利用hexdump查看
– link – Split Index 用于分散索引文件,避免索引文件体积过大,可使用git update-index --split-index手动开启
– UNTR – Untracked Cache 用于缓存在工作区但未提交的文件索引,可使用git update-index --untracked-cache手动开启
– FSMN – File System Monitor Cache 文件系统监视缓存,通过文件系统所提供信息的变更来判断文件是否发生了改变,可先编辑.git/config文件,在core字段内加入fsmonitor = true,然后执行git update-index --fsmonitor手动开启
– EOIE – End of Index Entry 文件索引结束标识符,用于更快读取文件索引后方的扩展索引(而不用等待文件索引完成),可先编辑.git/config文件,在index字段内加入threads = true激活之。
– IEOT – Index Entry Offset Table 索引项偏移表,用于优化多核心处理器下Git索引的性能。该功能于Git2.20版本推出(2018年底),本文撰写于2019年8月,主流发行版的Git大多尚未支持该特性,同样可先编辑.git/config文件,在index字段内加入threads = true激活之。