Blog

浪費半小時修了一個不存在的 bug

  • #開發工具
  • #除錯
  • #git

那個錯誤畫面是真的

改了三次程式碼、重新截了四次圖,每一次都看到同樣的錯誤。

那個錯誤畫面是真的。錯的是從那個畫面推出來的結論。

起因是要檢查一個網站在手機上會不會破版。用瀏覽器的無頭模式截了一張圖, 指定寬度 390 像素——大約一支手機的寬度:

chrome --headless --window-size=390,14000 --screenshot=shot.png

拿到一張 390 像素寬的圖。圖裡的文字在右邊被切斷,最後幾個字消失在邊界外。

結論很明顯:這個網站在手機上會破版。開始修。

調整排版設定,重新截圖,還是被切斷。又改了一個地方,再截,還是被切斷。

半小時後,換另一種方式去量這個網站,得到三個數字:

  • 內容實際寬度:342
  • 容器右邊界:366
  • 可視範圍:390

每一個都在範圍內。網站從頭到尾都是好的。

被切斷的是那張圖。


瀏覽器做了什麼

瀏覽器有一個最小視窗寬度。我要求 390,它內部用了一個更寬的版面去排版。

然後照著要求,輸出一張 390 像素寬的圖片。多出來的部分直接裁掉。

瀏覽器內部實際排版的寬度
├──────────────────────────────────────┤

你拿到的圖片
├──────────────────────┤ 390px
                        └──────────────┘
                        這一段被裁掉了,
                        而圖上沒有任何痕跡

要檢查的「右邊界有沒有超出去」,答案就在被裁掉的那一段裡。

這張圖有三個特徵:

  1. 寬度精確是 390,要求什麼就給什麼
  2. 沒有任何訊息說「版面其實比較寬,裁掉了一部分」
  3. 被裁掉的那一塊,正好是要檢查的東西

第一點最麻煩。尺寸完全正確,於是沒有任何理由懷疑它。

給一張 500 像素寬的圖,馬上會發現不對。印一行「已裁切」,三秒鐘就懂了。 它精確滿足了要求,於是我把「要求被滿足」當成了「畫面是完整的」。

這兩件事沒有關係,在螢幕上卻長得一樣。


換個場景說一次

假設你請一位助理幫你整理收件匣,他有一條規則:「系統自動發的通知沒有價值,直接刪掉。」

這條規則平常幫你省很多時間。廣告、電子報、自動回覆,刪得乾乾淨淨。

直到某天你要找「密碼重設信」。那封信正好是系統自動發的。

打開收件匣,信件整整齊齊,沒有一行寫著「已刪除 3 封」。於是你得到一個結論: 系統根本沒寄給我。 然後去重寄了一次。

助理沒有做錯事。他照規則辦事,而規則在絕大多數時候是對的。

問題出在你們對「有價值」的定義,在那一刻剛好不同。


兩天前,撞過一模一樣的事

那次是 git。

剛把一個 PR merge 進 main,想確認它真的合併進去了。跑 git log -5,畫面吐出五行:

bf27894  7baa375  c7ec6a6  63704e2  60be3f1

裡面沒有剛做的那次 merge。第一個念頭是「merge 沒成功」。

還好多跑了一個指令:

git rev-parse HEAD
→ 169ec6c

169ec6c 就是那顆 merge commit。它一直都在,merge 從頭到尾都是成功的。

同一台電腦、同一秒鐘、同一個 repo,兩個指令對「最新的 commit 是哪一顆」給了不同答案。

為什麼 git log 少了一行

那台機器裝了一個叫 rtk 的工具,用途是省 token:輸入 git log,它攔下來, 換成自己的版本去跑,把輸出裁短再交還。

它的裁法之一是把 merge commit 濾掉

這條規則多數時候是對的。merge commit 通常長這樣:

Merge pull request #3 from some-branch

沒有程式碼、沒有內容。回顧「這週改了什麼」的時候,它就是雜訊。

但當下要問的是「那次合併有沒有發生」。在這個問題底下,merge commit 是唯一有答案的那一顆。

工具沒有壞掉。明講 git log --merges,它會乖乖照做。它只是有一個我不知道的預設值, 而那個預設值剛好對準我要找的東西。

和截圖那次完全相同的地方

我說「給我 5 筆」。它做的順序是:

  1. 先把 merge commit 丟掉
  2. 從剩下的裡面數 5 筆

於是拿到完整的 5 筆。沒有缺口、沒有空行、行數也對。

跟那張 390 像素寬的圖一樣:要求的規格被精確滿足了,而「規格被滿足」 沒有告訴你任何關於完整性的事。


這兩次可以合成一句話

