@mrclrchtr/supi-review 2.7.0 → 3.0.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 (49) hide show
  1. package/README.md +75 -163
  2. package/node_modules/@mrclrchtr/supi-core/README.md +1 -1
  3. package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +40 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/settings/scoped-settings-list.ts +1 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +14 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-submenus.ts +31 -14
  9. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +1 -0
  10. package/package.json +3 -3
  11. package/src/config.ts +64 -0
  12. package/src/git-command.ts +78 -0
  13. package/src/git.ts +306 -505
  14. package/src/history/collect.ts +43 -120
  15. package/src/model.ts +35 -0
  16. package/src/review-path.ts +85 -0
  17. package/src/review-result.ts +43 -92
  18. package/src/review.ts +154 -288
  19. package/src/session/review-plan-store.ts +20 -51
  20. package/src/target/packet.ts +46 -369
  21. package/src/tool/agent-review-schemas.ts +101 -104
  22. package/src/tool/agent-review-tools.ts +269 -301
  23. package/src/tool/child-failure-diagnostics.ts +1 -1
  24. package/src/tool/child-resource-loader.ts +37 -0
  25. package/src/tool/child-session-runner.ts +83 -0
  26. package/src/tool/output-page.ts +47 -0
  27. package/src/tool/planner-runner.ts +69 -0
  28. package/src/tool/review-runner.ts +42 -257
  29. package/src/tool/review-system-prompt.ts +12 -110
  30. package/src/tool/review-tools.ts +119 -0
  31. package/src/tool/review-workflow.ts +309 -0
  32. package/src/tool/runner-helpers.ts +3 -8
  33. package/src/tool/schemas.ts +75 -62
  34. package/src/tui/common.ts +175 -0
  35. package/src/tui/prepare.ts +132 -0
  36. package/src/tui/run.ts +160 -0
  37. package/src/types.ts +153 -275
  38. package/src/history/synthesize.ts +0 -121
  39. package/src/tool/agent-review-workflow.ts +0 -398
  40. package/src/tool/brief-runner.ts +0 -180
  41. package/src/tool/guidance.ts +0 -21
  42. package/src/tool/review-handlers.ts +0 -314
  43. package/src/tool/snapshot-tools.ts +0 -117
  44. package/src/ui/flow.ts +0 -189
  45. package/src/ui/format-content.ts +0 -143
  46. package/src/ui/renderer.ts +0 -274
  47. package/src/ui/review-plan-inspector.ts +0 -391
  48. package/src/ui/review-tool-format.ts +0 -138
  49. 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,372 @@ 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 FULL_COMMIT_ID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
21
+ const DIFF_FLAGS = ["--no-ext-diff", "--no-textconv", "--binary"] as const;
18
22
 
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
- };
23
+ function parseNullList(text: string): string[] {
24
+ return text
25
+ .split("\0")
26
+ .filter(Boolean)
27
+ .sort((left, right) => left.localeCompare(right));
35
28
  }
36
29
 
37
- /** Parse simple git diff statistics from diff/show text. */
38
- export function parseDiffStats(text: string): DiffStats {
39
- let files = 0;
30
+ /** Count patch additions and deletions without interpreting binary payloads. */
31
+ export function parseDiffStats(text: string, fileCount?: number): DiffStats {
40
32
  let additions = 0;
41
33
  let deletions = 0;
42
- let inDiff = false;
43
-
34
+ let files = 0;
44
35
  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
- }
36
+ if (line.startsWith("diff --git ")) files++;
37
+ else if (line.startsWith("+") && !line.startsWith("+++")) additions++;
38
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
58
39
  }
59
-
60
- return { files, additions, deletions };
40
+ return { files: fileCount ?? files, additions, deletions };
61
41
  }
62
42
 
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);
43
+ /** Validate the agent-facing full Git object-id syntax before invoking Git. */
44
+ export function isFullCommitId(value: string): boolean {
45
+ return FULL_COMMIT_ID_RE.test(value);
66
46
  }
