@jhorst11/wt 2.0.1 → 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 +39 -3
- package/bin/wt.js +5 -4
- package/package.json +1 -1
- package/src/commands.js +172 -36
- package/src/config.js +181 -12
- package/src/git.js +19 -0
- package/src/ui.js +161 -6
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
|
|
130
|
-
| `WT_PATH` | Absolute path to the
|
|
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
|
-
|
|
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
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
|
-
|
|
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') {
|
|
@@ -372,11 +394,19 @@ export async function createWorktreeFlow() {
|
|
|
372
394
|
color: 'magenta',
|
|
373
395
|
}).start();
|
|
374
396
|
|
|
375
|
-
const hookResults = runHooks(
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`)}
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|