添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

DevOps - Git 内部结构:体系结构和索引文件

作者 Jonathan Waldman | 2017 年 8 月

在上一篇文章 ( msdn.com/magazine/mt809117 ) 中,我介绍过 Git 如何使用有向无环图 (DAG) 来组织存储库的提交对象。此外,我还研究过提交对象可以指代的 blob、树和标记对象。在这篇文章的最后,我还介绍了分支,包括 HEAD 和 head 的区别。阅读本文前必须先阅读这篇文章,因为本文将介绍 Git“三树”体系结构及其索引文件的重要性。进一步了解这些 Git 内部结构将有助于积累基础知识,提高 Git 用户的工作效率,并有助于用户在研究各种基于 Visual Studio IDE 图形 Git 工具的 Git 操作时获取新见解。

回顾一下,上一篇文章介绍了,Visual Studio 使用 Git API 与 Git 进行通信,以及 Visual Studio IDE Git 工具简化并取代了基础 Git 引擎的功能。对于要实现版本控制工作流而不依赖 Git 命令行接口 (CLI) 的开发者来说,这是一大福音。哎,但 IDE 的实用 Git 抽象有时可能会引起混乱。以下面的基本工作流为例:将项目添加到 Git 源控件,修改并暂存项目文件,再提交暂存文件。为此,需要打开“团队资源管理器 - 更改”窗格来查看更改后文件列表,再选择要暂存的文件。请注意图 1 中的最左侧图像,其中显示了我在工作目录中更改的两个文件(标记 1)。

图 1:“团队资源管理器 - 更改”窗格可以在“更改”和“暂存更改”部分中显示相同的文件

在下一张靠右的图像中,我暂存了其中一个更改后文件:Program.cs(标记 2)。当我这样做时,Program.cs 似乎已从“更改”列表中“移动”到了“暂存更改”列表中。如果我进一步修改,并在工作目录中保存 Program.cs 副本,那么此文件会继续出现在“暂存更改”部分中(标记 3),但同时也会出现在“更改”部分中(标记 4)! 如果不了解 Git 的幕后运行机制,可能会感到困惑,解疑释惑的关键在于有两个 Program.cs“副本”:一个位于工作文件夹中,另一个位于对象的 Git 内部数据库中。即使发现这一点,可能也无法知道在取消暂存副本、尝试暂存 Program.cs 的第二个更改副本、撤消对工作副本的更改或切换分支时会发生些什么。

若要真正了解 Git 在暂存、取消暂存、撤消、提交和签出文件时所做的工作,必须先了解 Git 的体系结构。

Git 三树体系结构

Git 实现的是三树体系结构(在此上下文中,“树”指的是目录结构和文件)。在图 2(Git 三树体系结构利用非常重要的索引文件实现智能化和高效性能)中从左向右看,第一棵树是工作目录(包含隐藏 .git 文件夹的 OS 目录)中的文件和文件夹集合;第二棵树通常存储在.git 文件夹根目录中一个名为 index 的二进制文件中;第三棵树由代表 DAG 的 Git 对象组成(回顾一下,名为 SHA-1 的 Git 对象位于 .git\objects 中用两个十六进制数命名的文件夹内,也可以存储在 .git\objects\pack 的“包”文件中,以及 .git\objects\info\alternates 文件定义的文件路径中)。请注意,Git 存储库是由 .git 文件夹中的所有文件进行定义。人们通常将 DAG 称为“Git 存储库”,但这并不太准确:因为索引和 DAG 都包含在 Git 存储库中。

图 2:Git 三树体系结构利用非常重要的索引文件实现智能化和高效性能

请注意,虽然每颗树都存储目录结构和文件,但它们利用的数据结构不同,以便可以保留树专属元数据,并优化存储和检索。第一棵树(工作目录树,也称为“工作树”)显然是 OS 文件和文件夹(除了 OS 级数据结构外,没有其他任何特殊数据结构),满足软件开发者和 Visual Studio 的需求;第二颗树(Git 索引)跨越工作目录和组成 DAG 的提交对象,从而帮助 Git 快速执行工作目录文件内容比较和快速提交;第三棵树 (DAG) 让 Git 能够跟踪可靠历史版本控制系统,同时 Git 还能向其存储在索引和提交对象中的项添加有用的元数据。例如,在索引中存储的元数据有助于检测工作目录中的文件更改,而在提交对象中存储的元数据则有助于跟踪提交签发者和签发原因。

本段内容是为了回顾三树体系结构中的三棵树,并引出本文剩余部分重点介绍的主题:已了解工作目录树的运行方式,因为实际上就是已精通使用的 OS 文件系统。如果阅读过我的上一篇文章,应该已非常了解 DAG 的运行方式。那么,此时,缺少的环节就是跨工作目录和 DAG 的索引树(下称“索引”)。实际上,索引的作用非常重要,将是本文剩余部分的唯一主题。

索引的工作原理

可能已听说过下面善意的意见:索引是“暂存区域”的代名词。 虽然这样的表述多少有些准确,但却掩盖了索引的真正作用:不仅支持暂存区域,还便于 Git 检测工作目录中的文件更改;协调分支合并过程,以便能够逐个文件解决冲突,并能随时安全地中止合并;将暂存文件和文件夹转换为树对象,这些对象的引用会被写入下一个提交对象。Git 还使用索引来保留工作树中文件的相关信息,以及从 DAG 中检索到的对象的相关信息,从而进一步将索引用作一种缓存。我们将更为全面地研究一下索引。

索引实现自己的独立式文件系统,从而能够存储对文件夹和文件的引用,以及关于文件夹和文件的元数据。Git 如何以及何时更新此索引取决于所发出的 Git 命令类型和指定的命令选项(若要试一试,可以使用 Git 更新索引底层命令来自行管理索引),因此这里无法详尽无遗地进行介绍。不过,使用 Visual Studio Git 工具时,不妨留意一下 Git 更新索引以及使用索引中存储信息的主要方式。图 3 展示了 Git 如何在用户暂存文件时在索引中更新工作目录数据,以及如何在用户启动合并(若有合并冲突)、执行克隆/拉取或切换分支时在索引中更新 DAG 数据。另一方面,Git 依赖索引中存储的信息,在用户签发提交后更新 DAG,并在用户执行克隆/拉取或切换分支后更新工作目录。意识到 Git 依赖索引,并且索引跨越多个 Git 操作后,便会开始重视用于修改索引的高级 Git 命令,从而能够有效地巧妙处理 Git 操作。

图 3:更新索引的主要 Git 操作(绿色)和依赖索引所含信息的 Git 操作(红色)

我们将在工作目录中新建一个文件,看看此文件被写入索引时会发生些什么。在用户暂存此文件后,Git 会立即使用以下字符串串联公式创建标头:

blob{space}{file-length in bytes}{null-termination character}

然后,Git 将标头串联到文件内容开头。因此,对于包含字符串“Hello”的文本文件,将标头与文件内容串联后生成如下字符串(请注意,字母“H”前面有一个空字符):

blob 5Hello

为了更加明确化,下面展示了此字符串的十六进制版本:

62 6C 6F 62 20 35 00 48 65 6C 6C 6F

然后,Git 计算此字符串的 SHA-1:

5ab2f8a4323abafb10abb68657d9d39f1a775057

接下来,Git 检查现有索引,以确定此文件夹\文件名的条目是否已存在且包含相同的 SHA-1。如果有,Git 会在 .git\objects 文件夹中找到此 blob 对象,并更新它的修改日期时间(Git 绝不会覆盖存储库中的现有对象;而是更新上次修改日期,以便延迟这一新添加的对象被视为垃圾遭到回收的时间)。如果没有,Git 会使用 SHA-1 字符串的前两个字符作为 .git\objects 中的目录名称,并使用剩下的 38 个字符命名 blob 文件,再对此文件进行 zlib 压缩并编写其内容。在我的示例中,Git 会在 .git\objects 中创建名为 5a 的文件夹,再将 blob 对象作为文件 b2f8a4323abafb10abb68657d9d39f1a775057 写入此文件夹。

