Git

摘要

“git bisect”使软件用户和开发人员能够轻松找到引入回归的提交。我们展示了拥有良好的工具来对抗回归的重要性。我们描述了“git bisect”从外部如何工作以及它在内部使用的算法。然后,我们解释如何利用“git bisect”来改进当前做法。我们还讨论了“git bisect”将来如何改进。

“git bisect”简介

Git 是由 Linus Torvalds 创建并由 Junio Hamano 维护的分布式版本控制系统 (DVCS)。

在 Git 中,就像在许多其他版本控制系统 (VCS) 中一样,系统管理的数据的不同状态称为提交。并且,由于 VCS 主要用于管理软件源代码,因此有时在某些提交中会引入软件中“有趣”的行为更改。

事实上,人们特别感兴趣的是引入“不良”行为的提交,称为错误或回归。他们对这些提交感兴趣,因为一个提交(希望)包含一组非常小的源代码更改。当你只需要检查一组非常小的更改时,理解和正确修复问题比你一开始不知道在哪里查找要容易得多。

因此,为了帮助人们找到引入“不良”行为的提交,发明了“git bisect”命令集。当然,“git bisect”术语中,存在“有趣行为”的提交称为“不良”提交,而其他提交称为“良好”提交。引入我们感兴趣的行为的提交称为“第一个不良提交”。请注意,在我们正在搜索的提交空间中可能有多个“第一个不良提交”。

因此,“git bisect”旨在帮助找到“第一个不良提交”。为了尽可能提高效率,它尝试执行二分搜索。

对抗回归概述

回归:一个大问题

回归是软件行业中的一个大问题。但很难用一些实际数字来支持这一说法。

有一些关于一般性缺陷的数字,例如 2002 年国家标准技术研究所的一项研究 [1]

根据美国商务部国家标准技术研究所 (NIST) 委托进行的一项新发布的研究,软件缺陷或错误非常普遍且有害,每年给美国经济造成约 595 亿美元的损失,约占国内生产总值的 0.6%。在国家层面,超过一半的成本由软件用户承担,其余由软件开发人员/供应商承担。该研究还发现,虽然无法消除所有错误,但通过改进测试基础设施,可以消除超过三分之一的这些成本,即估计为 222 亿美元,从而能够更早、更有效地识别和消除软件缺陷。这些节省与在引入错误的开发阶段找到更高百分比(但不是 100%)的错误相关。目前,超过一半的错误直到开发过程中的“下游”或在售后软件使用期间才被发现。

然后

软件开发人员已经将大约 80% 的开发成本花在识别和纠正缺陷上,但除了软件之外,很少有其他类型的产品在出货时存在如此高的错误级别。

最终,结论以以下内容开头

提高软件质量的途径是显著改进软件测试。

还有其他估计称,与软件相关的 80% 的成本是维护 [2]

不过,根据维基百科 [3]

人们普遍认为维护只是修复缺陷。然而,多年来的研究和调查表明,大多数(超过 80%)的维护工作都用于非纠正性操作(Pigosky 1997)。这种看法是由用户提交的问题报告造成的,而这些报告实际上是对系统的功能增强。

但我们可以猜测,改进现有软件的成本非常高,因为你必须注意回归。至少这会让上述研究之间保持一致。

当然,有些软件在开发后,在一段时间内使用而没有得到太多改进,最后被抛弃。在这种情况下,当然,回归可能不是一个大问题。但另一方面,有很多大型软件在很多年甚至几十年的时间里被很多人持续开发和维护。由于经常有很多人(有时是关键性的)依赖于此类软件,因此回归是一个非常大的问题。

Linux 内核就是这样一个软件。如果我们查看 Linux 内核,我们可以看到,花费了大量的时间和精力来对抗回归。发布周期从为期 2 周的合并窗口开始。然后标记第一个候选版本 (rc)。此后,在最终版本发布之前,大约会出现 7 或 8 个更多 rc 版本,每个版本之间大约间隔一周。

第一个 rc 版本和最终版本之间的这段时间应该用于测试 rc 版本并修复错误,尤其是回归。这段时间超过了发布周期时间的 80%。但这还不是对抗的结束,因为它当然会在发布后继续。

然后,这是 Ingo Molnar(一位著名的 Linux 内核开发者)关于他如何使用 git 二分法的说法

我在合并窗口期间最积极地使用它(当很多树合并到上游并且错误涌入量最高时)——是的,有些情况我每天都使用它多次。我的平均值大约是每天一次。

因此,开发人员一直在对抗回归,事实上,众所周知,应该尽快修复错误,以便在发现错误后尽快修复。这就是为什么为此目的拥有好工具很有趣的原因。

对抗回归的其他工具

那么,对抗回归使用的工具是什么?它们与用于对抗常规错误的工具几乎相同。唯一特定的工具是测试套件和类似于“git 二分法”的工具。

测试套件非常好。但是当单独使用它们时,它们应该在每次提交后检查所有测试。这意味着它们效率不高,因为许多测试运行后没有得到有趣的结果,而且它们会受到组合爆炸的影响。

事实上,问题在于大型软件通常有许多不同的配置选项,并且每个测试用例都应该在每次提交后通过每个配置。因此,如果您对每个版本有:N 个配置、M 个提交和 T 个测试用例,您应该执行

