Git-实践指南-全-

Git 实践指南(全)

原文:Practical Git

协议:CC BY-NC-SA 4.0

一、Git 直觉

我们都试过了。我们的 Git 存储库处于不一致和不可调和的状态。我们已经发现了许多关于堆栈溢出的解决方案,并犹豫地粘贴到我们的命令行中。但是在每一次试图回到正常状态后,我们感觉自己离解决 Git 问题越来越远。因此,我们删除本地存储库,克隆存储库,然后重新开始。我知道我不止一次遇到过这种情况。这种情况很普遍,是对 Git 如何工作缺乏直觉的症状。我们倾向于选择阻力最小的路径,用 Git 术语来说,这意味着我们学会了承诺、推动和拉动。在某些情况下,我们学会了用树枝工作,但是如果我们偏离了快乐的道路,我们会变得不舒服。这本书恰恰想避免这一点。我们将建立一个坚实的直觉基础,在此基础上,我们将应用具体的命令和解决方案。这允许我们首先对我们所处的情况进行推理,然后从我们的工具包中选择正确的解决方案。因为我们已经实践过了,所以我们可以放心地应用解决方案。

这本书恰恰想避免这一点。我们将建立一个坚实的直觉基础,在此基础上,我们将应用具体的命令和解决方案。这允许我们首先对我们所处的情况进行推理,然后从我们的工具包中选择正确的解决方案。因为我们已经实践过了,所以我们可以放心地应用解决方案。

这一章将在一个高层次上建立我们的直觉,我们将首先研究直觉如何映射到 Git 命令,以及我们的工作空间和存储库如何反映这些命令。

版本控制

在这一节中,我们将讨论当我们使用 Git 时,我们试图解决什么类型的问题和什么具体的问题。这是我们整个努力的基础和动力。Git 是一个版本控制系统,但是它在我们的日常生活中意味着什么呢?

Git 也称为内容可寻址文件系统。这种东西渗透到 Git 感知世界的整个方式中,并为 Git 能做的事情设定了界限。这意味着 Git 的核心是管理文件。当与 Git 交互时,我们要么管理文件和目录的版本,要么调查工作区的历史。

我们中的许多人都经历过类似图 1-1 的情况,在那里我们有一个工作区,基于一些任意的命名约定,到处复制着一个项目的不同版本。这就是当我们不积极地对软件进行版本控制时的结局。

img/495602_1_En_1_Fig1_HTML.jpg

图 1-1

工作区中命名不明确的文件夹,使得最新版本是什么以及它们之间的关系不明显

这种特别的方法导致了各种各样的困难挑战。这里要指出的重要一点是,这些问题或挑战都不是我们试图解决的问题或我们工作方式所固有的。这些工具是免费提供的;简直就是选择了一种不正当的工作方式。下面列出了直接在文件系统中工作时不可能或不必要的困难:

  • 每个文件夹是如何相互关联的?

  • 最新版本是什么?

  • 两个具体版本有什么区别?

  • 两种产品变体的共同基础是什么?

  • 如何还原产品变型中的特定变更?

  • 这种变化是在什么时间点引入的,由谁引入的?

  • 我如何合并这两个文件夹?

在图 1-2 中,我展示了如何将相同的文件夹合并到一个工作空间图中。这允许我们保持对我们的软件如何随时间发展的感觉。

img/495602_1_En_1_Fig2_HTML.jpg

图 1-2

图 1-1 中的文件夹映射到工作区的图表中。这极大地增加了我们对历史的了解

这些问题以及更多的问题是 Git 每天为全球开发者解决的。在我们开始研究我们的第一个 Git 存储库之前,我们需要描述一些基本概念。语言是传达理解的一种强有力的方式,所以我建议你在谈论 Git 的时候尽量学究气。这将有助于你内化这些概念。当你是大师的时候,你想怎么含糊就怎么含糊。

基本 Git 概念

既然我已经提供了这类问题的一个非常基本的概述,我将深入到我们需要建立对 Git 的理解的基本构件中。

仓库

当我们在高层次上谈论 Git 时,我们谈论的是存储库中的协作。许多软件开发人员在 GitHub 或 GitLab 等平台上将他们的代码作为开源共享。每个软件项目由一个或多个存储库代表,这取决于项目背后的组织包含的策略。在许多情况下,存储库代表一个单一的源组件,如软件库或您可以下载并在您的计算机或网站上运行的产品。

对于本书的大部分内容,我们将在一个单一的存储库中工作,并且对于本书的大部分内容,该存储库将是本地的。也就是说,我们不会协作或使用在线平台来同步我们的存储库。

存储库包含有关我们版本化工作空间的所有可用信息。这包括所有的提交,所有的引用,以及存储库的整个历史。

Note

新的 Git 用户,尤其是那些从另一个版本控制系统(比如 ClearCase 或 SVN)迁移进来的用户,担心整个存储库驻留在开发人员的本地 PC 上。他们担心存储库会占用不合理的空间,并且操作会很慢。深入探讨这个话题已经超出了本书的范围。简短的回答是,它不太可能成为大多数工作流的问题,如果它成为一个瓶颈,有工具和策略来处理它。

所有 Git 命令都在存储库的上下文中运行。无论我们是运行简单的命令与本地存储库交互,还是执行更复杂的在线协作命令,都是如此。本书中使用的所有练习都在存储库的环境中运行,您在日常生活中要做的所有工作也是如此。在存储库中开始工作有两种常见的方式。我们可以使用命令git init来初始化一个没有任何历史记录的新的本地存储库,并在那里开始我们的工作。这甚至可以在包含不受版本控制的内容的文件夹中完成。更常见的是,我们使用命令 git clone 来获得一个存储库的副本。存储库的来源通常是私有的(即本地的 Bitbucket)或公共的(即 GitHub cloud)存储库管理器。如果我们使用的是clone命令,我们通常称之为克隆一个存储库,我们称存储库的本地实例为克隆

本地存储库通过一个名为 remote 的配置与源绑定在一起。除非您使用开源软件,否则您不太可能使用一个以上的遥控器。开源软件通常是用所谓的“基于分叉的”工作流开发的,我们将在后面的章节中介绍。协作通常是通过在本地和远程存储库之间推和拉变更来完成的。如何做到这一点将在后面介绍。

简而言之,存储库是软件项目历史的总和。这些都与元数据一起提交。存储库允许您在本地使用版本控制,并通过远程存储库与其他人协作。

Note

一些商业软件开发在内部使用基于 fork 的工作流。这可能是由于软件工程部门中不同的信任级别或低成熟度造成的。我认为基于 fork 的工作流是一种反模式,或者在这种情况下最多是一种变通方法。基于 Google 的研究表明,感知开发人员生产力的一个关键因素是来自团队外部的源代码的可见性和可用性。

提交

Git 的基本单位是提交。直观地说,Git 提交代表了工作区在给定时间点的完整状态。提交还包含以下元数据:

  • 在它之前有什么承诺

  • 作者和委托人

  • 时间戳

  • 提交消息,包含提交内容的信息

Caution

新的提交绝不会无缘无故地被创建。它们的创建由用户发起。这可能会给新用户带来一些挫折,他们不明白为什么在共享存储库中看不到他们的变更。经常发生的情况是,用户试图共享他们所有的新代码,但是没有创建新的提交,共享存储库已经是最新的,没有最新的更改。在分享之前,确保你提交了。

前一次提交被称为父提交。我们可以看到,我们创建了一个提交图,通过提交中的父指针连接在一起。提交可以有零个、一个或多个父级。

最常见的情况是只有一个父提交。当我们沿着一个单一的品系或链条前进时——创造一个又一个版本——就会发生这种情况。

存储库中的第一次提交是特殊的,因为它没有父提交。这是有意义的,因为在第一次提交之前什么都没有发生。第一次提交通常也称为初始提交。许多存储库的第一次提交具有消息“初始提交”,指示它是版本化历史的开始。如果我们看到大量的首次提交,这通常是开发人员在考虑版本控制之前做了太多工作的迹象。这是不好的,因为版本控制不应该是事后的想法。但是你在这里,所以你当然不会再在这种情况下结束。

一个提交也可以有任意多的父级。当分支合并时,提交以多个父代结束。我们稍后会谈到这一点,所以现在不用担心。我说提交可以有任意多的父类,这是真的。Linux 内核是一个有趣的地方,可以看到 Git 已经习惯了它的极限。Linux 和 Git 的发明者 Linus Torvalds 对章鱼合并有着臭名昭著的喜爱,在章鱼合并中许多分支被一次合并。这个工作流程显然适用于 Linux 内核和其他开源项目,但是我的建议是,如果您最终合并了两个以上的分支,那么您很可能做错了。

简而言之,Git commit 是一个表示工作空间的包,我们可以在任何时间点以闪电般的速度检索和研究它。

树枝和标签

Git 有两种类型的东西,对象和引用。我们之前描述的提交是不可变的,并且属于称为对象的类别。另一类有用的东西叫做引用,它要轻量级得多。

现在,我将介绍两种类型的引用,分支和标记。两者都指向我们使用提交构建的图中的特定提交,如前所述。

标签

标签是 Git 中最简单的引用。它只是一个指向提交的指针。标记的提交用途是用以具体版本命名的标记来标记发布的提交。

在图 1-3 中,我们看到一个带有标签的提交;这允许我们引用这个提交,而不使用它的 sha。

img/495602_1_En_1_Fig3_HTML.jpg

图 1-3

一个标签指向提交

标签永远不会改变。这意味着我们在任何时候都可以通过一个名字返回到一个提交。讨论“1.0 版”发生的事情比一个龙沙更容易理解是怎么回事。

树枝

以我的经验来看,分支给开发人员带来了巨大的力量和挫折。然而,这种沮丧是没有理由的。没有经过适当的教育就简单地使用 Git,往往会缺乏心智模型。可视化 Git 图和分支是所有人都可以使用的强大动力。

一个分支就像一个标签,除了分支应该是移动的。当我们进行开发和创建提交时,当前活动的分支向前移动并指向我们创建的新提交。当前活动的分支也被称为我们已检出的分支。

Note

虽然不一定要签出一个分支,但是这样做被认为是最佳实践,而且我认为在正常开发的所有情况下,您都要签出一个分支。当你已经检查出一个分支之外的东西时,你将最终处于所谓的超脱头状态,这听起来比它更危险。我们稍后将讨论如何结束这种状态以及如何安全地恢复。

在 Git 中,分支是非常轻量级的。它们的重量不超过 41 字节。这些字节代表一个提交 sha 和一个换行符。这意味着拥有许多分支的主要成本不是技术成本,而是受限于工程师在 Git 存储库中拥有许多指针所带来的认知开销。

在图 1-4 中,我们可以看到当我们创建多个提交时,当前活动的分支是如何移动的。我们将在第四章中讲述 Git 如何知道当前活动的分支是什么。

img/495602_1_En_1_Fig4_HTML.jpg

图 1-4

Git 分支随着更多提交的创建而移动

Git 使用名为 master 的分支作为默认分支。这意味着我们期望大师分支是真理的主要来源,也是最重要的分支。在其他版本控制系统中,这可以称为主干或主线。有一些约定期望默认分支被命名为 master,我强烈建议您不要将您的单一真理源分支命名为 master 以外的名称。这样做后果自负。解决任何实际问题的可能性极小。

在 Git 中,我们可以有许多分支,但是建议我们有少量的长期或永久分支。以我的经验来看,需要一个以上的永久分支机构是人为的和被曲解的。拥有许多分支通常会导致出现过于复杂的工作流,从而在开发过程中产生开销。复杂的工作流经常被引入,以创建更高质量、更安全的集成,以及类似的其他东西,但通常,它们最多是处理软件工作方式中更深层次问题的症状。

设置 Git 和 Git 表格

既然我们已经介绍了基础词汇,我们要确保一切都设置好了。然后,我们可以深入实际的 Git 库,在那里进行一些有意识的练习。我倾向于把所有东西都放在 Git 存储库中,所以你不会惊讶我们在本书中使用的练习是以 Git 存储库的形式交付给你的。

这就是为什么我们现在将介绍我们的第一个命令。如前所述,Git 是一个分布式版本控制系统。在您的日常生活中,您很可能会在云中托管的存储库或许多存储库管理器中的一个上进行协作。我们使用 Git clone 命令来获取存储库的本地副本,以便在其中工作。

Git 克隆

克隆是一个两步命令:首先,它下载 Git 存储库,然后,它将存储库的默认分支上最近的提交检出到工作区。

首先,这使您能够更改文件、编译代码和运行测试——所有这些任务都是您通常在带有源代码的工作空间中执行的。其次,当您在克隆期间下载整个存储库时,您可以比较代码的不同修订,并在存储库中以本地速度执行所有可能的版本控制命令。

克隆命令有许多变体,但就其基本形式而言,看起来是这样的:git clone <remote-repository> <local-path>git clone https://github.com/eficode-academy/git-katas.git git-katas就是一个例子。这将把包含 git katas 的存储库下载到文件夹git-katas/.git/中,并将master分支上最新提交的工作区签出到文件夹git-katas中。

那个。git 文件夹

我写这本书的一个目标是让 Git 变得不可思议,变成一个你可以使用的令人敬畏的工具。Git 的一个比许多人觉得烦恼的部分是。git 文件夹。虽然它感觉像一个神奇的文件夹出现在你的工作区,但它却是一个抽象世界的理智之源。

我们将不会深入到许多事情的细节。git 文件夹,但是出于直观的目的,让它包含以下内容就足够了

  • 存储库的整个历史,包括数据

  • 本地配置

  • 指向当前已签出内容的指针

  • 指向克隆来源的指针

这决不是一个完整的列表,但是它强调了非常重要的一点:当您克隆一个存储库时,您会在您的本地计算机上获得整个存储库。有很多方法可以获得一个较小的存储库子集,但是假设您获得了完整的存储库,并且它不会导致性能问题或不必要的空间使用。相反,它允许您脱机、异步工作,并以本地系统的速度工作。

随着克隆命令的引入,我们将进入第一个练习,并下载我们将使用的练习。

SETTING UP GIT AND THE KATAS

是时候使用命令行了。我将展示在 Windows 中通过 Git Bash 执行的所有命令。这个命令行环境是随 Windows 一起安装的 Git 附带的,并且与 Linux 和 Mac 上的常见 shells 兼容,因此无论您选择什么平台,您都应该能够识别所有内容。一些用户报告了使用 zsh 命令行的问题。如果您遇到这种情况,请运行 bash 中的练习。

检查 Git 正在工作

首先,我们将打开一个命令行并运行git --version命令来检查一切是否按预期运行。

  • 打开您喜欢的命令提示符。

  • 在任何位置执行命令git --version

  • 您应该会在命令行中看到正在运行的 Git 版本的输出。

预期的结果应该如下所示:

$ git --version
git version 2.25.0.windows.1

我们得到的是 Git 的安装版本。我的情况是版本2.25.0.windows.1。在撰写本文时,这是 Git 的最新版本。我建议你更新到这个版本。Git 中发布了许多好东西,包括性能和 UX 智慧。所以要跟上版本。

检索 Git 卡塔

我们将要用来练习我们正在介绍的概念的练习叫做 Git katas,可以通过 GitHub 获得。

在我们开始使用 Git 进行具体练习之前,这个练习将带您完成克隆 Git katas 存储库的过程,并检查您是否有完整的练习集。如果您对基本的 shell 命令感到不舒服,那么现在正是阅读这些命令的好时机。

  1. 启动命令行:打开您喜欢的终端,准备在其中执行命令。

  2. 导航到您希望存储源代码的位置。我更喜欢将我的文件存储在~/repos/organization/repo中。

  3. 使用 Clone 命令克隆 Git 卡塔:

    git clone``https://github.com/eficode-academy/git-katas.git

  4. cdgit-katas文件夹,使用ls查看练习列表。

如果我运行上述命令,看起来如下所示:

$ cd ~/repos/randomsort

$ git clone https://github.com/eficode-academy/git-katas.git git-katas
Cloning into 'git-katas'...
remote: Enumerating objects: 34, done.
remote: Counting objects: 100% (34/34), done.
remote: Compressing objects: 100% (31/31), done.
remote: Total 1690 (delta 16), reused 7 (delta 3), pack-reused 1656
Receiving objects: 100% (1690/1690), 486.60 KiB | 1.72 MiB/s, done.
Resolving deltas: 100% (708/708), done.

$ cd git-katas

$ ls
3-way-merge/                  basic-staging/             ff-merge/        merge-driver/     rebase-interactive-autosquash/  test.ps1
advanced-rebase-interactive/  basic-stashing/            git-attributes/  merge-mergesort/  reorder-the-history/            test.sh
amend/                        bisect/                    git-tag/         objects/          reset/                          utils/
bad-commit/                   commit-on-wrong-branch/    ignore/          Overview.md       reverted-merge/
basic-branching/              commit-on-wrong-branch-2/  img/          pre-push/         save-my-commit/
basic-cleaning/               configure-git/             investigation/   README.md         SHELL-BASICS.md
basic-commits/                detached-head/             LICENSE.txt      rebase-branch/    squashing/
basic-revert/                 docs/                      merge-conflict/  rebase-exec/      submodules/

$

在这个小例子中有很多事情在发生。首先,我们从 clone 命令中得到很多输出,但幸运的是,我们可以忽略它,除非我们试图调试某些东西。其次,有两件事我们可以忽略,大多数人通常会忽略。我们经常会忽略。git 远程存储库的一部分,并让 Git 和存储库管理器对其进行分类。许多人忽略了命令的最后一部分。这将把存储库克隆到与存储库同名的文件夹中,如下例所示:

$ git clone https://github.com/eficode-academy/git-katas
Cloning into 'git-katas'...
remote: Enumerating objects: 34, done.
remote: Counting objects: 100% (34/34), done.
remote: Compressing objects: 100% (31/31), done.
remote: Total 1690 (delta 16), reused 7 (delta 3), pack-reused 1656
Receiving objects: 100% (1690/1690), 486.60 KiB | 1.72 MiB/s, done.
Resolving deltas: 100% (708/708), done.

$ ls git-katas
3-way-merge/                  basic-staging/             ff-merge/        merge-driver/     rebase-interactive-autosquash/  test.ps1
advanced-rebase-interactive/  basic-stashing/            git-attributes/  merge-mergesort/  reorder-the-history/            test.sh
amend/                        bisect/                    git-tag/         objects/          reset/                          utils/
bad-commit/                   commit-on-wrong-branch/    ignore/          Overview.md       reverted-merge/
basic-branching/              commit-on-wrong-branch-2/  img/          pre-push/         save-my-commit/
basic-cleaning/               configure-git/             investigation/   README.md         SHELL-BASICS.md
basic-commits/                detached-head/             LICENSE.txt      rebase-branch/    squashing/
basic-revert/                 docs/                      merge-conflict/  rebase-exec/      submodules/

正如您在前面的数据中看到的,在大多数情况下,这足以获得想要的结果。这将我们需要记住的命令减少到了git clone <repository>

故障排除:如果您正在键入克隆命令,并得到一个“权限被拒绝”的错误,这可能是因为您拼错了 URL。尝试复制粘贴命令,看看它是否有效。

现在你已经完成了我们的第一个练习,你已经确保你已经安装了 Git 并且正在运行,你也已经下载了 Git 表单,所以我们可以在本书的其余部分使用它们。

在仓库里找到我们的方向

现在,我们到了重要的部分:在 Git 存储库中工作。

在这一部分中,当我们与 Git 交互时,我们将使用几个 Git 命令来查看存储库内部,并使用几个 shell 命令来导航工作区。

我们将介绍命令:状态、日志和检验。

我们将深入讨论状态,但是日志和签出都是很大的命令,我们将在本书的过程中逐步介绍。

Git 状态

当我教 Git 的时候,我总是告诉我的学生:“如果你对将要发生的事情或者你应该做什么有疑问,只要运行git statusGit 就会告诉你。”虽然这有点夸张,但所有查询都应该以git status开头。

Git status 告诉您工作区的状态,以及它与当前签出的内容相比如何。如果工作区与检出的工作区相同,那么这个工作区就是干净的。如果工作区包含任何种类的变更,那么这个工作区就被称为脏的。可以修改、删除、添加或重命名文件。Git 还有一个被忽略路径的概念,从 Git 的角度来看,被忽略路径的变化不会使工作空间变脏。文件如何经历这些状态如图 1-5 所示。

img/495602_1_En_1_Fig5_HTML.jpg

图 1-5

文件可能处于的不同状态以及在这些状态之间转换的操作

Git 日志

当我们处于版本控制的领域中时,能够查看存在什么版本以及它们如何相互关联是一个重要的特性。Git 日志是我们查看存储库历史的最基本的方式。虽然 Git log 是一个基本命令,但它也是最容易配置的命令之一,而且标志和参数的数量可能会令人望而生畏。不要绝望,我会引导你自信地使用 log。

如果您查看下面的清单,您会看到一个没有任何命令和标志的 log 运行,配置了默认的 Git 2.25 安装。在这个存储库中,只有几个提交,在一个分支上,和一个标签。提交是按时间倒序进行的,这意味着最新的提交将首先打印,然后跟随每次提交的父指针,直到我们到达第一次提交。

在这个视图中,每个提交都包含大量信息。这里的所有数据都是在每次提交时打印的,这种行为对于大多数用途来说往往过于冗长。我们还可以看到它们指向的引用和提交。

$ git log
commit 335e019ac148297bd938f137ea9c7cf617c07576 (HEAD -> master, origin/master, origin/HEAD)
Author: Johan Sigfred Abildskov <randomsort@gmail.com>
Date:   Thu Feb 13 11:10:55 2020 +0100

    Clean up unused trainer-notes.md

commit c1514f22ebb31280d26b3062134a7066c59df737
Author: Alex Blanc <test@example.com>
Date:   Sat Oct 19 17:38:32 2019 +0200

    Add kata Rebase Interactive with autosquash

commit 032a8fcdef22a53f123f914a8b7b2d7d87cdd2e7
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Mon Feb 10 14:35:27 2020 +0100

    Fix typos in submodule README

如前所述,log 命令的冗长对于获得一个概述来说不是很有用,所以在接下来的几个清单中,我们将配置我们的 log 命令,使其更加简洁。首先,我们将对与前面相同的日志命令使用标志- oneline,以获得更精简的日志视图。整个命令变成了git log --oneline,得到的输出可以在下面的清单中看到(下一个清单!).oneline 标志将提交消息截断为主题,并将 sha 截断为较短的前缀。这使得它更容易得到一个概述。

$ git log --oneline
335e019 (HEAD -> master, origin/master, origin/HEAD) Clean up unused trainer-notes.md
c1514f2 Add kata Rebase Interactive with autosquash
032a8fc Fix typos in submodule README
262c478 Fix three typos
1e07423 Expand  on  submodules kata
1ef8902 Use explicit numbering
dbfccc8 Added pointer to Overview also as Learning Path
1848caf Reordered katas on Overview and added missing ones

如果我们遗漏了前面视图中的引用,这可能是由于 Git 的一个过时版本。在这种情况下,我们可以使用标志--decorate让 Git 用相关的指针注释提交。我们的命令就变成了git log --oneline --decorate。在新版 Git 中,修饰是默认的--oneline行为。使用标志--no-decoration可以模拟一个旧版本 Git 的例子。

$ git log --oneline
335e019  Clean up unused trainer-notes.md
c1514f2 Add kata Rebase Interactive with autosquash
032a8fc Fix typos in submodule README
262c478 Fix three typos
1e07423 Expand  on  submodules kata
1ef8902 Use explicit numbering
dbfccc8 Added pointer to Overview also as Learning Path
1848caf Reordered katas on Overview and added missing ones

只要我们只使用一个分支,因此有一个线性的历史,没有不同的开发线程,这对我们来说就足够了。当我们在第三章中研究更复杂的历史时,我们会在日志命令中添加更多的工具。然而,有一个标志对于限制我们在日志输出中获得的提交数量非常有用。我们可以使用标志-n <number>将日志输出中的条目数量限制为<数量>。对于小数字,我们可以使用文字数字作为标志。例如,git log -3 将只输出三次提交。在清单 6 中,我们用标志--oneline--decorate-n 2运行。

335e019 (HEAD -> master, origin/master, origin/HEAD) Clean up unused trainer-notes.md
c1514f2 Add kata Rebase Interactive with autosquash

Note

您可以使用 gitk 代替 git log 来获得更漂亮的基于 GUI 的输出。有些人更喜欢这样,很少有人知道不使用 Sourcetree 或 Git kraken 之类的成熟 GUI Git 客户端也能获得这样的概览。你可以不带参数的使用。

摘要

在本章中,我们介绍了我们正在处理的基本问题空间,即维护和关联文件和目录集合的多个版本。我们还确保安装了 Git,并使用命令git clone下载了 Git katas。然后,我们使用git log简要地查看了一个小型存储库的历史。

二、构建提交

在本章中,我们将详细讨论提交。提交是我们历史的基本组成部分,既包含我们版本的实际内容,也包含定义我们历史的父指针。有意地形成提交并附上格式良好的提交消息是在协作环境中成为有价值的个人贡献者所需的基本技能。

工作区里有什么

在其核心,Git 是关于文件和目录。在软件开发中,我们存储项目特定文件和代码的地方通常称为工作区。当我提到我们的工作空间时,我指的是我们项目的根文件夹,包含构成项目的文件和目录,以及。git 文件夹。在下面的代码片段中,我们看到了一个同时在 shell 和 Windows 资源管理器中列出的目录。请注意。包含 git 存储库的 Git 文件夹是隐藏的。隐藏以.开头的文件和文件夹是一种常见的惯例。

$ ls
img/  index.js  library.js  README.md

如前所述,与存储库上当前签出的提交相比,我们的工作空间可以是脏的,也可以是干净的。脏不是一个不好的词;它只是意味着不同。这种差异当然是一件好事,因为这些是我们已经做出的改变,只是还没有实施。另一个可能使我们的工作空间变脏的来源是自动生成的文件和构建工件。我们将在后面介绍如何让 Git 忽略某些路径。

我们可以把我们的工作区看作总是由一个提交和一个应用于其上的变更集或差异来表示。图 2-1 显示了对象在一个干净的工作区和存储库中是如何相同的。

