一、引言
在我们日常使用 Git 时,通常的操作是:
- 在写完一段代码后,执行
git add
命令,将这段代码添加到暂存区中 - 然后再执行
git commit
和git push
命令,将 本地 Git 版本库中的提交同步到服务器中的版本库中
Git 在中间做了什么,它如何存储不同的文件和内容,以及如何区分不同分支下的文件版本呢?日常操作对这些自动的操作都是无感的。
但是如果哪天一旦上述操作中出现了错误,需要找回自己的代码时,如果不懂 Git 其内部存储原理,是没法找回的,因此为了避免这种情况,就有必要去了解其内部的存储——Git 对象的原理。
二、Git 对象
2.1. Git 对象概述
我们知道,Git 是一个内容寻址文件系统,其核心部分是一个键值对数据库。
当我们向 Git 仓库中插入任意类型的内容时,它会返回一个唯一的键。我们可以通过该键在任意时刻再次取回插入的内容。
比如我们初始化 GitDemo
,发现 Git 对 objects 目录进行了初始化,并创建了 pack 和 info 子目录,但均为空:
$ git init GitDemo
Initialized empty Git repository in D:/GitDemo/.git/
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
$ find .git/objects -type f
然后创建一个 readme.txt 文本,执行 git add
后会发现在 .git/objects
中新增了一个文件夹 89
和文件 dab47ae90ebdfee4e6cb3d64708cd73e9c5472
,
$ echo 'read me please' > readme.txt
$ git add readme.txt
$ find .git/objects -type f
.git/objects/89/dab47ae90ebdfee4e6cb3d64708cd73e9c5472
查看其文件内容,类型和大小:
$ git cat-file -p 89dab47ae90ebdfee4e6cb3d64708cd73e9c5472
read me please
$ git cat-file -t 89dab47ae90ebdfee4e6cb3d64708cd73e9c5472
blob
$ git cat-file -s 89dab47ae90ebdfee4e6cb3d64708cd73e9c5472
15
这个键值为 89dab47ae90ebdfee4e6cb3d64708cd73e9c5472
的对象就是 Git 对象中的 blob 对象。而且 Git 中所有的对象都存储在 .git/objects
目录(也叫做对象库)中。
这个键值是一个 SHA-1 的哈希值,由 40 个十六进制的数字组成。它是通过一个将待存储的数据外加一个头部信息一起做 SHA 算法运算而得到的校验和。40 个十六进制数字就相当于 160 比特,当用 SHA-1 对不同对象进行区分和识别时,冲突的概率就会极低,不用存储文件的具体类型,用 blob 和 SHA-1 就足以分辨不同文件内容了。
下面来看看 Git 对象的类型:
2.2. Git 对象类型
2.2.1. Blob 对象
1. Blob 对象的定义和作用
Blob(Binary Large Object,二进制大对象)是Git中的一种对象类型,用来指代某些可以包含任意数据的变量或文件。它是Git对文件内容的一种抽象表示。每个文件在Git仓库中都被表示为一个独立的Blob对象。Blob对象保存了文件的原始二进制数据,无论文件是文本文件还是二进制文件,Git都以Blob对象的形式存储它们。
比如在上一节中的 readme.txt 文本,在 Git 中就是以 blob 对象存储的:
$ git cat-file -t 89dab47ae90ebdfee4e6cb3d64708cd73e9c5472
blob
$ git cat-file -p 89dab47ae90ebdfee4e6cb3d64708cd73e9c5472
read me please
- **当在Git仓库中添加、修改或删除某个文件时,Git会创建一个新的Blob对象来存储这个文件的内容。**这样就可以跟踪文件的变化历史,并且可以在需要时恢复到特定的文件版本。
比如我们修改 readme.txt 文本,会发现有两个 blob 对象存储 readme.txt 的两个版本:
//新增一行文本: reading
$ vi readme.txt
$ git add readme.txt
//原来版本的readme.txt内容还存在:
$ git cat-file -p 89dab47ae90ebdfee4e6cb3d64708cd73e9c5472
//新版本的readme.txt内容
$ git cat-file -p b0530c9
read me please
reading
- 因为在修改内容后创建了新的 Blob 对象,因此 Git 可以使用 Blob 对象来进行文件比较操作。通过比较两个Blob对象的哈希值,Git可以快速确定文件内容是否发生了变化,从而进行版本控制和合并操作。
2. Blob 对象的存储方式
Blob对象在Git中的存储方式是使用对象哈希值来进行索引和存储。具体的存储方式如下:
- 当在 Git 仓库中添加文件并执行
git add
时,Git 就会提取该内容,然后将内容进行 SHA-1 哈希计算,得到一个40个十六进制字符的哈希值。这个哈希值就是Blob对象的唯一标识符,也就是我们上节提到的键。 - 而后如果Blob对象是新的,Git会将它以哈希值(上面由 SHA-1 哈希计算得到的标识符)为文件名存储在对象数据库中(也就是
.git/objects
目录下)。 - 存储时,Git将Blob对象的内容写入一个临时文件,并将该文件的路径与哈希值相关联。(一般来说取前两位作为文件目录,剩下的 38 位作为文件名。比如 readme.txt 文件:目录是
89
,文件名为dab47ae90ebdfee4e6cb3d64708cd73e9c5472
。) - 此外如果 blob 对象过大,Git会对存储的Blob对象进行压缩,并将压缩后的数据写入真正的对象文件中。这些压缩的文件存储在
.git/objects/pack
中
2.2.2. Tree 对象
1. Tree 对象的定义和作用
Tree
对象是Git中的一种对象类型,用于表示文件和目录的组织结构。每当向Git仓库中添加一个目录时,Git会创建一个新的Tree对象来表示该目录的结构。Tree对象包含了目录中的文件和子目录的元数据,以及它们对应的Blob或Tree对象的哈希值。
比如我们接着在 GitDemo 仓库中添加目录 lib
和文件 readme2.txt 并提交后,当前目录为:
│ readme.txt
│
└─lib
readme2.txt
在 git 中的存储如下:
$ git cat-file -p master^{tree}
040000 tree dbff68a947c7cc60653ff64260b372a405939ae2 lib
100644 blob b0530c9b7360a8cea0e4af86475cac70a2985138 readme.txt
master^{tree}
语法表示 master 分支上最新的提交所指向的树对象。lib
子目录(所对应的那条树对象记录)并不是一个数据对象,而是一个指针,其指向的是另一个树对象:
$ git cat-file -p dbff68a947c7cc
//模式 对象类型 对象的SHA-1值 文件名
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 readme2.txt
从上面可以发现,一个 Tree 对象包含一条或多条树对象记录。每条记录含有一个指向数据对象或者子目录对象的 SHA-1 指针,以及相应的模式、类型、对象名(SHA-1 值)、文件名信息:
- 模式(Mode):模式表示文件或目录的类型和权限。它是一个八进制数字,通常以4位表示。常见的模式包括:
- 100644:表示普通文件,即Blob对象。
- 100755:表示可执行文件,即Blob对象。
- 040000:表示目录,即Tree对象。
- 类型(Type):类型表示列表项所指向的对象类型,即是Blob对象还是Tree对象。
- 对象名(Object Name):对象名是对应的Blob或Tree对象的哈希值。它是一个40个十六进制字符的字符串,用于唯一标识对象。
- 文件名或目录名(File/Directory Name):文件名或目录名是列表项所代表的文件或目录的名称。
如果记录中的类型为 Blob,表示该项是一个文件;如果该类型为 Tree,表示该项是一个子目录。
2. Tree 对象的存储方式
和 blob 对象的存储方式类似,Tree对象在Git中的存储方式是使用对象哈希值来进行索引和存储。当执行 git add
时 Git 内部的操作有:
- 构建树对象:当你在Git仓库中添加、修改或删除文件时,Git会根据当前目录结构构建一个Tree对象。该Tree对象包含了目录中的文件和子目录的元数据,以及它们对应的Blob或Tree对象的哈希值。
- 哈希计算:Git会对Tree对象的内容进行哈希计算,生成一个40个十六进制字符的哈希值。这个哈希值就是Tree对象的唯一标识符。
- 检查存储:Git会检查对象数据库中是否已存在具有相同哈希值的Tree对象。如果存在,则直接引用已有的对象;如果不存在,则进行存储。
- 存储对象:如果Tree对象是新的,Git会将它以哈希值为文件名存储在对象数据库中。对象数据库通常位于".git/objects"目录下。存储时,Git将Tree对象的内容以特定的格式写入一个临时文件,并将该文件的路径与哈希值相关联。
- 压缩和索引:Git会对存储的Tree对象进行压缩,并将压缩后的数据写入真正的对象文件中。同时,Git会更新索引文件,将Tree对象的哈希值与文件路径进行映射。
2.2.3. Commit 对象
1. Commit 对象的定义和作用
Commit对象是Git中的一种对象类型,用于记录代码仓库中的提交操作(也就是执行 git commit
命令)。每个Commit对象代表一个特定的提交操作,包含了提交的元数据和指向代码快照的引用。通过Commit对象,Git能够跟踪代码修改的历史,并实现版本控制和代码回溯等功能。
如下图所示,每个 Commit 对象就是一个 version 版本,Commit 对象通过指向代码快照(也就是一个 Tree 对象)的引用,记录了代码仓库在某个特定时间点的状态。
我们再次引用上面 Tree 对象中的 GitDemo 仓库案例,此时仓库中有如下文件和目录:
│ readme.txt
│
└─lib
readme2.txt
可以通过 git cat-file master
命令查看此时的 commit 对象:
//master就是一个指向commit对象的指针, 其内部存储Commit对象的SHA-1值
$ cat .git/refs/heads/master
2b2af66549827bd6a466fe43081f406c2a12900b
$ git cat-file -p 2b2af665498
tree 2503e9e0c4f774fc5ce298f4972f0e6d3a800d6f
parent 7b34a1e750918570ed610ee1f228e83b43a1192e
author wangJw <wangJw@163.com> 1705458723 +0800
committer wangJw <wangJw@163.com> 1705458723 +0800
second commit
从上面可知,一个 commit 对象由这样几个部分组成:
- 指向代码快照的引用(tree):Commit对象包含一个指向代码快照的引用,通常是指向一个Tree对象。该Tree对象记录了提交时代码仓库中文件和目录的状态。
- 父提交(parent):Commit对象可以有一个或多个父提交,指向前一个或多个Commit对象。这构成了提交历史的链式结构。通常,一个Commit对象的父提交是它之前的一个Commit对象,除非进行了分支合并等操作。
- 作者(author):Commit对象记录了提交的作者信息,包括姓名和电子邮件。作者是指实际进行代码更改的人。
- 提交者(Committer):Commit对象还包含了提交者信息,通常与作者相同。提交者是指将更改提交到代码仓库的人。
- 提交消息(Commit Message):也就是 second commit 中的内容, Commit对象包含了提交时附加的可选消息,用于描述提交的目的、更改的内容、修复的问题等。提交消息可以提供其他开发者和团队成员了解提交的背景和目的。
- Commit对象的哈希值(SHA-1 值):每个Commit对象都有一个唯一的哈希值,用于标识该对象。
加上 commit 对象和 master 指针,可以完善在 tree 对象中的图:
2. Commit 对象的存储方式
当执行 git commit
命令提交代码时,commit 对象随之创建,Git 的内部操作有:
- 内容提取:执行git commit命令提交代码时,Git会提取提交的相关信息,包括作者、提交者、提交时间、提交消息和父提交等。
- 创建对象:Git会将提交信息和父提交的引用等数据组合成一个Commit对象。
- 哈希计算:Git会对Commit对象的内容进行SHA-1哈希计算,得到一个40个十六进制字符的哈希值。这个哈希值就是Commit对象的唯一标识符。
- 检查存储:Git会检查对象数据库中是否已存在具有相同哈希值的Commit对象。如果存在,则直接引用已有的对象;如果不存在,则进行存储。
- 存储对象:如果Commit对象是新的,Git会将它以哈希值为文件名存储在对象数据库中。对象数据库通常位于
.git/objects
目录下。存储时,Git将Commit对象的内容写入一个临时文件,并将该文件的路径与哈希值相关联。 - 压缩和索引:Git会对存储的Commit对象进行压缩,并将压缩后的数据写入真正的对象文件中。同时,Git会更新索引文件,将Commit对象的哈希值与文件路径进行映射。
2.2.4. Tag 对象
1. Tag 对象的定义和作用
Tag对象是Git中的一种对象类型,用于给特定的提交打上标记。Tag对象的主要作用是标记代码仓库中的特定提交或里程碑。它可以用于记录发布版本、重要的里程碑、稳定的代码快照等。git 标签分为两种类型:轻量标签和附注标签。
轻量标签
轻量标签是指向提交对象的引用:
//创建轻量级标签
$ git tag firstTag
//查看标签:
$ git tag
firstTag
当创建了 firstTag 后,会在.git/refs/tags 目录下创建一个名为 firstTag 的文件,其内容指向当前的 commit 对象的 SHA-1 值
$ cat .git/refs/tags/firstTag
2b2af66549827bd6a466fe43081f406c2a12900b
//轻量标签指向提交对象的引用
$ git cat-file -t firstTag
commit
$ git cat-file -p firstTag
tree 2503e9e0c4f774fc5ce298f4972f0e6d3a800d6f
parent 7b34a1e750918570ed610ee1f228e83b43a1192e
author wangJw <wangJw@163.com> 1705458723 +0800
committer wangJw <wangJw@163.com> 1705458723 +0800
second commit
我们发现,轻量标签 firstTag 中的内容只有一个指向提交对象的 SHA-1 值。没有其他内容,因此无法知道何人,什么时间创建的标签。在团队开发中很容易发生混淆,因此可以用另外一种打标签的方式:附注标签
附注标签
附注标签则是仓库中的一个独立对象,使用带参数 -a
或 -m <msg>
的 git tag
命令:
//创建一个空提交:
$ git commit --allow-empty -m "empty commit for tagTest"
[master 8a4678f] empty commit for tagTest
//创建一个附注标签
$ git tag -m "secondTag" secondTag
//查看所有标签
$ git tag
firstTag
secondTag
这个时候再来看看 .git/refs/tags
中的 secondTag
标签内容:
//查看该标签的类型
$ git cat-file -t secondTag
tag
//再来看看secondTag标签的内容
$ git cat-file -p secondTag
object 8a4678fae181c16c6f4ff0e6a618991128d86da2
type commit
tag secondTag
tagger wangJw <wangJw@163.com> 1705480524 +0800
secondTag
主要由这样几个部分组成:
- 标签指向的提交对象(object):附注标签对象中包含一个指向特定提交的引用。此处 object 中的值为我上面的提交对象的 SHA-1 值
- 标签指向对象类型(type):指向的提交对象的类型
- 标签名称(tag):附注标签对象包含一个唯一的标签名称,用于标识和引用该标签。
- 标签作者(tagger):附注标签对象记录了标签的作者信息,包括姓名和电子邮件地址。作者是指创建该标签的人。
- 标签消息(Tag Message):其中的 "secondTag"内容,附注标签对象包含一个可选的标签消息,用于描述标签的目的、里程碑或其他相关信息。标签消息可以提供其他开发者和团队成员了解标签的背景和用途。
我们再来看看 Tag 对象的存储方式
2. Tag 对象的存储方式
当执行带有 -a
或 -m <msg>
的 git tag
命令时,Git 就会由如下操作:
- 创建对象:当你执行创建标签的操作(如git tag命令)时,Git会创建一个Tag对象。
- 内容提取:Tag对象包含标签的名称、类型、标签指向的提交、标签作者、标签创建时间、标签消息等信息。
- 哈希计算:Git会对Tag对象的内容进行SHA-1哈希计算,得到一个40个十六进制字符的哈希值。这个哈希值就是Tag对象的唯一标识符。
- 检查存储:Git会检查对象数据库中是否已存在具有相同哈希值的Tag对象。如果存在,则直接引用已有的对象;如果不存在,则进行存储。
- 存储对象:如果Tag对象是新的,Git会将它以哈希值为文件名存储在对象数据库中。对象数据库通常位于
.git/objects
目录下。存储时,Git将Tag对象的内容写入一个临时文件,并将该文件的路径与哈希值相关联。 - 压缩和索引:Git会对存储的Tag对象进行压缩,并将压缩后的数据写入真正的对象文件中。同时,Git会更新索引文件,将Tag对象的哈希值与文件路径进行映射。
三. Git 对象的存储
3.1 SHA-1 算法如何生成哈希值
SHA-1(Secure Hash Algorithm 1)是一种用于生成哈希值的加密算法。该算法将任意长度的输入经过散列运算转换为固定长度的输出。这个固定长度的输出就叫做对应输入内容的数字摘要或者哈希值。
那么对于 Git 对象中的 SHA-1 哈希值是如何生成的?
在 《Pro Git 2nd》这本书提到,SHA-1 哈希值是通过将待存储的数据+一个头部信息(header)一起做 SHA-1 校验运算而得到的。
而在头部信息由这些部分组成:
- 对象类型字符串,比如"blob", “tree”, “commit”, “tag”
- 空格
- 数组内容的字节数
- 空字节(null byte)
Git 会将上述的头部信息和文件原始数据拼接,来计算出 SHA-1 校验和。在 Linux 中有 sha1sum 命令可以生成 SHA1 哈希值,下面来验证一下我们生成的 SHA1 哈希值和 Git 是不是相同的:
//当前目录结构
│ a.txt
│
└─b
c.txt
3.1.1. blob 对象的 SHA1 哈希值
先来看看 blob 对象,也就是 a.txt 对应的文件内容的 SHA1 哈希值生成过程,
//a.txt中的内容为:
$ cat a.txt
123
//字符数为3
$ git cat-file blob HEAD:a.txt | wc -c
3
其头部信息为 blob 3\000
在文件内容上加上头部信息,然后对新文件内容执行 SHA-1 哈希算法:
$ (printf "blob 3\000"; git cat-file blob HEAD:a.txt) | sha1sum
d800886d9c86731ae5c4a62b0b77c437015e00d2 *-
查看在 Git 仓库中是否找到该 SHA-1 值对应的 blob 对象
$ git cat-file -p d80088
123
说明执行 sha1 算法和 Git 操作算法得到的结果一致,验证了 Git 中 SHA-1 哈希值的生成过程
3.1.2. commit 对象的 SHA1 哈希值
此时在提交链最末端的 commit 对象内容是:
$ git cat-file commit master
tree 46bda27c4834d428a388841808fdaa7ca15a7bc1
parent 61b04b17412e1d9639db2a6b1b4e83319473a14a
author wangJW <1w@163.com> 1679818066 +0800
committer wangJW <1w@163.com> 1679818066 +0800
second commit
根据头部信息的组成,需要知道 commit 中的字符数:
$ git cat-file commit HEAD | wc -c
218
然后加上空格以及空字符串:commit 218\000
,然后与 commit 对象内容拼接,将拼接后的内容计算 SHA1 校验和:
$ (printf "commit 218\000"; git cat-file commit HEAD) | sha1sum
2514fb61430ad5beea4f80e2548f1fbdfd97d74d *-
再来看看 HEAD 文件中对应的 Commit 对象以及其内容是不是与上面的 SHA1 相符:
$ cat .git/HEAD
2514fb61430ad5beea4f80e2548f1fbdfd97d74d
$ git cat-file -p 2514fb6
tree b79d07773ea2d47125f1e7078bbc8113a74a2fa7
parent 61b04b17412e1d9639db2a6b1b4e83319473a14a
author wangJW <1w@163.com> 1705493204 +0800
committer wangJW <1w@163.com> 1705493204 +0800
second commit
从结果可知,说明 Git 内部就是采用头部信息+内容利用 SHA1 算法得到的哈希值。
再来看看 tree 对象
3.1.3 tree 对象的 SHA1 哈希值
直接拿上面 commit 对象中的 tree 对象来做实验,首先查看 tree 对象中的内容和其中的字节数
$ git cat-file -p b79d0777
100644 blob d800886d9c86731ae5c4a62b0b77c437015e00d2 a.txt
040000 tree ceb3bfbba0a2f151a88628549113aa5c1be65bf5 b
//此时就是对应HEAD指针指向的树
$ git cat-file tree HEAD^{tree} | wc -c
61
然后根据头部信息+tree 对象内容信息计算 SHA-1 值:
$ (printf "tree 61\000"; git cat-file tree HEAD^{tree}) | sha1sum
b79d07773ea2d47125f1e7078bbc8113a74a2fa7 *-
发现此时计算出的 SHA-1 值和 commit 对象所指向的值完全相同,再次验证 SHA-1 生成方式。最后再来看看 tag 对象
3.1.4 tag 对象的 SHA1 哈希值
首先创建一个 tag 对象:
$ git tag -m "firstTag" firstTag
//创建成功
$ git tag
firstTag
获取这个 tag 对象的字节数,并执行 SHA1 哈希算法
$ git cat-file tag firstTag | wc -c
136
$ (printf "tag 136\000"; git cat-file tag firstTag) | sha1sum
d0c8f7e57f23b368152094bf3e57e70b3569cb13 *-
从 tag 对象的执行结果说明,SHA1 哈希值生成方式正确。
3.2 Git 对象的存储位置
从前面查看 blob 对象内容时提到过,在 Git 中的对象存储在 Git 仓库的 .git/objects
目录下。
在下列情况中,会触发 Git 存储对象的操作:
git add
:在执行 git add 命令暂存某个文件 时,Git 将会将文件的内容转换为一个 Blob 对象,并将该对象存储在本地对象数据库中。这个操作将文件添加到暂存区(Staging Area),为接下来的提交做准备。git commit
:执行 git commit 命令时,Git 首先会创建一个新的 Commit 对象。这个 Commit 对象包含了提交的元数据信息,如作者、提交时间、提交信息等。同时,Git 会创建一个对应的根目录的 Tree 对象,记录了当前提交时仓库中所有文件的快照。最后,Git 将这个 Commit 对象存储在本地对象数据库中,并将当前分支指向该 Commit 对象,表示当前的工作状态。git tag
:执行创建附注标注命令时,Git 会创建一个 Tag 对象,该对象包含标签的元数据信息,并指向一个特定的 Commit 对象。这个 Tag 对象会被存储在本地对象数据库中,以便后续引用。git merge
:执行 git merge 命令时,Git 会创建一个新的 Commit 对象,该对象包含合并的元数据信息,并引用两个或多个合并的分支的 Commit 对象。Git 会将这个新的 Commit 对象存储在本地对象数据库中,并将当前分支指向该新的 Commit 对象。
四、 总结
本文通过 .git
目录角度解析 Git对象
- Git 对象主要有以下四种类型:Blob存储文件内容,Tree记录文件结构,Commit记录历史,Tag添加标签。
- Git 通过提取对象内容加头信息,使用 SHA-1 算法生成哈希值作为唯一 ID
- Git 对象存储于
.git/objects
目录下,其中对象 ID 值前两位作为目录名,后 38 位作为文件名 - 在在执行暂存(add)、提交(commit)、合并(merge)、打标签(tag)等操作时都会触发 Git 对象的存储
参考资料
《Git 权威指南》
《Git Pro》