N * M * T tests

其中 N、M 和 T 都随着软件大小而增长。

因此,很快就不可能对所有内容进行完全测试。

如果一些错误通过了测试套件,那么你可以向测试套件添加一个测试。但是如果你想使用改进后的测试套件来查找错误的来源,那么你将必须模拟一个二分查找过程,或者你可以从你拥有的“错误”提交开始,逐个测试每个提交,这可能会非常浪费时间。

“git bisect”概述

开始二分查找

要使用的第一个“git bisect”子命令是“git bisect start”以开始搜索。然后必须设置边界以限制提交空间。这通常是通过提供一个“错误”提交和至少一个“正确”提交来完成的。它们可以在对“git bisect start”的初始调用中像这样传递

$ git bisect start [BAD [GOOD...]]

或者它们可以使用

$ git bisect bad [COMMIT]

$ git bisect good [COMMIT...]

设置,其中 BAD、GOOD 和 COMMIT 都是可以解析为提交的名称。

然后“git bisect”将签出它选择的提交,并要求用户对其进行测试,如下所示

$ git bisect start v2.6.27 v2.6.25
Bisecting: 10928 revisions left to test after this (roughly 14 steps)
[2ec65f8b89ea003c27ff7723525a2ee335a2b393] x86: clean up using max_low_pfn on 32-bit

请注意,我们将使用的示例实际上是一个玩具示例,我们将寻找第一个版本类似“2.6.26-something”的提交,即在顶级 Makefile 中有一行“SUBLEVEL = 26”的提交。这是一个玩具示例,因为有比使用“git bisect”更好的方法来查找此提交(例如“git blame”或“git log -S<string>”)。

手动执行二分查找

此时,基本上有 2 种方法来执行搜索。它可以由用户手动执行,也可以由脚本或命令自动执行。

如果由用户执行,那么在搜索的每一步,用户都必须测试当前提交,并使用上面描述的“git bisect good”或“git bisect bad”命令分别说明它是“正确”还是“错误”。例如

$ git bisect bad
Bisecting: 5480 revisions left to test after this (roughly 13 steps)
[66c0b394f08fd89236515c1c84485ea712a157be] KVM: kill file->f_count abuse in kvm

经过类似的几个步骤后,“git bisect”最终将找到第一个错误提交

$ git bisect bad
2ddcca36c8bcfa251724fe342c8327451988be0d is the first bad commit
commit 2ddcca36c8bcfa251724fe342c8327451988be0d
Author: Linus Torvalds <[email protected]>
Date:   Sat May 3 11:59:44 2008 -0700

    Linux 2.6.26-rc1

:100644 100644 5cf82581... 4492984e... M      Makefile

此时,我们可以查看提交的内容,签出(如果尚未签出)或对其进行修改,例如

$ git show HEAD
commit 2ddcca36c8bcfa251724fe342c8327451988be0d
Author: Linus Torvalds <[email protected]>
Date:   Sat May 3 11:59:44 2008 -0700

    Linux 2.6.26-rc1

diff --git a/Makefile b/Makefile
index 5cf8258..4492984 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
 VERSION = 2
 PATCHLEVEL = 6
-SUBLEVEL = 25
-EXTRAVERSION =
+SUBLEVEL = 26
+EXTRAVERSION = -rc1
 NAME = Funky Weasel is Jiggy wit it

 # *DOCUMENTATION*

完成后,我们可以使用“git bisect reset”返回到我们在开始二分查找之前所在的 branch

$ git bisect reset
Checking out files: 100% (21549/21549), done.
Previous HEAD position was 2ddcca3... Linux 2.6.26-rc1
Switched to branch 'master'

自动执行二分查找

执行二分查找过程的另一种方法是告诉“git bisect”在每个二分查找步骤启动一个脚本或命令,以了解当前提交是“正确”还是“错误”。为此,我们使用“git bisect run”命令。例如

$ git bisect start v2.6.27 v2.6.25
Bisecting: 10928 revisions left to test after this (roughly 14 steps)
[2ec65f8b89ea003c27ff7723525a2ee335a2b393] x86: clean up using max_low_pfn on 32-bit
$
$ git bisect run grep '^SUBLEVEL = 25' Makefile
running grep ^SUBLEVEL = 25 Makefile
Bisecting: 5480 revisions left to test after this (roughly 13 steps)
[66c0b394f08fd89236515c1c84485ea712a157be] KVM: kill file->f_count abuse in kvm
running grep ^SUBLEVEL = 25 Makefile
SUBLEVEL = 25
Bisecting: 2740 revisions left to test after this (roughly 12 steps)
[671294719628f1671faefd4882764886f8ad08cb] V4L/DVB(7879): Adding cx18 Support for mxl5005s
...
...
running grep ^SUBLEVEL = 25 Makefile
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[2ddcca36c8bcfa251724fe342c8327451988be0d] Linux 2.6.26-rc1
running grep ^SUBLEVEL = 25 Makefile
2ddcca36c8bcfa251724fe342c8327451988be0d is the first bad commit
commit 2ddcca36c8bcfa251724fe342c8327451988be0d
Author: Linus Torvalds <[email protected]>
Date:   Sat May 3 11:59:44 2008 -0700

    Linux 2.6.26-rc1

:100644 100644 5cf82581... 4492984e... M      Makefile
bisect run success

