Git系列:Git 对象
Git 中有四种主要的数据类型,分别是数据对象、树对象、提交对象及标签对象,在这篇文章中主要介绍前三种。
数据对象
Git 是一个内容寻址文件系统,这意味着,Git 的核心部分是一个简单的键值对数据库(key-value data store)。 你可以向 Git 仓库中插入任意类型的内容,它会返回一个唯一的键,通过该键可以在任意时刻再次取回该内容。
数据对象(blob object),顾名思义就是存放文件内容(数据)的对象,对应文件系统中的文件(FILE),可以通过底层命令 git hash-object 来创建一个数据对象。该命令可将任意数据保存于 .git/objects 目录(即对象数据库),并返回指向该数据对象的唯一的键。
1 | $ echo 'test content' | git hash-object -w --stdin |
此命令输出一个长度为 40 个字符的校验和。 这是一个 SHA-1 哈希值,该哈希值由两部分内容一起做 SHA-1 校验运算得到,它们分别是:1)待存储的数据;2)头部信息(header),最后会进到其生成算法。生成的数据对象位于 .git/objects 目录下。
说明:
- -w 选项会指示该命令不要只返回键,还要将该对象写入数据库中;
- –stdin 选项则指示该命令从标准输入读取内容;若不指定此选项,则须在命令尾部给出待存储文件的路径。
1 | $ find .git/objects -type f |
查看 objects 目录,那么可以在其中找到一个与新内容对应的文件。 一个文件对应一条内容, 该文件以 SHA-1 校验和文件命名。 校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。
一旦你将内容存储在了对象数据库中,那么可以通过 cat-file 命令从 Git 那里取回数据。为 cat-file 指定 -p 选项可指示该命令自动判断内容的类型,并为我们显示大致的内容:
1 | $ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4 |
使用数据对象的写入、读取命令,我们可以对一个文件实现简单的版本控制。首先,创建一个文件并将内容存入数据库:
1 | $ echo 'version 1' > test.txt |
接着,向文件里写入新内容,并再次将其存入数据库:
1 | $ echo 'version 2' > test.txt |
对象数据库记录下了该文件的两个不同版本,当然之前存入的第一条内容也还在:
1 | $ find .git/objects -type f |
现在可以在删掉 test.txt 的本地副本,然后用 Git 从对象数据库中取回它的第一个版本:
1 | $ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt |
或者第二个版本:
1 | $ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt |
最后,也可以使用 git cat-file -t 命令,查看对象的类型:
1 | $ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a |
blob表示为数据对象。数据对象有两个问题:1)一个数据对象对应一个 SHA-1,要记忆它还是比较困难的;2)数据对象仅保存了文件内容,没有保存文件名,接下来的树对象可以解决文件名保存的问题。
树对象
树对象(tree object)对应文件系统中的目录,它能解决文件名保存的问题,也允许将多个文件组织到一起。 Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。 所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了 inodes 或文件内容。 一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。 例如,某项目当前对应的最新树对象可能是这样的:
1 | $ git cat-file -p master^{tree} |
master^{tree} 语法表示 master 分支上最新的提交所指向的树对象。 请注意,lib 子目录(所对应的那条树对象记录)并不是一个数据对象,而是一个指针,其指向的是另一个树对象。
1 | $ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0 |
从概念上讲,Git 内部存储的数据有点像这样:
通常,Git 根据某一时刻暂存区(即 index 区域,下同)所表示的状态创建并记录一个对应的树对象, 如此重复便可依次记录(某个时间段内)一系列的树对象。 因此,为创建一个树对象,首先需要通过暂存一些文件来创建一个暂存区,暂存区存储在 .git/index 文件中。
可以通过底层命令 git update-index 为一个test.txt 文件创建一个暂存区。 利用该命令,可以把 test.txt 文件的首个版本人为地加入一个新的暂存区。 必须为上述命令指定 –add 选项,因为此前该文件并不在暂存区中(甚至都还没来得及创建一个暂存区); 同样必需的还有 –cacheinfo 选项,因为将要添加的文件位于 Git 数据库中,而不是位于当前目录下。 同时,需要指定文件模式、SHA-1 与文件名:
1 | $ git update-index --add --cacheinfo 100644 \ |
本例中,指定的文件模式为 100644,表明这是一个普通文件。 其他选择包括:100755,表示一个可执行文件;120000,表示一个符号链接。 这里的文件模式参考了常见的 UNIX 文件模式,但远没那么灵活——上述三种模式即是 Git 文件(即数据对象)的所有合法模式(当然,还有其他一些模式,但用于目录项和子模块)。
现在,可以通过 git write-tree 命令将暂存区内容写入一个树对象。 此处无需指定 -w 选项——如果某个树对象此前并不存在的话,当调用此命令时, 它会根据当前暂存区状态自动创建一个新的树对象:
1 | $ git write-tree |
用 git cat-file 命令验证一下它确实是一个树对象:
1 | $ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
接着来创建一个新的树对象,它包括 test.txt 文件的第二个版本,以及一个新的文件:
1 | $ echo 'new file' > new.txt |
暂存区现在包含了 test.txt 文件的新版本,和一个新文件:new.txt。 记录下这个目录树(将当前暂存区的状态记录为一个树对象),然后观察它的结构:
1 | $ git write-tree |
另外,可以将已经创建的树对象作为子目录,加载到当前对象中:
1 | $ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
通过调用 git read-tree 命令,可以把树对象读入暂存区。通过指定 –prefix 选项,指定目录的名称。
如果基于这个新的树对象创建一个工作目录,你会发现工作目录的根目录包含两个文件以及一个名为 bak 的子目录,该子目录包含 test.txt 文件。其结构如下:
命令参考:
查看index区文件
1 | git ls-files --stage |
删除index区的文件
1 | git rm --cached file |
提交对象
如果你做完了以上所有操作,那么现在就有了三个树对象,分别代表想要跟踪的不同项目快照。 然而问题依旧:若想重用这些快照,你必须记住所有三个 SHA-1 哈希值。并且,你也完全不知道是谁保存了这些快照,在什么时刻保存的,以及为什么保存这些快照。 而以上这些,正是提交对象(commit object)能为你保存的基本信息。
可以通过调用 git commit-tree 命令创建一个提交对象,为此需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话)。 从之前创建的第一个树对象开始:
1 | $ echo 'first commit' | git commit-tree d8329f |
由于创建时间和作者数据不同,你现在会得到一个不同的散列值。 现在可以通过 git cat-file 命令查看这个新提交对象:
1 | $ git cat-file -p fdf4fc3 |
提交对象的格式很简单:它先指定一个顶层树对象,代表当前项目快照; 然后是可能存在的父提交(前面描述的提交对象并不存在任何父提交); 之后是作者/提交者信息(依据你的 user.name 和 user.email 配置来设定,外加一个时间戳); 留空一行,最后是提交注释。
接着,创建另外两个提交对象,它们分别引用各自的上一个提交(作为其父提交对象):
1 | $ echo 'second commit' | git commit-tree 0155eb -p fdf4fc3 |
这三个提交对象分别指向之前创建的三个树对象快照中的一个。 现在,如果对最后一个提交的 SHA-1 值运行 git log 命令,会出乎意料的发现,你已有一个货真价实的、可由 git log 查看的 Git 提交历史了:
1 | $ git log --stat 1a410e |
没有借助任何上层命令,仅凭几个底层操作便完成了一个 Git 提交历史的创建。 这就是每次我们运行 git add 和 git commit 命令时,Git 所做的工作实质就是将被改写的文件保存为数据对象, 更新暂存区,记录树对象,最后创建一个指明了顶层树对象和父提交的提交对象。 这三种主要的 Git 对象——数据对象、树对象、提交对象——最初均以单独文件的形式保存在 .git/objects 目录下。 下面列出了目前示例目录内的所有对象,辅以各自所保存内容的注释:
1 | $ find .git/objects -type f |
如果跟踪所有的内部指针,将得到一个类似下面的对象关系图:
对象存储
Git 仓库提交的所有对象都会有一个头部信息,并与文件内容一起存储。 下面使用Ruby 脚本语言演示一个数据对象的生成,其内容是字符串“what is up, doc?”。
可以通过 irb 命令启动 Ruby 的交互模式:
1 | $ irb |
Git 首先会以识别出的对象的类型作为开头来构造一个头部信息,本例中是一个“blob”字符串。 接着 Git 会在头部的第一部分添加一个空格,随后是数据内容的字节数,最后是一个空字节(null byte):
1 | >> header = "blob #{content.length}\0" |
Git 会将上述头部信息和原始数据拼接起来,并计算出这条新内容的 SHA-1 校验和。 在 Ruby 中可以这样计算 SHA-1 值——先通过 require 命令导入 SHA-1 digest 库, 然后对目标字符串调用 Digest::SHA1.hexdigest():
1 | >> store = header + content |
我们来比较一下 git hash-object 的输出。 这里使用了 echo -n 以避免在输出中添加换行。
1 | $ echo -n "what is up, doc?" | git hash-object --stdin |
Git 会通过 zlib 压缩这条新内容。在 Ruby 中可以借助 zlib 库做到这一点。 先导入相应的库,然后对目标内容调用 Zlib::Deflate.deflate():
1 | >> require 'zlib' |
最后,需要将这条经由 zlib 压缩的内容写入磁盘上的某个对象。 要先确定待写入对象的路径(SHA-1 值的前两个字符作为子目录名称,后 38 个字符则作为子目录内文件的名称)。 如果该子目录不存在,可以通过 Ruby 中的 FileUtils.mkdir_p() 函数来创建它。 接着,通过 File.open() 打开这个文件。最后,对上一步中得到的文件句柄调用 write() 函数,以向目标文件写入之前那条 zlib 压缩过的内容:
1 | >> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38] |
我们用 git cat-file 查看一下该对象的内容:
1 | $ git cat-file -p bd9dbf5aae1a3862dd1526723246b20206e5fc37 |
就是这样——你已创建了一个有效的 Git 数据对象。
所有的 Git 对象均以这种方式存储,区别仅在于类型标识——另两种对象类型的头部信息以字符串“commit”或“tree”开头,而不是“blob”。 另外,虽然数据对象的内容几乎可以是任何东西,但提交对象和树对象的内容却有各自固定的格式。
参考: