git工作原理

4,610 阅读9分钟

写在前面

深入理解git的工作原理,再去结合实践中的场景和问题,不管是对git的使用还是解决一些疑难杂症,都会变得游刃有余,也更利于我们根据自己团队或项目的实际场景建立合理的git工作流

git原理剖析

commit到底是什么

先来看一张图

其实git内部一切皆对象,commit对象是git仓库的一个快照,包含了整个项目在某一次提交时所有的文件。从图中可以看到一个commit对象在git内部实际上指向一个tree对象,这个tree是项目的根路径,然后递归指向下面的tree和blob对象,blob则是单个文件的二进制存储。有人可能会有疑惑,如果每个commit都包含整个仓库,那如果两个commit之间只修改了一个文件,那其他所有文件不是都冗余存储了吗?实际上当然不会,再来看一张图

图中每个version其实就是一个commit,而虚线框的文件表示不同commit中该文件未做修改,git内部实际上对同一个文件(blob对象)的存储永远只有一份,不同commit中是用对象引用的形式来指向同一文件

探秘.git文件

你有没有想过,为什么当你从git仓库中clone一个项目后,就可以进行各种git操作来进行团队协作了?git其实也是计算机上的一个程序,加上它所需要的数据就能开始工作了,而这些数据全部都存放在项目的.git文件中。下图是一个普通git项目的.git文件夹

我们先重点关注下这个objects文件夹,cd进去

可以看到一堆名字是两个字符的文件夹,随便找一个cd进去(我找了00这个文件夹)

这次是一些一长串字符作为文件名的文件(实际上刚才的上层目录名00加上当前目录的一个文件名就得到了git中的一个对象,git用一个40个字符的hash来标识一个对象,它可能是commit、tree或者是blob),我们用cat命令查看一下文件的内容

可以看到结果是乱码,实际上它是一个blob对象,也就是二进制存储的文件,这里我们可以用另外一个git命令,cat-file来查看一下它转化后的内容

这个实际上就是仓库里某一个版本中,也就是某一次commit中的一个文件的具体内容。我们看一下另外一个文件

通过cat-file命令的-t选项实际上可以看到这个文件的类型,它是一个tree对象,表示一个目录

而这个tree对象的内容是其他一些tree和blob对象,也就是这个目录的子目录和文件。当然我们也能在objects文件夹中找到整个仓库里所有的commit、tree和blob对象。好了,对.git文件的初步探秘就先到这里,我们知道了git是如何存储所有文件,以及如何组织各个不同版本的仓库(或者说是commit)的

手工构造一个commit

看了上文的部分你应该对git的底层存储有一个大致的了解了,不过还不足以让我们对git底层的工作原理有一个深入的理解。接下来我们通过一些底层操作来手工构造出一个commit,通过这个过程来深入理解git的工作原理。首先我们构造一个空的git仓库

然后我们可以在另一个窗口watch一下.git文件的变化

可以看到objects里面只有info和pack,先忽略这两个文件,目前仓库中还没有文件,也没有commit,接下来我们向仓库中写入一些内容,但是不是用直接创建文件的方式,而是用一些git底层命令,如下

首先echo一个"hello world!"字符串,然后用管道把stdin也就是标准输入作为输入将这个字符串传递给git的hash-object命令

这个是通过git help hash-object查看到的该命令的文档,通过-w选项,我们可以直接将对象写入git的对象仓库,此时我们再看一下.git文件夹发生的变化

可以看到objects下已经多了一个文件,cat看下

通过cat命令查看发现是乱码,然后我们用cat-file

发现它是一个blob对象,我们的确写入了一个文件到git仓库,然后查看一下文件内容

内容正是我们之前通过echo输入的字符串"hello world!"。好了,这样我们就将一个文件写入到了git仓库中,这时候用git log一下日志

输出显示仓库中还没有任何提交,我们再git status一下看看当前仓库的状态

同样,啥都没有。这个时候我们只是通过底层命令直接把文件写入了仓库的数据库中,而这样还无法让git帮我们管理这个文件。接下来我们需要做什么呢?回忆一下我们正常写入一个文件并交由git管理需要怎么做?首先创建并编写一个文件,然后需要通过git add将该文件添加到git管理,再通过git commit提交这个文件到本地仓库。那我们现在还是用git的底层命令来完成这些操作。接下来需要用到update-index这个命令,git help查看一下文档