当 Git 以这种方式创建 blob 对象时,大家可能会感到惊讶,因为 blob 对象中明显缺少一个预期的文件属性:文件名! 然而,这是有意而为之。回顾一下,Git 是内容可寻址文件系统,因此它管理的是名为 SHA-1 的 blob 对象,而不是文件。每个 blob 对象通常是由至少一个树对象引用,反过来树对象通常是由提交对象引用。最终,Git 树对象表示暂存的文件的文件夹结构。不过,在用户签发提交之前,Git 不会创建这些树对象。因此,可以得出下列结论:如果 Git 仅使用索引来准备提交对象,还必须为索引中的每个 blob 捕获文件路径引用,而它正是这样做的。其实,即使两个 blob 的 SHA-1 值相同,只要每个映射到不同的文件名或不同的路径/文件值,都将显示为索引中的单独条目。

Git 还将文件元数据(如文件的创建日期和修改日期)与写入索引的每个 blob 对象一起保存。Git 利用此类信息通过比较文件日期和使用启发,有效地检测工作目录中的文件更改,而不是采用重新计算工作目录中每个文件的 SHA-1 值这种蛮力做法。此类策略可以加快“团队资源管理器 - 更改”窗格中显示信息的速度,或发出高层 Git 状态命令后显示信息的速度。

有了工作目录文件对应的索引条目及其相关元数据后,Git 据说就可以“跟踪”文件了,因为它可以很容易地将文件副本与工作目录中保留的副本进行比较。从技术角度来讲,被跟踪的文件也存在于工作目录中,并包含在下一次提交中。这与未受跟踪的文件相反,未受跟踪的文件分为以下两种类型:位于工作目录中但不位于索引中的文件,以及显式指定为不受跟踪的文件(见“索引扩展”部分)。总而言之,借助索引,Git 可以确定跟踪、不跟踪以及不得跟踪哪些文件。

为了更好地理解索引的具体内容,让我们来看看一个具体示例,从新的 Visual Studio 项目入手。此项目是否复杂并不太重要,只需几个文件就可以充分说明正在发生什么。新建名为 MSDNConsoleApp 的控制台应用程序,并选中“创建解决方案的目录”和“新建 Git 存储库”复选框。单击“确定”,创建解决方案。

稍后我将发出一些 Git 命令。因此,若要在系统上运行这些命令,请在工作目录中打开命令提示符窗口,并确保可以在继续操作时使用此窗口。一种为特定 Git 存储库快速打开 Git 命令窗口的方法是,访问“Visual Studio Team”菜单,并选择“管理连接”。此时,将看到本地 Git 存储库列表,以及相应存储库的工作目录路径。右键单击存储库名称,并选择“打开命令提示符”,以启动可用于输入 Git CLI 命令的窗口。

创建解决方案后,立即打开“团队资源管理器 - 分支”窗格(图 4 中的标记 1),以确定 Git 是否创建了名为“主分支”的默认分支(标记 2)。右键单击“主分支”(标记 2),并选择“查看历史记录”(标记 3),以查看 Visual Studio 代表用户创建的两个提交对象(标记 4)。第一个对象包含提交消息“添加 .gitignore 和 .gitattributes”;第二个对象包含提交消息“添加项目文件”。

图 4:查看历史记录以确定 Visual Studio 在用户新建项目时所做的工作

打开“团队资源管理器 - 更改”窗格。Visual Studio 依赖 Git API 在此窗口中填充项,因为它是 Visual Studio 版 Git 状态命令。当前,此窗口指明工作目录中没有取消暂存的更改。为了做出此决定,Git 将每个索引条目与各个工作目录文件进行比较。有了索引的文件条目和相关的文件元数据后,Git 就有了所需的全部信息,可以确定用户是否进行了任何更改、添加、删除,或是否重命名了工作目录中的任何文件(不包括 .gitignore 文件中提到的任何文件)。

因此,在方便 Git 智能化确定工作目录树与 HEAD 指向的提交对象的差异方面,索引起到了关键作用。若要详细了解索引提供给 Git 引擎的信息种类,请转到前面打开的命令行窗口,并发出以下底层命令:

git ls-files --stage

可以随时发出此命令,从而生成索引中当前包含的文件的完整列表。在我的系统上,此命令的输出如下:

100644 1ff0c423042b46cb1d617b81efb715defbe8054d 0       .gitattributes
100644 3c4efe206bd0e7230ad0ae8396a3c883c8207906 0       .gitignore
100644 f18cc2fac0bc0e4aa9c5e8655ed63fa33563ab1d 0       MSDNConsoleApp.sln
100644 88fa4027bda397de6bf19f0940e5dd6026c877f9 0       MSDNConsoleApp/App.config
100644 d837dc8996b727d6f6d2c4e788dc9857b840148a 0       MSDNConsoleApp/MSDNConsoleApp.csproj
100644 27e0d58c613432852eab6b9e693d67e5c6d7aba7 0       MSDNConsoleApp/Program.cs
100644 785cfad3244d5e16842f4cf8313c8a75e64adc38 0       MSDNConsoleApp/Properties/AssemblyInfo.cs

输出的第一列是八进制的 Unix OS 文件模式。不过,Git 并不支持全部文件模式值。可能只会看到 100644(对于非 EXE 文件)和 100755(对于基于 Unix 的 EXE 文件,适用于 Windows 的 Git 也对可执行文件类型使用 100644)。第二列是文件的 SHA-1 值。第三列是文件的合并暂存值,0 表示没有冲突,1、2 或 3 表示有合并冲突。最后,请注意,七个 blob 对象的路径和文件名全都存储在索引中。Git 使用路径值在下一次提交之前生成树对象(稍后将详细介绍)。

现在,让我们来研究一下索引文件本身。由于它是二进制文件,因此我将使用 HexEdit 4(hexedit.com 提供的免费软件十六进制编辑器)查看文件内容(图 5 摘录了部分内容)。

图 5:项目的 Git 索引文件的十六进制转储

图 6:Git 索引标头数据格式

索引文件 - 标头条目 00 - 03
(4 字节) 用于目录缓存条目的固定标头。
所有索引文件的开头都是此条目。 04 - 07
(4 字节) Version 索引版本号(适用于 Windows 的 Git
当前使用版本 2)。 08 - 11
(4 字节) 作为 4 字节的值,索引最多支持
4,294,967,296 个条目!

索引的前 12 个字节包含标头(见图 6)。前 4 个字节始终包含字符 DIRC(“目录缓存”的缩写),这是 Git 索引通常被称为“缓存”的原因之一。接下来的 4 个字节包含索引版本号,默认为版本 2,除非要使用 Git 的特定功能(如稀疏签出)。在这种情况下,可以设置为版本 3 或 4。最后 4 个字节包含索引进一步包含的文件条目数。

12 字节标头后面是 n 个索引项的列表,其中 n 与索引标头描述的条目数一致。图 7 展示了每个索引条目的格式。Git 根据路径/文件名字段按升序排列索引条目。

图 7:Git 索引文件 - 索引条目数据格式

2 个字节 (从高到低位)1 位:假设有效/假设未更改标志;1 位:扩展标志(如果版本低于 3,必须为 0;如果为 1,在路径\文件名前面附加 2 个字节);2 位:合并暂存;12 位:路径\文件名长度(如果小于 0xFFF) 2 个字节
(版本 3
或更高版本)
(从高到低位)
1 位:日后使用
1 位:跳过工作树标志(稀疏签出)
1 位:有意添加标志 (git add -N)
13 位:未使用,必须为零 长度不固定 路径/文件名 以空字符结尾

第一个 8 字节表示文件创建时间(与 1970 年 1 月 1 日 00:00:00 之间相隔的秒数)。第二个 8 字节表示文件修改时间(与 1970 年 1 月 1 日 00:00:00 之间相隔的秒数)。接下来是五个与主机 OS 相关的文件属性元数据的 4 字节值(设备、inode、模式、用户 ID 和组 ID)。唯一仅限 Windows 的值是模式,值通常是八进制的 100644,我在前面介绍 ls-files 命令输出时提到过(这会转换为 4 字节 814AH 值,如图 5 中的 26H 位置所示)。

