clawt 2.14.1 → 2.15.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.
@@ -16,6 +16,8 @@ export interface RunOptions {
16
16
  concurrency?: string;
17
17
  /** 任务文件路径(与 --tasks 互斥) */
18
18
  file?: string;
19
+ /** 预览模式,仅展示任务计划不实际执行 */
20
+ dryRun?: boolean;
19
21
  }
20
22
 
21
23
  /** validate 命令选项 */
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { CONFIG_PATH, CLAWT_HOME, LOGS_DIR, WORKTREES_DIR, DEFAULT_CONFIG, MESSAGES } from '../constants/index.js';
3
+ import { ClawtError } from '../errors/index.js';
3
4
  import { ensureDir } from './fs.js';
4
5
  import { logger } from '../logger/index.js';
5
6
  import type { ClawtConfig } from '../types/index.js';
@@ -65,3 +66,22 @@ export function ensureClawtDirs(): void {
65
66
  ensureDir(LOGS_DIR);
66
67
  ensureDir(WORKTREES_DIR);
67
68
  }
69
+
70
+ /**
71
+ * 解析并发数参数
72
+ * 优先级:命令行参数 > 全局配置 > 默认值 0
73
+ * @param {string | undefined} optionValue - 命令行传入的并发数字符串
74
+ * @param {number} configValue - 全局配置中的默认并发数
75
+ * @returns {number} 解析后的并发数,0 表示不限制
76
+ */
77
+ export function parseConcurrency(optionValue: string | undefined, configValue: number): number {
78
+ if (optionValue === undefined) {
79
+ return configValue;
80
+ }
81
+
82
+ const parsed = parseInt(optionValue, 10);
83
+ if (Number.isNaN(parsed) || parsed < 0) {
84
+ throw new ClawtError(MESSAGES.CONCURRENCY_INVALID);
85
+ }
86
+ return parsed;
87
+ }
@@ -0,0 +1,89 @@
1
+ import chalk from 'chalk';
2
+ import { join } from 'node:path';
3
+ import { MESSAGES } from '../constants/index.js';
4
+ import { checkBranchExists } from './git.js';
5
+ import { getProjectWorktreeDir } from './worktree.js';
6
+ import { printInfo, printDoubleSeparator, printSeparator } from './formatter.js';
7
+
8
+ /** dry-run 模式下任务描述的最大显示长度 */
9
+ const DRY_RUN_TASK_DESC_MAX_LENGTH = 70;
10
+
11
+ /**
12
+ * 截取任务描述,超出最大长度时末尾加省略号
13
+ * 多行内容会被合并为单行显示
14
+ * @param {string} task - 原始任务描述
15
+ * @returns {string} 截取后的任务描述
16
+ */
17
+ export function truncateTaskDesc(task: string): string {
18
+ // 将换行替换为空格,保证单行显示
19
+ const oneLine = task.replace(/\n/g, ' ').trim();
20
+ if (oneLine.length <= DRY_RUN_TASK_DESC_MAX_LENGTH) {
21
+ return oneLine;
22
+ }
23
+ return oneLine.slice(0, DRY_RUN_TASK_DESC_MAX_LENGTH) + '...';
24
+ }
25
+
26
+ /**
27
+ * 输出 dry-run 预览信息,展示将要创建的 worktree 列表和任务摘要
28
+ * @param {string[]} branchNames - 分支名列表
29
+ * @param {string[]} tasks - 任务描述列表(与 branchNames 等长,交互式模式可为空数组)
30
+ * @param {number} concurrency - 并发数
31
+ */
32
+ export function printDryRunPreview(branchNames: string[], tasks: string[], concurrency: number): void {
33
+ const projectDir = getProjectWorktreeDir();
34
+ const isInteractive = tasks.length === 0;
35
+
36
+ // 标题区
37
+ printDoubleSeparator();
38
+ printInfo(` ${chalk.bold(MESSAGES.DRY_RUN_TITLE)}`);
39
+ printDoubleSeparator();
40
+
41
+ // 摘要行:合并为一行,用 │ 分隔
42
+ const summaryParts = [
43
+ MESSAGES.DRY_RUN_TASK_COUNT(branchNames.length),
44
+ MESSAGES.DRY_RUN_CONCURRENCY(concurrency),
45
+ MESSAGES.DRY_RUN_WORKTREE_DIR(projectDir),
46
+ ];
47
+ if (isInteractive) {
48
+ summaryParts.push(MESSAGES.DRY_RUN_INTERACTIVE_MODE);
49
+ }
50
+ printInfo(summaryParts.join(chalk.gray(' │ ')));
51
+
52
+ printSeparator();
53
+
54
+ // 逐个展示 worktree 信息
55
+ let hasConflict = false;
56
+ for (let i = 0; i < branchNames.length; i++) {
57
+ const branch = branchNames[i];
58
+ const worktreePath = join(projectDir, branch);
59
+ const exists = checkBranchExists(branch);
60
+
61
+ if (exists) hasConflict = true;
62
+
63
+ // 序号行:正常用绿色 ✓,冲突用黄色 ⚠ + 警告文本
64
+ const indexLabel = `[${i + 1}/${branchNames.length}]`;
65
+ if (exists) {
66
+ printInfo(`${chalk.yellow('⚠')} ${indexLabel} ${chalk.yellow(branch)} ${chalk.gray('—')} ${chalk.yellow(MESSAGES.DRY_RUN_BRANCH_EXISTS_WARNING(branch))}`);
67
+ } else {
68
+ printInfo(`${chalk.green('✓')} ${indexLabel} ${chalk.cyan(branch)}`);
69
+ }
70
+
71
+ // 路径行(2空格缩进)
72
+ printInfo(` ${chalk.gray('路径:')} ${worktreePath}`);
73
+
74
+ // 任务描述行(2空格缩进,非交互式模式)
75
+ if (!isInteractive) {
76
+ printInfo(` ${chalk.gray('任务:')} ${truncateTaskDesc(tasks[i])}`);
77
+ }
78
+
79
+ printInfo('');
80
+ }
81
+
82
+ // 结尾
83
+ printDoubleSeparator();
84
+ if (hasConflict) {
85
+ printInfo(chalk.yellow(`⚠ ${MESSAGES.DRY_RUN_HAS_CONFLICT}`));
86
+ } else {
87
+ printInfo(chalk.green(`✓ ${MESSAGES.DRY_RUN_READY}`));
88
+ }
89
+ }
@@ -48,7 +48,7 @@ export {
48
48
  export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
49
49
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
50
50
  export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
51
- export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs } from './config.js';
51
+ export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
52
52
  export { printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration } from './formatter.js';