在这个示例中,我们将“grep ^SUBLEVEL = 25 Makefile”作为参数传递给“git bisect run”。这意味着在每一步中,我们将启动传递的 grep 命令。如果它以代码 0 退出(表示成功),则 git bisect 将把当前状态标记为“好”。如果它以代码 1 退出(或 1 到 127 之间的任何代码,包括特殊代码 125),则当前状态将标记为“坏”。

128 到 255 之间的退出代码对“git bisect run”来说是特殊的。它们会立即停止二分查找过程。这很有用,例如如果传递的命令花费太长时间才能完成,因为你可以使用信号终止它,它将停止二分查找过程。

如果检测到某些非常异常的情况,它在传递给“git bisect run”的脚本中也很有用,可以“exit 255”。

避免不可测试的提交

有时会发生当前状态无法测试的情况,例如如果它无法编译,因为当时存在阻止它的错误。这就是特殊退出代码 125 的用途。它告诉“git bisect run”当前提交应标记为不可测试,并且应选择并检出另一个提交。

如果手动驱动二分查找过程,可以使用“git bisect skip”执行相同操作。(事实上,特殊退出代码 125 使“git bisect run”在后台使用“git bisect skip”。)

或者,如果你希望获得更多控制,可以使用“git bisect visualize”等方式检查当前状态。它将启动 gitk(如果未设置 DISPLAY 环境变量,则启动“git log”)以帮助你找到更好的二分查找点。

无论哪种方式,如果你有一系列不可测试的提交,则你正在寻找的回归可能是由其中一个不可测试的提交引入的。在这种情况下,无法确切地说出哪个提交引入了回归。

因此,如果你使用了“git bisect skip”(或运行脚本以特殊代码 125 退出),则可能会得到这样的结果

There are only 'skip'ped commits left to test.
The first bad commit could be any of:
15722f2fa328eaba97022898a305ffc8172db6b1
78e86cf3e850bd755bb71831f42e200626fbd1e0
e15b73ad3db9b48d7d1ade32f8cd23a751fe0ace
070eab2303024706f2924822bfec8b9847e4ac1b
We cannot bisect more!

保存日志并重放它

如果你想向其他人展示你的二分查找过程,可以使用例如以下方式获取日志

$ git bisect log > bisect_log.txt

并且可以使用以下方式重放它

$ git bisect replay bisect_log.txt

“git bisect”详细信息

二分查找算法

由于 Git 提交形成有向无环图 (DAG),因此在每一步中找到要测试的最佳二分查找提交并不那么简单。无论如何,Linus 发现并实现了一种“真正愚蠢”的算法,后来由 Junio Hamano 改进,效果很好。

因此,当没有跳过的提交时,“git bisect”用于找到最佳二分查找提交的算法如下

1) 仅保留满足以下条件的提交:

a) 是“错误”提交的祖先(包括“错误”提交本身),b) 不是“正确”提交的祖先(排除“正确”提交)。

这意味着我们摆脱了 DAG 中不感兴趣的提交。

例如,如果我们从这样的图形开始

G-Y-G-W-W-W-X-X-X-X
	   \ /
	    W-W-B
	   /
Y---G-W---W
 \ /   \
Y-Y     X-X-X-X

-> time goes this way ->

其中 B 是“错误”提交,“G”是“正确”提交,W、X 和 Y 是其他提交,那么在执行此第一步后,我们将得到以下图形

W-W-W
     \
      W-W-B
     /
W---W

因此,仅保留 W 和 B 提交。因为提交 X 和 Y 将分别被规则 a) 和 b) 删除,并且因为提交 G 也被规则 b) 删除。

对于 Git 用户,请注意,这等同于仅保留由以下内容给出的提交

git rev-list BAD --not GOOD1 GOOD2...

还要注意,我们不要求保留的提交是“正确”提交的后代。因此,在以下示例中,将保留提交 W 和 Z

G-W-W-W-B
   /
Z-Z

2) 从图形的“正确”端开始,将每个提交与其祖先数加一关联起来

例如,对于以下图形,其中 H 是“错误”提交,A 和 D 是某些“正确”提交的某些父级

A-B-C
     \
      F-G-H
     /
D---E

这将给出

1 2 3
A-B-C
     \6 7 8
      F-G-H
1   2/
D---E

3) 将每个提交关联到:min(X, N - X)

其中 X 是步骤 2) 中与提交关联的值,N 是图形中的提交总数。

在上述示例中,我们有 N = 8,因此这将给出

1 2 3
A-B-C
     \2 1 0
      F-G-H
1   2/
D---E

4) 最佳二分点是关联数字最高的提交

因此,在上述示例中,最佳二分点是提交 C。

5) 请注意,实现了一些快捷方式以加快算法速度

由于我们从一开始就知道 N,因此我们知道 min(X, N - X) 不能大于 N/2。因此,在步骤 2) 和 3) 中,如果我们将 N/2 关联到一个提交,那么我们知道这是最佳二分点。因此,在这种情况下,我们可以停止处理任何其他提交并返回当前提交。

二分算法调试

对于任何提交图形,你可以使用“git rev-list --bisect-all”查看与每个提交关联的数字。

例如,对于上述图形,类似这样的命令

$ git rev-list --bisect-all BAD --not GOOD1 GOOD2

