Rust实现一个Git
项目来源 CodeCraft
Git 数据结构
首先来了解一下 Git 内部的数据结构
Git 的核心是一个内容寻址文件系统(Content-Addressable Filesystem)。简单来说,它是一个简单的键值对数据库:你可以向 Git 仓库中插入任何类型的内容,它会返回一个唯一的键(SHA-1 哈希值),通过这个键,你可以在任何时候取回该内容。
在 Git 内部,所有的操作都围绕着三种主要的对象展开:数据对象、树对象和提交对象。
数据对象:是最简单的对象类型,它只存储文件内容,而不存储文件名、权限等元数据。
- 当执行 git add 时,Git
会计算文件的哈希值,并将内容压缩后存入 .git/objects 目录。
- 如果有两个文件名不同但内容完全一样的文件,Git
在内部只会存储一份数据对象。
树对象:树对象类似于 Unix 系统中的目录,它记录了文件索引和目录结构。 - 一个树对象包含多条记录,每条记录包含:文件模式(权限)、对象类型(blob 或 tree)、对象的 SHA-1 值以及文件名。 - 通过嵌套树对象,Git 能够表示整个项目的目录层级。
提交对象:提交对象指向一个顶层树对象(代表该时刻的项目快照)。 - 它包含作者(Author)和提交者(Committer)的信息、时间戳以及提交说明。 - 最重要的是,它包含指向父提交对象的指针,这构成了 Git 的版本历史链条。
用一组实验来观察 git 是怎么管理这些对象的。在一个空目录中创建 git
目录: git init test-git
cd test-git
# 查看 objects 目录,现在它是空的
find .git/objects -type f
数据对象
Git 是内容寻址的。直接把一段字符串存入 Git
数据库。可以通过底层命令 git hash-object,该命令可将任意数据保存于 .git/objects 目录(即 对象数据库),并返回指向该数据对象的唯一的键。
|
-w:告诉 Git 不仅计算哈希,还要将对象写入数据库。--stdin:从标准输入读取内容。
此命令输出一个长度为 40 个字符的校验和。 这是一个 SHA-1 哈希值,一个将待存储的数据外加一个头部信息(header)一起做 SHA-1 校验运算而得的校验和。后文会简要讨论该头部信息。 现在可以查看 Git 是如何存储数据的:
|
一个文件对应一条内容, 以该内容加上特定头部信息一起的 SHA-1 校验和为文件命名。 校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。
一旦将内容存储在了对象数据库中,那么可以通过 cat-file 命令从
Git
那里取回数据。 cat-file 指定 -p 选项可指示该命令自动判断内容的类型,并显示大致的内容:
|
对一个文件进行简单的版本控制。 创建一个新文件并将其内容存入数据库:
|
向文件里写入新内容,并再次将其存入数据库:
|
对象数据库记录下了该文件的两个不同版本:
|
从对象数据库中取回它的第一个版本: $ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1
记住文件的每一个版本所对应的 SHA-1
值并不现实;另一个问题是,文件名并没有被保存,仅保存了文件的内容。上述类型的对象称之为 数据对象(blob
object)。利用 git cat-file -t 命令,可以让 Git
告诉我们其内部存储的任何对象类型,只要给定该对象的 SHA-1 值:
|
树对象
树对象(tree object),它能解决文件名保存的问题,也允许我们将多个文件组织到一起。 Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。 所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了 inodes 或文件内容。 一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。
git 的树对象数据结构类似于:

通常,Git
根据某一时刻暂存区所表示的状态创建并记录一个对应的树对象,如此重复便可依次记录(某个时间段内)一系列的树对象。因此,为创建一个树对象,首先需要通过暂存一些文件来创建一个暂存区。可以通过底层命令 git update-index 为一个单独文件创建一个暂存区。
|
--add:将文件添加到暂存区中;--cacheinfo:将文件添加到 git 数据库中;
指定的文件模式为 100644,表明这是一个普通文件。
其他选择包括:100755,表示一个可执行文件;120000,表示一个符号链接。
这里的文件模式参考了常见的 UNIX 文件模式。
通过 git write-tree 命令将暂存区内容写入一个树对象。此处无需指定 -w 选项——如果某个树对象此前并不存在的话,当调用此命令时,
它会根据当前暂存区状态自动创建一个新的树对象:
|
创建一个新的文件:
|
查看当前的树对象:
|
新的树对象包含两条文件记录。可以将第一个树对象加入第二个树对象,使其成为新的树对象的一个子目录。通过调用 git read-tree 命令,可以把树对象读入暂存区。对该命令指定 --prefix 选项,将一个已有的树对象作为子树读入暂存区:
|
提交对象
现在有了三个树对象,分别代表我们想要跟踪的不同项目快照。若想重用这些快照,必须记住所有三个 SHA-1 哈希值。并且,完全不知道是谁保存了这些快照,在什么时刻保存的,以及为什么保存这些快照。而以上这些,正是提交对象(commit object)能保存的基本信息。
可以通过调用 commit-tree 命令创建一个提交对象,为此需要指定一个树对象的
SHA-1
值,以及该提交的父提交对象(如果有的话)。从之前创建的第一个树对象开始:
|
由于创建时间和作者数据不同,会得到一个不同的散列值。现在可以通过 git cat-file 命令查看这个新提交对象:
|
提交对象的格式很简单:它先指定一个顶层树对象,代表当前项目快照;
然后是可能存在的父提交(前面描述的提交对象并不存在任何父提交);之后是作者/提交者信息(依据user.name 和 user.email 配置来设定,外加一个时间戳);留空一行,最后是提交注释。
创建另两个提交对象,它们分别引用各自的上一个提交:
|
对最后一个提交的 SHA-1
值运行 git log 命令,能够看见一个完整的提交历史:
|
现在来解释一下在实现 init 子命令的时候创建的文件夹: -
/objects:存储所有的内容,无论是文件内容、目录结构还是提交信息,全都压缩后存储在这里;
- refs/ - heads/:存放本地分支,比如
refs/heads/master,文件里存一个 40
位的哈希值,指向该分支最新的那个提交对象。 -
tags/:存放标签。 -
remotes/:存放远程分支的指针。 -
HEAD:当前位置,记录正在哪一个引用上。
git init 命令
这一 CodeCraft 的第一个练习,比较简单,使用 clap 库来实现:
|
最终的形式:
|
cat-file
在上一节的内容中一直使用到 cat-file
子命令,这个命令用于查看 git
数据对象的内容。在之前的使用当中,cat-file 子命令用到了
-p 参数,以及一个对象哈希值。
每个 Git Blob 都会作为独立文件存储在 .git/objects 目录中。该文件包含一个头部以及 Blob 对象的内容,内容使用 Zlib 进行了压缩。
Blob 对象文件的格式如下(Zlib 解压后):
|
<size>内容的大小(以字节为单位)\0空字节<content>文本内容
例如一个 hello world 的 blob object:
|
这一节的具体代码 cat-file 实现