yggtree 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,39 +11,175 @@ import { deleteCommand } from './commands/wt/delete.js';
11
11
  import { bootstrapCommand } from './commands/wt/bootstrap.js';
12
12
  import { pruneCommand } from './commands/wt/prune.js';
13
13
  import { execCommand } from './commands/wt/exec.js';
14
- import { enterCommand } from './commands/wt/enter.js';
15
14
  import { pathCommand } from './commands/wt/path.js';
16
15
  import { openCommand } from './commands/wt/open.js';
17
16
  import { applyCommand } from './commands/wt/apply.js';
18
- import { closeCommand } from './commands/wt/close.js';
19
17
  import { unapplyCommand } from './commands/wt/unapply.js';
18
+ import { handoffCommand } from './commands/wt/handoff.js';
20
19
  import { getVersion } from './lib/version.js';
20
+ import { notifyIfUpdateAvailable } from './lib/update-check.js';
21
21
  import { findSandboxRoot } from './lib/sandbox.js';
22
22
  import { bifrostCommand } from './commands/bifrost.js';
23
23
  import { thorCommand } from './commands/thor.js';
24
24
  const program = new Command();
25
+ const argv = process.argv.map((arg) => arg === '-v' || arg === '—version' ? '--version' : arg);
26
+ function registerWorktreeCommands(parent) {
27
+ parent.command('list')
28
+ .description('List all repo-linked worktrees')
29
+ .option('--open', 'Open a worktree in an IDE/agent tool instead of listing')
30
+ .action(async (options) => {
31
+ if (options.open) {
32
+ await openCommand();
33
+ return;
34
+ }
35
+ await listCommand();
36
+ });
37
+ parent.command('create [branch]')
38
+ .description('Create a new worktree (Smart branch detection)')
39
+ .option('-b, --branch <name>', 'Branch name (e.g. feat/new-ui)')
40
+ .option('--base <ref>', 'Base ref (e.g. main)')
41
+ .option('--source <type>', 'Base source (local or remote)')
42
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
43
+ .option('--open', 'Open an editor after creation')
44
+ .option('--no-open', 'Skip opening an editor after creation')
45
+ .option('--enter', 'Enter the worktree sub-shell after creation')
46
+ .option('--no-enter', 'Do not enter the worktree sub-shell after creation')
47
+ .option('--exec <command>', 'Command to execute after creation')
48
+ .action(async (branch, options) => {
49
+ await createCommandNew({
50
+ ...options,
51
+ branch: branch || options.branch
52
+ });
53
+ });
54
+ parent.command('create-multi')
55
+ .description('Create multiple worktrees (Smart branch detection)')
56
+ .option('--base <ref>', 'Base ref (e.g. main)')
57
+ .option('--source <type>', 'Base source (local or remote)')
58
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
59
+ .action(async (options) => {
60
+ await createCommandMulti(options);
61
+ });
62
+ const registerWorktreeCheckout = (commandName, description) => {
63
+ parent.command(`${commandName} [name] [ref]`)
64
+ .description(description)
65
+ .option('-n, --name <slug>', 'Worktree name (slug)')
66
+ .option('-r, --ref <ref>', 'Existing branch or ref')
67
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
68
+ .option('--open', 'Open editors or run a startup command before entering')
69
+ .option('--no-open', 'Skip opening editors or startup commands before entering')
70
+ .option('--tool <command>', 'Editor, app, or terminal command to open after checkout (skips open prompt)')
71
+ .addOption(new Option('--enter', 'Enter the worktree sub-shell after checkout/opening').hideHelp())
72
+ .option('--no-enter', 'Do not enter the worktree sub-shell after checkout/opening')
73
+ .option('--exec <command>', 'Command to execute after creation')
74
+ .action(async (name, ref, options) => {
75
+ await createCommand({
76
+ ...options,
77
+ name: name || options.name,
78
+ ref: ref || options.ref
79
+ });
80
+ });
81
+ };
82
+ registerWorktreeCheckout('worktree-checkout', 'Create a checkout-style worktree from an existing branch');
83
+ registerWorktreeCheckout('wc', 'Alias for worktree-checkout');
84
+ parent.command('delete')
85
+ .description('Delete managed worktrees')
86
+ .option('-a, --all', 'Include repo-linked worktrees outside ~/.yggtree (except main/current)')
87
+ .action(async (options) => {
88
+ await deleteCommand(options);
89
+ });
90
+ parent.command('open [worktree]')
91
+ .description('Open a worktree in an editor, supported app, or terminal target')
92
+ .option('--tool <command>', 'Editor, app, or terminal command to use (e.g. cursor, code, codex-app, tmux)')
93
+ .option('--enter', 'Enter the worktree sub-shell after opening')
94
+ .addOption(new Option('--no-enter', 'Do not enter the worktree sub-shell after opening').hideHelp())
95
+ .action(async (worktree, options) => {
96
+ await openCommand(worktree, options);
97
+ });
98
+ parent.command('bootstrap')
99
+ .description('Bootstrap dependencies in a worktree')
100
+ .action(bootstrapCommand);
101
+ parent.command('prune')
102
+ .description('Prune stale worktree information')
103
+ .action(pruneCommand);
104
+ parent.command('exec')
105
+ .description('Execute a command in a worktree')
106
+ .argument('[worktree]', 'Worktree name or path')
107
+ .argument('[command...]', 'Command and arguments to execute')
108
+ .action(async (worktree, command) => {
109
+ await execCommand(worktree, command);
110
+ });
111
+ parent.command('path [worktree]')
112
+ .description('Show the cd command for a specific worktree')
113
+ .action(async (worktree) => {
114
+ await pathCommand(worktree);
115
+ });
116
+ parent.command('create-sandbox')
117
+ .description('Create a sandbox worktree from current branch')
118
+ .option('-n, --name <name>', 'Optional sandbox name (auto-generated when omitted)')
119
+ .option('--carry', 'Carry uncommitted changes to sandbox')
120
+ .option('--no-carry', 'Do not carry uncommitted changes')
121
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
122
+ .option('--open', 'Open an editor after creation')
123
+ .option('--no-open', 'Skip opening an editor after creation')
124
+ .addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
125
+ .addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
126
+ .option('--exec <command>', 'Command to execute after creation')
127
+ .action(async (options) => {
128
+ await createSandboxCommand(options);
129
+ });
130
+ parent.command('handoff')
131
+ .description('Carry uncommitted work into a sandbox worktree')
132
+ .option('-n, --name <name>', 'Optional handoff name (prompted when omitted)')
133
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
134
+ .option('--open', 'Open an editor after creation')
135
+ .option('--no-open', 'Skip opening an editor after creation')
136
+ .addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
137
+ .addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
138
+ .option('--exec <command>', 'Command to execute after creation')
139
+ .action(async (options) => {
140
+ await handoffCommand(options);
141
+ });
142
+ parent.command('apply')
143
+ .description('Apply sandbox changes to origin directory')
144
+ .action(applyCommand);
145
+ parent.command('unapply')
146
+ .description('Undo applied sandbox changes in origin')
147
+ .action(unapplyCommand);
148
+ }
149
+ function rejectUnknownTopLevelCommand(args) {
150
+ const commandArg = args.slice(2).find(arg => !arg.startsWith('-'));
151
+ if (!commandArg)
152
+ return;
153
+ if (commandArg === 'help')
154
+ return;
155
+ const knownCommands = new Set(program.commands.flatMap(command => [command.name(), ...command.aliases()]));
156
+ if (!knownCommands.has(commandArg)) {
157
+ program.error(`error: unknown command '${commandArg}'`);
158
+ }
159
+ }
25
160
  program
