Git 底层原理:Git 对象
git 实际上是一个内容文件系统,载体是 git 的对象,存储的是一个个的内容版本。git 仓库就像一个书架,书架上放着的是一本本书,对于 git 来讲,这一本本书就是 git 对象,存储的是书的每一个版本的内容。
Git 对象 是 Git 的最小组成单位,git 的所有核心底层命令实际上都是在操作 git 对象。比如 git add 命令,就是把文件快照存储成 blob 对象,git commit 命令,就是把提交的文件列表和提交信息分别存储成 tree 对象和 commit 对象,git checkout -b创建分支命令,就是创建一个指针指向 commit 对象。
本文会从一个空的仓库开始,一步一步由浅入深的展开讲解 git 的内部原理以及底层对象。
0x01 首先初始化工程
1 | # 初始化工程 |
git 初始化时,实际上是在仓库下创建了一个 .git 目录的隐藏目录,以及一些默认的文件:
HEAD:HEAD指针,指向当前的操作分支,具体看 HEAD。config: 存储的本地仓库的配置,具体看 git 的配置说明。description: 用来存储仓库名称以及仓库的描述信息。具体看 ./git/description。hooks/*: git 钩子,git 钩子可以做非常有用的事情,也是构建 git 工作流中不可或缺的部分。具体看 git 钩子。info/exclude: 该文件的功能和 .gitignore 一样,都是配置 git 忽略本地文件。objects/*: git 的底层对象,具体看 git 底层对象。refs/heads和refs/tags: git 引用,实现了git 的分支策略,具体看 git 引用。
实际上还有更多不常用的文件和目录,更详细的细节可以查阅:Git Repository Layout。
0x02 添加一个文件
使用 git add 命令把当前工作区的变更提交到暂存区:
1 | # 添加文件 |
此时查看 .git/ 工作目录:
1 | $ find .git/objects -type f |
可以看到新生成了一个 git 对象,路径为.git/objects/6f/b38b7118b554886e96fa736051f18d63a80c85。
git 对象的文件路径和名称根据文件内容的 sha1 值决定,取 sha1 值的第一个字节的 hex 值为目录,其他字节的 hex 值为名称。这里使用这种方式存储 Git 对象有 2 个好处:
- 对 Git 对象做完整性校验。
- 快速遍历/查找 Git 对象。
为了减少存储大小,git 对象都是使用 zlib 压缩存储的。git 对象的详细说明可以参考这里:git 对象 。git 提供了 cat-file 命令用来格式化查看 git 对象内容:
1 | # 查看 git 对象内容 |
可以看到 6fb38b7(上述 git 对象的 sha1 值简写) 对象类型为 blob 对象,blob 对象存储变更文件的内容快照。
根据 sha1 的散列特性,使用 sha1 的前 7 个字符就基本可以表示该 sha1 值。Github、Gitlab 也一样。
此时查看 .git/ 目录下,会新增一个 index 文件(索引文件):
1 | $ file .git/index |
index 文件存储暂存区的文件列表,index文件代表了 git 的一个重要的概念:暂存区。index 文件的详细说明可以查看 索引文件 。index 文件使用二进制方式存储暂存区信息,通过 git 提供的 ls-file 底层命令可以查看索引文件的格式化输出:
1 | $ git ls-files -t |
有兴趣的同学可以使用
hexdump -C命令查看索引文件的二进制内容。
0x03 提交到本地版本库
使用 git commit 命令可以把暂存区的变动提交到本地版本库中:
1 | $ git commit -m "first commit" |
其中
100644是指的文件模式,100644表明这是一个普通文件。 其他情况比如100755表示可执行文件,120000表示符号链接。
如果你是边阅读本文边动手操作,那你会发现生成的 commit 对象的 sha1 值跟本文不一致,因为提交日期以及用户名邮箱是不一样的,可以点击这里 设置固定的时间日期、用户名和邮箱,这样提交的对象就会是一样的 sha1值,也方便阅读本文。
查看 .git/objects 目录下,会新增 2 个 git 对象:
1 | $ find .git/objects -type f |
分别是 523d41c 和 4120b5f 。
使用 git cat-file 可以看到 2 个 对象的类型和内容:
1 | # 523d41c 是一个 commit 对象 |
也可以使用
git cat-file -p 523d41c^{tree}来查看4120b5f的内容,523d41c^{tree}和4120b5f是等效的,更多请查看 git revisions。
这里新出现了 2 种新的 git 对象类型,分别是 tree 对象(523d41c) 和 commit 对象(4120b5f),tree 对象用来记录目录结构和 blob 对象索引,commit 对象包含着指向前述 tree 对象的指针和所有提交信息。
操作到这里,git 的底层对象一共生成了 3 个,分别是:
6fb38b7: blob 对象。4120b5f: tree 对象,指向6fb38b7。523d41c: commit 对象,指向4120b5f。
他们之间的关系是:
0x04 提交第二个版本
我们继续提交代码和文件:
1 | $ echo "append content" >> file.txt |
该提交为 file.txt 添加了内容,同时新增了子目录:doc/,并新增了 README.md 和 doc/changelog 2个文件。
查看 git 对象列表:
1 | $ find .git/objects -type f | sort |
可以看到除了原先的 6fb38b7、4120b5f、523d41c,又新增了:
10da374: tree 对象,指向README.md(5664e30) 、file.txt(aec2e48)、doc/(39fb0fb)。39fb0fb: tree 对象,指向changelog(45c7a58)。45c7a58: blob 对象, 存储changelog内容快照。5664e30: blob 对象,存储README.md内容快照。a0e96b5: commit 对象,指向10da374、523d41c。aec2e48: blob 对象,存储更改的file.txt内容快照。
查看新增的 2 个 tree 对象:
1 | $ git cat-file -p 10da374 |
这里有必要说明一下,Git 使用 tree 对象来存储目录结构,不同的目录对应不同的 tree 对象,这次提交里面,顶层目录对应的 tree 是 10da374,doc/ 目录对应的 tree 是 39fb0fb。
继续查看 commit 对象 a0e96b5 内容:
1 | $ git cat-file -p a0e96b5 |
仔细的同学会发现,a0e96b5 跟第一次提交生成的 commit 对象(523d41c)相比,多了一个 parent 字段。parent 字段是用来指向上一次提交的,一般是1个 parent ,有些情况下会是多个 parent ,比如 merge 这种情况。
我们再总结一下这些对象之间的关系:
如图所示,每一次提交可以是一个文件,也可以是多个文件和多个目录,一次提交就是一次版本( revision )。
同时这里又引申出来了 git 的一个非常重要的概念,每一次新的提交都会指向上一个提交,这样多个提交就组成了一个提交链。这个提交链使用到了一个非常有名的算法:merkle tree,感兴趣的同学可以去深入了解,这里就不深入讲解了。merkle tree 有一个重要的特性就是单独更改其中一个节点的内容就会破坏掉这个tree,也就是说 merkle tree 的节点是不可更改的。git 就是通过 merkle tree 来保证每个版本都是连续有效的。
这就是为什么很难修改 git 的历史提交记录的原因,如果要修改某一个提交,那同时还需要修改这个提交之后的所有提交,这样才能保证
merkle tree是有效成立的。
另外,区块链也是基于merkle tree来保证数据可靠性的。
可以猜想一下,如果继续提交代码,那 git 对象会是如下的关系:

按照先后时间顺序单独看 commit 对象之间的关系:

这个 commit 对象关系图非常重要,git 分支策略就是围绕着这个关系图来运作的,这里暂且不做展开。
0x05 打标签
上面的操作涉及了 3 种 git 对象,分别是 blob、tree、commit 对象,其实 git 还存在一个 tag 类型的对象,用来存储带注释的标签。
使用如下命令创建标签:
1 | $ git tag "v0.0.2" -m "this is annotated tag" |
此时新增了一个 032ddd9 的对象,同时在 .git/refs/ 中增加了名为 v0.0.2 的标签。使用如下命令查看他们的内容:
1 | # 查看 v0.0.2 的内容 |
.git/refs/tags/v0.0.2 是 Git 的一个重要的概念:引用。这个引用实际上是一个指针,内容为 032ddd9 的 sha1 值,代表指向 032ddd9 。而 032ddd9 是一个 tag 对象,指向第二次提交的 commit 对象:a0e96b5。
tag 对象相对比较独立,不参与构建文件系统,只是单纯的存储信息。
0xFF 总结
到这里其实应该已经对 Git 底层对象有一个深刻的了解了。从根本上来讲,git 底层实际上是由一个个对象(object)组成的,git 底层对象分为4种:
- blob 对象:保存着文件快照,数据结构参考: blob 对象。
- tree 对象:记录着目录结构和 blob 对象索引,其数据结构参考: tree 对象。
- commit 对象:包含着指向前述 tree 对象的指针和所有提交信息,数据结构参考:commit 对象。
- tag 对象:记录带注释的 tag 。
一个仓库里面的所有 Git 对象会组成一个图(Graph),按照指向关系可以简单的这么理解:refs –> tag 对象 –> commit 对象 –> tree 对象 –> blob 对象,对象之间通过对方的 sha1 值来确定指向关系,所以要是篡改了对象的内容,那指向关系就会被破坏掉,git fsck 命令就会提示 "hash mismatch" 。所以这也是 Git 对象的文件存储结构里面并没有自身数据的校验(checksum)字段的原因。
值得一提的是,git 社区正在积极推进 sha256 的方案,sha1 目前来看并不是绝对安全的,因为 HAttered attack 这种攻击方式能够伪造相同 sha1 值。
最后,我们用一张图来总结上述的一系列步骤生成的对象之间的关系:

git 对象的相关命令
git 擅长的一点是提供了很多丰富抽象的子命令来操作这些 git 对象,比如上面的一系列操作:
git add:实际上是把当前工作区的文件快照保存下来,产出是 blob 对象。git commit:保存暂存区的文件层级关系和提交者信息,产出是 tree 对象 和 commit 对象。git tag -m:保存 tag 标签的信息,产出是 tag 对象。
这些是上层命令,实际上 git 还提供了非常丰富的底层命令用来操作对象:
git-hash-object:把输入内容存储成 blob 对象。git-cat-file:读取并格式化输出对象。git-count-objects:计算对象数量。git-write-tree:把存储区的文件结构存储成 tree 对象。git-read-tree:把 tree 对象读取到暂存区。git-commit-tree:根据输入信息(tree、父提交、author、commiter、日期等)存储成 commit 对象。git-ls-tree:读取并格式化输出 tree 对象。git-mktag:把输入内容存储成 tag 对象。git-mktree:根据输入(ls-tree的输出格式)来生成 tree 对象。git-fsck:校验对象链表的正确性和有效性。git-diff-tree:比较 2 个tree 对象 的差异并格式化输出。
设置固定的时间日期、用户名和邮箱
本文中的示例都设置了固定的时间日期、用户名和邮箱,如果你是边阅读本文边动手操作,可以如下执行 git commit 或者 git tag ,这样生成的对象hash值和本文中的是一致的:
1 | # git commit |
或者可以使用 export 设置为全局的环境变量:
1 | export GIT_AUTHOR_DATE="1606913178 +0800" GIT_AUTHOR_NAME="xiaowenxia" GIT_AUTHOR_EMAIL="775117471@qq.com" GIT_COMMITTER_DATE="1606913178 +0800" GIT_COMMITTER_NAME="xiaowenxia" GIT_COMMITTER_EMAIL="775117471@qq.com" |
下载本文创建的 git 仓库
点击下载本文中创建的仓库。
git-draw
这里有一个很有趣的工具:git-draw,这个工具会绘制 git 仓库的所有 Git 对象和引用的关系。下图使用 git-draw 绘制了本文的仓库: