Git暂存区原理详解
Table of Contents
暂存区及index文件
我们知道,要向git版本库提交一个文件,需要先用git add .
(或git add <文件名>
)把该文件添加到暂存区,然后再用git commit -m "描述这次提交了什么"
来提交到版本库。
本文主要讲什么是暂存区及暂存区工作原理,至于“为什么要有暂存区”这个问题,目前我只能说,暂存区的一个作用是,先把你想提交的文件单个添加到暂存区,然后一起提交,但它肯定不只这个功能,有时候遇到要解决某些问题貌似因为暂存区的存在,很方便解决。
什么是暂存区?
暂存区,英文叫staging area(查词典是“集结待命区”的意思,git add
到暂存区确实就是集结待命,等待commit),也有人叫它缓存区(cache),或者index,暂存和缓存都是一个意思,在暂存区的文件都是等待提交(commmit)的文件。
但为什么又叫index呢?因为git add
的数据其实是存放在.git/index
文件中的,index有“索引”的意思,这个文件起名为index,估计也是“待commit的索引列表”的意思。
index文件是一个二进制文件,我们用git ls-files
可以查看里面的内容,用git ls-files --stage
会多显示一些信息。
从实例开始
# 创建一个文件夹并进入该文件夹
mkdir test-git && cd test-git
# 初始化该文件夹为git仓库
git init
# 创建一个readme.txt文件
echo 'This is a test' > readme.txt
# 在readme.txt文件未添加到暂存区时,先查看一下暂存区有什么(肯定是什么都没有)
git ls-files --stage
# 把刚刚创建的git add文件添加到暂存区中
git add readme.txt
# 然后再看看暂存区有什么
git ls-files --stage
git ls-files --stage
输出如下
100644 0527e6bd2d76b45e2933183f1b506c7ac49f5872 0 readme.txt
输出内容的解释如下
类型及权限 | 二进制内容SHA-1值 | 暂存区编号 | 文件名 |
---|---|---|---|
100644 | 0527e6bd2d76b45e2933183f1b506c7ac49f5872 | 0 | readme.txt |
在git源码说明中,有关于index
文件的格式说明index-format.txt,这里面有讲这个二进制数字的意义
32-bit mode, split into (high to low bits)
4-bit object type
valid values in binary are 1000 (regular file), 1010 (symbolic link)
and 1110 (gitlink)3-bit unused
9-bit unix permission. Only 0755 and 0644 are valid for regular files.
Symbolic links and gitlinks have value 0 in this field.
大概意思就是,这个index二进制文件中的二进制数字中,有32位二进制数字是被用来描述文件的类型以及权限的,虽然实际上只用了其中的16位,但是估计是为了统一,还是用了32位。并且描述了前4位、中间3位,后9位二进制数分别用来表示什么。
文件类型及权限
前面git ls-files --stage
输出的100644这个数字就是用来表示该文件的类型及权限的,它其实是个八进制数(而不是十进制)。
100644这6个数字中的每一个数字,其实都是由3位二进制数转换成的,现在我们把它转回二进制
十进制 | 1 | 0 | 0 | 6 | 4 | 4 |
---|---|---|---|---|---|---|
二进制 | 001 | 000 | 000 | 110 | 100 | 100 |
变成二进制就是:0b001000000110100100,其中0b
开头的数表示是二进制数,第一个1前面两个0没有意义,可以不要,所以可以写成:0b1000000110100100。
我们来看一下这个二进制数,按前面说的分成4位+3位+9位三部分代表的意义
前四位 | 中间三位 | 最后9位 |
---|---|---|
1000 | 000 | 110100100 |
前4位只可能有三种值:1000(普通文件)、1010(软链接文件)、1110(git链接文件)。
中间3位没有使用,应该是预留出来防止以后有用的。
最后9位是标准的权限位,前3位用来表示该文件所有者对它具有的权限、中间3位表示该文件所属组对它的权限、最后3位表示其它人(即非该文件所有者也非该文件所属组)对它的权限。
后9位分成3部分,如下所示
所有者 | 所属组 | 其它人 |
---|---|---|
110 | 100 | 100 |
那为什么表示权限要用三位呢?因为这三位刚好代表了读、写、执行权限,它们的英文为read、write、execute,读、写都用首字母表示,执行用第二个字母x表示,估计是用e会跟其它的混淆吧。
如下所示,读(r)、写(w)、执行(x)分别对应一位二进制数,1表示具有该权限,0表示不具有该权限
r w x
1 1 0
所以,110就表示可读可写,但不可执行,而110转成八进制是6,所以一看到6,我们就应该知道它的权限为可读可写不可执行;同理,111就表示具有读写执行三个权限,转成八进制就是7,所以644是三个八进制数,并不是十进制数!
暂存区编号
暂存区中有四个分类,这些分类叫做slot(插槽),而git add添加的文件,就是插到这四个插槽中的,这四个插槽分别编号为:0、1、2、3。
Slot 0: “normal”, 无冲突的文件会添加到这里;
Slot 1: “base”, 共同的祖先版本;
Slot 2: “ours”, 目标(HEAD)版本;
Slot 3: “theirs”, 正在被合并的版本。
index文件内容构成详解
查看index二进制内容
使用xxd
命令可以以二进制形式输出index文件中的内容
xxd -b .git/index
index文件二进制内容输出如下
00000000: 01000100 01001001 01010010 01000011 00000000 00000000 DIRC..
00000006: 00000000 00000010 00000000 00000000 00000000 00000001 ......
0000000c: 01100001 11111000 11000100 01011110 00100110 00000011 a..^&.
00000012: 10011010 10101010 01100001 11111000 11000100 01011110 ..a..^
00000018: 00100110 00000011 10011010 10101010 00000001 00000000 &.....
0000001e: 00000000 00000100 00001001 00101011 11111011 00000000 ...+..
00000024: 00000000 00000000 10000001 10100100 00000000 00000000 ......
0000002a: 00000001 11110101 00000000 00000000 00000000 00010100 ......
00000030: 00000000 00000000 00000000 00001111 00000101 00100111 .....'
00000036: 11100110 10111101 00101101 01110110 10110100 01011110 ..-v.^
0000003c: 00101001 00110011 00011000 00111111 00011011 01010000 )3.?.P
00000042: 01101100 01111010 11000100 10011111 01011000 01110010 lz..Xr
00000048: 00000000 00001010 01110010 01100101 01100001 01100100 ..read
0000004e: 01101101 01100101 00101110 01110100 01111000 01110100 me.txt
00000054: 00000000 00000000 00000000 00000000 00000000 00000000 ......
0000005a: 00000000 00000000 10000100 01001101 01111000 11000111 ...Mx.
00000060: 00100100 10001101 10100010 10011011 00000111 00000100 $.....
00000066: 10000100 10100010 01101111 11001111 11110110 01110001 ..o..q
0000006c: 00010011 11010110 11000110 01011100 ...\
index文件的二进制存储格式在index-format.txt中有写,按顺序分别为
- 1、12字节的头部信息(header),12字节是12×8=96位二进制;
- 2、排好序的index文件名(就是
git add
加入暂存区的文件名); - 3、扩展信息,它们通过签名来识别(目前我不太清楚这是干什么用的);
- 4、160位SHA-1校验和,对这个校验和前面的内容进行SHA-1计算得到的结果再拼凑到前面内容的尾部。
头部信息(header)
首先是12字节的头部信息,既然是“信息”,那它是什么信息呢?按顺序分别为:四个字节表示标记“DIRC”(DIR Cache的意思),四个字节表示版本号,最后四个字节表示暂存区文件数量。
字母 | ASCII码(十进制) | ASCII转二进制 | 二进制填充够8位 |
---|---|---|---|
D | 68 | 1000100 | 01000100 |
I | 73 | 1001001 | 01001001 |
R | 82 | 1010010 | 01010010 |
C | 67 | 1000011 | 01000011 |
把填充够8位的二进制数全部排列在一起,我们会发现,它刚好就是前面输出的二进制内容的最前面的部分
01000100 01001001 01010010 01000011
如下图所示,红色的4个字节表示DIRC四个字母,绿色4个字节表示index文件的版本号(前面都是0,只有最后10是实际数字,10就是十进制的2),蓝色的4个字母表示暂存区文件数量(前面都是0只有最后一个是1,二进制1转成十进制也是1,表示当前暂存区只有一个文件)
文件创建及修改时间
header之后就是每个文件的信息了,因为我这边只git add了readme.txt,所以只有一个文件。
文件信息首先是64位的创建时间(create time, ctime),32位秒数+32位纳秒数;接着是64位的修改时间,同样是32位秒数+32位纳秒数。
首先我们用以下命令得到readme.txt文件的ctime和mtime
stat -f 'ctime=%c mtime=%m' readme.txt | tr ' ' '\n'
输出(由于我们只创建,未修改,所以修改时间与创建时间一样)
ctime=1643693150
mtime=1643693150
我们把1643693150(十进制)转成二进制为
1100001111110001100010001011110
该数为31位,我们在前面加个0,把它补齐32位,并按每8位一个空格隔开
01100001 11111000 11000100 01011110
如下图,第一个红色32位表示ctime(文件创建时间戳,秒数部分),第一个绿色32位表示ctime(文件创建时间戳纳秒部分),第二个红色表示mtime(文件修改时间戳,秒数部分),第二个绿色32闰表示mtime(文件修改时间戳,纳秒部分)
由于在macOS中貌似不记录文件创建时间的纳秒部分,所以我们只能验证秒数部分是对的上的。
dev设备信息
接下来是32位设备信息,这块我还没有验证到,不知道具体指什么设备,我找了一下/dev/
下的硬盘设备,把设备名字转成二进制对不上,暂时放下,后面如果研究出来再补
00000001 00000000 00000000 00000100
inode编号
接下来就是32位的ino(inode)编号,inode是index node,索引节点,硬盘存储原理会用到这个
ls -i readme.txt
输出inode如下
153877248 readme.txt
153877248转成二进制并在前面补0补齐到32位,每8位隔开如下
00001001 00101011 11111011 00000000
32位文件类型及权限
接下来是32位的mode(文件类型+权限),前面我们已经查看过文件权限为:100644,刚好对应以下32位,其中前面16位都是0表示没有用到
00000000 00000000 10000001 10100100
32位uid
接下来是32位的uid,使用id -u
可以查看当前用户id,对于macOS默认用户的id一般都是501
,转成二进制是111110101
,前面填充0补到32位,并每8位隔开
00000000 00000000 00000001 11110101
32位gid
接下来是32位gid,使用id -g
可以获取,macOS一般是20
,转成二进制是10100,在前面补0填充够32位,并每8位隔开为
00000000 00000000 00000000 00010100
32位文件大小
32位的文件大小,使用ls -l readme.txt
可以查看文件大小,可以看到是15,单位是字节(Byte)
15转成二进制为“1111”,在前面补0补齐32位并每8位隔开为
00000000 0000000 00000000 00001111
160位SHA-1值
160位(即20Byte)SHA-1值,这个值就是前面我们用看到的那串长度为40的十六进制数
git ls-files readme.txt --stage
我们把“0527e6bd2d76b45e2933183f1b506c7ac49f5872”转成二进制数有点麻烦,我们可以反过来转,就是把二进制转成16进制,如下所示
上图一共20个字节,每个字节有8位,4位二进制就可以转一位十六进制,所以一个字节前4位跟后4位分开转就行,比如最后一个8位是“01110010”,那么前四位转十六进制数是7,后四位是2,所以转成十六进制就是72(注意这是十六进制,不是十进制)。
转换结果,刚好就是:
0527e6bd2d76b45e2933183f1b506c7ac49f5872
其实当进制数=2n时,它们与二进制是可以直接相互转换的,不用除2取余法,比如16进制,我们直接把它的每位数字转成二进制就行,不足4位在前面补0,而且一定要补0,八进制就是每3位二进制表示一位八进制数,也是不足3位就补0。
根据这个原理,我用golang写了个十六进制转二进制字符串的转换函数
// Hex2binStr converts hexdecimal to binary number and return as string
func Hex2binStr(hex string) (binStr string) {
hex2binMap := map[string]string{
"0": "0000",
"1": "0001",
"2": "0010",
"3": "0011",
"4": "0100",
"5": "0101",
"6": "0110",
"7": "0111",
"8": "1000",
"9": "1001",
"a": "1010",
"b": "1011",
"c": "1100",
"d": "1101",
"e": "1110",
"f": "1111",
}
charCount := len(hex)
for i := 0; i < charCount; i++ {
if bin, ok := hex2binMap[hex[i:i+1]]; ok {
binStr += bin
}
}
return binStr
}
16位flag
接下来是16位的flag
- 1位假设有效标志,在这里是0
- 1位扩展标志,在index版本2中必须为0(前面的header里已经得知我目前用的git的index版本是2)
- 2位stage(在合并期间使用),其实具体怎么用我也不知道
- 12位文件名称长度(如果长度小于
0xFFF
的话,如果大于0xFFF
,那就是0xFFF
,因为12位能存下的最大数就是0xFFF
,其中0x
表示十六进制)。
16位flag如下
n位文件名
接下来是文件名,readme.txt
共10个字符,每个字符1字节,共10字节,所以文件名占多少位要看文件名的长短
NUL字节结尾
文件名结束后,实际内容已经结束了,但是它要求文件名一定要以NUL结尾(可能是为了识别结尾吧),注意nul是名词,意思就是“空;空字符”,而null是形容词,表示空的,零值。
所以我们需要填充一个字节的空值,也就是8个全0,但是这样还不行,因为还要求从header之后(其实就是ctime开始)到文件名NUL字节结尾总字节数必须为8的倍数。
我们先看一下,从ctime到文件名(此时未添加NUL结尾)共12行,每行6个字节就是72,72刚好是8的倍数,但是如果我添加一个NUL字节后,就不是8的倍数了,这意味着,我必须一次性添加8个NUL能凑够8的倍数
所以这后面8个NUL(全0)字节,是为了凑够从header后到NUL结尾总字节数为8的倍数
假如有另外的例子,比如从header后到文件名结尾有75个字节,那么再加一个NUL字节就是76字节,很显示我们还要额外加4个全0(NUL)字节凑够80,这样才是8的倍数,所以这个末尾补多少个NUL字节是不一定的,主要是为了保证:以NUL字节结尾+从header后到结尾的NUL字节之间的总字节数是8的倍数。
160位总SHA-1值
最后20个字节又是检验和(checksum),它的值是这个校验和前面所有内容做SHA-1算法获得的
这篇文章讲的非常详细。
题主写的真好,赞
谢谢你的肯定