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 CHANGED
@@ -218,7 +218,7 @@ Options:
218
218
  * `--base <ref>`
219
219
  * `--source local|remote`
220
220
  * `--no-bootstrap`
221
- * `--enter / --no-enter`
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
- * `--enter / --no-enter`
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-enter
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
- * `--enter / --no-enter`
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 and enter it immediately</summary>
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
- * Drops you into a sub-shell inside the worktree
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 entering</summary>
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-enter
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 { buildAgentExecCommand, detectInstalledOpenTools, isAgentTool, launchOpenTool, promptOpenToolSelection, } from './open.js';
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 && answers.shouldOpenTool) {
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 (execCommandStr && execCommandStr.trim()) {
158
- log.info(`Executing: ${execCommandStr} in ${ui.path(wtPath)}`);
155
+ if (options.exec && options.exec.trim()) {
156
+ log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
159
157
  try {
160
- await execa(execCommandStr, {
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, execCommandStr, wtPath, [
168
- `cd ${wtPath} && ${execCommandStr}`,
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 && !isAgentTool(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
- if (shouldEnter) {
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 { buildAgentExecCommand, detectInstalledOpenTools, isAgentTool, launchOpenTool, promptOpenToolSelection, } from './open.js';
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 && answers.shouldOpenTool) {
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 (execCommandStr && execCommandStr.trim()) {
191
- log.info(`Executing: ${execCommandStr} in ${ui.path(wtPath)}`);
188
+ if (options.exec && options.exec.trim()) {
189
+ log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
192
190
  try {
193
- await execa(execCommandStr, {
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, execCommandStr, wtPath, [
201
- `cd ${wtPath} && ${execCommandStr}`,
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 && !isAgentTool(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
- if (shouldEnter) {
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 { buildAgentExecCommand, detectInstalledOpenTools, isAgentTool, launchOpenTool, promptOpenToolSelection, } from './open.js';
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.enter === false) {
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 enterCommand(existingManagedWorktree.path, { exec: options.exec ?? '' });
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 && answers.shouldOpenTool) {
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 (execCommandStr && execCommandStr.trim()) {
208
- log.info(`Executing: ${execCommandStr} in ${ui.path(wtPath)}`);
230
+ if (options.exec && options.exec.trim()) {
231
+ log.info(`Executing: ${options.exec} in ${ui.path(wtPath)}`);
209
232
  try {
210
- await execa(execCommandStr, {
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, execCommandStr, wtPath, [
218
- `cd ${wtPath} && ${execCommandStr}`,
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 && !isAgentTool(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
- if (shouldEnter) {
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
- await getRepoRoot();
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) {
@@ -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
- console.log(` ${chalk.dim('TYPE')} ${chalk.dim('STATE')} ${chalk.dim('LAST ACTIVE')} ${chalk.dim('BRANCH')}`);
17
- console.log(chalk.dim(' ' + '-'.repeat(70)));
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('--enter', 'Enter sub-shell after creation')
152
- .option('--no-enter', 'Skip entering sub-shell')
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('--enter', 'Enter sub-shell after creation')
174
- .option('--no-enter', 'Skip entering sub-shell')
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('--enter', 'Enter sub-shell after creation')
227
- .option('--no-enter', 'Skip entering sub-shell')
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
- return stdout.trim();
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
- * Returns the most recent activity date for a worktree by checking two signals:
135
- * 1. Last commit time (captures committed work)
136
- * 2. Git index mtime (captures staging, checkouts, uncommitted work)
137
- *
138
- * The most recent of the two wins. Returns null if both fail.
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
+ }
@@ -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.1",
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
  },