AI 助手說已經給圖書清單加上了標題搜尋。你執行 git diff,看到一個新 import 和幾行修改,卻沒看到搜尋函式的實作。提案還會漏掉目錄裡確實存在的書。要判斷這份修補程式,第一步是弄清 Git 到底比較了哪兩個狀態。

下載 Git diff 練習包,裡面有一個六本書的小程式,以及為教學編寫的修補程式和測試。需要 Node.js 22 或更新版本與 Git,無需安裝相依套件或連網。

先選比較物件,再看紅綠行

Git 中的工作區、暫存區和提交快照是不同的狀態。幾條看起來很相似的命令,回答的問題並不一樣:

命令 實際比較
git diff 暫存區 → 工作區:已跟蹤檔案中尚未暫存的修改
git diff --staged HEAD → 暫存區:準備進入下次提交的改動
git diff HEAD HEAD → 工作區:已跟蹤檔案相對 HEAD 的淨變化,包含已暫存和未暫存的編輯
git diff base HEAD base 和 HEAD 指向的兩個提交快照
git diff base...HEAD 兩者的共同祖先 → HEAD:head 分支自分叉以來的變化

前三種比較都不會包含普通的未跟蹤檔案。同一個檔案也可能同時有已暫存和未暫存的修改。各比較形式見 Git diff 官方文件。

Diff 只比較狀態,不判斷作者。如果 AI 開始前就有人工修改,混在一起的修補程式無法自動指出每一行是誰寫的。需要區分來源時,要保留任務開始前的狀態或提交 ID。審查分支時同樣要選對 base;換了基準,就換了要回答的問題。

找出普通 diff 沒顯示的檔案

解壓後進入 git-diff-reading-lab,建立練習儲存庫:

node scripts/create-workspace.mjs review-workspace
cd review-workspace
git status --short --untracked-files=all

指令碼只接受一個尚不存在的新目錄。它先提交原來的清單程式碼,打上 base 標籤,再留下未提交的提案,不修改任何已有儲存庫。你會看到:

 M src/list.mjs
?? src/search.mjs
?? tests/search.test.mjs

在這裡這種沒有合併衝突的狀態中,第一列描述暫存區,第二列描述工作區。 M 前面的空格有意義:list.mjs 已修改,但還沒暫存。?? 表示未跟蹤檔案。詳細規則見 Git status 短格式。

接著執行:

git diff --name-status
git diff --stat
git diff -- src/list.mjs

輸出裡只有 src/list.mjs。還要在編輯器中開啟 src/search.mjs 和 tests/search.test.mjs。在 macOS 或 Linux 終端,也可以用 git diff --no-index -- /dev/null src/search.mjs 把新原始碼顯示成新增內容;退出碼 1 在這裡表示兩個檔案有差異。練習包裡的 patches/review.patch 包含三個檔案的完整提案,而目前工作區的普通 diff 不包含兩個未跟蹤檔案。

此時 git diff --staged 為空,因為沒有暫存內容;git diff base...HEAD 也為空,因為提案還沒提交,base 和 HEAD 仍是同一個提交。它們都沒有檢查這份工作區提案。

逐行讀一個完整 hunk

完整修補程式中,list.mjs 的部分如下:

diff --git a/src/list.mjs b/src/list.mjs
index f9ed85e..b551297 100644
--- a/src/list.mjs
+++ b/src/list.mjs
@@ -1,6 +1,8 @@
 import { pageBooks } from "./page.mjs";
+import { searchBooks } from "./search.mjs";
 // Keep the caller's array unchanged.
