likes
comments
collection
share

Git原理浅析

作者站长头像
站长
· 阅读数 9

本文适合有一定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)为了加深大家对本章节的理解,我们将进行第四步操作:新增内容,提交第二次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,分别对应第一次和第二次commit,其中右边hashId为0d43f的commit为我们第二次提交的commit。在第二次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”,并提交第二次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。同理,第二次我们将分支切换到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 .
# 提交第二次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)