«
实现git

时间:2023-3   


git 我们每天都在用,但你知道它是怎么实现的么?

git add、git commit 整天都敲,但你知道它底层做了什么么?

commit、branch、暂存区这些都是怎么实现的,怎么做到的版本切换呢?

所有这些疑问,只要搞懂 3 个 object 就全部能解答了。

不信我们来看一下:

首先,执行 git init 初始化 git 仓库。

git 的所有内容都是存储在 .git 这个隐藏目录的,我们先把它给搞出来:

默认隐藏,但只要你把这个 exclude 配置删掉,就显示出来了:

展开以后可以看到这些东西:

重点就是这里的 objects。

它是什么呢?

我们添加一个 object 就知道了:

有这样一个 text.txt 的文件:

执行这个 hash-object 的命令:

git hash-object -w text.txt

它会返回一个 hash:

然后你会在 objects 目录下发现多了一个目录,目录名是 hash 前两位,剩下的是文件名:

它存了什么内容呢?

可以通过 cat-file 来看:

git cat-file -p 7c4a013e52c76442ab80ee5572399a30373600a2

-p 是 print 的意思。

可以看到文件内容就是 text.txt 的内容:

哦,原来 git 存储的文件内容就是放在这里的。

改一下文件内容,再存一下:

git hash-object -w text.txt

你会看到多了一个新的目录,同样是 hash 做目录名和文件名:

就这么一点东西,我们就能实现版本管理了!

怎么做呢?

读取不同 hash 的内容写入文件不就行了?

比如现在内容是 bbb,我想恢复上一个版本的内容是不是只要 cat-file 上个 hash 再写入文件就行了?

git cat-file -p 7c4a013e52c76442ab80ee5572399a303 > text.txt

这就是一个版本管理工具了!

当然,现在还没有存文件名的信息,还有目录信息,这些信息存在哪呢?

这就需要别的类型的 object 了。

刚才我们看的存储文件内容的 object 叫做 blob。

可以通过 cat-file 加个 -t 看出来:

-t 是 type 的意思。

git cat-file -t 7c4a013e52c76442ab80ee5572399a303

还有存储目录和文件名的 object,叫做 tree。

tree 和 blob 是咋关联的呢?

找个真实的仓库看看就知道了:

比如我在 react 项目下执行了 cat-file,之前我们用它查看过 blob 对象内容,这次查看的是 main 分支的顶部的 tree 对象。

git cat-file -p main^{tree}

可以看到有很多 blob 对象和 tree 对象:

很容易看出来,目录是 tree 对象,文件内容是 blob 对象:

那文件名呢?

文件名不是已经在 tree 对象里包含了么?

我们继续用 cat-file 看下 packages 这个 tree 对象的内容:

git cat-file -p 2889ab8f0ef04484849c40d3eebe330ec25bbe1c

很容易就可以看出来 git 是怎么存储一个目录的了:

在 tree 对象里存储每个子目录和文件的名字和 hash。

在 blob 对象里存储文件内容。

tree 对象里通过 hash 指向了对应的 blob 对象。

这样是不是就串起来了!

这就是 git 存储文件的方式。

那这个 hash 是怎么算出来的呢?

也很简单,是对“对象类型 内容长度\0内容” 的字符串 sha1 之后的值转为 16 进制字符串。

比如 aaa 的 hash 就是这样算的:

const crypto = require('crypto');

function hash(content) {
    const sha1 = crypto.createHash('sha1');
    sha1.update(content);
    return sha1.digest('hex');
}

console.log(hash('blob 3\0aaa'))

是不是一毛一样!

所有的 object 都是这么算 hash 的。

继续来讲 tree 对象:

其实我们放到暂存区的内容就相当于一个新的目录,也是通过 tree 对象存储的。

更新暂存区用 update-index 这个命令:

git update-index --add --cacheinfo 100644 7c4a013e52c76442ab80ee5572399a30373600a2 text.txt

--add --cacheinfo 就是往暂存区添加内容。

指定文件名和 hash,这里我们把 aaa 那个文件放进去了。

前面的 100644 是文件模式:

