Git 内部实现原理剖析

2020-12-10  本文已影响0人  Whyn

[TOC]

Git 内部实现原理剖析

前言

Git 可以说是当前最主流的版本控制系统,无论项目多大,Git 都能很好进行追踪,保证源码历史记录,方便回溯与回退。

Git 的上手其实很简单,比如:

上述一整套操作足以满足个人小项目的版本控制,但这不是使用 Git 的最佳实践。

而如果想进一步使用 Git,此时复杂度就会骤升,原因就在于 Git 对文件的追踪有自己的一套完整且自洽的逻辑与概念,不熟悉这些概念的话,就无法很好理解 Git 的相关操作命令,自然无法更好的使用 Git。

本篇博文会对 Git 的相关重要概念进行介绍,并对 Git 的内部实现原理简单进行剖析,让读者知其然并知其所以然。

三大分区

在 Git 中,对文件进行操作,会涉及到如下三个区域:

工作区、暂存区和版本库的工作模型如下图所示:

工作模型

git switchgit restore是 Git 2.23.0 版本新增加的命令,主要是用于替代git checkout命令的,因为git checkout命令承担了太多职能,比如进行分支切换,比如撤销工作区文件修改等等,git checkout不符合 UNIX 软件设计哲学中的『do one thing and do it well』,因此将其职责进行拆分,使用git switch来进行分支操作,使用git restore来进行文件回退操作...

对上图而言,git restore相关命令对应原先git checkout命令如下表所示:

新命令 对应旧命令 职能
git restore [--worktree] <file> git checkout -- <file> 重置工作区文件,即撤销文件工作区修改,恢复到上一次暂存状态
git restore --staged <file> git reset HEAD <file> 重置暂存区文件,相当于暂存区该文件恢复到上一次commit状态
git restore --source=HEAD <file> git checkout HEAD <file> 重置工作区文件到HEAD状态
git restore ---worktree --staged --source=HEAD <file> git chekcout HEAD <file> 重置工作区和暂存区文件到HEAD状态

目录结构

一般情况下,.git文件夹的目录结构如下所示:

$ tree -L 2 .git
.git
├── HEAD
├── branches
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

其中:

可以看到,本地版本库.git文件夹有很多条目,但最重要的条目为:HEADindexobjectsrefs,这几个条目共同完成了 Git 的数据模型,换句话说,借助这几个条目,就可以实现 Git 的版本控制功能。

Git 引用(References)

从前文内容可以知道,Git 本地版本库存在两种引用文件:refsHEAD,其中:

Git 对象模型(Git Objects)

Git 内置了四种对象模型,分别为blobtreecommittag,它们都存储在.git/objects目录中,这四种对象具备固定的格式:

<tag> <content size>\0<content data>

<tag> <content size>\0称为对象头(header),其中:

当内容要被追踪时(git add),Git 会进行如下操作:

  1. 依据内容相关信息拼接出上述格式字符串。

  2. 然后对该格式字符串进行 SHA-1 计算,得出 40 位字符串摘要值。

  3. 对格式字符串使用zlib.deflate()方法进行压缩,得到压缩内容。

  4. 最后将摘要的前两位作为对象文件存储目录名,后 38 位作为文件名,将压缩内容存储到.git/objects目录中。
    :理论上,.git/objects目录下可存在00~ff共 256 个摘要文件夹。

下面,具体介绍下 Git 的四种对象模型。

blob

blob可以认为是文件类型的对象模型,当我们要追踪某个文件时,首先需要将该文件添加到暂存区中,此时 Git 就会生成该文件的一个blob对象。

blob对象的格式如下所示:

blob <content size>\0<content data>

blob对象格式大致示意图如下所示:
:示意图将\0换成\n,为了更直观展示。

blob

举个例子:创建一个本地版本库,并添加一个文件到暂存区中,查看下版本库变化:

$ git init demo01 && cd demo01
Initialized empty Git repository in /mnt/e/code/temp/demo01/.git/

$ tree .git/objects
.git/objects
├── info
└── pack

2 directories, 0 file

# -n 不添加新行(非常重要,否则会导致末尾多个 \n 字符)
$ echo -n '111' > 1.txt

# 添加 1.txt 到暂存区
$ git add 1.txt

$ tree .git/objects
.git/objects
├── 9d
│   └── 07aa0df55c353e18eea6f1b401946b5dad7bce
├── info
└── pack

3 directories, 1 file

可以看到,当我们使用git add添加文件到暂存区时,.git/objects目录下就生成了一个文件9d/07aa0df55c353e18eea6f1b401946b5dad7bce(实际上此时还生成了.git/index文件,这里先略过不表),该文件名称就是blob格式字符串的摘要,我们可进行如下验证:

$ echo -n 'blob 3\x00111' | sha1sum
9d07aa0df55c353e18eea6f1b401946b5dad7bce  -

\x00是 ascii 码NUL字符的十六进制表示,可在命令行输入man ascii进行查看。

或者也可以使用 Git 提供的底层命令查看文件数字摘要:

$ echo -n '111' | git hash-object --stdin
9d07aa0df55c353e18eea6f1b401946b5dad7bce

可以看到,输出的 SHA-1 摘要值是一样的,说明我们构造的字符串应当是正确的。
:数字摘要算法理论上存在哈希碰撞,但实际使用可认为几乎是安全的,即不同的内容进行数字摘要计算,得出的摘要几乎都是不同的。

我们也可以对生成的文件.git/objects/9d/9d07aa0df55c353e18eea6f1b401946b5dad7bce进行解压操作,查看下其具体内容,这里使用 Python 脚本解压该文件,如下所示:

$ python3
Python 3.8.4 (default, Jul 20 2020, 19:38:34)
[GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> file = open('.git/objects/9d/07aa0df55c353e18eea6f1b401946b5dad7bce', 'rb')
>>> data = file.read()
>>> import zlib
>>> zlib.decompress(data).decode('utf-8')
'blob 3\x00111'

可以看到,解压缩后的内容与我们的预期一致。

综上所述,blob对象其实主要就是存储了被追踪文件的大小和内容,存储路径为文件内容(更确切地说:对象头 + 文件内容)的数字摘要。

到这里,我们已经知道blob对象模型的命名与存储规则,此外,blob对象模型还具备如下两个重要特性:

tree

blob只存储了文件内容,没有存储文件名,文件权限等信息,因此需要另外一个媒介存储这些信息,这样才能将文件名与相应blob对象文件关联到一起,而负责这项关联映射关系的对象模型就是tree。其格式如下所示:

tree <content size>\0<content data>

其中,content data内容为一个列表,称为Entries,列表的每一项称为entry,每个entry可能存储一个blob(即文件)相关信息,也可能存储一个tree(即子文件夹)相关信息,列表项entry的格式如下所示:

<mode> <file name>\0<sha1>

其中:

tree对象模型的大致示意图如下所示:
:示意图将\0换成\n,为了更直观展示。
tree对象的每条列表项entry都是直接拼接到一起的,这里增加\n表示,为了更直观展示。

tree

tree对象模型可以认为是对文件夹的描述,其内容包含了一个或多个treeblob对象信息,所以一个项目文件其实就是一个根tree,项目文件内被追踪的子文件夹和文件就是根tree的树枝结点(子tree)和叶子结点(blob),一个根tree就是项目一个时间点上的全量快照。

tree的树形结构示意图如下所示:

tree

tree内某个文件内容修改并暂存时,我们知道,此时 Git 对象数据库(即.git/objects)会生成一个新的blob对象文件,但是当前tree对象并不会更改其叶子结点指向新生成的blob对象,因为在 Git 中,tree对象的实现是一棵『默克尔树(Merkle Tree)』,默克尔树是一类基于哈希值的二叉树或多叉树,其每个结点都存储一个哈希值,其中,叶子结点通常是数据块的哈希值,树枝结点的值是其所有孩子结点组合结果的哈希值,因此,默克尔树的一个特性就是当孩子结点数据变化时,会导致其父节点哈希值变化,进而一层层往上传递,直至根结点哈希值变化。因此,当tree对象内的某个文件内容修改后,会最终触发导致生成一个新的tree对象,该tree对象就是当前目录的最新快照。比如,假设上图1.txt内容被修改并提交了该变化,则此时,整棵树的变化过程如下图所示:

new tree

:对于未修改的文件或文件夹,新生成的tree会复用这些文件对应的blobtree对象。

tree对象文件的生成过程是当我们提交的文件存在于项目子目录时,Git 就会为该子目录创建一个tree,该tree对象文件存储了其目录下所有被追踪的文件及子文件夹相关信息。示例如下所示:

  1. 创建一个新仓库

    $ git init demo02 && cd demo02
    Initialized empty Git repository in /mnt/e/code/temp/demo/demo02/.git/
    
  2. 在项目内创建一个子目录

    $ mkdir subdir
    
  3. 在该子目录下创建一个新文件

    $ echo -n '111' > subdir/1.txt
    
  4. 暂存所有改变

    $ git add .
    $ tree .git/objects
    .git/objects
    ├── 9d
    │   └── 07aa0df55c353e18eea6f1b401946b5dad7bce
    ├── info
    └── pack
    
    3 directories, 1 file
    
    $ git cat-file -t 9d07
    blob
    

    可以看到,暂存子目录文件,只会生成对应文件的blob对象。

  5. 提交暂存区内容:

    $ git commit -m '1st commit'
    [master (root-commit) cbe4ae2] 1st commit
     1 file changed, 1 insertion(+)
     create mode 100644 subdir/1.txt
    
    $ tree .git/objects
    .git/objects
    ├── 9d
    │   └── 07aa0df55c353e18eea6f1b401946b5dad7bce # blob
    ├── b0
    │   └── fa0d846c24e325b3c8814b850ba2ad61bd4be6
    ├── cb
    │   └── e4ae222eadd352cf39949d5c33ea0e9f6ba5f7
    ├── f1
    │   └── 843529cb2956ad82576cc37f0feb521004c672
    ├── info
    └── pack
    
    6 directories, 4 files
    

    此时可以看到,提交文件subdir/1.txt时,生成了很多新对象模型文件,它们的类型如下:

    $ find .git/objects -type f | awk -F '/' '{sha = $3$4; printf("%s\t", sha); system("git cat-file -t "sha)}'
    9d07aa0df55c353e18eea6f1b401946b5dad7bce        blob
    b0fa0d846c24e325b3c8814b850ba2ad61bd4be6        tree
    f1843529cb2956ad82576cc37f0feb521004c672        tree
    

    可以看到,有两个tree类型,分别查看这两个tree内容:

    $ git cat-file -p b0fa
    040000 tree f1843529cb2956ad82576cc37f0feb521004c672    subdir
    
    $ git cat-file -p f184
    100644 blob 9d07aa0df55c353e18eea6f1b401946b5dad7bce    1.txt
    

    可以看到,b0f0存储subdir信息,因此b0f0就是项目根目录的tree对象。
    f184存储1.txt,因此f184就是subdir文件夹的tree对象。

从上面例子我们可以知道,当暂存子目录文件时,只会生成暂存文件blob对象,而只有在提交时,才会生成子目录tree对象,所以,tree对象其实是根据暂存区内容而生成的。

上面都是使用上层命令操作从而间接创建tree等对象,Git 也提供了相应的底层命令可以直接生成tree对象。

下面使用 Git 提供的底层命令模拟上述例子,生成subdir子目录的tree对象:

  1. 首先,创建一个新仓库

    $ git init demo03 && cd demo03
    Initialized empty Git repository in /mnt/e/code/temp/demo03/.git/
    
  2. 在 Git 数据库中生成1.txt文件的blob对象:

    $ echo -n '111' | git hash-object -w --stdin
    9d07aa0df55c353e18eea6f1b401946b5dad7bce
    
    $ tree .git/objects
    .git/objects
    ├── 9d
    │   └── 07aa0df55c353e18eea6f1b401946b5dad7bce
    ├── info
    └── pack
    
    3 directories, 1 file
    
  3. 为索引文件添加1.txt的相关信息,一个重要的操作就是将1.txt设置到subdir目录下:

    $ git update-index --add --cacheinfo 100644 9d07aa0df55c353e18eea6f1b401946b5dad7bce subdir/1.txt
    
    # 查看暂存区文件
    $ git ls-files --stage
    100644 9d07aa0df55c353e18eea6f1b401946b5dad7bce 0       subdir/1.txt
    

    git update-index可以更新索引文件信息,其中:

    • --add:表示添加文件到暂存区中。
    • --cacheinfo:表示直接插入相关信息到索引文件中。
  4. 上述操作其实我们已经完成了索引文件.git/index的修改,将subdir/1.txt添加到暂存区中,此时使用git write-tree命令就可以生成相关tree对象:

    # 此时还未生成任何 tree 对象
    $ tree .git/objects
    .git/objects
    ├── 9d
    │   └── 07aa0df55c353e18eea6f1b401946b5dad7bce
    ├── info
    └── pack
    
    3 directories, 1 file
    
    # 生成 tree 对象
    $ git write-tree
    b0fa0d846c24e325b3c8814b850ba2ad61bd4be6
    
    $ tree .git/objects
    .git/objects
    ├── 9d
    │   └── 07aa0df55c353e18eea6f1b401946b5dad7bce
    ├── b0
    │   └── fa0d846c24e325b3c8814b850ba2ad61bd4be6
    ├── f1
    │   └── 843529cb2956ad82576cc37f0feb521004c672
    ├── info
    └── pack
    
    5 directories, 3 files
    

    当使用git write-tree后,可以看到 Git 对象数据库已经生成了两个tree对象:b0faf184,与我们上述的例子一摸一样。

commit

前文已经介绍过,tree对象本身就可以作为项目历史的一个快照,但是如果作为版本控制系统,一个版本中应当还包含其他一些辅助信息,比如版本创建时间、作者、提交信息以及当前版本的父版本信息...Git 中承载这些信息的对象模型就是commit。其格式如下所示:

commit <content size>\0<content data>

其中,content data是一个多行字符串,其内容大致如下所示:

tree 8c139d33efe89ef4a5b603bb84f6d23060015eee
parent 7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb
author Why8n <Why8n@gmail.com> 1607306315 +0800
committer Why8n <Why8n@gmail.com> 1607306315 +0800

2nd commit

其中:

commit对象模型的大致示意图如下所示:
:示意图将\0换成\n,为了更直观展示。

commit

commit的关键就是将其绑定到一个tree对象中,通常我们都是使用git commit创建一个commit对象,此时 Git 会根据暂存区内容生成一个项目根tree,然后将该commit绑定到该tree上,完成一个版本快照。这里为了方便,直接使用 Git 提供的底层命令git commit-tree来创建commit对象,完整来阐述 Git 实现一个版本快照的底层过程,如下例子所示:

  1. 创建一个新的本地仓库:

    $ git init demo04 && cd demo04
    Initialized empty Git repository in /mnt/e/code/temp/demo04/.git/
    
  2. 模拟生成一个文件:

    $ echo '111' | git hash-object --stdin -w
    58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c
    
  3. 将文件添加进暂存区:

    $ git update-index --add --cacheinfo 100644 58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c 1.txt
    
  4. 生成tree对象文件:

    $ git write-tree
    58736bb5bad915b7619ddc90e0043fe3a7bc967b
    
  5. 创建一个commit对象文件,将其绑定到上一步生成的tree对象:

    $ echo '1st commit' | git commit-tree 5873
    7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb
    

    此时,查看对象数据库,就可以看到生成该commit对象文件:

    $ find .git/objects -type f | awk -F '/' '{sha = $3$4; printf("%s\t", sha); system("git cat-file -t "sha)}'
    58736bb5bad915b7619ddc90e0043fe3a7bc967b        tree
    58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c        blob
    7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb        commit
    

    可以查看该commit对象内容:

    $ git cat-file -p 7f9c
    tree 58736bb5bad915b7619ddc90e0043fe3a7bc967b
    author Why8n <Why8n@gmail.com> 1607304955 +0800
    committer Why8n <Why8n@gmail.com> 1607304955 +0800
    
    1st commit
    

    可以看到,第一个commit对象没有parent信息。

  6. 虽然我们已经生成了一个commit对象,但此时还无法使用git log查看提交历史,因为新仓库还未指定分支信息:

    $ git update-ref refs/heads/master '7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb'
    

    refs/heads/master文件存在就表示存在master分支,将该文件内容设置为要指向的commit对象数字摘要即可。

  7. 每次使用 Git 命令时,都需要知道当前所在分支,这个信息写在HEAD文件中:

    $ git symbolic-ref HEAD refs/heads/master
    

    :这步骤其实可以忽略,因为 Git 默认就设置了HEAD指向master分支。

  8. 此时,就可以使用git log查看历史提交信息了:

    $ git log
    commit 7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb (HEAD -> master)
    Author: Why8n <Why8n@gmail.com>
    Date:   Mon Dec 7 09:35:55 2020 +0800
    
        1st commit
    
  9. 继续添加第二个提交:

    # 重命名 1.txt -> 2.txt
    $ git update-index --add --cacheinfo 100644 58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c 2.txt
    
    # 生成新树
    $ git write-tree
    8c139d33efe89ef4a5b603bb84f6d23060015eee
    
    # 创建新commit,绑定到新tree,并将其 parent 指定为 7f9c
    $ echo '2nd commit' | git commit-tree 8c13 -p 7f9c
    0980ef464c6f2a05d9cbfbff00add4134409747c
    
    # 查看新 commit 文件内容
    $ git cat-file -p 0980
    tree 8c139d33efe89ef4a5b603bb84f6d23060015eee
    parent 7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb
    author Why8n <Why8n@gmail.com> 1607306315 +0800
    committer Why8n <Why8n@gmail.com> 1607306315 +0800
    
    2nd commit
    
  10. 此时还需要更新master分支到最新提交:

    $ git update-ref refs/heads/master '0980ef464c6f2a05d9cbfbff00add4134409747c'
    
  11. 此时再次查看历史提交信息,就可以看到多条提交日志了:

    $ git log
    commit 0980ef464c6f2a05d9cbfbff00add4134409747c (HEAD -> master)
    Author: Why8n <Why8n@gmail.com>
    Date:   Mon Dec 7 09:58:35 2020 +0800
    
        2nd commit
    
    commit 7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb
    Author: Why8n <Why8n@gmail.com>
    Date:   Mon Dec 7 09:35:55 2020 +0800
    
        1st commit
    

上面一整套操作就是上层命令git addgit commit的底层实现原理。

tag

最后一种对象模型为tag,实际上,tag既可以作为一种对象模型,也可以作为一种引用,因为 Git 中存在两种类型的标签:

到这里,Git 对象模型相关内容已介绍完毕。最后在简单阐述一下:

在 Git 中,.git/objects目录也被称为对象数据库,其存储被追踪内容的对象模型。

对象模型总共有四种:blobtreecommittag,其中,blog存储对象文件内容,tree存储文件夹相关信息,commit表示一个版本快照,存储了版本快照相关信息,快照具体内容由其绑定的tree对象存储,版本的历史记录由其parent字段维护,tag一般用作某个commit的别名,方便引用该commit

所有对象模型只关注其内容,依据内容进行 SHA-1 计算得出数字摘要值作为对象文件名称,也即是说,给定一个数字摘要,就可以获取到一个唯一的对象文件(假设该文件存在),Git 具备的这种键值对象存储索引特性,本质上是一个『内容寻址文件系统(content-addressable filesystem)』。

分支原理

Git 中,分支的实现主要借助其『引用机制』,其实我们前面内容已经涵盖分支实现原理,这里再将关键过程的实现原理捋一遍。

分支实现主要涉及如下几个问题:

  1. 分支创建:在 Git 中,可以使用git branch <branch_name>来创建一个新分支,其底层实现原理其实就是在.git/refs目录下创建一个引用文件,文件名与分支名相同,但是会根据分支类别,创建在不同的子目录中,比如,对于本地分支创建,则在.git/refs/heads目录中创建同名文件,对于远程分支目录,则在.git/refs/remotes中创建引用文件,对于标签创建,则在.git/refs/tags目录中创建同名文件。

  2. 分支内容:当创建新分支时,会将创建分支时的最新提交的数字摘要设置为新分支文件内容,这样就将新分支与某个版本快照绑定到一起。
    如果在当前分支创建新提交或执行回退操作,则 Git 会将此时的提交数字摘要设置到分支文件中,确保分支永远指向最新提交。

  3. 确定当前分支:每次执行 Git 命令时,一个最基础的操作就是确定当前分支,这样才能索取到正确的内容。当前分支可以从HEAD符号链接引用文件中查询得到,每次当我们进行分支切换时,Git 会自动更新HEAD文件内容,确保其始终指向当前分支。

  4. 分支历史版本记录:在不同的分支中,可能存在不同的历史记录,不同分支维持各自历史记录的方式其实很简单,每个分支都对应一个引用文件,该引用文件的内容为某个特定提交的数字摘要(SHA-1),这样每个分支就各自对应一个commit。所以分支其实就是指向一个commit,而历史记录已存储在该commit对象之中。

以上,就基本实现分支功能了。

举个例子:比如现在我们想查询提交信息,于是执行git log命令,此时,我们模拟一下 Git 的操作逻辑,步骤如下所示:

  1. 首先,git log命令是要查询当前分支提交信息,那么第一步就是要确定当前分支:

    $ cat .git/HEAD
    ref: refs/heads/master
    
  2. 查找到当前分支后,就可以确定当前分支的最新提交:

    $ cat .git/refs/heads/master
    cb448bb7fc3b2a135995c35302e2772533ea5579
    
    $ git cat-file -t cb44
    commit
    
  3. 找到当前分支的最新提交后,进行展示,此时显示的是最新的记录:

    $ git cat-file -p cb44
    tree 58736bb5bad915b7619ddc90e0043fe3a7bc967b
    parent 6426362190b8f9f83c8133deea9c5db63a84bf1f
    author Why8n <Why8n@gmail.com> 1607350026 +0800
    committer Why8n <Why8n@gmail.com> 1607350026 +0800
    
    2nd commit
    
  4. 然后根据提交的parent信息,依次递归遍历其parent提交,直至没有parent信息,表示已达到提交起点:

    $ git cat-file -p 6426
    tree fffc9cb8a2c70b80b8c03c8662a6dbc75dee4c8d
    author Why8n <Why8n@gmail.com> 1607349847 +0800
    committer Why8n <Why8n@gmail.com> 1607349847 +0800
    
    1st commit
    

这样,就完成了git log功能。

暂存区原理

依据 Git 提供的对象模型和分支机制,其实就可以基本实现项目源码版本控制与分支功能。但是与传统版本控制系统不同的是,Git 还提供了一个称为『暂存区』的概念。

对于传统的版本控制系统,当被追踪文件内容修改时,提交保存的是差异部分(Delta 机制),而 Git 的实现与之相反,具体来说,有如下区别:

  1. Git 每次提交时,只要追踪文件内容修改,提交的都是全量更新内容。
  2. Git 支持缓存功能,对于文件的修改,可多次进行暂存,每次暂存都会生成一个全量更新的blob对象,因此对象数据库中保存了每次修改的内容,相对于传统的版本控制系统只会保存最终提交修改的内容,Git 缓存了每次修改的内容,因此可随时回退到某个修改历史版本,不会导致某次修改内容丢失。

我们使用暂存区最直观的感受就是可以多次暂存修改文件,直至修改满意再进行提交,但实际上,暂存区的作用远远不止于此,简单来说,暂存区主要有如下三个作用:

  1. 具备生成唯一tree对象相关信息:暂存区支持添加文件追踪,支持多次修改被追踪的文件,并且记录了所有被追踪文件的相关信息,提交时会根据暂存区记录的文件生成相应的tree对象,最终生成一个最新提交commit
    :此时该最新commit追踪的内容就是当前暂存区的内容,使用git diff --cached可以看到没有返回任何信息,说明暂存区和版本库没有差异。

  2. 具备差异比较功能:暂存区缓存了被追踪文件的最新相关信息,支持快速比较同一文件与工作区或版本库之间的差别。

  3. 具备分支合并功能:当进行分支合并时,会将相关分支所有被追踪文件按路径进行比对,然后合并相同文件内容,遇到冲突时,会自动尝试解决冲突,无法解决时,记录冲突内容,停止合并,交由开发者解决冲突。

下面主要介绍暂存区 差异比较分支合并 功能:

差异比较

暂存区的本质其实就是一个二进制文件:.git/index,该文件保存了所有被追踪文件的相关信息,记录了文件修改的相关状态,是工作区和版本库之间的沟通枢纽。
:Git 采用 mmap 方式将index文件映射到内存,因此即使文件很大,仍能快速操作该文件。

简单来说,暂存区记录了所有被追踪的文件的完整路径及其对应的blob对象,且默认按文件路径升序排列,这样做的原因是可以对文件路径进行二分查找,快速定位到暂存区中该文件的位置,因为 Git 对象文件的是分散存储,假设一个文件位于一个子目录中,要找到该文件对应的blob对象,则首先需要加载并深度优先遍历当前根tree对象,依次加载并比较每个结点的路径信息,找到子目录结点,加载并遍历子目录tree对象,直至找到所需文件。如果该文件项目层级过深时,则会导致大量的磁盘操作,严重影响性能。在这点上来说,暂存区就相当于数据库的索引文件,缓存了文件路径相关信息,并具备快速查找功能,这也是为什么暂存区文件名为index的原因吧。

暂存区保存了文件最新的修改状态,因此,在 Git 中,被追踪的文件会存在如下几种状态(即使用git status命令显示的结果):

文件状态的识别就是通过查询索引文件.git/index实现的,index文件定义了一套紧凑的格式来存储被追踪文件的相关信息,这里我们不深入研究具体的协议格式(索引文件具体协议格式可参考:index-format),只列举与文件状态识别相关的信息进行讲解,介绍其实现原理,具体如下:

  1. 由于被追踪的文件存在于工作区、暂存区和版本库中,所以同一文件内容可能在这三个工作区域有差别,在index文件中,对于同一个文件,其设置了几个状态量来记录各个区域该文件的相关信息:

    • mtime:表示被追踪文件最后一次更新的时间。
    • file:表示被追踪文件名称。
    • wdir:表示被追踪文件工作区的版本,即工作区文件的数字摘要。
    • stage:表示被追踪文件暂存区的版本。
    • repo:表示被追踪文件版本库的版本。
  2. 当切换分支时,Git 会做如下三件事:

    1. 首先将HEAD指针更新到切换分支中。
    2. 更新index文件,使其内容与切换分支最新提交的状态相同。具体来说,切换分支时,Git 会先清空暂存区内容,然后找到切换分支最新提交,获取其对应的tree对象,遍历该tree对象,找到所有的blob对象,将其相关信息记录到暂存区中。
    3. 根据此时暂存区内容重置工作区,即将工作区重置为切换分支最新提交状态。

    举个例子:比如现在我们本地有一个仓库,假设该仓库有一个分支dev,且该分支下被追踪的文件有1.txt2.html共两个文件(可通过命令git ls-files查看所有被追踪的文件):

    $ git switch dev
    Switched to branch 'dev'
    
    # 查看暂存区内容
    $ git ls-files --stage
    100644 a30a52a3be2c12cbc448a5c9be960577d13f4755 0       1.txt
    100644 c200906efd24ec5e783bee7f23b5d7c941b0c12c 0       2.html
    

    当我们执行git switch dev的时候,当前工作区会被设置到dev分支最新提交状态,且此时index文件也会被更新到dev分支最新提交状态,如下图所示:

    git switch

    可以看到,分支切换完成后,三个工作区域的文件状态都相同,如果此时我们修改1.txt文件内容,则工作区文件状态会发送变化,如下图所示:

    modify working tree

    可以看到,对工作区文件进行修改,只会影响工作区文件状态,不会影响其他区域该文件状态,而如果此时我们执行git status命令:

    $ git status
    On branch dev
    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:   1.txt
    
    no changes added to commit (use "git add" and/or "git commit -a")
    

    出现了Changes not staged for commit状态,原因是执行git status时,Git 会做如下两件事:

    1. 将工作区文件状态更新到index文件中,如下图所示:
    git status
    1. 判断wdirstagerepo三者版本区别,进而确定文件状态。
      对于我们上述的例子,此时,Git 判断到暂存区中1.txtwdirstage版本不同,说明工作区进行了修改,但未暂存,因此此时文件的状态即为:Changes not staged for commit。如下图所示:
    git status

    然后我们就可以使用git add 1.txt将工作区修改内容添加到暂存区中,此时,.git/objects会生成一个1.txt的全量快照blob对象文件,并且同时会更新index文件索引版本,如下图所示:

    git add

    如果此时我们执行git status命令:

    $ git status
    On branch dev
    Changes to be committed:
      (use "git restore --staged <file>..." to unstage)
            modified:   1.txt
    

    可以看到,此时1.txt的状态为:Changes to be committed,同理,出现这种状态的原因是,wdirstage版本相同,说明工作区和暂存区内容一致,而stagerepo版本不一致,说明暂存内容未提交。如下图所示:

    git status

    最后,我们可以使用git commit将暂存区内容进行提交,Git 会做如下三件事:

    • 创建commit对象和tree对象。
    • dev分支移动到最新提交上。
    • 更新index文件信息。

    如下图所示:

    git commit

    此时,三个工作区域的内容就完全一致了:

    $ git status
    On branch dev
    nothing to commit, working tree clean
    

分支合并

最简单的分支合并就是两路分支合并,也就是合并两个commit,其本质是合并两个commit对应的根tree对象,按正常思路来思考,只需同时依次遍历这两棵根tree,找到所有的叶子结点(被追踪的所有文件),合并文件路径相同的叶子结点即可。这种做法的思路是正确的,但是存在一个问题,如果出现无法自动解决的冲突,则需要将相关的文件版本信息展示给用户查看,因此需要一个地方存储这些冲突信息,这个地方就是暂存区。

这里我们以例子驱动介绍暂存区对于分支合并的原理:

  1. 创建一个本地仓库:

    $ git init demo05 && cd demo05
    Initialized empty Git repository in /mnt/e/code/temp/demo05/.git/
    
  2. 工作区写入内容,并进行提交:

    $ echo '111' > 1.txt
    
    $ git add 1.txt
    
    $ git commit -m 'master: 111'
    [master (root-commit) afd9e9a] master: 111
     1 file changed, 1 insertion(+)
     create mode 100644 1.txt
    

    此时,master分支指向afd9commit对象。

  3. 创建并切换到新分支dev,写入内容,并进行提交:

    $ git switch -c dev
    Switched to a new branch 'dev'
    
    $ echo '222' >> 1.txt
    
    $ git add 1.txt
    
    $ git commit -m 'dev: 222'
    [dev 14d1ae1] dev: 222
     1 file changed, 1 insertion(+)
    

    此时,dev分支指向14d1commit对象。

  4. 切换回master分支,再做一些修改:

    $ git switch master
    Switched to branch 'master'
    
    $ echo '333' >> 1.txt
    
    $ mkdir subdir
    
    # 添加新文件
    $ echo 'new data' > subdir/2.txt
    
    $ git add 1.txt subdir/2.txt
    
    $ git commit -m 'master: 333'
    [master fc5927c] master: 333
     2 files changed, 2 insertions(+)
     create mode 100644 subdir/2.txt
    
  5. master分支上,进行分支合并:

    $ git merge dev
    Auto-merging 1.txt
    CONFLICT (content): Merge conflict in 1.txt
    Automatic merge failed; fix conflicts and then commit the result.
    

    可以看到,有冲突产生,先忽略该冲突,我们先查看下此时暂存区状态:

    $ git ls-files --stage
    100644 58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c 1       1.txt
    100644 f39c1520a7dee8f5610920364b6faba45b01bfd0 2       1.txt
    100644 a30a52a3be2c12cbc448a5c9be960577d13f4755 3       1.txt
    100644 116c7ee1423b9a469b3b0e122952cdedc3ed28fc 0       subdir/2.txt
    

    git ls-files输出的信息很清晰,大部分字段我们都可以知道其意思,只有第三个字段可能需要解释一下,该字段代表暂存编号,是用来处理合并冲突问题的。具体来说,暂存编号有如下四个值可选:

    • 0:表示当前条目没有冲突问题。
    • 1:表示合并分支公共祖先的文件内容。
    • 2:表示当前分支(即HEAD)的文件内容。
    • 3:表示合并分支的文件内容。

    综上,对于subdir/2.txt文件,其暂存号为0,表示其不存在冲突问题,可直接合并。而对于1.txt,总共出现三个条目,我们依次查看其各自内容:

    # 暂存号 1
    $ git cat-file -p 58c9
    111
    
    # 暂存号 2
    $ git cat-file -p f39c
    111
    333
    
    # 暂存号 3
    $ git cat-file -p a30a
    111
    222
    

    可以看到,与我们介绍的一致,暂存号 1 的1.txt就是master分支的第一次提交内容,暂存号 2 的1.txt就是master分支最新内容,而暂存号 3 的1.txt文件内容就是dev分支的内容。

    因此,Git 在合并分支时,会比对两个commit各自的根tree对象,找到路径相同(即同一文件)的blob对象,自动进行合并操作,当合并成功时,会更新暂存区内容,更新该文件路径匹配条目。当出现冲突时,则需要执行三路合并(3-way merge),如果冲突解决,则更新暂存区内容,否则,将冲突的内容版本写入到暂存区中,即:写入分支公共祖先版本文件信息,并将暂存编号设置为1;写入当前分支版本文件信息,暂存编号设置为2;写入合并分支版本文件信息,暂存编号设置为3。当暂存区存储条目暂存编号不为0时,表示存在合并冲突,此时无法进行提交操作,必须等待用户手动解决该冲突,重新进行暂存并提交。

  6. 手动解决冲突:

    # 查看冲突文件
    $ cat 1.txt
    111
    <<<<<<< HEAD
    333
    =======
    222
    >>>>>>> dev
    
    # 修改冲突文件
    $ echo '444' > 1.txt
    

    我们可以从上一步合并冲突信息中找到冲突的文件,手动打开并进行修改,也可以使用git mergetool命令来唤起合并工具,自动打开冲突文件,然后进行修改。

  7. 解决完冲突后,需要将修改完的文件再次进行暂存:

    $ git add 1.txt
    
  8. 此时再次查看暂存区内容:

    $ git ls-files -s
    100644 1e6fd033863540bfb9eadf22019a6b4b3de7d07a 0       1.txt
    100644 116c7ee1423b9a469b3b0e122952cdedc3ed28fc 0       subdir/2.txt
    

    可以看到,所有条目暂存编号都为0了,表示不存在冲突,此时就可以进行提交或继续分支合并步骤。

  9. 继续执行分支合并:

    $ git merge --continue
    [master 8ca2cfe] Merge branch 'dev'
    
  10. 查看合并历史:

    $ git log --graph
    *   commit 8ca2cfe460b01ecdacb62919203c01358f98b81e (HEAD -> master)
    |\  Merge: fc5927c 14d1ae1
    | | Author: Why8n <Why8n@gmail.com>
    | | Date:   Sun Dec 20 07:28:49 2020 +0800
    | |
    | |     Merge branch 'dev'
    | |
    | * commit 14d1ae16dd008028fd66f88021f7cdaff1f8e941 (dev)
    | | Author: Why8n <Why8n@gmail.com>
    | | Date:   Sun Dec 20 07:24:33 2020 +0800
    | |
    | |     dev: 222
    | |
    * | commit fc5927ccb287305b0adfa055840e99a45fec0630
    |/  Author: Why8n <Why8n@gmail.com>
    |   Date:   Sun Dec 20 07:25:54 2020 +0800
    |
    |       master: 333
    |
    * commit afd9e9a13b81e902ce9f60af8cbb2cf9ea1b1fd0
      Author: Why8n <Why8n@gmail.com>
      Date:   Sun Dec 20 07:22:51 2020 +0800
    
      master: 111
    

参考

上一篇下一篇

猜你喜欢

热点阅读