本文适合有一定git运用经验的读者阅览。

1. Git中心概念

咱们的项目一般由文件夹和文件组成,在git的术语中,文件夹称为 “tree” ,文件称为 “blob” ,顶层文件夹称为 “top-level tree” 。下方的目录结构是个比方:

. (top-level tree)
├── foo.txt (blob,内容为“你好国际”)
└── test (tree)
   └── bar.txt (blob,内容为“你好git”)

上述目录结构能够笼统为一棵树,如下图所示:

Git原理浅析

整棵树称为 “snapshot”“commit” 。当咱们在git系统中提交了多个 commit 后,这些衔接的 commit 构成的有向非循环图称为 “history” ,如下所示:其间每个“o”表明的都是一个commit,“<–”表明的是当时 commit 指向它的父亲commit。

o <-- o <-- o <-- o
       ^
       \
        --- o <-- o

在Git中,tree、blob、commit都被称为git的 “object” ,object类型能够用代码表明如下:

type object = blob | tree | commit

Git中还有一个很重要的概念叫“分支”,术语叫 “reference” ,它其实便是一个指向commit的指针。

最终,咱们用下图来总结一下本小节的内容,了解了其间的一切概念,咱们就能够开端深化学习git底层原理了。

Git原理浅析

2. Git中心原理

在本节,咱们将经过实践来深化研究git在执行git addgit commit 两个指令时,底层发生了什么。

在此之前,咱们先来认识一下git cat-file指令,它是git的一个底层指令,就像git object的“瑞士军刀”,能帮助咱们调查git object。趁便回忆一下,git object 指的是blob 、tree、commit三者之一。下面是这个指令的详细用法:

# 获取hashId指向的object内容
git cat-file -p <hashId>
# 获取hashId指向的object类型
git cat-file -t <hashId>

接下来就跟着本文一起来探究git的中心吧!

本文主机的相关配置:Ubuntu 18.04.6 LTS,git version 2.17.1

1)第一步,咱们在合适的位置新建一个文件夹叫“git-internals”,并初始化git库房。

$ mkdir git-internals
$ cd git-internals
$ git init

检查git最初始的目录结构:

.
└── .git
   ├── HEAD
   ├── branches
   ├── config
   ├── description
   ├── hooks
   ├── info
   │ └── exclude
   ├── objects (这个文件夹是本节的重视要点!!!)
   │ ├── info
   │ └── pack
   └── refs
     ├── heads
     └── tags

简略了解一下这些文件的功能:

  • HEAD 文件:指向当时地点分支。
  • config文件:包含了一些配置。
  • description文件:只要在GitWeb项目中才会用到,所以不用重视这个文件。
  • hooks文件夹:包含了一些钩子脚本
  • info文件夹:包含了.gitignore 文件中的信息。
  • objects文件夹:存放object的数据库,存放整个项目的一切数据。
  • refs文件夹:存放了指向objects的指针(如branches,tags,remotes等)。

2)第二步,咱们增加一个foo.txt文件,并输入一些内容,然后执行git add指令。

bash指令如下:

$ echo '你好国际' > foo.txt
$ git add .

检查一下当时.git目录的结构:

.git/
├── HEAD
├── branches
├── config
├── description
├── hooks
├── index# 这儿多了一个文件)
├── info
│ └── exclude
├── objects
│ ├── 10# 这儿多了一个文件夹和一个文件)
│ │ └── a2c687b54721fe534e16830b3859efa56eeae0
│ ├── info
│ └── pack
└── refs
   ├── heads
   └── tags

能够看到,在objects文件夹下多了一个10文件夹和一个a2c687b54721fe534e16830b3859efa56eeae0文件,把两者当作一个整体便是一个git object,而且咱们将它们的姓名拼接起来就能得到一个40位的hashId,这个hashId是这个object的唯一标志符,git经过这个hashId来查找这个object。

别的a2c687b54721fe534e16830b3859efa56eeae0文件的内容是经过紧缩的,不能直接招供阅览,不过咱们能够经过git cat-file 指令来检查这个文件的内容和类型,咱们能够测验一下:

$ git cat-file -p 10a2c687b54721fe534e16830b3859efa56eeae0
你好国际
$ git cat-file -t 10a2c687b54721fe534e16830b3859efa56eeae0
blob

