clawt 2.15.0 → 2.16.1

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
@@ -119,12 +119,16 @@ clawt create -b <branch> -n 3 # 批量创建 3 个
119
119
  ### `clawt validate` — 在主 worktree 中验证分支变更
120
120
 
121
121
  ```bash
122
- clawt validate -b <branch> # 将变更迁移到主 worktree 测试
123
- clawt validate -b <branch> --clean # 清理 validate 状态
122
+ clawt validate -b <branch> # 将变更迁移到主 worktree 测试
123
+ clawt validate -b <branch> --clean # 清理 validate 状态
124
+ clawt validate -b <branch> -r "npm test" # validate 成功后自动运行测试
125
+ clawt validate -b <branch> -r "npm run build" # validate 成功后自动构建
124
126
  ```
125
127
 
126
128
  支持增量模式:再次 validate 同一分支时,可通过 `git diff` 查看两次之间的增量差异。
127
129
 
130
+ `-r, --run` 选项可在 validate 成功后自动在主 worktree 中执行指定命令(如测试、构建等),命令执行失败不影响 validate 结果。
131
+
128
132
  ### `clawt sync` — 同步主分支代码到目标 worktree
129
133
 
130
134
  ```bash
package/dist/index.js CHANGED
@@ -189,7 +189,15 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
189
189
  /** validate 交互选择提示 */
190
190
  VALIDATE_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u9A8C\u8BC1\u7684\u5206\u652F",
191
191
  /** validate 模糊匹配到多个结果提示 */
192
- VALIDATE_MULTIPLE_MATCHES: (name) => `"${name}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\uFF1A`
192
+ VALIDATE_MULTIPLE_MATCHES: (name) => `"${name}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\uFF1A`,
193
+ /** --run 命令开始执行提示 */
194
+ VALIDATE_RUN_START: (command) => `\u6B63\u5728\u4E3B worktree \u4E2D\u6267\u884C\u547D\u4EE4: ${command}`,
195
+ /** --run 命令执行成功(退出码 0) */
196
+ VALIDATE_RUN_SUCCESS: (command) => `\u2713 \u547D\u4EE4\u6267\u884C\u5B8C\u6210: ${command}\uFF0C\u9000\u51FA\u7801: 0`,
197
+ /** --run 命令执行失败(退出码非 0) */
198
+ VALIDATE_RUN_FAILED: (command, exitCode) => `\u2717 \u547D\u4EE4\u6267\u884C\u5B8C\u6210: ${command}\uFF0C\u9000\u51FA\u7801: ${exitCode}`,
199
+ /** --run 命令执行异常(进程启动失败等) */
200
+ VALIDATE_RUN_ERROR: (command, errorMessage) => `\u2717 \u547D\u4EE4\u6267\u884C\u51FA\u9519: ${errorMessage}`
193
201
  };
194
202
 
195
203
  // src/constants/messages/sync.ts
@@ -253,7 +261,9 @@ var REMOVE_MESSAGES = {
253
261
  ${branches.map((b) => ` - ${b}`).join("\n")}`,
254
262
  /** 批量移除部分失败 */
255
263
  REMOVE_PARTIAL_FAILURE: (failures) => `\u4EE5\u4E0B worktree \u79FB\u9664\u5931\u8D25\uFF1A
256
- ${failures.map((f) => ` \u2717 ${f.path}: ${f.error}`).join("\n")}`
264
+ ${failures.map((f) => ` \u2717 ${f.path}: ${f.error}`).join("\n")}`,
265
+ /** 用户选择保留本地分支 */
266
+ 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
267
  };
258
268
 
259
269
  // src/constants/messages/reset.ts
@@ -521,7 +531,7 @@ function enableConsoleTransport() {
521
531
  }
522
532
 
523
533
  // src/utils/shell.ts
