2005年4月6日,Linus Torvalds在Linux内核邮件列表中写道:「我一直在考虑自己写一个SCM。」第二天,他做出了第一个提交——用Git自己提交Git的代码。这个最初只有约10,000行代码的工具,在接下来二十年里彻底改变了软件协作的方式。

2025年,在Git诞生20周年的访谈中,Linus回顾道:「我本来以为CVS会一直占据市场,我写Git只是为了解决自己的问题,根本没指望它会流行起来。」

Git的成功不是偶然。它采用了一套看似「笨拙」但实则精妙的设计:内容寻址存储、有向无环图(DAG)历史、分布式架构。这些设计决策共同构成了一个既简单又强大的版本控制系统。

一个「反CVS」的宣言

Git的诞生源于一场许可证纠纷。Linux内核社区从2002年开始使用BitKeeper——一个商业版本控制系统。2005年初,BitKeeper的创建者Larry McVoy宣布撤销部分内核开发者的免费许可证,原因是有人逆向工程了BitKeeper。

Linus当时面临一个困境:市面上没有适合Linux内核开发的开源版本控制系统。CVS太慢且设计糟糕,Subversion本质上是「换皮的CVS」,而其他分布式系统要么不成熟,要么性能无法满足需求。

在AOSA(Architecture of Open Source Applications)的访谈中,Linus明确了他的设计目标:

哲学目标:成为「反CVS」

三个实用性目标

  1. 支持类似BitKeeper的分布式工作流
  2. 提供强保护以防止内容损坏
  3. 高性能——应用100个补丁不应需要「喝杯咖啡的时间」

这三个目标深刻影响了Git的每一个设计决策。

内容寻址存储:为什么「笨」即是好

Git最核心的设计是它的对象数据库——一个内容寻址文件系统(Content-Addressable Filesystem)。

什么是内容寻址?

传统文件系统通过路径(如/home/user/document.txt)定位文件。内容寻址则不同:它通过内容的哈希值来定位内容。

当你向Git存储一段内容时:

echo "hello world" | git hash-object -w --stdin
3b18e512dba79e4c8300dd08aeb37f8e728b8dad

Git返回一个40字符的SHA-1哈希。这个哈希就是内容的「地址」。要检索内容,你只需要知道这个哈希:

git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
hello world

为什么选择SHA-1?

很多人质疑Git使用SHA-1的决定——尤其是在2017年Google和CWI研究所宣布成功实施SHA-1碰撞攻击(SHAttered)之后。Linus在访谈中解释了他的理由:

「使用SHA-1哈希从来不是为了安全,而是为了检测损坏。」

在BitKeeper时代,内核社区确实遇到过数据损坏问题——BitKeeper使用CRC和MD5,但并非对所有内容都使用。Linus决定Git要保护「绝对所有内容」——每个对象都用一个强哈希保护。

这种设计带来几个关键优势:

完整性保证:任何数据损坏都能被检测。如果磁盘错误改变了一个字节,哈希值将完全不匹配。

去重:相同内容只存储一次。如果你在十个地方引用同一个文件内容,Git只存储一份。

无锁设计:不需要复杂的锁机制。两个开发者同时创建相同内容,会得到相同哈希,不会产生冲突。

Git对象模型

图片来源: git-scm.com

四种对象类型

Git的对象数据库包含四种基本对象类型:

Blob对象:存储文件内容。注意,blob不存储文件名、权限或任何元数据——只有内容本身。

Tree对象:存储目录结构。一个tree包含一系列条目,每个条目指向一个blob(文件)或另一个tree(子目录),同时记录文件名和Unix权限。

Commit对象:存储项目快照。包含指向根目录tree的指针、作者信息、提交信息,以及指向父提交的指针。

Tag对象:存储 annotated tag。包含指向特定对象的指针、标签信息、签名等。

这种分层设计创造了一个有向无环图(DAG):

Git数据模型完整结构

图片来源: git-scm.com

对象存储格式

每个Git对象的存储格式是:

[header][content][zlib压缩]

Header格式为:<类型> <字节数>\0

例如,存储"hello world"这个blob:

header = "blob 11\0"    # 11是内容字节数
store = header + "hello world"
sha1 = SHA1(store)      # 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
compressed = zlib(store)

最终存储路径是.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad

这个设计看似简单,但它有一个重要含义:所有对象都是不可变的。一旦创建,永不改变。这消除了并发访问的复杂性。

