@virtengine/openfleet 0.25.0

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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,1266 @@
1
+ /**
2
+ * worktree-manager.mjs — Centralized git worktree lifecycle management.
3
+ *
4
+ * Replaces scattered worktree operations across monitor.mjs, vk-error-resolver.mjs,
5
+ * maintenance.mjs, and git-editor-fix.mjs with a single, consistent API.
6
+ *
7
+ * Features:
8
+ * - acquire/release worktrees linked to task keys
9
+ * - find existing worktrees by branch name
10
+ * - automatic cleanup of stale/orphaned worktrees
11
+ * - consistent git env (GIT_EDITOR, GIT_MERGE_AUTOEDIT)
12
+ * - in-memory registry with disk persistence
13
+ * - thread registry integration for agent <-> worktree linkage
14
+ */
15
+
16
+ import { spawnSync, execSync } from "node:child_process";
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ writeFileSync,
21
+ rmSync,
22
+ statSync,
23
+ readdirSync,
24
+ } from "node:fs";
25
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
26
+ import { resolve } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ // ── Path Setup ──────────────────────────────────────────────────────────────
30
+
31
+ const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
32
+
33
+ // ── Types ───────────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * @typedef {Object} WorktreeRecord
37
+ * @property {string} path Absolute path to the worktree directory
38
+ * @property {string} branch Branch checked out in the worktree
39
+ * @property {string} taskKey Task key linking to thread registry (optional)
40
+ * @property {number} createdAt Unix ms timestamp
41
+ * @property {number} lastUsedAt Unix ms timestamp
42
+ * @property {string} status "active" | "releasing" | "stale"
43
+ * @property {string|null} owner Who created it: "monitor", "error-resolver", "merge-strategy", "manual"
44
+ */
45
+
46
+ // ── Constants ───────────────────────────────────────────────────────────────
47
+
48
+ const TAG = "[worktree-manager]";
49
+ const DEFAULT_BASE_DIR = ".cache/worktrees";
50
+ const REGISTRY_FILE = resolve(__dirname, "logs", "worktree-registry.json");
51
+ const MAX_WORKTREE_AGE_MS = 12 * 60 * 60 * 1000; // 12 hours
52
+ const COPILOT_WORKTREE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (existing policy)
53
+ const GIT_ENV = {
54
+ GIT_EDITOR: ":",
55
+ GIT_MERGE_AUTOEDIT: "no",
56
+ GIT_TERMINAL_PROMPT: "0",
57
+ };
58
+
59
+ /**
60
+ * Guard against git config corruption caused by worktree operations.
61
+ * Some git versions on Windows set core.bare=true on the main repo when
62
+ * adding worktrees, which conflicts with core.worktree and breaks git.
63
+ * This function cleans up those settings after every worktree operation.
64
+ * @param {string} repoRoot - Path to the main repository root
65
+ */
66
+ function fixGitConfigCorruption(repoRoot) {
67
+ try {
68
+ const bareResult = spawnSync("git", ["config", "--local", "core.bare"], {
69
+ cwd: repoRoot,
70
+ encoding: "utf8",
71
+ timeout: 5000,
72
+ env: { ...process.env, ...GIT_ENV },
73
+ });
74
+ if (bareResult.stdout?.trim() === "true") {
75
+ console.warn(
76
+ `${TAG} ⚠️ Detected core.bare=true on main repo — fixing git config corruption`,
77
+ );
78
+ spawnSync("git", ["config", "--unset", "core.bare"], {
79
+ cwd: repoRoot,
80
+ encoding: "utf8",
81
+ timeout: 5000,
82
+ env: { ...process.env, ...GIT_ENV },
83
+ });
84
+ }
85
+ } catch {
86
+ /* best-effort — don't crash on config repair */
87
+ }
88
+ }
89
+
90
+ // ── Helpers ─────────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Get age in milliseconds from filesystem mtime.
94
+ * Used as fallback when no registry entry exists for a worktree.
95
+ * @param {string} dirPath
96
+ * @returns {number} Age in ms, or Infinity if path cannot be stat'd
97
+ */
98
+ function _getFilesystemAgeMs(dirPath) {
99
+ try {
100
+ const stat = statSync(dirPath);
101
+ return Date.now() - stat.mtimeMs;
102
+ } catch {
103
+ return Infinity;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Sanitize a branch name into a filesystem-safe directory name.
109
+ * Replaces `/` with `-`, strips characters that are unsafe on Windows or Unix.
110
+ * @param {string} branch
111
+ * @returns {string}
112
+ */
113
+ function sanitizeBranchName(branch) {
114
+ return branch
115
+ .replace(/^refs\/heads\//, "")
116
+ .replace(/\//g, "-")
117
+ .replace(/[^a-zA-Z0-9._-]/g, "")
118
+ .replace(/^\.+/, "") // no leading dots
119
+ .replace(/\.+$/, "") // no trailing dots
120
+ .slice(0, 60); // Windows MAX_PATH is 260, worktree base path ~60, leaves ~140 for this + git overhead
121
+ }
122
+
123
+ /**
124
+ * Build the env object for all git subprocess calls.
125
+ * @returns {NodeJS.ProcessEnv}
126
+ */
127
+ function gitEnv() {
128
+ return { ...process.env, ...GIT_ENV };
129
+ }
130
+
131
+ /**
132
+ * Run a git command synchronously with consistent options.
133
+ * @param {string[]} args git arguments
134
+ * @param {string} cwd working directory
135
+ * @param {object} [opts]
136
+ * @param {number} [opts.timeout=30000]
137
+ * @returns {import("node:child_process").SpawnSyncReturns<string>}
138
+ */
139
+ function gitSync(args, cwd, opts = {}) {
140
+ return spawnSync("git", args, {
141
+ cwd,
142
+ encoding: "utf8",
143
+ timeout: opts.timeout ?? 30_000,
144
+ windowsHide: true,
145
+ env: gitEnv(),
146
+ stdio: ["ignore", "pipe", "pipe"],
147
+ // Avoid shell invocation to prevent Node DEP0190 warnings and argument
148
+ // concatenation risks.
149
+ shell: false,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Resolve the Git top-level directory for a candidate path.
155
+ * Returns null when the candidate is not inside a git worktree.
156
+ *
157
+ * @param {string} candidatePath
158
+ * @returns {string|null}
159
+ */
160
+ function detectGitTopLevel(candidatePath) {
161
+ if (!candidatePath) return null;
162
+ try {
163
+ const result = gitSync(
164
+ ["rev-parse", "--show-toplevel"],
165
+ resolve(candidatePath),
166
+ { timeout: 5000 },
167
+ );
168
+ if (result.status !== 0) return null;
169
+ const topLevel = String(result.stdout || "").trim();
170
+ return topLevel ? resolve(topLevel) : null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Resolve the best repository root for singleton initialization.
178
+ * Priority:
179
+ * 1) explicit repoRoot arg
180
+ * 2) VE_REPO_ROOT / CODEX_MONITOR_REPO_ROOT env
181
+ * 3) current working directory's git top-level
182
+ * 4) module-relative git top-level (useful for local dev)
183
+ * 5) process.cwd() fallback
184
+ *
185
+ * @param {string|undefined} repoRoot
186
+ * @returns {string}
187
+ */
188
+ function resolveDefaultRepoRoot(repoRoot) {
189
+ if (repoRoot) return resolve(repoRoot);
190
+
191
+ const envRoot =
192
+ process.env.VE_REPO_ROOT || process.env.CODEX_MONITOR_REPO_ROOT || "";
193
+ const fromEnv = detectGitTopLevel(envRoot) || (envRoot ? resolve(envRoot) : null);
194
+ if (fromEnv) return fromEnv;
195
+
196
+ const fromCwd = detectGitTopLevel(process.cwd());
197
+ if (fromCwd) return fromCwd;
198
+
199
+ const moduleRelativeCandidate = resolve(__dirname, "..", "..");
200
+ const fromModule = detectGitTopLevel(moduleRelativeCandidate);
201
+ if (fromModule) return fromModule;
202
+
203
+ return resolve(process.cwd());
204
+ }
205
+
206
+ /**
207
+ * Convert a Windows path to an extended-length path so long paths delete cleanly.
208
+ * @param {string} pathValue
209
+ * @returns {string}
210
+ */
211
+ function toWindowsExtendedPath(pathValue) {
212
+ if (process.platform !== "win32") return pathValue;
213
+ if (pathValue.startsWith("\\\\?\\")) return pathValue;
214
+ if (pathValue.startsWith("\\\\")) {
215
+ return `\\\\?\\UNC\\${pathValue.slice(2)}`;
216
+ }
217
+ return `\\\\?\\${pathValue}`;
218
+ }
219
+
220
+ /**
221
+ * Escape a string for use as a PowerShell single-quoted literal.
222
+ * @param {string} value
223
+ * @returns {string}
224
+ */
225
+ function escapePowerShellLiteral(value) {
226
+ return String(value).replace(/'/g, "''");
227
+ }
228
+
229
+ /**
230
+ * Remove a path on Windows using PowerShell, with optional attribute cleanup.
231
+ * Uses extended-length paths to avoid MAX_PATH errors.
232
+ * @param {string} targetPath
233
+ * @param {object} [opts]
234
+ * @param {boolean} [opts.clearAttributes=false]
235
+ * @param {number} [opts.timeoutMs=60000]
236
+ */
237
+ function removePathWithPowerShell(targetPath, opts = {}) {
238
+ const pwsh = process.env.PWSH_PATH || "powershell.exe";
239
+ const extendedPath = toWindowsExtendedPath(targetPath);
240
+ const escapedPath = escapePowerShellLiteral(extendedPath);
241
+ const clearAttributes = opts.clearAttributes === true;
242
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 60_000;
243
+ const preface = clearAttributes
244
+ ? "Get-ChildItem -LiteralPath '" +
245
+ escapedPath +
246
+ "' -Recurse -Force | ForEach-Object { $_.Attributes = 'Normal' } -ErrorAction SilentlyContinue; "
247
+ : "";
248
+ execSync(
249
+ `${pwsh} -NoProfile -Command "${preface}Remove-Item -LiteralPath '${escapedPath}' -Recurse -Force -ErrorAction Stop"`,
250
+ { timeout: timeoutMs, stdio: "pipe" },
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Remove a path synchronously, using PowerShell on Windows for long paths.
256
+ * @param {string} targetPath
257
+ * @param {object} [opts]
258
+ * @param {boolean} [opts.clearAttributes=false]
259
+ * @param {number} [opts.timeoutMs=60000]
260
+ */
261
+ function removePathSync(targetPath, opts = {}) {
262
+ if (!existsSync(targetPath)) return;
263
+ if (process.platform === "win32") {
264
+ removePathWithPowerShell(targetPath, opts);
265
+ return;
266
+ }
267
+ rmSync(targetPath, {
268
+ recursive: true,
269
+ force: true,
270
+ maxRetries: 5,
271
+ retryDelay: 1000,
272
+ });
273
+ }
274
+
275
+ // ── WorktreeManager Class ───────────────────────────────────────────────────
276
+
277
+ class WorktreeManager {
278
+ /**
279
+ * @param {string} repoRoot Absolute path to the repository root
280
+ * @param {object} [opts]
281
+ * @param {string} [opts.baseDir] Custom base directory for worktrees
282
+ */
283
+ constructor(repoRoot, opts = {}) {
284
+ this.repoRoot = resolve(repoRoot);
285
+ this.baseDir = resolve(repoRoot, opts.baseDir ?? DEFAULT_BASE_DIR);
286
+ /** @type {Map<string, WorktreeRecord>} keyed by taskKey (or auto-generated key) */
287
+ this.registry = new Map();
288
+ this._loaded = false;
289
+ }
290
+
291
+ // ── Registry Persistence ────────────────────────────────────────────────
292
+
293
+ /**
294
+ * Load the registry from disk, filtering out expired / missing entries.
295
+ */
296
+ async loadRegistry() {
297
+ if (this._loaded) return;
298
+ try {
299
+ const raw = await readFile(REGISTRY_FILE, "utf8");
300
+ const entries = JSON.parse(raw);
301
+ for (const [key, record] of Object.entries(entries)) {
302
+ // Skip entries that are far beyond max age
303
+ if (Date.now() - record.lastUsedAt > MAX_WORKTREE_AGE_MS * 2) continue;
304
+ // Verify path still exists on disk
305
+ if (!existsSync(record.path)) continue;
306
+ this.registry.set(key, record);
307
+ }
308
+ } catch {
309
+ // No registry yet or corrupt — start fresh
310
+ }
311
+ this._loaded = true;
312
+ }
313
+
314
+ /**
315
+ * Persist the current registry to disk.
316
+ */
317
+ async saveRegistry() {
318
+ try {
319
+ await mkdir(resolve(__dirname, "logs"), { recursive: true });
320
+ const obj = Object.fromEntries(this.registry);
321
+ await writeFile(REGISTRY_FILE, JSON.stringify(obj, null, 2), "utf8");
322
+ } catch {
323
+ // Non-critical — log dir may not be writable
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Synchronous variant of saveRegistry for use in cleanup paths.
329
+ */
330
+ saveRegistrySync() {
331
+ try {
332
+ mkdirSync(resolve(__dirname, "logs"), { recursive: true });
333
+ const obj = Object.fromEntries(this.registry);
334
+ writeFileSync(REGISTRY_FILE, JSON.stringify(obj, null, 2), "utf8");
335
+ } catch {
336
+ // Non-critical
337
+ }
338
+ }
339
+
340
+ // ── Core Operations ─────────────────────────────────────────────────────
341
+
342
+ /**
343
+ * Acquire a worktree for the given branch, creating it if needed.
344
+ *
345
+ * @param {string} branch Branch name (e.g. "ve/abc-fix-auth")
346
+ * @param {string} taskKey Task key for registry linkage
347
+ * @param {object} [opts]
348
+ * @param {string} [opts.owner] Who is acquiring ("monitor" | "error-resolver" | etc.)
349
+ * @param {string} [opts.baseBranch] Create the worktree from this base branch
350
+ * @returns {Promise<{ path: string, created: boolean, existing: boolean }>}
351
+ */
352
+ async acquireWorktree(branch, taskKey, opts = {}) {
353
+ await this.loadRegistry();
354
+
355
+ // 1. Check if a worktree already exists for this branch
356
+ const existingPath = this.findWorktreeForBranch(branch);
357
+ if (existingPath) {
358
+ // Update registry with the (possibly new) taskKey
359
+ const existingKey = this._findKeyByPath(existingPath);
360
+ if (existingKey && existingKey !== taskKey) {
361
+ // Transfer ownership
362
+ const record = this.registry.get(existingKey);
363
+ this.registry.delete(existingKey);
364
+ if (record) {
365
+ record.taskKey = taskKey;
366
+ record.lastUsedAt = Date.now();
367
+ record.owner = opts.owner ?? record.owner;
368
+ this.registry.set(taskKey, record);
369
+ }
370
+ } else if (!existingKey) {
371
+ // Not tracked — register it now
372
+ this.registry.set(taskKey, {
373
+ path: existingPath,
374
+ branch,
375
+ taskKey,
376
+ createdAt: Date.now(),
377
+ lastUsedAt: Date.now(),
378
+ status: "active",
379
+ owner: opts.owner ?? "manual",
380
+ });
381
+ } else {
382
+ // Same key — just update timestamp
383
+ const record = this.registry.get(taskKey);
384
+ if (record) {
385
+ record.lastUsedAt = Date.now();
386
+ }
387
+ }
388
+ await this.saveRegistry();
389
+ return { path: existingPath, created: false, existing: true };
390
+ }
391
+
392
+ // 2. Create a new worktree
393
+ const dirName = sanitizeBranchName(branch);
394
+ const worktreePath = resolve(this.baseDir, dirName);
395
+
396
+ // Ensure base directory exists
397
+ try {
398
+ mkdirSync(this.baseDir, { recursive: true });
399
+ } catch {
400
+ // May already exist
401
+ }
402
+
403
+ // Build git worktree add command
404
+ const args = ["worktree", "add", worktreePath];
405
+ if (opts.baseBranch) {
406
+ args.push("-b", branch, opts.baseBranch);
407
+ } else {
408
+ args.push(branch);
409
+ }
410
+
411
+ // Use extended timeout for large repos (7000+ files can take >120s on Windows)
412
+ const WT_TIMEOUT = 300_000;
413
+ let result = gitSync(args, this.repoRoot, { timeout: WT_TIMEOUT });
414
+
415
+ if (result.status !== 0) {
416
+ const stderr = (result.stderr || "").trim();
417
+ console.error(
418
+ `${TAG} Failed to create worktree for ${branch}: ${stderr}`,
419
+ );
420
+
421
+ // ── Branch or path already exists (from a prior run) ──
422
+ // `-b` fails because the branch ref already exists, OR
423
+ // the worktree directory itself already exists on disk.
424
+ if (stderr.includes("already exists")) {
425
+ console.warn(
426
+ `${TAG} branch/path "${branch}" already exists, attempting recovery`,
427
+ );
428
+ // Prune stale worktree refs first so the branch isn't considered "checked out"
429
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
430
+
431
+ // Remove stale worktree directory if it exists but isn't tracked by git
432
+ if (existsSync(worktreePath)) {
433
+ console.warn(
434
+ `${TAG} removing stale worktree directory: ${worktreePath}`,
435
+ );
436
+ try {
437
+ rmSync(worktreePath, { recursive: true, force: true });
438
+ } catch (rmErr) {
439
+ console.error(
440
+ `${TAG} failed to remove stale worktree dir: ${rmErr.message}`,
441
+ );
442
+ }
443
+ }
444
+
445
+ // Try checking out the existing branch into the new worktree (no -b)
446
+ // Use extended timeout for large repos (7000+ files can take >120s on Windows)
447
+ const existingResult = gitSync(
448
+ ["worktree", "add", worktreePath, branch],
449
+ this.repoRoot,
450
+ { timeout: WT_TIMEOUT },
451
+ );
452
+
453
+ if (existingResult.status !== 0) {
454
+ const stderr2 = (existingResult.stderr || "").trim();
455
+ if (
456
+ stderr2.includes("already checked out") ||
457
+ stderr2.includes("is already checked out") ||
458
+ stderr2.includes("is already used")
459
+ ) {
460
+ // Branch is checked out in another worktree — force-reset with -B
461
+ console.warn(
462
+ `${TAG} branch "${branch}" already checked out elsewhere, using -B to force-reset`,
463
+ );
464
+ const forceArgs = ["worktree", "add", worktreePath, "-B", branch];
465
+ if (opts.baseBranch) forceArgs.push(opts.baseBranch);
466
+ result = gitSync(forceArgs, this.repoRoot, { timeout: WT_TIMEOUT });
467
+ if (result.status !== 0) {
468
+ console.error(
469
+ `${TAG} Force-reset worktree also failed: ${(result.stderr || "").trim()}`,
470
+ );
471
+ // Clean up partial worktree directory to prevent repeat failures
472
+ this._cleanupPartialWorktree(worktreePath);
473
+ return { path: worktreePath, created: false, existing: false };
474
+ }
475
+ } else {
476
+ console.error(
477
+ `${TAG} Checkout of existing branch also failed: ${stderr2}`,
478
+ );
479
+ // Clean up partial worktree directory to prevent repeat failures
480
+ this._cleanupPartialWorktree(worktreePath);
481
+ return { path: worktreePath, created: false, existing: false };
482
+ }
483
+ }
484
+ // ── Branch already checked out in another worktree ──
485
+ } else if (
486
+ stderr.includes("already checked out") ||
487
+ stderr.includes("is already used")
488
+ ) {
489
+ const detachArgs = [
490
+ "worktree",
491
+ "add",
492
+ "--detach",
493
+ worktreePath,
494
+ branch,
495
+ ];
496
+ const retryResult = gitSync(detachArgs, this.repoRoot, {
497
+ timeout: WT_TIMEOUT,
498
+ });
499
+ if (retryResult.status !== 0) {
500
+ console.error(
501
+ `${TAG} Detached worktree also failed: ${(retryResult.stderr || "").trim()}`,
502
+ );
503
+ // Clean up partial worktree directory to prevent repeat failures
504
+ this._cleanupPartialWorktree(worktreePath);
505
+ return { path: worktreePath, created: false, existing: false };
506
+ }
507
+ } else {
508
+ // Unknown error — clean up any partial worktree directory
509
+ this._cleanupPartialWorktree(worktreePath);
510
+ return { path: worktreePath, created: false, existing: false };
511
+ }
512
+ }
513
+
514
+ // 2b. Guard against git config corruption after worktree operations.
515
+ // Some git versions on Windows set core.bare=true on the main repo
516
+ // when adding worktrees, which conflicts with core.worktree and breaks git.
517
+ fixGitConfigCorruption(this.repoRoot);
518
+
519
+ // 3. Register the new worktree
520
+ /** @type {WorktreeRecord} */
521
+ const record = {
522
+ path: worktreePath,
523
+ branch,
524
+ taskKey,
525
+ createdAt: Date.now(),
526
+ lastUsedAt: Date.now(),
527
+ status: "active",
528
+ owner: opts.owner ?? "manual",
529
+ };
530
+ this.registry.set(taskKey, record);
531
+ await this.saveRegistry();
532
+
533
+ console.log(`${TAG} Created worktree for ${branch} at ${worktreePath}`);
534
+ return { path: worktreePath, created: true, existing: false };
535
+ }
536
+
537
+ /**
538
+ * Release (remove) a worktree by its taskKey.
539
+ * @param {string} taskKey
540
+ * @returns {Promise<{ success: boolean, path: string|null }>}
541
+ */
542
+ async releaseWorktree(taskKey) {
543
+ await this.loadRegistry();
544
+ const record = this.registry.get(taskKey);
545
+ if (!record) {
546
+ return { success: false, path: null };
547
+ }
548
+ return this._removeWorktree(taskKey, record);
549
+ }
550
+
551
+ /**
552
+ * Release (remove) a worktree by its filesystem path.
553
+ * @param {string} path
554
+ * @returns {Promise<{ success: boolean, path: string|null }>}
555
+ */
556
+ async releaseWorktreeByPath(path) {
557
+ await this.loadRegistry();
558
+ const normalizedPath = resolve(path);
559
+ const key = this._findKeyByPath(normalizedPath);
560
+ if (!key) {
561
+ // Not in registry — try to remove directly
562
+ return this._forceRemoveWorktree(normalizedPath);
563
+ }
564
+ const record = this.registry.get(key);
565
+ return this._removeWorktree(key, record);
566
+ }
567
+
568
+ /**
569
+ * Release (remove) a worktree by its branch name.
570
+ * @param {string} branch
571
+ * @returns {Promise<{ success: boolean, path: string|null }>}
572
+ */
573
+ async releaseWorktreeByBranch(branch) {
574
+ await this.loadRegistry();
575
+ const key = this._findKeyByBranch(branch);
576
+ if (key) {
577
+ const record = this.registry.get(key);
578
+ return this._removeWorktree(key, record);
579
+ }
580
+ // Fallback: find via git and remove directly
581
+ const path = this.findWorktreeForBranch(branch);
582
+ if (path) {
583
+ return this._forceRemoveWorktree(path);
584
+ }
585
+ return { success: false, path: null };
586
+ }
587
+
588
+ // ── Discovery ───────────────────────────────────────────────────────────
589
+
590
+ /**
591
+ * Find the worktree path for a given branch by parsing `git worktree list --porcelain`.
592
+ * This replaces the scattered implementations in monitor.mjs and git-editor-fix.mjs.
593
+ *
594
+ * @param {string} branch Branch name (with or without refs/heads/ prefix)
595
+ * @returns {string|null} Absolute path to the worktree, or null
596
+ */
597
+ findWorktreeForBranch(branch) {
598
+ if (!branch) return null;
599
+ const normalizedBranch = branch.replace(/^refs\/heads\//, "");
600
+
601
+ try {
602
+ const result = gitSync(
603
+ ["worktree", "list", "--porcelain"],
604
+ this.repoRoot,
605
+ { timeout: 10_000 },
606
+ );
607
+ if (result.status !== 0 || !result.stdout) return null;
608
+
609
+ const lines = result.stdout.split("\n");
610
+ let currentPath = null;
611
+
612
+ for (const line of lines) {
613
+ if (line.startsWith("worktree ")) {
614
+ currentPath = line.slice(9).trim();
615
+ } else if (line.startsWith("branch ") && currentPath) {
616
+ const branchRef = line.slice(7).trim();
617
+ const branchName = branchRef.replace(/^refs\/heads\//, "");
618
+ if (branchName === normalizedBranch) {
619
+ return currentPath;
620
+ }
621
+ } else if (line.trim() === "") {
622
+ currentPath = null;
623
+ }
624
+ }
625
+ return null;
626
+ } catch {
627
+ return null;
628
+ }
629
+ }
630
+
631
+ /**
632
+ * List all worktrees known to git, enriched with registry metadata.
633
+ * @returns {Array<{ path: string, branch: string|null, taskKey: string|null, age: number, status: string, owner: string|null, isMainWorktree: boolean }>}
634
+ */
635
+ listAllWorktrees() {
636
+ /** @type {Array<{ path: string, branch: string|null, taskKey: string|null, age: number, status: string, owner: string|null, isMainWorktree: boolean }>} */
637
+ const worktrees = [];
638
+
639
+ try {
640
+ const result = gitSync(
641
+ ["worktree", "list", "--porcelain"],
642
+ this.repoRoot,
643
+ { timeout: 10_000 },
644
+ );
645
+ if (result.status !== 0 || !result.stdout) return worktrees;
646
+
647
+ // Parse porcelain output — blocks separated by blank lines
648
+ const blocks = result.stdout.split(/\n\n/).filter(Boolean);
649
+
650
+ for (const block of blocks) {
651
+ const pathMatch = block.match(/^worktree\s+(.+)/m);
652
+ if (!pathMatch) continue;
653
+ const wtPath = pathMatch[1].trim();
654
+
655
+ const branchMatch = block.match(/^branch\s+(.+)/m);
656
+ const branchRef = branchMatch ? branchMatch[1].trim() : null;
657
+ const branchName = branchRef
658
+ ? branchRef.replace(/^refs\/heads\//, "")
659
+ : null;
660
+
661
+ const isBare = /^bare$/m.test(block);
662
+ const isMainWorktree = wtPath === this.repoRoot || isBare;
663
+
664
+ // Look up in registry
665
+ const registryKey = this._findKeyByPath(resolve(wtPath));
666
+ const record = registryKey ? this.registry.get(registryKey) : null;
667
+
668
+ worktrees.push({
669
+ path: wtPath,
670
+ branch: branchName,
671
+ taskKey: record?.taskKey ?? null,
672
+ age: record ? Date.now() - record.createdAt : -1,
673
+ status: record?.status ?? (isMainWorktree ? "main" : "untracked"),
674
+ owner: record?.owner ?? null,
675
+ isMainWorktree,
676
+ });
677
+ }
678
+ } catch {
679
+ // Best effort
680
+ }
681
+
682
+ return worktrees;
683
+ }
684
+
685
+ /**
686
+ * List only worktrees that are tracked in the registry with "active" status.
687
+ * @returns {Array<{ path: string, branch: string|null, taskKey: string|null, age: number, status: string, owner: string|null, isMainWorktree: boolean }>}
688
+ */
689
+ listActiveWorktrees() {
690
+ const all = this.listAllWorktrees();
691
+ return all.filter((wt) => wt.status === "active" || wt.taskKey !== null);
692
+ }
693
+
694
+ // ── Maintenance ─────────────────────────────────────────────────────────
695
+
696
+ /**
697
+ * Prune stale and orphaned worktrees.
698
+ * This replaces `cleanupWorktrees()` from maintenance.mjs.
699
+ *
700
+ * @param {object} [opts]
701
+ * @param {boolean} [opts.dryRun=false] If true, log actions but don't delete
702
+ * @returns {Promise<{ pruned: number, evicted: number }>}
703
+ */
704
+ async pruneStaleWorktrees(opts = {}) {
705
+ await this.loadRegistry();
706
+ const dryRun = opts.dryRun ?? false;
707
+ let pruned = 0;
708
+ let evicted = 0;
709
+
710
+ // Step 1: git worktree prune (cleans up refs for deleted worktree dirs)
711
+ try {
712
+ if (!dryRun) {
713
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
714
+ }
715
+ console.log(
716
+ `${TAG} git worktree prune completed${dryRun ? " (dry-run)" : ""}`,
717
+ );
718
+ } catch (e) {
719
+ console.warn(`${TAG} git worktree prune failed: ${e.message}`);
720
+ }
721
+
722
+ // Step 2: Find VK / vibe-kanban worktrees older than MAX_WORKTREE_AGE_MS → remove
723
+ const allWorktrees = this.listAllWorktrees();
724
+
725
+ for (const wt of allWorktrees) {
726
+ if (wt.isMainWorktree) continue;
727
+
728
+ // Check vibe-kanban temp worktrees
729
+ const isVK =
730
+ wt.path.includes("vibe-kanban") ||
731
+ (wt.branch && wt.branch.startsWith("ve/"));
732
+
733
+ if (isVK) {
734
+ const registryKey = this._findKeyByPath(resolve(wt.path));
735
+ const record = registryKey ? this.registry.get(registryKey) : null;
736
+ const ageMs = record
737
+ ? Date.now() - record.lastUsedAt
738
+ : _getFilesystemAgeMs(wt.path);
739
+
740
+ // Prune if age exceeds threshold or path doesn't exist
741
+ if (ageMs > MAX_WORKTREE_AGE_MS || !existsSync(wt.path)) {
742
+ console.log(
743
+ `${TAG} ${dryRun ? "[dry-run] would remove" : "removing"} stale VK worktree: ${wt.path}`,
744
+ );
745
+ if (!dryRun) {
746
+ this._forceRemoveWorktreeSync(wt.path);
747
+ if (registryKey) {
748
+ this.registry.delete(registryKey);
749
+ evicted++;
750
+ }
751
+ pruned++;
752
+ }
753
+ }
754
+ }
755
+
756
+ // Step 3: copilot-worktree-YYYY-MM-DD entries older than 7 days
757
+ const dateMatch = wt.path.match(/copilot-worktree-(\d{4}-\d{2}-\d{2})/);
758
+ if (dateMatch) {
759
+ const wtDate = new Date(dateMatch[1]);
760
+ const ageMs = Date.now() - wtDate.getTime();
761
+ if (ageMs > COPILOT_WORKTREE_MAX_AGE_MS) {
762
+ console.log(
763
+ `${TAG} ${dryRun ? "[dry-run] would remove" : "removing"} old copilot worktree: ${wt.path}`,
764
+ );
765
+ if (!dryRun) {
766
+ this._forceRemoveWorktreeSync(wt.path);
767
+ const key = this._findKeyByPath(resolve(wt.path));
768
+ if (key) {
769
+ this.registry.delete(key);
770
+ evicted++;
771
+ }
772
+ pruned++;
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ // Step 3b: pr-cleanup temp worktrees (left by pr-cleanup-daemon)
779
+ for (const wt of allWorktrees) {
780
+ if (wt.isMainWorktree) continue;
781
+ if (wt.path.includes("pr-cleanup-")) {
782
+ const ageMs = _getFilesystemAgeMs(wt.path);
783
+ if (ageMs > MAX_WORKTREE_AGE_MS || !existsSync(wt.path)) {
784
+ console.log(
785
+ `${TAG} ${dryRun ? "[dry-run] would remove" : "removing"} stale pr-cleanup worktree: ${wt.path}`,
786
+ );
787
+ if (!dryRun) {
788
+ this._forceRemoveWorktreeSync(wt.path);
789
+ pruned++;
790
+ }
791
+ }
792
+ }
793
+ }
794
+
795
+ // Step 3c: catch-all — any other non-main worktree older than 7 days
796
+ for (const wt of allWorktrees) {
797
+ if (wt.isMainWorktree) continue;
798
+ const isVK =
799
+ wt.path.includes("vibe-kanban") ||
800
+ (wt.branch && wt.branch.startsWith("ve/"));
801
+ const isCopilot = /copilot-worktree-\d{4}-\d{2}-\d{2}/.test(wt.path);
802
+ const isPrCleanup = wt.path.includes("pr-cleanup-");
803
+ if (isVK || isCopilot || isPrCleanup) continue;
804
+
805
+ const registryKey = this._findKeyByPath(resolve(wt.path));
806
+ const record = registryKey ? this.registry.get(registryKey) : null;
807
+ const ageMs = record
808
+ ? Date.now() - record.lastUsedAt
809
+ : _getFilesystemAgeMs(wt.path);
810
+ if (ageMs > COPILOT_WORKTREE_MAX_AGE_MS) {
811
+ console.log(
812
+ `${TAG} ${dryRun ? "[dry-run] would remove" : "removing"} old untracked worktree: ${wt.path} (age=${(ageMs / 3600000).toFixed(1)}h)`,
813
+ );
814
+ if (!dryRun) {
815
+ this._forceRemoveWorktreeSync(wt.path);
816
+ if (registryKey) {
817
+ this.registry.delete(registryKey);
818
+ evicted++;
819
+ }
820
+ pruned++;
821
+ }
822
+ }
823
+ }
824
+
825
+ // Step 3d: scan .cache/worktrees/ for orphan dirs not tracked by git
826
+ try {
827
+ const cacheDir = resolve(this.repoRoot, DEFAULT_BASE_DIR);
828
+ if (existsSync(cacheDir)) {
829
+ const gitPaths = new Set(allWorktrees.map((wt) => resolve(wt.path)));
830
+ const entries = readdirSync(cacheDir, { withFileTypes: true });
831
+ for (const entry of entries) {
832
+ if (!entry.isDirectory()) continue;
833
+ const dirPath = resolve(cacheDir, entry.name);
834
+ if (gitPaths.has(dirPath)) continue;
835
+ const ageMs = _getFilesystemAgeMs(dirPath);
836
+ if (ageMs > MAX_WORKTREE_AGE_MS) {
837
+ console.log(
838
+ `${TAG} ${dryRun ? "[dry-run] would remove" : "removing"} orphan cache dir: ${dirPath} (age=${(ageMs / 3600000).toFixed(1)}h)`,
839
+ );
840
+ if (!dryRun) {
841
+ try {
842
+ rmSync(dirPath, { recursive: true, force: true });
843
+ } catch (e) {
844
+ console.warn(
845
+ `${TAG} rmSync failed for ${dirPath}: ${e.message}`,
846
+ );
847
+ }
848
+ pruned++;
849
+ }
850
+ }
851
+ }
852
+ }
853
+ } catch (e) {
854
+ console.warn(`${TAG} cache dir scan failed: ${e.message}`);
855
+ }
856
+
857
+ // Step 4: Evict registry entries whose paths no longer exist on disk
858
+ for (const [key, record] of this.registry.entries()) {
859
+ if (!existsSync(record.path)) {
860
+ console.log(
861
+ `${TAG} ${dryRun ? "[dry-run] would evict" : "evicting"} orphaned registry entry: ${key} → ${record.path}`,
862
+ );
863
+ if (!dryRun) {
864
+ this.registry.delete(key);
865
+ evicted++;
866
+ }
867
+ }
868
+ }
869
+
870
+ if (!dryRun) {
871
+ await this.saveRegistry();
872
+ }
873
+
874
+ return { pruned, evicted };
875
+ }
876
+
877
+ // ── Registry Lookups ────────────────────────────────────────────────────
878
+
879
+ /**
880
+ * Get the WorktreeRecord for a given taskKey.
881
+ * @param {string} taskKey
882
+ * @returns {WorktreeRecord|null}
883
+ */
884
+ getWorktreeForTask(taskKey) {
885
+ return this.registry.get(taskKey) ?? null;
886
+ }
887
+
888
+ /**
889
+ * Refresh the lastUsedAt timestamp for a task's worktree.
890
+ * Call this periodically for long-running tasks to prevent premature cleanup.
891
+ * @param {string} taskKey
892
+ */
893
+ async updateWorktreeUsage(taskKey) {
894
+ const record = this.registry.get(taskKey);
895
+ if (record) {
896
+ record.lastUsedAt = Date.now();
897
+ await this.saveRegistry();
898
+ }
899
+ }
900
+
901
+ /**
902
+ * Get aggregate statistics about tracked worktrees.
903
+ * @returns {{ total: number, active: number, stale: number, byOwner: Record<string, number> }}
904
+ */
905
+ getStats() {
906
+ let total = 0;
907
+ let active = 0;
908
+ let stale = 0;
909
+ /** @type {Record<string, number>} */
910
+ const byOwner = {};
911
+
912
+ for (const record of this.registry.values()) {
913
+ total++;
914
+ if (record.status === "active") active++;
915
+ if (
916
+ record.status === "stale" ||
917
+ Date.now() - record.lastUsedAt > MAX_WORKTREE_AGE_MS
918
+ ) {
919
+ stale++;
920
+ }
921
+ const owner = record.owner ?? "unknown";
922
+ byOwner[owner] = (byOwner[owner] ?? 0) + 1;
923
+ }
924
+
925
+ return { total, active, stale, byOwner };
926
+ }
927
+
928
+ // ── Private Helpers ─────────────────────────────────────────────────────
929
+
930
+ /**
931
+ * Find a registry key by the worktree's filesystem path.
932
+ * @param {string} normalizedPath
933
+ * @returns {string|null}
934
+ */
935
+ _findKeyByPath(normalizedPath) {
936
+ for (const [key, record] of this.registry.entries()) {
937
+ if (resolve(record.path) === normalizedPath) return key;
938
+ }
939
+ return null;
940
+ }
941
+
942
+ /**
943
+ * Find a registry key by branch name.
944
+ * @param {string} branch
945
+ * @returns {string|null}
946
+ */
947
+ _findKeyByBranch(branch) {
948
+ const normalized = branch.replace(/^refs\/heads\//, "");
949
+ for (const [key, record] of this.registry.entries()) {
950
+ if (record.branch === normalized) return key;
951
+ }
952
+ return null;
953
+ }
954
+
955
+ /**
956
+ * Clean up a partially-created worktree directory left behind by a failed
957
+ * `git worktree add` (e.g. timeout mid-checkout). If the directory remains,
958
+ * subsequent attempts will fail with "already exists" in an infinite loop.
959
+ * @param {string} wtPath Absolute path to the worktree directory
960
+ */
961
+ _cleanupPartialWorktree(wtPath) {
962
+ if (!existsSync(wtPath)) return;
963
+ try {
964
+ removePathSync(wtPath, { clearAttributes: true });
965
+ console.log(`${TAG} cleaned up partial worktree directory: ${wtPath}`);
966
+ } catch (err) {
967
+ console.warn(
968
+ `${TAG} failed to clean up partial worktree at ${wtPath}: ${err.message}`,
969
+ );
970
+ }
971
+ // Prune stale worktree refs that may reference the removed directory
972
+ try {
973
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
974
+ } catch {
975
+ /* best effort */
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Remove a worktree tracked in the registry.
981
+ * @param {string} key Registry key
982
+ * @param {WorktreeRecord} record
983
+ * @returns {Promise<{ success: boolean, path: string|null }>}
984
+ */
985
+ async _removeWorktree(key, record) {
986
+ if (!record) return { success: false, path: null };
987
+ const wtPath = record.path;
988
+
989
+ // Mark as releasing
990
+ record.status = "releasing";
991
+
992
+ const result = gitSync(
993
+ ["worktree", "remove", "--force", wtPath],
994
+ this.repoRoot,
995
+ { timeout: 60_000 },
996
+ );
997
+
998
+ if (result.status !== 0) {
999
+ const stderr = (result.stderr || "").trim();
1000
+ console.warn(`${TAG} Failed to remove worktree at ${wtPath}: ${stderr}`);
1001
+ // If git fails (e.g. "Directory not empty"), fall back to filesystem removal
1002
+ if (existsSync(wtPath)) {
1003
+ try {
1004
+ // Attempt 1: On Windows, use PowerShell first (most reliable for locked files + long paths)
1005
+ if (process.platform === "win32") {
1006
+ removePathWithPowerShell(wtPath, {
1007
+ clearAttributes: true,
1008
+ timeoutMs: 60_000,
1009
+ });
1010
+ console.log(`${TAG} PowerShell cleanup succeeded for ${wtPath}`);
1011
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
1012
+ } else {
1013
+ // Unix: Use rmSync with retries
1014
+ removePathSync(wtPath);
1015
+ console.log(`${TAG} Filesystem cleanup succeeded for ${wtPath}`);
1016
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
1017
+ }
1018
+ } catch (cleanupErr) {
1019
+ // Last resort: try basic Node.js rmSync (may partially succeed)
1020
+ try {
1021
+ rmSync(wtPath, {
1022
+ recursive: true,
1023
+ force: true,
1024
+ maxRetries: 5,
1025
+ retryDelay: 1000,
1026
+ });
1027
+ console.log(`${TAG} Fallback cleanup succeeded for ${wtPath}`);
1028
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
1029
+ } catch (finalErr) {
1030
+ console.warn(`${TAG} All cleanup attempts failed for ${wtPath}: ${cleanupErr.message || cleanupErr}`);
1031
+ // Don't throw — mark as zombie and continue. Background cleanup will retry later.
1032
+ this.registry.set(key, { ...record, status: "zombie", error: cleanupErr.message });
1033
+ return { success: false, path: wtPath };
1034
+ }
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ this.registry.delete(key);
1040
+ await this.saveRegistry();
1041
+
1042
+ console.log(`${TAG} Released worktree: ${wtPath}`);
1043
+ // Report command outcome, not filesystem state. We still clean registry/path
1044
+ // best-effort on failure to avoid stale worktree loops.
1045
+ return { success: result.status === 0, path: wtPath };
1046
+ }
1047
+
1048
+ /**
1049
+ * Force-remove a worktree that may or may not be in the registry.
1050
+ * @param {string} wtPath Absolute path
1051
+ * @returns {Promise<{ success: boolean, path: string|null }>}
1052
+ */
1053
+ async _forceRemoveWorktree(wtPath) {
1054
+ const result = gitSync(
1055
+ ["worktree", "remove", "--force", wtPath],
1056
+ this.repoRoot,
1057
+ { timeout: 60_000 },
1058
+ );
1059
+
1060
+ let success = result.status === 0;
1061
+ if (!success) {
1062
+ console.warn(
1063
+ `${TAG} Failed to force-remove worktree at ${wtPath}: ${(result.stderr || "").trim()}`,
1064
+ );
1065
+ // Fall back to filesystem removal (handles "Directory not empty" on Windows)
1066
+ if (existsSync(wtPath)) {
1067
+ try {
1068
+ // Attempt 1: On Windows, use PowerShell first (handles long paths better)
1069
+ if (process.platform === "win32") {
1070
+ removePathWithPowerShell(wtPath, { timeoutMs: 30_000 });
1071
+ } else {
1072
+ rmSync(wtPath, {
1073
+ recursive: true,
1074
+ force: true,
1075
+ maxRetries: 3,
1076
+ retryDelay: 500,
1077
+ });
1078
+ }
1079
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
1080
+ success = true;
1081
+ console.log(`${TAG} Filesystem cleanup succeeded for ${wtPath}`);
1082
+ } catch (rmErr) {
1083
+ // Attempt 2: On Windows, retry with attribute cleanup
1084
+ if (process.platform === "win32") {
1085
+ try {
1086
+ removePathWithPowerShell(wtPath, {
1087
+ clearAttributes: true,
1088
+ timeoutMs: 30_000,
1089
+ });
1090
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
1091
+ success = true;
1092
+ console.log(`${TAG} PowerShell cleanup succeeded for ${wtPath}`);
1093
+ } catch (pwshErr) {
1094
+ console.warn(`${TAG} All cleanup attempts failed for ${wtPath}: ${rmErr.message}`);
1095
+ }
1096
+ } else {
1097
+ console.warn(`${TAG} Filesystem cleanup failed: ${rmErr.message}`);
1098
+ }
1099
+ }
1100
+ } else {
1101
+ // Directory already gone, just needs prune
1102
+ gitSync(["worktree", "prune"], this.repoRoot, { timeout: 15_000 });
1103
+ success = true;
1104
+ }
1105
+ } else {
1106
+ console.log(`${TAG} Force-removed worktree: ${wtPath}`);
1107
+ }
1108
+
1109
+ // Also clean from registry if present
1110
+ const key = this._findKeyByPath(resolve(wtPath));
1111
+ if (key) {
1112
+ this.registry.delete(key);
1113
+ await this.saveRegistry();
1114
+ }
1115
+
1116
+ return { success, path: wtPath };
1117
+ }
1118
+
1119
+ /**
1120
+ * Synchronous force-remove for use in prune loops.
1121
+ * @param {string} wtPath
1122
+ */
1123
+ _forceRemoveWorktreeSync(wtPath) {
1124
+ try {
1125
+ gitSync(["worktree", "remove", "--force", wtPath], this.repoRoot, {
1126
+ timeout: 30_000,
1127
+ });
1128
+ } catch {
1129
+ // Best effort
1130
+ }
1131
+ if (existsSync(wtPath)) {
1132
+ try {
1133
+ removePathSync(wtPath, { clearAttributes: true, timeoutMs: 30_000 });
1134
+ } catch {
1135
+ // Best effort
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ // ── Singleton ───────────────────────────────────────────────────────────────
1142
+
1143
+ /** @type {WorktreeManager|null} */
1144
+ let _instance = null;
1145
+
1146
+ /**
1147
+ * Get or create the singleton WorktreeManager.
1148
+ * @param {string} [repoRoot] - Repository root (required on first call)
1149
+ * @param {object} [opts] - Options (only used on first call)
1150
+ * @returns {WorktreeManager}
1151
+ */
1152
+ function getWorktreeManager(repoRoot, opts) {
1153
+ const resolvedRoot = resolveDefaultRepoRoot(repoRoot);
1154
+ if (!_instance) {
1155
+ _instance = new WorktreeManager(resolvedRoot, opts);
1156
+ return _instance;
1157
+ }
1158
+
1159
+ // Allow explicit repoRoot to rebind singleton for the current process.
1160
+ if (repoRoot && _instance.repoRoot !== resolvedRoot) {
1161
+ _instance = new WorktreeManager(resolvedRoot, opts);
1162
+ }
1163
+ return _instance;
1164
+ }
1165
+
1166
+ /**
1167
+ * Reset the singleton (for testing).
1168
+ */
1169
+ function resetWorktreeManager() {
1170
+ _instance = null;
1171
+ }
1172
+
1173
+ // ── Convenience Wrappers ────────────────────────────────────────────────────
1174
+ // These use the singleton internally so callers don't need to manage it.
1175
+
1176
+ /**
1177
+ * Acquire a worktree for the given branch.
1178
+ * @param {string} branch
1179
+ * @param {string} taskKey
1180
+ * @param {object} [opts]
1181
+ * @returns {Promise<{ path: string, created: boolean, existing: boolean }>}
1182
+ */
1183
+ function acquireWorktree(branch, taskKey, opts) {
1184
+ return getWorktreeManager().acquireWorktree(branch, taskKey, opts);
1185
+ }
1186
+
1187
+ /**
1188
+ * Release a worktree by its taskKey.
1189
+ * @param {string} taskKey
1190
+ * @returns {Promise<{ success: boolean, path: string|null }>}
1191
+ */
1192
+ function releaseWorktree(taskKey) {
1193
+ return getWorktreeManager().releaseWorktree(taskKey);
1194
+ }
1195
+
1196
+ /**
1197
+ * Release a worktree by its branch name.
1198
+ * @param {string} branch
1199
+ * @returns {Promise<{ success: boolean, path: string|null }>}
1200
+ */
1201
+ function releaseWorktreeByBranch(branch) {
1202
+ return getWorktreeManager().releaseWorktreeByBranch(branch);
1203
+ }
1204
+
1205
+ /**
1206
+ * Find the worktree path for a given branch.
1207
+ * @param {string} branch
1208
+ * @returns {string|null}
1209
+ */
1210
+ function findWorktreeForBranch(branch) {
1211
+ return getWorktreeManager().findWorktreeForBranch(branch);
1212
+ }
1213
+
1214
+ /**
1215
+ * List all worktrees that are actively tracked.
1216
+ * @returns {Array<{ path: string, branch: string|null, taskKey: string|null, age: number, status: string, owner: string|null, isMainWorktree: boolean }>}
1217
+ */
1218
+ function listActiveWorktrees() {
1219
+ return getWorktreeManager().listActiveWorktrees();
1220
+ }
1221
+
1222
+ /**
1223
+ * Prune stale and orphaned worktrees.
1224
+ * @param {object} [opts]
1225
+ * @returns {Promise<{ pruned: number, evicted: number }>}
1226
+ */
1227
+ function pruneStaleWorktrees(opts) {
1228
+ return getWorktreeManager().pruneStaleWorktrees(opts);
1229
+ }
1230
+
1231
+ /**
1232
+ * Get aggregate statistics about tracked worktrees.
1233
+ * @returns {{ total: number, active: number, stale: number, byOwner: Record<string, number> }}
1234
+ */
1235
+ function getWorktreeStats() {
1236
+ return getWorktreeManager().getStats();
1237
+ }
1238
+
1239
+ // ── Exports ─────────────────────────────────────────────────────────────────
1240
+
1241
+ export {
1242
+ // Class
1243
+ WorktreeManager,
1244
+ // Singleton
1245
+ getWorktreeManager,
1246
+ resetWorktreeManager,
1247
+ // Convenience wrappers
1248
+ acquireWorktree,
1249
+ releaseWorktree,
1250
+ releaseWorktreeByBranch,
1251
+ findWorktreeForBranch,
1252
+ listActiveWorktrees,
1253
+ pruneStaleWorktrees,
1254
+ getWorktreeStats,
1255
+ // Helpers (useful for consumers that build their own paths)
1256
+ sanitizeBranchName,
1257
+ gitEnv,
1258
+ fixGitConfigCorruption,
1259
+ // Constants (allow consumers to reference)
1260
+ TAG,
1261
+ DEFAULT_BASE_DIR,
1262
+ REGISTRY_FILE,
1263
+ MAX_WORKTREE_AGE_MS,
1264
+ COPILOT_WORKTREE_MAX_AGE_MS,
1265
+ GIT_ENV,
1266
+ };