将输出类似以下内容

e15b73ad3db9b48d7d1ade32f8cd23a751fe0ace (dist=3)
15722f2fa328eaba97022898a305ffc8172db6b1 (dist=2)
78e86cf3e850bd755bb71831f42e200626fbd1e0 (dist=2)
a1939d9a142de972094af4dde9a544e577ddef0e (dist=2)
070eab2303024706f2924822bfec8b9847e4ac1b (dist=1)
a3864d4f32a3bf5ed177ddef598490a08760b70d (dist=1)
a41baa717dd74f1180abf55e9341bc7a0bb9d556 (dist=1)
9e622a6dad403b71c40979743bb9d5be17b16bd6 (dist=0)

二分算法讨论

首先,让我们定义“最佳二分点”。我们将说一个提交 X 是一个最佳二分点或一个最佳二分提交,如果知道它的状态(“正确”或“错误”)可以尽可能多地提供有关提交状态是“正确”还是“错误”的信息。

这意味着最佳二分提交是以下函数达到最大值的提交

f(X) = min(information_if_good(X), information_if_bad(X))

其中 information_if_good(X) 是如果 X 是正确的,我们得到的信息,information_if_bad(X) 是如果 X 是错误的,我们得到的信息。

现在我们假设只有一个“第一个错误提交”。这意味着其所有后代都是“错误的”,而所有其他提交都是“正确的”。并且我们假设所有提交都有相等的好坏概率,或者成为第一个错误提交的概率,因此了解 c 个提交的状态始终会提供相同数量的信息,无论这些 c 个提交在图表的什么位置,无论 c 是多少。(因此我们假设这些提交例如在一个分支上或靠近一个好或坏的提交并不会提供更多或更少的信息)。

让我们还假设我们有一个清理后的图表,就像上面二分查找算法中的步骤 1) 之后的图表一样。这意味着我们可以根据我们可以从图表中删除的提交数量来衡量我们获得的信息。

让我们在图表中取一个提交 X。

如果发现 X 是“好的”,那么我们知道它的祖先是“好的”,所以我们想说

information_if_good(X) = number_of_ancestors(X)  (TRUE)

这是正确的,因为在步骤 1) b) 中,我们删除了“好”提交的祖先。

如果发现 X 是“错误的”,那么我们知道它的后代都是“错误的”,所以我们想说

information_if_bad(X) = number_of_descendants(X)  (WRONG)

但这不正确,因为在步骤 1) a) 中,我们只保留错误提交的祖先。因此,当一个提交被标记为“错误”时,我们会获得更多信息,因为我们还知道以前“错误”提交的祖先(不是新“错误”提交的祖先)不是第一个错误提交。我们不知道它们是好还是坏,但我们知道它们不是第一个错误提交,因为它们不是新“错误”提交的祖先。

因此,当一个提交被标记为“错误”时,我们知道我们可以删除图表中除新“错误”提交的祖先之外的所有提交。这意味着

information_if_bad(X) = N - number_of_ancestors(X)  (TRUE)

其中 N 是(清理后)图表中的提交数量。

因此,最终这意味着要找到最佳二分查找提交,我们应该最大化函数

f(X) = min(number_of_ancestors(X), N - number_of_ancestors(X))

这很好,因为在步骤 2) 中,我们计算了 number_of_ancestors(X),所以在步骤 3) 中,我们计算了 f(X)。

让我们以以下图表为例

            G-H-I-J
           /       \
A-B-C-D-E-F         O
           \       /
            K-L-M-N

如果我们计算以下非最优函数

g(X) = min(number_of_ancestors(X), number_of_descendants(X))

我们得到

            4 3 2 1
            G-H-I-J
1 2 3 4 5 6/       \0
A-B-C-D-E-F         O
           \       /
            K-L-M-N
            4 3 2 1

但使用 git bisect 使用的算法,我们得到

            7 7 6 5
            G-H-I-J
1 2 3 4 5 6/       \0
A-B-C-D-E-F         O
           \       /
            K-L-M-N
            7 7 6 5

因此,我们选择 G、H、K 或 L 作为最佳二分点,它比 F 更好。因为如果例如 L 不好,那么我们不仅会知道 L、M 和 N 不好,还会知道 G、H、I 和 J 不是第一个错误提交(因为我们假设只有一个第一个错误提交,它必须是 L 的祖先)。

因此,给定我们最初的假设,当前算法似乎是最佳算法。

跳过算法

当某些提交已被跳过(使用“git bisect skip”)时,二分算法对于步骤 1) 到 3) 是相同的。但随后我们大致使用以下步骤

6) 按递减关联值对提交进行排序

7) 如果第一个提交未被跳过,我们可以返回它并在此处停止

8) 否则过滤掉排序列表中的所有已跳过提交

9) 使用伪随机数生成器 (PRNG) 生成 0 到 1 之间的随机数

10) 将此随机数乘以其平方根以使其偏向 0

11) 将结果乘以过滤列表中的提交数以获取此列表中的索引

12) 返回计算索引处的提交

跳过算法讨论

在步骤 7) 之后(在跳过算法中),我们可以检查第二个提交是否已被跳过,如果不是,则返回它。事实上,这是我们在 Git 版本 1.5.4(于 2008 年 2 月 1 日发布)中开发“git bisect skip”时使用的算法,直到 Git 版本 1.6.4(于 2009 年 7 月 29 日发布)。

