@senad-d/branchme 0.3.15 → 0.3.17

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,783 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { lstat, readFile, readlink, realpath, stat } from "node:fs/promises";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type {
7
+ ChangedFile,
8
+ CommitGitOptions,
9
+ GitChangeScope,
10
+ GitContext,
11
+ GitDiffSummary,
12
+ ProjectContext,
13
+ ProjectContextEntry,
14
+ ProjectContextEntryKind,
15
+ SkippedProjectContextEntry,
16
+ } from "./types.ts";
17
+ import {
18
+ DEFAULT_DIFF_FILE_LIMIT,
19
+ DEFAULT_DIFF_MAX_BYTES,
20
+ DEFAULT_DIFF_MAX_LINES,
21
+ DEFAULT_PROJECT_CONTEXT_FILE_LIMIT,
22
+ DEFAULT_PROJECT_FILE_MAX_BYTES,
23
+ DEFAULT_PROJECT_FILE_MAX_LINES,
24
+ PROJECT_METADATA_CANDIDATES,
25
+ } from "./constants.ts";
26
+ import { getCurrentBranch, getGitRoot, runGit } from "./git.ts";
27
+ import { appendTruncationNotice, truncateText } from "./truncation.ts";
28
+
29
+ const CONTENT_SENSITIVITY_SCAN_MAX_BYTES = 128_000;
30
+ const SECRET_SCAN_CHUNK_BYTES = 64_000;
31
+ const SECRET_SCAN_OVERLAP_CHARS = 4_096;
32
+ const CONTENT_SAFETY_SCAN_CONCURRENCY = 8;
33
+ const SECRET_KEY_SUFFIXES = [
34
+ "api_key",
35
+ "access_key",
36
+ "access_token",
37
+ "auth_token",
38
+ "bearer_token",
39
+ "client_secret",
40
+ "credential",
41
+ "credentials",
42
+ "id_token",
43
+ "password",
44
+ "passwd",
45
+ "private_key",
46
+ "refresh_token",
47
+ "secret",
48
+ "session_token",
49
+ "token",
50
+ ];
51
+ const SECRET_PLACEHOLDER_TERMS = ["fake", "dummy", "example", "placeholder", "not-real", "not_real", "changeme", "redacted", "xxxx"];
52
+ const SECRET_PATH_BASENAMES = new Set([
53
+ ".env",
54
+ ".envrc",
55
+ ".dockercfg",
56
+ ".netrc",
57
+ ".npmrc",
58
+ ".pypirc",
59
+ "_netrc",
60
+ "id_rsa",
61
+ "id_dsa",
62
+ "id_ecdsa",
63
+ "id_ed25519",
64
+ "kubeconfig",
65
+ ]);
66
+ const SECRET_PATH_EXTENSIONS = new Set([".pem", ".key", ".p12", ".pfx", ".crt", ".cer"]);
67
+ const GENERATED_PATH_SEGMENTS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".cache", ".local", ".turbo", ".next", "vendor"]);
68
+ const BINARY_PATH_EXTENSIONS = new Set([
69
+ ".png",
70
+ ".jpg",
71
+ ".jpeg",
72
+ ".gif",
73
+ ".webp",
74
+ ".ico",
75
+ ".pdf",
76
+ ".zip",
77
+ ".gz",
78
+ ".tgz",
79
+ ".xz",
80
+ ".7z",
81
+ ".tar",
82
+ ".mp4",
83
+ ".mov",
84
+ ".avi",
85
+ ".woff",
86
+ ".woff2",
87
+ ".ttf",
88
+ ".eot",
89
+ ".wasm",
90
+ ".bin",
91
+ ]);
92
+ const HIGH_CONFIDENCE_SECRET_PATTERNS = [
93
+ { name: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
94
+ { name: "bearer token", pattern: /Authorization:\s*Bearer\s+[-A-Z0-9._~+/=]{20,}/gi },
95
+ { name: "AWS access key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
96
+ { name: "GitHub token", pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_\w{36,}\b/g },
97
+ { name: "GitHub fine-grained token", pattern: /\bgithub_pat_\w{20,}\b/g },
98
+ { name: "GitLab token", pattern: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
99
+ { name: "OpenAI token", pattern: /\bsk-(?:ant-)?[A-Za-z0-9_-]{20,}\b/g },
100
+ { name: "Slack token", pattern: /\bxox[barps]-[A-Za-z0-9-]{20,}\b/g },
101
+ { name: "JWT", pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
102
+ ];
103
+
104
+ export interface GatherGitContextOptions extends CommitGitOptions {
105
+ diffFileLimit?: number;
106
+ diffMaxLines?: number;
107
+ diffMaxBytes?: number;
108
+ projectContextFileLimit?: number;
109
+ projectFileMaxLines?: number;
110
+ projectFileMaxBytes?: number;
111
+ }
112
+
113
+ export const STATUS_PORCELAIN_ARGS = ["status", "--porcelain=v1", "--branch", "-uall"];
114
+ export const STATUS_PORCELAIN_Z_ARGS = ["status", "--porcelain=v1", "-z", "-uall"];
115
+
116
+ async function getBranchLabel(
117
+ executor: Pick<ExtensionAPI, "exec">,
118
+ options: CommitGitOptions,
119
+ ): Promise<{ branch: string; isDetachedHead: boolean }> {
120
+ const current = await getCurrentBranch(executor, options, options.signal);
121
+ if (current.currentBranch) return { branch: current.currentBranch, isDetachedHead: false };
122
+
123
+ const head = (await runGit(executor, options, ["rev-parse", "--short", "HEAD"], { signal: options.signal, timeout: options.timeout })).stdout.trim();
124
+ return { branch: head.length > 0 ? `HEAD:${head}` : "HEAD", isDetachedHead: true };
125
+ }
126
+
127
+ function normalizeRepositoryPath(path: string): string {
128
+ return path.replaceAll("\\", "/");
129
+ }
130
+
131
+ function pathBasename(path: string): string {
132
+ return normalizeRepositoryPath(path).split("/").at(-1)?.toLowerCase() ?? "";
133
+ }
134
+
135
+ function pathSegments(path: string): string[] {
136
+ return normalizeRepositoryPath(path).split("/").map((segment) => segment.toLowerCase());
137
+ }
138
+
139
+ function pathExtension(basename: string): string {
140
+ const index = basename.lastIndexOf(".");
141
+ return index === -1 ? "" : basename.slice(index);
142
+ }
143
+
144
+ function isNameDelimiter(character: string): boolean {
145
+ return character === "." || character === "_" || character === "-";
146
+ }
147
+
148
+ function splitNameMarkers(value: string): string[] {
149
+ const markers: string[] = [];
150
+ let current = "";
151
+ for (const character of value.toLowerCase()) {
152
+ if (isNameDelimiter(character)) {
153
+ if (current) markers.push(current);
154
+ current = "";
155
+ } else {
156
+ current += character;
157
+ }
158
+ }
159
+ if (current) markers.push(current);
160
+ return markers;
161
+ }
162
+
163
+ function hasMarkerSequence(markers: string[], first: string, second: string): boolean {
164
+ return markers.some((marker, index) => marker === first && markers[index + 1] === second);
165
+ }
166
+
167
+ function hasSecretBasenameMarker(basename: string): boolean {
168
+ const markers = splitNameMarkers(basename);
169
+ return (
170
+ markers.some((marker) => marker === "secret" || marker === "secrets" || marker === "credential" || marker === "credentials") ||
171
+ hasMarkerSequence(markers, "private", "key") ||
172
+ hasMarkerSequence(markers, "service", "account")
173
+ );
174
+ }
175
+
176
+ function hasTokenBasenameMarker(basename: string): boolean {
177
+ return splitNameMarkers(basename).includes("token");
178
+ }
179
+
180
+ function isDotEnvVariantPath(path: string): boolean {
181
+ return pathBasename(path).startsWith(".env.");
182
+ }
183
+
184
+ export function isKnownSecretPath(path: string): boolean {
185
+ const segments = pathSegments(path);
186
+ const basename = segments.at(-1) ?? "";
187
+ const parentDirectories = new Set(segments.slice(0, -1));
188
+
189
+ if (isDotEnvVariantPath(path)) return false;
190
+
191
+ return (
192
+ SECRET_PATH_BASENAMES.has(basename) ||
193
+ basename.endsWith(".kubeconfig") ||
194
+ (parentDirectories.has(".kube") && basename === "config") ||
195
+ (parentDirectories.has(".docker") && basename === "config.json") ||
196
+ (parentDirectories.has(".aws") && (basename === "credentials" || basename === "config")) ||
197
+ hasSecretBasenameMarker(basename) ||
198
+ SECRET_PATH_EXTENSIONS.has(pathExtension(basename))
199
+ );
200
+ }
201
+
202
+ export function isSensitivePath(path: string): boolean {
203
+ if (isDotEnvVariantPath(path)) return false;
204
+ return isKnownSecretPath(path) || hasTokenBasenameMarker(pathBasename(path));
205
+ }
206
+
207
+ export function isGeneratedPath(path: string): boolean {
208
+ return pathSegments(path).some((segment) => GENERATED_PATH_SEGMENTS.has(segment));
209
+ }
210
+
211
+ export function looksBinaryByPath(path: string): boolean {
212
+ return BINARY_PATH_EXTENSIONS.has(pathExtension(pathBasename(path)));
213
+ }
214
+
215
+ function isBinaryBuffer(buffer: Buffer): boolean {
216
+ return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0);
217
+ }
218
+
219
+ function normalizedSecretKeyName(key: string): string {
220
+ return key.toLowerCase().replaceAll("-", "_");
221
+ }
222
+
223
+ function secretKeySuffixMatches(normalized: string, suffix: string): boolean {
224
+ const compact = normalized.replaceAll("_", "");
225
+ const compactSuffix = suffix.replaceAll("_", "");
226
+ return normalized === suffix || normalized.endsWith(`_${suffix}`) || compact === compactSuffix;
227
+ }
228
+
229
+ function isSecretLikeKeyName(key: string): boolean {
230
+ const normalized = normalizedSecretKeyName(key);
231
+ return SECRET_KEY_SUFFIXES.some((suffix) => secretKeySuffixMatches(normalized, suffix));
232
+ }
233
+
234
+ function isSecretKeyCharacter(character: string): boolean {
235
+ const codePoint = character.codePointAt(0);
236
+ if (codePoint === undefined) return false;
237
+ const isDigit = codePoint >= 48 && codePoint <= 57;
238
+ const isUpper = codePoint >= 65 && codePoint <= 90;
239
+ const isLower = codePoint >= 97 && codePoint <= 122;
240
+ return isDigit || isUpper || isLower || character === "_" || character === "-";
241
+ }
242
+
243
+ function readKeyBeforeOperator(text: string, operatorIndex: number): string {
244
+ let start = operatorIndex - 1;
245
+ while (start >= 0 && isSecretKeyCharacter(text[start] ?? "")) start -= 1;
246
+ return text.slice(start + 1, operatorIndex);
247
+ }
248
+
249
+ function hasAssignedValue(text: string, operatorIndex: number): boolean {
250
+ const value = text.slice(operatorIndex + 1).trim();
251
+ return value.length > 0 && !value.startsWith("#");
252
+ }
253
+
254
+ function hasSecretLikeAssignment(text: string): boolean {
255
+ for (let index = 0; index < text.length; index += 1) {
256
+ const character = text[index];
257
+ if (character !== ":" && character !== "=") continue;
258
+ const key = readKeyBeforeOperator(text, index);
259
+ if (key && isSecretLikeKeyName(key) && hasAssignedValue(text, index)) return true;
260
+ }
261
+ return false;
262
+ }
263
+
264
+ function isPlaceholderSecret(value: string): boolean {
265
+ const normalized = value.toLowerCase();
266
+ return SECRET_PLACEHOLDER_TERMS.some((term) => normalized.includes(term));
267
+ }
268
+
269
+ function patternHasRealSecret(pattern: RegExp, text: string): boolean {
270
+ pattern.lastIndex = 0;
271
+ let match = pattern.exec(text);
272
+ while (match) {
273
+ const value = match[0];
274
+ if (!isPlaceholderSecret(value)) return true;
275
+ match = pattern.exec(text);
276
+ }
277
+ return false;
278
+ }
279
+
280
+ export function looksHighConfidenceSecretContent(text: string): boolean {
281
+ return HIGH_CONFIDENCE_SECRET_PATTERNS.some((entry) => patternHasRealSecret(entry.pattern, text));
282
+ }
283
+
284
+ export function looksSensitiveContent(text: string): boolean {
285
+ return hasSecretLikeAssignment(text) || looksHighConfidenceSecretContent(text);
286
+ }
287
+
288
+ function lineEndingNormalizedLines(text: string): string[] {
289
+ return text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
290
+ }
291
+
292
+ function diffLinePrefix(line: string): string {
293
+ const first = line[0] ?? "";
294
+ return first === "+" || first === "-" || first === " " ? first : "";
295
+ }
296
+
297
+ function redactSensitiveLines(text: string): string {
298
+ return lineEndingNormalizedLines(text)
299
+ .map((line) => {
300
+ if (!looksSensitiveContent(line)) return line;
301
+ return `${diffLinePrefix(line)}[redacted sensitive line]`;
302
+ })
303
+ .join("\n");
304
+ }
305
+
306
+ function isInsidePath(root: string, candidate: string): boolean {
307
+ const backToRoot = relative(root, candidate);
308
+ return backToRoot === "" || (!backToRoot.startsWith("..") && !isAbsolute(backToRoot));
309
+ }
310
+
311
+ function resolveRepositoryPath(root: string, relativePath: string): string | undefined {
312
+ if (!relativePath || isAbsolute(relativePath)) return undefined;
313
+ const absolute = resolve(root, relativePath);
314
+ if (!isInsidePath(root, absolute)) return undefined;
315
+ return absolute;
316
+ }
317
+
318
+ function skippedReasonForRepositoryPath(path: string): SkippedProjectContextEntry["reason"] | undefined {
319
+ if (isSensitivePath(path)) return "sensitive";
320
+ if (isGeneratedPath(path)) return "generated";
321
+ if (looksBinaryByPath(path)) return "binary";
322
+ return undefined;
323
+ }
324
+
325
+ async function getReadableRepositoryFile(root: string, path: string): Promise<string | SkippedProjectContextEntry> {
326
+ const repositoryRoot = await realpath(root);
327
+ const absolute = resolveRepositoryPath(repositoryRoot, path);
328
+ if (!absolute) return { path, reason: "outside-repository" };
329
+
330
+ const info = await lstat(absolute);
331
+ if (info.isSymbolicLink()) {
332
+ const targetPath = await readlink(absolute);
333
+ const target = resolve(dirname(absolute), targetPath);
334
+ if (!isInsidePath(repositoryRoot, target)) return { path, reason: "outside-repository" };
335
+
336
+ const targetRelativePath = normalizeRepositoryPath(relative(repositoryRoot, target));
337
+ const targetSkipReason = skippedReasonForRepositoryPath(targetRelativePath);
338
+ if (targetSkipReason) return { path, reason: targetSkipReason };
339
+
340
+ return { path, reason: "symlink" };
341
+ }
342
+
343
+ if (!info.isFile()) return { path, reason: "missing" };
344
+
345
+ const canonicalFilePath = await realpath(absolute);
346
+ if (!isInsidePath(repositoryRoot, canonicalFilePath)) return { path, reason: "outside-repository" };
347
+
348
+ const canonicalRelativePath = normalizeRepositoryPath(relative(repositoryRoot, canonicalFilePath));
349
+ if (canonicalRelativePath !== normalizeRepositoryPath(path)) {
350
+ const canonicalSkipReason = skippedReasonForRepositoryPath(canonicalRelativePath);
351
+ return { path, reason: canonicalSkipReason ?? "symlink" };
352
+ }
353
+
354
+ return canonicalFilePath;
355
+ }
356
+
357
+ async function readContextEntry(
358
+ root: string,
359
+ path: string,
360
+ kind: ProjectContextEntryKind,
361
+ options: Required<Pick<GatherGitContextOptions, "projectFileMaxBytes" | "projectFileMaxLines">>,
362
+ ): Promise<ProjectContextEntry | SkippedProjectContextEntry> {
363
+ const pathSkipReason = skippedReasonForRepositoryPath(path);
364
+ if (pathSkipReason) return { path, reason: pathSkipReason };
365
+
366
+ try {
367
+ const resolved = await getReadableRepositoryFile(root, path);
368
+ if (typeof resolved !== "string") return resolved;
369
+ const fileInfo = await stat(resolved);
370
+ if (fileInfo.size > CONTENT_SENSITIVITY_SCAN_MAX_BYTES) return { path, reason: "too-large" };
371
+
372
+ const buffer = await readFile(resolved);
373
+ if (isBinaryBuffer(buffer)) return { path, reason: "binary" };
374
+
375
+ const content = buffer.toString("utf8");
376
+ if (looksHighConfidenceSecretContent(content)) return { path, reason: "sensitive" };
377
+
378
+ const redactedContent = redactSensitiveLines(content);
379
+ const truncated = truncateText(redactedContent, {
380
+ maxBytes: options.projectFileMaxBytes,
381
+ maxLines: options.projectFileMaxLines,
382
+ strategy: "head",
383
+ label: path,
384
+ });
385
+ return {
386
+ path,
387
+ kind,
388
+ content: appendTruncationNotice(truncated),
389
+ truncation: truncated.metadata,
390
+ };
391
+ } catch (error) {
392
+ const code = errorCode(error);
393
+ if (code === "ENOENT") return { path, reason: "missing" };
394
+ if (code === "EACCES" || code === "EPERM") return { path, reason: "unreadable" };
395
+ return { path, reason: "too-large" };
396
+ }
397
+ }
398
+
399
+ function isProjectContextEntry(entry: ProjectContextEntry | SkippedProjectContextEntry): entry is ProjectContextEntry {
400
+ return "content" in entry;
401
+ }
402
+
403
+ function dedupeChangedFileCandidates(files: ChangedFile[]): ChangedFile[] {
404
+ const byPath = new Map<string, ChangedFile>();
405
+ for (const file of files) {
406
+ const existing = byPath.get(file.path);
407
+ if (!existing) {
408
+ byPath.set(file.path, { ...file });
409
+ continue;
410
+ }
411
+
412
+ existing.sensitive = existing.sensitive || file.sensitive;
413
+ existing.generated = existing.generated || file.generated;
414
+ existing.binary = existing.binary || file.binary;
415
+ existing.secretContent = existing.secretContent || file.secretContent;
416
+ existing.unreadable = existing.unreadable || file.unreadable;
417
+ const relatedPaths = [...(existing.relatedPaths ?? []), ...(file.relatedPaths ?? [])];
418
+ if (relatedPaths.length > 0) existing.relatedPaths = [...new Set(relatedPaths)];
419
+ }
420
+ return [...byPath.values()];
421
+ }
422
+
423
+ function skippedReasonForChangedFile(file: ChangedFile): SkippedProjectContextEntry["reason"] | undefined {
424
+ if (file.sensitive) return "sensitive";
425
+ if (file.generated) return "generated";
426
+ if (file.binary) return "binary";
427
+ if (file.unreadable) return "unreadable";
428
+ return undefined;
429
+ }
430
+
431
+ function createAbortError(signal: AbortSignal): Error {
432
+ if (signal.reason instanceof Error) return signal.reason;
433
+ const error = new Error("Commit secret scan aborted.");
434
+ error.name = "AbortError";
435
+ return error;
436
+ }
437
+
438
+ async function scanFileForHighConfidenceSecret(path: string, signal?: AbortSignal): Promise<boolean> {
439
+ return new Promise((resolve, reject) => {
440
+ if (signal?.aborted) {
441
+ reject(createAbortError(signal));
442
+ return;
443
+ }
444
+
445
+ const stream = createReadStream(path, { encoding: "utf8", highWaterMark: SECRET_SCAN_CHUNK_BYTES });
446
+ let settled = false;
447
+ let tail = "";
448
+
449
+ function cleanup() {
450
+ signal?.removeEventListener("abort", onAbort);
451
+ }
452
+
453
+ function resolveOnce(value: boolean) {
454
+ if (settled) return;
455
+ settled = true;
456
+ cleanup();
457
+ resolve(value);
458
+ stream.destroy();
459
+ }
460
+
461
+ function rejectOnce(error: unknown) {
462
+ if (settled) return;
463
+ settled = true;
464
+ cleanup();
465
+ reject(error);
466
+ stream.destroy();
467
+ }
468
+
469
+ function onAbort() {
470
+ if (signal) rejectOnce(createAbortError(signal));
471
+ }
472
+
473
+ signal?.addEventListener("abort", onAbort, { once: true });
474
+ stream.on("data", (chunk) => {
475
+ const text = `${tail}${chunk}`;
476
+ if (looksHighConfidenceSecretContent(text)) {
477
+ resolveOnce(true);
478
+ return;
479
+ }
480
+ tail = text.slice(-SECRET_SCAN_OVERLAP_CHARS);
481
+ });
482
+ stream.once("end", () => resolveOnce(false));
483
+ stream.once("error", (error) => rejectOnce(error));
484
+ });
485
+ }
486
+
487
+ type ChangedFileContentSafety = "safe" | "secret" | "unreadable";
488
+
489
+ function errorCode(error: unknown): string {
490
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
491
+ }
492
+
493
+ async function inspectChangedFileContentSafety(root: string, path: string, signal?: AbortSignal): Promise<ChangedFileContentSafety> {
494
+ try {
495
+ if (signal?.aborted) throw createAbortError(signal);
496
+ const resolved = await getReadableRepositoryFile(root, path);
497
+ if (typeof resolved !== "string") return resolved.reason === "sensitive" ? "secret" : "safe";
498
+ return (await scanFileForHighConfidenceSecret(resolved, signal)) ? "secret" : "safe";
499
+ } catch (error) {
500
+ if (signal?.aborted) throw error;
501
+ const code = errorCode(error);
502
+ if (code === "EACCES" || code === "EPERM") return "unreadable";
503
+ return "safe";
504
+ }
505
+ }
506
+
507
+ async function inspectContentSafetyEntry(root: string, path: string, signal?: AbortSignal): Promise<[string, ChangedFileContentSafety]> {
508
+ return [path, await inspectChangedFileContentSafety(root, path, signal)];
509
+ }
510
+
511
+ async function* inspectContentSafetyBatches(root: string, paths: string[], signal?: AbortSignal) {
512
+ for (let start = 0; start < paths.length; start += CONTENT_SAFETY_SCAN_CONCURRENCY) {
513
+ if (signal?.aborted) throw createAbortError(signal);
514
+ const batch = paths.slice(start, start + CONTENT_SAFETY_SCAN_CONCURRENCY);
515
+ // Yield one batch at a time so concurrent secret scans cannot exhaust file descriptors.
516
+ yield Promise.all(batch.map((path) => inspectContentSafetyEntry(root, path, signal)));
517
+ }
518
+ }
519
+
520
+ function withContentSafety(file: ChangedFile, contentSafety?: ChangedFileContentSafety): ChangedFile {
521
+ if (isKnownSecretPath(file.path) || file.status.startsWith("D")) return file;
522
+ if (contentSafety === "secret") return { ...file, sensitive: true, secretContent: true };
523
+ if (contentSafety === "unreadable") return { ...file, unreadable: true };
524
+ return file;
525
+ }
526
+
527
+ async function applyContentSensitivity(root: string, changedFiles: ChangedFile[], signal?: AbortSignal): Promise<ChangedFile[]> {
528
+ if (signal?.aborted) throw createAbortError(signal);
529
+ const paths = [...new Set(changedFiles
530
+ .filter((file) => !isKnownSecretPath(file.path) && !file.status.startsWith("D"))
531
+ .map((file) => file.path))];
532
+ const scannedPaths = new Map<string, ChangedFileContentSafety>();
533
+ for await (const batch of inspectContentSafetyBatches(root, paths, signal)) {
534
+ for (const [path, safety] of batch) scannedPaths.set(path, safety);
535
+ }
536
+ return changedFiles.map((file) => withContentSafety(file, scannedPaths.get(file.path)));
537
+ }
538
+
539
+ function readChangedFileContext(
540
+ root: string,
541
+ file: ChangedFile,
542
+ options: Required<Pick<GatherGitContextOptions, "projectFileMaxBytes" | "projectFileMaxLines">>,
543
+ ): Promise<ProjectContextEntry | SkippedProjectContextEntry> {
544
+ const reason = skippedReasonForChangedFile(file);
545
+ return reason ? Promise.resolve({ path: file.path, reason }) : readContextEntry(root, file.path, "changed-file-snippet", options);
546
+ }
547
+
548
+ export async function gatherProjectContext(
549
+ root: string,
550
+ changedFiles: ChangedFile[],
551
+ options: Pick<GatherGitContextOptions, "projectContextFileLimit" | "projectFileMaxBytes" | "projectFileMaxLines"> = {},
552
+ ): Promise<ProjectContext> {
553
+ const resolvedOptions = {
554
+ projectContextFileLimit: options.projectContextFileLimit ?? DEFAULT_PROJECT_CONTEXT_FILE_LIMIT,
555
+ projectFileMaxBytes: options.projectFileMaxBytes ?? DEFAULT_PROJECT_FILE_MAX_BYTES,
556
+ projectFileMaxLines: options.projectFileMaxLines ?? DEFAULT_PROJECT_FILE_MAX_LINES,
557
+ };
558
+ const changedFileSnippets: ProjectContextEntry[] = [];
559
+ const skipped: SkippedProjectContextEntry[] = [];
560
+
561
+ const metadataPaths = PROJECT_METADATA_CANDIDATES.slice(0, resolvedOptions.projectContextFileLimit);
562
+ const metadataEntries = await Promise.all(metadataPaths.map((path) => readContextEntry(root, path, "metadata", resolvedOptions)));
563
+ const metadata = metadataEntries.filter(isProjectContextEntry);
564
+
565
+ const metadataSet = new Set(metadata.map((entry) => entry.path));
566
+ const snippetCandidates = dedupeChangedFileCandidates(
567
+ changedFiles
568
+ .filter((file) => file.scope === "staged" || file.scope === "unstaged")
569
+ .filter((file) => !metadataSet.has(file.path))
570
+ .sort((a, b) => a.path.localeCompare(b.path)),
571
+ ).slice(0, resolvedOptions.projectContextFileLimit);
572
+
573
+ const snippetEntries = await Promise.all(snippetCandidates.map((file) => readChangedFileContext(root, file, resolvedOptions)));
574
+ for (const entry of snippetEntries) {
575
+ if (isProjectContextEntry(entry)) {
576
+ changedFileSnippets.push(entry);
577
+ } else if (entry.reason !== "missing") {
578
+ skipped.push(entry);
579
+ }
580
+ }
581
+
582
+ return { root, metadata, changedFileSnippets, skipped };
583
+ }
584
+
585
+ function isRenameOrCopyStatus(status: string): boolean {
586
+ return status.startsWith("R") || status.startsWith("C");
587
+ }
588
+
589
+ export function parseNameStatusZ(output: string, scope: GitChangeScope): ChangedFile[] {
590
+ const files: ChangedFile[] = [];
591
+ const fields = output.split("\0");
592
+
593
+ for (let index = 0; index < fields.length; ) {
594
+ const status = fields[index++];
595
+ if (!status) continue;
596
+
597
+ const firstPath = fields[index++];
598
+ const secondPath = isRenameOrCopyStatus(status) ? fields[index++] : undefined;
599
+ const path = secondPath ?? firstPath;
600
+ if (!path) continue;
601
+
602
+ files.push(createChangedFile(path, status, scope, secondPath && firstPath ? [firstPath] : []));
603
+ }
604
+
605
+ return files;
606
+ }
607
+
608
+ function createChangedFile(path: string, status: string, scope: GitChangeScope, relatedPaths: string[] = []): ChangedFile {
609
+ const paths = [path, ...relatedPaths];
610
+ return {
611
+ path,
612
+ status,
613
+ scope,
614
+ sensitive: paths.some(isSensitivePath),
615
+ generated: paths.some(isGeneratedPath),
616
+ binary: paths.some(looksBinaryByPath),
617
+ ...(relatedPaths.length > 0 ? { relatedPaths } : {}),
618
+ };
619
+ }
620
+
621
+ interface StatusPorcelainRecord {
622
+ xy: string;
623
+ path: string;
624
+ previousPath?: string;
625
+ }
626
+
627
+ interface StatusPorcelainReadResult {
628
+ nextIndex: number;
629
+ record?: StatusPorcelainRecord;
630
+ }
631
+
632
+ function statusHasRenameOrCopySource(xy: string): boolean {
633
+ return xy.includes("R") || xy.includes("C");
634
+ }
635
+
636
+ function readStatusPorcelainRecord(records: string[], index: number): StatusPorcelainReadResult {
637
+ const record = records[index];
638
+ const nextIndex = index + 1;
639
+ if (!record) return { nextIndex };
640
+
641
+ const xy = record.slice(0, 2);
642
+ const path = record.slice(3);
643
+ if (!path) return { nextIndex };
644
+ if (!statusHasRenameOrCopySource(xy)) return { nextIndex, record: { xy, path } };
645
+
646
+ return {
647
+ nextIndex: nextIndex + 1,
648
+ record: { xy, path, previousPath: records[nextIndex] },
649
+ };
650
+ }
651
+
652
+ function hasStatusChange(status: string): boolean {
653
+ return status !== " " && status !== "?";
654
+ }
655
+
656
+ function relatedPathsForStatus(record: StatusPorcelainRecord): string[] {
657
+ return record.previousPath ? [record.previousPath] : [];
658
+ }
659
+
660
+ function changedFilesForStatusRecord(record: StatusPorcelainRecord): ChangedFile[] {
661
+ const relatedPaths = relatedPathsForStatus(record);
662
+ if (record.xy === "??") return [createChangedFile(record.path, "??", "unstaged", relatedPaths)];
663
+
664
+ const files: ChangedFile[] = [];
665
+ const indexStatus = record.xy[0] ?? " ";
666
+ const worktreeStatus = record.xy[1] ?? " ";
667
+ if (hasStatusChange(indexStatus)) files.push(createChangedFile(record.path, indexStatus, "staged", relatedPaths));
668
+ if (hasStatusChange(worktreeStatus)) files.push(createChangedFile(record.path, worktreeStatus, "unstaged", relatedPaths));
669
+ return files;
670
+ }
671
+
672
+ export function parseStatusPorcelainZ(output: string): ChangedFile[] {
673
+ const files: ChangedFile[] = [];
674
+ const records = output.split("\0");
675
+
676
+ for (let index = 0; index < records.length; ) {
677
+ const readResult = readStatusPorcelainRecord(records, index);
678
+ index = readResult.nextIndex;
679
+ if (readResult.record) files.push(...changedFilesForStatusRecord(readResult.record));
680
+ }
681
+ return files;
682
+ }
683
+
684
+ function dedupeChangedFiles(files: ChangedFile[]): ChangedFile[] {
685
+ const seen = new Set<string>();
686
+ const output: ChangedFile[] = [];
687
+ for (const file of files) {
688
+ const key = `${file.scope}:${file.path}`;
689
+ if (seen.has(key)) continue;
690
+ seen.add(key);
691
+ output.push(file);
692
+ }
693
+ return output;
694
+ }
695
+
696
+ function diffArgsForScope(scope: GitChangeScope, extraArgs: string[] = []): string[] {
697
+ const baseArgs = scope === "staged" ? ["diff", "--cached"] : ["diff"];
698
+ return [...baseArgs, "--no-ext-diff", "--no-textconv", ...extraArgs];
699
+ }
700
+
701
+ async function collectDiffSummary(
702
+ executor: Pick<ExtensionAPI, "exec">,
703
+ scope: GitChangeScope,
704
+ files: ChangedFile[],
705
+ options: Required<Pick<GatherGitContextOptions, "diffFileLimit" | "diffMaxBytes" | "diffMaxLines">> & CommitGitOptions,
706
+ ): Promise<GitDiffSummary> {
707
+ const stat = (await runGit(executor, options, diffArgsForScope(scope, ["--stat"]), { signal: options.signal, timeout: options.timeout })).stdout.trim();
708
+ const safePaths: string[] = [];
709
+ const candidates = files
710
+ .filter((file) => file.scope === scope && !file.sensitive && !file.generated && !file.binary && !file.status.startsWith("D"))
711
+ .slice(0, options.diffFileLimit);
712
+
713
+ const entryOptions = { projectFileMaxBytes: CONTENT_SENSITIVITY_SCAN_MAX_BYTES, projectFileMaxLines: Number.MAX_SAFE_INTEGER };
714
+ const entries = await Promise.all(candidates.map((file) => readChangedFileContext(options.cwd, file, entryOptions)));
715
+ safePaths.push(...entries.filter(isProjectContextEntry).map((entry) => entry.path));
716
+
717
+ let rawExcerpt = "";
718
+ if (safePaths.length > 0) {
719
+ rawExcerpt = redactSensitiveLines((await runGit(executor, options, diffArgsForScope(scope, ["--", ...safePaths]), { signal: options.signal, timeout: options.timeout })).stdout).trim();
720
+ }
721
+
722
+ const truncated = truncateText(rawExcerpt, {
723
+ maxLines: options.diffMaxLines,
724
+ maxBytes: options.diffMaxBytes,
725
+ strategy: "head",
726
+ label: `${scope} diff`,
727
+ });
728
+
729
+ return {
730
+ scope,
731
+ stat,
732
+ excerpt: appendTruncationNotice(truncated),
733
+ truncation: truncated.metadata,
734
+ };
735
+ }
736
+
737
+ export async function gatherGitContext(
738
+ executor: Pick<ExtensionAPI, "exec">,
739
+ options: GatherGitContextOptions,
740
+ ): Promise<GitContext> {
741
+ const repositoryRoot = await getGitRoot(executor, options, options.signal);
742
+ const commonOptions = { ...options, cwd: repositoryRoot };
743
+ const gitOptions = { signal: options.signal, timeout: options.timeout };
744
+ const { branch, isDetachedHead } = await getBranchLabel(executor, commonOptions);
745
+ const statusPorcelain = (await runGit(executor, commonOptions, STATUS_PORCELAIN_ARGS, gitOptions)).stdout.trim();
746
+
747
+ const statusPorcelainZ = (await runGit(executor, commonOptions, STATUS_PORCELAIN_Z_ARGS, gitOptions)).stdout;
748
+ const stagedNameStatusZ = (await runGit(executor, commonOptions, diffArgsForScope("staged", ["--name-status", "-z"]), gitOptions)).stdout;
749
+ const unstagedNameStatusZ = (await runGit(executor, commonOptions, diffArgsForScope("unstaged", ["--name-status", "-z"]), gitOptions)).stdout;
750
+ const changedFiles = await applyContentSensitivity(
751
+ repositoryRoot,
752
+ dedupeChangedFiles([
753
+ ...parseNameStatusZ(stagedNameStatusZ, "staged"),
754
+ ...parseNameStatusZ(unstagedNameStatusZ, "unstaged"),
755
+ ...parseStatusPorcelainZ(statusPorcelainZ),
756
+ ]),
757
+ options.signal,
758
+ );
759
+
760
+ const diffOptions = {
761
+ ...commonOptions,
762
+ diffFileLimit: options.diffFileLimit ?? DEFAULT_DIFF_FILE_LIMIT,
763
+ diffMaxBytes: options.diffMaxBytes ?? DEFAULT_DIFF_MAX_BYTES,
764
+ diffMaxLines: options.diffMaxLines ?? DEFAULT_DIFF_MAX_LINES,
765
+ };
766
+
767
+ const staged = await collectDiffSummary(executor, "staged", changedFiles, diffOptions);
768
+ const unstaged = await collectDiffSummary(executor, "unstaged", changedFiles, diffOptions);
769
+ const project = await gatherProjectContext(repositoryRoot, changedFiles, options);
770
+
771
+ return {
772
+ repositoryRoot,
773
+ branch,
774
+ isDetachedHead,
775
+ statusPorcelain,
776
+ staged,
777
+ unstaged,
778
+ changedFiles,
779
+ project,
780
+ hasChanges: changedFiles.length > 0,
781
+ warnings: [],
782
+ };
783
+ }