當一個工具替你做了取捨,它通常會先取捨,再來滿足你的規格。

你以為的順序 完整資料 6 筆 取最新 5 筆 輸出 5 筆 實際的順序 完整資料 6 筆 濾掉 merge commit 取最新 5 筆 輸出 5 筆 ↑ 這一步不會出現在任何地方 兩邊都是 「正好 5 筆」

兩條路徑的終點都寫著「5 筆」。你檢查的是數量,而數量在兩種情況下都正確。

那個「剛剛好」沒有任何資訊量。它只證明工具聽懂了你的話。

這種錯誤還有一個討厭的性質:它會壓抑你的懷疑。 明顯缺漏的清單會讓你去查, 看起來完整的清單會讓你直接下結論。


可以帶走的兩條做法

一、判斷「有沒有發生」時,問會回傳識別碼的指令

清單經過整理才交到你手上,整理的過程可能丟掉東西。識別碼指向一個明確的目標, 沒有整理的空間。

你想知道容易被騙的問法比較可靠的問法
merge / push 成功了嗎git loggit rev-parse HEAD
遠端跟本機同步了嗎看 log 前幾行git ls-remote origin <branch> 比對 SHA
這個版面在手機上正常嗎指定寬度截圖直接量元素的 scrollWidthclientWidth
這個字串有沒有出現在檔案裡看搜尋結果幾筆先確認搜尋工具的預設排除清單

二、問兩次,用不同的工具

那天真正救了我的,是兩個工具給出矛盾的答案:

git rev-parse HEAD   →  169ec6c
git log -1           →  bf27894

兩個都宣稱自己指的是最新的 commit,其中一個在說謊。只用其中一個的話, 這個錯誤永遠不會浮上來。

截圖那次也一樣。盯著同一張圖看半小時都不會發現問題,因為問題在那張圖本身。 換一種量法,三個數字就把答案給了。


這一課該放在哪一層

git log 那個坑,兩天前早就寫成筆記了。寫完兩天後,還是在截圖上踩了同一個形狀的坑。 筆記躺在硬碟裡,一次也沒有在需要的那一刻出現。

一條知識可以放三個地方,差別在誰負責執行它

誰執行什麼時候失效
筆記未來的你得先想到「我好像寫過這個」才會去查。而想不到的時候,正是最需要它的時候
常駐規則CLAUDE.md 之類)AI 助手,每個對話都讀長對話會稀釋它,當下的任務會壓過它
Hook工具本身,指令送出前只有寫錯才失效

由上往下,可靠度遞增,寫起來也遞增麻煩。所以不必每一課都做到底。

判準只有一句:同一個坑踩第二次,就往下搬一層。

罕見的那一條,放常駐規則就好

截圖那個坑只踩過一次,貼一段文字進 CLAUDE.md 就夠了:

## 判斷「某件事有沒有發生」時的取證規則

要確認 merge、push、部署、同步這類「發生了沒」的問題,
用會回傳識別碼的指令,避開會回傳清單或畫面的指令。

- merge / push → `git rev-parse HEAD``git ls-remote origin <branch>` 比對 SHA
- 版面有沒有溢出 → 量元素的 `scrollWidth``clientWidth`,避免用截圖判讀
- 檔案裡有沒有某字串 → 先確認搜尋工具的預設排除清單

理由:任何「幫你省事」的中間層都可能先做取捨、再滿足你指定的規格。
於是輸出會精確符合你的要求,而「符合要求」不包含任何關於完整性的資訊。

只有清單或畫面時,換第二個獨立工具再問一次。
兩個答案不一致時,相信會回傳識別碼的那一個。

踩第二次的那一條,降到 hook

git log 這條踩過兩次,所以它不該再靠任何人記得。我寫了一支 hook: 偵測到 git log 要執行,就先印一行提醒。不擋,而且一個對話只講一次—— 會擋住正常工作、或是一直跳出來的提醒,最後一定會被關掉。

寫完之後跑了單元測試,九種情況全過。接著在真實環境下了一次 git log, 它跳出來了;再下一次,它又跳了一次——「只提醒一次」失效。

查下去發現 hook 是對的,錯的是測試指令。我寫成 rm -f marker && git log, 而 hook 在指令執行之前就跑完了。真正的順序是:hook 建立 marker → 我的 rm 把它刪掉 → git log 執行。

我的量測動作把要量測的狀態清掉了。

這篇文章講的東西,在驗證這篇文章的結論時又發生了一次。


最後

現在多了一個習慣。要判斷一件事「有沒有發生」的時候,會先問自己一句:

手上這個答案,是原始的,還是有人幫我整理過的?

整理過的話,就再問一次——用一個不會整理的工具。