可以看到这个命令是用来更新索引文件,而索引文件就是我们的stage区,也就是暂存文件的索引,而git add文件实际上就是将文件添加到暂存区并更新了索引文件。所以接下来我们用这个命令来添加该文件到暂存区并更新索引文件

--add选项把文件添加到暂存区,--cacheinfo选项表示直接将信息写入索引文件,100644在unix下表示一个普通文件(100)和它对应的读写权限(644),同时我们指定了一个名字"test.txt"作为刚才我们输入的文本"hello world!"的文件名。这时我们在查看一下仓库的状态

可以看到我们已经成功创建了test.txt这个文件,并被git识别到了这个新文件。接下来我们回到第一张图

可以看到一个commit对象是指向tree对象的,也就是一个目录,然后tree对象再指向blob对象,也就是一个具体的文件。我们现在已经成功创建了一个git管理的文件,则还需要生成tree对象。接下来我们利用另外一个命令write-tree,还是先git help查看一下

可以看到这个命令会创建一个tree对象并写入索引文件,接着我们执行这个命令

输出了一个新的hash,也就是git为我们写入的tree对象生成的hash,cat-file看一下

没问题,类型是tree,内容可以看到这个tree对象指向一个blob对象,也就是刚才的test.txt文件。看一下.git文件夹的变化

可以看到objects下多了一个对象,现在总共有两个对象,一个是之前的test.txt的blob对象,另一个也就是刚才写入的tree对象。再查看下仓库当前的状态

可以看到commit还没有生成,此时我们已经将tree对象写入到仓库,接下来就是写入commit对象了。写入commit需要用到commit-tree这个命令,git help一下

我们可以通过这个命令将tree对象(在目前的场景下,这个tree对象表示项目根路径,也就是整个项目)作为一个commit写入git仓库

从输出可以看到git又生成了一个hash,这个hash则是生成的commit的hash值。看一下.git文件夹的变化

可以看到objects下又多出了一个对象,现在总共有三个对象,一个blob一个tree,还有一个从文件名可以看出就是刚才写入的commit对象,cat-file查看一下

可以看到这个对象类型是commit,而内容可以看到这个commit对象指向的刚才的tree对象,还包含auther、committer、commit message等信息。

这时再git log下,发现还是没有commit记录,可是我们已经成功写入了commit对象,这是为什么呢?实际上git仓库在需要维护一个指针,指向某一个commit对象,这样当一个分支上有很多个提交时,通过这个指针才能确定当前仓库是哪个版本,也就是哪个commit对应的快照。而这个指针也存储在.git文件中

也就是这个HEAD文件。cat看一下

HEAD指针指向了refs/heads/master这个文件,那查看下这个文件

现在refs/heads这个文件还是空的,所以我们现在要写入一个文件,使HEAD指针指向我们刚才创建的commit对象。这里有两种方式,一种是直接通过创建文件并编辑写入,不过不推荐用这种方式直接修改HEAD指针的指向,于是我们用另一种方式,利用update-ref命令更新一下HEAD指针的指向,使它指向之前生成的commit对象

可以看到refs/heads下生成了一个master文件,cat一下

master文件的内容就是刚才生成的commit的hash值(master实际上就是当前的分支,也就是git仓库默认的master分支)。再git log看下

好了,commit已经成功生成了。再git status看下

显示test.txt被删除了,这是因为我们是通过底层命令直接写入了文件,而工作区没有这个文件。checkout一下就好了

再git status就没问题了。大功告成!

总结

通过上面的一系列讲解和一次手工生成commit的过程,应该能对git的底层实现有一个比较深入的理解了。总结一下,实质上git使用了一个“万物皆对象”的工作模式,通过用commit对象包含tree目录对象,tree目录对象包含blob文件对象来进行整个仓库的文件夹、文件和不同版本仓库的控制和管理,然后再利用指向commit的指针移动来获取不同的版本快照。这就是git底层最核心的工作原理,理解这个原理后,所有的git命令、操作,包括复杂的工作流、各种场景的实践和疑难问题的解决都可以从原理出发,抽丝剥茧,事半功倍!