用 Python 创建并存储 Git 的 Blob 对象

2023-01-11 Git

如果你了解过一些 Git 底层原理,就会知道 Git 会在仓库的 .git/objects 目录中存储各种对象(Blob、Tree 和 Commit)。但 Git 是如何存储这些对象的呢?

本文将尝试用 Python 完成 Blob 对象的创建和保存,由此我们就可以知道 Git 是怎么存储 Blob 对象的。

一、先用 Git 创建一个最简单的 Blob

我们先创建一个最简单的仓库,里面存储一个文件,看看 Git 会在 .git/objects 中创建什么东西吧!

创建一个 hello 仓库,里面新建一个 hello.txt,并将其添加到暂存区:

$ mkdir hello
$ cd hello
$ git init
Initialized empty Git repository in /Users/guoshuai/Documents/repos/hello/.git/

$ find .git/objects -type f
此时还没有存储任何对象

$ echo 'Hello world!' > hello.txt
$ git add hello.txt
$ find .git/objects -type f
.git/objects/cd/0875583aabe89ee197ea133980a9085d08e497

可以看到,此时 Git 在 .git/objects 目录中存储了一个文件,这个就是 hello.txt 文件内容所对应的 Blob 对象的文件形式了。

file 命令查看其文件类型,发现是 zlib 压缩后的文件:

$ file .git/objects/cd/0875583aabe89ee197ea133980a9085d08e497
.git/objects/cd/0875583aabe89ee197ea133980a9085d08e497: zlib compressed data

我们计算一下它的 MD5,得到:

$ md5sum .git/objects/cd/0875583aabe89ee197ea133980a9085d08e497
b2ba11b81d81fd634f33befa5b166a6a  .git/objects/cd/0875583aabe89ee197ea133980a9085d08e497

记住这个 MD5,因为接下来我们将使用 Python 手动创建一个这样的对象,存储到文件中,看看能不能得到和 Git 一样的结果

二、使用 Python 手动创建对象

首先,还是创建一个空的仓库:

$ mkdir my-hello
$ cd my-hello
$ git init
Initialized empty Git repository in /Users/guoshuai/Documents/repos/my-hello/.git/
$ find .git/objects -type f
此时对象目录为空

启动一个 Python 交互式命令行,依次执行:

  1. 创建文件内容

    注意:echo 命令会自动添加一个 newline 字符,所以我们在这里也加上,让两者内容相同:

    >>> content = 'Hello world!\n'
    
  2. 创建文件头,Blob 对象的文件头格式为:

    对象类型(blob)+ 1 个空格 + 内容字节数 + 1 个空字节

    >>> header = f'blob {len(content)}\0'
    >>> header
    'blob 13\x00'
    
  3. 将文件头和文件内容拼接,得到真正要存储的内容:

    >>> store = header + content
    >>> store
    'blob 13\x00Hello world!\n'
    

    并对字符串 store 进行编码,得到字节串 store_bytes

    >>> store_bytes = store.encode('utf-8')
    >>> store_bytes
    b'blob 13\x00Hello world!\n'
    
  4. 引入 Python 哈希库,使用 SHA1 计算文件内容字节串的哈希值字符串:

    >>> import hashlib
    >>> sha1 = hashlib.sha1()
    >>> sha1.update(store_bytes)
    >>> hash_str = sha1.hexdigest()
    >>> hash_str
    'cd0875583aabe89ee197ea133980a9085d08e497'
    

    这个哈希值字符串将作为对象的 key(对象的存储路径),即对象最终会被存储到

    .git/objects/cd/0875583aabe89ee197ea133980a9085d08e497

    >>> obj_file_dir = '.git/objects/' + hash_str[:2]
    >>> obj_file_dir
    '.git/objects/cd'
    >>> obj_file_path = obj_file_path = obj_file_dir + '/' + hash_str[2:]
    >>> obj_file_path
    '.git/objects/cd/0875583aabe89ee197ea133980a9085d08e497'
    
  5. 引入 zlib,对 store_bytes 进行压缩,我们最终要存储的是压缩后的内容

    >>> import zlib, os
    >>> os.mkdir(obj_file_dir)
    >>> store_compressed = zlib.compress(store_bytes, level=1)
    >>> with open(obj_file_path, 'wb') as f:
             f.write(store_compressed)
    

文件保存后,退出 Python 命令行,检查新保存的文件的 MD5:

$ md5sum .git/objects/cd/0875583aabe89ee197ea133980a9085d08e497
b2ba11b81d81fd634f33befa5b166a6a  .git/objects/cd/0875583aabe89ee197ea133980a9085d08e497

可以看到,和前面 Git 生成的文件的 MD5 值是一样的,说明我们用 Python 保存的 Blob 对象和 Git 保存的 Blob 对象是完全一致的。

三、总结

至此,我们就很清楚 Git 是如何构造和存储 Blob 对象的了。即:

  1. 从文件内容(content)创建文件头(header);
  2. 将文件内容和文件头拼起来,得到要存储的文件内容;
  3. 将拼接后的文件内容用 SHA1 计算哈希值(字符串),得到此对象的 key,同时也是存储路径;
  4. 存储文件前用 zlib 对其进行压缩;
  5. 将压缩后的内容存储到 key 指向的路径。

四、启示

从上面可以看到,Git 将内存存储为 Blob 对象的时候,其存储路径由内容(加文件头)的哈希值决定。因此,相同的内容,其存储路径也相同,即只会存储一份

例如,我们有两个相同的文件 hello.txtanother-hello.txt,都添加到暂存区:

将现有的 hello.txt(已暂存)拷贝一份,命名为 another-hello.txt
$ cp hello.txt another-hello.txt

将 another-hello.txt 暂存
$ git add another-hello.txt

此时查看 Git 存储了哪些 Blob,发现只有一个 Blob 对象
$ find .git/objects -type f
.git/objects/cd/0875583aabe89ee197ea133980a9085d08e497

所以可以说,Git 不会存储重复的内容

五、踩坑记录

熟悉的读者可以早已看出,本文是参考了 Pro Git 2 的第 10 章。它使用 Ruby 完成了这个过程,我就想着尝试用 Python 试一试。第一次尝试失败了,原因是使用 zlib 压缩的时候,Python 的 zlib.compress 默认的压缩等级和 Git 采取的压缩等级不同,导致保存下来的文件的 magic number 不一样,最后对文件计算 MD5 就有不同的结果。所以这里明确指定 zlib.compresslevel 参数为 1,意思是「速度最快」,也就是 Git 中采取的压缩等级。

六、参考