Git内部原理
1、总述
作为一名软件开发工程师,我们在日常工作中很少单打独斗,一定会涉及到多人合作共同完成一个项目,那么我们开发的代码要如何给到其他的开发者呢?通过U盘?还是写在word里传给对方?这显然不够优雅,CVS、SVN以及Git等拥有版本控制功能的工具就完美的解决了这个问题,开发人员可以通过SVN或git等实现代码等文件的互通。
Git 和其它版本控制系统(包括 Subversion 和近似工具)的主要差别在于 Git 对待数据的方式。 从概念上来说,其它大部分系统以文件变更列表的方式存储信息,这类系统(CVS、Subversion、Perforce、Bazaar 等等) 将它们存储的信息看作是一组基本文件和每个文件随时间逐步累积的差异 (它们通常称作 基于差异(delta-based) 的版本控制)。
Git应该如何使用我想大家都已经耳熟能详,已经非常的熟练了,但是Git是如何实现版本控制、多人协作以及文件合并的呢?接下来将走进Git内部,深入的探究Git是如何做的,不仅要知其然,更要知其所以然。
2、Git对象
Git实际上是一个内容寻址文件系统(content-addressable filesystem),这意味着Git的核心实际上是一个基于key-value的数据库,我们可以向 Git 仓库中插入任意类型的内容,它会返回一个唯一的键,通过该键可以在任意时刻再次取回该内容。
既然Git是一个文件系统,那么就应该有能够充当“文件”和“文件夹”概念的存在,Git将其统称为“对象”。即向Git仓库插入的任意类型的内容都被称为对象。
2.1、数据对象(blob object)
数据对象充当文件系统中的“文件”概念(但不包含文件名)。数据对象包含一个键值对,其中值
就是文件内容,键
则是将待存储的数据外加一个头部信息(header)一起做 SHA-1 校验运算而得的校验和。 校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。
用底层命令git hash-object
来演示:
1、在经过git init
初始化一个git仓库后,可以看到git自动创建了一个objects
目录,以及info
和pack
两个子目录:
$ git init git-test
Initialized empty Git repository in E:/demo-projects/git-test/.git/
$ cd git-test/
$ find .git/objects/
.git/objects/
.git/objects/info
.git/objects/pack
2、接下来用git hash-object
创建新的blob object
并将其存入哈希数据库:
$ echo 'content one two' | git hash-object -w --stdin
2938b4de55b3da15112c00deadf244dd6d3ef073
$ find .git/objects/
.git/objects/
.git/objects/29
.git/objects/29/38b4de55b3da15112c00deadf244dd6d3ef073
.git/objects/info
.git/objects/pack
3、利用git cat-file
命令可以从Git取出数据:
$ git cat-file -p 2938b4de55b3da15112c00deadf244dd6d3ef073
content one two
$ git cat-file -t 2938b4de55b3da15112c00deadf244dd6d3ef073
blob
$ git cat-file -s 2938b4de55b3da15112c00deadf244dd6d3ef073
16
cat-file的参数主要使用下面几个
-t show object type
-s show object size
-p pretty-print object's content
4、开始进行简单的版本控制:创建一个文件并加入Git数据库,然后对文件内容进行修改,再存入数据库,看一下会有什么样的变化:
$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
warning: LF will be replaced by CRLF in test.txt.
The file will have its original line endings in your working directory
83baae61804e65cc73a7201a7252750c76066a30
$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
warning: LF will be replaced by CRLF in test.txt.
The file will have its original line endings in your working directory
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
$ find .git/objects/ -type f
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/29/38b4de55b3da15112c00deadf244dd6d3ef073
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
可以看到,两次加入的test.txt
都保存在数据库中,我们可以利用cat-file取回指定SHA-1值对应的文件内容。注意,由于是blob object没有指定文件名,因此理论上可以通过cat-file将文件内容取出到任意一个文件中。
$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test-new.txt
$ cat test-new.txt
version 1
2.2、树对象(tree object)
虽然git cat-file
非常强大,但日常工作中记住每一个文件的SHA-1值并不现实,Git引入了树对象来辅助管理,树对象类似于文件系统中的文件夹的概念。
通常Git 根据某一时刻暂存区(即 index 区域,下同)所表示的状态创建并记录一个对应的树对象,它记录了每一个文件/子文件夹的权限、文件类型、SHA-1信息以及文件名在某一个时刻的快照。
我们使用git update-index
命令来将刚刚取出来的test-new.txt加入Git暂存区:
$ git update-index --add --cacheinfo 100644 83baae61804e65cc73a7201a7252750c76066a30 test-new.txt
注:此处的100644指的是文件模式,其中100指文件,644指文件的权限。文件模式其他的选择还包括:
100755:可执行文件
120000:符号链接
040000:文件夹/树对象
此时,我们发现.git
目录下出现了一个index
目录,且通过tortoise git发现,test-new.txt
文件已经变成了Git已跟踪的状态了,使用git status验证一下:
$ git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: test-new.txt
接下来继续正题,用git write-tree
将暂存区内容写入一个树对象,接下来利用cat-file
即可查看该树对象包含的内容了:
$ git write-tree
a37e53c0b0e7c2520d1d594f9b5246ee148de814
$ git cat-file -p a37e53c0b0e7c2520d1d594f9b5246ee148de814
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test-new.txt
这样我们拥有了一个快照,现在来创建下一个树对象:包括一个新文件new.txt
以及前面添加过的test.txt
的第二版(内容为version 2)的那一版。
$ echo "new file" > new.txt
$ git update-index --add new.txt
warning: LF will be replaced by CRLF in new.txt.
The file will have its original line endings in your working directory
$ git update-index --add --cacheinfo 100644 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test-new.txt
$ git write-tree
8821d4c684e63569c0bf448affd5faef50919338
$ git cat-file -p 8821d4c684e63569c0bf448affd5faef50919338
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test-new.txt
添加完成后再用git status
查看暂存区状态:可以清晰的看到
$ git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: new.txt
new file: test-new.txt
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: test-new.txt
接下来还可以用read-tree
命令将第一个树对象添加到第二个树对象中,构造一个层级结构:
$ git read-tree --prefix=bak a37e53c0b0e7c2520d1d594f9b5246ee148de814
$ git write-tree
cdd0e09fa699af925dfb6747986a60b69c1a0c05
$ git cat-file -p cdd0e09fa699af925dfb6747986a60b69c1a0c05
040000 tree a37e53c0b0e7c2520d1d594f9b5246ee148de814 bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test-new.txt
#######################################################################
# 验证不同树对象下的test-new.txt的内容
$ git cat-file -p a37e53c0b0e7c2520d1d594f9b5246ee148de814
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test-new.txt
$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30
version 1
$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
version 2
此时,新的树对象cdd0e09fa699af925dfb6747986a60b69c1a0c05
的结构如下图:
2.3、提交对象(commit object)
到这里我们发现,即使是引入了tree object
依然还要使用SHA-1值来指代各个树对象,而我们并不能完全记住哪一颗树是哪个时间创建的,有什么内容,就像现在,你还记得本文创建的每棵树的SHA-1值吗?我们来回顾一下:
1、第一颗树,SHA-1值为a37e53c0b0e7c2520d1d594f9b5246ee148de814
包含一个内容为"version 1"的文件test-new.txt
$ git cat-file -p a37e53c0b0e7c2520d1d594f9b5246ee148de814
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test-new.txt
$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30
version 1
2、第二颗树,SHA-1值为8821d4c684e63569c0bf448affd5faef50919338
包含一个内容为"version 2"的文件test-new.txt
和一个内容为"new file"的文件new.txt
$ git cat-file -p 8821d4c684e63569c0bf448affd5faef50919338
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test-new.txt
$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
version 2
$ git cat-file -p fa49b077972391ad58037050f2a75f74e3671e92
new file
3、第三棵树,SHA-1值为cdd0e09fa699af925dfb6747986a60b69c1a0c05
,包含第二颗树和第一棵树,其中第一颗树的内容放在了bak目录下,具体如下:
$ git cat-file -p cdd0e09fa699af925dfb6747986a60b69c1a0c05
040000 tree a37e53c0b0e7c2520d1d594f9b5246ee148de814 bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test-new.txt
$ git cat-file -p a37e53c0b0e7c2520d1d594f9b5246ee148de814
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test-new.txt
$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30
version 1
$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
version 2
回顾完现有的三棵树对象后,将引入提交对象(commit object)来记录提交人、提交时间以及可能存在的父提交等等信息:
$ echo 'first commit' | git commit-tree a37e53c0b0e7c2520d1d594f9b5246ee148de814
1580971f914fe3bed9c1ccdb117387db3525cf63
$ git cat-file -p 1580971f914fe3bed9c1ccdb117387db3525cf63
tree a37e53c0b0e7c2520d1d594f9b5246ee148de814
author Lu Hao <luhao@tp-link.com.cn> 1638934182 +0800
committer Lu Hao <luhao@tp-link.com.cn> 1638934182 +0800
first commit
接下来再创建两个提交,将三个树对象串联起来:
$ echo 'second commit' | git commit-tree 8821d4c684e63569c0bf448affd5faef50919338 -p 1580971f914fe3bed9c1ccdb117387db3525cf63
bb6d6733199b5dbe16b4d5247999d6c9487cbd15
$ echo 'third commit' | git commit-tree cdd0e09fa699af925dfb6747986a60b69c1a0c05 -p bb6d6733199b5dbe16b4d5247999d6c9487cbd15
ff9152a5392a6f0f1f290cd8928d41b308b14fa9
这时,在最后一个提交上使用git log --stat
命令就会发现已经有了一个完整的提交日志了:这实际上就是git add
与git commit
做的事情:将被改写的文件保存为数据对象, 更新暂存区,记录树对象,最后创建一个指明了顶层树对象和父提交的提交对象。
$ git log --stat ff9152a5392a6f0f1f290cd8928d41b308b14fa9
commit ff9152a5392a6f0f1f290cd8928d41b308b14fa9
Author: Lu Hao <luhao@tp-link.com.cn>
Date: Wed Dec 8 11:46:29 2021 +0800
third commit
bak/test-new.txt | 1 -
1 file changed, 1 deletion(-)
commit bb6d6733199b5dbe16b4d5247999d6c9487cbd15
Author: Lu Hao <luhao@tp-link.com.cn>
Date: Wed Dec 8 11:32:13 2021 +0800
second commit
bak/test-new.txt | 1 +
new.txt | 1 +
test-new.txt | 2 +-
3 files changed, 3 insertions(+), 1 deletion(-)
commit 1580971f914fe3bed9c1ccdb117387db3525cf63
Author: Lu Hao <luhao@tp-link.com.cn>
Date: Wed Dec 8 11:29:42 2021 +0800
first commit
test-new.txt | 1 +
1 file changed, 1 insertion(+)
3、Git引用
HEAD、分支(branch)、标签(tag)等,其本质都是引用,可以在.git/refs
目录中看到他们的存在。git引用是帮助使用者标识对应SHA-1值的文件,我们可以创建一个新的引用来记录最新的提交所在的位置,这实际上就是分支的本质。
$ echo ff9152a5392a6f0f1f290cd8928d41b308b14fa9 > .git/refs/heads/master
$ git log --pretty=oneline master
ff9152a5392a6f0f1f290cd8928d41b308b14fa9 (HEAD -> master) third commit
bb6d6733199b5dbe16b4d5247999d6c9487cbd15 second commit
1580971f914fe3bed9c1ccdb117387db3525cf63 first commit
此时就可以用高级命令git log
来查看提交历史了:
$ git log
commit ff9152a5392a6f0f1f290cd8928d41b308b14fa9 (HEAD -> master)
Author: Lu Hao <luhao@tp-link.com.cn>
Date: Wed Dec 8 11:46:29 2021 +0800
third commit
commit bb6d6733199b5dbe16b4d5247999d6c9487cbd15
Author: Lu Hao <luhao@tp-link.com.cn>
Date: Wed Dec 8 11:32:13 2021 +0800
second commit
commit 1580971f914fe3bed9c1ccdb117387db3525cf63
Author: Lu Hao <luhao@tp-link.com.cn>
Date: Wed Dec 8 11:29:42 2021 +0800
first commit
HEAD引用是一个符号引用(symbolic reference),即一个指向其他引用的指针,HEAD一般会指向当前的分支,在进行其他诸如新建分支、切换分支、提交等操作时为表明最新提交的SHA-1值起关键作用。
标签引用则是永不移动的分支引用,它永远指向同一个提交对象,这个提交对象被我们称之为**“标签对象”(tag object)**
远程引用位于.git/object/origin
目录,储存着远程的分支、标签等信息,它是一个只读的引用,HEAD引用永远不能指向远程引用,因此永远不能用commit更新远程引用,而只能通过更新本地引用再push到远程。
4、Git包
前面提到,每一次创建新的对象都会在.git/objects
目录下生成一系列文件,而Git又是一个“基于快照”的版本控制系统,每次提交都将生成完整的快照,那么随着时间流逝,如果不做任何操作,Git数据库的体积的增长速度会越来越快,因此Git设计了压缩打包机制来防止产生过多的对象。
在没有压缩之前,Git 最初向磁盘中存储对象时所使用的格式被称为“松散(loose)”对象格式,而Git会通过压缩打包,将这些松散的对象打包成Git包,实现增量的存储。
git gc
就是这样的一条命令,它会在执行pull等命令时自动触发。在执行前,先看一下objects目录下有哪些文件:
$ find .git/objects/ -type f
.git/objects/15/80971f914fe3bed9c1ccdb117387db3525cf63
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/29/38b4de55b3da15112c00deadf244dd6d3ef073
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/88/21d4c684e63569c0bf448affd5faef50919338
.git/objects/a3/7e53c0b0e7c2520d1d594f9b5246ee148de814
.git/objects/bb/6d6733199b5dbe16b4d5247999d6c9487cbd15
.git/objects/cd/d0e09fa699af925dfb6747986a60b69c1a0c05
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92
.git/objects/ff/9152a5392a6f0f1f290cd8928d41b308b14fa9
随后执行git gc
:会发现很多个object消失了,取而代之的是pack
中的一对文件(idx和pack),用verify-pack
来看包中的数据发现,我们前面做的三个提交全部都打包进来了。
$ git gc
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 6 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (9/9), done.
Total 9 (delta 0), reused 5 (delta 0), pack-reused 0
$ find .git/objects/ -type f
.git/objects/29/38b4de55b3da15112c00deadf244dd6d3ef073
.git/objects/info/commit-graph
.git/objects/info/packs
.git/objects/pack/pack-dcaadb1de954542edabb220467ede8e288a9461e.idx
.git/objects/pack/pack-dcaadb1de954542edabb220467ede8e288a9461e.pack
$ git verify-pack -v .git/objects/pack/pack-dcaadb1de954542edabb220467ede8e288a9461e.idx
ff9152a5392a6f0f1f290cd8928d41b308b14fa9 commit 219 152 12
bb6d6733199b5dbe16b4d5247999d6c9487cbd15 commit 220 152 164
1580971f914fe3bed9c1ccdb117387db3525cf63 commit 171 123 316
83baae61804e65cc73a7201a7252750c76066a30 blob 10 19 439
fa49b077972391ad58037050f2a75f74e3671e92 blob 9 18 458
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob 10 19 476
cdd0e09fa699af925dfb6747986a60b69c1a0c05 tree 105 105 495
a37e53c0b0e7c2520d1d594f9b5246ee148de814 tree 40 50 600
8821d4c684e63569c0bf448affd5faef50919338 tree 75 77 650
non delta: 9 objects
.git/objects/pack/pack-dcaadb1de954542edabb220467ede8e288a9461e.pack: ok
但值得注意的是:git gc
不会打包那些悬空(dangling)的数据,比如,若没有将提交对象的SHA-1储存到引用中,打包时提交对象将不会被打包,如下:
$ git gc
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (5/5), done.
Total 5 (delta 0), reused 0 (delta 0), pack-reused 0
$ find .git/objects/ -type f
.git/objects/15/80971f914fe3bed9c1ccdb117387db3525cf63
.git/objects/29/38b4de55b3da15112c00deadf244dd6d3ef073
.git/objects/88/21d4c684e63569c0bf448affd5faef50919338
.git/objects/bb/6d6733199b5dbe16b4d5247999d6c9487cbd15
.git/objects/ff/9152a5392a6f0f1f290cd8928d41b308b14fa9
.git/objects/info/packs
.git/objects/pack/pack-44648cd90240ab687547eee0989f2dde2aee9a36.idx
.git/objects/pack/pack-44648cd90240ab687547eee0989f2dde2aee9a36.pack
$ git verify-pack -v .git/objects/pack/pack-44648cd90240ab687547eee0989f2dde2aee9a36.idx
83baae61804e65cc73a7201a7252750c76066a30 blob 10 19 12
fa49b077972391ad58037050f2a75f74e3671e92 blob 9 18 31
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob 10 19 49
cdd0e09fa699af925dfb6747986a60b69c1a0c05 tree 105 105 68
a37e53c0b0e7c2520d1d594f9b5246ee148de814 tree 40 50 173
non delta: 5 objects
.git/objects/pack/pack-44648cd90240ab687547eee0989f2dde2aee9a36.pack: ok
通过gc机制,Git也将文件变成了增量存储的方式,每次提交更新或保存项目状态时,Git就会对当时的全部文件创建一个快照并保存这个快照的索引。 如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件,执行了gc后,Git会根据文件名和文件大小识别出响应文件的变化,并保存这个差异。
5、Git传输协议
Git 可以通过两种主要的方式在版本库之间传输数据:“哑(dumb)”协议和“智能(smart)”协议
5.1、哑协议
在传输过程中,服务端不需要有针对 Git 特有的代码;抓取过程是一系列 HTTP 的 GET
请求,这种情况下,客户端可以推断出服务端 Git 仓库的布局。
通过拉取info/refs
、获取HEAD、读树对象等等一系列的操作对git仓库分布进行推断,这种方式现在已经几乎不再使用了。
5.2、智能协议
智能协议是更常用的传送数据的方法,它需要在服务端运行一个进程,可以读取本地数据,理解客户端有什么和需要什么,并为它生成合适的包文件。 总共有两组进程用于传输数据,它们分别负责上传和下载数据。
5.2.1、上传数据
上传数据启动的两个进程是send-pack
和receive-pack
,运行在客户端上的 send-pack
进程连接到远端运行的 receive-pack
进程。
在执行git push origin master
命令时,git-receive-pack
命令会立即为它所拥有的每一个引用发送一行响应,客户端接收到响应时,便知道了服务端的状态,你的 send-pack
进程会判断哪些提交记录是它所拥有但服务端没有的,send-pack
会告知 receive-pack
这次推送将会更新的各个引用。
5.2.2、下载数据
下载数据启动的两个进程是fetch-pack
和upload-pack
,客户端启动 fetch-pack
进程,连接至远端的 upload-pack
进程,以协商后续传输的数据。
fetch-pack
进程查看它自己所拥有的对象,并响应 “want” 和它需要的对象的 SHA-1 值。 它还会发送“have”和所有它已拥有的对象的 SHA-1 值。 在列表的最后,它还会发送“done”以通知 upload-pack
进程可以开始发送它所需对象的包文件。