Skip to content

Commit 6aebba2

Browse files
ymansurozerclaude
andcommitted
feat: focused reviews — rename handling, guide movedFrom, and a churn-skim taxonomy
Moved files no longer drown a review in delete+add noise, and "give me a focused review" is now a defined contract phrase: - Explicit -M on every git diff: committed/staged renames render merged (edits only + a "moved from" badge) regardless of diff.renames config; pure renames surface as a muted "renamed · no changes" row folded into the Skimmed group instead of vanishing or dominating the diff. - Working-mode pairing: a plain `mv` (deleted tracked file + byte-identical untracked file, unique 1:1) auto-merges into the same rename presentation; whole-file Approve stages both paths so the index records a real rename. - Guide movedFrom: the agent declares moved+edited files; the desk merges the pair so only the real edits need eyes (working repo mode, strict on a new guide, silent fallback on carry-forward). - Decisions/comments/sign-offs migrate old→new path across the reload that introduces a rename instead of silently dropping. - Focused review defined in the spec: a reviewer-requested modifier with a default churn policy (lockfiles/generated → skim, import-only blocks → skimBlocks, moved+edited → movedFrom, logic/config → never skim) and a display-only `focused` guide flag that badges the overview. - Spec deduped alongside (single statements for the READ-ONLY question rule and the reload-resets invariant; the loop example iterates questions[] with a space-safe while-read). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJ3L7PhcBSV6awUcBa8KuK
1 parent bbf0bc8 commit 6aebba2

27 files changed

Lines changed: 1066 additions & 121 deletions

skills/galley/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The review is the human's; the agent acts on the decisions and answers questions
1414

1515
## When to use it
1616

17-
Reach for Galley when the user should review something turn-by-turn: **code changes you made** (the working tree or staged diff), **a markdown plan or single artifact**, or **a branch / PR**. Use it when the user asks to "open the Galley", or whenever a diff is better reviewed interactively than pasted into chat.
17+
Reach for Galley when the user should review something turn-by-turn: **code changes you made** (the working tree or staged diff), **a markdown plan or single artifact**, or **a branch / PR**. Use it when the user asks to "open the Galley", or whenever a diff is better reviewed interactively than pasted into chat. When the user asks for a **focused review** (mechanical churn — lockfiles, generated code, import churn, moved files — de-emphasized so only the real changes stand out), attach a guide; `galley spec` documents the focused-review schema.
1818

1919
## Getting the tool
2020

skills/galley/agents-snippet.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ When the user should review a plan, a PR, or code changes you've made, hand it t
1212
- **A markdown plan / single artifact**`galley file <path> &`.
1313
- **A branch / PR**`galley pr <ref> &`.
1414

15+
For a **focused review** (mechanical churn de-emphasized so only the real changes stand out), attach a guide — `galley spec` documents it.
16+
1517
**For the full contract — the `await`/`comment`/`reload` loop, event shapes, `ReviewResult`, how to act on accepted/rejected/requested changes, the guided-review schema, and all options — run `galley spec` and follow it (once per session before your first review).**

src/cli.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ import {
1616
mergeReviewState,
1717
persistReview,
1818
readDeskLock,
19+
resolveMovedFrom,
1920
resolveSkim,
2021
reviewDir,
2122
sanitizeSession,
2223
stablePort,
2324
syncGitState,
2425
} from "./state.js";
25-
import type { ReviewMode } from "./types.js";
26+
import type { Guide, ReviewMode } from "./types.js";
2627
import { maybeOfferUpdate } from "./update.js";
2728

