yggtree 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch, ensure
5
5
  import { runBootstrap } from '../../lib/config.js';
6
6
  import { WORKTREES_ROOT } from '../../lib/paths.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
8
+ import { findLocalEnvFiles, promptAndCopyEnvFilesToWorktrees } from '../../lib/env-files.js';
8
9
  import { execa } from 'execa';
9
10
  import fs from 'fs-extra';
10
11
  export async function createCommandMulti(options) {
@@ -117,8 +118,16 @@ export async function createCommandMulti(options) {
117
118
  ]);
118
119
  }
119
120
  createdWorktrees.push(wtPath);
120
- // 4. Bootstrap
121
- if (shouldBootstrap) {
121
+ }
122
+ const envFiles = await findLocalEnvFiles(repoRoot);
123
+ if (envFiles.length > 0 && createdWorktrees.length > 0) {
124
+ await promptAndCopyEnvFilesToWorktrees(repoRoot, createdWorktrees, envFiles, {
125
+ promptMessage: `Copy local env file${envFiles.length === 1 ? '' : 's'} to all created worktrees? (${envFiles.join(', ')})`,
126
+ });
127
+ }
128
+ // 4. Bootstrap
129
+ if (shouldBootstrap) {
130
+ for (const wtPath of createdWorktrees) {
122
131
  await runBootstrap(wtPath, repoRoot);
123
132
  }
124
133
  }
@@ -6,6 +6,7 @@ import { runBootstrap } from '../../lib/config.js';
6
6
  import { WORKTREES_ROOT } from '../../lib/paths.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
8
8
  import { generateSandboxName, normalizeSandboxName, writeSandboxMeta } from '../../lib/sandbox.js';
9
+ import { promptAndCopyEnvFiles } from '../../lib/env-files.js';
9
10
  import { detectInstalledOpenTools, launchOpenTool, promptOpenToolSelection, } from './open.js';
10
11
  import { execa } from 'execa';
11
12
  import fs from 'fs-extra';
