yggtree 1.4.3 → 1.4.5

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
@@ -1,7 +1,8 @@
1
1
  import { Command, Option } from 'commander';
2
2
  import inquirer from 'inquirer';
3
3
  import chalk from 'chalk';
4
- import { welcome, log } from './lib/ui.js';
4
+ import { formatMainMenuChoice, renderMainMenuIntro, welcome, log, } from './lib/ui.js';
5
+ import { applyHelp, checkoutHelp, createHelp, createMultiHelp, createSandboxHelp, handoffHelp, intentRouterHelp, openHelp, unapplyHelp, } from './lib/help.js';
5
6
  import { listCommand } from './commands/wt/list.js';
6
7
  import { createCommand } from './commands/wt/create.js';
7
8
  import { createCommandNew } from './commands/wt/create-branch.js';
@@ -15,13 +16,24 @@ import { pathCommand } from './commands/wt/path.js';
15
16
  import { openCommand } from './commands/wt/open.js';
16
17
  import { applyCommand } from './commands/wt/apply.js';
17
18
  import { unapplyCommand } from './commands/wt/unapply.js';
19
+ import { handoffCommand } from './commands/wt/handoff.js';
20
+ import { configGetCommand, configResetCommand, configSetWorktreeLayoutCommand, configSetWorktreesRootCommand, configUseCommand, } from './commands/config.js';
18
21
  import { getVersion } from './lib/version.js';
19
- import { notifyIfUpdateAvailable } from './lib/update-check.js';
22
+ import { checkForUpdate } from './lib/update-check.js';
20
23
  import { findSandboxRoot } from './lib/sandbox.js';
21
24
  import { bifrostCommand } from './commands/bifrost.js';
22
25
  import { thorCommand } from './commands/thor.js';
23
26
  const program = new Command();
24
27
  const argv = process.argv.map((arg) => arg === '-v' || arg === '—version' ? '--version' : arg);
28
+ function mainMenuChoice(value, glyph, label, detail, tone) {
29
+ return {
30
+ name: formatMainMenuChoice({ glyph, label, detail, tone }),
31
+ value,
32
+ };
33
+ }
34
+ function mainMenuSeparator(label) {
35
+ return new inquirer.Separator(chalk.dim(` · ${label}`));
36
+ }
25
37
  function registerWorktreeCommands(parent) {
26
38
  parent.command('list')
27
39
  .description('List all repo-linked worktrees')
@@ -34,16 +46,17 @@ function registerWorktreeCommands(parent) {
34
46
  await listCommand();
35
47
  });
36
48
  parent.command('create [branch]')
37
- .description('Create a new worktree (Smart branch detection)')
49
+ .description('Create an official branch-backed task worktree')
38
50
  .option('-b, --branch <name>', 'Branch name (e.g. feat/new-ui)')
39
51
  .option('--base <ref>', 'Base ref (e.g. main)')
40
52
  .option('--source <type>', 'Base source (local or remote)')
41
53
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
42
54
  .option('--open', 'Open an editor after creation')
43
55
  .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())
56
+ .option('--enter', 'Enter the worktree sub-shell after creation')
57
+ .option('--no-enter', 'Do not enter the worktree sub-shell after creation')
46
58
  .option('--exec <command>', 'Command to execute after creation')
