Skip to content

Commit b9f4452

Browse files
ymansurozerclaude
andauthored
fix: show untracked files in working-tree review (#16)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4a45313 commit b9f4452

5 files changed

Lines changed: 72 additions & 9 deletions

File tree

skills/galley/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ If your harness can't hold a `galley await` long-poll open or background the des
168168

169169
Optionally attach a **guided review** so the desk presents the changeset as a guided flow: an overview page, then the files in the order you choose, each with a summary and category, and the critical ones flagged. The prose fields (overview, summaries, why) render as markdown — use inline code for identifiers and lists for multi-point guidance. You generate the guide from the diff and attach it at start with `galley … --guide <file>`. Galley validates and renders it and runs no model; with no guide the desk works exactly the same.
170170

171+
Write the guide file **outside the repo working tree** — a temp path (e.g. from `mktemp`) or a gitignored directory. Working-tree review mode surfaces untracked files, so a guide left in the repo would appear in the review as a stray addition (and risks being committed by accident).
172+
171173
Run **`galley guide-spec`** for the authoritative schema, field meanings, and validation rules.
172174

173175
The guide is attached at **start** and survives `reload` and restarts. It is stamped against the diff it was generated for; once a `reload` advances the diff past that point, the desk flags it stale. To refresh it, regenerate the guide from the new diff and swap it into the **live** desk — `galley reload --guide <new.json>`, or simply re-run the start command with the new `--guide` (a live desk is reused: same tab, guide swapped, diff reloaded). Never start a second desk for a fresh guide.

skills/galley/agents-snippet.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ 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-
Optionally attach an AI **guided review** so the human gets an overview + a logical, annotated file order: add `--guide guide.json` to the start command (see the skill for the JSON schema).
15+
Optionally attach an AI **guided review** so the human gets an overview + a logical, annotated file order: add `--guide <file>` to the start command (see the skill for the JSON schema). Write the guide file **outside the repo working tree** (a temp path or gitignored dir) — working-tree review surfaces untracked files, so a guide left in the repo shows up as a stray addition.
1616

1717
Then loop: `galley await --session <task-id>` blocks for the next event and prints a tagged JSON envelope.
1818
- `{"kind":"question",…}` → answer **now** with `galley comment --path … --line … --side … --body "…"` at the question's location. While gathering the answer (or acting on a review), post brief `galley status --session <task-id> --body "Reading X…"` lines — they show live next to the reviewer's waiting indicator so they're not staring at a static spinner.

src/diffsource.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { test, before, after } from "node:test";
21
import assert from "node:assert/strict";
32
import { execFileSync } from "node:child_process";
43
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
54
import { tmpdir } from "node:os";
65
import path from "node:path";
6+
import { test, before, after } from "node:test";
7+
78
import { buildDiffSource } from "./state.js";
89

910
let root: string;
@@ -99,3 +100,41 @@ test("file mode: staged content is the old-side baseline, not HEAD", async () =>
99100
git(["restore", "--staged", "a.txt"]);
100101
git(["checkout", "--", "a.txt"]);
101102
});
103+
104+
// `git diff` never lists untracked files, so the working review must surface them itself —
105+
// otherwise a brand-new file the agent created (but never `git add`ed) silently vanishes.
106+
test("repo mode: untracked file → full additions alongside tracked changes", async () => {
107+
write("a.txt", "a\nUNTRACKED-NEIGHBOR\nc\n"); // a tracked change…
108+
write("new.ts", "export const x = 1;\n"); // …and a brand-new untracked file
109+
const src = await buildDiffSource({ mode: "repo", root });
110+
assert.ok(src);
111+
const newFile = src!.files.find((f) => f.path === "new.ts");
112+
assert.ok(newFile, "untracked file should appear in the working diff");
113+
assert.equal(newFile!.oldFile.contents, ""); // nothing to diff against → all additions
114+
assert.equal(newFile!.newFile.contents, "export const x = 1;\n");
115+
assert.equal(
116+
src!.changes.filter((c) => c.path === "new.ts").length,
117+
0, // no per-hunk changes — whole-file Approve stages it via `git add`
118+
);
119+
assert.ok(src!.changes.some((c) => c.path === "a.txt")); // tracked change still present
120+
git(["checkout", "--", "a.txt"]);
121+
rmSync(path.join(root, "new.ts"));
122+
});
123+
124+
// Guards the early-return: with every tracked change staged, `git diff` is empty — but an
125+
// untracked-only working tree must still open a desk, not return null.
126+
test("repo mode: only untracked files, nothing else changed → non-null", async () => {
127+
write("new.ts", "export const y = 2;\n");
128+
const src = await buildDiffSource({ mode: "repo", root });
129+
assert.ok(src, "untracked-only working tree should still produce a review");
130+
assert.equal(src!.files.length, 1);
131+
assert.equal(src!.files[0]!.path, "new.ts");
132+
rmSync(path.join(root, "new.ts"));
133+
});
134+
135+
test("repo mode: staged diff ignores untracked files", async () => {
136+
write("new.ts", "export const z = 3;\n"); // untracked, never added
137+
const src = await buildDiffSource({ mode: "repo", root, staged: true });
138+
assert.equal(src, null); // nothing staged, and untracked must not leak into staged mode
139+
rmSync(path.join(root, "new.ts"));
140+
});

