精通-Git-第二版-全-
精通 Git 第二版(全)
原文:
annas-archive.org/md5/18d0759d9eb92a704e6904597c11415e
译者:飞龙
前言
Git 是最受欢迎的开源分布式版本控制系统。版本控制系统帮助软件团队管理项目源代码随时间变化的内容。在任何协作开发中使用版本控制都是必须的,即使你是单独工作,版本控制也同样有用。
掌握 Git 将帮助 Git 初学者达到专家级水平,理解 Git 的概念以及基本和高级 Git 任务背后的思维模型。使用 Git 的开发人员将能够利用其强大的功能让工作变得更加轻松。掌握 Git 将在开发过程中帮助你完成各种任务,节省时间和精力。
本书精心设计,帮助你深入了解 Git 的架构、底层概念、行为和最佳实践。
你将从一个简单的 Git 协作开发示例开始,以建立对 Git 操作任务和概念的基本理解。在你逐步阅读本书时,接下来的章节将详细描述 Git 使用的各个领域——从管理自己的工作、源代码考古,到与其他开发人员协作。你将学习如何检查和探索项目历史,创建和管理你的贡献,设置用于集中式和分布式工作流的仓库和分支,集成其他开发人员发送的工作,定制和扩展 Git,并从仓库错误中恢复。
版本控制的相关话题伴随着对 Git 架构和行为相关部分的详细描述。通过探索 Git 的高级实践并了解 Git 工作的细节,你将对其行为有更深入的理解,从而能够定制和扩展现有的做法,并编写你自己的做法。
本书适合谁
如果你是一个对 Git 有一定了解的用户,并且熟悉其基本概念,如分支、合并、暂存和工作流,那么这本书适合你。如果你已经使用 Git 很长时间,本书将帮助你理解 Git 的工作原理,充分利用其强大功能,学习高级工具、技巧和工作流。
如果你是系统管理员、项目负责人或运营经理,本书将帮助你配置 Git,以实现更好的协作开发,选择最适合团队和项目需求的工作流和分支模式。
安装 Git 和软件配置管理基本概念的知识至关重要。书中的第一章《Git 基础实践》应作为一个复习,并帮助你更新知识。本书假设你具备一定的命令行操作技能,尽管这不是严格要求的。
本书涵盖的内容
第一章,Git 基础实操,提醒读者 Git 版本控制的基础知识。重点介绍技术的实际应用,采用一个简单项目的开发作为示例。本章将展示并解释示范项目开发过程中基本的版本控制操作,以及两个开发者如何使用 Git 进行协作。
第二章,使用 Git 进行开发,展示了如何选择性地提交文件并交互式地选择提交内容。你将学习如何创建新的修订版本和新的开发线。本章介绍了提交的暂存区(索引)的概念,并解释了如何查看和读取工作目录、索引和当前修订之间的差异。还将教你如何创建、列出和切换分支,如何回溯历史,以及如何撤销更改或修正上次提交。
第三章,管理工作树,教你如何详细管理文件,以准备提交内容。将解释索引和文件状态的概念,教你如何检查工作区的状态,如何在工作树、索引和仓库之间移动文件内容,如何更改文件状态。还将展示如何管理需要特殊处理的文件,介绍忽略文件和文件属性的概念。
第四章,探索项目历史,介绍了修订版本图的概念,并解释了这一概念如何与 Git 中的分支、标签和当前分支等思想相关联。你将学习如何选择并查看某个修订版本或修订版本范围,以及如何引用这些版本。这些技能将帮助你聚焦于项目历史的特定部分,选择其中有趣的部分进行进一步的查找。
第五章,在仓库中搜索,探索如何从选定的提交中提取你所需的信息。你将学习如何根据修订元数据(如提交信息的内容)限制搜索,或者查看文件的更改。这些技能将帮助你聚焦于项目历史的特定部分,从中提取信息,查看何时以及如何发生了变化,甚至通过历史二分法找到错误。
第六章,使用 Git 进行协作开发,从全局视角展示了各种协作方式,展示了不同的集中式和分布式工作流,它们的优缺点以及如何设置这些工作流。本章将重点讨论协作开发中的仓库级别交互。你还将学习信任链的概念,以及如何使用签名标签、签名合并、签名提交和签名推送。
第七章,发布更改,探讨了 Git 如何在本地仓库和远程仓库之间交换信息和数据,描述了在传输协议方面的选择,并展示了 Git 如何帮助管理访问这些远程仓库时可能需要的凭证。本章还将教你如何将更改推送到上游,以便它们可以出现在项目的官方历史仓库中。
第八章,高级分支技术,深入探讨了分布式开发中协作的细节。它探讨了本地分支和远程仓库中的分支之间的关系,并描述了同步分支和标签的技术。你将学习使用分支时的不同模式,包括基于主干的工作流和主题分支(也叫功能分支)工作流,它们的优缺点,以及何时使用它们。
第九章,合并更改,教授你如何使用合并(merge)和变基(rebase)(以及压缩合并 squash merge)将来自不同开发分支的更改合并在一起。本章还将解释不同类型的合并冲突,如何检查这些冲突以及如何解决它们。你将学会如何使用 cherry-pick 复制更改,以及如何应用单个补丁和补丁系列。
第十章,保持历史清洁,解释了为什么你可能想保持清晰的历史,什么时候可以并且应该这样做,以及如何做到这一点。你将找到逐步的指引,教你如何重新排序、压缩和拆分提交。本章还演示了如何从历史重写中恢复,并解释了如果不能重写历史应该怎么做,如何撤销提交的影响,如何为提交添加备注,以及如何通过替换机制改变项目历史的视图。
第十一章,管理子项目,解释并展示了如何将不同项目连接到框架超级项目的单一仓库中,从通过嵌入一个项目的代码到另一个项目中的强连接(单体仓库和子树)到通过嵌套仓库(子模块和类似解决方案)实现的轻连接。
第十二章,管理大型仓库,介绍了针对大型 Git 仓库的各种解决方案,无论它们是由于历史悠久、包含大量文件,还是包含包含一些大文件的项目。
第十三章,自定义和扩展 Git,涵盖了配置和扩展 Git 以适应你的需求。在这里,你将找到如何设置命令行以便更轻松使用的详细信息,并简要介绍图形界面。本章解释了如何使用钩子自动化 Git(重点是客户端钩子)——例如,如何让 Git 检查正在创建的提交是否符合特定的编码规范。
第十四章,Git 管理,关注 Git 的管理方面。它简要介绍了服务 Git 仓库的话题。在这里,你将学习如何使用服务器端钩子进行日志记录、访问控制、强制执行开发策略等其他目的。
第十五章,Git 最佳实践,汇集了版本控制、通用和 Git 特定的建议和最佳实践。这些内容涉及如何管理工作目录、创建提交及提交系列(拉取请求)、提交更改以供合并和同行评审等问题。
为了最大限度地从本书中获益
为了跟随本书中的示例并运行提供的命令,你需要安装 Git 软件(git-scm.com/
),最好使用 2.41.0 或更高版本。Git 在每个平台上都有免费版本(如 Linux、Windows 和 macOS)。所有示例都使用文本版的 Git 界面,使用 bash shell(Git for Microsoft Windows 提供该 shell,而在默认情况下,Windows 系统没有它)。
本书涵盖的软件 | 操作系统要求 |
---|---|
Git | Windows、macOS 或 Linux |
要跟踪示例程序的开发过程,你需要参照 第一章,Git 基础实践,作为使用版本控制的示例演示,你还需要一个网页浏览器和文本编辑器(虽然推荐使用程序员编辑器或集成开发环境 IDE)。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件,网址是 github.com/PacktPublishing/Mastering-Git---Second-Edition
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还提供了来自我们丰富书籍和视频目录的其他代码包,网址是 github.com/PacktPublishing/
。快去看看吧!
使用的约定
本书中使用了多种文本约定。
文本中的代码
: 表示文本中的代码字词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 句柄。示例如下:“然后,Bob 编写了负责网页应用行为的 JavaScript 源代码(random.js
)。”
代码块的设置方式如下:
function getRandomInt(max) {
return Math.floor(Math.random() * max) + 1;
}
function generateRandom() {
let max = document.getElementById('max').value;
alert(getRandomInt(max));
}
当我们希望你关注某个代码块的特定部分时,相关的行或项目会以粗体显示:
<body>
<button disabled>Generate number</button>
<label for="max">up to</label>
<input type="number" id="max" name="rand_max" value="10" />
<div id="result"></div>
</body>
</html>
任何命令行输入或输出均按如下方式书写:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或在屏幕上显示的单词。例如,菜单或对话框中的单词通常是粗体的。以下是一个示例:“从管理面板中选择系统信息。”
提示或重要说明
显示方式如下。
与我们联系
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有问题,可以通过电子邮件联系 customercare@packtpub.com,并在邮件主题中注明书名。
勘误:虽然我们已经非常仔细地确保内容的准确性,但错误还是会发生。如果你发现本书中有任何错误,我们将非常感激你能向我们报告。请访问www.packtpub.com/support/errata并填写表单。
盗版:如果你在互联网上发现我们作品的任何非法复制品,请提供其位置地址或网站名称。请通过版权@packt.com 联系我们,并附上该材料的链接。
如果你有兴趣成为作者:如果你在某个领域拥有专业知识,并且有兴趣写作或参与编写书籍,请访问authors.packtpub.com。
分享你的想法
阅读完掌握 Git后,我们非常希望听到你的想法!请点击这里直接访问亚马逊评论页面并分享你的反馈。
你的评论对我们和技术社区非常重要,它将帮助我们确保提供优质内容。
下载这本书的免费 PDF 副本
感谢购买这本书!
你喜欢在路上阅读,但又不能随时携带纸质书籍吗?
你的电子书购买是否与你选择的设备不兼容?
不用担心,现在购买每本 Packt 书籍,你都会免费获得该书的无 DRM PDF 版本。
在任何地方、任何设备上阅读。可以从你最喜欢的技术书籍中直接搜索、复制和粘贴代码到你的应用程序中。
优惠不仅仅止步于此,你还可以独享折扣、新闻通讯和每天免费内容的电子邮件。
按照这些简单的步骤来获得优惠:
- 扫描二维码或访问下面的链接
packt.link/free-ebook/978-1-83508-607-0
-
提交你的购买证明
-
就这样!我们会直接通过电子邮件将免费 PDF 和其他福利发送给你。
第一部分 - 探索项目历史并管理自己的工作
在本部分,你将从通过一个简单示例理解使用 Git 的基础知识开始。接下来,你将学习如何使用它回答有关项目及其历史的问题。你还将学习如何检查工作树的状态、管理更改并创建良好的提交。
本部分包含以下章节:
-
第一章,Git 实践基础
-
第二章,使用 Git 进行开发
-
第三章,管理你的工作树
-
第四章,探索项目历史
-
第五章,在代码库中搜索
第一章:Git 基础实战
本书面向中级和高级 Git 用户,旨在帮助他们掌握 Git。因此,本章之后的章节假设你已经掌握了 Git 的基础,并已超越初学者阶段。
本章将作为 Git 版本控制基础的回顾。重点将放在提供该技术的实用方面,通过示例项目的开发过程以及两位开发者之间的协作,展示和解释基本的版本控制操作。
本章将涵盖以下内容:
-
版本控制和 Git 的简要介绍
-
设置 Git 环境和 Git 仓库(init 和 clone)
-
添加文件、检查状态、创建提交和检查历史记录
-
与其他 Git 仓库互动(pull 和 push)
-
创建和列出分支,切换到分支,并合并更改
-
解决一个简单的合并冲突
-
创建和发布标签
技术要求
要跟随本章所示的示例,你需要安装 Git:git-scm.com/
。你还需要一个交互式命令行工具(例如,如果你使用 MS Windows,可以使用 Git Bash),一个文本编辑器或适用于 Web 开发的 IDE(用于编辑 JavaScript 和 HTML),以及一个网页浏览器。
你可以通过以下链接访问本章中使用的示例项目的代码:github.com/PacktPublishing/Mastering-Git---Second-Edition/tree/main/chapter01
和 github.com/jnareb/Mastering-Git---Second-Edition---chapter01-sample_project
。
版本控制和 Git 的简要介绍
版本控制系统(有时称为修订控制)是一种工具,它可以让你跟踪项目文件随时间变化的历史记录和归属(存储在仓库中),并帮助团队中的开发者共同协作。现代版本控制系统为每个开发者提供自己的沙盒,防止他们的工作进展发生冲突,同时提供合并更改和同步工作的机制。它们还允许我们在不同的开发线之间切换,称为分支;这个机制使得开发者可以在不同的开发任务之间切换,例如从逐步引入新功能切换到修复项目的旧版本中的 bug。
分布式版本控制系统(如 Git)为每个开发者提供项目历史记录的副本,这被称为仓库的克隆。这就是 Git 快速的原因,因为几乎所有操作都是在本地执行的。它也是 Git 灵活的原因,因为你可以以多种方式设置仓库。面向开发的仓库还为每个开发者提供单独的工作区(或工作树),其中包含项目文件。Git 的分支模型使得本地创建分支变得便宜,从而可以通过为不同任务创建沙盒来进行上下文切换。它还使得使用非常灵活的主题分支工作流进行协作成为可能。
整个历史记录可访问的事实使得进行长期撤销成为可能,可以回到上一个工作版本,等等。自动跟踪变更的所有权使得可以找出谁对某个代码区域负责,以及每次变更是在何时完成的。你可以比较不同的版本,回到用户发送 bug 报告时的版本,甚至可以自动找出是哪一版本引入了回归 bug(使用git bisect
)。通过reflog跟踪分支的变更使得撤销和恢复变得更加简单。
Git 的一个独特特点是它允许显式访问暂存区来创建提交(新版本——即项目的新版本)。这为管理工作区和决定未来提交的形态带来了额外的灵活性。
所有这些灵活性和强大功能是有代价的。尽管学习 Git 的基本使用非常简单,但要掌握它并不容易。本书将帮助你达到这一专业水平,但让我们先从 Git 的基础知识回顾开始。
Git 示例
让我们通过一个简单的示例,逐步展示两个开发者如何使用 Git 协同工作在一个简单项目中。你可以在github.com/PacktPublishing/Mastering-Git---Second-Edition
找到本章的所有三个代码仓库(包括两个开发者的仓库和裸仓库服务器),并且可以在sample_project.zip
压缩包中查看代码、历史记录和 reflog。
跟随示例
若要在一台计算机上跟随这个团队开发流程的示例,你只需创建三个文件夹,命名为alice/、bob/和server/,并在跟随 Alice、Bob 和 Carol 的工作时切换到相应的文件夹。
为了使这个模拟工作,你需要做一些简单的修改。在作为 Carol 创建仓库时,你不需要创建并切换到/srv/git目录,因此可以直接跳过这些命令。在 Alice 或 Bob 的角色下,你需要在仓库的本地配置中创建单独的身份,方法是使用不带--user选项的git config命令,或者通过编辑适当仓库中的.git/config文件。在不存在的https://git.company.com/random仓库 URL 处,直接使用服务器仓库的路径:../server/random.git。
此外,如果你计划移动包含alice/、bob/和server/子目录的目录,你需要编辑存储在仓库配置文件中的“origin”仓库 URL,将其从绝对路径更改为相对路径——即../../server/random.git。
设置和初始化
一家公司已经开始开发一款新产品。这个产品用于计算一个随机数——一个指定范围内的整数值。
公司指派了两位开发人员来参与这个新项目,Alice 和 Bob。两位开发人员都在远程办公,连接到公司总部。经过一番讨论,他们决定将他们的产品实现为一个简单的 JavaScript 和 HTML Web 应用,并使用 Git 2.41.0 (git-scm.com) 进行版本控制。
注意
这个项目和代码仅用于演示目的,因此将大大简化。代码的细节在这里并不重要——重要的是代码如何变化,以及 Git 如何帮助开发。
仓库设置
对于一个小团队,他们决定使用下图所示的设置。
重要提示
这只是可能的一种设置,使用中心规范仓库,且没有专门的维护人员负责此仓库(在此设置中,所有开发者平等)。这并不是唯一的选择;其他配置仓库的方式将在第六章,与 Git 的协作开发中展示。
图 1.1 – 样本项目的仓库设置(使用集中式工作流)
创建 Git 仓库
Alice 通过让管理员 Carol 创建一个新的仓库,专门用于与团队共同协作来启动项目,分享工作成果:
carol@server:~$ mkdir -p /srv/git
carol@server:~$ cd /srv/git
carol@server:/srv/git$ git init --bare random.git
Initialized empty Git repository in /srv/git/random.git/
重要提示
命令行示例遵循 Unix 约定,命令提示符以user@host:directory开头,以便一眼看出是谁在执行命令,在哪台计算机上,在哪个目录中(此处,波浪号~表示用户的主目录)。这是 Linux 上通常的命令提示符设置;Git Bash 也使用类似的提示符。
你可以配置命令提示符,显示 Git 相关信息,例如仓库名称、仓库中的子目录、当前分支,甚至工作区状态(见 第十三章,Git 的定制与扩展)。
我认为服务器配置的细节对于这一章来说太多了。请想象它已经完成,并且没有出错,或者参考 第十四章,Git 管理。
你还可以使用工具来管理 Git 仓库(例如,gitolite
);在服务器上创建公共仓库的过程当然会有所不同。不过,通常这涉及在自己的主目录下使用 git init
(没有 --bare
)创建 Git 仓库,然后使用明确的 URI 推送到服务器,服务器会自动创建公共仓库。
或者该仓库可能是通过 GitHub、Bitbucket 或 GitLab 等工具的 web 界面创建的(可以是托管在云端,也可以是安装在本地的)。
克隆仓库并创建第一次提交
Bob 得知项目仓库已经准备好,他可以开始编码了。
由于这是 Bob 第一次使用 Git,他首先通过 git config --global --edit
设置了 ~/.gitconfig
文件,配置了将用于标识其提交的信息。
[user]
name = Bob Hacker
email = bob@company.com
现在,他需要获取自己的仓库实例(当前是空的):
bob@hostB:~$ git clone https://git.company.com/random
Cloning into 'random'...
warning: You appear to have cloned an empty repository.
done.
bob@hostB:~$ cd random
bob@hostB:~/random$
提示
本章中的所有示例都使用命令行界面。你也可以通过 Git GUI 或 IDE 集成来执行这些命令,具体可参考 第十三章,Git 的定制与扩展 中的 图形界面 部分。本书 Git: 面向所有人的版本控制,由 Packt Publishing 出版,展示了命令行的 GUI 对应操作。
Bob 注意到 Git 提示这是一个空的仓库,尚未有源代码,他开始编码。他打开文本编辑器(或选择的 IDE),为他的产品创建起点。
首先,他创建了一个 HTML 文件(index.html
),该文件包含为正在创建的 web 应用程序设计的最简单的界面,仅有一个按钮和一个输入框:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Random Number Generator</title>
<script src="img/random.js" defer></script>
</head>
<body>
<button disabled>Generate number</button>
<label for="max">up to</label>
<input type="number" id="max" name="rand_max" value="10" />
</body>
</html>
然后,Bob 编写了 JavaScript 源代码(random.js
),负责处理 web 应用程序的行为——在这种情况下,生成并显示一个在给定范围内(1 到可配置的最大值之间)的随机整数,包括最大值:
function getRandomInt(max) {
return Math.floor(Math.random() * max) + 1;
}
function generateRandom() {
let max = document.getElementById('max').value;
alert(getRandomInt(max));
}
let button = document.querySelector('button');
button.addEventListener('click', generateRandom);
button.disabled = false;
通常,对于大多数初步实现版本而言,这个版本缺少很多功能,但它是一个不错的起点。在提交代码之前,Bob 想确保一切看起来正常且工作正确。他在 web 浏览器中打开 index.html
文件,或使用 IDE 的实时预览功能,如 图 1.2 所示。
图 1.2 – 示例应用程序第一个版本的预览
好的!现在是时候添加两个文件到仓库中了:
bob@hostB:~/random$ git add index.html random.js
Bob 使用status
操作确保待处理的更改集(未来的提交)看起来是正确的。
我们在这里使用了git status
的简短形式,以减少示例占用的空间;你可以在本章后面找到status
输出的完整示例:
bob@hostB:~/random$ git status --short
A index.html
A random.js
现在是时候对当前版本进行提交了:
bob@hostB:~/random$ git commit -a -m "Initial implementation"
[master (root-commit) 961e72b] Initial implementation
2 files changed, 25 insertions(+)
create mode 100644 index.html
create mode 100644 random.js
重要提示
通常,你会创建一个提交信息,不是通过使用-m
git commit -a命令中的-a/--all选项意味着对所有已追踪的文件进行所有更改。这并不是创建修订版本的唯一方式;你可以将操作暂存区与创建提交分开—然而,这是一个单独的问题,留给第三章,管理 工作树。
现在是时候让这些更改对 Alice 可见了。
协作开发
版本控制系统的主要目标之一是帮助开发者在一个共同的项目中协作。使用分布式版本控制系统,如 Git,这涉及到一个明确的步骤,即发布更改以使其对其他人可见。
发布更改
在完成项目的初始版本工作后,Bob 决定它已经准备好发布(供其他开发者使用)。他通过以下方式推送更改:
bob@hostB:~/random$ git push
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 4 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 670 bytes | 22.00 KiB/s, done.
Total 4 (delta 0), reused 0 (delta 0)
To https://git.company.com/random.git
* [new branch] master -> master
提示
请注意,根据网络速度的不同,Git 在进行远程操作时,如克隆、推送和拉取,可能会显示进度信息。本书中的示例省略了这些信息,除非在检查历史和查看更改时实际讨论了这些信息。
此外,如果你使用的是旧版 Git,它可能需要设置push.default配置变量。
由于这是 Alice 第一次在她的桌面机器上使用 Git,她首先告诉 Git 如何识别她的提交:
alice@hostA:~$ git config --global user.name "Alice Developer"
alice@hostA:~$ git config --global user.email alice@company.com
现在,Alice 需要设置她自己的仓库实例:
alice@hostA:~$ git clone https://git.company.com/random
Cloning into 'random'...
done.
Alice 检查工作目录:
alice@hostA:~$ cd random
alice@hostA:~/random$ ls –alF
total 6
drwxr-xr-x 1 alice staff 0 May 2 16:44 ./
drwxr-xr-x 4 alice staff 0 May 2 16:39 ../
drwxr-xr-x 1 alice staff 0 May 2 16:39 .git/
-rw-r--r-- 1 alice staff 331 May 2 16:39 index.html
-rw-r--r-- 1 alice staff 327 May 2 16:39 random.js
提示
.git目录包含 Alice 的整个仓库副本(克隆),以 Git 内部格式存储,并包含一些仓库特定的管理信息。有关文件布局的详细信息,请参见gitrepository-layout(5)手册页,例如,可以通过git help repository-layout命令查看。
她想查看日志以查看详细信息(检查项目历史):
alice@hostA:~/random$ git log
commit 961e72b31b0d2dacc0584cbe8953c3aed1042e9b (HEAD -> master)
Author: Bob Hacker bob@company.com
Date: Sun May 2 22:34:40 2021 +0200
Initial implementation
命名修订版本
在最低级别,Git 版本标识符是一个 SHA-1 哈希值,例如,2b953b4e80。Git 支持多种引用修订版本的方式,包括明确缩短的 SHA-1(最少四个字符)—更多方式请参见第四章,探索项目历史。
当爱丽丝决定查看应用程序时,她认为使用alert()
来显示结果并不是一个好的用户界面。为了生成一个新的随机数,用户需要先关闭窗口。如果可以立即生成一个新结果,那会更好。
她决定一个更好的解决方案是将结果放到页面上,表单下方。她在index.html
中添加了一行来为此留出位置:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Random Number Generator</title>
<script src="img/random.js" defer></script>
</head>
<body>
<button disabled>Generate number</button>
<label for="max">up to</label>
<input type="number" id="max" name="rand_max" value="10" />
<div id="result"></div>
</body>
</html>
然后她将 JavaScript 代码中的alert()
替换为直接在应用页面上显示结果,仅使用新增的<div id="result"></div>
占位符:
function getRandomInt(max) {
return Math.floor(Math.random() * max) + 1;
}
function generateRandom() {
let max = document.getElementById('max').value;
let res = document.getElementById('result');
res.textContent = 'Result: ' + getRandomInt(max);
}
let button = document.querySelector('button');
button.addEventListener('click', generateRandom);
button.disabled = false;
她接着打开网页浏览器,检查它是否正常工作。她点击生成数字按钮几次,检查它是否真的生成了随机数:
图 1.3 – 爱丽丝更改后的应用程序,结果显示在页面上
一切看起来正常,于是她使用status
操作查看待处理的更改:
alice@hostA:~/random$ git status -s
M index.html
M random.js
这里没有惊讶。Git 知道index.html
和random.js
已经被修改。她想通过diff
命令重新检查实际更改:
alice@hostA:~/random$ $ git diff
diff --git a/index.html b/index.html
index 1e79bb1..3021b9d 100644
--- a/index.html
+++ b/index.html
@@ -9,5 +9,6 @@
<button disabled>Generate number</button>
<label for="max">up to</label>
<input type="number" id="max" name="rand_max" value="10" />
+<div id="result"></div>
</body>
</html>
diff --git a/random.js b/random.js
index 3533d15..b036fa1 100644
--- a/random.js
+++ b/random.js
@@ -4,7 +4,8 @@ function getRandomInt(max) {
function generateRandom() {
let max = document.getElementById('max').value;
- alert(getRandomInt(max));
+ let res = document.getElementById('result');
+ res.textContent = 'Result: ' + getRandomInt(max);
}
let button = document.querySelector('button');
现在,是时候提交更改并将它们推送到公共仓库了:
alice@hostA:~/random$ git commit -a -m "Show result on the page instead of using alert()"
[master a030d99] Show result on the page instead of using alert()
2 files changed, 14 insertions(+), 12 deletions(-)
alice@hostA:~/random$ git push
To https://git.company.com/random.git
961e72b..a030d99 master -> master
重命名和移动文件
在此过程中,鲍勃继续进行下一个任务,即稍微调整树的结构。他不希望仓库的顶层过于杂乱,所以他决定遵循目录结构的既定惯例,并将所有的 JavaScript 源代码文件移动到scripts/
子目录下:
bob@hostB:~/random$ mkdir scripts
bob@hostB:~/random$ git mv *.js scripts/
然后他检查一切是否正常工作,结果发现需要更新index.html
文件中 JavaScript 代码的路径,于是他做了这个更改:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Random Number Generator</title>
<script src="img/random.js" defer></script>
</head>
<body>
<button disabled>Generate number</button>
<label for="max">up to</label>
<input type="number" id="max" name="rand_max" value="10" />
</body>
</html>
他检查一切现在都正常,查看状态并提交更改:
bob@hostB:~/random$ git status --short
M index.html
R random.js -> scripts/random.js
bob@hostB:~/random$ git commit -a -m "Directory structure"
[master 1b58e54] Directory structure
2 files changed, 1 insertion(+), 1 deletion(-)
rename random.js => scripts/random.js (100%)
在此过程中,为了最小化重组对diff
输出的影响,他配置了 Git,使其始终使用rename
和复制检测:
bob@hostB:~/random$ git config --global diff.renames copies
然后鲍勃决定是时候为项目添加一个README.md
文件了:
bob@hostB:~/random$ git status -s
?? README.md
bob@hostB:~/random$ git add README.md
bob@hostB:~/random$ git status -s
A README.md
bob@hostB:~/random$ git commit -a -m "Added README.md"
[master 6789f76] Added README.md
1 file changed, 3 insertions(+)
create mode 100644 README.md
鲍勃决定将random.js
重命名为gen_random.js
:
bob@hostA:~/random$ git mv scripts/random.js scripts/gen_random.js
当然,这也需要更新index.html
文件:
bob@hostB:~/random$ git status -s
M index.html
R scripts/random.js -> scripts/gen_random.js
接着,他提交了这些更改。
bob@hostB:~/random$ git commit -a -m "Rename random.js to gen_random.js"
更新你的仓库(带有合并)
重组完成后,鲍勃尝试发布这些更改:
bob@hostA random$ git push
To https://git.company.com/random
! [rejected] master -> master (fetch first)
error: failed to push some refs to 'https://git.company.com/random'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
但爱丽丝同时也在工作,并且她的更改准备好提交并先行推送。Git 不允许鲍勃发布他的更改,因为爱丽丝已经向master
分支推送了内容,而 Git 在保留她的更改。
重要提示
为了简洁起见,Git 命令输出中的提示和建议将从此处开始跳过。
鲍勃使用pull
命令拉取更改(如命令输出中的hint
所述):
bob@hostB:~/random$ git pull
From https://git.company.com/random
+ 3b16f17...db23d0e master -> origin/master
Auto-merging scripts/gen_random.c
Merge made by the 'recursive' strategy.
index.html | 1 +
scripts/gen_random.js | 3 ++-
2 files changed, 3 insertions(+), 1 deletion(-)
git pull
命令拉取了更改,自动与鲍勃的更改合并,并创建了一个合并提交——打开编辑器以确认提交合并。
重要提示
从版本 2.31 开始,Git 要求用户设置pull.rebase配置变量;我们假设 Alice 和 Bob 将其设置为false。请参阅第九章,合并更改,合并更改的方法部分,了解使用合并提交和使用变基来合并更改之间的区别。
现在一切看起来都很好。合并提交完成了。显然,Git 能够直接将 Alice 的更改合并到 Bob 已经移动并重命名的文件副本中,没有任何问题。太棒了!
bob@hostB:~/random$ git show
commit df9132d4482dfd66d6d9843db205d4e775c76509 (HEAD -> master)
Merge: eabf309 a030d99
Author: Bob Hacker bob@company.com
Date: Mon May 3 02:31:23 2021 +0200
Merge branch 'master' of https://git.company.com/random
Bob 检查它是否正常工作(因为自动合并不一定意味着合并结果是正确的)。它工作得很好,他准备推送合并:
bob@hostB random$ git push
To https://git.company.com/random
a030d99..df9132d master -> master
创建标签 – 一个修订版的符号名称
Alice 和 Bob 决定该项目已经准备好进行更广泛的发布。Bob 创建了一个标签,以便他们可以更轻松地访问和引用发布的版本。他为此使用了注释标签;一个常用的替代方法是使用签名标签,其中注释包含 PGP 签名(以后可以进行验证):
bob@hostB:~/random$ git tag -a -m "random v0.1" v0.1
bob@hostB:~/random$ git tag --list
v0.1
bob@hostB:~/random$ git log -1 --oneline --decorate
df9132d (HEAD -> master, tag: v0.1, origin/master) Merge branch 'master' of https://git.company.com/random
当然,如果v0.1
标签仅存在于 Bob 的本地仓库中,它是没有帮助的。因此,他推送了刚创建的标签:
bob@hostB random$ git push origin tag v0.1
To https://git.company.com/random
* [new tag] v0.1 -> v0.1
Alice 更新她的仓库,获取v0.1
标签,并开始从最新的工作版本开始:
alice@hostA:~/random$ git pull
From https://git.company.com/random
a030d99..df9132d master -> origin/master
* [new tag] v0.1 -> v0.1
Updating a030d99..df9132d
Fast-forward
README.md | 3 +++
index.html | 2 +-
random.js => scripts/gen_random.js | 0
3 files changed, 4 insertions(+), 1 deletion(-)
create mode 100644 README.md
rename random.js => scripts/gen_random.js (100%)
解决合并冲突
Alice 决定添加一条关于随机数生成器结果将显示位置的信息:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Random Number Generator</title>
<script src="img/gen_random.js" defer></script>
</head>
<body>
<button disabled>Generate number</button>
<input type="number" id="max" name="rand_max" value="10" />
<div id="result">Result:</div>
</body>
</html>
太好了!让我们看看它是否正确工作。
图 1.4 – 添加关于结果显示位置的信息之后
很好。是时候提交更改了:
alice@hostA:~/random$ git status -s
M index.html
alice@hostA:~/random$ git commit -a -m "index.html: Show where result goes"
[master e04655f] index.html: Show where result goes
1 file changed, 1 insertion(+), 1 deletion(-)
这里没有问题。
与此同时,Bob 注意到如果在网页浏览器中禁用 JavaScript,或者使用不支持 JavaScript 的文本浏览器,则当前的网页应用程序无法正常工作,且未解释原因。通知用户这个问题会是个好主意:
bob@hostB:~/random$ git pull
Already up-to-date.
他决定添加一个<noscript>
标签,解释该应用程序需要 JavaScript 才能正常工作:
bob@hostB:~/random$ $ git status -s
M index.html
bob@hostB:~/random$ git diff
diff --git a/index.html b/index.html
index 108885f..80348b7 100644
--- a/index.html
+++ b/index.html
@@ -10,5 +10,6 @@
<label for="max">up to</label>
<input type="number" id="max" name="rand_max" value="10" />
<div id="result"></div>
+<noscript>To use this web app, please enable JavaScript.</noscript>
</body>
</html>
Bob 使用 w3m 文本浏览器检查<noscript>
是否按预期工作:
图 1.5 – 在 w3m 中测试应用程序,w3m 是一个不支持 JavaScript 的基于文本的网页浏览器
然后,他在图形化的网页浏览器(或实时预览)中检查,确认对于支持 JavaScript 的客户端没有任何变化。他准备好首先提交并推送他的更改:
bob@hostB:~/random$ git commit -a -m "Add <noscript> tag"
[master a808ecf] Add <noscript> tag
1 file changed, 1 insertion(+)
bob@hostB:~/random$ git push
To https://git.company.com/random
df9132d..a808ecf master -> master
所以,当 Alice 准备推送她的更改时,Git 拒绝了她的推送:
alice@hostA:~/random$ git push
To https://git.company.com/random
! [rejected] master -> master (non-fast-forward)
error: failed to push some refs to 'https://git.company.com/random'
啊,Bob 肯定已经推送了一个新的更改集。Alice 再次需要拉取并合并,以将 Bob 的更改与她自己的更改结合起来:
alice@hostA:~/random$ git pull
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Automatic merge failed; fix conflicts and then commit the result.
这次合并没有那么顺利。Git 无法自动合并 Alice 和 Bob 的更改。显然发生了冲突。Alice 决定在编辑器中打开 index.html
文件来检查情况(她也可以通过 git mergetool
使用图形化的合并工具):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Random Number Generator</title>
<script src="img/gen_random.js" defer></script>
</head>
<body>
<button disabled>Generate number</button>
<label for="max">up to</label>
<input type="number" id="max" name="rand_max" value="10" />
<<<<<<< HEAD
<div id="result">Result:</div>
=======
<div id="result"></div>
<noscript>To use this web app, please enable JavaScript.</noscript>
>>>>>>> a808ecfb89919fd05cf50fbf879b493c83499002
</body>
</html>
Git 已经包含了 Bob 的代码(在 <<<<<<<< HEAD
和 ========
冲突标记之间)和 Alice 的代码(在 ========
和 >>>>>>>>
之间)。我们希望最终的结果是同时包含这两个代码块。Git 无法自动合并它们,因为这两个块没有被分隔开。Alice 在 Result:
后添加的内容可以直接插入到 Bob 添加的 <noscript>
前面。解决冲突后,更改看起来是这样的:
alice@hostA:~/random$ $ git diff
diff --cc index.html
index ea1a830,80348b7..0000000
--- a/index.html
+++ b/index.html
@@@ -9,7 -9,6 +9,6 @@@
<button disabled>Generate number</button>
<label for="max">up to</label>
<input type="number" id="max" name="rand_max" value="10" />
-<div id="result"></div>
+<noscript>To use this web app, please enable JavaScript.</noscript>
+ <div id="result">Result:</div>
</body>
</html>
这应该能解决问题。Alice 在网页浏览器中刷新了 web 应用程序,检查其是否正常工作。她将冲突标记为已解决并提交了更改:
alice@hostA:~/random$ git status -s
UU index.html
alice@hostA:~/random$ git commit -a -m 'Merge: mark output + noscript'
[master 919f0f7] Merge: mark output + noscript
然后她重试推送:
alice@hostA:~/random$ git push
To https://git.company.com/random
a808ecf..919f0f7 master -> master
完成了。
批量添加文件和删除文件
Bob 决定为项目添加一个带有版权声明的 COPYRIGHT
文件。原本计划有一个 NEWS
文件(但尚未创建),于是他使用批量添加功能将所有文件添加进来:
bob@hostB:~/random$ git add -v
Nothing specified, nothing added.
Maybe you wanted to say 'git add .'?
bob@hostB:~/random$ git add -v .
add 'COPYRIGHT'
add 'COPYRIGHT~'
哎呀!由于 Bob 没有配置他的 COPYRIGHT~
,它也被捕获了(这种系统特有的模式应该放入仓库的 .git/info/exclude
文件或个人忽略文件 ~/.config/git/ignore
中,正如在 第三章 中所述,管理你的工作树,在 忽略文件 部分)。让我们删除这个文件:
bob@hostB:~/random$ git status -s
A COPYRIGHT
A COPYRIGHT~
bob@hostB:~/random$ git rm COPYRIGHT~
error: the following file has changes staged in the index:
COPYRIGHT~
(use --cached to keep the file, or -f to force removal)
bob@hostB:~/random$ git rm -f COPYRIGHT~
rm 'COPYRIGHT~'
让我们检查状态并提交更改:
bob@hostB:~/random$ git status -s
A COPYRIGHT
bob@hostB:~/random$ git commit -a -m 'Added COPYRIGHT'
[master ca3cdd6] Added COPYRIGHT
1 files changed, 2 insertions(+), 0 deletions(-)
create mode 100644 COPYRIGHT
撤销对文件的更改
有点无聊,Bob 决定他们的 web 应用程序看起来很单调,于是将 Bootstrap CSS 库(getbootstrap.com
)添加到 index.html
的头部:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
他检查了源代码更改的数量:
bob@hostB:~/random$ git diff --stat
index.html | 4 ++++
1 file changed, 4 insertions(+)
看起来一切都没问题;然而,应用程序在没有进一步修改的情况下并没有显著改善,而且现在还需要访问互联网。Bob 决定现在不是切换到 Bootstrap CSS 框架的时机,于是他撤销了对 index.html
的更改:
bob@hostB:~/random$ git status -s
M index.html
bob@hostB:~/random$ git restore index.html
bob@hostB:~/random$ git status -s
如果你不记得如何撤销某种类型的更改或更新要提交的内容(使用 git commit
而不加 -a
),那么 git status
(不加 -s
)的输出会包含有关使用哪些命令的信息。以下是一个示例:
bob@hostB:~/random$ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
(use "git push" to publish your local commits)
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: index.html
no changes added to commit (use "git add" and/or "git commit -a")
分支和合并
开发人员常常需要隔离一组特定的更改,这些更改预计在一段时间内不会准备好,从而创建另一个开发分支:即分支(branch)。通常,当该组更改准备好后,你会希望将这些分支合并,这可以通过合并操作(merge)来完成。
创建一个新分支
Alice 决定提供一个方法,让用户配置随机数选择范围的下限(当前设置为 1
),也就是说,让生成的数字的最小值和最大值都能配置。
她需要在 index.html
文件中添加一个新输入。Alice 还注意到需要调整输入的标签:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Random Number Generator</title>
<script src="img/gen_random.js" defer></script>
</head>
<body>
<button disabled>Generate number</button>
<label for="min">between</label>
<input type="number" id="min" name="rand_min" value="1" />
<label for="max">and</label>
<input type="number" id="max" name="rand_max" value="10" />
<div id="result">Result:</div>
<noscript>To use this web app, please enable JavaScript.</noscript>
</body>
</html>
接着,Alice 需要调整 JavaScript 代码来读取另一个输入,并在给定的两个值之间(包含端点)生成一个随机整数:
function getRandomIntInclusive(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1) + min);
}
function generateRandom() {
let min = document.getElementById('min').value;
let max = document.getElementById('max').value;
let res = document.getElementById('result');
res.textContent = 'Result: ' + getRandomIntInclusive(min, max);
}
let button = document.querySelector('button');
button.addEventListener('click', generateRandom);
button.disabled = false;
Alice 然后检查一切是否正常工作:
图 1.6 – 上下界变得可配置
然而,在测试过程中,她注意到应用程序没有确保最小值小于或等于最大值,并且如果输入顺序交换,应用程序也不能正确运行。
她决定尝试解决这个问题。然而,为了确保每次提交都小且独立,并确保在这种情况下应用程序能够正常工作(例如,当用户分别提供 10
和 5
作为最小值和最大值时),她决定将其作为一个独立的更改来完成。
为了将这一开发线与其他更改隔离,并防止集成未完全准备好的功能,她决定创建一个名为 'min-max
' 的分支(另见 第八章,高级分支技巧),并切换到该分支:
alice@hostA:~/random$ git checkout -b min-max
Switched to a new branch 'min-max'
alice@hostA:~/random$ git branch
master
* min-max
提示
如果 Alice 不使用 git checkout –b min-max 或 git switch --create min-max 快捷命令来创建新分支并切换到该分支,她本可以先用 git branch min-max 创建分支,再用 git switch min-max 切换到该分支。
她提交了自己的更改并推送,知道推送会成功,因为她正在自己的私有分支上工作:
alice@hostA:~/random$ git commit -a -m 'Make lower bound configurable'
[min-max 2361cfc] Make lower bound configurable
2 files changed, 9 insertions(+), 4 deletions(-)
alice@hostA:~/random$ git push
fatal: The current branch min-max has no upstream branch.
To push the current branch and set the remote as upstream, use
git push --set-upstream origin min-max
好的!Git 只是希望 Alice 设置远程源作为push
策略;这也会显式地推送这个分支:
alice@hostA:~/random$ git push --set-upstream origin min-max
To https://git.company.com/random
* [new branch] min-max -> min-max
Branch 'min-max' set up to track remote branch 'min-max' from 'origin'.
提示
如果她想让自己的分支既可见又私密(只有她自己能够推送到该分支),她需要使用钩子配置服务器,或者使用像gitolite这样的 Git 仓库管理软件来为她管理它。
合并一个分支(无冲突)
与此同时,在默认分支中,Bob 决定通过添加 COPYRIGHT
文件来推送他的更改:
bob@hostB random$ git push
To https://git.company.com/random
! [rejected] master -> master (fetch first)
[…]
好的,Alice 一直忙着使范围的最小值可配置,以便从中选择随机整数(并解决合并冲突),她首先推送了自己的更改:
bob@hostB:~/random$ git pull
From https://git.company.com/random
a808ecf..919f0f7 master -> origin/master
* [new branch] min-max -> origin/min-max
Git 然后打开带有合并提交信息的编辑器。Bob 退出编辑器以确认默认描述:
Merge made by the 'recursive' strategy.
index.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
好吧,Git 已经干净地合并了 Alice 的更改,但现在出现了一个新分支。我们来看一下它的内容,只展示 min-max
分支独有的更改(双点语法在 第四章,探索项目历史 中有介绍):
bob@hostB:~/random$ git log HEAD..origin/min-max
commit 2361cfc062809d96b9a04d8032b9c433cae5c350 (origin/min-max)
Author: Alice Developer <alice@company.com>
Date: Mon May 3 14:35:33 2021 +0200
Make lower bound configurable
有趣!Bob 决定也想要这个功能。所以,他要求 Git 将 Alice 的分支中的内容(该分支在各自的远程追踪分支中可用)合并到默认分支中:
bob@hostB:~/random$ git merge origin/min-max
Merge made by the 'recursive' strategy.
index.html | 4 +++-
scripts/gen_random.js | 9 ++++++---
2 files changed, 9 insertions(+), 4 deletions(-)
撤销未发布的合并
Bob 意识到应该由 Alice 决定何时将功能纳入(并且听说它还没准备好)。他决定撤销一次合并。因为它尚未发布,所以简单的做法是回滚到当前分支的先前状态:
bob@hostB:~/random$ git reset --hard @{1}
HEAD is now at 02ad67e Merge branch 'master' of https://git.company.com/random
重要提示
这个示例展示了使用reflog撤销操作;另一种解决方案是跟随第一个父提交,通过HEAD^而不是@{1}来回到一个之前(合并前)的提交。
Bob 随后推送了他的更改。
总结
本章带领我们通过了一个简单示例项目的过程,演示了一个小型开发团队的工作流程。
我们回顾了如何开始使用 Git,无论是创建一个新版本库还是克隆一个现有版本库。我们还看到了如何通过添加、编辑、移动和重命名文件来准备提交,如何撤销文件的更改,如何检查当前状态并查看待提交的更改,以及如何标记新版本。
我们回顾了如何使用 Git 在同一项目上同时进行工作,如何将我们的工作公开,以及如何从其他开发者那里获取更改。尽管使用版本控制系统有助于同时进行工作,但有时 Git 需要用户输入来解决不同开发者工作之间的冲突。我们还看到了如何解决合并冲突。
我们回顾了如何创建一个标记发布的标签,以及如何创建一个分支来开始独立的开发线路。Git 要求显式推送标签和新分支,但它会自动拉取这些标签和分支。我们还看到了如何合并一个分支。
下一章将更详细地讲解如何创建新的版本和新的开发线路,并将介绍并解释提交的暂存区的概念。
问题
回答以下问题来测试你对本章内容的理解:
-
描述如何从现有文件创建一个版本库,以及如何获取现有版本库的副本。
-
描述如何在本地创建项目的新版本,以及如何发布这些更改。
-
解释如何从其他开发者那里获取更改,并如何合并这些更改。
-
合并冲突标记是什么样子的?你如何解决合并冲突?
-
你可以做些什么来让 Git 不在状态输出中显示临时备份文件作为未知文件?构建系统的产品和副产品呢?
-
你可以在哪里找到关于如何撤销添加文件或如何撤销文件更改的信息?
-
你如何放弃一次提交?这样做有什么风险?
-
解释 Git 如何管理移动、复制和重命名文件。
答案
下面是上述问题的答案:
-
使用git init、git add .和git commit从现有文件创建一个版本库。使用git clone获取现有版本库的副本。
-
使用git commit或git commit -a来创建一个新的版本,并使用git push来发布更改。
-
使用git fetch从其他开发者那里获取更新,或者使用git pull获取更新并合并。使用git merge(或者在后续章节中提到的git rebase)来合并更改。
-
合并冲突通过<<<<<<<、=======和>>>>>>>标记来呈现;根据配置,你还可以看到|||||||标记。要解决冲突,你需要编辑标记为冲突的文件,处理完后使用git add将其添加,然后通过git commit或git merge --continue完成合并(或者使用git rebase --continue继续变基)。
-
要让 Git 忽略特定类型的文件,你需要将适当的 glob 模式添加到其中一个ignore文件中。最好使用.gitignore文件忽略构建系统的副产品和其他生成的文件,并将针对临时文件的模式添加到每个仓库(.git/info/ignore)或每个用户的ignore文件中。
-
关于如何撤销添加、移除或暂存文件的所有信息都可以在git status输出中找到。
-
你可以使用git reset --hard HEAD^放弃一次提交,但这可能会导致丢失你的更改(如果提交的更改没有过期,你可以通过 reflog 恢复它们;未提交的更改将永远丢失)。
-
Git 通过在合并和生成diff时使用重命名检测来处理代码移动,如重命名、移动和复制文件。
进一步阅读
如果你需要复习 Git 的基础知识,以下参考资料可能会对你有所帮助。
-
每天用 20 个命令左右的 Git,Git 文档的一部分,作为giteveryday(7):
git-scm.com/docs/giteveryday
-
Git 教程介绍,Git 文档的一部分,作为gittutorial(7):
git-scm.com/docs/gittutorial
-
Git 用户手册,Git 文档的一部分:
git-scm.com/docs/user-manual
-
Eric Sink, Version Control by Example, Pyrenean Gold Press (2011):
ericsink.com/vcbe/index.html
-
Scott Chacon 和 Ben Straub, Pro Git, 2nd Edition, Apress (2014):
git-scm.com/book/en/v2
第二章:使用 Git 开发
本章将描述如何使用 Git 创建新的修订版本和新的开发线路(新分支)。
在这里,我们将专注于提交自己在独立开发中的工作。作为贡献者之一的工作描述留给了第六章,使用 Git 进行协同开发,而第九章,合并更改,将展示如何将创建的开发线路合并以及 Git 如何帮助维护者工作。
本章将介绍 Git 中非常重要的概念——暂存区(也叫索引),而更高级的操作技巧将在第三章,管理你的工作树中进行描述。它还将详细解释分离的 HEAD的概念——即一个匿名的、无名的分支。在这里,你还可以了解 Git 如何描述两个项目版本之间的差异,或项目的更改,包括对所谓扩展统一****差异格式的详细描述。
以下是本章我们将涉及的主题列表:
-
索引——提交的暂存区
-
检查工作区的状态,以及其中的变化
-
如何阅读描述更改的扩展统一差异(unified diff)
-
选择性和交互式提交,并修改提交
-
创建、列出、重命名和切换分支,以及列出标签
-
什么可能会阻止切换分支,以及在这种情况下你可以做什么
-
使用git reset回退分支
-
分离的HEAD——即无名分支(例如,检查某个标签的结果)
创建新的提交
在开始使用 Git 开发之前,你应该用一个姓名和电子邮件进行自我介绍,正如在第一章中所示,Git 基础实用技巧。这些信息将用于识别你的工作,无论是作为作者还是提交者。配置可以是全局的,适用于你所有的仓库(使用 git config --global
,或直接编辑 ~/.gitconfig
文件),也可以是针对单个仓库的(使用 git config
,或编辑给定仓库中的 .git/config
文件)。每个仓库的配置会覆盖每个用户的配置(你将在第十三章中进一步了解这一点,Git 的自定义和扩展)。
多重身份
你可能希望为工作仓库使用公司邮箱,但为你参与的公开仓库使用个人的非工作邮箱。这可以通过全局设置一个身份(针对用户),然后在局部仓库配置中设置一个备用身份来处理例外情况。另一个可能的解决方案是使用条件包含和includeIf部分,利用它来包含带有每目录身份的适当配置文件。
相关配置文件片段可能如下所示:
[user]
name = Joe R. Hacker
email = joe@company.com
新提交如何扩展项目历史
对项目的开发做出贡献通常包括创建该项目的新修订版。为了将项目的当前状态标记为新版本,你使用 git commit
命令。Git 会要求输入更改的描述(提交信息),然后将新创建的修订版添加到项目历史中。这背后的过程是这样的——了解这些有助于更好地使用高级 Git 技巧。
在 Git 中,项目的历史存储为修订图(版本图),每个修订都指向它所基于的前一个版本。git commit
命令只是创建这个图中的一个新节点(一个 提交 节点),从而扩展了它。
为了知道每个分支的位置,Git 使用 branch HEAD 作为指向修订图的参考。HEAD 表示当前分支,即在哪个分支上创建新提交。
你可以在 第四章,探索项目历史 中了解更多关于 有向无环图 (DAG) 的概念。创建新提交会在修订图中添加一个新节点,并调整分支头(head)的定位,如下图所示。
图 2.1 – 示例项目的修订图,创建新提交前后,位于 “master” 分支上
假设我们在 master
分支上,并且我们想要创建一个新版本(这个操作的细节将在稍后详细描述)。git commit
命令将创建一个新的提交对象 —— 一个新的修订节点。这个提交将以已检出的修订(图 2.1 中的 c7cd3)作为上一个节点(作为 父节点)。
这个修订可以通过从 HEAD
开始跟踪引用来找到;在这里,它是从 HEAD
开始的链,然后指向 master
,最后到达 c7cd3
。
然后,Git 会创建一个新的提交节点,a3b79
,并将 master
指针移到这个新节点。在 图 2.1 中,新的提交用粗红色轮廓标出。注意,HEAD
指针没有变化;它始终指向 master
。执行的提交操作会记录在 master
分支以及 HEAD
(当前分支)中;可以通过 git reflog master
或 git reflog HEAD
命令查看此日志。
索引 —— 提交的暂存区
Git 仓库工作区中的每个文件在某一时刻都可以是 Git 已知的或未知的 —— 即,版本控制的或未版本控制的。任何 Git 已知的文件也被称为 git add
命令的文件。
Git 跟踪的文件通常有两种状态:已提交(或未更改)或已修改。HEAD
),这些文件被安全地存储在仓库中。该文件是 HEAD
。
然而,在 Git 中,还存在其他可能的状态。我们考虑使用 git add
命令将一个之前 Git 未知的文件(一个未跟踪的文件)添加到暂存区的情况,但在创建包含此文件的新提交之前。版本控制系统需要存储某处信息,指示该文件将在下次提交中包含。Git 使用一个叫做 git add <file>
的命令来暂存文件的当前内容(当前版本),并将其添加到索引中。
重要说明
如果你只想标记文件为添加状态,可以使用 git add -N
暂存区存储项目的状态。它是第三个这样的部分,前两个部分是工作目录(包含你自己的项目文件副本,用作私有独立工作空间以进行更改)和本地仓库(存储你自己的项目历史副本,用于与其他开发者同步更改)。图 2.2 显示了如何在创建新提交的上下文中与这三个部分交互:
图 2.2 – 工作目录、暂存区和本地 Git 仓库,创建新提交
图中的箭头显示了 Git 命令如何复制内容。例如,git add
命令将文件的内容从工作目录复制到暂存区。创建新的提交需要明确或隐式地执行以下步骤:
-
你在工作目录中对文件进行更改,通常使用你最喜欢的编辑器来修改它们。
-
你将文件暂存,添加它们的快照(当前内容)到暂存区,通常使用 git add 命令。
-
你使用 git commit 命令创建一个新的版本,该命令将暂存区中的文件作为当前状态,永久地将该快照存储到你的 本地仓库。
在开始时,通常在提交之后(除非是选择性提交),跟踪的文件在工作目录、暂存区和最后一次提交中是相同的(在已提交版本中,也就是HEAD
)。
检查暂存文件内容与已提交文件内容
要检查文件在工作目录中的状态,您可以简单地将其打开并编辑,或者(在 Linux 或 Git Bash 中)直接使用cat
然而,通常人们会使用一个特别的快捷命令——git commit -a
(长形式为git commit --all
),它会将所有已更改的跟踪文件添加到暂存区并创建一个新提交(参见图 2.2)。此命令的效果与运行git add --update
,然后再运行git commit
命令相同。请注意,新文件仍需通过git add
明确添加,才能被跟踪并包含在新提交中。
检查待提交的更改
在提交更改并创建新修订(新提交)之前,您会希望查看自己所做的修改。
Git 会将待提交的更改信息添加到提交消息模板中,然后传递给编辑器,您将在编写提交消息时看到这一点。当然,除非您在命令行中指定提交消息——例如,使用git commit -m "简短描述"
。Git 中的提交消息模板是可配置的(有关更多信息,请参见第十三章,自定义和扩展 Git)。
重要提示
您可以通过在编辑器中退出并不做任何更改,或者使用空的提交信息(注释行——即以#开头的行——不计入)来随时中止创建提交。如果您希望创建一个没有提交信息的提交,您需要使用--allow-empty-message选项。
在大多数情况下,您会希望在创建提交之前检查待处理更改的正确性。
工作目录的状态
您用来检查哪些文件处于哪种状态——即哪些文件已更改,是否有新文件等等——的主要工具是git status
命令。
默认输出是解释性的,并且非常详细。例如,在克隆后,如果没有任何更改,您可能会看到如下内容:
$ git status
On branch master
nothing to commit, working tree clean
如果当前分支(在本示例中是master
分支)是一个本地分支,旨在创建要发布并出现在公共仓库中的更改,并且已配置为跟踪其上游分支origin/master
,您还会看到有关跟踪分支的信息:
Your branch is up to date with 'origin/master'.
在本章的进一步示例中,我们将忽略这一点,不包括关于分支和跟踪分支的信息。
假设你向项目中添加了两个新文件:一个包含版权和许可信息的 COPYING
文件,以及一个当前为空的 NEWS
文件。为了开始跟踪新的 COPYING
文件,你使用了 git add COPYING
。不小心,你通过 rm README
命令将 README
文件从工作目录中删除了。你还修改了 Makefile
文件,并通过 git mv
将 rand.c
重命名为 random.c
,但没有修改文件内容。
默认的长格式旨在易于人类阅读、详细且具描述性:
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: COPYING
renamed: src/rand.c -> src/random.c
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: Makefile
deleted: README
Untracked files:
(use "git add <file>..." to include in what will be committed)
NEWS
较早版本的 Git 会建议使用与 git restore
不同的命令。
如你所见,Git 不仅描述了哪些文件发生了变化,还解释了如何改变它们的状态——要么将其包含在提交中,要么将其从待处理的更改集中移除(关于 git status
输出中显示的命令的更多信息,可以参考 第三章,管理你的工作树)。输出中最多可能会有三个部分:
-
待提交的更改:此部分列出的是那些已经暂存的、更改内容将在使用 git commit 提交时生效的文件(不带 -a/--all 选项)。它列出了那些在暂存区的快照与上次提交的版本(HEAD)不同的文件。
-
未暂存的更改:此部分列出的是那些工作目录中的内容与暂存区快照不同的文件。这些更改不会通过 git commit 提交,但会通过 git commit -a 作为已跟踪文件的更改提交。
-
未跟踪的文件:此部分列出了 Git 不认识的文件,这些文件未被忽略(关于如何使用 gitignores 使文件被忽略,参见 第三章,管理你的工作树)。如果在项目的根目录运行,使用大批量添加命令 git add . 会将这些文件添加进来。你可以通过 --untracked-files=no (-uno 缩写) 选项跳过此部分。
如果该部分没有任何文件,它将不会显示。请注意,文件可能出现在多个部分。例如,使用 git add
添加的新文件然后被修改过,会同时出现在 待提交的更改 和 未暂存的更改 两个部分。
不需要利用显式暂存区的灵活性;你可以仅通过 git add
来添加新文件,再用 git commit -a
来将所有已跟踪文件的更改一起提交。在这种情况下,你会从 待提交的更改 和 未暂存的更改 两个部分创建一个提交。
git status
还提供了一个简洁的 --short
输出格式。它的 --porcelain
版本适合用于脚本,因为它保证保持稳定,而 --short
主要面向用户输出,若可能的话会使用颜色,并且未来可能会发生变化。对于相同的更改集,输出格式可能类似于以下内容:
$ git status --short
A COPYING
M Makefile
D README
R src/rand.c -> src/random.c
?? NEWS
在此格式中,每个路径的状态使用两个字母的状态代码显示。第一个字母显示索引的状态(暂存区和上次提交之间的差异),第二个字母显示工作树的状态(工作区和暂存区之间的差异):
符号 | 含义 |
---|---|
(空格) | 未更新/未更改 |
M | 修改(已更新) |
A | 添加 |
D | 删除 |
R | 重命名 |
C | 复制 |
表 2.1 – git-status
命令短格式中使用的字母状态代码
并非所有组合都是可能的。状态字母A、R和C仅在第一列中表示索引状态时可能出现。
特殊情况??
用于表示未知(未跟踪)文件,!!
用于表示被忽略的文件(当使用git status --short --ignored
时)。
关于状态代码的说明
所有可能的输出在这里进行了描述;我们刚刚进行的合并导致了合并冲突的情况未在表格 2.1中显示,而是留待在第九章中描述,合并 更改。
检查自上次修订以来的差异
如果你不仅想知道哪些文件被更改了(可以通过git status
得到),还想知道具体更改了什么,可以使用git
diff
命令:
图 2.3 – 检查工作目录、暂存区和本地 Git 仓库之间的差异
在上一部分中,我们了解到在 Git 中,有三个阶段:工作目录、暂存区和仓库(通常是上次提交)。因此,我们不仅有一组差异,而是三组,如图 2.3所示。你可以向 Git 提出以下问题:
-
你更改了什么但还未暂存——也就是说,暂存区和工作目录之间有什么差异?
-
你已经暂存了哪些内容,准备提交——也就是说,最后一次提交(HEAD)和暂存区之间有什么差异?
-
自上次提交(HEAD)以来,你对工作目录中的文件做了哪些更改?
若要查看你已更改但尚未暂存的内容,请输入git diff
,不带任何其他参数。此命令将工作目录中的内容与暂存区中的内容进行比较。这些更改是可以添加的,但如果我们创建一个提交(使用git commit
而不加-a
),它们不会出现在提交中;这些更改会在git
status
的输出中列出。
要查看你已经暂存的、将要进入下次提交的内容,可以使用git diff --staged
(或git diff --cached
)。此命令将暂存区的内容与上次提交的内容进行比较。这些是将会在执行git commit
时添加的更改(不带-a
)—git status
的输出。你也可以通过git diff --staged <commit>
将暂存区与任何提交进行比较;HEAD
(最后一次提交)是默认的比较目标。
你可以使用git diff HEAD
来比较工作目录中的内容与最后一次提交的内容(或使用git diff <commit>
与任意提交进行比较)。这些是通过git commit -a
快捷方式将会添加的更改。
如果你使用git commit –a
并且没有使用暂存区,通常只需使用git diff
来检查将进入下次提交的更改。唯一的问题是通过普通的git add
添加的新文件;除非你使用git add --intent-to-add
(或其等效命令git add -N
)来添加新文件,否则它们不会出现在git diff
的输出中。
统一的 Git 差异格式
Git 默认情况下,在大多数情况下,会显示git bisect
找到的可疑提交的更改。
检查差异的不同方式
你可以使用--stat或--dirstat选项仅请求更改统计,使用--name-only仅请求更改文件的名称,使用--name-status请求带有更改类型的文件名,使用--raw请求更改的树级视图,或者使用--summary请求简洁的摘要(稍后将解释“扩展头信息”是什么意思以及包含哪些信息)。你还可以使用--word-diff请求字级差异而非行级差异,尽管这仅改变了更改的描述;标题和块头信息仍然类似。
差异生成还可以通过适当的gitattributes为特定文件或文件类型配置。你可以指定一个外部的差异辅助工具——也就是描述更改的命令——或者你可以为二进制文件指定一个文本转换过滤器(关于这一点,你将在第三章,管理工作区中学到更多)。
如果你更喜欢在图形工具中查看更改(通常提供并排差异),你可以使用git difftool而不是git diff来实现。你可以通过--tool=
让我们来看一个来自 Git 项目历史的高级差异示例,使用来自 git.git
仓库的 1088261f
提交的差异。你可以在网页浏览器中查看这些更改——例如,在 GitHub 上;这是该提交中的第三个补丁,github.com/git/git/commit/1088261f6fc90324014b5306cca4171987da85
:
diff --git a/builtin-http-fetch.c b/http-fetch.c
similarity index 95%
rename from builtin-http-fetch.c
rename to http-fetch.c
index f3e63d7206..e8f44babd9 100644
--- a/builtin-http-fetch.c
+++ b/http-fetch.c
@@ -1,8 +1,9 @@
#include "cache.h"
#include "walker.h"
-int cmd_http_fetch(int argc, const char **argv, const char *prefix)
+int main(int argc, const char **argv)
{
+ const char *prefix;
struct walker *walker;
int commits_on_stdin = 0;
int commits;
@@ -18,6 +19,8 @@ int cmd_http_fetch(int argc, const char **argv,
int get_verbosely = 0;
int get_recover = 0;
+ prefix = setup_git_directory();
+
git_config(git_default_config, NULL);
while (arg < argc && argv[arg][0] == '-') {
让我们逐行分析这个补丁。第一行,diff --git a/builtin-http-fetch.c b/http-fetch.c
,是一个 diff --git a/file1 b/file2
。a/
和 b/
的文件名除非涉及重命名或复制操作(如我们的例子中所示),即使文件被添加或删除,它们也是相同的。--git
选项表示差异是以 Git 差异输出格式呈现的。
接下来的几行是 builtin-http-fetch.c
到 http-fetch.c
的差异,这两个文件有 95% 的相似度(用于检测此重命名的信息):
similarity index 95%
rename from builtin-http-fetch.c
rename to http-fetch.c
重要说明
扩展头部行描述了在普通统一差异中无法表示的信息,除了文件重命名的信息之外。除了像本例中那样的相似度或差异度分数,这些行还可以描述文件类型的变化(例如从非可执行文件变为可执行文件)。
扩展差异头的最后一行,在这个例子中如下所示:
index f3e63d7206..e8f44babd9 100644
上面的代码告诉我们有关给定文件的模式(权限)。这里的 100644
表示这是一个普通文件而不是符号链接,并且它没有设置可执行权限位(这些是 Git 跟踪的三种文件权限)。这一行还告诉我们关于 f3e63d7206
(这里)和 e8f44babd9
(这里)缩短的哈希值。这一行用于 git am --3way
,如果补丁无法应用,它将尝试进行三方合并。对于新文件,源图像的哈希值为 0000000000
;对于删除的文件,目标图像的哈希值相同。
接下来是 统一差异头,由两行组成:
--- a/builtin-http-fetch.c
+++ b/http-fetch.c
与 diff -U
结果相比,它没有源文件修改时间和目标文件修改时间。这些应位于源文件(源图像)和目标文件(目标图像)文件名之后的空格处。如果文件是新创建的,则源文件为 /dev/null
;如果文件已被删除,则目标文件为 /dev/null
。
提示
如果你将 diff.mnemonicPrefix 配置变量设置为 true,以取代该两行头部中的 a/ 前缀(表示源文件)和 b/ 前缀(表示目标文件),那么你将分别看到 c/(表示提交)、i/(表示索引)、w/(表示工作树)和 o/(表示对象)前缀,来显示你正在比较的内容。这使得在 git diff、git diff --cached、git diff HEAD 输出等命令中很容易区分各个方面。
接下来是一个或多个 差异块,即文件之间差异的区域;每个差异块显示文件不同之处的一个区域。统一格式的差异块以描述更改所在行的行开始,称为 差异块头,如以下所示:
@@ -1,8 +1,9 @@
这一行匹配以下格式模式:@@ from-file-range to-file-range @@
。from-file
范围的格式为 -<起始行>,<行数>
,而 to-file 范围 为 +<起始行>,<行数>
。起始行
和 行数
分别指代前后图像中文件的起始位置和长度。如果没有显示 行数
,则表示其值为 0
。
在这个例子中,我们可以看到变化从文件的第一行开始,无论是前图像(更改前的文件)还是后图像(更改后的文件)。我们还看到,这个差异块对应的代码片段在前图像中有八行,在后图像中有九行。行数的差异意味着添加了一行。默认情况下,Git 还会显示环绕变化的三行未更改的行(即所谓的 上下文行)。
Git 还会显示每个更改发生的“函数名称”(或者对于其他类型的文件,如果有的话,可以通过 .gitattributes
配置文件的 diff 驱动程序来实现 —— 请参见 第三章,管理你的 工作区,在 配置 diff 输出 部分中的 文件属性);这就像 GNU diff 中的 -p
选项:
@@ -18,6 +19,8 @@ int cmd_http_fetch(int argc, const char
Git 包含了许多用于提取不同编程语言中“函数”头的构建模式。
接下来是描述文件差异的部分。两个文件中共有的行会以空格 "(" ")"
” 指示符作为前缀。两个文件之间不同的行在左侧打印列中会有以下指示符之一:
-
+:这里向第二个文件添加了一行
-
-:这里从第一个文件删除了一行
注意
在 纯文本词差异 格式中,不是逐行比较文件内容,新增的单词会被 {+ 和 +} 包围,删除的单词则被 [- 和 -] 包围,如以下示例所示:
int [-cmd_http_fetch-]{+main+}(int argc, const char **argv[-, const char *prefix-])
如果最后一个差异块的行中包含文件的最后一行,并且该最后一行是 不完整行(意味着文件在差异块末尾没有以行结束符结束),你将会看到以下情况:
\ No newline at end of file
这个情况在所用的例子中并不存在。
因此,在这里使用的例子中,第一个差异块意味着 cmd_http_fetch
被 main
替换,const char *prefix;
这一行被添加:
#include "cache.h"
#include "walker.h"
-int cmd_http_fetch(int argc, const char **argv, const char *prefix)
+int main(int argc, const char **argv)
{
+ const char *prefix;
struct walker *walker;
int commits_on_stdin = 0;
int commits;
看一下 替换行,旧版本的行被标记为删除(-
),新版本则被标记为添加(+
)。
换句话说,在更改之前,名为 builtin-http-fetch.c
的文件的相应片段大致如下所示:
#include "cache.h"
#include "walker.h"
int cmd_http_fetch(int argc, const char **argv, const char *prefix)
{
struct walker *walker;
int commits_on_stdin = 0;
int commits;
更改后,这段文件,现在名为http-fetch.c
,看起来类似于以下内容:
#include "cache.h"
#include "walker.h"
int main(int argc, const char **argv)
{
const char *prefix;
struct walker *walker;
int commits_on_stdin = 0;
int commits;
选择性提交
有时,在检查了待处理的更改后,你会意识到工作目录中有两个(或更多)不相关的更改,它们应该属于两个不同的逻辑更改;这种问题有时被称为交织工作副本问题。你需要将这些不相关的更改放入单独的提交中,作为单独的更改集。这种情况即使在尝试遵循最佳实践时也可能发生(参见第十五章,Git 最佳实践)。
一种解决方案是按原样创建提交,并稍后修复它(将其分成两部分)。你可以阅读如何做这件事的内容,见第十章,保持 历史清晰。
然而,有时,一些更改现在是必要的,必须立即发布(例如,修复在线网站的 bug),而其余的更改尚未准备好(它们还在进行中)。你需要将这些更改分开成两个独立的提交。
选择要提交的文件
最简单的情况是当这些不相关的更改影响不同的文件时。例如,如果 bug 出现在view/entry.tmpl
文件中,并且 bug 修复只更改了这个文件(且该文件没有其他与修复 bug 无关的更改),你可以使用以下命令创建一个 bug 修复提交:
$ git commit view/entry.tmpl
该命令将忽略在索引中暂存的更改(即暂存区中的内容),而记录给定文件或文件的当前内容(即工作目录中的内容)。
交互式选择更改
然而,有时这些更改无法以这种简单的方式分开;文件的更改是交织在一起的。你可以通过给git
commit
命令添加-````-````interactive
选项来尝试将它们分开:
$ git commit --interactive
staged unstaged path
1: unchanged +3/-2 Makefile
2: unchanged +64/-1 src/rand.c
*** Commands ***
1: status 2: update 3: revert 4: add untracked
5: patch 6: diff 7: quit 8: help
What now>
在这里,Git 向我们展示了工作区(unstaged
)和暂存区(staged
)的状态和更改总结,这也是status
子命令的输出。更改通过添加和删除的行数来描述——例如,这里的+3/-2
(这类似于git diff --numstat
命令所显示的内容)。
提示
使用像git gui这样的图形工具可能会更容易,具有这种功能的 GUI 可以使用鼠标选择要包含或排除的更改行。
你可以使用help
子命令,通过按h键访问,以检查这些列出的操作意味着什么:
What now> h
status - show paths with changes
update - add working tree state to the staged set of changes
revert - revert staged set of changes back to the HEAD version
patch - pick hunks and update selectively
diff - view diff between HEAD and index
add untracked - add contents of untracked files to the staged set of changes
要分开更改,你需要选择patch
子命令(例如,使用5或p)。Git 随后会提示你输入Update>>
,你需要选择要更新的文件及其数字标识符,如在状态中所示,并输入return
。你可以输入*
来选择所有可能的文件。选择完后,按空行结束:
What now> p
staged unstaged path
1: unchanged +3/-2 Makefile
2: unchanged +64/-1 src/rand.c
Patch update>> 1
staged unstaged path
* 1: unchanged +3/-2 Makefile
2: unchanged +64/-1 src/rand.c
Patch update>>
你可以通过使用 git commit --patch
跳过交互式选择文件,而直接进行文件修补。
Git 将逐块显示指定文件的所有更改,并让你在每个块中选择以下选项之一:
y - stage this hunk
n - do not stage this hunk
q - quit; do not stage this hunk or any of the remaining ones
a - stage this hunk and all later hunks in the file
…
s - split the current hunk into smaller hunks
e - manually edit the current hunk
? - print help
块输出和提示类似如下:
@@ -16,7 +15,6 @@ int main(int argc, char *argv[])
int max = atoi(argv[1]);
+ srand(time(NULL));
int result = random_int(max);
printf("%d\n", result);
Stage this hunk [y,n,q,a,d,/,j,J,g,e,?]? y
在许多情况下,只需选择哪些更改块要包含在提交中就足够了。在极端情况下,你可以将一个块拆分成更小的部分,甚至手动编辑差异。
许多图形工具,包括 git gui
,也允许交互式选择哪些更改将被提交到下一个提交。你可以在第十三章中找到更多信息,定制和扩展 Git,在图形 界面部分。
一步步创建提交
使用 git commit --interactive
选择要提交的更改,不幸的是,它无法让你测试即将提交的更改。你可以在创建提交后检查所有内容是否正常(即编译代码和/或运行测试),然后如果有任何错误,再进行修改(请参见下节,修改提交)。不过,也有一个替代方案。
如果不想使用交互式提交功能,你可以通过将待提交的更改放入暂存区来准备提交,方法是使用 git add --interactive
或其他等效方案(例如 Git 的图形化提交工具 — 例如 git gui
)。交互式提交只是 git diff --cached
的快捷方式,可以通过 git add <file>
、git checkout <file>
和 git reset <file>
等命令进行适当修改。
理论上,你还应该测试这些更改是否正确,至少确保它们不会破坏构建。为此,首先使用 git stash save --keep-index
保存当前状态,并将工作目录恢复到暂存区(索引)中准备好的状态。执行此命令后,你可以运行测试(或至少检查程序是否能编译且不崩溃)。如果测试通过,你可以使用 git commit
创建新版本。如果测试失败,你应该通过 git stash pop --index
命令恢复工作目录,同时保持暂存区的状态;有时可能需要在其前加上 git reset --hard
。之所以需要这样做,是因为 Git 在保留你的工作时非常保守,它不知道你刚才已经进行了 stash。首先,暂存区中有未提交的更改,导致 Git 无法应用 stash;其次,工作目录中的更改与 stash 中的内容相同,因此,当然会发生冲突。
你可以在第三章中找到关于 stash 的更多信息,包括它是如何工作的,管理你的工作树,在暂存你的 更改部分。
修改提交
Git 的一个优点是,你几乎可以撤销任何操作;你只需要知道如何操作。因为无论你多么仔细地编写提交,迟早你会忘记添加某些更改或者提交信息写错。这时候,git commit
命令的--amend
标志就派上用场了;它可以让你非常容易地修改最后一次提交。请注意,使用git commit --amend
时,你还可以修改合并提交(例如,修复合并错误)。图 2.4展示了此修改操作如何改变修订图表,图表代表了项目的历史。
提示
如果你想修改历史中更早的某个提交(假设它还没有发布,或者至少没有人基于该旧版本提交开始工作),你需要使用交互式变基,或者一些专用工具,如StGit(Git 上的补丁堆栈管理界面)。更多信息请参考第十章,保持历史清洁。
图 2.4 – 修正最后一次提交前后修订的图表
如果你只是想修正提交信息,并且假设没有任何已暂存的更改,你只需要运行git commit --amend
并修正它(请注意,我们使用的是不带-a
/ --all
标志的git commit
):
$ git commit --amend
如果你想对最后一次提交进行更多更改,你可以像正常一样使用git add
暂存它们,然后像前面的例子一样修改最后一次提交,或者直接进行更改后使用git commit -a --amend
:
重要提示
有一个非常重要的警告:你永远不要修改已经发布的提交!这是因为修改操作实际上会产生一个完全新的提交对象,替换掉旧的提交对象,正如在图 2.4中所见。在这个图中,你可以看到,操作前最近的提交(标记为C5)被项目历史中的提交C5'所替代,后者包含了修改后的内容。
如果只有你一个人有这个提交,做这件事是安全的。然而,在将原始提交发布到远程仓库后,其他人可能已经基于该版本的提交开始了新的工作。在这种情况下,用修改后的版本替换原始版本会在后续造成问题。你将在第十章,保持历史清洁中了解更多关于这个问题的信息。
这就是为什么,如果你尝试推送(发布)一个已经修改过的提交的分支,Git 会阻止覆盖已发布的历史,并要求你强制推送,如果你确实想要替换旧版本(除非你配置了默认强制推送)。关于这一点,请参见第六章,与 Git 的协作开发。
修改前的提交的旧版本将保留在分支的 reflog 中和 HEAD 的 reflog 中;例如,在修改后,修改后的版本将作为@{1}
可用。这意味着你可以使用例如git reset --keep HEAD@{1}
来撤销修改操作,如在回退或重置分支部分所描述。默认情况下,Git 会将旧版本保留一个月(30 天),除非有其他配置,或者手动清除 reflog。
你始终可以通过使用git reflog
命令检查操作的日志。在修改提交后,该命令的输出如下所示:
$ git reflog --no-decorate
94d3e03 HEAD@{0}: commit (amend): After amending
d69a0a9 HEAD@{1}: commit: Before amending
这里,HEAD@{1}
表示当前分支向后退 1 次操作的位置。除了 HEAD 的 reflog 外,每个分支也有一个自己的 reflog,稍后会有详细描述。注意,你可以在第四章中阅读更多关于如何使用 reflog 引用提交的内容,探索 项目历史。
使用分支和标签
在版本控制中,分支是独立的开发线,一种将不同想法和不同部分的变更分开的方式。你可以以不同的方式使用分支,具体方法在第八章中有详细描述,高级 分支技巧。
使用v1.0
标签,你将能够精确地访问版本 1.0 的代码。此外,使用注释标签,你可以为标记的修订提供更长的描述,而签名标签则有助于确保是你创建了它。
在 Git 中,每个分支实现为一个命名的“指针”(引用),指向修订图中的某个提交,称为分支头部。轻量标签也遵循相同的方式;对于注释标签和签名标签,“指针”指向标签对象(包含注释或签名),该对象指向一个提交。
Git 中分支的表示
Git 目前使用两种不同的磁盘上分支表示方式:“松散”格式(优先使用)和“打包”格式。
以master分支为例(目前是 Git 中新建仓库的默认分支名称,除非另行配置,否则会从该分支开始)。在“松散”格式(优先使用)中,分支表示为.git/refs/heads/master文件,文件中包含分支头部的 SHA-1 的十六进制文本表示。在“打包”格式中,分支表示为.git/packed-refs文件中的一行,连接了顶端提交的 SHA-1 标识符和完全限定的分支名称。
(命名的)开发线是从分支头部可达的所有修订的集合。它不一定是连续的修订线——它可以分叉并合并。
创建新分支
创建新分支时,你可以先创建分支,稍后再切换过去,或者你也可以使用一个命令同时创建并切换到该分支。这在图 2.5中有说明。
你可以使用 git branch
命令来创建新分支。例如,要从当前分支创建一个新的 testing
分支(见 图 2.5 的右上部分),可以运行以下命令:
$ git branch testing
这里发生了什么呢?这个命令为你创建了一个新的指针(一个新的引用),你可以移动它。如果你希望将新分支指向某个特定的提交,可以给这个命令传递一个可选的参数,像下面的示例一样:
$ git branch testing HEAD^^^
图 2.5 – 创建一个名为“testing”的新分支并切换到此分支,或者通过一个命令同时创建新分支并切换到它
注意
HEAD^^^ 表示法将在 第四章 探索项目历史 中解释。
然而,git branch <new branch>
命令不会改变当前分支。它不会切换到刚刚创建的分支,也不会改变 HEAD
(指向当前分支的符号引用)的指向,也不会改变工作目录的内容。
如果你想创建一个新分支并立即切换到它(开始在新分支上工作),你可以使用以下快捷方式:
$ git switch -c testing
在这里,简短的 -c
选项代表 --create
。你也可以使用以下替代命令,这在旧版本的 Git 中是唯一可用的选项:
$ git checkout -b testing
如果你想强制创建一个已经存在名称的分支,相当于删除现有分支,你需要使用 -C
和 -B
选项,而不是 -c
和 -b
选项。
如果我们在仓库的当前状态下创建一个新分支,switch -c
和 checkout -b
命令的唯一区别在于它们还会移动 HEAD
指针;参见从左侧到右下角的过渡,见 图 2.5。
创建孤立分支
很少情况下,你可能希望在仓库中创建一个全新的孤立分支,该分支与其他分支没有任何历史关联。或许你想要存储每个发布版本生成的文档,以便用户能够轻松地获得可读的文档(例如,作为 man 页或 HTML 帮助),而无需安装转换工具或渲染器(例如,AsciiDoc 或 Markdown 解析器)。或者,你可能想将项目的网页存储在与项目本身相同的仓库中,这正是 GitHub 项目页面可以使用的方式。也许你想开源代码,但需要先清理代码(例如,因版权和许可问题)。
一种解决方案是为孤立分支的内容创建一个单独的仓库,并将其拉取到某个远程追踪分支中。然后你可以基于该分支创建一个本地分支。这种方式的优点是将不相关的内容分开管理,但另一方面,这也意味着需要管理更多的仓库。
你也可以使用 git switch
或 git checkout
命令,结合 --orphan
选项来实现此操作:
$ git switch --orphan gh-pages
Switched to a new branch 'gh-pages'
这会重现类似于刚执行 git init
后的状态:HEAD
的 symref
指向 gh-pages
分支,该分支尚不存在;它将在第一次提交时被创建。
如果你希望从一个干净的状态开始,比如使用 GitHub Pages,你还需要删除分支起始点的内容(默认是 HEAD
—— 即当前分支及工作目录的当前状态)——例如,使用以下命令:
$ git rm -rf .
在公开源代码时,如果需要排除专有部分,使用孤立分支来确保在合并时不会意外地将专有代码带入开源版本,这时你可能需要小心地编辑工作目录。
选择并切换到一个分支
要切换到现有的本地分支,你需要运行 git switch
命令。例如,在创建了 testing
分支后,你可以使用以下命令切换到该分支:
$ git switch testing
如图 2.6所示,这是从右上方到右下方的垂直过渡;该图还显示了你可以使用 git checkout
来切换分支。
切换到分支的障碍
切换到分支时,Git 还会将其内容检出到工作目录。如果此时你有未提交的更改(并且 Git 并未将其视为任何分支上的更改),那么会发生什么呢?
提示
在干净的状态下切换分支是一个好习惯,可以将更改暂存或必要时创建提交。只有在少数几种特殊情况下,才会有未提交更改时检查分支的需求,这些情况将在下面的章节中描述。
如果当前分支与目标切换分支之间的差异没有涉及到更改的文件,则未提交的更改会被移动到新分支。这在你开始工作后才意识到最好在单独的功能分支中完成该工作时非常有用。
如果未提交的更改与给定分支上的更改冲突,Git 会拒绝切换到该分支,以防止你丢失工作内容:
$ git checkout other-branch
error: Your local changes to the following files would be overwritten by checkout:
file-with-local-changes
Please commit your changes or stash them before you switch branches.
Aborting
在这种情况下,你有几种可能的解决方案:
-
你可以使用 git stash 命令将更改暂存,并在返回到你之前的分支时恢复它们。这通常是首选的解决方案。
另外,你可以简单地将正在进行的工作和这些更改创建一个临时提交,然后在回到该分支时修改提交或回滚分支。
-
你可以尝试通过合并将更改移到新分支,可以使用 git switch --merge(它会执行当前分支、带有未保存更改的工作目录内容和新分支之间的三方合并),或者在切换之前将更改暂存,然后在切换后恢复它们。
-
你还可以使用 git switch --discard-changes 或 git checkout --force 来 丢弃 你的更改。
匿名分支
如果你尝试检出(切换到)一个不是本地分支的东西——例如一个任意修订(如 HEAD^
)、一个标签(如 v0.9
)或者一个远程追踪分支(例如 origin/master
)会发生什么?Git 假设你需要能够在当前工作目录的状态上创建提交。
旧版本的 Git 会拒绝切换到非分支。如今,Git 会通过分离 HEAD 指针并使其直接指向一个提交(这就是为什么它也被称为分离 HEAD 状态),而不是作为对分支的符号引用,来创建一个 匿名分支;参见 图 2.6 以获取示例。
图 2.6 – 检出非分支的结果是分离的 HEAD 状态(就像在匿名分支上一样)
由于这个功能仅在少数情况下使用,为了避免明确地进入这种状态,git switch
命令需要使用 --detach
选项;为了向后兼容,git checkout
不需要使用此选项来分离 HEAD 指针。此选项还可以用于在当前位置显式创建一个匿名分支。在旧版本的 Git 中,分离的 HEAD 状态在分支列表中显示为(no branch),在新版本的 Git 中显示为(detached from HEAD)或(HEAD detached at...)。
如果你不小心分离了 HEAD,你可以随时使用以下命令 返回到上一个分支(其中 -
表示上一个分支的名称):
$ git switch -
Previous HEAD position was a3bl9 <Some commit message>
Switched to branch 'master'
重要提示
git switch - 命令使用 HEAD 的 reflog 切换到上一个分支。重命名分支后,可能无法正常工作。
正如 Git 所告知的,创建一个分离分支时,如果不使用 --detach
选项,你始终可以使用 git switch -``c <new-branch-name>
给匿名分支命名。
因为标签是不可变的,所以尝试检出(或切换到)标签也会创建一个分离的 HEAD
—— 标签不是分支。
切换命令 DWIM-谋略
检出非分支的情况有一个特殊情况。如果你通过其短名称(在此情况下是 next
)像对待本地分支一样检出一个远程追踪分支(例如 origin/next
),Git 会假设你是想在远程追踪分支的状态上创建新的内容,并会按照它认为你需要的方式进行操作。这个 --no-guess
选项,或者伴随的 checkout.guess
配置变量。
这意味着:
$ git switch next
等同于:
$ git switch -c next --track origin/next
Git 只有在没有歧义的情况下才会执行此操作;本地分支必须不存在(否则该命令会直接切换到给定的本地分支),并且必须只有一个远程跟踪分支与之匹配。可以通过运行git show-ref next
(使用短名称)来检查此条件,验证它只返回一行,且包含远程跟踪分支的信息:
$ git show-ref --abbrev next
4936735 refs/remotes/origin/next
列出分支和标签
如果你使用git branch
命令而不带其他参数,它将列出所有分支,并用星号标记当前分支——即*
。
程序化地确定当前分支
git branch 命令是面向最终用户的;其输出可能在未来的 Git 版本中发生变化。要以编程方式获取当前分支的名称,可以使用git symbolic-ref HEAD(或git branch --show-current)。要获取当前提交的 SHA-1 值,请使用git rev-parse HEAD。要列出所有分支,请使用git show-ref 或 git for-each-ref;这同样适用于标签和远程跟踪分支。
git symbolic-ref、git rev-parse、git show-ref 和 git for-each-ref 命令都属于管道命令 —— 即,专门用于脚本中的命令。
你可以使用-v
(--verbose
)或-vv
请求更多信息。你还可以通过git branch --list <pattern>
来限制显示的分支,仅显示匹配给定 shell 通配符的分支(如果需要,引用模式以防止其被 shell 展开)。
查询远程信息,包括远程分支列表,可以使用git remote show
,该内容将在第八章中描述,高级 分支技术。
要列出所有标签,可以使用不带任何参数的 git tag
命令,或者 git tag --list
;使用 git tag --list <pattern>
,你可以选择显示哪些标签(例如,按分支),如下例所示:
$ git tag --list "v0.9*"
v0.99
v0.99.1
v0.99.2
回退或重置分支
如果你想放弃最后一个提交并且执行git reset --keep
(尝试保留未提交的更改)或git reset --hard
(放弃它们),该重置操作的结果如图 2.7所示。
图 2.7 – 使用重置命令将分支回退一个提交至 HEAD^
重置命令及其对工作区的影响将在第三章中详细说明,管理 你的 工作树。
注意
git reset
删除分支
因为在 Git 中,分支只是一个指针,是指向修订历史中某个节点的外部引用,删除分支实际上只是删除一个指针。这意味着,删除分支并不会立即删除历史记录,但可能会使其无法通过其他途径访问,除非通过 reflog
。不过,这些信息并不是永久保存的;reflog
条目会过期。
重要提示
实际上,删除一个分支也会不可恢复地(至少在当前版本的 Git 中)删除该分支的 reflog——即它的本地操作历史记录。
图 2.8 – 删除刚刚合并到 'master' 分支的 'based-doc' 分支,同时在包含它的 'master' 分支上
你可以使用 git branch --delete <branch-name>
或 branch -d
来删除分支,前提是该分支没有被检出。
但是,有一个问题需要考虑——如果删除一个分支,并且该分支指向的项目历史部分没有其他引用,会发生什么?这些修订将变得不可达,Git 会在 HEAD reflog 过期后删除它们(在默认配置下,通常是 30 天后)。
这就是为什么 Git 只允许删除完全合并的分支的原因,所有提交都可以通过 HEAD
访问,如图 2.8中的示例所示(或者如果删除的分支可以从其上游分支访问,也允许删除)。
要删除一个未合并的分支,这会导致 DAG 的部分内容变得不可达,如图 2.9所示,你需要使用更强的命令——即 git branch -D
或 git branch --delete --force
。当拒绝删除未合并的分支时,Git 会建议执行此操作。
图 2.9 – 删除未合并的分支,导致部分历史记录变得不可达
你可以通过检查 git branch --contains <branch>
是否显示内容来判断分支是否已经合并到其他分支。你不能删除当前分支。
如果你曾切换到被删除的分支,那么这个事件和切换离开该分支的信息将保存在 HEAD
的 reflog
中。这些信息可以用来恢复该分支,或者更准确地说,用来重新创建它:
$ git reflog --no-decorate HEAD
…
3a59408 HEAD@{3}: checkout: moving from base-doc to master
更改分支名称
有时候,分支的名称需要更改。例如,如果分支的范围在开发过程中发生变化,旧名称可能就不再适用了。分支的名称默认会出现在合并提交的提交信息中,并永远保留;这就是为什么你希望分支名称有意义的原因。
你可以使用 git branch -m
来重命名一个分支(如果目标名称已存在并且你想覆盖它,则使用 -M
);它会重命名分支并移动相应的 reflog。这也会改变该分支在所有配置中的名称(包括描述、上游等)。
重命名事件存储在 reflog 中,你可以找到之前的名称并使用它来撤销操作(将分支重命名回旧名称):
$ git reflog --no-decorate new-name
3a59408 new-name@{0}: branch: renamed refs/heads/old-name to refs/heads/new-name
总结
在本章中,我们学习了如何使用 Git 开发并通过创建新的提交和新的开发线路(分支)扩展项目历史。从修订图的角度来看,我们了解了创建提交、修改提交、创建分支、切换分支、回退分支以及删除分支意味着什么。
本章展示了 Git 的一个非常重要的功能——用于创建提交的暂存区,也称为索引。这使得通过选择性和交互式地选择要提交的内容,从而理清工作目录中的更改成为可能。
我们学习了如何在创建提交之前检查工作区的更改。本章详细描述了 Git 用来描述更改的扩展统一差异格式(unified diff format)。
我们还学习了分离头指针(detached HEAD,或称匿名分支)和孤立分支的概念。
在 第三章,管理你的工作区,我们将学习如何使用 Git 来准备新的提交,并如何配置它以便使我们的工作更轻松。我们还将学习如何检查、搜索和研究工作目录、暂存区以及项目历史的内容。我们还将看到如何使用 Git 处理中断并从错误中恢复。
问题
回答以下问题,以测试你对本章的理解:
-
创建新的提交如何改变存储在仓库中的历史——也就是说,它如何改变修订图以及分支头指针指向的位置?
-
git commit 和 git commit --all(或 git commit -a)有什么区别?
-
如何检查你在本地仓库中做了哪些更改?如何撤销它们?
-
修复当前分支上最后一次提交的提交信息的最简单方法是什么?
-
当你意识到你开始写的更改(但还未提交)应该在一个新的分支上进行时,你该怎么办?
-
切换到上一分支的最简单方法是什么?什么时候它可能失败?
答案
以下是上述问题的答案:
-
创建新的提交会在修订图中生成一个新的节点,该节点的父节点是先前的提交,并将当前分支的分支头引用推进到新创建的节点,同时保持 HEAD 不变。
-
git commit 操作从暂存区内容创建新的提交,而 git commit --all 则从所有已跟踪文件的更改中创建提交。
-
可以使用git status查看哪些文件已更改,使用git diff或git diff HEAD查看更改。你可以在完整的git status输出中找到如何撤销所做更改的解释。
-
若要更改最后一次提交的提交信息(即更改的描述),可以使用git commit --amend。
-
因为未提交的更改不属于任何分支,所以你可以简单地创建一个新分支并切换到该分支,使用git switch -c
或git checkout -b 。 -
若要切换到上一个分支,可以使用git switch -(用-代替分支名)。Git 通过查找 reflogs 来确定上一个分支。如果分支已被删除或重命名,则此操作可能失败。
进一步阅读
要了解更多本章涉及的主题,请查看以下资源:
-
Scott Chacon, Ben Straub. Pro Git,第二版(2014),Apress,第二章 2 节,Git 基础 - 记录变更到仓库:
git-scm.com/book/en/v2/Git-Basics-Recording-Changes-to-the-Repository
-
Jakub Narębski. 如何阅读 git diff 的输出?(对 StackOverflow 问题的回答):
stackoverflow.com/questions/2529441/how-to-read-the-output-from-git-diff/2530012#2530012
-
Dragos Barosan. Git 新特性:switch 和 restore(2021):
www.banterly.net/2021/07/31/new-in-git-switch-and-restore/
-
Junio C Hamano. 近期 Git 新特性乐趣(2016),关于git branch命令的--sort选项:
git-blame.blogspot.com/2016/05/fun-with-new-feature-in-recent-git.html
-
Tobias Günther. 从幕后看 Git 分支如何工作(2021):
stackoverflow.blog/2021/04/05/a-look-under-the-hood-how-branches-work-in-git/
-
Ryan Tomayko. 关于 Git 的事情(2008),讨论了复杂的工作副本问题,以及如何在 Git 中解决它:
tomayko.com/blog/2008/the-thing-about-git
-
Nick Quaranto. Reflog,你的安全网(2009),在 Gitready 上:
gitready.com/intermediate/2009/02/09/reflog-your-safety-net.html
第三章:管理你的工作树
前一章《使用 Git 开发》描述了如何使用 Git 进行项目开发,包括如何创建新的修订。在本章中,我们将重点学习如何管理工作目录(工作树),以便你为新的提交准备内容。本章将详细教你如何管理文件,还将展示如何处理那些需要特殊处理的文件,同时介绍忽略文件和文件属性的概念。然后,你将学习如何修复处理文件时的错误,无论是在工作目录还是在暂存区中,以及如何修复或拆分最新的提交。最后,你将学习如何通过暂存和多个工作目录安全地处理中断。
前一章还教了你如何检查更改。在本章中,你将学习如何有选择地撤销和重做这些更改,以及如何查看文件的不同版本。
本章将涵盖以下主题:
-
忽略文件 – 将文件标记为故意不受版本控制
-
文件属性 – 路径特定配置
-
使用git reset 命令的各种模式
-
将更改暂存以处理中断
-
管理工作目录的内容和暂存区
-
多个工作目录(工作树)
忽略文件
工作区(也称为工作树)中的文件可以被 Git 进行跟踪或不跟踪。跟踪文件顾名思义,是 Git 会跟踪其变化的文件。对于 Git 来说,如果文件存在于暂存区(也称为索引),它就会被跟踪,除非另行指定,否则它将成为下一个修订的一部分。你需要添加文件以使它们成为项目历史的一部分。
暂存区的作用
索引,或暂存区,不仅用于让 Git 知道哪些文件需要跟踪,还充当一种草稿区,用于创建新的提交,正如在第二章《使用 Git 开发》中所描述的,并且帮助解决合并冲突,正如在第九章《合并变更》中所展示的。
通常,你会有一些单独的文件或一类文件,你永远不希望它们成为项目历史的一部分,也不希望它们被跟踪。这些文件可能是你的编辑器备份文件,或者是由项目的构建系统自动生成的文件(可执行文件、目标文件、压缩后的源文件、源映射文件等)。
你不希望 Git 自动添加这些文件,例如,在执行git add :/
(添加整个工作区)或使用git add .
(添加当前目录)时,或者在使用git add --all
将索引更新为工作区的状态时。
完全相反:你希望 Git 能主动防止你不小心将它们添加进去。你还希望这些文件不出现在git status
的输出中,因为它们可能很多,否则它们会淹没那些合法的、未知的文件。你希望这些文件被故意不追踪——也就是说,被忽略。
取消追踪和重新追踪文件
如果你想开始忽略一个曾经被追踪的文件,例如,当你从手工生成的 HTML 文件转向使用轻量级标记语言如Markdown时,通常需要取消追踪该文件,而不是将其从工作目录中删除,同时将其添加到忽略文件的列表中。你可以使用git rm --cached
要添加(开始追踪)一个故意不追踪的(即被忽略的)文件,你需要使用git add --force
标记文件为故意不追踪(忽略)
如果你想将一个文件或一组文件标记为故意忽略,你需要将一个shell glob 模式添加到以下gitignore文件中的其中一个,每行一个模式:
-
每个用户的文件,可以通过core.excludesFile配置变量指定。如果没有设置此配置变量,则使用默认值\(XDG_CONFIG_HOME/git/ignore**。如果**\)XDG_CONFIG_HOME环境变量未设置或为空,则默认使用\(HOME/.config/git/ignore**(其中**\)HOME是当前用户的主目录)。
-
本地仓库中的\(GIT_DIR/info/exclude**文件,该文件位于本地克隆仓库的管理区域中(在大多数情况下,**\)GIT_DIR指向项目顶级目录中的.git/目录)。
-
项目工作目录中的.gitignore文件。通常这些文件是被追踪的,在这种情况下,它们会在所有开发者之间共享。
一些命令,如git clean
,也允许我们通过命令行指定忽略模式,使用--exclude=<pattern>
选项。
在决定是否忽略一个路径时,Git 会按照前面列表中指定的顺序检查所有这些来源,最后一个匹配的模式决定最终结果。.gitignore
文件会按照顺序检查,从项目的顶级目录一直到要检查的文件所在的目录。
为了使.gitignore
文件更具可读性,你可以使用空行将文件分组(空行不匹配任何文件)。你还可以用注释描述模式或模式组;以哈希字符#
开头的行作为注释(要忽略以#
开头的模式,可以用反斜杠\
转义第一个哈希字符,例如\#*#
)。行尾的空格会被忽略,除非用反斜杠\
进行转义。
.gitignore
文件中的每一行指定了一个 Unix glob 模式,也就是 shell 通配符。*
通配符匹配零个或多个字符(任意字符串),而 ?
通配符匹配任何单个字符。你还可以使用带有括号的字符类 [...]
。例如,以下是一些模式列表:
*.[oa]
*~
这里,第一行告诉 Git 忽略所有扩展名为 .a
或 .o
的文件——*.a
文件是归档文件(例如静态库),而 *.o
文件是目标文件,可能是你编译代码时生成的文件。第二行告诉 Git 忽略所有以波浪号(~
)结尾的文件;许多 Unix 文本编辑器使用波浪号来标记临时备份文件。
如果模式不包含斜杠 /
(斜杠是路径组件分隔符),Git 会将其视为 .gitignore
文件的位置,如果模式出现在这样的文件中,或者如果不在文件中,则视为仓库的顶级目录)。唯一的例外是以斜杠结尾的模式 /
,它用于只匹配目录,否则会像去掉结尾斜杠一样处理。
前导斜杠匹配路径名的开头。这意味着以下内容:
-
不包含斜杠的模式会匹配整个仓库;我们可以说,这种模式是递归的。
例如,
*.o
模式匹配任何地方的目标文件,包括.gitignore
文件所在的目录以及file.o
、obj/file.o
等子目录中的文件。 -
以斜杠结尾的模式只匹配目录,但在其他情况下是递归的(除非它们包含其他斜杠)。
例如,
auto/
模式会匹配顶级的auto
目录和src/auto
目录,但不会匹配auto
文件(也不会匹配符号链接)。 -
要锚定一个模式并使其非递归,可以在前面加一个斜杠。
例如,
/TODO
模式会匹配并使 Git 忽略当前级别的TODO
文件,但不会忽略子目录中的文件,比如src/TODO
。 -
包含斜杠的模式是锚定的,并且是非递归的,通配符(
doc/*.html
匹配doc/index.html
文件,但不匹配doc/api/index.html
;要匹配doc
目录下的任何 HTML 文件,你可以使用doc/**/*.html
模式(或者将*.html
模式放入doc/.gitignore
文件中)。
你还可以通过在模式前加上感叹号(!
)来否定一个模式;任何被之前规则排除的匹配文件将会重新被包含(不再被忽略)。例如,如果你想忽略所有生成的 HTML 文件,但包括手动生成的那个 HTML 文件,可以在.gitignore
文件中写入以下内容:
# ignore html files, generated from AsciiDoc sources
*.html
# except for the files below which are generated by hand
!welcome.html
注意
出于性能考虑,Git 不会进入被排除的目录,并且(直到 Git 2.7)这意味着如果父目录被排除,你不能重新包含一个文件。
这意味着,要忽略除了子目录以外的所有内容,你需要写下以下内容:
# exclude everything except directory t0001/bin
/*
!/t0001
/t0001/*
!/t0001/bin
要匹配以!
开头的模式,使用反斜杠进行转义,类似于你需要为#
字符做的事情——例如,使用\!important!.md
模式来匹配名为!important!.md
的文件。
哪些类型的文件应该被忽略?
现在我们知道如何将文件标记为故意未被追踪(被忽略),但接下来我们要考虑的问题是哪些文件(或文件类别)应该被标记为这样。另一个问题是我们应该在哪里添加忽略特定类型文件的模式——也就是说,在三种类型的.gitignore
文件中的哪一个里。
第一个规则是,你绝不应该追踪自动生成的文件(通常是项目构建系统生成的文件)。如果你将这些文件添加到仓库中并追踪它们,那么它们与源文件之间很可能会出现不同步的情况。此外,它们并不是必需的,因为你总是可以重新生成它们。唯一的例外情况是那些源文件很少变化且生成它们需要额外工具(开发者可能没有的工具)的生成文件(如果源文件变化更频繁,你可以使用孤立分支来存储这些生成的文件,并且只在发布时更新该分支;详情请参见第二章,与 Git 一起开发章节,创建孤立分支部分了解更多信息)。
这些自动生成的文件是所有开发者都会想要忽略的文件。因此,它们应该放入一个受版本控制的.gitignore
文件中。这个模式列表会被版本控制,并通过克隆分发给其他开发者;这样,所有开发者都会得到它。你可以在github.com/github/gitignore
找到不同编程语言的有用.gitignore
模板,或者你可以使用gitignore.io
上的网络应用。
第二类是临时文件和特定于某一用户工具链的副产品;这些通常不应该与其他开发者共享。如果模式同时特定于仓库和用户——例如,存在于仓库内但只对某个用户的工作流(例如,用于项目的 IDE)特有的辅助文件——则应将其放入每个克隆的$GIT_DIR/info/exclude
文件中。
用户希望在所有情况下都忽略的、与仓库(或项目)无关的模式,通常应放入每个用户的.gitignore
文件中,该文件通过core.excludesFile
配置变量指定,并在每个用户(全局)的~/.gitconfig
配置文件(或~/.config/git/config
)中设置。默认情况下,这通常是~/.config/git/ignore
。
关于每个用户的.gitignore 文件的重要说明
每个用户的忽略文件不能是/.gitignore**,因为这将是版本化用户主目录的仓库内**.gitignore**文件,如果用户希望将**/目录($HOME)纳入版本控制的话。
这是您可以放置与备份或临时文件匹配的模式的地方,这些文件是由您选择的编辑器或 IDE 生成的。
被忽略的文件被视为可丢弃的
警告:不要将宝贵的文件(即那些您不希望在特定仓库中追踪,但其内容很重要的文件)添加到被忽略的文件列表中!Git 忽略(排除)的文件类型要么容易再生成(如构建产物和其他生成的文件),要么对用户不重要(如临时文件或备份文件)。
因此,Git 会将被忽略的文件视为可丢弃的,并且在执行所请求的命令时,如果被忽略的文件与正在检出的修订内容冲突,Git 会在没有警告的情况下删除它们。
列出被忽略的文件
您可以通过将--ignored
选项附加到git
status
命令,列出未被追踪的被忽略文件:
$ git status --ignored
On branch master
Ignored files:
(use "git add -f <file>..." to include in what will be committed)
rand.c~
no changes added to commit (use "git add" and/or "git commit -a")
$ git status --short --branch --ignored
## master
!! rand.c~
与其使用git status --ignored
,您可以使用清理被忽略文件的干运行选项git clean -Xnd
,或者使用底层(plumbing)git ls-files
命令:
$ git ls-files --others --ignored --exclude-standard
rand.c~
后一个命令还可以用于列出匹配忽略模式的已追踪文件。如果有此类文件,可能意味着某些文件需要取消追踪(也许是因为曾经是源文件的文件现在是生成文件),或者忽略模式过于宽泛。由于 Git 使用暂存区(缓存)中文件的存在来确定要追踪哪些文件,您可以通过以下命令实现这一操作:
$ git ls-files --cached --ignored --exclude-standard
如此处所示的空结果意味着一切正常。
Plumbing 命令与 porcelain 命令
Git 命令可以分为两组:面向最终用户交互使用的高级porcelain命令和主要用于 shell 脚本的低级plumbing命令。它们的主要区别在于,高级命令的输出可能会改变并不断改进。例如,git branch命令在分离HEAD的情况下,其输出从(no branch)变为(detached from HEAD)。它们的输出和行为还会受到配置的影响。请注意,某些 porcelain 命令可以通过--porcelain选项切换到不变的输出。
另一个重要的区别是,plumbing 命令试图猜测您的意图,它们有默认参数,使用默认配置,等等。但 plumbing 命令并非如此。您需要为git ls-files命令传递--exclude-standard选项,以使其尊重默认的忽略文件集。
您可以在第十三章中找到更多关于此主题的内容,自定义和扩展 Git。
技巧 – 忽略已追踪文件的更改
你可能在仓库中有一些已更改但很少提交的文件。这些可能是各种本地配置文件,用于匹配本地设置,但永远不应该提交到上游。这可以是一个包含新版本提议名称的文件,等到标记下一个发布版本时再提交。
你通常会希望将这些文件保持在脏状态,但你希望 Git 不会一直告诉你它们的更改,以免你忽略其他更改,因为你已经习惯了忽略这些消息。
脏的工作目录
如果工作目录与已提交和已暂存的版本相同,则被认为是干净的;如果有任何修改或更改,则被认为是脏的。
Git 可以被配置——或者说在这种情况下被“欺骗”——跳过检查工作区(假定它始终是最新的),并使用文件的暂存版本。可以通过为文件设置恰当命名的skip-worktree
标志来实现这一点。为此,你需要使用底层的git update-index
命令,它是用户界面中git add
命令的底层实现。你可以使用git ls-files
来检查文件的状态和标志,标有此标志的文件会显示字母S
:
$ git update-index --skip-worktree GIT-VERSION-NAME
$ git ls-files -v
S GIT-VERSION-NAME
H …
请注意,这种跳过工作区的做法也包括git stash
命令;如果你想将更改暂存并使工作目录保持干净,你需要禁用此标志(至少是暂时禁用)。要让 Git 查看工作目录版本并开始跟踪文件的更改,请使用以下命令:
$ git update-index --no-skip-worktree GIT-VERSION-NAME
这个问题是由于skip-worktree
标志的使用不当所导致的;该标志是为了管理所谓的稀疏检出(sparse checkout)而创建的,更多内容请参见第十二章,管理 大型代码库。
重要说明
还有一个类似的assume-unchanged标志,可以用来让 Git 完全忽略文件的任何更改,或者说是假定文件未更改。标记此标志的文件在git status或git diff命令的输出中永远不会显示为已更改。对这些文件的更改既不会被暂存,也不会被提交。
当你在一个文件系统上处理一个大项目,而这个文件系统检查更改的速度非常慢时,这有时是有用的。然而,千万不要使用assume-unchanged来忽略已追踪文件的更改。你是在向 Git 保证文件没有更改,实际上是在欺骗 Git。这意味着,举个例子,如果你使用git stash save并相信你所说的内容,你会丢失宝贵的本地更改。
文件属性
Git 中有一些设置和选项,可以按路径指定,这类似于忽略文件(将文件标记为故意不跟踪)的方法。这些按路径指定的设置被称为属性。
要为匹配给定模式的文件指定属性,需要在一个 .gitignore
文件中添加一行,包含模式,空格分隔,后面跟着一个由空格分隔的属性列表。
-
每个用户的文件,用于影响单个用户的所有仓库的属性,由 core.attributesFile 配置变量指定。默认情况下,这是 ~/.config/git/attributes。
-
本地克隆仓库的管理区域中的每个仓库.git/info/attributes文件,用于那些只应影响特定克隆仓库(某个用户工作流程)的属性。
-
项目工作目录中的 .gitattributes 文件,用于那些应该在开发者之间共享的属性。
用于匹配文件的模式规则与 .gitignore
文件中描述的规则相同,只是没有对负模式的支持,并且匹配目录的模式不会递归地匹配该目录中的路径。
对于给定路径,每个属性可以处于以下状态之一:已设置(特殊值 true)、未设置(特殊值 false)、设置为给定值,或未指定:
pattern* set -unset set-to=value !unspecified
注意
设置属性为字符串值时,= 周围不能有空格!
当多个模式匹配路径时,后面的行会在每个属性的基础上覆盖前面的行。.gitattributes
文件按顺序使用,从每个用户的文件,到每个仓库的文件,再到给定目录中的 .gitattributes
文件,像 .gitignore
文件一样。
识别二进制文件和行尾转换
不同的操作系统和不同的应用程序在表示文本文件中的换行符时可能有所不同。Unix 和类 Unix 系统(包括 Mac OS X)使用单一的控制字符 LF (\n
),而 Windows 使用 CRLF —— 即 CR 后跟 LF (\n\r
);直到版本 9,macOS 使用的是仅 CR (\r
)。
如果不同的开发者使用不同的操作系统,这可能会成为开发可移植应用程序的一个问题。我们不希望因为不同的行尾约定而产生多余的更改。因此,Git 使得可以在提交(check-in)时自动将行尾字符标准化为 LF,并可选择在检出时将其转换为 CR + LF
。
你可以使用 text
属性控制文件是否应考虑进行行尾转换。设置它会启用行尾转换,取消设置则会禁用。将其设置为 auto
值时,Git 会猜测给定的文件是否是文本文件;如果是,行尾转换将启用。对于那些未指定 text
属性的文件,Git 会使用 core.autocrlf
来决定是否将其视为 text=auto
情况。
Git 如何检测文件是否包含二进制数据
为了判断一个文件是否包含二进制数据,Git 会检查文件开头是否有零字节(null/NUL 字符或 \0)。在决定是否转换文件(例如行尾转换)时,标准会更加严格:如果一个文件要被认为是文本文件,它必须没有 null 字符,并且非可打印字符的比例不能超过大约 1%。
然而,这意味着 Git 通常会将以 UTF-16 编码保存的文件视为二进制文件。
要决定 Git 在工作目录中为文本文件使用哪种行尾类型,你需要设置 core.eol
配置变量。该变量可以设置为 crlf
、lf
或 native
(默认值为 native
)。你也可以为特定文件强制指定某种行尾格式,通过 eol=lf
或 eol=crlf
属性:
旧的 crlf 属性 | 新的文本和 eol 属性 |
---|---|
crlf |
text |
-``crlf |
-``text |
crlf=input |
eol=lf |
表 3.1 – text
和 eof
属性与 crlf
属性的向后兼容性
行尾转换有轻微的损坏数据的风险。如果你希望 Git 在文件包含混合的 LF
和 CRLF
行尾时发出警告或阻止转换,可以使用 core.safecrlf
配置变量。
有时,Git 可能无法正确检测一个文件是否为二进制文件,或者有一些文件虽然名义上是文本文件,但对人类读者来说是不可读的。例如 PostScript 文档(*.ps
)和 Xcode 构建设置(*.pbxproj
)。这种类型的文件不应进行规范化,对它们使用文本 diff
是没有意义的。你可以通过 binary
属性宏显式标记这些文件为二进制文件(这相当于 -``text -diff
):
*.ps binary
*.pbxproj binary
如果文件未进行行尾规范化,应如何处理
当在仓库中启用行尾规范化(通过编辑 .gitattributes 文件)时,你还应该强制进行文件的 规范化。否则,换行符表示的变化将被错误地归因于下一个文件更改。例如,可以使用 git add --renormalize 命令来完成这项工作。当改变哪些文件具有 text 属性时,也应当执行此操作。
Diff 和合并配置
在 Git 中,你可以使用属性功能来配置如何显示文件不同版本之间的差异,以及如何进行三方合并。这可以用来增强该操作,使得 diff
更具吸引力,merge
更不容易发生冲突。它甚至可以用来有效地进行二进制文件的 diff
,或者以特定的方式描述差异。
在这两种情况下,我们通常需要设置diff
和/或merge
驱动程序。属性文件仅告诉我们使用哪个驱动程序;其余的信息包含在配置文件中,而这个配置不会自动在开发者之间共享,这与.gitattributes
文件不同(尽管你可以创建一个共享的配置片段,将其添加到仓库中,并让开发者通过相对的include.path
将其包含到本地的每个仓库配置中)。这种行为的原因很容易理解——工具的配置可能在不同的计算机上不同,并且某些工具可能在开发者选择的操作系统上不可用。但这意味着一些信息需要通过外部渠道分发。
然而,有一些内置的diff 驱动程序和合并驱动程序,任何人都可以在不做进一步配置的情况下使用它们。
生成差异和二进制文件
为特定文件生成的差异会受到diff
属性的影响。如果未设置此属性,Git 会将文件视为二进制文件来生成差异,并仅显示\``0
)字符。
你可以使用diff
属性,通过diff
命令使 Git 更有效地描述二进制文件的两个版本之间的差异。尽管转换为文本通常会丢失一些信息,但结果的差异对于人类查看是有用的(尽管它不是关于所有更改的信息)。
这可以通过diff
驱动程序的textconv
配置项来完成,在该项中你指定一个程序,该程序将文件名作为参数并返回其文本表示。
例如,你可能希望查看 Microsoft Word 文档的内容差异,或查看 JPEG 图像的元数据差异。首先,你需要在.gitattributes
文件中添加类似这样的内容:
*.doc diff=word2text
*.jpg diff=exif
例如,你可以使用catdoc
程序从二进制 Microsoft Word 文档中提取文本,使用exiftool
从 JPEG 图像中提取 EXIF 元数据。
由于转换可能会比较慢,Git 提供了一种机制,将输出缓存为布尔类型的cachetextconv
属性;缓存的数据通过notes存储(此机制将在第十章中解释,保持历史记录清晰)。负责此设置的配置文件部分如下所示:
[diff "word2text"]
textconv = catdoc
# cached data will be stored in refs/notes/textconv/exif
[diff "exif"]
textconv = exiftool
cachetextconv = true
你可以使用git show
或带有--textconv
选项的git cat-file -p
来查看textconv
过滤器的输出效果。
更复杂但也更强大的选项是使用GIT_EXTERNAL_DIFF
环境变量或diff.external
配置变量,并结合diff
驱动的command
选项。然而,选择使用此选项时,你会失去一些 Git diff
免费提供的功能:上色、单词差异和合并时的合并差异。
这样的程序将用七个参数调用:path
、old-file
、old-hex
、old-mode
、new-file
、new-hex
和new-mode
。在这里,old-file
和new-file
是diff
驱动程序可以用来读取两个不同版本文件内容的文件,old-hex
和new-hex
是文件内容的 SHA-1 标识符,old-mode
和new-mode
是文件模式的八进制表示。该命令预计将生成类似diff
的输出。例如,你可能想使用支持 XML 的diff
工具来比较 XML 文件:
$ echo "*.xml diff=xmldiff" >>.gitattributes
$ git config diff.xmldiff.command xmldiff-wrapper.sh
本示例假设你已经编写了xmldiff-wrapper.sh
脚本来重新排序选项,以便它们符合 XML diff
工具的期望。
配置 diff 输出
Git 用于显示更改的diff
格式在第二章中已经详细描述,使用 Git 开发。每一组更改(称为 hunk)在文本diff
输出中都以 hunk 头部行开始,如下所示:
@@ -18,6 +19,8 @@ int cmd_http_fetch(int argc, const char **argv,
第二个@@
后的文本是用来描述文件中该块所在的部分;对于 C 源文件,它是函数的开始。如何检测该部分的开始取决于文件的类型。Git 允许你通过设置diff
驱动程序的xfuncname
配置选项为正则表达式来配置这一点,该正则表达式与文件部分的描述相匹配。例如,对于 LaTeX 文档,你可能想为tex
diff
驱动程序使用以下配置(你不需要这么做,因为tex
是预定义的内置diff
驱动程序之一):
[diff "tex"]
xfuncname = "^(\\\\(sub)*section\\{.*)$"
wordRegex = "\\\\[a-zA-Z]+|[{}]|\\\\.|[^\\{}[:space:]]+"
wordRegex
配置定义了什么是word
,并为git diff --word-diff
命令(在第二章中描述,使用 Git 开发,接近统一 diff 输出部分的末尾)定义了它。这里,它用于 LaTeX 文档。
注意
你需要将反斜杠加倍:\匹配字面量反斜杠**,因此在正则表达式中需要使用\\**(这在存储正则表达式字符串时是典型的做法)。
执行三方合并
你还可以使用merge
属性告诉 Git 为项目中的特定文件或文件类别使用特定的合并策略。默认情况下,Git 将为文本文件使用三方合并驱动程序(类似于rcsmerge
),并将我们的(被合并的)版本标记为二进制文件的冲突合并结果。你可以通过设置merge
属性(或使用merge=text
)强制三方合并;你可以通过取消设置此属性(使用-merge
,相当于merge=binary
)强制进行类似二进制的合并。
你还可以在你的代码库中编写ChangeLog
文件(包含有描述的变更列表),并使用来自 GNU 可移植库(Gnulib)的git-merge-changelog
命令。你需要在适当的 Git 配置文件中添加以下内容:
[merge "merge-changelog"]
name = GNU-style ChangeLog merge driver
driver = git-merge-changelog %O %A %B
在这里,merge.merge-changelog.driver
中的令牌%O
将展开为包含合并祖先(旧版本)内容的临时文件的名称。%A
和%B
令牌分别展开为包含正在合并的内容的临时文件的名称——即当前(我们的,已合并)版本和其他分支(他们的,已合并)版本。merge
驱动程序预计会将合并后的版本保留在%A
文件中,如果发生合并冲突,将以非零状态退出。你还可以使用%L
来表示冲突标记的大小,并使用%P
查找合并结果将存储的路径名。
注意
你可以为常见祖先之间的内部合并使用不同的驱动程序(当有多个祖先时)。你可以通过为给定驱动程序设置merge.*.recursive配置变量来实现这一点。例如,在这里,你可以使用预定义的binary驱动程序。
当然,你还需要告诉 Git 使用此驱动程序处理ChangeLog
文件,将以下行添加到.gitattributes
中:
ChangeLog merge=merge-changelog
转换文件(内容过滤)
有时,你想放入版本控制系统中的内容格式可能取决于它存储的位置,无论是在磁盘上还是在仓库中,不同位置的格式对 Git、平台(操作系统)、文件系统和用户来说更方便使用。换行符转换可以视为此类操作的一个特例。
为此,你需要为适当的路径设置filter
属性,并配置指定过滤器驱动程序的clean
和smudge
命令(对于通行过滤器,可以不指定任何一个命令)。当检出与给定模式匹配的文件时,smudge
命令会将来自仓库的文件内容作为标准输入传递给它,标准输出用于更新工作目录中的文件。详情请参见图 3.1:
图 3.1 – 当检出(写入文件到工作目录)时,运行“smudge”过滤器
同样,过滤器的clean
命令用于将工作树文件的内容转换为适合存储在仓库中的格式;请参见图 3.2:
图 3.2 – 当文件被暂存(添加到索引,也称为暂存区)时,将运行“clean”过滤器。
在指定命令时,你可以使用%f
令牌,它将被过滤器正在处理的文件名替换。
一个简单的示例是如何使用此功能:使用rezip
脚本处理OpenDocument 格式(ODF)文件。ODF 文档是主要包含 XML 文件的 ZIP 归档。Git 本身使用压缩,并且也进行增量存储(但不能对已压缩的文件进行增量存储);其理念是将未压缩的文件存储在仓库中,但检出时使用压缩文件:
[filter "opendocument"]
clean = "rezip -p ODF_UNCOMPRESS"
smudge = "rezip -p ODF_COMPRESS"
当然,你还需要告诉 Git 为所有类型的 ODF 文件使用此过滤器:
*.odt filter=opendocument
*.ods filter=opendocument
*.odp filter=opendocument
建议性过滤器的另一个示例是使用indent
程序强制代码格式约定,如下例所示,或者使用 Go 编程语言的gofmt
。另一个类似的示例是,在提交时将制表符替换为空格:
[filter "indent"]
clean = indent
另一个示例是nbdev_clean
命令,用于从Jupyter Notebook文件中去除元数据和单元格输出。这样做是为了减少合并冲突的数量,并避免将生成的数据存储在仓库中。
强制性文件转换
内容过滤的另一个用途是将无法直接使用的内容存储在仓库中,并在检出时将其转化为可用的形式。
一个这样的示例可能是使用.gitattributes
文件配置 Git,以便将大型二进制文件存储在 Git 仓库之外(这些文件通常只有一部分开发者使用);仓库内部只有一个标识符,用来从外部存储获取文件内容。这就是git-media
的工作原理:
$ git config filter.media.clean "git-media filter-clean"
$ git config filter.media.smudge "git-media filter-smudge"
$ echo "*.mov filter=media -crlf" >> .gitattributes
提示
你可以在github.com/alebedev/git-media
找到git-media工具。其他类似工具将在第十二章,管理大型仓库中提到,作为处理大型文件问题的可能解决方案之一。
另一个强制性转换的示例是加密敏感内容,或者用占位符替换本地应用程序配置中所需的敏感程序配置(例如,数据库密码)。因为运行此类过滤器,像前述示例中一样,是必需的,才能获得有用的内容,所以你可以标记为必需:
[[filter "clean-password"]
clean = sed -e 's/^pass = .*$/pass = @PASSWORD@/'
smudge = sed -e 's/^pass = @PASSWORD@/pass = passw0rd/'
required
重要提示
这只是一个简化的示例;在实际使用中,如果你这么做或者将真实的密码存储在外部smudge
脚本中,你还必须考虑配置文件本身的安全性。在这种情况下,你还应该设置pre-commit、pre-push和update钩子,以确保密码不会泄露到公开的仓库中(详情请见第十三章,自定义与扩展 Git)。
如果需要处理许多文件,而调用和运行clean
和smudge
脚本所需的时间成为问题,你可以配置 Git,使用一个程序,通过单次调用过滤器处理所有文件,整个 Git 命令生命周期内都使用该过滤器。你可以用process
键定义这样一个过滤器,代替clean
和smudge
。
关键字扩展与替换
然而,虽然很少见,但有时确实需要在文件内容中包含版本化文件的动态信息。为了保持此类信息的最新状态,您可以要求版本控制系统执行 $Keyword$
,即将关键字放在美元符号(keyword anchor)内。通常,版本控制系统会将其替换为 $Keyword: value$
,其中 Keyword
是关键字,value
是其扩展值。
在 Git 中执行此操作的主要问题是,无法在提交之后修改存储在版本库中的文件内容和提交信息,这是由于 Git 的工作原理所决定的(更多信息请参考第十章,保持历史清洁)。这意味着关键字锚点必须原样存储在版本库中,并且只有在签出时才会在工作树中展开。然而,这也是一个优势;在检查历史记录时,不会因关键字扩展而产生无关的差异。
Git 支持的唯一内建关键字是 $Id$
:其值是文件内容的 SHA-1 标识符(表示文件内容的 blob 对象的 SHA-1 校验和,与文件的 SHA-1 不同;有关如何构建对象的详细信息,请参考第十章,保持历史清洁)。您需要通过为文件设置 ident
属性来请求此关键字扩展。
然而,您可以在定义 smudge
命令时编写自己的关键字扩展支持,通过适当的 filter
,该命令将扩展关键字,clean
命令则会将扩展后的关键字替换回其关键字锚点。
使用此机制,您可以例如实现对 $Date$
关键字的支持,在签出时将其扩展为文件最后修改的日期:
[filter "dater"]
clean = sed -e 's/\\\$Date[^\\\$]*\\\$/\\\$Date\\\$/'
smudge = expand_date %f
expand_date
脚本可以将文件名作为参数传递,运行 git log --pretty=format:"%ad" "$1"
命令来获取替换值,例如。
但是,您需要记住另一个限制:为了提高性能,Git 不会触及没有改变的文件,无论是在提交时、切换分支时(签出时)还是回滚分支时(重置时)。这意味着此技巧无法支持项目最后修订日期的关键字扩展(与更改文件的最后一次修订不同)。
如果您需要在分布式源中获取此类信息(例如,当前提交的描述,或自标签发布以来的时间),您可以将其作为构建系统的一部分,或使用 git archive
命令。后者是一个相当通用的功能:如果为文件设置了 export-subst
属性,Git 会在将文件添加到归档时展开 $Format:<PLACEHOLDERS>$
这种通用关键字。
使用 export-subst
的关键字扩展的限制
\(Format\)元关键字的扩展取决于修订标识符的可用性;例如,如果您将树对象的 SHA-1 标识符传递给git archive命令,就无法完成此操作。
占位符与git log
的--pretty=format:
自定义格式相同,这些格式在第四章 探索项目历史中有描述。例如,$Format:%H$
字符串将被替换(而非展开)为提交哈希值。这是一种不可逆的关键字替换;在存档(导出)操作的结果中不会留下关键字的任何痕迹。
其他内置属性
您还可以告诉 Git 在生成存档时不添加某些文件或目录。例如,在面向用户的存档中,您可能不希望包含包含分发测试的目录,这些测试对开发者有用,但对最终用户没有用(这些测试可能需要额外的工具或检查程序质量,而不是检查应用程序行为的正确性)。可以通过设置export-ignore
属性来完成此操作,例如,向.gitattributes
文件添加以下行:
# Do not include extra tests in the archive
xt/ export-ignore
使用文件属性还可以配置diff
和apply
应如何考虑core.whitespace
配置变量。请注意,列出应关注的常见空白问题时,在.gitattributes
文件中应使用逗号作为元素分隔符,并且没有任何周围的空白。请参见以下示例(摘自 Git 项目):
* whitespace=!indent,trail,space
*.[ch] whitespace=indent,trail,space
*.sh whitespace=indent,trail,space
使用文件属性,您还可以指定encoding
属性。Git 可以使用它来选择如何在 GUI 工具中显示文件(例如,gitk
和git gui
)。这是gui.encoding
配置变量的精细版本,并且仅在出于性能考虑明确要求时使用。例如,保存翻译的 GNU gettext 可移植对象(.po)文件应使用 UTF-8 编码:
/po/*.po encoding=UTF-8
要使 Git 在暂存区和仓库之间进行 UTF-8 编码转换,并指定工作目录中检出文件的编码,可以使用working-tree-encoding
属性。例如,要让diff
和其他命令正常工作,您可能想要使用以下命令:
*.ps1 text working-tree-encoding=UTF-16LE eol=CRLF
注意
重新编码可能会减慢某些 Git 操作。
定义属性宏
在识别二进制文件和行尾转换部分,我们学习了如何使用binary
属性标记二进制文件。binary
属性是-diff -merge -text
(取消设置这三个文件属性)。定义这种宏以支持属性的任意组合会非常有用。一个给定类型的文件可以匹配多个模式,但一个.gitattributes
行只能包含一个文件模式。如果我们希望不同类型的文件具有相同的属性,属性宏可以避免重复。
Git 允许我们定义这样的宏,但仅限于顶层的.gitattributes
文件,即core.attributesFile
、.git/info/attributes
,或者项目的主(顶层)目录下的.gitattributes
文件。内置的binary
宏可以这样定义:
[attr]binary -diff -merge -text
你也可以定义自己的属性。在这种情况下,你可以使用git check-attr
命令以编程方式检查给定文件设置了哪些属性,或者一组文件的某个属性值是什么。
使用重置命令修复错误
在开发的任何阶段,你可能想使用git undo
命令,但核心 Git 中并没有这个命令,Git 命令中也没有支持通用的--undo
选项,尽管许多命令都有--abort
选项用于放弃当前的工作进度(WIP)。没有这种命令或选项的原因之一是对于需要撤销的内容没有明确的定义(特别是对于多步骤操作)。
许多错误可以通过git reset
命令来修复。它可以用于多种目的和方式;理解该命令的工作原理将帮助你在任何情况下使用它,使用场景不限于提供的示例。
请注意,本节仅涉及git reset
的完整树模式;git reset -- <file>
的作用(这是使用更现代的git restore <file>
命令的替代方式)已被留到本章末尾的管理工作区和暂存区部分。
回滚分支头,软重置
git reset
命令在其完整树模式下会影响当前分支头,也可能影响索引(暂存区)和工作目录。这个重置不会改变当前分支,与git checkout
或git switch
不同。
要仅重置当前分支头,而不触及索引或工作区,你可以使用git reset --soft [<revision>]
(如果没有给定修订版本,它默认是HEAD
):
图 3.3 – 软重置前后
实际上,我们只是将当前分支(示例中是master
,见图 3.3)的指针指向某个修订版本(示例中的HEAD^
——上一个提交)。暂存区和工作目录不会受到影响。这使得所有已更改的文件(以及分支所指向的旧修订和新修订之间不同的所有文件)在git status
中都会显示出来。
删除或修改提交
命令的工作方式意味着git commit
有一个--amend
选项。
让我们来看看以下命令:
$ git commit --amend [<options>]
这相当于以下操作:
$ git reset --soft HEAD^
$ git commit --reedit-message=ORIG_HEAD [<options>]
git commit --amend
命令也适用于合并提交,而不是使用软重置。在修改提交时,如果你只想修复提交消息,不会有其他选项。如果你想在不更改提交消息的情况下包括工作目录中的修复,可以添加 --all --no-edit
。如果你想在修正 Git 配置后修复作者信息,可以使用 --reset-author --no-edit
。
你已经在 第二章《使用 Git 开发》中学习过如何通过修改提交来更改修订图,在 修改提交 部分。
使用重置压缩提交
你并不局限于将分支头回退到上一个提交。通过软重置,你可以将一些较早的提交合并(例如,提交和修复 bug,或引入新功能并使用它),将两个(或更多)提交合并为一个;另外,你也可以使用 merge --squash
的 squash
指令来实现这一点。
重置分支头和索引
reset
命令的默认模式——所谓的 混合重置(因为它介于软重置和硬重置之间)——会更改当前分支头,使其指向给定的修订,并且重置索引,将该修订的内容放入暂存区。此模式在 图 3.4 中显示:
图 3.4 – 混合重置前后的对比
这将把你所有更改过的文件(以及分支指向的旧修订与新修订之间的所有不同文件)保留在 git status
会显示的状态中。git reset --mixed
命令也会以简短的状态格式报告哪些内容没有更新:
$ git reset HEAD^
Unstaged changes after reset:
M README.md
这种版本的 reset
命令可以用于例如撤销所有新增文件的操作。通过运行 git reset
,如果你没有暂存任何更改(或者你可以接受丢失它们),就可以完成此操作。如果你想撤销某个特定文件的添加,可以使用 git rm --cached <file>
。
使用重置将提交拆分为两部分
你可以使用混合重置将提交拆分为两部分。首先,运行 git reset HEAD^
将分支头和索引重置为之前的修订。然后,交互式地添加你想包含在第一个提交中的更改,之后从索引中创建第一个提交(git add -i
和 git commit
)。然后,可以从工作目录状态中创建第二个提交(git commit -a
)。
如果交互式地移除更改更容易,也可以选择这种方式。使用 git reset --soft HEAD^
,通过交互式按文件撤销暂存的更改,在索引中创建第一个提交,然后从工作目录创建第二个提交。
在这里,同样地,就像压缩提交一样,你可以使用交互式 rebase 来进一步拆分历史中的提交。rebase 操作会切换到适当的提交点,此时可以进行实际的拆分,具体操作如这里所述。
使用 WIP 提交保存和恢复状态
假设你在开发分支上工作时被一个紧急的 bug 修复请求中断。你不想丢失更改,但工作树有点混乱,你也没有时间完成提交。一个可能的解决方案是通过创建临时提交来保存当前工作区的状态:
$ git commit -a -m 'snapshot WIP (Work In Progress)'
然后,你可以处理这个中断,切换到维护分支并创建一个提交来修复问题。此时,你需要返回到之前的分支(使用 checkout),将 WIP 提交从历史记录中移除(使用软重置),并返回到未暂存的起始状态(使用混合重置),如下所示:
$ git switch -
$ git reset --soft HEAD^
$ git reset
通常,使用git stash
来处理中断会更加方便,详见本章的暂存你的更改部分。另一方面,暂时的提交可以与其他开发者共享,而不像 stash 那样(因为 stash 栈是基于纯本地数据的——reflog)。
丢弃更改并回退分支
有时,你的文件可能会变得一团糟,以至于你想丢弃所有更改,并将工作目录和暂存区(索引)恢复到最后一次提交的状态,即恢复到最后一个正常版本。在其他情况下,你可能想将仓库的状态回退到早期的版本。在这种情况下,硬重置是你需要的操作;它将更改当前分支的头指针,同时重置索引和工作树。任何对已跟踪文件的更改都会被丢弃:
图 3.5 – 硬重置前后的状态
这个命令可以用来撤销一个提交,就像它从未发生过一样,方法是将其移除。运行git reset --hard HEAD^
将有效地丢弃最后一次提交(尽管它会在有限的时间内通过 reflog 可用),除非这个提交可以通过其他分支访问。
另一个常见的用法是使用git reset --hard
丢弃工作目录中的更改,这将重置为最后一次提交的状态。
重要提示
需要非常注意的是,硬重置会不可恢复地删除暂存区和工作目录中的所有更改。你不能撤销此操作!更改将永远丢失!
将提交移动到功能分支
假设你正在master
分支上工作,并且已经创建了一系列提交。你意识到你正在处理的功能更复杂,想要在一个单独的主题分支上继续完善它,正如在第八章中所描述的,高级分支技巧。你想把master
中的所有提交(假设是最后三次修订)移动到前述的功能分支。
你需要创建功能分支,保存未提交的更改(如果有),回退master
分支并移除其中的相关提交,然后切换到功能分支继续工作(或者你可以使用变基代替):
$ git branch feature/topic
$ git stash
No local changes to save
$ git reset --hard HEAD~3
HEAD is now at f82887f before
$ git switch feature/topic
Switched to branch 'feature/topic'
当然,如果有本地更改需要保存(在前面的例子中没有),那么在这组命令执行之后,需要使用git stash pop
。
撤销合并或拉取
硬重置也可以用于中止失败的合并。例如,如果你决定此时不想解决合并冲突,可以使用git reset --hard HEAD
(此处,HEAD
是修订版本的默认值,可以省略),虽然在现代 Git 中,你也可以使用git merge --abort
代替。
你还可以使用git reset --hard ORIG_HEAD
来移除一个成功的快进拉取操作或撤销变基(以及许多其他在移动分支头部时的操作)。(在这里,你可以使用HEAD@{1}
代替ORIG_HEAD
。)
更安全的重置 – 保留你的更改
硬重置会丢弃你的本地更改,类似于git switch --discard-changes
或git checkout --force
的效果。有时,你可能想回退当前分支并保留本地更改:这就是git reset --keep
的用途。
图 3.6 – 成功执行 git reset --keep HEAD^
命令前后的情况
该模式会重置暂存区(索引条目),但保留当前工作目录中的未暂存(本地)更改;见 图 3.6。如果无法执行,重置操作会被中止:
$ git reset --keep HEAD^
error: Entry 'README' not uptodate. Cannot merge.
fatal: Could not reset index file to revision 'HEAD^'.
这意味着工作树中的本地更改将被保留并移到新的提交中,类似于git checkout <branch>
在未提交更改时的操作。成功的情况有点像是暂存更改、硬重置,然后再恢复(但用一个原子命令完成)。
安全重置是如何工作的?
git reset --keep
将当前更改变基到早期的修订版本
假设你正在处理某个任务,但你意识到工作目录中的内容应该在另一个分支上,而与之前的提交无关。例如,你可能在master
分支上开始修复一个 bug,随后才意识到它也影响了维护分支maint
。
这意味着修复应该早些放入分支,从这些分支的共同祖先开始(或错误被引入的地方)。这样就可以将相同的修复合并到 master
和 maint
分支中,正如在 第十五章 Git 最佳实践 中所描述的那样:
$ edit
$ git checkout -b bugfix-127
$ git reset --keep start
另一种解决方案是简单地使用 git stash
来移动更改:
$ edit
$ git stash
$ git switch -c bugfix-127 start
$ git stash pop
暂存你的更改
通常,当你在一个项目上工作时,状态会变得混乱,无法提交为永久更改,这时你可能想要暂时保存当前状态,然后去处理其他事情。解决这个问题的命令就是 git
的 stash
命令。
暂存会将工作区域的脏状态——也就是你工作区内修改过的跟踪文件以及暂存区的状态——保存下来,并将工作目录和索引重置为最后一次提交的版本(与 HEAD
提交匹配),这实际上执行了 git reset --hard HEAD
。之后,你可以随时重新应用暂存的更改。
你还可以通过 --include-untracked
选项来暂存未跟踪的文件。
暂存内容保存在一个栈中:默认情况下,你会应用最后一次暂存的更改(stash@{0}
),不过你可以列出所有的暂存更改(使用git stash list
),并显式选择任何一个暂存内容。
使用 git stash
如果你不预期中断会持续太久,你可以简单地暂存你的更改,处理完中断后再恢复它们:
$ git stash
$ # ... handle interruption ...
$ git stash pop
默认情况下,git stash pop
会应用最后一次暂存的更改,并在应用成功后删除该暂存。如果你想查看已保存的暂存内容,可以使用 git stash list
:
$ git stash list
stash@{0}: WIP on master: 049d078 atoi() is deprecated
stash@{1}: WIP on master: c264051 Add error checking
你可以通过指定暂存名称作为参数,或仅指定其编号来使用任何较早的暂存。例如,你可以运行 git stash apply stash@{1}
或 git stash apply 1
来应用它,且可以使用 git stash drop stash@{1}
或 git stash drop 1
来删除它(从暂存列表中移除);git stash pop
命令其实是 apply
+ drop
的快捷方式。
Git 给暂存更改的默认描述(即 git stash show -p
)。但如果你预计中断会涉及更多内容,你应该在描述你正在做的工作时将当前状态保存为一个暂存:
$ git stash push -m 'Add <count>'
Saved working directory and index state On master: Add <count>
HEAD is now at 049d078 atoi() is deprecated
Git 随后会使用提供的消息来描述暂存的更改,当列出暂存内容时:
$ git stash list
stash@{0}: On master: Add <count>
stash@{1}: WIP on master: c264051 Add error checking
有时,当你执行 git stash save
时,你正在工作的分支发生了足够的变化,以至于执行 git stash pop
失败,因为在你暂存更改时,已经有太多的新修订提交。若你希望将暂存的更改创建为常规提交,或者只是测试暂存的更改,你可以使用 git stash branch <branch name>
。此命令会在你保存更改时所在的修订版本上创建一个新分支,切换到该分支,将你的工作重新应用于此分支,并删除暂存的更改。
暂存与暂存区
默认情况下,暂存会将工作目录和暂存区都重置为HEAD
版本。你可以使用--keep-index
选项让git stash
保留索引的状态并将工作区重置为暂存状态:
图 3.7 – git stash
与不使用--keep-index
的区别
这在你使用暂存区来整理工作目录中的更改时非常有用,如在第二章中《使用 Git 开发》一节中的选择性提交部分所描述,或者如果你想将提交拆分成两部分,如本章中的使用 reset 拆分提交部分所描述。在这两种情况下,你都会希望在提交之前测试每个更改。
工作流程如下所示:
$ git add --interactive
$ git stash --keep-index
$ make test
$ git commit -m 'First part'
$ git stash pop
你还可以使用git stash --patch
来选择暂存更改后,工作区应该是什么样子。
在恢复暂存的更改时,Git 通常会尝试只应用已保存的工作区更改,并将其添加到当前工作目录的状态(该状态必须与暂存区匹配)。如果在应用状态时发生冲突,冲突会像往常一样存储在索引中——如果存在冲突,Git 不会丢弃暂存。
你还可以尝试使用--index
选项恢复暂存区的已保存状态;如果在应用工作区更改时存在冲突,这将失败(因为暂存区已占用,无法存储冲突)。
Stash 内部结构
或许你应用了暂存的更改,做了一些工作,然后因为某种原因想要撤销最初来自暂存区的更改。也许你误删了暂存,或者清除了所有暂存(你可以通过git stash clear
来执行此操作),并希望恢复它们。或者你可能想查看在暂存更改时文件的样子。要做这些操作,你需要知道 Git 在创建暂存条目时是如何处理的。
为了暂存你的更改,Git 会创建两个自动提交:一个用于索引(暂存区),另一个用于工作目录。使用git stash --include-untracked
时,Git 会为未追踪的文件创建一个额外的第三个自动提交。
包含工作目录中进行中的工作的提交(即从那里跟踪的文件的状态)将暂存区(索引)中的提交作为其第二个父提交。这个包含进行中的工作的提交被存储在一个特殊的引用中:refs/stash
。无论是进行中的工作(stash)还是索引提交,都将保存更改时的修订作为其第一个父提交。
我们可以通过运行git log --graph
或gitk --all
来查看:
$ git stash save --quiet 'Add <count>'
$ git show-ref --abbrev
765b095 refs/heads/master
81ef667 refs/stash
$ gitk --all
这可以通过以下图示来查看:
图 3.8 – 不包含和包含未跟踪文件信息的 stash 结构。图形是通过在新创建的包含单个提交和 stash 的仓库上使用 gitk --all 生成的。
我们在这里不得不使用git show-ref
(本来可以使用git for-each-ref
)是因为git branch -a
只显示分支,而不显示任意引用。
在保存未跟踪的更改时,使用git stash --include-untracked
,情况类似。图 3.8显示未跟踪文件提交是 WIP 提交的第三个父提交,并且它没有任何父提交。它仅由未跟踪的文件组成,你可以使用git ls-tree -r stash@{<n>}³
检查它们。
好的,这就是 stash 的工作方式,但 Git 是如何维护 stash 栈的呢?你可能已经注意到,git stash list
的输出和其中的stash@{<n>}
表示法看起来像 reflog;Git 通过refs/stash
引用在 reflog 中查找较旧的 stash:
$ git reflog stash --no-decorate
81ef667 stash@{0}: On master: Add <count>
bb76632 stash@{1}: WIP on master: Added .gitignore
这就是为什么你不能共享 stash 栈的原因:reflog 是本地仓库的,并且在推送或获取时不能同步。
取消应用 stash
让我们以本节开头的第一个例子为例:撤销先前git stash apply
的更改。实现所需效果的一个可能解决方案是从 stash 中检索与工作目录更改相关的补丁,并反向应用它:
$ git stash show -p stash@{0} | git apply -R -
注意-p
选项是如何应用到git stash show
命令的——它强制输出补丁,而不是变更的总结。我们可以使用git show -m stash@{0}
(-m
选项是必要的,因为代表 stash 的 WIP 提交是一个合并提交),甚至可以简单地使用git diff stash@{0}^-1
来代替git stash show -p
。
恢复错误丢失的 stash
让我们尝试第二个例子:恢复被意外删除或清除的 stash。如果它们仍在你的仓库中,并且在仓库维护阶段没有被删除,你可以搜索所有无法从其他引用中访问的提交对象,看看它们是否像 stash(即,它们是合并提交,并且有使用严格模式的提交信息)。
一个简化的解决方案可能看起来像这样:
$ git fsck --unreachable |
grep "unreachable commit " | cut -d" " -f3 |
git log --stdin --merges --no-walk --grep="WIP on "
这条管道的第一行找到所有不可达(丢失的)对象,第二行过滤掉除提交以外的所有内容,并提取它们的 SHA-1 标识符,第三行进一步过滤,仅显示提交消息中包含"WIP on "
字符串的合并提交。
然而,这种方法无法找到带有自定义消息的 stash(那些使用git stash save "message"
创建的);你需要再加一个--grep
。
管理工作树和暂存区
在第二章,使用 Git 开发中,我们了解到,除了用于修改的工作目录(工作树)和用于存储已提交修改的本地仓库外,它们之间还有一个第三部分:暂存区,有时也叫做索引。
在同一章节中,我们学习了如何检查工作目录的状态,以及如何查看差异。我们还学会了如何从工作目录或暂存区创建一个新的提交。
现在,是时候学习如何检查和修改单个文件的状态了。
检查文件和目录
查看工作目录的内容很容易:你只需使用标准的文件查看工具(例如,编辑器或分页器)和目录查看工具(例如,文件管理器或dir
命令)。但是我们如何查看文件的暂存内容或最后一次提交的版本呢?
一种解决方案是使用git show
命令和适当的选择器。第四章,探索项目历史,将介绍并解释<revision>:<pathname>
语法,以检查给定修订版本中文件的内容。类似的语法也可用于检索暂存内容,即:<pathname>
(如果该文件涉及合并冲突,则使用:<stage>:<pathname>
;:<pathname>
本身等价于:0:<pathname>
)。
假设我们在src/
子目录中,想要查看该目录中rand.c
文件的内容,分别查看它在工作目录中的内容、暂存区中的内容(使用绝对路径和相对路径),以及最后一次提交中的内容(同样使用绝对路径和相对路径):
src $ less -FRX rand.c
src $ git show :src/rand.c
src $ git show :./rand.c
src $ git show HEAD:src/rand.c
src $ git show HEAD:./rand.c
若要查看已暂存到索引中的文件列表,可以使用git ls-files
命令。默认情况下,它操作的是暂存区的内容,但也可以用于检查工作目录。正如我们在这一章节中所看到的,这一功能可用于列出被忽略的文件。该命令会列出指定目录中的所有文件。或者,在当前目录下,你可以使用:/
来表示项目的顶级目录。递归行为源自于索引是一个类似于MANIFEST
文件的扁平化文件列表。
如果不使用--full-name
选项,它将显示相对于当前目录(或指定目录作为参数)的文件名。在所有示例中,我们假设我们在src/
子目录中,正如命令提示符所示:
src $ git ls-files
rand.c
src $ git ls-files --full-name :/
COPYRIGHT
Makefile
README
src/rand.c
那么,已提交的更改呢?我们如何查看某个修订版本中包含了哪些文件?这时git ls-tree
就能派上用场(注意,它是一个“管道”命令,并且默认不会查看HEAD
修订版本):
src $ git ls-tree --name-only HEAD
rand.c
src $ git ls-tree --abbrev --full-tree -r -t HEAD
100644 blob 862aafd COPYRIGHT
100644 blob 25c3d1b Makefile
100644 blob bdf2c76 README
040000 tree 7e44d2e src
100644 blob b2c087f src/rand.c
请注意,git ls-tree
默认不进行递归;你需要使用-r
选项。
搜索文件内容
假设你在项目中审查代码时,发现 C 源代码中有一个错误的双分号;;
,或者你正在编辑文件时发现了一个附近的 bug。你修复了它,但你在想,“这些错误到底有多少个?”你希望创建一个提交来修复这些错误。
或者你可能想搜索计划在下次提交时使用的版本——也就是暂存区的内容。或许你想查看它在next
分支中的样子。
使用 Git,你可以使用git
grep
命令:
$ git grep -e ';;'
默认情况下,这个命令会递归地搜索工作目录中已跟踪的文件,从当前目录开始往下。注意,当运行示例命令时,我们会从 shell 脚本等获得许多误报。因此,让我们将搜索范围限制为仅 C 源文件:
$ git grep -e ';;' -- '*.c'
对*.c
的引号是必须的,以便 Git 进行通配符模式扩展(路径限制),而不是让git grep
通过 shell 扩展获得文件列表。我们仍然会遇到来自 C 语言中永远循环的许多误报:
for (;;) {
使用git grep
,你可以构造复杂的条件,排除误报。假设我们想搜索整个项目,而不仅仅是当前目录,并且避免误报:
$ git grep -e ';;' --and --not 'for *(.*;;' -- '**/*.c'
要搜索暂存区,使用git grep --cached
或等效的命令——或许更容易记住的——git grep --staged
。要搜索next
分支,使用git grep next --
;这种构造可以用于搜索任何版本。
取消跟踪、取消暂存和取消修改文件
如果你想撤销某些文件级的操作(例如,如果你改变了主意,不再跟踪文件或暂存更改),那么就看一下git status
提示(加上--ignored
可以查看有关忽略文件的提示):
$ git status --ignored
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
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)
Untracked files:
(use "git add <file>..." to include in what will be committed)
Ignored files:
(use "git add -f <file>..." to include in what will be committed)
你需要记住,只有工作目录和暂存区的内容可以被更改。已提交的更改是不可变的(虽然你可以回滚历史或替换它)。
如果你想撤销将一个以前未跟踪的文件添加到索引中,或者将一个以前已跟踪的文件从暂存区移除,这样它将在下次提交中被删除(不会出现在提交中),但仍然保留在工作目录中——使用git rm --cached <file>
。
--cached
(--staged
)和--index
选项的区别
许多 Git 命令,包括git diff、git grep和git rm,支持--cached选项(或其别名--staged)。其他命令,如git stash,则有--index选项(索引是暂存区的另一个名称)。这些不是同义词(正如我们稍后会看到的git apply命令,它支持两者)。
--cached选项用于要求命令通常作用于工作目录中的文件时,仅作用于暂存的内容,代替工作目录。例如,git grep --cached将搜索暂存区,而不是工作目录,git rm --cached将仅从索引中移除文件,保留在工作树中。
--index选项用于要求命令通常作用于工作目录中的文件时,同时也作用于索引,另外影响索引。例如,git stash apply --index不仅恢复了暂存的工作目录更改,还恢复了索引。
如果你要求 Git 记录某个文件在暂存区的状态,但改变了主意,你可以使用git restore --staged <file>
(默认情况下--source=HEAD
)或git reset HEAD -- <file>
将文件的暂存内容重置为已提交版本。
如果你错误地编辑了文件,以至于工作目录中的版本已经乱七八糟,并且你想将其恢复到索引中的版本,可以使用git restore <file>
(如果没有给出--staged
,--worktree
是默认选项)或git checkout -- <file>
。如果你将这些混乱的更改暂存了,并且想将工作树和暂存区都恢复到上次提交的版本,可以使用git restore --worktree --staged <file>
或git checkout HEAD -- <file>
。
重要提示
这些命令不会撤销操作;它们是基于工作树、索引或已提交版本的备份恢复到先前的状态。例如,如果你暂存了一些更改,修改了一个文件,然后将修改添加到暂存区,你可以将索引重置为已提交的版本,而不能重置到第一次git add
后和第二次git add
前的状态。
将文件重置为旧版本
恢复文件时,你可以使用任何修订版本,并且可以进行单文件重置或单文件检出。例如,要用上一个提交的版本替换当前工作树中的src/rand.c
文件,可以使用git restore -s HEAD^ src/rand.c
或git checkout HEAD^ -- src/rand.c
(或者将git show HEAD^:src/rand.c
的输出重定向到文件)。要将next
分支中的版本放入暂存区,可以运行git restore -s next src/rand.c
或git reset next -- src/rand.c
。
请注意,git add <file>
、git restore <file>
、git reset <file>
和git checkout <file>
在使用--patch
选项时会进入交互模式。这可以用来精确地选择哪些更改应该应用(或撤销),从而手动处理文件的暂存区或工作区版本。
提示
使用命令行操作 Git 时,如果例如有一个文件与分支同名,你可能需要在其他选项后和文件名之前添加两个破折号,--。
清理工作区
未跟踪的文件和目录可能会堆积在工作目录中。它们可能是合并的残留文件,临时文件,概念验证工作文件,或者可能是误放的文件。无论哪种情况,它们通常没有规律,而你不需要也不想让 Git 忽略它们(请参阅本章的忽略文件部分);你只需要将它们删除。你可以使用git clean
命令来实现这一点。
由于未跟踪的文件在仓库中没有备份,并且你无法撤销它们的删除(除非操作系统或文件系统支持撤销或回收站),因此建议先使用--dry-run
/-n
查看哪些文件可以删除。默认情况下,实际删除需要--force
/-f
选项:
$ git clean --dry-run
Would remove patch-1.diff
Git 将递归清理所有未跟踪的文件,从当前目录开始。你可以通过将路径列为参数来选择受影响的路径;你还可以使用 --exclude=<pattern>
选项排除额外类型的文件。你还可以使用 --interactive
选项交互式选择删除哪些未跟踪的文件:
$ git clean --interactive
Would remove the following items:
src/rand.c~
screenlog.0
*** Commands ***
1: clean 2: filter by pattern 3: select by numbers
4: ask each 5: quit 6: help
What now>
clean
命令还允许我们只删除被忽略的文件,例如,使用 -X
选项删除构建产品,但保留手动跟踪的文件。然而,通常情况下,最好将构建产物的删除交给构建系统,这样可以在不克隆仓库的情况下清理项目文件。
你还可以将 git clean -x
与 git reset --hard
配合使用,通过删除被忽略和未被忽略的未跟踪文件并将已跟踪文件重置为提交版本,来创建一个干净的工作目录,以测试干净构建。
多个工作目录
很长一段时间,Git 允许你指定在何处找到仓库的管理区域(.git
目录)。这可以通过在 git
命令中添加 --git-dir=<path>
选项(即 git --git-dir=<path> <command>
构造),或通过设置 GIT_DIR
环境变量来完成。此功能使得从分离的 工作目录中工作成为可能。
使用现代 Git,你有了比手动配置更好的解决方案来创建新的链接工作树:git worktree add <path> <branch>
。此功能允许我们同时检出多个分支。为了方便起见,如果省略 <branch>
参数,则新分支将根据新创建的工作树名称创建。
如果你需要切换到另一个分支,但当前的工作目录以及可能的暂存区处于高度混乱的状态,可以使用此机制代替 git stash
。你可以创建一个临时链接的工作树进行修复,完成后再删除它。例如,你可能需要在一个单独的分支上紧急修复安全漏洞时使用此方法。
每个分离的工作树应该与不同的分支关联并已检出,或者位于匿名分支(分离的 HEAD
)上,以避免问题。你可以使用 --force
选项覆盖此安全措施。
你可以使用 git worktree remove
或通过删除其目录并允许它被修剪来删除任何分离的工作树。如果工作树位于便携设备或网络磁盘上(可能并非始终可用),我们可以 lock
工作树,以防它被修剪(如果不再需要,可以 unlock
)。
要查看每个工作目录的详细信息,例如当前检出的分支,并查看它是否被锁定,可以使用 git worktree
list
命令。
总结
在本章中,我们学习了如何更好地管理工作目录和暂存区的内容,为创建新的提交做准备。
我们现在知道如何撤销最后一次提交,如何丢弃工作区中的更改,如何回溯更改我们正在使用的分支,以及如何使用git reset
命令的其他用途。我们也理解了三种(以及一种半种)重置形式。
我们还学习了如何检查和搜索工作目录、暂存区和已提交的更改内容。现在我们知道如何使用 Git 将文件版本从工作树、索引或HEAD
提交复制到工作树或索引中。我们可以使用 Git 清理(删除)未跟踪的文件。
本章解释了如何配置工作目录中文件的处理方式,以及如何使 Git 忽略文件(通过将其故意设为未跟踪状态)及其原因。它描述了如何处理不同操作系统之间的行结束符格式差异。它还解释了如何启用(并编写)关键字扩展,如何配置二进制文件的处理方式,以及如何增强diff
和merge
特定类别文件的处理。
最后,我们学会了如何暂存更改,以应对中断并在创建提交之前,测试已准备好的交互式提交。本章解释了 Git 如何管理暂存,使我们能够超越内置操作。
本章以及第二章,使用 Git 开发,教你如何为项目做出贡献。
接下来的章节将教你如何与他人协作,如何提交你所贡献的内容,以及如何合并其他开发者的更改。我们将从两章开始,分别讲解如何探索和搜索项目历史,第四章,探索项目历史和第五章,搜索仓库。
问题
请回答以下问题,以测试你对本章内容的掌握:
-
如何避免在git status输出中出现大量构建工件?
-
假设你使用的是自定义的领域特定语言(DSL)或 Git 原生不支持的编程语言(例如 Julia)。如何配置 Git,使其更好地支持这种语言?
-
如何将最近的两个提交合并为一个提交?
-
如何将最近的提交拆分为两个提交?
-
如果需要紧急更改(例如,因安全漏洞),但工作区处于混乱状态,你又不想丢失工作,应该怎么做?
-
如何在不检出修订版的情况下,搜索项目的旧版本——例如,标记为v0.1的版本?
答案
下面是以上问题的答案:
-
将与这些构建工件路径名匹配的模式添加到.****gitignore文件中。
-
定义自定义diff驱动程序,并提供与代码的主要“部分”匹配的正则表达式模式,使用xfuncname。同时,添加一个适当的正则表达式来定义该编程语言中的单词,使用wordRegex,并且可能还需要定义与whitespace属性相关的空格问题。
-
使用git reset --soft HEAD~2来回滚分支并通过git commit创建合并提交,或者使用交互式 rebase。
-
执行软重置,git reset --soft HEAD^,使用interactive add构建第一个提交,通过git stash --keep-index测试代码,如果测试通过,则弹出 stash 并用git commit进行第一次提交,用git commit -a进行第二次提交;还有其他解决方案。
-
使用git stash暂存当前更改,创建一个 WIP 提交,或者使用git worktree add为紧急工作创建一个新的分离工作区。
-
要从标记为 v0.1 的修订版本中搜索文件内容,可以使用git grep -e <****pattern> v0.1。
深入阅读
要了解本章中涉及的更多内容,请查看以下资源:
-
Scott Chacon 和 Ben Straub,Pro Git,2.2 Git 基础 - 记录仓库的变更,忽略文件部分:
git-scm.com/book/en/v2/Git-Basics-Recording-Changes-to-the-Repository#_ignoring
-
Scott Chacon 和 Ben Straub,Pro Git,7.3 Git 工具 - 暂存和 清理:
git-scm.com/book/en/v2/Git-Tools-Stashing-and-Cleaning
-
Scott Chacon 和 Ben Straub,Pro Git,8.2 自定义 Git - Git Attributes:
git-scm.com/book/en/v2/Customizing-Git-Git-Attributes
-
gitattributes 手册页 - 为 路径定义属性:
www.git-scm.com/docs/gitattributes
-
gitignore 手册页 - 指定故意未跟踪的文件以 忽略:
www.git-scm.com/docs/gitignore
-
Pragati Verma,Git Stash 指南(2021):
dev.to/pragativerma18/a-guide-to-git-stash-2h5d
-
Andrew Knight,使用 Git 忽略文件(2018):
automationpanda.com/2018/09/19/ignoring-files-with-git/
-
Dragos Barosan,Git 新特性:切换和恢复(2021):
www.banterly.net/2021/07/31/new-in-git-switch-and-restore/
第四章:探索项目历史
掌握版本控制系统(VCS)的一个重要部分是探索项目历史,并利用 VCS 保存了每个版本的档案这一事实。例如,你可能希望查看其他开发者所做的工作,或提醒自己即将发布的内容。
在本章中,你将学习如何选择和查看一个修订或一系列修订,以及如何引用它们。下一章将继续这一主题,解释如何使用不同的标准查找修订,并如何在已选择的修订中进行搜索;同时,它还会描述如何在项目内容中进行搜索。
本章还将介绍修订的有向无环图(DAG)概念,并解释该概念与项目历史的关系,以及与 Git 中分支、标签和当前分支的关系。
这里是我们将在本章中讨论的主题列表:
-
作为表示历史的方式的修订 DAG
-
不同的修订选择方式
-
选择起始分支和标签
-
使用 reflog 中的数据来选择修订
-
双点(A..B)和三点表示法(A…B)用于修订范围选择
-
高级修订范围选择
本章的目的是教你如何选择项目历史中的相关部分。下一章将解释如何通过搜索你选择的内容进一步探讨这个问题。
DAGs
版本控制系统(VCS)与备份应用程序的不同之处在于能够表示超越线性历史的内容。这对于支持不同开发者之间的并行开发(每个开发者在自己克隆的仓库中)以及允许独立的并行开发线(即分支)是必要的。例如,使用 VCS,你可能希望将正在进行的开发与稳定版本的 bug 修复工作隔离开来。你可以通过为这些不同的开发线使用单独的分支来实现这一点。因此,VCS 需要能够建模这种非线性的开发方式,并且需要具备一些结构来表示它。
Git 用来表示项目可能存在的非线性历史的结构(在抽象层次上)叫做有向无环图(DAG)。
下图(图 4.1)展示了一个 DAG 的示例,使用两种不同的方式绘制。图中的相同图形在两侧都呈现:左侧使用自由格式布局,右侧使用从左到右的布局。
图 4.1 – DAG 的通用示例,使用不同布局绘制的相同图形
有向图是计算机科学和数学中的一种数据结构,由节点(顶点)通过有向边(箭头)连接组成。如果一个有向图不包含任何循环,它就是无环的,这意味着无法从某个节点出发,沿着有向边的序列返回到起始节点。
我认为理解这个主题有助于掌握探索、搜索和塑造项目历史的艺术。你可能需要多读几遍以内化这些知识。然而,这并不是使用 Git 成功所必需的,因此在第一次阅读时可以跳过这一部分。
在图的特定实现中,每个节点代表某个对象(或数据的一部分),而从一个节点到另一个节点的每一条边代表它们之间某种关系(或者节点所代表的数据之间的关系)。
分布式版本控制系统(DVCS)中的修订版的有向无环图(DAG)使用如下表示:
-
节点:在分布式版本控制系统(DVCS)中,每个节点代表项目(整个树)的一个修订版(一个版本)。这些对象被称为提交。
-
有向边:在 DVCS 中,每条边表示两个修订版之间的某修订版基于某修订版的关系。箭头从较新的子节点指向较早的父节点,即它基于的修订版。这与大多数人习惯的时间箭头的思维方式相反——也就是,从较早的提交指向较新的提交。
由于有向边表示修订版之间的基于因果关系,修订版的有向无环图中的箭头不能形成循环。通常,修订版的有向无环图是从左到右(根节点在左,叶子节点在右)或从下到上(最新的修订版在上)排列的。本书中的图示和 Git 文档中的 ASCII 艺术示例采用的是从左到右的排列方式,而 Git 命令行使用的是从下到上的排列方式,即最新的修订版在上。
在任何有向无环图中都有两种特殊类型的节点(见图 4.1):
-
根节点(或根):这些是没有父节点(没有出边)的节点(修订版)。在修订版的有向无环图中至少有一个根节点,它代表项目的初始(起始)版本。
-
叶子节点(或叶子):这些是没有子节点(没有入边)的节点;至少会有一个这样的节点。它们代表项目的最新版本,且没有任何基于它们的工作。通常,修订版的有向无环图(DAG)中的每个叶子节点都有一个指向它的分支头。
重要提示
在 Git 的修订 DAG 中可以有多个根节点。当你将两个原本独立的项目合并时,可以创建额外的根节点;每个合并的项目都会带来自己的根节点。由于这是一个非常罕见的情况,现代 Git 中你需要为git merge或git pull命令传递--allow-unrelated-histories选项,以帮助避免错误。
另一个根节点的来源是孤立分支——即没有共同历史的断开分支。例如,GitHub 用它们来管理项目的网页,这些网页与代码存储在同一个仓库中(在gh-pages分支中);Git 项目本身也使用它们来存储预生成的文档(man和html分支)以及相关项目(todo分支)。要创建这样的分支,需要在git checkout或git switch中使用--orphan选项。
DAG 可以有多个叶子节点,这意味着不存在像线性历史范式中的“最新版本”这一概念。
整棵树的提交
在分布式版本控制系统(DVCS)中,修订的每个节点(DVCS 的历史模型)代表一个项目作为一个整体的版本:一个快照,即项目整个目录树内容的快照。
这意味着默认情况下,每个开发者都会获得他们仓库克隆中的所有文件的历史记录。如果需要,他们可以选择仅获取部分历史记录(浅克隆和/或仅克隆选定分支),可以仅签出选定文件(稀疏签出),或者使用部分克隆功能(例如,根据需求加载不同版本的文件内容)。这些特殊情况,以及更多内容,将在第十二章《管理大型仓库》中详细描述。
分支和标签
一个 maintenance
分支,帮助管理对项目发布的稳定版本的 bug 修复,同时将这一活动与其他开发工作隔离开来。
一个 v1.3-rc3
标签等,可以让你回到这个特定版本,检查测试人员报告的 bug 的有效性,并找到报告的 bug 来源。
分支和标签,有时也称为引用(refs),当一起使用时,它们在修订的 DAG 中具有相同的含义和几乎相同的表示方式。它们是修订图的外部引用(指针),如图 4.2所示:
图 4.2 – 一个包含两个分支、一个标签、一个分叉点和一个合并提交的版本控制系统(VCS)修订的示例有向无环图(DAG)
v1.3-rc3
在图 4.2中。它始终指向相同的对象;它不会改变。使用标签的想法是能够通过符号名称引用给定的修订,并且这个符号名称对每个开发者来说都意味着相同的内容。检出或查看给定标签时,每个人都应该得到相同的结果。
一个maint
和master
。
DAG 中的分支,作为一个开发线路,是 DAG 的子图,由那些可以从分支的末端(从分支头)到达的修订组成——换句话说,就是从分支头开始,沿着父节点的边走到的那些修订。
在创建新提交时,Git 需要知道哪个分支的末端需要推进。它需要知道当前的分支是哪一个,HEAD
直接指向 DAG 中的一个节点。
引用(分支和标签)的全名
最初,Git 将分支和标签存储在.git
管理区域中的文件内,分别位于.git/refs/heads/
和.git/refs/tags/
目录下。现代 Git 可以将有关标签和分支的信息存储在.git/packed-refs
文件中,以避免处理大量小文件。然而,活动引用仍然使用原始的松散格式——每个引用一个文件。
HEAD
指针(表示当前分支)存储在.git/HEAD
中。它通常是一个符号引用——例如,ref: refs/heads/master。
master
分支存储在.git/refs/heads/master
中,并以refs/heads/master
作为它的全名(换句话说,分支位于refs/heads/
命名空间中)。分支的末端称为分支的头,因此这个命名空间的名称。以松散格式存储时,文件内容是分支上最新修订的 SHA-1 标识符(分支末端),以十六进制数字的纯文本形式表示。如果引用之间存在歧义,有时需要使用全名。
远程跟踪分支origin/master
,记住远程仓库origin
中master
分支的最后位置,存储在.git/refs/remotes/origin/master
中,并以refs/remotes/origin/master
作为其全名。远程的概念将在第六章,与 Git 的协同开发中解释,而远程跟踪分支将在第八章,高级分支技巧中讨论。
v1.3-rc3
标签的全名是refs/tags/v1.3-rc3
(标签位于refs/tags/
命名空间中)。更准确地说,在注释和签名标签的情况下,这个文件存储的是指向标签对象的引用,标签对象反过来指向 DAG 中的节点,而不是直接指向一个提交。这是唯一一种可以指向任何类型对象的引用;分支和远程跟踪分支始终指向一个提交。
使用为脚本设计的命令时(如git show-ref
),可以看到这些全名(完全限定名):
$ git show-ref
98cbfdf5c1be9a4f6c0f7e3b97608b39274463df refs/heads/master
d81ce7b6aeedb51aa2d5e18d110333aea080fdd4 refs/stash
分支点
当你从某个版本开始创建一个新分支时,开发线通常会发生分歧。创建一个分支的行为在 DAG 中表示为一个有多个子节点的提交——也就是说,某个节点被多个箭头指向。
重要提示
Git 并不跟踪创建(分叉)分支的信息,也不会以任何方式标记分支点,这些标记不会在克隆和推送中被保存。有关此事件的信息存在于reflog中(branch: Created from HEAD),但它仅限于发生分支的仓库,并且是临时的。然而,如果你知道B分支是从A分支开始的,你可以使用git merge-base A B来找到分支点。在现代 Git 中,你可以使用--fork-point选项来使该命令在有可用的情况下使用 reflog 中的信息。
在图 4.2中,34ac2
提交是一个分支点,或者说是master
和maint
两个分支。
合并提交
通常,当你使用分支来实现独立的并行开发时,稍后你会希望将它们合并。例如,你可能希望将已应用到稳定(维护)分支上的 bug 修复合并到主开发线中(如果它们适用且在主开发线中没有被意外修复)。
你可能还希望合并不同开发者在同一项目中并行创建的更改,每个开发者使用自己克隆的仓库并创建自己的一系列提交。
这样的合并操作会创建一个新的修订,将两个开发线合并。该操作的结果将基于多个提交。表示该修订的 DAG 中的节点将有多个父节点和多个输出边。这样的对象称为合并提交。
在图 4.2中,你可以看到合并提交3fb00
。
单一修订选择
在开发过程中,通常你会想选择项目历史中的某个修订,以便检查它或与当前版本进行比较。选择修订的能力也是选择修订范围的基础——例如,选择某一历史子集进行检查。
许多 Git 命令会将修订作为参数,这些修订通常在 Git 参考文档中以<rev>
表示。Git 允许你以多种方式指定某个提交或一系列提交。接下来将会在本节及下一节中描述这些方式。
HEAD – 隐式修订
大多数(但并非全部)需要修订参数的 Git 命令默认为使用HEAD
。例如,git log
和git log HEAD
将显示相同的信息。你还可以单独使用@
作为HEAD
的快捷方式。
这里,HEAD
表示当前分支,换句话说,表示已经检出的提交,它形成了当前工作的基础(当前修订)。
还有一些其他类似于HEAD
的引用:
-
FETCH_HEAD:此记录了从远程仓库通过上一次git fetch或git pull操作获取的远程分支的信息。它在一次性获取时非常有用,例如通过给定的 URL(git fetch
)获取,而不像从命名仓库(如origin)获取时,可以使用远程跟踪分支,例如origin/master。此外,使用命名仓库时,可以使用远程跟踪分支的 reflog —— 例如,origin/master@{1} —— 来获取 fetch 之前的位置。请注意,FETCH_HEAD会在每次从任何仓库进行 fetch 时被覆盖。 -
ORIG_HEAD:此记录了当前分支的上一个位置。此引用是由那些以剧烈方式移动当前分支的命令创建的(创建新提交不会设置ORIG_HEAD),用来记录HEAD在操作前的位置。如果你想撤销或中止此类操作,这非常有用。然而,如今,也可以通过 reflog 来实现相同的功能,reflog 存储了更多可以在其使用过程中查看的信息;有关更多详情,请参见Reflogging shortnames章节。
你还可能遇到在特定操作期间使用的短暂临时引用:
-
在合并过程中,在创建合并提交之前,MERGE_HEAD记录了你正在合并到当前分支的提交。它在创建合并提交后消失。
-
在 cherry-pick 过程中,在创建将选定更改复制到另一个分支的提交之前,CHERRY_PICK_HEAD记录了你为 cherry-pick 选择的提交。
分支和标签引用
指定修订的最直接和最常用的方式是使用符号名称:分支,命名开发线路,指向该线路的末端;以及标签,命名特定的修订。此方式可以用来查看开发线路的历史,检查给定分支上当前的修订(当前工作),或比较分支或标签与当前工作。
你可以使用任何refs(指向修订的有向无环图的外部引用)来选择一个提交。你可以在任何需要修订作为参数的 Git 命令中使用分支名、标签名和远程跟踪分支名。
通常,给出分支或标签的简短名称就足够了,例如 git log master
查看 master
分支的历史,或 git log v1.3-rc3
查看版本 v1.3-rc1
的历史。然而,也有可能存在具有相同名称的不同类型引用,例如同时存在名为 dev
的分支和标签(虽然建议避免这种情况)。另外,你可能(通常是无意的)创建了本地 origin/master
分支,而远程跟踪分支有一个名为 origin/master
的短名称,它跟踪远程仓库中 master
分支的位置。
在这种情况下,当引用名称模糊时,系统会通过以下规则中的第一个匹配项来消除歧义(这是一个简化版,完整列表请参见 gitrevisions(7) 手册页):
-
顶级符号名称——例如,HEAD。
-
否则,使用标签的名称(refs/tags/ 命名空间)。
-
否则,使用本地分支的名称(refs/heads/ 命名空间)。
-
否则,使用远程跟踪分支的名称(refs/remotes/ 命名空间)。
-
否则,若存在默认分支,则使用远程名称;该修订被认为是默认分支(例如,refs/remotes/origin/HEAD,表示 origin 作为参数)。
--branches
、--tags
和类似选项
如果你想查看整个修订历史图谱,需要一种方式来指定所有的引用——即分支、远程跟踪分支和标签。这就是 git log
命令的 --all
选项的作用。使用此选项,Git 会假装列出 refs/
命名空间中的所有引用,以及 HEAD
,作为修订遍历的起点(用于查看项目历史)。
如果你想限制自己仅操作分支、远程跟踪分支或标签,可以分别使用 --branches
、--remotes
或 --tags
选项。所有这些选项都可以使用可选的 <pattern>
参数,限制对应的引用匹配给定的 shell 通配符。如果模式没有通配符(即没有 *
、?
或 [
),则默认会在模式末尾添加 /*
。例如,如果你想模拟列出所有主题分支(以作者缩写开头的层次化名称)和所有 origin
远程的远程跟踪分支,可以使用以下命令:
$ git log --branches=??/* --remotes=origin
带有 <pattern>
参数的 --all
选项被命名为 --glob=<pattern>
。
通配符模式
在计算机科学中,glob 模式用于使用特定的通配符字符匹配字符串。这是 UNIX shell 使用的语法,并在 glob(7) 手册页中有描述。它比 正则表达式 简单,但表达能力较弱。
最常见的通配符有 、? 和 […]。**** 通配符字符匹配任意数量的字符,包括零个字符,? 匹配一个字符,[abc] 匹配括号内列出的一个字符。你可以使用字符范围简化字符列表——例如,[a-z]。
使用 --exclude=<pattern>
选项可以增强模式匹配功能,该选项会影响 --all
、--branches
、--tags
、--remotes
和 --glob
,排除下一个此类选项原本会考虑的引用。这个选项可以多次使用,累积排除模式。例如,要包括所有的主题分支,但排除你自己的主题分支(这些分支的名称以 jn/
开头),你可以使用以下命令:
$ git log --exclude=jn/* --branches=??/*
SHA-1 和缩短的 SHA-1 标识符
在 Git 中,每个修订版本都会被赋予一个唯一的标识符(对象名称),它是 git
log
输出的一部分:
$ git log
commit 50f84e34a1b0bb893327043cb0c491e02ced9ff5
Author: Junio C Hamano <gitster@pobox.com>
Date: Mon Jun 9 11:39:43 2014 -0700
Update draft release notes to 2.1
Signed-off-by: Junio C Hamano <gitster@pobox.com>
commit 07768e03b5a5efc9d768d6afc6246d2ec345cace
Merge: 251cb96 eb07774
Author: Junio C Hamano <gitster@pobox.com>
Date: Mon Jun 9 11:30:12 2014 -0700
Merge branch 'jc/shortlog-ref-exclude'
不必提供完整的 40 个字符的 SHA-1 标识符。如果你提供 SHA-1 修订标识符的前几个字符,Git 足够智能,能够推断出你想表达的意思,只要部分 SHA-1 至少有 4 个字符长。为了能够使用缩短的 SHA-1 来选择修订版本,它必须足够长,以避免歧义——也就是说,必须有且只有一个提交对象的 SHA-1 标识符以给定的字符开头。
例如,dae86e1950b1277e545cee180551750029cfe735
和 dae86e
都指向同一个提交对象,当然,前提是你的仓库中没有其他对象的名称以 dae86e
开头。如果存在歧义,Git 会告诉我们所有的选择,如下所示:
error: short object ID dae86e is ambiguous
hint: The candidates are:
hint: dae86e19 commit 2021-03-17 – README: Add CI badges
hint: dae86e1f tree
hint: dae86ebf blob
fatal: ambiguous argument 'dae86e': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
在许多地方,Git 会在命令输出中清晰地显示缩短的 SHA-1 标识符。例如,在前面的 git log
输出示例中,我们可以在 Merge:
行看到缩短的 SHA-1 标识符。
你还可以请求 Git 用缩短的 SHA-1 替代完整的 SHA-1 修订标识符,使用 --abbrev-commit
选项。默认情况下,Git 会使用至少 7 个字符来表示缩短的 SHA-1;你可以通过可选参数来更改这一点——例如,--abbrev-commit=12
。
请注意,Git 会根据命令执行时需要的字符数来使用缩短的 SHA-1 以确保其唯一性。--abbrev-commit
(以及类似的 --abbrev
选项)参数表示缩写的最小长度。
关于缩短的 SHA-1 简单说明
通常,8 到 10 个字符的缩短 SHA-1(对于 SHA-1 前缀)在项目中已足够唯一。最大的 Git 项目之一,Linux 内核,开始需要 12 个字符中的 40 个,以保持唯一性。尽管哈希冲突,即两个修订(两个对象)具有相同的完整 SHA-1 标识符,是极不可能的(其概率为1/2⁸⁰ ≈ 1/1.2×10²⁴),但由于仓库的增长,原本唯一的缩短 SHA-1 标识符可能会变得不再唯一。
SHA-1 和缩短版 SHA-1 通常是从命令输出中复制并粘贴作为另一个命令的修订参数。当有疑问或模糊不清时,它们也可以用于开发者之间的沟通,因为 SHA-1 标识符在任何仓库的克隆中都是相同的。图 4.2使用了五个字符的缩短 SHA-1 来标识 DAG 中的修订。
祖先引用
另一种指定修订的方法是通过其HEAD
、分支头或标签,然后通过父关系跟踪到相关的提交。对于这种祖先路径,有一种特殊的后缀语法来指定。
如果你将^
放在修订名称的末尾,Git 会将其解析为该修订的(第一个)父提交。例如,HEAD^
表示HEAD
的父提交——即上一个提交。
这是一个简化语法。对于合并提交,它们有多个父提交,你可能希望选择任何一个父提交。要选择父提交,可以在^
字符后加上父提交的编号:使用^<n>
后缀表示修订的第n个父提交。我们可以看到,^
是¹
的简化版本。
作为特殊情况,⁰
表示提交本身;当命令在使用分支名称作为参数与使用其他修订标识符时行为不同,它才显得重要。它也可以用于获取一个注释(或签名)标签所指向的提交;例如,git show v0.9
和git show v0.9⁰
。注意,你也可以使用<tag>^{commit}
来执行后一操作;在大多数情况下,这和<tag>^{}
做的事情一样(继续查找直到遇到一个不是标签的对象)。
这种后缀语法是可组合的。你可以使用HEAD^^
表示HEAD
的祖父提交,也就是HEAD^
的父提交。还有一种指定首个父提交链的简化语法。你可以直接使用~<n>
来代替写n
次^
后缀,即^^…^
或¹¹…¹
。作为特殊情况,~
等同于~1
,因此HEAD~
和HEAD^
是等价的。同理,HEAD~2
表示第一个父提交的第一个父提交,或者是祖父提交,它等同于HEAD^^
。
你还可以将所有内容组合起来。例如,你可以使用HEAD~3²
来获取HEAD
的曾祖父的第二个父提交(假设它是一个合并提交),依此类推。你可以使用git name-rev
或git describe --contains
来了解修订与本地引用的关系,如下所示:
$ git log | git name-rev --stdin
commit 82006acd359717624fb33a7ae554cba6be717911 (master)
Merge: 20cfc7c 3a59408
Author: Bob Hacker <bob@company.com>
Date: Sun May 30 00:58:23 2021 +0200
Merge branch 'master' of https://git.company.com/random
commit 20cfc7c25ff82e36d6e72b6a31f5839331f270e7 (master~1)
Author: Bob Hacker <bob@company.com>
Date: Sun May 30 00:44:59 2021 +0200
Added COPYRIGHT
[…]
如你所见,使用git name-rev --stdin
作为git log
的过滤器时,在每个 SHA-1 标识符后,你会看到其祖先引用(括号内)——例如,(master~1)。
逆向祖先引用 – git-describe 输出
祖先引用描述了历史版本与当前分支和标签之间的关系。它取决于起始修订的位置。例如,HEAD^
通常表示下个月会有一个完全不同的提交。
有时候,我们希望描述当前版本与先前命名版本的关系。例如,我们可能希望为当前版本提供一个易于人类阅读的名称,以便将其存储在生成的二进制应用程序中。我们希望这个名称对每个人来说都指向相同的修订版本。这就是git describe
的任务。
在这里,git describe
会找到从给定修订(默认是HEAD
)可以到达的最新标签,并使用该标签来描述这个版本。如果找到的标签指向给定的提交,那么(默认情况下)只显示标签名。否则,git describe
会在标签名后添加标签对象上方的提交数量,并使用给定修订的简短 SHA-1 标识符。例如,v1.0.4-14-g2414721
表示给定的提交是基于命名(已标签化)版本v1.0.4
,距离当前提交 14 次提交,并且它的简短 SHA-1 是2414721
。如果没有 SHA-1 缩写,表示法会产生歧义;在非线性历史的情况下,可能会有多个修订版本与给定标签相距 14 个提交。
Git 理解这种输出格式作为修订标识符。
Reflog 短名称
为了帮助你从某些类型的错误中恢复,并能够撤销更改(回到更改前的状态),Git 保持HEAD
和分支引用的记录,记录了过去几个月它们的变化情况以及它们是如何到达当前状态的,具体内容在第二章,《使用 Git 开发》中有所描述。默认情况下,Git 会保留 reflog 条目 90 天;对于那些只能通过 reflog 访问的修订(例如,修改过的提交),则保留 30 天。这可以在每个引用上单独配置;详情请见第十三章,《定制与扩展 Git》。
你可以使用git reflog
命令及其子命令来查看和操作你的 reflog。你还可以使用git log -g
(或git log --walk-reflog
)来显示历史记录:
$ git reflog
ba5807e HEAD@{0}: pull: Merge made by the 'recursive' strategy.
3b16f17 HEAD@{1}: reset: moving to HEAD@{2}
2b953b4 HEAD@{2}: reset: moving to HEAD^
69e0d3d HEAD@{3}: reset: moving to HEAD^^
3b16f17 HEAD@{4}: commit: random.c was too long to type
每当HEAD
和你的分支头因任何原因更新时,Git 会将这些信息存储在这个本地临时的引用历史日志中。reflog 中的数据可以用来指定引用(从而指定修订版本):
-
要指定你本地仓库中HEAD的第n个先前值,你可以使用HEAD@{n}语法,你可以在git reflog的输出中看到这个值。同样,这也适用于指定分支的第n个先前值——例如,master@{n}。这个特殊的语法@{n}表示当前分支的第n个先前值,它可能与HEAD@{n}不同。
-
你也可以使用这种语法来查看某个分支在特定时间点的位置。例如,要表示你本地仓库中master分支昨天的位置,可以使用master@{yesterday}。
-
你可以使用@{-n}语法来引用当前分支之前第n个被检出的分支。在某些地方,你可以简单地用– (dash)替代@{-1}。例如,git checkout – 或 git switch – 会切换到上一个分支。
上游远程跟踪分支
你用来工作的本地仓库通常不会孤立存在。它与其他仓库进行交互,通常至少与从中克隆出来的origin
仓库进行交互(除非是通过git init
从头开始创建的仓库)。
注意
默认远程仓库的名称可以通过设置clone.defaultRemoteName来指定。
对于这些你经常交互的远程仓库,Git 会跟踪它们分支在上次接触时的位置。
为了跟踪远程仓库中分支的移动,Git 使用some-branch
,然后运行git checkout <some-branch>
命令,Git 会为你基于这个远程跟踪分支创建一个本地分支。
例如,当你在一个开发分支上工作,最终该分支要发布到origin
仓库中的next
分支,而该next
分支由origin/next
远程跟踪分支进行跟踪时,你需要创建一个本地的next
分支。我们说origin/next
是next
分支的上游分支,我们可以将其称为next@{upstream}
。
@{upstream}后缀(简写为<refname>@{u}
),只能应用于本地分支名称,选择与引用设置的基础分支。缺少引用时,默认使用当前分支——即@{u}
是当前分支的上游分支。
还有[<branch>]@{push}
,它对于三角工作流很有用,其中你推送更改的仓库与获取更新的仓库不同。
通过提交信息选择修订版本
你可以通过使用正则表达式匹配提交信息来指定修订版本。:/<pattern>
表示从任何引用可以访问的最新匹配提交(例如,:/^Bugfix
),而<rev>^{/<pattern>}
(例如,next^{/fix bug}
)表示从<rev>
可以访问的最新匹配提交:
$ git log 'origin/pu^{/^Merge branch .rs/ref-transactions}'
这种修订指定器给出的结果类似于git log
的--grep=<pattern>
选项,但它是可组合的。这意味着它可以与其他组件(如祖先引用)结合使用。另一方面,它只返回第一个(最年轻的)匹配修订,而--grep
选项返回所有匹配的修订。
选择修订范围
现在你可以通过多种方式指定单个修订,让我们学习如何指定修订范围,这是我们想要查看的 DAG 的一个子集。修订范围特别适合用于查看项目历史中的选定部分。
例如,你可以使用范围指定来回答一些问题,如:“这个分支上有哪些工作我还没有合并到主分支中?”、“我主分支上的哪些工作我还没有发布?”或者简单地问:“自从创建这个分支以来,做了哪些工作?”
单一修订作为修订范围
历史遍历命令,如git log
,在一组提交上操作,从子提交向父提交逐步遍历。这类命令,在作为参数提供单个修订时(如本章单一修订选择部分所述),将显示从该修订开始,沿着提交的祖先链一直到根提交的所有提交。由于 Git 默认使用分页器,Git 将在显示一整页后停止——即显示一整屏的提交。
例如,git log master
会显示从master
分支的最新提交开始的所有提交(所有基于当前工作在该分支上的修订),这意味着它会显示整个master
分支——也就是整个开发线路。
双点符号
最常见的范围指定方法是双点语法,A..B
。对于线性历史,这意味着A
和B
之间的所有修订,或者更准确地说,是所有在B
中但不在A
中的提交,如图 4.3所示。例如,HEAD~4..HEAD
范围表示四个提交:HEAD
、HEAD^
、HEAD^^
和HEAD^^^
。换句话说,它意味着HEAD~0
、HEAD~1
、HEAD~2
和HEAD~3
,假设当前分支与其第四个祖先之间没有合并提交:
图 4.3 – 线性历史的双点符号 A..B。选定的修订范围用细光环(带有轮廓)标记
提示
如果你想包括起始提交(在一般情况下,边界提交),而 Git 默认认为这些提交不感兴趣,你可以使用--boundary选项与git log一起使用。
对于不是直线的历史,情况会更复杂。一个这样的情况是当A
不是B
的祖先(在修订的 DAG 中没有从B
到A
的路径),但它们有一个共同的祖先,如图 4.4所示:
图 4.4 – 非线性历史的双点符号 A..B,其中修订版本 A 不是修订版本 B 的祖先,显示具有分叉点的情况
另一个非线性历史的情况是路径从 B
到 A
不是一个简单的线路 – 也就是说,在 A
和 B
之间存在合并提交,如 图 4**.5 所示。在非线性历史视图中,双点符号 A..B
或 在 A 和 B 之间 被定义为那些可以从 A
到达而从 B
到达不了的提交:
图 4.5 – 非线性历史的双点符号 A..B,A 和 B 之间具有合并提交。要排除带有 * 标记的提交,请使用 --strict-ancestor 选项
对于 Git 来说,A..B
表示一个范围,包括从一个修订版本 (B
) 到另一个修订版本 (A
) 无法到达的所有提交,同时遵循祖先链。在 A
和 B
分歧的情况下,如 图 4**.4 所示,这简单地是从 A
的分支点开始的 B
中的所有提交。
例如,假设你的 master
和 experiment
分支分岔。你想查看你的 experiment
分支中尚未合并到 master
分支的内容。你可以要求 Git 只显示这些提交的日志,使用 master..experiment
。
另一方面,如果你想看到相反的情况 – 所有 master
中没有的 experiment
中的提交 – 你可以反转分支名称。experiment..master
符号显示了 master
中所有无法从 experiment
到达的内容。
另一个例子是 origin/master..HEAD
显示即将推送到远程仓库的内容(当前分支中尚未在 origin
的 master
分支中存在的提交),而 HEAD..origin/master
可显示已获取但尚未合并的内容。
小贴士
你也可以省略语法的一侧,让 Git 假设 HEAD:origin/master.. 就是 origin/master..HEAD,而 ..origin/master 就是 HEAD..origin/master;如果一侧缺失,Git 会替换为 HEAD。
Git 在许多地方使用双点符号,例如在 git fetch
和 git push
的输出中用于普通快进情况。在这里,你可以只需复制粘贴输出片段作为 git log
的参数。在这种情况下,范围的起始点是结束点的祖先 – 也就是说,这个范围是线性的:
$ git push
To https://git.company.com/random
8c4ceca..493e222 master -> master
创建范围时包括和排除修订版本
双点符号 A..B
语法非常有用且直观,但它是一个简写符号。通常,它已经足够了,但有时你可能希望获得比它提供的更多功能。也许你想指定多个分支来标识你的修订,例如查看哪些提交出现在多个分支中,但不在你当前所在的分支中。也许你只想查看 master
分支中那些不在其他长期存在的分支中的更改。
Git 允许通过前缀给定修订版本来排除可以从该修订版本到达的提交。例如,要查看所有位于 maint
或 master
上,但不在 next
中的修订版本,可以使用 git log maint master ^next
。这意味着 A..B
符号只是 B ^A
的简写。
Git 允许我们使用 --not
选项来替代在每个我们想要排除的修订版本前加上 ^
字符,这个选项会否定所有后续的修订。例如,B ^A ^C
可以写成 B --not A C
。这种方法在我们通过编程生成排除的修订版本时特别有用。
因此,这三个命令是等效的:
$ git log A..B
$ git log B ^A
$ git log B --not A
单个修订版本的修订范围
还有一个有用的简写符号 A^!
,它表示由单个提交组成的范围。对于非合并提交,它就是 A^..A
。
对于合并提交,A^!
会排除所有父提交。借助另一个特殊符号 A^@
,表示 A
的所有父提交(即 A¹
,A²
,… A^n
),我们可以说 A^!
是 A --not A^@
的简写。
三点符号
指定修订范围的最后一种主要语法是三点语法 A...B
。它选择能够由两个引用之一到达的所有提交,但不能同时由它们两个到达;见 图 4**.6。在数学中,这种表示法被称为A 和 B 的对称差:
图 4.6 – 三点符号 A...B,用于非线性历史,所选范围以细线框显示,O 是边界提交 —— A 和 B 的合并基点
这是 A B --not $(git merge-base --all A B)
的简写,其中 $(…)
表示 shell git merge-base
命令,用于查找所有最佳共同祖先(所有合并基点),然后将其输出粘贴到命令行中,以便进行否定操作。
git log
命令在使用三点符号时常用的一个开关是 --left-right
。此选项使 Git 通过在来自左侧(A
在 A...B
中)的提交前添加 <
,以及在来自右侧(B
在 A...B
中)的提交前添加 >
来显示每个提交属于范围的哪一侧,如 图 4**.6 所示,以及以下示例。这有助于使数据更具实用性:
$ git log --oneline --left-right 37ec5ed...8cd8cf8
>8cd8cf8 Merge branch 'fc/remote-helper-refmap' into next
>efcd02e Merge branch 'rs/more-starts-with' into next
>831aa30 Merge branch 'jm/api-strbuf-doc' into next
>1aeca19 Merge branch 'jc/count-parsing' into next
<1a7e8e8 Revert "replace: add --graft option"
<7a30690 t9001: avoid non-portable '\n' with sed
>5cc3268 fetch doc: remove "short-cut" section
重要提示
如果--left-right选项与--boundary结合使用,这些通常不感兴趣的边界提交将以-为前缀。
使用三点符号A...B修订范围时,这些边界提交是git merge-base --all A B。
当发生强制更新时,Git 在git fetch
和git push
的输出中使用三点符号表示,在旧版本(左侧)和更新版本(右侧)分叉的情况下,并且新版本被强制覆盖旧版本:
$ git fetch
From git://git.kernel.org/pub/scm/git/git
+ 37ec5ed...8cd8cf8 next -> origin/next (forced update)
+ 9478935...16067c9 pu -> origin/pu (forced update)
d0b0081..1f58507 todo -> origin/todo
在 diff 中使用修订范围符号
为了方便在log和diff命令之间复制粘贴,Git 允许我们在git diff命令中使用修订范围双点符号A..B和三点符号A...B,作为修订集(端点)。
对于 Git 来说,使用git diff A..B与git diff A B是一样的,这表示修订A和修订B之间的差异。如果省略双点符号两侧的修订,它将产生与使用HEAD相同的效果。例如,git diff A..等同于git diff A HEAD。
git diff A...B符号表示显示分支B上的传入更改。传入更改意味着从公共祖先——即A和B的合并基点开始,直到修订B。因此,写git diff A...B相当于git diff $(git merge-base A B) B;请注意,这里git merge-base没有使用--all选项。此约定的结果是,将git fetch输出(无论是双点符号还是三点符号)作为参数传递给git diff时,始终会显示已获取的更改。但需要注意的是,它不包括自分叉以来在A上所做的更改!
在现代 Git 中,你可以使用不那么难懂的git diff --merge-base A B,而不必使用三点符号表示法——也就是说,git diff A...B。
此外,此功能使得可以使用git diff A^!查看修订A与其父提交的差异(它是git diff A^ A的快捷方式)。
总结
本章涵盖了探索项目历史的各种方法:查找相关的修订,选择要显示的修订,并进行进一步分析。
我们从描述项目历史的概念模型开始:修订的有向无环图(DAG)。理解这一概念非常重要,因为许多选择工具直接或间接地引用了 DAG。
然后,你学习了如何选择单个修订和修订范围,以及修订范围的概念如何适用于非线性历史。我们可以利用这些知识查看自分支与基础分支分叉以来,分支上做了哪些更改,反之亦然;我们还可以检查自分叉以来两个分支发生了什么。
选择修订是搜索项目历史的第一步。这将在下一章中描述。
问题
回答以下问题,以测试你对本章内容的理解:
-
如何列出所有当前分支上未包含的、在上游分支上存在的修订版(尚未集成的)?
-
如何列出你将使用 git push 发送的所有修订版,支持三角工作流(推送远程仓库与拉取远程仓库不同)?
-
如何查找从分叉点开始的两个分支 A 和 B 中的所有分歧更改,并显示哪个更改集位于哪个分支上?
-
如何列出所有在任何远程追踪分支上进行的提交,这些分支的名称以 fix- 开头,并且来自任何远程仓库?
-
切换到前一个分支的最简单方法是什么?它是如何工作的?
答案
以下是本章问题的答案:
-
将双点表示法与上游分支的表示法结合使用:git log ..@****{upstream}。
-
使用 git log @{push}..HEAD,将双点表示法与“推送到的位置”表示法结合使用。请注意,对于简单的工作流程,@{push} 与 @{upstream} 是相同的。
-
使用三点表示法和适当的 git log 选项:git log --left-right A...B。
-
使用 --remotes[=
] 选项,并搭配适当的通配符模式:git log --remotes=/fix-。 -
使用 git checkout – 或 git switch -。在这些命令中,- 表示 @{-1},该符号利用 reflog 查找当前分支的前一个值。
进一步阅读
若要了解本章所涉及的更多主题,可以查看以下资源:
-
gitrevisions(7) – 指定 Git 的修订版和范围:
git-scm.com/docs/gitrevisions
-
Scott Chacon, Ben Straub: Pro Git, 第二版(2014 年),Apress 第二章 2.3:Git 基础 - 查看提交历史:
git-scm.com/book/en/v2/Git-Basics-Viewing-the-Commit-History
-
glob(7) – 通配符模式的路径匹配(Shell 通配符模式):
man7.org/linux/man-pages/man7/glob.7.html
-
Jan Goyvaerts: 正则表达式教程:学习如何使用并充分利用正则表达式:
www.regular-expressions.info/tutorial.html
第五章:在仓库中进行搜索
在选择了你想要搜索的项目历史部分之后,下一步任务是从选定的提交中提取你需要的信息。你可以根据修订元数据来限制搜索范围,例如提交的作者、变更创建的日期,或提交信息的内容。你可以查看实际的更改,或者你可能对某个特定文件或子系统如何演变感兴趣。通过访问项目历史,你可以找到谁编写了某一段代码,或是哪次提交引入了回归(首次出现的错误提交)。
另一个重要的技能是格式化 Git 输出,以便容易找到你想要的信息。通过各种预定义的漂亮git log
输出格式和定义及组合自定义输出格式的能力,可以实现这一任务。
以下是我们将在本章中讨论的主题列表:
-
限制历史记录和历史简化
-
使用取铁锹工具和差异搜索来搜索历史记录
-
使用git bisect查找错误
-
使用git blame查看文件内容的逐行历史,并检测文件重命名
-
选择和格式化输出(美观的格式)
-
使用git shortlog概括贡献
-
使用.mailmap指定标准的作者姓名和电子邮件
-
查看特定的修订、修订中的文件以及差异输出选项
本章的目的是展示如何从项目历史中提取信息。
搜索历史记录
git log
命令有大量且多样的有用选项,其中修订限制选项是最常用的——即那些只允许你显示部分提交的选项。这与通过传递适当的修订范围选择要查看的提交相辅相成,并允许我们利用除了修订图形外的其他信息来搜索特定版本的历史记录。
限制修订数量
限制git log
输出的最基本方法是仅显示指定数量的最新提交。可以使用-<n>
选项(其中n
是任意整数)来实现;也可以写作-n <n>
,或者以长格式写作--max-count=<n>
。例如,git log -2
将显示当前开发线中最新的两个提交,从隐式的HEAD
修订开始。
你可以使用--skip=<n>
跳过显示的前几个提交。
匹配修订元数据
历史限制选项可以分为两类:一种是检查提交对象本身存储的信息(修订元数据),另一种是根据变更集过滤提交(基于父提交或多个提交之间的变更)。
时间限制选项
如果你对某个特定日期范围内创建的提交感兴趣,可以使用如--since
和--until
,或者--before
和--after
等选项。例如,以下命令返回过去两周内所做的提交列表:
$ git log --since=2.weeks
这些选项可以与各种日期格式一起使用。你可以指定一个具体日期,如2008-04-21,也可以指定一个相对日期,如2 年前 3 个月 3 天;你还可以使用点代替空格。
当使用特定日期时,必须记住,如果日期没有包含时区,它将被解释为本地时区。这很重要,因为在这种情况下,当 Git 由位于世界其他时区的同事运行时,结果可能不会相同。例如,--since="2014-04-29 12:00:00"
在英国伯明翰(这表示 2014-04-29Z11:00:00 的世界协调时间)运行时,会比在美国阿拉巴马州伯明翰(这表示 2014-04-29Z17:00:00)运行时多出六小时的提交。为了获得相同的结果,你需要在时间限制中包括时区——例如,-````-after="2013-04-29T17:07:22+0200"
。
请注意,Git 中的提交并不是由单一日期描述的,而是由两个可能不同的日期描述:作者日期和提交者日期。此处描述的时间限制选项检查提交者日期,即修订对象创建的日期和时间。这可能不同于作者日期,即变更集创建的日期和时间(即更改发生的时间)。
在某些情况下,作者日期和提交者日期可以不同:
-
一种情况是,当提交是在一个仓库中创建的,转换为电子邮件后,再由另一个人应用到另一个仓库中。
-
另一种让这两个日期不同的方法是,在重新基础化时重新创建提交;默认情况下,这会保留作者日期并获得一个新的提交者日期(参见第九章,合并更改,基础化分支部分,以及第十章,保持历史记录清晰,交互式 基础化部分)。
匹配提交内容
如果你只想过滤出由特定作者或提交者完成的提交,可以分别使用--author
或--committer
选项。例如,假设你在寻找 Linus 编写的所有 Git 源代码中的提交。你可以使用类似git log --author=Linus
的命令。默认情况下,搜索是区分大小写的,使用--author=^Linus
。这里使用^
表示作者信息应该以Linus开头。
--grep
选项允许你搜索提交信息(这些信息应包含对更改的描述)。假设你想查找所有提到git
的安全漏洞修复,可以使用git log --grep=CVE
。
如果你同时指定了--author
和--grep
选项,或者多个--author
或--grep
选项,Git 将显示匹配任一查询的提交。换句话说,Git 会逻辑地“或”所有提交匹配选项。如果你希望找到匹配所有查询的提交,并且这些匹配选项之间是逻辑“与”关系,你需要使用--all-match
选项。
还有一组选项,可以修改匹配模式的含义,类似于grep
程序中使用的选项。为了使搜索不区分大小写,可以使用-i
/ --regexp-ignore-case
选项。如果你想简单地匹配一个子字符串,可以使用-F
/ --fixed-strings
(你可能希望这么做,以避免需要转义正则表达式中的元字符,如.
和?
)。如果你想写更强大的搜索词,可以使用--extended-regexp
或--perl-regexp
(如果 Git 是用--invert-grep
编译并链接的,后者才可用)。
当使用git log -g
查看 reflog 时(参见Reflog 简写部分),你可以使用--grep-reflog=<regexp>
选项,仅显示包含匹配 reflog 条目的位置。例如,要显示所有对HEAD
的操作,这些操作不是简单的提交操作,你可以使用以下命令:
$ git log -g --invert-grep --grep-reflog="^commit:"
提交的父级
默认情况下,Git 会在遍历提交历史时,跟随每个合并提交的所有父提交。为了只跟随第一个父提交,你可以使用恰如其分的--first-parent
选项。这样会显示历史的主线(有时称为主干),假设你遵循了特定的合并实践;你将在第八章《高级分支技巧》和第九章《合并变更》部分学到更多。
考虑以下命令(这个例子使用了非常实用的--graph
选项,它能够生成历史的 ASCII 艺术图表):
$ git log -5 --graph --oneline
* 50f84e3 Update draft release notes to 2.1
* 07768e0 Merge branch 'jc/shortlog-ref-exclude'
|\
| * eb07774 shortlog: allow --exclude=<glob> to be passed
* | 251cb96 Merge branch 'mn/sideband-no-ansi'
|\ \
| * | 38de156 sideband.c: do not use ANSI control sequence
将其与以下命令进行比较:
$ git log -5 --graph --oneline --first-parent
* 50f84e3 Update draft release notes to 2.1
* 07768e0 Merge branch 'jc/shortlog-ref-exclude'
* 251cb96 Merge branch 'mn/sideband-no-ansi'
* d37e8c5 Merge branch 'rs/mailinfo-header-cmp'
* 53b4d83 Merge branch 'pb/trim-trailing-spaces'
你可以使用--merges
和--no-merges
选项,分别过滤出仅显示合并提交或非合并提交。这些选项实际上是更通用选项的简化版:--min-parents=<number>
(--merges
等同于--min-parents=2
)和--max-parents=<number>
(--no-merges
等同于--max-parents=1
)。
假设你想找到项目的起始点,你可以借助--max-parents=0
来实现,这样会显示所有的根提交:
$ git log --max-parents=0 --oneline
0ca71b3 basic options parsing and whatnot.
16d6b8a Initial import of a python script...
cb07fc2 git-gui: Initial revision.
161332a first working version
1db95b0 Add initial version of gitk to the CVS repository
2744b23 Start of early patch applicator tools for git.
e83c516 Initial revision of "git", the information manager from hell
搜索修订中的更改
有时候,单靠搜索提交信息和其他修订元数据是不够的。也许对更改的描述不够详细,或者,你可能在寻找某个功能被引入的修订,或者某个变量开始使用的修订?
Git 允许你查看每个修订所带来的更改(即提交与其父提交之间的差异)。更快的选项被称为pickaxe搜索。
使用-S<string>
选项时,Git 会查找引入或删除给定字符串的差异。注意,这不同于字符串仅仅出现在 diff 输出中。你可以使用正则表达式与--pickaxe-regex
选项进行匹配。Git 会检查每个修订,看是否有文件的当前侧和父侧有不同数量的指定字符串,并显示匹配的修订。
作为副作用,使用git log
和-S
选项还会显示每次修订所做的更改(就像使用了--patch
选项一样),但仅显示与查询匹配的差异。若要显示所有文件的差异,以及发生数字变化的差异,你需要使用--pickaxe-all
选项:
$ git log -S'sub href'
commit 06a9d86b49b826562e2b12b5c7e831e20b8f7dce
Author: Martin Waitz <tali@admingilde.org>
Date: Wed Aug 16 00:23:50 2006 +0200
gitweb: provide function to format the URL for an action link.
Provide a new function which can be used to generate an URL for the CGI.
This makes it possible to consolidate the URL generation in order to make
it easier to change the encoding of actions into URLs.
Signed-off-by: Martin Waitz <tali@admingilde.org>
Signed-off-by: Junio C Hamano <junkio@cox.net>
使用-G<regex>
时,Git 会字面上寻找那些添加或删除的行与给定正则表达式匹配的差异。注意,Git 使用的统一 diff 格式将更改的行视为删除旧版本并添加新版本;参见第二章,使用 Git 开发(检查待提交的更改部分),了解 Git 如何描述更改。
为了说明-S<regex> --pickaxe-regex
与-G<regex>
之间的差异,考虑一个包含以下 diff 的提交:
if (lstat(path, &st))
- return error("cannot stat '%s': %s", path,
+ ret = error("cannot stat '%s': %s", path,
strerror(errno));
当git log -G"error\("
会显示这个提交(因为查询匹配了两个更改的行)时,git log -S"error\(" --pickaxe-regex
则不会显示(因为该字符串的出现次数没有发生变化)。
提示
如果你对单个文件感兴趣,使用git blame(也许在图形化的 blame 浏览器中,如git gui blame)查看某个更改何时被引入会更容易。然而,git blame无法用于找到删除行的提交——你需要使用 pickaxe 搜索。
选择更改类型
有时,你可能只想查看那些添加或重命名文件的更改。使用 Git,你可以通过git log --diff-filter=AR
来做到这一点。你可以选择任何类型变化的组合;详情请参见git-log(1)
手册。例如,要在列出所有更改的文件时找到所有重命名文件,你可以使用--diff-filter=R*
,如以下示例所示:
$ git log --diff-filter=R* --oneline –stat
8b4dbde Rename random.js to gen_random.js
index.html | 2 +-
scripts/{random.js => gen_random.js} | 0
2 files changed, 1 insertion(+), 1 deletion(-)
042a8af Directory structure
index.html | 2 +-
random.js => scripts/random.js | 0
2 files changed, 1 insertion(+), 1 deletion(-)
更改类型的助记符与git status --short
或git log --name-status
所使用的是一样的:
$ git log -1 --diff-filter=R --oneline --name-status
8b4dbde Rename random.js to gen_random.js
R100 scripts/random.js scripts/gen_random.js
接下来,我们将研究如何根据更改的文件搜索历史,随后还会讨论如何格式化git log
的输出。
文件的历史
正如前一章开头的整个树提交部分所描述,Git 修订是关于将整个项目作为一个单一实体的状态。
在许多情况下,特别是在大型项目中,我们只关心单个文件的历史,或仅限于给定目录(给定子系统)内更改的历史。
路径限制
要查看单个文件的历史,你只需使用git log <pathname>
。Git 将仅显示所有影响指定路径名(文件或目录)的修订,这意味着那些对指定文件或指定子目录中的文件做出更改的修订。
分支名称与路径名称的歧义消除
Git 通常会猜测你写的git log foo是什么意思;你是想查看foo分支(开发线)的历史,还是想查看foo文件的历史?然而,有时 Git 可能会混淆。为防止路径名和分支名称之间的混淆,你可以使用--(两个破折号)来分隔文件名参数和其他选项。--之后的所有内容将被视为路径名,而--之前的所有内容将被视为分支名称或其他选项。
例如,编写git log -- foo明确请求查看foo路径的历史。
除了当分支和文件同名时,另一个常见的使用场景是在检查已删除文件的历史时,该文件不再出现在项目中。
你可以指定多个路径;甚至可以使用通配符(模式匹配)来查找影响某一特定类型文件的更改。例如,要查找仅对 Perl 脚本(.pl
扩展名的文件)所做的更改,可以使用git log -- '*.pl'
。请注意,你需要保护*.pl
通配符,以免在 Git 看到它之前被 Shell 展开——例如,使用单引号,如此处所示。
Pathspec 魔法
大多数接受
然而,由于 Git 在显示项目历史时使用路径名参数作为限制器,查询单个文件的历史不会自动跟踪重命名。你需要使用 git log --follow <file>
来继续列出文件的历史,超越重命名。不幸的是,它并不是在所有情况下都有效。有时,你需要使用 git blame
命令(请参见Blame – 文件的逐行历史部分),或通过启用重命名检测的边界提交(git show -M -C --raw --abbrev <rev>
)来手动跟踪重命名和文件移动。
在现代 Git 中,你还可以使用 git log -L
跟踪文件中行范围的演变,目前该功能仅限于从单个修订(零或一个正向修订参数)和单个文件开始的遍历。范围可以通过-L <start>,<end>:<file>
来指定,<start>
或 <end>
可以是行号或/regexp/
(正则表达式),也可以用 -L :<funcname regexp>:<file>
来跟踪一个函数。 然而,这种技术不能与基于路径的常规路径限制一起使用。例如,要查看index.html
文件的历史,限制在<head>
元素中的更改,可以使用以下命令:
$ git log -L '/^<head>/','/^<\/head>/':index.html
历史简化
默认情况下,当请求某路径的历史时,Git 会简化历史,只显示那些必要的提交(足以解释匹配指定路径的文件是如何演变的)。Git 会排除那些没有更改给定文件的修订。此外,对于非排除的合并提交,Git 会排除那些没有更改文件的父提交(从而排除开发线)。
你可以通过 git log
选项来控制这种历史简化,如 --full-history
或 --simplify-merges
。更多详情请参见 Git 文档,尤其是 git-log(1)
手册中的历史简化部分。
Blame — 文件的逐行历史
git blame
会为每一行标注适当的行作者信息。
Git 可以从给定的修订开始标注(这在浏览文件历史或检查文件的旧版本是如何演变时非常有用),甚至可以将搜索限制在给定的修订范围内。你还可以限制标注的行范围,以提高 blame
的速度——例如,若只想查看 esc_html
函数的历史,可以使用以下命令:
$ git blame -L '/^sub esc_html {/,/}/' gitweb/gitweb.perl
blame
操作之所以如此有用,是因为它跟踪历史,即使文件进行了整文件的重命名。它可以选择跟踪从一个文件移动到另一个文件的行(使用 -M
选项),甚至跟踪从另一个文件复制粘贴过来的行(使用 -C
选项);这也包括内部代码的移动。
在跟踪代码移动时,忽略空白符变化非常有用,这样可以找出某段代码真正被引入的时间,而不是仅仅发现它被重新缩进(例如,由于重构重复代码为函数——代码移动)。这可以通过传递 diff 格式选项 –w
/ --ignore-all-space
来完成。
重命名检测
一个好的版本控制系统应该能够处理文件重命名和其他改变项目目录结构的方式。解决这个问题有两种方法。第一种是 重命名追踪,意味着在提交时保存关于文件被重命名的事实信息;版本控制系统会标记重命名。这通常需要使用 rename 和 move 命令来重命名文件。例如,你不能使用不支持版本控制的文件管理器来移动文件。但是,在创建修订时,可以检测到重命名。这可能涉及某种形式的 文件身份,它在重命名时得以保留。
第二种方法,也是 Git 使用的方法,是 重命名检测。在这种情况下,mv 命令仅仅是删除旧文件名并添加一个包含相同内容的新文件名的快捷方式。重命名检测意味着在需要时检测文件被重命名的事实:在进行合并时、查看文件的逐行历史记录时(如果请求)、或在显示差异时(如果请求或配置了)。这种方式的优势在于,重命名检测算法可以得到改进,并且不会在提交时被冻结。它是一种更通用的解决方案,不仅可以处理整个文件的重命名,还可以处理单个文件内部以及不同文件之间的代码移动和复制,这可以从 git blame 的描述中看到。
重命名检测的缺点是,它在 Git 中基于文件内容和路径名相似性的启发式方法,因此需要消耗一定的资源,并且在某些罕见情况下可能会失败:无法检测重命名,或错误地检测出没有发生重命名的情况。
请注意,在现代 Git 中,基本的重命名检测默认启用,用于显示差异。
许多 Git 的图形界面包含了图形化版本的 blame 操作。git gui blame
就是其中一个图形化界面的例子(它是基于 Tcl/Tk 的 git gui
图形界面的一部分)。这些图形界面可以展示完整的更改描述,并同时展示考虑与不考虑重命名的历史记录。通过这样的 GUI,通常可以跳转到指定的提交,交互式地浏览文件行的历史。此外,GUI blame 工具使得跨重命名跟踪文件变得非常简单:
图 5.1 – 'git gui blame' 操作示例,展示如何检测代码片段的复制或移动
使用 git bisect 查找 bug
Git 提供了一些工具来帮助你调试项目中的问题。这些工具非常有用,特别是在软件回归的情况下——即在某次修订后,软件出现了 bug,导致某个功能无法正常工作。如果你不知道 bug 出现的位置,而自上次知道代码正常工作以来已经有数十次或数百次提交,你可能会求助于 git bisect
。
bisect 命令通过半自动化的方式,一步步地在项目历史中进行查找,尝试找出引入 bug 的修订。在每一步中,它将历史分成大致相等的两部分,询问分割提交是否存在 bug。然后,它根据答案淘汰掉其中一部分,从而缩小包含 bug 的提交范围:
图 5.2 – git bisect 操作示例,在 4 步后找到有问题的提交
启动 git bisect 过程
假设项目的版本 1.14 正常工作,但新版本的候选发布版 1.15-rc0 崩溃。你回到 1.15-rc0 版本,结果发现你能够 重现问题(这非常重要!),但无法找出问题出在哪里。
你可以通过 bisect 代码历史来查找原因。你需要通过 git bisect start
启动二分查找过程,然后使用 git bisect bad
告诉 Git 哪个版本是坏的。接着,你必须告诉 bisect 过程最后一个已知的正常状态(或状态集合),使用 git bisect good
:
$ git bisect start
$ git bisect bad v1.15-rc0
$ git bisect good v1.14
Bisecting: 159 revisions left to test after this (roughly 7 steps)
[7ea60c15cc98ab586aea77c256934acd438c7f95] Merge branch 'mergetool'
查找有问题的提交
Git 发现大约有 300 次提交位于你标记为最后一次正常提交(v1.14
)和有问题版本(v1.15-rc0
)之间,并为你检出了中间的提交(7ea60c15
)。如果此时运行 git branch
或 git status
,你会看到 Git 已暂时将你切换到了(``no branch):
$ git branch
* (no branch, bisect started on master)
master
$ git status
HEAD detached at 7ea60c15cc
You are currently bisecting, started from branch 'master'.
(use "git bisect reset" to get back to the original branch)
此时,你需要运行测试以检查问题是否存在于 bisect 操作当前检出的提交中。如果程序崩溃,使用 git bisect bad
标记当前提交为有问题。如果问题不存在,使用 git bisect good
标记其为正常。大约经过七步后,Git 会显示出可疑的提交:
$ git bisect good
b047b02ea83310a70fd603dc8cd7a6cd13d15c04 is first bad commit
commit b047b02ea83310a70fd603dc8cd7a6cd13d15c04
Author: PJ Hyett <pjhyett@example.com>
Date: Tue Jan 27 14:48:32 2009 -0800
secure this thing
:040000 040000 40ee3e7… f24d3c6… M config
上述示例输出的最后一行是所谓的 原始 diff 输出,显示了在某次提交中发生变化的文件。你可以使用 git show
来检查可疑的提交。接下来,你可以查看该提交的作者,并向他们寻求澄清或要求修复(通过发送 bug 报告)。如果在项目开发过程中遵循了创建小规模、增量变化的良好实践,那么在找到有问题的提交后,需检查的代码量应该会很小。
如果在某个时刻,你遇到了一个与当前问题无关的提交,且它不适合用来测试,你可以使用git bisect skip
跳过此提交。你甚至可以通过向skip
子命令提供修订范围来跳过一系列提交。
当你完成时,应该运行git bisect reset
,以便将你带回到开始时的分支。
$ git bisect reset
Previous HEAD position was b047b02... secure this thing
Switched to branch 'master'
要在停留在你找到的坏提交上完成二分查找,你可以使用git bisect
reset HEAD
。
在 git bisect 过程中自动化测试
你甚至可以通过git bisect run
完全自动化查找坏修订版本。为此,你需要一个脚本来测试是否存在 bug,如果项目正常工作则退出时返回 0,或者如果存在 bug 则返回非 0 值。当当前检出的代码无法进行测试时,应使用特殊的退出代码125
。在这种情况下,你还可以通过提供已知的坏和好的提交来启动bisect
操作。你可以通过简单地使用bisect start
命令列出这些提交,先列出已知的坏提交,再列出已知的好提交(如果有多个好提交)。如果你知道问题涉及树中的哪一部分,还可以通过指定路径参数来减少测试的次数(路径前的双破折号并非严格必要,但很有帮助)。然后,你就可以开始自动化的二分查找:
$ git bisect start v1.5-rc0 v1.4 -- arch/i386
$ git bisect run ./test-error.sh
这样做会自动在每个检出的提交上运行test-error.sh
,直到 Git 找到第一个坏的提交。
如果问题是项目停止编译(构建失败),你可以使用make
作为测试脚本(与git bisect
run make
一起使用)。
选择并格式化 git 日志输出
现在你知道如何选择要检查的修订版本,并限制显示的修订版本(选择那些有意义的修订),接下来是学习如何选择与查询修订版本相关的部分信息并格式化输出。git log
命令提供了大量的选项来实现这一点。
预定义和用户定义的输出格式
一个非常有用的git log
选项是--pretty
。此选项会改变日志输出的格式。有几个预设格式可以供你使用。oneline
格式将每个提交打印在一行上,这对于查看大量提交时非常有用;存在--oneline
的快捷方式,它等同于--pretty=oneline --abbrev-commit
,通常一起使用。此外,short
、medium
(默认格式)、full
和fuller
格式以大致相同的格式显示输出,只是信息的多少有所不同。raw
格式以 Git 内部表示形式显示提交,email
或mboxrd
则以类似git format-patch
的邮件格式显示。reference
格式用于在提交信息中引用另一个提交,以下是一个示例:
$ git show --no-patch --pretty=reference master^
20cfc7c (Added COPYRIGHT, 2021-05-30)
您可以使用适当的--date
选项更改这些详细格式中显示日期的格式:让 Git 显示相对日期,例如--date=relative
,或者使用--date=local
显示本地时区的日期,等等。
您还可以使用--pretty=format:<string>
指定您自己的日志输出格式(以及其tformat
变体,该变体使用终止符而非分隔符语义——每次提交的输出会附加换行符)。当您为机器解析生成输出并在脚本中使用时,这尤其有用,因为当您明确指定格式时,您知道它在 Git 更新时不会更改。格式字符串的工作方式有点像printf
:
$ git log --pretty="%h - %an, %ar : %s"
50f84e3 - Junio C Hamano, 7 days ago : Update draft release notes
0953113 - Junio C Hamano, 10 days ago : Second batch for 2.1
afa53fe - Nick Alcock, 2 weeks ago : t5538: move http push tests out
这里有很多占位符。以下表格列出了其中的一些:
占位符 | 输出描述 |
---|---|
%H |
提交哈希(修订的完整 SHA-1 标识符) |
%h |
缩略提交哈希 |
%``an |
作者姓名 |
%``ae |
作者电子邮件 |
%``ar |
作者日期,相对时间 |
%``cn |
提交者姓名 |
%``ce |
提交者电子邮件 |
%``cr |
提交者日期,相对时间 |
%s |
主题(提交消息的第一行,描述修订) |
%% |
一个原始的% |
表格 5.1 – 占位符及其描述
作者与提交者
作者是最初编写补丁的人(撰写更改),而提交者是最后应用补丁的人(创建了包含这些更改的提交对象,表示 DAG 中的修订)。因此,如果您向一个项目提交补丁并且其中一位核心成员应用了该补丁,那么你们两个人都会获得荣誉——您是作者,核心成员是提交者。此外,在进行变基后,变基修订的原始提交作者保持不变,而执行变基的人成为提交者。
--oneline
格式选项与另一个名为--graph
的git log
选项结合使用时特别有用,尽管后者可以与任何格式一起使用。后者选项会添加一个漂亮的小 ASCII 图表,显示您的分支和合并历史。要查看标签和分支的位置,您可以使用一个名为--decorate
的选项(在现代 Git 中,默认已启用该选项):
$ git log --graph --decorate --oneline origin/maint
* bce14aa (origin/maint) Sync with 1.9.4
|\
| * 34d5217 (tag: v1.9.4) Git 1.9.4
| * 12188a8 Merge branch 'rh/prompt' into maint
| |\
| * \ 64d8c31 Merge branch 'mw/symlinks' into maint
| |\ \
* | | | d717282 t5537: re-drop http tests
* | | | e156455 (tag: v2.0.0) Git 2.0
您可能想使用图形工具来可视化您的提交历史。一个这样的工具是名为gitk
的 Tcl/Tk 程序,它随 Git 一起分发。您可以在第十三章中找到更多关于各种类型图形工具的信息,自定义和扩展 Git。
包括、格式化和总结更改
你可以使用git show
命令检查单个修订版本,该命令除了显示提交元数据外,还以统一 diff 格式展示更改,该格式在第二章中有描述,书名为使用 Git 开发,在统一 Git diff 格式小节中有详细介绍。然而,有时你可能希望将更改与所选历史部分一起显示在git log
输出中。你可以通过使用-p
选项来实现。这对于代码审查非常有帮助,或者快速浏览合作者在一系列提交中所做的更改。
通常情况下,Git 不会显示合并提交的更改。要显示来自所有父提交的更改,你需要使用–c
选项(或者使用–cc
进行压缩输出),而要显示每个父提交的更改,可以使用–m
。
有时,在单词级别审查更改比在行级别审查更容易。git log
接受多种选项来更改 diff 输出的格式。其中一个选项是--word-diff
(包括各种变体,如color
)。这种查看差异的方式对于检查文档中的更改(例如文档)非常有用:
commit 06ab60c06606613f238f3154cb27cb22d9723967
Author: Jason St. John <jstjohn@purdue.edu>
Date: Wed May 21 14:52:26 2014 -0400
Documentation: use "command-line" when used as a compound adjective, and fix
Signed-off-by: Jason St. John <jstjohn@purdue.edu>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
diff --git a/Documentation/config.txt b/Documentation/config.txt
index 1932e9b..553b300 100644
--- a/Documentation/config.txt
+++ b/Documentation/config.txt
@@ -381,7 +381,7
Set the path to the root of the working tree.
This can be overridden by the GIT_WORK_TREE environment
variable and the '--work-tree' [-command line-]{+command-line+} option.
The value can be an absolute path or relative to the path to
the .git directory, which is either specified by --git-dir
or GIT_DIR, or automatically discovered.
另一个有用的选项集是关于忽略空白字符更改的,包括–w
/ --ignore-all-space
来忽略所有空白字符更改,以及-b
/ --ignore-space-change
来忽略空白字符的数量变化。
在支持颜色的情况下,你可以要求 Git 使用--color-moved
来显示移动的代码,并可能忽略空白字符的变化(使用--color-moved-ws
)。
有时,你只对更改的汇总感兴趣,而不是详细信息。你可以使用一系列diff
汇总选项。如果你只想知道哪些文件发生了更改,可以使用--names-only
(或--raw --abbrev
)。如果你还想知道这些文件发生了多少更改,可以使用--stat
选项(或者它的机器解析友好的版本--numstat
)来查看一些简要的统计数据。如果你只关心更改的简短总结,可以使用--shortstat
或--summary
。
汇总贡献
是否曾想过自己为某个项目贡献了多少次提交?或者,或许你想知道在上个月(按提交次数计算),谁是最活跃的开发者?不必再猜测了,因为git shortlog
就是用来做这个的:
$ git shortlog -s -n
13885 Junio C Hamano
1399 Shawn O. Pearce
1384 Jeff King
1108 Linus Torvalds
743 Jonathan Nieder
-s
选项将所有的提交信息压缩成提交次数的统计;如果没有这个选项,git shortlog
将列出所有提交的汇总,按开发者分组。-n
选项按提交次数对开发者列表进行排序;否则,默认按字母顺序排序。你可以添加–e
选项来显示电子邮件地址;不过请注意,使用此选项时,Git 会根据不同的电子邮件地址将同一作者的贡献分开。git shortlog
输出的格式可以通过类似--format
选项的漂亮配置进行一定程度的调整。
git shortlog
命令接受修订版本范围和其他限制修订版本的选项,例如--since=1.month.ago
——任何git log
接受并对shortlog
有意义的选项。例如,要查看谁为最后一个发布候选版做出了贡献,您可以使用以下命令:
$ git shortlog -e v2.0.0-rc2..v2.0.0-rc3
Jonathan Nieder <jrnieder@gmail.com> (1):
shell doc: remove stray "+" in example
Junio C Hamano <gitster@pobox.com> (14):
Merge branch 'cl/p4-use-diff-tree'
Update draft release notes for 2.0
Merge branch 'km/avoid-cp-a' into maint
…
提示
需要记住,编写的修订版本数量仅是衡量贡献的一种方式。例如,那些只创建有缺陷提交以后再修复它们的人,其提交数量将比不犯错误的开发者多。
还有其他衡量程序员生产力的方法——例如,作者提交的更改行数,或者幸存行数。这些可以通过 Git 的帮助进行计算,但没有内置的命令来计算它们。
映射作者
在长期运行的项目中使用git shortlog –s -n -e
或git blame
命令时的一个问题是,作者在项目过程中可能会更改他们的姓名或电子邮件,或者两者都会,原因有很多:工作变动(及其工作电子邮件)、配置错误、拼写错误等等。例如,您可能在项目的顶级目录中有一个.mailmap
文件。此文件允许您为贡献者指定规范名称,其最简单的形式如下:
Bob Hacker <bob@example.com>
(实际上,它允许您指定规范名称、规范电子邮件,或者名称和电子邮件,通过电子邮件或名称和电子邮件匹配。)
默认情况下,这些更正会应用于所有命令:git blame
、git shortlog
和git log
。使用自定义log
输出,您可以使用占位符输出原始名称或更正后的名称,以及原始电子邮件或更正后的电子邮件。
查看修订版本和修订版本的文件
有时,您可能希望更详细地检查单个修订版本(例如,使用git bisect
发现的疑似有缺陷的提交),以及其更改和描述。或者,您可能希望检查带有标记消息的注释标签,以及它指向的提交。Git 提供了一个通用的git show
命令;它可以用于任何类型的对象。
例如,要检查当前版本的父版本,可以使用以下命令:
$ git show HEAD^^
commit ca3cdd6bb3fcd0c162a690d5383bdb8e8144b0d2
Author: Bob Hacker <bob@virtech.com>
Date: Sun Jun 1 02:36:32 2014 +0200
Added COPYRIGHT
diff --git a/COPYRIGHT b/COPYRIGHT
new file mode 100644
index 0000000..862aafd
--- /dev/null
+++ b/COPYRIGHT
@@ -0,0 +1,2 @@
+Copyright (c) 2014 VirTech Inc.
+All Rights Reserved
git show
命令还可用于显示目录(树)和文件内容(blob)。要查看文件(或目录),您需要指定它来自于哪个修订版本,并指定文件的路径,使用:
连接它们。例如,要查看标记为v0.1
的版本中src/rand.c
文件的内容,请使用以下命令:
$ git show v0.1:src/rand.c
这可能比用git checkout v0.1 -- src/rand.c
将所需版本的文件检出到工作目录中更方便。在冒号前面可以是任何命名提交的内容(此处为v0.1
),冒号后面可以是任何 Git 跟踪的文件路径(此处为src/rand.c
)。这里的路径名是从项目目录顶部开始的完整路径,但你可以在冒号后使用./
表示相对路径——例如,如果你在src/
子目录中,可以使用v0.1:./rand.c
。
你可以使用相同的技巧比较任意修订版本中的任意文件;另一方面,git show :src/rand.c
命令(如果没有指定修订版本)会显示git add
运行时文件的状态——即索引中(暂存区)选定文件的状态。
如果你想查看在给定修订版本中存在哪些文件(以便选择一个进行查看),你可以使用git ls-tree <revision>
。要查看工作树和索引中存在哪些文件,可以使用git ls-files
并选择适当的选项来查看你想要的内容。
总结
本章向我们展示了探索项目历史的各种方式:选择和过滤要显示的修订版本,搜索与提交相关的各种数据,并格式化输出。
你已经学会了如何找出给定开发者所做的所有修订版本,如何通过提交消息和提交所做的更改进行搜索,以及如何将搜索范围缩小到特定的时间段。
我们甚至可以通过探索历史来尝试找出代码中的 bug:通过撬棍搜索查找某个函数何时从代码中删除,使用git blame
检查文件,了解它的代码是如何变成现在这样并且是谁写的,以及通过git bisect
利用半自动或自动化搜索项目历史,找出哪个版本引入了回归。
在检查一个修订版本时,我们可以选择信息展示的格式,甚至可以自定义格式。总结信息的方式有很多种,从更改文件的统计数据,到每个作者提交次数的统计数据。
在下一章中,我们将研究 Git 如何帮助开发者作为团队在一个项目上协同工作。
问题
请回答以下问题,测试你对本章内容的理解:
-
如何列出自昨天以来在任何远程跟踪分支上做出的所有提交?
-
如何查找给定函数或类的原始作者,以便向他请教或进行代码审查?
-
你如何使用 Git 来帮助找出回归的来源——即在项目的新修订版本中存在的 bug,而在旧版本中并不存在?
-
你注意到你的同事使用了错误配置的电子邮件做了一些提交,使用了bob@laptop.company.com而不是bob@company.com。假设无法重写这些提交,你将如何修复归属问题?
答案
下面是上述问题的答案:
-
将时间限制选项与--****remotes选项结合使用:
-
git log --****since=yesterday --remotes。
-
使用git blame命令或交互式 GUI 进行操作,如git gui blame(或与编辑器或集成开发环境(IDE)的集成);你还可以通过git log -L搜索文件相关片段的历史。
-
使用git bisect来找到引入 bug 的提交,甚至可以通过自动化搜索来使用git bisect run。
-
将正确的姓名和电子邮件添加到.****mailmap文件中。
进一步阅读
要了解本章讨论的更多主题,请查看以下资源:
-
Scott Chacon, Ben Straub, Pro Git, 第二版(2014 年),Apress,第七章 7.5 Git 工具– 搜索:
git-scm.com/book/en/v2/Git-Tools-Searching
-
Christian Couder, 通过 git bisect 解决回归问题(2009 年 Linux-Kongress 大会的幻灯片):
www.linux-kongress.org/2009/slides/fighting_regressions_with_git_bisect_christian_couder.pdf
-
Junio C Hamano, 与第一父项历史的乐趣(2013 年):
git-blame.blogspot.com/2013/09/fun-with-first-parent-history.html
-
Junio C Hamano, 衡量项目活动(2)(2013 年):
git-blame.blogspot.com/2013/03/measuring-project-activities-2.html
-
Jan Goyvaerts, 正则表达式教程 - 学习如何使用并充分利用正则表达式:
www.regular-expressions.info/tutorial.html
第二部分 - 与其他开发人员合作
在这一部分,你将学习如何选择适合你的正确工作流程(包括仓库设置和分支模型),以及如何使用 Git 与其他开发人员协作。你还将发现如何重写历史记录,以及如果无法重写时该怎么办。
本部分包含以下章节:
-
第六章,使用 Git 进行协同开发
-
第七章,发布您的更改
-
第八章,高级分支技巧
-
第九章,合并更改
-
第十章,保持历史记录整洁
第六章:使用 Git 进行协作开发
第二章,使用 Git 开发,以及第三章,管理工作树,教你如何对项目做出新贡献,但这些内容仅限于影响你自己电脑上项目仓库的克隆。第二章描述了如何提交新修订,而第三章则展示了 Git 如何帮助你准备这些修订。
本章以及第七章,发布你的更改,为你提供了一个全景视图,展示了与他人协作的不同方式,涵盖了集中式和分布式工作流。这两章将重点介绍协作开发中的仓库级交互,而使用的分支模式将在第八章,高级分支技术中进行讲解。
本章描述了不同的协作工作流,解释了每种工作流的优缺点。你还将了解信任链概念,以及如何使用签名标签、签名合并和签名提交。
本章将涵盖以下主题:
-
集中式和分布式工作流,以及裸仓库
-
管理远程仓库和一次性协作
-
版本如何被处理——信任链
-
标签;轻量标签与签名标签
-
签名标签、签名合并和签名提交
协作工作流
使用版本控制系统时有不同的参与层次。例如,有些人可能仅对使用 Git 查看项目是如何发展的感兴趣。第四章,探索项目历史,以及第五章,浏览仓库,讲解了如何使用 Git 来进行这种操作。当然,查看项目的历史也是开发中的一个重要部分。
有人可能仅为私人开发使用版本控制,在单一机器上进行单人项目开发。第二章,使用 Git 开发,以及第三章,管理工作树,展示了如何在 Git 中进行这种操作。当然,人们通常不是单独工作,而是以团队形式进行开发:个人开发通常是协作的一部分。
但版本控制系统的一个主要目标是帮助多个开发人员协作完成项目。版本控制使得他们能够高效地在同一软件上同时工作,确保他们的更改互不冲突,从而帮助合并这些更改。
你可能和几个开发者一起合作一个项目,或者与很多人一起合作。你可能是贡献者,也可能是项目维护者。也许项目太大,需要子系统维护者。你可能在紧密的软件团队中工作,或者可能希望让外部贡献者更容易地提供建议的更改(例如,修复 bug 或修正文档中的错误)。对于这些不同的情况,可能有多种工作流程更为适合:
-
集中化工作流
-
点对点工作流
-
维护者工作流
-
分层工作流
裸仓库
有两种类型的仓库:
-
一个具有工作目录和暂存区的仓库(非裸仓库)
-
裸仓库,没有工作目录
前者类型用于私人单人开发并创建新历史,而后者则用于协作和同步开发结果。
按惯例,.git
扩展名——例如,project.git
——而project
(其中包含行政区域和本地仓库在project/.git
中)。
克隆仓库、推送到仓库或从仓库获取时,通常可以省略此扩展名;无论是使用https://github.com/git/git.git
作为仓库 URL,还是github.com/git/git
,都能正常工作。
要创建裸仓库,你需要在git init
或git clone
命令中添加--bare
选项,如下所示:
$ git init --bare project.git
Initialized empty Git repository in /home/user/project.git/
与其他仓库交互
在创建一组修订并扩展项目历史后,你通常需要与其他开发者共享这些更改。你需要与其他仓库实例同步:发布你的更改,并获取他人的更改。
从本地仓库实例——即你自己的仓库克隆——的角度来看,你需要将更改推送到用于发布更改的仓库,并从其他仓库获取更改。通常,你只需要与从中克隆的仓库进行交互。第七章,发布你的更改,将更详细地描述此过程(及其替代方案)。
获取更改后,有时需要通过合并两条开发线(或变基)将它们合并到你的工作中——你可以通过pull操作在一次操作中完成合并。合并和变基操作将在第九章中更详细地描述,合并更改。
Git 假设你不希望本地仓库对外公开,因为这些仓库是用于私人工作(有助于将尚未准备好公开的工作保持不被外界看到)。
这意味着需要额外的步骤来使你的完成工作可用:你需要执行git push
命令。
图 6.1中的图表,延伸自第二章中的图 2.2,使用 Git 开发,展示了创建和发布提交时可以采取的步骤。该图中的箭头表示用于将内容从一个地方复制到另一个地方的 Git 命令,包括从远程仓库到本地仓库。
图 6.1 – 创建提交、发布提交以及将其他开发者发布的更改获取到本地仓库
现在,让我们理解一下集中式工作流。
集中式工作流
使用分布式版本控制系统时,可以使用不同的协作模式,有些更加分布式,有些则不那么分布式。在集中式工作流中,有一个中心枢纽:一个共享仓库,通常是裸仓库,所有人都用它来同步工作。
图 6.2 – 集中式工作流 – 共享仓库为空
在这种工作流中,每个开发者都有自己非裸的克隆,用于开发软件的新版本。当更改准备好时,他们将这些更改推送到中央仓库,并从中获取(或拉取)其他开发者的更改。推送前可能需要先合并更改。在这种工作流中,变化的集成是分布式的。这种工作流如图 6.2所示。
现在,让我们来看看集中式工作流的优缺点。
集中式工作流的优点
集中式工作流的一些主要优点包括以下几点:
-
这种工作流有一个简单的设置;它是那些来自集中式版本控制系统并习惯于集中式管理的人熟悉的范式。它提供了集中的访问控制和便捷的备份。
-
它使得设置持续集成(CI)变得容易。
-
合并更改的过程是由开发人员共同承担的,没有单个人负责集成更改。
-
这可能是一个适合小型团队的私人项目的好设置,或者适合所有开发人员都值得信赖并具备能力的情况。
集中式工作流的缺点
集中式工作流的一些缺点如下:
-
共享仓库是单点故障:如果中央仓库出现问题,那么就无法同步更改。
-
每个开发者推送更改(使其对其他开发者可用)可能需要先更新自己的仓库,并合并其他人的更改。共享集成意味着每个开发者都需要知道如何操作。
-
在这种设置中,你还需要信任开发人员能够访问共享仓库,或者提供访问控制。
点对点或分叉工作流
集中式工作流的对立面是点对点或分支工作流。与使用单一的中央共享公共仓库不同,每个开发者都有一个公共仓库(该仓库为空仓库),以及一个私人工作仓库(带有工作目录),如图 6.3所示。
图 6.3 – 点对点工作流 – 这里,指向上的线表示推送操作,而指向下的线表示拉取/获取操作
当更改准备好后,开发者将更改推送到自己的公共仓库。为了合并来自其他开发者的更改,需要从其他开发者的公共仓库中获取它们。
这种很少使用的点对点工作流,也叫做分支工作流,其优缺点如下:
点对点工作流的优点
-
分支工作流的一个优点是可以在不需要中央仓库的情况下进行贡献集成;它是一个完全分布式的工作流。
-
另一个优点是,如果你想发布你的更改,你不必强制进行集成;你可以随时进行合并。
-
这是一个适用于有机团队的良好工作流,无需过多的设置
点对点工作流的缺点
-
其缺点是缺乏规范版本、没有集中管理,并且在这种工作流的基本形式中,你需要与多个仓库进行交互。尽管git remote update或git fetch --multiple命令可以通过单个命令进行多次获取操作来帮助解决这一问题。
-
设置此工作流需要确保开发者的公共仓库能够从其他开发者的工作站访问,这可能不像将自己的机器作为公共仓库的服务器那么简单。
-
如图 6.3所示,随着开发者数量的增加,协作变得更加复杂;这种工作流的扩展性较差。
集成经理或维护者工作流
点对点工作流的一个问题是没有项目的规范版本,非开发者无法使用。另外,每个开发者都必须自己进行集成(这在集中式工作流中也是如此)。如果我们将图 6.3中的某个公共仓库提升为规范(官方)仓库,并让其中一个开发者负责集成,那么我们就得到了集成经理工作流(或维护者工作流)。以下图展示了这种工作流,其中上方是空仓库,下方是非空仓库。
图 6.4 – 集成经理(维护者)工作流 – 向上的线表示推送操作,而向下的线表示拉取操作
在这种工作流中,当更改准备好时,开发者将更改推送到自己的公共代码库,并通知维护者(例如,通过拉取请求)表示已准备好。然后,维护者从开发者的代码库拉取更改到自己的工作代码库并进行集成。接着,维护者将合并后的更改推送到“受祝福的”代码库,供所有人查看,并可以被获取。
优点和缺点如下:
集成经理工作流的优点
-
这种工作流的优点是拥有项目的官方版本,并且开发者可以继续工作,而无需进行集成或等待集成,因为维护者随时可以拉取他们的更改。
-
这种工作流适用于大型的有机团队,例如开源项目。
-
由于“受祝福的”代码库是由社会共识决定的,这使得在不需要分配访问权限的情况下,能够轻松地切换到其他维护者,无论是暂时的(例如,当某个维护者需要休假时),还是永久的(例如,在进行项目分叉时)。
-
这种设置使得一个较小的开发者团队通过简单地指定一个小组中的代码库作为拉取源,从而更容易地协作。图 6.4中的虚线展示了从非官方代码库拉取的可能性。
集成经理工作流的缺点
-
主要的缺点是,维护者整合更改的能力可能成为瓶颈(与集中式工作流中的分布式集成相对)。
尤其是对于大型团队和大规模项目,这种情况可能会发生。因此,对于非常大的有机团队,例如 Linux 内核开发,最好使用层级工作流,在下一节中将描述该工作流。
-
需要有专门的人员负责合并操作,并且对“受祝福的”代码库的状态负责。
-
另一个缺点是,设置持续集成比在集中式代码库工作流中更为困难。
层级或独裁者与副手工作流
分层工作流是受认可仓库工作流的一个变体,通常用于有数百个合作者的巨大项目中。在这种工作流中,项目维护者(有时称为仁慈的独裁者)有多个额外的集成管理者,通常负责仓库的某些部分(子系统)。他们被称为副手。仁慈的独裁者的公共仓库作为受认可的参考仓库,所有合作者需要从中拉取更新。副手从开发者那里拉取更新,维护者从副手那里拉取更新,如图 6.5所示。(请注意,在以下图示中,用虚线表示的仓库实际上是开发者或副手的私有仓库和公共仓库的配对)。
图 6.5 – 独裁者和副手工作流(分层工作流)
在独裁者与副手工作流中,存在一个仓库的层级(网络)。在开始工作之前,无论是开发还是合并,通常会从项目的规范(受认可的)仓库拉取更新。
-
开发者在自己的私有仓库中准备更改,然后将更改发送给合适的子系统维护者(副手)。
更改可以通过电子邮件发送为补丁,或者通过将它们推送到开发者的公共仓库并向合适的集成管理者(合适的子系统维护者)发送拉取请求来进行。
-
副手负责合并他们各自负责区域的更改。
-
主维护者(独裁者)从副手那里拉取更新(偶尔直接从开发者那里拉取)。独裁者还负责将合并后的更改推送到参考(规范)仓库,通常也负责发布管理(例如,为发布创建标签)。
以下是这种工作流的优缺点概述。
分层工作流的优点
-
这种工作流的优点是,它允许项目负责人(独裁者)将大部分集成工作委派出去。
-
这种工作流在非常大的项目中(涉及开发者和/或更改的数量)或高度分层的环境中非常有用。例如,Linux 内核的开发就使用了这种工作流。
分层工作流的缺点
-
这种工作流的复杂设置是它的一个缺点。它通常对普通项目来说是过度设计。
-
集成管理者工作流几乎所有的其他缺点在这种工作流中都有体现,这也是它更复杂的变体。
选择哪种工作流以及如何设置仓库,取决于项目的开发方式。你需要决定哪些缺点是可以接受的,哪些优势最为重要。
管理远程仓库
在与任何使用 Git 管理的项目协作时,你将经常与一组固定的其他仓库进行交互。例如,使用集成管理器工作流时,至少会涉及到项目的规范仓库。在很多情况下,你将与多个远程仓库交互。
Git 允许我们保存关于远程仓库的信息(或仅仅是git remote
命令)。
存储远程仓库信息的遗留机制
还有两种遗留机制用于存储远程仓库的信息。
第一个是.git/remotes中的一个命名文件——这个文件的名称也将是远程的别名。该文件可以包含关于 URL 或 URLs,以及 fetch 和 push 的 refspecs 信息。
第二个是.git/branches中的一个命名文件——这个文件的名称也将是远程的别名。该文件的内容仅仅是仓库的 URL,后面可以选择跟随#和分支名称。
这些机制在现代代码库中不太可能被发现。详情请见git-fetch(1)手册中的远程部分。
“origin”远程
当克隆一个仓库时,Git 会为你创建一个远程——origin 远程,它存储关于你从哪里克隆的仓库的信息——也就是你仓库副本的起源(因此得名)。你可以使用这个远程来获取更新。
这是默认的远程;例如,git fetch
命令没有指定远程名称时,将使用 origin 远程。你可以通过在每个仓库中设置remote.default
配置变量来更改此设置,或者为特定分支设置不同的默认远程,方法是使用branch.<branchname>.remote
。
列出并检查远程仓库
要查看你已配置的远程仓库,可以运行git remote
命令。它会列出你所有远程仓库的简短名称。在克隆的仓库中,你至少会有一个名为origin
的远程:
$ git remote
origin
要查看 URL 及远程仓库信息,可以使用-v
或--verbose
选项:
$ git remote --verbose
origin https://github.com/git/git.git (fetch)
origin https://github.com/git/git.git (push)
从这个命令的输出中,你可以很容易猜测,fetch 和 push 的 URL 可以不同(在所谓的三角工作流中)。
如果你想查看更多关于某个远程的信息,可以使用git remote show <remote>
子命令:
$ git remote show origin
remote origin
Fetch URL: https://github.com/git/git.git
Push URL: https://github.com/git/git.git
HEAD branch: master
Remote branches:
maint tracked
master tracked
next tracked
pu tracked
todo tracked
Local branch configured for 'git pull':
master merges with remote master
Local ref configured for 'git push':
master pushes to master (up to date)
Git 将参考远程配置、分支配置以及远程仓库本身(以获取最新状态)。如果你希望跳过联系远程仓库并使用缓存信息,可以在git remote show
命令中添加-n
选项。如果没有互联网连接,并且你没有使用'-n'
选项,Git 将告诉你无法联系到仓库。
由于关于远程的信息存储在仓库配置文件中,你可以直接查看.git/config
:
[remote "origin"]
fetch = +refs/heads/*:refs/remotes/origin/*
url = git://git.kernel.org/pub/scm/git/git.git
本地分支和远程分支之间的区别(以及+refs/heads/*:refs/remotes/origin/*
)。你可以在前面的示例的第二行看到它。
添加新的远程
要添加一个新的远程 Git 仓库,并以简短名称存储其信息,请运行 git remote add <``shortname> <URL>
:
$ git remote add alice \
https://git.company.com/alice/random.git
添加远程不会自动从中获取数据——你需要使用 -f
选项来实现(或者之后运行 git fetch <``shortname>
)。
这个命令有一些选项会影响 Git 如何创建一个新的远程。你可以通过 -t <branch>
选项选择你感兴趣的远程仓库中的分支。你可以通过 -m <branch>
选项更改远程仓库中默认的分支(以及可以通过远程名称引用的分支);否则,它将是远程仓库中的当前分支。你可以使用 --tags
或 --no-tags
来获取所有标签或不获取标签;否则,只有获取分支上的标签才会被导入。或者你可以将远程仓库配置为镜像而非协作,使用 --mirror=push
或 --mirror=fetch
。
例如,运行以下命令:
$ git remote add -t master -t next -t maint github \
https://github.com/jnareb/git.git
将会导致远程配置如下:
[remote "github"]
url = https://github.com/jnareb/git.git
fetch = +refs/heads/master:refs/remotes/github/master
fetch = +refs/heads/next:refs/remotes/github/next
fetch = +refs/heads/maint:refs/remotes/github/maint
更新关于远程的信息
关于远程仓库的信息存储在三个地方:
-
在远程配置中:remote.<remote name>,
-
在远程跟踪分支和远程-HEAD(refs/remotes/
/HEAD )中 -
可选地,也存储在每个分支的配置中:branch.
remote-HEAD 是一个符号引用(symref
),它定义了当作为分支名称使用时,<remote name>
所引用的内容,例如在命令 'git log <``remote name>'
中。
你可以直接操作这些信息——无论是通过编辑适当的文件,还是使用诸如 git config
和 git symbolic-ref
之类的命令——但 Git 提供了各种 git remote
子命令来处理这些。
重命名远程
重命名远程——即更改其昵称——是一个相当复杂的操作。运行 git remote rename <old> <new>
不仅会更改 remote.<old>
中的部分名称,还会更改远程跟踪分支和相关的 refspec
,它们的 reflogs(如果有的话——见 core.logAllRefUpdates
配置变量)以及相应的分支配置。
更改远程 URL
你可以使用 git remote set-url
来添加或替换远程的 URL,但直接编辑配置也是相当简单的。
你还可以使用 insteadOf
(和 pushInsteadOf
)配置变量。如果你暂时想使用另一个服务器,这会很有用。例如,如果你克隆 Git 时从 www.kernel.org
下载 Git,但该网站暂时无法访问,你可以通过将以下内容添加到配置文件来实现:
[url "https://github.com/git/git.git"]
insteadOf = git://git.kernel.org/pub/scm/git/git.git
该功能的另一个应用场景是处理仓库迁移。你可以在每个用户的配置文件中使用insteadOf
重写,位于~/.gitconfig
(或~/.config/git/config
),无需更改每个仓库的.git/config
文件中的 URL。如果有多个匹配项,最长的匹配项将被使用。
提示 – 远程的多个 URL
你可以为一个远程设置多个 URL。Git 会按顺序尝试这些 URL,获取时使用第一个有效的 URL,推送时则会同时向所有 URL(所有服务器)发布。
更改远程跟踪分支列表
更改远程跟踪分支列表时,情况类似于更改 URL(即更改 fetch
行的内容)。你可以使用git remote set-branches
(需要使用较新的 Git 客户端)或者直接编辑配置文件。
注意 – 过时的远程跟踪分支
将远程仓库中的分支从跟踪中移除并不会删除远程跟踪分支——后者只是不会在获取时被更新。关于此操作的详细解释,请参阅本章后面的删除远程跟踪分支和远程仓库部分,里面描述了如何修剪与远程仓库中已删除分支对应的远程跟踪分支。
设置远程的默认分支
使用origin
而不是特定的远程跟踪分支(例如origin/master
)。该信息存储在符号引用<remote name>/HEAD
中(例如origin/HEAD
)。
你可以使用git remote set-head
命令来设置此项。--auto
选项根据远程仓库当前分支来设置:
$ git remote set-head origin master
$ git branch -r
origin/HEAD -> origin/master
origin/master
你可以使用--delete
选项删除远程的默认分支。
删除远程跟踪分支和远程仓库
当远程仓库中删除了公共分支时,Git 仍然保留相应的远程跟踪分支。这样做是因为你可能希望在其基础上进行工作,或者已经进行了工作。然而,你可以通过git branch -r -d
命令删除远程跟踪分支,或者使用git remote prune
命令要求 Git 修剪所有过时的远程跟踪分支。你还可以通过设置fetch.prune
和/或remote.<name>.prune
配置变量(后者是针对每个远程的配置)来让 Git 在每次获取时自动执行此操作,仿佛是使用了git fetch --prune
选项。
你可以使用git remote prune
命令的--dry-run
选项,或使用git remote show
命令来检查哪些远程跟踪分支已过时。
删除远程仓库非常简单,只需运行git remote delete
(或其别名git remote rm
)。该命令还会删除已删除远程的远程跟踪分支。
支持三角形工作流
在许多协作工作流中,例如维护者(或集成经理)工作流,你可能从一个 URL(来自受信任的仓库)获取代码,但将代码推送到另一个 URL(推送到你自己的公共仓库)。
如图 6.4所示,开发者与三个仓库进行交互:他们从受信仓库(左上方)拉取到自己的私有仓库(下方较暗),然后将自己的工作推送到自己的公共仓库(上方较亮)。
在这样的 origin
远程(或 remote.default
)中,配置推送到哪个仓库的一个选项是将该仓库作为一个独立的远程添加,并可能将其设置为默认值,使用 remote.pushDefault
:
[remote "origin"]
url = https://git.company.com/project
fetch = +refs/heads/*:refs/remotes/origin/*
[remote "myown"]
url = git@work.company.com:user/project
fetch = +refs/heads/*:refs/remotes/myown/*
[remote]
pushDefault = myown
你还可以在每个分支的配置中将其设置为 pushRemote
:
[branch "master"]
remote = origin
pushRemote = myown
merge = refs/heads/master
另一种选择是使用单一远程(可能是 origin
),但为其设置一个独立的 pushurl
。然而,这种解决方案有一个轻微的缺点,那就是你没有为推送仓库设置独立的远程追踪分支(因此除了有 @{upstream}
作为快捷方式外,无法使用 @{push}
表示法来指定相应的远程追踪分支):
[remote "origin"]
url = https://git.company.com/project
pushurl = git@work.company.com:user/project
fetch = +refs/heads/*:refs/remotes/origin/*
为推送仓库设置独立的远程追踪分支,可以让你跟踪哪些分支已推送到推送远程,哪些有本地未发布的更改。
信任链
项目开发过程中,协作工作的一个重要部分是确保代码的质量。这包括防止仓库意外损坏,也包括防止恶意意图——这是版本控制系统可以帮助完成的任务。Git 需要确保对仓库内容的信任:不仅是你自己的内容,还有其他开发者的内容(特别是项目的规范仓库)。
内容寻址存储
在第四章《探索项目历史》中的SHA-1 和缩短的 SHA-1 标识符一节中,我们了解到 Git 当前使用 SHA-1 哈希作为提交对象的本地标识符(提交对象表示项目的修订并构成项目的历史)。这种机制使得能够以分布式的方式生成提交标识符,通过对提交对象进行加密哈希处理。然后,这个哈希值被用来链接到上一个提交(父提交或多个父提交)。
此外,仓库中存储的所有其他数据(包括由 Blob 对象表示的修订中的文件内容,以及由 Tree 对象表示的文件层次结构)也使用相同的机制。所有类型的对象都是通过它们的内容来寻址,或者更准确地说,是通过对象的哈希函数来寻址。可以说,Git 仓库的基础是内容寻址的 对象数据库。
因此,Git 通过安全的 SHA-1 哈希提供了内建的信任链,通过一种哈希树,也称为 Merkle 树。在一个维度上,提交的 SHA-1 哈希取决于其内容,其中包括父提交或多个父提交的 SHA-1 哈希,父提交的哈希又取决于父提交的内容,依此类推,直到初始根提交。在另一个维度上,提交对象的内容包括表示项目顶层目录的树的 SHA-1 哈希,而这个哈希又依赖于其内容,这些内容包括子目录树和文件内容的 SHA-1 哈希,依此类推,直到单个文件。
图 6.6 – 项目简短历史的哈希树,包含一个标签、两个提交及其内容。SHA-1 哈希以简化形式显示,取决于其内容
所有这些都使得可以使用 SHA-1 哈希来验证从(潜在不可信的)来源获取的对象自创建以来是否已被篡改或修改。
轻量、注释和签名标签
信任链允许我们验证内容,但不验证创建内容的人的身份(作者和提交者的名字是完全可配置的,由用户控制)。这是 GPG/PGP 签名的任务:签名标签、签名提交和签名合并。
从 Git 版本 2.34 开始,你还可以通过将 gpg.format
配置变量设置为 ssh
来使用 SSH 密钥进行签名,例如使用 git config gpg.format ssh
(你可能还需要将公钥用作 user.signingKey
配置变量的配置值)。
轻量标签
Git 使用两种类型的标签:轻量标签和注释标签(还有签名标签,它是注释标签的一种特殊情况)。
使用 refs/tags/
命名空间,而不是在 refs/heads/
中。
注释标签
refs/tags/
命名空间指向一个标签对象,而该标签对象又指向一个提交。标签对象包含创建日期、标签创建者的身份(姓名和电子邮件)以及标签信息。你可以通过 git tag -a
(或 --annotate
)来创建注释标签。如果你没有在命令行中为注释标签指定信息(例如,使用 -m "<message>"
),Git 会启动编辑器让你输入信息。
你可以使用 git show
命令查看标签数据及其对应的提交(跳过提交):
$ git show v0.2
tag v0.2
Tagger: Joe R Hacker <joe@company.com>
Date: Sun Jun 1 03:10:07 2014 -0700
random v0.2
commit 5d2584867fe4e94ab7d211a206bc0bc3804d37a9
签名标签
签名标签是带有明文 PGP 签名(或者,在现代 Git 中,是带有 SSH 签名)的注释标签。你可以通过 git tag -s
创建它们(该命令使用你的提交者身份来选择签名密钥,或者如果已设置,则使用 user.signingKey
),或者使用 git tag -u <key-id>
;这两种方式假设你已经使用 gpg --gen-key
生成了密钥。
轻量标签与注释标签和签名标签的区别
注释标签或签名标签用于标记发布,而轻量标签则用于私人或临时修订标签。出于这个原因,某些 Git 命令(如git describe)默认会忽略轻量标签。
当然,在协作工作流中,重要的是签名标签需要被公开,并且要有一种方式来验证它;这两个操作将在以下章节中描述。
发布标签
Git 默认不推送标签:你需要明确地推送。一个解决方法是单独使用 git push <remote> tag <tag-name>
(这里,tag <tag>
相当于更长的 refs/tags/<tag>:refs/tags/<tag>
);然而,如果你的分支和标签之间没有命名冲突(即,分支和标签没有相同的名称),你就可以省略这条命令中的 tag
关键字。
另一个解决方法是批量推送标签:使用 --tags
选项推送所有标签—包括轻量标签和注释标签,或者仅推送所有指向已推送提交的注释标签,使用 --follow-tags
。这种明确性允许你在发现标签错误或需要最后时刻修复时重新标记(使用 git tag -f
),但前提是标签尚未公开。如果标签已推送,Git 不会(也不应该)在用户不知情的情况下更改标签;因此,如果你推送了错误的标签,需要请求其他人删除该旧标签以进行更改。
在获取变更时,Git 会自动跟随标签,下载指向已获取提交的注释标签。这意味着下游开发者将自动获得签名标签,并能够验证发布版本。
标签验证
要验证签名标签,可以使用 git tag --verify <tag-name>
(或者使用简写 -v
)。你需要导入签名者的 gpg --import
或 gpg --keyserver <key-server> --recv-key <key-id>
,当然,标签创建者的密钥也需要在你的信任链中经过验证。对于 gpg.ssh.allowedSignersFile
配置变量。
$ git tag --verify v0.2
object 1085f3360e148e4b290ea1477143e25cae995fdd
type commit
tag signed
tagger Joe Random 1411122206 +0200
project v0.2
gpg: Signature made Fri Jul 19 12:23:33 2014 CEST using RSA key ID A0218851
gpg: Good signature from "Joe Random <jrandom@example.com>"
签名提交
签名标签是用户和开发者验证标记发布是否由维护者创建的一个好方法。但我们如何确保一条声称是由名为 Jane Doe、邮箱为 jane@company.com
的人提交的提交,实际上是她的提交呢?我们如何确保任何人都能检查这一点?
一种可能的解决方案是签署单个提交。你可以使用 git commit
的 --gpg-sign[=<keyid>]
(或简写 -S
)来完成。密钥标识符是可选的—如果没有提供,Git 会使用你的身份作为作者。请注意,-S
(大写 S)与 -s
(小写 s)不同;后者会在提交信息的末尾添加一个 Signed-off-by 行,表示数字所有权证书:
$ git commit -a --gpg-sign
You need a passphrase to unlock the secret key for
user: "Jane Doe <jane@company.com>"
2048-bit RSA key, ID A0218851, created 2014-03-19
[master 1085f33] README: eol at eof
1 file changed, 1 insertion(+), 1 deletion(-)
为了使提交可供验证,只需将它们推送出去。任何人都可以使用 git log
(或 git show
)的 --show-signature
选项,或者在 git log --format=<format>
中使用 %Gx
占位符来验证它们:
$ git log -1 --show-signature
commit 1085f3360e148e4b290ea1477143e25cae995fdd
gpg: Signature made Wed Mar 19 11:53:49 2014 CEST using RSA key ID A0218851
gpg: Good signature from "Jane Doe <jane@company.com>
Author: Jane Doe <jane@company.com>
Date: Wed Mar 19 11:53:48 2014 +0200
README: eol at eof
你也可以使用git verify-commit
命令来完成这项工作。
合并签名标签(合并标签)
前一节中描述的签名提交机制,在某些工作流中可能有用,但在一个提前推送提交的环境中并不方便,只有在一段时间后你才决定是否将它们纳入主干。在这种情况下,你可能只想签名那些准备好发布的部分。
如果你遵循了第十章,《保持历史整洁》的建议,这种情况可能会发生;你在事后(很久以后)才知道给定的提交系列通过了代码审查。提交必须在创建时进行签名,但你可以在事后创建签名标签,在提交系列被接受后再进行签名。
你可以通过在提交系列的形态定型后(通过审查)重写整个提交系列来处理这个问题,签署每个重写的提交,或者仅修改并签署顶部提交。这两种解决方案都需要强制推送,以替换没有签名的旧历史。你始终可以对每个提交进行签名,或者创建一个空提交(使用--allow-empty
),签署它,并将其推送到系列的顶部。但有一个更好的解决方案:请求拉取一个签名标签。
在这个工作流中,你需要进行更改,当更改准备好后,创建并推送一个签名标签(标记系列中的最后一个提交)。你不需要推送你的工作分支——推送标签就足够了。如果工作流涉及向集成者发送拉取请求,你应当使用签名标签来创建拉取请求,而不是使用最终提交:
$ git tag -s 1253-for-maintainer
$ git request-pull origin/master public-repo \
1253-for-maintainer >msg.txt
签名标签消息会显示在拉取请求的虚线之间,这意味着你可能在创建签名标签时想要在标签消息中解释你的工作。维护者在收到这样的拉取请求后,可以从中复制仓库行,获取并集成命名标签。当记录拉取命名标签的合并结果时,Git 会打开编辑器并要求输入提交消息。集成者将看到一个以以下内容为开头的模板:
Merge tag '1252-for-maintainer'
Work on task tsk-1252
# gpg: Signature made Wed Mar 19 12:23:33 2014 CEST using RSA key ID A0218851
# gpg: Good signature from "Jane Doe <jane@company.com>"
该提交模板包括合并签名标签对象验证的注释输出(因此它不会出现在最终的合并提交消息中)。标签消息有助于更好地描述合并内容。
被拉取的签名标签不会存储在集成者的仓库中,不作为标签对象存储。其内容被存储在一个隐藏的合并提交中。这么做是为了避免将大量类似的工作标签污染标签命名空间。开发者在集成后可以安全地删除该标签(git push public-repo --delete 1252-for-maintainer
)。
在合并提交中记录签名允许在事后使用--show-signature
选项进行验证:
$ git log -1 --show-signature
commit 0507c804e0e297cd163481d4cb20f3f48ceb87cb
merged tag '1252-for-maintainer'
gpg: Signature made Wed Mar 19 12:23:33 2014 CEST using RSA key ID A0218851
gpg: Good signature from "Jane Doe <jane@company.com>"
Merge: 5d25848 1085f33
Author: Jane Doe <jane@company.com>
Date: Wed Mar 19 12:25:08 2014 +0200
Merge tag 'for-maintainer'
Work on task tsk-1252
总结
通过本章,我们学习了如何使用 Git 进行协作开发,如何在团队中共同进行项目工作。我们了解了不同的协作工作流,也就是设置仓库进行协作的不同方式。选择哪种方式取决于具体情况:团队的大小、团队的多样性等。本章重点讨论了仓库与仓库之间的交互;这些仓库中分支与远程跟踪分支的相互作用将在 第八章 中讨论,高级 分支技术。
我们学习了 Git 如何帮助管理与选定工作流相关的远程仓库的信息。我们学习了如何存储、查看和更新这些信息。本章解释了如何管理三角形工作流,其中你从一个仓库(规范仓库)获取数据,并推送到另一个仓库(公开仓库)。
我们学习了信任链:如何验证发布来自维护者,如何签署你的工作以便维护者能够验证它确实来自你,以及 Git 架构如何帮助实现这一点。
下一章,也就是 第七章,发布你的 更改,将讨论如何将你的贡献推送到其他远程仓库。接下来的两章将进一步展开协作主题:第八章,高级分支技术,将探讨本地分支与远程仓库中分支之间的关系,以及如何为协作设置分支;而 第九章,合并更改,将讨论相反的问题——如何将平行工作的结果合并起来。
问题
通过以下问题测试你对本章的理解:
-
你需要什么操作才能将更改发布到你的公共远程仓库?你需要什么操作才能从远程获取更改?
-
git fetch 和 git pull 有什么区别?
-
如何删除过期的远程跟踪分支(即,远程仓库中对应分支已删除的远程跟踪分支)?
答案
下面是上述问题的答案:
-
使用 git push 发布你的更改,使用 git fetch 或 git pull(或 git remote update)从远程仓库获取更改。
-
fetch 操作只下载更改并更新远程跟踪分支,而 pull 操作还会尝试通过 merge 或 rebase 更新当前分支(如果它被配置为跟踪远程仓库中的某个分支)。
-
你可以使用 git branch -d -r 删除单个远程跟踪分支,或者使用 git remote prune 删除所有过期的远程跟踪分支。
进一步阅读
若要了解更多本章讨论的内容,请查看以下资源:
-
Scott Chacon 和 Ben Straub: Pro Git, 第二版 (2014)
git-scm.com/book/en/v2
-
第 5.1 章 分布式 Git - 分布式工作流
-
第 2.5 章 Git 基础 - 与远程仓库 合作
-
第 7.4 章 Git 工具 - 签名 你的工作
-
-
Ryan Brown: gpg 签名发布 (2014)
gitready.com/advanced/2014/11/02/gpg-sign-releases.html
-
Danilo Bargen: 使用 SSH 密钥签名 Git 提交 (2021) https://blog.dbrgn.ch/2021/11/16/git-ssh-signatures/
-
Carl Tashian: SSH 技巧与窍门 – 为你的 SSH 登录添加第二因素 (2020)
smallstep.com/blog/ssh-tricks-and-tips/#add-a-second-factor-to-your-ssh
-
Junio C Hamano: Git Blame: 与 GnuPG 的趣味(?) (2014)
git-blame.blogspot.com/2014/09/fun-with-gnupg.html
第七章:发布你的更改
第六章《与 Git 协作开发》(上一章)教你如何使用 Git 进行团队协作,重点介绍仓库与仓库之间的交互。它描述了不同的仓库协作设置方式,展示了不同的协作工作流,如集中式工作流和集成管理工作流。它还展示了 Git 如何管理有关远程仓库的信息。
在本章中,你将了解如何在本地仓库和远程仓库之间交换信息,以及 Git 如何管理可能需要的远程仓库凭证。
本章还将教你如何将你的更改推送到上游,使其出现在项目的官方历史中,在其标准仓库中。这可以通过将更改推送到中央仓库,推送到你自己的发布仓库并向集成管理者发送某种拉取请求,或甚至交换补丁来完成。
本章将涵盖以下主题:
-
Git 使用的传输协议及其优缺点
-
管理远程仓库的凭证(密码、密钥)
-
发布更改:推送和拉取请求,以及交换补丁
-
使用捆绑包进行离线传输和加速初始克隆
-
远程传输助手及其使用
传输协议和远程助手
通常,远程仓库的配置中的 URL 包含有关传输协议、远程服务器地址(如果适用)以及仓库路径的信息。有时,提供对远程仓库访问的服务器支持多种传输协议;你需要选择使用哪种协议。本节旨在帮助做出这一选择。
本地传输
如果远程仓库位于同一个本地文件系统中,你可以使用仓库的路径或 file://
方案来指定仓库 URL:
/path/to/repo.git/
file:///path/to/repo.git/
前者意味着 Git 克隆时使用 --local
选项,这会绕过智能的 Git 机制,直接复制(或者对于 .git/objects
下不可变文件创建硬链接,虽然可以通过 --no-hardlinks
选项避免);后者较慢,但可以用于获取一个干净的仓库副本(例如,在历史重写后移除意外提交的密码或其他秘密;这在第十章《保持历史清洁》的“重写历史”部分中有描述)。
这种传输是一个不错的选择,适合快速从他人的工作仓库中获取工作,或使用具有适当权限的共享文件系统共享工作。
作为一个特殊情况,单个点(.
)表示当前仓库。这意味着
$ git pull . next
假设 pull.rebase
设置为 false,基本等价于
$ git merge next
智能传输
当你想从另一个机器上的代码库获取时,需要访问 Git 服务器。如今,最常见的是遇到支持 Git 的智能服务器。智能下载器会协商哪些版本是必需的,并创建一个定制的 packfile
发送给客户端。类似地,在推送过程中,Git 服务器会与用户机器上的 Git 进行通信(即与客户端)来确定需要上传哪些版本。
支持 Git 的智能服务器使用 git upload-pack
下载器来获取数据,使用 git receive-pack
来推送数据。如果这些工具不在 PATH
中(但例如安装在用户的主目录中),你可以通过 --upload-pack
和 --receive-pack
选项来指定获取和推送的路径,或者在 remote.<name>
部分使用 uploadpack
和 receivepack
配置变量。
几乎没有例外(例如,代码库使用了被旧版 Git 实例访问的子模块,而该版本无法理解子模块),Git 传输是向后和向前兼容的——客户端和服务器会协商双方都能使用的功能。
原生 Git 协议
使用 git://
URLs 的本地传输方式提供了只读的匿名访问权限(尽管理论上,你可以配置 Git 允许推送,通过启用 receive-pack
服务来实现,方法是通过命令行选项 --enable=receive-pack
或通过 daemon.receivePack
布尔配置变量—不过这种方式完全不推荐,甚至在封闭的本地网络中也不应使用)。
Git 协议没有进行任何认证,包括没有服务器认证,因此在不安全的网络上使用时需要小心。这个协议的 git daemon
TCP 服务器通常监听在 9418
端口;你需要能够访问该端口(通过防火墙)才能使用原生 Git 协议。
小知识
git:// 协议没有安全版本。与 FTP 和 HTTP 协议不同,git:// 协议没有像 FTPS 和 HTTPS 那样支持 TLS。另一方面,可以认为 Git 使用的 SSH 传输就是 git:// 协议通过 SSH 的实现。
SSH 协议
服务器上的 git upload-pack
或 git receive-pack
使用 SSH 执行远程命令。该协议不支持匿名的未认证访问,尽管作为变通方法,你可以为其设置一个访客账户(没有密码或密码为空)。
使用公钥-私钥认证可以在每次连接时无需提供密码。不过,你可能需要提供密码一次,以解锁受密码保护的私钥。你可以在本章的凭证/密码管理部分了解更多关于认证的信息。许多 Git 托管站点和软件开发平台要求通过 SSH 进行密钥认证来访问代码库。
对于 SSH 协议,你可以使用 ssh://
作为协议部分的 URL 语法:
ssh://[user@]host.example.com[:port]/path/to/repo.git/
或者,你可以使用类似于 scp
的语法:
[user@]host.example.com:path/to/repo.git/
SSH 协议还支持 ~username
扩展,就像原生 Git 传输一样(~
是你登录的用户的家目录,~user
是 user
的家目录),有两种语法形式:
ssh://[user@]host.example.com/~[user]/path/to/repo.git/
[user@]host.example.com:~[user]/path/to/repo.git/
SSH 使用首个接触认证用于服务器(TOFU——即首次使用时信任),它记住服务器端先前使用的密钥,并在密钥更改时提醒用户,要求确认(服务器密钥可能是合法更改的,例如,由于 SSH 服务器重新安装)。你可以在第一次连接时检查服务器密钥的指纹。
智能 HTTP(S) 协议
Git 还支持智能 HTTP(S) 协议,这需要一个 Git 知识的 CGI 或服务器模块——例如,git-http-backend
(它本身是一个 CGI 模块)。该协议使用以下 URL 语法:
http[s]://[user@]host.example.com[:port]/path/to/repo.git/
默认情况下,在没有任何其他配置的情况下,Git 允许匿名下载(git fetch
、git pull
、git clone
和 git ls-remote
),但要求客户端必须进行身份验证才能上传(git push
)。
如果需要认证才能访问仓库,则使用标准的 HTTP 认证,由 HTTP 服务器软件完成。使用 SSL/TLS 配合 HTTPS 可以确保如果需要密码(例如,如果服务器使用基本 HTTP 认证),密码会被加密发送,并且服务器身份会被验证(使用服务器的 CA 证书)。
传统(愚蠢)传输
一些传输方式不需要任何 Git 知识的智能服务器——它们不需要在服务器上安装 Git(对于智能传输,至少需要 git-upload-pack
和/或 git-receive-pack
)。这些是 FTP(S) 和“愚蠢”HTTP(S) 协议的传输方式(如今,使用 remote-curl
辅助工具实现)。
这些传输仅需要适当的标准服务器(FTP 服务器或 web 服务器)以及来自 git update-server-info
的最新数据。从此类服务器拉取时,Git 使用所谓的 提交遍历器 下载器:从已获取的分支和标签开始,Git 依次向下遍历提交链,下载包含缺失修订版本和其他数据(例如,修订版下的文件内容)的对象或包。
这种传输方式效率低下(在带宽方面,尤其是在延迟方面),但另一方面,如果被中断,可以恢复。尽管如此,相较于使用“愚蠢”协议,还有更好的解决方案——即使用捆绑包(参见本章中的 离线传输与捆绑包 部分),当网络连接不可靠到无法完成克隆操作时,使用捆绑包可以是一种替代方案。
向一个“愚蠢”服务器推送仅能通过 HTTP 和 HTTPS 协议进行。这要求 web 服务器支持 WebDAV,并且 Git 必须与 expat 库链接编译。FTP 和 FTPS 协议为只读(仅支持 clone
、fetch
和 pull
)。
作为设计特性,Git 可以自动将简单协议的 URL 升级为智能 URL。反之,一个支持 Git 的 HTTP 服务器也可以降级为向后兼容的简单协议(至少在获取操作时:智能 HTTP 服务器不支持基于 WebDAV 的简单 HTTP 推送操作)。这个特性允许使用相同的 HTTP(S) URL 进行简单和智能访问:
http[s]://[user@]host.example.com[:port]/path/to/repo.git/
离线传输与存档
有时,你的机器和持有你想要获取的 Git 仓库的服务器之间没有直接连接。或者,服务器可能没有运行,你仍然想将更改复制到另一台机器上。也许你的网络出现故障,或者你在某个现场工作,并且出于安全原因无法访问本地网络。也可能是你的无线/以太网卡坏了。
输入git bundle
命令。该命令会将所有通常通过网络传输的内容打包,将对象和引用放入一个特殊的二进制存档文件中,称为bundle
(类似于packfile
,只是包含了分支等信息)。你需要指定要打包哪些提交—这是网络协议自动为你完成的操作,通常用于在线传输。
细节
当你使用智能传输时,会进行一个want/have 协商阶段,客户端告诉服务器它在自己的仓库中已有的内容,以及它希望从服务器上获取哪些已发布的引用,以找到公共的版本。这些信息将被服务器用于创建一个 packfile,并且只发送必要的内容给客户端,从而最小化带宽使用。
接下来,你需要通过某种方式将这个包(这个存档)传输到你的机器上。可以通过例如使用git clone
或git fetch
命令,替换仓库 URL 为包的文件名来完成此操作。
Git 传输的代理
当无法直接访问服务器时,例如,在防火墙保护的局域网内部,有时可以通过代理连接。
对于原生 Git 协议(git://
),你可以使用core.gitProxy
配置变量,或GIT_PROXY_COMMAND
环境变量来指定代理命令—例如,ssh
。这可以通过core.gitProxy
值的特殊语法为每个远程仓库设置,例如,"ssh" for kernel.org
。
你可以使用http.proxy
配置变量或适当的curl环境变量,如http_proxy
,来指定用于 HTTP(S)协议的 HTTP 代理服务器(http(s)://
)。这可以通过remote.<remote name>.proxy
配置变量为每个远程仓库单独设置。
你可以配置 SSH(使用其配置文件,例如~/.ssh/config
)来使用隧道(端口转发)或代理命令(例如,netcat/nc;
或 SSH 的netcat
模式——即ssh -W –
——前提是你的 SSH 实现支持此功能)。这是一个推荐的 SSH 代理解决方案;如果无法使用隧道或代理,你可以使用ext::
传输助手,正如本章后面使用远程助手的传输中继部分所展示的那样。
使用捆绑包克隆和更新
假设你想将一个项目的历史(为了简单起见,仅限于master
分支)从machineA
(例如,你的工作电脑)转移到machineB
(例如,一个现场电脑)。然而,这两台机器之间没有直接连接。
首先,我们创建一个包含master
分支全部历史的捆绑包(见第四章,探索项目历史),并标记这个历史点以便知道我们捆绑了什么,稍后会用到:
user@machineA ~$ cd repo
user@machineA repo$ git bundle create ../repo.bundle master
user@machineA repo$ git tag -f lastbundle master
这里,捆绑包文件是在工作目录外创建的。这是一个选择问题;将其存储在仓库外意味着你不必担心不小心将其添加到项目历史中,或者需要添加新的ignore
规则。*.bundle
文件扩展名只是命名约定的一部分。
重要提示
出于安全原因,为了避免泄露已删除但未清除的历史部分(例如,意外提交的含密码的文件),Git 只允许从git show-ref兼容的引用中获取:分支、远程追踪分支和标签。
创建捆绑包时适用相同的限制。这意味着,例如,由于实现原因,你不能运行git bundle creates master¹。尽管当然,因为你控制着服务器端,作为一种解决方法,你可以为master**创建一个新分支,(暂时)回退**master**,或者检出**master的分离HEAD。
然后,你将刚刚创建的repo.bundle
文件转移到machineB
(通过电子邮件、USB 闪存驱动器等)。由于这个捆绑包包含了一个完整的、独立的历史子集,直到第一个(没有父提交的)根提交,你可以通过克隆它来创建一个新的仓库,只需将捆绑包文件名替换为仓库 URL:
user@machineB ~$ git clone repo.bundle repo
Cloning into 'repo'...
warning: remote HEAD refers to non-existent ref, unable to checkout.
user@machineB ~$ cd repo
user@machineB repo$ git branch -a
remotes/origin/master
哎呀。我们没有捆绑HEAD
,所以 Git 克隆时不知道当前应检出的分支:
user@machineB repo$ git bundle list-heads ../repo.bundle
5d2584867fe4e94ab7d211a206bc0bc3804d37a9 refs/heads/master
提示
因为捆绑包可以视作远程仓库,我们本可以直接使用git ls-remote ../repo.bundle命令,而不是git bundle list-heads ../repo.bundle。
因此,考虑到这个捆绑包的状态,我们需要指定要检出的分支,以避免问题(如果我们也捆绑了HEAD
,则不需要这样做):
user@machineB ~$ git clone repo.bundle --branch master repo
不需要再次克隆,我们可以通过选择当前分支来修复失败的检出问题:
user@machineB repo$ git switch master
Already on 'master'
Branch 'master' set up to track remote branch 'master' from 'origin'.
正如你所看到的,这里 Git 猜测当我们尝试切换到一个不存在的本地分支master
时,实际上我们想做的是创建一个本地分支来提交新的提交到远程master
分支。换句话说,就是创建一个本地分支来跟踪(追踪)远程origin
中存在的同名分支。Git 所做的事情与我们运行以下命令的效果相同:
$ git switch --create master --track origin/master
要更新从包中克隆的machineB
上的仓库,你可以在替换掉存储在/home/user/repo.bundle
的原始包后,执行fetch
或pull
操作来更新。
要创建一个包含自上次传输以来更改的包,在machineA
上运行以下命令:
user@machineA repo$ git bundle create ../repo.bundle \
lastbundle..master
user@machineA repo$ git tag -f lastbundle master
这将打包所有自lastbundle
标签以来的更改;该标签表示之前包中复制的内容(有关双点语法的解释,请参见第四章,探索项目历史,双点符号部分)。创建包后,这将更新标签(使用-f
或--force
来替换它),就像第一次创建包时一样,以便下一个包也可以从当前点增量创建。
然后,你需要将包复制到machineB
,替换旧的包。此时,可以简单地执行拉取操作来更新仓库,示例如下:
user@machineB repo$ git pullFrom /home/user/repo.bundle
ba5807e..5d25848 master -> origin/master
Updating ba5807e..5d25848
Fast-forward
使用包更新现有仓库
有时,你可能已经克隆了一个仓库,但网络出现故障。或者,也许你已经移动到了局域网(LAN)之外,现在无法访问服务器。最终结果是你有一个现有的仓库,但没有与上游的直接连接(即我们克隆的那个仓库)。
现在,如果你不想将整个仓库打包,这样会浪费空间,就像在使用包克隆和更新一节中那样,你需要找到一种方法,指定一个截止点(基点),确保它一定存在于目标仓库中(你想要更新的那个仓库)。你可以使用几乎任何技术来指定要打包到包中的修订范围,参考第四章,探索项目历史。唯一的限制是,如前所述,历史记录必须从一个分支或标签开始(任何git show-ref
接受的内容)。当然,你可以使用git
的log
命令检查这个范围。
常用的指定修订范围打包到包中的解决方案如下:
-
使用两个仓库中都存在的标签:
git bundle create ../``repo.bundle v0.1..master
-
根据提交创建时间创建一个截止点:
git bundle create ../repo.bundle --``since=1.week master
-
只打包最后几次修订,通过提交次数来限制修订范围:
git bundle create ../repo.bundle -``5 master
提示
打包时最好多装一点而不是太少。你可以通过git bundle verify检查仓库是否包含需要从捆绑包中获取的必要提交。如果打包内容过少,你会收到以下错误:
user@machineB repo$ git pull ../****repo.bundle master
错误:仓库缺少这些 先决提交:
错误:ca3cdd6bb3fcd0c162a690d5383bdb8e8144b0d2
然后,在将其传输到machineB
后,你可以像使用常规仓库一样使用该捆绑文件来执行一次性拉取(将捆绑文件名代替网址或远程名称):
user@machineB repo$ git pull ../repo.bundle master
From ../repo.bundle
* branch master -> FETCH_HEAD
Updating ba5807e..5d25848
如果你不想处理合并,你可以将其拉取到远程跟踪分支(这里使用的<远程分支>:<远程跟踪分支>
符号,称为refspec,将在第八章,高级 分支技巧中解释):
user@machineB repo$ git fetch ../repo.bundle \
refs/heads/master:refs/remotes/origin/master
From ../repo.bundle
ba5807e..5d25848 master -> origin/master
Updating ba5807e..5d25848
或者,你可以使用git remote add
创建一个新的快捷方式,使用捆绑包文件的路径代替仓库 URL。然后,你可以像上一节所述那样处理捆绑包。
使用捆绑包帮助进行初始克隆
智能传输提供比普通传输更有效的传输方式。另一方面,使用智能传输的可恢复克隆概念至今依然难以实现(它在 Git 版本 2.34 中不可用,不过也许将来某人会实现它)。对于历史较长、文件众多的大型项目,初次克隆可能非常庞大(例如,linux-next
超过 2.7 GB)且需要很长时间。如果网络不可靠,这可能成为一个问题。
提示 – 解决方法
你可以通过使用浅克隆或稀疏克隆来绕过不可靠网络的问题(见第十二章,管理 大型仓库),并逐步扩展,直到得到完整的仓库。也有一些第三方工具可以自动执行此操作。
你可以从源仓库创建一个捆绑包,例如,使用以下命令(需要在服务器上运行):
$ git –git-dir=/dir/repo.git bundle create -- all HEAD
一些服务器可能提供这样的捆绑包来帮助进行初始克隆。有一种做法是,供克隆使用的捆绑包与仓库位于相同的 URL 下,但后缀为.bundle
而非.git
。例如,https://git.example.com/user/repo.git
的捆绑包可以在https://git.example.com/user/repo.bundle
找到。
然后,你可以使用任何支持断点续传的传输方式下载这种捆绑包,它是一个普通的静态文件:HTTP(S)、FTP(S)、rsync,甚至是 BitTorrent(使用适当的客户端,支持断点续传)。
在现代 Git 中,用户可以通过 --bundle-uri
命令行选项指定 bundle URI,或者 Git 服务器可以广告一个 bundle 列表。bundle URI 列表也可以保存在配置文件中。然后,从 bundle 服务器(例如 github.com/git-ecosystem/git-bundle-server
)获取数据将是自动的。
远程传输助手
当 Git 不知道如何处理某个传输协议时(即尝试使用一个没有内建支持的协议),它会尝试使用适当的 远程助手 来处理该协议(如果有的话)。这就是为什么当仓库 URL 的协议部分出现错误时,Git 会返回如下错误信息:
$ git clone shh://git@example.com:repo
Cloning into 'repo'…
fatal: Unable to find remote helper for 'shh'
git: 'remote-shh' is not a git command. See 'git --help'.
这个错误信息表示 Git 尝试找到 git-remote-shh
来处理 shh
协议(实际上是 ssh
的拼写错误),但是没有找到具有此名称的可执行文件。
你可以通过 <transport>::<address>
语法显式地请求特定的远程助手,其中 <transport>
定义了助手(git remote-<transport>
),<address>
是助手用来查找仓库的字符串。
现代 Git 支持通过 curl
系列远程助手来处理傻瓜 HTTP、HTTPS、FTP 和 FTPS 协议,分别是 git-remote-http
、git-remote-https
、git-remote-ftp
和 git-remote-ftps
。
使用远程助手进行传输中继
Git 包括两个通用的远程助手,可以用来代理智能传输:git-remote-fd
帮助器通过双向套接字或一对管道连接远程服务器,git-remote-ext
帮助器则使用外部命令连接远程服务器。
在后者的情况下,Git 使用 "ext::<command> <arguments>"
语法指定仓库 URL,Git 运行指定的命令来连接服务器,将数据传递给命令的标准输入,并从其标准输出接收响应。这些数据假设会传递给 git://
服务器、git-upload-pack
、git-receive-pack
或 git-upload-archive
(具体取决于情况)。
例如,假设你的仓库托管在一个局域网主机上,你可以通过 SSH 登录。可是,出于安全原因,这台主机在互联网上不可见,你需要通过网关主机 login.example.com
:
user@home ~$ ssh user@login.example.com
user@login ~$ ssh work
user@work ~$ find . -name .git -type d -print
./repo/.git
问题是——同样出于安全原因——这个网关主机要么没有安装 Git(以减少攻击面),要么没有你的仓库(它使用的是不同的文件系统)。这意味着你无法使用普通的 SSH 协议——除非你能设置 ssh -L
。SSH 传输实际上是通过 SSH 远程访问的 git-receive-pack
/ git-upload-pack
,其路径作为参数。这意味着你可以使用 ext::
远程助手:
user@home ~$ git clone \
"ext::ssh -t ssh work %S 'repo'" repo
Cloning into 'repo'...
Checking connectivity... done.
这里,%S
将由 Git 展开为适当服务的完整名称——git-upload-pack
用于拉取,git-receive-pack
用于推送。如果登录到内部主机时使用交互式身份验证(例如密码),则需要使用-t
选项。请注意,克隆结果时需要为仓库命名(此处为repo
);否则,Git 将使用命令(ssh
)作为仓库名称。
提示
你还可以使用"ext::ssh [
这不是唯一的解决方案——尽管没有像原生git://
协议那样内建支持通过代理发送 SSH 传输(例如core.gitProxy
)和 HTTP(例如http.proxy
),你仍然可以通过配置 SSH 的ProxyCommand
选项,或创建 SSH 隧道来实现。
另一方面,你还可以使用ext::
远程助手来代理git://
协议——例如,借助socat
——并且可以使用单个代理来处理多个服务器。有关详细信息和示例,请参阅git-remote-ext(1)
手册页。
使用外部 SCM 仓库作为远程仓库
远程助手机制非常强大。它还可以与其他版本控制系统交互,透明地将其仓库作为本地 Git 仓库使用。尽管没有内建的助手(除非你考虑 Git 源代码中的contrib/
目录),你可以找到git-remote-hg
、gitifyhg
或git-cinnabar
助手来访问 Mercurial 仓库,以及git-remote-bzr
来访问 Bazaar 仓库。
一旦安装,这些远程助手桥接将允许你像操作 Git 仓库一样,使用<helper>::<URL>
语法来克隆、拉取和推送 Mercurial 或 Bazaar 仓库。例如,要克隆 Mercurial 仓库,你只需运行以下命令:
$ git clone "hg::https://hg.example.com/repo"
还有remote.<remote name>.vcs
配置变量,如果你不喜欢在仓库 URL 中使用<helper>::
前缀,可以使用此方法。通过这种方式,你可以对 Git 和原始版本控制系统(VCS)使用相同的 URL。
外部版本控制系统客户端
使用远程助手桥接的替代方法是使用专用客户端,例如git-svn用于 Subversion,或git-p4用于 Perforce。这些客户端与外部 VCS(通常是集中式 VCS)交互,基于此交互管理并更新 Git 仓库,同时基于 Git 仓库中的更改更新外部仓库。
当然,需要记住不同版本控制系统之间的阻抗不匹配,以及远程助手机制的限制。有些功能根本无法转换,或者转换效果不佳——例如,Git 中的章鱼合并(包含多个父提交),或者 Mercurial 中的多个匿名分支(头)。
通过远程助手,还可以没有地方修复错误,用目标原生语法替换对其他版本的引用,并清理由仓库转换创建的其他工件——在更改版本控制系统时应执行一次性转换,可以使用第三方工具reposurgeon
来执行此类清理操作。
通过远程助手,甚至可以使用严格意义上不是版本控制仓库的东西;例如,使用Git-Mediawiki项目,可以使用 Git 查看和编辑基于 MediaWiki 的维基(例如维基百科),将页面历史视为 Git 仓库:
$ git clone "mediawiki::https://wiki.example.com"
此外,还有远程助手允许额外的传输协议或存储选项——例如,git-remote-s3bundle
将仓库存储为 Amazon S3 上的捆绑文件,或者git-remote-codecommit
用于 AWS CodeCommit(如果不能或不想使用具有静态凭据的 HTTPS 身份验证)。还有git-ssb
通过 Secure ScuttleButt 协议在点对点日志存储中编码仓库。
凭证/密码管理
在大多数情况下,除了本地传输(文件系统权限控制访问),向远程仓库发布更改需要身份验证(用户识别自身)和授权(给定用户有权限执行推送操作)。有时,获取仓库也需要身份验证和授权,例如对私有仓库。
用于身份验证的常用凭证是用户名和密码。如果不担心信息泄露(关于有效用户名信息泄露的问题),可以将用户名放在 HTTP 和 SSH 存储库 URL 中,或者可以使用凭证助手机制。绝对不应该将密码放在 URL 中,即使在技术上对于 HTTP URL 也是可能的——例如当它们列出进程时,密码可能会对其他人可见。
除了底层传输引擎中固有的机制,如 SSH 的SSH_ASKPASS
或基于 curl 的传输中的~/.netrc
文件,Git 提供了自己的集成解决方案。
请求密码
一些 Git 命令会交互地要求输入密码(如果用户名未知,则需要用户名),例如git svn
、HTTP 接口或 IMAP 认证——可以告知它们使用外部程序。该程序会使用合适的提示(称为身份验证域,描述密码用途),Git 从该程序的标准输出中读取密码。
Git 会尝试以下位置来询问用户用户名和密码;参见gitcredentials(7)
手册页:
-
如果设置了GIT_ASKPASS环境变量指定的程序(Git 特定的环境变量始终优先于配置变量)
-
否则,如果设置了core.askpass配置变量,将使用它
-
否则,使用 SSH_ASKPASS 环境变量(如果已设置,且它不是 Git 特有的,因此在序列中稍后会被检查)。
-
否则,系统会提示用户在终端中输入。
这个 askpass
外部程序通常根据用户的桌面环境来选择(如有必要,安装后使用):
-
(x11-)ssh-askpass 提供一个简单的 X 窗口对话框,要求输入用户名和密码。
-
GNOME 有 ssh-askpass-gnome,KDE 有 ksshaskpass。
-
mac-ssh-askpass 可以用于 macOS。
-
win-ssh-askpass 可以用于 MS Windows。
Git 带有一个跨平台的密码对话框,使用 Tcl/Tk 编写——git-gui--askpass
——用于配合 git gui
图形界面和 gitk
历史查看器。
我们在这里看到的 Git 配置优先级将在 第十三章 中详细描述,定制和 扩展 Git。
SSH 的公钥认证。
对于 SSH 传输协议,除了密码之外,还有其他认证机制。其中之一是 gitolite
使用的—gitolite.com
。
公钥认证的思路是,用户创建一个 ssh-keygen
。然后将公钥发送到服务器,例如,使用 ssh-copy-id
(它还会将公钥 *.pub
添加到远程服务器的 ~/.ssh/authorized_keys
文件的末尾),或者将其粘贴到托管服务的网页表单中。然后你可以使用存储在本地机器上的私钥进行登录,例如 ~/.ssh/id_rsa
。如果不是默认身份密钥,你可能需要配置 SSH(在 Linux 上的 ~/.ssh/config
,在 MS Windows 上有类似的配置文件)以为给定的连接(主机名)使用特定的身份文件。
使用公钥认证的另一种便捷方法是使用认证代理,例如 ssh-agent
(或者在 MS Windows 上使用 PuTTY 的 Pageant)。利用代理也使得使用带有密码保护的私钥更加方便——你只需在添加密钥时提供一次密码给代理(这可能需要用户操作,例如,运行 ssh-add
对 ssh-agent
)。
凭据助手。
一直重复输入相同的凭据可能会很麻烦。对于 SSH,你可以使用公钥认证,但对于其他传输方式没有真正的等价物。Git 凭据配置提供了两种方法,至少可以减少提问次数。
第一个是为给定的 认证上下文 配置默认用户名(如果 URL 中没有提供用户名)——例如,主机名:
[credential "https://git.example.com"]
username = user
如果你没有安全存储凭据的方法,它会有所帮助。
第二种是使用外部程序,Git 可以从中请求用户名和密码 — 凭据助手。这些程序通常与桌面环境或操作系统提供的安全存储(钥匙链、密钥环、钱包、凭据管理器等)进行接口。
默认情况下,Git 至少包含cache
和store
助手。cache
助手(git-credential-cache
)将凭据存储在内存中,有效期为短暂的时间;默认情况下,它会为用户名和密码缓存 15 分钟。store
助手(git-credential-store
)将未加密的凭据永久存储在磁盘上,仅由用户可读的文件中(类似于~/.netrc
);还有第三方netrc
助手(git-credential-netrc
)用于 GPG 加密的netrc/authinfo
文件。
可以全局或每个身份验证上下文配置选择要使用的凭据助手及其选项,如前面的示例所示。全局凭据配置如下所示:
[credential]
helper = cache --timeout=300
这将使 Git 使用cache
凭据助手,它会缓存凭据 300 秒(5 分钟)。如果凭据助手名称不是绝对路径(例如,/usr/local/bin/git-kde-credentials-helper
),Git 会在助手名称前加上git credential-
前缀。你可以通过git help -a | grep credential-
检查可用的凭据助手类型。Git for Windows 还包括可选的git credential-helper-selection
。
存在使用桌面环境安全存储的凭据助手。当你使用它们时,只需提供密码一次,以解锁存储(某些助手可以在 Git 源代码的 contrib/
区域找到)。有适用于 GNOME 和 KDE 的git-credential-libsecret
,适用于 macOS Keychain 的 git-credential-osxkeychain
,以及适用于 Microsoft 跨平台 Git Credential Manager(GCM)的 git-credential-manager
。
你还可以使用git-credential-oauth
来避免设置个人访问令牌或 SSH 密钥。使用此解决方案时,第一次验证时,助手会打开浏览器窗口到主机。后续访问使用缓存的凭据。在这里,可以利用 Git 支持多个凭据助手的特性。GitHub、GitLab 和 Bitbucket 是支持 OAuth 身份验证的 Git 托管服务之一。
Git 将为最特定的身份验证上下文使用凭据配置,但如果你想通过路径名区分 HTTP URL(例如,在同一主机上为不同仓库提供不同的用户名),你需要将useHttpPath
配置变量设置为true
。如果为上下文配置了多个助手,则会依次尝试每个,直到 Git 获取到用户名和密码。
历史注释
在凭据助手引入之前,人们可以使用与桌面环境钥匙串接口的askpass程序——例如,kwalletaskpass(用于 KDE 钱包)或git-password(用于 macOS 钥匙串)。
将你的更改发布到上游
在第六章中,Git 协作开发节解释了各种仓库设置。在这里,我们将了解一些为项目做贡献的常见模式。我们将看到发布更改的主要选项。
在开始新的更改工作之前,你通常应该与主开发版本同步,将官方版本合并到你的仓库中。这部分内容以及维护者的工作将在第九章中,合并 更改 一起 中描述。
推送到公共仓库
在集中式工作流中,发布你的更改仅仅是将其推送到中央服务器,如图 6.2所示。因为你与其他开发者共享这个中央仓库,所以有可能其他人已经推送到你试图更新的分支(即非快进的情况)。在这种情况下,你需要拉取(获取并合并,或获取并变基)其他人的更改,然后才能推送你的更改。这种情况在第一章中“更新你的仓库(使用合并)”一节开始时有说明,位于《Git 基础 实践》中。
另一种类似工作流的系统是,当你的团队将每一组更改提交到代码审查系统时——例如,Gerrit。一种可用的选项是将更改推送到一个特殊的引用refs/for/<branchname>
(这个引用是以目标分支命名),并推送到一个特殊的仓库中。然后,变更审查服务器会将每一组更改自动放置到一个单独的每组引用中(例如,refs/changes/<change-id>
,适用于属于给定更改 ID 系列的提交)。
重要提示
在点对点(见图 6.3)和维护者工作流,或分层工作流变体(分别见图 6.4和图 6.5)中,将更改纳入项目的第一步是执行推送操作,但推送到你自己的“公共”仓库(对相应组可见)中的项目分支。然后,你需要请求你的共同开发者或项目维护者合并你的更改。你可以通过生成拉取请求来实现这一点,如下所述。
生成拉取请求
在除集中式工作流以外的所有工作流中,需要向共同开发者、维护者或集成经理发送通知,告知公共仓库中有可用的更改。git request-pull
命令可以帮助完成此步骤。给定起始点(即感兴趣的修订范围的底部),远程公共仓库的 URL 或名称,以及可选的提交结束点(如果不是 HEAD
),此命令将生成更改的摘要:
$ git request-pull origin/master publish
The following changes since commit ba5807e44d75285244e1d2eacb1c10cbc5cf3935:
Merge: strtol() + checks (2014-05-31 20:43:42 +0200)
are available in the Git repository at:
https://git.example.com/random master
for you to fetch changes up to 82006acd359717624fb33a7ae554cba6be717911:
Merge branch 'master' of https://git.company.com/random (2021-05-30 00:58:23 +0200)
-----------------------------------------------------------
Alice Developer (1):
Support optional <count> parameter
src/rand.c | 26 +++++++++++++++++++++-----
1 files changed, 21 insertions(+), 5 deletions(-)
拉取请求包含更改基准的 SHA-1(即提议拉取的系列中第一个提交之前的修订)、基准提交的标题、URL、公共仓库的分支(适合用作 git pull
参数)、最终提交的标题、git shortlog
输出和 git diff --stat
输出。这些输出可以发送给维护者——例如,通过电子邮件。
图 7.1 – 在 GitHub 的分支列表中显示的“新建拉取请求”操作
许多 Git 托管软件和服务都包括与 git request-pull
相对应的内置功能(例如,GitHub 中的 创建拉取请求 操作)。
交换补丁
许多大型项目(以及许多开源项目)已经建立了接受补丁形式更改的流程,例如,降低贡献的门槛。如果你想向一个项目发送一次性的代码提案,但不打算成为常规贡献者,发送补丁可能比完整的协作设置更容易(在集中式工作流中获得提交权限、设置个人公共仓库进行分支和类似工作流——在 GitHub 上,这将包括 fork 项目)。此外,任何兼容工具都可以生成补丁,项目可以接受补丁,无论版本控制设置如何。
提示
如今,随着各种免费 Git 托管服务的普及,可能更难设置电子邮件客户端以发送格式正确的补丁电子邮件——尽管像 GitGitGadget(用于向 Git 项目的邮件列表提交补丁)或较早的 submitGit 服务可以提供帮助。Git 本身也包括发送邮件的命令,即 git send-email 和 git imap-send,这两者都需要配置。
此外,补丁作为更改的文本表示形式,可以被计算机和人类轻松理解。这使得它们具有普遍的吸引力,并且在 代码审查 过程中非常有用。许多开源项目历史上使用公共邮件列表进行此目的:你可以将补丁通过电子邮件发送到这个列表,公众可以审查并评论你的更改(通过 public-inbox 和 lore+lei 等服务,即使没有订阅邮件列表,也可以进行此操作)。
要生成每个提交系列的电子邮件版本,并将其转换为 mbox 格式的补丁,可以使用 git format-patch
命令,如下所示:
$ git format-patch -M -1
0001-Support-optional-count-parameter.patch
你可以使用任何修订范围说明符与此命令配合使用。最常用的是通过提交数量来限制,如前面的示例所示,或者使用双点修订范围语法——例如,@{u}..
(见第四章,探索项目历史,双点符号部分)。在生成大量补丁时,选择一个目录来保存生成的补丁通常是很有用的。可以使用-o <directory>
选项来实现。git format-patch
的-M
选项(传递给git diff
)开启了重命名检测;这可以使补丁更小、更容易审查。
补丁文件最终看起来是这样的:
From db23d0eb16f553dd17ed476bec731d65cf37cbdc Mon Sep 17 00:00:00 2001
From: Alice Developer <alice@company.com>
Date: Sat, 31 May 2014 20:25:40 +0200
Subject: [PATCH] Initialize random number generator
Signed-off-by: Alice Developer
---
random.c | 2 ++
1 files changed, 2 insertions(+), 0 deletions(-)
diff --git a/random.c b/random.c
index cc09a47..5e095ce 100644
--- a/random.c
+++ b/random.c
@@ -1,5 +1,6 @@
#include <stdio.h>
#include <stdlib.h>
+#include <time.h>
int random_int(int max)
@@ -15,6 +16,7 @@ int main(int argc, char *argv[])
int max = atoi(argv[1]);
+ srand(time(NULL));
int result = random_int(max);
printf("%d\n", result);
--
2.42.0
它实际上是一个完整的 mbox 格式的电子邮件。主题(去掉[PATCH]
前缀后)以及三连字符线(---
)之前的所有内容构成了提交信息——即更改的描述。要将其发送到邮件列表或开发人员,可以使用git send-email
或git imap-send
,或者任何能够发送纯文本邮件的电子邮件客户端。维护者随后可以使用git am
来应用补丁系列,自动创建提交;有关更多内容,请参阅第九章,合并更改部分,应用补丁系列章节。
补丁的邮件主题约定
[PATCH]前缀是为了更容易区分补丁与其他电子邮件。这个前缀可以——并且通常会——包括额外的信息,如补丁系列中的编号(集合)、系列的修订版、关于它是进行中的工作(WIP)或征求意见(RFC)状态的信息——例如,[****RFC/PATCHv4 3/8]。
你也可以编辑这些补丁文件,为未来的审阅者添加更多信息——例如,关于替代方法的信息、补丁的前几个版本(之前的尝试)之间的差异,或者实现补丁的讨论的摘要和/或参考资料(例如,邮件列表中的讨论)。你可以在---
行和补丁开始之间的地方添加此类文本,在更改摘要(diffstat
)之前;git am
会忽略这些内容。
提示——范围差异
如果补丁系列正在修订并且需要以不同的方式重新执行,推荐的做法是在封面信中提供git range-diff输出,显示该系列的一个版本与另一个版本之间的差异。
总结
在本章中,我们学习了如何选择传输协议(如果远程服务器提供此类选择),以及一些技巧,比如将外部仓库当作本地 Git 仓库使用和使用离线传输(通过 bundle)。
与远程仓库的联系可能需要提供凭证——通常是用户名和密码,以便能够执行例如推送到仓库等操作。本章描述了 Git 如何通过凭证助手帮助简化这部分内容。
发布你的更改并将其推送到上游可能涉及不同的机制,这取决于工作流。本章介绍了 push、pull 请求和基于补丁的技术。
以下两章扩展了协作主题:第八章,高级分支技巧,探讨了本地分支和远程仓库分支之间的关系,以及如何为协作设置分支;而 第九章,合并更改,则讨论了相反的问题——如何将并行工作的结果合并在一起。
问题
请回答以下问题,以测试你对本章内容的掌握情况:
-
当连接到主机的网络不太稳定时,如何克隆一个大型仓库,但你可以登录到主机并访问远程仓库?
-
在集中式工作流中,如何将更改提交到规范仓库?在集成管理者工作流中需要做些什么?
-
如何设置 Git,使得只需提供一次密码,而不是每次与远程连接时都需要输入?
-
你能否使用 Git 与外国版本控制系统的仓库进行交互,提交提交并下载更新?
答案
以下是上述问题的答案:
-
一种可能的解决方案是使用 git bundle 在远程主机上创建打包文件,并通过可恢复的传输方式(如 HTTPS、rsync 或 BitTorrent)发送该文件,或通过可移动存储介质(如 USB 闪存驱动器)传输该文件。
-
在集中式工作流中,你需要将更改推送到指定的中央规范仓库,这可能需要先合并其他人的更改;在集成管理者工作流中,你需要将更改推送到公共仓库,并发送某种形式的 pull 请求(例如,使用 git request-pull 和电子邮件)到规范仓库,或者通过电子邮件将补丁发送给维护者。
-
你可以根据操作系统和桌面环境设置合适的凭证助手;对于 SSH 传输,你还可以使用 ssh-agent 或等效工具。
-
使用适当的工具,你可以使用 Git 作为外部版本控制系统的客户端(例如,git svn),或使用远程传输助手将外部仓库当作 Git 远程仓库来使用(例如,git-cinnabar)。
深入阅读
若要了解本章中涉及的更多内容,请查看以下资源:
-
Scott Chacon 和 Ben Straub: Pro Git(第 2 版)(2014 年)
git-scm.com/book/en/v2
-
第 7.12 章 Git 工具 – 打包
-
第 7.14 章 Git 工具 - 凭证存储
-
-
打包 URI:
git-scm.com/docs/bundle-uri
-
Anthony Heddings: 你应该使用 HTTPS 还是 SSH 来进行 Git 操作?(2021 年)
www.howtogeek.com/devops/should-you-use-https-or-ssh-for-git/#why-use-https
-
SSH 隧道 的视觉指南
robotmoon.com/ssh-tunnels/
-
Carl Tashian: SSH 技巧与窍门 – 为你的 SSH 登录添加第二因素认证(2020)
smallstep.com/blog/ssh-tricks-and-tips/#add-a-second-factor-to-your-ssh
-
Greg Kroah-Hartman: “刻入石板的补丁”,或为何 Linux 内核开发者依赖纯文本邮件,2016 年 Kernel Recipes 演讲
kernel-recipes.org/en/2016/talks/patches-carved-into-stone-tablets/
第八章:高级分支技术
第六章,Git 的协作开发,描述了如何在专注于仓库级交互时安排团队合作。在那一章中,您学习了各种集中式和分布式工作流及其优缺点。
本章将深入探讨分布式开发中的协作细节。我们将探索本地分支与远程仓库中的分支之间的关系。接着,我们将介绍远程跟踪分支、分支跟踪以及上游的概念。本章还将教我们如何通过使用引用规范和推送模式来指定仓库之间分支的同步。
您还将学习分支技术:如何使用分支来创建新功能、准备新版本并修复漏洞。您将了解不同分支模式的优缺点。本章将向您展示如何使用分支,以便您可以轻松选择哪些功能将进入项目的下一个版本。
在本章中,我们将涵盖以下主题:
-
不同种类的分支,包括长生命周期和短生命周期分支及其目的
-
各种分支模式,以及它们如何组成工作流
-
不同分支模型的发布工程
-
使用分支修复多个已发布版本中的安全问题
-
远程跟踪分支和引用规范
-
拉取和推送分支及标签的规则
-
选择适合所选协作工作流的推送模式
分支的目的
在版本控制系统中,分支是一个活跃的并行开发线(也叫代码线)。它们用于隔离、分离和汇聚不同类型的工作。例如,分支可以用于防止当前未完成的功能开发工作干扰 bug 修复的管理(隔离),或者将旧版本软件的修复集中起来(汇聚与集成)。
一个 Git 仓库可以拥有任意数量的分支。此外,使用像 Git 这样的分布式版本控制系统,单个项目可能有多个仓库(称为分叉或克隆),其中一些是公共的,一些是私有的;每个这样的仓库都会有自己的本地分支。这可以视为源分支。每个开发者至少会有一个项目公共仓库的私有克隆来进行工作。
一点历史——分支管理演进的简要说明
早期的分布式版本控制系统使用每个仓库一个分支的模型。当时,Bazaar(后来为 Bazaar-NG)和Mercurial 文档建议通过克隆仓库来创建新分支。
另一方面,Git 从一开始就很好地支持在单个仓库中使用多个分支。然而,最初假设会有一个中央的多分支仓库与多个单分支仓库交互(例如,遗留的 .git/branches 目录用于指定 URL 并拉取分支,正如 gitrepository-layout(7) 手册页中所描述的那样),尽管在 Git 中,这更多是关于默认设置而非能力。
因为在 Git 中创建分支非常便宜(而且合并也很容易),并且协作非常灵活,人们开始越来越多地使用分支,甚至是用于单独的工作。这导致了非常有用的主题分支工作流(也称为功能分支)的广泛使用。
隔离与集成
版本控制系统,如 Git,允许不同的人在相同的代码库上工作而不互相干扰。它们还使得在不同类型的工作之间切换变得容易。但这种分离的工作最终需要合并到某个集成目标中才能变得有用,并且稍后被包括在发布版本中。
我们需要隔离,但我们也需要集成变更,将工作合并成一个连贯的整体。为了尽量避免冲突,我们的变更需要对其他人可见,或者更好的是被集成。例如,如果我们改变了某个 API 的调用约定,但我们的工作仍然是隔离的,其他人就无法轻松地适应这些变化。他们会在工作中使用旧版的 API —— 导致合并冲突,并使未来的集成更加困难。因此,从这个角度来看,提前和更频繁的集成是值得追求的目标。
然而,一些功能更加复杂,它们的开发包含许多步骤。频繁集成的目标与隔离未完成的工作的需求相冲突,且防止这些工作可见。如果我们希望进行频繁集成,就需要能够处理这些问题。
通往生产发布的路径
软件开发的主要目标是将代码部署到生产环境,创建可用的发布版本,并让其被使用。一个适当的分支技术帮助我们为创建这样的发布版本提供稳定的基础。
使用何种分支模式取决于项目的具体情况。例如,团队可能需要将正在进行的工作与稳定的基础隔离开来。发布过程也可能会有更多或更少的摩擦。此外,你可能需要管理多个版本的发布,或者生产环境中多个版本的项目。
有一些特定的分支模式可以帮助你处理这些问题。
长期运行与短期存在的分支
主要目的是收集并集成变更的分支需要长时间存在,甚至是永久性的。它们的设计目的是持续存在,或至少存在很长时间;这些分支很少会被删除。
从协作的角度来看,长期分支可以预期在你下次更新数据或发布更改时仍然存在。这意味着你可以安全地从远程仓库中的任何长期分支派生开始工作,并确保在集成这些工作时不会遇到问题。这也意味着至少要有一个这样的分支存在。人们通常基于这些分支进行工作,定义了项目的当前版本,有时这些分支被称为主干。
长期存在的分支会一直存在,而短期分支或临时分支则用于处理单一问题,通常在问题解决后(分支合并或功能取消后)会被删除。它们的生命周期仅限于问题存在的时间。其目的是有时间限制的。
为单独的问题创建一个独立的分支有助于我们隔离并收集解决问题过程中的后续步骤,无论是添加新功能还是创建紧急的修复。这些分支通常会根据它们的主题命名。
分支的可见性
在公开仓库中,通常只能看到长期存在的分支。在大多数情况下,这些分支应该永远不会回滚(新版本始终是旧版本的后代)。这使得你可以安全地在公开分支上构建你的工作。
然而,这里有一些特殊情况;可能存在一些分支在每次新发布后都会被重建(此时需要强制拉取),也可能存在一些分支无法快速前进。每种情况都应该在开发者文档中明确提及,以帮助你避免不愉快的意外。
由于其临时性,短期分支通常仅存在于开发者或集成管理者(维护者)的本地私有仓库中,并不会推送到公开分发仓库。如果它们出现在公开仓库中,通常也只是作为个人贡献者的公开仓库中的目标,用于拉取请求(参见第六章,与 Git 协作开发)。
分支的替代方案
通过频繁的集成,可以及早发现潜在的冲突。然而,一些功能的开发需要更长时间,当推送到主干时它们可能尚未准备好。但团队不希望暴露半成品的功能。在这种以集成而非隔离为导向的分支工作流中,通常需要某种机制来隐藏未完成的工作。
一种技巧是先构建后端代码,等到它准备好时再为其创建用户界面,就像基石一样。另一方面,修改现有代码可以通过创建临时的抽象层来实现,这样等新实现准备好时,你就可以切换到底层实现。
另一种有用的方法是通过特性开关或特性切换来隐藏不同的未完成实现。这种技术在为已集成但未完成的功能提供隔离时非常有用。例如,使用运行时特性切换,你可以在实际生产数据上比较两种不同的算法,或者进行 A/B 测试。
无集成可见性
频繁集成到主线的替代方法是使用外部渠道。这可以通过创建一种proposed-updates
类型的分支来实现,用来合并所有功能分支。这样可以提高更改的可见性,并提供一个测试分支集成的地方。
像GitLive(可作为 VS Code 扩展和 JetBrains IDE 插件使用)这样的工具和服务,可以显示谁正在处理哪个分支、哪个问题,甚至显示团队成员的本地工作副本更改。
分支模式
在许多情况下,分支模式的选择(即分支技术)取决于分支的稳定性,换句话说,就是分支的健康状态。一个稳定分支或健康分支是指该分支的当前提交总是能成功构建和部署,且软件运行时几乎没有或最多只有几个 bug。
确保一个分支的健康状态基本上需要每天进行构建,并且有一套全面的自动化测试,这些测试需要频繁运行——如果不是每次提交时运行,至少在每次集成(合并)时运行。然而,如何做到这一点超出了本书的范围。
集成模式
决定采用何种分支策略将各个变化集成到一个连贯且健康的主线中,取决于各种因素。倾向于频繁集成的技术,如持续集成,需要被合并的分支保持健康。这要求团队有高度的纪律性,确保每个开发者能够确保每个变化都经过充分测试且不会破坏已开发的应用程序。
另一方面,如果不能确定正在开发的功能质量足够好,并且我们希望在功能完成后再作为一个单元进行评估,那么较少频繁的集成可能更有意义。要求预集成代码审查也会促使你采用特定的分支模式。
主线集成
最简单的分支策略就是直接在主线(主干)上工作,并将更改(提交)直接合并到主线中。在这种工作流程中,开发者从主线开始,在其基础上进行工作。
这一策略称为主线集成或基于主干的开发(名称取决于主分支的命名)。
当开发者达到想要集成的节点时,他们会先拉取主线的最新状态。如果其他开发者在他们工作期间发布了更改,他们需要将这些更改合并,可以使用合并或重基操作——详情请参见第九章,合并更改。然后,开发者验证代码的健康状况,并将集成的更改推送到主线。
基于主题分支的开发
在主题分支模式下(也叫做特性分支),其理念是为每个主题创建一个新的独立分支。这可能是创建一个新特性或修复一个 bug。此类分支旨在收集特性后续的开发步骤(每一步——每个提交——应该是一个独立的单元,易于审查),并将一个特性的工作与其他主题的工作隔离开来。使用特性分支可以将相关的更改保留在一起,而不是与其他提交混合。这还使得整个主题可以作为一个单元被丢弃(或回退)、作为一个单元进行审查,并作为一个单元被接受(集成)。
主题分支上的提交的最终目标是将它们包含在产品的发布版本中。这意味着,最终,短生命周期的主题分支将被合并到长期存在的稳定工作分支中,并且该主题分支必须被删除。
为了便于集成主题分支,推荐的做法是从你最终将合并的最古老、最稳定的集成分支上创建这些分支。通常,这意味着从稳定的工作毕业分支上创建一个新的分支。然而,如果某个特性依赖于尚未稳定的主题分支,你需要从包含所需依赖的合适主题分支上进行分支。
请注意,如果你发现自己从错误的分支上进行了分支,可以通过重基来修复(参见第九章,合并更改,以及第十章,保持历史清晰),因为主题分支是非公开的。
持续集成
在使用主线集成模式时,集成尽可能频繁:每次提交后都进行集成。特性分支则意味着集成的周期有一个下限——你集成的是完全开发好的、具有凝聚力的特性。
使用持续集成模式(也叫做规模化主干开发),你尽量进行频繁的集成——也就是说,每当你完成了足够的更改并且分支依然健康时,就进行集成。最佳的做法是使用短生命周期的特性分支,并进行更频繁的集成。推荐的做法是至少每天集成一次,特性分支最多存活几天。
使用这种模式时,你需要能够处理部分构建的特性。如果主干代码在生产环境中运行(持续交付),你需要考虑如何避免在运行中的代码中暴露这些未完成的特性。此问题在分支替代方案一节中有所描述。
发布工程
如果主干保持足够健康,并且始终处于可发布状态(遵循持续交付的原则),你可以通过从当前提交点创建 Git 标签来标记发布版本。这种简单的分支模式称为发布就绪主干。
但如果情况不是这样,或者你需要管理多个版本的产品,就需要更复杂的分支模式。在这种情况下,从集成分支到生产发布的路径上需要专门的分支。
渐进稳定分支
一个可能的解决方案是,对于持续开发不够稳定,无法始终准备好发布(临时包括一些不稳定代码)的问题,将较不成熟的代码和较成熟的代码放在不同的 maint
、master
、next
分支中:
图 8.1 – 成熟度分支的线性视图和“筒仓”视图(也称为渐进稳定分支)
这些分支形成一个层次结构,工作稳定性的毕业或稳定性级别逐渐降低,如图 8.1所示。在线性视图中(图的顶部),稳定的修订版本位于提交历史的较后位置,而最前沿的不稳定工作则位于较前的位置。或者,我们可以将分支看作工作筒仓(图的底部),根据更改的稳定性(毕业)将工作分配到不同的地方。请注意,在实际开发中,渐进稳定分支不会保持如此简单。在分叉点之后,分支上会有新的修订。然而,即使存在合并,整体形状仍然是相同的。
在这里和接下来的图示中,所选的提交名称(C1、C2、C3 等)仅用于区分提交,在某些情况下,也用于便于查看哪些提交对应其他提交。
对于成熟分支,规则是始终将更稳定的分支合并到较不稳定的分支中——也就是说,向上合并。这将保持分支筒仓的整体形状(参见本章“毕业或渐进稳定分支工作流”一节中的图 8.3)。这是因为合并意味着将所有来自合并分支的更改包含在内。
因此,将一个不太稳定的分支合并到一个更稳定的分支中,会将不稳定的工作带入稳定分支,违背了稳定分支的目的和契约。
通常,我们看到以下稳定级别的毕业分支:
-
maint、maintenance 或 fixes 分支仅包含对上一个主要版本的漏洞修复;小版本更新通过这个分支进行。
-
main、master、trunk 或 stable 分支,开发工作面向下一个主要版本;该分支的末端应始终保持在生产就绪状态。
-
next、devel、development 或 unstable 分支,新开发的功能会被推送到此分支以测试其是否准备好进行下一个版本的发布;该分支的末端可以用于每夜构建。
-
pu 或 proposed 分支,用于提议的更新。这是集成测试分支,旨在检查不同新特性之间的兼容性。
拥有多个长期运行的分支并非必要,但在非常大或复杂的项目中往往是有帮助的。在操作中,每个稳定性级别通常对应其自身的平台或部署环境。
你不需要——也可能不应该——使用这里列出的每一种分支类型。只选择适合你项目所需的。
按版本分支与按版本维护
为项目准备新版本可能是一个漫长且复杂的过程。按版本分支可以帮助实现这一点。发布分支用于将持续的开发与准备新版本的工作区分开来。它允许其他开发人员继续进行新功能编写和集成测试,而质量保证团队则在发布经理的协助下,花时间测试和稳定发布候选版本。
创建新版本后,保持这种按版本划分的分支可以让我们支持和维护软件的旧版本。在这些时候,这些分支作为一个地方,用于集中修复漏洞(针对其软件版本)并创建小版本更新。
并非所有项目都需要使用按版本分支。你可以在稳定工作毕业分支上准备新版本,或使用单独的仓库代替使用独立分支。同时,并非所有项目都必须支持超过最新版本的版本。
这种类型的分支通常以它所针对的发布版本命名,例如 release-v1.4
。最好不要为分支取与发布标签相同的名称。
发布列车与特性冻结
如果你的项目按照固定的周期发布(例如每两周或每六个月发布一次),且发布过程复杂(例如有外部测试或验证过程),那么使用发布列车分支模式可能会更有利。这可以视为按版本发布分支模式的变体。它在图 8.2中有示意:
图 8.2 – 月度发布的发布火车分支模式,5 月的“火车”已打标签并发布到生产,6 月的“火车”则处于功能冻结后的状态
在这种方法中,每个发布分支都与一个功能冻结日期(在计划发布日期之前)相关联。新的发布分支必须不晚于前一个发布的功能冻结日期创建。功能冻结后,集成分支转变为发布分支,只接受修复 bug 和准备项目发布的更改。这个方法通常与特性分支一起使用。
如果有多个每发布分支处于活跃状态并接受新特性,开发者可以估算完成新特性并推送到“火车”上的时间,并将其推送到较晚日期的“火车”(加载未来的火车)。较早出发的“火车”可以定期合并到较晚出发的“火车”中。
通过增加“火车”的频率并减少发布过程中的摩擦,这种模式可以过渡到持续交付模式(生产就绪的主线)。
安全修复的热修复分支
热修复分支类似于发布分支,但用于非计划发布。它们的目的是应对生产环境或广泛部署的版本的不良状态,通常是为了解决生产环境中的一些严重 bug(通常是安全漏洞)。这种类型的分支可以视为 bug 修复主题分支的长生命周期等效物(参见本章的Bugfix 分支部分)。
其他涉及长生命周期分支的分支模式
不同类型的分支主要目的是为了隔离和/或整合开发线路。然而,也存在一些不符合团队集成或生产发布路径主题的分支模式。
每个客户或每个部署的分支
假设你的项目有一些客户需要进行一些定制调整,因为他们的做法有所不同,或者某些部署站点有特殊要求。假设这些定制无法通过简单修改配置来完成。在这种情况下,你需要为这些客户或定制化需求创建独立的开发线路。
但是,你不希望这些开发线路保持独立。你期望会有一些变化适用于所有这些分支。一个解决方案是为每个定制集、每个客户或每个部署使用一个分支。另一个解决方案是使用独立的仓库。这两种方案都能帮助维持并行的开发线路,并将变更从一条线路传递到另一条线路。
这种环境分支可以被视为一种反模式。采用这种方法时,很容易引入导致生产环境与开发者工作站上行为不一致的变更,或者最终不得不为每个客户维护截然不同的产品。
自动化分支
假设你正在开发一个网页应用程序,并希望使用版本控制系统来自动化其部署。一种解决方案是设置一个守护进程来监视一个特定的分支(例如,名为deploy
的分支)是否有变化。更新该分支将自动更新并重新加载应用程序。
当然,这并不是唯一的解决方案。另一种可能是使用一个单独的deploy
仓库并在那里设置钩子,这样推送就会导致网页应用程序刷新。或者,你可以在一个公共仓库中配置钩子,使得推送到特定分支时触发重新部署(该机制将在第十四章 Git 管理中描述)。
这些技术也可以用于持续集成;不是部署应用程序,而是将其推送到特定分支会触发测试套件运行(触发器可以在该分支上创建一个新的提交或合并它)。
用于匿名推送访问的 Mob 分支
在远程仓库(服务器上)拥有一个特别处理推送的分支是一种具有多种用途的技术,包括帮助协作。它可以用来为一个项目启用受控的匿名推送访问。
假设你希望允许随机的贡献者推送到中央仓库。但你会希望以一种受管理的方式进行:一种解决方案是创建一个特殊的mob
分支或一个mob/*
命名空间(分支集),并放宽访问控制。
你将在第十四章 Git 管理中学习如何进行设置。
孤立分支的技巧
到目前为止描述的不同类型的分支在目的和管理上有所不同。然而,从技术角度来看(即,从提交图的角度来看),它们都是相同的。所谓的孤立分支则不同。
孤立分支是一个与主历史记录不共享任何修订的平行断开(孤立)开发线。它是对修订 DAG(有向无环图)中的一个不相交子图的引用,与主 DAG 图没有交集。在大多数情况下,它们的检出也由不同的文件组成。
这类分支有时被作为一种技巧,用于将与项目稍微相关的内容存储在同一个仓库中,而不是使用单独的仓库。(当使用单独的仓库存储相关内容时,你可能会想使用一些命名约定来表示这一事实——例如,使用一个常见的前缀。)它们可以用来执行以下操作:
-
存储项目的网页文件。例如,GitHub 使用一个名为gh-pages的分支来存储项目的网页。
-
当生成文件的过程需要一些非标准工具链时,可以存储生成的文件。例如,项目文档可以存储在 html、man 和 pdf 孤立分支中(html 分支还可以用来部署文档)。这样,用户可以获取特定格式的文档,而无需安装生成文档所需的工具链。
-
存储项目 TODO 备注(例如,在 todo 分支中),可能还可以将一些专门的维护者工具(脚本)存储在其中。
-
将 GitOps 的部署配置与源代码存储在同一个仓库中,而不是将代码和部署配置分开存储在两个不同的仓库中。
你可以通过 git checkout --orphan <new branch>
创建这样的分支,或者通过从一个独立的仓库推送到(或拉取到)特定分支,如下所示:
$ git fetch repo-htmldocs master:html
该命令将从不相关的 repo-htmldocs
仓库中获取 master
分支到未连接的 html
“孤立”分支。
小知识
使用 git checkout --orphan 创建孤立分支在技术上并不创建分支——也就是说,它不会创建新的分支引用。它所做的是将符号 HEAD 引用指向一个尚未存在的分支。引用是在新孤立分支的第一次提交之后创建的。因此,不能使用 git branch 命令创建孤立分支。
其他类型的短期分支
长期存在的分支永远存在,而短期或临时分支是为了解决单个问题而创建的,通常在处理完该问题后会被删除。它们的存在时间仅与问题存在的时间一致。
由于其临时性,孤立分支通常仅存在于开发者或集成经理(维护者)的本地私有仓库中,并不会推送到公共分发仓库。如果它们出现在公共仓库中,则仅存在于个人贡献者的公共仓库中(参见 第六章,使用 Git 进行协作开发),作为拉取请求的目标。
Bugfix 分支
我们可以区分一个特殊的主题分支,其目的是修复一个 bug。此类分支应从适用的最旧集成分支(包含该 bug 的最稳定分支)开始创建。这通常意味着从维护分支或所有集成分支的分叉点创建,而不是从稳定分支的最新提交开始。Bugfix 分支的目标是合并到相关的长期集成分支中。
Bugfix 分支可以视为长期存在的 hotfix 分支的短期替代品。使用它们比仅仅在维护分支(或其他适当的集成分支)上提交修复要更好。
Detached HEAD – 匿名分支
你可以把分离的 HEAD 状态(在 第二章,使用 Git 开发 中描述)看作是最终的临时分支——临时到甚至没有名字。Git 在一些情况下会自动使用这样的匿名分支,比如在二分查找和变基时。
因为在 Git 中只有一个匿名分支,而且它必须始终是当前分支,所以通常更好地创建一个真正的临时分支,并为其指定一个临时名称;你始终可以稍后更改该分支的名称。
分离的 HEAD
的一种可能用途是用作概念验证工作。然而,你需要记得在修改被证明有价值时设置分支的名称(或者如果你需要切换分支)。从匿名分支转到命名分支是很容易的。你只需从当前的分离 HEAD
状态创建一个新分支。
分支工作流与发布工程
现在我们了解了不同的分支模式及其目的,让我们来看看如何将它们组合成不同的分支工作流。不同的情况需要使用不同的分支以及不同的策略。例如,较小的项目更适合简单的分支工作流,而较大的项目可能需要更复杂的工作流。
在本节中,我们将描述如何使用几种常见的工作流。每种工作流通过它所使用的不同类型的分支来区分。除了了解特定工作流的持续开发情况外,我们还将探讨在新版本发布时(对于重大版本和次要版本,视情况而定)推荐的做法。
发布和主干分支工作流
最简单的工作流之一是仅使用一个集成分支。这样的分支有时被称为 main
或 master
分支(它是创建仓库时的默认分支)。在该工作流的纯粹版本中,你会将所有内容提交到这个分支,至少在正常开发阶段是这样。这种工作方式源自集中式版本控制时代,那时分支,尤其是合并,代价较高,因此人们避免使用过多分支的工作流。
该工作流非常适合持续集成。如果你能够保持主干的健康状态,可以通过标记直接从主干切出新的发布版本。
另一方面,如果发布过程更为复杂,这种工作流可以与每个版本的分支一起使用。在这种情况下,当我们决定进行新版本发布时,我们会从主干创建新的发布分支。这样做是为了避免发布稳定化与持续开发工作之间的干扰。规则是,所有的稳定化工作都在发布分支上进行,而所有的持续开发都在主干上进行。候选发布(Release candidates)是从发布分支切出的(打标签),就像主要版本的最终版本一样。给定版本的发布分支可以随后用于收集 bug 修复,并从中切出小版本发布。
这种简单工作流的缺点是它要求维持一个健康的分支。否则,如果在开发过程中进入不稳定状态,那么很难为新版本提供一个好的起点。一种替代方案是,在发布分支上创建还原提交,撤销尚未准备好的工作。然而,这可能需要大量的工作,并且会使项目的历史变得难以跟踪。
这种工作流的另一个难点是,一个看起来不错的功能可能在后期会引发问题。这是这个工作流难以应对的情况。如果在开发过程中发现某个通过多个提交实现的功能不是一个好主意,回滚它可能会很困难。特别是当这些提交分布在时间线(历史)中时,这种情况尤为突出。
尽管存在这些问题,这种简单的工作流对于小型或自律性强的团队来说是一个不错的选择。
毕业分支工作流
为了能够提供产品的稳定版本,并能够将其作为一种浮动的 beta 版本进行实际测试,你需要将稳定的工作和正在进行且可能破坏代码的工作分开。这就是毕业分支的作用——用于集成不同成熟度和稳定性的修订(这种长期运行的分支也被称为集成分支或渐进稳定性分支)。请参见图 8.1,位于成熟度或渐进稳定性分支部分,展示了渐进稳定性分支和线性历史的简单案例的图形视图和筒仓视图。我们将这种主要(或仅)使用这种类型分支的技术称为毕业分支工作流。
除了保持稳定和不稳定开发分开的需求外,还需要持续的维护。如果只需要支持一个版本的产品,并且创建新版本的过程足够简单,那么你也可以为此使用毕业类型的分支。
这里,“足够简单”意味着你可以仅从稳定分支中创建下一个主要版本。
在这种情况下,你至少会有三个集成分支。一个分支用于持续的维护工作(只包含对最后版本的 bug 修复),用于创建小版本更新;另一个分支用于稳定工作,创建主要版本发布(此分支也可用于每夜构建的稳定版本);还有一个分支用于持续开发,可能是不稳定的:
图 8.3 – 毕业或渐进稳定分支工作流。你绝不应将一个不太稳定的分支合并到更稳定的分支中,因为那样会带入所有不稳定的历史。
你可以按原样使用这个工作流,只包含毕业分支,而不包含其他类型的分支:
-
你在维护分支上提交 bug 修复,并在必要时将其合并到稳定分支和开发分支中。
-
你在稳定分支上创建经过充分测试的修订版,并在需要时将其合并到开发分支中(例如,如果新工作依赖于它们)。
-
你将进行中的工作(可能是不稳定的)放在开发分支上。
在正常开发过程中,绝不应将不太稳定的分支合并到更稳定的分支中,因为这会降低它们的稳定性。这个工作流的简化版本展示在图 8.3中。
当然,这要求你在开始工作之前就知道你正在处理的功能应该被视为稳定还是不稳定。另有一个前提假设是,从一开始不同的功能就能很好地协同工作。然而,在实际操作中,你会期望每个开发环节都从概念验证开始,经过可能多次迭代的进行中的工作,最终稳定下来。这个问题可以通过使用主题分支来解决,接下来会进行说明。
在纯粹的毕业分支工作流中,你会从维护分支创建小版本发布(包含 bug 修复)。主要版本发布(包含新功能)则从稳定工作分支创建。在主要版本发布后,稳定工作分支会合并到维护分支中,以开始支持刚刚创建的新版。此时,可以将一个不稳定的(开发)分支合并到稳定分支中。这是唯一允许进行向上合并——即将不太稳定的分支合并到更稳定的分支中的时候。
主题分支工作流
主题分支工作流的核心思想是为每个主题或功能创建一个独立的短期分支,这样所有属于某一主题的提交(即其开发过程的所有步骤)都被保留下来。每个主题分支的目的是开发一个新功能或修复一个 bug:
图 8.4 – 具有一个集成分支(master)和三个主题或功能分支的主题分支工作流。一个主题分支被合并到集成分支并删除
在主题分支工作流(也称为功能分支工作流)中,至少有两种不同类型的分支。首先,至少需要有一个长期存在的集成分支。这种类型的分支纯粹用于合并。集成分支是公开的。
其次,还有单独的短期临时功能分支,每个分支旨在开发某个主题或创建一个 Bug 修复。它们用于执行开发功能或修复所需的所有步骤——这是开发者的一个工作单元。这些分支可以在功能或 Bug 修复合并后删除。主题分支通常是私有的,且往往不出现在公共仓库中。
当一个功能准备好进行审查时,它的主题分支通常会被重新基准化,以便于集成,并可选择性地使历史记录更加清晰。然后,整个分支会被发送进行审查。主题分支可以用于git format-patch
和git send-email
。它通常会被作为一个独立的主题分支保存在维护者的工作仓库中(例如,如果作为补丁发送,可以使用git am --3way
),以帮助检查和管理它。
然后,集成管理者(在受信任的仓库工作流中是维护者,或者在中央仓库工作流中是其他开发者)会审查每个主题分支,并决定它是否准备好合并到选定的集成分支。如果准备好了,就会进行合并(可能使用--no-ff
选项)。
主题分支工作流中的毕业分支
主题分支工作流的最简单变体只使用一个集成分支。然而,通常情况下,你会将毕业分支工作流与主题分支结合使用。
图 8.5 – 具有两个毕业分支的主题分支工作流。在主题分支中,有一个足够稳定的分支,可以合并到两个毕业分支
在这个常用的变体中,功能分支通常是从给定稳定分支的最新状态(通常是)或最后一个主要发布版本开始,除非该分支需要某个其他功能。在后者的情况下,该分支需要从其依赖的主题分支中分叉(即从该主题分支创建),例如图 8.5中的feat
分支。Bugfix 主题分支是在维护分支之上创建的。
当该主题被认为完成时,它会被合并到开发工作集成分支(例如,next
)进行测试。例如,在图 8.5中,主题分支 idea
和 iss92
都已合并到 next
,而 feat
还未准备好。冒险的用户可以使用来自不稳定分支的构建来体验该功能,但必须考虑到崩溃和数据丢失的可能性。
在经过这一步检查后,当功能被认为已准备好被包含在下一个版本中时,它会被合并到稳定工作集成分支(例如,master
)。图 8.5 包含了这样一个分支:iss92
。此时,在将它合并到稳定集成分支后,主题分支可以被删除。
使用功能分支可以将专题修订保持在一起,而不会与其他提交混合。主题分支工作流使你可以轻松地整体撤销主题并一起删除所有错误提交(以整体单元移除一系列提交),而不是通过一系列回滚操作。
如果该功能最终被认为还未准备好,它就不会被合并到稳定分支,而只会保留在开发工作分支中。然而,如果我们意识到它未准备好已经太晚,主题已经被合并到稳定分支后,我们就需要回滚合并。这比回滚单个提交稍微复杂一些,但比一个个回滚提交要更简单,同时能确保所有提交都被正确回滚。有关回滚合并的更多问题,请参阅第十章,保持 历史 清晰。
包含 bug 修复的主题分支工作流类似。唯一的不同是,你需要考虑该 bugfix 分支将合并到哪个集成分支。这当然取决于具体情况。也许该 bug 修复仅适用于维护分支,因为它是通过稳定工作分支和开发工作分支中的新功能意外修复的;那么,它就只会合并到该分支。也许该 bug 仅适用于稳定工作和开发工作分支,因为它涉及一个在先前版本中不存在的功能,因此维护分支不会被合并进来。
使用单独的主题分支来修复 bug,而不是直接提交修复,有一个额外的优势:如果事后发现修复涉及的分支比预期的更多,这样可以轻松纠正错误。
例如,如果发现修复也需要应用到维护版本,而不仅仅是当前的工作版本,使用主题分支时,你可以简单地将修复合并到其他分支中。如果直接将修复提交到稳定分支,则不能这样操作。在后一种情况下,你不能使用合并,因为这会破坏维护分支的稳定性。你需要通过挑选提交(cherry-picking)将修复从其原始提交分支复制到维护分支中(有关此操作的详细描述,请参见第九章,合并更改)。但是,这意味着在项目历史中会出现重复的提交,而挑选的提交有时可能与合并操作发生冲突。
主题分支工作流还允许我们检查功能是否相互冲突,并在必要时进行修复。你可以简单地创建一个临时的集成分支,将包含这些功能的主题分支合并到该分支中,以测试它们之间的相互作用。你甚至可以发布这样的集成测试分支(例如命名为proposed-updates
或简称pu
),让其他开发者查看正在进行的工作。然而,你应该在开发者文档中明确声明,不应以该分支为基础进行工作,因为它每次都会从头创建。
主题分支工作流中的发布分支管理
假设我们使用三个毕业(集成)分支:maint
用于上一个版本的维护工作,master
用于稳定工作,next
用于开发。
在创建新版本之前,维护者(发布经理)需要做的第一件事是验证master
是否包含maint
的所有内容——也就是说,所有的错误修复是否也已应用于正在考虑发布的下一个版本。你可以通过检查以下命令是否输出空结果来验证这一点(见第四章,探索 项目历史):
$ git log master..maint
如果上述命令显示有未合并的提交,维护者需要决定如何处理这些提交。如果这些错误修复没有破坏任何内容,可以直接将maint
合并到master
中(因为这相当于将更稳定的分支合并到较不稳定的分支)。
现在,维护者知道master
是maint
的超集后,可以通过为远程master
分支打标签来创建新版本,并将新创建的标签推送到分发点(即公共仓库),可以使用以下命令:
$ git tag -s -m "Foo version 1.4" v1.4 master
$ git push origin v1.4 master
上述命令假设 Foo 项目的公共仓库是由origin
描述的,并且我们采用双位数字版本号来表示主要版本(遵循语义版本控制规范)。
提示
如果维护者想要支持多个旧版本,他们需要复制一个旧的维护分支,因为接下来的步骤是准备它以维护刚刚发布的版本:git branch maint-1.3.x maint。
然后,维护者将maint
更新到新发布版本,推进该分支(注意,第一步确保了maint
是master
的子集):
$ git checkout maint
$ git merge --ff-only master
如果第二个命令失败,意味着maint
分支上的一些提交没有出现在master
中,或者更准确地说,master
不是maint
的严格后代。
因为我们通常会逐个考虑将特性合并到master
中,所以可能有一些主题分支被合并到了next
中,但它们在合并到master
之前被放弃了(或者由于未准备好而未合并)。这意味着,虽然next
分支包含了构成master
分支的主题分支的超集,但master
不一定是next
的祖先。
这就是为什么在发布后推进next
分支比推进maint
分支更复杂的原因。一个解决方案是回退并重建next
分支:
$ git checkout next
$ git reset --hard master
$ git merge ai/topic_in_next_only_1...
你可以通过运行以下命令找到未合并的主题分支,以便合并并重建next
:
$ git branch --no-merged next
在重建next
分支并创建发布版本后,其他开发者需要强制获取next
分支(见下一节),因为如果没有配置为强制获取,它是无法快速前进的:
$ git pull
From git://git.example.com/pub/scm/project
62b553c..c2e8e4b maint -> origin/maint
a9583af..c5b9256 master -> origin/master
+ 990ffec...cc831f2 next -> origin/next (forced update)
注意这里对next
分支的强制更新。
git-flow——一个成功的 Git 分支模型
更高级的主题分支工作流版本是在毕业分支模型的基础上构建的。在某些情况下,可能需要一个更复杂的分支模型,使用更多类型的分支:毕业分支、发布分支、热修复分支和主题分支。其中一种模型是git-flow
。
这个开发模型使用两个主要的长期运行分支master
(稳定工作)和develop
(收集下一个发布的更改)。后者可以用于夜间构建。这两个集成分支有无限的生命周期。
这些分支伴随有支持分支——也就是特性分支、发布分支和热修复分支。
每个新特性都在devel
或master
分支上开发,具体取决于特性的需求。当特性开发完成后,其主题分支会使用--no-ff
选项进行合并(这样就会总是创建一个合并提交,用来描述特性)到devel
进行集成测试。当它们准备好进行下一次发布时,它们会被合并到master
分支。主题分支只在特性开发期间存在,并在合并后被删除(或在放弃时删除)。
发布分支的目的有两个。当创建时,目标是准备新的生产版本发布。这意味着进行最后的清理、应用小的 bug 修复,并准备发布的元数据(例如,版本号、发布名称等)。除了最后一步之外,所有工作都应该通过主题分支完成;元数据可以直接在发布分支上准备。发布分支的使用使我们能够将即将发布版本的质量保证与为下一个大版本开发功能的工作分开。
此类发布分支是在稳定状态反映或接近新发布所规划的理想状态时分叉出来的。每个这样的分支都以发布命名,通常是类似release-1.4
或release-v1.4.x
的名称。通常,你会从这个分支创建几个发布候选版本(将它们标记为v1.4-rc1
等),然后再标记新发布的最终版本(例如,v1.4
)。
发布分支可能只会存在到项目发布它所创建的版本为止,或者它可能会被保留以进行维护工作:为给定版本修复 bug。在后一种情况下,它会替代其他工作流中的maint
分支。
hotfix-1.4.1
或类似名称。如果相应的发布(维护)分支不存在,则从旧的发布标签创建一个热修复分支。这种分支的目的是解决生产版本中发现的关键 bug。在这些分支上进行修复后,会发布小版本(为每个这样的分支)。
Ship/Show/Ask —— 现代分支策略
这种方法尝试在进行预集成代码审查的拉取请求和特性分支与基于主干开发所提供的高频集成和发布就绪主线之间找到一个平衡。
在这个工作流中,每次做出修改时,你需要选择三个选项之一——Ship、Show 或 Ask。选择Ship时,你会将更改直接合并到主线(类似于基于主干的开发)。如果你想要快速集成并确保更改是健康的,这种方法非常有用——例如,当你使用已建立的模式添加一个功能、修复一个简单的 bug,或者更新文档时。
选择Show时,你会打开一个拉取请求,但如果自动检查通过,则立即合并它。这允许在不让特性等待的情况下进行轻松的集成后审查。
最后,选择Ask时,你会遵循主题分支工作流,并在集成前等待代码审查。
修复安全问题
让我们再看另一种情况:如何使用分支来修复 bug,比如安全问题。这需要比普通开发稍微不同的技巧。
正如主题分支工作流部分所解释的那样,虽然可以在最稳定的集成分支上直接创建 bug 修复提交,但通常最好创建一个单独的 bug 修复分支。
你首先从需要应用修复的最旧(最稳定)的集成分支上进行分叉,可能甚至是在所有相关分支的分叉点。你将修复(可能包含多个提交)放到你刚刚创建的分支上。测试完成后,你只需要将 bug 修复分支合并到每个需要修复的集成分支中。
该模型也可以用于在早期阶段解决分支之间的冲突(依赖关系)。假设你正在开发某个尚未完成的新特性(在一个主题分支上)。在编写过程中,你发现开发版本中存在一些 bug,并且知道如何修复它们。你希望在修复后的状态上进行开发,但你意识到其他开发人员也会需要这个 bug 修复。将修复提交到特性分支上会让 bug 修复“被绑架”。直接在集成分支上修复 bug,则有可能忘记将修复合并到正在进行中的特性分支中。
解决方案是:在一个单独的主题分支上创建修复,并将其合并到正在开发的特性主题分支以及测试集成分支中(并可能合并到发布分支)。
提示
你可以使用类似的技术来创建和管理一些客户群体要求的特性。你只需要为每个这样的特性创建一个单独的主题分支,并将其合并到每个客户的单独分支中。
如果涉及安全问题,事情就会变得更加复杂。在严重的安全漏洞情况下,你不仅需要在当前版本中修复它,还需要在所有广泛使用的版本中修复它。
为此,你需要为各种维护轨道创建一个热修复分支(从指定版本分叉)。
$ git checkout -b hotfix-1.9.x v1.9.4
然后,你需要将包含修复的主题分支合并到新创建的热修复分支中,最终创建 bug 修复发布版本:
$ git merge CVE-2014-1234
$ git tag -s -m "Project 1.9.5" v1.9.5
与远程仓库中的分支交互
如我们所见,在单个仓库中拥有多个分支非常有用。简便的分支和合并使得利用高级分支技术(如主题分支)的强大开发模型成为可能。这意味着远程仓库也将包含许多分支。因此,我们必须超越仅仅关注仓库间的交互,正如第六章《使用 Git 进行协同开发》中所描述的那样,我们必须考虑如何与远程仓库中的多个分支进行交互。
我们还需要考虑仓库中的本地分支与远程仓库中的分支(或一般的其他引用)之间的关系。另一个重要的知识点是本地仓库中的标签与其他仓库中的标签之间的关系。
理解仓库之间、这些仓库中的分支之间的交互,以及如何合并变更(如第九章,合并变更所述),对于真正掌握与 Git 的协作至关重要。
上游与下游
在软件开发中,上游指的是指向项目原作者或维护者的方向。如果一个仓库距离我们更近(在仓库与仓库之间的步骤上),那么我们可以说它是上游的,即指向那个被认定为软件源代码的仓库。如果一个变更(补丁或提交)被上游接受,它将立即或在未来的应用发布中包含,并且所有下游的人都将接收到这个变更。
类似地,我们可以说远程仓库中的某个给定分支(维护者仓库)是某个本地分支的上游分支,如果该本地分支中的变更最终将被合并并包含到远程分支中。
配置上游的定义
快速提醒:给定分支的上游仓库和上游分支在该远程仓库中的定义分别由branch.
上游分支通常是在从远程跟踪分支创建一个新分支时设置的,且可以通过git branch --set-upstream-to或git push --set-upstream进行修改。
上游分支不一定是远程仓库中的分支。它也可以是本地分支,尽管我们通常称其为跟踪分支,而不是称其为上游分支。当一个本地分支基于另一个本地分支时,这一功能特别有用,比如当一个主题分支是从另一个主题分支派生出来时(因为它包含了后续工作所需的功能)。
远程跟踪分支与引用规范
在协作项目中,你会与许多仓库进行交互(见第六章,与 Git 的协作开发)。你交互的每一个远程仓库都会有关于分支位置的概念。例如,远程仓库origin
中的master
分支不需要与你克隆仓库中的本地master
分支在同一个位置。换句话说,它们不需要指向修订图中的同一个提交。
远程跟踪分支
为了能够检查集成状态,查看origin
远程仓库中有哪些尚未同步到本地的更改,或查看你在本地仓库中所做的更改且尚未发布的内容,你需要知道远程仓库中分支的位置(嗯,也就是它们上次与这些仓库通信时的位置)。这是远程跟踪分支的任务——这些引用用于追踪远程仓库中分支的位置:
图 8.6 – 远程仓库和本地仓库,包含本地分支和远程跟踪分支。fetch 命令中灰色文本表示默认的隐式参数。
为了跟踪远程仓库中的变化,远程跟踪分支会自动更新;这意味着你无法在其上创建新的本地提交(因为在更新过程中这些提交会丢失)。你需要为此创建一个本地分支。例如,你可以通过运行git checkout <branchname>
来实现,前提是具有给定名称的本地分支尚未存在。此命令会从远程分支的<branchname>
创建一个新的本地分支,并为其设置上游信息。
Refspec – 远程到本地分支的映射规范
如在第四章《探索项目历史》中所述,本地分支位于refs/heads/
命名空间中,而针对某个远程仓库的远程跟踪分支位于refs/remotes/<远程名称>/
命名空间中。但这仅仅是默认设置。fetch
(和push
)命令中的remote.<远程名称>
配置描述了远程仓库中的分支(或其他引用)与本地仓库中的远程跟踪分支(或其他引用)之间的映射关系。
这种映射称为refspec;它可以是显式的,逐一映射分支,也可以是通配符的,描述映射模式。
例如,origin
仓库的默认映射如下所示:
[remote "origin"]
fetch = +refs/heads/*:refs/remotes/origin/*
这表示,例如,远程仓库origin
中的master
分支(完整名称为refs/heads/master
)的内容将存储在本地仓库的远程跟踪分支origin/master
(完整名称为refs/remotes/origin/master
)中。模式开头的加号(+
)告诉 Git 接受那些非快进更新的远程跟踪分支——也就是说,它们不是前一个值的后代。
映射可以通过远程配置中的 fetch 行给出,如前所示,或者也可以作为命令的参数传递(通常只需要指定引用的短名称,而不需要完整的 refspec)。只有在命令行没有 refspec 时,配置才会被考虑。
获取和拉取与推送的区别
向远程仓库发送更改(发布)可以通过git push
完成,而从远程获取更改可以通过git fetch
完成。这些命令会将更改发送到相反的方向。然而,请注意,你的本地仓库有一个非常重要的区别—它有你坐在键盘旁边,这样你可以随时运行其他 Git 命令。
这就是为什么在本地到远程的方向上没有与git pull
等效的命令,因为git pull
结合了获取和集成更改(请参阅下一节)。只是没有人能够解决可能发生的冲突(在进行自动化集成时发生的问题)。
特别地,分支和标签的获取方式与推送方式有所不同。这个问题将在后面详细解释。
Pull – 获取并更新当前分支
许多时候,你可能想将远程仓库特定分支的变更合并到当前分支中。git fetch
会根据给定的参数来执行操作;然后,它会自动将检索到的分支头合并到当前分支。根据配置,它会调用git merge
或git rebase
来完成这个操作。你可以使用--rebase=false
或--rebase
选项来覆盖默认设置,这些设置可以通过pull.rebase
配置选项或每个分支的branch.<branch name>.rebase
配置选项进行全局配置。
请注意,如果远程没有配置(你是通过 URL 执行 pull 操作),Git 会使用FETCH_HEAD
引用来存储已获取分支的提示。
还有一个git request-pull
命令,用于创建有关已发布或待处理更改的信息,适用于基于拉取的工作流—例如,适用于变体的受祝福的仓库工作流。它会创建一个简单文本格式的 GitHub 合并请求,特别适合通过电子邮件发送。
向非裸远程仓库的当前分支推送
通常,你推送的仓库是为了同步而创建的,并且是HEAD
)– 这里没有工作树,因此没有被检出的分支。
然而,有时你可能想要推送到非裸仓库。例如,这种情况可能发生在同步两个仓库,或者作为部署机制(例如,网页或 Web 应用程序的部署)。默认情况下,Git 服务器会拒绝对当前检出的分支进行 ref 更新。这是因为它会使 HEAD
与工作树和暂存区不同步,如果你没有预料到这一点,会非常混淆。然而,你可以通过将 receive.denyCurrentBranch
设置为 warn
或 ignore
(将其从默认的 refuse
更改)来启用这种推送。你甚至可以通过将此配置变量设置为 updateInstead
来使 Git 更新工作目录(必须是干净的,即没有未提交的更改)。
使用 git push
部署的一个替代方案和更灵活的解决方案是配置接收方的适当钩子 – 有关钩子的详细信息,请参见 第十三章,自定义和扩展 Git,以及 第十四章,Git 管理,了解它们在服务器上的使用情况。
默认的 fetch refspec 和 push 模式
我们通常从公开仓库获取所有公开的分支。通常,我们希望更新所有分支的最新状态。这就是为什么 git clone
设置了默认的 git pull <``URL> <branch>
。
在私有工作仓库中,通常会有许多我们不希望发布的分支,或者至少是我们暂时不想发布的分支。在大多数情况下,我们希望发布一个单一的分支:我们正在处理的分支,而且我们知道它已经准备好。然而,如果你是集成经理,你会希望发布一个精心挑选的分支子集,而不是仅仅发布一个单独的分支。
这又是 fetch 和 push 之间的另一个区别。这也是为什么 Git 默认不设置推送 refspec(尽管你可以手动配置),而是依赖于所谓的 push.default
配置变量,该变量仅在运行 git push
命令时使用,当没有明确指定要推送的分支时适用。
使用 git push 从无法拉取的主机同步
当你在两台机器上工作,machineA 和 machineB,每台机器都有自己的工作树时,一个典型的同步方式是相互运行 git pull。然而,在某些情况下,你可能只能在一个方向上建立连接(例如,由于防火墙或间歇性的连接问题)。假设你可以从 machineB 拉取和推送,但无法从 machineA 拉取。
你希望从 machineB 向 machineA 执行推送操作,使得操作的结果与在 machineA 上执行 fetch
几乎没有区别。为此,你需要通过 refspec 指定你希望将本地分支推送到其远程追踪分支:
machineB$ git push *machineA:repo.git *
refs/heads/master:refs/remotes/machineB/master
第一个参数是类似scp语法的 URL,而第二个参数是 refspec。你可以在配置文件中设置它,以防你经常需要执行类似操作。
获取和推送分支与标签
下一节将描述可用的推送模式,以及何时使用它们(适用于哪些协作工作流)。但首先,我们需要了解 Git 在与远程仓库交互时如何处理标签和分支。
因为推送并不是获取的完全反面操作,而且分支和标签的目标不同(分支指向开发线路,而标签指向特定的修订版本),所以它们的行为有细微的不同。
获取分支
获取分支非常简单。在默认配置下,git fetch
命令会下载更改并更新远程跟踪分支(如果可能)。后者是根据远程的获取 refspec 完成的。
当然,这条规则也有例外。其中一个例外是git clone --mirror
会为origin
生成以下配置:
[remote "origin"]
url = https://git.example.com/project
fetch = +refs/*:refs/*
mirror = true
获取的 ref 名称及其指向的对象名称会写入.git/FETCH_HEAD
文件中。例如,git pull
会使用这些信息;如果我们通过 URL 获取而不是通过远程名称获取,这就很有必要。这样做的原因是,当我们通过 URL 获取时,根本没有远程跟踪分支来存储关于被获取分支的信息。
你可以根据需要通过git branch -r -d
删除远程跟踪分支;如果远程仓库中相应的分支不再存在,你也可以通过git remote prune
(或者在现代 Git 中使用git fetch --prune
)逐个删除它们。
获取标签和自动跟踪标签
标签的情况有些不同。虽然我们希望不同的开发者能够在同一个分支上独立工作(例如,master
这样的集成分支),但在不同的仓库中,我们需要所有开发者都有一个特定的标签来始终指向相同的特定修订版本。这就是为什么远程仓库中的分支位置使用一个独立的每远程仓库命名空间refs/remotes/<remote name>/*
存储在远程跟踪分支中,而标签则是镜像的——每个标签都以相同的名称存储在refs/tags/*
命名空间中。
提示
请注意,远程仓库中标签的位置可以通过适当的获取 refspec 进行配置;Git 是如此灵活。一个可能需要这样操作的例子是获取子项目,我们希望将其标签存储在一个独立的命名空间中(有关此问题的更多信息,请参见第十一章,管理子项目)。
这也是为什么默认情况下,在下载更改时,Git 会同时拉取并存储所有指向下载对象的标签。你可以使用--no-tags
选项禁用此行为。此选项可以作为命令行参数设置,或者可以通过remote.<remote
name>.tagopt
设置进行配置。
你还可以使用--tags
选项使 Git 下载所有标签,或者通过为标签添加适当的拉取 refspec 值:
fetch = +refs/tags/*:refs/tags/*
推送分支和标签
推送是不同的。推送分支(通常)由所选的推送模式控制。你将本地分支(通常只有一个当前分支)推送到远程仓库中的特定分支,从本地的refs/heads/
推送到远程的refs/heads/
。通常是同名的分支,但它也可能是配置为上游的不同名称的分支——详细信息稍后会提供。你不需要指定完整的 refspec:使用 ref 名称(例如,分支名称)意味着将其推送到远程仓库中同名的 ref,如果它不存在则会创建它。推送HEAD
意味着将当前分支推送到同名的分支(而不是远程的HEAD
——它通常不存在)。
通常,你通过git push <远程仓库> <标签>
显式推送标签(或者如果有同名的标签和分支,使用tag <标签>
——两者都表示+refs/tags/<标签>:refs/tags/<标签>
refspec)。你也可以使用--tags
推送所有标签(并带有适当的 refspec),并使用--follow-tags
开启自动标签(默认情况下没有开启,像拉取时一样)。
作为 refspec 的一种特殊情况,将“空”源推送到远程的某个 ref 会删除它。git push
的--delete
选项只是使用这种类型的 refspec 的快捷方式。例如,要删除远程仓库中与experimental
匹配的 ref,可以运行以下命令:
$ git push origin :experimental
请注意,远程服务器可能会通过receive.denyDeletes
配置选项或钩子禁止删除 refs。
推送模式及其使用
如果没有指定推送内容的参数,并且没有配置推送的 refspec,git push
的行为由推送模式决定。
有不同的模式可供选择,每种模式适用于不同的协作工作流,详情见第六章,使用 Git 进行协作开发。
“简单”推送模式——默认模式
Git 2.0 及更高版本的默认推送模式是所谓的简单
模式。它的设计理念是最小惊讶:即防止意外将某个私有分支发布出去要比让私有更改意外公开更好。
在这种模式下,你始终将当前本地分支推送到远程仓库中同名的分支。如果你将更改推送到与拉取的同一仓库(集中式工作流),则需要为当前分支设置上游。上游与分支同名。
这意味着在集中式工作流中(推送到你拉取的同一仓库),它像upstream
一样工作,额外的安全性是上游必须与当前(推送的)分支具有相同的名称。在三角形工作流中,当推送到与通常拉取的远程仓库不同的远程仓库时,它像current
一样工作。
这是最安全的选项,非常适合初学者,因此它是默认模式。你可以通过git config
push.default simple
显式启用此模式。
维护者的“匹配”推送模式
在 Git 2.0 版本之前,默认的推送模式是matching
。这种模式对于维护者(也称为集成经理)在受信任的仓库工作流中非常有用。但大多数 Git 用户并不是维护者;这就是默认推送模式被更改为simple
的原因。
维护者将从其他开发者那里获取贡献,无论是通过拉取请求还是通过电子邮件发送的补丁,并将它们放入主题分支中。他们还可以为自己的贡献创建主题分支。然后,被认为适合的主题分支会被合并到适当的集成分支中(例如,maint
、master
和next
)——合并将在第九章中讨论,合并变更。所有这些操作都在维护者的私人仓库中完成。
公共受信仓库(每个人都会从中拉取的仓库,如第六章中所述,与 Git 的协作开发)应只包含长期存在的分支(否则,其他开发者可能会开始基于一个突然消失的分支进行工作)。Git 无法自行判断哪些分支是长期存在的,哪些是短期存在的。
在匹配模式下,Git 将推送所有本地分支,这些分支在远程仓库中有相同名称的对应分支。这意味着只有已经发布的分支才会被推送到远程仓库。要使新分支公开,你需要第一次显式推送它,像这样:
$ git push origin maint-1.4
重要提示
请注意,在此模式下,与其他模式不同,使用git push命令而不提供要推送的分支列表时,可以一次性推送多个分支,并且可能不会推送当前分支。
要全局启用匹配模式,你可以运行以下命令:
$ git config push.default matching
如果你想为特定仓库启用匹配模式,你需要使用由单个冒号组成的特殊 refspec。假设该仓库名为origin
,并且我们希望进行非强制推送,可以使用以下命令:
$ git config remote.origin.push :
当然,你可以通过在命令行中使用以下 refspec 来推送匹配的分支:
$ git push origin :
集中式工作流的“upstream”推送模式
在集中式工作流中,所有开发者都有提交权限,向一个单一的共享中央仓库推送代码。这个共享仓库通常只会有长期存在的集成分支,通常是maint
和master
,有时只有master
。
你最好永远不要直接在master
上工作(也许只有在简单的单提交主题的情况下除外),而是应该从远程跟踪分支中为每个独立功能派生一个主题分支:
$ git checkout -b feature-foo origin/master
在集中式工作流中,集成是分布式的:每个开发者负责合并其主题分支中的更改,并将结果发布到中央仓库的master
分支。你需要更新本地的master
分支,将主题分支合并到其中,然后推送:
$ git checkout master
$ git pull
$ git merge feature-foo
$ git push origin master
另一种解决方案是将主题分支重新基于远程跟踪分支,而不是将其合并。重新基准化后,主题分支应成为远程仓库中master
的祖先,因此我们可以直接将其推送到master
:
$ git checkout feature-foo
$ git pull --rebase
$ git push origin feature-foo:master
在这两种情况下,你都是将本地分支(在基于合并的工作流中是master
,在基于重新基准化的工作流中是功能分支)推送到它在远程仓库中跟踪的分支——在这种情况下是 origin 的master
。
这正是创建upstream
推送模式的原因:
$ git config push.default upstream
该模式使 Git 将当前分支推送到远程仓库中的特定分支——该分支通常是将更改集成到当前分支的目标分支。远程仓库中的这个分支就是上游分支(可以用@{upstream}
引用)。启用此模式后,可以将两个示例中的最后一条命令简化为以下内容:
$ git push
上游信息是通过自动方式(当从远程跟踪分支分支时)或通过--track
选项显式创建的。它存储在配置文件中,可以使用普通的配置工具进行编辑。
或者,稍后可以通过以下方式更改:
$ git branch --set-upstream-to=<branchname>
受托仓库工作流的“当前”推送模式
在受托仓库工作流中,每个开发者都有一个私有仓库和一个公共仓库。在这种模型中,你从受托仓库拉取代码并推送到你的公共仓库。
在这种工作流中,你通过为每个功能创建一个新的主题分支来开始工作:
$ git checkout -b fix-tty-bug origin/master
当功能准备好时,你将其推送到公共仓库,可能先进行重新基准化,以便让维护者更容易合并:
$ git push origin fix-tty-bug
这里假设你已经使用pushurl
配置了三角工作流,推送远程是origin
。如果你使用的是一个单独的远程仓库来作为公共仓库(使用不同的仓库不仅可以用于发布,还可以用于不同机器之间的同步),你需要将此处的origin
替换为公共仓库的正确远程名称。
你可以配置 Git,使得当你处于fix-tty-bug
分支时,只需运行git push
即可。为此,你需要设置 Git 使用current
推送模式,像这样:
$ git config push.default current
此模式将把当前分支推送到接收端同名的分支。
请注意,如果你使用的是单独的远程发布仓库,则需要设置remote.pushDefault
配置选项,以便只使用git push
进行发布。
总结
本章展示了如何有效地使用分支进行开发和协作。你还学到了一些实用的技巧。
首先,我们了解了分支的各种用途,从集成、发布管理、特性并行开发到修复 bug。你学习了不同的分支模式和分支工作流。这些知识应该能帮助你根据项目的需求和团队的偏好,灵活地创建和定制工作流。
你还学会了如何在下载或发布变更时处理每个仓库的多个分支。Git 提供了灵活性来管理远程仓库中分支和其他引用的相关信息,使用所谓的 refspec 来定义本地引用的映射:远程追踪分支、本地分支和标签。通常,获取操作由获取 refspec 管理,但推送则由配置的推送模式管理。各种协作工作流要求对分支发布的处理方式不同;本章描述了哪种推送模式适用于哪种工作流,并解释了原因。
下一章,第九章,合并变更,将解释如何集成来自其他分支和开发者的变更。你将学习合并和变基,以及如何处理 Git 无法自动执行这些操作的情况(如何处理各种类型的合并冲突)。你还将学习如何使用樱桃挑选(cherry-picking)和回滚提交。
问题
回答以下问题以测试你对本章内容的理解:
-
经常集成有哪些优点?
-
主题分支有哪些优点?
-
如何将一个项目网页或其 GitOps 配置与代码一起存储在同一个仓库中,同时保持它们的历史记录和文件分开?
-
如何同步托管在其他计算机上的 Git 仓库的工作目录?
答案
以下是本章问题的答案:
-
更频繁的集成可以带来更容易的集成,因为差异较小,冲突的可能性较低,而且冲突能更早发现。它还使得维护一个随时可以投入生产的主分支变得更加容易,缩短了将特性投入生产环境的时间。
-
使用主题分支可以更容易地回顾和检查创建一个特性所需的步骤,并在必要时移除它。主题分支的使用也与集成前的代码审查要求相得益彰。
-
你可以使用“孤儿”分支技巧——例如,使用 git checkout -- orphan——在一个仓库中拥有两个或更多不相关的历史记录。
-
登录到另一台计算机并使用 git pull;如果无法这样做,可以将更改 git push 到非裸仓库中(配置已检出分支的处理方式)。
进一步阅读
要了解更多本章讨论的主题,请查看以下资源:
-
Martin Fowler,管理源代码分支的模式(2020 年):
martinfowler.com/articles/branching-patterns.html
-
Rouan Wilsenach,Ship / Show / Ask: 一种现代分支策略(2021 年):
martinfowler.com/articles/ship-show-ask.html
-
Vincent Driessen,git-flow - 一种成功的 Git 分支模型(2010 年):
nvie.com/posts/a-successful-git-branching-model/
-
gitworkflows - Git 推荐工作流概览:
git-scm.com/docs/gitworkflows
-
Paul Hammant 和其他人,基于主干的 开发:
trunkbaseddevelopment.com/
-
Junio C Hamano: 提前解决主题分支之间的冲突/依赖关系(2009 年):
gitster.livejournal.com/27297.html
-
Junio C Hamano,各种工作流的乐趣 1 和 2(2013 年):
git-blame.blogspot.com/2013/06/fun-with-various-workflows-1.html
和git-blame.blogspot.com/2013/06/fun-with-various-workflows-2.html
第九章:合并更改
前一章《高级分支技术》介绍了如何有效地使用分支进行协作和开发。
本章将教你如何通过创建合并提交,或通过变基操作重新应用更改,将来自不同并行开发线(即分支)的更改合并在一起。在这里,将解释合并和变基的概念,包括它们之间的区别以及如何使用它们。本章还将解释不同类型的合并冲突,并教你如何避免、检查和解决这些冲突。
在本章中,我们将覆盖以下主题:
-
合并、合并策略和合并驱动程序
-
樱桃挑选和撤销提交
-
应用补丁和补丁系列
-
变基分支并重放其提交
-
基于文件和内容层面的合并算法
-
索引中的三个阶段
-
合并冲突 – 如何检查和解决它们
-
使用 git rerere 重用已记录的 [冲突] 解决方案
-
外部工具 – git-imerge
合并更改的方法
现在,你已经有了来自远程跟踪分支(或一系列电子邮件)的其他人更改,你需要将它们合并,可能还包括你自己的更改。
另外,你在一个独立的主题分支上创建并执行的新功能工作现在已经准备好可以合并到长期开发分支中,并让其他人可以使用。也许你已经创建了一个 bug 修复,并想将其包括到所有长期维护的分支中。简而言之,你想通过整合它们的更改将两条不同的开发线合并在一起。
Git 提供了几种不同的方法来合并更改,以及这些方法的变体。其中一种方法是合并操作,将两个开发线与一个双父提交连接起来。另一种将工作从一个分支复制到另一个分支的方法是通过樱桃挑选(cherry-picking),即在另一条开发线创建一个新的提交,包含相同的更改集(有时需要使用此方法)。另外,你可以重新应用更改,通过变基将一个分支移植到另一个分支之上。我们将现在详细探讨这些方法及其变体,看看它们如何工作,以及何时可以使用它们。
在许多情况下,Git 会自动合并更改;下一节将讨论如果合并失败以及发生合并冲突时该怎么办。
合并分支
git
merge
命令:
$ git switch master
$ git merge bugfix123
在这里,我们首先切换到一个要合并到的分支(在这个示例中是master
),然后指定要合并的分支(这里是bugfix123
)。
无分歧 – 快进和最新状态的情况
假设你需要为别人发现的一个 bug 创建修复。假设你已经遵循了《第八章》中主题分支工作流的建议,[高级分支技术],并创建了一个名为 i18n
的独立 bugfix 分支。
在这种情况下,通常没有真正的分歧,这意味着在维护分支(我们合并到的分支)上没有任何提交,因为已经创建了一个 bugfix 分支。由于这个原因,Git 默认会简单地将当前分支的指针向前移动:
$ git switch maint
Switched to branch 'maint'
$ git merge i18n
Updating f41c546..3a0b90c
Fast-forward
src/random.c | 2 ++
1 file changed, 2 insertions(+)
你可能已经见过 git pull
,当你拉取的分支没有任何变化时。快进合并的情况如 图 9.1 所示。
图 9.1 – 主分支在合并时被快进到 i18n
这种情况对于集中式和对等式工作流非常重要(在 第六章中有描述,使用 Git 进行协作开发),因为正是快进合并使你能够最终将更改推进。
在某些情况下,这并不是你想要的。例如,注意到在 图 9.1 中进行快进合并后,我们失去了 i18n
主题分支的信息。即便当前分支没有任何更改,我们也可以强制创建一个合并提交(将在下一节中描述),方法是使用 git merge --no-ff
命令。默认情况下是 --ff
;如果想要避免创建合并提交,可以使用 --ff-only
(确保仅进行快进合并)。
图 9.2 – 主分支与 i18n 分支是最新的(即,已包含该分支)
还有一种情况,其中一个分支的头(tip)是另一个分支的祖先——即,分支我们试图合并的分支已经包含(合并)到当前分支中的最新场景(图 9.2)。在这种情况下,Git 不需要做任何操作;它只会告知用户:
$ git merge i18n
Already up to date.
创建一个合并提交
当你合并完全成熟的功能分支时,情况通常与前面描述的快进合并有所不同,而不是像上一节中那样合并 bugfix 分支。在功能分支工作流的情况下,功能分支和集成分支的开发通常会发生分歧。
假设你已经决定完成了某个功能的工作(例如,在 i18n
主题分支上添加对国际化的支持),并准备将其包含到主稳定分支中。为了通过合并操作做到这一点,你需要首先检出你想要合并到的分支,然后运行 git merge
命令,指定待合并的分支作为参数:
$ git checkout master
Switched to branch 'master'
$ git merge i18n
Merge made by the 'ort' strategy.
src/random.c |
2 ++
1 file changed, 2 insertions(+)
因为你当前所在的分支(以及合并到的分支)的最新提交既不是你正在合并的分支的直接祖先,也不是其直接后代,Git 必须执行比仅仅移动分支指针更多的操作。在这种情况下,Git 会合并自分支分歧以来的所有变更,并将其存储为当前分支上的合并提交。这个提交有两个父提交,表示它是基于多个提交(多个分支)创建的;第一个父提交是当前分支的前一个提交,第二个父提交是你正在合并的分支的最新提交。
请注意,如果 Git 能够自动完成合并且没有冲突,它会开始提交合并结果。然而,合并在文本层面成功并不一定意味着合并结果是正确的。你可以使用git merge --no-commit
命令要求 Git 不自动提交合并,以便先进行检查,或者在检查合并提交后,如果发现问题,可以使用git commit --amend
命令进行修正(见图 2.4)。
图 9.3 – 在典型合并中使用的三个修订和结果合并提交
Git 创建合并提交的内容(git
merge-base
命令)。
一个非常重要的问题是,Git 通常仅基于三个修订来创建合并提交内容——合并进来的(ours)、合并出去的(theirs)以及共同祖先(merge base)。它不会检查分支的分歧部分发生了什么变化;这使得合并过程非常迅速。然而,正因为如此,Git 也无法得知在被合并的分支上进行的樱桃挑选或回退的更改,这可能会导致一些意外的结果(例如,参见 第十章 中关于回退合并的部分,保持历史清晰)。
合并策略及其选项
在合并信息中,我们可以看到它是通过 ‘ort’ 策略(在旧版本 Git 中称为 recursive)进行的。合并策略 是 Git 用来组合两个或多个开发分支的结果的算法。
你可以选择几种合并策略,通过 git merge
命令的 --strategy
/-s
选项来使用。默认情况下,Git 在合并两个分支时使用 ort 合并策略,在合并多个分支时使用非常简单的 octopus 合并策略。如果默认策略失败,你还可以选择 resolve 合并策略;它速度较快且安全,尽管合并能力较弱。
另外两种合并策略是特殊用途的算法。ours合并策略可以在我们想要放弃合并分支中的更改,但保留它们在目标分支历史中的情况下使用——例如,用于文档目的。此策略仅仅将当前快照(ours版本)重复作为一个合并提交。请注意,这种合并策略,通过--strategy=ours
或-s ours
调用时,不应与默认ort合并策略的ours选项混淆,--strategy=ort --strategy-option=ours
,或者只是-Xours,它们的含义不同。
子树合并策略可以用于将一个独立项目的内容合并到主项目中的子目录(子树)中。它会自动确定子项目的位置。此主题以及子树的概念将在第十一章中详细描述,管理子项目。
默认的ort(表面递归的双胞胎)合并策略及其前身递归合并策略,得名于该策略如何处理多个合并基和交叉合并。在存在多个合并基的情况下(这意味着有多个可以用作三方合并的共同祖先),该策略会从这些祖先创建一个合并树(包括冲突),作为合并基——也就是说,它会递归地合并。当然,这些被合并的共同祖先也可以有多个合并基。
一些策略是可定制的,并且有自己的选项。你可以通过命令行中的-X<option>
(或--strategy-option=<option>
)将选项传递给合并算法,或者通过适当的配置变量进行设置。你将在解决合并冲突部分中了解更多合并选项,在该部分我们将讨论如何解决合并冲突。
提醒——合并驱动程序
第三章**,管理你的 工作树,介绍了 git 属性——其中包括合并驱动程序。这些驱动程序是用户自定义的,处理在发生冲突时合并文件内容,替代了默认的三方文件级合并。相比之下,合并策略处理 DAG 级别的合并(以及树级别——即,合并目录),并且你只能从内置选项中选择。
提醒——签名合并和合并标签
在第六章,使用 Git 的协同开发中,你学到了如何对你的工作进行签名。在使用合并将两条开发线路连接时,你可以合并一个已签名的标签,签名一个合并提交,或者两者都做。签名一个合并提交可以通过在使用git merge
或git commit
命令时使用-S
/ --gpg-sign
选项来完成;如果存在冲突,或者在合并时使用了--no-commit
选项,后者会被使用。
复制和应用变更集
合并操作是将两条开发线(两条分支)合并,包括自它们分歧以来的所有变更。正如在第八章《高级分支技术》中所描述的那样,如果在较不稳定的分支(例如,master
)上有一个提交,你希望它也出现在一个更稳定的分支(例如,maint
)上,那么你不能使用合并操作。你需要创建该提交的副本。像这样的情况应尽量避免(使用主题分支),但它有时会发生,处理这种情况有时是必要的。
有时候,需要应用的变更不是来自仓库(作为要复制的 DAG 中的修订),而是以补丁的形式出现——也就是说,一个统一的 diff 或使用 git format-patch
生成的邮件(包括补丁和提交信息)。
Git 包含 git am
工具,用于处理大规模应用包含提交的补丁。
这两个命令本身都是有用的,但理解这些获取变更的方法也有助于理解 cherry-pick 和 rebase 的工作原理。
Cherry-pick – 创建变更集的副本
你可以使用 cherry-pick
命令创建一个提交(或一系列提交)的副本。给定一系列提交(通常只是一个提交),它会应用每个提交所引入的变更,为每个变更记录一个新的提交。
图 9.4 – 将 C4 提交从 master 分支 cherry-pick 到 maint 分支
图 9.4 中展示了一个 cherry-pick 操作的示例。(注意,这里从C4到C4’的粗虚线箭头表示的是复制操作;它不是引用。)
变更的复制并不意味着原始快照(即项目的状态)在原始位置(图 9.4 中的 C4)和复制位置(图 9.4 中的 C4’)是相同的;后者将包含其他变更,同时缺少一些变更。而且,虽然变更通常是相同的(如 图 9.4 中所示,C3 与 C4 之间的差异和 C7 与 C4’ 之间的 diff 是相同的),它们也可以是不同的——例如,如果部分变更已经出现在早期的提交中。
请注意,默认情况下,Git 不会保存关于 cherry-pick 提交来源的信息。你可以将此信息附加到原始提交信息中,例如 git cherry-pick -x <commit>
。这仅适用于没有冲突的 cherry-pick 操作。记住,这些信息只有在你可以访问被复制的提交时才有用。如果你是从私有分支复制提交,请不要使用此信息,因为其他开发者将无法使用这些信息。
Revert – 撤销提交的影响
有时,即使经过代码审查,仍会发现一些错误的提交需要撤销(可能是某个提交原来是个不太好的想法,或者它包含了错误)。如果提交已经公开,你不能简单地删除它;你需要撤销它的效果。这个问题将在 第十章 保持历史清洁 中详细解释。
这种“撤销提交”可以通过创建一个反向变更的提交来实现,类似于 cherry-pick,但应用的是变更的反向。这可以通过revert
命令完成(见图 9.5)。
图 9.5 – 使用 'git revert master^' 在 'master' 分支上的效果 – 创建一个新的提交,标记为 !C3,撤销 C3 提交中的变更
这个操作的名称可能会让人产生误解。如果你想撤销对整个工作区所做的所有更改,可以使用 git reset
(特别是使用 --hard
选项)。如果你想撤销对单个文件所做的更改,可以使用 git checkout <file>
或 git restore <file>
。这两者在 第三章 管理你的工作区 中有详细解释。git revert
命令会记录一个新提交,撤销之前提交的效果(通常是一个有问题的提交)。
应用一系列的补丁提交
一些协作工作流包括通过电子邮件(或其他通信媒介)交换变更作为补丁。这种工作流通常出现在开源项目中;对于新贡献者或偶尔贡献者来说,创建一封特别制作的电子邮件(例如,使用git format-patch
)并将其发送给维护者或邮件列表,往往比设置一个公共仓库并发送拉取请求更为简单。
你可以通过 git am
命令,从邮箱(采用 mbox
或 maildir
格式;后者只是一个文件系列)应用一系列补丁。如果这些电子邮件(或文件)是从 git format-patch
输出创建的,你可以使用 git am --3way
来使用三方文件合并,如果有冲突的话。解决冲突将在解决 合并冲突 部分中讨论。
你可以找到一些工具,帮助通过发送一系列补丁来使用补丁提交过程——例如,从 GitHub 上的拉取请求(例如,GitGitGadget GitHub 应用,或较老的 submitGit 网页应用,将补丁从 GitHub 的拉取请求提交到 Git 项目的邮件列表)——以及一些工具,用于跟踪发送到邮件列表的网页补丁(例如,patchwork 工具)。
选择性提交和撤销合并
这都没问题,但如果你想要选择性提交或撤销一个合并提交怎么办?这种提交有多个父提交,因此,它们与多个变更相关联。
在这种情况下,你需要告诉 Git 你希望采用哪个更改(在 cherry-pick 的情况下),或者撤销哪个更改(在 revert 的情况下),并使用 -m <parent number>
选项——例如,-m1
。
请注意,撤销合并操作会撤销更改,但不会从项目历史中删除合并记录。有关撤销合并的更多信息,请参见第十章,保持历史清晰部分。
重置分支
除了合并,Git 还支持一种将一个分支的更改集成到另一个分支中的额外方式——即rebase 操作。
像合并一样,它处理自分叉点以来的变化(至少是默认情况下)。然而,虽然合并通过连接两个分支来创建一个新的提交,rebase 则是将一个分支的新提交(即,自分叉以来的提交)重新应用到另一个分支上——具体示例如图 9.6。
图 9.6 – Rebase 操作的效果
使用合并时,你首先切换到要合并的分支,然后使用合并命令选择要合并的分支。使用 rebase 时,则有些不同。首先,你选择一个要重置的分支(即要重新应用的更改),然后使用 rebase 命令选择应用的位置。在这两种情况下,你首先会检出要修改的分支,在该分支上会有一个新的提交或多个提交(合并的情况下是合并提交,rebase 的情况下是提交的重新应用):
$ git switch i18n
Switched to branch 'i18n'
$ git rebase master
Successfully rebased and updated refs/heads/master.
另外,你也可以使用 git rebase master i18n
作为快捷方式。在这种形式下,你可以很容易看到,rebase 操作将 master..i18n
范围的修订(这种表示法在第四章,探索项目历史中有解释)重新应用到 master
上,最后将 i18n
指向重新应用的提交。
请注意,旧版本的提交不会立即消失,至少不会立刻。它们会在一段宽限期内通过 reflog(和 ORIG_HEAD
)访问。这意味着,查看重新应用操作如何改变项目快照并不困难,通过更多的努力,你也能看到变更集本身是如何变化的。
合并与 rebase
我们有这两种集成更改的方法——合并和 rebase。它们有什么不同,优缺点分别是什么?我们可以通过比较图 9.2(创建合并提交部分)和图 9.5(重置分支部分)来一探究竟。
首先,合并(merge)不会改变历史(见 第十章,保持历史清晰)。它会创建并添加一个新的提交(除非是快进合并;此时,它只是前移分支头部),但从分支上可达的提交仍然是可达的。这与 rebase 不同。提交会被重写,旧版本会被遗忘,修订的有向无环图(DAG)也会发生变化。曾经可以到达的提交可能不再可达。这意味着你不应对已发布的分支进行 rebase。
其次,合并是一个一步操作,只需要解决一次合并冲突。而 rebase 操作是多步的;每一步较小(如果你遵循推荐的实践并保持更改较小——见 第十五章,Git 最佳实践),但步骤更多。
与此相关的是,合并结果通常仅基于三个提交,并且它不考虑在逐步集成的任一分支上发生的事情;只有端点是重要的。相反,rebase 会单独重新应用每个提交,因此到达最终结果的过程是很重要的。
第三,历史看起来不同;使用 rebase,你将得到一个简单的线性历史,而使用合并操作则会导致一个复杂的历史,开发线路会分叉和合并。对于 rebase,历史更简单,但你失去了有关更改是在独立分支上开发并且被组合在一起的信息,这是使用合并时能够获得的(至少在使用 --no-ff
时)。Git contrib 工具中甚至有一个 git-resurrect
脚本,它利用合并提交信息中的数据恢复已删除的旧特性分支。
最后一个区别是,由于底层机制的原因,rebase 默认不会保留合并提交。在重新应用时,必须明确使用 --rebase-merges
选项。合并操作不会改变历史,因此合并提交会保持原样。
Rebase 后端
上一节描述了两种复制或应用更改的机制——git cherry-pick
命令和从 git format-patch
到 git am --3way
的管道。它们中的任意一种都可以被 git rebase
用来重新应用提交。
默认情况下使用基于合并的工作流,就像是调用了带有 --merge
选项的 git rebase
。默认的 'ort'
合并策略使得 rebase 能够感知上游端(我们放置回放提交的地方)上的重命名。使用此选项,你还可以选择特定的合并策略并将选项传递给它。
若要切换到基于补丁的策略,请使用 git rebase --apply
。在这种情况下,你可以传递一些选项给执行实际变更回放的 git am
。
这些选项将在稍后讨论冲突时描述。
还有一种交互式变基,它有一组自己的选项。这是第十章《保持历史整洁》中的主要工具之一。它可以用于在每次重放提交后执行测试,检查重放是否正确。
高级变基技巧
你还可以让你的变基操作在目标分支以外的分支上重放,方法是使用--onto <newbase>
。
假设你的featureA
主题分支基于不稳定的开发分支next
,因为它依赖于一些尚未准备好并且尚未出现在稳定分支(master)中的功能。如果featureA
依赖的功能被认为是稳定的,并且已经合并到 master 中,你可能希望将此分支从基于next
改为基于master
。或者,也许你从相关的client
分支开始了server
分支,但你希望更明显地表示它们是独立的。
在第一种情况下,你可以使用git rebase --onto master next featureA
,而在第二种情况下(如图 9.7所示),你可以使用git rebase --onto master server client
。
图 9.7 – 重新基准化分支,将其从一个分支移动到另一个分支
或者,也许你只想变基分支的一部分。你可以使用git rebase --interactive
来做到这一点,但你也可以使用git rebase --onto <new base> <starting point> <branch>
。你甚至可以选择用--root
选项变基整个分支(通常是孤立分支)。在这种情况下,你将重放整个分支,而不仅仅是其中的一个子集。
你还可以使用--keep-base
选项保持基础提交不变,而不是跟随上游。使用--fork-point
选项时,如果可能,Git 会通过 reflog(找出分支的创建位置)找到更合适的共同祖先。
Squash 合并
如果在某个分支上的更改不值得详细保留,而只需要保留它们的结果,你可以使用squash 合并将它们作为一个单独的提交进行整合。如果你要整合的分支充满了临时的、工作中的提交,情况就会是这样。
使用git merge --squash
时,Git 会在工作树和暂存区上产生与真正的合并相同的结果,但不会执行提交(git merge
的--commit
选项与--squash
不兼容)。这种方式下,下一次 git 提交将会创建一个普通提交,而不是合并提交。有关合并类型的对比,请参见图 9.8。
图 9.8 – 普通合并与 squash 合并对于相同分支集的对比
默认情况下,压缩提交的提交信息以 git log master..i18n
开头。但请注意,只有在我们打算丢弃(删除)“合并”分支时,这种技术才应使用。因为 Git 可能在合并之后的开发过程中遇到麻烦,因为修订图并没有表明该提交是合并的结果。
另一种选择是使用交互式变基的 squash
命令。
解决合并冲突
Git 中的合并通常相对简单。由于 Git 存储并可以访问完整的修订图,它可以自动找到分支分歧的地方,并仅合并那些分歧的部分。这在重复合并的情况下也有效,因此你可以通过不断地将更改合并或将其变基到新的更改上,保持长期存在的分支与时俱进。
然而,并非总是可以自动合并更改。有些问题 Git 无法解决,例如在不同的分支上对文件的相同区域进行了不同的更改。这些问题被称为合并冲突。同样,在重新应用更改时,也可能会出现问题,尽管如果有问题,你仍然会遇到合并冲突。
三方合并
与其他一些版本控制系统不同,Git 不会过于智能地处理合并冲突并尝试自动解决它们。Git 的哲学是智能地判断哪些情况下合并可以轻松自动完成(例如,考虑重命名),如果无法自动解决,则不会过于复杂地尝试解决。比起自动创建一个错误的合并,Git 更倾向于中止并要求用户手动解决合并,这样可能不需要一个复杂的算法。
Git 使用三方合并算法来得出合并结果,比较共同的祖先(base)、合并进来的版本(theirs)和合并的目标版本(ours)。这个算法非常简单,至少在树的层级上——也就是说,文件级别的粒度。下表解释了该算法的规则:
祖先(base) | HEAD(ours) | 分支(theirs) | 结果 |
---|---|---|---|
A | A | A | A |
A | A | B | B |
A | B | A | B |
A | B | B | B |
A | B | C | 合并 |
表 9.1 – 三方合并算法的工作原理
如上表所示,树级三方合并的基本规则如下:
-
如果只有一方更改了文件,采用更改后的版本。
-
如果两边有相同的更改,采用更改后的版本。
-
如果一方的更改与另一方不同,则在内容级别发生合并冲突。
如果有多个祖先,或者文件并非所有版本都有,那么情况会复杂一些,但通常只需要知道并理解这些规则就足够了。
如果一方对文件的修改与另一方不同(其中更改的类型很重要——例如,在一个分支上重命名文件并不会与另一个分支上文件内容的更改发生冲突),Git 会在内容层面尝试合并文件,如果定义了合并驱动程序,则使用该驱动程序,否则使用内容级三方合并(对于文本文件)。
三方文件合并会检查更改是否涉及文件的不同部分(即更改的是不同的行,并且这些更改相隔超过三行(上下文大小))。如果这些更改位于文件的不同部分,Git 会自动解决合并(并告诉我们哪些文件已自动合并)。
然而,如果你在合并的两个分支中分别修改了同一文件的同一部分,Git 将无法干净地合并它们:
$ git merge i18n
Auto-merging src/rand.c
CONFLICT (content): Merge conflict in src/rand.c
Automatic merge failed; fix conflicts and then commit the result.
这个问题(合并冲突)将由用户来解决。
检查失败的合并
如果 Git 无法自动解决合并(或者你在 git merge
命令中使用了 --no-commit
选项),它将不会创建合并提交。它会暂停流程,等待你解决冲突。
你可以随时使用 git merge --abort
中止合并过程。
工作树中的冲突标记
如果你想查看合并冲突后仍未合并的文件,可以运行 git status
:
$ git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified:
src/rand.c
任何未解决的冲突将列为未合并。在内容冲突的情况下,Git 会使用标准的冲突标记,并将它们放置在冲突的位置,标出ours和theirs版本的冲突区域。你的文件中将包含如下所示的部分:
<<<<<<< HEAD:src/rand.c
fprintf(stderr, "Usage: %s <number> [<count>]\n", argv[0]);
=======
fprintf(stderr, _("Usage: %s <number> [<count>\n"), argv[0]);
>>>>>>> i18n:src/rand.c
这意味着当前分支(HEAD
)中的ours版本位于该块的顶部,位于 <<<<<<<
和 =======
标记之间,而在要合并的 i18n
分支中的theirs版本(也是来自 src/rand.c
文件)位于底部,位于 >>>>>>>
标记之间。
你需要通过解决合并来替换整个块,可以选择其中一方(并删除其余部分)或者将两个更改结合起来,例如:
fprintf(stderr, _("Usage: %s <number> [<count>]\n"), argv[0]);
为了帮助你避免错误地提交未解决的更改,Git 默认会检查已提交的更改中是否包含类似冲突标记的内容,如果找到,它会拒绝创建合并提交。你可以使用 --no-verify
选项强制跳过此检查。
如果你需要查看共同祖先版本来解决冲突,可以切换到类似 diff3-
或 zdiff3-
的冲突标记,这些标记有一个额外的块,用 ||||||| 分隔。这个新块显示了共同祖先(ours)版本:
<<<<<<< HEAD:src/rand.c
fprintf(stderr, "Usage: %s <number> [<count>]\n", argv[0]);
|||||||
fprintf(stderr, "Usage: %s <number> [<count>\n", argv[0]);
=======
fprintf(stderr, _("Usage: %s <number> [<count>\n"), argv[0]);
>>>>>>> i18n:src/rand.c
你可以通过重新检查文件,使用以下命令逐个文件地替换合并冲突标记:
$ git checkout --conflict=diff3 src/rand.c
如果你更喜欢始终使用这种格式,你可以通过将 merge.conflictStyle
设置为 diff3
或 zdiff3
(而非默认的 merge
)来将其设置为未来合并冲突的默认格式。
索引中的三个阶段
Git 如何跟踪哪些文件已合并,哪些未合并?仅在工作区文件中使用冲突标记是不够的。有时,有些合法的内容看起来像是提交标记(例如,包含合并冲突示例的文件,或者是 AsciiDoc 格式的文件),并且冲突类型比 CONFLICT(content) 更多。比如,当两个版本都重命名了同一个文件,但重命名方式不同,或者一方修改了文件而另一方删除了文件时,Git 如何表示这种情况?
事实证明,这也是提交的暂存区(在此情况下是合并提交)另一个用途,也叫做索引。在冲突的情况下,Git 会在索引下的各个阶段存储所有冲突文件版本;每个阶段都有一个关联的编号。
-
阶段 1 是公共祖先(基准)
-
阶段 2 是来自 HEAD 的合并版本——即当前分支(我们的)
-
阶段 3 来自 MERGE_HEAD,即你正在合并的版本(他们的)
你可以通过低级(底层)命令 git ls-files --unmerged
来查看未合并文件的这些阶段(或者使用 git ls-files --stage
查看所有文件):
$ git ls-files --unmerged
100755 ac51efdc3df4f4fd318d1a02ad05331d8e2c9111 1
src/rand.c
100755 36c06c8752c78d2aaf89571132f3bf7841a7b5c3 2
src/rand.c
100755 e85207e04dfdd50b0a1e9febbc67fd837c44a1cd 3
src/rand.c
你可以使用 :<stage number>:<pathname>
指定符来引用每个版本。例如,如果你想查看 src/rand.c
的公共祖先版本,可以使用以下命令:
$ git show :1:src/rand.c
如果没有冲突,文件会处于索引的阶段 0。
检查差异——合并差异格式
你可以使用 status
命令查找哪些文件尚未合并,冲突标记能很好地显示冲突。但在处理之前,如何只查看冲突?又该如何查看冲突是如何解决的?答案是 git diff
。
有一点需要记住的是,对于合并,即使是在进行中的合并,Git 也会显示所谓的 合并差异 格式。它看起来如下(对于合并中的冲突文件):
$ git diff
diff --cc src/rand.c
index 293c8fc,4b87d29..0000000
--- a/src/rand.c
+++ b/src/rand.c
@@@ -14,16 -14,13 +14,26 @@@ int main(int argc, char *argv[]
return EXIT_FAILURE;
}
++<<<<<<< HEAD:src/rand.c
+fprintf(stderr, "Usage: %s <number> [<count>]\n", argv[0]);
++=======
+ fprintf(stderr, _("Usage: %s <number> [<count>\n"), argv[0]);
++>>>>>>> i18n:src/rand.c
你可以看到与普通统一差异格式(第二章,“使用 Git 开发”)的几个不同之处。首先,它在头部使用 diff --cc
来表示它使用紧凑的合并格式(如果使用 git diff -c
命令,它将使用 diff --combined
)。扩展的头部行,例如 index 293c8fc,4b87d29..0000000
,考虑到有多个源版本。块头部 @@@ -14,16 -14,13 +14,26 @@@
被修改(并且与普通补丁的不同),以防止人们将合并差异应用为统一差异——例如,使用 patch -p1
命令。
diff
命令的每一行前面都有两个或更多字符(在最常见的合并两个分支的情况下是两个字符);第一个字符告诉我们该行在第一个预成像中的状态(我们的版本)与结果的对比,第二个字符告诉我们另一个预成像的状态(他们的版本),以此类推。例如,++
表示该行在任何一个合并版本中都不存在(在这个例子中,您可以在冲突标记所在的行上找到它)。
检查差异对于检查合并冲突的解决情况非常有用。
要将结果(即工作目录的当前状态)与当前分支的版本(即合并的版本)进行比较——即我们的版本——可以使用git diff --ours
。这同样适用于正在合并的版本(他们的)和公共祖先版本(基础)。
我们是如何到达这个结论的——git log --merge
有时候,我们需要更多的上下文来决定选择哪个版本或者如何解决冲突。一个有效的方法是回顾一些历史,回忆为什么这两个开发分支会修改同一区域的代码。
要获取包含在任一分支中的所有分歧提交的完整列表,我们可以使用三点符号语法(在第四章,探索项目历史中学过的内容),并添加--left-right
选项,让 Git 显示给定提交属于哪一侧:
$ git log --oneline --left-right HEAD...MERGE_HEAD
我们可以进一步简化这一过程,并限制输出仅显示那些修改了至少一个冲突文件的提交,例如使用--merge
选项与git log
命令:
$ git log --oneline --left-right --merge
这对于快速提供您理解冲突原因和如何智能解决冲突所需的上下文非常有帮助。
避免合并冲突
虽然 Git 更倾向于明确地在自动合并失败时停止,而不是尝试复杂的合并算法,但仍然有一些工具和选项可以帮助 Git 避免合并冲突。
有用的合并选项
合并分支时可能遇到的问题之一是它们使用了不同的行尾符号规范化或清理/模糊过滤器(参见第三章,管理工作树)。当一个分支添加了这样的配置(例如,修改了一个 git 属性文件),而另一个没有时,可能会发生这种情况。在行尾字符配置发生变化的情况下,您会看到很多虚假的更改,其中行只在行尾字符(EOL)上有所不同。在这两种情况下,解决三方合并时,您可以让 Git 对文件的三个阶段执行虚拟的检出和检查操作。通过将renormalize
选项传递给'ort'
合并策略(git merge -Xrenormalize
),就可以实现这一点。正如名称所示,这将规范化行尾字符,使它们在所有阶段中保持一致。
更改如何定义行尾可能会导致与空格相关的冲突。当查看冲突时很容易看出这种情况,因为一侧的每行都被删除,而另一侧则再次添加,git diff --ignore-whitespace
显示了一个更易管理的冲突(或者甚至是已解决的冲突)。如果您发现合并中有大量空格问题,您可以中止并重新执行,这次使用-Xignore-all-space
,-Xignore-space-change
,-Xignore-space-at-eol
或-Xignore-cr-at-eol
。
请注意,混合其他更改的行的空白更改不会被忽略。
有时,由于不重要的匹配行(例如,来自不同函数的大括号),会发生错误合并。您可以通过选择patience
,histogram
或minimal
的 diff 算法,并使用-Xdiff-algorithm=patience
等方法,使 Git 花费更多时间来最小化差异。
如果问题是误检测的重命名文件,您可以使用-Xfind-renames=<n>
调整重命名阈值。
Rerere – 重用记录的解决方案
rerere(重用记录的解决方案)功能有点隐藏。正如该功能的名称所暗示的那样,它使 Git 记住每个冲突是如何逐块解决的,因此下次 Git 遇到相同的冲突时,它将能够自动解决。但请注意,即使 Git 可以干净地解决冲突(如果表面上是正确的),Git 也会停止解决冲突,并不会自动提交所述的 rerere-based 解决方案。
这样的功能在许多场景中都很有用。一个例子是当您希望长期存在(即长期开发)的分支在周期结束时能够干净地合并,但不希望创建中间合并提交时。在这种情况下,您可以进行试验合并(合并,然后删除合并),将解决冲突的信息保存到 rerere 缓存中。借助这种技术,最终的合并应该很容易,因为大部分内容将从先前记录的解决方案中干净解决。
另一种情况是您可以利用 rerere 缓存的情况是将一堆主题分支合并到可测试的永久分支中。如果分支的集成测试失败,您希望能够回退失败的分支,但不希望丢失解决合并工作的时间。
或者,也许您已经决定更喜欢使用 rebase 而不是 merge。rerere 机制允许我们将合并解决方案转换为 rebase 解决方案。
要启用此功能,只需将rerere.enabled
设置为true
,或创建.git/rr-cache
文件。
处理合并冲突
假设 Git 无法自动干净地合并,并且存在您需要解决才能创建新合并提交的合并冲突。您有哪些选择?
中止合并
首先,让我们了解如何摆脱这种情况。如果您可能没有准备好处理冲突,或者不太了解如何解决冲突,您可以通过git merge --abort
简单地退出您开始的合并。
此命令尝试将状态重置为您开始合并之前的状态。如果您没有从一个干净的状态开始,它可能无法做到这一点。因此,最好在执行合并操作之前,将所有更改暂存起来(您可以使用--autostash
,或merge.autoStash
/rebase.autoStash
配置选项来做到这一点)。
选择我们的版本或他们的版本
有时,在冲突的情况下,选择一个版本就足够了。如果您希望以这种方式解决所有冲突,强制所有部分解决为我们的版本或他们的版本,您可以分别使用-Xours
或-Xtheirs
合并策略选项。请注意,-Xours
(--strategy=ours
(合并策略);后者创建一个合并提交,其中项目状态与我们的版本相同,而不是仅对冲突文件采用我们的版本。
如果您只想对选定的文件进行此操作,您可以再次通过git checkout --ours
或git checkout --theirs
分别检查出我们的版本或他们的版本。请注意,在变基过程中,我们的版本和他们的版本可能会互换。
您可以通过git show :1:file
,git show :2:file
,或git show :3:file
分别查看基础、我们的或他们的版本,正如之前所描述的那样。
可脚本化修复——手动文件重新合并
有些类型的更改是 Git 无法自动处理的,但它们是可以通过脚本修复的。如果我们先转换我们的、他们的或基础版本,合并可以自动完成,或者至少会变得容易得多。更改文件在仓库中如何检查和存储的方式(即,eol 和 clean/smudge 过滤器)以及处理空格更改是内建选项。另一个示例是改变文件的编码或其他可脚本化的更改,如重命名变量,但不带内建支持。
要执行脚本化的合并,首先需要提取这些冲突文件的每个版本副本,可以通过git show
命令和:<stage>:<file>
来完成:
$ git show :1:src/rand.c >src/rand.common.c
$ git show :2:src/rand.c >src/rand.ours.c
$ git show :3:src/rand.c >src/rand.theirs.c
现在,您已经将三个阶段的文件内容放入工作区,您可以单独修复每个版本——例如,使用dos2unix
或iconv
。然后,您可以使用以下命令重新合并文件内容:
$ git merge-file -p \
rand.ours.c rand.common.c rand.theirs.c >rand.c
使用图形合并工具
如果您希望使用图形工具帮助您解决合并冲突,您可以运行git mergetool
,它将启动一个可视化的合并工具,并引导调用的工具解决所有合并冲突。
它提供了广泛的预配置支持,适用于各种图形化合并助手。你可以通过 merge.tool
配置想要使用的工具。如果不这样做,Git 将按照操作系统和桌面环境的顺序尝试所有可能的工具。
你还可以配置自己的工具设置。
标记文件为已解决并完成合并
如前所述,如果文件发生合并冲突,索引中将有三个阶段。要将文件标记为已解决,需要将文件内容放入阶段 0。只需运行 git add <file>
即可完成此操作(运行 git status
将给出此提示)。
当所有冲突都解决后,你只需运行 git commit
来完成合并提交(或者你可以跳过单独标记每个文件为已解决,直接运行 git commit -a
)。合并的默认提交消息总结了我们所合并的内容,包括冲突列表(如果有的话)。你可以通过 --log
选项为单次合并添加已合并分支的简短日志,或者通过 merge.log
配置变量永久设置此项。
解决 rebase 冲突
当应用补丁或补丁系列、挑选提交或重基准化分支时出现问题时,Git 将退回使用三方合并算法。如何解决这些冲突,已在前面章节中描述。
重要提示
请注意,在使用合并策略(默认策略)时,由于技术原因,ours是到目前为止的重新基准化系列——也就是正在集成的分支——而theirs是工作分支(被重新基准化的分支)。
然而,对于这些方法中的某些情况,如 rebase、应用邮箱(git am
)或挑选一系列提交,这些操作是逐步完成的(一个序列操作),还有其他问题——即在这种阶段中发生冲突时该如何处理。
你有三种选择:
-
你可以使用--continue参数来解决冲突并继续操作(或者在git am的情况下,也可以使用--resolved)
-
你可以使用--abort中止整个操作,并将HEAD重置回原始分支。
-
你可以使用--skip来跳过某个修订,可能是因为该提交已经在上游存在,且在重放过程中可以跳过它
git-imerge —— 一个增量合并和重基准化工具
Rebase 和 merge 各有其缺点。使用 merge 时,你需要以“全有或全无”的方式解决一个大的冲突(尽管使用测试合并和 rerere 来保持最新的提议解决方案可能会有所帮助)。几乎没有办法部分保存已完成的合并,也无法对其进行测试;git stash
可以提供帮助,但它可能是一个不完全的解决方案。
相反,Rebase 是逐步完成的,但它并不适合协作;你不应该重基准化已发布的项目历史部分。你可以中断重基准化,但它会将你置于一个奇怪的状态(在一个匿名分支上)。
这就是为什么创建了git imerge
这个第三方工具。它以小步骤逐对展示冲突。它记录所有中间的合并操作,使其可以共享,从而一个人可以开始合并,另一个人可以完成合并。最终的解决方案可以存储为普通合并、普通变基或带历史记录的变基。
总结
本章展示了如何有效地将两条开发线路合并在一起,结合它们自分歧以来所收集的提交记录。
首先,我们了解了几种合并更改的方法——合并、挑选提交和变基。本部分重点解释了这些功能如何在更高层次上工作——即修订的有向无环图(DAG)层面。您了解了合并和变基如何工作,以及它们之间的区别。还展示了一些更有趣的变基用法,例如将一个主题分支从一个长期存在的分支移植到另一个分支。
然后,您了解了如果 Git 无法自动合并更改时该怎么办——即在存在合并冲突的情况下可以采取什么措施。这个过程的关键是理解三方合并算法如何工作,以及在发生冲突时索引区和工作区的变化。您现在知道如何检查失败的合并,检查提议的解决方案,避免冲突,最后解决冲突并标记为已解决。
下一章,保持历史清晰,将解释为什么我们可能希望重写历史以保持清晰(以及这意味着什么)。重写历史的工具之一是交互式变基,它与本章中描述的普通变基操作是亲密的伙伴。您将学习重写提交的各种方法:如何重新排序提交,如何拆分过大的提交,如何将修复提交与它修正的提交合并,如何从历史中移除文件。您将了解如果无法重写历史(理解为什么重写已发布的历史是不好的),但仍然需要进行修正时,可以使用git replace
和git notes
命令来实现。我们还将讨论这些机制的其他应用。
问题
回答以下问题以测试您对本章的理解:
-
使用合并来整合更改有哪些优缺点?
-
使用变基来整合更改有哪些优缺点?
-
如何避免在合并或变基过程中反复解决类似的冲突?
-
如何发现自己是否处于合并或变基的过程中,并提醒自己如何解决冲突或中止操作?
答案
以下是对上述问题的答案:
-
使用合并时,您只需一步完成整合(这既是优点也可能是缺点),并且只需要测试一个提交——合并后的结果。您可以轻松看到分支从何处开始以及在哪里结束。首父视图可以作为集成分支的摘要。
-
使用变基(rebase)时,你是一步步地进行整合(这既可以是缺点,也可以是优点)。每个变基后的提交可能需要测试。最终的历史记录会更简洁、更线性、也更容易理解。使用二分法来查找回归错误,在线性历史记录下应该会更快。
-
你可以使用 rerere 机制,它会自动重新应用记录下来的冲突解决方案。
-
使用git status命令。
进一步阅读
要了解更多本章所涉及的主题,请查看以下资源:
-
Scott Chacon, Ben Straub: Pro Git,第 2 版(2014):
git-scm.com/book/en/v2
:-
第 3.6 章,Git 分支 – 变基
-
第 7.8 章,Git 工具 – 高级合并
-
第 7.9 章,Git 工具 – Rerere
-
-
Julia Evans,git rebase:会出什么问题?(2023):
jvns.ca/blog/2023/11/06/rebasing-what-can-go-wrong-/
-
Julia Evans,git cherry-pick 和 revert 如何使用三方合并(2023):
jvns.ca/blog/2023/11/10/how-cherry-pick-and-revert-work/
-
Junio C Hamano,恶意合并是从哪里来的?(2013):
git-blame.blogspot.com/2013/04/where-do-evil-merges-come-from.html
-
Nick Quaranto,git ready – 保留合并冲突中的任意文件(2009):
gitready.com/advanced/2009/02/25/keep-either-file-in-merge-conflicts.html
-
学会使用电子邮件与 git!:
git-send-email.io/
第十章:保持历史清洁
前一章《合并变更》描述了如何将不同人开发的变更(如在第六章《使用 Git 进行协作开发》中所述)或仅在单独的功能分支中开发的变更(如在第八章《高级分支技术》中所示)合并到一起。技术之一是 rebase,它有助于将待合并的分支调整到更好的状态。然而,如果我们正在重写历史,是否也可以修改已 rebase 的提交,使其更易于审查,从而使功能的开发步骤更清晰?如果重写历史被禁止,是否可以在不重写历史的情况下清理历史?如果我们不能重写项目历史,如何修正错误?
本章将解答所有这些问题。它将解释为什么有时需要保持历史清洁,何时可以以及应该这样做,以及如何做到这一点。你将在这里找到逐步指导,了解如何重新排序、压缩和拆分提交。本章还将描述如何进行大规模历史重写(例如,从其他版本控制系统导入后的清理),以及如果无法重写历史该怎么办:换句话说,使用回退、替换和注释。
要真正理解本章所介绍的一些主题,并真正掌握其使用方法,你需要了解一些 Git 内部结构的基础。这些内容会在本章的开始部分介绍。
本章将涵盖以下主题:
-
Git 仓库的对象模型基础
-
为什么不应该重写已发布的历史,以及如何恢复已重写的历史
-
交互式 rebase:重新排序、压缩、拆分和测试提交
-
大规模脚本化历史重写
-
回退修订、回退合并,以及回退合并后的重新合并
-
不通过替换重写历史来修正历史
-
通过注释向对象添加附加信息
Git 内部结构简介
要真正理解并有效利用本章所描述的至少部分方法,你需要至少了解 Git 内部结构的基础知识。除此之外,你还需要了解 Git 如何存储版本信息。
在描述 Git 内部结构时,创建不同类型的数据以便后续检查会很有帮助。这可以通过 Git 提供的一组低级命令来实现,作为对用户面对的高级命令的补充。这些低级命令操作的是内部表示层,而不是使用友好的抽象。虽然这些命令非常灵活和强大,但可能不够用户友好。
Git 对象
在 第四章,《探索项目历史》中,你了解了 Git 如何将历史表示为有向无环图(DAG)的修订版本,其中每个修订版本都是一个图节点,表示为提交对象。每个提交都有一个 SHA-1 标识符。我们可以使用这个标识符(无论是完整形式还是不明确的简化形式)来引用任何给定版本。
提交对象由修订元数据、指向零个或多个父提交的链接,以及它所代表的修订版本中的项目文件快照组成。修订元数据包括关于谁做了更改、何时做的、更改由谁(即谁将更改提交到仓库)以及何时提交的,当然还有提交信息。
除此之外,在某些情况下,了解 Git 如何在内部表示项目在给定修订版本时的文件快照也是非常有用的。Git 使用树对象来表示目录,使用二进制大对象(blobs)来表示文件的内容。
除了提交、树和 blobs 之外,可能还有标签对象,表示注释和签名标签。
每个对象都是通过其内容的 SHA-1 哈希函数进行标识的,或者更准确地说,是通过对象的类型和大小加上其内容的哈希。这样的基于内容的标识符不需要中央命名服务。正因为如此,每个分布式的相同项目仓库都会使用相同的标识符,我们无需担心名称冲突:
# calculate SHA-1 identifier of blob object with Git
$ printf "foo" | git hash-object -t blob --stdin
19102815663d23f8b75a47e7a01965dcdc96468c
# calculate SHA-1 identifier of blob object by hand
$ printf "blob 3\0foo" | sha1sum
19102815663d23f8b75a47e7a01965dcdc96468c
对象标识符 – 从 SHA-1 到 SHA-256 的过渡
随着时间的推移,SHA-1 哈希函数的缺陷被逐渐发现。因此,Git 将过渡到使用 SHA-256,同时提供兼容性。到本文写作时,Git 默认仍在使用 SHA-1。
我们可以说 Git 仓库是一个基于内容寻址的对象数据库。当然,这并不是全部;还有引用(分支和标签)、各种配置文件以及其他内容。
让我们更详细地描述 Git 对象,从底层开始。我们可以使用低级的git
cat-file
命令来检查对象:
-
Blob:这些对象存储给定修订版本中文件的内容。这样的对象可以使用低级别的git hash-object -w命令创建。请注意,如果不同的修订版本具有相同的文件内容,它只会被存储一次,得益于基于内容的寻址:
$ git cat-file blob HEAD:COPYRIGHT
Copyright (c) 2014 Company
All Rights Reserved
-
树对象:这些对象代表目录。每个树对象是一个按文件名排序的条目列表。每个条目由组合的权限和类型、文件或目录的名称以及与给定路径相关联的对象链接(即 SHA-1 标识符)组成,可能是树对象(表示子目录)、blob(表示文件内容),或者偶尔是提交对象(表示子模块;参见 第十一章,管理子项目)。请注意,如果不同的修订版本具有相同的子目录内容,它将仅存储一次,这是由于基于内容的寻址:
$ git cat-file -p HEAD^{tree}
100644 blob 862aafd...
COPYRIGHT
100644 blob 25c3d1b...
Makefile
100644 blob bdf2c76...
README
040000 tree 7e44d2e...
git update-index command) with git write-tree.
-
提交对象:这些对象代表修订版本。每个提交由一组头信息(键值对数据)组成,包括零个或多个 父 行和恰好一个树行,指向代表仓库内容快照的 树 对象(项目的顶层目录)。你可以使用低级别的 git commit-tree 命令,或者简单地使用 git commit 命令,通过给定的树对象来创建一个提交作为修订快照:
$ git cat-file -p HEAD
tree 752f12f08996b3c0352a189c5eed7cd7b32f42c7
parent cbb91914f7799cc8aed00baf2983449f2d806686
parent bb71a804f9686c4bada861b3fcd3cfb5600d2a47
author Joe Hacker <joe@example.com> 1401584917 +0200
committer Bob Developer <bob@example.com> 1401584917 +0200
Merge remote branch 'origin/multiple'
-
标签对象:这些对象代表注释标签,其中签名标签是一种特殊情况。标签(轻量级和注释标签)为提交(例如 v0.2)或任何对象提供永久名称。标签对象还包含一系列头信息(包括指向被标记对象的链接)和标签消息。你可以通过低级别的 git mktag 命令或简单地使用 git tag 命令来创建标签对象:
$ git cat-file tag v0.2
object 5d2584867fe4e94ab7d211a206bc0bc3804d37a9
type commit
tag v0.2
tagger John Tagger <john@example.com> 1401585007 +0200
random v0.2
内部日期时间格式
Git 内部格式中的作者、提交者和标记者日期是 <unix 时间戳> <时区偏移>。Unix 时间戳(POSIX 时间)是自 Unix 纪元以来的秒数,Unix 纪元是 1970 年 1 月 1 日星期四的 00:00:00 协调世界时 (UTC),不包括闰秒。这个时间表示事件发生的时间。你可以使用 date "%s" 打印 Unix 时间戳,并使用 date --date="@
时区偏移是相对于 UTC 的正或负偏移,以 HHMM(小时,分钟)格式表示。例如,CET(比 UTC 快 2 小时的时区)是 +0200。这可以用来查找事件的本地时间。
这里提到的不同类型的 Git 对象之间的关系如 图 10.1 所示。它代表了一个典型的情况,其中一个标签指向一个提交,且提交共享至少一些文件的相同内容。
图 10.1 – Git 仓库对象模型
一些 Git 命令适用于任何类型的对象。例如,你可以对任何类型的对象进行标记,不仅仅是提交。你可以标记一个 blob,以便将一些无关的数据保存在仓库中,并且在每个克隆中都能使用这些数据。公钥就是这样的数据之一。
本章稍后将介绍的注释和替代项也适用于任何类型的对象。
Plumbing 和 Porcelain Git 命令
Git 是以自下而上的方式开发的。这意味着它的开发从基本模块开始,然后逐步构建。许多面向用户的命令最初是作为 shell 脚本构建的,利用这些基本的低级模块来完成工作。正因如此,我们能够区分这两种类型的 Git 命令。
更为人知的类型是porcelain命令,这是面向用户的高级命令(porcelain一词是对称调用引擎级命令plumbing的一个双关语)。这些命令的输出是面向最终用户的。这意味着它们的输出可以根据需要更具用户友好性。因此,这些命令的输出在不同的 Git 版本中可能会有所不同。用户足够聪明,可以理解当他们看到额外信息、措辞变化或格式变化时发生了什么(例如)。
你在本章中可能编写的脚本,例如用于使用git filter-repo
进行重写的脚本,情况并非如此。在这里,你需要的是不变的输出——至少对于那些多次使用的脚本(作为钩子、.gitattribute
驱动程序和助手)。你通常可以找到一个开关,通常命名为--porcelain
,它确保命令的输出是不可变的。对于其他命令,解决方法是完全指定格式。或者,你可以使用专为脚本编写设计的低级命令:plumbing命令。这些命令通常没有用户友好的默认设置,更不用说“按我的意思做”的特性了。它们的输出也不依赖于 Git 配置;其中大多数命令不能通过 Git 配置文件进行配置。
git(1)
的手册页列出了所有 Git 命令,按porcelain和plumbing分类。plumbing和porcelain命令的区别在第三章中作为一个提示提到,当时我们遇到了第一个没有用户友好的porcelain等效命令的低级plumbing命令。
重写历史
在许多情况下,当你在一个项目中工作时,你可能想要修改你的提交历史。这样做的一个原因可能是为了在提交更改到上游之前更容易进行审查。另一个原因是考虑审阅者的意见,并在下一版本的更改中加以改进。或者,也许你想在使用二分法查找回归问题时拥有清晰的历史,正如在第四章中所描述的,探索 项目历史。
Git 的一大优点是它使得重写和修改历史成为可能,并提供了一整套工具来修订历史并使其保持干净。
关于重写历史的看法
在版本控制系统的用户中,有两种对立的观点。一种观点认为历史是神圣的,应该展示开发的真实历史,包含所有的缺陷。另一种观点认为,在发布之前,应该清理新的历史,使其更具可读性。
需要注意的一个重要问题是,即使我们谈论的是“重写”历史,Git 中的对象(包括提交)仍然是 ORIG_HEAD
。不过,至少它们会一直存在,直到它们在垃圾回收过程中被修剪(即被删除)为未引用和不可达的对象,尽管这种情况只有在 reflog 过期后才会发生。
修改最后一次提交
历史重写的最简单情况是修正分支上的最新提交。
有时你可能会注意到在提交消息中有拼写错误(或者在上一版本中提交了不完整的更改)。如果你还没有推送(发布)你的更改,可以对 git commit
命令使用 --amend
选项。
修改提交的结果如 第二章 中的 图 6 所示,使用 Git 开发。请注意,修改最后一次提交与更改历史中更深层次的某些提交之间没有功能上的区别。在两种情况下,你都在创建一个新提交,旧版本仍然会通过 reflog 被引用。不同之处在于其他提交的处理方式。
在这里,索引(即明确的提交暂存区)再次显示其重要性。例如,如果你只想修复提交消息,并且不想做其他更改,可以使用 git commit --amend
(注意没有使用 -a
或 --all
选项)。即使你已经开始工作在新的提交上,这也能正常工作——至少前提是你没有向索引中添加任何更改。如果你添加了更改,你可以使用 git stash
暂时保存它们,修正最后一次提交的消息,然后再用 git stash pop --index
恢复索引和弹出保存的更改。
另一方面,如果你意识到忘记了一些更改,你可以直接编辑文件并使用 git commit --amend --all
。如果更改交织在一起,你可以使用 git add
或其交互版本(利用来自 第三章,管理你的工作树)来创建你想要的内容,最终使用 git commit --amend
完成。
交互式变基
有时你可能想要编辑历史中的某些提交,或者将提交重新组织成一系列有逻辑顺序的步骤。在 Git 中,你可以使用 git rebase --interactive
这一内置工具来完成此任务。
在这里,我们假设你正在使用单独的主题分支进行功能开发,并且遵循在 第八章 中描述并推荐的主题分支工作流,高级分支技术。我们还假设你是按照一系列逻辑步骤进行工作,而不是通过一个大的提交。
在实现新特性时,你通常不会从一开始就做到完美。你会希望通过一系列小而独立的步骤引入它(参见第十五章,Git 最佳实践),以便更容易进行代码审查、代码审计和二分查找(找出回归错误的原因)。通常,只有在完成工作后,你才会发现如何更好地拆分它。期望在实现新特性时不犯错误也是不现实的。
在提交变更之前(无论是推送到中央仓库、推送到你自己的公共仓库并发送拉取请求,还是使用第六章中描述的其他工作流,Git 协作开发),你通常需要将你的分支更新为项目的最新状态,以便更容易合并。通过将你的变更 rebase 到当前状态并使其保持最新,你将使维护者(集成经理)在接受并合并你的变更时更加轻松。交互式 rebase允许你在做这些工作时清理历史,正如前面所述。
除了在发布之前整理变更外,像交互式 rebase 这样的工具还有其他用途。在开发一个复杂特性时,首次提交并不总是会被上游接受并添加到项目中。通常,补丁审查过程会发现代码或者变更说明中的问题。可能有一些内容缺失(例如,特性可能缺少文档或测试),某些提交需要修正,或者提交的补丁系列(或拉取请求中提交的分支)应该拆分成更小的提交,以便更容易审查。在这种情况下,你也可以使用交互式 rebase(或类似工具)来准备新版本提交,考虑代码检查的结果。
重新排序、删除和修复提交
Rebase,如第九章中所描述,合并变更,是将被 rebase 的一系列提交的变更提取出来,并重新应用到一个新的基础(一个新的提交)上。换句话说,rebase 移动的是变更集,而不是快照。Git 通过打开与这些操作相关的指令文件,在编辑器中启动交互式 rebase。
提示
你可以通过sequence.editor配置变量,单独配置用于编辑 rebase 指令文件的文本编辑器,该配置变量可以被GIT_SEQUENCE_EDITOR环境变量覆盖,而不是使用默认编辑器(例如,用于编辑提交消息的编辑器)。
就像编辑提交的模板一样,指令表格会附带注释,解释你可以对其进行的操作(请注意,如果你使用的是旧版本的 Git,某些交互式 rebase 命令可能在此表格中缺失):
pick 89579c9 first commit in a branch
pick d996b71 second commit in a branch
pick 6c89dee third commit in a branch
# Rebase 89579c9..6c89dee onto b8fffe1 (3 commands)
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
# d, drop = remove commit
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
请注意,空提交将在行尾标记为# empty
。根据你的 Git 版本和配置,指令表格中可能包含更多命令。
如注释所述,指令的顺序是按执行顺序排列的,从顶部的指令开始,创建第一个提交(将新的基础作为其父提交),并在底部结束,复制正在进行 rebase 操作的分支末端的提交。这意味着修订按时间顺序排列,较旧的提交排在前面。这与git log
的输出顺序相反,后者显示最近的提交在前(除非你使用git log --reverse
)。这很容易理解;rebase 重新应用变更集的顺序是按它们被添加到分支中的顺序,而日志操作则显示按可达性顺序排列的提交。
指令表格的每一行由三个元素组成,元素之间用空格分隔:
-
首先是一个单词的命令。默认情况下,交互式 rebase 以pick开始。每个命令都有一个单字母的快捷方式,你可以用它代替长格式,如注释中所示(例如,你可以用p代替pick)。
-
接下来,是与命令一起使用的提交的唯一缩短版 SHA-1 标识符。严格来说,它是 rebase 过程开始之前,正在进行 rebase 的提交的标识符。这个缩短的 SHA-1 标识符用于选择适当的提交(例如,在交互式 rebase 指令表格中重新排序行,这实际上意味着重新排序提交)。
-
最后是提交的描述(主题)。它取自提交信息的第一行。更具体来说,它是提交信息的第一段,去除了换行符,其中段落被定义为一组后续的文本行,段落之间至少用一个空行分隔——即两个或更多的换行符。这也是为什么提交信息的第一行应该是简短的更改描述的原因之一(参见 第十五章,Git 最佳实践)。这个描述是为了帮助你决定如何处理该提交;Git 使用其 SHA-1 标识符并忽略其余部分。
使用交互式 rebase 重排提交就像在指令表中重排行一样简单。但需要注意的是,如果变更之间不是独立的,即使重排后没有合并冲突,仍可能需要解决冲突。在这种情况下,按照 Git 的提示,你需要解决冲突,标记冲突已解决(例如使用git add
),然后运行git rebase --continue
。Git 会记住你正在进行交互式 rebase,因此你无需重复使用--interactive
选项。
另一种处理冲突的方法是跳过某个提交,而不是解决冲突,可以通过运行git rebase --skip
来实现。默认情况下,rebase 会删除上游已经存在的更改;如果 rebase 没有正确检测到待处理提交已存在于我们正在合并的分支中,可以使用此命令。换句话说,如果你知道解决冲突的正确方式是空的变更集,就跳过该提交。
提示
你也可以在 Git 因某种原因(包括指令表中的错误,例如将squash命令用于第一个提交)停止时,随时通过git rebase --edit-todo 命令让 Git 重新显示指令表。编辑完成后,你可以继续执行 rebase。
使用drop
命令。你可以通过此命令删除失败的实验,或通过删除你知道已经存在于上游(尽管可能形式不同)的变更集来简化 rebase。不过需要注意的是,完全删除指令表会导致 rebase 中止。
在指令表中,将目标提交前的pick
命令改为edit
(或简写为e
)。这将使 rebase 在该提交处停止,也就是在重新应用更改时停下,类似于发生冲突的情况。具体来说,交互式 rebase 会应用该提交,并将其设为HEAD
提交,然后停止流程,将控制权交给用户。此时,你可以像修改当前提交一样,通过git commit --amend
来修改该提交,具体操作见修改上一个提交。修改完成后,按照 Git 输出的指令,运行git rebase --continue
继续操作。
提示
一个合适的 Git 命令行提示符,例如 Git 源代码中contrib/
目录下的提示符,能够告诉你是否正在进行 rebase(参见第十三章,自定义和扩展 Git)。如果你没有使用这样的提示符,你可以随时通过git status来查看当前状态,在这种情况下它会提示有 rebase 操作在进行。你也可以在这里找到接下来可以执行的指令。
或者,你也可以随时使用git rebase --abort
命令回到开始 rebase 前的状态。
如果你只想更改提交消息(例如,修正拼写错误或包含额外信息),你可以跳过运行 git commit --amend
然后 git rebase --continue
的需要,而是使用 reword
(或 r
)代替 edit
。Git 将自动打开编辑器以进行提交消息的编辑。保存更改并退出编辑器将提交更改,修正提交并继续重新基础。
合并提交
有时,您可能需要将两个或多个提交合并为一个。也许您决定不再将更改分开,而是将它们一起使用会更合理。
使用交互式 rebase,您可以根据需要重新排序这些提交,以便它们相互靠近。然后,保留第一个要合并的提交的 pick
命令(或将其更改为 edit
命令)。对于其余的提交,请用 squash
或 fixup
命令替换 pick
命令。Git 将累积更改并创建包含所有更改的提交。合并提交的建议提交消息是第一个提交的提交消息,并附加具有 squash
命令的提交的消息。具有 fixup
命令的提交消息将被省略。这意味着 squash
命令用于合并更改,而 fixup
命令用于添加修复。如果提交具有不同的作者,则折叠提交将归因于第一个提交的作者。提交者将是执行 rebase 操作的您。
假设您注意到忘记将某些更改的部分添加到提交中。也许缺少测试(或只是负面测试)或文档。提交已经在过去,因此您不能简单地通过修改来添加。您可以使用交互式 rebase 或补丁管理界面进行修复,但通常更有效的方法是创建具有遗漏更改的提交,然后稍后再压缩它。
同样地,当你注意到一段时间前创建的提交存在 bug 时,不要立即尝试编辑它,而是可以创建一个包含 bug 修复的 fixup
提交,稍后再压缩它。
如果使用此技术,则在注意到需要进行更改或修复 bug 并创建适当提交之间可能会有一些延迟。此间隔包括重新基础操作所需的时间。
那么如何标记正在创建用于压缩或修复的提交呢?如果你的提交消息以魔术字符串 squash! ...
或 fixup! ...
开头,则会分别在描述(提交消息的第一行,有时称为 rebase -i
的部分)之前。您可以通过 --autosquash
选项在个别基础上请求此操作,或者您可以通过默认使用 rebase.autoSquash
配置变量启用此行为。要创建适当的“魔术”提交消息,您可以在创建要压缩到的提交或 bug 修复提交时使用 git commit --squash/--fixup
。
拆分提交
有时,您可能希望将一个提交拆分成两个或更多个提交,将其分割成多个部分。您可能已经注意到,某个提交过于庞大,可能是因为它尝试做了太多事情,应该将其拆分成更小的部分。或者,您可能决定将某个更改集的一部分从一个提交移到另一个提交,提取成单独的提交是实现这一目标的第一步。
Git 并没有提供一个一键式的内建命令来执行此操作。然而,借助交互式变基的巧妙使用,拆分提交是可能的。
要拆分一个给定的提交,首先使用edit
操作标记它。如前所述,Git 将在指定的提交处停下并将控制权交还给用户。在拆分提交的情况下,当通过git rebase --continue
将控制权交还给 Git 时,您希望用两个提交代替原来的一个提交。
拆分提交的问题与工作目录中不同更改混合在一起的问题相似,这在第二章《使用 Git 开发》(关于交互式提交的部分)和第三章《管理工作树》一章中都有提到。不同之处在于,在使用交互式变基拆分提交时,当变基停下来等待编辑时,提交已经由被变基的分支创建并复制。这可以通过git reset HEAD^
简单修复;如第三章《管理工作树》中所述,该命令将保持工作区处于(交织的)拆分前提交的状态,同时将HEAD
指针和提交的暂存区移回到该修订之前的状态。然后,您可以通过在暂存区中组合中间步骤,交互式地将您希望包含在第一个提交中的更改添加到索引中。接下来,您应该检查索引中是否包含所需内容,然后使用git commit
(不带-a
或--all
选项)从中创建提交。根据需要重复这最后两步。
对于系列中的最后一个提交(如果您将提交拆分为两个,那么它就是第二个提交),您可以做两件事中的任何一件。第一种选择是将所有内容添加到索引,使工作副本干净,然后从索引中创建提交。另一种选择是从工作区的状态中创建提交(git commit --all
)。如果您希望保留或从原始提交的提交信息开始,您可以在创建提交时使用--reuse-message=<commit>
或--reedit-message=<commit>
选项。我认为最简单的拆分提交命名方式是使用 reflog——它将是git reflog
输出中reset: moving to HEAD^
之前的HEAD@{n}
条目。
与其在暂存区(索引)中从要拆分的提交的父提交开始构建提交并添加更改(可能是交互式的),你可以直接从最终状态——要拆分的提交开始——并删除为第二步准备的更改。例如,可以使用 git reset --patch HEAD^
来完成。坦率地说,你可以使用来自 第三章 管理你的工作树 的任何技术组合。我发现图形化提交工具,如 git gui
,在这方面非常有用(你可以在 第十三章 定制和扩展 Git 中了解图形化提交工具,包括一些示例)。
如果你不完全确定在索引中创建的中间修订是连贯的(它们能编译,能通过测试套件,等等),你应该使用 git stash save --keep-index
将尚未提交的更改暂存起来,将工作区恢复到索引中所组成的状态。然后你可以测试这些更改,并在必要时修改暂存区。
另外,你可以从索引中创建提交,并使用普通的 git stash
命令在每个提交后保存工作区的状态。然后你可以测试并在必要时修改创建的中间提交。在这两种情况下,在处理拆分中的新提交之前,你需要使用 git stash pop
恢复更改。
测试每个重新基准的提交
一个好的软件开发实践是,在提交每个更改之前进行测试。然而,这一实践并非总是得到遵循。假设你忘记测试某个提交,或者因为更改看起来微不足道,且你时间紧迫而跳过了它。交互式 rebase 允许你使用 exec
(或 x
)操作。它在 rebase 提交的步骤之间运行。exec
命令本身的格式与本章前面描述的命令不同:它不是提供提交的 SHA-1 和摘要,而是提供要运行的命令。
exec
命令在 shell 中启动提供的命令(由该行的其余部分给出):使用 SHELL
环境变量指定的 shell,或者如果未设置 SHELL
,则使用默认 shell。这意味着你可以使用 shell 功能。对于 POSIX shell,这意味着可以使用 cd
切换目录,使用 >
重定向命令输出,使用 ;
和 &&
来顺序执行多个命令,等等。重要的是要记住,执行的命令是从工作树的根目录运行的,而不是从当前目录运行的(即,不是从开始交互式 rebase 时所在的子目录)。
如果你严格要求不发布未经测试的更改,你可能会担心已经重新基准的提交会在新更改的基础上不通过测试,尽管原始提交是通过的。然而,你可以让交互式 rebase 在每个提交上使用 --exec
选项进行测试。以下是一个例子:
$ git rebase --interactive --exec "make test"
这将修改起始指令表,在每个条目后插入exec make test
:
pick 89579c9 first commit in a branch
exec make test
pick d996b71 second commit in a branch
exec make test
pick 6c89dee third commit in a branch
exec make test
外部工具 – 补丁管理接口
你可能更倾向于在发现 bug 的时候立即修复旧的提交,而不是等到分支重新基础化的时候再修复。后者通常是在分支发送审查(以发布它)之前进行的。这可能是在意识到需要编辑过去的提交后相当长的一段时间。
Git 本身并不容易直接修复已发现的 bug,至少使用内置工具并不容易。然而,你可以找到第三方外部工具,它们在 Git 上实现了补丁管理接口。此类工具的例子包括Stacked Git(StGit)和Git Quilt(Guilt)——后者虽然不再维护,但仍可使用。
这些工具提供了与Quilt类似的功能(即将补丁推送到栈中或从栈中弹出)。使用这些工具,你可以在类似 Quilt 的栈中拥有一组正在进行的“浮动”补丁。你还可以有以正确 Git 提交形式存在的已接受更改。你可以在补丁和提交之间相互转换,移动和编辑补丁,移动和编辑提交(这通过将提交及其子提交转化为补丁、重新排序或编辑补丁,然后再将补丁转回提交来完成),合并补丁等。
然而,这仍然是一个需要安装的额外工具,一组需要学习的额外操作(即使它们可以让你的工作更轻松),以及来自 Git 与该工具之间边界的额外复杂性。如今,交互式 rebase 足够强大,并且通过 autosquash,Git 上再加一层工具的需求已大大减少。
使用 Git filter-repo 重写项目历史
在某些使用场景中,你可能需要使用比交互式 rebase 更强大的工具来重写和清理历史。你可能想要某种工具,在给定指定的重写算法时,能够非交互地重写完整历史。这样的情况适合使用git
filter-repo
命令。
这是一个外部项目,需要在 Git 之外单独安装。然而,由于它是一个单文件的 Python 脚本,安装它在大多数情况下是非常简单的。Git 项目现在建议使用 git filter-repo
项目来替代内置的 git filter-branch
命令(后者已经被弃用)。
此命令的调用约定与交互式 rebase 的约定有所不同。默认情况下,它作用于项目的整个历史,改变完整的修订图,尽管你可以使用--refs
选项将操作限制在选择的分支或分支集上。
该命令通过对每个要重写的修订应用自定义过滤器来重写 Git 修订历史。这是另一个区别:rebase 通过重新应用变更集来工作,而filter-branch
则与快照一起工作。其后果之一是,对于git filter-repo
而言,合并仅仅是一种提交对象,而 rebase 会删除合并并将提交排成一行,除非你使用--rebase-merges
选项。
当然,使用git filter-repo
时,你通过适当的选项来描述如何进行重写,而不是进行交互式的重写。这意味着操作的速度不是由用户交互的速度限制,而是由 I/O 速度决定。
安全检查
由于git filter-repo通常用于大量重写,并且会对项目的历史进行不可逆的修改,因此它需要从新的克隆仓库运行。这意味着用户始终会有一个以单独克隆的形式作为良好的备份。如果出现任何问题,你可以简单地删除克隆并重新开始。
你可以使用--force选项让git filter-repo忽略新克隆的检查。
无过滤器运行 filter-repo
如果未指定过滤器,filter-repo
会报错,除非你指定--force
。在这种情况下,提交将重新提交但没有任何更改。通常这种用法不会产生效果,但它被允许用于将来弥补一些 Git 的 bug。
这意味着git filter-repo --force
在没有其他选项的情况下,可以用来使通过替换引用实现的效果永久生效。通过这种方式,你可以使用以下技巧:在指定的提交上使用git replace
来修改历史,确保它看起来正确,然后将该修改永久化。这是做提交父级重写的最简单方法。
重要说明
git filter-repo命令尊重替换(位于refs/replace/命名空间中的引用)。替换是一种影响历史(或者更准确地说,是影响其视图)而不重写任何修订的技术。它将在稍后的替换 机制部分中进行说明。
可用于filter-repo
的过滤器类型
有一组广泛的不同过滤选项,用于指定如何重写历史。你可以指定多个选项,它们将按呈现的顺序应用。
你可以多次运行该命令,以实现你期望的结果。--analyze
选项可用于分析仓库历史,创建一个报告目录,其中(除了其他内容)提到重命名并列出对象大小。这些信息在选择如何过滤仓库和验证更改时可能很有用。
git filter-repo
命令支持以下类型的过滤器:
-
基于路径的过滤,指定要选择或排除的路径。请注意,重命名不会被跟踪,因此你可能需要同时指定路径的旧名称和新名称。
-
重命名路径,可以与路径过滤结合使用。
-
内容编辑过滤器,涉及替换项目文件中的文本,删除大型 Blob(文件)或删除指定的 Blob(文件内容的版本)。
-
使用.mailmap或类似的 mailmap 文件,过滤提交信息,特别是支持过滤作者姓名和邮箱。
-
重命名标签,涉及将一个标签前缀替换为另一个标签前缀。
为了灵活性,filter-repo
还允许你使用 Python 函数来进一步过滤所有更改,使用自定义 API,并通过各种--<something>-callback
选项进行操作,例如(例如)--filename-callback
或--commit-callback
。
你还可以配置如何重写和修剪提交。例如,你可以决定是否将提交信息重新编码为 UTF-8,或者是否修剪那些已变为空的提交(即没有对项目带来任何更改的提交)。
使用 filter-repo 的示例
假设你错误地提交了一个文件到仓库,并且你想执行git add .
,但你不小心包括了一个未被正确忽略的生成文件(例如可能是一个大型二进制文件)。或者,可能你没有该文件的分发权,需要将其移除以避免侵犯版权。使用git rm --cached
只会将其从未来的提交中移除。你还可以通过修订提交(如本章前面所述)轻松地从最新版本中移除该文件。
假设该文件名为passwords.txt
。要将其从整个历史中删除,你可以使用以下命令:
$ git filter-repo --path 'passwords.txt' --invert-paths
如果你想删除任何目录中的所有.DS_Store
文件(而不仅仅是项目的顶级目录),你可以使用以下两种命令之一。这里是第一种选择:
$ git filter-repo --invert-paths --path '.DS_Store' --use-base-name
你还可以使用以下选项:
$ git filter-repo --invert-paths --path-glob '*/.DS_Store' --path '.DS_Store'
如果未指定替换内容,你可以使用filter-repo
来***REMOVED***
。例如,要移除意外提交的 GitHub 个人访问令牌,你可以使用指定表达式列表的文件,每行一个。假设你创建了一个名为expressions.txt
的文件,内容如下:
regex:ghp_ua[A-Za-z0-9]{20,}==><access_token>
然后你需要运行以下命令:
$ git filter-repo --replace-text expressions.txt
你可以使用filter-repo
永久删除v1.0
标签,使用以下命令:
$ git replace --graft v1.0^{commit}
$ git filter-repo --force
另一个常见的情况是你在开始工作之前执行了git config
来设置你的姓名和邮箱,但 Git 猜测错误(如果它不能猜出,会在允许提交之前询问)。也许你想打开以前是专有闭源程序的源代码,并且需要将你的公司内部邮箱更改为个人地址。假设你希望这个更改是永久性的,而不是依赖于.mailmap
文件。
无论如何,你都可以使用filter-repo
修改整个历史中的电子邮件地址:
$ git filter-repo --use-mailmap
如果你正在开源一个项目,你可能还需要为数字来源证书(见 第十五章,Git 最佳实践)添加 Signed-off-by:
行,并在提交消息中添加这个结尾(如果还没有添加的话):
$ git filter-repo --message-callback '
if b"Signed-off-by:" not in message:
message += "\n\nSigned-off-by: Joe Hacker <joe@h.com>"
return message
假设你注意到一个子目录的名称存在拼写错误,例如 inlude/
而不是 include/
。只需运行以下命令即可修复:
$ git filter-repo --path-rename inlude/:include/
通常,较大项目中的某一部分会开始独立发展。在这种情况下,将这一部分与其最初所在的项目分离是有意义的。我们希望提取该部分的历史记录,使其子目录成为新的根目录。要以这种方式重写历史并丢弃其他历史记录,可以运行以下命令:
$ git filter-repo --subdirectory-filter lib/foo
然而,或许一个更好的解决方案是使用一个专业的第三方工具,即 git subtree
。这个工具(以及它的替代品)将在 第十一章,管理子项目 中讨论。
用于大规模历史重写的外部工具
git filter-repo
项目并不是重写项目历史的大规模解决方案的唯一选择。还有其他工具,它们更具专业性,可能包括大量预定义的清理操作,或者提供一定程度的交互性,能够进行脚本化重写(具有读取–评估–打印循环(REPL),类似于某些解释型编程语言中的交互式命令行)。
使用 BFG Repo Cleaner 从历史记录中移除文件
BFG Repo Cleaner 是 git filter-repo
的一个专业替代工具。它专门用于清理 Git 仓库历史中的不良数据,通过删除文件和目录并替换文件中的文本(例如,意外提交的密码或 API 密钥及其占位符)。它可以使用多核并行处理——BFG 是用 Scala 编写的,使用 JGit 作为 Git 实现。
BFG 提供了一组专门用于删除文件并修复它们的命令行参数,如 --delete-files
或 --replace-text
,一种“查询语言”。它缺乏其他工具的灵活性。如今,filter-repo
可以做它所能做的所有事情。甚至还有 filter-repo
。
需要记住的一个问题是,BFG 假设你已经修复了当前提交的内容。
使用 reposurgeon 编辑仓库历史
git fast-import
格式是当前源代码控制系统中常见的导入和导出格式,因为它是版本控制中立的。之前在本章中描述的 git filter-repo
工具也是基于处理 fast-import 流的。
它可以用于历史重写,包括编辑过去的提交和元数据、删除提交、合并(合并)和拆分提交、从历史中移除文件和目录、以及拆分和合并历史。
reposurgeon
相对于git filter-repo
的优势在于,它可以以两种模式运行:一种是交互式解释器,类似于历史调试器或编辑器,带有命令历史记录和标签补全;另一种是批处理模式,可以执行作为参数给出的命令。这使得用户能够交互式地检查历史记录并测试更改,然后对所有修订批量运行它们。
缺点在于需要安装并学习使用一个单独的工具。
重写已发布历史的危险
然而,有一个非常重要的原则需要知道:你永远不应该(或者至少没有非常非常充分的理由的话)重写已发布的历史,尤其是那些已推送到公共仓库或以其他方式公开的提交。你可以做的是更改修订图中的私人部分。
这条规则背后的原因是,重写已发布历史可能会给下游开发人员带来麻烦,如果他们基于被重写的修订版本进行了更改。
这意味着,重写和重建那些明确声明并文档化为处于变动中的公共分支是安全的,例如,作为展示工作进展的一种方式(如'``proposed-updates``'
类型的分支,用于测试合并所有特性分支——请参阅第八章**,《高级分支技术》中的可见性不依赖于集成和渐进稳定分支部分)。另一个安全重写公共分支的可能方式是在项目生命周期的特定阶段进行,即在创建新发布版本之后;同样,这需要被文档化。
上游重写的后果
现在,你将在一个简单的示例中看到重写已发布历史(例如,变基)带来的危险,以及它是如何造成麻烦的。假设有两个感兴趣的公共分支:master
和subsys
,后者是基于(从)前者创建的。还假设下游开发人员(可能是你)创建了一个新的topic
分支,基于subsys
分支用于自己的工作,但尚未发布;它仅存在于他们的本地仓库中。这种情况如图 10.2所示(虚线下方的修订版本,用较深的颜色表示,只存在于下游开发人员的本地仓库中)。
图 10.2 – 下游开发人员在重写已发布历史之前的本地仓库状态,带有放置在主题分支上的新本地工作
然后,上游开发者将subsys
分支重写为从master
分支的当前(最顶端)修订开始。这个操作叫做重基,已在《第九章》《合并更改》一章中描述(上一章)。假设在重写过程中,某个提交被丢弃了;也许相同的更改已经出现在master
中并被跳过,或者它因其他原因被丢弃,亦或是它被交互式重基合并到了前一个提交中。现在公共仓库的状态如下:
图 10.3 – 重写后公共上游仓库的状态,突出显示了重基分支的旧基础,以及新的基础和重写的提交(重基后)
请注意,在默认配置下,Git 会拒绝推送重写历史(它会拒绝非快进推送)。你需要强制推送。
问题在于合并基于重写前修订版本的更改,例如本例中的topic
分支。
图 10.4 – 将基于重写前修订的更改合并到重写后分支后的情况
请注意,合并操作带来了重写前版本的修订,包括在重基过程中丢弃的提交。
如果下游开发者和上游开发者都没有注意到已发布的历史被重写,并且其中一个开发者将来自topic
分支的更改合并到例如基于subsys
分支的分支中,合并将导致重复的提交。如在图 10.3中的示例所示,经过这样的合并(此处标记为M13),我们会看到topic
分支带来的C3、C4 和 C5 的重写前提交,以及C3’ 和 C5’ 的重写后提交(见图 10.4)。注意,在重写中删除的C4 提交已经回来了——它可能是一个安全漏洞!
从上游历史重写中恢复
然而,如果上游已经重写了已发布的历史记录(例如,进行了重基操作),我们该怎么办?我们能避免将已废弃的提交带回并合并重写版本的重复或接近重复的修订吗?毕竟,如果重写已经发布,改变它将是另一次重写。
解决方案是将你的工作重基到与上游的新版本匹配,从重写前的上游修订移动到重写后的修订。
图 10.5 – 下游主题分支重基后的情况
在我们的示例中,这意味着将topic
分支重新基于subsys
的一个新(重写后的)版本,如图 10.5所示。
提示
你可能没有subsys
分支的本地副本;在这种情况下,可以用相应的远程跟踪分支替换subsys
,例如origin/subsys
。
根据topic
分支是否公开,这可能意味着你现在打破了对下游不修改已发布历史的承诺。恢复上游的重写可能会导致一连串的变基,沿着下游的依赖库(dependent repositories)传播。
简单的情况是,subsys
只是被变基,而且更改保持不变(这意味着topic
在它的上游,即subsys
之上,如下所示):
$ git rebase subsys topic
如果你当前就在topic
分支上(即topic
是当前分支),那么topic
部分是没有必要的。这会对所有内容进行变基:包括subsys
的旧版本和你在topic
中的提交。然而,这种方案依赖于git rebase
会跳过重复的提交(删除C3、C4和C5,仅保留C10'和C12')。假设更复杂的情况可能更好且错误更少。
困难的情况是,当重写subsys
涉及到一些变更,并且不是纯粹的变基,或者使用了交互式变基。在这种情况下,最好明确仅移动你的更改,即subsys@{1}..topic
(假设subsys@{1}
条目来自重写前的subsys
reflog),并声明它们被移到新的subsys
之上。这可以通过--onto
选项来实现:
$ git rebase --onto subsys subsys@{1} topic
你可以使用git rebase
命令的--fork-point
选项,让 Git 通过 reflog 找到一个更好的共同祖先,如以下示例所示:
$ git rebase --fork-point subsys topic
然后,变基会将更改移动到topic
,从git merge-base --fork-point subsys topic
命令的结果开始。如果subsys
分支的 reflog 不包含必要的信息,Git 将回退到上游,这里是subsys
。
重要说明
你可以使用交互式变基(interactive rebase)代替普通的变基,如前面叙述的那样,这样可以获得更好的控制,但代价是需要更多的工作量(例如,删除已经存在但变基机制未能识别的提交)。
不重写历史的修改
如果需要修复的内容在已发布的历史部分中,你该怎么办?正如在重写已发布历史的风险中所描述的,修改已公开的历史部分可能会导致下游开发人员出现问题。你最好不要修改这个版本图的部分。
这个问题有几种解决方案。最常用的一种是添加一个新的修正提交,并进行适当的更改(例如,修正文档中的拼写错误)。如果你需要的是移除这些更改,认为它们不应该出现在历史中,你可以创建一个提交来撤销这些更改。
如果你修复了一个提交或撤销了一个提交,最好在该提交上加上注释,说明它是有问题的,以及哪个提交修复了(或撤销了)它。尽管你不能(也不应该)编辑已发布的提交来添加这些信息,但 Git 提供了notes机制,可以将额外的信息附加到现有提交上,这有点像发布补充说明、勘误表或修正。然而,记住,注释默认是不会被发布的;不过,发布它们很简单(你只需要记得这样做)。
撤销一个提交
如果你需要撤销一个已存在的提交,撤销它所带来的更改,你可以使用git revert
。如《第九章》中所述,合并更改(例如,在该章节的图 9.5中),revert
操作会创建一个包含逆向更改的提交。例如,当原始提交添加了一行时,撤销操作会删除该行;当原始提交删除了一行时,撤销操作会添加该行。
趣闻
注意,不同的版本控制系统对“撤销”一词有不同的定义。特别是,它通常用来指将文件的更改重置回最新提交的版本,丢弃未提交的更改。这正是git reset --
最好通过一个示例来说明这一点。假设multiple
分支上的最后一次提交有如下的更改总结:
$ git show --stat multiple
commit bb71a804f9686c4bada861b3fcd3cfb5600d2a47
Author: Alice Developer <alice@company.com>
Date: Sun Jun 1 03:02:09 2014 +0200
Support optional <count> parameter
src/rand.c | 26 +++++++++++++++++++++-----
1 file changed, 21 insertions(+), 5 deletions(-)
撤销这个提交(需要一个干净的工作目录)将创建一个新的版本。这个版本会撤销被撤销提交所带来的更改:
$ git revert bb71a80
[master 76d9e25] Revert "Support optional <count> parameter"
1 file changed, 5 insertions(+), 21 deletions(-)
Git 会要求输入一个提交信息,解释为什么要撤销该修订版:它是如何出错的,为什么需要撤销而不是修复。默认情况下,会提供被撤销提交的 SHA-1:
$ git show --stat
commit 76d9e259db23d67982c50ec3e6f371db3ec9efc2
Author: Alice Developer <alice@example.com>
Date: Tue Jun 16 02:33:54 2015 +0200
Revert "Support optional <count> parameter"
This reverts commit bb71a804f9686c4bada861b3fcd3cfb5600d2a47.
src/rand.c | 26 +++++---------------------
1 file changed, 5 insertions(+), 21 deletions(-)
比较提交和撤销操作的更改总结。在前面的示例中,提交有 21 次插入和 5 次删除,而撤销操作有 5 次插入和 21 次删除(其中,从一个版本变到另一个版本的行,算作旧版本的删除和新版本的插入)。
一种常见的做法是保留主题不变(这样可以轻松找到撤销操作),但用一个描述撤销原因的内容来替换。
撤销一个错误的合并
有时,你可能需要撤销一次合并的效果。假设你已经合并了更改,但结果发现它们合并得过早,并且该合并带来了回归问题。
假设合并的分支名为topic
,你正在将其合并到master
分支。这种情况如图 10.6所示。
图 10.6 – 一个意外或过早的合并提交,回退合并和重新做回退合并的起点。
如果在发现错误之前你没有发布这个合并提交,并且不希望的合并只存在于本地仓库,最简单的解决方案是使用git reset --hard HEAD^
来删除这个提交(详见第三章,管理你的工作树,了解git reset
的硬模式)。
如果你后来才意识到合并是错误的,例如在master
分支上创建并发布了另一个提交,该怎么办?一种可能性是回退该合并。
然而,合并提交有多个父提交,这意味着有多个增量(或者说多个变更集)。要对合并提交执行revert
,你需要指定要回退的补丁,换句话说,就是哪个父提交是主线。在这个特定的场景下,假设合并之后有一个新的提交(且合并距离历史两次提交),回退合并的命令将如下所示:
$ git revert -m 1 HEAD^^
[master b2d820c] Revert "Merge branch 'topic'"
回退合并后的情况如图 10.7所示。
图 10.7 显示了上图中回退合并后的历史;附加在选定提交上的方框表示它们的变更集,以类似差异的格式显示。
从新的!M1提交开始(!M1符号表示对M1提交的否定或回退),就好像合并从未发生过,至少就变更而言。
从回退合并中恢复
假设你继续在一个已回退合并的分支上工作。或许它是被过早合并的,但这并不意味着该分支上的开发工作已经停止。如果你继续在同一个分支上工作,或许通过创建修复提交,它们将在一段时间后准备就绪,然后你将需要将它们正确地再次合并到主线中。或者,也许主线已经成熟到足以接受合并。如果你仅仅像上次那样简单地再次合并你的分支,就会遇到麻烦。
图 10.8 – 尝试简单地重新做回退合并时意外出现的错误结果
如图 10.8所示,意外的结果是 Git 只引入了回退合并后的更改。被回退的侧分支上的提交所带来的更改不在这里。换句话说,你会得到一个奇怪的结果:新的合并不会包括在回退的合并之前你分支(侧分支)上创建的更改。
这是由于 git revert
撤销了更改(数据),但并不撤销历史(修订的 DAG)。这意味着新的合并会将C4,即回退合并之前的侧分支上的提交,视为共同祖先。由于默认的三方合并策略仅查看 ours、theirs 和 base 快照的状态,它不会搜索历史来发现有回退。它看到共同祖先 C4 和合并分支(即 theirs)C6 都包括了由 C3 和 C4 提交带来的功能,具体来说是 f3 和 f4,而我们正在合并的分支(即 ours)由于回退而没有这些更改。
对于合并策略,它看起来完全像一个分支删除了某些内容的情况,这意味着这个变化(删除)是合并的结果(看起来像是只有一方发生了更改的情况)。特别是,它看起来像基础分支和侧分支有这个特性,但当前分支没有(因为回退)——所以结果也没有这个特性。你可以在 第九章 中找到关于合并机制的解释,将更改合并在一起。
有多个选项可以解决此问题,并使 Git 正确地重新合并 topic
分支,这意味着包括所有已合并的 topic 分支在内的 proposed-updates
分支,理解为这些分支可以并且可能会被重写。
图 10.9 在重新合并(作为 M2)一个回退的 M1 合并后,回退回撤 !!M1(重放)的历史
一种选择是通过回退回撤来恢复已删除的更改。结果如图 10.9所示。在这种情况下,你已经带来了与记录历史相符的更改。
另一个选项是改变历史的视图(可能是暂时的),例如,通过使用 git replace
修改,或通过更改已发布的 topic
。
如果问题出在提交合并时出现的一些 bug(在topic
分支上),且要合并的分支尚未发布,你可以按照之前所述通过交互式变基来修复这些提交。变基会改变历史。因此,如果你还确保你通过变基创建的新历史与旧历史中包括失败和回退合并的部分没有任何共同的修订,那么重新合并该 topic 分支不会带来挑战。
图 10.10 – 重新合并已重基分支后的历史,该分支的合并被撤销
通常,你会将一个主题分支(此例中为topic
)重基到它所分叉的分支的当前状态,这里是master
分支。这样,你的修改会与当前的工作保持同步,从而使后续的合并变得更容易。现在,由于topic
分支有了新的历史,将其“再次”合并到master
中,就像在图 10.10中所示,变得简单,并且不会带来任何意外或麻烦。
更复杂的情况是,如果topic
分支由于某些原因需要保留其基础(例如,能够将其合并到maint
分支中)。这并不意味着在重基后重新合并topic
分支会出现问题,而是意味着我们需要确保该分支在重基后不再与撤销的合并历史共享。目标是让历史呈现出与图 10中所示相同的形态。默认情况下,重基会尝试快进修订,如果没有变化(例如,保留-f
或--force-rebase
强制重基那些可跳过的未更改提交(或者使用--no-ff
,其效果相同))。结果如图 10.11所示。
图 10.11 重新合并一个原地重基的主题分支后的历史,其中一个预重基的合并被撤销
因此,你不应该盲目地撤销合并撤销的操作。如何处理在撤销合并后重新合并的问题,取决于你如何处理正在合并的分支。如果该分支正在被重写(例如,使用交互式重基),那么撤销撤销将是一个错误的做法,因为你可能会带回在重写过程中已经修复的错误。
使用注释存储附加信息
注释机制是一种存储附加信息的方法,通常用于一个对象(通常是一个提交),而不触及该对象本身。你可以将它视为附加在对象上的附件或附录。每个注释都属于某一类别,这样不同用途的注释可以分开存放。
向提交添加注释
有时你可能想在提交中添加额外的信息,尤其是那些在提交创建后过了一段时间才有的内容。例如,可能是一个关于提交中发现了一个 bug 的注释,甚至可能是在某个指定的未来提交中修复了该 bug(如果是回归问题)。也许我们在提交发布后才意识到,我们忘记在提交信息中添加一些重要内容,例如,解释为什么这么做。或者可能是我们意识到还有另一种做法,并且我们想创建一个注释,以确保我们不会忘记它,并且让其他开发者共享这个想法。
由于 Git 中的历史是不可变的,你不能在不重写历史(创建修改后的副本并忘记旧版本历史)的情况下执行此操作。历史的不可变性非常重要;它允许人们对修订进行签名,并信任一旦检查过,历史就无法更改。你可以做的是将额外的信息作为注释添加。
假设协作开发者已经从atoi()
切换到strtol()
,因为前者已经被弃用。从那时起,变更已经公开。然而,提交信息并未包含为什么弃用以及为什么值得切换的解释,即使切换后的代码更长。让我们添加作为注释的信息:
$ git notes add \
-m 'atoi() invokes undefined behaviour upon error' v0.2~3
我们直接从命令行添加了注释,而没有调用编辑器,使用了-m
标志(与git commit
使用的标志相同),以简化此示例的说明。该注释将在运行git log
或git show
时可见:
$ git show --no-patch v0.2~3
commit 8c4ceca59d7402fb24a672c624b7ad816cf04e08
Author: Bob Hacker <bob@company.com>
Date: Sun Jun 1 01:46:19 2014 +0200
Use strtol(), atoi() is deprecated
Notes:
atoi() invokes undefined behaviour upon error
正如你从前面的输出中看到的,我们的注释显示在提交信息之后的Notes:
部分。可以使用--no-notes
选项禁用显示注释,使用--show-notes
重新启用显示注释。
注释是如何存储的
在 Git 中,注释是通过refs/notes/
命名空间中的额外引用存储的。默认情况下,提交注释存储在refs/notes/commits
引用中。可以通过core.notesRef
配置变量来更改此行为,该变量可以通过GIT_NOTES_REF
环境变量进一步覆盖。
如果给定的引用不存在,这并不是错误,而是意味着不应该打印任何注释。这些变量决定了在Notes:
行之后,提交的注释显示哪种类型,以及在哪里写入使用git
notes add
创建的注释。
你可以看到新的引用类型已经出现在代码库中:
$ git show-ref --abbrev commits
fcac4a6 refs/notes/commits
如果你检查新的引用,你会看到每个注释都存储在一个以注释对象的 SHA-1 标识符命名的文件中。这意味着你只能为一个对象拥有一个给定类型的注释。你可以随时编辑注释,向其中追加内容(使用git notes append
),或替换其内容(使用git notes
add --force
)。
在交互模式下,Git 会打开包含笔记内容的编辑器,因此编辑、附加和替换操作在交互模式下几乎是一样的。与提交不同,笔记是可变的,或者更准确地说,每条笔记只有最新版本会被使用:
$ git show refs/notes/commits
commit fcac4a649d2458ba8417a6bbb845da4000bbfa10
Author: Alice Developer <alice@example.com>
Date: Tue Jun 16 19:48:37 2015 +0200
Notes added by 'git notes add'
diff --git a/8c4ceca59d7402fb24a672c624b7ad816cf04e08 b/8c4ceca59d7402fb24a672c624b7ad816cf04e08
new file mode 100644
index 0000000..a033550
--- /dev/null
+++ b/8c4ceca59d7402fb24a672c624b7ad816cf04e08
@@ -0,0 +1 @@
+atoi() invokes undefined behaviour upon error
$ git log -1 --oneline \
8c4ceca59d7402fb24a672c624b7ad816cf04e08
8c4ceca Use strtol(), atoi() is deprecated
提交的笔记存储在一个独立的(元)历史记录行中,但其他类别的笔记不必是这样。笔记引用可以直接指向tree
对象,而不是指向commit
对象,如refs/notes/commits
。
一本书籍或文章中常被忽视的一个重要问题是,标识附加到笔记对象上的文件并非文件的基本名称,而是文件的完整路径。若有许多笔记,Git 可以并将使用扇出式目录层级,例如,将前述笔记存储在8c/4c/eca59d7402fb24a672c624b7ad816cf04e08
路径下(注意斜杠)。
笔记的其他类别和用途
笔记通常会被添加到提交中。然而,即使是那些附加到提交的笔记,在某些情况下,将不同的信息存储在不同类别的笔记中也是有意义的。这使得我们能够在个人基础上决定显示哪些部分信息,以及哪些部分信息推送到公共仓库。它还允许我们单独查询特定的信息部分。
若要在不同于默认的命名空间(类别)中创建笔记(默认情况下,notes/commits
,或如果设置了core.notesRef
配置变量,则为其值),则在添加笔记时需要指定类别:
$ git notes --ref=issues add -m '#2' v0.2~3
现在,默认情况下,Git 只会在提交信息之后显示core.notesRef
类别的笔记。要包含其他类型的笔记,您必须使用git log --notes=<category>
选择要显示的类别(其中<category>
可以是未限定或限定的引用名称,也可以是通配符;因此,您可以使用--notes=*
显示所有类别),或者通过display.notesRef
配置变量(或GIT_NOTES_DISPLAY_REF
环境变量)配置要显示的笔记类别,除了默认类别外。您可以像remote.<remote-name>.push
一样多次指定配置变量值(如果使用环境变量,则可以指定以冒号分隔的路径名列表),或者指定一个通配模式:
$ git config notes.displayRef 'refs/notes/*'
$ git log -1 v0.2~3
commit 8c4ceca59d7402fb24a672c624b7ad816cf04e08
Author: Bob Hacker <bob@company.com>
Date: Sun Jun 1 01:46:19 2014 +0200
Use strtol(), atoi() is deprecated
Notes:
atoi() invokes undefined behaviour upon error
Notes (issues):
#2
笔记有很多可能的用途。例如,您可以使用笔记可靠地标记哪些补丁(哪些提交)已被上游推送(前向移植到开发分支)或下游推送(回溯移植到更稳定的分支或稳定仓库),即使上游推送或下游推送的版本不完全相同,还可以标记一个补丁为推迟,如果它尚未准备好进行上游或下游操作。
如果需要手动输入,这比依赖 git patch-id
机制来检测变更集是否已存在更可靠(你可以通过变基、使用 git cherry-pick
,或者使用 git log
的 --cherry
、--cherry-pick
或 --cherry-mark
选项来实现)。当然,这是指我们没有从一开始就使用主题分支,而是通过挑选提交。
备注也可以用来存储提交后(但合并前)代码审计的结果,并通知其他开发者该版本补丁使用的原因。
备注还可以用来处理标记错误和错误修复,以及验证修复。你通常会在提交发布后很久才发现其中的错误;这就是你需要备注来处理这种情况的原因。如果你在发布前发现了错误,你会重写有问题的提交。
在这种情况下,当错误被报告时,如果它是回归错误,你首先需要找到是哪个版本引入了该错误(例如,使用 git bisect
,如 第四章 中所述,探索项目历史)。然后你需要标记这个提交,将项目问题追踪器中的错误条目标识符(通常是一个数字,或者是一个带有特定前缀的数字,例如 bugs
、defects
或 issues
类别的备注)放入其中。也许你还想包括错误的描述。如果这个错误影响了安全性,可能会分配一个漏洞标识符,例如 CVE-IDs
类别。
然后,经过一段时间,错误希望能被修复。就像我们在提交中标注包含错误的信息一样,我们还可以另外注解修复该错误的提交信息,例如在 fixes
类别中的备注。不幸的是,第一次修复可能并没有完全解决问题,你可能需要修改修复,或者甚至为修复创建一个修复。如果你使用的是 bugfix 或 hotfix 分支(用于修复错误的主题分支),正如在 第八章 中描述的,高级分支技巧,那么通过合并上述 bugfix 分支,找到并一起应用它们会很容易。如果你没有使用这种工作流,那么最好使用备注来标注应该一起挑选的修复以及补充提交,例如通过在 alsoCherryPick
或 seeAlso
类别中添加备注,或者任何你想命名的类别。也许原始提交者,或者一个 Q&A 小组,也会着手修复并测试它是否正常工作。最好是在发布前对提交进行测试,但这并不总是可能的,所以 refs/notes/tests
就是这样。
第三方工具使用(或可以使用)笔记来存储额外的 refs/notes/reviews
。这包括提交更改的 Gerrit 用户的姓名和电子邮件地址、提交提交的时间、Gerrit 实例中更改审核的 URL、审核标签和评分(包括审核人的身份)、项目和分支的名称等:
Notes (review):
Code-Review+2: John Reviewer <john@company.com>
Verified+1: Jenkins
Submitted-by: Bob Developer <bob@company.com>
Submitted-at: Thu, 20 Oct 2014 20:11:16 +0100
Reviewed-on: http://localhost:9080/7
Project: common/random
Branch: refs/heads/master
将笔记作为缓存
举一个更为复杂的例子,你可以使用笔记机制将构建结果(包括归档文件、安装包或仅是可执行文件)附加到提交或标签中。理论上,你可以将构建结果存储在标签中,但通常标签会包含漂亮的隐私保护(PGP)签名,并且可能还会有发布亮点。而且,几乎在所有情况下,你会希望获取所有标签,而不是每个人都愿意为预构建的可执行文件支付额外的磁盘空间费用。在自动跟踪标签时,你可以选择是否获取某个类别的笔记(例如跳过预构建的二进制文件)。这就是为什么笔记比标签更适合这个目的。
这里的问题是如何正确生成二进制笔记。你可以通过以下技巧安全地创建二进制笔记:
# store binary note as a blob object in the repository
$ blob_sha=$(git hash-object -w ./a.out)
# take the given blob object as the note message
$ git notes --ref=built add --allow-empty –C "$blob_sha" HEAD
你不能简单地使用-F ./a.out
,因为这不是二进制安全的——注释(或更准确地说,错误检测为注释的内容,即以#
开头的行)会被删除。
笔记机制还被用作启用为textconv
过滤器存储缓存的机制(请参阅第三章中的 gitattributes 部分,管理你的工作树)。你需要做的就是配置相关的过滤器,将其设置为 true
:
[diff "jpeg"]
textconv = exif
cachetextconv = true
在这里,refs/notes/textconv/jpeg
命名空间中的笔记(以过滤器命名)用于将转换后的文本附加到一个 blob 上。
笔记与历史重写
笔记通过它们的 SHA-1 标识符附加到它们注释的对象(通常是提交)。那么当我们重写历史时,笔记会怎样呢?在新重写的历史中,绝大多数情况下,对象的 SHA-1 标识符会发生变化。
事实证明,你可以进行非常广泛的配置。首先,你可以选择在重写时哪些类别的笔记应与注释的对象一起复制,使用 notes.rewriteRef
多值配置变量。此设置可以通过 GIT_NOTES_REWRITE_REF
环境变量进行覆盖,环境变量值为用冒号分隔的完全限定笔记引用和 glob(表示要匹配的引用模式)。此设置没有默认值;你必须配置该变量以启用重写。
其次,你还可以配置在重写过程中是否复制笔记,具体取决于执行重写的命令类型(目前支持rebase
和amend
命令)。这可以通过布尔值配置变量notes.rewrite.<command>
来完成。
此外,你可以决定在重写过程中复制笔记时,如果目标提交已经有笔记该怎么办,例如,在使用交互式变基时压缩提交。你需要在notes.rewriteMode
配置变量或GIT_NOTES_REWRITE_MODE
环境变量中选择overwrite
(从附加提交中获取笔记)、concatenate
(默认值)、cat_sort_uniq
(类似于concatenate
,但对行进行排序并移除重复项)和ignore
(使用附加到的原始提交中的笔记)之间进行选择。
发布和获取笔记
所以,我们在本地仓库中有笔记。如果我们想分享这些笔记该怎么办?我们如何将它们公开?我们以及其他开发者如何从其他公共仓库获取笔记?
我们可以在这里运用我们对 Git 的知识。笔记是如何存储的一节解释了笔记是如何使用refs/notes/
命名空间中的特殊引用存储在仓库的对象数据库中的。笔记的内容作为一个 blob 存储,通过这个特殊引用来引用。提交笔记(存储在refs/notes/commits
中的笔记)存储笔记的历史记录,尽管 Git 也允许你存储没有历史记录的笔记。所以,你需要做的是获取这个特殊引用,笔记的内容会随之而来。这是仓库同步(对象传输)的常见机制。
这意味着,为了发布你的笔记,你需要在适当的远程仓库配置中配置合适的push
行(请参见第六章,与 Git 的协作开发)。假设你使用的是一个单独的public
远程仓库(如果你是维护者,你可能只使用origin
),它可能被设置为remote.pushDefault
,并且你希望发布任何类别的笔记,你可以运行以下命令:
$ git config --add remote.public.push '+refs/notes/*:refs/notes/*'
如果push.default
设置为matching
(或者 Git 足够旧,默认行为是如此),或者push
行使用了特殊的 refspec,例如:
或+:
,那么只需第一次推送笔记引用,以后每次都会自动推送:
$ git push origin 'refs/notes/*'
获取笔记的过程稍微复杂一些。如果你自己没有生成指定类型的笔记,可以通过“镜像”模式将笔记获取到具有相同名称的引用:
$ git config --add remote.origin.fetch '+refs/notes/*:refs/notes/*'
然而,如果存在冲突的可能性,你需要将远程的笔记获取到远程跟踪笔记引用中,然后使用git notes merge
将它们合并到你的笔记中。详细信息请参阅文档。
提示
如果你希望更轻松地合并 Git 注释,甚至是自动化合并,那么遵循 Key: Value 格式,且每条内容独占一行并移除重复项,会有所帮助。
对于远程跟踪注释引用,虽然没有标准的命名约定,但你可以使用refs/notes/origin/*
(这样,来自origin
远程的简化commits
注释类别就是origin/commits
,依此类推),或者采用更彻底的方法,从origin
远程获取refs/*
到refs/remotes/origin/refs/*
(这样,commits
类别就会进入refs/remotes/origin/refs/notes/commits
)。
使用 git 替换
用于替换或类似替换机制的最初想法是使得能够将两个不同仓库的历史记录合并。
最初的动机是能够通过创建两个仓库来从其他版本控制系统迁移到 Git:第一个用于当前工作,从空仓库开始并且包含最新版本,第二个用于历史数据,存储从原始系统转换来的数据。通过这种方式,就可以花时间对历史数据进行忠实的转换,甚至在转换错误时进行修正,而不影响当前的工作。
需要的是某种机制来连接这两个仓库的历史,以便进行完整的历史检查,追溯到项目创建的时间(例如,用于 blame,即行历史注释)。
替换机制
这种工具的现代体现就是替换(或替换)机制。使用它,你可以将任何对象替换为任何对象,或者通过创建覆盖来创建虚拟历史(虚拟对象数据库),从而使大多数 Git 命令返回替换对象而不是原始对象。
然而,原始对象仍然存在,Git 对替换机制的行为是通过一种方式来消除丢失数据的可能性。你可以使用--no-replace-objects
选项并传递给git
命令包装器,以查看原始视图,或者直接使用GIT_NO_REPLACE_OBJECTS
环境变量。例如,要查看原始历史记录,可以使用git --no-replace-objects log
。
替换信息会通过将被替换对象的 SHA-1 名称存储在refs/replace/
命名空间中,并将替换对象的 SHA-1 作为唯一内容,保存到仓库中。但是不需要手动编辑它或使用低级的管道命令——你可以使用git replace
命令。
除非特别告知,否则几乎所有命令都会使用替换,正如之前所解释的那样。例外的是可达性分析命令。这意味着,考虑到替换的情况,Git 不会删除已替换的对象,因为它们不再可达。当然,替换对象是通过替换引用可以访问的。
重要提示
当前,一些用于加速非常大仓库的机制(见第十二章,管理大仓库)在使用git replace时不起作用。
你可以用任何其他对象替换任何对象,尽管更改对象的类型需要告诉 Git 你知道自己在做什么,使用git replace -f <object> <replacement>
。这是因为这样的更改可能会导致 Git 出现问题,因为它原本期望的是一种类型的对象,而得到了另一种。
使用git replace --edit <object>
,你可以交互式地编辑其内容。实际上,Git 会打开编辑器,显示对象的内容,编辑后,Git 会创建一个新对象和一个替换引用。对象格式(特别是提交对象格式,因为几乎总是编辑提交)在本章开头有所描述。你可以更改提交消息、提交父级和作者等。
示例 – 使用 git replace 合并历史
假设你已经按照前面关于filter-repo
的部分将仓库拆分为两个,也许是出于性能原因。但是,假设你希望能够将合并后的历史视为一个整体。
或者,可能在将版本控制系统更改为 Git 后,自然地发生了历史拆分,新仓库包含当前工作(在从项目当前状态切换后开始的仓库,历史为空),而转换后的历史仓库被单独保留。这可以加快切换速度。这种技术的优势在于,它允许你在拆分后改进转换。
这种情况显示在图 10.12中,历史仓库作为远程被添加到当前工作仓库中(即包含新提交的仓库)。
图 10.12 – 一个拆分历史的视图,替换被关闭(git --no-replace-objects)。提交左上角的简短 SHA-1 表示其标识符。
在许多情况下,你可能希望在“历史”仓库(包含历史较旧部分的仓库)之上创建一种信息性提交,例如,在README
文件中添加关于在哪里可以找到current work
仓库的通知。为了简化起见,这种提交在图 10.12中没有显示。
如何合并历史依赖于历史是否最初被拆分或合并。如果最初是合并的,然后被拆分,只需告诉 Git 使用git replace <post-split> <pre-split>
将拆分后的版本替换为拆分前的版本。如果仓库一开始就是拆分的,请使用git replace
的--edit
或--graft
选项。
图 10.13 – 使用替代合并后的历史分裂视图
分裂历史存在,只是被隐藏了。对于所有 Git 命令来说,历史看起来就像图 10.13中的样子。你可以像之前描述的那样,通过替代来关闭它;在这种情况下,你会看到历史就像图 10.12中的样子。
历史备注 – grafts
第一次尝试创建一个机制来实现历史记录行的合并,形式是.git/info/grafts
文件,文件中包含受影响提交的 SHA-1 标识符及其替代父提交,且各项之间由空格分隔。
该机制仅适用于提交,并且仅允许更改提交的父提交关系。它不支持传输,即无法将此信息从 Git 内部传播。你无法临时关闭 grafts 机制,至少不容易关闭。此外,它本质上不安全,因为没有对可达性检查命令的例外情况,使得 Git 在修剪(垃圾回收)过程中可能会意外删除必要的对象。
然而,你可以在示例中找到它的使用。如今,它已经过时,尤其是在git replace --graft
选项的存在下。如果你使用 grafts,考虑将它们替换为替代对象;Git 源码中的contrib/convert-grafts-to-replace-refs.sh
脚本可以帮助你完成此操作。
Git 中的其他类似 graft 的文件
浅克隆(即git clone --depth=
发布和获取替代
如何发布替代,并且如何从远程仓库获取它们?由于替代使用引用,这相当简单。
每个替代都是refs/replaces/
命名空间中的一个独立引用。因此,你可以使用 glob 方式的fetch
或push
命令获取所有替代:
+refs/replace/*:refs/replace/*
对于一个对象只能有一个替代,因此合并替代时不会出现问题。你只能在两者之间选择其一。
理论上,你也可以通过获取(和推送)单独的替代引用来请求单个替代,而不是使用*
通配符。
摘要
本章连同第八章,高级分支技术,提供了管理项目历史记录的所有工具,确保历史记录简洁、易读且易于审查。
本章中,你学到了如何通过重写历史来使历史更加清晰。你还学到了在 Git 中重写历史意味着什么,何时以及为什么要避免重写历史,以及如何从不及时的上游重写中恢复。你学会了使用交互式 rebase 删除、重新排序、合并和拆分提交,以及如何在 rebase 过程中测试每个提交。你知道如何使用 filter-repo
进行大规模的脚本化重写,以及如何编辑提交和提交元数据,以及如何永久更改历史,例如将其拆分成两个部分。你还了解了一些第三方外部工具,它们可以帮助完成这些任务。
你学到了如果不能重写历史时该怎么做:如何通过创建适当更改的提交来修复错误(例如,使用 git revert
),如何使用备注向现有提交添加额外信息,以及如何通过替换来更改历史的虚拟视图。你学会了如何处理撤销错误的合并以及如何在撤销合并后重新合并。你学会了如何获取并发布备注和替换。
为了真正理解高级历史重写及其背后的机制,本章解释了 Git 内部和低级命令的基础知识,这些命令可以用于脚本编写(包括脚本化重写)。
接下来的章节,第十一章,管理子项目,将解释并展示在一个仓库中连接不同子项目的多种方式,从子模块到子树。
在随后的章节,第十二章,管理大型仓库,你将学习如何管理(或减轻管理)仓库中的大型资产或大量文件。将大型项目拆分为子模块是一种方式,但不是唯一的方法。
问题
回答以下问题以测试你对本章节的理解:
-
在一系列提交中实现一个功能时,如何标记一个修复提交,以便在发布系列之前将其合并到原始提交中?
-
为什么如果你使用合并来集成更改,就不应该重写(rebase 或 amend)已发布的历史?
-
如何从上游的 rebase 中恢复?
-
如果你注意到在提交中不小心包含了一些不应该放入版本控制的巨大文件,你该怎么办?
-
如果你不能重写历史,如何撤销提交的影响?
-
存在什么机制可以在不重写历史的情况下修改历史或历史视图?
答案
以下是上述问题的答案:
-
你可以在创建 bug 修复时使用git commit --fixup,然后在发布系列之前使用git rebase --interactive --autosquash。
-
你不应该重写已发布的历史,因为其他开发人员可以基于更改之前的版本进行工作,合并时会将旧版本(来自重写之前)重新带入历史。
-
在新的、经过变基的上游版本之上变基你自己的更改。
-
如果问题出现在最近的提交中,你可以使用git commit --amend来修改它。如果你需要重写项目的整个历史,可以使用git filter-repo工具。不过,请注意,重写已发布的历史会带来一些问题,特别是其他开发者在尝试集成他们的更改时,可能会遇到麻烦。
-
你可以使用git revert来创建一个提交,撤销不需要的提交所带来的更改。
-
你可以使用git notes在提交对象上添加额外的信息,且可以使用git replace来改变历史的有效形态。
深入阅读
要了解本章所涉及的主题,您可以查看以下资源:
-
Scott Chacon 和 Ben Straub: Pro Git, 第二版 (2014)
git-scm.com/book/en/v2
.-
第 7.6 章,*Git 工具 - * 重写历史
-
第 7.13 章,*Git 工具 - * 替换
-
-
Aske Olsson 和 Rasmus Voss: Git 版本控制实用手册 (2014),Packt Publishing Ltd
-
第五章,在 你的仓库中存储附加信息
-
第八章,从错误中恢复
-
-
Git 文档 HOWTOs
-
Tyler Cipriani: Git Notes: Git 最酷但最不受欢迎的功能 (2022)
tylercipriani.com/blog/2022/11/19/git-notes-gits-coolest-most-unloved-feature/
-
Elijah Newren: git filter-repo
github.com/newren/git-filter-repo
-
Stacked Git: StGit 教程
stacked-git.github.io/guides/tutorial/
-
Jackson Gabbard: Stacked Diffs 与 Pull Requests (2018)
jg.gg/2018/09/29/stacked-diffs-versus-pull-requests/
第三部分 - 管理、配置和扩展 Git
本部分介绍了如何管理 Git,如何自定义和扩展 Git,以及如何配置它。你还将学习如何使用 Git hooks 自动化操作。如果你不需要完整功能的仓库托管软件,本文还会提供设置仓库服务的信息。最后,你将看到一系列版本控制、通用和 Git 特定的建议和最佳实践,涵盖诸如管理工作目录、创建提交及一系列提交(拉取请求)、提交更改以供纳入和同行评审更改等问题。
本部分包含以下章节:
-
第十一章,管理子项目
-
第十二章,管理大型仓库
-
第十三章,自定义和扩展 Git
-
第十四章,Git 管理
-
第十五章,Git 最佳实践
第十一章:管理子项目
在第六章,与 Git 进行协作开发中,我们学习了如何管理多个仓库,而第八章,高级分支技术则教会了我们利用多个分支进行各种开发技术,以及在这些仓库中进行多线开发。直到现在,这些多个仓库都是独立开发的。不同项目的仓库是自治的。
本章将解释并展示不同的方法,将不同的子项目连接到框架项目的单个仓库中,从通过嵌入一个项目的代码来进行强连接(子树)到通过嵌套仓库(子模块)来进行轻连接的方式。您将学习如何将子项目添加到主项目中,如何更新超级项目状态,以及如何更新子项目。我们将了解如何将我们的更改发送到上游,将它们回溯到适当的项目,并将它们推送到适当的仓库。管理子项目的不同技术在这里有不同的优缺点。
本章中,我们将涵盖以下主题:
-
管理库和框架依赖项
-
依赖管理工具:管理 Git 之外的依赖项
-
将代码作为子树导入超级项目
-
使用子树合并;git-subtree 和 git-stree 工具
-
嵌套仓库(子模块):超级项目中的子项目
-
子模块的内部工作原理:gitlinks,.gitmodules 和 .git 文件
-
子树和子模块的使用案例;不同方法的比较
-
替代的第三方解决方案和工具/助手
构建一个活跃的框架
加入外部项目到你自己的项目有多种原因。就像将一个项目(我们称之为子项目或模块)包含在另一个项目(我们称之为超级项目或主项目)中有不同的原因一样,有不同类型的包含适应不同情况。它们都有各自的优缺点,了解这些对于选择解决方案至关重要。
假设您正在开发一个 Web 应用程序,并且您的 Web 应用程序使用 JavaScript(可能作为单页应用程序)。为了更容易开发,您可能会使用一些 JavaScript 库或 Web 框架,如 React。
这样的库是一个独立的项目。你会希望能够将其固定在已知的工作版本上(以避免未来对库的更改导致其在你的项目中停止工作),同时也能够审查更改并自动将其更新到新版本。也许你还希望对库进行自己的更改,并将这些提议的更改发送到上游。当然,你希望你的项目用户能够使用带有你树外修复的库,即使这些修复尚未被原开发者接受。可以想象,你可能会有一些定制和更改,不希望公开(发送到上游),但你仍然可能会使它们可用。
这一切在 Git 中都是可能的。包括子项目的主要解决方案有两个:使用子树合并策略将代码导入到项目中,以及使用子模块链接子项目。
子模块和子树的目标都是重用来自另一个项目的代码,该项目通常有自己的代码库,并将其放置在自己代码库的工作目录树的某个位置。其目标通常是从多个容器仓库的集中维护中受益,而无需依赖笨拙、不可靠的手动维护(通常是复制粘贴)。
有时,这种情况更为复杂。许多公司中的典型情况是,他们使用许多内部生产的应用程序,这些应用程序依赖于公共工具库或一组库。你通常会希望单独开发每个这样的应用程序,与其他应用程序一起使用,进行分支和合并,并在它们自己的 Git 仓库中应用你自己的更改和定制。然而,如果能做到的话,使用单一的单体仓库(monorepo)也有其优点,比如简化的组织结构、依赖关系、跨项目的变更和工具支持。
子模块和子树方案所使用的机制(为每个应用程序、框架或库拥有独立的 Git 仓库)并非没有问题。开发变得更加复杂,因为现在需要与多个仓库交互。如果库得到改进,你会希望更新你的子项目,并且需要测试这个新版本是否与代码正确配合,然后决定是否在超级项目中使用它。另一方面,在某个时间点,你会希望将更改提交到库本身,以便与其他开发者共享他们的更改,哪怕只是为了分担维护这些功能的负担(树外补丁会带来维护成本,需要保持其更新)。
在这些情况下该怎么办?本章描述了几种管理子项目的策略。对于每种技术,我们将详细说明如何将子项目添加到超级项目中,如何保持它们的最新状态,如何创建自己的更改,以及如何将选定的更改发布到上游。
子目录需求
请注意,所有解决方案都要求子项目的所有文件都包含在超级项目的单个子目录中。当前没有任何可用的解决方案允许将子项目文件与其他文件混合,或让它们占用多个目录。
无论你如何管理子项目,不论是子树、子模块、第三方工具,还是 Git 之外的依赖管理,你应该努力让模块代码保持独立于超级项目的特殊性(或者至少通过外部配置来处理这些特殊性,可能是非版本化的配置)。使用针对超级项目的特定修改违背了模块化和封装的原则,导致两个项目之间不必要的耦合。
另一方面,共享通用组件、库和工具,并确保所有不同但相关的项目使用相同的组件,可能比这些项目的自主性更为重要(例如,当这些项目由同一公司开发时)。在多仓库的设置中,可能每次引入新功能时都需要在多个仓库中创建多个提交,而不仅仅是一个提交。在这种情况下,单一仓库(monorepo)可能是更好的解决方案。
管理 Git 之外的依赖关系
在很多情况下,所使用的开发栈允许你简单地使用打包和正式的依赖管理。如果可能,通常推荐走这条路。使用依赖管理解决方案能更好地拆分代码库,避免许多副作用、复杂性和陷阱,这些问题会出现在子模块和子树解决方案中(不同的技术会带来不同的复杂性)。它使得版本控制系统与管理模块脱离关系,也让你能够从版本控制方案中受益,例如语义化版本控制,用于管理依赖项。
提醒一下,这里是主要编程语言和开发栈的部分列表(按字母顺序排列),以及它们的依赖管理/打包系统和注册中心(查看完整比较请访问 www.modulecounts.com):
-
Go 使用 GoDoc
-
Java 使用 Maven Central(Maven 和 Gradle)
-
JavaScript 使用 npm(用于 Node.js)和 Bower
-
.NET 使用 NuGet
-
Objective-C 使用 CocoaPods
-
Perl 使用全面的 Perl 存档网络(CPAN)和 carton
-
PHP 使用 Composer、Packagist,以及经典的 PEAR 和 PECL
-
Python 使用Python 包索引(PyPI)和 pip
-
Ruby 使用 Bundler 和 RubyGems
-
Rust 使用 Crates
有时,仅仅使用官方的软件包注册表是不够的。您可能需要应用一些树外补丁(更改)来定制模块(子项目)以满足您的需求。然而,由于种种原因,您可能无法将这些更改上推并被接受。可能这些更改只与您的特定项目相关,或者上游响应提议更改的速度很慢,或者可能涉及许可方面的考虑。也许该子项目是一个不能公开的内部模块,但您被要求在公司项目中使用它。
在所有这些情况下,您需要使用自定义软件包注册表(软件包仓库),并且需要在默认注册表之外使用,或者需要将子项目管理为私有软件包,许多这类系统通常是支持的。如果没有对私有软件包的支持,还需要一个工具来管理私有注册表,例如 Perl 的 Pinto 或 CPAN::Mini。
手动将代码导入到您的项目中
有时候,您想要在项目中包含的库或工具在软件包注册表中不可用(可能是因为软件堆栈的原因;例如,C++的软件包注册表如 Conan 或 vcpkg 是比较新的东西)。
因此,让我们看一下另一种可能性:为什么我们不直接将库导入到项目中的某个子目录中呢?如果您需要更新它,只需将新版本作为一组新文件复制进来。在这种方法中,子项目代码被嵌入到超级项目的代码中。
最简单的解决方案是每次想要更新超级项目以使用新版本时,就覆盖子项目目录的内容。如果您想导入的项目没有使用 Git,或者根本没有使用版本控制系统(VCS),或者它使用的仓库不是公开的,那么这确实是唯一可能的解决方案。
使用外部 VCS 的仓库作为远程仓库
如果您想导入(嵌入)的项目使用的是 Git 以外的版本控制系统(VCS),但存在良好的转换机制(例如,快速导入流),您可以使用远程帮助程序来将外部 VCS 仓库设置为远程仓库(通过自动转换)。您可以查看第六章,与 Git 的协同开发,以及第十三章,定制和扩展 Git,以获取更多信息。
例如,可以使用 Mercurial 和 Bazaar 仓库,感谢git-remote-hg和git-remote-bzr帮助程序。
移动到导入库的新版本非常简单(而且机制容易理解)。删除目录中的所有文件,添加新版本库中的文件(例如,通过从归档中提取它们),然后使用git add
命令将其添加到目录中:
$ rm -rf mylib/
$ git rm mylib
$ tar -xzf /tmp/mylib-0.5.tar.gz
$ mv mylib-0.5 mylib
$ git add mylib
$ git commit
这种方法在简单的情况下效果非常好,但有以下几点注意事项:
-
在你的项目的 Git 历史记录中,只有在导入时的库版本。一方面,这使得你的项目历史清晰易懂;另一方面,你无法访问子项目的详细历史记录。例如,在使用git bisect时,你只能知道是通过升级库引入的问题,而无法知道导致此问题的具体提交。
-
如果你想通过添加与应用程序相关的更改来自定义库的代码,并将其适配到你的项目中,那么在导入新版本后,你需要以某种方式重新应用这些自定义更改。你可以使用git diff提取更改(将其与导入时的未更改版本进行比较),然后在升级库后使用git apply。或者,你可以使用 rebase、交互式 rebase 或某些补丁管理接口;详见第十章,保持历史清晰。Git 不会自动执行此操作。
-
每次导入新版本的库都需要运行一系列特定的命令来更新超项目:删除旧版本的文件,添加新文件,并提交更改。它不像运行git pull那么简单,尽管你可以使用脚本或别名来帮助完成。
一种用于嵌入子项目代码的 Git 子树解决方案
在稍微高级一些的解决方案中,你可以使用子树合并将子项目的历史记录合并到超项目的历史记录中。这仅比普通的 pull 操作稍微复杂一些(至少在子项目导入之后),但提供了一种自动合并更改的方法。
根据你的需求,这种方法可能非常适合你。它有以下优点:
-
你将始终拥有正确版本的库,避免错误使用库版本。
-
这种方法简单易懂,仅使用标准的(且广为人知的)Git 功能。正如你所看到的,最重要且最常用的操作都很容易完成且易于理解,而且很难出错。
-
你的应用程序仓库始终是自包含的;因此,克隆它(使用普通的git clone)将始终包含所有需要的内容。这意味着这种方法非常适合所需的依赖项。
-
即使你没有上游仓库的提交权限,也可以轻松地将补丁(例如,自定义修改)应用到库内。
-
在你的应用程序中创建一个新分支也会为库创建一个新分支;切换分支时也会如此。这是你期望的行为。与子模块的行为(管理子项目的另一种技术)形成对比。
-
如果你使用的是子树合并策略(在第九章中有描述,合并变更),例如使用git pull -s subtree,那么获取新的库版本将像更新项目中的其他部分一样简单。
然而,不幸的是,这种技术并非没有缺点。对于许多人和许多项目来说,这些缺点并不重要。基于子树的方法通常因其简便性而克服了其缺陷。
以下是子树方法的几个问题:
-
每个使用该库的应用程序都会增加其文件的副本。没有简单且安全的方式在不同的项目和不同的仓库之间共享其对象。(请参阅以下有关共享 Git 对象数据库的说明。)
-
每个使用该库的应用程序都有它自己的文件检出到工作区,尽管你可以借助稀疏检出来更改这一点(将在下一章第十二章中描述,处理 大型仓库)。
-
如果你的应用程序对其库的副本进行了更改,那么将这些更改发布并推送到上游并不那么简单。像git subtree或git stree这样的第三方工具可以提供帮助。它们有专门的子命令来提取子项目的更改。
-
由于子项目文件与超级项目文件之间缺乏分离,因此很容易在一次提交中混合库的更改和应用程序的更改。在这种情况下,你可能需要重写历史(或历史的副本),如第十章中所述,保持 历史清晰。
前两个问题意味着子树并不适合管理可选依赖(仅用于某些额外功能)或可选组件(如主题、扩展或插件)等子项目,特别是那些只需在文件系统层次结构中的适当位置存在就能安装的子项目。
在分支(副本)之间通过替代对象共享
你可以通过替代(换句话说,使用git clone --reference)来减少仓库中对象的重复。然而,这样做时你需要更加小心垃圾回收。问题的关键部分是那些在借用者仓库中被引用,但在借出者仓库的引用中没有被引用的历史部分。替代机制的描述和解释将在第十四章中介绍,Git 管理。
处理和管理子树导入的子项目有多种技术方法。你可以使用经典的 Git 命令,只需在操作子项目时使用适当的选项,例如 --strategy=subtree
(或将 subtree
选项添加到默认的 recursive
合并策略中,--strategy-option=subtree=<path>
)用于 merge
、cherry-pick
和相关操作。这种手动方法在大多数情况下非常简单,并且提供了最佳的操作控制度。然而,它需要对底层概念有充分的理解。
在现代 Git(自 1.7.11 版本起),git subtree
命令作为已安装的二进制文件之一可用。它来自 contrib/
区域,并未完全集成(例如,在文档方面)。这个脚本经过充分测试且稳健,但它的一些概念比较特殊或令人困惑,而且这个命令并不支持所有可能的子树操作。此外,这个工具仅支持 带历史的导入 工作流(稍后会定义),有些人认为这会使历史图表变得混乱。
还有一些第三方脚本可以帮助处理子树,其中之一是 git-subrepo
。
为子项目创建远程
通常,在导入子项目时,你会希望能够轻松地更新嵌入的文件。你希望继续与子项目互动。为此,你可以将该子项目(例如,公共库)作为 远程引用 添加到你的(超级)项目中,并进行拉取:
$ git remote add mylib_repo https://git.example.com/mylib.git
$ git fetch mylib_repo
warning: no common commits
From https://git.example.com/mylib.git
* [new branch] master -> mylib_repo/master
请注意,在这个示例中,进度信息被删除以简化展示。
然后你可以检查 mylib_repo/master
远程跟踪分支,可以通过 git checkout mylib_repo/master
将其签出到脱离的 HEAD
,或者通过创建一个本地分支并使用 git checkout -b mylib_branch mylib_repo/master
签出这个本地分支。另外,你也可以通过 git ls-tree -r --abbrev mylib_repo/master
列出它的文件。你会看到,子项目的根目录与超项目不同。此外,从 warning: no common commits 的信息可以看出,这个远程跟踪分支包含了来自独立项目的完全不同的历史。
将子项目作为子树添加
如果你没有使用像 git subtree
这样的专用工具,而是采用手动方法,下一步将会有些复杂,并且需要你使用一些高级 Git 概念和技巧。幸运的是,这个过程只需要做一次。
首先,如果你想导入子项目历史,你需要创建一个合并提交,导入相关的子项目。你需要在超级项目中的给定目录下拥有子项目的文件。不幸的是(至少在写这篇章时的当前 Git 版本中),使用-Xsubtree=mylib/
合并策略选项并不能按预期工作。我们必须分两步来做:先准备父项,然后准备内容。
第一步是准备一个合并提交,使用ours
合并策略,但不立即创建它(不写入到仓库中)。该策略会合并历史记录,但会从当前分支取当前版本的文件:
$ git merge --no-commit --strategy=ours --allow-unrelated-histories mylib_repo/master
Automatic merge went well; stopped before committing as requested
如果你想要一个简单的历史,类似于我们仅仅复制文件时得到的历史,你可以跳过这一步。
现在我们需要更新我们的索引(即提交的暂存区),将库仓库中master
分支的内容更新到我们的工作目录。所有这些也需要在适当的子文件夹中完成。这可以通过低级(plumbing)git
read-tree
命令来完成:
$ git read-tree --prefix=mylib/ -u mylib_repo/master
$ git status
On branch master
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge)
Changes to be committed:
new file: mylib/README [...]
我们使用了-u
选项,因此工作目录会随着索引一起更新。然后,我们只需按照 Git 的提示用git commit
来完成合并。
重要提示!
重要的是不要忘记--prefix选项参数中的尾部斜杠。检出的文件会以此为前缀。
这一系列步骤在 Git 文档的 HOWTO 部分中有描述,具体来说是在如何使用子树合并策略中。此 HOWTO 可以在kernel.org/pub/software/scm/git/docs/howto/using-merge-subtree.html
找到。
使用git subtree
这类工具要简单得多:
$ git subtree add --prefix=mylib mylib_repo master
git fetch mylib_repo master
From https://git.example.com/mylib.git
* branch master -> FETCH_HEAD
Added dir 'mylib'
git subtree
命令在必要时会拉取子树的远程版本;无需像手动方案那样执行手动拉取。
如果你检查历史记录,例如使用git log --oneline --graph --decorate
,你会看到这个命令将库的历史记录与应用程序(超级项目)的历史记录合并。如果你不希望这样,那就没办法了。git subtree
在其add
、pull
和merge
子命令中提供的--squash
选项在这里并不会帮上忙。这个工具的一个特殊之处在于,这个选项不会创建压缩合并,而是简单地合并压缩后的子项目历史(就像是经过交互式变基后的合并)。提交消息会像这样:Squashed ‘mylib/’ content from commit 5e28a71。请参见本章稍后的图 11.2(b)。
如果你想要一个没有历史记录与超级项目历史关联的子树,如图 11.2(c)所示,可以考虑使用外部工具git-subrepo
。它的额外优点是它能记住子树设置:
$ git subrepo clone \
https://git.example.com/mylib.git mylib/
Subrepo 'https://git.example.com/mylib.git' (master) cloned into 'mylib'.
有关子项目仓库 URL、主分支、原始提交等信息存储在包含子项目的目录中的 .gitrepo
文件里。所有后续的 git subrepo
命令都会通过子项目所在目录的名称来引用该嵌入的子项目(在前面的示例中是 mylib/
)。
你也可以使用外部的 git-stree
工具来实现类似的效果,但该工具已被弃用,取而代之的是 git-subrepo
。
克隆和更新包含子树的超级项目
好的!现在我们已经将库作为子树嵌入到项目中了,那我们需要做什么才能获取它呢?由于子树的概念是只有一个仓库(容器),你只需克隆这个仓库即可。
为了获取最新的仓库,你只需要进行常规的拉取;这将同时更新超级项目(容器)和子项目(库)。无论采用何种方法、使用何种工具,或者子树是如何添加的,这种方法都能正常工作。这是子树方法的一个巨大优势。
从子项目获取更新,通过子树合并
让我们看看自从导入子项目后,子项目是否有一些新变化。如果需要更新超级项目中嵌入的版本,这是很容易做到的:
$ git pull --strategy subtree mylib_repo master
From https://git.example.com/mylib.git
* branch master -> FETCH_HEAD
Merge made by the 'subtree' strategy.
你本来可以选择先拉取然后再合并,这样能更好地控制。或者,如果你喜欢,也可以选择变基而不是合并,这也可以。
选择子树合并策略的重要性
在拉取子项目时,别忘了选择合并策略 -s subtree。即使没有该选项,合并也能正常工作,因为 Git 会进行重命名检测,并通常能发现文件是从根目录(子项目中的)移动到子目录(我们正在合并到的超级项目中的) 的。问题出现在子项目内外存在冲突文件的情况下。潜在的候选文件包括 Makefile 和其他标准文件名。
如果 Git 无法正确检测到需要合并的目录,或者如果你需要使用普通 ort 合并策略的高级功能(这是默认策略),你可以改用 -Xsubtree=<path/to/subproject>,即 ort 合并策略的 subtree 选项。
你可能需要调整应用程序代码的其他部分,以便与更新后的库代码正常配合工作。
请注意,通过这种解决方案,你的子项目历史会与应用程序历史相关联,就像你在 图 11.1 中看到的那样:
图 11.1 – 超级项目的历史,子项目已合并到 'maps/' 目录中。子项目历史可以通过相关的远程跟踪分支在超级项目中查看
如果你不希望子项目的历史记录与主项目的历史记录混杂在一起,并且更喜欢更简洁的历史记录(如图 11.1所示),你可以使用git merge
(或git pull
)命令的--squash
选项来合并:
$ git merge -s subtree --squash mylib_repo/master
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested
$ git commit -m "Updated the library"
压缩合并在第九章中描述,合并 更改一起。
在这种情况下,在历史记录中,你将只看到子项目版本已更改这一事实,这既有优点也有缺点。你得到的是更简洁的历史,但也失去了详细的历史。
使用git subtree
或git subrepo
工具时,只需使用它们的pull
子命令;它们自己提供了subtree
合并策略。然而,目前git subtree pull
需要你重新指定--prefix
和整个子树设置。
图 11.2 – 不同类型的子树合并:(a)子树合并,(b)压缩提交的子树合并,(c)压缩子树合并
注意,git subtree
命令总是会进行合并,即使使用了--squash
选项;它只是会在合并之前将子项目的提交压缩(就像交互式变基中的squash
指令一样)。反过来,git subrepo pull
总是会将合并压缩(如git merge --squash
),这保持了超项目历史和子项目历史的分离,不会污染历史的图谱。所有这些可以在图 11.2中看到。注意,图中(c)中的虚线表示的是C2和C4提交的生成方式,而不是父提交。
显示子树与其上游之间的差异
要查找子项目与工作目录中当前版本之间的差异,你需要使用非常规选择器语法来执行git diff
。这是因为子项目中的所有文件(例如,在mylib_repo/master
远程跟踪分支中)都位于根目录下,而在超项目中(例如,在master
中)则位于mylib/
目录下。我们需要选择要与master
进行比较的子目录,将其放在修订标识符和冒号之后(跳过它意味着将与超项目的根目录进行比较)。
命令如下所示:
$ git diff master:mylib mylib_repo/master
类似地,要检查在子树合并之后,我们刚刚创建的提交(HEAD
)是否与合并后的提交中的mylib/
目录中的内容相同,即HEAD²
,我们可以使用以下命令:
$ git diff HEAD:mylib HEAD²
将更改发送到子树的上游
在某些情况下,子项目的子树代码只能在容器代码中使用或测试;大多数主题和插件都有这样的限制。在这种情况下,你将被迫直接在主项目代码库中发展你的子树代码,然后最终将其回传到子项目的上游。
这些变更通常需要对超级项目代码做出调整;尽管建议将子树代码变更和其他部分的变更分别提交(一个用于子树代码变更,另一个用于其他部分),但这并非严格必要。你可以告诉 Git 只提取子项目的变更。问题在于拆分的变更提交信息,因为 Git 无法自动提取变更集描述的相关部分。
另一个常见的情况是,虽然最好避免,但有时需要根据容器特定的方式自定义子项目代码(特别为主项目进行配置),通常不将这些变更推送回上游。你应该小心区分这两种情况,并将每个用例的变更(可回溯和不可回溯)保存在它们各自的提交中。
处理这个问题有不同的方式。你可以通过要求所有子树的变更必须在单独的模块仓库中进行,从而避免提取变更并将其提交到上游的问题。如果可能,我们甚至可以要求所有子项目的变更必须先提交到上游,然后只有通过上游接受,我们才能将这些变更合并到容器中。
如果你需要能够提取子树的变更,那么一种可能的解决方案是使用git filter-branch --directory-filter
(或使用带有适当脚本的--index-filter
)。另一种简单的解决方案是直接使用git subtree push
。然而,这两种方法都会回溯每一个触及相关子树的提交。
如果你只想将那些已经进入主项目仓库的子项目变更中的一部分发送到上游,那么解决方案就稍微复杂一些。一个可能的方法是创建一个本地分支,专门用于从子项目远程跟踪分支回溯。将其从该子树跟踪分支中分叉意味着它将以子树为根,并且只会包含子模块文件。
这个用于回溯子项目变更的分支需要在子项目上游仓库的远程中具有适当的分支作为上游分支。通过这种设置,我们就能够使用git cherry-pick --strategy=subtree
将我们希望发送到子项目上游的提交应用到这个分支上。然后,我们可以简单地使用git push
将这个分支推送到子项目的仓库中。
樱桃挑选和子模块
即使cherry-pick在没有它的情况下也能正常工作,最好还是指定--strategy=subtree,以确保子项目目录外的文件(即子树外的文件)会被安静地忽略。这可以用来从混合提交中提取子树的变更;如果没有这个选项,Git 将拒绝完成樱桃挑选。
这比普通的git push
需要更多的步骤。幸运的是,你只需要在将超级项目仓库中的更改推送回子项目时面对这个问题。正如你所见,将子项目的更改获取到超级项目中要简单得多。
好吧,使用git-stree
会让这变得微不足道;你只需要列出要推送到回退的提交即可:
$ git stree push mylib_repo master~3 master~1
• 5e28a71 [To backport] Support for creating debug symbols
• 5b0aa4b [To backport] Timestamping (requires application tweaks)
✔︎ STree 'mylib_repo' successfully backported local changes to its remote
事实上,这个工具在内部使用相同的技术,为子项目创建并使用一个专门的回退本地分支。
Git 子模块解决方案——仓库中的仓库
将子项目的代码(以及可能的历史)导入到超级项目中的子树方法有其缺点。在许多情况下,子项目和容器是两个不同的项目:你的应用程序依赖于该库,但显然它们是独立的实体。将两者的历史合并并不是最好的解决方案。
此外,子项目的嵌入代码和导入历史始终存在。因此,子树技术不适用于可选依赖项和组件(如插件或主题)。它也不允许你对子项目的历史设置不同的访问控制,可能唯一的例外是通过使用 Git 仓库管理解决方案,如gitolite
(你可以在第十四章,Git 管理中找到更多内容),来限制对子项目(实际上是子项目子目录)的写访问权限。
子模块解决方案是将子项目的代码和历史保存在其自己的仓库中,并将该仓库嵌入到超级项目的工作区中,但不会将其文件作为超级项目的文件添加。
Git 链接、.git 文件和 git 子模块命令
Git 包括一个名为git submodule
的命令,旨在与子模块一起使用。然而,要正确使用它,你需要理解至少一些它的操作细节。它是两种不同功能的结合:所谓的git submodule
工具本身。
无论是子树解决方案还是子模块解决方案,子项目都需要包含在超级项目工作目录中的一个单独文件夹中。但是,在使用子树时,子项目的代码属于超级项目仓库,而在子模块中则不是这种情况。使用子模块时,每个子项目有自己在容器仓库中的某个地方的仓库。子模块的代码属于其自己的仓库,超级项目本身仅存储获取子项目文件相应修订版所需的元信息。
实际上,在现代 Git 中,子模块使用一个简单的 .git
文件,其中包含一个包含实际仓库文件夹相对路径的 gitdir:
行。子模块仓库实际上位于超级工程的 .git/modules
文件夹内(并且 core.worktree
已适当设置)。这样做主要是为了处理当超级工程有些分支根本没有子模块的情况。它允许我们在切换到没有子模块的超级工程版本时,避免必须丢弃子模块的仓库。
提示
你可以将包含 gitdir:
行的 .git 文件视为等效的符号引用,作为 .git 目录的操作系统独立符号链接替代品。仓库的路径不一定需要是相对路径:
$ ls -aloF plugins/demo/
total 10
drwxr-xr-x 1 user 0 Jul 13 01:26 ./
drwxr-xr-x 1 user 0 Jul 13 01:26 ../
-rw-r--r-- 1 user 32 Jul 13 01:26 .git
-rw-r--r-- 1 user 9 Jul 13 01:26 README
[…]
$ cat plugins/demo/.git
gitdir: ../../.git/modules/plugins/demo
尽管如此,包含的超级工程和子工程模块实际上充当(并且实际上是)独立的仓库:它们有自己的历史记录、自己的暂存区和自己的当前分支。因此,在输入命令时,你应该小心,注意自己是在子模块内部还是外部,因为命令的上下文和影响差异很大!
使用子模块的主要思想是超级工程的提交记住子工程的 确切 修订版;此引用使用子工程提交的 SHA1 标识符。与某些依赖管理工具中使用清单文件的方式不同,子模块解决方案将此信息存储在一个树对象中,使用所谓的 gitlinks。Gitlink 是从超级工程仓库中的 树对象 到子模块仓库中的 提交对象 的引用;见 图 11.3。左侧子模块文件的淡色阴影表示它们作为文件存在于超级工程的工作目录中,但并不在超级工程仓库中。
图 11.3 – 一个超级工程的历史,子工程作为子模块链接在‘maps/’子目录中。子工程历史是独立的
回想一下,在第十章《保持历史干净》一节中描述的仓库数据库中对象的类型,每个提交对象(表示项目的一个修订版)准确地指向一个树对象,该树对象包含仓库内容的快照。每个树对象引用 blobs 和 trees,分别表示文件内容和目录内容。提交对象引用的树对象唯一标识与该提交对象相关联的修订版中包含的文件内容、文件名和文件权限集。
让我们记住,提交对象本身是彼此连接的,形成了有向无环图(DAG)的修订历史。每个提交对象都引用零个或多个父提交,它们共同描述了一个项目的历史。
前面提到的每种引用类型都参与了可达性检查。如果指向的对象缺失,则意味着仓库已损坏。
对于 gitlink 来说情况并非如此。指向提交的树对象条目指向的是另一个独立仓库中的对象,即子项目(子模块)仓库中的对象。子模块提交无法访问并不是错误,这一点使我们可以选择性地包含子模块:没有子模块仓库,也就没有在 gitlink 中引用的提交。
在一个包含所有类型对象的项目上运行 git ls-tree --abbrev HEAD
的结果如下:
040000 tree 573f464 docs
100755 blob f27adc2 executable.sh
100644 blob 1083735 README.txt
040000 tree ef9bcb4 subdirectory
160000 commit 5b0aa4b submodule
120000 blob 3295d66 symlink
将其与工作区的内容进行比较(使用 ls -l -o -F
):
drwxr-xr-x 5 user 12288 06-28 17:18 docs/
-rwxr-xr-x 1 user 36983 02-20 20:11 executable.sh*
-rw-r--r-- 1 user 2628 2015-01-03 README.txt
drwxr-xr-x 3 user 4096 06-28 17:19 subdirectory/
drwxr-xr-x 48 user 36864 06-28 17:19 submodule/
lrwxrwxrwx 1 user 32 06-28 17:18 symlink -> docs/toc.html
将子项目作为子模块添加
要管理子模块,可以使用 git submodule
命令。它的创建是为了帮助管理子模块的文件系统内容、元数据和配置,并检查其状态以及进行更新。
对于子树来说,第一步通常是将子项目仓库添加为远程,这意味着从子项目仓库中获取对象到超项目的对象数据库中。
要将给定的仓库作为子模块添加到超项目的特定目录中,请使用 git submodule
的 add
子命令:
$ git submodule add https://git.example.com/demo-plugin.git plugins/demo
Cloning into 'plugins/demo'...
done.
关于通过路径添加子项目到其仓库的说明
当使用路径而非 URL 作为远程地址时,需记住远程的相对路径是相对于我们的主远程地址解释的,而不是相对于我们仓库的根目录。
此命令将子模块的信息(例如仓库的 URL)存储在 .gitmodules
文件中。如果 .gitmodules
文件不存在,它会创建一个新的文件:
[submodule "plugins/demo"]
url = https://git.example.com/demo-plugin.git
请注意,子模块的名称等于其路径。你可以通过 --name
选项显式设置名称(或通过编辑配置);在子模块目录上执行 git mv
会改变子模块路径,但保持相同的名称。
获取子模块时的身份验证重用
在存储远程仓库的 URL 时,通常可以接受并且有用的是将用户名与子项目信息一起存储(例如,将用户名存储在 URL 中,如 user@git.company.com:mylib.git)。
然而,将用户名作为 URL 一部分来存储在 .gitmodules 中是不推荐的,因为该文件必须对其他开发人员可见(他们通常会使用不同的用户名进行身份验证)。幸运的是,深入子模块的命令可以重用克隆(或获取)超项目时的身份验证。
add
子命令还会为你执行等效的 git submodule init
,假设你添加了一个子模块,那么你肯定对它感兴趣。这个操作会将一些子模块特定的设置添加到主项目的本地配置中:
[submodule "plugins/demo"]
url = https://git.example.com/demo-plugin.git
为什么会有重复?为什么要将相同的信息存储在 .gitmodules
和 .git/config
中?原因是,虽然 .gitmodules
文件是为所有开发者设计的,但我们可以根据具体的本地情况调整本地配置。使用两个不同文件的另一个原因是,.gitmodules
文件中包含的子模块信息仅表示子项目可用,而 .git/config
中的子模块信息则意味着我们对某个特定的子模块感兴趣(并且希望它存在)。
你可以手动创建并编辑 .gitmodules
文件,或者使用 git config -f .gitmodules
来进行编辑。
这个文件通常会被提交到超级项目仓库(类似于 .gitignore
和 .gitattributes
文件),它的作用是列出所有可能的子项目。
所有其他子命令都要求 .gitmodules
文件必须存在;例如,如果在添加子模块之前运行 git submodule update
,你将得到以下结果:
$ git submodule update
No submodule mapping found in .gitmodules for path 'plugins/demo'
这就是为什么 git submodule add
命令会同时暂存 .gitmodules
文件和子模块本身:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: .gitmodules
new file: plugins/demo
请注意,整个子模块(它是一个目录)在 git status
中看起来就像是一个新文件。默认情况下,大多数 Git 命令仅限于操作活动容器仓库,并不会递归进入子模块的嵌套仓库。正如我们将看到的,这一行为是可以配置的。
克隆带有子模块的超级项目
一个重要的问题是,默认情况下,如果你克隆超级项目仓库,你将无法获取任何子模块。所有的子模块都不会出现在工作复制目录中;这里只有它们的基础目录。这种行为正是子模块可选性的基础。
然后我们需要告诉 Git 我们对某个特定的子模块感兴趣。我们通过调用 git submodule init
命令来实现这一点。这个命令的作用是将子模块的设置从 .gitmodules
文件复制到超级项目的仓库配置中,即 .git/config
,并注册该子模块:
$ git submodule init plugins/demo
Submodule 'plugins/demo' (https://git.example.com/demo-plugin.git) registered for path 'plugins/demo'
init
子命令会将以下两行添加到 .git/config
文件中:
[submodule "plugins/demo"]
url = https://git.example.com/demo-plugin.git
这个单独的本地配置允许你为你感兴趣的子模块配置不同的 URL 地址(可能是某个子项目仓库的公司专用克隆地址),与 .gitmodules
文件中指定的 URL 地址不同。
这个机制还使得在子项目的仓库发生移动时,能够提供一个新的 URL。这就是为什么本地配置会覆盖 .gitmodules
中记录的配置;否则,当切换到 URL 变更之前的版本时,你将无法从当前 URL 获取内容。另一方面,如果仓库发生了移动,并且 .gitmodules
文件已相应更新,我们可以通过 git submodule sync
将新的 URL 从 .gitmodules
提取到本地配置中。
我们已经告诉 Git 我们关注的是给定的子模块。但是,我们仍然没有从其远程仓库获取子模块提交,也没有将其检出并让其文件出现在超级项目的工作目录中。我们可以通过 git submodule update
来实现这一点。
快捷命令
实际上,在使用仓库处理子模块时,我们通常将这两个命令(init 和 update)合并为一个命令 git submodule update --init;除非我们需要自定义 URL。
如果你对所有子模块感兴趣,可以使用 git clone --recursive
(或 git clone --recurse-submodules
)在克隆后自动初始化并更新每个子模块。
要临时移除一个子模块,并保留稍后恢复它的可能性,你可以通过 git remote deinit
将其标记为不感兴趣。这只会影响 .git/config
。要永久移除一个子模块,你需要先取消初始化它,然后从 .gitmodules
和工作区中移除它(使用 git rm
)。
更新超级项目更改后的子模块
要更新子模块,使工作目录中的内容反映当前超级项目版本中子模块的状态,你需要执行 git submodule update
。这个命令会更新子项目的文件,或者如果需要的话,克隆初始的子模块仓库:
$ rm -rf plugins/demo # clean start for this example
$ git submodule update
Submodule path 'plugins/demo': checked out '5e28a713d8e87…'
git submodule update
命令会进入 .git/config
中引用的仓库,获取索引中找到的提交 ID(git ls-tree HEAD -- plugins/demo
),并将此版本检出到 .git/config
中指定的目录。当然,你可以指定要更新的子模块,作为参数提供子模块的路径。
因为我们在这里检查的是由 gitlink
提供的修订版本,而不是一个分支,git submodule update
会使子项目的 HEAD
脱离(参见 图 11.3)。这个命令会将子项目直接回滚到超级项目中记录的版本。
还有一些你需要了解的其他事项:
-
如果你以任何方式更改了超级项目的当前修订版本,无论是通过更改分支、使用 git pull 导入分支,还是通过 git reset 回滚历史记录,你都需要运行 git submodule update 来获取与子模块匹配的内容。默认情况下不会自动执行此操作,因为这可能导致子模块中的工作丢失。
-
相反,如果你切换到另一个分支,或者以其他方式更改了超项目中的当前修订版,并且没有运行git submodule update,Git 会认为你故意更改了子模块目录,以指向一个新的提交(尽管它实际上是你之前使用过的旧提交,但你忘记更新了)。如果在这种情况下你运行了git commit -a,那么你可能会意外更改 gitlink,从而导致超项目历史中存储了错误版本的子模块。
-
你可以通过在子项目中使用普通的 Git 命令来获取(或切换到)你想要的子模块版本,然后在超项目中提交这个版本,从而轻松升级 gitlink 引用。在这里不需要使用git submodule命令。
你可以在从主项目的远程仓库拉取更新时,让 Git 自动获取初始化的子模块。这一行为可以通过fetch.recurseSubmodules
(或submodule.<name>.fetchRecurseSubmodules
)进行配置。该配置的默认值是on-demand
(当 gitlink 更改且其指向的子模块提交丢失时进行获取)。你可以将其设置为yes
或no
,以无条件开启或关闭递归获取子模块。对应的命令行选项是--recurse-submodules
。
你可以将--recurse-submodules
命令行选项传递给许多 Git 命令,包括git pull
命令,这样它就会获取已初始化的模块并更新活动子模块的工作树。
始终递归进入活动子模块
要使支持该功能的 Git 命令默认使用--recurse-submodules选项,可以将submodule.recurse配置选项设置为true。checkout、fetch、grep、pull、push、read-tree、reset、restore和switch命令都支持此选项。
请注意,我们可以使用--merge
将超项目中记录的提交合并到子模块的当前分支,或者使用--rebase
将当前分支重基于 gitlink 上,就像使用git pull
一样,而不是在分离的HEAD
上检出 gitlinked 修订版。默认使用的子模块仓库分支是master
,但可以通过在.gitmodules
或.git/config
中设置submodule.<name>.branch
选项来覆盖分支名称,以后者为优先。
如你所见,使用 gitlink 和git submodule命令相当复杂。从根本上讲,gitlink 的概念可能很适合子项目和超项目之间的关系,但正确使用这些信息比你想象的要困难。另一方面,它提供了巨大的灵活性和强大功能。
检查子模块中的更改
默认情况下,状态、日志和diff
输出仅基于活动仓库的状态,并不深入子模块。这通常是个问题;你需要记住运行git submodule summary
。如果你仅限于这种视图,很容易错过回归:你可以看到子模块已经发生变化,但却看不到具体如何变化。
然而,你可以设置 Git,使其使用status.submoduleSummary
配置变量。如果设置为非零数字,则该数字将提供--summary-limit
限制;值为true
或-1
表示无限制。
设置此配置后,你会得到如下内容:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: .gitmodules
new file: plugins/demo
Submodule changes to be committed:
* plugins/demo 0000000...5e28a71 (3):
> Fix repository name in a README file
状态扩展了始终存在的信息,表明子模块发生了变化(plugins/demo
有三个新的提交,并显示最后一个提交的摘要(>
)标记在摘要行之前,意味着该提交已被添加,即存在于工作区中,但还未在超级项目的提交中)。
小知识
实际上,这部分添加的内容就是git submodule summary输出。
对于相关的子模块,将列出给定超级项目的提交中子模块版本与索引或工作树中的子模块版本之间的一系列提交(前者通过使用--cached
显示)。此外,还有git submodule status
命令,提供每个模块的简短信息。
git diff
命令的默认输出也不会提供关于子模块更改的详细信息,只是告诉你它有所不同:
$ git diff HEAD -- plugins/demo
diff --git a/plugins/demo b/plugins/demo
new file mode 160000
index 0000000..5e28a71
--- /dev/null
+++ b/plugins/demo
@@ -0,0 +1 @@
+Subproject commit 5e28a713d8e875f2cf1060c2580886dec3e5b04c
幸运的是,有一个--submodule=log
命令行选项(你可以通过diff.submodule
配置设置默认启用)可以让我们看到一些更有用的信息:
$ git diff HEAD --submodule=log -- plugins/demoSubmodule subrepo 0000000...5e28a71 (new submodule)
我们可以不使用log
,而使用short
格式,仅显示提交的名称,这也是默认格式(即使用git diff --submodule
时)。或者,我们可以使用diff
格式,显示子模块更改内容的内联diff
。
从子模块的上游获取更新
提醒一下,子模块的提交通过 gitlinks 使用 SHA1 标识符进行引用,该标识符始终指向相同的修订版本;它不是一个易变(不稳定)的引用,比如分支名称。因此,超级项目中的子模块不会自动升级(这可能会破坏应用程序)。然而,有时你可能想要将子项目更新到它的上游版本。
假设子项目仓库发布了新的修订版本,并且对于我们的超级项目,我们想要更新到子模块的新版本。
为了实现这一点,我们需要更新子模块的本地仓库,将我们想要的版本移到超级项目的工作目录中,最后在超级项目中提交子模块的更改。
我们可以手动完成这个过程,首先将当前目录更改为子模块工作目录。然后,在子模块中,执行 git fetch
以获取到本地克隆库中的数据(在超级项目的 .git/modules/
中)。在通过 git log
验证我们所拥有的内容后,我们可以更新工作目录。如果没有本地更改,你可以简单地检出所需的修订版本。最后,需要在超级项目中创建一个提交。
除了更精细的控制,这种方法还有额外的好处,即使在当前状态下也可以工作(无论你是在活动分支上还是在分离的HEAD
上)。
另一种方法是,从容器存储库开始,使用 git submodule update --remote
显式将子模块升级到其跟踪的远程分支。与普通的更新命令类似,你可以选择合并或变基而不是检出分支;你可以使用 submodule.<name>.update
配置变量配置更新的默认方式,并使用 submodule.<name>.branch
配置默认上游分支。
git submodule update 的变种
简而言之,submodule update --remote --merge 将上游的子项目更改合并到子模块中,而 submodule update --merge 将超级项目的 gitlink 更改合并到子模块中。
git submodule update --remote
命令会自动从子模块远程站点获取新的更改,除非使用 --no-fetch
明确告知不要这样做。
将子模块更改发送到上游
直接在子模块中进行更改(而不是通过其独立的存储库)的主要危险之一是忘记推送子模块。对于子模块的一个良好实践是首先提交子模块的更改,推送模块更改,然后再回到容器项目,提交并推送容器更改。
如果你只推送到超级模块存储库,忘记了子模块的推送,那么其他开发者在尝试获取更新时会遇到错误。虽然 Git 在获取超级项目时不会抱怨,但你会在 git submodule summary
输出(如果正确配置的话还有 git status
输出)以及尝试更新工作区时看到问题:
$ git submodule summary
* plugins/demo 12e3a52...0e90143:
Warn: plugins/demo doesn't contain commit 12e3a529698c519b2fab790…
$ git submodule update
fatal: reference is not a tree: 12e3a529698c519b2fab790…
Unable to checkout '12e3a529698c519b2fab790…' in submodule path 'plugins/demo'
显然你可以看出记得推送子模块是多么重要。如果需要,你可以请求 Git 在推送超级项目时自动推送子模块,使用命令 git push --recurse-submodules=on-demand
(另一个选项是仅检查)。你还可以使用 push.
recurseSubmodules
配置选项。
将子文件夹转换为子树或子模块
在思考 Git 中子项目的用例时,首先考虑的问题之一是准备好基本项目的源代码以进行这种分割。
子模块和子树始终表示为超级项目(主项目)的子目录。您不能在一个目录中混合来自不同子系统的文件。
经验表明,大多数系统即使在单体存储库中也使用这样的目录层次结构,这对模块化工作是一个很好的开端。因此,将子文件夹转换为真正的子模块/子树相当容易,并可以通过以下步骤序列完成:
-
将问题子目录移出超级项目的工作区以将其放置在超级项目顶级目录旁边。如果保留子项目的历史记录很重要,请考虑使用 git subtree split,或者 git filter-branch --subdirectory-filter 或其等效方法,也许还可以结合 reposurgeon 等工具清理历史记录。有关更多详细信息,请参阅 第十章,保持历史干净。
-
将包含子项目存储库的目录重命名,以更好地表达提取组件的本质。例如,原始命名为 refresh 的子目录可以重命名为 refresh-client-app-plugin。
-
创建子项目的公共存储库(上游)作为一流项目(例如,在 GitHub 上创建一个新项目以保存提取的代码,可以在与超级项目相同的组织下,也可以在专门的应用程序插件组织下)。
-
使用 git init 将自包含的独立插件初始化为 Git 存储库。如果在 第 1 步 中将子目录的历史记录提取到某个分支中,则将此分支推送到刚创建的存储库。设置在 第 3 步 中创建的公共存储库作为默认远程存储库,并将初始提交(或整个历史记录)推送到刚创建的 URL 以存储子项目代码。
-
在超级项目中,阅读您刚刚提取的子项目,但这次,作为一个合适的子模块或子树,无论哪种解决方案更合适,也无论您更喜欢使用哪种方法。使用刚创建的子项目的公共存储库的网址。
-
将超级项目中的更改提交并推送到其公共存储库,在子模块的情况下,包括新创建(或刚修改的).****gitmodules 文件。
将子目录转换为独立子模块的推荐实践是使用只读 URL 进行克隆(添加回)子模块。这意味着您可以使用 git://
协议(警告:在这种情况下,服务器未经身份验证)或者没有用户名的 https://
。此建议的目标是通过将子模块代码移动到独立的子项目存储库来强制实现分离。为确保所有其他开发者都能访问子模块提交,每个更改都应通过子项目的公共存储库进行。
如果这个建议(最佳实践)被明确拒绝,实际上,你可以直接在超级项目内处理子项目的源代码,尽管这更容易出错。你需要记得首先在子模块内提交和推送,在嵌套子模块子目录内执行;否则,其他开发者将无法获取更改。这种结合的方法可能更简单,但它失去了在使用子模块时应当更好地假设的实现和消费更改之间的真正分离。
子树与子模块的对比
一般来说,子树比子模块更容易使用,且不容易出错。很多人选择使用子模块,因为它们有更好的内建工具(拥有自己的 Git 命令,即git submodule
)、详细的文档,并且与 Subversion 外部引用类似,给人一种虚假的熟悉感。添加子模块非常简单(只需运行git submodule add
),尤其是相比于在没有第三方工具(如git subtree
或git subrepo
)帮助的情况下添加子树。
子树和子模块的主要区别在于,子树只有一个仓库,这意味着只有一个生命周期。而子模块和类似的解决方案使用嵌套仓库,每个仓库都有自己的生命周期。
虽然子模块设置简单且灵活性较强,但它们也充满了风险,在使用时需要保持警惕。子模块是可选的,这意味着涉及子模块的更改需要每个协作者手动更新。子树始终存在,因此获取超级项目的更改也意味着获取子项目的更改。
命令如status
、diff
和log
关于子模块的信息显示非常有限,除非正确配置以跨越仓库边界;否则很容易错过更改。使用子树时,status
正常工作,而diff
和log
需要一些小心,因为子项目的提交有不同的根目录。后者假设你没有决定不包含子项目历史(通过压缩子树合并)。如果有远程跟踪分支存在于子项目的仓库中,那么问题只会出现在远程跟踪分支上。
由于不同仓库的生命周期是独立的,在包含项目内更新子模块需要两个提交和两个推送。而更新一个子树合并的子项目非常简单:只需要一个提交和一个推送。另一方面,通过子模块发布子项目的更改更容易,而使用子树时则需要提取更改集(此时,git subtree
等工具非常有帮助)。
下一个主要问题,也是问题的根源,是子模块有两个当前版本来源:超项目中的 gitlink 和子模块克隆仓库中的分支。这意味着 git remote update
有点像将更改推送到非裸仓库(见 第八章,高级分支技术)。因此,子模块的头指针通常是分离的,所以任何本地更新都需要进行各种预处理操作,以避免创建丢失的提交。子树没有这种问题。所有修订更改命令在子树中照常工作,将子项目目录更新到正确版本,而无需任何额外操作。从子项目仓库获取更改只需要一次子树合并。唯一与普通拉取的不同之处是 -s
subtree
选项。
然而,有时子模块(submodules)是正确的选择。与子树相比,子模块允许不获取子项目(模块),这在你的代码库庞大时非常有用。当开发栈的生态系统没有原生处理或没有良好原生处理重度模块化时,子模块也很有用。
子模块本身也可能是其他子模块的超项目,创建一个子项目层级结构。使用嵌套子模块变得更容易,因为 git submodule status
、update
、foreach
和 sync
等子命令都支持 --recursive
开关。
子树的使用场景
对于子树(subtrees),只有一个代码库(没有嵌套的代码库),就像一个普通的代码库。这意味着只有一个生命周期。子树的一个关键好处是能够将特定容器的自定义与通用的修复和增强功能结合起来。
项目可以以任何你认为最符合逻辑的一种方式进行组织和分组。使用单一仓库还可以减少管理依赖关系的开销。
使用子树的基本示例是管理库的定制版本,一个必需的依赖项。设置开发环境以运行构建和测试非常简单。Monorepo 还使得为所有项目设定一个通用版本号成为可能。原子跨子模块提交是可行的,因此仓库始终可以保持一致的状态。
你还可以使用子树来嵌入相关项目,例如 GUI 或 Web 界面,进而嵌入到超项目中。实际上,子模块的许多用例也适用于子树解决方案,除了那些需要子项目可选,或者需要与主项目不同访问权限的情况。在这些情况下,你需要使用子模块。
Monorepo 的使用场景
如果所有子项目都由单一组织或公司管理,那么将所有这些相互关联的项目放入一个仓库中可能是有利的,这就是我们所说的 monorepo。
其中一个优势是简化的组织结构。你可以以任何你认为最具逻辑一致性的方式组织和分组项目。你不需要考虑如何将它们拆分为独立的代码库,也不需要考虑如何将它们合并成一个超级项目。如果所有内容都在单一代码库中,那么浏览历史记录和搜索内容也会更容易。
由于在单一代码库(monorepo)中可以进行跨项目的原子级提交,因此代码库始终保持一致状态。确保所有项目使用相同版本的特定组件变得更加容易。在多代码库设置(多个代码库,每个项目一个,使用子树或子模块策略管理)中进行跨代码库/跨项目的更改,要比在单一代码库中困难得多。
也更容易保持一致的工具链和共同的持续集成(CI)基础设施。
子模块的使用场景
使用子模块的最有力论据之一是模块化问题。在这里,子模块的主要使用领域是处理插件和扩展。一些编程生态系统,如 ANSI C、C++以及 Objective-C,缺乏对管理版本锁定的多模块项目的良好和标准化支持。在这种情况下,类似插件的代码可以通过子模块包含到应用程序(超级项目)中,而不会牺牲从其代码库轻松更新插件到最新版本的能力。传统的解决方案是将有关如何复制插件的说明放在 README 中,这样就会与历史元数据脱节。
这个方案也可以扩展到非编译代码,比如 Emacs Lisp 设置、dotfiles 中的配置(包括oh-my-zsh
等框架)和主题(也适用于 Web 应用程序)。在这些情况下,通常需要使用组件的是模块代码在主项目树中的常规位置,这一要求是由所使用的技术或框架规定的。例如,WordPress、Magento 等的主题和插件通常就是以这种方式安装的。在许多情况下,你需要处于超级项目中才能测试这些可选组件。
另一个子模块的特殊用例是基于访问控制和可见性限制来划分复杂应用程序。例如,项目可能使用具有许可证限制的加密代码,只允许少数开发者访问。通过将此代码放入一个访问受限的子模块中,其他开发者将无法克隆该子模块。在这种解决方案中,公共构建系统需要能够跳过加密组件,如果它不可用的话。另一方面,专用的构建服务器可以配置为使得客户端获取到已启用加密功能的应用程序。
一个类似的可见性限制目的,但反过来,是在计划发布之前很早就使示例的源代码可用。这可以通过社会反馈使代码变得更好。书籍的主仓库本身可以是私有的,但如果examples/
目录包含一个用作示例源代码的子模块,你可以使这个子仓库公开。在生成 PDF、EPUB(以及可能还有 MOBI)格式的书籍时,构建过程可以将这些示例(或其中的一部分)嵌入到书籍中,就像普通的子目录一样。
第三方子项目管理解决方案
如果你在git subtree
或git submodule
中找不到合适的工具,可以尝试使用许多第三方项目来管理依赖项、子项目或仓库集合。
其中一个工具是repo
(android.googlesource.com/tools/repo/
),它是安卓开源项目用来统一多个 Git 仓库以进行跨网络操作的工具。
另一个工具是gil
(gitlinks)(github.com/chronoxor/gil
),它用于管理复杂的递归仓库依赖关系,包括交叉引用和循环。与子模块相比,gil
避免了当主项目及其子项目使用相同的库作为依赖项时,重复包含相同的依赖项。这个工具还使得向上游贡献更改比使用git subtree
更加简便。
如果你需要将一个单一的单体仓库拆分成多个独立的仓库,除了git subtree split
,你还可以使用第三方工具splitsh-lite
。另一方面,如果你有多个独立的仓库,想要合并成一个单一的单体仓库(monorepo),你可以使用tomono
工具。
你可以找到许多其他类似的工具。
重要的考虑事项
在选择本地支持和众多工具之间管理多个仓库时,你应该检查相关工具是否使用类似子树或子模块的方法,以判断它是否适合你的项目。
总结
本章提供了你管理多组件项目所需的所有工具,涵盖了从库和图形界面,到插件和主题,再到框架的各个方面。
你了解了子树技术背后的概念,以及如何使用它来管理子项目。你知道如何创建、更新、检查和管理使用子树的子项目。
你已经了解了用于可选依赖项的嵌套仓库的子模块方法。你了解了 gitlinks、.gitmodules
和.git
文件背后的思想。你也遇到了使用子模块时需要保持警惕的陷阱和问题。你知道这些问题的原因,并理解其背后的概念。你知道如何创建、更新、检查和管理使用子模块的子项目。
你学会了何时使用子树和子模块,以及它们的优缺点。你了解了每种技术的一些用例。
现在你已经知道如何在各种情况下有效使用 Git,并了解了帮助你理解它的 Git 行为的高级思想,现在是时候解决如何在 第十三章,定制和扩展 Git。
问题
回答以下问题以测试您对本章内容的了解:
-
什么是子树,它们的优缺点是什么?
-
什么是子模块,它们的优缺点是什么?
-
为什么关于子模块的信息在 .gitmodules 和项目配置中重复?
答案
以下是上述问题的答案:
-
通过子树合并,子项目的历史记录(或其摘要)包含在超级项目存储库中,并且子项目文件直接放在超级项目的子目录中,并且是超级项目文件。子树只能用于必需的依赖关系,因为它们嵌入在超级项目中。它们更简单易懂且易于使用。
-
通过子模块,超级项目和子项目的存储库和历史记录是分开的。超级项目包含指向子项目提交的链接。子模块可以初始化并激活,但也可以保持非活动状态,因此它们可以用于可选依赖关系。要包含变更,您需要在子项目中进行更改,并将其包含在超级项目的提交中。
-
有关子模块的项目配置文件中的信息仅限于存储库,并在其他内容中定义哪些子模块处于活动状态以及哪些未处于活动状态。
进一步阅读
要了解本章涵盖的主题,请参阅以下资源:
-
git-submodule – 初始化、更新或检查子模块:
git-scm.com/docs/git-submodule
-
git-subtree – 将子树合并在一起并将仓库分割为子树:
github.com/git/git/blob/master/contrib/subtree/git-subtree.txt
-
Git 文档 HOWTO – 如何使用子树合并 策略:
github.com/git/git/blob/master/Documentation/howto/using-merge-subtree.txt
-
Scott Chacon,Ben Straub,《Pro Git》,第二版(2014)
git-scm.com/book/en/v2
- 第 7.11 章 Git 工具 - 子模块
-
Eric Pidoux,《Git 最佳实践指南》(2014),Packt Publishing Ltd
- 第四章,深入学习 Git,管理 Git 子模块
-
Johan Abildskov,《实用 Git:通过实践掌握自信的 Git》(2020),Apress
- 第八章,额外的 Git 特性 – Git 子模块
-
关于单仓库及其构建工具的一切:
monorepo.tools/
第十二章:管理大型仓库
由于其分布式特性,Git 在每个仓库副本中都包括完整的变更历史。每次克隆不仅获取所有文件,还会获取每个文件的所有修订版本。这使得开发变得高效(不涉及网络的本地操作通常足够快速,不会成为瓶颈)并且与他人协作也很高效(其分布式特性支持多种协作工作流)。
但当你想要处理的仓库非常庞大时会发生什么?我们能否避免占用大量磁盘空间进行版本控制存储?是否可以在克隆仓库时减少最终用户需要检索的数据量?我们是否需要所有文件都存在才能在项目中工作?
如果你考虑一下,你会发现仓库变得庞大的原因大致有三种:它们可能会积累非常长的历史(每个修订方向),它们可能包含需要与代码一起管理的大型二进制资产,项目可能包含大量文件(每个文件方向),或者这些原因的任何组合。对于这些情况,技术和解决方法是不同的,并且可以独立应用,尽管现代 Git 也包含了一站式解决方案。
子模块(在前一章中介绍的,管理子项目)有时用于管理大型资产。本章将介绍如何在处理大型二进制文件和其他大型资产时,使用子模块以及其他替代解决方案。
在本章中,我们将涵盖以下主题:
-
Git 和大型文件
-
使用浅克隆处理具有非常长历史的仓库
-
将大型二进制文件存储在子模块中或仓库外部
-
使用稀疏检出减少工作目录的大小
-
如何通过稀疏克隆缩小本地仓库的大小
-
不同变种的稀疏克隆中,哪些操作需要网络访问
-
使用文件系统监视器加速操作
Scalar – 适用于所有人的大规模 Git
配置 Git 以便更好地处理大型仓库的最简单方法,除了启用相关的 Git 功能外,就是使用内置的scalar
工具。这个可执行文件自 2022 年发布的 Git 2.38 版本以来一直存在,在此之前它是一个独立的项目,后来成为了 Microsoft 的 Git 分支的一部分。
使用它非常简单:你只需要用scalar clone
代替git clone
。如果仓库已经被克隆,你可以运行scalar register
来实现相同的效果。这个命令所做的一项工作是调度后台维护;你可以通过使用scalar unregister
命令停止这个过程,并将仓库从已注册的仓库列表中移除。scalar delete
命令会取消注册仓库并将其从文件系统中删除。
在scalar
升级后(可能是由于迁移到更新的 Git 版本),你可以运行scalar reconfigure --all
来升级所有使用 Scalar 注册的仓库。
通过使用 Scalar 注册仓库(或项目的顶级目录,在 Scalar 文档中称为登记),你可以开启部分克隆和稀疏检出,配置 Git 使用文件系统监控,并开启后台维护任务,如仓库预取。
所有这些功能将在接下来的章节中描述,还会介绍一些更具体针对用户需求的大型 Git 仓库处理功能。
处理具有非常长历史的仓库
尽管 Git 可以有效处理具有长历史的仓库,但非常古老的项目,跨越大量修订版本,可能会让克隆变得非常麻烦。在许多情况下,你对远古历史不感兴趣,也不想花费时间获取项目的所有修订版本以及存储它们的磁盘空间。在本节中,我们将讨论你可以用来克隆截断历史的技术,或者如何让 Git 在长历史的情况下仍然快速。
例如,如果你想提出一个新功能或修复一个 bug,你可能不想等到完整克隆完成,因为这可能需要一段时间。
在线编辑项目文件
一些 Git 仓库托管服务,比如 GitHub,提供一个基于网页的界面来管理仓库,包括浏览器内的文件管理和编辑。它们甚至可能会自动创建仓库的一个分叉,以便你可以编写并提出更改。
但基于网页的界面并不能涵盖所有内容,你可能在使用自托管仓库或不提供此功能的服务。
然而,修复这个 bug 可能需要在你的机器上运行git bisect
,在这里回归 bug 容易复现(请参见第四章,探索项目历史,了解如何使用二分法)。如果你时间和空间都紧张,可能想尝试执行浅克隆(在下面的小节中描述)或稀疏克隆(本章稍后描述)。
使用浅克隆来获取截断的历史
快速克隆并节省磁盘空间的简单解决方案是使用 Git 执行浅克隆。此操作允许你获取一个本地仓库副本,并将历史记录截断到特定的深度——即最新的修订版本数量。
怎么做呢?只需使用--depth
选项:
$ git clone --depth=1 https://git.company.com/project
前面的命令只克隆主分支的最新修订。这个技巧可以节省大量时间,并减轻服务器的负担。通常,浅克隆几秒钟就完成,而不是几分钟,这是一项显著的改进。如果你只对查看项目文件感兴趣,而不是整个历史(例如,查看 Git hooks 或 GitHub Actions 中的内容),这种方式非常有用——即,在构建结束后立即删除克隆的情况。
从 Git 1.9 版本开始,Git 即使在浅克隆的情况下,也支持拉取和推送操作,尽管仍需小心。你可以通过向git fetch
命令提供--depth=<n>
选项来更改浅克隆的深度(但请注意,深度增加的提交的标签不会被获取)。要将浅克隆仓库转换为完整仓库,请使用--unshallow
。
重要说明
由于浅克隆中的提交历史被截断,像git merge-base和git log这样的命令所显示的结果,与完全克隆的情况不同。如果你尝试访问克隆的深度之外的内容,就会发生这种情况。此外,由于 Git 服务器的优化方式,浅克隆仓库中的增量获取可能比完全克隆仓库中的获取花费更长时间。获取操作还可能意外地让仓库变得不再是浅克隆。
请注意,git clone --depth=1
仍然可能会获取所有分支和标签。如果远程仓库没有HEAD
,即没有选择主分支,就可能发生这种情况;否则,只有指定的单个分支的最新提交会被获取。长期存在的项目通常在其漫长历史中有许多版本发布。为了节省时间,你需要将浅克隆与下一个解决方案结合使用:分支限制。
使用现代 Git 时,使用部分克隆功能可能更有意义。
只克隆单个分支
默认情况下,Git 会克隆所有分支和标签(如果你想获取备注或替换,需显式指定)。你可以通过指定只想克隆单个分支来限制克隆的历史记录数量:
$ git clone --branch master --single-branch \
https://git.company.com/project
由于大多数项目历史(大部分修订的 DAG)在各分支之间是共享的,只有极少数例外,你可能不会发现使用此技巧时有太大的区别。
如果你不希望有分离的孤立分支,或者相反,只希望拥有一个孤立分支(例如,项目的网页分支,或者用于 GitHub Pages 的分支),这个功能可能会非常有用。当与非常浅的克隆(历史记录非常短,以至于大多数分支没有足够的时间进行合并)结合使用时,单分支克隆在节省磁盘空间方面表现良好。
在具有长期历史记录的仓库中加速操作
Git 在具有很长历史的仓库中加速的一个特点是 commit-graph 文件。使用此功能(从 Git 2.24 版本起默认启用),可以配置 Git 定期写入或更新一个辅助文件,该文件包含一个序列化的(且易于访问的)修订图。这样,查询项目历史的 Git 操作就会变得更快。
你可以通过将 core.commitGraph
配置变量设置为 false
来关闭此功能。如果需要刷新辅助文件,可以使用 git commit-graph
write
命令来实现。
避免做繁重的工作
一个可能在历史较长时变慢的意外地方是运行 git status。这是因为该命令会计算当前分支的详细 ahead/behind 计数(即本地分支领先上游远程分支多少个提交,远程仓库落后多少个提交)。
你可以通过 --no-ahead-behind 选项或将 status.aheadBehind 配置变量设置为 false 来关闭计算这些信息。现在,git status 在被 ahead/behind 计算拖慢时会显示此提示。
处理包含大型二进制文件的仓库
在某些特定情况下,你可能需要在代码库中跟踪 巨大的二进制资产。例如,游戏团队需要处理庞大的 3D 模型,网页开发团队可能需要跟踪原始图像资产或 Photoshop 文档。游戏开发和网页开发都可能需要将视频文件纳入版本控制。此外,有时你可能希望包含那些难以或成本较高的生成的大型二进制交付文件——例如,存储虚拟机镜像的快照。
有一些调整可以改进 Git 对二进制资产的处理。对于从版本到版本变化很大的二进制文件(而不仅仅是某些元数据头部的变化),你可能希望在 .gitattributes
文件中显式关闭 -delta
(参见 第三章,管理你的工作树,以及 第十三章,定制和扩展 Git)。Git 会自动对任何超过 core.bigFileThreshold
大小的文件关闭增量压缩,默认值为 512 MiB。你可能还希望关闭压缩(例如,如果文件已经是压缩格式)。但是,由于 core.compression
和 core.looseCompression
对整个仓库都是全局设置,因此如果二进制资产位于单独的仓库(子模块)中,会更有意义。
将二进制资产文件夹拆分为单独的子模块
处理大规模二进制资源文件夹的一种可能方法是将其拆分到一个单独的仓库中,并将资源作为子模块拉取到你的主项目中。使用子模块可以让你控制何时更新资源。此外,如果开发者不需要这些二进制资源来工作,他们可以简单地排除包含资源的子模块,从而避免拉取这些资源。
限制是你需要为这些巨大的二进制资源准备一个单独的文件夹。如果选择这种方式进行处理,托管包含这些大资源的子模块仓库的服务还需要能够存储这些大文件;许多 Git 托管网站对单个文件或整个仓库的最大大小设置了严格的限制。
将大规模二进制文件存储在仓库之外
另一种解决方案是使用许多第三方工具之一,这些工具尝试解决在 Git 仓库中处理大二进制文件的问题。它们中的许多使用类似的方案,即将庞大的二进制文件内容存储在仓库外部,同时提供某种方式的指针来引用检出的内容。
每种实现有三个部分:
-
它们如何在仓库内存储关于管理文件内容的信息
-
它们如何在团队之间共享大规模二进制文件
-
它们如何与 Git 集成(以及它们的性能惩罚)
在选择解决方案时,你需要考虑这些数据以及操作系统支持、易用性和社区的大小。
仓库中存储的内容和被检入的内容可能是文件的符号链接或密钥,或者可能是一个指针文件(通常是纯文本),它作为实际文件内容的引用(通过名称或文件内容的加密哈希)。被跟踪的文件需要存储在某种后端中以便协作(云服务、rsync、共享目录等)。后端可以由客户端直接访问,或者可能有一个单独的服务器,提供一个定义好的 API,供二进制文件(blob)写入,并将存储卸载到其他地方。
该工具可能要求使用单独的命令来检出和提交大文件,或从后端获取和推送文件,或者它可能已经集成到 Git 中。集成解决方案使用clean
/smudge
过滤器透明地处理检出和检入操作,并使用pre-push
钩子将大文件内容一同透明地推送。你只需要指定要跟踪的文件,并且当然需要初始化仓库以便工具使用。
基于过滤器的方法的优点在于其易用性;然而,由于该方法的工作方式,它会带来性能上的惩罚。使用单独的命令来处理大规模二进制资源会让学习曲线稍微陡峭,但它提供了更好的性能。一些工具同时提供这两种接口。
在不同的解决方案中,有git-annex,它拥有一个庞大的社区,并支持各种后端,还有Git 大文件存储(Git-LFS),由 GitHub 创建,提供了良好的 Microsoft Windows 支持、客户端-服务器模式,并具有透明性(支持基于过滤器的方式)。Git-LFS 扩展不仅由 GitHub 支持,其他 Git 托管站点和软件平台,如 GitLab、Bitbucket 和 Gitea,也都支持 Git-LFS。也存在专门的服务和项目来实现 Git-LFS。
还有许多其他类似的工具,但这两种是最流行的,而且都仍在维护中。
数据分析和机器学习的数据文件版本控制
机器学习项目通常处理大文件或大量文件。这些文件包括原始数据集,但也包括各种预处理步骤的结果,以及训练后的模型。
你希望将这些大文件或目录存储到某个地方,以避免需要重新下载或重新计算它们。另一方面,你也希望能够从头开始重建一切,以使科学研究具有可重复性。这些需求与典型的软件项目中处理大资产所遇到的需求有很大不同,后者需要专门的解决方案来整合数据处理和版本控制。在这些解决方案中,有数据版本控制(DVC)和Pachyderm。
处理包含大量文件的仓库
单一仓库(monorepo)使用的增多(这一概念在第十一章**《管理子项目》中有详细解释)导致了对处理大量文件的仓库的需求。在一个单一仓库中——即由多个相互关联的子项目组成的仓库——你通常会专注于一个子项目,并仅在特定的子目录中访问和修改文件。
使用稀疏检出限制工作目录中文件的数量
Git 包含了 core.sparseCheckout
配置变量,并将其设置为 true
,同时使用 .git/info/sparse-checkout
文件,采用类似 gitignore 的语法来指定工作目录中要出现的内容。索引(也称为暂存区)被完全填充,对于缺少的文件,设置了 skip-worktree
标志。
虽然如果你有一个庞大的文件夹树,这种方式可能会有所帮助,但它并不会影响本地仓库本身的整体大小。为了减少仓库的大小,必须结合使用稀疏克隆(稍后会介绍)。
然而,稀疏检出定义非常通用。这使得该功能非常灵活,但代价是对于大规模定义和大量文件来说性能较差。对于单一仓库,你不需要这种灵活性,因为每个子项目都包含在自己的子目录中——你只需要在稀疏检出定义中匹配目录即可。
要实现这一点,你需要使用已经弃用的 sparse-checkout
功能(例如,参见 git sparse-checkout
命令的文档)。这种模式的额外优点是它更容易使用。所有操作都由 git sparse-checkout
命令管理。
要将你的工作目录限制为特定目录集,运行以下命令:
$ git sparse-checkout set <directory_1> <directory_2>
该功能的早期版本要求你先运行 git sparse-checkout init --cone
,但现在不再需要使用此命令,init
子命令本身也正在被弃用。
提示
如果你正在克隆一个包含大量文件的仓库,你可以通过使用 --no-checkout 或 --sparse 选项来避免将文件填充到工作目录中(第二个选项只会签出项目顶级目录中的文件)。你还可以添加 --filter=blob:none 选项以获得更快的速度(启用无 blob 的稀疏克隆)。
在任何时候,你都可以使用以下命令检查哪些目录被包括在你的 sparse-checkout
定义中,并存在于你的工作目录中:
$ git sparse-checkout list
你可以通过 add
子命令将一个新目录添加到现有的稀疏签出中,如下所示的示例所示:
$ git sparse-checkout add <new_directory>
在撰写本文时,尚无 remove
子命令。要从已签出的文件列表中删除一个目录,你需要编辑 .git/info/sparse-checkout
文件的内容,然后运行以下子命令:
$ git sparse-checkout reapply
此子命令重新应用现有的稀疏目录规格,以使工作目录匹配。当某些操作更新工作目录但未完全遵循 sparse-checkout
定义时,也可以使用此命令。这可能是由使用 Git 外部工具,或者运行不完全支持稀疏签出的 Git 命令导致的。
你可以通过运行 git sparse-checkout
disable
命令关闭此功能,并恢复工作目录以包括所有文件。
使用稀疏克隆减少本地仓库大小
第十一章的初始部分,管理子项目,描述了 Git 如何存储项目的历史记录,其中包括每次修订的变更描述、目录结构和文件内容。这些数据使用不同类型的对象存储:标签对象、提交对象、树对象和二进制对象。对象之间互相引用:标签指向提交,提交指向父提交,树代表某一修订时项目的状态,树指向其他树和二进制对象。
当运行普通的 git clone
命令时,客户端会向服务器请求最新的提交(代表最新的修订)。服务器提供这些对象、它们所指向的所有对象、以及这些对象所指向的对象,依此类推。简而言之,服务器提供了那些提交对象和所有其他可达的对象(可能排除客户端已经拥有的那些对象)。结果就是你在本地拥有了整个项目的完整历史。
然而,如今许多开发者在工作时都有可用的网络连接。现代 Git 只允许你通过 部分克隆 下载对象的一个子集。在这种情况下,Git 会记住从哪里可以获取其余的对象,当有必要时,它会稍后向服务器请求更多数据。
通过在运行 git clone
命令时指定 --filter
选项,可以启用 Git 的部分克隆功能。有几种可用的过滤器,但托管你克隆的仓库的服务器可以选择拒绝你的过滤器并恢复到创建完全克隆。
在稀疏克隆中运行 git fetch
会保持稀疏克隆过滤器,并且不会下载初次克隆时不会下载的那些类型的对象。
最常用的两种过滤器应该是大多数 Git 托管站点支持的,如下所示:
-
无 Blob 克隆:git clone --****filter=blob:none
-
无树克隆:git clone --****filter=tree:0
使用 --filter=blob:none
选项时,初始的 git clone
命令会下载所有内容,除了 blob 对象(这些对象通常包含不同版本的文件内容)。如果没有被抑制,clone
操作的 checkout 部分会下载当前版本项目文件的 blobs。Git 客户端知道如何将这些下载请求批量化,只向服务器请求缺失的 blobs。
使用 git log
、git merge-base
和其他不检查文件内容的命令时,运行不会需要额外下载 blob 对象。
此外,为了检查文件是否已更改,Git 只需比较对象 ID,而无需访问实际内容。因此,使用 git log -- <path>
检查文件历史记录时,也无需下载任何对象。此命令的运行性能与完全克隆时相同。这是因为对象 ID 基于文件内容的加密哈希(目前 Git 使用 SHA-1 实现此功能)。
Git 命令如 git checkout
/git switch
、git reset --hard <revision>
和 git merge
需要下载 blobs 以填充工作目录、索引(暂存区)或两者。为了计算差异,Git 还需要有 blobs 来进行比较;因此,像 git diff
或 git blame <path>
这样的命令,第一次运行时可能会根据特定的参数触发 blob 下载。
在某些代码库中,树状数据可能占据代码库大小的相当大一部分。如果代码库包含大量文件和目录,并且目录层次结构深且宽,则可能会发生这种情况。在这种情况下,使用--filter=tree:0
选项可能会提供更好的解决方案。
请注意,任何仅由由于所选过滤器而跳过的对象所引用的对象也将丢失。这意味着无树克隆比无 blob 克隆更加稀疏(因为只有树能指向 blob…当然,标签对象也可以指向 blob 对象,但你通常不会遇到这种情况)。
无树克隆相对于无 blob 克隆的优点是初始克隆速度更快,后续获取速度更快。缺点是,在无树克隆中工作更为困难,因为在需要时下载缺失的树的成本更高。服务器也更难以察觉客户端已经在本地拥有某些树对象,因此请求可能会发送比必要的更多数据。此外,更多的命令需要额外的数据下载。例如,git log -- <file>
命令在无 blob 克隆中可以运行而无需额外下载任何内容。而在无树克隆中,这个命令几乎会为每个历史提交下载树。
无树克隆与子模块
包含子模块的代码库(参见第十一章,管理子项目)在使用无树克隆时可能会表现不佳。如果收到过多的树下载请求,你可以通过确保fetch.recurseSubmodules配置变量设置为false(或使用--no-recurse-submodules选项)来关闭自动获取子模块,或者通过设置clone.filterSubmodules配置选项(或使用--recurse-submodules --filter=tree:0 --also-filter-submodules命令行选项组合)来同时过滤子模块。
无树克隆对于自动构建非常有用,当你想快速克隆项目,检出单个修订版本,编译它和/或运行测试,然后丢弃代码库(而不是使用浅克隆)时,它非常有效。如果你只对查看整个项目的历史感兴趣,它也很有用。
无树克隆是--filter=tree:<depth>
的一个特例。在这种情况下,克隆会省略所有树和 blob,这些树的深度从根树(从项目的顶级目录)到达或超过指定的限制。可以很容易地看出,当<depth>
等于 0(即--filter=tree:0
)时,克隆将不包括任何树或 blob(除了初始检出所需的那些)。
通过稀疏克隆省略大型文件内容
部分克隆也可以作为处理大文件的工具。这要求 Git 服务器(Git 托管站点)支持特定类型的过滤器。它同样不会取消至少一个远程仓库必须包含那些大文件及其历史记录的要求,以便你可以按需下载它们。
你可以通过在克隆时提供 --filter=blob:limit=<size>
选项来实现,其中 <size>
可以包括 <size>
字节,无论是 KiB、MiB 还是 GiB(取决于后缀)。例如,blob:limit=1k
等同于 blob:limit=1024
。
将克隆稀疏性与检出稀疏性匹配
现代 Git 包括对稀疏克隆过滤器的基本支持,该过滤器使其省略所有对于稀疏检出不需要的 blob。然而,由于安全原因,Git 放弃了对 --filter=sparse:path=<path>
的易用形式的支持。支持的形式是 --filter=sparse:oid=<blob-ish>
。这种形式可以防止“检查时到使用时”问题,因为 <blob-ish>
(即对 blob 对象的引用)最终解析为定义其内容的对象 ID。
在写作时,你很难找到一个支持此过滤器且不会响应 警告:服务器不识别过滤器,已忽略 的 Git 服务器。但当它开始得到广泛支持时,一个可能的解决方案是为每个感兴趣的稀疏检出模式创建一个标签,然后使用选定的标签作为 blob-ish:
$ git sparse-checkout set <subdir>
$ git hash-object -t blob -w .git/info/sparse-checkout
bd177ff9527327c67f50c644c421d280bb8b55f5
$ git tag -a -m 'sparse-checkout pattern for <subdir>'
sparse/<subdir> bd177ff9527327c67f50c644c421d280bb8b55f5
$ git push origin tag sparse-checkout/<subdirectory>
To <repository url>
* [new tag] sparse/<subdir> -> sparse/<subdir>
当然,在第三步中,你需要使用之前命令输出的 SHA-1。
在这种情况下,克隆将使用--filter=sparse:oid=sparse/<subdir>^{blob}
选项(其中需要使用之前显示的命令序列所创建的标签名)。
使用文件系统监控器更快地检查文件变更
当你运行一个操作工作树的 Git 命令时,如git status
或git diff
,Git 必须发现相对于索引或相对于指定版本的变化。它通过搜索整个工作树来完成这项任务,而对于包含大量文件的仓库,这可能需要很长时间。每次你运行这样的命令时,它还必须从头开始重新发现相同的信息。
文件系统监控器是一个长期运行的守护进程或服务进程,其功能如下:
-
向操作系统注册以监视指定目录,并接收相关目录和文件的变更通知事件
-
将已更改的文件和目录的路径名保存在某些(内存中的)数据结构中,这些数据结构可以快速查询
-
响应客户端请求,提供最近修改的文件和目录的列表
从版本 2.37 开始,Git 包含了git fsmonitor--daemon
。目前它在 macOS 和 Windows 上可用。该守护进程监听来自客户端进程(如git status
)的 IPC 连接,并通过 Unix 域套接字或命名管道发送已更改文件的列表。
启用它非常简单;你只需要配置 Git 使用它。可以通过以下命令完成:
$ git config core.fsmonitor true
该监视器与core.untrackedCache
一起工作良好,因此建议将此配置选项设置为true
。
你可以查询此守护进程以获取被监视仓库的列表:
$ git fsmonitor--daemon status
fsmonitor-daemon is watching 'C:/work/chromium'
如果操作系统或仓库所在的文件系统不允许你使用此监视器,可以使用core.fsmonitor
配置选项指定文件系统监视器钩子的路径。该钩子必须支持fsmonitor-watchman
钩子协议,并且在运行时会将已更改的文件列表输出到标准输出。
Git 附带了fsmonitor-watchman.sample
文件,该文件安装在.git/hooks/
目录中。在启用之前,如前所述,需通过删除*.sample
后缀来重命名该文件。如果文件丢失,你可以从github.com/git/git/tree/master/templates
下载该文件。此钩子需要安装Watchman文件的监视服务(facebook.github.io/watchman/
)。
总结
本章提供了处理大型 Git 仓库的解决方案,从使用 Scalar 工具到专门的解决方案。
首先,你学会了如何使用浅克隆下载并操作项目历史的选定浅子集。
然后,你学会了如何通过将大文件存储在仓库外部或将其拆分成子模块来处理大文件。还简要提到了数据科学项目中大数据的问题,以及针对这一问题的专门解决方案。
最终,你学会了如何使用稀疏检出、稀疏克隆和文件系统监控来管理大型单体仓库。
下一章将帮助你使 Git 更易于使用,并更好地适应你的特定需求。这包括配置仓库维护,这对于使大型仓库的工作更加顺畅至关重要。
问题
回答以下问题以测试你对本章的理解:
-
处理大型仓库的最简单解决方案是什么?
-
如何加速长历史仓库的克隆?
-
如何处理只被部分开发者需要的大文件?
-
使用大量文件的仓库时,哪些技术可以加速工作?
-
浅克隆、稀疏克隆和稀疏检出的区别是什么?
答案
以下是本章问题的答案:
-
使用内置的scalar工具,无论是使用它来克隆仓库,还是将给定仓库注册到该工具。
-
你可以使用浅克隆或无 Blob 稀疏克隆。在第一种情况下,你将获得一个简化的历史记录,而在第二种情况下,仓库的大小将更小,但某些操作需要网络访问以下载额外的数据。
-
你可以使用 Git-LFS 或 git-annex(或类似解决方案)将大型文件存储在仓库之外。你可以使用稀疏克隆功能克隆仓库,而无需下载大型文件数据。
-
如果你只在特定子目录中工作,请使用稀疏签出功能;使用稀疏克隆来减少仓库大小;并且使用文件系统监控(如果可能的话)来加快操作。
-
浅克隆只下载选定的部分仓库历史记录,所有本地操作仅限于该选择,尽管改变历史深度很容易。稀疏克隆通过仅下载选定的对象子集来减少仓库大小,按需获取这些对象,随着操作的进行,当它们的存在变得必要时才会下载。稀疏签出减少了已签出的文件数量,使工作目录变得更小(并且操作更快)。
深入阅读
要了解本章涉及的更多内容,请查阅以下资源:
-
介绍 Scalar:面向每个人的 Git 大规模管理,作者 Derrick Stolee(2020):
devblogs.microsoft.com/devops/introducing-scalar/
-
Scalar 的故事,作者 Derrick Stolee 和 Victoria Dye(2022):
github.blog/2022-10-13-the-story-of-scalar/
-
scalar(1) - 管理大型 Git 仓库的工具:
git-scm.com/docs/scalar
-
超级增强 Git 提交图,作者 Derrick Stolee(2018):
devblogs.microsoft.com/devops/supercharging-the-git-commit-graph/
-
git-commit-graph(1) - 写入和验证 Git 提交图 文件:
git-scm.com/docs/git-commit-graph
-
Git LFS - Git 大文件 存储:
git-lfs.com/
-
git-annex:
git-annex.branchable.com/
-
跟上部分克隆和浅克隆的进度,作者 Derrick Stolee(2020):
github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/
-
使用稀疏签出将你的单体仓库缩小尺寸,作者 Derrick Stolee(2020):
github.blog/2020-01-17-bring-your-monorepo-down-to-size-with-sparse-checkout/
-
git-sparse-checkout(1) - 将工作树缩减为跟踪的 文件子集:
git-scm.com/docs/git-sparse-checkout
-
git-clone(1) - 克隆一个仓库到新的 目录中:
git-scm.com/docs/git-clone
-
通过文件系统监视器提高 Git 单体仓库性能,Jeff Hostetler(2022):
git.blog/2022-06-29-improve-git-monorepo-performance-with-a-file-system-monitor/
-
git-fsmonitor--daemon - 内建文件系统 监视器:
git-scm.com/docs/git-fsmonitor--daemon
-
githooks - Git 使用的钩子: fsmonitor-watchman:
git-scm.com/docs/githooks#_fsmonitor_watchman
第十三章:自定义和扩展 Git
前面的章节旨在帮助你理解 Git 的工作原理,并掌握 Git 作为版本控制系统的使用。接下来的两章将帮助你设置和配置 Git,使你能够更加高效地使用它(本章)以及帮助其他开发人员使用它(下一章)。
本章将介绍如何配置和扩展 Git 以满足个人需求。首先,它将展示如何设置 Git 命令行以使其更易于使用。对于某些任务,使用视觉工具会更容易;本章对图形界面的简要介绍将帮助你做出选择。接下来,将解释如何通过配置文件(并描述所选配置选项)到每个文件的配置(使用 .gitattributes
文件)来更改和配置 Git 的行为。
接下来,本章将介绍如何使用钩子自动化 Git,例如如何让 Git 检查正在创建的提交是否符合项目的编码规范。本部分将重点讨论客户端钩子,并仅简要涉及服务器端钩子——这些内容会留给 第十四章,Git 管理。本章的最后部分将描述如何扩展 Git,从 Git 命令别名,到集成新的用户可见命令,再到助手和驱动程序(新的后台功能)。
许多问题,如 gitattributes、远程和凭证助手以及 Git 配置的基础知识,应该已经在前面的章节中了解。本章将把这些信息集中到一个地方,并稍作扩展。
本章将涵盖以下主题:
-
设置 shell 提示符和命令行的 Tab 补全功能
-
图形用户界面的类型和示例
-
配置文件和基本配置选项
-
安装和使用各种类型的钩子
-
简单和复杂的别名
-
扩展 Git 以增加新命令和助手
命令行中的 Git
使用 Git 版本控制系统的方式有很多种。存在许多不同用途和功能的 图形用户界面(GUIs),并且还存在允许与 集成开发环境(IDE)或文件管理器集成的工具和插件。
然而,命令行是你可以运行所有 Git 命令并支持其所有选项的唯一地方。你可能希望使用的新功能,通常首先会为命令行开发。此外,大多数图形用户界面(GUI)仅实现了 Git 功能的一部分。精通命令行总能保证对工具、机制及其功能的深入理解。仅仅知道如何使用图形界面可能不足以获得扎实的知识。
无论你是出于选择、偏好环境,还是因为这是唯一能访问所需功能的方式在命令行中使用 Git,Git 可以利用一些 shell 特性,使你的体验更加友好。
Git 感知命令提示符
自定义 shell 提示符 显示我们所在 Git 仓库的状态信息是很有用的。
定义
shell 提示符 是写入终端或控制台输出的简短文本消息,用于通知用户交互式 shell 正在等待输入(通常是 shell 命令)。
这些信息可以根据需要简单或复杂。Git 的提示符可以类似于普通的命令行提示符(以减少不和谐感),也可以显著不同(以便能轻松区分我们是否在 Git 仓库中)。
在 contrib/
目录下有 bash
和 zsh
shell 的示例实现。如果你是从源代码安装 Git,只需将 contrib/completion/git-prompt.sh
文件复制到主目录;如果你通过包管理器在 Linux 上安装了 Git,那么该文件可能位于 /etc/bash_completion.d/git-prompt.sh
。此文件提供了 __git_ps1
shell 函数,用于在 Git 仓库中生成 Git 感知提示符,但你需要先在 .bashrc
或 .zshrc
shell 配置文件中加载此文件:
if [ -f /etc/bash_completion.d/git-prompt.sh ]; then
source /etc/bash_completion.d/git-prompt.sh
fi
shell 提示符是通过环境变量配置的。要设置提示符,必须直接或间接地更改 PS1
(提示字符串一,默认交互提示符)环境变量。因此,创建一个 Git 感知命令提示符的一个解决方案是通过命令替换,将对 PS1
环境变量进行调用,加入 __git_ps1
shell 函数:
export PS1='\u@\h:\w$( git_ps1 " (%s)")\$ '
注意,对于 zsh
,你还需要使用 setopt
PROMPT_SUBST
命令打开命令替换功能。
另外,为了获得稍微更快的提示符和支持颜色,你可以使用 __git_ps1
来设置 PS1
。在 bash
中通过 PROMPT_COMMAND
环境变量完成,在 zsh
中则通过 precmd()
函数完成。你可以在 git-prompt.sh
文件的注释中找到有关此选项的更多信息;对于 bash
,可能如下所示:
PROMPT_COMMAND='__git_ps1 "\\u@\\h:\\w" "\\\$ "'
使用此配置(任一解决方案),提示符将如下所示:
bob@host.company.org:~/random/src (master)$
Git for Windows 中的 Git Bash 命令自带类似的配置(尽管 Git Bash 默认提示符占两行,而非一行)。
bash
和 zsh
的 shell 提示符可以通过特殊字符进行定制,shell 会扩展这些字符。在这里使用的示例中(你可以在 Bash 参考手册 中找到更多示例),我们有如下内容:
-
\u 表示当前用户(bob)
-
\h 表示当前主机名(host.company.org)
-
\w 表示当前工作目录(~/random/src)
-
$ 打印提示符中的 $ 部分(如果以 root 用户身份登录,则为 #)
$(...)
在 PS1
设置中用于调用外部命令和 shell 函数。__git_ps1 " (%s)"
在这里调用由 git-prompt.sh
提供的 git_ps1
shell 函数,并带有格式化参数:%s
标记是显示的 Git 状态的占位符。请注意,你需要在从命令行设置 PS1
变量时使用单引号,如示例所示,或者转义 shell 替换,以便在显示提示符时展开,而不是在定义变量时展开。
如果你使用 __git_ps1
函数,Git 还会显示有关当前正在进行的多步骤操作的信息:合并、变基、二分查找等。例如,在 master
分支上进行交互式变基(-i
)时,提示符的相关部分将是 master|REBASE-i。如果在操作过程中被中断,将此信息直接显示在命令提示符中非常有用。
还可以在命令提示符中指示工作树、索引等的状态。我们可以通过导出这些环境变量的选定子集来启用这些功能(对于某些功能,你还可以使用提供的布尔值配置变量在每个仓库的基础上单独关闭它们):
-
GIT_PS1_SHOWDIRTYSTATE(对于每个仓库的设置,可使用 bash.showDirtyState)显示“*”表示未暂存更改,显示“+”表示已暂存更改,如果设置为非空值。
-
GIT_PS1_SHOWSTASHSTATE 如果有内容被暂存,则显示“$”。
-
GIT_PS1_SHOWUNTRACKEDFILES 和 bash.showUntrackedFiles 如果工作目录中存在未跟踪的文件,则显示“%”。
-
GIT_PS1_SHOWUPSTREAM 和 bash.showUpstream 可用于配置上游仓库的 ahead-behind 状态,值为 auto 的空格分隔列表将使提示符显示你是否落后于上游“<”、已同步“=”或领先于上游“>”,name 显示上游名称,verbose 显示你领先/落后的提交数量(带符号;例如,“+1”表示领先 1 次提交)。git 比较 HEAD 和 @{upstream},svn 比较 SVN 上游。
-
GIT_PS1_DESCRIBE_STYLE 可设置为配置如何显示脱离的 HEAD 情况;它可以设置为以下值之一:contains 使用更新的注释标签(v1.6.3.2~35),branch 使用更新的标签或分支(main~4),describe 使用旧的注释标签(v1.6.3.1-13-gdd42c2f),tag 使用任何标签,default 仅在标签完全匹配当前提交时显示标签。
-
GIT_PS1_SHOWCONFLICTSTATE 设置为“yes”将通知用户是否存在未解决的冲突,显示“|CONFLICT”。
-
GIT_PS1_SHOWCOLORHINTS 可用于配置当前脏状态的彩色提示,即是否存在未提交的更改(类似于 git status -****sb 的效果)。
-
GIT_PS1_HIDE_IF_PWD_IGNORED 或 bash.hideIfPwdIgnored 用于在当前目录被 Git 忽略时,不显示 Git 友好的提示,即使我们处于一个仓库内。
如果你使用的是zsh
shell,可以查看zsh-git
脚本集、zshkit
配置脚本,或可用于zsh
的oh-my-zsh
框架,而不是使用bash
——首先完成来自 Git contrib/ 的提示设置。或者,你也可以使用zsh
内建的vcs_info
子系统。
对于bash
(通常适用于多个不同的 shell),也有一些替代的提示解决方案,例如git-radar
或powerline-shell
。
提示
当然,你也可以生成自己的 Git 友好的提示。例如,你可能希望利用git rev-parse 命令,将当前目录分为仓库路径部分和项目子目录路径部分。
Git 的命令行补全
另一个让 Git 命令行更易于使用的 shell 特性是可编程的命令行补全。此功能可以显著加快 Git 命令的输入速度。命令行补全允许你输入命令或文件名的前几个字符,然后按补全键(通常是Tab)来填充剩余部分。启用了 Git 友好的补全后,你还可以填充子命令、命令行参数、远程仓库、分支和标签(引用名称),并且只有在适当的位置才会自动补全(例如,只有在命令期望远程仓库名称的情况下,远程仓库名称才会被补全)。
Git 自带(但并非总是安装)对bash
和zsh
shell 的 Git 命令自动补全支持。
对于bash
,如果没有随 Git 一起安装补全功能(默认情况下在 Linux 中位于/etc/bash_completion.d/git.sh
),你需要从 Git 源代码中获取contrib/completion/git-completion.bash
文件的副本。将其复制到一个可访问的位置,例如主目录,并从.bashrc
或.bash_profile
中引用它:
. ~/git-completion.bash
启用 Git 补全后,测试方法是输入一个 Git 命令,然后按Tab键。例如,你可以输入git check
,然后按Tab:
$ git check<TAB>
启用 Git 补全后,bash
(或zsh
)shell 会自动补全你输入的部分,直到git checkout
。
类似地,在模糊情况下,按两次Tab键会显示所有可能的补全项(尽管并非所有 shell 都支持此功能;有些 shell 会循环显示不同的补全项):
$ git che<TAB><TAB>
checkout cherry cherry-pick
补全功能也适用于选项;如果你不记得确切的选项而只记得前缀,这非常有用:
$ git config --<TAB><TAB>
--add --get-regexp --remove-section --unset
[…]
重要提示
一些 shell 使用(或可以配置为使用)旋转补全功能,而不是显示可能的补全项列表。当有多个可能的补全项时,每按一次Tab键会显示一个不同的补全项,循环显示相同前缀的不同补全项。
请注意,命令行补全(也叫 Tab 补全)通常仅在交互模式下工作,并且基于明确的前缀,而不是明确的缩写。
Git 命令的自动更正
一个与 Git 内置工具无关但类似于 Tab 补全的功能是 自动更正。默认情况下,如果你输入了看起来像是拼写错误的命令,Git 会尝试帮助你猜测你的意图。即使只有一个候选命令,Git 也不会执行该操作:
$ git chekout
git: 'chekout' is not a git command. See 'git --help'.
The most similar command is
checkout
然而,当 help.autoCorrect
配置变量设置为正数时,Git 会在等待指定的百分之一秒(0.1 秒)后自动更正并执行拼写错误的命令。你可以使用负值来立即执行,或者使用零来恢复默认行为:
$ git chekout
WARNING: You called a Git command named 'chekout', which does not exist.
Continuing in 0.1 seconds, assuming that you meant 'checkout'.
Your branch is up-to-date with 'origin/master'.
如果输入的文本可以推导出多个命令,则不会执行任何操作。此机制仅适用于 Git 命令;你不能自动更正子命令、参数和选项(与 Tab 补全不同)。
让命令行更美观
Git 完全支持彩色终端输出,这大大有助于视觉上解析命令输出。许多选项可以帮助你根据个人喜好设置颜色。
首先,你可以指定何时使用颜色,比如某些命令的输出。color.ui
是一个主开关,用来控制输出的着色,将 Git 的所有彩色终端输出关闭并设置为 false
。该配置变量的默认设置是 auto
,这意味着 Git 会在输出直接到终端时着色,但在输出被重定向到文件或管道时会省略颜色控制代码。
你也可以将 color.ui
设置为 always
,尽管你很少会需要这样做:如果你希望在重定向的输出中看到颜色代码,只需将 --color
标志传递给 Git 命令;相反,--no-color
选项将关闭彩色输出。
如果你想更具体地控制哪些命令以及输出的哪些部分使用颜色,Git 提供了相应的颜色设置:color.branch
、color.diff
、color.interactive
、color.status
等。就像 color.ui
主开关一样,每个设置都可以设置为 true
、false
、auto
或 always
。
此外,这些设置中的每一项都有子设置,可以用来为输出的特定部分设置特定的颜色。例如,color.diff.meta
(用于配置差异输出中文本的元信息颜色)这样的配置变量的颜色值由前景色、背景色(如果设置)和文本属性的空格分隔的名称组成。
你可以将颜色设置为以下任意值:normal
、black
、red
、green
、yellow
、blue
、magenta
、cyan
或 white
。至于属性,你可以选择 bold
、dim
、ul
(下划线)、blink
和 reverse
(将前景色与背景色互换)。
git log
的漂亮格式也包括设置颜色的选项;有关更多信息,请参阅 git log
文档。
外部工具
也有可以与 Git 一起使用的差异语法高亮工具。它们可以通过 core.pager 配置变量作为分页器进行设置,或通过别名进行配置。示例包括 delta (dandavison.github.io/delta
) 和来自 Git 源代码贡献区的 diff-highlight。
替代命令行
要理解 Git 用户界面的一些粗糙之处,你需要记住 Git 在很大程度上是自底向上开发的。从历史上看,Git 最初是作为一个编写版本控制系统的工具开发的(你可以通过 git help
core-tutorial
命令查看早期 Git 的使用方式,这也是开发者核心教程的一部分)。
Git 的第一个替代“瓷器”(即替代用户界面)是 Cogito。如今,Cogito 已不复存在;它的所有功能早已被 Git 纳入(或被更好的解决方案替代)。曾经有人尝试编写包装脚本(替代用户界面),目的是让其更容易学习和使用,例如 eg
)以及较新的 jj
)是一个版本控制系统,处于开发的初期阶段,能够使用 Git 仓库存储项目历史,因此也可以看作是 Git 上的一层。
还有一些外部 Git “瓷器”,它们并不打算替代整个用户界面,而是提供对一些额外功能的访问,或将 Git 封装起来提供一些有限的功能集。gq
)) 被创建的目的是为了方便重写、操作和清理未发布历史的选定部分;这些被提及作为一种替代交互式变基的方式,在第十章,“保持历史清洁”中有提到。接下来,是单文件版本控制系统,例如 Zit,它将 Git 用作后端。
替代实现
除了替代用户界面外,还有不同的 Git 实现(定义为读取和写入 Git 仓库)。它们在完成度上处于不同的阶段。除了核心的 C 实现外,还有 Java 中的 JGit,以及 libgit2 项目——它是现代 Git 绑定各种编程语言的基础。
图形界面
你已经学会了如何在命令行中使用 Git。上一节告诉你如何定制和配置 Git,使其更高效。但是,终端并不是终点。你还可以使用其他环境来管理 Git 仓库。有时,视觉表示正是你所需要的。
现在,我们将简要看一下各种以用户为中心的 Git 图形工具;Git 管理工具的介绍将在下一章第十四章,“Git 管理”中介绍。
图形工具的类型
不同的工具和接口针对不同的工作流程进行了定制。一些工具只暴露 Git 功能的一个子集,或者鼓励采用特定的版本控制工作方式。
为了在选择 Git 的图形化工具时做出明智的决策,你需要了解不同类型工具支持哪些操作。请注意,某一个工具可能支持多种类型的使用。
首先,有git log
。这是用于查找过去发生的事情,或者浏览和可视化项目历史和分支布局的工具。这些工具通常接受修订选择命令行选项,如--all
。命令行 Git 提供了git log --graph
,以及较少使用的git show-branch
,通过 ASCII 艺术来显示历史。
一个类似的工具是git log -L
)和所谓的镐式搜索(git log -S
),它们没有很多图形用户界面(GUI)。
接下来,有git add
、git reset
等命令,甚至允许你逐个块地暂存和撤销更改。交互式添加的图形化版本在第三章,管理你的工作树中有所描述,并在第二章,使用 Git 进行开发中提到。还有一些工具可以根据指定的标准来编写提交信息。
然后,我们有文件管理器集成(或图形化外壳集成)。这些插件通常通过图标叠加显示文件在 Git 中的状态(已跟踪/未跟踪/忽略)。它们可以为仓库、目录和文件提供右键菜单,通常附带键盘快捷键。它们还可能支持拖放功能。
程序员编辑器和 IDE 通常提供对IDE 集成的支持(或者一般的版本控制)。这些工具提供仓库管理(作为团队项目管理的一部分),使得可以直接从 IDE 执行 Git 操作,显示当前文件和仓库的状态,甚至可能在文件视图中标注版本控制信息。它们通常包括提交工具、远程管理、历史查看器和差异查看器。
Git 仓库托管网站通常提供面向工作流的桌面客户端。这些客户端主要集中于一组精选的常用功能,这些功能能够很好地协同工作,自动化常见的 Git 任务。它们通常旨在突出其服务,提供额外的功能和集成,但它们也可以与任何托管在任何地方的仓库一起使用。
甚至还有sequence.editor
配置变量,或者可以设置为默认 Git 分页器的语法高亮工具。
图形化的差异比较和合并工具
图形化差异工具和图形化合并工具在某些情况下是特殊的。在这些类别中,Git 包含了与第三方图形工具集成的命令,即git difftool
和git mergetool
。这些工具随后从 Git 仓库中调用。请注意,这与外部差异或差异合并驱动程序不同,后者会替代普通的git diff
或对其进行增强。
尽管 Git 有内部的差异实现和解决合并冲突的机制(见第九章,合并 更改),但您仍然可以使用外部图形化差异工具。这些工具通常用于更好地显示差异(通常是并排显示差异,可能带有一些细化),并帮助解决合并(通常使用三窗格界面)。
配置图形化差异或图形化合并工具需要配置一些自定义设置。为了分别指定用于差异和合并的工具,您可以分别设置diff.tool
和merge.tool
。如果没有设置,例如,merge.tool
配置变量,git mergetool
命令将打印如何配置的相关信息,并尝试运行预定义的工具之一:
$ git mergetool
This message is displayed because 'merge.tool' is not configured.
See 'git mergetool --tool-help' or 'git help config' for more details.
'git mergetool' will now attempt to use one of the following tools:
tortoisemerge emerge vimdiff
No files need merging
运行git mergetool --tool-help
将显示所有可用的工具,包括那些未安装的工具。如果您使用的工具不在$PATH
中,或者该工具的版本错误,您可以使用mergetool.<tool>.path
设置或覆盖给定工具的路径:
$ git mergetool --tool-help
'git mergetool --tool=<tool>' may be set to one of the following:
vimdiff Use Vim with a custom layout
[…]
The following tools are valid, but not currently available:
araxis Use Araxis Merge
[…]
Some of the tools listed above only work in a windowed
environment. If run in a terminal-only session, they will fail.
如果您的工具没有内置支持,您仍然可以使用它;您只需要进行配置。mergetool.<tool>.cmd
配置变量指定如何运行命令,而mergetool.<tool>.trustExitCode
告诉 Git 该程序的退出代码是否表示合并解决成功。相关的配置文件片段(对于名为extMerge
的图形化合并工具)可能如下所示:
[merge]
tool = extMerge
[mergetool "extMerge"]
cmd = extMerge "$BASE" "$LOCAL" "$REMOTE" "$MERGED"
有一些配置选项控制git mergetool
的行为,可以是全局的,也可以是每个工具单独设置的。其中一个选项是mergetool.hideResolved
(以及每个工具的mergetool.<tool>.hideResolved
变体),它使 Git 尽可能自行解决冲突,并仅将未解决的冲突呈现给合并工具。请注意,一些合并工具会自行执行此操作。
一些合并工具,如 vimdiff,是文本界面工具,可以在无需图形会话的情况下工作。如果您想在文本模式下运行一个工具(例如,当通过普通 SSH 访问远程主机时),而在图形模式下运行另一个工具,您可以通过在一个工具中配置mergetool.tool
,在另一个工具中配置mergetool.guitool
来实现——并使用git mergetool --gui
调用图形界面工具。
图形界面示例
在本节中,您将看到一些围绕 Git 的工具,您可以使用它们,或者它们可能会激发您进一步研究的兴趣。帮助您开始研究的一种不错方式是列出一些选定的 GUI 客户端。
Git 有两个可视化工具,通常与 Git 一起安装,分别是 gitk
和 git gui
,还有一个 git gui blame
,一个视觉化的 git gui
打开 gitk
。
可视化工具不一定需要使用图形环境。比如tig(即 Text Interface for Git)使用基于文本模式的界面(TUI),作为仓库浏览器和提交工具,还可以充当 Git 分页器。
另一个 TUI 示例是git interactive-rebase-tool,它可以作为交互式序列编辑器,用于交互式 rebase 指令表。
有一个用 Python 开发的git cola,它适用于所有操作系统,包含提交工具和远程管理工具,还提供一个 diff 查看器。然后是一个简单且多彩的Gitg工具,适用于 GNOME;你将获得一个图形化的历史查看器、diff 查看器和文件浏览器。
一个较受欢迎的 macOS 开源 GUI 工具是GitX。这个工具有很多分支,其中一个比较有趣的分支是Gitbox。它提供历史查看器和提交工具。
对于 MS Windows,有TortoiseGit和git-cheetah,这两款工具都提供与 Windows 上下文菜单的集成,因此你可以在 Windows 资源管理器内执行 Git 命令(文件管理器集成和 Shell 接口)。
GitHub 公司和 Atlassian 都发布了一个桌面 GUI 工具,分别可以轻松与 GitHub 或 Bitbucket 仓库配合使用,但这两个工具不仅限于与单一服务(GitHub 或 Bitbucket)交互。GitHub Client 和 SourceTree 都具有仓库管理功能,并提供一系列其他常见功能来增强你的开发工作流程。
许多编程编辑器和 IDE 都支持管理 Git 仓库,有时也支持与 Git 托管站点交互。这些功能可以是内置的,或者作为 IDE 插件或扩展提供。例如,GitLens(适用于 Visual Studio Code)、Magit(适用于 GNU Emacs)和 Fugitive(适用于 ViM)。这些工具通常会显示诸如哪些行被添加或更改,或者谁编写了这些行等信息,直接在编辑器窗格中展示。
配置 Git
到目前为止,在描述 Git 的工作原理以及如何使用它时,我们已经介绍了多种改变其行为的方法。在本节中,将系统地解释如何临时和永久地配置 Git 操作。我们还将看到如何通过引入和重新引入几个重要的配置设置,使 Git 按照自定义的方式运行。通过这些工具,轻松让 Git 按照你想要的方式工作。
命令行选项和环境变量
Git 按照层级结构处理更改其行为的选项,从最不具体到最具体,最具体的选项(且持续时间最短)具有优先权。
最具体的,覆盖其他所有方式的是命令行选项。显然,它们只影响当前的 Git 命令。
重要提示
需要注意的一个问题是,一些命令行选项,例如 --no-pager 或 --no-replace-objects,是传递给 git 外壳程序,而不是 Git 命令本身。例如,查看以下行可以看到这种区别:
$ git --no-replace-objects log -5 --oneline --****graph --decorate
你可以在 Git 命令行接口的手册页中找到使用的约定。
改变 Git 命令工作方式的第二种方法是使用环境变量。它们特定于当前的 shell,如果使用替代变量,则需要使用 export
内建命令(或其等价物)将变量传播到子进程。有些环境变量适用于所有核心 Git 命令,而有些则仅适用于某个特定的(子)命令。
Git 还使用一些非特定的环境变量。这些变量作为最后的手段使用;它们会被对应的 Git 特定变量覆盖。例如,包括 PAGER
和 EDITOR
这样的变量。
Git 配置文件
定制 Git 工作方式的最终方法是通过配置文件。在许多情况下,配置操作的命令行选项、环境变量和配置变量按优先级降序排列。
Git 使用一系列配置文件来确定你可能想要的非默认行为。Git 会按照从最不具体到最具体的顺序读取这些文件,配置设置越靠后越会覆盖早期的设置。你可以使用 git config
命令访问这些 Git 配置文件:默认情况下,它会操作所有文件的合并,但你可以通过命令行选项指定要访问的具体文件。你还可以按照配置文件语法访问任何给定的文件(例如 第十一章 中提到的 .gitmodules
文件,管理子项目),方法是使用 --file=<pathname>
选项(或 GIT_CONFIG
环境变量)。
提示
你也可以从任何具有配置类内容的 blob 中读取值;例如,你可以使用 git config --blob=master:.gitmodules 来读取 master 分支中的 .gitmodules 文件。
Git 查找配置文件的第一个地方是 /etc/gitconfig
。至少在 Linux 上是如此,/etc
目录用于存储主机特定的系统级配置文件;Git for Windows 会将此文件放在其 Program Files
文件夹的子目录中。此文件包含系统上每个用户及其所有仓库的配置值。要让 git config
专门从这个文件读取和写入(并用 --edit
打开它),需要将 --system
选项传递给 git
的 config
命令。
你可以通过设置 GIT_CONFIG_NOSYSTEM
环境变量来跳过从此文件读取设置。这可以用于设置可预测的环境或避免使用无法修复的有缺陷的配置。
Git 接下来查找的地方是 ~/.gitconfig
,如果存在,还会回退到 ~/.config/git/config
(默认配置)。此文件特定于每个用户,并影响该用户的所有仓库。如果你向 git config
传递 --global
选项,它将专门从此文件读取和写入。提示:在此处与其他位置一样,~
(波浪号字符)表示当前用户的主目录($HOME
)。
最后,Git 会在 .git/config
中查找配置值。此处设置的值是特定于该本地单个仓库的。你可以通过传递 --``local
选项来使 Git 读取和写入此文件。
在现代 Git 中,如果 extensions.worktreeConfig
设置为 true(默认值为 false),还可以存在一个 .git/config.worktree
文件(参见 git
worktree
命令)。
这些级别(系统级、全局级和本地级)会覆盖前一级的值,因此,例如,.git/config
中的值会覆盖 ~/.gitconfig
中的值;不过,除非配置变量是多值的。
提示
你可以利用本地(每个仓库)配置覆盖全局(每个用户)配置的特点,将默认身份设置在每个用户文件中,并在必要时通过每个仓库的配置文件在仓库级别进行覆盖。
最后,你可以通过 -c
选项为单个命令设置配置变量,方法是在 git
外壳中使用该选项:
$ git -c foo.int=1k config --get --type=int foo.int
1024
请参阅以下章节,以获得对此结果的完整解释。
Git 配置文件的语法
Git 的配置文件是纯文本文件,因此你也可以通过手动编辑选定的文件来自定义 Git 的行为。语法相当灵活和宽松;空格大多被忽略(与 .gitattributes
相反)。哈希符号 #
和分号 ;
用于开始注释,注释内容持续到行尾。空白行会被忽略。
该文件由节和变量组成,语法类似于 INI 文件的语法。节名和变量名均不区分大小写。节以方括号中的节名称 [section] 开始,并持续到下一个节为止。每个变量必须属于某个节,这意味着在设置变量之前必须有节头。节可以重复,也可以为空。
节可以进一步划分为子节。子节名称区分大小写,并且可以包含除换行符以外的任何字符(双引号 "
和反斜杠 \
必须分别转义为 \"
和 \\
)。子节的开始如下所示:
[section "subsection"]
所有其他行(以及节标题后的行其余部分)都被识别为 name = value
形式的设置变量。作为一种特例,只有 name
是 name = true
(布尔变量)的简写。这类行可以通过在行尾加上 \
(反斜杠字符)来续接到下一行,即通过转义行尾字符。前导和尾随的空格会被丢弃;值中的内部空格会原样保留。你可以使用双引号来保留值中的前导或尾随空格。
包含与有条件包含
你可以通过设置特殊变量 include.path
来包含另一个配置文件,路径指定要包含的文件位置。被包含的文件会立即展开,类似于 C 和 C++ 中的 #include
机制。该路径是相对于包含指令所在配置文件的路径。你可以通过 --no-includes
选项来关闭此功能。
你也可以通过设置 includeIf.<condition>.path
变量来有条件地包含另一个配置文件。条件以一个关键字开始,后跟冒号 :
,并包含与特定条件相关的数据。
支持的关键字如下:
-
gitdir,其后跟随的关键词数据作为全局模式来匹配 .git 目录的位置(即仓库本身的目录)。为了方便起见,模式开头的 ~ 和 ~/ 会被替换为主目录的位置,而 ./ 则被替换为包含当前配置文件的目录。还有 gitdir/i 变体,它以不区分大小写的方式进行匹配。
-
onbranch,可以用于将当前已检出的分支与全局模式进行匹配。
-
hasconfig:remote.*.url,该命令检查配置中是否存在至少一个符合全局模式的远程 URL。
例如,若要为主目录中的 work-repos/
子目录中的仓库使用不同的配置,你可以使用以下命令:
[includeIf "gitdir:~/work-repos/"]
path = ~/work.inc
访问 Git 配置
你可以使用 git config
命令访问 Git 配置,从列出标准形式的配置条目、检查单个变量,到编辑和添加条目。
你可以使用 git config --list
查询现有配置,并根据需要添加适当的参数以限制为单一配置层级。在默认安装的 Linux 系统中,在刚执行 git init
后的空 Git 仓库中,本地(每个仓库的)配置大致如下:
$ git config --list --local
core.repositoryformatversion=0
core.filemode=false
core.bare=false
core.logallrefupdates=true
你也可以使用 git var -l
来列出所有影响 Git 的配置和环境变量。
你还可以使用git config
查询单个键,限制(或不限制)范围到指定的文件,只需将配置变量的名称作为参数(可选地加上--get
),并且使用点号分隔部分、可选子部分和变量名(键):
$ git config user.email
这将返回最后一个值,即具有最高优先级的那个。你可以使用--get-all
获取所有值,或者使用--get-regexp=<match>
获取特定的键。这在访问像refspecs
这样的多值选项时非常有用,尤其是对于远程访问。
配置变量类型及类型说明符
在请求(或写入)配置变量时,你可以通过--type=
还有bool-or-int类型,以及与存储颜色和获取颜色转义码相关的一些选项;请参阅git config文档。
使用--get
、--get-all
和--get-regexp
时,你还可以将列表(以及多值变量的设置)限制为仅匹配regexp
值的那些变量(regexp
作为可选的最后一个参数传递)。例如,要查找所有影响给定主机代理设置的配置,你可以使用以下命令:
$ git config --get core.gitproxy 'for kernel\.org$'
你还可以使用git config
命令来设置配置变量的值。如果没有指定其他内容,本地层(每个仓库文件)是默认的写入位置。例如,要设置用户的电子邮件地址,使其适用于大多数仓库,你可以运行以下命令:
$ git config --global user.name "Alice Developer"
对于多值配置选项(multivar),你可以使用--add
选项向其添加多行。要更改一个多值配置变量的单个条目,你可以使用如下命令,其中第一个值表示要更改的值,第二个值表示新的值:
$ git config core.gitproxy '"ssh" for kernel.org' '"ssh" for kernel\.org$'
使用git config --unset
删除配置条目也非常简单。
与前面示例中在命令行上设置所有配置值不同,你也可以直接通过编辑相关的配置文件来设置或更改它们。只需在你喜欢的编辑器中打开配置文件,或运行git config --edit
命令。
在 Linux 上,执行git init
后,新的本地仓库配置文件如下所示:
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
如果你想通过编辑配置文件来更改配置,最好先找出你要更改的配置变量的来源。
查找配置值的来源
在有三层(或四层)配置文件的情况下,可能很难找出给定的配置变量是在哪里设置的,是否在更具体的配置文件中被覆盖或添加过。然后,还需要额外考虑include
和includeIf
部分的复杂性。
这时,传递给git config
命令的--show-origin
和--show-source
选项,结合--list
或--get/--get-all
选项,可以提供帮助。git config --list
命令将列出所有在配置文件中设置的变量及其值。--show-scope
选项会在所有查询的配置选项输出中增加该值的作用域(工作树、局部、全局、系统、命令),而--show-source
则显示来源类型(文件、标准输入、命令行、blob)及其实际来源(配置文件路径或 blob ID,如果适用)。
调试每个文件的配置
你可以使用git check-ignore检查文件为什么被忽略,使用git check-attr找出文件所分配的属性以及它们来自哪里。
例如,假设用户身份是在每个用户的配置文件中定义的:~/.gitconfig
[user]
name = Joe Random
email = joe@company.com
还假设使用git config
命令在项目的顶级目录中创建了work.inc
文件,并从每个仓库的配置文件中包含它:
$ git config --file=conf.inc foo.bar val
$ git config --local include.path ./conf.inc
在这种情况下,我们将获得以下查询结果,这里以简化的形式展示:
$ git config --show-scope --show-origin --list
global file:/home/joe/.gitconfig user.name=Joe Random
global file:/home/joe/.gitconfig user.email=joe@company.com
local file:.git/config core.repositoryformatversion=0
local file:.git/config core.filemode=false
[…]
local file:.git/config include.path=./../conf.inc
local file:.git/./../conf.inc foo.bar=val
第一列显示作用域,第二列显示来源,第三列显示完全限定的配置变量及其值。
基本的客户端配置
你可以将 Git 识别的配置选项分为两类:客户端和服务器端。大多数选项是关于配置个人工作偏好的,它们属于客户端。服务器端配置将在第十四章中详细讲解,Git 管理;本节只介绍基本内容。
有许多支持的配置选项,但其中只有一小部分需要设置;其中大部分有合理的默认值,显式设置它们仅在某些边缘情况下才有用。可用的选项很多;你可以通过git config --help
查看所有选项的列表。在这里,我们将只介绍最常见和最有用的选项。
需要真正设置的两个变量是user.email和user.name。这些配置变量定义了用户的身份(尽管在现代 Git 中,你可以为更改作者和提交者分别设置不同的身份,使用author.name和committer.name)。另外,如果你正在签名注解标签或提交(如在第六章中讨论的那样,Git 协作开发),你可能希望设置你的 GPG 签名密钥 ID。可以通过user.signingKey配置项来完成。
默认情况下,Git 会使用系统上设置的文本编辑器(通过VISUAL
或EDITOR
环境变量定义;VISUAL
仅用于图形桌面环境)来创建和编辑提交和标签信息。它还会使用你设置的分页器(PAGER
)来分页和浏览 Git 命令的输出。如果你想将默认编辑器更改为其他编辑器,可以使用vi
编辑器,或者在less
分页器上进行更改。
在 Git 中,分页器会自动调用。默认的less
分页器不仅支持分页功能,还支持增量搜索和其他功能。
重要提示
在默认配置下(即LESS环境变量未设置时),Git 调用less
时的行为与设置了LESS=FRX相同。这意味着,如果输出少于一页,它将跳过分页,传递 ANSI 颜色代码,并且退出时不会清屏。
创建提交信息也受到commit.template的影响。如果你设置了该配置变量,Git 将在提交时使用该文件作为默认信息。该模板通常不会随仓库一起分发。请注意,除非通过将commit.status设置为 false 来禁止,否则 Git 会将状态信息添加到提交信息模板中。
如果你有提交信息政策,这样的模板非常方便,因为它大大增加了遵循该政策的机会。它可以例如包括注释掉的提交信息填写说明。你可以通过适当的钩子进一步扩展此解决方案,检查提交信息是否符合政策(请参见本章后面的提交过程钩子部分)。
工作区中文件的状态会受到忽略模式和文件属性的影响(请参见第三章,管理你的工作树)。你可以将忽略模式放在项目的内置.gitignore
文件中(通常,.gitignore
文件用于指定哪些文件不需要跟踪,且该文件本身会被 Git 跟踪),或者将其放在.git/info/excludes
文件中,用于本地和私有模式,以定义哪些文件不感兴趣。这些是项目特定的;有时,你可能希望编写一个全局(每用户)的.gitignore
文件。你可以使用~/.config/git/ignore
。还有一个对应的.gitattributes
文件,默认位于~/.config/git/attributes
。
小知识
实际上,它是\(XDG_CONFIG_HOME/git/ignore**;如果未设置**\)XDG_CONFIG_HOME环境变量或该变量为空,则使用$HOME/.config/git/ignore。
尽管 Git 有内置的 diff 实现,你可以借助diff.external配置项设置外部工具代替 Git 内部的 diff 工具。通常,你会想要创建一个包装脚本来调整 Git 传递给它的参数,并按外部 diff 工具要求的顺序传递必要的参数。默认情况下,Git 会向 diff 程序传递以下参数:
path old-file old-hex old-mode new-file new-hex new-mode
参见 图形化 diff 和合并工具 部分,了解 git difftool
和 git mergetool
的配置。
变基和合并设置,配置拉取
在执行 git pull
操作时,Git 需要知道你是倾向于使用 merge 操作将本地历史和远程获取的历史合并,还是使用 rebase 操作来合并历史。这就是为什么它要求你提供 pull.rebase
配置变量的值。你可以在第九章中找到更多关于合并和变基的内容,合并 更改。
有几个配置设置可以用于配置 git pull
的行为。包括 pull.rebase
配置选项以及特定于分支的 branch.<name>.rebase
选项,当设置为 true
时,会告诉 Git 在拉取操作中执行变基(仅限 <name>
分支)。如果设置为 false
,则 git pull
会执行合并操作。两者还可以设置为 merges
,使用 --rebase-merges
选项执行变基,以便在变基过程中不将本地合并提交扁平化。
你可以通过 branch.autoSetupRebase
在创建特定类型的新分支时,自动为每个分支设置“拉取变基”配置。可以将其设置为 never
、local
(仅适用于本地跟踪的分支)、remote
(仅适用于远程跟踪的分支)或 always
(适用于本地和远程)。还有 branch.autoSetupMerge
用于设置分支跟踪另一个分支。
保留撤销信息 – 对象的过期
默认情况下,Git 会自动删除未引用的对象,并清理 git gc
命令。你可以在第十章中了解关于仓库面向对象结构的更多内容,保持 历史清晰。
为了安全起见,Git 在删除未引用的对象时会使用两周的宽限期;这一设置可以通过 gc.pruneExpire
配置项进行更改:该设置通常是相对日期(例如,1.month.ago
;你可以使用点作为单词分隔符)。要禁用宽限期(通常从命令行完成),可以使用 now
值。
分支提示历史记录默认会保留 90 天(如果设置了 gc.reflogExpire
,则按其设置)以用于可达修订版本,而对不属于当前历史记录的 reflog 条目,则保留 30 天(或 gc.reflogExpireUnreachable
)。这两个设置可以按每个引用进行配置,方法是提供要匹配的引用名称模式作为子部分名称,即 gc.<pattern>.reflogExpire
,其他设置类似。这可以用来更改 HEAD
或 refs/stash
(参见第三章,管理你的 工作区)的过期设置,或者单独为远程跟踪分支 refs/remotes/*
设置。该设置为一个时间长度(例如,6 个月);要完全关闭 reflog 过期,请使用 never
的值。你可以使用后者,例如,关闭 stash
条目的过期。
格式化和空白
代码格式化和空白问题是你在协作过程中可能遇到的一些令人头疼且微妙的问题,尤其是在跨平台开发时。补丁和合并很容易引入微妙且不必要的空白变化,因为编辑代码时可能悄悄引入这些变化(这些变化通常不可见),而且不同操作系统对行结尾有不同的定义:MS Windows、Linux 和 macOS。Git 提供了一些配置选项来帮助解决这些问题。
跨平台工作中一个重要的问题是 行结尾 的定义。这是因为 MS Windows 使用 回车(CR)字符和 换行(LF)字符的组合来表示文本文件中的新行,而 macOS 和 Linux 仅使用换行字符。许多 MS Windows 上的编辑器会悄悄地将现有的 LF 风格的行结尾替换为 CRLF,或者为新行使用 CRLF,这导致了微妙但恼人的问题。
Git 可以通过在将文件添加到索引时自动将行结尾转换为 LF 来处理此问题。如果你的编辑器使用 CRLF 行结尾,Git 在检出文件时也可以将行结尾转换为本地格式。影响此问题的有两个配置设置:core.eol
和 core.autocrlf
。第一个设置 core.eol
,设置在将文件检出到工作目录时使用的行结尾格式,对于已设置 text
属性的文件(请参见下面的按文件配置的 gitattributes部分,该部分总结并回顾了第三章中关于文件属性的信息,管理 你的 工作区)。
第二个较旧的设置,core.autocrlf
,可以用于开启自动转换行尾为 CRLF 的功能。将其设置为true
时,会在检出文件时将仓库中的 LF 行尾转换为 CRLF,在暂存文件时则反向操作;这是你在 Windows 机器上可能需要的设置。(这几乎与将text
属性设置为core.eol
为crlf
相同。)你也可以通过将core.autocrlf
设置为input
,让 Git 在提交时将 CRLF 转换为 LF,但不会进行反向转换;如果你使用的是 Linux 或 Mac 系统,这种设置最为合适。要关闭此功能,并按当前设置记录仓库中的行尾,则将此配置值设置为false
。
这处理了空白字符问题的一个方面——行尾变异性,以及引入该问题的一种途径——编辑文件。Git 还提供了一种检测并修复其他空白字符问题的方法。你可以配置它以查找一组常见的空白字符问题。可以使用core.whitespace
配置选项来启用它们(对于那些默认禁用的选项),或将其关闭(对于那些默认启用的选项)。默认启用的三个选项如下:
-
blank-at-eol:此选项查找行尾的多余空格。
-
blank-at-eof:此选项查找文件末尾的空行。
-
space-before-tab:此选项查找行首(开始处)缩进部分的制表符前面紧跟的空格。
core.whitespace
中的trailing-space
值是一个简写,涵盖了blank-at-eol
和blank-at-eof
。
默认情况下禁用但可以启用的三个选项如下:
-
indent-with-non-tab:此选项将把使用空格字符(而不是等效的制表符)缩进的行视为错误(等效性由tabwidth选项控制)。此选项强制执行使用 Tab 字符缩进。
-
tab-in-indent:此选项会检查行的初始缩进部分是否存在制表符(此处使用tabwidth来修复此类空白错误)。此选项强制执行使用 空格字符进行缩进。
-
cr-at-eol:此选项告诉 Git,行尾的回车符是可以的(允许在仓库中使用 CRLF 行尾)。
你可以通过将core.whitespace
设置为用逗号分隔的值列表,来告诉 Git 启用或禁用这些选项。要禁用某个选项,只需在值前面加上-
前缀。例如,如果你希望启用除了cr-at-eol
和tab-in-indent
以外的所有选项,并且同时将Tab空格值设置为4
,你可以使用:
$ git config --local core.whitespace \
trailing-space,space-before-tab,indent-with-non-tab,tabwidth=4
你也可以在每个文件的基础上使用whitespace
属性来设置这些选项。例如,你可以用它来关闭对测试用例中空白字符问题的检查,处理空白字符问题,或确保 Python 2 代码使用空格缩进:
*.py whitespace=tab-in-indent
EditorConfig
存在一个名为 EditorConfig 的项目 (editorconfig.org/
),它包括一个用于定义编码风格的文件格式,其中包括行结束符的类型,以及一组文本编辑器插件,使编辑器遵循选定的风格。.editorconfig 文件应由 Git 进行跟踪。
当你运行 git diff
命令时,Git 会检测到这些问题,并通过 color.diff.whitespace
颜色通知你,以便你在创建新提交之前注意到它们并可能修复它们。在使用 git apply
应用补丁时,你可以要求 Git 通过 git apply --whitespace=warn
警告空格问题,或通过 --whitespace=error
报错,或者让 Git 尝试自动修复问题,使用 --whitespace=fix
。同样的操作也适用于 git rebase
命令。
服务器端配置
Git 服务器端有一些可用的配置选项,这些选项将在第十四章《Git 管理》一节中详细介绍;在这里,你将找到一些更有趣的参数的简短总结。
你可以让 Git 服务器检查对象的一致性,即确保每个在推送过程中接收到的对象与其 SHA-1 标识符匹配,并且是有效对象,可以通过一个名为 receive.fsckObjects
的布尔配置变量来实现。默认情况下,该选项是关闭的,因为 git fsck
是一个相对昂贵的操作,可能会拖慢操作速度,特别是在进行大规模推送时(这在大型仓库中很常见)。这是对有缺陷或恶意客户端的检查。
如果你重写了已经推送到服务器的提交(如第十章《保持历史清晰》中所解释的那样,这是不好的做法),并尝试再次推送,你将被拒绝。然而,客户端可以通过向 git push
命令添加 --force
标志来强制更新远程分支。但是,可以通过将 receive.denyNonFastForward
设置为 true
来让服务器拒绝强制推送。
receive.denyDeletes
设置阻止了 denyNonFastForward
策略的一个解决方法,即删除并重新创建一个分支。此设置禁止删除分支和标签;你必须手动从服务器中删除引用。
所有这些功能也可以通过类似于服务器端接收的钩子来实现;这将在 安装 Git 钩子 部分讨论,并在第十四章《Git 管理》中有所涉及。
使用 gitattributes 进行逐文件配置
一些自定义设置也可以为路径指定(可能通过 glob),这样 Git 仅对某些文件或子目录应用这些设置。这些路径特定的设置被称为 gitattributes。
应用此类设置的优先顺序始于存储库本地(用户本地)路径设置,位于 $GIT_DIR/info/attributes
文件中。然后,会查阅.gitattributes
文件,从涉及路径所在目录开始,逐步向上查找父目录中的.gitattributes
文件,直至工作树的顶层(项目的根目录)。
最后,还会考虑全局用户属性文件(由 core.attributesFile
指定,如果未设置,则在 ~/.config/git/attributes
中),以及系统范围内的文件(在默认安装中为 /etc/gitattributes
)。
可用的 Git 属性在第三章,管理您的工作树中有详细描述。通过属性,您可以执行诸如为特定类型的文件(例如 ChangeLog
)指定单独的合并策略,告知 Git 如何比较非文本文件,或在检出(在写入到工作区域,即文件系统时)和提交(在暂存内容和提交更改到仓库,即创建对象到仓库数据库时)时过滤内容。
Git 属性文件的语法
gitattributes
文件是一个简单的文本文件,以每个路径为基础设置本地配置。空行和以井号(#
)开头的行将被忽略;因此,以 #
开头的行用作注释,而空行可用作可读性的分隔符。要为路径指定一组属性,请将模式放在水平空格分隔的属性列表之前:
pattern attribute1 attribute2
当路径匹配多个模式时,后面的行将覆盖前面的行,就像.gitignore
文件一样(您也可以认为 Git 属性文件从最不特定的系统范围文件读取到最特定的本地存储库文件)。
Git 使用反斜杠(\
)作为模式的转义字符。因此,对于以井号开头的模式,需要在第一个井号前面加上反斜杠(写为 \#
)。由于属性信息由空格分隔,模式末尾的空格将被忽略,内部空格将被视为模式的结尾,除非它们用反斜杠引用(即写为“**** “)。
如果模式不包含斜杠(/
),即目录分隔符,Git 将把该模式视为 shell 通配符模式,并检查相对于.gitattributes
文件所在位置的路径是否匹配。例如,*.c
模式将匹配任何从.gitattributes
文件所在位置开始的 C 文件。以斜杠开头的模式匹配路径的开头。例如,/*.c
匹配 bisect.c
,但不匹配 builtin/bisect--helper.c
,而 *.c
模式将匹配两者。
如果模式包含至少一个斜杠,Git 会将其视为适合 fnmatch(3)
函数调用的 shell 通配符,并使用 FNM_PATHNAME
标志。这意味着模式中的通配符不会匹配目录分隔符,即路径名中的斜杠(/
);匹配是从路径的开始处进行的。例如,include/*.h
模式匹配 include/version.h
,但不匹配 include/linux/asm.h
或 libxdiff/includes/xdiff.h
。Shell 通配符如下:
-
***** 匹配任意字符串(包括空字符串)
-
? 匹配任何单个字符
-
[…] 表达式匹配字符类(在方括号内,星号和问号失去特殊意义);注意,与正则表达式不同,字符类的补充/否定是用 ! 而不是 ^ 来完成的。例如,要匹配任何非数字字符,可以使用 [!0-9] shell 模式,它等价于正则表达式中的 [⁰-9]。
模式中的两个连续星号(**
)可能具有特殊含义,但仅在两个斜杠之间(/**/
),或者在斜杠和模式的开始或结束之间。这种通配符匹配零个或多个路径组件。因此,前导 **
后跟斜杠(**/
)表示在所有目录中进行匹配,而尾部的 /**
匹配指定目录内的所有文件或目录。
每个属性可以在给定路径上处于四种状态之一:
-
首先,它可以是 设置(该属性具有特殊值为 true)。通过仅在属性列表中列出属性的名称来指定,例如,text。
-
其次,它可以是 取消设置(该属性具有特殊值为 false)。通过列出属性名称并加上负号来指定,例如,-text。
-
其次,它可以 设置为特定值;通过列出属性名称后跟等号及其值来指定,例如,text=auto(请注意,与配置文件语法不同,等号两边不能有空格)。
-
如果没有模式匹配路径,并且没有任何规则指定路径是否具有属性,则该属性被称为 未指定(你可以通过 !text 强制将属性显式设置为未指定)。
如果你发现自己在多个不同模式中反复使用相同的属性集,你应该考虑定义一个宏属性。这可以在本地、全局或系统范围的属性文件中定义,但(在所有可能的仓库特定属性文件位置中),宏只能在顶级 .gitignore
文件中定义。宏使用 [attr]<macro>
代替文件模式来定义;属性列表定义了宏的扩展。例如,内建的 binary
宏属性定义如下:
[attr]binary -diff -merge -text
但是,命令行选项、环境变量、配置文件、gitattributes 和 gitignore 文件并非改变 Git 行为的唯一方式。还有钩子机制,可以在 Git 执行的特定点自动触发用户定义的操作。
使用钩子自动化 Git
通常生成的代码都有一定的前提条件,可以是自我诱导的,也可以是外部强制的。代码应始终能够编译并至少通过一组快速测试。在某些开发工作流程中,每个提交消息可能需要引用一个问题 ID(或匹配消息模板),或者包括一个数字签名的 Signed-off-by 行。在许多情况下,这些开发过程的一部分可以由 Git 自动化完成。
像许多编程工具一样,Git 包括一种方法来触发用户提供的代码中包含的自定义功能(自定义脚本),当某些重要预定义操作发生时,即当某些事件触发时。作为事件处理程序调用的这种功能称为 钩子。它允许我们采取额外的操作,并且至少对于某些钩子,还可以停止触发的功能。
Git 中的钩子可以分为客户端钩子和服务器端钩子。客户端钩子由本地操作(在客户端上)触发,如提交、应用补丁系列、变基和合并。另一方面,服务器端钩子在网络操作发生时在服务器上运行,例如接收推送的提交。
您还可以将钩子分为预钩子和后钩子。预钩子在操作完成之前调用,通常是在执行操作的下一步之前。如果它们以非零值退出,则会取消当前的 Git 操作。后钩子在操作完成后调用,可用于通知和日志记录;它们无法取消操作。
安装 Git 钩子
Git 中的钩子是可执行程序(通常是脚本),存储在 Git 仓库管理区域的 hooks/
子目录中,即非裸仓库的 .git/hooks/
。您可以通过 core.hooksPath
配置变量更改 Git 搜索钩子的目录位置。
钩子程序的命名方式是根据触发它们的事件命名的。这意味着如果您希望一个事件触发多个脚本,您将需要自己实现多路复用。
当你使用 git init
初始化一个新的仓库时(在使用 git clone
创建另一个仓库的副本时也会执行此操作;clone
内部会调用 init
),Git 会将一堆未激活的示例脚本填充到 .git/hooks/
目录中。许多这些脚本本身就非常有用,而且它们还记录了钩子的 API。所有的示例都是用 shell 或 Perl 脚本编写的,但任何正确命名的可执行文件都可以正常工作。如果你想使用捆绑的示例钩子脚本,你需要重命名它们,去掉 .sample
扩展名,并确保它们具有可执行权限。
仓库模板
有时你可能希望为所有的仓库使用相同的钩子集。你可以拥有一个全局(每个用户和系统范围内的)配置文件,一个全局属性文件和一个全局忽略列表。实际上,可以选择在创建仓库时填充钩子。默认的示例钩子会被复制到 .git/hooks
仓库,这些钩子是从 /usr/share/git-core/templates
填充的。
此外,可以将包含仓库创建模板的替代目录作为参数传递给 --template
命令行选项(用于 git clone
和 git init
),作为 GIT_TEMPLATE_DIR
环境变量,或作为 init.templateDir
配置选项(可以在每个用户的配置文件中设置)。该目录必须遵循 .git
(或 $GIT_DIR
)的目录结构,这意味着钩子需要位于其中的 hooks/
子目录下。
需要注意的是,这种机制有一些限制。由于模板目录中的文件只会在初始化时复制到 Git 仓库,因此对模板目录的更新不会影响现有的仓库。尽管你可以重新运行 git init
来重新初始化现有仓库,但请记得保存对钩子所做的任何修改。
钩子管理工具
为一组开发人员维护钩子可能会很棘手。有很多工具和框架可以用于 Git 钩子的管理;例如 Husky 和 pre-commit。你可以在 githooks.com
网站上找到更多此类工具的示例。这些工具通常允许更轻松地跳过钩子、为所有钩子运行公共代码,或为特定钩子运行多个脚本。
客户端钩子
客户端钩子有很多种。它们可以分为提交工作流钩子(由创建新提交的不同阶段调用的一组钩子)、应用邮件工作流钩子以及其他所有未组织成多钩子工作流的部分。
重要提示
需要注意的是,钩子在你克隆仓库时不会被复制。这部分是出于安全原因,钩子是无人值守并且大多不可见地运行的。你需要手动复制(并重命名)钩子文件,但在创建或重新初始化仓库时,你可以控制哪些钩子会被安装(见前面的章节)。这意味着,你不能依赖客户端钩子来强制执行某些策略;如果你需要引入一些强制要求,必须在服务器端进行。
提交过程钩子
在提交更改时(默认情况下)会调用四个客户端钩子。它们如下所示。
pre-commit 钩子
git commit --no-verify
。这个钩子没有参数。
这个钩子可以用于检查正确的代码风格、运行静态代码分析器(linter)检查问题代码结构、确保代码能够编译并通过所有测试(并且确保新代码被测试覆盖),或者检查某些新功能的适当文档。默认钩子检查空白错误(默认是尾部空白)使用 git diff --check
(或者其内部实现),并可选地检查修改文件中的非 ASCII 文件名。例如,你可以创建一个钩子,在提交时确认工作区有未提交的修改(这些修改不会成为当前提交的一部分);不过这是一个高级技巧。或者,你也可以让它检查新方法是否有文档和单元测试。
还有 git merge
。默认情况下,当启用时,该钩子会运行 pre-commit
钩子。
prepare-commit-msg 钩子
commit.template
(如果有的话),并在提交信息在编辑器中打开之前。它允许你在提交作者看到信息之前,编辑默认的提交信息或以编程方式创建一个模板。如果钩子以非零状态失败,提交将被中止。这个钩子以文件路径作为参数,该文件包含提交信息(稍后传递给编辑器),以及提交信息的来源信息(对于普通的 git commit
不存在此信息):如果给定了 -m
或 -F
选项、给定了 -t
选项或设置了 commit.template
、存在 .git/MERGE_MSG
文件、存在 .git/SQUASH_MSG
文件,或者给定了 -c
、-C
或 --amend
选项。在最后一种情况下,钩子会获得额外的参数,即作为信息来源的提交的 SHA-1 哈希值。
这个钩子的目的是编辑或创建提交信息,并且该钩子不会被 --no-verify
选项抑制。当使用自动生成默认信息的提交时,这个钩子最为有用,例如模板化的提交信息、合并提交、压缩提交和修改提交。Git 提供的示例钩子会将合并提交信息中的 Conflict:
部分注释掉。
另一个示例是,这个钩子可以使用 branch.<branch-name>.description
给出的当前分支描述(如果存在)作为分支相关的动态提交模板的基础。或者,它可以检查我们是否在主题分支上,然后列出项目问题跟踪器中分配给你的所有问题,以便轻松地将适当的工件 ID 添加到提交信息中。
commit-msg 钩子
.git/COMMIT_EDITMSG
。
如果这个脚本以非零状态退出,Git 会中止提交过程,因此你可以使用它来验证,例如,提交信息是否与项目状态匹配,或提交信息是否符合所需的模式。Git 提供的示例钩子可以检查、排序并删除重复的 Signed-off-by:
行(如果签名是来源链的一部分,这可能不是你想要的)。你也可以在这个钩子中检查问题编号的引用是否正确(并可能扩展它们,添加每个提到的问题的当前总结)。
Gerrit 代码审查提供了一个 commit-msg
钩子(需要安装在本地 Git 仓库中),它可以在 git commit
时自动创建、插入并维护一个唯一的 Change-Id:
行,位于签名行之上。此行用于追踪提交的迭代;如果推送到 Gerrit 的修订版缺少此信息,服务器将提供关于如何获取和安装该钩子脚本的说明。
post-commit 钩子
HEAD
。这个钩子的退出状态会被忽略。还有 post-merge 钩子。
通常,这个脚本(像大多数 post-*
脚本一样)最常用于通知和日志记录,显然它无法影响 git commit
的结果。你可以用它,例如,触发一个本地构建,使用类似 Jenkins 的持续集成工具。然而,在大多数情况下,你可能会希望在专用的持续集成服务器上使用 post-receive
钩子来完成这项工作。
另一个用例是列出所有代码和文档中的 TODO 和 FIXME 注释信息(例如,作者、版本、文件路径、行号和信息),将它们打印到钩子的标准输出中,以确保它们不会被遗忘,并保持最新和有用。
用于从电子邮件应用补丁的钩子
你可以为基于电子邮件的工作流(提交通过电子邮件发送)设置三个客户端钩子。它们都由 git am
命令调用(其名称来源于 git format-patch
,并通过 git send-email
发送),并将它们转化为一系列的提交。接下来我们将讨论这些钩子。
applypatch-msg 钩子
第一个运行的钩子是applypatch-msg 钩子。它在从补丁中提取提交消息后、应用补丁之前运行。像往常一样,对于非 post-*钩子,如果该钩子以非零状态退出,Git 将中止应用补丁。它接受一个参数:包含提取的提交消息的临时文件名。
你可以使用此钩子确保提交消息的格式正确,或通过让脚本修改文件来规范化提交消息。Git 提供的示例applypatch-msg
钩子会在存在钩子(文件存在且可执行)时,运行commit-msg
钩子。
pre-applypatch 钩子
下一个运行的钩子是git am
脚本,但不提交补丁。
Git 提供的示例钩子会运行pre-commit
钩子(如果存在)。
post-applypatch 钩子
最后运行的钩子是post-applypatch 钩子,它在提交完成后运行。它可以用于通知或记录日志,例如,通知所有开发者或仅通知补丁的作者你已应用了补丁。
其他客户端钩子
还有一些其他不属于单一过程步骤的客户端钩子。
pre-rebase 钩子
pre-*
钩子可以通过非零退出码中止变基过程。你可以使用此钩子来禁止对已发布的任何提交进行变基(即重写)。该钩子会使用基分支的名称(分支系列所分叉的上游分支名称)和正在变基的分支名称进行调用。只有当变基的分支不是当前分支时,才会将变基的分支名称传递给钩子。Git 提供的示例pre-rebase
钩子会尝试执行这一操作,尽管它假设了一些特定于 Git 项目开发的内容,这些假设可能不符合你的工作流程(请注意,修改提交也会重写它们,且变基可能会创建一个分支副本而非重写它)。
pre-push 钩子
git push
操作,在检查远程状态并确定服务器上缺少哪些版本后,但在推送任何更改之前调用该钩子。该钩子使用远程的引用(URL 或远程名称)和实际的推送 URL(远程位置)作为脚本参数进行调用。待推送的提交信息通过标准输入提供,每个要更新的引用占一行。你可以使用此钩子在推送发生前验证一组引用更新;非零退出码会中止推送。Git 提供的示例会检查待推送的版本集中是否存在以WIP开头的提交,或者在提交消息中标记了nopush关键字,如果符合这两种情况中的任何一种,则会中止推送。你甚至可以让钩子提示用户确认他们是否确定要进行此操作。这个钩子补充了服务器端的检查,避免了那些反正会失败验证的数据传输。
post-rewrite 钩子
git commit --amend
和 git rebase
。但是,请注意,这个钩子不会在大规模历史重写时执行,例如 git filter-repo
。触发重写的命令类型(post-checkout
和 post-merge
钩子),并且它会在自动复制注释之后运行,这由 notes.rewriteRef
配置变量控制(你可以在 第十章**, 保持 历史清洁 中找到更多关于注释机制的信息)。
post-checkout 和 post-merge 钩子
在更新工作树之后使用 git checkout
(或 git checkout <file>
)。该钩子会传递三个参数:前一个和当前 HEAD
的 SHA-1 哈希值(这两个值可能相同也可能不同),以及一个标志,指示是否为整个项目的检出(你正在切换分支;标志参数为 1),或者是文件的检出(从索引或指定的提交中恢复文件;标志参数为 0)。作为特殊情况,在 git clone
之后的初始检出期间,此钩子将传递全零的 SHA-1 作为第一个参数(作为源修订版本)。你可以使用这个钩子来根据你的使用场景正确设置工作目录。这可能意味着处理存储库之外的大型二进制文件(作为替代方案,不需要在每个文件上应用 filter
Git 属性),或者设置工作目录的元数据属性,例如完整的权限、所有者、组、时间、扩展属性或 ACL。它还可以用于执行存储库有效性检查,或者通过自动显示与之前检出版本之间的差异(或仅显示差异统计信息),来增强 git checkout
输出(如果它们有所不同)。
post-checkout
用于恢复 Git 不跟踪的工作树中的数据和元数据,如完整的权限数据(或者直接调用 post-checkout
)。这个钩子同样可以验证 Git 控制之外的文件是否存在,并在工作树发生变化时将其复制进来。
对于 Git,存储库中的对象(例如,表示修订的提交对象)是不可变的;重写历史(即使是修改提交)实际上是在创建一个修改过的副本并切换到它,从而将重写前的历史遗弃。
pre-auto-gc 钩子
删除分支也会留下被遗弃的历史。为了防止存储库过度膨胀,Git 会偶尔执行垃圾回收,通过移除旧的、未引用的对象来清理。除非是在 Git 的最古老版本中,否则这通常会作为正常 Git 操作的一部分通过执行 git gc --auto
来完成。pre-auto-gc 钩子 在垃圾回收发生之前被调用,可以用于中止操作,例如,如果你正在使用电池电源。它也可以用来通知你正在进行垃圾回收。
服务器端钩子
除了在你自己仓库中运行的客户端钩子外,还有一些重要的 服务器端钩子,系统管理员可以使用这些钩子来强制执行几乎任何类型的项目策略。
这些钩子会在你推送到服务器之前和之后运行。前置钩子(如前所述)可以通过退出非零状态来拒绝推送或其中一部分;前置钩子打印的消息将被发送回客户端(发送者)。你可以利用这些钩子来设置复杂的推送策略。Git 仓库管理工具,如 gitolite
和 Git 托管解决方案,使用这些钩子来实现更复杂的仓库访问控制。后置钩子可以用于通知、启动构建过程(或仅仅是重新构建和重新部署文档),或者运行完整的测试套件,例如作为 CI 解决方案的一部分。
在编写服务器端钩子时,你需要考虑钩子在操作序列中的位置以及可用的相关信息,这些信息以参数、标准输入和仓库的形式提供。
让我们回顾一下服务器接收到推送时发生的事情:
-
简单来说,第一步是将客户端存在而服务器缺失的所有对象发送到服务器并存储(但尚未被引用)。如果接收端未能正确完成此操作(例如,由于磁盘空间不足),整个推送操作将失败。
-
pre-receive 钩子会运行。它会将描述正在推送引用的列表传递到标准输入。如果它以非零状态退出,则会中止整个操作,且所有推送的引用都不会被接受。
-
对于每个正在更新的引用,内置的有效性检查可能会拒绝对该引用的推送,包括检查正在更新的分支、非快进推送(除非强制推送)等。
-
update 钩子会为每个引用运行,并将要推送的引用作为参数传递;如果此脚本以非零状态退出,只有这个引用会被拒绝。
-
对于每个推送的引用,相关引用会被更新(除非在早期阶段被拒绝)。
-
post-receive 钩子会运行,接收与 pre-receive 钩子相同的数据。这个钩子可以用于更新其他服务(例如,通知 CI 服务器)或通知用户(通过电子邮件、邮件列表、IRC 或问题跟踪系统)。
如果推送是原子的,要么所有引用都被更新(如果没有被拒绝),要么都不更新。
对于每个更新的引用,运行 git update-server-info
来准备一个仓库,保存额外的信息以便在 简单 传输中使用,尽管如果在 post-receive
后运行会更有效。
如果推送尝试更新当前检出的分支,而 receive.denyCurrentBranch
配置变量被设置为 updateInstead
,则会运行 push-to-checkout 钩子。
重要提示
你需要记住,在 pre 钩子中,引用尚未更新,而 post 钩子不能影响操作结果。你可以将 pre 钩子用于访问控制(权限检查),并将 post 钩子用于通知、更新附加数据和日志。
你将在第十四章《Git 管理》中看到 Git 强制执行的策略示例钩子(包括服务端和客户端)。你还将学习其他工具如何使用这些钩子,例如用于访问控制和在推送时触发操作。
扩展 Git
Git 提供了几种扩展机制。你可以添加快捷方式、创建新命令,并为新传输方式提供支持;所有这些都不需要你修改 Git 源代码。
Git 命令别名
有一个小技巧可以使你的 Git 命令行体验更简单、更容易、更熟悉,即 alias.<command-name>
配置变量;其值是别名的扩展。
使用别名的一个场景是为常用命令及其参数定义简短的缩写。另一个场景是创建新命令。以下是你可能希望设置的一些示例:
$ git config --global alias.co checkout
$ git config --global alias.ps = '--paginate status'
$ git config --global alias.lg "log --graph --oneline --decorate"
$ git config --global alias.aliases 'config --get-regexp ^alias\.'
前述设置意味着例如输入 git co
等同于输入 git checkout
,而 git aliases
会打印出所有定义的别名。别名接受参数,和常规 Git 命令一样。Git 默认不提供任何别名来为常见操作定义快捷方式,除非你使用 git-fc
项目,这是 Felipe Contreras 对 Git 的一个友好的分支。
这里的参数由空格分隔,并且支持常规的 shell 引号和转义方式。特别地,你可以使用引号对 ("a b"
) 或反斜杠 (a\ b
) 来将空格包含在单个参数中。
重要提示
然而需要注意的是,你不能将别名命名为与 Git 命令相同的名称。换句话说,你不能通过别名来改变命令的行为。做出这一限制的原因是,这样可能会导致现有的脚本和钩子意外失败。隐藏现有 Git 命令的别名(与 Git 命令同名的别名)会被直接忽略。
然而,你可能希望在别名中运行外部命令,而不是 Git 命令。或者,你可能希望将几个独立命令的结果组合在一起。在这种情况下,你可以通过 !
字符来开始别名定义:
$ git config --global alias.unmerged \
'!git ls-files --unmerged | cut -f2 | sort -u'
由于在这里别名扩展的第一个命令可能是外部工具,因此需要明确指定 git
包装器,如前面的示例所示。
注意
请注意,在许多 shell 中,例如在 bash 中,感叹号 ! 是历史扩展字符,需要转义为 ! 或者放在单引号 (') 内。
请注意,这些 shell 命令将从仓库的顶级目录执行(在执行 cd
到顶级目录后),而不一定是当前目录。Git 会将 GIT_PREFIX
环境变量设置为相对于仓库顶级目录的当前目录路径,即 git rev-parse --show-prefix
。和往常一样,git rev-parse
(以及一些 git
包装选项)在这里可能会派上用场。
前面提到的事实可以在创建别名时使用。git serve
别名,通过运行 git daemon
来服务(只读)当前仓库,地址为 git://127.0.0.1/
,利用了别名中的 shell 命令从仓库的顶级目录执行这一事实:
[alias]
serve = !git daemon --reuseaddr --verbose --base-path=. --export-all ./.git
有时候,你需要重新排列参数,使用某个参数两次,或者在管道的早期阶段传递一个参数。你可能想要像在 shell 脚本中那样,通过 $1
、$2
等方式引用后续参数,或者通过 $@
来引用所有参数。一个你可以在旧示例中找到的技巧是使用 -c
参数运行 shell,如下文第一个示例所示;最后的短横线是为了让参数从 $1
开始,而不是从 $0
开始。一种更现代的方式是定义并立即执行一个 shell 函数,如第二个示例所示(这种方法更受欢迎,因为它少用了一级引号,并且允许你使用标准的 shell 参数处理):
[alias]
record-1 = !sh -c 'git add -p -- $@ && git commit' -
record-2 = !f() { git add -p -- $@ && git commit }; f
别名与命令行补全功能集成。在确定使用哪个补全时,Git 会搜索一个 git
命令,跳过开括号或单引号(因此支持之前提到的两种语法)。在现代 Git 中,你可以使用空命令 ":
" 来声明所需的补全样式。例如,别名扩展为以下内容:
!f() { : git commit ; ... } f
会使用 git commit
的命令补全,不管别名的其余部分如何。
Git 别名也与帮助系统集成。如果你在别名上使用 --help
选项,Git 会告诉你它的扩展(这样你可以查看相关的手册页):
$ git co --help
'git co' is aliased to 'checkout'
添加新的 Git 命令
别名最适合将小的单行命令转化为小而有用的 Git 命令。你可以写复杂的别名,但当涉及到较大的脚本时,你可能更希望将它们直接集成到 Git 中。
Git 子命令可以是独立的可执行文件,位于 Git 执行路径中(你可以通过运行 git --exec-path
来找到这个路径);在 Linux 上,通常位于 /usr/libexec/git-core
。git
可执行文件本身是一个薄的包装器,知道子命令的位置。如果 git foo
不是内建命令,包装器首先会在 Git 执行路径中搜索 git-foo
命令,然后在 $PATH
中的其他地方继续搜索。后者使得你可以编写本地 Git 扩展(本地 Git 命令),而无需访问系统的空间。
这个特性使得在项目中实现一个与 Git 其余部分更或多或少集成的用户界面成为可能,比如在 git imerge
(见 第九章,合并更改)中,或者 git lfs
或 git annex
(见 第十二章,管理大型仓库)中。这也是像 Git Extras 这样的项目得以实现的原因,它们提供了额外的 Git 命令。
然而,请注意,如果你没有将命令的文档安装到典型位置,或者没有配置文档系统以查找命令的帮助页面,那么 git foo --help
将无法正确工作。
你可以通过 git --list-cmds=others
列出所有以这种方式安装的外部命令,或者你可以使用 git help --all
,该命令的输出末尾将出现以下列表:
$ git --list-cmds=others
credential-helper-selector
credential-manager
lfs
凭证助手和远程助手
还有一个地方,通过简单地放置一个适当命名的可执行文件,可以增强和扩展 Git。Git 会在需要与远程仓库和 Git 本身不原生支持的远程传输协议交互时调用 远程助手 程序。你可以在 第六章 中了解更多关于它们的内容,使用 Git 进行协作开发。
当 Git 遇到 <transport>://<address>
形式的 URL 时,其中 <transport>
是一个 Git 本身不原生支持的(伪)协议,它会自动调用 git remote-<transport>
命令,并以远程地址和完整的远程 URL 作为参数。<transport>::<address>
形式的 URL 也会调用这个远程助手,但只会将 <address>
作为第二个参数,而不是 URL。另外,如果 remote.<remote-name>.vcs
设置为 <transport>
,Git 将显式调用 git remote-<transport>
来访问该远程。
Git 中的助手机制是通过使用一个明确定义的格式与外部脚本进行交互。
每个远程助手都需要支持一组命令。你可以在 gitremote-helpers(1)
手册页中找到有关创建新助手的更多信息。
Git 中还有另一种助手,即 凭证助手。它们可以被 Git 用来获取用户所需的凭证,例如,用于通过 HTTP 访问远程仓库。这些助手是通过配置指定的,就像合并和差异驱动程序以及清理和污染过滤器一样。
摘要
本章提供了你使用 Git 所需的所有工具。你了解了如何通过 Git-aware 动态命令提示符、命令行自动补全、Git 命令的自动修正以及使用颜色来使命令行界面更易用、更有效。你还了解了替代接口的存在,从替代的瓷器到各种图形客户端。
你已经了解了多种更改 Git 命令行为的方式。你发现了 Git 如何访问其配置,并了解了一部分配置变量。你学会了如何通过钩子自动化 Git 以及如何利用它们。最后,你还学会了如何通过新命令和支持新的 URL 方案来扩展 Git。
本章主要讲解了如何让 Git 对你更高效;下一章,第十四章**, Git 管理,将解释如何让 Git 对其他开发者更高效。你将学习更多关于服务器端钩子的内容,并看到它们的使用。你还将学习关于仓库维护的知识。
问题
回答以下问题,测试你对本章内容的理解:
-
如何保存和重用你最喜欢的 Git 命令选项组合?
-
如何找到所有创建的别名?
-
如何运行图形化工具来显示git diff,或帮助解决合并问题?
-
如何找到给定配置的来源?
-
如何确保一个提交符合推荐的最佳实践?
答案
以下是上面问题的答案:
-
使用 Git 别名、Shell 别名或 Shell 函数。
-
你可以使用git config --get-regexp ^****alias. 命令。
-
使用git difftool显示差异,或使用git mergetool帮助解决合并冲突。内置支持许多现有的图形化工具。
-
如果是关于配置值,你可以使用git config --show-origin(或--show-scope)。如果是关于每个文件的属性,可以使用git check-attr。如果是关于忽略文件,可以使用git check-ignore。
-
使用pre-commit钩子(以及其他类似的钩子)来提醒如果没有遵循最佳实践。有许多第三方工具可以帮助管理钩子,并且通常支持各种辅助工具,如代码检查器和格式化工具。
进一步阅读
要了解更多本章所涉及的主题,请查看以下资源:
-
Scott Chacon, Ben Straub: Pro Git, 第二版 (2014),Apress
git-scm.com/book/en/v2
-
第二章 - Git 基础,2.1 节 - Git 别名
-
第八章 - 自定义 Git
-
附录 A: Git 在 其他环境中的应用
-
-
Matthew Hudson: Git 钩子 - 程序员指南
githooks.com/
-
bash/zsh git 提示 支持
github.com/git/git/blob/master/contrib/completion/git-prompt.sh
-
bash/zsh 完成支持核心 Git
github.com/git/git/blob/master/contrib/completion/git-completion.bash
-
Seth House:在各种合并工具中的冲突解决(2020)
www.eseth.org/2020/mergetools.html
-
Julia Evans:流行的 git 配置选项(2024)
jvns.ca/blog/2024/02/16/popular-git-config-options/
-
Ricardo Gerardi:让我更高效的 8 个 Git 别名(2020)
opensource.com/article/20/11/git-aliases
-
Git SCM Wiki(已归档):别名
archive.kernel.org/oldwiki/git.wiki.kernel.org/index.php/Aliases.html
-
Git 首页 - GUI 客户端
git-scm.com/downloads/guis
-
Git 版本 新闻
git.github.io/rev_news/
第十四章:Git 管理
前一章,自定义和扩展 Git,其中解释了如何使用 Git 钩子进行自动化。客户端钩子进行了详细描述,而服务器端钩子仅做了简要介绍。在本章中,我们将全面介绍服务器端钩子,并讨论客户端钩子作为助手的使用。
前面的章节帮助你作为开发者、团队成员和维护者掌握了 Git 的使用。当书中讨论设置仓库和分支结构时,是从 Git 用户的角度出发的。
本章旨在帮助那些处理 Git 管理方面工作的读者。这包括设置远程 Git 仓库和配置其访问权限。本章涵盖了让 Git 顺利运行所需的工作(即 Git 维护),以及如何发现并从仓库错误中恢复。同时还介绍了如何使用服务器端钩子来实施和强制执行开发政策。此外,你还会找到关于用于管理远程仓库的各种工具的简短描述,帮助你从中进行选择。
本章将涵盖以下内容:
-
服务器端钩子 – 实施政策和通知
-
如何在服务器上设置 Git
-
使用第三方工具管理远程仓库
-
签名推送以确保更新引用并启用审计
-
使用替代和命名空间减少托管仓库的大小
-
改进服务器性能并帮助初始化克隆
-
检查仓库是否损坏并修复仓库
-
在 reflog 的帮助下恢复错误并使用 git fsck
-
Git 仓库维护和重新打包
-
用 Git 增强开发工作流
仓库维护
偶尔,你可能需要清理一个仓库,通常是为了让它变得更紧凑。这种清理在从其他版本控制系统迁移仓库后也是一个非常重要的步骤。
使用 git-gc 自动化清理工作
现代 Git(或者说,几乎所有版本的 Git)会不时在每个仓库中运行git gc --auto
命令。这个命令检查是否有太多松散对象(对象作为单独的文件存储,每个对象一个文件,而不是存储在打包文件中;对象几乎总是以松散形式创建的),如果有,便会启动垃圾回收操作。垃圾回收意味着将所有松散对象收集起来并放入打包文件中,还会将多个小打包文件合并为一个大打包文件。此外,它还会将引用打包到packed-refs
文件中。那些即使通过 reflog 也无法访问且已经足够旧的对象,默认情况下会单独打包到一个垃圾打包文件中。然后,Git 会删除松散对象、垃圾打包文件和重新打包过的打包文件(保留松散对象文件的年龄安全边际),从而修剪掉旧的不可访问对象。gc.*
命名空间中有多个配置选项可以控制垃圾回收操作。
你可以手动运行auto gc
,使用git gc --auto
,或者通过git gc
强制垃圾回收。git count-objects
命令(有时配合-v
参数)可用于检查是否有需要重新打包的迹象。你甚至可以通过git repack
、git pack-refs
、git prune
和git prune-packed
分别运行垃圾回收的单个步骤。
默认情况下,Git 会尽量重用早期打包的结果,以减少重新打包时的 CPU 时间,同时仍能提供良好的磁盘空间利用率。在某些情况下,你可能希望通过更多的时间成本来更积极地优化仓库的大小;这可以通过git gc --aggressive
实现(或者通过手动使用git repack
并使用适当的参数重新打包仓库)。建议在从其他版本控制系统导入数据后执行此操作,因为 Git 用于导入的机制(即fast-import
流)是为了操作的速度优化的,而不是为了最终仓库的大小。
有一些维护问题是git gc
无法覆盖的,因为它们的性质。其中之一是修剪(删除)已经在远程仓库中删除的远程跟踪分支。可以使用git fetch --prune
或git remote prune
,或者通过git branch --delete --remotes <remote-tracking branch>
按分支逐个操作。这一操作由用户执行,而不是由git gc
运行,因为 Git 无法知道你是否基于将要被修剪的远程跟踪分支来进行自己的工作。
使用 git-maintenance 进行定期维护
像 git add
或 git fetch
这样将数据添加到仓库的 Git 命令,会触发自动垃圾回收并进行一些仓库优化。然而,由于这些命令需要提供响应式的用户界面,它们不会触发更昂贵的仓库优化任务。这些任务包括更新提交图数据、从远程仓库预取数据(这样 git fetch
就可以下载更少的对象)、清理松散对象以及进行增量重新打包。这些优化任务通常会随着仓库的整体大小增长而增加。
更好的解决方案是将耗费资源的维护任务定期地在后台运行——每小时、每天或每周运行一次。借助现代 Git,你可以通过 git maintenance
命令来调度这些任务。它会根据操作系统的不同,安排这些任务的运行方式。
你可以配置给定任务的运行频率。请注意,git maintenance run
这个执行调度任务的过程,会对仓库的对象数据库加锁,防止竞争进程导致仓库处于无法预测的状态。git gc
则不会这样做;因此,如果你进行定期维护,应该使用 git maintenance run --task=gc
而不是直接使用 git
的 gc
命令。
数据恢复与故障排除
几乎不可能做到从不犯错。这同样适用于使用 Git。书中呈现的知识,以及你使用 Git 的经验,应该有助于减少错误的发生。请注意,Git 会尽力避免帮助你丢失工作;很多错误是可以恢复的。下一节将解释如何尝试从错误中恢复。
恢复丢失的提交
可能会发生你不小心丢失了一个提交。也许你强制删除了一个错误的分支,或者你将分支回退到了错误的位置,或者在开始操作时,你所在的分支是错误的。如果发生了类似的情况,有没有办法找回你的提交并撤销这个错误呢?
因为 Git 并不会立即删除对象,而是将其保留一段时间,只有在垃圾回收阶段发现对象不可达时才会删除,所以你丢失的提交仍然存在;你只需要找到它。正如前面所提到的,垃圾回收操作有自己的安全边界;不过,如果你发现需要进行故障排除,最好暂时关闭自动垃圾回收功能,可以通过 git config gc.auto never
来关闭(并且如果 git maintenance
被调度定期运行,可以通过将 maintenance.gc.enabled
设置为 false 或通过 git maintenance unregister
关闭维护,来关闭 gc
任务)。
通常,找回丢失提交的最简单方法是使用git reflog
工具。对于每个分支,以及单独的HEAD
,Git 会悄悄记录(日志)分支的顶部在本地仓库中的位置、何时到达以及如何到达。这个记录被称为reflog。每次提交或回滚分支时,分支和 HEAD 的 reflog 都会更新。每次切换分支时,HEAD 的 reflog 也会更新,依此类推。
你可以通过运行git reflog
或git reflog <branch>
查看分支顶部曾经的位置。你也可以运行git log -g
,其中-g
是--walk-reflog
的简写;这将显示一个正常的可配置日志输出。还有--grep-reflog=<pattern>
可以用来搜索 reflog:
$ git reflog
6c89dee HEAD@{0}: commit: Ping asynchronously
d996b71 HEAD@{1}: rebase -i (finish): returning to refs/heads/ajax
d996b71 HEAD@{2}: rebase -i (continue): Ping asynchronously WIP
89579c9 HEAD@{3}: rebase -i (pick): Use Ajax mode
7c6d322 HEAD@{4}: commit (amend): Simplify index()
e1e6f65 HEAD@{5}: cherry-pick: fast-forward
eea7a7c HEAD@{6}: checkout: moving from ssh-check to ajax
c3e77bf HEAD@{7}: reset: moving to ajax@{1}
你应该记得第四章中的<ref>@{<n>}
语法,探索项目历史。通过 reflog 中的信息,你可以将相关分支回滚到操作之前的版本,或者可以从列表中的任何提交开始创建一个新分支。
假设你的丢失是因为删除了错误的分支。由于 reflog 的实现方式(例如,foo
分支的日志——即refs/heads/foo
的日志——被保存在.git/logs/refs/heads/foo
文件中),给定分支的 reflog 会在删除分支时一并删除。除非你没有涉及工作区,单纯操作了分支顶部,否则你可能仍然可以在HEAD
的 reflog 中找到必要的信息,但这可能不容易找到。
如果 reflog 中没有相关信息,可以使用git fsck
工具来查找恢复丢失对象所需的信息,它可以检查你的仓库是否完整。使用--full
选项,你可以用此命令显示所有未引用的对象:
$ git fsck --full
Checking object directories: 100% (256/256), done.
Checking objects: 100% (58/58), done.
dangling commit 50b836cb93af955ca99f2ccd4a1cc4014dc01a58
dangling blob 59fc7435baf79180a3835dddc52752f6044bab99
dangling blob fd64375c1f2b17b735f3145446d267822ae3ddd5
[...]
你可以在git fsck
输出的行中看到未引用(丢失)提交的 SHA1 标识符,通过grep "commit"
查找提交,使用cut -d' ' -f3
提取 SHA1 标识符,然后将这些修订输入git log --stdin --no-walk
,如下所示:
$ git fsck --full | grep "commit" | cut -d' ' -f3 | git log --stdin --no- walk
提示
同样的技术,但使用blob命令,可以用来恢复意外删除的文件——前提是你在想要恢复的文件版本上使用了git add。
故障排除 Git
git fsck
的主要目的是检查仓库是否损坏。除了可以找到悬挂对象外,这个命令还会对每个对象进行完整性检查,并跟踪对象的可达性。它可以发现损坏和丢失的对象;如果损坏仅限于你的克隆,且正确版本可以在其他仓库(备份和其他存档)中找到,你可以尝试从未损坏的源中恢复这些对象。
然而,有时错误可能更难恢复。你可以尝试寻找团队外的 Git 专家,但通常,仓库中的数据是专有的,无法创建问题的最小化重现。对于现代 Git,如果问题是结构性问题,你可以尝试使用git fast-export --anonymize
来去除仓库中的数据,同时确保匿名化后的仓库能够重现该问题。重现某些 bug 可能需要引用特定的提交或路径;对于现代 Git,你可以请求某个特定标记保持原样,或者通过一组--anonymize-map
选项将其映射到新值。
如果仓库本身没问题,但问题出在 Git 操作上,你可以尝试使用 Git 内置的各种追踪和调试机制,或者尝试增加命令的详细程度。你可以通过适当的环境变量来启用追踪功能(稍后我们会展示)。通过将相应环境变量的值设置为1、2或true,追踪输出可以被写入标准错误流。0或false值则会禁用追踪。介于 2 和 10 之间的其他整数值会被解释为用于追踪输出的打开文件描述符。你也可以将这些环境变量设置为文件的绝对路径,以便将追踪信息写入该文件。
这些与追踪相关的变量包括以下内容(请参阅git
包装器的手册页以获取完整列表):
-
GIT_TRACE:启用一般的追踪消息,适用于任何不属于特定类别的消息。这包括 Git 别名的扩展(参见第十三章,定制和扩展 Git)、内置命令执行和外部命令执行(例如分页器、编辑器或助手)。
-
GIT_TRACE_PACKET:启用对“智能”传输协议的网络操作进行数据包级别的追踪。这有助于调试协议问题或你设置的远程服务器上的任何问题。要调试并从浅层仓库获取数据,可以使用GIT_TRACE_SHALLOW。
-
GIT_TRACE_CURL(可能与GIT_TRACE_CURL_NO_DATA一起使用):启用对 HTTP(S) 传输协议的curl完整追踪,类似于运行curl --trace-ascii选项。
-
GIT_TRACE_SETUP:启用追踪消息,打印有关仓库管理区域、工作区、当前工作目录和前缀(最后一个是仓库目录结构中的子目录)的位置。
-
GIT_TRACE_PERFORMANCE:显示每个 Git 命令的总执行时间。
使用现代 Git,你可以启用来自trace2
库的更详细的跟踪信息,格式可以是简单的文本格式(供人类使用)通过GIT_TRACE2
,也可以是用于机器解析的 JSON 格式通过GIT_TRACE2_EVENT
。除了将输出重定向到标准错误、指定的文件描述符或指定的文件,你还可以要求将输出文件写入指定目录(每个进程一个文件),甚至可以请求将路径打开为 Unix 域套接字。GIT_TRACE_PERFORMANCE
的 Trace2 API 替代方案是GIT_TRACE2_PERF
。除了环境变量,你还可以分别使用trace2.normalTarget
、trace2.eventTarget
和trace2.perfTarget
配置变量。
还有GIT_CURL_VERBOSE
,它会输出由 curl 库生成的所有 HTTP 网络操作的消息,以及GIT_MERGE_VERBOSITY
,它用来控制递归合并策略显示的输出量。
服务器上的 Git
前几章应该已经为你提供了足够的知识,帮助你掌握大多数日常的 Git 版本控制任务。第六章,使用 Git 进行协作开发,解释了如何布局仓库以进行协作。在这里,我们将讲解如何设置 Git 仓库,以便在服务器上启用远程访问,允许你从中获取和推送数据。
Git 仓库的管理是一个庞大的话题。关于特定仓库管理解决方案(如 Gitolite、Gerrit、GitHub 或 GitLab)的书籍也已出版。在这里,希望你能找到足够的信息,帮助你选择解决方案或自行开发。
让我们从管理远程仓库的工具和机制开始,然后再讲解如何提供 Git 仓库服务(即将 Git 部署在服务器上)。
服务器端钩子
在服务器上调用的钩子可用于服务器管理;其中,钩子可以通过执行授权步骤来控制对远程仓库的访问,并且它们可以确保提交进入仓库时符合某些最基本的标准。后者最好通过客户端钩子的额外帮助来完成,这些钩子在第十三章,定制和扩展 Git中已有讨论。这样,用户在提交时才不会被通知提交未通过验证。相反,实现验证的客户端钩子很容易通过--no-verify
选项跳过(因此,服务器端验证是必要的),并且需要记得安装它们。
重要提示
但请注意,服务器端钩子仅在推送操作期间被触发;对于获取(和克隆)操作,你需要其他的访问控制解决方案。
显然,在使用“笨”协议时,钩子也不会被触发——此时服务器上不会调用 Git。
在编写钩子以实现某些 Git 强制的策略时,您需要记住该钩子在哪个阶段运行以及当时可用的信息。了解相关信息是如何传递给钩子的也很重要;然而,您可以通过查阅 Git 文档中的githooks
手册页轻松找到最后一点。上一章简要总结了服务器端钩子的内容。在这里,我们将对此主题做一些扩展。
所有服务器端钩子都是由git receive-pack
调用的,git receive-pack
负责接收已发布的提交(以 packfile 的形式接收,因此命令名称如此)。如果一个钩子(除了post-*
类型的钩子)以非零状态退出,则操作会中断,并且不会执行进一步的阶段。post 钩子在操作完成后运行,因此没有任何事情可以中断它。
标准输出和标准错误输出都会被转发到客户端的git send-pack
,因此钩子可以通过打印消息来简单地向用户传递信息(例如,如果钩子是作为 shell 脚本编写的,可以使用echo
)。请注意,客户端在所有钩子完成操作之前不会断开连接,因此,如果您尝试执行可能需要很长时间的操作(例如自动化测试),请小心。最好让钩子简单地启动这些长时间的操作并异步退出,以允许客户端完成。
您需要记住,在 pre 钩子中,引用尚未更新,而 post 钩子不能影响操作结果。您可以使用 pre 钩子进行访问控制(权限检查),并使用 post 钩子进行通知、更新侧数据和日志记录。钩子的列出顺序是按照操作的顺序。
pre-receive 钩子
第一个运行的钩子是git push
操作,Git 调用此钩子之前,操作将会失败。
该钩子不接收任何参数;所有信息都通过脚本的标准输入接收。对于每个要更新的引用,它会接收到一行,格式如下:
<old-SHA1-value> <new-SHA1-value> <full-ref-name>
需要创建的引用将具有 40 个零的旧 SHA1 值,而需要删除的引用将具有相同的新 SHA1 值。所有其他地方也使用相同的约定,其中钩子接收更新的引用的旧状态和新状态。
推送选项
您可以通过git push --push-option=或push.pushOption配置变量向服务器传递附加数据。两者都可以多次使用。然后,这些数据通过环境变量GIT_PUSH_OPTION_COUNT以及GIT_PUSH_OPTION_0、GIT_PUSH_OPTION_1等,传递给 pre-receive 和 post-receive 钩子。
如果更新无法接受,则可以使用此钩子快速中止操作——例如,如果接收到的提交不符合指定的策略,或者签名推送(稍后会讲到)无效。请注意,要将其用于访问控制(即授权),你需要以某种方式获取身份验证令牌,可以通过getpwuid
命令或使用环境变量如USER
。然而,这取决于服务器的设置和配置。
用于推送到非裸仓库的推送钩子
当推送到非裸仓库时,如果推送操作试图更新当前已检出的分支,则receive.denyCurrentBranch
配置变量会被设置为updateInstead
值(而不是true
、refuse
、warn
、false
或ignore
等值)。此钩子接收将成为即将更新的当前分支的提交的 SHA1 标识符。
该机制旨在同步工作目录,当一方不能方便地通过交互式方式访问时(例如,通过交互式ssh
访问),或作为一个简单的部署方案。它可以用于部署到实时网站,或在不同操作系统上运行代码测试。
如果此钩子不存在,Git 将在工作树或索引(暂存区)与HEAD
不同的情况下拒绝更新引用——也就是说,如果状态为“未清理”。此钩子应当用于覆盖此默认行为。
你可以编写这个钩子,使其对工作树和索引进行必要的修改,以使它们达到所需的状态。例如,钩子可以简单地运行git read-tree -u -m HEAD "$1"
来切换到新的分支尖端(-u
选项更新工作树中的文件),同时保留本地更改(-m
选项使其执行快速合并,处理两个提交/树)。如果此钩子以非零状态退出,那么 Git 将拒绝推送到当前已检出的分支。
更新钩子
接下来运行的是receive.denyDeletes
、receive.denyDeleteCurrent
、receive.denyCurrentBranch
和receive.denyNonFastForwards
。
请注意,退出时如果返回非零状态,则会拒绝更新引用;如果推送是原子性的(git push --atomic
),那么拒绝更新任何引用将放弃整个推送操作。对于普通推送,仅拒绝更新单个引用;其他引用的推送将正常进行。
此钩子接收有关要更新的引用的信息,按顺序提供:
-
被更新的引用的完整名称,
-
推送操作前存储在引用中的旧 SHA1 对象名称
-
推送操作后要存储在引用中的新 SHA1 对象名称
update.sample
钩子示例可用于阻止未注释的标签进入代码库,也可以允许或拒绝删除和修改标签、删除和创建分支。这个示例钩子的所有配置都通过适当的 hooks.*
配置变量完成,而不是硬编码的。此外,在 contrib/hooks/
目录中还有 update-paranoid
Perl 脚本,可以作为如何使用此钩子进行访问控制的示例。这个钩子通过外部配置文件进行配置,其中你可以设置访问控制,确保只有来自指定作者的提交和标签被允许,并且要求作者具有正确的访问权限。
许多代码库管理工具,如 Gitolite,会设置并使用这个钩子来完成它们的工作。如果你出于某种原因希望同时运行你自己的 update
钩子和这些工具提供的钩子,可能需要借助一些钩子管理工具(例如,查看 githooks.com/
上的工具列表),你需要查阅工具的文档。
post-receive
钩子
然后,在所有引用更新后,pre-receive
钩子会执行。只有现在,所有引用才会指向新的 SHA1 值。可能会发生在钩子评估引用之前,另一个用户已经修改了引用。这种钩子可以用于更新其他服务(例如,通知持续集成服务器),通知用户(通过电子邮件或邮件列表、聊天频道或问题跟踪系统),或记录关于推送的信息以便审计(例如,关于已签名的推送)。它取代了 post-update
钩子,应当使用这个钩子。
没有默认的 post-receive
钩子,但你可以在 contrib/hooks/
目录中找到简单的 post-receive-email
脚本及其替代品 git-multimail
。
这两个示例钩子实际上是独立于 Git 本身开发的,但为了方便,它们与 Git 源代码一起提供。git-multimail
会发送一封总结每个更改引用的电子邮件,每个新提交更改的电子邮件(作为回复进行线程化),以及每个新标注标签的公告邮件。每个邮件在使用的电子邮件地址以及包含的信息方面都可以单独配置。
举个第三方工具的例子,irker
包含一个脚本,作为 Git 的 post-receive
钩子,用于将新更改的通知发送到适当的 IRC 频道,使用 irker 守护进程(需要单独设置)。
post-update
钩子(一个遗留机制)
然后,post-receive
钩子是一个更好的解决方案。
示例钩子运行git update-server-info
,以准备一个仓库,用于通过简易传输(在第七章的传统(简易)传输部分、发布更改部分以及本章稍后的简易协议部分中描述)进行使用,通过创建和保存一些额外的信息。如果仓库所在的目录要通过普通的 HTTP 或其他基于步行器的传输方式(如 FTP)访问,您可以考虑启用此功能。然而,在现代 Git 中,只需将receive.updateServerInfo
设置为true
,就足够了,因此不再需要钩子。
使用钩子实现 Git 强制策略
强制执行策略的唯一真正方法是使用服务器端钩子来实现,使用pre-receive
或update
钩子;如果您需要按引用进行决策,则需要使用后者。客户端钩子可以帮助开发人员关注策略,但这些钩子可以被禁用、跳过或未启用。
使用服务器端钩子强制执行策略
开发政策的一部分可能是要求每个提交信息遵循指定的模板。例如,您可以要求每个非合并的提交信息包含Signed-off-by:行,作为数字来源证书,或者要求每个提交都引用问题追踪器的票据,格式类似于ref: 2387。可能性无穷无尽。
为了实现这样的钩子,您首先需要将引用的旧值和新值(您可以通过在pre-receive
中逐行读取标准输入,或者作为update
钩子的参数获取这些值)转化为正在推送的所有提交的列表。您需要处理一些特殊情况——删除引用(没有提交被推送)、创建新引用、以及非快进推送的可能性(您需要使用合并基准作为修订范围的下限——例如,使用git merge-base
命令),推送到标签、推送到注释以及其他非分支推送。将修订范围转换为提交列表的操作可以通过git rev-list
命令完成,这是git log
命令的低级等效命令(也叫做plumbing);默认情况下,此命令仅输出指定修订范围内每个提交的 SHA1 值,每行一个,并且没有其他信息。
然后,对于每个修订版,您需要获取提交信息并检查它是否符合政策中指定的模板。您可以使用另一个低级命令,git cat-file
,然后从此命令的输出中提取提交信息,跳过所有在第一个空行之前的内容。这个空行将提交元数据和提交主体分开:
$ git cat-file commit a7b1a955
tree 171626fc3b628182703c3b3c5da6a8c65b187b52
parent 5d2584867fe4e94ab7d211a206bc0bc3804d37a9
author Alice Developer 1440011825 +0200
committer Alice Developer 1440011825 +0200
Added COPYRIGHT file
或者,您可以使用git show -s
或git log -1
,这两个都是简单命令,代替git cat-file
。但是,您需要指定确切的输出格式——例如,git show -s --format=%B <SHA1>
。
当你拥有这些提交信息时,你可以使用正则表达式匹配或其他工具检查每个提交信息,以确认它们是否符合政策。
政策的另一个部分可能是关于分支管理的限制。例如,你可能希望防止删除长期存在的开发阶段分支(参见第八章,高级分支技术),同时允许删除主题分支。为了区分它们——也就是说,找出被删除的分支是否为主题分支——你可以选择包含一个可配置的分支列表来严格管理,或者假设主题分支总是使用<user>/<topic>
命名规范。后一种解决方案可以通过要求新创建的分支(应该只为主题分支)匹配这一命名规范来强制执行。
理论上,你可以制定一个政策,要求主题分支只能在没有合并的情况下进行快进合并,尽管实现这一政策的检查并非易事。
通常,只有特定的人有权限推送到项目的官方仓库(持有所谓的提交权限)。通过服务器端钩子,你可以配置仓库,使其允许任何人推送,但仅限于特殊的群体分支;所有其他推送访问将受到限制。
你还可以使用服务器端钩子来要求仓库中只允许注解标签,标签必须使用在指定密钥服务器中存在的公钥进行签名(因此,其他开发者可以验证),并且标签不能被删除或更新。如有需要,你可以将签名标签限制为来自已选择(并配置)的用户集——例如,执行一个政策,要求只有一位维护者可以标记项目为发布版本(通过创建一个适当命名的标签——例如v0.9
)。
使用客户端钩子提前通知关于政策违规的情况
严格执行开发政策而不给用户提供帮助监视和遵守这些政策的方式并不是一个好的解决方案。在推送过程中工作被拒绝可能会令人沮丧;为了修复阻止提交发布的问题,你需要编辑本地项目历史(也就是重写你的更改)。详细了解如何操作,请参见第十章,保持历史清洁。
解决这个问题的方法是提供一些客户端钩子,用户可以安装这些钩子,并让 Git 在他们违反政策时立即通知他们,这将导致他们的更改被服务器拒绝。其目的是尽可能快速地帮助纠正问题,通常是在提交更改之前。这些客户端钩子必须以某种方式分发,因为克隆仓库时不会复制钩子。各种分发这些钩子的方法在 第十三章,自定义和扩展 Git 中进行了描述。
如果对变更内容有任何限制(例如,某些文件可能仅由指定的开发者修改),可以使用 pre-commit
钩子创建警告信息。prepare-commit-msg
钩子(和 commit.template
配置变量)可以为开发者提供一个自定义的模板,开发者在提交消息时填写。你还可以让 Git 在提交记录之前,通过 commit-msg
钩子检查提交消息。此钩子会检查并通知你是否正确格式化了提交消息,是否包含了政策要求的所有信息。此钩子也可以替代或与 pre-commit
一起使用,检查你是否修改了不允许修改的文件。
pre-rebase
钩子可用于验证你是否试图以会导致非快进推送的方式重写历史(如果服务器启用了 receive.``denyNonFastForwards
,强制推送也无法生效)。
作为最后的手段,还有一个 pre-push
钩子,可以在尝试连接到远程仓库之前检查是否正确。
签名推送
第六章,使用 Git 进行协作开发,包括开发者可以用来确保自己工作完整性和真实性的各种机制描述——签名标签、签名提交和签名合并(合并签名标签)。所有这些机制都声明对象(及其包含的更改)来自签名者。
然而,签名标签和提交并不能证明开发者希望在某个特定分支的顶端有某个特定的修订版本。托管网站进行的身份验证无法轻松审计,且这要求你信任托管网站及其身份验证机制。现代 Git(版本 2.2 或更新版本)允许你为此目的签名推送。
签名推送要求服务器设置 receive.certNonceSeed
,客户端使用 git push --signed
。签名推送的处理通过服务器端钩子完成。目前,包括 GitHub、GitLab、Bitbucket 和 Gitea 在内的 Git 仓库平台都不支持签名推送;有一些工具如gittuf或Kernel.org Transparency Log Monitor提供推送操作的透明日志。
客户端发送的签名推送证书作为 blob 对象存储在仓库中,并通过 pre-receive
钩子进行验证,该钩子可以检查各种 GIT_PUSH_CERT_*
环境变量(有关详细信息,请参阅 git-receive-pack
手册页面),以决定是否接受或拒绝给定的签名推送。
可以通过 post-receive
钩子来记录签名推送的审计日志。您可以让此钩子发送关于签名推送的电子邮件通知,或者将推送信息附加到日志文件中。pre-receive
和 post-receive
输入。
提供 Git 仓库服务
在 第六章,《使用 Git 进行协作开发》中,我们探讨了 Git 用于连接远程仓库的四种主要协议——本地协议、HTTP 协议、SSH(安全 外壳)协议和 Git 协议(原生协议)。从客户端连接到仓库的角度进行讨论,解释了这些协议是什么,以及在远程仓库提供多种协议时应使用哪个协议。
本章将提供管理员的视角,解释如何设置并随后迁移改写后的 Git 仓库,以通过不同的传输协议提供服务。在这里,我们还将逐一检查每种协议的身份验证和授权方式。
本地协议
这是最基本的协议,客户端使用指向仓库的路径或 file://
URL 来访问远程仓库。您只需要拥有一个共享的文件系统,例如 NFS 或 SMB/CIFS 挂载,其中包含 Git 仓库。这是一个不错的选择,如果您已经有网络文件系统的访问权限,因为您不需要设置任何服务器。
使用基于文件的传输协议访问仓库由现有的文件权限和网络访问权限控制。您需要读取权限来获取和克隆,写入权限来推送。
在后一种情况下,如果您希望启用推送,最好以一种不会破坏权限的方式设置仓库。这可以通过使用 git init
(或 git clone
)时创建带有 --shared
选项的仓库来实现。此选项允许属于同一组的用户通过使用粘滞组 ID 推送到仓库,从而确保仓库对所有组成员可用。
这种方法的缺点在于,与基本的网络访问和设置适当的服务器相比,共享网络文件系统的访问通常更难设置并从多个远程位置安全地访问。通过互联网挂载远程磁盘可能困难且速度较慢。
此协议不能防止仓库遭受意外损坏。每个用户对仓库的内部文件都有完全访问权限,并且没有任何措施阻止他们意外破坏仓库。
SSH 协议
SSH 是一种常见的传输协议(Linux 用户常用)来自托管 Git 仓库。SSH 对服务器的访问在许多情况下已经设置好,通常是作为安全登录远程机器的一种方式;如果没有设置,通常也很容易设置和使用。SSH 是一种经过身份验证和加密的网络协议。
相反,你不能通过 SSH 为 Git 仓库提供匿名访问。用户必须至少具有对你的机器的有限 SSH 访问权限;该协议不允许对已发布的仓库进行匿名的只读访问。
通常,有两种方式可以通过 SSH 访问 Git 仓库。第一种是为每个尝试访问仓库的客户端在服务器上创建一个单独的账户(尽管这样的账户可以被限制,并且不需要完全的 shell 访问权限,在这种情况下,你可以为 Git 专用账户使用git-shell
作为登录 shell)。这种方式可以与普通的 SSH 访问一起使用,你提供密码,或者使用公钥登录。在每个用户一个账户的情况下,访问控制与本地协议类似——即通过文件系统权限控制访问。
第二种方法是创建一个单一的 shell 账户,通常是git
用户,专门用于访问 Git 仓库,并使用公钥登录来验证用户。每个需要访问仓库的用户需要将他们的 SSH 公钥发送给管理员,管理员将此公钥添加到授权密钥列表中。实际用户通过他们用于连接服务器的密钥来识别。
另一种选择是通过 LDAP 服务器或其他集中式身份验证方案对 SSH 服务器进行身份验证(通常用于实现单点登录)。只要客户端能够获得(有限的)shell 访问权限,就可以使用任何 SSH 身份验证机制。
匿名 Git 协议
接下来是 Git 协议。这是由一个特殊且非常简单的 TCP 守护进程提供的,它在一个专用端口上监听(默认情况下是端口9418
)。这是(或曾经是)快速、匿名、未经身份验证的只读访问 Git 仓库的常见选择。
Git 协议服务器git daemon
相对容易设置。基本上,你只需要运行此命令,通常以守护进程方式运行。如何运行守护进程(服务器)取决于你使用的操作系统。它可以是systemd
单元文件、Upstart 脚本或sysvinit
脚本。常见的解决方案是使用inetd
或xinetd
。
你可以通过--base-path=<directory>
重映射所有相对于给定路径(Git 仓库的项目根目录)的仓库请求。也支持虚拟主机;更多细节请参见git-daemon
文档。默认情况下,git daemon
只会导出在gitdir
内具有git-daemon-export-ok
文件的仓库,除非使用--export-all
选项。通常,你还会希望开启--reuseaddr
,允许服务器在不等待连接超时的情况下重新启动。
Git 协议的缺点是缺乏认证,并且运行时使用的端口较为隐蔽(可能需要你在防火墙中打孔)。缺乏认证的原因是,默认情况下它仅用于读取访问——即用于拉取和克隆仓库。通常,它与 SSH(始终经过认证且绝不匿名)或 HTTPS 一起用于推送。
你可以配置它以允许推送(通过使用--enable=<service>
命令行选项启动receive-pack
服务,或者在每个仓库的基础上,通过将daemon.receivePack
配置设置为true
),但通常不推荐这么做。钩子实现访问控制时可以获得的唯一信息是客户端地址,除非你要求所有推送都进行签名。你可以在访问钩子中运行外部命令,但这并不能提供更多关于客户端的信息。
提示
你可能考虑启用的一个服务是upload-archive,它提供git archive --remote服务。
这种缺乏认证意味着,Git 服务器不仅不知道是谁访问了仓库,而且客户端也必须信任网络,确保在访问服务器时地址不会被伪造。这种传输没有加密。
智能 HTTP(S)协议
设置所谓的“智能”HTTP(S)协议,基本上是启用一个服务器脚本,该脚本会在服务器上调用git receive-pack
和git upload-pack
。Git 为此任务提供了一个名为git-http-backend
的 CGI 脚本。这个 CGI 脚本可以检测客户端是否理解智能 HTTP 协议;如果不理解,它将回退到“笨重”模式(这是一种向后兼容的功能)。
要使用此协议,你需要一个 CGI 服务器——例如 Apache(使用此服务器时,你还需要mod_cgi
模块或其等效模块,以及mod_env
和mod_alias
模块)。参数通过环境变量传递(因此在使用 Apache 时需要mod_env
)——GIT_PROJECT_ROOT
用于指定仓库的位置,且如果你希望导出所有仓库而不仅仅是那些包含git-daemon-export-ok
文件的仓库,还可以选择使用GIT_HTTP_EXPORT_ALL
。
认证由 Web 服务器完成。特别是,你可以设置它允许未经认证的匿名只读访问,同时要求推送时进行认证。使用 HTTPS 可以提供加密和服务器认证,类似于 SSH 协议。使用 HTTP(S)时,拉取和推送的 URL 是相同的;你还可以配置,使得用于浏览 Git 仓库的 Web 界面也使用相同的 URL 进行拉取。
注意
git-http-backend的文档包括了不同情况下的 Apache 设置,包括未认证的读取和认证的写入。那里展示的设置有些复杂,因为最初的引用广告使用查询字符串,而receive-pack服务的调用使用路径信息。
相反,要求任何有效账户进行身份验证以进行读取和写入,并将写入的限制留给服务器端钩子,这是一个更简单且通常可接受的解决方案。
如果你尝试推送到需要身份验证的仓库,服务器可能会提示输入凭证。由于 HTTP 协议是无状态的,并且有时涉及多个连接,因此使用凭证助手(请参见 第十三章,自定义和扩展 Git)是有用的,这样就可以避免在单次操作中多次输入密码,或者将密码保存在磁盘上的某个地方(例如,在远程 URL 中)。
Gitolite 用于智能 HTTPS 访问控制
虽然 Gitolite (gitolite.com/
) 为通过 SSH 访问的 Git 提供了访问控制层,但它也可以配置为对智能 HTTP 模式进行授权。
傻瓜协议
如果你无法在服务器上运行 Git,你仍然可以使用傻瓜协议,因为它不需要 Git。在这种传输协议下,Git 仓库被期望像普通静态文件一样由 Web 服务器提供服务。然而,为了能够使用这种协议,Git 需要额外的 objects/info/packs
和 info/refs
文件,并且这些文件必须通过 git update-server-info
保持最新。通常,这个命令是在通过前面提到的某个智能协议进行推送时运行的(默认的 post-update
钩子会执行此操作,如果 receive.updateServerInfo
设置为 true
,git-receive-pack
也会执行此操作)。
可以使用傻瓜协议进行推送,但这需要一种设置,允许你使用指定的传输更新文件;对于傻瓜 HTTP(S) 传输协议,这意味着配置 WebDAV。
在这种情况下,身份验证是由 Web 服务器为静态文件完成的。显然,对于这种传输方式,Git 的服务器端钩子不会被调用,因此不能用来进一步限制访问。
历史说明
请注意,对于现代 Git,傻瓜传输是通过 curl 系列的远程助手实现的,而这些助手可能不是默认安装的。
这种传输方式(用于获取)通过下载请求的引用(作为普通文件),检查在哪儿可以找到包含引用提交对象的文件(因此需要服务器信息文件,至少对于包文件中的对象),获取它们,然后遍历修订链,检查每个所需的对象,如果对象尚未存在于本地仓库,则下载新文件。如果仓库在请求的修订范围内没有良好打包,这种遍历方法可能会非常低效。它需要大量连接,并且即使只需要其中一个对象,也总是会下载整个包。
使用智能协议时,客户端和服务器端的 Git 会协商哪些对象需要发送(即“需要/已有”协商)。然后 Git 创建一个定制的打包文件,利用已知的对方已有的对象,通常只包括增量——也就是与对方所拥有的不同部分(即瘦打包文件)。对方会将接收到的打包文件重写为自包含的格式。
远程助手
Git 允许我们通过编写远程助手程序来创建对新传输协议的支持。这个机制也可以用于支持外部仓库。Git 通过将远程助手作为独立的子进程启动,并通过标准输入和输出与这个进程通信,执行一组命令来与需要远程助手的仓库交互。远程传输助手的使用在第六章,使用 Git 进行协作开发中有描述。
你可以找到第三方远程助手来支持新的访问仓库方式——例如,有git-remote-dropbox
可以使用 Dropbox 来存储远程 Git 仓库。不过,注意的是,与内建传输支持相比,远程助手的功能可能(仍然)有所限制。
用于管理 Git 仓库的工具
现在,你不需要自己编写 Git 仓库管理解决方案。有许多各种各样的第三方解决方案可以使用。列出所有的解决方案是不可能的,甚至给出推荐也是有风险的。Git 生态系统正在积极开发中;哪个工具最好可能会随着时间的推移而改变。
我在这里仅想集中讨论管理员工具的类型,就像我在第十三章,Git 的定制与扩展中讨论 GUI 工具一样。
首先,在 contrib/
目录下有 update-paranoid
脚本。这些工具侧重于访问控制,通常是授权部分,使得添加仓库和管理其权限变得简单。一个这样的工具的例子是Gitolite。
它们通常支持某些机制,以添加你自己的额外访问限制。
然后是 git log
和 git show
命令,以及显示仓库中文件列表的视图。一个这样的工具的例子是与 Git 一起分发的 Perl 脚本 gitweb
;另一个是由 git.kernel.org 使用的 cgit
。
同样有用的是代码审查(代码协作)工具。这些工具使得团队中的开发人员可以通过网页界面审查彼此提出的修改。这些工具通常允许创建新项目并处理访问管理。一个这样的工具的例子是 Gerrit 代码审查。
最后,有Git 托管解决方案,也称为软件 Forge,通常具有用于管理仓库的网页界面,使我们能够添加用户、创建仓库、管理访问权限,并且通常可以在浏览器中操作 Git 仓库。此类工具的示例有 GitLab 和 Gitea。也有类似的源代码管理系统,它们提供(除了其他基于网页的界面)仓库托管服务,以及协作和开发管理功能。这样的系统示例有 Kallithea;然而,现如今,许多软件 Forge 也包括一些源代码管理功能,如问题追踪,以及CI/CD(持续集成/持续交付)管道。
当然,你不需要自己托管代码。市面上有大量第三方托管选项——如 GitHub、Bitbucket 等。甚至还有使用开源托管管理工具的托管解决方案,如 GitLab 和 Codeberg。
托管仓库的小技巧
如果你想自托管 Git 仓库,有一些事情可能有助于提升服务器性能和用户满意度。
减少仓库占用的空间
如果你托管了许多同一仓库的分支(克隆),你可能希望通过某种方式共享公共对象以减少磁盘使用。一个解决方案是在创建分支时使用git clone --reference
。在这种情况下,衍生仓库会在其自身找不到对象时查找其父仓库的对象存储。
然而,这种方法有两个问题。首先,你需要确保借用仓库依赖的对象不会从作为备用对象存储的仓库集合中消失(即你借用的仓库)。例如,你可以通过在借用对象的仓库中链接借用仓库的 refs(例如,在refs/borrowed/
命名空间中)来完成此操作。第二,进入借用仓库的对象不会自动去重;你需要运行git repack -a -d -l
,它内部会将--local
选项传递给git pack-objects
。
另一种解决方案是将所有的分支放在同一个仓库中,并使用git-http-backend
手册页中包含的示例配置,从单个仓库为不同的命名空间提供多个仓库。Gitolite 也对命名空间提供了一些支持,形式为逻辑仓库和后端仓库,以及option namespace.pattern
,尽管并不是所有功能都适用于逻辑仓库。
将多个仓库作为单一仓库的命名空间来存储,可以避免存储相同对象的重复副本。它自动防止了新对象之间的重复,而不需要进行持续的维护,这与替代方案不同。相反,安全性较弱;你需要将对单一命名空间(位于仓库内)有访问权限的人视为对所有其他命名空间都有访问权限(尽管这可能对你的情况来说不是问题)。
通过 pack 位图加速智能协议
另一个在自托管仓库时可能遇到的问题是智能协议的性能。对于你的服务器客户端而言,操作需要快速完成;作为管理员,你不希望因为提供 Git 仓库服务而在服务器上产生高 CPU 负载。
一项从 JGit 移植过来的特性应该显著提高计数对象阶段的性能,同时为使用该特性的仓库提供对象服务。这个特性是位图索引文件,自 Git 2.0 起可用。
位图索引文件
位图索引文件的主要功能是为选定的提交子集(包括最近的提交)提供位向量(位图),这些位向量存储了在 packfile 或多 pack 索引中的一组对象的可达性信息。在每个位向量中,索引 i 处的 1 表示第 i 个对象(按 packfile 或多 pack 索引文件定义的顺序)可以从该位向量所属的提交中访问。
该文件与 packfile 及其索引一起存储。可以通过运行git repack -A -d --write-bitmap-index
手动生成,或者通过将repack.writeBitmaps
配置变量设置为true
,与 packfile 一起自动生成。该方案的缺点是位图会占用额外的磁盘空间,而且初次 repack 需要额外时间来创建位图索引。随着现代 Git 的发展,得益于多 pack 索引,你不再需要将所有内容重新打包成单一的 packfile 就能使用位图文件。这个特性还加快了位图更新的速度。
如今,这个特性默认在裸仓库中启用。
解决大型不可恢复初始克隆问题
拥有大型代码库和悠久历史的仓库可能会变得相当庞大。问题在于,初始克隆需要从一个可能很大的仓库中获取所有内容,这是一个全有或全无的操作,至少对于现代(安全且有效的)智能传输协议——SSH、git://
和智能 HTTP(S) 来说是这样。如果网络连接不太可靠,这可能会成为问题。目前不支持可恢复克隆,遗憾的是,这似乎是 Git 开发者面临的一个根本难题。然而,这并不意味着你作为托管管理员无能为力,无法帮助用户完成初始克隆。
一个解决方案是使用git bundle
命令创建一个静态文件,该文件可以用于初始克隆,或者作为初始克隆的参考仓库(后者可以在下载 bundle 后使用git clone --reference=<bundle> --dissociate
命令完成)。这个 bundle 文件可以通过任何传输方式进行分发——特别是能够在中断时恢复的方式, 无论是 HTTP(S)、FTP、rsync 还是 BitTorrent。人们通常使用的约定是,在开发者文档中解释如何获取这样的 bundle 时,使用与仓库相同的 URL,但文件扩展名是.bundle
(而不是空扩展名或.git
后缀)。如果 bundle 可以通过 HTTP(S)或 SSH 协议访问,则可以在没有显式下载的情况下直接使用git clone --bundle-uri=<bundle uri>
。
Git 还有bundle-uri功能,服务器可以向客户端建议从哪里下载这样的 bundle,客户端可以使用该 bundle 来加速初始克隆。在写这篇文档时,没有软件托管平台支持此功能,但有一个git bundle-server(github.com/git-ecosystem/git-bundle-server
)的 Web 服务器和管理界面可以配合此功能使用。
还有一些更为复杂的方法可以解决初始克隆成本的问题,例如逐步加深一个浅克隆(或者也许只需要使用git clone --depth
进行浅克隆),从部分克隆开始,或者使用像 GitTorrent 这样的方式。
增强开发工作流程
版本控制只是开发工作流程的一部分。还有工作管理、代码审查与审核、自动化测试执行以及构建生成等任务。
许多这些步骤可以通过专门的工具来辅助。许多工具提供 Git 集成。例如,可以使用 Gerrit 来管理代码审查,要求每个变更在公开之前都通过审查。另一个例子是设置开发环境,以便将更改推送到公共仓库时,可以根据提交信息中的模式自动关闭问题跟踪器中的工单。可以通过服务器端钩子或托管服务的 Webhooks 来实现这一点。
一个仓库可以作为一个网关,运行自动化测试(例如,借助 Jenkins 或 Hudson 的持续集成服务)并在所有测试通过后仅部署更改以确保质量环境。另一个仓库可以配置为触发不同支持的系统的构建。许多工具和服务支持推送部署机制(例如,Heroku 或 Google 的 App Engine)。
Git 可以自动通知用户和开发者有关已发布更改的信息。这可以通过电子邮件、邮件列表、IRC/Discord/Slack 频道或基于 Web 的仪表盘应用程序来实现。可能性很多;你只需要找到它们。
在仓库中定义开发工作流
许多软件开发平台允许你直接从仓库自动化、定制和执行软件开发工作流。这些解决方案,如GitHub Actions 和 GitLab CI/CD,允许你在仓库中发生其他事件时运行各种工作流(例如,运行测试或部署应用程序)。这些工作流是通过运行器来执行的,运行器可以是虚拟机或容器。它们通常由一个 YAML 文件定义并检查到你的仓库中。
虽然 YAML 标记语言的具体方言、文件路径名以及可用的预定义操作因服务而异,但它们足够相似,你应该能够从一个解决方案迁移到另一个解决方案。
GitOps —— 使用 Git 进行操作程序
在 Git 仓库中定义软件开发工作流的自然扩展是使用 Git 来自动管理部署基础设施,特别是对于云原生应用程序。这被称为GitOps——一种操作框架,使用 Git 仓库存储基础设施即代码(IoC)文件和应用程序配置文件。这些数据可以与应用程序代码存储在同一个仓库中,或存储在单独的仓库中。
GitOps 确保基础设施(包括开发、测试和部署环境)基于 Git 仓库的状态能够立即重现。这为操作提供了版本控制,以便在需要回滚时使用。
通常,基础设施配置是声明式定义的,专门的软件代理(例如 Argo CD、Flux 或 Gitkube)在云端定期从 Git 仓库拉取并检查配置与实时状态的匹配情况,根据需要调整状态。
总结
本章涵盖了与 Git 使用相关的管理方面的各种问题。你学习了维护、数据恢复和仓库故障排除的基础知识。你还学习了如何在服务器上设置 Git,如何使用服务器端钩子以及如何管理远程仓库。本章还介绍了提高远程性能的技巧和窍门,并描述了如何在第三方工具的帮助下使用 Git 来增强开发工作流。本章中的信息应该帮助你选择 Git 仓库管理解决方案,甚至自己编写一个。
下一章将包含一套建议和最佳实践,既包括特定于 Git 的,也包括那些与版本控制无关的。可以通过本章中探索的工具来执行和鼓励基于这些建议的政策。
问题
回答以下问题,测试你对本章内容的理解:
-
如何设置自动化的仓库维护,确保 Git 操作不会变慢?
-
如何尝试恢复丢失的提交?
-
如何找出某些 Git 命令开始表现不佳且执行时间过长的原因?
-
如何确保开发过程遵循既定的定义政策?
-
分享仓库的最简单私密方法是什么,其中所有开发者都在单台计算机上工作(在一台机器上)?
答案
以下是对上述问题的答案:
-
使用git maintenance命令。
-
首先,如果无法从分支和 HEAD 的 reflog 中找到丢失的提交,检查它们。如果此方法无效,你可以尝试使用git fsck浏览不可达的提交。
-
你可以使用“Git trace”机制——例如,通过设置GIT_TRACE2_PERF或GIT_TRACE_PERFORMANCE环境变量。
-
如果可能,使用你的软件平台功能(例如,保护分支免受更改或删除)或使用服务器端钩子。客户端钩子可以帮助但不能确保强制执行政策。
-
只需使用git init --bare --shared创建裸仓库,同时确保所有需要访问仓库的开发者拥有适当的文件系统权限。如果需要,可以向该仓库推送。
深入阅读
要了解更多关于本章所涵盖的主题,请参考以下资源:
-
Scott Chacon, Ben Straub: Pro Git, 第 2 版,Apress(2014)第四章,Git 在服务器上
git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols
-
Scott Chacon: Git 技巧 2:Git 中的新特性(2024)
blog.gitbutler.com/git-tips-2-new-stuff-in-git/#git-maintenance
-
Konstantin Ryabitsev: 签名的 Git 推送(2020)
people.kernel.org/monsieuricon/signed-git-pushes
-
Vicent Martí: 计数对象(2015)
github.blog/2015-09-22-counting-objects/
-
Sitaram Chamarty: Gitolite 基础,Packt(2014)
subscription.packtpub.com/book/programming/9781783282371
-
Derrick Stolee: 探索 Git 推送性能的新前沿(2019)
devblogs.microsoft.com/devops/exploring-new-frontiers-for-git-push-performance/
-
Taylor Blau: 扩展单一仓库的维护(2021)
github.blog/2021-04-29-scaling-monorepo-maintenance/
第十五章:Git 最佳实践
《Git 入门》的最后一章展示了一系列通用和特定于 Git 的版本控制建议和最佳实践。你在前面的章节中已经接触到其中很多建议;这里将它们作为总结和提醒。有关每个最佳实践的详细信息和背后的原因,请参考具体章节。
本章将涵盖管理工作目录、创建提交和提交系列(拉取请求)、提交更改以供包含以及同行评审变更的问题。
本章将涵盖以下主题:
-
如何将项目拆分为仓库
-
在仓库中存储哪些类型的数据,以及 Git 应该忽略哪些文件
-
在创建新提交之前需要检查什么
-
如何创建良好的提交和良好的提交系列(换句话说,如何创建良好的拉取请求)
-
如何选择有效的分支策略,以及如何命名分支和标签
-
如何审查变更以及如何回应审查
启动一个项目
在启动项目时,你应该选择并明确项目治理模型(谁管理工作,谁整合更改,谁负责什么)。你还需要决定代码的许可证和版权:它是否是外包工作,贡献是否需要版权转让、贡献者协议、贡献者许可证协议,或者仅仅是数字原产地证书。
将工作分配到仓库中
在集中式版本控制系统中,通常所有内容都放在同一个项目树下。而在分布式版本控制系统(如 Git)中,这在很大程度上取决于项目的性质。通常,最好将不同的项目拆分到不同的仓库中,但如果这些项目紧密耦合在一起,那么使用单一仓库(monorepo)可能更好——将所有项目放在一个大的仓库中。
如果某部分代码被多个独立项目需要,考虑将其提取为一个独立的项目,并将其作为子模块或子树引入,按概念将其组织成一个超级项目。有关详细信息,请参见 第十一章,管理子项目。
选择协作工作流
你需要做出关于协作结构的决策,决定你的项目将采用分散贡献者模型、"受信任"仓库模型、还是中央仓库等(具体内容请见 第六章,使用 Git 进行协作开发)。
这通常需要设置访问控制机制并决定权限结构;有关如何设置这些内容的详细信息,请参见 第十四章**,Git 管理。
你还需要决定使用的分支模式。参见第八章,高级分支技术,其中包含了最常见模式的示例。你需要决定如何集成变更,以及如何隔离独立的工作。这些分支模式通常会组合成一个单独的命名分支工作流。
关于分支的决策不需要一成不变。随着项目和团队经验的增长,你可能希望考虑改变分支模型,例如,从基于主干的工作流改为每个功能独立分支模型、GitHub 流,或其他衍生模型。
有关许可、协作结构和分支模型的决策应该在开发者文档中明确说明(至少在README
和LICENSE
/COPYRIGHT
文件中,可能还包括在CodingGuidelines
和CodeOfConduct
中)。你需要记住,如果项目的开发方式发生变化,这些文档需要更新以反映这些变化。例如,因为项目已超出最初阶段,可能会发生这种情况。
选择要保留在版本控制中的文件
在大多数情况下,你不应该将任何生成的文件包含在版本控制系统中(尽管有一些非常罕见的例外)。你应该只跟踪源文件(即原始资源);Git 在处理这些源文件时表现最好,尽管它也能很好地处理二进制文件。
为了避免不小心将不需要的文件包含在代码库中,你应该在项目树中使用.gitignore
文件;开发者特定的文件(例如,由编辑器或操作系统创建的备份文件)应放入他们各自的core.excludesFile
(在现代 Git 中是~/.config/git/ignore
文件),或者放入特定克隆版本库的本地配置中,即.git/info/excludes
。有关详细信息,请参见第三章,管理你的工作树。
忽略模式的一个良好起点是gitignore.io
网站,它提供了各种操作系统、IDE 和编程语言的.gitignore
模板。
另一个建议是,不要将可能因环境而变化的配置文件添加到 Git 中(例如,MS Windows 和 Linux 之间不同的配置文件)。
在项目中工作
以下是有关如何创建变更和开发新版本的一些指导方针。这些方针可以用于你自己项目的工作,或者帮助你向由他人维护的项目贡献代码。
不同的项目可能使用不同的开发工作流;因此,根据使用的工作流,本文提供的一些建议可能没有意义。
在主题分支上工作
Git 中的分支有两个功能(第八章,高级分支技巧):作为开发者提交的代码在指定的代码稳定性和成熟度级别下的中介(长期存在的公共分支),提供集成和部署的路径,以及作为新想法开发的沙盒(短期存在的私人分支)。
沙盒化变更的能力是为什么创建一个单独的分支来处理每个新任务被认为是一个好习惯。这样的分支被称为主题分支或功能分支。使用独立的分支使得在任务之间轻松切换成为可能,并且可以防止不同的工作进度互相干扰。另一方面,如果这些分支存在很长时间,这会违反持续集成(CI)的实践,减少变更集的可见性,并因为更大的分歧导致更困难的集成。
你应该为分支选择简短且具有描述性的名称。主题分支有不同的命名约定,你的项目使用的约定应在开发文档中说明。通常,分支名称会总结它们所托管的主题,通常是全小写字母,单词之间的空格用连字符或下划线替代(查看git-check-ref-format
的手册页以了解分支名称中禁止使用的字符)。分支名称可以包含斜杠(成为层次化结构)。
如果你正在使用问题追踪器,那么修复 bug 或实现某个问题的分支可以在其名称前加上描述该问题的票据编号,例如1234-doc_spellcheck
。另一方面,维护者在收集其他开发者的提交时,可以将这些提交放入以开发者的首字母和主题名称命名的主题分支中,例如ad/whitespace-cleanup
(这是一个层次化的 分支名称示例)。
在完成相关分支的工作后,从本地仓库以及上游仓库删除你的主题分支被认为是一个好习惯,以减少杂乱。
决定基于什么来开展你的工作
作为开发者,你通常会在某个特定问题上进行工作,无论是 bug 修复、功能增强、某个主题的修正,还是新特性的开发。
在给定主题上开始工作的地方,以及基于哪个分支开始工作,都取决于项目所选的分支工作流(参见第八章,高级分支技巧,了解不同的分支工作流)。这个决定也取决于你所做工作的类型。
对于主题分支工作流(或每个功能一个分支工作流),你应该选择基于与你的更改相关的、最老且最稳定的长期运行分支,并计划将更改合并到该分支。这是因为,正如在第八章《高级分支技巧》中所描述的,你绝不应该将一个不太稳定的分支合并到一个更稳定的分支。这一最佳实践的背后原因是为了避免使分支不稳定,因为合并会带入所有更改。
不同类型的更改需要不同的长期存在的分支作为主题分支的基础,或者将这些更改放置到该分支上。一般来说,为了帮助开发者理解项目的工作流程,这些信息应当在开发文档中进行描述;并不是每个人都需要了解项目使用的分支工作流。
以下内容描述了根据更改的目的,通常作为基础分支使用的内容:
-
Bug 修复:在这种情况下,主题分支(bugfix 分支)应基于存在该 bug 的最老且最稳定的分支。通常来说,从维护分支开始。如果在维护分支中没有该 bug,则应基于稳定分支创建 bugfix 分支。对于在稳定分支中不存在的 bug,找到引入该 bug 的主题分支,并基于该主题分支进行工作。
-
新功能:在这种情况下,主题分支(功能分支)应尽可能基于稳定分支。如果新功能依赖于某个尚未准备好进入稳定分支的主题,则应基于该主题(从主题分支)。
-
对于未合并到稳定分支中的主题的修正和增强,应基于正在修正的主题分支的最新版本。如果相关主题尚未发布,可以对该主题的步骤进行更改,将小的修正合并到系列中(详见第十章《保持 历史清晰》中的历史重写部分)。
如果你所参与的项目足够大,拥有专门的维护者来管理系统的某些部分(子系统),你首先需要决定将基于哪个代码库和分支(有时称为“树”)进行工作。
将更改拆分为逻辑上独立的步骤
除非你的工作非常简单并且可以在一个步骤内完成(一个提交)——就像许多 bug 修复一样——你应该将逻辑上独立的更改拆分为单独的提交,每个提交代表一个步骤。这些提交应按逻辑顺序排列。
遵循提交信息的最佳实践(说明你所做的更改——见下一节)有助于决定何时提交。如果你的描述过长,并且开始看到你有两个独立的更改被混合在一起,那么这意味着你可能需要将提交拆分成更小的部分,并使用更细粒度的步骤。
然而,请记住,这取决于项目约定和所选开发工作流之间的平衡。更改至少应该是自成一体的。在实现某个特性时的每一步(每次提交),代码应该能够编译,程序也能通过测试套件。你应该使用 git bisect
(这一点在 第四章,探索项目历史 中有所描述)。
请注意,你不必一开始就制定完美的步骤序列。如果你发现工作目录的状态被混合在一起,可以利用暂存区,通过交互式添加将其解开(这一点在 第二章**,开发与 Git, 和 第三章**,管理工作树 中有所描述)。你也可以使用交互式变基或类似技术,如 第十章**,保持历史整洁 中所示,整理提交,生成易读(且易于二分)的历史,然后再发布。
重要提示
你应该记住,提交是记录你的结果(或朝着结果迈出的特定步骤)的位置,而不是保存工作临时状态的地方。如果你需要在返回之前暂时保存当前状态,可以使用 git stash。
写一条好的提交信息
一条好的提交信息应该包含对更改的解释,足够详细,以便团队中的其他开发人员(包括审查者和维护者)能够判断是否应该将该更改包含到代码库中。这个是否“好”的决策不应该需要他们阅读实际的更改内容才能判断提交的意图。
提交信息的第一行应简洁明了(大约 50 到 72 个字符),概述所做的更改。如果有其他内容,应该用空行将其与剩余部分分开。这是因为在许多地方,例如 git log --oneline
命令输出、图形化历史查看器如 gitk
和 git rebase --interactive
的指令页中,你只会看到这一行提交信息,并且必须根据这一行来决定对该提交的操作。如果你在总结更改时遇到困难,这可能意味着这些更改需要拆分成更小的步骤。
对于更改的摘要行,有多种约定。一种约定是使用area:作为前缀,后面跟随代码修改的领域标识符:如子系统名称、受影响的子目录名,或被修改的文件名。如果开发通过问题跟踪器管理,则此摘要行可以以类似[#1234]的前缀开始,其中1234是提交中实现的问题或任务的标识符。通常情况下,如果不确定提交信息中应该包含哪些信息,请参考开发文档或回溯到历史提交中使用的当前约定。
提示
如果你使用的是敏捷开发方法,你可以在回顾过程中寻找特别好的提交信息,并将其作为示例添加到未来的开发者文档中。
对于非琐碎的更改,应该有一段更长的有意义的描述,也就是提交信息的正文。这里有些事情是来自其他版本控制系统的人可能需要忘记的:即不写提交信息或将其写在一长行中。注意,Git 不允许创建一个空提交信息的提交,除非通过--allow-empty
强制允许。
提交信息应该做到以下几点:
-
包括提交的理由,解释提交试图解决的问题——换句话说,为什么。它应该包含描述当前代码或项目在没有这个变更时的行为或状态的问题。这部分应当自包含,但可以引用其他来源,包括问题跟踪器(bug 跟踪器)或其他外部文档,如文章、wiki 或常见漏洞与 暴露(CVE)。
-
包含简短的摘要。在大多数情况下,它也应该解释如何解决问题,并为提交解决方案的方式提供理由。
-
描述为什么你认为变更后的结果更好;这部分描述无需解释代码的作用,因为这通常是代码注释的任务。
-
如果存在多个可能的解决方案,请包含考虑过但最终被弃用的替代解决方案的描述,并提供指向讨论或评审的链接。
确保你的变更说明可以在不访问任何外部资源的情况下理解(也就是说,不访问问题跟踪器、互联网或邮件列表档案)。与其仅仅引用讨论,或在提供 URL 或问题编号的同时,写出相关要点的总结并包含在提交信息中。
写提交信息时的一项可能推荐做法是使用祈使语气描述变更,例如让 foo 执行 bar,就好像你在命令代码库改变其行为,而不是写这个提交使得...或[我] 更改了...。
在这里,commit.template
和提交消息钩子可以帮助遵循这些做法。详细信息请见第十三章,定制和扩展 Git(以及第十四章,Git 管理,描述了强制执行这一建议的方法)。
准备变更以供提交
如果话题分支是很久之前开始的,考虑将该分支基于当前基础分支的最新提交进行 rebase。这应该会使未来的变更集成变得更容易。如果你的话题分支是基于开发版本,或者是基于其他正在进行中的话题分支(可能是因为它依赖某些特定功能),并且它所基于的分支已经合并到稳定的开发线路中,那么你应该将你的变更 rebase 到稳定的集成分支之上。
Rebase 也是最后清理历史的机会;这是使提交的变更更容易审查的机会。只需运行 git rebase --interactive
进行交互式 rebase,或者如果你喜欢,可以使用补丁管理工具(详见第十章,保持历史干净)。有一个注意事项:不要重写已发布的历史。
考虑测试你的变更是否能够顺利合并,如果不能,尽量修复它(如果可能)。确保它们能够顺利地应用或合并到适当的集成分支中。
最后检查一下即将提交的提交记录。确保你的变更没有添加注释掉的代码(或是 ifdef
注释掉的代码),也没有包含任何与补丁目的无关的额外文件(例如,未来功能的变更)。在提交之前,审查你的提交系列,确保准确无误。
集成变更
提交变更以进行合并的具体细节,当然取决于项目使用的开发工作流。关于各种可能的工作流类,详情请见第六章,使用 Git 进行协作开发。
提交并描述变更
如果项目有专门的维护者,或者至少有负责将提交的变更合并到官方版本的人,那么你还需要对提交的变更进行整体描述(除了描述每个提交之外)。这可以通过邮件发送补丁时的补丁系列封面信形式来完成。也可以通过使用协作贡献者仓库模型时,在 pull 请求中的评论中进行描述,或者可以通过已包含 URL 和你公共仓库中更改分支的邮件描述来完成(该 URL 是通过 git request-pull
生成的)。
这封封面信件或拉取请求应该包括补丁系列或拉取请求的目的描述。考虑提供为什么进行此项工作的概述(包括任何相关链接和讨论摘要)。在更改描述中明确表示这是一项进行中的工作。
在分散的贡献者模型中,变更作为补丁或补丁系列提交以供审查,通常是提交到邮件列表,你应该使用基于 Git 的工具,如git format-patch
,如果可能,使用git send-email
。多个相关的补丁应该被归为一组,例如放在同一个邮件线程中。约定是将它们作为对附加封面信件的回复发送,封面信件应该描述整个特性。
如果更改被发送到邮件列表,通常的约定是将主题行前缀加上[PATCH]
或[PATCH m/n]
(其中m
是补丁系列中的补丁编号,n
是总补丁数)。这样可以让人们轻松区分补丁提交和其他邮件。这个部分可以通过git format-patch
来完成。你需要自己决定是否在PATCH
后使用额外的标记来标记系列的性质,例如,PATCH/RFC
。(RFC在这里指的是征求意见,即提出一个特性的想法,并附上其实现的示例。这种补丁系列只有在想法有价值时才应被审查;它还不准备应用/合并,只是为了开发者之间的讨论。)
在共同位置的贡献者仓库模型中,所有开发者使用相同的 Git 托管网站或软件(例如 GitHub、Bitbucket、GitLab 或其私有实例),你会将更改推送到你自己的公开仓库,即官方版本的一个分支。然后,你会创建一个合并请求或拉取请求,通常通过托管服务的网页界面进行,在那里再次描述更改的整体内容。
在使用中央仓库的情况下(可能是在共享维护模型中),你会将更改推送到集成仓库中的一个单独且可能是新的分支,然后向维护者发送公告,以便他们能够找到这些更改进行合并。此步骤的详细信息取决于具体的设置;发送公告可能通过电子邮件、某种内部消息机制,甚至通过工单(或工单中的评论)进行。
开发文档可能包括规则,指定在哪里发送公告和/或更改。通知涉及到你所修改的代码区域的人员是出于礼貌的行为(你可以使用git blame
和git shortlog
来识别这些人)。
人员;请参见第四章,探索项目历史)。这些人很重要;他们可以写评论并帮助审查变更。
署名和致谢
一些开源项目为了改进代码的来源追踪,使用了借鉴自 Linux 内核的签名程序,称为数字来源证书。签名是一行简单的内容,位于提交信息的末尾,如以下示例:
Signed-off-by: Random Developer rdeveloper@company.com
通过添加这一行,你证明该贡献要么完全由你创建,要么部分由你创建,或基于之前的工作,或直接提供给你,并且链中的每个人都有权在适当的许可证下提交它。如果你的工作是基于其他人的工作,或者你只是转交别人的工作,那么可能会有多条签名行,形成一条来源链。
为了表彰帮助创建提交的人,你可以在提交信息中附加其他尾注,例如Reported-by:、Reviewed-by:、Acked-by:(此项表示该更改得到了负责该区域的人的认可)或Tested-by:。
更改审查的艺术
完成同行评审更改是非常耗时的(尽管使用版本控制也同样耗时),但其好处巨大:更好的代码质量、减少质量保证测试所需的时间、知识转移等等。更改可以由同行开发人员进行评审,也可以由社区评审(需要达成共识),或者由维护者或他们的副手进行评审。
在开始代码审查过程之前,你应该阅读拟议更改的描述,以了解为什么提出这个更改,并决定你是否是执行审查的合适人选(这也是为什么好的提交信息如此重要的原因之一)。你需要理解该更改尝试解决的问题。你应该熟悉问题的背景,并了解更改涉及的代码部分。
第一步是重现更改前的状态,检查程序是否按描述工作(例如,检查一个 bug 修复是否能够重现该 bug)。然后,你需要检查包含拟议更改的主题分支,并验证结果是否正确。如果有效,审查这些更改,列出所有错误的地方(尽管如果在流程早期就发现错误,可能无需深入下去),如下所示:
-
提交信息是否足够描述性?代码是否易于理解?
-
贡献的架构是否正确?它在架构上是否稳健?
-
代码是否符合项目的编码标准和约定的编码规范?
-
更改是否仅限于提交信息中描述的范围?
-
代码是否遵循行业最佳实践?它是否安全且高效?
-
是否有冗余或重复的代码?代码是否尽可能模块化?
-
代码是否在测试套件中引入了任何回归?如果是新功能,变更是否包括该新功能的测试,包括正向和负向测试?
-
新代码的表现是否和变更前一样(在项目的容忍范围内)?
-
所有单词拼写是否正确,新版本是否遵循内容的格式化指南?
这只是一个可能的代码审查检查清单提案。根据项目的具体情况,可能还会有其他需要在审查中提出的问题;让团队编写自己的检查清单。你可以在网上找到很好的例子。
将在审查过程中发现的问题分为以下几类:
-
错误问题:该功能不在项目范围内。有时用于无法复现的 bug。贡献背后的想法是否合理?如果合理,则可以带有或不带有偏见地弹出变更,并且不再继续分析审查。
-
无法正常工作:这无法编译,导致回归,未通过测试套件,未修复 bug 等。这些问题必须彻底修复。
-
不符合最佳实践:这不符合行业指导原则或项目的编码规范。贡献是否经过打磨?这些问题是相当重要的,但可能存在一些写法上的细微差别。
-
不符合 审查者偏好。在这种情况下,你应该建议修改,或者请求进一步澄清。
小问题,例如拼写错误或打字错误,可以由审查者立即修复。然而,如果相同的问题重复出现,考虑请求原作者进行修复并重新提交;这样做是为了传播知识。在审查过程中,你不应该做任何实质性的编辑(除非有特殊情况)。
提问,而不是直接告诉。解释为什么代码需要修改。提供改进代码的建议。区分事实和意见。
回应审查和评论
变更并不总是会在第一次尝试时被接受。你可以并且会收到来自维护者、代码审查员和其他开发人员的改进建议(以及其他评论)。你甚至可能会收到补丁形式或修复提交形式的评论。
首先,可以考虑在回应中表达对评论者花时间进行审查的感激。如果审查中的任何内容不清楚,可以请求澄清;如果你和审查者之间存在理解上的差距,提供澄清。
下一步通常是打磨和完善变更。然后,你应该重新提交它们(也许标记为v2)。你应该针对每次提交和整个系列的审查作出回应。
如果你在回应 pull request 中的评论,按照相同的方式回复。如果是通过电子邮件提交补丁,你可以将新版本的评论(包括对审查的回应或与上次尝试的不同之处)放在三条破折号 diffstat
中间,或者放在电子邮件顶部,用“剪刀”线将其与提交信息分开,例如,使用 git
format-patch --notes
。
根据项目的治理结构,你可能需要等待更改被认为是合适的并准备好合并。这可能是开源项目中由终身仁慈的独裁者做出的决定,或者是团队领导、委员会或共识的决定。在提交功能的最终版本时,总结讨论结果被视为一种良好的做法。
请注意,已经接受的更改仍可能会经过几个阶段,才最终进入稳定分支并出现在项目中。
其他建议
在本节中,你将找到那些不完全符合前面描述的项目启动、项目工作和集成更改领域的最佳实践和建议。
不要慌张,恢复几乎总是可能的。
只要你已经提交了工作并将更改存储在版本库中,它们就不会丢失。它们可能会被误放置。Git 也会尽力保存你当前未提交(未保存)的工作,但它无法区分例如通过 git reset --hard
意外或故意删除所有工作目录中的更改。因此,在尝试恢复丢失的提交之前,确保先提交或暂存当前的工作。
多亏了 reflog(无论是特定分支还是 HEAD
引用),大多数操作都很容易撤销。然后是暂存更改的列表(见 第三章,管理你的工作树),你的更改可能隐藏在其中。还有 git fsck
,作为最后的手段。有关数据恢复的更多信息,请参见 第十四章,Git 管理。
如果问题是你弄乱了工作目录,停下来思考一下。不要无谓地丢弃你的更改。在交互式添加、交互式重置(--patch
选项)和交互式检出(相同的方式)帮助下,你通常可以解开这个困局。
运行 git status
并仔细阅读其输出,有助于你在执行某些鲜为人知的 git
操作后卡住时找到解决方法。
如果你遇到了 rebase 或 merge 问题,且无法将责任推给其他开发者,总有第三方 git-imerge
工具可以帮助你。
不要更改已发布的历史
一旦你将修改公开,理想情况下应认为这些修订是不可更改、不可改变的。如果你发现提交存在问题,应创建修复(可能通过使用git revert
撤销修改的效果)。这一切都在第十章,保持历史清洁中描述:也就是说,除非在开发文档中明确说明这些特定的分支可以被重写或重做,否则最好避免创建此类分支。
在一些少见的情况下,你可能确实需要更改历史记录:例如删除文件、清理未加密的存储密码、删除意外添加的大文件等。如果你需要这么做,请通知所有开发人员这一事实。
发布版本的编号和标签
在发布新版本之前,请使用签名标签标记将要发布的版本。这可以确保刚创建的修订版的完整性。
命名发布标签和使用发布编号有各种约定。常见的一种约定是通过使用例如1.0.2
或v1.0.2
作为标签名称来标记发布版本。
提示
如果项目的完整性很重要,考虑使用签名合并进行集成(即合并已签名的标签)。详见第六章,使用 Git 进行协作开发,签名推送的内容请见第十四章,Git 管理。
命名发布版本有不同的约定。例如,时间驱动的发布版本通常使用日期命名,如2015.04
(或15.04
)。然后,有一种常见的MAJOR.MINOR.PATCH
编号约定,其中PATCH
在进行向后兼容的错误修复时增加,MINOR
在添加向后兼容的功能时增加,而MAJOR
版本在进行不兼容的 API 更改时增加。即使不使用完整的语义版本控制,通常也会为维护版本添加第三个数字,例如v1.0
和v1.0.3
。
尽可能实现自动化
你不仅应该在开发文档中写下编码标准,还需要强制执行这些标准。通过客户端钩子(第十三章,定制和扩展 Git)可以方便地遵循这些标准,而通过服务器端钩子(第十四章,Git 管理)可以强制执行。
钩子还可以通过自动管理问题跟踪器中的工单,并根据提交消息中的触发模式(模式)选择操作。钩子还可用于防止重写历史记录。
考虑使用第三方解决方案,如 Gitolite 或 GitLab,来强制执行访问控制规则。如果你需要进行代码审查,使用适当的工具,如 Gerrit 或 GitHub、Bitbucket 或 GitLab 的拉取请求。
总结
这些建议基于使用 Git 作为版本控制系统的最佳实践,能够真正帮助你的开发工作和团队。你已经学会了沿着这条道路的步骤,从一个想法开始,一直到最后的更改被集成到项目中。这些清单应该帮助你开发更好的代码。
深入阅读
要了解更多本章所涵盖的主题,请查看以下资源:
-
Emma Jane Hogbin Westby: Git for Teams (2015),O'Reilly Media
-
学习 Git 分支管理
learngitbranching.js.org/
-
Conventional Commits:为提交 信息 添加人类和机器可读意义的规范
www.conventionalcommits.org/
-
Commitizen - 旨在为 团队 设计的发布管理工具
commitizen-tools.github.io/commitizen/
-
Sage Sharp: 补丁审查的温和艺术 (2014)
sage.thesharps.us/2014/09/01/the-gentle-art-of-patch-review/
-
该死的, Git!?!
dangitgit.com/en
-
Julia Evans: 天哪,git! 小册子
wizardzines.com/zines/oh-shit-git/
-
语义化版本控制 2.0.0
semver.org/
第十六章:索引
由于本电子书版本没有固定的分页,以下页面编号仅供参考,并已根据本书的印刷版进行超链接。
符号
.gitattributes 文件 66
.gitignore 文件 63
A
修正 248
祖先 107
注解标签 16, 47, 157
applypatch-msg 钩子 356
请求 204
属性宏 76
认证域 175
作者
与提交者对比 131
自动修正 334
自动标签跟随 211
自动合并 233
自动压缩 253
B
bare 仓库 142
仁慈的独裁者 148
类似 BFG 260
BFG Repo Cleaner
从历史中删除文件 260
二进制大对象(blobs) 244
bisect 命令 127
位图索引文件 385
blame 命令 125
无 blob 克隆 323
分支 4, 47, 101, 186
匿名分支 51, 52
创建 47, 48
删除 54, 55
下游 206
获取 210, 211
远程仓库中的交互 205
列出 53
创建合并提交 222, 223
合并驱动程序 224
合并签名 224
合并策略 223
合并 220
名称,变更 56
无分歧 220-222
推送 210
推送 212
变基 227,228
重置 53,54
回滚 53,54
选择 50
切换命令 DWIM-mery 52
切换 50
切换,障碍 50
标签,合并 224
上游 205
分支头 101
分支
替代方案 188
隔离, versus 集成 186
长期运行的分支 187
生产发布,路径 187
目的 186
短期存在的分支 187
有效性 187
可见性,无集成 188
工作流 196
分支模式
集成模式 189
涉及长期存在的分支 193
发布工程 190
短期存在的分支,类型 195
分支模式,涉及长期存在的分支
自动化分支 194
mob 分支,针对匿名推送访问 194
孤立分支技巧 194,195
每个客户或每个部署分支 193
分支工作流
git-flow 203
毕业分支工作流 197,198
发布和主干分支工作流 196,197
安全问题,修复 204,205
Ship/Show/Ask 策略 204
主题分支工作流 199
分支管理
演变 186
分支名称
消歧义,路径名称之间 123
分支操作 100
分支点 102
分支重新基
高级重新基技巧 230, 231
后端 229
与合并对比 229
分支尖端 101
有问题的提交
查找 128, 129
内建文件系统监控器 (FSMonitor) 325
包
用于克隆和更新 168-170
用于更新现有仓库 170, 171
利用,帮助初始化锥形结构 171, 172
包 URI 能力 386
C
缓存文本转换 274
回车 (CR) 348
中央规范仓库 5
集中式工作流 144, 145, 178
优缺点 145
更改块 40
更改
合并方法 219
更改集
樱桃挑选 227
复制,创建 225, 226
提交效果,撤销 226, 227
合并,回滚 227
从补丁应用提交系列 227
更改,发布到上游 178
补丁,交换 180, 182
拉取请求,生成 179, 180
推送到公共仓库 178
字符编码 76
樱桃挑选 201
客户端钩子 353, 354
提交过程钩子 354
检出后钩子 358
合并后钩子 358
重写后钩子 358
自动垃圾回收前钩子 358
推送前钩子 357
pre-rebase 钩子 357
用于从电子邮件应用补丁 356
克隆 4, 186
代码
导入,手动 288, 289
代码行 186
代码审查/代码协作 384
协作工作流 142
bare 仓库 142
集中式工作流 144, 145
层次化或独裁-副手工作流 148, 149
维护者或集成经理工作流 147
非 bare 仓库 142
对等或分叉工作流 145, 146
仓库 142
仓库,交互式操作 143
合并差异格式 236
命令行补全 333, 334
提交 60
修改 45, 46
变更,检查 33
变更,交互式选择 42-44
commit-msg 钩子 355
创建 30
创建,逐步进行 44, 45
扩展,在项目历史中 30, 31
文件,选择要 42
索引,作为暂存区 31-33
post-commit 钩子 356
pre-commit 钩子 354
prepare-commit-msg 钩子 355
选择性提交 42
commit 位 377
commit-graph 文件 318
提交历史
附加信息,使用笔记存储 269
修改 248, 249
修改,不重写 264
错误的合并,还原 265-267
历史与注释,重写 274
交互式变基 249, 250
注释,添加 270
注释,作为缓存 274
注释,类别与用途 272, 273
注释,发布与检索 275, 276
注释,存储 270, 271
恢复,来自已还原的合并 267-269
还原 264, 265
重写 248
提交历史,交互式变基
修复 250-252
删除 250-252
重新排序 250-252
提交信息 9, 30
commit-msg 钩子 355
提交对象 244
提交历史
外部工具 255, 256
提交历史,交互式变基
变基提交,测试 254, 255
拆分 253, 254
压缩 252, 253
提交文件内容
检查 33
提交者
对比作者 131
提交遍历器 166
公共漏洞与暴露(CVE) 119, 273, 396
综合 Perl 归档网络(CPAN) 287
条件包含 30
上下文行 40
持续集成(CI) 145, 311, 393
持续集成/持续交付(CI/CD) 384
持续集成模式 190
协调世界时(UTC) 246
凭证助手机制 175
凭证助手 177, 363
凭证/密码管理 175
请求密码 175, 176
凭证助手 177, 178
公钥认证,针对 SSH 176
当前推送模式 215
自定义包注册表 288
D
数据版本控制(DVC) 320
默认远程跟踪分支 152
差分压缩 319
深度限制克隆 324
分离的 HEAD 29, 51, 196
分离的工作目录 92
开发工作流
增强 386, 387
定义, 在仓库中 387
差异驱动程序 69
输出, 配置 70
差异和二进制文件
生成 69
数字证书来源 399
有向无环图(DAG) 30, 98, 99, 244, 300
分支 100
引用名称(分支和标签) 101, 102
标签 100
整树提交 100
有向边 99
有向图 98
分布式版本控制系统(DVCS) 4, 98
按我所说的做(DWIM) 52
下游(回移) 272
E
EditorConfig 项目
URL 350
入队 316
外部差异驱动程序 70
F
合并失败
--merge 选项,适用于 git log 237
合并差异格式 236
冲突标记,在工作树中 233, 235
检查 233
阶段,在索引中 235
特性分支工作流 199
特性分支 186, 189
特性开关 188
特性切换 188
获取
与推送 208
获取备注 276
获取 refspec 209
文件属性 66
定义属性宏 76
二进制文件,识别 67, 68
内置属性 76
差异配置 68
行尾转换,识别 67, 68
文件,转换 72, 73
关键字扩展与替换 74, 75
合并配置 68
文件
忽略已跟踪文件中的更改 65
历史 123
历史简化 125
忽略文件,列出 64
忽略 59, 60
按行历史,使用 blame 命令 125, 126
标记为故意未跟踪(忽略) 60-62
多个工作目录 92, 93
强制文件转换 73, 74
路径限制 123, 124
重置为旧版本 91
重新跟踪 60
忽略的类型选择 63
取消修改 89, 90
取消暂存 89, 90
取消跟踪 60, 89, 90
工作区,清理 91, 92
文件系统层次结构标准 (FHS) 341
文件系统监视器
文件更改,使用 325 检查
跟随标签 158
分叉 180
工作流 145, 146
分叉点 102
分叉 186
正式依赖关系管理 287
G
垃圾回收过程 54
生成的文件 392
Gerrit 273
gil 工具
参考链接 312
Git 4
自动化,使用钩子 353
分支 47
命令别名 360-362
配置 339
凭证助手和远程助手 363
依赖关系,管理 287, 288
示例 4
扩展 360
在服务器上 372
排除故障 371, 372
Git 别名 360
git-annex 320
git bisect
错误提交,查找 128, 129
错误,使用 127 查找
进程,启动 128
测试,自动化 129, 130
git bundle-server 386
Git 命令
添加 362
管道和瓷器 247
Git 配置 339
访问 343
命令行选项和环境变量 339, 340
配置变量 343
文件 340, 341
按文件配置,使用 gitattributes 350
Git 属性文件的语法 351, 352
类型说明符 343
Git 配置文件
基本的客户端配置 345, 346
条件包含 342, 344
配置值 344, 345
格式化和空格问题 348, 349
包含 342, 344
变基和合并设置 347
服务器端配置 350
语法 342
撤销信息,保留 347, 348
Git 凭证管理器(GCM)178
Git 强制执行的策略
实现,使用钩子 376
使用服务器端钩子 376, 377
Git 示例
分支和合并 22, 24-26
协作开发 9-22
设置和初始化 5-9
git 文件命令 299-301
Git filter-repo
项目历史,使用 256 重写
git-flow 203
git-gc
用于自动清理 368
Git 钩子
客户端钩子 354
安装 353
服务器端钩子 359
仓库模板 353, 354
Git 托管 384
gitignore 文件 60
gitignore 模式 392
Git 内部 244
对象 244-247
Git-Large File Storage (Git-LFS) 320
gitlinks 命令 298-301
git log 命令
行历史,带 blame 命令 118
git log 输出
作者,映射 134
更改,格式化 132, 133
更改,包括 132, 133
更改,总结 132, 133
贡献,总结 133, 134
修订版文件,查看 134, 135
格式化 130
预定义输出格式 130-132
修订版,查看 134, 135
选择 130
用户定义的输出格式 130-132
git 维护
用于定期维护 368, 369
git 命名空间 385
Gitolite
用于智能 HTTPS 访问控制 382
参考链接 176
URL 382
Git,命令行 330
替代命令行 335
自动修正 334
命令行补全 333
自定义 334, 335
Git 感知的命令提示符 330-332
GitOps 387
git push 210
Git Quilt (Guilt) 255
git 替换
插接 279
历史,合并 277, 279
替换机制 276, 277
替换,发布和检索 280
使用 276
Git 仓库
匿名 Git 协议 380, 381
最佳实践和建议 401-403
变更,集成 397-401
傻瓜协议 382, 383
托管,技巧与窍门 384-386
本地协议 379, 380
管理,工具 383, 384
项目,启动 391-393
远程帮助 383
提供服务 379
智能 HTTP(S) 协议 381, 382
SSH 协议 380
在项目上工作 393-397
git reset
git stash 命令
和暂存区 84, 85
用于暂存变更 83
使用 83, 84
git submodule 命令 299-301
Git 子模块解决方案 298
上游子模块变更,发送 308
子模块变更,检查 306, 307
子模块,更新 304, 305
子项目作为子模块,添加 301-303
子项目,带子模块克隆 303
从上游子模块获取更新 307, 308
Git 子树解决方案
嵌入子项目代码 289-291
远程引用,创建 291, 292
子项目,作为子树添加 292, 293
子项目变更,显示 296
子项目变更,发送至 297, 298
超级项目,带有子树克隆 294
超级项目更新,带有子树合并 294, 295
超级项目,更新带有子树 294
Git 传输
代理 167, 168
gittuf 379
通配符模式 105
GNU 可移植性库(Gnulib) 71
毕业分支 203
毕业分支工作流 197, 198
移植 279
图形化提交工具 339
图形化历史查看器 339
图形化界面 336
示例 338, 339
图形化差异 337, 338
图形化工具,类型 336, 337
合并工具 337, 338
图形化工具
提交工具 336
图形化责怪 336
图形化历史查看器 336
图形用户界面(GUIs) 330
H
健康分支 188
分层(独裁与副官)工作流 148, 149
优缺点 149
分层分支名称 394
基于钩子的文件系统监视器 326
钩子 353
Git,使用 353 自动化
用于实现 Git 强制策略的 376
钩子,用于应用来自邮件的补丁
applypatch-msg 钩子 356
post-applypatch 钩子 357
pre-applypatch 钩子 357
热修复分支 193, 204
hunk 40, 70
I
被忽略的文件
列表 64
忽略模式 21
不可变 248
索引 60
inefeed (LF) 348
基础设施即代码(IoC) 387
集成开发环境(IDE) 330
集成分支 197
集成经理 213
集成经理(维护者)工作流程 147
优势 147
缺点 148
集成模式 189
持续集成 190
主线集成 189
基于主题分支的开发 189, 190
交互式行历史浏览器 339
交互式变基 45, 78, 249, 250
隔离
与集成相比 186
问题跟踪票 376
J
JGit 336
Jupyter Notebook 73
K
Kernel.org 透明日志监控 379
关键词锚点 74
关键词扩展 74
关键词替换 75
限制 75
L
大规模不可恢复的初始克隆问题
解决 386
大规模历史重写
外部工具 259
文件,使用 BFG Repo Cleaner 从历史中移除 260
仓库历史,使用 reposurgeon 编辑 260
叶节点 99
传统(笨重)传输 166
libgit2 336
中尉 148
轻量级标签 47, 156
本地区域网络 (LAN) 170
本地传输 164
长期存在的分支 187
丢失的提交
恢复 369, 370
M
主线集成 189
主线 187
维护者工作流 147
恶意意图 155
主项目 286
匹配推送模式 213
成熟分支 190
合并提交 102
合并冲突
避免 237
处理 238
失败的合并,检查 233
文件,标记为已解决 240
git 合并 240
图形化合并工具,使用 239
合并,中止 238
合并选项 237
合并,最终化 240
选择我们的或他们的版本 239
rebase 冲突,解决 240
rerere 238
解决 232
可脚本化修复 239
三方合并 232, 233
合并驱动 69
合并操作 102
合并策略 223
向上合并 191
Merkle 树 156
合并更改的方法 220
分支,合并 220
更改集,应用 225
更改集,复制 225
压缩合并 231, 232
小版本发布 197
镜像 210
混合重置 78
单体仓库 (monorepo) 286, 392
用例 311
N
nbdev 73
节点 99
非裸远程仓库
当前分支,推送到 209
非裸仓库 143
注释 69, 264
添加到提交 270
存储附加信息 269
作为缓存 274
类别和用途 272, 273
发布与检索 275, 276
存储 270, 271
O
离线传输
使用捆绑包 167
开放文档格式(ODF) 73
孤立分支 99, 194
创建 49
表面上递归的双胞胎(ort0) 224
P
厚皮动物 320
打包 287
打包位图
用于加速智能协议的使用 385
父节点 31
部分克隆 317
补丁 255
补丁堆栈管理接口(StGit) 45
路径规格魔法 124
点对点(分叉)工作流 145, 146
优势 146
缺点 146
正在检查正确性时的待处理更改
与上次修订的差异 36, 37
统一的 Git 差异格式 37-42
工作目录状态 33-36
每次提交的工具特定信息 273
每个发布分支 192
每个仓库的配置文件 341
每个工作树的配置文件 341
铁锤搜索 121
管道命令 102, 248
与瓷器命令相比 65
政策违规,带客户端钩子
早期通知 378
瓷器命令 247
与管道命令对比 65
可移植对象 (.po) 76
应用补丁后钩子 357
检出后钩子 358
提交后钩子 356
后钩子 353
合并后钩子 358
消息后钩子 356
接收后钩子 359, 375
重写后钩子 358
post-update hook 359, 376
PowerShell 脚本文件 (*.ps1) 76
应用补丁前钩子 357
自动垃圾回收前钩子 358
提交前钩子 354, 355
前钩子 353
准备提交消息钩子 355
变基前钩子 357
接收前钩子 359, 373, 374
良好隐私保护 (PGP) 274
私钥 GPG 157
稳定性进展分支 197
项目文件
编辑,在线 316
项目历史
修订中的更改,搜索 121, 122
修订数量,限制 118
修订元数据,匹配 118
搜索 118
选择更改类型 122
使用 Git filter-repo 的项目历史
无过滤器的 filter-repo,运行 256, 257
过滤类型,检查可用性 257
示例使用 258, 259
公钥认证 176
公钥/私钥对 176
发布标签 157
拉取命令 208
拉取
与推送相对 208
拉取请求 147
生成 179, 180
推送证书 379
推送模式 209, 212
当前推送模式 215
匹配推送模式 213
简单推送模式 212
上游推送模式 214
使用 212
推送到检出钩子 360, 374
Python 包索引 (PyPI) 288
Q
Quilt 255
R
读取-评估-打印循环 (REPL) 259
重新基准化 143
递归 223
引用 (refs) 100
引用日志 26, 31, 369
引用规格 207
发布和主干分支工作流 196, 197
发布分支 203
发布候选 197
发布工程 190
热修复分支,用于安全修复 193
每发布分支 192
每发布维护 192
渐进稳定分支 190, 191
发布列车,功能冻结 192, 193
发布就绪主干 190
远程
默认分支,设置 153
删除 154
跟踪的分支列表,变更 153
删除远程跟踪分支 154
更改远程 URL 153
重命名 152
远程助手 288, 363
远程引用 291
远程仓库
更新信息 152
管理 149
添加新远程仓库 151
原始远程仓库 150
远程仓库,检查 150,151
远程仓库,列出 150,151
支持三角工作流 154,155
远程跟踪分支 151,206,207
远程传输助手 172
使用外部 SCM 仓库作为远程仓库 174
使用传输中继 173,174
删除更改 252
重命名检测 126
重命名跟踪 126
重新排序提交 251
包含大二进制文件的代码仓库 318
二进制资产文件夹,拆分为单独的子模块 319
在仓库外部存储 319,320
包含大量文件的代码仓库 320
使用文件系统监视器检查更改 325,326
使用稀疏克隆省略内容 324,325
使用稀疏克隆减少本地仓库大小 322-324
限制工作目录文件数量,使用稀疏签出 321
长期历史的代码仓库
处理 316
加速操作 318
使用浅克隆获取截断历史 317
单分支,克隆 318
代码仓库 4
代码仓库维护 368
使用 git-gc 进行自动清理 368
使用 git-maintenance 进行定期维护 368,369
reposurgeon
用于编辑仓库历史的工具 260
仓库工具
引用链接 312
请求评论 (RFC) 182,398
重置命令 53
分支头,重置 78,79
分支,回退与 80
变更,丢弃与 80
提交,修正 78
提交,删除 78
提交,移动到功能分支 81
提交,压缩与 78
索引,重置 78,79
合并或拉取,撤销 81
错误,修复与 77
分支头,回退与 77
用于将提交拆分为两个 79
重用记录的解决方案(rerere)238
修订控制 4
修订元数据,匹配 118
提交内容,匹配 119
提交父节点 120
时间限制选项 118,119
修订范围
创建,通过包含和排除修订 112
双点符号表示法 111,112
单一修订 113
选择 110
单一修订,作为修订范围 110
三点符号表示法 113-115
重写发布历史 260,261
上游重写的后果 261,262
恢复,来自上游历史重写 262,263
根节点 99
S
安全重置
变更,保持 82
当前变更,变基 82
工作 82
标量工具 316
扩展的基于主干的开发 190
安全外壳协议(SSH)165,379
安全问题
修复 204
语义化版本控制 287
URL 402
服务器端钩子 353, 359, 372, 373
post-receive 钩子 375, 376
post-update 钩子 376
pre-receive 钩子 373, 374
push-to-checkout 钩子,用于向非裸仓库推送 374
update 钩子 374
用于强制执行策略 376, 377
SHA-1 哈希函数 105, 106
浅克隆 280, 317
shell glob 模式 60, 61
shell 提示符 330
Ship 204
短暂分支
匿名分支 (游离 HEAD) 196
修复分支 196
类型 195
短暂 (临时) 分支 187, 188
shortlog 180
显示 204
签名提交 158, 159
签名推送 378, 379
签名标签 16, 47, 157
合并,与已合并的标签 159, 160
简单推送模式 212
单一修订版本选择 103
--branches 104, 105
--tags 104, 105
祖先引用 107, 108
分支和标签引用 104
HEAD 103
远程跟踪分支,上游 109, 110
反向祖先引用 108
通过提交消息选择修订版本 110
SHA-1 105-107
SHA-1 标识符 105-107
短名,重新登录 108, 109
智能 HTTP(S) 协议 381
智能传输 164
本地 Git 协议 165
智能 HTTP(S) 协议 166
SSH 协议 165
快照 100
sneakernet 167
软重置 78
软件仓库 384
源分支 186
源代码管理 384
稀疏签出技术 66, 290, 321
稀疏克隆 321
本地仓库大小,通过 322, 323 减少
用于匹配克隆稀疏度 324, 325
用于省略大文件内容 324
压缩合并 231, 232, 293
SSH 隧道 173
稳定分支 188
Stacked Git (StGit) 255
暂存文件内容
检查 33
暂存区 29, 59
暂存区 45
内部 85, 86
恢复 87
取消应用 86
子文件夹
转换为子模块 309
转换为子树 309
主题 253
子模块 286, 319
子文件夹,转换为 309
使用案例 311, 312
与子树 310
支持子模块的状态 306
子项目 286
子项目代码
嵌入,使用 Git 子树解决方案 289-291
子树 286
子文件夹,转换为 308, 309
用例 311
与子模块对比 310
子树方法
问题 290
子树合并 289
超级项目 286
支持分支 203
系统范围配置文件 341
T
标签补全 334
标签 16, 100
标签对象 157, 244
标签操作 100
标签 16, 47, 100
获取 210, 211
推送 210, 212
纠结的工作副本问题 42
第三方子项目管理解决方案 312
三路合并算法 232, 233
执行 71
主题分支 203
主题分支工作流 199
毕业分支 200-202
主题分支模式 189
跟踪分支 206
跟踪文件 31, 59
传输协议 163
凭证/密码管理 175
传统(笨重)传输 166
本地传输 164
离线传输与捆绑包 167
远程传输助手 172
智能传输 164
无树克隆 323, 324
树对象 244
尝试合并 238
三角形工作流 150, 154
主干 189
基于主干的开发 189
主干分支 196
信任链
注解标签 157
内容寻址存储 155, 156
轻量级标签 156
发布标签 157
签名标签 157
标签验证 158
信任首次使用 (TOFU) 166
U
统一差异格式 29
更新钩子 359, 374
上游分支 206
上游移植 (前向移植) 272
上游推送模式 214
上游仓库 206
V
版本控制系统 (VCS) 4, 97, 174, 288
W
网络接口 384
空白错误 76
WIP 提交
状态、保存与恢复 79
进行中的工作 (WIP) 77, 182
工作树 4, 59
文件内容,搜索 89
文件和目录,检查 87, 88
管理 87
订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助您规划个人发展并推进您的职业生涯。更多信息,请访问我们的网站。
第十七章:为什么订阅?
-
通过来自 4,000 多位行业专家的实用电子书和视频,减少学习时间,更多时间用于编码
-
通过专门为您设计的技能计划,提升学习效果
-
每月获得一本免费电子书或视频
-
完全可搜索,方便快速访问关键信息
-
复制、粘贴、打印和收藏内容
您知道 Packt 提供每本出版书籍的电子书版本,包括 PDF 和 ePub 文件吗?您可以在 packtpub.com 升级为电子书版本,作为纸质书籍的客户,您可以享受电子书版本的折扣。欲了解更多详情,请通过 customercare@packtpub.com 联系我们。
在 www.packtpub.com,您还可以阅读一系列免费的技术文章,订阅各种免费的电子报,并享受 Packt 书籍和电子书的独家折扣与优惠。
您可能会喜欢的其他书籍
如果您喜欢这本书,您可能会对 Packt 出版的其他书籍感兴趣:
《GitHub Actions Cookbook》
Michael Kaufmann
ISBN: 978-1-83546-894-4
-
使用 VS Code 和 Copilot 编写和调试 GitHub Actions 工作流
-
在 GitHub 提供的虚拟机(Linux、Windows 和 macOS)上运行工作流,或者在您的基础设施中托管自己的 Runner
-
了解如何使用 GitHub Actions 保障您的工作流安全
-
通过使用 GitHub 强大的工具(如 CLI、API、SDK 和访问令牌)自动化工作流,提升您的生产力
-
以安全可靠的方式进行分阶段或基于环形的部署,向任何云平台部署
《DevOps Unleashed with Git and GitHub》
Yuki Hattori
ISBN: 978-1-83546-371-0
-
掌握 Git 和 GitHub 的基础知识
-
解锁推动自动化、持续集成和持续部署(CI/CD)以及监控的 DevOps 原则
-
促进无缝的跨团队协作
-
使用 GitHub Actions 提升生产力
-
衡量并提升开发速度
-
利用 GitHub Copilot AI 工具提升您的开发者体验
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们与成千上万的开发者和技术专业人士合作,帮助他们将自己的见解分享给全球的技术社区。您可以提交一般申请、申请我们正在招募作者的特定热门话题,或提交您自己的创意。
分享您的想法
现在你已经完成了掌握 Git,我们很想听听你的想法!如果你是从 Amazon 购买的本书,请点击这里直接跳转到 Amazon 评论页面,分享你的反馈或在你购买书籍的站点上留下评论。
你的评论对我们和技术社区非常重要,它将帮助我们确保提供优质内容。
下载这本书的免费 PDF 副本
感谢你购买这本书!
你喜欢随时随地阅读,但又无法随身携带纸质书籍吗?
你的电子书购买是否与你选择的设备不兼容?
不用担心,现在每一本 Packt 书籍,你都可以免费获得该书的无 DRM PDF 版本。
随时随地,任何设备上阅读。从你最喜欢的技术书籍中直接搜索、复制和粘贴代码到你的应用程序中。
福利不仅仅到此为止,你还可以获得独家折扣、新闻简报以及每天送到邮箱的精彩免费内容
按照以下简单步骤即可享受相关福利:
- 扫描二维码或访问下面的链接
packt.link/free-ebook/978-1-83508-607-0
-
提交你的购买证明
-
就这样!我们将直接把你的免费 PDF 和其他福利发送到你的邮箱