Git工程开发实践(二)——Git内部实现机制

一、Git仓库内部实现简介

Git本质上是一个内容寻址(content-addressable)的文件系统,根据文件内容的SHA-1哈希值来定位文件。Git核心部分是一个简单的键值对数据库(key-value data store)。向Git数据库插入任意类型的内容,会返回一个键值,通过返回的键值可以在任意时刻再次检索(retrieve)插入的内容。通过底层命令hash-object可以将任意数据保存到.git目录并返回相应的键值。
Git包含一套面向版本控制系统的工具集,包括高级命令和底层命令。高级命令主要由用户使用,底层命令可以窥探Git内部的工作机制,但多数底层命令并不面向最终用户,更适合作为新命令和自定义脚本的组成部分。
使用git init创建仓库时,Git会创建一个.git目录,其目录结构如下:
Git工程开发实践(二)——Git内部实现机制_第1张图片
A、description文件仅供GitWeb程序使用。
B、config文件包含项目特有的配置选项。
C、info目录包含一个全局性排除(global exclude)文件,用于放置不希望被记录在.gitignore文件中的忽略模式(ignored patterns)。
D、hooks目录包含客户端或服务端的钩子脚本(hook scripts)。
E、objects目录存储所有数据内容,内有info、pack子目录。
F、refs目录存储指向数据(分支)的提交对象的指针。
G、HEAD文件指示目前被检出的分支。
H、index(尚待创建)文件保存暂存区信息。
objects、refs、HEAD、index是Git仓库的四个核心部分。

二、Git对象

1、Git对象简介

Git对象分为四种:数据对象(blob)、树对象(tree)、提交对象(commit)、标签对象(tag)。Git文件系统的设计思路与linux文件系统相似,即将文件的内容与文件的属性分开存储,文件内容存储在文件系统中,文件名、所有者、权限等文件属性信息则另外开辟区域进行存储。
Git利用SHA-1加密算法对其管理的每一个文件生成一个唯一的16进制的40个字符长度的SHA-1哈希值来唯一标识对象。如果文件不变化,SHA-1哈希值不会改变;如果文件改变,会生成新的SHA-1哈希值。40位字符SHA-1哈希值的前两个字符作为目录名,后38个字符作为文件名,标识生成的Git对象。
Git对象的SHA-1哈希值计算公式如下:

header = " " + content.length + "\0"
hash = sha1(header + content)

Git在计算对象hash时,首先会在对象头部添加一个header。header由3部分组成:第一部分表示对象的类型,可以取值blob、tree、commit以分别表示数据对象、树对象、提交对象;第二部分是数据的字节长度;第三部分是一个空字节,用来将header和content分隔开。将header添加到content头部后,使用sha1算法计算出一个40位的hash值。
在手动计算Git对象的hash时需要注意:
A、header中第二部分关于数据长度的计算,一定是字节的长度而不是字符串的长度;
B、header + content的操作并不是字符串级别的拼接,而是二进制级别的拼接。
各种Git对象的hash方法相同,不同的在于:
A、头部类型不同,数据对象是blob,树对象是tree,提交对象是commit;
B、数据内容不同,数据对象的内容可以是任意内容,而树对象和提交对象的内容有固定的格式。
git cat-file可以用来实现所有Git对象的读取,包括数据对象、树对象、提交对象的查看。
git cat-file -p [hash-key] 可以查看已经存在的object对象内容
git cat-file -t [hash-key] 可以查看已经存在的object对象类型

2、Git数据对象

2.1Git数据对象简介

数据对象通常用于存储文件的内容,但不包括文件名、权限等信息。数据对象和其对应文件的所在路径、文件名是否改被更改都完全没有关系。
Git会根据文件内容计算出一个SHA-1 hash值,以hash值作为索引将文件存储在Git文件系统中。由于相同的文件内容的hash值是一样的,因此Git将相同内容的文件只会存储一次。git hash-object命令可以用来计算文件内容的hash值,并将生成的数据对象存储到Git文件系统中。
echo -en "hello,git" | git hash-object --stdin
f28ffa36cdf69904e516babfdb3005e108dddfb7
在echo后面使用-n选项,用来阻止自动在字符串末尾添加换行符,否则会导致实际传给git hash-object是hello,git\n
数据对象查看:
git show + 对象名(SHA1哈希值)

2.2Git数据对象的SHA-1哈希值计算

数据对象的内容格式如下:

blob 

使用git hash-object计算文本的SHA1哈希值
echo -en "hello,git" | git hash-object --stdin
f28ffa36cdf69904e516babfdb3005e108dddfb7
使用openssl计算文本的SHA1哈希值:
echo -en "blob 9\0hello,git" | openssl sha1
(stdin)= f28ffa36cdf69904e516babfdb3005e108dddfb7
如果文本中有中文时,必须注意数据长度的计算是字节数而不是字符数。可以使用命令查看文本的字节数:
echo -n "中文" | wc -c

2.3Git数据对象的存取

git init Test //初始化一个版本库
cd Test //进入Test
find .git/objects //查找.git/objects目录下的内容

Git对objects目录进行初始化,并创建pack和info目录,但均为空。
echo 'test content' | git hash-object -w --stdin //向Git数据库存入文本
d670460b4b4aece5915caf5c68d12f560a9fe3e4 //返回的键值
-w选项指示hash-object命令存储数据对象;若不指定此选项,则上述命令仅返回对应的键值。
--stdin选项则指示上述命令从标准输入读取内容,若不指定此选项,则须在命令尾部给出待存储文件的路径。
命令输出一个长度为40个字符的校验和,是一个SHA-1哈希值,一个将待存储的数据外加一个头部信息(header)一起做SHA-1校验运算而得的校验和。
find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
可以在objects目录下看到一个文件。 Git存储内容的方式是一个文件对应一条内容,用内容加上特定头部信息一起的SHA-1校验和为文件命名。校验和的前两个字符用于命名子目录,余下的38个字符则用作文件名。
可以通过cat-file命令从Git数据库取回数据。指定-p选项可指示cat-file命令自动判断内容的类型,并显示格式友好的内容:
git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

2.4Git版本控制原理的实现

通过对一个文件进行简单的版本控制揭示Git版本控制的原理。首先,创建一个新文件并将其内容存入Git数据库。

echo "version 1" > test //写入test文件内容
git hash-object -w test  //存储test文件到Git数据库

83baae61804e65cc73a7201a7252750c76066a30

echo 'version 2' > test  //写入test文件新的内容
git hash-object -w test  //再次将修改后的test文件存储到Git数据库

1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
Git数据库记录了test文件的两个不同版本。
find .git/objects -type f

.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a

恢复test文件到第一个版本:

git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test   
cat test   //读取test文件内容

version 1
恢复test文件到第二个版本:

git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test
cat test  //读取test文件内容

version 2
上述对文件的版本控制中,记住文件的每一个版本所对应的SHA-1值并不现实,并且文件名并没有被保存。
利用cat-file -t命令,可以查看Git内部存储的任何对象类型,只要给定对象的SHA-1值。
git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob
通过每一个数据对象的hash值,可以访问Git文件系统中的任意数据对象,但记住数据对象的SHA-1哈希值显然是不现实的。数据对象只是解决了文件内容存储的问题,而文件名的存储则需要通过树对象实现。

3、Git树对象

3.1Git对象简介

树对象包含指向数据对象或是其它树对象的多个指针,用来表示内容之间的目录层次关系。
Git所有内容均以树对象和数据对象的形式存储,其中树对象对应UNIX中的目录项,数据对象对应inodes或文件内容。 一个树对象包含一条或多条树对象记录(tree entry),每条树对象记录含有一个指向数据对象或者子树对象的SHA-1指针以及相应的模式、类型、文件名信息。
某项目当前对应的最新树对象可以使用如下命令查看:
git cat-file -p master^{tree}
master^{tree}语法表示master分支上最新的提交所指向的树对象。 目录(所对应的树对象记录)并不是一个数据对象,而是一个指针,其指向的是另一个树对象。
树对象查看:
git show + 对象名/git ls-tree + 对象名
git ls-files --stage命令可以查看暂存区的内容。

3.2Git树对象的SHA1哈希值计算

树对象的内容格式如下:

tree  ...

item sha部分是二进制形式的sha1码,而不是十六进制形式的sha1码。
git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30  test.txt
首先使用xxd把83baae61804e65cc73a7201a7252750c76066a30转换成为二进制形式,并将结果保存为sha1.txt以方便后面做追加操作。

echo -en "83baae61804e65cc73a7201a7252750c76066a30" | xxd -r -p > sha1.txt

构造content部分,并保存至文件content.txt

echo -en "100644 test.txt\0" | cat - sha1.txt > content.txt

计算content的长度
cat content.txt | wc -c
生成SHA-1
echo -en "tree 36\0" | cat - content.txt | openssl sha1
(stdin)= d8329fc1cc938780ffdd9f94e0d364e0ea74f579

3.3Git树对象生成

Git根据某一时刻暂存区(即index文件)所表示的状态创建并记录一个对应的树对象,如此重复便可依次记录(某个时间段内)一系列的树对象。 因此,为创建一个树对象,首先需要通过暂存一些文件来创建一个暂存区。通过update-index为一个单独文件(test.txt文件)的首个版本创建一个暂存区。 利用update-index命令,可以把test文件的首个版本加入一个新的暂存区。

git update-index --add --cacheinfo 100644 \
  83baae61804e65cc73a7201a7252750c76066a30 test

--add表示新增文件名,如果第一次添加某一文件名,必须使用此选项;--cacheinfo mode object path是要添加的数据对象的模式、hash值和路径,path意味着为数据对象不仅可以指定单纯的文件名,也可以使用路径。另外要注意的是,使用git update-index添加完文件后,一定要使用git write-tree写入到Git文件系统中,否则只会存在于暂存区。
指定的文件模式为100644,表明是一个普通文件。 其它选择包括:100755,表示一个可执行文件;120000表示一个符号链接。
现在可以通过write-tree命令将暂存区内容写入一个树对象。无需指定-w 选项,如果某个树对象不存在,调用write-tree命令时会根据当前暂存区状态自动创建一个新的树对象。
git write-tree
5bf35b145b6281c080d58b6d19a5113a47f782ed
git cat-file -p 5bf35b145b6281c080d58b6d19a5113a47f782ed
100644 blob 83baae61804e65cc73a7201a7252750c76066a30 test
git cat-file -t 5bf35b145b6281c080d58b6d19a5113a47f782ed
tree
Git树对象是在commit的过程中生成的,其生成会根据.git目录下的index文件的内容来创建。git add的操作就是将文件的信息保存到index文件中,在commit时,根据index的内容来生成树对象。
使用git update-index可以为数据对象指定名称和模式,然后使用git write-tree将树对象写入到Git文件系统中。
创建一个新的树对象,包括test.txt文件的第二个版本以及一个新的文件。

echo 'new file' > new.txt
git update-index --cacheinfo 100644 \
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt

git update-index --add new.txt
暂存区现在包含test.txt文件的新版本和一个新文件new.txt,使用当前暂存区生成新的树对象。
git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341
git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341

100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt

新的树对象包含两条文件记录,同时test.txt的SHA-1值是第二版test.txt。 将第一个树对象加入第二个树对象,使其成为新的树对象的一个子目录。 通过调用read-tree命令可以把树对象读入暂存区。通过对 read-tree指定--prefix选项将一个已有的树对象作为子树读入暂存区。

git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579
git write-tree

3c4e9cd789d88d8d89c1073707c3585e41b0e614
git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614

040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt

如果基于新的树对象创建一个工作目录,工作目录的根目录包含两个文件以及一个名为bak的子目录,bak子目录包含test.txt文件的第一个版本。
Git工程开发实践(二)——Git内部实现机制_第2张图片
树对象解决了文件名的问题,而且由于分阶段提交树对象,树对象可以看做是开发阶段源代码目录树的一次次快照,因此可以用树对象作为源代码版本管理。但需要记住每个树对象的hash值,才能找到各阶段的源代码文件目录树。在源代码版本控制中,还需要知道谁提交了代码、什么时候提交的、提交的说明信息等,提交对象就是为了解决上述问题的。

4、Git提交对象

4.1Git提交对象简介

提交对象指向一个树对象,并且带有相关的描述信息,标记项目某一个特定时间点的状态。提交对象包含一些关于时间点的元数据,如时间戳、最近一次提交的作者、指向上次提交的指针等等。
提交对象查看如下:
git show / git log + -s + --pretty=raw +对象名

4.2Git提交对象的生成

提交对象是用来保存提交的作者、时间、说明这些信息的,可以使用git commit-tree来将提交对象写入到Git文件系统中。
echo 'first commit' | git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
162f9174ac6bb4c5d41bfc00fcb5147e2d62b839
commit-tree除了要指定提交的树对象,也要提供提交说明,但提交的作者和时间则根据环境变量自动生成,并不需要指定。由于提交的作者和时间不同,提交对象的SHA-1哈希值也不相同。
提交对象的查看可以使用git cat-file。
git cat-file -p 162f9174ac6bb4c5d41bfc00fcb5147e2d62b839

tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author scorpio <[email protected]> 1536497938 +0800
committer scorpio <[email protected]> 1536497938 +0800

first commit

非首次提交需要指定使用-p指定父提交对象,使代码版本才能成为一条时间线。
echo 'second commit' | git commit-tree 0155eb -p 162f9174ac6bb4c5d41bfc00fcb5147e2d62b839
f6bbc9d4e8de1b35ad66c2115aa8519587c26100
git cat-file查看一下新的提交对象,看到相比于第一次提交,多了parent部分。
第三次提交:
echo 'third commit' | git commit-tree 3c4e9c -p f6bbc9d4e8de1b35ad66c2115aa8519587c26100
第三次提交查看:
git log --stat 26a72965aa9c1bdab9fe5972012bd903f501f006 --pretty=oneline

26a72965aa9c1bdab9fe5972012bd903f501f006 third commit
 bak/test.txt | 1 +
 1 file changed, 1 insertion(+)
f6bbc9d4e8de1b35ad66c2115aa8519587c26100 second commit
 new.txt  | 1 +
 test.txt | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)
162f9174ac6bb4c5d41bfc00fcb5147e2d62b839 first commit
 test.txt | 1 +
 1 file changed, 1 insertion(+)

最终提交对象的结构图:
Git工程开发实践(二)——Git内部实现机制_第3张图片
合并的提交(merge commits)可能会有不只一个父对象。如果一个提交对象没有父对象,称为根提交(root commit),代表项目最初的一个版本(revision)。每个项目必须有至少有一个根提交(root commit)。

4.3Git提交对象的SHA-1哈希值计算

提交对象的内容格式如下:

commit tree 
parent 
[parent  if several parents from merges]
author    
committer    

第一次提交的提交对象的内容如下:
git cat-file -p 162f9174ac6bb4c5d41bfc00fcb5147e2d62b839

tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author scorpio <[email protected]> 1536497938 +0800
committer scorpio <[email protected]> 1536497938 +0800

first commit

使用openssl计算SHA-1

echo -n "commit 165\0
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author scorpio <[email protected]> 1536497938 +0800
committer scorpio <[email protected]> 1536497938 +0800

first commit" | openssl sha1

5、Git对象存储

Git中的数据对象解决了数据存储的问题,树对象解决了文件名存储问题,提交对象解决了提交信息的存储问题。
Git对象(数据对象、树对象和提交对象)都存储在.git/objects目录下。
Git工程开发实践(二)——Git内部实现机制_第4张图片
Git对象的40位SHA-1哈希值分为两部分:前两位作为目录名称,后38位作为对象文件名。
Git对象的存储路径规则为:.git/objects/hash[0, 1]/hash[2, 40]
Git对象存储的算法步骤:
A、计算content长度,构造header;
B、将header添加到content前面,构造Git对象;
C、使用sha1算法计算Git对象的40位hash码;
D、使用zlib的deflate算法压缩Git对象;
E、将压缩后的Git对象存储到.git/objects/hash[0, 2]/hash[2, 40]路径下;
使用Nodejs来实现git hash-object -w的功能,即计算Git对象的hash值并存储到Git文件系统中:

