你的 CI 在 main 上是紅燈,但昨天還是綠燈。從那時到現在之間,某個 bug 悄悄潛入。你可以翻閱 200 個 commit,讀 diff 並猜測。你可以在 Slack 上發問,希望有人記得碰過相關程式碼。或者你可以讓 Git 幫你做這件事。
git bisect 是針對 commit 歷史的二元搜尋工具。你標記一個 commit 為 bad,另一個為 good。Git checkout 中點。你測試它,標記為 good 或 bad,Git 再重複。只要 8 步,就能從 256 個 commit 中隔離出一個。12 步就能在 4,096 個中找到那根針。它是 Git 最接近歷史除錯器的功能。
為什麼手動 bisection 是在浪費時間
開發者早已在不知不覺中手動進行 bisection。你 checkout 一個較舊的 commit,執行測試,心想「還是壞的,得再往回找」。然後你 checkout 一個更舊的,測試通過了。現在你知道 bug 就在這兩點之間。於是你挑中間的一個,繼續縮小範圍。
這就是二元搜尋。git bisect 自動化記帳工作,讓你不會搞混已經測過哪些 commit。更重要的是,它防止了那種懶惰的啟發式判斷:「大概是週二那個大 refactor 搞的吧」,這種直覺會讓你鑽進錯誤的兔子洞兩小時。
真正的價值不在於速度,雖然它確實更快。真正的價值是正確性。當你在傍晚六點沮喪地追 bug 時,你會犯錯。你會忘記在 checkout 後重新建置。你會測試同一個錯誤的 commit 兩次。你會誤讀測試結果,把壞的 commit 標成 good,摧毀你的搜尋空間。git bisect 強制執行你在煩躁時不會有的紀律。
二元搜尋實際上如何運作
Git 不是按時間順序搜尋。它是按拓撲順序搜尋,走訪 commit graph 來找出已知 good 和已知 bad commit 之間的中點。當你的歷史有 merge 時這很重要,因為時間順序的中點可能根本無法從兩端點抵達。
以下是背後發生的事。你啟動 bisect 並提供邊界:
git bisect start
git bisect bad HEAD # current commit is broken
git bisect good v2.1.0 # this release was fine
Git 計算這兩點之間的 commit 數量。它 checkout 正中間的那一個,等你測試。你執行重現程序,看看 bug 是否存在,然後告訴 Git:
git bisect bad # this commit has the bug
git bisect good # this commit is clean
Git 丟棄一半的搜尋空間然後重複。當只剩下一個 commit 時,它會停下來並顯示 first bad commit。輸出包含 commit hash、作者、日期和訊息。沒有模糊地帶。就是這個 commit 引入了 bug,到此為止。
使用真實指令的具體演練
假設你的整合測試從今天早上開始失敗。你知道它們在最後一個標記的發行版 v1.4.0 中是通過的。以下是完整的操作過程:
# Start the session
git bisect start
# Mark the current HEAD as bad
git bisect bad HEAD
# Mark the last known good release
git bisect good v1.4.0
# Git checks out a midpoint commit automatically
# You run your reproduction script or test suite:
npm test -- --grep "checkout flow"
# Tests fail. Mark it bad.
git bisect bad
# Git checks out another midpoint. Run tests again.
npm test -- --grep "checkout flow"
# Tests pass. Mark it good.
git bisect good
# Repeat until Git tells you:
# "<commit-hash> is the first bad commit"
最後,Git 會讓你停留在 bad commit 上。你可以用 git show 檢視它,建立修復,然後清理:
git bisect reset
這會把你帶回開始前的分支。如果你忘記 reset,你會停留在 detached HEAD 上,納悶為什麼下一個 commit 不在你的分支上。我做過這種事。很糗。
用腳本自動化整個流程
手動版本仍然需要你執行測試並重複輸入 good 或 bad。如果你的重現程序是一個單一指令,成功時回傳 0、失敗時回傳非零,你可以把整件事交給 Git:
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
# Hand over control to an automated script
git bisect run npm test -- --grep "checkout flow"
Git 會 checkout commit、執行你的指令,並自動分類結果。完成時,你不用碰鍵盤就能得到相同的「first bad commit」輸出。這就是 git bisect 從有用變成不可或缺的時刻。
你的腳本不必是測試套件。它可以是任何可執行並回傳有意義結束碼的東西。以下是一個最小化的 shell script,用來檢查特定的日誌訊息:
#!/bin/bash
# reproduce-bug.sh
# Exit 0 if the bug is NOT present (good)
# Exit 1 if the bug IS present (bad)
if curl -s http://localhost:3000/api/health | grep -q "database_timeout"; then
exit 1 # bug is present
fi
exit 0 # bug is not present
執行它:
chmod +x reproduce-bug.sh
git bisect run ./reproduce-bug.sh
結束碼的約定很嚴格。回傳 0 代表「good」,回傳 1 到 124 代表「bad」,回傳 125 代表「跳過這個 commit,它無法測試」。回傳 125 在某個 commit 無法編譯,或伺服器因為不相關的設定變更而無法啟動時很有用。Git 會跳過那個 commit,並在周圍搜尋。
Bisect 會失效的地方:陷阱
git bisect 假設你的 bug 是單調的。一旦某個 commit 引入了它,每個後代 commit 也都是 bad 的。如果 bug 閃爍不定,在不同 commit 之間時而出現時而消失,二元搜尋就會崩潰。你會得到無意義的結果,或 Git 會抱怨它無法隔離出單一 commit。
非確定性 bug 是最糟糕的違規者。一個只有 10% 機率失敗的 race condition 偶爾會把 bad commit 標成 good,汙染整個搜尋。如果你的重現程序不穩定,先修復不穩定性。或者在你的腳本中多次執行測試,只有每次通過才標記為 good。這比較慢但更可靠。
建置產物是另一個陷阱。如果你從一個變更了建置工具版本的 commit 切換到一個沒有的,你過時的 node_modules 或已編譯的二進位檔可能與 checkout 的程式碼不符。如果你的專案有建置步驟,永遠要在重現腳本中清理並重新建置。
#!/bin/bash
# safer-reproduce.sh
rm -rf node_modules dist
npm ci
npm run build
npm test -- --grep "checkout flow"
這會讓每次迭代多花幾秒鐘,但它消除了整整一類假陽性:bug 其實是出在過時的產物上。
Bisect 做不到的事
git bisect 找到 commit。它不會告訴你為什麼那個 commit 是壞的。一個修改了五千行、觸及四十個檔案的 refactor 可能是 first bad commit,而你仍然必須閱讀 diff 來理解哪一行是罪魁禍首。Bisect 把你的範圍從「過去一個月的某處」縮小到「這個 diff 的某處」。剩下的還是你的工作。
它也幫不上那些存在於 codebase 中但從未被測試抓到的 bug。如果你的測試套件在 bug 被引入時就已經是綠燈,bisect 救不了你。你需要先有重現程序。Bisect 是搜尋工具,不是偵測工具。
何時該用 bisect,而不是 blame 或 log
git blame 在你已經知道哪個檔案包含 bug 時很好用。git log --grep 在你記得 commit 訊息的某些內容時很好用。Bisect 適用於兩者都不知道的情況。你只知道它在 A 點正常運作,在 B 點失敗,而兩者之間的範圍大到無法推論。
如果你的團隊對每個 commit 都執行 CI,有時候讀 CI 歷史可以更快完成 bisect。但 CI 只抓得到它測試的東西。效能迴歸、視覺 bug,或測試漏掉的細微邏輯錯誤,都不會出現在 CI 日誌中。Bisect 對任何你能寫成腳本的重現程序都有效,不論你的 CI 驗證什麼。
如何讓 bisect 成為工作流的一部分
你不需要特別的設定。指令內建在 Git 裡。但有兩個習慣能讓 bisect 在真正需要時更順暢。
第一,為你的發行版加上標籤。Bisect 需要一個已知 good 的 commit,而標籤是最簡單的提供方式。如果你的上次發行是三週前,而且你知道它是乾淨的,git bisect good v1.4.0 就是一個一行程式碼的錨點。
第二,保持你的重現程序最小化。一個跑三十秒的重現程序,執行八次還可以忍受。一個跑十分鐘的就是折磨。在開始 bisect 之前,花五分鐘把重現程序縮小到最小可能的指令。未來的你會感謝你。
完成後,立即執行 git bisect reset。一個帶有未提交變更的 detached HEAD 是丟失工作的好方法。我從慘痛經驗中學到這點,所以你可以不用經歷。