@jhorst11/wt 2.0.0 → 2.0.2

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/README.md CHANGED
@@ -107,6 +107,9 @@ Create `<repo>/.wt/config.json`:
107
107
  "post-create": [
108
108
  "npm install",
109
109
  "cp $WT_SOURCE/.env .env"
110
+ ],
111
+ "pre-destroy": [
112
+ "npm run clean"
110
113
  ]
111
114
  }
112
115
  }
@@ -120,16 +123,49 @@ Create `<repo>/.wt/config.json`:
120
123
  | `worktreesDir` | `string` | `~/projects/worktrees` | Directory where worktrees are created |
121
124
  | `branchPrefix` | `string` | `""` | Prefix for branch names (e.g., "username/") |
122
125
  | `hooks.post-create` | `string[]` | `[]` | Shell commands to run after creating a worktree |
126
+ | `hooks.pre-destroy` | `string[]` | `[]` | Shell commands to run before removing a worktree |
123
127
 
124
128
  **Hook environment variables** (available in hook commands):
125
129
 
126
130
  | Variable | Description |
127
131
  |----------|-------------|
128
132
  | `WT_SOURCE` | Absolute path to the main repository |
129
- | `WT_BRANCH` | Branch name of the new worktree |
130
- | `WT_PATH` | Absolute path to the new worktree (also the cwd) |
133
+ | `WT_BRANCH` | Branch name of the worktree |
134
+ | `WT_PATH` | Absolute path to the worktree (also the cwd) |
135
+ | `WT_NAME` | Worktree name (directory name, e.g. `feature-a`) |
136
+ | `WT_COLOR` | Hex color assigned to this worktree (e.g. `#E53935`), for UI/theming |
131
137
 
132
- Hook commands run with cwd set to the new worktree path. If a hook command fails, a warning is shown but the worktree creation still succeeds.
138
+ Each new worktree is assigned a unique color from a fixed palette (stored in `<repo>/.wt/worktree-colors.json`). In supported terminals (iTerm2, WezTerm, Ghostty, Kitty, Windows Terminal, Alacritty), the tab color is set to that worktree's color when you create a worktree or run `wt go <name>`. Colors also appear as indicators (●) throughout the CLI UI.
139
+
140
+ Hook commands run with cwd set to the worktree path. To run a Node script that lives in the main repo (e.g. `<repo>/.wt/scripts/foo.js`), use `WT_SOURCE`: `node "$WT_SOURCE/.wt/scripts/foo.js"` — `./scripts/foo.js` would look inside the worktree, not the main repo. If a hook command fails, a warning is shown but the operation continues (worktree creation still succeeds; worktree removal still proceeds after pre-destroy).
141
+
142
+ #### Worktree Color Configuration
143
+
144
+ Override automatic colors or provide a custom palette:
145
+
146
+ **Manual color assignment** (`.wt/config.json`):
147
+
148
+ ```json
149
+ {
150
+ "worktreeColors": {
151
+ "feature-auth": "#FF5733",
152
+ "feature-payments": "#33FF57"
153
+ }
154
+ }
155
+ ```
156
+
157
+ **Custom color palette** (`.wt/config.json`):
158
+
159
+ ```json
160
+ {
161
+ "colorPalette": [
162
+ "#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A",
163
+ "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E2"
164
+ ]
165
+ }
166
+ ```
167
+
168
+ Colors are stored per-repository in `.wt/worktree-colors.json` and support hierarchical configuration (global → repo → directory overrides).
133
169
 
134
170
  ## How It Works
135
171
 
package/bin/wt.js CHANGED
@@ -20,12 +20,13 @@ const { version } = require('../package.json');
20
20
  program
21
21
  .name('wt')
22
22
  .description('🌳 Beautiful interactive git worktree manager')
23
- .version(version);
23
+ .version(version)
24
+ .option('--verbose', 'Show full hook command output (default: show command name with spinner only)');
24
25
 
25
26
  program
26
27
  .command('new', { isDefault: false })
27
28
  .description('Create a new worktree interactively')
28
- .action(createWorktreeFlow);
29
+ .action((_args, cmd) => createWorktreeFlow({ verbose: !!cmd.parent?.opts?.()?.verbose }));
29
30
 
30
31
  program
31
32
  .command('list')
@@ -37,12 +38,12 @@ program
37
38
  .command('remove')
38
39
  .alias('rm')
39
40
  .description('Remove a worktree interactively')
40
- .action(removeWorktreeFlow);
41
+ .action((_args, cmd) => removeWorktreeFlow({ verbose: !!cmd.parent?.opts?.()?.verbose }));
41
42
 
42
43
  program
43
44
  .command('merge')
44
45
  .description('Merge a worktree branch back to main')
45
- .action(mergeWorktreeFlow);
46
+ .action((_args, cmd) => mergeWorktreeFlow({ verbose: !!cmd.parent?.opts?.()?.verbose }));
46
47
 
47
48
  program
48
49
  .command('home')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhorst11/wt",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "🌳 Beautiful interactive git worktree manager",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.js CHANGED
@@ -18,9 +18,13 @@ import {
18
18
  colors,
19
19
  icons,
20
20
  formatBranchChoice,
21
+ formatWorktreeChoice,
22
+ setTabColor,
23
+ resetTabColor,
24
+ colorIndicator,
21
25
  } from './ui.js';
22
26
  import { showCdHint } from './setup.js';
