English ▾ 主题 ▾ 最新版本 ▾ 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 主要用于管理软件源代码,因此软件行为中的一些“有趣的”变化有时会在一些提交中引入。

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

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

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

对抗回归概述

回归:一个大问题

回归是软件行业的一个大问题。 但很难为这一说法提供一些真实的数字。

有一些关于一般错误的数字,例如 NIST 在 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 bisect 的说法

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

因此,开发人员一直在与回归作斗争,而且众所周知,应该尽快修复错误,因此一旦发现就应该修复。 这就是拥有好的工具来实现此目的的原因。

其他对抗回归的工具

那么,用于对抗回归的工具是什么? 它们与用于对抗常规错误的工具几乎相同。 唯一的特定工具是测试套件和类似于“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 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”也很有用。

避免无法测试的提交

有时会出现当前状态无法测试的情况,例如,如果它无法编译,因为当时存在阻止它的错误。这就是特殊退出代码 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 版本 1.6.4(2009 年 7 月 29 日发布)期间“git bisect skip”开发时所使用的算法。

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

实际上,无法测试的提交通常是无法测试的,因为某个破坏在某个时间被引入,而该破坏直到许多其他提交被引入后才被修复。

当然,这种破坏通常与我们试图在提交图中定位的破坏无关。但它阻止我们知道有趣的“不良行为”是否存在。

因此,事实是,无法测试的提交附近的提交本身也很有可能无法测试。而且,由于二分算法,最佳的二分提交通常也会一起找到。

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

我们发现,图上的大多数提交在测试时都可能提供相当多的信息。而平均而言不会提供太多信息的提交是那些靠近好提交和坏提交的提交。

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

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

检查合并基础

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

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

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

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

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 版本)检查所有 T 个测试用例是否在所有 N 个配置中都通过。当某些测试未通过时,您可以使用“git bisect”(或更好的“git bisect run”)。因此,您应该大致执行:

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

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

因此,如果您在每次提交后都测试所有内容,则它比 O(N * T * M) 好得多,因为它是 O(N * T)。

这意味着测试套件可以很好地防止某些错误被提交,并且它们也很擅长告诉您存在某些错误。但是,它们不太擅长告诉您某些错误是在哪里引入的。为了有效地告诉您这一点,需要 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 感到非常自豪)。每个新版本都会导致减少约 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 是不可测试的。

在这种情况下,如果您手动进行二分查找,您可以创建一个特殊的 branch,从 BBC 之前开始。 此 branch 中的第一个提交应该是将 BFC 合并到其中的 BBC。 branch 中的其他提交应该是 BBC 和 BFC 之间的提交,基于 branch 的第一个提交进行 rebase,然后 BFC 之后的提交也基于它进行 rebase。

例如

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

用 ' 引用的提交已被 rebase。

您可以使用交互式 rebase 通过 Git 轻松创建这样的 branch。

例如,使用

$ git rebase -i Y Z

然后将 BFC 移动到 BBC 之后并将其 squash。

之后,您可以在新 branch 中像往常一样开始二分查找,您最终会找到第一个错误提交。

例如

$ git bisect start Z' Y

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

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

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

无论如何,人们可以在上面的特殊 branch 示例中注意到 Z' 和 Z 提交应指向相同的源代码状态(git 术语中的相同“tree”)。 这是因为 Z' 是通过以略微不同的顺序应用与 Z 相同的更改而产生的。

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

使用上面的示例,这将给出

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

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

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

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

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

二分查找偶发性错误

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

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

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

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

BBChop 就像*git bisect*(或等效的),但当您的错误是间歇性的时,它会起作用。 也就是说,它在存在假阴性时起作用(当一个版本这次碰巧可以工作,即使它包含该错误)。 它假设没有假阳性(原则上,相同的方法有效,但添加它可能并非易事)。

但 BBChop 独立于任何 VCS,Git 用户更容易在 Git 中集成一些东西。

结论

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

可以对“git bisect”内部的算法进行一些改进,并且一些新功能在某些情况下可能会有所帮助,但总的来说,“git bisect”已经运行良好,使用广泛,并且非常有用。 为了支持最后一个声明,让我们把最后一句话留给 Ingo Molnar,当作者问他使用“git bisect”时,他认为“git bisect”能节省他多少时间时

很多

大约十年前,我第一次对 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