killeros 2.1.30 → 2.1.31

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