能够看到,这个文件的内容便是foo.txt 中的内容(“你好国际”),且类型为blob。因此,当咱们执行git add指令时,git就会将相应文件的内容保存至.git/objects/文件夹下的object中。

别的咱们发现,.git 目录下还多了一个index文件,它其实是保存git暂存区数据的文件(假如对工作区、暂存区等概念模糊的,能够参阅Git-基础)。咱们经过git ls-files --stage指令检查index文件中的内容:

$ git ls-files --stage
100644 10a2c687b54721fe534e16830b3859efa56eeae0 0    foo.txt

能够看到,index文件中保存了10a2c687b54721fe534e16830b3859efa56eeae0指针,这个指针指向的便是上面说到的blob object(内容为“你好国际”)。

git add指令的底层运行流程能够总结如下:

  • 将待保存文件的内容紧缩;
  • 将紧缩后的内容保存至.git/objects文件夹下的object中,
  • 给这个object生成一个hashId,并将这个hashId保存至.git/index文件中。

3)第三步,提交第一个commit:

$ git commit -m"first commit"

再次检查.git目录,咱们发现objects目录下又多了两个object:

.git/
├── ...
├── objects
│ ├── 10
│ │ └── a2c687b54721fe534e16830b3859efa56eeae0
│ ├── 8c  (# 新增的)
│ │ └── f2fbfcc5e32df07730df4cb7473811c49439ec
│ ├── c5  (# 新增的)
│ │ └── 083f2f94bdf27b66469f5cc206e7f0f4d8486f
│ ├── info
│ └── pack
└── ...

相同的,咱们用git cat-file指令来调查这两个object。

$ git cat-file -p c5083f2f94bdf27b66469f5cc206e7f0f4d8486f
100644 blob 10a2c687b54721fe534e16830b3859efa56eeae0   foo.txt
$ git cat-file -t c5083f2f94bdf27b66469f5cc206e7f0f4d8486f
tree
​
$ git cat-file -p 8cf2fbfcc5e32df07730df4cb7473811c49439ec
tree c5083f2f94bdf27b66469f5cc206e7f0f4d8486f
author Steve <84xxxx8@qq.com> 1654493737 +0800
committer Steve <84xxxx8@qq.com> 1654493737 +0800
​
first commit
$ git cat-file -t 8cf2fbfcc5e32df07730df4cb7473811c49439ec
commit

经过以上信息咱们就能得到下图:

Git原理浅析

如图所示:

  • c5083最初的object是个tree类型,对应一个目录,它的内容包含了其目录下一切文件(或文件夹)的信息。在当时的比方中,由于根目录下只要一个文件,所以只保存了一条数据:
100644 blob 10a2c687b54721fe534e16830b3859efa56eeae0   foo.txt

这条数据对应着foo.txt文件的相关信息(100644这项暂时不用重视,如想了解能够参阅Git-Objects),包含指向foo.txt对应object的hashId。这样git就能经过这个hashId获取到foo.txt的内容。假设该目录下有多个文件或文件夹,那这儿就会多几条数据。最终值得注意的是,由于这个tree对应的是根目录,所以它是一个特别的tree:top-level tree

  • 8cf2f最初的object是个commit类型,它保存了咱们刚刚提交的commit的信息(如author、committer、commit message等数据),一起它还存储着指向top-level tree的指针。

注意:commit object的hashId和作者名、提交者、时间等要素有关,因此假如咱们跟着本文一起操作的话,得到的commit hashId会和本文的不共同,这是正常的现象。不过要注意的是,tree和blob类型的hashId只和其间的内容有关,因此咱们得到的tree和blob的hashId理应与本文的共同。

至此,咱们能够简略总结一下git commit的底层流程:git 会遍历.git/index中暂存的一切文件,构建如上图所示的文件关系索引图,生成相应的commit、tree、blob,并将它们无差别地存储在objects文件夹中。git只需求知道某个commit的hashId,就能构建出整个项目的文件关系索引图,并能完整地读取到相应文件的内容。

4)为了加深咱们对本章节的了解,咱们将进行第四步操作:新增内容,提交第2次commit。

先回忆一下当时咱们的目录结构:

外层目录结构如下,根目录下只要一个foo.txt文件。

.
├── foo.txt (内容为“你好国际”)

.git目录结构如下(目前有三个object):

.git/
├── ...
├── objects
│ ├── 10
│ │ └── a2c687b54721fe534e16830b3859efa56eeae0
│ ├── 8c
│ │ └── f2fbfcc5e32df07730df4cb7473811c49439ec
│ ├── c5
│ │ └── 083f2f94bdf27b66469f5cc206e7f0f4d8486f
│ ├── info
│ └── pack
└── ...

咱们经过执行以下指令,新增了一个test文件夹,并在test文件夹中新增了bar.txt文件,bar.txt中的内容为”你好git”。

mkdir test
cd test/
echo "你好git" > bar.txt
cd ..
git add .
git commit -m"second commit"

此刻的外层目录结构如下:

.
├── foo.txt (内容为“你好国际”)
└── test
   └── bar.txt (内容为“你好git”)

.git 目录结构如下:

.git/
├── ...
├── objects
│ ├── 0d  (commit,指向5a5a7,parent指向8cf2f)
│ │ └── 43f7e163c4047e1fda6ff0c6b4b2d5b2c426eb
│ ├── 10blob,内容为“你好国际”)
│ │ └── a2c687b54721fe534e16830b3859efa56eeae0
│ ├── 5a  (top-level tree,指向10a2c、9f10f)
│ │ └── 5a7bd341515987c0efa2e4dbd838ba2bc6c21b
│ ├── 8c  (commit,指向c5083,无parent)
│ │ └── f2fbfcc5e32df07730df4cb7473811c49439ec
│ ├── 91blob,内容为“你好git”)
│ │ └── 2fe2cd0cd725b29a62d3692985bde214cf15ff
│ ├── 9f  (tree,指向912fe)
│ │ └── 10f94bb6bf86f3b30c55c26e6865ef9e9b1b42
│ ├── c5  (top-level tree,指向10a2c)
│ │ └── 083f2f94bdf27b66469f5cc206e7f0f4d8486f
│ ├── info
│ └── pack
└── ...