67
47
 
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
- }
48
+ async function resolveCommit(cwd: string, value: string): Promise<string | undefined> {
49
+ if (!isFullCommitId(value)) return undefined;
50
+ const type = (await gitAllowExit(cwd, ["cat-file", "-t", value], [128])).trim();
51
+ if (type !== "commit") return undefined;
52
+ const canonical = (await git(cwd, ["rev-parse", "--verify", value])).trim().toLowerCase();
53
+ return canonical === value.toLowerCase() ? canonical : undefined;
85
54
  }
86
55
 
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;
56
+ async function headCommit(cwd: string): Promise<string | undefined> {
57
+ const value = (
58
+ await gitAllowExit(
59
+ cwd,
60
+ [
61
+ "rev-parse",
62
+ "--verify",
63
+ // biome-ignore lint/security/noSecrets: Git revision syntax, not a secret
64
+ "HEAD^{commit}",
65
+ ],
66
+ [1, 128],
67
+ )
68
+ ).trim();
69
+ return value || undefined;
94
70
  }
95
71
 
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`;
72
+ async function commitParent(cwd: string, commit: string): Promise<string | undefined> {
73
+ const body = await git(cwd, ["cat-file", "-p", commit]);
74
+ const parent = body.match(/^parent ([0-9a-f]+)$/m)?.[1];
75
+ if (!parent) return undefined;
76
+ const type = (await gitAllowExit(cwd, ["cat-file", "-t", parent], [128])).trim();
77
+ if (type !== "commit") {
78
+ throw new Error(`First parent ${parent} for ${commit} is unavailable.`);
119
79
  }
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();
80
+ return parent;
134
81
  }
135
82
 
136
- export interface CommitEntry {
137
- sha: string;
138
- subject: string;
83
+ function joinDiffs(parts: string[]): string {
84
+ return parts
85
+ .filter(Boolean)
86
+ .map((part) => (part.endsWith("\n") ? part : `${part}\n`))
87
+ .join("");
139
88
  }
140
89
 
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),
90
+ async function diffUntrackedFile(cwd: string, path: string): Promise<string> {
91
+ const safe = resolveReviewPath(cwd, path);
92
+ return gitAllowExit(
93
+ cwd,
94
+ literalPathspec(["diff", ...DIFF_FLAGS, "--no-index", "--", "/dev/null", safe.path]),
95
+ [1],
146
96
  );
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
97
  }
156
98
 
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;
99
+ async function workingTreeSnapshot(cwd: string): Promise<ReviewSnapshot | undefined> {
100
+ const head = await headCommit(cwd);
101
+ if (!head) return undefined;
102
+ return withHeadIndex(cwd, head, async (indexFile) => {
103
+ const [trackedDiff, tracked, untrackedText] = await Promise.all([
104
+ git(cwd, ["diff", ...DIFF_FLAGS, "HEAD"], indexFile),
105
+ git(cwd, ["diff", "--name-only", "-z", "HEAD"], indexFile),
106
+ git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], indexFile),
107
+ ]);
108
+ const untracked = parseNullList(untrackedText);
109
+ const changedFiles = Array.from(new Set([...parseNullList(tracked), ...untracked])).sort();
110
+ if (changedFiles.length === 0) return undefined;
111
+ const diffText = joinDiffs([
112
+ trackedDiff,
113
+ ...(await Promise.all(untracked.map((path) => diffUntrackedFile(cwd, path)))),
114
+ ]);
115
+ return {
116
+ requestedTarget: { kind: "working-tree" },
117
+ target: { kind: "working-tree", headCommit: head },
118
+ title: "Working tree changes",
119
+ changedFiles,
120
+ diffText,
121
+ stats: parseDiffStats(diffText, changedFiles.length),
122
+ };
123
+ });
164
124
  }
165
125
 
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);
126
+ async function comparisonSnapshot(
127
+ cwd: string,
128
+ requested: Extract<ReviewTargetSpec, { kind: "comparison" }>,
129
+ ): Promise<ReviewSnapshot | undefined> {
130
+ const [base, head] = await Promise.all([
131
+ resolveCommit(cwd, requested.baseCommit),
132
+ headCommit(cwd),
133
+ ]);
134
+ if (!base || !head) return undefined;
135
+ const mergeBase = (await gitAllowExit(cwd, ["merge-base", base, head], [1])).trim();
136
+ if (!mergeBase) return undefined;
137
+ const [diffText, names] = await Promise.all([
138
+ git(cwd, ["diff", ...DIFF_FLAGS, mergeBase, head]),
139
+ git(cwd, ["diff", "--name-only", "-z", mergeBase, head]),
140
+ ]);
141
+ const changedFiles = parseNullList(names);
142
+ if (changedFiles.length === 0) return undefined;
143
+ return {
144
+ requestedTarget: requested,
145
+ target: {
146
+ kind: "comparison",
147
+ requestedBaseCommit: base,
148
+ mergeBaseCommit: mergeBase,
149
+ headCommit: head,
150
+ },
151
+ title: `Changes ${mergeBase.slice(0, 7)}..${head.slice(0, 7)}`,
152
+ changedFiles,
153
+ diffText,
154
+ stats: parseDiffStats(diffText, changedFiles.length),
155
+ };
177
156
  }
178
157
 
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
- ),
158
+ async function commitSnapshot(
159
+ cwd: string,
160
+ requested: Extract<ReviewTargetSpec, { kind: "commit" }>,
161
+ ): Promise<ReviewSnapshot | undefined> {
162
+ const commit = await resolveCommit(cwd, requested.commit);
163
+ if (!commit) return undefined;
164
+ const parent = await commitParent(cwd, commit);
165
+ const [diffText, names] = await Promise.all([
166
+ parent
167
+ ? git(cwd, ["diff", ...DIFF_FLAGS, parent, commit])
168
+ : git(cwd, ["show", ...DIFF_FLAGS, "--format=", "--root", commit]),
169
+ parent
170
+ ? git(cwd, ["diff", "--name-only", "-z", parent, commit])
171
+ : git(cwd, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", commit]),
197
172
  ]);
173
+ const changedFiles = parseNullList(names);
174
+ if (changedFiles.length === 0) return undefined;
175
+ return {
176
+ requestedTarget: requested,
177
+ target: { kind: "commit", commit, ...(parent ? { parentCommit: parent } : {}) },
178
+ title: `Commit ${commit.slice(0, 7)}`,
179
+ changedFiles,
180
+ diffText,
181
+ stats: parseDiffStats(diffText, changedFiles.length),
182
+ };
183
+ }
198
184
 
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();
185
+ export interface CommitChoice {
186
+ commit: string;
187
+ label: string;
217
188
  }
218
189
 
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
190
+ /** List local branches with resolved commit ids for the interactive adapter. */
191
+ export async function listLocalBranches(cwd: string): Promise<CommitChoice[]> {
192
+ const output = await git(cwd, [
193
+ "for-each-ref",
194
+ // biome-ignore lint/security/noSecrets: Git format syntax, not a secret
195
+ "--format=%(objectname)%00%(refname:short)",
196
+ "refs/heads",
197
+ ]);
198
+ return output
226
199
  .trim()
227
200
  .split("\n")
228
- .map((f) => f.trim())
229
- .filter((f) => f.length > 0);
201
+ .flatMap((line) => {
202
+ const [commit, label] = line.split("\0");
203
+ return commit && label ? [{ commit, label }] : [];
204
+ });
230
205
  }
231
206
 
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)),
207
+ /** List recent commits with resolved ids for the interactive adapter. */
208
+ export async function listRecentCommits(cwd: string, limit = 30): Promise<CommitChoice[]> {
209
+ const output = await git(cwd, [
210
+ "log",
211
+ `--max-count=${limit}`,
212
+ // biome-ignore lint/security/noSecrets: Git format syntax, not a secret
213
+ "--format=%H%x00%s",
236
214
  ]);
237
-
238
- const names = local
215
+ return output
239
216
  .trim()
240
217
  .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;
218
+ .flatMap((line) => {
219
+ const [commit, subject] = line.split("\0");
220
+ return commit && subject ? [{ commit, label: `${commit.slice(0, 7)} ${subject}` }] : [];
221
+ });
258
222
  }
259
223
 
260
- /** Resolve any supported review target into a concrete snapshot. */
224
+ /** Pin target identities and capture the target's changed files, patch, and stats. */
261
225
  export function resolveReviewSnapshot(
262
- repoPath: string,
226
+ cwd: string,
263
227
  target: ReviewTargetSpec,
264
228
  ): Promise<ReviewSnapshot | undefined> {
265
229
  switch (target.kind) {
266
230
  case "working-tree":
267
- return resolveWorkingTreeSnapshot(repoPath);
268
- case "branch":
269
- return resolveBranchSnapshot(repoPath, target.base);
231
+ return workingTreeSnapshot(cwd);
232
+ case "comparison":
233
+ return comparisonSnapshot(cwd, target);
270
234
  case "commit":
271
- return resolveCommitSnapshot(repoPath, target.sha);
235
+ return commitSnapshot(cwd, target);
272
236
  }
273
237
  }
274
238
 
275
- /** Remove bulk diff text before retaining snapshot metadata in tool results. */
239
+ /** Remove the potentially large patch while retaining pinned target metadata. */
276
240
  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
- };
283
- }
284
-
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");
241
+ const { diffText: _, ...summary } = snapshot;
242
+ return summary;
329
243
  }
330
244
 
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,
245
+ async function showBlob(
246
+ cwd: string,
247
+ commit: string | undefined,
248
+ path: string,
413
249
  ): Promise<string | undefined> {
414
- if (!isCommitObjectId(revision)) return undefined;
250
+ if (!commit) return undefined;
415
251
  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;
252
+ return await git(cwd, ["show", `${commit}:${path}`]);
253
+ } catch (error) {
254
+ if (expectedExitOutput(error, [128]) !== undefined) return undefined;
255
+ throw error;
440
256
  }
441
257
  }
442
258
 
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
- };
259
+ async function isWorkingTreePathAllowed(cwd: string, head: string, path: string): Promise<boolean> {
260
+ return withHeadIndex(cwd, head, async (indexFile) => {
261
+ const output = await git(
262
+ cwd,
263
+ literalPathspec(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", path]),
264
+ indexFile,
265
+ );
266
+ return parseNullList(output).includes(path);
267
+ });
462
268
  }
463
269
 
464
- /** Get the per-file diff for a single changed file in the snapshot. */
465
- export async function getSnapshotFileDiff(
466
- repoPath: string,
270
+ /** Read one before/after file from the target rather than an unrelated live checkout. */
271
+ export async function readReviewFile(
272
+ cwd: string,
467
273
  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",
274
+ path: string,
275
+ side: "before" | "after" = "after",
515
276
  ): Promise<string | undefined> {
516
- if (side === "before") {
517
- try {
518
- return await showGitBlob(repoPath, "HEAD", file);
519
- } catch {
520
- return undefined;
277
+ const safe = resolveReviewPath(cwd, path);
278
+ const target = snapshot.target;
279
+ if (target.kind === "working-tree") {
280
+ if (side === "before") return showBlob(cwd, target.headCommit, safe.path);
281
+ if (!(await isWorkingTreePathAllowed(cwd, target.headCommit, safe.path))) {
282
+ throw new Error(`${safe.path} is not part of the selected working-tree target.`);
521
283
  }
284
+ return readWorkingTreeFile(cwd, safe.path);
522
285
  }
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;
286
+ if (target.kind === "comparison") {
287
+ return showBlob(cwd, side === "before" ? target.mergeBaseCommit : target.headCommit, safe.path);
531
288
  }
289
+ return showBlob(cwd, side === "before" ? target.parentCommit : target.commit, safe.path);
532
290
  }
533
291
 
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;
292
+ /** Read one changed file's patch from the selected target. */
293
+ export async function readReviewDiff(
294
+ cwd: string,
295
+ snapshot: ReviewSnapshot,
296
+ path: string,
297
+ ): Promise<string> {
298
+ const safe = resolveReviewPath(cwd, path);
299
+ if (!snapshot.changedFiles.includes(safe.path)) {
300
+ throw new Error(`${safe.path} is not changed by this target.`);
301
+ }
302
+ const target = snapshot.target;
303
+ if (target.kind === "working-tree") {
304
+ return withHeadIndex(cwd, target.headCommit, async (indexFile) => {
305
+ const tracked = await git(
306
+ cwd,
307
+ literalPathspec(["diff", ...DIFF_FLAGS, "HEAD", "--", safe.path]),
308
+ indexFile,
309
+ );
310
+ return tracked || diffUntrackedFile(cwd, safe.path);
311
+ });
312
+ }
313
+ if (target.kind === "comparison") {
314
+ return git(
315
+ cwd,
316
+ literalPathspec([
317
+ "diff",
318
+ ...DIFF_FLAGS,
319
+ target.mergeBaseCommit,
320
+ target.headCommit,
321
+ "--",
322
+ safe.path,
323
+ ]),
324
+ );
548
325
  }
326
+ return target.parentCommit
327
+ ? git(
328
+ cwd,
329
+ literalPathspec([
330
+ "diff",
331
+ ...DIFF_FLAGS,
332
+ target.parentCommit,
333
+ target.commit,
334
+ "--",
335
+ safe.path,
336
+ ]),
337
+ )
338
+ : git(
339
+ cwd,
340
+ literalPathspec([
341
+ "show",
342
+ ...DIFF_FLAGS,
343
+ "--format=",
344
+ "--root",
345
+ target.commit,
346
+ "--",
347
+ safe.path,
348
+ ]),
349
+ );
549
350
  }
550
351
 
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;
352
+ /** List files present on the target's after side, excluding ignored untracked files. */
353
+ export async function listReviewFiles(cwd: string, snapshot: ReviewSnapshot): Promise<string[]> {
354
+ const target = snapshot.target;
355
+ if (target.kind !== "working-tree") {
356
+ const commit = target.kind === "comparison" ? target.headCommit : target.commit;
357
+ return parseNullList(await git(cwd, ["ls-tree", "-r", "--name-only", "-z", commit]));
563
358
  }
359
+ return withHeadIndex(cwd, target.headCommit, async (indexFile) => {
360
+ const candidates = parseNullList(
361
+ await git(cwd, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], indexFile),
362
+ );
363
+ return filterExistingReviewPaths(cwd, candidates);
364
+ });
564
365
  }
565
366
 
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,
367
+ /** Search literal text on the target's after side with Git's repository-aware search. */
368
+ export async function searchReviewFiles(
369
+ cwd: string,
569
370
  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);
371
+ query: string,
372
+ path?: string,
373
+ ): Promise<string> {
374
+ const pathArgs = path ? ["--", resolveReviewPath(cwd, path).path] : [];
375
+ const target = snapshot.target;
376
+ const args = literalPathspec(["grep", "-n", "-F", "--untracked", "-e", query, ...pathArgs]);
377
+ if (target.kind === "working-tree") {
378
+ return withHeadIndex(cwd, target.headCommit, (indexFile) =>
379
+ gitAllowExit(cwd, args, [1], indexFile),
380
+ );
581
381
  }
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})`;
382
+ const commit = target.kind === "comparison" ? target.headCommit : target.commit;
383
+ return gitAllowExit(
384
+ cwd,
385
+ literalPathspec(["grep", "-n", "-F", "-e", query, commit, ...pathArgs]),
386
+ [1],
387
+ );
587
388
  }