Git 内部结构

自上而下 · 真实源码 · 中文

Git 到底在磁盘上
做了什么?

不从哈希函数讲起,也不从“版本控制的概念”讲起。我们从你敲下 git commit 之后,.git 里多出的那几个文件开始,一路打开对象库、索引和引用。

6 个模块 12 个小节 5 个核心实验 +1 扩展 ≈226 分钟 轨道:Deep Diver
主片 · 86 秒 · 中文旁白每个小节另有 20–37 秒“跟着做”视频

开始之前

课程页面只读;实验在真实文件里做。先确认本地二进制能用:

  1. 在课程根目录运行 node course/serve.mjs
  2. 打开 http://localhost:4173
  3. 实验前运行 node course/lab.mjs status
课程地图
flowchart LR A["可观察行为
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"]
先看整台机器,再依次打开四个盒子;六个实验分别落在对应模块之后,Lab 6 是可选扩展。
图源
flowchart LR ...(见页面源码)
一句话模型

Git 是一个内容寻址的对象库(不可变,按内容哈希取名),加一层可变的引用(分支、HEAD、reflog),中间用索引把工作区与下一次提交连接起来。后面每一节都在展开这句话里的一个词。

怎么用这门课

每个小节都是同一个节奏:先看本节视频(20–37 秒,直接给命令与输出)→ 一张图 → 两段解释 → 一个微检查 → 3 道选择题(逐项反馈)→ 一道简答(本地保存,可看参考答案)。没有实验的小节还有一个“60 秒动手”。有实验的小节,实验排在最后,不挡进度。

小节标题旁的“阅读 ≈7 分钟 · 检查 ≈5 分钟”就是字面意思:读懂这一节大约 7 分钟,做完选择题和简答大约 5 分钟。实验时间单独计算,不藏在阅读时间里;12 条小节视频合计约 6 分钟,也没有塞进阅读时间。

时间预算是规划工具,不是倒计时。看不懂就停下来读源码;源码在 git-src/,行号会随课程给出,但以你本地的文件为准。

代码块里的 ... 表示省略行;保留的行与仓库原文一致,没有改写。

01

整台机器

先不打开任何盒子。我们只做一件事:提交一次,然后盯着 .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 存目录结构。

一次提交的四个落点
flowchart LR W["工作区
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:0 个、1 个或多个"] C --> A["author / committer:人名、邮箱、时间戳、时区"] C --> M["正文:提交信息"] T --> E1["hello.txt → blob 哈希"] T --> E2["src/ → 子 tree"]
commit 只指向一棵 tree;文件内容通过 tree 里的 blob 哈希再走一步。目录嵌套就是 tree 套 tree。
图源
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]

所以“提交”不是把文件打包上传,而是给一棵已经存在的目录树签个名。树和文件对象可能早就写好了;提交只是把树、父提交、作者和时间绑在一起。

术语

对象是不可变的内容;引用是可变的指针;索引是两者之间的待提交快照。

60 秒动手≈2 分钟

在真实仓库里找出 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

哪一行是快照?哪一行指向上一次提交?

1

检查理解

≈2 分钟 · 不计分
1.一个普通(非根、非合并)commit 对象里,哪一项不会出现?

2.“Git 的提交不可变”最准确的意思是:

3.你在一个子目录里运行 git status,它却知道整个仓库的状态。最直接的原因是:

2

用自己的话解释

≈3 分钟 · 本地保存
0 字

本小节可以带走

  • commit 是文本对象:tree、parent、author/committer、信息。
  • 文件内容在 blob,目录结构在 tree,commit 只是给快照签名。
  • 对象不可变,分支/HEAD 可变;历史操作大多是“造新对象 + 移引用”。

命令是怎么被接住的?

阅读 ≈6 分钟 · 检查 ≈4 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

你已经知道提交留下了什么。现在反过来问:git commit 这行字,是怎么变成一个 C 函数的?答案在 git.c 的一张表里。

从命令行到函数
sequenceDiagram participant U as 用户 participant G as git 主程序 participant T as commands 表 participant R as run_builtin participant S as setup_git_directory participant C as cmd_commit U->>G: git commit -m "..." G->>G: handle_builtin 取 argv[0] G->>T: get_builtin("commit") T-->>G: { cmd_commit, RUN_SETUP | NEED_WORK_TREE } G->>R: run_builtin(p, argc, argv) R->>S: 定位 .git 并切到工作树根 S-->>R: prefix R->>C: p->fn(argc, argv, prefix, repo) C-->>U: 退出码
命令名先查表,再做仓库定位,最后才调用实现。实验 2 要加的 builtin 就走这条链。
图源
sequenceDiagram
  U->>G: git commit
  G->>T: get_builtin("commit")
  G->>R: run_builtin(...)
  R->>S: setup_git_directory
  R->>C: p->fn(...)

表长这样

git.c:533
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 | NEED_WORK_TREE

RUN_SETUP:必须找到仓库;找到后切换到工作树根,并把用户原来的子目录记录成 prefix。

NEED_WORK_TREE:这个命令需要工作区,不能只在裸仓库里跑。

也有一些命令不需要仓库,例如 git version;另一些“有仓库就更好”,例如 git config,它们用 RUN_SETUP_GENTLY。这个区别不是装饰,它决定了命令能不能在仓库外运行。

启动选项的分支
flowchart TB A["run_builtin"] --> B{"有 RUN_SETUP?"} B -->|"是"| C["setup_git_directory
找不到就退出"] 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,它至少应该带哪个启动选项?

1

检查理解

≈2 分钟 · 不计分
1.commands[] 表里的一项,不包含下面哪个信息?

2.关于 RUN_SETUP,哪句话是错的?

3.你要加一个只打印版本号、完全不需要仓库的 git hello,表项最合适写成:

2

用自己的话解释

≈2 分钟 · 本地保存
0 字

本小节可以带走

  • commands[] 把命令名映射到 cmd_* 函数与启动标志。
  • RUN_SETUP 负责定位仓库并切到工作树根;NEED_WORK_TREE 才要求有工作区。
  • 加一个 builtin 的四步:实现、声明、登记、加入构建。实验 2 就做这件事。
实验 0 · 12 分钟 · 难度:入门

不用 git init,手工搭一个仓库

如果 Git 只是一个对象库加引用,那么一个仓库的最小目录是什么?亲手搭出来,再让 Git 认它。

当前状态空目录;make-repo.sh 直接退出 1,运行 git status 会说 not a git repository。

任务

  1. 创建 .git/objects 与 .git/refs/heads。
  2. 写 .git/HEAD,内容为 ref: refs/heads/main。
  3. 写最小 .git/config:repositoryformatversion = 0、filemode = true、bare = false。
  4. 让 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
在临时空目录里验证
三级提示
提示 1 · 目录mkdir -p .git/objects .git/refs/heads。Git 不会要求 objects/info 或 refs/tags。
提示 2 · HEADprintf 'ref: refs/heads/main\n' > .git/HEAD。末尾必须有换行。
提示 3 · config用 heredoc 写 [core] 三行;repositoryformatversion 是 Git 判断“这是不是仓库”的关键。

终端检查:node course/check.mjs lab-gitdir;恢复骨架:node course/lab.mjs restore lab-gitdir。

02

跟着一次 add / commit 走

把上一节的四个落点拉成一条时间线:文件字节怎么变成 blob,索引记了什么,tree 什么时候写,最后是谁动了引用。实验 1 会把这条线亲手走一遍。

模块短片 · 29 秒add → tree → commit → ref

git add 的两个动作

阅读 ≈6 分钟 · 检查 ≈4 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆不往回翻:普通 commit 对象里,文件内容本身存在哪里?

上一节说对象不可变、索引是待提交快照。git add 正好同时碰这两层,但它不写 tree,也不写 commit。

add 做的两件事
flowchart LR W["hello.txt
工作区字节"] -->|"读文件 + 算哈希"| 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
一个动作写对象库(只增不改),另一个动作改索引(可反复覆盖)。tree 还不存在。
图源
flowchart LR
  W -->|读文件+算哈希| B[blob 对象]
  W -->|stat| E[索引项]
  B --> E
  E --> I[.git/index]

代码里的顺序

builtin/add.c 最终走到 read-cache.c 的 add_to_index。下面这段省略了路径规范化与 ignorecase 分支,只留下主干:

read-cache.c:721 · add_to_index
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);
	...
unsigned hash_flags = pretend ? 0 : INDEX_WRITE_OBJECT;

pretend:git add -n 只报告不落盘,所以不写对象。

INDEX_WRITE_OBJECT:正常 add 时,index_path 会把 blob 真正写进对象库。

fill_stat_cache_info(istate, ce, st);

stat 缓存:把文件的 mtime、size、ino、uid 等记进索引项。下一次 status 先比这些数字,不急着读文件内容。

所以索引项不是“文件名 → 内容”,而是“路径 → blob 哈希 + 一组 stat 证据 + 模式位”。这组证据是 status 能变快的关键,模块 4 会展开。

预测刚 git add hello.txt,还没 commit。此时 git write-tree 会失败吗?

为什么索引是排好序的数组

内存里的索引是 struct cache_entry **cache:一个按路径字节序排序的指针数组。插入新项时用 memmove 挪出空位,查找时二分。

read-cache.c:1312 · add_index_entry
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 的内部步骤按发生顺序点出来。

60 秒动手≈2 分钟

在一个测试仓库里 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 还不存在。

1

检查理解

≈2 分钟 · 不计分
1.git add 之后,对象库里一定新增了什么?

2.索引项里保存的 stat 信息,主要用途是:

3.把索引设计成按路径排序的数组,不会带来下面哪个好处?

2

用自己的话解释

≈2 分钟 · 本地保存
0 字

本小节可以带走

  • add = 写 blob + 插入/更新索引项,不写 tree/commit。
  • 索引项含 blob 哈希、模式位和 stat 缓存;stat 是 status 的快路径。
  • 索引按路径排序,查找快、插入要挪数组。

git commit 的收尾

阅读 ≈6 分钟 · 检查 ≈4 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆commit 写出的根 tree,来自哪里?

提交时,Git 先确保索引是最新的,再把索引变成 tree,最后写 commit 并移动引用。三个动作对应三个函数,顺序不能换。

commit 的五步
sequenceDiagram participant C as cmd_commit participant I as index participant T as cache-tree participant O as object store participant R as refs C->>I: prepare_index:加/删文件、refresh I->>T: cache_tree_update T->>O: 写出变化的 tree 对象 C->>O: commit_tree_extended 写 commit C->>R: update_head_with_reflog R-->>R: 写 .lock → rename → 追加 reflog
注意 cache-tree:提交并不重新扫描工作区,它复用索引里已知的子树。
图源
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,核心几行是:

builtin/commit.c:532
	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 发起一次引用事务。

builtin/commit.c:1938
	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);
	}
