原文:https://dev.to/remdore/i-wrote-git-objects-by-hand-until-real-git-stopped-complaining-6i1(作者 @remdore)
我使用Git大约十五年了,每天工作都在用,但我无法详细说出它在磁盘上存储了什么。我知道这个经典故事的大致轮廓:blob对象、tree对象、提交指针相互引用,因为所有人都知道这个故事。但我不确定的是,自己能否坐下来,在没有Git工具的情况下,手工生成这些字节,并让真正的Git接受它们。
于是我尝试了。不用任何库,不调用git命令,只用Python的hashlib和zlib,直接向.git/objects目录写入文件,看看能走多远,直到真Git指出我的错误。
它比我预想的更早告诉我错了,而具体的报错方式,竟然成了整个过程中最有趣的部分。
Blob 对象几乎什么都没有
第一种对象类型很简单。Blob 就是文件的内容,前面加上一个小小的头部,经过哈希计算、压缩,然后存放到以哈希值前两个字符命名的目录中。
header = f"{kind} {len(payload)}".encode() + b"\x00"
full = header + payload
oid = hashlib.sha1(full).hexdigest()
open(f".git/objects/{oid[:2]}/{oid[2:]}", "wb").write(zlib.compress(full))
这就是全部了。头部由单词 blob、一个空格、十进制长度和一个零字节组成。哈希计算的对象是头部加内容,这个细节常常让人困惑,因为仅对文件内容计算哈希得到的值,在Git中无法被识别。
将 hello world 通过这个函数写入,我得到了哈希值 3b18e512dba79e4c8300dd08aeb37f8e728b8dad。用Git检查,结果完全一致。git cat-file -p 成功打印出我的文件。一个 blob 对象没有名称、没有权限、没有时间戳、没有历史。在任何仓库中,两个内容完全相同的文件就是同一个对象,这就是为什么一个充满重复文件的仓库,其实际占用空间并不像看起来那么大。
树对象:我在这里出错了
树对象(tree)就是目录列表。对于每个条目:八进制模式、一个空格、文件名、一个零字节,然后是二十个原始字节的哈希值。注意,是原始字节,不是十六进制字符串。将所有条目拼接起来,用 tree 头包装,然后用同样的方式写入。
处理条目列表时,显而易见的做法是先按名称排序再写入,我也这么做了。我创建了一个小型仓库,包含 README.md、一个名为 lib.txt 的文件和一个名为 lib 的目录,然后构建树对象,写入提交,并让 refs/heads/master 指向它。
git log 显示了我的提交。git ls-tree 列出了所有三个条目,哈希值都正确。一切看起来都没问题,于是我出于礼貌运行了 git fsck:
error in tree ff04258ee8f650cd761ef58148080860cb2e5f8a: treeNotSorted: not properly sorted
Git 按名称对树条目排序,但它比较目录时,会把目录名当作以斜杠结尾来处理。我的天真排序将 lib 排在 lib.txt 之前,因为字符串比较就是这样做的。Git 要求 lib.txt 在前,因为它是在 lib.txt 和 lib/ 之间做比较。点的ASCII码是0x2E,而斜杠是0x2F。
在一个无人细看的表格中,差了一个字符。而修复只需四行代码:
def key(entry):
mode, name, _ = entry
return (name + "/") if mode == "40000" else name
这样之后,fsck 就安静了,树对象的哈希值也变了,因为字节顺序变了。在内容寻址存储中,顺序不同就是不同的对象。
我反复琢磨的是,git log、git ls-tree 和 git cat-file 都读取了损坏的树对象,却没有发出任何警告。Git 的读取器是宽容的。但它的校验器不是。如果我只检查了仓库看起来是否正常,我就会交付一个存在微妙损坏的对象数据库,并在几周后,在别人的机器上以一种很难追溯到排序规则的方式发现这个问题。
提交对象:一个名字不寻常的文本文件
经历了树对象的波折,提交对象(commit)就显得平淡了。提交对象是纯文本:
tree 595b3bb66abe5068cd37f4c86933c2cce0c28f7a author Kalin <kalin@example.com> 1790000000 +0300 committer Kalin <kalin@example.com> 1790000000 +0300 first commit, written by hand
一个 tree 行,零个或多个 parent 行,一个作者和一个提交者(都带有 Unix 时间戳和数字时区),一个空行,然后是提交信息。这就是承载你所有克隆过项目的格式。
用 parent 行指向第一个提交来写入第二个提交,我就有了历史。git log --graph 画出了它。git diff HEAD~1 HEAD 以普通的统一差异格式显示了对 lib/main.py 的更改,这是根据我手工编写的两个 blob 实时计算的,因为 Git 在这一层根本不存储差异。它存储完整的文件,只在你要求时才计算差异。
然后我用真正的 Git 通过文件系统克隆了这个仓库,得到了一个工作副本,包含所有三个文件和日志中的两个提交。
被遗忘的部分
在克隆成功之前,有一段时间仓库虽然正确,但完全无法使用。
git status 报告所有三个文件都被删除了。git checkout . 拒绝执行,说路径规格与 Git 知道的任何内容都不匹配。两者都是对的。我构建了一个对象数据库和一个引用,它们能很好地描述历史,但它们都不是 status 和 checkout 首先查阅的东西。
那个东西是索引,一个位于 .git/index 的独立二进制文件,它包含暂存区以及工作目录内容的缓存。我的仓库有对象和引用,但完全没有索引。因此,就底层机制而言,HEAD 列出了三个文件,而暂存区一个也没有,这看起来就像有三次删除等待提交。
git reset --hard HEAD 根据我的对象构建了索引和工作树,之后一切就正常了。我得到的教训是,大家画的那张有 blob、tree 和 commit 的图,只描述了仓库的三分之二。索引是第三部分,也是你交互最多的部分,每次输入 git add 时都在用它。
五十次修订之后
写入器工作正常后,我想看看 Git 如何持久化的另一半机制。松散对象(我之前一直在生成的那种)存储每个文件的每个版本的完整内容。虽然压缩了,但仍是完整的,版本之间没有任何共享,这意味着一个文件编辑五十次,在磁盘上就是五十份完整的副本。
于是,我生成了一个大约包含 1200 个函数、54,066 字节的源文件,然后对它进行了五十次修订,每次都随机修改三行,将每个版本都通过我自己的代码写入。这就是 150 个对象:五十个 blob、五十个 tree 和五十个 commit:
loose objects: 150 files, 425,894 bytes on disk
然后我让 Git 对它们进行打包:
packfile: 21,558 bytes
体积缩小了 19 倍,历史记录完全相同,并且 git fsck --strict 依然通过,每个修订版本仍然可以检索。
查看包文件内部,概念就变得具体了。在五十个 blob 中,一个是完整存储的:54,066 字节的内容被存储为 8,296 字节的压缩数据。其他四十九个是作为其他 blob 的增量存储的,增量的中位数大小是 48 字节。
四十八字节来记录一个五十四千字节文件的修订,这正如你所料——记录下来的只是那几行变化的行及其位置。
增量链也是可链式的。Git 构建了深达二十一层的链,因此重建某些修订版本意味着从那个完整的副本开始,按顺序应用二十一个补丁。这就是它的权衡:存储成本低,在读取时消耗一点 CPU 比保留五十份副本更划算。
这也解释了我以前从未联系起来的一件事。一个历史充满小幅文本编辑的仓库可以压缩到几乎不占空间,因为每个修订只有几十字节的增量。而一个有五十个版本的二进制资源的仓库则不行,因为两张不同压缩的图像之间找不到小的增量,你只能保留五十份完整副本。通常不提交大型二进制文件的建议是作为一条规则给出的,而这就是其背后的机制。
我犯的两个错误
第一个是树排序,我早有预料,因为这是那种只有违反了才能学到的规则。
第二个是我自己的度量。为了检查包文件,我运行了 git verify-pack -v 并编写了一个小脚本来总结增量链深度,它愉快地报告了深度为 15,710 和 16,949 的链。我一度相信了它,然后注意到在一个有五十个对象的包里出现深度两万的链是不可能的。
verify-pack 为完整存储的对象打印五列,为作为 delta 存储的对象打印七列。我一直都在读第五列。对于完整对象,那是包文件中的偏移量,是一个大数字,如果不注意的话看起来似乎合理。读取正确的列后,深度在一到二十一之间,就是我上面发布的那个数字。
这和我在自己工作中不断发现的问题是同一个:脚本运行了,产生了数字,但这些数字完全是错误的字段。没有崩溃。没有任何警告。唯一发现问题的是这个数字在物理上不可能,如果那些偏移量恰好很小,我就不会发现了。
如果你想动手试试
这个脚本大约六十行,只依赖 hashlib 和 zlib。建议从数据对象开始,因为你立刻就能用 git hash-object 校验结果,一分钟内就能判断头部是否正确。接着是树对象,你会遇到斜杠规则的坑。最后是提交对象,它实际上是三者中最简单的,尽管听起来应该最难。
每次修改后都请运行 git fsck --strict,而不是在最后才运行。我曾在一个树对象上浪费了不少时间,四个不同的 Git 命令都毫无报错地读取了它,而我之所以最终发现问题,只是因为偶然运行了一个校验工具。git log 看起来正确,并不能证明底层的字节是正确的。
最让我惊讶的一点与格式本身无关,而是与 Git 的效率息息相关。大家通常认为 Git 的高效源于对象模型,但事实并非如此。对象模型几乎是朴素的:整个文件,进行哈希和压缩,每个版本存一份副本,不共享。所有的巧思都出现在后来的打包层——它将我那 425,894 字节的零散对象压缩到了 21,558 字节,而没有改变任何一个哈希值或丢失任何一个字节的内容。我重新实现的部分,从来都不是让 Git 真正高效运转的那部分。
原文:https://dev.to/remdore/i-wrote-git-objects-by-hand-until-real-git-stopped-complaining-6i1(作者 @remdore)



