簡體中文 ▾ 主題 ▾ 最新版本 ▾ 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