yggtree 1.4.2 → 1.4.3
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 +125 -109
- package/dist/commands/wt/create-branch.js +3 -1
- package/dist/commands/wt/create-multi.js +11 -2
- package/dist/commands/wt/create-sandbox.js +3 -1
- package/dist/commands/wt/create.js +86 -44
- package/dist/commands/wt/enter.js +3 -27
- package/dist/commands/wt/open.js +207 -53
- package/dist/index.js +132 -126
- package/dist/lib/env-files.js +72 -0
- package/dist/lib/git.js +5 -0
- package/dist/lib/repo-context.js +46 -0
- package/dist/lib/update-check.js +55 -0
- package/package.json +8 -4
- package/dist/commands/wt/close.js +0 -96
package/dist/index.js
CHANGED
|
@@ -11,24 +11,148 @@ 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';
|
|
20
18
|
import { getVersion } from './lib/version.js';
|
|
19
|
+
import { notifyIfUpdateAvailable } from './lib/update-check.js';
|
|
21
20
|
import { findSandboxRoot } from './lib/sandbox.js';
|
|
22
21
|
import { bifrostCommand } from './commands/bifrost.js';
|
|
23
22
|
import { thorCommand } from './commands/thor.js';
|
|
24
23
|
const program = new Command();
|
|
24
|
+
const argv = process.argv.map((arg) => arg === '-v' || arg === '—version' ? '--version' : arg);
|
|
25
|
+
function registerWorktreeCommands(parent) {
|
|
26
|
+
parent.command('list')
|
|
27
|
+
.description('List all repo-linked worktrees')
|
|
28
|
+
.option('--open', 'Open a worktree in an IDE/agent tool instead of listing')
|
|
29
|
+
.action(async (options) => {
|
|
30
|
+
if (options.open) {
|
|
31
|
+
await openCommand();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await listCommand();
|
|
35
|
+
});
|
|
36
|
+
parent.command('create [branch]')
|
|
37
|
+
.description('Create a new worktree (Smart branch detection)')
|
|
38
|
+
.option('-b, --branch <name>', 'Branch name (e.g. feat/new-ui)')
|
|
39
|
+
.option('--base <ref>', 'Base ref (e.g. main)')
|
|
40
|
+
.option('--source <type>', 'Base source (local or remote)')
|
|
41
|
+
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
42
|
+
.option('--open', 'Open an editor after creation')
|
|
43
|
+
.option('--no-open', 'Skip opening an editor after creation')
|
|
44
|
+
.addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
|
|
45
|
+
.addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
|
|
46
|
+
.option('--exec <command>', 'Command to execute after creation')
|
|
47
|
+
.action(async (branch, options) => {
|
|
48
|
+
await createCommandNew({
|
|
49
|
+
...options,
|
|
50
|
+
branch: branch || options.branch
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
parent.command('create-multi')
|
|
54
|
+
.description('Create multiple worktrees (Smart branch detection)')
|
|
55
|
+
.option('--base <ref>', 'Base ref (e.g. main)')
|
|
56
|
+
.option('--source <type>', 'Base source (local or remote)')
|
|
57
|
+
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
58
|
+
.action(async (options) => {
|
|
59
|
+
await createCommandMulti(options);
|
|
60
|
+
});
|
|
61
|
+
const registerWorktreeCheckout = (commandName, description) => {
|
|
62
|
+
parent.command(`${commandName} [name] [ref]`)
|
|
63
|
+
.description(description)
|
|
64
|
+
.option('-n, --name <slug>', 'Worktree name (slug)')
|
|
65
|
+
.option('-r, --ref <ref>', 'Existing branch or ref')
|
|
66
|
+
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
67
|
+
.option('--open', 'Open editors or run a startup command before entering')
|
|
68
|
+
.option('--no-open', 'Skip opening editors or startup commands before entering')
|
|
69
|
+
.option('--tool <command>', 'Editor or app command to open after checkout (skips open prompt)')
|
|
70
|
+
.addOption(new Option('--enter', 'Enter the worktree sub-shell after checkout/opening').hideHelp())
|
|
71
|
+
.option('--no-enter', 'Do not enter the worktree sub-shell after checkout/opening')
|
|
72
|
+
.option('--exec <command>', 'Command to execute after creation')
|
|
73
|
+
.action(async (name, ref, options) => {
|
|
74
|
+
await createCommand({
|
|
75
|
+
...options,
|
|
76
|
+
name: name || options.name,
|
|
77
|
+
ref: ref || options.ref
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
registerWorktreeCheckout('worktree-checkout', 'Create a checkout-style worktree from an existing branch');
|
|
82
|
+
registerWorktreeCheckout('wc', 'Alias for worktree-checkout');
|
|
83
|
+
parent.command('delete')
|
|
84
|
+
.description('Delete managed worktrees')
|
|
85
|
+
.option('-a, --all', 'Include repo-linked worktrees outside ~/.yggtree (except main/current)')
|
|
86
|
+
.action(async (options) => {
|
|
87
|
+
await deleteCommand(options);
|
|
88
|
+
});
|
|
89
|
+
parent.command('open [worktree]')
|
|
90
|
+
.description('Open a worktree in an editor or supported app')
|
|
91
|
+
.option('--tool <command>', 'Editor or app command to use (e.g. cursor, code, codex-app)')
|
|
92
|
+
.option('--enter', 'Enter the worktree sub-shell after opening')
|
|
93
|
+
.addOption(new Option('--no-enter', 'Do not enter the worktree sub-shell after opening').hideHelp())
|
|
94
|
+
.action(async (worktree, options) => {
|
|
95
|
+
await openCommand(worktree, options);
|
|
96
|
+
});
|
|
97
|
+
parent.command('bootstrap')
|
|
98
|
+
.description('Bootstrap dependencies in a worktree')
|
|
99
|
+
.action(bootstrapCommand);
|
|
100
|
+
parent.command('prune')
|
|
101
|
+
.description('Prune stale worktree information')
|
|
102
|
+
.action(pruneCommand);
|
|
103
|
+
parent.command('exec')
|
|
104
|
+
.description('Execute a command in a worktree')
|
|
105
|
+
.argument('[worktree]', 'Worktree name or path')
|
|
106
|
+
.argument('[command...]', 'Command and arguments to execute')
|
|
107
|
+
.action(async (worktree, command) => {
|
|
108
|
+
await execCommand(worktree, command);
|
|
109
|
+
});
|
|
110
|
+
parent.command('path [worktree]')
|
|
111
|
+
.description('Show the cd command for a specific worktree')
|
|
112
|
+
.action(async (worktree) => {
|
|
113
|
+
await pathCommand(worktree);
|
|
114
|
+
});
|
|
115
|
+
parent.command('create-sandbox')
|
|
116
|
+
.description('Create a sandbox worktree from current branch')
|
|
117
|
+
.option('-n, --name <name>', 'Optional sandbox name (auto-generated when omitted)')
|
|
118
|
+
.option('--carry', 'Carry uncommitted changes to sandbox')
|
|
119
|
+
.option('--no-carry', 'Do not carry uncommitted changes')
|
|
120
|
+
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
121
|
+
.option('--open', 'Open an editor after creation')
|
|
122
|
+
.option('--no-open', 'Skip opening an editor after creation')
|
|
123
|
+
.addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
|
|
124
|
+
.addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
|
|
125
|
+
.option('--exec <command>', 'Command to execute after creation')
|
|
126
|
+
.action(async (options) => {
|
|
127
|
+
await createSandboxCommand(options);
|
|
128
|
+
});
|
|
129
|
+
parent.command('apply')
|
|
130
|
+
.description('Apply sandbox changes to origin directory')
|
|
131
|
+
.action(applyCommand);
|
|
132
|
+
parent.command('unapply')
|
|
133
|
+
.description('Undo applied sandbox changes in origin')
|
|
134
|
+
.action(unapplyCommand);
|
|
135
|
+
}
|
|
136
|
+
function rejectUnknownTopLevelCommand(args) {
|
|
137
|
+
const commandArg = args.slice(2).find(arg => !arg.startsWith('-'));
|
|
138
|
+
if (!commandArg)
|
|
139
|
+
return;
|
|
140
|
+
if (commandArg === 'help')
|
|
141
|
+
return;
|
|
142
|
+
const knownCommands = new Set(program.commands.flatMap(command => [command.name(), ...command.aliases()]));
|
|
143
|
+
if (!knownCommands.has(commandArg)) {
|
|
144
|
+
program.error(`error: unknown command '${commandArg}'`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
25
147
|
program
|
|
26
148
|
.name('yggtree')
|
|
27
149
|
.description('Interactive CLI for managing git worktrees and configs')
|
|
28
150
|
.version(getVersion())
|
|
151
|
+
.allowExcessArguments(false)
|
|
29
152
|
.action(async () => {
|
|
30
153
|
// Interactive Menu if no command is provided
|
|
31
154
|
await welcome();
|
|
155
|
+
await notifyIfUpdateAvailable();
|
|
32
156
|
const isInSandbox = Boolean(await findSandboxRoot(process.cwd()));
|
|
33
157
|
const realmChoices = [
|
|
34
158
|
{ name: `🌿 Grow New Realm ${chalk.dim('(create worktree)')}`, value: 'create-smart' },
|
|
@@ -36,14 +160,12 @@ program
|
|
|
36
160
|
{ name: `🌳 Grow Many Realms ${chalk.dim('(create multiple worktrees)')}`, value: 'create-multi' },
|
|
37
161
|
{ name: `🧪 Forge Sandbox Realm ${chalk.dim('(create sandbox worktree)')}`, value: 'create-sandbox' },
|
|
38
162
|
{ name: `🗺️ Survey Realms ${chalk.dim('(list worktrees)')}`, value: 'list' },
|
|
39
|
-
{ name: `🧭 Open Realm in
|
|
163
|
+
{ name: `🧭 Open Realm in Editor ${chalk.dim('(open worktree in editor)')}`, value: 'open' },
|
|
40
164
|
{ name: `🪓 Fell a Realm ${chalk.dim('(delete worktree)')}`, value: 'delete' },
|
|
41
165
|
{ name: `🚀 Bless Realm Setup ${chalk.dim('(bootstrap worktree)')}`, value: 'bootstrap' },
|
|
42
166
|
{ name: `🧹 Prune Withered Realms ${chalk.dim('(prune stale worktrees)')}`, value: 'prune' },
|
|
43
167
|
{ name: `🐚 Cast a Command ${chalk.dim('(exec command in worktree)')}`, value: 'exec' },
|
|
44
|
-
{ name: `🚪 Enter Realm Shell ${chalk.dim('(enter worktree)')}`, value: 'enter' },
|
|
45
168
|
{ name: `📍 Reveal Realm Path ${chalk.dim('(show worktree path)')}`, value: 'path' },
|
|
46
|
-
{ name: `🔒 Close Realm ${chalk.dim('(exit & optionally delete worktree)')}`, value: 'close' },
|
|
47
169
|
];
|
|
48
170
|
const sandboxChoices = [
|
|
49
171
|
{ name: `✅ Graft Sandbox Changes ${chalk.dim('(apply sandbox changes)')}`, value: 'apply' },
|
|
@@ -106,9 +228,6 @@ program
|
|
|
106
228
|
case 'exec':
|
|
107
229
|
await execCommand();
|
|
108
230
|
break;
|
|
109
|
-
case 'enter':
|
|
110
|
-
await enterCommand();
|
|
111
|
-
break;
|
|
112
231
|
case 'path':
|
|
113
232
|
await pathCommand();
|
|
114
233
|
break;
|
|
@@ -127,9 +246,6 @@ program
|
|
|
127
246
|
case 'thor':
|
|
128
247
|
await thorCommand();
|
|
129
248
|
break;
|
|
130
|
-
case 'close':
|
|
131
|
-
await closeCommand();
|
|
132
|
-
break;
|
|
133
249
|
case 'exit':
|
|
134
250
|
log.info('Bye! 👋');
|
|
135
251
|
process.exit(0);
|
|
@@ -137,124 +253,14 @@ program
|
|
|
137
253
|
});
|
|
138
254
|
// --- Worktree Commands ---
|
|
139
255
|
const wt = program.command('wt').description('Manage git worktrees');
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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);
|
|
256
|
+
registerWorktreeCommands(program);
|
|
257
|
+
registerWorktreeCommands(wt);
|
|
258
|
+
program.addHelpCommand(true);
|
|
254
259
|
program.command('bifrost')
|
|
255
260
|
.description('Summon the Bifrost (Easter Egg)')
|
|
256
261
|
.action(bifrostCommand);
|
|
257
262
|
program.command('thor')
|
|
258
263
|
.description('Consult the God of Thunder (Easter Egg)')
|
|
259
264
|
.action(thorCommand);
|
|
260
|
-
|
|
265
|
+
rejectUnknownTopLevelCommand(argv);
|
|
266
|
+
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.
|
|
3
|
+
"version": "1.4.3",
|
|
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/
|
|
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
|
-
"
|
|
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
|
-
}
|