clawt 2.16.0 → 2.16.2

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.
package/README.md CHANGED
@@ -123,11 +123,18 @@ clawt validate -b <branch> # 将变更迁移到主 worktree
123
123
  clawt validate -b <branch> --clean # 清理 validate 状态
124
124
  clawt validate -b <branch> -r "npm test" # validate 成功后自动运行测试
125
125
  clawt validate -b <branch> -r "npm run build" # validate 成功后自动构建
126
+ clawt validate -b <branch> -r "pnpm test & pnpm build" # 并行执行多个命令
126
127
  ```
127
128
 
128
129
  支持增量模式:再次 validate 同一分支时,可通过 `git diff` 查看两次之间的增量差异。
129
130
 
130
- `-r, --run` 选项可在 validate 成功后自动在主 worktree 中执行指定命令(如测试、构建等),命令执行失败不影响 validate 结果。
131
+ `-r, --run` 选项可在 validate 成功后自动在主 worktree 中执行指定命令(如测试、构建等),命令执行失败不影响 validate 结果。支持用 `&` 分隔多个命令并行执行:
132
+
133
+ | 用法 | 行为 |
134
+ | ---- | ---- |
135
+ | `-r "npm test"` | 单命令,同步执行 |
136
+ | `-r "npm lint && npm test"` | `&&` 不拆分,同步执行 |
137
+ | `-r "pnpm test & pnpm build"` | 并行执行,等全部完成后汇总结果 |
131
138
 
132
139
  ### `clawt sync` — 同步主分支代码到目标 worktree
133
140
 
package/dist/index.js CHANGED
@@ -197,7 +197,21 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
197
197
  /** --run 命令执行失败(退出码非 0) */
198
198
  VALIDATE_RUN_FAILED: (command, exitCode) => `\u2717 \u547D\u4EE4\u6267\u884C\u5B8C\u6210: ${command}\uFF0C\u9000\u51FA\u7801: ${exitCode}`,
199
199
  /** --run 命令执行异常(进程启动失败等) */
200
- VALIDATE_RUN_ERROR: (command, errorMessage) => `\u2717 \u547D\u4EE4\u6267\u884C\u51FA\u9519: ${errorMessage}`
200
+ VALIDATE_RUN_ERROR: (command, errorMessage) => `\u2717 \u547D\u4EE4\u6267\u884C\u51FA\u9519: ${errorMessage}`,
201
+ /** 并行命令开始执行提示 */
202
+ VALIDATE_PARALLEL_RUN_START: (count) => `\u6B63\u5728\u5E76\u884C\u6267\u884C ${count} \u4E2A\u547D\u4EE4...`,
203
+ /** 并行执行中单个命令开始提示(带序号) */
204
+ VALIDATE_PARALLEL_CMD_START: (index, total, command) => `[${index}/${total}] ${command}`,
205
+ /** 并行执行全部成功汇总提示 */
206
+ VALIDATE_PARALLEL_RUN_ALL_SUCCESS: (count) => `\u2713 \u5168\u90E8 ${count} \u4E2A\u547D\u4EE4\u6267\u884C\u6210\u529F`,
207
+ /** 并行执行部分失败汇总提示 */
208
+ VALIDATE_PARALLEL_RUN_SUMMARY: (successCount, failedCount) => `\u5171 ${successCount + failedCount} \u4E2A\u547D\u4EE4\uFF0C${successCount} \u4E2A\u6210\u529F\uFF0C${failedCount} \u4E2A\u5931\u8D25`,
209
+ /** 并行执行中单个命令成功 */
210
+ VALIDATE_PARALLEL_CMD_SUCCESS: (command) => ` \u2713 ${command}`,
211
+ /** 并行执行中单个命令失败 */
212
+ VALIDATE_PARALLEL_CMD_FAILED: (command, exitCode) => ` \u2717 ${command}\uFF08\u9000\u51FA\u7801: ${exitCode}\uFF09`,
213
+ /** 并行执行中单个命令启动失败 */
214
+ VALIDATE_PARALLEL_CMD_ERROR: (command, errorMessage) => ` \u2717 ${command}\uFF08\u9519\u8BEF: ${errorMessage}\uFF09`
201
215
  };
202
216
 
203
217
  // src/constants/messages/sync.ts
@@ -261,7 +275,9 @@ var REMOVE_MESSAGES = {
261
275
  ${branches.map((b) => ` - ${b}`).join("\n")}`,
262
276
  /** 批量移除部分失败 */
263
277
  REMOVE_PARTIAL_FAILURE: (failures) => `\u4EE5\u4E0B worktree \u79FB\u9664\u5931\u8D25\uFF1A
264
- ${failures.map((f) => ` \u2717 ${f.path}: ${f.error}`).join("\n")}`
278
+ ${failures.map((f) => ` \u2717 ${f.path}: ${f.error}`).join("\n")}`,
279
+ /** 用户选择保留本地分支 */
280
+ REMOVE_BRANCHES_KEPT: "\u5DF2\u4FDD\u7559\u672C\u5730\u5206\u652F\uFF0C\u53EF\u7A0D\u540E\u4F7F\u7528 git branch -D <\u5206\u652F\u540D> \u624B\u52A8\u5220\u9664"
265
281
  };
266
282
 
267
283
  // src/constants/messages/reset.ts
@@ -571,6 +587,31 @@ function runCommandInherited(command, options) {
571
587
  shell: true
572
588
  });
573
589
  }
590
+ function parseParallelCommands(commandString) {
591
+ const placeholder = "\0AND\0";
592
+ const escaped = commandString.replace(/&&/g, placeholder);
593
+ const parts = escaped.split("&");
594
+ return parts.map((part) => part.replace(new RegExp(placeholder, "g"), "&&").trim()).filter((part) => part.length > 0);
595
+ }
596
+ function runParallelCommands(commands, options) {
597
+ const promises = commands.map((command) => {
598
+ return new Promise((resolve2) => {
599
+ logger.debug(`\u5E76\u884C\u542F\u52A8\u547D\u4EE4: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
600
+ const child = spawn(command, {
601
+ cwd: options?.cwd,
602
+ stdio: "inherit",
603
+ shell: true
604
+ });
605
+ child.on("error", (err) => {
606
+ resolve2({ command, exitCode: 1, error: err.message });
607
+ });
608
+ child.on("close", (code) => {
609
+ resolve2({ command, exitCode: code ?? 1 });
610
+ });
611
+ });
612
+ });
613
+ return Promise.all(promises);
614
+ }
574
615
 