咱们发现,在.git目录下,新增了别离以0d5a919f最初的四个object。咱们能够经过git cat-file指令逐一解析每个object,并调查各个object之间的联络。由于该进程较为繁琐,我直接给出剖析好的object关系依赖图,如下所示:

Git原理浅析

在上图中一共有两个commit,别离对应第一次和第2次commit,其间右边hashId为0d43f的commit为咱们第2次提交的commit。在第2次commit中,咱们创立了一个test文件夹,对应9f10ftree;在test文件夹下创立了bar.txt文件,对应912feblob。

一起咱们能够发现:hashId为5a5a7的top-level tree是新生成的,是由于根目录下的文件/文件夹列表发生了变化。git并没有直接在c5083tree中直接修正,是由于假如咱们需求跳回第一次commit的内容时,直接运用c5083tree就能够了,这样就很便利。

还有值得注意的是:由于foo.txt文件内容未改动(“你好国际”),5a5a7tree直接引证了10a2cobject。这样的策略能够为.git目录节约很大的空间。

最终总结本章的首要内容:

  • objects文件夹是git最重要的数据库,一切的文件内容,及各个版别的内容,都保存在objects文件夹中。
  • objects文件夹中首要保存三类object:commit、tree、blob,它们都由一个文件夹和文件组成,文件夹和文件的姓名拼接成40位的hashId,这个hashId便是这个object的唯一标识符,git经过这个hashId来查找某个object。别的,这些文件都是经过紧缩的,不能直接招供阅览,需求经过git cat-file指令检查。
  • 每一个commit都对应一个top-level tree,以top-level tree为根节点,能够构造出当时版别的目录结构,经过访问blob类的object,就能读取到对应文件的详细内容。别的,多个版别之间的文件假如是完全相同的,git只会生成一个blob对象,多个版别引证同一个blob对象,这能够大大节约磁盘空间。

3. Reference

