yggtree 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonardo Dias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # 🌳 Yggdrasil Worktree (yggtree)
2
+
3
+ **Yggdrasil Worktree** (invoked as `yggtree`) is a powerful, interactive CLI designed to streamline your Git worktree workflow. Like the mythical world tree connecting the realms, Yggdrasil connects your branches into isolated, manageable worktrees.
4
+
5
+ ---
6
+
7
+ ## 🚀 Quick Start
8
+
9
+ ### Installation
10
+
11
+ ```bash
12
+ # Clone the repository
13
+ git clone https://github.com/leoreisdias/yggtree.git
14
+ cd yggtree
15
+
16
+ # Install dependencies and build
17
+ npm install
18
+ npm run build
19
+
20
+ # Link the CLI globally
21
+ npm link
22
+ ```
23
+
24
+ ### Usage
25
+
26
+ Simply run `yggtree` to open the interactive menu:
27
+
28
+ ```bash
29
+ yggtree
30
+ ```
31
+
32
+ Or use specific commands:
33
+
34
+ ```bash
35
+ yggtree wt create # Smart branch-based creation
36
+ yggtree wt list # View all managed worktrees
37
+ yggtree wt prune # Clean up stale worktree data
38
+ ```
39
+
40
+ ---
41
+
42
+ ## ✨ Key Features
43
+
44
+ ### 🌿 Smart Branch Creation (`wt create`)
45
+ The primary way to start working. Instead of worrying about folder names, just tell Yggdrasil which branch you want to work on.
46
+ - **Auto-Slug**: Converts `feat/eng-123-ui` to a clean folder name like `feat-eng-123-ui`.
47
+ - **Auto-Branching**: If the branch doesn't exist, Yggdrasil creates it for you from a base branch.
48
+ - **Remote Awareness**: Seamlessly base your work on `origin/main` or local refs.
49
+
50
+ ### 🌳 Batch Creation (`wt create-multi`)
51
+ Need to spin up multiple features? Provide branch names separated by spaces, and Yggdrasil will provision all of them in one go.
52
+
53
+ ### 🚀 Custom Bootstrapping
54
+ Configure your environment automatically using an `anvil-worktree.json` (also compatible with `.cursor/worktrees.json`) file in your project root.
55
+
56
+ ---
57
+
58
+ ## ⚙️ Configuration
59
+
60
+ Yggdrasil looks for setup instructions in your project root:
61
+
62
+ ```json
63
+ {
64
+ "setup-worktree": [
65
+ "npm install",
66
+ "git submodule sync --recursive",
67
+ "git submodule update --init --recursive",
68
+ "npm run build",
69
+ "echo '🌳 The realm is ready!'"
70
+ ]
71
+ }
72
+ ```
73
+
74
+ ---
75
+
76
+ ## 🛠️ Commands Reference
77
+
78
+ | Command | Description |
79
+ | :--- | :--- |
80
+ | `yggtree` | Open the interactive main menu. |
81
+ | `yggtree wt create` | Create a worktree by branch name (Recommended). |
82
+ | `yggtree wt create-multi` | Create multiple worktrees in a single command. |
83
+ | `yggtree wt create-slug` | Manually specify both folder name and branch ref. |
84
+ | `yggtree wt list` | List all managed worktrees and their status. |
85
+ | `yggtree wt delete` | Interactively select and remove a worktree. |
86
+ | `yggtree wt bootstrap` | Re-run the setup commands for an existing worktree. |
87
+ | `yggtree wt prune` | Clean up Git's internal data for worktrees. |
88
+
89
+ ---
90
+
91
+ ## 📄 License
92
+
93
+ MIT License.
package/bin/yggtree ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
@@ -0,0 +1,41 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import path from 'path';
4
+ import { listWorktrees, getRepoRoot } from '../../lib/git.js';
5
+ import { WORKTREES_ROOT } from '../../lib/paths.js';
6
+ import { log } from '../../lib/ui.js';
7
+ import { runBootstrap } from '../../lib/config.js';
8
+ export async function bootstrapCommand() {
9
+ try {
10
+ const _ = await getRepoRoot();
11
+ const worktrees = await listWorktrees();
12
+ // 1. Select Worktree
13
+ // Filter managed or just show all? The prompt said "managed", let's prioritize managed but maybe allow all if needed?
14
+ // User requirements say "managed", sticking to that for consistency.
15
+ const managedWts = worktrees.filter(wt => wt.path.startsWith(WORKTREES_ROOT));
16
+ if (managedWts.length === 0) {
17
+ log.info('No managed worktrees found to bootstrap.');
18
+ return;
19
+ }
20
+ const choices = managedWts.map(wt => ({
21
+ name: `${chalk.bold(path.basename(wt.path))} (${chalk.dim(wt.branch || wt.HEAD)})`,
22
+ value: wt.path,
23
+ }));
24
+ const { selectedPath } = await inquirer.prompt([
25
+ {
26
+ type: 'list',
27
+ name: 'selectedPath',
28
+ message: 'Select worktree to bootstrap:',
29
+ choices: choices,
30
+ },
31
+ ]);
32
+ const wtPath = selectedPath;
33
+ const repoRoot = await getRepoRoot();
34
+ await runBootstrap(wtPath, repoRoot);
35
+ log.success('Bootstrap completed!');
36
+ }
37
+ catch (error) {
38
+ log.error(error.message);
39
+ process.exit(1);
40
+ }
41
+ }
@@ -0,0 +1,138 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import path from 'path';
4
+ import { getRepoRoot, verifyRef, fetchAll, getCurrentBranch } from '../../lib/git.js';
5
+ import { runBootstrap } from '../../lib/config.js';
6
+ import { WORKTREES_ROOT } from '../../lib/paths.js';
7
+ import { log, ui, createSpinner } from '../../lib/ui.js';
8
+ import { execa } from 'execa';
9
+ import { spawn } from 'child_process';
10
+ import fs from 'fs-extra';
11
+ export async function createCommandNew(options) {
12
+ try {
13
+ const repoRoot = await getRepoRoot();
14
+ log.info(`Repo: ${chalk.dim(repoRoot)}`);
15
+ // 1. Gather inputs
16
+ const currentBranch = await getCurrentBranch();
17
+ const answers = await inquirer.prompt([
18
+ {
19
+ type: 'input',
20
+ name: 'branch',
21
+ message: 'Branch name (e.g. feat/new-thing):',
22
+ default: options.branch,
23
+ when: !options.branch,
24
+ validate: (input) => input.trim().length > 0 || 'Branch name is required',
25
+ },
26
+ {
27
+ type: 'input',
28
+ name: 'base',
29
+ message: 'Base branch name:',
30
+ default: options.base || currentBranch,
31
+ when: !options.base,
32
+ validate: (input) => input.trim().length > 0 || 'Base ref is required',
33
+ },
34
+ {
35
+ type: 'list',
36
+ name: 'source',
37
+ message: 'Base on:',
38
+ loop: false,
39
+ choices: [
40
+ { name: 'Remote (origin)', value: 'remote' },
41
+ { name: 'Local', value: 'local' },
42
+ ],
43
+ default: 'remote',
44
+ when: !options.base,
45
+ },
46
+ {
47
+ type: 'confirm',
48
+ name: 'bootstrap',
49
+ message: 'Run bootstrap? (npm install + submodules)',
50
+ default: true,
51
+ when: options.bootstrap !== false,
52
+ },
53
+ ]);
54
+ let shouldEnter = false;
55
+ if (!options.branch) {
56
+ const finalAnswer = await inquirer.prompt([{
57
+ type: 'confirm',
58
+ name: 'shouldEnter',
59
+ message: 'Do you want to enter the new worktree now?',
60
+ default: true
61
+ }]);
62
+ shouldEnter = finalAnswer.shouldEnter;
63
+ }
64
+ const branchName = options.branch || answers.branch;
65
+ let baseRef = options.base || answers.base;
66
+ // Append origin/ if remote is selected and not already present
67
+ if (!options.base && answers.source === 'remote' && !baseRef.startsWith('origin/')) {
68
+ baseRef = `origin/${baseRef}`;
69
+ }
70
+ const shouldBootstrap = options.bootstrap === false ? false : answers.bootstrap;
71
+ // Convert branch name to slug (friendly folder name)
72
+ // e.g. feat/eng-2222-new-button -> feat-eng-2222-new-button
73
+ const slug = branchName.replace(/[\/\\]/g, '-').replace(/\s+/g, '-');
74
+ const wtPath = path.join(WORKTREES_ROOT, slug);
75
+ // 2. Validation
76
+ if (!slug)
77
+ throw new Error('Invalid name');
78
+ if (!baseRef)
79
+ throw new Error('Invalid base ref');
80
+ // 3. Execution
81
+ const spinner = createSpinner('Fetching...').start();
82
+ await fetchAll();
83
+ spinner.text = 'Verifying base ref...';
84
+ const baseExists = await verifyRef(baseRef);
85
+ if (!baseExists) {
86
+ spinner.fail(`Base ref not found: ${baseRef}`);
87
+ log.warning(`Tip: try checking if the branch exists on remote.`);
88
+ return;
89
+ }
90
+ spinner.text = `Creating worktree at ${ui.path(wtPath)}...`;
91
+ // Check if target branch already exists
92
+ const targetBranchExists = await verifyRef(branchName);
93
+ // If branch doesn't exist, we create it from base
94
+ // If it does exist, we just check it out
95
+ const createBranchFlag = targetBranchExists ? '' : `-b ${branchName}`;
96
+ try {
97
+ await fs.ensureDir(path.dirname(wtPath));
98
+ // slightly different logic for creating new branch vs existing
99
+ if (targetBranchExists) {
100
+ await execa('git', ['worktree', 'add', wtPath, branchName]);
101
+ }
102
+ else {
103
+ await execa('git', ['worktree', 'add', '-b', branchName, wtPath, baseRef]);
104
+ }
105
+ spinner.succeed('Worktree created.');
106
+ }
107
+ catch (e) {
108
+ spinner.fail('Failed to create worktree.');
109
+ log.error(e.message);
110
+ return;
111
+ }
112
+ // 4. Bootstrap
113
+ if (shouldBootstrap) {
114
+ await runBootstrap(wtPath, repoRoot);
115
+ }
116
+ // 5. Final Output
117
+ log.success('Worktree ready!');
118
+ if (shouldEnter) {
119
+ log.info(`Spawning sub-shell in ${ui.path(wtPath)}...`);
120
+ log.dim('Type "exit" to return to the main terminal.');
121
+ const shell = process.env.SHELL || 'zsh';
122
+ const child = spawn(shell, [], {
123
+ cwd: wtPath,
124
+ stdio: 'inherit',
125
+ });
126
+ child.on('close', () => {
127
+ log.info('Exited sub-shell.');
128
+ });
129
+ }
130
+ else {
131
+ log.header(`cd "${wtPath}"`);
132
+ }
133
+ }
134
+ catch (error) {
135
+ log.error(error.message);
136
+ process.exit(1);
137
+ }
138
+ }
@@ -0,0 +1,109 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import path from 'path';
4
+ import { getRepoRoot, verifyRef, fetchAll, getCurrentBranch } from '../../lib/git.js';
5
+ import { runBootstrap } from '../../lib/config.js';
6
+ import { WORKTREES_ROOT } from '../../lib/paths.js';
7
+ import { log, ui, createSpinner } from '../../lib/ui.js';
8
+ import { execa } from 'execa';
9
+ import fs from 'fs-extra';
10
+ export async function createCommandMulti(options) {
11
+ try {
12
+ const repoRoot = await getRepoRoot();
13
+ log.info(`Repo: ${chalk.dim(repoRoot)}`);
14
+ // 1. Gather inputs
15
+ const currentBranch = await getCurrentBranch();
16
+ const answers = await inquirer.prompt([
17
+ {
18
+ type: 'input',
19
+ name: 'base',
20
+ message: 'Base branch name:',
21
+ default: options.base || currentBranch,
22
+ when: !options.base,
23
+ validate: (input) => input.trim().length > 0 || 'Base ref is required',
24
+ },
25
+ {
26
+ type: 'list',
27
+ name: 'source',
28
+ message: 'Base on:',
29
+ loop: false,
30
+ choices: [
31
+ { name: 'Remote (origin)', value: 'remote' },
32
+ { name: 'Local', value: 'local' },
33
+ ],
34
+ default: 'remote',
35
+ when: !options.base,
36
+ },
37
+ {
38
+ type: 'input',
39
+ name: 'branches',
40
+ message: 'Enter branch names (separated by space):',
41
+ validate: (input) => input.trim().length > 0 || 'At least one branch name is required',
42
+ },
43
+ {
44
+ type: 'confirm',
45
+ name: 'bootstrap',
46
+ message: 'Run bootstrap for all worktrees? (npm install + submodules)',
47
+ default: true,
48
+ when: options.bootstrap !== false,
49
+ },
50
+ ]);
51
+ let baseRef = options.base || answers.base;
52
+ if (!options.base && answers.source === 'remote' && !baseRef.startsWith('origin/')) {
53
+ baseRef = `origin/${baseRef}`;
54
+ }
55
+ const branchNames = answers.branches.split(/\s+/).filter((b) => b.length > 0);
56
+ const shouldBootstrap = options.bootstrap === false ? false : answers.bootstrap;
57
+ // 2. Validation of base ref
58
+ const spinner = createSpinner('Fetching...').start();
59
+ await fetchAll();
60
+ spinner.text = 'Verifying base ref...';
61
+ const baseExists = await verifyRef(baseRef);
62
+ if (!baseExists) {
63
+ spinner.fail(`Base ref not found: ${baseRef}`);
64
+ return;
65
+ }
66
+ spinner.succeed(`Base ref ${chalk.cyan(baseRef)} verified.`);
67
+ const createdWorktrees = [];
68
+ // 3. Execution for each branch
69
+ for (const branchName of branchNames) {
70
+ const slug = branchName.replace(/[\/\\]/g, '-').replace(/\s+/g, '-');
71
+ const wtPath = path.join(WORKTREES_ROOT, slug);
72
+ log.header(`Processing: ${branchName}`);
73
+ const wtSpinner = createSpinner(`Creating worktree at ${ui.path(wtPath)}...`).start();
74
+ try {
75
+ // Check if target branch already exists
76
+ const targetBranchExists = await verifyRef(branchName);
77
+ await fs.ensureDir(path.dirname(wtPath));
78
+ if (targetBranchExists) {
79
+ await execa('git', ['worktree', 'add', wtPath, branchName]);
80
+ }
81
+ else {
82
+ await execa('git', ['worktree', 'add', '-b', branchName, wtPath, baseRef]);
83
+ }
84
+ wtSpinner.succeed(`Worktree for ${chalk.cyan(branchName)} created.`);
85
+ createdWorktrees.push(wtPath);
86
+ // 4. Bootstrap
87
+ if (shouldBootstrap) {
88
+ await runBootstrap(wtPath, repoRoot);
89
+ }
90
+ }
91
+ catch (error) {
92
+ wtSpinner.fail(`Failed to create worktree for ${branchName}.`);
93
+ log.error(error.message);
94
+ }
95
+ }
96
+ // 5. Final Output
97
+ log.success(`${createdWorktrees.length} worktrees ready!`);
98
+ if (createdWorktrees.length > 0) {
99
+ log.info('You can access them with:');
100
+ createdWorktrees.forEach(wtPath => {
101
+ log.header(`cd "${wtPath}"`);
102
+ });
103
+ }
104
+ }
105
+ catch (error) {
106
+ log.error(error.message);
107
+ process.exit(1);
108
+ }
109
+ }
@@ -0,0 +1,124 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import path from 'path';
4
+ import { getRepoRoot, verifyRef, createWorktree, fetchAll, getCurrentBranch } from '../../lib/git.js';
5
+ import { runBootstrap } from '../../lib/config.js';
6
+ import { WORKTREES_ROOT } from '../../lib/paths.js';
7
+ import { log, ui, createSpinner } from '../../lib/ui.js';
8
+ import { spawn } from 'child_process';
9
+ import fs from 'fs-extra';
10
+ export async function createCommand(options) {
11
+ try {
12
+ const repoRoot = await getRepoRoot();
13
+ log.info(`Repo: ${chalk.dim(repoRoot)}`);
14
+ // 1. Gather inputs
15
+ const currentBranch = await getCurrentBranch();
16
+ const answers = await inquirer.prompt([
17
+ {
18
+ type: 'input',
19
+ name: 'name',
20
+ message: 'Worktree name (slug):',
21
+ default: options.name,
22
+ when: !options.name,
23
+ validate: (input) => input.trim().length > 0 || 'Name is required',
24
+ },
25
+ {
26
+ type: 'input',
27
+ name: 'ref',
28
+ message: 'Base branch name:',
29
+ default: options.ref || currentBranch,
30
+ when: !options.ref,
31
+ validate: (input) => input.trim().length > 0 || 'Ref is required',
32
+ },
33
+ {
34
+ type: 'list',
35
+ name: 'source',
36
+ message: 'Base on:',
37
+ loop: false,
38
+ choices: [
39
+ { name: 'Remote (origin)', value: 'remote' },
40
+ { name: 'Local', value: 'local' },
41
+ ],
42
+ default: 'remote',
43
+ when: !options.ref,
44
+ },
45
+ {
46
+ type: 'confirm',
47
+ name: 'bootstrap',
48
+ message: 'Run bootstrap? (npm install + submodules)',
49
+ default: true,
50
+ when: options.bootstrap !== false,
51
+ },
52
+ ]);
53
+ let shouldEnter = false;
54
+ if (!options.ref) {
55
+ const finalAnswer = await inquirer.prompt([{
56
+ type: 'confirm',
57
+ name: 'shouldEnter',
58
+ message: 'Do you want to enter the new worktree now?',
59
+ default: true
60
+ }]);
61
+ shouldEnter = finalAnswer.shouldEnter;
62
+ }
63
+ const name = options.name || answers.name;
64
+ let ref = options.ref || answers.ref;
65
+ // Append origin/ if remote is selected and not already present
66
+ if (!options.ref && answers.source === 'remote' && !ref.startsWith('origin/')) {
67
+ ref = `origin/${ref}`;
68
+ }
69
+ const shouldBootstrap = options.bootstrap === false ? false : answers.bootstrap;
70
+ const slug = name.replace(/\s+/g, '-');
71
+ const wtPath = path.join(WORKTREES_ROOT, slug);
72
+ // 2. Validation
73
+ if (!slug)
74
+ throw new Error('Invalid name');
75
+ if (!ref)
76
+ throw new Error('Invalid ref');
77
+ // 3. Execution
78
+ const spinner = createSpinner('Fetching...').start();
79
+ await fetchAll();
80
+ spinner.text = 'Verifying ref...';
81
+ const exists = await verifyRef(ref);
82
+ if (!exists) {
83
+ spinner.fail(`Ref not found: ${ref}`);
84
+ log.warning(`Tip: try 'origin/${ref}' or check if the branch exists.`);
85
+ return;
86
+ }
87
+ spinner.text = `Creating worktree at ${ui.path(wtPath)}...`;
88
+ try {
89
+ await fs.ensureDir(path.dirname(wtPath));
90
+ await createWorktree(wtPath, ref);
91
+ spinner.succeed('Worktree created.');
92
+ }
93
+ catch (e) {
94
+ spinner.fail('Failed to create worktree.');
95
+ log.error(e.message);
96
+ return;
97
+ }
98
+ // 4. Bootstrap
99
+ if (shouldBootstrap) {
100
+ await runBootstrap(wtPath, repoRoot);
101
+ }
102
+ // 5. Final Output
103
+ log.success('Worktree ready!');
104
+ if (shouldEnter) {
105
+ log.info(`Spawning sub-shell in ${ui.path(wtPath)}...`);
106
+ log.dim('Type "exit" to return to the main terminal.');
107
+ const shell = process.env.SHELL || 'zsh';
108
+ const child = spawn(shell, [], {
109
+ cwd: wtPath,
110
+ stdio: 'inherit',
111
+ });
112
+ child.on('close', () => {
113
+ log.info('Exited sub-shell.');
114
+ });
115
+ }
116
+ else {
117
+ log.header(`cd "${wtPath}"`);
118
+ }
119
+ }
120
+ catch (error) {
121
+ log.error(error.message);
122
+ process.exit(1);
123
+ }
124
+ }
@@ -0,0 +1,52 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import path from 'path';
4
+ import { listWorktrees, removeWorktree, getRepoRoot } from '../../lib/git.js';
5
+ import { WORKTREES_ROOT } from '../../lib/paths.js';
6
+ import { log, createSpinner } from '../../lib/ui.js';
7
+ export async function deleteCommand() {
8
+ try {
9
+ const _ = await getRepoRoot();
10
+ const worktrees = await listWorktrees();
11
+ // Filter only managed worktrees
12
+ const managedWts = worktrees.filter(wt => wt.path.startsWith(WORKTREES_ROOT));
13
+ if (managedWts.length === 0) {
14
+ log.info('No managed worktrees found to delete.');
15
+ return;
16
+ }
17
+ const choices = managedWts.map(wt => ({
18
+ name: `${chalk.bold(path.basename(wt.path))} (${chalk.dim(wt.branch || wt.HEAD)})`,
19
+ value: wt.path,
20
+ }));
21
+ const { selectedPath } = await inquirer.prompt([
22
+ {
23
+ type: 'list',
24
+ name: 'selectedPath',
25
+ message: 'Select worktree to delete:',
26
+ choices: choices,
27
+ },
28
+ ]);
29
+ const worktreeName = path.basename(selectedPath);
30
+ const { confirm } = await inquirer.prompt([
31
+ {
32
+ type: 'input',
33
+ name: 'confirm',
34
+ message: `Type "${chalk.bold(worktreeName)}" to confirm deletion:`,
35
+ validate: (input) => input === worktreeName || 'Incorrect name, deletion aborted.',
36
+ },
37
+ ]);
38
+ const spinner = createSpinner(`Deleting ${worktreeName}...`).start();
39
+ try {
40
+ await removeWorktree(selectedPath);
41
+ spinner.succeed(`Deleted worktree: ${worktreeName}`);
42
+ }
43
+ catch (e) {
44
+ spinner.fail(`Failed to delete ${worktreeName}`);
45
+ log.error(e.message);
46
+ }
47
+ }
48
+ catch (error) {
49
+ log.error(error.message);
50
+ process.exit(1);
51
+ }
52
+ }
@@ -0,0 +1,32 @@
1
+ import chalk from 'chalk';
2
+ import { listWorktrees, getRepoRoot } from '../../lib/git.js';
3
+ import { WORKTREES_ROOT } from '../../lib/paths.js';
4
+ import { log } from '../../lib/ui.js';
5
+ export async function listCommand() {
6
+ try {
7
+ const _ = await getRepoRoot(); // Verify we are in a git repo
8
+ const worktrees = await listWorktrees();
9
+ if (worktrees.length === 0) {
10
+ log.info('No worktrees found.');
11
+ return;
12
+ }
13
+ console.log(chalk.bold('\n Active Worktrees:\n'));
14
+ // Header
15
+ console.log(` ${chalk.dim('TYPE')} ${chalk.dim('BRANCH')} ${chalk.dim('PATH')}`);
16
+ console.log(chalk.dim(' ' + '-'.repeat(60)));
17
+ for (const wt of worktrees) {
18
+ const isManaged = wt.path.startsWith(WORKTREES_ROOT);
19
+ const isMain = !isManaged; // Simplification: assume main repo is not in managed dir
20
+ const type = isManaged ? chalk.green('MANAGED') : chalk.blue('MAIN ');
21
+ const branchName = wt.branch || wt.HEAD || 'detached';
22
+ const displayPath = wt.path.replace(process.env.HOME || '', '~');
23
+ const colorPath = isManaged ? chalk.cyan(displayPath) : chalk.dim(displayPath);
24
+ console.log(` ${type} ${chalk.yellow(branchName.padEnd(18))} ${colorPath}`);
25
+ }
26
+ console.log('');
27
+ }
28
+ catch (error) {
29
+ log.error(error.message);
30
+ process.exit(1);
31
+ }
32
+ }
@@ -0,0 +1,20 @@
1
+ import { pruneWorktrees, getRepoRoot } from '../../lib/git.js';
2
+ import { log, createSpinner } from '../../lib/ui.js';
3
+ export async function pruneCommand() {
4
+ try {
5
+ const _ = await getRepoRoot();
6
+ const spinner = createSpinner('Pruning stale worktrees...').start();
7
+ try {
8
+ await pruneWorktrees();
9
+ spinner.succeed('Worktrees pruned.');
10
+ }
11
+ catch (e) {
12
+ spinner.fail('Failed to prune worktrees.');
13
+ log.error(e.message);
14
+ }
15
+ }
16
+ catch (error) {
17
+ log.error(error.message);
18
+ process.exit(1);
19
+ }
20
+ }
package/dist/index.js ADDED
@@ -0,0 +1,102 @@
1
+ import { Command } from 'commander';
2
+ import inquirer from 'inquirer';
3
+ import { welcome, log } from './lib/ui.js';
4
+ import { listCommand } from './commands/wt/list.js';
5
+ import { createCommand } from './commands/wt/create.js';
6
+ import { createCommandNew } from './commands/wt/create-branch.js';
7
+ import { createCommandMulti } from './commands/wt/create-multi.js';
8
+ import { deleteCommand } from './commands/wt/delete.js';
9
+ import { bootstrapCommand } from './commands/wt/bootstrap.js';
10
+ import { pruneCommand } from './commands/wt/prune.js';
11
+ const program = new Command();
12
+ program
13
+ .name('yggtree')
14
+ .description('Interactive CLI for managing git worktrees and configs')
15
+ .version('1.0.0')
16
+ .action(async () => {
17
+ // Interactive Menu if no command is provided
18
+ await welcome();
19
+ const { action } = await inquirer.prompt([
20
+ {
21
+ type: 'list',
22
+ name: 'action',
23
+ message: 'What would you like to do?',
24
+ loop: false,
25
+ choices: [
26
+ { name: '🌿 Create new worktree (Smart Branch)', value: 'create-smart' },
27
+ { name: '🌳 Create multiple worktrees', value: 'create-multi' },
28
+ { name: '🌱 Create new worktree (Manual Slug)', value: 'create-slug' },
29
+ { name: '📋 List worktrees', value: 'list' },
30
+ { name: '🗑️ Delete worktree', value: 'delete' },
31
+ { name: '🚀 Bootstrap worktree', value: 'bootstrap' },
32
+ { name: '🧹 Prune stale worktrees', value: 'prune' },
33
+ new inquirer.Separator(),
34
+ { name: '🚪 Exit', value: 'exit' },
35
+ ],
36
+ },
37
+ ]);
38
+ switch (action) {
39
+ case 'create-smart':
40
+ await createCommandNew({ bootstrap: true });
41
+ break;
42
+ case 'create-multi':
43
+ await createCommandMulti({ bootstrap: true });
44
+ break;
45
+ case 'create-slug':
46
+ await createCommand({ bootstrap: true });
47
+ break;
48
+ case 'list':
49
+ await listCommand();
50
+ break;
51
+ case 'delete':
52
+ await deleteCommand();
53
+ break;
54
+ case 'bootstrap':
55
+ await bootstrapCommand();
56
+ break;
57
+ case 'prune':
58
+ await pruneCommand();
59
+ break;
60
+ case 'exit':
61
+ log.info('Bye! 👋');
62
+ process.exit(0);
63
+ }
64
+ });
65
+ // --- Worktree Commands ---
66
+ const wt = program.command('wt').description('Manage git worktrees');
67
+ wt.command('list')
68
+ .description('List all worktrees')
69
+ .action(listCommand);
70
+ wt.command('create')
71
+ .description('Create a new worktree (Smart branch detection)')
72
+ .option('-b, --branch <name>', 'Branch name (e.g. feat/new-ui)')
73
+ .option('--base <ref>', 'Base ref (e.g. main)')
74
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
75
+ .action(async (options) => {
76
+ await createCommandNew(options);
77
+ });
78
+ wt.command('create-multi')
79
+ .description('Create multiple worktrees (Smart branch detection)')
80
+ .option('--base <ref>', 'Base ref (e.g. main)')
81
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
82
+ .action(async (options) => {
83
+ await createCommandMulti(options);
84
+ });
85
+ wt.command('create-slug')
86
+ .description('Create a new worktree (Manual slug/ref)')
87
+ .option('-n, --name <slug>', 'Worktree name (slug)')
88
+ .option('-r, --ref <ref>', 'Existing branch or ref')
89
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
90
+ .action(async (options) => {
91
+ await createCommand(options);
92
+ });
93
+ wt.command('delete')
94
+ .description('Delete a managed worktree')
95
+ .action(deleteCommand);
96
+ wt.command('bootstrap')
97
+ .description('Bootstrap dependencies in a worktree')
98
+ .action(bootstrapCommand);
99
+ wt.command('prune')
100
+ .description('Prune stale worktree information')
101
+ .action(pruneCommand);
102
+ program.parse(process.argv);
@@ -0,0 +1,81 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import { execa } from 'execa';
4
+ import { log, createSpinner } from './ui.js';
5
+ export async function getBootstrapCommands(repoRoot) {
6
+ const configPath = path.join(repoRoot, 'anvil-worktree.json');
7
+ const cursorConfigPath = path.join(repoRoot, '.cursor', 'worktrees.json');
8
+ if (await fs.pathExists(configPath)) {
9
+ try {
10
+ const config = await fs.readJSON(configPath);
11
+ if (config['setup-worktree'] && Array.isArray(config['setup-worktree'])) {
12
+ return config['setup-worktree'];
13
+ }
14
+ }
15
+ catch (e) {
16
+ log.warning(`Failed to parse ${configPath}.`);
17
+ }
18
+ }
19
+ if (await fs.pathExists(cursorConfigPath)) {
20
+ try {
21
+ const config = await fs.readJSON(cursorConfigPath);
22
+ if (config['setup-worktree'] && Array.isArray(config['setup-worktree'])) {
23
+ return config['setup-worktree'];
24
+ }
25
+ }
26
+ catch (e) {
27
+ log.warning(`Failed to parse ${cursorConfigPath}.`);
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+ export async function runBootstrap(wtPath, repoRoot) {
33
+ const customCommands = await getBootstrapCommands(repoRoot);
34
+ if (customCommands) {
35
+ log.info('Using custom bootstrap commands from config...');
36
+ for (const cmd of customCommands) {
37
+ const spinner = createSpinner(`Running: ${cmd}`).start();
38
+ try {
39
+ // Split command and arguments properly if needed, or run via shell
40
+ await execa(cmd, { cwd: wtPath, shell: true });
41
+ spinner.succeed(`Finished: ${cmd}`);
42
+ }
43
+ catch (e) {
44
+ spinner.fail(`Failed: ${cmd}`);
45
+ log.error(e.message);
46
+ }
47
+ }
48
+ }
49
+ else {
50
+ // Fallback to default behavior
51
+ log.info('Running default bootstrap (npm install + submodules)...');
52
+ // npm install
53
+ try {
54
+ await execa('npm', ['--version']);
55
+ const installSpinner = createSpinner('Running npm install...').start();
56
+ try {
57
+ await execa('npm', ['install'], { cwd: wtPath });
58
+ installSpinner.succeed('Dependencies installed.');
59
+ }
60
+ catch (e) {
61
+ installSpinner.fail('npm install failed.');
62
+ log.error(e.message);
63
+ }
64
+ }
65
+ catch {
66
+ log.warning('npm not found, skipping install.');
67
+ }
68
+ // Submodules
69
+ const subSpinner = createSpinner('Syncing submodules...').start();
70
+ try {
71
+ await execa('git', ['submodule', 'sync', '--recursive'], { cwd: wtPath });
72
+ await execa('git', ['submodule', 'update', '--init', '--recursive'], { cwd: wtPath });
73
+ subSpinner.succeed('Submodules synced.');
74
+ }
75
+ catch (e) {
76
+ subSpinner.fail('Submodule sync failed.');
77
+ log.error(e.message);
78
+ log.warning('Tip: If auth failed, try adding your key to the agent.');
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,72 @@
1
+ import { execa } from 'execa';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ export async function getRepoRoot() {
5
+ try {
6
+ const { stdout } = await execa('git', ['rev-parse', '--show-toplevel']);
7
+ return stdout.trim();
8
+ }
9
+ catch (error) {
10
+ throw new Error('Not a git repository');
11
+ }
12
+ }
13
+ export async function getCurrentBranch() {
14
+ try {
15
+ const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
16
+ return stdout.trim();
17
+ }
18
+ catch {
19
+ return '';
20
+ }
21
+ }
22
+ export async function verifyRef(ref) {
23
+ try {
24
+ await execa('git', ['rev-parse', '--verify', ref]);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export async function fetchAll() {
32
+ await execa('git', ['fetch', '--all', '--prune']);
33
+ }
34
+ export async function createWorktree(wtPath, ref) {
35
+ await fs.ensureDir(path.dirname(wtPath));
36
+ await execa('git', ['worktree', 'add', wtPath, ref]);
37
+ }
38
+ export async function removeWorktree(wtPath) {
39
+ await execa('git', ['worktree', 'remove', wtPath, '--force']);
40
+ }
41
+ export async function listWorktrees() {
42
+ const { stdout } = await execa('git', ['worktree', 'list', '--porcelain']);
43
+ const worktrees = [];
44
+ let currentWt = {};
45
+ for (const line of stdout.split('\n')) {
46
+ if (!line) {
47
+ if (currentWt.path)
48
+ worktrees.push(currentWt);
49
+ currentWt = {};
50
+ continue;
51
+ }
52
+ const [key, ...rest] = line.split(' ');
53
+ const value = rest.join(' ');
54
+ if (key === 'worktree')
55
+ currentWt.path = value;
56
+ if (key === 'HEAD')
57
+ currentWt.HEAD = value;
58
+ if (key === 'branch')
59
+ currentWt.branch = value.replace('refs/heads/', '');
60
+ }
61
+ // Push the last one if active
62
+ if (currentWt.path)
63
+ worktrees.push(currentWt);
64
+ return worktrees;
65
+ }
66
+ export async function pruneWorktrees() {
67
+ await execa('git', ['worktree', 'prune']);
68
+ }
69
+ export async function syncSubmodules(cwd) {
70
+ await execa('git', ['submodule', 'sync', '--recursive'], { cwd });
71
+ await execa('git', ['submodule', 'update', '--init', '--recursive'], { cwd });
72
+ }
@@ -0,0 +1,4 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ export const ANVIL_ROOT = path.join(os.homedir(), '.yggdrasil');
4
+ export const WORKTREES_ROOT = path.join(ANVIL_ROOT, 'worktrees');
package/dist/lib/ui.js ADDED
@@ -0,0 +1,39 @@
1
+ import chalk from 'chalk';
2
+ import figlet from 'figlet';
3
+ import gradient from 'gradient-string';
4
+ import ora from 'ora';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ // --- Personality & Branding ---
9
+ export const welcome = async () => {
10
+ console.log('');
11
+ const title = figlet.textSync('Yggdrasil', { font: 'Standard' });
12
+ console.log(gradient.mind.multiline(title));
13
+ console.log(chalk.dim(' v1.0.0 • The World Tree Worktree Assistant'));
14
+ console.log('');
15
+ };
16
+ // --- Logger ---
17
+ export const log = {
18
+ info: (msg) => console.log(chalk.blue('ℹ'), msg),
19
+ success: (msg) => console.log(chalk.green('✔'), msg),
20
+ warning: (msg) => console.log(chalk.yellow('⚠'), msg),
21
+ error: (msg) => console.log(chalk.red('✖'), msg),
22
+ dim: (msg) => console.log(chalk.dim(msg)),
23
+ header: (msg) => console.log(chalk.bold.hex('#DEADED')(`\n${msg}\n`)),
24
+ };
25
+ // --- Initial Spinners ---
26
+ export const createSpinner = (text) => {
27
+ return ora({
28
+ text,
29
+ color: 'cyan',
30
+ spinner: 'dots',
31
+ });
32
+ };
33
+ // --- Prompts helpers ---
34
+ // Ensure consistency in UI
35
+ export const ui = {
36
+ docLink: (url) => chalk.underline.cyan(url),
37
+ code: (cmd) => chalk.bgBlack.white(` ${cmd} `),
38
+ path: (p) => chalk.cyan(p),
39
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "yggtree",
3
+ "version": "1.0.0",
4
+ "description": "Interactive CLI for managing git worktrees and configs",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "yggtree": "./bin/yggtree"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "bin",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "dev": "tsc --watch",
19
+ "test": "echo \"Error: no test specified\" && exit 1"
20
+ },
21
+ "keywords": [
22
+ "cli",
23
+ "git",
24
+ "worktree",
25
+ "productivity"
26
+ ],
27
+ "author": "Leonardo Dias",
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "chalk": "^5.3.0",
31
+ "commander": "^12.0.0",
32
+ "execa": "^8.0.1",
33
+ "figlet": "^1.7.0",
34
+ "fs-extra": "^11.2.0",
35
+ "gradient-string": "^2.0.2",
36
+ "inquirer": "^9.2.14",
37
+ "ora": "^8.0.1",
38
+ "zod": "^3.22.4"
39
+ },
40
+ "devDependencies": {
41
+ "@types/figlet": "^1.5.8",
42
+ "@types/fs-extra": "^11.0.4",
43
+ "@types/gradient-string": "^1.1.6",
44
+ "@types/inquirer": "^9.0.7",
45
+ "@types/node": "^20.11.19",
46
+ "typescript": "^5.3.3"
47
+ }
48
+ }