vde-worktree 0.0.19 → 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
  };
@@ -1018,36 +1743,6 @@ const listGitWorktrees = async (repoRoot) => {
1018
1743
 
1019
1744
  //#endregion
1020
1745
  //#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;
1050
- };
1051
1746
  const resolveDirty = async (worktreePath) => {
1052
1747
  return (await runGitCommand({
1053
1748
  cwd: worktreePath,
@@ -1316,17 +2011,13 @@ const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStateByBranch
1316
2011
  upstream
1317
2012
  };
1318
2013
  };
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
- ]);
2014
+ const collectWorktreeSnapshot = async (repoRoot, { baseBranch = null, ghEnabled = true, noGh = false } = {}) => {
2015
+ const worktrees = await listGitWorktrees(repoRoot);
1325
2016
  const prStateByBranch = await resolvePrStateByBranchBatch({
1326
2017
  repoRoot,
1327
2018
  baseBranch,
1328
2019
  branches: worktrees.map((worktree) => worktree.branch),
1329
- enabled: enableGh && noGh !== true
2020
+ enabled: ghEnabled && noGh !== true
1330
2021
  });
1331
2022
  return {
1332
2023
  repoRoot,
@@ -1350,7 +2041,8 @@ const RESERVED_FZF_ARGS = new Set([
1350
2041
  "prompt",
1351
2042
  "layout",
1352
2043
  "height",
1353
- "border"
2044
+ "border",
2045
+ "tmux"
1354
2046
  ]);
1355
2047
  const ANSI_ESCAPE_SEQUENCE_PATTERN = String.raw`\u001B\[[0-?]*[ -/]*[@-~]`;
1356
2048
  const ANSI_ESCAPE_SEQUENCE_REGEX = new RegExp(ANSI_ESCAPE_SEQUENCE_PATTERN, "g");
@@ -1390,6 +2082,13 @@ const defaultCheckFzfAvailability = async () => {
1390
2082
  throw error;
1391
2083
  }
1392
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
+ };
1393
2092
  const defaultRunFzf = async ({ args, input, cwd, env }) => {
1394
2093
  return { stdout: (await execa(FZF_BINARY, args, {
1395
2094
  input,
@@ -1402,20 +2101,46 @@ const ensureFzfAvailable = async (checkFzfAvailability) => {
1402
2101
  if (await checkFzfAvailability()) return;
1403
2102
  throw new Error("fzf is required for interactive selection");
1404
2103
  };
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 }) => {
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 }) => {
1406
2125
  if (candidates.length === 0) throw new Error("No candidates provided for fzf selection");
1407
2126
  if (isInteractive() !== true) throw new Error("fzf selection requires an interactive terminal");
1408
2127
  await ensureFzfAvailable(checkFzfAvailability);
1409
- const args = buildFzfArgs({
2128
+ const baseArgs = buildFzfArgs({
1410
2129
  prompt,
1411
2130
  fzfExtraArgs
1412
2131
  });
2132
+ const tryTmuxPopup = await shouldTryTmuxPopup({
2133
+ surface,
2134
+ env,
2135
+ checkFzfTmuxSupport
2136
+ });
2137
+ const args = tryTmuxPopup ? [...baseArgs, `--tmux=${tmuxPopupOpts}`] : baseArgs;
1413
2138
  const input = buildFzfInput(candidates);
1414
2139
  if (input.length === 0) throw new Error("All candidates are empty after sanitization");
1415
2140
  const candidateSet = new Set(input.split("\n").map((candidate) => stripAnsi(candidate)));
1416
- try {
2141
+ const runWithValidation = async (fzfArgs) => {
1417
2142
  const selectedPath = stripAnsi(stripTrailingNewlines((await runFzf({
1418
- args,
2143
+ args: fzfArgs,
1419
2144
  input,
1420
2145
  cwd,
1421
2146
  env
@@ -1426,7 +2151,16 @@ const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", fzfExtraAr
1426
2151
  status: "selected",
1427
2152
  path: selectedPath
1428
2153
  };
2154
+ };
2155
+ try {
2156
+ return await runWithValidation(args);
1429
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
+ }
1430
2164
  if (error.exitCode === 130) return { status: "cancelled" };
1431
2165
  throw error;
1432
2166
  }
@@ -1514,9 +2248,7 @@ const CD_FZF_EXTRA_ARGS = [
1514
2248
  "--preview-window=right,60%,wrap",
1515
2249
  "--ansi"
1516
2250
  ];
1517
- const LIST_TABLE_COLUMN_COUNT = 8;
1518
- const LIST_TABLE_PATH_COLUMN_INDEX = 7;
1519
- const LIST_TABLE_PATH_MIN_WIDTH = 12;
2251
+ const DEFAULT_LIST_TABLE_COLUMNS = [...LIST_TABLE_COLUMNS];
1520
2252
  const LIST_TABLE_CELL_HORIZONTAL_PADDING = 2;
1521
2253
  const COMPLETION_SHELLS = ["zsh", "fish"];
1522
2254
  const COMPLETION_FILE_BY_SHELL = {
@@ -1538,6 +2270,10 @@ const CATPPUCCIN_MOCHA = {
1538
2270
  overlay0: "#6c7086"
1539
2271
  };
1540
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
+ };
1541
2277
  const createCatppuccinTheme = ({ enabled }) => {
1542
2278
  if (enabled !== true) return {
1543
2279
  header: identityColor,
@@ -1985,85 +2721,29 @@ const ensureArgumentCount = ({ command, args, min, max }) => {
1985
2721
  const ensureHasCommandAfterDoubleDash = ({ command, argsAfterDoubleDash }) => {
1986
2722
  if (argsAfterDoubleDash.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `${command} requires arguments after --` });
1987
2723
  };
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({
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({
2020
2728
  cwd: repoRoot,
2021
2729
  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"
2730
+ "symbolic-ref",
2731
+ "--quiet",
2732
+ "--short",
2733
+ `refs/remotes/${remote}/HEAD`
2038
2734
  ],
2039
2735
  reject: false
2040
2736
  });
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;
2737
+ if (resolved.exitCode === 0) {
2061
2738
  const raw = resolved.stdout.trim();
2062
2739
  const prefix = `${remote}/`;
2063
2740
  if (raw.startsWith(prefix)) return raw.slice(prefix.length);
2064
2741
  }
2065
2742
  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." });
2743
+ throw createCliError("INVALID_ARGUMENT", {
2744
+ message: "Unable to resolve base branch from config.yml (baseRemote/HEAD -> main/master).",
2745
+ details: { remote }
2746
+ });
2067
2747
  };
2068
2748
  const ensureTargetPathWritable = async (targetPath) => {
2069
2749
  try {
@@ -2256,7 +2936,7 @@ const validateDeleteSafety = ({ target, forceFlags }) => {
2256
2936
  const resolveLinkTargetPath = ({ sourcePath, destinationPath }) => {
2257
2937
  return relative(dirname(destinationPath), sourcePath);
2258
2938
  };
2259
- const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
2939
+ const resolveFileCopyTargets = ({ repoRoot, targetWorktreeRoot, relativePath }) => {
2260
2940
  const sourcePath = resolveRepoRelativePath({
2261
2941
  repoRoot,
2262
2942
  relativePath
@@ -2264,13 +2944,32 @@ const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
2264
2944
  const relativeFromRoot = relative(repoRoot, sourcePath);
2265
2945
  return {
2266
2946
  sourcePath,
2267
- destinationPath: ensurePathInsideRepo({
2268
- repoRoot,
2269
- path: resolve(worktreePath, relativeFromRoot)
2947
+ destinationPath: ensurePathInsideRoot({
2948
+ rootPath: targetWorktreeRoot,
2949
+ path: resolve(targetWorktreeRoot, relativeFromRoot),
2950
+ message: "Path is outside target worktree root"
2270
2951
  }),
2271
2952
  relativeFromRoot
2272
2953
  };
2273
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
+ };
2274
2973
  const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
2275
2974
  if (branch !== baseBranch) return;
2276
2975
  throw createCliError("INVALID_ARGUMENT", {
@@ -2281,12 +2980,14 @@ const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
2281
2980
  }
2282
2981
  });
2283
2982
  };
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("/");
2983
+ const toManagedWorktreeName = ({ managedWorktreeRoot, worktreePath }) => {
2984
+ if (isManagedWorktreePath({
2985
+ worktreePath,
2986
+ managedWorktreeRoot
2987
+ }) !== true) return null;
2988
+ return relative(managedWorktreeRoot, worktreePath).split(sep).join("/");
2288
2989
  };
2289
- const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName }) => {
2990
+ const resolveManagedWorktreePathFromName = ({ managedWorktreeRoot, optionName, worktreeName }) => {
2290
2991
  const normalized = worktreeName.trim();
2291
2992
  if (normalized.length === 0) throw createCliError("INVALID_ARGUMENT", {
2292
2993
  message: `${optionName} requires non-empty worktree name`,
@@ -2295,18 +2996,10 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2295
2996
  worktreeName
2296
2997
  }
2297
2998
  });
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
2999
  let resolvedPath;
2307
3000
  try {
2308
3001
  resolvedPath = resolveRepoRelativePath({
2309
- repoRoot: worktreeRoot,
3002
+ repoRoot: managedWorktreeRoot,
2310
3003
  relativePath: normalized
2311
3004
  });
2312
3005
  } catch (error) {
@@ -2319,7 +3012,7 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2319
3012
  cause: error
2320
3013
  });
2321
3014
  }
2322
- if (resolvedPath === worktreeRoot) throw createCliError("INVALID_ARGUMENT", {
3015
+ if (resolvedPath === managedWorktreeRoot) throw createCliError("INVALID_ARGUMENT", {
2323
3016
  message: `${optionName} expects vw-managed worktree name`,
2324
3017
  details: {
2325
3018
  optionName,
@@ -2328,16 +3021,16 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
2328
3021
  });
2329
3022
  return resolvedPath;
2330
3023
  };
2331
- const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, branch, worktrees, optionName, worktreeName, role }) => {
3024
+ const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, managedWorktreeRoot, branch, worktrees, optionName, worktreeName, role }) => {
2332
3025
  const managedCandidates = worktrees.filter((worktree) => {
2333
3026
  return worktree.branch === branch && worktree.path !== repoRoot && toManagedWorktreeName({
2334
- repoRoot,
3027
+ managedWorktreeRoot,
2335
3028
  worktreePath: worktree.path
2336
3029
  }) !== null;
2337
3030
  });
2338
3031
  if (typeof worktreeName === "string") {
2339
3032
  const resolvedPath = resolveManagedWorktreePathFromName({
2340
- repoRoot,
3033
+ managedWorktreeRoot,
2341
3034
  optionName,
2342
3035
  worktreeName
2343
3036
  });
@@ -2368,7 +3061,7 @@ const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, branch, worktrees,
2368
3061
  optionName,
2369
3062
  candidates: managedCandidates.map((worktree) => {
2370
3063
  return toManagedWorktreeName({
2371
- repoRoot,
3064
+ managedWorktreeRoot,
2372
3065
  worktreePath: worktree.path
2373
3066
  }) ?? worktree.path;
2374
3067
  })
@@ -2525,19 +3218,24 @@ const resolveListColumnContentWidth = ({ rows, columnIndex }) => {
2525
3218
  return Math.max(width, stringWidth(cell));
2526
3219
  }, 0);
2527
3220
  };
2528
- const resolveListPathColumnWidth = ({ rows, disablePathTruncation }) => {
2529
- 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;
2530
3225
  if (process.stdout.isTTY !== true) return null;
2531
3226
  const terminalColumns = process.stdout.columns;
2532
3227
  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;
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;
2539
3237
  const availablePathWidth = Math.floor(terminalColumns) - borderWidth - paddingWidth - measuredNonPathWidth;
2540
- return Math.max(LIST_TABLE_PATH_MIN_WIDTH, availablePathWidth);
3238
+ return Math.max(minWidth, availablePathWidth);
2541
3239
  };
2542
3240
  const resolveAheadBehindAgainstBaseBranch = async ({ repoRoot, baseBranch, worktree }) => {
2543
3241
  if (baseBranch === null) return {
@@ -2878,7 +3576,7 @@ const createCli = (options = {}) => {
2878
3576
  from: {
2879
3577
  type: "string",
2880
3578
  valueHint: "value",
2881
- description: "For extract: filesystem path. For absorb: managed worktree name without .worktree/ prefix."
3579
+ description: "For extract: filesystem path. For absorb: managed worktree name."
2882
3580
  },
2883
3581
  to: {
2884
3582
  type: "string",
@@ -3027,39 +3725,47 @@ const createCli = (options = {}) => {
3027
3725
  const repoContext = await resolveRepoContext(runtimeCwd);
3028
3726
  const repoRoot = repoContext.repoRoot;
3029
3727
  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");
3728
+ const { config: resolvedConfig } = await loadResolvedConfig({
3729
+ cwd: runtimeCwd,
3730
+ repoRoot
3731
+ });
3732
+ const managedWorktreeRoot = getWorktreeRootPath(repoRoot, resolvedConfig.paths.worktreeRoot);
3034
3733
  const runtime = {
3035
3734
  command,
3036
3735
  json: jsonEnabled,
3037
- hooksEnabled: parsedArgs.hooks !== false && configuredHooksEnabled !== false,
3038
- ghEnabled: parsedArgs.gh !== false,
3736
+ hooksEnabled: parsedArgs.hooks !== false && resolvedConfig.hooks.enabled,
3737
+ ghEnabled: parsedArgs.gh !== false && resolvedConfig.github.enabled,
3039
3738
  strictPostHooks: parsedArgs.strictPostHooks === true,
3040
3739
  hookTimeoutMs: readNumberFromEnvOrDefault({
3041
3740
  rawValue: toNumberOption({
3042
3741
  value: parsedArgs.hookTimeoutMs,
3043
3742
  optionName: "--hook-timeout-ms"
3044
- }) ?? configuredHookTimeoutMs,
3743
+ }) ?? resolvedConfig.hooks.timeoutMs,
3045
3744
  defaultValue: DEFAULT_HOOK_TIMEOUT_MS
3046
3745
  }),
3047
3746
  lockTimeoutMs: readNumberFromEnvOrDefault({
3048
3747
  rawValue: toNumberOption({
3049
3748
  value: parsedArgs.lockTimeoutMs,
3050
3749
  optionName: "--lock-timeout-ms"
3051
- }) ?? configuredLockTimeoutMs,
3750
+ }) ?? resolvedConfig.locks.timeoutMs,
3052
3751
  defaultValue: DEFAULT_LOCK_TIMEOUT_MS
3053
3752
  }),
3054
3753
  allowUnsafe,
3055
3754
  isInteractive: isInteractiveFn()
3056
3755
  };
3057
3756
  const staleLockTTLSeconds = readNumberFromEnvOrDefault({
3058
- rawValue: configuredStaleTTL,
3757
+ rawValue: resolvedConfig.locks.staleLockTTLSeconds,
3059
3758
  defaultValue: DEFAULT_STALE_LOCK_TTL_SECONDS
3060
3759
  });
3061
3760
  const collectWorktreeSnapshot$1 = async (_ignoredRepoRoot) => {
3062
- 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
+ });
3063
3769
  };
3064
3770
  const runWriteOperation = async (task) => {
3065
3771
  if (WRITE_COMMANDS.has(command) !== true) return task();
@@ -3091,7 +3797,10 @@ const createCli = (options = {}) => {
3091
3797
  name: "init",
3092
3798
  context: hookContext
3093
3799
  });
3094
- const initialized = await initializeRepository(repoRoot);
3800
+ const initialized = await initializeRepository({
3801
+ repoRoot,
3802
+ managedWorktreeRoot
3803
+ });
3095
3804
  await runPostHook({
3096
3805
  name: "init",
3097
3806
  context: hookContext
@@ -3127,22 +3836,15 @@ const createCli = (options = {}) => {
3127
3836
  repoRoot,
3128
3837
  details: {
3129
3838
  baseBranch: snapshot.baseBranch,
3839
+ managedWorktreeRoot,
3130
3840
  worktrees: snapshot.worktrees
3131
3841
  }
3132
3842
  })));
3133
3843
  return EXIT_CODE.OK;
3134
3844
  }
3135
3845
  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) => {
3846
+ const columns = resolvedConfig.list.table.columns;
3847
+ const rows = [[...columns], ...await Promise.all(snapshot.worktrees.map(async (worktree) => {
3146
3848
  const distanceFromBase = await resolveAheadBehindAgainstBaseBranch({
3147
3849
  repoRoot,
3148
3850
  baseBranch: snapshot.baseBranch,
@@ -3154,35 +3856,41 @@ const createCli = (options = {}) => {
3154
3856
  prStatus: worktree.pr.status,
3155
3857
  isBaseBranch
3156
3858
  });
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
- ];
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]);
3167
3870
  }))];
3168
3871
  const pathColumnWidth = resolveListPathColumnWidth({
3169
3872
  rows,
3170
- 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
3171
3877
  });
3172
- 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]: {
3173
3880
  width: pathColumnWidth,
3174
3881
  truncate: pathColumnWidth
3175
3882
  } };
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
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
3185
3889
  });
3890
+ const colorized = hasDefaultListColumnOrder(columns) ? colorizeListTable({
3891
+ rendered,
3892
+ theme
3893
+ }) : rendered.trimEnd();
3186
3894
  for (const line of colorized.split("\n")) stdout(line);
3187
3895
  return EXIT_CODE.OK;
3188
3896
  }
@@ -3264,9 +3972,12 @@ const createCli = (options = {}) => {
3264
3972
  message: `Branch already exists locally: ${branch}`,
3265
3973
  details: { branch }
3266
3974
  });
3267
- const targetPath = branchToWorktreePath(repoRoot, branch);
3975
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3268
3976
  await ensureTargetPathWritable(targetPath);
3269
- const baseBranch = await resolveBaseBranch(repoRoot);
3977
+ const baseBranch = await resolveBaseBranch({
3978
+ repoRoot,
3979
+ config: resolvedConfig
3980
+ });
3270
3981
  const hookContext = createHookContext({
3271
3982
  runtime,
3272
3983
  repoRoot,
@@ -3341,7 +4052,7 @@ const createCli = (options = {}) => {
3341
4052
  path: existing.path
3342
4053
  };
3343
4054
  }
3344
- const targetPath = branchToWorktreePath(repoRoot, branch);
4055
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3345
4056
  await ensureTargetPathWritable(targetPath);
3346
4057
  const hookContext = createHookContext({
3347
4058
  runtime,
@@ -3366,7 +4077,10 @@ const createCli = (options = {}) => {
3366
4077
  ]
3367
4078
  });
3368
4079
  else {
3369
- const baseBranch = await resolveBaseBranch(repoRoot);
4080
+ const baseBranch = await resolveBaseBranch({
4081
+ repoRoot,
4082
+ config: resolvedConfig
4083
+ });
3370
4084
  lifecycleBaseBranch = baseBranch;
3371
4085
  await runGitCommand({
3372
4086
  cwd: repoRoot,
@@ -3449,7 +4163,7 @@ const createCli = (options = {}) => {
3449
4163
  message: `Branch already exists locally: ${newBranch}`,
3450
4164
  details: { branch: newBranch }
3451
4165
  });
3452
- const newPath = branchToWorktreePath(repoRoot, newBranch);
4166
+ const newPath = branchToWorktreePath(repoRoot, newBranch, resolvedConfig.paths.worktreeRoot);
3453
4167
  await ensureTargetPathWritable(newPath);
3454
4168
  const hookContext = createHookContext({
3455
4169
  runtime,
@@ -3543,6 +4257,17 @@ const createCli = (options = {}) => {
3543
4257
  message: "Cannot delete the primary worktree",
3544
4258
  details: { path: target.path }
3545
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
+ });
3546
4271
  validateDeleteSafety({
3547
4272
  target,
3548
4273
  forceFlags
@@ -3616,7 +4341,10 @@ const createCli = (options = {}) => {
3616
4341
  if (parsedArgs.apply === true && parsedArgs.dryRun === true) throw createCliError("INVALID_ARGUMENT", { message: "Cannot use --apply and --dry-run together" });
3617
4342
  const dryRun = parsedArgs.apply !== true;
3618
4343
  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);
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);
3620
4348
  if (dryRun) return {
3621
4349
  deleted: [],
3622
4350
  candidates,
@@ -3721,7 +4449,7 @@ const createCli = (options = {}) => {
3721
4449
  repoRoot,
3722
4450
  action: "get",
3723
4451
  branch,
3724
- worktreePath: branchToWorktreePath(repoRoot, branch),
4452
+ worktreePath: branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot),
3725
4453
  stderr
3726
4454
  });
3727
4455
  await runPreHook({
@@ -3772,7 +4500,7 @@ const createCli = (options = {}) => {
3772
4500
  path: existing.path
3773
4501
  };
3774
4502
  }
3775
- const targetPath = branchToWorktreePath(repoRoot, branch);
4503
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3776
4504
  await ensureTargetPathWritable(targetPath);
3777
4505
  await runGitCommand({
3778
4506
  cwd: repoRoot,
@@ -3846,12 +4574,15 @@ const createCli = (options = {}) => {
3846
4574
  details: { path: sourceWorktree.path }
3847
4575
  });
3848
4576
  const branch = sourceWorktree.branch;
3849
- const baseBranch = await resolveBaseBranch(repoRoot);
4577
+ const baseBranch = await resolveBaseBranch({
4578
+ repoRoot,
4579
+ config: resolvedConfig
4580
+ });
3850
4581
  ensureBranchIsNotPrimary({
3851
4582
  branch,
3852
4583
  baseBranch
3853
4584
  });
3854
- const targetPath = branchToWorktreePath(repoRoot, branch);
4585
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3855
4586
  await ensureTargetPathWritable(targetPath);
3856
4587
  const dirty = (await runGitCommand({
3857
4588
  cwd: repoRoot,
@@ -3972,6 +4703,7 @@ const createCli = (options = {}) => {
3972
4703
  });
3973
4704
  const sourceWorktree = resolveManagedNonPrimaryWorktreeByBranch({
3974
4705
  repoRoot,
4706
+ managedWorktreeRoot,
3975
4707
  branch,
3976
4708
  worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees,
3977
4709
  optionName: "--from",
@@ -4106,6 +4838,7 @@ const createCli = (options = {}) => {
4106
4838
  });
4107
4839
  const targetWorktree = resolveManagedNonPrimaryWorktreeByBranch({
4108
4840
  repoRoot,
4841
+ managedWorktreeRoot,
4109
4842
  branch,
4110
4843
  worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees,
4111
4844
  optionName: "--to",
@@ -4393,17 +5126,14 @@ const createCli = (options = {}) => {
4393
5126
  min: 1,
4394
5127
  max: Number.MAX_SAFE_INTEGER
4395
5128
  });
4396
- const worktreePath = ensurePathInsideRepo({
4397
- repoRoot,
4398
- path: resolvePathFromCwd({
4399
- cwd: repoContext.currentWorktreeRoot,
4400
- path: process.env.WT_WORKTREE_PATH ?? "."
4401
- })
5129
+ const targetWorktreeRoot = resolveTargetWorktreeRootForCopyLink({
5130
+ repoContext,
5131
+ snapshot: await collectWorktreeSnapshot$1(repoRoot)
4402
5132
  });
4403
5133
  for (const relativePath of commandArgs) {
4404
5134
  const { sourcePath, destinationPath } = resolveFileCopyTargets({
4405
5135
  repoRoot,
4406
- worktreePath,
5136
+ targetWorktreeRoot,
4407
5137
  relativePath
4408
5138
  });
4409
5139
  await access(sourcePath, constants.F_OK);
@@ -4422,7 +5152,7 @@ const createCli = (options = {}) => {
4422
5152
  repoRoot,
4423
5153
  details: {
4424
5154
  copied: commandArgs,
4425
- worktreePath
5155
+ worktreePath: targetWorktreeRoot
4426
5156
  }
4427
5157
  })));
4428
5158
  return EXIT_CODE.OK;
@@ -4436,18 +5166,15 @@ const createCli = (options = {}) => {
4436
5166
  min: 1,
4437
5167
  max: Number.MAX_SAFE_INTEGER
4438
5168
  });
4439
- const worktreePath = ensurePathInsideRepo({
4440
- repoRoot,
4441
- path: resolvePathFromCwd({
4442
- cwd: repoContext.currentWorktreeRoot,
4443
- path: process.env.WT_WORKTREE_PATH ?? "."
4444
- })
5169
+ const targetWorktreeRoot = resolveTargetWorktreeRootForCopyLink({
5170
+ repoContext,
5171
+ snapshot: await collectWorktreeSnapshot$1(repoRoot)
4445
5172
  });
4446
5173
  const fallbackEnabled = parsedArgs.fallback !== false;
4447
5174
  for (const relativePath of commandArgs) {
4448
5175
  const { sourcePath, destinationPath } = resolveFileCopyTargets({
4449
5176
  repoRoot,
4450
- worktreePath,
5177
+ targetWorktreeRoot,
4451
5178
  relativePath
4452
5179
  });
4453
5180
  await access(sourcePath, constants.F_OK);
@@ -4485,7 +5212,7 @@ const createCli = (options = {}) => {
4485
5212
  repoRoot,
4486
5213
  details: {
4487
5214
  linked: commandArgs,
4488
- worktreePath,
5215
+ worktreePath: targetWorktreeRoot,
4489
5216
  fallback: fallbackEnabled
4490
5217
  }
4491
5218
  })));
@@ -4640,16 +5367,25 @@ const createCli = (options = {}) => {
4640
5367
  }));
4641
5368
  if (candidates.length === 0) throw createCliError("WORKTREE_NOT_FOUND", { message: "No worktree candidates found" });
4642
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;
4643
5380
  const selection = await selectPathWithFzf$1({
4644
5381
  candidates,
4645
- prompt: typeof promptValue === "string" && promptValue.length > 0 ? promptValue : "worktree> ",
5382
+ prompt,
4646
5383
  fzfExtraArgs: mergeFzfArgs({
4647
5384
  defaults: CD_FZF_EXTRA_ARGS,
4648
- extras: collectOptionValues({
4649
- args: beforeDoubleDash,
4650
- optionNames: ["fzfArg", "fzf-arg"]
4651
- })
5385
+ extras: mergedConfigFzfArgs
4652
5386
  }),
5387
+ surface,
5388
+ tmuxPopupOpts: resolvedConfig.selector.cd.tmuxPopupOpts,
4653
5389
  cwd: repoRoot,
4654
5390
  isInteractive: () => runtime.isInteractive || process.stderr.isTTY === true
4655
5391
  }).catch((error) => {