但 Ingo Molnar 和 H. Peter Anvin(另一位著名的 Linux 内核开发者)都抱怨说,有时最佳二分点恰好都在所有提交都不可测试的区域中。在这种情况下,用户被要求测试许多不可测试的提交,这可能非常低效。

事实上,不可测试的提交通常不可测试,因为一次引入了中断,并且仅在引入许多其他提交后才修复了该中断。

当然,此中断与我们试图在提交图中找到的中断无关。但它阻止我们了解是否存在有趣的“错误行为”。

因此,事实是,接近不可测试提交的提交很可能本身不可测试。并且最佳二分提交通常也一起找到(由于二分算法)。

这就是为什么当第一个提交被跳过时,仅选择下一个最佳未跳过的二分提交是一个坏主意。

我们发现,当测试时,图上的大多数提交可能会提供大量信息。而平均不会提供大量信息的提交是接近好提交和坏提交的提交。

因此,使用带有偏向来支持远离好提交和坏提交的提交的 PRNG 看起来是一个不错的选择。

对该算法的一个明显的改进是,在使用 PRNG 之前,查找一个提交,其关联值接近最佳二分提交的值,并且位于另一个分支上。因为如果存在这样的提交,那么它不太可能也是不可测试的,因此它可能比几乎随机选择的提交提供更多信息。

检查合并基

二分算法中还有另一个调整,在上面的“二分算法”中没有描述。

我们在前面的示例中假设“好”提交是“坏”提交的祖先。但这并不是“git bisect”的要求。

当然,“坏”提交不能是“好”提交的祖先,因为好提交的祖先应该是“好”的。并且所有“好”提交都必须与坏提交相关。它们不能位于与“坏”提交分支没有链接的分支上。但是,一个好提交有可能与一个坏提交相关,但既不是它的祖先也不是它的后代。

例如,可能有一个“主”分支和一个“开发”分支,该分支在名为“D”的提交处从主分支派生,如下所示

A-B-C-D-E-F-G  <--main
       \
        H-I-J  <--dev

提交“D”被称为分支“主”和“开发”的“合并基”,因为它是这些分支用于合并的最佳公共祖先。

现在让我们假设提交 J 是坏的,提交 G 是好的,并且我们像前面描述的那样应用二分算法。

如二分算法的步骤 1) b) 中所述,我们删除所有好提交的祖先,因为它们也应该是好的。

所以我们只剩下

H-I-J

但是,如果第一个坏提交是“B”,并且它已在“主”分支中通过提交“F”修复,会发生什么情况?

这种二分的结果是我们会发现 H 是第一个坏提交,但实际上是 B。所以这是错误的!

是的,在实践中可能会发生这种情况,即在一个分支上工作的人员不知道在另一个分支上工作的人员修复了一个错误!还可能发生 F 修复了多个错误,或者它是某个尚未准备好发布的大型开发工作的一部分。

事实上,开发团队通常同时维护开发分支和维护分支,如果“git bisect”在他们想要对开发分支上的回归进行二分(该回归不在维护分支上)时起作用,那对他们来说会非常容易。他们应该能够使用以下命令开始二分

$ git bisect start dev main

为了启用该附加功能,当开始二分并且一些好提交不是坏提交的祖先时,我们首先计算坏提交和好提交之间的合并基,然后选择这些合并基作为将签出和测试的第一个提交。

如果碰巧有一个合并基准很糟糕,那么二分查找过程将停止,并显示类似于以下内容的消息

The merge base BBBBBB is bad.
This means the bug has been fixed between BBBBBB and [GGGGGG,...].

其中 BBBBBB 是糟糕的合并基准的 sha1 哈希值,而 [GGGGGG,…​] 是良好提交的 sha1 的逗号分隔列表。

如果跳过了一些合并基准,那么二分查找过程将继续,但会为每个跳过的合并基准打印以下消息

Warning: the merge base between BBBBBB and [GGGGGG,...] must be skipped.
So we cannot be sure the first bad commit is between MMMMMM and BBBBBB.
We continue anyway.

其中 BBBBBB 是糟糕的提交的 sha1 哈希值,MMMMMM 是跳过的合并基准的 sha1 哈希值,而 [GGGGGG,…​] 是良好提交的 sha1 的逗号分隔列表。

因此,如果不存在糟糕的合并基准,那么二分查找过程将在执行此步骤后照常继续。

最佳二分查找实践

同时使用测试套件和 git bisect

如果您同时拥有测试套件和使用 git bisect,那么在每次提交后检查所有测试是否通过就变得不那么重要了。当然,最好进行一些检查,以避免破坏太多内容,因为这可能会让二分查找其他错误变得更加困难。

您可以将精力集中在几个点(例如 rc 和 beta 版本)上,以检查所有 T 个测试用例是否通过所有 N 个配置。当某些测试未通过时,您可以使用“git bisect”(或更好的“git bisect run”)。因此,您应该执行大约

c * N * T + b * M * log2(M) tests

其中 c 是测试轮数(因此是一个小常数),而 b 是每次提交的错误比率(希望也是一个小常数)。

因此,如果您在每次提交后对所有内容进行测试,那么当然会好得多,因为它是 O(N * T) 与 O(N * T * M)。