the_repository->index->cache_tree->oid

根 tree:提交用的不是“重新算一遍的树”,而是索引 cache-tree 顶端的 OID。

update_head_with_reflog(current_head, &oid, ...)

移动 HEAD:把 HEAD 从旧提交更新到新提交,同时把这次移动写进 reflog。HEAD 是符号引用,真正被改写的是它指向的分支。

所以“提交成功”的原子性边界在引用更新:commit 对象可能已经写进对象库,但引用事务失败时,你会看到一个没有分支指向的提交——它会在 reflog 或 fsck 里出现。实验 3 会利用这一点。

对象与引用的先后
flowchart LR A["索引
已 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[孤立对象]
1

检查理解

≈2 分钟 · 不计分
1.cache_tree_update 的主要收益是:

2.如果 commit 对象写成功、但引用事务失败,最可能的状态是:

3.为什么同一份文件内容重复提交,blob 不会重复写?

2

用自己的话解释

≈2 分钟 · 本地保存
0 字

本小节可以带走

  • 提交不重扫工作区:索引 + cache-tree 已经给出树。
  • commit 对象由 tree、parents、作者、信息拼成,写入前先算哈希。
  • 引用更新是最后一步;对象与引用之间有一个可观测的缝隙。
实验 1 · 18 分钟 · 难度:中

只用 plumbing 造一个 commit

不运行 git add,不运行 git commit。用底层命令把刚刚学的四步亲手接起来。

当前状态build.sh 里只有一个 TODO,运行后直接退出 1;沙盒仓库里什么也不会发生。

任务

  1. 用 hash-object -w 把 hello git\n 写成 blob。
  2. 用 update-index --add --cacheinfo 把 blob 以 100644 模式登记为 hello.txt。
  3. 用 write-tree 得到根 tree。
  4. 用 commit-tree 写一个无父提交,信息为 lab: one commit。
  5. 用 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
在真实仓库里运行,页面只读
三级提示(先自己想,卡住再展开)
提示 1 · 方向先把“内容 → blob”做实:printf 'hello git\n' | git hash-object -w --stdin。把输出存进变量。
提示 2 · 结构索引需要模式和路径:git update-index --add --cacheinfo 100644,$blob,hello.txt;然后 git write-tree。
提示 3 · 最后一步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。参考答案在检查器里,页面不展示。

03

对象库

上一节反复出现“写对象”。现在打开写入函数:一个对象在磁盘上到底长什么样,名字怎么算出来,读的时候又怎么在松散对象与 pack 之间找到它。

内容寻址:名字就是指纹

阅读 ≈7 分钟 · 检查 ≈5 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆上一节 commit 的最后一步动了什么?

Git 不存储“文件名 → 内容”的表。它把内容加上一个小头,算哈希,用哈希当文件名。取的时候,用同样的算法验证内容——名字本身就是完整性校验。

从字节到路径
flowchart LR A["内容字节
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"]
头里的类型与长度也参与哈希,所以 blob 与 tree 即使正文相同也不会撞名。
图源
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 生成;哈希覆盖“头 + 正文”。

object-file.c:102 · 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;
}
object-file.c:459 · hash_object_file
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);
}
+ 1