const fs = require('fs')
const crypto = require('crypto')
const zlib = require('zlib')
function gitHashObject(content, type) {
  // 构造header
  const header = `${type} ${Buffer.from(content).length}\0`
  // 构造Git对象
  const store = Buffer.concat([Buffer.from(header), Buffer.from(content)])
  // 计算hash
  const sha1 = crypto.createHash('sha1')
  sha1.update(store)
  const hash = sha1.digest('hex')
  // 压缩Git对象
  const zlib_store = zlib.deflateSync(store)
  // 存储Git对象
  fs.mkdirSync(`.git/objects/${hash.substring(0, 2)}`)
  fs.writeFileSync(`.git/objects/${hash.substring(0, 2)}/${hash.substring(2, 40)}`, zlib_store)
  console.log(hash)
}
// 调用入口
gitHashObject(process.argv[2], process.argv[3])

测试:
node index.js 'hello, world' blob
8c01d89ae06311834ee4b1fab2f0414d35f01102
git cat-file -p 8c01d89ae06311834ee4b1fab2f0414d35f01102
hello, world

三、Git引用

1、Git引用简介

Git操作中经常需要浏览完整的提交历史,但为了能遍历提交历史从而找到所有相关对象,必须记住最后一个提交对象的SHA1哈希值。因此,需要一个文件来保存SHA-1值,并给文件起一个简单名字,然后用名字来替代原始的 SHA-1值。可以在.git/refs目录下找到含有SHA-1值的文件。
find .git/refs

.git/refs
.git/refs/heads
.git/refs/tags

如果需要创建一个新引用来帮助记录最新提交所在的位置,从技术上只需将最新提交对象的SHA1哈希值写入引用文件内:

 echo "524fd8729bbee740392739d22f64784ec81a9804" > .git/refs/heads/test

然后就可以在Git命令中使用刚创建的新引用来代替SHA-1值。
git log --pretty=oneline test
通常,不提倡直接编辑引用文件。 如果想更新某个引用,Git提供了一个更加安全的命令update-ref来编辑引用。
Git分支的本质上是一个指向某一系列提交之首的指针或引用。 若想在某个提交对象上创建一个分支,可以进行如下操作:
git update-ref refs/heads/newbranchname commit_id

2、HEAD引用

当执行git branch (branchname)时,Git通过HEAD文件获取最新提交对象的SHA-值。HEAD文件是一个符号引用(symbolic reference),不像普通引用包含一个SHA-1值,而是一个指向其它引用的指针,指向当前所在的分支。 可以查看HEAD文件的内容:
cat .git/HEAD
ref: refs/heads/master
如果执行git checkout test,Git会对HEAD文件进行更新。
cat .git/HEAD
ref: refs/heads/test
当执行git commit时,会创建一个提交对象,并用HEAD文件中引用所指向的SHA-1值设置其父提交字段。

3、标签引用

标签对象(tag object)类似于一个提交对象,包含一个标签创建者信息、一个日期、一段注释信息以及一个指针。主要的区别在于,标签对象通常指向一个提交对象,而不是一个树对象。 标签对象永远指向同一个提交对象,并给指向的提交对象加上一个更友好的名字。

4、远程引用

如果添加了一个远程版本库并对其执行过推送操作,Git会记录下最近一次推送操作时每一个分支所对应的值,并保存在refs/remotes目录下。可以添加一个叫做origin的远程版本库,然后把master分支推送到远程仓库。
如果查看refs/remotes/origin/master文件,可以发现origin远程版本库的 master分支所对应的SHA-1值就是最近一次与服务器通信时本地master分支所对应的SHA-1值。
远程引用和分支(位于refs/heads目录下的引用)之间最主要的区别在于,远程引用是只读的。虽然可以git checkout 到某个远程引用,但Git并不会将 HEAD引用指向该远程引用。因此,永远不能通过commit命令来更新远程引用。 Git将远程引用作为记录远程服务器上各分支最后已知位置状态的书签来管理。

四、Git包文件

Git最初向磁盘中存储对象时所使用的格式被称为“松散(loose)”对象格式。 但Git会时不时地将多个松散对象打包成一个称为“包文件(packfile)”的二进制文件,以节省空间和提高效率。当版本库中有太多的松散对象,或者手动执行git gc命令,或者向远程服务器执行推送时,Git都会对对象打包。 要看到打包过程,可以手动执行git gc命令让Git对对象进行打包。
git gc

Counting objects: 47126, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (16945/16945), done.
Writing objects: 100% (47126/47126), done.
Total 47126 (delta 29923), reused 46986 (delta 29783)