23
- import { resolveConfig, loadConfig, runHooks } from './config.js';
27
+ import { resolveConfig, loadConfig, runHooks, assignWorktreeColor, getWorktreeColor, removeWorktreeColor } from './config.js';
24
28
  import {
25
29
  isGitRepo,
26
30
  getRepoRoot,
@@ -39,6 +43,7 @@ import {
39
43
  getMainBranch,
40
44
  hasUncommittedChanges,
41
45
  deleteBranch,
46
+ getCurrentWorktreeInfo,
42
47
  } from './git.js';
43
48
 
44
49
  function isUserCancellation(err) {
@@ -71,12 +76,18 @@ export async function mainMenu() {
71
76
  const currentBranch = await getCurrentBranch();
72
77
  const config = resolveConfig(process.cwd(), repoRoot);
73
78
  const worktrees = await getWorktreesInBase(repoRoot, config);
79
+ const currentWt = await getCurrentWorktreeInfo(repoRoot, config);
74
80
 
75
81
  const branchDisplay = currentBranch && currentBranch !== 'HEAD'
76
82
  ? colors.branch(currentBranch)
77
83
  : colors.warning('detached HEAD');
78
84
  subheading(` 📍 ${colors.path(repoRoot)}`);
79
85
  subheading(` 🌿 ${branchDisplay}`);
86
+ if (currentWt) {
87
+ const wtColor = getWorktreeColor(repoRoot, currentWt.name);
88
+ const colorDot = colorIndicator(wtColor);
89
+ subheading(` ${colorDot} ${colors.highlight(currentWt.name)}`);
90
+ }
80
91
  spacer();
81
92
 
82
93
  const choices = [
@@ -162,14 +173,17 @@ export async function mainMenu() {
162
173
  }
163
174
  }
164
175
 
165
- export async function createWorktreeFlow() {
166
- showMiniLogo();
176
+ export async function createWorktreeFlow(options = {}) {
167
177
  await ensureGitRepo();
178
+ const repoRoot = await getRepoRoot();
179
+ const config = resolveConfig(process.cwd(), repoRoot);
180
+ const currentWt = await getCurrentWorktreeInfo(repoRoot, config);
181
+ const wtColor = currentWt ? getWorktreeColor(repoRoot, currentWt.name) : null;
182
+ showMiniLogo(currentWt ? { ...currentWt, color: wtColor } : null);
168
183
 
169
184
  heading(`${icons.plus} Create New Worktree`);
170
185
 
171
186
  const currentBranch = await getCurrentBranch();
172
- const repoRoot = await getRepoRoot();
173
187
  const isDetached = !currentBranch || currentBranch === 'HEAD';
174
188
 
175
189
  try {
@@ -321,6 +335,10 @@ export async function createWorktreeFlow() {
321
335
  info(`Branch: ${colors.branch(branchName)}`);
322
336
  info(`Base: ${colors.muted(baseBranch || 'HEAD')}`);
323
337
  info(`Path: ${colors.path(getWorktreesBase(repoRoot, config) + '/' + worktreeName)}`);
338
+ const postCreateHooks = config.hooks?.['post-create'];
339
+ if (postCreateHooks?.length) {
340
+ info(`Hooks: ${colors.muted(`post-create (${postCreateHooks.length} command${postCreateHooks.length === 1 ? '' : 's'})`)}`);
341
+ }
324
342
  divider();
325
343
  spacer();
326
344
 
@@ -354,7 +372,11 @@ export async function createWorktreeFlow() {
354
372
  spinner.succeed(colors.success('Worktree created!'));
355
373
  spacer();
356
374
 
357
- success(`Created worktree at ${colors.path(result.path)}`);
375
+ const worktreeColor = assignWorktreeColor(repoRoot, worktreeName);
376
+ setTabColor(worktreeColor);
377
+
378
+ const colorDot = colorIndicator(worktreeColor);
379
+ success(`${colorDot} Created worktree at ${colors.path(result.path)}`);
358
380
  if (result.branchCreated) {
359
381
  success(`Created new branch ${colors.branch(branchName)}`);
360
382
  } else if (result.branchSource === 'updated-from-remote') {
@@ -364,7 +386,7 @@ export async function createWorktreeFlow() {
364
386
  }
365
387
 
366
388
  // Run post-create hooks
367
- const hookCommands = repoConfig.hooks?.['post-create'];
389
+ const hookCommands = config.hooks?.['post-create'];
368
390
  if (hookCommands && hookCommands.length > 0) {
369
391
  spacer();
370
392
  const hookSpinner = ora({
@@ -372,11 +394,19 @@ export async function createWorktreeFlow() {
372
394
  color: 'magenta',
373
395
  }).start();
374
396
 
375
- const hookResults = runHooks('post-create', repoConfig, {
376
- source: repoRoot,
377
- path: result.path,
378
- branch: branchName,
379
- });
397
+ const hookResults = await runHooks(
398
+ 'post-create',
399
+ config,
400
+ { source: repoRoot, path: result.path, branch: branchName, name: worktreeName, color: worktreeColor },
401
+ {
402
+ verbose: options.verbose,
403
+ onCommandStart: (cmd, i, total) => {
404
+ hookSpinner.text = total > 1
405
+ ? `Running post-create hooks... (${i}/${total}: ${cmd})`
406
+ : `Running post-create hooks... (${cmd})`;
407
+ },
408
+ }
409
+ );
380
410
 
381
411
  const failed = hookResults.filter((r) => !r.success);
382
412
  if (failed.length === 0) {
@@ -385,6 +415,7 @@ export async function createWorktreeFlow() {
385
415
  hookSpinner.warn(colors.warning(`${failed.length} of ${hookResults.length} hook${hookResults.length === 1 ? '' : 's'} failed`));
386
416
  for (const f of failed) {
387
417
  warning(`Hook failed: ${colors.muted(f.command)}`);
418
+ if (f.error) info(colors.muted(f.error));
388
419
  }
389
420
  }
390
421
  }
@@ -400,13 +431,14 @@ export async function createWorktreeFlow() {
400
431
  }
401
432
 
402
433
  export async function listWorktrees() {
403
- showMiniLogo();
404
434
  await ensureGitRepo();
405
-
406
435
  const repoRoot = await getRepoRoot();
407
436
  const config = resolveConfig(process.cwd(), repoRoot);
408
437
  const worktrees = await getWorktreesInBase(repoRoot, config);
409
438
  const currentPath = process.cwd();
439
+ const currentWt = await getCurrentWorktreeInfo(repoRoot, config);
440
+ const wtColor = currentWt ? getWorktreeColor(repoRoot, currentWt.name) : null;
441
+ showMiniLogo(currentWt ? { ...currentWt, color: wtColor } : null);
410
442
 
411
443
  heading(`${icons.folder} Worktrees`);
412
444
 
@@ -423,7 +455,8 @@ export async function listWorktrees() {
423
455
 
424
456
  for (const wt of worktrees) {
425
457
  const isCurrent = currentPath === wt.path || currentPath.startsWith(wt.path + '/');
426
- worktreeItem(wt.name, wt.path, isCurrent);
458
+ const wtColor = getWorktreeColor(repoRoot, wt.name);
459
+ worktreeItem(wt.name, wt.path, isCurrent, wtColor);
427
460
  const branchDisplay = wt.branch === 'unknown'
428
461
  ? colors.warning('detached HEAD')
429
462
  : colors.branch(wt.branch);
@@ -436,14 +469,16 @@ export async function listWorktrees() {
436
469
  spacer();
437
470
  }
438
471
 
439
- export async function removeWorktreeFlow() {
440
- showMiniLogo();
472
+ export async function removeWorktreeFlow(options = {}) {
441
473
  await ensureGitRepo();
474
+ const repoRoot = await getRepoRoot();
475
+ const config = resolveConfig(process.cwd(), repoRoot);
476
+ const currentWt = await getCurrentWorktreeInfo(repoRoot, config);
477
+ const wtColor = currentWt ? getWorktreeColor(repoRoot, currentWt.name) : null;
478
+ showMiniLogo(currentWt ? { ...currentWt, color: wtColor } : null);
442
479
 
443
480
  heading(`${icons.trash} Remove Worktree`);
444
481
 
445
- const repoRoot = await getRepoRoot();
446
- const config = resolveConfig(process.cwd(), repoRoot);
447
482
  const worktrees = await getWorktreesInBase(repoRoot, config);
448
483
  const currentPath = process.cwd();
449
484
 
@@ -459,8 +494,9 @@ export async function removeWorktreeFlow() {
459
494
  const choices = worktrees.map((wt) => {
460
495
  const isCurrent = currentPath === wt.path || currentPath.startsWith(wt.path + '/');
461
496
  const currentLabel = isCurrent ? colors.warning(' (you are here)') : '';
497
+ const wtColor = getWorktreeColor(repoRoot, wt.name);
462
498
  return {
463
- name: `${icons.folder} ${colors.highlight(wt.name)} ${colors.muted(`→ ${wt.branch}`)}${currentLabel}`,
499
+ name: formatWorktreeChoice(wt, wtColor) + currentLabel,
464
500
  value: wt,
465
501
  description: wt.path,
466
502
  };
@@ -491,7 +527,13 @@ export async function removeWorktreeFlow() {
491
527
  }
492
528
 
493
529
  spacer();
494
- warning(`This will remove: ${colors.path(selected.path)}`);
530
+ const selectedColor = getWorktreeColor(repoRoot, selected.name);
531
+ const colorDot = colorIndicator(selectedColor);
532
+ warning(`${colorDot} This will remove: ${colors.path(selected.path)}`);
533
+ const preDestroyHooks = config.hooks?.['pre-destroy'];
534
+ if (preDestroyHooks?.length) {
535
+ info(`Hooks: ${colors.muted(`pre-destroy (${preDestroyHooks.length} command${preDestroyHooks.length === 1 ? '' : 's'}) will run first`)}`);
536
+ }
495
537
  spacer();
496
538
 
497
539
  const confirmed = await confirm({
@@ -505,6 +547,42 @@ export async function removeWorktreeFlow() {
505
547
  return;
506
548
  }
507
549
 
550
+ // Run pre-destroy hooks
551
+ const preDestroyCommands = config.hooks?.['pre-destroy'];
552
+ if (preDestroyCommands && preDestroyCommands.length > 0) {
553
+ spacer();
554
+ const hookSpinner = ora({
555
+ text: 'Running pre-destroy hooks...',
556
+ color: 'magenta',
557
+ }).start();
558
+
559
+ const hookResults = await runHooks(
560
+ 'pre-destroy',
561
+ config,
562
+ { source: repoRoot, path: selected.path, branch: selected.branch, name: selected.name, color: getWorktreeColor(repoRoot, selected.name) },
563
+ {
564
+ verbose: options.verbose,
565
+ onCommandStart: (cmd, i, total) => {
566
+ hookSpinner.text = total > 1
567
+ ? `Running pre-destroy hooks... (${i}/${total}: ${cmd})`
568
+ : `Running pre-destroy hooks... (${cmd})`;
569
+ },
570
+ }
571
+ );
572
+
573
+ const failed = hookResults.filter((r) => !r.success);
574
+ if (failed.length === 0) {
575
+ hookSpinner.succeed(colors.success(`Ran ${hookResults.length} pre-destroy hook${hookResults.length === 1 ? '' : 's'}`));
576
+ } else {
577
+ hookSpinner.warn(colors.warning(`${failed.length} of ${hookResults.length} hook${hookResults.length === 1 ? '' : 's'} failed`));
578
+ for (const f of failed) {
579
+ warning(`Hook failed: ${colors.muted(f.command)}`);
580
+ if (f.error) info(colors.muted(f.error));
581
+ }
582
+ }
583
+ spacer();
584
+ }
585
+
508
586
  const spinner = ora({
509
587
  text: 'Removing worktree...',
510
588
  color: 'yellow',
@@ -536,7 +614,10 @@ export async function removeWorktreeFlow() {
536
614
 
537
615
  spinner.succeed(colors.success('Worktree removed!'));
538
616
  spacer();
539
- success(`Removed ${colors.highlight(selected.name)}`);
617
+ const removedColor = getWorktreeColor(repoRoot, selected.name);
618
+ const removedColorDot = colorIndicator(removedColor);
619
+ removeWorktreeColor(repoRoot, selected.name);
620
+ success(`${removedColorDot} Removed ${colors.highlight(selected.name)}`);
540
621
 
541
622
  if (isInsideSelected) {
542
623
  spacer();
@@ -556,15 +637,17 @@ export async function removeWorktreeFlow() {
556
637
  }
557
638
  }
558
639
 
559
- export async function mergeWorktreeFlow() {
560
- showMiniLogo();
640
+ export async function mergeWorktreeFlow(options = {}) {
561
641
  await ensureGitRepo();
642
+ const repoRoot = await getRepoRoot();
643
+ const config = resolveConfig(process.cwd(), repoRoot);
644
+ const currentWt = await getCurrentWorktreeInfo(repoRoot, config);
645
+ const wtColor = currentWt ? getWorktreeColor(repoRoot, currentWt.name) : null;
646
+ showMiniLogo(currentWt ? { ...currentWt, color: wtColor } : null);
562
647
 
563
648
  heading(`🔀 Merge Worktree`);
564
649
 
565
- const repoRoot = await getRepoRoot();
566
650
  const mainPath = await getMainRepoPath();
567
- const config = resolveConfig(process.cwd(), repoRoot);
568
651
  const worktrees = await getWorktreesInBase(repoRoot, config);
569
652
  const currentPath = process.cwd();
570
653
  const isAtHome = currentPath === mainPath;
@@ -579,11 +662,14 @@ export async function mergeWorktreeFlow() {
579
662
 
580
663
  try {
581
664
  // Select worktree to merge
582
- const wtChoices = worktrees.map((wt) => ({
583
- name: `${icons.folder} ${colors.highlight(wt.name)} ${colors.muted(`→ ${wt.branch}`)}`,
584
- value: wt,
585
- description: wt.path,
586
- }));
665
+ const wtChoices = worktrees.map((wt) => {
666
+ const wtColor = getWorktreeColor(repoRoot, wt.name);
667
+ return {
668
+ name: formatWorktreeChoice(wt, wtColor),
669
+ value: wt,
670
+ description: wt.path,
671
+ };
672
+ });
587
673
 
588
674
  wtChoices.push({
589
675
  name: `${colors.muted(icons.cross + ' Cancel')}`,
@@ -682,7 +768,9 @@ export async function mergeWorktreeFlow() {
682
768
  // Confirm merge
683
769
  spacer();
684
770
  divider();
685
- info(`From: ${colors.highlight(selectedWt.branch)} ${colors.muted(`(${selectedWt.name})`)}`);
771
+ const selectedColor = getWorktreeColor(repoRoot, selectedWt.name);
772
+ const selectedColorDot = colorIndicator(selectedColor);
773
+ info(`${selectedColorDot} From: ${colors.highlight(selectedWt.branch)} ${colors.muted(`(${selectedWt.name})`)}`);
686
774
  info(`Into: ${colors.branch(targetBranch)}`);
687
775
  divider();
688
776
  spacer();
@@ -719,6 +807,42 @@ export async function mergeWorktreeFlow() {
719
807
  });
720
808
 
721
809
  if (cleanup) {
810
+ // Run pre-destroy hooks before removing the worktree
811
+ const preDestroyCommands = config.hooks?.['pre-destroy'];
812
+ if (preDestroyCommands && preDestroyCommands.length > 0) {
813
+ spacer();
814
+ const hookSpinner = ora({
815
+ text: 'Running pre-destroy hooks...',
816
+ color: 'magenta',
817
+ }).start();
818
+
819
+ const hookResults = await runHooks(
820
+ 'pre-destroy',
821
+ config,
822
+ { source: repoRoot, path: selectedWt.path, branch: selectedWt.branch, name: selectedWt.name, color: getWorktreeColor(repoRoot, selectedWt.name) },
823
+ {
824
+ verbose: options.verbose,
825
+ onCommandStart: (cmd, i, total) => {
826
+ hookSpinner.text = total > 1
827
+ ? `Running pre-destroy hooks... (${i}/${total}: ${cmd})`
828
+ : `Running pre-destroy hooks... (${cmd})`;
829
+ },
830
+ }
831
+ );
832
+
833
+ const failed = hookResults.filter((r) => !r.success);
834
+ if (failed.length === 0) {
835
+ hookSpinner.succeed(colors.success(`Ran ${hookResults.length} pre-destroy hook${hookResults.length === 1 ? '' : 's'}`));
836
+ } else {
837
+ hookSpinner.warn(colors.warning(`${failed.length} of ${hookResults.length} hook${hookResults.length === 1 ? '' : 's'} failed`));
838
+ for (const f of failed) {
839
+ warning(`Hook failed: ${colors.muted(f.command)}`);
840
+ if (f.error) info(colors.muted(f.error));
841
+ }
842
+ }
843
+ spacer();
844
+ }
845
+
722
846
  const cleanupSpinner = ora({
723
847
  text: 'Cleaning up...',
724
848
  color: 'yellow',
@@ -726,6 +850,7 @@ export async function mergeWorktreeFlow() {
726
850
 
727
851
  try {
728
852
  await removeWorktree(selectedWt.path, false, mainPath);
853
+ removeWorktreeColor(repoRoot, selectedWt.name);
729
854
  cleanupSpinner.succeed(colors.success('Worktree removed'));
730
855
 
731
856
  // Ask about deleting branch
@@ -764,8 +889,12 @@ export async function mergeWorktreeFlow() {
764
889
  }
765
890
 
766
891
  export async function goHome() {
767
- showMiniLogo();
768
892
  await ensureGitRepo();
893
+ const repoRoot = await getRepoRoot();
894
+ const config = resolveConfig(process.cwd(), repoRoot);
895
+ const currentWt = await getCurrentWorktreeInfo(repoRoot, config);
896
+ const wtColor = currentWt ? getWorktreeColor(repoRoot, currentWt.name) : null;
897
+ showMiniLogo(currentWt ? { ...currentWt, color: wtColor } : null);
769
898
 
770
899
  const mainPath = await getMainRepoPath();
771
900
  const currentPath = process.cwd();
@@ -792,16 +921,18 @@ export async function goHome() {
792
921
  spacer();
793
922
  success(`Heading home... ${icons.home}`);
794
923
  console.log(` ${colors.muted('Path:')} ${colors.path(mainPath)}`);
795
-
924
+ resetTabColor();
796
925
  showCdHint(mainPath);
797
926
  }
798
927
 
799
928
  export async function goToWorktree(name) {
800
- showMiniLogo();
801
929
  await ensureGitRepo();
802
-
803
930
  const repoRoot = await getRepoRoot();
804
931
  const config = resolveConfig(process.cwd(), repoRoot);
932
+ const currentWt = await getCurrentWorktreeInfo(repoRoot, config);
933
+ const wtColor = currentWt ? getWorktreeColor(repoRoot, currentWt.name) : null;
934
+ showMiniLogo(currentWt ? { ...currentWt, color: wtColor } : null);
935
+
805
936
  const worktrees = await getWorktreesInBase(repoRoot, config);
806
937
 
807
938
  if (worktrees.length === 0) {
@@ -847,8 +978,9 @@ export async function goToWorktree(name) {
847
978
  const choices = worktrees.map((wt) => {
848
979
  const isCurrent = currentPath === wt.path || currentPath.startsWith(wt.path + '/');
849
980
  const currentLabel = isCurrent ? colors.muted(' (current)') : '';
981
+ const wtColor = getWorktreeColor(repoRoot, wt.name);
850
982
  return {
851
- name: `${icons.folder} ${colors.highlight(wt.name)} ${colors.muted(`→ ${wt.branch}`)}${currentLabel}`,
983
+ name: formatWorktreeChoice(wt, wtColor) + currentLabel,
852
984
  value: wt,
853
985
  description: wt.path,
854
986
  };
@@ -876,8 +1008,12 @@ export async function goToWorktree(name) {
876
1008
  }
877
1009
 
878
1010
  spacer();
879
- success(`Jumping to ${colors.highlight(selected.name)}`);
1011
+ const selectedColor = getWorktreeColor(repoRoot, selected.name);
1012
+ const selectedColorDot = colorIndicator(selectedColor);
1013
+ success(`${selectedColorDot} Jumping to ${colors.highlight(selected.name)}`);
880
1014
  console.log(` ${colors.muted('Path:')} ${colors.path(selected.path)}`);
881
1015
 
1016
+ if (selectedColor) setTabColor(selectedColor);
1017
+
882
1018
  showCdHint(selected.path);
883
1019
  }
package/src/config.js CHANGED
@@ -1,10 +1,117 @@
1
- import { readFileSync } from 'fs';
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { execSync } from 'child_process';
3
+ import { spawn } from 'child_process';
4
4
  import { homedir } from 'os';
5
+ import { once } from 'events';
5
6
 
6
7
  const CONFIG_DIR = '.wt';
7
8
  const CONFIG_FILE = 'config.json';
9
+ const WORKTREE_COLORS_FILE = 'worktree-colors.json';
10
+
11
+ /** Distinct hex colors (with #) for worktree tab/UI; cycle through for unique assignment. */
12
+ export const WORKTREE_COLORS_PALETTE = [
13
+ '#E53935', '#D81B60', '#8E24AA', '#5E35B1', '#3949AB', '#1E88E5', '#039BE5', '#00ACC1',
14
+ '#00897B', '#43A047', '#7CB342', '#C0CA33', '#FDD835', '#FFB300', '#FB8C00', '#F4511E',
15
+ ];
16
+
17
+ function getWorktreeColorsPath(repoRoot) {
18
+ return join(repoRoot, CONFIG_DIR, WORKTREE_COLORS_FILE);
19
+ }
20
+
21
+ /**
22
+ * Load worktree name → hex color map from repo's .wt/worktree-colors.json.
23
+ * @returns {Record<string, string>}
24
+ */
25
+ export function loadWorktreeColors(repoRoot) {
26
+ const path = getWorktreeColorsPath(repoRoot);
27
+ try {
28
+ const raw = readFileSync(path, 'utf8');
29
+ const data = JSON.parse(raw);
30
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
31
+ const out = {};
32
+ for (const [name, hex] of Object.entries(data)) {
33
+ if (typeof name === 'string' && typeof hex === 'string' && /^#[0-9A-Fa-f]{6}$/.test(hex)) {
34
+ out[name] = hex;
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+ } catch {
40
+ // file missing or invalid
41
+ }
42
+ return {};
43
+ }
44
+
45
+ /**
46
+ * Save worktree name → hex color map to repo's .wt/worktree-colors.json.
47
+ */
48
+ export function saveWorktreeColors(repoRoot, mapping) {
49
+ const dir = join(repoRoot, CONFIG_DIR);
50
+ const path = getWorktreeColorsPath(repoRoot);
51
+ try {
52
+ mkdirSync(dir, { recursive: true });
53
+ writeFileSync(path, JSON.stringify(mapping, null, 2) + '\n', 'utf8');
54
+ } catch {
55
+ // ignore write errors (e.g. read-only repo)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Assign a unique color to a new worktree. Checks config overrides first, then
61
+ * uses first palette color not used by existing worktrees.
62
+ * Persists and returns the hex color (e.g. "#E53935").
63
+ */
64
+ export function assignWorktreeColor(repoRoot, worktreeName) {
65
+ const current = loadWorktreeColors(repoRoot);
66
+
67
+ // Check if already assigned
68
+ let hex = current[worktreeName];
69
+ if (hex) return hex;
70
+
71
+ // Check config override
72
+ const config = resolveConfig(process.cwd(), repoRoot);
73
+ if (config.worktreeColors?.[worktreeName]) {
74
+ hex = config.worktreeColors[worktreeName];
75
+ current[worktreeName] = hex;
76
+ saveWorktreeColors(repoRoot, current);
77
+ return hex;
78
+ }
79
+
80
+ // Auto-assign from palette (prefer custom palette if configured)
81
+ const palette = config.colorPalette || WORKTREE_COLORS_PALETTE;
82
+ const usedColors = new Set(Object.values(current));
83
+
84
+ for (const c of palette) {
85
+ if (!usedColors.has(c)) {
86
+ hex = c;
87
+ break;
88
+ }
89
+ }
90
+
91
+ hex = hex || palette[usedColors.size % palette.length];
92
+ current[worktreeName] = hex;
93
+ saveWorktreeColors(repoRoot, current);
94
+ return hex;
95
+ }
96
+
97
+ /**
98
+ * Get the assigned hex color for a worktree, or null if none.
99
+ */
100
+ export function getWorktreeColor(repoRoot, worktreeName) {
101
+ const current = loadWorktreeColors(repoRoot);
102
+ return current[worktreeName] ?? null;
103
+ }
104
+
105
+ /**
106
+ * Remove a worktree's color assignment so the color can be reused.
107
+ */
108
+ export function removeWorktreeColor(repoRoot, worktreeName) {
109
+ const current = loadWorktreeColors(repoRoot);
110
+ if (worktreeName in current) {
111
+ delete current[worktreeName];
112
+ saveWorktreeColors(repoRoot, current);
113
+ }
114
+ }
8
115
 
9
116
  /**
10
117
  * Load configuration from .wt/config.json or config.json at the given directory.
@@ -21,6 +128,8 @@ export function loadConfig(dirPath) {
21
128
  worktreesDir: undefined,
22
129
  branchPrefix: undefined,
23
130
  hooks: {},
131
+ worktreeColors: {},
132
+ colorPalette: undefined,
24
133
  };
25
134
 
26
135
  let raw;
@@ -70,6 +179,23 @@ export function loadConfig(dirPath) {
70
179
  }
71
180
  }
72
181
 
182
+ if (typeof parsed.worktreeColors === 'object' && parsed.worktreeColors !== null && !Array.isArray(parsed.worktreeColors)) {
183
+ for (const [name, hex] of Object.entries(parsed.worktreeColors)) {
184
+ if (typeof name === 'string' && typeof hex === 'string' && /^#[0-9A-Fa-f]{6}$/.test(hex)) {
185
+ result.worktreeColors[name] = hex;
186
+ }
187
+ }
188
+ }
189
+
190
+ if (Array.isArray(parsed.colorPalette)) {
191
+ const validColors = parsed.colorPalette.filter(hex =>
192
+ typeof hex === 'string' && /^#[0-9A-Fa-f]{6}$/.test(hex)
193
+ );
194
+ if (validColors.length > 0) {
195
+ result.colorPalette = validColors;
196
+ }
197
+ }
198
+
73
199
  return result;
74
200
  }
75
201
 
@@ -219,37 +345,80 @@ export function resolveConfig(cwd = process.cwd(), repoRoot, globalConfigPath) {
219
345
  };
220
346
  }
221
347
 
348
+ const HOOK_TIMEOUT_MS = 300_000; // 5 minutes per command
349
+
222
350
  /**
223
351
  * Run hook commands sequentially. Each command runs with cwd set to `wtPath`
224
352
  * and receives WT_SOURCE, WT_BRANCH, and WT_PATH as environment variables.
225
353
  *
354
+ * Options:
355
+ * - verbose: if true, stream stdout/stderr to the terminal; if false, suppress output and only report results.
356
+ * - onCommandStart(cmd, index, total): called before each command (e.g. to update a spinner).
357
+ *
226
358
  * Returns an array of { command, success, error? } results.
227
359
  * Hook failures are non-fatal — they produce warnings but don't throw.
228
360
  */
229
- export function runHooks(hookName, config, { source, path: wtPath, branch }) {
361
+ export async function runHooks(hookName, config, { source, path: wtPath, branch, name: wtName, color: wtColor }, options = {}) {
230
362
  const commands = config.hooks?.[hookName];
231
363
  if (!commands || commands.length === 0) return [];
232
364
 
365
+ const { verbose = false, onCommandStart } = options;
366
+ const total = commands.length;
367
+
233
368
  const env = {
234
369
  ...process.env,
235
370
  WT_SOURCE: source,
236
371
  WT_BRANCH: branch,
237
372
  WT_PATH: wtPath,
373
+ ...(wtName !== undefined && { WT_NAME: wtName }),
374
+ ...(wtColor !== undefined && wtColor !== null && { WT_COLOR: wtColor }),
238
375
  };
239
376
 
240
377
  const results = [];
241
378
 
242
- for (const cmd of commands) {
243
- try {
244
- execSync(cmd, {
245
- cwd: wtPath,
246
- env,
247
- stdio: 'pipe',
248
- timeout: 300_000, // 5 minute timeout per command
379
+ for (let i = 0; i < commands.length; i++) {
380
+ const cmd = commands[i];
381
+ if (typeof onCommandStart === 'function') {
382
+ onCommandStart(cmd, i + 1, total);
383
+ }
384
+
385
+ const child = spawn(cmd, [], {
386
+ shell: true,
387
+ cwd: wtPath,
388
+ env,
389
+ stdio: ['inherit', 'pipe', 'pipe'],
390
+ });
391
+
392
+ const stderrChunks = [];
393
+ if (verbose) {
394
+ child.stdout.pipe(process.stdout);
395
+ child.stderr.on('data', (chunk) => {
396
+ process.stderr.write(chunk);
397
+ stderrChunks.push(chunk);
249
398
  });
250
- results.push({ command: cmd, success: true });
399
+ } else {
400
+ child.stdout.on('data', () => {}); // consume to avoid blocking the child
401
+ child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
402
+ }
403
+
404
+ const timeoutId = setTimeout(() => {
405
+ child.kill('SIGTERM');
406
+ }, HOOK_TIMEOUT_MS);
407
+
408
+ try {
409
+ const [code, signal] = await once(child, 'exit');
410
+ clearTimeout(timeoutId);
411
+ if (code === 0 && !signal) {
412
+ results.push({ command: cmd, success: true });
413
+ } else {
414
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
415
+ const detail = stderr || (signal ? `Killed by ${signal}` : `Exited with code ${code}`);
416
+ results.push({ command: cmd, success: false, error: detail });
417
+ }
251
418
  } catch (err) {
252
- results.push({ command: cmd, success: false, error: err.message });
419
+ clearTimeout(timeoutId);
420
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
421
+ results.push({ command: cmd, success: false, error: stderr || err.message });
253
422
  }
254
423
  }
255
424
 
package/src/git.js CHANGED
@@ -395,3 +395,22 @@ export async function deleteBranch(branchName, force = false, cwd = process.cwd(
395
395
  await git.branch([flag, branchName]);
396
396
  return { success: true };
397
397
  }
398
+
399
+ /**
400
+ * Get information about the current worktree if user is inside one.
401
+ * Returns the worktree object with name, path, and branch if inside a worktree.
402
+ * Returns null if in main repository or error occurs.
403
+ * @param {string} repoRoot - Git repository root
404
+ * @param {object} config - Configuration object from resolveConfig()
405
+ * @returns {Promise<object|null>} Worktree info object or null
406
+ */
407
+ export async function getCurrentWorktreeInfo(repoRoot, config) {
408
+ const currentPath = process.cwd();
409
+ const worktrees = await getWorktreesInBase(repoRoot, config);
410
+
411
+ const currentWt = worktrees.find(wt =>
412
+ currentPath === wt.path || currentPath.startsWith(wt.path + '/')
413
+ );
414
+
415
+ return currentWt || null;
416
+ }
package/src/ui.js CHANGED
@@ -55,8 +55,13 @@ export function showLogo() {
55
55
  console.log(logo);
56
56
  }
57
57
 
58
- export function showMiniLogo() {
59
- console.log(`\n ${icons.tree} ${wtGradient('worktree')} ${colors.muted(`v${version}`)}\n`);
58
+ export function showMiniLogo(worktreeInfo = null) {
59
+ console.log(`\n ${icons.tree} ${wtGradient('worktree')} ${colors.muted(`v${version}`)}`);
60
+ if (worktreeInfo) {
61
+ const colorDot = colorIndicator(worktreeInfo.color);
62
+ console.log(` ${colorDot} ${colors.highlight(worktreeInfo.name)} ${colors.muted(`→ ${worktreeInfo.branch}`)}`);
63
+ }
64
+ console.log('');
60
65
  }
61
66
 
62
67
  export function success(message) {
@@ -96,10 +101,12 @@ export function branchItem(name, isCurrent = false, isRemote = false) {
96
101
  console.log(` ${prefix} ${icon} ${branchName}${typeLabel}`);
97
102
  }
98
103
 
99
- export function worktreeItem(name, path, isCurrent = false) {
104
+ export function worktreeItem(name, path, isCurrent = false, color = null) {
100
105
  const prefix = isCurrent ? colors.success(icons.pointer) : ' ';
101
106
  const nameDisplay = isCurrent ? colors.success.bold(name) : colors.highlight(name);
102
- console.log(` ${prefix} ${icons.folder} ${nameDisplay}`);
107
+ const colorDot = colorIndicator(color);
108
+ const displayName = colorDot ? `${colorDot} ${nameDisplay}` : nameDisplay;
109
+ console.log(` ${prefix} ${icons.folder} ${displayName}`);
103
110
  console.log(` ${colors.muted(path)}`);
104
111
  }
105
112
 
@@ -111,14 +118,162 @@ export function spacer() {
111
118
  console.log('');
112
119
  }
113
120
 
121
+ /**
122
+ * Convert hex color to chalk color function.
123
+ * Falls back to gray for invalid hex.
124
+ * @param {string} hex - Hex color like "#E53935" or "E53935"
125
+ * @returns {Function} Chalk color function
126
+ */
127
+ export function hexToChalk(hex) {
128
+ if (!hex) return chalk.gray;
129
+ const clean = hex.replace(/^#/, '');
130
+ if (!/^[0-9A-Fa-f]{6}$/.test(clean)) return chalk.gray;
131
+ return chalk.hex(clean);
132
+ }
133
+
134
+ /**
135
+ * Create a colored circle indicator (●) for visual color display.
136
+ * Returns empty string if hex is null/invalid.
137
+ * @param {string} hex - Hex color like "#E53935"
138
+ * @returns {string} Colored circle character or empty string
139
+ */
140
+ export function colorIndicator(hex) {
141
+ if (!hex) return '';
142
+ const color = hexToChalk(hex);
143
+ return color('●'); // U+25CF filled circle
144
+ }
145
+
146
+ /**
147
+ * Convert hex to RGB components for terminal sequences.
148
+ * @param {string} hex - Hex color like "#E53935"
149
+ * @returns {{r: number, g: number, b: number}} RGB components 0-255
150
+ */
151
+ export function hexToRgb(hex) {
152
+ const clean = hex.replace(/^#/, '');
153
+ return {
154
+ r: parseInt(clean.slice(0, 2), 16),
155
+ g: parseInt(clean.slice(2, 4), 16),
156
+ b: parseInt(clean.slice(4, 6), 16),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Create a colored divider using the specified hex color.
162
+ * Falls back to muted color if hex is invalid.
163
+ * @param {string} hex - Hex color like "#E53935"
164
+ */
165
+ export function coloredDivider(hex) {
166
+ const color = hex ? hexToChalk(hex) : colors.muted;
167
+ console.log(color(' ─'.repeat(20)));
168
+ }
169
+
170
+ /**
171
+ * Detect terminal type for appropriate color sequences.
172
+ * Checks TERM_PROGRAM, TERM, WT_SESSION, and COLORTERM env vars.
173
+ * @returns {string} Terminal type: 'iterm2' | 'wezterm' | 'alacritty' |
174
+ * 'kitty' | 'ghostty' | 'windows-terminal' | 'vscode' |
175
+ * 'osc-generic' | 'unsupported'
176
+ */
177
+ export function detectTerminal() {
178
+ const termProgram = process.env.TERM_PROGRAM || '';
179
+ const term = process.env.TERM || '';
180
+ const colorTerm = process.env.COLORTERM || '';
181
+
182
+ if (termProgram === 'iTerm.app') return 'iterm2';
183
+ if (termProgram === 'WezTerm') return 'wezterm';
184
+ if (termProgram === 'ghostty') return 'ghostty';
185
+ if (termProgram === 'vscode') return 'vscode';
186
+ if (process.env.WT_SESSION) return 'windows-terminal';
187
+ if (term.includes('kitty')) return 'kitty';
188
+ if (termProgram.includes('Alacritty') || term.includes('alacritty')) return 'alacritty';
189
+
190
+ // Generic OSC support for truecolor terminals
191
+ if (colorTerm === 'truecolor' || colorTerm === '24bit') return 'osc-generic';
192
+
193
+ return 'unsupported';
194
+ }
195
+
196
+ /**
197
+ * Set terminal tab color (supports multiple terminal types).
198
+ * Works with iTerm2, WezTerm, Kitty, Alacritty, Windows Terminal.
199
+ * No-op if stdout is not a TTY or terminal is unsupported.
200
+ * @param {string} hex - Hex color like "#E53935" or "E53935"
201
+ */
202
+ export function setTabColor(hex) {
203
+ if (!process.stdout.isTTY || !hex) return;
204
+
205
+ const terminal = detectTerminal();
206
+ const rgb = hex.replace(/^#/, '');
207
+
208
+ if (!/^[0-9A-Fa-f]{6}$/.test(rgb)) return;
209
+
210
+ switch (terminal) {
211
+ case 'iterm2':
212
+ case 'osc-generic':
213
+ process.stdout.write(`\x1b]1337;SetColors=tab=${rgb}\x07`);
214
+ break;
215
+ case 'wezterm':
216
+ // WezTerm uses base64-encoded user vars
217
+ const b64 = Buffer.from(rgb).toString('base64');
218
+ process.stdout.write(`\x1b]1337;SetUserVar=tab_color=${b64}\x07`);
219
+ break;
220
+ case 'kitty':
221
+ process.stdout.write(`\x1b]30001;rgb:${rgb}\x07`);
222
+ break;
223
+ case 'alacritty':
224
+ // Background color as fallback
225
+ const r = rgb.slice(0, 2);
226
+ const g = rgb.slice(2, 4);
227
+ const b = rgb.slice(4, 6);
228
+ process.stdout.write(`\x1b]10;rgb:${r}/${g}/${b}\x07`);
229
+ break;
230
+ case 'windows-terminal':
231
+ process.stdout.write(`\x1b]9;4;1;${rgb}\x07`);
232
+ break;
233
+ // 'ghostty', 'vscode' and 'unsupported' - no-op
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Reset terminal tab color to default.
239
+ * Gracefully handles multiple terminal types.
240
+ */
241
+ export function resetTabColor() {
242
+ if (!process.stdout.isTTY) return;
243
+
244
+ const terminal = detectTerminal();
245
+
246
+ switch (terminal) {
247
+ case 'iterm2':
248
+ case 'osc-generic':
249
+ process.stdout.write('\x1b]1337;SetColors=tab=default\x07');
250
+ break;
251
+ case 'wezterm':
252
+ const b64 = Buffer.from('').toString('base64');
253
+ process.stdout.write(`\x1b]1337;SetUserVar=tab_color=${b64}\x07`);
254
+ break;
255
+ case 'kitty':
256
+ process.stdout.write('\x1b]30001;rgb:000000\x07');
257
+ break;
258
+ case 'alacritty':
259
+ process.stdout.write('\x1b]10;rgb:00/00/00\x07');
260
+ break;
261
+ case 'windows-terminal':
262
+ process.stdout.write('\x1b]9;4;0\x07');
263
+ break;
264
+ }
265
+ }
266
+
114
267
  export function formatBranchChoice(branch, type = 'local') {
115
268
  const icon = type === 'remote' ? icons.remote : icons.local;
116
269
  const typeLabel = type === 'remote' ? chalk.dim(' (remote)') : '';
117
270
  return `${icon} ${branch}${typeLabel}`;
118
271
  }
119
272
 
120
- export function formatWorktreeChoice(wt) {
121
- return `${icons.folder} ${colors.highlight(wt.name)} ${colors.muted(`→ ${wt.branch}`)}`;
273
+ export function formatWorktreeChoice(wt, color = null) {
274
+ const colorDot = colorIndicator(color);
275
+ const prefix = colorDot ? `${colorDot} ` : '';
276
+ return `${prefix}${icons.folder} ${colors.highlight(wt.name)} ${colors.muted(`→ ${wt.branch}`)}`;
122
277
  }
123
278
 
124
279
  export function showHelp() {