vde-worktree 0.0.18 → 0.0.20

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,691 @@ 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 isPathInsideOrEqual$1 = ({ parentPath, childPath }) => {
754
+ const rel = relative(parentPath, childPath);
755
+ if (rel.length === 0) return true;
756
+ return rel !== ".." && rel.startsWith(`..${sep}`) !== true;
757
+ };
758
+ const validateWorktreeRoot = async ({ repoRoot, config }) => {
759
+ const rawWorktreeRoot = config.paths.worktreeRoot;
760
+ const resolvedWorktreeRoot = isAbsolute(rawWorktreeRoot) ? resolve(rawWorktreeRoot) : resolve(repoRoot, rawWorktreeRoot);
761
+ if (isPathInsideOrEqual$1({
762
+ parentPath: resolve(repoRoot, ".git"),
763
+ childPath: resolvedWorktreeRoot
764
+ })) throwInvalidConfig({
765
+ file: "<resolved>",
766
+ keyPath: "paths.worktreeRoot",
767
+ reason: "must not point inside .git"
768
+ });
769
+ try {
770
+ if ((await lstat(resolvedWorktreeRoot)).isDirectory() !== true) throwInvalidConfig({
771
+ file: "<resolved>",
772
+ keyPath: "paths.worktreeRoot",
773
+ reason: "must not point to an existing file"
774
+ });
775
+ } catch (error) {
776
+ if (error.code === "ENOENT") return;
777
+ throw error;
778
+ }
779
+ };
780
+ const parseConfigFile = async (file) => {
781
+ const rawContent = await readFile(file, "utf8");
782
+ let parsed;
783
+ try {
784
+ parsed = parse(rawContent);
785
+ } catch (error) {
786
+ throwInvalidConfig({
787
+ file,
788
+ keyPath: "<root>",
789
+ reason: error instanceof Error ? error.message : String(error)
790
+ });
791
+ }
792
+ return validatePartialConfig({
793
+ rawConfig: parsed,
794
+ ctx: { file }
795
+ });
796
+ };
797
+ const cloneDefaultConfig = () => {
798
+ return mergeConfig(DEFAULT_CONFIG, {});
799
+ };
800
+ const loadResolvedConfig = async ({ cwd, repoRoot }) => {
801
+ const files = await resolveExistingConfigFiles({
802
+ cwd,
803
+ repoRoot
804
+ });
805
+ let config = cloneDefaultConfig();
806
+ for (const file of files) {
807
+ const partial = await parseConfigFile(file);
808
+ config = mergeConfig(config, partial);
809
+ }
810
+ await validateWorktreeRoot({
811
+ repoRoot,
812
+ config
813
+ });
814
+ return {
815
+ config,
816
+ loadedFiles: files
817
+ };
818
+ };
819
+
133
820
  //#endregion
134
821
  //#region src/git/exec.ts
