章节 ▾ 第二版

10.2 Git 内部原理 - Git 对象

Git 对象

Git 是一个内容寻址的文件系统。太棒了。这意味着什么?它的意思是 Git 的核心是一个简单的键值数据存储。这意味着您可以将任何类型的内容插入到 Git 存储库中,Git 会返回一个唯一的键,您可以使用该键来检索该内容。

作为演示,让我们看看底层命令 git hash-object,它接收一些数据,将其存储在您的 .git/objects 目录(对象数据库)中,并返回一个唯一的键,该键现在引用该数据对象。

首先,您初始化一个新的 Git 存储库,并验证 objects 目录中(不出所料)没有任何内容

$ git init test
Initialized empty Git repository in /tmp/test/.git/
$ cd test
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
$ find .git/objects -type f

Git 已经初始化了 objects 目录,并在其中创建了 packinfo 子目录,但没有常规文件。现在,让我们使用 git hash-object 创建一个新的数据对象,并手动将其存储在您的新 Git 数据库中

$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

在其最简单的形式中,git hash-object 将获取您传递给它的内容,并仅返回将用于将其存储在 Git 数据库中的唯一键。-w 选项然后告诉命令不要简单地返回键,而是将该对象写入数据库。最后,--stdin 选项告诉 git hash-object 从 stdin 获取要处理的内容;否则,该命令将在命令末尾期望一个包含要使用的内容的 filenames 参数。

上述命令的输出是 40 个字符的校验和哈希。这是 SHA-1 哈希 — 您存储的内容加上标头的校验和,您将在稍后了解。

$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

如果您再次检查您的 objects 目录,您可以看到它现在包含该新内容的文件。这就是 Git 最初存储内容的方式 — 每个内容作为一个单独的文件,文件名是内容及其标头的 SHA-1 校验和。子目录以 SHA-1 的前 2 个字符命名,文件名是剩余的 38 个字符。

一旦您在对象数据库中拥有了内容,您可以使用 git cat-file 命令来检查该内容。此命令有点像用于检查 Git 对象的瑞士军刀。将 -p 传递给 cat-file 指示该命令首先确定内容的类型,然后以适当的方式显示它

$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

现在,您可以将内容添加到 Git 并再次提取出来。您也可以使用文件中的内容来做到这一点。例如,您可以对文件进行一些简单的版本控制。首先,创建一个新文件并将其内容保存到您的数据库中

$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
83baae61804e65cc73a7201a7252750c76066a30

然后,向文件中写入一些新内容,并再次保存。

$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

你的对象数据库现在包含这个新文件的两个版本(以及你存储的第一个内容)。

$ find .git/objects -type f
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

此时,你可以删除本地的test.txt文件,然后使用 Git 从对象数据库中检索你保存的第一个版本

$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1

或第二个版本。

$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
$ cat test.txt
version 2

但是记住文件的每个版本的 SHA-1 键并不实际;而且,你并没有在系统中存储文件名,而只是存储了内容。这种对象类型称为blob。你可以让 Git 告诉你 Git 中任何对象的对象类型,给定其 SHA-1 键,使用 git cat-file -t

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob

树对象

我们将要研究的下一个 Git 对象类型是,它解决了存储文件名的问题,并且允许你将一组文件一起存储。Git 以类似于 UNIX 文件系统的方式存储内容,但稍微简化了一些。所有内容都存储为树和 blob 对象,树对应于 UNIX 目录条目,blob 对应于 inode 或文件内容。单个树对象包含一个或多个条目,每个条目都是 blob 或子树的 SHA-1 哈希值,以及其关联的模式、类型和文件名。例如,假设你有一个项目,其中最新的树看起来像这样:

$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859      README
100644 blob 8f94139338f9404f26296befa88755fc2598c289      Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0      lib

master^{tree} 语法指定了 master 分支上最后一次提交所指向的树对象。请注意,lib 子目录不是 blob,而是指向另一棵树的指针。

$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b      simplegit.rb
注意

根据你使用的 shell,使用 master^{tree} 语法时可能会遇到错误。

