@signalridge/pi-worktree 0.49.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/git.ts ADDED
@@ -0,0 +1,1250 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
4
+ import { lstat, readdir, rename, rmdir, unlink } from "node:fs/promises";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import type { ExecResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import lockfile from "proper-lockfile";
8
+
9
+ const GIT_TIMEOUT_MS = 15_000;
10
+ const GIT_MUTATION_TIMEOUT_MS = 60_000;
11
+ const MUTATION_LOCK_WAIT_MS = 50;
12
+ const MUTATION_LOCK_TIMEOUT_MS = 120_000;
13
+ const MUTATION_LOCK_STALE_MS = 30_000;
14
+ const LOCAL_BRANCH_PREFIX = "refs/heads/";
15
+ const metadataPruneLocks = new Map<string, Promise<void>>();
16
+
17
+ export interface WorktreeRecord {
18
+ path: string;
19
+ head?: string;
20
+ branchRef?: string;
21
+ branch?: string;
22
+ isMain: boolean;
23
+ bare: boolean;
24
+ detached: boolean;
25
+ lockedReason?: string;
26
+ prunableReason?: string;
27
+ }
28
+
29
+ export interface AddArguments {
30
+ path: string;
31
+ branch: string;
32
+ startOid?: string;
33
+ }
34
+
35
+ export interface AdministrativePruneCandidate {
36
+ id: string;
37
+ administrativePath: string;
38
+ head?: string;
39
+ branchRef?: string;
40
+ indexDirty: boolean;
41
+ }
42
+ interface MetadataIdentity {
43
+ kind: "directory" | "leaf";
44
+ dev: number;
45
+ ino: number;
46
+ size: number;
47
+ mtimeMs: number;
48
+ ctimeMs: number;
49
+ children?: Map<string, MetadataIdentity>;
50
+ }
51
+
52
+ const administrativeIdentities = new WeakMap<AdministrativePruneCandidate, MetadataIdentity>();
53
+
54
+ export interface GitClient {
55
+ exec(command: string, args: string[], options?: GitExecOptions): Promise<ExecResult>;
56
+ }
57
+
58
+ interface GitExecOptions {
59
+ cwd?: string;
60
+ signal?: AbortSignal;
61
+ timeout?: number;
62
+ }
63
+
64
+ export class GitWorktreeError extends Error {
65
+ readonly args?: readonly string[];
66
+
67
+ constructor(message: string, args?: readonly string[]) {
68
+ super(message);
69
+ this.name = "GitWorktreeError";
70
+ this.args = args;
71
+ }
72
+ }
73
+ class MetadataDeletionRetainedError extends Error {
74
+ readonly retainedPath: string;
75
+ readonly outcomeUnknown: boolean;
76
+
77
+ constructor(retainedPath: string, message: string, outcomeUnknown = false) {
78
+ super(message);
79
+ this.name = "MetadataDeletionRetainedError";
80
+ this.retainedPath = retainedPath;
81
+ this.outcomeUnknown = outcomeUnknown;
82
+ }
83
+ }
84
+
85
+ export function parseWorktreePorcelain(output: string): WorktreeRecord[] {
86
+ const records: WorktreeRecord[] = [];
87
+ let current: Omit<WorktreeRecord, "isMain"> | undefined;
88
+
89
+ const finish = () => {
90
+ if (!current) return;
91
+ records.push({ ...current, isMain: records.length === 0 });
92
+ current = undefined;
93
+ };
94
+
95
+ for (const field of output.split("\0")) {
96
+ if (field === "") {
97
+ finish();
98
+ continue;
99
+ }
100
+ const separator = field.indexOf(" ");
101
+ const key = separator < 0 ? field : field.slice(0, separator);
102
+ const value = separator < 0 ? "" : field.slice(separator + 1);
103
+
104
+ if (key === "worktree") {
105
+ finish();
106
+ if (!value) throw new GitWorktreeError("Worktree porcelain record is missing path.");
107
+ current = { path: value, bare: false, detached: false };
108
+ continue;
109
+ }
110
+ if (!current) {
111
+ throw new GitWorktreeError(`Worktree porcelain field ${JSON.stringify(key)} appears before worktree.`);
112
+ }
113
+
114
+ switch (key) {
115
+ case "HEAD":
116
+ current.head = value;
117
+ break;
118
+ case "branch":
119
+ current.branchRef = value;
120
+ current.branch = value.startsWith(LOCAL_BRANCH_PREFIX) ? value.slice(LOCAL_BRANCH_PREFIX.length) : undefined;
121
+ break;
122
+ case "bare":
123
+ current.bare = true;
124
+ break;
125
+ case "detached":
126
+ current.detached = true;
127
+ break;
128
+ case "locked":
129
+ current.lockedReason = value;
130
+ break;
131
+ case "prunable":
132
+ current.prunableReason = value;
133
+ break;
134
+ }
135
+ }
136
+ finish();
137
+ return records;
138
+ }
139
+
140
+ export function worktreeForBranch(records: readonly WorktreeRecord[], branch: string): WorktreeRecord | undefined {
141
+ const branchRef = `${LOCAL_BRANCH_PREFIX}${branch}`;
142
+ return records.find((record) => record.branchRef === branchRef);
143
+ }
144
+
145
+ export function defaultWorktreePath(mainWorktreePath: string, branch: string, worktreeRoot: string): string {
146
+ return resolve(worktreeRoot, basename(mainWorktreePath), branch.replaceAll("/", "-"));
147
+ }
148
+
149
+ export function buildAddArguments(input: AddArguments): string[] {
150
+ return input.startOid
151
+ ? ["worktree", "add", "-b", input.branch, input.path, input.startOid]
152
+ : ["worktree", "add", input.path, input.branch];
153
+ }
154
+
155
+ export function pathIdentity(path: string): string {
156
+ const absolute = resolve(path);
157
+ if (!existsSync(absolute)) return absolute;
158
+ try {
159
+ return realpathSync.native(absolute);
160
+ } catch {
161
+ return absolute;
162
+ }
163
+ }
164
+
165
+ export function pathEntryExists(path: string): boolean {
166
+ try {
167
+ lstatSync(path);
168
+ return true;
169
+ } catch (error) {
170
+ if (isNodeError(error) && error.code === "ENOENT") return false;
171
+ throw new GitWorktreeError(`Cannot inspect filesystem path ${path}: ${formatError(error)}`);
172
+ }
173
+ }
174
+
175
+ export function unresolvableSymlinkAncestor(path: string): string | undefined {
176
+ let current = dirname(resolve(path));
177
+ while (true) {
178
+ try {
179
+ const stat = lstatSync(current);
180
+ if (!stat.isSymbolicLink()) return undefined;
181
+ try {
182
+ realpathSync.native(current);
183
+ return undefined;
184
+ } catch (error) {
185
+ if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ELOOP")) {
186
+ return current;
187
+ }
188
+ throw new GitWorktreeError(`Cannot resolve filesystem ancestor ${current}: ${formatError(error)}`);
189
+ }
190
+ } catch (error) {
191
+ if (!isNodeError(error) || error.code !== "ENOENT") {
192
+ if (error instanceof GitWorktreeError) throw error;
193
+ throw new GitWorktreeError(`Cannot inspect filesystem ancestor ${current}: ${formatError(error)}`);
194
+ }
195
+ const parent = dirname(current);
196
+ if (parent === current) return undefined;
197
+ current = parent;
198
+ }
199
+ }
200
+ }
201
+
202
+ export function pathsEqual(left: string, right: string): boolean {
203
+ return pathIdentity(left) === pathIdentity(right);
204
+ }
205
+
206
+ export function sameWorktreeIdentity(left: WorktreeRecord, right: WorktreeRecord): boolean {
207
+ return (
208
+ pathsEqual(left.path, right.path) &&
209
+ left.head === right.head &&
210
+ left.branchRef === right.branchRef &&
211
+ left.detached === right.detached &&
212
+ left.isMain === right.isMain &&
213
+ left.bare === right.bare
214
+ );
215
+ }
216
+
217
+ export async function listWorktrees(
218
+ pi: Pick<ExtensionAPI, "exec">,
219
+ cwd: string,
220
+ signal?: AbortSignal,
221
+ ): Promise<WorktreeRecord[]> {
222
+ const result = await runGit(pi, ["worktree", "list", "--porcelain", "-z"], cwd, signal);
223
+ return parseWorktreePorcelain(result.stdout);
224
+ }
225
+
226
+ export async function currentWorktreePath(
227
+ pi: Pick<ExtensionAPI, "exec">,
228
+ cwd: string,
229
+ signal?: AbortSignal,
230
+ ): Promise<string> {
231
+ const result = await runGit(pi, ["rev-parse", "--show-toplevel"], cwd, signal);
232
+ const path = removeLineEnding(result.stdout);
233
+ if (!path) throw new GitWorktreeError("Git did not return the current worktree path.");
234
+ return pathIdentity(path);
235
+ }
236
+
237
+ export async function symbolicBranch(
238
+ pi: Pick<ExtensionAPI, "exec">,
239
+ cwd: string,
240
+ signal?: AbortSignal,
241
+ ): Promise<string | undefined> {
242
+ const result = await runGitAllowFailure(pi, ["symbolic-ref", "--quiet", "--short", "HEAD"], cwd, signal);
243
+ if (result.killed) throw killedError(["symbolic-ref", "--quiet", "--short", "HEAD"]);
244
+ if (result.code !== 0) return undefined;
245
+ return result.stdout.trim() || undefined;
246
+ }
247
+
248
+ export async function validateBranch(
249
+ pi: Pick<ExtensionAPI, "exec">,
250
+ cwd: string,
251
+ branch: string,
252
+ signal?: AbortSignal,
253
+ ): Promise<string> {
254
+ const result = await runGit(pi, ["check-ref-format", "--branch", branch], cwd, signal);
255
+ const normalized = result.stdout.trim();
256
+ if (!normalized) throw new GitWorktreeError("Git returned an empty branch name.");
257
+ return normalized;
258
+ }
259
+
260
+ export async function localBranchExists(
261
+ pi: Pick<ExtensionAPI, "exec">,
262
+ cwd: string,
263
+ branch: string,
264
+ signal?: AbortSignal,
265
+ ): Promise<boolean> {
266
+ const result = await runGitAllowFailure(
267
+ pi,
268
+ ["show-ref", "--verify", "--quiet", `${LOCAL_BRANCH_PREFIX}${branch}`],
269
+ cwd,
270
+ signal,
271
+ );
272
+ if (result.killed) throw killedError(["show-ref", "--verify", "--quiet"]);
273
+ if (result.code === 0) return true;
274
+ if (result.code === 1) return false;
275
+ throw gitFailure(["show-ref", "--verify", "--quiet"], result);
276
+ }
277
+
278
+ export async function resolveCommit(
279
+ pi: Pick<ExtensionAPI, "exec">,
280
+ cwd: string,
281
+ startPoint: string,
282
+ signal?: AbortSignal,
283
+ ): Promise<string> {
284
+ const result = await runGit(pi, ["rev-parse", "--verify", "--end-of-options", `${startPoint}^{commit}`], cwd, signal);
285
+ const oid = result.stdout.trim();
286
+ if (!/^[0-9a-fA-F]{40,64}$/u.test(oid)) {
287
+ throw new GitWorktreeError(`Git returned an invalid commit object for ${startPoint}.`);
288
+ }
289
+ return oid;
290
+ }
291
+
292
+ export async function addWorktree(
293
+ pi: Pick<ExtensionAPI, "exec">,
294
+ cwd: string,
295
+ input: AddArguments,
296
+ signal?: AbortSignal,
297
+ ): Promise<void> {
298
+ await runGit(pi, buildAddArguments(input), cwd, signal, GIT_MUTATION_TIMEOUT_MS);
299
+ }
300
+
301
+ export async function moveWorktree(
302
+ pi: Pick<ExtensionAPI, "exec">,
303
+ cwd: string,
304
+ path: string,
305
+ newPath: string,
306
+ signal?: AbortSignal,
307
+ ): Promise<void> {
308
+ await runGit(pi, ["worktree", "move", path, newPath], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
309
+ }
310
+
311
+ export async function removeWorktree(
312
+ pi: Pick<ExtensionAPI, "exec">,
313
+ cwd: string,
314
+ path: string,
315
+ signal?: AbortSignal,
316
+ ): Promise<void> {
317
+ await runGit(pi, ["worktree", "remove", path], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
318
+ }
319
+
320
+ async function waitForMutationLock(signal?: AbortSignal): Promise<void> {
321
+ if (signal?.aborted) throw new GitWorktreeError("worktree mutation lock wait aborted.");
322
+ await new Promise<void>((resolveLock, reject) => {
323
+ let onAbort!: () => void;
324
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
325
+ const timer = setTimeout(() => {
326
+ cleanup();
327
+ resolveLock();
328
+ }, MUTATION_LOCK_WAIT_MS);
329
+ onAbort = () => {
330
+ clearTimeout(timer);
331
+ cleanup();
332
+ reject(new GitWorktreeError("worktree mutation lock wait aborted."));
333
+ };
334
+ signal?.addEventListener("abort", onAbort, { once: true });
335
+ if (signal?.aborted) onAbort();
336
+ });
337
+ }
338
+
339
+ async function acquireFilesystemMutationLock(key: string, signal?: AbortSignal): Promise<() => Promise<void>> {
340
+ if (!existsSync(key)) return async () => {};
341
+ const lockTarget = join(key, ".pi-worktree-mutation");
342
+ const deadline = Date.now() + MUTATION_LOCK_TIMEOUT_MS;
343
+
344
+ while (true) {
345
+ if (signal?.aborted) throw new GitWorktreeError("worktree mutation lock wait aborted.");
346
+ try {
347
+ const releaseLock = await lockfile.lock(lockTarget, {
348
+ realpath: false,
349
+ retries: 0,
350
+ stale: MUTATION_LOCK_STALE_MS,
351
+ update: Math.floor(MUTATION_LOCK_STALE_MS / 3),
352
+ });
353
+ let released = false;
354
+ return async () => {
355
+ if (released) return;
356
+ released = true;
357
+ try {
358
+ await releaseLock();
359
+ } catch (error: unknown) {
360
+ throw new GitWorktreeError(`Cannot release worktree mutation lock: ${formatError(error)}`);
361
+ }
362
+ };
363
+ } catch (error: unknown) {
364
+ if (!isNodeError(error) || (error.code !== "ELOCKED" && error.code !== "EEXIST")) {
365
+ throw new GitWorktreeError(`Cannot acquire worktree mutation lock: ${formatError(error)}`);
366
+ }
367
+ if (Date.now() >= deadline) {
368
+ throw new GitWorktreeError(`Timed out waiting for worktree mutation lock ${lockTarget}.`);
369
+ }
370
+ await waitForMutationLock(signal);
371
+ }
372
+ }
373
+ }
374
+
375
+ async function withMetadataPruneLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
376
+ const previous = metadataPruneLocks.get(key);
377
+ let release!: () => void;
378
+ const current = new Promise<void>((resolveLock) => {
379
+ release = resolveLock;
380
+ });
381
+ metadataPruneLocks.set(key, current);
382
+ if (previous) await previous;
383
+ try {
384
+ return await operation();
385
+ } finally {
386
+ release();
387
+ if (metadataPruneLocks.get(key) === current) metadataPruneLocks.delete(key);
388
+ }
389
+ }
390
+
391
+ export function withWorktreeMutationLock<T>(
392
+ cwd: string,
393
+ operation: () => Promise<T>,
394
+ signal?: AbortSignal,
395
+ ): Promise<T> {
396
+ const key = worktreeMutationLockKey(cwd);
397
+ return withMetadataPruneLock(key, async () => {
398
+ const release = await acquireFilesystemMutationLock(key, signal);
399
+ try {
400
+ return await operation();
401
+ } finally {
402
+ await release();
403
+ }
404
+ });
405
+ }
406
+
407
+ function commonDirectoryForGitdir(gitdir: string): string {
408
+ try {
409
+ const value = removeLineEnding(readFileSync(join(gitdir, "commondir"), "utf8"));
410
+ return value ? realpathSync(resolve(gitdir, value)) : realpathSync(gitdir);
411
+ } catch (error: unknown) {
412
+ if (isNodeError(error) && error.code === "ENOENT") return realpathSync(gitdir);
413
+ throw error;
414
+ }
415
+ }
416
+
417
+ function worktreeMutationLockKey(cwd: string): string {
418
+ let current = resolve(cwd);
419
+ while (true) {
420
+ const gitEntry = join(current, ".git");
421
+ try {
422
+ const entry = lstatSync(gitEntry);
423
+ if (entry.isDirectory()) return commonDirectoryForGitdir(gitEntry);
424
+ if (entry.isFile() && !entry.isSymbolicLink()) {
425
+ const gitdir = /^gitdir:\s*(.+)$/mu.exec(readFileSync(gitEntry, "utf8"))?.[1]?.trim();
426
+ if (gitdir) return commonDirectoryForGitdir(realpathSync(resolve(current, gitdir)));
427
+ }
428
+ } catch {
429
+ // Keep walking; Git may be invoked from a nested repository directory.
430
+ }
431
+ const parent = dirname(current);
432
+ if (parent === current) return resolve(cwd);
433
+ current = parent;
434
+ }
435
+ }
436
+
437
+ function prunePreviewEntries(stdout: string): string[] {
438
+ return stdout
439
+ .split(/\r?\n/u)
440
+ .map((line) => line.trim())
441
+ .filter((line) => line.startsWith("Removing "));
442
+ }
443
+
444
+ function hasAdministrativeWorktrees(cwd: string): boolean {
445
+ return existsSync(join(worktreeMutationLockKey(cwd), "worktrees"));
446
+ }
447
+
448
+ async function administrativePruneCandidatesIfPresent(
449
+ pi: Pick<ExtensionAPI, "exec">,
450
+ cwd: string,
451
+ signal?: AbortSignal,
452
+ ): Promise<AdministrativePruneCandidate[]> {
453
+ return hasAdministrativeWorktrees(cwd) ? administrativePruneCandidates(pi, cwd, signal) : [];
454
+ }
455
+
456
+ /**
457
+ * Deregister a worktree without asking Git to recursively delete its files.
458
+ *
459
+ * `git worktree remove <path>` is deliberately not used here: even a brief
460
+ * absent-path window lets Git interpret a late-created directory as its delete
461
+ * target. The caller reserves the registered path with a non-directory entry;
462
+ * once the real tree has moved away, Git marks exactly that record prunable.
463
+ * Refuse to prune when Git's complete dry-run preview contains any unrelated
464
+ * stale administrative record, then use Git's metadata-only prune command and
465
+ * verify the target disappeared. Calls in this process are serialized per cwd.
466
+ */
467
+ export async function removeWorktreeMetadata(
468
+ pi: Pick<ExtensionAPI, "exec">,
469
+ cwd: string,
470
+ path: string,
471
+ signal?: AbortSignal,
472
+ onMetadataRemoved?: () => void,
473
+ lockHeld = false,
474
+ ): Promise<void> {
475
+ const operation = async (): Promise<void> => {
476
+ const before = await listWorktrees(pi, cwd, signal);
477
+ const target = before.find((record) => pathsEqual(record.path, path));
478
+ if (!target) {
479
+ throw new GitWorktreeError(`Refusing metadata prune because the target record is absent: ${path}.`);
480
+ }
481
+ const stale = before.filter((record) => record.prunableReason);
482
+ if (!target.prunableReason || stale.length !== 1 || stale[0] !== target) {
483
+ throw new GitWorktreeError(`Refusing metadata prune for non-isolated worktree ${path}.`);
484
+ }
485
+ const administrativeBefore = await administrativePruneCandidatesIfPresent(pi, cwd, signal);
486
+ const hasAdministrative = hasAdministrativeWorktrees(cwd);
487
+ const targetAdministrative = administrativeBefore[0];
488
+ if (hasAdministrative && (!targetAdministrative || administrativeBefore.length !== 1)) {
489
+ throw new GitWorktreeError(
490
+ `Refusing metadata prune because Git has ${administrativeBefore.length} stale administrative records.`,
491
+ );
492
+ }
493
+ if (targetAdministrative) {
494
+ const targetPath = administrativeCandidateWorktreePath(targetAdministrative);
495
+ if (!targetPath || !pathsEqual(targetPath, path)) {
496
+ throw new GitWorktreeError(`Refusing metadata prune because the stale administrative record is not ${path}.`);
497
+ }
498
+ }
499
+ const preview = await runGit(
500
+ pi,
501
+ ["worktree", "prune", "--dry-run", "--verbose", "--expire", "now"],
502
+ cwd,
503
+ signal,
504
+ GIT_MUTATION_TIMEOUT_MS,
505
+ );
506
+ const previewEntries = prunePreviewEntries(`${preview.stdout}\n${preview.stderr}`);
507
+ if (
508
+ previewEntries.length !== 1 ||
509
+ (targetAdministrative && !previewEntries[0]?.includes(targetAdministrative.id))
510
+ ) {
511
+ throw new GitWorktreeError(`Refusing metadata prune because the stale-record preview changed.`);
512
+ }
513
+ if (targetAdministrative) {
514
+ await removeAdministrativeRecord(targetAdministrative);
515
+ } else {
516
+ await runGit(pi, ["worktree", "prune", "--expire", "now"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
517
+ }
518
+ const after = await listWorktrees(pi, cwd, signal);
519
+ if (after.some((record) => pathsEqual(record.path, path))) {
520
+ throw new GitWorktreeError(`Git did not remove worktree metadata for ${path}.`);
521
+ }
522
+ const administrativeAfter = await administrativePruneCandidatesIfPresent(pi, cwd, signal);
523
+ if (administrativeAfter.length !== 0) {
524
+ throw new GitWorktreeError(`Git left stale administrative records after pruning ${path}.`);
525
+ }
526
+ onMetadataRemoved?.();
527
+ };
528
+ if (lockHeld) await operation();
529
+ else await withWorktreeMutationLock(cwd, operation, signal);
530
+ }
531
+
532
+ export async function worktreeInventory(
533
+ pi: Pick<ExtensionAPI, "exec">,
534
+ path: string,
535
+ signal?: AbortSignal,
536
+ ): Promise<string[]> {
537
+ const statusArgs = [
538
+ "status",
539
+ "--porcelain=v1",
540
+ "--untracked-files=all",
541
+ "--ignored=matching",
542
+ "--ignore-submodules=none",
543
+ ];
544
+ const status = await runGit(pi, statusArgs, path, signal);
545
+ const indexFlags = await runGit(pi, ["ls-files", "-v", "-z"], path, signal);
546
+ const indexInventory = await indexFlagInventory(pi, indexFlags.stdout, path, signal);
547
+ const submoduleStatus = await runGit(pi, ["submodule", "status", "--recursive"], path, signal);
548
+ const initializedSubmodules = nonEmptyLines(submoduleStatus.stdout)
549
+ .filter((line) => !line.startsWith("-"))
550
+ .map((line) => `initialized submodule: ${line.slice(1).trimStart()}`);
551
+ const submodules = await runGit(
552
+ pi,
553
+ [
554
+ "submodule",
555
+ "foreach",
556
+ "--recursive",
557
+ "--quiet",
558
+ "git status --porcelain=v1 --untracked-files=all --ignored=matching --ignore-submodules=none",
559
+ ],
560
+ path,
561
+ signal,
562
+ );
563
+ return [
564
+ ...nonEmptyLines(status.stdout),
565
+ ...indexInventory,
566
+ ...initializedSubmodules,
567
+ ...nonEmptyLines(submodules.stdout),
568
+ ];
569
+ }
570
+
571
+ export async function worktreeAdministrativeDirectory(
572
+ pi: Pick<ExtensionAPI, "exec">,
573
+ cwd: string,
574
+ signal?: AbortSignal,
575
+ ): Promise<string> {
576
+ const result = await runGit(pi, ["rev-parse", "--path-format=absolute", "--git-dir"], cwd, signal);
577
+ const value = removeLineEnding(result.stdout);
578
+ if (!value) throw new GitWorktreeError("Git did not return its worktree administrative path.");
579
+ return resolve(cwd, value);
580
+ }
581
+
582
+ export async function administrativeHistoryOids(
583
+ pi: Pick<ExtensionAPI, "exec">,
584
+ cwd: string,
585
+ administrativePath: string,
586
+ signal?: AbortSignal,
587
+ ): Promise<string[]> {
588
+ const gitDirArgument = `--git-dir=${administrativePath}`;
589
+ const values = readAdministrativeReflogOids(resolve(administrativePath, "logs"));
590
+
591
+ const refs = await runGit(
592
+ pi,
593
+ [gitDirArgument, "for-each-ref", "--format=%(objectname)", "refs/worktree", "refs/rewritten", "refs/bisect"],
594
+ cwd,
595
+ signal,
596
+ );
597
+ values.push(...splitAdministrativeOids(refs.stdout, "per-worktree refs"));
598
+ const reflogs = await runGit(pi, [gitDirArgument, "reflog", "--all", "--format=%H"], cwd, signal);
599
+ values.push(...splitAdministrativeOids(reflogs.stdout, "Git reflogs"));
600
+
601
+ for (const name of ["ORIG_HEAD", "MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_HEAD"]) {
602
+ const contents = readAdministrativeFile(administrativePath, name);
603
+ if (contents === undefined) continue;
604
+ values.push(...splitAdministrativeOids(contents, name));
605
+ }
606
+ const fetchHead = readAdministrativeFile(administrativePath, "FETCH_HEAD");
607
+ if (fetchHead !== undefined) {
608
+ values.push(...splitFetchHeadOids(fetchHead));
609
+ }
610
+ return [...new Set(values)];
611
+ }
612
+
613
+ export async function administrativePruneCandidates(
614
+ pi: Pick<ExtensionAPI, "exec">,
615
+ cwd: string,
616
+ signal?: AbortSignal,
617
+ ): Promise<AdministrativePruneCandidate[]> {
618
+ const commonResult = await runGit(pi, ["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd, signal);
619
+ const commonValue = removeLineEnding(commonResult.stdout);
620
+ if (!commonValue) throw new GitWorktreeError("Git did not return its common directory.");
621
+ const commonDirectory = resolve(cwd, commonValue);
622
+ const administrativeRoot = resolve(commonDirectory, "worktrees");
623
+ if (!existsSync(administrativeRoot)) return [];
624
+
625
+ const candidates: AdministrativePruneCandidate[] = [];
626
+ const addCandidate = (candidate: AdministrativePruneCandidate): void => {
627
+ candidates.push(candidate);
628
+ administrativeIdentities.set(candidate, metadataSnapshot(candidate.administrativePath));
629
+ };
630
+ for (const entry of readdirSync(administrativeRoot, { withFileTypes: true })) {
631
+ const administrativePath = resolve(administrativeRoot, entry.name);
632
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
633
+ throw new GitWorktreeError(`Unexpected Git worktree administrative entry: ${administrativePath}.`);
634
+ }
635
+ if (existsSync(resolve(administrativePath, "locked"))) continue;
636
+
637
+ const gitdirPath = resolve(administrativePath, "gitdir");
638
+ let registeredGitFile: string | undefined;
639
+ try {
640
+ registeredGitFile = removeLineEnding(readFileSync(gitdirPath, "utf8"));
641
+ } catch (error) {
642
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
643
+ }
644
+ if (registeredGitFile) {
645
+ const targetGitFile = resolve(administrativePath, registeredGitFile);
646
+ if (existsSync(targetGitFile)) continue;
647
+ }
648
+
649
+ const headPath = resolve(administrativePath, "HEAD");
650
+ let headValue: string;
651
+ try {
652
+ if (!lstatSync(headPath).isFile()) {
653
+ throw new GitWorktreeError(`Git worktree administrative HEAD is not a file: ${headPath}.`);
654
+ }
655
+ headValue = removeLineEnding(readFileSync(headPath, "utf8"));
656
+ } catch (error) {
657
+ if (error instanceof GitWorktreeError) throw error;
658
+ throw new GitWorktreeError(`Cannot inspect Git worktree administrative HEAD ${headPath}: ${formatError(error)}`);
659
+ }
660
+ const indexDirty = await administrativeIndexIsDirty(pi, cwd, administrativePath, signal);
661
+ if (headValue.startsWith("ref: ")) {
662
+ const branchRef = headValue.slice("ref: ".length);
663
+ if (!branchRef) {
664
+ throw new GitWorktreeError(`Git worktree administrative HEAD has an empty ref: ${headPath}.`);
665
+ }
666
+ addCandidate({
667
+ id: entry.name,
668
+ administrativePath,
669
+ branchRef,
670
+ indexDirty,
671
+ });
672
+ continue;
673
+ }
674
+ if (!/^[0-9a-fA-F]{40,64}$/u.test(headValue)) {
675
+ throw new GitWorktreeError(`Git worktree administrative HEAD is malformed: ${headPath}.`);
676
+ }
677
+ addCandidate({
678
+ id: entry.name,
679
+ administrativePath,
680
+ head: headValue,
681
+ indexDirty,
682
+ });
683
+ }
684
+ return candidates;
685
+ }
686
+ function metadataSnapshot(path: string): MetadataIdentity {
687
+ const stat = lstatSync(path);
688
+ const metadata = {
689
+ kind: stat.isDirectory() ? ("directory" as const) : ("leaf" as const),
690
+ dev: stat.dev,
691
+ ino: stat.ino,
692
+ size: stat.size,
693
+ mtimeMs: stat.mtimeMs,
694
+ ctimeMs: stat.ctimeMs,
695
+ };
696
+ if (metadata.kind === "leaf") return metadata;
697
+ const children = new Map<string, MetadataIdentity>();
698
+ for (const name of readdirSync(path)) children.set(name, metadataSnapshot(join(path, name)));
699
+ return { ...metadata, children };
700
+ }
701
+
702
+ function sameMetadataIdentity(actual: MetadataIdentity, expected: MetadataIdentity): boolean {
703
+ if (actual.kind !== expected.kind || actual.dev !== expected.dev || actual.ino !== expected.ino) return false;
704
+ if (expected.kind === "directory") return true;
705
+ return actual.size === expected.size && actual.mtimeMs === expected.mtimeMs && actual.ctimeMs === expected.ctimeMs;
706
+ }
707
+ function sameMetadataIdentityAfterRename(actual: MetadataIdentity, expected: MetadataIdentity): boolean {
708
+ if (actual.kind !== expected.kind || actual.dev !== expected.dev || actual.ino !== expected.ino) return false;
709
+ if (expected.kind === "directory") return true;
710
+ return actual.size === expected.size && actual.mtimeMs === expected.mtimeMs;
711
+ }
712
+ async function claimMetadataDeletion(path: string, expected: MetadataIdentity): Promise<string> {
713
+ const claimed = join(dirname(path), `.${randomUUID()}.pi-worktree-metadata-final-delete`);
714
+ try {
715
+ await rename(path, claimed);
716
+ const stat = await lstat(claimed);
717
+ const actual: MetadataIdentity = {
718
+ kind: stat.isDirectory() ? "directory" : "leaf",
719
+ dev: stat.dev,
720
+ ino: stat.ino,
721
+ size: stat.size,
722
+ mtimeMs: stat.mtimeMs,
723
+ ctimeMs: stat.ctimeMs,
724
+ };
725
+ if (!sameMetadataIdentityAfterRename(actual, expected)) {
726
+ throw new MetadataDeletionRetainedError(claimed, `Git metadata changed before final deletion: ${path}.`);
727
+ }
728
+ return claimed;
729
+ } catch (error: unknown) {
730
+ if (error instanceof MetadataDeletionRetainedError) throw error;
731
+ if (isNodeError(error) && error.code === "ENOENT") {
732
+ throw new MetadataDeletionRetainedError(
733
+ claimed,
734
+ `Git metadata disappeared before final deletion; removal outcome is unknown: ${path}.`,
735
+ true,
736
+ );
737
+ }
738
+ throw error;
739
+ }
740
+ }
741
+
742
+ async function removeMetadataTree(path: string, expected: MetadataIdentity): Promise<void> {
743
+ const stat = await lstat(path);
744
+ const actual: MetadataIdentity = {
745
+ kind: stat.isDirectory() ? "directory" : "leaf",
746
+ dev: stat.dev,
747
+ ino: stat.ino,
748
+ size: stat.size,
749
+ mtimeMs: stat.mtimeMs,
750
+ ctimeMs: stat.ctimeMs,
751
+ };
752
+ if (!sameMetadataIdentity(actual, expected)) throw new Error(`Git metadata changed while removing ${path}.`);
753
+ if (expected.kind === "leaf") {
754
+ const claimed = await claimMetadataDeletion(path, expected);
755
+ try {
756
+ await unlink(claimed);
757
+ } catch (error: unknown) {
758
+ throw new MetadataDeletionRetainedError(
759
+ claimed,
760
+ `Git metadata could not be deleted after claiming ${path}: ${formatError(error)}.`,
761
+ isNodeError(error) && error.code === "ENOENT",
762
+ );
763
+ }
764
+ return;
765
+ }
766
+ const children = expected.children ?? new Map<string, MetadataIdentity>();
767
+ const actualNames = await readdir(path);
768
+ const expectedNames = new Set(children.keys());
769
+ if (
770
+ actualNames.some((name) => !expectedNames.has(name)) ||
771
+ [...expectedNames].some((name) => !actualNames.includes(name))
772
+ ) {
773
+ throw new Error(`New Git metadata appeared while removing ${path}.`);
774
+ }
775
+ for (const [name, child] of children) await removeMetadataTree(join(path, name), child);
776
+ const claimed = await claimMetadataDeletion(path, expected);
777
+ try {
778
+ await rmdir(claimed);
779
+ } catch (error: unknown) {
780
+ throw new MetadataDeletionRetainedError(
781
+ claimed,
782
+ `Git metadata directory could not be deleted after claiming ${path}: ${formatError(error)}.`,
783
+ isNodeError(error) && error.code === "ENOENT",
784
+ );
785
+ }
786
+ }
787
+
788
+ async function removeAdministrativeRecord(candidate: AdministrativePruneCandidate): Promise<void> {
789
+ const expected = administrativeIdentities.get(candidate);
790
+ if (!expected) throw new GitWorktreeError(`Git metadata identity was not captured for ${candidate.id}.`);
791
+ const source = candidate.administrativePath;
792
+ const worktreePath = administrativeCandidateWorktreePath(candidate);
793
+ const worktreeGitFile = worktreePath ? join(worktreePath, ".git") : undefined;
794
+ const tombstone = join(dirname(source), `.${basename(source)}.${randomUUID()}.pi-worktree-metadata-delete`);
795
+ let moved = false;
796
+ try {
797
+ if (worktreeGitFile && existsSync(worktreeGitFile)) {
798
+ throw new Error(`worktree ${worktreePath} became valid before metadata removal`);
799
+ }
800
+ await rename(source, tombstone);
801
+ moved = true;
802
+ if (worktreeGitFile && existsSync(worktreeGitFile)) {
803
+ await rename(tombstone, source);
804
+ moved = false;
805
+ throw new Error(`worktree ${worktreePath} became valid while claiming metadata`);
806
+ }
807
+ await removeMetadataTree(tombstone, expected);
808
+ } catch (error: unknown) {
809
+ const retainedPath =
810
+ error instanceof MetadataDeletionRetainedError ? error.retainedPath : moved ? tombstone : undefined;
811
+ const outcomeWarning =
812
+ error instanceof MetadataDeletionRetainedError && error.outcomeUnknown
813
+ ? " Metadata removal outcome is unknown."
814
+ : "";
815
+ throw new GitWorktreeError(
816
+ `Git administrative metadata removal failed for ${candidate.id}${retainedPath ? `; retained at ${retainedPath}` : ""}.${outcomeWarning} ${formatError(error)}`,
817
+ );
818
+ }
819
+ }
820
+ function sameAdministrativeCandidate(left: AdministrativePruneCandidate, right: AdministrativePruneCandidate): boolean {
821
+ return (
822
+ left.id === right.id &&
823
+ pathsEqual(left.administrativePath, right.administrativePath) &&
824
+ left.head === right.head &&
825
+ left.branchRef === right.branchRef &&
826
+ left.indexDirty === right.indexDirty
827
+ );
828
+ }
829
+ function administrativeCandidateWorktreePath(candidate: AdministrativePruneCandidate): string | undefined {
830
+ try {
831
+ const gitdir = removeLineEnding(readFileSync(join(candidate.administrativePath, "gitdir"), "utf8"));
832
+ return gitdir ? dirname(resolve(candidate.administrativePath, gitdir)) : undefined;
833
+ } catch (error: unknown) {
834
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
835
+ throw new GitWorktreeError(
836
+ `Cannot inspect Git worktree administrative gitdir for ${candidate.id}: ${formatError(error)}`,
837
+ );
838
+ }
839
+ }
840
+
841
+ async function administrativeIndexIsDirty(
842
+ pi: Pick<ExtensionAPI, "exec">,
843
+ cwd: string,
844
+ administrativePath: string,
845
+ signal?: AbortSignal,
846
+ ): Promise<boolean> {
847
+ const args = [
848
+ `--git-dir=${administrativePath}`,
849
+ "diff",
850
+ "--cached",
851
+ "--quiet",
852
+ "--no-ext-diff",
853
+ "--no-textconv",
854
+ "--ignore-submodules=none",
855
+ "--",
856
+ ];
857
+ const result = await runGitAllowFailure(pi, args, cwd, signal);
858
+ if (result.killed) throw killedError(args);
859
+ if (result.code === 0) return false;
860
+ if (result.code === 1) return true;
861
+ throw gitFailure(args, result);
862
+ }
863
+
864
+ export async function durableRefExists(
865
+ pi: Pick<ExtensionAPI, "exec">,
866
+ cwd: string,
867
+ ref: string,
868
+ signal?: AbortSignal,
869
+ ): Promise<boolean> {
870
+ if (!ref.startsWith("refs/") || ref.includes("\0")) {
871
+ throw new GitWorktreeError("Git worktree administrative HEAD contains an invalid ref.");
872
+ }
873
+ const result = await runGitAllowFailure(pi, ["show-ref", "--verify", "--quiet", ref], cwd, signal);
874
+ if (result.killed) throw killedError(["show-ref", "--verify", "--quiet"]);
875
+ if (result.code === 0) return true;
876
+ if (result.code === 1) return false;
877
+ throw gitFailure(["show-ref", "--verify", "--quiet"], result);
878
+ }
879
+
880
+ export async function durableRefsContaining(
881
+ pi: Pick<ExtensionAPI, "exec">,
882
+ cwd: string,
883
+ head: string,
884
+ signal?: AbortSignal,
885
+ ): Promise<string[]> {
886
+ if (!/^[0-9a-fA-F]{40,64}$/u.test(head)) {
887
+ throw new GitWorktreeError("Detached worktree has an invalid HEAD object.");
888
+ }
889
+ const result = await runGit(
890
+ pi,
891
+ ["for-each-ref", "--format=%(refname)", `--contains=${head}`, "refs/heads", "refs/tags", "refs/remotes"],
892
+ cwd,
893
+ signal,
894
+ );
895
+ return nonEmptyLines(result.stdout);
896
+ }
897
+
898
+ export async function prunePreview(pi: Pick<ExtensionAPI, "exec">, cwd: string, signal?: AbortSignal): Promise<string> {
899
+ const result = await runGit(pi, ["worktree", "prune", "--dry-run", "--verbose"], cwd, signal);
900
+ return combineOutput(result);
901
+ }
902
+
903
+ export async function pruneWorktrees(
904
+ pi: Pick<ExtensionAPI, "exec">,
905
+ cwd: string,
906
+ signal?: AbortSignal,
907
+ lockHeld = false,
908
+ approvedCandidates?: readonly AdministrativePruneCandidate[],
909
+ ): Promise<string> {
910
+ const operation = async (): Promise<string> => {
911
+ const currentCandidates = await administrativePruneCandidates(pi, cwd, signal);
912
+ const candidates = approvedCandidates
913
+ ? approvedCandidates.map((approved) => {
914
+ const current = currentCandidates.find((candidate) =>
915
+ pathsEqual(candidate.administrativePath, approved.administrativePath),
916
+ );
917
+ if (!current || !sameAdministrativeCandidate(current, approved)) {
918
+ throw new GitWorktreeError(`Git administrative metadata changed before pruning ${approved.id}.`);
919
+ }
920
+ return approved;
921
+ })
922
+ : currentCandidates;
923
+ if (candidates.length === 0) {
924
+ if (approvedCandidates?.length || hasAdministrativeWorktrees(cwd) || existsSync(worktreeMutationLockKey(cwd))) {
925
+ throw new GitWorktreeError("No approved Git administrative records are available to prune.");
926
+ }
927
+ const result = await runGit(pi, ["worktree", "prune", "--verbose"], cwd, signal, GIT_MUTATION_TIMEOUT_MS);
928
+ return combineOutput(result);
929
+ }
930
+ for (const candidate of candidates) await removeAdministrativeRecord(candidate);
931
+ return candidates.map((candidate) => `Removed ${candidate.id}`).join("\n");
932
+ };
933
+ return lockHeld ? operation() : withWorktreeMutationLock(cwd, operation, signal);
934
+ }
935
+
936
+ export function formatWorktree(record: WorktreeRecord, currentPath?: string): string {
937
+ const labels = [
938
+ currentPath && pathsEqual(record.path, currentPath) ? "current" : undefined,
939
+ record.isMain ? "main" : undefined,
940
+ record.bare ? "bare" : undefined,
941
+ record.detached ? "detached" : record.branch,
942
+ record.lockedReason !== undefined ? `locked${record.lockedReason ? `: ${record.lockedReason}` : ""}` : undefined,
943
+ record.prunableReason !== undefined
944
+ ? `prunable${record.prunableReason ? `: ${record.prunableReason}` : ""}`
945
+ : undefined,
946
+ ].filter((label): label is string => Boolean(label));
947
+ const head = record.head ? record.head.slice(0, 8) : "no HEAD";
948
+ return stripTerminalControls(`${record.path} [${labels.join(", ") || "unknown"}] ${head}`);
949
+ }
950
+
951
+ export function stripTerminalControls(value: string): string {
952
+ return [...value]
953
+ .filter((character) => {
954
+ const code = character.codePointAt(0) ?? 0;
955
+ return code > 0x1f && (code < 0x7f || code > 0x9f);
956
+ })
957
+ .join("");
958
+ }
959
+
960
+ async function runGit(
961
+ pi: Pick<ExtensionAPI, "exec">,
962
+ args: string[],
963
+ cwd: string,
964
+ signal?: AbortSignal,
965
+ timeout = GIT_TIMEOUT_MS,
966
+ ): Promise<ExecResult> {
967
+ const result = await runGitAllowFailure(pi, args, cwd, signal, timeout);
968
+ if (result.killed) throw killedError(args);
969
+ if (result.code !== 0) throw gitFailure(args, result);
970
+ return result;
971
+ }
972
+
973
+ async function runGitAllowFailure(
974
+ pi: Pick<ExtensionAPI, "exec">,
975
+ args: string[],
976
+ cwd: string,
977
+ signal?: AbortSignal,
978
+ timeout = GIT_TIMEOUT_MS,
979
+ ): Promise<ExecResult> {
980
+ try {
981
+ return await pi.exec("git", args, { cwd, signal, timeout });
982
+ } catch (error) {
983
+ const message = formatError(error);
984
+ if (/\bENOENT\b|not found/i.test(message)) {
985
+ throw new GitWorktreeError("Git executable was not found. Install Git and retry.", args);
986
+ }
987
+ throw new GitWorktreeError(`Could not start git ${args.slice(0, 2).join(" ")}: ${message}`, args);
988
+ }
989
+ }
990
+
991
+ function gitFailure(args: string[], result: ExecResult): GitWorktreeError {
992
+ const detail = stripTerminalControls([result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n"));
993
+ const hint = /not a git repository/i.test(detail)
994
+ ? "The current Pi workspace is not inside a Git repository."
995
+ : detail || `Git exited with code ${result.code}.`;
996
+ return new GitWorktreeError(`git ${args.slice(0, 2).join(" ")} failed: ${hint}`, args);
997
+ }
998
+
999
+ function killedError(args: string[]): GitWorktreeError {
1000
+ return new GitWorktreeError(`git ${args.slice(0, 2).join(" ")} timed out or was cancelled.`, args);
1001
+ }
1002
+
1003
+ function nonEmptyLines(value: string): string[] {
1004
+ return value.split(/\r?\n/u).filter((line) => line.length > 0);
1005
+ }
1006
+
1007
+ interface IndexFlagEntry {
1008
+ path: string;
1009
+ skipWorktree: boolean;
1010
+ assumeUnchanged: boolean;
1011
+ }
1012
+
1013
+ async function indexFlagInventory(
1014
+ pi: Pick<ExtensionAPI, "exec">,
1015
+ value: string,
1016
+ cwd: string,
1017
+ signal?: AbortSignal,
1018
+ ): Promise<string[]> {
1019
+ const entries = parseIndexFlagEntries(value);
1020
+ const sparseManagedPaths = await sparseManagedSkipWorktreePaths(
1021
+ pi,
1022
+ cwd,
1023
+ entries.filter((entry) => entry.skipWorktree).map((entry) => entry.path),
1024
+ signal,
1025
+ );
1026
+ const inventory: string[] = [];
1027
+ for (const entry of entries) {
1028
+ const flags = [
1029
+ entry.skipWorktree && !sparseManagedPaths.has(entry.path) ? "skip-worktree" : undefined,
1030
+ entry.assumeUnchanged ? "assume-unchanged" : undefined,
1031
+ ].filter((flag): flag is string => flag !== undefined);
1032
+ if (flags.length > 0) inventory.push(`index flag ${flags.join("+")}: ${entry.path}`);
1033
+ }
1034
+ return inventory;
1035
+ }
1036
+
1037
+ function parseIndexFlagEntries(value: string): IndexFlagEntry[] {
1038
+ const entries: IndexFlagEntry[] = [];
1039
+ for (const entry of value.split("\0")) {
1040
+ if (!entry) continue;
1041
+ if (entry.length < 3 || entry[1] !== " ") {
1042
+ throw new GitWorktreeError("Git returned malformed ls-files index-flag output.");
1043
+ }
1044
+ const tag = entry[0] ?? "";
1045
+ const skipWorktree = tag.toUpperCase() === "S";
1046
+ const assumeUnchanged = /[a-z]/u.test(tag);
1047
+ if (skipWorktree || assumeUnchanged) {
1048
+ entries.push({ path: entry.slice(2), skipWorktree, assumeUnchanged });
1049
+ }
1050
+ }
1051
+ return entries;
1052
+ }
1053
+
1054
+ async function sparseManagedSkipWorktreePaths(
1055
+ pi: Pick<ExtensionAPI, "exec">,
1056
+ cwd: string,
1057
+ paths: readonly string[],
1058
+ signal?: AbortSignal,
1059
+ ): Promise<ReadonlySet<string>> {
1060
+ if (paths.length === 0) return new Set();
1061
+ const configArgs = ["config", "--bool", "--get", "core.sparseCheckout"];
1062
+ const config = await runGitAllowFailure(pi, configArgs, cwd, signal);
1063
+ if (config.killed) throw killedError(configArgs);
1064
+ if (config.code !== 0 || config.stdout.trim() !== "true") return new Set();
1065
+
1066
+ const candidates = new Set(paths);
1067
+ const checkArgs = ["sparse-checkout", "check-rules", "-z"];
1068
+ const checked = await runGitWithInputAllowFailure(checkArgs, cwd, `${[...candidates].join("\0")}\0`, signal);
1069
+ if (checked.killed) throw killedError(checkArgs);
1070
+ // Older Git versions lack check-rules; retain every flag rather than guessing.
1071
+ if (checked.code !== 0) return new Set();
1072
+
1073
+ const included = new Set(nulSeparatedPaths(checked.stdout, "sparse-checkout rules"));
1074
+ if ([...included].some((path) => !candidates.has(path))) {
1075
+ throw new GitWorktreeError("Git returned an unexpected sparse-checkout path.");
1076
+ }
1077
+ return new Set([...candidates].filter((path) => !included.has(path)));
1078
+ }
1079
+
1080
+ function runGitWithInputAllowFailure(
1081
+ args: string[],
1082
+ cwd: string,
1083
+ input: string,
1084
+ signal?: AbortSignal,
1085
+ timeout = GIT_TIMEOUT_MS,
1086
+ ): Promise<ExecResult> {
1087
+ if (signal?.aborted) {
1088
+ return Promise.resolve({ stdout: "", stderr: "", code: 1, killed: true });
1089
+ }
1090
+ return new Promise((resolveResult, reject) => {
1091
+ // ExtensionAPI.exec has no stdin channel, so this read-only check uses an argv-only child.
1092
+ const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1093
+ let stdout = "";
1094
+ let stderr = "";
1095
+ let killed = false;
1096
+ let settled = false;
1097
+ const finish = (result: ExecResult) => {
1098
+ if (settled) return;
1099
+ settled = true;
1100
+ clearTimeout(timeoutHandle);
1101
+ signal?.removeEventListener("abort", stop);
1102
+ resolveResult(result);
1103
+ };
1104
+ const fail = (error: unknown) => {
1105
+ if (settled) return;
1106
+ settled = true;
1107
+ clearTimeout(timeoutHandle);
1108
+ signal?.removeEventListener("abort", stop);
1109
+ child.kill();
1110
+ const message = formatError(error);
1111
+ reject(
1112
+ /\bENOENT\b|not found/i.test(message)
1113
+ ? new GitWorktreeError("Git executable was not found. Install Git and retry.", args)
1114
+ : new GitWorktreeError(`Could not start git ${args.slice(0, 2).join(" ")}: ${message}`, args),
1115
+ );
1116
+ };
1117
+ const stop = () => {
1118
+ killed = true;
1119
+ child.kill();
1120
+ };
1121
+ const timeoutHandle = setTimeout(stop, timeout);
1122
+ child.stdout.setEncoding("utf8");
1123
+ child.stderr.setEncoding("utf8");
1124
+ child.stdout.on("data", (chunk: string) => {
1125
+ stdout += chunk;
1126
+ });
1127
+ child.stderr.on("data", (chunk: string) => {
1128
+ stderr += chunk;
1129
+ });
1130
+ child.stdin.on("error", (error) => {
1131
+ if (!isNodeError(error) || error.code !== "EPIPE") fail(error);
1132
+ });
1133
+ child.once("error", fail);
1134
+ child.once("close", (code, closeSignal) => {
1135
+ finish({ stdout, stderr, code: code ?? 1, killed: killed || closeSignal !== null });
1136
+ });
1137
+ signal?.addEventListener("abort", stop, { once: true });
1138
+ if (signal?.aborted) stop();
1139
+ child.stdin.end(input);
1140
+ });
1141
+ }
1142
+
1143
+ function nulSeparatedPaths(value: string, source: string): string[] {
1144
+ if (value && !value.endsWith("\0")) {
1145
+ throw new GitWorktreeError(`Git returned malformed ${source} output.`);
1146
+ }
1147
+ return value.split("\0").filter(Boolean);
1148
+ }
1149
+
1150
+ function combineOutput(result: ExecResult): string {
1151
+ return [result.stdout.trimEnd(), result.stderr.trimEnd()].filter(Boolean).join("\n");
1152
+ }
1153
+
1154
+ function removeLineEnding(value: string): string {
1155
+ if (value.endsWith("\r\n")) return value.slice(0, -2);
1156
+ if (value.endsWith("\n")) return value.slice(0, -1);
1157
+ return value;
1158
+ }
1159
+
1160
+ function splitAdministrativeOids(value: string, source: string): string[] {
1161
+ const normalized = value.endsWith("\n") ? value.slice(0, -1) : value;
1162
+ if (!normalized) return [];
1163
+ const values = normalized.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
1164
+ if (values.some((oid) => !/^[0-9a-fA-F]{40,64}$/u.test(oid))) {
1165
+ throw new GitWorktreeError(`Git returned malformed object IDs for ${source}.`);
1166
+ }
1167
+ return values;
1168
+ }
1169
+
1170
+ function splitFetchHeadOids(value: string): string[] {
1171
+ const normalized = value.endsWith("\n") ? value.slice(0, -1) : value;
1172
+ if (!normalized) return [];
1173
+ return normalized.split("\n").map((line) => {
1174
+ const match = /^([0-9a-fA-F]{40,64})\t/u.exec(line);
1175
+ if (!match?.[1]) {
1176
+ throw new GitWorktreeError("Git worktree administrative FETCH_HEAD is malformed.");
1177
+ }
1178
+ return match[1];
1179
+ });
1180
+ }
1181
+
1182
+ function readAdministrativeFile(administrativePath: string, name: string): string | undefined {
1183
+ const path = resolve(administrativePath, name);
1184
+ let stat: ReturnType<typeof lstatSync>;
1185
+ try {
1186
+ stat = lstatSync(path);
1187
+ } catch (error) {
1188
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
1189
+ throw new GitWorktreeError(`Cannot inspect Git worktree administrative ${name}: ${formatError(error)}`);
1190
+ }
1191
+ if (stat.isSymbolicLink() || !stat.isFile()) {
1192
+ throw new GitWorktreeError(`Git worktree administrative ${name} must be a regular file: ${path}.`);
1193
+ }
1194
+ try {
1195
+ return readFileSync(path, "utf8");
1196
+ } catch (error) {
1197
+ throw new GitWorktreeError(`Cannot inspect Git worktree administrative ${name}: ${formatError(error)}`);
1198
+ }
1199
+ }
1200
+
1201
+ function readAdministrativeReflogOids(logPath: string): string[] {
1202
+ if (!existsSync(logPath)) return [];
1203
+ let stat: ReturnType<typeof lstatSync>;
1204
+ try {
1205
+ stat = lstatSync(logPath);
1206
+ } catch (error) {
1207
+ throw new GitWorktreeError(`Cannot inspect Git reflog path ${logPath}: ${formatError(error)}`);
1208
+ }
1209
+ if (stat.isSymbolicLink()) {
1210
+ throw new GitWorktreeError(`Git reflog path must not be a symbolic link: ${logPath}.`);
1211
+ }
1212
+ if (stat.isDirectory()) {
1213
+ const values: string[] = [];
1214
+ for (const entry of readdirSync(logPath, { withFileTypes: true })) {
1215
+ values.push(...readAdministrativeReflogOids(resolve(logPath, entry.name)));
1216
+ }
1217
+ return values;
1218
+ }
1219
+ if (!stat.isFile()) {
1220
+ throw new GitWorktreeError(`Unexpected Git reflog entry type: ${logPath}.`);
1221
+ }
1222
+
1223
+ let contents: string;
1224
+ try {
1225
+ contents = readFileSync(logPath, "utf8");
1226
+ } catch (error) {
1227
+ throw new GitWorktreeError(`Cannot read Git reflog ${logPath}: ${formatError(error)}`);
1228
+ }
1229
+ const normalized = contents.endsWith("\n") ? contents.slice(0, -1) : contents;
1230
+ if (!normalized) return [];
1231
+ const values: string[] = [];
1232
+ for (const line of normalized.split("\n")) {
1233
+ const match = /^([0-9a-fA-F]{40,64}) ([0-9a-fA-F]{40,64}) /u.exec(line);
1234
+ if (!match?.[1] || !match[2] || match[1].length !== match[2].length) {
1235
+ throw new GitWorktreeError(`Git worktree reflog is malformed: ${logPath}.`);
1236
+ }
1237
+ for (const oid of [match[1], match[2]]) {
1238
+ if (!/^0+$/u.test(oid)) values.push(oid);
1239
+ }
1240
+ }
1241
+ return values;
1242
+ }
1243
+
1244
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
1245
+ return error instanceof Error && "code" in error;
1246
+ }
1247
+
1248
+ function formatError(error: unknown): string {
1249
+ return error instanceof Error ? error.message : String(error);
1250
+ }