src/guidespec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export const GUIDE_SPEC = `galley guided-review spec
77
88
Attach a guide at start: galley <mode> --guide <file.json>
99
10+
Write the guide file OUTSIDE the repo working tree (a temp path, e.g. one from \`mktemp\`, or
11+
a gitignored directory). Working-tree review mode surfaces untracked files, so a guide left in
12+
the repo would show up in the review as a stray addition — and could get committed by accident.
13+
1014
The file is one JSON object:
1115
1216
{

src/state.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import crypto from "node:crypto";
22
import { promises as fs } from "node:fs";
33
import path from "node:path";
4+
45
import {
56
changeBlockContent,
67
changeStableKeyFromBlock,
@@ -177,17 +178,34 @@ export async function buildDiffSource(opts: {
177178
if (opts.staged) args.push("--cached");
178179
if (opts.path) args.push("--", opts.path);
179180
const rawDiff = await git(args, root);
180-
if (!rawDiff.trim()) return null;
181181
// Each side must match what the diff was taken against, because the UI re-diffs the
182182
// old/new contents itself instead of rendering these hunks. Unstaged diffs working
183183
// tree vs INDEX, so old reads :0 — a HEAD baseline would resurrect already-staged
184184
// changes as pending diff on every reload. Staged (--cached) diffs index vs HEAD.
185-
const { files, changes } = await assembleDiff(
186-
rawDiff,
187-
(p) => fileAt(root, p, opts.staged ? "HEAD" : ":0"),
188-
(p) => (opts.staged ? fileAt(root, p, ":0") : fileAt(root, p)),
189-
true,
190-
);
185+
const { files, changes } = rawDiff.trim()
186+
? await assembleDiff(
187+
rawDiff,
188+
(p) => fileAt(root, p, opts.staged ? "HEAD" : ":0"),
189+
(p) => (opts.staged ? fileAt(root, p, ":0") : fileAt(root, p)),
190+
true,
191+
)
192+
: { files: [], changes: [] };
193+
// `git diff` never reports untracked files (a brand-new file has no index/HEAD side to
194+
// diff against), so the working review would silently drop any file the agent created but
195+
// never `git add`ed. Surface them as full-file additions — same representation as file
196+
// mode. They carry no stageable hunks; whole-file Approve stages them via `git add`
197+
// (/api/stage), which doesn't rely on rawDiff. Staged mode is unaffected: untracked files
198+
// are by definition not in the index.
199+
if (!opts.staged) {
200+
const lsArgs = ["ls-files", "--others", "--exclude-standard"];
201+
if (opts.path) lsArgs.push("--", opts.path);
202+
const untracked = (await git(lsArgs, root)).split(/\r?\n/).filter(Boolean);
203+
for (const rel of untracked) {
204+
const working = await fs.readFile(path.join(root, rel), "utf8").catch(() => "");
205+
files.push(fileEntry(rel, "", working));
206+
}
207+
}
208+
if (files.length === 0) return null;
191209
return { files, changes, rawDiff };
192210
}
193211

0 commit comments

Comments
 (0)