26
161
  .name('yggtree')
27
162
  .description('Interactive CLI for managing git worktrees and configs')
28
163
  .version(getVersion())
164
+ .allowExcessArguments(false)
29
165
  .action(async () => {
30
166
  // Interactive Menu if no command is provided
31
167
  await welcome();
168
+ await notifyIfUpdateAvailable();
32
169
  const isInSandbox = Boolean(await findSandboxRoot(process.cwd()));
33
170
  const realmChoices = [
34
171
  { name: `🌿 Grow New Realm ${chalk.dim('(create worktree)')}`, value: 'create-smart' },
35
172
  { name: `🔀 Traverse to Another Realm ${chalk.dim('(checkout existing branch in new worktree)')}`, value: 'worktree-checkout' },
36
173
  { name: `🌳 Grow Many Realms ${chalk.dim('(create multiple worktrees)')}`, value: 'create-multi' },
174
+ { name: `🤝 Hand Off Current Work ${chalk.dim('(carry dirty work into a sandbox)')}`, value: 'handoff' },
37
175
  { name: `🧪 Forge Sandbox Realm ${chalk.dim('(create sandbox worktree)')}`, value: 'create-sandbox' },
38
176
  { name: `🗺️ Survey Realms ${chalk.dim('(list worktrees)')}`, value: 'list' },
39
- { name: `🧭 Open Realm in Tool ${chalk.dim('(open worktree in IDE/agent)')}`, value: 'open' },
177
+ { name: `🧭 Open Realm in Editor ${chalk.dim('(open worktree in editor)')}`, value: 'open' },
40
178
  { name: `🪓 Fell a Realm ${chalk.dim('(delete worktree)')}`, value: 'delete' },
41
179
  { name: `🚀 Bless Realm Setup ${chalk.dim('(bootstrap worktree)')}`, value: 'bootstrap' },
42
180
  { name: `🧹 Prune Withered Realms ${chalk.dim('(prune stale worktrees)')}`, value: 'prune' },
43
181
  { name: `🐚 Cast a Command ${chalk.dim('(exec command in worktree)')}`, value: 'exec' },
44
- { name: `🚪 Enter Realm Shell ${chalk.dim('(enter worktree)')}`, value: 'enter' },
45
182
  { name: `📍 Reveal Realm Path ${chalk.dim('(show worktree path)')}`, value: 'path' },
46
- { name: `🔒 Close Realm ${chalk.dim('(exit & optionally delete worktree)')}`, value: 'close' },
47
183
  ];
48
184
  const sandboxChoices = [
49
185
  { name: `✅ Graft Sandbox Changes ${chalk.dim('(apply sandbox changes)')}`, value: 'apply' },
@@ -106,9 +242,6 @@ program
106
242
  case 'exec':
107
243
  await execCommand();
108
244
  break;
109
- case 'enter':
110
- await enterCommand();
111
- break;
112
245
  case 'path':
113
246
  await pathCommand();
114
247
  break;
@@ -121,15 +254,15 @@ program
121
254
  case 'create-sandbox':
122
255
  await createSandboxCommand({ bootstrap: true });
123
256
  break;
257
+ case 'handoff':
258
+ await handoffCommand({ bootstrap: true });
259
+ break;
124
260
  case 'bifrost':
125
261
  await bifrostCommand();
126
262
  break;
127
263
  case 'thor':
128
264
  await thorCommand();
129
265
  break;
130
- case 'close':
131
- await closeCommand();
132
- break;
133
266
  case 'exit':
134
267
  log.info('Bye! 👋');
135
268
  process.exit(0);
@@ -137,124 +270,14 @@ program
137
270
  });
138
271
  // --- Worktree Commands ---
139
272
  const wt = program.command('wt').description('Manage git worktrees');
140
- wt.command('list')
141
- .description('List all repo-linked worktrees')
142
- .option('--open', 'Open a worktree in an IDE/agent tool instead of listing')
143
- .action(async (options) => {
144
- if (options.open) {
145
- await openCommand();
146
- return;
147
- }
148
- await listCommand();
149
- });
150
- wt.command('create [branch]')
151
- .description('Create a new worktree (Smart branch detection)')
152
- .option('-b, --branch <name>', 'Branch name (e.g. feat/new-ui)')
153
- .option('--base <ref>', 'Base ref (e.g. main)')
154
- .option('--source <type>', 'Base source (local or remote)')
155
- .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
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())
160
- .option('--exec <command>', 'Command to execute after creation')
161
- .action(async (branch, options) => {
162
- await createCommandNew({
163
- ...options,
164
- branch: branch || options.branch
165
- });
166
- });
167
- wt.command('create-multi')
168
- .description('Create multiple worktrees (Smart branch detection)')
169
- .option('--base <ref>', 'Base ref (e.g. main)')
170
- .option('--source <type>', 'Base source (local or remote)')
171
- .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
172
- .action(async (options) => {
173
- await createCommandMulti(options);
174
- });
175
- wt.command('worktree-checkout [name] [ref]')
176
- .description('Create a checkout-style worktree from an existing branch')
177
- .option('-n, --name <slug>', 'Worktree name (slug)')
178
- .option('-r, --ref <ref>', 'Existing branch or ref')
179
- .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
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())
184
- .option('--exec <command>', 'Command to execute after creation')
185
- .action(async (name, ref, options) => {
186
- await createCommand({
187
- ...options,
188
- name: name || options.name,
189
- ref: ref || options.ref
190
- });
191
- });
192
- wt.command('delete')
193
- .description('Delete managed worktrees')
194
- .option('-a, --all', 'Include repo-linked worktrees outside ~/.yggtree (except main/current)')
195
- .action(async (options) => {
196
- await deleteCommand(options);
197
- });
198
- wt.command('open [worktree]')
199
- .description('Open a worktree in an IDE or agent CLI')
200
- .option('--tool <command>', 'Tool command to use (e.g. cursor, code, claude, codex)')
201
- .action(async (worktree, options) => {
202
- await openCommand(worktree, options);
203
- });
204
- wt.command('bootstrap')
205
- .description('Bootstrap dependencies in a worktree')
206
- .action(bootstrapCommand);
207
- wt.command('prune')
208
- .description('Prune stale worktree information')
209
- .action(pruneCommand);
210
- wt.command('exec')
211
- .description('Execute a command in a worktree')
212
- .argument('[worktree]', 'Worktree name or path')
213
- .argument('[command...]', 'Command and arguments to execute')
214
- .action(async (worktree, command) => {
215
- await execCommand(worktree, command);
216
- });
217
- wt.command('enter')
218
- .description('Enter a worktree sub-shell')
219
- .argument('[worktree]', 'Worktree name or path')
220
- .option('--exec <command>', 'Command to execute before entering')
221
- .action(async (worktree, options) => {
222
- await enterCommand(worktree, options);
223
- });
224
- wt.command('path [worktree]')
225
- .description('Show the cd command for a specific worktree')
226
- .action(async (worktree) => {
227
- await pathCommand(worktree);
228
- });
229
- wt.command('create-sandbox')
230
- .description('Create a sandbox worktree from current branch')
231
- .option('-n, --name <name>', 'Optional sandbox name (auto-generated when omitted)')
232
- .option('--carry', 'Carry uncommitted changes to sandbox')
233
- .option('--no-carry', 'Do not carry uncommitted changes')
234
- .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
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())
239
- .option('--exec <command>', 'Command to execute after creation')
240
- .action(async (options) => {
241
- await createSandboxCommand(options);
242
- });
243
- wt.command('apply')
244
- .description('Apply sandbox changes to origin directory')
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
- });
251
- wt.command('unapply')
252
- .description('Undo applied sandbox changes in origin')
253
- .action(unapplyCommand);
273
+ registerWorktreeCommands(program);
274
+ registerWorktreeCommands(wt);
275
+ program.addHelpCommand(true);
254
276
  program.command('bifrost')
