前言
最近在读《Pro Git》这本书,虽然距离第二版已经过去了好几年,Git 也在不断更新,但由于 Git 核心团队一直保持着良好的向后兼容性,所以书中关于 Git 的核心概念和命令依然有效。
这本书令我收益良多,明白了 Git 的本质,对于经常使用的 add、commit 等命令有了更直观的感受。本文用于记录我对 Git 的一些理解,先讲基础原理,再说使用实践,自底向上的阐述 Git。
基本原理
要想真正的了解 Git,必须要明白它在本质上是一个内容寻址的文件系统,在此之上,实现了版本控制的功能。那么,什么叫内容寻址呢?
内容寻址
在了解内容寻址之前,要先谈一下位置寻址;什么是位置寻址?在真实世界中,地理地址就是一个明显的例子:xx 省 xx 市 xx 区 xx 街 xx 号,通过地址可以更容易找到目的地(位置)。Web 也是位置寻址。Youtube.com 和 Github.com 都是地点。我们可以通过资源定位符(URL)来找到视频、代码仓库等资源内容。还有操作系统,通过目录及文件名定位位置,查看文件内容。
要注意的是,位置寻址有一个很重要的特性:内容改变。在电影《落叶归根》中,老赵背着工友的尸体,历经辛苦才找到工友的家(位置寻址),却只剩一片废墟,而后在废墟中找到了新的地址,重新启程(HTTP 301 Status Code)。在 Web 上,我们访问的新闻网站,随时都有可能更新内容,访问的资源文件,有可能会 404(链接失效)。在这种情况下,地址是相同的,内容却发生了改变。
所以在有的时候,我们需要根据内容寻址。比如我们去书店买书,会告诉店员想要买《Pro Git》这本书,更精确一点可能是《Pro Git 第一版》或《Pro Git 第二版》。而不是告诉店员,我们需要上次来书店时,发现的 x 号书架上第三排左数第四本书,如果是这样,店员很可能会给你递来一本无关的书。在这个场景下,《Pro Git》这本书就是我们想要的内容。
内容寻址最重要的是记录了它是什么,而不是它在哪里。
直接记录快照,而非基于差异
如果平时在提交前会使用 git diff 命令检查变更内容,或是使用 diff 进行 Code Review,就会很容易给人造成一种错觉:Git 是通过差异比较形成的版本控制。
基于差异(delta-based) 的版本控制是指以文件变更列表的方式存储信息,将存储的信息看作是一组基本文件和每个文件随时间逐步累计的差异。
但其实 Git 不按照以上方式对待或保存数据。反之,Git 在每次提交更新时都会制作一个快照,存储全部文件内容及其目录关系,并保存这个快照的索引。 如果文件没有修改,Git 也不会重复存储,而是根据内容去做寻址。Git 对待数据更像是一个 快照流。
哪种方式更好?
直接记录快照的优势在于可以更简单的实现快速切换版本,包括不同分支之间的切换,因为每个版本的文件都是直接存储的。而劣势在于存储空间会占用的更多,因为哪怕文件只改动一个字符,也需要重新存储。反之,基于差异的存储方式占用的虽然存储空间少,但也不容易实现快速切换。
两种方式各有优劣,虽然 Git 在存储时是按照快照的方式存储,但为了避免 Git 仓库过大,也会在 gc 时将松散对象(loose object)压缩为打包对象(packed object)。Git 将打包对象生成为 .pack 文件,使用差异的方式进行存储,它会使用复杂的算法去决定哪些文件是最相似的,然后基于此分析去计算差异。
Git 对象
现在我们已经知道了 Git 是内容寻址的文件系统,它的核心就是一个简单的键值对数据库(key-value data store)。你可以向该数据库插入任何内容,它会返回一个 40 位的字符串键,键值就是根据文件内容生成的哈希值,使用该键值可以在任何时刻在此查找该内容。这就意味着同样内容的文件,在这个数据库中会指向同一个位置,不会重复存储。
Git 存储的主要对象类型有三种:数据对象(blob object)、树对象(tree object)、提交对象(commit object);数据对象相当于文件内容,树对象相当于文件目录,提交对象则是对文件系统的快照。
接下来会通过实例,使用 hash-object、cat-file 等对其一一讲解。首先,我们需要使用 git init 命令初始化项目,该命令会在目录下生成 .git 子目录,Git 会将其存储的对象放入 .git/objects 目录中:
$ git init demoInitialized empty Git repository in /tmp/demo/.git/$ cd demo$ find .git/objects.git/objects.git/objects/pack.git/objects/info
数据对象
初始化后的 objects 目录中仅有两个空的目录:pack 与 info。使用 hash-object 命令将测试文件内容写入存储:
$ echo "demo" > demo.txt$ git hash-object -w demo.txt1549b67ca5936e6893c89221d508697e7e97d42b$ find .git/objects -type f.git/objects/15/49b67ca5936e6893c89221d508697e7e97d42b
从上面的执行结果可以看出,hash-object 返回了一个 SHA-1 的哈希值,这是由待存储的数据外加一个头部信息(header)结合在一起做 SHA-1 校验运算而得出的哈希摘要。Git 使用了前两个字符作为子目录,后 38 位用作文件名。因为有些文件系统不能允许目录中文件数量过多,或当文件过多时影响效率,所以 Git 使用前两位作为子目录,可以避免这种情况。
生成后的文件内容并不能使用 cat 等命令直接查看,需要使用 Git 命令 cat-file 来查看文件的原始内容:
$ git cat-file -p 1549b67ca5936e6893c89221d508697e7e97d42bdemo
接下来让我们修改 demo.txt 文件内容,对其进行简单的版本控制:
$ echo "demo-version2" > demo.txt$ git hash-object -w demo.txt3e13725cb0bc40746ca29bca1abed58c2fbd0024$ find .git/objects -type f.git/objects/3e/13725cb0bc40746ca29bca1abed58c2fbd0024.git/objects/15/49b67ca5936e6893c89221d508697e7e97d42b
可以看到,数据库已经记录了两个不同的版本,让我们把文件内容修改回第一个版本:
$ git cat-file -p 1549b67ca5936e6893c89221d508697e7e97d42b > demo.txt$ cat demo.txtdemo
上面使用 cat-file 的 -p 参数表示自动判断对象类型,并展示格式友好的数据,可以使用 -t 参数来获取对象的类型,验证一下是否为数据对象:
$ git cat-file -t 1549b67ca5936e6893c89221d508697e7e97d42bblob
数据对象只是解决了文件内容的存储,我们需要记住每个数据对象的哈希值才能去访问,这明显是不现实的。而且我们还需要记录文件名等相关内容,这些都需要树对象来处理。
树对象
树对象就是文件目录树,它能将多个文件组织在一起,记录了文件获取目录的模式、类型、文件名信息。Git 以一种类似 Unix 文件系统的方式存储内容,工作目录中的所有内容均以树对象和数据对象的形式存储,其中树对象对应了 Unix 中的目录,数据对象则是文件内容。
从概念上讲,Git 内部存储的数据有点像这样:
Git 会通过暂存区(Index)所存储的状态创建一个树对象,所以我们要先将一个文件添加到暂存区中,还是上面的仓库,使用 update-index 命令将 demo.txt 的首个版本加入暂存区:
$ git update-index --add --cacheinfo 100644 1549b67ca5936e6893c89221d508697e7e97d42b demo.txt
--add 参数表示将新文件加入至暂存区,--cacheinfo 三个参数分别代表文件模式、数据对象、文件名或路径。在本例中,我们指定的文件模式为 100644,表示这是一个普通文件。其他的选择还包括:100755 ,表示一个可执行文件;120000,表示一个符号链接;040000,表示一个目录;160000,表示 gitlink,用于链接子模块。以上就是 Git 的全部模式。
写入暂存区后,并未将树对象保存在 Git 存储中,还需要使用 write-tree 存储:
$ git write-tree5085171957abcbffd876c271b6e157228fe56e5c
使用 cat-file 进行验证:
$ git cat-file -p 5085171957abcbffd876c271b6e157228fe56e5c100644 blob 1549b67ca5936e6893c89221d508697e7e97d42bdemo.txt$ git cat-file -t 5085171957abcbffd876c271b6e157228fe56e5ctree
现在让我们将第二版也写入进来,加入 new 文件夹中:
$ git update-index --add --cacheinfo 10644 1549b67ca5936e6893c89221d508697e7e97d42b new/version2.txt$ git write-tree5f80cae766291d6ce164a5632b213054bc25f13d$ git cat-file -p 5f80cae766291d6ce164a5632b213054bc25f13d100644 blob 1549b67ca5936e6893c89221d508697e7e97d42bdemo.txt040000 tree 84398f2084c6ea59f656fab4a358c086a0e817a9new$ git cat-file -p 84398f2084c6ea59f656fab4a358c086a0e817a9100644 blob 1549b67ca5936e6893c89221d508697e7e97d42bversion2.txt
现在我们最新的树对象已经形成了上文数据模型图中的结构组织,这种组织模型叫做哈希树,哈希树的概念由 Merkle 提出并申请专利,所以也叫默克尔树(Merkle tree)。
默克尔树是基于哈希值的树形数据结构,可以是二叉树,也可以是多叉树。它的叶子节点是以数据块或数据块的哈希值作为标签,而非叶子节点的标签则是由计算其子节点的哈希值得到。也就是说,默克尔树是一种自底向上构建形成的树结构。它的特点是,底层数据的任何变动,都会传递至上层节点,直至根节点。所以,如果两个默克尔树的根节点值一致,则这两个树的结构,节点内容也必定相同。
提交对象
虽然我们已经有了树对象,它解决了文件名及目录组织的问题,而且,如果我们分阶段提交,树对象就可以看作是目录树的一次次快照。但是作为一个完整的版本控制系统,我们还需要知道谁保存了这些快照、什么时间、以及为什么保存这些快照。以上这些问题,就是提交对象所解决的问题。
使用上面所生成的树对象,通过 commit-tree 命令写入提交对象:
$ git commit-tree 84398f2084c6ea59f656fab4a358c086a0e817a9 -m "first"47d5e351638a282f5b2c3d70113e890a8b59ac0f
与 commit 命令类似,可以通过 -m 参数指定提交信息,提交作者与提交时间会通过配置自动生成,不需要手动填写。同样,使用 cat-file 进行验证:
$ git cat-file -p 47d5e351638a282f5b2c3d70113e890a8b59ac0ftree 84398f2084c6ea59f656fab4a358c086a0e817a9author cnbailian <594647004@qq.com> 1590018346 +0800committer cnbailian <594647004@qq.com> 1590018346 +0800first
上面是属于首次提交,接着我们将创建另一个提交对象,引用上一个提交,这样代码版本才能形成一条时间线:
$ echo "second" | git commit-tree 5f80cae -p 47d5e354a50341ad5fd8b672a7485b5a13490e9d9a7161c$ git cat-file -p 4a50341tree 5f80cae766291d6ce164a5632b213054bc25f13dparent 47d5e351638a282f5b2c3d70113e890a8b59ac0fauthor cnbailian <594647004@qq.com> 1590018473 +0800committer cnbailian <594647004@qq.com> 1590018473 +0800second
可以看出,相比上一次提交,多出了 parent 部分指向上一次的提交对象。现在,我们使用 git log 命令查看提交历史,使用 --stat 参数可以查看文件的改动状态:
$ git log --stat 4a50341commit 4a50341ad5fd8b672a7485b5a13490e9d9a7161cAuthor: cnbailian <594647004@qq.com>Date: Thu May 21 07:47:53 2020 +0800 second version2.txt => demo.txt | 0 new/version2.txt | 1 + 2 files changed, 1 insertion(+)commit 47d5e351638a282f5b2c3d70113e890a8b59ac0fAuthor: cnbailian <594647004@qq.com>Date: Thu May 21 07:45:46 2020 +0800 first version2.txt | 1 + 1 file changed, 1 insertion(+)
Git 并不会显式跟踪文件移动操作,如果在 Git 中重命名了某个文件,仓库中存储的元数据也不会体现出这是一次改名操作。不过 Git 可以根据文件内容,也就是 blob 对象推测出发生了什么。如上图,version2.txt 与 demo.txt 文件内容一致,所以在 second 提交中,推测是重命名操作,显示文件内容没有修改。
总结
本篇文章主要介绍了 Git 的本质——内容寻址的文件系统,同时通过底层命令学习 Git 如何存储内容对象以及对象的三种类别。
下篇文章我将介绍第四种 Git 对象,同时还会讲解 Git 中我们非常熟悉的概念:分支。