元数据后面是 4 字节的文件内容长度。在图 5 中,此值从 030 行的 00 00 0A 15(十进制为 2,581)开始,我的系统上的 .gitattributes 文件长度为:

05/08/2017  09:24 PM    <DIR>          .
05/08/2017  09:24 PM    <DIR>          ..
05/08/2017  09:24 PM             2,581 .gitattributes
05/08/2017  09:24 PM             4,565 .gitignore
05/08/2017  09:24 PM    <DIR>          MSDNConsoleApp
05/08/2017  09:24 PM             1,009 MSDNConsoleApp.sln
               3 File(s)          8,155 bytes
               3 Dir(s)  92,069,982,208 bytes free

偏移 034H 是 blob 对象的 20 字节 SHA-1 值:

1ff0c423042b46cb1d617b81efb715defbe8054d.

请注意,此 SHA-1 指向 blob 对象,其中包含相关文件 (.gitattributes) 的文件内容。

048H 是 2 字节值,包含两个 1 位标志、2 位合并暂存值,以及当前索引条目的路径/文件名的 12 位长度。在这两个 1 位标志中,高位指定索引条目是否设置了假设未更改标志(通常使用 Git 更新索引底层命令完成);低位指定是否在路径\文件名条目之前附加两字节的数据(只有当索引版本不小于 3 时,此位才会是 1)。接下来的 2 位保留介于 0 到 3 之间的合并暂存值,如前所述。12 位值包含路径\文件名字符串的长度。

如果设置了扩展标志,那么 2 字节值包含跳过工作树标志和有意添加位标志,以及填充占位符。

最后,长度不固定的字节序列包含路径\文件名。此值以一个或多个空字符结尾。空字符结尾后面是索引中的下一个 blob 对象,或一个或多个索引扩展条目(我很快将会介绍)。

我在前面提到过,在用户提交暂存内容前,Git 不会生成树对象。也就是说,索引最初只包含路径/文件名,以及对 blob 对象的引用。不过,只要用户签发提交,Git 就会将索引更新为包含对在上次提交期间创建的树对象的引用。如果在下一次提交期间这些目录引用仍位于工作目录中,那么可以使用缓存的树对象引用,以减少 Git 需要在下一次提交期间完成的工作量。可以看到,索引的作用涉及许多方面,正因为此,它被描述为索引、暂存区域和缓存。

图 7 展示的索引条目只支持 blob 对象引用。Git 使用扩展,以便可以存储树对象。

索引可以包含扩展条目,这些条目存储特殊化数据流,为 Git 引擎提供其他信息,以供它在监视工作目录中的文件和准备下一次提交时参考。为了缓存在上次提交期间创建的树对象,Git 将树扩展对象添加到工作目录的根目录的索引,以及每个子目录的索引。

图 5**** 中的标记 2 展示了索引的最终字节,并捕获索引中存储的树对象。图 8**** 展示了树扩展数据的格式。

图 8:Git 索引文件树扩展对象数据格式

在偏移 284H 处出现的树扩展数据标头包含字符串“TREE”(标记了缓存的树扩展数据的开头),后跟 32 位值(表示后面的扩展数据的长度)。接下来是各个树条目:第一个条目是树路径的以 NULL 结尾的长度不固定字符串值(或直接为 NUL,如果是根树的话)。后跟 ASCII 值,因此十六进制编辑器中显示“7”,即当前树覆盖的 blob 条目数(因为这是根树,条目数与先前在签发 Git ls-files 暂存命令时看到的条目数相同)。下一个字符是空格,后又跟一个 ASCII 数字,表示当前树的子树数量。

我们项目的根树只有 1 个子树:MSDNConsoleApp。此值后面是换行符和树的 SHA-1 值。SHA-1 从偏移 291 处的 0d21e2 开始。

我们将确认 0d21e2 是否就是根树的 SHA-1。为此,请转到命令窗口并输入:

git log

