@mrclrchtr/supi-review 2.8.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +71 -180
  2. package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
  3. package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +40 -0
  5. package/package.json +3 -3
  6. package/src/config.ts +30 -21
  7. package/src/git-command.ts +78 -0
  8. package/src/git.ts +311 -507
  9. package/src/history/collect.ts +43 -120
  10. package/src/model.ts +3 -1
  11. package/src/review-path.ts +85 -0
  12. package/src/review-result.ts +43 -92
  13. package/src/review.ts +187 -286
  14. package/src/session/review-plan-store.ts +20 -51
  15. package/src/target/packet.ts +46 -369
  16. package/src/tool/agent-review-schemas.ts +108 -104
  17. package/src/tool/agent-review-tools.ts +283 -307
  18. package/src/tool/child-failure-diagnostics.ts +59 -1
  19. package/src/tool/child-lifecycle-trace.ts +32 -3
  20. package/src/tool/child-resource-loader.ts +37 -0
  21. package/src/tool/child-session-runner.ts +83 -0
  22. package/src/tool/output-page.ts +47 -0
  23. package/src/tool/planner-runner.ts +69 -0
  24. package/src/tool/review-runner.ts +42 -257
  25. package/src/tool/review-system-prompt.ts +12 -110
  26. package/src/tool/review-tools.ts +119 -0
  27. package/src/tool/review-workflow.ts +325 -0
  28. package/src/tool/runner-helpers.ts +3 -8
  29. package/src/tool/schemas.ts +75 -62
  30. package/src/tui/common.ts +237 -0
  31. package/src/tui/prepare.ts +132 -0
  32. package/src/tui/run.ts +160 -0
  33. package/src/types.ts +157 -275
  34. package/src/history/synthesize.ts +0 -121
  35. package/src/tool/agent-review-workflow.ts +0 -398
  36. package/src/tool/brief-runner.ts +0 -180
  37. package/src/tool/guidance.ts +0 -21
  38. package/src/tool/review-handlers.ts +0 -314
  39. package/src/tool/snapshot-tools.ts +0 -117
  40. package/src/ui/flow.ts +0 -189
  41. package/src/ui/format-content.ts +0 -143
  42. package/src/ui/renderer.ts +0 -274
  43. package/src/ui/review-plan-inspector.ts +0 -391
  44. package/src/ui/review-tool-format.ts +0 -138
  45. package/src/ui/review-tool-renderer.ts +0 -234
