@mrclrchtr/supi-review 2.8.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.
- 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 +306 -505
- 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 +154 -290
- package/src/session/review-plan-store.ts +20 -51
- package/src/target/packet.ts +46 -369
- package/src/tool/agent-review-schemas.ts +101 -104
- package/src/tool/agent-review-tools.ts +268 -309
- package/src/tool/child-failure-diagnostics.ts +1 -1
- 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 +309 -0
- package/src/tool/runner-helpers.ts +3 -8
- package/src/tool/schemas.ts +75 -62
- package/src/tui/common.ts +175 -0
- package/src/tui/prepare.ts +132 -0
- package/src/tui/run.ts +160 -0
- package/src/types.ts +153 -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,372 @@ import type {
|
|
|
12
17
|
ReviewTargetSpec,
|
|
13
18
|
} from "./types.ts";
|
|
14
19
|
|
|
15
|
-
const
|
|
16
|
-
const
|
|
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
|
|
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
|
-
};
|
|
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
|
-
/**
|
|
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
|
|
43
|
-
|
|
34
|
+
let files = 0;
|
|
44
35
|
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
|
-
}
|
|
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
|
-
/**
|
|
64
|
-
export function
|
|
65
|
-
return
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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`;
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
["
|
|
192
|
-
|
|
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
|
-
|
|
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();
|
|
185
|
+
export interface CommitChoice {
|
|
186
|
+
commit: string;
|
|
187
|
+
label: string;
|
|
217
188
|
}
|
|
218
189
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
.
|
|
229
|
-
|
|
201
|
+
.flatMap((line) => {
|
|
202
|
+
const [commit, label] = line.split("\0");
|
|
203
|
+
return commit && label ? [{ commit, label }] : [];
|
|
204
|
+
});
|
|
230
205
|
}
|
|
231
206
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
.
|
|
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;
|
|
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
|
-
/**
|
|
224
|
+
/** Pin target identities and capture the target's changed files, patch, and stats. */
|
|
261
225
|
export function resolveReviewSnapshot(
|
|
262
|
-
|
|
226
|
+
cwd: string,
|
|
263
227
|
target: ReviewTargetSpec,
|
|
264
228
|
): Promise<ReviewSnapshot | undefined> {
|
|
265
229
|
switch (target.kind) {
|
|
266
230
|
case "working-tree":
|
|
267
|
-
return
|
|
268
|
-
case "
|
|
269
|
-
return
|
|
231
|
+
return workingTreeSnapshot(cwd);
|
|
232
|
+
case "comparison":
|
|
233
|
+
return comparisonSnapshot(cwd, target);
|
|
270
234
|
case "commit":
|
|
271
|
-
return
|
|
235
|
+
return commitSnapshot(cwd, target);
|
|
272
236
|
}
|
|
273
237
|
}
|
|
274
238
|
|
|
275
|
-
/** Remove
|
|
239
|
+
/** Remove the potentially large patch while retaining pinned target metadata. */
|
|
276
240
|
export function summarizeReviewSnapshot(snapshot: ReviewSnapshot): ReviewSnapshotSummary {
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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 (!
|
|
250
|
+
if (!commit) return undefined;
|
|
415
251
|
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;
|
|
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
|
-
|
|
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
|
-
};
|
|
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
|
-
/**
|
|
465
|
-
export async function
|
|
466
|
-
|
|
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
|
-
|
|
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",
|
|
274
|
+
path: string,
|
|
275
|
+
side: "before" | "after" = "after",
|
|
515
276
|
): Promise<string | undefined> {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
): Promise<string
|
|
540
|
-
const
|
|
541
|
-
if (!
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
/**
|
|
567
|
-
export async function
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
): Promise<string
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
}
|