git是经过hashId来查找某个commit的,这关于计算机来说是件十分简略的事,但关于人来说,要记住这么长的hashId,真不是件容易的事。假如能给这些hashId取一些“简略的姓名”(比方master、dev、HEAD等),那就十分容易回忆了。在git术语中,这些“简略的姓名”被叫做 “reference” 。这些reference首要保存在.git/refs文件夹中。下面将介绍一些常见的reference。

3.1 Branch

下面是git最初的目录结构,分支信息保存在.git/refs/heads目录下:

.
└── .git
   ├── HEAD
   ├── branches
   ├── config
   ├── description
   ├── hooks
   ├── info
   │ └── exclude
   ├── objects
   │ ├── info
   │ └── pack
   └── refs    (# 这个文件夹是本章的重视要点!!!)
     ├── heads
     └── tags

咱们新建一个新的文件夹“git-branch”,增加foo.txt,并增加内容为“你好国际”。然后提交一个新的commit。

$ mkdir git-branch
$ cd git-branch/
$ git init
$ echo "你好国际" > foo.txt
$ git add .
$ git commit -m"fist commit"

检查.git目录:

.git/
├── ...
├── objects
│ ├── 10
│ │ └── a2c687b54721fe534e16830b3859efa56eeae0
│ ├── 86
│ │ └── 8ace1300274e49cca122af2b5c6a87b8007feb
│ ├── c5
│ │ └── 083f2f94bdf27b66469f5cc206e7f0f4d8486f
│ ├── info
│ └── pack
└── refs
   ├── heads
   │ └── master  (# 新增了一个文件)
   └── tags

objects文件夹中新增的三个git object在第二节中已详细讲解,在此就不再过多赘述。此外,咱们注意到在refs/heads文件夹下面多了一个master文件,这个文件未被紧缩,能够直接检查:

$ cat .git/refs/heads/master
868ace1300274e49cca122af2b5c6a87b8007feb
​
$ git cat-file -p 868ace1300274e49cca122af2b5c6a87b8007feb
tree c5083f2f94bdf27b66469f5cc206e7f0f4d8486f
author Steve <841532108@qq.com> 1654506109 +0800
committer Steve <841532108@qq.com> 1654506109 +0800
​
fist commit

咱们发现refs/heads/master文件的内容为一个hashId,咱们经过git cat-file检查这个hashId对应的object,发现这个object便是咱们第一次提交的commit。

由此得知:git管理的项目原本没有master分支,当咱们提交第一个commit后,git会自动给咱们创立master分支,并将它指向第一个commit。

咱们不妨再多提交几个commit,并切换一下分支试试:

$ echo "hello world" > foo.txt
$ git add .
$ git commit -m"second commit"$ git checkout -b test
$ echo "hello git" > bar.txt
$ git add .
$ git commit -m"third commit"

咱们修正foo.txt文件中的内容为“hello world”,并提交第2次commit;然后将分支切换到test分支,创立新文件bar.txt,内容为“hello git”,提交第三次commit。

咱们来看看此刻的.git目录结构:

.git/
├── ...
├── objects
│ ├── 10blob,内容为“你好国际”)
│ │ └── a2c687b54721fe534e16830b3859efa56eeae0
│ ├── 3b  (blob,内容为“hello world”)
│ │ └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
│ ├── 49  (top-level tree,指向8d0e4、3b18e)
│ │ └── a443a4252c15bdeb5c11f419553b60161d3df6
│ ├── 4f  (commit,指向52f13,parent为868ac,second commit)
│ │ └── 082af95f62ee85f444011bf1f84f8a34f22ab7
│ ├── 52  (top-level tree,指向3b18e)
│ │ └── f13e2940cb1c6dfc116781fb7912cef05e1670
│ ├── 81commit,指向49a44,parent为4f082,third commit)
│ │ └── 91a4105280cf70c29fddd5109e8d4e83df8014
│ ├── 86commit,指向c5083,无parent,fist commit)
│ │ └── 8ace1300274e49cca122af2b5c6a87b8007feb
│ ├── 8d  (blob,内容为“hello git”)
│ │ └── 0e41234f24b6da002d962a26c2495ea16a425f
│ ├── c5  (top-level tree,指向10a2c)
│ │ └── 083f2f94bdf27b66469f5cc206e7f0f4d8486f
│ ├── info
│ └── pack
└── refs
   ├── heads
   │ ├── master
   │ └── test
   └── tags