59
+ .addHelpText('after', createHelp)
47
60
  .action(async (branch, options) => {
48
61
  await createCommandNew({
49
62
  ...options,
@@ -51,10 +64,11 @@ function registerWorktreeCommands(parent) {
51
64
  });
52
65
  });
53
66
  parent.command('create-multi')
54
- .description('Create multiple worktrees (Smart branch detection)')
67
+ .description('Bulk-create official branch-backed worktrees')
55
68
  .option('--base <ref>', 'Base ref (e.g. main)')
56
69
  .option('--source <type>', 'Base source (local or remote)')
57
70
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
71
+ .addHelpText('after', createMultiHelp)
58
72
  .action(async (options) => {
59
73
  await createCommandMulti(options);
60
74
  });
@@ -66,10 +80,11 @@ function registerWorktreeCommands(parent) {
66
80
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
67
81
  .option('--open', 'Open editors or run a startup command before entering')
68
82
  .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)')
83
+ .option('--tool <command>', 'Editor, app, or terminal command to open after checkout (skips open prompt)')
70
84
  .addOption(new Option('--enter', 'Enter the worktree sub-shell after checkout/opening').hideHelp())
71
85
  .option('--no-enter', 'Do not enter the worktree sub-shell after checkout/opening')
72
86
  .option('--exec <command>', 'Command to execute after creation')
87
+ .addHelpText('after', checkoutHelp)
73
88
  .action(async (name, ref, options) => {
74
89
  await createCommand({
75
90
  ...options,
@@ -82,15 +97,16 @@ function registerWorktreeCommands(parent) {
82
97
  registerWorktreeCheckout('wc', 'Alias for worktree-checkout');
83
98
  parent.command('delete')
84
99
  .description('Delete managed worktrees')
85
- .option('-a, --all', 'Include repo-linked worktrees outside ~/.yggtree (except main/current)')
100
+ .option('-a, --all', 'Include repo-linked worktrees outside the managed root (except main/current)')
86
101
  .action(async (options) => {
87
102
  await deleteCommand(options);
88
103
  });
89
104
  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)')
105
+ .description('Open a worktree in an editor, supported app, or terminal target')
106
+ .option('--tool <command>', 'Editor, app, or terminal command to use (e.g. cursor, code, codex-app, tmux)')
92
107
  .option('--enter', 'Enter the worktree sub-shell after opening')
93
108
  .addOption(new Option('--no-enter', 'Do not enter the worktree sub-shell after opening').hideHelp())
109
+ .addHelpText('after', openHelp)
94
110
  .action(async (worktree, options) => {
95
111
  await openCommand(worktree, options);
96
112
  });
@@ -113,24 +129,40 @@ function registerWorktreeCommands(parent) {
113
129
  await pathCommand(worktree);
114
130
  });
115
131
  parent.command('create-sandbox')
116
- .description('Create a sandbox worktree from current branch')
132
+ .description('Create a local-only disposable experiment sandbox')
117
133
  .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')
134
+ .option('--carry', 'Copy uncommitted changes to sandbox')
135
+ .option('--no-carry', 'Do not copy uncommitted changes')
120
136
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
121
137
  .option('--open', 'Open an editor after creation')
122
138
  .option('--no-open', 'Skip opening an editor after creation')
123
139
  .addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
124
140
  .addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
125
141
  .option('--exec <command>', 'Command to execute after creation')
142
+ .addHelpText('after', createSandboxHelp)
126
143
  .action(async (options) => {
127
144
  await createSandboxCommand(options);
128
145
  });
146
+ parent.command('handoff')
147
+ .description('Carry dirty current work into a named sandbox')
148
+ .option('-n, --name <name>', 'Optional handoff name (prompted when omitted)')
149
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
150
+ .option('--open', 'Open an editor after creation')
151
+ .option('--no-open', 'Skip opening an editor after creation')
152
+ .addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
153
+ .addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
154
+ .option('--exec <command>', 'Command to execute after creation')
155
+ .addHelpText('after', handoffHelp)
156
+ .action(async (options) => {
157
+ await handoffCommand(options);
158
+ });
129
159
  parent.command('apply')
130
- .description('Apply sandbox changes to origin directory')
160
+ .description('Copy sandbox file changes to the origin checkout')
161
+ .addHelpText('after', applyHelp)
131
162
  .action(applyCommand);
132
163
  parent.command('unapply')
133
- .description('Undo applied sandbox changes in origin')
164
+ .description('Restore origin files from sandbox apply backups')
165
+ .addHelpText('after', unapplyHelp)
134
166
  .action(unapplyCommand);
135
167
  }
136
168
  function rejectUnknownTopLevelCommand(args) {
@@ -149,54 +181,69 @@ program
149
181
  .description('Interactive CLI for managing git worktrees and configs')
150
182
  .version(getVersion())
151
183
  .allowExcessArguments(false)
184
+ .addHelpText('after', intentRouterHelp)
152
185
  .action(async () => {
153
- // Interactive Menu if no command is provided
154
- await welcome();
155
- await notifyIfUpdateAvailable();
186
+ const update = await checkForUpdate();
187
+ await welcome({ update });
156
188
  const isInSandbox = Boolean(await findSandboxRoot(process.cwd()));
157
- const realmChoices = [
158
- { name: `🌿 Grow New Realm ${chalk.dim('(create worktree)')}`, value: 'create-smart' },
159
- { name: `🔀 Traverse to Another Realm ${chalk.dim('(checkout existing branch in new worktree)')}`, value: 'worktree-checkout' },
160
- { name: `🌳 Grow Many Realms ${chalk.dim('(create multiple worktrees)')}`, value: 'create-multi' },
161
- { name: `🧪 Forge Sandbox Realm ${chalk.dim('(create sandbox worktree)')}`, value: 'create-sandbox' },
162
- { name: `🗺️ Survey Realms ${chalk.dim('(list worktrees)')}`, value: 'list' },
163
- { name: `🧭 Open Realm in Editor ${chalk.dim('(open worktree in editor)')}`, value: 'open' },
164
- { name: `🪓 Fell a Realm ${chalk.dim('(delete worktree)')}`, value: 'delete' },
165
- { name: `🚀 Bless Realm Setup ${chalk.dim('(bootstrap worktree)')}`, value: 'bootstrap' },
166
- { name: `🧹 Prune Withered Realms ${chalk.dim('(prune stale worktrees)')}`, value: 'prune' },
167
- { name: `🐚 Cast a Command ${chalk.dim('(exec command in worktree)')}`, value: 'exec' },
168
- { name: `📍 Reveal Realm Path ${chalk.dim('(show worktree path)')}`, value: 'path' },
189
+ const dailyChoices = [
190
+ mainMenuChoice('create-smart', '◆', 'Grow new realm', 'create a branch-backed worktree', 'growth'),
191
+ mainMenuChoice('worktree-checkout', '⇄', 'Check out branch', 'open an existing ref in a new realm', 'travel'),
192
+ mainMenuChoice('handoff', '✦', 'Hand off current work', 'carry dirty work into a named sandbox', 'sandbox'),
193
+ mainMenuChoice('open', '◇', 'Open realm', 'jump into an existing worktree', 'travel'),
194
+ mainMenuChoice('list', '○', 'Survey realms', 'scan active worktrees and PR state', 'tending'),
195
+ ];
196
+ const growthChoices = [
197
+ mainMenuChoice('create-multi', '✧', 'Grow many realms', 'create multiple branch worktrees', 'growth'),
198
+ mainMenuChoice('create-sandbox', '△', 'Forge sandbox', 'create a local experiment realm', 'sandbox'),
199
+ ];
200
+ const tendingChoices = [
201
+ mainMenuChoice('bootstrap', '↳', 'Bootstrap realm', 'install dependencies and submodules', 'tending'),
202
+ mainMenuChoice('exec', '⌁', 'Cast command', 'run a command inside a worktree', 'tending'),
203
+ mainMenuChoice('path', '⌖', 'Reveal path', 'print a worktree cd command', 'tending'),
204
+ mainMenuChoice('delete', '×', 'Fell realm', 'delete managed worktrees', 'danger'),
205
+ mainMenuChoice('prune', '⌫', 'Prune stale realms', 'remove stale worktree metadata', 'danger'),
169
206
  ];
170
207
  const sandboxChoices = [
171
- { name: `✅ Graft Sandbox Changes ${chalk.dim('(apply sandbox changes)')}`, value: 'apply' },
172
- { name: `↩️ Undo Sandbox Graft ${chalk.dim('(unapply sandbox changes)')}`, value: 'unapply' },
208
+ mainMenuChoice('apply', '◆', 'Graft sandbox changes', 'apply sandbox changes to origin', 'sandbox'),
209
+ mainMenuChoice('unapply', '↶', 'Undo sandbox graft', 'restore origin from sandbox backups', 'sandbox'),
210
+ ];
211
+ const loreChoices = [
212
+ mainMenuChoice('bifrost', '✺', 'Summon the Bifrost', 'open the long way around', 'lore'),
213
+ mainMenuChoice('thor', 'ϟ', 'Consult Thor', 'ask the thunder route', 'lore'),
214
+ mainMenuChoice('exit', '·', 'Leave Yggdrasil', 'exit without changing anything', 'exit'),
173
215
  ];
174
216
  const choices = isInSandbox
175
217
  ? [
176
218
  ...sandboxChoices,
177
- new inquirer.Separator(),
178
- ...realmChoices,
179
- new inquirer.Separator(),
180
- { name: `🌈 Summon the Bifrost ${chalk.dim('(easter egg)')}`, value: 'bifrost' },
181
- { name: `⚡ Consult Thor ${chalk.dim('(easter egg)')}`, value: 'thor' },
182
- { name: `🚪 Leave Yggdrasil ${chalk.dim('(exit)')}`, value: 'exit' },
219
+ mainMenuSeparator('Daily routes'),
220
+ ...dailyChoices,
221
+ mainMenuSeparator('Growth and experiments'),
222
+ ...growthChoices,
223
+ mainMenuSeparator('Tend realms'),
224
+ ...tendingChoices,
225
+ mainMenuSeparator('Lore'),
226
+ ...loreChoices,
183
227
  ]
184
228
  : [
185
- ...realmChoices,
186
- new inquirer.Separator(),
229
+ ...dailyChoices,
230
+ mainMenuSeparator('Growth and experiments'),
231
+ ...growthChoices,
232
+ mainMenuSeparator('Tend realms'),
233
+ ...tendingChoices,
234
+ mainMenuSeparator('Sandbox grafts'),
187
235
  ...sandboxChoices,
188
- new inquirer.Separator(),
189
- { name: `🌈 Summon the Bifrost ${chalk.dim('(easter egg)')}`, value: 'bifrost' },
190
- { name: `⚡ Consult Thor ${chalk.dim('(easter egg)')}`, value: 'thor' },
191
- { name: `🚪 Leave Yggdrasil ${chalk.dim('(exit)')}`, value: 'exit' },
236
+ mainMenuSeparator('Lore'),
237
+ ...loreChoices,
192
238
  ];
239
+ console.log(renderMainMenuIntro({ isInSandbox }));
193
240
  const { action } = await inquirer.prompt([
194
241
  {
195
242
  type: 'list',
196
243
  name: 'action',
197
- message: 'What would you like to do?',
244
+ message: 'Which route should Yggtree follow?',
198
245
  loop: false,
199
- pageSize: 12,
246
+ pageSize: 14,
200
247
  choices,
201
248
  },
202
249
  ]);
@@ -240,6 +287,9 @@ program
240
287
  case 'create-sandbox':
241
288
  await createSandboxCommand({ bootstrap: true });
242
289
  break;
290
+ case 'handoff':
291
+ await handoffCommand({ bootstrap: true });
292
+ break;
243
293
  case 'bifrost':
244
294
  await bifrostCommand();
245
295
  break;
@@ -253,6 +303,7 @@ program
253
303
  });
254
304
  // --- Worktree Commands ---
255
305
  const wt = program.command('wt').description('Manage git worktrees');
306
+ wt.addHelpText('after', intentRouterHelp);
256
307
  registerWorktreeCommands(program);
257
308
  registerWorktreeCommands(wt);
258
309
  program.addHelpCommand(true);
@@ -262,5 +313,21 @@ program.command('bifrost')
262
313
  program.command('thor')
263
314
  .description('Consult the God of Thunder (Easter Egg)')
264
315
  .action(thorCommand);
316
+ const config = program.command('config').description('Inspect or update global Yggtree settings');
317
+ config.command('get')
318
+ .description('Show resolved global settings')
319
+ .action(configGetCommand);
320
+ config.command('use <preset>')
321
+ .description('Use a global worktree path preset (default, yggtree, codex)')
322
+ .action(configUseCommand);
323
+ config.command('set-worktrees-root <path>')
324
+ .description('Set the global managed worktrees root')
325
+ .action(configSetWorktreesRootCommand);
326
+ config.command('set-worktree-layout <layout>')
327
+ .description('Set the managed worktree path layout (yggtree or codex)')
328
+ .action(configSetWorktreeLayoutCommand);
329
+ config.command('reset')
330
+ .description('Reset global settings to defaults')
331
+ .action(configResetCommand);
265
332
  rejectUnknownTopLevelCommand(argv);
266
333
  program.parse(argv);
@@ -0,0 +1,89 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ import { YGG_ROOT } from './paths.js';
5
+ const CONFIG_PATH = path.join(YGG_ROOT, 'config.json');
6
+ const DEFAULT_WORKTREE_LAYOUT = 'yggtree';
7
+ export function expandHome(value) {
8
+ if (value === '~')
9
+ return os.homedir();
10
+ if (value.startsWith('~/'))
11
+ return path.join(os.homedir(), value.slice(2));
12
+ return value;
13
+ }
14
+ export function formatHome(value) {
15
+ const home = os.homedir();
16
+ if (value === home)
17
+ return '~';
18
+ if (value.startsWith(`${home}${path.sep}`))
19
+ return `~/${path.relative(home, value)}`;
20
+ return value;
21
+ }
22
+ function normalizeRoot(value) {
23
+ return path.resolve(expandHome(value || YGG_ROOT));
24
+ }
25
+ export function normalizeWorktreesRootInput(value) {
26
+ return path.resolve(expandHome(value));
27
+ }
28
+ function normalizeLayout(value) {
29
+ return value || DEFAULT_WORKTREE_LAYOUT;
30
+ }
31
+ export async function readGlobalConfig() {
32
+ try {
33
+ if (await fs.pathExists(CONFIG_PATH)) {
34
+ const config = await fs.readJSON(CONFIG_PATH);
35
+ return {
36
+ worktreesRoot: typeof config.worktreesRoot === 'string' ? config.worktreesRoot : undefined,
37
+ worktreeLayout: config.worktreeLayout === 'codex' ? 'codex' : undefined,
38
+ };
39
+ }
40
+ }
41
+ catch {
42
+ return {};
43
+ }
44
+ return {};
45
+ }
46
+ export async function writeGlobalConfig(config) {
47
+ await fs.ensureDir(YGG_ROOT);
48
+ await fs.writeJSON(CONFIG_PATH, config, { spaces: 2 });
49
+ }
50
+ export async function getWorktreePathConfig() {
51
+ const config = await readGlobalConfig();
52
+ return {
53
+ root: normalizeRoot(config.worktreesRoot),
54
+ layout: normalizeLayout(config.worktreeLayout),
55
+ };
56
+ }
57
+ export async function getManagedWorktreesRoot() {
58
+ const config = await getWorktreePathConfig();
59
+ return config.root;
60
+ }
61
+ export function buildManagedWorktreePath(repoName, worktreeSlug, config) {
62
+ if (config.layout === 'codex') {
63
+ return path.join(config.root, worktreeSlug, repoName);
64
+ }
65
+ return path.join(config.root, repoName, worktreeSlug);
66
+ }
67
+ export function getPresetConfig(preset) {
68
+ const normalized = preset.trim().toLowerCase();
69
+ if (normalized === 'default' || normalized === 'yggtree') {
70
+ return {};
71
+ }
72
+ if (normalized === 'codex') {
73
+ return {
74
+ worktreesRoot: '~/.codex/worktrees',
75
+ worktreeLayout: 'codex',
76
+ };
77
+ }
78
+ return undefined;
79
+ }
80
+ export function formatGlobalConfig(config, resolved) {
81
+ return [
82
+ `worktreesRoot: ${formatHome(resolved.root)}`,
83
+ `worktreeLayout: ${resolved.layout}`,
84
+ `configFile: ${formatHome(CONFIG_PATH)}`,
85
+ config.worktreesRoot || config.worktreeLayout
86
+ ? 'preset: custom'
87
+ : 'preset: default',
88
+ ];
89
+ }
@@ -0,0 +1,93 @@
1
+ export const intentRouterHelp = `
2
+ Choose by intent:
3
+ New official task branch yggtree create feat/name --base main --source remote
4
+ Existing branch/interruption yggtree wc --ref branch-name
5
+ Continue dirty current work yggtree handoff --name task-name
6
+ Disposable local experiment yggtree create-sandbox
7
+ Copy sandbox files to origin yggtree apply # from inside sandbox, not a Git merge
8
+ Bulk official worktrees yggtree create-multi
9
+
10
+ More detail: run yggtree help <command>, for example yggtree help handoff.
11
+ `;
12
+ export const createHelp = `
13
+ Behavior:
14
+ Creates an official branch-backed worktree, publishes the branch to origin, and enters the shell by default.
15
+ Use for real task branches. For disposable experiments, use create-sandbox.
16
+
17
+ Examples:
18
+ yggtree create feat/new-flow --base main --source remote
19
+ yggtree create feat/background-task --base main --source remote --no-open --no-enter
20
+ `;
21
+ export const createMultiHelp = `
22
+ Behavior:
23
+ Bulk-creates official branch-backed worktrees.
24
+ This does not share create's open/enter/exec lifecycle. Use create for one task branch.
25
+
26
+ Example:
27
+ yggtree create-multi --base main --source remote
28
+ `;
29
+ export const checkoutHelp = `
30
+ Behavior:
31
+ Creates or reuses a worktree for an existing branch or ref without disturbing current work.
32
+ Use for interruptions and branch review. Use create for a new official task branch.
33
+ Enters the worktree shell by default; add --no-enter when automation should return.
34
+
35
+ Examples:
36
+ yggtree wc --ref hotfix/payment-timeout
37
+ yggtree wc --ref main --name fresh-main --no-open --no-enter
38
+ `;
39
+ export const openHelp = `
40
+ Behavior:
41
+ Opens an existing worktree in an editor, desktop app, or terminal target.
42
+ Returns by default after opening. Add --enter only when the shell should continue inside the worktree.
43
+
44
+ Examples:
45
+ yggtree open my-feature --tool cursor
46
+ yggtree open my-feature --tool codex-app
47
+ yggtree list --open
48
+ `;
49
+ export const createSandboxHelp = `
50
+ Behavior:
51
+ Creates a local-only disposable experiment from the current branch.
52
+ For continuing current dirty work, prefer handoff instead of teaching create-sandbox --carry.
53
+
54
+ Examples:
55
+ yggtree create-sandbox
56
+ yggtree create-sandbox --name ui-option-a --no-open
57
+
58
+ Related:
59
+ handoff carries current dirty work into a named sandbox.
60
+ apply copies changed sandbox files back to origin.
61
+ `;
62
+ export const handoffHelp = `
63
+ Behavior:
64
+ Carry staged, unstaged, and untracked work into a named sandbox so you can continue there.
65
+ The origin checkout is not cleaned; review or reset it separately if needed.
66
+
67
+ Example:
68
+ yggtree handoff --name continue-auth-refactor
69
+
70
+ Related:
71
+ create-sandbox starts a disposable local experiment.
72
+ apply copies changed sandbox files back to origin.
73
+ `;
74
+ export const applyHelp = `
75
+ Behavior:
76
+ Copies changed files from the current sandbox back to the origin checkout.
77
+ Stores backups in sandbox metadata before overwriting origin files.
78
+ Not a Git merge, rebase, patch, or cherry-pick. Review the origin diff afterward.
79
+
80
+ Example:
81
+ yggtree apply
82
+
83
+ Related:
84
+ unapply restores from the sandbox backup metadata while the sandbox still exists.
85
+ `;
86
+ export const unapplyHelp = `
87
+ Behavior:
88
+ Restores origin files from sandbox metadata created by apply.
89
+ Only works while the sandbox still exists.
90
+
91
+ Example:
92
+ yggtree unapply
93
+ `;
package/dist/lib/paths.js CHANGED
@@ -1,4 +1,3 @@
1
1
  import os from 'os';
2
2
  import path from 'path';
3
3
  export const YGG_ROOT = path.join(os.homedir(), '.yggtree');
4
- export const WORKTREES_ROOT = YGG_ROOT;
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import { getRepoRoot } from './git.js';
4
+ import { getManagedWorktreesRoot } from './global-config.js';
4
5
  import { getValidRegisteredRepos } from './registry.js';
5
6
  import { log } from './ui.js';
6
7
  import { formatWorktreeDisplayPath } from './worktree.js';
@@ -11,6 +12,7 @@ export async function ensureRepoContext() {
11
12
  catch {
12
13
  const validRepos = await getValidRegisteredRepos();
13
14
  const repoEntries = Object.entries(validRepos);
15
+ const managedRoot = await getManagedWorktreesRoot();
14
16
  if (repoEntries.length === 0) {
15
17
  log.error('Not inside a git repository and no registered realms found.');
16
18
  log.dim('Run `yggtree` inside an existing git project first to register it.');
@@ -34,7 +36,7 @@ export async function ensureRepoContext() {
34
36
  name: 'selectedRepoPath',
35
37
  message: 'Select a realm:',
36
38
  choices: repoEntries.map(([name, repoPath]) => ({
37
- name: `${chalk.bold.yellow(name)} ${chalk.dim(formatWorktreeDisplayPath(repoPath))}`,
39
+ name: `${chalk.bold.yellow(name)} ${chalk.dim(formatWorktreeDisplayPath(repoPath, managedRoot))}`,
38
40
  value: repoPath,
39
41
  })),
40
42
  pageSize: 10,
package/dist/lib/ui.js CHANGED
@@ -6,15 +6,80 @@ import path from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { getVersion } from './version.js';
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
- // --- Personality & Branding ---
10
- export const welcome = async () => {
11
- console.log('');
9
+ const yggAccent = chalk.hex('#DEADED');
10
+ const yggLeaf = chalk.hex('#9AD68B');
11
+ const yggSky = chalk.hex('#7AC7D8');
12
+ const yggBark = chalk.hex('#C99A5B');
13
+ const yggRoot = chalk.hex('#B48AE8');
14
+ const yggWarn = chalk.hex('#E4B85E');
15
+ const yggDanger = chalk.hex('#E06C75');
16
+ const yggMuted = chalk.hex('#8D8D8D');
17
+ export function renderWelcome(options = {}) {
18
+ const rule = yggAccent('─'.repeat(54));
12
19
  const title = figlet.textSync('Yggdrasil', { font: 'Standard' });
13
- console.log(gradient.mind.multiline(title));
14
20
  const version = getVersion();
15
- console.log(chalk.dim(` v${version} The World Tree Worktree Assistant`));
16
- console.log('');
21
+ const updateLines = renderUpdateNotice(options.update);
22
+ return [
23
+ '',
24
+ rule,
25
+ gradient.mind.multiline(title),
26
+ chalk.bold(yggAccent(' Yggdrasil')),
27
+ chalk.dim(` v${version} • The World Tree Worktree Assistant`),
28
+ ...updateLines,
29
+ rule,
30
+ '',
31
+ ].join('\n');
32
+ }
33
+ export const welcome = async (options = {}) => {
34
+ console.log(renderWelcome(options));
17
35
  };
36
+ export function renderMainMenuIntro(options) {
37
+ const detail = options.isInSandbox
38
+ ? 'Sandbox tools appear first because this realm can apply or undo grafted changes.'
39
+ : 'Create, enter, inspect, or tend your worktrees.';
40
+ return [
41
+ 'Choose the next realm action.',
42
+ yggMuted('Daily routes first. Maintenance and lore wait below.'),
43
+ '',
44
+ `${yggBark('┌')} ${badge('route 01')} ${chalk.bold('Realm')}`,
45
+ `${yggBark('│')} ${yggMuted(detail)}`,
46
+ ].join('\n');
47
+ }
48
+ export function formatMainMenuChoice(options) {
49
+ const tone = menuTone(options.tone);
50
+ const label = options.label.padEnd(24);
51
+ return `${tone(options.glyph)} ${chalk.bold(label)} ${yggMuted(options.detail)}`;
52
+ }
53
+ function renderUpdateNotice(update) {
54
+ if (!update?.updateAvailable)
55
+ return [];
56
+ return [
57
+ '',
58
+ `${yggWarn('Update available')} ${yggMuted(`yggtree ${update.currentVersion} -> ${update.latestVersion}`)}`,
59
+ yggMuted('Run: npm install -g yggtree'),
60
+ ];
61
+ }
62
+ function badge(value) {
63
+ return yggRoot(` ${value} `);
64
+ }
65
+ function menuTone(tone) {
66
+ switch (tone) {
67
+ case 'growth':
68
+ return yggLeaf;
69
+ case 'travel':
70
+ return yggSky;
71
+ case 'sandbox':
72
+ return yggRoot;
73
+ case 'tending':
74
+ return yggBark;
75
+ case 'danger':
76
+ return yggDanger;
77
+ case 'lore':
78
+ return yggAccent;
79
+ case 'exit':
80
+ return yggMuted;
81
+ }
82
+ }
18
83
  // --- Logger ---
19
84
  export const log = {
20
85
  info: (msg) => console.log(chalk.blue('ℹ'), msg),
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
- import { WORKTREES_ROOT } from './paths.js';
4
+ import { YGG_ROOT } from './paths.js';
5
5
  import { getSandboxMetaPath } from './sandbox.js';
6
6
  export const WORKTREE_TYPE_ORDER = {
7
7
  MAIN: 0,
@@ -12,19 +12,23 @@ export const WORKTREE_TYPE_ORDER = {
12
12
  export function getWorktreeBranchName(worktree) {
13
13
  return worktree.branch || worktree.HEAD || 'detached';
14
14
  }
15
- export function isManagedWorktreePath(worktreePath) {
16
- return worktreePath.startsWith(WORKTREES_ROOT);
15
+ function isPathInsideRoot(worktreePath, managedRoot) {
16
+ const relative = path.relative(managedRoot, worktreePath);
17
+ return relative === '' || Boolean(relative && !relative.startsWith('..') && !path.isAbsolute(relative));
17
18
  }
18
- export function formatWorktreeDisplayPath(worktreePath) {
19
- if (isManagedWorktreePath(worktreePath)) {
20
- return path.relative(WORKTREES_ROOT, worktreePath);
19
+ export function isManagedWorktreePath(worktreePath, managedRoot = YGG_ROOT) {
20
+ return isPathInsideRoot(path.resolve(worktreePath), path.resolve(managedRoot));
21
+ }
22
+ export function formatWorktreeDisplayPath(worktreePath, managedRoot = YGG_ROOT) {
23
+ if (isManagedWorktreePath(worktreePath, managedRoot)) {
24
+ return path.relative(managedRoot, worktreePath);
21
25
  }
22
26
  return worktreePath.replace(process.env.HOME || '', '~');
23
27
  }
24
- export function findWorktreeByName(worktrees, worktreeName) {
28
+ export function findWorktreeByName(worktrees, worktreeName, managedRoot = YGG_ROOT) {
25
29
  return worktrees.find(worktree => {
26
30
  const branchName = getWorktreeBranchName(worktree);
27
- const relativePath = path.relative(WORKTREES_ROOT, worktree.path);
31
+ const relativePath = path.relative(managedRoot, worktree.path);
28
32
  const basename = path.basename(worktree.path);
29
33
  return branchName === worktreeName ||
30
34
  relativePath === worktreeName ||
@@ -32,8 +36,8 @@ export function findWorktreeByName(worktrees, worktreeName) {
32
36
  basename === worktreeName;
33
37
  });
34
38
  }
35
- export async function detectWorktreeType(worktree, mainWorktreePath) {
36
- const isManaged = isManagedWorktreePath(worktree.path);
39
+ export async function detectWorktreeType(worktree, mainWorktreePath, managedRoot = YGG_ROOT) {
40
+ const isManaged = isManagedWorktreePath(worktree.path, managedRoot);
37
41
  if (!isManaged) {
38
42
  return worktree.path === mainWorktreePath ? 'MAIN' : 'LINKED';
39
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yggtree",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "packageManager": "pnpm@11.0.9",
5
5
  "description": "Interactive CLI for managing git worktrees and configs",
6
6
  "main": "dist/index.js",