[TOC]
前言
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 switch
和git 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 的『对象数据库』。该目录下存储的数据类型有blob
、tree
、commit
和tag
这四类对象模型,具体内容请参考后文: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
文件夹有很多条目,但最重要的条目为:HEAD
、index
、objects
和refs
,这几个条目共同完成了 Git 的数据模型,换句话说,借助这几个条目,就可以实现 Git 的版本控制功能。
Git 引用(References)
从前文内容可以知道,Git 本地版本库存在两种引用文件:refs
和HEAD
,其中:
-
refs
:该目录主要用于保存一些引用文件(分支、远程仓库和标签等)。默认会创建两个文件夹:heads
和tags
,其中,heads
用于存储本地分支信息,每当创建一个新分支,该文件夹下就会创建一个同名文件,其内容为该分支的最新提交的数字摘要值(SHA-1)。tags
与heads
目录功能类似,只是只有当创建标签时,才会在该文件夹下创建相应的同名标签引用文件,其内容为标签指向的提交 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 内置了四种对象模型,分别为blob
、tree
、commit
和tag
,它们都存储在.git/objects
目录中,这四种对象具备固定的格式:
<tag> <content size>\0<content data>
<tag> <content size>\0
称为对象头(header
),其中:
-
tag
:表示对象类型,其可选值有:blob
、tree
、commit
和tag
。 -
content size
:表示文件内容大小,以十进制表示。 -
\0
:表示 ascii 码的NUL
字符。 -
content data
:表示文件内容,具体内容取决于对象类型。
当内容要被追踪时(git add
),Git 会进行如下操作:
依据内容相关信息拼接出上述格式字符串。
然后对该格式字符串进行 SHA-1 计算,得出 40 位字符串摘要值。
对格式字符串使用
zlib.deflate()
方法进行压缩,得到压缩内容。最后将摘要的前两位作为对象文件存储目录名,后 38 位作为文件名,将压缩内容存储到
.git/objects
目录中。
注:理论上,.git/objects
目录下可存在00~ff
共 256 个摘要文件夹。
下面,具体介绍下 Git 的四种对象模型。
blob
blob
可以认为是文件类型的对象模型,当我们要追踪某个文件时,首先需要将该文件添加到暂存区中,此时 Git 就会生成该文件的一个blob
对象。
blob
对象的格式如下所示:
blob <content size>\0<content data>
blob
对象格式大致示意图如下所示:
注:示意图将\0
换成\n
,为了更直观展示。
举个例子:创建一个本地版本库,并添加一个文件到暂存区中,查看下版本库变化:
$ 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
对象。验证过程如下所示:-
前面我们通过
git add
命令添加新文件到暂存区,从而生成相应对象模型,这些命令都是 Git 提供的上层命令(Porcelain),是我们日常操作经常使用的,但 Git 同时也提供了一些底层命令(Plumbing),可以让我们直接生成blob
等对象,如下所示:$ echo -n '222' | git hash-object -w --stdin 6dd90d24d319b452859920bf74120405fcdaa017
其中:
-
--stdin
:表示git hash-object
从标准流中读取数据,否则读取的是文件。 -
-w
:表示将内容写输入对象数据库中,即生成相应文件到.git/objects
中。不加该选项,则只会显示数字摘要。
-
-
此时查看下
.git/objects
:$ tree .git/objects .git/objects ├── 6d │ └── d90d24d319b452859920bf74120405fcdaa017 ├── 9d │ └── 07aa0df55c353e18eea6f1b401946b5dad7bce ├── info └── pack 4 directories, 2 files
可以看到,一个新的文件生成了:
.git/objects/6d/d90d24d319b452859920bf74120405fcdaa017
-
可以通过如下命令查看新文件的对象类型:
$ git cat-file -t 6dd9 blob
可以看到,新文件是一个
blob
对象 -
可以通过如下命令查看对象模型数据:
$ git cat-file -p 6dd9 222%
的确是我们写入的数据。
-
假设我们再次写入相同的数据,查看下版本库变化:
$ 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
文件的摘要。
注:sha1
在entry
中是以字节形式进行存储,不是以十六进制字符串(应该是为了减小文件大小),因此如果解压该文件,直接显示可能出现乱码,可以通过以下命令输出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
或blob
对象信息,所以一个项目文件其实就是一个根tree
,项目文件内被追踪的子文件夹和文件就是根tree
的树枝结点(子tree
)和叶子结点(blob
),一个根tree
就是项目一个时间点上的全量快照。
tree
的树形结构示意图如下所示:
当tree
内某个文件内容修改并暂存时,我们知道,此时 Git 对象数据库(即.git/objects
)会生成一个新的blob
对象文件,但是当前tree
对象并不会更改其叶子结点指向新生成的blob
对象,因为在 Git 中,tree
对象的实现是一棵『默克尔树(Merkle Tree)』,默克尔树是一类基于哈希值的二叉树或多叉树,其每个结点都存储一个哈希值,其中,叶子结点通常是数据块的哈希值,树枝结点的值是其所有孩子结点组合结果的哈希值,因此,默克尔树的一个特性就是当孩子结点数据变化时,会导致其父节点哈希值变化,进而一层层往上传递,直至根结点哈希值变化。因此,当tree
对象内的某个文件内容修改后,会最终触发导致生成一个新的tree
对象,该tree
对象就是当前目录的最新快照。比如,假设上图1.txt
内容被修改并提交了该变化,则此时,整棵树的变化过程如下图所示:
注:对于未修改的文件或文件夹,新生成的tree
会复用这些文件对应的blob
或tree
对象。
tree
对象文件的生成过程是当我们提交的文件存在于项目子目录时,Git 就会为该子目录创建一个tree
,该tree
对象文件存储了其目录下所有被追踪的文件及子文件夹相关信息。示例如下所示:
-
创建一个新仓库
$ git init demo02 && cd demo02 Initialized empty Git repository in /mnt/e/code/temp/demo/demo02/.git/
-
在项目内创建一个子目录
$ mkdir subdir
-
在该子目录下创建一个新文件
$ echo -n '111' > subdir/1.txt
-
暂存所有改变
$ git add . $ tree .git/objects .git/objects ├── 9d │ └── 07aa0df55c353e18eea6f1b401946b5dad7bce ├── info └── pack 3 directories, 1 file $ git cat-file -t 9d07 blob
可以看到,暂存子目录文件,只会生成对应文件的
blob
对象。 -
提交暂存区内容:
$ 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
对象:
-
首先,创建一个新仓库
$ git init demo03 && cd demo03 Initialized empty Git repository in /mnt/e/code/temp/demo03/.git/
-
在 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
-
为索引文件添加
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
:表示直接插入相关信息到索引文件中。
-
-
上述操作其实我们已经完成了索引文件
.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
对象:b0fa
和f184
,与我们上述的例子一摸一样。
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
的关键就是将其绑定到一个tree
对象中,通常我们都是使用git commit
创建一个commit
对象,此时 Git 会根据暂存区内容生成一个项目根tree
,然后将该commit
绑定到该tree
上,完成一个版本快照。这里为了方便,直接使用 Git 提供的底层命令git commit-tree
来创建commit
对象,完整来阐述 Git 实现一个版本快照的底层过程,如下例子所示:
-
创建一个新的本地仓库:
$ git init demo04 && cd demo04 Initialized empty Git repository in /mnt/e/code/temp/demo04/.git/
-
模拟生成一个文件:
$ echo '111' | git hash-object --stdin -w 58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c
-
将文件添加进暂存区:
$ git update-index --add --cacheinfo 100644 58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c 1.txt
-
生成
tree
对象文件:$ git write-tree 58736bb5bad915b7619ddc90e0043fe3a7bc967b
-
创建一个
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
信息。 -
虽然我们已经生成了一个
commit
对象,但此时还无法使用git log
查看提交历史,因为新仓库还未指定分支信息:$ git update-ref refs/heads/master '7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb'
refs/heads/master
文件存在就表示存在master
分支,将该文件内容设置为要指向的commit
对象数字摘要即可。 -
每次使用 Git 命令时,都需要知道当前所在分支,这个信息写在
HEAD
文件中:$ git symbolic-ref HEAD refs/heads/master
注:这步骤其实可以忽略,因为 Git 默认就设置了
HEAD
指向master
分支。 -
此时,就可以使用
git log
查看历史提交信息了:$ git log commit 7f9ca74ca22bb0f70fc1ba31a1dddbd73dade9bb (HEAD -> master) Author: Why8n <Why8n@gmail.com> Date: Mon Dec 7 09:35:55 2020 +0800 1st commit
-
继续添加第二个提交:
# 重命名 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
-
此时还需要更新
master
分支到最新提交:$ git update-ref refs/heads/master '0980ef464c6f2a05d9cbfbff00add4134409747c'
-
此时再次查看历史提交信息,就可以看到多条提交日志了:
$ 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 add
和git 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
的别名。最后,之所以称为轻量级标签,是因为它就只是创建了一个引用文件而已。
上述示例的完整示意图如下所示:
注: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
命令时加上-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
上述例子示意图如下所示:
-
到这里,Git 对象模型相关内容已介绍完毕。最后在简单阐述一下:
在 Git 中,.git/objects
目录也被称为对象数据库,其存储被追踪内容的对象模型。
对象模型总共有四种:blob
、tree
、commit
和tag
,其中,blog
存储对象文件内容,tree
存储文件夹相关信息,commit
表示一个版本快照,存储了版本快照相关信息,快照具体内容由其绑定的tree
对象存储,版本的历史记录由其parent
字段维护,tag
一般用作某个commit
的别名,方便引用该commit
。
所有对象模型只关注其内容,依据内容进行 SHA-1 计算得出数字摘要值作为对象文件名称,也即是说,给定一个数字摘要,就可以获取到一个唯一的对象文件(假设该文件存在),Git 具备的这种键值对象存储索引特性,本质上是一个『内容寻址文件系统(content-addressable filesystem)』。
分支原理
Git 中,分支的实现主要借助其『引用机制』,其实我们前面内容已经涵盖分支实现原理,这里再将关键过程的实现原理捋一遍。
分支实现主要涉及如下几个问题:
分支创建:在 Git 中,可以使用
git branch <branch_name>
来创建一个新分支,其底层实现原理其实就是在.git/refs
目录下创建一个引用文件,文件名与分支名相同,但是会根据分支类别,创建在不同的子目录中,比如,对于本地分支创建,则在.git/refs/heads
目录中创建同名文件,对于远程分支目录,则在.git/refs/remotes
中创建引用文件,对于标签创建,则在.git/refs/tags
目录中创建同名文件。分支内容:当创建新分支时,会将创建分支时的最新提交的数字摘要设置为新分支文件内容,这样就将新分支与某个版本快照绑定到一起。
如果在当前分支创建新提交或执行回退操作,则 Git 会将此时的提交数字摘要设置到分支文件中,确保分支永远指向最新提交。-
确定当前分支:每次执行 Git 命令时,一个最基础的操作就是确定当前分支,这样才能索取到正确的内容。当前分支可以从
HEAD
符号链接引用文件中查询得到,每次当我们进行分支切换时,Git 会自动更新HEAD
文件内容,确保其始终指向当前分支。注:从这里可以看出,如果说分支的本质是指针(或引用),那么
HEAD
就是指向指针的指针,因为HEAD
的含义是表示当前分支,而分支是指针,其指向一个具体提交,所以HEAD
最终表示的就是操作当前分支的最新提交。 分支历史版本记录:在不同的分支中,可能存在不同的历史记录,不同分支维持各自历史记录的方式其实很简单,每个分支都对应一个引用文件,该引用文件的内容为某个特定提交的数字摘要(SHA-1),这样每个分支就各自对应一个
commit
。所以分支其实就是指向一个commit
,而历史记录已存储在该commit
对象之中。
以上,就基本实现分支功能了。
举个例子:比如现在我们想查询提交信息,于是执行git log
命令,此时,我们模拟一下 Git 的操作逻辑,步骤如下所示:
-
首先,
git log
命令是要查询当前分支提交信息,那么第一步就是要确定当前分支:$ cat .git/HEAD ref: refs/heads/master
-
查找到当前分支后,就可以确定当前分支的最新提交:
$ cat .git/refs/heads/master cb448bb7fc3b2a135995c35302e2772533ea5579 $ git cat-file -t cb44 commit
-
找到当前分支的最新提交后,进行展示,此时显示的是最新的记录:
$ 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
-
然后根据提交的
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 的实现与之相反,具体来说,有如下区别:
- Git 每次提交时,只要追踪文件内容修改,提交的都是全量更新内容。
- Git 支持缓存功能,对于文件的修改,可多次进行暂存,每次暂存都会生成一个全量更新的
blob
对象,因此对象数据库中保存了每次修改的内容,相对于传统的版本控制系统只会保存最终提交修改的内容,Git 缓存了每次修改的内容,因此可随时回退到某个修改历史版本,不会导致某次修改内容丢失。
我们使用暂存区最直观的感受就是可以多次暂存修改文件,直至修改满意再进行提交,但实际上,暂存区的作用远远不止于此,简单来说,暂存区主要有如下三个作用:
具备生成唯一
tree
对象相关信息:暂存区支持添加文件追踪,支持多次修改被追踪的文件,并且记录了所有被追踪文件的相关信息,提交时会根据暂存区记录的文件生成相应的tree
对象,最终生成一个最新提交commit
。
注:此时该最新commit
追踪的内容就是当前暂存区的内容,使用git diff --cached
可以看到没有返回任何信息,说明暂存区和版本库没有差异。具备差异比较功能:暂存区缓存了被追踪文件的最新相关信息,支持快速比较同一文件与工作区或版本库之间的差别。
具备分支合并功能:当进行分支合并时,会将相关分支所有被追踪文件按路径进行比对,然后合并相同文件内容,遇到冲突时,会自动尝试解决冲突,无法解决时,记录冲突内容,停止合并,交由开发者解决冲突。
下面主要介绍暂存区 差异比较 和 分支合并 功能:
差异比较
暂存区的本质其实就是一个二进制文件:.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),只列举与文件状态识别相关的信息进行讲解,介绍其实现原理,具体如下:
-
由于被追踪的文件存在于工作区、暂存区和版本库中,所以同一文件内容可能在这三个工作区域有差别,在
index
文件中,对于同一个文件,其设置了几个状态量来记录各个区域该文件的相关信息:-
mtime
:表示被追踪文件最后一次更新的时间。 -
file
:表示被追踪文件名称。 -
wdir
:表示被追踪文件工作区的版本,即工作区文件的数字摘要。 -
stage
:表示被追踪文件暂存区的版本。 -
repo
:表示被追踪文件版本库的版本。
-
-
当切换分支时,Git 会做如下三件事:
- 首先将
HEAD
指针更新到切换分支中。 - 更新
index
文件,使其内容与切换分支最新提交的状态相同。具体来说,切换分支时,Git 会先清空暂存区内容,然后找到切换分支最新提交,获取其对应的tree
对象,遍历该tree
对象,找到所有的blob
对象,将其相关信息记录到暂存区中。 - 根据此时暂存区内容重置工作区,即将工作区重置为切换分支最新提交状态。
举个例子:比如现在我们本地有一个仓库,假设该仓库有一个分支
dev
,且该分支下被追踪的文件有1.txt
和2.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
分支最新提交状态,如下图所示:可以看到,分支切换完成后,三个工作区域的文件状态都相同,如果此时我们修改
1.txt
文件内容,则工作区文件状态会发送变化,如下图所示:可以看到,对工作区文件进行修改,只会影响工作区文件状态,不会影响其他区域该文件状态,而如果此时我们执行
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 会做如下两件事:- 将工作区文件状态更新到
index
文件中,如下图所示:
注:
git status
只更新计算工作区文件摘要,但不会生成对应的blob
文件,只有在git add
时,才会生成最新内容的blob
对象模型。- 判断
wdir
、stage
和repo
三者版本区别,进而确定文件状态。
对于我们上述的例子,此时,Git 判断到暂存区中1.txt
的wdir
和stage
版本不同,说明工作区进行了修改,但未暂存,因此此时文件的状态即为:Changes not staged for commit
。如下图所示:
然后我们就可以使用
git add 1.txt
将工作区修改内容添加到暂存区中,此时,.git/objects
会生成一个1.txt
的全量快照blob
对象文件,并且同时会更新index
文件索引版本,如下图所示:如果此时我们执行
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
,同理,出现这种状态的原因是,wdir
和stage
版本相同,说明工作区和暂存区内容一致,而stage
和repo
版本不一致,说明暂存内容未提交。如下图所示:最后,我们可以使用
git commit
将暂存区内容进行提交,Git 会做如下三件事:- 创建
commit
对象和tree
对象。 - 将
dev
分支移动到最新提交上。 - 更新
index
文件信息。
如下图所示:
此时,三个工作区域的内容就完全一致了:
$ git status On branch dev nothing to commit, working tree clean
- 首先将
分支合并
最简单的分支合并就是两路分支合并,也就是合并两个commit
,其本质是合并两个commit
对应的根tree
对象,按正常思路来思考,只需同时依次遍历这两棵根tree
,找到所有的叶子结点(被追踪的所有文件),合并文件路径相同的叶子结点即可。这种做法的思路是正确的,但是存在一个问题,如果出现无法自动解决的冲突,则需要将相关的文件版本信息展示给用户查看,因此需要一个地方存储这些冲突信息,这个地方就是暂存区。
这里我们以例子驱动介绍暂存区对于分支合并的原理:
-
创建一个本地仓库:
$ git init demo05 && cd demo05 Initialized empty Git repository in /mnt/e/code/temp/demo05/.git/
-
工作区写入内容,并进行提交:
$ 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
分支指向afd9
的commit
对象。 -
创建并切换到新分支
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
分支指向14d1
的commit
对象。 -
切换回
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
-
在
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
时,表示存在合并冲突,此时无法进行提交操作,必须等待用户手动解决该冲突,重新进行暂存并提交。 -
-
手动解决冲突:
# 查看冲突文件 $ cat 1.txt 111 <<<<<<< HEAD 333 ======= 222 >>>>>>> dev # 修改冲突文件 $ echo '444' > 1.txt
我们可以从上一步合并冲突信息中找到冲突的文件,手动打开并进行修改,也可以使用
git mergetool
命令来唤起合并工具,自动打开冲突文件,然后进行修改。 -
解决完冲突后,需要将修改完的文件再次进行暂存:
$ git add 1.txt
-
此时再次查看暂存区内容:
$ git ls-files -s 100644 1e6fd033863540bfb9eadf22019a6b4b3de7d07a 0 1.txt 100644 116c7ee1423b9a469b3b0e122952cdedc3ed28fc 0 subdir/2.txt
可以看到,所有条目暂存编号都为
0
了,表示不存在冲突,此时就可以进行提交或继续分支合并步骤。 -
继续执行分支合并:
$ git merge --continue [master 8ca2cfe] Merge branch 'dev'
-
查看合并历史:
$ 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 中,对于版本控制,其将存储的数据类型抽象为四种数据对象模型:blob
、tree
、commit
和tag
,分别表示对文件内容的抽象,对文件夹的抽象,对版本提交的抽象,对标签的抽象,这四种对象模型很好的支撑了版本控制功能,简洁且高效。
所有的对象模型都有统一的格式进行描述存储,但不同的对象模型其具体内容有所差别,但其本质都是对内容的抽象,其具体实现为:任意的对象模型都对应为一个文件,文件名为其内容(实际上是其格式)的数字摘要,文件内容就是对象模型的具体内容(实际上是其格式)。
可以看到,对于所有的对象模型,我们通过其数字摘要,就可索引到其内容,所以 Git 的对象模型就是一个简单的键值对数据库(key-value data store),由键可以索引到其内容,因此,Git 也是一个 『内容寻址文件系统』。
最后,通常在使用 Git 过程中,分支是绝对会使用到的特性,分支被称为 Git 中一个杀手级特性,因为相比于其他版本控制系统,Git 的分支功能特别高效,原因在于:在 Git 中,分支的本质就是一个引用,具体实现就是用一个文件记录对应的commit
摘要,文件名就是分支名,文件内容就是指向的提交。
所以在 Git 中,对分支的操作其实就是对文件的写入与读取,比如,创建新分支,其实就是向一个文件写入 41个字节 数据(数字摘要+换行),切换分支其实就是对一个文件读取 41个字节 数据,这些都是非常轻量级操作,也因此,Git 中的分支性能特别高效。