Rust实现一个Git

项目来源 CodeCraft

Git 数据结构

首先来了解一下 Git 内部的数据结构

Git 的核心是一个内容寻址文件系统(Content-Addressable Filesystem)。简单来说,它是一个简单的键值对数据库:你可以向 Git 仓库中插入任何类型的内容,它会返回一个唯一的键(SHA-1 哈希值),通过这个键,你可以在任何时候取回该内容。

在 Git 内部,所有的操作都围绕着三种主要的对象展开:数据对象树对象提交对象

数据对象:是最简单的对象类型,它只存储文件内容,而不存储文件名、权限等元数据。 - 当执行 git add 时,Git 会计算文件的哈希值,并将内容压缩后存入 .git/objects 目录。 - 如果有两个文件名不同但内容完全一样的文件,Git 在内部只会存储一份数据对象。

树对象:树对象类似于 Unix 系统中的目录,它记录了文件索引和目录结构。 - 一个树对象包含多条记录,每条记录包含:文件模式(权限)、对象类型(blob 或 tree)、对象的 SHA-1 值以及文件名。 - 通过嵌套树对象,Git 能够表示整个项目的目录层级。

提交对象:提交对象指向一个顶层树对象(代表该时刻的项目快照)。 - 它包含作者(Author)和提交者(Committer)的信息、时间戳以及提交说明。 - 最重要的是,它包含指向父提交对象的指针,这构成了 Git 的版本历史链条。

用一组实验来观察 git 是怎么管理这些对象的。在一个空目录中创建 git 目录:

git init test-git
cd test-git
# 查看 objects 目录,现在它是空的
find .git/objects -type f

数据对象

Git 是内容寻址的。直接把一段字符串存入 Git 数据库。可以通过底层命令 git hash-object,该命令可将任意数据保存于 .git/objects 目录(即 对象数据库),并返回指向该数据对象的唯一的键。

$ echo "hello git" | git hash-object -w --stdin
# 8d0e41234f24b6da002d962a26c2495ea16a425f
  • -w:告诉 Git 不仅计算哈希,还要将对象写入数据库。
  • --stdin:从标准输入读取内容。

此命令输出一个长度为 40 个字符的校验和。 这是一个 SHA-1 哈希值,一个将待存储的数据外加一个头部信息(header)一起做 SHA-1 校验运算而得的校验和。后文会简要讨论该头部信息。 现在可以查看 Git 是如何存储数据的:

$ find .git/objects -type f
.git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f

一个文件对应一条内容, 以该内容加上特定头部信息一起的 SHA-1 校验和为文件命名。 校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。

一旦将内容存储在了对象数据库中,那么可以通过 cat-file 命令从 Git 那里取回数据。 cat-file 指定 -p 选项可指示该命令自动判断内容的类型,并显示大致的内容:

$ git cat-file -p 8d0e41234f24b6da002d962a26c2495ea16a425f
hello git

对一个文件进行简单的版本控制。 创建一个新文件并将其内容存入数据库:

$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
83baae61804e65cc73a7201a7252750c76066a30

向文件里写入新内容,并再次将其存入数据库:

$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

对象数据库记录下了该文件的两个不同版本:

$ find .git/objects -type f
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f

从对象数据库中取回它的第一个版本:

$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1

记住文件的每一个版本所对应的 SHA-1 值并不现实;另一个问题是,文件名并没有被保存,仅保存了文件的内容。上述类型的对象称之为 数据对象(blob object)。利用 git cat-file -t 命令,可以让 Git 告诉我们其内部存储的任何对象类型,只要给定该对象的 SHA-1 值:

$ git cat-file -t 83baae61804e65cc73a7201a7252750c76066a30
blob

树对象

树对象(tree object),它能解决文件名保存的问题,也允许我们将多个文件组织到一起。 Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。 所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了 inodes 或文件内容。 一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。

git 的树对象数据结构类似于:

通常,Git 根据某一时刻暂存区所表示的状态创建并记录一个对应的树对象,如此重复便可依次记录(某个时间段内)一系列的树对象。因此,为创建一个树对象,首先需要通过暂存一些文件来创建一个暂存区。可以通过底层命令 git update-index 为一个单独文件创建一个暂存区。

$ git update-index --add --cacheinfo 100644 \
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
  • --add:将文件添加到暂存区中;
  • --cacheinfo:将文件添加到 git 数据库中;

指定的文件模式为 100644,表明这是一个普通文件。 其他选择包括:100755,表示一个可执行文件;120000,表示一个符号链接。 这里的文件模式参考了常见的 UNIX 文件模式。

通过 git write-tree 命令将暂存区内容写入一个树对象。此处无需指定 -w 选项——如果某个树对象此前并不存在的话,当调用此命令时, 它会根据当前暂存区状态自动创建一个新的树对象:

$ git write-tree
$ git cat-file -p 2f39845a4a2c3ad86adebb00b1ddabd959c131c
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
$ git cat-file -t 2f39845a4a2c3ad86adebb00b1ddabd959c131c
tree

创建一个新的文件:

$ echo 'new file' > new.txt
$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341

查看当前的树对象:

git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt

新的树对象包含两条文件记录。可以将第一个树对象加入第二个树对象,使其成为新的树对象的一个子目录。通过调用 git read-tree 命令,可以把树对象读入暂存区。对该命令指定 --prefix 选项,将一个已有的树对象作为子树读入暂存区:

$ git read-tree --prefix=bak 0155eb4229851634a0f03eb265b69f5a2d56f341
$ git write-tree
dd325c8d87b9087ddcf69e9455743f7e2fce6c8e
$ git cat-file -p dd325c8d87b9087ddcf69e9455743f7e2fce6c8e
040000 tree 0155eb4229851634a0f03eb265b69f5a2d56f341 bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt

提交对象

现在有了三个树对象,分别代表我们想要跟踪的不同项目快照。若想重用这些快照,必须记住所有三个 SHA-1 哈希值。并且,完全不知道是谁保存了这些快照,在什么时刻保存的,以及为什么保存这些快照。而以上这些,正是提交对象(commit object)能保存的基本信息。

可以通过调用 commit-tree 命令创建一个提交对象,为此需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话)。从之前创建的第一个树对象开始:

$ echo 'first commit' | git commit-tree 0155eb4229851634a0f03eb265b69f5a2d56f341
2c831d820204717dc1061ed0c06a2c1916247c09

由于创建时间和作者数据不同,会得到一个不同的散列值。现在可以通过 git cat-file 命令查看这个新提交对象:

$ git cat-file -p 2c831d820204717dc1061ed0c06a2c1916247c09
tree 0155eb4229851634a0f03eb265b69f5a2d56f341
author Cheryl-Chun <769303522@qq.com> 1775286358 +0800
committer Cheryl-Chun <769303522@qq.com> 1775286358 +0800

first commit

提交对象的格式很简单:它先指定一个顶层树对象,代表当前项目快照; 然后是可能存在的父提交(前面描述的提交对象并不存在任何父提交);之后是作者/提交者信息(依据user.name 和 user.email 配置来设定,外加一个时间戳);留空一行,最后是提交注释。

创建另两个提交对象,它们分别引用各自的上一个提交:

$ echo 'second commit' | git commit-tree 0155eb -p 2c831
ad6e79e1fb4df23260060169482f3d58eee89c0e
$ echo 'third commit' | git commit-tree dd325 -p ad6e7
d23aa94c10cdf8c8734bf0a95971fe87f9b1906b

对最后一个提交的 SHA-1 值运行 git log 命令,能够看见一个完整的提交历史:

$ git log --stat d23aa
commit d23aa94c10cdf8c8734bf0a95971fe87f9b1906b
Author: Cheryl-Chun <769303522@qq.com>
Date: Sat Apr 4 17:20:22 2026 +0800

third commit

bak/new.txt | 1 +
bak/test.txt | 1 +
2 files changed, 2 insertions(+)

commit ad6e79e1fb4df23260060169482f3d58eee89c0e
Author: Cheryl-Chun <769303522@qq.com>
Date: Sat Apr 4 17:02:48 2026 +0800

second commit

commit 2c831d820204717dc1061ed0c06a2c1916247c09
Author: Cheryl-Chun <769303522@qq.com>
Date: Sat Apr 4 15:05:58 2026 +0800

first commit

new.txt | 1 +
test.txt | 1 +
2 files changed, 2 insertions(+)

现在来解释一下在实现 init 子命令的时候创建的文件夹: - /objects:存储所有的内容,无论是文件内容、目录结构还是提交信息,全都压缩后存储在这里; - refs/ - heads/:存放本地分支,比如 refs/heads/master,文件里存一个 40 位的哈希值,指向该分支最新的那个提交对象。 - tags/:存放标签。 - remotes/:存放远程分支的指针。 - HEAD:当前位置,记录正在哪一个引用上。

git init 命令

这一 CodeCraft 的第一个练习,比较简单,使用 clap 库来实现:

cargo add clap --features derive

最终的形式:

#[allow(unused_imports)]
use std::env;
#[allow(unused_imports)]
use std::fs;

use clap::Parser;
use clap::Subcommand;

#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
Init,
}

fn main() {
let args = Args::parse();
// You can use print statements as follows for debugging, they'll be visible when running tests.
eprintln!("Logs from your program will appear here!");

// TODO: Uncomment the code below to pass the first stage
match args.command {
Command::Init => {
fs::create_dir(".git").unwrap();
fs::create_dir(".git/objects").unwrap();
fs::create_dir(".git/refs").unwrap();
fs::write(".git/HEAD", "ref: refs/heads/main\n").unwrap();
println!("Initialized git directory")
}
}
}

cat-file

在上一节的内容中一直使用到 cat-file 子命令,这个命令用于查看 git 数据对象的内容。在之前的使用当中,cat-file 子命令用到了 -p 参数,以及一个对象哈希值。

每个 Git Blob 都会作为独立文件存储在 .git/objects 目录中。该文件包含一个头部以及 Blob 对象的内容,内容使用 Zlib 进行了压缩。

Blob 对象文件的格式如下(Zlib 解压后):

blob <size>\0<content>
  • <size> 内容的大小(以字节为单位)
  • \0 空字节
  • <content> 文本内容

例如一个 hello world 的 blob object:

blob 11\0hello world

这一节的具体代码 cat-file 实现