简体中文 ▾ 主题 ▾ 最新版本 ▾ git-bisect-lk2009 上次更新于 2.40.0

摘要

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

“git bisect”简介

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

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

事实上,人们特别关注引入“坏”行为(称为 bug 或回归)的提交。他们关注这些提交是因为一个提交(希望)包含非常小的源代码更改集。当你只需要检查一小组更改时,比你根本不知道从哪里开始查找要容易得多,可以理解并正确地修复一个问题。

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

因此,“git bisect”旨在帮助找到“首个坏提交”。为了尽可能高效,它会尝试执行二分查找。

对抗回归概述

回归:一个大问题

回归是软件行业的一个大问题。但很难为此主张提供真实的数字。

有一些关于 bug 的普遍数字,例如 2002 年的 NIST 研究 [1] 表明:

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

然后

软件开发人员已经花费了大约 80% 的开发成本用于识别和纠正缺陷,但除了软件之外,很少有产品能以如此高的错误率交付。

最终的结论开始于

更高软件质量的道路是显著改进的软件测试。

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

然而,根据维基百科 [3]

维护的一个普遍看法是,它仅仅是修复 bug。然而,多年的研究和调查表明,大部分(超过 80%)的维护工作用于非纠正性操作 (Pigosky 1997)。这种看法是由用户提交的实际上是系统功能增强的问题报告所延续的。

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

当然,有些软件在开发后会使用一段时间而不作太多改进,然后最终被丢弃。在这种情况下,回归可能不是一个大问题。但另一方面,有很多大型软件由许多人开发和维护多年甚至几十年。而且由于很多人(有时是关键地)依赖于此类软件,回归是一个真正的大问题。

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

第一个 rc 版本发布到最终发布之间的时间应该用于测试 rc 版本并处理 bug,尤其是回归。这段时间占发布周期时间的 80% 以上。但这还不是战斗的终点,因为当然它会在发布后继续。

然后,这是 Ingo Molnar(一位著名的 Linux 内核开发者)关于他使用 git bisect 的看法

我在合并窗口期间(当大量树被合并到上游,并且 bug 涌入最多时)最积极地使用它——是的,曾有几次我一天使用它多次。我的平均使用频率大约是每天一次。

所以开发人员一直在对抗回归,事实上,众所周知 bug 应该尽早修复,一旦发现就立即修复。这就是为什么拥有好的工具来达到这个目的很有意义。

其他对抗回归的工具

那么,用来对抗回归的工具是什么呢?它们几乎与用来对抗常规 bug 的工具相同。唯一特定的工具是测试套件和类似于“git bisect”的工具。

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

事实上,问题在于大型软件通常有许多不同的配置选项,并且每个测试用例都应该在每次提交后通过每个配置。所以,如果你为每次发布有:N 个配置,M 个提交和 T 个测试用例,你应该执行

N * M * T tests

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

所以很快将不可能完全测试所有内容。

如果某些 bug 绕过了你的测试套件,那么你可以将一个测试添加到你的测试套件中。但是,如果你想使用你新改进的测试套件来查找 bug slipped in 的地方,那么你将不得不模拟一个二分过程,或者你可能会从你拥有的“坏”提交开始,逐个向后测试,这可能非常浪费。

“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>”)。

手动驱动二分查找

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

如果用户手动驱动,那么在搜索的每一步,用户都必须测试当前提交,并使用上面描述的“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 <torvalds@linux-foundation.org>
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 <torvalds@linux-foundation.org>
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”返回到开始二分查找之前的分支。

$ 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 <torvalds@linux-foundation.org>
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”以检测到非常异常的情况也可能很有用。

避免不可测试的提交

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

如果二分过程是手动驱动的,你可以使用“git bisect skip”来做同样的事情。(事实上,特殊退出代码 125 会让“git bisect run”在后台使用“git bisect skip”。)

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

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

所以,如果你使用了“git bisect skip”(或 run 脚本退出了特殊代码 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 是多少,都提供相同的信息。(因此,我们假设这些提交例如在分支上或靠近好或坏提交不会提供更多或更少的信息)。

我们还假设我们有一个清理过的图,就像上面二分算法步骤 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”的要求。

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

例如,可以有一个“main”分支,以及一个在名为“D”的提交处分叉的“dev”分支,如下所示:

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

提交“D”被称为分支“main”和“dev”的“合并基”,因为它是这些分支进行合并的最佳公共祖先。

现在,假设提交 J 是坏的,提交 G 是好的,并且我们按照前面描述的方式应用二分算法。

如二分算法的步骤 1)b)所述,我们移除了好提交的所有祖先,因为它们也被认为是好的。

