@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.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- 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
|
+
};
|