Skip to content

4. 读取和写入对象:hash-object 和 cat-file

4.1. 什么是对象?

既然仓库已经搭建完成,接下来我们可以向里面添加内容。当然,仅仅搭建仓库显得太过简单,实现 Git 的过程绝不应仅限于编写一堆 mkdir 命令。接下来,我们来深入探讨 Git 的核心概念——对象(object),并尝试实现 git hash-objectgit cat-file 命令。

也许你对这两个命令并不熟悉——它们并不是日常 Git 工具箱的一部分,实际上它们是相当底层的(在 Git 行话中称为“管道(plumbing)”)。它们的功能其实非常简单:hash-object 将一个现有文件转换为 Git 对象,而 cat-file 则将一个现有的 Git 对象打印到标准输出。

那么,**Git 对象到底是什么?**从本质上讲,Git 是一个“基于内容寻址的文件系统”。这意味着,与普通文件系统不同,普通文件系统中,文件的名称是任意的,与文件内容无关,而 Git 存储的文件名称是根据其内容数学推导而来的。这有一个非常重要的含义:如果某个文本文件的单个字节发生变化,它的内部名称也会随之改变。简单来说:你在 Git 中并不是修改文件,而是在另一个位置创建新文件。对象就是这样:在 Git 仓库中的文件,其路径由其内容决定

警告

Git 其实并不是真正的键值存储

一些文档,包括优秀的 Pro Git,将 Git 称为“键值存储”。这并不错误,但可能会误导人。普通的文件系统实际上比 Git 更接近于键值存储。由于 Git 由数据计算键,因此可以更准确地称其为值值存储

Git 使用对象来存储很多东西:首先也是最重要的,就是它在版本控制中保存的实际文件——例如源代码。提交(commit)也是对象,标签(tag)也是。除了少数显著的例外(稍后会学到!),几乎所有东西在 Git 中都以对象的形式存储。

Git 存储对象路径的方式,是通过计算其内容的 SHA-1 哈希值 来确定的。具体而言,Git 将该哈希值表示为小写的十六进制字符串,并将其分为两部分:前两位字符和其余部分。前两位字符用作目录名,其余部分则作为文件名(这是因为大多数文件系统在单个目录中存放过多文件时会影响性能。通过这种方法,Git 创建了 256 个可能的中间目录,从而将每个目录中的平均文件数量减少至 1/256)。

备注

什么是哈希函数?

SHA-1 被称为“哈希函数”。简单来说,哈希函数是一种单向数学函数:计算一个值的哈希值很简单,但无法反向计算出哪个值生成了该哈希。

一个非常简单的哈希函数例子便是经典的 len(或 strlen)函数,它仅返回字符串的长度。计算字符长度非常容易,而且只要字符串本身不改变,其长度就保持不变。然而,仅凭一个字符串的长度是无法还原出其原始内容的。而密码学哈希函数则是这种函数的复杂版本,其额外特性在于:在已知某个哈希值的情况下,寻找出能够生成该哈希值的输入几乎是不可行的。(例如,要构造一个输入 i 使得 strlen(i) == 12,只需输入12个随机字符;而对于如 SHA-1 这样的算法,则所需的时间长到几乎无法企及的程度[1]。)

在我们开始实现对象存储系统之前,必须了解它们的确切存储格式。一个对象以一个头部开始,头部指定其类型:blobcommittagtree(稍后会详细介绍)。这个头部后面跟着一个 ASCII 空格(0x20),然后是以 ASCII 数字表示的对象大小(以字节为单位),接着是一个空字节(0x00),最后是对象的内容。在 Wyag 的仓库中,一个提交对象的前 48 个字节如下所示:

txt
00000000  63 6f 6d 6d 69 74 20 31  30 38 36 00 74 72 65 65  |commit 1086.tree|
00000010  20 32 39 66 66 31 36 63  39 63 31 34 65 32 36 35  | 29ff16c9c14e265|
00000020  32 62 32 32 66 38 62 37  38 62 62 30 38 61 35 61  |2b22f8b78bb08a5a|

在第一行中,我们看到类型头部、一个空格(0x20)、以 ASCII 表示的大小(1086)和空分隔符 0x00。第一行的最后四个字节是该对象内容的开头,单词“tree”——当我们讨论提交时会进一步探讨这个。

对象(头部和内容)是使用 zlib 压缩存储的。

4.2. 通用对象

