vde-worktree 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,3355 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { constants } from "node:fs";
4
+ import { access, appendFile, chmod, cp, mkdir, open, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
5
+ import { homedir, hostname } from "node:os";
6
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { parseArgs } from "citty";
9
+ import { execa } from "execa";
10
+ import stringWidth from "string-width";
11
+ import chalk from "chalk";
12
+
13
+ //#region src/core/constants.ts
14
+ const SCHEMA_VERSION = 1;
15
+ const EXIT_CODE = {
16
+ OK: 0,
17
+ NOT_GIT_REPOSITORY: 2,
18
+ INVALID_ARGUMENT: 3,
19
+ SAFETY_REJECTED: 4,
20
+ DEPENDENCY_MISSING: 5,
21
+ LOCK_FAILED: 6,
22
+ HOOK_FAILED: 10,
23
+ GIT_COMMAND_FAILED: 20,
24
+ CHILD_PROCESS_FAILED: 21,
25
+ INTERNAL_ERROR: 30
26
+ };
27
+ const DEFAULT_HOOK_TIMEOUT_MS = 3e4;
28
+ const DEFAULT_LOCK_TIMEOUT_MS = 15e3;
29
+ const DEFAULT_STALE_LOCK_TTL_SECONDS = 1800;
30
+ const COMMAND_NAMES = {
31
+ INIT: "init",
32
+ LIST: "list",
33
+ STATUS: "status",
34
+ PATH: "path",
35
+ SWITCH: "switch",
36
+ NEW: "new",
37
+ MV: "mv",
38
+ DEL: "del",
39
+ GONE: "gone",
40
+ GET: "get",
41
+ EXTRACT: "extract",
42
+ USE: "use",
43
+ EXEC: "exec",
44
+ INVOKE: "invoke",
45
+ COPY: "copy",
46
+ LINK: "link",
47
+ LOCK: "lock",
48
+ UNLOCK: "unlock",
49
+ CD: "cd",
50
+ COMPLETION: "completion"
51
+ };
52
+ const WRITE_COMMANDS = new Set([
53
+ COMMAND_NAMES.INIT,
54
+ COMMAND_NAMES.SWITCH,
55
+ COMMAND_NAMES.NEW,
56
+ COMMAND_NAMES.MV,
57
+ COMMAND_NAMES.DEL,
58
+ COMMAND_NAMES.GONE,
59
+ COMMAND_NAMES.GET,
60
+ COMMAND_NAMES.EXTRACT,
61
+ COMMAND_NAMES.USE,
62
+ COMMAND_NAMES.LOCK,
63
+ COMMAND_NAMES.UNLOCK
64
+ ]);
65
+
66
+ //#endregion
67
+ //#region src/core/errors.ts
68
+ const ERROR_CODE_TO_EXIT_CODE = {
69
+ NOT_GIT_REPOSITORY: EXIT_CODE.NOT_GIT_REPOSITORY,
70
+ INVALID_ARGUMENT: EXIT_CODE.INVALID_ARGUMENT,
71
+ UNKNOWN_COMMAND: EXIT_CODE.INVALID_ARGUMENT,
72
+ UNSAFE_FLAG_REQUIRED: EXIT_CODE.SAFETY_REJECTED,
73
+ NOT_INITIALIZED: EXIT_CODE.SAFETY_REJECTED,
74
+ WORKTREE_NOT_FOUND: EXIT_CODE.SAFETY_REJECTED,
75
+ BRANCH_ALREADY_ATTACHED: EXIT_CODE.SAFETY_REJECTED,
76
+ BRANCH_ALREADY_EXISTS: EXIT_CODE.SAFETY_REJECTED,
77
+ BRANCH_IN_USE: EXIT_CODE.SAFETY_REJECTED,
78
+ TARGET_PATH_NOT_EMPTY: EXIT_CODE.SAFETY_REJECTED,
79
+ PATH_OUTSIDE_REPO: EXIT_CODE.SAFETY_REJECTED,
80
+ ABSOLUTE_PATH_NOT_ALLOWED: EXIT_CODE.SAFETY_REJECTED,
81
+ LOCK_CONFLICT: EXIT_CODE.SAFETY_REJECTED,
82
+ DETACHED_HEAD: EXIT_CODE.SAFETY_REJECTED,
83
+ DIRTY_WORKTREE: EXIT_CODE.SAFETY_REJECTED,
84
+ UNMERGED_WORKTREE: EXIT_CODE.SAFETY_REJECTED,
85
+ UNPUSHED_WORKTREE: EXIT_CODE.SAFETY_REJECTED,
86
+ LOCKED_WORKTREE: EXIT_CODE.SAFETY_REJECTED,
87
+ STASH_APPLY_FAILED: EXIT_CODE.SAFETY_REJECTED,
88
+ REMOTE_NOT_FOUND: EXIT_CODE.SAFETY_REJECTED,
89
+ REMOTE_BRANCH_NOT_FOUND: EXIT_CODE.SAFETY_REJECTED,
90
+ INVALID_REMOTE_BRANCH_FORMAT: EXIT_CODE.INVALID_ARGUMENT,
91
+ HOOK_NOT_FOUND: EXIT_CODE.SAFETY_REJECTED,
92
+ DEPENDENCY_MISSING: EXIT_CODE.DEPENDENCY_MISSING,
93
+ REPO_LOCK_TIMEOUT: EXIT_CODE.LOCK_FAILED,
94
+ REPO_LOCK_STALE_RECOVERY_FAILED: EXIT_CODE.LOCK_FAILED,
95
+ HOOK_NOT_EXECUTABLE: EXIT_CODE.HOOK_FAILED,
96
+ HOOK_TIMEOUT: EXIT_CODE.HOOK_FAILED,
97
+ HOOK_FAILED: EXIT_CODE.HOOK_FAILED,
98
+ GIT_COMMAND_FAILED: EXIT_CODE.GIT_COMMAND_FAILED,
99
+ INTERNAL_ERROR: EXIT_CODE.INTERNAL_ERROR
100
+ };
101
+ var CliError = class extends Error {
102
+ code;
103
+ exitCode;
104
+ details;
105
+ constructor(code, options) {
106
+ super(options.message, { cause: options.cause });
107
+ this.code = code;
108
+ this.exitCode = ERROR_CODE_TO_EXIT_CODE[code];
109
+ this.details = options.details ?? {};
110
+ }
111
+ };
112
+ const createCliError = (code, options) => {
113
+ return new CliError(code, options);
114
+ };
115
+ const ensureCliError = (error) => {
116
+ if (error instanceof CliError) return error;
117
+ if (error instanceof Error) return createCliError("INTERNAL_ERROR", {
118
+ message: error.message,
119
+ cause: error
120
+ });
121
+ return createCliError("INTERNAL_ERROR", {
122
+ message: "An unexpected error occurred",
123
+ details: { value: String(error) }
124
+ });
125
+ };
126
+
127
+ //#endregion
128
+ //#region src/git/exec.ts
129
+ const runGitCommand = async ({ cwd, args, reject = true }) => {
130
+ try {
131
+ const result = await execa("git", [...args], {
132
+ cwd,
133
+ reject
134
+ });
135
+ return {
136
+ stdout: result.stdout,
137
+ stderr: result.stderr,
138
+ exitCode: result.exitCode ?? 0
139
+ };
140
+ } catch (error) {
141
+ const execaError = error;
142
+ throw createCliError("GIT_COMMAND_FAILED", {
143
+ message: "git command failed",
144
+ details: {
145
+ command: ["git", ...args],
146
+ cwd,
147
+ exitCode: execaError.exitCode,
148
+ stdout: execaError.stdout ?? "",
149
+ stderr: execaError.stderr ?? "",
150
+ shortMessage: execaError.shortMessage ?? execaError.message
151
+ },
152
+ cause: error
153
+ });
154
+ }
155
+ };
156
+ const doesGitRefExist = async (cwd, ref) => {
157
+ return (await runGitCommand({
158
+ cwd,
159
+ args: [
160
+ "show-ref",
161
+ "--verify",
162
+ "--quiet",
163
+ ref
164
+ ],
165
+ reject: false
166
+ })).exitCode === 0;
167
+ };
168
+
169
+ //#endregion
170
+ //#region src/core/paths.ts
171
+ const GIT_DIR_NAME = ".git";
172
+ const resolveRepoRootFromCommonDir = ({ currentWorktreeRoot, gitCommonDir }) => {
173
+ if (gitCommonDir.endsWith(`/${GIT_DIR_NAME}`)) return dirname(gitCommonDir);
174
+ if (gitCommonDir.endsWith(`\\${GIT_DIR_NAME}`)) return dirname(gitCommonDir);
175
+ return currentWorktreeRoot;
176
+ };
177
+ const resolveRepoContext = async (cwd) => {
178
+ const toplevelResult = await runGitCommand({
179
+ cwd,
180
+ args: ["rev-parse", "--show-toplevel"],
181
+ reject: false
182
+ });
183
+ if (toplevelResult.exitCode !== 0) throw createCliError("NOT_GIT_REPOSITORY", {
184
+ message: "Current directory is not inside a Git repository",
185
+ details: { cwd }
186
+ });
187
+ const currentWorktreeRoot = toplevelResult.stdout.trim();
188
+ const commonDirResult = await runGitCommand({
189
+ cwd,
190
+ args: [
191
+ "rev-parse",
192
+ "--path-format=absolute",
193
+ "--git-common-dir"
194
+ ],
195
+ reject: false
196
+ });
197
+ const gitCommonDir = commonDirResult.exitCode === 0 ? commonDirResult.stdout.trim() : join(currentWorktreeRoot, GIT_DIR_NAME);
198
+ return {
199
+ repoRoot: resolveRepoRootFromCommonDir({
200
+ currentWorktreeRoot,
201
+ gitCommonDir
202
+ }),
203
+ currentWorktreeRoot,
204
+ gitCommonDir
205
+ };
206
+ };
207
+ const getWorktreeRootPath = (repoRoot) => {
208
+ return join(repoRoot, ".worktree");
209
+ };
210
+ const getWorktreeMetaRootPath = (repoRoot) => {
211
+ return join(repoRoot, ".vde", "worktree");
212
+ };
213
+ const getHooksDirectoryPath = (repoRoot) => {
214
+ return join(getWorktreeMetaRootPath(repoRoot), "hooks");
215
+ };
216
+ const getLogsDirectoryPath = (repoRoot) => {
217
+ return join(getWorktreeMetaRootPath(repoRoot), "logs");
218
+ };
219
+ const getLocksDirectoryPath = (repoRoot) => {
220
+ return join(getWorktreeMetaRootPath(repoRoot), "locks");
221
+ };
222
+ const getStateDirectoryPath = (repoRoot) => {
223
+ return join(getWorktreeMetaRootPath(repoRoot), "state");
224
+ };
225
+ const branchToWorktreeId = (branch) => {
226
+ return encodeURIComponent(branch);
227
+ };
228
+ const branchToWorktreePath = (repoRoot, branch) => {
229
+ return join(getWorktreeRootPath(repoRoot), branchToWorktreeId(branch));
230
+ };
231
+ const ensurePathInsideRepo = ({ repoRoot, path }) => {
232
+ const rel = relative(repoRoot, path);
233
+ if (rel === "") return path;
234
+ if (rel === ".." || rel.startsWith(`..${sep}`)) throw createCliError("PATH_OUTSIDE_REPO", {
235
+ message: "Path is outside repository root",
236
+ details: {
237
+ repoRoot,
238
+ path
239
+ }
240
+ });
241
+ return path;
242
+ };
243
+ const resolveRepoRelativePath = ({ repoRoot, relativePath }) => {
244
+ if (isAbsolute(relativePath)) throw createCliError("ABSOLUTE_PATH_NOT_ALLOWED", {
245
+ message: "Absolute path is not allowed",
246
+ details: { path: relativePath }
247
+ });
248
+ return ensurePathInsideRepo({
249
+ repoRoot,
250
+ path: resolve(repoRoot, normalize(relativePath))
251
+ });
252
+ };
253
+ const resolvePathFromCwd = ({ cwd, path }) => {
254
+ if (isAbsolute(path)) return path;
255
+ return resolve(cwd, path);
256
+ };
257
+
258
+ //#endregion
259
+ //#region src/core/hooks.ts
260
+ const nowTimestamp = () => {
261
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[^\d]/g, "").slice(0, 14);
262
+ };
263
+ const toLogFileName = ({ action, branch }) => {
264
+ const safeBranch = typeof branch === "string" && branch.length > 0 ? branch.replace(/[^\w.-]/g, "_") : "none";
265
+ return `${nowTimestamp()}_${action}_${safeBranch}.log`;
266
+ };
267
+ const hookPath = (repoRoot, hookName) => {
268
+ return join(getHooksDirectoryPath(repoRoot), hookName);
269
+ };
270
+ const appendHookLog = async ({ repoRoot, action, branch, content }) => {
271
+ const logsDir = getLogsDirectoryPath(repoRoot);
272
+ await mkdir(logsDir, { recursive: true });
273
+ await appendFile(join(logsDir, toLogFileName({
274
+ action,
275
+ branch
276
+ })), content, "utf8");
277
+ };
278
+ const runHook = async ({ phase, hookName, args, context, requireExists = false }) => {
279
+ if (context.enabled !== true) return;
280
+ const path = hookPath(context.repoRoot, hookName);
281
+ try {
282
+ await access(path, constants.F_OK);
283
+ } catch {
284
+ if (requireExists) throw createCliError("HOOK_NOT_FOUND", {
285
+ message: `Hook not found: ${hookName}`,
286
+ details: {
287
+ hook: hookName,
288
+ path
289
+ }
290
+ });
291
+ return;
292
+ }
293
+ try {
294
+ await access(path, constants.X_OK);
295
+ } catch {
296
+ throw createCliError("HOOK_NOT_EXECUTABLE", {
297
+ message: `Hook is not executable: ${hookName}`,
298
+ details: {
299
+ hook: hookName,
300
+ path
301
+ }
302
+ });
303
+ }
304
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
305
+ try {
306
+ const result = await execa(path, [...args], {
307
+ cwd: context.worktreePath ?? context.repoRoot,
308
+ env: {
309
+ ...process.env,
310
+ WT_REPO_ROOT: context.repoRoot,
311
+ WT_ACTION: context.action,
312
+ WT_BRANCH: context.branch ?? "",
313
+ WT_WORKTREE_PATH: context.worktreePath ?? "",
314
+ WT_IS_TTY: process.stdout.isTTY === true ? "1" : "0",
315
+ WT_TOOL: "vde-worktree",
316
+ ...context.extraEnv ?? {}
317
+ },
318
+ timeout: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
319
+ reject: false
320
+ });
321
+ const endedAt = (/* @__PURE__ */ new Date()).toISOString();
322
+ const logContent = [
323
+ `hook=${hookName}`,
324
+ `phase=${phase}`,
325
+ `start=${startedAt}`,
326
+ `end=${endedAt}`,
327
+ `exitCode=${String(result.exitCode ?? 0)}`,
328
+ `stderr=${result.stderr ?? ""}`,
329
+ ""
330
+ ].join("\n");
331
+ await appendHookLog({
332
+ repoRoot: context.repoRoot,
333
+ action: context.action,
334
+ branch: context.branch,
335
+ content: logContent
336
+ });
337
+ if ((result.exitCode ?? 0) === 0) return;
338
+ const message = `Hook failed: ${hookName} (exitCode=${String(result.exitCode ?? 1)})`;
339
+ if (phase === "post" && context.strictPostHooks !== true) {
340
+ context.stderr(message);
341
+ return;
342
+ }
343
+ throw createCliError("HOOK_FAILED", {
344
+ message,
345
+ details: {
346
+ hook: hookName,
347
+ exitCode: result.exitCode,
348
+ stderr: result.stderr
349
+ }
350
+ });
351
+ } catch (error) {
352
+ if (error instanceof CliError) throw error;
353
+ const hookError = error;
354
+ if (hookError.code === "ETIMEDOUT" || hookError.code === "ERR_EXECA_TIMEOUT") throw createCliError("HOOK_TIMEOUT", {
355
+ message: `Hook timed out: ${hookName}`,
356
+ details: {
357
+ hook: hookName,
358
+ timeoutMs: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
359
+ stderr: hookError.stderr ?? ""
360
+ },
361
+ cause: error
362
+ });
363
+ if (phase === "post" && context.strictPostHooks !== true) {
364
+ context.stderr(`Hook failed: ${hookName}`);
365
+ return;
366
+ }
367
+ throw createCliError("HOOK_FAILED", {
368
+ message: `Hook failed: ${hookName}`,
369
+ details: {
370
+ hook: hookName,
371
+ stderr: hookError.stderr ?? hookError.message
372
+ },
373
+ cause: error
374
+ });
375
+ }
376
+ };
377
+ const runPreHook = async ({ name, context }) => {
378
+ await runHook({
379
+ phase: "pre",
380
+ hookName: `pre-${name}`,
381
+ args: [],
382
+ context
383
+ });
384
+ };
385
+ const runPostHook = async ({ name, context }) => {
386
+ await runHook({
387
+ phase: "post",
388
+ hookName: `post-${name}`,
389
+ args: [],
390
+ context
391
+ });
392
+ };
393
+ const invokeHook = async ({ hookName, args, context }) => {
394
+ await runHook({
395
+ phase: hookName.startsWith("pre-") ? "pre" : "post",
396
+ hookName,
397
+ args,
398
+ context,
399
+ requireExists: true
400
+ });
401
+ };
402
+
403
+ //#endregion
404
+ //#region src/core/init.ts
405
+ const MANAGED_EXCLUDE_BLOCK = `# vde-worktree (managed)\n.worktree/\n.vde/worktree/\n`;
406
+ const DEFAULT_HOOKS = [{
407
+ name: "post-new",
408
+ lines: [
409
+ "#!/usr/bin/env bash",
410
+ "set -eu",
411
+ "",
412
+ "# example:",
413
+ "# vde-worktree copy .envrc .claude/settings.local.json",
414
+ "",
415
+ "exit 0"
416
+ ]
417
+ }, {
418
+ name: "post-switch",
419
+ lines: [
420
+ "#!/usr/bin/env bash",
421
+ "set -eu",
422
+ "",
423
+ "# example:",
424
+ "# vde-worktree link .envrc",
425
+ "",
426
+ "exit 0"
427
+ ]
428
+ }];
429
+ const createHookTemplate = async (hooksDir, name, lines) => {
430
+ const targetPath = join(hooksDir, name);
431
+ try {
432
+ await access(targetPath, constants.F_OK);
433
+ return;
434
+ } catch {
435
+ await writeFile(targetPath, `${lines.join("\n")}\n`, "utf8");
436
+ await chmod(targetPath, 493);
437
+ }
438
+ };
439
+ const ensureExcludeBlock = async (repoRoot) => {
440
+ const excludePath = join(repoRoot, ".git", "info", "exclude");
441
+ let current = "";
442
+ try {
443
+ current = await readFile(excludePath, "utf8");
444
+ } catch {
445
+ current = "";
446
+ }
447
+ if (current.includes(MANAGED_EXCLUDE_BLOCK)) return;
448
+ await writeFile(excludePath, `${current.endsWith("\n") || current.length === 0 ? current : `${current}\n`}${MANAGED_EXCLUDE_BLOCK}`, "utf8");
449
+ };
450
+ const isInitialized = async (repoRoot) => {
451
+ try {
452
+ await access(getWorktreeMetaRootPath(repoRoot), constants.F_OK);
453
+ return true;
454
+ } catch {
455
+ return false;
456
+ }
457
+ };
458
+ const initializeRepository = async (repoRoot) => {
459
+ const wasInitialized = await isInitialized(repoRoot);
460
+ await mkdir(getWorktreeRootPath(repoRoot), { recursive: true });
461
+ await mkdir(getHooksDirectoryPath(repoRoot), { recursive: true });
462
+ await mkdir(getLogsDirectoryPath(repoRoot), { recursive: true });
463
+ await mkdir(getLocksDirectoryPath(repoRoot), { recursive: true });
464
+ await mkdir(getStateDirectoryPath(repoRoot), { recursive: true });
465
+ await ensureExcludeBlock(repoRoot);
466
+ for (const hook of DEFAULT_HOOKS) await createHookTemplate(getHooksDirectoryPath(repoRoot), hook.name, hook.lines);
467
+ return { alreadyInitialized: wasInitialized };
468
+ };
469
+
470
+ //#endregion
471
+ //#region src/core/repo-lock.ts
472
+ const sleep = async (ms) => {
473
+ await new Promise((resolve) => {
474
+ setTimeout(resolve, ms);
475
+ });
476
+ };
477
+ const isProcessAlive = (pid) => {
478
+ if (pid <= 0 || Number.isFinite(pid) !== true) return false;
479
+ try {
480
+ process.kill(pid, 0);
481
+ return true;
482
+ } catch (error) {
483
+ if (error.code === "ESRCH") return false;
484
+ return true;
485
+ }
486
+ };
487
+ const safeParseLockFile = (content) => {
488
+ try {
489
+ const parsed = JSON.parse(content);
490
+ if (parsed.schemaVersion !== 1) return null;
491
+ if (typeof parsed.command !== "string" || typeof parsed.owner !== "string") return null;
492
+ if (typeof parsed.pid !== "number" || typeof parsed.host !== "string" || typeof parsed.startedAt !== "string") return null;
493
+ return parsed;
494
+ } catch {
495
+ return null;
496
+ }
497
+ };
498
+ const lockFilePath$1 = async (repoRoot) => {
499
+ const stateDir = getStateDirectoryPath(repoRoot);
500
+ try {
501
+ await access(stateDir, constants.F_OK);
502
+ return join(stateDir, "repo.lock");
503
+ } catch {
504
+ return join(repoRoot, ".git", "vde-worktree.init.lock");
505
+ }
506
+ };
507
+ const buildLockPayload = (command) => {
508
+ return {
509
+ schemaVersion: 1,
510
+ owner: "vde-worktree",
511
+ command,
512
+ pid: process.pid,
513
+ host: hostname(),
514
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
515
+ };
516
+ };
517
+ const canRecoverStaleLock = ({ lock, staleLockTTLSeconds }) => {
518
+ if (lock === null) return true;
519
+ const startedAtMs = Date.parse(lock.startedAt);
520
+ if (Number.isFinite(startedAtMs) !== true) return true;
521
+ if (startedAtMs + staleLockTTLSeconds * 1e3 > Date.now()) return false;
522
+ if (lock.host === hostname() && isProcessAlive(lock.pid)) return false;
523
+ return true;
524
+ };
525
+ const writeNewLockFile = async (path, payload) => {
526
+ try {
527
+ const handle = await open(path, "wx");
528
+ await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8");
529
+ await handle.close();
530
+ return true;
531
+ } catch (error) {
532
+ if (error.code === "EEXIST") return false;
533
+ throw error;
534
+ }
535
+ };
536
+ const acquireRepoLock = async ({ repoRoot, command, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS, staleLockTTLSeconds = DEFAULT_STALE_LOCK_TTL_SECONDS }) => {
537
+ const path = await lockFilePath$1(repoRoot);
538
+ const startAt = Date.now();
539
+ const payload = buildLockPayload(command);
540
+ while (Date.now() - startAt <= timeoutMs) {
541
+ if (await writeNewLockFile(path, payload)) return { release: async () => {
542
+ try {
543
+ await rm(path, { force: true });
544
+ } catch {
545
+ return;
546
+ }
547
+ } };
548
+ let lockContent = "";
549
+ try {
550
+ lockContent = await readFile(path, "utf8");
551
+ } catch {
552
+ await sleep(100);
553
+ continue;
554
+ }
555
+ if (canRecoverStaleLock({
556
+ lock: safeParseLockFile(lockContent),
557
+ staleLockTTLSeconds
558
+ })) {
559
+ try {
560
+ await rm(path, { force: true });
561
+ } catch {
562
+ throw createCliError("REPO_LOCK_STALE_RECOVERY_FAILED", {
563
+ message: "Failed to recover stale repo lock",
564
+ details: { path }
565
+ });
566
+ }
567
+ continue;
568
+ }
569
+ await sleep(100);
570
+ }
571
+ throw createCliError("REPO_LOCK_TIMEOUT", {
572
+ message: "Timed out while acquiring repo lock",
573
+ details: {
574
+ path,
575
+ timeoutMs
576
+ }
577
+ });
578
+ };
579
+ const withRepoLock = async (options, task) => {
580
+ const handle = await acquireRepoLock(options);
581
+ try {
582
+ return await task();
583
+ } finally {
584
+ await handle.release();
585
+ }
586
+ };
587
+ const readNumberFromEnvOrDefault = ({ rawValue, defaultValue }) => {
588
+ if (typeof rawValue !== "number" || Number.isFinite(rawValue) !== true) return defaultValue;
589
+ return rawValue;
590
+ };
591
+
592
+ //#endregion
593
+ //#region src/core/worktree-lock.ts
594
+ const parseLock = (content) => {
595
+ try {
596
+ const parsed = JSON.parse(content);
597
+ if (parsed.schemaVersion !== 1 || typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.reason !== "string" || typeof parsed.owner !== "string" || typeof parsed.host !== "string" || typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string" || typeof parsed.updatedAt !== "string") return {
598
+ valid: false,
599
+ record: null
600
+ };
601
+ return {
602
+ valid: true,
603
+ record: parsed
604
+ };
605
+ } catch {
606
+ return {
607
+ valid: false,
608
+ record: null
609
+ };
610
+ }
611
+ };
612
+ const writeJsonAtomically = async ({ filePath, payload }) => {
613
+ const tmpPath = `${filePath}.tmp-${String(process.pid)}-${String(Date.now())}`;
614
+ await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
615
+ await rename(tmpPath, filePath);
616
+ };
617
+ const lockFilePath = (repoRoot, branch) => {
618
+ return join(getLocksDirectoryPath(repoRoot), `${branchToWorktreeId(branch)}.json`);
619
+ };
620
+ const readWorktreeLock = async ({ repoRoot, branch }) => {
621
+ const path = lockFilePath(repoRoot, branch);
622
+ try {
623
+ await access(path, constants.F_OK);
624
+ } catch {
625
+ return {
626
+ path,
627
+ exists: false,
628
+ valid: true,
629
+ record: null
630
+ };
631
+ }
632
+ try {
633
+ return {
634
+ path,
635
+ exists: true,
636
+ ...parseLock(await readFile(path, "utf8"))
637
+ };
638
+ } catch {
639
+ return {
640
+ path,
641
+ exists: true,
642
+ valid: false,
643
+ record: null
644
+ };
645
+ }
646
+ };
647
+ const upsertWorktreeLock = async ({ repoRoot, branch, reason, owner }) => {
648
+ const { path, record } = await readWorktreeLock({
649
+ repoRoot,
650
+ branch
651
+ });
652
+ const now = (/* @__PURE__ */ new Date()).toISOString();
653
+ const next = {
654
+ schemaVersion: 1,
655
+ branch,
656
+ worktreeId: branchToWorktreeId(branch),
657
+ reason,
658
+ owner,
659
+ host: hostname(),
660
+ pid: process.pid,
661
+ createdAt: record?.createdAt ?? now,
662
+ updatedAt: now
663
+ };
664
+ await writeJsonAtomically({
665
+ filePath: path,
666
+ payload: next
667
+ });
668
+ return next;
669
+ };
670
+ const deleteWorktreeLock = async ({ repoRoot, branch }) => {
671
+ await rm(lockFilePath(repoRoot, branch), { force: true });
672
+ };
673
+
674
+ //#endregion
675
+ //#region src/integrations/gh.ts
676
+ const defaultRunGh = async ({ cwd, args }) => {
677
+ const result = await execa("gh", [...args], {
678
+ cwd,
679
+ reject: false
680
+ });
681
+ return {
682
+ exitCode: result.exitCode ?? 0,
683
+ stdout: result.stdout,
684
+ stderr: result.stderr
685
+ };
686
+ };
687
+ const parseMergedResult = (raw) => {
688
+ try {
689
+ const parsed = JSON.parse(raw);
690
+ if (Array.isArray(parsed) !== true) return null;
691
+ const records = parsed;
692
+ if (records.length === 0) return false;
693
+ return records.some((record) => typeof record?.mergedAt === "string" && record.mergedAt.length > 0);
694
+ } catch {
695
+ return null;
696
+ }
697
+ };
698
+ const resolveMergedByPr = async ({ repoRoot, branch, enabled = true, runGh = defaultRunGh }) => {
699
+ if (enabled !== true) return null;
700
+ try {
701
+ const result = await runGh({
702
+ cwd: repoRoot,
703
+ args: [
704
+ "pr",
705
+ "list",
706
+ "--state",
707
+ "merged",
708
+ "--head",
709
+ branch,
710
+ "--limit",
711
+ "1",
712
+ "--json",
713
+ "mergedAt"
714
+ ]
715
+ });
716
+ if (result.exitCode !== 0) return null;
717
+ return parseMergedResult(result.stdout);
718
+ } catch (error) {
719
+ if (error.code === "ENOENT") return null;
720
+ return null;
721
+ }
722
+ };
723
+
724
+ //#endregion
725
+ //#region src/git/worktree.ts
726
+ const BRANCH_PREFIX = "refs/heads/";
727
+ const parseBranchName = (rawRef) => {
728
+ if (rawRef.startsWith(BRANCH_PREFIX)) return rawRef.slice(11);
729
+ return rawRef.length > 0 ? rawRef : null;
730
+ };
731
+ const parseWorktreePorcelain = (raw) => {
732
+ const tokens = raw.split("\0");
733
+ const worktrees = [];
734
+ let currentPath = "";
735
+ let currentHead = "";
736
+ let currentBranch = null;
737
+ const flush = () => {
738
+ if (currentPath.length === 0) return;
739
+ worktrees.push({
740
+ path: currentPath,
741
+ head: currentHead,
742
+ branch: currentBranch
743
+ });
744
+ currentPath = "";
745
+ currentHead = "";
746
+ currentBranch = null;
747
+ };
748
+ for (const token of tokens) {
749
+ if (token.length === 0) {
750
+ flush();
751
+ continue;
752
+ }
753
+ if (token.startsWith("worktree ")) {
754
+ flush();
755
+ currentPath = token.slice(9);
756
+ continue;
757
+ }
758
+ if (token.startsWith("HEAD ")) {
759
+ currentHead = token.slice(5);
760
+ continue;
761
+ }
762
+ if (token.startsWith("branch ")) {
763
+ currentBranch = parseBranchName(token.slice(7));
764
+ continue;
765
+ }
766
+ if (token === "detached") currentBranch = null;
767
+ }
768
+ flush();
769
+ return worktrees;
770
+ };
771
+ const listGitWorktrees = async (repoRoot) => {
772
+ return parseWorktreePorcelain((await runGitCommand({
773
+ cwd: repoRoot,
774
+ args: [
775
+ "worktree",
776
+ "list",
777
+ "--porcelain",
778
+ "-z"
779
+ ]
780
+ })).stdout);
781
+ };
782
+
783
+ //#endregion
784
+ //#region src/core/worktree-state.ts
785
+ const resolveBaseBranch$1 = async (repoRoot) => {
786
+ const explicit = await runGitCommand({
787
+ cwd: repoRoot,
788
+ args: [
789
+ "config",
790
+ "--get",
791
+ "vde-worktree.baseBranch"
792
+ ],
793
+ reject: false
794
+ });
795
+ if (explicit.exitCode === 0 && explicit.stdout.trim().length > 0) return explicit.stdout.trim();
796
+ for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
797
+ return null;
798
+ };
799
+ const resolveEnableGh = async (repoRoot) => {
800
+ const result = await runGitCommand({
801
+ cwd: repoRoot,
802
+ args: [
803
+ "config",
804
+ "--bool",
805
+ "--get",
806
+ "vde-worktree.enableGh"
807
+ ],
808
+ reject: false
809
+ });
810
+ if (result.exitCode !== 0) return true;
811
+ const value = result.stdout.trim().toLowerCase();
812
+ if (value === "false" || value === "no" || value === "off" || value === "0") return false;
813
+ return true;
814
+ };
815
+ const resolveDirty = async (worktreePath) => {
816
+ return (await runGitCommand({
817
+ cwd: worktreePath,
818
+ args: ["status", "--porcelain"],
819
+ reject: false
820
+ })).stdout.trim().length > 0;
821
+ };
822
+ const parseLockPayload = (content) => {
823
+ try {
824
+ const parsed = JSON.parse(content);
825
+ if (parsed.schemaVersion !== 1) return null;
826
+ if (typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.reason !== "string" || parsed.reason.length === 0) return null;
827
+ return parsed;
828
+ } catch {
829
+ return null;
830
+ }
831
+ };
832
+ const resolveLockState = async ({ repoRoot, branch }) => {
833
+ if (branch === null) return {
834
+ value: false,
835
+ reason: null,
836
+ owner: null
837
+ };
838
+ const id = branchToWorktreeId(branch);
839
+ const lockPath = join(getLocksDirectoryPath(repoRoot), `${id}.json`);
840
+ try {
841
+ await access(lockPath, constants.F_OK);
842
+ } catch {
843
+ return {
844
+ value: false,
845
+ reason: null,
846
+ owner: null
847
+ };
848
+ }
849
+ try {
850
+ const lock = parseLockPayload(await readFile(lockPath, "utf8"));
851
+ if (lock === null) return {
852
+ value: true,
853
+ reason: "invalid lock metadata",
854
+ owner: null
855
+ };
856
+ return {
857
+ value: true,
858
+ reason: lock.reason,
859
+ owner: typeof lock.owner === "string" && lock.owner.length > 0 ? lock.owner : null
860
+ };
861
+ } catch {
862
+ return {
863
+ value: true,
864
+ reason: "invalid lock metadata",
865
+ owner: null
866
+ };
867
+ }
868
+ };
869
+ const resolveMergedState = async ({ repoRoot, branch, baseBranch, enableGh }) => {
870
+ if (branch === null) return {
871
+ byAncestry: null,
872
+ byPR: null,
873
+ overall: null
874
+ };
875
+ let byAncestry = null;
876
+ if (baseBranch !== null) {
877
+ const result = await runGitCommand({
878
+ cwd: repoRoot,
879
+ args: [
880
+ "merge-base",
881
+ "--is-ancestor",
882
+ branch,
883
+ baseBranch
884
+ ],
885
+ reject: false
886
+ });
887
+ if (result.exitCode === 0) byAncestry = true;
888
+ else if (result.exitCode === 1) byAncestry = false;
889
+ }
890
+ const byPR = await resolveMergedByPr({
891
+ repoRoot,
892
+ branch,
893
+ enabled: enableGh
894
+ });
895
+ return {
896
+ byAncestry,
897
+ byPR,
898
+ overall: resolveMergedOverall({
899
+ byAncestry,
900
+ byPR
901
+ })
902
+ };
903
+ };
904
+ const resolveMergedOverall = ({ byAncestry, byPR }) => {
905
+ if (byPR === true) return true;
906
+ if (byPR === false) return false;
907
+ return byAncestry;
908
+ };
909
+ const resolveUpstreamState = async (worktreePath) => {
910
+ const upstreamRef = await runGitCommand({
911
+ cwd: worktreePath,
912
+ args: [
913
+ "rev-parse",
914
+ "--abbrev-ref",
915
+ "--symbolic-full-name",
916
+ "@{upstream}"
917
+ ],
918
+ reject: false
919
+ });
920
+ if (upstreamRef.exitCode !== 0) return {
921
+ ahead: null,
922
+ behind: null,
923
+ remote: null
924
+ };
925
+ const distance = await runGitCommand({
926
+ cwd: worktreePath,
927
+ args: [
928
+ "rev-list",
929
+ "--left-right",
930
+ "--count",
931
+ "@{upstream}...HEAD"
932
+ ],
933
+ reject: false
934
+ });
935
+ if (distance.exitCode !== 0) return {
936
+ ahead: null,
937
+ behind: null,
938
+ remote: upstreamRef.stdout.trim()
939
+ };
940
+ const [behindRaw, aheadRaw] = distance.stdout.trim().split(/\s+/);
941
+ const behind = Number.parseInt(behindRaw ?? "", 10);
942
+ const ahead = Number.parseInt(aheadRaw ?? "", 10);
943
+ return {
944
+ ahead: Number.isNaN(ahead) ? null : ahead,
945
+ behind: Number.isNaN(behind) ? null : behind,
946
+ remote: upstreamRef.stdout.trim()
947
+ };
948
+ };
949
+ const enrichWorktree = async ({ repoRoot, worktree, baseBranch, enableGh }) => {
950
+ const [dirty, locked, merged, upstream] = await Promise.all([
951
+ resolveDirty(worktree.path),
952
+ resolveLockState({
953
+ repoRoot,
954
+ branch: worktree.branch
955
+ }),
956
+ resolveMergedState({
957
+ repoRoot,
958
+ branch: worktree.branch,
959
+ baseBranch,
960
+ enableGh
961
+ }),
962
+ resolveUpstreamState(worktree.path)
963
+ ]);
964
+ return {
965
+ branch: worktree.branch,
966
+ path: worktree.path,
967
+ head: worktree.head,
968
+ dirty,
969
+ locked,
970
+ merged,
971
+ upstream
972
+ };
973
+ };
974
+ const collectWorktreeSnapshot = async (repoRoot) => {
975
+ const [baseBranch, worktrees, enableGh] = await Promise.all([
976
+ resolveBaseBranch$1(repoRoot),
977
+ listGitWorktrees(repoRoot),
978
+ resolveEnableGh(repoRoot)
979
+ ]);
980
+ return {
981
+ repoRoot,
982
+ baseBranch,
983
+ worktrees: await Promise.all(worktrees.map(async (worktree) => {
984
+ return enrichWorktree({
985
+ repoRoot,
986
+ worktree,
987
+ baseBranch,
988
+ enableGh
989
+ });
990
+ }))
991
+ };
992
+ };
993
+
994
+ //#endregion
995
+ //#region src/integrations/fzf.ts
996
+ const FZF_BINARY = "fzf";
997
+ const FZF_CHECK_TIMEOUT_MS = 5e3;
998
+ const RESERVED_FZF_ARGS = new Set([
999
+ "prompt",
1000
+ "layout",
1001
+ "height",
1002
+ "border"
1003
+ ]);
1004
+ const sanitizeCandidate = (value) => value.replace(/[\t\r\n]+/g, " ").trim();
1005
+ const buildFzfInput = (candidates) => {
1006
+ return candidates.map((candidate) => sanitizeCandidate(candidate)).filter((candidate) => candidate.length > 0).join("\n");
1007
+ };
1008
+ const validateExtraFzfArgs = (fzfExtraArgs) => {
1009
+ for (const arg of fzfExtraArgs) {
1010
+ if (typeof arg !== "string" || arg.length === 0) throw new Error("Empty value is not allowed for --fzf-arg");
1011
+ if (!arg.startsWith("--")) continue;
1012
+ const withoutPrefix = arg.slice(2);
1013
+ if (withoutPrefix.length === 0) continue;
1014
+ const optionName = withoutPrefix.split("=")[0];
1015
+ if (optionName !== void 0 && RESERVED_FZF_ARGS.has(optionName)) throw new Error(`--fzf-arg cannot override reserved fzf option: --${optionName}`);
1016
+ }
1017
+ };
1018
+ const buildFzfArgs = ({ prompt, fzfExtraArgs }) => {
1019
+ validateExtraFzfArgs(fzfExtraArgs);
1020
+ return [
1021
+ `--prompt=${prompt}`,
1022
+ "--layout=reverse",
1023
+ "--height=80%",
1024
+ "--border",
1025
+ ...fzfExtraArgs
1026
+ ];
1027
+ };
1028
+ const defaultCheckFzfAvailability = async () => {
1029
+ try {
1030
+ await execa(FZF_BINARY, ["--version"], { timeout: FZF_CHECK_TIMEOUT_MS });
1031
+ return true;
1032
+ } catch (error) {
1033
+ const execaError = error;
1034
+ if (execaError.code === "ENOENT" || execaError.code === "ETIMEDOUT" || execaError.code === "ERR_EXECA_TIMEOUT" || execaError.timedOut === true) return false;
1035
+ throw error;
1036
+ }
1037
+ };
1038
+ const defaultRunFzf = async ({ args, input, cwd, env }) => {
1039
+ return { stdout: (await execa(FZF_BINARY, args, {
1040
+ input,
1041
+ cwd,
1042
+ env,
1043
+ stderr: "inherit"
1044
+ })).stdout };
1045
+ };
1046
+ const ensureFzfAvailable = async (checkFzfAvailability) => {
1047
+ if (await checkFzfAvailability()) return;
1048
+ throw new Error("fzf is required for interactive selection");
1049
+ };
1050
+ const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", fzfExtraArgs = [], cwd = process.cwd(), env = process.env, isInteractive = () => process.stdout.isTTY === true && process.stderr.isTTY === true, checkFzfAvailability = defaultCheckFzfAvailability, runFzf = defaultRunFzf }) => {
1051
+ if (candidates.length === 0) throw new Error("No candidates provided for fzf selection");
1052
+ if (isInteractive() !== true) throw new Error("fzf selection requires an interactive terminal");
1053
+ await ensureFzfAvailable(checkFzfAvailability);
1054
+ const args = buildFzfArgs({
1055
+ prompt,
1056
+ fzfExtraArgs
1057
+ });
1058
+ const input = buildFzfInput(candidates);
1059
+ if (input.length === 0) throw new Error("All candidates are empty after sanitization");
1060
+ const candidateSet = new Set(input.split("\n"));
1061
+ try {
1062
+ const selectedPath = (await runFzf({
1063
+ args,
1064
+ input,
1065
+ cwd,
1066
+ env
1067
+ })).stdout.trim();
1068
+ if (selectedPath.length === 0) return { status: "cancelled" };
1069
+ if (!candidateSet.has(selectedPath)) throw new Error("fzf returned a value that is not in the candidate list");
1070
+ return {
1071
+ status: "selected",
1072
+ path: selectedPath
1073
+ };
1074
+ } catch (error) {
1075
+ if (error.exitCode === 130) return { status: "cancelled" };
1076
+ throw error;
1077
+ }
1078
+ };
1079
+
1080
+ //#endregion
1081
+ //#region src/utils/logger.ts
1082
+ let LogLevel = /* @__PURE__ */ function(LogLevel) {
1083
+ LogLevel[LogLevel["ERROR"] = 0] = "ERROR";
1084
+ LogLevel[LogLevel["WARN"] = 1] = "WARN";
1085
+ LogLevel[LogLevel["INFO"] = 2] = "INFO";
1086
+ LogLevel[LogLevel["DEBUG"] = 3] = "DEBUG";
1087
+ return LogLevel;
1088
+ }({});
1089
+ const resolveDefaultLogLevel = () => {
1090
+ if (process.env.VDE_WORKTREE_DEBUG === "true" || process.env.VDE_DEBUG === "true") return LogLevel.DEBUG;
1091
+ if (process.env.VDE_WORKTREE_VERBOSE === "true" || process.env.VDE_VERBOSE === "true") return LogLevel.INFO;
1092
+ return LogLevel.WARN;
1093
+ };
1094
+ const formatMessage = (prefix, message) => {
1095
+ return prefix ? `${prefix} ${message}` : message;
1096
+ };
1097
+ const createLogger = (options = {}) => {
1098
+ const level = options.level ?? resolveDefaultLogLevel();
1099
+ const prefix = options.prefix ?? "";
1100
+ const build = (nextPrefix, nextLevel) => {
1101
+ const resolvedPrefix = nextPrefix;
1102
+ return {
1103
+ level: nextLevel,
1104
+ prefix: resolvedPrefix,
1105
+ error(message, error) {
1106
+ if (nextLevel >= LogLevel.ERROR) {
1107
+ console.error(chalk.red(formatMessage(resolvedPrefix, `Error: ${message}`)));
1108
+ if (error && (process.env.VDE_WORKTREE_DEBUG === "true" || process.env.VDE_DEBUG === "true")) console.error(chalk.gray(error.stack));
1109
+ }
1110
+ },
1111
+ warn(message) {
1112
+ if (nextLevel >= LogLevel.WARN) console.warn(chalk.yellow(formatMessage(resolvedPrefix, message)));
1113
+ },
1114
+ info(message) {
1115
+ if (nextLevel >= LogLevel.INFO) console.log(formatMessage(resolvedPrefix, message));
1116
+ },
1117
+ debug(message) {
1118
+ if (nextLevel >= LogLevel.DEBUG) console.log(chalk.gray(formatMessage(resolvedPrefix, `[DEBUG] ${message}`)));
1119
+ },
1120
+ success(message) {
1121
+ console.log(chalk.green(formatMessage(resolvedPrefix, message)));
1122
+ },
1123
+ createChild(suffix) {
1124
+ return build(resolvedPrefix ? `${resolvedPrefix} ${suffix}` : suffix, nextLevel);
1125
+ }
1126
+ };
1127
+ };
1128
+ return build(prefix, level);
1129
+ };
1130
+
1131
+ //#endregion
1132
+ //#region src/cli/package-version.ts
1133
+ const CANDIDATE_PATHS = ["../package.json", "../../package.json"];
1134
+ const isModuleNotFoundError = (error) => {
1135
+ return error instanceof Error && error.code === "MODULE_NOT_FOUND";
1136
+ };
1137
+ const loadPackageVersion = (requireFn) => {
1138
+ let lastNotFound;
1139
+ for (const candidatePath of CANDIDATE_PATHS) try {
1140
+ return requireFn(candidatePath).version;
1141
+ } catch (error) {
1142
+ if (isModuleNotFoundError(error)) {
1143
+ lastNotFound = error;
1144
+ continue;
1145
+ }
1146
+ throw error;
1147
+ }
1148
+ throw lastNotFound ?? /* @__PURE__ */ new Error(`Unable to resolve package version from candidates: ${CANDIDATE_PATHS.join(", ")}`);
1149
+ };
1150
+
1151
+ //#endregion
1152
+ //#region src/cli/index.ts
1153
+ const EXIT_CODE_CANCELLED = 130;
1154
+ const optionNamesAllowOptionLikeValue = new Set(["fzfArg", "fzf-arg"]);
1155
+ const COMPLETION_SHELLS = ["zsh", "fish"];
1156
+ const COMPLETION_FILE_BY_SHELL = {
1157
+ zsh: "zsh/_vw",
1158
+ fish: "fish/vw.fish"
1159
+ };
1160
+ const commandHelpEntries = [
1161
+ {
1162
+ name: "init",
1163
+ usage: "vw init",
1164
+ summary: "Initialize directories, hooks, and managed exclude entries.",
1165
+ details: ["Creates .worktree and .vde/worktree directories.", "Appends managed entries to .git/info/exclude (idempotent)."]
1166
+ },
1167
+ {
1168
+ name: "list",
1169
+ usage: "vw list [--json]",
1170
+ summary: "List worktrees with status metadata.",
1171
+ details: ["Includes branch, path, dirty, lock, merged, and upstream fields."]
1172
+ },
1173
+ {
1174
+ name: "status",
1175
+ usage: "vw status [branch] [--json]",
1176
+ summary: "Show a single worktree status.",
1177
+ details: ["Without branch, resolves from current working directory."]
1178
+ },
1179
+ {
1180
+ name: "path",
1181
+ usage: "vw path <branch> [--json]",
1182
+ summary: "Print absolute worktree path for the branch.",
1183
+ details: []
1184
+ },
1185
+ {
1186
+ name: "new",
1187
+ usage: "vw new [branch]",
1188
+ summary: "Create branch + worktree under .worktree.",
1189
+ details: ["Without branch, generates wip-xxxxxx."]
1190
+ },
1191
+ {
1192
+ name: "switch",
1193
+ usage: "vw switch <branch>",
1194
+ summary: "Idempotent branch entrypoint.",
1195
+ details: ["Reuses existing worktree when present, otherwise creates one."]
1196
+ },
1197
+ {
1198
+ name: "mv",
1199
+ usage: "vw mv <new-branch>",
1200
+ summary: "Rename current non-primary worktree branch and move its directory.",
1201
+ details: ["Requires branch checkout (detached HEAD is rejected)."]
1202
+ },
1203
+ {
1204
+ name: "del",
1205
+ usage: "vw del [branch] [flags]",
1206
+ summary: "Delete worktree + branch with safety checks.",
1207
+ details: ["Default rejects dirty, locked, unmerged/unknown, or unpushed/unknown states.", "For non-TTY force usage, --allow-unsafe is required."],
1208
+ options: [
1209
+ "--force-dirty",
1210
+ "--allow-unpushed",
1211
+ "--force-unmerged",
1212
+ "--force-locked",
1213
+ "--force",
1214
+ "--allow-unsafe"
1215
+ ]
1216
+ },
1217
+ {
1218
+ name: "gone",
1219
+ usage: "vw gone [--json] [--apply|--dry-run]",
1220
+ summary: "Bulk cleanup by safety-filtered candidate selection.",
1221
+ details: ["Default mode is dry-run. Use --apply to delete candidates."]
1222
+ },
1223
+ {
1224
+ name: "get",
1225
+ usage: "vw get <remote/branch>",
1226
+ summary: "Fetch remote branch, create tracking local branch if needed, then attach worktree.",
1227
+ details: ["Example target format: origin/feature/foo."]
1228
+ },
1229
+ {
1230
+ name: "extract",
1231
+ usage: "vw extract --current [--stash]",
1232
+ summary: "Extract current primary branch into .worktree and switch primary back to base.",
1233
+ details: ["Current implementation targets primary worktree extraction flow."],
1234
+ options: [
1235
+ "--current",
1236
+ "--stash",
1237
+ "--from <path>"
1238
+ ]
1239
+ },
1240
+ {
1241
+ name: "use",
1242
+ usage: "vw use <branch> [--allow-agent --allow-unsafe]",
1243
+ summary: "Checkout target branch in primary worktree.",
1244
+ details: ["Non-TTY execution requires --allow-agent and --allow-unsafe."]
1245
+ },
1246
+ {
1247
+ name: "exec",
1248
+ usage: "vw exec <branch> -- <cmd...>",
1249
+ summary: "Run command in target branch worktree.",
1250
+ details: ["Returns exit code 21 when child process exits non-zero."]
1251
+ },
1252
+ {
1253
+ name: "invoke",
1254
+ usage: "vw invoke <pre-*/post-*> [-- <args...>]",
1255
+ summary: "Manually run hook script for debugging/operations.",
1256
+ details: []
1257
+ },
1258
+ {
1259
+ name: "copy",
1260
+ usage: "vw copy <repo-relative-path...>",
1261
+ summary: "Copy repo-root files/dirs to target worktree (typically WT_WORKTREE_PATH).",
1262
+ details: []
1263
+ },
1264
+ {
1265
+ name: "link",
1266
+ usage: "vw link <repo-relative-path...> [--no-fallback]",
1267
+ summary: "Create symlink from target worktree to repo-root file.",
1268
+ details: ["On Windows, fallback copy is used unless --no-fallback is set."]
1269
+ },
1270
+ {
1271
+ name: "lock",
1272
+ usage: "vw lock <branch> [--owner <name>] [--reason <text>]",
1273
+ summary: "Create/update lock metadata to protect worktree from cleanup/deletion.",
1274
+ details: []
1275
+ },
1276
+ {
1277
+ name: "unlock",
1278
+ usage: "vw unlock <branch> [--owner <name>] [--force]",
1279
+ summary: "Remove lock metadata with owner/force checks.",
1280
+ details: []
1281
+ },
1282
+ {
1283
+ name: "cd",
1284
+ usage: "vw cd",
1285
+ summary: "Interactive fzf picker that prints selected worktree absolute path.",
1286
+ details: ["Use with shell: cd \"$(vw cd)\""],
1287
+ options: ["--prompt <text>", "--fzf-arg <arg>"]
1288
+ },
1289
+ {
1290
+ name: "completion",
1291
+ usage: "vw completion <zsh|fish> [--install] [--path <file>]",
1292
+ summary: "Print or install shell completion scripts.",
1293
+ details: ["Without --install, prints completion script to stdout.", "With --install, writes completion file to default shell path or --path."],
1294
+ options: ["--install", "--path <file>"]
1295
+ }
1296
+ ];
1297
+ const splitRawArgsByDoubleDash = (args) => {
1298
+ const separatorIndex = args.indexOf("--");
1299
+ if (separatorIndex < 0) return {
1300
+ beforeDoubleDash: [...args],
1301
+ afterDoubleDash: []
1302
+ };
1303
+ return {
1304
+ beforeDoubleDash: args.slice(0, separatorIndex),
1305
+ afterDoubleDash: args.slice(separatorIndex + 1)
1306
+ };
1307
+ };
1308
+ const toKebabCase = (value) => {
1309
+ return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
1310
+ };
1311
+ const toOptionSpec = (kind, optionName) => {
1312
+ return {
1313
+ kind,
1314
+ allowOptionLikeValue: optionNamesAllowOptionLikeValue.has(optionName)
1315
+ };
1316
+ };
1317
+ const buildOptionSpecs = (argsDef) => {
1318
+ const longOptions = /* @__PURE__ */ new Map();
1319
+ const shortOptions = /* @__PURE__ */ new Map();
1320
+ for (const [argName, arg] of Object.entries(argsDef)) {
1321
+ if (arg.type === "positional") continue;
1322
+ const valueKind = arg.type === "boolean" ? "boolean" : "value";
1323
+ const kebabName = toKebabCase(argName);
1324
+ longOptions.set(argName, toOptionSpec(valueKind, argName));
1325
+ longOptions.set(kebabName, toOptionSpec(valueKind, kebabName));
1326
+ const aliases = "alias" in arg ? Array.isArray(arg.alias) ? arg.alias : typeof arg.alias === "string" ? [arg.alias] : [] : [];
1327
+ for (const alias of aliases) {
1328
+ if (alias.length === 1) {
1329
+ shortOptions.set(alias, toOptionSpec(valueKind, alias));
1330
+ continue;
1331
+ }
1332
+ longOptions.set(alias, toOptionSpec(valueKind, alias));
1333
+ const kebabAlias = toKebabCase(alias);
1334
+ longOptions.set(kebabAlias, toOptionSpec(valueKind, kebabAlias));
1335
+ }
1336
+ }
1337
+ return {
1338
+ longOptions,
1339
+ shortOptions
1340
+ };
1341
+ };
1342
+ const validateRawOptions = (args, optionSpecs) => {
1343
+ for (let index = 0; index < args.length; index += 1) {
1344
+ const token = args[index];
1345
+ if (typeof token !== "string") continue;
1346
+ if (token === "--") break;
1347
+ if (!token.startsWith("-") || token === "-") continue;
1348
+ if (token.startsWith("--")) {
1349
+ const value = token.slice(2);
1350
+ if (value.length === 0) continue;
1351
+ const separatorIndex = value.indexOf("=");
1352
+ const rawOptionName = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
1353
+ const directOptionSpec = optionSpecs.longOptions.get(rawOptionName);
1354
+ const optionNameForNegation = rawOptionName.startsWith("no-") ? rawOptionName.slice(3) : rawOptionName;
1355
+ const optionSpec = directOptionSpec ?? optionSpecs.longOptions.get(optionNameForNegation);
1356
+ if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: --${rawOptionName}` });
1357
+ if (optionSpec.kind === "value") if (separatorIndex >= 0) {
1358
+ if (value.slice(separatorIndex + 1).length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
1359
+ } else {
1360
+ const nextToken = args[index + 1];
1361
+ if (typeof nextToken !== "string" || nextToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
1362
+ if (nextToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
1363
+ index += 1;
1364
+ }
1365
+ continue;
1366
+ }
1367
+ const shortFlags = token.slice(1);
1368
+ for (let flagIndex = 0; flagIndex < shortFlags.length; flagIndex += 1) {
1369
+ const option = shortFlags[flagIndex];
1370
+ if (typeof option !== "string" || option.length === 0) continue;
1371
+ const optionSpec = optionSpecs.shortOptions.get(option);
1372
+ if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: -${option}` });
1373
+ if (optionSpec.kind === "value") {
1374
+ if (flagIndex < shortFlags.length - 1) break;
1375
+ const nextToken = args[index + 1];
1376
+ if (typeof nextToken !== "string" || nextToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
1377
+ if (nextToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
1378
+ index += 1;
1379
+ break;
1380
+ }
1381
+ }
1382
+ }
1383
+ };
1384
+ const getPositionals = (args) => {
1385
+ return args._.filter((value) => typeof value === "string");
1386
+ };
1387
+ const collectOptionValues = ({ args, optionNames }) => {
1388
+ const values = [];
1389
+ const optionNameSet = new Set(optionNames);
1390
+ for (let index = 0; index < args.length; index += 1) {
1391
+ const token = args[index];
1392
+ if (typeof token !== "string") continue;
1393
+ if (token === "--") break;
1394
+ if (!token.startsWith("--")) continue;
1395
+ const eqIndex = token.indexOf("=");
1396
+ const rawName = eqIndex >= 0 ? token.slice(2, eqIndex) : token.slice(2);
1397
+ if (optionNameSet.has(rawName) !== true) continue;
1398
+ if (eqIndex >= 0) {
1399
+ values.push(token.slice(eqIndex + 1));
1400
+ continue;
1401
+ }
1402
+ const nextToken = args[index + 1];
1403
+ if (typeof nextToken === "string") {
1404
+ values.push(nextToken);
1405
+ index += 1;
1406
+ }
1407
+ }
1408
+ return values;
1409
+ };
1410
+ const toNumberOption = ({ value, optionName }) => {
1411
+ if (value === void 0) return;
1412
+ if (typeof value !== "string") throw createCliError("INVALID_ARGUMENT", { message: `${optionName} must be a number` });
1413
+ const parsed = Number.parseInt(value, 10);
1414
+ if (Number.isFinite(parsed) !== true || parsed <= 0) throw createCliError("INVALID_ARGUMENT", { message: `${optionName} must be a positive integer` });
1415
+ return parsed;
1416
+ };
1417
+ const ensureArgumentCount = ({ command, args, min, max }) => {
1418
+ if (args.length < min || args.length > max) throw createCliError("INVALID_ARGUMENT", {
1419
+ message: `${command} expects ${String(min)}-${String(max)} positional argument(s), received ${String(args.length)}`,
1420
+ details: {
1421
+ command,
1422
+ args
1423
+ }
1424
+ });
1425
+ };
1426
+ const ensureHasCommandAfterDoubleDash = ({ command, argsAfterDoubleDash }) => {
1427
+ if (argsAfterDoubleDash.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `${command} requires arguments after --` });
1428
+ };
1429
+ const readGitConfigInt = async (repoRoot, key) => {
1430
+ const result = await runGitCommand({
1431
+ cwd: repoRoot,
1432
+ args: [
1433
+ "config",
1434
+ "--get",
1435
+ key
1436
+ ],
1437
+ reject: false
1438
+ });
1439
+ if (result.exitCode !== 0) return;
1440
+ const parsed = Number.parseInt(result.stdout.trim(), 10);
1441
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1442
+ };
1443
+ const readGitConfigBoolean = async (repoRoot, key) => {
1444
+ const result = await runGitCommand({
1445
+ cwd: repoRoot,
1446
+ args: [
1447
+ "config",
1448
+ "--bool",
1449
+ "--get",
1450
+ key
1451
+ ],
1452
+ reject: false
1453
+ });
1454
+ if (result.exitCode !== 0) return;
1455
+ const value = result.stdout.trim().toLowerCase();
1456
+ if (value === "true" || value === "yes" || value === "on" || value === "1") return true;
1457
+ if (value === "false" || value === "no" || value === "off" || value === "0") return false;
1458
+ };
1459
+ const resolveConfiguredBaseRemote = async (repoRoot) => {
1460
+ const configured = await runGitCommand({
1461
+ cwd: repoRoot,
1462
+ args: [
1463
+ "config",
1464
+ "--get",
1465
+ "vde-worktree.baseRemote"
1466
+ ],
1467
+ reject: false
1468
+ });
1469
+ if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
1470
+ return "origin";
1471
+ };
1472
+ const resolveBaseBranch = async (repoRoot) => {
1473
+ const configured = await runGitCommand({
1474
+ cwd: repoRoot,
1475
+ args: [
1476
+ "config",
1477
+ "--get",
1478
+ "vde-worktree.baseBranch"
1479
+ ],
1480
+ reject: false
1481
+ });
1482
+ if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
1483
+ const remotesToProbe = [
1484
+ await resolveConfiguredBaseRemote(repoRoot),
1485
+ "origin",
1486
+ "upstream"
1487
+ ].filter((value, index, arr) => {
1488
+ return arr.indexOf(value) === index;
1489
+ });
1490
+ for (const remote of remotesToProbe) {
1491
+ const resolved = await runGitCommand({
1492
+ cwd: repoRoot,
1493
+ args: [
1494
+ "symbolic-ref",
1495
+ "--quiet",
1496
+ "--short",
1497
+ `refs/remotes/${remote}/HEAD`
1498
+ ],
1499
+ reject: false
1500
+ });
1501
+ if (resolved.exitCode !== 0) continue;
1502
+ const raw = resolved.stdout.trim();
1503
+ const prefix = `${remote}/`;
1504
+ if (raw.startsWith(prefix)) return raw.slice(prefix.length);
1505
+ }
1506
+ for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
1507
+ throw createCliError("INVALID_ARGUMENT", { message: "Unable to resolve base branch. Configure vde-worktree.baseBranch." });
1508
+ };
1509
+ const ensureTargetPathWritable = async (targetPath) => {
1510
+ try {
1511
+ await access(targetPath, constants.F_OK);
1512
+ } catch {
1513
+ return;
1514
+ }
1515
+ if ((await readdir(targetPath)).length > 0) throw createCliError("TARGET_PATH_NOT_EMPTY", {
1516
+ message: `Target path is not empty: ${targetPath}`,
1517
+ details: { path: targetPath }
1518
+ });
1519
+ };
1520
+ const buildJsonSuccess = ({ command, status, repoRoot, details }) => {
1521
+ return {
1522
+ schemaVersion: SCHEMA_VERSION,
1523
+ command,
1524
+ status,
1525
+ repoRoot,
1526
+ ...details ?? {}
1527
+ };
1528
+ };
1529
+ const buildJsonError = ({ command, repoRoot, error }) => {
1530
+ return {
1531
+ schemaVersion: SCHEMA_VERSION,
1532
+ command,
1533
+ status: "error",
1534
+ repoRoot,
1535
+ code: error.code,
1536
+ message: error.message,
1537
+ details: error.details
1538
+ };
1539
+ };
1540
+ const resolveTargetWorktreeByBranch = ({ branch, worktrees }) => {
1541
+ const found = worktrees.find((worktree) => worktree.branch === branch);
1542
+ if (found !== void 0) return found;
1543
+ throw createCliError("WORKTREE_NOT_FOUND", {
1544
+ message: `Worktree not found for branch: ${branch}`,
1545
+ details: { branch }
1546
+ });
1547
+ };
1548
+ const resolveCurrentWorktree = ({ snapshot, currentWorktreeRoot }) => {
1549
+ const directMatch = snapshot.worktrees.find((worktree) => worktree.path === currentWorktreeRoot);
1550
+ if (directMatch !== void 0) return directMatch;
1551
+ const containing = snapshot.worktrees.find((worktree) => {
1552
+ return currentWorktreeRoot.startsWith(`${worktree.path}${sep}`);
1553
+ });
1554
+ if (containing !== void 0) return containing;
1555
+ throw createCliError("WORKTREE_NOT_FOUND", {
1556
+ message: "No worktree found for current location",
1557
+ details: { currentWorktreeRoot }
1558
+ });
1559
+ };
1560
+ const validateInitializedForWrite = async (repoRoot) => {
1561
+ if (await isInitialized(repoRoot)) return;
1562
+ throw createCliError("NOT_INITIALIZED", {
1563
+ message: "Repository is not initialized. Run `vde-worktree init` first.",
1564
+ details: { repoRoot }
1565
+ });
1566
+ };
1567
+ const randomWipBranchName = () => {
1568
+ const random = Math.floor(Math.random() * 1e6);
1569
+ return `wip-${String(random).padStart(6, "0")}`;
1570
+ };
1571
+ const parseForceFlags = (parsedArgs) => {
1572
+ const globalForce = parsedArgs.force === true;
1573
+ return {
1574
+ forceDirty: globalForce || parsedArgs.forceDirty === true,
1575
+ allowUnpushed: globalForce || parsedArgs.allowUnpushed === true,
1576
+ forceUnmerged: globalForce || parsedArgs.forceUnmerged === true,
1577
+ forceLocked: globalForce || parsedArgs.forceLocked === true
1578
+ };
1579
+ };
1580
+ const hasAnyForceFlag = (flags) => {
1581
+ return flags.forceDirty || flags.allowUnpushed || flags.forceUnmerged || flags.forceLocked;
1582
+ };
1583
+ const ensureUnsafeForNonTty = ({ runtime, reason }) => {
1584
+ if (runtime.isInteractive || runtime.allowUnsafe) return;
1585
+ throw createCliError("UNSAFE_FLAG_REQUIRED", { message: `UNSAFE_FLAG_REQUIRED: ${reason}` });
1586
+ };
1587
+ const resolveRemoteAndBranch = (remoteBranch) => {
1588
+ const separatorIndex = remoteBranch.indexOf("/");
1589
+ if (separatorIndex <= 0 || separatorIndex >= remoteBranch.length - 1) throw createCliError("INVALID_REMOTE_BRANCH_FORMAT", {
1590
+ message: `Invalid remote branch format: ${remoteBranch}`,
1591
+ details: { value: remoteBranch }
1592
+ });
1593
+ return {
1594
+ remote: remoteBranch.slice(0, separatorIndex),
1595
+ branch: remoteBranch.slice(separatorIndex + 1)
1596
+ };
1597
+ };
1598
+ const resolveCompletionShell = (value) => {
1599
+ if (COMPLETION_SHELLS.includes(value)) return value;
1600
+ throw createCliError("INVALID_ARGUMENT", {
1601
+ message: `Unsupported shell for completion: ${value}`,
1602
+ details: {
1603
+ value,
1604
+ supported: COMPLETION_SHELLS
1605
+ }
1606
+ });
1607
+ };
1608
+ const resolveCompletionSourceCandidates = (shell) => {
1609
+ const relativeCompletionFile = COMPLETION_FILE_BY_SHELL[shell];
1610
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
1611
+ return [
1612
+ resolve(moduleDirectory, "..", "..", "completions", relativeCompletionFile),
1613
+ resolve(moduleDirectory, "..", "completions", relativeCompletionFile),
1614
+ resolve(process.cwd(), "completions", relativeCompletionFile)
1615
+ ];
1616
+ };
1617
+ const loadCompletionScript = async (shell) => {
1618
+ const candidates = resolveCompletionSourceCandidates(shell);
1619
+ for (const candidate of candidates) try {
1620
+ return await readFile(candidate, "utf8");
1621
+ } catch {
1622
+ continue;
1623
+ }
1624
+ throw createCliError("INTERNAL_ERROR", {
1625
+ message: `Completion template not found for shell: ${shell}`,
1626
+ details: {
1627
+ shell,
1628
+ candidates
1629
+ }
1630
+ });
1631
+ };
1632
+ const resolveDefaultCompletionInstallPath = (shell) => {
1633
+ const homeDirectory = homedir();
1634
+ if (homeDirectory.length === 0) throw createCliError("INTERNAL_ERROR", {
1635
+ message: "Unable to resolve home directory for completion installation",
1636
+ details: { shell }
1637
+ });
1638
+ if (shell === "zsh") return join(homeDirectory, ".zsh", "completions", "_vw");
1639
+ return join(homeDirectory, ".config", "fish", "completions", "vw.fish");
1640
+ };
1641
+ const resolveCompletionInstallPath = ({ shell, requestedPath }) => {
1642
+ if (typeof requestedPath === "string" && requestedPath.trim().length > 0) return resolve(requestedPath);
1643
+ return resolveDefaultCompletionInstallPath(shell);
1644
+ };
1645
+ const installCompletionScript = async ({ content, destinationPath }) => {
1646
+ await mkdir(dirname(destinationPath), { recursive: true });
1647
+ await writeFile(destinationPath, content, "utf8");
1648
+ };
1649
+ const normalizeHookName = (value) => {
1650
+ if (/^(pre|post)-[a-z0-9][a-z0-9-]*$/.test(value) !== true) throw createCliError("INVALID_ARGUMENT", {
1651
+ message: "hookName must be pre-* or post-*",
1652
+ details: { hookName: value }
1653
+ });
1654
+ return value;
1655
+ };
1656
+ const defaultOwner = () => {
1657
+ return process.env.USER ?? process.env.USERNAME ?? "unknown";
1658
+ };
1659
+ const resolveBranchDeleteMode = (forceFlags) => {
1660
+ if (forceFlags.forceDirty || forceFlags.forceUnmerged || forceFlags.allowUnpushed || forceFlags.forceLocked) return "-D";
1661
+ return "-d";
1662
+ };
1663
+ const validateDeleteSafety = ({ target, forceFlags }) => {
1664
+ if (target.dirty && forceFlags.forceDirty !== true) throw createCliError("DIRTY_WORKTREE", {
1665
+ message: "Worktree has uncommitted changes",
1666
+ details: {
1667
+ branch: target.branch,
1668
+ path: target.path
1669
+ }
1670
+ });
1671
+ if (target.locked.value && forceFlags.forceLocked !== true) throw createCliError("LOCKED_WORKTREE", {
1672
+ message: "Worktree is locked",
1673
+ details: {
1674
+ branch: target.branch,
1675
+ path: target.path,
1676
+ reason: target.locked.reason
1677
+ }
1678
+ });
1679
+ if (target.merged.overall !== true && forceFlags.forceUnmerged !== true) throw createCliError("UNMERGED_WORKTREE", {
1680
+ message: "Worktree is not merged (or merge state is unknown)",
1681
+ details: {
1682
+ branch: target.branch,
1683
+ path: target.path,
1684
+ merged: target.merged
1685
+ }
1686
+ });
1687
+ if ((target.upstream.ahead === null || target.upstream.ahead > 0) && forceFlags.allowUnpushed !== true) throw createCliError("UNPUSHED_WORKTREE", {
1688
+ message: "Worktree has unpushed commits (or push state is unknown)",
1689
+ details: {
1690
+ branch: target.branch,
1691
+ path: target.path,
1692
+ upstream: target.upstream
1693
+ }
1694
+ });
1695
+ };
1696
+ const resolveLinkTargetPath = ({ sourcePath, destinationPath }) => {
1697
+ return relative(dirname(destinationPath), sourcePath);
1698
+ };
1699
+ const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
1700
+ const sourcePath = resolveRepoRelativePath({
1701
+ repoRoot,
1702
+ relativePath
1703
+ });
1704
+ const relativeFromRoot = relative(repoRoot, sourcePath);
1705
+ return {
1706
+ sourcePath,
1707
+ destinationPath: ensurePathInsideRepo({
1708
+ repoRoot,
1709
+ path: resolve(worktreePath, relativeFromRoot)
1710
+ }),
1711
+ relativeFromRoot
1712
+ };
1713
+ };
1714
+ const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
1715
+ if (branch !== baseBranch) return;
1716
+ throw createCliError("INVALID_ARGUMENT", {
1717
+ message: "extract cannot target the base branch",
1718
+ details: {
1719
+ branch,
1720
+ baseBranch
1721
+ }
1722
+ });
1723
+ };
1724
+ const formatDisplayPath = (absolutePath) => {
1725
+ const homeDirectory = homedir();
1726
+ if (homeDirectory.length === 0) return absolutePath;
1727
+ if (absolutePath === homeDirectory) return "~";
1728
+ if (absolutePath.startsWith(`${homeDirectory}${sep}`)) return `~${absolutePath.slice(homeDirectory.length)}`;
1729
+ return absolutePath;
1730
+ };
1731
+ const padEndByWidth = (value, targetWidth) => {
1732
+ const width = stringWidth(value);
1733
+ if (width >= targetWidth) return value;
1734
+ return `${value}${" ".repeat(targetWidth - width)}`;
1735
+ };
1736
+ const containsBranch = ({ branch, worktrees }) => {
1737
+ return worktrees.some((worktree) => worktree.branch === branch);
1738
+ };
1739
+ const readStringOption = (parsedArgsRecord, key) => {
1740
+ const value = parsedArgsRecord[key];
1741
+ if (typeof value === "string") return value;
1742
+ };
1743
+ const findCommandHelp = (commandName) => {
1744
+ return commandHelpEntries.find((entry) => entry.name === commandName);
1745
+ };
1746
+ const renderGeneralHelpText = ({ version }) => {
1747
+ const commandList = commandHelpEntries.map((entry) => ` ${entry.name.padEnd(8)} ${entry.summary}`).join("\n");
1748
+ return [
1749
+ "vde-worktree",
1750
+ "",
1751
+ "Usage:",
1752
+ " vw <command> [options]",
1753
+ " vde-worktree <command> [options]",
1754
+ "",
1755
+ `Version: ${version}`,
1756
+ "",
1757
+ "Commands:",
1758
+ commandList,
1759
+ "",
1760
+ "Global options:",
1761
+ " --json Output machine-readable JSON.",
1762
+ " --verbose Enable verbose logs.",
1763
+ " --no-hooks Disable hooks for this run (requires --allow-unsafe).",
1764
+ " --allow-unsafe Explicitly allow unsafe behavior in non-TTY mode.",
1765
+ " --hook-timeout-ms <ms> Override hook timeout.",
1766
+ " --lock-timeout-ms <ms> Override repository lock timeout.",
1767
+ " -h, --help Show help.",
1768
+ " -v, --version Show version.",
1769
+ "",
1770
+ "Help commands:",
1771
+ " vw help",
1772
+ " vw help <command>",
1773
+ " vw <command> --help",
1774
+ "",
1775
+ "Examples:",
1776
+ " vw switch feature/foo",
1777
+ " cd \"$(vw cd)\"",
1778
+ " vw completion zsh --install",
1779
+ " vw del feature/foo --force-unmerged --allow-unpushed --allow-unsafe"
1780
+ ].join("\n");
1781
+ };
1782
+ const renderCommandHelpText = ({ entry }) => {
1783
+ const lines = [
1784
+ `Command: ${entry.name}`,
1785
+ "",
1786
+ "Usage:",
1787
+ ` ${entry.usage}`,
1788
+ "",
1789
+ "Summary:",
1790
+ ` ${entry.summary}`
1791
+ ];
1792
+ if (entry.details.length > 0) {
1793
+ lines.push("", "Details:");
1794
+ for (const detail of entry.details) lines.push(` - ${detail}`);
1795
+ }
1796
+ if (entry.options !== void 0 && entry.options.length > 0) {
1797
+ lines.push("", "Options:");
1798
+ for (const option of entry.options) lines.push(` - ${option}`);
1799
+ }
1800
+ if (entry.examples !== void 0 && entry.examples.length > 0) {
1801
+ lines.push("", "Examples:");
1802
+ for (const example of entry.examples) lines.push(` ${example}`);
1803
+ }
1804
+ lines.push("", "Show all commands: vw help");
1805
+ return lines.join("\n");
1806
+ };
1807
+ const createHookContext = ({ runtime, repoRoot, action, branch, worktreePath, stderr, extraEnv }) => {
1808
+ return {
1809
+ repoRoot,
1810
+ action,
1811
+ branch,
1812
+ worktreePath,
1813
+ timeoutMs: runtime.hookTimeoutMs,
1814
+ enabled: runtime.hooksEnabled,
1815
+ strictPostHooks: runtime.strictPostHooks,
1816
+ stderr,
1817
+ extraEnv
1818
+ };
1819
+ };
1820
+ const createCli = (options = {}) => {
1821
+ const require = createRequire(import.meta.url);
1822
+ const version = options.version ?? (() => {
1823
+ try {
1824
+ return loadPackageVersion(require);
1825
+ } catch {
1826
+ return "0.0.0";
1827
+ }
1828
+ })();
1829
+ const runtimeCwd = options.cwd ?? process.cwd();
1830
+ const stdout = options.stdout ?? ((line) => console.log(line));
1831
+ const stderr = options.stderr ?? ((line) => console.error(line));
1832
+ const selectPathWithFzf$1 = options.selectPathWithFzf ?? selectPathWithFzf;
1833
+ const isInteractiveFn = options.isInteractive ?? (() => process.stdout.isTTY === true && process.stderr.isTTY === true);
1834
+ let logger = createLogger();
1835
+ const rootArgsDef = {
1836
+ command: {
1837
+ type: "positional",
1838
+ description: "Command name",
1839
+ required: false
1840
+ },
1841
+ prompt: {
1842
+ type: "string",
1843
+ valueHint: "text",
1844
+ description: "Custom fzf prompt for cd command"
1845
+ },
1846
+ fzfArg: {
1847
+ type: "string",
1848
+ valueHint: "arg",
1849
+ description: "Additional argument passed to fzf (repeatable)"
1850
+ },
1851
+ json: {
1852
+ type: "boolean",
1853
+ description: "Output JSON on stdout"
1854
+ },
1855
+ verbose: {
1856
+ type: "boolean",
1857
+ description: "Show detailed logs"
1858
+ },
1859
+ hooks: {
1860
+ type: "boolean",
1861
+ description: "Enable hooks (disable with --no-hooks)",
1862
+ default: true
1863
+ },
1864
+ allowUnsafe: {
1865
+ type: "boolean",
1866
+ description: "Allow unsafe operations"
1867
+ },
1868
+ strictPostHooks: {
1869
+ type: "boolean",
1870
+ description: "Fail when post hooks fail"
1871
+ },
1872
+ hookTimeoutMs: {
1873
+ type: "string",
1874
+ valueHint: "ms",
1875
+ description: "Override hook timeout (ms)"
1876
+ },
1877
+ lockTimeoutMs: {
1878
+ type: "string",
1879
+ valueHint: "ms",
1880
+ description: "Override lock timeout (ms)"
1881
+ },
1882
+ allowAgent: {
1883
+ type: "boolean",
1884
+ description: "Allow non-TTY execution for use command"
1885
+ },
1886
+ reason: {
1887
+ type: "string",
1888
+ valueHint: "text",
1889
+ description: "Reason text for lock command"
1890
+ },
1891
+ owner: {
1892
+ type: "string",
1893
+ valueHint: "owner",
1894
+ description: "Owner for lock/unlock commands"
1895
+ },
1896
+ force: {
1897
+ type: "boolean",
1898
+ description: "Force operation"
1899
+ },
1900
+ forceDirty: {
1901
+ type: "boolean",
1902
+ description: "Allow dirty worktree for del"
1903
+ },
1904
+ allowUnpushed: {
1905
+ type: "boolean",
1906
+ description: "Allow unpushed commits for del"
1907
+ },
1908
+ forceUnmerged: {
1909
+ type: "boolean",
1910
+ description: "Allow unmerged worktree for del"
1911
+ },
1912
+ forceLocked: {
1913
+ type: "boolean",
1914
+ description: "Allow deleting locked worktree"
1915
+ },
1916
+ apply: {
1917
+ type: "boolean",
1918
+ description: "Apply changes"
1919
+ },
1920
+ dryRun: {
1921
+ type: "boolean",
1922
+ description: "Dry-run mode"
1923
+ },
1924
+ current: {
1925
+ type: "boolean",
1926
+ description: "Use current worktree for extract"
1927
+ },
1928
+ from: {
1929
+ type: "string",
1930
+ valueHint: "path",
1931
+ description: "Path used by extract --from"
1932
+ },
1933
+ stash: {
1934
+ type: "boolean",
1935
+ description: "Allow stash for extract"
1936
+ },
1937
+ fallback: {
1938
+ type: "boolean",
1939
+ description: "Enable fallback behavior (disable with --no-fallback)",
1940
+ default: true
1941
+ },
1942
+ install: {
1943
+ type: "boolean",
1944
+ description: "Install generated artifacts to default location (used by completion command)"
1945
+ },
1946
+ path: {
1947
+ type: "string",
1948
+ valueHint: "path",
1949
+ description: "Custom file path (used by completion command install)"
1950
+ },
1951
+ help: {
1952
+ type: "boolean",
1953
+ alias: "h",
1954
+ description: "Show help"
1955
+ },
1956
+ version: {
1957
+ type: "boolean",
1958
+ alias: "v",
1959
+ description: "Show version"
1960
+ }
1961
+ };
1962
+ const optionSpecs = buildOptionSpecs(rootArgsDef);
1963
+ const run = async (rawArgs = process.argv.slice(2)) => {
1964
+ logger = createLogger();
1965
+ let command = "unknown";
1966
+ let jsonEnabled = false;
1967
+ let repoRootForJson = null;
1968
+ try {
1969
+ const { beforeDoubleDash, afterDoubleDash } = splitRawArgsByDoubleDash(rawArgs);
1970
+ validateRawOptions(beforeDoubleDash, optionSpecs);
1971
+ const parsedArgs = parseArgs(beforeDoubleDash, rootArgsDef);
1972
+ const parsedArgsRecord = parsedArgs;
1973
+ const positionals = getPositionals(parsedArgs);
1974
+ command = positionals[0] ?? "unknown";
1975
+ jsonEnabled = parsedArgs.json === true;
1976
+ if (parsedArgs.help === true) {
1977
+ const commandHelpTarget = typeof command === "string" && command !== "unknown" && command !== "help" ? command : null;
1978
+ if (commandHelpTarget !== null) {
1979
+ const entry = findCommandHelp(commandHelpTarget);
1980
+ if (entry !== void 0) {
1981
+ stdout(`${renderCommandHelpText({ entry })}\n`);
1982
+ return EXIT_CODE.OK;
1983
+ }
1984
+ }
1985
+ stdout(`${renderGeneralHelpText({ version })}\n`);
1986
+ return EXIT_CODE.OK;
1987
+ }
1988
+ if (parsedArgs.version === true) {
1989
+ stdout(version);
1990
+ return EXIT_CODE.OK;
1991
+ }
1992
+ logger = parsedArgs.verbose === true ? createLogger({ level: LogLevel.INFO }) : createLogger();
1993
+ if (positionals.length === 0) {
1994
+ stdout(`${renderGeneralHelpText({ version })}\n`);
1995
+ return EXIT_CODE.OK;
1996
+ }
1997
+ if (command === "help") {
1998
+ const helpTarget = positionals[1];
1999
+ if (typeof helpTarget !== "string" || helpTarget.length === 0) {
2000
+ stdout(`${renderGeneralHelpText({ version })}\n`);
2001
+ return EXIT_CODE.OK;
2002
+ }
2003
+ const entry = findCommandHelp(helpTarget);
2004
+ if (entry === void 0) throw createCliError("INVALID_ARGUMENT", {
2005
+ message: `Unknown command for help: ${helpTarget}`,
2006
+ details: {
2007
+ requested: helpTarget,
2008
+ availableCommands: commandHelpEntries.map((item) => item.name)
2009
+ }
2010
+ });
2011
+ stdout(`${renderCommandHelpText({ entry })}\n`);
2012
+ return EXIT_CODE.OK;
2013
+ }
2014
+ const commandArgs = positionals.slice(1);
2015
+ if (command === "completion") {
2016
+ ensureArgumentCount({
2017
+ command,
2018
+ args: commandArgs,
2019
+ min: 1,
2020
+ max: 1
2021
+ });
2022
+ const shell = resolveCompletionShell(commandArgs[0]);
2023
+ const script = await loadCompletionScript(shell);
2024
+ if (parsedArgs.install === true) {
2025
+ const destinationPath = resolveCompletionInstallPath({
2026
+ shell,
2027
+ requestedPath: readStringOption(parsedArgsRecord, "path")
2028
+ });
2029
+ await installCompletionScript({
2030
+ content: script,
2031
+ destinationPath
2032
+ });
2033
+ if (jsonEnabled) {
2034
+ stdout(JSON.stringify(buildJsonSuccess({
2035
+ command,
2036
+ status: "ok",
2037
+ repoRoot: null,
2038
+ details: {
2039
+ shell,
2040
+ installed: true,
2041
+ path: destinationPath
2042
+ }
2043
+ })));
2044
+ return EXIT_CODE.OK;
2045
+ }
2046
+ stdout(`installed completion: ${destinationPath}`);
2047
+ if (shell === "zsh") stdout("zsh note: ensure completion path is in fpath, then run: autoload -Uz compinit && compinit");
2048
+ return EXIT_CODE.OK;
2049
+ }
2050
+ if (jsonEnabled) {
2051
+ stdout(JSON.stringify(buildJsonSuccess({
2052
+ command,
2053
+ status: "ok",
2054
+ repoRoot: null,
2055
+ details: {
2056
+ shell,
2057
+ installed: false,
2058
+ script
2059
+ }
2060
+ })));
2061
+ return EXIT_CODE.OK;
2062
+ }
2063
+ stdout(script);
2064
+ return EXIT_CODE.OK;
2065
+ }
2066
+ const allowUnsafe = parsedArgs.allowUnsafe === true;
2067
+ if (parsedArgs.hooks === false && allowUnsafe !== true) throw createCliError("UNSAFE_FLAG_REQUIRED", { message: "UNSAFE_FLAG_REQUIRED: --no-hooks requires --allow-unsafe" });
2068
+ const repoContext = await resolveRepoContext(runtimeCwd);
2069
+ const repoRoot = repoContext.repoRoot;
2070
+ repoRootForJson = repoRoot;
2071
+ const configuredHookTimeoutMs = await readGitConfigInt(repoRoot, "vde-worktree.hookTimeoutMs");
2072
+ const configuredLockTimeoutMs = await readGitConfigInt(repoRoot, "vde-worktree.lockTimeoutMs");
2073
+ const configuredStaleTTL = await readGitConfigInt(repoRoot, "vde-worktree.staleLockTTLSeconds");
2074
+ const configuredHooksEnabled = await readGitConfigBoolean(repoRoot, "vde-worktree.hooksEnabled");
2075
+ const runtime = {
2076
+ command,
2077
+ json: jsonEnabled,
2078
+ hooksEnabled: parsedArgs.hooks !== false && configuredHooksEnabled !== false,
2079
+ strictPostHooks: parsedArgs.strictPostHooks === true,
2080
+ hookTimeoutMs: readNumberFromEnvOrDefault({
2081
+ rawValue: toNumberOption({
2082
+ value: parsedArgs.hookTimeoutMs,
2083
+ optionName: "--hook-timeout-ms"
2084
+ }) ?? configuredHookTimeoutMs,
2085
+ defaultValue: DEFAULT_HOOK_TIMEOUT_MS
2086
+ }),
2087
+ lockTimeoutMs: readNumberFromEnvOrDefault({
2088
+ rawValue: toNumberOption({
2089
+ value: parsedArgs.lockTimeoutMs,
2090
+ optionName: "--lock-timeout-ms"
2091
+ }) ?? configuredLockTimeoutMs,
2092
+ defaultValue: DEFAULT_LOCK_TIMEOUT_MS
2093
+ }),
2094
+ allowUnsafe,
2095
+ isInteractive: isInteractiveFn()
2096
+ };
2097
+ const staleLockTTLSeconds = readNumberFromEnvOrDefault({
2098
+ rawValue: configuredStaleTTL,
2099
+ defaultValue: DEFAULT_STALE_LOCK_TTL_SECONDS
2100
+ });
2101
+ const runWriteOperation = async (task) => {
2102
+ if (WRITE_COMMANDS.has(command) !== true) return task();
2103
+ if (command !== "init") await validateInitializedForWrite(repoRoot);
2104
+ return withRepoLock({
2105
+ repoRoot,
2106
+ command,
2107
+ timeoutMs: runtime.lockTimeoutMs,
2108
+ staleLockTTLSeconds
2109
+ }, task);
2110
+ };
2111
+ if (command === "init") {
2112
+ ensureArgumentCount({
2113
+ command,
2114
+ args: commandArgs,
2115
+ min: 0,
2116
+ max: 0
2117
+ });
2118
+ const result = await runWriteOperation(async () => {
2119
+ const hookContext = createHookContext({
2120
+ runtime,
2121
+ repoRoot,
2122
+ action: "init",
2123
+ branch: null,
2124
+ worktreePath: repoRoot,
2125
+ stderr
2126
+ });
2127
+ await runPreHook({
2128
+ name: "init",
2129
+ context: hookContext
2130
+ });
2131
+ const initialized = await initializeRepository(repoRoot);
2132
+ await runPostHook({
2133
+ name: "init",
2134
+ context: hookContext
2135
+ });
2136
+ return initialized;
2137
+ });
2138
+ if (runtime.json) {
2139
+ stdout(JSON.stringify(buildJsonSuccess({
2140
+ command,
2141
+ status: "ok",
2142
+ repoRoot,
2143
+ details: {
2144
+ initialized: true,
2145
+ alreadyInitialized: result.alreadyInitialized
2146
+ }
2147
+ })));
2148
+ return EXIT_CODE.OK;
2149
+ }
2150
+ return EXIT_CODE.OK;
2151
+ }
2152
+ if (command === "list") {
2153
+ ensureArgumentCount({
2154
+ command,
2155
+ args: commandArgs,
2156
+ min: 0,
2157
+ max: 0
2158
+ });
2159
+ const snapshot = await collectWorktreeSnapshot(repoRoot);
2160
+ if (runtime.json) {
2161
+ stdout(JSON.stringify(buildJsonSuccess({
2162
+ command,
2163
+ status: "ok",
2164
+ repoRoot,
2165
+ details: {
2166
+ baseBranch: snapshot.baseBranch,
2167
+ worktrees: snapshot.worktrees
2168
+ }
2169
+ })));
2170
+ return EXIT_CODE.OK;
2171
+ }
2172
+ const rows = snapshot.worktrees.map((worktree) => {
2173
+ return {
2174
+ branch: worktree.branch ?? "(detached)",
2175
+ path: formatDisplayPath(worktree.path)
2176
+ };
2177
+ });
2178
+ const branchWidth = rows.reduce((max, row) => {
2179
+ return Math.max(max, stringWidth(row.branch));
2180
+ }, 0);
2181
+ for (const row of rows) stdout(`${padEndByWidth(row.branch, branchWidth)} ${row.path}`);
2182
+ return EXIT_CODE.OK;
2183
+ }
2184
+ if (command === "status") {
2185
+ ensureArgumentCount({
2186
+ command,
2187
+ args: commandArgs,
2188
+ min: 0,
2189
+ max: 1
2190
+ });
2191
+ const snapshot = await collectWorktreeSnapshot(repoRoot);
2192
+ const targetBranch = commandArgs[0];
2193
+ const targetWorktree = typeof targetBranch === "string" && targetBranch.length > 0 ? resolveTargetWorktreeByBranch({
2194
+ branch: targetBranch,
2195
+ worktrees: snapshot.worktrees
2196
+ }) : resolveCurrentWorktree({
2197
+ snapshot,
2198
+ currentWorktreeRoot: repoContext.currentWorktreeRoot
2199
+ });
2200
+ if (runtime.json) {
2201
+ stdout(JSON.stringify(buildJsonSuccess({
2202
+ command,
2203
+ status: "ok",
2204
+ repoRoot,
2205
+ details: { worktree: targetWorktree }
2206
+ })));
2207
+ return EXIT_CODE.OK;
2208
+ }
2209
+ stdout(`branch: ${targetWorktree.branch ?? "(detached)"}`);
2210
+ stdout(`path: ${formatDisplayPath(targetWorktree.path)}`);
2211
+ stdout(`dirty: ${targetWorktree.dirty ? "true" : "false"}`);
2212
+ stdout(`locked: ${targetWorktree.locked.value ? "true" : "false"}`);
2213
+ return EXIT_CODE.OK;
2214
+ }
2215
+ if (command === "path") {
2216
+ ensureArgumentCount({
2217
+ command,
2218
+ args: commandArgs,
2219
+ min: 1,
2220
+ max: 1
2221
+ });
2222
+ const branch = commandArgs[0];
2223
+ const target = resolveTargetWorktreeByBranch({
2224
+ branch,
2225
+ worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
2226
+ });
2227
+ if (runtime.json) {
2228
+ stdout(JSON.stringify(buildJsonSuccess({
2229
+ command,
2230
+ status: "ok",
2231
+ repoRoot,
2232
+ details: {
2233
+ branch,
2234
+ path: target.path
2235
+ }
2236
+ })));
2237
+ return EXIT_CODE.OK;
2238
+ }
2239
+ stdout(target.path);
2240
+ return EXIT_CODE.OK;
2241
+ }
2242
+ if (command === "new") {
2243
+ ensureArgumentCount({
2244
+ command,
2245
+ args: commandArgs,
2246
+ min: 0,
2247
+ max: 1
2248
+ });
2249
+ const branch = commandArgs[0] ?? randomWipBranchName();
2250
+ const result = await runWriteOperation(async () => {
2251
+ if (containsBranch({
2252
+ branch,
2253
+ worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
2254
+ })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
2255
+ message: `Branch is already attached to a worktree: ${branch}`,
2256
+ details: { branch }
2257
+ });
2258
+ if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
2259
+ message: `Branch already exists locally: ${branch}`,
2260
+ details: { branch }
2261
+ });
2262
+ const targetPath = branchToWorktreePath(repoRoot, branch);
2263
+ await ensureTargetPathWritable(targetPath);
2264
+ const baseBranch = await resolveBaseBranch(repoRoot);
2265
+ const hookContext = createHookContext({
2266
+ runtime,
2267
+ repoRoot,
2268
+ action: "new",
2269
+ branch,
2270
+ worktreePath: targetPath,
2271
+ stderr
2272
+ });
2273
+ await runPreHook({
2274
+ name: "new",
2275
+ context: hookContext
2276
+ });
2277
+ await runGitCommand({
2278
+ cwd: repoRoot,
2279
+ args: [
2280
+ "worktree",
2281
+ "add",
2282
+ "-b",
2283
+ branch,
2284
+ targetPath,
2285
+ baseBranch
2286
+ ]
2287
+ });
2288
+ await runPostHook({
2289
+ name: "new",
2290
+ context: hookContext
2291
+ });
2292
+ return {
2293
+ branch,
2294
+ path: targetPath
2295
+ };
2296
+ });
2297
+ if (runtime.json) {
2298
+ stdout(JSON.stringify(buildJsonSuccess({
2299
+ command,
2300
+ status: "created",
2301
+ repoRoot,
2302
+ details: result
2303
+ })));
2304
+ return EXIT_CODE.OK;
2305
+ }
2306
+ stdout(result.path);
2307
+ return EXIT_CODE.OK;
2308
+ }
2309
+ if (command === "switch") {
2310
+ ensureArgumentCount({
2311
+ command,
2312
+ args: commandArgs,
2313
+ min: 1,
2314
+ max: 1
2315
+ });
2316
+ const branch = commandArgs[0];
2317
+ const result = await runWriteOperation(async () => {
2318
+ const existing = (await collectWorktreeSnapshot(repoRoot)).worktrees.find((worktree) => worktree.branch === branch);
2319
+ if (existing !== void 0) return {
2320
+ status: "existing",
2321
+ branch,
2322
+ path: existing.path
2323
+ };
2324
+ const targetPath = branchToWorktreePath(repoRoot, branch);
2325
+ await ensureTargetPathWritable(targetPath);
2326
+ const hookContext = createHookContext({
2327
+ runtime,
2328
+ repoRoot,
2329
+ action: "switch",
2330
+ branch,
2331
+ worktreePath: targetPath,
2332
+ stderr
2333
+ });
2334
+ await runPreHook({
2335
+ name: "switch",
2336
+ context: hookContext
2337
+ });
2338
+ if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) await runGitCommand({
2339
+ cwd: repoRoot,
2340
+ args: [
2341
+ "worktree",
2342
+ "add",
2343
+ targetPath,
2344
+ branch
2345
+ ]
2346
+ });
2347
+ else await runGitCommand({
2348
+ cwd: repoRoot,
2349
+ args: [
2350
+ "worktree",
2351
+ "add",
2352
+ "-b",
2353
+ branch,
2354
+ targetPath,
2355
+ await resolveBaseBranch(repoRoot)
2356
+ ]
2357
+ });
2358
+ await runPostHook({
2359
+ name: "switch",
2360
+ context: hookContext
2361
+ });
2362
+ return {
2363
+ status: "created",
2364
+ branch,
2365
+ path: targetPath
2366
+ };
2367
+ });
2368
+ if (runtime.json) {
2369
+ stdout(JSON.stringify(buildJsonSuccess({
2370
+ command,
2371
+ status: result.status,
2372
+ repoRoot,
2373
+ details: {
2374
+ branch: result.branch,
2375
+ path: result.path
2376
+ }
2377
+ })));
2378
+ return EXIT_CODE.OK;
2379
+ }
2380
+ stdout(result.path);
2381
+ return EXIT_CODE.OK;
2382
+ }
2383
+ if (command === "mv") {
2384
+ ensureArgumentCount({
2385
+ command,
2386
+ args: commandArgs,
2387
+ min: 1,
2388
+ max: 1
2389
+ });
2390
+ const newBranch = commandArgs[0];
2391
+ const result = await runWriteOperation(async () => {
2392
+ const snapshot = await collectWorktreeSnapshot(repoRoot);
2393
+ const current = resolveCurrentWorktree({
2394
+ snapshot,
2395
+ currentWorktreeRoot: repoContext.currentWorktreeRoot
2396
+ });
2397
+ const oldBranch = current.branch;
2398
+ if (oldBranch === null) throw createCliError("DETACHED_HEAD", {
2399
+ message: "mv requires a branch checkout (detached HEAD is not supported)",
2400
+ details: { path: current.path }
2401
+ });
2402
+ if (current.path === repoRoot) throw createCliError("INVALID_ARGUMENT", {
2403
+ message: "mv cannot move the primary worktree",
2404
+ details: { path: current.path }
2405
+ });
2406
+ if (oldBranch === newBranch) return {
2407
+ branch: newBranch,
2408
+ path: current.path
2409
+ };
2410
+ if (containsBranch({
2411
+ branch: newBranch,
2412
+ worktrees: snapshot.worktrees
2413
+ })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
2414
+ message: `Branch is already attached to another worktree: ${newBranch}`,
2415
+ details: { branch: newBranch }
2416
+ });
2417
+ if (await doesGitRefExist(repoRoot, `refs/heads/${newBranch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
2418
+ message: `Branch already exists locally: ${newBranch}`,
2419
+ details: { branch: newBranch }
2420
+ });
2421
+ const newPath = branchToWorktreePath(repoRoot, newBranch);
2422
+ await ensureTargetPathWritable(newPath);
2423
+ const hookContext = createHookContext({
2424
+ runtime,
2425
+ repoRoot,
2426
+ action: "mv",
2427
+ branch: newBranch,
2428
+ worktreePath: newPath,
2429
+ stderr,
2430
+ extraEnv: {
2431
+ WT_OLD_BRANCH: oldBranch,
2432
+ WT_NEW_BRANCH: newBranch
2433
+ }
2434
+ });
2435
+ await runPreHook({
2436
+ name: "mv",
2437
+ context: hookContext
2438
+ });
2439
+ await runGitCommand({
2440
+ cwd: current.path,
2441
+ args: [
2442
+ "branch",
2443
+ "-m",
2444
+ oldBranch,
2445
+ newBranch
2446
+ ]
2447
+ });
2448
+ await runGitCommand({
2449
+ cwd: repoRoot,
2450
+ args: [
2451
+ "worktree",
2452
+ "move",
2453
+ current.path,
2454
+ newPath
2455
+ ]
2456
+ });
2457
+ await runPostHook({
2458
+ name: "mv",
2459
+ context: hookContext
2460
+ });
2461
+ return {
2462
+ branch: newBranch,
2463
+ path: newPath
2464
+ };
2465
+ });
2466
+ if (runtime.json) {
2467
+ stdout(JSON.stringify(buildJsonSuccess({
2468
+ command,
2469
+ status: "ok",
2470
+ repoRoot,
2471
+ details: result
2472
+ })));
2473
+ return EXIT_CODE.OK;
2474
+ }
2475
+ stdout(result.path);
2476
+ return EXIT_CODE.OK;
2477
+ }
2478
+ if (command === "del") {
2479
+ ensureArgumentCount({
2480
+ command,
2481
+ args: commandArgs,
2482
+ min: 0,
2483
+ max: 1
2484
+ });
2485
+ const forceFlags = parseForceFlags(parsedArgsRecord);
2486
+ if (hasAnyForceFlag(forceFlags)) ensureUnsafeForNonTty({
2487
+ runtime,
2488
+ reason: "force flags in non-TTY mode require --allow-unsafe"
2489
+ });
2490
+ const branchArg = commandArgs[0];
2491
+ const result = await runWriteOperation(async () => {
2492
+ const snapshot = await collectWorktreeSnapshot(repoRoot);
2493
+ const target = typeof branchArg === "string" && branchArg.length > 0 ? resolveTargetWorktreeByBranch({
2494
+ branch: branchArg,
2495
+ worktrees: snapshot.worktrees
2496
+ }) : resolveCurrentWorktree({
2497
+ snapshot,
2498
+ currentWorktreeRoot: repoContext.currentWorktreeRoot
2499
+ });
2500
+ if (target.branch === null) throw createCliError("DETACHED_HEAD", {
2501
+ message: "Cannot delete detached worktree without branch",
2502
+ details: { path: target.path }
2503
+ });
2504
+ if (target.path === repoRoot) throw createCliError("INVALID_ARGUMENT", {
2505
+ message: "Cannot delete the primary worktree",
2506
+ details: { path: target.path }
2507
+ });
2508
+ validateDeleteSafety({
2509
+ target,
2510
+ forceFlags
2511
+ });
2512
+ const hookContext = createHookContext({
2513
+ runtime,
2514
+ repoRoot,
2515
+ action: "del",
2516
+ branch: target.branch,
2517
+ worktreePath: target.path,
2518
+ stderr
2519
+ });
2520
+ await runPreHook({
2521
+ name: "del",
2522
+ context: hookContext
2523
+ });
2524
+ const removeArgs = [
2525
+ "worktree",
2526
+ "remove",
2527
+ target.path
2528
+ ];
2529
+ if (forceFlags.forceDirty) removeArgs.push("--force");
2530
+ await runGitCommand({
2531
+ cwd: repoRoot,
2532
+ args: removeArgs
2533
+ });
2534
+ await runGitCommand({
2535
+ cwd: repoRoot,
2536
+ args: [
2537
+ "branch",
2538
+ resolveBranchDeleteMode(forceFlags),
2539
+ target.branch
2540
+ ]
2541
+ });
2542
+ await deleteWorktreeLock({
2543
+ repoRoot,
2544
+ branch: target.branch
2545
+ });
2546
+ await runPostHook({
2547
+ name: "del",
2548
+ context: hookContext
2549
+ });
2550
+ return {
2551
+ branch: target.branch,
2552
+ path: target.path
2553
+ };
2554
+ });
2555
+ if (runtime.json) {
2556
+ stdout(JSON.stringify(buildJsonSuccess({
2557
+ command,
2558
+ status: "deleted",
2559
+ repoRoot,
2560
+ details: result
2561
+ })));
2562
+ return EXIT_CODE.OK;
2563
+ }
2564
+ stdout(result.path);
2565
+ return EXIT_CODE.OK;
2566
+ }
2567
+ if (command === "gone") {
2568
+ ensureArgumentCount({
2569
+ command,
2570
+ args: commandArgs,
2571
+ min: 0,
2572
+ max: 0
2573
+ });
2574
+ if (parsedArgs.apply === true && parsedArgs.dryRun === true) throw createCliError("INVALID_ARGUMENT", { message: "Cannot use --apply and --dry-run together" });
2575
+ const dryRun = parsedArgs.apply !== true;
2576
+ const execute = async () => {
2577
+ const candidates = (await collectWorktreeSnapshot(repoRoot)).worktrees.filter((worktree) => worktree.branch !== null).filter((worktree) => worktree.path !== repoRoot).filter((worktree) => worktree.dirty === false).filter((worktree) => worktree.locked.value === false).filter((worktree) => worktree.merged.overall === true).filter((worktree) => worktree.upstream.ahead === 0).map((worktree) => worktree.branch);
2578
+ if (dryRun) return {
2579
+ deleted: [],
2580
+ candidates,
2581
+ dryRun: true
2582
+ };
2583
+ const hookContext = createHookContext({
2584
+ runtime,
2585
+ repoRoot,
2586
+ action: "gone",
2587
+ branch: null,
2588
+ worktreePath: repoRoot,
2589
+ stderr
2590
+ });
2591
+ await runPreHook({
2592
+ name: "gone",
2593
+ context: hookContext
2594
+ });
2595
+ const deleted = [];
2596
+ for (const branch of candidates) {
2597
+ await runGitCommand({
2598
+ cwd: repoRoot,
2599
+ args: [
2600
+ "worktree",
2601
+ "remove",
2602
+ resolveTargetWorktreeByBranch({
2603
+ branch,
2604
+ worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
2605
+ }).path
2606
+ ]
2607
+ });
2608
+ await runGitCommand({
2609
+ cwd: repoRoot,
2610
+ args: [
2611
+ "branch",
2612
+ "-d",
2613
+ branch
2614
+ ]
2615
+ });
2616
+ await deleteWorktreeLock({
2617
+ repoRoot,
2618
+ branch
2619
+ });
2620
+ deleted.push(branch);
2621
+ }
2622
+ await runPostHook({
2623
+ name: "gone",
2624
+ context: hookContext
2625
+ });
2626
+ return {
2627
+ deleted,
2628
+ candidates,
2629
+ dryRun: false
2630
+ };
2631
+ };
2632
+ const result = await runWriteOperation(execute);
2633
+ if (runtime.json) {
2634
+ stdout(JSON.stringify(buildJsonSuccess({
2635
+ command,
2636
+ status: "ok",
2637
+ repoRoot,
2638
+ details: {
2639
+ dryRun: result.dryRun,
2640
+ candidates: result.candidates,
2641
+ deleted: result.deleted
2642
+ }
2643
+ })));
2644
+ return EXIT_CODE.OK;
2645
+ }
2646
+ const label = result.dryRun ? "candidates" : "deleted";
2647
+ const branches = result.dryRun ? result.candidates : result.deleted;
2648
+ for (const branch of branches) stdout(`${label}: ${branch}`);
2649
+ return EXIT_CODE.OK;
2650
+ }
2651
+ if (command === "get") {
2652
+ ensureArgumentCount({
2653
+ command,
2654
+ args: commandArgs,
2655
+ min: 1,
2656
+ max: 1
2657
+ });
2658
+ const remoteBranchArg = commandArgs[0];
2659
+ const { remote, branch } = resolveRemoteAndBranch(remoteBranchArg);
2660
+ const result = await runWriteOperation(async () => {
2661
+ if ((await runGitCommand({
2662
+ cwd: repoRoot,
2663
+ args: [
2664
+ "remote",
2665
+ "get-url",
2666
+ remote
2667
+ ],
2668
+ reject: false
2669
+ })).exitCode !== 0) throw createCliError("REMOTE_NOT_FOUND", {
2670
+ message: `Remote not found: ${remote}`,
2671
+ details: { remote }
2672
+ });
2673
+ const hookContext = createHookContext({
2674
+ runtime,
2675
+ repoRoot,
2676
+ action: "get",
2677
+ branch,
2678
+ worktreePath: branchToWorktreePath(repoRoot, branch),
2679
+ stderr
2680
+ });
2681
+ await runPreHook({
2682
+ name: "get",
2683
+ context: hookContext
2684
+ });
2685
+ if ((await runGitCommand({
2686
+ cwd: repoRoot,
2687
+ args: [
2688
+ "fetch",
2689
+ remote,
2690
+ branch
2691
+ ],
2692
+ reject: false
2693
+ })).exitCode !== 0) throw createCliError("REMOTE_BRANCH_NOT_FOUND", {
2694
+ message: `Remote branch not found: ${remote}/${branch}`,
2695
+ details: {
2696
+ remote,
2697
+ branch
2698
+ }
2699
+ });
2700
+ if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`) !== true) await runGitCommand({
2701
+ cwd: repoRoot,
2702
+ args: [
2703
+ "branch",
2704
+ "--track",
2705
+ branch,
2706
+ `${remote}/${branch}`
2707
+ ]
2708
+ });
2709
+ const existing = (await collectWorktreeSnapshot(repoRoot)).worktrees.find((worktree) => worktree.branch === branch);
2710
+ if (existing !== void 0) {
2711
+ await runPostHook({
2712
+ name: "get",
2713
+ context: hookContext
2714
+ });
2715
+ return {
2716
+ status: "existing",
2717
+ branch,
2718
+ path: existing.path
2719
+ };
2720
+ }
2721
+ const targetPath = branchToWorktreePath(repoRoot, branch);
2722
+ await ensureTargetPathWritable(targetPath);
2723
+ await runGitCommand({
2724
+ cwd: repoRoot,
2725
+ args: [
2726
+ "worktree",
2727
+ "add",
2728
+ targetPath,
2729
+ branch
2730
+ ]
2731
+ });
2732
+ await runPostHook({
2733
+ name: "get",
2734
+ context: hookContext
2735
+ });
2736
+ return {
2737
+ status: "created",
2738
+ branch,
2739
+ path: targetPath
2740
+ };
2741
+ });
2742
+ if (runtime.json) {
2743
+ stdout(JSON.stringify(buildJsonSuccess({
2744
+ command,
2745
+ status: result.status,
2746
+ repoRoot,
2747
+ details: {
2748
+ branch: result.branch,
2749
+ path: result.path
2750
+ }
2751
+ })));
2752
+ return EXIT_CODE.OK;
2753
+ }
2754
+ stdout(result.path);
2755
+ return EXIT_CODE.OK;
2756
+ }
2757
+ if (command === "extract") {
2758
+ ensureArgumentCount({
2759
+ command,
2760
+ args: commandArgs,
2761
+ min: 0,
2762
+ max: 0
2763
+ });
2764
+ const fromPath = typeof parsedArgs.from === "string" ? parsedArgs.from : void 0;
2765
+ if (fromPath !== void 0 && parsedArgs.current === true) throw createCliError("INVALID_ARGUMENT", { message: "extract cannot use --current and --from together" });
2766
+ const result = await runWriteOperation(async () => {
2767
+ const snapshot = await collectWorktreeSnapshot(repoRoot);
2768
+ const resolvedSourcePath = ensurePathInsideRepo({
2769
+ repoRoot,
2770
+ path: fromPath !== void 0 ? resolvePathFromCwd({
2771
+ cwd: runtimeCwd,
2772
+ path: fromPath
2773
+ }) : repoContext.currentWorktreeRoot
2774
+ });
2775
+ const sourceWorktree = snapshot.worktrees.find((worktree) => worktree.path === resolvedSourcePath) ?? snapshot.worktrees.find((worktree) => resolvedSourcePath.startsWith(`${worktree.path}${sep}`));
2776
+ if (sourceWorktree === void 0) throw createCliError("WORKTREE_NOT_FOUND", {
2777
+ message: "extract source worktree not found",
2778
+ details: { path: resolvedSourcePath }
2779
+ });
2780
+ if (sourceWorktree.path !== repoRoot) throw createCliError("INVALID_ARGUMENT", {
2781
+ message: "extract currently supports only the primary worktree",
2782
+ details: { sourcePath: sourceWorktree.path }
2783
+ });
2784
+ if (sourceWorktree.branch === null) throw createCliError("DETACHED_HEAD", {
2785
+ message: "extract requires current branch checkout",
2786
+ details: { path: sourceWorktree.path }
2787
+ });
2788
+ const branch = sourceWorktree.branch;
2789
+ const baseBranch = await resolveBaseBranch(repoRoot);
2790
+ ensureBranchIsNotPrimary({
2791
+ branch,
2792
+ baseBranch
2793
+ });
2794
+ const targetPath = branchToWorktreePath(repoRoot, branch);
2795
+ await ensureTargetPathWritable(targetPath);
2796
+ const dirty = (await runGitCommand({
2797
+ cwd: repoRoot,
2798
+ args: ["status", "--porcelain"],
2799
+ reject: false
2800
+ })).stdout.trim().length > 0;
2801
+ if (dirty && parsedArgs.stash !== true) throw createCliError("DIRTY_WORKTREE", { message: "extract requires clean worktree unless --stash is specified" });
2802
+ let stashRef = null;
2803
+ if (dirty && parsedArgs.stash === true) {
2804
+ await runGitCommand({
2805
+ cwd: repoRoot,
2806
+ args: [
2807
+ "stash",
2808
+ "push",
2809
+ "-u",
2810
+ "-m",
2811
+ `vde-worktree extract ${branch}`
2812
+ ]
2813
+ });
2814
+ const stashTop = await runGitCommand({
2815
+ cwd: repoRoot,
2816
+ args: [
2817
+ "stash",
2818
+ "list",
2819
+ "--max-count=1",
2820
+ "--format=%gd"
2821
+ ]
2822
+ });
2823
+ stashRef = stashTop.stdout.trim().length > 0 ? stashTop.stdout.trim() : null;
2824
+ }
2825
+ const hookContext = createHookContext({
2826
+ runtime,
2827
+ repoRoot,
2828
+ action: "extract",
2829
+ branch,
2830
+ worktreePath: targetPath,
2831
+ stderr
2832
+ });
2833
+ await runPreHook({
2834
+ name: "extract",
2835
+ context: hookContext
2836
+ });
2837
+ await runGitCommand({
2838
+ cwd: repoRoot,
2839
+ args: ["checkout", baseBranch]
2840
+ });
2841
+ await runGitCommand({
2842
+ cwd: repoRoot,
2843
+ args: [
2844
+ "worktree",
2845
+ "add",
2846
+ targetPath,
2847
+ branch
2848
+ ]
2849
+ });
2850
+ if (stashRef !== null) {
2851
+ if ((await runGitCommand({
2852
+ cwd: targetPath,
2853
+ args: [
2854
+ "stash",
2855
+ "apply",
2856
+ stashRef
2857
+ ],
2858
+ reject: false
2859
+ })).exitCode !== 0) throw createCliError("STASH_APPLY_FAILED", {
2860
+ message: "Failed to apply stash to extracted worktree",
2861
+ details: {
2862
+ stashRef,
2863
+ branch,
2864
+ path: targetPath
2865
+ }
2866
+ });
2867
+ await runGitCommand({
2868
+ cwd: repoRoot,
2869
+ args: [
2870
+ "stash",
2871
+ "drop",
2872
+ stashRef
2873
+ ]
2874
+ });
2875
+ }
2876
+ await runPostHook({
2877
+ name: "extract",
2878
+ context: hookContext
2879
+ });
2880
+ return {
2881
+ branch,
2882
+ path: targetPath
2883
+ };
2884
+ });
2885
+ if (runtime.json) {
2886
+ stdout(JSON.stringify(buildJsonSuccess({
2887
+ command,
2888
+ status: "created",
2889
+ repoRoot,
2890
+ details: result
2891
+ })));
2892
+ return EXIT_CODE.OK;
2893
+ }
2894
+ stdout(result.path);
2895
+ return EXIT_CODE.OK;
2896
+ }
2897
+ if (command === "use") {
2898
+ ensureArgumentCount({
2899
+ command,
2900
+ args: commandArgs,
2901
+ min: 1,
2902
+ max: 1
2903
+ });
2904
+ const branch = commandArgs[0];
2905
+ if (runtime.isInteractive !== true) {
2906
+ if (parsedArgs.allowAgent !== true) throw createCliError("UNSAFE_FLAG_REQUIRED", { message: "UNSAFE_FLAG_REQUIRED: use in non-TTY requires --allow-agent" });
2907
+ ensureUnsafeForNonTty({
2908
+ runtime,
2909
+ reason: "use in non-TTY mode requires --allow-unsafe"
2910
+ });
2911
+ }
2912
+ const result = await runWriteOperation(async () => {
2913
+ if ((await runGitCommand({
2914
+ cwd: repoRoot,
2915
+ args: ["status", "--porcelain"],
2916
+ reject: false
2917
+ })).stdout.trim().length > 0) throw createCliError("DIRTY_WORKTREE", {
2918
+ message: "use requires clean primary worktree",
2919
+ details: { repoRoot }
2920
+ });
2921
+ const hookContext = createHookContext({
2922
+ runtime,
2923
+ repoRoot,
2924
+ action: "use",
2925
+ branch,
2926
+ worktreePath: repoRoot,
2927
+ stderr
2928
+ });
2929
+ await runPreHook({
2930
+ name: "use",
2931
+ context: hookContext
2932
+ });
2933
+ await runGitCommand({
2934
+ cwd: repoRoot,
2935
+ args: ["checkout", branch]
2936
+ });
2937
+ await runPostHook({
2938
+ name: "use",
2939
+ context: hookContext
2940
+ });
2941
+ return {
2942
+ branch,
2943
+ path: repoRoot
2944
+ };
2945
+ });
2946
+ if (runtime.json) {
2947
+ stdout(JSON.stringify(buildJsonSuccess({
2948
+ command,
2949
+ status: "ok",
2950
+ repoRoot,
2951
+ details: result
2952
+ })));
2953
+ return EXIT_CODE.OK;
2954
+ }
2955
+ stdout(result.path);
2956
+ return EXIT_CODE.OK;
2957
+ }
2958
+ if (command === "exec") {
2959
+ ensureArgumentCount({
2960
+ command,
2961
+ args: commandArgs,
2962
+ min: 1,
2963
+ max: 1
2964
+ });
2965
+ ensureHasCommandAfterDoubleDash({
2966
+ command,
2967
+ argsAfterDoubleDash: afterDoubleDash
2968
+ });
2969
+ const branch = commandArgs[0];
2970
+ const target = resolveTargetWorktreeByBranch({
2971
+ branch,
2972
+ worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
2973
+ });
2974
+ const executable = afterDoubleDash[0];
2975
+ if (typeof executable !== "string" || executable.length === 0) throw createCliError("INVALID_ARGUMENT", { message: "exec requires executable after --" });
2976
+ const childExitCode = (await execa(executable, afterDoubleDash.slice(1), {
2977
+ cwd: target.path,
2978
+ stdin: "inherit",
2979
+ stdout: "inherit",
2980
+ stderr: "inherit",
2981
+ reject: false
2982
+ })).exitCode ?? 0;
2983
+ if (runtime.json) {
2984
+ if (childExitCode === 0) {
2985
+ stdout(JSON.stringify(buildJsonSuccess({
2986
+ command,
2987
+ status: "ok",
2988
+ repoRoot,
2989
+ details: {
2990
+ branch,
2991
+ path: target.path,
2992
+ childExitCode
2993
+ }
2994
+ })));
2995
+ return EXIT_CODE.OK;
2996
+ }
2997
+ stdout(JSON.stringify({
2998
+ schemaVersion: SCHEMA_VERSION,
2999
+ command,
3000
+ status: "error",
3001
+ repoRoot,
3002
+ code: "CHILD_PROCESS_FAILED",
3003
+ message: "target command exited with non-zero status",
3004
+ details: {
3005
+ branch,
3006
+ path: target.path,
3007
+ childExitCode
3008
+ }
3009
+ }));
3010
+ return EXIT_CODE.CHILD_PROCESS_FAILED;
3011
+ }
3012
+ return childExitCode === 0 ? EXIT_CODE.OK : EXIT_CODE.CHILD_PROCESS_FAILED;
3013
+ }
3014
+ if (command === "invoke") {
3015
+ ensureArgumentCount({
3016
+ command,
3017
+ args: commandArgs,
3018
+ min: 1,
3019
+ max: 1
3020
+ });
3021
+ const hookName = normalizeHookName(commandArgs[0]);
3022
+ const current = resolveCurrentWorktree({
3023
+ snapshot: await collectWorktreeSnapshot(repoRoot),
3024
+ currentWorktreeRoot: repoContext.currentWorktreeRoot
3025
+ });
3026
+ await invokeHook({
3027
+ hookName,
3028
+ args: afterDoubleDash,
3029
+ context: createHookContext({
3030
+ runtime,
3031
+ repoRoot,
3032
+ action: `invoke:${hookName}`,
3033
+ branch: current.branch,
3034
+ worktreePath: current.path,
3035
+ stderr
3036
+ })
3037
+ });
3038
+ if (runtime.json) {
3039
+ stdout(JSON.stringify(buildJsonSuccess({
3040
+ command,
3041
+ status: "ok",
3042
+ repoRoot,
3043
+ details: {
3044
+ hook: hookName,
3045
+ exitCode: 0
3046
+ }
3047
+ })));
3048
+ return EXIT_CODE.OK;
3049
+ }
3050
+ return EXIT_CODE.OK;
3051
+ }
3052
+ if (command === "copy") {
3053
+ ensureArgumentCount({
3054
+ command,
3055
+ args: commandArgs,
3056
+ min: 1,
3057
+ max: Number.MAX_SAFE_INTEGER
3058
+ });
3059
+ const worktreePath = ensurePathInsideRepo({
3060
+ repoRoot,
3061
+ path: resolvePathFromCwd({
3062
+ cwd: repoContext.currentWorktreeRoot,
3063
+ path: process.env.WT_WORKTREE_PATH ?? "."
3064
+ })
3065
+ });
3066
+ for (const relativePath of commandArgs) {
3067
+ const { sourcePath, destinationPath } = resolveFileCopyTargets({
3068
+ repoRoot,
3069
+ worktreePath,
3070
+ relativePath
3071
+ });
3072
+ await access(sourcePath, constants.F_OK);
3073
+ await mkdir(dirname(destinationPath), { recursive: true });
3074
+ await cp(sourcePath, destinationPath, {
3075
+ recursive: true,
3076
+ force: true,
3077
+ errorOnExist: false,
3078
+ dereference: false
3079
+ });
3080
+ }
3081
+ if (runtime.json) {
3082
+ stdout(JSON.stringify(buildJsonSuccess({
3083
+ command,
3084
+ status: "ok",
3085
+ repoRoot,
3086
+ details: {
3087
+ copied: commandArgs,
3088
+ worktreePath
3089
+ }
3090
+ })));
3091
+ return EXIT_CODE.OK;
3092
+ }
3093
+ return EXIT_CODE.OK;
3094
+ }
3095
+ if (command === "link") {
3096
+ ensureArgumentCount({
3097
+ command,
3098
+ args: commandArgs,
3099
+ min: 1,
3100
+ max: Number.MAX_SAFE_INTEGER
3101
+ });
3102
+ const worktreePath = ensurePathInsideRepo({
3103
+ repoRoot,
3104
+ path: resolvePathFromCwd({
3105
+ cwd: repoContext.currentWorktreeRoot,
3106
+ path: process.env.WT_WORKTREE_PATH ?? "."
3107
+ })
3108
+ });
3109
+ const fallbackEnabled = parsedArgs.fallback !== false;
3110
+ for (const relativePath of commandArgs) {
3111
+ const { sourcePath, destinationPath } = resolveFileCopyTargets({
3112
+ repoRoot,
3113
+ worktreePath,
3114
+ relativePath
3115
+ });
3116
+ await access(sourcePath, constants.F_OK);
3117
+ await rm(destinationPath, {
3118
+ recursive: true,
3119
+ force: true
3120
+ });
3121
+ await mkdir(dirname(destinationPath), { recursive: true });
3122
+ try {
3123
+ await symlink(resolveLinkTargetPath({
3124
+ sourcePath,
3125
+ destinationPath
3126
+ }), destinationPath);
3127
+ } catch (error) {
3128
+ if (process.platform === "win32" && fallbackEnabled) {
3129
+ stderr(`symlink failed for ${relativePath}; fallback to copy`);
3130
+ await cp(sourcePath, destinationPath, {
3131
+ recursive: true,
3132
+ force: true,
3133
+ errorOnExist: false,
3134
+ dereference: false
3135
+ });
3136
+ continue;
3137
+ }
3138
+ throw createCliError("INVALID_ARGUMENT", {
3139
+ message: `Failed to create symlink for ${relativePath}`,
3140
+ cause: error
3141
+ });
3142
+ }
3143
+ }
3144
+ if (runtime.json) {
3145
+ stdout(JSON.stringify(buildJsonSuccess({
3146
+ command,
3147
+ status: "ok",
3148
+ repoRoot,
3149
+ details: {
3150
+ linked: commandArgs,
3151
+ worktreePath,
3152
+ fallback: fallbackEnabled
3153
+ }
3154
+ })));
3155
+ return EXIT_CODE.OK;
3156
+ }
3157
+ return EXIT_CODE.OK;
3158
+ }
3159
+ if (command === "lock") {
3160
+ ensureArgumentCount({
3161
+ command,
3162
+ args: commandArgs,
3163
+ min: 1,
3164
+ max: 1
3165
+ });
3166
+ const branch = commandArgs[0];
3167
+ const ownerOption = readStringOption(parsedArgsRecord, "owner");
3168
+ const reasonOption = readStringOption(parsedArgsRecord, "reason");
3169
+ const owner = typeof ownerOption === "string" && ownerOption.length > 0 ? ownerOption : defaultOwner();
3170
+ const reason = typeof reasonOption === "string" && reasonOption.length > 0 ? reasonOption : "locked";
3171
+ const result = await runWriteOperation(async () => {
3172
+ resolveTargetWorktreeByBranch({
3173
+ branch,
3174
+ worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
3175
+ });
3176
+ const existing = await readWorktreeLock({
3177
+ repoRoot,
3178
+ branch
3179
+ });
3180
+ if (existing.exists && existing.valid !== true) throw createCliError("LOCK_CONFLICT", {
3181
+ message: "Cannot update lock with invalid metadata; fix or remove lock file first",
3182
+ details: {
3183
+ branch,
3184
+ path: existing.path
3185
+ }
3186
+ });
3187
+ if (existing.record !== null && existing.record.owner !== owner) throw createCliError("LOCK_CONFLICT", {
3188
+ message: "Lock is owned by another owner",
3189
+ details: {
3190
+ branch,
3191
+ owner: existing.record.owner
3192
+ }
3193
+ });
3194
+ return await upsertWorktreeLock({
3195
+ repoRoot,
3196
+ branch,
3197
+ reason,
3198
+ owner
3199
+ });
3200
+ });
3201
+ if (runtime.json) {
3202
+ stdout(JSON.stringify(buildJsonSuccess({
3203
+ command,
3204
+ status: "ok",
3205
+ repoRoot,
3206
+ details: {
3207
+ branch,
3208
+ locked: {
3209
+ value: true,
3210
+ reason: result.reason,
3211
+ owner: result.owner
3212
+ }
3213
+ }
3214
+ })));
3215
+ return EXIT_CODE.OK;
3216
+ }
3217
+ return EXIT_CODE.OK;
3218
+ }
3219
+ if (command === "unlock") {
3220
+ ensureArgumentCount({
3221
+ command,
3222
+ args: commandArgs,
3223
+ min: 1,
3224
+ max: 1
3225
+ });
3226
+ const branch = commandArgs[0];
3227
+ const ownerOption = readStringOption(parsedArgsRecord, "owner");
3228
+ const owner = typeof ownerOption === "string" && ownerOption.length > 0 ? ownerOption : defaultOwner();
3229
+ const force = parsedArgs.force === true;
3230
+ await runWriteOperation(async () => {
3231
+ const existing = await readWorktreeLock({
3232
+ repoRoot,
3233
+ branch
3234
+ });
3235
+ if (existing.exists !== true) return;
3236
+ if (existing.valid !== true) {
3237
+ if (force) {
3238
+ await deleteWorktreeLock({
3239
+ repoRoot,
3240
+ branch
3241
+ });
3242
+ return;
3243
+ }
3244
+ throw createCliError("LOCK_CONFLICT", {
3245
+ message: "Lock metadata is invalid; use --force to unlock",
3246
+ details: {
3247
+ branch,
3248
+ path: existing.path
3249
+ }
3250
+ });
3251
+ }
3252
+ if (existing.record !== null && existing.record.owner !== owner && force !== true) throw createCliError("LOCK_CONFLICT", {
3253
+ message: "Lock is owned by another owner. Use --force to unlock.",
3254
+ details: {
3255
+ branch,
3256
+ owner: existing.record.owner
3257
+ }
3258
+ });
3259
+ await deleteWorktreeLock({
3260
+ repoRoot,
3261
+ branch
3262
+ });
3263
+ });
3264
+ if (runtime.json) {
3265
+ stdout(JSON.stringify(buildJsonSuccess({
3266
+ command,
3267
+ status: "ok",
3268
+ repoRoot,
3269
+ details: {
3270
+ branch,
3271
+ locked: {
3272
+ value: false,
3273
+ reason: null
3274
+ }
3275
+ }
3276
+ })));
3277
+ return EXIT_CODE.OK;
3278
+ }
3279
+ return EXIT_CODE.OK;
3280
+ }
3281
+ if (command === "cd") {
3282
+ ensureArgumentCount({
3283
+ command,
3284
+ args: commandArgs,
3285
+ min: 0,
3286
+ max: 0
3287
+ });
3288
+ const candidates = (await collectWorktreeSnapshot(repoRoot)).worktrees.map((worktree) => worktree.path);
3289
+ if (candidates.length === 0) throw createCliError("WORKTREE_NOT_FOUND", { message: "No worktree candidates found" });
3290
+ const promptValue = readStringOption(parsedArgsRecord, "prompt");
3291
+ const selection = await selectPathWithFzf$1({
3292
+ candidates,
3293
+ prompt: typeof promptValue === "string" && promptValue.length > 0 ? promptValue : "worktree> ",
3294
+ fzfExtraArgs: collectOptionValues({
3295
+ args: beforeDoubleDash,
3296
+ optionNames: ["fzfArg", "fzf-arg"]
3297
+ }),
3298
+ cwd: repoRoot,
3299
+ isInteractive: () => runtime.isInteractive || process.stderr.isTTY === true
3300
+ }).catch((error) => {
3301
+ const message = error instanceof Error ? error.message : String(error);
3302
+ if (message.includes("interactive terminal") || message.includes("fzf is required")) throw createCliError("DEPENDENCY_MISSING", { message: `DEPENDENCY_MISSING: ${message}` });
3303
+ throw error;
3304
+ });
3305
+ if (selection.status === "cancelled") return EXIT_CODE_CANCELLED;
3306
+ if (runtime.json) {
3307
+ stdout(JSON.stringify(buildJsonSuccess({
3308
+ command,
3309
+ status: "ok",
3310
+ repoRoot,
3311
+ details: { path: selection.path }
3312
+ })));
3313
+ return EXIT_CODE.OK;
3314
+ }
3315
+ stdout(selection.path);
3316
+ return EXIT_CODE.OK;
3317
+ }
3318
+ throw createCliError("UNKNOWN_COMMAND", { message: `Unknown command: ${command}` });
3319
+ } catch (error) {
3320
+ const cliError = ensureCliError(error);
3321
+ if (jsonEnabled) stdout(JSON.stringify(buildJsonError({
3322
+ command,
3323
+ repoRoot: repoRootForJson,
3324
+ error: cliError
3325
+ })));
3326
+ else {
3327
+ stderr(`[${cliError.code}] ${cliError.message}`);
3328
+ logger.debug(JSON.stringify(cliError.details));
3329
+ }
3330
+ return cliError.exitCode;
3331
+ }
3332
+ };
3333
+ return { run };
3334
+ };
3335
+
3336
+ //#endregion
3337
+ //#region src/index.ts
3338
+ const main = async () => {
3339
+ const cli = createCli();
3340
+ try {
3341
+ const exitCode = await cli.run(process.argv.slice(2));
3342
+ if (typeof exitCode === "number" && exitCode !== 0) process.exit(exitCode);
3343
+ } catch (error) {
3344
+ if (error instanceof Error) {
3345
+ console.error("Error:", error.message);
3346
+ if (process.env.VDE_WORKTREE_DEBUG === "true" || process.env.VDE_DEBUG === "true") console.error(error.stack);
3347
+ } else console.error("An unexpected error occurred:", String(error));
3348
+ process.exit(1);
3349
+ }
3350
+ };
3351
+ main();
3352
+
3353
+ //#endregion
3354
+ export { };
3355
+ //# sourceMappingURL=index.mjs.map