100644 是普通文件,100755 是可执行文件,120000 是符号链接文件。

添加之后就可以看到 .git/index 这个文件了,暂存区的内容就是放在这:

这时候你执行 git status 就可以看到暂存区已经有这个文件了:

所以说,git add 的底层就是执行了 git update-index。

然后暂存区的内容写入版本库的话只要执行下 write-tree 就好了:

git write-tree

然后你就会发现它返回了一个 hash,并且 objects 目录下多了一个 object:

这个对象是啥类型呢?

通过 cat-file -t 看下就知道了:

git cat-file -t 9ef7e5a61a3b70ff7149805fc86a4c26e953bb3f

可以看到,是个 tree 对象:

所以说,暂存区的内容是作为 tree 对象保存的。

再 cat-file -p 看下它的内容:

git cat-file -p 9ef7e5a61a3b70ff7149805fc86a4c26e953bb3f

可以看到是这样的:

这就是 git commit 的原理了。

现在假设有个需求,让你找到某个版本的某个文件的内容,恢复回去。

是不是就很简单了?

只要找到对应版本的那个 tree 的 hash,然后再一层层找到对应的 blob 对象,读取内容再写入文件就好了!

这就是 git revert 的原理了。

当然,要是每个版本都要自己记住顶层 tree 的 hash 也太麻烦了。

所以 git 又设计了 commit 对象。

可以通过 commit-tree 命令把某个 tree 对象创建一个 commit 对象。

echo 'guang 111' | git commit-tree 9ef7e5

这里的参数就是上面的 tree 对象的 hash:

再用 cat-file -t 看看返回的对象的类型:

git cat-file -t b5f92e68912595dbb3b6cbda9123838546b18f7d

确实,这是一个 commit 对象:

那 commit 对象都存了啥呢?

还是用 cat-file -p 看看:

git cat-file -p b5f92e68912595dbb3b6cbda9123838546b18f7d

下面的内容很熟悉,但是多了一个 tree 节点的指向,这个很正常,commit 的内容就是某个 tree 所对应的版本嘛。

commit、tree、blob 三个对象就是这样的关系:

commit 之间还能关联,也就是有先后顺序。

这个用 commit-tree -p 来指定:

比如我们再创建两个 commit:

echo 'guang 111' | git commit-tree 9ef7e5 -p b5f92e6
echo 'guang 222' | git commit-tree 9ef7e5 -p c3f9f5

这时你用 git log 看看:

git log 1d1234

你会看到平时经常看到的 commit 历史:

这就是 commit 的实现原理!

当然,这里要记 commit 的 hash 同样也很麻烦。

平时我们怎么用呢?

用 branch 或者 tag 呀!

branch 和 tag 其实就是记录了这个 commit 的 hash。

这部分就不是 object 了,叫做 ref:

创建 ref 使用 update-ref 的命令:

git update-ref refs/heads/guang 1d1234e77de6de0bb8edcf90cbd1a9546d7b1d9a

比如我创建了一个叫做 guang 的指向一个 commmit 对象的 ref。

这里就会多一个文件,内容存着指向的 commit 是啥:

然后你 git branch 看看:

其实这就是创建了一个新的分支。

这就是 branch 的原理。

tag 也是一样,只不过它是放在 refs/tags 目录下的:

git update-ref refs/tags/v1.0 1d1234e77de6de0bb8edcf90cbd1a9546d7b1d9a

blob、tree、commit 和 ref 的关系就是这样的:

总结

今天我们探究了 git 的实现原理,主要是 3 个 object 以及两个 ref。

3 个对象是:

2 个 ref 是:

此外,暂存区放在 .git/index 文件里,内容其实也是个 tree 对象的内容。

还有,hash 的计算方式是类似 blob 3\0aaa 这样 “对象类型 内容长度\0内容”的格式,对它做 sha1 然后转为十六进制。

基本看懂这张图就好了:

用到了这几个命令:

理解了这些,你就能理解 git add、git commit、git log、git revert、git branch、git tag 等等绝大多数 git 命令的实现原理了。

甚至按照这个思路来,自己写一个 git 是不是也不难呢?