yggtree 1.4.3 β 1.4.4
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 +43 -12
- package/dist/commands/wt/create-branch.js +38 -26
- package/dist/commands/wt/create.js +46 -7
- package/dist/commands/wt/handoff.js +7 -0
- package/dist/commands/wt/open.js +126 -71
- package/dist/index.js +22 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -146,7 +146,10 @@ Create, manage, and navigate Git worktrees as a primary workflow, not an afterth
|
|
|
146
146
|
Work on multiple branches at the same time, each in its own isolated environment.
|
|
147
147
|
|
|
148
148
|
π§ͺ **Sandbox worktrees for experimentation**
|
|
149
|
-
Prototyping something risky? Create a sandbox
|
|
149
|
+
Prototyping something risky? Create a sandbox, try different strategies, and apply the winner back to your origin branch.
|
|
150
|
+
|
|
151
|
+
π€ **Handoff current work**
|
|
152
|
+
Started in your main checkout? Carry staged, unstaged, and untracked work into a named sandbox worktree and continue there.
|
|
150
153
|
|
|
151
154
|
π€ **AI-friendly isolation**
|
|
152
155
|
One worktree per agent, per experiment, per idea. No shared state, no collisions.
|
|
@@ -217,7 +220,13 @@ Sometimes you don't want to "commit to a branch" yet. You just want to try somet
|
|
|
217
220
|
3. **Apply**: `yggtree apply` to "push" those file changes back to your origin directory.
|
|
218
221
|
4. **Unapply**: Don't like it? `yggtree unapply` restores your origin to exactly how it was before.
|
|
219
222
|
|
|
220
|
-
Sandboxes are **not pushed to remote
|
|
223
|
+
Sandboxes are **not pushed to remote**. Omit the name for a generated temporary sandbox, or provide one when the work needs to be easy to find later.
|
|
224
|
+
|
|
225
|
+
Use `handoff` when you started in the origin checkout and want to continue that dirty work in a sandbox:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
yggtree handoff --name auth-refactor
|
|
229
|
+
```
|
|
221
230
|
|
|
222
231
|
---
|
|
223
232
|
|
|
@@ -269,11 +278,13 @@ Options:
|
|
|
269
278
|
* `--source local|remote`
|
|
270
279
|
* `--no-bootstrap`
|
|
271
280
|
* `--open / --no-open`
|
|
281
|
+
* `--enter / --no-enter`
|
|
272
282
|
* `--exec "<command>"`
|
|
273
283
|
|
|
274
284
|
Interactive flow:
|
|
275
285
|
|
|
276
286
|
* Instead of asking for a free-form `exec` command, yggtree now asks if you want to open an editor after creation.
|
|
287
|
+
* After creation, yggtree enters the new worktree shell unless you pass `--no-enter`.
|
|
277
288
|
* `--exec` remains available as an advanced explicit override.
|
|
278
289
|
|
|
279
290
|
<details>
|
|
@@ -307,15 +318,17 @@ Options:
|
|
|
307
318
|
* `-r, --ref <ref>`: skip picker and use a specific branch (`feature/x` or `origin/feature/x`)
|
|
308
319
|
* `--no-bootstrap`
|
|
309
320
|
* `--open / --no-open`: choose whether to open editors or run a startup command before the worktree shell starts
|
|
310
|
-
* `--tool <command>`: open a specific editor
|
|
321
|
+
* `--tool <command>`: open a specific editor, app, or terminal target and skip the open prompt (`cursor`, `code`, `codex-app`, `cmux`, `tmux`)
|
|
311
322
|
* `--no-enter`: finish after create/open and return to the caller
|
|
312
323
|
* `--exec "<command>"`
|
|
313
324
|
|
|
314
325
|
Interactive flow:
|
|
315
326
|
|
|
316
327
|
* Yggtree asks what to open before starting the worktree shell.
|
|
317
|
-
*
|
|
318
|
-
*
|
|
328
|
+
* Shell-entry flows use a single action picker, so pressing Enter on Cmux or Tmux chooses that terminal target directly.
|
|
329
|
+
* Plain `yggtree open` flows use the same single action picker and return after opening the selected target.
|
|
330
|
+
* Use `--tool` to skip the open prompt and launch one editor/app or terminal target directly.
|
|
331
|
+
* Cmux, Tmux, and `Other command...` are mutually exclusive because the open picker accepts one action.
|
|
319
332
|
* `Other command...` runs a command in the Yggtree shell first, then leaves you there.
|
|
320
333
|
* Use `--no-enter` when you only want the worktree created/opened and the command to return.
|
|
321
334
|
* `--exec` remains available as an advanced explicit override.
|
|
@@ -354,6 +367,21 @@ Interactive flow:
|
|
|
354
367
|
|
|
355
368
|
---
|
|
356
369
|
|
|
370
|
+
### `yggtree handoff`
|
|
371
|
+
|
|
372
|
+
Carry uncommitted work from the current checkout into a sandbox worktree.
|
|
373
|
+
|
|
374
|
+
Options:
|
|
375
|
+
|
|
376
|
+
* `-n, --name <name>`: Optional handoff name (prompted when omitted).
|
|
377
|
+
* `--no-bootstrap`
|
|
378
|
+
* `--open / --no-open`
|
|
379
|
+
* `--exec "<command>"`
|
|
380
|
+
|
|
381
|
+
This is the continuation-focused version of `create-sandbox --carry`: it keeps sandbox metadata and apply/unapply behavior, but defaults to carrying staged, unstaged, and untracked files.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
357
385
|
### `yggtree apply`
|
|
358
386
|
|
|
359
387
|
Apply changes from the current sandbox back to the origin repository.
|
|
@@ -415,20 +443,22 @@ Notes:
|
|
|
415
443
|
|
|
416
444
|
### `yggtree open [worktree]`
|
|
417
445
|
|
|
418
|
-
Open a worktree in an editor
|
|
446
|
+
Open a worktree in an editor, supported desktop app, or terminal target.
|
|
419
447
|
|
|
420
448
|
Behavior:
|
|
421
449
|
|
|
422
450
|
* If `[worktree]` is omitted, you can pick from the worktree list with type-to-filter search.
|
|
423
451
|
* Detects available editor commands in your `PATH` (for example: `cursor`, `code`, `zed`, `webstorm`).
|
|
424
452
|
* Detects Codex App on macOS and launches it with `open -b com.openai.codex`.
|
|
425
|
-
*
|
|
426
|
-
*
|
|
453
|
+
* Detects Cmux and Tmux when their CLI commands are available.
|
|
454
|
+
* Lets you choose one editor, app, or terminal target interactively, or pass `--tool`.
|
|
455
|
+
* Keeps Cmux, Tmux, and `Other command...` mutually exclusive by using a single action picker.
|
|
456
|
+
* By default, `open` launches the selected target and returns, except foreground terminal targets such as Tmux.
|
|
427
457
|
* Use `wc --open` when you want to open a worktree and continue in its shell.
|
|
428
458
|
|
|
429
459
|
Options:
|
|
430
460
|
|
|
431
|
-
* `--tool <command>` (for example: `cursor`, `code`, `codex`,
|
|
461
|
+
* `--tool <command>` (for example: `cursor`, `code`, `codex`, `codex-app`, `cmux`, or `tmux`)
|
|
432
462
|
|
|
433
463
|
<details>
|
|
434
464
|
<summary>Examples</summary>
|
|
@@ -437,6 +467,7 @@ Options:
|
|
|
437
467
|
yggtree open
|
|
438
468
|
yggtree open feat/new-ui --tool cursor
|
|
439
469
|
yggtree open feat/new-ui --tool codex-app
|
|
470
|
+
yggtree open feat/new-ui --tool tmux
|
|
440
471
|
yggtree list --open
|
|
441
472
|
```
|
|
442
473
|
|
|
@@ -531,12 +562,12 @@ yggtree create feat/login-flow
|
|
|
531
562
|
---
|
|
532
563
|
|
|
533
564
|
<details>
|
|
534
|
-
<summary>Create a worktree without bootstrap
|
|
565
|
+
<summary>Create a worktree without bootstrap, opening a tool, or entering</summary>
|
|
535
566
|
|
|
536
567
|
**Command:**
|
|
537
568
|
|
|
538
569
|
```
|
|
539
|
-
yggtree create feat/cleanup-api --no-bootstrap --no-open
|
|
570
|
+
yggtree create feat/cleanup-api --no-bootstrap --no-open --no-enter
|
|
540
571
|
```
|
|
541
572
|
|
|
542
573
|
**When to use:**
|
|
@@ -636,7 +667,7 @@ yggtree create-sandbox --carry
|
|
|
636
667
|
**Scenario:**
|
|
637
668
|
|
|
638
669
|
1. You have 5 files changed in your main repo but aren't sure about the direction.
|
|
639
|
-
2. Run `
|
|
670
|
+
2. Run `handoff --name risky-refactor` to carry those changes into an isolated `sandbox-risky-refactor` folder.
|
|
640
671
|
3. Experiment freely.
|
|
641
672
|
4. If it works: `yggtree apply`.
|
|
642
673
|
5. If it fails: Just delete the sandbox or `unapply`.
|
|
@@ -7,15 +7,17 @@ import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
|
7
7
|
import { log, ui, createSpinner } from '../../lib/ui.js';
|
|
8
8
|
import { promptAndCopyEnvFiles } from '../../lib/env-files.js';
|
|
9
9
|
import { detectInstalledOpenTools, launchOpenTool, promptOpenToolSelection, } from './open.js';
|
|
10
|
+
import { enterCommand } from './enter.js';
|
|
10
11
|
import { execa } from 'execa';
|
|
11
12
|
import fs from 'fs-extra';
|
|
13
|
+
export function shouldEnterCreatedWorktree(options) {
|
|
14
|
+
return options.enter !== false;
|
|
15
|
+
}
|
|
12
16
|
export async function createCommandNew(options) {
|
|
13
17
|
try {
|
|
14
18
|
const repoRoot = await getRepoRoot();
|
|
15
19
|
log.info(`Repo: ${chalk.dim(repoRoot)}`);
|
|
16
|
-
|
|
17
|
-
log.warning('`--enter` / `--no-enter` is deprecated. Use `--open` / `--no-open` instead.');
|
|
18
|
-
}
|
|
20
|
+
const shouldEnterShell = shouldEnterCreatedWorktree(options);
|
|
19
21
|
// 1. Gather inputs
|
|
20
22
|
const currentBranch = await getCurrentBranch();
|
|
21
23
|
const answers = await inquirer.prompt([
|
|
@@ -68,9 +70,7 @@ export async function createCommandNew(options) {
|
|
|
68
70
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
69
71
|
const shouldOpenTool = options.open !== undefined
|
|
70
72
|
? options.open
|
|
71
|
-
:
|
|
72
|
-
? options.enter
|
|
73
|
-
: Boolean(answers.shouldOpenTool);
|
|
73
|
+
: Boolean(answers.shouldOpenTool);
|
|
74
74
|
let selectedTool;
|
|
75
75
|
if (options.exec === undefined && shouldOpenTool) {
|
|
76
76
|
const installedTools = await detectInstalledOpenTools();
|
|
@@ -153,35 +153,47 @@ export async function createCommandNew(options) {
|
|
|
153
153
|
if (shouldBootstrap) {
|
|
154
154
|
await runBootstrap(wtPath, repoRoot);
|
|
155
155
|
}
|
|
156
|
-
// 5.
|
|
156
|
+
// 5. Post-create actions
|
|
157
157
|
if (options.exec && options.exec.trim()) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
await execa(options.exec, {
|
|
161
|
-
cwd: wtPath,
|
|
162
|
-
stdio: 'inherit',
|
|
163
|
-
shell: true
|
|
164
|
-
});
|
|
158
|
+
if (shouldEnterShell) {
|
|
159
|
+
await enterCommand(wtPath, { exec: options.exec });
|
|
165
160
|
}
|
|
166
|
-
|
|
167
|
-
log.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
161
|
+
else {
|
|
162
|
+
log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
|
|
163
|
+
try {
|
|
164
|
+
await execa(options.exec, {
|
|
165
|
+
cwd: wtPath,
|
|
166
|
+
stdio: 'inherit',
|
|
167
|
+
shell: true
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
log.actionableError(error.message, options.exec, wtPath, [
|
|
172
|
+
`cd ${wtPath} && ${options.exec}`,
|
|
173
|
+
'Check your command syntax and environment variables'
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
|
-
else
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
else {
|
|
179
|
+
if (selectedTool) {
|
|
180
|
+
try {
|
|
181
|
+
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
|
|
182
|
+
await launchOpenTool(selectedTool, wtPath);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
|
|
186
|
+
}
|
|
177
187
|
}
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
if (shouldEnterShell) {
|
|
189
|
+
await enterCommand(wtPath);
|
|
180
190
|
}
|
|
181
191
|
}
|
|
182
192
|
// 6. Final Output
|
|
183
193
|
log.success('Worktree ready!');
|
|
184
|
-
|
|
194
|
+
if (!shouldEnterShell) {
|
|
195
|
+
log.header(`cd "${wtPath}"`);
|
|
196
|
+
}
|
|
185
197
|
}
|
|
186
198
|
catch (error) {
|
|
187
199
|
log.actionableError(error.message, 'yggtree wt create');
|
|
@@ -74,6 +74,30 @@ export function findExistingBranchWorktree(worktrees, branchName) {
|
|
|
74
74
|
!wt.prunable &&
|
|
75
75
|
fs.pathExistsSync(wt.path));
|
|
76
76
|
}
|
|
77
|
+
function normalizeExistingPath(value) {
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync(value);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return path.resolve(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function findWorktreeByPath(worktrees, wtPath) {
|
|
86
|
+
const normalizedPath = normalizeExistingPath(wtPath);
|
|
87
|
+
return worktrees.find(wt => normalizeExistingPath(wt.path) === normalizedPath);
|
|
88
|
+
}
|
|
89
|
+
export function getWorktreePathCollisionMessage(worktreeName, wtPath, worktrees) {
|
|
90
|
+
if (!fs.pathExistsSync(wtPath))
|
|
91
|
+
return undefined;
|
|
92
|
+
const occupyingWorktree = findWorktreeByPath(worktrees, wtPath);
|
|
93
|
+
if (occupyingWorktree?.branch) {
|
|
94
|
+
return `Worktree name "${worktreeName}" is already used by branch "${occupyingWorktree.branch}".`;
|
|
95
|
+
}
|
|
96
|
+
if (occupyingWorktree?.prunable) {
|
|
97
|
+
return `Worktree path exists but Git marks its metadata as prunable: ${occupyingWorktree.prunable}.`;
|
|
98
|
+
}
|
|
99
|
+
return 'Worktree path already exists.';
|
|
100
|
+
}
|
|
77
101
|
export async function createCommand(options) {
|
|
78
102
|
try {
|
|
79
103
|
const repoRoot = await ensureRepoContext();
|
|
@@ -179,6 +203,8 @@ export async function createCommand(options) {
|
|
|
179
203
|
}
|
|
180
204
|
// 3. Gather remaining inputs
|
|
181
205
|
const defaultSlug = toSlug(selectedBranch.branchName);
|
|
206
|
+
const repoName = await getRepoName();
|
|
207
|
+
const resolveWorktreePath = (name) => path.join(WORKTREES_ROOT, repoName, toSlug(name));
|
|
182
208
|
const answers = await inquirer.prompt([
|
|
183
209
|
{
|
|
184
210
|
type: 'input',
|
|
@@ -186,7 +212,12 @@ export async function createCommand(options) {
|
|
|
186
212
|
message: 'Worktree name (slug):',
|
|
187
213
|
default: options.name || defaultSlug,
|
|
188
214
|
when: !options.name,
|
|
189
|
-
validate: (input) =>
|
|
215
|
+
validate: (input) => {
|
|
216
|
+
const slug = toSlug(input);
|
|
217
|
+
if (!slug)
|
|
218
|
+
return 'Name is required';
|
|
219
|
+
return getWorktreePathCollisionMessage(slug, resolveWorktreePath(input), existingWorktrees) || true;
|
|
220
|
+
},
|
|
190
221
|
},
|
|
191
222
|
{
|
|
192
223
|
type: 'confirm',
|
|
@@ -206,6 +237,20 @@ export async function createCommand(options) {
|
|
|
206
237
|
},
|
|
207
238
|
]);
|
|
208
239
|
const name = options.name || answers.name;
|
|
240
|
+
const slug = toSlug(name);
|
|
241
|
+
const wtPath = resolveWorktreePath(name);
|
|
242
|
+
// 4. Validation
|
|
243
|
+
if (!slug)
|
|
244
|
+
throw new Error('Invalid name');
|
|
245
|
+
const pathCollisionMessage = getWorktreePathCollisionMessage(slug, wtPath, existingWorktrees);
|
|
246
|
+
if (pathCollisionMessage) {
|
|
247
|
+
log.actionableError(pathCollisionMessage, `yggtree wc --name ${slug}`, wtPath, [
|
|
248
|
+
'Choose a different worktree name with --name',
|
|
249
|
+
`Inspect the occupying path: ls ${wtPath}`,
|
|
250
|
+
'Use yggtree wt prune only if Git reports stale worktree metadata',
|
|
251
|
+
]);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
209
254
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
210
255
|
const shouldOpenTool = options.tool
|
|
211
256
|
? true
|
|
@@ -234,12 +279,6 @@ export async function createCommand(options) {
|
|
|
234
279
|
});
|
|
235
280
|
}
|
|
236
281
|
}
|
|
237
|
-
const slug = toSlug(name);
|
|
238
|
-
const repoName = await getRepoName();
|
|
239
|
-
const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
|
|
240
|
-
// 4. Validation
|
|
241
|
-
if (!slug)
|
|
242
|
-
throw new Error('Invalid name');
|
|
243
282
|
// 5. Execution (checkout-style: attach to selected branch)
|
|
244
283
|
const spinner = createSpinner(`Creating worktree at ${ui.path(wtPath)}...`).start();
|
|
245
284
|
try {
|
package/dist/commands/wt/open.js
CHANGED
|
@@ -25,9 +25,13 @@ export const OPEN_TOOL_CANDIDATES = [
|
|
|
25
25
|
aliases: ['codex', 'codex-app'],
|
|
26
26
|
bundleId: 'com.openai.codex',
|
|
27
27
|
},
|
|
28
|
+
{ id: 'cmux', name: 'Cmux Panel', command: 'cmux', kind: 'terminal', aliases: ['cmux', 'cmux-panel'] },
|
|
29
|
+
{ id: 'tmux', name: 'Tmux Session', command: 'tmux', kind: 'terminal', aliases: ['tmux', 'tmux-session'] },
|
|
28
30
|
];
|
|
29
31
|
const OTHER_COMMAND_ACTION = '__other_command__';
|
|
30
32
|
const NO_OPEN_ACTION = '__no_open__';
|
|
33
|
+
const OPEN_ACTION_PAGE_SIZE = 16;
|
|
34
|
+
const CMUX_TERMINAL_STARTUP_DELAY_MS = 350;
|
|
31
35
|
const yggtreeAccent = chalk.hex('#DEADED');
|
|
32
36
|
function truncateEnd(value, maxLen) {
|
|
33
37
|
if (maxLen <= 0)
|
|
@@ -61,9 +65,6 @@ function formatWorktreeChoiceLabel(type, branchName, displayPath, terminalColumn
|
|
|
61
65
|
function openRail() {
|
|
62
66
|
return yggtreeAccent('β');
|
|
63
67
|
}
|
|
64
|
-
function openSectionLabel(label) {
|
|
65
|
-
return `${yggtreeAccent('β')} ${chalk.bold(label)}`;
|
|
66
|
-
}
|
|
67
68
|
function formatOpenColumns(name, command, detail) {
|
|
68
69
|
const paddedName = name.padEnd(16);
|
|
69
70
|
const paddedCommand = command.padEnd(13);
|
|
@@ -73,6 +74,51 @@ function formatOpenColumns(name, command, detail) {
|
|
|
73
74
|
export function formatOpenToolChoice(tool) {
|
|
74
75
|
return formatOpenColumns(tool.name, tool.command, '');
|
|
75
76
|
}
|
|
77
|
+
function shellQuote(value) {
|
|
78
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
79
|
+
}
|
|
80
|
+
function formatTerminalSessionName(wtPath) {
|
|
81
|
+
const basename = path.basename(wtPath) || 'worktree';
|
|
82
|
+
const slug = basename.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
83
|
+
return `yggtree-${slug || 'worktree'}`.slice(0, 80);
|
|
84
|
+
}
|
|
85
|
+
export function buildTmuxLaunchCommand(wtPath, env = process.env) {
|
|
86
|
+
const sessionName = formatTerminalSessionName(wtPath);
|
|
87
|
+
if (env.TMUX) {
|
|
88
|
+
return {
|
|
89
|
+
executable: 'tmux',
|
|
90
|
+
args: ['new-window', '-c', wtPath, '-n', sessionName],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
executable: 'tmux',
|
|
95
|
+
args: ['new-session', '-A', '-s', sessionName, '-c', wtPath],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function buildCmuxNewPaneCommand() {
|
|
99
|
+
return {
|
|
100
|
+
executable: 'cmux',
|
|
101
|
+
args: ['new-pane', '--type', 'terminal', '--direction', 'right', '--focus', 'true'],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function parseCmuxSurfaceRef(output) {
|
|
105
|
+
return output.match(/\bsurface:\S+/)?.[0];
|
|
106
|
+
}
|
|
107
|
+
function delay(ms) {
|
|
108
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
109
|
+
}
|
|
110
|
+
export function buildCmuxSendCommand(surfaceRef, wtPath) {
|
|
111
|
+
return {
|
|
112
|
+
executable: 'cmux',
|
|
113
|
+
args: ['send', '--surface', surfaceRef, `cd ${shellQuote(wtPath)}`],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function buildCmuxEnterCommand(surfaceRef) {
|
|
117
|
+
return {
|
|
118
|
+
executable: 'cmux',
|
|
119
|
+
args: ['send-key', '--surface', surfaceRef, 'Enter'],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
76
122
|
export function formatOpenSpecialChoice(value, allowShellAction) {
|
|
77
123
|
if (value === OTHER_COMMAND_ACTION) {
|
|
78
124
|
return formatOpenColumns('Other command', 'custom', 'run first, then stay in shell');
|
|
@@ -146,8 +192,23 @@ export function resolveOpenToolOption(input, installed) {
|
|
|
146
192
|
tool.command.toLowerCase() === normalized ||
|
|
147
193
|
tool.aliases?.some(alias => alias.toLowerCase() === normalized));
|
|
148
194
|
}
|
|
195
|
+
export function resolveOpenToolCandidate(input) {
|
|
196
|
+
const normalized = input.trim().toLowerCase();
|
|
197
|
+
return OPEN_TOOL_CANDIDATES.find(tool => tool.id === normalized ||
|
|
198
|
+
tool.command.toLowerCase() === normalized ||
|
|
199
|
+
tool.aliases?.some(alias => alias.toLowerCase() === normalized));
|
|
200
|
+
}
|
|
201
|
+
async function resolveKnownOpenToolOption(input) {
|
|
202
|
+
const candidate = resolveOpenToolCandidate(input);
|
|
203
|
+
if (!candidate)
|
|
204
|
+
return undefined;
|
|
205
|
+
const exists = candidate.bundleId
|
|
206
|
+
? await macOSAppBundleExists(candidate.bundleId)
|
|
207
|
+
: await commandExists(candidate.command);
|
|
208
|
+
return exists ? candidate : undefined;
|
|
209
|
+
}
|
|
149
210
|
export async function resolveOpenToolAction(input, installed) {
|
|
150
|
-
let chosenTool = resolveOpenToolOption(input, installed);
|
|
211
|
+
let chosenTool = resolveOpenToolOption(input, installed) || await resolveKnownOpenToolOption(input);
|
|
151
212
|
if (!chosenTool && await commandExists(input)) {
|
|
152
213
|
chosenTool = {
|
|
153
214
|
id: 'custom',
|
|
@@ -159,27 +220,11 @@ export async function resolveOpenToolAction(input, installed) {
|
|
|
159
220
|
return chosenTool ? { type: 'tool', tool: chosenTool } : undefined;
|
|
160
221
|
}
|
|
161
222
|
export async function promptOpenToolSelection(installedTools, message = 'Select tool to open:') {
|
|
162
|
-
const
|
|
163
|
-
.filter(tool => tool.kind === 'editor')
|
|
223
|
+
const choices = installedTools
|
|
164
224
|
.map(tool => ({
|
|
165
225
|
name: formatOpenToolChoice(tool),
|
|
166
226
|
value: tool,
|
|
167
227
|
}));
|
|
168
|
-
const appChoices = installedTools
|
|
169
|
-
.filter(tool => tool.kind === 'app')
|
|
170
|
-
.map(tool => ({
|
|
171
|
-
name: formatOpenToolChoice(tool),
|
|
172
|
-
value: tool,
|
|
173
|
-
}));
|
|
174
|
-
const choices = [];
|
|
175
|
-
if (ideChoices.length > 0) {
|
|
176
|
-
choices.push(new inquirer.Separator(openSectionLabel('Editors & IDEs')));
|
|
177
|
-
choices.push(...ideChoices);
|
|
178
|
-
}
|
|
179
|
-
if (appChoices.length > 0) {
|
|
180
|
-
choices.push(new inquirer.Separator(openSectionLabel('Apps')));
|
|
181
|
-
choices.push(...appChoices);
|
|
182
|
-
}
|
|
183
228
|
const { selectedTool } = await inquirer.prompt([
|
|
184
229
|
{
|
|
185
230
|
type: 'list',
|
|
@@ -187,79 +232,55 @@ export async function promptOpenToolSelection(installedTools, message = 'Select
|
|
|
187
232
|
message,
|
|
188
233
|
choices,
|
|
189
234
|
loop: false,
|
|
190
|
-
pageSize:
|
|
235
|
+
pageSize: OPEN_ACTION_PAGE_SIZE,
|
|
191
236
|
},
|
|
192
237
|
]);
|
|
193
238
|
return selectedTool;
|
|
194
239
|
}
|
|
195
|
-
export function buildOpenActionsFromSelection(
|
|
196
|
-
if (
|
|
240
|
+
export function buildOpenActionsFromSelection(selectedValue, installedTools, otherCommand) {
|
|
241
|
+
if (selectedValue === NO_OPEN_ACTION)
|
|
197
242
|
return [];
|
|
198
|
-
|
|
199
|
-
.
|
|
200
|
-
|
|
201
|
-
.filter((tool) => Boolean(tool))
|
|
202
|
-
.map(tool => ({ type: 'tool', tool }));
|
|
203
|
-
if (selectedValues.includes(OTHER_COMMAND_ACTION) && otherCommand?.trim()) {
|
|
204
|
-
actions.push({ type: 'other-command', command: otherCommand.trim() });
|
|
243
|
+
if (selectedValue.startsWith('tool:')) {
|
|
244
|
+
const tool = installedTools.find(candidate => `tool:${candidate.id}` === selectedValue);
|
|
245
|
+
return tool ? [{ type: 'tool', tool }] : [];
|
|
205
246
|
}
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
export function validateOpenActionSelection(selectedValues) {
|
|
209
|
-
if (selectedValues.includes(NO_OPEN_ACTION) && selectedValues.length > 1) {
|
|
210
|
-
return 'Choose either "Nothing" or one or more actions, not both.';
|
|
247
|
+
if (selectedValue === OTHER_COMMAND_ACTION && otherCommand?.trim()) {
|
|
248
|
+
return [{ type: 'other-command', command: otherCommand.trim() }];
|
|
211
249
|
}
|
|
212
|
-
return
|
|
250
|
+
return [];
|
|
213
251
|
}
|
|
214
|
-
export
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const appTools = installedTools.filter(tool => tool.kind === 'app');
|
|
220
|
-
if (editorTools.length > 0) {
|
|
221
|
-
choices.push(new inquirer.Separator(openSectionLabel('Editors & IDEs')));
|
|
222
|
-
choices.push(...editorTools.map(tool => ({
|
|
223
|
-
name: formatOpenToolChoice(tool),
|
|
224
|
-
value: `tool:${tool.id}`,
|
|
225
|
-
})));
|
|
226
|
-
}
|
|
227
|
-
if (appTools.length > 0) {
|
|
228
|
-
choices.push(new inquirer.Separator(openSectionLabel('Apps')));
|
|
229
|
-
choices.push(...appTools.map(tool => ({
|
|
230
|
-
name: formatOpenToolChoice(tool),
|
|
231
|
-
value: `tool:${tool.id}`,
|
|
232
|
-
})));
|
|
233
|
-
}
|
|
234
|
-
}
|
|
252
|
+
export function buildOpenActionChoices(installedTools, allowShellAction) {
|
|
253
|
+
const choices = installedTools.map(tool => ({
|
|
254
|
+
name: formatOpenToolChoice(tool),
|
|
255
|
+
value: `tool:${tool.id}`,
|
|
256
|
+
}));
|
|
235
257
|
if (allowShellAction) {
|
|
236
|
-
if (choices.length > 0)
|
|
237
|
-
choices.push(new inquirer.Separator(' '));
|
|
238
|
-
choices.push(new inquirer.Separator(openSectionLabel('Shell')));
|
|
239
258
|
choices.push({
|
|
240
259
|
name: formatOpenSpecialChoice(OTHER_COMMAND_ACTION, allowShellAction),
|
|
241
260
|
value: OTHER_COMMAND_ACTION,
|
|
242
261
|
});
|
|
243
262
|
}
|
|
244
|
-
if (choices.length > 0)
|
|
245
|
-
choices.push(new inquirer.Separator(' '));
|
|
246
263
|
choices.push({
|
|
247
264
|
name: formatOpenSpecialChoice(NO_OPEN_ACTION, allowShellAction),
|
|
248
265
|
value: NO_OPEN_ACTION,
|
|
249
266
|
});
|
|
250
|
-
|
|
267
|
+
return choices;
|
|
268
|
+
}
|
|
269
|
+
export async function promptOpenActions(installedTools, options = {}) {
|
|
270
|
+
const allowShellAction = options.allowShellAction ?? true;
|
|
271
|
+
const choices = buildOpenActionChoices(installedTools, allowShellAction);
|
|
272
|
+
const { selectedValue } = await inquirer.prompt([
|
|
251
273
|
{
|
|
252
|
-
type: '
|
|
253
|
-
name: '
|
|
274
|
+
type: 'list',
|
|
275
|
+
name: 'selectedValue',
|
|
254
276
|
message: options.message || 'Open anything before entering the shell?',
|
|
255
277
|
choices,
|
|
256
278
|
loop: false,
|
|
257
|
-
pageSize:
|
|
258
|
-
validate: validateOpenActionSelection,
|
|
279
|
+
pageSize: OPEN_ACTION_PAGE_SIZE,
|
|
259
280
|
},
|
|
260
281
|
]);
|
|
261
282
|
let otherCommand;
|
|
262
|
-
if (
|
|
283
|
+
if (selectedValue === OTHER_COMMAND_ACTION) {
|
|
263
284
|
const answer = await inquirer.prompt([
|
|
264
285
|
{
|
|
265
286
|
type: 'input',
|
|
@@ -270,9 +291,39 @@ export async function promptOpenActions(installedTools, options = {}) {
|
|
|
270
291
|
]);
|
|
271
292
|
otherCommand = answer.otherCommand;
|
|
272
293
|
}
|
|
273
|
-
return buildOpenActionsFromSelection(
|
|
294
|
+
return buildOpenActionsFromSelection(selectedValue, installedTools, otherCommand);
|
|
274
295
|
}
|
|
275
296
|
export async function launchOpenTool(tool, wtPath) {
|
|
297
|
+
if (tool.id === 'cmux') {
|
|
298
|
+
const newPaneCommand = buildCmuxNewPaneCommand();
|
|
299
|
+
const newPaneResult = await execa(newPaneCommand.executable, newPaneCommand.args, {
|
|
300
|
+
cwd: wtPath,
|
|
301
|
+
});
|
|
302
|
+
const surfaceRef = parseCmuxSurfaceRef(newPaneResult.stdout);
|
|
303
|
+
if (!surfaceRef) {
|
|
304
|
+
throw new Error(`Cmux did not return a terminal surface handle: ${newPaneResult.stdout || '<empty output>'}`);
|
|
305
|
+
}
|
|
306
|
+
await delay(CMUX_TERMINAL_STARTUP_DELAY_MS);
|
|
307
|
+
const sendCommand = buildCmuxSendCommand(surfaceRef, wtPath);
|
|
308
|
+
await execa(sendCommand.executable, sendCommand.args, {
|
|
309
|
+
cwd: wtPath,
|
|
310
|
+
stdio: 'ignore',
|
|
311
|
+
});
|
|
312
|
+
const enterCommand = buildCmuxEnterCommand(surfaceRef);
|
|
313
|
+
await execa(enterCommand.executable, enterCommand.args, {
|
|
314
|
+
cwd: wtPath,
|
|
315
|
+
stdio: 'ignore',
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (tool.id === 'tmux') {
|
|
320
|
+
const { executable, args } = buildTmuxLaunchCommand(wtPath);
|
|
321
|
+
await execa(executable, args, {
|
|
322
|
+
cwd: wtPath,
|
|
323
|
+
stdio: 'inherit',
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
276
327
|
const { executable, args } = buildOpenToolLaunchCommand(tool, wtPath);
|
|
277
328
|
await execa(executable, args, {
|
|
278
329
|
cwd: wtPath,
|
|
@@ -292,10 +343,14 @@ export function buildOpenToolLaunchCommand(tool, wtPath) {
|
|
|
292
343
|
};
|
|
293
344
|
}
|
|
294
345
|
export async function runOpenActions(actions, wtPath, options = {}) {
|
|
346
|
+
let openedTerminal = false;
|
|
295
347
|
for (const action of actions) {
|
|
296
348
|
if (action.type === 'tool' && action.tool) {
|
|
297
349
|
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(action.tool.name)}...`);
|
|
298
350
|
await launchOpenTool(action.tool, wtPath);
|
|
351
|
+
if (action.tool.kind === 'terminal') {
|
|
352
|
+
openedTerminal = true;
|
|
353
|
+
}
|
|
299
354
|
}
|
|
300
355
|
}
|
|
301
356
|
const shellAction = actions.find(action => action.type === 'other-command');
|
|
@@ -303,7 +358,7 @@ export async function runOpenActions(actions, wtPath, options = {}) {
|
|
|
303
358
|
await enterCommand(wtPath, { exec: shellAction.command });
|
|
304
359
|
return;
|
|
305
360
|
}
|
|
306
|
-
if (options.enter !== false) {
|
|
361
|
+
if (options.enter !== false && !openedTerminal) {
|
|
307
362
|
await enterCommand(wtPath);
|
|
308
363
|
}
|
|
309
364
|
}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { pathCommand } from './commands/wt/path.js';
|
|
|
15
15
|
import { openCommand } from './commands/wt/open.js';
|
|
16
16
|
import { applyCommand } from './commands/wt/apply.js';
|
|
17
17
|
import { unapplyCommand } from './commands/wt/unapply.js';
|
|
18
|
+
import { handoffCommand } from './commands/wt/handoff.js';
|
|
18
19
|
import { getVersion } from './lib/version.js';
|
|
19
20
|
import { notifyIfUpdateAvailable } from './lib/update-check.js';
|
|
20
21
|
import { findSandboxRoot } from './lib/sandbox.js';
|
|
@@ -41,8 +42,8 @@ function registerWorktreeCommands(parent) {
|
|
|
41
42
|
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
42
43
|
.option('--open', 'Open an editor after creation')
|
|
43
44
|
.option('--no-open', 'Skip opening an editor after creation')
|
|
44
|
-
.
|
|
45
|
-
.
|
|
45
|
+
.option('--enter', 'Enter the worktree sub-shell after creation')
|
|
46
|
+
.option('--no-enter', 'Do not enter the worktree sub-shell after creation')
|
|
46
47
|
.option('--exec <command>', 'Command to execute after creation')
|
|
47
48
|
.action(async (branch, options) => {
|
|
48
49
|
await createCommandNew({
|
|
@@ -66,7 +67,7 @@ function registerWorktreeCommands(parent) {
|
|
|
66
67
|
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
67
68
|
.option('--open', 'Open editors or run a startup command before entering')
|
|
68
69
|
.option('--no-open', 'Skip opening editors or startup commands before entering')
|
|
69
|
-
.option('--tool <command>', 'Editor or
|
|
70
|
+
.option('--tool <command>', 'Editor, app, or terminal command to open after checkout (skips open prompt)')
|
|
70
71
|
.addOption(new Option('--enter', 'Enter the worktree sub-shell after checkout/opening').hideHelp())
|
|
71
72
|
.option('--no-enter', 'Do not enter the worktree sub-shell after checkout/opening')
|
|
72
73
|
.option('--exec <command>', 'Command to execute after creation')
|
|
@@ -87,8 +88,8 @@ function registerWorktreeCommands(parent) {
|
|
|
87
88
|
await deleteCommand(options);
|
|
88
89
|
});
|
|
89
90
|
parent.command('open [worktree]')
|
|
90
|
-
.description('Open a worktree in an editor or
|
|
91
|
-
.option('--tool <command>', 'Editor or
|
|
91
|
+
.description('Open a worktree in an editor, supported app, or terminal target')
|
|
92
|
+
.option('--tool <command>', 'Editor, app, or terminal command to use (e.g. cursor, code, codex-app, tmux)')
|
|
92
93
|
.option('--enter', 'Enter the worktree sub-shell after opening')
|
|
93
94
|
.addOption(new Option('--no-enter', 'Do not enter the worktree sub-shell after opening').hideHelp())
|
|
94
95
|
.action(async (worktree, options) => {
|
|
@@ -126,6 +127,18 @@ function registerWorktreeCommands(parent) {
|
|
|
126
127
|
.action(async (options) => {
|
|
127
128
|
await createSandboxCommand(options);
|
|
128
129
|
});
|
|
130
|
+
parent.command('handoff')
|
|
131
|
+
.description('Carry uncommitted work into a sandbox worktree')
|
|
132
|
+
.option('-n, --name <name>', 'Optional handoff name (prompted when omitted)')
|
|
133
|
+
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
134
|
+
.option('--open', 'Open an editor after creation')
|
|
135
|
+
.option('--no-open', 'Skip opening an editor after creation')
|
|
136
|
+
.addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
|
|
137
|
+
.addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
|
|
138
|
+
.option('--exec <command>', 'Command to execute after creation')
|
|
139
|
+
.action(async (options) => {
|
|
140
|
+
await handoffCommand(options);
|
|
141
|
+
});
|
|
129
142
|
parent.command('apply')
|
|
130
143
|
.description('Apply sandbox changes to origin directory')
|
|
131
144
|
.action(applyCommand);
|
|
@@ -158,6 +171,7 @@ program
|
|
|
158
171
|
{ name: `πΏ Grow New Realm ${chalk.dim('(create worktree)')}`, value: 'create-smart' },
|
|
159
172
|
{ name: `π Traverse to Another Realm ${chalk.dim('(checkout existing branch in new worktree)')}`, value: 'worktree-checkout' },
|
|
160
173
|
{ name: `π³ Grow Many Realms ${chalk.dim('(create multiple worktrees)')}`, value: 'create-multi' },
|
|
174
|
+
{ name: `π€ Hand Off Current Work ${chalk.dim('(carry dirty work into a sandbox)')}`, value: 'handoff' },
|
|
161
175
|
{ name: `π§ͺ Forge Sandbox Realm ${chalk.dim('(create sandbox worktree)')}`, value: 'create-sandbox' },
|
|
162
176
|
{ name: `πΊοΈ Survey Realms ${chalk.dim('(list worktrees)')}`, value: 'list' },
|
|
163
177
|
{ name: `π§ Open Realm in Editor ${chalk.dim('(open worktree in editor)')}`, value: 'open' },
|
|
@@ -240,6 +254,9 @@ program
|
|
|
240
254
|
case 'create-sandbox':
|
|
241
255
|
await createSandboxCommand({ bootstrap: true });
|
|
242
256
|
break;
|
|
257
|
+
case 'handoff':
|
|
258
|
+
await handoffCommand({ bootstrap: true });
|
|
259
|
+
break;
|
|
243
260
|
case 'bifrost':
|
|
244
261
|
await bifrostCommand();
|
|
245
262
|
break;
|