TL;DR 本文较长,如果对 Git 内部实现不感兴趣可以快速跳过中间两个章节。
吾诗已成。无论大神的震怒,还是山崩地裂,都不能把它化为无形!
—— 奥维德《变形记》
背景
Linux 作为规模最大也最成功的开源项目,吸引了全球程序员的贡献。到目前为止,已有两万多名开发者给 Linux Kernel 提交过代码。但许多人不知道的是,在项目的前十年(1991 ~ 2002)里,项目管理员 Linus Torvalds 并没有借助任何版本控制工具,而是手工通过 patch 文件来合并代码。
这并非因为他喜欢手工操作,而是他对当时的软件配置管理工具(SCM)极为挑剔。无论是商用的 ClearCase,还是开源的 CVS、SVN,都无法满足他的要求。在他看来,一个能支撑 Linux 内核开发的版本控制系统必须满足几个条件:1) 速度快;2) 支持数千个分支的并行开发;3) 分布式;4) 能处理超大型项目。
直到 2002 年,Linus 找到了一款基本符合要求的商业工具——BitKeeper。BitKeeper 公司允许 Linux 社区免费使用,但需遵守协议,不得进行反编译。后来,一位社区开发者反编译了 BitKeeper 并使用了未公开接口,导致该公司收回了免费授权。被逼无奈之下,Linus 利用一个十天的假期,亲手实现了一款分布式版本控制系统(DVCS),并将其命名为 Git,推送给社区使用。
设计
如今,Git 已成为全球开发者的标配,其基本用法已无需赘述。今天,我们不妨深入其内部,看看 Linus 最初是如何构思 Git 的。在阅读下文前,或许你可以先思考一个问题:如果由你来设计 Git(或者重新设计),你会怎么做?第一个版本打算实现哪些核心功能?读完本文后,不妨与你的想法做个对比,欢迎在云栈社区交流讨论。
学习 Git 内部实现的最好方法,就是查看其最初的代码提交。如果你 checkout 出 Git 项目的第一次提交,会发现代码库中仅有寥寥几个文件:一个 README,一个构建脚本 Makefile,以及几个 C 源文件。这次提交的备注也写得格外特别:
commit e83c5163316f89bfbde7d9ab23ca2e25604af290
Author: Linus Torvalds <torvalds@ppc970.osdl.org>
Date: Thu Apr 7 15:13:13 2005 -0700
Initial revision of <span>"git"</span>, the information manager from hell
在 README 中,Linus 详细阐述了 Git 的设计思路。看似复杂的 Git,在其最初的设计中被抽象为两层核心概念:1) 对象数据库(“object database”);2) 当前目录缓存(“current directory cache”)。
Git 的本质就是一个文件对象模型的集合。代码文件是对象,文件目录树是对象,提交(commit)也是对象。每个对象的名称就是其内容的 SHA1 哈希值(40位)。Linus 将前两位作为目录名,后 38 位作为文件名。你在 .git/objects 目录下看到的那些两位字符的文件夹,里面存放的 38 位哈希值文件,就是 Git 存储的全部信息。
对象的数据结构被设计为:<标签ASCII码表示> + <空格> + <长度ASCII码表示> + <\0> + <二进制数据内容>。你可以用 xxd 命令查看 objects 目录下的对象文件(需要先用 zlib 解压),例如一个 tree 对象的内容可能如下:
00000000: 7472 6565 2033 3700 3130 3036 3434 2068 tree 37.100644 h
00000010: 656c 6c6f 2e74 7874 0027 0c61 1ee7 2c56 ello.txt.'.a..,V
00000020: 7bc1 b2ab ec4c bc34 5bab 9f15 ba {....L.4[....
对象主要分为三种:BLOB、TREE、CHANGESET。
BLOB(二进制对象):这是 Git 存储的实际文件内容。与 SVN 等存储差异(delta)的 VCS 不同,Git 存储的是每个版本文件的完整快照。比如,你提交了一个 hello.c 文件,Git 会生成一个 BLOB 对象完整记录其内容;之后修改该文件并再次提交,会再生成一个新的 BLOB 对象记录新内容。值得注意的是,BLOB 对象只存储文件内容,不包含文件名、权限等元数据,这些信息被记录在 TREE 对象中。
TREE(目录树对象):在最初的设计中,TREE 对象代表某个时间点下整个项目的目录树信息快照,包含了文件名、文件属性以及对应 BLOB 对象的 SHA1 值,但不包含历史信息。这种设计的好处是,能快速比较两个不同历史节点的 TREE 对象,仅通过 SHA1 值就能判断文件是否相同或相异。由于文件名和属性信息记录在 TREE 中,当仅修改文件名或移动文件(而不改内容)时,可以复用 BLOB 对象,从而节省存储空间。后来,Git 优化了这一设计,使 TREE 可以包含子 TREE 的引用,更好地支持了深层目录结构。

图片摘自:Pro Git, 10.2 Git Internals - Git Objects
CHANGESET(提交对象):即我们熟知的 Commit 对象。它记录了本次提交对应的 TREE 对象 SHA1 值、提交者信息、提交备注等。与其他 SCM 工具不同,Git 的提交对象不直接记录文件重命名、属性修改或内容差异(delta)。它会记录父提交对象的 SHA1 值(最多允许 16 个父节点,以支持复杂合并),通过比较本节点与父节点的 TREE 对象来推导出变更内容。
Linus 在解释完三种对象后,特别强调了可信(TRUST)。虽然 Git 设计本身不处理信任问题,但其基于 SHA1 哈希(后来计划升级为 SHA256)的对象存储机制,配合 GPG 等签名工具,可以构建出可信的提交链。
理解了这三种基本对象,再来看“对象数据库”和“当前目录缓存”这两层抽象就清晰了。加上实际的工作目录,Git 的工作流包含三个区域:
- 工作区(Working Directory):我们日常查看和编辑代码的地方。
- 暂存区(Staging Area):即
.git/index 文件(最初叫 .dircache),执行 git add 就是将修改放入此区域。
- 仓库(Repository):即
.git/objects 目录,是最终存储所有 Git 对象的地方。
Linus 设计的“当前目录缓存”(即 index 文件)是一个二进制文件,其结构类似于 TREE 对象,但不同之处在于,它不会包含嵌套结构,当前所有已暂存的文件信息都集中存储在一个文件里。这种设计有两个好处:1. 能快速完整地恢复缓存内容,即使工作区文件被误删;2. 能快速找出缓存区与工作区不一致的文件。

图片摘自:Things About Git and Github You Need to Know as Developer
实现
在 Git 的第一次代码提交中,Linus 就完成了最基础的功能,代码极其简洁,加上 Makefile 总共只有 848 行。如果你有兴趣,可以 checkout 最早的提交进行编译。由于依赖库版本问题,需要对原始的 Makefile 做些小调整:Git 依赖 openssl 和 zlib,在 Ubuntu 上执行 sudo apt install libssl-dev libz-dev 安装开发库。然后修改 Makefile,将 LIBS= -lssl 改为 LIBS= -lcrypto -lz。最后执行 make(忽略编译警告),你会得到 7 个可执行文件:
- init-db:初始化一个 Git 本地仓库。这就是我们现在
git init 命令的前身,最初创建的文件夹名为 .dircache。
- update-cache:接受文件路径作为参数,将文件加入暂存区。其实现是:计算文件内容的 SHA1 值,将内容加上
blob 头信息压缩后写入对象数据库,并更新 .dircache/index 文件。
- write-tree:根据当前暂存区(index)的信息生成一个 TREE 对象,写入对象数据库,并返回其 SHA1 值。
- commit-tree:接受一个 TREE 对象的 SHA1 值和最多 16 个父提交 SHA1,生成一个 Commit 对象,写入数据库并返回其 SHA1 值。
- cat-file:由于对象文件都经过 zlib 压缩,此工具用于解压并查看对象内容。
- show-diff:快速比较当前暂存区与工作区文件的差异。
- read-tree:根据输入的 TREE 对象 SHA1 值,打印其内容。
这就是最初版本 Git 的全部功能。你可能已经发现,这里没有 git add 或 git commit 命令。在 Git 的设计哲学中,命令分为两种:底层命令(Plumbing commands) 和高层命令(Porcelain commands)。Linus 最初设计的就是这些给“管道工”(黑客)使用的底层、原子化命令,符合 Unix 的 KISS 原则。后来的维护者 Junio Hamano 在此基础上封装了更友好、更精美的高层命令,也就是我们如今每天使用的 git add(封装了 update-cache)、git commit(封装了 write-tree 和 commit-tree)等。
Linus 的代码风格以极度简洁著称,并且娴熟地运用了 mmap 等系统调用进行内存文件映射,以提升性能。正如有人调侃:除了不符合大多数公司的代码规范,他的代码几乎挑不出毛病。
启示
Linus 完成 Git 的首次提交并向社区发布后,一位名叫 Junio Hamano 的开发者被这个精巧的工具吸引。他发现整个项目才 1244 行代码,这让他产生了极大兴趣。Junio 通过邮件列表与 Linus 交流,帮助添加了 merge 等功能,并持续打磨,最终完全接手了 Git 的维护工作,而 Linus 则回归 Linux 内核的开发。
如果说要评选历史上最伟大的一次 Git 提交,那么非 Git 项目本身的这第一次提交莫属。这次提交无疑是开创性的。如果说 Linux 成就了开源运动,改写了软件行业格局,那么 Git 则从根本上改变了全球开发者的协作方式。在 Git 诞生两年后,三位年轻程序员在一家小酒馆里决定用它做点什么,几个月后,GitHub 上线。
回到文章开头的问题,如果让我来设计 Git,我很可能会受限于已有工具(如 SVN)的经验,做出一个“分布式 SVN”式的设计。正是在深入理解了 Git 的对象模型和初始代码后,才感叹其设计的精妙。Git 的诞生过程,给(开源)软件产品带来了深刻启示:
- 解决真实痛点:Git 源于 Linus 本人及 Linux 社区的迫切需求,而这些需求(快、分布式、支持大规模并行开发)正是跨地域项目协作的共性痛点。解决了自己的问题,便成就了一项伟大的工具。
- 极简的核心设计:不受传统 SCM 工具思维的束缚,仅通过 BLOB、TREE、CHANGESET 三种对象的抽象,就清晰构建了整个系统的基石。
- 坚定的 MVP 原则:最小可行产品应该包含什么?Linus 给出了典范:拆解出最原子的底层操作,快速实现一个仅面向高级用户(黑客)可用的版本。这已足够证明其价值,并吸引社区为其添砖加瓦。
- 快速迭代:得益于 Linux 内核的开发经验,Linus 在实现 MVP 后立即公开发布,收集反馈,快速迭代。
- 找到合适的接班人:在 Git 的基础架构稳固后,Linus 发现 Junio Hamano 比自己更擅长实现丰富、友好的功能,于是果断将项目移交。为开源项目找到更合适的维护者,需要魄力与智慧。
参考链接: