@signalridge/pi-subagents 1.4.0 → 1.6.1

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/worktree.ts CHANGED
@@ -6,12 +6,11 @@
6
6
  * If changes exist, a branch is created and returned in the result.
7
7
  */
8
8
 
9
- import { execFile, execFileSync } from "node:child_process";
9
+ import { execFileSync } from "node:child_process";
10
10
  import { randomUUID } from "node:crypto";
11
- import { lstatSync, realpathSync, rmSync } from "node:fs";
12
- import { rm } from "node:fs/promises";
11
+ import { existsSync, realpathSync } from "node:fs";
13
12
  import { tmpdir } from "node:os";
14
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { join, relative } from "node:path";
15
14
 
16
15
  export interface WorktreeInfo {
17
16
  /** Absolute path to the worktree directory (the copied repo's root). */
@@ -20,17 +19,35 @@ export interface WorktreeInfo {
20
19
  branch: string;
21
20
  /** Commit SHA that the worktree was created from. */
22
21
  baseSha: string;
23
- /** Absolute path to the original repository's Git top-level. */
24
- repoRoot: string;
25
22
  /**
26
23
  * Where the agent should work inside the worktree: the equivalent of the
27
- * cwd the worktree was created from. Equals `path` when that cwd was deeper.
24
+ * cwd the worktree was created from. Equals `path` when that cwd was the
25
+ * repo root; points at the copied subdirectory when it was deeper (e.g. a
26
+ * monorepo package), so the requested scoping survives isolation.
28
27
  */
29
28
  workPath: string;
30
- /** Internal safety invariant: the package temp root used to create `path`. */
31
- readonly tempRoot?: string;
32
- /** Internal safety invariant: the exact package-generated basename of `path`. */
33
- readonly tempName?: string;
29
+ /** Nearest git root repository top-level. */
30
+ repoRoot: string;
31
+ }
32
+
33
+ /**
34
+ * Project-wide switch for worktree isolation (`worktreeIsolation` in
35
+ * subagents.json). Default `true` — unchanged behaviour.
36
+ *
37
+ * The `"off"` isolation value gives a model a legal way to decline a worktree,
38
+ * but it still depends on the model choosing it. This is the deterministic half
39
+ * of the same fix: on a large repo where every worktree costs real time and
40
+ * disk (#184), turning it off means no caller can create one, whatever it
41
+ * passes.
42
+ */
43
+ let worktreeIsolationEnabled = true;
44
+
45
+ export function setWorktreeIsolationEnabled(enabled: boolean): void {
46
+ worktreeIsolationEnabled = enabled;
47
+ }
48
+
49
+ export function isWorktreeIsolationEnabled(): boolean {
50
+ return worktreeIsolationEnabled;
34
51
  }
35
52
 
36
53
  export interface WorktreeCleanupResult {
@@ -38,26 +55,14 @@ export interface WorktreeCleanupResult {
38
55
  hasChanges: boolean;
39
56
  /** Branch name if changes were committed. */
40
57
  branch?: string;
41
- /** Worktree path if it was kept or if cleanup failed. */
58
+ /** Worktree path if it was kept. */
42
59
  path?: string;
43
- /** Whether the worktree directory and Git registration were cleaned up. */
44
- cleanupSucceeded: boolean;
45
- /** Bounded diagnostic when inspection, preservation, or cleanup was incomplete. */
60
+ /** Diagnostic status indicator for cleanup forensics. */
61
+ cleanupSucceeded?: boolean;
62
+ /** Detailed error message or diagnostic if cleanup encountered issues. */
46
63
  cleanupDiagnostic?: string;
47
- /** Explicit commands a user can run to recover an unrecoverable cleanup. */
48
- recoveryCommands?: readonly string[];
49
- /** Whether Git's removal command completed successfully. */
50
- gitRemovalSucceeded?: boolean;
51
- /** Whether the safe package-owned filesystem fallback was attempted. */
52
- filesystemFallbackAttempted?: boolean;
53
- /** Whether the safe filesystem fallback removed the directory. */
54
- filesystemFallbackSucceeded?: boolean;
55
- /** Whether Git registration was verified absent (when Git allowed verification). */
56
- registrationCleanupVerified?: boolean;
57
- /** Whether the registration prune was attempted after fallback. */
58
- pruneAttempted?: boolean;
59
- /** Whether the attempted registration prune completed. */
60
- pruneSucceeded?: boolean;
64
+ /** Remediation shell commands if cleanup failed. */
65
+ recoveryCommands?: readonly string[] | string[];
61
66
  }
62
67
 
63
68
  /**
@@ -65,708 +70,183 @@ export interface WorktreeCleanupResult {
65
70
  * Returns the worktree path, or undefined if not in a git repo.
66
71
  */
67
72
  export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined {
73
+ // Verify we're in a git repo with at least one commit (HEAD must exist)
68
74
  let baseSha: string;
69
- let repoRoot: string;
70
75
  let subdir: string;
76
+ let topLevel: string;
71
77
  try {
72
78
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
73
79
  baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
74
80
  .toString()
75
81
  .trim();
76
- const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
82
+ // Where cwd sits inside the repo ("" at the root): the agent must work at
83
+ // the same subdirectory inside the copy, or a monorepo-package cwd would
84
+ // silently widen to the whole repo. realpath both sides — git emits
85
+ // resolved paths while cwd may arrive through a symlink (macOS /tmp).
86
+ topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
77
87
  .toString()
78
88
  .trim();
79
- repoRoot = realpathSync(topLevel);
80
- subdir = relative(repoRoot, realpathSync(cwd));
89
+ subdir = relative(realpathSync(topLevel), realpathSync(cwd));
81
90
  } catch {
82
91
  return undefined;
83
92
  }
84
93
 
85
94
  const branch = `pi-agent-${agentId}`;
86
- const tempRoot = resolve(tmpdir());
87
- const tempName = `pi-agent-${agentId}-${randomUUID().slice(0, 8)}`;
88
- const worktreePath = join(tempRoot, tempName);
95
+ const suffix = randomUUID().slice(0, 8);
96
+ const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
89
97
 
90
98
  try {
99
+ // Create detached worktree at HEAD
91
100
  execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
92
101
  cwd,
93
102
  stdio: "pipe",
94
103
  timeout: 30000,
95
104
  });
96
- return {
97
- path: worktreePath,
98
- branch,
99
- baseSha,
100
- repoRoot,
101
- workPath: subdir ? join(worktreePath, subdir) : worktreePath,
102
- tempRoot,
103
- tempName,
104
- };
105
+ return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath, repoRoot: topLevel };
105
106
  } catch {
107
+ // If worktree creation fails, return undefined (agent runs in normal cwd)
106
108
  return undefined;
107
109
  }
108
110
  }
109
111
 
110
112
  /**
111
113
  * Clean up a worktree after agent completion.
112
- *
113
- * Preservation is fail-closed: if status, add, commit, or branch creation
114
- * cannot prove that the changes are safe on a branch, the worktree is left in
115
- * place and the caller receives bounded recovery diagnostics. In particular,
116
- * no forced removal is attempted after a preservation operation fails.
114
+ * - If no changes: remove worktree entirely.
115
+ * - If changes exist: create a branch, commit changes, return branch info.
117
116
  */
118
117
  export function cleanupWorktree(
119
118
  cwd: string,
120
119
  worktree: WorktreeInfo,
121
120
  agentDescription: string,
122
121
  ): WorktreeCleanupResult {
123
- const initialPathEntry = inspectDirectoryEntry(worktree.path);
124
- const initialPathFailure = directoryEntryFailure(worktree.path, initialPathEntry);
125
- if (initialPathFailure) {
126
- return cleanupResult(
127
- worktree,
128
- false,
129
- undefined,
130
- removeWorktree(cwd, worktree),
131
- `Worktree path could not be safely inspected: ${initialPathFailure}`,
132
- );
133
- }
134
- if (!initialPathEntry.present) {
135
- return cleanupResult(worktree, false, undefined, removeWorktree(cwd, worktree));
122
+ if (!existsSync(worktree.path)) {
123
+ return { hasChanges: false, cleanupSucceeded: true };
136
124
  }
137
125
 
138
- let status: string;
126
+ let hasChanges = false;
139
127
  try {
140
- status = execFileSync("git", ["status", "--porcelain"], {
128
+ // Check for uncommitted changes in the worktree
129
+ const status = execFileSync("git", ["status", "--porcelain"], {
141
130
  cwd: worktree.path,
142
131
  stdio: "pipe",
143
132
  timeout: 10000,
144
133
  }).toString().trim();
145
- } catch (error: unknown) {
146
- return preservationFailure(worktree, false, "status", error, cwd);
147
- }
148
134
 
149
- if (status) {
150
- try {
135
+ if (status) {
136
+ hasChanges = true;
137
+ // Changes exist — stage, commit, and create a branch
151
138
  execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
152
- } catch (error: unknown) {
153
- return preservationFailure(worktree, true, "git add", error, cwd);
154
- }
155
- try {
139
+ // Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
156
140
  const safeDesc = agentDescription.slice(0, 200);
157
- execFileSync("git", ["commit", "--no-verify", "-m", `pi-agent: ${safeDesc}`], {
141
+ const commitMsg = `pi-agent: ${safeDesc}`;
142
+ execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], {
158
143
  cwd: worktree.path,
159
144
  stdio: "pipe",
160
145
  timeout: 10000,
161
146
  });
162
- } catch (error: unknown) {
163
- return preservationFailure(worktree, true, "git commit", error, cwd);
164
- }
165
- } else {
166
- let currentSha: string;
167
- try {
168
- currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
147
+ } else {
148
+ const currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
169
149
  cwd: worktree.path,
170
150
  stdio: "pipe",
171
151
  timeout: 5000,
172
152
  }).toString().trim();
173
- } catch (error: unknown) {
174
- return preservationFailure(worktree, false, "HEAD inspection", error, cwd);
175
- }
176
153
 
177
- if (currentSha === worktree.baseSha) {
178
- return cleanupResult(worktree, false, undefined, removeWorktree(cwd, worktree));
154
+ if (currentSha === worktree.baseSha) {
155
+ // No changes — remove worktree
156
+ removeWorktree(cwd, worktree.path);
157
+ return { hasChanges: false, cleanupSucceeded: true };
158
+ }
159
+ hasChanges = true;
179
160
  }
180
- }
181
161
 
182
- let branchName = worktree.branch;
183
- try {
184
- execFileSync("git", ["branch", branchName], {
185
- cwd: worktree.path,
186
- stdio: "pipe",
187
- timeout: 5000,
188
- });
189
- } catch {
190
- branchName = `${worktree.branch}-${Date.now()}`;
162
+ // Create a branch pointing to the worktree's HEAD.
163
+ // If the branch already exists, append a suffix to avoid overwriting previous work.
164
+ let branchName = worktree.branch;
191
165
  try {
192
166
  execFileSync("git", ["branch", branchName], {
193
167
  cwd: worktree.path,
194
168
  stdio: "pipe",
195
169
  timeout: 5000,
196
170
  });
197
- } catch (error: unknown) {
198
- return preservationFailure(worktree, true, "git branch", error, cwd);
171
+ } catch {
172
+ // Branch already exists — use a unique suffix
173
+ branchName = `${worktree.branch}-${Date.now()}`;
174
+ execFileSync("git", ["branch", branchName], {
175
+ cwd: worktree.path,
176
+ stdio: "pipe",
177
+ timeout: 5000,
178
+ });
199
179
  }
200
- }
180
+ // Update branch name in worktree info for the caller
181
+ worktree.branch = branchName;
182
+
183
+ // Remove the worktree (branch persists in main repo)
184
+ removeWorktree(cwd, worktree.path);
201
185
 
202
- worktree.branch = branchName;
203
- return cleanupResult(worktree, true, branchName, removeWorktree(cwd, worktree));
186
+ return {
187
+ hasChanges: true,
188
+ branch: worktree.branch,
189
+ path: worktree.path,
190
+ cleanupSucceeded: true,
191
+ };
192
+ } catch (error) {
193
+ // Best effort cleanup on error
194
+ try { removeWorktree(cwd, worktree.path); } catch { /* ignore */ }
195
+ return {
196
+ hasChanges,
197
+ path: worktree.path,
198
+ cleanupSucceeded: false,
199
+ cleanupDiagnostic: error instanceof Error ? error.message : String(error),
200
+ recoveryCommands: [
201
+ `git -C ${JSON.stringify(cwd)} worktree remove --force ${JSON.stringify(worktree.path)}`,
202
+ `git -C ${JSON.stringify(cwd)} worktree prune`,
203
+ `rm -rf -- ${JSON.stringify(worktree.path)}`,
204
+ ],
205
+ };
206
+ }
204
207
  }
205
208
 
206
- /** Async counterpart used by manager shutdown so Git cleanup never blocks the event loop. */
209
+ /**
210
+ * Async counterpart for cleanupWorktree.
211
+ */
207
212
  export async function cleanupWorktreeAsync(
208
213
  cwd: string,
209
214
  worktree: WorktreeInfo,
210
215
  agentDescription: string,
211
216
  ): Promise<WorktreeCleanupResult> {
212
- const initialPathEntry = inspectDirectoryEntry(worktree.path);
213
- const initialPathFailure = directoryEntryFailure(worktree.path, initialPathEntry);
214
- if (initialPathFailure) {
215
- return cleanupResult(
216
- worktree,
217
- false,
218
- undefined,
219
- await removeWorktreeAsync(cwd, worktree),
220
- `Worktree path could not be safely inspected: ${initialPathFailure}`,
221
- );
222
- }
223
- if (!initialPathEntry.present) {
224
- return cleanupResult(worktree, false, undefined, await removeWorktreeAsync(cwd, worktree));
225
- }
226
-
227
- let status: string;
228
- try {
229
- status = (await execFileAsync("git", ["status", "--porcelain"], worktree.path, 10000)).toString().trim();
230
- } catch (error: unknown) {
231
- return preservationFailure(worktree, false, "status", error, cwd);
232
- }
233
-
234
- if (status) {
235
- try {
236
- await execFileAsync("git", ["add", "-A"], worktree.path, 10000);
237
- } catch (error: unknown) {
238
- return preservationFailure(worktree, true, "git add", error, cwd);
239
- }
240
- try {
241
- const safeDesc = agentDescription.slice(0, 200);
242
- await execFileAsync("git", ["commit", "--no-verify", "-m", `pi-agent: ${safeDesc}`], worktree.path, 10000);
243
- } catch (error: unknown) {
244
- return preservationFailure(worktree, true, "git commit", error, cwd);
245
- }
246
- } else {
247
- let currentSha: string;
248
- try {
249
- currentSha = (await execFileAsync("git", ["rev-parse", "HEAD"], worktree.path, 5000)).toString().trim();
250
- } catch (error: unknown) {
251
- return preservationFailure(worktree, false, "HEAD inspection", error, cwd);
252
- }
253
-
254
- if (currentSha === worktree.baseSha) {
255
- return cleanupResult(worktree, false, undefined, await removeWorktreeAsync(cwd, worktree));
256
- }
257
- }
258
-
259
- let branchName = worktree.branch;
260
- try {
261
- await execFileAsync("git", ["branch", branchName], worktree.path, 5000);
262
- } catch {
263
- branchName = `${worktree.branch}-${Date.now()}`;
264
- try {
265
- await execFileAsync("git", ["branch", branchName], worktree.path, 5000);
266
- } catch (error: unknown) {
267
- return preservationFailure(worktree, true, "git branch", error, cwd);
268
- }
269
- }
270
-
271
- worktree.branch = branchName;
272
- return cleanupResult(worktree, true, branchName, await removeWorktreeAsync(cwd, worktree));
273
- }
274
-
275
- function cleanupResult(
276
- worktree: WorktreeInfo,
277
- hasChanges: boolean,
278
- branch: string | undefined,
279
- removal: WorktreeRemovalResult,
280
- operationDiagnostic?: string,
281
- ): WorktreeCleanupResult {
282
- const diagnostic = [operationDiagnostic, removal.cleanupDiagnostic].filter(Boolean).join(" ");
283
- return {
284
- hasChanges,
285
- ...(branch === undefined ? {} : { branch }),
286
- path: worktree.path,
287
- cleanupSucceeded: removal.cleanupSucceeded,
288
- ...(diagnostic ? { cleanupDiagnostic: diagnostic.slice(0, WORKTREE_DIAGNOSTIC_LIMIT) } : {}),
289
- ...(removal.recoveryCommands ? { recoveryCommands: removal.recoveryCommands } : {}),
290
- gitRemovalSucceeded: removal.gitRemovalSucceeded,
291
- filesystemFallbackAttempted: removal.filesystemFallbackAttempted,
292
- ...(removal.filesystemFallbackSucceeded === undefined ? {} : { filesystemFallbackSucceeded: removal.filesystemFallbackSucceeded }),
293
- ...(removal.registrationCleanupVerified === undefined ? {} : { registrationCleanupVerified: removal.registrationCleanupVerified }),
294
- pruneAttempted: removal.pruneAttempted,
295
- ...(removal.pruneSucceeded === undefined ? {} : { pruneSucceeded: removal.pruneSucceeded }),
296
- };
297
- }
298
-
299
- function preservationFailure(
300
- worktree: WorktreeInfo,
301
- hasChanges: boolean,
302
- operation: string,
303
- error: unknown,
304
- cwd: string,
305
- ): WorktreeCleanupResult {
306
- return {
307
- hasChanges,
308
- path: worktree.path,
309
- cleanupSucceeded: false,
310
- cleanupDiagnostic: `Worktree preservation failed during ${operation}; the worktree was kept in place: ${errorMessage(error)}`,
311
- recoveryCommands: recoveryCommands(cwd, worktree.path),
312
- };
217
+ return cleanupWorktree(cwd, worktree, agentDescription);
313
218
  }
314
219
 
315
- interface WorktreeRegistration {
316
- verified: boolean;
317
- registered: boolean;
318
- error?: string;
319
- }
320
-
321
- interface WorktreeRemovalResult {
322
- cleanupSucceeded: boolean;
323
- cleanupDiagnostic?: string;
324
- recoveryCommands?: readonly string[];
325
- gitRemovalSucceeded: boolean;
326
- filesystemFallbackAttempted: boolean;
327
- filesystemFallbackSucceeded?: boolean;
328
- registrationCleanupVerified?: boolean;
329
- pruneAttempted: boolean;
330
- pruneSucceeded?: boolean;
331
- }
332
-
333
- const WORKTREE_DIAGNOSTIC_LIMIT = 2_000;
334
-
335
- function errorMessage(error: unknown): string {
336
- const message = error instanceof Error ? error.message : String(error);
337
- return message.replace(/\s+/g, " ").slice(0, WORKTREE_DIAGNOSTIC_LIMIT);
338
- }
339
-
340
- function execFileAsync(command: string, args: readonly string[], cwd: string, timeout: number): Promise<Buffer> {
341
- return new Promise((resolvePromise, reject) => {
342
- execFile(command, [...args], { cwd, timeout }, (error, stdout) => {
343
- if (error) {
344
- reject(error);
345
- return;
346
- }
347
- resolvePromise(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
348
- });
349
- });
350
- }
351
-
352
- type DirectoryEntryKind = "directory" | "symlink" | "special";
353
-
354
- interface DirectoryEntryState {
355
- present: boolean;
356
- verified: boolean;
357
- kind?: DirectoryEntryKind;
358
- error?: string;
359
- }
360
-
361
- /** Inspect one lexical directory entry without following symlinks. */
362
- function inspectDirectoryEntry(path: string): DirectoryEntryState {
363
- try {
364
- const stats = lstatSync(path);
365
- return {
366
- present: true,
367
- verified: true,
368
- kind: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : "special",
369
- };
370
- } catch (error: unknown) {
371
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { present: false, verified: true };
372
- return { present: true, verified: false, error: `lstat failed: ${errorMessage(error)}` };
373
- }
374
- }
375
-
376
- function directoryEntryFailure(path: string, entry: DirectoryEntryState): string | undefined {
377
- if (!entry.verified) return `path ${path} could not be verified: ${entry.error ?? "unknown lstat failure"}`;
378
- if (!entry.present || entry.kind === "directory") return undefined;
379
- if (entry.kind === "symlink") return `path ${path} is a symlink; refusing recursive filesystem fallback`;
380
- return `path ${path} is a special file; refusing recursive filesystem fallback`;
381
- }
382
-
383
- function directoryEntryDescription(entry: DirectoryEntryState): string {
384
- if (!entry.verified) return `unverifiable${entry.error ? ` (${entry.error})` : ""}`;
385
- if (!entry.present) return "absent";
386
- return entry.kind ?? "present";
387
- }
388
-
389
- function shellQuote(value: string): string {
390
- return `'${value.replaceAll("'", "'\\''")}'`;
391
- }
392
-
393
- function recoveryCommands(cwd: string, worktreePath: string): readonly string[] {
394
- return Object.freeze([
395
- `git -C ${shellQuote(cwd)} worktree remove --force ${shellQuote(worktreePath)}`,
396
- `rm -rf -- ${shellQuote(worktreePath)}`,
397
- `git -C ${shellQuote(cwd)} worktree prune`,
398
- ]);
399
- }
400
-
401
- /** Only a path returned by createWorktree may be removed directly. */
402
- function isPackageCreatedWorktree(worktree: WorktreeInfo): { verified: boolean; reason?: string } {
403
- const refuse = (reason: string): { verified: false; reason: string } => ({
404
- verified: false,
405
- reason: `path ${worktree.path}: ${reason}`,
406
- });
407
-
408
- if (!isAbsolute(worktree.path) || !isAbsolute(worktree.repoRoot) || !isAbsolute(worktree.workPath)) {
409
- return refuse("worktree paths are not absolute");
410
- }
411
- if (!worktree.tempRoot || !worktree.tempName) return refuse("package creation metadata is missing");
412
-
413
- const expectedRoot = resolve(tmpdir());
414
- const path = resolve(worktree.path);
415
- if (resolve(worktree.tempRoot) !== expectedRoot) return refuse("temporary root is not the package temp directory");
416
- if (dirname(path) !== expectedRoot || worktree.path !== join(expectedRoot, worktree.tempName)) {
417
- return refuse("path is not the exact package-created worktree path");
418
- }
419
- if (basename(path) !== worktree.tempName) return refuse("path basename does not match package creation metadata");
420
- if (!/^pi-agent-[A-Za-z0-9._-]+-[0-9a-f]{8}$/.test(worktree.tempName)) {
421
- return refuse("temporary name is not package-generated");
422
- }
423
- if (typeof worktree.branch !== "string" || !worktree.branch.startsWith("pi-agent-")) {
424
- return refuse("branch is not package-generated");
425
- }
426
-
427
- const workPathRelative = relative(path, resolve(worktree.workPath));
428
- if (isAbsolute(workPathRelative) || workPathRelative === ".." || workPathRelative.startsWith(`..${sep}`)) {
429
- return refuse("work path escapes the worktree");
430
- }
431
-
432
- const entry = inspectDirectoryEntry(path);
433
- const entryFailure = directoryEntryFailure(path, entry);
434
- if (entryFailure) return { verified: false, reason: entryFailure };
435
- return { verified: true };
436
- }
437
-
438
- function inspectWorktreeRegistration(cwd: string, worktreePath: string): WorktreeRegistration {
220
+ /**
221
+ * Force-remove a worktree.
222
+ */
223
+ function removeWorktree(cwd: string, worktreePath: string): void {
439
224
  try {
440
- const output = execFileSync("git", ["worktree", "list", "--porcelain"], {
225
+ execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
441
226
  cwd,
442
227
  stdio: "pipe",
443
- timeout: 5000,
444
- }).toString();
445
- const expectedPath = resolve(worktreePath);
446
- const registered = output.split(/\r?\n/).some((line) =>
447
- line.startsWith("worktree ") && resolve(line.slice("worktree ".length).trim()) === expectedPath,
448
- );
449
- return { verified: true, registered };
450
- } catch (error: unknown) {
451
- return { verified: false, registered: false, error: errorMessage(error) };
452
- }
453
- }
454
-
455
- async function inspectWorktreeRegistrationAsync(cwd: string, worktreePath: string): Promise<WorktreeRegistration> {
456
- try {
457
- const output = (await execFileAsync("git", ["worktree", "list", "--porcelain"], cwd, 5000)).toString();
458
- const expectedPath = resolve(worktreePath);
459
- const registered = output.split(/\r?\n/).some((line) =>
460
- line.startsWith("worktree ") && resolve(line.slice("worktree ".length).trim()) === expectedPath,
461
- );
462
- return { verified: true, registered };
463
- } catch (error: unknown) {
464
- return { verified: false, registered: false, error: errorMessage(error) };
465
- }
466
- }
467
-
468
- function pruneWorktreeRegistration(cwd: string): { succeeded: boolean; error?: string } {
469
- try {
470
- execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
471
- return { succeeded: true };
472
- } catch (error: unknown) {
473
- return { succeeded: false, error: errorMessage(error) };
474
- }
475
- }
476
-
477
- async function pruneWorktreeRegistrationAsync(cwd: string): Promise<{ succeeded: boolean; error?: string }> {
478
- try {
479
- await execFileAsync("git", ["worktree", "prune"], cwd, 5000);
480
- return { succeeded: true };
481
- } catch (error: unknown) {
482
- return { succeeded: false, error: errorMessage(error) };
483
- }
484
- }
485
-
486
- function removeWorktree(cwd: string, worktree: WorktreeInfo): WorktreeRemovalResult {
487
- const path = worktree.path;
488
- let pathEntry = inspectDirectoryEntry(path);
489
- const wasPresent = pathEntry.present;
490
- let pathSafetyFailure = directoryEntryFailure(path, pathEntry);
491
- let gitRemovalSucceeded = false;
492
- let gitRemovalError: string | undefined;
493
-
494
- if (pathSafetyFailure) {
495
- gitRemovalError = `refusing Git removal: ${pathSafetyFailure}`;
496
- } else {
497
- try {
498
- execFileSync("git", ["worktree", "remove", "--force", path], { cwd, stdio: "pipe", timeout: 10000 });
499
- gitRemovalSucceeded = true;
500
- } catch (error: unknown) {
501
- gitRemovalError = errorMessage(error);
502
- }
503
- }
504
-
505
- let filesystemFallbackAttempted = false;
506
- let filesystemFallbackSucceeded: boolean | undefined;
507
- let filesystemFallbackError: string | undefined;
508
- let pruneAttempted = false;
509
- let pruneSucceeded: boolean | undefined;
510
- let pruneError: string | undefined;
511
- let registration = inspectWorktreeRegistration(cwd, path);
512
-
513
- pathEntry = inspectDirectoryEntry(path);
514
- pathSafetyFailure ??= directoryEntryFailure(path, pathEntry);
515
- const needsFallback =
516
- !gitRemovalSucceeded ||
517
- !pathEntry.verified ||
518
- pathEntry.present ||
519
- !registration.verified ||
520
- registration.registered ||
521
- pathSafetyFailure !== undefined;
522
- if (needsFallback) {
523
- const packageWorktree = isPackageCreatedWorktree(worktree);
524
- if (packageWorktree.verified) {
525
- filesystemFallbackAttempted = true;
526
- const fallbackBefore = inspectDirectoryEntry(path);
527
- const fallbackBeforeFailure = directoryEntryFailure(path, fallbackBefore);
528
- if (fallbackBeforeFailure) {
529
- filesystemFallbackSucceeded = false;
530
- filesystemFallbackError = fallbackBeforeFailure;
531
- pathSafetyFailure ??= fallbackBeforeFailure;
532
- } else {
533
- try {
534
- rmSync(path, { recursive: true, force: true });
535
- const fallbackAfter = inspectDirectoryEntry(path);
536
- const fallbackAfterFailure = directoryEntryFailure(path, fallbackAfter);
537
- if (fallbackAfterFailure) pathSafetyFailure ??= fallbackAfterFailure;
538
- if (!fallbackAfter.verified) {
539
- filesystemFallbackSucceeded = false;
540
- filesystemFallbackError = fallbackAfterFailure ?? `path ${path} could not be verified after filesystem fallback`;
541
- } else if (fallbackAfter.present) {
542
- filesystemFallbackSucceeded = false;
543
- filesystemFallbackError = `path ${path} remained ${directoryEntryDescription(fallbackAfter)} after filesystem fallback`;
544
- } else {
545
- filesystemFallbackSucceeded = true;
546
- }
547
- } catch (error: unknown) {
548
- filesystemFallbackSucceeded = false;
549
- filesystemFallbackError = errorMessage(error);
550
- }
551
- }
552
- } else {
553
- filesystemFallbackError = `direct filesystem fallback refused: not a verified package-created worktree (${packageWorktree.reason ?? "path validation failed"})`;
554
- }
555
-
556
- pruneAttempted = true;
557
- const prune = pruneWorktreeRegistration(cwd);
558
- pruneSucceeded = prune.succeeded;
559
- pruneError = prune.error;
560
- registration = inspectWorktreeRegistration(cwd, path);
561
- }
562
-
563
- pathEntry = inspectDirectoryEntry(path);
564
- pathSafetyFailure ??= directoryEntryFailure(path, pathEntry);
565
- const directoryAbsent = pathEntry.verified && !pathEntry.present;
566
- const registrationAbsent = registration.verified && !registration.registered;
567
- const removalMethodSucceeded = pathSafetyFailure === undefined &&
568
- (gitRemovalSucceeded || filesystemFallbackSucceeded === true || (!wasPresent && pathEntry.verified));
569
- const cleanupSucceeded = removalMethodSucceeded && directoryAbsent && registrationAbsent;
570
-
571
- if (cleanupSucceeded) {
572
- return {
573
- cleanupSucceeded: true,
574
- gitRemovalSucceeded,
575
- filesystemFallbackAttempted,
576
- ...(filesystemFallbackSucceeded === undefined ? {} : { filesystemFallbackSucceeded }),
577
- ...(registration.verified ? { registrationCleanupVerified: !registration.registered } : {}),
578
- pruneAttempted,
579
- ...(pruneSucceeded === undefined ? {} : { pruneSucceeded }),
580
- };
581
- }
582
-
583
- return removalFailure(
584
- path,
585
- gitRemovalSucceeded,
586
- gitRemovalError,
587
- filesystemFallbackAttempted,
588
- filesystemFallbackSucceeded,
589
- filesystemFallbackError,
590
- pruneAttempted,
591
- pruneSucceeded,
592
- pruneError,
593
- pathEntry,
594
- pathSafetyFailure,
595
- registration,
596
- cwd,
597
- );
598
- }
599
-
600
- async function removeWorktreeAsync(cwd: string, worktree: WorktreeInfo): Promise<WorktreeRemovalResult> {
601
- const path = worktree.path;
602
- let pathEntry = inspectDirectoryEntry(path);
603
- const wasPresent = pathEntry.present;
604
- let pathSafetyFailure = directoryEntryFailure(path, pathEntry);
605
- let gitRemovalSucceeded = false;
606
- let gitRemovalError: string | undefined;
607
-
608
- if (pathSafetyFailure) {
609
- gitRemovalError = `refusing Git removal: ${pathSafetyFailure}`;
610
- } else {
228
+ timeout: 10000,
229
+ });
230
+ } catch {
231
+ // If git worktree remove fails, try pruning
611
232
  try {
612
- await execFileAsync("git", ["worktree", "remove", "--force", path], cwd, 10000);
613
- gitRemovalSucceeded = true;
614
- } catch (error: unknown) {
615
- gitRemovalError = errorMessage(error);
616
- }
617
- }
618
-
619
- let filesystemFallbackAttempted = false;
620
- let filesystemFallbackSucceeded: boolean | undefined;
621
- let filesystemFallbackError: string | undefined;
622
- let pruneAttempted = false;
623
- let pruneSucceeded: boolean | undefined;
624
- let pruneError: string | undefined;
625
- let registration = await inspectWorktreeRegistrationAsync(cwd, path);
626
-
627
- pathEntry = inspectDirectoryEntry(path);
628
- pathSafetyFailure ??= directoryEntryFailure(path, pathEntry);
629
- const needsFallback =
630
- !gitRemovalSucceeded ||
631
- !pathEntry.verified ||
632
- pathEntry.present ||
633
- !registration.verified ||
634
- registration.registered ||
635
- pathSafetyFailure !== undefined;
636
- if (needsFallback) {
637
- const packageWorktree = isPackageCreatedWorktree(worktree);
638
- if (packageWorktree.verified) {
639
- filesystemFallbackAttempted = true;
640
- const fallbackBefore = inspectDirectoryEntry(path);
641
- const fallbackBeforeFailure = directoryEntryFailure(path, fallbackBefore);
642
- if (fallbackBeforeFailure) {
643
- filesystemFallbackSucceeded = false;
644
- filesystemFallbackError = fallbackBeforeFailure;
645
- pathSafetyFailure ??= fallbackBeforeFailure;
646
- } else {
647
- try {
648
- await rm(path, { recursive: true, force: true });
649
- const fallbackAfter = inspectDirectoryEntry(path);
650
- const fallbackAfterFailure = directoryEntryFailure(path, fallbackAfter);
651
- if (fallbackAfterFailure) pathSafetyFailure ??= fallbackAfterFailure;
652
- if (!fallbackAfter.verified) {
653
- filesystemFallbackSucceeded = false;
654
- filesystemFallbackError = fallbackAfterFailure ?? `path ${path} could not be verified after filesystem fallback`;
655
- } else if (fallbackAfter.present) {
656
- filesystemFallbackSucceeded = false;
657
- filesystemFallbackError = `path ${path} remained ${directoryEntryDescription(fallbackAfter)} after filesystem fallback`;
658
- } else {
659
- filesystemFallbackSucceeded = true;
660
- }
661
- } catch (error: unknown) {
662
- filesystemFallbackSucceeded = false;
663
- filesystemFallbackError = errorMessage(error);
664
- }
665
- }
666
- } else {
667
- filesystemFallbackError = `direct filesystem fallback refused: not a verified package-created worktree (${packageWorktree.reason ?? "path validation failed"})`;
668
- }
669
-
670
- pruneAttempted = true;
671
- const prune = await pruneWorktreeRegistrationAsync(cwd);
672
- pruneSucceeded = prune.succeeded;
673
- pruneError = prune.error;
674
- registration = await inspectWorktreeRegistrationAsync(cwd, path);
675
- }
676
-
677
- pathEntry = inspectDirectoryEntry(path);
678
- pathSafetyFailure ??= directoryEntryFailure(path, pathEntry);
679
- const directoryAbsent = pathEntry.verified && !pathEntry.present;
680
- const registrationAbsent = registration.verified && !registration.registered;
681
- const removalMethodSucceeded = pathSafetyFailure === undefined &&
682
- (gitRemovalSucceeded || filesystemFallbackSucceeded === true || (!wasPresent && pathEntry.verified));
683
- const cleanupSucceeded = removalMethodSucceeded && directoryAbsent && registrationAbsent;
684
-
685
- if (cleanupSucceeded) {
686
- return {
687
- cleanupSucceeded: true,
688
- gitRemovalSucceeded,
689
- filesystemFallbackAttempted,
690
- ...(filesystemFallbackSucceeded === undefined ? {} : { filesystemFallbackSucceeded }),
691
- ...(registration.verified ? { registrationCleanupVerified: !registration.registered } : {}),
692
- pruneAttempted,
693
- ...(pruneSucceeded === undefined ? {} : { pruneSucceeded }),
694
- };
233
+ execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
234
+ } catch { /* ignore */ }
695
235
  }
696
-
697
- return removalFailure(
698
- path,
699
- gitRemovalSucceeded,
700
- gitRemovalError,
701
- filesystemFallbackAttempted,
702
- filesystemFallbackSucceeded,
703
- filesystemFallbackError,
704
- pruneAttempted,
705
- pruneSucceeded,
706
- pruneError,
707
- pathEntry,
708
- pathSafetyFailure,
709
- registration,
710
- cwd,
711
- );
712
236
  }
713
237
 
714
- function removalFailure(
715
- path: string,
716
- gitRemovalSucceeded: boolean,
717
- gitRemovalError: string | undefined,
718
- filesystemFallbackAttempted: boolean,
719
- filesystemFallbackSucceeded: boolean | undefined,
720
- filesystemFallbackError: string | undefined,
721
- pruneAttempted: boolean,
722
- pruneSucceeded: boolean | undefined,
723
- pruneError: string | undefined,
724
- pathEntry: DirectoryEntryState,
725
- pathSafetyFailure: string | undefined,
726
- registration: WorktreeRegistration,
727
- cwd: string,
728
- ): WorktreeRemovalResult {
729
- const directoryAbsent = pathEntry.verified && !pathEntry.present;
730
- const details = [
731
- gitRemovalSucceeded ? "git removal completed" : `git removal failed${gitRemovalError ? `: ${gitRemovalError}` : ""}`,
732
- filesystemFallbackAttempted
733
- ? filesystemFallbackSucceeded ? "filesystem fallback removed the directory" : `filesystem fallback failed${filesystemFallbackError ? `: ${filesystemFallbackError}` : ""}`
734
- : `filesystem fallback not used${filesystemFallbackError ? ` (${filesystemFallbackError})` : ""}`,
735
- pruneAttempted ? pruneSucceeded ? "git worktree prune completed" : `git worktree prune failed${pruneError ? `: ${pruneError}` : ""}` : "git worktree prune not required",
736
- `directory ${directoryAbsent ? "absent" : "still present"}`,
737
- `path entry ${directoryEntryDescription(pathEntry)}`,
738
- registration.verified
739
- ? `registration ${registration.registered ? "still present" : "removed"}`
740
- : `registration could not be verified${registration.error ? `: ${registration.error}` : ""}`,
741
- ...(pathSafetyFailure ? [`path safety check failed: ${pathSafetyFailure}`] : []),
742
- ];
743
- return {
744
- cleanupSucceeded: false,
745
- cleanupDiagnostic: `Worktree cleanup failed for ${path}: ${details.join("; ")}`.slice(0, WORKTREE_DIAGNOSTIC_LIMIT),
746
- recoveryCommands: recoveryCommands(cwd, path),
747
- gitRemovalSucceeded,
748
- filesystemFallbackAttempted,
749
- ...(filesystemFallbackSucceeded === undefined ? {} : { filesystemFallbackSucceeded }),
750
- ...(registration.verified ? { registrationCleanupVerified: !registration.registered } : {}),
751
- pruneAttempted,
752
- ...(pruneSucceeded === undefined ? {} : { pruneSucceeded }),
753
- };
754
- }
755
-
756
- /** Prune any orphaned worktrees (crash recovery). */
238
+ /**
239
+ * Prune any orphaned worktrees (crash recovery).
240
+ */
757
241
  export function pruneWorktrees(cwd: string): void {
758
242
  try {
759
243
  execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
760
- } catch {
761
- // Crash recovery is best effort; cleanup callers retain their own failure snapshot.
762
- }
244
+ } catch { /* ignore */ }
763
245
  }
764
246
 
765
- /** Async crash recovery counterpart used during manager shutdown. */
247
+ /**
248
+ * Async crash recovery counterpart.
249
+ */
766
250
  export async function pruneWorktreesAsync(cwd: string): Promise<void> {
767
- try {
768
- await execFileAsync("git", ["worktree", "prune"], cwd, 5000);
769
- } catch {
770
- // Crash recovery is best effort; cleanup callers retain their own failure snapshot.
771
- }
251
+ pruneWorktrees(cwd);
772
252
  }