135
822
  const runGitCommand = async ({ cwd, args, reject = true }) => {
@@ -175,6 +862,7 @@ const doesGitRefExist = async (cwd, ref) => {
175
862
  //#endregion
176
863
  //#region src/core/paths.ts
177
864
  const GIT_DIR_NAME = ".git";
865
+ const DEFAULT_WORKTREE_ROOT = ".worktree";
178
866
  const WORKTREE_ID_HASH_LENGTH = 12;
179
867
  const WORKTREE_ID_SLUG_MAX_LENGTH = 48;
180
868
  const resolveRepoRootFromCommonDir = ({ currentWorktreeRoot, gitCommonDir }) => {
@@ -212,8 +900,9 @@ const resolveRepoContext = async (cwd) => {
212
900
  gitCommonDir
213
901
  };
214
902
  };
215
- const getWorktreeRootPath = (repoRoot) => {
216
- return join(repoRoot, ".worktree");
903
+ const getWorktreeRootPath = (repoRoot, configuredWorktreeRoot = DEFAULT_WORKTREE_ROOT) => {
904
+ if (isAbsolute(configuredWorktreeRoot)) return resolve(configuredWorktreeRoot);
905
+ return resolve(repoRoot, configuredWorktreeRoot);
217
906
  };
218
907
  const getWorktreeMetaRootPath = (repoRoot) => {
219
908
  return join(repoRoot, ".vde", "worktree");
@@ -233,25 +922,33 @@ const getStateDirectoryPath = (repoRoot) => {
233
922
  const branchToWorktreeId = (branch) => {
234
923
  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
924
  };
236
- const branchToWorktreePath = (repoRoot, branch) => {
237
- const worktreeRoot = getWorktreeRootPath(repoRoot);
238
- return ensurePathInsideRepo({
239
- repoRoot: worktreeRoot,
240
- path: join(worktreeRoot, ...branch.split("/"))
925
+ const branchToWorktreePath = (repoRoot, branch, configuredWorktreeRoot = DEFAULT_WORKTREE_ROOT) => {
926
+ const worktreeRoot = getWorktreeRootPath(repoRoot, configuredWorktreeRoot);
927
+ return ensurePathInsideRoot({
928
+ rootPath: worktreeRoot,
929
+ path: join(worktreeRoot, ...branch.split("/")),
930
+ message: "Path is outside managed worktree root"
241
931
  });
242
932
  };
243
- const ensurePathInsideRepo = ({ repoRoot, path }) => {
244
- const rel = relative(repoRoot, path);
933
+ const ensurePathInsideRoot = ({ rootPath, path, message = "Path is outside allowed root" }) => {
934
+ const rel = relative(rootPath, path);
245
935
  if (rel === "") return path;
246
936
  if (rel === ".." || rel.startsWith(`..${sep}`)) throw createCliError("PATH_OUTSIDE_REPO", {
247
- message: "Path is outside repository root",
937
+ message,
248
938
  details: {
249
- repoRoot,
939
+ rootPath,
250
940
  path
251
941
  }
252
942
  });
253
943
  return path;
254
944
  };
945
+ const ensurePathInsideRepo = ({ repoRoot, path }) => {
946
+ return ensurePathInsideRoot({
947
+ rootPath: repoRoot,
948
+ path,
949
+ message: "Path is outside repository root"
950
+ });
951
+ };
255
952
  const resolveRepoRelativePath = ({ repoRoot, relativePath }) => {
256
953
  if (isAbsolute(relativePath)) throw createCliError("ABSOLUTE_PATH_NOT_ALLOWED", {
257
954
  message: "Absolute path is not allowed",
@@ -266,6 +963,11 @@ const resolvePathFromCwd = ({ cwd, path }) => {
266
963
  if (isAbsolute(path)) return path;
267
964
  return resolve(cwd, path);
268
965
  };
966
+ const isManagedWorktreePath = ({ worktreePath, managedWorktreeRoot }) => {
967
+ const rel = relative(managedWorktreeRoot, worktreePath);
968
+ if (rel === "" || rel === "." || rel === "..") return false;
969
+ return rel.startsWith(`..${sep}`) !== true;
970
+ };
269
971
 
270
972
  //#endregion
271
973
  //#region src/core/hooks.ts
@@ -414,7 +1116,7 @@ const invokeHook = async ({ hookName, args, context }) => {
414
1116
 
415
1117
  //#endregion
416
1118
  //#region src/core/init.ts
417
- const MANAGED_EXCLUDE_BLOCK = `# vde-worktree (managed)\n.worktree/\n.vde/worktree/\n`;
1119
+ const EXCLUDE_MARKER = "# vde-worktree (managed)";
418
1120
  const DEFAULT_HOOKS = [{
419
1121
  name: "post-new",
420
1122
  lines: [
@@ -448,7 +1150,27 @@ const createHookTemplate = async (hooksDir, name, lines) => {
448
1150
  await chmod(targetPath, 493);
449
1151
  }
450
1152
  };
451
- const ensureExcludeBlock = async (repoRoot) => {
1153
+ const isPathInsideOrEqual = ({ rootPath, candidatePath }) => {
1154
+ const rel = relative(rootPath, candidatePath);
1155
+ if (rel.length === 0) return true;
1156
+ return rel !== ".." && rel.startsWith(`..${sep}`) !== true;
1157
+ };
1158
+ const toExcludeEntry = ({ repoRoot, managedWorktreeRoot }) => {
1159
+ if (isPathInsideOrEqual({
1160
+ rootPath: repoRoot,
1161
+ candidatePath: managedWorktreeRoot
1162
+ }) !== true) return null;
1163
+ const rel = relative(repoRoot, managedWorktreeRoot).split(sep).join("/");
1164
+ const normalized = rel.length === 0 ? "." : rel;
1165
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
1166
+ };
1167
+ const ensureExcludeBlock = async ({ repoRoot, managedWorktreeRoot }) => {
1168
+ const managedEntry = toExcludeEntry({
1169
+ repoRoot,
1170
+ managedWorktreeRoot
1171
+ });
1172
+ if (managedEntry === null) return;
1173
+ const managedExcludeBlock = `${EXCLUDE_MARKER}\n${managedEntry}\n.vde/worktree/\n`;
452
1174
  const excludePath = join(repoRoot, ".git", "info", "exclude");
453
1175
  let current = "";
454
1176
  try {
@@ -456,8 +1178,8 @@ const ensureExcludeBlock = async (repoRoot) => {
456
1178
  } catch {
457
1179
  current = "";
458
1180
  }
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");
1181
+ if (current.includes(managedExcludeBlock)) return;
1182
+ await writeFile(excludePath, `${current.endsWith("\n") || current.length === 0 ? current : `${current}\n`}${managedExcludeBlock}`, "utf8");
461
1183
  };
462
1184
  const isInitialized = async (repoRoot) => {
463
1185
  try {
@@ -467,14 +1189,17 @@ const isInitialized = async (repoRoot) => {
467
1189
  return false;
468
1190
  }
469
1191
  };
470
- const initializeRepository = async (repoRoot) => {
1192
+ const initializeRepository = async ({ repoRoot, managedWorktreeRoot }) => {
471
1193
  const wasInitialized = await isInitialized(repoRoot);
472
- await mkdir(getWorktreeRootPath(repoRoot), { recursive: true });
1194
+ await mkdir(managedWorktreeRoot, { recursive: true });
473
1195
  await mkdir(getHooksDirectoryPath(repoRoot), { recursive: true });
474
1196
  await mkdir(getLogsDirectoryPath(repoRoot), { recursive: true });
475
1197
  await mkdir(getLocksDirectoryPath(repoRoot), { recursive: true });
476
1198
  await mkdir(getStateDirectoryPath(repoRoot), { recursive: true });
477
- await ensureExcludeBlock(repoRoot);
1199
+ await ensureExcludeBlock({
1200
+ repoRoot,
1201
+ managedWorktreeRoot
1202
+ });
478
1203
  for (const hook of DEFAULT_HOOKS) await createHookTemplate(getHooksDirectoryPath(repoRoot), hook.name, hook.lines);
479
1204
  return { alreadyInitialized: wasInitialized };
480
1205
  };
@@ -853,8 +1578,11 @@ const toTargetBranches = ({ branches, baseBranch }) => {
853
1578
  }
854
1579
  return [...uniqueBranches];
855
1580
  };
856
- const buildUnknownPrStatusMap = (branches) => {
857
- return new Map(branches.map((branch) => [branch, "unknown"]));
1581
+ const buildUnknownPrStateMap = (branches) => {
1582
+ return new Map(branches.map((branch) => [branch, {
1583
+ status: "unknown",
1584
+ url: null
1585
+ }]));
858
1586
  };
859
1587
  const parseUpdatedAtMillis = (value) => {
860
1588
  if (typeof value !== "string" || value.length === 0) return Number.NEGATIVE_INFINITY;
@@ -870,7 +1598,11 @@ const toPrStatus = (record) => {
870
1598
  if (state === "CLOSED") return "closed_unmerged";
871
1599
  return "unknown";
872
1600
  };
873
- const parsePrStatusByBranch = ({ raw, targetBranches }) => {
1601
+ const toPrUrl = (record) => {
1602
+ if (typeof record.url === "string" && record.url.length > 0) return record.url;
1603
+ return null;
1604
+ };
1605
+ const parsePrStateByBranch = ({ raw, targetBranches }) => {
874
1606
  try {
875
1607
  const parsed = JSON.parse(raw);
876
1608
  if (Array.isArray(parsed) !== true) return null;
@@ -882,31 +1614,43 @@ const parsePrStatusByBranch = ({ raw, targetBranches }) => {
882
1614
  if (targetBranchSet.has(record.headRefName) !== true) continue;
883
1615
  const updatedAtMillis = parseUpdatedAtMillis(record.updatedAt);
884
1616
  const status = toPrStatus(record);
1617
+ const url = toPrUrl(record);
885
1618
  const current = latestByBranch.get(record.headRefName);
886
1619
  if (current === void 0 || updatedAtMillis > current.updatedAtMillis || updatedAtMillis === current.updatedAtMillis && index > current.index) latestByBranch.set(record.headRefName, {
887
1620
  updatedAtMillis,
888
1621
  index,
889
- status
1622
+ status,
1623
+ url
890
1624
  });
891
1625
  }
892
1626
  const result = /* @__PURE__ */ new Map();
893
1627
  for (const branch of targetBranches) {
894
1628
  const latest = latestByBranch.get(branch);
895
- result.set(branch, latest?.status ?? "none");
1629
+ if (latest === void 0) {
1630
+ result.set(branch, {
1631
+ status: "none",
1632
+ url: null
1633
+ });
1634
+ continue;
1635
+ }
1636
+ result.set(branch, {
1637
+ status: latest.status,
1638
+ url: latest.url
1639
+ });
896
1640
  }
897
1641
  return result;
898
1642
  } catch {
899
1643
  return null;
900
1644
  }
901
1645
  };
902
- const resolvePrStatusByBranchBatch = async ({ repoRoot, baseBranch, branches, enabled = true, runGh = defaultRunGh }) => {
1646
+ const resolvePrStateByBranchBatch = async ({ repoRoot, baseBranch, branches, enabled = true, runGh = defaultRunGh }) => {
903
1647
  if (baseBranch === null) return /* @__PURE__ */ new Map();
904
1648
  const targetBranches = toTargetBranches({
905
1649
  branches,
906
1650
  baseBranch
907
1651
  });
908
1652
  if (targetBranches.length === 0) return /* @__PURE__ */ new Map();
909
- if (enabled !== true) return buildUnknownPrStatusMap(targetBranches);
1653
+ if (enabled !== true) return buildUnknownPrStateMap(targetBranches);
910
1654
  try {
911
1655
  const result = await runGh({
912
1656
  cwd: repoRoot,
@@ -922,19 +1666,19 @@ const resolvePrStatusByBranchBatch = async ({ repoRoot, baseBranch, branches, en
922
1666
  "--limit",
923
1667
  "1000",
924
1668
  "--json",
925
- "headRefName,state,mergedAt,updatedAt"
1669
+ "headRefName,state,mergedAt,updatedAt,url"
926
1670
  ]
927
1671
  });
928
- if (result.exitCode !== 0) return buildUnknownPrStatusMap(targetBranches);
929
- const prStatusByBranch = parsePrStatusByBranch({
1672
+ if (result.exitCode !== 0) return buildUnknownPrStateMap(targetBranches);
1673
+ const prStatusByBranch = parsePrStateByBranch({
930
1674
  raw: result.stdout,
931
1675
  targetBranches
932
1676
  });
933
- if (prStatusByBranch === null) return buildUnknownPrStatusMap(targetBranches);
1677
+ if (prStatusByBranch === null) return buildUnknownPrStateMap(targetBranches);
934
1678
  return prStatusByBranch;
935
1679
  } catch (error) {
936
- if (error.code === "ENOENT") return buildUnknownPrStatusMap(targetBranches);
937
- return buildUnknownPrStatusMap(targetBranches);
1680
+ if (error.code === "ENOENT") return buildUnknownPrStateMap(targetBranches);
1681
+ return buildUnknownPrStateMap(targetBranches);
938
1682
  }
939
1683
  };
940
1684
 
@@ -999,36 +1743,6 @@ const listGitWorktrees = async (repoRoot) => {
999
1743
 
1000
1744
  //#endregion
1001
1745
  //#region src/core/worktree-state.ts
1002
- const resolveBaseBranch$1 = async (repoRoot) => {
1003
- const explicit = await runGitCommand({
1004
- cwd: repoRoot,
1005
- args: [
1006
- "config",
1007
- "--get",
1008
- "vde-worktree.baseBranch"
1009
- ],
1010
- reject: false
1011
- });
1012
- if (explicit.exitCode === 0 && explicit.stdout.trim().length > 0) return explicit.stdout.trim();
1013
- for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
1014
- return null;
1015
- };
1016
- const resolveEnableGh = async (repoRoot) => {
1017
- const result = await runGitCommand({
1018
- cwd: repoRoot,
1019
- args: [
1020
- "config",
1021
- "--bool",
1022
- "--get",
1023
- "vde-worktree.enableGh"
1024
- ],
1025
- reject: false
1026
- });
1027
- if (result.exitCode !== 0) return true;
1028
- const value = result.stdout.trim().toLowerCase();
1029
- if (value === "false" || value === "no" || value === "off" || value === "0") return false;
1030
- return true;
1031
- };
1032
1746
  const resolveDirty = async (worktreePath) => {
1033
1747
  return (await runGitCommand({
1034
1748
  cwd: worktreePath,
@@ -1133,7 +1847,7 @@ const resolveLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1133
1847
  divergedHead: latestWorkHead
1134
1848
  };
1135
1849
  };
1136
- const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStatusByBranch }) => {
1850
+ const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStateByBranch }) => {
1137
1851
  if (branch === null) return {
1138
1852
  byAncestry: null,
1139
1853
  byPR: null,
@@ -1154,7 +1868,7 @@ const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStatus
1154
1868
  if (result.exitCode === 0) byAncestry = true;
1155
1869
  else if (result.exitCode === 1) byAncestry = false;
1156
1870
  }
1157
- const prStatus = branch === baseBranch ? null : prStatusByBranch.get(branch) ?? null;
1871
+ const prStatus = branch === baseBranch ? null : prStateByBranch.get(branch)?.status ?? null;
1158
1872
  let byPR = null;
1159
1873
  if (prStatus === "merged") byPR = true;
1160
1874
  else if (prStatus === "none" || prStatus === "open" || prStatus === "closed_unmerged") byPR = false;
@@ -1208,9 +1922,16 @@ const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStatus
1208
1922
  })
1209
1923
  };
1210
1924
  };
1211
- const resolvePrState = ({ branch, baseBranch, prStatusByBranch }) => {
1212
- if (branch === null || branch === baseBranch) return { status: null };
1213
- return { status: prStatusByBranch.get(branch) ?? null };
1925
+ const resolveWorktreePrState = ({ branch, baseBranch, prStateByBranch }) => {
1926
+ if (branch === null || branch === baseBranch) return {
1927
+ status: null,
1928
+ url: null
1929
+ };
1930
+ const prState = prStateByBranch.get(branch);
1931
+ return {
1932
+ status: prState?.status ?? null,
1933
+ url: prState?.url ?? null
1934
+ };
1214
1935
  };
1215
1936
  const resolveMergedOverall = ({ byAncestry, byPR, byLifecycle }) => {
1216
1937
  if (byPR === true || byLifecycle === true) return true;
@@ -1258,7 +1979,7 @@ const resolveUpstreamState = async (worktreePath) => {
1258
1979
  remote: upstreamRef.stdout.trim()
1259
1980
  };
1260
1981
  };
1261
- const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStatusByBranch }) => {
1982
+ const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStateByBranch }) => {
1262
1983
  const [dirty, locked, merged, upstream] = await Promise.all([
1263
1984
  resolveDirty(worktree.path),
1264
1985
  resolveLockState({
@@ -1270,14 +1991,14 @@ const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStatusByBranch
1270
1991
  branch: worktree.branch,
1271
1992
  head: worktree.head,
1272
1993
  baseBranch,
1273
- prStatusByBranch
1994
+ prStateByBranch
1274
1995
  }),
1275
1996
  resolveUpstreamState(worktree.path)
1276
1997
  ]);
1277
- const pr = resolvePrState({
1998
+ const pr = resolveWorktreePrState({
1278
1999
  branch: worktree.branch,
1279
2000
  baseBranch,
1280
- prStatusByBranch
2001
+ prStateByBranch
1281
2002
  });
1282
2003
  return {
1283
2004
  branch: worktree.branch,
@@ -1290,17 +2011,13 @@ const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStatusByBranch
1290
2011
  upstream
1291
2012
  };
1292
2013
  };
1293
- const collectWorktreeSnapshot = async (repoRoot, { noGh = false } = {}) => {
1294
- const [baseBranch, worktrees, enableGh] = await Promise.all([
1295
- resolveBaseBranch$1(repoRoot),
1296
- listGitWorktrees(repoRoot),
1297
- resolveEnableGh(repoRoot)
1298
- ]);
1299
- const prStatusByBranch = await resolvePrStatusByBranchBatch({
2014
+ const collectWorktreeSnapshot = async (repoRoot, { baseBranch = null, ghEnabled = true, noGh = false } = {}) => {
2015
+ const worktrees = await listGitWorktrees(repoRoot);
2016
+ const prStateByBranch = await resolvePrStateByBranchBatch({
1300
2017
  repoRoot,
1301
2018
  baseBranch,
1302
2019
  branches: worktrees.map((worktree) => worktree.branch),
1303
- enabled: enableGh && noGh !== true
2020
+ enabled: ghEnabled && noGh !== true
1304
2021
  });
1305
2022
  return {
1306
2023
  repoRoot,
@@ -1310,7 +2027,7 @@ const collectWorktreeSnapshot = async (repoRoot, { noGh = false } = {}) => {
1310
2027
  repoRoot,
1311
2028
  worktree,
1312
2029
  baseBranch,
1313
- prStatusByBranch
2030
+ prStateByBranch
1314
2031
  });
1315
2032
  }))
1316
2033
  };
@@ -1324,7 +2041,8 @@ const RESERVED_FZF_ARGS = new Set([
1324
2041
  "prompt",
1325
2042
  "layout",
1326
2043
  "height",
1327
- "border"
2044
+ "border",
2045
+ "tmux"
1328
2046
  ]);
1329
2047
  const ANSI_ESCAPE_SEQUENCE_PATTERN = String.raw`\u001B\[[0-?]*[ -/]*[@-~]`;
1330
2048
  const ANSI_ESCAPE_SEQUENCE_REGEX = new RegExp(ANSI_ESCAPE_SEQUENCE_PATTERN, "g");
@@ -1364,6 +2082,13 @@ const defaultCheckFzfAvailability = async () => {
1364
2082
  throw error;
1365
2083
  }
1366
2084
  };
2085
+ const defaultCheckFzfTmuxSupport = async () => {
2086
+ try {
2087
+ return (await execa(FZF_BINARY, ["--help"], { timeout: FZF_CHECK_TIMEOUT_MS })).stdout.includes("--tmux");
2088
+ } catch {
2089
+ return false;
2090
+ }
2091
+ };
1367
2092
  const defaultRunFzf = async ({ args, input, cwd, env }) => {
1368
2093
  return { stdout: (await execa(FZF_BINARY, args, {
1369
2094
  input,
@@ -1376,20 +2101,46 @@ const ensureFzfAvailable = async (checkFzfAvailability) => {
1376
2101
  if (await checkFzfAvailability()) return;
1377
2102
  throw new Error("fzf is required for interactive selection");
1378
2103
  };
1379
- 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 }) => {
2104
+ const shouldTryTmuxPopup = async ({ surface, env, checkFzfTmuxSupport }) => {
2105
+ if (surface === "inline") return false;
2106
+ if (surface === "tmux-popup") return true;
2107
+ if (typeof env.TMUX !== "string" || env.TMUX.length === 0) return false;
2108
+ try {
2109
+ return await checkFzfTmuxSupport();
2110
+ } catch {
2111
+ return false;
2112
+ }
2113
+ };
2114
+ const isTmuxUnknownOptionError = (error) => {
2115
+ const execaError = error;
2116
+ const text = [
2117
+ execaError.message,
2118
+ execaError.shortMessage,
2119
+ execaError.stderr,
2120
+ execaError.stdout
2121
+ ].filter((value) => typeof value === "string" && value.length > 0).join("\n");
2122
+ return /unknown option.*--tmux|--tmux.*unknown option/i.test(text);
2123
+ };
2124
+ 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 }) => {
1380
2125
  if (candidates.length === 0) throw new Error("No candidates provided for fzf selection");
1381
2126
  if (isInteractive() !== true) throw new Error("fzf selection requires an interactive terminal");
1382
2127
  await ensureFzfAvailable(checkFzfAvailability);
1383
- const args = buildFzfArgs({
2128
+ const baseArgs = buildFzfArgs({
1384
2129
  prompt,
1385
2130
  fzfExtraArgs
1386
2131
  });
2132
+ const tryTmuxPopup = await shouldTryTmuxPopup({
2133
+ surface,
2134
+ env,
2135
+ checkFzfTmuxSupport
2136
+ });
2137
+ const args = tryTmuxPopup ? [...baseArgs, `--tmux=${tmuxPopupOpts}`] : baseArgs;
1387
2138
  const input = buildFzfInput(candidates);
1388
2139
  if (input.length === 0) throw new Error("All candidates are empty after sanitization");
1389
2140
  const candidateSet = new Set(input.split("\n").map((candidate) => stripAnsi(candidate)));
1390
- try {
2141
+ const runWithValidation = async (fzfArgs) => {
1391
2142
  const selectedPath = stripAnsi(stripTrailingNewlines((await runFzf({
1392
- args,
2143
+ args: fzfArgs,
1393
2144
  input,
1394
2145
  cwd,
1395
2146
  env
@@ -1400,7 +2151,16 @@ const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", fzfExtraAr
1400
2151
  status: "selected",
1401
2152
  path: selectedPath
1402
2153
  };
2154
+ };
2155
+ try {
2156
+ return await runWithValidation(args);
1403
2157
  } catch (error) {
2158
+ if (tryTmuxPopup && isTmuxUnknownOptionError(error)) try {
2159
+ return await runWithValidation(baseArgs);
2160
+ } catch (fallbackError) {
2161
+ if (fallbackError.exitCode === 130) return { status: "cancelled" };
2162
+ throw fallbackError;
2163
+ }
1404
2164
  if (error.exitCode === 130) return { status: "cancelled" };
1405
2165
  throw error;
1406
2166
  }
@@ -1488,9 +2248,7 @@ const CD_FZF_EXTRA_ARGS = [
1488
2248
  "--preview-window=right,60%,wrap",
1489
2249
  "--ansi"
1490
2250
  ];
1491
- const LIST_TABLE_COLUMN_COUNT = 8;
1492
- const LIST_TABLE_PATH_COLUMN_INDEX = 7;
1493
- const LIST_TABLE_PATH_MIN_WIDTH = 12;
2251
+ const DEFAULT_LIST_TABLE_COLUMNS = [...LIST_TABLE_COLUMNS];
1494
2252
  const LIST_TABLE_CELL_HORIZONTAL_PADDING = 2;
1495
2253
  const COMPLETION_SHELLS = ["zsh", "fish"];
1496
2254
  const COMPLETION_FILE_BY_SHELL = {
@@ -1512,6 +2270,10 @@ const CATPPUCCIN_MOCHA = {
1512
2270
  overlay0: "#6c7086"
1513
2271
  };
1514
2272
  const identityColor = (value) => value;
2273
+ const hasDefaultListColumnOrder = (columns) => {
2274
+ if (columns.length !== DEFAULT_LIST_TABLE_COLUMNS.length) return false;
2275
+ return columns.every((column, index) => column === DEFAULT_LIST_TABLE_COLUMNS[index]);
2276
+ };
1515
2277
  const createCatppuccinTheme = ({ enabled }) => {
1516
2278
  if (enabled !== true) return {
1517
2279
  header: identityColor,
@@ -1665,7 +2427,7 @@ const commandHelpEntries = [
1665
2427
  details: [
1666
2428
  "Table output includes branch, path, dirty, lock, merged, PR state, and ahead/behind vs base branch.",
1667
2429
  "By default, long path values are truncated to fit terminal width.",
1668
- "JSON output includes PR and upstream metadata fields."
2430
+ "JSON output includes PR status/url and upstream metadata fields."
1669
2431
  ],
1670
2432
  options: ["--full-path"]
1671
2433
  },
@@ -1959,85 +2721,29 @@ const ensureArgumentCount = ({ command, args, min, max }) => {
1959
2721
  const ensureHasCommandAfterDoubleDash = ({ command, argsAfterDoubleDash }) => {
1960
2722
  if (argsAfterDoubleDash.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `${command} requires arguments after --` });
1961
2723
  };
1962
- const readGitConfigInt = async (repoRoot, key) => {
1963
- const result = await runGitCommand({
2724
+ const resolveBaseBranch = async ({ repoRoot, config }) => {
2725
+ if (typeof config.git.baseBranch === "string" && config.git.baseBranch.length > 0) return config.git.baseBranch;
2726
+ const remote = config.git.baseRemote;
2727
+ const resolved = await runGitCommand({
1964
2728
  cwd: repoRoot,
1965
2729
  args: [
1966
- "config",
1967
- "--get",
1968
- key
1969
- ],
1970
- reject: false
1971
- });
1972
- if (result.exitCode !== 0) return;
1973
- const parsed = Number.parseInt(result.stdout.trim(), 10);
1974
- return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1975
- };
1976
- const readGitConfigBoolean = async (repoRoot, key) => {
1977
- const result = await runGitCommand({
1978
- cwd: repoRoot,
1979
- args: [
1980
- "config",
1981
- "--bool",
1982
- "--get",
1983
- key
1984
- ],
1985
- reject: false
1986
- });
1987
- if (result.exitCode !== 0) return;
1988
- const value = result.stdout.trim().toLowerCase();
1989
- if (value === "true" || value === "yes" || value === "on" || value === "1") return true;
1990
- if (value === "false" || value === "no" || value === "off" || value === "0") return false;
1991
- };
1992
- const resolveConfiguredBaseRemote = async (repoRoot) => {
1993
- const configured = await runGitCommand({
1994
- cwd: repoRoot,
1995
- args: [
1996
- "config",
1997
- "--get",
1998
- "vde-worktree.baseRemote"
1999
- ],
2000
- reject: false
2001
- });
2002
- if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
2003
- return "origin";
2004
- };
2005
- const resolveBaseBranch = async (repoRoot) => {
2006
- const configured = await runGitCommand({
2007
- cwd: repoRoot,
2008
- args: [
2009
- "config",
2010
- "--get",
2011
- "vde-worktree.baseBranch"
2730
+ "symbolic-ref",
2731
+ "--quiet",
2732
+ "--short",
2733
+ `refs/remotes/${remote}/HEAD`
2012
2734
  ],
2013
2735
  reject: false
2014
2736
  });
2015
- if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
2016
- const remotesToProbe = [
2017
- await resolveConfiguredBaseRemote(repoRoot),
2018
- "origin",
2019
- "upstream"
2020
- ].filter((value, index, arr) => {
2021
- return arr.indexOf(value) === index;
2022
- });
2023
- for (const remote of remotesToProbe) {
2024
- const resolved = await runGitCommand({
2025
- cwd: repoRoot,
2026
- args: [
2027
- "symbolic-ref",
2028
- "--quiet",
2029
- "--short",
2030
- `refs/remotes/${remote}/HEAD`
2031
- ],
2032
- reject: false
2033
- });
2034
- if (resolved.exitCode !== 0) continue;
2737
+ if (resolved.exitCode === 0) {
2035
2738
  const raw = resolved.stdout.trim();
2036
2739
  const prefix = `${remote}/`;
2037
2740
  if (raw.startsWith(prefix)) return raw.slice(prefix.length);
2038
2741
  }
2039
2742
  for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
2040
- throw createCliError("INVALID_ARGUMENT", { message: "Unable to resolve base branch. Configure vde-worktree.baseBranch." });
2743
+ throw createCliError("INVALID_ARGUMENT", {
2744
+ message: "Unable to resolve base branch from config.yml (baseRemote/HEAD -> main/master).",
2745
+ details: { remote }
2746
+ });
2041
2747
  };
2042
2748
  const ensureTargetPathWritable = async (targetPath) => {
2043
2749
  try {
@@ -2230,7 +2936,7 @@ const validateDeleteSafety = ({ target, forceFlags }) => {
2230
2936
  const resolveLinkTargetPath = ({ sourcePath, destinationPath }) => {
2231
2937
  return relative(dirname(destinationPath), sourcePath);
2232
2938
  };
2233
- const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
2939
+ const resolveFileCopyTargets = ({ repoRoot, targetWorktreeRoot, relativePath }) => {
2234
2940
  const sourcePath = resolveRepoRelativePath({
2235
2941
  repoRoot,
2236
2942
  relativePath
@@ -2238,13 +2944,32 @@ const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
2238
2944
  const relativeFromRoot = relative(repoRoot, sourcePath);
2239
2945
  return {
2240
2946
  sourcePath,
2241
- destinationPath: ensurePathInsideRepo({
2242
- repoRoot,
2243
- path: resolve(worktreePath, relativeFromRoot)
2947
+ destinationPath: ensurePathInsideRoot({
2948
+ rootPath: targetWorktreeRoot,
2949
+ path: resolve(targetWorktreeRoot, relativeFromRoot),
2950
+ message: "Path is outside target worktree root"
2244
2951
  }),
2245
2952
  relativeFromRoot
2246
2953
  };
2247
2954
  };
2955
+ const resolveTargetWorktreeRootForCopyLink = ({ repoContext, snapshot }) => {
2956
+ const rawTarget = process.env.WT_WORKTREE_PATH ?? repoContext.currentWorktreeRoot;
2957
+ const resolvedTarget = resolvePathFromCwd({
2958
+ cwd: repoContext.currentWorktreeRoot,
2959
+ path: rawTarget
2960
+ });
2961
+ const matched = snapshot.worktrees.filter((worktree) => {
2962
+ return worktree.path === resolvedTarget || resolvedTarget.startsWith(`${worktree.path}${sep}`);
2963
+ }).sort((a, b) => b.path.length - a.path.length)[0];
2964
+ if (matched === void 0) throw createCliError("WORKTREE_NOT_FOUND", {
2965
+ message: "copy/link target worktree not found",
2966
+ details: {
2967
+ rawTarget,
2968
+ resolvedTarget
2969
+ }
2970
+ });
2971
+ return matched.path;
2972
+ };
2248
2973
  const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
2249
2974
  if (branch !== baseBranch) return;
2250
2975
  throw createCliError("INVALID_ARGUMENT", {
@@ -2255,12 +2980,14 @@ const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
2255
2980
  }
2256
2981
  });
2257
2982
  };
2258
- const toManagedWorktreeName = ({ repoRoot, worktreePath }) => {
2259
- const relativePath = relative(getWorktreeRootPath(repoRoot), worktreePath);
2260
- if (relativePath.length === 0 || relativePath === "." || relativePath === ".." || relativePath.startsWith(`..${sep}`)) return null;
2261
- return relativePath.split(sep).join("/");
2983
+ const toManagedWorktreeName = ({ managedWorktreeRoot, worktreePath }) => {
2984
+ if (isManagedWorktreePath({
2985
+ worktreePath,
2986
+ managedWorktreeRoot
2987
+ }) !== true) return null;
2988
+ return relative(managedWorktreeRoot, worktreePath).split(sep).join("/");
2262
2989
  };
2263
- const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName }) => {
2990
+ const resolveManagedWorktreePathFromName = ({ managedWorktreeRoot, optionName, worktreeName }) => {
2264
2991
  const normalized = worktreeName.trim();
2265
2992
  if (normalized.length === 0) throw createCliError("INVALID_ARGUMENT", {
2266
2993
  message: `${optionName} requires non-empty worktree name`,
@@ -2269,18 +2996,10 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2269
2996
  worktreeName
2270
2997
  }
2271
2998
  });
2272
- if (normalized === ".worktree" || normalized.startsWith(".worktree/") || normalized.startsWith(".worktree\\")) throw createCliError("INVALID_ARGUMENT", {
2273
- message: `${optionName} expects vw-managed worktree name (without .worktree/ prefix)`,
2274
- details: {
2275
- optionName,
2276
- worktreeName
2277
- }
2278
- });
2279
- const worktreeRoot = getWorktreeRootPath(repoRoot);
2280
2999
  let resolvedPath;
2281
3000
  try {
2282
3001
  resolvedPath = resolveRepoRelativePath({
2283
- repoRoot: worktreeRoot,
3002
+ repoRoot: managedWorktreeRoot,
2284
3003
  relativePath: normalized
2285
3004
  });
2286
3005
  } catch (error) {
@@ -2293,7 +3012,7 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2293
3012
  cause: error
2294
3013
  });
2295
3014
  }
2296
- if (resolvedPath === worktreeRoot) throw createCliError("INVALID_ARGUMENT", {
3015
+ if (resolvedPath === managedWorktreeRoot) throw createCliError("INVALID_ARGUMENT", {
2297
3016
  message: `${optionName} expects vw-managed worktree name`,
2298
3017
  details: {
2299
3018
  optionName,
@@ -2302,16 +3021,16 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2302
3021
  });
2303
3022
  return resolvedPath;
2304
3023
  };
2305
- const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, branch, worktrees, optionName, worktreeName, role }) => {
3024
+ const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, managedWorktreeRoot, branch, worktrees, optionName, worktreeName, role }) => {
2306
3025
  const managedCandidates = worktrees.filter((worktree) => {
2307
3026
  return worktree.branch === branch && worktree.path !== repoRoot && toManagedWorktreeName({
2308
- repoRoot,
3027
+ managedWorktreeRoot,
2309
3028
  worktreePath: worktree.path
2310
3029
  }) !== null;
2311
3030
  });
2312
3031
  if (typeof worktreeName === "string") {
2313
3032
  const resolvedPath = resolveManagedWorktreePathFromName({
2314
- repoRoot,
3033
+ managedWorktreeRoot,
2315
3034
  optionName,
2316
3035
  worktreeName
2317
3036
  });
@@ -2342,7 +3061,7 @@ const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, branch, worktrees,
2342
3061
  optionName,
2343
3062
  candidates: managedCandidates.map((worktree) => {
2344
3063
  return toManagedWorktreeName({
2345
- repoRoot,
3064
+ managedWorktreeRoot,
2346
3065
  worktreePath: worktree.path
2347
3066
  }) ?? worktree.path;
2348
3067
  })
@@ -2499,19 +3218,24 @@ const resolveListColumnContentWidth = ({ rows, columnIndex }) => {
2499
3218
  return Math.max(width, stringWidth(cell));
2500
3219
  }, 0);
2501
3220
  };
2502
- const resolveListPathColumnWidth = ({ rows, disablePathTruncation }) => {
2503
- if (disablePathTruncation) return null;
3221
+ const resolveListPathColumnWidth = ({ rows, columns, truncateMode, fullPath, minWidth }) => {
3222
+ const pathColumnIndex = columns.indexOf("path");
3223
+ if (pathColumnIndex < 0) return null;
3224
+ if (fullPath || truncateMode === "never") return null;
2504
3225
  if (process.stdout.isTTY !== true) return null;
2505
3226
  const terminalColumns = process.stdout.columns;
2506
3227
  if (typeof terminalColumns !== "number" || Number.isFinite(terminalColumns) !== true || terminalColumns <= 0) return null;
2507
- const measuredNonPathWidth = Array.from({ length: LIST_TABLE_PATH_COLUMN_INDEX }).map((_, index) => resolveListColumnContentWidth({
2508
- rows,
2509
- columnIndex: index
2510
- })).reduce((sum, width) => sum + width, 0);
2511
- const borderWidth = LIST_TABLE_COLUMN_COUNT + 1;
2512
- const paddingWidth = LIST_TABLE_COLUMN_COUNT * LIST_TABLE_CELL_HORIZONTAL_PADDING;
3228
+ const measuredNonPathWidth = columns.map((_, index) => {
3229
+ if (index === pathColumnIndex) return 0;
3230
+ return resolveListColumnContentWidth({
3231
+ rows,
3232
+ columnIndex: index
3233
+ });
3234
+ }).reduce((sum, width) => sum + width, 0);
3235
+ const borderWidth = columns.length + 1;
3236
+ const paddingWidth = columns.length * LIST_TABLE_CELL_HORIZONTAL_PADDING;
2513
3237
  const availablePathWidth = Math.floor(terminalColumns) - borderWidth - paddingWidth - measuredNonPathWidth;
2514
- return Math.max(LIST_TABLE_PATH_MIN_WIDTH, availablePathWidth);
3238
+ return Math.max(minWidth, availablePathWidth);
2515
3239
  };
2516
3240
  const resolveAheadBehindAgainstBaseBranch = async ({ repoRoot, baseBranch, worktree }) => {
2517
3241
  if (baseBranch === null) return {
@@ -2852,7 +3576,7 @@ const createCli = (options = {}) => {
2852
3576
  from: {
2853
3577
  type: "string",
2854
3578
  valueHint: "value",
2855
- description: "For extract: filesystem path. For absorb: managed worktree name without .worktree/ prefix."
3579
+ description: "For extract: filesystem path. For absorb: managed worktree name."
2856
3580
  },
2857
3581
  to: {
2858
3582
  type: "string",
@@ -3001,39 +3725,47 @@ const createCli = (options = {}) => {
3001
3725
  const repoContext = await resolveRepoContext(runtimeCwd);
3002
3726
  const repoRoot = repoContext.repoRoot;
3003
3727
  repoRootForJson = repoRoot;
3004
- const configuredHookTimeoutMs = await readGitConfigInt(repoRoot, "vde-worktree.hookTimeoutMs");
3005
- const configuredLockTimeoutMs = await readGitConfigInt(repoRoot, "vde-worktree.lockTimeoutMs");
3006
- const configuredStaleTTL = await readGitConfigInt(repoRoot, "vde-worktree.staleLockTTLSeconds");
3007
- const configuredHooksEnabled = await readGitConfigBoolean(repoRoot, "vde-worktree.hooksEnabled");
3728
+ const { config: resolvedConfig } = await loadResolvedConfig({
3729
+ cwd: runtimeCwd,
3730
+ repoRoot
3731
+ });
3732
+ const managedWorktreeRoot = getWorktreeRootPath(repoRoot, resolvedConfig.paths.worktreeRoot);
3008
3733
  const runtime = {
3009
3734
  command,
3010
3735
  json: jsonEnabled,
3011
- hooksEnabled: parsedArgs.hooks !== false && configuredHooksEnabled !== false,
3012
- ghEnabled: parsedArgs.gh !== false,
3736
+ hooksEnabled: parsedArgs.hooks !== false && resolvedConfig.hooks.enabled,
3737
+ ghEnabled: parsedArgs.gh !== false && resolvedConfig.github.enabled,
3013
3738
  strictPostHooks: parsedArgs.strictPostHooks === true,
3014
3739
  hookTimeoutMs: readNumberFromEnvOrDefault({
3015
3740
  rawValue: toNumberOption({
3016
3741
  value: parsedArgs.hookTimeoutMs,
3017
3742
  optionName: "--hook-timeout-ms"
3018
- }) ?? configuredHookTimeoutMs,
3743
+ }) ?? resolvedConfig.hooks.timeoutMs,
3019
3744
  defaultValue: DEFAULT_HOOK_TIMEOUT_MS
3020
3745
  }),
3021
3746
  lockTimeoutMs: readNumberFromEnvOrDefault({
3022
3747
  rawValue: toNumberOption({
3023
3748
  value: parsedArgs.lockTimeoutMs,
3024
3749
  optionName: "--lock-timeout-ms"
3025
- }) ?? configuredLockTimeoutMs,
3750
+ }) ?? resolvedConfig.locks.timeoutMs,
3026
3751
  defaultValue: DEFAULT_LOCK_TIMEOUT_MS
3027
3752
  }),
3028
3753
  allowUnsafe,
3029
3754
  isInteractive: isInteractiveFn()
3030
3755
  };
3031
3756
  const staleLockTTLSeconds = readNumberFromEnvOrDefault({
3032
- rawValue: configuredStaleTTL,
3757
+ rawValue: resolvedConfig.locks.staleLockTTLSeconds,
3033
3758
  defaultValue: DEFAULT_STALE_LOCK_TTL_SECONDS
3034
3759
  });
3035
3760
  const collectWorktreeSnapshot$1 = async (_ignoredRepoRoot) => {
3036
- return collectWorktreeSnapshot(repoRoot, { noGh: runtime.ghEnabled !== true });
3761
+ return collectWorktreeSnapshot(repoRoot, {
3762
+ baseBranch: await resolveBaseBranch({
3763
+ repoRoot,
3764
+ config: resolvedConfig
3765
+ }),
3766
+ ghEnabled: runtime.ghEnabled,
3767
+ noGh: runtime.ghEnabled !== true
3768
+ });
3037
3769
  };
3038
3770
  const runWriteOperation = async (task) => {
3039
3771
  if (WRITE_COMMANDS.has(command) !== true) return task();
@@ -3065,7 +3797,10 @@ const createCli = (options = {}) => {
3065
3797
  name: "init",
3066
3798
  context: hookContext
3067
3799
  });
3068
- const initialized = await initializeRepository(repoRoot);
3800
+ const initialized = await initializeRepository({
3801
+ repoRoot,
3802
+ managedWorktreeRoot
3803
+ });
3069
3804
  await runPostHook({
3070
3805
  name: "init",
3071
3806
  context: hookContext
@@ -3101,22 +3836,15 @@ const createCli = (options = {}) => {
3101
3836
  repoRoot,
3102
3837
  details: {
3103
3838
  baseBranch: snapshot.baseBranch,
3839
+ managedWorktreeRoot,
3104
3840
  worktrees: snapshot.worktrees
3105
3841
  }
3106
3842
  })));
3107
3843
  return EXIT_CODE.OK;
3108
3844
  }
3109
3845
  const theme = createCatppuccinTheme({ enabled: shouldUseAnsiColors({ interactive: runtime.isInteractive }) });
3110
- const rows = [[
3111
- "branch",
3112
- "dirty",
3113
- "merged",
3114
- "pr",
3115
- "locked",
3116
- "ahead",
3117
- "behind",
3118
- "path"
3119
- ], ...await Promise.all(snapshot.worktrees.map(async (worktree) => {
3846
+ const columns = resolvedConfig.list.table.columns;
3847
+ const rows = [[...columns], ...await Promise.all(snapshot.worktrees.map(async (worktree) => {
3120
3848
  const distanceFromBase = await resolveAheadBehindAgainstBaseBranch({
3121
3849
  repoRoot,
3122
3850
  baseBranch: snapshot.baseBranch,
@@ -3128,35 +3856,41 @@ const createCli = (options = {}) => {
3128
3856
  prStatus: worktree.pr.status,
3129
3857
  isBaseBranch
3130
3858
  });
3131
- return [
3132
- `${worktree.path === repoContext.currentWorktreeRoot ? "*" : " "} ${worktree.branch ?? "(detached)"}`,
3133
- worktree.dirty ? "dirty" : "clean",
3134
- mergedState,
3135
- prState,
3136
- worktree.locked.value ? "locked" : "-",
3137
- formatListUpstreamCount(distanceFromBase.ahead),
3138
- formatListUpstreamCount(distanceFromBase.behind),
3139
- formatDisplayPath(worktree.path)
3140
- ];
3859
+ const valuesByColumn = {
3860
+ branch: `${worktree.path === repoContext.currentWorktreeRoot ? "*" : " "} ${worktree.branch ?? "(detached)"}`,
3861
+ dirty: worktree.dirty ? "dirty" : "clean",
3862
+ merged: mergedState,
3863
+ pr: prState,
3864
+ locked: worktree.locked.value ? "locked" : "-",
3865
+ ahead: formatListUpstreamCount(distanceFromBase.ahead),
3866
+ behind: formatListUpstreamCount(distanceFromBase.behind),
3867
+ path: formatDisplayPath(worktree.path)
3868
+ };
3869
+ return columns.map((column) => valuesByColumn[column]);
3141
3870
  }))];
3142
3871
  const pathColumnWidth = resolveListPathColumnWidth({
3143
3872
  rows,
3144
- disablePathTruncation: parsedArgs.fullPath === true
3873
+ columns,
3874
+ truncateMode: resolvedConfig.list.table.path.truncate,
3875
+ fullPath: parsedArgs.fullPath === true,
3876
+ minWidth: resolvedConfig.list.table.path.minWidth
3145
3877
  });
3146
- const columnsConfig = pathColumnWidth === null ? void 0 : { [LIST_TABLE_PATH_COLUMN_INDEX]: {
3878
+ const pathColumnIndex = columns.indexOf("path");
3879
+ const columnsConfig = pathColumnWidth === null || pathColumnIndex < 0 ? void 0 : { [pathColumnIndex]: {
3147
3880
  width: pathColumnWidth,
3148
3881
  truncate: pathColumnWidth
3149
3882
  } };
3150
- const colorized = colorizeListTable({
3151
- rendered: table(rows, {
3152
- border: getBorderCharacters("norc"),
3153
- drawHorizontalLine: (lineIndex, rowCount) => {
3154
- return lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount;
3155
- },
3156
- columns: columnsConfig
3157
- }),
3158
- theme
3883
+ const rendered = table(rows, {
3884
+ border: getBorderCharacters("norc"),
3885
+ drawHorizontalLine: (lineIndex, rowCount) => {
3886
+ return lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount;
3887
+ },
3888
+ columns: columnsConfig
3159
3889
  });
3890
+ const colorized = hasDefaultListColumnOrder(columns) ? colorizeListTable({
3891
+ rendered,
3892
+ theme
3893
+ }) : rendered.trimEnd();
3160
3894
  for (const line of colorized.split("\n")) stdout(line);
3161
3895
  return EXIT_CODE.OK;
3162
3896
  }
@@ -3238,9 +3972,12 @@ const createCli = (options = {}) => {
3238
3972
  message: `Branch already exists locally: ${branch}`,
3239
3973
  details: { branch }
3240
3974
  });
3241
- const targetPath = branchToWorktreePath(repoRoot, branch);
3975
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3242
3976
  await ensureTargetPathWritable(targetPath);
3243
- const baseBranch = await resolveBaseBranch(repoRoot);
3977
+ const baseBranch = await resolveBaseBranch({
3978
+ repoRoot,
3979
+ config: resolvedConfig
3980
+ });
3244
3981
  const hookContext = createHookContext({
3245
3982
  runtime,
3246
3983
  repoRoot,
@@ -3315,7 +4052,7 @@ const createCli = (options = {}) => {
3315
4052
  path: existing.path
3316
4053
  };
3317
4054
  }
3318
- const targetPath = branchToWorktreePath(repoRoot, branch);
4055
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3319
4056
  await ensureTargetPathWritable(targetPath);
3320
4057
  const hookContext = createHookContext({
3321
4058
  runtime,
@@ -3340,7 +4077,10 @@ const createCli = (options = {}) => {
3340
4077
  ]
3341
4078
  });
3342
4079
  else {
3343
- const baseBranch = await resolveBaseBranch(repoRoot);
4080
+ const baseBranch = await resolveBaseBranch({
4081
+ repoRoot,
4082
+ config: resolvedConfig
4083
+ });
3344
4084
  lifecycleBaseBranch = baseBranch;
3345
4085
  await runGitCommand({
3346
4086
  cwd: repoRoot,
@@ -3423,7 +4163,7 @@ const createCli = (options = {}) => {
3423
4163
  message: `Branch already exists locally: ${newBranch}`,
3424
4164
  details: { branch: newBranch }
3425
4165
  });
3426
- const newPath = branchToWorktreePath(repoRoot, newBranch);
4166
+ const newPath = branchToWorktreePath(repoRoot, newBranch, resolvedConfig.paths.worktreeRoot);
3427
4167
  await ensureTargetPathWritable(newPath);
3428
4168
  const hookContext = createHookContext({
3429
4169
  runtime,
@@ -3517,6 +4257,17 @@ const createCli = (options = {}) => {
3517
4257
  message: "Cannot delete the primary worktree",
3518
4258
  details: { path: target.path }
3519
4259
  });
4260
+ if (isManagedWorktreePath({
4261
+ worktreePath: target.path,
4262
+ managedWorktreeRoot
4263
+ }) !== true) throw createCliError("WORKTREE_NOT_FOUND", {
4264
+ message: "Target branch is not in managed worktree root",
4265
+ details: {
4266
+ branch: target.branch,
4267
+ path: target.path,
4268
+ managedWorktreeRoot
4269
+ }
4270
+ });
3520
4271
  validateDeleteSafety({
3521
4272
  target,
3522
4273
  forceFlags
@@ -3590,7 +4341,10 @@ const createCli = (options = {}) => {
3590
4341
  if (parsedArgs.apply === true && parsedArgs.dryRun === true) throw createCliError("INVALID_ARGUMENT", { message: "Cannot use --apply and --dry-run together" });
3591
4342
  const dryRun = parsedArgs.apply !== true;
3592
4343
  const execute = async () => {
3593
- 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);
4344
+ const candidates = (await collectWorktreeSnapshot$1(repoRoot)).worktrees.filter((worktree) => worktree.branch !== null).filter((worktree) => worktree.path !== repoRoot).filter((worktree) => isManagedWorktreePath({
4345
+ worktreePath: worktree.path,
4346
+ managedWorktreeRoot
4347
+ })).filter((worktree) => worktree.dirty === false).filter((worktree) => worktree.locked.value === false).filter((worktree) => worktree.merged.overall === true).map((worktree) => worktree.branch);
3594
4348
  if (dryRun) return {
3595
4349
  deleted: [],
3596
4350
  candidates,
@@ -3695,7 +4449,7 @@ const createCli = (options = {}) => {
3695
4449
  repoRoot,
3696
4450
  action: "get",
3697
4451
  branch,
3698
- worktreePath: branchToWorktreePath(repoRoot, branch),
4452
+ worktreePath: branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot),
3699
4453
  stderr
3700
4454
  });
3701
4455
  await runPreHook({
@@ -3746,7 +4500,7 @@ const createCli = (options = {}) => {
3746
4500
  path: existing.path
3747
4501
  };
3748
4502
  }
3749
- const targetPath = branchToWorktreePath(repoRoot, branch);
4503
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3750
4504
  await ensureTargetPathWritable(targetPath);
3751
4505
  await runGitCommand({
3752
4506
  cwd: repoRoot,
@@ -3820,12 +4574,15 @@ const createCli = (options = {}) => {
3820
4574
  details: { path: sourceWorktree.path }
3821
4575
  });
3822
4576
  const branch = sourceWorktree.branch;
3823
- const baseBranch = await resolveBaseBranch(repoRoot);
4577
+ const baseBranch = await resolveBaseBranch({
4578
+ repoRoot,
4579
+ config: resolvedConfig
4580
+ });
3824
4581
  ensureBranchIsNotPrimary({
3825
4582
  branch,
3826
4583
  baseBranch
3827
4584
  });
3828
- const targetPath = branchToWorktreePath(repoRoot, branch);
4585
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3829
4586
  await ensureTargetPathWritable(targetPath);
3830
4587
  const dirty = (await runGitCommand({
3831
4588
  cwd: repoRoot,
@@ -3946,6 +4703,7 @@ const createCli = (options = {}) => {
3946
4703
  });
3947
4704
  const sourceWorktree = resolveManagedNonPrimaryWorktreeByBranch({
3948
4705
  repoRoot,
4706
+ managedWorktreeRoot,
3949
4707
  branch,
3950
4708
  worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees,
3951
4709
  optionName: "--from",
@@ -4080,6 +4838,7 @@ const createCli = (options = {}) => {
4080
4838
  });
4081
4839
  const targetWorktree = resolveManagedNonPrimaryWorktreeByBranch({
4082
4840
  repoRoot,
4841
+ managedWorktreeRoot,
4083
4842
  branch,
4084
4843
  worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees,
4085
4844
  optionName: "--to",
@@ -4367,17 +5126,14 @@ const createCli = (options = {}) => {
4367
5126
  min: 1,
4368
5127
  max: Number.MAX_SAFE_INTEGER
4369
5128
  });
4370
- const worktreePath = ensurePathInsideRepo({
4371
- repoRoot,
4372
- path: resolvePathFromCwd({
4373
- cwd: repoContext.currentWorktreeRoot,
4374
- path: process.env.WT_WORKTREE_PATH ?? "."
4375
- })
5129
+ const targetWorktreeRoot = resolveTargetWorktreeRootForCopyLink({
5130
+ repoContext,
5131
+ snapshot: await collectWorktreeSnapshot$1(repoRoot)
4376
5132
  });
4377
5133
  for (const relativePath of commandArgs) {
4378
5134
  const { sourcePath, destinationPath } = resolveFileCopyTargets({
4379
5135
  repoRoot,
4380
- worktreePath,
5136
+ targetWorktreeRoot,
4381
5137
  relativePath
4382
5138
  });
4383
5139
  await access(sourcePath, constants.F_OK);
@@ -4396,7 +5152,7 @@ const createCli = (options = {}) => {
4396
5152
  repoRoot,
4397
5153
  details: {
4398
5154
  copied: commandArgs,
4399
- worktreePath
5155
+ worktreePath: targetWorktreeRoot
4400
5156
  }
4401
5157
  })));
4402
5158
  return EXIT_CODE.OK;
@@ -4410,18 +5166,15 @@ const createCli = (options = {}) => {
4410
5166
  min: 1,
4411
5167
  max: Number.MAX_SAFE_INTEGER
4412
5168
  });
4413
- const worktreePath = ensurePathInsideRepo({
4414
- repoRoot,
4415
- path: resolvePathFromCwd({
4416
- cwd: repoContext.currentWorktreeRoot,
4417
- path: process.env.WT_WORKTREE_PATH ?? "."
4418
- })
5169
+ const targetWorktreeRoot = resolveTargetWorktreeRootForCopyLink({
5170
+ repoContext,
5171
+ snapshot: await collectWorktreeSnapshot$1(repoRoot)
4419
5172
  });
4420
5173
  const fallbackEnabled = parsedArgs.fallback !== false;
4421
5174
  for (const relativePath of commandArgs) {
4422
5175
  const { sourcePath, destinationPath } = resolveFileCopyTargets({
4423
5176
  repoRoot,
4424
- worktreePath,
5177
+ targetWorktreeRoot,
4425
5178
  relativePath
4426
5179
  });
4427
5180
  await access(sourcePath, constants.F_OK);
@@ -4459,7 +5212,7 @@ const createCli = (options = {}) => {
4459
5212
  repoRoot,
4460
5213
  details: {
4461
5214
  linked: commandArgs,
4462
- worktreePath,
5215
+ worktreePath: targetWorktreeRoot,
4463
5216
  fallback: fallbackEnabled
4464
5217
  }
4465
5218
  })));
@@ -4614,16 +5367,25 @@ const createCli = (options = {}) => {
4614
5367
  }));
4615
5368
  if (candidates.length === 0) throw createCliError("WORKTREE_NOT_FOUND", { message: "No worktree candidates found" });
4616
5369
  const promptValue = readStringOption(parsedArgsRecord, "prompt");
5370
+ const prompt = typeof promptValue === "string" && promptValue.length > 0 ? promptValue : resolvedConfig.selector.cd.prompt;
5371
+ const cliFzfExtraArgs = collectOptionValues({
5372
+ args: beforeDoubleDash,
5373
+ optionNames: ["fzfArg", "fzf-arg"]
5374
+ });
5375
+ const mergedConfigFzfArgs = mergeFzfArgs({
5376
+ defaults: resolvedConfig.selector.cd.fzf.extraArgs,
5377
+ extras: cliFzfExtraArgs
5378
+ });
5379
+ const surface = resolvedConfig.selector.cd.surface;
4617
5380
  const selection = await selectPathWithFzf$1({
4618
5381
  candidates,
4619
- prompt: typeof promptValue === "string" && promptValue.length > 0 ? promptValue : "worktree> ",
5382
+ prompt,
4620
5383
  fzfExtraArgs: mergeFzfArgs({
4621
5384
  defaults: CD_FZF_EXTRA_ARGS,
4622
- extras: collectOptionValues({
4623
- args: beforeDoubleDash,
4624
- optionNames: ["fzfArg", "fzf-arg"]
4625
- })
5385
+ extras: mergedConfigFzfArgs
4626
5386
  }),
5387
+ surface,
5388
+ tmuxPopupOpts: resolvedConfig.selector.cd.tmuxPopupOpts,
4627
5389
  cwd: repoRoot,
4628
5390
  isInteractive: () => runtime.isInteractive || process.stderr.isTTY === true
4629
5391
  }).catch((error) => {