Packfile:当「笨」变得聪明

每个文件都独立存储听起来很浪费空间——如果你修改了一个大文件中的一个字符,难道要存储两份完整的文件?

Git的答案是Packfile——一个延迟执行的压缩机制。

Loose Objects vs Packed Objects

初始创建的对象以「松散对象」(loose objects)形式存储——每个对象一个文件,zlib压缩但无增量压缩。这快速但低效。

当运行git gc或对象数量达到阈值时,Git将松散对象打包成packfile。Packfile使用增量压缩(delta compression)——存储对象之间的差异而非完整内容。

Packfile结构

图片来源: github.blog

Delta压缩的原理

Packfile支持两种delta表示:

REF_DELTA:存储基础对象的SHA-1哈希。

OFS_DELTA:存储到基础对象的偏移量(更紧凑)。

Delta编码的核心思想是:给定一个基础对象,用一系列指令重建目标对象。指令只有两种类型:

  1. 复制:从基础对象复制一段字节
  2. 插入:插入新的字节数据

这种编码对于文本文件特别高效——大多数修改只是添加、删除或更改少量文本。

Pack Index:快速查找的秘诀

Packfile本身不包含按哈希索引的结构——要查找对象需要扫描整个文件。Git使用pack index(.idx文件)来解决这个问题。

Pack Index包含:

  • 256项的fanout表(用于快速二分查找)
  • 按哈希排序的对象列表
  • 每个对象的CRC32校验和(用于验证压缩数据)
  • 每个对象在packfile中的偏移量

查找流程:

  1. 用对象哈希的前一个字节在fanout表中定位范围
  2. 在该范围内二分查找
  3. 找到后获取偏移量
  4. 从packfile读取对象数据

GitHub的工程师Taylor Blau在博客系列「Git’s database internals」中详细解释了这种设计如何支持大规模仓库的高效操作。

引用:分支只是41字节

Git的分支模型可能是它最被低估的设计。

在Subversion中,创建分支意味着在仓库中复制整个目录树——可能需要几分钟。在Git中,创建分支是瞬时完成的。为什么?

引用的本质

Git的引用(reference或ref)只是包含40字符SHA-1哈希的文本文件。

创建一个分支:

git branch feature

这个命令做的事仅仅是:

echo "a1b2c3d4..." > .git/refs/heads/feature

文件内容是当前提交的哈希值,总共41字节(40个十六进制字符加换行符)。

删除分支:

git branch -d feature

这只是删除一个文件:

rm .git/refs/heads/feature

Git引用结构

图片来源: git-scm.com

HEAD:特殊的引用

.git/HEAD是一个特殊的引用——它通常不直接指向提交,而是「符号引用」(symbolic reference),指向另一个引用:

$ cat .git/HEAD
ref: refs/heads/master

当你执行git checkout feature时,Git只是修改HEAD文件的内容:

$ cat .git/HEAD
ref: refs/heads/feature

这种设计的优雅之处:分支只是指针,不涉及任何数据复制

Detached HEAD状态

当HEAD直接指向一个提交(而非分支引用)时,Git处于「detached HEAD」状态:

$ git checkout a1b2c3d
$ cat .git/HEAD
a1b2c3d4e5f6...

这种状态下,新提交不属于任何分支,可能被垃圾回收。理解这个机制对于使用git rebasegit bisect非常重要。

快照 vs 差异:一场哲学辩论

Git与其他版本控制系统最根本的区别在于它如何存储历史。

两种历史模型

Delta-based(CVS、SVN):每次提交存储与前一个版本的差异。要重建某个版本,需要从初始版本开始应用所有差异。

Snapshot-based(Git):每次提交存储完整的目录树快照。未改变的文件通过引用现有blob来避免重复存储。

快照模型的优势

Linus在设计Git时坚持快照模型,原因如下:

完整性:每个提交是一个独立的、自包含的快照。要验证某个版本,不需要检查整个历史链。

性能:检出任意版本不需要应用漫长的差异序列——直接从快照恢复。

分支理解:合并历史是DAG而非线性序列,能准确表示「哪些变更已包含在哪些分支中」。

一个误解

很多人认为Git「存储快照」意味着浪费空间。实际上:

  1. 松散对象阶段:每次提交创建新blob,但未改变的文件复用现有blob。
  2. 打包阶段:Packfile使用delta压缩,最终存储空间与delta-based系统相当。