img/495602_1_En_2_Fig2_HTML.jpg

图 2-2

在存储库顶部更改文件 A 后的脏工作区

img/495602_1_En_2_Fig1_HTML.jpg

图 2-1

存储库顶部的干净工作区

因为工作区和存储库的内容是相同的,所以工作区被认为是干净的。在图 2-2 中,我们可以看到当我们改变文件 a 时,脏工作区是如何与存储库相关联的。

我们可以使用 Git status 命令看到这一点。当我们更改存储库中的文件并运行 git status 命令时,我们可以看到 git 如何告诉我们已经被 Git 跟踪的文件被修改或删除,或者我们第一次添加到工作区的文件如何在 Git 状态中显示为未被跟踪。

$ ls
A  B  C  D
$ git status
On branch master
nothing to commit, working tree clean
$ echo testing > A
$ git status
On branch master
nothing to commit, working tree clean
$ git add A
$ git status
On branch master
nothing to commit, working tree clean
$ git commit -m 'Edit A'
On branch master
nothing to commit, working tree clean
$ git status
On branch master
nothing to commit, working tree clean
$ rm B
$ git status
On branch master
Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        deleted:    B

no changes added to commit (use "git add" and/or "git commit -a")
$ git commit -am 'Remove B'
[master db1f9c6] Remove B
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 B
$ git status

On branch master
nothing to commit, working tree clean
$ touch D
$ git status
On branch master
nothing to commit, working tree clean
$ git add D
$ git status
On branch master
nothing to commit, working tree clean
$ git commit -m 'Add D'
On branch master
nothing to commit, working tree clean
$ git status
On branch master
nothing to commit, working tree clean

使用阶段准备提交

在 Git 中,文件可以表示在三个不同的位置,其中两个我们已经讨论过了:工作区和存储库。在这两者之间还有第三个区域,这个区域被称为指数或阶段。这一阶段的逻辑是,我们在这里设计下一步要提交的内容。工作流程如下:我们进行一些更改,我们准备我们希望成为下一次提交的一部分的更改,最后我们提交。冲洗并重复。这个流程可以在图 2-3 中看到。

img/495602_1_En_2_Fig3_HTML.jpg

图 2-3

本地存储库中的流程

这个流程是普通软件开发流程的简化视图,但是希望它是可识别的。我听过的对舞台最直观的描述是,我们想象自己是家庭聚会上的摄影师,我们在指导人们谁应该去拍下一张照片,当我们完成后,我们就拍照。然后,我们重复。以类似的方式,我们使用命令add向舞台添加一个路径。此操作也称为转移文件。

Note

当我们存放一个文件或目录时,我们不仅仅存放一个路径,我们告诉 Git 在下一次提交中包含这个路径。我们在运行git add命令时暂存内容。这意味着,如果我们在转移文件后更改它,我们需要再次转移它,以便在下一次提交时包含它。

正如我们在图 2-3 中看到的,从 Git 的角度来看,一个文件可以有几种不同的状态。下面的列表只省略了我们将在本章后面讨论的最后一个状态:

  • 未修改的:这个文件在工作区和存储库中当前签出的提交中是相同的。

  • 修改过的:这个文件同时存在于工作区和存储库中,但是不同。

  • Staged :该文件位于工作区、当前提交和 stage 中。请注意,该文件在所有三个位置都可以不同。

  • 未跟踪:该文件在工作区中,但不在当前提交中。

MANIPULATING THE STAGE

在本练习中,我们将通过一些步骤来操作转移,以及在更改、转移和取消转移文件时会发生什么。

首先,让我们使用调查命令的基本工具包来看看我们的存储库中的内容。

首先,我们运行命令 pwd 来查看我们所在的路径,然后运行 ls 来查看目录包含的内容。

$ pwd
/c/Users/rando/repos/randomsort/practical-git/chapter2/stage-repo
$ ls
file1.txt  subfolder/
$ ls -al
total 9
drwxr-xr-x 1 rando 197609  0 mar 13 12:18 ./
drwxr-xr-x 1 rando 197609  0 mar 13 12:18 ../
drwxr-xr-x 1 rando 197609  0 mar 13 12:19 .git/
-rw-r--r-- 1 rando 197609 14 mar 13 12:18 file1.txt
drwxr-xr-x 1 rando 197609  0 mar 13 12:18 subfolder/

请注意。git 文件夹在我们使用标志-a 之前不会显示,这是因为默认情况下ls不会显示隐藏的文件夹,正如前面提到的,惯例是文件和文件夹以句点(.)被认为是隐藏的。除此之外,我们可以看到我们有几个文件和一个目录。

现在我们已经对文件系统有了基本的了解,我们使用几个基本的 git 命令来控制存储库。我们使用 git status 来询问 git 我们的工作空间相对于我们的存储库的状态。我们得到了更多的信息,我们将忽略这些信息,直到下一章。

$ git status
On branch master
nothing to commit, working tree clean
$ git log --oneline --decorate
1cc4f2e (HEAD -> master) Initial commit

我们从 git 状态中得到的首先是确认我们确实在 Git 存储库中工作。我们还了解到,我们的工作空间是干净的,没有任何东西是精心布置的。从 git 日志中,我们了解到我们有一个历史非常短的存储库。

在接下来的步骤中,我们将对工作区进行更改,并在此过程中存放文件。我们将继续使用 Git status 来查看我们的操作如何反映在文件的状态中。记住,我们从一个干净的工作空间开始。

$ echo "test" > file1.txt

$ git status
On branch master
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:   file1.txt

no changes added to commit (use "git add" and/or "git commit -a")

$ git add file1.txt

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   file1.txt

$ echo thing > file1.txt

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   file1.txt

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:   file1.txt

$ echo content > file_that_did_not_exist_before.txt

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   file1.txt

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:   file1.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        file_that_did_not_exist_before.txt

$ git add file
file_that_did_not_exist_before.txt  file1.txt

$ git add file
file_that_did_not_exist_before.txt  file1.txt

$ git add file_that_did_not_exist_before.txt

$ git status
On branch master

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   file1.txt
        new file:   file_that_did_not_exist_before.txt

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:   file1.txt

$ echo content > subfolder/subfile1.txt

$ echo content > subfolder/subfile2.txt

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   file1.txt
        new file:   file_that_did_not_exist_before.txt

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:   file1.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        subfolder/subfile1.txt
        subfolder/subfile2.txt

$ git add subfolder/
$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   file1.txt
        new file:   file_that_did_not_exist_before.txt
        new file:   subfolder/subfile1.txt
        new file:   subfolder/subfile2.txt

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:   file1.txt

$ git restore --staged file1.txt

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   file_that_did_not_exist_before.txt
        new file:   subfolder/subfile1.txt
        new file:   subfolder/subfile2.txt

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:   file1.txt

$ git commit -m "our first commit"
[master de09faa] our first commit
 3 files changed, 3 insertions(+)
 create mode 100644 file_that_did_not_exist_before.txt
 create mode 100644 subfolder/subfile1.txt
 create mode 100644 subfolder/subfile2.txt

$ git status
On branch master
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:   file1.txt

no changes added to commit (use "git add" and/or "git commit -a")

$ git log --oneline --decorate
de09faa (HEAD -> master) our first commit
1cc4f2e Initial commit

在前面的列表中,我们看到一些值得注意的项目。

  • 当我们在转移 file1.txt 后对其进行更改时,它将同时被修改和转移。

  • 我们可以在同一个阶段修改和添加文件。

  • 当我们暂存一个目录时,所有子路径都会被暂存。

  • 我们可以使用 git restore - staged 撤销路径的暂存。

犯罪

在上一节中,我们花了很多精力讨论如何控制提交的内容。在这一节中,我们将介绍如何进行实际的持久化,将阶段的内容持久化到我们称为提交的包中的存储库中。

去吧,Committee

为了创建提交,我们使用命令git commit。图 2-4 显示了提交流程的不同步骤。它假设我们已经在舞台上添加了一些变化。接下来发生的是用命令行标志-m传递的消息,变更集和一些自动生成的内容被保存在提交对象中。之后,当前签出的分支被更新以指向这个新创建的提交。

img/495602_1_En_2_Fig4_HTML.jpg

图 2-4

这是我们运行命令git commit时发生的情况,假设文件是暂存的

如前所述,我们主动控制提交的两个部分,而其余部分由 Git 自动处理。我们使用 stage 定义了提交的文件内容,同时我们控制在创建提交时将什么消息附加到提交。

指定提交消息的最常见方式是使用标志-m,并在命令中直接传递提交消息。这种方法的优点是提交消息很可能是简短的,并且我们在创建提交的过程中没有额外的步骤。这有几个缺点;稍后当我们讨论什么是好的提交消息时,我们将会谈到这些。命令行中最基本的提交流程如下所示:

$ git add file.txt
$ git commit -m “Add file.txt”
$ git log
commit 2a99799c0b9727dc22ae8a790d3978ac40273960 (HEAD -> master)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Fri Mar 13 13:17:41 2020 +0100

我们在前面的示例中看到的是,我们添加到阶段的变更集成为我们使用git commit命令创建的下一个提交的一部分。我们作为参数传递的消息显示在日志中。这很有用,因为我们可以给每个提交一个标题,给出变更集的原因。许多开发人员还使用消息来引用外部问题,这些问题在吉拉、GitLab 或 Azure DevOps Boards 等工具中维护。

如果我们在commit命令中省略了-m标志,Git 将打开一个文本编辑器,您可以在其中设计提交消息。这也为使用提交消息的主题和主体打开了大门。在大多数公司中,只使用 subject,因为附加信息是在版本控制之外保存的。在这些情况下,将任何问题引用放在正文中,不要放在主题中。这允许一个更干净的提交头,它简明地描述了任何 Git 提交引入的更改。

在开源工作流中,提交消息体更常用于添加更多关于变更集的文档。该信息不应与适当文件中的内容重复,如阅读材料、生成的文件、用户说明或类似文件。相反,它应该包含与具体变更集相关的补充信息。以下项目列表是此类文档中可能包含的内容的一些示例:

  • 引入这个变更集的原因是

  • 任何架构决策

  • 设计选择

  • 代码中不一定显而易见的权衡

  • 可以考虑的替代解决方案的描述

  • 变更集内容的更详细描述

在清单 2-1 中,我从 Git 核心源代码库中抓取了一条提交消息。这显示了使用提交消息的主题和正文的提交消息的示例。

mm, treewide: rename kzfree() to kfree_sensitive()

Listing 2-1An example commit message with the subject giving a high-level description of the feature and the body verbosely listing the content

正如莱纳斯所说:

对称命名只有在暗示使用中的对称性时才有帮助。

否则就是主动误导。

在“kzalloc()”中,z 是有意义的,是

来电者想要。

在“kzfree()”中,z 是有害的,因为可能在

将来我们真的可能想要使用“memfill(0xdeadbeef)”或

一些东西。界面的“零”部分甚至是不相关的。

kzfree()存在的主要原因是清除敏感信息

这不应该泄露给相同存储器的其他未来用户

物体。

在前面的代码中,我们看到了当我们在提交消息中都有一个 subject 和一个 body 时,它会是什么样子——在下面的代码中,我将展示当我们在命令行中没有指定提交消息的情况下执行创建提交的步骤时,它会是什么样子。

$ git commit
hint: Waiting for your editor to close the file...
[master 1a41582] Commit message from editor
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 file2
$ git log -n 1
commit 1a41582591bedad5757914acf3fc8be562e468a4 (HEAD -> master)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Fri Mar 13 13:19:57 2020 +0100

    Commit message from editor

    This part of the message is written as a part of the message body.
    Notice that there is a newline between the header and the subject.

但是前面的代码中缺少了一个步骤,很难在书中展示出来。图 2-5 显示了 Git 在我的系统上打开的编辑器的截图,其中有一个预填充的提交消息,我们可以根据自己的喜好进行更改。注意,如果我们保存并退出编辑器时只有空行和注释,提交过程将被中止。

img/495602_1_En_2_Fig5_HTML.jpg

图 2-5

命令行中未指定消息时提交消息编辑器的默认视图

开箱即用,Git 将默认您的 shell 正在使用的任何编辑器,在环境变量编辑器或 VISUAL 中定义。如果 Git 不能决定使用哪个编辑器,它将会退回到 vi,这让许多 Windows 用户感到沮丧。前面描述的行为可以用配置 core.editor 覆盖。我们将在后面介绍配置,但是请注意,您可以选择您最喜欢的编辑器。不一定所有的编辑器都支持这样使用,或者可以由 Git 运行,而不需要启动特定的配置。大多数常用的编辑器都是兼容的,或者可以很容易地配置为正常工作。这比谷歌搜索导致堆栈溢出差不了多少。

好的、坏的和难看的提交消息

正如我们前面提到的,提交是不可变的。这也意味着提交消息是永久的,因此在编写它们时投入一些思考以最大化消息对我们的合作者和未来的自己的价值是很重要的。有一种说法是,计算机科学中的一个难题是给事物命名。我想我们可以一起写提交消息。这很难,但很重要。

在这一节中,我们将介绍编写好的提交消息的一些基本规则,一些需要避免的事情,并给出好的和坏的提交消息的例子。

在深入细节之前,我想强调有用的提交消息的一个关键因素是存储库中合作者之间的一致性。在我看来,提交消息具有相同的语义和外观比它们被客观地优化编写要重要得多。能够浏览提交列表并且能够感受到存储库中正在发生的事情是非常强大的。有些人还将集成添加到他们的 ide 中,用最近更改代码行的提交的提交消息主题来修饰每个代码行。如果提交消息具有相同的基本形状,这种工作方式会更加强大。我们为此使用的 git 特性不太敏感地称为 Git 责备。它让我们能够指出是谁引起了特定的变化。虽然这很有用,但是责备这个词的内涵并不是很有建设性,也不利于健康的文化。我们将在稍后讨论 git 责备。

主题或标题

从图 2-6 中可以看出,无论从哪个角度看,提交消息主题的位置都很突出。在日志中,它是我们与提交相关联的内容;这是通过它们描述的事件来显示我们代码库的脉动。在存储库管理器中,通常显示的文件和目录用最后修改它们的提交来注释。有些人甚至在编辑器中逐行添加这些提交消息。例如,这可以在 Visual Studio 代码中使用 Git lens 来完成。

img/495602_1_En_2_Fig6_HTML.jpg

图 2-6

通过 GitHub 接口提交消息头

当大多数人讨论提交消息时,他们仅仅是指主题。有几类好的提交消息,如前所述,代码库的各个贡献者在策略上保持一致是很重要的。

我将介绍的第一个策略是简单地描述提交中发生了什么变化。在我看来,这不应该是描述一个实现细节。因此,好的提交消息可能是“启用单独的调试和信息记录”或“将签出功能移动到单独的类”,而坏的消息可能是“将 checkout()从 app.js 移动到 checkout.js”。我的观点是,我应该能够从提交头中获得比简单的 diff 更多的信息。

第二种风格是描述如果应用这个提交将会发生什么。这可以成功地用在开源项目中,或者用在开发人员为许多模块做贡献的现场,也许有些模块超出了他们的核心职责。使用这些语义还可以很好地表明提交和交付形状的意图。语言和交流塑造了我们的工作方式,因此这也是帮助构建一致的工作流程的一种强有力的方式。像这样的好的提交消息可能是“添加随机重试”或“更新 2020 模型的价格”。不好的例子可能是“删除不需要的文件”或“添加 JIRA-1234”。

如前所述,许多组织也使用提交头来引用一个或多个问题。这是一个我有很多意见的话题,但我会尽量保持最少的咆哮,只介绍几个项目来思考。我不反对在提交消息中引用问题,我认为这提高了可追溯性,并且在大多数情况下是一个好的实践。我反对使用问题引用来代替适当的、有用的提交消息。我更喜欢将问题引用放在消息体中,而不是占据提交头中的稀疏空间。提交头只有一行,所以最好不要超过 70 个字符。如果您有许多提交引用了同一个问题,或者需要从同一个提交中引用多个问题,您应该反思您是否以次优的方式处理了您的工作。这两种一对多的关系都是工作流的味道,表明您要么试图一次做太多的事情(在一次提交中有许多问题的情况下),要么在将变更集提交到公共存储库之前没有做基本的历史记录整理。当然,可能会有各种各样的特殊场景,但是在大多数情况下,在单个存储库中应该有一对一的提交与问题比率。

利用我们越来越多的高级终端和添加到 Unicode 中的表情符号,一种新的风格已经被引入。一些人在他们的提交信息中使用这些表情符号来表示改变的意图或类型。这有时被称为 Gitmojis。在图 2-7 中,你可以看到一些描述和用法的例子。

img/495602_1_En_2_Fig7_HTML.jpg

图 2-7

Gitmojis 用于简明地对变更集进行分类

使用 Gitmojis 可能看起来只不过是一个噱头,但如果持续使用并经过思考,它可能是一种非常有效的交流方式。然而,有效地使用它需要纪律。如果你正在使用 Gitmoji,就把它作为标题的第一个字符。请限制在提交消息中使用表情符号。如果没有别的,那么确保你使用的表情符号与代码库的工程文化一致。用于调味的表情符号应该添加在提交消息的末尾,这样它们就不会干扰所用 Gitmoji 的消息传递。最后,根据与存储库交互的工具的最新程度和定制程度,可能会有技术上的挑战。定制工具或过时软件可能与提交消息或路径中的表情符号不兼容。

以上是编写好的提交消息的一些不同的风格或策略。您所选择的内容会因项目和存储库的不同而不同。确保有高度的一致性,这样 git 日志看起来是干净的!我用一个错误提交消息的汇编列表来结束这一节,它应该作为如何不编写提交消息的警告。该列表可能基于也可能不基于实际的提交消息:

  • 修正打字错误

  • 虚拟提交

  • 修复 CI(在十个提交的字符串上)

  • 也许这能行

  • 我甚至不知道为什么我咒骂删除关心了

  • img/495602_1_En_2_Figa_HTML.gif

提交消息的主体应该包含什么内容是非常依赖于项目的,但是如果没有别的,我建议您将提交消息放在这里——即使这会妨碍您使用 short -m 直接在命令行中设置提交消息。获得一个好看的历史记录当然值得那些额外的按键。我不会详细介绍提交消息体,因为这里有太多不同的方法要介绍。

修正从哎呀时刻恢复

git commit 命令有一个名为amend的标志,允许我们编辑最近的提交。我说的编辑,是骗人的。我们将在后面更详细地介绍发生了什么,但是如前所述,提交是不可变的,所以我们并不真正编辑提交,而是创建一个新的几乎相同的提交,并指向它。旧的提交不会立即消失,但会在一段时间后被垃圾收集。

图 2-8 显示了当我们做amend时会发生什么。创建新的提交并更新分支指针。

img/495602_1_En_2_Fig8_HTML.jpg

图 2-8

修改提交会创建新的提交并更新指针。它留下了一个悬空提交,该提交将在某个时候被垃圾收集。新的悬空提交以淡化的轮廓示出,新的提交以实线示出

提交amend在很多情况下都是有用的。我们可能忘记了准备我们需要的一切,或者准备得太多了。我们可能已经写了一个可怕的提交消息,并立即后悔。在下面的部分中,我运行了一个提交示例,然后向提交中添加了一个文件。在本例中,我将使用-m 运行命令来指定消息。如果我们不使用-m,我们将打开我们的$EDITOR,并预先填充来自我们正在修改的提交的消息。

$ git log -n 2
commit 25cb0925de7cbc8c12803c6d51a7ddbc5f114509 (HEAD -> master)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Sat Mar 14 13:13:59 2020 +0100

    Add Feature X

commit 67e7eef1a44f222a50207fc20b24477bd9e0ddd8
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Sat Mar 14 13:12:34 2020 +0100

    Initial Commit
$ git add example.md
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   example.md

$ git commit –amend -m “ADD Feature X, with docs”
[master 8fb3eba] Add Feature X, with docs
 Date: Sat Mar 14 13:13:59 2020 +0100
 2 files changed, 2 insertions(+)
 create mode 100644 example.md
 create mode 100644 featurex.app

$ git log -n 2
commit 8fb3ebac82ac4940cf478746e04d73eb1882aa76 (HEAD -> master)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Sat Mar 14 13:13:59 2020 +0100

    Add Feature X, with docs

commit 67e7eef1a44f222a50207fc20b24477bd9e0ddd8
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Sat Mar 14 13:12:34 2020 +0100

    Initial Commit

$ git status
On branch master
nothing to commit, working tree clean

从前面的例子中我们可以看到,我们可以很容易地修复一个常见的错误。虽然修改历史和提交通常被认为是不好的做法,但这只适用于人们之间共享的项目。因此,只要我们致力于不共享的提交,我们就应该注意积极地编辑和塑造我们的历史,以便为后代提供尽可能多的价值。

使用获取干净提交。被增加

开发人员的一个常见错误是在他们的提交中添加了太多内容。这可能是编译的文件、日志或其他构建工件。如果我们正在构建 Python 代码,我们绝不会对持久化。pyc 编译 Python 文件作为我们版本化源代码的一部分。过多地进入我们的存储库可能会有一些不幸的结果。

首先,随着时间的推移,它会降低性能。进行初始克隆和通过签出将不同的提交放入工作区都可能成为长时间运行的任务。由于 Git 是不可变的和分布式的,这可能会有很高的代价,并且以后很难修复。

第二,它混淆了真正的变更,使得提交具有明确定义的边界和明显的变更集变得更加困难。如果一个提交包含了由于一堆变更的构建工件而导致的数万个文件的变更,那么很难辨别真正的逻辑变更隐藏在哪里。

第三,它强制实施坏习惯,只是将所有已经改变的添加到下一次提交中。作为专业的软件工作者,我们必须小心并确保我们交付的是我们想要交付的。以这种方式,简单地添加我们的存储库中存在的东西确实有助于我们形成深思熟虑的变更集。

幸运的是,Git 附带了一个解决方案,可以帮助我们避免意外弄乱我们的存储库。Git 附带了一个特性,允许我们将路径放入一个名为。gitignore,并且当我们暂存要提交的项目时,此处包含的文件将被忽略。这使得我们在发布内容时更加自由。使用 git add folder/比单独暂存文件要高效得多。

a。gitignore 文件包含要忽略的模式列表。如果您在一行前面加上感叹号,那么该模式将被包括在内,即使它以前被忽略了。在下图中,我们将展示根据。gitignore 文件。

清单 2-2 展示了一个例子。gitignore 适合 Python 项目的文件。

# Created by www.gitignore.io/api/python
# Edit at www.gitignore.io/?templates=python

### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

Listing 2-2A basic .gitignore file that can be used for Python projects to avoid cluttering the repository (generated from https://gitignore.io)

Note

比如说。gitignore 文件对于大多数常用的语言和框架,可以去 gitignore.io 下载一个合适的。

将. gitignore 文件添加到我们的存储库之后,我们可能还需要做一些清理工作。仅仅因为我们忽略了文件,并不能从工作区中删除已经提交的文件。更重要的是,它不会将它们从历史中删除,所以它们仍然会占用空间,即使它们不再使工作空间变得杂乱。如何处理这是一个完全不同的复杂问题,我们将在后面讨论。

在下面的命令行片段中,您可以看到添加. gitignore 文件如何更改 add 命令的行为:

$ ls
app.exe*  example.md  featurex.app  file
$ git add .
$ git status
 $ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   app.exe

$ git restore app.exe

$ git status
On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)

        app.exe

nothing added to commit but untracked files present (use "git add" to track)
$ echo “*.exe” > .gitignore
$ git add .
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   .gitignore

从前面的例子可以看出,创建一个. gitignore 文件有助于防止我们的存储库变得不必要的混乱。我们还可以看到。gitignore file 只是一个文件,对它的更改将像任何其他文件更改一样被跟踪。

高级版。吉蒂尔

虽然前面的例子很好,并且提供了很多价值,但通常还不够。我们倾向于对我们的存储库中允许的内容有更详细的计划。

制定诸如“除了文件夹图像之外,我们不允许 png 出现在我们的存储库中”这样的方案并不少见。我们可以用 git ignore 文件做这些事情。

git ignore 文件包含一个从上到下应用的模式列表。我们可以给行加上前缀!,使它们成为包含而不是排除。

因此,为了获得前面的场景,我们可以使用下面的.gitignore文件。请注意,git ignore 文件中以#开头的行将被忽略。