所以我们只剩下:

H-I-J

但是,如果首个坏提交是“B”,并且它在“main”分支上被提交“F”修复了,会发生什么?

这种二分查找的结果是,我们将发现 H 是首个坏提交,而实际上它是 B。所以这是错误的!

是的,实际上可能发生人们在一个分支上工作,但不知道另一个分支上的人已经修复了一个 bug!也可能 F 修复了不止一个 bug,或者它是一个大型开发工作的回滚,而该工作尚未准备好发布。

事实上,开发团队通常同时维护一个开发分支和一个维护分支,如果“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,那么在每次提交后检查所有测试是否通过的重要性就会降低。尽管当然,为了避免破坏太多东西而进行一些检查可能是一个好主意,因为这可能会使二分查找其他 bug 变得更加困难。

你可以将精力集中在少数几个点(例如 rc 和 beta 发布)检查所有 T 个测试用例是否都通过所有 N 个配置。当某些测试不通过时,你可以使用“git bisect”(或者更好地使用“git bisect run”)。因此,你应该大约执行:

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

其中 c 是测试轮数(一个小的常数),b 是每个提交的 bug 率(希望也是一个小的常数)。

所以,当然这比 O(N * T * M) 的时间复杂度要好得多,如果你在每次提交后都会测试所有内容的话。

这意味着测试套件对于防止某些 bug 被提交以及告知你存在一些 bug 都非常有用。但它们在告诉你在哪里引入了 bug 方面不是那么好。为了有效地告诉你这一点,需要 git bisect。

测试套件的另一个优点是,当你拥有一个时,你已经知道如何测试坏行为。所以,当出现回归时,你可以利用这些知识来为“git bisect”创建一个新的测试用例。这样,更容易二分查找 bug 并修复它。然后,你可以将你刚刚创建的测试用例添加到你的测试套件中。

所以,如果你知道如何创建测试用例以及如何进行二分查找,你将受益于一个良性循环:

更多测试 ⇒ 更易于创建测试 ⇒ 更易于二分查找 ⇒ 更多测试

因此,测试套件和“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”的次数会更少,因为小的更改更容易审查,即使它们只由提交者审查。

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

这些一般最佳实践对经常进行二分查找很有帮助。

避免易出错的合并

首先,即使合并不需要源代码冲突的解决,合并本身也可能引入一些回归。这是因为一个分支中的语义更改可能发生在另一个分支没有意识到的时候。

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

如果需要修复许多文件来解决冲突,情况会更糟。这就是为什么这类合并被称为“邪恶合并”。它们可能使回归非常难以追踪。如果首个坏提交恰好是这样的合并,甚至可能会误导,因为人们可能会认为 bug 来自糟糕的冲突解决,而实际上它来自一个分支中的语义更改。

无论如何,“git rebase”可用于线性化历史。这可以用于避免合并。或者,它可用于在线性历史而不是非线性历史进行二分查找,因为在语义更改的情况下,这应该提供更多信息。

使用更小的分支或使用多个主题分支而不是仅使用长的版本相关分支,也可以使合并变得更简单。

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

调整你的工作流程

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

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

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

  • 使用“git bisect run”查找引入它的提交

  • 修复 bug(通常通过上一步骤变得明显)

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

这是 Andreas 关于这个工作流程所说的 [5]

给出一些具体数字,我们过去平均的报告到修复周期为 142.6 小时(根据我们有些奇怪的 bug 跟踪器,它只测量实际时间)。自从我们迁移到 Git 后,我们将其降低到 16.2 小时。主要是因为我们现在可以及时修复 bug,而且每个人都在争先恐后地修复 bug(我们很自豪我们能懒惰到让 Git 为我们找到 bug)。每次新版本发布都会导致 bug 减少约 40%(几乎可以肯定是由于我们现在愿意编写测试)。

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

在其他消息中,Andreas 说他们也使用了上述“最佳实践”:小的逻辑提交、主题分支、没有恶意的合并……​ 这些实践都通过使提交图更容易和更有用地进行二分查找来改进其可二分性。

因此,良好的工作流程应该围绕上述几点进行设计。即使二分查找更容易、更有用和标准化。

让 QA 人员和可能的最终用户参与进来

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

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

例如 David Miller 写道 [6]