255
277
  .description('Summon the Bifrost (Easter Egg)')
256
278
  .action(bifrostCommand);
257
279
  program.command('thor')
258
280
  .description('Consult the God of Thunder (Easter Egg)')
259
281
  .action(thorCommand);
260
- program.parse(process.argv);
282
+ rejectUnknownTopLevelCommand(argv);
283
+ program.parse(argv);
@@ -0,0 +1,72 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import inquirer from 'inquirer';
4
+ import path from 'path';
5
+ import { log } from './ui.js';
6
+ const EXAMPLE_ENV_FILES = new Set([
7
+ '.env.example',
8
+ '.env.sample',
9
+ '.env.template',
10
+ '.env.defaults',
11
+ ]);
12
+ export function canPromptForEnvFiles() {
13
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);
14
+ }
15
+ async function confirmEnvFileCopy(files, defaultMessage, options) {
16
+ const isInteractive = options.interactive ?? canPromptForEnvFiles();
17
+ if (!isInteractive) {
18
+ log.dim(`Skipped local env file${files.length === 1 ? '' : 's'} in non-interactive mode.`);
19
+ return false;
20
+ }
21
+ const { shouldCopyEnvFiles } = await inquirer.prompt([
22
+ {
23
+ type: 'confirm',
24
+ name: 'shouldCopyEnvFiles',
25
+ message: options.promptMessage || defaultMessage,
26
+ default: false,
27
+ },
28
+ ]);
29
+ if (!shouldCopyEnvFiles) {
30
+ log.dim('Skipped local env files.');
31
+ }
32
+ return shouldCopyEnvFiles;
33
+ }
34
+ export async function findLocalEnvFiles(repoRoot) {
35
+ const entries = await fs.readdir(repoRoot, { withFileTypes: true });
36
+ return entries
37
+ .filter(entry => entry.isFile())
38
+ .map(entry => entry.name)
39
+ .filter(name => name === '.env' || name.startsWith('.env.'))
40
+ .filter(name => !EXAMPLE_ENV_FILES.has(name))
41
+ .sort((a, b) => a.localeCompare(b));
42
+ }
43
+ export async function copyEnvFiles(repoRoot, wtPath, envFiles) {
44
+ if (envFiles.length === 0)
45
+ return;
46
+ for (const envFile of envFiles) {
47
+ await fs.copy(path.join(repoRoot, envFile), path.join(wtPath, envFile));
48
+ }
49
+ log.info(`Copied ${envFiles.map(file => chalk.cyan(file)).join(', ')} to the worktree.`);
50
+ }
51
+ export async function promptAndCopyEnvFiles(repoRoot, wtPath, envFiles, options = {}) {
52
+ const files = envFiles || await findLocalEnvFiles(repoRoot);
53
+ if (files.length === 0)
54
+ return;
55
+ const shouldCopyEnvFiles = await confirmEnvFileCopy(files, `Copy local env file${files.length === 1 ? '' : 's'} to this worktree? (${files.join(', ')})`, options);
56
+ if (!shouldCopyEnvFiles) {
57
+ return;
58
+ }
59
+ await copyEnvFiles(repoRoot, wtPath, files);
60
+ }
61
+ export async function promptAndCopyEnvFilesToWorktrees(repoRoot, wtPaths, envFiles, options = {}) {
62
+ const files = envFiles || await findLocalEnvFiles(repoRoot);
63
+ if (files.length === 0 || wtPaths.length === 0)
64
+ return;
65
+ const shouldCopyEnvFiles = await confirmEnvFileCopy(files, `Copy local env file${files.length === 1 ? '' : 's'} to these worktrees? (${files.join(', ')})`, options);
66
+ if (!shouldCopyEnvFiles) {
67
+ return;
68
+ }
69
+ for (const wtPath of wtPaths) {
70
+ await copyEnvFiles(repoRoot, wtPath, files);
71
+ }
72
+ }
package/dist/lib/git.js CHANGED
@@ -54,6 +54,9 @@ export async function removeWorktree(wtPath) {
54
54
  }
