@henryqw/pi-pr 3.1.10 → 4.0.2
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 +24 -9
- package/docs/pr-routing.svg +1 -1
- package/extensions/pr-ci.ts +908 -0
- package/extensions/pr-command.ts +101 -20
- package/extensions/pr-comment-sweep.ts +1102 -0
- package/extensions/pr-create.ts +684 -0
- package/extensions/pr-execution.ts +558 -0
- package/extensions/pr-feedback.ts +519 -0
- package/extensions/pr-github.ts +363 -145
- package/extensions/pr-merge.ts +4 -44
- package/extensions/pr-routing.ts +2 -1
- package/extensions/pr-ui.ts +1 -1
- package/extensions/pr-update-branch.ts +327 -0
- package/extensions/pr.ts +396 -7
- package/package.json +4 -2
- package/skills/pi-pr-comment-sweep/SKILL.md +45 -89
- package/skills/pi-pr-comment-sweep/references/recovery.md +24 -46
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +2 -2
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +223 -1799
- package/skills/pi-pr-create/SKILL.md +6 -77
- package/skills/pi-pr-fix-ci/SKILL.md +39 -39
- package/skills/pi-pr-update-branch/SKILL.md +5 -108
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { extensionConfigDir } from "@henryqw/pi-config-store";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
|
|
8
|
+
export const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
|
|
9
|
+
export const MAX_CONFLICT_PATHS = 128;
|
|
10
|
+
export const MAX_CONFLICT_PATH_BYTES = 1_024;
|
|
11
|
+
export const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
|
|
12
|
+
|
|
13
|
+
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
14
|
+
const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
15
|
+
const PROCESS_TERMINATION_GRACE_MS = 250;
|
|
16
|
+
const LOCK_OWNER_MAX_BYTES = 128;
|
|
17
|
+
const LOCK_NONCE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
|
+
const LOCK_OWNER_PAYLOAD = /^pid=([1-9][0-9]*)\nnonce=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/;
|
|
19
|
+
const LOCK_REF_PREFIX = "refs/pi-pr/worktree-locks";
|
|
20
|
+
|
|
21
|
+
export type ExecResult = {
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
code: number;
|
|
25
|
+
killed: boolean;
|
|
26
|
+
stdoutTruncated?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ExecOptions = {
|
|
30
|
+
cwd: string;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
stdoutLimitBytes?: number;
|
|
34
|
+
stdoutTailBytes?: number;
|
|
35
|
+
stderrLimitBytes?: number;
|
|
36
|
+
stdin?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type Exec = (command: string, args: string[], options: ExecOptions) => Promise<ExecResult>;
|
|
40
|
+
|
|
41
|
+
export type AttemptState = "none" | "attempting" | "applied" | "blocked" | "unknown";
|
|
42
|
+
|
|
43
|
+
function positiveInteger(value: number, label: string): number {
|
|
44
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${label} must be a positive safe integer`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function requiredText(value: unknown, label: string): string {
|
|
49
|
+
if (typeof value !== "string" || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
50
|
+
throw new TypeError(`${label} must be a non-empty string`);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function requiredOid(value: unknown, label: string): string {
|
|
56
|
+
const parsed = requiredText(value, label).toLowerCase();
|
|
57
|
+
if (!OID.test(parsed)) throw new TypeError(`${label} must be a full Git OID`);
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validatedArguments(args: string[]): string[] {
|
|
62
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) {
|
|
63
|
+
throw new TypeError("command arguments must be an array of strings without NUL bytes");
|
|
64
|
+
}
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decode(chunks: Buffer[], bytes: number, label: string): string {
|
|
69
|
+
try {
|
|
70
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes));
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error(`${label} was not valid UTF-8`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run one argv-only child process with bounded streaming output, cancellation, and a deadline. */
|
|
77
|
+
export const spawnBounded: Exec = async (command, args, options) => {
|
|
78
|
+
requiredText(command, "command");
|
|
79
|
+
validatedArguments(args);
|
|
80
|
+
requiredText(options.cwd, "cwd");
|
|
81
|
+
options.signal?.throwIfAborted();
|
|
82
|
+
const timeoutMs = positiveInteger(options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS, "timeoutMs");
|
|
83
|
+
const stdoutLimit = positiveInteger(options.stdoutLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, "stdoutLimitBytes");
|
|
84
|
+
const stdoutTailLimit = options.stdoutTailBytes === undefined
|
|
85
|
+
? undefined
|
|
86
|
+
: positiveInteger(options.stdoutTailBytes, "stdoutTailBytes");
|
|
87
|
+
const stderrLimit = positiveInteger(options.stderrLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, "stderrLimitBytes");
|
|
88
|
+
if (options.stdin !== undefined && typeof options.stdin !== "string") throw new TypeError("stdin must be a string");
|
|
89
|
+
|
|
90
|
+
return await new Promise<ExecResult>((resolve, reject) => {
|
|
91
|
+
const child = spawn(command, args, {
|
|
92
|
+
cwd: options.cwd,
|
|
93
|
+
detached: process.platform !== "win32",
|
|
94
|
+
env: process.env,
|
|
95
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
96
|
+
});
|
|
97
|
+
const childPid = child.pid;
|
|
98
|
+
const processGroup = process.platform !== "win32" && typeof childPid === "number" &&
|
|
99
|
+
Number.isSafeInteger(childPid) && childPid > 0 ? childPid : null;
|
|
100
|
+
const stdout: Buffer[] = [];
|
|
101
|
+
const stderr: Buffer[] = [];
|
|
102
|
+
const stdoutDecoder = stdoutTailLimit === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
105
|
+
let stdoutBytes = 0;
|
|
106
|
+
let stdoutTruncated = false;
|
|
107
|
+
let stderrBytes = 0;
|
|
108
|
+
let failure: Error | undefined;
|
|
109
|
+
let killed = false;
|
|
110
|
+
let settled = false;
|
|
111
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
112
|
+
|
|
113
|
+
const signalChild = (signal: NodeJS.Signals): void => {
|
|
114
|
+
if (processGroup !== null) {
|
|
115
|
+
try {
|
|
116
|
+
process.kill(-processGroup, signal);
|
|
117
|
+
killed = true;
|
|
118
|
+
return;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ESRCH") return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
killed = child.kill(signal) || killed;
|
|
124
|
+
};
|
|
125
|
+
const stop = (error: Error): void => {
|
|
126
|
+
if (failure) return;
|
|
127
|
+
failure = error;
|
|
128
|
+
signalChild("SIGTERM");
|
|
129
|
+
killTimer = setTimeout(() => signalChild("SIGKILL"), PROCESS_TERMINATION_GRACE_MS);
|
|
130
|
+
};
|
|
131
|
+
const appendTail = (text: string): void => {
|
|
132
|
+
if (!text || stdoutTailLimit === undefined) return;
|
|
133
|
+
const chunk = Buffer.from(text, "utf8");
|
|
134
|
+
stdout.push(chunk);
|
|
135
|
+
stdoutBytes += chunk.length;
|
|
136
|
+
if (stdoutBytes <= stdoutTailLimit) return;
|
|
137
|
+
stdoutTruncated = true;
|
|
138
|
+
let remove = stdoutBytes - stdoutTailLimit;
|
|
139
|
+
while (remove > 0) {
|
|
140
|
+
const first = stdout[0]!;
|
|
141
|
+
if (first.length <= remove) {
|
|
142
|
+
stdout.shift();
|
|
143
|
+
stdoutBytes -= first.length;
|
|
144
|
+
remove -= first.length;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let start = remove;
|
|
148
|
+
while (start < first.length && (first[start]! & 0xc0) === 0x80) start += 1;
|
|
149
|
+
stdout[0] = Buffer.from(first.subarray(start));
|
|
150
|
+
stdoutBytes -= start;
|
|
151
|
+
remove = 0;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const decodeTail = (chunk?: Buffer): void => {
|
|
155
|
+
if (!stdoutDecoder) return;
|
|
156
|
+
try {
|
|
157
|
+
appendTail(chunk === undefined ? stdoutDecoder.decode() : stdoutDecoder.decode(chunk, { stream: true }));
|
|
158
|
+
} catch {
|
|
159
|
+
stop(new Error(`${command} stdout was not valid UTF-8`));
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const timer = setTimeout(() => stop(new Error(`${command} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
163
|
+
const abort = (): void => stop(options.signal?.reason instanceof Error
|
|
164
|
+
? options.signal.reason
|
|
165
|
+
: new DOMException("The operation was aborted", "AbortError"));
|
|
166
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
167
|
+
if (options.signal?.aborted) abort();
|
|
168
|
+
|
|
169
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
170
|
+
if (failure) return;
|
|
171
|
+
if (stdoutDecoder) {
|
|
172
|
+
decodeTail(chunk);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
stdoutBytes += chunk.length;
|
|
176
|
+
if (stdoutBytes > stdoutLimit) {
|
|
177
|
+
stop(new Error(`${command} stdout exceeded ${stdoutLimit} bytes`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
stdout.push(chunk);
|
|
181
|
+
});
|
|
182
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
183
|
+
if (failure) return;
|
|
184
|
+
stderrBytes += chunk.length;
|
|
185
|
+
if (stderrBytes > stderrLimit) {
|
|
186
|
+
stop(new Error(`${command} stderr exceeded ${stderrLimit} bytes`));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
stderr.push(chunk);
|
|
190
|
+
});
|
|
191
|
+
child.once("error", stop);
|
|
192
|
+
child.once("close", (code, signal) => {
|
|
193
|
+
if (settled) return;
|
|
194
|
+
settled = true;
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
if (killTimer !== undefined && processGroup === null) clearTimeout(killTimer);
|
|
197
|
+
options.signal?.removeEventListener("abort", abort);
|
|
198
|
+
if (!failure && stdoutDecoder) decodeTail();
|
|
199
|
+
if (failure) {
|
|
200
|
+
reject(failure);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
resolve({
|
|
205
|
+
stdout: stdoutDecoder
|
|
206
|
+
? Buffer.concat(stdout, stdoutBytes).toString("utf8")
|
|
207
|
+
: decode(stdout, stdoutBytes, `${command} stdout`),
|
|
208
|
+
stderr: decode(stderr, stderrBytes, `${command} stderr`),
|
|
209
|
+
code: code ?? 1,
|
|
210
|
+
killed: killed || signal !== null,
|
|
211
|
+
...(stdoutDecoder ? { stdoutTruncated } : {}),
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
reject(error);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
if (options.stdin === undefined) child.stdin.end();
|
|
218
|
+
else child.stdin.end(options.stdin, "utf8");
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export function commandText(command: string, args: readonly string[]): string {
|
|
223
|
+
return [command, ...args].join(" ");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function runChecked(
|
|
227
|
+
exec: Exec,
|
|
228
|
+
command: string,
|
|
229
|
+
args: string[],
|
|
230
|
+
options: ExecOptions,
|
|
231
|
+
allowedCodes: readonly number[] = [0],
|
|
232
|
+
): Promise<ExecResult> {
|
|
233
|
+
const result = await exec(command, args, options);
|
|
234
|
+
if (result.killed || !allowedCodes.includes(result.code)) {
|
|
235
|
+
const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
|
|
236
|
+
throw new Error(`${commandText(command, args)} failed: ${detail}`);
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Inspect both porcelain state and Git operation markers without mutating the repository. */
|
|
242
|
+
export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise<"clean" | "dirty"> {
|
|
243
|
+
const status = await runChecked(exec, "git", ["status", "--porcelain=v1", "--untracked-files=all"], options);
|
|
244
|
+
const stateOutput = await runChecked(exec, "git", [
|
|
245
|
+
"rev-parse",
|
|
246
|
+
...GIT_OPERATION_STATES.flatMap((state) => ["--git-path", state]),
|
|
247
|
+
], options);
|
|
248
|
+
const normalized = stateOutput.stdout.replace(/\r\n/g, "\n");
|
|
249
|
+
const statePaths = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
|
|
250
|
+
if (statePaths.length !== GIT_OPERATION_STATES.length || statePaths.some((path) => !path)) {
|
|
251
|
+
throw new Error("Git operation state path resolution returned invalid output");
|
|
252
|
+
}
|
|
253
|
+
for (const [index, path] of statePaths.entries()) {
|
|
254
|
+
try {
|
|
255
|
+
await lstat(resolve(options.cwd, path));
|
|
256
|
+
return "dirty";
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
259
|
+
const code = error && typeof error === "object" && typeof (error as NodeJS.ErrnoException).code === "string"
|
|
260
|
+
? (error as NodeJS.ErrnoException).code
|
|
261
|
+
: String(error);
|
|
262
|
+
throw new Error(`Git operation state inspection failed for ${GIT_OPERATION_STATES[index]}: ${code}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return status.stdout === "" ? "clean" : "dirty";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type WorktreeLockOwner = {
|
|
269
|
+
oid: string;
|
|
270
|
+
pid: number;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
function gitFailure(args: string[], result: ExecResult): Error {
|
|
274
|
+
const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
|
|
275
|
+
return new Error(`${commandText("git", args)} failed: ${detail}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function oidLine(output: string, label: string): string {
|
|
279
|
+
const normalized = output.replace(/\r\n/g, "\n");
|
|
280
|
+
if (!normalized.endsWith("\n") || normalized.slice(0, -1).includes("\n")) {
|
|
281
|
+
throw new Error(`${label} returned invalid output`);
|
|
282
|
+
}
|
|
283
|
+
return requiredOid(normalized.slice(0, -1), label);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function lockOwnerPid(payload: string): number {
|
|
287
|
+
const match = LOCK_OWNER_PAYLOAD.exec(payload);
|
|
288
|
+
if (!match || match[0] !== payload) throw new Error("Git worktree lock owner object is invalid");
|
|
289
|
+
const pid = Number(match[1]);
|
|
290
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || String(pid) !== match[1]) {
|
|
291
|
+
throw new Error("Git worktree lock owner PID is invalid");
|
|
292
|
+
}
|
|
293
|
+
return pid;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function ownerIsDead(pid: number): boolean {
|
|
297
|
+
positiveInteger(pid, "Git worktree lock owner PID");
|
|
298
|
+
try {
|
|
299
|
+
process.kill(pid, 0);
|
|
300
|
+
return false;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return !!error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ESRCH";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function zeroOid(oid: string): string {
|
|
307
|
+
return requiredOid(oid, "Git worktree lock owner OID").replace(/[0-9a-f]/g, "0");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function readWorktreeLock(cwd: string, lockRef: string, signal?: AbortSignal): Promise<WorktreeLockOwner | null> {
|
|
311
|
+
const symbolicArgs = ["symbolic-ref", "-q", lockRef];
|
|
312
|
+
const symbolic = await spawnBounded("git", symbolicArgs, { cwd, signal });
|
|
313
|
+
if (symbolic.killed || (symbolic.code !== 0 && symbolic.code !== 1)) throw gitFailure(symbolicArgs, symbolic);
|
|
314
|
+
if (symbolic.code === 0) throw new Error("Git worktree lock ref is symbolic");
|
|
315
|
+
if (symbolic.stdout !== "" || symbolic.stderr !== "") throw new Error("Git worktree lock symbolic-ref check returned invalid output");
|
|
316
|
+
|
|
317
|
+
const refArgs = ["rev-parse", "--verify", "--quiet", lockRef];
|
|
318
|
+
const ref = await spawnBounded("git", refArgs, { cwd, signal });
|
|
319
|
+
if (ref.killed || (ref.code !== 0 && ref.code !== 1)) throw gitFailure(refArgs, ref);
|
|
320
|
+
if (ref.code === 1) {
|
|
321
|
+
if (ref.stdout !== "" || ref.stderr !== "") throw new Error("Git worktree lock ref lookup returned invalid output");
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
if (ref.stderr !== "") throw new Error("Git worktree lock ref lookup returned invalid output");
|
|
325
|
+
const oid = oidLine(ref.stdout, "Git worktree lock ref");
|
|
326
|
+
|
|
327
|
+
const objectArgs = ["cat-file", "blob", oid];
|
|
328
|
+
const object = await runChecked(spawnBounded, "git", objectArgs, {
|
|
329
|
+
cwd,
|
|
330
|
+
signal,
|
|
331
|
+
stdoutLimitBytes: LOCK_OWNER_MAX_BYTES,
|
|
332
|
+
stderrLimitBytes: LOCK_OWNER_MAX_BYTES,
|
|
333
|
+
});
|
|
334
|
+
if (object.stderr !== "") throw new Error("Git worktree lock owner object returned invalid output");
|
|
335
|
+
return { oid, pid: lockOwnerPid(object.stdout) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function updateWorktreeLock(cwd: string, args: string[]): Promise<boolean> {
|
|
339
|
+
const result = await spawnBounded("git", args, { cwd });
|
|
340
|
+
if (result.killed) throw gitFailure(args, result);
|
|
341
|
+
return result.code === 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function createWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<boolean> {
|
|
345
|
+
return await updateWorktreeLock(cwd, ["update-ref", "--no-deref", lockRef, ownerOid, zeroOid(ownerOid)]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function deleteWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<boolean> {
|
|
349
|
+
return await updateWorktreeLock(cwd, ["update-ref", "--no-deref", "-d", lockRef, ownerOid]);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function releaseWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<void> {
|
|
353
|
+
const args = ["update-ref", "--no-deref", "-d", lockRef, ownerOid];
|
|
354
|
+
const result = await spawnBounded("git", args, { cwd });
|
|
355
|
+
if (result.killed || result.code !== 0) throw gitFailure(args, result);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function acquireWorktreeLock(
|
|
359
|
+
cwd: string,
|
|
360
|
+
canonical: string,
|
|
361
|
+
lockRef: string,
|
|
362
|
+
ownerOid: string,
|
|
363
|
+
signal?: AbortSignal,
|
|
364
|
+
): Promise<void> {
|
|
365
|
+
signal?.throwIfAborted();
|
|
366
|
+
if (await createWorktreeLock(cwd, lockRef, ownerOid)) return;
|
|
367
|
+
signal?.throwIfAborted();
|
|
368
|
+
const observed = await readWorktreeLock(cwd, lockRef, signal);
|
|
369
|
+
if (!observed) throw new Error("Git worktree lock acquisition failed without a competing owner");
|
|
370
|
+
if (!ownerIsDead(observed.pid)) throw new Error(`Another pi-pr mutation is active for ${canonical}`);
|
|
371
|
+
signal?.throwIfAborted();
|
|
372
|
+
const reclaimed = await deleteWorktreeLock(cwd, lockRef, observed.oid);
|
|
373
|
+
signal?.throwIfAborted();
|
|
374
|
+
if (await createWorktreeLock(cwd, lockRef, ownerOid)) return;
|
|
375
|
+
const replacement = await readWorktreeLock(cwd, lockRef, signal);
|
|
376
|
+
if (!replacement) throw new Error("Git worktree lock disappeared during stale owner recovery");
|
|
377
|
+
if (!ownerIsDead(replacement.pid)) throw new Error(`Another pi-pr mutation is active for ${canonical}`);
|
|
378
|
+
if (replacement.oid !== observed.oid) throw new Error("Git worktree lock changed during stale owner recovery");
|
|
379
|
+
if (!reclaimed) throw new Error("Git worktree stale lock owner could not be reclaimed");
|
|
380
|
+
throw new Error("Git worktree stale lock owner remained after reclamation");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function writeWorktreeLockOwner(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
384
|
+
const pid = positiveInteger(process.pid, "process PID");
|
|
385
|
+
const nonce = randomUUID();
|
|
386
|
+
if (!LOCK_NONCE.test(nonce)) throw new Error("Generated Git worktree lock nonce is invalid");
|
|
387
|
+
const result = await runChecked(spawnBounded, "git", ["hash-object", "-w", "--stdin", "--no-filters"], {
|
|
388
|
+
cwd,
|
|
389
|
+
signal,
|
|
390
|
+
stdin: `pid=${pid}\nnonce=${nonce}\n`,
|
|
391
|
+
});
|
|
392
|
+
if (result.stderr !== "") throw new Error("Git worktree lock owner creation returned invalid output");
|
|
393
|
+
return oidLine(result.stdout, "Git worktree lock owner");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Exclude concurrent PR mutations for one canonical worktree and pi-pr namespace with a Git-ref lease. */
|
|
397
|
+
export async function withWorktreeLock<T>(
|
|
398
|
+
cwd: string,
|
|
399
|
+
operation: () => Promise<T>,
|
|
400
|
+
options: { agentDir?: string; signal?: AbortSignal } = {},
|
|
401
|
+
): Promise<T> {
|
|
402
|
+
options.signal?.throwIfAborted();
|
|
403
|
+
const rootResult = await runChecked(spawnBounded, "git", ["rev-parse", "--show-toplevel"], {
|
|
404
|
+
cwd: requiredText(cwd, "cwd"),
|
|
405
|
+
signal: options.signal,
|
|
406
|
+
});
|
|
407
|
+
const normalizedRoot = rootResult.stdout.replace(/\r\n/g, "\n");
|
|
408
|
+
const rootLines = (normalizedRoot.endsWith("\n") ? normalizedRoot.slice(0, -1) : normalizedRoot).split("\n");
|
|
409
|
+
if (rootLines.length !== 1 || !rootLines[0]) throw new Error("Git worktree root resolution returned invalid output");
|
|
410
|
+
const canonical = requiredText(await realpath(rootLines[0]), "canonical Git worktree root");
|
|
411
|
+
const lockNamespace = resolve(extensionConfigDir("pi-pr", options.agentDir));
|
|
412
|
+
const identity = createHash("sha256").update(lockNamespace).update("\0").update(canonical).digest("hex");
|
|
413
|
+
const lockRef = `${LOCK_REF_PREFIX}/${identity}`;
|
|
414
|
+
const ownerOid = await writeWorktreeLockOwner(canonical, options.signal);
|
|
415
|
+
let acquired = false;
|
|
416
|
+
try {
|
|
417
|
+
await acquireWorktreeLock(canonical, canonical, lockRef, ownerOid, options.signal);
|
|
418
|
+
acquired = true;
|
|
419
|
+
options.signal?.throwIfAborted();
|
|
420
|
+
return await operation();
|
|
421
|
+
} finally {
|
|
422
|
+
if (acquired) await releaseWorktreeLock(canonical, lockRef, ownerOid);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function parseNulPaths(output: string, label: string): string[] {
|
|
427
|
+
if (output === "") return [];
|
|
428
|
+
if (!output.endsWith("\0")) throw new Error(`${label} returned malformed paths`);
|
|
429
|
+
const paths = output.slice(0, -1).split("\0");
|
|
430
|
+
let bytes = 0;
|
|
431
|
+
if (paths.length > MAX_CONFLICT_PATHS) throw new Error(`${label} returned more than ${MAX_CONFLICT_PATHS} paths`);
|
|
432
|
+
for (const path of paths) {
|
|
433
|
+
if (!path || path.startsWith("/") || path === "." || path === ".." || path.split("/").includes("..")) {
|
|
434
|
+
throw new Error(`${label} returned an unsafe path`);
|
|
435
|
+
}
|
|
436
|
+
const size = Buffer.byteLength(path, "utf8");
|
|
437
|
+
if (size > MAX_CONFLICT_PATH_BYTES) throw new Error(`${label} returned an overlong path`);
|
|
438
|
+
bytes += size;
|
|
439
|
+
}
|
|
440
|
+
if (bytes > MAX_CONFLICT_PATHS_BYTES) throw new Error(`${label} returned too much path data`);
|
|
441
|
+
if (new Set(paths).size !== paths.length) throw new Error(`${label} returned duplicate paths`);
|
|
442
|
+
return paths;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function statusPath(record: string): { path: string; rename: boolean } {
|
|
446
|
+
if (record.startsWith("? ") || record.startsWith("! ")) return { path: record.slice(2), rename: false };
|
|
447
|
+
const fields = record[0] === "1" ? 8 : record[0] === "2" ? 9 : record[0] === "u" ? 10 : 0;
|
|
448
|
+
if (!fields) throw new Error("Git status returned an unsupported record");
|
|
449
|
+
let separator = -1;
|
|
450
|
+
for (let count = 0; count < fields; count += 1) {
|
|
451
|
+
separator = record.indexOf(" ", separator + 1);
|
|
452
|
+
if (separator < 0) throw new Error("Git status returned a malformed record");
|
|
453
|
+
}
|
|
454
|
+
return { path: record.slice(separator + 1), rename: record[0] === "2" };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Parse porcelain v2 -z into exact per-path records for conflict-baseline comparison. */
|
|
458
|
+
export function parseStatusSnapshot(output: string): Map<string, string> {
|
|
459
|
+
const snapshot = new Map<string, string>();
|
|
460
|
+
if (output === "") return snapshot;
|
|
461
|
+
if (!output.endsWith("\0")) throw new Error("Git status returned a malformed snapshot");
|
|
462
|
+
const records = output.slice(0, -1).split("\0");
|
|
463
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
464
|
+
const record = records[index]!;
|
|
465
|
+
const parsed = statusPath(record);
|
|
466
|
+
if (!parsed.path || snapshot.has(parsed.path)) throw new Error("Git status returned duplicate or empty paths");
|
|
467
|
+
let raw = `${record}\0`;
|
|
468
|
+
const paths = [parsed.path];
|
|
469
|
+
if (parsed.rename) {
|
|
470
|
+
const original = records[++index];
|
|
471
|
+
if (original === undefined || !original || original === parsed.path || snapshot.has(original)) {
|
|
472
|
+
throw new Error("Git status returned a malformed or duplicate rename record");
|
|
473
|
+
}
|
|
474
|
+
raw += `${original}\0`;
|
|
475
|
+
paths.push(original);
|
|
476
|
+
}
|
|
477
|
+
for (const path of paths) snapshot.set(path, raw);
|
|
478
|
+
}
|
|
479
|
+
return snapshot;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function assertOnlyDeclaredStatusChanged(
|
|
483
|
+
baseline: string,
|
|
484
|
+
current: string,
|
|
485
|
+
declaredPaths: readonly string[],
|
|
486
|
+
): void {
|
|
487
|
+
const before = parseStatusSnapshot(baseline);
|
|
488
|
+
const after = parseStatusSnapshot(current);
|
|
489
|
+
const declared = new Set(declaredPaths);
|
|
490
|
+
for (const path of new Set([...before.keys(), ...after.keys()])) {
|
|
491
|
+
if (!declared.has(path) && before.get(path) !== after.get(path)) {
|
|
492
|
+
throw new Error(`Worktree changed outside declared conflict paths: ${path}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function readHead(exec: Exec, options: ExecOptions): Promise<string> {
|
|
498
|
+
const result = await runChecked(exec, "git", ["rev-parse", "--verify", "HEAD^{commit}"], options);
|
|
499
|
+
return requiredOid(result.stdout.trim(), "local HEAD");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function readRemoteOid(
|
|
503
|
+
exec: Exec,
|
|
504
|
+
options: ExecOptions,
|
|
505
|
+
fetchSource: string,
|
|
506
|
+
ref: string,
|
|
507
|
+
): Promise<string | null> {
|
|
508
|
+
const result = await runChecked(exec, "git", [
|
|
509
|
+
"ls-remote", "--exit-code", "--refs", fetchSource, `refs/heads/${ref}`,
|
|
510
|
+
], options, [0, 2]);
|
|
511
|
+
if (result.code === 2) {
|
|
512
|
+
if (result.stdout !== "") throw new Error("git ls-remote returned an invalid absent-ref response");
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
const line = result.stdout.endsWith("\n") ? result.stdout.slice(0, -1) : result.stdout;
|
|
516
|
+
const parts = line.split("\t");
|
|
517
|
+
if (line.includes("\n") || parts.length !== 2 || parts[1] !== `refs/heads/${ref}`) {
|
|
518
|
+
throw new Error("git ls-remote returned an unexpected ref");
|
|
519
|
+
}
|
|
520
|
+
return requiredOid(parts[0], "remote OID");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export async function isAncestor(exec: Exec, options: ExecOptions, ancestor: string, descendant: string): Promise<boolean> {
|
|
524
|
+
const result = await runChecked(exec, "git", ["merge-base", "--is-ancestor", ancestor, descendant], options, [0, 1]);
|
|
525
|
+
return result.code === 0;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export async function resolveRepositoryFetchSource(
|
|
529
|
+
exec: Exec,
|
|
530
|
+
options: ExecOptions,
|
|
531
|
+
authority: { host: string; repository: string },
|
|
532
|
+
): Promise<string> {
|
|
533
|
+
const host = requiredText(authority.host, "repository host").toLowerCase();
|
|
534
|
+
const repository = requiredText(authority.repository, "repository");
|
|
535
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) throw new TypeError("repository must be OWNER/REPOSITORY");
|
|
536
|
+
const protocol = (await runChecked(exec, "gh", ["config", "get", "git_protocol", "--host", host], options)).stdout.trim();
|
|
537
|
+
if (protocol !== "https" && protocol !== "ssh") throw new Error("GitHub CLI git protocol must be https or ssh");
|
|
538
|
+
const response = await runChecked(exec, "gh", [
|
|
539
|
+
"api", "--hostname", host,
|
|
540
|
+
"-H", "Accept: application/vnd.github+json",
|
|
541
|
+
"-H", "X-GitHub-Api-Version: 2022-11-28",
|
|
542
|
+
`repos/${repository}`,
|
|
543
|
+
], options);
|
|
544
|
+
let value: unknown;
|
|
545
|
+
try {
|
|
546
|
+
value = JSON.parse(response.stdout);
|
|
547
|
+
} catch {
|
|
548
|
+
throw new Error("Read repository URLs returned invalid JSON");
|
|
549
|
+
}
|
|
550
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Read repository URLs returned invalid JSON");
|
|
551
|
+
const record = value as Record<string, unknown>;
|
|
552
|
+
const expectedHttps = `https://${host}/${repository}.git`;
|
|
553
|
+
const expectedSsh = `git@${host}:${repository}.git`;
|
|
554
|
+
if (record.clone_url !== expectedHttps || record.ssh_url !== expectedSsh) {
|
|
555
|
+
throw new Error("Read repository URLs did not match frozen repository authority");
|
|
556
|
+
}
|
|
557
|
+
return protocol === "https" ? expectedHttps : expectedSsh;
|
|
558
|
+
}
|