killeros 3.0.7 → 3.0.8

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,408 +1,391 @@
1
- import { createHash } from "node:crypto";
2
- import { accessSync, constants, existsSync, realpathSync, statSync } from "node:fs";
3
- import { lstat, open, readlink } from "node:fs/promises";
4
- import path from "node:path";
5
-
6
- const WORKTREE_CONTENT_LIMIT = 128 * 1024 * 1024;
7
- const PASSIVE_METADATA_ARGS = ["-c", "core.fsmonitor=false"] as const;
8
-
9
- export type PassiveGitRunner = (args: readonly string[]) => Promise<Buffer>;
10
-
11
- /** Revs-only returns empty output for an unborn HEAD; command failures still reject. */
12
- export async function resolvePassiveHead(runGit: PassiveGitRunner): Promise<string> {
13
- const head = decode(await runGit(["rev-parse", "--revs-only", "--end-of-options", "HEAD"])).trim();
14
- if (head && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new Error("invalid HEAD");
15
- return head || "(initial)";
16
- }
17
-
18
- export type PassiveWorktreeFile = {
19
- path: string;
20
- headMode?: string;
21
- headObjectId?: string;
22
- indexMode?: string;
23
- indexObjectId?: string;
24
- mode?: string;
25
- content?: Buffer;
26
- contentObjectId?: string;
27
- };
28
-
29
- export type PassiveWorktreeSnapshot = {
30
- head: string;
31
- files: Map<string, PassiveWorktreeFile>;
32
- fileMode: boolean;
33
- symlinks: boolean;
34
- };
35
-
36
- export async function passiveWorktreeSettings(runGit: PassiveGitRunner): Promise<Pick<PassiveWorktreeSnapshot, "fileMode" | "symlinks">> {
37
- const [fileMode, symlinks] = await Promise.all([
38
- runGit(["config", "--type=bool", "--default", "true", "--get", "core.filemode"]),
39
- runGit(["config", "--type=bool", "--default", "true", "--get", "core.symlinks"]),
40
- ]);
41
- return { fileMode: decode(fileMode).trim() === "true", symlinks: decode(symlinks).trim() === "true" };
42
- }
43
-
44
- export function passiveWorktreeMode(
45
- actual: string,
46
- indexed: string | undefined,
47
- settings: Pick<PassiveWorktreeSnapshot, "fileMode" | "symlinks">,
48
- ): string {
49
- if (actual === "100644" || actual === "100755") {
50
- if (indexed === "120000" && !settings.symlinks) return indexed;
51
- if ((indexed === "100644" || indexed === "100755") && !settings.fileMode) return indexed;
52
- }
53
- return actual;
54
- }
55
-
56
- type IndexEntry = {
57
- mode: string;
58
- objectId: string;
59
- eolAttributes: string;
60
- ctimeNanoseconds: bigint;
61
- mtimeNanoseconds: bigint;
62
- size: bigint;
63
- skipWorktree: boolean;
64
- };
65
-
66
- type TreeEntry = { mode: string; objectId: string };
67
-
68
- function searchPathEntries(env: NodeJS.ProcessEnv): string[] {
69
- const values: string[] = [];
70
- for (const [key, value] of Object.entries(env)) {
71
- if (key.toLowerCase() === "path" && typeof value === "string") values.push(value);
72
- }
73
- const entries: string[] = [];
74
- for (const value of values) entries.push(...value.split(path.delimiter));
75
- return entries;
76
- }
77
-
78
- function pathExtensionEntries(env: NodeJS.ProcessEnv): string[] {
79
- let raw: string | undefined;
80
- for (const [key, value] of Object.entries(env)) {
81
- if (key.toLowerCase() === "pathext" && typeof value === "string") {
82
- raw = value;
83
- break;
84
- }
85
- }
86
- raw ??= ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL";
87
- return raw.split(";").map((entry) => entry.trim()).filter(Boolean).map((entry) => entry.startsWith(".") ? entry : `.${entry}`);
88
- }
89
-
90
- function unquoted(entry: string): string {
91
- if (entry.length >= 2) {
92
- const first = entry[0];
93
- const last = entry[entry.length - 1];
94
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) return entry.slice(1, -1);
95
- }
96
- return entry;
97
- }
98
-
99
- function inspectedRoot(cwd: string): string | undefined {
100
- if (!cwd || typeof cwd !== "string") return undefined;
101
- let start: string;
102
- try {
103
- start = path.resolve(cwd);
104
- } catch {
105
- return undefined;
106
- }
107
- let base: string;
108
- try {
109
- base = realpathSync.native(start);
110
- } catch {
111
- base = start;
112
- }
113
- let current = base;
114
- for (;;) {
115
- try {
116
- if (existsSync(path.join(current, ".git"))) {
117
- try {
118
- return realpathSync.native(current);
119
- } catch {
120
- return current;
121
- }
122
- }
123
- } catch {
124
- // Unreadable directory: keep walking toward the filesystem root.
125
- }
126
- const parent = path.dirname(current);
127
- if (parent === current) break;
128
- current = parent;
129
- }
130
- return base;
131
- }
132
-
133
- function insideInspected(candidate: string, root: string): boolean {
134
- if (process.platform === "win32") {
135
- const normalizedCandidate = path.win32.normalize(candidate).toLowerCase();
136
- const normalizedRoot = path.win32.normalize(root).toLowerCase();
137
- const trimmed = normalizedRoot.length > 3 && normalizedRoot.endsWith(path.win32.sep)
138
- ? normalizedRoot.slice(0, -1)
139
- : normalizedRoot;
140
- if (normalizedCandidate === trimmed) return true;
141
- return normalizedCandidate.startsWith(`${trimmed}${path.win32.sep}`);
142
- }
143
- const normalizedCandidate = path.normalize(candidate);
144
- const normalizedRoot = path.normalize(root);
145
- const trimmed = normalizedRoot.length > 1 && normalizedRoot.endsWith(path.sep)
146
- ? normalizedRoot.slice(0, -1)
147
- : normalizedRoot;
148
- if (normalizedCandidate === trimmed) return true;
149
- return normalizedCandidate.startsWith(`${trimmed}${path.sep}`);
150
- }
151
-
152
- function traversesInspected(candidate: string, root: string): boolean {
153
- let current = path.dirname(candidate);
154
- for (;;) {
155
- try {
156
- if (insideInspected(realpathSync.native(current), root)) return true;
157
- } catch {
158
- return true;
159
- }
160
- const parent = path.dirname(current);
161
- if (parent === current) return false;
162
- current = parent;
163
- }
164
- }
165
-
166
- /** Returns an absolute Git binary outside the inspected repository without starting a locator process. */
167
- export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
168
- const root = inspectedRoot(cwd);
169
- if (!root) return undefined;
170
- const entries = searchPathEntries(env);
171
- if (entries.length === 0) return undefined;
172
- const windows = process.platform === "win32";
173
- const baseNames = windows ? ["git", ...pathExtensionEntries(env).map((extension) => `git${extension}`)] : ["git"];
174
- for (const raw of entries) {
175
- if (raw === "" || raw.trim() === "") continue;
176
- const directory = unquoted(raw);
177
- if (directory === "" || directory.trim() === "" || !path.isAbsolute(directory)) continue;
178
- for (const base of baseNames) {
179
- const candidate = path.join(directory, base);
180
- if (insideInspected(candidate, root)) continue;
181
- try {
182
- if (!statSync(candidate).isFile()) continue;
183
- } catch {
184
- continue;
185
- }
186
- if (!windows) {
187
- try {
188
- accessSync(candidate, constants.X_OK);
189
- } catch {
190
- continue;
191
- }
192
- }
193
- let resolved: string;
194
- try {
195
- resolved = realpathSync.native(candidate);
196
- } catch {
197
- continue;
198
- }
199
- if (path.isAbsolute(resolved) && !insideInspected(resolved, root) && !traversesInspected(candidate, root)) return resolved;
200
- }
201
- }
202
- return undefined;
203
- }
204
-
205
- /** Environment for automatic Git children, without inherited repository selection or PATH command resolution. */
206
- export function passiveGitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
207
- const env: NodeJS.ProcessEnv = { ...base };
208
- const selectors = [
209
- "GIT_DIR",
210
- "GIT_CEILING_DIRECTORIES",
211
- "GIT_DISCOVERY_ACROSS_FILESYSTEM",
212
- "GIT_WORK_TREE",
213
- "GIT_INDEX_FILE",
214
- "GIT_OBJECT_DIRECTORY",
215
- "GIT_ALTERNATE_OBJECT_DIRECTORIES",
216
- "GIT_COMMON_DIR",
217
- "GIT_NAMESPACE",
218
- ];
219
- let hasPath = false;
220
- for (const key of Object.keys(env)) {
221
- const normalized = key.toUpperCase();
222
- if (selectors.includes(normalized)
223
- || normalized === "GIT_OPTIONAL_LOCKS"
224
- || normalized === "GIT_NO_LAZY_FETCH") {
225
- delete env[key];
226
- } else if (normalized === "PATH") {
227
- env[key] = "";
228
- hasPath = true;
229
- }
230
- }
231
- if (!hasPath) env.PATH = "";
232
- env.GIT_OPTIONAL_LOCKS = "0";
233
- env.GIT_NO_LAZY_FETCH = "1";
234
- return env;
235
- }
236
-
237
- function decode(buffer: Buffer): string {
238
- return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
239
- }
240
-
241
- function parseIndex(output: Buffer): Map<string, IndexEntry> {
242
- const text = decode(output);
243
- const pattern = /([0-7]{6}) ([0-9a-f]{40}(?:[0-9a-f]{24})?) ([0-3])\t([^\t]*)\t([^\0]*)\0 ctime: (\d+):(\d+)\n mtime: (\d+):(\d+)\n dev: \d+\tino: \d+\n uid: \d+\tgid: \d+\n size: (\d+)\tflags: ([0-9a-f]+)\n/gu;
244
- const entries = new Map<string, IndexEntry>();
245
- let end = 0;
246
- for (const match of text.matchAll(pattern)) {
247
- if (match.index !== end) throw new Error("invalid index metadata");
248
- end = match.index + match[0].length;
249
- const [, mode, objectId, stage, eolAttributes, filePath, ctimeSeconds, ctimeNanoseconds, mtimeSeconds, mtimeNanoseconds, size, flags] = match;
250
- if (!mode || !objectId || !stage || eolAttributes === undefined || filePath === undefined || !ctimeSeconds || !ctimeNanoseconds || !mtimeSeconds || !mtimeNanoseconds || !size || !flags || stage !== "0") {
251
- throw new Error("unsupported index entry");
252
- }
253
- entries.set(filePath, {
254
- mode,
255
- objectId,
256
- eolAttributes: eolAttributes.trim(),
257
- ctimeNanoseconds: BigInt(ctimeSeconds) * 1_000_000_000n + BigInt(ctimeNanoseconds),
258
- mtimeNanoseconds: BigInt(mtimeSeconds) * 1_000_000_000n + BigInt(mtimeNanoseconds),
259
- size: BigInt(size),
260
- skipWorktree: (Number.parseInt(flags, 16) & 0x40000000) !== 0,
261
- });
262
- }
263
- if (end !== text.length) throw new Error("incomplete index metadata");
264
- return entries;
265
- }
266
-
267
- function parseTree(output: Buffer): Map<string, TreeEntry> {
268
- const entries = new Map<string, TreeEntry>();
269
- for (const record of decode(output).split("\0")) {
270
- if (!record) continue;
271
- const match = /^([0-7]{6}) (?:blob|commit) ([0-9a-f]{40}(?:[0-9a-f]{24})?)\t([^\0]+)$/u.exec(record);
272
- if (!match?.[1] || !match[2] || !match[3]) throw new Error("invalid tree entry");
273
- entries.set(match[3], { mode: match[1], objectId: match[2] });
274
- }
275
- return entries;
276
- }
277
-
278
- function parsePaths(output: Buffer): string[] {
279
- const records = decode(output).split("\0");
280
- if (records.at(-1) !== "") throw new Error("incomplete path list");
281
- return records.slice(0, -1);
282
- }
283
-
284
- async function readFileState(root: string, entry: IndexEntry | undefined, filePath: string, remaining: number): Promise<{ mode: string; content?: Buffer; statsMatch: boolean }> {
285
- const absolutePath = path.join(root, ...filePath.split("/"));
286
- const stats = await lstat(absolutePath, { bigint: true });
287
- const mode = stats.isSymbolicLink() ? "120000" : stats.isFile() ? stats.mode & 0o111n ? "100755" : "100644" : "";
288
- if (!mode) throw new Error("unsupported worktree entry");
289
- const statsMatch = entry !== undefined
290
- && entry.mode === mode
291
- && entry.size === stats.size
292
- && entry.ctimeNanoseconds === stats.ctimeNs
293
- && entry.mtimeNanoseconds === stats.mtimeNs;
294
- if (statsMatch) return { mode, statsMatch };
295
- if (!stats.isSymbolicLink() && stats.size > BigInt(remaining)) throw new Error("worktree content limit exceeded");
296
- const content = stats.isSymbolicLink() ? Buffer.from(await readlink(absolutePath)) : await readBounded(absolutePath, remaining);
297
- return { mode, content, statsMatch };
298
- }
299
-
300
- async function readBounded(filePath: string, limit: number): Promise<Buffer> {
301
- const handle = await open(filePath, "r");
302
- try {
303
- const stats = await handle.stat();
304
- if (!stats.isFile() || stats.size > limit) throw new Error("worktree content limit exceeded");
305
- const content = Buffer.alloc(stats.size + 1);
306
- let length = 0;
307
- while (length < content.length) {
308
- const { bytesRead } = await handle.read(content, length, content.length - length, length);
309
- if (bytesRead === 0) break;
310
- length += bytesRead;
311
- }
312
- if (length > stats.size) throw new Error("worktree changed while reading");
313
- return content.subarray(0, length);
314
- } finally {
315
- await handle.close();
316
- }
317
- }
318
-
319
- function blobObjectId(content: Buffer, length: number): string {
320
- const algorithm = length === 64 ? "sha256" : "sha1";
321
- return createHash(algorithm).update(`blob ${content.length}\0`).update(content).digest("hex");
322
- }
323
-
324
- function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boolean {
325
- if (blobObjectId(content, entry.objectId.length) === entry.objectId) return true;
326
- if (entry.mode === "120000" || !content.includes(Buffer.from("\r\n"))) return false;
327
- const attributes = entry.eolAttributes;
328
- // eol controls checkout bytes; text files still normalize CRLF when compared with the index.
329
- if (attributes === "-text") return false;
330
- const automatic = attributes.startsWith("text=auto") || !attributes;
331
- if (!attributes && !["true", "input"].includes(autoCrlf)) return false;
332
- if (automatic) {
333
- // Match Git's convert_is_binary heuristic, including its terminal EOF exception.
334
- let printable = 0;
335
- let nonprintable = 0;
336
- for (let index = 0; index < content.length; index += 1) {
337
- const byte = content[index];
338
- if (byte === 0 || byte === 13 && content[index + 1] !== 10) return false;
339
- if (byte === 13 || byte === 10 || byte === 26 && index === content.length - 1) continue;
340
- if (byte === 127 || byte < 32 && ![8, 9, 12, 27].includes(byte)) nonprintable += 1;
341
- else printable += 1;
342
- }
343
- if (Math.floor(printable / 128) < nonprintable) return false;
344
- }
345
- const normalized = Buffer.from(content.toString("latin1").replaceAll("\r\n", "\n"), "latin1");
346
- return blobObjectId(normalized, entry.objectId.length) === entry.objectId;
347
- }
348
-
349
- /**
350
- * Reads index and tree metadata with Git, then inspects worktree files with Node.
351
- * No Git command in this path asks Git to convert worktree content, so clean and process filters cannot start.
352
- */
353
- export async function inspectWorktreeWithoutFilters(root: string, runGit: PassiveGitRunner): Promise<PassiveWorktreeSnapshot> {
354
- const [indexOutput, untrackedOutput, head, autoCrlfOutput, settings] = await Promise.all([
355
- runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--cached", "--debug", "-z", "--full-name",
356
- "--format=%(objectmode) %(objectname) %(stage)%x09%(eolattr)%x09%(path)"]),
357
- runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--others", "--exclude-standard", "-z", "--full-name"]),
358
- resolvePassiveHead(runGit),
359
- runGit(["config", "--type=bool-or-str", "--default", "false", "--get", "core.autocrlf"]),
360
- passiveWorktreeSettings(runGit),
361
- ]);
362
- const autoCrlf = decode(autoCrlfOutput).trim().toLowerCase();
363
- const index = parseIndex(indexOutput);
364
- const tree = head !== "(initial)" ? parseTree(await runGit(["ls-tree", "-r", "-z", "--full-tree", head])) : new Map<string, TreeEntry>();
365
- const files = new Map<string, PassiveWorktreeFile>();
366
- let contentBytes = 0;
367
-
368
- for (const filePath of new Set([...tree.keys(), ...index.keys()])) {
369
- const headEntry = tree.get(filePath);
370
- const indexEntry = index.get(filePath);
371
- if (headEntry?.mode === "160000" || indexEntry?.mode === "160000") continue;
372
- const staged = headEntry?.mode !== indexEntry?.mode || headEntry?.objectId !== indexEntry?.objectId;
373
- let mode: string | undefined;
374
- let content: Buffer | undefined;
375
- let worktreeChanged = false;
376
- if (indexEntry && !indexEntry.skipWorktree) {
377
- try {
378
- const state = await readFileState(root, indexEntry, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
379
- mode = passiveWorktreeMode(state.mode, indexEntry.mode, settings);
380
- content = state.content;
381
- if (content) contentBytes += content.length;
382
- worktreeChanged = !state.statsMatch && (mode !== indexEntry.mode || !content || !matchesIndex(content, indexEntry, autoCrlf));
383
- } catch (error) {
384
- if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") worktreeChanged = true;
385
- else throw error;
386
- }
387
- }
388
- if (!staged && !worktreeChanged) continue;
389
- files.set(filePath, {
390
- path: filePath,
391
- headMode: headEntry?.mode,
392
- headObjectId: headEntry?.objectId,
393
- indexMode: indexEntry?.mode,
394
- indexObjectId: indexEntry?.objectId,
395
- mode: worktreeChanged ? mode : indexEntry?.mode,
396
- ...(worktreeChanged && content ? { content } : indexEntry ? { contentObjectId: indexEntry.objectId } : {}),
397
- });
398
- }
399
-
400
- for (const filePath of parsePaths(untrackedOutput)) {
401
- const state = await readFileState(root, undefined, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
402
- if (!state.content) throw new Error("missing untracked content");
403
- contentBytes += state.content.length;
404
- const previous = files.get(filePath);
405
- files.set(filePath, { ...previous, path: filePath, mode: state.mode, content: state.content, contentObjectId: undefined });
406
- }
407
- return { head, files, ...settings };
408
- }
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, realpathSync, statSync } from "node:fs";
3
+ import { lstat, open, readlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ const WORKTREE_CONTENT_LIMIT = 128 * 1024 * 1024;
7
+ const PASSIVE_METADATA_ARGS = ["-c", "core.fsmonitor=false"] as const;
8
+
9
+ export type PassiveGitRunner = (args: readonly string[]) => Promise<Buffer>;
10
+
11
+ /** Revs-only returns empty output for an unborn HEAD; command failures still reject. */
12
+ export async function resolvePassiveHead(runGit: PassiveGitRunner): Promise<string> {
13
+ const head = decode(await runGit(["rev-parse", "--revs-only", "--end-of-options", "HEAD"])).trim();
14
+ if (head && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new Error("invalid HEAD");
15
+ return head || "(initial)";
16
+ }
17
+
18
+ export type PassiveWorktreeFile = {
19
+ path: string;
20
+ headMode?: string;
21
+ headObjectId?: string;
22
+ indexMode?: string;
23
+ indexObjectId?: string;
24
+ mode?: string;
25
+ content?: Buffer;
26
+ contentObjectId?: string;
27
+ };
28
+
29
+ export type PassiveWorktreeSnapshot = {
30
+ head: string;
31
+ files: Map<string, PassiveWorktreeFile>;
32
+ fileMode: boolean;
33
+ symlinks: boolean;
34
+ };
35
+
36
+ export async function passiveWorktreeSettings(runGit: PassiveGitRunner): Promise<Pick<PassiveWorktreeSnapshot, "fileMode" | "symlinks">> {
37
+ const [fileMode, symlinks] = await Promise.all([
38
+ runGit(["config", "--type=bool", "--default", "true", "--get", "core.filemode"]),
39
+ runGit(["config", "--type=bool", "--default", "true", "--get", "core.symlinks"]),
40
+ ]);
41
+ return { fileMode: decode(fileMode).trim() === "true", symlinks: decode(symlinks).trim() === "true" };
42
+ }
43
+
44
+ export function passiveWorktreeMode(
45
+ actual: string,
46
+ indexed: string | undefined,
47
+ settings: Pick<PassiveWorktreeSnapshot, "fileMode" | "symlinks">,
48
+ ): string {
49
+ if (actual === "100644" || actual === "100755") {
50
+ if (indexed === "120000" && !settings.symlinks) return indexed;
51
+ if ((indexed === "100644" || indexed === "100755") && !settings.fileMode) return indexed;
52
+ }
53
+ return actual;
54
+ }
55
+
56
+ type IndexEntry = {
57
+ mode: string;
58
+ objectId: string;
59
+ eolAttributes: string;
60
+ ctimeNanoseconds: bigint;
61
+ mtimeNanoseconds: bigint;
62
+ size: bigint;
63
+ skipWorktree: boolean;
64
+ };
65
+
66
+ type TreeEntry = { mode: string; objectId: string };
67
+
68
+ function searchPathEntries(env: NodeJS.ProcessEnv): string[] {
69
+ const values: string[] = [];
70
+ for (const [key, value] of Object.entries(env)) {
71
+ if (key.toLowerCase() === "path" && typeof value === "string") values.push(value);
72
+ }
73
+ const entries: string[] = [];
74
+ for (const value of values) entries.push(...value.split(path.delimiter));
75
+ return entries;
76
+ }
77
+
78
+ function pathExtensionEntries(env: NodeJS.ProcessEnv): string[] {
79
+ let raw: string | undefined;
80
+ for (const [key, value] of Object.entries(env)) {
81
+ if (key.toLowerCase() === "pathext" && typeof value === "string") {
82
+ raw = value;
83
+ break;
84
+ }
85
+ }
86
+ raw ??= ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL";
87
+ return raw.split(";").map((entry) => entry.trim()).filter(Boolean).map((entry) => entry.startsWith(".") ? entry : `.${entry}`);
88
+ }
89
+
90
+ function unquoted(entry: string): string {
91
+ if (entry.length >= 2) {
92
+ const first = entry[0];
93
+ const last = entry[entry.length - 1];
94
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) return entry.slice(1, -1);
95
+ }
96
+ return entry;
97
+ }
98
+
99
+ function inspectedRoot(cwd: string): string | undefined {
100
+ if (!cwd || typeof cwd !== "string") return undefined;
101
+ let start: string;
102
+ try {
103
+ start = path.resolve(cwd);
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ let base: string;
108
+ try {
109
+ base = realpathSync.native(start);
110
+ } catch {
111
+ base = start;
112
+ }
113
+ let current = base;
114
+ for (;;) {
115
+ try {
116
+ if (existsSync(path.join(current, ".git"))) {
117
+ try {
118
+ return realpathSync.native(current);
119
+ } catch {
120
+ return current;
121
+ }
122
+ }
123
+ } catch {
124
+ // Unreadable directory: keep walking toward the filesystem root.
125
+ }
126
+ const parent = path.dirname(current);
127
+ if (parent === current) break;
128
+ current = parent;
129
+ }
130
+ return base;
131
+ }
132
+
133
+ function insideInspected(candidate: string, root: string): boolean {
134
+ const normalizedCandidate = path.normalize(candidate).toLowerCase();
135
+ const normalizedRoot = path.normalize(root).toLowerCase();
136
+ const trimmed = normalizedRoot.length > 3 && normalizedRoot.endsWith(path.sep)
137
+ ? normalizedRoot.slice(0, -1)
138
+ : normalizedRoot;
139
+ if (normalizedCandidate === trimmed) return true;
140
+ return normalizedCandidate.startsWith(`${trimmed}${path.sep}`);
141
+ }
142
+
143
+ function traversesInspected(candidate: string, root: string): boolean {
144
+ let current = path.dirname(candidate);
145
+ for (;;) {
146
+ try {
147
+ if (insideInspected(realpathSync.native(current), root)) return true;
148
+ } catch {
149
+ return true;
150
+ }
151
+ const parent = path.dirname(current);
152
+ if (parent === current) return false;
153
+ current = parent;
154
+ }
155
+ }
156
+
157
+ /** Returns an absolute Git binary outside the inspected repository without starting a locator process. */
158
+ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
159
+ const root = inspectedRoot(cwd);
160
+ if (!root) return undefined;
161
+ const entries = searchPathEntries(env);
162
+ if (entries.length === 0) return undefined;
163
+ const baseNames = ["git", ...pathExtensionEntries(env).map((extension) => `git${extension}`)];
164
+ for (const raw of entries) {
165
+ if (raw === "" || raw.trim() === "") continue;
166
+ const directory = unquoted(raw);
167
+ if (directory === "" || directory.trim() === "" || !path.isAbsolute(directory)) continue;
168
+ for (const base of baseNames) {
169
+ const candidate = path.join(directory, base);
170
+ if (insideInspected(candidate, root)) continue;
171
+ try {
172
+ if (!statSync(candidate).isFile()) continue;
173
+ } catch {
174
+ continue;
175
+ }
176
+ let resolved: string;
177
+ try {
178
+ resolved = realpathSync.native(candidate);
179
+ } catch {
180
+ continue;
181
+ }
182
+ if (path.isAbsolute(resolved) && !insideInspected(resolved, root) && !traversesInspected(candidate, root)) return resolved;
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ /** Environment for automatic Git children, without inherited repository selection or PATH command resolution. */
189
+ export function passiveGitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
190
+ const env: NodeJS.ProcessEnv = { ...base };
191
+ const selectors = [
192
+ "GIT_DIR",
193
+ "GIT_CEILING_DIRECTORIES",
194
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
195
+ "GIT_WORK_TREE",
196
+ "GIT_INDEX_FILE",
197
+ "GIT_OBJECT_DIRECTORY",
198
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
199
+ "GIT_COMMON_DIR",
200
+ "GIT_NAMESPACE",
201
+ ];
202
+ let hasPath = false;
203
+ for (const key of Object.keys(env)) {
204
+ const normalized = key.toUpperCase();
205
+ if (selectors.includes(normalized)
206
+ || normalized === "GIT_OPTIONAL_LOCKS"
207
+ || normalized === "GIT_NO_LAZY_FETCH") {
208
+ delete env[key];
209
+ } else if (normalized === "PATH") {
210
+ env[key] = "";
211
+ hasPath = true;
212
+ }
213
+ }
214
+ if (!hasPath) env.PATH = "";
215
+ env.GIT_OPTIONAL_LOCKS = "0";
216
+ env.GIT_NO_LAZY_FETCH = "1";
217
+ return env;
218
+ }
219
+
220
+ function decode(buffer: Buffer): string {
221
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
222
+ }
223
+
224
+ function parseIndex(output: Buffer): Map<string, IndexEntry> {
225
+ const text = decode(output);
226
+ const pattern = /([0-7]{6}) ([0-9a-f]{40}(?:[0-9a-f]{24})?) ([0-3])\t([^\t]*)\t([^\0]*)\0 ctime: (\d+):(\d+)\n mtime: (\d+):(\d+)\n dev: \d+\tino: \d+\n uid: \d+\tgid: \d+\n size: (\d+)\tflags: ([0-9a-f]+)\n/gu;
227
+ const entries = new Map<string, IndexEntry>();
228
+ let end = 0;
229
+ for (const match of text.matchAll(pattern)) {
230
+ if (match.index !== end) throw new Error("invalid index metadata");
231
+ end = match.index + match[0].length;
232
+ const [, mode, objectId, stage, eolAttributes, filePath, ctimeSeconds, ctimeNanoseconds, mtimeSeconds, mtimeNanoseconds, size, flags] = match;
233
+ if (!mode || !objectId || !stage || eolAttributes === undefined || filePath === undefined || !ctimeSeconds || !ctimeNanoseconds || !mtimeSeconds || !mtimeNanoseconds || !size || !flags || stage !== "0") {
234
+ throw new Error("unsupported index entry");
235
+ }
236
+ entries.set(filePath, {
237
+ mode,
238
+ objectId,
239
+ eolAttributes: eolAttributes.trim(),
240
+ ctimeNanoseconds: BigInt(ctimeSeconds) * 1_000_000_000n + BigInt(ctimeNanoseconds),
241
+ mtimeNanoseconds: BigInt(mtimeSeconds) * 1_000_000_000n + BigInt(mtimeNanoseconds),
242
+ size: BigInt(size),
243
+ skipWorktree: (Number.parseInt(flags, 16) & 0x40000000) !== 0,
244
+ });
245
+ }
246
+ if (end !== text.length) throw new Error("incomplete index metadata");
247
+ return entries;
248
+ }
249
+
250
+ function parseTree(output: Buffer): Map<string, TreeEntry> {
251
+ const entries = new Map<string, TreeEntry>();
252
+ for (const record of decode(output).split("\0")) {
253
+ if (!record) continue;
254
+ const match = /^([0-7]{6}) (?:blob|commit) ([0-9a-f]{40}(?:[0-9a-f]{24})?)\t([^\0]+)$/u.exec(record);
255
+ if (!match?.[1] || !match[2] || !match[3]) throw new Error("invalid tree entry");
256
+ entries.set(match[3], { mode: match[1], objectId: match[2] });
257
+ }
258
+ return entries;
259
+ }
260
+
261
+ function parsePaths(output: Buffer): string[] {
262
+ const records = decode(output).split("\0");
263
+ if (records.at(-1) !== "") throw new Error("incomplete path list");
264
+ return records.slice(0, -1);
265
+ }
266
+
267
+ async function readFileState(root: string, entry: IndexEntry | undefined, filePath: string, remaining: number): Promise<{ mode: string; content?: Buffer; statsMatch: boolean }> {
268
+ const absolutePath = path.join(root, ...filePath.split("/"));
269
+ const stats = await lstat(absolutePath, { bigint: true });
270
+ const mode = stats.isSymbolicLink() ? "120000" : stats.isFile() ? stats.mode & 0o111n ? "100755" : "100644" : "";
271
+ if (!mode) throw new Error("unsupported worktree entry");
272
+ const statsMatch = entry !== undefined
273
+ && entry.mode === mode
274
+ && entry.size === stats.size
275
+ && entry.ctimeNanoseconds === stats.ctimeNs
276
+ && entry.mtimeNanoseconds === stats.mtimeNs;
277
+ if (statsMatch) return { mode, statsMatch };
278
+ if (!stats.isSymbolicLink() && stats.size > BigInt(remaining)) throw new Error("worktree content limit exceeded");
279
+ const content = stats.isSymbolicLink() ? Buffer.from(await readlink(absolutePath)) : await readBounded(absolutePath, remaining);
280
+ return { mode, content, statsMatch };
281
+ }
282
+
283
+ async function readBounded(filePath: string, limit: number): Promise<Buffer> {
284
+ const handle = await open(filePath, "r");
285
+ try {
286
+ const stats = await handle.stat();
287
+ if (!stats.isFile() || stats.size > limit) throw new Error("worktree content limit exceeded");
288
+ const content = Buffer.alloc(stats.size + 1);
289
+ let length = 0;
290
+ while (length < content.length) {
291
+ const { bytesRead } = await handle.read(content, length, content.length - length, length);
292
+ if (bytesRead === 0) break;
293
+ length += bytesRead;
294
+ }
295
+ if (length > stats.size) throw new Error("worktree changed while reading");
296
+ return content.subarray(0, length);
297
+ } finally {
298
+ await handle.close();
299
+ }
300
+ }
301
+
302
+ function blobObjectId(content: Buffer, length: number): string {
303
+ const algorithm = length === 64 ? "sha256" : "sha1";
304
+ return createHash(algorithm).update(`blob ${content.length}\0`).update(content).digest("hex");
305
+ }
306
+
307
+ function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boolean {
308
+ if (blobObjectId(content, entry.objectId.length) === entry.objectId) return true;
309
+ if (entry.mode === "120000" || !content.includes(Buffer.from("\r\n"))) return false;
310
+ const attributes = entry.eolAttributes;
311
+ // eol controls checkout bytes; text files still normalize CRLF when compared with the index.
312
+ if (attributes === "-text") return false;
313
+ const automatic = attributes.startsWith("text=auto") || !attributes;
314
+ if (!attributes && !["true", "input"].includes(autoCrlf)) return false;
315
+ if (automatic) {
316
+ // Match Git's convert_is_binary heuristic, including its terminal EOF exception.
317
+ let printable = 0;
318
+ let nonprintable = 0;
319
+ for (let index = 0; index < content.length; index += 1) {
320
+ const byte = content[index];
321
+ if (byte === 0 || byte === 13 && content[index + 1] !== 10) return false;
322
+ if (byte === 13 || byte === 10 || byte === 26 && index === content.length - 1) continue;
323
+ if (byte === 127 || byte < 32 && ![8, 9, 12, 27].includes(byte)) nonprintable += 1;
324
+ else printable += 1;
325
+ }
326
+ if (Math.floor(printable / 128) < nonprintable) return false;
327
+ }
328
+ const normalized = Buffer.from(content.toString("latin1").replaceAll("\r\n", "\n"), "latin1");
329
+ return blobObjectId(normalized, entry.objectId.length) === entry.objectId;
330
+ }
331
+
332
+ /**
333
+ * Reads index and tree metadata with Git, then inspects worktree files with Node.
334
+ * No Git command in this path asks Git to convert worktree content, so clean and process filters cannot start.
335
+ */
336
+ export async function inspectWorktreeWithoutFilters(root: string, runGit: PassiveGitRunner): Promise<PassiveWorktreeSnapshot> {
337
+ const [indexOutput, untrackedOutput, head, autoCrlfOutput, settings] = await Promise.all([
338
+ runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--cached", "--debug", "-z", "--full-name",
339
+ "--format=%(objectmode) %(objectname) %(stage)%x09%(eolattr)%x09%(path)"]),
340
+ runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--others", "--exclude-standard", "-z", "--full-name"]),
341
+ resolvePassiveHead(runGit),
342
+ runGit(["config", "--type=bool-or-str", "--default", "false", "--get", "core.autocrlf"]),
343
+ passiveWorktreeSettings(runGit),
344
+ ]);
345
+ const autoCrlf = decode(autoCrlfOutput).trim().toLowerCase();
346
+ const index = parseIndex(indexOutput);
347
+ const tree = head !== "(initial)" ? parseTree(await runGit(["ls-tree", "-r", "-z", "--full-tree", head])) : new Map<string, TreeEntry>();
348
+ const files = new Map<string, PassiveWorktreeFile>();
349
+ let contentBytes = 0;
350
+
351
+ for (const filePath of new Set([...tree.keys(), ...index.keys()])) {
352
+ const headEntry = tree.get(filePath);
353
+ const indexEntry = index.get(filePath);
354
+ if (headEntry?.mode === "160000" || indexEntry?.mode === "160000") continue;
355
+ const staged = headEntry?.mode !== indexEntry?.mode || headEntry?.objectId !== indexEntry?.objectId;
356
+ let mode: string | undefined;
357
+ let content: Buffer | undefined;
358
+ let worktreeChanged = false;
359
+ if (indexEntry && !indexEntry.skipWorktree) {
360
+ try {
361
+ const state = await readFileState(root, indexEntry, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
362
+ mode = passiveWorktreeMode(state.mode, indexEntry.mode, settings);
363
+ content = state.content;
364
+ if (content) contentBytes += content.length;
365
+ worktreeChanged = !state.statsMatch && (mode !== indexEntry.mode || !content || !matchesIndex(content, indexEntry, autoCrlf));
366
+ } catch (error) {
367
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") worktreeChanged = true;
368
+ else throw error;
369
+ }
370
+ }
371
+ if (!staged && !worktreeChanged) continue;
372
+ files.set(filePath, {
373
+ path: filePath,
374
+ headMode: headEntry?.mode,
375
+ headObjectId: headEntry?.objectId,
376
+ indexMode: indexEntry?.mode,
377
+ indexObjectId: indexEntry?.objectId,
378
+ mode: worktreeChanged ? mode : indexEntry?.mode,
379
+ ...(worktreeChanged && content ? { content } : indexEntry ? { contentObjectId: indexEntry.objectId } : {}),
380
+ });
381
+ }
382
+
383
+ for (const filePath of parsePaths(untrackedOutput)) {
384
+ const state = await readFileState(root, undefined, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
385
+ if (!state.content) throw new Error("missing untracked content");
386
+ contentBytes += state.content.length;
387
+ const previous = files.get(filePath);
388
+ files.set(filePath, { ...previous, path: filePath, mode: state.mode, content: state.content, contentObjectId: undefined });
389
+ }
390
+ return { head, files, ...settings };
391
+ }