Julia Evans在一篇博文中做了一个有趣的调查:开发者如何看待Git提交?是快照?差异?还是历史列表?答案反映了这个模型的微妙之处——概念上是快照,但实现上使用了delta压缩。

三路合并:DAG的威力

Git的合并能力源于它的DAG历史模型。

什么是三路合并?

当合并两个分支时,Git需要找到它们的「共同祖先」(merge base),然后执行三路合并:

  • Base:共同祖先版本
  • Ours:当前分支版本
  • Theirs:要合并的分支版本

对于每个文件区域,Git应用以下规则:

  1. Base和Ours相同,Theirs不同 → 采用Theirs
  2. Base和Theirs相同,Ours不同 → 采用Ours
  3. Base、Ours、Theirs都不同 → 冲突
  4. 三者都相同 → 无变化

Recursive策略

当存在多个共同祖先时(criss-cross merge场景),Git的recursive策略会先合并这些祖先,创建一个虚拟的共同祖先,然后再进行三路合并。

这种策略比简单的「选一个祖先」更健壮,能正确处理复杂的历史拓扑。

为什么SVN做不到?

Subversion使用线性历史模型——每个文件有一个版本序列,更高的版本号覆盖更低的。虽然可以通过目录结构模拟分支,但系统无法真正理解「分支」概念。

当你在SVN中合并分支时,系统不知道哪些变更已经被合并过。你必须手动记录,或依赖容易出错的合并跟踪属性。

Git的DAG模型天然解决了这个问题:每个合并提交记录了它的所有父提交。要判断某个提交是否在当前分支中,只需要图遍历——从分支尖端回溯到所有祖先。

分布式架构:没有中央权威

Git的分布式设计可能是它最具革命性的特点。

每个仓库都是完整的

克隆一个Git仓库,你得到的是完整的历史——所有提交、所有分支、所有标签。你可以完全离线工作:提交、分支、合并、查看历史。

这不仅仅是「离线工作」的便利,它改变了协作模型:

没有单点故障:任何仓库都可以作为「真理来源」。

灵活的工作流:你可以先提交到本地,等准备好再推送到共享仓库;可以在多个远程仓库之间同步;可以创建只用于实验的本地分支。

更好的性能:大多数操作完全本地化——查看历史、比较版本、搜索代码都不需要网络请求。

传输协议

Git支持两种传输协议:

Dumb Protocol:简单的HTTP文件访问。客户端通过一系列GET请求获取对象文件。效率低但兼容性好。

Smart Protocol:客户端和服务器协商传输内容。客户端发送「我有这些对象」,服务器返回「你需要这些对象」,然后发送一个定制的packfile。

Smart Protocol的核心是pack negotiation——双方交换对象列表,服务器生成包含最小必要数据的packfile。这避免了传输客户端已有的对象。

浅克隆和部分克隆

对于大型仓库,Git提供了几种减少克隆大小的方法:

浅克隆--depth):只获取最近的N次提交。历史被截断,某些操作受限。

部分克隆--filter):不立即获取所有对象。可以按blob大小过滤,或使用tree-less克隆只获取提交和tree。

稀疏检出:只检出特定目录到工作区。

这些技术使得处理超大型仓库(如Windows代码库,据报道超过300GB)成为可能。

SHA-256迁移:一个痛苦但必要的决定

2017年2月,Google和CWI研究所宣布成功实施SHA-1碰撞攻击(SHAttered)。他们生成了两个内容不同但SHA-1哈希相同的PDF文件。

这对Git意味着什么?

SHA-1在Git中的角色

Git使用SHA-1作为对象标识符。如果两个不同的内容产生相同的哈希,攻击者可能创建恶意提交,其哈希与合法提交相同。

但是,这种攻击的实际风险很低:

  1. 攻击成本:SHAttered需要约6500年的CPU时间和110年的GPU时间。
  2. Git的结构限制:碰撞文件必须是有效的Git对象格式。
  3. 开发者的审查:恶意代码仍然会被人工审查发现。

迁移到SHA-256

尽管风险有限,Git社区决定迁移到SHA-256。这是一项巨大的工程:

兼容性挑战:SHA-1和SHA-256对象ID不兼容,需要同时支持两种格式。

协议更新:传输协议需要识别对象的哈希类型。

工具生态:所有依赖Git的工具都需要更新。

Git 2.29(2020年10月)引入了实验性的SHA-256支持。迁移计划允许每个仓库独立迁移,无需全局协调。