objects文件夹中的内容咱们就不再剖析,首要调查.git/refs/heads文件夹中的内容。heads文件夹下有master和test两个文件,别离对应master和test分支。这两个文件保存的内容为一个hashId,这个hashId指向了某个commit。

读者能够经过调查下图并结合上面的.git目录结构来加深了解,一起着也能帮助回忆第二章的内容:

Git原理浅析

最终总结一下:git分支其实便是一个指向commit的指针。git将分支的信息保存在.git/refs/heads文件夹中,一个分支对应一个文件,比方master分支对应master分支,dev分支对应dev文件,这些文件的内容能够直接阅览,内容为一个hashId,这个hashId指向了某个commit。

3.2 HEAD

紧接着3.1的比方,咱们检查一下.git/HEAD文件中的内容:

$ cat .git/HEAD
ref: refs/heads/test

可见,HEAD保存了一个文件途径,这个途径指向了test分支,表明咱们当时处在test分支。

HEAD文件保存的内容是当时地点分支的途径,当咱们切换分支的时候,这个HEAD文件也在不断更新,能够看下面的比方:

$ git branch
  master
* test
​
$ cat .git/HEAD
ref: refs/heads/test
​
$ git checkout master
$ cat .git/HEAD
ref: refs/heads/master
​
$ git checkout -b dev
$ cat .git/HEAD
ref: refs/heads/dev

如上,咱们一共切换了两次分支。初始状况时,咱们处于test分支,HEAD文件中的途径地址指向了test分支地点的途径refs/heads/test。第一次咱们将分支切换到master,HEAD文件中的途径也当即更新为了master分支地点途径refs/heads/master。同理,第2次咱们将分支切换到dev,HEAD文件发生了对应的更新。

最终总结一下:HEAD文件保存着当时地点分支的途径,git经过这个途径访问到对应分支文件,然后经过这个分支文件保存的hashId就能获取到对应commit,这样git就能构建出当时commit对应的目录结构。

3.3 Remote

remote其实和branch相似,也是一个指向commit的指针。唯一的差异便是remote是“只读”的,后面咱们会举例解说这一点。

咱们重建一个测验库房git-remote,并初始化git库房。

$ mkdir git-remote
$ cd git-remote/
$ git init

此刻的.git目录结构是最初的状况:

