从.git文件夹探析git实现原理
git是一款分布式代码版本管理工具,通过git能够更加高效地协同编程。了解git的工作原理将有助于我们使用git工具更好地管理项目。通过了解.git文件夹中的文件组成,我们可以从一个角度去窥探git的实现原理。我们知道,在开始开发一个项目或加入一个项目时,需要创建一个新的仓库git init [options]
,或从远端克隆一个已经存在的仓库git clone [uri]
,除使用git init --bare
创建一个“裸”仓库以外,所有创建的本地仓库都包含有一个.git文件夹,需要了解的是,“裸”仓库的内容就是.git文件夹中的内容。讨论“裸”仓库与实际仓库作用的异同不是我们现在讨论的重点,如需了解可翻阅相关文档。由此,git系统当中的的所有数据都存在于.git文件夹之中。
.git文件夹中的内容及作用
打开.git文件夹后,通常有五个文件夹:
- hooks文件夹,用于存储shell脚本,当执行某些git指令后,会触发存储在该文件夹下指定的shell脚本
- info文件夹,用于存储该项目仓库的相关信息
- logs文件夹,用于记录分支提交记录
- objects文件夹,“key-value数据库”
- refs文件夹,用于记录每个分支的最新提交结点以及tags
我们需要着重关注logs文件夹、objests文件夹以及refs文件夹,通过这三个文件夹所存储的内容来分析git。在.git文件夹中,同样存在有一些文件,譬如HEAD、config、index等文件,其中HEAD文件用以记录当前仓库指向的项目提交结点,config文件中记录着仓库的配置信息,这些文件内容不是我们要讨论的重点。
objects文件夹,git中的“key-value数据库”
在讨论objects文件夹的内容之前,我们需要明确存在于git系统中的三个实体,即“提交结点”、“节点内容”、“文件内容”。
objects可以认为是一种“key-value数据库”,之所以将数据库打引号,是因为这个“git的数据库”不具备数据库的基本功能,而仅仅具备可以通过key值能够找到与之对应的value。
提交结点实体,是整个git中的核心实体,提交节点中描述了提交节点之间的继承关系,即本次提交的内容是基于哪个或哪几个之前的提交的内容,提交结点实体之间的关系形成了一个DAG图,通过这个DAG图可以清晰地理顺整个项目的发展脉络,提交节点的内容如下:
tree <SHA1-signature> [ parent <SHA1-signature> ... ] author <author name> <\<author email\>> <timestamp> <time zone> committer <committer name> <\<committer email\>> <timestamp> <time zone> <commit message>
tree
用于指向与该提交结点实体关联的节点内容实体。parent
用于指向该提交节点实体所基于的之前的提交结点实体,可以看到,parent可以是多个。author
用于记录本次提交的作者姓名、作者邮箱、作者所添加的内容时间以及时区。committer
用于记录本次提交的提交者姓名、邮箱等内容。commit message
用于记录当前提交的消息日志。
节点内容实体,用于记录本次提交时,提交中所包含的所有文件名,以及文件名所对应的key值,值得注意的是,可能由于查询性能的缘故,并非是仅记录本次提交时修改的文件,而是记录本次提交时所有的文件。另有一点值得注意的是,即便项目仓库中的文件不变,仅改变某个或某几个文件内容的前后两次提交,生成的前后两次提交节点中的tree
值是不同的,换句话说,节点内容与提交节点是逻辑上的一对一关系。随着之后的讨论我们会很自然地得出这样的结论,这种一对一关系也同样是必须的,尽管在实际情况中允许两个不同的提交节点实体指向相同的节点内容实体。
文件内容实体,用于记录具体的文件内容。也就是说,在一个git仓库中,并非只有程序员们所能看到的当前项目文件夹下的代码版本,包括所有的历史代码都会在.git文件夹中有一个备份。
在objects文件夹中,三种数据实体无差别的以key-value的形式进行存储。因此一次提交操作,在objects文件夹中至少生成两个文件。存储时采用deflate算法对原始文件内容进行压缩,而key值是根据原始文件内容、文件大小等数据生成的消息摘要,在当前版本的git中,消息摘要生成算法采用SHA1算法,生成过程是将文件格式与文件长度组成头部,将文件内容作为尾部,由头部和尾部拼接后作为原文,经过SHA1算法计算之后得到该文件的160位长的SHA1签名。为防止一个文件夹内的文件数量过多,将签名每四位用字符表示十六进制数,于是得到一个长度为40的字符串,将字符串的前两个字符作为文件夹,后38个字符作为文件名进行存储。
观察仔细的同学可以发现,在三个实体的内容里,没有任何一个字段提供分支概念的信息。
logs文件夹,用于记录分支提交记录
该文件夹下的内容是一条分支下的所有提交节点实体序列。在该文件夹下,文件内容格式是单一的,即形如这样:
0000000000000000000000000000000000000000 6a0fa53d78f03abea3439b9213123d1f260f5beb author <mail> 1511776312 +0800 commit (initial): master 1 6a0fa53d78f03abea3439b9213123d1f260f5beb 75642040a2da5b324befde7ca8531b3426b32ba7 author <mail> 1511776323 +0800 commit: master 2 ...
在一个分支创建时,无论这个分支是master还是基于某个提交结点创建的子分支,在logs文件中关于分支的时间线总是以全0的值为开始的。我们需要关注这样几个问题:
- master在初始化时是否会创建一个起始地提交结点?
- 分支创建时的是否会创建一个新的提交节点?
通过 git init
创建一个初始化的git仓库时,master是默认创建的,在初始化的git仓库中,.git文件夹中是不存在logs文件夹的,且在objects文件夹中不包含任何key-value键值对,甚至不存在一个实际存在的master主分支,因此所谓的master初始化并非是在git仓库初始化时进行的,而是在首次提交时进行的。在测试项目中,我在以75为开头的提交结点时创建了一个子分支,查看子分支的logs文件内容:
0000000000000000000000000000000000000000 75642040a2da5b324befde7ca8531b3426b32ba7 author <mail> 1511776334 +0800 branch: Created from HEAD ...
可以看到,子分支创建时并非将主分支上分叉节点复制一下,而是从这个节点起即为一个子分支。
当合并分支时,logs的日志是如何表现,事先需要明确的是,合并分支等价于一次提交(合并分支会生成一个提交结点实体)。我们需要关注这样几个问题:
- 在子分支上已经提交过若干次,在父分支上不提交代码,当在父分支上合并子分支时,父分支的logs记录序列是怎样的
- 在子分支上提交若干次,在父分支上同样提交若干次,当在父分支上合并子分支时,父分支的logs记录序列是怎样的
关于这两个问题,我们需要观察相关的文件。第一个实验是,首先创建了一个仓库,并在master分支上提交了一次代码,之后在master分支的最新提交结点上创建了一个子分支branch,再在branch分支上连续提交了两次代码,而后切换到mster分支后,合并branch分支,logs文档的记录如下:
该文件是master分支的logs文件:
0000000000000000000000000000000000000000 79f586c23a8a169f1651411c879657406757ef92 author <mail> 1511853763 +0800 commit (initial): master 1 79f586c23a8a169f1651411c879657406757ef92 ea4fbfc8600b90555a8a4eb410a176cfbdfa48d7 author <mail> 1511853908 +0800 merge branch: Fast-forward
该文件是branch分支的logs文件
0000000000000000000000000000000000000000 79f586c23a8a169f1651411c879657406757ef92 author <mail> 1511853776 +0800 branch: Created from master 79f586c23a8a169f1651411c879657406757ef92 2dbe03d87733bbcf5b760ba3beacd61ab3f54b58 author <mail> 1511853808 +0800 commit: branch 1 2dbe03d87733bbcf5b760ba3beacd61ab3f54b58 ea4fbfc8600b90555a8a4eb410a176cfbdfa48d7 author <mail> 1511853870 +0800 commit: branch 2
可以看到,在父分支没有做改动且子分支做改动的情况下,由父分支进行合并时,是直接将父分支的最新分支节点定义为子分支上的最新分支节点,ea开头的提交节点实体的内容如下:
tree 1247c7d74e9c28fb83e8e394910346dee104fcae parent 2dbe03d87733bbcf5b760ba3beacd61ab3f54b58 author author <mail> 1511853870 +0800 committer author <mail> 1511853870 +0800 ...
第二个实验是在父分支上提交若干次切在子分支上提交若干次,在父分支上合并子分支时,logs的数据内容。首先创建一个仓库,且在master分支上提交一次代码。在该提交的代码基础上创建一个分支branch。分别在branch分支和在master分支上各自提交两次代码(无冲突),再在master分支上合并branch分支,观察两个分支下的文件内容:
该文件是master分支的logs文件:
0000000000000000000000000000000000000000 8bf95277d6c020f0ade434896355c448fd0cac00 author <mail> 1511854786 +0800 commit (initial): 1 8bf95277d6c020f0ade434896355c448fd0cac00 368ddb7d615e5eedfe5813ff2f88547dcb66b02b author <mail> 1511854819 +0800 commit: change 368ddb7d615e5eedfe5813ff2f88547dcb66b02b 3ba031189d37d816421a019a6240e9d79a683fdd author <mail> 1511854837 +0800 commit: master add 2 3ba031189d37d816421a019a6240e9d79a683fdd 12c6263013cf467f348549337813db01044046b9 author <mail> 1511854896 +0800 merge branch: Merge made by the 'recursive' strategy.
该文件是branch分支的logs文件
0000000000000000000000000000000000000000 8bf95277d6c020f0ade434896355c448fd0cac00 author <mail> 1511854795 +0800 branch: Created from master 8bf95277d6c020f0ade434896355c448fd0cac00 ba65e4cc73953f522f14a47088acf814e26ebc29 author <mail> 1511854859 +0800 commit: add 3 ba65e4cc73953f522f14a47088acf814e26ebc29 eccc7d8176edb5064cde7649ad651bb2e4fce0e3 author <mail> 1511854873 +0800 commit: add 4
可以看出,在执行merge之后,master分支的最后一次提交结点实体是两个logs文件中都从未出现过的,并且,这个以12为开头的最新提交结点实体的前一个结点实体,是父节点的次新提交结点实体。
额外关注一下以12为开头的最新提交结点实体的内容:
tree 659231fc28ddcd98c8d557e07a3fb5de4efc55b6 parent 3ba031189d37d816421a019a6240e9d79a683fdd parent eccc7d8176edb5064cde7649ad651bb2e4fce0e3 author author <mail> 1511854896 +0800 committer author <mail> 1511854896 +0800