clawt 2.10.0 → 2.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 (57) hide show
  1. package/.claude/agent-memory/docs-sync-updater/MEMORY.md +14 -7
  2. package/.claude/agents/docs-sync-updater.md +11 -0
  3. package/README.md +80 -284
  4. package/dist/index.js +839 -307
  5. package/dist/postinstall.js +272 -0
  6. package/docs/spec.md +84 -22
  7. package/package.json +1 -1
  8. package/src/commands/remove.ts +21 -28
  9. package/src/commands/run.ts +68 -206
  10. package/src/constants/config.ts +4 -0
  11. package/src/constants/index.ts +11 -1
  12. package/src/constants/messages/common.ts +41 -0
  13. package/src/constants/messages/config.ts +5 -0
  14. package/src/constants/messages/create.ts +5 -0
  15. package/src/constants/messages/index.ts +29 -0
  16. package/src/constants/messages/merge.ts +42 -0
  17. package/src/constants/messages/remove.ts +15 -0
  18. package/src/constants/messages/reset.ts +7 -0
  19. package/src/constants/messages/resume.ts +12 -0
  20. package/src/constants/messages/run.ts +46 -0
  21. package/src/constants/messages/status.ts +25 -0
  22. package/src/constants/messages/sync.ts +24 -0
  23. package/src/constants/messages/validate.ts +25 -0
  24. package/src/constants/progress.ts +39 -0
  25. package/src/types/command.ts +4 -0
  26. package/src/types/config.ts +2 -0
  27. package/src/types/index.ts +1 -0
  28. package/src/types/taskFile.ts +13 -0
  29. package/src/utils/formatter.ts +16 -0
  30. package/src/utils/index.ts +8 -4
  31. package/src/utils/progress-render.ts +90 -0
  32. package/src/utils/progress.ts +213 -0
  33. package/src/utils/task-executor.ts +365 -0
  34. package/src/utils/task-file.ts +87 -0
  35. package/src/utils/worktree-matcher.ts +92 -0
  36. package/src/utils/worktree.ts +27 -0
  37. package/tests/unit/commands/config.test.ts +110 -0
  38. package/tests/unit/commands/create.test.ts +115 -0
  39. package/tests/unit/commands/list.test.ts +118 -0
  40. package/tests/unit/commands/merge.test.ts +323 -0
  41. package/tests/unit/commands/remove.test.ts +240 -0
  42. package/tests/unit/commands/reset.test.ts +124 -0
  43. package/tests/unit/commands/resume.test.ts +91 -0
  44. package/tests/unit/commands/run.test.ts +456 -0
  45. package/tests/unit/commands/status.test.ts +214 -0
  46. package/tests/unit/commands/sync.test.ts +208 -0
  47. package/tests/unit/commands/validate.test.ts +382 -0
  48. package/tests/unit/constants/config.test.ts +1 -0
  49. package/tests/unit/constants/messages.test.ts +1 -1
  50. package/tests/unit/utils/config.test.ts +21 -1
  51. package/tests/unit/utils/formatter.test.ts +70 -1
  52. package/tests/unit/utils/git.test.ts +44 -0
  53. package/tests/unit/utils/progress.test.ts +255 -0
  54. package/tests/unit/utils/task-file.test.ts +236 -0
  55. package/tests/unit/utils/validate-snapshot.test.ts +25 -0
  56. package/tests/unit/utils/worktree-matcher.test.ts +81 -5
  57. package/tests/unit/utils/worktree.test.ts +26 -1