@@ -66,7 +67,7 @@ export async function createSandboxCommand(options = {}) {
66
67
  {
67
68
  type: 'confirm',
68
69
  name: 'shouldOpenTool',
69
- message: 'Open a tool after creation? (IDE or agent CLI)',
70
+ message: 'Open an editor after creation?',
70
71
  default: false,
71
72
  when: options.exec === undefined && options.open === undefined && options.enter === undefined,
72
73
  },
@@ -181,6 +182,7 @@ export async function createSandboxCommand(options = {}) {
181
182
  spinner.succeed(`Sandbox created: ${chalk.cyan(sandboxName)}`);
182
183
  log.dim('This is a local sandbox - no remote branch will be pushed.');
183
184
  // 8. Bootstrap
185
+ await promptAndCopyEnvFiles(repoRoot, wtPath);
184
186
  if (shouldBootstrap) {
185
187
  await runBootstrap(wtPath, repoRoot);
186
188
  }
@@ -1,13 +1,15 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import path from 'path';
4
- import { getRepoRoot, getRepoName, createWorktree, fetchAll, listWorktrees } from '../../lib/git.js';
4
+ import { getRepoName, createWorktree, fetchAll, listWorktrees } from '../../lib/git.js';
5
5
  import { runBootstrap } from '../../lib/config.js';
6
6
  import { WORKTREES_ROOT } from '../../lib/paths.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
8
8
  import { ensureAutocompletePrompt } from '../../lib/prompt.js';
9
+ import { promptAndCopyEnvFiles } from '../../lib/env-files.js';
10
+ import { ensureRepoContext } from '../../lib/repo-context.js';
9
11
  import { enterCommand } from './enter.js';
10
- import { detectInstalledOpenTools, launchOpenTool, promptOpenToolSelection, } from './open.js';
12
+ import { detectInstalledOpenTools, promptOpenActions, resolveOpenToolAction, runOpenActions, } from './open.js';
11
13
  import { execa } from 'execa';
12
14
  import fs from 'fs-extra';
13
15
  function toSlug(value) {
@@ -16,7 +18,7 @@ function toSlug(value) {
16
18
  .replace(/[\/\\]/g, '-')
17
19
  .replace(/\s+/g, '-');
18
20
  }
19
- async function listBranchCandidates() {
21
+ export async function listBranchCandidates() {
20
22
  const [localRefs, remoteRefs] = await Promise.all([
21
23
  execa('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
22
24
  execa('git', ['for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin']),
@@ -32,21 +34,26 @@ async function listBranchCandidates() {
32
34
  .filter(ref => ref !== 'origin/HEAD' && !ref.endsWith('/HEAD'))
33
35
  .map(ref => ref.replace(/^origin\//, ''));
34
36
  const localSet = new Set(localBranches);
35
- const remoteOnly = [...new Set(remoteBranches)].filter(branch => !localSet.has(branch));
37
+ const uniqueRemoteBranches = [...new Set(remoteBranches)];
36
38
  const localCandidates = localBranches.map(branchName => ({
37
39
  branchName,
38
40
  checkoutRef: branchName,
39
41
  createLocalBranch: false,
40
42
  source: 'local',
43
+ attachedBranchName: branchName,
44
+ sourceLabel: 'local',
41
45
  }));
42
- const remoteCandidates = remoteOnly.map(branchName => ({
46
+ const remoteCandidates = uniqueRemoteBranches.map(branchName => ({
43
47
  branchName,
44
48
  checkoutRef: `origin/${branchName}`,
45
- createLocalBranch: true,
49
+ createLocalBranch: !localSet.has(branchName),
46
50
  source: 'remote',
51
+ attachedBranchName: localSet.has(branchName) ? undefined : branchName,
52
+ sourceLabel: localSet.has(branchName) ? 'remote tip, detached' : 'remote, creates local branch',
47
53
  }));
48
54
  return [...localCandidates, ...remoteCandidates]
49
- .sort((a, b) => a.branchName.localeCompare(b.branchName));
55
+ .sort((a, b) => a.branchName.localeCompare(b.branchName) ||
56
+ a.checkoutRef.localeCompare(b.checkoutRef));
50
57
  }
51
58
  function resolveCandidateFromRef(ref, candidates) {
52
59
  const trimmedRef = ref.trim();
@@ -54,18 +61,47 @@ function resolveCandidateFromRef(ref, candidates) {
54
61
  return undefined;
55
62
  if (trimmedRef.startsWith('origin/')) {
56
63
  const branchName = trimmedRef.replace(/^origin\//, '');
57
- return candidates.find(candidate => candidate.branchName === branchName);
64
+ return candidates.find(candidate => candidate.branchName === branchName &&
65
+ candidate.checkoutRef === trimmedRef);
58
66
  }
59
67
  return candidates.find(candidate => candidate.branchName === trimmedRef ||
60
68
  candidate.checkoutRef === trimmedRef);
61
69
  }
70
+ export function findExistingBranchWorktree(worktrees, branchName) {
71
+ if (!branchName)
72
+ return undefined;
73
+ return worktrees.find(wt => wt.branch === branchName &&
74
+ !wt.prunable &&
75
+ fs.pathExistsSync(wt.path));
76
+ }
77
+ function normalizeExistingPath(value) {
78
+ try {
79
+ return fs.realpathSync(value);
80
+ }
81
+ catch {
82
+ return path.resolve(value);
83
+ }
84
+ }
85
+ function findWorktreeByPath(worktrees, wtPath) {
86
+ const normalizedPath = normalizeExistingPath(wtPath);
87
+ return worktrees.find(wt => normalizeExistingPath(wt.path) === normalizedPath);
88
+ }
89
+ export function getWorktreePathCollisionMessage(worktreeName, wtPath, worktrees) {
90
+ if (!fs.pathExistsSync(wtPath))
91
+ return undefined;
92
+ const occupyingWorktree = findWorktreeByPath(worktrees, wtPath);
93
+ if (occupyingWorktree?.branch) {
94
+ return `Worktree name "${worktreeName}" is already used by branch "${occupyingWorktree.branch}".`;
95
+ }
96
+ if (occupyingWorktree?.prunable) {
97
+ return `Worktree path exists but Git marks its metadata as prunable: ${occupyingWorktree.prunable}.`;
98
+ }
99
+ return 'Worktree path already exists.';
100
+ }
62
101
  export async function createCommand(options) {
63
102
  try {
64
- const repoRoot = await getRepoRoot();
103
+ const repoRoot = await ensureRepoContext();
65
104
  log.info(`Repo: ${chalk.dim(repoRoot)}`);
66
- if (options.enter !== undefined) {
67
- log.warning('`--enter` / `--no-enter` is deprecated. Use `--open` / `--no-open` instead.');
68
- }
69
105
  // 1. Load branches
70
106
  const loadingSpinner = createSpinner('Fetching branches...').start();
71
107
  await fetchAll();
@@ -96,10 +132,11 @@ export async function createCommand(options) {
96
132
  source: async (_answers, input = '') => {
97
133
  const term = input.trim().toLowerCase();
98
134
  const filtered = term
99
- ? candidates.filter(candidate => candidate.branchName.toLowerCase().includes(term))
135
+ ? candidates.filter(candidate => candidate.branchName.toLowerCase().includes(term) ||
136
+ candidate.checkoutRef.toLowerCase().includes(term))
100
137
  : candidates;
101
138
  return filtered.map(candidate => ({
102
- name: `${chalk.yellow(candidate.branchName)} ${chalk.dim(`(${candidate.source})`)}`,
139
+ name: `${chalk.yellow(candidate.checkoutRef)} ${chalk.dim(`(${candidate.sourceLabel})`)}`,
103
140
  value: candidate,
104
141
  }));
105
142
  },
@@ -113,44 +150,61 @@ export async function createCommand(options) {
113
150
  return;
114
151
  }
115
152
  const existingWorktrees = await listWorktrees();
116
- const existingManagedWorktree = existingWorktrees.find(wt => wt.branch === selectedBranch.branchName && wt.path.startsWith(WORKTREES_ROOT));
117
- if (existingManagedWorktree) {
118
- log.info(`Branch ${chalk.cyan(selectedBranch.branchName)} is already active in ${ui.path(existingManagedWorktree.path)}.`);
153
+ const existingBranchWorktree = findExistingBranchWorktree(existingWorktrees, selectedBranch.attachedBranchName);
154
+ const shouldEnterShell = options.enter !== false;
155
+ if (existingBranchWorktree) {
156
+ log.info(`Branch ${chalk.cyan(selectedBranch.branchName)} is already active in ${ui.path(existingBranchWorktree.path)}.`);
119
157
  if (options.exec && options.exec.trim()) {
120
158
  log.info('Reusing the existing worktree and running the requested command...');
121
- await enterCommand(existingManagedWorktree.path, { exec: options.exec });
159
+ await enterCommand(existingBranchWorktree.path, { exec: options.exec });
122
160
  return;
123
161
  }
124
- const shouldOpenExisting = options.open !== undefined
125
- ? options.open
126
- : options.enter !== undefined
127
- ? options.enter
162
+ const shouldOpenExisting = options.tool
163
+ ? true
164
+ : options.open !== undefined
165
+ ? options.open
128
166
  : (await inquirer.prompt([
129
167
  {
130
168
  type: 'confirm',
131
169
  name: 'shouldOpenTool',
132
- message: 'Open a tool in the existing worktree now? (IDE or agent CLI)',
170
+ message: shouldEnterShell
171
+ ? 'Open anything before entering the existing worktree shell?'
172
+ : 'Open anything in the existing worktree?',
133
173
  default: true,
134
174
  },
135
175
  ])).shouldOpenTool;
136
176
  if (!shouldOpenExisting) {
137
- log.header(`cd "${existingManagedWorktree.path}"`);
177
+ if (shouldEnterShell) {
178
+ await enterCommand(existingBranchWorktree.path);
179
+ return;
180
+ }
181
+ log.header(`cd "${existingBranchWorktree.path}"`);
138
182
  return;
139
183
  }
140
184
  const installedTools = await detectInstalledOpenTools();
141
- if (installedTools.length === 0) {
142
- log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
143
- log.header(`cd "${existingManagedWorktree.path}"`);
185
+ log.info('Opening existing worktree instead of creating a duplicate...');
186
+ const actions = options.tool
187
+ ? [await resolveOpenToolAction(options.tool, installedTools)].filter((action) => Boolean(action))
188
+ : await promptOpenActions(installedTools, {
189
+ allowShellAction: shouldEnterShell,
190
+ message: shouldEnterShell
191
+ ? 'Open anything before entering the existing worktree shell?'
192
+ : 'Open anything in the existing worktree?',
193
+ });
194
+ if (options.tool && actions.length === 0) {
195
+ const available = installedTools.map(tool => tool.command).join(', ') || 'none detected';
196
+ log.error(`Tool "${options.tool}" not found.`);
197
+ log.dim(`Detected tool commands: ${available}`);
144
198
  return;
145
199
  }
146
- const selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open in the existing worktree:');
147
- log.info('Opening existing worktree instead of creating a duplicate...');
148
- await launchOpenTool(selectedTool, existingManagedWorktree.path);
200
+ await runOpenActions(actions, existingBranchWorktree.path, { enter: shouldEnterShell });
149
201
  log.success('Existing worktree opened.');
150
202
  return;
151
203
  }
152
204
  // 3. Gather remaining inputs
153
205
  const defaultSlug = toSlug(selectedBranch.branchName);
206
+ const repoName = await getRepoName();
207
+ const resolveWorktreePath = (name) => path.join(WORKTREES_ROOT, repoName, toSlug(name));
154
208
  const answers = await inquirer.prompt([
155
209
  {
156
210
  type: 'input',
@@ -158,7 +212,12 @@ export async function createCommand(options) {
158
212
  message: 'Worktree name (slug):',
159
213
  default: options.name || defaultSlug,
160
214
  when: !options.name,
161
- validate: (input) => input.trim().length > 0 || 'Name is required',
215
+ validate: (input) => {
216
+ const slug = toSlug(input);
217
+ if (!slug)
218
+ return 'Name is required';
219
+ return getWorktreePathCollisionMessage(slug, resolveWorktreePath(input), existingWorktrees) || true;
220
+ },
162
221
  },
163
222
  {
164
223
  type: 'confirm',
@@ -170,34 +229,56 @@ export async function createCommand(options) {
170
229
  {
171
230
  type: 'confirm',
172
231
  name: 'shouldOpenTool',
173
- message: 'Open a tool after creation? (IDE or agent CLI)',
232
+ message: shouldEnterShell
233
+ ? 'Open anything before entering the worktree shell?'
234
+ : 'Open anything after creation?',
174
235
  default: false,
175
- when: options.exec === undefined && options.open === undefined && options.enter === undefined,
236
+ when: options.exec === undefined && options.open === undefined && options.tool === undefined,
176
237
  },
177
238
  ]);
178
239
  const name = options.name || answers.name;
240
+ const slug = toSlug(name);
241
+ const wtPath = resolveWorktreePath(name);
242
+ // 4. Validation
243
+ if (!slug)
244
+ throw new Error('Invalid name');
245
+ const pathCollisionMessage = getWorktreePathCollisionMessage(slug, wtPath, existingWorktrees);
246
+ if (pathCollisionMessage) {
247
+ log.actionableError(pathCollisionMessage, `yggtree wc --name ${slug}`, wtPath, [
248
+ 'Choose a different worktree name with --name',
249
+ `Inspect the occupying path: ls ${wtPath}`,
250
+ 'Use yggtree wt prune only if Git reports stale worktree metadata',
251
+ ]);
252
+ return;
253
+ }
179
254
  const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
180
- const shouldOpenTool = options.open !== undefined
181
- ? options.open
182
- : options.enter !== undefined
183
- ? options.enter
255
+ const shouldOpenTool = options.tool
256
+ ? true
257
+ : options.open !== undefined
258
+ ? options.open
184
259
  : Boolean(answers.shouldOpenTool);
185
- let selectedTool;
260
+ let openActions = [];
186
261
  if (options.exec === undefined && shouldOpenTool) {
187
262
  const installedTools = await detectInstalledOpenTools();
188
- if (installedTools.length === 0) {
189
- log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
263
+ if (options.tool) {
264
+ const toolAction = await resolveOpenToolAction(options.tool, installedTools);
265
+ if (!toolAction) {
266
+ const available = installedTools.map(tool => tool.command).join(', ') || 'none detected';
267
+ log.error(`Tool "${options.tool}" not found.`);
268
+ log.dim(`Detected tool commands: ${available}`);
269
+ return;
270
+ }
271
+ openActions = [toolAction];
190
272
  }
191
273
  else {
192
- selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
274
+ openActions = await promptOpenActions(installedTools, {
275
+ allowShellAction: shouldEnterShell,
276
+ message: shouldEnterShell
277
+ ? 'Open anything before entering the worktree shell?'
278
+ : 'Open anything after creation?',
279
+ });
193
280
  }
194
281
  }
195
- const slug = toSlug(name);
196
- const repoName = await getRepoName();
197
- const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
198
- // 4. Validation
199
- if (!slug)
200
- throw new Error('Invalid name');
201
282
  // 5. Execution (checkout-style: attach to selected branch)
202
283
  const spinner = createSpinner(`Creating worktree at ${ui.path(wtPath)}...`).start();
203
284
  try {
@@ -223,6 +304,7 @@ export async function createCommand(options) {
223
304
  ]);
224
305
  return;
225
306
  }
307
+ await promptAndCopyEnvFiles(repoRoot, wtPath);
226
308
  if (shouldBootstrap) {
227
309
  await runBootstrap(wtPath, repoRoot);
228
310
  }
@@ -243,13 +325,12 @@ export async function createCommand(options) {
243
325
  ]);
244
326
  }
245
327
  }
246
- else if (selectedTool) {
328
+ else {
247
329
  try {
248
- log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
249
- await launchOpenTool(selectedTool, wtPath);
330
+ await runOpenActions(openActions, wtPath, { enter: shouldEnterShell });
250
331
  }
251
332
  catch (error) {
252
- log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
333
+ log.warning(`Could not complete post-checkout action: ${error.message}`);
253
334
  }
254
335
  }
255
336
  // 7. Final Output
@@ -2,10 +2,10 @@ import inquirer from 'inquirer';
2
2
  import { spawn } from 'child_process';
3
3
  import { execa } from 'execa';
4
4
  import chalk from 'chalk';
5
- import { listWorktrees, getRepoRoot } from '../../lib/git.js';
5
+ import { listWorktrees } from '../../lib/git.js';
6
6
  import { log, ui } from '../../lib/ui.js';
7
+ import { ensureRepoContext } from '../../lib/repo-context.js';
7
8
  import { detectWorktreeType, findWorktreeByName, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, } from '../../lib/worktree.js';
8
- import { getValidRegisteredRepos } from '../../lib/registry.js';
9
9
  function truncateEnd(value, maxLen) {
10
10
  if (maxLen <= 0)
11
11
  return '';
@@ -37,31 +37,7 @@ function formatChoiceLabel(type, branchName, displayPath, terminalColumns) {
37
37
  }
38
38
  export async function enterCommand(wtName, options = {}) {
39
39
  try {
40
- try {
41
- await getRepoRoot();
42
- }
43
- catch {
44
- const validRepos = await getValidRegisteredRepos();
45
- const repoNames = Object.keys(validRepos);
46
- if (repoNames.length === 0) {
47
- log.error('Not inside a git repository and no registered realms found.');
48
- log.dim('Run `yggtree` inside an existing git project first to register it.');
49
- process.exit(1);
50
- }
51
- console.log(chalk.bold('\n Not inside a realm. Pick a known one:'));
52
- const { selectedRepoName } = await inquirer.prompt([{
53
- type: 'list',
54
- name: 'selectedRepoName',
55
- message: 'Select a realm:',
56
- choices: Object.entries(validRepos).map(([name, rPath]) => ({
57
- name: `${chalk.bold.yellow(name)} ${chalk.dim(formatWorktreeDisplayPath(rPath))}`,
58
- value: name,
59
- })),
60
- pageSize: 10,
61
- }]);
62
- const repoPath = validRepos[selectedRepoName];
63
- process.chdir(repoPath);
64
- }
40
+ await ensureRepoContext();
65
41
  const worktrees = await listWorktrees();
66
42
  const mainWorktreePath = worktrees[0]?.path || '';
67
43
  if (worktrees.length === 0) {
@@ -0,0 +1,7 @@
1
+ import { createSandboxCommand } from './create-sandbox.js';
2
+ export async function handoffCommand(options = {}) {
3
+ await createSandboxCommand({
4
+ ...options,
5
+ carry: true,
6
+ });
7
+ }