Linus对此评论道:「这造成了很多无意义的混乱……我认为大多数人不需要它,但人们担心,所以就做了。」尽管如此,他承认这是一个合理的安全预防措施。

为什么Git如此成功?

回顾Git二十年的发展,几个关键因素解释了它的成功:

设计的简洁性

Git的底层概念很少:四种对象类型、引用、索引。高级命令(porcelain)是这些底层概念(plumbing)的组合。这种「正交设计」使得系统既简单又强大——新的工作流可以从现有原语组合出来。

分布式优先

Git从一开始就设计为分布式。这使得它天然适合开源协作——任何人都可以克隆仓库、创建分支、发送补丁。GitHub的成功证明了这种模型的价值。

性能

Linus对性能的执着深刻影响了Git的设计。对象数据库用哈希索引、Packfile用delta压缩、操作本地化——所有这些决策都服务于「快速」这个目标。

社区

Junio Hamano从2005年8月开始维护Git,已经坚持了20年。Linus评价道:「他在项目中几乎每一部分都有惊人的了解。」稳定的核心维护为社区发展提供了坚实基础。

一个「意外」的成功

在20周年访谈中,Linus提到一个有趣的细节:他的女儿上大学后告诉他,在计算机科学实验室里,他因Git而闻名——而不是Linux。

「这很荒谬,」Linus说,「我只用了四个月的时间维护Git……它从来不是我真正关心的事情。」

Git的成功也许正是因为它的创造者不试图让它成为一切——他只是想解决自己的问题,然后把解决方案交给社区。这种务实的态度渗透在Git的设计中:简单、快速、可靠。

有时候,最好的工具不是那些被设计为「完美」的工具,而是那些诚实地面对问题、清晰地定义边界、然后优雅地解决问题的工具。Git就是这样一种工具——它不完美,但它二十年来一直「够用」,而且持续演进。


参考资料

  1. Torvalds, L. (2005). Git Initial Commit. https://github.com/git/git/commit/e83c5163316f89bfbde7
  2. Chacon, S. & Straub, B. (2014). Pro Git, 2nd Edition. Apress.
  3. The Architecture of Open Source Applications (Volume 2). “Git.” https://aosabook.org/en/v2/git.html
  4. GitHub Blog. (2022). “Git’s database internals I: packed object store.” https://github.blog/open-source/git/gits-database-internals-i-packed-object-store/
  5. GitHub Blog. (2025). “Git turns 20: A Q&A with Linus Torvalds.” https://github.blog/open-source/git/git-turns-20-a-qa-with-linus-torvalds/
  6. Git Documentation. “Git Objects.” https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
  7. Git Documentation. “Git References.” https://git-scm.com/book/en/v2/Git-Internals-Git-References
  8. Git Documentation. “Transfer Protocols.” https://git-scm.com/book/en/v2/Git-Internals-Transfer-Protocols
  9. Git Documentation. “pack-format.” https://git-scm.com/docs/pack-format
  10. Git Documentation. “hash-function-transition.” https://git-scm.com/docs/hash-function-transition
  11. Stevens, M. et al. (2017). “The First Collision for Full SHA-1.” CWI Amsterdam & Google.
  12. Google Security Blog. (2017). “Announcing the first SHA1 collision.” https://security.googleblog.com/2017/02/announcing-first-sha1-collision.html
  13. LWN.net. (2017). “Moving Git past SHA-1.” https://lwn.net/Articles/715716/
  14. LWN.net. (2022). “Whatever happened to SHA-256 support in Git?” https://lwn.net/Articles/898522/
  15. LWN.net. (2005). “The kernel and BitKeeper part ways.” https://lwn.net/Articles/130746/
  16. Evans, J. (2024). “Do we think of git commits as diffs, snapshots, and/or histories?” https://jvns.ca/blog/2024/01/05/do-we-think-of-git-commits-as-diffs--snapshots--or-histories/
  17. Wikipedia. “Distributed version control.” https://en.wikipedia.org/wiki/Distributed_version_control
  18. Atlassian Git Tutorial. “Git Merge Strategies.” https://www.atlassian.com/git/tutorials/using-branches/merge-strategy
  19. Atlassian Git Tutorial. “Git Hooks.” https://www.atlassian.com/git/tutorials/git-hooks
  20. Git Documentation. “git-bisect.” https://git-scm.com/docs/git-bisect
  21. GitButler Blog. (2025). “20 years of Git.” https://blog.gitbutler.com/20-years-of-git