vde-worktree 0.0.19 → 0.0.21

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { constants } from "node:fs";
4
- import { access, appendFile, chmod, cp, mkdir, open, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { access, appendFile, chmod, cp, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
5
5
  import { homedir, hostname } from "node:os";
6
6
  import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -10,6 +10,7 @@ import { parseArgs } from "citty";
10
10
  import { execa } from "execa";
11
11
  import stringWidth from "string-width";
12
12
  import { getBorderCharacters, table } from "table";
13
+ import { parse } from "yaml";
13
14
  import { createHash } from "node:crypto";
14
15
 
15
16
  //#region src/core/constants.ts
@@ -74,6 +75,7 @@ const WRITE_COMMANDS = new Set([
74
75
  const ERROR_CODE_TO_EXIT_CODE = {
75
76
  NOT_GIT_REPOSITORY: EXIT_CODE.NOT_GIT_REPOSITORY,
76
77
  INVALID_ARGUMENT: EXIT_CODE.INVALID_ARGUMENT,
78
+ INVALID_CONFIG: EXIT_CODE.INVALID_ARGUMENT,
77
79
  UNKNOWN_COMMAND: EXIT_CODE.INVALID_ARGUMENT,
78
80
  UNSAFE_FLAG_REQUIRED: EXIT_CODE.SAFETY_REJECTED,
79
81
  NOT_INITIALIZED: EXIT_CODE.SAFETY_REJECTED,
@@ -130,6 +132,678 @@ const ensureCliError = (error) => {
130
132
  });
131
133
  };
132
134
 
135
+ //#endregion
136
+ //#region src/config/git-boundary.ts
137
+ const hasGitMarker = async (directory) => {
138
+ try {
139
+ const stat = await lstat(join(directory, ".git"));
140
+ return stat.isDirectory() || stat.isFile();
141
+ } catch {
142
+ return false;
143
+ }
144
+ };
145
+ const findGitBoundaryDirectory = async (cwd) => {
146
+ let current = resolve(cwd);
147
+ while (true) {
148
+ if (await hasGitMarker(current)) return current;
149
+ const parent = dirname(current);
150
+ if (parent === current) return null;
151
+ current = parent;
152
+ }
153
+ };
154
+ const collectConfigSearchDirectories = async (cwd) => {
155
+ const absoluteCwd = resolve(cwd);
156
+ const boundary = await findGitBoundaryDirectory(absoluteCwd);
157
+ if (boundary === null) return [absoluteCwd];
158
+ const directories = [];
159
+ let current = absoluteCwd;
160
+ while (true) {
161
+ directories.push(current);
162
+ if (current === boundary) break;
163
+ const parent = dirname(current);
164
+ if (parent === current) break;
165
+ current = parent;
166
+ }
167
+ return directories.reverse();
168
+ };
169
+
170
+ //#endregion
171
+ //#region src/config/types.ts
172
+ const LIST_TABLE_COLUMNS = [
173
+ "branch",
174
+ "dirty",
175
+ "merged",
176
+ "pr",
177
+ "locked",
178
+ "ahead",
179
+ "behind",
180
+ "path"
181
+ ];
182
+ const LIST_PATH_TRUNCATE_VALUES = ["auto", "never"];
183
+ const SELECTOR_CD_SURFACE_VALUES = [
184
+ "auto",
185
+ "inline",
186
+ "tmux-popup"
187
+ ];
188
+ const DEFAULT_CONFIG = {
189
+ paths: { worktreeRoot: ".worktree" },
190
+ git: {
191
+ baseBranch: null,
192
+ baseRemote: "origin"
193
+ },
194
+ github: { enabled: true },
195
+ hooks: {
196
+ enabled: true,
197
+ timeoutMs: DEFAULT_HOOK_TIMEOUT_MS
198
+ },
199
+ locks: {
200
+ timeoutMs: DEFAULT_LOCK_TIMEOUT_MS,
201
+ staleLockTTLSeconds: DEFAULT_STALE_LOCK_TTL_SECONDS
202
+ },
203
+ list: { table: {
204
+ columns: [...LIST_TABLE_COLUMNS],
205
+ path: {
206
+ truncate: "auto",
207
+ minWidth: 12
208
+ }
209
+ } },
210
+ selector: { cd: {
211
+ prompt: "worktree> ",
212
+ surface: "auto",
213
+ tmuxPopupOpts: "80%,70%",
214
+ fzf: { extraArgs: [] }
215
+ } }
216
+ };
217
+
218
+ //#endregion
219
+ //#region src/config/loader.ts
220
+ const CONFIG_FILE_BASENAME = "config.yml";
221
+ const LOCAL_CONFIG_PATH_SEGMENTS = [
222
+ ".vde",
223
+ "worktree",
224
+ CONFIG_FILE_BASENAME
225
+ ];
226
+ const GLOBAL_CONFIG_PATH_SEGMENTS = [
227
+ "vde",
228
+ "worktree",
229
+ CONFIG_FILE_BASENAME
230
+ ];
231
+ const isRecord = (value) => {
232
+ return value !== null && typeof value === "object" && Array.isArray(value) !== true;
233
+ };
234
+ const toKeyPath = (segments) => {
235
+ if (segments.length === 0) return "<root>";
236
+ return segments.join(".");
237
+ };
238
+ const throwInvalidConfig = ({ file, keyPath, reason }) => {
239
+ throw createCliError("INVALID_CONFIG", {
240
+ message: `Invalid config: ${file} (${keyPath}: ${reason})`,
241
+ details: {
242
+ file,
243
+ keyPath,
244
+ reason
245
+ }
246
+ });
247
+ };
248
+ const expectRecord = ({ value, ctx, keyPath }) => {
249
+ if (isRecord(value)) return value;
250
+ return throwInvalidConfig({
251
+ file: ctx.file,
252
+ keyPath: toKeyPath(keyPath),
253
+ reason: "must be an object"
254
+ });
255
+ };
256
+ const ensureNoUnknownKeys = ({ record, allowedKeys, ctx, keyPath }) => {
257
+ const allowed = new Set(allowedKeys);
258
+ for (const key of Object.keys(record)) {
259
+ if (allowed.has(key)) continue;
260
+ const path = [...keyPath, key];
261
+ throwInvalidConfig({
262
+ file: ctx.file,
263
+ keyPath: toKeyPath(path),
264
+ reason: "unknown key"
265
+ });
266
+ }
267
+ };
268
+ const parseBoolean = ({ value, ctx, keyPath }) => {
269
+ if (typeof value === "boolean") return value;
270
+ return throwInvalidConfig({
271
+ file: ctx.file,
272
+ keyPath: toKeyPath(keyPath),
273
+ reason: "must be boolean"
274
+ });
275
+ };
276
+ const parseNonEmptyString = ({ value, ctx, keyPath }) => {
277
+ if (typeof value === "string" && value.trim().length > 0) return value;
278
+ return throwInvalidConfig({
279
+ file: ctx.file,
280
+ keyPath: toKeyPath(keyPath),
281
+ reason: "must be a non-empty string"
282
+ });
283
+ };
284
+ const parsePositiveInteger = ({ value, ctx, keyPath }) => {
285
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
286
+ return throwInvalidConfig({
287
+ file: ctx.file,
288
+ keyPath: toKeyPath(keyPath),
289
+ reason: "must be a positive integer"
290
+ });
291
+ };
292
+ const parseStringArray = ({ value, ctx, keyPath }) => {
293
+ if (Array.isArray(value) !== true) return throwInvalidConfig({
294
+ file: ctx.file,
295
+ keyPath: toKeyPath(keyPath),
296
+ reason: "must be an array"
297
+ });
298
+ const values = value;
299
+ const result = [];
300
+ for (const [index, item] of values.entries()) {
301
+ if (typeof item !== "string" || item.length === 0) throwInvalidConfig({
302
+ file: ctx.file,
303
+ keyPath: toKeyPath([...keyPath, String(index)]),
304
+ reason: "must be a non-empty string"
305
+ });
306
+ result.push(item);
307
+ }
308
+ return result;
309
+ };
310
+ const parseColumns = ({ value, ctx, keyPath }) => {
311
+ if (Array.isArray(value) !== true) return throwInvalidConfig({
312
+ file: ctx.file,
313
+ keyPath: toKeyPath(keyPath),
314
+ reason: "must be an array"
315
+ });
316
+ const values = value;
317
+ if (values.length === 0) return throwInvalidConfig({
318
+ file: ctx.file,
319
+ keyPath: toKeyPath(keyPath),
320
+ reason: "must not be empty"
321
+ });
322
+ const allowed = new Set(LIST_TABLE_COLUMNS);
323
+ const seen = /* @__PURE__ */ new Set();
324
+ const parsed = [];
325
+ for (const [index, item] of values.entries()) {
326
+ if (typeof item !== "string") throwInvalidConfig({
327
+ file: ctx.file,
328
+ keyPath: toKeyPath([...keyPath, String(index)]),
329
+ reason: "must be a string"
330
+ });
331
+ if (allowed.has(item) !== true) throwInvalidConfig({
332
+ file: ctx.file,
333
+ keyPath: toKeyPath([...keyPath, String(index)]),
334
+ reason: `unsupported column: ${item}`
335
+ });
336
+ if (seen.has(item)) throwInvalidConfig({
337
+ file: ctx.file,
338
+ keyPath: toKeyPath([...keyPath, String(index)]),
339
+ reason: `duplicate column: ${item}`
340
+ });
341
+ seen.add(item);
342
+ parsed.push(item);
343
+ }
344
+ return parsed;
345
+ };
346
+ const parseListPathTruncate = ({ value, ctx, keyPath }) => {
347
+ if (typeof value !== "string" || LIST_PATH_TRUNCATE_VALUES.includes(value) !== true) throwInvalidConfig({
348
+ file: ctx.file,
349
+ keyPath: toKeyPath(keyPath),
350
+ reason: `must be one of: ${LIST_PATH_TRUNCATE_VALUES.join(", ")}`
351
+ });
352
+ return value;
353
+ };
354
+ const parseSelectorSurface = ({ value, ctx, keyPath }) => {
355
+ if (typeof value !== "string" || SELECTOR_CD_SURFACE_VALUES.includes(value) !== true) throwInvalidConfig({
356
+ file: ctx.file,
357
+ keyPath: toKeyPath(keyPath),
358
+ reason: `must be one of: ${SELECTOR_CD_SURFACE_VALUES.join(", ")}`
359
+ });
360
+ return value;
361
+ };
362
+ const validatePartialConfig = ({ rawConfig, ctx }) => {
363
+ if (rawConfig === null || rawConfig === void 0) return {};
364
+ const root = expectRecord({
365
+ value: rawConfig,
366
+ ctx,
367
+ keyPath: []
368
+ });
369
+ ensureNoUnknownKeys({
370
+ record: root,
371
+ allowedKeys: [
372
+ "paths",
373
+ "git",
374
+ "github",
375
+ "hooks",
376
+ "locks",
377
+ "list",
378
+ "selector"
379
+ ],
380
+ ctx,
381
+ keyPath: []
382
+ });
383
+ const partial = {};
384
+ if (root.paths !== void 0) {
385
+ const paths = expectRecord({
386
+ value: root.paths,
387
+ ctx,
388
+ keyPath: ["paths"]
389
+ });
390
+ ensureNoUnknownKeys({
391
+ record: paths,
392
+ allowedKeys: ["worktreeRoot"],
393
+ ctx,
394
+ keyPath: ["paths"]
395
+ });
396
+ partial.paths = {};
397
+ if (paths.worktreeRoot !== void 0) partial.paths.worktreeRoot = parseNonEmptyString({
398
+ value: paths.worktreeRoot,
399
+ ctx,
400
+ keyPath: ["paths", "worktreeRoot"]
401
+ });
402
+ }
403
+ if (root.git !== void 0) {
404
+ const git = expectRecord({
405
+ value: root.git,
406
+ ctx,
407
+ keyPath: ["git"]
408
+ });
409
+ ensureNoUnknownKeys({
410
+ record: git,
411
+ allowedKeys: ["baseBranch", "baseRemote"],
412
+ ctx,
413
+ keyPath: ["git"]
414
+ });
415
+ partial.git = {};
416
+ if (git.baseBranch !== void 0) {
417
+ if (git.baseBranch !== null && typeof git.baseBranch !== "string") throwInvalidConfig({
418
+ file: ctx.file,
419
+ keyPath: toKeyPath(["git", "baseBranch"]),
420
+ reason: "must be a string or null"
421
+ });
422
+ partial.git.baseBranch = git.baseBranch === null ? null : parseNonEmptyString({
423
+ value: git.baseBranch,
424
+ ctx,
425
+ keyPath: ["git", "baseBranch"]
426
+ });
427
+ }
428
+ if (git.baseRemote !== void 0) partial.git.baseRemote = parseNonEmptyString({
429
+ value: git.baseRemote,
430
+ ctx,
431
+ keyPath: ["git", "baseRemote"]
432
+ });
433
+ }
434
+ if (root.github !== void 0) {
435
+ const github = expectRecord({
436
+ value: root.github,
437
+ ctx,
438
+ keyPath: ["github"]
439
+ });
440
+ ensureNoUnknownKeys({
441
+ record: github,
442
+ allowedKeys: ["enabled"],
443
+ ctx,
444
+ keyPath: ["github"]
445
+ });
446
+ partial.github = {};
447
+ if (github.enabled !== void 0) partial.github.enabled = parseBoolean({
448
+ value: github.enabled,
449
+ ctx,
450
+ keyPath: ["github", "enabled"]
451
+ });
452
+ }
453
+ if (root.hooks !== void 0) {
454
+ const hooks = expectRecord({
455
+ value: root.hooks,
456
+ ctx,
457
+ keyPath: ["hooks"]
458
+ });
459
+ ensureNoUnknownKeys({
460
+ record: hooks,
461
+ allowedKeys: ["enabled", "timeoutMs"],
462
+ ctx,
463
+ keyPath: ["hooks"]
464
+ });
465
+ partial.hooks = {};
466
+ if (hooks.enabled !== void 0) partial.hooks.enabled = parseBoolean({
467
+ value: hooks.enabled,
468
+ ctx,
469
+ keyPath: ["hooks", "enabled"]
470
+ });
471
+ if (hooks.timeoutMs !== void 0) partial.hooks.timeoutMs = parsePositiveInteger({
472
+ value: hooks.timeoutMs,
473
+ ctx,
474
+ keyPath: ["hooks", "timeoutMs"]
475
+ });
476
+ }
477
+ if (root.locks !== void 0) {
478
+ const locks = expectRecord({
479
+ value: root.locks,
480
+ ctx,
481
+ keyPath: ["locks"]
482
+ });
483
+ ensureNoUnknownKeys({
484
+ record: locks,
485
+ allowedKeys: ["timeoutMs", "staleLockTTLSeconds"],
486
+ ctx,
487
+ keyPath: ["locks"]
488
+ });
489
+ partial.locks = {};
490
+ if (locks.timeoutMs !== void 0) partial.locks.timeoutMs = parsePositiveInteger({
491
+ value: locks.timeoutMs,
492
+ ctx,
493
+ keyPath: ["locks", "timeoutMs"]
494
+ });
495
+ if (locks.staleLockTTLSeconds !== void 0) partial.locks.staleLockTTLSeconds = parsePositiveInteger({
496
+ value: locks.staleLockTTLSeconds,
497
+ ctx,
498
+ keyPath: ["locks", "staleLockTTLSeconds"]
499
+ });
500
+ }
501
+ if (root.list !== void 0) {
502
+ const list = expectRecord({
503
+ value: root.list,
504
+ ctx,
505
+ keyPath: ["list"]
506
+ });
507
+ ensureNoUnknownKeys({
508
+ record: list,
509
+ allowedKeys: ["table"],
510
+ ctx,
511
+ keyPath: ["list"]
512
+ });
513
+ partial.list = {};
514
+ if (list.table !== void 0) {
515
+ const table = expectRecord({
516
+ value: list.table,
517
+ ctx,
518
+ keyPath: ["list", "table"]
519
+ });
520
+ ensureNoUnknownKeys({
521
+ record: table,
522
+ allowedKeys: ["columns", "path"],
523
+ ctx,
524
+ keyPath: ["list", "table"]
525
+ });
526
+ partial.list.table = {};
527
+ if (table.columns !== void 0) partial.list.table.columns = parseColumns({
528
+ value: table.columns,
529
+ ctx,
530
+ keyPath: [
531
+ "list",
532
+ "table",
533
+ "columns"
534
+ ]
535
+ });
536
+ if (table.path !== void 0) {
537
+ const pathConfig = expectRecord({
538
+ value: table.path,
539
+ ctx,
540
+ keyPath: [
541
+ "list",
542
+ "table",
543
+ "path"
544
+ ]
545
+ });
546
+ ensureNoUnknownKeys({
547
+ record: pathConfig,
548
+ allowedKeys: ["truncate", "minWidth"],
549
+ ctx,
550
+ keyPath: [
551
+ "list",
552
+ "table",
553
+ "path"
554
+ ]
555
+ });
556
+ partial.list.table.path = {};
557
+ if (pathConfig.truncate !== void 0) partial.list.table.path.truncate = parseListPathTruncate({
558
+ value: pathConfig.truncate,
559
+ ctx,
560
+ keyPath: [
561
+ "list",
562
+ "table",
563
+ "path",
564
+ "truncate"
565
+ ]
566
+ });
567
+ if (pathConfig.minWidth !== void 0) {
568
+ const minWidth = parsePositiveInteger({
569
+ value: pathConfig.minWidth,
570
+ ctx,
571
+ keyPath: [
572
+ "list",
573
+ "table",
574
+ "path",
575
+ "minWidth"
576
+ ]
577
+ });
578
+ if (minWidth < 8 || minWidth > 200) throwInvalidConfig({
579
+ file: ctx.file,
580
+ keyPath: toKeyPath([
581
+ "list",
582
+ "table",
583
+ "path",
584
+ "minWidth"
585
+ ]),
586
+ reason: "must be in range 8..200"
587
+ });
588
+ partial.list.table.path.minWidth = minWidth;
589
+ }
590
+ }
591
+ }
592
+ }
593
+ if (root.selector !== void 0) {
594
+ const selector = expectRecord({
595
+ value: root.selector,
596
+ ctx,
597
+ keyPath: ["selector"]
598
+ });
599
+ ensureNoUnknownKeys({
600
+ record: selector,
601
+ allowedKeys: ["cd"],
602
+ ctx,
603
+ keyPath: ["selector"]
604
+ });
605
+ partial.selector = {};
606
+ if (selector.cd !== void 0) {
607
+ const cd = expectRecord({
608
+ value: selector.cd,
609
+ ctx,
610
+ keyPath: ["selector", "cd"]
611
+ });
612
+ ensureNoUnknownKeys({
613
+ record: cd,
614
+ allowedKeys: [
615
+ "prompt",
616
+ "surface",
617
+ "tmuxPopupOpts",
618
+ "fzf"
619
+ ],
620
+ ctx,
621
+ keyPath: ["selector", "cd"]
622
+ });
623
+ partial.selector.cd = {};
624
+ if (cd.prompt !== void 0) partial.selector.cd.prompt = parseNonEmptyString({
625
+ value: cd.prompt,
626
+ ctx,
627
+ keyPath: [
628
+ "selector",
629
+ "cd",
630
+ "prompt"
631
+ ]
632
+ });
633
+ if (cd.surface !== void 0) partial.selector.cd.surface = parseSelectorSurface({
634
+ value: cd.surface,
635
+ ctx,
636
+ keyPath: [
637
+ "selector",
638
+ "cd",
639
+ "surface"
640
+ ]
641
+ });
642
+ if (cd.tmuxPopupOpts !== void 0) partial.selector.cd.tmuxPopupOpts = parseNonEmptyString({
643
+ value: cd.tmuxPopupOpts,
644
+ ctx,
645
+ keyPath: [
646
+ "selector",
647
+ "cd",
648
+ "tmuxPopupOpts"
649
+ ]
650
+ });
651
+ if (cd.fzf !== void 0) {
652
+ const fzf = expectRecord({
653
+ value: cd.fzf,
654
+ ctx,
655
+ keyPath: [
656
+ "selector",
657
+ "cd",
658
+ "fzf"
659
+ ]
660
+ });
661
+ ensureNoUnknownKeys({
662
+ record: fzf,
663
+ allowedKeys: ["extraArgs"],
664
+ ctx,
665
+ keyPath: [
666
+ "selector",
667
+ "cd",
668
+ "fzf"
669
+ ]
670
+ });
671
+ partial.selector.cd.fzf = {};
672
+ if (fzf.extraArgs !== void 0) partial.selector.cd.fzf.extraArgs = parseStringArray({
673
+ value: fzf.extraArgs,
674
+ ctx,
675
+ keyPath: [
676
+ "selector",
677
+ "cd",
678
+ "fzf",
679
+ "extraArgs"
680
+ ]
681
+ });
682
+ }
683
+ }
684
+ }
685
+ return partial;
686
+ };
687
+ const mergeConfig = (base, partial) => {
688
+ return {
689
+ paths: { worktreeRoot: partial.paths?.worktreeRoot ?? base.paths.worktreeRoot },
690
+ git: {
691
+ baseBranch: partial.git?.baseBranch === void 0 ? base.git.baseBranch : partial.git.baseBranch,
692
+ baseRemote: partial.git?.baseRemote ?? base.git.baseRemote
693
+ },
694
+ github: { enabled: partial.github?.enabled ?? base.github.enabled },
695
+ hooks: {
696
+ enabled: partial.hooks?.enabled ?? base.hooks.enabled,
697
+ timeoutMs: partial.hooks?.timeoutMs ?? base.hooks.timeoutMs
698
+ },
699
+ locks: {
700
+ timeoutMs: partial.locks?.timeoutMs ?? base.locks.timeoutMs,
701
+ staleLockTTLSeconds: partial.locks?.staleLockTTLSeconds ?? base.locks.staleLockTTLSeconds
702
+ },
703
+ list: { table: {
704
+ columns: partial.list?.table?.columns ? [...partial.list.table.columns] : [...base.list.table.columns],
705
+ path: {
706
+ truncate: partial.list?.table?.path?.truncate ?? base.list.table.path.truncate,
707
+ minWidth: partial.list?.table?.path?.minWidth ?? base.list.table.path.minWidth
708
+ }
709
+ } },
710
+ selector: { cd: {
711
+ prompt: partial.selector?.cd?.prompt ?? base.selector.cd.prompt,
712
+ surface: partial.selector?.cd?.surface ?? base.selector.cd.surface,
713
+ tmuxPopupOpts: partial.selector?.cd?.tmuxPopupOpts ?? base.selector.cd.tmuxPopupOpts,
714
+ fzf: { extraArgs: partial.selector?.cd?.fzf?.extraArgs ? [...partial.selector.cd.fzf.extraArgs] : [...base.selector.cd.fzf.extraArgs] }
715
+ } }
716
+ };
717
+ };
718
+ const configPathExists = async (filePath) => {
719
+ try {
720
+ await access(filePath, constants.F_OK);
721
+ return true;
722
+ } catch {
723
+ return false;
724
+ }
725
+ };
726
+ const resolveLocalConfigPath = (directory) => {
727
+ return join(directory, ...LOCAL_CONFIG_PATH_SEGMENTS);
728
+ };
729
+ const resolveGlobalConfigPath = () => {
730
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
731
+ if (typeof xdgConfigHome === "string" && xdgConfigHome.length > 0) return join(resolve(xdgConfigHome), ...GLOBAL_CONFIG_PATH_SEGMENTS);
732
+ return join(homedir(), ".config", ...GLOBAL_CONFIG_PATH_SEGMENTS);
733
+ };
734
+ const resolveExistingConfigFiles = async ({ cwd, repoRoot }) => {
735
+ const localCandidates = (await collectConfigSearchDirectories(cwd)).map((directory) => resolveLocalConfigPath(directory));
736
+ const repoRootCandidate = resolveLocalConfigPath(repoRoot);
737
+ const lowToHighCandidates = [
738
+ resolveGlobalConfigPath(),
739
+ repoRootCandidate,
740
+ ...localCandidates
741
+ ];
742
+ const deduped = /* @__PURE__ */ new Map();
743
+ for (const [order, candidate] of lowToHighCandidates.entries()) {
744
+ if (await configPathExists(candidate) !== true) continue;
745
+ const canonical = await realpath(candidate).catch(() => resolve(candidate));
746
+ deduped.set(canonical, {
747
+ path: candidate,
748
+ order
749
+ });
750
+ }
751
+ return [...deduped.values()].sort((a, b) => a.order - b.order).map((entry) => entry.path);
752
+ };
753
+ const validateWorktreeRoot = async ({ repoRoot, config }) => {
754
+ const rawWorktreeRoot = config.paths.worktreeRoot;
755
+ const resolvedWorktreeRoot = isAbsolute(rawWorktreeRoot) ? resolve(rawWorktreeRoot) : resolve(repoRoot, rawWorktreeRoot);
756
+ try {
757
+ if ((await lstat(resolvedWorktreeRoot)).isDirectory() !== true) throwInvalidConfig({
758
+ file: "<resolved>",
759
+ keyPath: "paths.worktreeRoot",
760
+ reason: "must not point to an existing file"
761
+ });
762
+ } catch (error) {
763
+ if (error.code === "ENOENT") return;
764
+ throw error;
765
+ }
766
+ };
767
+ const parseConfigFile = async (file) => {
768
+ const rawContent = await readFile(file, "utf8");
769
+ let parsed;
770
+ try {
771
+ parsed = parse(rawContent);
772
+ } catch (error) {
773
+ throwInvalidConfig({
774
+ file,
775
+ keyPath: "<root>",
776
+ reason: error instanceof Error ? error.message : String(error)
777
+ });
778
+ }
779
+ return validatePartialConfig({
780
+ rawConfig: parsed,
781
+ ctx: { file }
782
+ });
783
+ };
784
+ const cloneDefaultConfig = () => {
785
+ return mergeConfig(DEFAULT_CONFIG, {});
786
+ };
787
+ const loadResolvedConfig = async ({ cwd, repoRoot }) => {
788
+ const files = await resolveExistingConfigFiles({
789
+ cwd,
790
+ repoRoot
791
+ });
792
+ let config = cloneDefaultConfig();
793
+ for (const file of files) {
794
+ const partial = await parseConfigFile(file);
795
+ config = mergeConfig(config, partial);
796
+ }
797
+ await validateWorktreeRoot({
798
+ repoRoot,
799
+ config
800
+ });
801
+ return {
802
+ config,
803
+ loadedFiles: files
804
+ };
805
+ };
806
+
133
807
  //#endregion
134
808
  //#region src/git/exec.ts
135
809
  const runGitCommand = async ({ cwd, args, reject = true }) => {
@@ -175,6 +849,7 @@ const doesGitRefExist = async (cwd, ref) => {
175
849
  //#endregion
176
850
  //#region src/core/paths.ts
177
851
  const GIT_DIR_NAME = ".git";
852
+ const DEFAULT_WORKTREE_ROOT = ".worktree";
178
853
  const WORKTREE_ID_HASH_LENGTH = 12;
179
854
  const WORKTREE_ID_SLUG_MAX_LENGTH = 48;
180
855
  const resolveRepoRootFromCommonDir = ({ currentWorktreeRoot, gitCommonDir }) => {
@@ -212,8 +887,9 @@ const resolveRepoContext = async (cwd) => {
212
887
  gitCommonDir
213
888
  };
214
889
  };
215
- const getWorktreeRootPath = (repoRoot) => {
216
- return join(repoRoot, ".worktree");
890
+ const getWorktreeRootPath = (repoRoot, configuredWorktreeRoot = DEFAULT_WORKTREE_ROOT) => {
891
+ if (isAbsolute(configuredWorktreeRoot)) return resolve(configuredWorktreeRoot);
892
+ return resolve(repoRoot, configuredWorktreeRoot);
217
893
  };
218
894
  const getWorktreeMetaRootPath = (repoRoot) => {
219
895
  return join(repoRoot, ".vde", "worktree");
@@ -233,25 +909,33 @@ const getStateDirectoryPath = (repoRoot) => {
233
909
  const branchToWorktreeId = (branch) => {
234
910
  return `${branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, WORKTREE_ID_SLUG_MAX_LENGTH) || "branch"}--${createHash("sha256").update(branch).digest("hex").slice(0, WORKTREE_ID_HASH_LENGTH)}`;
235
911
  };
236
- const branchToWorktreePath = (repoRoot, branch) => {
237
- const worktreeRoot = getWorktreeRootPath(repoRoot);
238
- return ensurePathInsideRepo({
239
- repoRoot: worktreeRoot,
240
- path: join(worktreeRoot, ...branch.split("/"))
912
+ const branchToWorktreePath = (repoRoot, branch, configuredWorktreeRoot = DEFAULT_WORKTREE_ROOT) => {
913
+ const worktreeRoot = getWorktreeRootPath(repoRoot, configuredWorktreeRoot);
914
+ return ensurePathInsideRoot({
915
+ rootPath: worktreeRoot,
916
+ path: join(worktreeRoot, ...branch.split("/")),
917
+ message: "Path is outside managed worktree root"
241
918
  });
242
919
  };
243
- const ensurePathInsideRepo = ({ repoRoot, path }) => {
244
- const rel = relative(repoRoot, path);
920
+ const ensurePathInsideRoot = ({ rootPath, path, message = "Path is outside allowed root" }) => {
921
+ const rel = relative(rootPath, path);
245
922
  if (rel === "") return path;
246
923
  if (rel === ".." || rel.startsWith(`..${sep}`)) throw createCliError("PATH_OUTSIDE_REPO", {
247
- message: "Path is outside repository root",
924
+ message,
248
925
  details: {
249
- repoRoot,
926
+ rootPath,
250
927
  path
251
928
  }
252
929
  });
253
930
  return path;
254
931
  };
932
+ const ensurePathInsideRepo = ({ repoRoot, path }) => {
933
+ return ensurePathInsideRoot({
934
+ rootPath: repoRoot,
935
+ path,
936
+ message: "Path is outside repository root"
937
+ });
938
+ };
255
939
  const resolveRepoRelativePath = ({ repoRoot, relativePath }) => {
256
940
  if (isAbsolute(relativePath)) throw createCliError("ABSOLUTE_PATH_NOT_ALLOWED", {
257
941
  message: "Absolute path is not allowed",
@@ -266,6 +950,11 @@ const resolvePathFromCwd = ({ cwd, path }) => {
266
950
  if (isAbsolute(path)) return path;
267
951
  return resolve(cwd, path);
268
952
  };
953
+ const isManagedWorktreePath = ({ worktreePath, managedWorktreeRoot }) => {
954
+ const rel = relative(managedWorktreeRoot, worktreePath);
955
+ if (rel === "" || rel === "." || rel === "..") return false;
956
+ return rel.startsWith(`..${sep}`) !== true;
957
+ };
269
958
 
270
959
  //#endregion
271
960
  //#region src/core/hooks.ts
@@ -287,11 +976,10 @@ const appendHookLog = async ({ repoRoot, action, branch, content }) => {
287
976
  branch
288
977
  })), content, "utf8");
289
978
  };
290
- const runHook = async ({ phase, hookName, args, context, requireExists = false }) => {
291
- if (context.enabled !== true) return;
292
- const path = hookPath(context.repoRoot, hookName);
979
+ const ensureHookExists = async ({ path, hookName, requireExists }) => {
293
980
  try {
294
981
  await access(path, constants.F_OK);
982
+ return true;
295
983
  } catch {
296
984
  if (requireExists) throw createCliError("HOOK_NOT_FOUND", {
297
985
  message: `Hook not found: ${hookName}`,
@@ -300,8 +988,10 @@ const runHook = async ({ phase, hookName, args, context, requireExists = false }
300
988
  path
301
989
  }
302
990
  });
303
- return;
991
+ return false;
304
992
  }
993
+ };
994
+ const ensureHookExecutable = async ({ path, hookName }) => {
305
995
  try {
306
996
  await access(path, constants.X_OK);
307
997
  } catch {
@@ -313,43 +1003,102 @@ const runHook = async ({ phase, hookName, args, context, requireExists = false }
313
1003
  }
314
1004
  });
315
1005
  }
1006
+ };
1007
+ const executeHookProcess = async ({ path, args, context }) => {
316
1008
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
317
- try {
318
- const result = await execa(path, [...args], {
319
- cwd: context.worktreePath ?? context.repoRoot,
320
- env: {
321
- ...process.env,
322
- WT_REPO_ROOT: context.repoRoot,
323
- WT_ACTION: context.action,
324
- WT_BRANCH: context.branch ?? "",
325
- WT_WORKTREE_PATH: context.worktreePath ?? "",
326
- WT_IS_TTY: process.stdout.isTTY === true ? "1" : "0",
327
- WT_TOOL: "vde-worktree",
328
- ...context.extraEnv ?? {}
329
- },
330
- timeout: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
331
- reject: false
332
- });
333
- const endedAt = (/* @__PURE__ */ new Date()).toISOString();
334
- const logContent = [
1009
+ const result = await execa(path, [...args], {
1010
+ cwd: context.worktreePath ?? context.repoRoot,
1011
+ env: {
1012
+ ...process.env,
1013
+ WT_REPO_ROOT: context.repoRoot,
1014
+ WT_ACTION: context.action,
1015
+ WT_BRANCH: context.branch ?? "",
1016
+ WT_WORKTREE_PATH: context.worktreePath ?? "",
1017
+ WT_IS_TTY: process.stdout.isTTY === true ? "1" : "0",
1018
+ WT_TOOL: "vde-worktree",
1019
+ ...context.extraEnv ?? {}
1020
+ },
1021
+ timeout: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
1022
+ reject: false
1023
+ });
1024
+ const endedAt = (/* @__PURE__ */ new Date()).toISOString();
1025
+ return {
1026
+ exitCode: result.exitCode ?? 0,
1027
+ stderr: result.stderr ?? "",
1028
+ timedOut: result.timedOut === true,
1029
+ startedAt,
1030
+ endedAt
1031
+ };
1032
+ };
1033
+ const writeHookLog = async ({ repoRoot, action, branch, hookName, phase, result }) => {
1034
+ await appendHookLog({
1035
+ repoRoot,
1036
+ action,
1037
+ branch,
1038
+ content: [
335
1039
  `hook=${hookName}`,
336
1040
  `phase=${phase}`,
337
- `start=${startedAt}`,
338
- `end=${endedAt}`,
339
- `exitCode=${String(result.exitCode ?? 0)}`,
340
- `stderr=${result.stderr ?? ""}`,
1041
+ `start=${result.startedAt}`,
1042
+ `end=${result.endedAt}`,
1043
+ `exitCode=${String(result.exitCode)}`,
1044
+ `timedOut=${result.timedOut ? "1" : "0"}`,
1045
+ `stderr=${result.stderr}`,
341
1046
  ""
342
- ].join("\n");
343
- await appendHookLog({
1047
+ ].join("\n")
1048
+ });
1049
+ };
1050
+ const shouldIgnorePostHookFailure = ({ phase, context }) => {
1051
+ return phase === "post" && context.strictPostHooks !== true;
1052
+ };
1053
+ const handleIgnoredPostHookFailure = ({ context, hookName, message }) => {
1054
+ context.stderr(message ?? `Hook failed: ${hookName}`);
1055
+ };
1056
+ const runHook = async ({ phase, hookName, args, context, requireExists = false }) => {
1057
+ if (context.enabled !== true) return;
1058
+ const path = hookPath(context.repoRoot, hookName);
1059
+ if (await ensureHookExists({
1060
+ path,
1061
+ hookName,
1062
+ requireExists
1063
+ }) !== true) return;
1064
+ await ensureHookExecutable({
1065
+ path,
1066
+ hookName
1067
+ });
1068
+ try {
1069
+ const result = await executeHookProcess({
1070
+ path,
1071
+ args,
1072
+ context
1073
+ });
1074
+ await writeHookLog({
344
1075
  repoRoot: context.repoRoot,
345
1076
  action: context.action,
346
1077
  branch: context.branch,
347
- content: logContent
1078
+ hookName,
1079
+ phase,
1080
+ result
348
1081
  });
349
- if ((result.exitCode ?? 0) === 0) return;
350
- const message = `Hook failed: ${hookName} (exitCode=${String(result.exitCode ?? 1)})`;
351
- if (phase === "post" && context.strictPostHooks !== true) {
352
- context.stderr(message);
1082
+ if (result.timedOut) throw createCliError("HOOK_TIMEOUT", {
1083
+ message: `Hook timed out: ${hookName}`,
1084
+ details: {
1085
+ hook: hookName,
1086
+ timeoutMs: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
1087
+ exitCode: result.exitCode,
1088
+ stderr: result.stderr
1089
+ }
1090
+ });
1091
+ if (result.exitCode === 0) return;
1092
+ const message = `Hook failed: ${hookName} (exitCode=${String(result.exitCode)})`;
1093
+ if (shouldIgnorePostHookFailure({
1094
+ phase,
1095
+ context
1096
+ })) {
1097
+ handleIgnoredPostHookFailure({
1098
+ context,
1099
+ hookName,
1100
+ message
1101
+ });
353
1102
  return;
354
1103
  }
355
1104
  throw createCliError("HOOK_FAILED", {
@@ -372,8 +1121,14 @@ const runHook = async ({ phase, hookName, args, context, requireExists = false }
372
1121
  },
373
1122
  cause: error
374
1123
  });
375
- if (phase === "post" && context.strictPostHooks !== true) {
376
- context.stderr(`Hook failed: ${hookName}`);
1124
+ if (shouldIgnorePostHookFailure({
1125
+ phase,
1126
+ context
1127
+ })) {
1128
+ handleIgnoredPostHookFailure({
1129
+ context,
1130
+ hookName
1131
+ });
377
1132
  return;
378
1133
  }
379
1134
  throw createCliError("HOOK_FAILED", {
@@ -414,7 +1169,7 @@ const invokeHook = async ({ hookName, args, context }) => {
414
1169
 
415
1170
  //#endregion
416
1171
  //#region src/core/init.ts
417
- const MANAGED_EXCLUDE_BLOCK = `# vde-worktree (managed)\n.worktree/\n.vde/worktree/\n`;
1172
+ const EXCLUDE_MARKER = "# vde-worktree (managed)";
418
1173
  const DEFAULT_HOOKS = [{
419
1174
  name: "post-new",
420
1175
  lines: [
@@ -448,7 +1203,27 @@ const createHookTemplate = async (hooksDir, name, lines) => {
448
1203
  await chmod(targetPath, 493);
449
1204
  }
450
1205
  };
451
- const ensureExcludeBlock = async (repoRoot) => {
1206
+ const isPathInsideOrEqual = ({ rootPath, candidatePath }) => {
1207
+ const rel = relative(rootPath, candidatePath);
1208
+ if (rel.length === 0) return true;
1209
+ return rel !== ".." && rel.startsWith(`..${sep}`) !== true;
1210
+ };
1211
+ const toExcludeEntry = ({ repoRoot, managedWorktreeRoot }) => {
1212
+ if (isPathInsideOrEqual({
1213
+ rootPath: repoRoot,
1214
+ candidatePath: managedWorktreeRoot
1215
+ }) !== true) return null;
1216
+ const rel = relative(repoRoot, managedWorktreeRoot).split(sep).join("/");
1217
+ const normalized = rel.length === 0 ? "." : rel;
1218
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
1219
+ };
1220
+ const ensureExcludeBlock = async ({ repoRoot, managedWorktreeRoot }) => {
1221
+ const managedEntry = toExcludeEntry({
1222
+ repoRoot,
1223
+ managedWorktreeRoot
1224
+ });
1225
+ if (managedEntry === null) return;
1226
+ const managedExcludeBlock = `${EXCLUDE_MARKER}\n${managedEntry}\n.vde/worktree/\n`;
452
1227
  const excludePath = join(repoRoot, ".git", "info", "exclude");
453
1228
  let current = "";
454
1229
  try {
@@ -456,8 +1231,8 @@ const ensureExcludeBlock = async (repoRoot) => {
456
1231
  } catch {
457
1232
  current = "";
458
1233
  }
459
- if (current.includes(MANAGED_EXCLUDE_BLOCK)) return;
460
- await writeFile(excludePath, `${current.endsWith("\n") || current.length === 0 ? current : `${current}\n`}${MANAGED_EXCLUDE_BLOCK}`, "utf8");
1234
+ if (current.includes(managedExcludeBlock)) return;
1235
+ await writeFile(excludePath, `${current.endsWith("\n") || current.length === 0 ? current : `${current}\n`}${managedExcludeBlock}`, "utf8");
461
1236
  };
462
1237
  const isInitialized = async (repoRoot) => {
463
1238
  try {
@@ -467,18 +1242,94 @@ const isInitialized = async (repoRoot) => {
467
1242
  return false;
468
1243
  }
469
1244
  };
470
- const initializeRepository = async (repoRoot) => {
1245
+ const initializeRepository = async ({ repoRoot, managedWorktreeRoot }) => {
471
1246
  const wasInitialized = await isInitialized(repoRoot);
472
- await mkdir(getWorktreeRootPath(repoRoot), { recursive: true });
1247
+ await mkdir(managedWorktreeRoot, { recursive: true });
473
1248
  await mkdir(getHooksDirectoryPath(repoRoot), { recursive: true });
474
1249
  await mkdir(getLogsDirectoryPath(repoRoot), { recursive: true });
475
1250
  await mkdir(getLocksDirectoryPath(repoRoot), { recursive: true });
476
1251
  await mkdir(getStateDirectoryPath(repoRoot), { recursive: true });
477
- await ensureExcludeBlock(repoRoot);
1252
+ await ensureExcludeBlock({
1253
+ repoRoot,
1254
+ managedWorktreeRoot
1255
+ });
478
1256
  for (const hook of DEFAULT_HOOKS) await createHookTemplate(getHooksDirectoryPath(repoRoot), hook.name, hook.lines);
479
1257
  return { alreadyInitialized: wasInitialized };
480
1258
  };
481
1259
 
1260
+ //#endregion
1261
+ //#region src/core/json-storage.ts
1262
+ const parseJsonRecord = ({ content, schemaVersion, validate }) => {
1263
+ try {
1264
+ const parsed = JSON.parse(content);
1265
+ if (parsed.schemaVersion !== schemaVersion || validate(parsed) !== true) return {
1266
+ valid: false,
1267
+ record: null
1268
+ };
1269
+ return {
1270
+ valid: true,
1271
+ record: parsed
1272
+ };
1273
+ } catch {
1274
+ return {
1275
+ valid: false,
1276
+ record: null
1277
+ };
1278
+ }
1279
+ };
1280
+ const readJsonRecord = async ({ path, schemaVersion, validate }) => {
1281
+ try {
1282
+ return {
1283
+ path,
1284
+ exists: true,
1285
+ ...parseJsonRecord({
1286
+ content: await readFile(path, "utf8"),
1287
+ schemaVersion,
1288
+ validate
1289
+ })
1290
+ };
1291
+ } catch (error) {
1292
+ if (error.code === "ENOENT") return {
1293
+ path,
1294
+ exists: false,
1295
+ valid: true,
1296
+ record: null
1297
+ };
1298
+ return {
1299
+ path,
1300
+ exists: true,
1301
+ valid: false,
1302
+ record: null
1303
+ };
1304
+ }
1305
+ };
1306
+ const writeJsonAtomically = async ({ filePath, payload, ensureDir = false }) => {
1307
+ if (ensureDir) await mkdir(dirname(filePath), { recursive: true });
1308
+ const tmpPath = `${filePath}.tmp-${String(process.pid)}-${String(Date.now())}`;
1309
+ try {
1310
+ await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
1311
+ await rename(tmpPath, filePath);
1312
+ } catch (error) {
1313
+ try {
1314
+ await rm(tmpPath, { force: true });
1315
+ } catch {}
1316
+ throw error;
1317
+ }
1318
+ };
1319
+ const writeJsonExclusively = async ({ path, payload }) => {
1320
+ let handle;
1321
+ try {
1322
+ handle = await open(path, "wx");
1323
+ await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8");
1324
+ return true;
1325
+ } catch (error) {
1326
+ if (error.code === "EEXIST") return false;
1327
+ throw error;
1328
+ } finally {
1329
+ if (handle !== void 0) await handle.close();
1330
+ }
1331
+ };
1332
+
482
1333
  //#endregion
483
1334
  //#region src/core/repo-lock.ts
484
1335
  const sleep = async (ms) => {
@@ -496,16 +1347,8 @@ const isProcessAlive = (pid) => {
496
1347
  return true;
497
1348
  }
498
1349
  };
499
- const safeParseLockFile = (content) => {
500
- try {
501
- const parsed = JSON.parse(content);
502
- if (parsed.schemaVersion !== 1) return null;
503
- if (typeof parsed.command !== "string" || typeof parsed.owner !== "string") return null;
504
- if (typeof parsed.pid !== "number" || typeof parsed.host !== "string" || typeof parsed.startedAt !== "string") return null;
505
- return parsed;
506
- } catch {
507
- return null;
508
- }
1350
+ const isRepoLockFileSchema = (parsed) => {
1351
+ return typeof parsed.owner === "string" && typeof parsed.command === "string" && typeof parsed.pid === "number" && typeof parsed.host === "string" && typeof parsed.startedAt === "string";
509
1352
  };
510
1353
  const lockFilePath$1 = async (repoRoot) => {
511
1354
  const stateDir = getStateDirectoryPath(repoRoot);
@@ -534,23 +1377,15 @@ const canRecoverStaleLock = ({ lock, staleLockTTLSeconds }) => {
534
1377
  if (lock.host === hostname() && isProcessAlive(lock.pid)) return false;
535
1378
  return true;
536
1379
  };
537
- const writeNewLockFile = async (path, payload) => {
538
- try {
539
- const handle = await open(path, "wx");
540
- await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8");
541
- await handle.close();
542
- return true;
543
- } catch (error) {
544
- if (error.code === "EEXIST") return false;
545
- throw error;
546
- }
547
- };
548
1380
  const acquireRepoLock = async ({ repoRoot, command, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS, staleLockTTLSeconds = DEFAULT_STALE_LOCK_TTL_SECONDS }) => {
549
1381
  const path = await lockFilePath$1(repoRoot);
550
1382
  const startAt = Date.now();
551
1383
  const payload = buildLockPayload(command);
552
1384
  while (Date.now() - startAt <= timeoutMs) {
553
- if (await writeNewLockFile(path, payload)) return { release: async () => {
1385
+ if (await writeJsonExclusively({
1386
+ path,
1387
+ payload
1388
+ })) return { release: async () => {
554
1389
  try {
555
1390
  await rm(path, { force: true });
556
1391
  } catch {
@@ -565,7 +1400,11 @@ const acquireRepoLock = async ({ repoRoot, command, timeoutMs = DEFAULT_LOCK_TIM
565
1400
  continue;
566
1401
  }
567
1402
  if (canRecoverStaleLock({
568
- lock: safeParseLockFile(lockContent),
1403
+ lock: parseJsonRecord({
1404
+ content: lockContent,
1405
+ schemaVersion: 1,
1406
+ validate: isRepoLockFileSchema
1407
+ }).record,
569
1408
  staleLockTTLSeconds
570
1409
  })) {
571
1410
  try {
@@ -614,57 +1453,16 @@ const hasStateDirectory = async (repoRoot) => {
614
1453
  return false;
615
1454
  }
616
1455
  };
617
- const parseLifecycle = (content) => {
618
- try {
619
- const parsed = JSON.parse(content);
620
- const isLastDivergedHeadValid = parsed.lastDivergedHead === null || typeof parsed.lastDivergedHead === "string" && parsed.lastDivergedHead.length > 0;
621
- if (parsed.schemaVersion !== 2 || typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.baseBranch !== "string" || typeof parsed.everDiverged !== "boolean" || isLastDivergedHeadValid !== true || typeof parsed.createdAt !== "string" || typeof parsed.updatedAt !== "string") return {
622
- valid: false,
623
- record: null
624
- };
625
- return {
626
- valid: true,
627
- record: parsed
628
- };
629
- } catch {
630
- return {
631
- valid: false,
632
- record: null
633
- };
634
- }
635
- };
636
- const writeJsonAtomically$1 = async ({ filePath, payload }) => {
637
- await mkdir(dirname(filePath), { recursive: true });
638
- const tmpPath = `${filePath}.tmp-${String(process.pid)}-${String(Date.now())}`;
639
- await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
640
- await rename(tmpPath, filePath);
1456
+ const isWorktreeMergeLifecycleRecord = (parsed) => {
1457
+ const isLastDivergedHeadValid = parsed.lastDivergedHead === null || typeof parsed.lastDivergedHead === "string" && parsed.lastDivergedHead.length > 0;
1458
+ return typeof parsed.branch === "string" && typeof parsed.worktreeId === "string" && typeof parsed.baseBranch === "string" && typeof parsed.everDiverged === "boolean" && isLastDivergedHeadValid && typeof parsed.createdAt === "string" && typeof parsed.updatedAt === "string";
641
1459
  };
642
1460
  const readWorktreeMergeLifecycle = async ({ repoRoot, branch }) => {
643
- const path = lifecycleFilePath(repoRoot, branch);
644
- try {
645
- await access(path, constants.F_OK);
646
- } catch {
647
- return {
648
- path,
649
- exists: false,
650
- valid: true,
651
- record: null
652
- };
653
- }
654
- try {
655
- return {
656
- path,
657
- exists: true,
658
- ...parseLifecycle(await readFile(path, "utf8"))
659
- };
660
- } catch {
661
- return {
662
- path,
663
- exists: true,
664
- valid: false,
665
- record: null
666
- };
667
- }
1461
+ return readJsonRecord({
1462
+ path: lifecycleFilePath(repoRoot, branch),
1463
+ schemaVersion: 2,
1464
+ validate: isWorktreeMergeLifecycleRecord
1465
+ });
668
1466
  };
669
1467
  const upsertWorktreeMergeLifecycle = async ({ repoRoot, branch, baseBranch, observedDivergedHead }) => {
670
1468
  const normalizedObservedHead = typeof observedDivergedHead === "string" && observedDivergedHead.length > 0 ? observedDivergedHead : null;
@@ -699,9 +1497,10 @@ const upsertWorktreeMergeLifecycle = async ({ repoRoot, branch, baseBranch, obse
699
1497
  createdAt: current.record?.createdAt ?? now,
700
1498
  updatedAt: now
701
1499
  };
702
- await writeJsonAtomically$1({
1500
+ await writeJsonAtomically({
703
1501
  filePath: current.path,
704
- payload: next
1502
+ payload: next,
1503
+ ensureDir: true
705
1504
  });
706
1505
  return next;
707
1506
  };
@@ -738,71 +1537,32 @@ const moveWorktreeMergeLifecycle = async ({ repoRoot, fromBranch, toBranch, base
738
1537
  createdAt: source.record?.createdAt ?? now,
739
1538
  updatedAt: now
740
1539
  };
741
- await writeJsonAtomically$1({
742
- filePath: targetPath,
743
- payload: next
744
- });
745
- if (source.path !== targetPath) await rm(source.path, { force: true });
746
- return next;
747
- };
748
- const deleteWorktreeMergeLifecycle = async ({ repoRoot, branch }) => {
749
- await rm(lifecycleFilePath(repoRoot, branch), { force: true });
750
- };
751
-
752
- //#endregion
753
- //#region src/core/worktree-lock.ts
754
- const parseLock = (content) => {
755
- try {
756
- const parsed = JSON.parse(content);
757
- 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 {
758
- valid: false,
759
- record: null
760
- };
761
- return {
762
- valid: true,
763
- record: parsed
764
- };
765
- } catch {
766
- return {
767
- valid: false,
768
- record: null
769
- };
770
- }
1540
+ await writeJsonAtomically({
1541
+ filePath: targetPath,
1542
+ payload: next,
1543
+ ensureDir: true
1544
+ });
1545
+ if (source.path !== targetPath) await rm(source.path, { force: true });
1546
+ return next;
771
1547
  };
772
- const writeJsonAtomically = async ({ filePath, payload }) => {
773
- const tmpPath = `${filePath}.tmp-${String(process.pid)}-${String(Date.now())}`;
774
- await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
775
- await rename(tmpPath, filePath);
1548
+ const deleteWorktreeMergeLifecycle = async ({ repoRoot, branch }) => {
1549
+ await rm(lifecycleFilePath(repoRoot, branch), { force: true });
1550
+ };
1551
+
1552
+ //#endregion
1553
+ //#region src/core/worktree-lock.ts
1554
+ const isWorktreeLockRecord = (parsed) => {
1555
+ return 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";
776
1556
  };
777
1557
  const lockFilePath = (repoRoot, branch) => {
778
1558
  return join(getLocksDirectoryPath(repoRoot), `${branchToWorktreeId(branch)}.json`);
779
1559
  };
780
1560
  const readWorktreeLock = async ({ repoRoot, branch }) => {
781
- const path = lockFilePath(repoRoot, branch);
782
- try {
783
- await access(path, constants.F_OK);
784
- } catch {
785
- return {
786
- path,
787
- exists: false,
788
- valid: true,
789
- record: null
790
- };
791
- }
792
- try {
793
- return {
794
- path,
795
- exists: true,
796
- ...parseLock(await readFile(path, "utf8"))
797
- };
798
- } catch {
799
- return {
800
- path,
801
- exists: true,
802
- valid: false,
803
- record: null
804
- };
805
- }
1561
+ return readJsonRecord({
1562
+ path: lockFilePath(repoRoot, branch),
1563
+ schemaVersion: 1,
1564
+ validate: isWorktreeLockRecord
1565
+ });
806
1566
  };
807
1567
  const upsertWorktreeLock = async ({ repoRoot, branch, reason, owner }) => {
808
1568
  const { path, record } = await readWorktreeLock({
@@ -833,16 +1593,40 @@ const deleteWorktreeLock = async ({ repoRoot, branch }) => {
833
1593
 
834
1594
  //#endregion
835
1595
  //#region src/integrations/gh.ts
1596
+ var GhUnavailableError = class extends Error {
1597
+ code = "GH_UNAVAILABLE";
1598
+ constructor(message = "gh command is unavailable") {
1599
+ super(message);
1600
+ this.name = "GhUnavailableError";
1601
+ }
1602
+ };
1603
+ var GhCommandError = class extends Error {
1604
+ code = "GH_COMMAND_FAILED";
1605
+ details;
1606
+ constructor({ exitCode, stderr }) {
1607
+ super(`gh command failed with exitCode=${String(exitCode)}`);
1608
+ this.name = "GhCommandError";
1609
+ this.details = {
1610
+ exitCode,
1611
+ stderr
1612
+ };
1613
+ }
1614
+ };
836
1615
  const defaultRunGh = async ({ cwd, args }) => {
837
- const result = await execa("gh", [...args], {
838
- cwd,
839
- reject: false
840
- });
841
- return {
842
- exitCode: result.exitCode ?? 0,
843
- stdout: result.stdout,
844
- stderr: result.stderr
845
- };
1616
+ try {
1617
+ const result = await execa("gh", [...args], {
1618
+ cwd,
1619
+ reject: false
1620
+ });
1621
+ return {
1622
+ exitCode: result.exitCode ?? 0,
1623
+ stdout: result.stdout,
1624
+ stderr: result.stderr
1625
+ };
1626
+ } catch (error) {
1627
+ if (error.code === "ENOENT") throw new GhUnavailableError("gh command not found");
1628
+ throw error;
1629
+ }
846
1630
  };
847
1631
  const toTargetBranches = ({ branches, baseBranch }) => {
848
1632
  const uniqueBranches = /* @__PURE__ */ new Set();
@@ -944,7 +1728,10 @@ const resolvePrStateByBranchBatch = async ({ repoRoot, baseBranch, branches, ena
944
1728
  "headRefName,state,mergedAt,updatedAt,url"
945
1729
  ]
946
1730
  });
947
- if (result.exitCode !== 0) return buildUnknownPrStateMap(targetBranches);
1731
+ if (result.exitCode !== 0) throw new GhCommandError({
1732
+ exitCode: result.exitCode,
1733
+ stderr: result.stderr
1734
+ });
948
1735
  const prStatusByBranch = parsePrStateByBranch({
949
1736
  raw: result.stdout,
950
1737
  targetBranches
@@ -952,6 +1739,7 @@ const resolvePrStateByBranchBatch = async ({ repoRoot, baseBranch, branches, ena
952
1739
  if (prStatusByBranch === null) return buildUnknownPrStateMap(targetBranches);
953
1740
  return prStatusByBranch;
954
1741
  } catch (error) {
1742
+ if (error instanceof GhUnavailableError || error instanceof GhCommandError) return buildUnknownPrStateMap(targetBranches);
955
1743
  if (error.code === "ENOENT") return buildUnknownPrStateMap(targetBranches);
956
1744
  return buildUnknownPrStateMap(targetBranches);
957
1745
  }
@@ -1018,35 +1806,8 @@ const listGitWorktrees = async (repoRoot) => {
1018
1806
 
1019
1807
  //#endregion
1020
1808
  //#region src/core/worktree-state.ts
1021
- const resolveBaseBranch$1 = async (repoRoot) => {
1022
- const explicit = await runGitCommand({
1023
- cwd: repoRoot,
1024
- args: [
1025
- "config",
1026
- "--get",
1027
- "vde-worktree.baseBranch"
1028
- ],
1029
- reject: false
1030
- });
1031
- if (explicit.exitCode === 0 && explicit.stdout.trim().length > 0) return explicit.stdout.trim();
1032
- for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
1033
- return null;
1034
- };
1035
- const resolveEnableGh = async (repoRoot) => {
1036
- const result = await runGitCommand({
1037
- cwd: repoRoot,
1038
- args: [
1039
- "config",
1040
- "--bool",
1041
- "--get",
1042
- "vde-worktree.enableGh"
1043
- ],
1044
- reject: false
1045
- });
1046
- if (result.exitCode !== 0) return true;
1047
- const value = result.stdout.trim().toLowerCase();
1048
- if (value === "false" || value === "no" || value === "off" || value === "0") return false;
1049
- return true;
1809
+ const isLockPayload = (parsed) => {
1810
+ return typeof parsed.branch === "string" && typeof parsed.worktreeId === "string" && typeof parsed.reason === "string" && parsed.reason.length > 0 && (typeof parsed.owner === "undefined" || typeof parsed.owner === "string");
1050
1811
  };
1051
1812
  const resolveDirty = async (worktreePath) => {
1052
1813
  return (await runGitCommand({
@@ -1055,16 +1816,6 @@ const resolveDirty = async (worktreePath) => {
1055
1816
  reject: false
1056
1817
  })).stdout.trim().length > 0;
1057
1818
  };
1058
- const parseLockPayload = (content) => {
1059
- try {
1060
- const parsed = JSON.parse(content);
1061
- if (parsed.schemaVersion !== 1) return null;
1062
- if (typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.reason !== "string" || parsed.reason.length === 0) return null;
1063
- return parsed;
1064
- } catch {
1065
- return null;
1066
- }
1067
- };
1068
1819
  const resolveLockState = async ({ repoRoot, branch }) => {
1069
1820
  if (branch === null) return {
1070
1821
  value: false,
@@ -1072,38 +1823,62 @@ const resolveLockState = async ({ repoRoot, branch }) => {
1072
1823
  owner: null
1073
1824
  };
1074
1825
  const id = branchToWorktreeId(branch);
1075
- const lockPath = join(getLocksDirectoryPath(repoRoot), `${id}.json`);
1076
- try {
1077
- await access(lockPath, constants.F_OK);
1078
- } catch {
1079
- return {
1080
- value: false,
1081
- reason: null,
1082
- owner: null
1083
- };
1084
- }
1085
- try {
1086
- const lock = parseLockPayload(await readFile(lockPath, "utf8"));
1087
- if (lock === null) return {
1088
- value: true,
1089
- reason: "invalid lock metadata",
1090
- owner: null
1091
- };
1092
- return {
1093
- value: true,
1094
- reason: lock.reason,
1095
- owner: typeof lock.owner === "string" && lock.owner.length > 0 ? lock.owner : null
1096
- };
1097
- } catch {
1098
- return {
1099
- value: true,
1100
- reason: "invalid lock metadata",
1101
- owner: null
1102
- };
1103
- }
1826
+ const lock = await readJsonRecord({
1827
+ path: join(getLocksDirectoryPath(repoRoot), `${id}.json`),
1828
+ schemaVersion: 1,
1829
+ validate: isLockPayload
1830
+ });
1831
+ if (lock.exists !== true) return {
1832
+ value: false,
1833
+ reason: null,
1834
+ owner: null
1835
+ };
1836
+ if (lock.valid !== true || lock.record === null) return {
1837
+ value: true,
1838
+ reason: "invalid lock metadata",
1839
+ owner: null
1840
+ };
1841
+ return {
1842
+ value: true,
1843
+ reason: lock.record.reason,
1844
+ owner: typeof lock.record.owner === "string" && lock.record.owner.length > 0 ? lock.record.owner : null
1845
+ };
1104
1846
  };
1105
1847
  const WORK_REFLOG_MESSAGE_PATTERN = /^(commit(?: \([^)]*\))?|cherry-pick|revert|rebase \(pick\)|merge):/;
1106
- const resolveLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1848
+ const resolveAncestryFromExitCode = (exitCode) => {
1849
+ if (exitCode === 0) return true;
1850
+ if (exitCode === 1) return false;
1851
+ return null;
1852
+ };
1853
+ const resolveMergedByPr = ({ branch, baseBranch, prStateByBranch }) => {
1854
+ const prStatus = branch === baseBranch ? null : prStateByBranch.get(branch)?.status ?? null;
1855
+ if (prStatus === "merged") return true;
1856
+ if (prStatus === "none" || prStatus === "open" || prStatus === "closed_unmerged") return false;
1857
+ return null;
1858
+ };
1859
+ const hasLifecycleDivergedHead = (lifecycle) => {
1860
+ return lifecycle.everDiverged === true && lifecycle.lastDivergedHead !== null;
1861
+ };
1862
+ const parseWorkReflogHeads = (reflogOutput) => {
1863
+ const heads = [];
1864
+ let latestHead = null;
1865
+ for (const line of reflogOutput.split("\n")) {
1866
+ const trimmed = line.trim();
1867
+ if (trimmed.length === 0) continue;
1868
+ const separatorIndex = trimmed.indexOf(" ");
1869
+ if (separatorIndex <= 0) continue;
1870
+ const head = trimmed.slice(0, separatorIndex).trim();
1871
+ const message = trimmed.slice(separatorIndex + 1).trim();
1872
+ if (head.length === 0 || WORK_REFLOG_MESSAGE_PATTERN.test(message) !== true) continue;
1873
+ if (latestHead === null) latestHead = head;
1874
+ heads.push(head);
1875
+ }
1876
+ return {
1877
+ heads,
1878
+ latestHead
1879
+ };
1880
+ };
1881
+ const probeLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1107
1882
  const reflog = await runGitCommand({
1108
1883
  cwd: repoRoot,
1109
1884
  args: [
@@ -1118,17 +1893,13 @@ const resolveLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1118
1893
  merged: null,
1119
1894
  divergedHead: null
1120
1895
  };
1121
- let latestWorkHead = null;
1122
- for (const line of reflog.stdout.split("\n")) {
1123
- const trimmed = line.trim();
1124
- if (trimmed.length === 0) continue;
1125
- const separatorIndex = trimmed.indexOf(" ");
1126
- if (separatorIndex <= 0) continue;
1127
- const head = trimmed.slice(0, separatorIndex).trim();
1128
- const message = trimmed.slice(separatorIndex + 1).trim();
1129
- if (head.length === 0 || WORK_REFLOG_MESSAGE_PATTERN.test(message) !== true) continue;
1130
- if (latestWorkHead === null) latestWorkHead = head;
1131
- const result = await runGitCommand({
1896
+ const parsedHeads = parseWorkReflogHeads(reflog.stdout);
1897
+ if (parsedHeads.heads.length === 0) return {
1898
+ merged: null,
1899
+ divergedHead: null
1900
+ };
1901
+ for (const head of parsedHeads.heads) {
1902
+ const merged = resolveAncestryFromExitCode((await runGitCommand({
1132
1903
  cwd: repoRoot,
1133
1904
  args: [
1134
1905
  "merge-base",
@@ -1137,19 +1908,52 @@ const resolveLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1137
1908
  baseBranch
1138
1909
  ],
1139
1910
  reject: false
1140
- });
1141
- if (result.exitCode === 0) return {
1911
+ })).exitCode);
1912
+ if (merged === true) return {
1142
1913
  merged: true,
1143
1914
  divergedHead: head
1144
1915
  };
1145
- if (result.exitCode !== 1) return {
1916
+ if (merged === null) return {
1146
1917
  merged: null,
1147
- divergedHead: latestWorkHead
1918
+ divergedHead: parsedHeads.latestHead
1148
1919
  };
1149
1920
  }
1150
1921
  return {
1151
1922
  merged: false,
1152
- divergedHead: latestWorkHead
1923
+ divergedHead: parsedHeads.latestHead
1924
+ };
1925
+ };
1926
+ const createMergeLifecycleRepository = ({ repoRoot }) => {
1927
+ return { upsert: async ({ branch, baseBranch, observedDivergedHead }) => {
1928
+ return upsertWorktreeMergeLifecycle({
1929
+ repoRoot,
1930
+ branch,
1931
+ baseBranch,
1932
+ observedDivergedHead
1933
+ });
1934
+ } };
1935
+ };
1936
+ const createMergeProbeRepository = ({ repoRoot }) => {
1937
+ return {
1938
+ probeAncestry: async ({ branch, baseBranch }) => {
1939
+ return resolveAncestryFromExitCode((await runGitCommand({
1940
+ cwd: repoRoot,
1941
+ args: [
1942
+ "merge-base",
1943
+ "--is-ancestor",
1944
+ branch,
1945
+ baseBranch
1946
+ ],
1947
+ reject: false
1948
+ })).exitCode);
1949
+ },
1950
+ probeLifecycleFromReflog: async ({ branch, baseBranch }) => {
1951
+ return probeLifecycleFromReflog({
1952
+ repoRoot,
1953
+ branch,
1954
+ baseBranch
1955
+ });
1956
+ }
1153
1957
  };
1154
1958
  };
1155
1959
  const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStateByBranch }) => {
@@ -1158,64 +1962,42 @@ const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStateB
1158
1962
  byPR: null,
1159
1963
  overall: null
1160
1964
  };
1161
- let byAncestry = null;
1162
- if (baseBranch !== null) {
1163
- const result = await runGitCommand({
1164
- cwd: repoRoot,
1165
- args: [
1166
- "merge-base",
1167
- "--is-ancestor",
1168
- branch,
1169
- baseBranch
1170
- ],
1171
- reject: false
1172
- });
1173
- if (result.exitCode === 0) byAncestry = true;
1174
- else if (result.exitCode === 1) byAncestry = false;
1175
- }
1176
- const prStatus = branch === baseBranch ? null : prStateByBranch.get(branch)?.status ?? null;
1177
- let byPR = null;
1178
- if (prStatus === "merged") byPR = true;
1179
- else if (prStatus === "none" || prStatus === "open" || prStatus === "closed_unmerged") byPR = false;
1965
+ const mergeProbeRepository = createMergeProbeRepository({ repoRoot });
1966
+ const mergeLifecycleRepository = createMergeLifecycleRepository({ repoRoot });
1967
+ const byAncestry = baseBranch === null ? null : await mergeProbeRepository.probeAncestry({
1968
+ branch,
1969
+ baseBranch
1970
+ });
1971
+ const byPR = resolveMergedByPr({
1972
+ branch,
1973
+ baseBranch,
1974
+ prStateByBranch
1975
+ });
1180
1976
  let byLifecycle = null;
1181
1977
  if (baseBranch !== null) {
1182
- const lifecycle = await upsertWorktreeMergeLifecycle({
1183
- repoRoot,
1978
+ const lifecycle = await mergeLifecycleRepository.upsert({
1184
1979
  branch,
1185
1980
  baseBranch,
1186
1981
  observedDivergedHead: byAncestry === false ? head : null
1187
1982
  });
1188
1983
  if (byAncestry === false) byLifecycle = false;
1189
- else if (byAncestry === true) if (lifecycle.everDiverged !== true || lifecycle.lastDivergedHead === null) if (byPR === true) byLifecycle = null;
1984
+ else if (byAncestry === true) if (hasLifecycleDivergedHead(lifecycle)) byLifecycle = await mergeProbeRepository.probeAncestry({
1985
+ branch: lifecycle.lastDivergedHead,
1986
+ baseBranch
1987
+ });
1988
+ else if (byPR === true) byLifecycle = null;
1190
1989
  else {
1191
- const probe = await resolveLifecycleFromReflog({
1192
- repoRoot,
1990
+ const probe = await mergeProbeRepository.probeLifecycleFromReflog({
1193
1991
  branch,
1194
1992
  baseBranch
1195
1993
  });
1196
1994
  byLifecycle = probe.merged;
1197
- if (probe.divergedHead !== null) await upsertWorktreeMergeLifecycle({
1198
- repoRoot,
1995
+ if (probe.divergedHead !== null) await mergeLifecycleRepository.upsert({
1199
1996
  branch,
1200
1997
  baseBranch,
1201
1998
  observedDivergedHead: probe.divergedHead
1202
1999
  });
1203
2000
  }
1204
- else {
1205
- const lifecycleResult = await runGitCommand({
1206
- cwd: repoRoot,
1207
- args: [
1208
- "merge-base",
1209
- "--is-ancestor",
1210
- lifecycle.lastDivergedHead,
1211
- baseBranch
1212
- ],
1213
- reject: false
1214
- });
1215
- if (lifecycleResult.exitCode === 0) byLifecycle = true;
1216
- else if (lifecycleResult.exitCode === 1) byLifecycle = false;
1217
- else byLifecycle = null;
1218
- }
1219
2001
  }
1220
2002
  return {
1221
2003
  byAncestry,
@@ -1316,17 +2098,13 @@ const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStateByBranch
1316
2098
  upstream
1317
2099
  };
1318
2100
  };
1319
- const collectWorktreeSnapshot = async (repoRoot, { noGh = false } = {}) => {
1320
- const [baseBranch, worktrees, enableGh] = await Promise.all([
1321
- resolveBaseBranch$1(repoRoot),
1322
- listGitWorktrees(repoRoot),
1323
- resolveEnableGh(repoRoot)
1324
- ]);
2101
+ const collectWorktreeSnapshot = async (repoRoot, { baseBranch = null, ghEnabled = true, noGh = false } = {}) => {
2102
+ const worktrees = await listGitWorktrees(repoRoot);
1325
2103
  const prStateByBranch = await resolvePrStateByBranchBatch({
1326
2104
  repoRoot,
1327
2105
  baseBranch,
1328
2106
  branches: worktrees.map((worktree) => worktree.branch),
1329
- enabled: enableGh && noGh !== true
2107
+ enabled: ghEnabled && noGh !== true
1330
2108
  });
1331
2109
  return {
1332
2110
  repoRoot,
@@ -1350,10 +2128,55 @@ const RESERVED_FZF_ARGS = new Set([
1350
2128
  "prompt",
1351
2129
  "layout",
1352
2130
  "height",
1353
- "border"
2131
+ "border",
2132
+ "tmux"
1354
2133
  ]);
1355
2134
  const ANSI_ESCAPE_SEQUENCE_PATTERN = String.raw`\u001B\[[0-?]*[ -/]*[@-~]`;
1356
2135
  const ANSI_ESCAPE_SEQUENCE_REGEX = new RegExp(ANSI_ESCAPE_SEQUENCE_PATTERN, "g");
2136
+ var FzfError = class extends Error {
2137
+ code;
2138
+ constructor(options) {
2139
+ super(options.message);
2140
+ this.name = "FzfError";
2141
+ this.code = options.code;
2142
+ }
2143
+ };
2144
+ var FzfDependencyError = class extends FzfError {
2145
+ constructor(message = "fzf is required for interactive selection") {
2146
+ super({
2147
+ code: "FZF_DEPENDENCY_MISSING",
2148
+ message
2149
+ });
2150
+ this.name = "FzfDependencyError";
2151
+ }
2152
+ };
2153
+ var FzfInteractiveRequiredError = class extends FzfError {
2154
+ constructor(message = "fzf selection requires an interactive terminal") {
2155
+ super({
2156
+ code: "FZF_INTERACTIVE_REQUIRED",
2157
+ message
2158
+ });
2159
+ this.name = "FzfInteractiveRequiredError";
2160
+ }
2161
+ };
2162
+ var FzfInvalidArgumentError = class extends FzfError {
2163
+ constructor(message) {
2164
+ super({
2165
+ code: "FZF_INVALID_ARGUMENT",
2166
+ message
2167
+ });
2168
+ this.name = "FzfInvalidArgumentError";
2169
+ }
2170
+ };
2171
+ var FzfInvalidSelectionError = class extends FzfError {
2172
+ constructor(message) {
2173
+ super({
2174
+ code: "FZF_INVALID_SELECTION",
2175
+ message
2176
+ });
2177
+ this.name = "FzfInvalidSelectionError";
2178
+ }
2179
+ };
1357
2180
  const sanitizeCandidate = (value) => value.replace(/[\r\n]+/g, " ").trim();
1358
2181
  const stripAnsi = (value) => value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, "");
1359
2182
  const stripTrailingNewlines = (value) => value.replace(/[\r\n]+$/g, "");
@@ -1362,12 +2185,12 @@ const buildFzfInput = (candidates) => {
1362
2185
  };
1363
2186
  const validateExtraFzfArgs = (fzfExtraArgs) => {
1364
2187
  for (const arg of fzfExtraArgs) {
1365
- if (typeof arg !== "string" || arg.length === 0) throw new Error("Empty value is not allowed for --fzf-arg");
2188
+ if (typeof arg !== "string" || arg.length === 0) throw new FzfInvalidArgumentError("Empty value is not allowed for --fzf-arg");
1366
2189
  if (!arg.startsWith("--")) continue;
1367
2190
  const withoutPrefix = arg.slice(2);
1368
2191
  if (withoutPrefix.length === 0) continue;
1369
2192
  const optionName = withoutPrefix.split("=")[0];
1370
- if (optionName !== void 0 && RESERVED_FZF_ARGS.has(optionName)) throw new Error(`--fzf-arg cannot override reserved fzf option: --${optionName}`);
2193
+ if (optionName !== void 0 && RESERVED_FZF_ARGS.has(optionName)) throw new FzfInvalidArgumentError(`--fzf-arg cannot override reserved fzf option: --${optionName}`);
1371
2194
  }
1372
2195
  };
1373
2196
  const buildFzfArgs = ({ prompt, fzfExtraArgs }) => {
@@ -1390,6 +2213,13 @@ const defaultCheckFzfAvailability = async () => {
1390
2213
  throw error;
1391
2214
  }
1392
2215
  };
2216
+ const defaultCheckFzfTmuxSupport = async () => {
2217
+ try {
2218
+ return (await execa(FZF_BINARY, ["--help"], { timeout: FZF_CHECK_TIMEOUT_MS })).stdout.includes("--tmux");
2219
+ } catch {
2220
+ return false;
2221
+ }
2222
+ };
1393
2223
  const defaultRunFzf = async ({ args, input, cwd, env }) => {
1394
2224
  return { stdout: (await execa(FZF_BINARY, args, {
1395
2225
  input,
@@ -1400,33 +2230,68 @@ const defaultRunFzf = async ({ args, input, cwd, env }) => {
1400
2230
  };
1401
2231
  const ensureFzfAvailable = async (checkFzfAvailability) => {
1402
2232
  if (await checkFzfAvailability()) return;
1403
- throw new Error("fzf is required for interactive selection");
2233
+ throw new FzfDependencyError();
2234
+ };
2235
+ const shouldTryTmuxPopup = async ({ surface, env, checkFzfTmuxSupport }) => {
2236
+ if (surface === "inline") return false;
2237
+ if (surface === "tmux-popup") return true;
2238
+ if (typeof env.TMUX !== "string" || env.TMUX.length === 0) return false;
2239
+ try {
2240
+ return await checkFzfTmuxSupport();
2241
+ } catch {
2242
+ return false;
2243
+ }
1404
2244
  };
1405
- 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 }) => {
1406
- if (candidates.length === 0) throw new Error("No candidates provided for fzf selection");
1407
- if (isInteractive() !== true) throw new Error("fzf selection requires an interactive terminal");
2245
+ const isTmuxUnknownOptionError = (error) => {
2246
+ const execaError = error;
2247
+ const text = [
2248
+ execaError.message,
2249
+ execaError.shortMessage,
2250
+ execaError.stderr,
2251
+ execaError.stdout
2252
+ ].filter((value) => typeof value === "string" && value.length > 0).join("\n");
2253
+ return /unknown option.*--tmux|--tmux.*unknown option/i.test(text);
2254
+ };
2255
+ const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", surface = "inline", tmuxPopupOpts = "80%,70%", fzfExtraArgs = [], cwd = process.cwd(), env = process.env, isInteractive = () => process.stdout.isTTY === true && process.stderr.isTTY === true, checkFzfAvailability = defaultCheckFzfAvailability, checkFzfTmuxSupport = defaultCheckFzfTmuxSupport, runFzf = defaultRunFzf }) => {
2256
+ if (candidates.length === 0) throw new FzfInvalidArgumentError("No candidates provided for fzf selection");
2257
+ if (isInteractive() !== true) throw new FzfInteractiveRequiredError();
1408
2258
  await ensureFzfAvailable(checkFzfAvailability);
1409
- const args = buildFzfArgs({
2259
+ const baseArgs = buildFzfArgs({
1410
2260
  prompt,
1411
2261
  fzfExtraArgs
1412
2262
  });
2263
+ const tryTmuxPopup = await shouldTryTmuxPopup({
2264
+ surface,
2265
+ env,
2266
+ checkFzfTmuxSupport
2267
+ });
2268
+ const args = tryTmuxPopup ? [...baseArgs, `--tmux=${tmuxPopupOpts}`] : baseArgs;
1413
2269
  const input = buildFzfInput(candidates);
1414
- if (input.length === 0) throw new Error("All candidates are empty after sanitization");
2270
+ if (input.length === 0) throw new FzfInvalidArgumentError("All candidates are empty after sanitization");
1415
2271
  const candidateSet = new Set(input.split("\n").map((candidate) => stripAnsi(candidate)));
1416
- try {
2272
+ const runWithValidation = async (fzfArgs) => {
1417
2273
  const selectedPath = stripAnsi(stripTrailingNewlines((await runFzf({
1418
- args,
2274
+ args: fzfArgs,
1419
2275
  input,
1420
2276
  cwd,
1421
2277
  env
1422
2278
  })).stdout));
1423
2279
  if (selectedPath.length === 0) return { status: "cancelled" };
1424
- if (!candidateSet.has(selectedPath)) throw new Error("fzf returned a value that is not in the candidate list");
2280
+ if (!candidateSet.has(selectedPath)) throw new FzfInvalidSelectionError("fzf returned a value that is not in the candidate list");
1425
2281
  return {
1426
2282
  status: "selected",
1427
2283
  path: selectedPath
1428
2284
  };
2285
+ };
2286
+ try {
2287
+ return await runWithValidation(args);
1429
2288
  } catch (error) {
2289
+ if (tryTmuxPopup && isTmuxUnknownOptionError(error)) try {
2290
+ return await runWithValidation(baseArgs);
2291
+ } catch (fallbackError) {
2292
+ if (fallbackError.exitCode === 130) return { status: "cancelled" };
2293
+ throw fallbackError;
2294
+ }
1430
2295
  if (error.exitCode === 130) return { status: "cancelled" };
1431
2296
  throw error;
1432
2297
  }
@@ -1483,6 +2348,157 @@ const createLogger = (options = {}) => {
1483
2348
  return build(prefix, level);
1484
2349
  };
1485
2350
 
2351
+ //#endregion
2352
+ //#region src/cli/commands/handler-groups.ts
2353
+ const createHandlerMap = (entries) => {
2354
+ return new Map(entries);
2355
+ };
2356
+ const dispatchCommandHandler = async ({ command, handlers }) => {
2357
+ const handler = handlers.get(command);
2358
+ if (handler === void 0) return;
2359
+ return await handler();
2360
+ };
2361
+ const createEarlyRepoCommandHandlers = ({ initHandler, listHandler, statusHandler, pathHandler }) => {
2362
+ return createHandlerMap([
2363
+ ["init", initHandler],
2364
+ ["list", listHandler],
2365
+ ["status", statusHandler],
2366
+ ["path", pathHandler]
2367
+ ]);
2368
+ };
2369
+ const createWriteCommandHandlers = ({ newHandler, switchHandler }) => {
2370
+ return createHandlerMap([["new", newHandler], ["switch", switchHandler]]);
2371
+ };
2372
+ const createWriteMutationHandlers = ({ mvHandler, delHandler }) => {
2373
+ return createHandlerMap([["mv", mvHandler], ["del", delHandler]]);
2374
+ };
2375
+ const createWorktreeActionHandlers = ({ goneHandler, getHandler, extractHandler }) => {
2376
+ return createHandlerMap([
2377
+ ["gone", goneHandler],
2378
+ ["get", getHandler],
2379
+ ["extract", extractHandler]
2380
+ ]);
2381
+ };
2382
+ const createSynchronizationHandlers = ({ absorbHandler, unabsorbHandler, useHandler }) => {
2383
+ return createHandlerMap([
2384
+ ["absorb", absorbHandler],
2385
+ ["unabsorb", unabsorbHandler],
2386
+ ["use", useHandler]
2387
+ ]);
2388
+ };
2389
+ const createMiscCommandHandlers = ({ execHandler, invokeHandler, copyHandler, linkHandler, lockHandler, unlockHandler, cdHandler }) => {
2390
+ return createHandlerMap([
2391
+ ["exec", execHandler],
2392
+ ["invoke", invokeHandler],
2393
+ ["copy", copyHandler],
2394
+ ["link", linkHandler],
2395
+ ["lock", lockHandler],
2396
+ ["unlock", unlockHandler],
2397
+ ["cd", cdHandler]
2398
+ ]);
2399
+ };
2400
+
2401
+ //#endregion
2402
+ //#region src/cli/commands/read/dispatcher.ts
2403
+ const handled = (exitCode) => {
2404
+ return {
2405
+ handled: true,
2406
+ exitCode
2407
+ };
2408
+ };
2409
+ const NOT_HANDLED = { handled: false };
2410
+ const dispatchReadOnlyCommands = async (input) => {
2411
+ if (input.parsedArgs.help === true) {
2412
+ const commandHelpTarget = input.command !== "unknown" && input.command !== "help" ? input.command : null;
2413
+ if (commandHelpTarget !== null) {
2414
+ const entry = input.findCommandHelp(commandHelpTarget);
2415
+ if (entry !== void 0) {
2416
+ input.stdout(`${input.renderCommandHelpText({ entry })}\n`);
2417
+ return handled(EXIT_CODE.OK);
2418
+ }
2419
+ }
2420
+ input.stdout(`${input.renderGeneralHelpText({ version: input.version })}\n`);
2421
+ return handled(EXIT_CODE.OK);
2422
+ }
2423
+ if (input.parsedArgs.version === true) {
2424
+ input.stdout(input.version);
2425
+ return handled(EXIT_CODE.OK);
2426
+ }
2427
+ if (input.positionals.length === 0) {
2428
+ input.stdout(`${input.renderGeneralHelpText({ version: input.version })}\n`);
2429
+ return handled(EXIT_CODE.OK);
2430
+ }
2431
+ if (input.command === "help") {
2432
+ const helpTarget = input.positionals[1];
2433
+ if (typeof helpTarget !== "string" || helpTarget.length === 0) {
2434
+ input.stdout(`${input.renderGeneralHelpText({ version: input.version })}\n`);
2435
+ return handled(EXIT_CODE.OK);
2436
+ }
2437
+ const entry = input.findCommandHelp(helpTarget);
2438
+ if (entry === void 0) throw createCliError("INVALID_ARGUMENT", {
2439
+ message: `Unknown command for help: ${helpTarget}`,
2440
+ details: {
2441
+ requested: helpTarget,
2442
+ availableCommands: input.availableCommandNames
2443
+ }
2444
+ });
2445
+ input.stdout(`${input.renderCommandHelpText({ entry })}\n`);
2446
+ return handled(EXIT_CODE.OK);
2447
+ }
2448
+ if (input.command === "completion") {
2449
+ input.ensureArgumentCount({
2450
+ command: input.command,
2451
+ args: input.commandArgs,
2452
+ min: 1,
2453
+ max: 1
2454
+ });
2455
+ const shell = input.resolveCompletionShell(input.commandArgs[0]);
2456
+ const script = await input.loadCompletionScript(shell);
2457
+ if (input.parsedArgs.install === true) {
2458
+ const destinationPath = input.resolveCompletionInstallPath({
2459
+ shell,
2460
+ requestedPath: input.readStringOption(input.parsedArgs, "path")
2461
+ });
2462
+ await input.installCompletionScript({
2463
+ content: script,
2464
+ destinationPath
2465
+ });
2466
+ if (input.jsonEnabled) {
2467
+ input.stdout(JSON.stringify(input.buildJsonSuccess({
2468
+ command: input.command,
2469
+ status: "ok",
2470
+ repoRoot: null,
2471
+ details: {
2472
+ shell,
2473
+ installed: true,
2474
+ path: destinationPath
2475
+ }
2476
+ })));
2477
+ return handled(EXIT_CODE.OK);
2478
+ }
2479
+ input.stdout(`installed completion: ${destinationPath}`);
2480
+ if (shell === "zsh") input.stdout("zsh note: ensure completion path is in fpath, then run: autoload -Uz compinit && compinit");
2481
+ return handled(EXIT_CODE.OK);
2482
+ }
2483
+ if (input.jsonEnabled) {
2484
+ input.stdout(JSON.stringify(input.buildJsonSuccess({
2485
+ command: input.command,
2486
+ status: "ok",
2487
+ repoRoot: null,
2488
+ details: {
2489
+ shell,
2490
+ installed: false,
2491
+ script
2492
+ }
2493
+ })));
2494
+ return handled(EXIT_CODE.OK);
2495
+ }
2496
+ input.stdout(script);
2497
+ return handled(EXIT_CODE.OK);
2498
+ }
2499
+ return NOT_HANDLED;
2500
+ };
2501
+
1486
2502
  //#endregion
1487
2503
  //#region src/cli/package-version.ts
1488
2504
  const CANDIDATE_PATHS = ["../package.json", "../../package.json"];
@@ -1514,9 +2530,7 @@ const CD_FZF_EXTRA_ARGS = [
1514
2530
  "--preview-window=right,60%,wrap",
1515
2531
  "--ansi"
1516
2532
  ];
1517
- const LIST_TABLE_COLUMN_COUNT = 8;
1518
- const LIST_TABLE_PATH_COLUMN_INDEX = 7;
1519
- const LIST_TABLE_PATH_MIN_WIDTH = 12;
2533
+ const DEFAULT_LIST_TABLE_COLUMNS = [...LIST_TABLE_COLUMNS];
1520
2534
  const LIST_TABLE_CELL_HORIZONTAL_PADDING = 2;
1521
2535
  const COMPLETION_SHELLS = ["zsh", "fish"];
1522
2536
  const COMPLETION_FILE_BY_SHELL = {
@@ -1538,6 +2552,10 @@ const CATPPUCCIN_MOCHA = {
1538
2552
  overlay0: "#6c7086"
1539
2553
  };
1540
2554
  const identityColor = (value) => value;
2555
+ const hasDefaultListColumnOrder = (columns) => {
2556
+ if (columns.length !== DEFAULT_LIST_TABLE_COLUMNS.length) return false;
2557
+ return columns.every((column, index) => column === DEFAULT_LIST_TABLE_COLUMNS[index]);
2558
+ };
1541
2559
  const createCatppuccinTheme = ({ enabled }) => {
1542
2560
  if (enabled !== true) return {
1543
2561
  header: identityColor,
@@ -1848,6 +2866,7 @@ const commandHelpEntries = [
1848
2866
  options: ["--install", "--path <file>"]
1849
2867
  }
1850
2868
  ];
2869
+ const commandHelpNames = commandHelpEntries.map((entry) => entry.name);
1851
2870
  const splitRawArgsByDoubleDash = (args) => {
1852
2871
  const separatorIndex = args.indexOf("--");
1853
2872
  if (separatorIndex < 0) return {
@@ -1865,7 +2884,8 @@ const toKebabCase = (value) => {
1865
2884
  const toOptionSpec = (kind, optionName) => {
1866
2885
  return {
1867
2886
  kind,
1868
- allowOptionLikeValue: optionNamesAllowOptionLikeValue.has(optionName)
2887
+ allowOptionLikeValue: optionNamesAllowOptionLikeValue.has(optionName),
2888
+ allowNegation: kind === "boolean"
1869
2889
  };
1870
2890
  };
1871
2891
  const buildOptionSpecs = (argsDef) => {
@@ -1893,6 +2913,69 @@ const buildOptionSpecs = (argsDef) => {
1893
2913
  shortOptions
1894
2914
  };
1895
2915
  };
2916
+ const ensureOptionValueToken = ({ valueToken, optionLabel, optionSpec }) => {
2917
+ if (valueToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: ${optionLabel}` });
2918
+ if (valueToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: ${optionLabel}` });
2919
+ };
2920
+ const resolveLongOption = ({ rawOptionName, optionSpecs }) => {
2921
+ const directOptionSpec = optionSpecs.longOptions.get(rawOptionName);
2922
+ if (directOptionSpec !== void 0) return {
2923
+ optionSpec: directOptionSpec,
2924
+ optionName: rawOptionName
2925
+ };
2926
+ if (rawOptionName.startsWith("no-")) {
2927
+ const optionName = rawOptionName.slice(3);
2928
+ const negatedOptionSpec = optionSpecs.longOptions.get(optionName);
2929
+ if (negatedOptionSpec?.allowNegation === true) return {
2930
+ optionSpec: negatedOptionSpec,
2931
+ optionName
2932
+ };
2933
+ }
2934
+ };
2935
+ const validateLongOptionToken = ({ args, index, token, optionSpecs }) => {
2936
+ const value = token.slice(2);
2937
+ if (value.length === 0) return index;
2938
+ const separatorIndex = value.indexOf("=");
2939
+ const rawOptionName = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
2940
+ const resolved = resolveLongOption({
2941
+ rawOptionName,
2942
+ optionSpecs
2943
+ });
2944
+ if (resolved === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: --${rawOptionName}` });
2945
+ if (resolved.optionSpec.kind !== "value") return index;
2946
+ if (separatorIndex >= 0) {
2947
+ if (value.slice(separatorIndex + 1).length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
2948
+ return index;
2949
+ }
2950
+ const nextToken = args[index + 1];
2951
+ if (typeof nextToken !== "string") throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
2952
+ ensureOptionValueToken({
2953
+ valueToken: nextToken,
2954
+ optionLabel: `--${rawOptionName}`,
2955
+ optionSpec: resolved.optionSpec
2956
+ });
2957
+ return index + 1;
2958
+ };
2959
+ const validateShortOptionToken = ({ args, index, token, optionSpecs }) => {
2960
+ const shortFlags = token.slice(1);
2961
+ for (let flagIndex = 0; flagIndex < shortFlags.length; flagIndex += 1) {
2962
+ const option = shortFlags[flagIndex];
2963
+ if (typeof option !== "string" || option.length === 0) continue;
2964
+ const optionSpec = optionSpecs.shortOptions.get(option);
2965
+ if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: -${option}` });
2966
+ if (optionSpec.kind !== "value") continue;
2967
+ if (flagIndex < shortFlags.length - 1) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
2968
+ const nextToken = args[index + 1];
2969
+ if (typeof nextToken !== "string") throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
2970
+ ensureOptionValueToken({
2971
+ valueToken: nextToken,
2972
+ optionLabel: `-${option}`,
2973
+ optionSpec
2974
+ });
2975
+ return index + 1;
2976
+ }
2977
+ return index;
2978
+ };
1896
2979
  const validateRawOptions = (args, optionSpecs) => {
1897
2980
  for (let index = 0; index < args.length; index += 1) {
1898
2981
  const token = args[index];
@@ -1900,39 +2983,20 @@ const validateRawOptions = (args, optionSpecs) => {
1900
2983
  if (token === "--") break;
1901
2984
  if (!token.startsWith("-") || token === "-") continue;
1902
2985
  if (token.startsWith("--")) {
1903
- const value = token.slice(2);
1904
- if (value.length === 0) continue;
1905
- const separatorIndex = value.indexOf("=");
1906
- const rawOptionName = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
1907
- const directOptionSpec = optionSpecs.longOptions.get(rawOptionName);
1908
- const optionNameForNegation = rawOptionName.startsWith("no-") ? rawOptionName.slice(3) : rawOptionName;
1909
- const optionSpec = directOptionSpec ?? optionSpecs.longOptions.get(optionNameForNegation);
1910
- if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: --${rawOptionName}` });
1911
- if (optionSpec.kind === "value") if (separatorIndex >= 0) {
1912
- if (value.slice(separatorIndex + 1).length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
1913
- } else {
1914
- const nextToken = args[index + 1];
1915
- if (typeof nextToken !== "string" || nextToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
1916
- if (nextToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
1917
- index += 1;
1918
- }
2986
+ index = validateLongOptionToken({
2987
+ args,
2988
+ index,
2989
+ token,
2990
+ optionSpecs
2991
+ });
1919
2992
  continue;
1920
2993
  }
1921
- const shortFlags = token.slice(1);
1922
- for (let flagIndex = 0; flagIndex < shortFlags.length; flagIndex += 1) {
1923
- const option = shortFlags[flagIndex];
1924
- if (typeof option !== "string" || option.length === 0) continue;
1925
- const optionSpec = optionSpecs.shortOptions.get(option);
1926
- if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: -${option}` });
1927
- if (optionSpec.kind === "value") {
1928
- if (flagIndex < shortFlags.length - 1) break;
1929
- const nextToken = args[index + 1];
1930
- if (typeof nextToken !== "string" || nextToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
1931
- if (nextToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
1932
- index += 1;
1933
- break;
1934
- }
1935
- }
2994
+ index = validateShortOptionToken({
2995
+ args,
2996
+ index,
2997
+ token,
2998
+ optionSpecs
2999
+ });
1936
3000
  }
1937
3001
  };
1938
3002
  const getPositionals = (args) => {
@@ -1985,85 +3049,29 @@ const ensureArgumentCount = ({ command, args, min, max }) => {
1985
3049
  const ensureHasCommandAfterDoubleDash = ({ command, argsAfterDoubleDash }) => {
1986
3050
  if (argsAfterDoubleDash.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `${command} requires arguments after --` });
1987
3051
  };
1988
- const readGitConfigInt = async (repoRoot, key) => {
1989
- const result = await runGitCommand({
1990
- cwd: repoRoot,
1991
- args: [
1992
- "config",
1993
- "--get",
1994
- key
1995
- ],
1996
- reject: false
1997
- });
1998
- if (result.exitCode !== 0) return;
1999
- const parsed = Number.parseInt(result.stdout.trim(), 10);
2000
- return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
2001
- };
2002
- const readGitConfigBoolean = async (repoRoot, key) => {
2003
- const result = await runGitCommand({
2004
- cwd: repoRoot,
2005
- args: [
2006
- "config",
2007
- "--bool",
2008
- "--get",
2009
- key
2010
- ],
2011
- reject: false
2012
- });
2013
- if (result.exitCode !== 0) return;
2014
- const value = result.stdout.trim().toLowerCase();
2015
- if (value === "true" || value === "yes" || value === "on" || value === "1") return true;
2016
- if (value === "false" || value === "no" || value === "off" || value === "0") return false;
2017
- };
2018
- const resolveConfiguredBaseRemote = async (repoRoot) => {
2019
- const configured = await runGitCommand({
3052
+ const resolveBaseBranch = async ({ repoRoot, config }) => {
3053
+ if (typeof config.git.baseBranch === "string" && config.git.baseBranch.length > 0) return config.git.baseBranch;
3054
+ const remote = config.git.baseRemote;
3055
+ const resolved = await runGitCommand({
2020
3056
  cwd: repoRoot,
2021
3057
  args: [
2022
- "config",
2023
- "--get",
2024
- "vde-worktree.baseRemote"
2025
- ],
2026
- reject: false
2027
- });
2028
- if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
2029
- return "origin";
2030
- };
2031
- const resolveBaseBranch = async (repoRoot) => {
2032
- const configured = await runGitCommand({
2033
- cwd: repoRoot,
2034
- args: [
2035
- "config",
2036
- "--get",
2037
- "vde-worktree.baseBranch"
3058
+ "symbolic-ref",
3059
+ "--quiet",
3060
+ "--short",
3061
+ `refs/remotes/${remote}/HEAD`
2038
3062
  ],
2039
3063
  reject: false
2040
3064
  });
2041
- if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
2042
- const remotesToProbe = [
2043
- await resolveConfiguredBaseRemote(repoRoot),
2044
- "origin",
2045
- "upstream"
2046
- ].filter((value, index, arr) => {
2047
- return arr.indexOf(value) === index;
2048
- });
2049
- for (const remote of remotesToProbe) {
2050
- const resolved = await runGitCommand({
2051
- cwd: repoRoot,
2052
- args: [
2053
- "symbolic-ref",
2054
- "--quiet",
2055
- "--short",
2056
- `refs/remotes/${remote}/HEAD`
2057
- ],
2058
- reject: false
2059
- });
2060
- if (resolved.exitCode !== 0) continue;
3065
+ if (resolved.exitCode === 0) {
2061
3066
  const raw = resolved.stdout.trim();
2062
3067
  const prefix = `${remote}/`;
2063
3068
  if (raw.startsWith(prefix)) return raw.slice(prefix.length);
2064
3069
  }
2065
3070
  for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
2066
- throw createCliError("INVALID_ARGUMENT", { message: "Unable to resolve base branch. Configure vde-worktree.baseBranch." });
3071
+ throw createCliError("INVALID_ARGUMENT", {
3072
+ message: "Unable to resolve base branch from config.yml (baseRemote/HEAD -> main/master).",
3073
+ details: { remote }
3074
+ });
2067
3075
  };
2068
3076
  const ensureTargetPathWritable = async (targetPath) => {
2069
3077
  try {
@@ -2256,7 +3264,7 @@ const validateDeleteSafety = ({ target, forceFlags }) => {
2256
3264
  const resolveLinkTargetPath = ({ sourcePath, destinationPath }) => {
2257
3265
  return relative(dirname(destinationPath), sourcePath);
2258
3266
  };
2259
- const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
3267
+ const resolveFileCopyTargets = ({ repoRoot, targetWorktreeRoot, relativePath }) => {
2260
3268
  const sourcePath = resolveRepoRelativePath({
2261
3269
  repoRoot,
2262
3270
  relativePath
@@ -2264,13 +3272,32 @@ const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
2264
3272
  const relativeFromRoot = relative(repoRoot, sourcePath);
2265
3273
  return {
2266
3274
  sourcePath,
2267
- destinationPath: ensurePathInsideRepo({
2268
- repoRoot,
2269
- path: resolve(worktreePath, relativeFromRoot)
3275
+ destinationPath: ensurePathInsideRoot({
3276
+ rootPath: targetWorktreeRoot,
3277
+ path: resolve(targetWorktreeRoot, relativeFromRoot),
3278
+ message: "Path is outside target worktree root"
2270
3279
  }),
2271
3280
  relativeFromRoot
2272
3281
  };
2273
3282
  };
3283
+ const resolveTargetWorktreeRootForCopyLink = ({ repoContext, snapshot }) => {
3284
+ const rawTarget = process.env.WT_WORKTREE_PATH ?? repoContext.currentWorktreeRoot;
3285
+ const resolvedTarget = resolvePathFromCwd({
3286
+ cwd: repoContext.currentWorktreeRoot,
3287
+ path: rawTarget
3288
+ });
3289
+ const matched = snapshot.worktrees.filter((worktree) => {
3290
+ return worktree.path === resolvedTarget || resolvedTarget.startsWith(`${worktree.path}${sep}`);
3291
+ }).sort((a, b) => b.path.length - a.path.length)[0];
3292
+ if (matched === void 0) throw createCliError("WORKTREE_NOT_FOUND", {
3293
+ message: "copy/link target worktree not found",
3294
+ details: {
3295
+ rawTarget,
3296
+ resolvedTarget
3297
+ }
3298
+ });
3299
+ return matched.path;
3300
+ };
2274
3301
  const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
2275
3302
  if (branch !== baseBranch) return;
2276
3303
  throw createCliError("INVALID_ARGUMENT", {
@@ -2281,12 +3308,14 @@ const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
2281
3308
  }
2282
3309
  });
2283
3310
  };
2284
- const toManagedWorktreeName = ({ repoRoot, worktreePath }) => {
2285
- const relativePath = relative(getWorktreeRootPath(repoRoot), worktreePath);
2286
- if (relativePath.length === 0 || relativePath === "." || relativePath === ".." || relativePath.startsWith(`..${sep}`)) return null;
2287
- return relativePath.split(sep).join("/");
3311
+ const toManagedWorktreeName = ({ managedWorktreeRoot, worktreePath }) => {
3312
+ if (isManagedWorktreePath({
3313
+ worktreePath,
3314
+ managedWorktreeRoot
3315
+ }) !== true) return null;
3316
+ return relative(managedWorktreeRoot, worktreePath).split(sep).join("/");
2288
3317
  };
2289
- const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName }) => {
3318
+ const resolveManagedWorktreePathFromName = ({ managedWorktreeRoot, optionName, worktreeName }) => {
2290
3319
  const normalized = worktreeName.trim();
2291
3320
  if (normalized.length === 0) throw createCliError("INVALID_ARGUMENT", {
2292
3321
  message: `${optionName} requires non-empty worktree name`,
@@ -2295,18 +3324,10 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2295
3324
  worktreeName
2296
3325
  }
2297
3326
  });
2298
- if (normalized === ".worktree" || normalized.startsWith(".worktree/") || normalized.startsWith(".worktree\\")) throw createCliError("INVALID_ARGUMENT", {
2299
- message: `${optionName} expects vw-managed worktree name (without .worktree/ prefix)`,
2300
- details: {
2301
- optionName,
2302
- worktreeName
2303
- }
2304
- });
2305
- const worktreeRoot = getWorktreeRootPath(repoRoot);
2306
3327
  let resolvedPath;
2307
3328
  try {
2308
3329
  resolvedPath = resolveRepoRelativePath({
2309
- repoRoot: worktreeRoot,
3330
+ repoRoot: managedWorktreeRoot,
2310
3331
  relativePath: normalized
2311
3332
  });
2312
3333
  } catch (error) {
@@ -2319,7 +3340,7 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2319
3340
  cause: error
2320
3341
  });
2321
3342
  }
2322
- if (resolvedPath === worktreeRoot) throw createCliError("INVALID_ARGUMENT", {
3343
+ if (resolvedPath === managedWorktreeRoot) throw createCliError("INVALID_ARGUMENT", {
2323
3344
  message: `${optionName} expects vw-managed worktree name`,
2324
3345
  details: {
2325
3346
  optionName,
@@ -2328,16 +3349,16 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2328
3349
  });
2329
3350
  return resolvedPath;
2330
3351
  };
2331
- const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, branch, worktrees, optionName, worktreeName, role }) => {
3352
+ const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, managedWorktreeRoot, branch, worktrees, optionName, worktreeName, role }) => {
2332
3353
  const managedCandidates = worktrees.filter((worktree) => {
2333
3354
  return worktree.branch === branch && worktree.path !== repoRoot && toManagedWorktreeName({
2334
- repoRoot,
3355
+ managedWorktreeRoot,
2335
3356
  worktreePath: worktree.path
2336
3357
  }) !== null;
2337
3358
  });
2338
3359
  if (typeof worktreeName === "string") {
2339
3360
  const resolvedPath = resolveManagedWorktreePathFromName({
2340
- repoRoot,
3361
+ managedWorktreeRoot,
2341
3362
  optionName,
2342
3363
  worktreeName
2343
3364
  });
@@ -2368,7 +3389,7 @@ const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, branch, worktrees,
2368
3389
  optionName,
2369
3390
  candidates: managedCandidates.map((worktree) => {
2370
3391
  return toManagedWorktreeName({
2371
- repoRoot,
3392
+ managedWorktreeRoot,
2372
3393
  worktreePath: worktree.path
2373
3394
  }) ?? worktree.path;
2374
3395
  })
@@ -2525,19 +3546,24 @@ const resolveListColumnContentWidth = ({ rows, columnIndex }) => {
2525
3546
  return Math.max(width, stringWidth(cell));
2526
3547
  }, 0);
2527
3548
  };
2528
- const resolveListPathColumnWidth = ({ rows, disablePathTruncation }) => {
2529
- if (disablePathTruncation) return null;
3549
+ const resolveListPathColumnWidth = ({ rows, columns, truncateMode, fullPath, minWidth }) => {
3550
+ const pathColumnIndex = columns.indexOf("path");
3551
+ if (pathColumnIndex < 0) return null;
3552
+ if (fullPath || truncateMode === "never") return null;
2530
3553
  if (process.stdout.isTTY !== true) return null;
2531
3554
  const terminalColumns = process.stdout.columns;
2532
3555
  if (typeof terminalColumns !== "number" || Number.isFinite(terminalColumns) !== true || terminalColumns <= 0) return null;
2533
- const measuredNonPathWidth = Array.from({ length: LIST_TABLE_PATH_COLUMN_INDEX }).map((_, index) => resolveListColumnContentWidth({
2534
- rows,
2535
- columnIndex: index
2536
- })).reduce((sum, width) => sum + width, 0);
2537
- const borderWidth = LIST_TABLE_COLUMN_COUNT + 1;
2538
- const paddingWidth = LIST_TABLE_COLUMN_COUNT * LIST_TABLE_CELL_HORIZONTAL_PADDING;
3556
+ const measuredNonPathWidth = columns.map((_, index) => {
3557
+ if (index === pathColumnIndex) return 0;
3558
+ return resolveListColumnContentWidth({
3559
+ rows,
3560
+ columnIndex: index
3561
+ });
3562
+ }).reduce((sum, width) => sum + width, 0);
3563
+ const borderWidth = columns.length + 1;
3564
+ const paddingWidth = columns.length * LIST_TABLE_CELL_HORIZONTAL_PADDING;
2539
3565
  const availablePathWidth = Math.floor(terminalColumns) - borderWidth - paddingWidth - measuredNonPathWidth;
2540
- return Math.max(LIST_TABLE_PATH_MIN_WIDTH, availablePathWidth);
3566
+ return Math.max(minWidth, availablePathWidth);
2541
3567
  };
2542
3568
  const resolveAheadBehindAgainstBaseBranch = async ({ repoRoot, baseBranch, worktree }) => {
2543
3569
  if (baseBranch === null) return {
@@ -2878,7 +3904,7 @@ const createCli = (options = {}) => {
2878
3904
  from: {
2879
3905
  type: "string",
2880
3906
  valueHint: "value",
2881
- description: "For extract: filesystem path. For absorb: managed worktree name without .worktree/ prefix."
3907
+ description: "For extract: filesystem path. For absorb: managed worktree name."
2882
3908
  },
2883
3909
  to: {
2884
3910
  type: "string",
@@ -2931,135 +3957,76 @@ const createCli = (options = {}) => {
2931
3957
  const parsedArgsRecord = parsedArgs;
2932
3958
  const positionals = getPositionals(parsedArgs);
2933
3959
  command = positionals[0] ?? "unknown";
3960
+ const commandArgs = positionals.slice(1);
2934
3961
  jsonEnabled = parsedArgs.json === true;
2935
- if (parsedArgs.help === true) {
2936
- const commandHelpTarget = typeof command === "string" && command !== "unknown" && command !== "help" ? command : null;
2937
- if (commandHelpTarget !== null) {
2938
- const entry = findCommandHelp(commandHelpTarget);
2939
- if (entry !== void 0) {
2940
- stdout(`${renderCommandHelpText({ entry })}\n`);
2941
- return EXIT_CODE.OK;
2942
- }
2943
- }
2944
- stdout(`${renderGeneralHelpText({ version })}\n`);
2945
- return EXIT_CODE.OK;
2946
- }
2947
- if (parsedArgs.version === true) {
2948
- stdout(version);
2949
- return EXIT_CODE.OK;
2950
- }
3962
+ const readOnlyDispatch = await dispatchReadOnlyCommands({
3963
+ command,
3964
+ commandArgs,
3965
+ positionals,
3966
+ parsedArgs: parsedArgsRecord,
3967
+ jsonEnabled,
3968
+ version,
3969
+ availableCommandNames: commandHelpNames,
3970
+ stdout,
3971
+ findCommandHelp,
3972
+ renderGeneralHelpText,
3973
+ renderCommandHelpText,
3974
+ ensureArgumentCount,
3975
+ resolveCompletionShell,
3976
+ loadCompletionScript,
3977
+ resolveCompletionInstallPath,
3978
+ installCompletionScript,
3979
+ readStringOption,
3980
+ buildJsonSuccess
3981
+ });
3982
+ if (readOnlyDispatch.handled) return readOnlyDispatch.exitCode;
2951
3983
  logger = parsedArgs.verbose === true ? createLogger({ level: LogLevel.INFO }) : createLogger();
2952
- if (positionals.length === 0) {
2953
- stdout(`${renderGeneralHelpText({ version })}\n`);
2954
- return EXIT_CODE.OK;
2955
- }
2956
- if (command === "help") {
2957
- const helpTarget = positionals[1];
2958
- if (typeof helpTarget !== "string" || helpTarget.length === 0) {
2959
- stdout(`${renderGeneralHelpText({ version })}\n`);
2960
- return EXIT_CODE.OK;
2961
- }
2962
- const entry = findCommandHelp(helpTarget);
2963
- if (entry === void 0) throw createCliError("INVALID_ARGUMENT", {
2964
- message: `Unknown command for help: ${helpTarget}`,
2965
- details: {
2966
- requested: helpTarget,
2967
- availableCommands: commandHelpEntries.map((item) => item.name)
2968
- }
2969
- });
2970
- stdout(`${renderCommandHelpText({ entry })}\n`);
2971
- return EXIT_CODE.OK;
2972
- }
2973
- const commandArgs = positionals.slice(1);
2974
- if (command === "completion") {
2975
- ensureArgumentCount({
2976
- command,
2977
- args: commandArgs,
2978
- min: 1,
2979
- max: 1
2980
- });
2981
- const shell = resolveCompletionShell(commandArgs[0]);
2982
- const script = await loadCompletionScript(shell);
2983
- if (parsedArgs.install === true) {
2984
- const destinationPath = resolveCompletionInstallPath({
2985
- shell,
2986
- requestedPath: readStringOption(parsedArgsRecord, "path")
2987
- });
2988
- await installCompletionScript({
2989
- content: script,
2990
- destinationPath
2991
- });
2992
- if (jsonEnabled) {
2993
- stdout(JSON.stringify(buildJsonSuccess({
2994
- command,
2995
- status: "ok",
2996
- repoRoot: null,
2997
- details: {
2998
- shell,
2999
- installed: true,
3000
- path: destinationPath
3001
- }
3002
- })));
3003
- return EXIT_CODE.OK;
3004
- }
3005
- stdout(`installed completion: ${destinationPath}`);
3006
- if (shell === "zsh") stdout("zsh note: ensure completion path is in fpath, then run: autoload -Uz compinit && compinit");
3007
- return EXIT_CODE.OK;
3008
- }
3009
- if (jsonEnabled) {
3010
- stdout(JSON.stringify(buildJsonSuccess({
3011
- command,
3012
- status: "ok",
3013
- repoRoot: null,
3014
- details: {
3015
- shell,
3016
- installed: false,
3017
- script
3018
- }
3019
- })));
3020
- return EXIT_CODE.OK;
3021
- }
3022
- stdout(script);
3023
- return EXIT_CODE.OK;
3024
- }
3025
3984
  const allowUnsafe = parsedArgs.allowUnsafe === true;
3026
3985
  if (parsedArgs.hooks === false && allowUnsafe !== true) throw createCliError("UNSAFE_FLAG_REQUIRED", { message: "UNSAFE_FLAG_REQUIRED: --no-hooks requires --allow-unsafe" });
3027
3986
  const repoContext = await resolveRepoContext(runtimeCwd);
3028
3987
  const repoRoot = repoContext.repoRoot;
3029
3988
  repoRootForJson = repoRoot;
3030
- const configuredHookTimeoutMs = await readGitConfigInt(repoRoot, "vde-worktree.hookTimeoutMs");
3031
- const configuredLockTimeoutMs = await readGitConfigInt(repoRoot, "vde-worktree.lockTimeoutMs");
3032
- const configuredStaleTTL = await readGitConfigInt(repoRoot, "vde-worktree.staleLockTTLSeconds");
3033
- const configuredHooksEnabled = await readGitConfigBoolean(repoRoot, "vde-worktree.hooksEnabled");
3989
+ const { config: resolvedConfig } = await loadResolvedConfig({
3990
+ cwd: runtimeCwd,
3991
+ repoRoot
3992
+ });
3993
+ const managedWorktreeRoot = getWorktreeRootPath(repoRoot, resolvedConfig.paths.worktreeRoot);
3034
3994
  const runtime = {
3035
3995
  command,
3036
3996
  json: jsonEnabled,
3037
- hooksEnabled: parsedArgs.hooks !== false && configuredHooksEnabled !== false,
3038
- ghEnabled: parsedArgs.gh !== false,
3997
+ hooksEnabled: parsedArgs.hooks !== false && resolvedConfig.hooks.enabled,
3998
+ ghEnabled: parsedArgs.gh !== false && resolvedConfig.github.enabled,
3039
3999
  strictPostHooks: parsedArgs.strictPostHooks === true,
3040
4000
  hookTimeoutMs: readNumberFromEnvOrDefault({
3041
4001
  rawValue: toNumberOption({
3042
4002
  value: parsedArgs.hookTimeoutMs,
3043
4003
  optionName: "--hook-timeout-ms"
3044
- }) ?? configuredHookTimeoutMs,
4004
+ }) ?? resolvedConfig.hooks.timeoutMs,
3045
4005
  defaultValue: DEFAULT_HOOK_TIMEOUT_MS
3046
4006
  }),
3047
4007
  lockTimeoutMs: readNumberFromEnvOrDefault({
3048
4008
  rawValue: toNumberOption({
3049
4009
  value: parsedArgs.lockTimeoutMs,
3050
4010
  optionName: "--lock-timeout-ms"
3051
- }) ?? configuredLockTimeoutMs,
4011
+ }) ?? resolvedConfig.locks.timeoutMs,
3052
4012
  defaultValue: DEFAULT_LOCK_TIMEOUT_MS
3053
4013
  }),
3054
4014
  allowUnsafe,
3055
4015
  isInteractive: isInteractiveFn()
3056
4016
  };
3057
4017
  const staleLockTTLSeconds = readNumberFromEnvOrDefault({
3058
- rawValue: configuredStaleTTL,
4018
+ rawValue: resolvedConfig.locks.staleLockTTLSeconds,
3059
4019
  defaultValue: DEFAULT_STALE_LOCK_TTL_SECONDS
3060
4020
  });
3061
4021
  const collectWorktreeSnapshot$1 = async (_ignoredRepoRoot) => {
3062
- return collectWorktreeSnapshot(repoRoot, { noGh: runtime.ghEnabled !== true });
4022
+ return collectWorktreeSnapshot(repoRoot, {
4023
+ baseBranch: await resolveBaseBranch({
4024
+ repoRoot,
4025
+ config: resolvedConfig
4026
+ }),
4027
+ ghEnabled: runtime.ghEnabled,
4028
+ noGh: runtime.ghEnabled !== true
4029
+ });
3063
4030
  };
3064
4031
  const runWriteOperation = async (task) => {
3065
4032
  if (WRITE_COMMANDS.has(command) !== true) return task();
@@ -3071,7 +4038,30 @@ const createCli = (options = {}) => {
3071
4038
  staleLockTTLSeconds
3072
4039
  }, task);
3073
4040
  };
3074
- if (command === "init") {
4041
+ const executeWorktreeMutation = async ({ name, branch, worktreePath, extraEnv, precheck, runGit, finalize }) => {
4042
+ const precheckResult = await precheck();
4043
+ const hookContext = createHookContext({
4044
+ runtime,
4045
+ repoRoot,
4046
+ action: name,
4047
+ branch,
4048
+ worktreePath,
4049
+ stderr,
4050
+ extraEnv
4051
+ });
4052
+ await runPreHook({
4053
+ name,
4054
+ context: hookContext
4055
+ });
4056
+ const result = await runGit(precheckResult);
4057
+ if (finalize !== void 0) await finalize(precheckResult, result);
4058
+ await runPostHook({
4059
+ name,
4060
+ context: hookContext
4061
+ });
4062
+ return result;
4063
+ };
4064
+ const handleInit = async () => {
3075
4065
  ensureArgumentCount({
3076
4066
  command,
3077
4067
  args: commandArgs,
@@ -3091,28 +4081,28 @@ const createCli = (options = {}) => {
3091
4081
  name: "init",
3092
4082
  context: hookContext
3093
4083
  });
3094
- const initialized = await initializeRepository(repoRoot);
4084
+ const initialized = await initializeRepository({
4085
+ repoRoot,
4086
+ managedWorktreeRoot
4087
+ });
3095
4088
  await runPostHook({
3096
4089
  name: "init",
3097
4090
  context: hookContext
3098
4091
  });
3099
4092
  return initialized;
3100
4093
  });
3101
- if (runtime.json) {
3102
- stdout(JSON.stringify(buildJsonSuccess({
3103
- command,
3104
- status: "ok",
3105
- repoRoot,
3106
- details: {
3107
- initialized: true,
3108
- alreadyInitialized: result.alreadyInitialized
3109
- }
3110
- })));
3111
- return EXIT_CODE.OK;
3112
- }
4094
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
4095
+ command,
4096
+ status: "ok",
4097
+ repoRoot,
4098
+ details: {
4099
+ initialized: true,
4100
+ alreadyInitialized: result.alreadyInitialized
4101
+ }
4102
+ })));
3113
4103
  return EXIT_CODE.OK;
3114
- }
3115
- if (command === "list") {
4104
+ };
4105
+ const handleList = async () => {
3116
4106
  ensureArgumentCount({
3117
4107
  command,
3118
4108
  args: commandArgs,
@@ -3127,22 +4117,15 @@ const createCli = (options = {}) => {
3127
4117
  repoRoot,
3128
4118
  details: {
3129
4119
  baseBranch: snapshot.baseBranch,
4120
+ managedWorktreeRoot,
3130
4121
  worktrees: snapshot.worktrees
3131
4122
  }
3132
4123
  })));
3133
4124
  return EXIT_CODE.OK;
3134
4125
  }
3135
4126
  const theme = createCatppuccinTheme({ enabled: shouldUseAnsiColors({ interactive: runtime.isInteractive }) });
3136
- const rows = [[
3137
- "branch",
3138
- "dirty",
3139
- "merged",
3140
- "pr",
3141
- "locked",
3142
- "ahead",
3143
- "behind",
3144
- "path"
3145
- ], ...await Promise.all(snapshot.worktrees.map(async (worktree) => {
4127
+ const columns = resolvedConfig.list.table.columns;
4128
+ const rows = [[...columns], ...await Promise.all(snapshot.worktrees.map(async (worktree) => {
3146
4129
  const distanceFromBase = await resolveAheadBehindAgainstBaseBranch({
3147
4130
  repoRoot,
3148
4131
  baseBranch: snapshot.baseBranch,
@@ -3154,39 +4137,45 @@ const createCli = (options = {}) => {
3154
4137
  prStatus: worktree.pr.status,
3155
4138
  isBaseBranch
3156
4139
  });
3157
- return [
3158
- `${worktree.path === repoContext.currentWorktreeRoot ? "*" : " "} ${worktree.branch ?? "(detached)"}`,
3159
- worktree.dirty ? "dirty" : "clean",
3160
- mergedState,
3161
- prState,
3162
- worktree.locked.value ? "locked" : "-",
3163
- formatListUpstreamCount(distanceFromBase.ahead),
3164
- formatListUpstreamCount(distanceFromBase.behind),
3165
- formatDisplayPath(worktree.path)
3166
- ];
4140
+ const valuesByColumn = {
4141
+ branch: `${worktree.path === repoContext.currentWorktreeRoot ? "*" : " "} ${worktree.branch ?? "(detached)"}`,
4142
+ dirty: worktree.dirty ? "dirty" : "clean",
4143
+ merged: mergedState,
4144
+ pr: prState,
4145
+ locked: worktree.locked.value ? "locked" : "-",
4146
+ ahead: formatListUpstreamCount(distanceFromBase.ahead),
4147
+ behind: formatListUpstreamCount(distanceFromBase.behind),
4148
+ path: formatDisplayPath(worktree.path)
4149
+ };
4150
+ return columns.map((column) => valuesByColumn[column]);
3167
4151
  }))];
3168
4152
  const pathColumnWidth = resolveListPathColumnWidth({
3169
4153
  rows,
3170
- disablePathTruncation: parsedArgs.fullPath === true
4154
+ columns,
4155
+ truncateMode: resolvedConfig.list.table.path.truncate,
4156
+ fullPath: parsedArgs.fullPath === true,
4157
+ minWidth: resolvedConfig.list.table.path.minWidth
3171
4158
  });
3172
- const columnsConfig = pathColumnWidth === null ? void 0 : { [LIST_TABLE_PATH_COLUMN_INDEX]: {
4159
+ const pathColumnIndex = columns.indexOf("path");
4160
+ const columnsConfig = pathColumnWidth === null || pathColumnIndex < 0 ? void 0 : { [pathColumnIndex]: {
3173
4161
  width: pathColumnWidth,
3174
4162
  truncate: pathColumnWidth
3175
4163
  } };
3176
- const colorized = colorizeListTable({
3177
- rendered: table(rows, {
3178
- border: getBorderCharacters("norc"),
3179
- drawHorizontalLine: (lineIndex, rowCount) => {
3180
- return lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount;
3181
- },
3182
- columns: columnsConfig
3183
- }),
3184
- theme
4164
+ const rendered = table(rows, {
4165
+ border: getBorderCharacters("norc"),
4166
+ drawHorizontalLine: (lineIndex, rowCount) => {
4167
+ return lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount;
4168
+ },
4169
+ columns: columnsConfig
3185
4170
  });
4171
+ const colorized = hasDefaultListColumnOrder(columns) ? colorizeListTable({
4172
+ rendered,
4173
+ theme
4174
+ }) : rendered.trimEnd();
3186
4175
  for (const line of colorized.split("\n")) stdout(line);
3187
4176
  return EXIT_CODE.OK;
3188
- }
3189
- if (command === "status") {
4177
+ };
4178
+ const handleStatus = async () => {
3190
4179
  ensureArgumentCount({
3191
4180
  command,
3192
4181
  args: commandArgs,
@@ -3216,8 +4205,8 @@ const createCli = (options = {}) => {
3216
4205
  stdout(`dirty: ${targetWorktree.dirty ? "true" : "false"}`);
3217
4206
  stdout(`locked: ${targetWorktree.locked.value ? "true" : "false"}`);
3218
4207
  return EXIT_CODE.OK;
3219
- }
3220
- if (command === "path") {
4208
+ };
4209
+ const handlePath = async () => {
3221
4210
  ensureArgumentCount({
3222
4211
  command,
3223
4212
  args: commandArgs,
@@ -3243,8 +4232,18 @@ const createCli = (options = {}) => {
3243
4232
  }
3244
4233
  stdout(target.path);
3245
4234
  return EXIT_CODE.OK;
3246
- }
3247
- if (command === "new") {
4235
+ };
4236
+ const earlyRepoExitCode = await dispatchCommandHandler({
4237
+ command,
4238
+ handlers: createEarlyRepoCommandHandlers({
4239
+ initHandler: handleInit,
4240
+ listHandler: handleList,
4241
+ statusHandler: handleStatus,
4242
+ pathHandler: handlePath
4243
+ })
4244
+ });
4245
+ if (earlyRepoExitCode !== void 0) return earlyRepoExitCode;
4246
+ const handleNew = async () => {
3248
4247
  ensureArgumentCount({
3249
4248
  command,
3250
4249
  args: commandArgs,
@@ -3252,58 +4251,56 @@ const createCli = (options = {}) => {
3252
4251
  max: 1
3253
4252
  });
3254
4253
  const branch = commandArgs[0] ?? randomWipBranchName();
4254
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3255
4255
  const result = await runWriteOperation(async () => {
3256
- if (containsBranch({
3257
- branch,
3258
- worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees
3259
- })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
3260
- message: `Branch is already attached to a worktree: ${branch}`,
3261
- details: { branch }
3262
- });
3263
- if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
3264
- message: `Branch already exists locally: ${branch}`,
3265
- details: { branch }
3266
- });
3267
- const targetPath = branchToWorktreePath(repoRoot, branch);
3268
- await ensureTargetPathWritable(targetPath);
3269
- const baseBranch = await resolveBaseBranch(repoRoot);
3270
- const hookContext = createHookContext({
3271
- runtime,
3272
- repoRoot,
3273
- action: "new",
3274
- branch,
3275
- worktreePath: targetPath,
3276
- stderr
3277
- });
3278
- await runPreHook({
4256
+ return executeWorktreeMutation({
3279
4257
  name: "new",
3280
- context: hookContext
3281
- });
3282
- await runGitCommand({
3283
- cwd: repoRoot,
3284
- args: [
3285
- "worktree",
3286
- "add",
3287
- "-b",
3288
- branch,
3289
- targetPath,
3290
- baseBranch
3291
- ]
3292
- });
3293
- await upsertWorktreeMergeLifecycle({
3294
- repoRoot,
3295
4258
  branch,
3296
- baseBranch,
3297
- observedDivergedHead: null
3298
- });
3299
- await runPostHook({
3300
- name: "new",
3301
- context: hookContext
4259
+ worktreePath: targetPath,
4260
+ precheck: async () => {
4261
+ if (containsBranch({
4262
+ branch,
4263
+ worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees
4264
+ })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
4265
+ message: `Branch is already attached to a worktree: ${branch}`,
4266
+ details: { branch }
4267
+ });
4268
+ if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
4269
+ message: `Branch already exists locally: ${branch}`,
4270
+ details: { branch }
4271
+ });
4272
+ await ensureTargetPathWritable(targetPath);
4273
+ return { baseBranch: await resolveBaseBranch({
4274
+ repoRoot,
4275
+ config: resolvedConfig
4276
+ }) };
4277
+ },
4278
+ runGit: async ({ baseBranch }) => {
4279
+ await runGitCommand({
4280
+ cwd: repoRoot,
4281
+ args: [
4282
+ "worktree",
4283
+ "add",
4284
+ "-b",
4285
+ branch,
4286
+ targetPath,
4287
+ baseBranch
4288
+ ]
4289
+ });
4290
+ return {
4291
+ branch,
4292
+ path: targetPath
4293
+ };
4294
+ },
4295
+ finalize: async ({ baseBranch }) => {
4296
+ await upsertWorktreeMergeLifecycle({
4297
+ repoRoot,
4298
+ branch,
4299
+ baseBranch,
4300
+ observedDivergedHead: null
4301
+ });
4302
+ }
3302
4303
  });
3303
- return {
3304
- branch,
3305
- path: targetPath
3306
- };
3307
4304
  });
3308
4305
  if (runtime.json) {
3309
4306
  stdout(JSON.stringify(buildJsonSuccess({
@@ -3316,8 +4313,8 @@ const createCli = (options = {}) => {
3316
4313
  }
3317
4314
  stdout(result.path);
3318
4315
  return EXIT_CODE.OK;
3319
- }
3320
- if (command === "switch") {
4316
+ };
4317
+ const handleSwitch = async () => {
3321
4318
  ensureArgumentCount({
3322
4319
  command,
3323
4320
  args: commandArgs,
@@ -3327,74 +4324,72 @@ const createCli = (options = {}) => {
3327
4324
  const branch = commandArgs[0];
3328
4325
  const result = await runWriteOperation(async () => {
3329
4326
  const snapshot = await collectWorktreeSnapshot$1(repoRoot);
3330
- const existing = snapshot.worktrees.find((worktree) => worktree.branch === branch);
3331
- if (existing !== void 0) {
3332
- if (snapshot.baseBranch !== null) await upsertWorktreeMergeLifecycle({
3333
- repoRoot,
3334
- branch,
3335
- baseBranch: snapshot.baseBranch,
3336
- observedDivergedHead: null
3337
- });
3338
- return {
3339
- status: "existing",
3340
- branch,
3341
- path: existing.path
3342
- };
3343
- }
3344
- const targetPath = branchToWorktreePath(repoRoot, branch);
3345
- await ensureTargetPathWritable(targetPath);
3346
- const hookContext = createHookContext({
3347
- runtime,
3348
- repoRoot,
3349
- action: "switch",
3350
- branch,
3351
- worktreePath: targetPath,
3352
- stderr
3353
- });
3354
- await runPreHook({
3355
- name: "switch",
3356
- context: hookContext
3357
- });
3358
- let lifecycleBaseBranch = snapshot.baseBranch;
3359
- if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) await runGitCommand({
3360
- cwd: repoRoot,
3361
- args: [
3362
- "worktree",
3363
- "add",
3364
- targetPath,
3365
- branch
3366
- ]
3367
- });
3368
- else {
3369
- const baseBranch = await resolveBaseBranch(repoRoot);
3370
- lifecycleBaseBranch = baseBranch;
3371
- await runGitCommand({
3372
- cwd: repoRoot,
3373
- args: [
3374
- "worktree",
3375
- "add",
3376
- "-b",
3377
- branch,
3378
- targetPath,
3379
- baseBranch
3380
- ]
4327
+ const existing = snapshot.worktrees.find((worktree) => worktree.branch === branch);
4328
+ if (existing !== void 0) {
4329
+ if (snapshot.baseBranch !== null) await upsertWorktreeMergeLifecycle({
4330
+ repoRoot,
4331
+ branch,
4332
+ baseBranch: snapshot.baseBranch,
4333
+ observedDivergedHead: null
3381
4334
  });
4335
+ return {
4336
+ status: "existing",
4337
+ branch,
4338
+ path: existing.path
4339
+ };
3382
4340
  }
3383
- if (lifecycleBaseBranch !== null) await upsertWorktreeMergeLifecycle({
3384
- repoRoot,
3385
- branch,
3386
- baseBranch: lifecycleBaseBranch,
3387
- observedDivergedHead: null
3388
- });
3389
- await runPostHook({
4341
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
4342
+ return executeWorktreeMutation({
3390
4343
  name: "switch",
3391
- context: hookContext
3392
- });
3393
- return {
3394
- status: "created",
3395
4344
  branch,
3396
- path: targetPath
3397
- };
4345
+ worktreePath: targetPath,
4346
+ precheck: async () => {
4347
+ await ensureTargetPathWritable(targetPath);
4348
+ if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) return {
4349
+ gitArgs: [
4350
+ "worktree",
4351
+ "add",
4352
+ targetPath,
4353
+ branch
4354
+ ],
4355
+ lifecycleBaseBranch: snapshot.baseBranch
4356
+ };
4357
+ const baseBranch = await resolveBaseBranch({
4358
+ repoRoot,
4359
+ config: resolvedConfig
4360
+ });
4361
+ return {
4362
+ gitArgs: [
4363
+ "worktree",
4364
+ "add",
4365
+ "-b",
4366
+ branch,
4367
+ targetPath,
4368
+ baseBranch
4369
+ ],
4370
+ lifecycleBaseBranch: baseBranch
4371
+ };
4372
+ },
4373
+ runGit: async ({ gitArgs }) => {
4374
+ await runGitCommand({
4375
+ cwd: repoRoot,
4376
+ args: [...gitArgs]
4377
+ });
4378
+ return {
4379
+ status: "created",
4380
+ branch,
4381
+ path: targetPath
4382
+ };
4383
+ },
4384
+ finalize: async ({ lifecycleBaseBranch }) => {
4385
+ if (lifecycleBaseBranch !== null) await upsertWorktreeMergeLifecycle({
4386
+ repoRoot,
4387
+ branch,
4388
+ baseBranch: lifecycleBaseBranch,
4389
+ observedDivergedHead: null
4390
+ });
4391
+ }
4392
+ });
3398
4393
  });
3399
4394
  if (runtime.json) {
3400
4395
  stdout(JSON.stringify(buildJsonSuccess({
@@ -3410,8 +4405,16 @@ const createCli = (options = {}) => {
3410
4405
  }
3411
4406
  stdout(result.path);
3412
4407
  return EXIT_CODE.OK;
3413
- }
3414
- if (command === "mv") {
4408
+ };
4409
+ const writeCommandExitCode = await dispatchCommandHandler({
4410
+ command,
4411
+ handlers: createWriteCommandHandlers({
4412
+ newHandler: handleNew,
4413
+ switchHandler: handleSwitch
4414
+ })
4415
+ });
4416
+ if (writeCommandExitCode !== void 0) return writeCommandExitCode;
4417
+ const handleMv = async () => {
3415
4418
  ensureArgumentCount({
3416
4419
  command,
3417
4420
  args: commandArgs,
@@ -3438,68 +4441,68 @@ const createCli = (options = {}) => {
3438
4441
  branch: newBranch,
3439
4442
  path: current.path
3440
4443
  };
3441
- if (containsBranch({
3442
- branch: newBranch,
3443
- worktrees: snapshot.worktrees
3444
- })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
3445
- message: `Branch is already attached to another worktree: ${newBranch}`,
3446
- details: { branch: newBranch }
3447
- });
3448
- if (await doesGitRefExist(repoRoot, `refs/heads/${newBranch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
3449
- message: `Branch already exists locally: ${newBranch}`,
3450
- details: { branch: newBranch }
3451
- });
3452
- const newPath = branchToWorktreePath(repoRoot, newBranch);
3453
- await ensureTargetPathWritable(newPath);
3454
- const hookContext = createHookContext({
3455
- runtime,
3456
- repoRoot,
3457
- action: "mv",
4444
+ const newPath = branchToWorktreePath(repoRoot, newBranch, resolvedConfig.paths.worktreeRoot);
4445
+ return executeWorktreeMutation({
4446
+ name: "mv",
3458
4447
  branch: newBranch,
3459
4448
  worktreePath: newPath,
3460
- stderr,
3461
4449
  extraEnv: {
3462
4450
  WT_OLD_BRANCH: oldBranch,
3463
4451
  WT_NEW_BRANCH: newBranch
4452
+ },
4453
+ precheck: async () => {
4454
+ if (containsBranch({
4455
+ branch: newBranch,
4456
+ worktrees: snapshot.worktrees
4457
+ })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
4458
+ message: `Branch is already attached to another worktree: ${newBranch}`,
4459
+ details: { branch: newBranch }
4460
+ });
4461
+ if (await doesGitRefExist(repoRoot, `refs/heads/${newBranch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
4462
+ message: `Branch already exists locally: ${newBranch}`,
4463
+ details: { branch: newBranch }
4464
+ });
4465
+ await ensureTargetPathWritable(newPath);
4466
+ return {
4467
+ oldBranch,
4468
+ currentPath: current.path,
4469
+ baseBranch: snapshot.baseBranch
4470
+ };
4471
+ },
4472
+ runGit: async ({ oldBranch: resolvedOldBranch, currentPath }) => {
4473
+ await runGitCommand({
4474
+ cwd: currentPath,
4475
+ args: [
4476
+ "branch",
4477
+ "-m",
4478
+ resolvedOldBranch,
4479
+ newBranch
4480
+ ]
4481
+ });
4482
+ await runGitCommand({
4483
+ cwd: repoRoot,
4484
+ args: [
4485
+ "worktree",
4486
+ "move",
4487
+ currentPath,
4488
+ newPath
4489
+ ]
4490
+ });
4491
+ return {
4492
+ branch: newBranch,
4493
+ path: newPath
4494
+ };
4495
+ },
4496
+ finalize: async ({ oldBranch: resolvedOldBranch, baseBranch }) => {
4497
+ if (baseBranch !== null) await moveWorktreeMergeLifecycle({
4498
+ repoRoot,
4499
+ fromBranch: resolvedOldBranch,
4500
+ toBranch: newBranch,
4501
+ baseBranch,
4502
+ observedDivergedHead: null
4503
+ });
3464
4504
  }
3465
4505
  });
3466
- await runPreHook({
3467
- name: "mv",
3468
- context: hookContext
3469
- });
3470
- await runGitCommand({
3471
- cwd: current.path,
3472
- args: [
3473
- "branch",
3474
- "-m",
3475
- oldBranch,
3476
- newBranch
3477
- ]
3478
- });
3479
- await runGitCommand({
3480
- cwd: repoRoot,
3481
- args: [
3482
- "worktree",
3483
- "move",
3484
- current.path,
3485
- newPath
3486
- ]
3487
- });
3488
- if (snapshot.baseBranch !== null) await moveWorktreeMergeLifecycle({
3489
- repoRoot,
3490
- fromBranch: oldBranch,
3491
- toBranch: newBranch,
3492
- baseBranch: snapshot.baseBranch,
3493
- observedDivergedHead: null
3494
- });
3495
- await runPostHook({
3496
- name: "mv",
3497
- context: hookContext
3498
- });
3499
- return {
3500
- branch: newBranch,
3501
- path: newPath
3502
- };
3503
4506
  });
3504
4507
  if (runtime.json) {
3505
4508
  stdout(JSON.stringify(buildJsonSuccess({
@@ -3512,8 +4515,8 @@ const createCli = (options = {}) => {
3512
4515
  }
3513
4516
  stdout(result.path);
3514
4517
  return EXIT_CODE.OK;
3515
- }
3516
- if (command === "del") {
4518
+ };
4519
+ const handleDel = async () => {
3517
4520
  ensureArgumentCount({
3518
4521
  command,
3519
4522
  args: commandArgs,
@@ -3543,56 +4546,69 @@ const createCli = (options = {}) => {
3543
4546
  message: "Cannot delete the primary worktree",
3544
4547
  details: { path: target.path }
3545
4548
  });
3546
- validateDeleteSafety({
3547
- target,
3548
- forceFlags
3549
- });
3550
- const hookContext = createHookContext({
3551
- runtime,
3552
- repoRoot,
3553
- action: "del",
3554
- branch: target.branch,
4549
+ if (isManagedWorktreePath({
3555
4550
  worktreePath: target.path,
3556
- stderr
3557
- });
3558
- await runPreHook({
3559
- name: "del",
3560
- context: hookContext
3561
- });
3562
- const removeArgs = [
3563
- "worktree",
3564
- "remove",
3565
- target.path
3566
- ];
3567
- if (forceFlags.forceDirty) removeArgs.push("--force");
3568
- await runGitCommand({
3569
- cwd: repoRoot,
3570
- args: removeArgs
3571
- });
3572
- await runGitCommand({
3573
- cwd: repoRoot,
3574
- args: [
3575
- "branch",
3576
- resolveBranchDeleteMode(forceFlags),
3577
- target.branch
3578
- ]
3579
- });
3580
- await deleteWorktreeLock({
3581
- repoRoot,
3582
- branch: target.branch
3583
- });
3584
- await deleteWorktreeMergeLifecycle({
3585
- repoRoot,
3586
- branch: target.branch
4551
+ managedWorktreeRoot
4552
+ }) !== true) throw createCliError("WORKTREE_NOT_FOUND", {
4553
+ message: "Target branch is not in managed worktree root",
4554
+ details: {
4555
+ branch: target.branch,
4556
+ path: target.path,
4557
+ managedWorktreeRoot
4558
+ }
3587
4559
  });
3588
- await runPostHook({
4560
+ const targetBranch = target.branch;
4561
+ return executeWorktreeMutation({
3589
4562
  name: "del",
3590
- context: hookContext
4563
+ branch: targetBranch,
4564
+ worktreePath: target.path,
4565
+ precheck: async () => {
4566
+ validateDeleteSafety({
4567
+ target,
4568
+ forceFlags
4569
+ });
4570
+ const removeArgs = [
4571
+ "worktree",
4572
+ "remove",
4573
+ target.path
4574
+ ];
4575
+ if (forceFlags.forceDirty) removeArgs.push("--force");
4576
+ return {
4577
+ branch: targetBranch,
4578
+ path: target.path,
4579
+ removeArgs,
4580
+ branchDeleteMode: resolveBranchDeleteMode(forceFlags)
4581
+ };
4582
+ },
4583
+ runGit: async ({ branch: targetBranch, removeArgs, branchDeleteMode, path }) => {
4584
+ await runGitCommand({
4585
+ cwd: repoRoot,
4586
+ args: removeArgs
4587
+ });
4588
+ await runGitCommand({
4589
+ cwd: repoRoot,
4590
+ args: [
4591
+ "branch",
4592
+ branchDeleteMode,
4593
+ targetBranch
4594
+ ]
4595
+ });
4596
+ return {
4597
+ branch: targetBranch,
4598
+ path
4599
+ };
4600
+ },
4601
+ finalize: async ({ branch: targetBranch }) => {
4602
+ await deleteWorktreeLock({
4603
+ repoRoot,
4604
+ branch: targetBranch
4605
+ });
4606
+ await deleteWorktreeMergeLifecycle({
4607
+ repoRoot,
4608
+ branch: targetBranch
4609
+ });
4610
+ }
3591
4611
  });
3592
- return {
3593
- branch: target.branch,
3594
- path: target.path
3595
- };
3596
4612
  });
3597
4613
  if (runtime.json) {
3598
4614
  stdout(JSON.stringify(buildJsonSuccess({
@@ -3605,8 +4621,16 @@ const createCli = (options = {}) => {
3605
4621
  }
3606
4622
  stdout(result.path);
3607
4623
  return EXIT_CODE.OK;
3608
- }
3609
- if (command === "gone") {
4624
+ };
4625
+ const writeMutationExitCode = await dispatchCommandHandler({
4626
+ command,
4627
+ handlers: createWriteMutationHandlers({
4628
+ mvHandler: handleMv,
4629
+ delHandler: handleDel
4630
+ })
4631
+ });
4632
+ if (writeMutationExitCode !== void 0) return writeMutationExitCode;
4633
+ const handleGone = async () => {
3610
4634
  ensureArgumentCount({
3611
4635
  command,
3612
4636
  args: commandArgs,
@@ -3616,7 +4640,10 @@ const createCli = (options = {}) => {
3616
4640
  if (parsedArgs.apply === true && parsedArgs.dryRun === true) throw createCliError("INVALID_ARGUMENT", { message: "Cannot use --apply and --dry-run together" });
3617
4641
  const dryRun = parsedArgs.apply !== true;
3618
4642
  const execute = async () => {
3619
- const candidates = (await collectWorktreeSnapshot$1(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).map((worktree) => worktree.branch);
4643
+ const candidates = (await collectWorktreeSnapshot$1(repoRoot)).worktrees.filter((worktree) => worktree.branch !== null).filter((worktree) => worktree.path !== repoRoot).filter((worktree) => isManagedWorktreePath({
4644
+ worktreePath: worktree.path,
4645
+ managedWorktreeRoot
4646
+ })).filter((worktree) => worktree.dirty === false).filter((worktree) => worktree.locked.value === false).filter((worktree) => worktree.merged.overall === true).map((worktree) => worktree.branch);
3620
4647
  if (dryRun) return {
3621
4648
  deleted: [],
3622
4649
  candidates,
@@ -3693,8 +4720,8 @@ const createCli = (options = {}) => {
3693
4720
  const branches = result.dryRun ? result.candidates : result.deleted;
3694
4721
  for (const branch of branches) stdout(`${label}: ${branch}`);
3695
4722
  return EXIT_CODE.OK;
3696
- }
3697
- if (command === "get") {
4723
+ };
4724
+ const handleGet = async () => {
3698
4725
  ensureArgumentCount({
3699
4726
  command,
3700
4727
  args: commandArgs,
@@ -3721,7 +4748,7 @@ const createCli = (options = {}) => {
3721
4748
  repoRoot,
3722
4749
  action: "get",
3723
4750
  branch,
3724
- worktreePath: branchToWorktreePath(repoRoot, branch),
4751
+ worktreePath: branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot),
3725
4752
  stderr
3726
4753
  });
3727
4754
  await runPreHook({
@@ -3772,7 +4799,7 @@ const createCli = (options = {}) => {
3772
4799
  path: existing.path
3773
4800
  };
3774
4801
  }
3775
- const targetPath = branchToWorktreePath(repoRoot, branch);
4802
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3776
4803
  await ensureTargetPathWritable(targetPath);
3777
4804
  await runGitCommand({
3778
4805
  cwd: repoRoot,
@@ -3813,8 +4840,8 @@ const createCli = (options = {}) => {
3813
4840
  }
3814
4841
  stdout(result.path);
3815
4842
  return EXIT_CODE.OK;
3816
- }
3817
- if (command === "extract") {
4843
+ };
4844
+ const handleExtract = async () => {
3818
4845
  ensureArgumentCount({
3819
4846
  command,
3820
4847
  args: commandArgs,
@@ -3846,12 +4873,15 @@ const createCli = (options = {}) => {
3846
4873
  details: { path: sourceWorktree.path }
3847
4874
  });
3848
4875
  const branch = sourceWorktree.branch;
3849
- const baseBranch = await resolveBaseBranch(repoRoot);
4876
+ const baseBranch = await resolveBaseBranch({
4877
+ repoRoot,
4878
+ config: resolvedConfig
4879
+ });
3850
4880
  ensureBranchIsNotPrimary({
3851
4881
  branch,
3852
4882
  baseBranch
3853
4883
  });
3854
- const targetPath = branchToWorktreePath(repoRoot, branch);
4884
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3855
4885
  await ensureTargetPathWritable(targetPath);
3856
4886
  const dirty = (await runGitCommand({
3857
4887
  cwd: repoRoot,
@@ -3943,8 +4973,17 @@ const createCli = (options = {}) => {
3943
4973
  }
3944
4974
  stdout(result.path);
3945
4975
  return EXIT_CODE.OK;
3946
- }
3947
- if (command === "absorb") {
4976
+ };
4977
+ const worktreeActionExitCode = await dispatchCommandHandler({
4978
+ command,
4979
+ handlers: createWorktreeActionHandlers({
4980
+ goneHandler: handleGone,
4981
+ getHandler: handleGet,
4982
+ extractHandler: handleExtract
4983
+ })
4984
+ });
4985
+ if (worktreeActionExitCode !== void 0) return worktreeActionExitCode;
4986
+ const handleAbsorb = async () => {
3948
4987
  ensureArgumentCount({
3949
4988
  command,
3950
4989
  args: commandArgs,
@@ -3972,6 +5011,7 @@ const createCli = (options = {}) => {
3972
5011
  });
3973
5012
  const sourceWorktree = resolveManagedNonPrimaryWorktreeByBranch({
3974
5013
  repoRoot,
5014
+ managedWorktreeRoot,
3975
5015
  branch,
3976
5016
  worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees,
3977
5017
  optionName: "--from",
@@ -4065,8 +5105,8 @@ const createCli = (options = {}) => {
4065
5105
  }
4066
5106
  stdout(result.path);
4067
5107
  return EXIT_CODE.OK;
4068
- }
4069
- if (command === "unabsorb") {
5108
+ };
5109
+ const handleUnabsorb = async () => {
4070
5110
  ensureArgumentCount({
4071
5111
  command,
4072
5112
  args: commandArgs,
@@ -4106,6 +5146,7 @@ const createCli = (options = {}) => {
4106
5146
  });
4107
5147
  const targetWorktree = resolveManagedNonPrimaryWorktreeByBranch({
4108
5148
  repoRoot,
5149
+ managedWorktreeRoot,
4109
5150
  branch,
4110
5151
  worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees,
4111
5152
  optionName: "--to",
@@ -4197,8 +5238,8 @@ const createCli = (options = {}) => {
4197
5238
  }
4198
5239
  stdout(result.path);
4199
5240
  return EXIT_CODE.OK;
4200
- }
4201
- if (command === "use") {
5241
+ };
5242
+ const handleUse = async () => {
4202
5243
  ensureArgumentCount({
4203
5244
  command,
4204
5245
  args: commandArgs,
@@ -4291,8 +5332,17 @@ const createCli = (options = {}) => {
4291
5332
  }
4292
5333
  stdout(result.path);
4293
5334
  return EXIT_CODE.OK;
4294
- }
4295
- if (command === "exec") {
5335
+ };
5336
+ const synchronizationExitCode = await dispatchCommandHandler({
5337
+ command,
5338
+ handlers: createSynchronizationHandlers({
5339
+ absorbHandler: handleAbsorb,
5340
+ unabsorbHandler: handleUnabsorb,
5341
+ useHandler: handleUse
5342
+ })
5343
+ });
5344
+ if (synchronizationExitCode !== void 0) return synchronizationExitCode;
5345
+ const handleExec = async () => {
4296
5346
  ensureArgumentCount({
4297
5347
  command,
4298
5348
  args: commandArgs,
@@ -4347,8 +5397,8 @@ const createCli = (options = {}) => {
4347
5397
  return EXIT_CODE.CHILD_PROCESS_FAILED;
4348
5398
  }
4349
5399
  return childExitCode === 0 ? EXIT_CODE.OK : EXIT_CODE.CHILD_PROCESS_FAILED;
4350
- }
4351
- if (command === "invoke") {
5400
+ };
5401
+ const handleInvoke = async () => {
4352
5402
  ensureArgumentCount({
4353
5403
  command,
4354
5404
  args: commandArgs,
@@ -4372,38 +5422,32 @@ const createCli = (options = {}) => {
4372
5422
  stderr
4373
5423
  })
4374
5424
  });
4375
- if (runtime.json) {
4376
- stdout(JSON.stringify(buildJsonSuccess({
4377
- command,
4378
- status: "ok",
4379
- repoRoot,
4380
- details: {
4381
- hook: hookName,
4382
- exitCode: 0
4383
- }
4384
- })));
4385
- return EXIT_CODE.OK;
4386
- }
5425
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5426
+ command,
5427
+ status: "ok",
5428
+ repoRoot,
5429
+ details: {
5430
+ hook: hookName,
5431
+ exitCode: 0
5432
+ }
5433
+ })));
4387
5434
  return EXIT_CODE.OK;
4388
- }
4389
- if (command === "copy") {
5435
+ };
5436
+ const handleCopy = async () => {
4390
5437
  ensureArgumentCount({
4391
5438
  command,
4392
5439
  args: commandArgs,
4393
5440
  min: 1,
4394
5441
  max: Number.MAX_SAFE_INTEGER
4395
5442
  });
4396
- const worktreePath = ensurePathInsideRepo({
4397
- repoRoot,
4398
- path: resolvePathFromCwd({
4399
- cwd: repoContext.currentWorktreeRoot,
4400
- path: process.env.WT_WORKTREE_PATH ?? "."
4401
- })
5443
+ const targetWorktreeRoot = resolveTargetWorktreeRootForCopyLink({
5444
+ repoContext,
5445
+ snapshot: await collectWorktreeSnapshot$1(repoRoot)
4402
5446
  });
4403
5447
  for (const relativePath of commandArgs) {
4404
5448
  const { sourcePath, destinationPath } = resolveFileCopyTargets({
4405
5449
  repoRoot,
4406
- worktreePath,
5450
+ targetWorktreeRoot,
4407
5451
  relativePath
4408
5452
  });
4409
5453
  await access(sourcePath, constants.F_OK);
@@ -4415,39 +5459,33 @@ const createCli = (options = {}) => {
4415
5459
  dereference: false
4416
5460
  });
4417
5461
  }
4418
- if (runtime.json) {
4419
- stdout(JSON.stringify(buildJsonSuccess({
4420
- command,
4421
- status: "ok",
4422
- repoRoot,
4423
- details: {
4424
- copied: commandArgs,
4425
- worktreePath
4426
- }
4427
- })));
4428
- return EXIT_CODE.OK;
4429
- }
5462
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5463
+ command,
5464
+ status: "ok",
5465
+ repoRoot,
5466
+ details: {
5467
+ copied: commandArgs,
5468
+ worktreePath: targetWorktreeRoot
5469
+ }
5470
+ })));
4430
5471
  return EXIT_CODE.OK;
4431
- }
4432
- if (command === "link") {
5472
+ };
5473
+ const handleLink = async () => {
4433
5474
  ensureArgumentCount({
4434
5475
  command,
4435
5476
  args: commandArgs,
4436
5477
  min: 1,
4437
5478
  max: Number.MAX_SAFE_INTEGER
4438
5479
  });
4439
- const worktreePath = ensurePathInsideRepo({
4440
- repoRoot,
4441
- path: resolvePathFromCwd({
4442
- cwd: repoContext.currentWorktreeRoot,
4443
- path: process.env.WT_WORKTREE_PATH ?? "."
4444
- })
5480
+ const targetWorktreeRoot = resolveTargetWorktreeRootForCopyLink({
5481
+ repoContext,
5482
+ snapshot: await collectWorktreeSnapshot$1(repoRoot)
4445
5483
  });
4446
5484
  const fallbackEnabled = parsedArgs.fallback !== false;
4447
5485
  for (const relativePath of commandArgs) {
4448
5486
  const { sourcePath, destinationPath } = resolveFileCopyTargets({
4449
5487
  repoRoot,
4450
- worktreePath,
5488
+ targetWorktreeRoot,
4451
5489
  relativePath
4452
5490
  });
4453
5491
  await access(sourcePath, constants.F_OK);
@@ -4478,22 +5516,19 @@ const createCli = (options = {}) => {
4478
5516
  });
4479
5517
  }
4480
5518
  }
4481
- if (runtime.json) {
4482
- stdout(JSON.stringify(buildJsonSuccess({
4483
- command,
4484
- status: "ok",
4485
- repoRoot,
4486
- details: {
4487
- linked: commandArgs,
4488
- worktreePath,
4489
- fallback: fallbackEnabled
4490
- }
4491
- })));
4492
- return EXIT_CODE.OK;
4493
- }
5519
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5520
+ command,
5521
+ status: "ok",
5522
+ repoRoot,
5523
+ details: {
5524
+ linked: commandArgs,
5525
+ worktreePath: targetWorktreeRoot,
5526
+ fallback: fallbackEnabled
5527
+ }
5528
+ })));
4494
5529
  return EXIT_CODE.OK;
4495
- }
4496
- if (command === "lock") {
5530
+ };
5531
+ const handleLock = async () => {
4497
5532
  ensureArgumentCount({
4498
5533
  command,
4499
5534
  args: commandArgs,
@@ -4535,25 +5570,22 @@ const createCli = (options = {}) => {
4535
5570
  owner
4536
5571
  });
4537
5572
  });
4538
- if (runtime.json) {
4539
- stdout(JSON.stringify(buildJsonSuccess({
4540
- command,
4541
- status: "ok",
4542
- repoRoot,
4543
- details: {
4544
- branch,
4545
- locked: {
4546
- value: true,
4547
- reason: result.reason,
4548
- owner: result.owner
4549
- }
5573
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5574
+ command,
5575
+ status: "ok",
5576
+ repoRoot,
5577
+ details: {
5578
+ branch,
5579
+ locked: {
5580
+ value: true,
5581
+ reason: result.reason,
5582
+ owner: result.owner
4550
5583
  }
4551
- })));
4552
- return EXIT_CODE.OK;
4553
- }
5584
+ }
5585
+ })));
4554
5586
  return EXIT_CODE.OK;
4555
- }
4556
- if (command === "unlock") {
5587
+ };
5588
+ const handleUnlock = async () => {
4557
5589
  ensureArgumentCount({
4558
5590
  command,
4559
5591
  args: commandArgs,
@@ -4598,24 +5630,21 @@ const createCli = (options = {}) => {
4598
5630
  branch
4599
5631
  });
4600
5632
  });
4601
- if (runtime.json) {
4602
- stdout(JSON.stringify(buildJsonSuccess({
4603
- command,
4604
- status: "ok",
4605
- repoRoot,
4606
- details: {
4607
- branch,
4608
- locked: {
4609
- value: false,
4610
- reason: null
4611
- }
5633
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5634
+ command,
5635
+ status: "ok",
5636
+ repoRoot,
5637
+ details: {
5638
+ branch,
5639
+ locked: {
5640
+ value: false,
5641
+ reason: null
4612
5642
  }
4613
- })));
4614
- return EXIT_CODE.OK;
4615
- }
5643
+ }
5644
+ })));
4616
5645
  return EXIT_CODE.OK;
4617
- }
4618
- if (command === "cd") {
5646
+ };
5647
+ const handleCd = async () => {
4619
5648
  ensureArgumentCount({
4620
5649
  command,
4621
5650
  args: commandArgs,
@@ -4640,21 +5669,29 @@ const createCli = (options = {}) => {
4640
5669
  }));
4641
5670
  if (candidates.length === 0) throw createCliError("WORKTREE_NOT_FOUND", { message: "No worktree candidates found" });
4642
5671
  const promptValue = readStringOption(parsedArgsRecord, "prompt");
5672
+ const prompt = typeof promptValue === "string" && promptValue.length > 0 ? promptValue : resolvedConfig.selector.cd.prompt;
5673
+ const cliFzfExtraArgs = collectOptionValues({
5674
+ args: beforeDoubleDash,
5675
+ optionNames: ["fzfArg", "fzf-arg"]
5676
+ });
5677
+ const mergedConfigFzfArgs = mergeFzfArgs({
5678
+ defaults: resolvedConfig.selector.cd.fzf.extraArgs,
5679
+ extras: cliFzfExtraArgs
5680
+ });
5681
+ const surface = resolvedConfig.selector.cd.surface;
4643
5682
  const selection = await selectPathWithFzf$1({
4644
5683
  candidates,
4645
- prompt: typeof promptValue === "string" && promptValue.length > 0 ? promptValue : "worktree> ",
5684
+ prompt,
4646
5685
  fzfExtraArgs: mergeFzfArgs({
4647
5686
  defaults: CD_FZF_EXTRA_ARGS,
4648
- extras: collectOptionValues({
4649
- args: beforeDoubleDash,
4650
- optionNames: ["fzfArg", "fzf-arg"]
4651
- })
5687
+ extras: mergedConfigFzfArgs
4652
5688
  }),
5689
+ surface,
5690
+ tmuxPopupOpts: resolvedConfig.selector.cd.tmuxPopupOpts,
4653
5691
  cwd: repoRoot,
4654
5692
  isInteractive: () => runtime.isInteractive || process.stderr.isTTY === true
4655
5693
  }).catch((error) => {
4656
- const message = error instanceof Error ? error.message : String(error);
4657
- if (message.includes("interactive terminal") || message.includes("fzf is required")) throw createCliError("DEPENDENCY_MISSING", { message: `DEPENDENCY_MISSING: ${message}` });
5694
+ if (error instanceof FzfDependencyError || error instanceof FzfInteractiveRequiredError) throw createCliError("DEPENDENCY_MISSING", { message: `DEPENDENCY_MISSING: ${error.message}` });
4658
5695
  throw error;
4659
5696
  });
4660
5697
  if (selection.status === "cancelled") return EXIT_CODE_CANCELLED;
@@ -4670,7 +5707,20 @@ const createCli = (options = {}) => {
4670
5707
  }
4671
5708
  stdout(selectedPath);
4672
5709
  return EXIT_CODE.OK;
4673
- }
5710
+ };
5711
+ const miscCommandExitCode = await dispatchCommandHandler({
5712
+ command,
5713
+ handlers: createMiscCommandHandlers({
5714
+ execHandler: handleExec,
5715
+ invokeHandler: handleInvoke,
5716
+ copyHandler: handleCopy,
5717
+ linkHandler: handleLink,
5718
+ lockHandler: handleLock,
5719
+ unlockHandler: handleUnlock,
5720
+ cdHandler: handleCd
5721
+ })
5722
+ });
5723
+ if (miscCommandExitCode !== void 0) return miscCommandExitCode;
4674
5724
  throw createCliError("UNKNOWN_COMMAND", { message: `Unknown command: ${command}` });
4675
5725
  } catch (error) {
4676
5726
  const cliError = ensureCliError(error);