575
616
  // src/utils/git.ts
576
617
  import { basename } from "path";
@@ -762,6 +803,9 @@ function printWarning(message) {
762
803
  function printInfo(message) {
763
804
  console.log(message);
764
805
  }
806
+ function printHint(message) {
807
+ console.log(chalk2.hex("#FF8C00")(message));
808
+ }
765
809
  function printSeparator() {
766
810
  console.log(MESSAGES.SEPARATOR);
767
811
  }
@@ -2062,6 +2106,9 @@ async function handleRemove(options) {
2062
2106
  let shouldDeleteBranch = autoDelete;
2063
2107
  if (!autoDelete) {
2064
2108
  shouldDeleteBranch = await confirmAction("\u662F\u5426\u540C\u65F6\u5220\u9664\u5BF9\u5E94\u7684\u672C\u5730\u5206\u652F\uFF1F");
2109
+ if (!shouldDeleteBranch) {
2110
+ printHint(MESSAGES.REMOVE_BRANCHES_KEPT);
2111
+ }
2065
2112
  }
2066
2113
  const failures = [];
2067
2114
  for (const wt of worktreesToRemove) {
@@ -2384,8 +2431,7 @@ function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, project
2384
2431
  }
2385
2432
  printSuccess(MESSAGES.INCREMENTAL_VALIDATE_SUCCESS(branchName));
2386
2433
  }
2387
- function executeRunCommand(command, mainWorktreePath) {
2388
- printInfo("");
2434
+ function executeSingleCommand(command, mainWorktreePath) {
2389
2435
  printInfo(MESSAGES.VALIDATE_RUN_START(command));
2390
2436
  printSeparator();
2391
2437
  const result = runCommandInherited(command, { cwd: mainWorktreePath });
@@ -2401,6 +2447,43 @@ function executeRunCommand(command, mainWorktreePath) {
2401
2447
  printError(MESSAGES.VALIDATE_RUN_FAILED(command, exitCode));
2402
2448
  }
2403
2449
  }
2450
+ function reportParallelResults(results) {
2451
+ printSeparator();
2452
+ const successCount = results.filter((r) => r.exitCode === 0 && !r.error).length;
2453
+ const failedCount = results.length - successCount;
2454
+ for (const result of results) {
2455
+ if (result.error) {
2456
+ printError(MESSAGES.VALIDATE_PARALLEL_CMD_ERROR(result.command, result.error));
2457
+ } else if (result.exitCode === 0) {
2458
+ printSuccess(MESSAGES.VALIDATE_PARALLEL_CMD_SUCCESS(result.command));
2459
+ } else {
2460
+ printError(MESSAGES.VALIDATE_PARALLEL_CMD_FAILED(result.command, result.exitCode));
2461
+ }
2462
+ }
2463
+ if (failedCount === 0) {
2464
+ printSuccess(MESSAGES.VALIDATE_PARALLEL_RUN_ALL_SUCCESS(results.length));
2465
+ } else {
2466
+ printError(MESSAGES.VALIDATE_PARALLEL_RUN_SUMMARY(successCount, failedCount));
2467
+ }
2468
+ }
2469
+ async function executeParallelCommands(commands, mainWorktreePath) {
2470
+ printInfo(MESSAGES.VALIDATE_PARALLEL_RUN_START(commands.length));
2471
+ for (let i = 0; i < commands.length; i++) {
2472
+ printInfo(MESSAGES.VALIDATE_PARALLEL_CMD_START(i + 1, commands.length, commands[i]));
2473
+ }
2474
+ printSeparator();
2475
+ const results = await runParallelCommands(commands, { cwd: mainWorktreePath });
2476
+ reportParallelResults(results);
2477
+ }
2478
+ async function executeRunCommand(command, mainWorktreePath) {
2479
+ printInfo("");
2480
+ const commands = parseParallelCommands(command);
2481
+ if (commands.length <= 1) {
2482
+ executeSingleCommand(commands[0] || command, mainWorktreePath);
2483
+ } else {
2484
+ await executeParallelCommands(commands, mainWorktreePath);
2485
+ }
2486
+ }
2404
2487
  async function handleValidate(options) {
2405
2488
  if (options.clean) {
2406
2489
  await handleValidateClean(options);
@@ -2433,7 +2516,7 @@ async function handleValidate(options) {
2433
2516
  handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted);
2434
2517
  }
2435
2518
  if (options.run) {
2436
- executeRunCommand(options.run, mainWorktreePath);
2519
+ await executeRunCommand(options.run, mainWorktreePath);
2437
2520
  }
2438
2521
  }
2439
2522
 
@@ -189,7 +189,21 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
189
189
  /** --run 命令执行失败(退出码非 0) */
190
190
  VALIDATE_RUN_FAILED: (command, exitCode) => `\u2717 \u547D\u4EE4\u6267\u884C\u5B8C\u6210: ${command}\uFF0C\u9000\u51FA\u7801: ${exitCode}`,
191
191
  /** --run 命令执行异常(进程启动失败等) */
192
- VALIDATE_RUN_ERROR: (command, errorMessage) => `\u2717 \u547D\u4EE4\u6267\u884C\u51FA\u9519: ${errorMessage}`
192
+ VALIDATE_RUN_ERROR: (command, errorMessage) => `\u2717 \u547D\u4EE4\u6267\u884C\u51FA\u9519: ${errorMessage}`,
193
+ /** 并行命令开始执行提示 */
194
+ VALIDATE_PARALLEL_RUN_START: (count) => `\u6B63\u5728\u5E76\u884C\u6267\u884C ${count} \u4E2A\u547D\u4EE4...`,
195
+ /** 并行执行中单个命令开始提示(带序号) */
196
+ VALIDATE_PARALLEL_CMD_START: (index, total, command) => `[${index}/${total}] ${command}`,
197
+ /** 并行执行全部成功汇总提示 */
198
+ VALIDATE_PARALLEL_RUN_ALL_SUCCESS: (count) => `\u2713 \u5168\u90E8 ${count} \u4E2A\u547D\u4EE4\u6267\u884C\u6210\u529F`,
199
+ /** 并行执行部分失败汇总提示 */
200
+ VALIDATE_PARALLEL_RUN_SUMMARY: (successCount, failedCount) => `\u5171 ${successCount + failedCount} \u4E2A\u547D\u4EE4\uFF0C${successCount} \u4E2A\u6210\u529F\uFF0C${failedCount} \u4E2A\u5931\u8D25`,
201
+ /** 并行执行中单个命令成功 */
202
+ VALIDATE_PARALLEL_CMD_SUCCESS: (command) => ` \u2713 ${command}`,
203
+ /** 并行执行中单个命令失败 */
204
+ VALIDATE_PARALLEL_CMD_FAILED: (command, exitCode) => ` \u2717 ${command}\uFF08\u9000\u51FA\u7801: ${exitCode}\uFF09`,
205
+ /** 并行执行中单个命令启动失败 */
206
+ VALIDATE_PARALLEL_CMD_ERROR: (command, errorMessage) => ` \u2717 ${command}\uFF08\u9519\u8BEF: ${errorMessage}\uFF09`
193
207
  };
194
208
 
195
209
  // src/constants/messages/sync.ts
@@ -253,7 +267,9 @@ var REMOVE_MESSAGES = {
253
267
  ${branches.map((b) => ` - ${b}`).join("\n")}`,
254
268
  /** 批量移除部分失败 */
255
269
  REMOVE_PARTIAL_FAILURE: (failures) => `\u4EE5\u4E0B worktree \u79FB\u9664\u5931\u8D25\uFF1A
256
- ${failures.map((f) => ` \u2717 ${f.path}: ${f.error}`).join("\n")}`
270
+ ${failures.map((f) => ` \u2717 ${f.path}: ${f.error}`).join("\n")}`,
271
+ /** 用户选择保留本地分支 */
272
+ REMOVE_BRANCHES_KEPT: "\u5DF2\u4FDD\u7559\u672C\u5730\u5206\u652F\uFF0C\u53EF\u7A0D\u540E\u4F7F\u7528 git branch -D <\u5206\u652F\u540D> \u624B\u52A8\u5220\u9664"
257
273
  };
258
274
 
259
275
  // src/constants/messages/reset.ts
package/docs/spec.md CHANGED
@@ -603,40 +603,104 @@ git restore --staged .
603
603
  如果用户传入了 `-r, --run` 选项,在 validate 成功后自动在主 worktree 中执行指定命令:
604
604
 
605
605
  ```bash
606
- # 示例
606
+ # 示例:单命令
607
607
  clawt validate -b feature-scheme-1 -r "npm test"
608
+
609
+ # 示例:并行执行多个命令(& 为并行分隔符)
610
+ clawt validate -b feature-scheme-1 -r "pnpm test & pnpm build"
608
611
  ```
609
612
 
610
613
  **执行说明:**
611
614
 
612
- - 命令通过 `spawnSync` + `inherit` stdio 模式在主 worktree 中执行,输出实时显示在终端
613
615
  - 命令执行失败(退出码非 0 或进程启动失败)**不影响** validate 本身的结果,仅输出提示信息
614
616
  - `--clean` 模式下传入 `--run` 会被忽略(只执行 clean 逻辑)
615
617
 
618
+ **命令解析规则:**
619
+
620
+ `-r` 选项支持通过 `&` 将多个命令并行执行。解析由 `parseParallelCommands()`(`src/utils/shell.ts`)负责:
621
+
622
+ 1. 先将命令字符串中的 `&&` 临时替换为占位符,避免被误拆
623
+ 2. 按单个 `&` 分割为多个独立命令
624
+ 3. 还原占位符为 `&&`,去除首尾空白,过滤空串
625
+
626
+ | 输入示例 | 解析结果 | 执行方式 |
627
+ | -------- | -------- | -------- |
628
+ | `"npm test"` | `["npm test"]` | 单命令,同步执行(`spawnSync` + `inherit`) |
629
+ | `"npm lint && npm test"` | `["npm lint && npm test"]` | 单命令(`&&` 不拆分),同步执行 |
630
+ | `"npm test & npm build"` | `["npm test", "npm build"]` | 并行执行(`spawn` + `Promise.all`) |
631
+ | `"npm lint && npm test & npm build"` | `["npm lint && npm test", "npm build"]` | 并行执行 2 个命令 |
632
+
633
+ **单命令执行:**
634
+
635
+ 当解析后只有 1 个命令时,通过 `spawnSync` + `inherit` stdio 模式同步执行,输出实时显示在终端。
636
+
637
+ **并行命令执行:**
638
+
639
+ 当解析后有多个命令时,通过 `runParallelCommands()`(`src/utils/shell.ts`)执行:
640
+
641
+ - 每个命令通过 Node.js `spawn` 以 shell 模式启动,`stdio: 'inherit'`
642
+ - 使用 `Promise.all` 等待全部命令完成
643
+ - 完成后汇总输出各命令的执行结果
644
+
645
+ **向后兼容性:**
646
+
647
+ - `-r "npm test"` — 单命令,走原有同步路径,行为无变化
648
+ - `-r "npm lint && npm test"` — `&&` 不拆分,走原有同步路径,行为无变化
649
+ - `-r "npm test & npm build"` — **新行为**:并行执行,等全部完成后汇总
650
+
616
651
  **输出格式:**
617
652
 
618
653
  ```
619
- # 命令执行成功
654
+ # 单命令执行成功
620
655
  正在主 worktree 中执行命令: npm test
621
656
  ────────────────────────────────────────
622
657
  ... 命令的实时输出 ...
623
658
  ────────────────────────────────────────
624
659
  ✓ 命令执行完成: npm test,退出码: 0
625
660
 
626
- # 命令执行失败(退出码非 0)
661
+ # 单命令执行失败(退出码非 0)
627
662
  正在主 worktree 中执行命令: npm test
628
663
  ────────────────────────────────────────
629
664
  ... 命令的实时输出 ...
630
665
  ────────────────────────────────────────
631
666
  ✗ 命令执行完成: npm test,退出码: 1
632
667
 
633
- # 命令执行出错(进程启动失败)
668
+ # 单命令执行出错(进程启动失败)
634
669
  正在主 worktree 中执行命令: nonexistent
635
670
  ────────────────────────────────────────
636
671
  ────────────────────────────────────────
637
672
  ✗ 命令执行出错: spawn ENOENT
673
+
674
+ # 并行命令执行(全部成功)
675
+ 正在并行执行 2 个命令...
676
+ [1/2] pnpm test
677
+ [2/2] pnpm build
678
+ ────────────────────────────────────────
679
+ ... 各命令的实时输出(交错显示) ...
680
+ ────────────────────────────────────────
681
+ ✓ pnpm test
682
+ ✓ pnpm build
683
+ ✓ 全部 2 个命令执行成功
684
+
685
+ # 并行命令执行(部分失败)
686
+ 正在并行执行 2 个命令...
687
+ [1/2] pnpm test
688
+ [2/2] pnpm build
689
+ ────────────────────────────────────────
690
+ ... 各命令的实时输出(交错显示) ...
691
+ ────────────────────────────────────────
692
+ ✗ pnpm test(退出码: 1)
693
+ ✓ pnpm build
694
+ 共 2 个命令,1 个成功,1 个失败
638
695
  ```
639
696
 
697
+ **实现要点:**
698
+
699
+ - 命令解析:`parseParallelCommands()`(`src/utils/shell.ts`)
700
+ - 并行执行:`runParallelCommands()`(`src/utils/shell.ts`),返回 `ParallelCommandResult[]`
701
+ - 结果汇总:`reportParallelResults()`(`src/commands/validate.ts`)
702
+ - 消息常量:`MESSAGES.VALIDATE_PARALLEL_*` 系列(`src/constants/messages/validate.ts`)
703
+
640
704
  #### 增量 validate(存在历史快照)
641
705
 
642
706
  当 `~/.clawt/validate-snapshots/<project>/<branchName>.tree` 存在时,自动进入增量模式:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "2.16.0",
3
+ "version": "2.16.2",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,6 +16,7 @@ import {
16
16
  printInfo,
17
17
  printSuccess,
18
18
  printError,
19
+ printHint,
19
20
  confirmAction,
20
21
  removeSnapshot,
21
22
  removeProjectSnapshots,
@@ -85,6 +86,9 @@ async function handleRemove(options: RemoveOptions): Promise<void> {
85
86
 
86
87
  if (!autoDelete) {
87
88
  shouldDeleteBranch = await confirmAction('是否同时删除对应的本地分支?');
89
+ if (!shouldDeleteBranch) {
90
+ printHint(MESSAGES.REMOVE_BRANCHES_KEPT);
91
+ }
88
92
  }
89
93
 
90
94
  // 执行移除,收集失败项
@@ -40,8 +40,10 @@ import {
40
40
  printSeparator,
41
41
  resolveTargetWorktree,
42
42
  runCommandInherited,
43
+ parseParallelCommands,
44
+ runParallelCommands,
43
45
  } from '../utils/index.js';
44
- import type { WorktreeResolveMessages } from '../utils/index.js';
46
+ import type { WorktreeResolveMessages, ParallelCommandResult } from '../utils/index.js';
45
47
 
46
48
  /** validate 命令的分支解析消息配置 */
47
49
  const VALIDATE_RESOLVE_MESSAGES: WorktreeResolveMessages = {
@@ -305,13 +307,11 @@ function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath:
305
307
  }
306
308
 
307
309
  /**
308
- * 在主 worktree 中执行用户指定的命令
309
- * 命令执行失败不影响 validate 本身的结果,仅输出提示
310
+ * 执行单个命令(同步方式,保持原有行为不变)
310
311
  * @param {string} command - 要执行的命令字符串
311
312
  * @param {string} mainWorktreePath - 主 worktree 路径
312
313
  */
313
- function executeRunCommand(command: string, mainWorktreePath: string): void {
314
- printInfo('');
314
+ function executeSingleCommand(command: string, mainWorktreePath: string): void {
315
315
  printInfo(MESSAGES.VALIDATE_RUN_START(command));
316
316
  printSeparator();
317
317
 
@@ -333,6 +333,73 @@ function executeRunCommand(command: string, mainWorktreePath: string): void {
333
333
  }
334
334
  }
335
335
 
336
+ /**
337
+ * 汇总输出并行命令的执行结果
338
+ * @param {ParallelCommandResult[]} results - 各命令的执行结果数组
339
+ */
340
+ function reportParallelResults(results: ParallelCommandResult[]): void {
341
+ printSeparator();
342
+
343
+ const successCount = results.filter((r) => r.exitCode === 0 && !r.error).length;
344
+ const failedCount = results.length - successCount;
345
+
346
+ for (const result of results) {
347
+ if (result.error) {
348
+ printError(MESSAGES.VALIDATE_PARALLEL_CMD_ERROR(result.command, result.error));
349
+ } else if (result.exitCode === 0) {
350
+ printSuccess(MESSAGES.VALIDATE_PARALLEL_CMD_SUCCESS(result.command));
351
+ } else {
352
+ printError(MESSAGES.VALIDATE_PARALLEL_CMD_FAILED(result.command, result.exitCode));
353
+ }
354
+ }
355
+
356
+ if (failedCount === 0) {
357
+ printSuccess(MESSAGES.VALIDATE_PARALLEL_RUN_ALL_SUCCESS(results.length));
358
+ } else {
359
+ printError(MESSAGES.VALIDATE_PARALLEL_RUN_SUMMARY(successCount, failedCount));
360
+ }
361
+ }
362
+
363
+ /**
364
+ * 并行执行多个命令并汇总结果
365
+ * @param {string[]} commands - 要并行执行的命令数组
366
+ * @param {string} mainWorktreePath - 主 worktree 路径
367
+ */
368
+ async function executeParallelCommands(commands: string[], mainWorktreePath: string): Promise<void> {
369
+ printInfo(MESSAGES.VALIDATE_PARALLEL_RUN_START(commands.length));
370
+
371
+ for (let i = 0; i < commands.length; i++) {
372
+ printInfo(MESSAGES.VALIDATE_PARALLEL_CMD_START(i + 1, commands.length, commands[i]));
373
+ }
374
+
375
+ printSeparator();
376
+
377
+ const results = await runParallelCommands(commands, { cwd: mainWorktreePath });
378
+
379
+ reportParallelResults(results);
380
+ }
381
+
382
+ /**
383
+ * 在主 worktree 中执行用户指定的命令
384
+ * 根据命令字符串中的 & 分隔符决定是单命令执行还是并行执行
385
+ * 命令执行失败不影响 validate 本身的结果,仅输出提示
386
+ * @param {string} command - 要执行的命令字符串
387
+ * @param {string} mainWorktreePath - 主 worktree 路径
388
+ */
389
+ async function executeRunCommand(command: string, mainWorktreePath: string): Promise<void> {
390
+ printInfo('');
391
+
392
+ const commands = parseParallelCommands(command);
393
+
394
+ if (commands.length <= 1) {
395
+ // 单命令(包括含 && 的串行命令),走原有同步路径
396
+ executeSingleCommand(commands[0] || command, mainWorktreePath);
397
+ } else {
398
+ // 多命令,并行执行
399
+ await executeParallelCommands(commands, mainWorktreePath);
400
+ }
401
+ }
402
+
336
403
  /**
337
404
  * 执行 validate 命令的核心逻辑
338
405
  * @param {ValidateOptions} options - 命令选项
@@ -386,6 +453,6 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
386
453
 
387
454
  // validate 成功后执行用户指定的命令
388
455
  if (options.run) {
389
- executeRunCommand(options.run, mainWorktreePath);
456
+ await executeRunCommand(options.run, mainWorktreePath);
390
457
  }
391
458
  }
@@ -12,4 +12,6 @@ export const REMOVE_MESSAGES = {
12
12
  /** 批量移除部分失败 */
13
13
  REMOVE_PARTIAL_FAILURE: (failures: Array<{ path: string; error: string }>) =>
14
14
  `以下 worktree 移除失败:\n${failures.map((f) => ` ✗ ${f.path}: ${f.error}`).join('\n')}`,
15
+ /** 用户选择保留本地分支 */
16
+ REMOVE_BRANCHES_KEPT: '已保留本地分支,可稍后使用 git branch -D <分支名> 手动删除',
15
17
  } as const;
@@ -32,4 +32,25 @@ export const VALIDATE_MESSAGES = {
32
32
  /** --run 命令执行异常(进程启动失败等) */
33
33
  VALIDATE_RUN_ERROR: (command: string, errorMessage: string) =>
34
34
  `✗ 命令执行出错: ${errorMessage}`,
35
+ /** 并行命令开始执行提示 */
36
+ VALIDATE_PARALLEL_RUN_START: (count: number) =>
37
+ `正在并行执行 ${count} 个命令...`,
38
+ /** 并行执行中单个命令开始提示(带序号) */
39
+ VALIDATE_PARALLEL_CMD_START: (index: number, total: number, command: string) =>
40
+ `[${index}/${total}] ${command}`,
41
+ /** 并行执行全部成功汇总提示 */
42
+ VALIDATE_PARALLEL_RUN_ALL_SUCCESS: (count: number) =>
43
+ `✓ 全部 ${count} 个命令执行成功`,
44
+ /** 并行执行部分失败汇总提示 */
45
+ VALIDATE_PARALLEL_RUN_SUMMARY: (successCount: number, failedCount: number) =>
46
+ `共 ${successCount + failedCount} 个命令,${successCount} 个成功,${failedCount} 个失败`,
47
+ /** 并行执行中单个命令成功 */
48
+ VALIDATE_PARALLEL_CMD_SUCCESS: (command: string) =>
49
+ ` ✓ ${command}`,
50
+ /** 并行执行中单个命令失败 */
51
+ VALIDATE_PARALLEL_CMD_FAILED: (command: string, exitCode: number) =>
52
+ ` ✗ ${command}(退出码: ${exitCode})`,
53
+ /** 并行执行中单个命令启动失败 */
54
+ VALIDATE_PARALLEL_CMD_ERROR: (command: string, errorMessage: string) =>
55
+ ` ✗ ${command}(错误: ${errorMessage})`,
35
56
  } as const;
@@ -35,6 +35,14 @@ export function printInfo(message: string): void {
35
35
  console.log(message);
36
36
  }
37
37
 
38
+ /**
39
+ * 输出提示信息(橙色)
40
+ * @param {string} message - 消息内容
41
+ */
42
+ export function printHint(message: string): void {
43
+ console.log(chalk.hex('#FF8C00')(message));
44
+ }
45
+
38
46
  /**
39
47
  * 输出分隔线
40
48
  */
@@ -1,4 +1,5 @@
1
- export { execCommand, spawnProcess, killAllChildProcesses, execCommandWithInput, runCommandInherited } from './shell.js';
1
+ export { execCommand, spawnProcess, killAllChildProcesses, execCommandWithInput, runCommandInherited, parseParallelCommands, runParallelCommands } from './shell.js';
2
+ export type { ParallelCommandResult } from './shell.js';
2
3
  export {
3
4
  getGitCommonDir,
4
5
  getGitTopLevel,
@@ -49,7 +50,7 @@ export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } fro
49
50
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
50
51
  export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
51
52
  export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
52
- export { printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration } from './formatter.js';
53
+ export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration } from './formatter.js';
53
54
  export { ensureDir, removeEmptyDir } from './fs.js';
54
55
  export { multilineInput } from './prompt.js';
55
56
  export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
@@ -1,6 +1,16 @@
1
1
  import { execSync, execFileSync, spawn, spawnSync, type ChildProcess, type SpawnSyncReturns, type StdioOptions } from 'node:child_process';
2
2
  import { logger } from '../logger/index.js';
3
3
 
4
+ /** 并行命令执行的单个结果 */
5
+ export interface ParallelCommandResult {
6
+ /** 执行的命令字符串 */
7
+ command: string;
8
+ /** 进程退出码 */
9
+ exitCode: number;
10
+ /** 进程启动失败时的错误信息 */
11
+ error?: string;
12
+ }
13
+
4
14
  /**
5
15
  * 同步执行 shell 命令并返回 stdout
6
16
  * @param {string} command - 要执行的命令
@@ -92,3 +102,58 @@ export function runCommandInherited(
92
102
  shell: true,
93
103
  });
94
104
  }
105
+
106
+ /**
107
+ * 解析命令字符串中的并行分隔符 `&`,将其拆分为多个独立命令
108
+ * `&&` 不会被拆分(属于 shell 的串行逻辑与操作符)
109
+ * @param {string} commandString - 命令字符串
110
+ * @returns {string[]} 拆分后的命令数组
111
+ */
112
+ export function parseParallelCommands(commandString: string): string[] {
113
+ // 将 && 临时替换为占位符,避免被 & 分割逻辑误拆
114
+ const placeholder = '\x00AND\x00';
115
+ const escaped = commandString.replace(/&&/g, placeholder);
116
+
117
+ // 按单个 & 分割
118
+ const parts = escaped.split('&');
119
+
120
+ // 还原占位符为 &&,并去除首尾空白
121
+ return parts
122
+ .map((part) => part.replace(new RegExp(placeholder, 'g'), '&&').trim())
123
+ .filter((part) => part.length > 0);
124
+ }
125
+
126
+ /**
127
+ * 并行执行多个命令,等待全部完成后返回结果
128
+ * 每个命令通过 spawn 以 shell 模式启动,stdio 继承父进程(实时输出到终端)
129
+ * @param {string[]} commands - 要并行执行的命令数组
130
+ * @param {object} options - 可选配置
131
+ * @param {string} options.cwd - 工作目录
132
+ * @returns {Promise<ParallelCommandResult[]>} 各命令的执行结果
133
+ */
134
+ export function runParallelCommands(
135
+ commands: string[],
136
+ options?: { cwd?: string },
137
+ ): Promise<ParallelCommandResult[]> {
138
+ const promises = commands.map((command) => {
139
+ return new Promise<ParallelCommandResult>((resolve) => {
140
+ logger.debug(`并行启动命令: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
141
+
142
+ const child = spawn(command, {
143
+ cwd: options?.cwd,
144
+ stdio: 'inherit',
145
+ shell: true,
146
+ });
147
+
148
+ child.on('error', (err) => {
149
+ resolve({ command, exitCode: 1, error: err.message });
150
+ });
151
+
152
+ child.on('close', (code) => {
153
+ resolve({ command, exitCode: code ?? 1 });
154
+ });
155
+ });
156
+ });
157
+
158
+ return Promise.all(promises);
159
+ }
@@ -24,6 +24,7 @@ vi.mock('../../../src/constants/index.js', () => ({
24
24
  REMOVE_SELECT_BRANCH: '请选择要移除的分支(空格选择,回车确认)',
25
25
  REMOVE_MULTIPLE_MATCHES: (name: string) => `"${name}" 匹配到多个分支`,
26
26
  REMOVE_NO_MATCH: (name: string, branches: string[]) => `未找到与 "${name}" 匹配的分支,可用:${branches.join(', ')}`,
27
+ REMOVE_BRANCHES_KEPT: '已保留本地分支,可稍后使用 git branch -D <分支名> 手动删除',
27
28
  },
28
29
  }));
29
30
 
@@ -40,6 +41,7 @@ vi.mock('../../../src/utils/index.js', () => ({
40
41
  printInfo: vi.fn(),
41
42
  printSuccess: vi.fn(),
42
43
  printError: vi.fn(),
44
+ printHint: vi.fn(),
43
45
  confirmAction: vi.fn(),
44
46
  removeSnapshot: vi.fn(),
45
47
  removeProjectSnapshots: vi.fn(),
@@ -59,6 +61,7 @@ import {
59
61
  removeProjectSnapshots,
60
62
  printSuccess,
61
63
  printError,
64
+ printHint,
62
65
  resolveTargetWorktrees,
63
66
  } from '../../../src/utils/index.js';
64
67
 
@@ -72,6 +75,7 @@ const mockedRemoveSnapshot = vi.mocked(removeSnapshot);
72
75
  const mockedRemoveProjectSnapshots = vi.mocked(removeProjectSnapshots);
73
76
  const mockedPrintSuccess = vi.mocked(printSuccess);
74
77
  const mockedPrintError = vi.mocked(printError);
78
+ const mockedPrintHint = vi.mocked(printHint);
75
79
  const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
76
80
 
77
81
  beforeEach(() => {
@@ -86,6 +90,7 @@ beforeEach(() => {
86
90
  mockedRemoveProjectSnapshots.mockReset();
87
91
  mockedPrintSuccess.mockReset();
88
92
  mockedPrintError.mockReset();
93
+ mockedPrintHint.mockReset();
89
94
  mockedResolveTargetWorktrees.mockReset();
90
95
  });
91
96
 
@@ -199,6 +204,8 @@ describe('handleRemove', () => {
199
204
  expect(mockedConfirmAction).toHaveBeenCalled();
200
205
  // 用户拒绝删除分支
201
206
  expect(mockedDeleteBranch).not.toHaveBeenCalled();
207
+ // 应提示用户分支已保留
208
+ expect(mockedPrintHint).toHaveBeenCalledWith('已保留本地分支,可稍后使用 git branch -D <分支名> 手动删除');
202
209
  });
203
210
 
204
211
  it('-b 指定不存在的分支时 resolveTargetWorktrees 抛出错误', async () => {
@@ -32,6 +32,13 @@ vi.mock('../../../src/constants/index.js', () => ({
32
32
  VALIDATE_RUN_SUCCESS: (cmd: string) => `✓ 命令执行完成: ${cmd}`,
33
33
  VALIDATE_RUN_FAILED: (cmd: string, code: number) => `✗ 命令失败: ${cmd},退出码: ${code}`,
34
34
  VALIDATE_RUN_ERROR: (cmd: string, msg: string) => `✗ 命令出错: ${msg}`,
35
+ VALIDATE_PARALLEL_RUN_START: (count: number) => `正在并行执行 ${count} 个命令...`,
36
+ VALIDATE_PARALLEL_CMD_START: (index: number, total: number, cmd: string) => `[${index}/${total}] ${cmd}`,
37
+ VALIDATE_PARALLEL_RUN_ALL_SUCCESS: (count: number) => `✓ 全部 ${count} 个命令执行成功`,
38
+ VALIDATE_PARALLEL_RUN_SUMMARY: (s: number, f: number) => `共 ${s + f} 个命令,${s} 个成功,${f} 个失败`,
39
+ VALIDATE_PARALLEL_CMD_SUCCESS: (cmd: string) => ` ✓ ${cmd}`,
40
+ VALIDATE_PARALLEL_CMD_FAILED: (cmd: string, code: number) => ` ✗ ${cmd}(退出码: ${code})`,
41
+ VALIDATE_PARALLEL_CMD_ERROR: (cmd: string, msg: string) => ` ✗ ${cmd}(错误: ${msg})`,
35
42
  SEPARATOR: '────',
36
43
  },
37
44
  }));
@@ -79,6 +86,8 @@ vi.mock('../../../src/utils/index.js', () => ({
79
86
  runCommandInherited: vi.fn(),
80
87
  printError: vi.fn(),
81
88
  printSeparator: vi.fn(),
89
+ parseParallelCommands: vi.fn(),
90
+ runParallelCommands: vi.fn(),
82
91
  }));
83
92
 
84
93
  import { registerValidateCommand } from '../../../src/commands/validate.js';
@@ -116,6 +125,8 @@ import {
116
125
  runCommandInherited,
117
126
  printError,
118
127
  printSeparator,
128
+ parseParallelCommands,
129
+ runParallelCommands,
119
130
  } from '../../../src/utils/index.js';
120
131
 
121
132
  const mockedGetProjectName = vi.mocked(getProjectName);
@@ -151,6 +162,8 @@ const mockedPrintWarning = vi.mocked(printWarning);
151
162
  const mockedRunCommandInherited = vi.mocked(runCommandInherited);
152
163
  const mockedPrintError = vi.mocked(printError);
153
164
  const mockedPrintSeparator = vi.mocked(printSeparator);
165
+ const mockedParseParallelCommands = vi.mocked(parseParallelCommands);
166
+ const mockedRunParallelCommands = vi.mocked(runParallelCommands);
154
167
 
155
168
  const worktree = { path: '/path/feature', branch: 'feature' };
156
169
 
@@ -188,6 +201,10 @@ beforeEach(() => {
188
201
  mockedRunCommandInherited.mockReset();
189
202
  mockedPrintError.mockReset();
190
203
  mockedPrintSeparator.mockReset();
204
+ mockedParseParallelCommands.mockReset();
205
+ mockedRunParallelCommands.mockReset();
206
+ // 默认让 parseParallelCommands 返回单命令数组,保持旧测试兼容
207
+ mockedParseParallelCommands.mockImplementation((cmd: string) => [cmd]);
191
208
  });
192
209
 
193
210
  describe('registerValidateCommand', () => {
@@ -516,3 +533,89 @@ describe('--run 选项', () => {
516
533
  expect(mockedRunCommandInherited).not.toHaveBeenCalled();
517
534
  });
518
535
  });
536
+
537
+ describe('--run 并行命令', () => {
538
+ /** 设置首次 validate 成功的公共 mock */
539
+ function setupSuccessfulFirstValidate(): void {
540
+ mockedIsWorkingDirClean.mockReturnValue(true);
541
+ mockedHasLocalCommits.mockReturnValue(true);
542
+ mockedHasSnapshot.mockReturnValue(false);
543
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
544
+ mockedGitWriteTree.mockReturnValue('treehash');
545
+ mockedGetHeadCommitHash.mockReturnValue('headhash');
546
+ }
547
+
548
+ it('& 分隔的命令触发并行执行', async () => {
549
+ setupSuccessfulFirstValidate();
550
+ mockedParseParallelCommands.mockReturnValue(['pnpm test', 'pnpm build']);
551
+ mockedRunParallelCommands.mockResolvedValue([
552
+ { command: 'pnpm test', exitCode: 0 },
553
+ { command: 'pnpm build', exitCode: 0 },
554
+ ]);
555
+
556
+ const program = new Command();
557
+ program.exitOverride();
558
+ registerValidateCommand(program);
559
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm test & pnpm build'], { from: 'user' });
560
+
561
+ // 应该调用并行执行而非同步执行
562
+ expect(mockedRunParallelCommands).toHaveBeenCalledWith(['pnpm test', 'pnpm build'], { cwd: '/repo' });
563
+ expect(mockedRunCommandInherited).not.toHaveBeenCalled();
564
+ // 全部成功,printSuccess 被调用(validate 成功 + 各命令成功 + 汇总成功)
565
+ expect(mockedPrintSuccess).toHaveBeenCalled();
566
+ });
567
+
568
+ it('并行执行部分失败时输出错误汇总', async () => {
569
+ setupSuccessfulFirstValidate();
570
+ mockedParseParallelCommands.mockReturnValue(['pnpm test', 'pnpm build']);
571
+ mockedRunParallelCommands.mockResolvedValue([
572
+ { command: 'pnpm test', exitCode: 1 },
573
+ { command: 'pnpm build', exitCode: 0 },
574
+ ]);
575
+
576
+ const program = new Command();
577
+ program.exitOverride();
578
+ registerValidateCommand(program);
579
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm test & pnpm build'], { from: 'user' });
580
+
581
+ expect(mockedRunParallelCommands).toHaveBeenCalled();
582
+ // 部分失败,应有错误输出
583
+ expect(mockedPrintError).toHaveBeenCalled();
584
+ });
585
+
586
+ it('单命令走原有同步路径不触发并行', async () => {
587
+ setupSuccessfulFirstValidate();
588
+ mockedParseParallelCommands.mockReturnValue(['npm test']);
589
+ mockedRunCommandInherited.mockReturnValue({
590
+ pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
591
+ status: 0, signal: null, error: undefined,
592
+ });
593
+
594
+ const program = new Command();
595
+ program.exitOverride();
596
+ registerValidateCommand(program);
597
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
598
+
599
+ // 单命令应该走同步路径
600
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
601
+ expect(mockedRunParallelCommands).not.toHaveBeenCalled();
602
+ });
603
+
604
+ it('&& 命令不触发并行执行', async () => {
605
+ setupSuccessfulFirstValidate();
606
+ mockedParseParallelCommands.mockReturnValue(['pnpm lint && pnpm test']);
607
+ mockedRunCommandInherited.mockReturnValue({
608
+ pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
609
+ status: 0, signal: null, error: undefined,
610
+ });
611
+
612
+ const program = new Command();
613
+ program.exitOverride();
614
+ registerValidateCommand(program);
615
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm lint && pnpm test'], { from: 'user' });
616
+
617
+ // && 不拆分,走同步路径
618
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm lint && pnpm test', { cwd: '/repo' });
619
+ expect(mockedRunParallelCommands).not.toHaveBeenCalled();
620
+ });
621
+ });
@@ -13,7 +13,7 @@ vi.mock('../../../src/logger/index.js', () => ({
13
13
  }));
14
14
 
15
15
  import { execSync, execFileSync, spawn } from 'node:child_process';
16
- import { execCommand, execCommandWithInput, spawnProcess, killAllChildProcesses } from '../../../src/utils/shell.js';
16
+ import { execCommand, execCommandWithInput, spawnProcess, killAllChildProcesses, parseParallelCommands } from '../../../src/utils/shell.js';
17
17
 
18
18
  const mockedExecSync = vi.mocked(execSync);
19
19
  const mockedExecFileSync = vi.mocked(execFileSync);
@@ -106,3 +106,44 @@ describe('killAllChildProcesses', () => {
106
106
  expect(() => killAllChildProcesses([])).not.toThrow();
107
107
  });
108
108
  });
109
+
110
+ describe('parseParallelCommands', () => {
111
+ it('单个命令返回包含该命令的数组', () => {
112
+ expect(parseParallelCommands('pnpm test')).toEqual(['pnpm test']);
113
+ });
114
+
115
+ it('使用 & 分隔的多个命令被正确拆分', () => {
116
+ expect(parseParallelCommands('pnpm test & pnpm build')).toEqual(['pnpm test', 'pnpm build']);
117
+ });
118
+
119
+ it('&& 不会被拆分,保持为单条命令', () => {
120
+ expect(parseParallelCommands('pnpm lint && pnpm test')).toEqual(['pnpm lint && pnpm test']);
121
+ });
122
+
123
+ it('混合场景:&& 和 & 同时存在', () => {
124
+ expect(parseParallelCommands('pnpm lint && pnpm test & pnpm build')).toEqual([
125
+ 'pnpm lint && pnpm test',
126
+ 'pnpm build',
127
+ ]);
128
+ });
129
+
130
+ it('多个 & 分隔的命令', () => {
131
+ expect(parseParallelCommands('cmd1 & cmd2 & cmd3')).toEqual(['cmd1', 'cmd2', 'cmd3']);
132
+ });
133
+
134
+ it('空字符串返回空数组', () => {
135
+ expect(parseParallelCommands('')).toEqual([]);
136
+ });
137
+
138
+ it('去除命令首尾空白', () => {
139
+ expect(parseParallelCommands(' pnpm test & pnpm build ')).toEqual(['pnpm test', 'pnpm build']);
140
+ });
141
+
142
+ it('多个 && 不拆分', () => {
143
+ expect(parseParallelCommands('cmd1 && cmd2 && cmd3')).toEqual(['cmd1 && cmd2 && cmd3']);
144
+ });
145
+
146
+ it('尾部 & 后无内容时过滤空字符串', () => {
147
+ expect(parseParallelCommands('pnpm test & ')).toEqual(['pnpm test']);
148
+ });
149
+ });