An AI assistant says it has added title search to a book list. git diff shows a new import and a few changed lines. The search implementation is missing from the output, and the proposed code returns no results for a book that is in the catalog. Before deciding whether the patch is correct, find out what Git actually compared.
Download the Git diff lab, a six-book application with a sample patch and tests written for this tutorial. You need Node.js 22 or later and Git; no dependencies or network access are required.
Choose the two states before reading the lines
Git keeps a working tree, an index containing the staged snapshot, and committed snapshots. These commands answer different questions:
| Command | What it compares |
|---|---|
git diff |
Index → working tree: unstaged changes to tracked files |
git diff --staged |
HEAD → index: what is staged for the next commit |
git diff HEAD |
HEAD → working tree: the net tracked change, including staged and unstaged edits |
git diff base HEAD |
The two committed snapshots named base and HEAD |
git diff base...HEAD |
Their merge base → HEAD: the head branch’s change since divergence |
Ordinary untracked files are absent from the first three comparisons. A file can also have both staged and unstaged edits. See the Git diff reference for these comparison forms.
Diff output describes states, not authorship. If earlier manual changes are mixed with an assistant’s edits, the resulting patch cannot identify which lines the assistant wrote. Keep the pre-task state or commit IDs if that distinction matters. A branch comparison also needs the intended base; choosing a different one changes the question.
Find the file that plain diff leaves out
Unzip the download and enter git-diff-reading-lab. Create a disposable repository:
node scripts/create-workspace.mjs review-workspace
cd review-workspace
git status --short --untracked-files=all
The setup script refuses an existing destination. It commits the original listing code, tags that commit base, and leaves the proposed changes uncommitted. Its output is reproducible without touching an existing repository.
M src/list.mjs
?? src/search.mjs
?? tests/search.test.mjs
For this ordinary, non-conflicted status, the first column describes the index and the second describes the working tree. The leading space in M matters: list.mjs is modified but unstaged. ?? identifies untracked files. These conventions are defined in Git status’s short format.
Now run:
git diff --name-status
git diff --stat
git diff -- src/list.mjs
Only src/list.mjs appears. Read src/search.mjs and tests/search.test.mjs separately in your editor. On macOS or Linux, you can also display the new source as an addition with git diff --no-index -- /dev/null src/search.mjs; exit code 1 means the files differ. The download’s patches/review.patch includes all three changed files, whereas plain diff in this untracked state does not.
git diff --staged is empty here because nothing is staged. git diff base...HEAD is also empty because the proposal is not committed and base still equals HEAD. Neither command has reviewed the working proposal.
Read one complete hunk
The list.mjs portion of the supplied patch is:
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);
}
Read the headers before the function:
a/andb/mark the old and new sides; they are not branch names.index f9ed85e..b551297contains abbreviated file-content object IDs, not the two commit IDs.100644is the unchanged regular, non-executable file mode.---identifies the old file and+++the new one. A newly added file uses/dev/nullon its old side, as the next file inreview.patchshows.@@ -1,6 +1,8 @@starts at old line 1 and covers 6 old lines, then starts at new line 1 and covers 8 new lines. The numbers are spans, not counts of deletions and additions.- Inside the hunk, a space prefixes retained context,
-a removed line, and+an added line. Here, 4 context lines plus 2 removals account for the old span; 4 context lines plus 4 additions account for the new span. The retained comment counts as context too.
The Git patch format documentation explains the headers. You can check the hunk arithmetic directly against the six-line base file and eight-line proposed file in the download. These are ordinary two-sided hunks; a combined merge diff has a different format.
Follow the new call into its input
The request is specific: search the full catalog by title without case sensitivity, then paginate the matching books. Keep the existing title order and leave the input unchanged.
The new helper filters whatever list it receives:
export function searchBooks(books, query) {
const needle = query.toLowerCase();
return books.filter((book) => book.title.toLowerCase().includes(needle));
}
The helper can work correctly while its caller supplies the wrong list. Open the unchanged src/page.mjs: it slices the sorted books down to one page. In the proposed hunk, currentPage already holds that slice when it reaches searchBooks.
With the fixture’s page size of two, the original first page contains Amber Atlas and Moon Garden. Star Notes is later in the catalog. Searching that first page for sTaR returns [], although the requested first page of search results should contain book b5. The fix in patches/fix.patch searches sorted first, then passes the matches to pageBooks.
This is the one behavior to verify. For a larger change with many consumers, continue with code change impact analysis; the changed-file list alone will not enumerate them.
Check what the passing test actually called
Return from review-workspace to the lab’s top-level folder and run these commands separately:
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
The proposed author test passes: it gives searchBooks a complete two-book array and checks a mixed-case query. It never calls listBooks, so it never exercises pagination before search. No old assertion was removed or weakened.
The same three public-list requirements run against each version. The download also supplies two unchanged regression checks for ordering, pagination, and non-mutation:
| Version | Existing regression checks | New public-list requirements | Requirement exit code |
|---|---|---|---|
base: no search feature |
2 passed | 0 passed, 3 failed | 1 |
proposed: search after pagination |
2 passed | 1 passed, 2 failed | 1 |
fixed: search before pagination |
2 passed | 3 passed | 0 |
Run node scripts/run-tests.mjs base requirement or replace the version and suite names to reproduce the other rows. The base failures mean the new feature is absent, not that the old listing regressed. The proposed failures concern a full result page and a match beyond the original first page. Both failing suites genuinely exit with code 1; joining them with && would stop the remaining commands.
scripts/example.mjs prints one JSON object per version. In each object’s results, the sTaR value is ["b1","b2"] for base, [] for proposed, and ["b5"] for fixed. These narrow checks answer the search-order question. They do not certify a production search implementation. If a patch also changes its assertions, use the separate guide to reviewing AI-generated tests.
See why an empty diff can still leave a patch to review
After inspecting the three files, stage them only in the disposable lab repository:
cd review-workspace
git add -- src/list.mjs src/search.mjs tests/search.test.mjs
git diff
git diff --staged --stat
git diff --staged
Plain diff is now empty because the working files match the index. The staged diff contains all three files, including the new helper and test. This exercise changes the lab’s staging area; it does not commit the proposal. Avoid staging unrelated files just to make a diff show them in a real review.
Compare the proposal as two committed versions
To use the same example in Yenpo’s Git diff viewer for Mac, create a separate exercise repository with both versions committed. Return to the extracted git-diff-reading-lab folder—cd .. if you are still in review-workspace—then run:
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
The new mode commits the original listing files as base, copies and stages the three proposed changes, then creates a second commit tagged proposal. It prints the actual IDs for both commits. proposal is HEAD; base is its parent. Your earlier review-workspace is left as it was.
The status command should print nothing. Both snapshots are committed, so plain git diff and git diff --staged are also empty. The explicit base → proposal comparison still contains the modified src/list.mjs, added src/search.mjs, and added tests/search.test.mjs. Open the unchanged src/page.mjs as well: the list function still passes a paginated subset to the new search function.
These commits contain the same source as the lab’s versions/base and versions/proposed. The earlier sTaR observation remains ["b1","b2"] before the proposal and [] after it; the requested result is ["b5"]. Committing the proposal preserves its search-order problem. The separate fixed snapshot and its passing requirements remain in the lab; this mode creates no fixed commit.
The setup accepts only a new child directory and refuses existing files, folders, or symlinks. It uses an explicit fictional teaching identity and fixed dates, ignores inherited Git identity/configuration, and writes settings only inside the new exercise repository. It does not change your global Git identity or another repository.
Open committed-workspace in Yenpo. Use the two printed commit IDs to check the older and newer versions against the Git comparison guide: old side base, new side proposal. The comparison concerns those committed snapshots. It does not include unstaged or staged edits, or identify the changes made during an AI conversation turn.