这意味着测试套件可以很好地防止一些错误被提交,而且它们还可以很好地告诉您您有一些错误。但它们并不能很好地告诉您一些错误是在哪里引入的。要有效地告诉您这一点,需要 git bisect。

测试套件的另一个好处是,当你拥有一个测试套件时,你已经知道如何测试错误行为。因此,你可以利用此知识为“git bisect”创建一个新的测试用例,当出现回归时。这样可以更轻松地对错误进行二分查找并修复它。然后,你可以将刚创建的测试用例添加到测试套件中。

因此,如果你知道如何创建测试用例以及如何进行二分查找,你将受到良性循环的影响

更多测试 ⇒ 更容易创建测试 ⇒ 更容易进行二分查找 ⇒ 更多测试

因此,测试套件和“git bisect”是互补的工具,当一起使用时非常强大且高效。

二分查找构建失败

你可以非常轻松地使用类似以下内容自动对损坏的构建进行二分查找

$ git bisect start BAD GOOD
$ git bisect run make

将 sh -c "some commands" 传递给“git bisect run”

例如

$ git bisect run sh -c "make || exit 125; ./my_app | grep 'good output'"

另一方面,如果你经常这样做,那么编写脚本以避免过多输入可能是值得的。

查找性能回归

这是一个示例脚本,它经过了 Junio Hamano [4] 使用的真实脚本的轻微修改。

此脚本可以传递给“git bisect run”以查找引入性能回归的提交

#!/bin/sh

# Build errors are not what I am interested in.
make my_app || exit 255

# We are checking if it stops in a reasonable amount of time, so
# let it run in the background...

./my_app >log 2>&1 &

# ... and grab its process ID.
pid=$!

# ... and then wait for sufficiently long.
sleep $NORMAL_TIME

# ... and then see if the process is still there.
if kill -0 $pid
then
	# It is still running -- that is bad.
	kill $pid; sleep 1; kill $pid;
	exit 1
else
	# It has already finished (the $pid process was no more),
	# and we are happy.
	exit 0
fi

遵循一般最佳实践

显然,最好不要提交已知会破坏事物的更改,即使其他一些提交稍后修复了损坏。

在使用任何 VCS 时,最好在每次提交中只进行一个小的逻辑更改。

提交中的更改越小,“git bisect”就越有效。而且你可能一开始就不太需要“git bisect”,因为即使只有提交者审查,小更改也更容易审查。

另一个好主意是撰写良好的提交消息。它们可以非常有助于理解为什么进行某些更改。

如果你经常进行二分查找,这些一般最佳实践非常有用。

避免容易出错的合并

首先,即使合并不需要源代码冲突解决,合并本身也可能引入一些回归。这是因为语义更改可能发生在一个分支中,而另一个分支不知道它。

例如,一个分支可以更改函数的语义,而另一个分支可以向同一函数添加更多调用。

如果必须修复许多文件才能解决冲突,情况会变得更糟。这就是为什么这种合并被称为“邪恶合并”。它们可能使回归非常难以追踪。如果碰巧是这种合并,即使知道第一个错误的提交也可能具有误导性,因为人们可能会认为错误来自冲突解决不当,而不是来自一个分支中的语义更改。

无论如何,“git rebase”都可以用来使历史线性化。这可以用来避免首先进行合并。或者,它可以用来对线性历史进行二分,而不是对非线性历史进行二分,因为如果一个分支发生语义变化,这应该会提供更多信息。

通过使用较小的分支或使用许多主题分支而不是仅使用与较长版本相关的分支,还可以使合并变得更简单。

并且可以在诸如 Linux 内核的 linux-next 之类的特殊集成分支中更频繁地进行测试。

调整你的工作流

一个处理回归的特殊工作流可以产生很好的结果。

以下是 Andreas Ericsson 使用的工作流示例

  • 在测试套件中编写一个公开回归的测试脚本

  • 使用“git bisect run”找到引入回归的提交

  • 修复通常在上一步中显而易见的错误

  • 提交修复和测试脚本(如果需要,还可以提交更多测试)

以下是 Andreas 对此工作流的看法 [5]

给出一些具体数字,我们曾经有一个平均报告到修复的周期为 142.6 小时(根据我们有点奇怪的错误跟踪器,它只测量时钟时间)。自从我们迁移到 Git 以来,我们已将其降低到 16.2 小时。主要是因为我们现在可以继续进行错误修复,并且每个人都在争相修复错误(我们对自己懒得让 Git 为我们查找错误感到非常自豪)。每次新版本都会减少约 40% 的错误(几乎可以肯定是因为我们现在对编写测试的感觉)。

显然,此工作流使用了测试套件和“git bisect”之间的良性循环。事实上,它使其成为处理回归的标准程序。

在其他消息中,Andreas 说他们还使用了上面描述的“最佳实践”:小的逻辑提交、主题分支、没有邪恶的合并,……这些实践通过使二分更容易和更有用,从而提高了提交图表的可二分性。

因此,一个好的工作流应该围绕上述要点进行设计。也就是说,使二分更容易、更有用和更标准化。

让 QA 人员参与,如果可能,让最终用户参与

