yggtree 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -10
- package/dist/commands/wt/create-branch.js +26 -6
- package/dist/commands/wt/create-sandbox.js +63 -10
- package/dist/commands/wt/create.js +157 -57
- package/dist/commands/wt/delete.js +43 -18
- package/dist/commands/wt/enter.js +43 -31
- package/dist/commands/wt/exec.js +4 -17
- package/dist/commands/wt/list.js +20 -10
- package/dist/commands/wt/open.js +245 -0
- package/dist/commands/wt/path.js +4 -17
- package/dist/index.js +66 -25
- package/dist/lib/prompt.js +9 -0
- package/dist/lib/sandbox.js +14 -0
- package/dist/lib/worktree.js +52 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -162,7 +162,7 @@ Sometimes you don't want to "commit to a branch" yet. You just want to try somet
|
|
|
162
162
|
|
|
163
163
|
**Sandboxes** are temporary, local-only worktrees designed for this:
|
|
164
164
|
|
|
165
|
-
1. **Create**: `yggtree wt create-sandbox` (creates `
|
|
165
|
+
1. **Create**: `yggtree wt create-sandbox` (creates something like `sandbox-a3f2_feature-branch`).
|
|
166
166
|
2. **Experiment**: Change files, run tests, try that risky refactor.
|
|
167
167
|
3. **Apply**: `yggtree wt apply` to "push" those file changes back to your origin directory.
|
|
168
168
|
4. **Unapply**: Don't like it? `yggtree wt unapply` restores your origin to exactly how it was before.
|
|
@@ -177,10 +177,13 @@ Yggdrasil automatically prepares each worktree.
|
|
|
177
177
|
|
|
178
178
|
Resolution order:
|
|
179
179
|
|
|
180
|
-
1.
|
|
181
|
-
2. `yggtree-worktree.json` in the repo root
|
|
182
|
-
3. `.cursor/worktrees.json`
|
|
183
|
-
4.
|
|
180
|
+
1. `.yggtree/worktree-setup.json` in the repo root
|
|
181
|
+
2. `yggtree-worktree.json` in the repo root (legacy fallback)
|
|
182
|
+
3. `.cursor/worktrees.json` in the repo root (legacy fallback)
|
|
183
|
+
4. `.yggtree/worktree-setup.json` inside the worktree (per-worktree fallback)
|
|
184
|
+
5. `yggtree-worktree.json` inside the worktree (legacy fallback)
|
|
185
|
+
6. `.cursor/worktrees.json` inside the worktree (legacy fallback)
|
|
186
|
+
7. Fallback: `npm install` + submodules
|
|
184
187
|
|
|
185
188
|
### Example configuration
|
|
186
189
|
|
|
@@ -211,12 +214,18 @@ Create a worktree from a branch.
|
|
|
211
214
|
|
|
212
215
|
Options:
|
|
213
216
|
|
|
217
|
+
* `-b, --branch <name>`
|
|
214
218
|
* `--base <ref>`
|
|
215
219
|
* `--source local|remote`
|
|
216
220
|
* `--no-bootstrap`
|
|
217
221
|
* `--enter / --no-enter`
|
|
218
222
|
* `--exec "<command>"`
|
|
219
223
|
|
|
224
|
+
Interactive flow:
|
|
225
|
+
|
|
226
|
+
* Instead of asking for a free-form `exec` command, yggtree now asks if you want to open a tool after creation (IDE or agent CLI).
|
|
227
|
+
* `--exec` remains available as an advanced explicit override.
|
|
228
|
+
|
|
220
229
|
<details>
|
|
221
230
|
<summary>Example</summary>
|
|
222
231
|
|
|
@@ -228,17 +237,59 @@ yggtree wt create feat/new-ui --base main --exec "cursor ."
|
|
|
228
237
|
|
|
229
238
|
---
|
|
230
239
|
|
|
240
|
+
### `yggtree wt worktree-checkout [name] [ref]`
|
|
241
|
+
|
|
242
|
+
Create a checkout-style worktree from an existing branch.
|
|
243
|
+
|
|
244
|
+
Behavior:
|
|
245
|
+
|
|
246
|
+
* Prompts a searchable branch picker (type to filter in real time).
|
|
247
|
+
* Attaches the new worktree directly to the selected branch (checkout-style).
|
|
248
|
+
* If you select a remote-only branch (`origin/*`), yggtree creates the local branch in the new worktree automatically.
|
|
249
|
+
* If that branch already has an active yggtree-managed worktree, yggtree falls back to entering that worktree instead of creating a duplicate.
|
|
250
|
+
|
|
251
|
+
Options:
|
|
252
|
+
|
|
253
|
+
* `-n, --name <slug>`
|
|
254
|
+
* `-r, --ref <ref>`: skip picker and use a specific branch (`feature/x` or `origin/feature/x`)
|
|
255
|
+
* `--no-bootstrap`
|
|
256
|
+
* `--enter / --no-enter`
|
|
257
|
+
* `--exec "<command>"`
|
|
258
|
+
|
|
259
|
+
Interactive flow:
|
|
260
|
+
|
|
261
|
+
* Instead of asking for a free-form `exec` command, yggtree now asks if you want to open a tool after creation (IDE or agent CLI).
|
|
262
|
+
* `--exec` remains available as an advanced explicit override.
|
|
263
|
+
|
|
264
|
+
<details>
|
|
265
|
+
<summary>Example</summary>
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
yggtree wt worktree-checkout -n hotfix-auth -r main --no-enter
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
</details>
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
231
275
|
### `yggtree wt create-sandbox`
|
|
232
276
|
|
|
233
277
|
Create a temporary sandbox from your current local branch.
|
|
234
278
|
|
|
235
279
|
Options:
|
|
236
280
|
|
|
281
|
+
* `-n, --name <name>`: Optional sandbox name (auto-generated if omitted).
|
|
237
282
|
* `--carry / --no-carry`: Bring uncommitted changes (staged/unstaged/untracked) with you.
|
|
238
283
|
* `--no-bootstrap`
|
|
239
284
|
* `--enter / --no-enter`
|
|
240
285
|
* `--exec "<command>"`
|
|
241
286
|
|
|
287
|
+
Interactive flow:
|
|
288
|
+
|
|
289
|
+
* Prompts for an optional sandbox name (leave empty to auto-generate one from current branch).
|
|
290
|
+
* Instead of asking for a free-form `exec` command, yggtree now asks if you want to open a tool after creation (IDE or agent CLI).
|
|
291
|
+
* `--exec` remains available as an advanced explicit override.
|
|
292
|
+
|
|
242
293
|
---
|
|
243
294
|
|
|
244
295
|
### `yggtree wt apply`
|
|
@@ -261,6 +312,12 @@ Undo a previous `apply` operation.
|
|
|
261
312
|
|
|
262
313
|
Create multiple worktrees at once.
|
|
263
314
|
|
|
315
|
+
Options:
|
|
316
|
+
|
|
317
|
+
* `--base <ref>`
|
|
318
|
+
* `--source local|remote`
|
|
319
|
+
* `--no-bootstrap`
|
|
320
|
+
|
|
264
321
|
<details>
|
|
265
322
|
<summary>Example</summary>
|
|
266
323
|
|
|
@@ -274,14 +331,21 @@ yggtree wt create-multi --base main
|
|
|
274
331
|
|
|
275
332
|
### `yggtree wt list`
|
|
276
333
|
|
|
277
|
-
List all worktrees with state.
|
|
334
|
+
List all repo-linked worktrees with state.
|
|
278
335
|
|
|
279
336
|
Columns:
|
|
280
337
|
|
|
281
|
-
* TYPE (MAIN
|
|
338
|
+
* TYPE (`MAIN`, `MANAGED`, `LINKED`, `SANDBOX`)
|
|
282
339
|
* STATE (clean / dirty)
|
|
340
|
+
* LAST ACTIVE
|
|
283
341
|
* BRANCH
|
|
284
|
-
|
|
342
|
+
|
|
343
|
+
Notes:
|
|
344
|
+
|
|
345
|
+
* Entries are grouped by `TYPE`.
|
|
346
|
+
* `SANDBOX` and `MANAGED` are worktrees inside `~/.yggtree`.
|
|
347
|
+
* External worktrees are labeled `LINKED`.
|
|
348
|
+
* Use `--open` to switch this flow into "pick and open in tool" mode.
|
|
285
349
|
|
|
286
350
|
---
|
|
287
351
|
|
|
@@ -307,6 +371,35 @@ yggtree wt enter feat/new-ui --exec "npm test"
|
|
|
307
371
|
|
|
308
372
|
---
|
|
309
373
|
|
|
374
|
+
### `yggtree wt open [worktree]`
|
|
375
|
+
|
|
376
|
+
Open a worktree in an IDE or agent CLI.
|
|
377
|
+
|
|
378
|
+
Behavior:
|
|
379
|
+
|
|
380
|
+
* If `[worktree]` is omitted, you can pick from the worktree list with type-to-filter search.
|
|
381
|
+
* Detects available tool commands in your `PATH` (for example: IDEs like `cursor`, `code`, `zed`; agents like `claude`, `codex`, `gemini`, `opencode`).
|
|
382
|
+
* Lets you choose one interactively, or pass `--tool`.
|
|
383
|
+
* If an agent CLI is selected, yggtree opens a sub-shell and launches it there.
|
|
384
|
+
|
|
385
|
+
Options:
|
|
386
|
+
|
|
387
|
+
* `--tool <command>`
|
|
388
|
+
|
|
389
|
+
<details>
|
|
390
|
+
<summary>Examples</summary>
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
yggtree wt open
|
|
394
|
+
yggtree wt open feat/new-ui --tool cursor
|
|
395
|
+
yggtree wt open feat/new-ui --tool claude
|
|
396
|
+
yggtree wt list --open
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
</details>
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
310
403
|
### `yggtree wt exec [worktree] -- <command>`
|
|
311
404
|
|
|
312
405
|
Run a command inside a worktree **without entering**.
|
|
@@ -338,7 +431,18 @@ Re‑run bootstrap commands for a worktree.
|
|
|
338
431
|
|
|
339
432
|
### `yggtree wt delete`
|
|
340
433
|
|
|
341
|
-
Interactively delete
|
|
434
|
+
Interactively delete worktrees.
|
|
435
|
+
|
|
436
|
+
Behavior:
|
|
437
|
+
|
|
438
|
+
* Default flow targets managed worktrees.
|
|
439
|
+
* In interactive mode, yggtree asks whether to include external linked worktrees.
|
|
440
|
+
* In direct CLI usage, `--all` includes external linked worktrees (main/current are still excluded for safety).
|
|
441
|
+
* The delete selector shows 6 items per page.
|
|
442
|
+
|
|
443
|
+
Optional:
|
|
444
|
+
|
|
445
|
+
* `--all` includes linked worktrees outside `~/.yggtree` (main/current worktree is excluded for safety)
|
|
342
446
|
|
|
343
447
|
---
|
|
344
448
|
|
|
@@ -488,7 +592,7 @@ yggtree wt create-sandbox --carry
|
|
|
488
592
|
**Scenario:**
|
|
489
593
|
|
|
490
594
|
1. You have 5 files changed in your main repo but aren't sure about the direction.
|
|
491
|
-
2. Run `create-sandbox --carry` to move those changes into an isolated `
|
|
595
|
+
2. Run `create-sandbox --carry` to move those changes into an isolated `sandbox-a3f2_feature-branch` folder.
|
|
492
596
|
3. Experiment freely.
|
|
493
597
|
4. If it works: `yggtree wt apply`.
|
|
494
598
|
5. If it fails: Just delete the sandbox or `unapply`.
|
|
@@ -5,6 +5,7 @@ import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch, ensure
|
|
|
5
5
|
import { runBootstrap } from '../../lib/config.js';
|
|
6
6
|
import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
7
7
|
import { log, ui, createSpinner } from '../../lib/ui.js';
|
|
8
|
+
import { buildAgentExecCommand, detectInstalledOpenTools, isAgentTool, launchOpenTool, promptOpenToolSelection, } from './open.js';
|
|
8
9
|
import { execa } from 'execa';
|
|
9
10
|
import { spawn } from 'child_process';
|
|
10
11
|
import fs from 'fs-extra';
|
|
@@ -58,19 +59,29 @@ export async function createCommandNew(options) {
|
|
|
58
59
|
when: options.enter === undefined,
|
|
59
60
|
},
|
|
60
61
|
{
|
|
61
|
-
type: '
|
|
62
|
-
name: '
|
|
63
|
-
message: '
|
|
64
|
-
default:
|
|
62
|
+
type: 'confirm',
|
|
63
|
+
name: 'shouldOpenTool',
|
|
64
|
+
message: 'Open a tool after creation? (IDE or agent CLI)',
|
|
65
|
+
default: false,
|
|
65
66
|
when: options.exec === undefined,
|
|
66
|
-
}
|
|
67
|
+
},
|
|
67
68
|
]);
|
|
68
69
|
const branchName = options.branch || answers.branch;
|
|
69
70
|
let baseRef = options.base || answers.base;
|
|
70
71
|
const source = options.source || answers.source;
|
|
71
72
|
const shouldEnter = options.enter !== undefined ? options.enter : answers.shouldEnter;
|
|
72
73
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
73
|
-
|
|
74
|
+
let selectedTool;
|
|
75
|
+
if (options.exec === undefined && answers.shouldOpenTool) {
|
|
76
|
+
const installedTools = await detectInstalledOpenTools();
|
|
77
|
+
if (installedTools.length === 0) {
|
|
78
|
+
log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const execCommandStr = options.exec || (selectedTool && isAgentTool(selectedTool) ? buildAgentExecCommand(selectedTool) : undefined);
|
|
74
85
|
// Append origin/ if remote is selected and not already present
|
|
75
86
|
if (!options.base && source === 'remote' && !baseRef.startsWith('origin/')) {
|
|
76
87
|
baseRef = `origin/${baseRef}`;
|
|
@@ -159,6 +170,15 @@ export async function createCommandNew(options) {
|
|
|
159
170
|
]);
|
|
160
171
|
}
|
|
161
172
|
}
|
|
173
|
+
if (selectedTool && !isAgentTool(selectedTool)) {
|
|
174
|
+
try {
|
|
175
|
+
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
|
|
176
|
+
await launchOpenTool(selectedTool, wtPath);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
162
182
|
// 6. Final Output
|
|
163
183
|
log.success('Worktree ready!');
|
|
164
184
|
if (shouldEnter) {
|
|
@@ -5,7 +5,8 @@ import { getRepoRoot, getRepoName, getCurrentBranch } from '../../lib/git.js';
|
|
|
5
5
|
import { runBootstrap } from '../../lib/config.js';
|
|
6
6
|
import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
7
7
|
import { log, ui, createSpinner } from '../../lib/ui.js';
|
|
8
|
-
import { generateSandboxName, writeSandboxMeta } from '../../lib/sandbox.js';
|
|
8
|
+
import { generateSandboxName, normalizeSandboxName, writeSandboxMeta } from '../../lib/sandbox.js';
|
|
9
|
+
import { buildAgentExecCommand, detectInstalledOpenTools, isAgentTool, launchOpenTool, promptOpenToolSelection, } from './open.js';
|
|
9
10
|
import { execa } from 'execa';
|
|
10
11
|
import { spawn } from 'child_process';
|
|
11
12
|
import fs from 'fs-extra';
|
|
@@ -20,11 +21,32 @@ export async function createSandboxCommand(options = {}) {
|
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
23
|
log.info(`Current branch: ${chalk.cyan(currentBranch)}`);
|
|
23
|
-
// 2.
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
// 2. Build default + optional custom name validation
|
|
25
|
+
const generatedSandboxName = generateSandboxName(currentBranch);
|
|
26
|
+
const validateSandboxName = async (input) => {
|
|
27
|
+
if (!input.trim())
|
|
28
|
+
return true;
|
|
29
|
+
const normalized = normalizeSandboxName(input);
|
|
30
|
+
if (!normalized) {
|
|
31
|
+
return 'Please provide at least one valid character.';
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
await execa('git', ['check-ref-format', '--branch', normalized], { cwd: repoRoot });
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return 'Sandbox name is not a valid git branch name.';
|
|
39
|
+
}
|
|
40
|
+
};
|
|
26
41
|
// 3. Gather remaining inputs
|
|
27
42
|
const answers = await inquirer.prompt([
|
|
43
|
+
{
|
|
44
|
+
type: 'input',
|
|
45
|
+
name: 'name',
|
|
46
|
+
message: `Sandbox name (optional, leave empty for auto: ${generatedSandboxName}):`,
|
|
47
|
+
when: options.name === undefined,
|
|
48
|
+
validate: validateSandboxName,
|
|
49
|
+
},
|
|
28
50
|
{
|
|
29
51
|
type: 'confirm',
|
|
30
52
|
name: 'carry',
|
|
@@ -47,17 +69,39 @@ export async function createSandboxCommand(options = {}) {
|
|
|
47
69
|
when: options.enter === undefined,
|
|
48
70
|
},
|
|
49
71
|
{
|
|
50
|
-
type: '
|
|
51
|
-
name: '
|
|
52
|
-
message: '
|
|
53
|
-
default:
|
|
72
|
+
type: 'confirm',
|
|
73
|
+
name: 'shouldOpenTool',
|
|
74
|
+
message: 'Open a tool after creation? (IDE or agent CLI)',
|
|
75
|
+
default: false,
|
|
54
76
|
when: options.exec === undefined,
|
|
55
|
-
}
|
|
77
|
+
},
|
|
56
78
|
]);
|
|
79
|
+
const requestedSandboxName = options.name !== undefined ? options.name : answers.name || '';
|
|
80
|
+
const nameValidation = await validateSandboxName(requestedSandboxName);
|
|
81
|
+
if (nameValidation !== true) {
|
|
82
|
+
log.error(nameValidation);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const normalizedCustomName = normalizeSandboxName(requestedSandboxName);
|
|
86
|
+
const sandboxName = normalizedCustomName || generatedSandboxName;
|
|
87
|
+
if (requestedSandboxName.trim() && normalizedCustomName !== requestedSandboxName.trim()) {
|
|
88
|
+
log.dim(`Normalized sandbox name: ${chalk.yellow(sandboxName)}`);
|
|
89
|
+
}
|
|
90
|
+
log.info(`Sandbox name: ${chalk.yellow(sandboxName)}`);
|
|
57
91
|
const shouldCarry = options.carry !== undefined ? options.carry : answers.carry;
|
|
58
92
|
const shouldEnter = options.enter !== undefined ? options.enter : answers.shouldEnter;
|
|
59
93
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
60
|
-
|
|
94
|
+
let selectedTool;
|
|
95
|
+
if (options.exec === undefined && answers.shouldOpenTool) {
|
|
96
|
+
const installedTools = await detectInstalledOpenTools();
|
|
97
|
+
if (installedTools.length === 0) {
|
|
98
|
+
log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const execCommandStr = options.exec || (selectedTool && isAgentTool(selectedTool) ? buildAgentExecCommand(selectedTool) : undefined);
|
|
61
105
|
// 4. Detect uncommitted changes before creating worktree
|
|
62
106
|
let changedFiles = [];
|
|
63
107
|
let submodulePaths = [];
|
|
@@ -159,6 +203,15 @@ export async function createSandboxCommand(options = {}) {
|
|
|
159
203
|
]);
|
|
160
204
|
}
|
|
161
205
|
}
|
|
206
|
+
if (selectedTool && !isAgentTool(selectedTool)) {
|
|
207
|
+
try {
|
|
208
|
+
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
|
|
209
|
+
await launchOpenTool(selectedTool, wtPath);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
162
215
|
// 10. Final output
|
|
163
216
|
log.success('Sandbox ready!');
|
|
164
217
|
log.info(`Use ${chalk.cyan('yggtree wt apply')} to apply changes to origin.`);
|
|
@@ -1,48 +1,138 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import { getRepoRoot, getRepoName,
|
|
4
|
+
import { getRepoRoot, getRepoName, createWorktree, fetchAll, listWorktrees } from '../../lib/git.js';
|
|
5
5
|
import { runBootstrap } from '../../lib/config.js';
|
|
6
6
|
import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
7
7
|
import { log, ui, createSpinner } from '../../lib/ui.js';
|
|
8
|
+
import { ensureAutocompletePrompt } from '../../lib/prompt.js';
|
|
9
|
+
import { enterCommand } from './enter.js';
|
|
10
|
+
import { buildAgentExecCommand, detectInstalledOpenTools, isAgentTool, launchOpenTool, promptOpenToolSelection, } from './open.js';
|
|
8
11
|
import { execa } from 'execa';
|
|
9
12
|
import { spawn } from 'child_process';
|
|
10
13
|
import fs from 'fs-extra';
|
|
14
|
+
function toSlug(value) {
|
|
15
|
+
return value
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/[\/\\]/g, '-')
|
|
18
|
+
.replace(/\s+/g, '-');
|
|
19
|
+
}
|
|
20
|
+
async function listBranchCandidates() {
|
|
21
|
+
const [localRefs, remoteRefs] = await Promise.all([
|
|
22
|
+
execa('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
|
|
23
|
+
execa('git', ['for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin']),
|
|
24
|
+
]);
|
|
25
|
+
const localBranches = localRefs.stdout
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map(line => line.trim())
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
const remoteBranches = remoteRefs.stdout
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map(line => line.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.filter(ref => ref !== 'origin/HEAD' && !ref.endsWith('/HEAD'))
|
|
34
|
+
.map(ref => ref.replace(/^origin\//, ''));
|
|
35
|
+
const localSet = new Set(localBranches);
|
|
36
|
+
const remoteOnly = [...new Set(remoteBranches)].filter(branch => !localSet.has(branch));
|
|
37
|
+
const localCandidates = localBranches.map(branchName => ({
|
|
38
|
+
branchName,
|
|
39
|
+
checkoutRef: branchName,
|
|
40
|
+
createLocalBranch: false,
|
|
41
|
+
source: 'local',
|
|
42
|
+
}));
|
|
43
|
+
const remoteCandidates = remoteOnly.map(branchName => ({
|
|
44
|
+
branchName,
|
|
45
|
+
checkoutRef: `origin/${branchName}`,
|
|
46
|
+
createLocalBranch: true,
|
|
47
|
+
source: 'remote',
|
|
48
|
+
}));
|
|
49
|
+
return [...localCandidates, ...remoteCandidates]
|
|
50
|
+
.sort((a, b) => a.branchName.localeCompare(b.branchName));
|
|
51
|
+
}
|
|
52
|
+
function resolveCandidateFromRef(ref, candidates) {
|
|
53
|
+
const trimmedRef = ref.trim();
|
|
54
|
+
if (!trimmedRef)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (trimmedRef.startsWith('origin/')) {
|
|
57
|
+
const branchName = trimmedRef.replace(/^origin\//, '');
|
|
58
|
+
return candidates.find(candidate => candidate.branchName === branchName);
|
|
59
|
+
}
|
|
60
|
+
return candidates.find(candidate => candidate.branchName === trimmedRef ||
|
|
61
|
+
candidate.checkoutRef === trimmedRef);
|
|
62
|
+
}
|
|
11
63
|
export async function createCommand(options) {
|
|
12
64
|
try {
|
|
13
65
|
const repoRoot = await getRepoRoot();
|
|
14
66
|
log.info(`Repo: ${chalk.dim(repoRoot)}`);
|
|
15
|
-
// 1.
|
|
16
|
-
const
|
|
67
|
+
// 1. Load branches
|
|
68
|
+
const loadingSpinner = createSpinner('Fetching branches...').start();
|
|
69
|
+
await fetchAll();
|
|
70
|
+
const candidates = await listBranchCandidates();
|
|
71
|
+
if (candidates.length === 0) {
|
|
72
|
+
loadingSpinner.fail('No branches found.');
|
|
73
|
+
log.warning('Create a branch first, then run worktree-checkout again.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
loadingSpinner.succeed(`Loaded ${candidates.length} branches.`);
|
|
77
|
+
// 2. Select branch
|
|
78
|
+
ensureAutocompletePrompt();
|
|
79
|
+
let selectedBranch;
|
|
80
|
+
if (options.ref) {
|
|
81
|
+
selectedBranch = resolveCandidateFromRef(options.ref, candidates);
|
|
82
|
+
if (!selectedBranch) {
|
|
83
|
+
log.error(`Branch "${options.ref}" not found.`);
|
|
84
|
+
log.warning('Tip: use a local branch name or origin/<branch>.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const { branchChoice } = await inquirer.prompt([
|
|
90
|
+
{
|
|
91
|
+
type: 'autocomplete',
|
|
92
|
+
name: 'branchChoice',
|
|
93
|
+
message: 'Select branch (type to filter):',
|
|
94
|
+
source: async (_answers, input = '') => {
|
|
95
|
+
const term = input.trim().toLowerCase();
|
|
96
|
+
const filtered = term
|
|
97
|
+
? candidates.filter(candidate => candidate.branchName.toLowerCase().includes(term))
|
|
98
|
+
: candidates;
|
|
99
|
+
return filtered.map(candidate => ({
|
|
100
|
+
name: `${chalk.yellow(candidate.branchName)} ${chalk.dim(`(${candidate.source})`)}`,
|
|
101
|
+
value: candidate,
|
|
102
|
+
}));
|
|
103
|
+
},
|
|
104
|
+
pageSize: 10,
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
selectedBranch = branchChoice;
|
|
108
|
+
}
|
|
109
|
+
if (!selectedBranch) {
|
|
110
|
+
log.error('No branch selected.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const existingWorktrees = await listWorktrees();
|
|
114
|
+
const existingManagedWorktree = existingWorktrees.find(wt => wt.branch === selectedBranch.branchName && wt.path.startsWith(WORKTREES_ROOT));
|
|
115
|
+
if (existingManagedWorktree) {
|
|
116
|
+
log.info(`Branch ${chalk.cyan(selectedBranch.branchName)} is already active in ${ui.path(existingManagedWorktree.path)}.`);
|
|
117
|
+
if (options.enter === false) {
|
|
118
|
+
log.header(`cd "${existingManagedWorktree.path}"`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
log.info('Opening existing worktree instead of creating a duplicate...');
|
|
122
|
+
await enterCommand(existingManagedWorktree.path, { exec: options.exec ?? '' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// 3. Gather remaining inputs
|
|
126
|
+
const defaultSlug = toSlug(selectedBranch.branchName);
|
|
17
127
|
const answers = await inquirer.prompt([
|
|
18
128
|
{
|
|
19
129
|
type: 'input',
|
|
20
130
|
name: 'name',
|
|
21
131
|
message: 'Worktree name (slug):',
|
|
22
|
-
default: options.name,
|
|
132
|
+
default: options.name || defaultSlug,
|
|
23
133
|
when: !options.name,
|
|
24
134
|
validate: (input) => input.trim().length > 0 || 'Name is required',
|
|
25
135
|
},
|
|
26
|
-
{
|
|
27
|
-
type: 'input',
|
|
28
|
-
name: 'ref',
|
|
29
|
-
message: 'Base branch name:',
|
|
30
|
-
default: options.ref || currentBranch,
|
|
31
|
-
when: !options.ref,
|
|
32
|
-
validate: (input) => input.trim().length > 0 || 'Ref is required',
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
type: 'list',
|
|
36
|
-
name: 'source',
|
|
37
|
-
message: 'Base on:',
|
|
38
|
-
loop: false,
|
|
39
|
-
choices: [
|
|
40
|
-
{ name: 'Remote (origin)', value: 'remote' },
|
|
41
|
-
{ name: 'Local', value: 'local' },
|
|
42
|
-
],
|
|
43
|
-
default: 'remote',
|
|
44
|
-
when: !options.ref && !options.source,
|
|
45
|
-
},
|
|
46
136
|
{
|
|
47
137
|
type: 'confirm',
|
|
48
138
|
name: 'bootstrap',
|
|
@@ -58,61 +148,62 @@ export async function createCommand(options) {
|
|
|
58
148
|
when: options.enter === undefined,
|
|
59
149
|
},
|
|
60
150
|
{
|
|
61
|
-
type: '
|
|
62
|
-
name: '
|
|
63
|
-
message: '
|
|
64
|
-
default:
|
|
151
|
+
type: 'confirm',
|
|
152
|
+
name: 'shouldOpenTool',
|
|
153
|
+
message: 'Open a tool after creation? (IDE or agent CLI)',
|
|
154
|
+
default: false,
|
|
65
155
|
when: options.exec === undefined,
|
|
66
|
-
}
|
|
156
|
+
},
|
|
67
157
|
]);
|
|
68
158
|
const name = options.name || answers.name;
|
|
69
|
-
let ref = options.ref || answers.ref;
|
|
70
|
-
const source = options.source || answers.source;
|
|
71
159
|
const shouldEnter = options.enter !== undefined ? options.enter : answers.shouldEnter;
|
|
72
160
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
161
|
+
let selectedTool;
|
|
162
|
+
if (options.exec === undefined && answers.shouldOpenTool) {
|
|
163
|
+
const installedTools = await detectInstalledOpenTools();
|
|
164
|
+
if (installedTools.length === 0) {
|
|
165
|
+
log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
|
|
169
|
+
}
|
|
77
170
|
}
|
|
78
|
-
const
|
|
171
|
+
const execCommandStr = options.exec || (selectedTool && isAgentTool(selectedTool) ? buildAgentExecCommand(selectedTool) : undefined);
|
|
172
|
+
const slug = toSlug(name);
|
|
79
173
|
const repoName = await getRepoName();
|
|
80
174
|
const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
|
|
81
|
-
//
|
|
175
|
+
// 4. Validation
|
|
82
176
|
if (!slug)
|
|
83
177
|
throw new Error('Invalid name');
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// 3. Execution
|
|
87
|
-
const spinner = createSpinner('Fetching...').start();
|
|
88
|
-
await fetchAll();
|
|
89
|
-
spinner.text = 'Verifying ref...';
|
|
90
|
-
const exists = await verifyRef(ref);
|
|
91
|
-
if (!exists) {
|
|
92
|
-
spinner.fail(`Ref not found: ${ref}`);
|
|
93
|
-
log.warning(`Tip: try 'origin/${ref}' or check if the branch exists.`);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
spinner.text = `Creating worktree at ${ui.path(wtPath)}...`;
|
|
178
|
+
// 5. Execution (checkout-style: attach to selected branch)
|
|
179
|
+
const spinner = createSpinner(`Creating worktree at ${ui.path(wtPath)}...`).start();
|
|
97
180
|
try {
|
|
98
181
|
await fs.ensureDir(path.dirname(wtPath));
|
|
99
|
-
|
|
182
|
+
if (selectedBranch.createLocalBranch) {
|
|
183
|
+
await execa('git', ['worktree', 'add', '-b', selectedBranch.branchName, wtPath, selectedBranch.checkoutRef]);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
await createWorktree(wtPath, selectedBranch.checkoutRef);
|
|
187
|
+
}
|
|
100
188
|
spinner.succeed('Worktree created.');
|
|
101
189
|
}
|
|
102
190
|
catch (e) {
|
|
103
191
|
spinner.fail('Failed to create worktree.');
|
|
104
|
-
|
|
192
|
+
const command = selectedBranch.createLocalBranch
|
|
193
|
+
? `git worktree add -b ${selectedBranch.branchName} ${wtPath} ${selectedBranch.checkoutRef}`
|
|
194
|
+
: `git worktree add ${wtPath} ${selectedBranch.checkoutRef}`;
|
|
195
|
+
log.actionableError(e.message, command, wtPath, [
|
|
105
196
|
'Check if the folder already exists: ls ' + wtPath,
|
|
106
|
-
|
|
197
|
+
`Check if branch "${selectedBranch.branchName}" is already checked out in another worktree: git worktree list`,
|
|
107
198
|
'Try pruning stale worktrees: yggtree wt prune',
|
|
108
|
-
`Run manually:
|
|
199
|
+
`Run manually: ${command}`
|
|
109
200
|
]);
|
|
110
201
|
return;
|
|
111
202
|
}
|
|
112
203
|
if (shouldBootstrap) {
|
|
113
204
|
await runBootstrap(wtPath, repoRoot);
|
|
114
205
|
}
|
|
115
|
-
//
|
|
206
|
+
// 6. Exec Command
|
|
116
207
|
if (execCommandStr && execCommandStr.trim()) {
|
|
117
208
|
log.info(`Executing: ${execCommandStr} in ${ui.path(wtPath)}`);
|
|
118
209
|
try {
|
|
@@ -129,7 +220,16 @@ export async function createCommand(options) {
|
|
|
129
220
|
]);
|
|
130
221
|
}
|
|
131
222
|
}
|
|
132
|
-
|
|
223
|
+
if (selectedTool && !isAgentTool(selectedTool)) {
|
|
224
|
+
try {
|
|
225
|
+
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
|
|
226
|
+
await launchOpenTool(selectedTool, wtPath);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// 7. Final Output
|
|
133
233
|
log.success('Worktree ready!');
|
|
134
234
|
if (shouldEnter) {
|
|
135
235
|
log.info(`Spawning sub-shell in ${ui.path(wtPath)}...`);
|
|
@@ -148,7 +248,7 @@ export async function createCommand(options) {
|
|
|
148
248
|
}
|
|
149
249
|
}
|
|
150
250
|
catch (error) {
|
|
151
|
-
log.actionableError(error.message, 'yggtree wt
|
|
251
|
+
log.actionableError(error.message, 'yggtree wt worktree-checkout');
|
|
152
252
|
process.exit(1);
|
|
153
253
|
}
|
|
154
254
|
}
|