AI アシスタントは本の一覧にタイトル検索を追加したと言います。git diff には import と数行の変更しかなく、検索本体は見えません。しかもカタログに存在する本が見つかりません。正しさを判断する前に、Git が何を比較したかを確認します。

Git diff ラボには、6 冊の本を扱う小さなアプリと、この教材用に書いたパッチとテストが入っています。Node.js 22 以降と Git が必要です。依存パッケージのインストールやネットワーク接続は不要です。

行を読む前に、二つの状態を選ぶ

Git には作業ツリー、ステージ済みスナップショットを持つインデックス、コミット済みのスナップショットがあります。コマンドごとに問いが異なります。

コマンド 比較するもの
git diff インデックス → 作業ツリー。追跡中ファイルの未ステージ変更
git diff --staged HEAD → インデックス。次のコミットにステージした内容
git diff HEAD HEAD → 作業ツリー。ステージ済み・未ステージを含む追跡中ファイルの差
git diff base HEAD base と HEAD が指すコミット済みスナップショット
git diff base...HEAD 共通祖先 → HEAD。分岐後に head 側が加えた変更

通常の未追跡ファイルは最初の三つに含まれません。同じファイルにステージ済みと未ステージの編集がある場合もあります。比較形式は Git diff のリファレンスで確認できます。

差分は状態の違いで、作者を示しません。先に行った手動変更と AI の編集が混ざれば、どの行を 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

競合のない通常の状態では、1 列目がインデックス、2 列目が作業ツリーです。 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 は 3 ファイルをすべて含みますが、この未追跡状態の通常の diff には含まれません。

何もステージしていないため git diff --staged は空です。提案は未コミットで base は HEAD と等しいため、git diff base...HEAD も空です。どちらも作業中の変更案をレビューしたことにはなりません。

ハンクを一つ、全体で読む

配布パッチの 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 で、review.patch の次のファイルに例があります。
  • @@ -1,6 +1,8 @@ は旧 1 行目から 6 行、新 1 行目から 8 行です。削除・追加の件数ではなく行範囲を表します。
  • 空白で始まる行は残る文脈、- は削除、+ は追加です。ここでは文脈 4 行と削除 2 行で旧範囲、文脈 4 行と追加 4 行で新範囲になります。残ったコメントも文脈に数えます。

Git パッチ形式にヘッダーの説明があります。配布物の旧 6 行、新 8 行のファイルで計算を確かめられます。これは通常の二側比較であり、複合マージ差分は別形式です。

新しい呼び出しの入力を追う

要件は、カタログ全体を大文字小文字を区別せずタイトル検索してから、一致した本をページ分割することです。既存のタイトル順を保ち、入力は変更しません。

新ヘルパーは受け取った一覧をフィルターします。

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

ヘルパーが正しくても、呼び出し元の一覧が違う場合があります。未変更の src/page.mjs は並べた本を 1 ページに切り出します。提案されたハンクでは、searchBooks に届く時点で currentPage はすでにその部分集合です。

ページサイズは 2 で、元の先頭ページは 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

作者の提案テストは成功します。完全な 2 冊の配列を searchBooks に渡し、大文字小文字が混じる検索を確認するからです。listBooks は呼ばず、検索前のページ分割は通りません。古いアサーションの削除や弱体化はありません。

各版に同じ 3 件の公開一覧要件を適用します。配布物には順序、ページ分割、非変更を確認する未変更の回帰テストも 2 件あります。

版 既存の回帰確認 新しい公開一覧の要件 要件の終了コード
base:検索なし 2 成功 0 成功・3 失敗 1
proposed:ページ分割後に検索 2 成功 1 成功・2 失敗 1
fixed:検索後にページ分割 2 成功 3 成功 0

node scripts/run-tests.mjs base requirement の版とスイート名を変えて各行を再現できます。base の失敗は新機能がないことを示し、旧一覧の回帰ではありません。提案の失敗は検索結果のページ全体と元の先頭ページ外の一致です。失敗したスイートは実際に 1 で終了するため、&& でつなぐと後続が止まります。

scripts/example.mjs は版ごとに JSON を表示します。results 内の sTaR は base で ["b1","b2"]、proposed で []、fixed で ["b5"] です。この確認が答えるのは検索順序の問題で、本番の検索実装全体を保証しません。アサーションも変更するパッチならAI 生成テストのレビューもご覧ください。

空の diff でもレビューが残る理由

3 ファイルを読んだら、演習用リポジトリ内だけでステージします。

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 は空になります。ステージ済みの差分には新ヘルパーとテストを含む 3 ファイルが現れます。この演習はステージを変更するだけでコミットしません。実際のレビューでは表示のためだけに無関係なファイルをステージしないでください。

二つのコミット済み版として比較する

同じ例を Mac 向け Yenpo の 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 タグ付きの 2 番目のコミットを作ります。実際の ID を表示し、HEAD は proposal、親は base です。先に作った review-workspace はそのままです。

status の出力は空になるはずです。両方がコミット済みなので 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 を開きます。表示された二つの ID をGit 比較ガイドに照らし、旧側 base、新側 proposal を確認します。対象はこのコミット済みの二つで、ステージ済み・未ステージの編集は含まず、AI の一回の会話で加わった変更を特定するものでもありません。