
从零实现 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文件名到对象哈希的映射文件具体内容committree、parent、作者、提交信息文件差异ref一个名字指向哪个 commit历史本身这张表里最反直觉的是两点第一blob 不保存文件名。 同一份内容可以出现在不同路径下如果 blob 绑定路径就没法天然复用。第二commit 不保存 diff。 commit 指向的是一次完整目录快照两个版本之间的 diff 是比较两棵 tree 算出来的。mini-git 里对象写入的核心在src/core/object.c。它和真实 Git 使用同一个基本规则步骤动作1拼出对象头类型、空格、内容长度、NUL2把对象头和原始内容连起来3对整段内容计算 SHA-14用 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 条目commitindextree commit更新引用commit hashrefs/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先把已跟踪文件的修改写入 index3把 index 写成 tree4读取当前 HEAD 作为 parent5创建 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 indexindex 解析是否兼容Git 读 mini-git 更新后的 indexindex 写出是否兼容ref/tag 互读引用系统是否兼容这里我最看重第三类Git 读 mini-git 对象。因为这不是“我的代码能理解我的格式”而是“真实 Git 承认我写出来的是 Git 对象”。比如 mini-git 写出一个 commit 后测试会用真实 Git 去做这些事Git 命令目的git cat-file -p commit看真实 Git 能否解析 commitgit ls-tree commit^{tree}看真实 Git 能否解析 treegit log --oneline看真实 Git 是否承认这段历史这套验证方式让我对项目更有底气。因为它不是“长得像 Git”而是在关键数据格式上确实和 Git 对齐。五、几个实现中真正咬人的坑如果只看最终代码很多地方都像理所当然。但实际实现时下面这些坑都很容易踩。坑后果修法push 首条指令能力串用空格分隔服务端把能力串当引用名返回异常receive-pack 首行必须用 NUL 分隔把结构体内嵌 hash 当连续数组传第二个 hash 读到引用名字节服务端报 not our ref需要连续 hash 时先拷贝成紧凑数组index 不按路径排序真实 Git 拒读 index写出前排序tree 目录 mode 写成040000tree 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并更新 indexcommitindex 写成 tree再创建 commitbranch创建或移动 refcheckout切换 HEAD并恢复 index/worktreereset移动 HEAD/分支并按模式处理 index/worktreemerge判断 fast-forward 或基于 merge-base 做三方合并fetch下载远端对象和引用但不改当前工作区pullfetch 后再 merge 或 rebase这个表比单独背命令更有用。因为一旦你知道命令在改哪一层就不容易慌。工作区、index、对象库、引用这四层一旦分清很多 Git 问题都会从“玄学”变成“状态变化”。总结mini-git 对我来说不是一个“我也能写 Git”的炫技项目。它更像一次把 Git 拆开再装回去的练习blob 解释内容寻址。tree 解释目录快照。commit 解释历史关系。index 解释为什么提交前要有准备清单。ref 解释分支为什么只是可移动名字。兼容性测试解释为什么必须尊重真实 Git 的字节级格式。如果只想会用 Git背命令也许够。但如果想真正理解 Git 为什么这样设计写一个能被真实 Git 读取的 mini-git会比看十遍概念图更直接。