“纸上谈兵”之 Git 原理

avatar
前端工程师 @字节跳动

豆皮粉儿们,又又又见面了,今天这一期,由字节跳动数据平台的“怀玉”来讲讲“纸上谈兵”之 Git 原理。 作者:怀玉

抄过一遍,不就等于学会了么~(手动狗头)

引言

之所以对 Git 原理感兴趣,主要是因为之前在做关于什么是 Monorepo 的分享时,介绍到 Monorepo 中一般采用 trunk based development 模式管理分支,在说明这个管理模式的时候,说到了 git merge 和 git rebase 两个常见的命令,然后突然被问到,这两个命令有啥区别?当时的我也不是很明白,只是凭自己的理解说了自己的看法,这个问题一下让我对 git 的原理感了兴趣,所以今天和大家简单的聊一聊。

本次分享主要介绍 Git 是怎么存储数据的,以及我们常用的一些命令的原理是什么,不会过多介绍 Git 的底层命令。

什么是 Git

Git 是一个分布式版本控制系统,与集中式版本控制系统相比,分布式版本控制系统的客户端并不只提取最新版本的文件快照, 而是把代码仓库完整地镜像下来,包括完整的历史记录。这样带来的最大的好处就是能够不受中央服务器的限制,在本地就可以随时的进行正常的工作。

图片

                                             集中式版本控制系统

图片

                                              分布式版本控制系统

Git 存储

Git 和其它版本控制系统(包括 Subversion 和近似工具)的主要差别在于 Git 对待数据的方式。从概念上来说,其它大部分系统以文件变更列表的方式存储信息,这类系统将它们存储的信息看作是一组基本文件和每个文件随时间逐步累积的差异 (它们通常称作基于差异(delta-based)的版本控制)。\

图片

而 Git 更像是把数据看作是对小型文件系统的一系列快照。在 Git 中,每当提交更新或保存项目状态时,它基本上就会对当时的全部文件创建一个快照并保存这个快照的索引。为了效率,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。Git 对待数据更像是一个快照流。

图片

那 Git 是怎么生成和存储这些快照的呢?

Git 对象

Git 的核心部分是一个简单的键值对数据库(key-value data store)。这意味着我们可以向 Git 仓库中插入任意类型的内容,它会返回一个唯一的键,通过该键可以在任意时刻再次取回该内容。

  • 在 Git 中,有三种主要的 Git 对象——blob(数据对象)、tree(树对象)、commit(提交对象),它们最初均以单独文件的形式保存在 .git/objects 目录下。其中:
    • blob 对象存储的是文件内容(只是内容,不包括文件名),每个文件都对应一个 blob 对象;
    • tree 对象描述了整体的目录结构。一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息;
    • commit 对象主要包括一个顶层树对象,代表当前项目快照;然后是可能存在的父提交;最后是作者/提交者信息与提交注释。

举个🌰

假设我们存在如下的内容,其中 learn_git 是根目录:

图片

现在对目录中的两个文件执行 git add ,然后执行 git commit -m "first commit" 完成第一次提交,此时执行 git log 查看提交记录如下:

图片

执行 find .git/objects -type f ,可以看到当前 .git/objects 中有如下 4 个 object:

图片

这就是开始时 Git 存储内容的方式——一个对象对应一个 SHA-1 校验和。校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。使用 git cat-file -t 可以查看object的类型,使用 git cat-file -p 可以看object的内容,对上述 4 个对象分别执行这两个命令,可以得到如下信息:

图片

  • 从上面的信息中,我们可以知道这 4 个 object 分别是:
    • 2 个 blob 对象,分别对应着当前的两个文件 README.md 和 test.txt 的内容;
    • 1 个 tree 对象,存着 2 个指向 blob 对象的 SHA-1 指针,以及相应的模式、类型、文件名;
    • 1 个 commit 对象,存着代表当前项目快照的树对象的 SHA-1 指针,以及作者/提交者信息与提交注释,该 commit 对象并没有父提交信息(因为是第一个提交,所以没有父提交);

此时,这 4 个 object 的关系可以用下图描述:

图片

如果我们在项目中再新增一个 lib 文件夹,并在 lib 中新增一个 lib.js 的库文件,然后完成第二次提交,此时目录结构为:

图片

执行 git log 查看提交记录如下:

图片

执行 find .git/objects -type f ,可以看到当前 .git/objects 中变成了 8 个 object:

图片

对 8 个 object 分别执行 git cat-file -t 与 git cat-file -p,可以得到如下信息:

图片

图片

  • 从上面的信息中,我们可以发现,其中 4 个 object 是在第一次提交的时候生成的(4e7318、657cef、99ddc2、d2a249), 另外 4 个 object 分别是:
    • 1 个 blob 对象(e69de2),对应着文件 lib.js 的内容,内容为空(无内容);
    • 2 个 tree 对象,其中 1 个(f3fa1f)存着 1 个指向 blob 对象(lib.js 的内容)的 SHA-1 指针,以及相应的模式、类型、文件名;另 1 个(440ad0)存着 2 个指向 blob 对象(README.md 和 test.txt 的内容)和 1 个 tree 对象(目录 lib)的 SHA-1 指针,以及相应的模式、类型、文件名
    • 1 个 commit 对象(42c2b9),存着代表当前项目快照的树对象(440ad0)的 SHA-1 指针,父提交(第一次提交) commit 对象(4e7318)的 SHA-1 指针,以及作者/提交者信息与提交注释;

