news 2026/10/3 2:26:41

从零实现 mini-git:用真实 Git 验证 blob、tree、commit 和 index

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现 mini-git:用真实 Git 验证 blob、tree、commit 和 index

从零实现 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拼出对象头:类型、空格、内容长度、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 条目
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,先把已跟踪文件的修改写入 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 indexindex 解析是否兼容
Git 读 mini-git 更新后的 indexindex 写出是否兼容
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 写成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,并更新 index
commitindex 写成 tree,再创建 commit
branch创建或移动 ref
checkout切换 HEAD,并恢复 index/worktree
reset移动 HEAD/分支,并按模式处理 index/worktree
merge判断 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,会比看十遍概念图更直接。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 2:24:37

darktable:免费实用的RAW处理器,快速从导入到出片

darktable&#xff1a;免费实用的RAW处理器&#xff0c;快速从导入到出片 【免费下载链接】darktable darktable is an open source photography workflow application and raw developer 项目地址: https://gitcode.com/GitHub_Trending/da/darktable RAW 打开灰蒙蒙&a…

作者头像 李华
网站建设 2026/10/3 2:24:36

一次扫 100 个仓库会不会失控?Codex Security 批量扫描成本门禁

一次扫 100 个仓库会不会失控?Codex Security 批量扫描成本门禁 [!NOTE] Codex Security 的 bulk-scan 能发现 GitHub 仓库或读取固定提交的 CSV,并把每个仓库隔离成可续跑、可审计的独立尝试。 真正的成本门禁不是把 workers 调大,而是先冻结清单、分波次试点、区分仓库级并…

作者头像 李华