package/src/git.ts CHANGED
@@ -1,10 +1,15 @@
1
- // biome-ignore lint/style/noExcessiveLinesPerFile: many tightly-coupled git helpers; splitting would create cross-ref overhead
2
- import { execFile } from "node:child_process";
3
- import { createHash } from "node:crypto";
4
- import { createReadStream } from "node:fs";
5
- import { lstat, readFile, readlink } from "node:fs/promises";
6
- import { basename, join } from "node:path";
7
- import { promisify } from "node:util";
1
+ import {
2
+ expectedGitExitOutput as expectedExitOutput,
3
+ runGit as git,
4
+ runGitAllowExit as gitAllowExit,
5
+ literalPathspec,
6
+ withHeadIndex,
7
+ } from "./git-command.ts";
8
+ import {
9
+ filterExistingReviewPaths,
10
+ readWorkingTreeFile,
11
+ resolveReviewPath,
12
+ } from "./review-path.ts";
8
13
  import type {
9
14
  DiffStats,
10
15
  ReviewSnapshot,
@@ -12,576 +17,375 @@ import type {
12
17
  ReviewTargetSpec,
13
18
  } from "./types.ts";
14
19
 
15
- const execFileAsync = promisify(execFile);
16
- const GIT_TIMEOUT_MS = 30_000;
17
- const COMMIT_OBJECT_ID_RE = /^[0-9a-f]{7,64}$/i;
20
+ const DIFF_FLAGS = ["--no-ext-diff", "--no-textconv", "--binary"] as const;
18
21
 
19
- function scrubGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
20
- const next = { ...env };
21
- for (const key of Object.keys(next)) {
22
- if (key.startsWith("GIT_")) {
23
- delete next[key];
24
- }
25
- }
26
- return next;
27
- }
28
-
29
- function gitExecOptions(repoPath: string) {
30
- return {
31
- cwd: repoPath,
32
- env: scrubGitEnv(process.env),
33
- timeout: GIT_TIMEOUT_MS,
34
- };
22
+ function parseNullList(text: string): string[] {
23
+ return text
24
+ .split("\0")
25
+ .filter(Boolean)
26
+ .sort((left, right) => left.localeCompare(right));
35
27
  }
36
28
 
37
- /** Parse simple git diff statistics from diff/show text. */
38
- export function parseDiffStats(text: string): DiffStats {
39
- let files = 0;
29
+ /** Count patch additions and deletions without interpreting binary payloads. */
30
+ export function parseDiffStats(text: string, fileCount?: number): DiffStats {
40
31
  let additions = 0;
41
32
  let deletions = 0;
42
- let inDiff = false;
43
-
33
+ let files = 0;
44
34
  for (const line of text.split("\n")) {
45
- if (line.startsWith("diff --git ")) {
46
- files++;
47
- inDiff = true;
48
- continue;
49
- }
50
-
51
- if (!inDiff) continue;
52
-
53
- if (line.startsWith("+") && !line.startsWith("+++")) {
54
- additions++;
55
- } else if (line.startsWith("-") && !line.startsWith("---")) {
56
- deletions++;
57
- }
35
+ if (line.startsWith("diff --git ")) files++;
36
+ else if (line.startsWith("+") && !line.startsWith("+++")) additions++;
37
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
58
38
  }
59
-
60
- return { files, additions, deletions };
39
+ return { files: fileCount ?? files, additions, deletions };
61
40
  }
62
41
 
63
- /** Return whether a value is a hexadecimal abbreviated or full Git object id. */
64
- export function isCommitObjectId(value: string): boolean {
65
- return COMMIT_OBJECT_ID_RE.test(value);
66
- }
67
-
68
- export async function getMergeBase(repoPath: string, branch: string): Promise<string | undefined> {
69
- const branchRef = `refs/heads/${branch}`;
70
- try {
71
- await execFileAsync(
72
- "git",
73
- ["show-ref", "--verify", "--quiet", branchRef],
74
- gitExecOptions(repoPath),
75
- );
76
- const { stdout } = await execFileAsync(
77
- "git",
78
- ["merge-base", "HEAD", branchRef],
79
- gitExecOptions(repoPath),
80
- );
81
- return stdout.trim() || undefined;
82
- } catch {
83
- return undefined;
84
- }
42
+ async function resolveCommit(cwd: string, value: string): Promise<string | undefined> {
43
+ const canonical = (await gitAllowExit(cwd, ["rev-parse", "--verify", value], [1, 128])).trim();
44
+ if (!canonical) return undefined;
45
+ const type = (await gitAllowExit(cwd, ["cat-file", "-t", canonical], [128])).trim();
46
+ if (type !== "commit") return undefined;
47
+ return canonical.toLowerCase();
85
48
  }
86
49
 
87
- export async function getDiff(repoPath: string, baseSha: string): Promise<string> {
88
- const { stdout } = await execFileAsync(
89
- "git",
90
- ["diff", "--end-of-options", baseSha, "HEAD"],
91
- gitExecOptions(repoPath),
92
- );
93
- return stdout;
50
+ async function headCommit(cwd: string): Promise<string | undefined> {
51
+ const value = (
52
+ await gitAllowExit(
53
+ cwd,
54
+ [
55
+ "rev-parse",
56
+ "--verify",
57
+ // biome-ignore lint/security/noSecrets: Git revision syntax, not a secret
58
+ "HEAD^{commit}",
59
+ ],
60
+ [1, 128],
61
+ )
62
+ ).trim();
63
+ return value || undefined;
94
64
  }
95
65
 
96
- export async function getUncommittedDiff(repoPath: string): Promise<string> {
97
- const [staged, unstaged, untracked] = await Promise.all([
98
- execFileAsync("git", ["diff", "--cached"], gitExecOptions(repoPath)).then(
99
- (r) => r.stdout,
100
- () => "",
101
- ),
102
- execFileAsync("git", ["diff"], gitExecOptions(repoPath)).then(
103
- (r) => r.stdout,
104
- () => "",
105
- ),
106
- execFileAsync(
107
- "git",
108
- ["ls-files", "--others", "--exclude-standard"],
109
- gitExecOptions(repoPath),
110
- ).then(
111
- (r) => r.stdout,
112
- () => "",
113
- ),
114
- ]);
115
-
116
- let result = "";
117
- if (staged.trim()) {
118
- result += `=== Staged ===\n${staged}\n`;
66
+ async function commitParent(cwd: string, commit: string): Promise<string | undefined> {
67
+ const body = await git(cwd, ["cat-file", "-p", commit]);
68
+ const parent = body.match(/^parent ([0-9a-f]+)$/m)?.[1];
69
+ if (!parent) return undefined;
70
+ const type = (await gitAllowExit(cwd, ["cat-file", "-t", parent], [128])).trim();
71
+ if (type !== "commit") {
72
+ throw new Error(`First parent ${parent} for ${commit} is unavailable.`);
119
73
  }
120
- if (unstaged.trim()) {
121
- result += `=== Unstaged ===\n${unstaged}\n`;
122
- }
123
- if (untracked.trim()) {
124
- const files = untracked
125
- .trim()
126
- .split("\n")
127
- .map((f) => f.trim())
128
- .filter((f) => f.length > 0);
129
- if (files.length > 0) {
130
- result += `=== Untracked files ===\n${files.join("\n")}\n`;
131
- }
132
- }
133
- return result.trimEnd();
74
+ return parent;
134
75
  }
135
76
 
136
- export interface CommitEntry {
137
- sha: string;
138
- subject: string;
77
+ function joinDiffs(parts: string[]): string {
78
+ return parts
79
+ .filter(Boolean)
80
+ .map((part) => (part.endsWith("\n") ? part : `${part}\n`))
81
+ .join("");
139
82
  }
140
83
 
141
- export async function getRecentCommits(repoPath: string, limit = 20): Promise<CommitEntry[]> {
142
- const { stdout } = await execFileAsync(
143
- "git",
144
- ["log", `--max-count=${limit}`, "--pretty=format:%H %s"],
145
- gitExecOptions(repoPath),
84
+ async function diffUntrackedFile(cwd: string, path: string): Promise<string> {
85
+ const safe = resolveReviewPath(cwd, path);
86
+ return gitAllowExit(
87
+ cwd,
88
+ literalPathspec(["diff", ...DIFF_FLAGS, "--no-index", "--", "/dev/null", safe.path]),
89
+ [1],
146
90
  );
147
- return stdout
148
- .split("\n")
149
- .map((line) => {
150
- const idx = line.indexOf(" ");
151
- if (idx <= 0) return undefined;
152
- return { sha: line.slice(0, idx), subject: line.slice(idx + 1) };
153
- })
154
- .filter((entry): entry is CommitEntry => entry !== undefined);
155
91
  }
156
92
 
157
- export async function getCommitShow(repoPath: string, sha: string): Promise<string> {
158
- const { stdout } = await execFileAsync(
159
- "git",
160
- ["show", "--end-of-options", sha],
161
- gitExecOptions(repoPath),
162
- );
163
- return stdout;
93
+ async function workingTreeSnapshot(cwd: string): Promise<ReviewSnapshot | undefined> {
94
+ const head = await headCommit(cwd);
95
+ if (!head) return undefined;
96
+ return withHeadIndex(cwd, head, async (indexFile) => {
97
+ const [trackedDiff, tracked, untrackedText] = await Promise.all([
98
+ git(cwd, ["diff", ...DIFF_FLAGS, "HEAD"], indexFile),
99
+ git(cwd, ["diff", "--name-only", "-z", "HEAD"], indexFile),
100
+ git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], indexFile),
101
+ ]);
102
+ const untracked = parseNullList(untrackedText);
103
+ const changedFiles = Array.from(new Set([...parseNullList(tracked), ...untracked])).sort();
104
+ if (changedFiles.length === 0) return undefined;
105
+ const diffText = joinDiffs([
106
+ trackedDiff,
107
+ ...(await Promise.all(untracked.map((path) => diffUntrackedFile(cwd, path)))),
108
+ ]);
109
+ return {
110
+ requestedTarget: { kind: "working-tree" },
111
+ target: { kind: "working-tree", headCommit: head },
112
+ title: "Working tree changes",
113
+ changedFiles,
114
+ diffText,
115
+ stats: parseDiffStats(diffText, changedFiles.length),
116
+ };
117
+ });
164
118
  }
165
119
 
166
- export async function getDiffFileNames(repoPath: string, baseSha: string): Promise<string[]> {
167
- const { stdout } = await execFileAsync(
168
- "git",
169
- ["diff", "--name-only", "--end-of-options", baseSha, "HEAD"],
170
- gitExecOptions(repoPath),
171
- );
172
- return stdout
173
- .trim()
174
- .split("\n")
175
- .map((f) => f.trim())
176
- .filter((f) => f.length > 0);
120
+ async function comparisonSnapshot(
121
+ cwd: string,
122
+ requested: Extract<ReviewTargetSpec, { kind: "comparison" }>,
123
+ ): Promise<ReviewSnapshot | undefined> {
124
+ const [base, head] = await Promise.all([
125
+ resolveCommit(cwd, requested.baseCommit),
126
+ headCommit(cwd),
127
+ ]);
128
+ if (!base) {
129
+ throw new Error(`Commit ${requested.baseCommit.slice(0, 7)} not found in this repository.`);
130
+ }
131
+ if (!head) {
132
+ throw new Error("No HEAD commit found in this repository.");
133
+ }
134
+ const mergeBase = (await gitAllowExit(cwd, ["merge-base", base, head], [1])).trim();
135
+ if (!mergeBase) {
136
+ throw new Error(`No common ancestor between ${base.slice(0, 7)} and ${head.slice(0, 7)}.`);
137
+ }
138
+ const [diffText, names] = await Promise.all([
139
+ git(cwd, ["diff", ...DIFF_FLAGS, mergeBase, head]),
140
+ git(cwd, ["diff", "--name-only", "-z", mergeBase, head]),
141
+ ]);
142
+ const changedFiles = parseNullList(names);
143
+ if (changedFiles.length === 0) return undefined;
144
+ return {
145
+ requestedTarget: requested,
146
+ target: {
147
+ kind: "comparison",
148
+ requestedBaseCommit: base,
149
+ mergeBaseCommit: mergeBase,
150
+ headCommit: head,
151
+ },
152
+ title: `Changes ${mergeBase.slice(0, 7)}..${head.slice(0, 7)}`,
153
+ changedFiles,
154
+ diffText,
155
+ stats: parseDiffStats(diffText, changedFiles.length),
156
+ };
177
157
  }
178
158
 
179
- export async function getUncommittedFileNames(repoPath: string): Promise<string[]> {
180
- const [unstaged, staged, untracked] = await Promise.all([
181
- execFileAsync("git", ["diff", "--name-only"], gitExecOptions(repoPath)).then(
182
- (r) => r.stdout,
183
- () => "",
184
- ),
185
- execFileAsync("git", ["diff", "--cached", "--name-only"], gitExecOptions(repoPath)).then(
186
- (r) => r.stdout,
187
- () => "",
188
- ),
189
- execFileAsync(
190
- "git",
191
- ["ls-files", "--others", "--exclude-standard"],
192
- gitExecOptions(repoPath),
193
- ).then(
194
- (r) => r.stdout,
195
- () => "",
196
- ),
159
+ async function commitSnapshot(
160
+ cwd: string,
161
+ requested: Extract<ReviewTargetSpec, { kind: "commit" }>,
162
+ ): Promise<ReviewSnapshot | undefined> {
163
+ const commit = await resolveCommit(cwd, requested.commit);
164
+ if (!commit) {
165
+ throw new Error(`Commit ${requested.commit.slice(0, 7)} not found in this repository.`);
166
+ }
167
+ const parent = await commitParent(cwd, commit);
168
+ const [diffText, names] = await Promise.all([
169
+ parent
170
+ ? git(cwd, ["diff", ...DIFF_FLAGS, parent, commit])
171
+ : git(cwd, ["show", ...DIFF_FLAGS, "--format=", "--root", commit]),
172
+ parent
173
+ ? git(cwd, ["diff", "--name-only", "-z", parent, commit])
174
+ : git(cwd, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", commit]),
197
175
  ]);
176
+ const changedFiles = parseNullList(names);
177
+ if (changedFiles.length === 0) return undefined;
178
+ return {
179
+ requestedTarget: requested,
180
+ target: { kind: "commit", commit, ...(parent ? { parentCommit: parent } : {}) },
181
+ title: `Commit ${commit.slice(0, 7)}`,
182
+ changedFiles,
183
+ diffText,
184
+ stats: parseDiffStats(diffText, changedFiles.length),
185
+ };
186
+ }
198
187
 
199
- const set = new Set([
200
- ...unstaged
201
- .trim()
202
- .split("\n")
203
- .map((f) => f.trim())
204
- .filter((f) => f.length > 0),
205
- ...staged
206
- .trim()
207
- .split("\n")
208
- .map((f) => f.trim())
209
- .filter((f) => f.length > 0),
210
- ...untracked
211
- .trim()
212
- .split("\n")
213
- .map((f) => f.trim())
214
- .filter((f) => f.length > 0),
215
- ]);
216
- return Array.from(set).sort();
188
+ export interface CommitChoice {
189
+ commit: string;
190
+ label: string;
217
191
  }
218
192
 
219
- export async function getCommitFileNames(repoPath: string, sha: string): Promise<string[]> {
220
- const { stdout } = await execFileAsync(
221
- "git",
222
- ["diff-tree", "--no-commit-id", "--name-only", "-r", "--end-of-options", sha],
223
- gitExecOptions(repoPath),
224
- );
225
- return stdout
193
+ /** List local branches with resolved commit ids for the interactive adapter. */
194
+ export async function listLocalBranches(cwd: string): Promise<CommitChoice[]> {
195
+ const output = await git(cwd, [
196
+ "for-each-ref",
197
+ // biome-ignore lint/security/noSecrets: Git format syntax, not a secret
198
+ "--format=%(objectname)%00%(refname:short)",
199
+ "refs/heads",
200
+ ]);
201
+ return output
226
202
  .trim()
227
203
  .split("\n")
228
- .map((f) => f.trim())
229
- .filter((f) => f.length > 0);
204
+ .flatMap((line) => {
205
+ const [commit, label] = line.split("\0");
206
+ return commit && label ? [{ commit, label }] : [];
207
+ });
230
208
  }
231
209
 
232
- export async function getLocalBranches(repoPath: string): Promise<string[]> {
233
- const [{ stdout: local }, { stdout: current }] = await Promise.all([
234
- execFileAsync("git", ["branch", "--format=%(refname:short)"], gitExecOptions(repoPath)),
235
- execFileAsync("git", ["branch", "--show-current"], gitExecOptions(repoPath)),
210
+ /** List recent commits with resolved ids for the interactive adapter. */
211
+ export async function listRecentCommits(cwd: string, limit = 30): Promise<CommitChoice[]> {
212
+ const output = await git(cwd, [
213
+ "log",
214
+ `--max-count=${limit}`,
215
+ // biome-ignore lint/security/noSecrets: Git format syntax, not a secret
216
+ "--format=%H%x00%s",
236
217
  ]);
237
-
238
- const names = local
218
+ return output
239
219
  .trim()
240
220
  .split("\n")
241
- .map((b) => b.trim())
242
- .filter((b) => b.length > 0);
243
-
244
- const currentBranch = current.trim();
245
- const set = new Set(names);
246
- const sorted: string[] = [];
247
-
248
- for (const candidate of ["main", "master", currentBranch]) {
249
- if (candidate && set.has(candidate)) {
250
- sorted.push(candidate);
251
- set.delete(candidate);
252
- }
253
- }
254
-
255
- const remaining = Array.from(set).sort((a, b) => a.localeCompare(b));
256
- sorted.push(...remaining);
257
- return sorted;
221
+ .flatMap((line) => {
222
+ const [commit, subject] = line.split("\0");
223
+ return commit && subject ? [{ commit, label: `${commit.slice(0, 7)} ${subject}` }] : [];
224
+ });
258
225
  }
259
226
 
260
- /** Resolve any supported review target into a concrete snapshot. */
227
+ /** Pin target identities and capture the target's changed files, patch, and stats. */
261
228
  export function resolveReviewSnapshot(
262
- repoPath: string,
229
+ cwd: string,
263
230
  target: ReviewTargetSpec,
264
231
  ): Promise<ReviewSnapshot | undefined> {
265
232
  switch (target.kind) {
266
233
  case "working-tree":
267
- return resolveWorkingTreeSnapshot(repoPath);
268
- case "branch":
269
- return resolveBranchSnapshot(repoPath, target.base);
234
+ return workingTreeSnapshot(cwd);
235
+ case "comparison":
236
+ return comparisonSnapshot(cwd, target);
270
237
  case "commit":
271
- return resolveCommitSnapshot(repoPath, target.sha);
238
+ return commitSnapshot(cwd, target);
272
239
  }
273
240
  }
274
241
 
275
- /** Remove bulk diff text before retaining snapshot metadata in tool results. */
242
+ /** Remove the potentially large patch while retaining pinned target metadata. */
276
243
  export function summarizeReviewSnapshot(snapshot: ReviewSnapshot): ReviewSnapshotSummary {
277
- return {
278
- target: snapshot.target,
279
- title: snapshot.title,
280
- changedFiles: [...snapshot.changedFiles],
281
- stats: { ...snapshot.stats },
282
- };
244
+ const { diffText: _, ...summary } = snapshot;
245
+ return summary;
283
246
  }
284
247
 
285
- /**
286
- * Compute an abort-aware deterministic snapshot fingerprint.
287
- * Untracked regular files are streamed, symlinks are hashed by link identity,
288
- * and special file types are encoded without opening potentially blocking paths.
289
- */
290
- export async function fingerprintReviewSnapshot(
291
- repoPath: string,
292
- snapshot: ReviewSnapshot,
293
- signal?: AbortSignal,
294
- ): Promise<string> {
295
- const changedFiles = [...snapshot.changedFiles].sort((left, right) => left.localeCompare(right));
296
- const hash = createHash("sha256").update(
297
- JSON.stringify({
298
- target: snapshot.target,
299
- changedFiles,
300
- diffText: snapshot.diffText,
301
- }),
302
- );
303
-
304
- for (const file of extractUntrackedFiles(snapshot)) {
305
- signal?.throwIfAborted();
306
- const filePath = join(repoPath, file);
307
- hash.update(`\nuntracked:${file}\n`);
308
- try {
309
- const stat = await lstat(filePath);
310
- signal?.throwIfAborted();
311
- hash.update(`mode:${stat.mode.toString(8)}\n`);
312
- if (stat.isSymbolicLink()) {
313
- hash.update(`symlink:${await readlink(filePath)}\n`);
314
- } else if (stat.isFile()) {
315
- for await (const chunk of createReadStream(filePath, { signal })) {
316
- hash.update(chunk);
317
- }
318
- } else {
319
- hash.update("[unsupported-file-type]");
320
- }
321
- } catch {
322
- signal?.throwIfAborted();
323
- hash.update("[unavailable]");
324
- }
325
- signal?.throwIfAborted();
326
- }
327
-
328
- return hash.digest("hex");
329
- }
330
-
331
- function extractUntrackedFiles(snapshot: ReviewSnapshot): string[] {
332
- if (snapshot.target.kind !== "working-tree") return [];
333
- const marker = "=== Untracked files ===\n";
334
- const markerIndex = snapshot.diffText.indexOf(marker);
335
- if (markerIndex < 0) return [];
336
- const changedFiles = new Set(snapshot.changedFiles);
337
- return snapshot.diffText
338
- .slice(markerIndex + marker.length)
339
- .split("\n")
340
- .map((file) => file.trim())
341
- .filter((file) => file.length > 0 && changedFiles.has(file))
342
- .sort((left, right) => left.localeCompare(right));
343
- }
344
-
345
- /** Re-resolve a snapshot target and report whether its review evidence has drifted. */
346
- export async function checkReviewSnapshotFreshness(
347
- repoPath: string,
348
- snapshot: ReviewSnapshot,
349
- expectedFingerprint: string,
350
- signal?: AbortSignal,
351
- ): Promise<{ fresh: true } | { fresh: false; reason: string }> {
352
- const current = await resolveReviewSnapshot(repoPath, snapshot.target);
353
- if (!current) {
354
- return {
355
- fresh: false,
356
- reason: "The prepared review target no longer has reviewable changes.",
357
- };
358
- }
359
-
360
- if ((await fingerprintReviewSnapshot(repoPath, current, signal)) !== expectedFingerprint) {
361
- return {
362
- fresh: false,
363
- reason:
364
- "The review target changed after the brief was prepared. Run supi_review_prepare again.",
365
- };
366
- }
367
-
368
- return { fresh: true };
369
- }
370
-
371
- /** Resolve the current working tree into a concrete review snapshot. */
372
- export async function resolveWorkingTreeSnapshot(
373
- repoPath: string,
374
- ): Promise<ReviewSnapshot | undefined> {
375
- const [diffText, changedFiles] = await Promise.all([
376
- getUncommittedDiff(repoPath),
377
- getUncommittedFileNames(repoPath),
378
- ]);
379
- if (!diffText.trim() && changedFiles.length === 0) return undefined;
380
- return {
381
- target: { kind: "working-tree" },
382
- title: "Working tree changes",
383
- changedFiles,
384
- diffText,
385
- stats: parseDiffStats(diffText),
386
- };
387
- }
388
-
389
- /** Resolve a branch-vs-base diff into a concrete review snapshot. */
390
- export async function resolveBranchSnapshot(
391
- repoPath: string,
392
- base: string,
393
- ): Promise<ReviewSnapshot | undefined> {
394
- const baseSha = await getMergeBase(repoPath, base);
395
- if (!baseSha) return undefined;
396
- const [diffText, changedFiles] = await Promise.all([
397
- getDiff(repoPath, baseSha),
398
- getDiffFileNames(repoPath, baseSha),
399
- ]);
400
- if (!diffText.trim() && changedFiles.length === 0) return undefined;
401
- return {
402
- target: { kind: "branch", base },
403
- title: `Changes vs ${base}`,
404
- changedFiles,
405
- diffText,
406
- stats: parseDiffStats(diffText),
407
- };
408
- }
409
-
410
- async function resolveCommitObjectId(
411
- repoPath: string,
412
- revision: string,
248
+ async function showBlob(
249
+ cwd: string,
250
+ commit: string | undefined,
251
+ path: string,
413
252
  ): Promise<string | undefined> {
414
- if (!isCommitObjectId(revision)) return undefined;
253
+ if (!commit) return undefined;
415
254
  try {
416
- const { stdout } = await execFileAsync(
417
- "git",
418
- ["rev-parse", `--disambiguate=${revision}`],
419
- gitExecOptions(repoPath),
420
- );
421
- const candidates = Array.from(
422
- new Set(
423
- stdout
424
- .split("\n")
425
- .map((candidate) => candidate.trim())
426
- .filter((candidate) => isCommitObjectId(candidate)),
427
- ),
428
- );
429
- if (candidates.length !== 1) return undefined;
430
-
431
- const candidate = candidates[0];
432
- const { stdout: objectType } = await execFileAsync(
433
- "git",
434
- ["cat-file", "-t", candidate],
435
- gitExecOptions(repoPath),
436
- );
437
- return objectType.trim() === "commit" ? candidate : undefined;
438
- } catch {
439
- return undefined;
255
+ return await git(cwd, ["show", `${commit}:${path}`]);
256
+ } catch (error) {
257
+ if (expectedExitOutput(error, [128]) !== undefined) return undefined;
258
+ throw error;
440
259
  }
441
260
  }
442
261
 
443
- /** Resolve one commit into a concrete review snapshot. */
444
- export async function resolveCommitSnapshot(
445
- repoPath: string,
446
- sha: string,
447
- ): Promise<ReviewSnapshot | undefined> {
448
- const resolvedSha = await resolveCommitObjectId(repoPath, sha);
449
- if (!resolvedSha) return undefined;
450
- const [diffText, changedFiles] = await Promise.all([
451
- getCommitShow(repoPath, resolvedSha),
452
- getCommitFileNames(repoPath, resolvedSha),
453
- ]);
454
- if (!diffText.trim() && changedFiles.length === 0) return undefined;
455
- return {
456
- target: { kind: "commit", sha: resolvedSha },
457
- title: `Commit ${resolvedSha.slice(0, 7)}`,
458
- changedFiles,
459
- diffText,
460
- stats: parseDiffStats(diffText),
461
- };
262
+ async function isWorkingTreePathAllowed(cwd: string, head: string, path: string): Promise<boolean> {
263
+ return withHeadIndex(cwd, head, async (indexFile) => {
264
+ const output = await git(
265
+ cwd,
266
+ literalPathspec(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", path]),
267
+ indexFile,
268
+ );
269
+ return parseNullList(output).includes(path);
270
+ });
462
271
  }
463
272
 
464
- /** Get the per-file diff for a single changed file in the snapshot. */
465
- export async function getSnapshotFileDiff(
466
- repoPath: string,
273
+ /** Read one before/after file from the target rather than an unrelated live checkout. */
274
+ export async function readReviewFile(
275
+ cwd: string,
467
276
  snapshot: ReviewSnapshot,
468
- file: string,
469
- ): Promise<string> {
470
- const { target } = snapshot;
471
- switch (target.kind) {
472
- case "working-tree": {
473
- const { stdout } = await execFileAsync(
474
- "git",
475
- ["diff", "HEAD", "--", file],
476
- gitExecOptions(repoPath),
477
- );
478
- return stdout;
479
- }
480
- case "branch": {
481
- const baseSha = await getMergeBase(repoPath, target.base);
482
- if (!baseSha) return "";
483
- const { stdout } = await execFileAsync(
484
- "git",
485
- ["diff", "--end-of-options", baseSha, "HEAD", "--", file],
486
- gitExecOptions(repoPath),
487
- );
488
- return stdout;
489
- }
490
- case "commit": {
491
- const { stdout } = await execFileAsync(
492
- "git",
493
- ["show", "--end-of-options", target.sha, "--", file],
494
- gitExecOptions(repoPath),
495
- );
496
- return stdout;
497
- }
498
- }
499
- }
500
-
501
- /** Run `git show <ref>:<file>` and return the blob content. */
502
- async function showGitBlob(repoPath: string, ref: string, file: string): Promise<string> {
503
- const { stdout } = await execFileAsync(
504
- "git",
505
- ["show", "--end-of-options", `${ref}:${file}`],
506
- gitExecOptions(repoPath),
507
- );
508
- return stdout;
509
- }
510
-
511
- async function resolveWorkingTreeContent(
512
- repoPath: string,
513
- file: string,
514
- side: "before" | "after",
277
+ path: string,
278
+ side: "before" | "after" = "after",
515
279
  ): Promise<string | undefined> {
516
- if (side === "before") {
517
- try {
518
- return await showGitBlob(repoPath, "HEAD", file);
519
- } catch {
520
- return undefined;
280
+ const safe = resolveReviewPath(cwd, path);
281
+ const target = snapshot.target;
282
+ if (target.kind === "working-tree") {
283
+ if (side === "before") return showBlob(cwd, target.headCommit, safe.path);
284
+ if (!(await isWorkingTreePathAllowed(cwd, target.headCommit, safe.path))) {
285
+ throw new Error(`${safe.path} is not part of the selected working-tree target.`);
521
286
  }
287
+ return readWorkingTreeFile(cwd, safe.path);
522
288
  }
523
- try {
524
- const filePath = join(repoPath, file);
525
- const stat = await lstat(filePath);
526
- if (stat.isSymbolicLink()) return await readlink(filePath);
527
- if (!stat.isFile()) return undefined;
528
- return await readFile(filePath, "utf-8");
529
- } catch {
530
- return undefined;
289
+ if (target.kind === "comparison") {
290
+ return showBlob(cwd, side === "before" ? target.mergeBaseCommit : target.headCommit, safe.path);
531
291
  }
292
+ return showBlob(cwd, side === "before" ? target.parentCommit : target.commit, safe.path);
532
293
  }
533
294
 
534
- async function resolveBranchContent(
535
- repoPath: string,
536
- target: ReviewTargetSpec & { kind: "branch" },
537
- file: string,
538
- side: "before" | "after",
539
- ): Promise<string | undefined> {
540
- const baseSha = await getMergeBase(repoPath, target.base);
541
- if (!baseSha) return undefined;
542
- const ref = side === "before" ? baseSha : "HEAD";
543
- try {
544
- return await showGitBlob(repoPath, ref, file);
545
- } catch (err) {
546
- if (side === "before") return undefined;
547
- throw err;
295
+ /** Read one changed file's patch from the selected target. */
296
+ export async function readReviewDiff(
297
+ cwd: string,
298
+ snapshot: ReviewSnapshot,
299
+ path: string,
300
+ ): Promise<string> {
301
+ const safe = resolveReviewPath(cwd, path);
302
+ if (!snapshot.changedFiles.includes(safe.path)) {
303
+ throw new Error(`${safe.path} is not changed by this target.`);
304
+ }
305
+ const target = snapshot.target;
306
+ if (target.kind === "working-tree") {
307
+ return withHeadIndex(cwd, target.headCommit, async (indexFile) => {
308
+ const tracked = await git(
309
+ cwd,
310
+ literalPathspec(["diff", ...DIFF_FLAGS, "HEAD", "--", safe.path]),
311
+ indexFile,
312
+ );
313
+ return tracked || diffUntrackedFile(cwd, safe.path);
314
+ });
315
+ }
316
+ if (target.kind === "comparison") {
317
+ return git(
318
+ cwd,
319
+ literalPathspec([
320
+ "diff",
321
+ ...DIFF_FLAGS,
322
+ target.mergeBaseCommit,
323
+ target.headCommit,
324
+ "--",
325
+ safe.path,
326
+ ]),
327
+ );
548
328
  }
329
+ return target.parentCommit
330
+ ? git(
331
+ cwd,
332
+ literalPathspec([
333
+ "diff",
334
+ ...DIFF_FLAGS,
335
+ target.parentCommit,
336
+ target.commit,
337
+ "--",
338
+ safe.path,
339
+ ]),
340
+ )
341
+ : git(
342
+ cwd,
343
+ literalPathspec([
344
+ "show",
345
+ ...DIFF_FLAGS,
346
+ "--format=",
347
+ "--root",
348
+ target.commit,
349
+ "--",
350
+ safe.path,
351
+ ]),
352
+ );
549
353
  }
550
354
 
551
- async function resolveCommitContent(
552
- repoPath: string,
553
- target: ReviewTargetSpec & { kind: "commit" },
554
- file: string,
555
- side: "before" | "after",
556
- ): Promise<string | undefined> {
557
- const ref = side === "before" ? `${target.sha}^` : target.sha;
558
- try {
559
- return await showGitBlob(repoPath, ref, file);
560
- } catch (err) {
561
- if (side === "before") return undefined;
562
- throw err;
355
+ /** List files present on the target's after side, excluding ignored untracked files. */
356
+ export async function listReviewFiles(cwd: string, snapshot: ReviewSnapshot): Promise<string[]> {
357
+ const target = snapshot.target;
358
+ if (target.kind !== "working-tree") {
359
+ const commit = target.kind === "comparison" ? target.headCommit : target.commit;
360
+ return parseNullList(await git(cwd, ["ls-tree", "-r", "--name-only", "-z", commit]));
563
361
  }
362
+ return withHeadIndex(cwd, target.headCommit, async (indexFile) => {
363
+ const candidates = parseNullList(
364
+ await git(cwd, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], indexFile),
365
+ );
366
+ return filterExistingReviewPaths(cwd, candidates);
367
+ });
564
368
  }
565
369
 
566
- /** Get before or after content for a single changed file in the snapshot. Returns undefined when legitimately unavailable; propagates unexpected errors. */
567
- export async function getSnapshotFileContent(
568
- repoPath: string,
370
+ /** Search literal text on the target's after side with Git's repository-aware search. */
371
+ export async function searchReviewFiles(
372
+ cwd: string,
569
373
  snapshot: ReviewSnapshot,
570
- file: string,
571
- side: "before" | "after",
572
- ): Promise<string | undefined> {
573
- const { target } = snapshot;
574
- switch (target.kind) {
575
- case "working-tree":
576
- return resolveWorkingTreeContent(repoPath, file, side);
577
- case "branch":
578
- return resolveBranchContent(repoPath, target, file, side);
579
- case "commit":
580
- return resolveCommitContent(repoPath, target, file, side);
374
+ query: string,
375
+ path?: string,
376
+ ): Promise<string> {
377
+ const pathArgs = path ? ["--", resolveReviewPath(cwd, path).path] : [];
378
+ const target = snapshot.target;
379
+ const args = literalPathspec(["grep", "-n", "-F", "--untracked", "-e", query, ...pathArgs]);
380
+ if (target.kind === "working-tree") {
381
+ return withHeadIndex(cwd, target.headCommit, (indexFile) =>
382
+ gitAllowExit(cwd, args, [1], indexFile),
383
+ );
581
384
  }
582
- }
583
-
584
- /** Convenience label for one changed file, used in synthesized prompts/UI. */
585
- export function formatChangedFileLabel(file: string): string {
586
- return basename(file) === file ? file : `${basename(file)} (${file})`;
385
+ const commit = target.kind === "comparison" ? target.headCommit : target.commit;
386
+ return gitAllowExit(
387
+ cwd,
388
+ literalPathspec(["grep", "-n", "-F", "-e", query, commit, ...pathArgs]),
389
+ [1],
390
+ );
587
391
  }