@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.
- package/README.md +71 -180
- package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +40 -0
- package/package.json +3 -3
- package/src/config.ts +30 -21
- package/src/git-command.ts +78 -0
- package/src/git.ts +311 -507
- package/src/history/collect.ts +43 -120
- package/src/model.ts +3 -1
- package/src/review-path.ts +85 -0
- package/src/review-result.ts +43 -92
- package/src/review.ts +187 -286
- package/src/session/review-plan-store.ts +20 -51
- package/src/target/packet.ts +46 -369
- package/src/tool/agent-review-schemas.ts +108 -104
- package/src/tool/agent-review-tools.ts +283 -307
- package/src/tool/child-failure-diagnostics.ts +59 -1
- package/src/tool/child-lifecycle-trace.ts +32 -3
- package/src/tool/child-resource-loader.ts +37 -0
- package/src/tool/child-session-runner.ts +83 -0
- package/src/tool/output-page.ts +47 -0
- package/src/tool/planner-runner.ts +69 -0
- package/src/tool/review-runner.ts +42 -257
- package/src/tool/review-system-prompt.ts +12 -110
- package/src/tool/review-tools.ts +119 -0
- package/src/tool/review-workflow.ts +325 -0
- package/src/tool/runner-helpers.ts +3 -8
- package/src/tool/schemas.ts +75 -62
- package/src/tui/common.ts +237 -0
- package/src/tui/prepare.ts +132 -0
- package/src/tui/run.ts +160 -0
- package/src/types.ts +157 -275
- package/src/history/synthesize.ts +0 -121
- package/src/tool/agent-review-workflow.ts +0 -398
- package/src/tool/brief-runner.ts +0 -180
- package/src/tool/guidance.ts +0 -21
- package/src/tool/review-handlers.ts +0 -314
- package/src/tool/snapshot-tools.ts +0 -117
- package/src/ui/flow.ts +0 -189
- package/src/ui/format-content.ts +0 -143
- package/src/ui/renderer.ts +0 -274
- package/src/ui/review-plan-inspector.ts +0 -391
- package/src/ui/review-tool-format.ts +0 -138
- package/src/ui/review-tool-renderer.ts +0 -234
package/src/git.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
/**
|
|
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
|
|
43
|
-
|
|
33
|
+
let files = 0;
|
|
44
34
|
for (const line of text.split("\n")) {
|
|
45
|
-
if (line.startsWith("diff --git "))
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
.
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
["
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
(
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
.
|
|
229
|
-
|
|
204
|
+
.flatMap((line) => {
|
|
205
|
+
const [commit, label] = line.split("\0");
|
|
206
|
+
return commit && label ? [{ commit, label }] : [];
|
|
207
|
+
});
|
|
230
208
|
}
|
|
231
209
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
/**
|
|
227
|
+
/** Pin target identities and capture the target's changed files, patch, and stats. */
|
|
261
228
|
export function resolveReviewSnapshot(
|
|
262
|
-
|
|
229
|
+
cwd: string,
|
|
263
230
|
target: ReviewTargetSpec,
|
|
264
231
|
): Promise<ReviewSnapshot | undefined> {
|
|
265
232
|
switch (target.kind) {
|
|
266
233
|
case "working-tree":
|
|
267
|
-
return
|
|
268
|
-
case "
|
|
269
|
-
return
|
|
234
|
+
return workingTreeSnapshot(cwd);
|
|
235
|
+
case "comparison":
|
|
236
|
+
return comparisonSnapshot(cwd, target);
|
|
270
237
|
case "commit":
|
|
271
|
-
return
|
|
238
|
+
return commitSnapshot(cwd, target);
|
|
272
239
|
}
|
|
273
240
|
}
|
|
274
241
|
|
|
275
|
-
/** Remove
|
|
242
|
+
/** Remove the potentially large patch while retaining pinned target metadata. */
|
|
276
243
|
export function summarizeReviewSnapshot(snapshot: ReviewSnapshot): ReviewSnapshotSummary {
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 (!
|
|
253
|
+
if (!commit) return undefined;
|
|
415
254
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
/**
|
|
465
|
-
export async function
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
524
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
): Promise<string
|
|
540
|
-
const
|
|
541
|
-
if (!
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
target
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
/**
|
|
567
|
-
export async function
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
): Promise<string
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
}
|