“git bisect” 的一个优点是它不仅仅是一个开发人员工具。它可以有效地由 QA 人员甚至最终用户使用(如果他们有权访问源代码或可以访问所有版本)。

Linux 内核邮件列表中曾讨论过是否可以始终要求最终用户进行二分查找,并且提出了非常好的观点来支持这种观点。

例如,David Miller 写道 [6]

人们没有意识到这是一个“终端节点原则”适用的情况。当你的资源有限(此处:开发人员)时,不要把大部分负担推给他们。相反,你应该把事情推给你有大量资源的终端节点(此处:用户),这样情况实际上就会得到改善。

这意味着如果 QA 人员或最终用户可以执行此操作,通常会“更便宜”。

同样有趣的是,报告错误的最终用户(或重现错误的 QA 人员)可以访问发生错误的环境。因此,他们通常可以更轻松地重现回归。如果他们可以进行二分查找,那么将从发生错误的环境中提取更多信息,这意味着理解和修复错误会更容易。

对于开源项目来说,这可能是从最终用户那里获得更有用贡献并让他们了解 QA 和开发活动的好方法。

使用复杂脚本

在某些情况下,例如内核开发,可能值得开发复杂脚本以能够完全自动执行二分查找。

以下是 Ingo Molnar 对此的看法 [7]

我有一个全自动的启动挂起二分查找脚本。它基于“git-bisect run”。我运行该脚本,它会全自动地构建和启动内核,当启动失败时(脚本会通过它持续监视的串行日志注意到这一点,或者通过超时,如果系统在 10 分钟内没有启动,则它是一个“错误”内核),脚本会通过蜂鸣声引起我的注意,然后我给测试机断电重启。(是的,我应该使用一个托管电源插座来 100% 地实现自动化)

组合测试套件、git bisect 和其他系统

我们已经看到,当测试套件和 git bisect 一起使用时,它们非常强大。如果你能将它们与其他系统结合使用,它们会更加强大。

例如,一些测试套件可以在夜间使用一些不寻常的(甚至是随机的)配置自动运行。如果测试套件发现回归,那么可以自动启动“git bisect”,并且可以将结果通过电子邮件发送给“git bisect”发现的第一个错误提交的作者,也许还可以发送给其他人。还可以自动创建缺陷跟踪系统中的新条目。

二分查找的未来

“git replace”

我们之前看到,“git bisect skip”现在正在使用 PRNG 来尝试避免提交图中提交不可测试的区域。问题在于,有时第一个错误提交将位于不可测试区域中。

为了简化讨论,我们假设不可测试区域是一串简单的提交,并且它是由一个提交引入的故障创建的(我们称之为 BBC,用于二分查找故障提交),后来由另一个提交修复(我们称之为 BFC,用于二分查找修复提交)。

例如

...-Y-BBC-X1-X2-X3-X4-X5-X6-BFC-Z-...

我们知道 Y 是好的,BFC 是错误的,并且 BBC 和 X1 到 X6 是不可测试的。

在这种情况下,如果你正在手动进行二分查找,你可以做的是创建一个特殊分支,该分支从 BBC 之前开始。此分支中的第一个提交应该是 BBC,其中包含已合并的 BFC。分支中的其他提交应该是 BBC 和 BFC 之间的提交,它们基于分支的第一个提交重新设定基础,然后基于 BFC 之后的提交也重新设定基础。

例如

      (BBC+BFC)-X1'-X2'-X3'-X4'-X5'-X6'-Z'
     /
...-Y-BBC-X1-X2-X3-X4-X5-X6-BFC-Z-...

其中用 ' 引用的提交已重新设定基础。

你可以使用交互式重新设定基础轻松地使用 Git 创建这样的分支。

例如,使用

$ git rebase -i Y Z

然后将 BFC 移到 BBC 之后并将其合并。

之后,你可以在新分支中像往常一样开始进行二分查找,最终你应该会找到第一个错误提交。

例如

$ git bisect start Z' Y

如果你正在使用“git bisect run”,你可以使用与上面相同的手动修复,然后在特殊分支中启动另一个“git bisect run”。或者,正如“git bisect”手册页所述,传递给“git bisect run”的脚本可以在编译和测试软件之前应用补丁 [8]。该补丁应将当前不可测试的提交变成可测试的提交。因此,测试将导致“好”或“坏”,并且“git bisect”将能够找到第一个错误提交。并且脚本在退出脚本之前不要忘记在测试完成后删除补丁。

(请注意,您可以使用“git cherry-pick BFC”来应用修复程序,而不是补丁,在这种情况下,您应该使用“git reset --hard HEAD^”在测试后和从脚本返回之前还原 cherry-pick。)

但上述解决不可测试区域的方法有点笨拙。使用特殊分支很好,因为这些分支可以像普通分支一样在开发人员之间共享,但风险是人们将获得许多这样的分支。而且它会中断正常的“git bisect”工作流。因此,如果您想完全自动地使用“git bisect run”,则必须在脚本中添加特殊代码以在特殊分支中重新启动二分查找。

无论如何,人们都会注意到在上述特殊分支示例中,Z' 和 Z 提交应该指向相同的源代码状态(在 git 术语中相同的“树”)。这是因为 Z' 的结果与 Z 相同,只是顺序略有不同。

因此,如果我们在二分查找时可以用 Z' “替换” Z,那么我们就无需向脚本添加任何内容。它将适用于项目中共享特殊分支和替换的任何人。

