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,365 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+ import { logger } from '../logger/index.js';
3
+ import { MESSAGES } from '../constants/index.js';
4
+ import type { ClaudeCodeResult, TaskResult, TaskSummary, WorktreeInfo } from '../types/index.js';
5
+ import { spawnProcess, killAllChildProcesses } from './shell.js';
6
+ import { cleanupWorktrees } from './worktree.js';
7
+ import { getConfigValue } from './config.js';
8
+ import { printSuccess, printWarning, printInfo, printDoubleSeparator, confirmAction } from './formatter.js';
9
+ import { ProgressRenderer } from './progress.js';
10
+
11
+ /** executeClaudeTask 的返回结构,包含子进程引用和结果 Promise */
12
+ interface ClaudeTaskHandle {
13
+ /** 子进程实例,用于在中断时终止 */
14
+ child: ChildProcess;
15
+ /** 任务结果 Promise */
16
+ promise: Promise<TaskResult>;
17
+ }
18
+
19
+ /**
20
+ * 在指定 worktree 中执行 Claude Code 任务,由于是--output-format json形式,所以这里固定claude code cli的启动命令
21
+ * @param {WorktreeInfo} worktree - worktree 信息
22
+ * @param {string} task - 任务描述
23
+ * @returns {ClaudeTaskHandle} 包含子进程引用和结果 Promise
24
+ */
25
+ function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHandle {
26
+ const child = spawnProcess(
27
+ 'claude',
28
+ ['-p', task, '--output-format', 'json', '--permission-mode', 'bypassPermissions'],
29
+ {
30
+ cwd: worktree.path,
31
+ // stdin 必须设置为 'ignore',不能用 'pipe'
32
+ // 原因:claude -p 是非交互模式,不需要 stdin 输入。如果 stdin 为 'pipe',
33
+ // 父进程会创建一个可写流连接到子进程但从不写入也不关闭,
34
+ // claude 检测到 stdin 是管道后会尝试读取输入,导致进程永远卡住
35
+ stdio: ['ignore', 'pipe', 'pipe'],
36
+ },
37
+ );
38
+
39
+ const promise = new Promise<TaskResult>((resolve) => {
40
+ let stdout = '';
41
+ let stderr = '';
42
+
43
+ child.stdout?.on('data', (data: Buffer) => {
44
+ stdout += data.toString();
45
+ });
46
+
47
+ child.stderr?.on('data', (data: Buffer) => {
48
+ stderr += data.toString();
49
+ });
50
+
51
+ child.on('close', (code) => {
52
+ let result: ClaudeCodeResult | null = null;
53
+ let success = code === 0;
54
+
55
+ try {
56
+ if (stdout.trim()) {
57
+ result = JSON.parse(stdout.trim()) as ClaudeCodeResult;
58
+ success = !result.is_error;
59
+ }
60
+ } catch {
61
+ logger.warn(`解析 Claude Code 输出失败: ${stdout.substring(0, 200)}`);
62
+ }
63
+
64
+ resolve({
65
+ task,
66
+ branch: worktree.branch,
67
+ worktreePath: worktree.path,
68
+ success,
69
+ result,
70
+ error: success ? undefined : stderr || '任务执行失败',
71
+ });
72
+ });
73
+
74
+ child.on('error', (err) => {
75
+ resolve({
76
+ task,
77
+ branch: worktree.branch,
78
+ worktreePath: worktree.path,
79
+ success: false,
80
+ result: null,
81
+ error: err.message,
82
+ });
83
+ });
84
+ });
85
+
86
+ return { child, promise };
87
+ }
88
+
89
+ /**
90
+ * 输出所有任务的汇总信息
91
+ * @param {TaskSummary} summary - 汇总信息
92
+ */
93
+ function printTaskSummary(summary: TaskSummary): void {
94
+ printDoubleSeparator();
95
+ printInfo(`全部任务已完成 (${summary.total}/${summary.total})`);
96
+ printInfo(` 成功: ${summary.succeeded}`);
97
+ printInfo(` 失败: ${summary.failed}`);
98
+ printInfo(` 总耗时: ${(summary.totalDurationMs / 1000).toFixed(1)}s`);
99
+ printInfo(` 总花费: $${summary.totalCostUsd.toFixed(2)}`);
100
+ printDoubleSeparator();
101
+ }
102
+
103
+ /**
104
+ * 处理用户中断(Ctrl+C)后的清理流程
105
+ * 根据全局配置决定自动清理或交互式确认
106
+ * @param {WorktreeInfo[]} worktrees - 本次创建的 worktree 列表
107
+ */
108
+ async function handleInterruptCleanup(worktrees: WorktreeInfo[]): Promise<void> {
109
+ const autoDelete = getConfigValue('autoDeleteBranch');
110
+
111
+ if (autoDelete) {
112
+ // 全局配置了自动删除,直接清理
113
+ cleanupWorktrees(worktrees);
114
+ printSuccess(MESSAGES.INTERRUPT_AUTO_CLEANED(worktrees.length));
115
+ return;
116
+ }
117
+
118
+ // 交互式确认是否清理
119
+ const shouldClean = await confirmAction(MESSAGES.INTERRUPT_CONFIRM_CLEANUP);
120
+
121
+ if (shouldClean) {
122
+ cleanupWorktrees(worktrees);
123
+ printSuccess(MESSAGES.INTERRUPT_CLEANED(worktrees.length));
124
+ } else {
125
+ printInfo(MESSAGES.INTERRUPT_KEPT);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 更新进度面板中指定任务的完成/失败状态
131
+ * @param {ProgressRenderer} renderer - 进度面板渲染器
132
+ * @param {number} index - 任务索引(从 0 开始)
133
+ * @param {TaskResult} result - 任务执行结果
134
+ * @param {number} startTime - 任务批次启动时间戳
135
+ */
136
+ function updateRendererStatus(renderer: ProgressRenderer, index: number, result: TaskResult, startTime: number): void {
137
+ if (result.success) {
138
+ renderer.markDone(
139
+ index,
140
+ result.result?.duration_ms ?? (Date.now() - startTime),
141
+ result.result?.total_cost_usd ?? 0,
142
+ );
143
+ } else {
144
+ renderer.markFailed(
145
+ index,
146
+ result.result?.duration_ms ?? (Date.now() - startTime),
147
+ );
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 以并发限制模式执行任务队列
153
+ * 维护活跃任务池,某个任务完成后立即启动队列中下一个
154
+ * @param {WorktreeInfo[]} worktrees - worktree 列表
155
+ * @param {string[]} tasks - 任务描述列表
156
+ * @param {number} concurrency - 最大并发数
157
+ * @param {ProgressRenderer} renderer - 进度面板渲染器
158
+ * @param {number} startTime - 任务批次启动时间戳
159
+ * @param {() => boolean} isInterrupted - 检查是否已中断的函数
160
+ * @param {ChildProcess[]} childProcesses - 共享子进程数组,执行过程中动态追加
161
+ * @returns {Promise<TaskResult[]>} 所有任务结果
162
+ */
163
+ async function executeWithConcurrency(
164
+ worktrees: WorktreeInfo[],
165
+ tasks: string[],
166
+ concurrency: number,
167
+ renderer: ProgressRenderer,
168
+ startTime: number,
169
+ isInterrupted: () => boolean,
170
+ childProcesses: ChildProcess[],
171
+ ): Promise<TaskResult[]> {
172
+ const total = tasks.length;
173
+ const results: TaskResult[] = new Array(total);
174
+ let nextIndex = 0;
175
+ let completedCount = 0;
176
+
177
+ return new Promise((resolve) => {
178
+ /**
179
+ * 启动下一个排队中的任务
180
+ * 从队列中取出任务并启动执行,完成时递归调用自身
181
+ */
182
+ function launchNext(): void {
183
+ if (nextIndex >= total || isInterrupted()) return;
184
+
185
+ const index = nextIndex;
186
+ nextIndex++;
187
+
188
+ const wt = worktrees[index];
189
+ const task = tasks[index];
190
+ logger.info(`启动任务 ${index + 1}: ${task} (worktree: ${wt.path})`);
191
+
192
+ // 标记为运行中
193
+ renderer.markRunning(index);
194
+
195
+ const handle = executeClaudeTask(wt, task);
196
+ childProcesses.push(handle.child);
197
+
198
+ // 监听 stderr 输出,更新任务活动时间戳
199
+ handle.child.stderr?.on('data', () => {
200
+ renderer.updateActivity(index);
201
+ });
202
+
203
+ handle.promise.then((result) => {
204
+ results[index] = result;
205
+ completedCount++;
206
+
207
+ // 被中断时不再更新面板
208
+ if (!isInterrupted()) {
209
+ updateRendererStatus(renderer, index, result, startTime);
210
+ }
211
+
212
+ // 启动下一个排队任务
213
+ launchNext();
214
+
215
+ // 所有任务完成时 resolve
216
+ if (completedCount === total) {
217
+ resolve(results);
218
+ }
219
+ });
220
+ }
221
+
222
+ // 初始启动 concurrency 个任务
223
+ const initialBatch = Math.min(concurrency, total);
224
+ for (let i = 0; i < initialBatch; i++) {
225
+ launchNext();
226
+ }
227
+ });
228
+ }
229
+
230
+ /**
231
+ * 以全量并行模式执行所有任务
232
+ * @param {WorktreeInfo[]} worktrees - worktree 列表
233
+ * @param {string[]} tasks - 任务描述列表
234
+ * @param {ProgressRenderer} renderer - 进度面板渲染器
235
+ * @param {number} startTime - 任务批次启动时间戳
236
+ * @param {() => boolean} isInterrupted - 检查是否已中断的函数
237
+ * @param {ChildProcess[]} childProcesses - 共享子进程数组,启动时追加
238
+ * @returns {Promise<TaskResult[]>} 所有任务结果
239
+ */
240
+ async function executeAllParallel(
241
+ worktrees: WorktreeInfo[],
242
+ tasks: string[],
243
+ renderer: ProgressRenderer,
244
+ startTime: number,
245
+ isInterrupted: () => boolean,
246
+ childProcesses: ChildProcess[],
247
+ ): Promise<TaskResult[]> {
248
+ const handles = worktrees.map((wt, index) => {
249
+ const task = tasks[index];
250
+ logger.info(`启动任务 ${index + 1}: ${task} (worktree: ${wt.path})`);
251
+ const handle = executeClaudeTask(wt, task);
252
+ childProcesses.push(handle.child);
253
+
254
+ // 监听 stderr 输出,更新任务活动时间戳
255
+ handle.child.stderr?.on('data', () => {
256
+ renderer.updateActivity(index);
257
+ });
258
+
259
+ return handle;
260
+ });
261
+
262
+ const results = await Promise.all(
263
+ handles.map((handle, index) =>
264
+ handle.promise.then((result) => {
265
+ // 被中断时不再更新面板
266
+ if (!isInterrupted()) {
267
+ updateRendererStatus(renderer, index, result, startTime);
268
+ }
269
+ return result;
270
+ }),
271
+ ),
272
+ );
273
+
274
+ return results;
275
+ }
276
+
277
+ /**
278
+ * 批量任务执行的公共逻辑
279
+ * 负责进度面板、SIGINT 处理、并发控制、汇总输出
280
+ * @param {WorktreeInfo[]} worktrees - worktree 列表
281
+ * @param {string[]} tasks - 任务描述列表
282
+ * @param {number} concurrency - 最大并发数,0 表示不限制
283
+ */
284
+ export async function executeBatchTasks(
285
+ worktrees: WorktreeInfo[],
286
+ tasks: string[],
287
+ concurrency: number,
288
+ ): Promise<void> {
289
+ const count = tasks.length;
290
+
291
+ // 有并发限制时输出提示
292
+ if (concurrency > 0) {
293
+ printInfo(MESSAGES.CONCURRENCY_INFO(concurrency, count));
294
+ printInfo('');
295
+ }
296
+
297
+ // 实例化进度面板渲染器
298
+ const startTime = Date.now();
299
+ const branches = worktrees.map((wt) => wt.branch);
300
+ const paths = worktrees.map((wt) => wt.path);
301
+ // 有并发限制时任务初始化为 pending 状态,否则初始化为 running
302
+ const allRunning = concurrency === 0;
303
+ const renderer = new ProgressRenderer(branches, paths, allRunning);
304
+
305
+ // 启动进度面板渲染
306
+ renderer.start();
307
+
308
+ // 共享中断状态标志和子进程引用数组
309
+ let interrupted = false;
310
+ const isInterrupted = () => interrupted;
311
+ const childProcesses: ChildProcess[] = [];
312
+
313
+ // 监听 SIGINT(Ctrl+C),终止所有子进程并触发清理流程
314
+ const sigintHandler = async () => {
315
+ if (interrupted) return;
316
+ interrupted = true;
317
+
318
+ // 停止进度面板渲染
319
+ renderer.stop();
320
+
321
+ printInfo('');
322
+ printWarning(MESSAGES.INTERRUPTED);
323
+ killAllChildProcesses(childProcesses);
324
+
325
+ // 等待所有已启动的子进程退出后再执行清理
326
+ await Promise.allSettled(childProcesses.map((cp) =>
327
+ new Promise<void>((resolve) => {
328
+ if (cp.exitCode !== null) {
329
+ resolve();
330
+ } else {
331
+ cp.on('close', () => resolve());
332
+ }
333
+ }),
334
+ ));
335
+
336
+ await handleInterruptCleanup(worktrees);
337
+ process.exit(1);
338
+ };
339
+ process.on('SIGINT', sigintHandler);
340
+
341
+ // 根据并发限制选择执行模式
342
+ const results = concurrency > 0
343
+ ? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses)
344
+ : await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses);
345
+
346
+ // 正常完成,停止进度面板并移除 SIGINT 监听器
347
+ renderer.stop();
348
+ process.removeListener('SIGINT', sigintHandler);
349
+
350
+ // 被中断时不输出汇总(已在 sigintHandler 中处理退出)
351
+ if (interrupted) return;
352
+
353
+ const totalDurationMs = Date.now() - startTime;
354
+
355
+ // 汇总
356
+ const summary: TaskSummary = {
357
+ total: results.length,
358
+ succeeded: results.filter((r) => r.success).length,
359
+ failed: results.filter((r) => !r.success).length,
360
+ totalDurationMs,
361
+ totalCostUsd: results.reduce((sum, r) => sum + (r.result?.total_cost_usd ?? 0), 0),
362
+ };
363
+
364
+ printTaskSummary(summary);
365
+ }
@@ -0,0 +1,87 @@
1
+ import { resolve } from 'node:path';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { ClawtError } from '../errors/index.js';
4
+ import { MESSAGES } from '../constants/index.js';
5
+ import type { TaskFileEntry, ParseTaskFileOptions } from '../types/index.js';
6
+
7
+ /** 匹配任务块的正则:<!-- CLAWT-TASKS:START --> ... <!-- CLAWT-TASKS:END --> */
8
+ const TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
9
+
10
+ /** 匹配分支名行的正则:# branch: <name> */
11
+ const BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
12
+
13
+ /**
14
+ * 解析任务文件内容,提取所有任务块
15
+ * 每个块内 `# branch: <name>` 为分支名,其余行为任务描述
16
+ * @param {string} content - 文件内容
17
+ * @param {ParseTaskFileOptions} [options] - 解析选项
18
+ * @returns {TaskFileEntry[]} 解析出的任务列表
19
+ */
20
+ export function parseTaskFile(content: string, options?: ParseTaskFileOptions): TaskFileEntry[] {
21
+ const branchRequired = options?.branchRequired ?? true;
22
+ const entries: TaskFileEntry[] = [];
23
+ let match: RegExpExecArray | null;
24
+ let blockIndex = 0;
25
+
26
+ // 重置正则 lastIndex,避免模块级 /g 正则跨调用残留状态
27
+ TASK_BLOCK_REGEX.lastIndex = 0;
28
+
29
+ while ((match = TASK_BLOCK_REGEX.exec(content)) !== null) {
30
+ blockIndex++;
31
+ const blockContent = match[1].trim();
32
+ const lines = blockContent.split('\n');
33
+
34
+ let branch: string | undefined;
35
+ const taskLines: string[] = [];
36
+
37
+ for (const line of lines) {
38
+ const branchMatch = line.trim().match(BRANCH_LINE_REGEX);
39
+ if (branchMatch) {
40
+ branch = branchMatch[1].trim();
41
+ } else {
42
+ taskLines.push(line);
43
+ }
44
+ }
45
+
46
+ if (branchRequired && !branch) {
47
+ throw new ClawtError(MESSAGES.TASK_FILE_MISSING_BRANCH(blockIndex));
48
+ }
49
+
50
+ const task = taskLines.join('\n').trim();
51
+ if (!task) {
52
+ throw new ClawtError(
53
+ branch
54
+ ? MESSAGES.TASK_FILE_MISSING_TASK(branch)
55
+ : MESSAGES.TASK_FILE_MISSING_TASK_BY_INDEX(blockIndex),
56
+ );
57
+ }
58
+
59
+ entries.push({ branch, task });
60
+ }
61
+
62
+ return entries;
63
+ }
64
+
65
+ /**
66
+ * 读取并解析任务文件
67
+ * 支持相对路径(基于 cwd)和绝对路径
68
+ * @param {string} filePath - 文件路径
69
+ * @param {ParseTaskFileOptions} [options] - 解析选项
70
+ * @returns {TaskFileEntry[]} 解析出的任务列表
71
+ */
72
+ export function loadTaskFile(filePath: string, options?: ParseTaskFileOptions): TaskFileEntry[] {
73
+ const absolutePath = resolve(filePath);
74
+
75
+ if (!existsSync(absolutePath)) {
76
+ throw new ClawtError(MESSAGES.TASK_FILE_NOT_FOUND(absolutePath));
77
+ }
78
+
79
+ const content = readFileSync(absolutePath, 'utf-8');
80
+ const entries = parseTaskFile(content, options);
81
+
82
+ if (entries.length === 0) {
83
+ throw new ClawtError(MESSAGES.TASK_FILE_EMPTY);
84
+ }
85
+
86
+ return entries;
87
+ }
@@ -38,6 +38,21 @@ export function findFuzzyMatches(worktrees: WorktreeInfo[], keyword: string): Wo
38
38
  return worktrees.filter((wt) => wt.branch.toLowerCase().includes(lowerKeyword));
39
39
  }
40
40
 
41
+ /**
42
+ * 多选场景下的分支解析消息文案配置
43
+ * 与 WorktreeResolveMessages 类似,但用于需要多选的命令(如 remove)
44
+ */
45
+ export interface WorktreeMultiResolveMessages {
46
+ /** 无可用 worktree 时的错误消息 */
47
+ noWorktrees: string;
48
+ /** 未传分支名时的多选交互提示 */
49
+ selectBranch: string;
50
+ /** 模糊匹配到多个结果时的多选交互提示 */
51
+ multipleMatches: (keyword: string) => string;
52
+ /** 无匹配结果时的错误消息 */
53
+ noMatch: (keyword: string, branches: string[]) => string;
54
+ }
55
+
41
56
  /**
42
57
  * 通过交互式列表让用户从 worktree 列表中选择一个分支
43
58
  * @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
@@ -57,6 +72,83 @@ export async function promptSelectBranch(worktrees: WorktreeInfo[], message: str
57
72
  return worktrees.find((wt) => wt.branch === selectedBranch)!;
58
73
  }
59
74
 
75
+ /**
76
+ * 通过交互式多选列表让用户从 worktree 列表中选择多个分支
77
+ * 用户可通过空格键选择/取消,回车键确认
78
+ * @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
79
+ * @param {string} message - 选择提示信息
80
+ * @returns {Promise<WorktreeInfo[]>} 用户选择的 worktree 列表
81
+ */
82
+ export async function promptMultiSelectBranches(worktrees: WorktreeInfo[], message: string): Promise<WorktreeInfo[]> {
83
+ // @ts-expect-error enquirer 类型声明未导出 MultiSelect 类,但运行时存在
84
+ const selectedBranches: string[] = await new Enquirer.MultiSelect({
85
+ message,
86
+ choices: worktrees.map((wt) => ({
87
+ name: wt.branch,
88
+ message: wt.branch,
89
+ })),
90
+ // 使用空心圆/实心圆作为选中指示符
91
+ symbols: {
92
+ indicator: { on: '●', off: '○' },
93
+ },
94
+ }).run();
95
+
96
+ return worktrees.filter((wt) => selectedBranches.includes(wt.branch));
97
+ }
98
+
99
+ /**
100
+ * 根据用户输入解析目标 worktree(多选版本)
101
+ * 匹配策略:精确匹配 → 模糊匹配(唯一直接使用,多个交互多选) → 无匹配报错
102
+ * 不传分支名时列出所有可用分支供多选
103
+ * @param {WorktreeInfo[]} worktrees - 可用的 worktree 列表
104
+ * @param {WorktreeMultiResolveMessages} messages - 命令专属的消息文案
105
+ * @param {string} [branchName] - 用户输入的分支名(可选)
106
+ * @returns {Promise<WorktreeInfo[]>} 解析后的目标 worktree 列表
107
+ * @throws {ClawtError} 无可用 worktree 或无匹配结果时抛出
108
+ */
109
+ export async function resolveTargetWorktrees(
110
+ worktrees: WorktreeInfo[],
111
+ messages: WorktreeMultiResolveMessages,
112
+ branchName?: string,
113
+ ): Promise<WorktreeInfo[]> {
114
+ // 无可用 worktree,直接报错
115
+ if (worktrees.length === 0) {
116
+ throw new ClawtError(messages.noWorktrees);
117
+ }
118
+
119
+ // 未传 -b 参数:列出所有分支供多选
120
+ if (!branchName) {
121
+ // 只有一个 worktree 时直接使用,无需选择
122
+ if (worktrees.length === 1) {
123
+ return [worktrees[0]];
124
+ }
125
+ return promptMultiSelectBranches(worktrees, messages.selectBranch);
126
+ }
127
+
128
+ // 1. 精确匹配优先
129
+ const exactMatch = findExactMatch(worktrees, branchName);
130
+ if (exactMatch) {
131
+ return [exactMatch];
132
+ }
133
+
134
+ // 2. 模糊匹配
135
+ const fuzzyMatches = findFuzzyMatches(worktrees, branchName);
136
+
137
+ // 2a. 唯一匹配,直接使用
138
+ if (fuzzyMatches.length === 1) {
139
+ return [fuzzyMatches[0]];
140
+ }
141
+
142
+ // 2b. 多个匹配,交互多选
143
+ if (fuzzyMatches.length > 1) {
144
+ return promptMultiSelectBranches(fuzzyMatches, messages.multipleMatches(branchName));
145
+ }
146
+
147
+ // 3. 无匹配,抛出错误并列出所有可用分支
148
+ const allBranches = worktrees.map((wt) => wt.branch);
149
+ throw new ClawtError(messages.noMatch(branchName, allBranches));
150
+ }
151
+
60
152
  /**
61
153
  * 根据用户输入解析目标 worktree
62
154
  * 匹配策略:精确匹配 → 模糊匹配(唯一直接使用,多个交互选择) → 无匹配报错
@@ -49,6 +49,33 @@ export function createWorktrees(branchName: string, count: number): WorktreeInfo
49
49
  return results;
50
50
  }
51
51
 
52
+ /**
53
+ * 根据独立分支名列表逐个创建 worktree(不自动编号)
54
+ * 与 createWorktrees 不同,不使用 generateBranchNames 自动编号
55
+ * 调用方负责分支名清理(sanitizeBranchName)
56
+ * @param {string[]} branchNames - 已清理的分支名列表
57
+ * @returns {WorktreeInfo[]} 创建的 worktree 信息列表
58
+ */
59
+ export function createWorktreesByBranches(branchNames: string[]): WorktreeInfo[] {
60
+ // 1. 校验所有分支是否都不存在
61
+ validateBranchesNotExist(branchNames);
62
+
63
+ // 2. 确保项目 worktree 目录存在
64
+ const projectDir = getProjectWorktreeDir();
65
+ ensureDir(projectDir);
66
+
67
+ // 3. 串行创建 worktree
68
+ const results: WorktreeInfo[] = [];
69
+ for (const name of branchNames) {
70
+ const worktreePath = join(projectDir, name);
71
+ gitCreateWorktree(name, worktreePath);
72
+ results.push({ path: worktreePath, branch: name });
73
+ logger.info(`worktree 创建完成: ${worktreePath} (分支: ${name})`);
74
+ }
75
+
76
+ return results;
77
+ }
78
+
52
79
  /**
53
80
  * 获取当前项目在 ~/.clawt/worktrees/<project>/ 下的所有 worktree
54
81
  * 通过与 git worktree list 交叉验证确认有效性
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+
4
+ // mock 依赖模块
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/utils/index.js', () => ({
10
+ loadConfig: vi.fn(),
11
+ writeDefaultConfig: vi.fn(),
12
+ printInfo: vi.fn(),
13
+ printSuccess: vi.fn(),
14
+ printSeparator: vi.fn(),
15
+ confirmDestructiveAction: vi.fn(),
16
+ }));
17
+
18
+ vi.mock('../../../src/constants/index.js', () => ({
19
+ CONFIG_PATH: '/mock/.clawt/config.json',
20
+ DEFAULT_CONFIG: {
21
+ claudeCodeCommand: 'claude',
22
+ autoDeleteBranch: false,
23
+ autoPullPush: false,
24
+ confirmDestructiveOps: true,
25
+ },
26
+ CONFIG_DESCRIPTIONS: {
27
+ claudeCodeCommand: 'Claude Code CLI 命令',
28
+ autoDeleteBranch: '自动删除分支',
29
+ autoPullPush: '自动 pull/push',
30
+ confirmDestructiveOps: '破坏性操作确认',
31
+ },
32
+ MESSAGES: {
33
+ CONFIG_RESET_SUCCESS: '配置已恢复为默认值',
34
+ DESTRUCTIVE_OP_CANCELLED: '已取消操作',
35
+ },
36
+ }));
37
+
38
+ import { registerConfigCommand } from '../../../src/commands/config.js';
39
+ import { loadConfig, writeDefaultConfig, printInfo, printSuccess, confirmDestructiveAction } from '../../../src/utils/index.js';
40
+
41
+ const mockedLoadConfig = vi.mocked(loadConfig);
42
+ const mockedWriteDefaultConfig = vi.mocked(writeDefaultConfig);
43
+ const mockedPrintInfo = vi.mocked(printInfo);
44
+ const mockedPrintSuccess = vi.mocked(printSuccess);
45
+ const mockedConfirmDestructiveAction = vi.mocked(confirmDestructiveAction);
46
+
47
+ beforeEach(() => {
48
+ mockedLoadConfig.mockReset();
49
+ mockedWriteDefaultConfig.mockReset();
50
+ mockedPrintInfo.mockReset();
51
+ mockedPrintSuccess.mockReset();
52
+ mockedConfirmDestructiveAction.mockReset();
53
+ });
54
+
55
+ describe('registerConfigCommand', () => {
56
+ it('注册 config 命令和 config reset 子命令', () => {
57
+ const program = new Command();
58
+ registerConfigCommand(program);
59
+ const configCmd = program.commands.find((c) => c.name() === 'config');
60
+ expect(configCmd).toBeDefined();
61
+ const resetCmd = configCmd!.commands.find((c) => c.name() === 'reset');
62
+ expect(resetCmd).toBeDefined();
63
+ });
64
+ });
65
+
66
+ describe('handleConfig(通过 action 间接测试)', () => {
67
+ it('展示配置列表', () => {
68
+ mockedLoadConfig.mockReturnValue({
69
+ claudeCodeCommand: 'claude',
70
+ autoDeleteBranch: false,
71
+ autoPullPush: false,
72
+ confirmDestructiveOps: true,
73
+ });
74
+
75
+ const program = new Command();
76
+ program.exitOverride();
77
+ registerConfigCommand(program);
78
+ program.parse(['config'], { from: 'user' });
79
+
80
+ expect(mockedLoadConfig).toHaveBeenCalled();
81
+ // 应输出配置信息
82
+ expect(mockedPrintInfo).toHaveBeenCalled();
83
+ });
84
+ });
85
+
86
+ describe('handleConfigReset(通过 action 间接测试)', () => {
87
+ it('用户确认后恢复默认配置', async () => {
88
+ mockedConfirmDestructiveAction.mockResolvedValue(true);
89
+
90
+ const program = new Command();
91
+ program.exitOverride();
92
+ registerConfigCommand(program);
93
+ await program.parseAsync(['config', 'reset'], { from: 'user' });
94
+
95
+ expect(mockedWriteDefaultConfig).toHaveBeenCalled();
96
+ expect(mockedPrintSuccess).toHaveBeenCalled();
97
+ });
98
+
99
+ it('用户取消操作时不写入', async () => {
100
+ mockedConfirmDestructiveAction.mockResolvedValue(false);
101
+
102
+ const program = new Command();
103
+ program.exitOverride();
104
+ registerConfigCommand(program);
105
+ await program.parseAsync(['config', 'reset'], { from: 'user' });
106
+
107
+ expect(mockedWriteDefaultConfig).not.toHaveBeenCalled();
108
+ expect(mockedPrintInfo).toHaveBeenCalled();
109
+ });
110
+ });