此时,这 8 个 object 的关系可以用下图描述:

图片

快照,而非差异

通过上面的例子我们可以确定,每当提交更新或保存项目状态时,Git 存储的是快照而并非差异。并且无论哪种 object,一旦创建就不会再变更,后续如果内容变更,则会创建新的 object,而不是去修改原 object。

Git 引用

在了解了 Git 存储方式后,我们知道了 commit object 是每个快照的入口,相对应的是一个 SHA-1 值,我们执行任何相关的操作时,都需要记住这个 SHA-1 值(比如通过 git log 42c2b9 查看第二次提交后的整个历史记录),这显然是很不方便的,所以我们需要一个更简单的方式。在 Git 中,这被称为“引用”(reference 或 ref),它们被存放在 .git/refs 目录下。

分支

Git 分支本质上就是一个简单的指针或引用,它指向一系列工作内容的头部。master 分支是 Git 中默认创建的一个分支,查看 .git/refs/heads/master 文件可以看到当前 master 所指向的 commit object 的 SHA-1 值。

image.png

HEAD

HEAD 文件是一个到当前所在分支的符号引用。所谓符号引用,表示它是一个指向其它引用的指针。如果查看 HEAD 文件的内容,通常我们看到类似这样的内容:

图片

当我们执行 git commit 时,该命令会创建一个提交对象,并用 HEAD 文件中那个引用所指向的 SHA-1 值设置其父提交字段。

标签

前面我们刚讨论过 Git 的三种主要的对象类型(数据对象、树对象和提交对象 ),然而实际上还有第四种。 标签对象(tag object) 非常类似于一个提交对象——它包含一个标签创建者信息、一个日期、一段注释信息,以及一个指针。主要的区别在于,标签对象通常指向一个提交对象,而不是一个树对象。它像是一个永远不变的分支引用,总是指向同一个提交对象,无非是提供了一个更友好的名称。

Git 中存在两种类型的标签:注释标签和轻量标签。我们可以使用 git tag 命令创建轻量标签,加参数 -a 创建注释标签:

图片

图片

查看 .git/refs/tags 可以看到当前所有的标签文件:

图片

分别查看两个标签文件,可以看到它们存储的内容如下:

图片

与之前的信息对比,我们可以发现:标签 v1.0 存储的是之前第一个提交时的 commit object 的 SHA-1 值;而标签 v1.1 存储的 SHA-1 值我们并没有找到,其实这个 SHA-1 值是一个 tag object 的 SHA-1 值,此时查看 .git/objects 中存放的内容如下:

图片

我们发现此时 .git/objects 中变成了 9 个 object,多的那个正是标签 v1.1 存储的 SHA-1 值。使用 cat-file 命令查看这个 object 的信息如下:

图片

可以发现,这个 tag object 存储了一个 commit object 的 SHA-1 值,以及标签创建者信息、日期、注释信息。与之前的信息对比,我们可以知道这个 commit object 就是第二次提交时创建的 commit object。

远程引用

如果你添加了一个远程版本库并对其执行过推送操作,Git 会记录下最近一次推送操作时每一个分支所对应的值,并保存在 .git/refs/remotes 目录下。例如 Nuwa 项目的远程引用如下:

图片

查看远程 master 分支的内容:

图片

远程引用和分支(位于 .git/refs/heads 目录下的引用)之间最主要的区别在于,远程引用是只读的。虽然可以 git checkout 到某个远程引用,但是 Git 并不会将 HEAD 引用指向该远程引用。

常用命令的原理

图片

Git 项目的三个工作区域的概念:Git 仓库、工作目录以及暂存区域。

git add 与 git commit

通过之前的分析,我们知道,git add 命令可以将工作目录中的修改保存至暂存区,然后 git commit 命令将暂存区中的内容提交至 Git 仓库,最终在 Git 仓库中存储的是 blob、tree、commit 这些对象。

其实,一次快照中的 blob、tree、commit 对象并不是一次生成的,在执行 git add 时,blob 对象被创建,同时 git add 命令会向暂存区写入相关信息,暂存区的信息在 .git/index 文件中记录,这是一个二进制文件。

还是以上述例子中的 learn_git 仓库为例,在执行完第二次提交后,.git/objects 与 .git/index 内容如下:

图片

图片

此时,我们增加 test2.txt 文件,然后执行 git add 命令,再查看 .git/objects 与 .git/index 内容,如下:

图片

图片

可以看到在 .git/objects 中多了一个 SHA-1 值为 5e520d 的 object,.git/index 文件的内容也发生了改动。用 cat-file 命令查看 5e520d,可以得到如下信息:

图片

执行 git commit 命令时,tree 和 commit 对象被创建,在创建的 commit 对象时,会用 .git/HEAD 文件中那个引用所指向的 SHA-1 值设置其父提交字段。至此,一个完成整的快照就生成了。

