在我撰写的 Git DevOps 文章 (msdn.com/magazine/mt767697) 中,我介绍了 Git 版本控制系统 (VCS) 与可能已经很熟悉的集中式 VCS 的区别。然后,我演示了如何在 Visual Studio 中使用 Git 工具完成一些 Git 任务。在本文中,我将汇总 Git 在新发布的 Visual Studio 2017 IDE 中的运作方式的相关变化,并介绍 Git 存储库在文件系统中的实现方式。之后,我将探究数据存储的拓扑和各种存储对象的结构和内容。最后,我将对 Git 分支进行低级别解释,以阐明我的观点,即希望大家能够理解我将在近期发表的文章中介绍的更高级 Git 操作。
注意: 在本文中,我没有使用服务器或远程方案。我探究的是纯本地方案,可以在安装了 Visual Studio 2017 和 Git for Windows (G4W) 的任何一台 Windows 计算机(已连接或未连接 Internet/网络连接)上进行操作。本文介绍了 Git 内部源代码。阅读本文的前提是,熟悉 Visual Studio Git 工具以及 Git 基本操作和概念。
Visual Studio、Git 和你
Git 不仅是指包含版本控制数据存储的存储库,也是指处理管理命令的引擎: 底层命令执行低级别操作;高层命令以类似于宏的方式捆绑底层命令,从而宏观调用,简化操作。掌握 Git 后,你会发现一些任务需要用到这些命令(我将在本文中使用其中一些命令),并且需要使用命令行接口 (CLI) 来调用这些命令。遗憾的是,Visual Studio 2017 不再安装 Git CLI,因为它使用的新 Git 引擎 MinGit 并不提供 Git CLI。与 G4W 2.10 一同发布的 MinGit(“最简 Git”)是可移植的简化功能集 API,专为需要与 Git 存储库进行交互的 Windows 应用程序而设计。G4W 乃至 MinGit 都是官方 Git 开放源代码项目的分支。也就是说,它们都继承了官方 Git 修补程序和更新程序(只要已发布),并确保 Visual Studio 可以执行同样的操作。
若要访问 Git CLI(并继续和我一起操作),建议安装完整的 G4W 程序包。虽然有其他 Git CLI/GUI 工具选项,但 G4W(作为 MinGit 的官方父级引擎)是明智之选,特别是因为它与 MinGit 共享配置文件。若要获取最新的 G4W 安装程序,请访问官方网站来源 (git-scm.com) 的“下载”部分。运行安装程序,并选中“Git Bash Here”复选框(创建 Git 命令提示符窗口)和“Git GUI Here”复选框(创建 Git GUI 窗口)。这样一来,便可以在 Windows 资源管理器中轻松右键单击文件夹,然后对当前文件夹选择这两个选项之一(Git Bash 中的“Bash”是指 Bourne Again Shell,在 G4W 的 Unix shell 中表示 Git CLI)。下一步,选择“通过 Windows 命令提示符使用 Git”,这会将环境配置为可以在 Visual Studio 程序包管理器控制台 (Windows PowerShell) 或命令提示符窗口中轻松运行 Git 命令。
如果使用我在本文中建议的选项安装 G4W(见图 1),通信路径将会在与 Git 存储库通信时生效: Visual Studio 2017 使用 MinGit API,而 PowerShell 和命令提示符会话则使用 G4W CLI,这是与 Git 存储库通信的不同通信路径。虽然 MinGit 和 G4W 是不同的通信终结点,但都派生自官方 Git 源代码,并共享配置文件。请注意,发出的高层命令会先被转换成底层命令,然后再由 CLI 进行处理。重要的是了解 Git 专家通常会(有时完全依靠)向 CLI 发出裸机 Git 底层命令,因为这样做是管理、查询和更新 Git 存储库的最直接方法,也是最低级别方法。与低级别的底层命令相比,Visual Studio IDE 公开的更高级别高层命令和 Git 操作也可以更新 Git 存储库,但具体方式始终不太清楚,特别是因为高层命令经常接受在调用时行为发生变化的选项。我得出的结论是,熟悉 Git 底层命令对充分利用 Git 功能必不可少。正因为此,我强烈建议同时安装 G4W 和 Visual Studio 2017。(若要详细了解 Git 底层命令和高层命令,请访问 git-scm.com/docs。)
图 1:MinGit API 和 Git for Windows 命令行接口的往来通信路径
低级别 Git
Visual Studio 开发者在迁移到 Git 时很自然就会试图利用 VCS 的现有知识,如 Team Foundation Server (TFS)。用于描述这两个系统中的操作的术语和概念(如签出/签入代码,合并、分支等)的确存在重叠。不过,如果因此假设类似词汇指代的基础操作也类似,是十足错误和危险的想法。这是因为分散式 Git VCS 存储和跟踪文件的方式,以及其实现熟悉的版本控制功能的方式是根本不同的。简单来说,在迁移到 Git 时,最佳做法可能是完全忘掉关于集中式 VCS 所掌握的一切知识,然后重新开始学习。
在处理受 Git 源代码管理的 Visual Studio 项目时,典型的编辑/分段处理/提交流程如下: 根据需要,在项目中添加、编辑和删除(以下统称为“更改”)文件。完成后,先对部分或所有这些更改进行分段处理,然后再将文件提交到存储库中。提交后,这些更改就会变成存储库完整透明的修订记录的一部分。现在,让我们来了解一下 Git 是如何在内部管理此流程的每一步的。
有向无环图:在后台,每次提交最终成为 Git 托管的有向无环图(图论用语为“DAG”)上的顶点(节点)。DAG 代表 Git 存储库,每个顶点代表称为“提交对象”的数据元素(见图 2)。DAG 中的顶点与称为“边”的线相连;按照惯例,将 DAG 边绘制为箭头,这样可以表示父/子关系(头指向父顶点;尾指向子顶点)。原始顶点表示存储库的首次提交;终端顶点没有子顶点。DAG 边表示所连每个顶点之间的确切父子关系。由于 Git 提交对象(简称为“提交”)表示为顶点,因此 Git 可以利用 DAG 结构,对所有提交之间的父子关系进行建模,这样 Git 便能够生成从任意一次提交向后追溯到存储库初始提交的修订记录。此外,与线性图不同的是,DAG 支持分支(一个父顶点有多个子顶点)和合并(一个子顶点有多个父顶点)。每当提交对象生成一个新的子顶点,便会生成 Git 分支;每当多个提交对象合并成一个子顶点时,便会发生合并。
图 2:显示顶点、边、头、尾、原始顶点和终端顶点的有向无环图;3 个分支(A、B 和 C);2 个分支事件(在 A4 处);1 个合并事件(B3 和 A5 在 A6 处合并)
我已经非常详尽地介绍了 DAG 及其相关术语,因为此类知识是了解高级 Git 操作的先决条件,掌握这些知识的具体方式往往为管理 Git DAG 上的顶点。此外,DAG 有助于直观呈现 Git 存储库,广泛用于教学资料、演示和 Git GUI 工具。
Git 对象概览:到目前为止,我只提到了 Git 提交对象。不过,实际上,Git 在存储库中存储以下四种不同类型的对象:提交、树、blob 和标记。若要调查以上每种类型,请启动 Visual Studio(我使用的是 Visual Studio 2017,但支持 Git 的旧版的运作方式也类似),然后使用“文件 | 新建项目”新建一个控制台应用程序。命名项目,选中“新建 Git 存储库”复选框,然后单击“确定”。(如果之前没有在 Visual Studio 中配置过 Git,将会看到“Git 用户信息”对话框。如果看到,请指定你的姓名和电子邮件地址,每次提交时此类信息都会写入 Git 存储库。此外,若要对计算机上的每个 Git 存储库使用此类信息,请选中“设置全局 .gitconfig”复选框。)
完成后,打开“解决方案资源管理器”窗口(见图 3 中的标记 1)。可以看到,文件旁边显示有淡蓝色锁形图标,尽管我还没有进行过提交! (此示例表明,Visual Studio 有时可能会对存储库执行非预期操作。) 若要确切了解 Visual Studio 执行的操作,请查看当前分支的修订记录。
图 3:新建的 Visual Studio 项目及其 Git 存储库修订记录报告
Git 将默认分支命名为“master”,并使之成为当前分支。Visual Studio 在状态栏的右边缘显示当前分支的名称(标记 2)。当前分支表示 DAG 上将成为下一次提交的父级的提交对象(稍后将会详细介绍分支)。若要查看当前分支的提交修订记录,请单击主分支标签(标记 2),然后选择菜单中的“查看修订记录”(标记 3)。
随即出现的“修订记录 - 主分支”窗口在多列中显示信息。左侧(标记4)是 DAG 上的两个顶点;每个顶点均经过图形处理,在 Git DAG 上表示一次提交。“ID”、“作者”、“日期”和“消息”列(标记 5)显示每次提交的详细信息。主分支的 HEAD 以深红色指针(标记 6)表示,我将在本文快结束时全面讲解这其中的含义。此 HEAD 标记了当提交在 DAG 中添加了新顶点后下一个边箭头的头位置。
报告显示 Visual Studio 进行了两次提交,每次提交都有自己的提交 ID(标记 7)。第一次(最早)提交由 ID a759f283 进行唯一标识;第二次提交则由 bfeb0957 进行唯一标识。这些值截取自包含 40 个字符的完整十六进制安全哈希算法 1 (SHA-1)。SHA-1 是一种加密哈希函数,旨在通过获取消息(如提交数据)并创建消息摘要(即完整的 SHA-1 哈希值,如提交 ID)来检测是否有损坏。简单来说,SHA-1 哈希算法的行为不仅类似于校验和,还类似于 GUID,因为有大约 1.46 x 1048 个唯一组合。与其他许多 Git 工具一样,Visual Studio 仅显示完整值的前 8 个字符,因为有 43 亿个惟一值,足以在日常工作中避免冲突发生。若要查看完整的 SHA-1 值,请将鼠标悬停在“修订记录报告”(标记 8)中的行之上。
虽然“查看修订记录报告”的消息列会指明每个提交的声明用途(由提交者在提交过程中提供),但毕竟只是注释而已。若要查看提交的实际更改,请右键单击列表中的行,然后选择“查看提交详细信息”(见图 4)。
第一次提交(标记 1)包含两个更改:.gitignore 和 .gitattributes(我在上一篇文章中介绍过这些文件)。 每个文件旁边的“[添加]”表明文件是被添加到存储库中。第二次提交(标记 2)不仅显示添加了 5 个文件,还将父提交对象的 ID 显示为可单击链接。若要将完整的 SHA-1 值复制到剪贴板中,只需单击“操作”菜单,然后选择“复制提交 ID”即可。
在文件系统中实现 Git 存储库:若要查看 Git 如何在存储库中存储这些文件,请右键单击解决方案资源管理器中的解决方案(而不是项目),然后选择文件资源管理器中的“打开文件夹”。在解决方案的根目录下,可以看到 .git 隐藏文件夹(如果看不到 .git,请单击文件资源管理器“视图”菜单中的“已隐藏项”)。.git 文件夹是项目的 Git 存储库。它的 objects 文件夹定义了 DAG: 所有 DAG 顶点以及所有顶点之间的全部父子关系都是通过文件进行编码,这些文件表示存储库中从原始顶点开始的每次提交(再次见图 2)。.git 文件夹的 HEAD 文件和 refs 文件夹定义了分支。让我们来深入了解一下这些 .git 项。
探索 Git 对象
.git\objects 文件夹存储所有类型的 Git 对象:提交(对于提交)、树(对于文件夹)、blob(对于二进制文件)和标记(易记的提交对象别名)。
提交对象:现在,是时候启动 Git CLI 了。可以使用常用的任意工具(Git Bash、PowerShell 或命令窗口)。我将使用 PowerShell。首先,转到解决方案根目录的 .git\objects 文件夹,然后列出其内容(图 5 中的标记 1)。可以看到,它包含许多以两个字符的十六进制值命名的文件夹。为了避免超出操作系统允许的文件夹内含文件数量,Git 将从所有 40 个字节的 SHA-1 值中删除的前两个字符用作文件夹名称,然后使用剩下的 38 个字符作为要存储的对象的文件名。举例来说,我项目中第一次提交的提交 ID 为 a759f283,因此对象所在文件夹的名称为 a7(ID 的前两个字符)。与预期一样,当我打开此文件夹时,看到了名为 59f283 的文件。请注意,这些以十六进制命名的文件夹中存储的所有文件都是 Git 对象。为节省空间,Git 使用 zlib 压缩对象存储中的文件。由于这种压缩会生成二进制文件,因此无法使用文本编辑器来查看这些文件。相反,需要调用 Git 命令,从而正确解压缩 Git 对象数据,并使用能够理解的格式来呈现数据。
我已知道文件 59f283 包含一个提交对象,因为这是提交 ID。但有时会在 objects 文件夹中看到不知道是什么的文件。Git 提供 cat-file 底层命令来报告对象类型以及所含内容(标记 3)。若要获取类型,请在调用命令时指定 -t(类型)选项,以及 Git 对象文件名的几个惟一字符:
git cat-file -t a759f2
在我的系统中,此命令报告的值为“commit”,表明以 a759f2 开头的文件包含提交对象。虽然仅指定 SHA-1 哈希值的前 5 个字符通常就足够了,但也可以根据需要提供任意数量的字符(不要忘记添加文件夹名称中的两个字符)。使用 -p(优质打印)选项发出同一命令后,Git 会从提交对象提取信息,然后以清晰明了的格式呈现这些信息(标记 4)。
提交对象包含以下属性: 父提交 ID、树 ID、作者姓名、作者电子邮件地址、作者提交时间戳、提交者姓名、提交者电子邮件地址、提交者提交时间戳和提交消息(存储库中的第一次提交不显示父提交 ID)。每个提交对象的 SHA-1 都是根据这些提交对象属性中包含的所有值计算得出,这实际上保证了每个提交对象都有一个惟一提交 ID。
树和 blob 对象:请注意,尽管提交对象包含提交的相关信息,但并不包含任何文件或文件夹。相反,包含的是指向 Git 树对象的树 ID(也是 SHA-1 值)。树对象和其他所有 Git 对象都存储在 .git\objects 文件夹中。
图 6 展示了每个提交对象包含的根树对象。根树对象进而根据需要映射到 blob 对象(接下来我将介绍)和其他树对象。
由于我的项目中的第二次提交(提交 ID 为 bfeb09)包括文件和文件夹(见上面的图 4),因此我将用它来说明树对象的工作方式。图 7 中的标记 1 展示了 cat‑file ‑p bfeb09 输出。这一次,请注意,其中包含可正确引用第一个提交对象的 SHA-1 值的父属性。(请注意,此为提交对象的父引用,以便 Git 能够构造和维护提交 DAG。)
根树对象进而根据需要映射到 blob 对象(使用 zlib 压缩的文件)和其他树对象。
提交 bfeb09 包含 ID 为 ca853d 的树属性。图 7 中的标记 2 展示了 cat-file -p ca853d 输出。每个树对象包含与对象的 POSIX 权限掩码(040000 = 目录、100644 = 常规不可执行文件、100664 = 常规不可执行组可写文件、100755 = 常规可执行文件、120000 = 符号链接和 160000 = Gitlink)对应的权限属性、类型(树或 blob)、SHA-1(对于树或 blob)和名称。名称是文件夹名称(对于树对象)或文件名(对于 blob 对象)。观察发现,此树对象由 3 个 blob 对象和另一个树对象组成。可以看到,这 3 个 blob 分别指的是文件 .gitattributes、.gitignore 和 DemoConsole.sln,而树指的是文件夹 DemoConsoleApp(图 7 中的标记 3)。尽管树对象 ca853d 与项目的第二次提交相关联,但它的前两个 blob 表示第一次提交时添加的文件 .gitattributes 和 .gitignore(见图 4 中的标记 1)! 这些文件之所以会出现在第二次提交的树中是因为,每次提交表示的是上一个提交对象,以及当前提交对象捕获的更改。若要更深入地“遍历树”,请参阅图 7 中的标记 3,其中展示了 cat-file -p a763da 输出,包含另外 3 个 blob(App.config、emoConsoleApp.csproj 和 Program.cs)和另一个树(文件夹属性)。
blob 对象也是直接使用 zlib 进行压缩的文件。如果未压缩的文件包含文本,可以使用相同的 cat-file 命令和 blob ID 提取 blob 的全部内容(图 7 中的标记 5)。由于 blob 对象表示的是文件,因此 Git 使用 SHA-1 blob ID来确定文件是否自上次提交后发生变化;还使用 SHA-1 值对存储库中的任意两次提交进行差异对比。
标记对象:鉴于 SHA-1 值的加密字母数字性,沟通起来可能有点难。使用标记对象,可以为任何提交、树或 blob 对象分配易记名称,尽管最常见的做法是只标记提交对象。标记对象的类型分为以下两种:轻量级和注释。这两种类型的对象都作为 .git\refs\tags 文件夹中的文件显示(其中,标记名称就是文件名)。轻量级标记文件的内容是现有提交、树或 blob 对象的 SHA-1。注释标记文件的内容是与其他所有 Git 对象一同存储在 .git\objects 文件夹中的标记对象的 SHA-1。若要查看标记对象的内容,可以使用相同的 cat-file -p 命令。可以看到标记对象的 SHA-1 值,以及对象类型、标记作者、日期时间和标记消息。在 Visual Studio 中,可以通过许多种方法来标记提交。一种方法是单击“提交详细信息”窗口(见上面图 3 中的标记 3)中的“创建标记”链接。“提交详细信息”窗口(见上面图 3 中的标记 3)和“查看修订记录报告”(见上面图 3 中的标记 9)中显示了标记名称。
向存储库中的对象应用存储优化时,Git 会在 .git\objects 文件夹中填充信息和包文件夹。我将在近期发表的文章中更全面地介绍这些文件夹和 Git 文件存储优化。
了解这 4 种类型的 Git 对象后,我发现可以将 Git 称为“内容可寻址的文件系统”,因为任意数量文件和文件夹中的任何类型内容都可以简化成一个 SHA-1 值。稍后,可以使用相应的 SHA-1 值,准确可靠地重新创建同一内容。从另一个角度来说,在惯常的密钥索引驱动查找表的高级实现中,SHA-1 是键,内容是值。此外,如果文件内容在两次提交之间没有发生变化,Git 可以节省开支,因为未发生变化的文件生成的 SHA-1 值相同。也就是说,提交对象可以引用上一次提交使用的相同 SHA-1 blob 或树 ID 值,而无需新建任何对象,即无需新建文件副本!
分支
必须先了解 Git 是如何在内部定义分支的,才能真正理解什么是 Git 分支。总的来说,这归结为理解以下两个关键词的用途:头和 HEAD。
第一个关键词“头”(英文为全部字母小写)是 Git 为每个新建的提交对象维护的引用。为了阐明具体工作方式,图 8 展示了多个提交和分支操作。对于提交 01,Git 为存储库创建了第一个头引用,并将其默认命名为“master”(master 是没有任何特殊含义的任意名称,只是一个默认名称而已,Git 团队经常会重命名此引用)。新建头引用后,Git 会在 ref\heads 文件夹中创建一个文本文件,并将新提交对象的完整 SHA‑1 置于此文件中。对于提交 01,也就是说,Git 会创建一个名为“master”的文件,并将提交对象 A1 的 SHA-1 置于此文件中。对于提交 02,Git 会删除旧 SHA-1 值,并将其替换成 A2 的完整 SHA-1 提交 ID,从而更新 heads 文件夹中的 master 头文件。Git 会对提交 03 执行相同操作: 它会将 heads 文件夹中的 master 头文件更新为包含 A3 的完整提交 ID。
图 8:2 个头好过 1 个头:Git 在 heads 文件夹中维护各种文件以及一个 HEAD 文件
大家可能已经猜到,heads 文件夹中的 master 文件就是它指向的提交对象的分支名称。奇怪的是,分支名称也许最初指向一个提交对象,而不是一系列提交对象(我很快就会详细介绍这一特定概念)。
请观察图 8 中的“创建分支和签出文件”部分。其中,用户在 Visual Studio 中为打印预览功能新建了一个分支。用户将此分支命名为 feat_print_preview,让其以 master 为依据,然后在团队资源管理器的“从选定项创建本地分支”窗格中选中了“签出分支”复选框。选中此复选框即指示 Git 要让新分支成为当前分支(我很快就会对此进行解释)。在后台,Git 在 heads 文件夹中新建了一个 feat_print_preview 头文件,并将提交对象 A3 的 SHA-1 值置于其中。也就是说,现在 heads 文件夹中包含以下两个文件:master 和 feat_print_preview。这两个文件都指向 A3。
在提交 04 中,Git 需要做出一项决定: 通常情况下,它会更新 heads 文件夹中文件引用的 SHA-1 值。而现在,此文件夹中有两个文件引用,该更新哪个文件引用呢? 此时,HEAD 就派上用场了。HEAD(所有字母大写)是 .git 文件夹根目录下的一个文件,指向 heads 文件夹中的头(英文为全部字母小写)文件。(请注意,“HEAD”实际上就是一个一直被命名为 HEAD 的文件,而“头”文件则没有特定的名称。) 头文件 HEAD 包含将分配为下一个提交对象的父 ID 的提交 ID。实际上,HEAD 标记的是 Git 当前在 DAG 上的位置。也就是说,可能有很多头,但始终只有一个 HEAD。
再回到图 8,提交 01 显示 HEAD 指向 master 头文件,进而指向 A1(也就是说,master 头文件包含提交对象 A1 的 SHA-1)。在提交 02 中,Git 不需要对 HEAD 文件执行任何操作,因为 HEAD 已经指向文件 master。提交 03 同上。不过,在“创建和签出新分支”步骤中,用户创建了一个分支,并通过选中“签出分支”复选框签出了分支文件。作为响应,Git 将 HEAD 更新为指向 feat_print_preview 头文件,而不是 master。(如果用户没有选中“签出分支”复选框,那么 HEAD 会继续指向 master。)
了解 HEAD 后,现在可以看到提交 04 不再需要 Git 做出任何决定: Git 只需检查 HEAD 的值,发现它指向的是 feat_print_preview 头文件。然后,便确定必须将 feat_print_preview 头文件中的 SHA-1 更新为包含 B1 的提交 ID。
在“签出分支”步骤中,用户访问了团队资源管理器的“分支”窗格,右键单击了“主分支”,然后选择了“签出”。作为响应,Git 签出提交 A3 的文件,并将 HEAD 文件更新为指向 master 头文件。
此时,应该非常清楚为什么 Git 中的分支操作如此高效快速: 新建分支可以归结为创建一个文本文件(头文件)和更新另一个文本文件 (HEAD)。切换分支只涉及更新一个文本文件 (HEAD),造成的性能影响通常很小,因为工作目录中的文件是从存储库进行更新。
请注意,提交对象不包含任何分支信息! 实际上,仅通过 HEAD 文件和 heads 文件夹中用作引用的各种文件来维护分支。然而,使用 Git 的开发者在谈到分支或引用分支时,口语上通常指的是源自 master 或新组建的分支的一系列提交对象。上面的图 2 展示了许多开发者可以确定的三个分支: A、B 和 C。分支 A 从 A1 一直到 A6。A4 处的分支活动生成了两个新分支: B1 和 C1。因此,可以将从 B1 一直到 B3 的提交称为分支 B,而将从 C1 一直到 C2 的提交称为分支 C。
这里得出的结论是,不要忘记 Git 分支的形式定义: 就是指向提交对象的指针。此外,Git 维护所有分支(称为“头”)的分支指针和当前分支(称为“HEAD”)的一个分支指针。
Jonathan Waldman 是一名 Microsoft 认证专家,专攻软件工效学,从 Microsoft 技术诞生之际便一直研究这些技术。Waldman 是 Pluralsight 技术团队的成员,目前负责机构和私企软件开发项目。可以通过 [email protected] 与他联系。
原文地址:https://msdn.microsoft.com/en-us/magazine/mt809117
.NET社区新闻,深度好文,微信中搜索dotNET跨平台或扫描二维码关注