@jhorst11/wt 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,736 @@
1
+ import { select, input, confirm, search } from '@inquirer/prompts';
2
+ import ora from 'ora';
3
+ import chalk from 'chalk';
4
+ import {
5
+ showLogo,
6
+ showMiniLogo,
7
+ success,
8
+ error,
9
+ warning,
10
+ info,
11
+ heading,
12
+ subheading,
13
+ listItem,
14
+ worktreeItem,
15
+ divider,
16
+ spacer,
17
+ colors,
18
+ icons,
19
+ formatBranchChoice,
20
+ } from './ui.js';
21
+ import { showCdHint } from './setup.js';
22
+ import {
23
+ isGitRepo,
24
+ getRepoRoot,
25
+ getCurrentBranch,
26
+ getLocalBranches,
27
+ getRemoteBranches,
28
+ getAllBranches,
29
+ getWorktreesInBase,
30
+ getMainRepoPath,
31
+ createWorktree,
32
+ removeWorktree,
33
+ buildBranchName,
34
+ isValidBranchName,
35
+ getConfig,
36
+ getWorktreesBase,
37
+ mergeBranch,
38
+ getMainBranch,
39
+ hasUncommittedChanges,
40
+ deleteBranch,
41
+ } from './git.js';
42
+
43
+ async function ensureGitRepo() {
44
+ if (!(await isGitRepo())) {
45
+ error('Not in a git repository');
46
+ process.exit(1);
47
+ }
48
+ }
49
+
50
+ export async function mainMenu() {
51
+ showLogo();
52
+
53
+ await ensureGitRepo();
54
+
55
+ const repoRoot = await getRepoRoot();
56
+ const currentBranch = await getCurrentBranch();
57
+ const worktrees = await getWorktreesInBase(repoRoot);
58
+
59
+ subheading(` 📍 ${colors.path(repoRoot)}`);
60
+ subheading(` 🌿 ${colors.branch(currentBranch)}`);
61
+ spacer();
62
+
63
+ const choices = [
64
+ {
65
+ name: `${icons.plus} Create new worktree`,
66
+ value: 'new',
67
+ description: 'Create a new worktree from a branch',
68
+ },
69
+ {
70
+ name: `${icons.folder} List worktrees`,
71
+ value: 'list',
72
+ description: 'View all worktrees for this repo',
73
+ },
74
+ {
75
+ name: `${icons.trash} Remove worktree`,
76
+ value: 'remove',
77
+ description: 'Delete a worktree',
78
+ },
79
+ {
80
+ name: `${icons.home} Go home`,
81
+ value: 'home',
82
+ description: 'Return to the main repository',
83
+ },
84
+ ];
85
+
86
+ if (worktrees.length > 0) {
87
+ choices.splice(1, 0, {
88
+ name: `${icons.rocket} Jump to worktree`,
89
+ value: 'go',
90
+ description: 'Switch to an existing worktree',
91
+ });
92
+ choices.splice(3, 0, {
93
+ name: `🔀 Merge worktree`,
94
+ value: 'merge',
95
+ description: 'Merge a worktree branch back to main',
96
+ });
97
+ }
98
+
99
+ choices.push({
100
+ name: `${colors.muted(icons.cross + ' Exit')}`,
101
+ value: 'exit',
102
+ });
103
+
104
+ const action = await select({
105
+ message: 'What would you like to do?',
106
+ choices,
107
+ theme: {
108
+ prefix: icons.tree,
109
+ style: {
110
+ highlight: (text) => colors.primary(text),
111
+ },
112
+ },
113
+ });
114
+
115
+ switch (action) {
116
+ case 'new':
117
+ await createWorktreeFlow();
118
+ break;
119
+ case 'list':
120
+ await listWorktrees();
121
+ break;
122
+ case 'remove':
123
+ await removeWorktreeFlow();
124
+ break;
125
+ case 'merge':
126
+ await mergeWorktreeFlow();
127
+ break;
128
+ case 'home':
129
+ await goHome();
130
+ break;
131
+ case 'go':
132
+ await goToWorktree();
133
+ break;
134
+ case 'exit':
135
+ spacer();
136
+ info('Goodbye! ' + icons.sparkles);
137
+ spacer();
138
+ break;
139
+ }
140
+ }
141
+
142
+ export async function createWorktreeFlow() {
143
+ showMiniLogo();
144
+ await ensureGitRepo();
145
+
146
+ heading(`${icons.plus} Create New Worktree`);
147
+
148
+ const currentBranch = await getCurrentBranch();
149
+ const repoRoot = await getRepoRoot();
150
+
151
+ // Step 1: Choose source type
152
+ const sourceType = await select({
153
+ message: 'What do you want to base your worktree on?',
154
+ choices: [
155
+ {
156
+ name: `${icons.branch} Current branch (${colors.branch(currentBranch)})`,
157
+ value: 'current',
158
+ description: 'Create from your current branch',
159
+ },
160
+ {
161
+ name: `${icons.local} Local branch`,
162
+ value: 'local',
163
+ description: 'Choose from existing local branches',
164
+ },
165
+ {
166
+ name: `${icons.remote} Remote branch`,
167
+ value: 'remote',
168
+ description: 'Choose from remote branches',
169
+ },
170
+ {
171
+ name: `${icons.sparkles} New branch`,
172
+ value: 'new',
173
+ description: 'Create a fresh branch from a base',
174
+ },
175
+ ],
176
+ theme: {
177
+ prefix: icons.tree,
178
+ },
179
+ });
180
+
181
+ let baseBranch = null;
182
+ let branchName = null;
183
+ let worktreeName = null;
184
+
185
+ if (sourceType === 'current') {
186
+ baseBranch = currentBranch;
187
+ } else if (sourceType === 'local') {
188
+ const branches = await getLocalBranches();
189
+ if (branches.length === 0) {
190
+ error('No local branches found');
191
+ return;
192
+ }
193
+
194
+ const branchChoices = branches.map((b) => ({
195
+ name: formatBranchChoice(b.name, 'local'),
196
+ value: b.name,
197
+ description: b.isCurrent ? '(current)' : undefined,
198
+ }));
199
+
200
+ baseBranch = await select({
201
+ message: 'Select a local branch:',
202
+ choices: branchChoices,
203
+ theme: { prefix: icons.local },
204
+ });
205
+ } else if (sourceType === 'remote') {
206
+ const spinner = ora({
207
+ text: 'Fetching remote branches...',
208
+ color: 'magenta',
209
+ }).start();
210
+
211
+ const remoteBranches = await getRemoteBranches();
212
+ spinner.stop();
213
+
214
+ if (remoteBranches.length === 0) {
215
+ error('No remote branches found');
216
+ return;
217
+ }
218
+
219
+ // Use search for large branch lists
220
+ if (remoteBranches.length > 10) {
221
+ baseBranch = await search({
222
+ message: 'Search for a remote branch:',
223
+ source: async (term) => {
224
+ const filtered = term
225
+ ? remoteBranches.filter((b) => b.name.toLowerCase().includes(term.toLowerCase()))
226
+ : remoteBranches.slice(0, 15);
227
+ return filtered.map((b) => ({
228
+ name: formatBranchChoice(b.name, 'remote'),
229
+ value: `origin/${b.name}`,
230
+ }));
231
+ },
232
+ theme: { prefix: icons.remote },
233
+ });
234
+ } else {
235
+ const branchChoices = remoteBranches.map((b) => ({
236
+ name: formatBranchChoice(b.name, 'remote'),
237
+ value: `origin/${b.name}`,
238
+ }));
239
+
240
+ baseBranch = await select({
241
+ message: 'Select a remote branch:',
242
+ choices: branchChoices,
243
+ theme: { prefix: icons.remote },
244
+ });
245
+ }
246
+ } else if (sourceType === 'new') {
247
+ const branches = await getAllBranches();
248
+ const allChoices = branches.all.map((b) => ({
249
+ name: formatBranchChoice(b.name, b.type),
250
+ value: b.type === 'remote' ? `origin/${b.name}` : b.name,
251
+ }));
252
+
253
+ baseBranch = await select({
254
+ message: 'Select base branch for your new branch:',
255
+ choices: allChoices,
256
+ theme: { prefix: icons.branch },
257
+ });
258
+ }
259
+
260
+ // Step 2: Get worktree name
261
+ spacer();
262
+
263
+ worktreeName = await input({
264
+ message: 'Worktree name (also used as branch name):',
265
+ theme: { prefix: icons.folder },
266
+ validate: (value) => {
267
+ if (!value.trim()) return 'Name is required';
268
+ if (!isValidBranchName(value.trim())) return 'Invalid name (avoid spaces and special characters)';
269
+ return true;
270
+ },
271
+ transformer: (value) => colors.highlight(value),
272
+ });
273
+
274
+ worktreeName = worktreeName.trim().replace(/ /g, '-');
275
+
276
+ // Build branch name with optional prefix
277
+ const config = getConfig();
278
+ branchName = buildBranchName(worktreeName, config.branchPrefix);
279
+
280
+ // Step 3: Confirm
281
+ spacer();
282
+ divider();
283
+ info(`Worktree: ${colors.highlight(worktreeName)}`);
284
+ info(`Branch: ${colors.branch(branchName)}`);
285
+ info(`Base: ${colors.muted(baseBranch || 'HEAD')}`);
286
+ info(`Path: ${colors.path(getWorktreesBase(repoRoot) + '/' + worktreeName)}`);
287
+ divider();
288
+ spacer();
289
+
290
+ const confirmed = await confirm({
291
+ message: 'Create this worktree?',
292
+ default: true,
293
+ theme: { prefix: icons.tree },
294
+ });
295
+
296
+ if (!confirmed) {
297
+ warning('Cancelled');
298
+ return;
299
+ }
300
+
301
+ // Step 4: Create worktree
302
+ spacer();
303
+ const spinner = ora({
304
+ text: 'Creating worktree...',
305
+ color: 'magenta',
306
+ }).start();
307
+
308
+ try {
309
+ const result = await createWorktree(worktreeName, branchName, baseBranch);
310
+
311
+ if (!result.success) {
312
+ spinner.fail(colors.error('Failed to create worktree'));
313
+ error(result.error);
314
+ return;
315
+ }
316
+
317
+ spinner.succeed(colors.success('Worktree created!'));
318
+ spacer();
319
+
320
+ success(`Created worktree at ${colors.path(result.path)}`);
321
+ if (result.branchCreated) {
322
+ success(`Created new branch ${colors.branch(branchName)}`);
323
+ } else {
324
+ info(`Using existing branch ${colors.branch(branchName)} (${result.branchSource})`);
325
+ }
326
+
327
+ showCdHint(result.path);
328
+ } catch (err) {
329
+ spinner.fail(colors.error('Failed to create worktree'));
330
+ error(err.message);
331
+ }
332
+ }
333
+
334
+ export async function listWorktrees() {
335
+ showMiniLogo();
336
+ await ensureGitRepo();
337
+
338
+ const repoRoot = await getRepoRoot();
339
+ const worktrees = await getWorktreesInBase(repoRoot);
340
+ const currentPath = process.cwd();
341
+
342
+ heading(`${icons.folder} Worktrees`);
343
+
344
+ if (worktrees.length === 0) {
345
+ info('No worktrees found for this repository');
346
+ spacer();
347
+ console.log(` ${colors.muted('Create one with')} ${colors.primary('wt new')}`);
348
+ spacer();
349
+ return;
350
+ }
351
+
352
+ subheading(`Found ${worktrees.length} worktree${worktrees.length === 1 ? '' : 's'}:`);
353
+ spacer();
354
+
355
+ for (const wt of worktrees) {
356
+ const isCurrent = currentPath === wt.path || currentPath.startsWith(wt.path + '/');
357
+ worktreeItem(wt.name, wt.path, isCurrent);
358
+ console.log(` ${icons.branch} ${colors.branch(wt.branch)}`);
359
+ spacer();
360
+ }
361
+
362
+ divider();
363
+ console.log(` ${colors.muted('Main repo:')} ${colors.path(repoRoot)}`);
364
+ spacer();
365
+ }
366
+
367
+ export async function removeWorktreeFlow() {
368
+ showMiniLogo();
369
+ await ensureGitRepo();
370
+
371
+ heading(`${icons.trash} Remove Worktree`);
372
+
373
+ const repoRoot = await getRepoRoot();
374
+ const worktrees = await getWorktreesInBase(repoRoot);
375
+
376
+ if (worktrees.length === 0) {
377
+ info('No worktrees found to remove');
378
+ spacer();
379
+ return;
380
+ }
381
+
382
+ const choices = worktrees.map((wt) => ({
383
+ name: `${icons.folder} ${colors.highlight(wt.name)} ${colors.muted(`→ ${wt.branch}`)}`,
384
+ value: wt,
385
+ description: wt.path,
386
+ }));
387
+
388
+ choices.push({
389
+ name: `${colors.muted(icons.cross + ' Cancel')}`,
390
+ value: null,
391
+ });
392
+
393
+ const selected = await select({
394
+ message: 'Select worktree to remove:',
395
+ choices,
396
+ theme: { prefix: icons.trash },
397
+ });
398
+
399
+ if (!selected) {
400
+ info('Cancelled');
401
+ return;
402
+ }
403
+
404
+ spacer();
405
+ warning(`This will remove: ${colors.path(selected.path)}`);
406
+ spacer();
407
+
408
+ const confirmed = await confirm({
409
+ message: `Are you sure you want to remove "${selected.name}"?`,
410
+ default: false,
411
+ theme: { prefix: icons.warning },
412
+ });
413
+
414
+ if (!confirmed) {
415
+ info('Cancelled');
416
+ return;
417
+ }
418
+
419
+ const spinner = ora({
420
+ text: 'Removing worktree...',
421
+ color: 'yellow',
422
+ }).start();
423
+
424
+ try {
425
+ // First try normal remove, then force if needed
426
+ try {
427
+ await removeWorktree(selected.path, false);
428
+ } catch {
429
+ const forceRemove = await confirm({
430
+ message: 'Worktree has changes. Force remove?',
431
+ default: false,
432
+ });
433
+
434
+ if (forceRemove) {
435
+ await removeWorktree(selected.path, true);
436
+ } else {
437
+ spinner.fail('Aborted');
438
+ return;
439
+ }
440
+ }
441
+
442
+ spinner.succeed(colors.success('Worktree removed!'));
443
+ spacer();
444
+ success(`Removed ${colors.highlight(selected.name)}`);
445
+ spacer();
446
+ } catch (err) {
447
+ spinner.fail(colors.error('Failed to remove worktree'));
448
+ error(err.message);
449
+ }
450
+ }
451
+
452
+ export async function mergeWorktreeFlow() {
453
+ showMiniLogo();
454
+ await ensureGitRepo();
455
+
456
+ heading(`🔀 Merge Worktree`);
457
+
458
+ const repoRoot = await getRepoRoot();
459
+ const mainPath = await getMainRepoPath();
460
+ const worktrees = await getWorktreesInBase(repoRoot);
461
+ const currentPath = process.cwd();
462
+ const isAtHome = currentPath === mainPath;
463
+
464
+ if (worktrees.length === 0) {
465
+ info('No worktrees found to merge');
466
+ spacer();
467
+ return;
468
+ }
469
+
470
+ // Select worktree to merge
471
+ const wtChoices = worktrees.map((wt) => ({
472
+ name: `${icons.folder} ${colors.highlight(wt.name)} ${colors.muted(`→ ${wt.branch}`)}`,
473
+ value: wt,
474
+ description: wt.path,
475
+ }));
476
+
477
+ wtChoices.push({
478
+ name: `${colors.muted(icons.cross + ' Cancel')}`,
479
+ value: null,
480
+ });
481
+
482
+ const selectedWt = await select({
483
+ message: 'Select worktree to merge:',
484
+ choices: wtChoices,
485
+ theme: { prefix: '🔀' },
486
+ });
487
+
488
+ if (!selectedWt) {
489
+ info('Cancelled');
490
+ return;
491
+ }
492
+
493
+ // Select target branch
494
+ const mainBranch = await getMainBranch(mainPath);
495
+ const currentBranch = await getCurrentBranch();
496
+ const localBranches = await getLocalBranches(mainPath);
497
+
498
+ const targetChoices = [];
499
+
500
+ // Add main branch first if it exists
501
+ if (localBranches.some(b => b.name === mainBranch)) {
502
+ targetChoices.push({
503
+ name: `${icons.home} ${colors.branch(mainBranch)} ${colors.muted('(main branch)')}`,
504
+ value: mainBranch,
505
+ });
506
+ }
507
+
508
+ // Add current branch if different and we're at home
509
+ if (isAtHome && currentBranch && currentBranch !== mainBranch) {
510
+ targetChoices.push({
511
+ name: `${icons.pointer} ${colors.branch(currentBranch)} ${colors.muted('(current)')}`,
512
+ value: currentBranch,
513
+ });
514
+ }
515
+
516
+ // Add other branches
517
+ for (const branch of localBranches) {
518
+ if (branch.name !== mainBranch && branch.name !== currentBranch) {
519
+ targetChoices.push({
520
+ name: `${icons.branch} ${colors.branch(branch.name)}`,
521
+ value: branch.name,
522
+ });
523
+ }
524
+ }
525
+
526
+ targetChoices.push({
527
+ name: `${colors.muted(icons.cross + ' Cancel')}`,
528
+ value: null,
529
+ });
530
+
531
+ spacer();
532
+ const targetBranch = await select({
533
+ message: `Merge ${colors.highlight(selectedWt.branch)} into:`,
534
+ choices: targetChoices,
535
+ theme: { prefix: icons.arrowRight },
536
+ });
537
+
538
+ if (!targetBranch) {
539
+ info('Cancelled');
540
+ return;
541
+ }
542
+
543
+ // Check for uncommitted changes in main repo
544
+ if (await hasUncommittedChanges(mainPath)) {
545
+ spacer();
546
+ warning('Main repository has uncommitted changes!');
547
+ const proceed = await confirm({
548
+ message: 'Stash changes and continue?',
549
+ default: false,
550
+ });
551
+
552
+ if (!proceed) {
553
+ info('Cancelled. Commit or stash your changes first.');
554
+ return;
555
+ }
556
+
557
+ // Stash changes
558
+ const { simpleGit } = await import('simple-git');
559
+ const git = simpleGit(mainPath);
560
+ await git.stash();
561
+ info('Changes stashed');
562
+ }
563
+
564
+ // Confirm merge
565
+ spacer();
566
+ divider();
567
+ info(`From: ${colors.highlight(selectedWt.branch)} ${colors.muted(`(${selectedWt.name})`)}`);
568
+ info(`Into: ${colors.branch(targetBranch)}`);
569
+ divider();
570
+ spacer();
571
+
572
+ const confirmed = await confirm({
573
+ message: 'Proceed with merge?',
574
+ default: true,
575
+ theme: { prefix: '🔀' },
576
+ });
577
+
578
+ if (!confirmed) {
579
+ info('Cancelled');
580
+ return;
581
+ }
582
+
583
+ // Perform merge
584
+ const spinner = ora({
585
+ text: 'Merging...',
586
+ color: 'magenta',
587
+ }).start();
588
+
589
+ try {
590
+ await mergeBranch(selectedWt.branch, targetBranch, mainPath);
591
+ spinner.succeed(colors.success('Merged successfully!'));
592
+ spacer();
593
+ success(`Merged ${colors.highlight(selectedWt.branch)} into ${colors.branch(targetBranch)}`);
594
+
595
+ // Ask about cleanup
596
+ spacer();
597
+ const cleanup = await confirm({
598
+ message: `Remove the worktree "${selectedWt.name}" now that it's merged?`,
599
+ default: false,
600
+ theme: { prefix: icons.trash },
601
+ });
602
+
603
+ if (cleanup) {
604
+ const cleanupSpinner = ora({
605
+ text: 'Cleaning up...',
606
+ color: 'yellow',
607
+ }).start();
608
+
609
+ try {
610
+ await removeWorktree(selectedWt.path, false, mainPath);
611
+ cleanupSpinner.succeed(colors.success('Worktree removed'));
612
+
613
+ // Ask about deleting branch
614
+ const deleteBr = await confirm({
615
+ message: `Delete the branch "${selectedWt.branch}" too?`,
616
+ default: false,
617
+ theme: { prefix: icons.trash },
618
+ });
619
+
620
+ if (deleteBr) {
621
+ await deleteBranch(selectedWt.branch, false, mainPath);
622
+ success(`Branch ${colors.branch(selectedWt.branch)} deleted`);
623
+ }
624
+ } catch (err) {
625
+ cleanupSpinner.fail('Failed to remove worktree');
626
+ error(err.message);
627
+ }
628
+ }
629
+
630
+ spacer();
631
+ console.log(` ${icons.sparkles} ${colors.success('All done!')}`);
632
+ spacer();
633
+
634
+ } catch (err) {
635
+ spinner.fail(colors.error('Merge failed'));
636
+ error(err.message);
637
+ spacer();
638
+ warning('You may need to resolve conflicts manually');
639
+ spacer();
640
+ }
641
+ }
642
+
643
+ export async function goHome() {
644
+ showMiniLogo();
645
+ await ensureGitRepo();
646
+
647
+ const mainPath = await getMainRepoPath();
648
+ const currentPath = process.cwd();
649
+
650
+ if (!mainPath) {
651
+ error('Could not find main repository');
652
+ return;
653
+ }
654
+
655
+ // Check if we're already home
656
+ if (currentPath === mainPath || currentPath.startsWith(mainPath + '/')) {
657
+ const isExactlyHome = currentPath === mainPath;
658
+ spacer();
659
+ if (isExactlyHome) {
660
+ console.log(` ${icons.home} ${colors.success("You're already home!")} ${icons.sparkles}`);
661
+ } else {
662
+ console.log(` ${icons.home} ${colors.success("You're in the main repo")} ${icons.sparkles}`);
663
+ }
664
+ console.log(` ${colors.muted('Path:')} ${colors.path(mainPath)}`);
665
+ spacer();
666
+ return;
667
+ }
668
+
669
+ spacer();
670
+ success(`Heading home... ${icons.home}`);
671
+ console.log(` ${colors.muted('Path:')} ${colors.path(mainPath)}`);
672
+
673
+ showCdHint(mainPath);
674
+ }
675
+
676
+ export async function goToWorktree(name) {
677
+ showMiniLogo();
678
+ await ensureGitRepo();
679
+
680
+ const repoRoot = await getRepoRoot();
681
+ const worktrees = await getWorktreesInBase(repoRoot);
682
+
683
+ if (worktrees.length === 0) {
684
+ info('No worktrees found');
685
+ spacer();
686
+ console.log(` ${colors.muted('Create one with')} ${colors.primary('wt new')}`);
687
+ spacer();
688
+ return;
689
+ }
690
+
691
+ let selected;
692
+
693
+ if (name) {
694
+ // Direct jump by name
695
+ selected = worktrees.find((wt) => wt.name === name);
696
+ if (!selected) {
697
+ error(`Worktree "${name}" not found`);
698
+ spacer();
699
+ info('Available worktrees:');
700
+ worktrees.forEach((wt) => listItem(wt.name));
701
+ spacer();
702
+ return;
703
+ }
704
+ } else {
705
+ // Interactive selection
706
+ heading(`${icons.rocket} Jump to Worktree`);
707
+
708
+ const choices = worktrees.map((wt) => ({
709
+ name: `${icons.folder} ${colors.highlight(wt.name)} ${colors.muted(`→ ${wt.branch}`)}`,
710
+ value: wt,
711
+ description: wt.path,
712
+ }));
713
+
714
+ choices.push({
715
+ name: `${colors.muted(icons.cross + ' Cancel')}`,
716
+ value: null,
717
+ });
718
+
719
+ selected = await select({
720
+ message: 'Select worktree:',
721
+ choices,
722
+ theme: { prefix: icons.rocket },
723
+ });
724
+
725
+ if (!selected) {
726
+ info('Cancelled');
727
+ return;
728
+ }
729
+ }
730
+
731
+ spacer();
732
+ success(`Jumping to ${colors.highlight(selected.name)}`);
733
+ console.log(` ${colors.muted('Path:')} ${colors.path(selected.path)}`);
734
+
735
+ showCdHint(selected.path);
736
+ }