2829
function parseArgs(argv: string[]) {
@@ -433,18 +434,36 @@ async function runDesk(
433434
return;
434435
}
435436
const saved = await loadLatestReview(base.root, session);
436-
const state = mergeReviewState(base, saved);
437-
// Optional agent-generated guided review, attached at startup. Required to be a readable,
438-
// valid JSON file when --guide is passed; survives reload via the state merge.
437+
// Load the new guide (if any) up front — loadGuideArg validates it. Guide-declared moves
438+
// (movedFrom) must merge into `base` BEFORE reconciliation, so the merged pair's distinct paths
439+
// drive mergeReviewState's rename migration (issue 01). A new guide resolves strictly (an
440+
// unresolvable move aborts the launch); a guide carried forward by a previous session resolves
441+
// leniently off the saved state (the move drops back to delete+add).
442+
let newGuide: Guide | undefined;
439443
if (args.guide !== undefined) {
440-
const guide = loadGuideArg(args.guide);
441-
if (!guide) {
444+
const loaded = loadGuideArg(args.guide);
445+
if (!loaded) {
446+
process.exitCode = 1;
447+
return;
448+
}
449+
newGuide = loaded;
450+
}
451+
const moveGuide = newGuide ?? saved?.guide;
452+
if (moveGuide) {
453+
const moved = resolveMovedFrom(base, moveGuide, { strict: !!newGuide });
454+
if (!moved.ok) {
455+
console.error(`Invalid guide: ${moved.reason}.`);
442456
process.exitCode = 1;
443457
return;
444458
}
459+
}
460+
const state = mergeReviewState(base, saved);
461+
// Optional agent-generated guided review, attached at startup. Required to be a readable,
462+
// valid JSON file when --guide is passed; survives reload via the state merge.
463+
if (newGuide) {
445464
// Stamp the diff hash the guide was generated against; if a later reload advances the
446465
// diff past it, the desk flags the guide as possibly stale (slice 05).
447-
state.guide = { ...guide, baseDiffHash: state.baseDiffHash };
466+
state.guide = { ...newGuide, baseDiffHash: state.baseDiffHash };
448467
// Resolve skim spans against the fresh diff and stamp the collapsed blocks. Strict at
449468
// initial attach: an unresolvable span aborts the launch naming the offending field, like
450469
// any other invalid-guide input.

src/diffsource.test.ts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { execFileSync } from "node:child_process";
3-
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3+
import { mkdtempSync, writeFileSync, rmSync, renameSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import path from "node:path";
66
import { test, before, after } from "node:test";
@@ -138,3 +138,145 @@ test("repo mode: staged diff ignores untracked files", async () => {
138138
assert.equal(src, null); // nothing staged, and untracked must not leak into staged mode
139139
rmSync(path.join(root, "new.ts"));
140140
});
141+
142+
// ── git -M rename handling (issue 01) ────────────────────────────────────────
143+
// Self-contained repos (own git init) so ordering can't collide with the shared-root tests above,
144+
// and so we can force `diff.renames=false` — proving detection rides our explicit -M, not config.
145+
146+
function freshRepo(renamesOff: boolean): { dir: string; main: string } {
147+
const dir = mkdtempSync(path.join(tmpdir(), "galley-rn-"));
148+
const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString();
149+
g(["init", "-q"]);
150+
g(["config", "user.email", "t@t.co"]);
151+
g(["config", "user.name", "tester"]);
152+
if (renamesOff) g(["config", "diff.renames", "false"]);
153+
writeFileSync(path.join(dir, "old.txt"), "a\nb\nc\n");
154+
g(["add", "."]);
155+
g(["commit", "-qm", "init"]);
156+
const main = g(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
157+
return { dir, main };
158+
}
159+
160+
test("pr mode: rename+edit with diff.renames=false → one file at the new path, edited lines only", async () => {
161+
const { dir, main } = freshRepo(true);
162+
const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString();
163+
g(["checkout", "-q", "-b", "feature"]);
164+
g(["mv", "old.txt", "new.txt"]);
165+
writeFileSync(path.join(dir, "new.txt"), "a\nB\nc\n"); // one edited line
166+
g(["commit", "-qam", "rename + edit"]);
167+
const src = await buildDiffSource({ mode: "pr", root: dir, base: main });
168+
assert.ok(src);
169+
assert.equal(src!.files.length, 1);
170+
const f = src!.files[0]!;
171+
assert.equal(f.path, "new.txt");
172+
assert.equal(f.oldFile.name, "old.txt"); // distinct names → @pierre infers rename-changed
173+
assert.equal(f.newFile.name, "new.txt");
174+
assert.equal(f.oldFile.contents, "a\nb\nc\n"); // full old content (moved, not re-added)
175+
assert.equal(f.newFile.contents, "a\nB\nc\n");
176+
assert.equal(src!.changes.length, 1); // only the edited line is up for review, not the whole file
177+
rmSync(dir, { recursive: true, force: true });
178+
});
179+
180+
test("pr mode: a pure committed rename → a zero-hunk entry at the new path, distinct names", async () => {
181+
const { dir, main } = freshRepo(true);
182+
const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString();
183+
g(["checkout", "-q", "-b", "feature"]);
184+
g(["mv", "old.txt", "new.txt"]);
185+
g(["commit", "-qam", "pure rename"]);
186+
const src = await buildDiffSource({ mode: "pr", root: dir, base: main });
187+
assert.ok(src, "a pure rename must not vanish from the review");
188+
assert.equal(src!.files.length, 1);
189+
const f = src!.files[0]!;
190+
assert.equal(f.path, "new.txt");
191+
assert.equal(f.oldFile.name, "old.txt");
192+
assert.equal(f.newFile.name, "new.txt");
193+
assert.equal(f.hunks.length, 0); // no content change
194+
assert.equal(f.oldFile.contents, f.newFile.contents); // identical → the muted moved row in the UI
195+
assert.equal(src!.changes.length, 0);
196+
rmSync(dir, { recursive: true, force: true });
197+
});
198+
199+
test("staged mode: a staged git mv + edit renders as a merged rename", async () => {
200+
const { dir } = freshRepo(false);
201+
const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString();
202+
g(["mv", "old.txt", "new.txt"]);
203+
writeFileSync(path.join(dir, "new.txt"), "a\nB\nc\n");
204+
g(["add", "."]);
205+
const src = await buildDiffSource({ mode: "repo", root: dir, staged: true });
206+
assert.ok(src);
207+
assert.equal(src!.files.length, 1);
208+
const f = src!.files[0]!;
209+
assert.equal(f.path, "new.txt");
210+
assert.equal(f.oldFile.name, "old.txt");
211+
assert.equal(f.newFile.name, "new.txt");
212+
assert.equal(src!.changes.length, 1);
213+
rmSync(dir, { recursive: true, force: true });
214+
});
215+
216+
// ── working-mode move pairing (issue 02) ─────────────────────────────────────
217+
// A plain `mv` (no git mv) shows as a full deletion + a full untracked addition; git can't see
218+
// the move. buildDiffSource pairs byte-identical halves into one rename-pure entry.
219+
220+
test("repo mode: a plain mv (no edit) pairs into one rename-pure entry", async () => {
221+
const { dir } = freshRepo(false);
222+
renameSync(path.join(dir, "old.txt"), path.join(dir, "new.txt")); // plain mv, not git mv
223+
const src = await buildDiffSource({ mode: "repo", root: dir });
224+
assert.ok(src);
225+
assert.equal(src!.files.length, 1); // merged, not delete + add
226+
const f = src!.files[0]!;
227+
assert.equal(f.path, "new.txt");
228+
assert.equal(f.oldPath, "old.txt");
229+
assert.equal(f.newPath, "new.txt");
230+
assert.equal(f.oldFile.name, "old.txt");
231+
assert.equal(f.newFile.name, "new.txt");
232+
assert.equal(f.oldFile.contents, f.newFile.contents); // identical → the muted moved row
233+
assert.equal(src!.changes.length, 0);
234+
rmSync(dir, { recursive: true, force: true });
235+
});
236+
237+
test("repo mode: two identical untracked copies of a deleted file → nothing paired (ambiguity)", async () => {
238+
const { dir } = freshRepo(false);
239+
rmSync(path.join(dir, "old.txt")); // delete the tracked file…
240+
writeFileSync(path.join(dir, "copy1.txt"), "a\nb\nc\n"); // …and two byte-identical untracked copies
241+
writeFileSync(path.join(dir, "copy2.txt"), "a\nb\nc\n");
242+
const src = await buildDiffSource({ mode: "repo", root: dir });
243+
assert.ok(src);
244+
assert.deepEqual(
245+
src!.files.map((f) => f.path).sort(),
246+
["copy1.txt", "copy2.txt", "old.txt"], // deletion stays + two additions, none merged
247+
);
248+
assert.equal(
249+
src!.files.find((f) => f.path === "old.txt")!.newPath,
250+
undefined, // still a plain deletion, not a rename
251+
);
252+
rmSync(dir, { recursive: true, force: true });
253+
});
254+
255+
test("repo mode: mv + edit is NOT paired — renders as delete + add (exact-content only)", async () => {
256+
const { dir } = freshRepo(false);
257+
rmSync(path.join(dir, "old.txt"));
258+
writeFileSync(path.join(dir, "new.txt"), "a\nB\nc\n"); // moved AND edited → not byte-identical
259+
const src = await buildDiffSource({ mode: "repo", root: dir });
260+
assert.ok(src);
261+
assert.deepEqual(src!.files.map((f) => f.path).sort(), ["new.txt", "old.txt"]);
262+
assert.equal(src!.files.find((f) => f.path === "old.txt")!.newPath, undefined); // deletion
263+
assert.equal(src!.files.find((f) => f.path === "new.txt")!.oldFile.contents, ""); // untracked add
264+
rmSync(dir, { recursive: true, force: true });
265+
});
266+
267+
test("mode-only and same-path binary changes still produce no review entry", async () => {
268+
const { dir } = freshRepo(false);
269+
const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString();
270+
const shPath = path.join(dir, "s.sh");
271+
const binPath = path.join(dir, "img.bin");
272+
writeFileSync(shPath, "#!/bin/sh\necho hi\n");
273+
writeFileSync(binPath, Buffer.from([0, 1, 2, 0, 255]));
274+
g(["add", "."]);
275+
g(["commit", "-qm", "add binary + script"]);
276+
// A mode-only change on the script, and a byte change to the binary — both zero-hunk, same-path.
277+
execFileSync("chmod", ["755", shPath], { cwd: dir });
278+
writeFileSync(binPath, Buffer.from([0, 9, 9, 9, 255]));
279+
const src = await buildDiffSource({ mode: "repo", root: dir });
280+
assert.equal(src, null); // no text hunks and same-path → both drop; nothing else → null
281+
rmSync(dir, { recursive: true, force: true });
282+
});

src/git.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,66 @@ test("a pure addition keys on the deletions side count of zero", () => {
3737
const blocks = changeBlocks(parseUnifiedDiff(diff)[0]!.hunks[0]!);
3838
assert.equal(changeStableKeyFromBlock(blocks[0]!), "additions:4:0:1");
3939
});
40+
41+
// ── git -M rename handling (issue 01) ────────────────────────────────────────
42+
43+
test("a pure rename (zero hunks, distinct paths) is kept, from the rename headers", () => {
44+
const diff = `diff --git a/old name.txt b/new name.txt
45+
similarity index 100%
46+
rename from old name.txt
47+
rename to new name.txt
48+
`;
49+
const files = parseUnifiedDiff(diff);
50+
assert.equal(files.length, 1); // NOT filtered out despite zero hunks
51+
// Paths come from the rename headers, so spaces survive (the diff --git regex would mangle them).
52+
assert.equal(files[0]!.oldPath, "old name.txt");
53+
assert.equal(files[0]!.newPath, "new name.txt");
54+
assert.equal(files[0]!.hunks.length, 0);
55+
});
56+
57+
test("a rename+edit keeps distinct paths AND its hunk", () => {
58+
const diff = `diff --git a/old.txt b/new.txt
59+
similarity index 80%
60+
rename from old.txt
61+
rename to new.txt
62+
index 1111111..2222222 100644
63+
--- a/old.txt
64+
+++ b/new.txt
65+
@@ -1,2 +1,2 @@
66+
a
67+
-b
68+
+B
69+
`;
70+
const files = parseUnifiedDiff(diff);
71+
assert.equal(files.length, 1);
72+
assert.equal(files[0]!.oldPath, "old.txt");
73+
assert.equal(files[0]!.newPath, "new.txt");
74+
assert.equal(files[0]!.hunks.length, 1);
75+
});
76+
77+
test("a mode-only change (zero hunks, same path) stays dropped", () => {
78+
const diff = `diff --git a/s.sh b/s.sh
79+
old mode 100644
80+
new mode 100755
81+
`;
82+
assert.equal(parseUnifiedDiff(diff).length, 0);
83+
});
84+
85+
test("a same-path binary diff stays dropped", () => {
86+
const diff = `diff --git a/img.png b/img.png
87+
index 1111111..2222222 100644
88+
Binary files a/img.png and b/img.png differ
89+
`;
90+
assert.equal(parseUnifiedDiff(diff).length, 0);
91+
});
92+
93+
test("a renamed binary (distinct paths + Binary line) is dropped, not read as text", () => {
94+
const diff = `diff --git a/old.png b/new.png
95+
similarity index 60%
96+
rename from old.png
97+
rename to new.png
98+
index 1111111..2222222 100644
99+
Binary files a/old.png and b/new.png differ
100+
`;
101+
assert.equal(parseUnifiedDiff(diff).length, 0);
102+
});

src/git.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export async function getBranch(cwd: string) {
4848

4949
export function parseUnifiedDiff(raw: string): DiffFile[] {
5050
const files: DiffFile[] = [];
51+
// Files whose content section is binary ("Binary files … differ" / "GIT binary patch") — a
52+
// transient parse-time set (never a DiffFile field, so it can't leak into ReviewFile). Used
53+
// to drop a renamed binary: it's zero-hunk with distinct paths, so the rename-keep rule below
54+
// would otherwise keep it and its bytes would later be read as mangled utf8 text (fileAt).
55+
const binaryFiles = new WeakSet<DiffFile>();
56+
// Files whose paths came from the `rename from`/`rename to` extended headers — those give the
57+
// raw, unprefixed path (reliable for paths with spaces, unlike the `diff --git`/`--- +++`
58+
// regexes), so once set we don't let the later `--- a/`/`+++ b/` lines clobber them.
59+
const renamedFiles = new WeakSet<DiffFile>();
5160
let file: DiffFile | undefined;
5261
let hunk: DiffHunk | undefined;
5362
let oldLine = 0;
@@ -68,12 +77,30 @@ export function parseUnifiedDiff(raw: string): DiffFile[] {
6877
continue;
6978
}
7079
if (!file) continue;
80+
// git -M rename headers. Authoritative over the diff --git / --- +++ paths (see above), and
81+
// they arrive BEFORE any hunk, so they must be handled ahead of the `!hunk` guard below.
82+
if (rawLine.startsWith("rename from ")) {
83+
file.oldPath = rawLine.slice("rename from ".length);
84+
renamedFiles.add(file);
85+
continue;
86+
}
87+
if (rawLine.startsWith("rename to ")) {
88+
file.newPath = rawLine.slice("rename to ".length);
89+
renamedFiles.add(file);
90+
continue;
91+
}
92+
if (rawLine.startsWith("Binary files ") || rawLine.startsWith("GIT binary patch")) {
93+
binaryFiles.add(file);
94+
continue;
95+
}
7196
if (rawLine.startsWith("--- ")) {
97+
if (renamedFiles.has(file)) continue;
7298
const value = rawLine.slice(4).trim();
7399
file.oldPath = value === "/dev/null" ? undefined : value.replace(/^a\//, "");
74100
continue;
75101
}
76102
if (rawLine.startsWith("+++ ")) {
103+
if (renamedFiles.has(file)) continue;
77104
const value = rawLine.slice(4).trim();
78105
file.newPath = value === "/dev/null" ? undefined : value.replace(/^b\//, "");
79106
continue;
@@ -119,7 +146,16 @@ export function parseUnifiedDiff(raw: string): DiffFile[] {
119146
}
120147
}
121148

122-
return files.filter((f) => f.hunks.length > 0);
149+
// Keep every file that has real hunks, plus a zero-hunk PURE rename (git -M with 100%
150+
// similarity emits no content) — distinct old/new paths and not binary. A renamed binary is
151+
// also zero-hunk with distinct paths, but its "Binary files … differ" line marks it (excluded
152+
// so its bytes aren't read as text); same-path zero-hunk sections (mode-only changes, same-path
153+
// binary diffs) have no rename to surface and stay dropped.
154+
return files.filter(
155+
(f) =>
156+
f.hunks.length > 0 ||
157+
(!!f.oldPath && !!f.newPath && f.oldPath !== f.newPath && !binaryFiles.has(f)),
158+
);
123159
}
124160

125161
export async function fileAt(root: string, rel: string | undefined, ref?: string) {

0 commit comments

Comments
 (0)