53
53
  export { ensureDir, removeEmptyDir } from './fs.js';
54
54
  export { multilineInput } from './prompt.js';
@@ -57,9 +57,10 @@ export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, write
57
57
  export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
58
58
  export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
59
59
  export { ProgressRenderer } from './progress.js';
60
- export { parseTaskFile, loadTaskFile } from './task-file.js';
60
+ export { parseTaskFile, loadTaskFile, parseTasksFromOptions } from './task-file.js';
61
61
  export { executeBatchTasks } from './task-executor.js';
62
62
  export { detectTerminalApp, openCommandInNewTerminalTab } from './terminal.js';
63
+ export { truncateTaskDesc, printDryRunPreview } from './dry-run.js';
63
64
  export { applyAliases } from './alias.js';
64
65
  export { isValidConfigKey, getValidConfigKeys, parseConfigValue, promptConfigValue, formatConfigValue } from './config-strategy.js';
65
66
 
@@ -10,6 +10,24 @@ const TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:E
10
10
  /** 匹配分支名行的正则:# branch: <name> */
11
11
  const BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
12
12
 
13
+ /** 任务列表为空时的错误提示 */
14
+ const EMPTY_TASKS_MESSAGE = '任务列表不能为空';
15
+
16
+ /**
17
+ * 从命令行 --tasks 选项中解析出有效的任务列表
18
+ * 去除首尾空白并过滤空字符串,结果为空时抛出错误
19
+ * @param {string[]} rawTasks - 原始任务字符串数组
20
+ * @returns {string[]} 过滤后的有效任务列表
21
+ * @throws {ClawtError} 任务列表为空时抛出
22
+ */
23
+ export function parseTasksFromOptions(rawTasks: string[]): string[] {
24
+ const tasks = rawTasks.map((t) => t.trim()).filter(Boolean);
25
+ if (tasks.length === 0) {
26
+ throw new ClawtError(EMPTY_TASKS_MESSAGE);
27
+ }
28
+ return tasks;
29
+ }
30
+
13
31
  /**
14
32
  * 解析任务文件内容,提取所有任务块
15
33
  * 每个块内 `# branch: <name>` 为分支名,其余行为任务描述
@@ -19,7 +19,7 @@ vi.mock('../../../src/utils/index.js', () => ({
19
19
  validateClaudeCodeInstalled: vi.fn(),
20
20
  getProjectWorktrees: vi.fn(),
21
21
  launchInteractiveClaude: vi.fn(),
22
- resolveTargetWorktree: vi.fn(),
22
+ resolveTargetWorktrees: vi.fn(),
23
23
  }));
24
24
 
25
25
  import { registerResumeCommand } from '../../../src/commands/resume.js';
@@ -28,21 +28,21 @@ import {
28
28
  validateClaudeCodeInstalled,
29
29
  getProjectWorktrees,
30
30
  launchInteractiveClaude,
31
- resolveTargetWorktree,
31
+ resolveTargetWorktrees,
32
32
  } from '../../../src/utils/index.js';
33
33
 
34
34
  const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
35
35
  const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
36
36
  const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
37
37
  const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
38
- const mockedResolveTargetWorktree = vi.mocked(resolveTargetWorktree);
38
+ const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
39
39
 
40
40
  beforeEach(() => {
41
41
  mockedValidateMainWorktree.mockReset();
42
42
  mockedValidateClaudeCodeInstalled.mockReset();
43
43
  mockedGetProjectWorktrees.mockReset();
44
44
  mockedLaunchInteractiveClaude.mockReset();
45
- mockedResolveTargetWorktree.mockReset();
45
+ mockedResolveTargetWorktrees.mockReset();
46
46
  });
47
47
 
48
48
  describe('registerResumeCommand', () => {
@@ -58,7 +58,7 @@ describe('handleResume', () => {
58
58
  it('成功恢复 Claude Code 会话', async () => {
59
59
  const worktree = { path: '/path/feature', branch: 'feature' };
60
60
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
61
- mockedResolveTargetWorktree.mockResolvedValue(worktree);
61
+ mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
62
62
 
63
63
  const program = new Command();
64
64
  program.exitOverride();
@@ -67,14 +67,14 @@ describe('handleResume', () => {
67
67
 
68
68
  expect(mockedValidateMainWorktree).toHaveBeenCalled();
69
69
  expect(mockedValidateClaudeCodeInstalled).toHaveBeenCalled();
70
- expect(mockedResolveTargetWorktree).toHaveBeenCalled();
70
+ expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
71
71
  expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
72
72
  });
73
73
 
74
- it('不传 -b 时也能调用 resolveTargetWorktree', async () => {
74
+ it('不传 -b 时也能调用 resolveTargetWorktrees', async () => {
75
75
  const worktree = { path: '/path/feature', branch: 'feature' };
76
76
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
77
- mockedResolveTargetWorktree.mockResolvedValue(worktree);
77
+ mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
78
78
 
79
79
  const program = new Command();
80
80
  program.exitOverride();
@@ -82,7 +82,7 @@ describe('handleResume', () => {
82
82
  await program.parseAsync(['resume'], { from: 'user' });
83
83
 
84
84
  // branchName 参数为 undefined
85
- expect(mockedResolveTargetWorktree).toHaveBeenCalledWith(
85
+ expect(mockedResolveTargetWorktrees).toHaveBeenCalledWith(
86
86
  expect.any(Array),
87
87
  expect.any(Object),
88
88
  undefined,
@@ -31,6 +31,14 @@ vi.mock('../../../src/constants/index.js', () => ({
31
31
  BRANCH_OR_FILE_REQUIRED: '请指定 -b 或 -f',
32
32
  TASK_FILE_LOADED: (count: number, path: string) => `✓ 从 ${path} 加载了 ${count} 个任务`,
33
33
  TASK_FILE_MISSING_TASK_BY_INDEX: (blockIndex: number) => `第 ${blockIndex} 个任务块缺少任务描述`,
34
+ DRY_RUN_TITLE: 'Dry Run 预览',
35
+ DRY_RUN_TASK_COUNT: (count: number) => `任务数: ${count}`,
36
+ DRY_RUN_CONCURRENCY: (concurrency: number) => `并发数: ${concurrency === 0 ? '不限制' : concurrency}`,
37
+ DRY_RUN_WORKTREE_DIR: (dir: string) => `Worktree 目录: ${dir}`,
38
+ DRY_RUN_BRANCH_EXISTS_WARNING: (name: string) => `分支 ${name} 已存在`,
39
+ DRY_RUN_INTERACTIVE_MODE: '模式: 交互式(无预设任务)',
40
+ DRY_RUN_READY: '预览完成,无冲突。移除 --dry-run 即可正式执行。',
41
+ DRY_RUN_HAS_CONFLICT: '存在分支冲突,实际执行时将会报错。请先处理冲突的分支。',
34
42
  },
35
43
  }));
36
44
 
@@ -46,8 +54,11 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
46
54
  validateClaudeCodeInstalled: vi.fn(),
47
55
  createWorktrees: vi.fn(),
48
56
  sanitizeBranchName: vi.fn(),
57
+ generateBranchNames: vi.fn(),
49
58
  checkBranchExists: vi.fn(),
50
59
  getConfigValue: vi.fn().mockReturnValue(0),
60
+ parseConcurrency: vi.fn().mockReturnValue(0),
61
+ getProjectWorktreeDir: vi.fn().mockReturnValue('/mock/.clawt/worktrees/test-project'),
51
62
  printSuccess: vi.fn(),
52
63
  printError: vi.fn(),
53
64
  printWarning: vi.fn(),
@@ -57,7 +68,9 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
57
68
  confirmAction: vi.fn(),
58
69
  launchInteractiveClaude: vi.fn(),
59
70
  loadTaskFile: vi.fn(),
71
+ parseTasksFromOptions: vi.fn(),
60
72
  createWorktreesByBranches: vi.fn(),
73
+ printDryRunPreview: vi.fn(),
61
74
  };
62
75
  });
63
76
 
@@ -80,6 +93,7 @@ vi.mock('../../../src/utils/worktree.js', () => ({
80
93
 
81
94
  vi.mock('../../../src/utils/config.js', () => ({
82
95
  getConfigValue: vi.fn().mockReturnValue(0),
96
+ parseConcurrency: vi.fn().mockReturnValue(0),
83
97
  loadConfig: vi.fn(),
84
98
  writeDefaultConfig: vi.fn(),
85
99
  ensureClawtDirs: vi.fn(),
@@ -110,30 +124,45 @@ vi.mock('../../../src/utils/progress.js', () => ({
110
124
  },
111
125
  }));
112
126
 
127
+ vi.mock('../../../src/utils/dry-run.js', () => ({
128
+ truncateTaskDesc: vi.fn(),
129
+ printDryRunPreview: vi.fn(),
130
+ }));
131
+
113
132
  import { registerRunCommand } from '../../../src/commands/run.js';
114
133
  import {
115
134
  createWorktrees,
116
135
  createWorktreesByBranches,
117
136
  sanitizeBranchName,
137
+ generateBranchNames,
118
138
  checkBranchExists,
139
+ parseConcurrency,
119
140
  printSuccess,
141
+ printDryRunPreview,
120
142
  launchInteractiveClaude,
121
143
  getConfigValue,
122
144
  loadTaskFile,
145
+ parseTasksFromOptions,
146
+ validateClaudeCodeInstalled,
123
147
  } from '../../../src/utils/index.js';
124
148
  import { spawnProcess } from '../../../src/utils/shell.js';
125
- import { printInfo } from '../../../src/utils/formatter.js';
149
+ import { printInfo as formatterPrintInfo } from '../../../src/utils/formatter.js';
126
150
 
127
151
  const mockedCreateWorktrees = vi.mocked(createWorktrees);
128
152
  const mockedCreateWorktreesByBranches = vi.mocked(createWorktreesByBranches);
129
153
  const mockedSanitizeBranchName = vi.mocked(sanitizeBranchName);
154
+ const mockedGenerateBranchNames = vi.mocked(generateBranchNames);
130
155
  const mockedCheckBranchExists = vi.mocked(checkBranchExists);
156
+ const mockedParseConcurrency = vi.mocked(parseConcurrency);
131
157
  const mockedSpawnProcess = vi.mocked(spawnProcess);
132
158
  const mockedPrintSuccess = vi.mocked(printSuccess);
133
- const mockedPrintInfo = vi.mocked(printInfo);
159
+ const mockedPrintDryRunPreview = vi.mocked(printDryRunPreview);
160
+ const mockedFormatterPrintInfo = vi.mocked(formatterPrintInfo);
134
161
  const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
135
162
  const mockedGetConfigValue = vi.mocked(getConfigValue);
136
163
  const mockedLoadTaskFile = vi.mocked(loadTaskFile);
164
+ const mockedParseTasksFromOptions = vi.mocked(parseTasksFromOptions);
165
+ const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
137
166
 
138
167
  /**
139
168
  * 创建模拟子进程
@@ -166,16 +195,33 @@ beforeEach(() => {
166
195
  mockedCreateWorktrees.mockReset();
167
196
  mockedCreateWorktreesByBranches.mockReset();
168
197
  mockedSanitizeBranchName.mockReset();
198
+ mockedGenerateBranchNames.mockReset();
169
199
  mockedCheckBranchExists.mockReset();
200
+ mockedParseConcurrency.mockReset();
201
+ mockedParseConcurrency.mockReturnValue(0);
170
202
  mockedSpawnProcess.mockReset();
171
203
  mockedPrintSuccess.mockReset();
172
- mockedPrintInfo.mockReset();
204
+ mockedPrintDryRunPreview.mockReset();
205
+ mockedFormatterPrintInfo.mockReset();
173
206
  mockedLaunchInteractiveClaude.mockReset();
174
207
  mockedGetConfigValue.mockReset();
175
208
  mockedGetConfigValue.mockReturnValue(0 as any);
176
209
  mockedLoadTaskFile.mockReset();
210
+ mockedParseTasksFromOptions.mockReset();
211
+ mockedValidateClaudeCodeInstalled.mockReset();
177
212
  // sanitizeBranchName 默认返回输入值
178
213
  mockedSanitizeBranchName.mockImplementation((name: string) => name);
214
+ // generateBranchNames 默认使用真实逻辑
215
+ mockedGenerateBranchNames.mockImplementation((name: string, count: number) => {
216
+ if (count === 1) return [name];
217
+ return Array.from({ length: count }, (_, i) => `${name}-${i + 1}`);
218
+ });
219
+ // parseTasksFromOptions 默认使用真实逻辑
220
+ mockedParseTasksFromOptions.mockImplementation((rawTasks: string[]) => {
221
+ const tasks = rawTasks.map((t) => t.trim()).filter(Boolean);
222
+ if (tasks.length === 0) throw new Error('任务列表不能为空');
223
+ return tasks;
224
+ });
179
225
  });
180
226
 
181
227
  describe('registerRunCommand', () => {
@@ -285,6 +331,7 @@ describe('handleRun', () => {
285
331
  });
286
332
 
287
333
  it('传 --concurrency 限制并发数', async () => {
334
+ mockedParseConcurrency.mockReturnValue(1);
288
335
  const worktrees = [
289
336
  { path: '/path/feat-1', branch: 'feat-1' },
290
337
  { path: '/path/feat-2', branch: 'feat-2' },
@@ -311,7 +358,7 @@ describe('handleRun', () => {
311
358
  // 所有任务都应执行完毕
312
359
  expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
313
360
  // 应输出并发限制提示
314
- expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
361
+ expect(mockedFormatterPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
315
362
  });
316
363
 
317
364
  it('--concurrency 为 0 时不限制并发', async () => {
@@ -336,12 +383,13 @@ describe('handleRun', () => {
336
383
  await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', '-c', '0'], { from: 'user' });
337
384
 
338
385
  // 不限制并发时不输出并发限制提示
339
- expect(mockedPrintInfo).not.toHaveBeenCalledWith(expect.stringContaining('并发限制'));
386
+ expect(mockedFormatterPrintInfo).not.toHaveBeenCalledWith(expect.stringContaining('并发限制'));
340
387
  expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
341
388
  });
342
389
 
343
390
  it('未传 -c 时使用全局配置的 maxConcurrency', async () => {
344
391
  mockedGetConfigValue.mockReturnValue(2 as any);
392
+ mockedParseConcurrency.mockReturnValue(2);
345
393
 
346
394
  const worktrees = [
347
395
  { path: '/path/feat-1', branch: 'feat-1' },
@@ -366,7 +414,7 @@ describe('handleRun', () => {
366
414
  await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', 'task3'], { from: 'user' });
367
415
 
368
416
  // 应输出并发限制提示(使用配置值 2)
369
- expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
417
+ expect(mockedFormatterPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
370
418
  expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
371
419
  });
372
420
 
@@ -454,3 +502,109 @@ describe('handleRun', () => {
454
502
  ).rejects.toThrow();
455
503
  });
456
504
  });
505
+
506
+ describe('handleRun --dry-run', () => {
507
+ it('--dry-run + --tasks 展示任务预览,不创建 worktree 也不启动 Claude Code', async () => {
508
+ const program = new Command();
509
+ program.exitOverride();
510
+ registerRunCommand(program);
511
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', '实现登录功能', '修复首页bug', '--dry-run'], { from: 'user' });
512
+
513
+ // 不创建 worktree
514
+ expect(mockedCreateWorktrees).not.toHaveBeenCalled();
515
+ expect(mockedCreateWorktreesByBranches).not.toHaveBeenCalled();
516
+ // 不启动 Claude Code
517
+ expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
518
+ expect(mockedSpawnProcess).not.toHaveBeenCalled();
519
+ // 应调用 generateBranchNames 生成分支名
520
+ expect(mockedGenerateBranchNames).toHaveBeenCalledWith('feat', 2);
521
+ // 应调用 printDryRunPreview 输出预览
522
+ expect(mockedPrintDryRunPreview).toHaveBeenCalledWith(['feat-1', 'feat-2'], ['实现登录功能', '修复首页bug'], 0);
523
+ });
524
+
525
+ it('--dry-run 不调用 validateClaudeCodeInstalled', async () => {
526
+ const program = new Command();
527
+ program.exitOverride();
528
+ registerRunCommand(program);
529
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', '--dry-run'], { from: 'user' });
530
+
531
+ expect(mockedValidateClaudeCodeInstalled).not.toHaveBeenCalled();
532
+ });
533
+
534
+ it('--dry-run 交互式模式展示单个 worktree 信息', async () => {
535
+ const program = new Command();
536
+ program.exitOverride();
537
+ registerRunCommand(program);
538
+ await program.parseAsync(['run', '-b', 'feat', '--dry-run'], { from: 'user' });
539
+
540
+ // 不创建 worktree
541
+ expect(mockedCreateWorktrees).not.toHaveBeenCalled();
542
+ // 不启动 Claude Code
543
+ expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
544
+ // 交互式模式:分支名列表为单个,任务为空数组
545
+ expect(mockedPrintDryRunPreview).toHaveBeenCalledWith(['feat'], [], 0);
546
+ });
547
+
548
+ it('--dry-run + -f 从任务文件展示预览', async () => {
549
+ mockedLoadTaskFile.mockReturnValue([
550
+ { branch: 'feat-login', task: '实现登录功能' },
551
+ { branch: 'fix-bug', task: '修复问题' },
552
+ ]);
553
+
554
+ const program = new Command();
555
+ program.exitOverride();
556
+ registerRunCommand(program);
557
+ await program.parseAsync(['run', '-f', 'tasks.md', '--dry-run'], { from: 'user' });
558
+
559
+ // 应加载任务文件
560
+ expect(mockedLoadTaskFile).toHaveBeenCalledWith('tasks.md', { branchRequired: true });
561
+ // 不创建 worktree
562
+ expect(mockedCreateWorktrees).not.toHaveBeenCalled();
563
+ expect(mockedCreateWorktreesByBranches).not.toHaveBeenCalled();
564
+ // 不启动 Claude Code
565
+ expect(mockedSpawnProcess).not.toHaveBeenCalled();
566
+ // 应调用 printDryRunPreview
567
+ expect(mockedPrintDryRunPreview).toHaveBeenCalledWith(
568
+ ['feat-login', 'fix-bug'],
569
+ ['实现登录功能', '修复问题'],
570
+ 0,
571
+ );
572
+ });
573
+
574
+ it('--dry-run + -f + -b 模式使用 -b 自动编号', async () => {
575
+ mockedLoadTaskFile.mockReturnValue([
576
+ { task: '任务1' },
577
+ { task: '任务2' },
578
+ ]);
579
+
580
+ const program = new Command();
581
+ program.exitOverride();
582
+ registerRunCommand(program);
583
+ await program.parseAsync(['run', '-b', 'feat', '-f', 'tasks.md', '--dry-run'], { from: 'user' });
584
+
585
+ // 应使用 generateBranchNames 自动编号
586
+ expect(mockedGenerateBranchNames).toHaveBeenCalledWith('feat', 2);
587
+ // 不实际创建 worktree
588
+ expect(mockedCreateWorktrees).not.toHaveBeenCalled();
589
+ // 应调用 printDryRunPreview
590
+ expect(mockedPrintDryRunPreview).toHaveBeenCalled();
591
+ });
592
+
593
+ it('--dry-run 传递并发配置给 printDryRunPreview', async () => {
594
+ mockedParseConcurrency.mockReturnValue(3);
595
+
596
+ const program = new Command();
597
+ program.exitOverride();
598
+ registerRunCommand(program);
599
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', '-c', '3', '--dry-run'], { from: 'user' });
600
+
601
+ // 不创建 worktree
602
+ expect(mockedCreateWorktrees).not.toHaveBeenCalled();
603
+ // 应将并发数传递给 printDryRunPreview
604
+ expect(mockedPrintDryRunPreview).toHaveBeenCalledWith(
605
+ expect.any(Array),
606
+ expect.any(Array),
607
+ 3,
608
+ );
609
+ });
610
+ });
@@ -12,7 +12,6 @@ describe('MESSAGES', () => {
12
12
  'TARGET_WORKTREE_CLEAN',
13
13
  'MERGE_CONFLICT',
14
14
  'COMMIT_MESSAGE_REQUIRED',
15
- 'TARGET_WORKTREE_DIRTY_NO_MESSAGE',
16
15
  'TARGET_WORKTREE_NO_CHANGES',
17
16
  'INTERRUPTED',
18
17
  'INTERRUPT_CONFIRM_CLEANUP',
@@ -49,6 +48,11 @@ describe('MESSAGES', () => {
49
48
  });
50
49
 
51
50
  describe('模板函数消息', () => {
51
+ it('TARGET_WORKTREE_DIRTY_NO_MESSAGE 包含 worktree 路径', () => {
52
+ const result = MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE('/path/to/wt');
53
+ expect(result).toContain('/path/to/wt');
54
+ });
55
+
52
56
  it('BRANCH_EXISTS 包含分支名', () => {
53
57
  const result = MESSAGES.BRANCH_EXISTS('feature-a');
54
58
  expect(result).toContain('feature-a');
@@ -18,7 +18,7 @@ vi.mock('../../../src/utils/fs.js', () => ({
18
18
  }));
19
19
 
20
20
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
- import { loadConfig, getConfigValue, writeDefaultConfig, writeConfig, saveConfig, ensureClawtDirs } from '../../../src/utils/config.js';
21
+ import { loadConfig, getConfigValue, writeDefaultConfig, writeConfig, saveConfig, ensureClawtDirs, parseConcurrency } from '../../../src/utils/config.js';
22
22
  import { DEFAULT_CONFIG } from '../../../src/constants/index.js';
23
23
  import { ensureDir } from '../../../src/utils/fs.js';
24
24
 
@@ -96,6 +96,28 @@ describe('ensureClawtDirs', () => {
96
96
  });
97
97
  });
98
98
 
99
+ describe('parseConcurrency', () => {
100
+ it('命令行参数 undefined 时返回配置值', () => {
101
+ expect(parseConcurrency(undefined, 5)).toBe(5);
102
+ });
103
+
104
+ it('命令行参数为有效正整数时返回解析值', () => {
105
+ expect(parseConcurrency('3', 0)).toBe(3);
106
+ });
107
+
108
+ it('命令行参数为 0 时返回 0(不限制)', () => {
109
+ expect(parseConcurrency('0', 5)).toBe(0);
110
+ });
111
+
112
+ it('命令行参数为负数时抛出错误', () => {
113
+ expect(() => parseConcurrency('-1', 0)).toThrow();
114
+ });
115
+
116
+ it('命令行参数为非数字时抛出错误', () => {
117
+ expect(() => parseConcurrency('abc', 0)).toThrow();
118
+ });
119
+ });
120
+
99
121
  describe('saveConfig', () => {
100
122
  it('将配置对象写入配置文件', () => {
101
123
  const customConfig = { ...DEFAULT_CONFIG, autoDeleteBranch: true };