killeros 2.0.22 → 2.1.23

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.
@@ -0,0 +1,727 @@
1
+ import { spawn } from "node:child_process";
2
+ import { watch } from "node:fs";
3
+ import { lstat, open, readFile, readlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { inflate } from "node:zlib";
7
+
8
+ const GIT_TIMEOUT_MS = 5_000;
9
+ const GIT_OUTPUT_LIMIT = 16 * 1024 * 1024;
10
+ const SNAPSHOT_CONTENT_LIMIT = 128 * 1024 * 1024;
11
+ const MAX_DIFF_OPERATIONS = 500_000;
12
+ const MAX_FILES = 20;
13
+ const inflateAsync = promisify(inflate);
14
+
15
+ export type ChangeUnavailableReason = "not-git" | "timeout" | "too-large" | "error";
16
+
17
+ export type ChangedFile =
18
+ | { kind: "added" | "modified" | "deleted"; path: string; additions: number; deletions: number; detail?: "binary" | "mode" }
19
+ | { kind: "renamed"; path: string; previousPath: string; additions: number; deletions: number; detail?: "binary" | "mode" };
20
+
21
+ export type ChangeSummary =
22
+ | { state: "available"; totalFiles: number; additions: number; deletions: number; files: ChangedFile[]; omittedFiles: number }
23
+ | { state: "unavailable"; reason: ChangeUnavailableReason };
24
+
25
+ export type CheckAttempt = { label: CheckLabel; outcome: "passed" | "failed" };
26
+
27
+ const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
28
+ const PACKAGE_SCRIPTS = ["test", "check", "lint", "typecheck", "build"] as const;
29
+ const GRADLE_COMMANDS = ["gradle", "gradlew", "gradlew.bat", "./gradlew", "./gradlew.bat", ".\\gradlew", ".\\gradlew.bat"] as const;
30
+ const PACKAGE_CHECK_LABELS = PACKAGE_MANAGERS.flatMap((manager) => [
31
+ `${manager} test`,
32
+ ...PACKAGE_SCRIPTS.map((script) => `${manager} run ${script}` as const),
33
+ ] as const);
34
+ const GRADLE_CHECK_LABELS = GRADLE_COMMANDS.flatMap((command) => [
35
+ `${command} test`,
36
+ `${command} check`,
37
+ ] as const);
38
+
39
+ export const CHECK_LABELS = [
40
+ ...PACKAGE_CHECK_LABELS,
41
+ "python -m pytest", "py -m pytest", "node --test", "cargo clippy", "cargo check", "cargo test",
42
+ "dotnet test", "mvn verify", "mvn test", "go test", "go vet", "pytest",
43
+ ...GRADLE_CHECK_LABELS,
44
+ ] as const;
45
+
46
+ export type CheckLabel = typeof CHECK_LABELS[number];
47
+
48
+ export interface ChangeReceiptCollection {
49
+ finish(): Promise<ChangeSummary>;
50
+ dispose(): Promise<void>;
51
+ }
52
+
53
+ class GitFailure extends Error {
54
+ readonly reason: Exclude<ChangeUnavailableReason, "not-git">;
55
+ readonly stderr: Buffer;
56
+
57
+ constructor(reason: Exclude<ChangeUnavailableReason, "not-git">, stderr = Buffer.alloc(0)) {
58
+ super(reason);
59
+ this.reason = reason;
60
+ this.stderr = stderr;
61
+ }
62
+ }
63
+
64
+ function runGit(cwd: string, args: readonly string[], input?: Buffer): Promise<Buffer> {
65
+ return new Promise((resolve, reject) => {
66
+ const child = spawn("git", args, {
67
+ cwd,
68
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
69
+ stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
70
+ windowsHide: true,
71
+ });
72
+ const stdout: Buffer[] = [];
73
+ const stderr: Buffer[] = [];
74
+ let stdoutBytes = 0;
75
+ let stderrBytes = 0;
76
+ let failure: GitFailure | undefined;
77
+ let settled = false;
78
+ const timer = setTimeout(() => {
79
+ failure = new GitFailure("timeout");
80
+ child.kill();
81
+ }, GIT_TIMEOUT_MS);
82
+ timer.unref();
83
+ const capture = (chunks: Buffer[], isStdout: boolean) => (chunk: Buffer): void => {
84
+ const nextBytes = (isStdout ? stdoutBytes : stderrBytes) + chunk.length;
85
+ if (nextBytes > GIT_OUTPUT_LIMIT) {
86
+ failure = new GitFailure("too-large");
87
+ child.kill();
88
+ return;
89
+ }
90
+ if (isStdout) stdoutBytes = nextBytes;
91
+ else stderrBytes = nextBytes;
92
+ chunks.push(chunk);
93
+ };
94
+ child.stdout!.on("data", capture(stdout, true));
95
+ child.stderr!.on("data", capture(stderr, false));
96
+ if (input) child.stdin?.end(input);
97
+ child.once("error", () => {
98
+ if (settled) return;
99
+ settled = true;
100
+ clearTimeout(timer);
101
+ reject(new GitFailure("error"));
102
+ });
103
+ child.once("close", (code) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ if (failure) reject(failure);
108
+ else if (code === 0) resolve(Buffer.concat(stdout));
109
+ else reject(new GitFailure("error", Buffer.concat(stderr)));
110
+ });
111
+ });
112
+ }
113
+
114
+ function decode(buffer: Buffer): string {
115
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
116
+ }
117
+
118
+ function missingFile(error: unknown): boolean {
119
+ return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
120
+ }
121
+
122
+ type FilterConfiguration = { names: readonly string[]; sources: ReadonlyMap<string, Buffer> };
123
+ type Repository = {
124
+ root: string;
125
+ gitDirectory: string;
126
+ commonDirectory: string;
127
+ objectDirectory: string;
128
+ filterConfiguration: FilterConfiguration;
129
+ blobCache: Map<string, Buffer>;
130
+ blobCacheBytes: number;
131
+ };
132
+ const repositoryCache = new Map<string, Promise<Repository>>();
133
+
134
+ async function loadFilterConfiguration(root: string, gitDirectory: string): Promise<FilterConfiguration> {
135
+ const records = decode(await runGit(root, ["config", "--null", "--show-origin", "--name-only", "--list"])).split("\0");
136
+ const names = new Set<string>();
137
+ const sourcePaths = new Set<string>([path.join(gitDirectory, "HEAD")]);
138
+ for (let index = 0; index + 1 < records.length; index += 2) {
139
+ const origin = records[index];
140
+ const key = records[index + 1];
141
+ if (origin?.startsWith("file:")) sourcePaths.add(path.resolve(root, origin.slice("file:".length)));
142
+ if (key && /^filter\..*\.(clean|process)$/u.test(key)) names.add(key.slice("filter.".length, key.lastIndexOf(".")));
143
+ }
144
+ const sources = new Map<string, Buffer>();
145
+ for (const sourcePath of sourcePaths) sources.set(sourcePath, await readBoundedFile(sourcePath, GIT_OUTPUT_LIMIT));
146
+ return { names: [...names], sources };
147
+ }
148
+
149
+ async function currentFilterNames(repo: Repository): Promise<readonly string[]> {
150
+ for (const [sourcePath, previous] of repo.filterConfiguration.sources) {
151
+ try {
152
+ if (!(await readBoundedFile(sourcePath, GIT_OUTPUT_LIMIT)).equals(previous)) {
153
+ repo.filterConfiguration = await loadFilterConfiguration(repo.root, repo.gitDirectory);
154
+ break;
155
+ }
156
+ } catch (error) {
157
+ if (!missingFile(error)) throw error;
158
+ repo.filterConfiguration = await loadFilterConfiguration(repo.root, repo.gitDirectory);
159
+ break;
160
+ }
161
+ }
162
+ return repo.filterConfiguration.names;
163
+ }
164
+
165
+ async function repository(cwd: string): Promise<Repository> {
166
+ const cached = repositoryCache.get(cwd);
167
+ if (cached) return cached;
168
+ const pending = (async () => {
169
+ const result = decode(await runGit(cwd, ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"])).trimEnd().split(/\r?\n/u);
170
+ const [root, gitDirectory, commonDirectory] = result;
171
+ if (!root || !gitDirectory || !commonDirectory) throw new GitFailure("error");
172
+ return {
173
+ root,
174
+ gitDirectory,
175
+ commonDirectory,
176
+ objectDirectory: path.join(commonDirectory, "objects"),
177
+ filterConfiguration: await loadFilterConfiguration(root, gitDirectory),
178
+ blobCache: new Map(),
179
+ blobCacheBytes: 0,
180
+ };
181
+ })();
182
+ repositoryCache.set(cwd, pending);
183
+ try {
184
+ return await pending;
185
+ } catch (error) {
186
+ repositoryCache.delete(cwd);
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ type DirtyFile = {
192
+ path: string;
193
+ headMode?: string;
194
+ headObjectId?: string;
195
+ indexMode?: string;
196
+ indexObjectId?: string;
197
+ mode?: string;
198
+ content?: Buffer;
199
+ contentObjectId?: string;
200
+ };
201
+
202
+ async function readBoundedFile(filePath: string, limit: number): Promise<Buffer> {
203
+ const handle = await open(filePath, "r");
204
+ try {
205
+ const stats = await handle.stat();
206
+ if (!stats.isFile()) throw new GitFailure("error");
207
+ if (stats.size > limit) throw new GitFailure("too-large");
208
+ const content = Buffer.alloc(stats.size + 1);
209
+ let length = 0;
210
+ while (length < content.length) {
211
+ const { bytesRead } = await handle.read(content, length, content.length - length, length);
212
+ if (bytesRead === 0) break;
213
+ length += bytesRead;
214
+ }
215
+ if (length > stats.size) throw new GitFailure("error");
216
+ return content.subarray(0, length);
217
+ } finally {
218
+ await handle.close();
219
+ }
220
+ }
221
+
222
+ async function readSnapshotFiles(repo: Repository, files: Map<string, DirtyFile>): Promise<void> {
223
+ const pending = [...files.values()].filter((file) => file.mode && !file.contentObjectId);
224
+ let totalBytes = 0;
225
+ let nextIndex = 0;
226
+ const readNext = async (): Promise<void> => {
227
+ while (nextIndex < pending.length) {
228
+ const file = pending[nextIndex];
229
+ nextIndex += 1;
230
+ if (!file?.mode) continue;
231
+ const absolutePath = path.join(repo.root, ...file.path.split("/"));
232
+ if (file.mode === "120000") {
233
+ const content = Buffer.from(await readlink(absolutePath));
234
+ totalBytes += content.length;
235
+ if (totalBytes > SNAPSHOT_CONTENT_LIMIT) throw new GitFailure("too-large");
236
+ file.content = content;
237
+ continue;
238
+ }
239
+ const handle = await open(absolutePath, "r");
240
+ try {
241
+ const stats = await handle.stat();
242
+ if (!stats.isFile()) throw new GitFailure("error");
243
+ totalBytes += stats.size;
244
+ if (totalBytes > SNAPSHOT_CONTENT_LIMIT) throw new GitFailure("too-large");
245
+ const content = Buffer.alloc(stats.size + 1);
246
+ let length = 0;
247
+ while (length < content.length) {
248
+ const { bytesRead } = await handle.read(content, length, content.length - length, length);
249
+ if (bytesRead === 0) break;
250
+ length += bytesRead;
251
+ }
252
+ if (length > stats.size) throw new GitFailure("error");
253
+ totalBytes -= stats.size - length;
254
+ file.content = content.subarray(0, length);
255
+ } finally {
256
+ await handle.close();
257
+ }
258
+ }
259
+ };
260
+ await Promise.all(Array.from({ length: Math.min(8, pending.length) }, readNext));
261
+ }
262
+
263
+ type Snapshot = { head: string; files: Map<string, DirtyFile> };
264
+ type RepositoryMonitor = {
265
+ changedPaths: Set<string>;
266
+ inUse: boolean;
267
+ repo: Repository;
268
+ snapshot: Snapshot;
269
+ watchFailed: boolean;
270
+ watchers: Array<ReturnType<typeof watch>>;
271
+ };
272
+
273
+ const repositoryMonitors = new Map<string, RepositoryMonitor>();
274
+
275
+ function discardMonitor(monitor: RepositoryMonitor): void {
276
+ for (const watcher of monitor.watchers) watcher.close();
277
+ repositoryMonitors.delete(monitor.repo.root);
278
+ }
279
+
280
+ async function snapshot(repo: Repository, paths?: readonly string[]): Promise<Snapshot> {
281
+ const filterNames = await currentFilterNames(repo);
282
+ const output = decode(await runGit(repo.root, [
283
+ "-c", "core.fsmonitor=false",
284
+ ...filterNames.flatMap((name) => ["-c", `filter.${name}.clean=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`]),
285
+ "status", "--porcelain=v2", "--branch", "--no-ahead-behind", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=all",
286
+ ...(paths ? ["--", ...paths] : []),
287
+ ]));
288
+ const records = output.split("\0").filter(Boolean);
289
+ const headRecord = records.find((record) => record.startsWith("# branch.oid "));
290
+ if (!headRecord) throw new Error("missing HEAD state");
291
+ const files = new Map<string, DirtyFile>();
292
+ for (const record of records) {
293
+ if (record.startsWith("# ")) continue;
294
+ if (record.startsWith("u ")) throw new Error("unmerged index");
295
+ if (record.startsWith("? ")) {
296
+ const filePath = record.slice(2);
297
+ if (!filePath || filePath.endsWith("/")) continue;
298
+ files.set(filePath, {
299
+ ...files.get(filePath),
300
+ path: filePath,
301
+ indexMode: undefined,
302
+ indexObjectId: undefined,
303
+ mode: "100644",
304
+ contentObjectId: undefined,
305
+ });
306
+ continue;
307
+ }
308
+ const match = /^1 (\S{2}) \S+ ([0-7]{6}) ([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) (.*)$/su.exec(record);
309
+ if (!match) throw new Error("invalid status record");
310
+ const [, status, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, filePath] = match;
311
+ if (!status || !headMode || !indexMode || !worktreeMode || !headObjectId || !indexObjectId || !filePath) throw new Error("incomplete status record");
312
+ const worktreeChanged = status[1] !== ".";
313
+ const selectedMode = worktreeChanged ? worktreeMode : indexMode;
314
+ files.set(filePath, {
315
+ ...files.get(filePath),
316
+ path: filePath,
317
+ headMode: headMode === "000000" ? undefined : headMode,
318
+ headObjectId: /^0+$/u.test(headObjectId) ? undefined : headObjectId,
319
+ indexMode: indexMode === "000000" ? undefined : indexMode,
320
+ indexObjectId: /^0+$/u.test(indexObjectId) ? undefined : indexObjectId,
321
+ mode: selectedMode === "000000" ? undefined : selectedMode,
322
+ contentObjectId: worktreeChanged || /^0+$/u.test(indexObjectId) ? undefined : indexObjectId,
323
+ });
324
+ }
325
+ await readSnapshotFiles(repo, files);
326
+ return { head: headRecord.slice("# branch.oid ".length), files };
327
+ }
328
+
329
+ async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: readonly string[]): Promise<Snapshot | undefined> {
330
+ const files = new Map<string, DirtyFile>();
331
+ let totalBytes = 0;
332
+ for (const filePath of paths) {
333
+ const previous = baseline.files.get(filePath);
334
+ if (!previous) return undefined;
335
+ try {
336
+ const absolutePath = path.join(repo.root, ...filePath.split("/"));
337
+ const stats = await lstat(absolutePath);
338
+ const mode = stats.isSymbolicLink() ? "120000" : stats.mode & 0o111 ? "100755" : "100644";
339
+ const content = stats.isSymbolicLink()
340
+ ? Buffer.from(await readlink(absolutePath))
341
+ : await readBoundedFile(absolutePath, SNAPSHOT_CONTENT_LIMIT - totalBytes);
342
+ totalBytes += content.length;
343
+ if (totalBytes > SNAPSHOT_CONTENT_LIMIT) throw new GitFailure("too-large");
344
+ files.set(filePath, { ...previous, mode, content, contentObjectId: undefined });
345
+ } catch (error) {
346
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error;
347
+ files.set(filePath, { ...previous, mode: undefined, content: undefined });
348
+ }
349
+ }
350
+ return { head: baseline.head, files };
351
+ }
352
+
353
+ async function currentHead(repo: Repository): Promise<string> {
354
+ const head = (await readBoundedFile(path.join(repo.gitDirectory, "HEAD"), 4_096)).toString("ascii").trim();
355
+ if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) return head;
356
+ const match = /^ref: (refs\/[^\0\r\n]+)$/u.exec(head);
357
+ const reference = match?.[1];
358
+ if (!reference || reference.includes("\\") || reference.split("/").some((part) => part === "." || part === "..")) throw new GitFailure("error");
359
+ try {
360
+ return (await readBoundedFile(path.join(repo.commonDirectory, ...reference.split("/")), 4_096)).toString("ascii").trim();
361
+ } catch (error) {
362
+ if (!missingFile(error)) throw error;
363
+ }
364
+ try {
365
+ const packed = (await readBoundedFile(path.join(repo.commonDirectory, "packed-refs"), GIT_OUTPUT_LIMIT)).toString("ascii");
366
+ const packedMatch = new RegExp(`^([0-9a-f]{40}(?:[0-9a-f]{24})?) ${reference.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`, "mu").exec(packed);
367
+ return packedMatch?.[1] ?? "(initial)";
368
+ } catch (error) {
369
+ if (missingFile(error)) return "(initial)";
370
+ throw error;
371
+ }
372
+ }
373
+
374
+ function observes(filePath: string, observedPaths: readonly string[]): boolean {
375
+ return observedPaths.includes(".") || observedPaths.some((observed) => filePath === observed || filePath.startsWith(`${observed}/`) || observed.startsWith(`${filePath}/`));
376
+ }
377
+
378
+ async function refreshSnapshot(monitor: RepositoryMonitor, observedPaths: readonly string[], current = monitor.snapshot): Promise<Snapshot> {
379
+ if (monitor.watchFailed || observedPaths.length > 200) return snapshot(monitor.repo);
380
+ if (observedPaths.length === 0) return current;
381
+ const scoped = await snapshotKnownPaths(monitor.repo, current, observedPaths) ?? await snapshot(monitor.repo, observedPaths);
382
+ const files = new Map(current.files);
383
+ for (const filePath of files.keys()) {
384
+ if (observes(filePath, observedPaths)) files.delete(filePath);
385
+ }
386
+ for (const [filePath, file] of scoped.files) files.set(filePath, file);
387
+ return { head: scoped.head, files };
388
+ }
389
+
390
+ function createMonitor(repo: Repository, initialSnapshot: Snapshot): RepositoryMonitor {
391
+ const changedPaths = new Set<string>();
392
+ const monitor: RepositoryMonitor = {
393
+ changedPaths,
394
+ inUse: false,
395
+ repo,
396
+ snapshot: initialSnapshot,
397
+ watchFailed: false,
398
+ watchers: [],
399
+ };
400
+ const observe = (
401
+ directory: string,
402
+ options: { recursive?: boolean },
403
+ callback: (event: "change" | "rename", filename: string | null) => void,
404
+ ): void => {
405
+ try {
406
+ const watcher = watch(directory, options, callback);
407
+ watcher.unref();
408
+ watcher.on("error", () => { monitor.watchFailed = true; });
409
+ monitor.watchers.push(watcher);
410
+ } catch {
411
+ monitor.watchFailed = true;
412
+ }
413
+ };
414
+ observe(repo.root, { recursive: true }, (event, filename) => {
415
+ if (!filename) {
416
+ monitor.watchFailed = true;
417
+ return;
418
+ }
419
+ const normalized = String(filename).replaceAll("\\", "/");
420
+ if (normalized === ".git" || normalized.startsWith(".git/")) return;
421
+ changedPaths.add(event === "rename" ? path.posix.dirname(normalized) : normalized);
422
+ });
423
+ observe(repo.gitDirectory, {}, (_event, filename) => {
424
+ if (filename && String(filename).replaceAll("\\", "/") === "index") changedPaths.add(".");
425
+ });
426
+ return monitor;
427
+ }
428
+
429
+ async function looseBlob(objectDirectory: string, id: string): Promise<Buffer | undefined> {
430
+ try {
431
+ const inflated = await inflateAsync(await readFile(path.join(objectDirectory, id.slice(0, 2), id.slice(2))));
432
+ const separator = inflated.indexOf(0);
433
+ if (separator < 0 || !inflated.subarray(0, separator).toString("ascii").startsWith("blob ")) throw new Error("invalid blob");
434
+ return inflated.subarray(separator + 1);
435
+ } catch (error) {
436
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined;
437
+ throw error;
438
+ }
439
+ }
440
+
441
+ function cacheBlob(repo: Repository, id: string, content: Buffer): void {
442
+ if (repo.blobCacheBytes + content.length > SNAPSHOT_CONTENT_LIMIT) {
443
+ repo.blobCache.clear();
444
+ repo.blobCacheBytes = 0;
445
+ }
446
+ if (content.length > SNAPSHOT_CONTENT_LIMIT) return;
447
+ repo.blobCache.set(id, content);
448
+ repo.blobCacheBytes += content.length;
449
+ }
450
+
451
+ async function loadHeadBlobs(repo: Repository, ids: readonly string[]): Promise<Map<string, Buffer>> {
452
+ const blobs = new Map<string, Buffer>();
453
+ const missing: string[] = [];
454
+ for (const id of new Set(ids)) {
455
+ const cached = repo.blobCache.get(id);
456
+ if (cached) {
457
+ blobs.set(id, cached);
458
+ continue;
459
+ }
460
+ const content = await looseBlob(repo.objectDirectory, id);
461
+ if (content) {
462
+ blobs.set(id, content);
463
+ cacheBlob(repo, id, content);
464
+ } else missing.push(id);
465
+ }
466
+ if (missing.length > 0) {
467
+ const output = await runGit(repo.root, ["cat-file", "--batch"], Buffer.from(`${missing.join("\n")}\n`));
468
+ let offset = 0;
469
+ for (const id of missing) {
470
+ const headerEnd = output.indexOf(10, offset);
471
+ const match = /^([0-9a-f]+) blob (\d+)$/u.exec(output.subarray(offset, headerEnd).toString("ascii"));
472
+ if (!match || match[1] !== id) throw new Error("invalid batch blob");
473
+ const size = Number(match[2]);
474
+ const start = headerEnd + 1;
475
+ const end = start + size;
476
+ if (output[end] !== 10) throw new Error("invalid batch body");
477
+ const content = Buffer.from(output.subarray(start, end));
478
+ blobs.set(id, content);
479
+ cacheBlob(repo, id, content);
480
+ offset = end + 1;
481
+ }
482
+ }
483
+ return blobs;
484
+ }
485
+
486
+ type FileState = { mode: string; content: Buffer };
487
+
488
+ function sameDirtyFile(left: DirtyFile | undefined, right: DirtyFile | undefined): boolean {
489
+ if (!left || !right) return left === right;
490
+ return left.headMode === right.headMode
491
+ && left.headObjectId === right.headObjectId
492
+ && left.indexMode === right.indexMode
493
+ && left.indexObjectId === right.indexObjectId
494
+ && left.mode === right.mode
495
+ && left.contentObjectId === right.contentObjectId
496
+ && (left.content && right.content ? left.content.equals(right.content) : left.content === right.content);
497
+ }
498
+
499
+ function lines(content: Buffer): string[] {
500
+ return (content.toString("latin1").match(/[^\n]*\n|[^\n]+$/gu) ?? [])
501
+ .map((line) => line.endsWith("\r\n") ? `${line.slice(0, -2)}\n` : line);
502
+ }
503
+
504
+ function sameFileState(left: FileState | undefined, right: FileState | undefined): boolean {
505
+ if (!left || !right) return left === right;
506
+ return left.mode === right.mode && left.content.equals(right.content);
507
+ }
508
+
509
+ function lineDelta(before: Buffer, after: Buffer): { additions: number; deletions: number } {
510
+ const left = lines(before);
511
+ const right = lines(after);
512
+ const maximum = left.length + right.length;
513
+ let frontier = new Map<number, number>([[1, 0]]);
514
+ let operations = 0;
515
+ for (let distance = 0; distance <= maximum; distance += 1) {
516
+ const next = new Map<number, number>();
517
+ for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
518
+ operations += 1;
519
+ if (operations > MAX_DIFF_OPERATIONS) throw new GitFailure("too-large");
520
+ let x = diagonal === -distance || diagonal !== distance && (frontier.get(diagonal - 1) ?? -1) < (frontier.get(diagonal + 1) ?? -1)
521
+ ? frontier.get(diagonal + 1) ?? 0
522
+ : (frontier.get(diagonal - 1) ?? 0) + 1;
523
+ let y = x - diagonal;
524
+ while (x < left.length && y < right.length && left[x] === right[y]) {
525
+ x += 1;
526
+ y += 1;
527
+ }
528
+ if (x >= left.length && y >= right.length) {
529
+ return { additions: (distance + right.length - left.length) / 2, deletions: (distance + left.length - right.length) / 2 };
530
+ }
531
+ next.set(diagonal, x);
532
+ }
533
+ frontier = next;
534
+ }
535
+ throw new Error("line diff failed");
536
+ }
537
+
538
+ function isBinary(content: Buffer): boolean {
539
+ return content.subarray(0, 8_000).includes(0);
540
+ }
541
+
542
+ function binarySimilarity(left: Buffer, right: Buffer): number {
543
+ const maximum = Math.max(left.length, right.length);
544
+ if (maximum === 0) return 1;
545
+ let equalBytes = 0;
546
+ for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
547
+ if (left[index] === right[index]) equalBytes += 1;
548
+ }
549
+ return equalBytes / maximum;
550
+ }
551
+
552
+ // ponytail: fuzzy rename matching is quadratic up to 400 pairs; use Git's diff engine if larger rename batches matter.
553
+ async function compare(repo: Repository, baseline: Snapshot, settlement: Snapshot): Promise<ChangeSummary> {
554
+ if (baseline.head !== settlement.head) throw new Error("HEAD changed during response");
555
+ const paths = new Set([...baseline.files.keys(), ...settlement.files.keys()]
556
+ .filter((filePath) => !sameDirtyFile(baseline.files.get(filePath), settlement.files.get(filePath))));
557
+ const ids = [...paths].flatMap((filePath) => {
558
+ const sources = [baseline.files.get(filePath), settlement.files.get(filePath)];
559
+ return sources.flatMap((source) => [source?.headObjectId, source?.indexObjectId, source?.contentObjectId].filter((id): id is string => Boolean(id)));
560
+ });
561
+ const headBlobs = await loadHeadBlobs(repo, ids);
562
+ const current = (file: DirtyFile | undefined, fallback: DirtyFile | undefined): FileState | undefined => {
563
+ if (file) {
564
+ if (!file.mode) return undefined;
565
+ const content = file.content ?? (file.contentObjectId ? headBlobs.get(file.contentObjectId) : undefined);
566
+ if (!content) throw new Error("missing selected blob");
567
+ return { mode: file.mode, content };
568
+ }
569
+ if (!fallback?.headMode || !fallback.headObjectId) return undefined;
570
+ const content = headBlobs.get(fallback.headObjectId);
571
+ if (!content) throw new Error("missing HEAD blob");
572
+ return { mode: fallback.headMode, content };
573
+ };
574
+ const indexed = (file: DirtyFile | undefined, fallback: DirtyFile | undefined): FileState | undefined => {
575
+ if (file) {
576
+ if (!file.indexMode || !file.indexObjectId) return undefined;
577
+ const content = headBlobs.get(file.indexObjectId);
578
+ if (!content) throw new Error("missing index blob");
579
+ return { mode: file.indexMode, content };
580
+ }
581
+ if (!fallback?.headMode || !fallback.headObjectId) return undefined;
582
+ const content = headBlobs.get(fallback.headObjectId);
583
+ if (!content) throw new Error("missing HEAD blob");
584
+ return { mode: fallback.headMode, content };
585
+ };
586
+ const removed: Array<{ path: string; file: FileState }> = [];
587
+ const added: Array<{ path: string; file: FileState }> = [];
588
+ const modified: Array<{ path: string; before: FileState; after: FileState }> = [];
589
+ for (const filePath of paths) {
590
+ const beforeEntry = baseline.files.get(filePath);
591
+ const afterEntry = settlement.files.get(filePath);
592
+ const worktreeBefore = current(beforeEntry, afterEntry);
593
+ const worktreeAfter = current(afterEntry, beforeEntry);
594
+ const indexBefore = indexed(beforeEntry, afterEntry);
595
+ const indexAfter = indexed(afterEntry, beforeEntry);
596
+ const [before, after] = sameFileState(worktreeBefore, worktreeAfter)
597
+ ? [indexBefore, indexAfter]
598
+ : [worktreeBefore, worktreeAfter];
599
+ if (sameFileState(before, after)) continue;
600
+ if (!before && after) added.push({ path: filePath, file: after });
601
+ else if (before && !after) removed.push({ path: filePath, file: before });
602
+ else if (before && after) modified.push({ path: filePath, before, after });
603
+ }
604
+
605
+ const renames = new Map<string, { path: string; file: FileState; additions: number; deletions: number }>();
606
+ const consumedAdditions = new Set<string>();
607
+ for (const oldFile of removed) {
608
+ const exact = added.find((candidate) => !consumedAdditions.has(candidate.path) && candidate.file.content.equals(oldFile.file.content));
609
+ if (!exact) continue;
610
+ renames.set(oldFile.path, { ...exact, additions: 0, deletions: 0 });
611
+ consumedAdditions.add(exact.path);
612
+ }
613
+ if (removed.length * added.length <= 400) {
614
+ for (const oldFile of removed) {
615
+ if (renames.has(oldFile.path)) continue;
616
+ const oldIsBinary = isBinary(oldFile.file.content);
617
+ let best: { path: string; file: FileState; additions: number; deletions: number; score: number } | undefined;
618
+ for (const candidate of added) {
619
+ if (consumedAdditions.has(candidate.path) || isBinary(candidate.file.content) !== oldIsBinary) continue;
620
+ const delta = oldIsBinary ? { additions: 0, deletions: 0 } : lineDelta(oldFile.file.content, candidate.file.content);
621
+ const score = oldIsBinary
622
+ ? binarySimilarity(oldFile.file.content, candidate.file.content)
623
+ : (lines(oldFile.file.content).length + lines(candidate.file.content).length - delta.additions - delta.deletions)
624
+ / (2 * Math.max(lines(oldFile.file.content).length, lines(candidate.file.content).length, 1));
625
+ if (score >= 0.5 && (!best || score > best.score)) best = { ...candidate, ...delta, score };
626
+ }
627
+ if (!best) continue;
628
+ renames.set(oldFile.path, best);
629
+ consumedAdditions.add(best.path);
630
+ }
631
+ }
632
+
633
+ const changes: ChangedFile[] = [];
634
+ for (const oldFile of removed) {
635
+ const rename = renames.get(oldFile.path);
636
+ if (rename) {
637
+ const binary = isBinary(oldFile.file.content) || isBinary(rename.file.content);
638
+ changes.push({ kind: "renamed", path: rename.path, previousPath: oldFile.path, additions: rename.additions, deletions: rename.deletions, ...(binary ? { detail: "binary" as const } : oldFile.file.mode !== rename.file.mode && rename.additions === 0 && rename.deletions === 0 ? { detail: "mode" as const } : {}) });
639
+ continue;
640
+ }
641
+ const binary = isBinary(oldFile.file.content);
642
+ changes.push({ kind: "deleted", path: oldFile.path, additions: 0, deletions: binary ? 0 : lines(oldFile.file.content).length, ...(binary ? { detail: "binary" as const } : {}) });
643
+ }
644
+ for (const newFile of added) {
645
+ if (consumedAdditions.has(newFile.path)) continue;
646
+ const binary = isBinary(newFile.file.content);
647
+ changes.push({ kind: "added", path: newFile.path, additions: binary ? 0 : lines(newFile.file.content).length, deletions: 0, ...(binary ? { detail: "binary" as const } : {}) });
648
+ }
649
+ for (const file of modified) {
650
+ const binary = isBinary(file.before.content) || isBinary(file.after.content);
651
+ const delta = binary || file.before.content.equals(file.after.content) ? { additions: 0, deletions: 0 } : lineDelta(file.before.content, file.after.content);
652
+ changes.push({ kind: "modified", path: file.path, ...delta, ...(binary ? { detail: "binary" as const } : file.before.mode !== file.after.mode && file.before.content.equals(file.after.content) ? { detail: "mode" as const } : {}) });
653
+ }
654
+ changes.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
655
+ const additions = changes.reduce((total, file) => total + file.additions, 0);
656
+ const deletions = changes.reduce((total, file) => total + file.deletions, 0);
657
+ return { state: "available", totalFiles: changes.length, additions, deletions, files: changes.slice(0, MAX_FILES), omittedFiles: Math.max(0, changes.length - MAX_FILES) };
658
+ }
659
+
660
+ export function recognizedCheck(command: unknown, failed: boolean): CheckAttempt | undefined {
661
+ if (typeof command !== "string") return undefined;
662
+ const normalized = command.replace(/^[\t ]+|[\t ]+$/gu, "");
663
+ const label = CHECK_LABELS.find((candidate) => normalized === candidate);
664
+ return label ? { label, outcome: failed ? "failed" : "passed" } : undefined;
665
+ }
666
+
667
+ export async function beginChangeReceipt(cwd: string): Promise<ChangeReceiptCollection> {
668
+ try {
669
+ const repo = await repository(cwd);
670
+ let monitor = repositoryMonitors.get(repo.root);
671
+ if (!monitor) {
672
+ monitor = createMonitor(repo, await snapshot(repo));
673
+ repositoryMonitors.set(repo.root, monitor);
674
+ }
675
+ if (monitor.inUse) throw new Error("change collection already active");
676
+ const idleChanges = [...monitor.changedPaths];
677
+ monitor.changedPaths.clear();
678
+ try {
679
+ monitor.snapshot = await refreshSnapshot(monitor, idleChanges);
680
+ } catch (error) {
681
+ discardMonitor(monitor);
682
+ throw error;
683
+ }
684
+ const baseline = monitor.snapshot;
685
+ monitor.inUse = true;
686
+ let disposed = false;
687
+ return {
688
+ finish: async () => {
689
+ if (disposed) return { state: "unavailable", reason: "error" };
690
+ disposed = true;
691
+ try {
692
+ monitor.changedPaths.clear();
693
+ let settlement = await snapshot(repo);
694
+ while (monitor.changedPaths.size > 0) {
695
+ const observedPaths = [...monitor.changedPaths];
696
+ monitor.changedPaths.clear();
697
+ settlement = await refreshSnapshot(monitor, observedPaths, settlement);
698
+ }
699
+ if (await currentHead(repo) !== baseline.head) throw new Error("HEAD changed during response");
700
+ const summary = await compare(repo, baseline, settlement);
701
+ monitor.snapshot = settlement;
702
+ return summary;
703
+ } catch (error) {
704
+ discardMonitor(monitor);
705
+ return { state: "unavailable", reason: error instanceof GitFailure ? error.reason : "error" };
706
+ } finally {
707
+ monitor.inUse = false;
708
+ }
709
+ },
710
+ dispose: async () => {
711
+ disposed = true;
712
+ monitor.inUse = false;
713
+ },
714
+ };
715
+ } catch (error) {
716
+ const reason = error instanceof GitFailure && decode(error.stderr).includes("not a git repository")
717
+ ? "not-git" as const
718
+ : error instanceof GitFailure ? error.reason : "error";
719
+ return { finish: async () => ({ state: "unavailable", reason }), dispose: async () => undefined };
720
+ }
721
+ }
722
+
723
+ export function disposeChangeReceipts(): void {
724
+ for (const monitor of repositoryMonitors.values()) discardMonitor(monitor);
725
+ repositoryMonitors.clear();
726
+ repositoryCache.clear();
727
+ }