clawt 2.15.0 → 2.16.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.
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
@@ -521,7 +529,7 @@ function enableConsoleTransport() {
521
529
  }
522
530
 
523
531
  // src/utils/shell.ts
524
- import { execSync, execFileSync, spawn } from "child_process";
532
+ import { execSync, execFileSync, spawn, spawnSync } from "child_process";
525
533
  function execCommand(command, options) {
526
534
  logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
527
535
  const result = execSync(command, {
@@ -555,6 +563,14 @@ function execCommandWithInput(command, args, options) {
555
563
  });
556
564
  return result.trim();
557
565
  }
566
+ function runCommandInherited(command, options) {
567
+ logger.debug(`\u6267\u884C\u547D\u4EE4(inherit): ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
568
+ return spawnSync(command, {
569
+ cwd: options?.cwd,
570
+ stdio: "inherit",
571
+ shell: true
572
+ });
573
+ }
558
574
 
559
575
  // src/utils/git.ts
560
576
  import { basename } from "path";
@@ -1000,7 +1016,7 @@ function parseConcurrency(optionValue, configValue) {
1000
1016
  import Enquirer from "enquirer";
1001
1017
 
1002
1018
  // src/utils/claude.ts
1003
- import { spawnSync } from "child_process";
1019
+ import { spawnSync as spawnSync2 } from "child_process";
1004
1020
  import { existsSync as existsSync6, readdirSync as readdirSync3 } from "fs";
1005
1021
  import { join as join3 } from "path";
1006
1022
 
@@ -1109,7 +1125,7 @@ function launchInteractiveClaude(worktree, options = {}) {
1109
1125
  printInfo(` \u6A21\u5F0F: ${hasPreviousSession ? "\u7EE7\u7EED\u4E0A\u6B21\u5BF9\u8BDD" : "\u65B0\u5BF9\u8BDD"}`);
1110
1126
  }
1111
1127
  printInfo("");
1112
- const result = spawnSync(cmd, args, {
1128
+ const result = spawnSync2(cmd, args, {
1113
1129
  cwd: worktree.path,
1114
1130
  stdio: "inherit"
1115
1131
  });
@@ -1779,7 +1795,7 @@ async function executeBatchTasks(worktrees, tasks, concurrency) {
1779
1795
  // src/utils/dry-run.ts
1780
1796
  import chalk4 from "chalk";
1781
1797
  import { join as join5 } from "path";
1782
- var DRY_RUN_TASK_DESC_MAX_LENGTH = 70;
1798
+ var DRY_RUN_TASK_DESC_MAX_LENGTH = 80;
1783
1799
  function truncateTaskDesc(task) {
1784
1800
  const oneLine = task.replace(/\n/g, " ").trim();
1785
1801
  if (oneLine.length <= DRY_RUN_TASK_DESC_MAX_LENGTH) {
@@ -2227,7 +2243,7 @@ var VALIDATE_RESOLVE_MESSAGES = {
2227
2243
  noMatch: MESSAGES.VALIDATE_NO_MATCH
2228
2244
  };
2229
2245
  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) => {
2246
+ 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
2247
  await handleValidate(options);
2232
2248
  });
2233
2249
  }
@@ -2368,6 +2384,23 @@ function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, project
2368
2384
  }
2369
2385
  printSuccess(MESSAGES.INCREMENTAL_VALIDATE_SUCCESS(branchName));
2370
2386
  }
2387
+ function executeRunCommand(command, mainWorktreePath) {
2388
+ printInfo("");
2389
+ printInfo(MESSAGES.VALIDATE_RUN_START(command));
2390
+ printSeparator();
2391
+ const result = runCommandInherited(command, { cwd: mainWorktreePath });
2392
+ printSeparator();
2393
+ if (result.error) {
2394
+ printError(MESSAGES.VALIDATE_RUN_ERROR(command, result.error.message));
2395
+ return;
2396
+ }
2397
+ const exitCode = result.status ?? 1;
2398
+ if (exitCode === 0) {
2399
+ printSuccess(MESSAGES.VALIDATE_RUN_SUCCESS(command));
2400
+ } else {
2401
+ printError(MESSAGES.VALIDATE_RUN_FAILED(command, exitCode));
2402
+ }
2403
+ }
2371
2404
  async function handleValidate(options) {
2372
2405
  if (options.clean) {
2373
2406
  await handleValidateClean(options);
@@ -2399,6 +2432,9 @@ async function handleValidate(options) {
2399
2432
  }
2400
2433
  handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted);
2401
2434
  }
2435
+ if (options.run) {
2436
+ executeRunCommand(options.run, mainWorktreePath);
2437
+ }
2402
2438
  }
2403
2439
 
2404
2440
  // 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
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.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
  }
@@ -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
  * 截取任务描述,超出最大长度时末尾加省略号
@@ -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,
@@ -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
+ }
@@ -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
+ });