yggtree 1.2.1 → 1.4.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/README.md +118 -13
- package/dist/commands/wt/apply.js +7 -3
- package/dist/commands/wt/create-branch.js +40 -20
- package/dist/commands/wt/create-multi.js +13 -10
- package/dist/commands/wt/create-sandbox.js +63 -10
- package/dist/commands/wt/create.js +157 -57
- package/dist/commands/wt/delete.js +45 -17
- package/dist/commands/wt/enter.js +43 -31
- package/dist/commands/wt/exec.js +4 -17
- package/dist/commands/wt/list.js +29 -19
- package/dist/commands/wt/open.js +245 -0
- package/dist/commands/wt/path.js +4 -17
- package/dist/index.js +66 -25
- package/dist/lib/config.js +17 -4
- package/dist/lib/git.js +63 -13
- package/dist/lib/prompt.js +9 -0
- package/dist/lib/sandbox.js +19 -3
- package/dist/lib/ui.js +20 -0
- package/dist/lib/worktree.js +52 -0
- package/package.json +2 -1
- package/dist/commands/wt/leave.js +0 -13
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import { constants as fsConstants } from 'fs';
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
import { listWorktrees, getRepoRoot } from '../../lib/git.js';
|
|
8
|
+
import { log, ui } from '../../lib/ui.js';
|
|
9
|
+
import { ensureAutocompletePrompt } from '../../lib/prompt.js';
|
|
10
|
+
import { detectWorktreeType, findWorktreeByName, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, } from '../../lib/worktree.js';
|
|
11
|
+
import { enterCommand } from './enter.js';
|
|
12
|
+
const OPEN_TOOL_CANDIDATES = [
|
|
13
|
+
{ id: 'cursor', name: 'Cursor', command: 'cursor', kind: 'ide', aliases: ['cursor'] },
|
|
14
|
+
{ id: 'code', name: 'VS Code', command: 'code', kind: 'ide', aliases: ['vscode', 'code'] },
|
|
15
|
+
{ id: 'windsurf', name: 'Windsurf', command: 'windsurf', kind: 'ide', aliases: ['windsurf'] },
|
|
16
|
+
{ id: 'zed', name: 'Zed', command: 'zed', kind: 'ide', aliases: ['zed'] },
|
|
17
|
+
{ id: 'agy', name: 'Agy', command: 'agy', kind: 'ide', aliases: ['agy'] },
|
|
18
|
+
{ id: 'idea', name: 'IntelliJ IDEA', command: 'idea', kind: 'ide', aliases: ['idea', 'intellij'] },
|
|
19
|
+
{ id: 'webstorm', name: 'WebStorm', command: 'webstorm', kind: 'ide', aliases: ['webstorm'] },
|
|
20
|
+
{ id: 'subl', name: 'Sublime Text', command: 'subl', kind: 'ide', aliases: ['subl', 'sublime'] },
|
|
21
|
+
{ id: 'claude', name: 'Claude Code', command: 'claude', kind: 'agent', aliases: ['claude', 'claude-code'] },
|
|
22
|
+
{ id: 'codex', name: 'Codex', command: 'codex', kind: 'agent', aliases: ['codex'] },
|
|
23
|
+
{ id: 'gemini', name: 'Gemini CLI', command: 'gemini', kind: 'agent', aliases: ['gemini'] },
|
|
24
|
+
{ id: 'opencode', name: 'OpenCode', command: 'opencode', kind: 'agent', aliases: ['opencode'] },
|
|
25
|
+
];
|
|
26
|
+
function truncateEnd(value, maxLen) {
|
|
27
|
+
if (maxLen <= 0)
|
|
28
|
+
return '';
|
|
29
|
+
if (value.length <= maxLen)
|
|
30
|
+
return value;
|
|
31
|
+
if (maxLen <= 1)
|
|
32
|
+
return '…';
|
|
33
|
+
return `${value.slice(0, maxLen - 1)}…`;
|
|
34
|
+
}
|
|
35
|
+
function truncateStart(value, maxLen) {
|
|
36
|
+
if (maxLen <= 0)
|
|
37
|
+
return '';
|
|
38
|
+
if (value.length <= maxLen)
|
|
39
|
+
return value;
|
|
40
|
+
if (maxLen <= 1)
|
|
41
|
+
return '…';
|
|
42
|
+
return `…${value.slice(-(maxLen - 1))}`;
|
|
43
|
+
}
|
|
44
|
+
function formatWorktreeChoiceLabel(type, branchName, displayPath, terminalColumns) {
|
|
45
|
+
const maxRowWidth = Math.max(40, terminalColumns - 10);
|
|
46
|
+
const typeWidth = 8;
|
|
47
|
+
const availableWidth = Math.max(22, maxRowWidth - typeWidth);
|
|
48
|
+
const branchWidth = Math.min(44, Math.max(12, Math.floor(availableWidth * 0.55)));
|
|
49
|
+
const pathWidth = Math.max(10, availableWidth - branchWidth - 1);
|
|
50
|
+
const branchText = truncateEnd(branchName, branchWidth).padEnd(branchWidth);
|
|
51
|
+
const pathText = truncateStart(displayPath, pathWidth);
|
|
52
|
+
const typeText = formatWorktreeType(type);
|
|
53
|
+
return `${typeText} ${chalk.yellow(branchText)} ${chalk.cyan(pathText)}`;
|
|
54
|
+
}
|
|
55
|
+
export async function commandExists(command) {
|
|
56
|
+
if (!command)
|
|
57
|
+
return false;
|
|
58
|
+
const isWindows = process.platform === 'win32';
|
|
59
|
+
const pathEntries = (process.env.PATH || '')
|
|
60
|
+
.split(path.delimiter)
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
const extensions = isWindows
|
|
63
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT')
|
|
64
|
+
.split(';')
|
|
65
|
+
.map(ext => ext.toLowerCase())
|
|
66
|
+
: [''];
|
|
67
|
+
const hasPathSeparator = command.includes('/') || command.includes('\\');
|
|
68
|
+
const candidates = hasPathSeparator
|
|
69
|
+
? [command]
|
|
70
|
+
: pathEntries.flatMap(dir => extensions.map(ext => path.join(dir, `${command}${ext}`)));
|
|
71
|
+
const mode = isWindows ? fsConstants.F_OK : fsConstants.X_OK;
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
try {
|
|
74
|
+
await fs.access(candidate, mode);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Continue scanning
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
export async function detectInstalledOpenTools() {
|
|
84
|
+
const checks = await Promise.all(OPEN_TOOL_CANDIDATES.map(async (tool) => ({
|
|
85
|
+
tool,
|
|
86
|
+
exists: await commandExists(tool.command),
|
|
87
|
+
})));
|
|
88
|
+
return checks
|
|
89
|
+
.filter(check => check.exists)
|
|
90
|
+
.map(check => check.tool);
|
|
91
|
+
}
|
|
92
|
+
function resolveWorktreeByName(worktrees, wtName) {
|
|
93
|
+
return findWorktreeByName(worktrees, wtName);
|
|
94
|
+
}
|
|
95
|
+
export function resolveOpenToolOption(input, installed) {
|
|
96
|
+
const normalized = input.trim().toLowerCase();
|
|
97
|
+
return installed.find(tool => tool.id === normalized ||
|
|
98
|
+
tool.command.toLowerCase() === normalized ||
|
|
99
|
+
tool.aliases?.some(alias => alias.toLowerCase() === normalized));
|
|
100
|
+
}
|
|
101
|
+
export function isAgentTool(tool) {
|
|
102
|
+
return tool.kind === 'agent';
|
|
103
|
+
}
|
|
104
|
+
export function buildAgentExecCommand(tool) {
|
|
105
|
+
return tool.command;
|
|
106
|
+
}
|
|
107
|
+
export async function promptOpenToolSelection(installedTools, message = 'Select tool to open:') {
|
|
108
|
+
const ideChoices = installedTools
|
|
109
|
+
.filter(tool => tool.kind === 'ide')
|
|
110
|
+
.map(tool => ({
|
|
111
|
+
name: `${tool.name} ${chalk.dim(`(${tool.command})`)}`,
|
|
112
|
+
value: tool,
|
|
113
|
+
}));
|
|
114
|
+
const agentChoices = installedTools
|
|
115
|
+
.filter(tool => tool.kind === 'agent')
|
|
116
|
+
.map(tool => ({
|
|
117
|
+
name: `${tool.name} ${chalk.dim(`(${tool.command})`)}`,
|
|
118
|
+
value: tool,
|
|
119
|
+
}));
|
|
120
|
+
const choices = [];
|
|
121
|
+
if (ideChoices.length > 0) {
|
|
122
|
+
choices.push(new inquirer.Separator(chalk.dim('── IDEs ──')));
|
|
123
|
+
choices.push(...ideChoices);
|
|
124
|
+
}
|
|
125
|
+
if (agentChoices.length > 0) {
|
|
126
|
+
if (choices.length > 0)
|
|
127
|
+
choices.push(new inquirer.Separator(" "));
|
|
128
|
+
choices.push(new inquirer.Separator(chalk.dim('── Agent CLIs ──')));
|
|
129
|
+
choices.push(...agentChoices);
|
|
130
|
+
}
|
|
131
|
+
const { selectedTool } = await inquirer.prompt([
|
|
132
|
+
{
|
|
133
|
+
type: 'list',
|
|
134
|
+
name: 'selectedTool',
|
|
135
|
+
message,
|
|
136
|
+
choices,
|
|
137
|
+
loop: false,
|
|
138
|
+
pageSize: 10,
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
return selectedTool;
|
|
142
|
+
}
|
|
143
|
+
export async function launchOpenTool(tool, wtPath) {
|
|
144
|
+
if (isAgentTool(tool)) {
|
|
145
|
+
await enterCommand(wtPath, { exec: buildAgentExecCommand(tool) });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await execa(tool.command, [wtPath], {
|
|
149
|
+
cwd: wtPath,
|
|
150
|
+
stdio: 'ignore',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export async function openCommand(wtName, options = {}) {
|
|
154
|
+
try {
|
|
155
|
+
await getRepoRoot();
|
|
156
|
+
const worktrees = await listWorktrees();
|
|
157
|
+
const mainWorktreePath = worktrees[0]?.path || '';
|
|
158
|
+
if (worktrees.length === 0) {
|
|
159
|
+
log.info('No worktrees found.');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let targetWt;
|
|
163
|
+
if (wtName) {
|
|
164
|
+
targetWt = resolveWorktreeByName(worktrees, wtName);
|
|
165
|
+
if (!targetWt) {
|
|
166
|
+
log.error(`Worktree "${wtName}" not found.`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
ensureAutocompletePrompt();
|
|
172
|
+
const terminalColumns = process.stdout.columns || 100;
|
|
173
|
+
const candidates = await Promise.all(worktrees.map(async (wt) => {
|
|
174
|
+
const type = await detectWorktreeType(wt, mainWorktreePath);
|
|
175
|
+
const branchName = getWorktreeBranchName(wt);
|
|
176
|
+
const displayPath = formatWorktreeDisplayPath(wt.path);
|
|
177
|
+
const rawType = type.toLowerCase();
|
|
178
|
+
const rawDisplayPath = displayPath.toLowerCase();
|
|
179
|
+
const rawBranchName = branchName.toLowerCase();
|
|
180
|
+
return {
|
|
181
|
+
worktree: wt,
|
|
182
|
+
label: formatWorktreeChoiceLabel(type, branchName, displayPath, terminalColumns),
|
|
183
|
+
searchText: `${rawType} ${rawBranchName} ${rawDisplayPath}`,
|
|
184
|
+
};
|
|
185
|
+
}));
|
|
186
|
+
const { selectedWt } = await inquirer.prompt([
|
|
187
|
+
{
|
|
188
|
+
type: 'autocomplete',
|
|
189
|
+
name: 'selectedWt',
|
|
190
|
+
message: 'Select a worktree to open (type to filter):',
|
|
191
|
+
source: async (_answers, input = '') => {
|
|
192
|
+
const term = input.trim().toLowerCase();
|
|
193
|
+
const filtered = term
|
|
194
|
+
? candidates.filter(candidate => candidate.searchText.includes(term))
|
|
195
|
+
: candidates;
|
|
196
|
+
return filtered.map(candidate => ({
|
|
197
|
+
name: candidate.label,
|
|
198
|
+
value: candidate.worktree,
|
|
199
|
+
}));
|
|
200
|
+
},
|
|
201
|
+
pageSize: 10,
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
targetWt = selectedWt;
|
|
205
|
+
}
|
|
206
|
+
if (!targetWt) {
|
|
207
|
+
log.error('No worktree selected.');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const installedTools = await detectInstalledOpenTools();
|
|
211
|
+
let chosenTool;
|
|
212
|
+
const requestedTool = options.tool;
|
|
213
|
+
if (requestedTool) {
|
|
214
|
+
chosenTool = resolveOpenToolOption(requestedTool, installedTools);
|
|
215
|
+
if (!chosenTool && await commandExists(requestedTool)) {
|
|
216
|
+
chosenTool = {
|
|
217
|
+
id: 'custom',
|
|
218
|
+
name: requestedTool,
|
|
219
|
+
command: requestedTool,
|
|
220
|
+
kind: 'ide',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (!chosenTool) {
|
|
224
|
+
const available = installedTools.map(tool => tool.command).join(', ') || 'none detected';
|
|
225
|
+
log.error(`Tool "${requestedTool}" not found.`);
|
|
226
|
+
log.dim(`Detected tool commands: ${available}`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
if (installedTools.length === 0) {
|
|
232
|
+
log.error('No supported open tool command was detected in PATH.');
|
|
233
|
+
log.dim('Try: yggtree wt open --tool <command> (e.g. --tool cursor, --tool claude)');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
chosenTool = await promptOpenToolSelection(installedTools);
|
|
237
|
+
}
|
|
238
|
+
log.info(`Opening ${ui.path(targetWt.path)} in ${chalk.cyan(chosenTool.name)}...`);
|
|
239
|
+
await launchOpenTool(chosenTool, targetWt.path);
|
|
240
|
+
log.success(isAgentTool(chosenTool) ? 'Agent launched.' : 'IDE launched.');
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
log.error(error.message);
|
|
244
|
+
}
|
|
245
|
+
}
|
package/dist/commands/wt/path.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
|
-
import path from 'path';
|
|
3
2
|
import { listWorktrees, getRepoRoot } from '../../lib/git.js';
|
|
4
|
-
import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
5
3
|
import { log } from '../../lib/ui.js';
|
|
4
|
+
import { findWorktreeByName, formatWorktreeDisplayPath, getWorktreeBranchName } from '../../lib/worktree.js';
|
|
6
5
|
export async function pathCommand(wtName) {
|
|
7
6
|
try {
|
|
8
7
|
await getRepoRoot();
|
|
@@ -13,16 +12,7 @@ export async function pathCommand(wtName) {
|
|
|
13
12
|
}
|
|
14
13
|
let targetWt;
|
|
15
14
|
if (wtName) {
|
|
16
|
-
|
|
17
|
-
targetWt = worktrees.find(wt => {
|
|
18
|
-
const branchName = wt.branch || wt.HEAD || '';
|
|
19
|
-
const relativePath = path.relative(WORKTREES_ROOT, wt.path);
|
|
20
|
-
const basename = path.basename(wt.path);
|
|
21
|
-
return branchName === wtName ||
|
|
22
|
-
relativePath === wtName ||
|
|
23
|
-
wt.path === wtName ||
|
|
24
|
-
basename === wtName;
|
|
25
|
-
});
|
|
15
|
+
targetWt = findWorktreeByName(worktrees, wtName);
|
|
26
16
|
if (!targetWt) {
|
|
27
17
|
log.error(`Worktree "${wtName}" not found.`);
|
|
28
18
|
return;
|
|
@@ -31,11 +21,8 @@ export async function pathCommand(wtName) {
|
|
|
31
21
|
else {
|
|
32
22
|
// Interactive Selection
|
|
33
23
|
const choices = worktrees.map(wt => {
|
|
34
|
-
const branchName = wt
|
|
35
|
-
const
|
|
36
|
-
const displayPath = isManaged
|
|
37
|
-
? path.relative(WORKTREES_ROOT, wt.path)
|
|
38
|
-
: wt.path.replace(process.env.HOME || '', '~');
|
|
24
|
+
const branchName = getWorktreeBranchName(wt);
|
|
25
|
+
const displayPath = formatWorktreeDisplayPath(wt.path);
|
|
39
26
|
return {
|
|
40
27
|
name: `${branchName.padEnd(20)} ${displayPath}`,
|
|
41
28
|
value: wt
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
|
+
import chalk from 'chalk';
|
|
3
4
|
import { welcome, log } from './lib/ui.js';
|
|
4
5
|
import { listCommand } from './commands/wt/list.js';
|
|
5
6
|
import { createCommand } from './commands/wt/create.js';
|
|
@@ -12,9 +13,11 @@ import { pruneCommand } from './commands/wt/prune.js';
|
|
|
12
13
|
import { execCommand } from './commands/wt/exec.js';
|
|
13
14
|
import { enterCommand } from './commands/wt/enter.js';
|
|
14
15
|
import { pathCommand } from './commands/wt/path.js';
|
|
16
|
+
import { openCommand } from './commands/wt/open.js';
|
|
15
17
|
import { applyCommand } from './commands/wt/apply.js';
|
|
16
18
|
import { unapplyCommand } from './commands/wt/unapply.js';
|
|
17
19
|
import { getVersion } from './lib/version.js';
|
|
20
|
+
import { findSandboxRoot } from './lib/sandbox.js';
|
|
18
21
|
const program = new Command();
|
|
19
22
|
program
|
|
20
23
|
.name('yggtree')
|
|
@@ -23,30 +26,48 @@ program
|
|
|
23
26
|
.action(async () => {
|
|
24
27
|
// Interactive Menu if no command is provided
|
|
25
28
|
await welcome();
|
|
29
|
+
const isInSandbox = Boolean(await findSandboxRoot(process.cwd()));
|
|
30
|
+
const realmChoices = [
|
|
31
|
+
{ name: `🌿 Grow New Realm ${chalk.dim('(create worktree)')}`, value: 'create-smart' },
|
|
32
|
+
{ name: `🔀 Traverse to Another Realm ${chalk.dim('(checkout existing branch in new worktree)')}`, value: 'worktree-checkout' },
|
|
33
|
+
{ name: `🌳 Grow Many Realms ${chalk.dim('(create multiple worktrees)')}`, value: 'create-multi' },
|
|
34
|
+
{ name: `🧪 Forge Sandbox Realm ${chalk.dim('(create sandbox worktree)')}`, value: 'create-sandbox' },
|
|
35
|
+
{ name: `🗺️ Survey Realms ${chalk.dim('(list worktrees)')}`, value: 'list' },
|
|
36
|
+
{ name: `🧭 Open Realm in Tool ${chalk.dim('(open worktree in IDE/agent)')}`, value: 'open' },
|
|
37
|
+
{ name: `🪓 Fell a Realm ${chalk.dim('(delete worktree)')}`, value: 'delete' },
|
|
38
|
+
{ name: `🚀 Bless Realm Setup ${chalk.dim('(bootstrap worktree)')}`, value: 'bootstrap' },
|
|
39
|
+
{ name: `🧹 Prune Withered Realms ${chalk.dim('(prune stale worktrees)')}`, value: 'prune' },
|
|
40
|
+
{ name: `🐚 Cast a Command ${chalk.dim('(exec command in worktree)')}`, value: 'exec' },
|
|
41
|
+
{ name: `🚪 Enter Realm Shell ${chalk.dim('(enter worktree)')}`, value: 'enter' },
|
|
42
|
+
{ name: `📍 Reveal Realm Path ${chalk.dim('(show worktree path)')}`, value: 'path' },
|
|
43
|
+
];
|
|
44
|
+
const sandboxChoices = [
|
|
45
|
+
{ name: `✅ Graft Sandbox Changes ${chalk.dim('(apply sandbox changes)')}`, value: 'apply' },
|
|
46
|
+
{ name: `↩️ Undo Sandbox Graft ${chalk.dim('(unapply sandbox changes)')}`, value: 'unapply' },
|
|
47
|
+
];
|
|
48
|
+
const choices = isInSandbox
|
|
49
|
+
? [
|
|
50
|
+
...sandboxChoices,
|
|
51
|
+
new inquirer.Separator(),
|
|
52
|
+
...realmChoices,
|
|
53
|
+
new inquirer.Separator(),
|
|
54
|
+
{ name: `🚪 Leave Yggdrasil ${chalk.dim('(exit)')}`, value: 'exit' },
|
|
55
|
+
]
|
|
56
|
+
: [
|
|
57
|
+
...realmChoices,
|
|
58
|
+
new inquirer.Separator(),
|
|
59
|
+
...sandboxChoices,
|
|
60
|
+
new inquirer.Separator(),
|
|
61
|
+
{ name: `🚪 Leave Yggdrasil ${chalk.dim('(exit)')}`, value: 'exit' },
|
|
62
|
+
];
|
|
26
63
|
const { action } = await inquirer.prompt([
|
|
27
64
|
{
|
|
28
65
|
type: 'list',
|
|
29
66
|
name: 'action',
|
|
30
67
|
message: 'What would you like to do?',
|
|
31
68
|
loop: false,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{ name: '🌳 Create multiple worktrees', value: 'create-multi' },
|
|
35
|
-
{ name: '🧪 Create sandbox worktree', value: 'create-sandbox' },
|
|
36
|
-
// { name: '🌱 Create new worktree (Manual Slug)', value: 'create-slug' },
|
|
37
|
-
{ name: '📋 List worktrees', value: 'list' },
|
|
38
|
-
{ name: '🪓 Delete worktree', value: 'delete' },
|
|
39
|
-
{ name: '🚀 Bootstrap worktree', value: 'bootstrap' },
|
|
40
|
-
{ name: '🧹 Prune stale worktrees', value: 'prune' },
|
|
41
|
-
{ name: '🐚 Exec command', value: 'exec' },
|
|
42
|
-
{ name: '🚪 Enter worktree', value: 'enter' },
|
|
43
|
-
{ name: '📍 Get worktree path', value: 'path' },
|
|
44
|
-
new inquirer.Separator(),
|
|
45
|
-
{ name: '✅ Apply sandbox changes', value: 'apply' },
|
|
46
|
-
{ name: '↩️ Unapply sandbox changes', value: 'unapply' },
|
|
47
|
-
new inquirer.Separator(),
|
|
48
|
-
{ name: '🚪 Exit', value: 'exit' },
|
|
49
|
-
],
|
|
69
|
+
pageSize: 12,
|
|
70
|
+
choices,
|
|
50
71
|
},
|
|
51
72
|
]);
|
|
52
73
|
switch (action) {
|
|
@@ -56,12 +77,15 @@ program
|
|
|
56
77
|
case 'create-multi':
|
|
57
78
|
await createCommandMulti({ bootstrap: true });
|
|
58
79
|
break;
|
|
59
|
-
case '
|
|
80
|
+
case 'worktree-checkout':
|
|
60
81
|
await createCommand({ bootstrap: true });
|
|
61
82
|
break;
|
|
62
83
|
case 'list':
|
|
63
84
|
await listCommand();
|
|
64
85
|
break;
|
|
86
|
+
case 'open':
|
|
87
|
+
await openCommand();
|
|
88
|
+
break;
|
|
65
89
|
case 'delete':
|
|
66
90
|
await deleteCommand();
|
|
67
91
|
break;
|
|
@@ -97,8 +121,15 @@ program
|
|
|
97
121
|
// --- Worktree Commands ---
|
|
98
122
|
const wt = program.command('wt').description('Manage git worktrees');
|
|
99
123
|
wt.command('list')
|
|
100
|
-
.description('List all worktrees')
|
|
101
|
-
.
|
|
124
|
+
.description('List all repo-linked worktrees')
|
|
125
|
+
.option('--open', 'Open a worktree in an IDE/agent tool instead of listing')
|
|
126
|
+
.action(async (options) => {
|
|
127
|
+
if (options.open) {
|
|
128
|
+
await openCommand();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
await listCommand();
|
|
132
|
+
});
|
|
102
133
|
wt.command('create [branch]')
|
|
103
134
|
.description('Create a new worktree (Smart branch detection)')
|
|
104
135
|
.option('-b, --branch <name>', 'Branch name (e.g. feat/new-ui)')
|
|
@@ -122,8 +153,8 @@ wt.command('create-multi')
|
|
|
122
153
|
.action(async (options) => {
|
|
123
154
|
await createCommandMulti(options);
|
|
124
155
|
});
|
|
125
|
-
wt.command('
|
|
126
|
-
.description('Create a
|
|
156
|
+
wt.command('worktree-checkout [name] [ref]')
|
|
157
|
+
.description('Create a checkout-style worktree from an existing branch')
|
|
127
158
|
.option('-n, --name <slug>', 'Worktree name (slug)')
|
|
128
159
|
.option('-r, --ref <ref>', 'Existing branch or ref')
|
|
129
160
|
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
|
@@ -138,8 +169,17 @@ wt.command('create-slug [name] [ref]')
|
|
|
138
169
|
});
|
|
139
170
|
});
|
|
140
171
|
wt.command('delete')
|
|
141
|
-
.description('Delete
|
|
142
|
-
.
|
|
172
|
+
.description('Delete managed worktrees')
|
|
173
|
+
.option('-a, --all', 'Include repo-linked worktrees outside ~/.yggtree (except main/current)')
|
|
174
|
+
.action(async (options) => {
|
|
175
|
+
await deleteCommand(options);
|
|
176
|
+
});
|
|
177
|
+
wt.command('open [worktree]')
|
|
178
|
+
.description('Open a worktree in an IDE or agent CLI')
|
|
179
|
+
.option('--tool <command>', 'Tool command to use (e.g. cursor, code, claude, codex)')
|
|
180
|
+
.action(async (worktree, options) => {
|
|
181
|
+
await openCommand(worktree, options);
|
|
182
|
+
});
|
|
143
183
|
wt.command('bootstrap')
|
|
144
184
|
.description('Bootstrap dependencies in a worktree')
|
|
145
185
|
.action(bootstrapCommand);
|
|
@@ -167,6 +207,7 @@ wt.command('path [worktree]')
|
|
|
167
207
|
});
|
|
168
208
|
wt.command('create-sandbox')
|
|
169
209
|
.description('Create a sandbox worktree from current branch')
|
|
210
|
+
.option('-n, --name <name>', 'Optional sandbox name (auto-generated when omitted)')
|
|
170
211
|
.option('--carry', 'Carry uncommitted changes to sandbox')
|
|
171
212
|
.option('--no-carry', 'Do not carry uncommitted changes')
|
|
172
213
|
.option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
|
package/dist/lib/config.js
CHANGED
|
@@ -3,13 +3,26 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import { log, createSpinner } from './ui.js';
|
|
5
5
|
export async function getBootstrapCommands(repoRoot, wtPath) {
|
|
6
|
-
|
|
7
|
-
if
|
|
6
|
+
// repoRoot first (source of truth — where yggtree is run from)
|
|
7
|
+
// wtPath second (per-worktree override if needed)
|
|
8
|
+
const searchPaths = [repoRoot];
|
|
9
|
+
if (wtPath && wtPath !== repoRoot)
|
|
8
10
|
searchPaths.push(wtPath);
|
|
9
|
-
searchPaths.push(repoRoot);
|
|
10
11
|
for (const searchPath of searchPaths) {
|
|
12
|
+
const yggtreeConfigPath = path.join(searchPath, '.yggtree', 'worktree-setup.json');
|
|
11
13
|
const configPath = path.join(searchPath, 'yggtree-worktree.json');
|
|
12
14
|
const cursorConfigPath = path.join(searchPath, '.cursor', 'worktrees.json');
|
|
15
|
+
if (await fs.pathExists(yggtreeConfigPath)) {
|
|
16
|
+
try {
|
|
17
|
+
const config = await fs.readJSON(yggtreeConfigPath);
|
|
18
|
+
if (config['setup-worktree'] && Array.isArray(config['setup-worktree'])) {
|
|
19
|
+
return config['setup-worktree'];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
log.warning(`Failed to parse ${yggtreeConfigPath}.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
13
26
|
if (await fs.pathExists(configPath)) {
|
|
14
27
|
try {
|
|
15
28
|
const config = await fs.readJSON(configPath);
|
|
@@ -50,7 +63,7 @@ export async function runBootstrap(wtPath, repoRoot) {
|
|
|
50
63
|
spinner.fail(`Failed: ${cmd}`);
|
|
51
64
|
log.actionableError(e.message, cmd, wtPath, [
|
|
52
65
|
`Try running the command manually: cd ${wtPath} && ${cmd}`,
|
|
53
|
-
'Check your configuration in yggtree-worktree.json'
|
|
66
|
+
'Check your configuration in .yggtree/worktree-setup.json or yggtree-worktree.json'
|
|
54
67
|
]);
|
|
55
68
|
}
|
|
56
69
|
}
|
package/dist/lib/git.js
CHANGED
|
@@ -86,9 +86,12 @@ export async function isGitClean(cwd) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
89
|
+
* Safety net: ensures the branch does NOT track an incorrect upstream
|
|
90
|
+
* (e.g. origin/main when the branch is feat/new-thing).
|
|
91
|
+
*
|
|
92
|
+
* - Correct tracking (origin/<branchName>) → no-op
|
|
93
|
+
* - No tracking at all → no-op
|
|
94
|
+
* - Wrong tracking → unsets it
|
|
92
95
|
*/
|
|
93
96
|
export async function ensureCorrectUpstream(wtPath, branchName) {
|
|
94
97
|
const desiredUpstream = `origin/${branchName}`;
|
|
@@ -98,21 +101,68 @@ export async function ensureCorrectUpstream(wtPath, branchName) {
|
|
|
98
101
|
currentUpstream = stdout.trim();
|
|
99
102
|
}
|
|
100
103
|
catch {
|
|
101
|
-
// No upstream set
|
|
104
|
+
// No upstream set — safe
|
|
105
|
+
return;
|
|
102
106
|
}
|
|
103
107
|
if (currentUpstream === desiredUpstream) {
|
|
104
108
|
return; // Already correct
|
|
105
109
|
}
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
// Wrong tracking — kill it
|
|
111
|
+
log.warning(`Incorrect upstream detected: ${chalk.red(currentUpstream)} (expected ${chalk.cyan(desiredUpstream)}). Unsetting...`);
|
|
112
|
+
await execa('git', ['branch', '--unset-upstream'], { cwd: wtPath });
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Publishes a local branch to origin and sets upstream tracking.
|
|
116
|
+
* Skips if the branch is already published (origin/<branchName> exists and is tracked).
|
|
117
|
+
*/
|
|
118
|
+
export async function publishBranch(wtPath, branchName) {
|
|
119
|
+
const desiredUpstream = `origin/${branchName}`;
|
|
120
|
+
// Check if already tracking the correct remote
|
|
121
|
+
try {
|
|
122
|
+
const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: wtPath });
|
|
123
|
+
if (stdout.trim() === desiredUpstream) {
|
|
124
|
+
return; // Already published and tracked
|
|
125
|
+
}
|
|
110
126
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (remoteExists) {
|
|
114
|
-
throw new Error(`Remote branch '${desiredUpstream}' already exists. Cannot publish safely without more info. Use 'git push -u origin HEAD' manually if you want to link them.`);
|
|
127
|
+
catch {
|
|
128
|
+
// No upstream — expected for new branches
|
|
115
129
|
}
|
|
116
|
-
log.info(`Publishing
|
|
130
|
+
log.info(`Publishing ${chalk.cyan(branchName)} → ${chalk.cyan(desiredUpstream)}...`);
|
|
117
131
|
await execa('git', ['push', '-u', 'origin', 'HEAD'], { cwd: wtPath });
|
|
118
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Returns the most recent activity date for a worktree by checking two signals:
|
|
135
|
+
* 1. Last commit time (captures committed work)
|
|
136
|
+
* 2. Git index mtime (captures staging, checkouts, uncommitted work)
|
|
137
|
+
*
|
|
138
|
+
* The most recent of the two wins. Returns null if both fail.
|
|
139
|
+
*/
|
|
140
|
+
export async function getLastActivity(wtPath) {
|
|
141
|
+
const dates = [];
|
|
142
|
+
// Signal 1 — last commit epoch
|
|
143
|
+
try {
|
|
144
|
+
const { stdout } = await execa('git', ['log', '-1', '--format=%ct'], { cwd: wtPath });
|
|
145
|
+
const epoch = parseInt(stdout.trim(), 10);
|
|
146
|
+
if (!isNaN(epoch)) {
|
|
147
|
+
dates.push(new Date(epoch * 1000));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// no commits reachable — skip
|
|
152
|
+
}
|
|
153
|
+
// Signal 2 — git index file mtime
|
|
154
|
+
try {
|
|
155
|
+
const { stdout: gitDir } = await execa('git', ['rev-parse', '--git-dir'], { cwd: wtPath });
|
|
156
|
+
const gitDirPath = gitDir.trim();
|
|
157
|
+
const resolvedGitDir = path.isAbsolute(gitDirPath) ? gitDirPath : path.join(wtPath, gitDirPath);
|
|
158
|
+
const indexPath = path.join(resolvedGitDir, 'index');
|
|
159
|
+
const stat = await fs.stat(indexPath);
|
|
160
|
+
dates.push(stat.mtime);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// index not found — skip
|
|
164
|
+
}
|
|
165
|
+
if (dates.length === 0)
|
|
166
|
+
return null;
|
|
167
|
+
return new Date(Math.max(...dates.map(d => d.getTime())));
|
|
168
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import autocompletePrompt from 'inquirer-autocomplete-prompt';
|
|
3
|
+
let autocompleteRegistered = false;
|
|
4
|
+
export function ensureAutocompletePrompt() {
|
|
5
|
+
if (!autocompleteRegistered) {
|
|
6
|
+
inquirer.registerPrompt('autocomplete', autocompletePrompt);
|
|
7
|
+
autocompleteRegistered = true;
|
|
8
|
+
}
|
|
9
|
+
}
|
package/dist/lib/sandbox.js
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
-
export const
|
|
4
|
+
export const YGGTREE_DIR = '.yggtree';
|
|
5
|
+
export const SANDBOX_META_FILE = 'sandbox-meta.json';
|
|
5
6
|
/**
|
|
6
7
|
* Generate a sandbox worktree name: <branch>_<4-char-hash>
|
|
7
8
|
*/
|
|
8
9
|
export function generateSandboxName(branch) {
|
|
9
10
|
const hash = crypto.randomBytes(2).toString('hex'); // 4 hex chars
|
|
10
11
|
const safeBranch = branch.replace(/[\\/]/g, '-');
|
|
11
|
-
return
|
|
12
|
+
return `sandbox-${hash}_${safeBranch}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a custom sandbox name into a branch-safe slug.
|
|
16
|
+
* Keeps current sandbox prefix conventions for easier filtering.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeSandboxName(input) {
|
|
19
|
+
const cleaned = input
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/[\\/]/g, '-')
|
|
22
|
+
.replace(/\s+/g, '-')
|
|
23
|
+
.replace(/^-+|-+$/g, '');
|
|
24
|
+
if (!cleaned)
|
|
25
|
+
return '';
|
|
26
|
+
return cleaned.startsWith('sandbox-') ? cleaned : `sandbox-${cleaned}`;
|
|
12
27
|
}
|
|
13
28
|
/**
|
|
14
29
|
* Get the path to the sandbox metadata file
|
|
15
30
|
*/
|
|
16
31
|
export function getSandboxMetaPath(wtPath) {
|
|
17
|
-
return path.join(wtPath, SANDBOX_META_FILE);
|
|
32
|
+
return path.join(wtPath, YGGTREE_DIR, SANDBOX_META_FILE);
|
|
18
33
|
}
|
|
19
34
|
/**
|
|
20
35
|
* Write sandbox metadata to the worktree
|
|
21
36
|
*/
|
|
22
37
|
export async function writeSandboxMeta(wtPath, meta) {
|
|
23
38
|
const metaPath = getSandboxMetaPath(wtPath);
|
|
39
|
+
await fs.ensureDir(path.dirname(metaPath));
|
|
24
40
|
await fs.writeJSON(metaPath, meta, { spaces: 2 });
|
|
25
41
|
}
|
|
26
42
|
/**
|
package/dist/lib/ui.js
CHANGED
|
@@ -50,3 +50,23 @@ export const ui = {
|
|
|
50
50
|
code: (cmd) => chalk.bgBlack.white(` ${cmd} `),
|
|
51
51
|
path: (p) => chalk.cyan(p),
|
|
52
52
|
};
|
|
53
|
+
// --- Time helpers ---
|
|
54
|
+
/**
|
|
55
|
+
* Returns a human-friendly relative time string like "just now", "5 min ago", "3 days ago".
|
|
56
|
+
*/
|
|
57
|
+
export function timeAgo(date) {
|
|
58
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
59
|
+
if (seconds < 60)
|
|
60
|
+
return 'just now';
|
|
61
|
+
if (seconds < 3600)
|
|
62
|
+
return `${Math.floor(seconds / 60)} min ago`;
|
|
63
|
+
if (seconds < 86400)
|
|
64
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
65
|
+
if (seconds < 604800)
|
|
66
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
67
|
+
if (seconds < 2592000)
|
|
68
|
+
return `${Math.floor(seconds / 604800)}w ago`;
|
|
69
|
+
if (seconds < 31536000)
|
|
70
|
+
return `${Math.floor(seconds / 2592000)}mo ago`;
|
|
71
|
+
return `${Math.floor(seconds / 31536000)}y ago`;
|
|
72
|
+
}
|