对象可以拥有多种类型,但它们都共享相同的存储与检索机制以及统一的通用头格式。在深入探讨各个对象类型的细节之前,我们需要先抽象出它们的共同特性。最简单的做法是创建一个通用的 GitObject 类,该类定义了两个抽象方法:serialize()deserialize(),同时提供一个默认的 init() 方法,用于在需要时构造一个全新的空对象。(抱歉,Python 爱好者,这样的设计可能略显不够优雅,但相比超级构造函数而言,它更易于阅读。)在我们的 __init__ 方法中,要么从提供的数据中加载对象,要么调用子类中实现的 init() 方法来创建新的空对象。

接下来,我们将对子类进行扩展,为不同的对象格式实现这些方法的具体功能。

python
class GitObject (object):

    def __init__(self, data=None):
        if data is not None:
            self.deserialize(data)
        else:
            self.init()

    def serialize(self, repo):
        """该函数必须由子类实现。

它需要从 self.data(一个字节字符串)中读取对象的内容,并执行必要的转换以生成有意义的表示。具体的转换方式由各个子类自行定义。"""
        raise Exception("未实现!")

    def deserialize(self, data):
        raise Exception("未实现!")

    def init(self):
        pass  # 什么也不做。这是一个合理的默认值!

4.3. 读取对象

要读取一个对象,我们需要知道它的 SHA-1 哈希值。然后,我们根据这个哈希计算它的路径(使用上面解释的公式:前两个字符,然后是目录分隔符 /,然后是剩余部分),并在 gitdir 的“objects”目录中查找它。也就是说,e673d1b7eaa0aa01b5bc2442d570a765bdaae751 的路径是 .git/objects/e6/73d1b7eaa0aa01b5bc2442d570a765bdaae751

接下来,我们将该文件作为二进制文件读取,并使用 zlib 进行解压缩。

从解压缩的数据中,我们提取两个头部组件:对象类型和大小。根据类型,我们确定实际使用的类。我们将大小转换为 Python 整数,并检查其是否匹配。

完成所有操作后,我们只需调用该对象格式的正确构造函数。

python
def object_read(repo, sha):
    """从 Git 仓库 repo 读取对象 sha。返回 GitObject 实例,其确切类型取决于对象。"""

    path = repo_file(repo, "objects", sha[0:2], sha[2:])

    if not os.path.isfile(path):
        return None

    with open(path, "rb") as f:
        raw = zlib.decompress(f.read())

        # 读取对象类型
        x = raw.find(b' ')
        fmt = raw[0:x]

        # 读取并验证对象大小
        y = raw.find(b'\x00', x)
        size = int(raw[x:y].decode("ascii"))
        if size != len(raw) - y - 1:
            raise Exception("对象格式错误 {0}:长度错误".format(sha))

        # 选择构造函数
        match fmt:
            case b'commit': c = GitCommit
            case b'tree': c = GitTree
            case b'tag': c = GitTag
            case b'blob': c = GitBlob
            case _:
                raise Exception("对象 {1} 的类型 {0} 未知".format(fmt.decode("ascii"), sha))

        # 调用构造函数并返回对象
        return c(raw[y + 1:])

4.4. 写入对象

写入对象实际上是读取的反向过程:我们计算哈希值,插入头部,使用 zlib 进行压缩,然后将结果写入正确的位置。这实际上不需要太多解释,只需注意哈希是在添加头部之后计算的(因此它是对象本身的哈希值,而不是仅仅是其内容)。

python
def object_write(obj, repo=None):
    # 序列化对象数据
    data = obj.serialize()
    # 添加头部
    result = obj.fmt + b' ' + str(len(data)).encode() + b'\x00' + data
    # 计算哈希
    sha = hashlib.sha1(result).hexdigest()

    if repo:
        # 计算路径
        path = repo_file(repo, "objects", sha[0:2], sha[2:], mkdir=True)

        if not os.path.exists(path):
            with open(path, 'wb') as f:
                # 压缩并写入
                f.write(zlib.compress(result))
    return sha

4.5. 处理 Blob

我们之前提到过,类型头可以是四种之一:blobcommittagtree——因此 Git 有四种对象类型。

Blob 是这四种类型中最简单的一种,因为它们没有实际的格式。Blob 是用户数据:你放入 Git 中的每个文件的内容(如 main.clogo.pngREADME.md)都作为 Blob 存储。这使得它们易于操作,因为它们除了基本的对象存储机制外没有实际的语法或约束:它们只是未指定的数据。因此,GitBlob 类很容易创建,serializedeserialize 函数只需存储和返回未修改的输入即可。

python
class GitBlob(GitObject):
    fmt = b'blob'

    def serialize(self):
        return self.blobdata

    def deserialize(self, data):
        self.blobdata = data

4.6. cat-file 命令

现在我们可以创建 wyag cat-file 了。git cat-file 只是将对象的原始内容打印到标准输出(stdout),内容被解压缩并去掉 Git 头部。在 wyag 的源代码库 的克隆中,执行 git cat-file blob e0695f14a412c29e252c998c81de1dde59658e4a 将显示 README 的版本。