包含 NUL:xsnprintf 返回写入的字符数,加 1 把结尾的 NUL 也算进哈希输入。少算这个字节,所有哈希都会和 Git 对不上。

写入时,odb_write_object_ext 先算哈希,再检查对象是否已存在;存在就只更新 mtime(freshen),不重写。因此重复 add 同一内容不会让对象库膨胀。

预测两个不同路径的文件,内容完全相同。它们会有几个 blob 对象?

写入路径
stateDiagram-v2 [*] --> Hash Hash --> Exists: 算出 OID Exists --> Freshen: 对象已存在 Exists --> Write: 不存在 Freshen --> [*]: 只更新 mtime Write --> Compress: 加头 + zlib Compress --> Rename: 写临时文件 Rename --> [*]: 原子改名到 objects/xx/yyyy
“先查再写”让写入是幂等的;临时文件 + 改名保证读到的对象不会只写了一半。
图源
stateDiagram-v2
  [*] --> Hash
  Hash --> Exists
  Exists --> Freshen: exists
  Exists --> Write: missing
  Write --> Compress
  Compress --> Rename
  Rename --> [*]
60 秒动手≈1 分钟

算一次 blob 哈希,再改一个字节重算。

$ printf 'hello git\n' | git hash-object --stdin
$ printf 'hello git!\n' | git hash-object --stdin
看预期输出
8d0e41234f24b6da002d962a26c2495ea16a425f
d1c64694584cf480b01273f2c729fd8b6b7c320c

