Git 2.56 架构原理与实战-第 1 章 对象模型与哈希:Git 的数据哲学
系列:《Git 2.56 架构原理与实战》。本章对应源码版本 v2.56.0,所有代码引用均以
https://github.com/git/git/blob/v2.56.0/<path>定位。
§1 本章概览与问题地图
1.1 Git 不是"版本的文件系统",而是"内容寻址的数据库"
如果把 Git 理解成一个"能存历史的文件系统",那么很多设计会显得别扭:为什么改一个字节整个仓库的 ID 都会变?为什么分支不是目录而只是一个 41 字节的文本文件?为什么删除一个大文件并不能立刻减小仓库体积?
把 Git 理解成一个内容寻址(content-addressed)的对象数据库,这些问题就立刻变得自然:
- 数据库里存的不是"文件",而是对象(object);
- 每个对象的名字不是人给的,而是它的内容算出来的哈希值;
- 文件名、目录、版本、标签,都只是"指向某个哈希值的引用"或"由哈希值组成的结构";
- 因此"版本"不是被记录的,而是被计算出来的。
这就是 Git 的数据哲学:名字即内容,内容即名字。理解第 1 章的关键,不是记住 blob/tree/commit/tag 四个名词,而是理解这套寻址模型如何自洽地推导出后续所有子系统(packfile、index、refs、commit-graph)的形态。
1.2 内容寻址的定义式
Git 对一个对象的命名规则可以写成一个精确的公式:
object_id = HASH( header || payload )
header = "<type> <size>\0"
其中:
HASH是可替换的哈希函数(SHA-1 或 SHA-256,见 §1.5);<type>是四种类型名之一:blob、tree、commit、tag;<size>是十进制 ASCII 表示的 payload 字节数;\0是一个真实的 NUL 字节;payload是对象的原始内容,不做任何换行/编码转换。
注意三个容易忽略的细节:
- 类型进入哈希,所以同样的字节序列作为 blob 和作为 tree 的哈希不同;
- 长度进入哈希,且是 ASCII 十进制文本,所以内容
"5"和内容"05"是不同对象(后者的长度是 2,头部是blob 2\0); - 没有额外分隔符,payload 紧跟 NUL,长度信息恰好起到边界作用。
这个设计最早由 Linus Torvalds 在 2005 年为 Git 引入,其灵感直接来自 Monotone 与 Venti 等早期内容寻址存储系统。源码中实现这个公式的正是 hash_object_file()(object-file.c:459),它把 format_object_header()(object-file.c:102)产生的头先喂进哈希上下文,再把 payload 喂进去。
1.3 四种对象类型的分工
| 类型 | 语义 | payload 是什么 | 是否可解析为指针图 |
|---|---|---|---|
| blob | 文件内容(不带文件名) | 任意字节流 | 否(叶子) |
| tree | 一个目录 | 排序后的条目表(mode + name + oid) | 是(指向 blob/tree) |
| commit | 一次提交 | 头字段 + 空行 + 提交信息 | 是(指向 tree 与 parent 提交) |
| tag | 附注标签 | 头字段 + 空行 + 标签信息 | 是(指向任意被标记对象) |
四者构成的图就是 Git 的"提交 DAG + 树形目录"复合结构。关键洞察:
- blob 完全不知道自己的文件名。改名不产生新对象,这是 Git 高效的根基;也正因为此,"重命名检测"必须在 diff 阶段凭空猜,而不是查询元数据(这是第 6 章的起点)。
- tree 用名字换哈希,它是唯一"带路径信息"的对象,因此它可以被共享:两个提交只要目录内容相同,就共享同一棵子树。
- commit 只指向一棵顶层 tree,整棵目录树通过递归可达;任何子树的改动都会沿路径向上传播,直到顶层 tree 的哈希改变,最终改变 commit 的哈希。这就是"改一个字节,整条历史 ID 都变"的传播链。
- tag 是唯一可"指回旧世界"的对象,它让一个人类可读的名字绑定到一个不可变的哈希上,是发布流程的锚点。
1.4 本章的问题地图
本章要回答十个具体问题,它们构成后续章节的地基:
object_id在内存里到底长什么样?为什么它同时携带哈希算法号?(§2)- 四种对象的磁盘字节布局分别是什么?tree 的条目如何编码与排序?(§3)
- 哈希计算与对象头解析的算法细节、边界与复杂度?(§4)
- 从
git hash-object -w到落盘,函数调用链经过了哪些真实函数与行号?(§5) hash-object、cat-file、rev-parse每条选项映射到哪一步?(§6)- 哪些输入会被拒绝?哪些坑会让两个不同内容得到同一路径?(§7)
- 大对象、流式对象与内存对象在性能上有何差别?(§8)
- 对象层如何与 ODB 可插拔、packfile、commit-graph、refs 交互?(§9)
- 松散对象与哈希 API 经历了哪些历史重构?(§10)
- 2.56.0 在这一层具体改了什么?(§11)
读者若能独立回答这十个问题,就具备了阅读后续"packfile 格式逐字节""refs 三后端""commit-graph"等章节的前置知识。
1.5 为什么会有 SHA-1 与 SHA-256 两套
Git 从诞生起使用 SHA-1(160 位,20 字节 raw / 40 字符 hex)。2017 年 SHAttered 攻击证明 SHA-1 的碰撞抗性已被击破,Git 社区启动了向 SHA-256(256 位,32 字节 raw / 64 字符 hex)的迁移。但 Git 的数据模型让"换哈希函数"变成了一件极其昂贵的事:
- 每个对象的 ID 都会变,等于整库重写;
- 所有 commit 的 parent 指针、所有 tree 的 oid、所有 tag 的目标都会变;
- 索引文件、pack idx、reflog、refs、bundle、替代对象……全部依赖 ID。
因此 Git 采取了一个教科书级的工程折衷:仓库级别选择哈希算法,并允许"过渡期双哈希"——即主算法为 SHA-256 的仓库同时维护一套 SHA-1 兼容 oid,用于与尚未升级的远端互通。
源码把这一折衷抽象成了数据结构,而不是散落的 if:
- 仓库有
hash_algo(主算法)与compat_hash_algo(兼容算法); object_id内部带一个algo字段(hash.h:229),使 OID 可以"自带身份",从而让双哈希对象共存于同一进程而不混乱;- 所有哈希计算都通过
struct git_hash_algo的 vtable 分派(hash.h:294),而非硬编码调用 SHA-1 函数。
这一层的完整过渡设计(pack idx v3、loose-object-idx、transfer table、四种操作模式)是本章 §9、§11 的核心素材,规格见 Documentation/technical/hash-function-transition.adoc。
1.6 你可以读到什么深度
本章不是"Git 入门"。它按如下深度逐层下钻:
- 逐字节:给出真实的十六进制转储与逐字段拆解,包括 zlib 包裹格式与 Adler-32 校验;
- 逐字段:
object_id、git_hash_algo、object、blob/tree/commit/tag的每个成员都有类型、偏移、位宽与生命周期说明; - 逐行:关键函数给出文件、行号、调用链与失败路径;
- 可复现:§12 提供一批可以直接粘贴运行的 shell 实验,让你亲眼看到哈希、压缩与解析的关系;
- 可扩展:§13 讲解如何接入新哈希算法、如何写一个读取松散对象的独立工具、如何对接 2.56 的 ODB vtable。
§2 核心数据结构
本节按"从底层到上层"的顺序,逐个拆解对象层的 C 结构体:OID → 哈希算法表 → 对象基类 → 四个具体类型 → 仓库级缓存。
2.1 struct object_id:自带算法号的 34 字节
定义在 hash.h:229:
struct object_id {
unsigned char hash[GIT_MAX_RAWSZ];
uint32_t algo; /* XXX requires 4-byte alignment */
};
逐字段表:
| 字段 | 类型 | 大小 | 含义 | 生命周期 |
|---|---|---|---|---|
hash | unsigned char[GIT_MAX_RAWSZ] | 32 字节 | 原始二进制哈希值,永远按最大算法预留 | 随结构体 |
algo | uint32_t | 4 字节 | GIT_HASH_* 常量,标识 hash 的有效前缀长度 | 随结构体 |
其中 GIT_MAX_RAWSZ 展开为 GIT_SHA256_RAWSZ = 32(hash.h:224、hash.h:218);对应的十六进制最大长度为 GIT_MAX_HEXSZ = 64(hash.h:225)。
三个重要设计点:
- 哈希值永远按 32 字节分配,即使当前是 SHA-1(只用前 20 字节)。这是"用空间换类型安全":任何 OID 都能容纳两种算法,无需重新分配;代价是 SHA-1 模式下每个 OID 多 12 字节尾部(未初始化或清零)。
algo使 OID 自描述。比较两个 OID 时必须同时比较其算法,否则会把同一段二进制在不同算法下误判为相等。源码提供了oideq()/oidcmp()封装(hash.h中的内联函数族),它们在比较时处理了这点。- 注释里的
XXX requires 4-byte alignment是一个真实约束:algo是uint32_t,而hash是unsigned char[32],结构体总大小 36 字节但会被对齐到 4 的倍数(实际sizeof(struct object_id)为 36;这里是"字段对齐"提示,用于#[repr(C)]的 Rust 侧镜像)。Rust 端的ObjectID(src/hash.rs)用#[repr(C)]精确镜像了这一布局:hash: [u8; 32]+algo: u32,以保证 FFI 边界两侧内存布局一致。
关于 algo 的一个微妙之处:GIT_HASH_UNKNOWN = 0(hash.h:187)表示"算法未知"。当 OID 从外部文本解析(例如命令行给的 40 位十六进制)时,往往先填成 GIT_HASH_UNKNOWN,再在需要时按仓库主算法补齐。parse_oid_prefix()(object-name.c:232)末尾就显式写:
oid_out->algo = algo ? hash_algo_by_ptr(algo) : GIT_HASH_UNKNOWN;
hash_algo_by_ptr()(hash.h:385)通过指针反查数组下标,等价于 OID 与算法表之间的双向索引。
2.2 struct git_hash_algo:哈希算法的 vtable
定义在 hash.h:294。这是 Git 能"热插拔"哈希算法的关键抽象——所有调用方都不直接调 SHA-1/SHA-256 函数,而是通过这张函数表分派。
逐字段表:
| 字段 | 类型 | 含义 | 备注 |
|---|---|---|---|
name | const char * | 算法名,出现在 config 与报错中 | "sha1" / "sha256" |
format_id | uint32_t | 4 字节版本标识,用于 pack idx v3 | 见 §3.6 |
rawsz | size_t | 二进制哈希长度 | 20 / 32 |
hexsz | size_t | 十六进制字符数 | 40 / 64 |
blksz | size_t | 哈希算法内部块大小 | SHA-1 为 64,SHA-256 为 64 |
init_fn | 函数指针 | 初始化上下文 | 对应 git_hash_init() |
clone_fn | 函数指针 | 复制上下文(用于流式重算) | 对应 git_hash_clone() |
update_fn | 函数指针 | 喂入数据 | 对应 git_hash_update() |
final_fn | 函数指针 | 产出 raw 哈希 | 对应 git_hash_final() |
final_oid_fn | 函数指针 | 产出 struct object_id | 对应 git_hash_final_oid() |
discard_fn | 函数指针 | 释放/废弃上下文 | 2.56 起保证幂等,见 §11 |
empty_tree | const struct object_id * | 空树 OID | 常量指针 |
empty_blob | const struct object_id * | 空 blob OID | 常量指针 |
null_oid | const struct object_id * | 全零 OID | 常量指针 |
unsafe | const struct git_hash_algo * | 该算法的"不安全"变体 | 仅 SHA-1 有 |
几个值得展开的点:
为什么有 unsafe? Git 的历史对象可能由已知存在碰撞的输入产生(例如 shattered 演示文件)。为了在安全模式下也能处理这些历史数据,Git 为 SHA-1 提供了一套"不安全"实现(sha1_unsafe_algo,hash.c:188),它在算法层面仍算 SHA-1,但不做碰撞检测加固。unsafe_hash_algo()(hash.h:396)负责取到这个变体。
为什么 format_id 用整数而不是字符串? 因为它要写入 pack idx v3 的二进制头部(见第 2 章),必须定长。GIT_SHA1_FORMAT_ID = 0x73686131(hash.h:206)恰好是大端下的 ASCII "sha1";GIT_SHA256_FORMAT_ID = 0x73323536(hash.h:215)是 "s256"(注意不是 "sha256",历史命名习惯)。
为什么 empty_tree/empty_blob/null_oid 是表内常量而非独立函数? 因为不同算法下这三个常量不同,把它们挂在 vtable 上,调用方只需 repo->hash_algo->empty_tree 即可,无需分支。hash.c 顶部(hash.c:5~hash.c:44)为 SHA-1 与 SHA-256 分别定义了这三组常量。
2.3 hash_algos[] 与三个军用值
全局算法表定义在 hash.c:205:
const struct git_hash_algo hash_algos[GIT_HASH_NALGOS] = {
{ /* GIT_HASH_UNKNOWN = 0 */ ... init_fn = git_hash_unknown_init, ... },
{ /* GIT_HASH_SHA1 = 1 */ .name = "sha1", .rawsz = 20, .hexsz = 40, ... },
{ /* GIT_HASH_SHA256 = 2 */ .name = "sha256", .rawsz = 32, .hexsz = 64, ... },
};
s GIT_HASH_NALGOS = GIT_HASH_SHA256 + 1 = 3(hash.h:193)。索引 0 是"未知算法",它的 init_fn 等全部指向 git_hash_unknown_*(会 BUG()),这是一种"哨兵"设计:任何忘记设置算法的路径都会在第一时间崩溃,而不是静默计算出错误哈希。
三个必须记住的常量 OID:
| 常量 | 算法 | 十六进制 | 源码位置 |
|---|---|---|---|
| 空 blob | SHA-1 | e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 | hash.c:12 |
| 空 tree | SHA-1 | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 | hash.c:5 |
| 空 blob | SHA-256 | 473a0f4c3be8a93681a267e3b1e9a7dcd1185436fe141f7749120a303721813 | hash.c:32 |
| 空 tree | SHA-256 | 6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321 | hash.c:23 |
空 tree 的 SHA-1 4b825dc6... 极为常用:git diff --cached、根提交的 diff、git write-tree(空索引时)都涉及它。它甚至出现在很多工具的硬编码里,因此是 RNA(Repo Neutral Anchor)级别的事实。
记忆点:空 blob =
e69de29("e6 9d e2",可谐音成"一路就…");空 tree =4b825dc。第一眼记住这两个,能省下后面无数次翻文档。
2.4 enum object_type:类型编号
定义在 object.h:98:
enum object_type {
OBJ_BAD = -1,
OBJ_NONE = 0,
OBJ_COMMIT = 1,
OBJ_TREE = 2,
OBJ_BLOB = 3,
OBJ_TAG = 4,
/* 5 is reserved for "future" */
OBJ_OFS_DELTA = 6,
OBJ_REF_DELTA = 7,
};
关键点:
- 编号 1~4 与 pack 格式中的类型字段完全一致(见第 2 章),因此从 pack 解出的类型可以直接当
enum object_type用。 - 编号 5 预留(历史上是
OBJ_EXT)。 OBJ_OFS_DELTA = 6与OBJ_REF_DELTA = 7不是真实对象类型,只存在于 pack 内部,表示"这是一个 delta,基准在该 pack 的某偏移 / 某 OID"。它们永远不会以用户的<type>出现在 object header 里。format_object_header()只接受type_name()能返回的四种类型,否则BUG()(object-file.c:107)。OBJ_BAD/OBJ_NONE是负值/零值,用于错误与"尚未确定"。
type_name() 与 type_from_string_gently()(object.h:166)负责类型名与编号互转。注意 type_from_string_gently() 的 gentle 参数:parse_loose_header()(object-file.c:271)在解析磁盘头时用 gentle=1,把未知类型名解析成 OBJ_BAD 而不是直接报错,以便上层给出更友好的信息。
2.5 struct object:所有对象的共同前缀
定义在 object.h:159:
struct object {
unsigned parsed : 1;
unsigned type : TYPE_BITS;
unsigned flags : FLAG_BITS;
struct object_id oid;
};
其中 TYPE_BITS = 3(object.h:92)、FLAG_BITS = 29(object.h:90)。逐字段表:
| 字段 | 位宽 | 含义 |
|---|---|---|
parsed | 1 bit | 是否已从磁盘解析为具体类型结构 |
type | 3 bit | OBJ_COMMIT~OBJ_TAG,或解析前的 OBJ_NONE |
flags | 29 bit | 遍历/可达性/显示用的标志位(如 SEEN、UNINTERESTING) |
oid | 36 字节 | 该对象自身的 OID(副本,非指针) |
为什么 type 只给 3 位? 因为对象类型只需 0~7,3 位足够;这是一种激进的位域优化——object 基类被所有对象共享,每次遍历可能有数百万个实例,省下的字节很可观。FLAG_BITS = 29 是把 unsigned 的 32 位分掉 1+3 后剩下的部分,用于承载遍历标志。
注意 oid 存的是值而非指针。这意味着每个对象自带一份 36 字节 OID;但仓库级缓存(object_array、oidmap)中通常还会有一份指针/索引指向它。create_object()(object.h:195)与 lookup_object()(object.h:193)管理这套缓存。
2.6 四种具体对象
blob(blob.h)在基类之上不加任何字段:
struct blob {
struct object object;
};
这意味着 blob 的"内容"完全在被丢弃/按需读取的 payload 里;内存中的 struct blob 只是一个带 OID 与标志的壳。这是一种"元数据与内容分离"的设计:内容可以是几十 GB,仍然不需要驻留内存(见 §8 的流式路径)。
tree(tree.h):
struct tree {
struct object object;
void *buffer; /* 已解析的原始条目缓冲 */
unsigned long size; /* 缓冲字节数 */
};
buffer 是惰性填充的:lookup_tree()(tree.c:167)只登记壳,parse_tree_buffer()(tree.c:175)才把磁盘字节填入 buffer,供 tree-walk.c 迭代。
commit(commit.h):
struct commit {
struct object object;
timestamp_t date;
struct commit_list *parents;
struct tree *maybe_tree; /* 可能为 NULL:未解析或已失效 */
unsigned int index; /* 在提交图中的序号,加速查询 */
};
maybe_tree 这个名字本身就是契约:不可假设它非空。追根溯源的代码必须先调 repo_get_commit_tree() 确保解析。parents 是有序链表——父顺序对合并提交有意义(第一个父是"主分支")。
tag(tag.h):
struct tag {
struct object object;
struct object *tagged; /* 被标记对象 */
char *tag; /* 标签名 */
timestamp_t date;
};
tagged 是惰性解析的指针:parse_tag_buffer()(tag.c:130)只在需要时把目标对象 lookup 出来。
2.7 仓库级的对象缓存
object.h 还定义了 struct parsed_object_pool 与 struct object_array。前者是每个仓库一份的"已解析对象池"(含 struct oidmap 与按类型分的链表);后者是遍历时携带的紧凑数组(nr/alloc/objects[]),常用于 walk 结果集。
get_indexed_object()(object.h:178)与 get_max_object_index()(object.h:173)暴露的是"扁平化索引":所有曾 lookup_object() 过的对象被顺序编号,flags 位域配合 index 实现 O(1) 的"是否见过"判断——这正是第 5 章可达性算法的基础。
2.8 生命周期总览
一次典型对象生命:
- 命名:命令行给出短名,
parse_oid_prefix()解析为object_id(algo可能为 UNKNOWN); - 注册:
lookup_object()在池里登记一个"半初始化"壳(parsed=0, type=OBJ_NONE); - 解析:
parse_object()→object_as_type()(object.h:197)按需从 ODB 读字节并填buffer; - 使用:
tree-walk、commit_list等消费解析结果; - 释放:仓库关闭时统一释放;blob 内容即使不释放也仅占指针。
理解这个生命周期很重要,因为它解释了为什么"对象缓存"与"ODB 存储"是两个层次:前者是内存加速器,后者才是权威数据源。2.56 的 ODB 可插拔重构(§11)正是把权威数据源这一侧抽成了 vtable,而对象缓存层基本不变。
§3 磁盘与内存格式逐字节解读
本节把"内容寻址"从公式落到真实字节。我们按顺序解析:object header → 松散对象包装 → 四种对象的 payload → tree 的排序规则。
3.1 object header:<type> <size>\0
源码中的唯一生产者是 format_object_header()(object-file.c:102):
int format_object_header(char *str, size_t size, enum object_type type,
size_t objsize)
{
const char *name = type_name(type);
if (!name)
BUG("could not get a type name for 'enum object_type' value %d", type);
return xsnprintf(str, size, "%s %"PRIuMAX, name, (uintmax_t)objsize) + 1;
}
逐点解读:
- 格式是
%s %"PRIuMAX:类型名 + 一个空格 + 无符号十进制。PRIuMAX保证跨平台地把uintmax_t打印正确(Windows 上是I64u,Linux 上是lu)。 objsize用size_t传入,2.56 起放宽到了size_t(此前是unsigned long),以支持 >4GB 对象(见 §11)。- 返回值是
snprintf返回值 + 1:xsnprintf()返回的是"本应写入的字符数"(不含 NUL),加 1 后正好等于含 NUL 的总字节数。调用方odb_source_loose_write_object()(odb/source-loose.c:845)直接把它当hdrlen:因为哈希要覆盖的正是 header 包括结尾的 NUL。 MAX_HEADER_LEN = 32(object-file.h:11):类型名最长 6(commit)+ 空格 1 + 十进制长度最多 20 位(uint64上限 20 位)+ NUL 1 = 28,留有余量。栈上缓冲一律char hdr[MAX_HEADER_LEN]。
一个真实例子:payload 为 hello\n(6 字节)时,header 的字节是:
62 6c 6f 62 20 36 00
b l o b 6 \0
即 "blob 6\0",长度 7 字节。
3.2 松散对象的磁盘布局:zlib 包裹 header+payload
松散对象不是把 header||payload 原样写盘,而是先拼接、再整体 zlib 压缩。路径由 OID 决定:objects/<前2位hex>/<剩余38位hex>(SHA-1)。
源码路径构造在 odb_loose_path()(object-file.h:31,实现于 object-file.c 的 fill_loose_path(),object-file.c:46)。磁盘文件的字节结构是:
[ zlib stream = 2 字节头 + deflate 数据 + 4 字节 Adler-32 ]
└─ 解压后得到: "<type> <size>\0" + payload
- zlib 头 2 字节:CMF + FLG。默认压缩级别(
zlib_compression_level,通常为 -1,即 Z_DEFAULT_COMPRESSION=6)时是78 9c;若是Z_BEST_SPEED(级别 1)则为78 01。低 4 位的 FLEVEL 编码压缩级别。 - deflate 数据:自定义 LZ77 + 霍夫曼,长度不定。
- 尾部 Adler-32:4 字节大端,对未压缩数据做校验。这不是加密,但能检测磁盘损坏。
举例:git hash-object -w 写入内容 hello\n 后,objects/8b/9a5cf4...(虚构)文件头两个字节通常是 78 01 或 78 9c,zlib-flate -uncompress 得到 blob 6\0hello\n。
关键性质:哈希计算的是解压后的 header+payload,而不是磁盘上的压缩字节。因此压缩算法的选择(甚至不同 zlib 实现)不会改变对象 ID——这是内容寻址的必然要求。start_loose_object_common()(odb/source-loose.c:675)在压缩的同时并行喂哈希:
/* Start to feed header to zlib stream */
stream->next_in = (unsigned char *)hdr;
stream->avail_in = hdrlen;
while (git_deflate(stream, 0) == Z_OK)
; /* nothing */
git_hash_update(c, hdr, hdrlen);
即:同一份输入既进 zlib 压缩器,也进哈希上下文。write_loose_object_common()(odb/source-loose.c:725)在每次 git_deflate() 后,用 stream->next_in - in0 得知本次消费了多少输入字节,喂给哈希。这样哈希与压缩严格同步,无需二次扫描。
3.3 blob 的 payload
blob 的 payload 就是文件的原始字节,没有任何内部结构。这意味着:
- 一个 0 字节文件 → payload 长度 0 → header
"blob 0\0"→ 得到空 blob OIDe69de29b...; - 文本/二进制/符号链接目标,对 blob 层完全无差别;
- 换行符转换(CRLF↔LF)发生在进入对象层之前(
convert.c,见第 3、6 章),一旦进入 blob 就已定型。
3.4 tree 的 payload:mode + name + NUL + raw oid
tree 是唯一带路径的二进制结构。每个条目的布局是:
<mode ASCII> SP <name> NUL <raw oid (rawsz 字节)>
注意:
- mode 是 ASCII 文本(如
100644、100755、40000、160000),不是 4 字节整数。这是为了让 tree 解析器无需处理字节序。 40000而非040000:Git 省略前导零,目录模式的规范写法是 5 个字符40000,而文件是 6 个字符100644。解析时按 ASCII 数字读,因此040000这种带前导零的写法是非规范的(写 tree 时只产出规范形式)。- name 不含
/:tree 条目的名字本身就是单层名,路径分隔由递归遍历体现。 - oid 是 raw 二进制(SHA-1 下 20 字节,SHA-256 下 32 字节),紧跟 NUL 之后,长度由算法决定——这就是为什么 tree 解析器必须知道当前仓库的哈希算法。
一个最小 tree(含一个空文件)的十六进制:
31 30 30 36 34 34 20 65 6d 70 74 79 2e 74 78 74 00
1 0 0 6 4 4 SP e m p t y . t x t \0
e6 9d e2 9b b2 d1 d6 43 4b 8b 29 ae 77 5a d8 c2 e4 8c 53 91
(20 字节 raw SHA-1,即空 blob)
长度 = 6(mode) + 1(SP) + 9(name) + 1(NUL) + 20(oid) = 37 字节。因此对象 header 为 "tree 37\0"。
为什么用 NUL 分隔而不是长度前缀? 名字里不可能有 NUL(POSIX 路径不允许),所以 NUL 是零成本的分隔符;oid 定长,无需分隔。这让解析器可以顺序扫描而无需回溯——tree-walk.c 的 tree_entry()(tree-walk.c:152)正是这么做的。
gitlink(子模块):mode 160000,其 oid 指向一个 commit(而非 tree/blob)。S_IFGITLINK 0160000(object.h:121)与 object_type()(object.h:126)把这种 mode 映射为 OBJ_COMMIT,这是"Git 中唯一一个 oid 类型随 mode 变化"的位置。
3.5 commit 的 payload
commit 是文本行格式,字段顺序固定(write_commit_tree(),commit.c:1686):
tree <hex oid>\n
parent <hex oid>\n (0~N 行,合并提交有多个)
author <name> <email> <timestamp> <tz>\n
committer <name> <email> <timestamp> <tz>\n
[encoding <charset>\n]
[gpgsig ...\n] (多行签名,续行以空格开头)
[<extra header>\n ...]
\n (空行,分隔 header 与 message)
<commit message>
逐点解读:
- hex 而非 raw:与 tree 不同,commit 里的 oid 是 40/64 个十六进制字符。这是"人可读优先"的选择——commit 常被
git log、git show、文本工具处理。 author/committer行是自由的name <email> timestamp tz,Git 不解析中间部分,只在行首识别关键字。parent行可重复且有序:第一个父是合并时的"当前分支"侧。根提交没有parent行。- 空行必需:
parse_commit_buffer()(commit.c:516)用空行切分 header 与 message。 timestamp是 Unix 秒,tz形如+0800。二者一起构成"作者时间/提交时间",注意 Git 存的是"本地时间 + 偏移",显示时可还原。
一个最小 commit 的 payload:
tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author A U Thor <author@example.com> 1760000000 +0800
committer A U Thor <author@example.com> 1760000000 +0800
initial commit
它的对象 header 是 commit <字节数>(注意:tree 行的 oid 恰是空 tree,即此次提交的目录为空——这正是 git commit --allow-empty 时的一种常见形态,但通常根提交仍带非空 tree)。
parse_commit_buffer() 内部有两个常数值得记住:tree_entry_len = hexsz + 5("tree " 5 字节 + 40 hex)、parent_entry_len = hexsz + 7("parent " 7 字节 + 40 hex)。用定长前缀直接 memcmp 比较,比 strcmp 快。
3.6 tag 的 payload
tag(附注标签)同样是文本行格式,parse_tag_buffer()(tag.c:130)要求 size < hexsz + 24 直接判定非法(这是一个廉价的下限检查)。字段顺序:
object <hex oid>\n
type <blob|tree|commit|tag>\n
tag <标签名>\n
tagger <name> <email> <timestamp> <tz>\n
\n
<标签信息>
要点:
type行声明被标记对象的类型,使解析可以不解引用目标对象就完成一半工作(但tagged指针仍是惰性 lookup 的)。- tag 可以指向 tag("标签的标签"),
deref_tag()(tag.c:76)会循环解引用直到非 tag 对象。 tagger行在某些手写 tag 中可能缺失,解析器需容忍。
3.7 tree 条目的排序规则
tree 条目必须按规范顺序排列,否则哈希不可复现、diff 会错乱。规则在 base_name_compare()(tree.c:99)中实现,核心是"把目录名视作带尾随 / 来比较":
对两个条目比较:
1. 逐字节比较 name,取较短长度;
2. 若共同前缀内出现差异,直接按 unsigned char 大小返回;
3. 若一个是另一个的前缀:
取"下一个待比较字符":
- 较长者的下一字节 (若还有)
- 否则用 '/' (目录) 或 '\0'/'结束'
比较这两个字符.
为什么目录要补 /?因为 /(0x2F)的字节值小于所有数字和字母(0x30+),大于 .(0x2E)。这样目录能排在"名字是它前缀的文件"之前/之后,形成稳定的嵌套顺序。直观上,git ls-tree 的输出顺序就是这套规则的结果。
df_name_compare()(tree.c:128)是更严格的一版,明确处理 '\0' < '.' < '/' 的层级关系,用于需要区分同名前缀目录/文件的场景。name_compare()(tree.c:154)则是不考虑 mode 的纯名字比较。
一个排序实例(假设同名冲突):
条目 A: name="a", mode=100644 (文件)
条目 B: name="a/", mode=40000 (目录,实际条目名是 "a")
base_name_compare 会把 "a" 的"下一字符"当作 '\0'(文件结束语义),把目录 "a" 当作 '/';因为 '\0'(0x00) < '/'(0x2F),文件排在目录前。这正是 Git 允许同名文件与目录在同一棵树不同层级共存的根源(它们本就不在同一棵 tree 的同一目录内,但相邻路径比较时会用到这条规则)。
3.8 与 pack/idx 格式的衔接(预告)
松散对象只是"对象数据库"的一种后端。pack 后端把多个对象打包,并用索引(.idx)定位。SHA-256 迁移为此定义了 pack idx v3,其头部携带 format_id(见 git_hash_algo.format_id,§2.2)。第 2 章会逐字节展开 idx v1/v2/v3 与 pack 头。这里只需记住:哈希算法的标识会被写进索引文件,因此 index 不是"算法无关"的扁平数据。
同理,SHA-1↔SHA-256 的对应关系靠 objects/info/loose-object-idx(松散对象映射)与 .pack 的 transfer table(objects/info/ 或 pack 内)记录,格式是文本行 sha256-name SP sha1-name LF(见 Documentation/technical/hash-function-transition.adoc)。Rust 端的 src/loose.rs 用二进制 ObjectMap(签名 LMAP)实现同一映射,见 §13。
§4 核心算法推导
本节把关键算法写成可推演的伪代码,并给出复杂度与设计理由。
4.1 对象哈希算法(hash_object_file)
hash_object_file(algo, buf, len, type, out):
hdr[0..] = sprintf("%s %llu", type_name(type), len) // 注意:不含 NUL
hdrlen = strlen(hdr) + 1 // 含 NUL
ctx = init(algo)
update(ctx, hdr, hdrlen) // 头,含 NUL
update(ctx, buf, len) // payload
final_oid(ctx, out) // 得到 object_id
复杂度:O(n),n = header + payload 字节数,常数由哈希实现决定(SHA-1 约 1.5–3 GB/s/核,SHA-256 约 1–2 GB/s/核,取决于硬件加速)。
设计理由:
- 先头后体:符合定义式,且流式友好——可以先算头,再边读文件边算体(§5 有
index_fd的流式路径)。 - 不需要长度前缀:因为头部已编码长度,解析器读出 header 就知道 payload 边界。
- 不缓存中间状态:每次都是从零算,避免"增量哈希"带来的状态一致性风险。
4.2 format_object_header 的一次写入
xsnprintf(str, size, "%s %"PRIuMAX, ...) 的语义是"最多写 size 字节,含 NUL,返回本应写入的字符数(不含 NUL)"。Git 用它而非 sprintf 是为了防止栈溢出(xsnprintf 在缓冲不够时会 BUG() 而非静默截断)。返回 +1 后即含 NUL 的总长,正好供哈希与 zlib 使用。
4.3 parse_loose_header:手写状态机
这是一段典型的"Git 式防御性解析"——注释直言 sscanf() 太宽松(object-file.c:267)。伪代码:
parse_loose_header(hdr, oi):
// 阶段 1: 读类型名,直到空格
p = hdr
while (c = *p++):
if c == ' ': break
type_len++
if !c: return -1 // 没有空格 → 非法
type = type_from_string_gently(hdr, type_len, gentle=1)
*oi->typep = type
// 阶段 2: 读十进制长度,禁止前导零
size = *p++ - '0'
if size > 9: return -1 // 首字符非数字
if size:
while (c = *p - '0') <= 9:
p++
size = size*10 + c
*oi->sizep = size
// 阶段 3: 必须紧跟 NUL
if *p: return -1
return 0
为什么禁止前导零("010" 非法)?因为规范形式唯一。如果允许前导零,同一 payload 会有多种 header 写法,进而产生多个哈希——这直接破坏内容寻址。注意这里有个细节:size = *p++ - '0' 之后若 size==0(即字符是 '0'),进入 if(size) 为假分支,不会继续读后续数字字符;因此 "0" 是合法的零长度,而 "00" 会因阶段 3 看到 '0' 而失败。这个边界很微妙,测试用例里专门覆盖。
type_len 无上限检查,但整头长度由外层 MAX_HEADER_LEN 限制(见 unpack_loose_header)。
4.4 unpack_loose_header:流式 zlib 头解析
松散对象在磁盘上是压缩的,"读 header"必须先解压。但不要解压整个对象——只是想拿类型和长度。unpack_loose_header()(object-file.c:177)的策略:
unpack_loose_header(stream, map, mapsize, buffer, bufsiz):
obj = buffer
max = min(bufsiz, MAX_HEADER_LEN) // 只看头
inflate 直到 buffer 中出现 NUL 或达到 max
if 找不到 NUL: return ULHR_TOO_LONG
if zlib 报错: return ULHR_BAD
return ULHR_OK
返回枚举 ULHR_OK/ULHR_BAD/ULHR_TOO_LONG(object-file.h:140)。设计理由:
- 惰性:读取 header 的常见用途是"判断类型/大小",例如
cat-file -s、--batch;不必解压数 GB 的 payload。 - 上限保护:
MAX_HEADER_LEN防止恶意对象让解析器在巨大缓冲里扫描 NUL。 - 流式友好:
git_zstream可在任意位置暂停,因此返回后可以把同一个 stream 交给unpack_loose_rest()(object-file.c:211)继续解压 payload。
4.5 tree 排序算法与复杂度
排序本身发生在写入 tree 时(write_index_as_tree / cache-tree,见第 3 章),而非读取时。比较算法见 §3.7。复杂度:对 n 个条目排序为 O(n log n) 次比较,每次比较 O(名字长度)。因为树小(单层目录条目数通常远小于文件总数),代价可忽略。
关键不变量:同 oid、同 mode、同 name 的条目在规范顺序下唯一确定。这保证 write-tree 是纯函数——同样的索引 → 同样的 tree oid。
4.6 缩写长度算法(odb_find_abbrev_len)
Git 允许用短前缀(如 a1b2c3d)指代对象。为每个对象动态计算"最短唯一前缀"代价较高,Git 采用基于仓库对象总数的统计下界(msb,odb.c:905):
odb_find_abbrev_len(odb, min_length):
count = 松散+packed 对象总数
if count == 0: return FALLBACK_DEFAULT_ABBREV // 4
if min_length < 0:
len = msb(count) + 1
else:
len = min_length
len = DIV_ROUND_UP(len, 2)
len = max(len, FALLBACK_DEFAULT_ABBREV) // 至少 4
return len
msb(val) 返回最高有效位的位置(对数级)。逻辑:若有 count 个均匀分布的对象,则平均碰撞所需前缀长度约为 log16(count),即 msb(count)/2(因为每 hex 字符 4 位)。DIV_ROUND_UP(len, 2) 把"比特对数"转成"hex 字符数"。这只是一个下界,repo_find_unique_abbrev_r()(object-name.c:585)还会在此基础上按需加长,直到无歧义。
4.7 歧义消解状态机(disambiguate_state)
get_short_oid()(object-name.c:455)的目标:给定前缀,找出唯一匹配对象。它用 odb_for_each_object_ext 枚举所有以该前缀开头的对象,对每个调用 update_disambiguate_state()(object-name.c:52),维护一个状态机:
state 字段: candidate_exists / candidate_checked / candidate_ok / ambiguous
遇到第一个匹配:
candidate = current; candidate_exists = 1
遇到第二个匹配:
if 与 candidate 相同: 忽略(同一对象走多遍)
if 没有 hint 回调: ambiguous = 1; 停止
if !candidate_checked:
candidate_ok = hint(candidate) // 如 disambiguate_commit_only
candidate_checked = 1
if !candidate_ok:
换候选为 current; candidate_checked = 0
else if hint(current) 也通过:
ambiguous = 1; 停止 // 两个都通过 hint → 无法区分
else:
丢弃 current,保留 candidate
finish_object_disambiguation()(object-name.c:104)在枚举结束后决定返回 FOUND / MISSING_OBJECT / SHORT_NAME_AMBIGUOUS。hint 回调(disambiguate_commit_only 等,object-name.c:138~200)允许"只看提交"这类语义过滤:例如 HEAD~1 的解析只认 commit/tag,于是同一前缀下若一个是 blob 一个是 commit,就能唯一确定。
复杂度:最坏 O(对象总数),但实际通过"越靠前匹配越可能早停"和 ambiguous 提前退出,通常很快。这也是为什么短前缀越短越慢(候选更多)。
4.8 双哈希写入算法
当仓库同时有 hash_algo 与 compat_hash_algo 时,写入路径要同时算两个 oid。start_loose_object_common()(odb/source-loose.c:675)接收 c 与 compat_c 两个上下文,write_loose_object_common()(odb/source-loose.c:725)每次都对两者同时 update。最终 end_loose_object_common()(odb/source-loose.c:753)分别 git_hash_final_oid() 得到主 oid 与兼容 oid,再由 repo_add_loose_object_map()(loose.c:214)登记映射。
复杂度:约 2 倍哈希开销,但仅出现在过渡期,且可只对 blob 走"直接 hash"优化(odb_write_object_ext 中,blob 不需重编码结构,见 odb.c:982)。
4.9 复杂度总表
| 操作 | 复杂度 | 主导因子 |
|---|---|---|
hash_object_file | O(n) | payload 长度 |
format_object_header | O(log₁₀ size) | 十进制位数 |
parse_loose_header | O(header 长度) | 类型名 + 位数 |
unpack_loose_header | O(MAX_HEADER_LEN) | 解压 32 字节内 |
| 解压完整松散对象 | O(n) | payload |
| tree 写入排序 | O(k log k) | 单层条目数 k |
| 缩写长度 | O(1) | 对象总数取对数 |
| 前缀消解 | O(m) 最坏 | 匹配数 m |
这些复杂度共同解释了 Git 的性能直觉:对象层几乎所有操作都是线性的或更好的,真正昂贵的是 I/O(读压缩字节)与遍历(图级)。
§5 代码走读:从命令行到字节
本节沿两条真实调用链走读:写入路径(git hash-object -w 到落盘)与读取路径(git cat-file -p 到输出),并补充解析路径与完整性校验。所有行号基于 v2.56.0。
5.1 写入路径总览(一张调用图)
builtin/hash-object.c: hash_object()
└─ hash_fd(fd, type, path, literally)
└─ index_fd(istate, oid, fd, &st, type, path, flags) object-file.c:942
├─ (blob 且需 filter) index_stream_convert_blob() object-file.c:607
├─ (非普通文件) index_pipe() object-file.c:633
│ └─ index_mem() object-file.c:564
├─ (小文件) index_core() object-file.c:650
│ └─ index_mem() object-file.c:564
└─ (大 blob) odb_transaction_write_object_stream()
└─ odb_transaction_files_write_object_stream() object-file.c:882
└─ stream_to_pack() object-file.c:751
index_mem() 内:
├─ convert_to_git()(过滤器)
├─ fsck(INDEX_FORMAT_CHECK)
└─ odb_write_object() / hash_object_file()
└─ odb_write_object_ext() odb.c:982
├─ hash_object_file()(算主 oid) object-file.c:459
├─ odb_freshen_object()(命中即返回)
└─ odb_source_write_object()(分派到 source)
└─ odb_source_loose_write_object() odb/source-loose.c:833
├─ format_object_header() object-file.c:102
└─ write_loose_object() odb/source-loose.c:771
├─ start_loose_object_common() odb/source-loose.c:675
├─ write_loose_object_common() odb/source-loose.c:725
├─ end_loose_object_common() odb/source-loose.c:753
├─ close_loose_object() odb/source-loose.c:599
└─ finalize_object_file_flags()object-file.c:391
└─ repo_add_loose_object_map() loose.c:214
这条链体现了 2.56 的核心解耦:object-file.c 只负责"从文件/流到对象"的编排,odb.c 负责算法选择与哈希,odb/source-loose.c 负责"松散对象"这一具体后端的落盘。这正是 §11 要展开的重构。
5.2 index_fd:四条分流
index_fd()(object-file.c:942)是"从 fd 生成对象"的入口,它有四条分流,每条对应不同的性能/语义:
- blob + 需要 filter(
would_convert_to_git_filter_fd):走index_stream_convert_blob()(object-file.c:607),用流式过滤器,避免把整个文件读进内存。断言要求path非空(ASSERT(...))。 - 非普通文件(如管道、fifo):走
index_pipe()(object-file.c:633),strbuf_read(fd, 4096)读全部。 - 常规文件且(大小 ≤ big_file_threshold,或非 blob,或需要 convert):走
index_core()(object-file.c:650)。这里再分三档:size == 0→index_mem("", 0)(空对象,直接给空串);size <= SMALL_FILE_SIZE(32 KiB)→xmalloc+read_in_full,读进堆;- 否则 →
xmmap映射,零拷贝。
- 大 blob(超过 big_file_threshold 且不需 convert):走流式
odb_stream_from_fd()+odb_transaction_write_object_stream()(或只 hash 的hash_stream(),object-file.c:713)。这是 2.56 为"巨大文件不驻留内存"准备的路径。
repo_settings_get_big_file_threshold() 是可通过 core.bigFileThreshold 配置的(默认 512 MiB)。这一阈值的意义在 §8 详述。
5.3 index_core 到 index_mem
index_core()(object-file.c:650)只做"把 fd 变成内存缓冲",随后统一调用 index_mem()(object-file.c:564)。index_mem() 是真正的语义中枢:
if (!type)
type = OBJ_BLOB; /* 默认类型 */
if ((type == OBJ_BLOB) && path)
convert_to_git(...); /* CRLF/过滤器 */
if (flags & INDEX_FORMAT_CHECK) {
fsck_buffer(null_oid(...), type, buf, size, &opts); /* 格式校验 */
if (...) die(_("refusing to create malformed object"));
}
if (write_object)
ret = odb_write_object(...); /* 写库 */
else
hash_object_file(...); /* 只算哈希 */
注意 INDEX_FORMAT_CHECK(object-file.h:16)的作用:在写库前用 fsck 校验对象格式,防止把损坏的 commit/tree/tag 写进仓库。git hash-object 默认开启它(见 §6),而 --literally 关闭它。
hash-object 的 flags 默认是 INDEX_FORMAT_CHECK(builtin/hash-object.c),只有加了 -w 才会额外带 INDEX_WRITE_OBJECT。这解释了为什么 git hash-object file 与 git hash-object -w file 哈希相同但后者落盘。
5.4 odb_write_object_ext:算法选择与双哈希
odb_write_object_ext()(odb.c:982)是"写入分发器":
hash_object_file(odb->repo->hash_algo, buf, len, type, oid); /* 主 oid */
if (odb_freshen_object(odb, oid)) /* 已存在 → 只更新 mtime */
return 0;
if (compat) { /* 过渡期:还要兼容 oid */
if (compat_oid_in) oidcpy(&compat_oid, compat_oid_in);
else if (type == OBJ_BLOB) hash_object_file(compat, buf, len, type, &compat_oid);
else { convert_object_file(...); hash_object_file(compat, converted, ...); }
compat_oid_p = &compat_oid;
}
return odb_source_write_object(odb->sources, buf, len, type,
oid, compat_oid_p, NULL, flags);
三个要点:
odb_freshen_object()短路:若对象已存在,不重复写盘,只"刷新访问时间"(freshen)。这是 Git 长期使用的优化,避免热路径重复 I/O。2.56 把 freshen 逻辑也归入了 ODB 层。- blob 的双哈希是直接 hash,因为 blob 不含结构化的 oid;而 commit/tree/tag 需要
convert_object_file()把内部 oid 从 SHA-1 转 SHA-256(或反向),再算兼容 oid。这是"内容相同但编码不同"的关键。 odb_source_write_object()是 ODB source 的 vtable 入口,会依次尝试该 ODB 的各 source(松散/打包),最终调用到odb_source_loose_write_object()。
5.5 odb_source_loose_write_object 与 write_loose_object
/* odb/source-loose.c:833 */
hdrlen = format_object_header(hdr, sizeof(hdr), type, len);
if (write_loose_object(loose, oid, hdr, hdrlen, buf, len, mtime, flags))
return -1;
if (compat_oid)
return repo_add_loose_object_map(loose, oid, compat_oid);
return 0;
write_loose_object()(odb/source-loose.c:771)的骨架:
odb_loose_path(loose, &filename, oid); /* objects/xx/yyyy */
fd = start_loose_object_common(...); /* 建 tmp + 压 header + 喂 hash */
/* 压缩 payload:循环 git_deflate(),边压边 hash */
do {
unsigned char *in0 = stream.next_in;
ret = write_loose_object_common(loose, &c, NULL, &stream, 1,
in0, fd, compressed, sizeof(compressed));
} while (ret == Z_OK);
if (ret != Z_STREAM_END) die(...);
ret = end_loose_object_common(loose, &c, NULL, &stream, ¶no_oid, NULL);
if (!oideq(oid, ¶no_oid)) /* 复算校验 */
die(_("confused by unstable object source data for %s"), oid_to_hex(oid));
close_loose_object(loose, fd, tmp_file.buf); /* close + rename 前 */
if (mtime) { utime(...); } /* 保留 mtime(如 checkout) */
return finalize_object_file_flags(repo, tmp_file.buf, filename.buf,
FOF_SKIP_COLLISION_CHECK);
两处值得强调:
- "parano_oid" 复算:
write_loose_object在压缩过程中重新累积了一个哈希parano_oid,与调用方传入的oid比对。不一致即die("confused by unstable object source data")。这防止"对象内容在计算 oid 之后被改动"造成的静默损坏——典型场景是 mmap 的文件在哈希期间被外部进程修改。这也是源码中"防御性编程"风格的范例。 FOF_SKIP_COLLISION_CHECK:因为写入路径已经在odb_write_object_ext里先算过 oid 且odb_freshen_object短路,这里跳过check_collision()(object-file.c:329,逐字节比对 tmp 与目标),省一次全文件读。
5.6 write_loose_object_stream:无 OID 的流式写入
当对象 OID 未知(大 blob 流式)时,走 odb_source_loose_write_object_stream()(odb/source-loose.c:856)。它先把临时文件写进 odb 路径(因为还不知道 objects/xx/yyyy 名字),边压缩边累积哈希,最后算出 oid 再 rename 到位。与 write_loose_object() 的差别:
| 维度 | write_loose_object | write_loose_object_stream |
|---|---|---|
| oid 是否已知 | 已知(调用方传入) | 未知,边写边算 |
| 临时文件位置 | objects/xx/tmp_* | odb 根目录下 |
| 数据来源 | 内存 buf | struct odb_stream |
| 校验 | parano 复算 | 用最终算出的 oid |
| 双哈希 | 可带 compat_c | 可带 compat_c |
5.7 finalize_object_file_flags:原子落盘与碰撞检测
finalize_object_file_flags()(object-file.c:391)是"把 tmp 变成正式对象"的收尾。逻辑:
- 若
object_creation_mode == OBJECT_CREATION_USES_RENAMES→ 直接rename; - 否则先
link(tmpfile, filename)。用 link 而非 rename 是为了原子性:link 不会覆盖已存在文件; EEXIST时(目标已存在):若未跳过碰撞检查,调用check_collision()(object-file.c:329)逐块比对内容。若两者内容不同 → 报files '...' and '...' differ in contents,这是哈希碰撞或数据损坏的信号;CHECK_COLLISION_DEST_VANISHED(目标在检查期间被删除)→ 重试,最多 5 次("repeatedly vanishing file");- 收尾
adjust_shared_perm()设置共享仓库权限。
设计理由:Git 必须保证"同名对象 = 同内容"。若允许 link 在 EEXIST 时静默覆盖,则一次 SHA-1 碰撞就可能让仓库"记住"错误内容。check_collision 是这条不变量的守卫。
5.8 读取路径:cat-file 到对象
builtin/cat-file.c: cat_one_file(type, oid, obj_name) builtin/cat-file.c:133
├─ -t : odb_read_object_info_extended(...,&oi{typep},0)
├─ -s : odb_read_object_info_extended(...,&oi{sizep},0)
├─ -e : odb_has_object(...) → 退出码
└─ -p : 按类型
├─ OBJ_TREE → cmd_ls_tree()
├─ OBJ_BLOB → stream_blob() → odb_stream_blob_to_fd(fd, oid, ...)
└─ 其它 → odb_read_object(oid, ...) 后写出 buf
关键差异:tree 与 blob 走"流式/特殊渲染",commit/tag 走"整块读出再打印"。-p 对 blob 用 stream_blob() 是因为大 blob 可能数 GB,不能整体载入。
odb_read_object_info_extended()(odb.c:697)是"只取元信息"的入口:它依次查松散对象与 pack,命中后按 struct object_info 的请求字段填充 typep/sizep 等,不需要解压全部 payload(对松散对象即 §4.4 的 unpack_loose_header 惰性路径)。
5.9 read_loose_object:完整读取与校验
read_loose_object()(object-file.c:1192)是松散对象的完整读取实现:
fd = git_open(path); map = map_fd(fd, path, &mapsize); /* mmap */
if (unpack_loose_header(&stream, map, mapsize, hdr, sizeof(hdr)) != ULHR_OK) { ... }
if (parse_loose_header(hdr, oi) < 0) { ... }
if (*oi->typep < 0) { ... } /* 未知类型名 */
if (*oi->typep == OBJ_BLOB && *size > big_file_threshold) {
/* 大 blob:不载入内存,只做流式签名校验 */
check_stream_oid(&stream, hdr, *size, path, expected_oid, repo->hash_algo);
} else {
*contents = unpack_loose_rest(&stream, hdr, *size, expected_oid);
hash_object_file(repo->hash_algo, *contents, *size, *oi->typep, real_oid);
if (!oideq(expected_oid, real_oid)) goto out_inflate; /* 内容校验失败 */
}
要点:
map_fd用 mmap,让解压直接读页缓存;- 大 blob 分支用
check_stream_oid()(object-file.c:1132)流式校验,避免内存爆炸; - 小对象分支读完再
hash_object_file复算,与期望 oid 比对——读路径也做完整性验证,不只是写路径; - 失败统一
goto out_inflate,确保git_inflate_end()与munmap()都执行。
5.10 check_object_signature 与 stream_object_signature
check_object_signature()(object-file.c:113):
const struct git_hash_algo *algo = oid->algo ? &hash_algos[oid->algo] : r->hash_algo;
hash_object_file(algo, buf, size, type, &real_oid);
return !oideq(oid, &real_oid) ? -1 : 0;
注意第一行:优先使用 oid 自带的算法号。这是 §2.1 中"OID 自描述"的实际收益——校验时不必猜测调用方意图。stream_object_signature()(object-file.c:126)是其流式版本,供大对象使用。
5.11 解析路径
读完字节后,四类对象各自的解析函数:
- blob:
parse_blob_buffer()直接接受 buf(无结构); - tree:
parse_tree_buffer()(tree.c:175)把 buf 存入item->buffer,再由tree-walk.c惰性迭代。init_tree_desc()(tree-walk.c:65)、tree_entry()(tree-walk.c:152); - commit:
parse_commit_buffer()(commit.c:516),含tree_entry_len/parent_entry_len定长前缀比较、日期解析、编码处理、以及gpgsig续行折叠; - tag:
parse_tag_buffer()(tag.c:130),含size < hexsz+24下限检查、object/type/tag/tagger顺序解析。
object_as_type()(object.h:197)是从"半初始化壳"到"具体类型"的转换中枢:它按 type 把 struct object * 转成 struct blob */struct tree * 等(本质是同指针不同视图),并置 parsed=1。
5.12 一次完整写入的时序(带时间线)
以 git hash-object -w bigfile(bigfile 超过 bigFileThreshold)为例:
hash_object()解析选项,打开 fd,取 stat;index_fd()判定是"大 blob 但不需 convert" → 构造odb_stream;- 因带
-w:odb_transaction_begin_or_die()开启 ODB 事务; odb_transaction_write_object_stream()→ 关键:不先读全文件,而是边流边处理;odb_transaction_files_write_object_stream()(object-file.c:882)用format_object_header()生成头,git_hash_init/update累积哈希;stream_to_pack()(object-file.c:751)把流写进临时 packfile,并算 CRC32;already_written()检查是否重复 → 重复则hashfile_truncate()回滚;- 事务提交
odb_transaction_files_commit()(object-file.c:1259)把 pack 落盘、更新 idx; - 若未超阈值,则走
write_loose_object松散路径,最终finalize_object_file_flags()原子落盘。
这条时序解释了 2.56 的一个关键词:ODB transaction。它把"多对象批量写"的中间状态(packfile 缓冲、written 列表)收拢进一个事务对象,并在结束时统一 flush/commit,避免每个对象都独立落盘。这是 §13 扩展接口的落点之一。
§6 命令用法映射
本节的目的是建立"命令 ←→ 内部步骤"的对照表,让你看到每个用户可见选项背后真正触碰了哪些对象层逻辑。
6.1 git hash-object:选项逐条映射
选项定义见 builtin/hash-object.c:83(hash_object_options[]),默认 flags = INDEX_FORMAT_CHECK(builtin/hash-object.c:80)。
| 选项 | 内部效果 | 触及函数 |
|---|---|---|
默认(无 -w) | 计算 oid 但不落盘 | hash_object_file()(object-file.c:459) |
-w | 置 INDEX_WRITE_OBJECT,写库 | odb_write_object() → odb_write_object_ext()(odb.c:982) |
-t <type> | 指定对象类型(默认 blob) | type_from_string() → index_fd 的 type 参数 |
--stdin | 从 stdin 读内容;可重复计数 | hash_fd(0, type, vpath, flags)(builtin/hash-object.c:138) |
--stdin-paths | 从 stdin 每行读一个文件名 | hash_stdin_paths()(builtin/hash-object.c:46) |
--no-filters | path 传 NULL,跳过 convert | index_mem() 中 if (type==OBJ_BLOB && path) 短路 |
--literally | OPT_NEGBIT 清除 INDEX_FORMAT_CHECK | 跳过 fsck_buffer()(object-file.c:593) |
--path <file> | 伪造路径用于过滤器 | would_convert_to_git() / convert_to_git() |
几个反直觉点:
--stdin可重复(OPT_COUNTUP):git hash-object --stdin --stdin -t commit会连续从同一个 stdin 流读两次(第一次读完 EOF,第二次读到空),用途极窄但确实存在。--stdin-paths与--stdin不同:前者读的是文件名列表,每个名字会被当作真实文件打开;配合--no-filters可批量哈希而避免每个文件的过滤器开销。-w与-t的组合:git hash-object -w -t commit somefile会把文件内容当作 commit 写库——若格式不合法且未加--literally,会在fsck_buffer()阶段被拒绝(refusing to create malformed object)。
6.2 git hash-object 与 cat-file 的往返
最能说明内容寻址的实验是往返:
$ echo 'test content' | git hash-object --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
$ git hash-object -w --stdin <<< 'test content' # 落盘
$ git cat-file -t d670460b # 短前缀
blob
$ git cat-file -s d670460b
13
$ git cat-file -p d670460b
test content
读者请注意 d670460b... 这个值:它是 "blob 13\0test content\n" 的 SHA-1,是 Git 文档中出现频率最高的对象 ID 之一(13 = "test content\n" 的字节数)。你可以用 §12 的命令自行验证:把 header 与 payload 拼起来做 sha1sum,结果完全一致。
6.3 git cat-file:五种模式与批量协议
cat_one_file()(builtin/cat-file.c:133)处理单对象;批量由 struct batch_options(builtin/cat-file.c:55)驱动。模式枚举:
enum {
BATCH_MODE_CONTENTS, /* --batch:输出内容 */
BATCH_MODE_INFO, /* --batch-check:只输出元信息 */
BATCH_MODE_QUEUE_AND_DISPATCH /* --batch-command:命令队列 */
};
| 模式/选项 | 行为 | 内部路径 |
|---|---|---|
-t | 输出类型名 | odb_read_object_info_extended(..., typep, ...) |
-s | 输出大小 | 同上,sizep |
-e | 存在性检查(退出码) | odb_has_object() == OBJ_NONE 判断 |
-p | 美化打印 | 按类型分派,tree→cmd_ls_tree,blob→stream_blob |
--batch[=<format>] | 逐行读 oid,输出内容 | batch_objects()(builtin/cat-file.c:1033) |
--batch-check[=<format>] | 逐行读 oid,输出信息 | BATCH_MODE_INFO |
--batch-command | 逐行执行 info/contents/remote-object-info | batch_objects_command()(builtin/cat-file.c:924) |
--batch-all-objects | 枚举库中所有对象 | 配合 odb_for_each_object_ext |
--buffer | 输出缓冲 | batch_write()(builtin/cat-file.c:428) |
-Z | 输入输出 NUL 分隔 | 处理含换行/空格的字段 |
--unordered | 不按 oid 排序枚举 | 提升 --batch-all-objects 吞吐 |
--textconv / --filters | 对 blob 应用转换 | filter_object()(builtin/cat-file.c:101) |
--follow-symlinks | 跟随 tree 内符号链接 | filter_object 分支 |
--allow-unknown-type | 容忍未知类型 | 仅影响 -t/-s 的严格性 |
批量格式默认值:--batch 与 --batch-check 在没有显式 format 时,默认输出 %(objectname) %(objecttype) %(objectsize),--batch 再追加内容与换行。expand_data 负责把这些占位符映射到 object_info 的字段,源码中的 %(objectname)/%(objecttype)/%(objectsize) 是 expand_data 结构里预置的表达式。
2.56 新增的 remote-object-info:作为 --batch-command 的一个子命令(parse_cmd_remote_object_info(),builtin/cat-file.c:818),它允许客户端通过 protocol v2 只请求远端对象的元信息(当前是 size 与 type)而不下载对象本体。实现要点:
- 限制单次请求对象数(
MAX_REMOTE_OBJECT_INFO)与命令长度(remote-object-info command too long,builtin/cat-file.c:829); - 需显式提供 remote(否则
must supply valid remote when using remote-object-info,builtin/cat-file.c:695); - 格式占位符按服务端声明的能力动态过滤:不适用或不受支持的字段返回空串。这一设计让同一份客户端脚本能在支持与不支持
remote-object-info的服务器之间平滑降级。
6.4 git rev-parse:对象名解析
rev-parse 的对象名解析覆盖了 §4.7 的歧义消解与"peel"(解引用)逻辑。常见语法与内部动作:
| 语法 | 含义 | 内部动作 |
|---|---|---|
<hex> | 完整/短 oid | get_oid() → get_short_oid()(object-name.c:455) |
HEAD / <ref> | 引用解析 | 经 refs 子系统得 oid,再按需 peel |
<rev>^{} | 解引用到非 tag 对象 | peel_object 循环 deref_tag() |
<rev>^{tree} | peel 到 tree | disambiguate_tree_only 类过滤 |
<rev>^{commit} | peel 到 commit | disambiguate_commit_only |
<rev>:<path> | 树内路径 | 逐层 tree-walk 查找 |
<rev>@{<n>} | reflog 第 n 条 | refs reflog 读取 |
<rev>@{<date>} | 指定时间点 | reflog 时间戳查找 |
--verify <name> | 仅验证存在性 | 解析失败即非零退出 |
--short[=n] | 输出唯一短名 | repo_find_unique_abbrev_r()(object-name.c:585) |
--disambiguate=<prefix> | 列出所有匹配 | repo_for_each_abbrev()(object-name.c:547) |
rev-parse 的一个隐藏事实:它默认对 <rev> 尝试多种解释(引用名 vs oid 前缀 vs 特殊语法),顺序在 refs 代码里定义。若同名既是分支又是前缀,分支优先——这是排障时最常见的困惑来源之一。
6.5 git ls-tree 与 git show
git ls-tree <tree>:直接消费 tree 条目。内部走read_tree_at()(tree.c:14)与tree-walk,输出 mode/type/oid/name 四列。它是观察 §3.7 排序规则的最佳工具。git ls-tree -r:递归下降;--full-tree从根开始;-t显示子树本身。git show <commit>:先parse_commit_buffer()拿 tree 与父,再对 tree 做 diff(第 6 章);对 tag 则先deref_tag()。
6.6 git hash-object 的三种典型用法
- 算哈希不写库(CI 里比对产物):
git hash-object -t blob --path=src/a.c /tmp/a.c--path让过滤器按仓库规则处理(如 CRLF 归一化),从而得到"如果加进索引会得到的 oid"。 - 手动写一个对象(脚本生成 tree/commit):
tree=$(git mktree < listing) commit=$(git commit-tree $tree -m msg)commit-tree内部即commit_tree_extended()(commit.c:1729)。 - 批量从 stdin 路径哈希:
printf 'a\nb\n' | git hash-object --stdin-paths --no-filters
6.7 命令与子系统的横向对照
| 用户意图 | 命令 | 落到的对象层函数 |
|---|---|---|
| 内容 → oid | hash-object | hash_object_file |
| oid → 类型 | cat-file -t | odb_read_object_info_extended |
| oid → 大小 | cat-file -s | 同上(sizep) |
| oid → 内容 | cat-file -p | read_loose_object / pack 读 |
| 名字 → oid | rev-parse | get_oid / get_short_oid |
| oid → 短名 | rev-parse --short | repo_find_unique_abbrev_r |
| tree → 条目 | ls-tree | read_tree_at / tree-walk |
| commit → tree | show | parse_commit_buffer |
| tag → 目标 | rev-parse ^{} | deref_tag |
§7 边界条件、错误路径与常见坑
本节把散落在源码各处的"陷阱"集中成清单,每一条都标注真实报错信息或源码位置。
7.1 对象头解析的严格性坑
- 前导零非法:
"blob 010\0"会被parse_loose_header()拒绝(object-file.c:294的注释:"ie "010" is not valid")。规范化是内容寻址的前提。 "blob 0\0"合法但"blob 00\0"非法:见 §4.3 对if (size)分支的分析。空对象只允许写一个0。- 类型名未知:
type_from_string_gently(..., gentle=1)(object-file.c:290)返回负数,read_loose_object随后报unable to parse type from header(object-file.c:1226)。除非用cat-file --allow-unknown-type。 - 无空格分隔:
"blob5\0"会被判非法(找不到空格)。 - 头过长:超过
MAX_HEADER_LEN(32 字节)仍未出现 NUL →ULHR_TOO_LONG。
7.2 空对象语义坑
- 空 blob ≠ 空树。空 blob 是"零字节文件",空树是"空目录"。二者的 oid 完全不同(§2.3)。
- 目录永远不会以 blob 形式存对象:目录必然是 tree,哪怕只有一个条目。
git hash-object -t tree </dev/null会得到一个 tree 头但 payload 为空(0 条目)的对象,其 oid 恰好等于空树4b825dc6...。这常被用来"手工制造空树"。
7.3 哈希碰撞与完整性
check_collision()(object-file.c:329):当finalize_object_file_flags遇到目标已存在时逐块比对。若内容不同 →files '%s' and '%s' differ in contents。这是理论上唯一能捕获 SHA-1 碰撞的路径之一。confused by unstable object source data for %s(odb/source-loose.c:813):写入时复算 oid 与预期不符。典型诱因是 mmap 的文件在哈希期间被改写。- 读取时的
unable to unpack contents of %s/ 内容哈希不匹配(object-file.c:1239、:1245):说明磁盘上对象损坏或被人为篡改。 - 签名/校验失败不是"警告"而是"拒绝":读路径
goto out_inflate直接返回错误。
7.4 短前缀的歧义坑
MINIMUM_ABBREV:init_object_disambiguation()(object-name.c:268)要求len >= MINIMUM_ABBREV且len <= GIT_MAX_HEXSZ,否则返回 -1。过短前缀直接不被接受。- 歧义报错
short object ID ... is ambiguous:finish_object_disambiguation()(object-name.c:104)返回SHORT_NAME_AMBIGUOUS。show_ambiguous_object()(object-name.c:292)会列出候选项及类型,帮助用户加长前缀。 - 类型提示影响结果:
core.disambiguate可设为commit/tree/blob等(set_disambiguate_hint_config(),object-name.c:204)。设置后,同一前缀在"只要 commit"的语境下可能唯一。 - 不同哈希算法下的前缀不通用:在 SHA-256 仓库里用 SHA-1 的 40 字符前缀通常无意义,反之亦然。
GET_OID_HASH_ANY用于跨算法查找。
7.5 双哈希过渡期的坑
- 子模块算法不一致:
index_path()(object-file.c:1016)对 gitlink 检查&hash_algos[oid->algo] != istate->repo->hash_algo时直接报cannot add a submodule of a different hash algorithm。即一个仓库不能混合引用不同哈希算法的子模块 commit。 - 兼容 oid 依赖显式映射:读取
^{sha1}/^{sha256}记法时,若仓库没有对应映射,会失败。松散对象映射写在objects/info/loose-object-idx(loose.c),pack 则靠 transfer table。 - 不要假设 oid 长度固定:写脚本处理 oid 时应读
hash_algo->hexsz(40 或 64),硬编码 40 在 SHA-256 仓库会出错。 - repo format version:使用 SHA-256 需要
core.repositoryFormatVersion=1与extensions.objectFormat,否则仓库根本不加载该格式(见 §9)。
7.6 tree 相关的坑
- 排序错误导致 diff 错乱:如果手工构造的 tree 条目未按
base_name_compare排序,git fsck会报not properly sorted,diff 也可能产生虚假变化。 - mode 必须规范:
40000不能写成040000;100644不能写成644。parse_mode()(object.h:200)按 ASCII 读,非规范写法在 fsck 中报错。 - gitlink 的 oid 是 commit:不要试图对
160000条目做 tree-walk 下降,它没有 tree。 - 名字中的特殊字符:
tree条目名不含/,但可以含其他任意非 NUL 字节(包括\n)。因此解析 tree 不能用"按行分割",必须按 NUL 与定长 oid 走(tree-walk.c正是如此)。
7.7 大小与内存坑
xsize_t()的 die:index_fd()注释(object-file.c:947)明说"只在需要时才 xsize_t,避免大文件上不必要的 die"。当st_size超出size_t范围且路径需要它时才会die。- bigFileThreshold:默认 512 MiB。超过它且不需 convert 的 blob 走流式路径,不整块载入内存(§5.2、§8)。
- zlib 解压炸弹:恶意用户可构造高度压缩的松散对象,声称 size 巨大。Git 通过"只解压 MAX_HEADER_LEN 取头"与"按桶读取 payload"限制了即时内存,但极端的 size 声明仍可能触发大分配——这在
fsck与unpack-objects中是重点审查面。 - >4GB 对象:2.56 将相关
unsigned long改为size_t(§11),使 64 位平台能正确寻址 4GiB+ 对象。32 位平台仍受限。
7.8 文件系统坑
- 大小写不敏感 FS:macOS/Windows 默认不区分大小写。由于对象路径是十六进制(
0-9a-f),两个不同 oid 的目录名不可能仅大小写不同(hex 只用小写),所以对象层本身安全;但refs 名与此无关,是另一层。 - 硬链接/rename 失败:
finalize_object_file_flags()对 Coda、FAT 等文件系统有 rename 回退(object-file.c:409注释)。在 NFS 上link可能不可靠,core.createObject=rename可强制走 rename。 - 共享仓库权限:
adjust_shared_perm()在每个对象落盘后调用(object-file.c:453)。core.sharedRepository配置不当会导致新对象与旧对象权限不一致。 - 磁盘满:
write_in_full()失败会die_errno("unable to write loose object file")(odb/source-loose.c:740)。注意它用的是die而非error——因为此时 tmp 文件已存在,必须中止以保证一致性。
7.9 "看似正确其实错误"的清单
| 错误做法 | 后果 | 正确做法 |
|---|---|---|
用 strlen(buf) 当对象长度 | 二进制内容遇 NUL 截断 | 始终用显式 len |
| 假设 oid 是 40 字符 | SHA-256 仓库失效 | 用 hashsz/hexsz |
手工拼 "%s" 头忘记 NUL | 哈希不一致 | 用 format_object_header() |
| 对 blob 做 tree-walk | 解析失败 | 先判断类型 |
| 读 tree 时按行分割 | 名字含 \n 即崩 | 按 NUL/定长 oid |
| 硬编码空 tree oid 的 SHA-1 | 双哈希仓库出错 | 用 repo->hash_algo->empty_tree |
| 用短前缀做唯一键 | 歧义/碰撞 | 用全 oid,或先 --disambiguate 验证 |
7.10 错误返回值约定
对象层普遍遵循"C 风格整数返回":
index_fd()/index_path():失败返回负值或error()的返回值;read_loose_object():成功 0,失败负值,细节写 stderr;get_oid():返回FOUND=0/MISSING_OBJECT=-1/SHORT_NAME_AMBIGUOUS=-2(hash.h:253的enum get_oid_result);parse_commit_buffer():成功 0,损坏返回 -1(并可配合commit-graph的 generation 校验)。
理解这些约定能让你在扩展 Git 时选对"该 die 还是该 error 还是该返回错误码":用户输入错误 → 报错返回;内部不变量破坏 → die;数据损坏 → 报错并拒绝。
§8 性能特征与实测要点
对象层是整个 Git 的性能地基。本节给出可量化的特征与可验证的实测方法。
8.1 哈希吞吐:为什么哈希通常不是瓶颈
- SHA-1:纯软件实现约 500 MB/s ~ 1 GB/s/核;启用 SHA-NI(x86)后可到 2~3 GB/s/核,
sha1dc/sha1.c与硬件加速分支共同决定。 - SHA-256:纯软件约 300 MB/s ~ 800 MB/s/核;SHA-NI/SHA2 扩展下约 1.5~2 GB/s/核。
- 对比:zlib 压缩(默认级别 6)通常只有 20~60 MB/s/核,deflate 才是松散对象写入的真正瓶颈。
结论:优化对象写入应先关注压缩级别与是否落盘,而不是哈希实现。core.compression / core.looseCompression 可调级别:级别 1 换取 2~3 倍写入速度,代价是对象体积膨胀。
8.2 读路径:松散 vs pack 的成本
| 维度 | 松散对象 | pack 内对象 |
|---|---|---|
| 定位 | 目录+文件名(1 次 open) | idx 二分(O(log n)) |
| 解压 | 全文 zlib | 可能有多层 delta 链 |
| 额外开销 | 无 | delta 重建(最坏 O(链长×大小)) |
| 典型延迟 | 单对象 μs 级 | 单对象 μs 级,但需读 pack 多半页 |
| 顺序读 | 每对象独立文件,随机 I/O | 单文件顺序读,页缓存友好 |
关键取舍:对象少时松散格式更快(无索引开销),对象多时 pack 更快(缓存与局部性好)。git gc 的"打包阈值"正是基于这个权衡。第 2 章会展开 --max-delta-depth、pack.windowMemory 等参数。
8.3 内存策略:四种档位
index_core()(object-file.c:650)把输入分三档(§5.3),加上流式档共四档:
| 档 | 条件 | 内存占用 | 适用场景 |
|---|---|---|---|
| 空对象 | size == 0 | 0 | 空文件 |
| 堆读 | size <= 32 KiB | size 字节 | 小文件,避免 mmap 开销 |
| mmap | 32 KiB < size <= bigFileThreshold | 虚拟地址,物理按页 | 中大型文件,零拷贝 |
| 流式 | 大 blob 且不需 convert | 常量缓冲(几 KB) | 巨型文件/管道 |
SMALL_FILE_SIZE = 32*1024(object-file.c:648)的选择理由:mmap 有固定的系统调用与页表开销,小于 32 KiB 时"读进堆"更快。
8.4 大对象流式路径
对超过 core.bigFileThreshold 的 blob,index_fd() 走 odb_stream_from_fd()(object-file.c:962)。它不会把文件读进内存,而是边读边压缩边哈希。代价:
- 不反复随机访问:只能顺序处理,因此不适合需要多次扫描数据的场景(如重编码);
- 临时文件写入:因为 oid 未知,先写 tmp 再 rename;
- 收益:可以处理几十 GB 的文件而内存占用稳定。
实测方法(§12 会给完整命令):git hash-object -w 一个 2 GiB 文件,观察 RSS 是否远小于文件大小。
8.5 freshen 与对象缓存:避免重复工作
odb_freshen_object()(odb.c内):写库前先查是否已存在,存在则只 touch mtime。这让"重复git add"几乎零 I/O。lookup_object()/create_object():内存对象池,避免同一 oid 被解析多次。遍历提交图时命中率极高。repo_find_unique_abbrev_r()的 4 缓冲轮转(object-name.c:601):因为返回的是内部静态缓冲,调用方不能长期持有;4 个缓冲保证在printf组合多个短名时不互相覆盖。这是一个易被忽视的性能/正确性折衷点。
8.6 缩写计算的缓存与代价
odb_find_abbrev_len()(odb.c:913)每次要统计对象总数。虽然统计本身可能昂贵,但它有下界缓存与 FALLBACK_DEFAULT_ABBREV。真正的开销在"保证唯一"时的逐对象比较——get_short_oid() 会枚举前缀匹配项。短名越短越慢,因此 --short=7 在巨型仓库里可能明显慢于 --short=12。
8.7 --batch-all-objects --unordered 的吞吐
cat-file --batch-all-objects 枚举所有对象。默认会按 oid 排序输出(需要收集再排),--unordered 跳过排序,直接按后端遍历顺序流式输出,显著提升大仓库批量导出吞吐。源码路径:batch_each_object()(builtin/cat-file.c:1011)与 oq/排序逻辑。
8.8 压缩级别的实测
用同一份内容分别以不同级别写入,比较文件大小与耗时:
for lvl in 1 6 9; do
rm -rf /tmp/r && git init -q /tmp/r
time (head -c 100M /dev/urandom > /tmp/r/f
git -C /tmp/r -c core.looseCompression=$lvl hash-object -w /tmp/r/f)
du -sh /tmp/r/.git/objects
done
预期:级别越高文件越小、耗时越长;随机数据几乎不可压缩,此时级别 1 与 9 体积接近,级别 1 明显更快。这解释了为什么 core.looseCompression=1 在高频写入场景(如大 checkout)常被采用。
8.9 I/O 与页缓存
read_loose_object()用map_fd()(mmap)而非read(),让内核页缓存承担随机访问;- pack 读取用
use_pack()+ 窗口映射(第 2 章); - 冷缓存 vs 热缓存差异可达 10 倍以上,因此对对象层的 benchmark 必须报告缓存状态,否则不可比。
8.10 一次写入的耗时分解
以 git hash-object -w(松散路径) 为例,理论耗时可近似为:
T ≈ T_open + T_format_header + T_zlib_compress + T_hash + T_link + T_fsync?
T_zlib_compress通常占 60~90%;T_hash占 10~30%(取决于算法与硬件);T_fsync默认不触发(松散对象写入默认不 fsync,除非事务化的批量路径按FSYNC_COMPONENT_LOOSE_OBJECT配置)。
这解释了为什么"批量写入用 packfile(--stdin-packs/transaction)"更快:它把 N 个对象写进一个文件,减少 open/link 次数。
§9 与其他子系统的交互
对象层不是孤岛。本节梳理它与 packfile、refs、commit-graph、index、transport、fsck 以及"哈希迁移基础设施"的接口。
9.1 ODB 可插拔:source vtable
2.56 把对象数据库抽象成 struct odb_source 的 vtable(odb.h)。核心操作:
odb_read_object()/odb_read_object_info_extended()(odb.c:697):读取;odb_write_object_ext()(odb.c:982) /odb_write_object_stream()(odb.c:1024):写入;odb_has_object()/odb_freshen_object():存在性/刷新;odb_for_each_object_ext:枚举;odb_source_optimize:housekeeping。
object_database(odb_new(),odb.c:1064)持有多个 source(松散、packed、promisor 等),按优先级尝试。这意味着:新增存储后端(如云端对象存储、Rust 实现)只需实现 vtable,对象层的其余代码无需改动。这正是 odb/source-loose.c 从 object-file.c 拆出的动机。
9.2 与 packfile 的交互
- 写入:
stream_to_pack()(object-file.c:751)把流写进临时 packfile,并维护struct transaction_packfile(object-file.c:475)的written[]列表与 CRC32; - 读取:
odb_read_object_info_extended()先查松散,再查 pack(odb/source-files.c/packfile.c); - delta 基准:pack 内
OBJ_OFS_DELTA/OBJ_REF_DELTA不是真对象类型,§2.4 已说明; - idx v3 的
format_id:直接来自git_hash_algo.format_id(§2.2、§3.8),使 pack 能记录自己的哈希算法。
细节留给第 2 章,这里只需记住边界:对象层负责"一个对象的字节语义",packfile 负责"多个对象如何共享字节"。
9.3 与 refs 的交互
refs 存的是"名字 → oid"。对象层接收来自 refs 的 oid 并解析:
HEAD/分支/tag 经 refs 子系统解出 oid;- 随后
parse_object()把 oid 变成struct object; deref_tag()(tag.c:76)处理"tag 指向 tag"。
注意:peel(<rev>^{})本质是"沿 tag 链走到底",属于对象层语义,但触发它的语法解析在 object-name.c。第 4 章会讲 reftable 之后 refs 如何存 oid(含 SHA-256)。
9.4 与 commit-graph 的交互
commit-graph(第 5 章)缓存 commit 的 tree oid + 父列表 + 日期 + generation 号,从而避免解压 commit 对象。它存储的正是 parse_commit_buffer() 会产出的字段。因此:
- commit-graph 命中时,对象层不必读 commit payload;
- generation number 用于加速可达性,与对象层的
flags位域配合; - 签名信息(gpgsig)通常不在 commit-graph 中,因此需要验签时仍要读原始 commit。
9.5 与 index / cache-tree 的交互
- 写入方向:
git add用index_fd/index_path生成 blob oid 写入索引; - tree 生成方向:
git write-tree用 cache-tree 把索引直接拼成 tree 对象,不重新扫描工作树(第 3 章); - tree 排序:cache-tree 必须遵守 §3.7 的
base_name_compare规则,否则 oid 不匹配。
9.6 与 transport / partial clone 的交互
- promisor remote:当对象缺失时,
odb_read_object_info_extended可触发向 promisor 拉取(ODB_HAS_OBJECT_FETCH_PROMISOR); - protocol v2 的
remote-object-info(§6.3):只取元信息,减少传输; - partial clone 的
blob:none过滤器让 blob 按需拉取,此时对象层必须能"按需填充"。
这一层是第 7 章的主战场,但它对对象层的接口要求是:读失败要能区分"不存在"与"暂时缺失可拉取"。
9.7 与 fsck 的交互
- 写入前校验:
INDEX_FORMAT_CHECK→fsck_buffer()(object-file.c:593); - 全库校验:
git fsck遍历对象,检查格式、排序、可达性、悬空对象; - fsck 是"对象层不变量的集中体现":tree 排序、mode 规范、header 规范、哈希一致性都在这里被验证。
9.8 哈希迁移基础设施全景
这是本章最"硬核"的子系统,规格见 Documentation/technical/hash-function-transition.adoc。其组件:
| 组件 | 作用 | 关键点 |
|---|---|---|
extensions.objectFormat | 仓库主哈希算法 | sha1/sha256 |
extensions.compatObjectFormat | 兼容算法 | 过渡期使用 |
core.repositoryFormatVersion=1 | 启用扩展 | 为 0 时忽略扩展 |
objects/info/loose-object-idx | 松散对象双哈希映射 | 行格式 sha256-name SP sha1-name LF |
| transfer table(pack 内/旁) | packed 对象双哈希映射 | 供 ^{sha256} 查找 |
| pack idx v3 | 记录 format_id | 与 idx v2 不兼容 |
gpgsig-sha256 头 | 双签名字段 | 与 gpgsig 并列 |
^{sha1} / ^{sha256} | 指定算法的 peel 记法 | 解析时按算法过滤 |
四种过渡模式(规格中的 dark launch / early transition / late transition / post-transition):核心思想是让"已升级"与"未升级"的客户端能互通——要么双方都能读两种算法,要么在 fetch/push 时做双向转换。转换的有趣之处在于:blob 可以直接重哈希(内容不变),而 commit/tree 必须先"改写内部 oid"再重哈希(convert_object_file(),见 odb.c:1010)。
9.9 与 filter/convert 的交互
convert_to_git()/convert_to_git_filter_fd()在 blob 生成之前把工作树内容转成 Git 内部格式(LF、编码);- 因此同一文件在不同
.gitattributes下会得到不同 blob oid; --no-filters(hash-object)与INDEX_RENORMALIZE(object-file.h:18)控制是否应用转换;- 这解释了 §7 的坑:"本地哈希"与"入库哈希"可能不同。
9.10 交互总图
┌──────────── refs (name → oid) ────────────┐
│ │
worktree ──convert──> index ──cache-tree──> tree 对象 ──> commit
│ │ │
│ └── index_fd/index_path ─┘
│ │
└────────> ODB <──── hash_object_file
│ ├── source-loose (松散 + zlib + 双哈希映射)
│ ├── source-files / packed (idx v3)
│ └── promisor source (partial clone)
│
commit-graph / midx (加速层)
│
fsck (不变量校验)
这张图的关键结论:对象层是唯一同时被"写入侧(worktree→index→tree→commit)"与"读取侧(refs→oid→object→walk)"穿过的公共层,因此它的任何性能或正确性回归都会放大到整个 Git。
§10 历史演进:从 sha1_file.c 到可插拔 ODB
对象层的代码形态经历了四轮大重构。理解这四轮,能解释今天源码里很多"看起来多余"的抽象。
10.1 第一轮:2005 起源与 "一个文件搞定"
Git 诞生时,对象层几乎全部集中在一个文件里。最初的实现直接调用 SHA-1 函数、硬编码 20 字节哈希、把"松散对象"当作唯一后端。这一时期的特征:
- 函数名普遍带
sha1_前缀(如sha1_object_info、write_sha1_file); - 类型信息与哈希长度耦合;
- 后来该文件被命名为
sha1_file.c。
为什么会有 sha1_ 前缀? 因为在当时的设计里,"对象数据库"与"SHA-1 哈希函数"是同一件事。这种耦合在需要支持第二种哈希算法时被证明是最大的障碍。
10.2 第二轮:抽象出哈希 API(hash.h / hash.c)
随着 SHA-1 碰撞问题浮出水面,社区启动 SHA-256 迁移。这一步的先决条件是把"哈希计算"从对象层剥离:
- 新增
struct git_hash_algo(今天的hash.h:294),用 vtable 承载各算法; - 新增
struct git_hash_ctx(hash.h:277),用union容纳 SHA-1 与 SHA-256 上下文; - 引入
GIT_MAX_RAWSZ=32、GIT_MAX_HEXSZ=64,让所有 OID 都能容纳最大算法; - 给
struct object_id加algo字段(hash.h:229),使 OID 自描述。
这一轮的关键收益是类型安全:从此 "OID 是 20 字节还是 32 字节" 不再是散落的假设,而是由 algo 决定。
10.3 第三轮:文件重命名与关注点分离
随着 cache.h 被拆分,sha1_file.c 也逐渐演变为多个文件:
object-file.c:对象文件的读写与哈希;object.h/object.c:对象基类与缓存;packfile.c:pack 后端;hash.h/hash.c:哈希算法。
同时,unpack_loose_header() 与 parse_loose_header() 被拆成两步(object-file.c:177、object-file.c:271):
unpack_loose_header():只负责解压出 header 字符串(流式,限制 32 字节);parse_loose_header():只负责解析 header 语义(纯字符串,无 I/O)。
拆开的理由:让"已解压的 header 字符串"可以被复用与单元测试;也让 read_loose_object() 的流程更清晰(先 unpack 再 parse)。
10.4 第四轮:2.56 的 ODB 可插拔
2.56 把"对象数据库"进一步抽成 ODB(object database) 抽象:
struct object_database(odb.c:1064的odb_new())持有多个struct odb_source;- 松散后端独立成
odb/source-loose.c; - packed 后端重构为正式
struct odb_source; - ODB 事务(
struct odb_transaction)统一批量写入。
详见 §11。
10.5 双哈希迁移的历史脉络
SHA-1→SHA-256 迁移的设计文档 Documentation/technical/hash-function-transition.adoc 是理解这一演进的核心。其历史要点:
- 2017 年 SHAttered 公开 SHA-1 碰撞,社区开始规划迁移;
- 迁移设计选择"仓库级别算法"而非"逐对象算法",因为逐对象会魔改所有现有工具;
- 为兼容旧客户端,设计了 compat oid:主算法 SHA-256 的仓库同时记住 SHA-1 oid;
- 为此定义 pack idx v3(携带
format_id)、transfer table(pack 内 oid 映射)、loose-object-idx(松散映射); - 定义
gpgsig-sha256头,让双签名为两种算法各留一份; - 定义
^{sha1}/^{sha256}记法,允许从命名对象按指定算法取 oid。
四种操作模式(文档中称为 dark launch / early transition / late transition / post-transition)对应"是否已在客户端侧换算法、是否已在服务端侧换算法"的组合,核心问题是:在过渡期,谁来负责转换 oid。这段历史解释了为什么今天代码里有 repo->hash_algo 与 repo->compat_hash_algo 两个字段,以及 odb_write_object_ext() 里那段 compat 分支(odb.c:1001)。
10.6 缩写算法与命令的历史
hash-object的--literally选项在早期加入,用于故意写入非法对象(测试与救援场景);git_zstream从直接使用 zlib 的z_stream封装为git-zlib.h(git_zlib前缀函数),以统一错误处理与初始化;cat-file --batch的默认格式经历优化:2.56 中默认路径改用strbuf_add_oid_hex()与strbuf_add_uint()替代strbuf_addf()(RelNotes 2.56.0,见 §11),因为strbuf_addf的格式化开销在批量输出时很显著;- 缩写长度计算从"逐对象比较"演进为"基于对象总数的统计下界 + 按需加长",见
odb_find_abbrev_len()(odb.c:913)。
10.7 从"松散优先"到"事务化写入"
早期每次 git add 都独立落盘一个松散对象;大规模导入(git fast-import、git fetch 的 pack 应用)因此产生海量小文件与大量 open/link 系统调用。演进方向是:
- 批量写 pack:把多个对象先写进临时 packfile(
stream_to_pack()),再统一提交; - ODB transaction(2.56 强化):把"开始事务 → 写对象 → 提交"显式化,
odb_transaction_files_write_object_stream()(object-file.c:882)正是这个模型的实现; already_written()去重(object-file.c:679):事务内避免重复写同一 oid,用hashfile_truncate()回滚未采用的字节。
这一演进的结果是:对象层的写路径从"每对象一次系统调用"变成"每事务一次 flush",直接改善了 fetch/import 的吞吐。
10.8 struct object_id 字段的历史变化
早期 struct object_id 只有 hash[20](固定 SHA-1)。迁移后变为:
struct object_id {
unsigned char hash[GIT_MAX_RAWSZ]; /* 从 20 扩到 32 */
uint32_t algo; /* 新增:算法号 */
};
这次改动的影响是全局的:所有 memcpy(oid, ..., 20) 的写法都不再安全,必须改用 oidcpy()/oidread()/hashcpy()(hash.h 内联族)。这也是为什么今天代码里到处强调"不要假设 rawsz"。
Rust 侧的 ObjectID(src/hash.rs)用 #[repr(C)] 精确镜像了这一布局,因此 C 与 Rust 可以零拷贝交换 OID。2.56 还修复了 Rust 侧 CryptoHasher 的 Clone 未初始化上下文与 Drop 未释放上下文的问题(RelNotes 2.56.0),使 FFI 层与 C 侧生命周期一致。
§11 2.56.0 的具体变化
本节逐条列出 v2.56.0 在对象模型与哈希层的变化,均来自 Documentation/RelNotes/2.56.0.adoc 与对应源码。
11.1 松散对象写入迁出 object-file.c
RelNotes 原文:
The logic to write loose objects has been refactored and moved from
object-file.cto the loose backend source fileodb/source-loose.c, making the loose backend more self-contained. This is achieved by first refactoringforce_object_loose()to use generic ODB write interfaces instead of loose-backend internals.
影响:
- 所有松散写入函数(
write_loose_object、odb_source_loose_write_object、odb_source_loose_write_object_stream、start/write/end_loose_object_common、close_loose_object、create_tmpfile)现在位于odb/source-loose.c(行号见 §5.1); object-file.c保留的是跨后端编排(index_fd、finalize_object_file_flags、read_loose_object、hash_object_file)与旧的兼容入口;- 动机是"让松散后端自包含",为未来其他后端(如内存后端、Rust 后端)树立样板。
这是本章 §5 走读路径在 2.55 与 2.56 之间最大的结构差异。
11.2 odb_stream 统一读写流
RelNotes:
The
struct odb_read_streamandstruct odb_write_streamstructures have been consolidated into a single unifiedstruct odb_streamstructure, simplifying object database streaming APIs and enabling streaming of arbitrary object types.
影响:
- 此前读流与写流是两套类型;现在统一为
struct odb_stream,携带type、size与读取/关闭操作; index_fd()中的odb_stream_from_fd(fd, size, OBJ_BLOB)(object-file.c:962)、odb_stream_close()、odb_stream_blob_to_fd()都基于统一结构;- 价值:任意对象类型(不再只有 blob)都能流式处理,减少大对象整体载入。
11.3 ODB housekeeping 可插拔
RelNotes:
Object database housekeeping in
git gcandgit maintenancehas been refactored to be pluggable. The files-backend-specific logic, including incremental and geometric repacking as well as object pruning, has been moved out of the command implementation and into the files object database source.
影响:git gc/git maintenance 不再直接调用 files 后端内部函数,而是通过 odb_source_optimize(odb.c:1031)这一 vtable。对第 8 章(libification)而言,这是"用接口替换实现依赖"的又一实例。
11.4 reprepare() → prepare() 与 odb_prepare()
RelNotes:
The
reprepare()callback for object database sources has been generalized into aprepare()callback with an optional flush cache flag, and a newodb_prepare()wrapper has been introduced to allow pre-opening object database sources.
影响:get_short_oid()(object-name.c:455)在首次枚举失败后曾调用 odb_reprepare() 重试;现在统一为 odb_prepare(),并可预先打开 source(减少首次访问延迟)。这属于缩写/歧义解析路径的接口更新。
11.5 struct object_info 去 whence、增 object_info_source
RelNotes:
The
whencefield instruct object_infohas been removed. The backend-specific object information retrieval has been refactored into an opt-instruct object_info_sourcestructure.
影响:odb_read_object_info_extended()(odb.c:697)不再把"对象来自哪个后端"硬塞进 object_info;需要后端细节的调用方显式请求 object_info_source。这是"消除后端泄漏"的延续(见 §11.8)。
11.6 大小 > 4GB 的处理改用 size_t
RelNotes:
Support for hashing loose or packed objects larger than 4GB on Windows and other LLP64 platforms has been improved by converting object header buffers and data-handling functions from
unsigned longtosize_t.
影响:
format_object_header()的objsize参数与hash_object_file()的len参数改为size_t(object-file.c:102、object-file.c:459);- 这是 Windows(LLP64:
long仍 32 位)上处理 >4GiB 对象的前提;Linux(LP64)上unsigned long恰好 64 位,因此问题只在 Windows 暴露; - 迁移提示:写扩展代码时,对象大小与偏移一律用
size_t/off_t,不要用unsigned long。
11.7 git_hash_* 包装统一 + git_hash_discard 幂等
RelNotes:
The
git_hash_*()wrappers have been updated to be used consistently across the codebase instead of direct calls to members ofstruct git_hash_algo, andgit_hash_discard()has been made idempotent to simplify cleanups.
影响:
- 调用方应使用
git_hash_init/clone/update/final/final_oid/discard(hash.h:343~hash.h:364),而非直接algo->init_fn(...); git_hash_discard()幂等意味着可以在"提前返回"与"正常结束"两条路径上重复调用而不出错,简化了错误清理(这也修复了非默认后端下的内存泄漏,RelNotes 另有条目提到 k/hash-algo-leak-fixes);- Rust 侧相应修复:
CryptoHasher::clone()先初始化上下文再克隆,Drop调用 discard。
11.8 ODB 区分"缺失"与"损坏"
RelNotes:
The object database (odb) API has been refactored to distinguish between missing objects and corrupt ones by returning more descriptive error statuses. Both the packed and loose backends now faithfully propagate error details using a generic strbuf error mechanism, removing backend-specific leakage from central lookup paths.
影响:odb_read_object_info_extended() 的返回状态更有表达力;调用方(如 cat-file)能给出"对象不存在"还是"对象损坏"的准确信息。这是"错误路径"(§7)在 2.56 的专门改进。
11.9 packed source 重构为 struct odb_source
RelNotes:
The packed object source has been refactored into a proper
struct odb_source.
影响:pack 后端与松散后端现在对称地实现同一 vtable,odb.c 的枚举/查找逻辑可以对两者一视同仁。这是 ODB 可插拔真正落地的一步。
11.10 cat-file --batch 默认格式性能优化
RelNotes:
The default format path of
git cat-file --batchhas been optimized to usestrbuf_add_oid_hex()andstrbuf_add_uint()instead ofstrbuf_addf(), yielding a noticeable speedup.
影响:批量输出 %(objectname) %(objecttype) %(objectsize) 时避免 printf 式格式化,直接追加 hex 与无符号整数。对 --batch-all-objects 这类大输出场景收益明显。
11.11 remote-object-info 命令
RelNotes:
The
remote-object-infocommand has been added togit cat-file --batch-command, allowing clients to request object metadata (currently size) from a remote server via protocol v2 without downloading the entire object. Format placeholders are dynamically filtered on the client based on server-advertised capabilities, returning empty strings for inapplicable or unsupported fields.
随后又有一条:
The
remote-object-infocommand forgit cat-file --batch-commandhas been extended to support the%(objecttype)placeholder.
影响:对象层的"元信息读取"扩展到远端。内部路径见 §6.3(parse_cmd_remote_object_info(),builtin/cat-file.c:818)。注意其约束:单次请求对象数上限、命令长度上限、必填 remote。
11.12 Rust 首次作为一等构件随源码发布
2.56 中 src/ 下的 Rust 代码(src/lib.rs 声明 csum_file、hash、loose、varint 四个模块)与对象层直接相关:
src/hash.rs:ObjectID(#[repr(C)]镜像 C 布局)、HashAlgorithm枚举(含from_u32/from_format_id/raw_len/hex_len/block_size/empty_blob/empty_tree/null_oid/hash_algo_ptr)、CryptoHasher(通过 FFI 调用git_hash_alloc/init/update/final/final_oid/discard/free,并实现Write/Clone/Drop)、CryptoDigesttrait;src/loose.rs:ObjectMap/ObjectMemoryMap/MmapedObjectMap,实现松散对象双哈希映射,含LMAP签名、VERSION_NUMBER=1、NUM_OBJECT_FORMATS=2、binary_search_slice、find_short_name_len、required_nul_padding。
关键事实:Rust 侧不是"重新实现哈希",而是通过 FFI 复用 C 的哈希实现(CryptoHasher 持有 C 侧上下文指针)。这保证了 C 与 Rust 计算出完全一致的 oid——这是内容寻址系统里不可妥协的正确性要求。第 8 章会展开 FFI/ABI 与构建(meson/Cargo 二选一)。
11.13 其它相关项
git receive-pack改用 ODB transaction 接口写收到的 packfile(RelNotes),使服务端写路径与本地一致;- commit-graph 相关(第 5 章):
git repack --write-commit-graph等交互受 ODB 重构影响; git repo info新增gitdir/commondir的绝对与相对路径(依赖从rev-parse提取的路径格式化助手);- 若干 NULL 指针与 fd 加固(Coverity),涉及对象读取路径。
11.14 迁移提示(面向扩展开发者)
若你维护 fork 或扩展,2.56 的下列变化需要跟随:
- 不要调用松散后端的内部函数(
write_loose_object等已移入odb/source-loose.c且多为static);改用odb_write_object()/odb_write_object_stream(); - 不要用
algo->init_fn直接分派;改用git_hash_*包装; - 不要假设
struct object_info.whence存在;需要后端信息请用object_info_source; - 对象大小/偏移用
size_t/off_t; - 读流/写流统一用
struct odb_stream; git_hash_discard()可以重复调用,但仍应在所有提前返回路径上调用它以避免非默认后端泄漏。
§12 实操实验(可复制)
本节所有命令都可直接粘贴。默认在 POSIX shell(Linux / macOS / Git Bash)下运行。若某些工具缺失,文末给出 Python 替代。
12.1 亲手验证内容寻址公式
要验证 oid = SHA1("blob <len>\0" + content),用 printf 精确构造字节流(bash 内建 printf 支持 \0 输出 NUL):
$ printf 'blob 13\0test content\n' | sha1sum
d670460b4b4aece5915caf5c68d12f560a9fe3e4 -
$ echo 'test content' | git hash-object --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
两者必须一致。13 正是 test content + 换行 = 13 字节。
注意换行:echo 'test content' 会追加一个 \n;printf 'test content' 不会。若你用 printf 但不加 \n,长度应为 12:
$ printf 'blob 12\0test content' | sha1sum
(得到另一个 oid,与 git hash-object --stdin < <(printf 'test content') 相同)
12.2 验证空对象常量
$ printf 'blob 0\0' | sha1sum
e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -
$ printf 'tree 0\0' | sha1sum
4b825dc642cb6eb9a060e54bf8d69288fbee4904 -
这两个 oid 与 §2.3 表格完全吻合。
12.3 解压松散对象,直击 zlib 包裹与 header
$ rm -rf /tmp/demo && git init -q /tmp/demo && cd /tmp/demo
$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
$ ls .git/objects/d6/
70460b4b4aece5915caf5c68d12f560a9fe3e4 # 目录名=d6,文件名=剩余38位
$ # 看 zlib 头两字节(78 01 或 78 9c)
$ xxd -l 2 .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
$ # 解压并显示(含 NUL,用 cat -v 观察)
$ python3 -c "import zlib,sys; d=zlib.decompress(open(sys.argv[1],'rb').read()); sys.stdout.buffer.write(d)" \
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 | cat -v
blob 13^@test content
^@ 是 cat -v 对 NUL 的显示。你可以清楚看到 blob 13 + NUL + test content\n。
12.4 验证 Adler-32 校验
zlib 流末尾 4 字节是大端 Adler-32,对未压缩数据计算:
$ python3 - <<'PY'
import zlib, struct, sys
p = ".git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4"
raw = open(p, "rb").read()
stored = int.from_bytes(raw[-4:], "big")
data = zlib.decompress(raw)
print("stored adler32 =", hex(stored))
print("computed =", hex(zlib.adler32(data)))
print("header+payload=", data)
PY
12.5 观察 tree 的二进制布局与排序
先建立一个混合目录:一个文件、一个子目录、一个可执行文件、一个符号链接。
$ cd /tmp/demo
$ printf 'a\n' > a.txt && mkdir sub && printf 'b\n' > sub/b.txt
$ printf '#!/bin/sh\n' > run.sh && chmod +x run.sh
$ ln -s a.txt link
$ git add -A && tree=$(git write-tree)
$ git ls-tree $tree
100644 blob <oid> a.txt
100755 blob <oid> run.sh # 可执行位体现在 mode
120000 blob <oid> link # 符号链接也是 blob
040000 tree <oid> sub # 目录被打包成 tree
$ # 逐字节 dump(前若干字节)
$ python3 -c "import zlib,sys; d=zlib.decompress(open(sys.argv[1],'rb').read()); sys.stdout.buffer.write(d)" \
.git/objects/${tree:0:2}/${tree:2} | xxd | head
你会看到正好是 mode SP name NUL <20 字节 raw oid> 的重复模式,且条目名一个接一个,没有换行分隔。
排序验证:git ls-tree 的输出顺序就是 base_name_compare 的结果。把目录名与同名文件比较,观察 §3.7 的 '\0' < '/' 规则。
12.6 观察 commit 与 tag 的文本布局
$ cd /tmp/demo
$ git -c user.name=A -c user.email=a@e commit -q -m 'first' && c=$(git rev-parse HEAD)
$ git cat-file -p $c
tree <tree-oid>
author A <a@e> 1760000000 +0800
committer A <a@e> 1760000000 +0800
first
$ last=$(git cat-file -p $c | grep '^committer' | sed 's/committer //')
$ git tag -a v1 -m 'release 1' $c
$ git cat-file -p v1
object <c-oid>
type commit
tag v1
tagger A <a@e> ...
release 1
cat-file -p 对 commit/tag 输出的是去掉 header 后的 payload——因为 payload 是文本,直接打印即可。
12.7 建立一个 SHA-256 仓库并验证其空对象
$ rm -rf /tmp/s256 && git init -q --object-format=sha256 /tmp/s256 && cd /tmp/s256
$ git rev-parse --show-object-format
sha256
$ printf 'blob 0\0' | sha256sum
473a0f4c3be8a93681a267e3b1e9a7dcd1185436fe141f7749120a303721813 -
$ printf 'tree 0\0' | sha256sum
6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321 -
与 §2.3 的 SHA-256 常量一致。注意 git hash-object --stdin 在此仓库会输出 64 字符 oid。
12.8 双哈希兼容仓库
$ rm -rf /tmp/compat && git init -q --object-format=sha256 /tmp/compat
$ cd /tmp/compat
$ git config extensions.compatObjectFormat sha1
$ echo hi | git hash-object -w --stdin # 主算法 sha256
$ ls .git/objects/info/ # 可能生成 loose-object-idx
$ cat .git/objects/info/loose-object-idx 2>/dev/null
# 每行:<sha256-name> <sha1-name>
若映射文件存在,你会看到 §9.8 所述的 sha256-name SP sha1-name LF 文本行。
12.9 短前缀与歧义消解
$ cd /tmp/demo
$ git rev-parse --short=4 HEAD
a1b2 # 4 字符(可能被判歧义后自动加长)
$ git rev-parse --disambiguate=a1b2 # 列出所有匹配(若有)
$ git rev-parse 'HEAD^{tree}' # peel 到 tree
$ git rev-parse 'HEAD^{commit}' # 仍解析到 commit
若某前缀有多个匹配,git rev-parse <prefix> 会报 short object ID <pfx> is ambiguous 并列出候选(show_ambiguous_object(),object-name.c:292)。
12.10 过滤器导致的哈希差异
$ cd /tmp/demo
$ printf 'a\r\nb\r\n' > crlf.txt
$ sha=$(git hash-object crlf.txt) # 不加 --path:按原字节
$ sha2=$(git hash-object --path=crlf.txt crlf.txt)
$ echo "$sha vs $sha2"
若仓库配置或 .gitattributes 启用了 CRLF 归一化,sha2 会不同——这正是 §7.9 "本地哈希 ≠ 入库哈希" 的实测。
12.11 大文件的流式哈希与内存占用
$ head -c 2G /dev/zero > /tmp/big.bin
$ # 观察 RSS(Linux 用 /usr/bin/time -v;macOS 用 time -l)
$ /usr/bin/time -v git -c core.bigFileThreshold=1m hash-object -w /tmp/big.bin 2>&1 | grep -E 'Maximum resident'
预期最大 RSS 远小于 2 GiB(流式路径只占用常量缓冲),而 -c core.bigFileThreshold=100g 会走高内存路径。
12.12 压缩级别对体积/耗时的影响
$ cd /tmp/demo
$ head -c 50M /dev/urandom > rnd.bin
$ for lvl in 1 6 9; do
rm -rf /tmp/z && git init -q /tmp/z
t0=$(date +%s.%N)
git -C /tmp/z -c core.looseCompression=$lvl hash-object -w rnd.bin
t1=$(date +%s.%N)
sz=$(du -sb /tmp/z/.git/objects | cut -f1)
echo "level=$lvl time=$(echo "$t1-$t0"|bc)s size=$sz"
done
12.13 批量模式的吞吐对照
$ cd /tmp/demo
$ git rev-list --objects --all | cut -d' ' -f1 > /tmp/oids
$ time (cat /tmp/oids | git cat-file --batch-check > /dev/null)
$ time (cat /tmp/oids | git cat-file --batch-check=%(objectsize) > /dev/null) # 更窄的输出
$ time (git cat-file --batch-all-objects --unordered --batch-check > /dev/null)
--unordered 通常明显快于默认排序输出(§8.7)。
12.14 用 fsck 观察对象层不变量
$ cd /tmp/demo
$ git fsck --strict --full
$ # 手工破坏一个 tree 的排序,观察报错
$ git fsck 2>&1 | head
--strict 会启用更严格的检查(如 tree 排序、mode 规范),是 §7.6 坑位的自动化验证器。
12.15 工具缺失时的 Python 替代
若无 xxd/sha1sum:
import hashlib, zlib, sys
p = sys.argv[1]
raw = open(p, "rb").read()
print("zlib header:", raw[:2].hex())
data = zlib.decompress(raw)
print("inflated :", data)
print("oid :", hashlib.sha1(data).hexdigest())
§13 扩展与二次开发
对象层是 Git 中最容易被扩展的部分之一。本节给出四条可操作的扩展路径。
13.1 编写一个独立读取松散对象的工具
最小实现需要三步(对应 §5.9 的流程):
/* 伪代码骨架,基于真实 API */
int fd = git_open(path);
void *map = map_fd(fd, path, &mapsize);
git_zstream stream;
char hdr[MAX_HEADER_LEN]; /* object-file.h:11 */
struct object_info oi = OBJECT_INFO_INIT;
if (unpack_loose_header(&stream, map, mapsize, hdr, sizeof(hdr)) != ULHR_OK)
return -1; /* ULHR_BAD / ULHR_TOO_LONG */
if (parse_loose_header(hdr, &oi) < 0)
return -1;
/* oi.typep / oi.sizep 已被填充 */
void *contents = unpack_loose_rest(&stream, hdr, *oi.sizep, &expected_oid);
要点:先只解压 32 字节取 header(MAX_HEADER_LEN),需要内容时再 unpack_loose_rest()。这就是"惰性读取"的正确姿势。
13.2 实现一个新的 odb_source
struct odb_source 的 vtable 是 2.56 的扩展点。你需要实现的核心回调(名称为源码中的语义):
read_object/read_object_info:读取与元信息;write_object/write_object_stream:写入;has_object/freshen_object:存在性与刷新;for_each_object:枚举(可选,用于--batch-all-objects、fsck);optimize:housekeeping(可选);prepare:预打开与缓存 flush(odb_prepare(),§11.4)。
参考实现:odb/source-loose.c(松散 + zlib + 双哈希映射)与重构后的 packed source。松散后端的实现顺序建议:先做写(create_tmpfile → git_deflate → finalize_object_file_flags),再做读(unpack_loose_header → unpack_loose_rest → 复算 oid)。
一个必须遵守的不变量:写入后必须能读回相同字节并复算出相同 oid。这是内容寻址的生命线,也是 write_loose_object() 里 parano_oid 复核的意义(§5.5)。
13.3 通过 FFI 在 Rust 中使用哈希
Rust 侧(src/hash.rs)提供:
// C 侧布局的镜像
#[repr(C)]
pub struct ObjectID { pub hash: [u8; 32], pub algo: u32 }
// 包装 C 的 git_hash_* —— Clone 先 init 再 clone,Drop 调用 discard
pub struct CryptoHasher { /* ... */ }
impl Write for CryptoHasher { /* update */ }
impl CryptoDigest for CryptoHasher { /* final_oid */ }
HashAlgorithm 枚举的 from_u32 / from_format_id 对应 C 的 hash_algo_by_id / format_id;empty_blob / empty_tree / null_oid 直接读取 C 表(§2.3),因此 Rust 不会算错空对象常量。
关键约束:不要用 Rust 重写哈希算法。Git 的历史对象依赖特定实现(含 sha1collisiondetection),Rust 重写会导致 oid 不一致。正确做法是通过 CryptoHasher 复用 C 实现。
13.4 使用 ODB transaction 批量写入
struct odb_transaction *tx;
odb_transaction_begin_or_die(odb, &tx, 0);
for (each object) {
struct odb_stream *s = odb_stream_from_fd(fd, size, OBJ_BLOB);
odb_transaction_write_object_stream(tx, s, &oid);
odb_stream_close(s);
}
odb_transaction_commit_and_finalize_or_die(tx);
这条路径(index_fd 中的大 blob 分支正是如此,object-file.c:967)会把对象先写进临时 packfile(stream_to_pack(),object-file.c:751),提交时统一落盘并生成 idx。相比逐对象写松散,显著减少系统调用与文件数。
13.5 接入哈希迁移的扩展点
若你要实现"双哈希仓库的第三方工具",需要处理的三个映射来源:
- 松散对象:
objects/info/loose-object-idx(文本行格式,loose.c的repo_loose_object_map_oid(),loose.c:229与repo_add_loose_object_map(),loose.c:214); - packed 对象:transfer table(pack 内或旁路,见迁移规格);
- 内存索引:Rust 的
MmapedObjectMap(src/loose.rs,LMAP签名、binary_search_slice)。
转换规则(convert_object_file(),odb.c:1010 调用):
- blob:内容不变,直接重哈希;
- tree/commit/tag:把内部 oid 从源算法改写为目标算法(tree 的 raw oid 需换长度;commit 的
tree/parent行需换 hex 长度),再重哈希。
13.6 大概率不会做但你该知道的事
- 新增哈希算法(如 BLAKE3):理论上只需新增
hash_algos[]条目与实现六个函数指针,但实际不可行,因为仓库格式、pack idx、签名、协议都假设"要么 sha1 要么 sha256"。format_id数据结构上支持任意四字节,工程上却不支持。 - 改变对象头格式:绝无可能向后兼容;
parse_loose_header的严格性(§7.1)就是为此。 - 去掉
algo字段:会让所有 OID 比较失去算法维度,在双哈希下产生正确性 bug,因此这个字段会长期存在。
13.7 扩展时的测试建议
Git 自带测试框架(t/)。新增对象层功能时,建议至少覆盖:
t/t1007-hash-object.sh(hash-object 各选项);- 与
t/t1013-read-tree-submodule.sh、t/t1450-fsck.sh交互; - SHA-256 相关在
t/下有独立目录/前缀(如t/oid相关); - 务必验证"写后读回哈希一致",这是对象层的黄金不变量。
§14 设计取舍与替代方案对比
对象模型的每一个决定都是取舍的结果。本节把关键取舍摆上台面,并对比"另一种做法为什么会输"。
14.1 内容寻址 vs 文件名寻址
| 维度 | 内容寻址(Git) | 文件名寻址(如传统 VCS/文件服务器) |
|---|---|---|
| 身份 | 由内容决定 | 由路径决定 |
| 去重 | 天然全局去重(同内容=同 oid) | 需额外机制 |
| 改名 | 零成本(blob 不变) | 需元数据操作/历史追踪 |
| 篡改检测 | 内建(任何改动改 oid) | 需额外校验 |
| 寻址成本 | 需哈希计算 | 直接按路径 |
| 不可变性 | 强(对象不可改) | 弱 |
Git 选了内容寻址,代价是改名历史不可直接查询(必须靠 diff 的 rename detection 猜,见第 6 章);收益是全局去重、内建完整性、无锁并发写(不同内容不会写同一路径,天然无冲突)。
14.2 为什么类型与长度要进入哈希
若 oid = HASH(payload)(不含类型与长度),会出两个问题:
- 类型歧义:一段文本既可以是 blob 也可以是 commit 的 message 部分,同一个哈希会指向两种语义;
- 长度可被"隐藏":不同长度前缀的组合可能产生相同字节流的错觉(分级边界),无法可靠切分。
把 <type> <size>\0 纳入哈希后,哈希本身即编码了边界,解析器无需外部信息即可切分。代价是 header 冗余(每次都要算一遍类型字符串),收益是自描述。
14.3 为什么 header 用 ASCII 十进制而非二进制
- 人可读:
git cat-file -p与非压缩对象 dump 时可直接看到blob 13; - 跨平台:无字节序问题;
- 代价:多几个字节、需解析十进制。
二进制写法(如 4 字节 length prefix)体积更小,但会让对象头在调试时不可读,且需要定义字节序。Git 选可读性。
14.4 为什么 tree 的 mode 用 ASCII、oid 用 raw
这是"可读性优先"与"紧凑优先"的有意混合:
- mode ASCII(
100644):便于调试、避免定义 mode 的二进制编码; - name NUL 分隔:无需长度前缀,零成本;
- oid raw:每个条目省约一半字节(raw 20 vs hex 40),且 tree 是海量存在的最热对象——紧凑收益巨大。
对比 commit 整体用 hex oid:因为 commit 数量远少于 tree 条目,且 commit 常被文本工具处理,可读性更重要。
14.5 哈希算法选择:SHA-1 / SHA-256 / 其它
| 算法 | raw 长度 | 碰撞抗性 | Git 支持 | 备注 |
|---|---|---|---|---|
| SHA-1 | 20 | 已被攻破(SHAttered) | 默认(历史) | 靠 sha1collisiondetection 缓解 |
| SHA-256 | 32 | 目前安全 | 支持(实验性) | 迁移目标 |
| SHA-512/256 | 32 | 安全 | 不支持 | 无必要 |
| BLAKE2/BLAKE3 | 32 | 安全且更快 | 不支持 | 会破坏历史对象兼容 |
Git 选择 SHA-256 而非 SHA-3/BLAKE3 的理由:题目是"替换 SHA-1 且最小化过渡成本",而 SHA-256 与 SHA-1 结构相近、库支持广泛、无长度前缀歧义。速度不是主导因素(§8.1 已说明压缩才是瓶颈),所以没有理由选更激进的函数。
替代方案(如"逐对象算法")被否决:那会让同一仓库中 oid 长度不固定,破坏所有假设 40 字符的工具链,且拓扑排序、pack idx 都要特判。
14.6 仓库级算法 vs 逐对象算法
- 仓库级(Git 的选择):
extensions.objectFormat决定整库算法,struct object_id.algo只是记录"这个 OID 属于哪个算法",正常仓库下全库一致; - 逐对象:理论上更灵活,实际上灾难——图上的父子关系会跨越算法,verification 与传输都需逐边转换。
仓库级 + compat oid 的组合,是"最小改动实现迁移"的折衷:绝大多数代码仍假设"一个仓库一个算法",只有过渡期边界需要处理两套 oid。
14.7 双哈希过渡 vs 硬切换
| 方案 | 优点 | 缺点 | Git 是否采用 |
|---|---|---|---|
| 硬切换(一次性换 SHA-256) | 简单直接 | 所有客户端/服务器必须同时升级;巨库重写代价高 | 否 |
| 双哈希过渡 | 平滑、可灰度 | 实现复杂(idx v3/transfer table/loose map/双签) | 是 |
| 长生命周期双哈希(永久) | 无需迁移 | 存储与计算开销永久翻倍 | 否(过渡期后去掉 compat) |
双哈希的复杂度集中体现在:任何"由 oid 反推内容"或"由内容反推 oid"的地方,都要问一句"用哪个算法"。这也是 §9.8 那套基础设施存在的原因。
14.8 为什么保留松散对象(不总是 pack)
- 松散:写
O(1)、无索引维护、损坏影响面小(单对象)、易调试; - pack:空间效率高、读局部性好,但写入需重打包、索引需更新。
若只用 pack,每次 git add 都可能触发重打包,代价不可接受。若只用松散,海量小文件会拖垮文件系统。因此 Git 保留双后端 + 自动打包(gc/repack)。2.56 的 ODB 可插拔让"第三种后端"成为可能(§13.2)。
14.9 压缩算法:zlib vs 其它
- zlib(deflate):Git 从一开始就用它,因为跨平台、成熟、解压快;
- 替代:zstd 压缩比/速度更好,但 Git 的 pack/松散格式已把 zlib 写进格式定义(松散对象本身就是裸 zlib 流),更换会破坏兼容;
- 折衷:保留 zlib,但允许调级别(
core.compression)。
这是典型的"格式一旦发布就冻结"案例。除非像哈希那样整体版本化(idx v3),否则不能改。
14.10 ASCII mode 的坑与收益
mode 用 ASCII 带来的直接后果是:解析必须按"读到空格"切分,且需要处理前导零规范(§7.6)。收益是 mode 在 xxd/git ls-tree 中可直接看出。若用二进制 4 字节 mode,tree 会更紧凑,但调试成本上升。Git 再次选择可读性。
14.11 缩写 vs 完整 oid
- 完整 oid:唯一、无歧义、长(40/64 字符);
- 缩写:短、可读,但可能歧义,且"最短长度"随仓库增大而变化。
Git 的选择是动态:默认输出 DEFAULT_ABBREV(通常 7),但在歧义时自动加长(repo_find_unique_abbrev_r(),object-name.c:585)。代价是性能(§8.6)与非确定性(同一前缀在不同仓库含义不同)。这也解释了 --short 为何"看起来不稳定"。
14.12 惰性解析 vs 立即解析
- 惰性(Git 的选择):
maybe_tree可能为 NULL、tagged惰性、treebuffer惰性——只在需要时解析; - 立即:读对象时就把一切解好。
惰性的收益巨大:git log --oneline 不需要任何 tree;git fetch 落地 pack 时完全不解对象。代价是代码中到处要"先确保解析",以及 maybe_tree 这类命名带来的心智负担。
14.13 取舍总表
| 决定 | 选择 | 主要收益 | 主要代价 |
|---|---|---|---|
| 身份 | 内容哈希 | 去重/完整性 | 改名不可查 |
| hash 输入 | 含 type+len | 自描述/可切分 | 冗余字节 |
| header | ASCII 十进制 | 可读/跨平台 | 解析开销 |
| tree oid | raw | 紧凑 | 需知道算法 |
| commit oid | hex | 可读 | 体积 |
| 算法 | 仓库级 SHA-256 | 最小迁移改动 | 过渡基建复杂 |
| 迁移 | 双哈希 | 平滑 | 复杂+短期双倍开销 |
| 存储 | 松散+pack | 写快+省空间 | 双后端维护 |
| 压缩 | zlib | 兼容 | 非最优压缩 |
| 解析 | 惰性 | 性能 | 心智负担 |
| oid 显示 | 动态缩写 | 简洁 | 非确定/慢 |
§15 小结与延伸阅读
15.1 一句话总结
Git 的对象模型 = "把内容算成名字"的不可变数据库 + "用名字组成结构"的有向无环图。 第 1 章拆解的是这套模型的字节层与代码层实现。
15.2 回填本章的十个问题
object_id结构:hash[32] + algo(u32),OID 自描述算法(§2.1);- 四种对象布局:blob 裸字节、tree
mode SP name NUL raw-oid、commit/tag 文本行(§3); - 算法细节:
hash_object_file线性、parse_loose_header手写状态机拒绝前导零、tree 排序按'\0'<'/'(§4); - 写入调用链:
hash-object→index_fd→index_core/mem→odb_write_object_ext→source-loose→write_loose_object→finalize(§5); - 命令映射:
hash-object/cat-file/rev-parse/ls-tree的选项逐条对应内部步骤(§6); - 边界与坑:非法 header、空对象语义、碰撞检测、
confused by unstable...、短前缀歧义、子树排序、>4GB(§7); - 性能:zlib 是瓶颈而非哈希、四档内存策略、流式大对象、freshen 短路(§8);
- 子系统交互:ODB vtable、packfile、refs、commit-graph、index/cache-tree、transport/promisor、fsck、哈希迁移基建(§9);
- 历史演进:
sha1_file.c→ 哈希 API 抽象 → 关注点分离 → ODB 可插拔;双哈希迁移脉络(§10); - 2.56 变化:松散写入迁
odb/source-loose.c、odb_stream统一、ODB housekeeping 可插拔、prepare()、object_info_source、size_t、git_hash_*统一与 discard 幂等、缺失/损坏区分、packed source 重构、remote-object-info、Rust 首发布(§11)。
15.3 三条最重要的心智模型
- 哈希输入 = header + payload。任何时候你怀疑 oid 不对,先检查 header 是否正确(类型、长度、NUL)。
- 名字里的"算法"是 OID 的一部分。双哈希下,"同一个 oid"这个说法必须带上算法限定。
- 写路径必须能读回并复算出同一 oid。这是所有扩展(新后端、新工具、Rust FFI)的验收标准。
15.4 延伸阅读(源码,带 v2.56.0 链接)
- 对象文件与哈希:
hash.h/hash.c:github.com/git/git/blo…object-file.c:github.com/git/git/blo…object-file.h:github.com/git/git/blo…
- 对象基类与四种类型:
object.h/object.c、blob.h、tree.h/tree.c、commit.h/commit.c、tag.h/tag.c
- ODB:
odb.h/odb.c、odb/source-loose.c、odb/transaction.*
- 对象名解析:
object-name.c/object-name.h
- Rust:
src/hash.rs、src/loose.rs、src/lib.rs
15.5 延伸阅读(文档)
Documentation/technical/hash-function-transition.adoc(本章最重要的规格,SHA-1→SHA-256 全设计);Documentation/git-hash-object.adoc、Documentation/git-cat-file.adoc(命令语义与 BATCH OUTPUT 格式);Documentation/RelNotes/2.56.0.adoc(§11 的原始出处);Documentation/technical/repository-version.adoc(仓库格式版本与扩展机制)。
15.6 与后续章节的衔接
- 第 2 章(对象存储):本章讲"一个对象的字节语义",第 2 章讲"多个对象如何共享字节"——pack 格式、idx v2/v3、delta、MIDX。
- 第 3 章(索引/工作树):
index_fd的上游是索引;cache-tree 如何从索引直接生成本章的 tree 对象。 - 第 4 章(refs):refs 存 oid,本章讲 oid 的含义;reftable 与双哈希 oid 的存取。
- 第 5 章(提交图):commit-graph 缓存本章
parse_commit_buffer的产物,跳过解压。 - 第 6 章(差分/合并):tree 的字节与排序直接决定 diff 的正确性与 rename 检测。
- 第 7 章(传输):
remote-object-info、partial clone 与 promisor 是本章 ODB 接口的网络延伸。 - 第 8 章(架构演进):本章 §9.1 的 ODB vtable 与 §11 的 Rust 化,是第 8 章 libification 的核心案例。
15.7 自检清单
读完本章,你应当能不看源码回答:
-
blob 13\0test content\n的 SHA-1 是多少?为什么是 13 而非 12? - 空 blob 与空 tree 的 SHA-1 / SHA-256 分别是什么?
- tree 条目的四个组成部分分别是什么编码?
- 为什么
010不是合法的对象长度? -
odb_write_object_ext在什么情况下会跳过写盘? -
finalize_object_file_flags遇到 EEXIST 时做什么?FOF_SKIP_COLLISION_CHECK影响什么? -
git hash-object默认开启哪个 fsck 标志?--literally关掉什么? - 双哈希仓库里,blob 与 commit 的兼容 oid 计算有何不同?
- 2.56 把松散写入移到了哪个文件?
odb_stream统一了什么? - 为什么 Rust 侧不重新实现哈希,而要通过 FFI 调 C?
若能全部答出,你已具备进入第 2 章(packfile 逐字节)的完整前置知识。对内容寻址的直觉,会让后面每一章都变得"顺理成章"——这正是本章把它放在全书第一位的原因。