我们的简化版本只需接受两个位置参数:类型和对象标识符:

txt
wyag cat-file TYPE OBJECT

子解析器非常简单:

python
argsp = argsubparsers.add_parser("cat-file",
                                 help="显示 Git 对象的内容。")

argsp.add_argument("type",
                   metavar="type",
                   choices=["blob", "commit", "tag", "tree"],
                   help="指定类型")

argsp.add_argument("object",
                   metavar="object",
                   help="要显示的对象")

我们可以实现函数,调用之前编写的现有代码:

python
def cmd_cat_file(args):
    repo = repo_find()
    cat_file(repo, args.object, fmt=args.type.encode())

def cat_file(repo, obj, fmt=None):
    obj = object_read(repo, object_find(repo, obj, fmt=fmt))
    sys.stdout.buffer.write(obj.serialize())

这个函数调用了一个我们尚未介绍的 object_find 函数。现在,它只是不加修改地返回其参数中的一个值,如下所示:

python
def object_find(repo, name, fmt=None, follow=True):
    return name

这个奇怪的小函数存在的原因在于 Git 有很多方式来引用对象:完整哈希、短哈希、标签……object_find() 将是我们的名称解析函数。我们只会在稍后实现它,所以这只是一个临时占位符。这意味着在我们实现真实功能之前,我们只能通过对象的完整哈希引用它。

4.7. hash-object 命令

不过,我们确实想在我们的仓库中放入自己的数据。hash-object 基本上是 cat-file 的反向操作:它读取一个文件,计算其哈希作为一个对象,若传递了 -w 标志,则将其存储在仓库中,否则仅打印其哈希。

wyag hash-object 的语法是 git hash-object 的简化版本:

txt
wyag hash-object [-w] [-t TYPE] FILE

对应的解析如下:

python
argsp = argsubparsers.add_parser(
    "hash-object",
    help="计算对象 ID,并可选择用文件创建一个 blob")

argsp.add_argument("-t",
                   metavar="type",
                   dest="type",
                   choices=["blob", "commit", "tag", "tree"],
                   default="blob",
                   help="指定类型")

argsp.add_argument("-w",
                   dest="write",
                   action="store_true",
                   help="实际将对象写入数据库")

argsp.add_argument("path",
                   help="从 <file> 读取对象")

实际的实现非常简单。和往常一样,我们创建一个小的桥接函数:

python
def cmd_hash_object(args):
    if args.write:
        repo = repo_find()
    else:
        repo = None

    with open(args.path, "rb") as fd:
        sha = object_hash(fd, args.type.encode(), repo)
        print(sha)

实际的实现也很简单。repo 参数是可选的,如果为 None,对象将不会被写入(这在上面的 object_write() 中处理):

python
def object_hash(fd, fmt, repo=None):
    """ 哈希对象,如果提供了 repo,则将其写入。"""
    data = fd.read()

    # 根据 fmt 参数选择构造函数
    match fmt:
        case b'commit' : obj=GitCommit(data)
        case b'tree'   : obj=GitTree(data)
        case b'tag'    : obj=GitTag(data)
        case b'blob'   : obj=GitBlob(data)
        case _: raise Exception("未知类型 %s!" % fmt)

    return object_write(obj, repo)

4.8. 旁白:那么,包文件呢?

我们刚刚实现的被称为“松散对象”。Git 还有一种第二种对象存储机制,叫做包文件(packfiles)。包文件比松散对象更高效,但也复杂得多。简单来说,包文件是松散对象的编译(就像 tar),但其中一些以增量的形式存储(作为另一个对象的变换)。包文件复杂得多,无法被 wyag 支持。

包文件存储在 .git/objects/pack/ 中,扩展名为 .pack,并伴随一个同名的索引文件,扩展名为 .idx。如果你想将包文件转换为松散对象格式(例如,在现有仓库上使用 wyag),以下是解决方案。

首先,将包文件移动到 gitdir 之外(仅复制是无效的)。

shell
mv .git/objects/pack/pack-d9ef004d4ca729287f12aaaacf36fee39baa7c9d.pack .

你可以忽略 .idx 文件。然后,从工作树中,只需 cat 它并将结果管道传递给 git unpack-objects

shell
cat pack-d9ef004d4ca729287f12aaaacf36fee39baa7c9d.pack | git unpack-objects

  1. 你可能知道 SHA-1 中已发现碰撞。实际上,Git 现在不再使用 SHA-1:它使用一种加强版,该版本不是 SHA,但对每个已知输入应用相同的哈希,除了已知存在碰撞的两个 PDF 文件。 ↩︎