# Example .gitignore
# Exclude all pngs
*.png
# Include pngs in img/
!img/*.png

BUILDING A .GITIGNORE FILE

以下一组命令在命令行中构建了一个 git ignore 文件,该文件不允许将 png 文件添加到存储库中,除非它们位于 images 文件夹中。

首先,我们注意到存储库中有两个 png 文件,一个在根目录中,一个在 images 文件夹中。当我们添加根时。),两个 png 文件都是临时的。

$ ls
file.png  img/  README.md
$ git add .

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   file.png
        new file:   img/file.png
$ git restore .
$ git status
On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)

        file.png
        img/file.png

nothing added to commit but untracked files present (use "git add" to track)

在将我们的 stage 恢复到存储库中的状态之后,我们完全忽略存储库中的 png 文件。当我们转移根目录时,不会转移任何 png 文件。

$ echo *.png > .gitignore
$ git add .
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   .gitignore

$ git restore .
$ git status
On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)

        .gitignore

nothing added to commit but untracked files present (use "git add" to track)

既然我们已经再次恢复到基本状态,我们可以使用!作为模式的前缀。在这种情况下,我们允许图像文件夹中的 png。

$ echo !img/*.png >> .gitignore
$ git add .
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   .gitignore
        new file:   img/file.png

这也可以通过单独的。gitignore file 文件夹中只包含这一行的文件!*.png。这将具有这样的效果,即无论存储库相信什么,在这个文件夹和下面的 png 文件都是允许的。因此,我们可以在目录结构中放置任意数量的 git ignore 文件。与 Git 的许多其他方面一样,这增加了复杂性,我们应该在根之外添加 ignore 文件。

忽略全局

在 shell 语言中,有一个 globbing 的概念,这是一种模糊通配符扩展。一个*代表任何一个名字。但是我们也可以使用序列**来表示任意嵌套。这使得我们可以说“我们不希望 png 文件在我们的存储库中,除非它们在一个名为 images 的文件夹中,不管那个文件夹在哪里”。在您希望允许存储库中有 png 文件,但又担心人们会不小心添加他们随意放置的 png 文件的情况下,这是非常有用的。如果 png 被放在一个名为 images 的文件夹中,那么它就是允许的,这迫使开发人员在将 png 添加到存储库时要更加慎重,同时不要过度限制。

以下示例从上一个练习开始,然后扩展到其他几个位置。

GLOB PATTERNS IN GIT IGNORE

本练习假设我们有与上一个练习相同的 git ignore 文件,也就是说,git ignore 文件拒绝 png,除了存储库根目录中的文件夹 img/之外。这意味着我们有一些不同的图像文件夹,它们的内容是不允许的。

我们从上一个练习中的设置开始,然后向 gitignore 文件添加一个通配符模式。

$ echo ‘img/*.png’ >> .gitignore
$ git add .
$ git status
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   .gitignore
        new file:   img/file.png
        new file:   subfoldimg/file.png

$ git restore .

我们现在添加了一个通配符模式,允许任何子文件夹在名为 images 的子文件夹中包含 png。这允许我们更广泛地允许异常,而不用在一个文件夹一个文件夹的层次上做所有的事情。

$ echo '!img/*.png' >> .gitignore
$ git add .
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   .gitignore
        new file:   img/file.png
        new file:   subfoldimg/file.png
        new file:   subfolder/subfoldimg/file.png

我们现在添加了一个更广泛的模式,只要 png 在一个名为 img/的文件夹中,这种情况发生在目录树中的多远都没有关系。该子句比本练习中的第一个子句更宽泛,因此我们可以返回并从 Git ignore 文件中删除行img/*.png,因为它被刚刚添加的行所覆盖。

前面的例子非常具体地展示了我们如何创建精细的方案,这些方案将给予我们对进入存储库的内容的细粒度控制。它还表明,即使是像前面这样简单的例子,它也可能很快变得复杂。

我强烈建议不要创建太复杂的 gitignore 文件,它们会很快变得看起来很神奇,而且当开发人员使用他们的版本控制系统时,他们不会很清楚发生了什么。如果可能的话,这当然是要避免的。

去吧卡塔

为了更好地支持本章的学习目标,我建议您阅读以下 Git 表格:

  • 基本-提交

  • 基本阶段

  • 改进

  • 忽视

摘要

在这一章中,我们已经介绍了这个阶段,以及如何利用它来创建漂亮的原子提交。我们还讨论了提交消息以及如何写好它们。更重要的是,我们讨论了一些不同的策略来决定您希望您的提交消息遵循什么模式。我们谈到了使用 amend 重做提交的最简单的方法。最后,我们讨论了如何使用 Git ignore 文件来避免存储库中的意外文件。有了这些知识,我们就可以大胆尝试,创造完美提交的悠久历史。

三、线性历史

Git 以其轻量级分支而闻名。它们在创建和合并方面都非常高效。作为开发人员,它们使用起来也相对简单。任何经历过看似无法解决的合并冲突的人都会质疑这种说法。

简而言之,分支是我们如何管理我们的源代码生命周期,我们如何管理我们的代码库的不同版本,以及我们如何隔离我们的变更集以促进原子变更和协作。

分支看起来有点复杂,但是我会尽我所能给你正确的词汇和心智模型,让你能够轻松地使用分支。

在这一章中,我们将涵盖线性历史,也就是说,具有完整的单字符串提交链的存储库。首先,我们将介绍 Git 分支模型的基本构件。然后,我们将展示它们如何以可视化的方式交互,并通过 Git 存储库中的任务来总结这一点。

分支基础

当我们想到树枝时,会想到两件事。一是发散。它甚至在自然语言中表现出来。“分支的道路”指的是分成多个方向的道路。其次,我们对分支的直觉是有一定长度的——而不仅仅是一个点。就版本控制而言,当我们有独立的提交链时,我们会期望我们有分支。这种直觉是人们学习 Git 时许多困惑的根源。在图 3-1 中,我们可以看到这些概念是如何映射到实际树的绘图中的。

img/495602_1_En_3_Fig1_HTML.jpg

图 3-1

我们的直觉表现出来了;树枝有长度,代表分叉点

在 Git 中,分支只不过是对单个提交的引用。这是本书中最重要的一句话,所以我要重复一遍。在 Git 中,分支只不过是对单个提交的引用。这意味着,当我们提到源代码的一个分支时,我们通常指的是该分支上的最新提交以及在它之前的提交。但是从技术上来说,分支只是指向最新的提交,而“分支上”的其余提交是通过跟随来自该提交的父指针而得到的。这也意味着分支的存在不一定需要任何分歧。我们可以有两个指向相同提交的分支,因此两个分支是相同的,没有任何差异。

该布局如图 3-2 所示。我们有一串提交和指向这串提交的三个分支。分支 A 和 B 指向相同的提交,而分支 C 指向不同的提交。请注意,虽然 C 不同于 A 和 B,但并不存在分歧。c 仅仅是 A 和 b 的前缀,这在我们讨论合并时会变得很重要。

img/495602_1_En_3_Fig2_HTML.jpg

图 3-2

线性历史中的三个分支。A 和 B 指向同一个提交,而 C 指向 A 和 B 的前任

保持头脑清醒

如前所述,我们可以在同一个存储库中拥有多个分支,甚至指向同一个提交。这使得我们目前在哪个分支上工作变得不明显。在 Git 中,当前活动的分支被称为签出。Git 使用一个名为 HEAD 的文件来跟踪当前签出的内容。

在图 3-3 中,你可以看到当我们创建提交时,头指针是如何引用移动的分支指针的。

img/495602_1_En_3_Fig3_HTML.jpg

图 3-3

树枝是如何移动的

头指着的东西定义了两件事。首先,HEAD 指向一个分支,也就是说,当我们进行更多提交时移动的分支。第二,当我们使用 git status 这样的命令时,我们的工作区和 stage 要与提交进行比较。

提交到您的分支机构

从上一章,我们知道了如何创建提交。我们现在已经讨论了分支指针和头指针。这意味着我们现在准备在主分支上创建一些提交。需要注意的重要一点是,当我们提交时,HEAD 指针不会改变。它一直指向主分支,主分支指向的内容会发生变化。

以下练习将使用 git 命令完成图 3-3 中的场景。

COMMITTING

在下面的练习中,我们将首先看到我们的历史与图 3-3 的历史相匹配。然后我们将提交并看到我们的历史现在与图 3-3 的下半部分相匹配。这个练习可以在本书的源代码中找到。

$ git log --oneline
f157eed (HEAD -> A, B) 4
3652176 3
5cbbdc1 (C) 2
6856025 1

$ echo 5 > README.md

$ git commit -am "5"
[A 933a6c5] 5
 1 file changed, 1 insertion(+), 1 deletion(-)

$ git log --oneline
933a6c5 (HEAD -> A) 5
f157eed (B) 4
3652176 3
5cbbdc1 (C) 2
6856025 1

正如在下面的练习中可以看到的,我们在创建提交时移动了一个分支,但是头部保持不变。

签出以前的版本

到目前为止,我们只关心创建提交。我们没有积极地探索历史。在这一节中,我将向您展示如何在您的工作区中切换版本。Git 的一个关键特性是速度快。这也意味着使您的工作空间类似于您的代码库的不同版本是一项微不足道的任务。由于 Git 是分布式的,我们在本地表示了整个存储库,这也允许这些操作发生,即使我们离线。

我们使用命令“git checkout <target>”将特定的修订放入我们的工作区。因为目标检验可以获取任何最终导致提交的内容。最常见的是,我们使用分支、标记或提交 sha。Git 结帐是一个两步的过程。首先,它将头指针移动到特定的修订版。然后,它获取该修订中的内容,并将其移动到工作区中,使工作区看起来像该修订。如果 Git 不能以安全的方式做到这一点,它将中止签出。这意味着如果您的工作被签出覆盖,Git 将不会完成操作。Git 也不会清理任何未被跟踪的文件。如果我们使用一个标签或者一个提交 sha 作为 checkout 命令的目标,我们将会以一个分离的 HEAD 状态结束。这听起来比实际更危险。这仅仅意味着我们目前没有跟踪任何分支,并且可能会丢失我们在这一点上所做的工作,因为我们没有提交任何分支。然而,没有理由为此担心。稍后,我们将一起解决这个问题。

在下面的练习中,我们将看到如何使用 git checkout 命令在不同版本的存储库之间切换。我们将检查单独的提交,查看工作空间是如何变化的,并返回到最近的版本。

CHECKING OUT DIFFERENT VERSIONS

这个练习从上一个练习的结束状态开始。

$ git log --oneline --decorate
7f1c255 (HEAD -> A) 5
f157eed (B) 4
3652176 3
5cbbdc1 (C) 2
6856025 1

$ cat README.md
5

$ git checkout 4
error: pathspec '4' did not match any file(s) known to git.

$ git checkout B
Switched to branch 'B'

$ cat README.md
4

$ git log --oneline --decorate
f157eed (HEAD -> B) 4
3652176 3
5cbbdc1 (C) 2
6856025 1

$ git checkout C
Switched to branch 'C'

$ cat README.md
2

$ git checkout A
Switched to branch 'A'

$ cat README.md
5

$ echo "Important information" > README.md

$ git checkout B
error: Your local changes to the following files would be overwritten by checkout:
        README.md
Please commit your changes or stash them before you switch branches.
Aborting

请注意,当我们试图检出一些会以不安全的方式覆盖我们工作空间中的更改的内容时,Git 会阻止该操作,并建议可能的操作。

当你完成前面的练习时,注意每个操作有多快。它几乎不需要任何时间。虽然这是一个小而琐碎的存储库,但是在非常大的存储库上也可以看到类似的性能。简单地说,Git 不需要与服务器通信这一事实是一个很大的优势,即使假设网络连接和服务器负载处于最佳状态。

看到不同版本之间的差异

在上一节中,我们看到了如何将代码库的任何版本实例化到我们的工作空间中。有了这些知识,找出我们的存储库的两个版本之间的区别的一个常见方法是在磁盘上有相同存储库的两个副本,在不同的文件夹中检查不同的版本,并比较它们。这既可以通过手动调查感兴趣的领域来完成,也可以使用工具来显示差异。这不是地道的饭桶。它还容易出错,并可能导致繁琐的返工,因为您需要反复检查哪个版本在哪个文件夹中,并试图找出哪个文件要复制到哪里。

Git 用 diff 命令解决了这个问题。diff 命令显示了两次提交之间的区别。该命令接受两次提交,或者对提交的引用作为参数。如果只给出一个参数,则假定 HEAD 是第一个参数。该命令如下所示:git diff ,git diff master release-1.0 就是一个例子。这将显示提交主机引用的提交和 1.0 版引用的提交之间的内容差异。

Note

git diff 的参数顺序很重要。如果改变参数的顺序,一个方向的文件创建就变成了文件删除。添加的 20 行变成删除的 20 行。当您试图找出变更集中的内容时,这可能会导致混乱。

我对 diff 的直觉是,我将 Git 指向两个不同的提交,它会告诉我从一个提交到另一个提交需要做什么。这当然也可以看作是两者之间发生的事情。在清单 3-1 中,显示了这一点,以及参数顺序对 diff 的影响。

$ git diff C A
diff --git a/README.md b/README.md
index 0cfbf08..7edff8 100644
--- a/README.md
+++ b/README.md
@@ -1 +1 @@
-2
+5

$ git diff C A
diff --git b/README.md a/README.md
index 7edff8..0cfbf08 100644
--- a/README.md
+++ b/README.md
@@ -1 +1 @@
-5

+2

Listing 3-1A diff and the impact of the order of the arguments. Here, we need to delete a 2 and add a 5 or, in the other direction, delete a 5 and add a 2

diff 命令并不关注提交之间的历史记录,不管它是否有分歧。Git 只是告诉您作为参数传递的提交所代表的两个工作区之间的区别。

有时,补丁输出可能有点难以解析——尤其是长行中的小变化。这有时可以用标志--word-diff来帮助,它将在一行中内联更改,而不是作为两个单独的行。这可以在清单 3-2 中看到。

Normal Diff
 <Navbar bg="success" variant="dark">
-    <Navbar.Brand href={window.location.host}>Cultooling</Navbar.Brand>
+    <Navbar.Brand href={homeUrl()}>Cultooling</Navbar.Brand>
     <Nav className="mr-auto">
-      <Nav.Link href={window.location.host}>Home</Nav.Link>
+      <Nav.Link href={homeUrl()}>Home</Nav.Link>
     </Nav>
   </Navbar>
With --word-diff
<Navbar bg="success" variant="dark">
    <Navbar.Brand
[-href={window.location.host}>Cultooling</Navbar.Brand>-]{+href={homeUrl()}>Cultooling</Navbar.Brand>+}
    <Nav className="mr-auto">
      <Nav.Link
[-href={window.location.host}>Home</Nav.Link>-]{+href={homeUrl()}>Home</Nav.Link>+}
    </Nav>
  </Navbar>

Listing 3-2Showing how it is much easier to see what the changes are using the --word-diff flag. This can vary from use case to use case

我们还可以使用不带参数或带标志--staged的 diff 命令来查看我们的工作区、阶段和存储库之间的差异。这两个命令是强大的工具,可以帮助您更加谨慎地进行提交。

去吧卡塔

为了支持本章的学习目标,我建议你去解决以下 Git 卡塔:

  • 分离的头部。

  • 再次执行基本提交并注意正在进行的分支特定的事情可能是有用的。

摘要

在这一章中,我们首先介绍了如何使用一个简单的分支,包括头指针来跟踪我们当前已经签出的内容。我们还创建了一些提交,并看到我们的分支指针随着我们的操作而移动。随后,我们用 checkout 命令浏览了我们的历史,并用一些 diff 魔术完成,向我们展示了在历史的两个点之间到底发生了什么。学完这一章后,你应该对使用线性分支历史感到舒服了。

四、复杂分支

在上一章中,我们看了线性历史。这对于琐碎的存储库来说可能是好的,但是如果我们有信心使用分支工作,它将几乎不会引入开销,所以我们可以运用分支的力量,甚至对于我们最简单的项目。

在 Git 中与分支积极合作有很多好处。我们将在下一章讨论与多个开发人员的合作,但是即使对于一个单独的开发人员,也有来自分支的成功。它们主要源于这样一个事实,即我们可以使用分支来隔离我们的工作。当我们隔离我们的工作时,我们可以减轻一些多任务的成本。通过隔离我们在一个分支上的工作,我们总是可以创建一个新的分支,如果一个紧急的任务需要被开发的话。我们可以安全地在一个分支上运行实验,并且只有当它以一种有利的方式出现时才整合我们的实验。如前所述,分支是围绕 Git 如何工作产生混乱的一个重要原因。这是非常不幸的,因为它们是获得 Git 全部价值和理解许多概念的关键,包括使用远程存储库和除了最简单的协作方案之外的所有概念。

在这一章中,我们将着重于围绕多个分支获得一个健康的心智模型,并获得足够的实践经验,使你能够使用和推理分支。

创建分支

我们已经介绍过,分支是提交的指针。具体地说,这意味着分支是存储库中的一个文件,包含分支所指向的 sha。这可以在清单 4-1 中看到。

$ cat .git/refs/heads/master
5355b7b7f01b6d69c1ae94b428f54952139eb2f8
$ git log --oneline --decorate -n 1
5355b7b (HEAD -> master, origin/master, origin/HEAD) [Chapter 7] Add aliases exercise

Listing 4-1A branch is a file containing the sha of the commit it points to

我们可以使用命令 git branch 来操作和列出分支。谈到远程分支时有一些微妙之处,但是我们将在下一章中讨论这些。当我们使用不带参数的命令时,我们列出(本地)分支。

我们还使用 branch 命令创建分支。我们用两个参数调用:git branch <branch-name> <commit>。举个例子:git branch my-branch master。这将在存储库中创建一个分支。它将被称为my-branch,并指向与主服务器相同的提交。这可以在图 4-1 中看到。

img/495602_1_En_4_Fig1_HTML.jpg

图 4-1

从引用创建分支

现在我们已经创建了一个分支,我们可以在不同的分支上做一些工作。根据 HEAD 当前指向的内容,将在适当的位置创建新的提交,并更新当前签出的分支以指向新的提交。这可以从图 4-2 中看出。

img/495602_1_En_4_Fig2_HTML.jpg

图 4-2

在分支上创建提交将添加提交并更新分支指针

既然我们已经看到了在分支上创建提交时的样子,我们就为分支的下一步做好了准备

使用多个分支

在 Git 中,没有任何分支的工作真的没有任何意义,我们默认总是在一个分支上工作:主分支。但真正的力量来自于对多个分支的玩弄。使用多个分支时有两个主要任务。一是让我们的工作在不同的分支上分开。我们之前已经讨论过了。另一部分是将多个分支上的变更放入同一个分支。这通常被称为合并。有多种方法可以做到这一点。在这一章中,我们将讨论合并和重置基础。

从概念上讲,当我们想要合并两个分支时,我们创建一个新的 commit,它包含来自两个分支的联合变更集。这是通过找到分支分叉的点并连接两个变更集来实现的。这可以从图 4-3 中看出。

img/495602_1_En_4_Fig3_HTML.jpg

图 4-3

一个普通的合并,合并分别指向 C 和 E 的分支

在变更集兼容的情况下,Git 将为我们处理一切。如果变更集不兼容,或者 Git 无法合并它们,我们将会陷入合并冲突。我们将在本章后面讨论这些。在我工作过的大多数代码库中,合并冲突并不常见。

合并

合并是我们的语言妨碍我们理解 Git 的另一个地方。我们都在谈论分支的抽象合并,不管我们打算如何做,我们都在谈论命令“git merge”。

使用 merge 命令的常见方式是“git merge branch”形式,它将把分支中的变更集合并到当前签出的分支中,例如,git merge feature-123。还有其他选择,但我喜欢这种工作方式,因为我们只改变我们所在的分支,这很好,因为它导致相对较少的问题。这种融合就是图 4-3 被创造出来的原因。

快进合并

快进合并是 Git 中最简单的合并形式。不幸的是,人们对它们的工作方式也有一些误解。这一节将有希望让您处于喜欢快速合并的状态。

当您要合并的分支之间没有分歧时,就会发生快进合并。当一个分支是另一个分支的延续时,就会出现这种情况。在图 4-4 中,我们可以看到特征分支线性领先于主分支的场景。为了合并特性中的变化,我们需要做的就是将主分支指针移动到提交特性点。因为主分支中包含的所有变更已经是特征分支的一部分。

img/495602_1_En_4_Fig4_HTML.jpg

图 4-4

执行快进合并不会导致任何新的提交,而是一个简单的操作

这也意味着任何冲突都不可能进行快速合并。出于这个原因,快进合并可以被认为是安全的。

Note

一些工作流使用 Git 特性,其中创建新的提交来标记分支的合并。这将创建一个没有变更集的合并提交,以标记此时分支已被合并。这是通过命令 git merge - no-ff 完成的。

FAST FORWARD

在本练习中,我们将只从master分支开始。它有两个提交。我们将创建一个名为feature的分支,创建一个 commit,并将其合并到 master 中。这个练习可以在练习文件夹中找到,名为chapter4/fast-forward/

$ git log --oneline --decorate
fa8d7db (HEAD -> master) second commit
35b6a68 Initial Commit

$ git checkout -b feature
Switched to a new branch 'feature'

$ git add 1.txt

$ git commit -m "Adding file1"
[feature 4b346fe] Adding file1
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 1.txt

$ git log --oneline --decorate
4b346fe (HEAD -> feature) Adding file1
fa8d7db (master) second commit
35b6a68 Initial Commit

此时,功能分支包含不在主服务器上的提交,但是主服务器不包含也不能从功能分支到达的任何内容。

$ git checkout master
Switched to branch 'master'

$ git merge feature
Updating fa8d7db..4b346fe
Fast-forward
 1.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 1.txt

Git 告诉我们它正在进行快进,并从哪个提交开始移动指针。

$ git log --oneline
4b346fe (HEAD -> master, feature) Adding file1
fa8d7db second commit
35b6a68 Initial Commit

如果我们将其与快进合并之前的日志语句中的一个进行比较,我们可以看到提交 ID 是相同的。这意味着没有创建新的提交,并且更改纯粹是分支更新。

从前面的练习中可以看出,默认情况下,快进合并不会导致新的提交。这意味着这种类型的合并是一个非常快速的操作,因为它只是一个简单的两步过程:将更新的 sha 写入分支文件,然后签出该修订版的工作区。

三向合并

在上一节中,我们讨论了琐碎的或快速前进的合并,其中没有分歧,也没有冲突的可能性。在本节中,我们将讨论简单合并或三向合并。当我们正在合并的两个分支都包含只在一个分支上的工作时,就会出现这种情况。这种差异是完全自然的,在大多数情况下,当多个开发人员在一个源代码库上合作时,都会出现这种差异。通常,当我们在我们的特性分支上开发时,一些其他开发人员已经向主分支交付了一些变更。因此,我们从主分支分支出来的点不再是主分支上的最新提交。由于提交表示工作区的特定状态,我们需要创建一个新的提交,它包含在获取两个变更集之后工作区的状态。在图 4-5 中,您可以看到这在合并前后在 Git 图上的样子。在下一个练习中,我们将介绍它在磁盘上的外观。

img/495602_1_En_4_Fig5_HTML.jpg

图 4-5

合并两个分支会创建新的提交并更新分支指针

三向合并之所以这样命名,是因为在合并中涉及三个点——两个结束状态以及两个分支离开的点。我们分别将它们命名为源、目标和合并库。这可以在图 4-6 中看到。

img/495602_1_En_4_Fig6_HTML.jpg

图 4-6

三向合并的不同组件:源、目标和合并基础

Git 使用合并基础来确定不同的变更集,并计算它们是否重叠,从而不能被 Git 自动合并。结果将是提交,并且接收分支将被更新。当我们在一个方向上完成了三向合并,如果我们在另一个方向上进行合并,它将始终是一个快进合并。

THREE-WAY MERGE

在本练习中,我们有两个内容不同的分支需要合并。我们将首先把来自master的内容合并到feature中。然后,我们将主更新到特征分支。这是一个常见的工作流程,因为您可以在交付给主模块之前,首先测试您的特征分支中的最终状态。这个练习的资源库可以在chapter4/three-way-merge/的练习中找到。

$ git log --all --graph --oneline
* d03b0bd (HEAD -> feature) Add feature.txt
| * 390d440 (master) Add master.txt
|/
* ea2b9f5 second commit
* f90da57 Initial Commit

我们看到有两个分叉。

$ git merge master
Merge made by the 'recursive' strategy.
 master.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 master.txt

当我们将来自master的变更合并到feature分支时,使用三向合并来解决合并问题。它使用了递归策略,这是一个我们可以放心忽略的实现细节。

$ git log --all --graph --oneline
*   ddeeef9 (HEAD -> feature) Merge branch 'master' into feature
|\
| * 390d440 (master) Add master.txt
* | d03b0bd Add feature.txt
|/
* ea2b9f5 second commit
* f90da57 Initial Commit

三向合并导致了新的提交ddeeef9。请注意,主分支仍然指向与以前相同的提交。

$ git checkout master
Switched to branch 'master'

$ git merge feature
Updating 390d440..ddeeef9
Fast-forward
 feature.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 feature.txt

现在我们在另一个方向合并分支,我们得到一个快速前进的合并。这是真的,因为从master可到达的所有内容也是从feature可到达的,因此 Git 认为这个合并已经解决了。许多工作流只允许在master上快进合并,这就是如何实现的。

$ git log --all --graph --oneline
*   ddeeef9 (HEAD -> master, feature) Merge branch 'master' into feature
|\
| * 390d440 Add master.txt
* | d03b0bd Add feature.txt
|/
* ea2b9f5 second commit
* f90da57 Initial Commit

在前面的代码中,我们遍历了三路合并,并注意到在另一个方向重复三路合并会导致快进合并。

前面的练习经历了快乐之路的场景。当我们的合并很简单时,Git 可以很容易地自动解决它们,我们感觉很强大。不幸的是,Git 并不总是能够为我们解决合并问题。我们将在下一节讨论这一点。

合并冲突

Git 可能无法确定合并分支的结果。在这种情况下,Git 将要求用户解决合并问题,并继续这个过程。这种情况称为合并冲突。Git 将进入提示符状态,并将文件标记为处于冲突状态。清单 4-2 通过一个状态命令展示了这一点。

$ 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:   mergesort.py
no changes added to commit (use "git add" and/or "git commit -a")

Listing 4-2Git status shows that we are in a state of an unresolved merge conflict and instructs as to what our next steps are

我可以解释如何解决合并冲突的最简单的方法是,您需要使工作区看起来像您想要的合并,然后告诉 Git 您已经完成了。Git 在冲突的文件中输出所谓的标记。这可以在清单 4-3 中看到。

$ cat mergesort.py
from heapq import merge

def merge_sort2(m):
    """Sort list, using two part merge sort"""
    if len(m) <= 1:
        return m

    # Determine the pivot point

    middle = len(m) // 2

    # Split the list at the pivot
<<<<<<< HEAD
    left = m[:middle]
    right = m[middle:]
=======
    right = m[middle:]
    left = m[:middle]
>>>>>>> Mergesort-Impl
<Rest of file truncated>

Listing 4-3Merge markers in a file show origin of different changes

如果您遇到复杂的合并冲突,通常使用外部合并工具(如 meld 或 kdiff)会有所帮助。在正常情况下,必须合并冲突很容易解决,可以简单地在您的普通编辑器中处理。编辑器,比如 Visual Studio 代码,理解 Git 放在文件中的标记,这使得解决合并冲突变得更加容易。

同一文件中可以有多个合并冲突。Git 查看更小的块,找出文件版本之间的相似之处。这使得处理合并冲突变得更加容易,因为您不必一次决定整个文件,而是可以分解成更小的片段进行比较。

MERGE CONFLICT

在本练习中,我们将经历与上一个练习相同的情况,只是分叉的分支会有不兼容的变化。这将导致合并冲突,我们将解决这一冲突。这个练习可以在chapter4/merge-conflict/下的例子中找到。

$ ls
0.txt  master.txt

$ cat master.txt
feature

$ git log --oneline --decorate --graph --all
* 6ce4209 (HEAD -> feature) Add feature.txt
| * c301b9a (master) Add master.txt
|/
* f237b8b second commit
* 7e48076 Initial Commit

$ git checkout master
Switched to branch 'master'

$ cat master.txt
master

现在,我们在仓库里找到了方向。两个分支已经分开。每个都添加了内容不同的文件master.txt

$ git merge feature

Auto-merging master.txt
CONFLICT (add/add): Merge conflict in master.txt
Automatic merge failed; fix conflicts and then commit the result.

在我们启动合并后,Git 检测到合并冲突并暂停合并,提示我们解决合并。

$ 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 added:      master.txt

no changes added to commit (use "git add" and/or "git commit -a")

使用git status向我们展示哪里有问题,让我们知道 Git 无法合并文件master.txt

$ cat master.txt
<<<<<<< HEAD
master
=======
feature
>>>>>>> feature

Git 在 master.txt 中放置了显示不同变更集的合并标记,这表明当前状态是包含 master 的文件,引入的变更是包含 feature 的文件。

$ echo master > master.txt

$ git add master.txt
warning: LF will be replaced by CRLF in master.txt.
The file will have its original line endings in your working directory.

大多数情况下,我们希望在编辑器或合并工具中完成合并,但在这种情况下,我只需选择我想要的状态。请注意,此状态可以是解决方案之一,也可以是它们的某种组合。这就是 Git 需要人工干预的原因——它不知道我们的源的语义。我们使用add将文件标记为处于已解析状态。

$ git status
On branch master

All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

$ git commit
[master 3be77eb] Merge branch 'feature'

$ git log --oneline --decorate --graph --all
*   3be77eb (HEAD -> master) Merge branch 'feature'
|\
| * 6ce4209 (feature) Add feature.txt
* | c301b9a Add master.txt
|/
* f237b8b second commit
* 7e48076 Initial Commit

解决了合并冲突后,我们看到我们处于与快乐之路三向合并类似的情况。我们只需要在前进的道路上帮助 Git 一点点。

从这个练习中可以看出,解决合并冲突并不是一项令人畏惧的任务。然而,在复杂的场景中,当使用我们不熟悉的代码库时,这可能会很困难。

重定…的基准

三向合并的替代方法是重定基数。与三向合并不同,三向合并通过合并两个分支来创建表示工作区的新提交,而 rebase 直观地移动了提交。这在技术上是错误的,但是我们暂时保留直觉。当我们将我们的分支放在另一个分支之上时,直觉上我们将提交移动到我们的分支上,并将它们应用到目标分支之上。这可以在图 4-7 中看到。

img/495602_1_En_4_Fig7_HTML.jpg

图 4-7

重定基础与合并。从 A 开始,B 是将母版合并到特征的结果,而 C 是将特征重新基于母版的结果

我们使用git rebase <target>命令在<target>之上重设HEAD的基础。假设特性被签出,我们将编写git rebase master在主特性的基础上重新构建特性分支。这可以从图 4-7 (c)中看出。

REBASE EXERCISE

在本练习中,我们从与三路合并练习相同的情况开始,但是我们不是合并分支,而是在master的顶部重设feature的基础。这个库可以在练习文件夹中找到,名为chapter4/rebase/

$ git log --oneline --graph --all
* b188294 (HEAD -> feature) Add feature.txt
| * 8cab888 (master) Add master.txt
|/
* 6fb6ffc second commit
* 2a97e8c Initial Commit

$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: Add feature.txt

$ git log --oneline --graph --all
* 449abd2 (HEAD -> feature) Add feature.txt
* 8cab888 (master) Add master.txt
* 6fb6ffc second commit
* 2a97e8c Initial Commit

重定基数的结果与合并的结果有一个巨大的区别。也就是说,我们没有增加提交的数量,并且降低了 Git 图的复杂性。特别是,当您在开发代码时更新您的分支以包含来自 master 的最新内容时,这是一种很好的工作方式。请注意,该功能指向一个新的提交 sha。

$ git show b18829
commit b1882942ed4722828d595e3428fbac75522bb587
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Mon May 4 09:34:52 2020 +0200

    Add feature.txt

diff --git a/feature.txt b/feature.txt
new file mode 100644
index 0000000..e69de29

在这里,我们使用show来查看特性先前指向的提交仍然存在,因此我们可以安全地从 rebase 中恢复。

Note

虽然我们对 rebase 的直觉是我们移动了一个分支,但事实并非如此。新的提交是在合并基础之上进行的,而旧的提交没有任何对它们的引用。因此,在垃圾收集发生之前,它们可以被恢复。

对于重定基数或合并的情况有许多不同的意见。对此我有几点看法。首先,无论谁交付给定的变更集,整个团队的工作方式都会产生一致的历史,这一点很关键。这最有可能意味着每个人都 rebases 或每个人都合并。团队正在开发的工作流也可能带来影响。然而,如果工作流指示您是否可以从技术的角度使用合并或 rebases,它可能需要被查看,并且您需要重新评估它是否是一种合理的工作方式。

第二,如果你不在一个共享的分支上工作,你应该总是改变基础。这将使您的历史保持干净,并将您的提交很好地捆绑在一起,以实现简洁的交付。这也使你在交付之前更容易操纵你的本地历史,我们将在后面的章节中讨论。由于重定基础改变了提交 sha,所以重定公共分支的基础被认为是不好的做法。但是,您可能正在自己的公共分支上工作。它可以被发布以从持续集成系统中获得构建,或者从同行那里获得反馈。在这种情况下,您不应该停止对您自己的分支,而是对 public 分支进行重新基准化。

标签

到目前为止,在这一章中,我们已经介绍了分支,以及它们是如何轻量级和易于移动的。对于更加静态的提交,命名引用有许多用途。在 Git 中,我们有标签来提供这种功能。标签是对提交的引用。通常,标签被用来标记我们的源代码的发布版本,所以我们有一个产生我们的软件的任何给定版本的源代码的命名引用。

有两种类型的标签,轻量级的和带注释的。轻量级标签就像分支,只是它们是静态的。这意味着它们只是对提交的引用,没有附加信息。带注释的标签是 Git 对象数据库中的完整对象,接收消息并提供附加信息。通过在 tag 命令中添加-a、-s 或-m 来创建带注释的提交。tag 命令看起来像这样:git tag <target>对于轻量级标签。例如,git tag v1.6.2 a233b将创建一个指向提交的轻量级标签,前缀为 a233b。

如果我们省略目标,标签将在 HEAD 处创建。

TAGGING

在本练习中,我们将进入一个简单的存储库,添加一些标签并研究它们。本练习的知识库可在chapter4/tags/中找到。

$ git tag

首先,我们注意到没有标签。这与 flow log 命令的输出一致。

$ git log --oneline --all
f203381 (HEAD -> feature) Add feature.txt
0a664dc (master) Add master.txt
810eb22 second commit
0cae311 Initial Commit

现在,我们使用 sha 810eb22 在提交时创建一个标记。我们使用提交的唯一前缀。

$ git tag v1.0 810eb

现在,当我们列出所有标记时,这些标记都会显示出来,并作为日志上的参考。

$ git tag

v1.0

$ git log --oneline --decorate --graph --all
* f203381 (HEAD -> feature) Add feature.txt
| * 0a664dc (master) Add master.txt
|/
* 810eb22 (tag: v1.0) second commit
* 0cae311 Initial Commit

之前的提交是直接使用提交 sha 进行的。在下面,我们重复相同的流程,但是不使用提交,而是从引用创建一个标签。

$ git tag v2.0 master

$ git tag
v1.0
v2.0

$ git log --oneline --decorate --graph --all
* f203381 (HEAD -> feature) Add feature.txt
| * 0a664dc (tag: v2.0, master) Add master.txt
|/
* 810eb22 (tag: v1.0) second commit
* 0cae311 Initial Commit

前面的标签是轻量级标签,是纯粹的引用。我们可以创建完整的标签对象,例如,将消息附加到标签上。

$ git tag v3.0 feature -m "pre-release"

创建标记后,我们可以看到标记和被标记的提交的完整信息。将这与轻量级标签上的相同信息进行对比。

$ git show v3.0
tag v3.0
Tagger: Johan Abildskov <randomsort@gmail.com>
Date:   Mon May 4 10:04:34 2020 +0200

pre-release

commit f203381f79576e69f4de2a75cd6289ea635f3543 (HEAD -> feature, tag: v3.0)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Mon May 4 10:02:12 2020 +0200

    Add feature.txt

diff --git a/feature.txt b/feature.txt
new file mode 100644
index 0000000..e69de29

$ git show v1.0
commit 810eb22a50a1bd94facd9917531295ddddd27bb7 (tag: v1.0)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Mon May 4 10:02:11 2020 +0200

    second commit

diff --git a/0.txt b/0.txt
index 303ff98..36db9be 100644
--- a/0.txt
+++ b/0.txt
@@ -1 +1,2 @@
 first file
+\n additional content

正如我们在这个练习中所看到的,标签可以用来标记我们历史中具有某种意义的地方。

分离头

如果你在开始阅读这本书之前有过任何 Git 经验,很可能你已经发现自己处于一种超然的头脑状态,很可能它吓到了你。我知道,因为至少我花了一些时间才没有让我觉得自己做了不该做的事。

头部分离是完全正常的情况,很容易补救。分离的头仅仅意味着头指向提交而不是分支。这样做的结果是,在分离 head 情况下创建的提交没有任何指向它们的引用。这可能会使它们从 git 日志中消失,被垃圾收集,或者变得不必要的难以恢复。在分离的 HEAD 中结束的两种最常见的方法是显式地检查提交或检查标记。图 4-8 给出了一个例子。

img/495602_1_En_4_Fig8_HTML.jpg

图 4-8

分离的头部,带有悬空的提交

如果结束一个分离的头的情况的目的是简单地看代码,看在那个时间点上库的状态是什么,没有问题,并且我们可以停留在分离的头的状态直到我们准备好返回我们正在工作的分支。如果我们想做出改变,我们最好创建一个分支;这可以在签出时使用标志-b 很容易地完成,它将在我们签出的目标上创建一个分支。这个看起来像git checkout -b <branch-name> <target>。如果我们想在标签v1.2.7处创建一个名为 bugfix 的分支,我们使用命令git checkout -b bugfix v1.2.7

DETACHED HEAD

在这个练习中,我们将把自己置于分离的头部状态,并从中恢复过来。本练习的资源库可以在示例中找到,名称为chapter4/detached-head/

$ git log --oneline --decorate --graph --all
* adfcb1d (HEAD -> feature) Add feature.txt
| * ca3e69b (tag: v1.0, master) Add master.txt
|/
* 66d6ce7 second commit
* 66d93b9 Initial Commit

我们检查与主分支指向同一个分支的标签。

$ git checkout v1.0
Note: checking out 'v1.0'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b <new-branch-name>

HEAD is now at ca3e69b... Add master.txt

前面的文字墙是分离的头感到危险的主要原因。注意,即使有指向我们签出的提交的引用,HEAD 并不指向它们,而是直接指向提交。

$ git log --oneline --decorate --graph --all

* adfcb1d (feature) Add feature.txt
| * ca3e69b (HEAD, tag: v1.0, master) Add master.txt
|/
* 66d6ce7 second commit
* 66d93b9 Initial Commit

$ git checkout -b new-branch
Switched to a new branch 'new-branch'

请注意,我们只是在 HEAD 处创建并签出了一个分支。根据我们的用例,我们可以检查主分支并从那里继续。

$ git log --oneline --decorate --graph --all
* adfcb1d (feature) Add feature.txt
| * ca3e69b (HEAD -> new-branch, tag: v1.0, master) Add master.txt
|/
* 66d6ce7 second commit
* 66d93b9 Initial Commit

从下面的练习可以看出,没有理由害怕脱离的头部,很容易恢复。

去吧卡塔

为了支持本章的学习目标,我建议您完成以下表格:

  • 基本分支

  • 三路合并

  • 合并-冲突

  • 合并-合并排序

  • Rebase-branch

  • Git-tag

  • 分离头

摘要

在这一章中,我们谈到了 Git 中的分支以及它们是如何工作的。我们讨论了不同类型的合并,并将合并与 rebases 进行了对比。我们逐步解决了合并冲突。我们以一个简短的描述结束了这一章,描述了我们如何使用标签来标记我们代码库中的有趣的点。最后,我们缩小了分离头部的情况。

现在我们已经有了分支的基础,我们可以继续使用 Git 进行协作了。

五、Git 中的协作

和许多其他事情一样,软件开发并不有趣,除非我们和其他人一起做。不幸的是,大多数软件开发人员都没有在健康的环境中接触过 Git。要么他们在教室里体验 Git,在那里教授理解 Git 的重要性,应该有人向学生教授它,但它只是更大课程中的一个脚注。或者他们被介绍到某个组织的 Git 中的工作流和协作,这些组织更关心按照描述的过程而不是以有意义的方式做事。这一章将有希望让你回到正轨,并使你能够选择 Git 工作流,并与同事有效地工作。

在本章中,我们将首先介绍使用远程存储库的基础。到目前为止,我们只关心本地存储库。不要害怕,如果你已经掌握了分支,遥控器将是这些概念的一个小的延伸。之后,我们将比较最常见的工作流,并讨论每种工作流的优缺点。

使用遥控器

Git 据说是一个分布式版本控制系统,Git 通过 remotes 的概念来实现这种分发。通常,我们在存储库中使用单个遥控器,默认情况下,它的名称是origin。大多数软件项目的开发都是从现有项目的克隆开始的。这将在您的计算机上实例化原始存储库的本地副本,并将对原始存储库的引用保存为远程源。

在基于客户机/服务器的版本控制系统中,所有的命令和动作都要通过服务器。这意味着我们可以做像锁定文件这样的事情,所以一次只有一个用户可以修改它。在 Git 中,情况并非如此。我们异步工作,然后在用户空闲时同步我们的工作。最常见的是,这是通过一个公共的存储库管理器来完成的,比如 GitHub。

协作中的大多数任务都围绕着我们如何管理分支,但除此之外,我们还会处理克隆、获取、推送和拉取。有了这四个命令,您 98%的日常协作工作都将涵盖在内。

Note

协作通常发生在托管服务器或云解决方案上,如 GitHub、GitLab 或 Bitbucket。为了创建独立的练习,我们没有使用存储库管理器。我们正在使用本地存储库对工作流进行建模。如果你选择完成本章的最后一个练习,需要一个 GitHub 帐户,并展示一个存储库管理器。

克隆

开始一个项目的工作有两种情况。第一,可以是新项目。我们很久以前使用git init讨论过这个场景。第二,也可能是更常见的,我们将对现有的代码库做出贡献,开源的或专有的。当从现有的代码库开始时,我们要做的第一件事是克隆存储库,以便在我们的机器上获得一个本地实例。我们用命令git clone <url> <path>来做到这一点,例如git clone https://github.com/randomsort/practical-git/ git-exercises。这将初始化磁盘上的本地存储库,从远程下载整个存储库,检查工作区中的默认分支,并创建一个指向名为origin的远程存储库的指针。大多数情况下默认的分支是master分支。如果我们省略了path参数,Git 将使用存储库名称。在前面的例子中,如果我们省略路径,它将在一个名为practical-git的文件夹中。

CLONING A REPOSITORY

在本练习中,我们将从 GitHub 克隆一个公共存储库,并看看我们在磁盘上得到了什么。这个练习可以在任何地方完成,因此不依赖于本书附带的练习源。

首先,我们克隆存储库 https://github.com/randomsort/practical-git-students

Git 告诉了我们很多关于克隆过程中发生的事情,但是它基本上是关于性能的无趣的事实。把它当成一个令人讨厌的进度条。

$ git clone https://github.com/randomsort/practical-git-students git-exercises
Cloning into 'git-exercises'...
remote: Enumerating objects: 7, done.
remote: Counting objects: 100% (7/7), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 7 (delta 1), reused 2 (delta 0), pack-reused 0
Unpacking objects: 100% (7/7), done.

$ cd git-exercises/

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

nothing to commit, working tree clean

$ ls
README.md  the-practical-git.md

导航到存储库并使用 git status 和 ls 让我们知道我们下载了什么。我们可以看到有一些文件,我们有一个干净的工作区,主分支与远程是最新的。这是意料之中的,因为我们没有在存储库中做任何工作。

$ git log --oneline -n 5
f18e7bc (HEAD -> master, origin/master, origin/HEAD) Merge pull request #1 from the-practical-git/master
1135048 Add the Practical Git Bio
ce866b9 Initial commit

我们用git log来看历史。对于您来说,这可能看起来有所不同,因为更多的拉请求会频繁地进入存储库。在这里,我们既可以看到本地分支,也可以看到远程分支。远程分支以origin/为前缀。

$ git remote show origin

我们使用命令 git remote show origin 来查看关于我们的遥控器的一些细节。

* remote origin
  Fetch URL: https://github.com/randomsort/practical-git-students
  Push  URL: https://github.com/randomsort/practical-git-students
  HEAD branch: master
  Remote branch:
    master 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 branch
* master

$ git branch --remote
  origin/HEAD -> origin/master
  origin/master

这个练习向您展示了如何克隆一个存储库并查看其来源。

与远程同步

既然我们的本地存储库已经建立,我们可以开始做一些工作了。Git 中常见的工作流是在本地完成一些工作,然后将这些工作与远程同步。把我们的工作交付到远程叫做推送。当远程上有本地没有的工作时,我们可以使用 pull 或 fetch 来获得该工作。本地遥控器和本地遥控器之间有几种不同的地方。它们可以是关于对象的,也可以是关于引用的。为了本章的目的,提交是我们唯一关心的对象类型。当我们同步对象时,它总是一个加法运算。我们总是交付更多的对象或下载更多的对象。我们永远不能本地或远程删除或修改对象。这使得对象操作是安全的,因为除了垃圾收集之外,我们不会丢失任何对象。其次,我们可能需要同步引用,即分支和标签。他们可以不同意他们指向什么,或者他们是否应该存在。

使用分支方法和与远程交互的方法来协调这些分歧:推、取和拉。Pull 是获取和合并的简写。Push 是最没意思的命令。我们将拥有的引用和对象发送到远程设备,如果远程设备无法对引用进行快速合并,那么它将拒绝更改。

当我们取的时候,我们从遥控器上得到我们丢失的所有对象。然后,我们从遥控器获取引用。它们的名字是分开的,因此来自远程命名源的引用以 origin/为前缀;因此,当我们从本地存储库中查看时,原点上的主分支称为原点/主分支。因此,从原始主机获取更改的流程如下:

  • 获取:从远程获取对象和引用

  • Merge :将远程的更改放到本地的主分支上

这可以从图 5-2 中看出。

img/495602_1_En_5_Fig12_HTML.jpg

图 5-12

与拉取请求贡献者互动

img/495602_1_En_5_Fig11_HTML.jpg

图 5-11

拉取请求标签

img/495602_1_En_5_Fig10_HTML.jpg

图 5-10

打开拉取请求

img/495602_1_En_5_Fig9_HTML.jpg

图 5-9

查看变更集。创建拉取请求

img/495602_1_En_5_Fig8_HTML.jpg

图 5-8

在存储库中提交–单击“拉取请求”

img/495602_1_En_5_Fig7_HTML.jpg

图 5-7

为您的帐户使用存储库

img/495602_1_En_5_Fig6_HTML.jpg

图 5-6

存储库中的分叉按钮

img/495602_1_En_5_Fig5_HTML.jpg

图 5-5

从分支到原始存储库的拉请求。通常,分支的所有者没有访问原始存储库的权限

img/495602_1_En_5_Fig4_HTML.jpg

图 5-4

(一)在我们推之前,这是我们的世界观;(b)由于已经在远程主机上完成了工作(提交 E),推送将被拒绝。获取后,它看起来是这样的(c)。我们通过合并来协调差异,并且结果可以被推送,这导致推送后的(d)状态

img/495602_1_En_5_Fig3_HTML.jpg

图 5-3

(一)情景预演。(b)推送后的场景。请注意,在推送之前,遥控器上的 C 和 D 不可用

img/495602_1_En_5_Fig2_HTML.jpg

图 5-2

(一)取数前的储存库。(b)获取后的储存库。(c)合并后的储存库

img/495602_1_En_5_Fig1_HTML.jpg

图 5-1

Git 存储库集中管理,但在本地克隆。Alice 和 Bob 可以异步工作,或者在原点或者在彼此之间协调工作

当我们推送并拒绝我们的更改时,我们会经历一个获取/合并循环,然后能够提交我们的更改。

Note

我们可以将 origin/ namespace 视为远程存储库外观的缓存。这不是 Git 自动同步的,所以我们需要进行提取来更新我们的缓存。因此,当我们运行 Git status 时,输出是基于我们的缓存,而不是远程的,这可能会产生意想不到的结果。

我们将在下一个基于简化工作流的练习中介绍这是如何进行的。既然我们已经研究了遥控器的活动部件,我们可以了解不同的工作方式以及如何在其中工作。

简化的工作流程

你可能已经听说过简化工作流、基于主流程或集中式工作流。此工作流有许多名称,并且是默认的工作流,除非您对存储库管理器进行了不同的配置。这个工作流的定义特征是所有的协作都直接发生在主服务器上。这意味着,虽然您可能有本地分支机构来隔离您的工作,但当您完成工作时,您会将其推送到 master。这个工作流程就是我如何处理我的玩具项目、笔记库和类似的东西。好的一面是开销很小,几乎没有流程。这使得它成为一个容易理解的高效工作流程,也就是说,如果我们保持在快乐的道路上。糟糕的是,我们可能会与同事产生竞争,并且我们没有工作流工具来帮助我们的主分支保持高质量的源代码。

在基于母版的工作流中,我们基本上需要涵盖两种场景。首先,有一个令人高兴的场景,当我们在本地工作时,master 中没有工作被完成。这种情况是无聊的,因为这种工程,并成为一个快速前进的合并在远程。这种情况可以在图 5-3 中看到。

然后,有一个竞争条件场景,当我们在本地工作时,一个同事已经将工作交付给主分支。这是一个非常有趣的场景,因为它需要一些努力来解决。技术细节是,存储库管理器只允许您推动快进合并。任何其他类型的合并必须在本地进行对账。这意味着竞争交付的情况如下所示:

  • 从原点克隆或提取。

  • 一定要在本地工作,并且全身心的投入。

  • 推,被遥控拒绝。

  • 获取最新的更改,并将它们合并到本地主分支中。

  • 将 master 推至原点,因为现在是快进合并。

Note

这完全有可能重复发生,从而阻止开发人员交付他们的变更。这意味着存储库跨越了太多的架构边界,或者您正在使用的工作流没有随您的组织扩展。在任何情况下,这在正常使用中都不太可能发生,所以如果您在这里结束,请后退一步,反思一下存储库架构。

前面的工作流程如图 5-4 所示。首先是本地更改将被拒绝的场景,然后是远程上的协调和快速合并。

在下面,我们将做一个练习,模拟与主分支上的远程存储库进行交互。由于这是一个比前面的练习更复杂的练习,所以我将在这个练习中贯穿主工作流程式。kata 可以在 git-kata 库中找到。

MASTER-BASED WORKFLOW

在这个练习中,我们将浏览整个主工作流程形,并体验快乐路径和竞赛状态路径。

$ git clone https://github.com/praqma-training/gitkatas
Cloning into 'gitkatas'...
remote: Enumerating objects: 111, done.
remote: Counting objects: 100% (111/111), done.
remote: Compressing objects: 100% (99/99), done.
remote: Total 1961 (delta 26), reused 35 (delta 10), pack-reused 1850
Receiving objects: 100% (1961/1961), 528.24 KiB | 1.56 MiB/s, done.
Resolving deltas: 100% (825/825), done.

$ cd gitkatas/

$ cd master-based-workflow/

$ source setup.sh

--- Truncated Output ---

现在,我们已经获取了形并运行了适当的练习脚本,所以我们已经准备好完成自述文件中描述的练习。

$ ls
fake-remote-repository/  fitzgerald-pushes-before-we-do.sh*

首先,我们克隆假的远程存储库,并在本地存储库中提交。然后,我们可以调查本地和远程之间的关系。

$ git clone fake-remote-repository/ local-repo
Cloning into 'local-repo'...
done.

$ cd local-repo/

$ echo "line of text" >> README.md

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   README.md

no changes added to commit (use "git add" and/or "git commit -a")

我们注意到,因为我们没有创建任何提交,所以我们仍然与远程主机保持同步,也指定为 origin/master。

$ git add .

$ git commit -m "Added content to the README"
[master 9eea570] Added content to the README
 1 file changed, 1 insertion(+)

$ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

由于我们已经创建了一个提交,并且远程上没有做任何工作,所以我们是最新的。

$ git push
Counting objects: 3, done.
Writing objects: 100% (3/3), 279 bytes | 279.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To C:/Users/rando/repos/randomsort/gitkatas/master-based-workflow/exercise/fake-remote-repository/
   054c055..9eea570  master -> master

现在,我们可以将更改提交到遥控器,并继续处理不愉快的路径场景。

$ echo "Another line of text" >> README.md

$ git add README.md

$ git commit -m "Update README"
[master d144b48] Update README
 1 file changed, 1 insertion(+)

现在,在我们更新了自述文件并再次提交之后,我们运行一个脚本来模拟我们的同事交付工作。

$ ../fitzgerald-pushes-before-we-do.sh
 --- Output truncated ---

$ git push
To C:/Users/rando/repos/randomsort/gitkatas/master-based-workflow/exercise/fake-remote-repository/
 ! [rejected]        master -> master (fetch first)
error: failed to push some refs to 'C:/Users/rando/repos/randomsort/gitkatas/master-based-workflow/exercise/fake-remote-repository/'
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 status 时,我们被告知我们是最新的。

$ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

这是因为我们有一个远程状态的本地缓存,它不是在推送时更新的,而是在获取时更新的。

$ git fetch
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From C:/Users/rando/repos/randomsort/gitkatas/master-based-workflow/exercise/fake-remote-repository
   9eea570..96a3f9c  master     -> origin/master

$ git status
On branch master
Your branch and 'origin/master' have diverged,
and have 1 and 1 different commits each, respectively.
  (use "git pull" to merge the remote branch into yours)

nothing to commit, working tree clean

在获取之后,状态告诉我们,我们已经偏离了原点/主点。这就是图 5-4 (b)所示的场景。

$ git log --all --graph --decorate --oneline
* 96a3f9c (origin/master, origin/HEAD) Fitz made this
| * d144b48 (HEAD -> master) Update README
|/
* 9eea570 Added content to the README
* 054c055 Add README.md

$ git merge origin/master -m "merge"
Merge made by the 'recursive' strategy.
 fitz-was-here.md | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 fitz-was-here.md
$ git merge origin/master

$ git status
On branch master
Your branch is ahead of 'origin/master' by 2 commits.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

合并后,我们处于如图 5-4 (c)所示的状态。我们提前了两次提交,本地提交和合并提交。

$ git log --all --oneline --decorate --graph
*   a73deeb (HEAD -> master) Merge remote-tracking branch 'origin/master'
|\
| * 96a3f9c (origin/master, origin/HEAD) Fitz made this
* | d144b48 Update README
|/
* 9eea570 Added content to the README
* 054c055 Add README.md

我们现在可以推进,因为我们已经建立了从origin/maste r 到master的快速前进合并的条件。

$ git push
Counting objects: 5, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (5/5), 582 bytes | 582.00 KiB/s, done.
Total 5 (delta 0), reused 0 (delta 0)
To C:/Users/rando/repos/randomsort/gitkatas/master-based-workflow/exercise/fake-remote-repository/
   96a3f9c..a73deeb  master -> master

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

nothing to commit, working tree clean

正如我们在这个练习中所看到的,没有发生非常令人兴奋的事情,在这个简单的场景中很容易协调竞争条件。如果您遇到许多合并冲突,这表明您应该研究一种不同的工作方式。

基于 master 的工作流对于简单的项目来说是不错的,并且低开销和低流程对许多人来说是有吸引力的。如果你刚刚开始,这是一个很好的工作流程,以获得你的轴承。如果你持续关注流程的缺失是否会损害你的生产力,你应该是优秀的。

基于分叉的工作流

基于 Fork 的工作流通常用在开源软件中,其中的信任模型与组织内部的有点不同。虽然开源意味着每个人都可以做出贡献,但这并不意味着所有的变化都会进入项目。基于 fork 的工作流有助于实现这种工作方式。

在基于 fork 的工作流中,我们有多个远程存储库。其中一个是原始的,包含了项目的最终真相。假设我想为 Kubernetes 这样的大型开源项目做贡献。我不能简单地克隆存储库,然后将我想要的任何更改推回。首先,是我的交付质量问题,如果我非常不称职,我的工作应该被排除在外怎么办?其次,还有产品的愿景。如果没有清晰的愿景和指导方针来确定项目想要支持的特性,随着时间的推移,它将变得不可维护和不可用。因此,即使我的工作很好,项目也可能对集成它不感兴趣。最后,前两点甚至假设我的意图是善意的。如果我们没有防护栏或某种访问控制,所有开源项目都会立即被不良的第三方破坏。曾经有过这样的情况,邪恶的行为者在引人注目的开源项目中注入漏洞,从而危及所有依赖于这些代码的人。

对此的解决方案是,我们在自己的名称空间上创建原始项目的所谓分支。这使我们可以完全使用我们的叉子。然后,我们可以使用一种通常称为“拉请求”的机制进行更改,并将这些更改提交回原始项目。这可以从图 5-5 中看出。

Note

之所以称之为拉请求,是因为您提供了第二个可用的遥控器,并请求维护人员将您的更改拉至他们的存储库中。

FORK-BASED WORKFLOW

这个练习有点不同,因为它将需要一个 GitHub 帐户,并且它将更多地基于截图而不是命令行界面。

然而,如果你完成了这个练习,你就已经为 GitHub 上的一个公共库做出了贡献。

本练习假设您有一个 GitHub 帐户,并且已经登录。

首先,我们将定位我们要贡献的存储库,并创建一个分支。

为此,在浏览器中打开 https://github.com/randomsort/practical-git-students 并找到如图 5-6 所示的分叉按钮。

单击 fork 按钮会将存储库分支到您自己的帐户,并将您带到图 5-7 中的页面,在这里显示的将是您自己的用户名,而不是我的。

我们可以注意到,从何处分叉存储库是显式的。

现在我们有了自己的分支,或者工作副本,我们可以通过克隆按钮或者命令行来克隆这个链接。

我将通过命令行克隆。我不会在这里介绍如何设置凭证或任何东西。

$ git clone https://github.com/the-practical-git/practical-git-students
Cloning into 'practical-git-students'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), done.

现在,我将进入该文件夹,创建一个包含我的简历的文件。

$ cd practical-git-students/

$ touch the-practical-git.md

$ vim the-practical-git.md

$ git add .

$ git commit -m "Add the Practical Git Bio"
[master 1135048] Add the Practical Git Bio
 1 file changed, 11 insertions(+)
 create mode 100644 the-practical-git.md

$ git push
Username for 'https://github.com': the-practical-git
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 493 bytes | 493.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To https://github.com/the-practical-git/practical-git-students
   ce866b9..1135048  master -> master

注意,如果你按照练习进行,你应该选择你自己的名字或用户名作为文件名,并且你应该使用你自己的用户名来认证 GitHub。根据本地 Git 安装的配置,可能会提示您输入凭证,或者它可能会正常工作。

现在,我们可以回到我们的 fork,看到我们所做的更改出现在 GitHub 界面中。对我来说,我去 https://github.com/the-practical-git/practical-git-students ,但是你必须用你自己的用户名代替。

我们可以在图 5-8 中看到,我们现在有了一个提交,这个提交在原始存储库中是不存在的。这就是我们想要回报的!因此,我们单击右侧的“拉取请求”链接。

这将我们带到图 5-9 中,在那里我们可以看到变更集和我们正在使用的分支。在这种情况下,我们将贡献回原始存储库中的主分支,即我们 fork 中主分支上的内容。因此,我们单击“创建拉取请求”按钮。

这将我们带到图 5-10 ,在这里我们可以向拉取请求添加更多的信息。通常,我们会描述变更集,或者变更的原因。这是我们与仓库维护者的交流。在这种情况下,我们的变更集很简单,所以我们只在单击 Create pull request 之前添加了一个简短的描述。

在许多场景中,贡献者和维护者之间会有一点反复,以确保 pull 请求符合他们的编码指南,有他们需要的文档和测试,等等。在这种情况下,我会接受你的请求,如果你保持语言的干净和友好,不涉及政治或宗教问题。不过,我希望你能向我问好!

既然您已经创建了拉请求,那么您的工作就完成了,除非维护人员有任何返工的请求。从维护者的角度来看,我们现在可以在 pull requests 选项卡中找到 Pull 请求,如图 5-11 所示。

我们单击“拉”请求来查看贡献了什么,在这里我们可以发表评论并与贡献者进行交互(图 5-12 )。

作为维护者,我可以点击合并请求并接受您的更改。如果你一直在做这个练习,我期待着加入你的提交!

请注意,虽然这个练习是在 GitHub 中执行的,但是所有大型存储库管理器都支持基于 fork 的工作流。

这是对基于 fork 的工作流的一次练习,通常在开源设置中使用。我知道一些在 Git 中有源代码的开源项目有不同的基于电子邮件的系统,但这是如此神秘,并没有被大量使用,我们就不详细讨论了。在下一节中,我们将介绍组织内部更常用的工作流。

基于拉取请求的工作流

虽然我们可以说前面描述的基于 fork 的工作流也是基于 pull 请求的,但是我们在这一节中经历的工作流通常被称为基于 pull 请求的工作流。它是基于 fork 的工作流的一个更简单的版本,从一个组织内部我们有一个不同的信任模型这一事实开始。每个人都被允许直接对存储库做出贡献,尽管并不是每个人都必须拥有合并到主分支的访问权限。

其工作方式是使用分支作为抽象,而不是分叉。这大大减少了保持本地和远程存储库最新的开销。基于拉取请求的工作流是这样的:

  • 克隆或获取存储库。

  • 创建特征分支。

  • 一定要在本地工作,并致力于你的特色分支。

  • 将您的功能分支推至远程。

  • 转到 remotes web 界面,创建一个从您的功能分支到您的主分支的 pull 请求。

  • 那些有访问权限的合并或请求更改。

基于拉取请求的工作流简单易懂,并且没有太多的开销。然而,拉请求本身有助于一些我们将在这里讨论的反模式。首先,根据您的工作方式,拉取请求可能是一个人工关口,需要审查和人工批准。这可能导致切换和延迟的反馈回路;这降低了生产力和士气,导致软件质量下降。

第二,拉取请求往往是在开发过程的后期创建的,那时我们已经准备好交付了。为了达到更好的效果,它们可以在流程开始时被创建为一个在制品分支。这将创建可追溯性,并增加对工作和协作的早期反馈能力,从而提高生产率。

第三,当许多拉请求以同一个主分支为目标时,这也会导致同步和维护拉请求的问题,而队列中前面的请求会得到处理。由于测试在另一个状态上运行,最终被合并,这也可能导致主服务器上的构建被破坏。

同样,如果您遇到这种情况,您已经超越了这种工作方式或您的存储库架构。

Git 流

关于是否覆盖 Git 流,我已经进行了很长时间的内心讨论。我看到许多组织都采用了这种工作流程,但没有一个成功。nvie 在 https://nvie.com/posts/a-successful-git-branching-model/ 的博客中将其描述为“一个成功的 git 分支模型”虽然我确信一些组织在这个工作流中运气不错,但是 Git、围绕它的工具以及我们的工作方式已经超越了它。因此,在大多数情况下,Git 流是一种反模式。我们试图通过引入抽象和“开发”分支来解决的问题往往以相反的方式结束。我们最终会有很长的合并队列、复杂的工作流和多个方向的集成地狱。所以,我真的建议不要这么做。

我能想象的 Git 流有用的场景是,如果你有一个完全不正常的工作方式,你需要一个临时的过渡流来到达一个正常的地方。这有助于组织阻力、工具和所需的技能提升。

去吧卡塔

为了支持本章的学习目标,我建议你完成上一个练习的主工作流程图。在那之后,如果你还没有完成在 GitHub 上做一个拉请求的练习,我建议你回到那个练习,现在就做一个拉请求。我期待着你的问候和来信!

摘要

在这一章中,我们介绍了一些基本的 Git 工作流程,并展示了如何使用 Git 进行协作。希望你现在更有信心成为一个软件组织中有价值的贡献者。对我来说很重要的一点是,你要掌控自己的工作流程,不要让工作流程决定你的工作方式,而是让你的工作方式决定你的工作流程。如果期望的工作方式和实现的 Git 工作流不匹配,您将生活在痛苦和沮丧中。

我建议您定期考虑以下问题:

  • 我的工作流是否引入了手动入口或交接?

  • 我的工作流程使交付变更变得容易了吗?

  • 我对我们的工作流程有信心吗?

  • 工作流程是否引入了不必要的官僚主义?

  • 我们开发人员常犯的错误有哪些?我们能做些什么来最小化这些事件的影响或频率吗?

如果我们不断地问这些问题,并接受我们的工作流不是一个死的静态的东西,而是与我们的软件一起生活和发展的东西,我们将会在一个好的地方结束。

六、操纵历史

我用了整整一章的篇幅来讲述操纵历史,这似乎非常违反直觉。版本控制的核心是可追溯性、可再现性和不变性。但是 Git 允许您操纵历史。对于任何公开的历史,无论是向同事公布的还是在互联网上可以找到的,我们都必须小心行事,谨慎负责地使用本章赋予我们的权力。但是对于本地历史,在我们发布它之前,塑造版本历史以适应逻辑单元会带来巨大的价值。

在这一章中,我们将首先介绍用 revert 撤销历史中的一个变更。这允许我们安全地撤销以前的工作,同时保持完全的可追溯性和不变性。

接下来,我们将介绍 reset,这是一个红色的大按钮,用于撤销我们的大量历史记录,不仅从我们的工作区中删除更改,还从我们的历史记录中删除它们。它也做一些不太有影响力的事情,是我最喜欢的地方杂耍分支的工具。

最后,我们讨论了交互式 rebase,它允许我们在历史中合并、拆分、删除和重新排序提交。这是一个极其强大的工具,但可能会让人觉得有点可怕,而且同样应该与公共历史保持很长的距离。就向同事或未来的自己传递最好的历史而言,没有什么工具比它更好。

还原提交

在很多情况下,我们需要撤销历史上的一些改变。如果我们幸运的话,这是最近的变化,但很可能不是。我们希望从应用中移除的这些更改可能是引入的错误、不再使用的功能,或者只是一些我们希望移除的混乱。在这个场景中,我们有一个特定的提交,它引入了一个我们想要删除的更改,我们可以使用 git revert。git revert 的逻辑是,它创建一个提交,该提交是我们想要还原的提交的反向变更集。这可以从图 6-1 中看出。

img/495602_1_En_6_Fig1_HTML.jpg

图 6-1

(a)两次提交,每次添加一个文件。(b)运行命令 git revert a 后的历史记录

在这个场景中,我们没有主动操纵历史,而是使用 Git 作为恢复变更的快捷方式。如果没有 Git,我们将被迫手动尝试并找出如何撤销给定的更改,然后自己创建提交。

这也意味着我们没有做任何会损害通过 Git 建立的可追溯性的事情。因此,从审计的角度来看,对公共历史使用 revert 是安全的。您是否破坏了您不想破坏的功能超出了 Git 的范围。始终运行您的测试!

REVERT EXERCISE

在本练习中,我们将介绍如何恢复提交。这个练习的资源库可以在文件夹revert/中第六章的源代码中找到。

$ ls
a.txt  b.txt

$ git log --oneline
5be4a3d (HEAD -> master) Add File B
c8482f6 Add File A

我们看到一个简单的历史记录,我们想撤销 commit c8482中引入的更改,并显示消息“Add File A”。

首先,我们使用git show来查看提交代表什么变更集。

$ git show c8482
commit c8482f67747fd8dcb6ced373d89ce3e8dc7d7754
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Sun Jun 14 16:05:10 2020 +0200

    Add File A

diff --git a/a.txt b/a.txt
new file mode 100644
index 0000000..4ef30bb
--- /dev/null
+++ b/a.txt
@@ -0,0 +1 @@
+file a

除了普通的提交信息之外,我们还可以看到差异。在这里,我们可以看到文件a.txt被创建。这是我们将要还原的基础。

$ git revert c8482
Removing a.txt
hint: Waiting for your editor to close the file...
[master 26dc609] Revert "Add File A"
 1 file changed, 1 deletion(-)
 delete mode 100644 a.txt

当我们将提交定位到恢复时,我们得到通常的提交消息提示。它预先填充了一条 sane 消息,所以我们可以保存文件并让 Git 创建提交。

$ git log --oneline
26dc609 (HEAD -> master) Revert "Add File A"
5be4a3d Add File B
c8482f6 Add File A

我们观察到 Git 创建了一个新的 commit,所以让我们看看它包含了什么。

$ git show 26dc
commit 26dc6094fbbd6293bb2a69f354d78008194ea6c3 (HEAD -> master)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Sun Jun 14 16:05:53 2020 +0200

    Revert "Add File A"

    This reverts commit c8482f67747fd8dcb6ced373d89ce3e8dc7d7754.

diff --git a/a.txt b/a.txt
deleted file mode 100644
index 4ef30bb..0000000
--- a/a.txt
+++ /dev/null
@@ -1 +0,0 @@
-file a

这里,我们得到了与我们恢复的提交完全相反的结果,即文件不再存在。我们在提交消息的主体中获得了更多的细节,因为对原始提交的跟踪得到了维护。

$ ls
b.txt

不出所料,我们的工作区现在只有b.txt。如本练习所示,恢复提交是一种安全的方法,可以撤消在历史上任意时间点引入的更改。

如果作为开发人员,您能注意到您正在处理的更改的语义,那么恢复提交就可以容易而安全地完成。这可能比在没有工具帮助的情况下试图手动恢复更改更安全。像 revert 等 Git 工具是使提交原子化和自包含的另一个好理由。

重置

Reset 是我最喜欢的 Git 命令之一,不仅因为它的强大功能,还因为它是允许我们揭示关于 Git 如何工作以及我们的直觉如何与之冲突的最多知识的命令之一。

总的来说,Git 在采取可能导致您意外丢失工作的行动方面非常保守。在其硬模式下,Git 重置是 Git 在没有警告的情况下丢弃未保存的工作的方式之一。它确实需要用户主动选择,所以这本身并不太坏。不幸的是,reset 也是用户体验很糟糕的命令之一。我希望通过命令来指导你,并结合练习和做形,使你有信心在日常编码生活中引入复位命令。

Git 重置有三种模式:软、混合和硬。我们将依次讨论它们,并以涵盖所有三个方面的练习结束。

软复位

在软模式下,git reset --soft <ref>,我们只操纵头部。也就是说,当前签出的引用将被更改为作为参数给出的目标。换句话说,软复位可以用来移动分支指针。

例如,如果您在开始工作之前忘记创建您的特性分支,从而在 master 上创建了您的提交,那么这将非常有用。然后,你可以通过首先在master创建你的特征分支,然后将--soft master重置为origin/master,让它看起来你一直在做正确的事情。

由于软复位不涉及工作目录和载物台,因此这是一种完全安全的操作。图 6-2 显示更新分支指针。

img/495602_1_En_6_Fig2_HTML.jpg

图 6-2

(B)是从(a)开始并运行 git 重置软 B 的结果

软复位可用于将一系列提交压缩成一次提交。这是通过重置到您的工作开始的点,然后创建一个提交来完成的。squash 之所以有效,是因为最新提交的所有工作都将处于可以提交到单个提交中的阶段。这不是一个典型的场景,通常可以通过交互式的 rebase 来更好地解决,我们将在本章的后面讨论。

混合复位

当您不向 git reset 传递模式时,混合重置是默认行为。混合重置除了像软重置一样更新磁头外,还会将载物台更新到目标位置。当我们不向 reset 传递任何 ref 时,HEAD 是默认行为。这导致了一个令人困惑的情况,即reset --mixed最常见的用例是不暂存文件。也就是说,如果您在某个时候使用了 git add 来存放路径,并且您不再希望存放该路径,那么您可以使用命令git reset <path>。逻辑是用 ref 指向的提交中的内容覆盖 stage,默认情况下是 HEAD。我花了一段时间才明白这样一个事实:要从舞台上拿走一样东西,你必须在那里放些别的东西。

图 6-3 显示了这种情况。在其中,我们还展示了 stage,除非在它上面添加了一些东西,否则它将等同于 HEAD 中的内容。

img/495602_1_En_6_Fig3_HTML.jpg

图 6-3

显示 git reset d.txt 改变了阶段,但没有改变工作区

基于之前的文本,一个合理的问题是,如果我们重置 mixed 为 B,会发生什么?在这种情况下,我们会将 B 且仅将 B 放入 stage 并更新 HEAD。

硬重置

如前所述,硬重置是 Git 中唯一危险的命令之一——至少从 Git 在没有给你警告的情况下丢弃你的工作的可能性来看是这样的。混合复位用目标 ref 的内容更新磁头和载物台。硬复位更新磁头、载物台和工作目录。这意味着不仅未保存的工作会丢失,不属于提交的工作也会丢失。这是 Git 能够以不可恢复的方式覆盖您的工作的少数几种方式之一。所以,小心行事。硬重置是我日常 Git 工作的一部分,也可能是你的一部分;只要确保你是故意这样做的。图 6-4 显示了硬复位如何改变载物台、工作空间和头部。

img/495602_1_En_6_Fig4_HTML.jpg

图 6-4

git reset - hard B 将 HEAD、stage 和 workspace 更新为 B 的内容

虽然硬重置被一些人认为是禁区,但它是我日常工作流程的一部分。如果我们在经常提交时遵守纪律,并且在硬重置之前注意运行 git status,我们就拥有了一个强大而简单的工具。我曾多次看到开发人员无意中用拉的方式弄乱了他们的本地历史,或者污染了他们的主分支。我个人做这件事的方法是,除了最简单的情况之外,避免拉扯。大多数情况下,我会使用git fetch来更新我的本地缓存,然后使用git reset --hard origin/maste r 从最新的开始。当我确定将我的工作放在独立的分支上时,这是一个运行的安全命令。

RESET EXERCISE

在本练习中,我将从 git-katas 存储库中浏览 reset kata。这个练习可以在 git 卡塔中找到,叫做重置。在本练习中,我们使用HEAD~1来指代HEAD的父节点。

$ ls
1.txt  10.txt  2.txt  3.txt  4.txt  5.txt  6.txt  7.txt  8.txt  9.txt

$ git log --oneline
6742e05 (HEAD -> master) 10
76ac07a 9
c3e33b7 8
da46ca2 7
1d9b4de 6
21a5ff1 5
a7e2065 4
065ebe8 3
df9cfa3 2
89514e1 1

我们注意到,我们有一个很长的历史,并且每次提交都有一个包含单个文件的工作区。我们不做调查,但是可以安全地假设每个文件都是在相应的提交中添加的。

$ git reset --soft HEAD~1

$ git log --oneline
76ac07a (HEAD -> master) 9
c3e33b7 8
da46ca2 7
1d9b4de 6
21a5ff1 5
a7e2065 4
065ebe8 3
df9cfa3 2
89514e1 1

我们注意到master分支现在指向提交 9 而不是 10。

调查工作区和git status向我们展示了 stage 和 workspace 仍然有来自 10。

$ ls
1.txt  10.txt  2.txt  3.txt  4.txt  5.txt  6.txt  7.txt  8.txt  9.txt

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   10.txt

现在,我们可以reset --mixe d 了,日志显示我们又继续前进了。

$ git reset --mixed HEAD~1

$ git log --oneline
c3e33b7 (HEAD -> master) 8
da46ca2 7
1d9b4de 6
21a5ff1 5
a7e2065 4
065ebe8 3
df9cfa3 2
89514e1 1

$ ls
1.txt  10.txt  2.txt  3.txt  4.txt  5.txt  6.txt  7.txt  8.txt  9.txt

$ git status
On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        10.txt
        9.txt

nothing added to commit but untracked files present (use "git add" to track)

查看工作区并检查状态显示我们仍然没有更改我们的工作区,但是现在9.txt10.txt没有被跟踪,因为 stage 与 8 中的内容相匹配。

$ git reset --hard HEAD~1
HEAD is now at da46ca2 7

$ git log --oneline
da46ca2 (HEAD -> master) 7
1d9b4de 6
21a5ff1 5
a7e2065 4
065ebe8 3
df9cfa3 2
89514e1 1

硬重置延续了头部更新的趋势。但现在,我们正在努力重置,所以我们期待我们的工作空间发生变化。在继续之前,我建议你花一些时间思考一下你期望的工作空间是什么样子的。

$ ls
1.txt  10.txt  2.txt  3.txt  4.txt  5.txt  6.txt  7.txt  9.txt

这里正在发生一件奇怪的事情。8.txt不见了,但是9.txt10.txt仍然存在于工作区中。发生这种情况是因为 9 和 10 因为我们之前的行动而没有被跟踪。因此,Git 此时并不关心它们,它们将被留在工作区中。

$ git status
On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        10.txt
        9.txt

nothing added to commit but untracked files present (use "git add" to track)

现在我们已经看到了 git reset 命令的三种不同模式。这可能是令人生畏的,这个形是我最喜欢的一个,因为它包含了很多知识。这就是为什么我真的建议你多练习几次这个形,直到你建立了你的重置直觉,并且可以像忍者一样使用重置硬技。

在本节中,我们将 reset 用于不同的目的。需要记住的一件重要事情是,无论如何,如果您将数据提交,您可以恢复它,即使是在硬重置之后。我希望这一节已经向您展示了这种安全性可以带给您的力量。

交互式 Rebase

我们之前经历的一些技巧可以用来操纵历史。但是,真正强大和精细的方法来调整你的本地历史是与互动 rebase。请记住,如果我们的历史是地方性的,我们可以随意修改它。这个能力给了我们机会和责任来考虑我们发布的历史作为交付的一部分。我们交付的 Git 历史也是一种交流形式,它应该以正确的顺序被分割成正确的提交,并带有良好、清晰的提交消息。通过向 git rebase 命令添加标志--interactive来调用交互式 rebase,例如git rebase --interactive master

准备 Git 历史的最好方法是交互式 rebase。从概念上讲,您给 Git 一个 rebase 目标,这就是您想要在其上进行 rebase 的目标。然后,Git 为您提供一个它打算执行的 rebase 计划。在 Git 执行这个计划之前,您可以修改它。这允许您完全跳过提交、编辑提交、重新排序提交或将其压缩在一起。这个计划采取行动的形式。删除一行只会让 rebase 跳过提交。如果不编辑计划,这与在 rebase 命令中省略- interactive 标志是一样的。

最常见的操作如下:

  • Pick 此时添加提交。

  • 挤压将该提交合并到先前的提交中。

  • 编辑停止编辑该提交。

  • Drop 不选择此提交。

前面的动作和重新排序是交互式 rebases 最常用的方式。

以下是 rebase - interactive 执行计划的示例:

pick 8c1e4de file9
reword 921d2d0 file8
squash 3374035 file3
pick 5b3a4fc file4
pick f0d1634 file5
drop a7df72d file2
drop 3d7e5ea file6
pick 18bfdfe file7

交互式 rebase 可能是最强大的 Git 命令,几乎任何 Git 任务都可以用这个命令来解决。我希望意识到这个命令将有助于你在旅途中始终向你的合作伙伴和你未来的自己传递一个精心整理的历史。

去吧卡塔

为了支持本章的学习目标,我建议你做以下 Git 动作:

  • 回复。

  • 重置。

  • 重新排列历史。

  • 然后,我建议你再做一遍重置形;重温 1F642 永远是健康的运动。

摘要

由于可追溯性,操纵历史通常被认为是版本控制中的大忌。但是,只要我们只重写本地的历史,或者只重写已经发布到临时分支的历史,我们就有义务使历史尽可能地有用。无论是将多个提交挤压在一起,还是将提交分割成不同的包,都是关于将您交付的历史视为您的可交付成果的一部分。

记住,我们在这里介绍的所有命令都是安全的,在 Git 内部的章节中,我们将介绍如何从事故中恢复。

七、定制 Git

Git 是工程师的工具,由工程师构建。这意味着,虽然 Git 以某种方式开箱即用,但当我们开始定制 Git 以匹配我们的工作方式时,真正的力量才会释放出来。使用 Git,我们可以在简单配置方面做很多事情,为我们经常使用的任务创建快捷方式,或者拥有特定于存储库的配置来帮助我们管理我们工作的不同上下文。

但是 Git 并没有就此止步。使用钩子,我们可以将脚本注入到 Git 操作的正常工作流中,以更好地支持我们的工作流,使用 Git 属性和过滤器,我们可以改变 Git 的最基本操作,即文件如何在存储库和工作区之间移动。在这一章中,我们将讨论从最基本的配置和别名到改变 Git 一些基本行为的定制。

正在配置 Git

Git 支持三个级别的配置:系统、用户和本地存储库。在大多数情况下,我们只使用用户配置。系统配置很少使用,可以在多用户环境中使用,以实施一些合理的默认设置。存储库本地配置是我们作为普通 Git 用户可以在更大程度上使用的东西。Git 按照以下顺序应用配置:系统、用户和本地。每个分组都会覆盖前一个分组中的任何重复条目。如图 7-1 所示。

img/495602_1_En_7_Fig1_HTML.jpg

图 7-1

在全局配置中,user.name 被设置为 briana,而在 Repo A 中,有一个. gitconfig 文件将 user.name 指定为 phillip。因此,在全局范围和 Repo B 中,user.name 将解析为 briana,而在 Repo A 中,它将解析为 phillip。在系统配置中,默认编辑器设置为 emacs

在 Git 中应用配置是通过接口 git config 完成的。如果我们在命令中添加- list,我们将读取而不是设置值。我们使用键/值对来设置配置。使用标志- global 和--system,我们可以设置用户或系统配置,而不是默认的存储库本地配置。为了将拉策略设置为总是为当前用户重设基础,我们将运行命令git config --global pull.rebase true。如果我们想为系统设置它,我们可以使用- system,或者把它放在存储库配置中,去掉--global标志。Git 中有很多配置,这里就不赘述了。具体的配置可以在 Git 文档中找到。然而,我们将讨论 Git 配置,因为它们支持接下来的三个部分。

GIT CONFIGURATION EXERCISE

在本练习中,我们将对 Git 配置进行调整。这个练习的知识库可以在随书提供的练习中找到,在文件夹chapter7/中。

在这个练习中,我们有两个存储库config-ACMEconfig-AJAX,我们将使用它们来研究配置是如何重叠的。首先,我们运行脚本来设置练习,然后我们可以继续。注意,在非 bash 提示符下运行可能会有问题。

$ ./config.sh
$ cd config
$ ls
config-ACME/  config-AJAX/
$ git config user.email
randomsort@gmail.com

这里,我们注意到即使我们不在 Git 存储库中,我们也可以访问配置。在这种情况下,本地配置没有任何意义。你也不太可能得到和我一样的回复。

$ cd config-ACME
$ git config user.email
janedoe@acme

进入 ACME 存储库,我们可以看到用户的电子邮件现在不同了。我们访问本地和全局范围来验证此配置的来源。

$ git config --local user.email
janedoe@acme
$ git config --global user.email
randomsort@gmail.com

我们也可以用标志--show-origin获得相同的信息。

$ git config --show-origin user.email
file:.git/config        janedoe@acme

现在,我们去另一个存储库看看我们得到了什么值。

$ cd ..
$ cd config-AJAX/
$ git config user.email
randomsort@gmail.com
$ git config --local user.email
$ git config --show-origin user.email
file:C:/Users/Rando/.gitconfig   randomsort@gmail.com

在这个存储库中,我们注意到没有设置本地 user.email,所以我们改为访问用户定义的。我们使用--show-origin来验证这一点。

user.email 配置是 Git 开箱即用的一部分,但是我们也可以为自己的目的添加任意配置。在这些存储库中,我们正在使用一个自定义配置,我们称之为 practical-git。在我们的部分中可以有多个条目,每个条目都有一个名称,但是我们使用的是公司密钥。

cd ../config-ACME
$ git config practical-git.company
ACME
In the ACME repository company contains the value ACME

, let’s check in AJAX.
$ cd ../config-AJAX/
$ git config  practical-git.company
UNKNOWN

这里,我们收到的值是 UNKNOWN,所以让我们将配置设置为 AJAX。

$ git config practical-git.company AJAX
$ git config practical-git.company
AJAX
We can still access the global scope.
$ git config --global practical-git.company
UNKNOWN

现在我们已经用这个部分污染了您的全局配置空间,我们将删除这个部分,以便从您的配置文件中删除它。

$ git config --remove-section --global practical-git
$ git config --get --global practical-git.company

练习到此结束。我们已经讨论了用户和本地范围,以及如何在不同的存储库中拥有不同的配置。如果您将同一台计算机用于个人、开源和公司项目,这可能特别有用。

别名

在 Git 中,我们可以使用别名来构造快捷方式或扩展 Git 的功能。我们既可以使用 Git 自带的命令,也可以调用外部命令。别名的一个常见目标是让您的日志与您的特定品味完美匹配。我的 go-to log 命令是git log --oneline --decorate --graph --all,这是一个需要键入的长字符串,为打字错误和其他错误留下了足够的空间。通常,我无法正确拼写--oneline。在这种情况下,我可以为该命令创建一个别名。没有直接的 alias 命令,但是我们可以使用 git config 来设置别名。注意,这也意味着我们可以有不同作用域的别名。

GIT ALIAS EXERCISE

在本练习中,我们将为存储库中的常见任务设置一些别名。本练习的知识库可在chapter7/aliases中找到。

我经常使用一种相当长的 log 来研究存储库。

$ git log --decorate --oneline --graph --all
$ git log --decorate --oneline --graph --all
* b5566ae (myBranch) 7
* 506bb29 6
* f662f41 5
* bd90c39 (HEAD -> master) 5
* 55936c5 4
* 6519696 3
| * e645e36 (newBranch) 9
| * d5ed404 8
|/
* 0425411 (tag: originalVersion) 2
* 11fbef7 1

当然,这是乏味的,并且经常导致打字错误和我不记得我实际上想要添加的部分。因此,让我们为这个命令设置一个别名。我们在本地存储库中设置了所有的别名,这样我们就不会泄漏到我们的全局范围中。

$ git config --local alias.l "log --oneline --decorate --graph --all"
This allows us to use git l as a shortcut to the longer variation.
$ git l
$ git log --decorate --oneline --graph --all
* b5566ae (myBranch) 7
* 506bb29 6
* f662f41 5
* bd90c39 (HEAD -> master) 5
* 55936c5 4
* 6519696 3
| * e645e36 (newBranch) 9
| * d5ed404 8
|/
* 0425411 (tag: originalVersion) 2
* 11fbef7 1

已经省掉了一堆按键,我们正在优化我们的工作方式。接下来,我们将添加运行外部命令的快捷方式。在这个简单的例子中,我们将简单地执行ls -al,但是它可能是一个任意复杂的命令。请注意,我们在别名的开头添加了一个感叹号,以表示我们运行的不是 Git 命令。这对扩展 Git 很有用。例如,Git LFS 就是这样开始的。考虑一下如果您使用 shell 别名会不会更好。

$ git config --local alias.ll '!ls -al'

$ git ll
total 10
drwxr-xr-x 1 joab 1049089   0 Jul  9 13:10 .
drwxr-xr-x 1 joab 1049089   0 Jul  9 13:10 ..
drwxr-xr-x 1 joab 1049089   0 Jul  9 13:14 .git
-rw-r--r-- 1 joab 1049089 155 Jul  9 13:10 gitconfig-alias
-rw-r--r-- 1 joab 1049089  25 Jul  9 13:10 test
So now we have augmented Git’s functionality ever so slightly.
We can all set up scripts to run as in the following section.
$ git config --local alias.helloworld '!f() { echo "Hello World"; }; f'
joab@LT02920 MINGW64 ~/repos/randomsort/practical-git/chapter7/aliases (master)
$ git helloworld
Hello World
And we can make our scripts take arguments.
$ git config --local alias.helloperson '!f() { echo "Hello, ${1}"; }; f'
$ git helloperson Phillip
Hello, Phillip

虽然这些别名很简单,但是它们应该显示出它们是多么强大的一个工具,以及如何既可以为经常使用的命令创建快捷方式,又可以用额外的功能扩展 Git。如果您在工作流程中有一组常用的事情,您可以为其中的每一项创建别名,并与您的团队共享它们。这是调整你工作方式的好方法。

正如我们所见,我们可以快速创建自定义命令的快捷方式,甚至用别名替换工作流程中的复杂部分。对于普通开发人员来说,别名是一个严重未被充分利用的 Git 特性。从现在开始,你有义务为你经常输入的东西创建别名。你也可能偶尔需要一个复杂的魔法,下一次你这么做的时候,为它创建一个别名,这样它总是在手边准备好。

属性

Git 属性是 Git 特性集的高级部分。这是我们可以从根本上改变 Git 在其内部数据库中编写对象的方式的地方之一。它们通常用于强制行尾或如何处理二进制文件,但也可以用于在签入时转换为特定的编码样式。由于这是发生在客户端的事情,如果我们真的想实施什么,我们需要在服务器端或自动化引擎中实现它。

我们实现属性的方式类似于。我们创建了.gitattributes文件,在这些文件中,我们列出了在这些特定路径上设置和取消设置属性的路径。例如,如果我们想让 Git 知道一个特定的 XML 文件是自动生成的,不应该像文本文件一样被合并,我们可以在它上面设置属性binary,得到一个. gitattributes,如下所示:

autogeneratedFile.xml binary

在路径上设置-text属性会阻止 Git 将匹配的路径视为文本文件。调整现有 Git 行为最常见的场景是,要么像前面展示的那样移除文本行为,要么强制 Git 以特定的方式处理行尾。

我们还可以使用 Git 属性来添加与 Git 本来的功能无关的功能。我们可以通过向配置中添加过滤器并从我们的.gitattributes中引用这些过滤器来做到这一点。Git LFS (Git 大文件存储)用这个来处理大文件。过滤器改变了 Git 处理文件和存储库的方式。Git LFS 将匹配的路径上传到中央二进制存储库管理器,并且只在签入时将引用保存在 Git 中。在结帐时,Git LFS 解析这些路径并下载二进制文件。Git LFS 似乎允许我们在 Git 中存储大型二进制文件,Git 在处理这种文件方面是出了名的糟糕。存储库大小的减少是以能够完全脱机工作为代价的。如果连接性在您的环境中是一种稀缺资源,那么不能完全分布式工作可能会是一个问题。该过滤器工作流程如图 7-2 所示。

img/495602_1_En_7_Fig2_HTML.jpg

图 7-2

清洁过滤器适用于从工作目录到临时区域和涂抹的其他方向

根据我的经验,Git 属性很少是必需的,除非您的上下文有些复杂,比如在多个不同的平台上,您使用的工具在检查代码时遇到行尾时会很脆弱。当然,正确的解决方案是修复脆弱性或复杂性,但在此之前,Git 属性可以帮助解决这些问题。

ATTRIBUTES

在本练习中,我们将回顾之前产生合并冲突的形,并研究如何使用。改变发生的事情。在本练习中,我们将进行 kata 合并-合并排序,因为我们知道这会导致合并冲突的发生,我们可以使用 Git 属性来改变其结果。

$ cd merge-mergesort
$ . setup.sh

现在,我们在练习中,我们可以通过在分支 Mergesort-Impl 中合并来强制合并冲突。

$ git merge Mergesort-Impl
Auto-merging mergesort.py
CONFLICT (content): Merge conflict in mergesort.py
Automatic merge failed; fix conflicts and then commit the result.
$ 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:   mergesort.py
no changes added to commit (use "git add" and/or "git commit -a")

$ cat mergesort.py
from heapq import merge

def merge_sort2(m):
    """Sort list, using two part merge sort"""
    if len(m) <= 1:
        return m

    # Determine the pivot point
    middle = len(m) // 2

    # Split the list at the pivot
<<<<<<< HEAD
    left = m[:middle]
    right = m[middle:]
=======
    right = m[middle:]
    left = m[:middle]
>>>>>>> Mergesort-Impl
    # Sort recursively
    right = merge_sort2(right)
    left = merge_sort2(left)
    # Merge and return
    return list(merge(right, left))

def merge_sort4(m):
    """Sort list, using four part merge sort"""
    if len(m) <= 4:
        return sorted(m)

    # Determine the pivot point
    middle = len(m) // 2
    leftMiddle = middle // 2
    rightMiddle = middle + leftMiddle

    # Split the list at the pivots
    first = m[:leftMiddle]
    second = m[leftMiddle:middle]
    third = m[middle:rightMiddle]
<<<<<<< HEAD

    last = m[rightMiddle:]
=======
    fourth = m[rightMiddle:]
>>>>>>> Mergesort-Impl

    # Sort recursively
    first = merge_sort4(first)
    second = merge_sort4(second)
    third = merge_sort4(third)
<<<<<<< HEAD
    last = merge_sort4(last)

    # Merge and return
    return list(merge(first, second, third, last))
=======
    fourth = merge_sort4(fourth)

    # Merge and return
    return list(merge(first,second, third, fourth))
>>>>>>> Mergesort-Impl

在前面的代码中,我们注意到有合并标记。如果它是一个自动生成的文件,或者是一个合并没有任何意义的文件,那就糟了。所以我们放弃合并。

$ git merge --abort
We then make Git consider mergesort.py a binary file, not to be automatically merged. We then repeat the merge.
$ echo "mergesort.py binary" > .gitattributes
$ git merge Mergesort-Impl
warning: Cannot merge binary files: mergesort.py (HEAD vs. Mergesort-Impl)
Auto-merging mergesort.py
CONFLICT (content): Merge conflict in mergesort.py
Automatic merge failed; fix conflicts and then commit the result.
$ cat mergesort.py
from heapq import merge

def merge_sort2(m):
    """Sort list, using two part merge sort"""
    if len(m) <= 1:
        return m

    # Determine the pivot point
    middle = len(m) // 2

    # Split the list at the pivot
    left = m[:middle]
    right = m[middle:]

    # Sort recursively
    right = merge_sort2(right)
    left = merge_sort2(left)

    # Merge and return
    return list(merge(right, left))

def merge_sort4(m):
    """Sort list, using four part merge sort"""
    if len(m) <= 4:
        return sorted(m)

    # Determine the pivot point
    middle = len(m) // 2
    leftMiddle = middle // 2
    rightMiddle = middle + leftMiddle

    # Split the list at the pivots

    first = m[:leftMiddle]
    second = m[leftMiddle:middle]
    third = m[middle:rightMiddle]
    last = m[rightMiddle:]

    # Sort recursively
    first = merge_sort4(first)
    second = merge_sort4(second)
    third = merge_sort4(third)
    last = merge_sort4(last)

    # Merge and return
    return list(merge(first, second, third, last))

正如我们所看到的,我们的文件中不再有合并标记,而是有一个大的自包含文件。我们可以使用 git checkout 和标志-- ours 和-- their 来建立传入的文件或者已经存在于我们的分支中的文件。

$ git checkout --ours -- mergesort.py
$ git add mergesort.py
$ git commit -m “merge”
$ git status

关于分行行长

未跟踪的文件:

  (use "git add <file>..." to include in what will be committed)
        .gitattributes

提交时没有添加任何内容,但存在未跟踪的文件(使用“git add”进行跟踪)

所以我们很好地解决了合并问题。如果我们已经知道如果有任何冲突,我们需要哪个源,我们可以将它指定为合并策略,作为合并命令的标志。首先,我们重置到前一阶段,然后用策略标志重复合并。

$ git reset --hard HEAD~1
HEAD is now at b4cac37 Mergesort implemented on master
$ git merge --strategy ours Mergesort-Impl
Merge made by the 'ours' strategy.

本练习展示了一种使用 Git 属性来改变 Git 工作方式的简单方法。关于 Git 属性还有更高级的事情要做,但是它们超出了本书的范围。

差异和合并工具

虽然命令行或 IDE 扩展对于大多数用例来说已经足够了,但是在有些情况下,您的域会为您设置一些具有挑战性的差异和合并。如果是这种情况,我们可以配置 Git 使用外部工具来处理这个问题。也许不足为奇的是,我们在 git config 中设置了工具,然后可以通过命令行调用它们。合并和比较工具的过程是相似的。如果我们配置了一个 diff 工具,我们可以通过git difftool调用它,如果我们配置了一个 merge 工具,命令是git mergetool。有免费的、开源的和专有的合并工具可用。我们在练习中使用开源工具 meld,而一个流行的付费工具 BeyondCompare。您的团队或部门可能有偏好的工具。如果是这样,这是一个好主意。

MERGE TOOL

本练习假设您已经安装了 meld 合并工具(meldmerge.com ),并且使用的是 Windows。如果您在不同的平台上,我建议您遵循特定于平台的指南来配置 meld 和 mergetools,但是您可能会比在 Windows 上更容易。首先,我们将把 meld 配置为 mergetool,然后我们将重新访问 merge-mergesort 表,看看当我们使用合并工具来解决冲突时合并是什么样子的。

当我安装 Meld 时,它位于路径 C:\ Program Files(x86)\ Meld \ Meld . exe 中,所以我想将 Git 指向该路径。

$ git config --global mergetool.meld.path 'C:\Program Files (x86)\Meld\Meld.exe'

然后,我们可以告诉 Git 使用 Meld 作为 mergetool 和 difftool。

$ git config --global merge.tool meld
$ git config --global diff.tool meld
So let’s go back to the merge-mergesort kata. Remember to run the setup script again to get a clean kata.
$ pwd
$ . setup.sh
$ git diff Mergesort-Impl
diff --git a/mergesort.py b/mergesort.py
index 9de927a..646b20f 100644
--- a/mergesort.py
+++ b/mergesort.py
@@ -9,8 +9,8 @@ def merge_sort2(m):
     middle = len(m) // 2

     # Split the list at the pivot
-    right = m[middle:]
     left = m[:middle]
+    right = m[middle:]

     # Sort recursively
     right = merge_sort2(right)
@@ -33,13 +33,13 @@ def merge_sort4(m):
     first = m[:leftMiddle]
     second = m[leftMiddle:middle]
     third = m[middle:rightMiddle]
-    fourth = m[rightMiddle:]
+    last = m[rightMiddle:]

     # Sort recursively
     first = merge_sort4(first)
     second = merge_sort4(second)
     third = merge_sort4(third)
-    fourth = merge_sort4(fourth)
+    last = merge_sort4(last)

     # Merge and return
-    return list(merge(first,second, third, fourth))
+    return list(merge(first, second, third, last))

对于更复杂的产品来说,这种差异可能是无用的。我们可以使用 difftool 命令运行 meld。

img/495602_1_En_7_Figa_HTML.jpg

$ git difftool Mergesort-impl

现在,我们有了一个更直观的视图。

让我们试着继续合并。

$ git merge Mergesort-Impl
Auto-merging mergesort.py
CONFLICT (content): Merge conflict in mergesort.py

自动合并失败;修复冲突,然后提交结果。

img/495602_1_En_7_Figb_HTML.jpg

$ git mergetool
Merging:
mergesort.py
Normal merge conflict for 'mergesort.py':
  {local}: modified file
  {remote}: modified file

因此,我们得到了一种可视化的方法来解决我们的合并,而不是手动设置冲突路径的状态。

如果您处理特定的文件类型或者有复杂的合并冲突,这可能是有用的,但是我在实践中很少遇到对这些工具的实际需求。在大多数情况下,合并冲突不会出现,当它们出现时,ide 提供了开箱即用的优秀工具。

钩住

我们讨论的最后一个配置选项是 Git 挂钩。钩子是小的 shell 脚本,允许我们在 Git 动作流中注入功能。钩子可以帮助防止我们做不应该做的事情,或者为 Git 准备数据。

钩子可以在服务器端和客户端使用。在本书中,我们只讨论客户端钩子,但是如果您注意到服务器因为非快进合并而拒绝推送,那么您已经看到了服务器端钩子的作用。其他常用的服务器端钩子检查引用的问题,或者防止您意外地将大文件添加到存储库中。

当谈到客户端钩子时,我多次使用过的那个短语仍然有效。如果你想在服务器端执行任何你必须做的事情,你只能在客户端支持工作流。钩子驻留在文件夹.git/hooks中,当您git init一个存储库时,有一组样例钩子,您可以查看 Git 钩子的实例。如果钩子退出时返回一个非零的退出代码,当前的操作将被中止。我们在下一个练习中使用这个来防止主分支上使用pre-commit hook的提交。在prepare-commit-msg钩子的情况下,我们都可以检查一些东西,也就是说,提交消息中是否存在诅咒语,或者是否缺少引用的问题 ID。因此,钩子帮助我们做正确的事情,通过阻力最小的路径,我们提高。当然,我们可以在本地绕过它。请注意,钩子不能跨分布式存储库共享,因为这会带来安全问题。

GIT HOOK EXERCISE

在这个练习中,我们已经学习了如何实现一个简单的钩子来帮助我们避免一个常见的错误,以及如何在需要的时候避开这个钩子。这个练习的存储库可以在文件夹chapter7/pre-commit-hook中找到。如果你是一个 Mac 和经验的问题,你可以看看这个堆栈溢出帖子寻求帮助: https://stackoverflow.com/a/14219160/28376

$ ls
pre-commit*

我们可以看到这里只有一个文件,但是让我们首先检查一下我们是否能够以正常的方式创建一个提交。

$ echo "test" > testfile.txt
$ git add testfile.txt
$ git commit -m "Initial commit"
[master (root-commit) 8d6ae42] Initial commit
 1 file changed, 1 insertion(+)
 create mode 100644 testfile.txt

这没什么奇怪的,我们可以存放一个文件并创建一个提交。所以让我们看看文件预提交中的内容。您不必成为 shell ninja,也能看出这个脚本的结构。我们退出时出错,当前分支是主分支;否则,我们零退出。有几个 echo 语句可以让我们看到控制流。

$ cat pre-commit
#!/bin/bash

echo "Running Hook"

if [ `git rev-parse --abbrev-ref HEAD` = master ]
then
    echo "You can't commit to master"
    exit 1
else
    echo "Commit freely on this branch"
fi

钩子是活跃的,它位于.git/hooks文件夹中,当它应该运行时有一个匹配的名字。我们的钩子叫做预提交,所以它将在提交创建之前运行。

$ cp pre-commit .git/hooks

现在钩子已经就位,我们将尝试看看是否可以创建一个额外的提交。

$ echo "more content" >> testfile.txt
$ git commit -am "Add more content"
Running Hook
You can't commit to master

我们的提交被拒绝,所以我们将创建另一个分支并在这里创建提交。

$ git checkout -b other
Switched to a new branch 'other'

$ git commit -am "Add more content"
Running Hook
Commit freely on this branch
[other ec31264] Add more content
 1 file changed, 1 insertion(+)

我们的钩子运行,但是因为我们在不同的分支上,所以允许提交。这有助于缓解那些糟糕的时刻。

但是让我们说,我们真的很想在 master 上提交,即使有一个钩子阻止我们这样做。让我们回到 master,在那里创建一个提交。

$ git checkout master
$ echo "some items of interest" > test
$ git add test
$ git commit -m "on master"
Running Hook
You can't commit to master

我们的钩子还在工作,阻止我们向主人承诺。但是,我们可以使用标志- no-verify 来阻止钩子运行。

$ git commit --no-verify -am "on master"
[master c6d4486] on master
 1 file changed, 1 insertion(+)
 create mode 100644 test

这就是我一直说我们需要处理服务器端强制执行的原因。有人可能会说,“不验证”是一种不好的做法,或者我们不能禁用它吗?但是考虑到钩子驻留在本地存储库中,没有什么可以阻止用户简单地删除钩子。

至少--no-verify给我们提供了一个跳过钩子的合适方法。

卡塔斯

为了支持本章的学习目标,我建议你练习以下动作:

  • Git 属性

  • 预推

作为补充,您可以进入任何本地 Git 存储库,查看.git/hooks文件夹中的示例钩子。

摘要

在这一章中,我们介绍了许多不同的方法,您可以定制您的 Git 安装,以更有效地工作并支持任意的工作流和约束。

我们讨论了配置文件如何允许我们拥有全局、用户和存储库本地配置,以及我们如何使用这些配置来扩展 Git 功能。

我们构建了自己的快捷方式,并使用别名调用外部命令。我们研究了 Git 属性,以及如何使用它们来调整 Git 的默认性能和完全改变 Git 的基本功能。我们介绍了如何使用 mergetools 获得定制的合并体验。最后,我们讨论了如何使用钩子来促进我们的工作流,从而干扰标准的 Git 流。

八、额外的 Git 特性

在这一章中,我有一个 Git 特性的可爱组合给你——这些特性我找不到任何地方可以放。它们最终出现在这里的原因可能是,在它们原本适合的地方,我们没有建立正确的心智模型,或者它们与本书的其余内容略有关联。这些功能可能对你的工作有所帮助,但不应该在日常生活中使用。意识到他们的存在可能会让你陷入可怕的境地,而他们正是你需要的。我们将介绍如何使用 Git 二等分来找出具体的提交引入了差异。我们使用 Git 子模块来管理存储库之间的依赖关系。我们将使用 Git 大文件存储或简称 Git LFS。如果你能走到这一步,恭喜你。您已经完成了实用 Git 课程并掌握了基础知识。剩下的就是锦上添花了。

吉毕斯

在一个完美的世界中,我们可以在每次提交时运行快速测试,让我们知道我们是否引入了错误,破坏了现有的功能。不幸的是,这似乎是一个乌托邦式的愿景。事实上,我们很少有完美的测试,即使我们有广泛的测试覆盖面,也不能保证没有错误从我们的网络中溜走。它也可以是一个非破坏性的变化,比如一个元素改变了颜色,这很难用有意义的方式进行测试。在这些情况下,我们可以手动恢复更改,以补救不需要的更改。但是这既繁琐又容易出错。此外,这种变化可能有一个有效的理由。因此,能够找到引入变更的提交是很有价值的。

最直接的策略是从处于健康状态的最近提交开始,一次检查一个提交。对于每一次提交,仔细检查并确定是否是提交引入了测试。在某个时刻,我们找出了引入重大变更的提交,并且我们可以恢复该提交。如果幸运的话,我们可以在每次提交中运行测试来验证给定提交的质量。最坏的情况是,我们需要检查好的和坏的提交之间的所有提交,这是一项令人厌倦和艰巨的任务。这种线性策略如图 8-1 所示。可以有一些小的改进,比如从最新的提交开始,如果你认为这是你正在寻找的最近的改变,但是,这可能需要很长时间。

img/495602_1_En_8_Fig1_HTML.jpg

图 8-1

以线性方式搜索历史

幸运的是,Git 为我们提供了一种更好的方法来找到罪魁祸首。你可能听说过二分搜索法。二进制是在排序列表中查找元素的一种更好的方法。当我们在时间中搜索时,我们可以将提交历史视为一个排序列表。二分搜索法递归地查看中间的元素,以确定想要的元素是在列表的左半部分还是右半部分。当我们坚持这样做时,它会很快产生想要的元素。对于历史悠久的人来说,这种表演特别有吸引力。线性地遍历 1000 个元素需要很长时间,并且最坏的情况是遍历所有的元素。使用二分搜索法,我们可以保证在最多 11 次迭代后找到该元素。这是一个巨大的差异!图 8-2 显示了在历史中跳跃寻找突破性的变化。

img/495602_1_En_8_Fig2_HTML.jpg

图 8-2

像二分搜索法一样穿越历史

跟踪我们在哪里以及承诺调查什么是乏味的。Git 用命令二等分对此进行检测。

Git 二等分的工作原理是将一个提交标记为坏的,一个标记为好的。然后,平分将反复检查我们可以标记为好或坏的提交,平分将继续,直到明确哪个提交是第一个坏提交。

GIT BISECT EXERCISE

在这个练习中,我们将练习二分法。它可以在二分文件夹的 katas 存储库中找到。在本练习中,我们剩下 50 个文件中的 100 个提交和更改。想弄清楚这是什么时候坏的可不是件容易的事!幸运的是,我们有一个脚本可以验证提交是否被破坏,所以我们将使用二等分来浏览历史。

$ . setup.sh
<Truncated>

$ git bisect start

开始对分之后,我们需要将一个提交标记为好的,一个标记为坏的。这为我们的搜索设置了端点。我们找到我们想要标记为好的标签,而将 HEAD 标记为坏的。

$ git tag
initial-commit

$ git bisect good initial-commit

$ git bisect bad
Bisecting: 49 revisions left to test after this (roughly 6 steps)
[9d7c0188ea01453068cab551cd07bc2f52cb4a44] 50

现在我们已经标记了搜索的端点,Git 检查出我们需要验证的第一个提交。我们使用练习文件夹中的脚本test.sh来验证提交。根据测试结果,我们将提交标记为好或坏,并继续验证 Git 提供给我们的提交。

$ ./test.sh
test failed

$ git bisect bad
Bisecting: 24 revisions left to test after this (roughly 5 steps)
[7ff73ce2a82182eaa46e7239e093b976b851c2fc] 25

$ ./test.sh
test failed

$ git bisect bad
Bisecting: 12 revisions left to test after this (roughly 4 steps)
[1bb261b8f8d9549430af7c93e27c54a25abee63d] 12

$ ./test.sh
test passed

$ git bisect good
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[c3b042dd17d10492c94d2544ec36982637efef36] 18

$ ./test.sh
test passed

$ git bisect good
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[a604e7c7d423c6271925d1f7431cdbaa0c069a5a] 21

$ ./test.sh
test passed

$ git bisect good
Bisecting: 1 revision left to test after this (roughly 1 step)
[878630d3e906eb6e262f58d16b5611c79313ba91] 23

$ ./test.sh
test failed

$ git bisect bad
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[819fa50314086a1e031427704e7bbc9419375cfd] 22

$ ./test.sh
test failed

$ git bisect bad
819fa50314086a1e031427704e7bbc9419375cfd is the first bad commit
commit 819fa50314086a1e031427704e7bbc9419375cfd
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Sun Aug 2 21:15:32 2020 +0200

    22

 22.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 22.txt

虽然这很乏味,但我们毫不含糊地找到了错误的提交,并且很好地保证了我们需要测试的最大提交量。

幸运的是,因为我们提供了一个测试,所以我们可以使用 git 二分运行更有效地完成这项工作。

$ git bisect reset
Previous HEAD position was 819fa50 22
Switched to branch 'master'

$ git bisect start

$ git bisect good initial-commit

$ git bisect bad
Bisecting: 49 revisions left to test after this (roughly 6 steps)
[9d7c0188ea01453068cab551cd07bc2f52cb4a44] 50

在 Git 为我们提供初始提交来验证之后,我们将测试脚本传递给二分法,而不是手动完成。

$ git bisect run './test.sh'
running ./test.sh
test failed
Bisecting: 24 revisions left to test after this (roughly 5 steps)
[7ff73ce2a82182eaa46e7239e093b976b851c2fc] 25
running ./test.sh
test failed
Bisecting: 12 revisions left to test after this (roughly 4 steps)
[1bb261b8f8d9549430af7c93e27c54a25abee63d] 12
running ./test.sh
test passed
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[c3b042dd17d10492c94d2544ec36982637efef36] 18
running ./test.sh
test passed
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[a604e7c7d423c6271925d1f7431cdbaa0c069a5a] 21
running ./test.sh
test passed
Bisecting: 1 revision left to test after this (roughly 1 step)
[878630d3e906eb6e262f58d16b5611c79313ba91] 23
running ./test.sh
test failed
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[819fa50314086a1e031427704e7bbc9419375cfd] 22
running ./test.sh
test failed
819fa50314086a1e031427704e7bbc9419375cfd is the first bad commit
commit 819fa50314086a1e031427704e7bbc9419375cfd
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Sun Aug 2 21:15:32 2020 +0200

    22

 22.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 22.txt
bisect run success

使用 run,我们很容易找到违规的提交。在许多情况下,拥有。这个练习展示了我们如何顺利地找出引入给定变更或 bug 的特定提交。

Git 二等分是一个优秀的特性,但是它要求您关心您创建的历史。如果您在一次提交中捆绑了太多更改,那么仍然很难找出是哪个提交的特定部分引入了坏的更改。理想情况下,您将能够恢复错误的提交,因为更改是原子性的。当您没有很多合并和分支级别时,Git 二等分也更容易使用。因此,如果使用 rebase 而不是 merges,使用等分会更容易。

子模块 Git

开发软件的一个普遍问题是处理依赖性和使用他人的代码。无论这些代码是开源的、公开的还是私有的,如何以可追踪的方式将这些代码放入您的工作空间都是一个挑战。根据您选择的编程语言的生态系统,有一些首选的解决方案。Python 有 pip 包,JavaScript npm,还有。NET NuGet 包,很多语言都有自己的。原生包管理应该是跨代码库共享代码的首选解决方案。在某些情况下,这样的解决方案可能不会出现。例如,C 和 C++没有自带的依赖管理解决方案。在这些场景中,我们可以转向 Git 子模块来跨代码库共享代码。由于 Git 是语言无关的,所以它应该是我们的后备解决方案,而不是默认方案。默认使用 Git 子模块进行依赖管理会导致您错过集成生态系统的好处。

使用 Git 子模块,我们可以添加其内容应该来自不同存储库的文件夹。Git 子模块使用一个名为。gitmodules 跟踪子模块的路径。这允许 Git 将该文件夹的内容恢复到我们远程添加的内容。例如,如果我们想将 Git katas 存储库作为依赖项添加到我们的存储库中,我们可以运行命令 git submodule add git@github.com:eficode-academy/git-katas katas。运行该命令后,文件夹 katas 包含 kata repositorycc 上主分支的内容。如果我们看看。gitmodules,看起来如下。

$ cat .gitmodules
[submodule "katas"]
        path = katas
        url = git@github.com:eficode-academy/git-katas

Listing 8-1Content of .gitmodules after adding submodule

请注意,这与手动将 katas 存储库放入另一个 Git repo 中非常不同,这是我们永远不应该做的事情。.gitmodules文件允许我们在遥控器的其他克隆中重新建立这种依赖关系。Git 子模块配置存在于.git/confi g 中,但是由于它不能跨远程共享,我们需要初始化子模块,以便在新的克隆上从.gitmodules重新创建配置。该初始化或者由git submodule init接着由git submodule update完成,或者由git submodule update --init完成。除非您需要定制子模块位置,否则最好选择后者。Init 将配置恢复到.git/config,而 update 将内容检出到该路径。

Note

使用子模块的挑战之一是跟踪您当前试图在哪个项目中做出改变。这是外部项目还是内部项目的变更?除了深思熟虑什么样的改变属于哪里,并在交付时集中精力之外,没有其他方法可以帮助我们。

SUBMODULE EXERCISE

在这个练习中,我们将练习 Git 子模块形。我们展示了如何添加子模块以及向外部和内部存储库交付变更的工作流。子模块卡塔在文件夹submodules/的卡塔中。

首先,我们设置练习。

cd submodules/
$ ls
README.md  setup.ps1  setup.sh
$ . setup.sh
<Truncated>
$ ls
component  product  remote

我们注意到三个文件夹,每个都是一个 Git 存储库。我们有我们正在制造的产品。文件夹 remote 表示组件在像 GitHub 这样的存储库管理器中的存在。组件文件夹代表子模块开发人员的本地工作文件夹。

我们做的第一件事是将组件添加到我们的产品中。

$ cd product/

/product$ ls
product.h

/product$ git submodule add ../remote include
Cloning into '/home/randomsort/repos/git-katas/submodules/exercise/product/include'...
done.
/product$ ls
include  product.h
/product$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   .gitmodules
        new file:   include

我们观察到两个路径发生了变化:跟踪子模块的.gitmodules文件和添加子模块的路径。

在 include 中,模块的内容是存在的。

/product$ ls include
component.h
/product$ cd include
/product/include$ git status
On branch master
Your branch is up to date with 'origin/master'.

nothing to commit, working tree clean

子模块的状态是干净的,即使我们的根存储库是脏的。这是子模块的棘手之处之一。

/product/include$ cd ..
/product$ git diff --cached
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..79d5c92
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "include"]
+       path = include
+       url = ../remote
diff --git a/include b/include
new file mode 160000
index 0000000..3aecaf4
--- /dev/null
+++ b/include
@@ -0,0 +1 @@
+Subproject commit 3aecaf441cca7d98cbec906bf7bf61902fcd41ee

除了+Subproject commit <hash>行之外,产品存储库中的差异与我们基于上一步的预期相匹配。

/product$ cat .gitmodules
[submodule "include"]
        path = include
        url = ../remote

然而,当我们查看.gitmodules文件时,没有任何信息让我们知道我们将哪个提交添加到了我们的产品存储库中。这是因为 Git 将对象引用直接存储在内部数据库中,作为树对象中的提交列表。我们将在下一章介绍提交是如何构造的以及树是什么样子的。

现在,我们将我们的变更提交给产品存储库,也就是说,添加子模块。

/product$ git commit -m "Add component"
[master f7a101d] Add component
 2 files changed, 4 insertions(+)
 create mode 100644 .gitmodules
 create mode 160000 include
/product$ cd ..

让我们继续,在远程子模块内部创建一个更改。由于子模块本身是一个完全普通的 Git 存储库,这里没有什么新的东西。

$ cd component
/component$ ls
component.h
/component$ git status
On branch master
Your branch is up to date with 'origin/master'.

nothing to commit, working tree clean
/component$ echo "important change" > file
/component$ git add file
/component$ git commit -m "important change"
[master 19451c0] important change
 1 file changed, 1 insertion(+)
 create mode 100644 file
/component$ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean
/component$ git push
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 298 bytes | 149.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To /home/randomsort/repos/git-katas/submodules/exercise/remote
   3aecaf4..19451c0  master -> master
/component$ cd ..

我们发布了对遥控器的更改。让我们从产品的角度来看看它是怎样的。

$ cd product
/product$ git status
On branch master
nothing to commit, working tree clean

我们的主分支是干净的,所以我们没有检测到子模块的变化。

/product$ git submodule foreach 'git status'
Entering 'include'
On branch master
Your branch is up to date with 'origin/master'.

nothing to commit, working tree clean

即使在那里浏览子模块和运行状态对我们也没有帮助。我们需要进入子模块。

/product$ cd include
/product/include$ git pull
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From /home/randomsort/repos/git-katas/submodules/exercise/remote
   3aecaf4..19451c0  master     -> origin/master
Updating 3aecaf4..19451c0
Fast-forward
 file | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 file
/product/include$ ls
component.h  file

虽然这是可行的,并且我们可以使用git submodule foreach来迭代我们的每一个库,但是我们在产品中引入什么样的变化变得不那么透明了。

/product/include$ cd ..
/product$ git status
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   include (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

更新子模块后,我们可以看到,从产品的有利位置来看,有一个变化。使用 git diff,我们可以看到从跟踪一个提交到另一个提交的变化。我们将这一变化应用到我们产品中。

/product$ git diff
diff --git a/include b/include
index 3aecaf4..19451c0 160000
--- a/include
+++ b/include
@@ -1 +1 @@
-Subproject commit 3aecaf441cca7d98cbec906bf7bf61902fcd41ee
+Subproject commit 19451c07652a282a71eeb7d953d9d807c66284a8

/product$ git add .
/product$ git commit -m "Update include"
[master ebb028e] Update include
 1 file changed, 1 insertion(+), 1 deletion(-)

有了这样更新的产品,我们可以利用将子模块作为适当的 Git 存储库嵌入到产品中的优势。这是一个强大的特性,因为我们可以在使用它的产品环境中开发我们的子模块。它的缺点是,当您在哪个存储库中工作时变得更加难以辨别,并且如果一个子模块在多个产品中使用,那么在单个特定产品的上下文中进行开发可能不是一个好主意。

/product$ cd include/
/product/include$ ls
component.h  file
/product/include$ git mv file file.txt
/product/include$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        renamed:    file -> file.txt

/product/include$ git commit -m "Add file extension to file"
[master d9ba324] Add file extension to file
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename file => file.txt (100%)
/product/include$ git push
Counting objects: 2, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 285 bytes | 285.00 KiB/s, done.
Total 2 (delta 0), reused 0 (delta 0)
To /home/randomsort/repos/git-katas/submodules/exercise/remote
   19451c0..d9ba324  master -> master

我们已经从嵌入在我们产品中的 Git 存储库中交付了一个子模块的变更。接下来,我们从 product 文件夹中克隆第二个产品,以显示如果您不添加子模块,而是克隆一个已经使用子模块的存储库时的外观。

/product/include$ cd ..
/product$ cd ..
$ git clone product product_alpha
Cloning into 'product_alpha'...
done.

$ cd product_alpha/
/product_alpha$ ls
include  product.h
/product_alpha$ ls include/

在我们新克隆的存储库中,存在 include 文件夹,但是它是空的。下面的日志语句显示了我们确实在项目存储库上有最新的提交。所以问题一定出在子模块本身。

/product_alpha$ git log
commit ebb028e42833ba80df82f1694257e646d26436d1 (HEAD -> master, origin/master, origin/HEAD)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Tue Aug 4 20:57:06 2020 +0200

    Update include

commit f7a101df8286b36cd2abee11cd878306c5b89a7b
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Tue Aug 4 20:50:24 2020 +0200

    Add component

commit 53e5bf7ed2455e9aa578ff1f9a7bdd7a09eb4c21
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Tue Aug 4 20:47:44 2020 +0200

    Touch product header

在使用子模块克隆存储库之后,我们首先需要初始化子模块。需要初始化才能正确填充本地存储库配置。

/product_alpha$ git submodule init
Submodule 'include' (/home/randomsort/repos/git-katas/submodules/exercise/remote) registered for path 'include'
/product_alpha$ ls include

仍然令人沮丧的空 include 目录告诉我们,仅仅初始化子模块是不够的。我们使用update来检查相关路径的子模块。

/product_alpha$ git submodule update
Cloning into '/home/randomsort/repos/git-katas/submodules/exercise/product_alpha/include'...
done.
Submodule path 'include': checked out '19451c07652a282a71eeb7d953d9d807c66284a8'
/product_alpha$ ls include
component.h  file

所以我们没有得到子模块的最新变化,因为我们有file而不是file.txt

/product_alpha$ cd ..
$ cd product
/product$ git status
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   include (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

正如我们所看到的,仅仅因为我们在产品的上下文中对子模块进行了更改,并不能保证产品反映了这种更改。这个陷阱是使用子模块的另一个警告。拥有其他版本控制系统(比如 ClearCase)经验的人可能会有一种直觉,我们可以跨多个存储库原子地交付单个变更,但是这在 Git 中是不可能的。虽然可能感觉不像,但是子模块和使用子模块的产品中的变化是完全独立的,不能作为一个事务来完成。

因此,让我们将变更提交给产品存储库中的子模块版本。

/product$ git add .
/product$ git commit -m "update submodule"
[master 6102bac] update submodule
 1 file changed, 1 insertion(+), 1 deletion(-)
/product$ cd ..
$ cd product_alpha/
/product_alpha$ git submodule update
/product_alpha$ git pull
remote: Counting objects: 2, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (2/2), done.
From /home/randomsort/repos/git-katas/submodules/exercise/product
   ebb028e..6102bac  master     -> origin/master
Updating ebb028e..6102bac
Fast-forward
 include | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
/product_alpha$ ls include
component.h  file
/product_alpha$ git submodule update
Submodule path 'include': checked out 'd9ba3247bb58bfc4f36ed3d6fa60781b0b32a5e1'
/product_alpha$ ls include/
component.h  file.txt

这里,我们再次注意到从子模块获得变更的两步方法。首先,我们更新对子模块的引用,然后我们让本地路径反映子模块在该引用处的内容。

本练习指导您使用子模块。正如您所看到的,这个工具非常容易使用。子模块的困难来自非平凡的使用,在这种情况下很难跟踪正在发生的事情。

这一节已经介绍了 Git 子模块,所以您现在应该对它们的工作方式以及可以用它们做什么有所了解。如果您正在使用的框架有可用的工具,我仍然推荐使用原生依赖管理工具。

Git 大文件存储

Git 擅长管理文本文件,这是 Git 不太适合存储二进制文件的礼貌说法。特别是大型二进制资产,要缴纳 Git 税。这是由 Git 的离线功能造成的,Git 的分布式特性将所有版本放在我们的每个克隆中。这会导致大量的带宽和存储使用,这可能会使 Git 运行缓慢。

当人们想在 Git 中存储二进制资产时,我的第一反应是告诉他们不要这样做。在一般情况下,在 Git 中存储二进制资产是一种变通方法,而不是真正的解决方案。适当的工件管理策略和二进制存储库管理器,比如 JFrog Artifactory 或 Sonatype Nexus,通常是最好的解决方案。在某些情况下,将二进制资产保存在 Git 中是有用的,如果有必要,在我看来,唯一正确的方法是使用 Git LFS。使用 Git LFS 的工作流程的主要成本是你不再能真正离线工作。根据连接性和二进制资产的大小,这是一个比五年或十年前更小的问题。

如今,大多数安装程序都捆绑了 Git LFS。你可以用命令git lfs测试你是否安装了它。如果它不出错,你的电脑上就有了 Git LFS。如果缺少 Git LFS,可以从 https://git-lfs.github.com/ 下载安装。

履行

虽然对日常用户来说是不可见的,但我相信理解 Git LFS 实现的形状有助于直觉地判断你的工作流的哪些部分将从非 Git LFS 工作流中发生根本的变化。Git LFS 使用了我们之前讨论过的一些特性,即过滤器和 Git 属性。

Git LFS 使用 Git 属性来跟踪哪些路径应该通过 LFS 来处理,而不是 Git 的普通持久性模型。过滤器用于用来自 Git LFS 的读写操作替代普通 Git 的读写操作。

为了能够使用 Git LFS,您正在使用的存储库管理器需要支持它。大型 Git 存储库管理器支持开箱即用的 Git LFS。有些人需要二级存储来存放大文件,而有些人能够独立维护这些文件。请查阅特定存储库管理器的文档。

当您使用 Git LFS 跟踪路径时,会发生的情况是,它不会将完整的二进制对象写入存储库,而是写入一个空的虚拟文件。当提交被推送时,它不是直接被推送到存储库,而是被卸载到由中央主机上的存储库配置所定义的辅助存储。当您签出一个被跟踪的路径时,Git LFS 会在必要时从二级存储中下载该文件,然后将该文件签出到给定的路径。除了在切换到以前未签出的版本时无法在脱机模式下工作之外,这将完全透明地运行。图 8-3 显示了该工作流程。

img/495602_1_En_8_Fig3_HTML.jpg

图 8-3

Git LFS 流显示在推送期间上传到二级存储,在结帐期间下载

因此,Git 不会在获取时检索所有对象的所有提交,而是在给定的签出需要之前不获取某些对象。

用 Git LFS 跟踪文件

在这一节中,我们将介绍如何使用添加到 Git LFS 中的新文件。稍后,我们将介绍如何从您的存储库中移除大型资产,并将它们转移到 Git LFS。最初,我们需要运行命令 git lfs install 来初始化 git lfs。每个本地存储库只应执行一次。完成这些之后,我们可以使用 git lfs track path 向 track 添加路径。这将在。gitattributes 文件,带有相关属性。通常,我们想要跟踪路径的模式,而不是具体的路径。这消除了我们显式添加我们想要单独跟踪 LFS 的所有二进制资产的需要。所以我们宁愿用 git lfs track *。iso than git lfs track image.iso

运行 git lfs track *命令后。国际标准化组织。gitattributes 文件应该包含以下内容:

*.iso filter=lfs diff=lfs merge=lfs -text

这意味着无论何时有人向我们的库添加 ISO,它都会被 Git LFS 处理。假设您的遥控器支持 Git LFS,这就是您需要做的全部工作。

正如我们前面所提到的,提交是不可变的,所以这并没有清除之前添加到存储库中的二进制资产。我们将在下一节中介绍如何找到它们并清理它们。

你去吧

感觉使用您的一个存储库很笨拙是很常见的。通常,我们甚至知道是什么让存储库工作起来很麻烦。但是,如果我们要做一项巨大的事业,比如清理我们的仓库,我们不应该凭直觉去做,我们应该基于数据库去做。幸运的是,有免费的工具可以帮助我们调查我们的存储库。一个这样的工具是 git-sizer https://github.com/github/git-sizer 。git-sizer 允许我们分析存储库并报告大 git 存储库的常见问题。清单 8-2 显示了分析 DevOpsDays 资产存储库的快照。尽管它主要包含二进制资产,这是存储库太大的一个常见原因,Git sizer 只报告了一个有问题的资产。这表明,如果操作得当,Git 可以明智地用于资产。DevOpsDays web 团队还将二进制资产从代码库中分离出来,以便于使用。

/pg-lfs$ ~/git-sizer

Processing blobs: 5
Processing trees: 4
Processing commits: 4
Matching commits to trees: 4
Processing annotated tags: 0
Processing references: 3
| Name                         | Value     | Level of concern               |
| ---------------------------- | --------- | ------------------------------ |
| Biggest objects              |           |                                |
| * Blobs                      |           |                                |
|   * Maximum size         [1] |  81.6 MiB | ********                       |

[1]  6660801deb787c5d0fa941801c73dd573381c4c6 (refs/heads/master:alpine-rpi-3.12.0-armv7.tar.gz)

Listing 8-2Report from Git sizer

这个报告对于确定我们是否可以处理存储库的特定方面是很有用的。git-sizer 存储库的 README 包含一些针对不同 git 存储库大小问题的补救措施。在我们的例子中,我们正在寻找有问题的二进制资产,现在我们知道如何使用 git-sizer 来定位它们,我们继续使用 BFG 回购清理器将这些文件移动到 Git LFS。

将存储库转换为 Git LFS

现在,我们可以检测到已经存在于我们的存储库中的有问题的文件,我们准备清理存储库,并使它在我们的工作流中更加有效。

我们可以使用 BFG 回购清除不需要的文件从我们的历史。这些不需要的数据可能是我们不希望在历史中出现的敏感信息,或者是我们不应该添加的更常见的二进制资产,或者是随着时间的推移变得有问题的二进制资产。

Caution

我们现在正进入潜在的危险地带。只要我们小心,这些操作应该是安全的,但是可能会发生潜在的破坏性的、不可恢复的情况。然而,如果我们深思熟虑,谨慎行事,我们可以避免任何意外事件。

我们可以使用 Git LFS 重写我们的历史,并添加有问题的路径到 Git LFS。

CONVERT TO LFS

这个练习包括从 GitHub 派生一个存储库并在其中工作,所以在您的终端上完成它,无论您将存储库放在哪里。首先转到 https://github.com/randomsort/practical-git-lfs 并为您的帐户创建一个该存储库的分支。在本练习中,我从分叉pg-lfs开始工作。注意,这个练习需要一个支持 Git LFS 的遥控器。GitHub 可以做到这一点,但是你可能需要在你的设置页面上启用它。

首先,在本练习中,我克隆了我工作的存储库。将 URL 替换为您的个人分叉。

$ git clone git@github.com:randomsort/pg-lfs
Cloning into 'pg-lfs'...
remote: Enumerating objects: 13, done.
remote: Total 13 (delta 0), reused 0 (delta 0), pack-reused 13
Receiving objects: 100% (13/13), 81.64 MiB | 11.28 MiB/s, done.
Resolving deltas: 100% (2/2), done.

从打印的终端输出来看,这并不明显,但是这花费了很长很长的时间,我们知道这会扼杀开发人员的生产力和积极性。所以我们看看能不能找到问题。

$ cd pg-lfs
/pg-lfs$ ls
LICENSE  README.md  alpine-rpi-3.12.0-armv7.tar.gz

我们注意到有一个 large 文件,而且 Git 文件夹与这么小的存储库相比是很大的。我们运行git-sizer来找出是否有任何问题。

/pg-lfs$ du -s -h .git
82M     .git
/pg-lfs$ ~/git-sizer

Processing blobs: 5
Processing trees: 4
Processing commits: 4
Matching commits to trees: 4
Processing annotated tags: 0
Processing references: 3
| Name                         | Value     | Level of concern               |
| ---------------------------- | --------- | ------------------------------ |
| Biggest objects              |           |                                |
| * Blobs                      |           |                                |
|   * Maximum size         [1] |  81.6 MiB | ********                       |

[1]  6660801deb787c5d0fa941801c73dd573381c4c6 (refs/heads/master:alpine-rpi-3.12.0-armv7.tar.gz)

git-sizer的输出中,我们看到至少有一个tar.gz文件有问题。我们决定将tar.gz文件存储在 Git LFS 中,而不是直接存储在 Git 仓库中。为此,我们可以使用git lfs migrate工具。我们传递希望 Git LFS 处理的模式和引用。

/pg-lfs$ git lfs migrate import --include="*.tar.gz" --include-ref=master
migrate: Sorting commits: ..., done
migrate: Rewriting commits: 100% (4/4), done
  master        9a3d24f44a28e5f514633b834afbe6022062febe -> 873439a4869e29b388027465e2a488d68c977df2
migrate: Updating refs: ..., done
migrate: checkout: ..., done
/pg-lfs$ git status
On branch master
Your branch and 'origin/master' have diverged,
and have 4 and 4 different commits each, respectively.
  (use "git pull" to merge the remote branch into yours)

nothing to commit, working tree clean

Git 状态告诉我们,我们有所有不同的提交,我们的工作目录是干净的。在这个场景中,这表明我们与我们的远程没有共同的提交。

/pg-lfs$ ls -al
total 4
drwxrwxrwx 1 randomsort randomsort  512 Aug  4 22:06 .
drwxrwxrwx 1 randomsort randomsort  512 Aug  4 22:03 ..
drwxrwxrwx 1 randomsort randomsort  512 Aug  4 22:06 .git
-rw-rw-rw- 1 randomsort randomsort   45 Aug  4 22:06 .gitattributes
-rw-rw-rw- 1 randomsort randomsort 1080 Aug  4 22:03 LICENSE
-rw-rw-rw- 1 randomsort randomsort  287 Aug  4 22:03 README.md
-rw-rw-rw- 1 randomsort randomsort  133 Aug  4 22:06 alpine-rpi-3.12.0-armv7.tar.gz
randomsort@DESKTOP-3196DO6:~/repos/lfs2$ cat .gitattributes
*.tar.gz filter=lfs diff=lfs merge=lfs -text

Git LFS 迁移为.gitattributes添加了正确的条目,具有追溯性。我们对存储库的状态感到满意,并将其推送到远程。

/pg-lfs$ git push --force
Counting objects: 14, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (14/14), 2.34 KiB | 2.34 MiB/s, done.
Total 14 (delta 3), reused 14 (delta 3)
remote: Resolving deltas: 100% (3/3), done.
remote: This repository moved. Please use the new location:
remote:   git@github.com:RandomSort/pg-lfs.git
To github.com:randomsort/pg-lfs
 + 9a3d24f...873439a master -> master (forced update)

强制推进不应该从容不迫地进行,如前所述,我们应该使用--force-with-lease,但这在这种情况下不起作用,因为我们没有共同的历史。在推送到遥控器后,我们克隆到一个单独的位置,看看是否节省了空间。

/pg-lfs$ cd ..
$ git clone git@github.com:randomsort/pg-lfs lfs2
Cloning into 'lfs2'...
remote: Enumerating objects: 10, done.
remote: Counting objects: 100% (10/10), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 14 (delta 3), reused 10 (delta 3), pack-reused 4
Receiving objects: 100% (14/14), done.
Resolving deltas: 100% (3/3), done.
$ cd lfs2
/lfs2$ du -s -h .git
48K     .git
/lfs2$ ls
LICENSE  README.md  alpine-rpi-3.12.0-armv7.tar.gz

尽管我们的工作空间看起来是一样的,但是我们的 Git 存储库只是它的一小部分。48K 与 82M 相比,这是一个我们不经历就无法理解的差异。这将对开发人员的生活质量产生影响,并对自动化产生影响。

记得删除你的 fork,这样就不会占用 GitHub 不必要的资源:)。

这个练习展示了,如果在大小方面对您不利,那么将存储库的一部分切掉是多么容易。

去吧卡塔

为了支持本章的学习目标,请完成以下表格:

  • 把…分为两个部分

  • 子模块

摘要

在这一章中,我们学习了如何使用子模块管理依赖关系,如何使用 Git 二等分有效地发现错误的变更集,以及如何使用 Git LFS 从我们的存储库中删除有问题的资产。

我真诚地希望这些都不会对你的日常工作有所帮助,因为它们只是一些小例子。但是现在您意识到了是否需要这些专门的 Git 特性。

九、Git 内部原理

随着这本书接近尾声,我将用几页的篇幅讲述 Git 的一些内部知识,以帮助巩固心智模型并揭开 Git 的秘密。本章的目的不是详尽无遗,也不允许你成为 Git 的贡献者,尽管我鼓励每个人考虑为开源做贡献。我们将打开 Git 的盖子,看看一些组件是如何连接在一起的,这样我们就可以更好地推理发生了什么,如果最坏的情况发生,我们可以深入挖掘。

Git 图

在基本层面上,Git 是一个带有标签的提交图。这个图就是所谓的有向无环图,有一些有趣的性质。图论是计算机科学中一个成熟且被广泛研究的领域。Git 的许多基础算法都来自图论领域。

一个图被定义为一组顶点和它们之间的边。在 Git 中,顶点被实现为提交,边被表示为提交中的父指针。图 9-1 显示了一个带有边和顶点的 Git 图。图是有向的意味着每条边都有一个方向,因此可以认为是一个箭头。非循环意味着提交图中没有循环。因此,不可能通过跟随父指针出站来返回提交。这对 Git 使用的算法的有效性有影响。我们不打算深入研究图论背景,但在相关的地方,我会在本章介绍这个术语。

img/495602_1_En_9_Fig1_HTML.jpg

图 9-1

Git 提交图,其中包含父指针。添加了两个分支和头

承诺

在前面的章节中,我们已经将提交视为基本的原子单位,Git 最基本的部分。但是就像一个原子可以分解成中子、质子和电子一样,我们可以把一个承诺分解成它的复合元素。由于我们已经对工作区进行了版本控制,并讨论了这些版本如何与另一个版本相关联,所以提交是一个适当的抽象级别。提交也是我们在软件开发期间正常 Git 操作中使用的抽象层次。

提交由元数据组成,如 ID、作者、消息、时间戳和父指针。提交还包含一个指向树的指针,树是 Git 用来存储工作目录状态的数据结构。提交数据结构如图 9-2 所示。

img/495602_1_En_9_Fig2_HTML.jpg

图 9-2

提交中包含的数据

提交数据结构的一个关键部分是 ID,即给定提交的唯一标识符。Git 根据提交的内容确定性地生成这些惟一的 id。Git 通过散列函数来实现这一点。散列函数具有这样的特性:如果它们的输入发生变化,输出会变成什么样是不可预测的。我们可以有把握地假设,如果两个提交具有相同的 ID,则它们具有相同的内容,因此是相同的提交。Git 将.git/objects/中的对象存储在一个名为 ID 前两个字符的文件夹中,一个名为 ID 最后 38 个字符的文件中。提交c70be832f3c02582ed3b587b282aa1c034f5dc1b因此存在于文件夹中。文件0be832f3c02582ed3b587b282aa1c034f5dc1b中的 git/objects/c7/获取.git/objects/c7/0be832f3c02582ed3b587b282aa1c034f5dc1b的完整路径。

img/495602_1_En_9_Fig3_HTML.jpg

图 9-3

将内容散列到文件系统地址

前面的属性是 Git 有时被称为内容可寻址的原因。内容定义了 ID 以及存储内容的地址。正如我们在下一节中所讨论的,树也有一个由其内容决定的 ID,这意味着提交的 ID 是由其目录内容和元数据唯一决定的。请注意,即使在一个存储库中,也可能存在多个目录内容相同但元数据不同的提交。

提交包含所有这些信息。我们可以使用命令show来研究头部的提交。我们可以给它传递一个 ref,如果没有,它将默认为 HEAD。Show 还附加了 diff,展示了清单 9-1 中的变更集。

$ git log -1
commit 1135048cd36443eee6e28b472aa203b61997087b (HEAD -> master, origin/master, origin/HEAD)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Thu May 21 13:35:18 2020 +0200

    Add the Practical Git Bio

$ git show
commit 1135048cd36443eee6e28b472aa203b61997087b (HEAD -> master, origin/master, origin/HEAD)
Author: Johan Abildskov <randomsort@gmail.com>
Date:   Thu May 21 13:35:18 2020 +0200

    Add the Practical Git Bio

diff --git a/the-practical-git.md b/the-practical-git.md
new file mode 100644
index 0000000..7e8aac9
--- /dev/null
+++ b/the-practical-git.md
@@ -0,0 +1,11 @@
+# The Practical Git
+
+Hi,
+I am the first to submit a pull request to this repository and I am so happy to do it!
+
+I represent the book, so I am a part of an exercise, how exciting is this?!?
+
+Other than that, I hope you enjoy the book and contribute your bio to this repository!
+
+Cheers,
+The Practical Git

Listing 9-1Using git show to display information about a commit

输出包含有价值的信息,但是有些信息,比如 diff,是计算出来的。在我们想要研究原始内容的场景中,我们使用命令git cat-file。Cat-file 允许我们直接输出 Git 对象,如果我们以人类可读的格式而不是二进制格式使用标志-p。我只遇到过两种使用cat-file的方式:使用-p调查内容和使用-s检查大小。在清单 9-2 中,我展示了运行cat-file -p-s来查看提交的大小和存储在磁盘上的内容。当钩子和过滤器可能会干扰一个简单的工作空间调查时,像这样深入挖掘是有用的。

$ git cat-file -p 11350
tree e119db480900fac506e721d6560fce9ffcc0765f
parent ce866b9f738529476f87347a76b0ba69e5ff0960
author Johan Abildskov <randomsort@gmail.com> 1590060918 +0200
committer Johan Abildskov <randomsort@gmail.com> 1590060918 +0200

Add the Practical Git Bio

$ git cat-file -s 113504
250

Listing 9-2Using cat-file -p and -s to investigate a commit

在清单 9-2 中,我们看到了我们之前提到的树引用。这个树对象包含我们正在版本化的工作目录的根目录上的数据。清单 9-2 中包含的数据唯一地确定了提交 ID,目录内容唯一地确定了树 ID。

虽然我们在抽象层次上有一个 Git 提交图,在提交之间有指针,但在更具体的层次上,我们对工作目录的演变以及它们之间的关系感兴趣。树对象跟踪文件系统中的目录。

树包含路径列表以及需要恢复到该路径的对象的类型和引用。由于树也可以引用树,这允许 Git 恢复一个完整的嵌套文件夹的工作目录。路径可以引用树、blob 或提交。树表示嵌套文件夹、blobs 文件内容,并提交要在该路径实例化的子模块。清单 9-3 中显示了一个示例树清单。

$ git cat-file -p 4f66
100644 blob f5b7a1a105b79d9b0bd889c4ba9c3feba88687fc    README.md
100644 blob 9b2c04de2d845c775fa98f86fcf2bed7f0bf1549    setup.ps1
100755 blob 20cbad89573a7f1472e9bd2bcafd8441eedfecef    setup.sh
040000 tree 85d92a502a5fa0297480932721ccd07c91bb9ef6    utils

Listing 9-3A tree listing

在下一节中,我们将展示 blobs 是如何工作的,这将允许我们绘制 Git 内部数据表示的完整图像。这里有趣的一点是,blob 只包含要在给定路径上恢复的文件的内容。这意味着树列表中的名称单独负责给定文件在目录结构中的名称。这也是 Git 对文件进行重复数据消除的方式。因为相同的内容将以相同的散列 ID 结束,所以相同文件的多个副本不会占用存储库中的空间。树也驻留在.git/objects

一滴

Blobs 是文件内容存储。我们的直觉告诉我们,一个文件由一个包含名称的路径和一些内容组成。在 Git 中,处理路径和文件名信息的是树或文件夹抽象,所以留给 blob 的唯一责任是管理文件内容。Git 中的 id 是使用 sha1 或 sha256 算法通过散列内容生成的。如前所述,Git 被认为是内容可寻址的。当讨论 blobs 的地址或文件路径直接从文件内容中计算出来时,这一点可能最为明显。下面的代码显示了稍微更改一个文件会如何不可预知地更改 blob ID:

$ ls -alh
total 24K
drwxr-xr-x 1 rando 197609    0 aug 10 14:30 ./
drwxr-xr-x 1 rando 197609    0 aug 10 14:29 ../
drwxr-xr-x 1 rando 197609    0 aug 10 14:31 .git/
-rw-r--r-- 1 rando 197609 8,1K aug 10 14:31 file.txt

$ git cat-file -p HEAD
tree b8041d12e65e591d4921bc3edfc9cabc23f9565a
author Johan Abildskov <randomsort@gmail.com> 1597062696 +0200
committer Johan Abildskov <randomsort@gmail.com> 1597062696 +0200

First commit

$ git cat-file -p b8041d12e65e591d4921bc3edfc9cabc23f9565a
100644 blob 02454bc2cea1cdbce18a1cdcc39d94fad5a9777f    file.txt

$ echo " " >> file.txt

$ git add .

$ git commit -m "update file"
[master da9a6db] update file
 1 file changed, 1 insertion(+), 1 deletion(-)

$ git cat-file -p HEAD
tree 243983d2cef9f535fd2a6d728958e0b09398bf72
parent 41e1a39ebc9c3720d60945c95bd4bd7152dbc907
author Johan Abildskov <randomsort@gmail.com> 1597062777 +0200
committer Johan Abildskov <randomsort@gmail.com> 1597062777 +0200

update file

$ git cat-file -p 243983d2cef9f535fd2a6d728958e0b09398bf72
100644 blob 2f7720fb6a49470af72fbc2b56061e1871320c93    file.txt

在前面的代码中,添加一个空白字符会将 ID 从02454bc2cea1cdbce18a1cdcc39d94fad5a9777f更改为243983d2cef9f535fd2a6d728958e0b09398bf72,这两个字符串没有明显的联系。这是哈希函数的一个属性。哈希函数的另一个特性是,冲突发生的可能性很小,实际上不会发生。冲突是指两个不同的输入产生相同的输出。Git 从 sha1 迁移到 sha256 的一个原因是,面对现代计算能力,很难产生冲突。其结果是,即使文件名不同,也不可能有重复的文件内容。用不同的内容覆盖一个文件实际上也是不可能的,因为那需要一个冲突。

参考

在 Git 中,我们有三种引用类型:分支、标签和远程。除了带注释的标签,引用是轻量级的。轻量级意味着没有附加额外的信息,但它是一个简单的指针。

分支驻留在.git/refs/heads中,而标签驻留在。git/refs/tags。Remotes 出现在.git/refs/remotes中,从我们的角度来看,它可以被视为只读分支。这是因为更新它们应该总是来自从远程获取信息的操作,而不是在本地操作它们。正如我们在前面讨论分支时所讨论的,引用打破了我们对分支外观和行为的直觉和心理模型。在 Git 中,引用是标记特定提交的标签,因此比直接通过 ID 检索更容易。标签可以被认为是不移动的分支。

HEAD 是一个特殊的指针,它指向当前签出的内容。HEAD 既可以指向本地分支,也可以指向 commit。如果我们试图切换到一个远程分支或一个标签,我们将结束在分离头状态。在这种情况下,我们可能会丢失我们的工作,因为新提交在默认情况下没有对它们的引用,所以过一段时间后,它们将被垃圾收集。

下面的代码清单显示了分离的 HEAD 场景:

$ git log --oneline --decorate
da9a6db (HEAD -> master, tag: test) update file
41e1a39 First commit

$ git switch test
fatal: a branch is expected, got tag 'test'

$ git checkout test
Note: switching to 'test'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at da9a6db update file

这里的解决方案是创建一个新的指针,或者签出一个指向相关提交的现有指针。

虽然我们的分支本身不包含任何信息,但是它们包含的元信息对于可追溯性是很重要的——并且询问这样的问题,比如在我进行硬重置之前主分支在哪里。为此,我们可以使用 git reflog。如果我们传递一个对 git reflog 的引用,我们会得到一个指针如何改变的列表。然后,我们可以使用那里的引用,根据引用所在的位置来检查提交。最常见的是,我们使用像master@{1}这样的索引,这意味着主引用是在一个变更之前。还有更多抽象引用,如master@{yesterday}master@{upstream}来检查主设备的跟踪分支指向哪里。下面的屏幕截图显示了在一个简单的线性示例中使用 reflog。真正有趣的地方是更复杂的历史。

$ git reflog
da9a6db (HEAD -> master, tag: test) HEAD@{0}: checkout: moving from 41e1a39ebc9c3720d60945c95bd4bd7152dbc907 to master
41e1a39 HEAD@{1}: checkout: moving from master to master@{1}
da9a6db (HEAD -> master, tag: test) HEAD@{2}: checkout: moving from da9a6dbe39f09e98520f208e2b94ec610af1af4f to master
da9a6db (HEAD -> master, tag: test) HEAD@{3}: checkout: moving from master to test
da9a6db (HEAD -> master, tag: test) HEAD@{4}: commit: update file
41e1a39 HEAD@{5}: commit (initial): First commit

$ git checkout master@{1}
Note: switching to 'master@{1}'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this state without impacting any branches by switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at 41e1a39 First commit

Listing 9-4Using git reflog

to investigate where a pointer has been

请注意,reflog 是一个纯粹的本地概念,不能在同一个存储库的多个克隆之间共享。

使用树进行版本控制

我们已经讨论了所有的组成部分,所以我们有了讨论 Git 中版本控制如何工作的框架。我们有指向分支、指向提交、指向树、而树指向 blobs 和树头。虽然前面的句子是我们需要知道的全部,但它也以一种实际上可理解的格式显示在图 9-4 中。

img/495602_1_En_9_Fig4_HTML.jpg

图 9-4

底层对象的单次初始提交

在图 9-4 中,显示了具有相应 Git 对象结构的单次提交。作为文件系统的一对一映射,这看起来很了不起。当我们添加更多的提交时,真正有趣的部分发生了。Git 将尽可能多地重用已经存在的提交。鉴于 Git 的内容可寻址特性,这是免费的。这意味着只需要创建包含更改的树,因为已经存在的树将被重用,因为它们具有正确的地址。在提交操作期间,不会删除任何 blob 对象;这是一个附加的过程,只创建表示当前工作目录所需的 blobs。这种重用就是为什么 Git 没有贪婪地把你的硬盘和你周围所有不同的版本打包在一起。图 9-5 显示了改变一个文件如何创建新的树和 blob 对象,同时重用未改变的对象。

img/495602_1_En_9_Fig5_HTML.jpg

图 9-5

创建提交会重用对象和树

Note

如果工作目录中的任何文件由于被添加、删除或修改而发生变化,这将最终导致根树的变化。如果文件室树不变,目录内容也不变。Git 通常不会接受这一点,但是您可以在提交时使用标志- allow-empty 强制它这样做。

Git Internals

在这个练习中,我们从一个空的存储库开始,随着我们添加内容,慢慢地研究存储库中显示了哪些部分。本练习首先在本地创建一个空的存储库,因此您可以从有命令行的地方开始。请注意,所有的 id 都与您在练习中看到的不同,所以如果您天真地复制粘贴命令,它们不太可能成功。

我们从初始化一个空的存储库开始,四处看看我们能找到什么。

$ git init pg-internals
Initialized empty Git repository in /user/randomsort/pg-internals/.git/
$ cd pg-internals/
$ ls -al .git
total 24
drwxr-xr-x. 4 randomsort users 4096 Aug 11 13:43 .
drwxr-xr-x. 3 randomsort users 4096 Aug 11 13:43 ..
-rw-r--r--. 1 randomsort users   92 Aug 11 13:43 config
-rw-r--r--. 1 randomsort users   23 Aug 11 13:43 HEAD
drwxr-xr-x. 4 randomsort users 4096 Aug 11 13:43 objects
drwxr-xr-x. 4 randomsort users 4096 Aug 11 13:43 refs
$ ls .git/objects
info  pack

要注意的第一个有趣的部分是,除了 info 和 pack 文件夹,object 文件夹是空的。这些是用于 Git 压缩的文件夹。它们在新初始化的存储库中是空的。

$ ls .git/refs
heads  tags
$ ls .git/refs/heads

我们有 refs 文件夹,但是我们可以看到没有分支。

$ cat .git/HEAD
ref: refs/heads/master

HEAD 仍然指向主分支,即使它不存在。这是一种只需要处理棘手问题的情况。要么头不存在,要么它指向的分支不存在,要么分支指向的对象丢失。从下面的 status 命令中,我们可以看到 Git 清楚地意识到了这种情况,但是如果我们尝试签出主分支,就会得到一个错误。

$ git status
On branch master

No commits yet

nothing to commit (create/copy files and use "git add" to track)
$ git checkout master
error: pathspec 'master' did not match any file(s) known to git

让我们创建第一个提交。

$ echo "# First data" > README.md
$ git add README.md
$ git commit -m "Initial Commit"
[master (root-commit) 2fb5c2e] Initial Commit
 1 file changed, 1 insertion(+)
 create mode 100644 README.md

在创建了第一个提交之后,我们期望在.git/objects文件夹中看到三个对象:一个用于提交,一个用于树,一个用于 blob。

$ ls .git/objects
2f  bf  d4  info  pack
$ ls .git/objects/2f
b5c2e86d6f21d52d2f05b07ed524669f10d07f

不深入,我们可以看到我们有三个对象。我们可以在这里使用cat-file来检查任何给定对象的内容。我们注意到对象的 ID 是文件夹名(2f)与文件名(b5c2e86d6f21d52d2f05b07ed524669f10d07f连接在一起。类似于我们引用提交时,我们可以使用完整 ID 的唯一前缀。

$ git cat-file -p 2fb5c
tree bfd4eb4e8767678f4abfe229f7ee701ca9ee0b69
author Johan Abildskov <randomsort@gmail.com> 1597146899 +0200
committer Johan Abildskov <randomsort@gmail.com> 1597146899 +0200

Initial Commit

因此,在这个场景中,我们似乎遇到了提交对象。

现在我们用一个文件创建一个子目录,看看它是如何改变我们的对象的。

$ mkdir subdir
$ echo "important content" > subdir/file.txt
$ git add subdir/file.txt
$ git commit -m "Add important content"
[master 62b545d] Add important content
 1 file changed, 1 insertion(+)
 create mode 100644 subdir/file.txt
$ ls .git/objects
24  2f  3a  62  bf  c9  d4  info  pack

在这里,我们看到,与初始提交时的三个对象相比,我们最终获得了七个对象。我们以此结束,因为我们创建了一个新的 commit、一个新的根树对象、一个用于 subdir 的树对象和一个用于 file 的 blob 对象——再加上仍然存在的最初的三个对象。我们没有为 README.md 创建新的 blob,因为它没有改变,将被重用。我们可以再次使用 cat-file 来查看新提交的内容,注意我们有了一个新的树对象。

$ git cat-file -p HEAD
tree 3a7a21c251d2e8c05a6c1c7c2c866c4c3821e97e
parent 2fb5c2e86d6f21d52d2f05b07ed524669f10d07f
author Johan Abildskov <randomsort@gmail.com> 1597147088 +0200
committer Johan Abildskov <randomsort@gmail.com> 1597147088 +0200

Add important content

Git 过滤器和驱动程序会导致工作目录中的 Git 存储库内容与存储库中的不同。对于 Git LFS 来说是这样,但是也可能是本地配置,比如行尾。

在我们想知道 Git 中真正存储了什么的情况下,我们可以使用git ls-tree来查找给定路径在给定时间点的 blob 是什么。在下面一行中,我们告诉 Git 递归地遍历树,在修订头中搜索 subdir/file.txt。代替 HEAD,我们可以提供一个任意树或提交对象。

$ git ls-tree -r HEAD subdir/file.txt
100644 blob 24013f7d4de4b5143b03c76db8656625c00798d2    subdir/file.txt

现在我们已经修补了对象,让我们操纵一些分支。首先,我们来看看有哪些分支。

$ git branch
* master
$ ls .git/refs/heads
master
We have the master branch, and we can see it is now also present in refs/heads. We can conclude that the file representing the branch was created when we made the first commit.
Under normal circumstances we use Git to create branches, but it is trivial to do so manually.
$ cp .git/refs/heads/master .git/refs/heads/practical-git
$ git branch
* master
  practical-git

通过复制命令的魔力,我们创建了一个新的分支。这样做的结果是创建一个分支的成本非常低,因为文件包含的只是提交的 sha。HEAD 指向 master,所以让我们检查一下我们的另一个分支。

$ echo "ref: refs/heads/practical-git" > .git/HEAD
$ git status
On branch practical-git
nothing to commit, working tree clean

如我们所见,我们成功地手动切换了分支。当然有一个警告,如果分支没有指向相同的提交,我们的状态可能会非常不同。

卡塔斯

为了支持本章的学习目标,我建议您完成以下表格:

  • 调查

  • 重置(你之前做过这个练习,但是现在你应该有一个更好的基础来推理正在发生的事情。记得用 reflog!)

摘要

本章已经带你浏览了 Git 的一些内部结构,以进一步加深你对 Git 工作原理的理解。除了参考日志,你不应该再挖这么深,但我希望你喜欢这次旅行!

posted @ 2024-08-12 11:18  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报