Git 内部实现原理剖析

[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的作用就是:指示当前操作的分支

    :通常不建议直接修改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
    

    可以看到,b0fa存储subdir信息,因此b0fa就是项目根目录的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文件内容,确保其始终指向当前分支。

    :从这里可以看出,如果说分支的本质是指针(或引用),那么HEAD就是指向指针的指针,因为HEAD的含义是表示当前分支,而分支是指针,其指向一个具体提交,所以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

    git status只更新计算工作区文件摘要,但不会生成对应的blob文件,只有在git add时,才会生成最新内容的blob对象模型。

    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 的一些底层实现原理进行分析,让我们对 Git 能知其然,且知其所以然。

简单来说,Git 是当前最主流的版本控制系统,其本质是一颗 默克尔树,被追踪的文件(叶子结点)内容更改后,其对应的父目录(树枝结点)也会重新生成,循环往上,直至根目录(根结点)重新生成,最终就生成一颗全新的树,也即表示一个新版本诞生。
所有的默克尔树就构成了版本迭代历史。

Git 中,存在三个分区:工作区,暂存区和版本库。
工作区是项目源码存放地区,用于我们编辑代码,进行项目实际开发的区域。
暂存区是对文件的暂存/缓存,表示对该文件进行追踪,进行版本控制。
版本库就是迭代完成一个版本,对此时项目的一个快照。
一般而言,所有被追踪的文件都会依次经由工作区,暂存区,版本库位置迁移,最终完成文件托管。

在 Git 中,对于版本控制,其将存储的数据类型抽象为四种数据对象模型:blobtreecommittag,分别表示对文件内容的抽象,对文件夹的抽象,对版本提交的抽象,对标签的抽象,这四种对象模型很好的支撑了版本控制功能,简洁且高效。

所有的对象模型都有统一的格式进行描述存储,但不同的对象模型其具体内容有所差别,但其本质都是对内容的抽象,其具体实现为:任意的对象模型都对应为一个文件,文件名为其内容(实际上是其格式)的数字摘要,文件内容就是对象模型的具体内容(实际上是其格式)。

可以看到,对于所有的对象模型,我们通过其数字摘要,就可索引到其内容,所以 Git 的对象模型就是一个简单的键值对数据库(key-value data store),由键可以索引到其内容,因此,Git 也是一个 『内容寻址文件系统』。

最后,通常在使用 Git 过程中,分支是绝对会使用到的特性,分支被称为 Git 中一个杀手级特性,因为相比于其他版本控制系统,Git 的分支功能特别高效,原因在于:在 Git 中,分支的本质就是一个引用,具体实现就是用一个文件记录对应的commit摘要,文件名就是分支名,文件内容就是指向的提交。
所以在 Git 中,对分支的操作其实就是对文件的写入与读取,比如,创建新分支,其实就是向一个文件写入 41个字节 数据(数字摘要+换行),切换分支其实就是对一个文件读取 41个字节 数据,这些都是非常轻量级操作,也因此,Git 中的分支性能特别高效。

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • 前言 git是每一个程序员必须熟练使用的一个工具,但是在当前这个浮躁的社会,特别是正在大发展的前端领域,大家似乎只...
    DLillard0阅读 187评论 0 0
  • 1 前言 Git使用比较灵活,达到相同结果有多种方式。 靠记忆不同场景下的命令组合,会停留在“知其然,不知其所以然...
    此间有道阅读 432评论 0 0
  • Git是一个快速,可扩展的分布式版本控制系统。从根本上来说,Git是一个内容寻址(content-addressa...
    kawa007阅读 916评论 0 0
  • 前言 从工作开始就一直使用git命令, clone,checkout, branch等,但是一直不知道为什么提交代...
    violet_syls阅读 353评论 0 0
  • 0、导读 本文适合对git有过接触,但知其然不知其所以然的小伙伴,也适合想要学习git的初学者,通过这篇文章,能让...
    程序员BUG阅读 420评论 0 0