人们不明白的是,这是一种“终端节点原则”适用的情况。当你的资源有限(在这里是开发人员)时,你不会把大部分负担压在他们身上。相反,你会把事情推给那些你拥有大量资源的资源,即终端节点(在这里是用户),这样情况实际上才具有可扩展性。

这意味着,如果 QA 人员或最终用户能够做到,通常会“更便宜”。

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

对于开源项目来说,这是一种从最终用户那里获得更多有用贡献,并将他们引入 QA 和开发活动的好方法。

使用复杂脚本

在某些情况下,例如内核开发,开发复杂的脚本来完全自动化二分查找是值得的。

这是 Ingo Molnar 关于此的说法 [7]

我有一个完全自动化的启动挂起二分查找脚本。它基于“git-bisect run”。我运行脚本,它会自动构建和启动内核,当启动失败时(脚本通过连续监视的串行日志或超时来检测,如果系统在 10 分钟内没有启动,则被认为是“坏”内核),脚本会通过蜂鸣声引起我的注意,然后我重启测试框。(是的,我应该 100% 自动化使用受管理的电源插座)

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

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

例如,一些测试套件可以在夜间以一些不寻常的(甚至随机的)配置自动运行。如果测试套件发现了一个回归,那么可以自动启动“git bisect”,并且其结果可以被电子邮件发送给“git bisect”找到的第一个坏提交的作者,以及可能其他的相关人员。也可以自动创建一个新的 bug 跟踪系统条目。

二分查找的未来

“git replace”

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

为了简化讨论,我们将假设不可测试区域是一连串简单的提交,并且它是由一个提交(我们称之为 BBC,即 bisect breaking commit)引入的损坏,后来被另一个提交(我们称之为 BFC,即 bisect fixing commit)修复。

例如

...-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 通过交互式 rebase 轻松创建这样的分支。

例如,使用

$ 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 术语中称为“tree”)。这是因为 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 将几乎一直被使用。

二分查找偶发性 bug

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

一些内核开发人员提出了这个要求,因为一些被称为偶发性 bug 的 bug 并不会出现在所有的内核构建中,因为它们非常依赖于编译器输出。

想法是,例如每 3 次测试,“git bisect”可以要求用户测试一个已经确定为“好”或“坏”的提交(因为它的后代或祖先已经分别被确定为“好”或“坏”)。如果一个提交之前被错误地分类,那么二分查找可以提前中止,希望在犯下太多错误之前。然后用户需要查看发生了什么,并使用修复的二分日志重新启动二分查找。

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

BBChop 类似于 *git bisect*(或同等工具),但适用于你的 bug 是间歇性时。也就是说,它在存在误报(当一个版本碰巧工作,尽管它包含 bug)的情况下也能工作。它假定不存在漏报(原则上,相同的方法也有效,但添加它可能并不简单)。

但是 BBChop 与任何 VCS 都无关,对于 Git 用户来说,拥有一个集成到 Git 中的东西会更容易。

结论

我们已经看到回归是一个重要的问题,并且“git bisect”拥有很好的功能,能够很好地补充实践和其他工具(尤其是测试套件),这些工具通常用于对抗回归。但是,可能需要改变一些工作流程和(糟糕的)习惯才能充分发挥其作用。

对“git bisect”内部算法的一些改进是可能的,一些新功能在某些情况下会有帮助,但总的来说,“git bisect”已经工作得很好,被广泛使用,并且已经非常有用了。为了支持最后这一说法,让我们听听 Ingo Molnar 的看法,当作者问他,他认为使用“git bisect”能为他节省多少时间时

很多

大约十年前,我第一次对 Linux 补丁队列进行了二分查找。那时还没有 Git(甚至没有 BitKeeper)。我花了好几天时间整理补丁,创建本质上是独立的提交,我猜测它们与那个 bug 相关。

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

有了 Git bisect,这就像一阵风:在最好的情况下,我可以在 20-30 分钟内完成一个大约 15 步的内核二分查找,而且是自动化的。即使需要手动帮助或二分查找多个重叠的 bug,也通常不超过一小时。

事实上,它是无价的,因为有些 bug 如果不是因为 git bisect,我根本不会尝试去调试。过去,有些 bug 的模式对我来说是立即无望的——充其量我只能将崩溃/bug 签名发送到 lkml,并希望别人能想到些什么。

而且,即使今天二分查找失败,它也告诉我们关于 bug 的有价值信息:它是非确定性的——依赖于时序或内核镜像布局。

所以 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 组委会选择作者发表演讲并出版本文。