git merge

git merge 是我们常用来操作分支合并的命令,默认的 git merge 命令是 no fast-forward 模式,即不进行快进合并,它的执行过程如下图所示:

图片

Git 会使用两个分支的末端所指的快照,以及这两个分支最优的共同祖先(以此作为合并的基础),做一个简单的三方合并,将此次三方合并的结果做成一个新的快照并自动创建一个新的提交指向它,这被称作一次合并提交。它的特别之处在于它有不止一个父提交。

假设我们现在有一个项目 learn_git_merge ,目前它总共有 2 个分支, 3 次提交,具体如下:

图片

此时,.git/objects 的内容如下:

图片

然后,我们在 master 分支执行 git merge dev 将 dev 分支的提交合并到 master 分支:

图片

此时,我们所有的分支和提交关系如下图:

图片

可以看到,此时 master 分支多了一个 SHA-1 值为 db350fd 的提交,此时查看 .git/objects 的内容如下:

图片

发现在执行 git merge 后,.git/objects 中多了两个对象,使用 git cat-file 查看:

图片

我们发现,这两个对象分别是一个 tree 对象和一个 commit 对象。在执行 merge 后,目录结构发生了变化(多了一个 dev.text 文件),所以生成了新的 tree 对象,并被新创建的 commit 对象存储,在新的 commit 对象中,我们看到它存着两个父提交的 SHA-1 值,这两个 SHA-1 正是 merge 前 master 和 dev 分支所指向的 commit 对象的 SHA-1 值。(一个小问题:如果 merge 时冲突了,那解决冲突合并完后,.git/objects 有什么变化?)

git rebase

git rebase 命令被称作变基操作,它可以将提交到某一分支上的所有修改都移至另一分支上。它的原理是首先找到这两个分支(即当前分支、变基操作的目标基底分支)的最近共同祖先,然后对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件,然后将当前分支指向目标基底, 最后以此将之前另存为临时文件的修改依序应用。它的执行过程如下图所示:

640.gif

通常我们在执行完 git rebase 后,还需要切换到变基操作的目标基底分支执行 git merge 进行分支合并(此时就是进行的一次快进合并)。

假设我们现在有一个项目 learn_git_rebase ,目前它总共有 2 个分支, 3 次提交,具体如下:

image.png

此时,.git/objects 的内容如下:

图片

然后,我们在 dev 分支执行 git rebase master :

图片

此时,我们所有的分支和提交关系如下图:

图片

可以看到,此时 dev 分支指向了 SHA-1 值为 194b076 的提交,之前指向的 SHA-1值为 a30774e 的提交已经看不到了。此时查看 .git/objects 的内容如下:

图片

发现在执行 git rebase 后,之前的 SHA-1 值为 a30774e 的提交对象其实还在,同时 .git/objects 中多了两个对象,使用 git cat-file 查看:

图片

我们发现,新增两个对象分别是一个 tree 对象和一个 commit 对象。在执行 rebase 后,此时 dev 分支的 master.txt 文件内容变为了之前 master 分支第二次提交所创建的,所以需要生成新的 tree  对象,并被新创建的 commit 对象存储,在新的 commit 对象(194b076)中,我们发现和之前的 commit 对象(a30774e)相比,存储的 tree 对象、父提交、提交者信息都发生了变化,只有作者信息、提交注释没变。(一个小问题:如果 rebase 时冲突了,那解决冲突并完成变基后,.git/objects 有什么变化?)

此时,如果我们查看 master 和 dev 所指向的内容,会分别看到如下信息:

图片

让我们切换到 master 分支,执行一下 git merge dev ,然后再查看指针信息和 .git/objects 信息:

图片

我们发现,执行完 git merge dev 后,只有 .git/refs/heads/master 中的内容发生了变化(master 和 dev 分支指向了相同的 commit 对象),.git/objects 中的内容并没有变,说明此次执行的 git merge dev 是一次快进合并,只是简单的将指针向前推进(指针右移)。

其它

  • 关于 git reset 和 git stash 命令,大家可以查看这两篇文章:
    • Git Reset 三种模式 -- 简书
    • Git 操作之 Stash -- 网上文章

总结

通过上面的内容,我们目前已经基本了解了 Git 是怎么存储数据的,以及我们常用的一些命令的原理是什么。Git 除了是一个版本控制系统外,同时也是一个非常强大且易用的工具。比如我们可以利用 git hook 来编写脚本实现自定义策略,利用一些高级命令来恢复我们的一些错误操作,甚至我们还可以做一些调试和性能优化。总之,用好 Git 可以让我们的日常开发变得更加的便捷、高效。

以上内容,如有雷同,纯属我抄他 || 她 👇

参考

  1. Pro Git V2(有的章节打不开,很卡,可以看 www.progit.cn/
  1. tonybai.com/2020/04/07/…
  1. devblogs.microsoft.com/devops/pull…
  1. 特性分支开发模式 or 主干开发模式,团队该如何选择?
  1. 版本分支管理标准 - Trunk Based Development 主干开发模型
  1. git原理