美文网首页
Git 内部实现原理剖析

Git 内部实现原理剖析

作者: Whyn | 来源:发表于2020-12-10 02:06 被阅读0次

    [TOC]

    Git 内部实现原理剖析

    前言

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

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

    • 对于本地仓库,完整的一套操作就是三部曲:初始化仓库(git init)-> 追踪文件(git add)-> 本地提交(git commit

    • 对于远程仓库,最简单的一套完整操作也就只有如下几步:下载源码(git clone)-> 修改并暂存(git add)-> 本地提交(git commit)-> 下载更新(git pull)-> 远程提交(git push)。

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

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

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

    三大分区

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

    • 工作区(Working Directory):工作区就是项目根目录,对该目录下的所有文件(除了.git)进行任意操作不会影响到暂存区和版本库。工作区反映的是版本库当前分支的内容,如果切换分支,工作区内容就会被重置到切换分支状态。
      :切换分支时,确保当前分支被追踪的内容已提交(git commit)或储藏(git stash),否则无法切换,因为如果能切换,则此时工作区会重置到切换分支版本状态,导致当前分支修改的内容丢失。但是存在一种情况可以进行切换,就是切换到创建新分支上,然后做些修改,此时无需提交,就可切换回原先分支上,原因是此时新分支与原分支都指向同一个commit,也即共享一棵tree,因此此时在新分支中对被追踪的文件进行修改,但未提交时,修改的都是同一棵tree的子结点,此时如果切换回原分支,则会将变更的内容带到原分支中,修改内容不会丢失,但是会污染原分支。

    • 暂存区(Stage / Index):如果要对文件进行追踪,则需要将文件添加到暂存区。最终提交时,提交的是暂存区的所有内容。
      :暂存区的本质是一个二进制文件:.git/index,该文件存储了被追踪文件的相关信息,是工作区和版本库沟通枢纽,方便追踪文件的最新内容与工作区和版本库的差异。
      :关于暂存区更详细介绍,请参考后文:暂存区原理

    • 版本库(Repository):版本库更确切的说法应当是『本地版本库』,其实际存储路径为工作区内的隐藏文件夹.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
    

    其中:

    • HEAD:表示指向当前分支的最新提交。

      $ cat .git/HEAD
      ref: refs/heads/master                   # 表示 HEAD 文件指向 refs/heas/master
      
      $ cat .git/refs/heas/master              # 查看 HEAD 指向的具体内容
      1a201d63514a2e99c1e59d23839d3eac7dc5d9a3 # 内容为数字摘要
      
      $ git cat-file -p 1a20                   # 查看该数字摘要对应的文件内容
      tree 58736bb5bad915b7619ddc90e0043fe3a7bc967b
      author Why8n <Why8n@gmail.com> 1607092274 +0800
      committer Why8n <Why8n@gmail.com> 1607092274 +0800
      
      1st commit
      

      :数字摘要无需全部书写,通常只要前几位(最少 4 位)就能进行区分。

    • index:即暂存区,其本质是一个二进制文件,保存了所有被追踪文件的相关信息。

    • objects:该目录存储所有数据内容,是 Git 的数据库存储与管理模块,也被称为 Git 的『对象数据库』。该目录下存储的数据类型有blobtreecommittag这四类对象模型,具体内容请参考后文:Git 对象模型

    • refs:该目录主要用于保存一些引用文件(分支、远程仓库和标签等)。具体内容请参考后文:Git 引用(References)

    • config:该文件为项目本地配置文件。Git 会优先使用该文件配置选项,比如,通常我们都使用全局邮箱作用于所有 Git 项目,但是如果某个项目需要使用其他邮箱,则可以在该文件中进行配置,如下所示:

      $ git config user.email another_email@xxx.com
      $ cat .git/config | grep -i email -A1
      [user]
              email = another_email@xxx.com
      
    • logs:存储各分支提交的日志信息。

      $ cat .git/logs/refs/heads/master
      0000000000000000000000000000000000000000 1a201d63514a2e99c1e59d23839d3eac7dc5d9a3 Why8n <Why8n@gmail.com> 1607092274 +0800      commit (initial): 1st commit
      
    • hooks:该目录包含一些钩子脚本,可在 Git 执行某些操作时进行触发。比如,如果想在git push前执行一些操作,则可将这些操作写入.git/hooks/pre-push脚本中。

    • info:该目录包含一个全局性排除(global exclude)文件, 用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns)。

    • description:该文件仅供 GitWeb 程序应用,我们无需关心。

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

    Git 引用(References)

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

    • refs:该目录主要用于保存一些引用文件(分支、远程仓库和标签等)。默认会创建两个文件夹:headstags,其中,heads用于存储本地分支信息,每当创建一个新分支,该文件夹下就会创建一个同名文件,其内容为该分支的最新提交的数字摘要值(SHA-1)。tagsheads目录功能类似,只是只有当创建标签时,才会在该文件夹下创建相应的同名标签引用文件,其内容为标签指向的提交 SHA-1 摘要。另外,通常该目录下还会存在一个remotes目录,用以存储远程分支文件。

      下面列举一个示例来观察创建分支时该目录变化:

      $ tree .git/refs
      .git/refs
      ├── heads
      │   └── master # 本地存在 master 分支
      └── tags
      
      2 directories, 1 file
      
      # 查看 master 分支内容
      $ cat .git/refs/heads/master
      1a201d63514a2e99c1e59d23839d3eac7dc5d9a3
      
      # 创建新分支
      $ git switch -c newbranch
      
      $ tree .git/refs
      .git/refs
      ├── heads
      │   ├── master
      │   └── newbranch # 新分支文件
      └── tags
      
      2 directories, 2 files
      

      我们也可以在.git/refs/heads目录内手动创建一个文件,看是否真的成功创建了一个分支:

      $ git branch
      *master # 当前只有 master 分支
      
      # 手动创建新分支 newbranch
      $ cat .git/refs/heads/master > .git/refs/heads/newbranch
      
      $ git branch
      newbranch # 新分支创建成功
      *master
      

      可以看到,分支的本质就是创建文件到.git/refs相应目录中,其内容为某一个提交的数字摘要值。

      :通常不建议直接修改引用文件,更安全的做法应当是使用 Git 提供的底层命令(Plumbing)进行修改:

      $ git update-ref refs/heads/newbranch '1a201d63514a2e99c1e59d23839d3eac7dc5d9a3'
      
    • HEAD:该文件是一个符号链接引用(symbolic reference),即包含一个指针指向其他引用文件。每次切换分支时,该文件内容都会被设置为切换分支引用,即HEAD始终指向当前分支的最新提交。

      HEAD在仓库创建完成的时候,就会初始化默认指向master分支,如下所示:

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

      我们可以手动更改该文件,让其指向其他分支:

      # 创建一个新分支
      $ git branch dev
      
      # 查看当前分支
      $ git branch
      dev
      * master                                 # 当前处在 master 分支中
      
      # 手动更改 HEAD 文件
      $ echo 'ref: refs/heads/dev' > .git/HEAD
      
      # 查看当前分支
      $ git branch
      * dev                                    # 当前处在 dev 分支中
      master 
      

      可以看到,当我们手动修改了HEAD文件内容时,就进行了分支切换。从这我们可以知道,每次执行 Git 命令时,Git 都会先读取HEAD文件,从而知道我们所处的分支,进而从分支文件中获取得到分支的最新提交。

      :通常不建议直接修改HEAD文件,更安全的做法应当是使用 Git 提供的底层命令进行修改:

      # 修改 HEAD 指向
      $ git symbolic-ref HEAD refs/heads/master
      
      # 查看 HEAD 指向
      $ git symbolic-ref HEAD
      refs/heads/master
      

    Git 对象模型(Git Objects)

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

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

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

    • tag:表示对象类型,其可选值有:blobtreecommittag
    • content size:表示文件内容大小,以十进制表示。
    • \0:表示 ascii 码的NUL字符。
    • content data:表示文件内容,具体内容取决于对象类型。

    当内容要被追踪时(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对象模型还具备如下两个重要特性:

    • 相同内容只会存储为一个blob文件blob对象只关心文件内容,不关注文件其他信息(比如文件名称、权限等),因此,如果存在多个相同内容的不同名称文件,Git 最终只会保存为一个blob对象。验证过程如下所示:

      1. 前面我们通过git add命令添加新文件到暂存区,从而生成相应对象模型,这些命令都是 Git 提供的上层命令(Porcelain),是我们日常操作经常使用的,但 Git 同时也提供了一些底层命令(Plumbing),可以让我们直接生成blob等对象,如下所示:

        $ echo -n '222' | git hash-object -w --stdin
        6dd90d24d319b452859920bf74120405fcdaa017
        

        其中:

        • --stdin:表示git hash-object从标准流中读取数据,否则读取的是文件。
        • -w:表示将内容写输入对象数据库中,即生成相应文件到.git/objects中。不加该选项,则只会显示数字摘要。
      2. 此时查看下.git/objects

        $ tree .git/objects
        .git/objects
        ├── 6d
        │   └── d90d24d319b452859920bf74120405fcdaa017
        ├── 9d
        │   └── 07aa0df55c353e18eea6f1b401946b5dad7bce
        ├── info
        └── pack
        
        4 directories, 2 files
        

        可以看到,一个新的文件生成了:.git/objects/6d/d90d24d319b452859920bf74120405fcdaa017

      3. 可以通过如下命令查看新文件的对象类型:

        $ git cat-file -t 6dd9
        blob
        

        可以看到,新文件是一个blob对象

      4. 可以通过如下命令查看对象模型数据:

        $ git cat-file -p 6dd9
        222%
        

        的确是我们写入的数据。

      5. 假设我们再次写入相同的数据,查看下版本库变化:

        $ echo -n '111' | git hash-object -w --stdin
        9d07aa0df55c353e18eea6f1b401946b5dad7bce
        
        $ echo -n '222' | git hash-object -w --stdin
        6dd90d24d319b452859920bf74120405fcdaa017
        
        $ tree .git/objects
        .git/objects
        ├── 6d
        │   └── d90d24d319b452859920bf74120405fcdaa017
        ├── 9d
        │   └── 07aa0df55c353e18eea6f1b401946b5dad7bce
        ├── info
        └── pack
        
        4 directories, 2 files
        

        这里可以看到,写入相同内容的不同文件,永远只存在一个相同的blob对象。

    • 同一个文件内容被修改后,会生成另一个blob文件:当我们对已暂存的文件进行修改后,再次暂存时,会生成另一个全量更新的blob对象,因为blob对象只关注数据内容,不关注是否为同一文件。如下例子所示:

      $ git hash-object 1.txt                                # 查看当前 1.txt 摘要值
      9d07aa0df55c353e18eea6f1b401946b5dad7bce
      
      $ find .git/objects -type f
      .git/objects/6d/d90d24d319b452859920bf74120405fcdaa017
      .git/objects/9d/07aa0df55c353e18eea6f1b401946b5dad7bce # 1.txt
      
      $ echo -n '222' >> 1.txt                               # 修改文件内容
      
      $ git hash-object -w 1.txt                             # 为修改后的文件生成 blob 对象文件
      6de418c139823a34ca26fd924edb2166c159cdaf
      
      $ find .git/objects -type f
      .git/objects/6d/d90d24d319b452859920bf74120405fcdaa017
      .git/objects/6d/e418c139823a34ca26fd924edb2166c159cdaf # 修改后的 1.txt
      .git/objects/9d/07aa0df55c353e18eea6f1b401946b5dad7bce # 旧 1.txt
      
      $ git cat-file -p 9d07                                 # 旧 1.txt 内容
      111%
      
      $ git cat-file -p 6de4                                 # 修改后的 1.txt 内容
      111222%
      

    tree

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

    tree <content size>\0<content data>
    

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

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

    其中:

    • mode:表示文件类型和权限信息,其常见可选值如下所示:

      • 100644:表示普通文件。
      • 100755:表示可执行文件。
      • 120000:表示符号链接。
      • 040000:表示普通目录。
    • file name:表示文件或目录名。
      entries会根据file name排序。

    • sha1:表示file name对应的 SHA-1 数字摘要,可能是blob文件摘要,也可能是tree文件的摘要。
      sha1entry中是以字节形式进行存储,不是以十六进制字符串(应该是为了减小文件大小),因此如果解压该文件,直接显示可能出现乱码,可以通过以下命令输出tree对象的原始文件列表内容:

      $ git cat-file tree 8cd8
      100644 2.txtm$R tڠ%
      

      可以看到,对于sha1内容输出是乱码,这里我写了一个 Python 脚本,可以以字符串形式显示tree对象文件原始内容:

      $ 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.
      >>> def _decodeHeader(data):
      ...     pos = 0
      ...     while( data[pos] != 0):
      ...             pos += 1
      ...     header = str(data[:pos+1], 'utf-8')
      ...     return (header, pos)
      ...
      >>> def _decodeEntry(data, pos):
      ...     curPos = pos
      ...     while( data[curPos] != 0):
      ...             curPos += 1
      ...     curPos += 1
      ...     info = str(data[ pos : curPos], 'utf-8')
      ...     sha1 = ''.join( [ format(num, 'x') for num in data[curPos : curPos + 20] ]) # sha1 40 位字符,等于 20 个数字
      ...     entry = info + sha1
      ...     return (entry, curPos + 19)
      ...
      >>> def decodeTree(data):
      ...     tree, pos = _decodeHeader(data)
      ...     while( pos < len(data) - 1 ):
      ...             entry, pos = _decodeEntry(data, pos + 1)
      ...             tree = tree + entry
      ...     return tree
      ...
      >>> raw = open('.git/objects/8c/d8f71474e5a801775d46445f49464f1a4b990f', 'rb').read()
      >>> import zlib
      >>> binaryData = zlib.decompress(raw)
      >>> binaryData
      b'tree 33\x00100644 2.txt\x00m\xd9\r$\xd3\x19\xb4R\x85\x99 \xbft\x12\x04\x05\xfc\xda\xa0\x17'
      >>> decodeTree(binaryData)
      'tree 33\x00100644 2.txt\x006dd9d24d319b452859920bf741245fcdaa017'
      

      :Git 其实已经提供了其他命令可以直接读取tree对象的列表内容,并以用户友好的格式进行展示:

      # 方法一
      $ git ls-tree 8cd8
      100644 blob 6dd90d24d319b452859920bf74120405fcdaa017    2.txt
      
      # 方法二
      $ git cat-file -p 8cd8
      100644 blob 6dd90d24d319b452859920bf74120405fcdaa017    2.txt
      

    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
    

    其中:

    • tree:表示当前commit对应的版本快照树。
    • parent:表示当前commit的父版本提交对象。
      :一个commit对象可以有 0 个或多个parent,当首次提交时,该commit对象没有parent,后续提交时,通常只有一个parent,当合并分支时,该commit对象可以有 2 个或多个parent提交对象。
    • 最后一行内容表示提交信息,是对当前版本快照的一个描述。

    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 中存在两种类型的标签:

    • 轻量级标签(lightweight):通常将轻量级标签打在某一个提交上,因此,轻量级标签的本质就是某个特定提交的引用,且该引用不会改变。可以简单理解轻量级标签为某个提交的别名,使用别名进行查看比使用提交的数字摘要更加方便。

      轻量级标签的使用方式如下:

      # 为当前分支最新提交打个标签 v1.0
      $ git tag v1.0
      
      $ git show -s v1.0
      commit 0980ef464c6f2a05d9cbfbff00add4134409747c (HEAD -> master, tag: v1.0)
      Author: Why8n <Why8n@gmail.com>
      Date:   Mon Dec 7 09:58:35 2020 +0800
      
          2nd commit
      

      当使用git tag v1.0打上一个轻量级标签时,.git/refs/heads/tags会生成一个同名文件:

      $ tree .git/refs/
      .git/refs/
      ├── heads
      │   └── master
      └── tags
          └── v1.0
      
      2 directories, 2 files
      

      查看该引用文件相关信息:

      # 查看标签类型
      $ git cat-file -t v1.0
      commit
      
      # 查看标签内容
      $ cat .git/refs/tags/v1.0
      0980ef464c6f2a05d9cbfbff00add4134409747c
      
      $ git cat-file -t 0980
      commit
      

      轻量级标签的类型是commit,内容是一个commit的数字摘要,所以轻量级标签就是一个commit,并且是一个固定指向的引用,因为标签内容不会被更改,始终指向设置的那个commit。也可以将标签理解为某个commit的别名,方便引用。比如,标签v1.0指向提交对象0980,相当于是0980的别名。

      最后,之所以称为轻量级标签,是因为它就只是创建了一个引用文件而已。

      上述示例的完整示意图如下所示:

      lightweight tag

      :Git 也提供了创建轻量级标签的底层命令:

      # 创建轻量级标签 v0.1,指向提交 7f9c
      $ git update-ref refs/tags/v0.1 7f9c
      

      最后,通常都将tag打到一个commit对象上,但其实tag可以打到任意对象模型上。比如,假设我们有一个公钥需要经常查看,那么就可以将该公钥内容添加到对象数据库中,生成一个blob,然后,为这个公钥blob打上一个tag,作为别名,方便使用。

      # 为公钥内容生成一个 blob 对象
      $ echo 'public key string' | git hash-object --stdin -w
      3a3bea03936b9b843afa629b333f307c7044507c
      
      # 查看公钥内容
      $ git cat-file blob 3a3b
      public key string
      
      # 为公钥内容打上一个标签(别名)
      $ git tag public_key 3a3b
      
      # 使用标签别名查看公钥内容
      $ git cat-file blob public_key
      public key string
      
    • 附注标签(annotated):轻量级标签对应的是引用文件,而附注标签对应的是对象模型,创建一个附注标签会在对象数据库中生成一个tag对象文件。

      附注标签的格式如下所示:

      tag <content size>\0<content data>
      

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

      object 7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb
      type commit
      tag v0.2
      tagger Why8n <Why8n@gmail.com> 1607329704 +0800
      
      Version 0.2
      

      其中:

      • object:表示当前tag对象指向的对象(通常为提交对象)。
      • type:表示object的类型。
      • tag:表示当前标签名。
      • tagger:表示打该标签的作者。
      • 最后一行是该标签的描述信息。

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

      tag

      附注标签的创建方式十分简单,只需在使用tag命令时加上-a参数:

      $ git tag -a v0.2 7f9c -m 'Version 0.2'
      

      此时会同时在.git/objects内创建一个tag对象文件和在.git/refs/tags目录内创建一个v0.2引用文件,该引用文件存储的是新生成的附注标签对象数字摘要,即v0.2指向附注标签对象。

      $ git cat-file -t v0.2
      tag
      
      $ cat .git/refs/tags/v0.2
      b9485d96cfe64ae44257fdf25348a3144f41265d
      
      $ git cat-file -t b948
      tag
      
      $ git cat-file -p b948
      object 7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb
      type commit
      tag v0.2
      tagger Why8n <Why8n@gmail.com> 1607329704 +0800
      
      Version 0.2
      

      上述例子示意图如下所示:

      annotated tag

    到这里,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命令显示的结果):

    • Untracked files:未被追踪的文件,也即没有添加到暂存区的文件。
      :此时可使用命令git add进行暂存。

    • Changes to be committed:待提交的文件,即已添加到暂存区,但未提交的文件。
      :此时可使用命令git commit进行提交。

    • Changes not staged for commit:表示内容被修改,但是未暂存。
      :此时可使用命令git add进行将修改内容进行暂存。

    • nothing to commit, working tree clean:表示工作区和暂存区内容干净,没有需要提交的内容。

    文件状态的识别就是通过查询索引文件.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
      

    参考

    相关文章

      网友评论

          本文标题:Git 内部实现原理剖析

          本文链接:https://www.haomeiwen.com/subject/jiyiwktx.html