.git/
├── HEAD
├── branches
├── config
├── description
├── hooks
├── info
│ └── exclude
├── objects
│ ├── info
│ └── pack
└── refs    (# 本节要点重视文件夹)
   ├── heads
   └── tags

本文在gitee上建立了一个空白库房,地址为git@gitee.com:xiaofei1996/git-remote.git,读者如需按步操作,需求自行建立一个长途库房。下面咱们需求衔接长途库房与本地库房,并提交第一个commit。

$ git remote add origin git@gitee.com:xiaofei1996/git-remote.git
$ echo '你好国际' > foo.txt
$ git add .
$ git commit -m"first commit"
$ git push origin master

温馨提示:假如对git remote相关操作不熟悉的,能够参阅长途库房的运用

咱们来看一下此刻的.git 目录结构:

.git/
├── ...
├── objects
│ ├── 10
│ │ └── a2c687b54721fe534e16830b3859efa56eeae0
│ ├── 41
│ │ └── f3c0757f605528b0544830356730823b6678ef
│ ├── c5
│ │ └── 083f2f94bdf27b66469f5cc206e7f0f4d8486f
│ ├── info
│ └── pack
└── refs
   ├── heads
   │ └── master
   ├── remotes (# 新增了一个文件夹)
   │ └── origin
   │   └── master
   └── tags

咱们能够在.git/refs文件夹下看到本地分支和长途分支,其间heads下的分支文件表明本地分支,remotes/origin文件下的分支文件表明名为origin的长途库房的分支。两个文件夹下都有一个master分支,这两个分支目前应该指向同一个commit,也便是第一个commit,咱们经过如下操作证明这一点:

$ cat .git/refs/remotes/origin/master
41f3c0757f605528b0544830356730823b6678ef
​
$ cat .git/refs/heads/master
41f3c0757f605528b0544830356730823b6678ef

接下来,咱们要开端移动本地master分支,但长途master保持不变。

$ echo 'hello world' > foo.txt
$ git add .
# 提交第2次commit
$ git commit -m"second commit"
# 检查本地master分支文件的内容
$ cat .git/refs/heads/master
31f3d65ee0021edf8fe9a0b90946b33bffc377d3
$ git cat-file -p 31f3d65ee0021edf8fe9a0b90946b33bffc377d3
tree 52f13e2940cb1c6dfc116781fb7912cef05e1670
parent 41f3c0757f605528b0544830356730823b6678ef
author Steve <841532108@qq.com> 1654526267 +0800
committer Steve <841532108@qq.com> 1654526267 +0800
​
second commit
​
# 检查长途master分支文件的内容
$ cat .git/refs/remotes/origin/master
41f3c0757f605528b0544830356730823b6678ef
$ git cat-file -p 41f3c0757f605528b0544830356730823b6678ef
tree c5083f2f94bdf27b66469f5cc206e7f0f4d8486f
author Steve <841532108@qq.com> 1654521323 +0800
committer Steve <841532108@qq.com> 1654521323 +0800
​
first commit

咱们在本地提交了第二个commit,但未将这个commit推送至长途库房。能够看到,咱们的本地master分支现已指向第二个commit,但长途master分支仍指向第一个commit。到这儿咱们应该就能了解:remote其实和branch相似,也是一个指向commit的指针。

接下来咱们将解说本节一开端说到的“remote是‘只读’的”这句话。咱们测验将分支切换到长途master分支:

$ git checkout origin/master
Note: checking out 'origin/master'.
​
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.
​
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:
​
 git checkout -b <new-branch-name>
​
HEAD is now at 41f3c07 first commit
​
$ git branch
* (HEAD detached at origin/master)
  master

咱们测验将分支切换到origin/master分支上,git给了一段提示,这段提示信息其实就解说了“remote是‘只读’的”。下面我将经过比方解说这一点。

当咱们把分支切换到长途master分支上时,此刻HEAD是处于“脱离”状况的,咱们经过下面的两个比方来说明这个“脱离”状况:

例1:

$ git checkout master
$ cat .git/HEAD
ref: refs/heads/master
​
$ git checkout origin/master
$ cat .git/HEAD
41f3c0757f605528b0544830356730823b6678ef

当咱们切换到origin/master分支时,.git/HEAD文件中保存的内容为一个hashId,而不是一个文件途径。

例2:

$ echo 'hello world' > foo.txt
$ git add .
$ git commit -m"third commit"
$ git log --pretty=oneline
a737d18c9f1d12a85c8479e03aad8ddb71ee1907 (HEAD) third commit
41f3c0757f605528b0544830356730823b6678ef (origin/master) first commit
$ git checkout master
$ git checkout origin/master
$ git log --pretty=oneline
41f3c0757f605528b0544830356730823b6678ef (HEAD, origin/master) first commit

咱们在origin/master分支上提交了“第三次commit”,然后切换到master分支,再切换回origin/master分支,发现第三次commit丢失了。

上述进程解说了什么是“脱离”态的HEAD:

  • 当咱们处于一般分支上时,HEAD文件的内容为当时分支的详细途径(如:ref: refs/heads/master),而当处于长途分支上时,HEAD文件内容则为hashId(如:41f3c0757f605528b0544830356730823b6678ef),git经过这点区别咱们是否处在长途分支上。
  • 任安在长途分支提交的commit,当咱们切换成其它分支后,这些commit都会被丢掉。

最终总结一下,remote其实和branch相似,也是一个指向commit的指针。唯一的差异便是remote是“只读”的,“只读”表现再当咱们处在长途分支时,HEAD分支时处于“脱离”状况的。“脱离”状况已在上文解说。

4. 参阅资料

本文对git底层原理进行了粗浅的剖析,有兴趣的读者能够继续深化阅览下面两篇文章:

missing.csail.mit.edu/2020/versio… (首要讲了git的数据模型)

git-scm.com/book/en/v2 (Book: Pro Git 2nd Edition (2014) – 第10部分-Git Internals)