@@ -0,0 +1,456 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { EventEmitter, Readable } from 'node:stream';
4
+
5
+ vi.mock('../../../src/logger/index.js', () => ({
6
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
7
+ }));
8
+
9
+ vi.mock('../../../src/errors/index.js', () => ({
10
+ ClawtError: class ClawtError extends Error {
11
+ exitCode: number;
12
+ constructor(message: string, exitCode = 1) {
13
+ super(message);
14
+ this.exitCode = exitCode;
15
+ }
16
+ },
17
+ }));
18
+
19
+ vi.mock('../../../src/constants/index.js', () => ({
20
+ MESSAGES: {
21
+ BRANCH_EXISTS_USE_RESUME: (name: string) => `分支 ${name} 已存在,请使用 resume 恢复`,
22
+ WORKTREE_CREATED: (count: number) => `✓ 已创建 ${count} 个 worktree`,
23
+ INTERRUPTED: '已中断',
24
+ INTERRUPT_AUTO_CLEANED: (count: number) => `已清理 ${count} 个 worktree`,
25
+ INTERRUPT_CONFIRM_CLEANUP: '是否清理已创建的 worktree?',
26
+ INTERRUPT_CLEANED: (count: number) => `已清理 ${count} 个 worktree`,
27
+ INTERRUPT_KEPT: '已保留 worktree',
28
+ CONCURRENCY_INFO: (concurrency: number, total: number) => `并发限制: ${concurrency},共 ${total} 个任务`,
29
+ CONCURRENCY_INVALID: '并发数必须为正整数',
30
+ FILE_AND_TASKS_CONFLICT: '--file 和 --tasks 不能同时使用',
31
+ BRANCH_OR_FILE_REQUIRED: '请指定 -b 或 -f',
32
+ TASK_FILE_LOADED: (count: number, path: string) => `✓ 从 ${path} 加载了 ${count} 个任务`,
33
+ TASK_FILE_MISSING_TASK_BY_INDEX: (blockIndex: number) => `第 ${blockIndex} 个任务块缺少任务描述`,
34
+ },
35
+ }));
36
+
37
+ /**
38
+ * mock utils/index.js —— run.ts 的直接依赖
39
+ * 注意:executeBatchTasks 不在此 mock,由 utils/index.js 的 re-export 指向 task-executor.js 的真实实现
40
+ */
41
+ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
42
+ const actual = await importOriginal<typeof import('../../../src/utils/index.js')>();
43
+ return {
44
+ ...actual,
45
+ validateMainWorktree: vi.fn(),
46
+ validateClaudeCodeInstalled: vi.fn(),
47
+ createWorktrees: vi.fn(),
48
+ sanitizeBranchName: vi.fn(),
49
+ checkBranchExists: vi.fn(),
50
+ getConfigValue: vi.fn().mockReturnValue(0),
51
+ printSuccess: vi.fn(),
52
+ printError: vi.fn(),
53
+ printWarning: vi.fn(),
54
+ printInfo: vi.fn(),
55
+ printSeparator: vi.fn(),
56
+ printDoubleSeparator: vi.fn(),
57
+ confirmAction: vi.fn(),
58
+ launchInteractiveClaude: vi.fn(),
59
+ loadTaskFile: vi.fn(),
60
+ createWorktreesByBranches: vi.fn(),
61
+ };
62
+ });
63
+
64
+ /** task-executor.ts 内部依赖的具体模块 mock */
65
+ vi.mock('../../../src/utils/shell.js', () => ({
66
+ spawnProcess: vi.fn(),
67
+ killAllChildProcesses: vi.fn(),
68
+ execCommand: vi.fn(),
69
+ execCommandWithInput: vi.fn(),
70
+ }));
71
+
72
+ vi.mock('../../../src/utils/worktree.js', () => ({
73
+ cleanupWorktrees: vi.fn(),
74
+ createWorktrees: vi.fn(),
75
+ getProjectWorktrees: vi.fn(),
76
+ getProjectWorktreeDir: vi.fn(),
77
+ getWorktreeStatus: vi.fn(),
78
+ createWorktreesByBranches: vi.fn(),
79
+ }));
80
+
81
+ vi.mock('../../../src/utils/config.js', () => ({
82
+ getConfigValue: vi.fn().mockReturnValue(0),
83
+ loadConfig: vi.fn(),
84
+ writeDefaultConfig: vi.fn(),
85
+ ensureClawtDirs: vi.fn(),
86
+ }));
87
+
88
+ vi.mock('../../../src/utils/formatter.js', () => ({
89
+ printSuccess: vi.fn(),
90
+ printError: vi.fn(),
91
+ printWarning: vi.fn(),
92
+ printInfo: vi.fn(),
93
+ printSeparator: vi.fn(),
94
+ printDoubleSeparator: vi.fn(),
95
+ confirmAction: vi.fn(),
96
+ confirmDestructiveAction: vi.fn(),
97
+ formatWorktreeStatus: vi.fn(),
98
+ isWorktreeIdle: vi.fn(),
99
+ formatDuration: vi.fn(),
100
+ }));
101
+
102
+ vi.mock('../../../src/utils/progress.js', () => ({
103
+ ProgressRenderer: class {
104
+ start = vi.fn();
105
+ stop = vi.fn();
106
+ updateActivity = vi.fn();
107
+ markRunning = vi.fn();
108
+ markDone = vi.fn();
109
+ markFailed = vi.fn();
110
+ },
111
+ }));
112
+
113
+ import { registerRunCommand } from '../../../src/commands/run.js';
114
+ import {
115
+ createWorktrees,
116
+ createWorktreesByBranches,
117
+ sanitizeBranchName,
118
+ checkBranchExists,
119
+ printSuccess,
120
+ launchInteractiveClaude,
121
+ getConfigValue,
122
+ loadTaskFile,
123
+ } from '../../../src/utils/index.js';
124
+ import { spawnProcess } from '../../../src/utils/shell.js';
125
+ import { printInfo } from '../../../src/utils/formatter.js';
126
+
127
+ const mockedCreateWorktrees = vi.mocked(createWorktrees);
128
+ const mockedCreateWorktreesByBranches = vi.mocked(createWorktreesByBranches);
129
+ const mockedSanitizeBranchName = vi.mocked(sanitizeBranchName);
130
+ const mockedCheckBranchExists = vi.mocked(checkBranchExists);
131
+ const mockedSpawnProcess = vi.mocked(spawnProcess);
132
+ const mockedPrintSuccess = vi.mocked(printSuccess);
133
+ const mockedPrintInfo = vi.mocked(printInfo);
134
+ const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
135
+ const mockedGetConfigValue = vi.mocked(getConfigValue);
136
+ const mockedLoadTaskFile = vi.mocked(loadTaskFile);
137
+
138
+ /**
139
+ * 创建模拟子进程
140
+ * @param {string} stdout - 子进程标准输出内容
141
+ * @param {number} exitCode - 退出码
142
+ * @returns {object} 模拟的子进程对象
143
+ */
144
+ function createMockChildProcess(stdout: string, exitCode: number) {
145
+ const child = new EventEmitter() as any;
146
+ const stdoutStream = new Readable({ read() {} });
147
+ const stderrStream = new Readable({ read() {} });
148
+ child.stdout = stdoutStream;
149
+ child.stderr = stderrStream;
150
+ child.pid = 12345;
151
+ child.exitCode = null;
152
+
153
+ // 延迟触发 close 事件
154
+ setTimeout(() => {
155
+ stdoutStream.push(stdout);
156
+ stdoutStream.push(null);
157
+ stderrStream.push(null);
158
+ child.exitCode = exitCode;
159
+ child.emit('close', exitCode);
160
+ }, 10);
161
+
162
+ return child;
163
+ }
164
+
165
+ beforeEach(() => {
166
+ mockedCreateWorktrees.mockReset();
167
+ mockedCreateWorktreesByBranches.mockReset();
168
+ mockedSanitizeBranchName.mockReset();
169
+ mockedCheckBranchExists.mockReset();
170
+ mockedSpawnProcess.mockReset();
171
+ mockedPrintSuccess.mockReset();
172
+ mockedPrintInfo.mockReset();
173
+ mockedLaunchInteractiveClaude.mockReset();
174
+ mockedGetConfigValue.mockReset();
175
+ mockedGetConfigValue.mockReturnValue(0 as any);
176
+ mockedLoadTaskFile.mockReset();
177
+ // sanitizeBranchName 默认返回输入值
178
+ mockedSanitizeBranchName.mockImplementation((name: string) => name);
179
+ });
180
+
181
+ describe('registerRunCommand', () => {
182
+ it('注册 run 命令', () => {
183
+ const program = new Command();
184
+ registerRunCommand(program);
185
+ const cmd = program.commands.find((c) => c.name() === 'run');
186
+ expect(cmd).toBeDefined();
187
+ });
188
+ });
189
+
190
+ describe('handleRun', () => {
191
+ it('未传 --tasks 时创建单个 worktree 并打开交互式界面', async () => {
192
+ mockedSanitizeBranchName.mockReturnValue('feature');
193
+ mockedCheckBranchExists.mockReturnValue(false);
194
+ const worktree = { path: '/path/feature', branch: 'feature' };
195
+ mockedCreateWorktrees.mockReturnValue([worktree]);
196
+
197
+ const program = new Command();
198
+ program.exitOverride();
199
+ registerRunCommand(program);
200
+ await program.parseAsync(['run', '-b', 'feature'], { from: 'user' });
201
+
202
+ expect(mockedCreateWorktrees).toHaveBeenCalledWith('feature', 1);
203
+ expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree);
204
+ });
205
+
206
+ it('分支已存在时提示使用 resume', async () => {
207
+ mockedSanitizeBranchName.mockReturnValue('feature');
208
+ mockedCheckBranchExists.mockReturnValue(true);
209
+
210
+ const program = new Command();
211
+ program.exitOverride();
212
+ registerRunCommand(program);
213
+
214
+ await expect(
215
+ program.parseAsync(['run', '-b', 'feature'], { from: 'user' }),
216
+ ).rejects.toThrow();
217
+ });
218
+
219
+ it('传 --tasks 时创建对应数量 worktree 并并行执行', async () => {
220
+ const worktrees = [
221
+ { path: '/path/feat-1', branch: 'feat-1' },
222
+ { path: '/path/feat-2', branch: 'feat-2' },
223
+ ];
224
+ mockedCreateWorktrees.mockReturnValue(worktrees);
225
+
226
+ const jsonOutput = JSON.stringify({
227
+ is_error: false,
228
+ duration_ms: 5000,
229
+ total_cost_usd: 0.05,
230
+ });
231
+ mockedSpawnProcess
232
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
233
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
234
+
235
+ const program = new Command();
236
+ program.exitOverride();
237
+ registerRunCommand(program);
238
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2'], { from: 'user' });
239
+
240
+ expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 2);
241
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
242
+ });
243
+
244
+ it('任务执行失败时在通知中报告', async () => {
245
+ const worktrees = [{ path: '/path/feat-1', branch: 'feat-1' }];
246
+ mockedCreateWorktrees.mockReturnValue(worktrees);
247
+
248
+ const jsonOutput = JSON.stringify({
249
+ is_error: true,
250
+ duration_ms: 1000,
251
+ total_cost_usd: 0.01,
252
+ });
253
+ mockedSpawnProcess.mockReturnValueOnce(createMockChildProcess(jsonOutput, 1));
254
+
255
+ const program = new Command();
256
+ program.exitOverride();
257
+ registerRunCommand(program);
258
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'fail-task'], { from: 'user' });
259
+
260
+ // 应输出汇总(含失败信息)
261
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(1);
262
+ });
263
+
264
+ it('子进程发生错误时返回失败结果', async () => {
265
+ const worktrees = [{ path: '/path/feat-1', branch: 'feat-1' }];
266
+ mockedCreateWorktrees.mockReturnValue(worktrees);
267
+
268
+ // 创建会触发 error 事件的子进程
269
+ const child = new EventEmitter() as any;
270
+ child.stdout = new Readable({ read() {} });
271
+ child.stderr = new Readable({ read() {} });
272
+ child.pid = 12345;
273
+ child.exitCode = null;
274
+ setTimeout(() => {
275
+ child.emit('error', new Error('spawn error'));
276
+ }, 10);
277
+ mockedSpawnProcess.mockReturnValueOnce(child);
278
+
279
+ const program = new Command();
280
+ program.exitOverride();
281
+ registerRunCommand(program);
282
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1'], { from: 'user' });
283
+
284
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(1);
285
+ });
286
+
287
+ it('传 --concurrency 限制并发数', async () => {
288
+ const worktrees = [
289
+ { path: '/path/feat-1', branch: 'feat-1' },
290
+ { path: '/path/feat-2', branch: 'feat-2' },
291
+ { path: '/path/feat-3', branch: 'feat-3' },
292
+ ];
293
+ mockedCreateWorktrees.mockReturnValue(worktrees);
294
+
295
+ const jsonOutput = JSON.stringify({
296
+ is_error: false,
297
+ duration_ms: 5000,
298
+ total_cost_usd: 0.05,
299
+ });
300
+ mockedSpawnProcess
301
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
302
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
303
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
304
+
305
+ const program = new Command();
306
+ program.exitOverride();
307
+ registerRunCommand(program);
308
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', 'task3', '-c', '1'], { from: 'user' });
309
+
310
+ expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 3);
311
+ // 所有任务都应执行完毕
312
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
313
+ // 应输出并发限制提示
314
+ expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
315
+ });
316
+
317
+ it('--concurrency 为 0 时不限制并发', async () => {
318
+ const worktrees = [
319
+ { path: '/path/feat-1', branch: 'feat-1' },
320
+ { path: '/path/feat-2', branch: 'feat-2' },
321
+ ];
322
+ mockedCreateWorktrees.mockReturnValue(worktrees);
323
+
324
+ const jsonOutput = JSON.stringify({
325
+ is_error: false,
326
+ duration_ms: 5000,
327
+ total_cost_usd: 0.05,
328
+ });
329
+ mockedSpawnProcess
330
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
331
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
332
+
333
+ const program = new Command();
334
+ program.exitOverride();
335
+ registerRunCommand(program);
336
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', '-c', '0'], { from: 'user' });
337
+
338
+ // 不限制并发时不输出并发限制提示
339
+ expect(mockedPrintInfo).not.toHaveBeenCalledWith(expect.stringContaining('并发限制'));
340
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
341
+ });
342
+
343
+ it('未传 -c 时使用全局配置的 maxConcurrency', async () => {
344
+ mockedGetConfigValue.mockReturnValue(2 as any);
345
+
346
+ const worktrees = [
347
+ { path: '/path/feat-1', branch: 'feat-1' },
348
+ { path: '/path/feat-2', branch: 'feat-2' },
349
+ { path: '/path/feat-3', branch: 'feat-3' },
350
+ ];
351
+ mockedCreateWorktrees.mockReturnValue(worktrees);
352
+
353
+ const jsonOutput = JSON.stringify({
354
+ is_error: false,
355
+ duration_ms: 5000,
356
+ total_cost_usd: 0.05,
357
+ });
358
+ mockedSpawnProcess
359
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
360
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
361
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
362
+
363
+ const program = new Command();
364
+ program.exitOverride();
365
+ registerRunCommand(program);
366
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', 'task3'], { from: 'user' });
367
+
368
+ // 应输出并发限制提示(使用配置值 2)
369
+ expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
370
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
371
+ });
372
+
373
+ it('-f 从文件加载任务并执行(无 -b,使用文件中分支名)', async () => {
374
+ mockedLoadTaskFile.mockReturnValue([
375
+ { branch: 'feat-login', task: '实现登录功能' },
376
+ { branch: 'fix-bug', task: '修复问题' },
377
+ ]);
378
+ const worktrees = [
379
+ { path: '/path/feat-login', branch: 'feat-login' },
380
+ { path: '/path/fix-bug', branch: 'fix-bug' },
381
+ ];
382
+ mockedCreateWorktreesByBranches.mockReturnValue(worktrees);
383
+
384
+ const jsonOutput = JSON.stringify({
385
+ is_error: false,
386
+ duration_ms: 5000,
387
+ total_cost_usd: 0.05,
388
+ });
389
+ mockedSpawnProcess
390
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
391
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
392
+
393
+ const program = new Command();
394
+ program.exitOverride();
395
+ registerRunCommand(program);
396
+ await program.parseAsync(['run', '-f', 'tasks.md'], { from: 'user' });
397
+
398
+ expect(mockedLoadTaskFile).toHaveBeenCalledWith('tasks.md', { branchRequired: true });
399
+ expect(mockedCreateWorktreesByBranches).toHaveBeenCalledWith(['feat-login', 'fix-bug']);
400
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
401
+ });
402
+
403
+ it('-f + -b 模式使用 -b 自动编号', async () => {
404
+ mockedLoadTaskFile.mockReturnValue([
405
+ { task: '任务1' },
406
+ { task: '任务2' },
407
+ ]);
408
+ const worktrees = [
409
+ { path: '/path/feat-1', branch: 'feat-1' },
410
+ { path: '/path/feat-2', branch: 'feat-2' },
411
+ ];
412
+ mockedCreateWorktrees.mockReturnValue(worktrees);
413
+
414
+ const jsonOutput = JSON.stringify({
415
+ is_error: false,
416
+ duration_ms: 5000,
417
+ total_cost_usd: 0.05,
418
+ });
419
+ mockedSpawnProcess
420
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
421
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
422
+
423
+ const program = new Command();
424
+ program.exitOverride();
425
+ registerRunCommand(program);
426
+ await program.parseAsync(['run', '-b', 'feat', '-f', 'tasks.md'], { from: 'user' });
427
+
428
+ // 应使用 createWorktrees(带 -b 自动编号),而非 createWorktreesByBranches
429
+ expect(mockedLoadTaskFile).toHaveBeenCalledWith('tasks.md', { branchRequired: false });
430
+ expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 2);
431
+ expect(mockedCreateWorktreesByBranches).not.toHaveBeenCalled();
432
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
433
+ });
434
+
435
+ it('-f 和 --tasks 互斥时报错', async () => {
436
+ mockedLoadTaskFile.mockReturnValue([{ branch: 'feat', task: '任务' }]);
437
+
438
+ const program = new Command();
439
+ program.exitOverride();
440
+ registerRunCommand(program);
441
+
442
+ await expect(
443
+ program.parseAsync(['run', '-f', 'tasks.md', '--tasks', 'task1'], { from: 'user' }),
444
+ ).rejects.toThrow();
445
+ });
446
+
447
+ it('未传 -b 和 -f 时报错', async () => {
448
+ const program = new Command();
449
+ program.exitOverride();
450
+ registerRunCommand(program);
451
+
452
+ await expect(
453
+ program.parseAsync(['run', '--tasks', 'task1'], { from: 'user' }),
454
+ ).rejects.toThrow();
455
+ });
456
+ });
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+
4
+ vi.mock('../../../src/logger/index.js', () => ({
5
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
+ }));
7
+
8
+ vi.mock('../../../src/constants/index.js', () => ({
9
+ MESSAGES: {
10
+ STATUS_TITLE: (name: string) => `项目 ${name} 状态`,
11
+ STATUS_MAIN_SECTION: '主 Worktree',
12
+ STATUS_WORKTREES_SECTION: 'Worktree 列表',
13
+ STATUS_SNAPSHOTS_SECTION: 'Validate 快照',
14
+ STATUS_NO_WORKTREES: '无 worktree',
15
+ STATUS_NO_SNAPSHOTS: '无快照',
16
+ STATUS_CHANGE_COMMITTED: '已提交',
17
+ STATUS_CHANGE_UNCOMMITTED: '未提交',
18
+ STATUS_CHANGE_CONFLICT: '冲突',
19
+ STATUS_CHANGE_CLEAN: '干净',
20
+ STATUS_SNAPSHOT_ORPHANED: '(孤立)',
21
+ },
22
+ }));
23
+
24
+ vi.mock('../../../src/utils/index.js', () => ({
25
+ validateMainWorktree: vi.fn(),
26
+ getProjectName: vi.fn(),
27
+ getCurrentBranch: vi.fn(),
28
+ isWorkingDirClean: vi.fn(),
29
+ getProjectWorktrees: vi.fn(),
30
+ getCommitCountAhead: vi.fn(),
31
+ getCommitCountBehind: vi.fn(),
32
+ getDiffStat: vi.fn(),
33
+ hasMergeConflict: vi.fn(),
34
+ hasLocalCommits: vi.fn(),
35
+ hasSnapshot: vi.fn(),
36
+ getProjectSnapshotBranches: vi.fn(),
37
+ printInfo: vi.fn(),
38
+ printDoubleSeparator: vi.fn(),
39
+ printSeparator: vi.fn(),
40
+ }));
41
+
42
+ import { registerStatusCommand } from '../../../src/commands/status.js';
43
+ import {
44
+ getProjectName,
45
+ getCurrentBranch,
46
+ isWorkingDirClean,
47
+ getProjectWorktrees,
48
+ getCommitCountAhead,
49
+ getCommitCountBehind,
50
+ getDiffStat,
51
+ hasMergeConflict,
52
+ hasLocalCommits,
53
+ hasSnapshot,
54
+ getProjectSnapshotBranches,
55
+ printInfo,
56
+ } from '../../../src/utils/index.js';
57
+
58
+ const mockedGetProjectName = vi.mocked(getProjectName);
59
+ const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
60
+ const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
61
+ const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
62
+ const mockedGetCommitCountAhead = vi.mocked(getCommitCountAhead);
63
+ const mockedGetCommitCountBehind = vi.mocked(getCommitCountBehind);
64
+ const mockedGetDiffStat = vi.mocked(getDiffStat);
65
+ const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
66
+ const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
67
+ const mockedHasSnapshot = vi.mocked(hasSnapshot);
68
+ const mockedGetProjectSnapshotBranches = vi.mocked(getProjectSnapshotBranches);
69
+ const mockedPrintInfo = vi.mocked(printInfo);
70
+
71
+ beforeEach(() => {
72
+ mockedGetProjectName.mockReturnValue('test-project');
73
+ mockedGetCurrentBranch.mockReturnValue('main');
74
+ mockedIsWorkingDirClean.mockReturnValue(true);
75
+ mockedGetProjectWorktrees.mockReturnValue([]);
76
+ mockedGetProjectSnapshotBranches.mockReturnValue([]);
77
+ mockedGetCommitCountAhead.mockReturnValue(0);
78
+ mockedGetCommitCountBehind.mockReturnValue(0);
79
+ mockedGetDiffStat.mockReturnValue({ insertions: 0, deletions: 0 });
80
+ mockedHasMergeConflict.mockReturnValue(false);
81
+ mockedHasLocalCommits.mockReturnValue(false);
82
+ mockedHasSnapshot.mockReturnValue(false);
83
+ mockedPrintInfo.mockReset();
84
+ });
85
+
86
+ describe('registerStatusCommand', () => {
87
+ it('注册 status 命令', () => {
88
+ const program = new Command();
89
+ registerStatusCommand(program);
90
+ const cmd = program.commands.find((c) => c.name() === 'status');
91
+ expect(cmd).toBeDefined();
92
+ });
93
+ });
94
+
95
+ describe('handleStatus', () => {
96
+ it('无 worktree 时文本输出', () => {
97
+ const program = new Command();
98
+ program.exitOverride();
99
+ registerStatusCommand(program);
100
+ program.parse(['status'], { from: 'user' });
101
+
102
+ expect(mockedPrintInfo).toHaveBeenCalled();
103
+ });
104
+
105
+ it('--json 输出完整 JSON 结构', () => {
106
+ mockedGetProjectWorktrees.mockReturnValue([
107
+ { path: '/path/feature', branch: 'feature' },
108
+ ]);
109
+ mockedGetCommitCountAhead.mockReturnValue(2);
110
+ mockedGetDiffStat.mockReturnValue({ insertions: 10, deletions: 5 });
111
+ mockedHasLocalCommits.mockReturnValue(true);
112
+
113
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
114
+
115
+ const program = new Command();
116
+ program.exitOverride();
117
+ registerStatusCommand(program);
118
+ program.parse(['status', '--json'], { from: 'user' });
119
+
120
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
121
+ try { JSON.parse(call[0]); return true; } catch { return false; }
122
+ });
123
+ expect(jsonCall).toBeDefined();
124
+ const parsed = JSON.parse(jsonCall![0]);
125
+ expect(parsed.main.projectName).toBe('test-project');
126
+ expect(parsed.main.branch).toBe('main');
127
+ expect(parsed.totalWorktrees).toBe(1);
128
+ expect(parsed.worktrees).toHaveLength(1);
129
+ expect(parsed.worktrees[0].branch).toBe('feature');
130
+ });
131
+
132
+ it('有 worktree 时收集正确的变更状态', () => {
133
+ mockedGetProjectWorktrees.mockReturnValue([
134
+ { path: '/path/feature', branch: 'feature' },
135
+ ]);
136
+ // 模拟冲突状态
137
+ mockedHasMergeConflict.mockReturnValue(true);
138
+
139
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
140
+
141
+ const program = new Command();
142
+ program.exitOverride();
143
+ registerStatusCommand(program);
144
+ program.parse(['status', '--json'], { from: 'user' });
145
+
146
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
147
+ try { JSON.parse(call[0]); return true; } catch { return false; }
148
+ });
149
+ const parsed = JSON.parse(jsonCall![0]);
150
+ expect(parsed.worktrees[0].changeStatus).toBe('conflict');
151
+ });
152
+
153
+ it('主 worktree 不干净时 isClean=false', () => {
154
+ mockedIsWorkingDirClean.mockReturnValue(false);
155
+
156
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
157
+
158
+ const program = new Command();
159
+ program.exitOverride();
160
+ registerStatusCommand(program);
161
+ program.parse(['status', '--json'], { from: 'user' });
162
+
163
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
164
+ try { JSON.parse(call[0]); return true; } catch { return false; }
165
+ });
166
+ const parsed = JSON.parse(jsonCall![0]);
167
+ expect(parsed.main.isClean).toBe(false);
168
+ });
169
+
170
+ it('存在快照时标识孤立快照', () => {
171
+ mockedGetProjectSnapshotBranches.mockReturnValue(['feature', 'deleted-branch']);
172
+ mockedGetProjectWorktrees.mockReturnValue([
173
+ { path: '/path/feature', branch: 'feature' },
174
+ ]);
175
+
176
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
177
+
178
+ const program = new Command();
179
+ program.exitOverride();
180
+ registerStatusCommand(program);
181
+ program.parse(['status', '--json'], { from: 'user' });
182
+
183
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
184
+ try { JSON.parse(call[0]); return true; } catch { return false; }
185
+ });
186
+ const parsed = JSON.parse(jsonCall![0]);
187
+ expect(parsed.snapshots).toHaveLength(2);
188
+ expect(parsed.snapshots[0]).toEqual({ branch: 'feature', worktreeExists: true });
189
+ expect(parsed.snapshots[1]).toEqual({ branch: 'deleted-branch', worktreeExists: false });
190
+ });
191
+
192
+ it('uncommitted 变更状态正确检测', () => {
193
+ mockedGetProjectWorktrees.mockReturnValue([
194
+ { path: '/path/feature', branch: 'feature' },
195
+ ]);
196
+ mockedHasMergeConflict.mockReturnValue(false);
197
+ mockedIsWorkingDirClean
198
+ .mockReturnValueOnce(true) // 主 worktree
199
+ .mockReturnValueOnce(false); // 目标 worktree 不干净
200
+
201
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
202
+
203
+ const program = new Command();
204
+ program.exitOverride();
205
+ registerStatusCommand(program);
206
+ program.parse(['status', '--json'], { from: 'user' });
207
+
208
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
209
+ try { JSON.parse(call[0]); return true; } catch { return false; }
210
+ });
211
+ const parsed = JSON.parse(jsonCall![0]);
212
+ expect(parsed.worktrees[0].changeStatus).toBe('uncommitted');
213
+ });
214
+ });