我用 Rust 自己写了一个 Git 来理解版本控制
我使用 Git 已经好几年了。提交、推送、拉取,偶尔遇到问题也会手忙脚乱。但如果你问我运行 `git commit` 到底发生了什么git commit,我只会含糊其辞地回答“保存更改”,然后希望你不要再追问了。
这让我很困扰。所以我用 Rust 写了自己的版本控制系统Veridian。这并非因为世界需要另一个 Git,而是因为我需要理解我们现有的版本控制系统。
事实证明,Git 比我想象的要简单得多。
为什么每个人都觉得 Git 令人困惑
我们学习 Git 的方式是本末倒置。我们死记硬背命令,却不理解它们的作用。git add暂存文件。好吧,但暂存到底是什么意思?git commit保存你的工作。不错,但保存到哪里,又如何保存呢?
我花了多年时间只是机械地执行命令。我会用 Git,但却无法理解它的运作原理。创建 Veridian 改变了这一切。
这就是 Git 的真正含义
抛开所有命令和功能,Git 本质上就是一个内容寻址存储系统。听起来很复杂,但其实不然。
你的存储系统是按内容而非名称保存数据的。两次输入相同的内容?存储到同一个位置。更改一个字符?存储到不同的位置。
这就是 Git。“location”是 SHA-1 哈希值。“storage”是文件.git/objects夹。
三种类型的物体
Blob 指的是文件内容。具体来说,就是给你的文件添加一个类似 `.blob` 的头部blob <size>\0,然后进行哈希处理、压缩和存储。搞定。Git 在这里并不关心文件名,只关心文件内容。
树状结构是目录列表。它们通过列出文件和文件夹及其哈希值来表示“这个文件夹以前的样子”。树状结构可以指向二进制大对象(blob)和其他树状结构。
提交是带有上下文的快照。每个提交都指向一个树状结构(你的项目之前的状态),指向父提交(之前的代码),并包含作者、时间和消息等元数据。
三种对象类型。这就是整个系统。
为什么哈希系统很智能
同一个文件在多个提交中出现?只存储一次。大文件中只修改了一行?只存储新版本。想检查两个文件是否相同?比较哈希值,立即得到答案。
Git 并不会反复复制你的项目。它存储的是独特的代码片段,并从中构建快照。这就是为什么拥有数百次提交的仓库体积并不大的原因。
分支只是文件
分支实际上就是一个包含提交哈希值的文件。
该文件.git/refs/heads/main包含 40 个字符,即你最近一次提交的哈希值。当你创建一个分支时,Git 会写入一个包含当前提交哈希值的新文件。当你提交时,Git 会用新的哈希值更新该文件。
无需复制文件,只需更新一个小型文本文件。这就是分支“轻量级”的原因。
压缩部分很酷
Git 使用 zlib 在存储之前压缩所有内容。因此,你的目标文件并非原始内容,而是经过压缩的。我在开发 Veridian 时,必须处理每次读写操作的压缩和解压缩。
具体过程如下:Git 会获取你的 blob 数据(包含头部信息),对其进行压缩,然后将其存储在 `<path>` 目录中,.git/objects/ab/cdef123...其中ab`<path>` 是哈希值的前两个字符,` cdef123...<path>` 是剩余部分。之所以采用两个字符的分割方式,是为了避免在一个目录中出现成千上万个文件,从而降低文件系统的运行速度。
读取文件需要找到文件,用 zlib 解压缩,解析文件头检查对象类型和大小,然后返回文件内容。Rust 的标准库没有内置 zlib,所以我用了 zlib crate flate2。整个过程只用了五行代码。
维瑞迪安大厦教会了我什么
我原以为构建版本控制系统会很难,结果并非如此。
用 Rust 构建程序很有意思,因为 Rust 会让你思考内存和所有权的问题。当你对文件进行哈希运算和构建目录树时,你需要正确处理错误(如果文件不存在怎么办?),并谨慎管理缓冲区(你不能直接把一个 5GB 的文件加载到内存中)。
但说实话,版本控制逻辑本身很简单。我的代码大部分都是读取文件、计算 SHA-1 哈希值,然后写入压缩数据。难点不在于算法,而在于理解 Git 实际在做什么。
初始化命令
创建一个.veridian文件夹。添加用于存放对象和引用的子文件夹。创建一个 HEAD 文件。完成,你就拥有了一个代码仓库。
哈希对象命令
读取文件,添加文件头,使用 SHA-1 算法对其进行哈希处理,使用 zlib 压缩,然后写入目标位置.veridian/objects/。返回哈希值。这就是文件进入系统的方式。
Write-Tree 命令
遍历目录。对每个文件进行哈希运算(生成二进制数据块)。将所有文件名和哈希值放入一个树状对象中。再次对该树状对象进行哈希运算。现在你就得到了目录的快照。
我学到的一点是:目录树条目需要按文件名排序。如果不排序,同样的目录结构会因为文件处理顺序的不同而产生不同的哈希值。Git 会自动排序以保持哈希值的一致性。虽然是个小细节,但很重要。
提交树命令
获取树哈希值。如果存在父提交,则添加父提交哈希值。添加作者信息和时间。添加提交消息。对所有内容进行哈希处理。写入对象。更新分支指针。更新 HEAD。这就是一次提交。
它的实现非常简洁。它的工作原理和 Git 类似,因为 Git 本身就非常简单。
有趣的是:Git 将时间戳存储为 Unix 时间戳(自 1970 年以来的秒数),并包含时区信息。因此,提交对象包含类似 ` <time_tamp_id>` 的信息1760211794 +0530,其中包含时间戳和时区偏移量。当你执行 ` git commit` 时git commit,它会获取你的系统时间和时区。我使用了 Rust 的 ` chronogist` crate 来实现这一点,但你也可以使用任何语言。
终于明白的事情
为什么 Git 速度快:它比较的是哈希值,而不是文件内容。40 个字符的字符串。速度超快。
为什么会出现分离的 HEAD 指针: HEAD 通常指向一个分支文件,而该分支文件又指向一个提交。如果直接检出一个提交,HEAD 指针就会直接指向该提交,跳过分支。此时 HEAD 指针就会分离,因为你当前不在分支上,而是在某个特定的提交上。
为什么可以恢复已删除的提交:它们仍然存在.git/objects,只是未被引用。使用git reflog`get_hash ...
为什么会出现合并冲突:两个提交拥有相同的父提交,但对同一个文件进行了不同的更改。Git 无法决定哪个提交生效,需要您手动决定。
我学到了什么
Git难用不是因为它复杂,而是因为我们学习方法不对。
一旦你理解了 Git 是一个键值存储系统,其中键是内容哈希,值有三种类型(blob、tree、commit),一切就都明白了。分支是指针。合并操作会合并树。变基操作会将提交重新应用到不同的父分支上。
我用 Git 好几年都没搞懂。后来我花了一周时间搭建了 Veridian,突然间 Git 就变得有意义了。不是因为搭建过程有什么神奇之处,而是因为它迫使你理解代码运行的原理。
为什么你应该尝试一下
你不需要打造完美的东西,开始就好。
创建一个代码仓库。将文件存储为 blob 对象。构建一个代码树。提交一次代码。完成这四件事,你对 Git 的理解就会超过大多数开发者。
构建 Veridian 大概花了一周时间。现在我用 Git 的时候,我终于知道发生了什么。它只是数据结构和文件操作,没什么复杂的。
Veridian 并不完美。它缺少一些功能,可能还有 bug。但它教会了我 Git 的工作原理,而这才是重点。
如果你想真正学习 Git,而不仅仅是使用它,那就动手做点什么。哪怕只是个小项目,哪怕会出错。花一周时间实践,你学到的东西远比花几个月时间阅读文档要多得多。
去GitHub上看看Veridian。把它搞坏,修好,从中学习。这就是它的运作方式。
文章来源:https://dev.to/kayleecodez/i-built-git-from-scratch-to-finally-understand-what-ive-been-using-for-years-37a9
