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

摘要

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

“git bisect”简介

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

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

实际上,人们特别关注引入“不良”行为(称为错误或回归)的提交。他们对这些提交感兴趣,因为一次提交(希望)只包含非常少量的源代码更改。当您只需要检查非常少量的更改时,理解并正确修复问题会容易得多,而不是在您一开始就不知道从何处入手时。

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

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

对抗回归概述

回归:一个大问题

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

关于一般性错误有一些数据,例如 2002 年的一项 NIST 研究 [1] 指出:

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

此外:

软件开发人员已经将大约 80% 的开发成本用于识别和纠正缺陷,然而除了软件之外,很少有其他类型的产品会以如此高的错误率出货。

最终的结论始于:

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

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

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

对维护的普遍看法是它仅仅是修复错误。然而,多年来的研究和调查表明,大部分(超过 80%)的维护工作用于非纠正性活动 (Pigosky 1997)。这种看法通过用户提交的实际是系统功能增强的问题报告而得以延续。

但我们可以推测,改进现有软件的成本非常高昂,因为您必须警惕回归。至少这会使上述研究之间保持一致。

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

Linux 内核就是其中之一。如果我们看一下 Linux 内核,会发现大量时间和精力都用于对抗回归。发布周期从为期两周的合并窗口开始。然后标记第一个发布候选 (rc) 版本。之后,在大约一周的间隔内,将出现约 7 或 8 个更多的 rc 版本,然后才发布最终版本。

第一个 rc 版本发布到最终版本发布之间的时间,旨在用于测试 rc 版本并修复错误,特别是回归。这段时间占发布周期总时间的 80% 以上。但这还不是斗争的结束,因为在发布之后,斗争当然还在继续。

Ingo Molnar(一位著名的 Linux 内核开发者)关于他使用 git bisect 的说法是:

我最常在合并窗口期间使用它(当时有大量代码树合并到上游,也是 bug 涌入最多的时候)——是的,有些时候我一天会用它好几次。我的平均使用频率大约是一天一次。

因此,开发人员一直在与回归作斗争,而且众所周知,bug 应该尽快修复,即一发现就修复。这就是为什么拥有良好的工具用于此目的很有意义。

其他对抗回归的工具

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

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

实际上,问题在于大型软件通常有许多不同的配置选项,并且每个测试用例在每次提交后都应该针对每个配置通过。因此,如果您在每个版本中都有: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 中有比使用“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

在此示例中,我们向“git bisect run”传递了“grep *^SUBLEVEL = 25* Makefile”作为参数。这意味着在每个步骤中,我们传递的 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 的状态(“好”或“坏”)能提供尽可能多的信息,无论该提交的状态是“好”还是“坏”,我们都称提交 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”的要求。

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

例如,可能有一个“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。所以这将是错误的!

事实上,在实践中,在一个分支上工作的人可能并不知道在另一个分支上工作的人已经修复了一个错误!也可能 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 版本)来检查所有 N 种配置的 T 个测试用例是否都通过。当某些测试不通过时,您可以使用“git bisect”(或更好的“git bisect run”)。因此,您应该大致执行:

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

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

因此,当然要好得多,因为它避免了每次提交后测试所有内容所产生的 O(N * T * M) 开销,而只需 O(N * T)。

这意味着测试套件擅长阻止一些错误被提交,并且也能很好地告诉您存在一些错误。但它们在告诉您错误是在何处引入方面表现不佳。要高效地做到这一点,就需要 git bisect。

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

因此,如果您知道如何创建测试用例以及如何进行二分查找,您将进入一个良性循环:

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

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

二分查找构建失败

您可以非常轻松地自动二分查找损坏的构建,使用类似以下命令:

$ git bisect start BAD GOOD
$ git bisect run make

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

例如

$ 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 小时(根据我们有些奇怪的只测量实际时间的 bug 追踪器)。自从我们转向 Git 后,我们已将这个时间降低到 16.2 小时。这主要是因为我们现在能够及时修复 bug,而且每个人都争相修复 bug(我们对自己懒惰地让 Git 为我们找到 bug 感到非常自豪)。每个新版本都会带来大约 40% 的 bug 减少(几乎可以肯定是因为我们现在对编写测试的态度)。

显然,这个工作流程利用了测试套件和“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 之前开始。该分支中的第一个提交应该是将 BFC 合并到 BBC 中。而该分支中的其他提交应该是 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/heads/”中)或标签(存储在“refs/tags/”中),这意味着它们可以像分支或标签一样在开发人员之间自动共享。

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

事实上,正是这个最后的功能让它“推销”给了 Git 社区,因此它现在位于 Git 仓库的“master”分支中,并预计在 2009 年 10 月或 11 月的 Git 1.6.5 版本中发布。

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

二分查找偶发性错误

“git bisect”的另一个可能改进是可选地增加所执行测试的冗余,以便在追踪偶发性错误时更加可靠。

一些内核开发者曾提出此要求,因为某些被称为偶发性错误的 bug 并非出现在所有内核构建中,因为它们非常依赖于编译器输出。

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

GitHub 上已有一个由 Ealdwulf Wuffinga 创建名为 BBChop 的项目,它利用贝叶斯搜索理论实现了类似的功能 [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 步的内核二分查找。即使有手动帮助或在二分查找多个重叠错误时,也很少会超过一个小时。

事实上,它是无价之宝,因为如果没有 git bisect,有些 bug 我甚至都不会*尝试*去调试。过去,有些 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 项目委员会选择作者进行演讲并发表本文。

scroll-to-top