我在上大学的时候并无接触过VCS(版本控制系统)。虽然曾经在Google Code发布过去项目,可是以压缩包的形式发布的;与室友合做开发计算机网络这门课的课程设计时,也没有用上。直到入职第一家公司后才真正开始使用,当时用的是Git,此后也始终没用过其它的VCS——SVN仅仅耳闻不曾使用——转眼间已经用了六年多的Git了。html
尽管平常使用问题不大,但对于Git的内部运行原理我仍然是只知其一;不知其二——也不是我谦虚,基本就是不懂吧。例如,使用git add
、git commit
、git branch
等命令的时候,Git在背后究竟作了什么,我是答不上来的。好在互联网上有许多这方面的资料可供学习,我硬着头皮看了很多文档和博客后,总算是习得了一些皮毛。git
如今,我试着按部就班地讲解一遍吧。github
首先建立出一个仓库并向其中添加一个文件shell
mkdir git-test
cd git-test
git init
echo 'hello' > a
git add .
复制代码
到此为止,暂时不要提交改动。如今,我来看看Git到底在背后作了些什么。Git的秘密都藏在叫作.git
的目录中,尤为是其中的objects
目录。用tree
命令查看这个目录的结果以下bash
.git/objects
├── ce
│ └── 013625030ba8dba906f756967f9e9ca394464a
├── info
└── pack
复制代码
与运行git add
前相比,多出了一个叫ce
的目录,以及位于其中的叫013625030ba8dba906f756967f9e9ca394464a
的文件。这个文件其实就是a
的一个“副本”,其中存储着文件a
的内容。可是不能用cat
直接查看,由于Git对这个文件作了压缩。能够用pigz
来获得压缩前的原文,示例代码以下网络
pigz -d < .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
复制代码
结果为数据结构
blob 6hello
复制代码
Git生成这个文件的规则其实不复杂。首先Git会计算原文件的长度,即6(之因此是6,是由于用echo
和重定向写入文件a
时,添加了一个换行符)。而后,Git将一个固定的前缀blob
(此处有一个空格)、文件长度、一个空字符(ASCII码为0的字符),以及文件内容这四者链接成一个字符串,并计算这个字符串的SHA1摘要。具体到文件a
,能够用下面的命令试着计算ide
printf "blob 6\0hello\n" | shasum
复制代码
或者用Git内置的hash-object
子命令会更简单post
git hash-object a
复制代码
不论是哪个命令,算出来的摘要都是ce013625030ba8dba906f756967f9e9ca394464a
。而后Git会取前两个字符(ce
)做为目录名,在.git/objects
下建立新的目录。以从第三个字符开始的剩余内容(013625030ba8dba906f756967f9e9ca394464a
)为文件名,将方才拼接好的内容压缩后写如文件。这种文件用Git的术语来说叫作blob
对象,稍后还会遇到tree
类型和commit
类型的对象。学习
接下来提交改动
git config user.email 'foobar'
git config user.name 'foobar'
git commit -m 'test'
复制代码
此时会发现.git/objects
下新增了两个文件
.git/objects
├── 09
│ └── 76950c1fdbcb52435a433913017bf044b3a58f # 新的
├── 14
│ └── c77e71bd06df41e1509280cfba045e1db2aa5f # 新的
├── ce
│ └── 013625030ba8dba906f756967f9e9ca394464a
├── info
└── pack
复制代码
用git cat-file -t
能够查看这两个新文件的类型
git cat-file -t 14c77e71bd06df41e1509280cfba045e1db2aa5f # 输出commit
git cat-file -t 0976950c1fdbcb52435a433913017bf044b3a58f # 输出tree
复制代码
也能够用git cat-file -p
以可读的方式输出新文件的内容。例如用git cat-file -p 0976950c1fdbcb52435a433913017bf044b3a58f
输出tree
类型的对象的内容,结果为
100644 blob ce013625030ba8dba906f756967f9e9ca394464a a
复制代码
tree
类型的对象中记录着Git所追踪的文件的元信息,包括文件的权限、在Git中的对象类型、对象摘要,以及文件名。另外一个commit
类型的对象中存储着本次提交的信息,用git cat-file -p
查看的结果以下
tree 0976950c1fdbcb52435a433913017bf044b3a58f
author foobar <foobar> 1576676836 +0800
committer foobar <foobar> 1576676836 +0800
test
复制代码
第一行表示这个commit
对象指向的是哪个tree
对象,从这个tree
对象出发,能够遍历仓库中直到本次提交为止、全部被Git追踪的文件。commit
指向tree
,tree
能够指向blob
也能够指向其它的tree
,blob
就像是树中的叶子节点,再也不指向其它的对象,它们之间的关系以下图所示
Git的branch
子命令用于建立新分支——虽然我平时更多地使用git checkout -b
。既然add
和commit
的时候,Git会建立出blob
、tree
,以及commit
类型的对象,那么建立新分支的时候,Git是否是也会建立名为branch
的对象呢?答案是否认的。
Git的分支很是简单——它仅仅是指向某个commit
对象的引用,就像是*nix
系统中的符号连接同样。全部分支都存储在.git/refs/heads
之下。例如文件.git/refs/heads/master
中便存储着master
分支上的最新提交的摘要
cat .git/refs/heads/master # 输出14c77e71bd06df41e1509280cfba045e1db2aa5f
复制代码
这就是在Git中建立新分支的成本很低的缘由——不过是复制一下当前分支在.git/refs/heads
下的同名文件而已。我建立一个新分支develop
并提交一个新文件b
,.git/objects
下会多出三个文件
git checkout -b develop
echo 'good' > b
git add b
git commit -m 'new branch'
复制代码
三个新文件分别存储着文件b
的内容(一个blob
对象)、文件b
的元信息(一个tree
对象),以及本次提交(一个commit
对象)。这些文件中没有任何关于develop
分支的信息,develop
分支仅仅是一个存在于.git/refs/heads/
目录下的同名文件。
develop
分支是从master
分叉出来,将develop
合并回master
时,Git会进行一次fast-forward
的合并。虽然名字很唬人但其实Git作的事情很是简单,只须要将.git/refs/heads/master
文件的内容修改成与develop
相同的摘要便可。
也能够要求Git不使用fast-forward
。先用git reset --hard HEAD^1
将master
分支回退到第一次提交的状态,而后使用下列的命令再次将develop
合并进来
git merge --no-ff develop
复制代码
这一次,Git再也不简单地修改.git/refs/heads/master
文件了事,而是会建立一个新的commit
对象。在个人电脑上,这个新的commit
对象的摘要为d1403bb629c7a636c724069b22875ed882b54bcc
,使用git cat-file -p
看看它的内容
tree e960ed43b8e6b5fe9b4e57b806f70796da820056
parent 14c77e71bd06df41e1509280cfba045e1db2aa5f
parent db891542d3e44448433ba86c7cd636d8aec3da54
author foobar <foobar> 1576679608 +0800
committer foobar <foobar> 1576679608 +0800
Merge branch 'develop'
复制代码
有趣的是,这个commit
对象有两个“父级”的commit
,而不像日常所认识的树形数据结构那般只有一个“父节点”。显然,这两个父节点分别是合并前的master
分支的最新一次提交,以及develop
的最新提交。
虽然建立了一个新的commit
对象,但其实develop
分支的最新提交持有的即是整个仓库的最新版本,因此不须要建立新的tree
,合并所产生的commit
直接与develop
分支的最新提交共用同一个tree
对象便足够了——在上面输出内容的第一行的摘要,就是develop
分支的最新commit
所指向的tree
对象的摘要。
至此,终于解决了我一直以来的一个困惑。我曾天真地觉得,Git在合并两个分支的时候,会将待合进来的分支中的全部多出来的改动,复制到要合进去的分支中去。这都是由于我没有理解分支的本质,Git的分支并非一根水管,没有哪个提交是只能装在一个特定的分支中的。Git合并的时候,就像是在一个immutable的树上作修改,只须要建立很少的新commit
和tree
对象,再引用已经存在的旧commit
和tree
对象便可。不然,哪能快速地完成两个分支的合并呢。
没想到还写了蛮多内容的,通过这么几回试验,我对Git的核心原理也算略知一二了,暂时不打算继续深刻。各位读者若是有兴趣,能够试着制造一次有冲突的合并,而后看看冲突解决的先后,.git/objects
目录下会有什么变化。
最后,在摸索Git原理的过程当中,我找到了很多优质的参考资料,这里一并奉上: