@arnilo/prism-coding-agent 0.0.8 → 0.0.11
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/CHANGELOG.md +37 -6
- package/README.md +22 -7
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +35 -0
- package/dist/ask-user-decision.d.ts +160 -0
- package/dist/ask-user-decision.js +471 -0
- package/dist/checks.d.ts +26 -0
- package/dist/checks.js +249 -0
- package/dist/coding-checkpoint.d.ts +159 -0
- package/dist/coding-checkpoint.js +576 -0
- package/dist/git-exec.d.ts +62 -0
- package/dist/git-exec.js +257 -0
- package/dist/git-status.d.ts +30 -0
- package/dist/git-status.js +146 -0
- package/dist/git-tools.d.ts +34 -0
- package/dist/git-tools.js +502 -0
- package/dist/git.d.ts +139 -0
- package/dist/git.js +495 -0
- package/dist/goal-verify.d.ts +66 -0
- package/dist/goal-verify.js +283 -0
- package/dist/index.d.ts +40 -4
- package/dist/index.js +43 -5
- package/dist/limits.d.ts +76 -0
- package/dist/limits.js +81 -0
- package/dist/list.d.ts +14 -0
- package/dist/list.js +144 -0
- package/dist/repository.d.ts +119 -0
- package/dist/repository.js +633 -0
- package/dist/search.d.ts +14 -0
- package/dist/search.js +166 -0
- package/package.json +8 -5
package/dist/git.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured Git operations over a typed runner.
|
|
3
|
+
*
|
|
4
|
+
* All invocations use file+argument arrays with `--` pathspec separation,
|
|
5
|
+
* `git check-ref-format` for refs, and safe config that disables hooks,
|
|
6
|
+
* external diff/textconv, pagers, and credential prompts by default.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DEFAULT_MAX_GIT_CHANGED_FILES, DEFAULT_MAX_GIT_DIFF_LINES, DEFAULT_MAX_GIT_MESSAGE_BYTES, DEFAULT_MAX_GIT_OUTPUT_BYTES, DEFAULT_MAX_GIT_PATCH_BYTES, DEFAULT_MAX_GIT_PATHS, DEFAULT_MAX_GIT_REF_BYTES, DEFAULT_MAX_GIT_WORKTREES, DEFAULT_MAX_PR_COMMITS, DEFAULT_MAX_PR_HANDOFF_BYTES, HARD_MAX_GIT_CHANGED_FILES, HARD_MAX_GIT_DIFF_LINES, HARD_MAX_GIT_MESSAGE_BYTES, HARD_MAX_GIT_OUTPUT_BYTES, HARD_MAX_GIT_PATCH_BYTES, HARD_MAX_GIT_PATHS, HARD_MAX_GIT_REF_BYTES, HARD_MAX_GIT_WORKTREES, HARD_MAX_PR_COMMITS, HARD_MAX_PR_HANDOFF_BYTES, validateCodingLimit, } from "./limits.js";
|
|
12
|
+
import { createBoundGitRunner, GitError, gitRequireOk, gitText, } from "./git-exec.js";
|
|
13
|
+
import { parsePorcelainV2 } from "./git-status.js";
|
|
14
|
+
import { resolveToCwd } from "./path-utils.js";
|
|
15
|
+
export function resolveGitLimits(options) {
|
|
16
|
+
return {
|
|
17
|
+
maxPaths: validateCodingLimit("maxPaths", options?.maxPaths ?? DEFAULT_MAX_GIT_PATHS, HARD_MAX_GIT_PATHS),
|
|
18
|
+
maxRefBytes: validateCodingLimit("maxRefBytes", options?.maxRefBytes ?? DEFAULT_MAX_GIT_REF_BYTES, HARD_MAX_GIT_REF_BYTES),
|
|
19
|
+
maxMessageBytes: validateCodingLimit("maxMessageBytes", options?.maxMessageBytes ?? DEFAULT_MAX_GIT_MESSAGE_BYTES, HARD_MAX_GIT_MESSAGE_BYTES),
|
|
20
|
+
maxOutputBytes: validateCodingLimit("maxOutputBytes", options?.maxOutputBytes ?? DEFAULT_MAX_GIT_OUTPUT_BYTES, HARD_MAX_GIT_OUTPUT_BYTES),
|
|
21
|
+
maxDiffLines: validateCodingLimit("maxDiffLines", options?.maxDiffLines ?? DEFAULT_MAX_GIT_DIFF_LINES, HARD_MAX_GIT_DIFF_LINES),
|
|
22
|
+
maxChangedFiles: validateCodingLimit("maxChangedFiles", options?.maxChangedFiles ?? DEFAULT_MAX_GIT_CHANGED_FILES, HARD_MAX_GIT_CHANGED_FILES),
|
|
23
|
+
maxPatchBytes: validateCodingLimit("maxPatchBytes", options?.maxPatchBytes ?? DEFAULT_MAX_GIT_PATCH_BYTES, HARD_MAX_GIT_PATCH_BYTES),
|
|
24
|
+
maxWorktrees: validateCodingLimit("maxWorktrees", options?.maxWorktrees ?? DEFAULT_MAX_GIT_WORKTREES, HARD_MAX_GIT_WORKTREES),
|
|
25
|
+
maxPrCommits: validateCodingLimit("maxPrCommits", options?.maxPrCommits ?? DEFAULT_MAX_PR_COMMITS, HARD_MAX_PR_COMMITS),
|
|
26
|
+
maxPrHandoffBytes: validateCodingLimit("maxPrHandoffBytes", options?.maxPrHandoffBytes ?? DEFAULT_MAX_PR_HANDOFF_BYTES, HARD_MAX_PR_HANDOFF_BYTES),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function byteLength(text) {
|
|
30
|
+
return Buffer.byteLength(text, "utf8");
|
|
31
|
+
}
|
|
32
|
+
async function validateBranchName(runner, cwd, name, limits, signal) {
|
|
33
|
+
if (typeof name !== "string" || name.length === 0)
|
|
34
|
+
throw new GitError("branch name is required");
|
|
35
|
+
if (byteLength(name) > limits.maxRefBytes)
|
|
36
|
+
throw new GitError(`branch name exceeds ${limits.maxRefBytes} byte limit`);
|
|
37
|
+
if (name.includes("\0") || name.includes("\n") || name.includes("\r") || name.startsWith("-")) {
|
|
38
|
+
throw new GitError("branch name must not start with '-' or contain NUL/newlines");
|
|
39
|
+
}
|
|
40
|
+
const result = await runner.exec({
|
|
41
|
+
args: ["check-ref-format", "--branch", name],
|
|
42
|
+
cwd,
|
|
43
|
+
signal,
|
|
44
|
+
maxOutputBytes: 64 * 1024,
|
|
45
|
+
});
|
|
46
|
+
if (result.exitCode !== 0) {
|
|
47
|
+
throw new GitError(`invalid branch name: ${name}`);
|
|
48
|
+
}
|
|
49
|
+
return name;
|
|
50
|
+
}
|
|
51
|
+
function validatePaths(paths, limits) {
|
|
52
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
53
|
+
throw new GitError("paths must be a non-empty array");
|
|
54
|
+
}
|
|
55
|
+
if (paths.length > limits.maxPaths) {
|
|
56
|
+
throw new GitError(`paths exceed ${limits.maxPaths} entry limit`);
|
|
57
|
+
}
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const path of paths) {
|
|
60
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
61
|
+
throw new GitError("each path must be a non-empty string");
|
|
62
|
+
}
|
|
63
|
+
if (path.includes("\0"))
|
|
64
|
+
throw new GitError("path must not contain NUL");
|
|
65
|
+
// Keep leading-dash paths as data; always pass after `--`.
|
|
66
|
+
out.push(path);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function truncateLines(text, maxLines) {
|
|
71
|
+
if (text.length === 0)
|
|
72
|
+
return { text: "", truncated: false, lineCount: 0 };
|
|
73
|
+
const endsWithNewline = text.endsWith("\n");
|
|
74
|
+
const lines = text.split("\n");
|
|
75
|
+
if (endsWithNewline)
|
|
76
|
+
lines.pop();
|
|
77
|
+
const lineCount = lines.length;
|
|
78
|
+
if (lineCount <= maxLines)
|
|
79
|
+
return { text, truncated: false, lineCount };
|
|
80
|
+
const kept = lines.slice(0, maxLines).join("\n") + "\n";
|
|
81
|
+
return { text: kept, truncated: true, lineCount };
|
|
82
|
+
}
|
|
83
|
+
async function withTempFile(prefix, contents, fn) {
|
|
84
|
+
const dir = await mkdtemp(join(tmpdir(), prefix));
|
|
85
|
+
const filePath = join(dir, "payload");
|
|
86
|
+
try {
|
|
87
|
+
await writeFile(filePath, contents, { mode: 0o600 });
|
|
88
|
+
return await fn(filePath);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await rm(dir, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function createGitOperations(options) {
|
|
95
|
+
const cwd = resolveToCwd(options.cwd, process.cwd());
|
|
96
|
+
const limits = resolveGitLimits(options);
|
|
97
|
+
const runner = await createBoundGitRunner(options);
|
|
98
|
+
const artifacts = options.artifactWriter;
|
|
99
|
+
async function status(request) {
|
|
100
|
+
const args = ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all"];
|
|
101
|
+
if (request?.includeIgnored)
|
|
102
|
+
args.push("--ignored=traditional");
|
|
103
|
+
const result = await gitRequireOk(runner, {
|
|
104
|
+
args,
|
|
105
|
+
cwd,
|
|
106
|
+
signal: request?.signal,
|
|
107
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
108
|
+
}, "git status");
|
|
109
|
+
return parsePorcelainV2(result.stdout, { maxEntries: limits.maxChangedFiles });
|
|
110
|
+
}
|
|
111
|
+
async function ensureCleanOrCheckpoint(createCheckpoint, signal, label, allowPaths) {
|
|
112
|
+
const current = await status({ signal });
|
|
113
|
+
const blocking = current.entries.filter((entry) => {
|
|
114
|
+
if (entry.kind === "ignored")
|
|
115
|
+
return false;
|
|
116
|
+
if (!allowPaths)
|
|
117
|
+
return true;
|
|
118
|
+
if (allowPaths.has(entry.path))
|
|
119
|
+
return false;
|
|
120
|
+
if (entry.origPath && allowPaths.has(entry.origPath))
|
|
121
|
+
return false;
|
|
122
|
+
return true;
|
|
123
|
+
});
|
|
124
|
+
if (blocking.length === 0)
|
|
125
|
+
return undefined;
|
|
126
|
+
if (!createCheckpoint) {
|
|
127
|
+
throw new GitError(`${label} refused: worktree is dirty. Pass createCheckpoint=true to stash a bounded checkpoint first, or use a disposable worktree.`);
|
|
128
|
+
}
|
|
129
|
+
const stashPaths = [...new Set(blocking.flatMap((entry) => (entry.origPath ? [entry.path, entry.origPath] : [entry.path])))];
|
|
130
|
+
if (stashPaths.length > limits.maxPaths) {
|
|
131
|
+
throw new GitError(`checkpoint paths exceed ${limits.maxPaths} entry limit`);
|
|
132
|
+
}
|
|
133
|
+
await gitRequireOk(runner, {
|
|
134
|
+
args: ["stash", "push", "-u", "-m", "prism-git-checkpoint", "--", ...stashPaths],
|
|
135
|
+
cwd,
|
|
136
|
+
signal,
|
|
137
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
138
|
+
}, "git stash checkpoint");
|
|
139
|
+
const top = await gitRequireOk(runner, { args: ["rev-parse", "-q", "--verify", "refs/stash"], cwd, signal, maxOutputBytes: 64 * 1024 }, "git rev-parse stash");
|
|
140
|
+
return gitText(top).trim() || "refs/stash";
|
|
141
|
+
}
|
|
142
|
+
async function restoreCheckpoint(checkpoint, signal) {
|
|
143
|
+
if (!checkpoint)
|
|
144
|
+
return false;
|
|
145
|
+
await gitRequireOk(runner, {
|
|
146
|
+
args: ["stash", "pop", "--index"],
|
|
147
|
+
cwd,
|
|
148
|
+
signal,
|
|
149
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
150
|
+
}, "git stash pop");
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
async function diff(request) {
|
|
154
|
+
const built = ["diff", "--no-ext-diff", "--no-textconv", "--no-color"];
|
|
155
|
+
if (request?.staged)
|
|
156
|
+
built.push("--cached");
|
|
157
|
+
built.push("--");
|
|
158
|
+
if (request?.paths) {
|
|
159
|
+
built.push(...validatePaths(request.paths, limits));
|
|
160
|
+
}
|
|
161
|
+
const result = await gitRequireOk(runner, { args: built, cwd, signal: request?.signal, maxOutputBytes: limits.maxOutputBytes }, "git diff");
|
|
162
|
+
const raw = gitText(result);
|
|
163
|
+
const trimmed = truncateLines(raw, limits.maxDiffLines);
|
|
164
|
+
let artifact;
|
|
165
|
+
if (trimmed.truncated && artifacts) {
|
|
166
|
+
artifact = await artifacts({
|
|
167
|
+
kind: "diff",
|
|
168
|
+
filename: "diff.patch",
|
|
169
|
+
bytes: Buffer.from(raw, "utf8"),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return { ...trimmed, artifact };
|
|
173
|
+
}
|
|
174
|
+
async function branch(request) {
|
|
175
|
+
if (request.action === "list") {
|
|
176
|
+
const result = await gitRequireOk(runner, {
|
|
177
|
+
args: ["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
|
178
|
+
cwd,
|
|
179
|
+
signal: request.signal,
|
|
180
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
181
|
+
}, "git for-each-ref");
|
|
182
|
+
const refs = gitText(result)
|
|
183
|
+
.split("\n")
|
|
184
|
+
.map((line) => line.trim())
|
|
185
|
+
.filter(Boolean);
|
|
186
|
+
return { refs };
|
|
187
|
+
}
|
|
188
|
+
const name = await validateBranchName(runner, cwd, request.name ?? "", limits, request.signal);
|
|
189
|
+
if (request.action === "validate")
|
|
190
|
+
return { name };
|
|
191
|
+
if (request.action === "create") {
|
|
192
|
+
await gitRequireOk(runner, { args: ["branch", "--", name], cwd, signal: request.signal, maxOutputBytes: limits.maxOutputBytes }, "git branch create");
|
|
193
|
+
return { name };
|
|
194
|
+
}
|
|
195
|
+
// switch
|
|
196
|
+
const checkpoint = await ensureCleanOrCheckpoint(request.createCheckpoint, request.signal, "git switch");
|
|
197
|
+
try {
|
|
198
|
+
await gitRequireOk(runner, { args: ["switch", "--", name], cwd, signal: request.signal, maxOutputBytes: limits.maxOutputBytes }, "git switch");
|
|
199
|
+
return { name, checkpoint };
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (checkpoint)
|
|
203
|
+
await restoreCheckpoint(checkpoint, request.signal).catch(() => undefined);
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function worktree(request) {
|
|
208
|
+
if (request.action === "list") {
|
|
209
|
+
const result = await gitRequireOk(runner, {
|
|
210
|
+
args: ["worktree", "list", "--porcelain", "-z"],
|
|
211
|
+
cwd,
|
|
212
|
+
signal: request.signal,
|
|
213
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
214
|
+
}, "git worktree list");
|
|
215
|
+
const records = gitText(result).split("\0").filter(Boolean);
|
|
216
|
+
const worktrees = [];
|
|
217
|
+
let current;
|
|
218
|
+
for (const record of records) {
|
|
219
|
+
if (record.startsWith("worktree ")) {
|
|
220
|
+
if (current)
|
|
221
|
+
worktrees.push(current);
|
|
222
|
+
current = { path: record.slice("worktree ".length) };
|
|
223
|
+
}
|
|
224
|
+
else if (current && record.startsWith("HEAD ")) {
|
|
225
|
+
current.head = record.slice("HEAD ".length);
|
|
226
|
+
}
|
|
227
|
+
else if (current && record.startsWith("branch ")) {
|
|
228
|
+
current.branch = record.slice("branch ".length);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (current)
|
|
232
|
+
worktrees.push(current);
|
|
233
|
+
if (worktrees.length > limits.maxWorktrees) {
|
|
234
|
+
return { worktrees: worktrees.slice(0, limits.maxWorktrees) };
|
|
235
|
+
}
|
|
236
|
+
return { worktrees };
|
|
237
|
+
}
|
|
238
|
+
if (request.action === "add") {
|
|
239
|
+
const existing = await worktree({ action: "list", signal: request.signal });
|
|
240
|
+
if (existing.worktrees.length >= limits.maxWorktrees) {
|
|
241
|
+
throw new GitError(`worktree count would exceed ${limits.maxWorktrees} limit`);
|
|
242
|
+
}
|
|
243
|
+
if (!request.path)
|
|
244
|
+
throw new GitError("worktree path is required");
|
|
245
|
+
if (request.path.includes("\0") || request.path.startsWith("-")) {
|
|
246
|
+
throw new GitError("worktree path must not start with '-' or contain NUL");
|
|
247
|
+
}
|
|
248
|
+
const args = ["worktree", "add"];
|
|
249
|
+
if (request.branch) {
|
|
250
|
+
const branchName = await validateBranchName(runner, cwd, request.branch, limits, request.signal);
|
|
251
|
+
args.push("-b", branchName);
|
|
252
|
+
}
|
|
253
|
+
args.push("--", request.path);
|
|
254
|
+
await gitRequireOk(runner, { args, cwd, signal: request.signal, maxOutputBytes: limits.maxOutputBytes }, "git worktree add");
|
|
255
|
+
return { worktrees: (await worktree({ action: "list", signal: request.signal })).worktrees, path: request.path };
|
|
256
|
+
}
|
|
257
|
+
// remove
|
|
258
|
+
if (!request.path)
|
|
259
|
+
throw new GitError("worktree path is required");
|
|
260
|
+
const args = ["worktree", "remove"];
|
|
261
|
+
if (request.force)
|
|
262
|
+
args.push("--force");
|
|
263
|
+
args.push("--", request.path);
|
|
264
|
+
await gitRequireOk(runner, { args, cwd, signal: request.signal, maxOutputBytes: limits.maxOutputBytes }, "git worktree remove");
|
|
265
|
+
return { worktrees: (await worktree({ action: "list", signal: request.signal })).worktrees, path: request.path };
|
|
266
|
+
}
|
|
267
|
+
async function apply(request) {
|
|
268
|
+
if (typeof request.patch !== "string")
|
|
269
|
+
throw new GitError("patch must be a string");
|
|
270
|
+
const patchBytes = byteLength(request.patch);
|
|
271
|
+
if (patchBytes < 1)
|
|
272
|
+
throw new GitError("patch must be non-empty");
|
|
273
|
+
if (patchBytes > limits.maxPatchBytes) {
|
|
274
|
+
throw new GitError(`patch exceeds ${limits.maxPatchBytes} byte limit`);
|
|
275
|
+
}
|
|
276
|
+
const runApply = async (args, filePath) => runner.exec({
|
|
277
|
+
args: [...args, "--", filePath],
|
|
278
|
+
cwd,
|
|
279
|
+
signal: request.signal,
|
|
280
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
281
|
+
});
|
|
282
|
+
return await withTempFile("prism-git-patch-", request.patch, async (filePath) => {
|
|
283
|
+
if (request.action === "check") {
|
|
284
|
+
const result = await runApply(["apply", "--check"], filePath);
|
|
285
|
+
const output = (gitText(result, "stderr") || gitText(result)).trim();
|
|
286
|
+
if (result.exitCode !== 0) {
|
|
287
|
+
return { ok: false, output: output || `exit ${result.exitCode}` };
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, output: output || "patch applies cleanly" };
|
|
290
|
+
}
|
|
291
|
+
const checkpoint = request.action === "apply"
|
|
292
|
+
? await ensureCleanOrCheckpoint(request.createCheckpoint, request.signal, `git apply ${request.action}`)
|
|
293
|
+
: undefined;
|
|
294
|
+
// Always check first for apply/reverse.
|
|
295
|
+
const checkArgs = request.action === "reverse"
|
|
296
|
+
? ["apply", "--reverse", "--check"]
|
|
297
|
+
: ["apply", "--check"];
|
|
298
|
+
const check = await runApply(checkArgs, filePath);
|
|
299
|
+
if (check.exitCode !== 0) {
|
|
300
|
+
const output = (gitText(check, "stderr") || gitText(check)).trim();
|
|
301
|
+
if (checkpoint)
|
|
302
|
+
await restoreCheckpoint(checkpoint, request.signal).catch(() => undefined);
|
|
303
|
+
return { ok: false, checkpoint, restored: Boolean(checkpoint), output: output || "patch check failed" };
|
|
304
|
+
}
|
|
305
|
+
const applyArgs = request.action === "reverse"
|
|
306
|
+
? ["apply", "--reverse"]
|
|
307
|
+
: ["apply"];
|
|
308
|
+
const result = await runApply(applyArgs, filePath);
|
|
309
|
+
if (result.exitCode !== 0) {
|
|
310
|
+
const output = (gitText(result, "stderr") || gitText(result)).trim();
|
|
311
|
+
let restored = false;
|
|
312
|
+
if (checkpoint) {
|
|
313
|
+
restored = await restoreCheckpoint(checkpoint, request.signal).catch(() => false);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
// Best-effort restore of tracked files when no checkpoint was taken (clean tree).
|
|
317
|
+
await runner.exec({
|
|
318
|
+
args: ["checkout", "--", "."],
|
|
319
|
+
cwd,
|
|
320
|
+
signal: request.signal,
|
|
321
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
322
|
+
}).catch(() => undefined);
|
|
323
|
+
restored = true;
|
|
324
|
+
}
|
|
325
|
+
return { ok: false, checkpoint, restored, output: output || `apply failed with exit ${result.exitCode}` };
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
checkpoint,
|
|
330
|
+
output: (gitText(result, "stderr") || gitText(result) || "patch applied").trim(),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async function commit(request) {
|
|
335
|
+
const paths = validatePaths(request.paths, limits);
|
|
336
|
+
if (typeof request.message !== "string" || request.message.trim().length === 0) {
|
|
337
|
+
throw new GitError("commit message is required");
|
|
338
|
+
}
|
|
339
|
+
if (byteLength(request.message) > limits.maxMessageBytes) {
|
|
340
|
+
throw new GitError(`commit message exceeds ${limits.maxMessageBytes} byte limit`);
|
|
341
|
+
}
|
|
342
|
+
const identity = options.commitIdentity;
|
|
343
|
+
if (!identity?.name?.trim() || !identity?.email?.trim()) {
|
|
344
|
+
throw new GitError("commitIdentity name and email are required for git commit");
|
|
345
|
+
}
|
|
346
|
+
if (identity.name.includes("\n") || identity.email.includes("\n")) {
|
|
347
|
+
throw new GitError("commitIdentity must not contain newlines");
|
|
348
|
+
}
|
|
349
|
+
const checkpoint = await ensureCleanOrCheckpoint(request.createCheckpoint, request.signal, "git commit", new Set(paths));
|
|
350
|
+
try {
|
|
351
|
+
await gitRequireOk(runner, {
|
|
352
|
+
args: ["add", "--", ...paths],
|
|
353
|
+
cwd,
|
|
354
|
+
signal: request.signal,
|
|
355
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
356
|
+
}, "git add");
|
|
357
|
+
await withTempFile("prism-git-msg-", request.message, async (messageFile) => {
|
|
358
|
+
await gitRequireOk(runner, {
|
|
359
|
+
args: [
|
|
360
|
+
"-c",
|
|
361
|
+
`user.name=${identity.name}`,
|
|
362
|
+
"-c",
|
|
363
|
+
`user.email=${identity.email}`,
|
|
364
|
+
"commit",
|
|
365
|
+
"--no-verify",
|
|
366
|
+
"-F",
|
|
367
|
+
messageFile,
|
|
368
|
+
"--",
|
|
369
|
+
...paths,
|
|
370
|
+
],
|
|
371
|
+
cwd,
|
|
372
|
+
signal: request.signal,
|
|
373
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
374
|
+
}, "git commit");
|
|
375
|
+
});
|
|
376
|
+
const shaResult = await gitRequireOk(runner, { args: ["rev-parse", "HEAD"], cwd, signal: request.signal, maxOutputBytes: 64 * 1024 }, "git rev-parse HEAD");
|
|
377
|
+
return { sha: gitText(shaResult).trim(), checkpoint };
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
// Reset index for the attempted paths; never drop pre-existing dirty work unless checkpointed.
|
|
381
|
+
await runner.exec({
|
|
382
|
+
args: ["reset", "-q", "HEAD", "--", ...paths],
|
|
383
|
+
cwd,
|
|
384
|
+
signal: request.signal,
|
|
385
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
386
|
+
}).catch(() => undefined);
|
|
387
|
+
if (checkpoint)
|
|
388
|
+
await restoreCheckpoint(checkpoint, request.signal).catch(() => undefined);
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function prHandoff(request) {
|
|
393
|
+
const base = request.base;
|
|
394
|
+
if (!base || byteLength(base) > limits.maxRefBytes) {
|
|
395
|
+
throw new GitError("base ref is required and must be within ref byte limits");
|
|
396
|
+
}
|
|
397
|
+
const headResult = await gitRequireOk(runner, {
|
|
398
|
+
args: ["rev-parse", "--verify", request.head ?? "HEAD"],
|
|
399
|
+
cwd,
|
|
400
|
+
signal: request.signal,
|
|
401
|
+
maxOutputBytes: 64 * 1024,
|
|
402
|
+
}, "git rev-parse head");
|
|
403
|
+
const head = gitText(headResult).trim();
|
|
404
|
+
const baseShaResult = await gitRequireOk(runner, { args: ["rev-parse", "--verify", base], cwd, signal: request.signal, maxOutputBytes: 64 * 1024 }, "git rev-parse base");
|
|
405
|
+
const baseSha = gitText(baseShaResult).trim();
|
|
406
|
+
const log = await gitRequireOk(runner, {
|
|
407
|
+
args: ["log", "--format=%H%x09%s", `${baseSha}..${head}`],
|
|
408
|
+
cwd,
|
|
409
|
+
signal: request.signal,
|
|
410
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
411
|
+
}, "git log");
|
|
412
|
+
const commits = gitText(log)
|
|
413
|
+
.split("\n")
|
|
414
|
+
.map((line) => line.trim())
|
|
415
|
+
.filter(Boolean)
|
|
416
|
+
.slice(0, limits.maxPrCommits)
|
|
417
|
+
.map((line) => {
|
|
418
|
+
const tab = line.indexOf("\t");
|
|
419
|
+
if (tab < 0)
|
|
420
|
+
return { sha: line, subject: "" };
|
|
421
|
+
return { sha: line.slice(0, tab), subject: line.slice(tab + 1) };
|
|
422
|
+
});
|
|
423
|
+
const nameStatus = await gitRequireOk(runner, {
|
|
424
|
+
args: ["diff", "--no-ext-diff", "--no-textconv", "--name-only", `${baseSha}...${head}`],
|
|
425
|
+
cwd,
|
|
426
|
+
signal: request.signal,
|
|
427
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
428
|
+
}, "git diff name-only");
|
|
429
|
+
const changedPaths = gitText(nameStatus)
|
|
430
|
+
.split("\n")
|
|
431
|
+
.map((line) => line.trim())
|
|
432
|
+
.filter(Boolean)
|
|
433
|
+
.slice(0, limits.maxChangedFiles)
|
|
434
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
435
|
+
const stat = await gitRequireOk(runner, {
|
|
436
|
+
args: ["diff", "--no-ext-diff", "--no-textconv", "--stat", `${baseSha}...${head}`],
|
|
437
|
+
cwd,
|
|
438
|
+
signal: request.signal,
|
|
439
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
440
|
+
}, "git diff --stat");
|
|
441
|
+
const diffstat = truncateLines(gitText(stat), 200).text.trim();
|
|
442
|
+
let artifact;
|
|
443
|
+
if (artifacts) {
|
|
444
|
+
if (request.includeBundle) {
|
|
445
|
+
const bundleDir = await mkdtemp(join(tmpdir(), "prism-git-bundle-"));
|
|
446
|
+
const bundlePath = join(bundleDir, "handoff.bundle");
|
|
447
|
+
try {
|
|
448
|
+
await gitRequireOk(runner, {
|
|
449
|
+
args: ["bundle", "create", bundlePath, `${baseSha}..${head}`],
|
|
450
|
+
cwd,
|
|
451
|
+
signal: request.signal,
|
|
452
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
453
|
+
}, "git bundle create");
|
|
454
|
+
const { readFile } = await import("node:fs/promises");
|
|
455
|
+
const bytes = await readFile(bundlePath);
|
|
456
|
+
artifact = await artifacts({ kind: "bundle", filename: "handoff.bundle", bytes });
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
await rm(bundleDir, { recursive: true, force: true });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
const patch = await gitRequireOk(runner, {
|
|
464
|
+
args: ["diff", "--no-ext-diff", "--no-textconv", "--binary", `${baseSha}...${head}`],
|
|
465
|
+
cwd,
|
|
466
|
+
signal: request.signal,
|
|
467
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
468
|
+
}, "git diff patch");
|
|
469
|
+
artifact = await artifacts({
|
|
470
|
+
kind: "patch",
|
|
471
|
+
filename: "handoff.patch",
|
|
472
|
+
bytes: patch.stdout,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const handoff = {
|
|
477
|
+
base: baseSha,
|
|
478
|
+
head,
|
|
479
|
+
commits,
|
|
480
|
+
changedPaths,
|
|
481
|
+
diffstat,
|
|
482
|
+
checks: [...(request.checks ?? [])],
|
|
483
|
+
artifact,
|
|
484
|
+
};
|
|
485
|
+
const encoded = Buffer.from(JSON.stringify(handoff), "utf8");
|
|
486
|
+
if (encoded.length > limits.maxPrHandoffBytes) {
|
|
487
|
+
throw new GitError(`PR handoff JSON exceeds ${limits.maxPrHandoffBytes} byte limit`);
|
|
488
|
+
}
|
|
489
|
+
return handoff;
|
|
490
|
+
}
|
|
491
|
+
return { status, diff, branch, worktree, apply, commit, prHandoff };
|
|
492
|
+
}
|
|
493
|
+
export { parsePorcelainV2 } from "./git-status.js";
|
|
494
|
+
export { GitError, SAFE_GIT_ENV, SAFE_GIT_CONFIG_ARGS, createBoundGitRunner, runGitCli, } from "./git-exec.js";
|
|
495
|
+
//# sourceMappingURL=git.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin goal→verify composition: plan Markdown + named checks + workflow
|
|
3
|
+
* suspend/approve + bounded handoff. Not a second agent/workflow engine.
|
|
4
|
+
*/
|
|
5
|
+
import type { OwnershipScope, SecretRedactor } from "@arnilo/prism";
|
|
6
|
+
import { type WorkflowCheckpointAdapter, type WorkflowEvent, type WorkflowResumeValidator, type WorkflowRunResult } from "@arnilo/prism-workflows";
|
|
7
|
+
import { type CodingCheckSummary, type CodingCheckpointMetadata, type CodingHandoffSummary } from "./coding-checkpoint.js";
|
|
8
|
+
export declare const CODING_GOAL_VERIFY_WORKFLOW_ID: "coding-goal-verify";
|
|
9
|
+
export declare const CODING_GOAL_VERIFY_REVISION: "1";
|
|
10
|
+
export declare const CODING_GOAL_VERIFY_SUSPEND_REASON: "approve-coding-goal-verify";
|
|
11
|
+
export declare class CodingGoalVerifyError extends Error {
|
|
12
|
+
readonly code = "ERR_PRISM_CODING_GOAL_VERIFY";
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
export interface CodingGoalVerifyApproval {
|
|
16
|
+
/** Host validator; required — helper fails closed without it. */
|
|
17
|
+
readonly validateResume: WorkflowResumeValidator;
|
|
18
|
+
readonly reason?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface RunCodingGoalVerifyOptions {
|
|
21
|
+
readonly goal: string;
|
|
22
|
+
readonly cwd: string;
|
|
23
|
+
readonly taskId?: string;
|
|
24
|
+
readonly title?: string;
|
|
25
|
+
readonly baseBranch?: string;
|
|
26
|
+
readonly branch?: string;
|
|
27
|
+
/** Named checks to execute via `runCheck` (host-declared; no free-form shell). */
|
|
28
|
+
readonly checks: readonly string[];
|
|
29
|
+
readonly runCheck: (name: string) => Promise<CodingCheckSummary>;
|
|
30
|
+
/** Host-owned bounded handoff; required before completion. */
|
|
31
|
+
readonly buildHandoff: (input: {
|
|
32
|
+
readonly coding: CodingCheckpointMetadata;
|
|
33
|
+
readonly checks: readonly CodingCheckSummary[];
|
|
34
|
+
}) => Promise<CodingHandoffSummary>;
|
|
35
|
+
readonly approval: CodingGoalVerifyApproval;
|
|
36
|
+
readonly checkpoints: WorkflowCheckpointAdapter;
|
|
37
|
+
readonly ownership?: OwnershipScope;
|
|
38
|
+
readonly redactor?: SecretRedactor;
|
|
39
|
+
readonly signal?: AbortSignal;
|
|
40
|
+
readonly onEvent?: (event: WorkflowEvent) => void;
|
|
41
|
+
/** Second call after suspension — mirrors workflow resume. */
|
|
42
|
+
readonly resume?: {
|
|
43
|
+
readonly runId: string;
|
|
44
|
+
readonly decision: "approve" | "deny";
|
|
45
|
+
readonly expectedVersion: number;
|
|
46
|
+
readonly input?: unknown;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Build the durable DAG used by `runCodingGoalVerify` (exported for hosts that want the definition alone). */
|
|
50
|
+
export declare function createCodingGoalVerifyWorkflow(options: {
|
|
51
|
+
readonly goal: string;
|
|
52
|
+
readonly cwd: string;
|
|
53
|
+
readonly taskId: string;
|
|
54
|
+
readonly title: string;
|
|
55
|
+
readonly baseBranch: string;
|
|
56
|
+
readonly branch: string;
|
|
57
|
+
readonly checks: readonly string[];
|
|
58
|
+
readonly runCheck: (name: string) => Promise<CodingCheckSummary>;
|
|
59
|
+
readonly buildHandoff: RunCodingGoalVerifyOptions["buildHandoff"];
|
|
60
|
+
readonly suspendReason: string;
|
|
61
|
+
}): import("@arnilo/prism-workflows").WorkflowDefinition;
|
|
62
|
+
/**
|
|
63
|
+
* Run (or resume) a thin goal→verify coding composition.
|
|
64
|
+
* Fails closed when `approval` / `approval.validateResume` is missing.
|
|
65
|
+
*/
|
|
66
|
+
export declare function runCodingGoalVerify(options: RunCodingGoalVerifyOptions): Promise<WorkflowRunResult>;
|