524
- import { execSync, execFileSync, spawn } from "child_process";
534
+ import { execSync, execFileSync, spawn, spawnSync } from "child_process";
525
535
  function execCommand(command, options) {
526
536
  logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
527
537
  const result = execSync(command, {
@@ -555,6 +565,14 @@ function execCommandWithInput(command, args, options) {
555
565
  });
556
566
  return result.trim();
557
567
  }
568
+ function runCommandInherited(command, options) {
569
+ logger.debug(`\u6267\u884C\u547D\u4EE4(inherit): ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
570
+ return spawnSync(command, {
571
+ cwd: options?.cwd,
572
+ stdio: "inherit",
573
+ shell: true
574
+ });
575
+ }
558
576
 
559
577
  // src/utils/git.ts
560
578
  import { basename } from "path";
@@ -746,6 +764,9 @@ function printWarning(message) {
746
764
  function printInfo(message) {
747
765
  console.log(message);
748
766
  }
767
+ function printHint(message) {
768
+ console.log(chalk2.hex("#FF8C00")(message));
769
+ }
749
770
  function printSeparator() {
750
771
  console.log(MESSAGES.SEPARATOR);
751
772
  }
@@ -1000,7 +1021,7 @@ function parseConcurrency(optionValue, configValue) {
1000
1021
  import Enquirer from "enquirer";
1001
1022
 
1002
1023
  // src/utils/claude.ts
1003
- import { spawnSync } from "child_process";
1024
+ import { spawnSync as spawnSync2 } from "child_process";
1004
1025
  import { existsSync as existsSync6, readdirSync as readdirSync3 } from "fs";
1005
1026
  import { join as join3 } from "path";
1006
1027
 
@@ -1109,7 +1130,7 @@ function launchInteractiveClaude(worktree, options = {}) {
1109
1130
  printInfo(` \u6A21\u5F0F: ${hasPreviousSession ? "\u7EE7\u7EED\u4E0A\u6B21\u5BF9\u8BDD" : "\u65B0\u5BF9\u8BDD"}`);
1110
1131
  }
1111
1132
  printInfo("");
1112
- const result = spawnSync(cmd, args, {
1133
+ const result = spawnSync2(cmd, args, {
1113
1134
  cwd: worktree.path,
1114
1135
  stdio: "inherit"
1115
1136
  });
@@ -1779,7 +1800,7 @@ async function executeBatchTasks(worktrees, tasks, concurrency) {
1779
1800
  // src/utils/dry-run.ts
1780
1801
  import chalk4 from "chalk";
1781
1802
  import { join as join5 } from "path";
1782
- var DRY_RUN_TASK_DESC_MAX_LENGTH = 70;
1803
+ var DRY_RUN_TASK_DESC_MAX_LENGTH = 80;
1783
1804
  function truncateTaskDesc(task) {
1784
1805
  const oneLine = task.replace(/\n/g, " ").trim();
1785
1806
  if (oneLine.length <= DRY_RUN_TASK_DESC_MAX_LENGTH) {
@@ -2046,6 +2067,9 @@ async function handleRemove(options) {
2046
2067
  let shouldDeleteBranch = autoDelete;
2047
2068
  if (!autoDelete) {
2048
2069
  shouldDeleteBranch = await confirmAction("\u662F\u5426\u540C\u65F6\u5220\u9664\u5BF9\u5E94\u7684\u672C\u5730\u5206\u652F\uFF1F");
2070
+ if (!shouldDeleteBranch) {
2071
+ printHint(MESSAGES.REMOVE_BRANCHES_KEPT);
2072
+ }
2049
2073
  }
2050
2074
  const failures = [];
2051
2075
  for (const wt of worktreesToRemove) {
@@ -2227,7 +2251,7 @@ var VALIDATE_RESOLVE_MESSAGES = {
2227
2251
  noMatch: MESSAGES.VALIDATE_NO_MATCH
2228
2252
  };
2229
2253
  function registerValidateCommand(program2) {
2230
- program2.command("validate").description("\u5728\u4E3B worktree \u9A8C\u8BC1\u67D0\u4E2A worktree \u5206\u652F\u7684\u53D8\u66F4").option("-b, --branch <branchName>", "\u8981\u9A8C\u8BC1\u7684\u5206\u652F\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF0C\u4E0D\u4F20\u5219\u5217\u51FA\u6240\u6709\u5206\u652F\uFF09").option("--clean", "\u6E05\u7406 validate \u72B6\u6001\uFF08\u91CD\u7F6E\u4E3B worktree \u5E76\u5220\u9664\u5FEB\u7167\uFF09").action(async (options) => {
2254
+ program2.command("validate").description("\u5728\u4E3B worktree \u9A8C\u8BC1\u67D0\u4E2A worktree \u5206\u652F\u7684\u53D8\u66F4").option("-b, --branch <branchName>", "\u8981\u9A8C\u8BC1\u7684\u5206\u652F\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF0C\u4E0D\u4F20\u5219\u5217\u51FA\u6240\u6709\u5206\u652F\uFF09").option("--clean", "\u6E05\u7406 validate \u72B6\u6001\uFF08\u91CD\u7F6E\u4E3B worktree \u5E76\u5220\u9664\u5FEB\u7167\uFF09").option("-r, --run <command>", "validate \u6210\u529F\u540E\u5728\u4E3B worktree \u4E2D\u6267\u884C\u7684\u547D\u4EE4").action(async (options) => {
2231
2255
  await handleValidate(options);
2232
2256
  });
2233
2257
  }
@@ -2368,6 +2392,23 @@ function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, project
2368
2392
  }
2369
2393
  printSuccess(MESSAGES.INCREMENTAL_VALIDATE_SUCCESS(branchName));
2370
2394
  }
2395
+ function executeRunCommand(command, mainWorktreePath) {
2396
+ printInfo("");
2397
+ printInfo(MESSAGES.VALIDATE_RUN_START(command));
2398
+ printSeparator();
2399
+ const result = runCommandInherited(command, { cwd: mainWorktreePath });
2400
+ printSeparator();
2401
+ if (result.error) {
2402
+ printError(MESSAGES.VALIDATE_RUN_ERROR(command, result.error.message));
2403
+ return;
2404
+ }
2405
+ const exitCode = result.status ?? 1;
2406
+ if (exitCode === 0) {
2407
+ printSuccess(MESSAGES.VALIDATE_RUN_SUCCESS(command));
2408
+ } else {
2409
+ printError(MESSAGES.VALIDATE_RUN_FAILED(command, exitCode));
2410
+ }
2411
+ }
2371
2412
  async function handleValidate(options) {
2372
2413
  if (options.clean) {
2373
2414
  await handleValidateClean(options);
@@ -2399,6 +2440,9 @@ async function handleValidate(options) {
2399
2440
  }
2400
2441
  handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted);
2401
2442
  }
2443
+ if (options.run) {
2444
+ executeRunCommand(options.run, mainWorktreePath);
2445
+ }
2402
2446
  }
2403
2447
 
2404
2448
  // src/commands/merge.ts
@@ -181,7 +181,15 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
181
181
  /** validate 交互选择提示 */
182
182
  VALIDATE_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u9A8C\u8BC1\u7684\u5206\u652F",
183
183
  /** validate 模糊匹配到多个结果提示 */
184
- VALIDATE_MULTIPLE_MATCHES: (name) => `"${name}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\uFF1A`
184
+ VALIDATE_MULTIPLE_MATCHES: (name) => `"${name}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\uFF1A`,
185
+ /** --run 命令开始执行提示 */
186
+ VALIDATE_RUN_START: (command) => `\u6B63\u5728\u4E3B worktree \u4E2D\u6267\u884C\u547D\u4EE4: ${command}`,
187
+ /** --run 命令执行成功(退出码 0) */
188
+ VALIDATE_RUN_SUCCESS: (command) => `\u2713 \u547D\u4EE4\u6267\u884C\u5B8C\u6210: ${command}\uFF0C\u9000\u51FA\u7801: 0`,
189
+ /** --run 命令执行失败(退出码非 0) */
190
+ VALIDATE_RUN_FAILED: (command, exitCode) => `\u2717 \u547D\u4EE4\u6267\u884C\u5B8C\u6210: ${command}\uFF0C\u9000\u51FA\u7801: ${exitCode}`,
191
+ /** --run 命令执行异常(进程启动失败等) */
192
+ VALIDATE_RUN_ERROR: (command, errorMessage) => `\u2717 \u547D\u4EE4\u6267\u884C\u51FA\u9519: ${errorMessage}`
185
193
  };
186
194
 
187
195
  // src/constants/messages/sync.ts
@@ -245,7 +253,9 @@ var REMOVE_MESSAGES = {
245
253
  ${branches.map((b) => ` - ${b}`).join("\n")}`,
246
254
  /** 批量移除部分失败 */
247
255
  REMOVE_PARTIAL_FAILURE: (failures) => `\u4EE5\u4E0B worktree \u79FB\u9664\u5931\u8D25\uFF1A
248
- ${failures.map((f) => ` \u2717 ${f.path}: ${f.error}`).join("\n")}`
256
+ ${failures.map((f) => ` \u2717 ${f.path}: ${f.error}`).join("\n")}`,
257
+ /** 用户选择保留本地分支 */
258
+ 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"
249
259
  };
250
260
 
251
261
  // src/constants/messages/reset.ts
package/docs/spec.md CHANGED
@@ -460,18 +460,19 @@ Claude Code CLI 以 `--output-format json` 运行时,退出后会在 stdout
460
460
 
461
461
  ```bash
462
462
  # 指定分支名(支持模糊匹配)
463
- clawt validate -b <branchName> [--clean]
463
+ clawt validate -b <branchName> [--clean] [-r <command>]
464
464
 
465
465
  # 不指定分支名(列出所有分支供选择)
466
- clawt validate [--clean]
466
+ clawt validate [--clean] [-r <command>]
467
467
  ```
468
468
 
469
469
  **参数:**
470
470
 
471
- | 参数 | 必填 | 说明 |
472
- | --------- | ---- | ------------------------------------------------------------------------ |
473
- | `-b` | 否 | 要验证的 worktree 分支名(支持模糊匹配,不传则列出所有分支供选择) |
474
- | `--clean` | 否 | 清理 validate 状态(重置主 worktree 并删除快照) |
471
+ | 参数 | 必填 | 说明 |
472
+ | ------------- | ---- | ------------------------------------------------------------------------ |
473
+ | `-b` | 否 | 要验证的 worktree 分支名(支持模糊匹配,不传则列出所有分支供选择) |
474
+ | `--clean` | 否 | 清理 validate 状态(重置主 worktree 并删除快照) |
475
+ | `-r, --run` | 否 | validate 成功后在主 worktree 中执行的命令(如测试、构建等) |
475
476
 
476
477
  > **限制:** 单次只能验证一个分支,不支持批量验证。
477
478
 
@@ -597,6 +598,45 @@ git restore --staged .
597
598
  可以开始验证了
598
599
  ```
599
600
 
601
+ ##### 步骤 6:执行 `--run` 命令(可选)
602
+
603
+ 如果用户传入了 `-r, --run` 选项,在 validate 成功后自动在主 worktree 中执行指定命令:
604
+
605
+ ```bash
606
+ # 示例
607
+ clawt validate -b feature-scheme-1 -r "npm test"
608
+ ```
609
+
610
+ **执行说明:**
611
+
612
+ - 命令通过 `spawnSync` + `inherit` stdio 模式在主 worktree 中执行,输出实时显示在终端
613
+ - 命令执行失败(退出码非 0 或进程启动失败)**不影响** validate 本身的结果,仅输出提示信息
614
+ - `--clean` 模式下传入 `--run` 会被忽略(只执行 clean 逻辑)
615
+
616
+ **输出格式:**
617
+
618
+ ```
619
+ # 命令执行成功
620
+ 正在主 worktree 中执行命令: npm test
621
+ ────────────────────────────────────────
622
+ ... 命令的实时输出 ...
623
+ ────────────────────────────────────────
624
+ ✓ 命令执行完成: npm test,退出码: 0
625
+
626
+ # 命令执行失败(退出码非 0)
627
+ 正在主 worktree 中执行命令: npm test
628
+ ────────────────────────────────────────
629
+ ... 命令的实时输出 ...
630
+ ────────────────────────────────────────
631
+ ✗ 命令执行完成: npm test,退出码: 1
632
+
633
+ # 命令执行出错(进程启动失败)
634
+ 正在主 worktree 中执行命令: nonexistent
635
+ ────────────────────────────────────────
636
+ ────────────────────────────────────────
637
+ ✗ 命令执行出错: spawn ENOENT
638
+ ```
639
+
600
640
  #### 增量 validate(存在历史快照)
601
641
 
602
642
  当 `~/.clawt/validate-snapshots/<project>/<branchName>.tree` 存在时,自动进入增量模式:
@@ -666,6 +706,10 @@ git apply --cached < patch
666
706
  可以开始验证了
667
707
  ```
668
708
 
709
+ ##### 步骤 7:执行 `--run` 命令(可选)
710
+
711
+ 与首次 validate 的步骤 6 相同,增量 validate 成功后也会执行 `-r, --run` 指定的命令。
712
+
669
713
  ---
670
714
 
671
715
  ### 5.5 移除 Worktree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "2.15.0",
3
+ "version": "2.16.1",
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
  // 执行移除,收集失败项
@@ -34,9 +34,12 @@ import {
34
34
  removeSnapshot,
35
35
  confirmDestructiveAction,
36
36
  printSuccess,
37
+ printError,
37
38
  printWarning,
38
39
  printInfo,
40
+ printSeparator,
39
41
  resolveTargetWorktree,
42
+ runCommandInherited,
40
43
  } from '../utils/index.js';
41
44
  import type { WorktreeResolveMessages } from '../utils/index.js';
42
45
 
@@ -58,6 +61,7 @@ export function registerValidateCommand(program: Command): void {
58
61
  .description('在主 worktree 验证某个 worktree 分支的变更')
59
62
  .option('-b, --branch <branchName>', '要验证的分支名(支持模糊匹配,不传则列出所有分支)')
60
63
  .option('--clean', '清理 validate 状态(重置主 worktree 并删除快照)')
64
+ .option('-r, --run <command>', 'validate 成功后在主 worktree 中执行的命令')
61
65
  .action(async (options: ValidateOptions) => {
62
66
  await handleValidate(options);
63
67
  });
@@ -300,6 +304,35 @@ function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath:
300
304
  printSuccess(MESSAGES.INCREMENTAL_VALIDATE_SUCCESS(branchName));
301
305
  }
302
306
 
307
+ /**
308
+ * 在主 worktree 中执行用户指定的命令
309
+ * 命令执行失败不影响 validate 本身的结果,仅输出提示
310
+ * @param {string} command - 要执行的命令字符串
311
+ * @param {string} mainWorktreePath - 主 worktree 路径
312
+ */
313
+ function executeRunCommand(command: string, mainWorktreePath: string): void {
314
+ printInfo('');
315
+ printInfo(MESSAGES.VALIDATE_RUN_START(command));
316
+ printSeparator();
317
+
318
+ const result = runCommandInherited(command, { cwd: mainWorktreePath });
319
+
320
+ printSeparator();
321
+
322
+ if (result.error) {
323
+ // 进程启动失败(如命令不存在)
324
+ printError(MESSAGES.VALIDATE_RUN_ERROR(command, result.error.message));
325
+ return;
326
+ }
327
+
328
+ const exitCode = result.status ?? 1;
329
+ if (exitCode === 0) {
330
+ printSuccess(MESSAGES.VALIDATE_RUN_SUCCESS(command));
331
+ } else {
332
+ printError(MESSAGES.VALIDATE_RUN_FAILED(command, exitCode));
333
+ }
334
+ }
335
+
303
336
  /**
304
337
  * 执行 validate 命令的核心逻辑
305
338
  * @param {ValidateOptions} options - 命令选项
@@ -350,4 +383,9 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
350
383
 
351
384
  handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted);
352
385
  }
386
+
387
+ // validate 成功后执行用户指定的命令
388
+ if (options.run) {
389
+ executeRunCommand(options.run, mainWorktreePath);
390
+ }
353
391
  }
@@ -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;
@@ -22,4 +22,14 @@ export const VALIDATE_MESSAGES = {
22
22
  VALIDATE_SELECT_BRANCH: '请选择要验证的分支',
23
23
  /** validate 模糊匹配到多个结果提示 */
24
24
  VALIDATE_MULTIPLE_MATCHES: (name: string) => `"${name}" 匹配到多个分支,请选择:`,
25
+ /** --run 命令开始执行提示 */
26
+ VALIDATE_RUN_START: (command: string) => `正在主 worktree 中执行命令: ${command}`,
27
+ /** --run 命令执行成功(退出码 0) */
28
+ VALIDATE_RUN_SUCCESS: (command: string) => `✓ 命令执行完成: ${command},退出码: 0`,
29
+ /** --run 命令执行失败(退出码非 0) */
30
+ VALIDATE_RUN_FAILED: (command: string, exitCode: number) =>
31
+ `✗ 命令执行完成: ${command},退出码: ${exitCode}`,
32
+ /** --run 命令执行异常(进程启动失败等) */
33
+ VALIDATE_RUN_ERROR: (command: string, errorMessage: string) =>
34
+ `✗ 命令执行出错: ${errorMessage}`,
25
35
  } as const;
@@ -26,6 +26,8 @@ export interface ValidateOptions {
26
26
  branch?: string;
27
27
  /** 清理 validate 状态 */
28
28
  clean?: boolean;
29
+ /** validate 成功后在主 worktree 中执行的命令 */
30
+ run?: string;
29
31
  }
30
32
 
31
33
  /** merge 命令选项 */
@@ -6,7 +6,7 @@ import { getProjectWorktreeDir } from './worktree.js';
6
6
  import { printInfo, printDoubleSeparator, printSeparator } from './formatter.js';
7
7
 
8
8
  /** dry-run 模式下任务描述的最大显示长度 */
9
- const DRY_RUN_TASK_DESC_MAX_LENGTH = 70;
9
+ const DRY_RUN_TASK_DESC_MAX_LENGTH = 80;
10
10
 
11
11
  /**
12
12
  * 截取任务描述,超出最大长度时末尾加省略号
@@ -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,4 @@
1
- export { execCommand, spawnProcess, killAllChildProcesses, execCommandWithInput } from './shell.js';
1
+ export { execCommand, spawnProcess, killAllChildProcesses, execCommandWithInput, runCommandInherited } from './shell.js';
2
2
  export {
3
3
  getGitCommonDir,
4
4
  getGitTopLevel,
@@ -49,7 +49,7 @@ export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } fro
49
49
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
50
50
  export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
51
51
  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';
52
+ export { printSuccess, printError, printWarning, printInfo, printHint, 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';
55
55
  export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
@@ -1,4 +1,4 @@
1
- import { execSync, execFileSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process';
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
4
  /**
@@ -72,3 +72,23 @@ export function execCommandWithInput(command: string, args: string[], options: {
72
72
  });
73
73
  return result.trim();
74
74
  }
75
+
76
+ /**
77
+ * 同步执行命令,继承父进程的 stdio(实时输出到终端)
78
+ * 使用 shell 模式以支持管道、重定向等 shell 语法
79
+ * @param {string} command - 要执行的命令字符串
80
+ * @param {object} options - 可选配置
81
+ * @param {string} options.cwd - 工作目录
82
+ * @returns {SpawnSyncReturns<Buffer>} spawnSync 的返回结果
83
+ */
84
+ export function runCommandInherited(
85
+ command: string,
86
+ options?: { cwd?: string },
87
+ ): SpawnSyncReturns<Buffer> {
88
+ logger.debug(`执行命令(inherit): ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
89
+ return spawnSync(command, {
90
+ cwd: options?.cwd,
91
+ stdio: 'inherit',
92
+ shell: true,
93
+ });
94
+ }
@@ -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 () => {
@@ -28,6 +28,11 @@ vi.mock('../../../src/constants/index.js', () => ({
28
28
  INCREMENTAL_VALIDATE_SUCCESS: (branch: string) => `✓ 增量验证 ${branch}`,
29
29
  INCREMENTAL_VALIDATE_FALLBACK: '降级为全量模式',
30
30
  DESTRUCTIVE_OP_CANCELLED: '已取消操作',
31
+ VALIDATE_RUN_START: (cmd: string) => `正在执行命令: ${cmd}`,
32
+ VALIDATE_RUN_SUCCESS: (cmd: string) => `✓ 命令执行完成: ${cmd}`,
33
+ VALIDATE_RUN_FAILED: (cmd: string, code: number) => `✗ 命令失败: ${cmd},退出码: ${code}`,
34
+ VALIDATE_RUN_ERROR: (cmd: string, msg: string) => `✗ 命令出错: ${msg}`,
35
+ SEPARATOR: '────',
31
36
  },
32
37
  }));
33
38
 
@@ -71,6 +76,9 @@ vi.mock('../../../src/utils/index.js', () => ({
71
76
  printWarning: vi.fn(),
72
77
  printInfo: vi.fn(),
73
78
  resolveTargetWorktree: vi.fn(),
79
+ runCommandInherited: vi.fn(),
80
+ printError: vi.fn(),
81
+ printSeparator: vi.fn(),
74
82
  }));
75
83
 
76
84
  import { registerValidateCommand } from '../../../src/commands/validate.js';
@@ -105,6 +113,9 @@ import {
105
113
  gitApplyCachedCheck,
106
114
  gitApplyCachedFromStdin,
107
115
  printWarning,
116
+ runCommandInherited,
117
+ printError,
118
+ printSeparator,
108
119
  } from '../../../src/utils/index.js';
109
120
 
110
121
  const mockedGetProjectName = vi.mocked(getProjectName);
@@ -137,6 +148,9 @@ const mockedGitDiffTree = vi.mocked(gitDiffTree);
137
148
  const mockedGitApplyCachedCheck = vi.mocked(gitApplyCachedCheck);
138
149
  const mockedGitApplyCachedFromStdin = vi.mocked(gitApplyCachedFromStdin);
139
150
  const mockedPrintWarning = vi.mocked(printWarning);
151
+ const mockedRunCommandInherited = vi.mocked(runCommandInherited);
152
+ const mockedPrintError = vi.mocked(printError);
153
+ const mockedPrintSeparator = vi.mocked(printSeparator);
140
154
 
141
155
  const worktree = { path: '/path/feature', branch: 'feature' };
142
156
 
@@ -171,6 +185,9 @@ beforeEach(() => {
171
185
  mockedGitApplyCachedCheck.mockReset();
172
186
  mockedGitApplyCachedFromStdin.mockReset();
173
187
  mockedPrintWarning.mockReset();
188
+ mockedRunCommandInherited.mockReset();
189
+ mockedPrintError.mockReset();
190
+ mockedPrintSeparator.mockReset();
174
191
  });
175
192
 
176
193
  describe('registerValidateCommand', () => {
@@ -380,3 +397,122 @@ describe('增量 validate', () => {
380
397
  expect(mockedPrintSuccess).toHaveBeenCalled();
381
398
  });
382
399
  });
400
+
401
+ describe('--run 选项', () => {
402
+ /** 设置首次 validate 成功的公共 mock */
403
+ function setupSuccessfulFirstValidate(): void {
404
+ mockedIsWorkingDirClean.mockReturnValue(true);
405
+ mockedHasLocalCommits.mockReturnValue(true);
406
+ mockedHasSnapshot.mockReturnValue(false);
407
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
408
+ mockedGitWriteTree.mockReturnValue('treehash');
409
+ mockedGetHeadCommitHash.mockReturnValue('headhash');
410
+ }
411
+
412
+ /** 构造 spawnSync 返回值的辅助函数 */
413
+ function createSpawnResult(overrides: { status?: number | null; error?: Error }) {
414
+ return {
415
+ pid: 0,
416
+ output: [],
417
+ stdout: Buffer.alloc(0),
418
+ stderr: Buffer.alloc(0),
419
+ status: overrides.status ?? null,
420
+ signal: null,
421
+ error: overrides.error,
422
+ };
423
+ }
424
+
425
+ it('validate 成功后执行 --run 指定的命令', async () => {
426
+ setupSuccessfulFirstValidate();
427
+ mockedRunCommandInherited.mockReturnValue(createSpawnResult({ status: 0 }));
428
+
429
+ const program = new Command();
430
+ program.exitOverride();
431
+ registerValidateCommand(program);
432
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
433
+
434
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
435
+ expect(mockedPrintSuccess).toHaveBeenCalledTimes(2);
436
+ });
437
+
438
+ it('--run 命令失败时输出错误信息但不抛异常', async () => {
439
+ setupSuccessfulFirstValidate();
440
+ mockedRunCommandInherited.mockReturnValue(createSpawnResult({ status: 1 }));
441
+
442
+ const program = new Command();
443
+ program.exitOverride();
444
+ registerValidateCommand(program);
445
+ await program.parseAsync(['validate', '-b', 'feature', '--run', 'npm test'], { from: 'user' });
446
+
447
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
448
+ expect(mockedPrintError).toHaveBeenCalled();
449
+ });
450
+
451
+ it('--run 命令进程启动失败时输出错误信息', async () => {
452
+ setupSuccessfulFirstValidate();
453
+ mockedRunCommandInherited.mockReturnValue(createSpawnResult({ error: new Error('spawn ENOENT') }));
454
+
455
+ const program = new Command();
456
+ program.exitOverride();
457
+ registerValidateCommand(program);
458
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'nonexistent'], { from: 'user' });
459
+
460
+ expect(mockedPrintError).toHaveBeenCalled();
461
+ // validate 成功 + run 出错,printSuccess 只被调用 1 次(validate 成功)
462
+ expect(mockedPrintSuccess).toHaveBeenCalledTimes(1);
463
+ });
464
+
465
+ it('未传 --run 时不执行任何命令', async () => {
466
+ setupSuccessfulFirstValidate();
467
+
468
+ const program = new Command();
469
+ program.exitOverride();
470
+ registerValidateCommand(program);
471
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
472
+
473
+ expect(mockedRunCommandInherited).not.toHaveBeenCalled();
474
+ });
475
+
476
+ it('--clean 与 --run 同时传入时只执行 clean 不执行 run', async () => {
477
+ mockedGetConfigValue.mockReturnValue(false);
478
+ mockedIsWorkingDirClean.mockReturnValue(true);
479
+
480
+ const program = new Command();
481
+ program.exitOverride();
482
+ registerValidateCommand(program);
483
+ await program.parseAsync(['validate', '--clean', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
484
+
485
+ expect(mockedRemoveSnapshot).toHaveBeenCalled();
486
+ expect(mockedRunCommandInherited).not.toHaveBeenCalled();
487
+ });
488
+
489
+ it('增量 validate 成功后也执行 --run 命令', async () => {
490
+ mockedIsWorkingDirClean.mockReturnValue(true);
491
+ mockedHasLocalCommits.mockReturnValue(true);
492
+ mockedHasSnapshot.mockReturnValue(true);
493
+ mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash' });
494
+ mockedGetHeadCommitHash.mockReturnValue('headhash');
495
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
496
+ mockedGitWriteTree.mockReturnValue('newtree');
497
+ mockedRunCommandInherited.mockReturnValue(createSpawnResult({ status: 0 }));
498
+
499
+ const program = new Command();
500
+ program.exitOverride();
501
+ registerValidateCommand(program);
502
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
503
+
504
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
505
+ });
506
+
507
+ it('目标分支无变更时不执行 --run', async () => {
508
+ mockedIsWorkingDirClean.mockReturnValue(true);
509
+ mockedHasLocalCommits.mockReturnValue(false);
510
+
511
+ const program = new Command();
512
+ program.exitOverride();
513
+ registerValidateCommand(program);
514
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
515
+
516
+ expect(mockedRunCommandInherited).not.toHaveBeenCalled();
517
+ });
518
+ });