第 1 篇:SHA-1 与 blob
这一篇讲 Git 对象系统的第一块:一个普通文件为什么能变成一个稳定的对象 ID。
本文基于 mini-git 当前版本:
44f6a6524350a4b94bb446bedf616a46d72d0e97
核心问题
执行:
mgit hash-object a.txt
为什么会得到一个 40 位十六进制字符串?这个字符串到底是文件路径的哈希、文件内容的哈希,还是 Git 内部对象的哈希?
答案是:它是 Git 规范对象字节的 SHA-1。对 blob 来说,参与哈希的字节是:
blob <size>\0<content>
所以 hello\n 对应的不是直接 sha1("hello\n"),而是:
sha1("blob 6\0hello\n")
这个细节很关键。Git 的对象 ID 绑定的是“带类型和长度的对象内容”,不是文件路径,也不是裸文件内容。
真实运行
从空仓库开始:
mkdir /tmp/mgit-article-01
cd /tmp/mgit-article-01
mgit init
printf "hello\n" > a.txt
mgit hash-object a.txt
mgit hash-object -w a.txt
mgit cat-file -t <hash>
mgit cat-file -p <hash>
find .git/objects -type f | sort
git cat-file -t <hash>
git cat-file -p <hash>
一次实际运行输出如下:
Initialized empty Git repository in ./.git
ce013625030ba8dba906f756967f9e9ca394464a
ce013625030ba8dba906f756967f9e9ca394464a
blob
hello
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
blob
hello
这里有三个观察点。
第一,mgit hash-object a.txt 和 mgit hash-object -w a.txt 得到同一个 hash:
ce013625030ba8dba906f756967f9e9ca394464a
这说明 -w 只控制是否写入对象库,不改变对象 ID 的计算。
第二,写入后的对象路径是:
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
Git loose object 的路径规则是把 40 位 hash 拆成两段:前 2 位作为目录名,后 38 位作为文件名。
第三,真实 Git 可以读这个对象:
git cat-file -t ce013625030ba8dba906f756967f9e9ca394464a
git cat-file -p ce013625030ba8dba906f756967f9e9ca394464a
输出是:
blob
hello
这说明 mini-git 写出的 loose object 符合真实 Git 的对象格式。
源码入口
这一篇只需要看五个文件:
src/commands/cmd_hash_object.c
src/commands/cmd_cat_file.c
src/core/object.c
src/base/hash.c
src/base/zlib_util.c
调用链可以先压缩成这样:
main
-> cmd_hash_object.run
-> file_read_all
-> object_hash
-> object_store_write
-> zlib_compress
-> file_write_all
main 如何找到 cmd_hash_object 已经在导读里讲过,这里不重复。本文重点从 cmd_hash_object.run 开始。
命令层:参数解析只服务于行为
src/commands/cmd_hash_object.c 里,hash_object_run 先处理参数:
int write = 0;
const char *type_name = "blob";
const char *file = NULL;
这三个变量刚好对应命令语义:
write:有没有-wtype_name:对象类型,默认是blobfile:要读取哪个文件
参数循环只做一件事:把命令行输入翻译成内部变量。
-w -> write = 1
-t <type> -> type_name = argv[++i]
普通参数 -> file = argv[i]
这类细节属于工程骨架。它必须存在,因为程序需要从命令行进入内部逻辑;但它不是 Git 原理本身。写文章时讲到能解释行为即可,不需要把每个错误分支都展开。
计算对象 ID
参数解析后,命令读取文件内容:
file_read_all(file, &data, &size)
然后调用:
object_hash(type, data, size, &hash);
真正的对象 ID 计算在 src/core/object.c:
header_len = snprintf(header, sizeof(header), "%s %lu",
object_type_name(type), (unsigned long)size);
header_len++;
sha1_init(&ctx);
sha1_update(&ctx, (const uint8_t *)header, header_len);
sha1_update(&ctx, (const uint8_t *)data, size);
sha1_final(&ctx, out);
注意 header_len++。这一步把字符串结尾的 NUL 字节也算进去,所以参与哈希的是:
blob 6\0hello\n
这里的 6 是内容长度,因为 hello\n 一共 6 个字节。
这也解释了为什么 blob 不保存文件名。object_hash 的输入只有对象类型、内容长度和内容本身,没有路径。文件叫 a.txt、b.txt,只要内容都是 hello\n,blob hash 就一样。
SHA-1 在这里承担什么职责
src/base/hash.c 是一个手写 SHA-1 实现。它包含:
- 初始状态
- 64 字节分块
- 80 轮变换
- padding
- 大端长度写入
- 20 字节结果转 40 位十六进制字符串
文章里不需要把 SHA-1 的 80 轮细节全部推导一遍,因为这个系列的主线不是密码学。这里真正要理解的是 SHA-1 在 Git 里的职责:
- 把对象规范字节映射成固定长度 ID
- 相同对象得到相同 ID
- 内容改变会导致 ID 改变
- 读取对象时可以重新计算 hash 做完整性校验
所以这篇讲 SHA-1,要讲到“Git 为什么能内容寻址”,不需要讲成“从零实现密码学哈希算法”。
写入 loose object
mgit hash-object a.txt 只计算 hash。加上 -w 后,命令进入:
object_store_write(store, type, data, size, &hash)
src/core/object.c 里的写入流程是:
计算对象 hash
-> 根据 hash 得到对象路径
-> 拼完整对象数据:header + content
-> zlib 压缩
-> 写入 .git/objects/xx/yyyy...
对象路径由 object_path 决定:
char subdir[3] = { hex[0], hex[1], 0 };
path_join(path1, sizeof(path1), store->objects_dir, subdir);
path_join(buf, size, path1, hex + 2);
所以:
ce013625030ba8dba906f756967f9e9ca394464a
会被存成:
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
写入前还有一个判断:
if (file_exists(path)) {
return 0;
}
这说明对象数据库天然去重。对象 ID 由内容决定,同一个对象已经存在时,不需要重复写。
压缩不影响对象 ID
loose object 文件里存的是 zlib 压缩后的数据,但 hash 不是对压缩结果算的。
mini-git 的顺序是:
先对 header + content 算 hash
再把 header + content 压缩写入磁盘
这点非常重要。压缩只是物理存储方式,对象 ID 来自逻辑对象内容。后面讲 pack 时也会继续用到这个判断:对象从 loose object 进入 packfile 后,ID 不会变。
cat-file 如何反向验证
cat-file 的意义是把对象读回来:
hash
-> object_path
-> 读取压缩文件
-> zlib 解压
-> 解析 "type size\0"
-> 输出类型或内容
这就是为什么实验里:
mgit cat-file -t ce013625030ba8dba906f756967f9e9ca394464a
输出:
blob
而:
mgit cat-file -p ce013625030ba8dba906f756967f9e9ca394464a
输出:
hello
真实 Git 也能读出同样结果,说明 mini-git 写入的对象格式是兼容的。
必要边界
mini-git 这里保留了 Git 对象系统最核心的东西:
- 对象头
- SHA-1 对象 ID
- loose object 路径
- zlib 压缩
- 类型和内容解析
但它不是完整 Git 对象数据库实现。后面还会遇到 packfile、delta、对象遍历、引用可达性等复杂机制。本文只关心第一个问题:一个文件内容如何变成一个可寻址的 blob 对象。
另外,SHA-1 本身已经不适合作为现代安全哈希来抵御有意构造的碰撞攻击。Git 这里讨论的是对象寻址模型;真实 Git 生态里还有 SHA-256 仓库格式等演进。这些不是 mini-git 第一阶段的主线,只需要知道对象 ID 的抽象可以换底层哈希算法。
面试表达
可以这样回答“Git 的 blob 和对象 ID 是什么”:
Git 的 blob 对象只保存文件内容,不保存文件名。对象 ID 来自规范对象字节的哈希,
对 blob 来说是 "blob <size>\0<content>"。因此同样内容会得到同样 blob ID,
文件名、目录结构和权限由 tree 对象保存。loose object 会按 hash 的前 2 位分目录,
剩余 38 位做文件名,并以 zlib 压缩后的形式存储。
如果继续追问“为什么 Git 可以校验对象完整性”,可以这样答:
因为对象 ID 是对象规范内容的哈希。读取对象后,Git 可以解压并重新计算
"type size\0content" 的哈希,如果结果和请求的对象 ID 不一致,就说明对象损坏或不匹配。
这两个答案都能回到实验验证:mgit hash-object -w 写对象,mgit cat-file 和真实 git cat-file 读对象,.git/objects/ce/... 证明 loose object 的路径规则。

334

被折叠的 条评论
为什么被折叠?



