@nemus-cli/nemus 0.9.0 → 0.11.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +23 -5
  3. package/bin/workspace.js +12 -0
  4. package/dist/commands/analyze-deps.js +5 -12
  5. package/dist/commands/archive.js +13 -18
  6. package/dist/commands/branch/create.js +5 -12
  7. package/dist/commands/branch/switch.js +14 -25
  8. package/dist/commands/cache/manager.js +13 -24
  9. package/dist/commands/cleanup.js +14 -26
  10. package/dist/commands/configure-claude.js +4 -8
  11. package/dist/commands/configure.js +38 -32
  12. package/dist/commands/dashboard/session-picker.js +19 -26
  13. package/dist/commands/dashboard/workspace-picker.js +19 -26
  14. package/dist/commands/delete.js +15 -30
  15. package/dist/commands/ghq-status.js +7 -12
  16. package/dist/commands/go.js +18 -27
  17. package/dist/commands/history.js +6 -13
  18. package/dist/commands/list.js +20 -29
  19. package/dist/commands/prune.js +196 -0
  20. package/dist/commands/remove-repo.js +21 -29
  21. package/dist/commands/save-context.js +5 -10
  22. package/dist/commands/sessions.js +19 -25
  23. package/dist/commands/suite/create.js +27 -53
  24. package/dist/commands/suite/delete.js +13 -25
  25. package/dist/commands/suite/export.js +20 -36
  26. package/dist/commands/suite/import.js +9 -20
  27. package/dist/commands/suite/use.js +9 -17
  28. package/dist/program.js +2 -0
  29. package/dist/utils/prompt.js +26 -0
  30. package/dist/utils/prompts.js +86 -118
  31. package/dist/utils/prune.js +70 -0
  32. package/package.json +3 -6
  33. package/scripts/release-notes.mjs +54 -0
  34. package/skills/config.md +17 -0
  35. package/skills/nemus/SKILL.md +7 -2
  36. package/skills/nemus/references/completion.md +22 -0
  37. package/skills/nemus/references/config.md +32 -0
  38. package/skills/nemus/references/prune.md +44 -0
  39. package/skills/nemus/references/reflect.md +43 -0
  40. package/skills/nemus/references/save-context.md +25 -0
  41. package/skills/prune-workspaces.md +23 -0
  42. package/skills/reflect.md +21 -0
  43. package/src/commands/analyze-deps.ts +5 -9
  44. package/src/commands/archive.ts +5 -7
  45. package/src/commands/branch/create.ts +5 -9
  46. package/src/commands/branch/switch.ts +14 -22
  47. package/src/commands/cache/manager.ts +13 -21
  48. package/src/commands/cleanup.ts +14 -23
  49. package/src/commands/configure-claude.ts +4 -5
  50. package/src/commands/configure.ts +35 -29
  51. package/src/commands/dashboard/session-picker.ts +6 -11
  52. package/src/commands/dashboard/workspace-picker.ts +6 -11
  53. package/src/commands/delete.test.ts +36 -42
  54. package/src/commands/delete.ts +15 -27
  55. package/src/commands/ghq-status.ts +5 -7
  56. package/src/commands/go.ts +18 -25
  57. package/src/commands/history.ts +6 -10
  58. package/src/commands/list.test.ts +19 -26
  59. package/src/commands/list.ts +24 -31
  60. package/src/commands/prune.ts +183 -0
  61. package/src/commands/remove-repo.ts +11 -17
  62. package/src/commands/save-context.ts +3 -5
  63. package/src/commands/sessions.ts +6 -10
  64. package/src/commands/suite/create.ts +28 -50
  65. package/src/commands/suite/delete.ts +14 -23
  66. package/src/commands/suite/export.ts +20 -33
  67. package/src/commands/suite/import.ts +9 -17
  68. package/src/commands/suite/use.ts +9 -14
  69. package/src/program.ts +2 -0
  70. package/src/utils/prompt.ts +16 -0
  71. package/src/utils/prompts.test.ts +70 -41
  72. package/src/utils/prompts.ts +98 -128
  73. package/src/utils/prune.test.ts +121 -0
  74. package/src/utils/prune.ts +109 -0
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Interactive prompt primitives.
3
+ *
4
+ * We depend on the modular `@inquirer/prompts` (the maintained successor to the
5
+ * classic `inquirer` package and its `inquirer-autocomplete-prompt` plugin).
6
+ * All prompt call sites import from HERE, not from `@inquirer/prompts` directly,
7
+ * so there is a single place to:
8
+ * - centralize the ESM-only package (loaded from our CommonJS build via
9
+ * Node 22's stable `require(esm)` — the CLI requires Node >= 22), and
10
+ * - stub prompts in tests (mock '../utils/prompt').
11
+ *
12
+ * The classic array API (`inquirer.prompt([{ type, name, message }])` returning
13
+ * `{ name: value }`) is replaced by these functions, which take an options
14
+ * object and return the answer VALUE directly.
15
+ */
16
+ export { confirm, input, select, checkbox, password, search, Separator } from '@inquirer/prompts';
@@ -1,19 +1,20 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
- const { mockPrompt } = vi.hoisted(() => ({
4
- mockPrompt: vi.fn(),
3
+ const { mockSearch, mockInput } = vi.hoisted(() => ({
4
+ mockSearch: vi.fn(),
5
+ mockInput: vi.fn(),
5
6
  }));
6
7
 
7
- // Mock inquirer before imports
8
- vi.mock('inquirer', () => ({
9
- default: {
10
- prompt: mockPrompt,
11
- registerPrompt: vi.fn(),
12
- },
13
- }));
14
-
15
- vi.mock('inquirer-autocomplete-prompt', () => ({
16
- default: {},
8
+ // Mock the modular prompt module. promptMultiWorkspaceSelection uses search();
9
+ // the modular API returns the selected VALUE directly (not { name: value }).
10
+ vi.mock('./prompt', () => ({
11
+ search: mockSearch,
12
+ input: mockInput,
13
+ confirm: vi.fn(),
14
+ select: vi.fn(),
15
+ checkbox: vi.fn(),
16
+ password: vi.fn(),
17
+ Separator: class {},
17
18
  }));
18
19
 
19
20
  vi.mock('fuzzy', () => ({
@@ -35,7 +36,8 @@ vi.mock('./validation', () => ({
35
36
  sanitizeWorkspaceName: vi.fn((input: string) => input),
36
37
  }));
37
38
 
38
- import { promptMultiWorkspaceSelection } from './prompts';
39
+ import { promptMultiWorkspaceSelection, promptWorkspaceName } from './prompts';
40
+ import { validateWorkspaceName, sanitizeWorkspaceName } from './validation';
39
41
 
40
42
  const makeWorkspaces = (...names: string[]) =>
41
43
  names.map(name => ({
@@ -44,6 +46,33 @@ const makeWorkspaces = (...names: string[]) =>
44
46
  metadata: { repositories: [], createdAt: new Date().toISOString() },
45
47
  }));
46
48
 
49
+ describe('promptWorkspaceName', () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ vi.spyOn(console, 'log').mockImplementation(() => {});
53
+ });
54
+
55
+ it('validates the SANITIZED name, not the raw input (classic filter-before-validate parity)', async () => {
56
+ // Classic inquirer ran filter (sanitize) BEFORE validate, so a name like
57
+ // "My Workspace" was validated as "my-workspace". Regression guard.
58
+ (sanitizeWorkspaceName as any).mockImplementation((s: string) =>
59
+ s.trim().toLowerCase().replace(/ +/g, '-'));
60
+ (validateWorkspaceName as any).mockReturnValue(true);
61
+
62
+ mockInput.mockImplementation(async (opts: any) => {
63
+ // Simulate the user submitting a raw value containing a space.
64
+ opts.validate('My Workspace');
65
+ return 'My Workspace';
66
+ });
67
+
68
+ const result = await promptWorkspaceName();
69
+
70
+ // validate must have seen the sanitized value, and the return is sanitized.
71
+ expect(validateWorkspaceName).toHaveBeenCalledWith('my-workspace');
72
+ expect(result).toBe('my-workspace');
73
+ });
74
+ });
75
+
47
76
  describe('promptMultiWorkspaceSelection', () => {
48
77
  beforeEach(() => {
49
78
  vi.clearAllMocks();
@@ -51,10 +80,10 @@ describe('promptMultiWorkspaceSelection', () => {
51
80
  });
52
81
 
53
82
  it('returns selected workspace names', async () => {
54
- mockPrompt
55
- .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
56
- .mockResolvedValueOnce({ workspaceName: 'ws-beta' })
57
- .mockResolvedValueOnce({ workspaceName: 'done' });
83
+ mockSearch
84
+ .mockResolvedValueOnce('ws-alpha')
85
+ .mockResolvedValueOnce('ws-beta')
86
+ .mockResolvedValueOnce('done');
58
87
 
59
88
  const result = await promptMultiWorkspaceSelection(
60
89
  makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
@@ -65,10 +94,10 @@ describe('promptMultiWorkspaceSelection', () => {
65
94
 
66
95
  it('requires at least one selection before accepting done', async () => {
67
96
  // First "done" should be rejected, then select one, then done
68
- mockPrompt
69
- .mockResolvedValueOnce({ workspaceName: 'done' })
70
- .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
71
- .mockResolvedValueOnce({ workspaceName: 'done' });
97
+ mockSearch
98
+ .mockResolvedValueOnce('done')
99
+ .mockResolvedValueOnce('ws-alpha')
100
+ .mockResolvedValueOnce('done');
72
101
 
73
102
  const result = await promptMultiWorkspaceSelection(
74
103
  makeWorkspaces('ws-alpha', 'ws-beta')
@@ -76,13 +105,13 @@ describe('promptMultiWorkspaceSelection', () => {
76
105
 
77
106
  expect(result).toEqual(['ws-alpha']);
78
107
  // Should have prompted 3 times (done rejected, select, done accepted)
79
- expect(mockPrompt).toHaveBeenCalledTimes(3);
108
+ expect(mockSearch).toHaveBeenCalledTimes(3);
80
109
  });
81
110
 
82
111
  it('auto-completes when all workspaces are selected', async () => {
83
- mockPrompt
84
- .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
85
- .mockResolvedValueOnce({ workspaceName: 'ws-beta' });
112
+ mockSearch
113
+ .mockResolvedValueOnce('ws-alpha')
114
+ .mockResolvedValueOnce('ws-beta');
86
115
 
87
116
  const result = await promptMultiWorkspaceSelection(
88
117
  makeWorkspaces('ws-alpha', 'ws-beta')
@@ -90,13 +119,13 @@ describe('promptMultiWorkspaceSelection', () => {
90
119
 
91
120
  expect(result).toEqual(['ws-alpha', 'ws-beta']);
92
121
  // Only 2 prompts needed - loop breaks when no available names left
93
- expect(mockPrompt).toHaveBeenCalledTimes(2);
122
+ expect(mockSearch).toHaveBeenCalledTimes(2);
94
123
  });
95
124
 
96
125
  it('returns single workspace selection', async () => {
97
- mockPrompt
98
- .mockResolvedValueOnce({ workspaceName: 'ws-only' })
99
- .mockResolvedValueOnce({ workspaceName: 'done' });
126
+ mockSearch
127
+ .mockResolvedValueOnce('ws-only')
128
+ .mockResolvedValueOnce('done');
100
129
 
101
130
  const result = await promptMultiWorkspaceSelection(
102
131
  makeWorkspaces('ws-only', 'ws-other')
@@ -110,7 +139,7 @@ describe('promptMultiWorkspaceSelection', () => {
110
139
  throw new Error('process.exit');
111
140
  });
112
141
 
113
- mockPrompt.mockRejectedValueOnce(new Error('User force closed'));
142
+ mockSearch.mockRejectedValueOnce(new Error('User force closed'));
114
143
 
115
144
  await expect(
116
145
  promptMultiWorkspaceSelection(makeWorkspaces('ws-alpha'))
@@ -122,9 +151,9 @@ describe('promptMultiWorkspaceSelection', () => {
122
151
 
123
152
  it('prints summary with correct singular form', async () => {
124
153
  const logSpy = vi.spyOn(console, 'log');
125
- mockPrompt
126
- .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
127
- .mockResolvedValueOnce({ workspaceName: 'done' });
154
+ mockSearch
155
+ .mockResolvedValueOnce('ws-alpha')
156
+ .mockResolvedValueOnce('done');
128
157
 
129
158
  await promptMultiWorkspaceSelection(makeWorkspaces('ws-alpha', 'ws-beta'));
130
159
 
@@ -136,10 +165,10 @@ describe('promptMultiWorkspaceSelection', () => {
136
165
 
137
166
  it('prints summary with correct plural form', async () => {
138
167
  const logSpy = vi.spyOn(console, 'log');
139
- mockPrompt
140
- .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
141
- .mockResolvedValueOnce({ workspaceName: 'ws-beta' })
142
- .mockResolvedValueOnce({ workspaceName: 'done' });
168
+ mockSearch
169
+ .mockResolvedValueOnce('ws-alpha')
170
+ .mockResolvedValueOnce('ws-beta')
171
+ .mockResolvedValueOnce('done');
143
172
 
144
173
  await promptMultiWorkspaceSelection(
145
174
  makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
@@ -153,10 +182,10 @@ describe('promptMultiWorkspaceSelection', () => {
153
182
 
154
183
  it('prints green confirmation after each selection', async () => {
155
184
  const logSpy = vi.spyOn(console, 'log');
156
- mockPrompt
157
- .mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
158
- .mockResolvedValueOnce({ workspaceName: 'ws-beta' })
159
- .mockResolvedValueOnce({ workspaceName: 'done' });
185
+ mockSearch
186
+ .mockResolvedValueOnce('ws-alpha')
187
+ .mockResolvedValueOnce('ws-beta')
188
+ .mockResolvedValueOnce('done');
160
189
 
161
190
  await promptMultiWorkspaceSelection(
162
191
  makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
@@ -1,13 +1,9 @@
1
- import inquirer from 'inquirer';
2
- import autocompletePrompt from 'inquirer-autocomplete-prompt';
1
+ import { confirm, input, select, search } from './prompt';
3
2
  import * as fuzzy from 'fuzzy';
4
3
  import { GitHubRepo } from '../types';
5
4
  import { validateWorkspaceName, checkWorkspaceExists, sanitizeWorkspaceName } from './validation';
6
5
  import { colorize } from './colors';
7
6
 
8
- // Register autocomplete prompt type
9
- inquirer.registerPrompt('autocomplete', autocompletePrompt);
10
-
11
7
  export interface RepoSelection {
12
8
  repo: GitHubRepo;
13
9
  directoryName: string;
@@ -17,32 +13,28 @@ export const promptInstanceSuffix = async (
17
13
  repoName: string,
18
14
  existingDirectoryNames: string[]
19
15
  ): Promise<string> => {
20
- const { suffix } = await inquirer.prompt([
21
- {
22
- type: 'input',
23
- name: 'suffix',
24
- message: `"${repoName}" already exists. Enter a suffix for this instance:`,
25
- validate: (input: string) => {
26
- if (!input || input.trim().length === 0) {
27
- return 'Suffix cannot be empty';
28
- }
16
+ const suffix = await input({
17
+ message: `"${repoName}" already exists. Enter a suffix for this instance:`,
18
+ validate: (input: string) => {
19
+ if (!input || input.trim().length === 0) {
20
+ return 'Suffix cannot be empty';
21
+ }
29
22
 
30
- const trimmed = input.trim();
23
+ const trimmed = input.trim();
31
24
 
32
- // Validate characters (alphanumeric, hyphens, underscores)
33
- if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
34
- return 'Suffix can only contain letters, numbers, hyphens, and underscores';
35
- }
25
+ // Validate characters (alphanumeric, hyphens, underscores)
26
+ if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
27
+ return 'Suffix can only contain letters, numbers, hyphens, and underscores';
28
+ }
36
29
 
37
- const candidateName = `${repoName}-${trimmed}`;
38
- if (existingDirectoryNames.includes(candidateName)) {
39
- return `"${candidateName}" already exists. Choose a different suffix`;
40
- }
30
+ const candidateName = `${repoName}-${trimmed}`;
31
+ if (existingDirectoryNames.includes(candidateName)) {
32
+ return `"${candidateName}" already exists. Choose a different suffix`;
33
+ }
41
34
 
42
- return true;
43
- },
35
+ return true;
44
36
  },
45
- ]);
37
+ });
46
38
 
47
39
  return suffix.trim();
48
40
  };
@@ -63,43 +55,39 @@ export const promptRepositorySelection = async (
63
55
 
64
56
  while (true) {
65
57
  try {
66
- const { repoName } = await inquirer.prompt([
67
- {
68
- type: 'autocomplete',
69
- name: 'repoName',
70
- message: `Search and select repository (${colorize(String(selectedEntries.length), 'cyan')} selected):`,
71
- source: async (_answersSoFar: any, input: string | undefined) => {
72
- const searchInput = input || '';
73
-
74
- // Always include done option
75
- const doneOption = { name: colorize('done - Finish selection', 'green'), value: 'done' };
76
-
77
- // If no input or "done" typed, show done + top repos
78
- if (!searchInput || searchInput.toLowerCase().startsWith('done')) {
79
- return [
80
- doneOption,
81
- ...repos.slice(0, 15).map(repo => ({
82
- name: `${repo.name}${repo.description ? ` - ${colorize(repo.description, 'gray')}` : ''}`,
83
- value: repo.name,
84
- }))
85
- ];
86
- }
87
-
88
- // Perform fuzzy search
89
- const results = fuzzy.filter(searchInput, repos, {
90
- extract: (repo) => `${repo.name} ${repo.description || ''}`,
91
- });
92
-
93
- const suggestions = results.slice(0, 15).map(result => ({
94
- name: `${result.original.name}${result.original.description ? ` - ${colorize(result.original.description, 'gray')}` : ''}`,
95
- value: result.original.name,
96
- }));
97
-
98
- return [doneOption, ...suggestions];
99
- },
100
- pageSize: 16,
101
- } as any,
102
- ]);
58
+ const repoName = await search<string>({
59
+ message: `Search and select repository (${colorize(String(selectedEntries.length), 'cyan')} selected):`,
60
+ pageSize: 16,
61
+ source: async (term: string | undefined) => {
62
+ const searchInput = term || '';
63
+
64
+ // Always include done option
65
+ const doneOption = { name: colorize('done - Finish selection', 'green'), value: 'done' };
66
+
67
+ // If no input or "done" typed, show done + top repos
68
+ if (!searchInput || searchInput.toLowerCase().startsWith('done')) {
69
+ return [
70
+ doneOption,
71
+ ...repos.slice(0, 15).map(repo => ({
72
+ name: `${repo.name}${repo.description ? ` - ${colorize(repo.description, 'gray')}` : ''}`,
73
+ value: repo.name,
74
+ }))
75
+ ];
76
+ }
77
+
78
+ // Perform fuzzy search
79
+ const results = fuzzy.filter(searchInput, repos, {
80
+ extract: (repo) => `${repo.name} ${repo.description || ''}`,
81
+ });
82
+
83
+ const suggestions = results.slice(0, 15).map(result => ({
84
+ name: `${result.original.name}${result.original.description ? ` - ${colorize(result.original.description, 'gray')}` : ''}`,
85
+ value: result.original.name,
86
+ }));
87
+
88
+ return [doneOption, ...suggestions];
89
+ },
90
+ });
103
91
 
104
92
  if (repoName === 'done') {
105
93
  if (selectedEntries.length === 0) {
@@ -147,23 +135,18 @@ export const promptRepositorySelection = async (
147
135
  };
148
136
 
149
137
  export const promptWorkspaceName = async (): Promise<string> => {
150
- const { workspaceName } = await inquirer.prompt([
151
- {
152
- type: 'input',
153
- name: 'workspaceName',
154
- message: 'Enter workspace name:',
155
- validate: (input: string) => {
156
- const validationResult = validateWorkspaceName(input);
157
- if (validationResult !== true) {
158
- return validationResult;
159
- }
160
- return true;
161
- },
162
- filter: (input: string) => sanitizeWorkspaceName(input),
138
+ const raw = await input({
139
+ message: 'Enter workspace name:',
140
+ validate: (value: string) => {
141
+ const validationResult = validateWorkspaceName(sanitizeWorkspaceName(value));
142
+ if (validationResult !== true) {
143
+ return validationResult;
144
+ }
145
+ return true;
163
146
  },
164
- ]);
147
+ });
165
148
 
166
- return workspaceName;
149
+ return sanitizeWorkspaceName(raw);
167
150
  };
168
151
 
169
152
  export const confirmWorkspaceCreation = async (
@@ -179,14 +162,10 @@ export const confirmWorkspaceCreation = async (
179
162
  console.log(`Repositories: ${colorize(String(repoCount), 'yellow')}`);
180
163
  console.log('='.repeat(60) + '\n');
181
164
 
182
- const { confirmed } = await inquirer.prompt([
183
- {
184
- type: 'confirm',
185
- name: 'confirmed',
186
- message: 'Create workspace with these settings?',
187
- default: true,
188
- },
189
- ]);
165
+ const confirmed = await confirm({
166
+ message: 'Create workspace with these settings?',
167
+ default: true,
168
+ });
190
169
 
191
170
  return confirmed;
192
171
  };
@@ -201,18 +180,13 @@ export const promptWorkspaceSelection = async (workspaces: Array<{ name: string;
201
180
  ? `${ws.name} (${ws.metadata.repositories.length} repos)`
202
181
  : ws.name,
203
182
  value: ws.name,
204
- short: ws.name,
205
183
  }));
206
184
 
207
- const { workspaceName } = await inquirer.prompt([
208
- {
209
- type: 'list',
210
- name: 'workspaceName',
211
- message: 'Select workspace to update:',
212
- choices,
213
- pageSize: 15,
214
- },
215
- ]);
185
+ const workspaceName = await select<string>({
186
+ message: 'Select workspace to update:',
187
+ choices,
188
+ pageSize: 15,
189
+ });
216
190
 
217
191
  return workspaceName;
218
192
  };
@@ -236,38 +210,34 @@ export const promptMultiWorkspaceSelection = async (
236
210
  break;
237
211
  }
238
212
 
239
- const { workspaceName } = await inquirer.prompt([
240
- {
241
- type: 'autocomplete',
242
- name: 'workspaceName',
243
- message: `Search and select workspace (${colorize(String(selectedNames.length), 'cyan')} selected):`,
244
- source: async (_answersSoFar: any, input: string | undefined) => {
245
- const searchInput = input || '';
246
-
247
- const doneOption = { name: colorize('done - Finish selection', 'green'), value: 'done' };
248
-
249
- if (!searchInput || searchInput.toLowerCase().startsWith('done')) {
250
- return [
251
- doneOption,
252
- ...availableNames.slice(0, 15).map(name => ({
253
- name,
254
- value: name,
255
- }))
256
- ];
257
- }
258
-
259
- const results = fuzzy.filter(searchInput, availableNames);
260
-
261
- const suggestions = results.slice(0, 15).map(result => ({
262
- name: result.original,
263
- value: result.original,
264
- }));
265
-
266
- return [doneOption, ...suggestions];
267
- },
268
- pageSize: 16,
269
- } as any,
270
- ]);
213
+ const workspaceName = await search<string>({
214
+ message: `Search and select workspace (${colorize(String(selectedNames.length), 'cyan')} selected):`,
215
+ pageSize: 16,
216
+ source: async (term: string | undefined) => {
217
+ const searchInput = term || '';
218
+
219
+ const doneOption = { name: colorize('done - Finish selection', 'green'), value: 'done' };
220
+
221
+ if (!searchInput || searchInput.toLowerCase().startsWith('done')) {
222
+ return [
223
+ doneOption,
224
+ ...availableNames.slice(0, 15).map(name => ({
225
+ name,
226
+ value: name,
227
+ }))
228
+ ];
229
+ }
230
+
231
+ const results = fuzzy.filter(searchInput, availableNames);
232
+
233
+ const suggestions = results.slice(0, 15).map(result => ({
234
+ name: result.original,
235
+ value: result.original,
236
+ }));
237
+
238
+ return [doneOption, ...suggestions];
239
+ },
240
+ });
271
241
 
272
242
  if (workspaceName === 'done') {
273
243
  if (selectedNames.length === 0) {
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ toCandidate,
4
+ isStale,
5
+ protectionReason,
6
+ planPrune,
7
+ type WorkspaceForPrune,
8
+ type PruneCandidate,
9
+ } from './prune';
10
+ import type { GitStatus } from '../types';
11
+
12
+ const NOW = Date.parse('2026-09-01T00:00:00Z');
13
+ const daysAgo = (n: number) => NOW - n * 24 * 60 * 60 * 1000;
14
+
15
+ function ws(over: Partial<WorkspaceForPrune> = {}): WorkspaceForPrune {
16
+ return { name: 'w', path: '/w', repoDirNames: [], lastActiveAt: 0, createdAt: 0, ...over };
17
+ }
18
+ function status(over: Partial<GitStatus> = {}): GitStatus {
19
+ return {
20
+ repo: 'r', branch: 'main', clean: true, ahead: 0, behind: 0,
21
+ modifiedFiles: 0, untrackedFiles: 0, hasRemote: true, detachedHead: false, ...over,
22
+ };
23
+ }
24
+
25
+ describe('toCandidate', () => {
26
+ it('prefers lastActive over createdAt and marks fromSession', () => {
27
+ const c = toCandidate(ws({ lastActiveAt: daysAgo(5), createdAt: daysAgo(40) }), NOW);
28
+ expect(c.fromSession).toBe(true);
29
+ expect(c.ageDays).toBe(5);
30
+ expect(c.undatable).toBe(false);
31
+ });
32
+
33
+ it('falls back to createdAt when there is no session', () => {
34
+ const c = toCandidate(ws({ lastActiveAt: 0, createdAt: daysAgo(40) }), NOW);
35
+ expect(c.fromSession).toBe(false);
36
+ expect(c.ageDays).toBe(40);
37
+ });
38
+
39
+ it('is undatable when neither timestamp is present', () => {
40
+ const c = toCandidate(ws({ lastActiveAt: 0, createdAt: 0 }), NOW);
41
+ expect(c.undatable).toBe(true);
42
+ expect(c.ageDays).toBe(0);
43
+ });
44
+
45
+ it('floors a future reference to a negative age (clock skew) without marking undatable', () => {
46
+ const c = toCandidate(ws({ lastActiveAt: NOW + 60_000 }), NOW);
47
+ expect(c.undatable).toBe(false);
48
+ expect(c.ageDays).toBeLessThan(0);
49
+ });
50
+ });
51
+
52
+ describe('isStale', () => {
53
+ const c = (over: Partial<WorkspaceForPrune>) => toCandidate(ws(over), NOW);
54
+
55
+ it('is true at or beyond the threshold', () => {
56
+ expect(isStale(c({ lastActiveAt: daysAgo(30) }), 30)).toBe(true);
57
+ expect(isStale(c({ lastActiveAt: daysAgo(31) }), 30)).toBe(true);
58
+ });
59
+ it('is false below the threshold', () => {
60
+ expect(isStale(c({ lastActiveAt: daysAgo(29) }), 30)).toBe(false);
61
+ });
62
+ it('never selects an undatable workspace', () => {
63
+ expect(isStale(c({ lastActiveAt: 0, createdAt: 0 }), 0)).toBe(false);
64
+ });
65
+ it('never selects a future-dated (skewed) workspace', () => {
66
+ expect(isStale(c({ lastActiveAt: NOW + 86_400_000 }), 0)).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe('protectionReason', () => {
71
+ it('returns null for an all-clean workspace', () => {
72
+ expect(protectionReason([status(), status()], false)).toBeNull();
73
+ });
74
+ it('returns null for an empty workspace', () => {
75
+ expect(protectionReason([], false)).toBeNull();
76
+ });
77
+ it('flags uncommitted changes', () => {
78
+ expect(protectionReason([status({ clean: false })], false)).toBe('1 repo with uncommitted changes');
79
+ });
80
+ it('flags unpushed commits', () => {
81
+ expect(protectionReason([status({ ahead: 2 })], false)).toBe('1 repo with unpushed commits');
82
+ });
83
+ it('combines both reasons and pluralizes', () => {
84
+ expect(
85
+ protectionReason([status({ clean: false }), status({ clean: false }), status({ ahead: 1 })], false),
86
+ ).toBe('2 repos with uncommitted changes, 1 repo with unpushed commits');
87
+ });
88
+ it('returns null when includeDirty overrides protection', () => {
89
+ expect(protectionReason([status({ clean: false, ahead: 3 })], true)).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe('planPrune', () => {
94
+ const mk = (name: string, repos: string[]): PruneCandidate =>
95
+ toCandidate(ws({ name, path: `/w/${name}`, repoDirNames: repos, lastActiveAt: daysAgo(40) }), NOW);
96
+
97
+ it('partitions prunable vs protected and skips git calls for empty workspaces', async () => {
98
+ const empty = mk('empty', []);
99
+ const clean = mk('clean', ['a']);
100
+ const dirty = mk('dirty', ['b']);
101
+ let calls = 0;
102
+ const resolver = async (c: PruneCandidate): Promise<GitStatus[]> => {
103
+ calls++;
104
+ return c.name === 'dirty' ? [status({ clean: false })] : [status()];
105
+ };
106
+
107
+ const plan = await planPrune([empty, clean, dirty], resolver, false);
108
+
109
+ expect(plan.prunable.map((c) => c.name)).toEqual(['empty', 'clean']);
110
+ expect(plan.protected.map((p) => p.candidate.name)).toEqual(['dirty']);
111
+ expect(plan.protected[0].reason).toBe('1 repo with uncommitted changes');
112
+ expect(calls).toBe(2); // empty workspace incurred no status call
113
+ });
114
+
115
+ it('includeDirty moves everything to prunable', async () => {
116
+ const dirty = mk('dirty', ['b']);
117
+ const plan = await planPrune([dirty], async () => [status({ clean: false, ahead: 2 })], true);
118
+ expect(plan.prunable.map((c) => c.name)).toEqual(['dirty']);
119
+ expect(plan.protected).toHaveLength(0);
120
+ });
121
+ });