55
55
  export async function listWorktrees() {
56
56
  const { stdout } = await execa('git', ['worktree', 'list', '--porcelain']);
57
+ return parseWorktreeList(stdout);
58
+ }
59
+ export function parseWorktreeList(stdout) {
57
60
  const worktrees = [];
58
61
  let currentWt = {};
59
62
  for (const line of stdout.split('\n')) {
@@ -71,6 +74,8 @@ export async function listWorktrees() {
71
74
  currentWt.HEAD = value;
72
75
  if (key === 'branch')
73
76
  currentWt.branch = value.replace('refs/heads/', '');
77
+ if (key === 'prunable')
78
+ currentWt.prunable = value;
74
79
  }
75
80
  // Push the last one if active
76
81
  if (currentWt.path)
@@ -0,0 +1,46 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { getRepoRoot } from './git.js';
4
+ import { getValidRegisteredRepos } from './registry.js';
5
+ import { log } from './ui.js';
6
+ import { formatWorktreeDisplayPath } from './worktree.js';
7
+ export async function ensureRepoContext() {
8
+ try {
9
+ return await getRepoRoot();
10
+ }
11
+ catch {
12
+ const validRepos = await getValidRegisteredRepos();
13
+ const repoEntries = Object.entries(validRepos);
14
+ if (repoEntries.length === 0) {
15
+ log.error('Not inside a git repository and no registered realms found.');
16
+ log.dim('Run `yggtree` inside an existing git project first to register it.');
17
+ process.exit(1);
18
+ }
19
+ if (repoEntries.length === 1 && (process.env.CI === 'true' || !process.stdin.isTTY)) {
20
+ const [, selectedRepoPath] = repoEntries[0];
21
+ process.chdir(selectedRepoPath);
22
+ return await getRepoRoot();
23
+ }
24
+ if (!process.stdin.isTTY) {
25
+ log.error('Not inside a git repository and multiple registered realms are available.');
26
+ log.dim('Run yggtree from the repo you want to use, or run interactive mode from a real terminal to choose one.');
27
+ log.dim(`Available realms: ${repoEntries.map(([name]) => name).join(', ')}`);
28
+ process.exit(1);
29
+ }
30
+ console.log(chalk.bold('\n Not inside a realm. Pick a known one:'));
31
+ const { selectedRepoPath } = await inquirer.prompt([
32
+ {
33
+ type: 'list',
34
+ name: 'selectedRepoPath',
35
+ message: 'Select a realm:',
36
+ choices: repoEntries.map(([name, repoPath]) => ({
37
+ name: `${chalk.bold.yellow(name)} ${chalk.dim(formatWorktreeDisplayPath(repoPath))}`,
38
+ value: repoPath,
39
+ })),
40
+ pageSize: 10,
41
+ },
42
+ ]);
43
+ process.chdir(selectedRepoPath);
44
+ return await getRepoRoot();
45
+ }
46
+ }
@@ -0,0 +1,55 @@
1
+ import chalk from 'chalk';
2
+ import { execa } from 'execa';
3
+ import { getVersion } from './version.js';
4
+ import { log } from './ui.js';
5
+ function parseVersion(version) {
6
+ const [core] = version.trim().replace(/^v/, '').split('-');
7
+ return core.split('.').map(part => {
8
+ const parsed = Number.parseInt(part, 10);
9
+ return Number.isNaN(parsed) ? 0 : parsed;
10
+ });
11
+ }
12
+ export function isNewerVersion(latestVersion, currentVersion) {
13
+ const latestParts = parseVersion(latestVersion);
14
+ const currentParts = parseVersion(currentVersion);
15
+ const maxLength = Math.max(latestParts.length, currentParts.length);
16
+ for (let index = 0; index < maxLength; index += 1) {
17
+ const latestPart = latestParts[index] ?? 0;
18
+ const currentPart = currentParts[index] ?? 0;
19
+ if (latestPart > currentPart)
20
+ return true;
21
+ if (latestPart < currentPart)
22
+ return false;
23
+ }
24
+ return false;
25
+ }
26
+ export async function fetchLatestPackageVersion() {
27
+ const { stdout } = await execa('npm', ['view', 'yggtree', 'version', '--silent'], {
28
+ timeout: 3_000,
29
+ });
30
+ return stdout.trim();
31
+ }
32
+ export async function checkForUpdate(options = {}) {
33
+ const currentVersion = getVersion();
34
+ const fetchLatestVersion = options.fetchLatestVersion ?? fetchLatestPackageVersion;
35
+ try {
36
+ const latestVersion = await fetchLatestVersion();
37
+ if (!latestVersion)
38
+ return null;
39
+ return {
40
+ currentVersion,
41
+ latestVersion,
42
+ updateAvailable: isNewerVersion(latestVersion, currentVersion),
43
+ };
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ export async function notifyIfUpdateAvailable() {
50
+ const result = await checkForUpdate();
51
+ if (!result?.updateAvailable)
52
+ return;
53
+ log.warning(`A newer yggtree is available: ${chalk.dim(`v${result.currentVersion}`)} -> ${chalk.green(`v${result.latestVersion}`)}`);
54
+ log.dim('Update with: npm install -g yggtree');
55
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "yggtree",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
+ "packageManager": "pnpm@11.0.9",
4
5
  "description": "Interactive CLI for managing git worktrees and configs",
5
6
  "main": "dist/index.js",
6
7
  "type": "module",
7
8
  "repository": {
8
- "url": "github.com/leoreisdias/yggdrasil-worktree",
9
+ "url": "git+https://github.com/logbookfordevs/yggdrasil-worktree.git",
9
10
  "type": "git"
10
11
  },
11
12
  "homepage": "yggtree.logbookfordevs.com",
@@ -21,7 +22,9 @@
21
22
  "build": "tsc",
22
23
  "start": "node dist/index.js",
23
24
  "dev": "tsc --watch",
24
- "test": "echo \"Error: no test specified\" && exit 1"
25
+ "site:dev": "pnpm --dir apps/site dev",
26
+ "test": "pnpm build && pnpm test:typecheck && vitest run",
27
+ "test:typecheck": "tsc --noEmit -p tsconfig.test.json"
25
28
  },
26
29
  "keywords": [
27
30
  "cli",
@@ -49,6 +52,7 @@
49
52
  "@types/gradient-string": "^1.1.6",
50
53
  "@types/inquirer": "^9.0.7",
51
54
  "@types/node": "^20.11.19",
52
- "typescript": "^5.3.3"
55
+ "typescript": "^5.3.3",
56
+ "vitest": "^4.1.7"
53
57
  }
54
58
  }
@@ -1,96 +0,0 @@
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
- }