使用上述示例将给出

      (BBC+BFC)-X1'-X2'-X3'-X4'-X5'-X6'-Z'-...
     /
...-Y-BBC-X1-X2-X3-X4-X5-X6-BFC-Z

这就是“git replace”命令创建的原因。从技术上讲,它将替换“refs”存储在“refs/replace/”层次结构中。这些“refs”类似于分支(存储在“refs/heads/”中)或标签(存储在“refs/tags”中),这意味着它们可以像分支或标签一样在开发人员之间自动共享。

“git replace”是一种非常强大的机制。它可用于修复已发布历史记录中的提交,例如更改提交消息或作者。它还可以用于代替 git “grafts”将存储库与另一个旧存储库链接起来。

事实上,正是这个最后一个特性“卖”给了 Git 社区,所以它现在位于 Git 的 Git 存储库的“master”分支中,它应该在 2009 年 10 月或 11 月发布在 Git 1.6.5 中。

“git replace”的一个问题是,它当前将所有替换 refs 存储在“refs/replace/”中,但如果仅对二分查找有用的替换 refs 位于“refs/replace/bisect/”中,则可能更好。这样,替换 refs 只能用于二分查找,而直接位于“refs/replace/”中的其他 refs 几乎可以始终使用。

剖析偶发性错误

对“git bisect”的另一项可能的改进是,可选地为执行的测试添加一些冗余,以便在跟踪偶发性错误时更可靠。

一些内核开发人员提出了此请求,因为某些称为偶发性错误的错误不会出现在所有内核版本中,因为它们很大程度上依赖于编译器输出。

例如,每 3 次测试,“git bisect”可以要求用户测试一个已被发现为“好”或“坏”的提交(因为其后代之一或其祖先之一已被发现为“好”或“坏”)。如果碰巧一个提交以前被错误地分类,那么可以及早中止二分法,希望在犯下太多错误之前。然后,用户将不得不查看发生了什么,然后使用固定的二分法日志重新启动二分法。

Github 上已经有一个名为 BBChop 的项目,由 Ealdwulf Wuffinga 创建,它使用贝叶斯搜索理论 [9] 做类似的事情

BBChop 类似于git bisect(或同等工具),但当你的错误是间歇性时,它会起作用。也就是说,它可以在存在假阴性(即使版本包含错误,但这次碰巧可以工作)的情况下工作。它假设没有假阳性(原则上,相同的方法会起作用,但添加它可能不是微不足道的)。

但 BBChop 与任何 VCS 无关,对于 Git 用户来说,在 Git 中集成一些东西会更容易。

结论

我们已经看到,回归是一个重要的问题,“git bisect”具有很好的特性,可以很好地补充实践和其他工具,尤其是通常用于对抗回归的测试套件。但可能需要改变一些工作流程和(不良)习惯,才能充分利用它。

“git bisect” 中的算法有一些改进的可能性,一些新功能在某些情况下可能有所帮助,但总体而言,“git bisect” 已经运行得非常好,被广泛使用,并且非常有用。为了支持最后的声明,当作者询问他使用“git bisect”时,他认为节省了多少时间时,让我们听听 Ingo Molnar 的最后一句话

很多

大约十年前,我第一次对 Linux 补丁队列进行了二分查找。那是在 Git(甚至 BitKeeper)之前。我花了几天时间对补丁进行排序,创建本质上是独立提交的内容,我猜测这些内容与该错误有关。

这是绝对最后的手段。我宁愿花几天时间查看 printk 输出,也不愿进行手动补丁二分查找

使用 Git bisect 非常简单:在最好的情况下,我可以在 20-30 分钟内以自动化的方式完成大约 15 步的内核二分查找。即使在手动帮助或对多个重叠错误进行二分查找的情况下,也很少超过一小时。

事实上,它非常有价值,因为如果没有 git bisect,我甚至永远不会尝试调试一些错误。过去,有一些错误模式对我来说是无法调试的——充其量,我可以将崩溃/错误签名发送给 lkml,并希望其他人能想到一些办法。

即使二分查找今天失败了,它也会告诉我们一些关于错误的有价值的信息:它是不确定的——依赖于时间或内核映像布局。

所以 git bisect 是无条件的好东西——随时引用 ;-)

致谢

非常感谢 Junio Hamano 审阅本文,审阅我发送到 Git 邮件列表的补丁,讨论一些想法并帮助我改进它们,极大地改进了“git bisect”,并感谢他在维护和开发 Git 方面所做的出色工作。

非常感谢 Ingo Molnar 为我提供了本文中出现的一些非常有用的信息,对本文发表评论,提出改进“git bisect”的建议,并在 linux 内核邮件列表上推广“git bisect”。

非常感谢 Linus Torvalds 发明、开发和推广“git bisect”,Git 和 Linux。

非常感谢许多其他优秀的人,他们在我在 Git 上工作时以各种方式提供了帮助,特别是 Andreas Ericsson、Johannes Schindelin、H. Peter Anvin、Daniel Barkalow、Bill Lear、John Hawley、Shawn O. Pierce、Jeff King、Sam Vilain、Jon Seymour。

非常感谢 Linux-Kongress 计划委员会选择作者发表演讲并发表本文。

scroll-to-top