这将显示与最近提交相关的详细信息:

commit 5192391e9f907eeb47aa38d1c6a3a4ea78e33564
Author: Jonathan Waldman <jonathan.waldman@live.com>
Date:   Mon May 8 21:24:15 2017 -0500
  Add project files.
commit dc0d3343fa24e912f08bc18aaa6f664a4a020079
Author: Jonathan Waldman <jonathan.waldman@live.com>
Date:   Mon May 8 21:24:07 2017 -0500
  Add .gitignore and .gitattributes.

最近一次提交的时间戳为 21:24:15,所以就是上次更新索引的提交。我可以使用此提交的 SHA-1 查找根树的 SHA-1 值:

git cat-file -p 51923

输出如下:

tree 0d21e2f7f760f77ead2cb85cc128efb13f56401d
parent dc0d3343fa24e912f08bc18aaa6f664a4a020079
author Jonathan Waldman <jonathan.waldman@live.com> 1494296655 -0500
committer Jonathan Waldman <jonathan.waldman@live.com> 1494296655 -0500

以上树条目就是根树对象。据此可以确认,索引转储中偏移 291H 处的 0d21e2 值实际上就是根树对象的 SHA-1 值。

SHA-1 值后面为其他树条目(从偏移 2A5H 开始)。若要确认根树下缓存的树对象的 SHA-1 值,请运行以下命令:

git ls-tree -r -d master

这仅以递归方式显示当前分支上的树对象:

040000 tree c7c367f2d5688dddc25e59525cc6b8efd0df914d    MSDNConsoleApp
040000 tree 2723ceb04eda3051abf913782fadeebc97e0123c    MSDNConsoleApp/Properties

第一列中的模式值 040000 表明此对象是目录,而不是文件。

最后,索引的最后 20 个字节包含表示索引本身的 SHA-1 哈希值:就跟预期一样,Git 使用此 SHA-1 值来验证索引的数据完整性。

虽然我介绍了本文中示例索引文件的所有条目,但更大、更复杂的索引文件成为一种规范。索引文件格式支持附加的扩展数据流,例如:

  • 支持合并操作和合并冲突解决方法的数据流。它的签名为“REUC”(用于解决撤消冲突)。
  • 用于维护未受跟踪的文件(这些是未包含在跟踪范围内的文件,在 .gitignore 和 .git\info\exclude 中指定,并由 core.excludesfile 指向的文件指定)的缓存的数据流。它的签名为“UNTR”。
  • 支持拆分索引模式的数据流,以便加快非常大的索引文件的索引更新速度。它的签名为“link”。
  • 借助索引的扩展功能,可以继续添加索引功能。

    本文回顾了 Git 三树体系结构,并深入详细地探讨了索引文件的幕后运行机制。我介绍了 Git 为响应特定操作而更新索引,并依赖索引包含的信息,以便执行其他操作。

    可以在不太考虑索引的情况下使用 Git。然而,了解索引可以获取对 Git 核心功能的宝贵见解,同时了解 Git 如何检测工作目录中的文件更改、什么是暂存区域及其非常有用的原因、Git 如何管理合并以及 Git 执行特定操作如此快速的原因。此外,还可以轻松了解签出命令和变基命令的命令行变体,以及软重置、混合重置与硬重置的区别。通过此类功能,可以指定应在发出特定命令时更新索引、工作目录还是两者都更新。了解 Git 工作流、策略和高级操作时,将会看到此类选项。本文旨在让读者认识到索引起到的重要作用,从而可以更好地了解具体利用方式。

    Jonathan Waldman 是一名 Microsoft 认证专家,专攻软件工效学,从 Microsoft 技术诞生之际便一直研究这些技术。Waldman 是 Pluralsight 技术团队的成员,目前负责机构和私营部分的软件开发项目。可以通过 jonathan.waldman@live.com 与他联系。

    衷心感谢以下 Microsoft 技术专家对本文的审阅:Kraig Brockschmidt、Saeed Noursalehi、Ralph Squillace 和 Edward Thomson

    在 MSDN 杂志论坛讨论这篇文章