从零实现 mini-git:用真实 Git 验证 blob、tree、commit 和 index
项目地址:https://github.com/yituanxing/mini-git
我一开始写 mini-git,不是为了再造一个能替代 Git 的工具。
真正的动机更简单:很多 Git 概念背起来都像八股,但一旦自己写一遍,就会发现它们不是孤立知识点,而是同一个模型的不同侧面。
比如这些问题:
- 为什么文件内容叫 blob,却不保存文件名?
- 为什么 commit 指向 tree,而不是直接保存 diff?
- 为什么 Git 明明可以直接提交工作区,还要多一个 index?
- 为什么分支只是一个名字,却能表示一条历史?
- 为什么我自己写出的对象,真实 Git 也能读?
mini-git 这个项目就是沿着这些问题写出来的。它用 C 语言实现了一个教学版 Git,支持 init、add、commit、status、log、diff、branch、checkout、merge、rebase、stash、reflog、clone、fetch、pull、push 等命令。
但这篇不做功能清单。功能清单很容易变成宣传稿,读完也不知道项目真正难在哪里。
我想讲的是:这个项目里最关键的几个设计点,以及它们为什么必须这么做。
一、先把 Git 想成对象数据库,而不是命令集合
很多人学 Git 是从命令开始的:
| 命令 | 常见印象 |
|---|---|
| git add | 加到暂存区 |
| git commit | 提交一次版本 |
| git branch | 创建分支 |
| git checkout | 切换分支 |
| git merge | 合并代码 |
这个入口没错,但写实现时不够。
真正落到代码里,Git 首先是一个对象数据库。
| 对象 | 保存什么 | 不保存什么 |
|---|---|---|
| blob | 文件内容 | 文件名、路径 |
| tree | 文件名到对象哈希的映射 | 文件具体内容 |
| commit | tree、parent、作者、提交信息 | 文件差异 |
| ref | 一个名字指向哪个 commit | 历史本身 |
这张表里最反直觉的是两点:
第一,blob 不保存文件名。 同一份内容可以出现在不同路径下,如果 blob 绑定路径,就没法天然复用。
第二,commit 不保存 diff。 commit 指向的是一次完整目录快照,两个版本之间的 diff 是比较两棵 tree 算出来的。
mini-git 里对象写入的核心在src/core/object.c。它和真实 Git 使用同一个基本规则:
| 步骤 | 动作 |
|---|---|
| 1 | 拼出对象头:类型、空格、内容长度、NUL |
| 2 | 把对象头和原始内容连起来 |
| 3 | 对整段内容计算 SHA-1 |
| 4 | 用 zlib 压缩整段对象数据 |
| 5 | 按哈希前两位建目录,剩余 38 位做文件名 |
也就是这个形态:
| 路径 | 含义 |
|---|---|
.git/objects/3f/28c... | 3f28c...这个对象 |
.git/objects/pack/ | 打包后的对象 |
我觉得这里最值得记住的不是 SHA-1,而是“内容寻址”。
路径不是对象的身份,文件名也不是对象的身份,内容算出来的哈希才是身份。这个视角一旦建立,后面 tree、commit、branch 都会变得顺。
二、index 不是多余的一层,它是“下一次提交的草稿”
Git 里最容易被低估的是 index。
如果只从用户体验看,它像一个麻烦的中间层:为什么不能直接把工作区提交掉?
但实现时会发现,index 很重要,因为 commit 不是“把工作区扫一遍”,而是“把准备好的清单固化成 tree”。
mini-git 里的提交流程可以拆成这样:
| 阶段 | 数据来源 | 产物 |
|---|---|---|
| 工作区 | 普通文件 | 用户正在编辑的内容 |
| add | 工作区文件 | blob + index 条目 |
| commit | index | tree + commit |
| 更新引用 | commit hash | refs/heads/master |
也就是说,commit 不直接相信工作区,它相信 index。
这个设计带来一个很重要的能力:你可以只提交一部分文件,甚至同一个工作区里,一些修改进入下一次提交,另一些修改继续留在本地。
mini-git 的 index 实现在src/core/index.c。这里比想象中细很多:
| 细节 | 为什么重要 |
|---|---|
文件签名必须是DIRC | 真实 Git 识别 index 的入口 |
| 使用 index v2 格式 | 和真实 Git 互操作 |
| 条目需要 8 字节对齐 | 少一个 NUL 都会导致解析错位 |
| 尾部有 SHA-1 checksum | 防止损坏的 index 被静默读入 |
| 写出前按路径排序 | 否则真实 Git 会报 unordered stage entries |
我踩过的一个坑就很典型:index 条目大小不是“固定字段 + 文件名”这么简单,它还要包含文件名后的 NUL,并补齐到 8 字节。如果漏算这个 NUL,短路径时可能不明显,条目一多就开始错位,真实 Git 会直接拒读。
这类坑很有价值。它逼着你承认:Git 的兼容性不是“意思差不多”就行,而是字节级格式必须对。
三、commit 的本质:给一棵 tree 加上历史关系
mini-git 里的 commit 入口在src/commands/cmd_commit.c。
它做的事情并不神秘:
| 步骤 | 动作 |
|---|---|
| 1 | 打开对象库、index、ref 管理器 |
| 2 | 如果有-a,先把已跟踪文件的修改写入 index |
| 3 | 把 index 写成 tree |
| 4 | 读取当前 HEAD 作为 parent |
| 5 | 创建 commit 对象 |
| 6 | 更新当前分支引用 |
| 7 | 写 reflog |
这里有一个很容易被忽略的点:commit 本身不等于文件快照。
更准确地说:
| 名字 | 作用 |
|---|---|
| tree | 表示这次提交时,项目目录长什么样 |
| commit | 表示这棵 tree 在历史里的位置 |
所以 commit 至少要回答两个问题:
- 当前版本的目录快照是哪棵 tree?
- 它的父提交是谁?
如果是 merge commit,它还会有两个 parent。这个结构不是为了看起来高级,而是为了让两条历史都被保留下来。
这也是为什么我不喜欢把 Git 讲成“保存差异”。那会误导读者。
更贴近实现的说法是:
Git 保存对象和引用;差异是比较对象算出来的。
四、用真实 Git 做验收,而不是自己说自己对
写教学项目最怕“自嗨”:自己的程序写对象,自己的程序读对象,看起来能跑,但其实和真实 Git 不兼容。
所以 mini-git 里有一组兼容性测试,重点不是测 UI 文案,而是让真实 Git 参与验收。
测试文件在tests/test_compat.ps1,里面有几组很关键的检查:
| 测试 | 验证什么 |
|---|---|
| 同一内容 hash 是否一致 | mini-git 和 Git 的 blob 规则是否一致 |
| mini-git 读 Git 对象 | commit/tree/blob 解析是否兼容 |
| Git 读 mini-git 对象 | mini-git 写出的对象是否被真实 Git 接受 |
| mini-git 读 Git index | index 解析是否兼容 |
| Git 读 mini-git 更新后的 index | index 写出是否兼容 |
| ref/tag 互读 | 引用系统是否兼容 |
这里我最看重第三类:Git 读 mini-git 对象。
因为这不是“我的代码能理解我的格式”,而是“真实 Git 承认我写出来的是 Git 对象”。
比如 mini-git 写出一个 commit 后,测试会用真实 Git 去做这些事:
| Git 命令 | 目的 |
|---|---|
git cat-file -p <commit> | 看真实 Git 能否解析 commit |
git ls-tree <commit>^{tree} | 看真实 Git 能否解析 tree |
git log --oneline | 看真实 Git 是否承认这段历史 |
这套验证方式让我对项目更有底气。因为它不是“长得像 Git”,而是在关键数据格式上确实和 Git 对齐。
五、几个实现中真正咬人的坑
如果只看最终代码,很多地方都像理所当然。但实际实现时,下面这些坑都很容易踩。
| 坑 | 后果 | 修法 |
|---|---|---|
| push 首条指令能力串用空格分隔 | 服务端把能力串当引用名,返回异常 | receive-pack 首行必须用 NUL 分隔 |
| 把结构体内嵌 hash 当连续数组传 | 第二个 hash 读到引用名字节,服务端报 not our ref | 需要连续 hash 时先拷贝成紧凑数组 |
| index 不按路径排序 | 真实 Git 拒读 index | 写出前排序 |
tree 目录 mode 写成040000 | tree hash 和真实 Git 不一致 | 存储写40000,显示时补 0 |
| 固定长度遍历队列 | 大历史下静默丢祖先 | 改动态扩容 |
| 新分支 push 空 pack 被当成无事可做 | 远端引用没有更新 | 对象差集为空也要发送引用更新 |
这些坑有一个共同点:它们不是算法题式的“会不会”,而是工程实现里的“差一个字节就不兼容”。
这也是我觉得写 mini-git 有价值的地方。
只读教程时,你会觉得“对象、引用、pack、index”都是概念;真正写代码时,它们会变成很具体的边界条件。
六、这个项目没有做什么
开源复盘不能只讲亮点,也要讲边界。
mini-git 是教学实现,不是生产级 Git。它刻意保留了可读性,也简化了很多真实 Git 的复杂场景。
| 方向 | 当前边界 |
|---|---|
| index | 支持 v2 读写,但不覆盖真实 Git 的所有扩展 |
| merge | 有三方合并和冲突处理,但不追求完整复刻 Git 所有策略 |
| pack | 支持基础 pack/idx 和部分 delta 场景,但不是工业级优化 |
| 网络 | 对接 Smart HTTP,但没有覆盖所有协议版本和认证场景 |
| 性能 | 够教学和测试,不以大型仓库性能为第一目标 |
这些边界不是缺陷说明书,而是项目定位的一部分。
我希望它做到的是:读者能打开源码,看见 Git 核心概念是怎么落到磁盘、哈希、对象、引用和协议上的。
如果为了“完整复刻 Git”把代码写到几十万行,这个教学价值反而会下降。
七、我从这个项目里得到的最大收获
写完 mini-git 后,我对 Git 最大的理解变化是:
Git 不是一堆命令的集合,而是一套非常稳定的数据模型。
命令只是操作这套模型的不同方式。
| 命令 | 本质动作 |
|---|---|
| add | 工作区内容写成 blob,并更新 index |
| commit | index 写成 tree,再创建 commit |
| branch | 创建或移动 ref |
| checkout | 切换 HEAD,并恢复 index/worktree |
| reset | 移动 HEAD/分支,并按模式处理 index/worktree |
| merge | 判断 fast-forward 或基于 merge-base 做三方合并 |
| fetch | 下载远端对象和引用,但不改当前工作区 |
| pull | fetch 后再 merge 或 rebase |
这个表比单独背命令更有用。
因为一旦你知道命令在改哪一层,就不容易慌。
工作区、index、对象库、引用,这四层一旦分清,很多 Git 问题都会从“玄学”变成“状态变化”。
总结
mini-git 对我来说不是一个“我也能写 Git”的炫技项目。
它更像一次把 Git 拆开再装回去的练习:
- blob 解释内容寻址。
- tree 解释目录快照。
- commit 解释历史关系。
- index 解释为什么提交前要有准备清单。
- ref 解释分支为什么只是可移动名字。
- 兼容性测试解释为什么必须尊重真实 Git 的字节级格式。
如果只想会用 Git,背命令也许够。
但如果想真正理解 Git 为什么这样设计,写一个能被真实 Git 读取的 mini-git,会比看十遍概念图更直接。