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.
@@ -1,48 +1,138 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import path from 'path';
4
- import { getRepoRoot, getRepoName, verifyRef, createWorktree, fetchAll, getCurrentBranch } from '../../lib/git.js';
4
+ import { getRepoRoot, 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
+ import { ensureAutocompletePrompt } from '../../lib/prompt.js';
9
+ import { enterCommand } from './enter.js';
10
+ import { buildAgentExecCommand, detectInstalledOpenTools, isAgentTool, launchOpenTool, promptOpenToolSelection, } from './open.js';
8
11
  import { execa } from 'execa';
9
12
  import { spawn } from 'child_process';
10
13
  import fs from 'fs-extra';
14
+ function toSlug(value) {
15
+ return value
16
+ .trim()
17
+ .replace(/[\/\\]/g, '-')
18
+ .replace(/\s+/g, '-');
19
+ }
20
+ async function listBranchCandidates() {
21
+ const [localRefs, remoteRefs] = await Promise.all([
22
+ execa('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
23
+ execa('git', ['for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin']),
24
+ ]);
25
+ const localBranches = localRefs.stdout
26
+ .split('\n')
27
+ .map(line => line.trim())
28
+ .filter(Boolean);
29
+ const remoteBranches = remoteRefs.stdout
30
+ .split('\n')
31
+ .map(line => line.trim())
32
+ .filter(Boolean)
33
+ .filter(ref => ref !== 'origin/HEAD' && !ref.endsWith('/HEAD'))
34
+ .map(ref => ref.replace(/^origin\//, ''));
35
+ const localSet = new Set(localBranches);
36
+ const remoteOnly = [...new Set(remoteBranches)].filter(branch => !localSet.has(branch));
37
+ const localCandidates = localBranches.map(branchName => ({
38
+ branchName,
39
+ checkoutRef: branchName,
40
+ createLocalBranch: false,
41
+ source: 'local',
42
+ }));
43
+ const remoteCandidates = remoteOnly.map(branchName => ({
44
+ branchName,
45
+ checkoutRef: `origin/${branchName}`,
46
+ createLocalBranch: true,
47
+ source: 'remote',
48
+ }));
49
+ return [...localCandidates, ...remoteCandidates]
50
+ .sort((a, b) => a.branchName.localeCompare(b.branchName));
51
+ }
52
+ function resolveCandidateFromRef(ref, candidates) {
53
+ const trimmedRef = ref.trim();
54
+ if (!trimmedRef)
55
+ return undefined;
56
+ if (trimmedRef.startsWith('origin/')) {
57
+ const branchName = trimmedRef.replace(/^origin\//, '');
58
+ return candidates.find(candidate => candidate.branchName === branchName);
59
+ }
60
+ return candidates.find(candidate => candidate.branchName === trimmedRef ||
61
+ candidate.checkoutRef === trimmedRef);
62
+ }
11
63
  export async function createCommand(options) {
12
64
  try {
13
65
  const repoRoot = await getRepoRoot();
14
66
  log.info(`Repo: ${chalk.dim(repoRoot)}`);
15
- // 1. Gather inputs
16
- const currentBranch = await getCurrentBranch();
67
+ // 1. Load branches
68
+ const loadingSpinner = createSpinner('Fetching branches...').start();
69
+ await fetchAll();
70
+ const candidates = await listBranchCandidates();
71
+ if (candidates.length === 0) {
72
+ loadingSpinner.fail('No branches found.');
73
+ log.warning('Create a branch first, then run worktree-checkout again.');
74
+ return;
75
+ }
76
+ loadingSpinner.succeed(`Loaded ${candidates.length} branches.`);
77
+ // 2. Select branch
78
+ ensureAutocompletePrompt();
79
+ let selectedBranch;
80
+ if (options.ref) {
81
+ selectedBranch = resolveCandidateFromRef(options.ref, candidates);
82
+ if (!selectedBranch) {
83
+ log.error(`Branch "${options.ref}" not found.`);
84
+ log.warning('Tip: use a local branch name or origin/<branch>.');
85
+ return;
86
+ }
87
+ }
88
+ else {
89
+ const { branchChoice } = await inquirer.prompt([
90
+ {
91
+ type: 'autocomplete',
92
+ name: 'branchChoice',
93
+ message: 'Select branch (type to filter):',
94
+ source: async (_answers, input = '') => {
95
+ const term = input.trim().toLowerCase();
96
+ const filtered = term
97
+ ? candidates.filter(candidate => candidate.branchName.toLowerCase().includes(term))
98
+ : candidates;
99
+ return filtered.map(candidate => ({
100
+ name: `${chalk.yellow(candidate.branchName)} ${chalk.dim(`(${candidate.source})`)}`,
101
+ value: candidate,
102
+ }));
103
+ },
104
+ pageSize: 10,
105
+ },
106
+ ]);
107
+ selectedBranch = branchChoice;
108
+ }
109
+ if (!selectedBranch) {
110
+ log.error('No branch selected.');
111
+ return;
112
+ }
113
+ const existingWorktrees = await listWorktrees();
114
+ const existingManagedWorktree = existingWorktrees.find(wt => wt.branch === selectedBranch.branchName && wt.path.startsWith(WORKTREES_ROOT));
115
+ if (existingManagedWorktree) {
116
+ log.info(`Branch ${chalk.cyan(selectedBranch.branchName)} is already active in ${ui.path(existingManagedWorktree.path)}.`);
117
+ if (options.enter === false) {
118
+ log.header(`cd "${existingManagedWorktree.path}"`);
119
+ return;
120
+ }
121
+ log.info('Opening existing worktree instead of creating a duplicate...');
122
+ await enterCommand(existingManagedWorktree.path, { exec: options.exec ?? '' });
123
+ return;
124
+ }
125
+ // 3. Gather remaining inputs
126
+ const defaultSlug = toSlug(selectedBranch.branchName);
17
127
  const answers = await inquirer.prompt([
18
128
  {
19
129
  type: 'input',
20
130
  name: 'name',
21
131
  message: 'Worktree name (slug):',
22
- default: options.name,
132
+ default: options.name || defaultSlug,
23
133
  when: !options.name,
24
134
  validate: (input) => input.trim().length > 0 || 'Name is required',
25
135
  },
26
- {
27
- type: 'input',
28
- name: 'ref',
29
- message: 'Base branch name:',
30
- default: options.ref || currentBranch,
31
- when: !options.ref,
32
- validate: (input) => input.trim().length > 0 || '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.ref && !options.source,
45
- },
46
136
  {
47
137
  type: 'confirm',
48
138
  name: 'bootstrap',
@@ -58,61 +148,62 @@ export async function createCommand(options) {
58
148
  when: options.enter === undefined,
59
149
  },
60
150
  {
61
- type: 'input',
62
- name: 'exec',
63
- message: 'Command to run after creation (optional):',
64
- default: options.exec,
151
+ type: 'confirm',
152
+ name: 'shouldOpenTool',
153
+ message: 'Open a tool after creation? (IDE or agent CLI)',
154
+ default: false,
65
155
  when: options.exec === undefined,
66
- }
156
+ },
67
157
  ]);
68
158
  const name = options.name || answers.name;
69
- let ref = options.ref || answers.ref;
70
- const source = options.source || answers.source;
71
159
  const shouldEnter = options.enter !== undefined ? options.enter : answers.shouldEnter;
72
160
  const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
73
- const execCommandStr = options.exec || answers.exec;
74
- // Append origin/ if remote is selected and not already present
75
- if (!options.ref && source === 'remote' && !ref.startsWith('origin/')) {
76
- ref = `origin/${ref}`;
161
+ let selectedTool;
162
+ if (options.exec === undefined && answers.shouldOpenTool) {
163
+ const installedTools = await detectInstalledOpenTools();
164
+ if (installedTools.length === 0) {
165
+ log.warning('No IDE/agent tool detected in PATH. Skipping open step.');
166
+ }
167
+ else {
168
+ selectedTool = await promptOpenToolSelection(installedTools, 'Select tool to open:');
169
+ }
77
170
  }
78
- const slug = name.replace(/\s+/g, '-');
171
+ const execCommandStr = options.exec || (selectedTool && isAgentTool(selectedTool) ? buildAgentExecCommand(selectedTool) : undefined);
172
+ const slug = toSlug(name);
79
173
  const repoName = await getRepoName();
80
174
  const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
81
- // 2. Validation
175
+ // 4. Validation
82
176
  if (!slug)
83
177
  throw new Error('Invalid name');
84
- if (!ref)
85
- throw new Error('Invalid ref');
86
- // 3. Execution
87
- const spinner = createSpinner('Fetching...').start();
88
- await fetchAll();
89
- spinner.text = 'Verifying ref...';
90
- const exists = await verifyRef(ref);
91
- if (!exists) {
92
- spinner.fail(`Ref not found: ${ref}`);
93
- log.warning(`Tip: try 'origin/${ref}' or check if the branch exists.`);
94
- return;
95
- }
96
- spinner.text = `Creating worktree at ${ui.path(wtPath)}...`;
178
+ // 5. Execution (checkout-style: attach to selected branch)
179
+ const spinner = createSpinner(`Creating worktree at ${ui.path(wtPath)}...`).start();
97
180
  try {
98
181
  await fs.ensureDir(path.dirname(wtPath));
99
- await createWorktree(wtPath, ref);
182
+ if (selectedBranch.createLocalBranch) {
183
+ await execa('git', ['worktree', 'add', '-b', selectedBranch.branchName, wtPath, selectedBranch.checkoutRef]);
184
+ }
185
+ else {
186
+ await createWorktree(wtPath, selectedBranch.checkoutRef);
187
+ }
100
188
  spinner.succeed('Worktree created.');
101
189
  }
102
190
  catch (e) {
103
191
  spinner.fail('Failed to create worktree.');
104
- log.actionableError(e.message, `git worktree add ${wtPath} ${ref}`, wtPath, [
192
+ const command = selectedBranch.createLocalBranch
193
+ ? `git worktree add -b ${selectedBranch.branchName} ${wtPath} ${selectedBranch.checkoutRef}`
194
+ : `git worktree add ${wtPath} ${selectedBranch.checkoutRef}`;
195
+ log.actionableError(e.message, command, wtPath, [
105
196
  'Check if the folder already exists: ls ' + wtPath,
106
- 'Check if the branch is already used: git worktree list',
197
+ `Check if branch "${selectedBranch.branchName}" is already checked out in another worktree: git worktree list`,
107
198
  'Try pruning stale worktrees: yggtree wt prune',
108
- `Run manually: git worktree add ${wtPath} ${ref}`
199
+ `Run manually: ${command}`
109
200
  ]);
110
201
  return;
111
202
  }
112
203
  if (shouldBootstrap) {
113
204
  await runBootstrap(wtPath, repoRoot);
114
205
  }
115
- // 5. Exec Command
206
+ // 6. Exec Command
116
207
  if (execCommandStr && execCommandStr.trim()) {
117
208
  log.info(`Executing: ${execCommandStr} in ${ui.path(wtPath)}`);
118
209
  try {
@@ -129,7 +220,16 @@ export async function createCommand(options) {
129
220
  ]);
130
221
  }
131
222
  }
132
- // 6. Final Output
223
+ if (selectedTool && !isAgentTool(selectedTool)) {
224
+ try {
225
+ log.info(`Opening ${ui.path(wtPath)} in ${chalk.cyan(selectedTool.name)}...`);
226
+ await launchOpenTool(selectedTool, wtPath);
227
+ }
228
+ catch (error) {
229
+ log.warning(`Could not open ${selectedTool.name}: ${error.message}`);
230
+ }
231
+ }
232
+ // 7. Final Output
133
233
  log.success('Worktree ready!');
134
234
  if (shouldEnter) {
135
235
  log.info(`Spawning sub-shell in ${ui.path(wtPath)}...`);
@@ -148,7 +248,7 @@ export async function createCommand(options) {
148
248
  }
149
249
  }
150
250
  catch (error) {
151
- log.actionableError(error.message, 'yggtree wt create');
251
+ log.actionableError(error.message, 'yggtree wt worktree-checkout');
152
252
  process.exit(1);
153
253
  }
154
254
  }
@@ -1,32 +1,60 @@
1
1
  import chalk from 'chalk';
2
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() {
3
+ import { listWorktrees, removeWorktree, getRepoRoot, getLastActivity } from '../../lib/git.js';
4
+ import { log, createSpinner, timeAgo } from '../../lib/ui.js';
5
+ import { detectWorktreeType, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, isManagedWorktreePath, } from '../../lib/worktree.js';
6
+ export async function deleteCommand(options = {}) {
8
7
  try {
9
- const _ = await getRepoRoot();
8
+ const currentWorktreePath = await getRepoRoot();
10
9
  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.');
10
+ let showAll = options.all;
11
+ if (showAll === undefined) {
12
+ const { includeExternal } = await inquirer.prompt([
13
+ {
14
+ type: 'confirm',
15
+ name: 'includeExternal',
16
+ message: 'Include external linked worktrees (outside ~/.yggtree)?',
17
+ default: false,
18
+ },
19
+ ]);
20
+ showAll = includeExternal;
21
+ }
22
+ const includeAll = Boolean(showAll);
23
+ const mainWorktreePath = worktrees[0]?.path;
24
+ const deletableWts = worktrees.filter(wt => {
25
+ if (includeAll) {
26
+ const isMainWorktree = wt.path === mainWorktreePath;
27
+ const isCurrentWorktree = wt.path === currentWorktreePath;
28
+ return !isMainWorktree && !isCurrentWorktree;
29
+ }
30
+ return isManagedWorktreePath(wt.path);
31
+ });
32
+ if (deletableWts.length === 0) {
33
+ log.info(includeAll
34
+ ? 'No deletable linked worktrees found.'
35
+ : 'No managed worktrees found to delete. Use "yggtree wt delete --all" to include external linked worktrees.');
15
36
  return;
16
37
  }
17
- const choices = managedWts.map(wt => {
18
- const relative = path.relative(WORKTREES_ROOT, wt.path);
38
+ const choices = await Promise.all(deletableWts.map(async (wt) => {
39
+ const [activity, type] = await Promise.all([
40
+ getLastActivity(wt.path),
41
+ detectWorktreeType(wt, mainWorktreePath || ''),
42
+ ]);
43
+ const branchName = getWorktreeBranchName(wt);
44
+ const active = activity ? chalk.magenta(timeAgo(activity)) : chalk.dim('—');
45
+ const displayPath = formatWorktreeDisplayPath(wt.path);
19
46
  return {
20
- name: `${chalk.bold(relative)} (${chalk.dim(wt.branch || wt.HEAD)})`,
47
+ name: `${formatWorktreeType(type)} ${chalk.bold.yellow(branchName)} ${chalk.dim('·')} ${active} ${chalk.dim('·')} ${chalk.dim(displayPath)}`,
21
48
  value: wt.path,
22
49
  };
23
- });
50
+ }));
24
51
  const { selectedPaths } = await inquirer.prompt([
25
52
  {
26
53
  type: 'checkbox',
27
54
  name: 'selectedPaths',
28
- message: 'Select worktrees to delete:',
55
+ message: includeAll ? 'Select worktrees to delete:' : 'Select managed worktrees to delete:',
29
56
  choices: choices,
57
+ pageSize: 10,
30
58
  },
31
59
  ]);
32
60
  if (!selectedPaths || selectedPaths.length === 0) {
@@ -34,7 +62,7 @@ export async function deleteCommand() {
34
62
  return;
35
63
  }
36
64
  const count = selectedPaths.length;
37
- const names = selectedPaths.map((p) => path.relative(WORKTREES_ROOT, p));
65
+ const names = selectedPaths.map((p) => formatWorktreeDisplayPath(p));
38
66
  const { confirm } = await inquirer.prompt([
39
67
  {
40
68
  type: 'confirm',
@@ -48,7 +76,7 @@ export async function deleteCommand() {
48
76
  return;
49
77
  }
50
78
  for (const wtPath of selectedPaths) {
51
- const worktreeName = path.relative(WORKTREES_ROOT, wtPath);
79
+ const worktreeName = formatWorktreeDisplayPath(wtPath);
52
80
  const spinner = createSpinner(`Deleting ${worktreeName}...`).start();
53
81
  try {
54
82
  await removeWorktree(wtPath);
@@ -1,30 +1,51 @@
1
1
  import inquirer from 'inquirer';
2
2
  import { spawn } from 'child_process';
3
3
  import { execa } from 'execa';
4
- import path from 'path';
4
+ import chalk from 'chalk';
5
5
  import { listWorktrees, getRepoRoot } from '../../lib/git.js';
6
- import { WORKTREES_ROOT } from '../../lib/paths.js';
7
6
  import { log, ui } from '../../lib/ui.js';
7
+ import { detectWorktreeType, findWorktreeByName, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, } from '../../lib/worktree.js';
8
+ function truncateEnd(value, maxLen) {
9
+ if (maxLen <= 0)
10
+ return '';
11
+ if (value.length <= maxLen)
12
+ return value;
13
+ if (maxLen <= 1)
14
+ return '…';
15
+ return `${value.slice(0, maxLen - 1)}…`;
16
+ }
17
+ function truncateStart(value, maxLen) {
18
+ if (maxLen <= 0)
19
+ return '';
20
+ if (value.length <= maxLen)
21
+ return value;
22
+ if (maxLen <= 1)
23
+ return '…';
24
+ return `…${value.slice(-(maxLen - 1))}`;
25
+ }
26
+ function formatChoiceLabel(type, branchName, displayPath, terminalColumns) {
27
+ const maxRowWidth = Math.max(40, terminalColumns - 10);
28
+ const typeWidth = 8;
29
+ const availableWidth = Math.max(22, maxRowWidth - typeWidth);
30
+ const branchWidth = Math.min(44, Math.max(12, Math.floor(availableWidth * 0.55)));
31
+ const pathWidth = Math.max(10, availableWidth - branchWidth - 1);
32
+ const branchText = truncateEnd(branchName, branchWidth).padEnd(branchWidth);
33
+ const pathText = truncateStart(displayPath, pathWidth);
34
+ const typeText = formatWorktreeType(type);
35
+ return `${typeText} ${chalk.yellow(branchText)} ${chalk.cyan(pathText)}`;
36
+ }
8
37
  export async function enterCommand(wtName, options = {}) {
9
38
  try {
10
39
  await getRepoRoot();
11
40
  const worktrees = await listWorktrees();
41
+ const mainWorktreePath = worktrees[0]?.path || '';
12
42
  if (worktrees.length === 0) {
13
43
  log.info('No worktrees found.');
14
44
  return;
15
45
  }
16
46
  let targetWt;
17
47
  if (wtName) {
18
- // Find worktree by name (branch name, relative path, or slug/basename)
19
- targetWt = worktrees.find(wt => {
20
- const branchName = wt.branch || wt.HEAD || '';
21
- const relativePath = path.relative(WORKTREES_ROOT, wt.path);
22
- const basename = path.basename(wt.path);
23
- return branchName === wtName ||
24
- relativePath === wtName ||
25
- wt.path === wtName ||
26
- basename === wtName;
27
- });
48
+ targetWt = findWorktreeByName(worktrees, wtName);
28
49
  if (!targetWt) {
29
50
  log.error(`Worktree "${wtName}" not found.`);
30
51
  return;
@@ -32,38 +53,29 @@ export async function enterCommand(wtName, options = {}) {
32
53
  }
33
54
  else {
34
55
  // Interactive Selection
35
- const choices = worktrees.map(wt => {
36
- const branchName = wt.branch || wt.HEAD || 'detached';
37
- const isManaged = wt.path.startsWith(WORKTREES_ROOT);
38
- const displayPath = isManaged
39
- ? path.relative(WORKTREES_ROOT, wt.path)
40
- : wt.path.replace(process.env.HOME || '', '~');
56
+ const terminalColumns = process.stdout.columns || 100;
57
+ const choices = await Promise.all(worktrees.map(async (wt) => {
58
+ const type = await detectWorktreeType(wt, mainWorktreePath);
59
+ const branchName = getWorktreeBranchName(wt);
60
+ const displayPath = formatWorktreeDisplayPath(wt.path);
41
61
  return {
42
- name: `${branchName.padEnd(20)} ${displayPath}`,
62
+ name: formatChoiceLabel(type, branchName, displayPath, terminalColumns),
43
63
  value: wt
44
64
  };
45
- });
65
+ }));
46
66
  const { selectedWt } = await inquirer.prompt([
47
67
  {
48
68
  type: 'list',
49
69
  name: 'selectedWt',
50
70
  message: 'Select a worktree to enter:',
51
71
  choices,
52
- loop: false
72
+ loop: false,
73
+ pageSize: 10,
53
74
  }
54
75
  ]);
55
76
  targetWt = selectedWt;
56
77
  }
57
- const { execCommandStr } = await inquirer.prompt([
58
- {
59
- type: 'input',
60
- name: 'execCommandStr',
61
- message: 'Command to run before entering (optional):',
62
- default: options.exec,
63
- when: options.exec === undefined,
64
- }
65
- ]);
66
- const finalExec = options.exec || execCommandStr;
78
+ const finalExec = options.exec;
67
79
  if (finalExec && finalExec.trim()) {
68
80
  log.info(`Executing: ${finalExec} in ${ui.path(targetWt?.path || '')}`);
69
81
  try {
@@ -1,9 +1,8 @@
1
1
  import inquirer from 'inquirer';
2
2
  import { execa } from 'execa';
3
- import path from 'path';
4
3
  import { listWorktrees, getRepoRoot } from '../../lib/git.js';
5
- import { WORKTREES_ROOT } from '../../lib/paths.js';
6
4
  import { log } from '../../lib/ui.js';
5
+ import { findWorktreeByName, formatWorktreeDisplayPath, getWorktreeBranchName } from '../../lib/worktree.js';
7
6
  export async function execCommand(wtName, commandArgs) {
8
7
  let targetWt;
9
8
  let command = '';
@@ -16,16 +15,7 @@ export async function execCommand(wtName, commandArgs) {
16
15
  return;
17
16
  }
18
17
  if (wtName) {
19
- // Find worktree by name (branch name, relative path, or slug/basename)
20
- targetWt = worktrees.find(wt => {
21
- const branchName = wt.branch || wt.HEAD || '';
22
- const relativePath = path.relative(WORKTREES_ROOT, wt.path);
23
- const basename = path.basename(wt.path);
24
- return branchName === wtName ||
25
- relativePath === wtName ||
26
- wt.path === wtName ||
27
- basename === wtName;
28
- });
18
+ targetWt = findWorktreeByName(worktrees, wtName);
29
19
  if (!targetWt) {
30
20
  log.error(`Worktree "${wtName}" not found.`);
31
21
  return;
@@ -34,11 +24,8 @@ export async function execCommand(wtName, commandArgs) {
34
24
  else {
35
25
  // Interactive Selection
36
26
  const choices = worktrees.map(wt => {
37
- const branchName = wt.branch || wt.HEAD || 'detached';
38
- const isManaged = wt.path.startsWith(WORKTREES_ROOT);
39
- const displayPath = isManaged
40
- ? path.relative(WORKTREES_ROOT, wt.path)
41
- : wt.path.replace(process.env.HOME || '', '~');
27
+ const branchName = getWorktreeBranchName(wt);
28
+ const displayPath = formatWorktreeDisplayPath(wt.path);
42
29
  return {
43
30
  name: `${branchName.padEnd(20)} ${displayPath}`,
44
31
  value: wt
@@ -1,34 +1,44 @@
1
1
  import chalk from 'chalk';
2
- import path from 'path';
3
- import { listWorktrees, getRepoRoot, isGitClean } from '../../lib/git.js';
4
- import { WORKTREES_ROOT } from '../../lib/paths.js';
5
- import { log } from '../../lib/ui.js';
2
+ import { listWorktrees, getRepoRoot, isGitClean, getLastActivity } from '../../lib/git.js';
3
+ import { log, timeAgo } from '../../lib/ui.js';
4
+ import { detectWorktreeType, formatWorktreeType, getWorktreeBranchName, WORKTREE_TYPE_ORDER, } from '../../lib/worktree.js';
6
5
  export async function listCommand() {
7
6
  try {
8
- const _ = await getRepoRoot(); // Verify we are in a git repo
7
+ await getRepoRoot(); // Verify we are in a git repo
9
8
  const worktrees = await listWorktrees();
9
+ const mainWorktreePath = worktrees[0]?.path || '';
10
10
  if (worktrees.length === 0) {
11
11
  log.info('No worktrees found.');
12
12
  return;
13
13
  }
14
14
  console.log(chalk.bold('\n Active Worktrees:\n'));
15
15
  // Header
16
- console.log(` ${chalk.dim('TYPE')} ${chalk.dim('STATE')} ${chalk.dim('BRANCH')} ${chalk.dim('PATH')}`);
17
- console.log(chalk.dim(' ' + '-'.repeat(75)));
18
- for (const wt of worktrees) {
19
- const isManaged = wt.path.startsWith(WORKTREES_ROOT);
20
- const type = isManaged ? chalk.green('MANAGED') : chalk.blue('MAIN ');
21
- const branchName = wt.branch || wt.HEAD || 'detached';
22
- let displayPath = wt.path.replace(process.env.HOME || '', '~');
23
- if (isManaged) {
24
- displayPath = path.relative(WORKTREES_ROOT, wt.path);
25
- }
26
- const colorPath = isManaged ? chalk.cyan(displayPath) : chalk.dim(displayPath);
27
- const isClean = await isGitClean(wt.path);
16
+ console.log(` ${chalk.dim('TYPE')} ${chalk.dim('STATE')} ${chalk.dim('LAST ACTIVE')} ${chalk.dim('BRANCH')}`);
17
+ console.log(chalk.dim(' ' + '-'.repeat(70)));
18
+ const rows = await Promise.all(worktrees.map(async (wt, index) => {
19
+ const [typeKey, isClean, lastActive] = await Promise.all([
20
+ detectWorktreeType(wt, mainWorktreePath),
21
+ isGitClean(wt.path),
22
+ getLastActivity(wt.path),
23
+ ]);
24
+ const type = formatWorktreeType(typeKey);
25
+ const branchName = getWorktreeBranchName(wt);
28
26
  const stateLabel = (isClean ? 'clean' : 'dirty').padEnd(8);
29
27
  const stateText = isClean ? chalk.green(stateLabel) : chalk.yellow(stateLabel);
30
- console.log(` ${type} ${stateText} ${chalk.yellow(branchName.padEnd(18))} ${colorPath}`);
31
- }
28
+ const activeLabel = lastActive ? timeAgo(lastActive) : '—';
29
+ const activeText = chalk.magenta(activeLabel.padEnd(14));
30
+ return {
31
+ text: ` ${type} ${stateText} ${activeText} ${chalk.yellow(branchName)}`,
32
+ sortType: WORKTREE_TYPE_ORDER[typeKey],
33
+ sortBranch: branchName.toLowerCase(),
34
+ sortIndex: index,
35
+ };
36
+ }));
37
+ rows
38
+ .sort((a, b) => a.sortType - b.sortType ||
39
+ a.sortBranch.localeCompare(b.sortBranch) ||
40
+ a.sortIndex - b.sortIndex)
41
+ .forEach(row => console.log(row.text));
32
42
  console.log('');
33
43
  }
34
44
  catch (error) {