在 Windows 上的 CMD 中,^ 字符用于转义,因此你必须将其加倍以避免这种情况:git cat-file -p master^^{tree}。使用 PowerShell 时,使用 {} 字符的参数必须用引号引起来,以避免参数被错误解析:git cat-file -p 'master^{tree}'

如果你正在使用 ZSH,则 ^ 字符用于全局匹配,因此你必须将整个表达式括在引号中:git cat-file -p "master^{tree}"

从概念上讲,Git 正在存储的数据看起来像这样:

Simple version of the Git data model
图 173. Git 数据模型的简单版本

你可以很容易地创建自己的树。Git 通常通过获取暂存区或索引的状态并从中写入一系列树对象来创建树。因此,要创建一个树对象,你首先必须通过暂存一些文件来设置一个索引。要创建一个包含单个条目的索引 — test.txt 文件的第一个版本 — 你可以使用底层命令 git update-index。你可以使用此命令将 test.txt 文件的早期版本人为地添加到新的暂存区。你必须传递 --add 选项,因为该文件尚未存在于你的暂存区中(你甚至还没有设置暂存区),并且传递 --cacheinfo 选项,因为你要添加的文件不在你的目录中,而是在你的数据库中。然后,你指定模式、SHA-1 和文件名:

$ git update-index --add --cacheinfo 100644 \
  83baae61804e65cc73a7201a7252750c76066a30 test.txt

在本例中,你指定了模式 100644,这意味着它是一个普通文件。其他选项是 100755,这意味着它是一个可执行文件;以及 120000,它指定了一个符号链接。该模式取自正常的 UNIX 模式,但灵活性要差得多 — 这三种模式是 Git 中文件(blob)唯一有效的模式(尽管其他模式用于目录和子模块)。

现在,你可以使用 git write-tree 将暂存区写入树对象。不需要 -w 选项 — 如果该树尚不存在,调用此命令会自动从索引的状态创建一个树对象。

$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30      test.txt

你也可以使用之前看到的相同的 git cat-file 命令来验证这是一个树对象。

$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree

现在,你将使用 test.txt 的第二个版本和一个新文件创建一个新树。

$ echo 'new file' > new.txt
$ git update-index --cacheinfo 100644 \
  1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
$ git update-index --add new.txt

你的暂存区现在有 test.txt 的新版本以及新文件 new.txt。写出那棵树(将暂存区或索引的状态记录到一个树对象),看看它是什么样子:

$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341
$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

请注意,这棵树既有文件条目,并且 test.txt SHA-1 是之前“版本 2”的 SHA-1(1f7a7a)。为了好玩,你将添加第一棵树作为此树中的一个子目录。你可以通过调用 git read-tree 将树读入你的暂存区。在这种情况下,你可以使用此命令的 --prefix 选项将现有树作为子树读入你的暂存区。

$ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
3c4e9cd789d88d8d89c1073707c3585e41b0e614
$ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579      bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

如果你从你刚刚写入的新树中创建一个工作目录,你将在工作目录的顶层获得两个文件,以及一个名为 bak 的子目录,其中包含 test.txt 文件的第一个版本。你可以将 Git 包含的这些结构的数据视为如下所示:

The content structure of your current Git data
图 174. 你当前 Git 数据的内容结构

提交对象

如果你已经完成了以上所有操作,那么你现在有三个树,它们代表了你要跟踪的项目的不同快照,但之前的问题仍然存在:你必须记住所有三个 SHA-1 值才能回忆起这些快照。你也没有任何关于谁保存了快照、何时保存的快照以及为什么保存快照的信息。这是提交对象为你存储的基本信息。

要创建一个提交对象,你可以调用 commit-tree 并指定一个树 SHA-1,以及直接在其之前出现的提交对象(如果有)。从你写入的第一棵树开始:

$ echo 'First commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d
注意

由于创建时间和作者数据的不同,你将获得一个不同的哈希值。此外,虽然原则上任何提交对象都可以根据该数据精确地再现,但本书的构建历史细节意味着打印的提交哈希可能与给定的提交不对应。在本章中进一步将提交和标签哈希替换为你自己的校验和。

现在你可以使用 git cat-file 查看你的新提交对象:

$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700

First commit

提交对象的格式很简单:它指定了项目在该点的快照的顶层树;父提交(如果有)(上面描述的提交对象没有任何父提交);作者/提交者信息(它使用你的 user.nameuser.email 配置设置和一个时间戳);一个空行,然后是提交消息。

接下来,你将写入其他两个提交对象,每个提交对象都引用直接在其之前的提交:

$ echo 'Second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d
$ echo 'Third commit'  | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9

这三个提交对象中的每一个都指向你创建的三个快照树之一。奇怪的是,你现在拥有一个真正的 Git 历史,你可以使用 git log 命令查看,如果你在最后一个提交 SHA-1 上运行它:

$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

	Third commit

 bak/test.txt | 1 +
 1 file changed, 1 insertion(+)

commit cac0cab538b970a37ea1e769cbbde608743bc96d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:14:29 2009 -0700

	Second commit

 new.txt  | 1 +
 test.txt | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:09:34 2009 -0700

    First commit

 test.txt | 1 +
 1 file changed, 1 insertion(+)

太棒了。你刚刚完成了底层操作来构建一个 Git 历史,而没有使用任何前端命令。这基本上就是当你运行 git addgit commit 命令时 Git 所做的事情 — 它存储已更改文件的 blob,更新索引,写出树,并写出引用顶层树和直接在其之前的提交的提交对象。这三个主要的 Git 对象 — blob、树和提交 — 最初存储为 .git/objects 目录中的单独文件。这是示例目录中现在的所有对象,并附有它们存储的内容的注释:

$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1

如果你遵循所有内部指针,你会得到一个类似这样的对象图:

All the reachable objects in your Git directory
图 175. 你的 Git 目录中所有可访问的对象

对象存储

我们之前提到,每个你提交到 Git 对象数据库的对象都有一个头部存储。让我们花一点时间看看 Git 如何存储它的对象。你将看到如何以交互方式在 Ruby 脚本语言中存储一个 blob 对象 — 在本例中,字符串 “what is up, doc?”。

你可以使用 irb 命令启动交互式 Ruby 模式:

$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"

Git 首先构造一个头部,该头部首先标识对象的类型 — 在本例中,是一个 blob。对于头部的第一部分,Git 添加一个空格,后跟内容的字节大小,并添加一个最终的空字节:

>> header = "blob #{content.bytesize}\0"
=> "blob 16\u0000"

Git 连接头部和原始内容,然后计算新内容的 SHA-1 校验和。你可以通过使用 require 命令包含 SHA1 摘要库,然后使用字符串调用 Digest::SHA1.hexdigest() 来计算 Ruby 中字符串的 SHA-1 值:

>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"

让我们将其与 git hash-object 的输出进行比较。在这里,我们使用 echo -n 来防止向输入添加换行符。

$ echo -n "what is up, doc?" | git hash-object --stdin
bd9dbf5aae1a3862dd1526723246b20206e5fc37

Git 使用 zlib 压缩新内容,你可以在 Ruby 中使用 zlib 库来完成。首先,你需要 require 该库,然后在内容上运行 Zlib::Deflate.deflate()

>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"

最后,你将把你的 zlib 压缩的内容写入磁盘上的一个对象。你将确定你要写入的对象的路径(SHA-1 值的前两个字符作为子目录名称,最后 38 个字符作为该目录中的文件名)。在 Ruby 中,你可以使用 FileUtils.mkdir_p() 函数来创建子目录(如果它不存在)。然后,使用 File.open() 打开文件,并使用结果文件句柄上的 write() 调用将先前 zlib 压缩的内容写入该文件:

>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32

让我们使用 git cat-file 检查对象的内容:

---
$ git cat-file -p bd9dbf5aae1a3862dd1526723246b20206e5fc37
what is up, doc?
---

就是这样 – 你已经创建了一个有效的 Git blob 对象。

所有 Git 对象都以相同的方式存储,只是类型不同 – 头部不会以字符串 blob 开头,而是以 commit 或 tree 开头。此外,虽然 blob 内容几乎可以是任何东西,但 commit 和 tree 内容的格式非常具体。

scroll-to-top