从前面我们可以知道,git底层是以元数据的方式存储,我们来新建一个仓库,查看一下objects内容:
find .git/object/
可以看到 Git 对 objects 目录进行了初始化,并创建了 pack 和 info 子目录,但均为空。
下面新建并提交一个新文件,看一下元数据,在仓库根目录查看元数据文件的命令:
find .git/objects/ -type -f
我们可以看到,只提交了一个文件,但是元数据多了三个,这三个分别是什么呢?这三个分别是数据对象,提交对象和树对象。下面来详细说一下这三个对象。也就是git的底层原理。
Git数据对象
Git 本质上是一个内容寻址的文件系统,其核心部分是一个简单的键值对数据库(key-value data store),在不进行提交操作的情况下,你仍然可以向数据库中插入任意内容,它会返回一个用于取回该值的hash 键(key)。如果内容一样,那么返回的key也一样。我们来创建一个新仓库,创建一个新文件hello.txt,然后插入到键值数据库中,尝试插入多次,查看返回的key是否一样:
git hash-object -w hello.txt
可以看到多次插入返回的key是一样的。我们稍微改变一下内容,就会发现返回的键发生变化:
还可以基于键获取指定的内容:
git cat-file -p 3ea522c66db4133cf6135a2764e33905c841780f
键值只要保证唯一,输入前几位即可。
上面演示的就是git作为简单键值对数据库的基本操作。前面说过,git是基于元数据的存储方式,元数据存储在.git文件夹下面的objects文件夹中。那么我们来查询一下objects下面所有的文件,也就是git对象:
find .git/objects/ -type f
显示两个文件名就是我们刚才保存的文件两个版本的key。这和上面演示的提交不同,单纯的把数据插入到数据库只生成一个元数据。
这个时候再来看版本回退,从本质上来说,版本变化了2次,目前文件内容是第2次的修改,如果想回退到第一个版本,那么本质上只需要把第一个版本的元数据写回到当前文件中即可,先来看一下现在文件的内容:
显示的是第2个版本,也就是最新版本的内容,我们现在来恢复到第一个版本,第一个版本的key是 3ea522c66db4133cf6135a2764e33905c841780f,我们来吧第一个版本的内容写到文件中:
git cat-file -p 3ea522c66db4133cf6135a2764e33905c841780f > hello.txt
这样就实现了一个版本的回退。文件内容也恢复到了第一个版本的内容。我们在进行git的版本回退时,git底层存储实际上就是这么做的。我们存储的元数据并不是普通的文件,而是一个blob对象,来看下数据类型:
git cat-file -t 3ea522c66db4133cf6135a2764e33905c841780f
blob对象就是我们git的实际存储的文件的数据类型。然而,记住文件的每一个版本所对应的 key并不现实;另一个问题是,在这个(简单的版本控制)系统中,文件名并没有被保存——我们仅保存了文件的内容。 上述类型的对象我们称之为数据对象(blob object)。 利用 cat-file -t 命令,可以让 Git 告诉我们其内部存储的任何对象类型,只要给定该对象的key值。
看完了上面的底层操作,再来看一下git命令操作,新建一个文件test.txt,然后添加到暂存区:
再来看一下元数据:
添加到暂存的命令虽然没有返回key,但是从元数据数量上来看,明显多了一条数据,其实本质上添加到暂存区也要执行git hash-object -w命令。而且添加到暂存区也只是新增了存储对象,并没有提交对象和树对象,因此只多了一条。我们上面用数据库操作把hello.txt文件添加到了键值数据库中,现在再来用git add添加,看看数据会不会增多:
可以看到数据依然是3条,没有变化,数据的key也没有改变,这是因为我们上面已经添加了,同样的数据添加几次生成的key都是一样的。
Git树对象
在上面的数据对象中,提到了记住每个版本对应的key肯定是不现实的,文件名也没有被保存,接下来要探讨的对象类型是树对象(tree object),它能解决文件名保存的问题,也允许我们将多个文件组织到一起。 Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。 所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了 inodes 或文件内容。 一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 key指针,以及相应的模式、类型、文件名信息。
树对像解决了文件名的问题,它的目的是将多个文件名组织在一起,其内包含多个文件名称与其对应的Key和其它树对像的引用,可以理解成操作系统当中的文件夹,一个文件夹包含多个文件和多个其它文件夹。
上图中,顶层的tree对象下面有两个blob对象,代表两个文件,第三个是个tree对象,代表子文件夹,下面还是一个blob对象,表示子文件夹下面的文件。
简单介绍完树对象,下面我们新建一个仓库,提交一些内容:
上面我们新建了一个仓库test,在仓库下新建了一个test.txt文件和一个hello文件夹,在hello文件夹下面新建了一个hello.txt文件,然后回到仓库根目录对仓库所有内容进行了整体的一个提交。当前对应的最新树对象是这样的:
git cat-file -p master^{tree}
master^{tree} 语法表示 master 分支上最新的提交所指向的树对象。 请注意,hello 子目录(所对应的那条树对象记录)并不是一个数据对象,而是一个指针,其指向的是另一个树对象:
git cat-file -p 62be0186dd46837d7efe02bdf2e3c3731a7ed2fe
可以看到树对象下面存储了文件的key,文件的名称和下级的树对象,是个经典的树形结构。大家可以想象我们平时开发的项目提交到git仓库,就是文件系统转化为树对象和数据对象的过程。
可以想象到,文件夹下面的文件更新后,文件夹树对象以上的所有树对象都会发生变化,但是下层的不会变,虽然会生成很多key,但是指向关系非常明确,有兴趣可以自行验证。
Git提交对象
现在假如有三个树对象,分别代表了我们想要跟踪的不同项目快照。然而问题依旧:若想重用这些快照,你必须记住所有三个key。 并且,你也完全不知道是谁保存了这些快照,在什么时刻保存的,以及为什么保存这些快照。 而以上这些,正是提交对象(commit object)能为你保存的基本信息。什么是提交对象呢?来看一下操作日志:
第一行commit后面跟着一个hash值,就是提交对象的key,每次提交都会对应一个提交对象。提交对象下面是什么呢?来看一下:
可以看到提交对象对应的就是一个树对象,有几次提交就会对应几个树对象,树对象很抽象并且不好记住,但是提交对象通过提交记录很容易就能找到。我们再来看一下这个树对象下面是什么:
可以看到这个树对象就是我们上面根据分支查询到的树对象:
这就是提交对象和树对象对应的一个关系。提交对象的格式很简单:它先指定一个顶层树对象,代表当前项目快照;然后是作者/提交者信息(依据你的 user.name 和 user.email 配置来设定,外加一个时间戳);留空一行,最后是提交注释。
我们来修改一下test.txt文件并提交,再看一下提交对象:
这时候产生了第二个提交对象。
可以看到第二次的提交对象中还包含了一个父提交对象,指向的是第一次的提交对象。通过这种链接可以明确提交对象的顺序。
每次我们运行 git add 和 git commit 命令时, Git 所做的实质工作——将被改写的文件保存为数据对象,更新暂存区,记录树对象,最后创建一个指明了顶层树对象和父提交的提交对象。 这三种主要的 Git 对象——数据对象、树对象、提交对象——最初均以单独文件的形式保存在 .git/objects 目录下。 简单看一个提交三次后,各种对象的关系图:
大家可以想象我们通过这种方式管理元数据,一旦进行分支切换,版本回退等等操作,可以快速找到对应的key指向的内容,然后再把内容恢复到现在的目录中,是何等的快速!
上面三种对象是git仓库真正的核心部分,其他的像分支,标签等等都是对其中一个提交对象的引用,可以在.git/refs 文件下面看到。我们来看下master分支的引用:
这个key就是我们仓库的提交对象:
所谓的分支标签就是这么一回事。