yggtree 1.4.1 → 1.4.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 +37 -9
- package/dist/commands/wt/close.js +96 -0
- package/dist/commands/wt/create-branch.js +18 -34
- package/dist/commands/wt/create-sandbox.js +18 -34
- package/dist/commands/wt/create.js +45 -36
- package/dist/commands/wt/enter.js +26 -1
- package/dist/commands/wt/list.js +23 -6
- package/dist/index.js +23 -7
- package/dist/lib/git.js +84 -6
- package/dist/lib/registry.js +81 -0
- package/dist/lib/worktree.js +12 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -218,7 +218,7 @@ Options:
|
|
|
218
218
|
* `--base <ref>`
|
|
219
219
|
* `--source local|remote`
|
|
220
220
|
* `--no-bootstrap`
|
|
221
|
-
* `--
|
|
221
|
+
* `--open / --no-open`
|
|
222
222
|
* `--exec "<command>"`
|
|
223
223
|
|
|
224
224
|
Interactive flow:
|
|
@@ -253,7 +253,7 @@ Options:
|
|
|
253
253
|
* `-n, --name <slug>`
|
|
254
254
|
* `-r, --ref <ref>`: skip picker and use a specific branch (`feature/x` or `origin/feature/x`)
|
|
255
255
|
* `--no-bootstrap`
|
|
256
|
-
* `--
|
|
256
|
+
* `--open / --no-open`
|
|
257
257
|
* `--exec "<command>"`
|
|
258
258
|
|
|
259
259
|
Interactive flow:
|
|
@@ -265,7 +265,7 @@ Interactive flow:
|
|
|
265
265
|
<summary>Example</summary>
|
|
266
266
|
|
|
267
267
|
```bash
|
|
268
|
-
yggtree wt worktree-checkout -n hotfix-auth -r main --no-
|
|
268
|
+
yggtree wt worktree-checkout -n hotfix-auth -r main --no-open
|
|
269
269
|
```
|
|
270
270
|
|
|
271
271
|
</details>
|
|
@@ -281,7 +281,7 @@ Options:
|
|
|
281
281
|
* `-n, --name <name>`: Optional sandbox name (auto-generated if omitted).
|
|
282
282
|
* `--carry / --no-carry`: Bring uncommitted changes (staged/unstaged/untracked) with you.
|
|
283
283
|
* `--no-bootstrap`
|
|
284
|
-
* `--
|
|
284
|
+
* `--open / --no-open`
|
|
285
285
|
* `--exec "<command>"`
|
|
286
286
|
|
|
287
287
|
Interactive flow:
|
|
@@ -338,6 +338,7 @@ Columns:
|
|
|
338
338
|
* TYPE (`MAIN`, `MANAGED`, `LINKED`, `SANDBOX`)
|
|
339
339
|
* STATE (clean / dirty)
|
|
340
340
|
* LAST ACTIVE
|
|
341
|
+
* PR (optional — requires [GitHub CLI](https://cli.github.com/))
|
|
341
342
|
* BRANCH
|
|
342
343
|
|
|
343
344
|
Notes:
|
|
@@ -346,6 +347,7 @@ Notes:
|
|
|
346
347
|
* `SANDBOX` and `MANAGED` are worktrees inside `~/.yggtree`.
|
|
347
348
|
* External worktrees are labeled `LINKED`.
|
|
348
349
|
* Use `--open` to switch this flow into "pick and open in tool" mode.
|
|
350
|
+
* The **PR** column shows the pull request status for each branch (e.g. `OPEN`, `IN REVIEW`, `APPROVED`, `MERGED`, `DRAFT`, `CHANGES`). It only appears when `gh` CLI is installed and authenticated — otherwise it's silently omitted.
|
|
349
351
|
|
|
350
352
|
---
|
|
351
353
|
|
|
@@ -371,6 +373,32 @@ yggtree wt enter feat/new-ui --exec "npm test"
|
|
|
371
373
|
|
|
372
374
|
---
|
|
373
375
|
|
|
376
|
+
### `yggtree wt close`
|
|
377
|
+
|
|
378
|
+
Gracefully exit a worktree sub-shell with an option to delete it.
|
|
379
|
+
|
|
380
|
+
Behavior:
|
|
381
|
+
|
|
382
|
+
* Only works inside an Yggdrasil sub-shell (entered via `wt enter` or post-creation).
|
|
383
|
+
* Asks whether you want to delete the worktree before leaving.
|
|
384
|
+
* Includes double-confirmation for safety.
|
|
385
|
+
* Main worktree is never offered for deletion.
|
|
386
|
+
|
|
387
|
+
<details>
|
|
388
|
+
<summary>Example</summary>
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
# Inside a worktree sub-shell:
|
|
392
|
+
yggtree wt close
|
|
393
|
+
# → "Delete this worktree before leaving? (y/N)"
|
|
394
|
+
# → If yes: removes the worktree, then exits
|
|
395
|
+
# → If no: exits normally
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
</details>
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
374
402
|
### `yggtree wt open [worktree]`
|
|
375
403
|
|
|
376
404
|
Open a worktree in an IDE or agent CLI.
|
|
@@ -467,7 +495,7 @@ Yggdrasil is ideal when:
|
|
|
467
495
|
## 📝 Practical Examples
|
|
468
496
|
|
|
469
497
|
<details>
|
|
470
|
-
<summary>Create a worktree
|
|
498
|
+
<summary>Create a worktree with the guided post-create flow</summary>
|
|
471
499
|
|
|
472
500
|
**Command:**
|
|
473
501
|
|
|
@@ -480,25 +508,25 @@ yggtree wt create feat/login-flow
|
|
|
480
508
|
* Creates a new branch if it doesn’t exist (without inheriting base tracking), then publishes it to `origin` when possible
|
|
481
509
|
* Creates a dedicated worktree
|
|
482
510
|
* Runs bootstrap if enabled
|
|
483
|
-
*
|
|
511
|
+
* Lets you choose whether to open an IDE or agent after creation
|
|
484
512
|
|
|
485
513
|
|
|
486
514
|
</details>
|
|
487
515
|
---
|
|
488
516
|
|
|
489
517
|
<details>
|
|
490
|
-
<summary>Create a worktree without bootstrap and without
|
|
518
|
+
<summary>Create a worktree without bootstrap and without opening a tool</summary>
|
|
491
519
|
|
|
492
520
|
**Command:**
|
|
493
521
|
|
|
494
522
|
```
|
|
495
|
-
yggtree wt create feat/cleanup-api --no-bootstrap --no-
|
|
523
|
+
yggtree wt create feat/cleanup-api --no-bootstrap --no-open
|
|
496
524
|
```
|
|
497
525
|
|
|
498
526
|
**When to use:**
|
|
499
527
|
|
|
500
528
|
* You just want the folder ready
|
|
501
|
-
* You’ll enter it later
|
|
529
|
+
* You’ll open or enter it later if needed
|
|
502
530
|
* You don’t want installs running automatically
|
|
503
531
|
|
|
504
532
|
</details>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { listWorktrees, removeWorktree, getRepoRoot } from '../../lib/git.js';
|
|
4
|
+
import { log, createSpinner } from '../../lib/ui.js';
|
|
5
|
+
import { detectWorktreeType, formatWorktreeDisplayPath, getWorktreeBranchName, isManagedWorktreePath, } from '../../lib/worktree.js';
|
|
6
|
+
/**
|
|
7
|
+
* Terminate the parent sub-shell and exit.
|
|
8
|
+
* Sends SIGHUP to the parent process (the spawned shell from `wt enter`)
|
|
9
|
+
* so the user doesn't get stranded in a dead directory.
|
|
10
|
+
*/
|
|
11
|
+
function exitSubShell() {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(process.ppid, 'SIGHUP');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
log.dim('Type "exit" to leave the sub-shell.');
|
|
17
|
+
}
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Close command — gracefully exit a worktree sub-shell.
|
|
22
|
+
*
|
|
23
|
+
* When inside an yggtree sub-shell (YGGTREE_SHELL=true), this command:
|
|
24
|
+
* 1. Asks whether you want to delete the worktree you're leaving.
|
|
25
|
+
* 2. If yes, removes the worktree via `git worktree remove`.
|
|
26
|
+
* 3. Terminates the parent sub-shell so the user returns to their original terminal.
|
|
27
|
+
*
|
|
28
|
+
* Outside a sub-shell it simply informs the user.
|
|
29
|
+
*/
|
|
30
|
+
export async function closeCommand() {
|
|
31
|
+
const isSubShell = process.env.YGGTREE_SHELL === 'true';
|
|
32
|
+
if (!isSubShell) {
|
|
33
|
+
log.info('Not inside an Yggdrasil sub-shell. Use `exit` to leave your terminal.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const currentPath = await getRepoRoot();
|
|
38
|
+
const worktrees = await listWorktrees();
|
|
39
|
+
const mainWorktreePath = worktrees[0]?.path || '';
|
|
40
|
+
const currentWt = worktrees.find(wt => wt.path === currentPath);
|
|
41
|
+
if (!currentWt) {
|
|
42
|
+
log.info('Bye! \ud83d\udc4b');
|
|
43
|
+
exitSubShell();
|
|
44
|
+
}
|
|
45
|
+
const branchName = getWorktreeBranchName(currentWt);
|
|
46
|
+
const type = await detectWorktreeType(currentWt, mainWorktreePath);
|
|
47
|
+
const displayPath = formatWorktreeDisplayPath(currentWt.path);
|
|
48
|
+
const isMain = type === 'MAIN';
|
|
49
|
+
const isManaged = isManagedWorktreePath(currentWt.path);
|
|
50
|
+
// Main worktree can't be deleted — just exit
|
|
51
|
+
if (isMain) {
|
|
52
|
+
log.info('Bye! \ud83d\udc4b');
|
|
53
|
+
exitSubShell();
|
|
54
|
+
}
|
|
55
|
+
console.log('');
|
|
56
|
+
log.info(`Leaving worktree: ${chalk.yellow(branchName)} ${chalk.dim(`(${displayPath})`)}`);
|
|
57
|
+
const { shouldDelete } = await inquirer.prompt([{
|
|
58
|
+
type: 'confirm',
|
|
59
|
+
name: 'shouldDelete',
|
|
60
|
+
message: `Delete this worktree before leaving?${isManaged ? '' : chalk.dim(' (external worktree \u2014 will use --force)')}`,
|
|
61
|
+
default: false,
|
|
62
|
+
}]);
|
|
63
|
+
if (shouldDelete) {
|
|
64
|
+
const { confirmDelete } = await inquirer.prompt([{
|
|
65
|
+
type: 'confirm',
|
|
66
|
+
name: 'confirmDelete',
|
|
67
|
+
message: `Are you sure? This will remove ${chalk.bold.yellow(branchName)} at ${chalk.cyan(displayPath)}.`,
|
|
68
|
+
default: false,
|
|
69
|
+
}]);
|
|
70
|
+
if (confirmDelete) {
|
|
71
|
+
const spinner = createSpinner(`Deleting ${displayPath}...`).start();
|
|
72
|
+
try {
|
|
73
|
+
await removeWorktree(currentWt.path);
|
|
74
|
+
spinner.succeed(`Deleted worktree: ${displayPath}`);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
spinner.fail(`Failed to delete ${displayPath}`);
|
|
78
|
+
log.actionableError(e.message, `git worktree remove ${currentWt.path} --force`, currentWt.path, [
|
|
79
|
+
`Try running manually: git worktree remove ${currentWt.path} --force`,
|
|
80
|
+
'Check if any files in the worktree are open or locked',
|
|
81
|
+
'Try running yggtree wt prune to clean up git metadata',
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
log.info('Deletion cancelled.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
log.info('Bye! \ud83d\udc4b');
|
|
90
|
+
exitSubShell();
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
log.error(error.message);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -5,14 +5,16 @@ 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 {
|
|
8
|
+
import { detectInstalledOpenTools, launchOpenTool, promptOpenToolSelection, } from './open.js';
|
|
9
9
|
import { execa } from 'execa';
|
|
10
|
-
import { spawn } from 'child_process';
|
|
11
10
|
import fs from 'fs-extra';
|
|
12
11
|
export async function createCommandNew(options) {
|
|
13
12
|
try {
|
|
14
13
|
const repoRoot = await getRepoRoot();
|
|
15
14
|
log.info(`Repo: ${chalk.dim(repoRoot)}`);
|
|
15
|
+
if (options.enter !== undefined) {
|
|
16
|
+
log.warning('`--enter` / `--no-enter` is deprecated. Use `--open` / `--no-open` instead.');
|
|
17
|
+
}
|
|
16
18
|
// 1. Gather inputs
|
|
17
19
|
const currentBranch = await getCurrentBranch();
|
|
18
20
|
const answers = await inquirer.prompt([
|
|
@@ -51,28 +53,25 @@ export async function createCommandNew(options) {
|
|
|
51
53
|
default: true,
|
|
52
54
|
when: options.bootstrap !== false && options.bootstrap !== true,
|
|
53
55
|
},
|
|
54
|
-
{
|
|
55
|
-
type: 'confirm',
|
|
56
|
-
name: 'shouldEnter',
|
|
57
|
-
message: 'Do you want to enter the new worktree now?',
|
|
58
|
-
default: true,
|
|
59
|
-
when: options.enter === undefined,
|
|
60
|
-
},
|
|
61
56
|
{
|
|
62
57
|
type: 'confirm',
|
|
63
58
|
name: 'shouldOpenTool',
|
|
64
59
|
message: 'Open a tool after creation? (IDE or agent CLI)',
|
|
65
60
|
default: false,
|
|
66
|
-
when: options.exec === undefined,
|
|
61
|
+
when: options.exec === undefined && options.open === undefined && options.enter === undefined,
|
|
67
62
|
},
|
|
68
63
|
]);
|
|
69
64
|
const branchName = options.branch || answers.branch;
|
|
70
65
|
let baseRef = options.base || answers.base;
|
|
71
66
|
const source = options.source || answers.source;
|
|
72
|
-
const shouldEnter = options.enter !== undefined ? options.enter : answers.shouldEnter;
|
|
73
67
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
68
|
+
const shouldOpenTool = options.open !== undefined
|
|
69
|
+
? options.open
|
|
70
|
+
: options.enter !== undefined
|
|
71
|
+
? options.enter
|
|
72
|
+
: Boolean(answers.shouldOpenTool);
|
|
74
73
|
let selectedTool;
|
|
75
|
-
if (options.exec === undefined &&
|
|
74
|
+
if (options.exec === undefined && shouldOpenTool) {
|
|
76
75
|
const installedTools = await detectInstalledOpenTools();
|
|
77
76
|
if (installedTools.length === 0) {
|
|
78
77
|
log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
|
|
@@ -81,7 +80,6 @@ export async function createCommandNew(options) {
|
|
|
81
80
|
selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
|
-
const execCommandStr = options.exec || (selectedTool && isAgentTool(selectedTool) ? buildAgentExecCommand(selectedTool) : undefined);
|
|
85
83
|
// Append origin/ if remote is selected and not already present
|
|
86
84
|
if (!options.base && source === 'remote' && !baseRef.startsWith('origin/')) {
|
|
87
85
|
baseRef = `origin/${baseRef}`;
|
|
@@ -154,23 +152,23 @@ export async function createCommandNew(options) {
|
|
|
154
152
|
await runBootstrap(wtPath, repoRoot);
|
|
155
153
|
}
|
|
156
154
|
// 5. Exec Command
|
|
157
|
-
if (
|
|
158
|
-
log.info(`Executing: ${
|
|
155
|
+
if (options.exec && options.exec.trim()) {
|
|
156
|
+
log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
|
|
159
157
|
try {
|
|
160
|
-
await execa(
|
|
158
|
+
await execa(options.exec, {
|
|
161
159
|
cwd: wtPath,
|
|
162
160
|
stdio: 'inherit',
|
|
163
161
|
shell: true
|
|
164
162
|
});
|
|
165
163
|
}
|
|
166
164
|
catch (error) {
|
|
167
|
-
log.actionableError(error.message,
|
|
168
|
-
`cd ${wtPath} && ${
|
|
165
|
+
log.actionableError(error.message, options.exec, wtPath, [
|
|
166
|
+
`cd ${wtPath} && ${options.exec}`,
|
|
169
167
|
'Check your command syntax and environment variables'
|
|
170
168
|
]);
|
|
171
169
|
}
|
|
172
170
|
}
|
|
173
|
-
if (selectedTool
|
|
171
|
+
else if (selectedTool) {
|
|
174
172
|
try {
|
|
175
173
|
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
|
|
176
174
|
await launchOpenTool(selectedTool, wtPath);
|
|
@@ -181,21 +179,7 @@ export async function createCommandNew(options) {
|
|
|
181
179
|
}
|
|
182
180
|
// 6. Final Output
|
|
183
181
|
log.success('Worktree ready!');
|
|
184
|
-
|
|
185
|
-
log.info(`Spawning sub-shell in ${ui.path(wtPath)}...`);
|
|
186
|
-
log.dim('Type "exit" to return to the main terminal.');
|
|
187
|
-
const shell = process.env.SHELL || 'zsh';
|
|
188
|
-
const child = spawn(shell, [], {
|
|
189
|
-
cwd: wtPath,
|
|
190
|
-
stdio: 'inherit',
|
|
191
|
-
});
|
|
192
|
-
child.on('close', () => {
|
|
193
|
-
log.info('Exited sub-shell.');
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
else {
|
|
197
|
-
log.header(`cd "${wtPath}"`);
|
|
198
|
-
}
|
|
182
|
+
log.header(`cd "${wtPath}"`);
|
|
199
183
|
}
|
|
200
184
|
catch (error) {
|
|
201
185
|
log.actionableError(error.message, 'yggtree wt create');
|
|
@@ -6,14 +6,16 @@ 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
8
|
import { generateSandboxName, normalizeSandboxName, writeSandboxMeta } from '../../lib/sandbox.js';
|
|
9
|
-
import {
|
|
9
|
+
import { detectInstalledOpenTools, launchOpenTool, promptOpenToolSelection, } from './open.js';
|
|
10
10
|
import { execa } from 'execa';
|
|
11
|
-
import { spawn } from 'child_process';
|
|
12
11
|
import fs from 'fs-extra';
|
|
13
12
|
export async function createSandboxCommand(options = {}) {
|
|
14
13
|
try {
|
|
15
14
|
const repoRoot = await getRepoRoot();
|
|
16
15
|
log.info(`Repo: ${chalk.dim(repoRoot)}`);
|
|
16
|
+
if (options.enter !== undefined) {
|
|
17
|
+
log.warning('`--enter` / `--no-enter` is deprecated. Use `--open` / `--no-open` instead.');
|
|
18
|
+
}
|
|
17
19
|
// 1. Auto-detect current branch (no prompt!)
|
|
18
20
|
const currentBranch = await getCurrentBranch();
|
|
19
21
|
if (!currentBranch) {
|
|
@@ -61,19 +63,12 @@ export async function createSandboxCommand(options = {}) {
|
|
|
61
63
|
default: true,
|
|
62
64
|
when: options.bootstrap === undefined,
|
|
63
65
|
},
|
|
64
|
-
{
|
|
65
|
-
type: 'confirm',
|
|
66
|
-
name: 'shouldEnter',
|
|
67
|
-
message: 'Do you want to enter the sandbox now?',
|
|
68
|
-
default: true,
|
|
69
|
-
when: options.enter === undefined,
|
|
70
|
-
},
|
|
71
66
|
{
|
|
72
67
|
type: 'confirm',
|
|
73
68
|
name: 'shouldOpenTool',
|
|
74
69
|
message: 'Open a tool after creation? (IDE or agent CLI)',
|
|
75
70
|
default: false,
|
|
76
|
-
when: options.exec === undefined,
|
|
71
|
+
when: options.exec === undefined && options.open === undefined && options.enter === undefined,
|
|
77
72
|
},
|
|
78
73
|
]);
|
|
79
74
|
const requestedSandboxName = options.name !== undefined ? options.name : answers.name || '';
|
|
@@ -89,10 +84,14 @@ export async function createSandboxCommand(options = {}) {
|
|
|
89
84
|
}
|
|
90
85
|
log.info(`Sandbox name: ${chalk.yellow(sandboxName)}`);
|
|
91
86
|
const shouldCarry = options.carry !== undefined ? options.carry : answers.carry;
|
|
92
|
-
const shouldEnter = options.enter !== undefined ? options.enter : answers.shouldEnter;
|
|
93
87
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
88
|
+
const shouldOpenTool = options.open !== undefined
|
|
89
|
+
? options.open
|
|
90
|
+
: options.enter !== undefined
|
|
91
|
+
? options.enter
|
|
92
|
+
: Boolean(answers.shouldOpenTool);
|
|
94
93
|
let selectedTool;
|
|
95
|
-
if (options.exec === undefined &&
|
|
94
|
+
if (options.exec === undefined && shouldOpenTool) {
|
|
96
95
|
const installedTools = await detectInstalledOpenTools();
|
|
97
96
|
if (installedTools.length === 0) {
|
|
98
97
|
log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
|
|
@@ -101,7 +100,6 @@ export async function createSandboxCommand(options = {}) {
|
|
|
101
100
|
selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
|
|
102
101
|
}
|
|
103
102
|
}
|
|
104
|
-
const execCommandStr = options.exec || (selectedTool && isAgentTool(selectedTool) ? buildAgentExecCommand(selectedTool) : undefined);
|
|
105
103
|
// 4. Detect uncommitted changes before creating worktree
|
|
106
104
|
let changedFiles = [];
|
|
107
105
|
let submodulePaths = [];
|
|
@@ -187,23 +185,23 @@ export async function createSandboxCommand(options = {}) {
|
|
|
187
185
|
await runBootstrap(wtPath, repoRoot);
|
|
188
186
|
}
|
|
189
187
|
// 9. Exec command
|
|
190
|
-
if (
|
|
191
|
-
log.info(`Executing: ${
|
|
188
|
+
if (options.exec && options.exec.trim()) {
|
|
189
|
+
log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
|
|
192
190
|
try {
|
|
193
|
-
await execa(
|
|
191
|
+
await execa(options.exec, {
|
|
194
192
|
cwd: wtPath,
|
|
195
193
|
stdio: 'inherit',
|
|
196
194
|
shell: true
|
|
197
195
|
});
|
|
198
196
|
}
|
|
199
197
|
catch (error) {
|
|
200
|
-
log.actionableError(error.message,
|
|
201
|
-
`cd ${wtPath} && ${
|
|
198
|
+
log.actionableError(error.message, options.exec, wtPath, [
|
|
199
|
+
`cd ${wtPath} && ${options.exec}`,
|
|
202
200
|
'Check your command syntax and environment variables'
|
|
203
201
|
]);
|
|
204
202
|
}
|
|
205
203
|
}
|
|
206
|
-
if (selectedTool
|
|
204
|
+
else if (selectedTool) {
|
|
207
205
|
try {
|
|
208
206
|
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
|
|
209
207
|
await launchOpenTool(selectedTool, wtPath);
|
|
@@ -216,21 +214,7 @@ export async function createSandboxCommand(options = {}) {
|
|
|
216
214
|
log.success('Sandbox ready!');
|
|
217
215
|
log.info(`Use ${chalk.cyan('yggtree wt apply')} to apply changes to origin.`);
|
|
218
216
|
log.info(`Use ${chalk.cyan('yggtree wt unapply')} to undo applied changes.`);
|
|
219
|
-
|
|
220
|
-
log.info(`Spawning sub-shell in ${ui.path(wtPath)}...`);
|
|
221
|
-
log.dim('Type "exit" to return to the main terminal.');
|
|
222
|
-
const shell = process.env.SHELL || 'zsh';
|
|
223
|
-
const child = spawn(shell, [], {
|
|
224
|
-
cwd: wtPath,
|
|
225
|
-
stdio: 'inherit',
|
|
226
|
-
});
|
|
227
|
-
child.on('close', () => {
|
|
228
|
-
log.info('Exited sub-shell.');
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
else {
|
|
232
|
-
log.header(`cd "${wtPath}"`);
|
|
233
|
-
}
|
|
217
|
+
log.header(`cd "${wtPath}"`);
|
|
234
218
|
}
|
|
235
219
|
catch (error) {
|
|
236
220
|
log.actionableError(error.message, 'yggtree wt create-sandbox');
|
|
@@ -7,9 +7,8 @@ import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
|
7
7
|
import { log, ui, createSpinner } from '../../lib/ui.js';
|
|
8
8
|
import { ensureAutocompletePrompt } from '../../lib/prompt.js';
|
|
9
9
|
import { enterCommand } from './enter.js';
|
|
10
|
-
import {
|
|
10
|
+
import { detectInstalledOpenTools, launchOpenTool, promptOpenToolSelection, } from './open.js';
|
|
11
11
|
import { execa } from 'execa';
|
|
12
|
-
import { spawn } from 'child_process';
|
|
13
12
|
import fs from 'fs-extra';
|
|
14
13
|
function toSlug(value) {
|
|
15
14
|
return value
|
|
@@ -64,6 +63,9 @@ export async function createCommand(options) {
|
|
|
64
63
|
try {
|
|
65
64
|
const repoRoot = await getRepoRoot();
|
|
66
65
|
log.info(`Repo: ${chalk.dim(repoRoot)}`);
|
|
66
|
+
if (options.enter !== undefined) {
|
|
67
|
+
log.warning('`--enter` / `--no-enter` is deprecated. Use `--open` / `--no-open` instead.');
|
|
68
|
+
}
|
|
67
69
|
// 1. Load branches
|
|
68
70
|
const loadingSpinner = createSpinner('Fetching branches...').start();
|
|
69
71
|
await fetchAll();
|
|
@@ -114,12 +116,37 @@ export async function createCommand(options) {
|
|
|
114
116
|
const existingManagedWorktree = existingWorktrees.find(wt => wt.branch === selectedBranch.branchName && wt.path.startsWith(WORKTREES_ROOT));
|
|
115
117
|
if (existingManagedWorktree) {
|
|
116
118
|
log.info(`Branch ${chalk.cyan(selectedBranch.branchName)} is already active in ${ui.path(existingManagedWorktree.path)}.`);
|
|
117
|
-
if (options.
|
|
119
|
+
if (options.exec && options.exec.trim()) {
|
|
120
|
+
log.info('Reusing the existing worktree and running the requested command...');
|
|
121
|
+
await enterCommand(existingManagedWorktree.path, { exec: options.exec });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const shouldOpenExisting = options.open !== undefined
|
|
125
|
+
? options.open
|
|
126
|
+
: options.enter !== undefined
|
|
127
|
+
? options.enter
|
|
128
|
+
: (await inquirer.prompt([
|
|
129
|
+
{
|
|
130
|
+
type: 'confirm',
|
|
131
|
+
name: 'shouldOpenTool',
|
|
132
|
+
message: 'Open a tool in the existing worktree now? (IDE or agent CLI)',
|
|
133
|
+
default: true,
|
|
134
|
+
},
|
|
135
|
+
])).shouldOpenTool;
|
|
136
|
+
if (!shouldOpenExisting) {
|
|
137
|
+
log.header(`cd "${existingManagedWorktree.path}"`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const installedTools = await detectInstalledOpenTools();
|
|
141
|
+
if (installedTools.length === 0) {
|
|
142
|
+
log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
|
|
118
143
|
log.header(`cd "${existingManagedWorktree.path}"`);
|
|
119
144
|
return;
|
|
120
145
|
}
|
|
146
|
+
const selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open in the existing worktree:');
|
|
121
147
|
log.info('Opening existing worktree instead of creating a duplicate...');
|
|
122
|
-
await
|
|
148
|
+
await launchOpenTool(selectedTool, existingManagedWorktree.path);
|
|
149
|
+
log.success('Existing worktree opened.');
|
|
123
150
|
return;
|
|
124
151
|
}
|
|
125
152
|
// 3. Gather remaining inputs
|
|
@@ -140,26 +167,23 @@ export async function createCommand(options) {
|
|
|
140
167
|
default: true,
|
|
141
168
|
when: options.bootstrap !== false && options.bootstrap !== true,
|
|
142
169
|
},
|
|
143
|
-
{
|
|
144
|
-
type: 'confirm',
|
|
145
|
-
name: 'shouldEnter',
|
|
146
|
-
message: 'Do you want to enter the new worktree now?',
|
|
147
|
-
default: true,
|
|
148
|
-
when: options.enter === undefined,
|
|
149
|
-
},
|
|
150
170
|
{
|
|
151
171
|
type: 'confirm',
|
|
152
172
|
name: 'shouldOpenTool',
|
|
153
173
|
message: 'Open a tool after creation? (IDE or agent CLI)',
|
|
154
174
|
default: false,
|
|
155
|
-
when: options.exec === undefined,
|
|
175
|
+
when: options.exec === undefined && options.open === undefined && options.enter === undefined,
|
|
156
176
|
},
|
|
157
177
|
]);
|
|
158
178
|
const name = options.name || answers.name;
|
|
159
|
-
const shouldEnter = options.enter !== undefined ? options.enter : answers.shouldEnter;
|
|
160
179
|
const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
|
|
180
|
+
const shouldOpenTool = options.open !== undefined
|
|
181
|
+
? options.open
|
|
182
|
+
: options.enter !== undefined
|
|
183
|
+
? options.enter
|
|
184
|
+
: Boolean(answers.shouldOpenTool);
|
|
161
185
|
let selectedTool;
|
|
162
|
-
if (options.exec === undefined &&
|
|
186
|
+
if (options.exec === undefined && shouldOpenTool) {
|
|
163
187
|
const installedTools = await detectInstalledOpenTools();
|
|
164
188
|
if (installedTools.length === 0) {
|
|
165
189
|
log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
|
|
@@ -168,7 +192,6 @@ export async function createCommand(options) {
|
|
|
168
192
|
selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
|
|
169
193
|
}
|
|
170
194
|
}
|
|
171
|
-
const execCommandStr = options.exec || (selectedTool && isAgentTool(selectedTool) ? buildAgentExecCommand(selectedTool) : undefined);
|
|
172
195
|
const slug = toSlug(name);
|
|
173
196
|
const repoName = await getRepoName();
|
|
174
197
|
const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
|
|
@@ -204,23 +227,23 @@ export async function createCommand(options) {
|
|
|
204
227
|
await runBootstrap(wtPath, repoRoot);
|
|
205
228
|
}
|
|
206
229
|
// 6. Exec Command
|
|
207
|
-
if (
|
|
208
|
-
log.info(`Executing: ${
|
|
230
|
+
if (options.exec && options.exec.trim()) {
|
|
231
|
+
log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
|
|
209
232
|
try {
|
|
210
|
-
await execa(
|
|
233
|
+
await execa(options.exec, {
|
|
211
234
|
cwd: wtPath,
|
|
212
235
|
stdio: 'inherit',
|
|
213
236
|
shell: true
|
|
214
237
|
});
|
|
215
238
|
}
|
|
216
239
|
catch (error) {
|
|
217
|
-
log.actionableError(error.message,
|
|
218
|
-
`cd ${wtPath} && ${
|
|
240
|
+
log.actionableError(error.message, options.exec, wtPath, [
|
|
241
|
+
`cd ${wtPath} && ${options.exec}`,
|
|
219
242
|
'Check your command syntax and environment variables'
|
|
220
243
|
]);
|
|
221
244
|
}
|
|
222
245
|
}
|
|
223
|
-
if (selectedTool
|
|
246
|
+
else if (selectedTool) {
|
|
224
247
|
try {
|
|
225
248
|
log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
|
|
226
249
|
await launchOpenTool(selectedTool, wtPath);
|
|
@@ -231,21 +254,7 @@ export async function createCommand(options) {
|
|
|
231
254
|
}
|
|
232
255
|
// 7. Final Output
|
|
233
256
|
log.success('Worktree ready!');
|
|
234
|
-
|
|
235
|
-
log.info(`Spawning sub-shell in ${ui.path(wtPath)}...`);
|
|
236
|
-
log.dim('Type "exit" to return to the main terminal.');
|
|
237
|
-
const shell = process.env.SHELL || 'zsh';
|
|
238
|
-
const child = spawn(shell, [], {
|
|
239
|
-
cwd: wtPath,
|
|
240
|
-
stdio: 'inherit',
|
|
241
|
-
});
|
|
242
|
-
child.on('close', () => {
|
|
243
|
-
log.info('Exited sub-shell.');
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
log.header(`cd "${wtPath}"`);
|
|
248
|
-
}
|
|
257
|
+
log.header(`cd "${wtPath}"`);
|
|
249
258
|
}
|
|
250
259
|
catch (error) {
|
|
251
260
|
log.actionableError(error.message, 'yggtree wt worktree-checkout');
|
|
@@ -5,6 +5,7 @@ import chalk from 'chalk';
|
|
|
5
5
|
import { listWorktrees, getRepoRoot } from '../../lib/git.js';
|
|
6
6
|
import { log, ui } from '../../lib/ui.js';
|
|
7
7
|
import { detectWorktreeType, findWorktreeByName, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, } from '../../lib/worktree.js';
|
|
8
|
+
import { getValidRegisteredRepos } from '../../lib/registry.js';
|
|
8
9
|
function truncateEnd(value, maxLen) {
|
|
9
10
|
if (maxLen <= 0)
|
|
10
11
|
return '';
|
|
@@ -36,7 +37,31 @@ function formatChoiceLabel(type, branchName, displayPath, terminalColumns) {
|
|
|
36
37
|
}
|
|
37
38
|
export async function enterCommand(wtName, options = {}) {
|
|
38
39
|
try {
|
|
39
|
-
|
|
40
|
+
try {
|
|
41
|
+
await getRepoRoot();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
const validRepos = await getValidRegisteredRepos();
|
|
45
|
+
const repoNames = Object.keys(validRepos);
|
|
46
|
+
if (repoNames.length === 0) {
|
|
47
|
+
log.error('Not inside a git repository and no registered realms found.');
|
|
48
|
+
log.dim('Run `yggtree` inside an existing git project first to register it.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
console.log(chalk.bold('\n Not inside a realm. Pick a known one:'));
|
|
52
|
+
const { selectedRepoName } = await inquirer.prompt([{
|
|
53
|
+
type: 'list',
|
|
54
|
+
name: 'selectedRepoName',
|
|
55
|
+
message: 'Select a realm:',
|
|
56
|
+
choices: Object.entries(validRepos).map(([name, rPath]) => ({
|
|
57
|
+
name: `${chalk.bold.yellow(name)} ${chalk.dim(formatWorktreeDisplayPath(rPath))}`,
|
|
58
|
+
value: name,
|
|
59
|
+
})),
|
|
60
|
+
pageSize: 10,
|
|
61
|
+
}]);
|
|
62
|
+
const repoPath = validRepos[selectedRepoName];
|
|
63
|
+
process.chdir(repoPath);
|
|
64
|
+
}
|
|
40
65
|
const worktrees = await listWorktrees();
|
|
41
66
|
const mainWorktreePath = worktrees[0]?.path || '';
|
|
42
67
|
if (worktrees.length === 0) {
|
package/dist/commands/wt/list.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { listWorktrees, getRepoRoot, isGitClean, getLastActivity } from '../../lib/git.js';
|
|
2
|
+
import { listWorktrees, getRepoRoot, isGitClean, getLastActivity, isGhAvailable, getPrStatusBatch } from '../../lib/git.js';
|
|
3
3
|
import { log, timeAgo } from '../../lib/ui.js';
|
|
4
|
-
import { detectWorktreeType, formatWorktreeType, getWorktreeBranchName, WORKTREE_TYPE_ORDER, } from '../../lib/worktree.js';
|
|
4
|
+
import { detectWorktreeType, formatWorktreeType, formatPrStatus, getWorktreeBranchName, WORKTREE_TYPE_ORDER, } from '../../lib/worktree.js';
|
|
5
5
|
export async function listCommand() {
|
|
6
6
|
try {
|
|
7
7
|
await getRepoRoot(); // Verify we are in a git repo
|
|
@@ -11,10 +11,17 @@ export async function listCommand() {
|
|
|
11
11
|
log.info('No worktrees found.');
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
|
+
// Determine if PR status column should be shown
|
|
15
|
+
const ghReady = await isGhAvailable();
|
|
16
|
+
// Collect branch names for batch PR lookup
|
|
17
|
+
const branches = worktrees.map(wt => getWorktreeBranchName(wt));
|
|
18
|
+
const prStatusMap = ghReady ? await getPrStatusBatch(branches) : new Map();
|
|
19
|
+
const showPr = prStatusMap.size > 0;
|
|
14
20
|
console.log(chalk.bold('\n Active Worktrees:\n'));
|
|
15
|
-
// Header
|
|
16
|
-
|
|
17
|
-
console.log(chalk.dim('
|
|
21
|
+
// Header — PR column only appears when there's data
|
|
22
|
+
const headerPr = showPr ? `${chalk.dim('PR')} ` : '';
|
|
23
|
+
console.log(` ${chalk.dim('TYPE')} ${chalk.dim('STATE')} ${chalk.dim('LAST ACTIVE')} ${headerPr}${chalk.dim('BRANCH')}`);
|
|
24
|
+
console.log(chalk.dim(' ' + '-'.repeat(showPr ? 90 : 70)));
|
|
18
25
|
const rows = await Promise.all(worktrees.map(async (wt, index) => {
|
|
19
26
|
const [typeKey, isClean, lastActive] = await Promise.all([
|
|
20
27
|
detectWorktreeType(wt, mainWorktreePath),
|
|
@@ -27,8 +34,12 @@ export async function listCommand() {
|
|
|
27
34
|
const stateText = isClean ? chalk.green(stateLabel) : chalk.yellow(stateLabel);
|
|
28
35
|
const activeLabel = lastActive ? timeAgo(lastActive) : '—';
|
|
29
36
|
const activeText = chalk.magenta(activeLabel.padEnd(14));
|
|
37
|
+
const prStatus = prStatusMap.get(branchName);
|
|
38
|
+
const prText = showPr
|
|
39
|
+
? (prStatus ? formatPrStatus(prStatus).padEnd(24) : chalk.dim('—'.padEnd(14)))
|
|
40
|
+
: '';
|
|
30
41
|
return {
|
|
31
|
-
text: ` ${type} ${stateText} ${activeText} ${chalk.yellow(branchName)}`,
|
|
42
|
+
text: ` ${type} ${stateText} ${activeText} ${prText}${chalk.yellow(branchName)}`,
|
|
32
43
|
sortType: WORKTREE_TYPE_ORDER[typeKey],
|
|
33
44
|
sortBranch: branchName.toLowerCase(),
|
|
34
45
|
sortIndex: index,
|
|
@@ -39,6 +50,12 @@ export async function listCommand() {
|
|
|
39
50
|
a.sortBranch.localeCompare(b.sortBranch) ||
|
|
40
51
|
a.sortIndex - b.sortIndex)
|
|
41
52
|
.forEach(row => console.log(row.text));
|
|
53
|
+
if (ghReady && !showPr) {
|
|
54
|
+
console.log(chalk.dim('\n ℹ No open PRs found for any worktree branch.'));
|
|
55
|
+
}
|
|
56
|
+
else if (!ghReady) {
|
|
57
|
+
console.log(chalk.dim('\n ℹ PR status omitted (gh CLI not found). Install GitHub CLI for PR tracking.'));
|
|
58
|
+
}
|
|
42
59
|
console.log('');
|
|
43
60
|
}
|
|
44
61
|
catch (error) {
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
1
|
+
import { Command, Option } from 'commander';
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { welcome, log } from './lib/ui.js';
|
|
@@ -15,6 +15,7 @@ import { enterCommand } from './commands/wt/enter.js';
|
|
|
15
15
|
import { pathCommand } from './commands/wt/path.js';
|
|
16
16
|
import { openCommand } from './commands/wt/open.js';
|
|
17
17
|
import { applyCommand } from './commands/wt/apply.js';
|
|
18
|
+
import { closeCommand } from './commands/wt/close.js';
|
|
18
19
|
import { unapplyCommand } from './commands/wt/unapply.js';
|
|
19
20
|
import { getVersion } from './lib/version.js';
|
|
20
21
|
import { findSandboxRoot } from './lib/sandbox.js';
|
|
@@ -42,6 +43,7 @@ program
|
|
|
42
43
|
{ name: `🐚 Cast a Command ${chalk.dim('(exec command in worktree)')}`, value: 'exec' },
|
|
43
44
|
{ name: `🚪 Enter Realm Shell ${chalk.dim('(enter worktree)')}`, value: 'enter' },
|
|
44
45
|
{ name: `📍 Reveal Realm Path ${chalk.dim('(show worktree path)')}`, value: 'path' },
|
|
46
|
+
{ name: `🔒 Close Realm ${chalk.dim('(exit & optionally delete worktree)')}`, value: 'close' },
|
|
45
47
|
];
|
|
46
48
|
const sandboxChoices = [
|
|
47
49
|
{ name: `✅ Graft Sandbox Changes ${chalk.dim('(apply sandbox changes)')}`, value: 'apply' },
|
|
@@ -125,6 +127,9 @@ program
|
|
|
125
127
|
case 'thor':
|
|
126
128
|
await thorCommand();
|
|
127
129
|
break;
|
|
130
|
+
case 'close':
|
|
131
|
+
await closeCommand();
|
|
132
|
+
break;
|
|
128
133
|
case 'exit':
|
|
129
134
|
log.info('Bye! 👋');
|
|
130
135
|
process.exit(0);
|
|
@@ -148,8 +153,10 @@ wt.command('create [branch]')
|
|
|
148
153
|
.option('--base <ref>', 'Base ref (e.g. main)')
|
|
149
154
|
.option('--source <type>', 'Base source (local or remote)')
|
|
150
155
|
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
151
|
-
.option('--
|
|
152
|
-
.option('--no-
|
|
156
|
+
.option('--open', 'Open a tool after creation (IDE or agent CLI)')
|
|
157
|
+
.option('--no-open', 'Skip opening a tool after creation')
|
|
158
|
+
.addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
|
|
159
|
+
.addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
|
|
153
160
|
.option('--exec <command>', 'Command to execute after creation')
|
|
154
161
|
.action(async (branch, options) => {
|
|
155
162
|
await createCommandNew({
|
|
@@ -170,8 +177,10 @@ wt.command('worktree-checkout [name] [ref]')
|
|
|
170
177
|
.option('-n, --name <slug>', 'Worktree name (slug)')
|
|
171
178
|
.option('-r, --ref <ref>', 'Existing branch or ref')
|
|
172
179
|
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
173
|
-
.option('--
|
|
174
|
-
.option('--no-
|
|
180
|
+
.option('--open', 'Open a tool after creation (IDE or agent CLI)')
|
|
181
|
+
.option('--no-open', 'Skip opening a tool after creation')
|
|
182
|
+
.addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
|
|
183
|
+
.addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
|
|
175
184
|
.option('--exec <command>', 'Command to execute after creation')
|
|
176
185
|
.action(async (name, ref, options) => {
|
|
177
186
|
await createCommand({
|
|
@@ -223,8 +232,10 @@ wt.command('create-sandbox')
|
|
|
223
232
|
.option('--carry', 'Carry uncommitted changes to sandbox')
|
|
224
233
|
.option('--no-carry', 'Do not carry uncommitted changes')
|
|
225
234
|
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
226
|
-
.option('--
|
|
227
|
-
.option('--no-
|
|
235
|
+
.option('--open', 'Open a tool after creation (IDE or agent CLI)')
|
|
236
|
+
.option('--no-open', 'Skip opening a tool after creation')
|
|
237
|
+
.addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
|
|
238
|
+
.addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
|
|
228
239
|
.option('--exec <command>', 'Command to execute after creation')
|
|
229
240
|
.action(async (options) => {
|
|
230
241
|
await createSandboxCommand(options);
|
|
@@ -232,6 +243,11 @@ wt.command('create-sandbox')
|
|
|
232
243
|
wt.command('apply')
|
|
233
244
|
.description('Apply sandbox changes to origin directory')
|
|
234
245
|
.action(applyCommand);
|
|
246
|
+
wt.command('close')
|
|
247
|
+
.description('Exit the sub-shell with an option to delete the worktree')
|
|
248
|
+
.action(async () => {
|
|
249
|
+
await closeCommand();
|
|
250
|
+
});
|
|
235
251
|
wt.command('unapply')
|
|
236
252
|
.description('Undo applied sandbox changes in origin')
|
|
237
253
|
.action(unapplyCommand);
|
package/dist/lib/git.js
CHANGED
|
@@ -3,10 +3,18 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { log } from './ui.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
+
import { registerRepo } from './registry.js';
|
|
7
|
+
let cachedRepoRoot = null;
|
|
6
8
|
export async function getRepoRoot() {
|
|
9
|
+
if (cachedRepoRoot)
|
|
10
|
+
return cachedRepoRoot;
|
|
7
11
|
try {
|
|
8
12
|
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel']);
|
|
9
|
-
|
|
13
|
+
cachedRepoRoot = stdout.trim();
|
|
14
|
+
// Silently register the repo in the background
|
|
15
|
+
const name = path.basename(cachedRepoRoot);
|
|
16
|
+
registerRepo(name, cachedRepoRoot).catch(() => { });
|
|
17
|
+
return cachedRepoRoot;
|
|
10
18
|
}
|
|
11
19
|
catch (error) {
|
|
12
20
|
throw new Error('Not a git repository');
|
|
@@ -130,13 +138,83 @@ export async function publishBranch(wtPath, branchName) {
|
|
|
130
138
|
log.info(`Publishing ${chalk.cyan(branchName)} → ${chalk.cyan(desiredUpstream)}...`);
|
|
131
139
|
await execa('git', ['push', '-u', 'origin', 'HEAD'], { cwd: wtPath });
|
|
132
140
|
}
|
|
141
|
+
let ghAvailableCache = null;
|
|
133
142
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
143
|
+
* Check whether `gh` CLI is installed and authenticated.
|
|
144
|
+
* Result is cached for the process lifetime.
|
|
145
|
+
*/
|
|
146
|
+
export async function isGhAvailable() {
|
|
147
|
+
if (ghAvailableCache !== null)
|
|
148
|
+
return ghAvailableCache;
|
|
149
|
+
try {
|
|
150
|
+
await execa('gh', ['auth', 'status'], { stdio: 'ignore' });
|
|
151
|
+
ghAvailableCache = true;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
ghAvailableCache = false;
|
|
155
|
+
}
|
|
156
|
+
return ghAvailableCache;
|
|
157
|
+
}
|
|
158
|
+
function derivePrLabel(state, reviewDecision) {
|
|
159
|
+
if (state === 'merged')
|
|
160
|
+
return 'MERGED';
|
|
161
|
+
if (state === 'closed')
|
|
162
|
+
return 'CLOSED';
|
|
163
|
+
if (state === 'draft')
|
|
164
|
+
return 'DRAFT';
|
|
165
|
+
// state === 'open'
|
|
166
|
+
if (reviewDecision === 'approved')
|
|
167
|
+
return 'APPROVED';
|
|
168
|
+
if (reviewDecision === 'changes_requested')
|
|
169
|
+
return 'CHANGES';
|
|
170
|
+
if (reviewDecision === 'review_required')
|
|
171
|
+
return 'IN REVIEW';
|
|
172
|
+
return 'OPEN';
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Fetch PR status for a branch using `gh pr view`.
|
|
176
|
+
* Returns null when `gh` is unavailable or the branch has no PR.
|
|
139
177
|
*/
|
|
178
|
+
export async function getPrStatusForBranch(branch, cwd) {
|
|
179
|
+
if (!branch || branch === 'detached')
|
|
180
|
+
return null;
|
|
181
|
+
try {
|
|
182
|
+
const { stdout } = await execa('gh', ['pr', 'view', branch, '--json', 'state,reviewDecision,isDraft,number'], { cwd, timeout: 10_000 });
|
|
183
|
+
const data = JSON.parse(stdout);
|
|
184
|
+
const isDraft = data.isDraft === true;
|
|
185
|
+
const rawState = (data.state || '').toLowerCase();
|
|
186
|
+
const state = isDraft && rawState === 'open' ? 'draft' : rawState;
|
|
187
|
+
const reviewDecision = (data.reviewDecision || '').toLowerCase().replace(/ /g, '_') || null;
|
|
188
|
+
return {
|
|
189
|
+
state,
|
|
190
|
+
reviewDecision,
|
|
191
|
+
label: derivePrLabel(state, reviewDecision),
|
|
192
|
+
number: data.number,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Batch-fetch PR statuses for all provided branches (parallelized).
|
|
201
|
+
* Returns a Map<branchName, PrStatus>.
|
|
202
|
+
* If `gh` is unavailable, returns an empty map immediately.
|
|
203
|
+
*/
|
|
204
|
+
export async function getPrStatusBatch(branches, cwd) {
|
|
205
|
+
const map = new Map();
|
|
206
|
+
if (!(await isGhAvailable()))
|
|
207
|
+
return map;
|
|
208
|
+
const results = await Promise.all(branches.map(async (branch) => {
|
|
209
|
+
const status = await getPrStatusForBranch(branch, cwd);
|
|
210
|
+
return { branch, status };
|
|
211
|
+
}));
|
|
212
|
+
for (const { branch, status } of results) {
|
|
213
|
+
if (status)
|
|
214
|
+
map.set(branch, status);
|
|
215
|
+
}
|
|
216
|
+
return map;
|
|
217
|
+
}
|
|
140
218
|
export async function getLastActivity(wtPath) {
|
|
141
219
|
const dates = [];
|
|
142
220
|
// Signal 1 — last commit epoch
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { YGG_ROOT } from './paths.js';
|
|
4
|
+
const REGISTRY_PATH = path.join(YGG_ROOT, 'registry.json');
|
|
5
|
+
/**
|
|
6
|
+
* Read the registry, initializing if missing.
|
|
7
|
+
*/
|
|
8
|
+
export async function readRegistry() {
|
|
9
|
+
try {
|
|
10
|
+
if (await fs.pathExists(REGISTRY_PATH)) {
|
|
11
|
+
return await fs.readJson(REGISTRY_PATH);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Ignore JSON read errors, just return empty
|
|
16
|
+
}
|
|
17
|
+
return { repos: {} };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Write the registry back to disk.
|
|
21
|
+
*/
|
|
22
|
+
async function writeRegistry(registry) {
|
|
23
|
+
await fs.ensureDir(YGG_ROOT);
|
|
24
|
+
await fs.writeJson(REGISTRY_PATH, registry, { spaces: 2 });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Silently register a repository path.
|
|
28
|
+
* This is meant to be called in the background (fire-and-forget).
|
|
29
|
+
*/
|
|
30
|
+
export async function registerRepo(repoName, repoRoot) {
|
|
31
|
+
try {
|
|
32
|
+
const registry = await readRegistry();
|
|
33
|
+
// Only write if it actually changed
|
|
34
|
+
if (registry.repos[repoName] !== repoRoot) {
|
|
35
|
+
registry.repos[repoName] = repoRoot;
|
|
36
|
+
await writeRegistry(registry);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Fail silently — registry is a nice-to-have, shouldn't crash commands
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get a specific repo path from the registry.
|
|
45
|
+
* Also verifies the path still exists on disk, removing it if not.
|
|
46
|
+
*/
|
|
47
|
+
export async function getRegisteredRepoPath(repoName) {
|
|
48
|
+
const registry = await readRegistry();
|
|
49
|
+
const repoPath = registry.repos[repoName];
|
|
50
|
+
if (!repoPath)
|
|
51
|
+
return null;
|
|
52
|
+
// Prune dead links
|
|
53
|
+
if (!(await fs.pathExists(repoPath))) {
|
|
54
|
+
delete registry.repos[repoName];
|
|
55
|
+
await writeRegistry(registry).catch(() => { });
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return repoPath;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get all registered repos that still exist on disk.
|
|
62
|
+
*/
|
|
63
|
+
export async function getValidRegisteredRepos() {
|
|
64
|
+
const registry = await readRegistry();
|
|
65
|
+
const valid = {};
|
|
66
|
+
let changed = false;
|
|
67
|
+
// Validate in parallel
|
|
68
|
+
await Promise.all(Object.entries(registry.repos).map(async ([name, rPath]) => {
|
|
69
|
+
if (await fs.pathExists(rPath)) {
|
|
70
|
+
valid[name] = rPath;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
delete registry.repos[name];
|
|
74
|
+
changed = true;
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
if (changed) {
|
|
78
|
+
await writeRegistry(registry).catch(() => { });
|
|
79
|
+
}
|
|
80
|
+
return valid;
|
|
81
|
+
}
|
package/dist/lib/worktree.js
CHANGED
|
@@ -50,3 +50,15 @@ export function formatWorktreeType(type) {
|
|
|
50
50
|
return chalk.blue('MAIN ');
|
|
51
51
|
return chalk.cyan('LINKED ');
|
|
52
52
|
}
|
|
53
|
+
export function formatPrStatus(pr) {
|
|
54
|
+
switch (pr.label) {
|
|
55
|
+
case 'MERGED': return chalk.magenta(`#${pr.number} MERGED`);
|
|
56
|
+
case 'APPROVED': return chalk.green(`#${pr.number} APPROVED`);
|
|
57
|
+
case 'CHANGES': return chalk.red(`#${pr.number} CHANGES`);
|
|
58
|
+
case 'IN REVIEW': return chalk.yellow(`#${pr.number} IN REVIEW`);
|
|
59
|
+
case 'DRAFT': return chalk.dim(`#${pr.number} DRAFT`);
|
|
60
|
+
case 'OPEN': return chalk.cyan(`#${pr.number} OPEN`);
|
|
61
|
+
case 'CLOSED': return chalk.dim(`#${pr.number} CLOSED`);
|
|
62
|
+
default: return chalk.dim(`#${pr.number} ${pr.label}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yggtree",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Interactive CLI for managing git worktrees and configs",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "github.com/leoreisdias/yggdrasil-worktree",
|
|
9
|
+
"type": "git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "yggtree.logbookfordevs.com",
|
|
7
12
|
"bin": {
|
|
8
13
|
"yggtree": "./bin/yggtree"
|
|
9
14
|
},
|