自上而下 · 真实源码 · 中文
Git 到底在磁盘上
做了什么?
不从哈希函数讲起,也不从“版本控制的概念”讲起。我们从你敲下 git commit 之后,.git 里多出的那几个文件开始,一路打开对象库、索引和引用。
开始之前
课程页面只读;实验在真实文件里做。先确认本地二进制能用:
- 在课程根目录运行
node course/serve.mjs - 打开
http://localhost:4173 - 实验前运行
node course/lab.mjs status
commit 之后多了什么"] --> B["跟着一次 add/commit 走"] B --> C["对象库
blob / tree / commit"] C --> D["索引
DIRC 与 stat 缓存"] D --> E["引用与可达性
refs / reflog / gc"] E --> F["打包与传输
pack / clone"] A -.-> L0["Lab 0
手搭 .git"] B -.-> L1["Lab 1
手工造 commit"] C -.-> L2["Lab 2
加一个 builtin"] D -.-> L4["Lab 4
解析 index"] E -.-> L3["Lab 3
找回丢失提交"] F -.-> L6["Lab 6(可选)
读 pack"]
图源
flowchart LR ...(见页面源码)
Git 是一个内容寻址的对象库(不可变,按内容哈希取名),加一层可变的引用(分支、HEAD、reflog),中间用索引把工作区与下一次提交连接起来。后面每一节都在展开这句话里的一个词。
怎么用这门课
每个小节都是同一个节奏:先看本节视频(20–37 秒,直接给命令与输出)→ 一张图 → 两段解释 → 一个微检查 → 3 道选择题(逐项反馈)→ 一道简答(本地保存,可看参考答案)。没有实验的小节还有一个“60 秒动手”。有实验的小节,实验排在最后,不挡进度。
小节标题旁的“阅读 ≈7 分钟 · 检查 ≈5 分钟”就是字面意思:读懂这一节大约 7 分钟,做完选择题和简答大约 5 分钟。实验时间单独计算,不藏在阅读时间里;12 条小节视频合计约 6 分钟,也没有塞进阅读时间。
时间预算是规划工具,不是倒计时。看不懂就停下来读源码;源码在 git-src/,行号会随课程给出,但以你本地的文件为准。
代码块里的 ... 表示省略行;保留的行与仓库原文一致,没有改写。
整台机器
先不打开任何盒子。我们只做一件事:提交一次,然后盯着 .git 看它多了什么、变了什么。再把产生这些变化的命令入口找出来。
一次 commit,磁盘上多了什么?
阅读 ≈7 分钟 · 检查 ≈5 分钟在一个只有两个文件的仓库里提交一次,然后问 Git 要它的原始内容:
$ git log -1 --oneline a1b2c3d add hello.txt $ git cat-file -p HEAD tree 07ed5a7aebb914e3a02edf6d622b82d364037e3c parent 9fceb02d0ae598e95dc970b74767f19372d61af8 author A U Thor <a@example.com> 1767225600 +0000 committer A U Thor <a@example.com> 1767225600 +0000 add hello.txt
注意最后一段:git cat-file -p 把对象解压后原样打印出来。commit 不是什么二进制黑盒,它是一段带头的纯文本。文件内容不在里面,只有一个 tree 的哈希。
第一次见这些词:blob 存内容,tree 存目录结构。
hello.txt 的实际字节"] -->|"git add"| I["索引
.git/index"] I -->|"git commit"| T["tree 对象
目录快照"] T --> C["commit 对象
tree + parent + 作者 + 信息"] C --> R["引用
refs/heads/main"] R -.->|"下一次提交的 parent"| C
图源
flowchart LR W -->|git add| I I -->|git commit| T T --> C C --> R R -.->|下一次提交的 parent| C
对象的“名字”是内容哈希:同样的字节永远得到同样的名字,改一个字节就得到一个全新的对象。引用则相反,它只是一个文件,里面写着一行十六进制;移动分支,就是改写这一行。
这也解释了一个常见困惑:git commit --amend 并没有改掉旧提交。它造了一个新对象,然后把分支引用指过去;旧对象还在,直到 gc 发现没人引用它。
预测只改提交信息(git commit --amend -m "new"),commit 的哈希会怎样?
commit 对象的解剖
这四行头加一个空行加正文,就是 commit 的全部。父提交可以没有(根提交),也可以不止一个(合并提交)。
图源
flowchart TB C[commit] --> T[tree] C --> P[parent] C --> A[author/committer] C --> M[message] T --> E1[hello.txt → blob] T --> E2[src/ → subtree]
所以“提交”不是把文件打包上传,而是给一棵已经存在的目录树签个名。树和文件对象可能早就写好了;提交只是把树、父提交、作者和时间绑在一起。
对象是不可变的内容;引用是可变的指针;索引是两者之间的待提交快照。
在真实仓库里找出 commit 的三行头。
$ cd git-src && ./git cat-file -p HEAD | head -4
看预期输出
tree 006b28c5d4599d10965d9e5bbe22450db8c24eca parent b4b80b4885038ebe3f3e61a7ae2961ab1991a75f author Junio C Hamano <gitster@pobox.com> 1792437752 -0700 committer Junio C Hamano <gitster@pobox.com> 1792437774 -0700
哪一行是快照?哪一行指向上一次提交?
检查理解
≈2 分钟 · 不计分用自己的话解释
≈3 分钟 · 本地保存对象按内容哈希命名,所以任何改动都会产生一个新对象,旧对象不会被覆盖;分支只是一个写着哈希的文件,移动分支就是改写这个指针。执行 amend 时,Git 用新的 tree、parent 和提交信息写出一个新的 commit 对象,然后把当前分支引用从旧哈希改到新哈希。旧提交仍然存在,只是不再被这个分支引用,最终可能被 gc 回收。因此“不可变”约束的是对象层,“可移动”描述的是引用层。
本小节可以带走
- commit 是文本对象:tree、parent、author/committer、信息。
- 文件内容在 blob,目录结构在 tree,commit 只是给快照签名。
- 对象不可变,分支/HEAD 可变;历史操作大多是“造新对象 + 移引用”。
命令是怎么被接住的?
阅读 ≈6 分钟 · 检查 ≈4 分钟你已经知道提交留下了什么。现在反过来问:git commit 这行字,是怎么变成一个 C 函数的?答案在 git.c 的一张表里。
图源
sequenceDiagram
U->>G: git commit
G->>T: get_builtin("commit")
G->>R: run_builtin(...)
R->>S: setup_git_directory
R->>C: p->fn(...)表长这样
static struct cmd_struct commands[] = {
{ "add", cmd_add, RUN_SETUP | NEED_WORK_TREE },
{ "am", cmd_am, RUN_SETUP | NEED_WORK_TREE },
...
{ "commit", cmd_commit, RUN_SETUP | NEED_WORK_TREE },
...
};
三个字段:命令名、函数指针、启动选项。选项决定“调用实现之前,主程序要替它做多少准备工作”。
RUN_SETUP:必须找到仓库;找到后切换到工作树根,并把用户原来的子目录记录成 prefix。
NEED_WORK_TREE:这个命令需要工作区,不能只在裸仓库里跑。
也有一些命令不需要仓库,例如 git version;另一些“有仓库就更好”,例如 git config,它们用 RUN_SETUP_GENTLY。这个区别不是装饰,它决定了命令能不能在仓库外运行。
找不到就退出"] B -->|"否"| D{"有 RUN_SETUP_GENTLY?"} D -->|"是"| E["setup_git_directory_gently
允许 no_repo"] D -->|"否"| F["prefix = NULL
不碰仓库"] C --> G["p->fn(argc, argv, prefix, repo)"] E --> G F --> G
run_builtin,三种启动方式。你加的 builtin 选哪一种,取决于它是否必须依赖仓库。图源
flowchart TB
A --> B{有 RUN_SETUP?}
B -->|是| C[setup_git_directory]
B -->|否| D{有 GENTLY?}
D -->|是| E[setup_git_directory_gently]
D -->|否| F[prefix = NULL]
C --> G[p->fn]
E --> G
F --> G定位如果 cmd_foo 要读取 .git/index,它至少应该带哪个启动选项?
检查理解
≈2 分钟 · 不计分用自己的话解释
≈2 分钟 · 本地保存命令实现读的是全局的 the_repository 和索引,而这些对象需要先由 setup_git_directory 根据当前目录定位并初始化;同时它会把用户所在的子目录记成 prefix,供命令把相对路径还原到仓库根。若先调用实现,the_repository 可能没有对象库,索引路径也不对,git add ../x 这类相对路径会解析到错误位置。顺序反过来还会让“仓库外运行”与“仓库内运行”的错误处理无法统一。
本小节可以带走
commands[]把命令名映射到cmd_*函数与启动标志。RUN_SETUP负责定位仓库并切到工作树根;NEED_WORK_TREE才要求有工作区。- 加一个 builtin 的四步:实现、声明、登记、加入构建。实验 2 就做这件事。
不用 git init,手工搭一个仓库
如果 Git 只是一个对象库加引用,那么一个仓库的最小目录是什么?亲手搭出来,再让 Git 认它。
当前状态空目录;make-repo.sh 直接退出 1,运行 git status 会说 not a git repository。
任务
- 创建
.git/objects与.git/refs/heads。 - 写
.git/HEAD,内容为ref: refs/heads/main。 - 写最小
.git/config:repositoryformatversion = 0、filemode = true、bare = false。 - 让
git status成功返回,且git symbolic-ref HEAD输出refs/heads/main。
交付物
course/work/lab-gitdir/make-repo.sh约束
- 不能调用
git init或git init-db;检查器会用 shim 拦截。 - 脚本在空目录里运行,只能创建文件和目录。
- 不需要 index、不需要提交;空仓库即可。
只读预览
#!/bin/sh set -eu # TODO 1: 目录 # TODO 2: HEAD # TODO 3: config echo "not implemented" >&2 exit 1
三级提示
mkdir -p .git/objects .git/refs/heads。Git 不会要求 objects/info 或 refs/tags。printf 'ref: refs/heads/main\n' > .git/HEAD。末尾必须有换行。[core] 三行;repositoryformatversion 是 Git 判断“这是不是仓库”的关键。终端检查:node course/check.mjs lab-gitdir;恢复骨架:node course/lab.mjs restore lab-gitdir。
跟着一次 add / commit 走
把上一节的四个落点拉成一条时间线:文件字节怎么变成 blob,索引记了什么,tree 什么时候写,最后是谁动了引用。实验 1 会把这条线亲手走一遍。
git add 的两个动作
阅读 ≈6 分钟 · 检查 ≈4 分钟
回忆不往回翻:普通 commit 对象里,文件内容本身存在哪里?
上一节说对象不可变、索引是待提交快照。git add 正好同时碰这两层,但它不写 tree,也不写 commit。
工作区字节"] -->|"读文件 + 算哈希"| B["blob 对象
.git/objects/8d/0e41..."] W -->|"stat:mtime / size / ino"| E["索引项
path + blob 哈希 + stat"] B --> E E --> I[".git/index"] style B fill:#FDF0EC,stroke:#B83A1F style I fill:#F7F7F8,stroke:#5F6673
图源
flowchart LR W -->|读文件+算哈希| B[blob 对象] W -->|stat| E[索引项] B --> E E --> I[.git/index]
代码里的顺序
builtin/add.c 最终走到 read-cache.c 的 add_to_index。下面这段省略了路径规范化与 ignorecase 分支,只留下主干:
int add_to_index(struct index_state *istate, const char *path,
struct stat *st, int flags)
{
...
unsigned hash_flags = pretend ? 0 : INDEX_WRITE_OBJECT;
...
ce = make_empty_cache_entry(istate, namelen);
memcpy(ce->name, path, namelen);
ce->ce_namelen = namelen;
if (!intent_only)
fill_stat_cache_info(istate, ce, st);
...
if (!intent_only) {
if (index_path(istate, &ce->oid, path, st, hash_flags)) {
discard_cache_entry(ce);
return error(_("unable to index file '%s'"), path);
}
} else
set_object_name_for_intent_to_add_entry(ce);
...
pretend:git add -n 只报告不落盘,所以不写对象。
INDEX_WRITE_OBJECT:正常 add 时,index_path 会把 blob 真正写进对象库。
stat 缓存:把文件的 mtime、size、ino、uid 等记进索引项。下一次 status 先比这些数字,不急着读文件内容。
所以索引项不是“文件名 → 内容”,而是“路径 → blob 哈希 + 一组 stat 证据 + 模式位”。这组证据是 status 能变快的关键,模块 4 会展开。
预测刚 git add hello.txt,还没 commit。此时 git write-tree 会失败吗?
为什么索引是排好序的数组
内存里的索引是 struct cache_entry **cache:一个按路径字节序排序的指针数组。插入新项时用 memmove 挪出空位,查找时二分。
int add_index_entry(struct index_state *istate, struct cache_entry *ce, int option)
{
int pos;
...
ALLOC_GROW(istate->cache, istate->cache_nr + 1,
istate->cache_alloc);
istate->cache_nr++;
if (istate->cache_nr > pos + 1)
MOVE_ARRAY(istate->cache + pos + 1,
istate->cache + pos,
istate->cache_nr - pos - 1);
set_index_entry(istate, pos, ce);
istate->cache_changed |= CE_ENTRY_ADDED;
return 0;
}
有序带来三件事:按路径二分查找、线性合并两棵树、status 可以顺序扫描工作区。代价是插入要挪数组——这也是超大仓库需要 split index 的原因之一。
排序把 git add 的内部步骤按发生顺序点出来。
在一个测试仓库里 add 一个文件,确认索引指向的是 blob。
$ printf 'hello git\n' > hello.txt $ git add hello.txt $ git ls-files -s
看预期输出
100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0 hello.txt
用 git cat-file -t <oid> 验证类型是 blob;tree 还不存在。
检查理解
≈2 分钟 · 不计分用自己的话解释
≈2 分钟 · 本地保存对象库保存不可变的内容,下一次提交必须能通过哈希取到文件字节,所以 add 要先把内容写成 blob。索引保存“这次提交包含哪些路径、每个路径指向哪个 blob、文件当时是什么状态”,提交时才能在不重新扫描工作区的情况下写出 tree。若省略写对象,索引里会指向不存在的 blob;若省略改索引,blob 虽然存在,但没有任何东西告诉 commit 应该把它放进哪棵树。
本小节可以带走
- add = 写 blob + 插入/更新索引项,不写 tree/commit。
- 索引项含 blob 哈希、模式位和 stat 缓存;stat 是 status 的快路径。
- 索引按路径排序,查找快、插入要挪数组。
git commit 的收尾
阅读 ≈6 分钟 · 检查 ≈4 分钟
回忆commit 写出的根 tree,来自哪里?
提交时,Git 先确保索引是最新的,再把索引变成 tree,最后写 commit 并移动引用。三个动作对应三个函数,顺序不能换。
图源
sequenceDiagram C->>I: prepare_index I->>T: cache_tree_update T->>O: write trees C->>O: commit_tree_extended C->>R: update_head_with_reflog
prepare_index 与 cache-tree
prepare_index 在 builtin/commit.c:351,核心几行是:
repo_hold_locked_index(the_repository, &index_lock,
LOCK_DIE_ON_ERROR);
add_remove_files(&partial);
refresh_index(the_repository->index, REFRESH_QUIET,
NULL, NULL, NULL);
cache_tree_update(the_repository->index, WRITE_TREE_SILENT);
if (write_locked_index(the_repository->index, &index_lock, 0))
die(_("unable to write new index file"));
cache_tree_update 不是“写一棵全新的树”。索引里带着一棵 cache-tree,只有被标记失效的目录才重算。所以提交一个 10 万文件的仓库,通常只为改动路径上的少数目录写 tree。
对象库只增不改。同一份内容重复提交,tree 与 blob 的哈希相同,不会再写一遍;只有 commit 因为时间/信息/父提交不同而是新对象。
预测只改提交信息、文件内容不变,新 commit 的根 tree 哈希会怎样?
写 commit,再移动引用
commit_tree_extended 把 tree、parents、author/committer 和信息拼成文本,再交给对象库写入。之后 update_head_with_reflog 发起一次引用事务。
if (commit_tree_extended(sb.buf, sb.len,
&the_repository->index->cache_tree->oid,
parents, &oid, author_ident.buf, NULL,
sign_commit, extra)) {
rollback_index_files();
die(_("failed to write commit object"));
}
if (update_head_with_reflog(current_head, &oid, reflog_msg, &sb,
&err)) {
rollback_index_files();
die("%s", err.buf);
}
根 tree:提交用的不是“重新算一遍的树”,而是索引 cache-tree 顶端的 OID。
移动 HEAD:把 HEAD 从旧提交更新到新提交,同时把这次移动写进 reflog。HEAD 是符号引用,真正被改写的是它指向的分支。
所以“提交成功”的原子性边界在引用更新:commit 对象可能已经写进对象库,但引用事务失败时,你会看到一个没有分支指向的提交——它会在 reflog 或 fsck 里出现。实验 3 会利用这一点。
已 refresh"] -->|"cache_tree_update"| B["tree 对象
只写变化的目录"] B -->|"commit_tree_extended"| C["commit 对象
tree + parent + 信息"] C -->|"update_head_with_reflog"| D["refs/heads/main
旧哈希 → 新哈希"] D --> E["reflog
main@{0}"] C -.->|"引用失败时"| F["孤立对象
仍可被 fsck 找到"] style F fill:#FDF0EC,stroke:#B83A1F
图源
flowchart LR A[索引] -->|cache_tree_update| B[tree] B -->|commit_tree_extended| C[commit] C -->|update_head_with_reflog| D[refs/heads/main] D --> E[reflog] C -.-> F[孤立对象]
检查理解
≈2 分钟 · 不计分用自己的话解释
≈2 分钟 · 本地保存提交先把索引 refresh 并锁住,用 cache-tree 把变化的目录写成 tree,再用这些 tree、父提交和提交信息写出 commit 对象,最后通过引用事务把 HEAD(实际是它指向的分支)移到新哈希并追加 reflog。前三步只写对象库,最后一步才改变“当前分支是什么”。如果引用事务失败,commit 对象已经落盘但没有引用指向它,于是成为孤立对象;它仍能被 fsck 找到,也可能还能从 reflog 里看到。
本小节可以带走
- 提交不重扫工作区:索引 + cache-tree 已经给出树。
- commit 对象由 tree、parents、作者、信息拼成,写入前先算哈希。
- 引用更新是最后一步;对象与引用之间有一个可观测的缝隙。
只用 plumbing 造一个 commit
不运行 git add,不运行 git commit。用底层命令把刚刚学的四步亲手接起来。
当前状态build.sh 里只有一个 TODO,运行后直接退出 1;沙盒仓库里什么也不会发生。
任务
- 用
hash-object -w把hello git\n写成 blob。 - 用
update-index --add --cacheinfo把 blob 以 100644 模式登记为hello.txt。 - 用
write-tree得到根 tree。 - 用
commit-tree写一个无父提交,信息为lab: one commit。 - 用
update-ref把refs/heads/main指过去,并打印提交哈希。
交付物
course/work/lab-plumbing/build.sh约束
- 作者与提交者都必须是
Lab <lab@example.com>。 - 两个时间都固定为
2026-01-01T00:00:00 +0000。 - 只能用 plumbing:
hash-object、update-index、write-tree、commit-tree、update-ref。
只读预览
#!/bin/sh # 在这里完成实验 1。 set -eu # TODO 1: 写 blob # TODO 2: 登记索引 # TODO 3: 写 tree # TODO 4: 写 commit # TODO 5: 移动 refs/heads/main echo "not implemented" >&2 exit 1
三级提示(先自己想,卡住再展开)
printf 'hello git\n' | git hash-object -w --stdin。把输出存进变量。git update-index --add --cacheinfo 100644,$blob,hello.txt;然后 git write-tree。git commit-tree 从 stdin 读信息,作者/时间用 GIT_AUTHOR_*、GIT_COMMITTER_* 环境变量固定;最后 git update-ref refs/heads/main $commit。终端检查:node course/check.mjs lab-plumbing;恢复骨架:node course/lab.mjs restore lab-plumbing。参考答案在检查器里,页面不展示。
对象库
上一节反复出现“写对象”。现在打开写入函数:一个对象在磁盘上到底长什么样,名字怎么算出来,读的时候又怎么在松散对象与 pack 之间找到它。
内容寻址:名字就是指纹
阅读 ≈7 分钟 · 检查 ≈5 分钟回忆上一节 commit 的最后一步动了什么?
Git 不存储“文件名 → 内容”的表。它把内容加上一个小头,算哈希,用哈希当文件名。取的时候,用同样的算法验证内容——名字本身就是完整性校验。
hello git\n
10 字节"] --> B["加头
blob 10 + NUL"] B --> C["SHA-1
8d0e4123..."] C --> D["松散对象路径
.git/objects/8d/0e4123..."] D --> E["zlib 压缩后写入"] C -.-> F["对象名
8d0e41234f24b6da002d962a26c2495ea16a425f"]
图源
flowchart LR A[内容] --> B[加头 blob 10 NUL] B --> C[SHA-1] C --> D[.git/objects/8d/0e41...] D --> E[zlib 写入]
两段真实代码
头是 "<type> <size>\0",由 format_object_header 生成;哈希覆盖“头 + 正文”。
int format_object_header(char *str, size_t size, enum object_type type,
size_t objsize)
{
const char *name = type_name(type);
...
return xsnprintf(str, size, "%s %"PRIuMAX,
name, (uintmax_t)objsize) + 1;
}
void hash_object_file(const struct git_hash_algo *algo, const void *buf,
size_t len, enum object_type type,
struct object_id *oid)
{
struct git_hash_ctx c;
char hdr[MAX_HEADER_LEN];
int hdrlen;
hdrlen = format_object_header(hdr, sizeof(hdr), type, len);
git_hash_init(&c, algo);
git_hash_update(&c, hdr, hdrlen);
git_hash_update(&c, buf, len);
git_hash_final_oid(oid, &c);
}
包含 NUL:xsnprintf 返回写入的字符数,加 1 把结尾的 NUL 也算进哈希输入。少算这个字节,所有哈希都会和 Git 对不上。
写入时,odb_write_object_ext 先算哈希,再检查对象是否已存在;存在就只更新 mtime(freshen),不重写。因此重复 add 同一内容不会让对象库膨胀。
预测两个不同路径的文件,内容完全相同。它们会有几个 blob 对象?
图源
stateDiagram-v2 [*] --> Hash Hash --> Exists Exists --> Freshen: exists Exists --> Write: missing Write --> Compress Compress --> Rename Rename --> [*]
算一次 blob 哈希,再改一个字节重算。
$ printf 'hello git\n' | git hash-object --stdin $ printf 'hello git!\n' | git hash-object --stdin
看预期输出
8d0e41234f24b6da002d962a26c2495ea16a425f d1c64694584cf480b01273f2c729fd8b6b7c320c
第二个哈希是 hello git!\n 的真实结果;它与第一个完全不同。
检查理解
≈2 分钟 · 不计分用自己的话解释
≈3 分钟 · 本地保存对象名由“类型 + 长度 + NUL + 正文”算出,名字就是内容的指纹,所以相同内容必然得到相同名字,自然去重;读取时重算哈希并与文件名比对,就能发现内容被篡改或损坏。哈希输入必须包含头与正文:漏掉正文会让所有同类型同长度的对象撞名,漏掉头则无法区分 blob 与 tree,也无法在解压时确定正文边界。因此内容寻址把“存储位置”和“完整性验证”合并成了一件事。
本小节可以带走
- 对象 =
类型 长度\0+ 正文,哈希覆盖全部字节。 - 松散对象路径是
objects/<前2位>/<其余>,内容 zlib 压缩。 - 写入先查存在,存在则 freshen;对象写入幂等。
读路径与 pack
阅读 ≈7 分钟 · 检查 ≈5 分钟回忆给定一个 OID,读对象时最先查哪里?
对象不可能永远以松散文件存在。一个 20 年历史的仓库有几百万个对象,逐个文件存会浪费 inode,也无法做跨对象压缩。Git 的解法是 packfile,但读接口保持不变:给哈希,拿类型和内容。
遇到 delta 就追 base"] P -->|"否"| A{"有 alternates / promisor?"} A -->|"是"| O["去别的对象库找"] A -->|"否"| E["报错:bad object"] D --> Z
odb_read_object_info;松散、packed、alternates 的差异被对象库吸收。图源
flowchart TB
Q[OID] --> L{loose?}
L -->|yes| Z[inflate + verify]
L -->|no| P{in pack idx?}
P -->|yes| D[read at offset + delta]
P -->|no| A{alternates?}
A -->|yes| O[other odb]
A -->|no| E[bad object]pack 的布局
一个 pack 文件是:12 字节头(PACK、版本、对象数)、一串对象记录、末尾是整个文件内容的哈希。旁边的 .idx 是查找表:哈希 → pack 内偏移。
类型 + 长度 + 数据"] O1 --> O2["对象 2
OFS_DELTA → 对象 1"] O2 --> O3["对象 3
REF_DELTA → 另一个 pack 的 base"] O3 --> TR["尾部校验哈希"] end subgraph IDX[".idx"] F["fanout[256]"] --> S["排序后的 OID 表"] S --> C["CRC 表"] C --> X["偏移表"] end S -.->|"二分找到 OID"| X X -.->|"偏移"| O1
图源
flowchart LR PACK --> O1 O1 --> O2[OFS_DELTA] O2 --> O3[REF_DELTA] IDX --> fanout fanout --> oids oids --> offsets offsets -.-> O1
打开 pack 时,Git 会校验签名与对象数,代码在 packfile.c:561:
if (hdr.hdr_signature != htonl(PACK_SIGNATURE))
return error("file %s is not a GIT packfile",
p->pack_name);
if (!pack_version_ok(hdr.hdr_version))
return error("packfile %s is version %"PRIu32" and not"
" supported (try upgrading GIT to a newer version)",
p->pack_name, ntohl(hdr.hdr_version));
/* Verify the pack matches its index. */
if (p->num_objects != ntohl(hdr.hdr_entries))
return error("packfile %s claims to have %"PRIu32" objects"
" while index indicates %"PRIu32" objects",
p->pack_name, ntohl(hdr.hdr_entries),
p->num_objects);
读对象时,odb_read_object_info 只需要类型和大小,不必把完整内容解压出来;只有真正取内容时才追 delta 链。这是 git cat-file --batch-check 比 --batch 快的原因之一。
辨析pack 里对象 A 以 delta 形式存成“相对对象 B 的差异”。要读出 A 的完整内容,Git 必须做什么?
检查理解
≈2 分钟 · 不计分用自己的话解释
≈3 分钟 · 本地保存pack 为了省空间把对象紧凑地放在一起,并大量使用 delta,所以对象在文件里的物理顺序与哈希顺序无关。若没有 idx,给定一个哈希只能顺序扫描整个 pack、逐个解压并计算哈希,复杂度是 O(对象数),在大仓库上不可接受。idx 用 fanout 加排序后的 OID 表做二分,直接得到 pack 内偏移,把查找降到 O(log n);找到偏移后仍要追 delta 链才能取完整内容。
本小节可以带走
- 读对象先查松散,再查 pack idx,再查 alternates。
- pack 省空间,idx 提供 O(log n) 查找;delta 需要 base。
- 取“类型 + 大小”比取完整内容浅,batch-check 因此更快。
给 Git 加一个 object-meta 子命令
在真实 C 源码里走完“实现 → 声明 → 登记 → 构建”,并用真实二进制验证。
当前状态git object-meta 不存在;执行会得到 git: 'object-meta' is not a git command。
任务
- 新建
builtin/object-meta.c,实现cmd_object_meta。 - 在
builtin.h声明函数。 - 在
git.c的commands[]登记,带RUN_SETUP。 - 在
Makefile的BUILTIN_OBJS加入目标文件。 - 让
git object-meta <object>输出<type> <size>;-s只输出大小。
通过标准
- commit / tree / blob 三类对象都正确。
- 支持任意 revision 写法(
HEAD、HEAD^{tree}、HEAD:path)。 - 未知对象退出码非零;缺参数打印用法。
-s只打印一个十进制数字。
构建
$ make -j8 NO_GETTEXT=1 NO_TCLTK=1 NO_PERL=1 NO_PYTHON=1
只读预览 · 骨架
#include "builtin.h"
#include "gettext.h"
#include "object-name.h"
#include "odb.h"
#include "object.h"
#include "parse-options.h"
static const char * const builtin_object_meta_usage[] = {
"git object-meta [-s] <object>",
NULL
};
int cmd_object_meta(int argc, const char **argv, const char *prefix,
struct repository *repo)
{
/* TODO 1: 解析 -s 和 <object> */
/* TODO 2: 把 <object> 解析成 struct object_id */
/* TODO 3: 读取类型与大小 */
/* TODO 4: 按 -s 分支输出 */
return error("object-meta: not implemented");
}三级提示(先自己想,卡住再展开)
parse_options 的 OPT_BOOL('s', NULL, &size_only, ...);解析后 argc 应等于 1。repo_get_oid(repo, argv[0], &oid) 返回非零表示解析失败。int type = odb_read_object_info(repo->objects, &oid, &size);,类型名用 type_name(type)。终端检查:node course/check.mjs lab-object-meta;恢复源码:node course/lab.mjs restore lab-object-meta。检查器不会改你的源码,只读并构建。
索引
索引是 Git 里最像“数据库”的部分:一个二进制文件、按路径排序的条目、每个条目带着 stat 证据。理解它,才能理解 status 为什么快、commit 为什么不重扫工作区。
一张排好序的表
阅读 ≈7 分钟 · 检查 ≈5 分钟回忆索引条目里,除了 blob 哈希和路径,还存了什么?
.git/index 不是文本。它以 DIRC 开头,后面是版本、条目数、条目数组、可选扩展,最后是校验哈希。文件本身还可能被 mmap,读的时候不复制整份内容。
路径 + stat + OID + 标志"] E1 --> E2["条目 2
按路径排序"] E2 --> X["扩展
TREE / UNTR / link ..."] X --> H["尾部哈希"]
图源
flowchart TB DIRC --> version --> entries entries --> e1 --> e2 e2 --> extensions --> trailer
条目里到底有什么
struct cache_entry {
struct hashmap_entry ent;
struct stat_data ce_stat_data;
unsigned int ce_mode;
unsigned int ce_flags;
unsigned int mem_pool_allocated;
unsigned int ce_namelen;
unsigned int index; /* for link extension */
struct object_id oid;
char name[FLEX_ARRAY]; /* more */
};
stat 证据:ctime、mtime、dev、ino、uid、gid、size。用来判断“工作区文件是否可能变过”。
模式位:普通文件 100644 / 100755、符号链接 120000、子模块 160000。只有可执行位有意义。
标志:低两位是合并冲突的 stage(0 正常,1/2/3 分别 base/ours/theirs);高位是 skip-worktree、intent-to-add 等。
磁盘格式与内存结构不完全一样:字段按固定宽度序列化,路径后面补 NUL 到 8 字节边界(v2/v3);v4 用前缀压缩路径。内存里则是上面这个带柔性数组的结构。
排序按 .git/index 从前往后的字节顺序点击。
图源
stateDiagram-v2 [*] --> Clean Clean --> Suspicious: stat changed Suspicious --> Dirty: hash differs Suspicious --> Clean: hash same Dirty --> Staged: add Staged --> Clean Clean --> Conflict: merge Conflict --> Staged
直接看索引的头 16 个字节。
$ xxd .git/index | head -2
看预期输出
00000000: 4449 5243 0000 0002 0000 0004 6ad8 c1e1 DIRC........j... 00000010: 1484 080d 6ad8 c1e1 1484 080d 0100 0011 ....j...........
4449 5243 是 DIRC,0000 0002 是版本 2,0000 0004 是条目数(随仓库变化)。
检查理解
≈2 分钟 · 不计分用自己的话解释
≈3 分钟 · 本地保存blob 哈希回答“下一次提交应该指向哪份内容”,没有它就无法写出 tree;stat 信息回答“工作区文件是否还值得重新读”,没有它每次 status 都要哈希所有文件。两者结合才能既正确又快:先用便宜的元数据判断是否需要验证,只有可疑时才付出读文件与哈希的代价。只留哈希会让 status 退化为全量哈希;只留 stat 则无法知道文件内容对应哪个对象。
本小节可以带走
- index = DIRC 头 + 排序条目 + 扩展 + 校验哈希。
- 条目含模式、stage、blob OID、stat 证据与路径。
- 磁盘格式与内存结构不同;v4 做路径前缀压缩。
status 的快路径
阅读 ≈6 分钟 · 检查 ≈4 分钟
回忆判断一个已跟踪文件是否修改,status 先比什么?
git status 要回答三个问题:哪些已暂存、哪些改了没暂存、哪些没被跟踪。第三项最贵,因为要遍历工作区。索引与 cache-tree 的设计就是为了尽量不读内容。
mtime 与索引时间同秒"} D -->|"否"| CLEAN["干净,跳过"] D -->|"是"| H["读内容 + 哈希"] C -->|"不一致"| H H --> E{"OID 相同?"} E -->|"是"| F["更新 stat,标记干净"] E -->|"否"| G["modified"]
图源
flowchart TB
A[lstat] --> B{in index?}
B -->|no| U[untracked]
B -->|yes| C{stat matches?}
C -->|no| H[hash content]
C -->|yes| D{racy?}
D -->|no| CLEAN
D -->|yes| H
H --> E{oid same?}
E -->|yes| F[refresh stat]
E -->|no| G[modified]所谓 racy clean,是这套缓存最微妙的边界:时间戳精度不够时,元数据可能“看起来没变”,但内容已经变了。
三层加速
- stat 缓存:最常见的路径,只比元数据,不读内容。
- cache-tree:提交时复用未变目录的 tree OID;status 也能用 TREE 扩展判断整棵子树是否干净。
- untracked cache:记录目录的 mtime 与已扫描结果,目录没变就跳过整段。
再往上还有 split index(共享大部分条目,只写变化部分)与 sparse index(用一个目录条目代表整棵未展开的子树)。它们都在解决同一个问题:索引变大后,如何避免每次操作都碰全部条目。
O(条目数)"] A --> B["读内容 + 哈希
O(文件大小)"] B --> C["扫描未跟踪目录
O(目录项)"] A -.-> A1["stat 缓存"] B -.-> B1["cache-tree / TREE 扩展"] C -.-> C1["untracked cache"] A -.-> A2["split index"] A -.-> A3["sparse index"]
图源
flowchart LR S[status] --> A[stat each path] A --> B[hash content] B --> C[scan untracked] A -.-> A1[stat cache] B -.-> B1[cache-tree] C -.-> C1[untracked cache]
预测你把文件内容改了又改回原样,mtime 变了。下一次 status 会显示 modified 吗?
检查理解
≈2 分钟 · 不计分用自己的话解释
≈3 分钟 · 本地保存索引写入时记录文件 mtime;如果文件在索引写完后、同一时间戳精度内又被修改,mtime 可能仍然“一致”,stat 缓存就会误判为干净。racy clean 检查发现文件 mtime 与索引文件 mtime 落在同一秒时,强制读取内容并重算哈希,避免这种误判。stat 一致时 status 直接标记干净并跳过;stat 不一致时进入慢路径,读文件、算 blob 哈希,与索引里的 OID 比较,相同则刷新 stat,不同则报告 modified。
本小节可以带走
- status 先用 stat 判断,再用哈希验证可疑路径。
- racy clean 处理时间戳精度不足的边界。
- cache-tree、untracked cache、split/sparse index 各削减一层成本。
直接读 .git/index 的二进制
不用 git ls-files。自己按 DIRC 格式解析版本、条目、OID、模式和 stage。
当前状态index-report.py 只抛出 not implemented;沙盒里的索引已经准备好,但没人读它。
任务
- 读 12 字节头:
DIRC、版本(必须为 2)、条目数。 - 逐条解析:40 字节 stat、20 字节 OID、2 字节 flags、路径。
- 路径后补 NUL 到 8 字节边界;解析完 count 个条目。
- 输出 JSON,按索引原顺序(即路径字节序)。
输出格式
{"version":2,"entries":[
{"path":"a.txt","mode":"100644","stage":0,"oid":"..."},
{"path":"run.sh","mode":"100755","stage":0,"oid":"..."}
]}
交付物
course/work/lab-index/index-report.py约束
- 不能调用任何 git 命令;检查器会把
git替换成失败 shim。 - 只支持 v2;沙盒会用
git update-index --index-version 2固定版本。 - 输出必须是纯 JSON,不能夹调试文字。
只读预览
import json, struct, sys
repo = sys.argv[1] if len(sys.argv) > 1 else "."
data = open(f"{repo}/.git/index", "rb").read()
# TODO 1: magic / version / count
# TODO 2: 40 字节 stat + 20 字节 OID + 2 字节 flags
# TODO 3: namelen = flags & 0xFFF
# TODO 4: 输出 JSON
raise SystemExit("not implemented")三级提示
magic, version, count = struct.unpack(">4sII", data[:12]);magic != b"DIRC" 就退出。fields = struct.unpack(">10I", data[off:off+40]),mode = fields[6];随后 20 字节 OID、2 字节 flags。namelen = flags & 0xFFF,stage = (flags >> 12) & 3;整条长度 (62 + namelen + 8) & ~7,路径按 UTF-8 解码。终端检查:node course/check.mjs lab-index;恢复骨架:node course/lab.mjs restore lab-index。
引用与可达性
对象层是只增不改的,真正让历史“活”起来的是引用:分支、HEAD、reflog。它们决定哪些对象算“还在”,也决定了 gc 能删什么、你能找回什么。
引用就是一个文件
阅读 ≈7 分钟 · 检查 ≈5 分钟回忆普通状态下,.git/HEAD 的内容是什么?
打开 .git/refs/heads/main,你会看到 40 个十六进制字符加一个换行。没有 JSON,没有数据库。HEAD 稍特殊:它通常写着 ref: refs/heads/main,是一个符号引用。
ref: refs/heads/main"] --> M["refs/heads/main
a1b2c3d..."] H -.->|"detached 时"| C3["直接写 commit 哈希"] M --> C3["commit a1b2c3d"] C3 --> C2["parent"] C2 --> C1["parent"] T["refs/tags/v1"] --> C2 R["refs/remotes/origin/main"] --> C2
图源
flowchart LR HEAD --> main HEAD -. detached .-> C3 main --> C3 C3 --> C2 --> C1 tag --> C2 remote --> C2
引用多到一定程度后,逐个文件会很碎,于是 Git 会把它们打包进 .git/packed-refs。松散引用仍然优先;更新一个已打包的引用时,Git 会写一个松散文件覆盖它。
更新引用的锁协议
引用更新必须是原子的:不能出现“文件写了一半”的状态。Git 用 .lock 文件 + rename,并在成功后追加 reflog。
static enum ref_transaction_error write_ref_to_lockfile(
struct files_ref_store *refs, struct ref_lock *lock,
const struct object_id *oid, struct strbuf *err)
{
static char term = '\n';
int fd;
fd = get_lock_file_fd(&lock->lk);
if (write_in_full(fd, oid_to_hex(oid),
refs->base.repo->hash_algo->hexsz) < 0 ||
write_in_full(fd, &term, 1) < 0 ||
fsync_component(FSYNC_COMPONENT_REFERENCE,
get_lock_file_fd(&lock->lk)) < 0 ||
close_ref_gently(lock) < 0) {
...
return REF_TRANSACTION_ERROR_GENERIC;
}
return 0;
}
写 OID:写入 40(SHA-1)或 64(SHA-256)个十六进制字符,不含换行。
落盘再改名:先把新值刷到磁盘,再原子替换;崩溃时读端只会看到完整的旧值或完整的新值。
上层通过 refs_update_ref 发起事务:先准备所有锁,再提交。HEAD 的更新也走同一条路——它最终解析到分支文件,所以“提交”在引用层就是改一个分支文件。
int refs_update_ref(struct ref_store *refs, const char *msg,
const char *refname, const struct object_id *new_oid,
const struct object_id *old_oid, unsigned int flags,
enum action_on_err onerr)
{
...
t = ref_store_transaction_begin(refs, 0, &err);
if (!t ||
ref_transaction_update(t, refname, new_oid, old_oid,
NULL, NULL, flags, msg, &err) ||
ref_transaction_commit(t, &err)) {
ret = 1;
...
}
预测你处于 detached HEAD,提交一次。此时 .git/HEAD 的内容最可能是?
图源
stateDiagram-v2 [*] --> Locked Locked --> Prepared: write + fsync Prepared --> Committed: rename Committed --> Logged: reflog Prepared --> Aborted
读 HEAD、分支文件与 reflog,确认谁存了真正的哈希。
$ cat .git/HEAD $ cat .git/refs/heads/main $ git reflog -1
看预期输出
ref: refs/heads/main
4afa24239d665b676522345a3b64d722c25119b6
4afa242 HEAD@{0}: commit: lab: one commitHEAD 是符号引用;哈希在分支文件里;reflog 记录这次移动。
检查理解
≈2 分钟 · 不计分用自己的话解释
≈3 分钟 · 本地保存提交对象写入对象库后,update_head_with_reflog 发起引用事务,把 HEAD 的目标从旧提交改为新提交。引用层先创建 .lock 并写入新哈希、fsync,再用 rename 原子替换分支文件,因此读端不会看到半个引用。锁解决的是并发与原子性;reflog 在成功后追加一条“旧值 → 新值”的记录,解决的是可追溯与误操作恢复。两者都在对象写入之后,所以对象可能先于引用存在。
本小节可以带走
- 分支 = 一个写着哈希的文件;HEAD = 指向分支的符号引用。
- 松散引用优先于 packed-refs。
- 引用更新用 lock + rename,成功后追加 reflog。
可达性与找回
阅读 ≈7 分钟 · 检查 ≈5 分钟回忆gc 决定一个对象能不能删,主要看什么?
对象库不关心“历史”,它只存对象。是引用把某些提交标成“仍然可达”,gc 再从这些根出发,标记所有能走到的对象;走不到的就是垃圾。但垃圾不一定立刻删——reflog、索引、其他引用都可能是根。
图源
flowchart LR main --> C4 --> C3 --> C2 --> C1 tag --> C2 reflog -.-> C5 C5 --> C3 C5 -. prune .-> X
可达性不是“存在时间”,也不是“有没有被 push”。一个提交可以很旧但被标签引用;也可以很新但没有任何根指向它。
预测reflog 已过期,又执行了 git gc --prune=now。还能用 fsck 找回那个提交吗?
误删之后,为什么还能找回来
三种常见证据源,按可靠性排序:
- reflog:HEAD 与各分支的移动记录,包含旧值、新值、动作和时间。
- fsck:扫描对象库,报告 dangling / unreachable 的 commit、tree、blob。
- 索引与工作区:即使提交没了,暂存区仍可能指向 blob。
图源
flowchart TB
A[ref deleted] --> B{reflog?}
B -->|yes| C[reflog]
B -->|no| D[fsck --lost-found]
D --> E[cat-file -p]
C --> F[branch rescue sha]
E --> F
F --> G[fsck verify]注意 git fsck --lost-found 会把找到的对象写进 .git/lost-found/,但这只是方便你查看,不是恢复引用本身。真正让它“回来”的动作是新建一个引用(分支、标签),让对象重新可达。
另外,git gc 默认会保留两周内的不可达对象(gc.pruneExpire),并尊重 reflog。很多“删了还能救”的窗口来自这些默认值,而不是 Git 有回收站。
排序把“找回丢失提交”的操作按安全顺序点出来。
检查理解
≈2 分钟 · 不计分用自己的话解释
≈3 分钟 · 本地保存删除分支只是移除一个引用,提交对象仍然在对象库里;只要还有 reflog、标签或其他引用能到达它,gc 就不会把它当垃圾。reflog 提供“曾经指向哪里”的时间线,是最快的证据;fsck 在 reflog 过期后扫描对象库,报告 dangling/unreachable 对象;gc 则是最终执行者,在 reflog 过期且对象不可达时才 prune。找回的动作不是“让 gc 反悔”,而是新建一个引用,让对象重新可达。
本小节可以带走
- 可达性从引用、reflog、索引等根出发;gc 只删不可达且过期的对象。
- reflog 是最快的证据,fsck 是兜底扫描。
- 恢复 = 新建引用,而不是修改对象。
找回一个被删掉的提交
沙盒里有一条被 reset 掉、分支也被删掉的功能提交。reflog 已过期,但对象还在。写脚本把它救回来。
当前状态recover.sh 直接退出 1;沙盒里没有 rescue 分支,目标提交仍不可达。
任务
- 在沙盒里找到信息为
feature: add secret的提交。 - 确认它的 tree 里有
feature.txt,内容为recovered\n。 - 创建分支
rescue指向它;不要移动main。 - 把证据写进
notes.md:用了什么命令、SHA、为什么是它。
交付物
course/work/lab-recover/recover.shcourse/work/lab-recover/notes.md约束
- 脚本在沙盒仓库根目录运行。
- 不能使用
git reflog作为唯一证据(检查器会清空 reflog)。 - 可以创建临时文件,但最终只保留
rescue分支。
只读预览
#!/bin/sh # 在沙盒仓库根目录运行。 set -eu # TODO 1: 列出不可达对象 # TODO 2: 找到 message 为 feature: add secret 的提交 # TODO 3: 核对 feature.txt 内容 # TODO 4: git branch rescue <sha> echo "not implemented" >&2 exit 1
三级提示(先自己想,卡住再展开)
git fsck --lost-found 会列出 dangling commit <sha>;沙盒里不止一个。git cat-file -p <sha>,看提交信息;目标信息是 feature: add secret。git branch rescue <sha> 建引用;在 notes.md 里写清命令、SHA、以及它为什么不是另一个 dangling 提交。终端检查:node course/check.mjs lab-recover;重建沙盒:node course/lab.mjs start lab-recover。参考答案在检查器里。
打包与传输
最后一层:对象怎么被压进一个 pack,clone/fetch 又怎么只传缺少的部分。这一模块是可选扩展,不配实验;读完你就有了继续读 Documentation/technical/ 的地图。
pack 内部
阅读 ≈6 分钟 · 检查 ≈4 分钟回忆pack 旁边哪个文件提供“哈希 → 偏移”的查找?
pack 的对象记录不是定长结构。每个对象开头是一个变长头:最高位表示“还有后续字节”,接着 3 位是类型,最低 4 位是 size 的低 4 位;后续每字节再贡献 7 位 size。
1 位继续 + 3 位类型 + 4 位 size"] --> B{"继续位 = 1?"} B -->|"是"| C["下一字节
1 位继续 + 7 位 size"] C --> B B -->|"否"| D["按类型读数据"] D --> E{"类型是 delta?"} E -->|"OFS_DELTA"| F["读负偏移,找同 pack 的 base"] E -->|"REF_DELTA"| G["读 base OID,可能跨 pack"] E -->|"普通"| H["zlib 解压内容"]
图源
flowchart LR
A[first byte] --> B{continuation?}
B -->|yes| C[next byte 7 bits]
C --> B
B -->|no| D[read payload]
D --> E{delta?}
E -->|OFS| F[negative offset base]
E -->|REF| G[base oid]
E -->|plain| H[inflate]idx 与 bitmap
.idx v2 的开头是魔法 \377tOc 和版本号,然后是 256 项 fanout:第 i 项表示“首字节 ≤ i 的对象有多少个”。二分时先用首字节定位区间,再在区间内查 OID 表。
fanout: 256 * 4 bytes
fanout[i] = number of objects whose first byte is <= i
object names: N * hash_size
sorted by object name
CRC32: N * 4 bytes
offsets: N * 4 bytes
large offsets: M * 8 bytes
pack checksum: hash_size
idx checksum: hash_size
前缀计数:fanout[0x8d] - fanout[0x8c] 就是首字节为 0x8d 的对象数量;两个相邻项直接给出二分区间。
.bitmap 是另一份可选文件:为某些提交预计算“可达对象集合”的位图。它让 fetch/push 在判断“对方已经有哪些对象”时不必逐个走图,是大型仓库传输提速的关键之一。
pack.depth 就是在两者之间取平衡。图源
flowchart LR base --> d1 --> d2 --> d3 d3 -. too deep .-> repack repack --> newbase
辨析OFS_DELTA 与 REF_DELTA 的核心区别是:
在 git-src 里看松散对象与打包对象各有几个。
$ cd git-src && ./git count-objects -v
看预期输出
count: 0 size: 0 in-pack: 4962 packs: 1 size-pack: 13215
数字随仓库变化。记下 in-pack;Lab 6 会让你从 idx 里读出同一个数字。
检查理解
≈2 分钟 · 不计分用自己的话解释
≈2 分钟 · 本地保存pack 把对象紧凑地放在一起并大量使用 delta,物理顺序与哈希顺序无关,所以需要一个 idx 把哈希映射到偏移。idx 里的 OID 表按哈希排序,可以二分;但如果直接在几百万项上做 32 次比较,缓存局部性很差。fanout 用 256 个前缀计数先把范围缩到“首字节相同”的小区间,再在区间内二分,显著减少比较次数与随机访存。去掉 fanout 仍然正确,只是每次查找都要在整张表上反复跳转。
本小节可以带走
- pack 对象头是变长编码:类型 + 解压后大小。
- idx = fanout + 排序 OID + CRC + 偏移;bitmap 加速可达性判断。
- delta 深度是空间与读取延迟的权衡。
clone 与 fetch
阅读 ≈6 分钟 · 检查 ≈4 分钟回忆fetch 协商的核心是哪一对消息?
传输的本质是两句话:“我有哪些引用”和“我要哪些对象”。客户端先拿到对方的引用列表,再说“我要这些 tip,但我已经有这些”,服务器据此算出差集并生成一个 pack。
图源
sequenceDiagram C->>S: info/refs S-->>C: ref advertisement C->>S: want + have + done S-->>C: packfile C->>C: index-pack + checkout
协议 v2 改了什么
v2 把“引用广告”和“取对象”拆成两个命令:ls-refs 与 fetch。客户端可以先问引用,再决定要什么;服务器也能按 prefix 过滤引用,减少大仓库的广告体积。
传输层还有一层常被忽略的选择:file://、ssh、https 都只是“怎么搬字节”,pack 的生成与校验仍在两端各自完成。
图源
flowchart LR A[advertise] --> B[negotiate] B --> C[server pack] C --> D[index-pack] D --> E[update refs] E --> F[checkout] F --> G[index stat]
按依赖顺序:index-format.txt → pack-format.txt → pack-protocol.txt → commit-graph.txt。每读一份,回到本课程对应的模块,把图上的一个盒子替换成文档里的字段。
定位clone 之后,工作区文件是在哪一步出现的?
检查理解
≈2 分钟 · 不计分用自己的话解释
≈2 分钟 · 本地保存客户端先请求引用广告,拿到服务器各分支与标签的 tip;随后发送 want 与 have 进行协商,服务器从 want 出发、跳过 have 可达的对象,只生成差集 pack。客户端收到后用 index-pack 校验并建立 idx,更新远端跟踪引用,最后 checkout 按 tree 把 blob 写到工作区并填入索引。大仓库的瓶颈通常在协商与 checkout:前者要在巨大的提交图上判断“对方已有什么”,后者要为每个路径写文件并做 stat;pack 传输本身有 delta 与 bitmap 辅助。
本小节可以带走
- fetch/clone 的核心是引用广告 + want/have 协商。
- pack 落地后还要 index-pack、更新引用、checkout。
- 协议 v2 把 ls-refs 与 fetch 拆开,支持按需查询。
读 .pack 与 .idx 的头
不调用 git verify-pack。直接读 pack 的对象数,以及 idx 的 fanout 与第一个 OID。
当前状态pack-report.py 只抛出 not implemented;沙盒里已经有一个 repack 后的仓库。
任务
- 找到
.git/objects/pack/*.pack。 - 读 12 字节:
PACK、版本、对象数。 - 读同名
.idx:magic\377tOc、版本、256 项 fanout。 - 输出对象数、idx 对象数、第一个 OID。
输出格式
{"packs":[
{"pack":"pack-xxxx.pack","version":2,
"objects":42,"idx_objects":42,
"first_oid":"..."}
]}
交付物
course/work/lab-pack/pack-report.py约束
- 不能调用 git 命令。
- 只处理 idx v2。
- 沙盒至少有一个 pack;没有 pack 时输出空数组即可。
只读预览
import glob, json, os, struct, sys
repo = sys.argv[1] if len(sys.argv) > 1 else "."
packdir = os.path.join(repo, ".git", "objects", "pack")
for pack in sorted(glob.glob(packdir + "/*.pack")):
# TODO 1: PACK + version + count
# TODO 2: idx magic + fanout[256]
# TODO 3: count = fanout[255]
# TODO 4: first OID at 8 + 1024
raise SystemExit("not implemented")三级提示
magic, version, count = struct.unpack(">4sII", head[:12]);magic 必须是 b"PACK"。b"\377tOc",版本 2;fanout 从偏移 8 开始,共 256 个 big-endian uint32。idx_objects = fanout[255];排序后的第一个 OID 在偏移 8 + 256*4,取 20 字节转 hex。终端检查:node course/check.mjs lab-pack;恢复骨架:node course/lab.mjs restore lab-pack。这是扩展实验,不计入核心路径。