-export function listBooks(books, { page = 1, pageSize = 2 } = {}) {
+export function listBooks(books, { page = 1, pageSize = 2, query = "" } = {}) {
   const sorted = [...books].sort((a, b) => a.title.localeCompare(b.title, "en"));
-  return pageBooks(sorted, { page, pageSize });
+  const currentPage = pageBooks(sorted, { page, pageSize });
+  return searchBooks(currentPage, query);
 }

先看檔案頭,再讀函式:

  • a/ 和 b/ 分別標記舊側、新側,不是分支名。
  • index f9ed85e..b551297 是新舊檔案內容物件的縮寫 ID,不是兩個提交 ID。100644 表示檔案型別及權限未變,仍是不可執行的普通檔案。
  • --- 指向舊檔案,+++ 指向新檔案。新建檔案的舊側是 /dev/null,完整修補程式的下一個檔案就展示了這種形式。
  • @@ -1,6 +1,8 @@ 表示舊側從第 1 行開始,共覆蓋 6 行;新側從第 1 行開始,共覆蓋 8 行。這不是「刪了 6 行、加了 8 行」。
  • Hunk 內以空格開頭的是保留的上下文,- 是刪除行,+ 是新增行。本例有 4 行上下文、2 行刪除、4 行新增,所以舊側合計 6 行,新側合計 8 行。保留的註釋也按一行上下文計數。

檔案頭的完整規則見 Git 修補程式格式文件。行數可以直接對照下載包裡的六行 base 檔案和八行 proposed 檔案核算。這裡講的是普通雙側 hunk;合併提交的 combined diff 使用另一種格式。

沿新增呼叫看它收到什麼輸入

這次需求很具體:在整個目錄中按標題搜尋,忽略大小寫,再對匹配結果分頁;保留原來的標題順序,不改變傳入的資料。

新 helper 會過濾它收到的清單:

export function searchBooks(books, query) {
  const needle = query.toLowerCase();
  return books.filter((book) => book.title.toLowerCase().includes(needle));
}

函式本身能正常搜尋,呼叫方仍可能傳錯範圍。開啟沒有改動的 src/page.mjs,可以看到它會從排好序的書中擷取一頁。提案中的 currentPage 到達 searchBooks 時,已經只剩這一頁。

固定資料每頁顯示兩本,原始第一頁是 Amber Atlas 和 Moon Garden,Star Notes 排在後面。只在第一頁搜尋 sTaR,得到的是 [];需求要求的搜尋結果第一頁應當包含 b5。patches/fix.patch 的修復順序是先搜尋 sorted,再把匹配項交給 pageBooks。

本次先驗證這一處行為。如果真實修補程式影響很多呼叫方,可以繼續做程式碼變更影響分析;變更檔案清單本身不會列出所有消費者。

看清透過的測試呼叫了誰

從 review-workspace 回到練習包根目錄,分別執行:

cd ..
node scripts/run-tests.mjs proposed author
node scripts/run-tests.mjs proposed requirement
node scripts/run-tests.mjs fixed requirement
node scripts/example.mjs

提案附帶的 author 測試能透過:它把完整的兩本書陣列交給 searchBooks,驗證混合大小寫查詢。測試沒有呼叫 listBooks,自然也沒有走到「先分頁、後搜尋」的路徑。這個例子沒有刪除或削弱舊斷言。

另外三項測試從公共清單入口檢查新需求,三個版本使用同一份斷言。兩項原有回歸檢查繼續核對排序、分頁以及輸入沒有被修改:

版本 原有回歸檢查 新的清單搜尋要求 新要求測試退出碼
base:還沒有搜尋功能 2 項透過 0 項透過,3 項失敗 1
proposed:分頁後搜尋 2 項透過 1 項透過,2 項失敗 1
fixed:搜尋後分頁 2 項透過 3 項透過 0

執行 node scripts/run-tests.mjs base requirement,或替換版本名、測試組名,可以重現表中的其他結果。Base 的失敗表示新功能尚未實作,不是舊清單發生回歸。Proposed 的兩項失敗分別是無法湊齊應有的一頁匹配項,以及漏掉原始第一頁之外的匹配項。失敗測試真實返回退出碼 1,不要用 && 串起這些命令,否則後面的命令不會執行。

scripts/example.mjs 為每個版本輸出一行 JSON。在各行的 results 中,sTaR 對應的值依次是 base 的 ["b1","b2"]、proposed 的 [] 和 fixed 的 ["b5"]。這些檢查驗證的是本例的搜尋順序,並不能證明一套生產搜尋功能已全面正確。如果修補程式還改動了測試斷言,再看如何審查 AI 產生的測試。

親自看一次「diff 為空,但修補程式還在」

檢查完三個檔案後,只在這個練習儲存庫中把它們暫存:

cd review-workspace
git add -- src/list.mjs src/search.mjs tests/search.test.mjs
git diff
git diff --staged --stat
git diff --staged

普通 diff 現在為空,因為工作檔案與暫存區一致。已暫存 diff 則包含全部三個檔案,包括新 helper 和新測試。這個操作只改變練習儲存庫的暫存區,沒有提交提案;審查真實專案時,也不要為了讓檔案出現在 diff 中就順手暫存無關改動。

把同一份提案變成兩個已提交版本

要把這個例子放進 Yenpo 的 Mac Git 差異檢視器閱讀,先另建一個兩端都已提交的練習儲存庫。回到解壓後的 git-diff-reading-lab 根目錄;如果仍在 review-workspace 中,先執行 cd ..,再執行:

node scripts/create-workspace.mjs committed-workspace --committed
cd committed-workspace
git status --short --untracked-files=all
git rev-parse base
git rev-parse proposal
git diff base proposal --name-status
git diff base proposal

新模式先提交原始清單檔案並標記為 base,再複製、暫存三個提案改動檔案,建立第二提交併標記為 proposal。指令碼會列印兩個實際提交 ID。HEAD 指向 proposal,其父提交是 base;先前的 review-workspace 保持原樣。

狀態命令應當沒有輸出。兩個快照都已提交,普通 git diff 和 git diff --staged 也為空,但明確的 base → proposal 對比仍包含三個檔案:修改的 src/list.mjs、新增的 src/search.mjs 和 tests/search.test.mjs。還要開啟沒有改動的 src/page.mjs:清單函式仍然把分頁後的子集傳給新搜尋函式。

兩個提交中的原始碼分別與包內 versions/base、versions/proposed 一致。前文的 sTaR 觀察結果仍是提案前 ["b1","b2"]、提案後 [],需求期望則為 ["b5"]。提交提案不會修復搜尋順序;包內另有 fixed 快照及其透過的需求檢查,這個模式不會建立 fixed 提交。

準備指令碼只接受一個新的子目錄,已有檔案、資料夾或符號連結都會被拒絕。它使用明確的虛構教學身分和固定時間,隔離繼承的 Git 身分與設定,設定只寫入新練習儲存庫,不改變使用者全域性身分或其他儲存庫。

在 Yenpo 中開啟 committed-workspace,按Git 對比指南用列印的兩個提交 ID 核對版本:舊側是 base,新側是 proposal。這裡比較的是已提交快照,不包括未暫存或已暫存但未提交的編輯,也不會識別某輪 AI 對話改了哪些程式碼。