关于git:git原理学习记录从基本指令到背后原理实现一个简单的

25次阅读

共计 10850 个字符,预计需要花费 28 分钟才能阅读完成。

一开始我还放心 git 的原理会不会很难懂,但在浏览了官网文档后我发现其实并不难懂,仿佛能够入手实现一个简略的 git,于是就有了上面这篇学习记录。

本文的叙述思路参照了官网文档 Book 的原理介绍局部,在一些节点上探讨代码实现,官网文档链接。

看完本文你能:1. 理解 git 的设计思维。2. 播种一点高兴?

编程语言抉择了 go,因为刚学不太熟悉想多应用一下。

这是我的仓库地址,但如果你和我一样是初学,间接看代码可能不能疾速上手,举荐顺着文章看。

迷你 git 实现 – 链接

如果文章看得吃力能够跟着官网文档的原理局部操作一次再回头看,可能更易懂?

  1. init

========

在学习 git 原理之前,咱们先忘掉平时用的 commit,branch,tag 这些炫酷的 git 指令,前面咱们会摸清楚它们的实质的。

要晓得,git 是 Linus 在写 Linux 的时候顺便写进去的,用于对 Linux 进行版本治理,所以,记录文件我的项目在不同版本的变更信息是 git 最外围的性能。

大牛们在设计软件的时候总是会做相应的形象,想要了解他们的设计思路,咱们就得在他们的形象下进行思考。尽管说的有点玄乎,然而这些形象最终都会落实到代码上的,所以不用放心,很好了解的。

首先,咱们要奠定一个 ojbect 的概念,这是 git 最底层的形象,你能够把 git 了解成一个 object 数据库。

废话不多说,跟着指令操作,你会对 git 有一个全新的意识。首先咱们在任意目录下创立一个 git 仓库:

我的操作环境是 win10 + git bash

$ git init git-test
Initialized empty Git repository in C:/git-test/.git/ 

能够看到 git 为咱们创立了一个空的 git 仓库,外面有一个 .git 目录,目录构造如下:

$ ls
config  description  HEAD  hooks/  info/  objects/  refs/ 

.git 目录下咱们先重点关注 .git/objects这个目录,咱们一开始说 git 是一个 object 数据库,这个目录就是 git 寄存 object 的中央。

进入 .git/objects 目录后咱们能看到 infopack两个目录,不过这和外围性能无关,咱们只须要晓得当初 .git/objects 目录下除了两个空目录其余啥都没有就行了。

到这里咱们停停,先把这部分实现了吧,逻辑很简略,咱们只须要编写一个入口函数,解析命令行的参数,在失去 init 指令后在指定目录下创立相应的目录与文件即可。

这里是我的实现:init

为了易读临时没有对创立文件 / 目录进行错误处理。

我给它取了个土一点的名字,叫 jun,呃,其实管它叫啥都能够(⊙ˍ⊙)

2.object

接下来咱们进入 git 仓库目录并增加一个文件:

$ echo "version1" > file.txt 

而后咱们把对这个文件的记录增加进 git 零碎。要留神的是 ,咱们暂不应用add 指令增加,只管咱们平时很可能这么做,但这是一篇揭示原理的文章,这里咱们要引入一条平时大家可能没有听到过的 git 指令git hash-object

$ git hash-object -w file.txt
5bdcfc19f119febc749eef9a9551bc335cb965e2 

指令执行后返回了一个哈希值,实际上这条指令曾经把对 file.txt 的内容以一个 object 的模式增加进 object 数据库中了,而这个哈希值就对应着这个 object。

为了验证 git 把这个 object 写入了数据库(以文件的模式保留下来),咱们查看一下 .git/objects 目录:

$ find .git/objects/ -type f    #-type 用于制订类型,f 示意文件
.git/objects/5b/dcfc19f119febc749eef9a9551bc335cb965e2 

发现多了一个文件夹 5b,该文件夹下有一个名为dcfc19f119febc749eef9a9551bc335cb965e2 的文件,也就是说 git 把该 object 哈希值的前 2 个字符作为目录名,后 38 个字符作为文件名,寄存到了 object 数据库中。

对于 git hash-object 指令的官网介绍,这条指令用于计算一个 ojbect 的 ID 值。-w 是可选参数,示意把 object 写入到 object 数据库中;还有一个参数是 -t,用于指定 object 的类型,如果不指定类型,默认是 blob 类型。

当初你可能好奇 object 外面保留了什么信息,咱们应用 git cat-file 指令去查看一下:

$ git cat-file -p 5bdc  # -p:查看 object 的内容,咱们能够只给出哈希值的前缀
version1
 $ git cat-file -t 5bdc  # -t:查看 object 的类型
blob 

有了下面的铺垫之后,接下来咱们就揭开 git 实现版本控制的机密!

咱们扭转 file.txt 的内容,并从新写入 object 数据库中:

$ echo "version2" > file.txt
$ git hash-object -w file.txt
df7af2c382e49245443687973ceb711b2b74cb4a 

控制台返回了一个新的哈希值,咱们再查看一下 object 数据库:

$ find .git/objects -type f
.git/objects/5b/dcfc19f119febc749eef9a9551bc335cb965e2
.git/objects/df/7af2c382e49245443687973ceb711b2b74cb4a 

(゚Д゚)发现多了一个 object!咱们查看一下新 object 的内容:

$ git cat-file -p df7a
version2
 $ git cat-file -t df7a
blob 

看到这里,你可能对 git 是一个 object 数据库的概念有了进一步的意识:git 把文件每个版本的内容都保留到了一个 object 外面。

如果你想把 file.txt 复原到第一个版本的状态,只须要这样做:

$ git cat-file -p 5bdc > file.txt 

而后查看 file.txt 的内容:

$ cat file.txt
version1 

至此,一个能记录文件版本,并能把文件复原到任何版本状态的版本控制系统实现(ง •_•)ง

是不是感觉还行,不是那么难?你能够把 git 了解成一个 key – value 数据库,一个哈希值对应一个 object。

到这里咱们停停,把这部分实现了吧。

我一开始有点好奇,为啥查看 object 不间接用 cat 指令,而是本人编了一条 git cat-file 指令呢?起初想了一下,git 必定不会把文件的内容一成不变保留进 object,应该是做了压缩,所以咱们还要专门的指令去解压读取。

这两条指令咱们参照官网的思路进行实现,先说 git hash-object,一个 object 存储的内容是这样的:

  1. 首先要结构头部信息,头部信息由对象类型,一个空格,数据内容的字节数,一个空字节拼接而成,格局是这样:
blob 9u0000 
  1. 而后把头部信息和原始数据拼接起来,格局是这样:
blob 9u0000version1 
  1. 接着用 zlib 把下面拼接好的信息进行压缩,而后存进 object 文件中。

git cat-file 指令的实现则是相同,先把 object 文件里寄存的数据用 zlib 进行解压,依据空格和空字节对解压后的数据进行划分,而后依据参数 -t 或 -p 返回 object 的内容或者类型。

这里是我的实现:hash-object and cat-file

采纳了简略粗犷的面向过程实现,然而我曾经模摸糊糊感到前面会用很多重用的性能,所以先把单元测试写上,不便前面重构。

  1. tree object

===============

在上一章中,仔细的小伙伴可能会发现,git 会把咱们的文件内容以 blob 类型的 object 进行保留。这些 blob 类型的 object 仿佛只保留了文件的内容,没有保留文件名。

而且当咱们在开发我的项目的时候,不可能只有一个文件,通常状况下咱们是须要对一个我的项目进行版本治理的,一个我的项目会蕴含多个文件和文件夹。

所以最根底的 blob object 曾经满足不了咱们应用了,咱们须要引入一种新的 object,叫 tree object,它不仅能保留文件名,还能将多个文件组织到一起。

然而问题来了,引入概念很容易,然而具体落实到代码上怎么写呢?(T_T),我脑袋里的第一个想法是先在内存里创立一个 tree objct,而后咱们往这个指定的 tree object 外面去增加内容。但这样仿佛很麻烦,每次增加货色都要给出 tree object 的哈希值。而且这样的话 tree object 就是可变的了,一个可变的 object 曾经违反了保留固定版本信息的初衷。

咱们还是看 git 是怎么思考这个问题的吧,git 在创立 tree object 的时候引入了一个叫暂存区概念,这是个不错的主见!你想,咱们的 tree object 是要保留整个我的项目的版本信息的,我的项目有很多个文件,于是咱们把文件都放进缓冲区里,git 依据缓冲区里的内容一次性创立一个 tree object,这样不就能记录版本信息了吗!

咱们先操作一下 git 的缓冲区加深一下了解,首先引入一条新的指令 git update-index,它能够人为地把一个文件退出到一个新的缓冲区中,而且要加上一个 –add 的参数,因为这个文件之前还不存在于缓冲区中。

$ git update-index --add file.txt 

而后咱们察看一下 .git 目录的变动

$ ls
config  description  HEAD  hooks/  index  info/  objects/  refs/
 $ find .git/objects/ -type f
objects/5b/dcfc19f119febc749eef9a9551bc335cb965e2
objects/df/7af2c382e49245443687973ceb711b2b74cb4a 

发现 .git 目录下多了一个名为 index 的文件,这预计就是咱们的缓冲区了。而 objects 目录下的 object 倒没什么变动。

咱们查看一下缓冲区的内容,这里用到一条指令:git ls-files –stage

$ git ls-files --stage
100644 df7af2c382e49245443687973ceb711b2b74cb4a 0       file.txt 

咱们发现缓冲区是这样来存储咱们的增加记录的:一个文件模式的代号,文件内容的 blob object,一个数字和文件的名字。

而后咱们把以后缓冲区的内容以一个 tree object 的模式进行保留。引入一条新的指令:git write-tree

$ git write-tree
907aa76a1e4644e31ae63ad932c99411d0dd9417 

输出指令后,咱们失去了新生成的 tree object 的哈希值,咱们去验证一下它是否存在,并看看它的内容:

$ find .git/objects/ -type f
.git/objects/5b/dcfc19f119febc749eef9a9551bc335cb965e2 #文件内容为 version1 的 blob object
.git/objects/90/7aa76a1e4644e31ae63ad932c99411d0dd9417 #新的 tree object
.git/objects/df/7af2c382e49245443687973ceb711b2b74cb4a #文件内容为 version2 的 blob object
 $ git cat-file -p 907a
100644 blob df7af2c382e49245443687973ceb711b2b74cb4a    file.txt 

预计看到这里,大家对暂存区与 tree object 的关系就有了初步的理解。

当初咱们进一步理解两点:一个内容未被 git 记录的文件会被怎么记录,一个文件夹又会被怎么记录。

上面咱们一步步来,创立一个新的文件,并退出暂存区:

$ echo abc > new.txt
 $ git update-index --add new.txt
 $ git ls-files --stage
100644 df7af2c382e49245443687973ceb711b2b74cb4a 0       file.txt
100644 8baef1b4abc478178b004d62031cf7fe6db6f903 0       new.txt 

查看缓冲区后,咱们发现新文件的记录已追加的形式退出了暂存区,而且也对应了一个哈希值。咱们查看一下哈希值的内容:

$ find .git/objects/ -type f
.git/objects/5b/dcfc19f119febc749eef9a9551bc335cb965e2 #新的 object
.git/objects/8b/aef1b4abc478178b004d62031cf7fe6db6f903 #文件内容为 version1 的 blob object
.git/objects/90/7aa76a1e4644e31ae63ad932c99411d0dd9417 #tree object
.git/objects/df/7af2c382e49245443687973ceb711b2b74cb4a #文件内容为 version2 的 blob object
 $ git cat-file -p 8bae
abc
 $ git cat-file -t 8bae
blob 

咱们发现,在把 new.txt 退出到暂存区时,git 主动给 new.txt 的内容创立了一个 blob object。

咱们再尝试一下创立一个文件夹,并增加到暂存区中:

$ mkdir dir
 $ git update-index --add dir
error: dir: is a directory - add files inside instead
fatal: Unable to process path dir 

后果 git 通知咱们不能增加一个空文件夹,须要在文件夹中增加文件,那么咱们就往文件夹中加一个文件,而后再次增加到暂存区:

$ echo 123 > dir/dirFile.txt
 $ git update-index --add dir/dirFile.txt 

胜利了~ 而后查看暂存区的内容:

$ git ls-files --stage
100644 190a18037c64c43e6b11489df4bf0b9eb6d2c9bf 0       dir/dirFile.txt
100644 df7af2c382e49245443687973ceb711b2b74cb4a 0       file.txt
100644 8baef1b4abc478178b004d62031cf7fe6db6f903 0       new.txt
 $ git cat-file -t 190a
blob 

和之前的演示一样,主动帮咱们为文件内容创立了一个 blob object。

接下来咱们把以后的暂存区保留成为一个 tree object:

$ git write-tree
dee1f9349126a50a52a4fdb01ba6f573fa309e8f
 $ git cat-file -p dee1
040000 tree 374e190215e27511116812dc3d2be4c69c90dbb0    dir
100644 blob df7af2c382e49245443687973ceb711b2b74cb4a    file.txt
100644 blob 8baef1b4abc478178b004d62031cf7fe6db6f903    new.txt 

新的 tree object 保留了暂存区的以后版本信息,值得注意的是,暂存区是以 blob object 的模式记录 dir/dirFile.txt 的,而在保留树对象的过程中,git 为目录 dir 创立了一个树对象,咱们验证一下:

$ git cat-file -p 374e
100644 blob 190a18037c64c43e6b11489df4bf0b9eb6d2c9bf    dirFile.txt
 $ git cat-file -t 374e
tree 

发现这个为 dir 目录而创的树对象保留了 difFile.txt 的信息,是不是感觉似曾类似!这个 tree object 就是对文件目录的模仿呀!

咱们停停!开始入手!

这次咱们须要实现上述的三条指令:

  1. git update-index –add

git update-index 更新暂存区,官网的这条指令是带有很多参数的,咱们只实现 –add,也就是增加文件到暂存区。总体的流程是这样的:如果是第一次增加文件进缓冲区,咱们须要创立一个 index 文件,如果 index 文件曾经存在则间接把暂存区的内容读取进去,留神要有个解压的过程。而后把新的文件信息增加到暂存区中,把暂存区的内容压缩后存入 index 文件。

这里波及到一个序列化和反序列的操作,请容许我偷懒通过 json 进行模仿ψ(._.)>

  1. git ls-files –stage

git ls-files 用来查看暂存区和工作区的文件信息,同样有很多参数,咱们只实现 –stage,查看暂存区的内容(不带参数的 ls-files 指令是列出当前目录包含子目录下的所有文件)。实现流程:从 index 文件中读取暂存区的内容,解压后依照肯定的格局打印到规范输入。

  1. git write-tree

git write-tree 用于把暂存区的内容转换成一个 tree object,依据咱们之前演示的例子,对于文件夹咱们须要递归降落解析 tree object,这应该是本章最难实现的中央了。

代码如下:update-index –add, ls-files –stage, write-tree

感觉能够把 object 形象一下,于是重构了一下和 object 相干的代码:refactor object part

当这部分实现后,咱们曾经领有一个可能对文件夹进行版本治理的零碎了(ง •_•)ง

4.commit object

尽管咱们曾经能够用一个 tree object 来示意整个我的项目的版本信息了,然而仿佛还是有些有余的中央:

tree object 只记录了文件的版本信息,这个版本是谁批改的?是因什么而批改的?它的上一个版本是谁?这些信息没有被保留下来。

这个时候,就该 commit object 出场了!怎么样,从底层一路向上摸索的感觉是不是很爽!?

咱们先用 git 操作一遍,而后再思考如何实现。上面咱们应用 commit-tree 指令来创立一个 commit object,这个 commit object 指向第三章最初生成的 tree object。

$ git commit-tree dee1 -m 'first commit'
893fba19d63b401ae458c1fc140f1a48c23e4873 

因为生成工夫和作者不同,你失去的哈希值会不一样,咱们查看一下这个新生成的 commit object:

$ git cat-file -p 893f
tree dee1f9349126a50a52a4fdb01ba6f573fa309e8f
author liuyj24 <liuyijun2017@email.szu.edu.cn> 1608981484 +0800
committer liuyj24 <liuyijun2017@email.szu.edu.cn> 1608981484 +0800

first commit 

能够看到,这个 commit ojbect 指向一个 tree object,第二第三行是作者和提交者的信息,空一行后是提交信息。

上面咱们批改咱们的我的项目,模仿版本的变更:

$ echo version3 > file.txt
 $ git update-index --add file.txt
 $ git write-tree
ff998d076c02acaf1551e35d76368f10e78af140 

而后咱们创立一个新的提交对象,把它的父对象指向第一个提交对象:

$ git commit-tree ff99 -m 'second commit' -p 893f
b05c65b6fdd7e13a51aaf1abb8ff3e795835bfb0 

咱们再批改咱们的我的项目,而后创立第三个提交对象:

$ echo version4 >file.txt
 $ git update-index --add file.txt
 $ git write-tree
1403e859154aee76360e0082c4b272e5d145e13e
 $ git commit-tree 1403 -m 'third commit' -p b05c
fe2544fb26a26f0412ce32f7418515a66b31b22d 

而后咱们执行 git log 指令查看咱们的提交历史:

$ git log fe25
commit fe2544fb26a26f0412ce32f7418515a66b31b22d
Author: liuyj24 <liuyijun2017@email.szu.edu.cn>
Date:   Sat Dec 26 19:36:31 2020 +0800

    third commit

commit b05c65b6fdd7e13a51aaf1abb8ff3e795835bfb0
Author: liuyj24 <liuyijun2017@email.szu.edu.cn>
Date:   Sat Dec 26 19:34:25 2020 +0800

    second commit

commit 893fba19d63b401ae458c1fc140f1a48c23e4873
Author: liuyj24 <liuyijun2017@email.szu.edu.cn>
Date:   Sat Dec 26 19:18:04 2020 +0800

    first commit 

怎么样?是不是有种恍然大悟的感觉!

上面咱们停停,把这一部分给实现了。

一共是两条指令

  1. commit-tree

创立一个 commit object,让它指向一个 tree object,增加作者信息,提交者信息,提交信息,再减少一个父节点即可(父节点能够不指定)。作者信息和提交者信息咱们临时写死,这个能够通过 git config 指令设置,你能够查看一下.git/config,其实就是一个读写配置文件的操作。

  1. log

依据传入的 commit object 的哈希值向上找它的父节点并打印信息,通过递归能疾速实现。

这里是我的实现:commit-tree, log

  1. references

==============

在后面的四章咱们铺垫了很多 git 的底层指令,从这章开始,咱们将对 git 的罕用性能进行解说,这相对会有一种长驱直入的感觉。

尽管咱们的 commit object 曾经可能很残缺地记录版本信息了,然而还有一个致命的毛病:咱们须要通过一个很长的 SHA1 散列值来定位这个版本,如果在开发的过程中你和共事说:

嘿!能帮我 review 一下 32h52342 这个版本的代码吗?

那他必定会回你:哪。。。哪个版本来着?(+_+)?

所以咱们要得思考给咱们的 commit object 起名字,比方起名叫 master。

咱们实际操作一下 git,给咱们最新的提交对象起名叫 master:

$ git update-ref refs/heads/master fe25 

而后通过新的名字查看提交记录:

$ git log master
commit fe2544fb26a26f0412ce32f7418515a66b31b22d (HEAD -> master)
Author: liuyj24 <liuyijun2017@email.szu.edu.cn>
Date:   Sat Dec 26 19:36:31 2020 +0800

    third commit

commit b05c65b6fdd7e13a51aaf1abb8ff3e795835bfb0
Author: liuyj24 <liuyijun2017@email.szu.edu.cn>
Date:   Sat Dec 26 19:34:25 2020 +0800

    second commit

commit 893fba19d63b401ae458c1fc140f1a48c23e4873
Author: liuyj24 <liuyijun2017@email.szu.edu.cn>
Date:   Sat Dec 26 19:18:04 2020 +0800

    first commit 

好家伙 (→_→),要不咱们给这个性能起个牛逼的名字,就叫 分支 吧!

这个时候你可能会想,平时咱们在 master 分支上进行提交,都是一个 git commit -m 指令就搞定的,当初背地的原理我仿佛也懂:

  1. 首先是通过命令 write-tree 把暂存区的记录写到一个树对象里,失去树对象的 SHA1 值。
  2. 而后通过命令 commit-tree 创立一个新的提交对象。

问题是:commit-tree 指令所用到的的树对象 SHA1 值,-m 提交信息都有了,然而 -p 父提交对象的 SHA1 值咱们怎么取得呢?

这就要提到咱们的 HEAD 援用了!你会发现咱们的 .git 目录中有一个 HEAD 文件,咱们查看一下它的内容:

$ ls
config  description  HEAD  hooks/  index  info/  logs/  objects/  refs/
 $ cat HEAD
ref: refs/heads/master 

所以当咱们进行 commit 操作的时候,git 会到 HEAD 文件中取出以后的援用,也就是以后的提交对象的 SHA1 值作为新提交对象的父对象,这样整个提交历史就能串联起来啦!

看到这里,你是不是对 git branch 创立分支,git checkout 切换分支也有点感觉了呢?!

当初咱们有三个提交对象,咱们尝试在第二个提交对象上创立分支,同样先用底层指令实现,咱们应用 git update-ref 指令对第二个提交创立一个 reference:

$ git update-ref refs/heads/bugfix b05c
 $ git log bugfix
commit b05c65b6fdd7e13a51aaf1abb8ff3e795835bfb0 (bugfix)
Author: liuyj24 <liuyijun2017@email.szu.edu.cn>
Date:   Sat Dec 26 19:34:25 2020 +0800

    second commit

commit 893fba19d63b401ae458c1fc140f1a48c23e4873
Author: liuyj24 <liuyijun2017@email.szu.edu.cn>
Date:   Sat Dec 26 19:18:04 2020 +0800

    first commit 

而后咱们扭转咱们以后所处的分支,也就是批改 .git/HEAD文件的值,咱们用到 git symbolic-ref 指令:

git symbolic-ref HEAD refs/heads/bugfix 

咱们再次通过 log 指令查看日志,如果不加参数的话,默认就是查看以后分支:

$ git log
commit b05c65b6fdd7e13a51aaf1abb8ff3e795835bfb0 (HEAD -> bugfix)

正文完
 0