第二个哈希是 hello git!\n 的真实结果;它与第一个完全不同。

1

检查理解

≈2 分钟 · 不计分
1.对象哈希的输入是:

2.关于松散对象,哪句话是错的?

3.如果对象头里漏掉长度,最直接的后果是:

2

用自己的话解释

≈3 分钟 · 本地保存
0 字

本小节可以带走

  • 对象 = 类型 长度\0 + 正文,哈希覆盖全部字节。
  • 松散对象路径是 objects/<前2位>/<其余>,内容 zlib 压缩。
  • 写入先查存在,存在则 freshen;对象写入幂等。

读路径与 pack

阅读 ≈7 分钟 · 检查 ≈5 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆给定一个 OID,读对象时最先查哪里?

对象不可能永远以松散文件存在。一个 20 年历史的仓库有几百万个对象,逐个文件存会浪费 inode,也无法做跨对象压缩。Git 的解法是 packfile,但读接口保持不变:给哈希,拿类型和内容。

读一个对象的查找顺序
flowchart TB Q["已知 OID"] --> L{"松散对象存在?"} L -->|"是"| Z["解压 → 校验哈希 → 返回"] L -->|"否"| P{"在某个 pack 的 idx 里?"} P -->|"是"| D["按偏移读 pack
遇到 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 内偏移。

pack 与 idx
flowchart LR subgraph PACK[".pack"] H["PACK · version · count"] --> O1["对象 1
类型 + 长度 + 数据"] 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
pack 负责省空间,idx 负责可查找;delta 让相似对象只存差异。
图源
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:

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 必须做什么?

1

检查理解

≈2 分钟 · 不计分
1..idx 文件的核心作用是:

2.关于 delta 对象,哪句话正确?

3.为什么 cat-file --batch-check 通常比 --batch 快?

2

用自己的话解释

≈3 分钟 · 本地保存
0 字

本小节可以带走

  • 读对象先查松散,再查 pack idx,再查 alternates。
  • pack 省空间,idx 提供 O(log n) 查找;delta 需要 base。
  • 取“类型 + 大小”比取完整内容浅,batch-check 因此更快。
实验 2 · 24 分钟 · 难度:中高

给 Git 加一个 object-meta 子命令

在真实 C 源码里走完“实现 → 声明 → 登记 → 构建”,并用真实二进制验证。

当前状态git object-meta 不存在;执行会得到 git: 'object-meta' is not a git command。

任务

  1. 新建 builtin/object-meta.c,实现 cmd_object_meta。
  2. 在 builtin.h 声明函数。
  3. 在 git.c 的 commands[] 登记,带 RUN_SETUP。
  4. 在 Makefile 的 BUILTIN_OBJS 加入目标文件。
  5. 让 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");
}
会运行增量 make,再执行真实二进制
三级提示(先自己想,卡住再展开)
提示 1 · 解析parse_options 的 OPT_BOOL('s', NULL, &size_only, ...);解析后 argc 应等于 1。
提示 2 · 取 OIDrepo_get_oid(repo, argv[0], &oid) 返回非零表示解析失败。
提示 3 · 取类型与大小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。检查器不会改你的源码,只读并构建。

04

索引

索引是 Git 里最像“数据库”的部分:一个二进制文件、按路径排序的条目、每个条目带着 stat 证据。理解它,才能理解 status 为什么快、commit 为什么不重扫工作区。

一张排好序的表

阅读 ≈7 分钟 · 检查 ≈5 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆索引条目里,除了 blob 哈希和路径,还存了什么?

.git/index 不是文本。它以 DIRC 开头,后面是版本、条目数、条目数组、可选扩展,最后是校验哈希。文件本身还可能被 mmap,读的时候不复制整份内容。

index 文件的字节布局
flowchart TB subgraph HEADER["12 字节头"] S["DIRC"] --> V["版本 2 / 3 / 4"] V --> N["条目数"] end N --> E1["条目 1
路径 + stat + OID + 标志"] E1 --> E2["条目 2
按路径排序"] E2 --> X["扩展
TREE / UNTR / link ..."] X --> H["尾部哈希"]
条目按路径字节序排列;扩展是可选块,cache-tree 就存在 TREE 扩展里。
图源
flowchart TB
  DIRC --> version --> entries
  entries --> e1 --> e2
  e2 --> extensions --> trailer

条目里到底有什么

read-cache-ll.h:22 · struct cache_entry
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 */
};
struct stat_data ce_stat_data;

stat 证据:ctime、mtime、dev、ino、uid、gid、size。用来判断“工作区文件是否可能变过”。

unsigned int ce_mode;

模式位:普通文件 100644 / 100755、符号链接 120000、子模块 160000。只有可执行位有意义。

unsigned int ce_flags;

标志:低两位是合并冲突的 stage(0 正常,1/2/3 分别 base/ours/theirs);高位是 skip-worktree、intent-to-add 等。

磁盘格式与内存结构不完全一样:字段按固定宽度序列化,路径后面补 NUL 到 8 字节边界(v2/v3);v4 用前缀压缩路径。内存里则是上面这个带柔性数组的结构。

排序按 .git/index 从前往后的字节顺序点击。

一个条目的状态
stateDiagram-v2 [*] --> Clean: add / checkout 写入 Clean --> Suspicious: stat 变了 Suspicious --> Dirty: 重新哈希不一致 Suspicious --> Clean: 重新哈希一致 Dirty --> Staged: 再次 add Staged --> Clean: 写入索引 Clean --> Conflict: merge 冲突 Conflict --> Staged: 解决后 add
“干净”不是内容真的没变,而是“stat 证据支持它没变”。可疑时才回到内容层验证。
图源
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
60 秒动手≈2 分钟

直接看索引的头 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 是条目数(随仓库变化)。

1

检查理解

≈2 分钟 · 不计分
1.索引条目的 stage 位(0/1/2/3)主要表示:

2.为什么索引条目要按路径排序?

3.v4 索引格式用路径前缀压缩,主要为了:

2

用自己的话解释

≈3 分钟 · 本地保存
0 字

本小节可以带走

  • index = DIRC 头 + 排序条目 + 扩展 + 校验哈希。
  • 条目含模式、stage、blob OID、stat 证据与路径。
  • 磁盘格式与内存结构不同;v4 做路径前缀压缩。

status 的快路径

阅读 ≈6 分钟 · 检查 ≈4 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆判断一个已跟踪文件是否修改,status 先比什么?

git status 要回答三个问题:哪些已暂存、哪些改了没暂存、哪些没被跟踪。第三项最贵,因为要遍历工作区。索引与 cache-tree 的设计就是为了尽量不读内容。

判断一个路径是否修改
flowchart TB A["lstat 工作区文件"] --> B{"索引里有这个路径?"} B -->|"没有"| U["untracked"] B -->|"有"| C{"stat 与索引一致?"} C -->|"一致"| D{"racy?
mtime 与索引时间同秒"} D -->|"否"| CLEAN["干净,跳过"] D -->|"是"| H["读内容 + 哈希"] C -->|"不一致"| H H --> E{"OID 相同?"} E -->|"是"| F["更新 stat,标记干净"] E -->|"否"| G["modified"]
便宜的判断在前,昂贵的哈希在后;racy 检查防止“同一秒内改动”被 stat 骗过。
图源
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(用一个目录条目代表整棵未展开的子树)。它们都在解决同一个问题:索引变大后,如何避免每次操作都碰全部条目。

优化各自挡住哪一层成本
flowchart LR S["status"] --> A["逐路径 stat
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 吗?

1

检查理解

≈2 分钟 · 不计分
1.如果文件 mtime 和大小都没变,但内容被改了(假设能绕过时间精度),Git 最可能:

2.untracked cache 主要避免:

3.sparse index 与 sparse checkout 的关系是:

2

用自己的话解释

≈3 分钟 · 本地保存
0 字

本小节可以带走

  • status 先用 stat 判断,再用哈希验证可疑路径。
  • racy clean 处理时间戳精度不足的边界。
  • cache-tree、untracked cache、split/sparse index 各削减一层成本。
实验 4 · 20 分钟 · 难度:中高

直接读 .git/index 的二进制

不用 git ls-files。自己按 DIRC 格式解析版本、条目、OID、模式和 stage。

当前状态index-report.py 只抛出 not implemented;沙盒里的索引已经准备好,但没人读它。

任务

  1. 读 12 字节头:DIRC、版本(必须为 2)、条目数。
  2. 逐条解析:40 字节 stat、20 字节 OID、2 字节 flags、路径。
  3. 路径后补 NUL 到 8 字节边界;解析完 count 个条目。
  4. 输出 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")
会创建一个含中文名与可执行文件的索引
三级提示
提示 1 · 头magic, version, count = struct.unpack(">4sII", data[:12]);magic != b"DIRC" 就退出。
提示 2 · 条目从偏移 12 开始:fields = struct.unpack(">10I", data[off:off+40]),mode = fields[6];随后 20 字节 OID、2 字节 flags。
提示 3 · 长度与标志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。

05

引用与可达性

对象层是只增不改的,真正让历史“活”起来的是引用:分支、HEAD、reflog。它们决定哪些对象算“还在”,也决定了 gc 能删什么、你能找回什么。

模块短片 · 32 秒HEAD → ref → reflog → 可达性

引用就是一个文件

阅读 ≈7 分钟 · 检查 ≈5 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆普通状态下,.git/HEAD 的内容是什么?

打开 .git/refs/heads/main,你会看到 40 个十六进制字符加一个换行。没有 JSON,没有数据库。HEAD 稍特殊:它通常写着 ref: refs/heads/main,是一个符号引用。

引用如何指向对象
flowchart LR H["HEAD
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。

refs/files-backend.c:2058 · write_ref_to_lockfile
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;
}
write_in_full(fd, oid_to_hex(oid), hexsz)

写 OID:写入 40(SHA-1)或 64(SHA-256)个十六进制字符,不含换行。

fsync_component(FSYNC_COMPONENT_REFERENCE, fd)

落盘再改名:先把新值刷到磁盘,再原子替换;崩溃时读端只会看到完整的旧值或完整的新值。

上层通过 refs_update_ref 发起事务:先准备所有锁,再提交。HEAD 的更新也走同一条路——它最终解析到分支文件,所以“提交”在引用层就是改一个分支文件。

refs.c:1579 · refs_update_ref
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: 创建 refs/heads/main.lock Locked --> Prepared: 写入新 OID + fsync Prepared --> Committed: rename 覆盖 main Committed --> Logged: 追加 reflog Logged --> [*] Prepared --> Aborted: 校验失败 Aborted --> [*]: 删除 .lock
读到的旧值永远完整,新值要么完整出现,要么完全不出现;reflog 记录的是“从哪个旧值到哪个新值”。
图源
stateDiagram-v2
  [*] --> Locked
  Locked --> Prepared: write + fsync
  Prepared --> Committed: rename
  Committed --> Logged: reflog
  Prepared --> Aborted
60 秒动手≈2 分钟

读 HEAD、分支文件与 reflog,确认谁存了真正的哈希。

$ cat .git/HEAD
$ cat .git/refs/heads/main
$ git reflog -1
看预期输出
ref: refs/heads/main
4afa24239d665b676522345a3b64d722c25119b6
4afa242 HEAD@{0}: commit: lab: one commit

HEAD 是符号引用;哈希在分支文件里;reflog 记录这次移动。

1

检查理解

≈2 分钟 · 不计分
1.普通状态下 .git/HEAD 的内容是:

2.引用更新为什么需要 .lock 文件?

3.一个分支已经存在于 packed-refs 中,你再提交一次,会发生什么?

2

用自己的话解释

≈3 分钟 · 本地保存
0 字

本小节可以带走

  • 分支 = 一个写着哈希的文件;HEAD = 指向分支的符号引用。
  • 松散引用优先于 packed-refs。
  • 引用更新用 lock + rename,成功后追加 reflog。

可达性与找回

阅读 ≈7 分钟 · 检查 ≈5 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆gc 决定一个对象能不能删,主要看什么?

对象库不关心“历史”,它只存对象。是引用把某些提交标成“仍然可达”,gc 再从这些根出发,标记所有能走到的对象;走不到的就是垃圾。但垃圾不一定立刻删——reflog、索引、其他引用都可能是根。

可达与不可达
flowchart LR subgraph ROOTS["根"] M["refs/heads/main"] T["refs/tags/v1"] L["reflog"] end M --> C4["C4"] C4 --> C3["C3"] C3 --> C2["C2"] C2 --> C1["C1"] T --> C2 L -.-> C5["C5 孤立提交"] C5 --> C3 C5 -.->|"reflog 过期 + gc"| X["可被 prune"] style C5 fill:#FDF0EC,stroke:#B83A1F style X fill:#F7F7F8,stroke:#5B6470
同一个 C5,有 reflog 记着就暂时安全;reflog 过期且没有引用后,才会进入可删除集合。
图源
flowchart LR
  main --> C4 --> C3 --> C2 --> C1
  tag --> C2
  reflog -.-> C5
  C5 --> C3
  C5 -. prune .-> X

可达性不是“存在时间”,也不是“有没有被 push”。一个提交可以很旧但被标签引用;也可以很新但没有任何根指向它。

预测reflog 已过期,又执行了 git gc --prune=now。还能用 fsck 找回那个提交吗?

误删之后,为什么还能找回来

三种常见证据源,按可靠性排序:

  1. reflog:HEAD 与各分支的移动记录,包含旧值、新值、动作和时间。
  2. fsck:扫描对象库,报告 dangling / unreachable 的 commit、tree、blob。
  3. 索引与工作区:即使提交没了,暂存区仍可能指向 blob。
找回一个丢失提交的路径
flowchart TB A["分支被删 / reset --hard"] --> B{"reflog 还在?"} B -->|"是"| C["git reflog 找旧值"] B -->|"否"| D["git fsck --lost-found"] D --> E["逐个 cat-file -p 看提交信息"] C --> F["git branch rescue <sha>"] E --> F F --> G["git fsck 验证可达"]
先找证据,再建引用;不要急着 gc,也不要先改工作区。
图源
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 有回收站。

排序把“找回丢失提交”的操作按安全顺序点出来。

1

检查理解

≈2 分钟 · 不计分
1.一个提交没有任何分支/标签指向,但 HEAD reflog 里还有记录。它现在:

2.git fsck --lost-found 做的事情,最准确的是:

3.为什么误删后第一件事通常是“停止 gc / 不要再 prune”?

2

用自己的话解释

≈3 分钟 · 本地保存
0 字

本小节可以带走

  • 可达性从引用、reflog、索引等根出发;gc 只删不可达且过期的对象。
  • reflog 是最快的证据,fsck 是兜底扫描。
  • 恢复 = 新建引用,而不是修改对象。
实验 3 · 20 分钟 · 难度:中高

找回一个被删掉的提交

沙盒里有一条被 reset 掉、分支也被删掉的功能提交。reflog 已过期,但对象还在。写脚本把它救回来。

当前状态recover.sh 直接退出 1;沙盒里没有 rescue 分支,目标提交仍不可达。

任务

  1. 在沙盒里找到信息为 feature: add secret 的提交。
  2. 确认它的 tree 里有 feature.txt,内容为 recovered\n。
  3. 创建分支 rescue 指向它;不要移动 main。
  4. 把证据写进 notes.md:用了什么命令、SHA、为什么是它。

交付物

course/work/lab-recover/recover.sh
course/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
检查器会重建一个同样的沙盒
三级提示(先自己想,卡住再展开)
提示 1 · 找候选git fsck --lost-found 会列出 dangling commit <sha>;沙盒里不止一个。
提示 2 · 辨认对每个候选运行 git cat-file -p <sha>,看提交信息;目标信息是 feature: add secret。
提示 3 · 固定证据用 git branch rescue <sha> 建引用;在 notes.md 里写清命令、SHA、以及它为什么不是另一个 dangling 提交。

终端检查:node course/check.mjs lab-recover;重建沙盒:node course/lab.mjs start lab-recover。参考答案在检查器里。

06

打包与传输

最后一层:对象怎么被压进一个 pack,clone/fetch 又怎么只传缺少的部分。这一模块是可选扩展,不配实验;读完你就有了继续读 Documentation/technical/ 的地图。

pack 内部

阅读 ≈6 分钟 · 检查 ≈4 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆pack 旁边哪个文件提供“哈希 → 偏移”的查找?

pack 的对象记录不是定长结构。每个对象开头是一个变长头:最高位表示“还有后续字节”,接着 3 位是类型,最低 4 位是 size 的低 4 位;后续每字节再贡献 7 位 size。

一个对象记录的编码
flowchart LR A["首字节
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 解压内容"]
类型只有 3 位,所以 8 种:commit/tree/blob/tag、两种 delta、保留值。size 是解压后的大小。
图源
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 表。

Documentation/technical/pack-format.txt · idx v2
    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[i] = number of objects whose first byte is <= i

前缀计数:fanout[0x8d] - fanout[0x8c] 就是首字节为 0x8d 的对象数量;两个相邻项直接给出二分区间。

.bitmap 是另一份可选文件:为某些提交预计算“可达对象集合”的位图。它让 fetch/push 在判断“对方已经有哪些对象”时不必逐个走图,是大型仓库传输提速的关键之一。

delta 链与重新打包
flowchart LR B["base 完整对象"] --> D1["delta 1"] D1 --> D2["delta 2"] D2 --> D3["delta 3"] D3 -.->|"链太长,读取变慢"| R["repack:限制深度"] R --> B2["新的 base + 更短链"]
delta 越深越省空间,但读一个对象要追完整条链;pack.depth 就是在两者之间取平衡。
图源
flowchart LR
  base --> d1 --> d2 --> d3
  d3 -. too deep .-> repack
  repack --> newbase

辨析OFS_DELTA 与 REF_DELTA 的核心区别是:

60 秒动手≈1 分钟

在 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 里读出同一个数字。

1

检查理解

≈2 分钟 · 不计分
1.pack 中对象头里的 size 表示:

2.idx 的 fanout 表作用是:

3.delta 链过深的主要代价是:

2

用自己的话解释

≈2 分钟 · 本地保存
0 字

本小节可以带走

  • pack 对象头是变长编码:类型 + 解压后大小。
  • idx = fanout + 排序 OID + CRC + 偏移;bitmap 加速可达性判断。
  • delta 深度是空间与读取延迟的权衡。

clone 与 fetch

阅读 ≈6 分钟 · 检查 ≈4 分钟
本节视频 · 看完就能动手命令与输出来自真实仓库

回忆fetch 协商的核心是哪一对消息?

传输的本质是两句话:“我有哪些引用”和“我要哪些对象”。客户端先拿到对方的引用列表,再说“我要这些 tip,但我已经有这些”,服务器据此算出差集并生成一个 pack。

一次 clone 的往返
sequenceDiagram participant C as 客户端 participant S as 服务器 C->>S: GET info/refs?service=git-upload-pack S-->>C: 引用广告(main、tags、capabilities) C->>S: want <tip> + have <local> + done S->>S: 计算可达差集,生成 pack S-->>C: packfile C->>C: index-pack 建 idx C->>C: 更新 refs,checkout 工作区
clone 是“空仓库的 fetch + checkout”;真正省流量的是 have/want 协商,而不是压缩算法本身。
图源
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["引用广告"] --> B["协商 want / have"] B --> C["服务器生成 pack"] C --> D["客户端 index-pack"] D --> E["更新远端跟踪引用"] E --> F["checkout:读 tree,写文件"] F --> G["索引写入 stat"]
pack 落地不等于工作区有文件;checkout 是另一段:读 tree、写工作区、填索引。
图源
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 之后,工作区文件是在哪一步出现的?

1

检查理解

≈2 分钟 · 不计分
1.fetch 时,客户端发送 have 的主要目的是:

2.协议 v2 相对 v0/v1 的一个关键变化是:

3.“clone 完成”与“工作区可用”之间还差哪一步?

2

用自己的话解释

≈2 分钟 · 本地保存
0 字

本小节可以带走

  • fetch/clone 的核心是引用广告 + want/have 协商。
  • pack 落地后还要 index-pack、更新引用、checkout。
  • 协议 v2 把 ls-refs 与 fetch 拆开,支持按需查询。
实验 6 · 16 分钟 · 难度:中高 · 扩展(可选)

读 .pack 与 .idx 的头

不调用 git verify-pack。直接读 pack 的对象数,以及 idx 的 fanout 与第一个 OID。

当前状态pack-report.py 只抛出 not implemented;沙盒里已经有一个 repack 后的仓库。

任务

  1. 找到 .git/objects/pack/*.pack。
  2. 读 12 字节:PACK、版本、对象数。
  3. 读同名 .idx:magic \377tOc、版本、256 项 fanout。
  4. 输出对象数、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")
会创建 6 个提交并 repack
三级提示
提示 1 · packmagic, version, count = struct.unpack(">4sII", head[:12]);magic 必须是 b"PACK"。
提示 2 · idx 头magic 是 b"\377tOc",版本 2;fanout 从偏移 8 开始,共 256 个 big-endian uint32。
提示 3 · 计数与首个 OIDidx_objects = fanout[255];排序后的第一个 OID 在偏移 8 + 256*4,取 20 字节转 hex。

终端检查:node course/check.mjs lab-pack;恢复骨架:node course/lab.mjs restore lab-pack。这是扩展实验,不计入核心路径。