clawt 1.2.0 → 1.3.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.
@@ -29,11 +29,13 @@
29
29
  ## 关键约定
30
30
  - `autoDeleteBranch` 配置项影响三处:remove 命令、merge 命令、run 中断清理
31
31
  - merge 的清理确认在 merge 操作之前询问(避免交互中断),但清理在 merge 成功后执行
32
+ - merge 成功后自动清理对应的 validate 快照(hasSnapshot + removeSnapshot)
32
33
  - run 的中断清理在所有子进程退出后执行
33
34
  - 文档中文风格,技术术语保留英文(worktree, merge, branch, SIGINT 等)
34
35
  - cleanupWorktrees 是 merge 和 run 共用的公共清理函数(在 src/utils/worktree.ts)
35
36
  - `launchInteractiveClaude` 是 run(交互式模式)和 resume 共用的公共函数(在 src/utils/claude.ts)
36
37
  - killAllChildProcesses 是 run 专用的子进程终止函数(在 src/utils/shell.ts)
38
+ - validate 快照管理函数在 `src/utils/validate-snapshot.ts`,被 validate 和 merge 两个命令使用
37
39
 
38
40
  ## 配置项同步检查点
39
41
 
@@ -65,3 +67,17 @@ run 命令有两种模式(自 claudeCodeCommand 特性后):
65
67
  Notes:
66
68
  - resume 和 run(交互式模式)共用 `launchInteractiveClaude()`,该函数从 run.ts 提取到 src/utils/claude.ts
67
69
  - `claudeCodeCommand` 配置项同时影响 run 交互式模式和 resume 命令
70
+
71
+ ## validate 快照机制
72
+
73
+ - validate 命令支持首次/增量两种模式,通过 `hasSnapshot()` 判断
74
+ - 快照路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.patch`
75
+ - 常量 `VALIDATE_SNAPSHOTS_DIR` 定义在 `src/constants/paths.ts`
76
+ - validate 新增 `--clean` 选项(`ValidateOptions.clean?: boolean`)
77
+ - 增量模式核心:旧 patch 应用到暂存区 + 新全量变更在工作目录 → `git diff` 可查看增量差异
78
+ - 增量 apply 失败时自动降级为全量模式
79
+ - shell 层新增 `execCommandWithInput()`(`execFileSync` + stdin),用于 `gitApplyCachedFromStdin()`
80
+ - git 层新增 `gitDiffCachedBinary()`(返回 Buffer)和 `gitApplyCachedFromStdin()`
81
+ - merge 成功后自动清理对应快照;merge 时主 worktree 脏 + 存在快照会输出警告提示
82
+ - docs/spec.md 中 validate 章节(5.4)按 `--clean 模式`、`首次 validate`、`增量 validate` 三段描述
83
+ - CLAUDE.md 中在 validate + merge 工作流章节用缩进列表描述两种模式
package/CLAUDE.md CHANGED
@@ -55,15 +55,19 @@ run 命令有两种模式:
55
55
 
56
56
  ### validate + merge 工作流
57
57
 
58
- - `validate`:将目标 worktree 的变更通过 git stash 迁移到主 worktree,便于在主 worktree 中测试
59
- - `merge`:检测目标 worktree 状态(有修改则需 `-m` 提交,已提交则跳过,无变更则报错)→ 合并到主 worktree pull push 可选清理 worktree 和分支(受 `autoDeleteBranch` 配置或交互式确认控制)
58
+ - `validate`:将目标 worktree 的变更通过 git stash 迁移到主 worktree,便于在主 worktree 中测试。支持两种模式:
59
+ - **首次 validate**(无历史快照):stash 迁移保存纯净快照 patch结果:暂存区=空,工作目录=全量变更
60
+ - **增量 validate**(存在历史快照):读取旧 patch → 清空主 worktree → stash 迁移最新变更 → 保存新快照 → 旧 patch 应用到暂存区 → 结果:暂存区=上次快照,工作目录=最新变更(可通过 `git diff` 查看增量差异)
61
+ - `--clean` 选项:重置主 worktree + 删除对应快照文件
62
+ - 快照存储路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.patch`
63
+ - `merge`:检测目标 worktree 状态(有修改则需 `-m` 提交,已提交则跳过,无变更则报错)→ 合并到主 worktree → pull → push → 可选清理 worktree 和分支(受 `autoDeleteBranch` 配置或交互式确认控制)→ 清理对应的 validate 快照
60
64
  - `run` 中断清理:Ctrl+C 终止所有子进程后,根据 `autoDeleteBranch` 配置自动清理或交互式确认清理本次创建的 worktree 和分支
61
65
 
62
66
  ### 目录层级
63
67
 
64
68
  - `src/commands/` — 各命令的注册与处理逻辑
65
- - `src/utils/` — 工具函数(git 操作、shell 执行与子进程管理、分支名处理、worktree 管理与批量清理、配置、格式化输出、交互式输入、Claude Code 交互式启动)
66
- - `src/constants/` — 常量定义(路径、退出码、消息模板、分支规则、配置默认值、终端控制序列)
69
+ - `src/utils/` — 工具函数(git 操作、shell 执行与子进程管理、分支名处理、worktree 管理与批量清理、配置、格式化输出、交互式输入、Claude Code 交互式启动、validate 快照管理)
70
+ - `src/constants/` — 常量定义(路径、退出码、消息模板、分支规则、配置默认值、终端控制序列、validate 快照目录)
67
71
  - `src/types/` — TypeScript 类型定义
68
72
  - `src/errors/` — 自定义 `ClawtError` 错误类(携带退出码)
69
73
  - `src/logger/` — winston 日志(按日期滚动,写入 `~/.clawt/logs/`)
@@ -73,6 +77,6 @@ run 命令有两种模式:
73
77
  - 所有命令执行前都会调用 `validateMainWorktree()` 确保在主 worktree 根目录(`git rev-parse --git-common-dir === ".git"`)
74
78
  - Worktree 统一存放在 `~/.clawt/worktrees/<projectName>/` 下
75
79
  - 全局配置文件 `~/.clawt/config.json`,postinstall 时自动创建/合并,包含 `autoDeleteBranch`(是否自动删除分支)、`claudeCodeCommand`(Claude Code CLI 启动指令,用于 `run` 和 `resume` 的交互式界面)、`autoPullPush`(merge 后是否自动 pull/push)三个配置项。配置项以 `CONFIG_DEFINITIONS` 为单一数据源,`DEFAULT_CONFIG` 和 `CONFIG_DESCRIPTIONS` 均从中派生
76
- - shell 命令执行有同步(`execCommand` → `execSync`)和异步(`spawnProcess` → `spawn`)两种方式
80
+ - shell 命令执行有同步(`execCommand` → `execSync`)、异步(`spawnProcess` → `spawn`)和同步带 stdin(`execCommandWithInput` → `execFileSync`)三种方式
77
81
  - 项目为纯 ESM(`"type": "module"`),模块导入需带 `.js` 后缀
78
82
  - 分支名特殊字符会被 `sanitizeBranchName()` 自动清理
package/README.md CHANGED
@@ -93,17 +93,27 @@ clawt resume -b feature-login
93
93
  ### `clawt validate` — 在主 worktree 验证分支变更
94
94
 
95
95
  ```bash
96
- clawt validate -b <branchName>
96
+ clawt validate -b <branchName> [--clean]
97
97
  ```
98
98
 
99
99
  | 参数 | 必填 | 说明 |
100
100
  | ---- | ---- | ---- |
101
101
  | `-b` | 是 | 要验证的分支名 |
102
+ | `--clean` | 否 | 清理 validate 状态(重置主 worktree 并删除快照) |
102
103
 
103
104
  将目标 worktree 的变更通过 `git stash` 迁移到主 worktree,方便在主 worktree 中直接测试,无需重新安装依赖。
104
105
 
106
+ 支持增量模式:首次 validate 后会自动保存快照,再次 validate 同一分支时会将上次快照应用到暂存区、最新变更保留在工作目录,用户可通过 `git diff` 查看两次 validate 之间的增量差异。使用 `--clean` 可清理 validate 状态(重置主 worktree 并删除快照文件)。
107
+
105
108
  ```bash
109
+ # 首次验证
110
+ clawt validate -b feature-scheme-1
111
+
112
+ # 再次验证(增量模式,可通过 git diff 查看增量差异)
106
113
  clawt validate -b feature-scheme-1
114
+
115
+ # 清理 validate 状态
116
+ clawt validate -b feature-scheme-1 --clean
107
117
  ```
108
118
 
109
119
  ### `clawt merge` — 合并分支到主 worktree
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ var CLAWT_HOME = join(homedir(), ".clawt");
13
13
  var CONFIG_PATH = join(CLAWT_HOME, "config.json");
14
14
  var LOGS_DIR = join(CLAWT_HOME, "logs");
15
15
  var WORKTREES_DIR = join(CLAWT_HOME, "worktrees");
16
+ var VALIDATE_SNAPSHOTS_DIR = join(CLAWT_HOME, "validate-snapshots");
16
17
 
17
18
  // src/constants/branch.ts
18
19
  var INVALID_BRANCH_CHARS = /[\/\\.\s~:*?[\]^]+/g;
@@ -81,7 +82,18 @@ var MESSAGES = {
81
82
  /** 创建数量参数无效 */
82
83
  INVALID_COUNT: (value) => `\u65E0\u6548\u7684\u521B\u5EFA\u6570\u91CF: "${value}"\uFF0C\u8BF7\u8F93\u5165\u6B63\u6574\u6570`,
83
84
  /** worktree 状态获取失败 */
84
- WORKTREE_STATUS_UNAVAILABLE: "(\u72B6\u6001\u4E0D\u53EF\u7528)"
85
+ WORKTREE_STATUS_UNAVAILABLE: "(\u72B6\u6001\u4E0D\u53EF\u7528)",
86
+ /** 增量 validate 成功提示 */
87
+ INCREMENTAL_VALIDATE_SUCCESS: (branch) => `\u2713 \u5DF2\u5C06\u5206\u652F ${branch} \u7684\u6700\u65B0\u53D8\u66F4\u5E94\u7528\u5230\u4E3B worktree\uFF08\u589E\u91CF\u6A21\u5F0F\uFF09
88
+ \u6682\u5B58\u533A = \u4E0A\u6B21\u5FEB\u7167\uFF0C\u5DE5\u4F5C\u76EE\u5F55 = \u6700\u65B0\u53D8\u66F4`,
89
+ /** 增量 validate 降级为全量模式提示 */
90
+ INCREMENTAL_VALIDATE_FALLBACK: "\u589E\u91CF\u5BF9\u6BD4\u5931\u8D25\uFF0C\u5DF2\u964D\u7EA7\u4E3A\u5168\u91CF\u6A21\u5F0F",
91
+ /** validate 状态已清理 */
92
+ VALIDATE_CLEANED: (branch) => `\u2713 \u5206\u652F ${branch} \u7684 validate \u72B6\u6001\u5DF2\u6E05\u7406`,
93
+ /** 增量 validate 检测到脏状态,即将清空 */
94
+ INCREMENTAL_VALIDATE_RESET: "\u68C0\u6D4B\u5230\u4E0A\u6B21 validate \u7684\u6B8B\u7559\u72B6\u6001\uFF0C\u5C06\u6E05\u7A7A\u4E3B worktree \u5E76\u91CD\u65B0\u5E94\u7528",
95
+ /** merge 命令检测到 validate 状态的提示 */
96
+ MERGE_VALIDATE_STATE_HINT: (branch) => `\u4E3B worktree \u53EF\u80FD\u5B58\u5728 validate \u6B8B\u7559\u72B6\u6001\uFF0C\u53EF\u5148\u6267\u884C clawt validate -b ${branch} --clean \u6E05\u7406`
85
97
  };
86
98
 
87
99
  // src/constants/exitCodes.ts
@@ -168,7 +180,7 @@ var logger = winston.createLogger({
168
180
  });
169
181
 
170
182
  // src/utils/shell.ts
171
- import { execSync, spawn } from "child_process";
183
+ import { execSync, execFileSync, spawn } from "child_process";
172
184
  function execCommand(command, options) {
173
185
  logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
174
186
  const result = execSync(command, {
@@ -192,9 +204,20 @@ function killAllChildProcesses(children) {
192
204
  }
193
205
  }
194
206
  }
207
+ function execCommandWithInput(command, args, options) {
208
+ logger.debug(`\u6267\u884C\u547D\u4EE4(stdin): ${command} ${args.join(" ")}${options.cwd ? ` (cwd: ${options.cwd})` : ""}`);
209
+ const result = execFileSync(command, args, {
210
+ cwd: options.cwd,
211
+ input: options.input,
212
+ encoding: "utf-8",
213
+ stdio: ["pipe", "pipe", "pipe"]
214
+ });
215
+ return result.trim();
216
+ }
195
217
 
196
218
  // src/utils/git.ts
197
219
  import { basename } from "path";
220
+ import { execSync as execSync2 } from "child_process";
198
221
  function getGitCommonDir(cwd) {
199
222
  return execCommand("git rev-parse --git-common-dir", { cwd });
200
223
  }
@@ -316,6 +339,16 @@ function getDiffStat(branchName, worktreePath, cwd) {
316
339
  deletions: committed.deletions + uncommitted.deletions
317
340
  };
318
341
  }
342
+ function gitDiffCachedBinary(cwd) {
343
+ logger.debug(`\u6267\u884C\u547D\u4EE4: git diff --cached --binary${cwd ? ` (cwd: ${cwd})` : ""}`);
344
+ return execSync2("git diff --cached --binary", {
345
+ cwd,
346
+ stdio: ["pipe", "pipe", "pipe"]
347
+ });
348
+ }
349
+ function gitApplyCachedFromStdin(patchContent, cwd) {
350
+ execCommandWithInput("git", ["apply", "--cached"], { input: patchContent, cwd });
351
+ }
319
352
 
320
353
  // src/utils/formatter.ts
321
354
  import chalk from "chalk";
@@ -565,6 +598,35 @@ function launchInteractiveClaude(worktree) {
565
598
  }
566
599
  }
567
600
 
601
+ // src/utils/validate-snapshot.ts
602
+ import { join as join3 } from "path";
603
+ import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync as readdirSync3, rmdirSync as rmdirSync2 } from "fs";
604
+ function getSnapshotPath(projectName, branchName) {
605
+ return join3(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.patch`);
606
+ }
607
+ function hasSnapshot(projectName, branchName) {
608
+ return existsSync5(getSnapshotPath(projectName, branchName));
609
+ }
610
+ function readSnapshot(projectName, branchName) {
611
+ const snapshotPath = getSnapshotPath(projectName, branchName);
612
+ logger.debug(`\u8BFB\u53D6 validate \u5FEB\u7167: ${snapshotPath}`);
613
+ return readFileSync2(snapshotPath);
614
+ }
615
+ function writeSnapshot(projectName, branchName, patch) {
616
+ const snapshotPath = getSnapshotPath(projectName, branchName);
617
+ const snapshotDir = join3(VALIDATE_SNAPSHOTS_DIR, projectName);
618
+ ensureDir(snapshotDir);
619
+ writeFileSync2(snapshotPath, patch);
620
+ logger.info(`\u5DF2\u4FDD\u5B58 validate \u5FEB\u7167: ${snapshotPath}`);
621
+ }
622
+ function removeSnapshot(projectName, branchName) {
623
+ const snapshotPath = getSnapshotPath(projectName, branchName);
624
+ if (existsSync5(snapshotPath)) {
625
+ unlinkSync(snapshotPath);
626
+ logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
627
+ }
628
+ }
629
+
568
630
  // src/commands/list.ts
569
631
  import chalk2 from "chalk";
570
632
  function registerListCommand(program2) {
@@ -624,7 +686,7 @@ function handleCreate(options) {
624
686
  }
625
687
 
626
688
  // src/commands/remove.ts
627
- import { join as join3 } from "path";
689
+ import { join as join4 } from "path";
628
690
  function registerRemoveCommand(program2) {
629
691
  program2.command("remove").description("\u79FB\u9664 worktree\uFF08\u652F\u6301\u5355\u4E2A/\u6279\u91CF/\u5168\u90E8\uFF09").option("--all", "\u79FB\u9664\u5F53\u524D\u9879\u76EE\u4E0B\u6240\u6709 worktree").option("-b, --branch <branchName>", "\u6307\u5B9A\u5206\u652F\u540D").option("-i, --index <index>", "\u6307\u5B9A\u7D22\u5F15\uFF08\u914D\u5408 -b \u4F7F\u7528\uFF09").action(async (options) => {
630
692
  await handleRemove(options);
@@ -641,7 +703,7 @@ function resolveWorktreesToRemove(options) {
641
703
  }
642
704
  if (options.index !== void 0) {
643
705
  const targetName = `${options.branch}-${options.index}`;
644
- const targetPath = join3(projectDir, targetName);
706
+ const targetPath = join4(projectDir, targetName);
645
707
  const found = allWorktrees.find((wt) => wt.path === targetPath);
646
708
  if (!found) {
647
709
  throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(targetName));
@@ -881,11 +943,11 @@ async function handleResume(options) {
881
943
  }
882
944
 
883
945
  // src/commands/validate.ts
884
- import { join as join4 } from "path";
885
- import { existsSync as existsSync5 } from "fs";
946
+ import { join as join5 } from "path";
947
+ import { existsSync as existsSync6 } from "fs";
886
948
  import Enquirer2 from "enquirer";
887
949
  function registerValidateCommand(program2) {
888
- program2.command("validate").description("\u5728\u4E3B worktree \u9A8C\u8BC1\u67D0\u4E2A worktree \u5206\u652F\u7684\u53D8\u66F4").requiredOption("-b, --branch <branchName>", "\u8981\u9A8C\u8BC1\u7684\u5206\u652F\u540D").action(async (options) => {
950
+ program2.command("validate").description("\u5728\u4E3B worktree \u9A8C\u8BC1\u67D0\u4E2A worktree \u5206\u652F\u7684\u53D8\u66F4").requiredOption("-b, --branch <branchName>", "\u8981\u9A8C\u8BC1\u7684\u5206\u652F\u540D").option("--clean", "\u6E05\u7406 validate \u72B6\u6001\uFF08\u91CD\u7F6E\u4E3B worktree \u5E76\u5220\u9664\u5FEB\u7167\uFF09").action(async (options) => {
889
951
  await handleValidate(options);
890
952
  });
891
953
  }
@@ -923,24 +985,8 @@ async function handleDirtyMainWorktree(mainWorktreePath) {
923
985
  throw new ClawtError("\u5DE5\u4F5C\u533A\u4ECD\u7136\u4E0D\u5E72\u51C0\uFF0C\u8BF7\u624B\u52A8\u5904\u7406");
924
986
  }
925
987
  }
926
- async function handleValidate(options) {
927
- validateMainWorktree();
928
- const projectName = getProjectName();
929
- const mainWorktreePath = getGitTopLevel();
930
- const projectDir = getProjectWorktreeDir();
931
- const targetWorktreePath = join4(projectDir, options.branch);
932
- logger.info(`validate \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch}`);
933
- if (!existsSync5(targetWorktreePath)) {
934
- throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
935
- }
936
- if (!isWorkingDirClean(mainWorktreePath)) {
937
- await handleDirtyMainWorktree(mainWorktreePath);
938
- }
939
- if (isWorkingDirClean(targetWorktreePath)) {
940
- printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
941
- return;
942
- }
943
- const stashMessage = `clawt:validate:${options.branch}`;
988
+ function migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName) {
989
+ const stashMessage = `clawt:validate:${branchName}`;
944
990
  gitAddAll(targetWorktreePath);
945
991
  gitStashPush(stashMessage, targetWorktreePath);
946
992
  gitStashApply(targetWorktreePath);
@@ -951,12 +997,86 @@ async function handleValidate(options) {
951
997
  throw new ClawtError(MESSAGES.STASH_CHANGED);
952
998
  }
953
999
  gitStashPop(0, mainWorktreePath);
954
- printSuccess(MESSAGES.VALIDATE_SUCCESS(options.branch));
1000
+ }
1001
+ function saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName) {
1002
+ gitAddAll(mainWorktreePath);
1003
+ const patch = gitDiffCachedBinary(mainWorktreePath);
1004
+ gitRestoreStaged(mainWorktreePath);
1005
+ writeSnapshot(projectName, branchName, patch);
1006
+ return patch;
1007
+ }
1008
+ function handleValidateClean(options) {
1009
+ validateMainWorktree();
1010
+ const projectName = getProjectName();
1011
+ const mainWorktreePath = getGitTopLevel();
1012
+ logger.info(`validate --clean \u6267\u884C\uFF0C\u5206\u652F: ${options.branch}`);
1013
+ if (!isWorkingDirClean(mainWorktreePath)) {
1014
+ gitResetHard(mainWorktreePath);
1015
+ gitCleanForce(mainWorktreePath);
1016
+ }
1017
+ removeSnapshot(projectName, options.branch);
1018
+ printSuccess(MESSAGES.VALIDATE_CLEANED(options.branch));
1019
+ }
1020
+ function handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName) {
1021
+ migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName);
1022
+ saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
1023
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
1024
+ }
1025
+ function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, branchName) {
1026
+ const oldPatch = readSnapshot(projectName, branchName);
1027
+ printInfo(MESSAGES.INCREMENTAL_VALIDATE_RESET);
1028
+ gitResetHard(mainWorktreePath);
1029
+ gitCleanForce(mainWorktreePath);
1030
+ migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName);
1031
+ const newPatch = saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
1032
+ if (oldPatch.length > 0) {
1033
+ try {
1034
+ gitApplyCachedFromStdin(oldPatch, mainWorktreePath);
1035
+ } catch (error) {
1036
+ logger.warn(`\u589E\u91CF apply \u5931\u8D25: ${error}`);
1037
+ printWarning(MESSAGES.INCREMENTAL_VALIDATE_FALLBACK);
1038
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
1039
+ return;
1040
+ }
1041
+ }
1042
+ printSuccess(MESSAGES.INCREMENTAL_VALIDATE_SUCCESS(branchName));
1043
+ }
1044
+ async function handleValidate(options) {
1045
+ if (options.clean) {
1046
+ handleValidateClean(options);
1047
+ return;
1048
+ }
1049
+ validateMainWorktree();
1050
+ const projectName = getProjectName();
1051
+ const mainWorktreePath = getGitTopLevel();
1052
+ const projectDir = getProjectWorktreeDir();
1053
+ const targetWorktreePath = join5(projectDir, options.branch);
1054
+ logger.info(`validate \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch}`);
1055
+ if (!existsSync6(targetWorktreePath)) {
1056
+ throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
1057
+ }
1058
+ const isIncremental = hasSnapshot(projectName, options.branch);
1059
+ if (isIncremental) {
1060
+ if (isWorkingDirClean(targetWorktreePath)) {
1061
+ printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
1062
+ return;
1063
+ }
1064
+ handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
1065
+ } else {
1066
+ if (!isWorkingDirClean(mainWorktreePath)) {
1067
+ await handleDirtyMainWorktree(mainWorktreePath);
1068
+ }
1069
+ if (isWorkingDirClean(targetWorktreePath)) {
1070
+ printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
1071
+ return;
1072
+ }
1073
+ handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
1074
+ }
955
1075
  }
956
1076
 
957
1077
  // src/commands/merge.ts
958
- import { join as join5 } from "path";
959
- import { existsSync as existsSync6 } from "fs";
1078
+ import { join as join6 } from "path";
1079
+ import { existsSync as existsSync7 } from "fs";
960
1080
  function registerMergeCommand(program2) {
961
1081
  program2.command("merge").description("\u5408\u5E76\u67D0\u4E2A\u5DF2\u9A8C\u8BC1\u7684 worktree \u5206\u652F\u5230\u4E3B worktree").requiredOption("-b, --branch <branchName>", "\u8981\u5408\u5E76\u7684\u5206\u652F\u540D").option("-m, --message <message>", "\u63D0\u4EA4\u4FE1\u606F\uFF08\u5DE5\u4F5C\u533A\u6709\u4FEE\u6539\u65F6\u5FC5\u586B\uFF09").action(async (options) => {
962
1082
  await handleMerge(options);
@@ -978,12 +1098,16 @@ async function handleMerge(options) {
978
1098
  validateMainWorktree();
979
1099
  const mainWorktreePath = getGitTopLevel();
980
1100
  const projectDir = getProjectWorktreeDir();
981
- const targetWorktreePath = join5(projectDir, options.branch);
1101
+ const targetWorktreePath = join6(projectDir, options.branch);
982
1102
  logger.info(`merge \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch}\uFF0C\u63D0\u4EA4\u4FE1\u606F: ${options.message ?? "(\u672A\u63D0\u4F9B)"}`);
983
- if (!existsSync6(targetWorktreePath)) {
1103
+ if (!existsSync7(targetWorktreePath)) {
984
1104
  throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
985
1105
  }
1106
+ const projectName = getProjectName();
986
1107
  if (!isWorkingDirClean(mainWorktreePath)) {
1108
+ if (hasSnapshot(projectName, options.branch)) {
1109
+ printWarning(MESSAGES.MERGE_VALIDATE_STATE_HINT(options.branch));
1110
+ }
987
1111
  throw new ClawtError(MESSAGES.MAIN_WORKTREE_DIRTY);
988
1112
  }
989
1113
  const shouldCleanup = await shouldCleanupAfterMerge(options.branch);
@@ -1024,6 +1148,9 @@ async function handleMerge(options) {
1024
1148
  if (shouldCleanup) {
1025
1149
  cleanupWorktreeAndBranch(targetWorktreePath, options.branch);
1026
1150
  }
1151
+ if (hasSnapshot(projectName, options.branch)) {
1152
+ removeSnapshot(projectName, options.branch);
1153
+ }
1027
1154
  }
1028
1155
 
1029
1156
  // src/commands/config.ts
@@ -10,6 +10,7 @@ var CLAWT_HOME = join(homedir(), ".clawt");
10
10
  var CONFIG_PATH = join(CLAWT_HOME, "config.json");
11
11
  var LOGS_DIR = join(CLAWT_HOME, "logs");
12
12
  var WORKTREES_DIR = join(CLAWT_HOME, "worktrees");
13
+ var VALIDATE_SNAPSHOTS_DIR = join(CLAWT_HOME, "validate-snapshots");
13
14
 
14
15
  // src/constants/config.ts
15
16
  var CONFIG_DEFINITIONS = {
package/docs/spec.md CHANGED
@@ -137,6 +137,10 @@ git show-ref --verify refs/heads/<branchName> 2>/dev/null
137
137
  ├── logs/ # 日志目录
138
138
  │ ├── clawt-2025-02-06.log
139
139
  │ └── ...
140
+ ├── validate-snapshots/ # validate 快照目录
141
+ │ └── <project-name>/ # 以项目名分组
142
+ │ ├── <branchName>.patch # 每个分支一个 patch 快照文件
143
+ │ └── ...
140
144
  └── worktrees/ # 所有 worktree 的统一存放目录
141
145
  └── <project-name>/ # 以项目名分组
142
146
  ├── <branchName>/ # n=1 时直接使用分支名
@@ -336,14 +340,15 @@ Claude Code CLI 以 `--output-format json` 运行时,退出后会在 stdout
336
340
  **命令:**
337
341
 
338
342
  ```bash
339
- clawt validate -b <branchName>
343
+ clawt validate -b <branchName> [--clean]
340
344
  ```
341
345
 
342
346
  **参数:**
343
347
 
344
- | 参数 | 必填 | 说明 |
345
- | ---- | ---- | ------------------------------------------------------------------------ |
346
- | `-b` | 是 | 要验证的 worktree 分支名(例如 `feature-scheme-1`) |
348
+ | 参数 | 必填 | 说明 |
349
+ | --------- | ---- | ------------------------------------------------------------------------ |
350
+ | `-b` | 是 | 要验证的 worktree 分支名(例如 `feature-scheme-1`) |
351
+ | `--clean` | 否 | 清理 validate 状态(重置主 worktree 并删除快照) |
347
352
 
348
353
  > **限制:** 单次只能验证一个分支,不支持批量验证。
349
354
 
@@ -351,9 +356,24 @@ clawt validate -b <branchName>
351
356
 
352
357
  Git worktree 不会包含 `node_modules`、`.venv` 等依赖文件,每次安装依赖耗时较长。利用 `git stash` 可以在所有 worktree 间共享的特性,将目标 worktree 的变更迁移到主 worktree 进行测试,无需重新安装依赖。
353
358
 
359
+ **快照机制:**
360
+
361
+ validate 命令引入了**快照(snapshot)机制**来支持增量对比。每次 validate 执行成功后,会将当前全量变更保存为 patch 文件(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`)。当再次执行 validate 时,通过对比新旧快照,将上次快照应用到暂存区、最新变更保留在工作目录,用户可通过 `git diff` 直接查看两次 validate 之间的增量差异。
362
+
354
363
  **运行流程:**
355
364
 
356
- #### 步骤 1:检测主 worktree 工作区状态
365
+ #### `--clean` 模式
366
+
367
+ 当指定 `--clean` 选项时,执行清理逻辑后直接返回,不进入常规 validate 流程:
368
+
369
+ 1. **主 worktree 校验** (2.1)
370
+ 2. 如果主 worktree 有未提交更改,执行 `git reset --hard` + `git clean -fd` 清空
371
+ 3. 删除对应分支的 patch 快照文件
372
+ 4. 输出清理成功提示
373
+
374
+ #### 首次 validate(无历史快照)
375
+
376
+ ##### 步骤 1:检测主 worktree 工作区状态
357
377
 
358
378
  执行 `git status --porcelain`,判断主 worktree 是否有未提交的更改。
359
379
 
@@ -376,7 +396,7 @@ Git worktree 不会包含 `node_modules`、`.venv` 等依赖文件,每次安
376
396
 
377
397
  执行完毕后,通过 `git status --porcelain` 再次检测状态,确保工作区干净。如果仍然不干净,报错退出。
378
398
 
379
- #### 步骤 2:在目标 worktree 中创建 stash
399
+ ##### 步骤 2:通过 stash 迁移目标 worktree 变更
380
400
 
381
401
  ```bash
382
402
  # 定位目标 worktree
@@ -398,13 +418,10 @@ git restore --staged .
398
418
 
399
419
  > 此步骤结束后,目标 worktree 的代码保持原样(变更仍然存在于工作区),同时变更已被记录到共享的 stash 中。
400
420
 
401
- #### 步骤 3:在主 worktree 应用 stash
421
+ 在主 worktree 中应用 stash
402
422
 
403
423
  ```bash
404
- # 回到主 worktree
405
424
  cd <主 worktree 路径>
406
-
407
- # 校验 stash@{0} 是否为我们创建的
408
425
  git stash list
409
426
  ```
410
427
 
@@ -417,9 +434,67 @@ git stash list
417
434
  git stash pop stash@{0}
418
435
  ```
419
436
 
420
- #### 步骤 4:输出成功提示
437
+ ##### 步骤 3:保存纯净快照
438
+
439
+ 将主 worktree 工作目录的全量变更保存为 patch 文件:
440
+
441
+ ```bash
442
+ git add .
443
+ git diff --cached --binary > ~/.clawt/validate-snapshots/<project>/<branchName>.patch
444
+ git restore --staged .
445
+ ```
446
+
447
+ > 结果:暂存区=空,工作目录=全量变更。
448
+
449
+ ##### 步骤 4:输出成功提示
450
+
451
+ ```
452
+ ✓ 已将分支 feature-scheme-1 的变更应用到主 worktree
453
+ 可以开始验证了
454
+ ```
455
+
456
+ #### 增量 validate(存在历史快照)
457
+
458
+ 当 `~/.clawt/validate-snapshots/<project>/<branchName>.patch` 存在时,自动进入增量模式:
459
+
460
+ ##### 步骤 1:读取旧 patch
461
+
462
+ 在清空主 worktree 之前,读取上次保存的快照 patch 内容。
463
+
464
+ ##### 步骤 2:清空主 worktree
465
+
466
+ 丢弃上次 validate 留下的变更和用户手动修改:
421
467
 
468
+ ```bash
469
+ git reset --hard
470
+ git clean -fd
422
471
  ```
472
+
473
+ ##### 步骤 3:从目标 worktree 获取最新全量变更
474
+
475
+ 检查目标 worktree 是否有更改(无更改则退出)。通过 stash 迁移目标 worktree 的最新变更到主 worktree(流程同首次 validate 的步骤 2)。
476
+
477
+ ##### 步骤 4:保存最新快照
478
+
479
+ 将最新全量变更保存为新的 patch 文件(覆盖旧快照,流程同首次 validate 的步骤 3)。
480
+
481
+ ##### 步骤 5:将旧 patch 应用到暂存区
482
+
483
+ ```bash
484
+ git apply --cached < <旧 patch 内容>
485
+ ```
486
+
487
+ - **应用成功** → 结果:暂存区=上次快照,工作目录=最新全量变更(用户可通过 `git diff` 查看增量差异)
488
+ - **应用失败**(文件结构变化过大)→ 降级为全量模式,暂存区保持为空,等同于首次 validate 的结果
489
+
490
+ ##### 步骤 6:输出成功提示
491
+
492
+ ```
493
+ # 增量模式成功
494
+ ✓ 已将分支 feature-scheme-1 的最新变更应用到主 worktree(增量模式)
495
+ 暂存区 = 上次快照,工作目录 = 最新变更
496
+
497
+ # 增量降级为全量
423
498
  ✓ 已将分支 feature-scheme-1 的变更应用到主 worktree
424
499
  可以开始验证了
425
500
  ```
@@ -503,7 +578,9 @@ clawt merge -b <branchName> [-m <commitMessage>]
503
578
  1. **主 worktree 校验** (2.1)
504
579
  2. **主 worktree 状态检测**
505
580
  - 执行 `git status --porcelain`
506
- - 如果有更改 → 提示 `主 worktree 有未提交的更改,请先处理`,退出
581
+ - 如果有更改:
582
+ - 如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`),额外输出警告提示用户可先执行 `clawt validate -b <branchName> --clean` 清理
583
+ - 提示 `主 worktree 有未提交的更改,请先处理`,退出
507
584
  - 无更改 → 继续
508
585
  3. **根据目标 worktree 状态决定是否需要提交**
509
586
  - 检测目标 worktree 工作区是否干净(`git status --porcelain`)
@@ -561,6 +638,9 @@ clawt merge -b <branchName> [-m <commitMessage>]
561
638
  ```
562
639
  - 输出清理成功提示:`✓ 已清理 worktree 和分支: <branchName>`
563
640
 
641
+ 9. **清理 validate 快照**
642
+ - merge 成功后,如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`),自动删除该快照文件(merge 成功后快照已无意义)
643
+
564
644
  > **注意:** 清理确认在 merge 操作之前询问(避免 merge 成功后因交互中断而遗留未清理的 worktree),但清理操作在 merge 成功后才执行。
565
645
 
566
646
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -7,6 +7,7 @@ import { MESSAGES } from '../constants/index.js';
7
7
  import type { MergeOptions } from '../types/index.js';
8
8
  import {
9
9
  validateMainWorktree,
10
+ getProjectName,
10
11
  getGitTopLevel,
11
12
  getProjectWorktreeDir,
12
13
  isWorkingDirClean,
@@ -17,8 +18,11 @@ import {
17
18
  gitPull,
18
19
  gitPush,
19
20
  hasLocalCommits,
21
+ hasSnapshot,
22
+ removeSnapshot,
20
23
  printSuccess,
21
24
  printInfo,
25
+ printWarning,
22
26
  getConfigValue,
23
27
  confirmAction,
24
28
  cleanupWorktrees,
@@ -82,8 +86,14 @@ async function handleMerge(options: MergeOptions): Promise<void> {
82
86
  throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
83
87
  }
84
88
 
89
+ const projectName = getProjectName();
90
+
85
91
  // 步骤 3:主 worktree 状态检测
86
92
  if (!isWorkingDirClean(mainWorktreePath)) {
93
+ // 如果存在 validate 快照状态,提示用户先清理
94
+ if (hasSnapshot(projectName, options.branch)) {
95
+ printWarning(MESSAGES.MERGE_VALIDATE_STATE_HINT(options.branch));
96
+ }
87
97
  throw new ClawtError(MESSAGES.MAIN_WORKTREE_DIRTY);
88
98
  }
89
99
 
@@ -143,4 +153,9 @@ async function handleMerge(options: MergeOptions): Promise<void> {
143
153
  if (shouldCleanup) {
144
154
  cleanupWorktreeAndBranch(targetWorktreePath, options.branch);
145
155
  }
156
+
157
+ // 步骤 10:清理 validate 快照(merge 成功后快照已无意义)
158
+ if (hasSnapshot(projectName, options.branch)) {
159
+ removeSnapshot(projectName, options.branch);
160
+ }
146
161
  }
@@ -20,9 +20,13 @@ import {
20
20
  gitRestoreStaged,
21
21
  gitResetHard,
22
22
  gitCleanForce,
23
- getStatusPorcelain,
23
+ gitDiffCachedBinary,
24
+ gitApplyCachedFromStdin,
25
+ hasSnapshot,
26
+ readSnapshot,
27
+ writeSnapshot,
28
+ removeSnapshot,
24
29
  printSuccess,
25
- printError,
26
30
  printWarning,
27
31
  printInfo,
28
32
  } from '../utils/index.js';
@@ -36,13 +40,14 @@ export function registerValidateCommand(program: Command): void {
36
40
  .command('validate')
37
41
  .description('在主 worktree 验证某个 worktree 分支的变更')
38
42
  .requiredOption('-b, --branch <branchName>', '要验证的分支名')
43
+ .option('--clean', '清理 validate 状态(重置主 worktree 并删除快照)')
39
44
  .action(async (options: ValidateOptions) => {
40
45
  await handleValidate(options);
41
46
  });
42
47
  }
43
48
 
44
49
  /**
45
- * 处理主 worktree 工作区有未提交更改的情况
50
+ * 处理主 worktree 工作区有未提交更改的情况(首次 validate 时使用)
46
51
  * @param {string} mainWorktreePath - 主 worktree 路径
47
52
  */
48
53
  async function handleDirtyMainWorktree(mainWorktreePath: string): Promise<void> {
@@ -86,11 +91,139 @@ async function handleDirtyMainWorktree(mainWorktreePath: string): Promise<void>
86
91
  }
87
92
  }
88
93
 
94
+ /**
95
+ * 通过 stash 将目标 worktree 的变更迁移到主 worktree
96
+ * @param {string} targetWorktreePath - 目标 worktree 路径
97
+ * @param {string} mainWorktreePath - 主 worktree 路径
98
+ * @param {string} branchName - 分支名
99
+ */
100
+ function migrateChangesViaStash(targetWorktreePath: string, mainWorktreePath: string, branchName: string): void {
101
+ const stashMessage = `clawt:validate:${branchName}`;
102
+ gitAddAll(targetWorktreePath);
103
+ gitStashPush(stashMessage, targetWorktreePath);
104
+ gitStashApply(targetWorktreePath);
105
+ gitRestoreStaged(targetWorktreePath);
106
+
107
+ // 在主 worktree 验证并应用 stash
108
+ const stashList = gitStashList(mainWorktreePath);
109
+ const firstLine = stashList.split('\n')[0] || '';
110
+
111
+ if (!firstLine.includes(stashMessage)) {
112
+ throw new ClawtError(MESSAGES.STASH_CHANGED);
113
+ }
114
+
115
+ gitStashPop(0, mainWorktreePath);
116
+ }
117
+
118
+ /**
119
+ * 保存当前主 worktree 工作目录变更为纯净快照 patch
120
+ * 操作序列:git add . → git diff --cached --binary → git restore --staged .
121
+ * @param {string} mainWorktreePath - 主 worktree 路径
122
+ * @param {string} projectName - 项目名
123
+ * @param {string} branchName - 分支名
124
+ * @returns {Buffer} 生成的 patch 内容
125
+ */
126
+ function saveCurrentSnapshotPatch(mainWorktreePath: string, projectName: string, branchName: string): Buffer {
127
+ gitAddAll(mainWorktreePath);
128
+ const patch = gitDiffCachedBinary(mainWorktreePath);
129
+ gitRestoreStaged(mainWorktreePath);
130
+ writeSnapshot(projectName, branchName, patch);
131
+ return patch;
132
+ }
133
+
134
+ /**
135
+ * 处理 --clean 选项:清理 validate 状态
136
+ * @param {ValidateOptions} options - 命令选项
137
+ */
138
+ function handleValidateClean(options: ValidateOptions): void {
139
+ validateMainWorktree();
140
+
141
+ const projectName = getProjectName();
142
+ const mainWorktreePath = getGitTopLevel();
143
+
144
+ logger.info(`validate --clean 执行,分支: ${options.branch}`);
145
+
146
+ // 清空主 worktree
147
+ if (!isWorkingDirClean(mainWorktreePath)) {
148
+ gitResetHard(mainWorktreePath);
149
+ gitCleanForce(mainWorktreePath);
150
+ }
151
+
152
+ // 删除对应的 patch 文件
153
+ removeSnapshot(projectName, options.branch);
154
+
155
+ printSuccess(MESSAGES.VALIDATE_CLEANED(options.branch));
156
+ }
157
+
158
+ /**
159
+ * 首次 validate 逻辑(无历史快照)
160
+ * @param {string} targetWorktreePath - 目标 worktree 路径
161
+ * @param {string} mainWorktreePath - 主 worktree 路径
162
+ * @param {string} projectName - 项目名
163
+ * @param {string} branchName - 分支名
164
+ */
165
+ function handleFirstValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string): void {
166
+ // 通过 stash 迁移目标 worktree 变更到主 worktree
167
+ migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName);
168
+
169
+ // 保存纯净快照到 patch 文件
170
+ saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
171
+
172
+ // 结果:暂存区=空,工作目录=全量变更
173
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
174
+ }
175
+
176
+ /**
177
+ * 增量 validate 逻辑(存在历史快照)
178
+ * @param {string} targetWorktreePath - 目标 worktree 路径
179
+ * @param {string} mainWorktreePath - 主 worktree 路径
180
+ * @param {string} projectName - 项目名
181
+ * @param {string} branchName - 分支名
182
+ */
183
+ function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string): void {
184
+ // 步骤 1:读取旧 patch(在清空前读取)
185
+ const oldPatch = readSnapshot(projectName, branchName);
186
+
187
+ // 步骤 2:清空主 worktree(丢弃手动修改和上次 validate 留下的变更)
188
+ printInfo(MESSAGES.INCREMENTAL_VALIDATE_RESET);
189
+ gitResetHard(mainWorktreePath);
190
+ gitCleanForce(mainWorktreePath);
191
+
192
+ // 步骤 3:从目标 worktree 获取最新全量变更
193
+ migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName);
194
+
195
+ // 步骤 4:保存最新快照
196
+ const newPatch = saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
197
+
198
+ // 步骤 5:将旧 patch 应用到暂存区
199
+ if (oldPatch.length > 0) {
200
+ try {
201
+ gitApplyCachedFromStdin(oldPatch, mainWorktreePath);
202
+ } catch (error) {
203
+ // 旧 patch 无法应用(可能文件结构变化太大),降级为全量模式
204
+ logger.warn(`增量 apply 失败: ${error}`);
205
+ printWarning(MESSAGES.INCREMENTAL_VALIDATE_FALLBACK);
206
+ // 降级后暂存区保持为空,工作目录为最新全量变更,与首次 validate 一致
207
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
208
+ return;
209
+ }
210
+ }
211
+
212
+ // 结果:暂存区=上次快照,工作目录=最新全量变更
213
+ printSuccess(MESSAGES.INCREMENTAL_VALIDATE_SUCCESS(branchName));
214
+ }
215
+
89
216
  /**
90
217
  * 执行 validate 命令的核心逻辑
91
218
  * @param {ValidateOptions} options - 命令选项
92
219
  */
93
220
  async function handleValidate(options: ValidateOptions): Promise<void> {
221
+ // 处理 --clean 选项
222
+ if (options.clean) {
223
+ handleValidateClean(options);
224
+ return;
225
+ }
226
+
94
227
  validateMainWorktree();
95
228
 
96
229
  const projectName = getProjectName();
@@ -105,33 +238,28 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
105
238
  throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
106
239
  }
107
240
 
108
- // 步骤 1:检测主 worktree 工作区状态
109
- if (!isWorkingDirClean(mainWorktreePath)) {
110
- await handleDirtyMainWorktree(mainWorktreePath);
111
- }
241
+ // 判断是否为增量 validate
242
+ const isIncremental = hasSnapshot(projectName, options.branch);
112
243
 
113
- // 步骤 2:在目标 worktree 中创建 stash
114
- if (isWorkingDirClean(targetWorktreePath)) {
115
- printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
116
- return;
117
- }
244
+ if (isIncremental) {
245
+ // 增量模式:检查目标 worktree 是否有变更
246
+ if (isWorkingDirClean(targetWorktreePath)) {
247
+ printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
248
+ return;
249
+ }
250
+ handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
251
+ } else {
252
+ // 首次模式:先确保主 worktree 干净
253
+ if (!isWorkingDirClean(mainWorktreePath)) {
254
+ await handleDirtyMainWorktree(mainWorktreePath);
255
+ }
118
256
 
119
- const stashMessage = `clawt:validate:${options.branch}`;
120
- gitAddAll(targetWorktreePath);
121
- gitStashPush(stashMessage, targetWorktreePath);
122
- gitStashApply(targetWorktreePath);
123
- gitRestoreStaged(targetWorktreePath);
257
+ // 检查目标 worktree 是否有变更
258
+ if (isWorkingDirClean(targetWorktreePath)) {
259
+ printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
260
+ return;
261
+ }
124
262
 
125
- // 步骤 3:在主 worktree 应用 stash
126
- const stashList = gitStashList(mainWorktreePath);
127
- const firstLine = stashList.split('\n')[0] || '';
128
-
129
- if (!firstLine.includes(stashMessage)) {
130
- throw new ClawtError(MESSAGES.STASH_CHANGED);
263
+ handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
131
264
  }
132
-
133
- gitStashPop(0, mainWorktreePath);
134
-
135
- // 步骤 4:输出成功提示
136
- printSuccess(MESSAGES.VALIDATE_SUCCESS(options.branch));
137
265
  }
@@ -1,4 +1,4 @@
1
- export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR } from './paths.js';
1
+ export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR, VALIDATE_SNAPSHOTS_DIR } from './paths.js';
2
2
  export { INVALID_BRANCH_CHARS } from './branch.js';
3
3
  export { MESSAGES } from './messages.js';
4
4
  export { EXIT_CODES } from './exitCodes.js';
@@ -64,4 +64,16 @@ export const MESSAGES = {
64
64
  INVALID_COUNT: (value: string) => `无效的创建数量: "${value}",请输入正整数`,
65
65
  /** worktree 状态获取失败 */
66
66
  WORKTREE_STATUS_UNAVAILABLE: '(状态不可用)',
67
+ /** 增量 validate 成功提示 */
68
+ INCREMENTAL_VALIDATE_SUCCESS: (branch: string) =>
69
+ `✓ 已将分支 ${branch} 的最新变更应用到主 worktree(增量模式)\n 暂存区 = 上次快照,工作目录 = 最新变更`,
70
+ /** 增量 validate 降级为全量模式提示 */
71
+ INCREMENTAL_VALIDATE_FALLBACK: '增量对比失败,已降级为全量模式',
72
+ /** validate 状态已清理 */
73
+ VALIDATE_CLEANED: (branch: string) => `✓ 分支 ${branch} 的 validate 状态已清理`,
74
+ /** 增量 validate 检测到脏状态,即将清空 */
75
+ INCREMENTAL_VALIDATE_RESET: '检测到上次 validate 的残留状态,将清空主 worktree 并重新应用',
76
+ /** merge 命令检测到 validate 状态的提示 */
77
+ MERGE_VALIDATE_STATE_HINT: (branch: string) =>
78
+ `主 worktree 可能存在 validate 残留状态,可先执行 clawt validate -b ${branch} --clean 清理`,
67
79
  } as const;
@@ -12,3 +12,6 @@ export const LOGS_DIR = join(CLAWT_HOME, 'logs');
12
12
 
13
13
  /** worktree 统一存放目录 ~/.clawt/worktrees/ */
14
14
  export const WORKTREES_DIR = join(CLAWT_HOME, 'worktrees');
15
+
16
+ /** validate 快照目录 ~/.clawt/validate-snapshots/ */
17
+ export const VALIDATE_SNAPSHOTS_DIR = join(CLAWT_HOME, 'validate-snapshots');
@@ -18,6 +18,8 @@ export interface RunOptions {
18
18
  export interface ValidateOptions {
19
19
  /** 要验证的分支名 */
20
20
  branch: string;
21
+ /** 清理 validate 状态 */
22
+ clean?: boolean;
21
23
  }
22
24
 
23
25
  /** merge 命令选项 */
package/src/utils/git.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { basename } from 'node:path';
2
- import { execCommand } from './shell.js';
2
+ import { execSync } from 'node:child_process';
3
+ import { execCommand, execCommandWithInput } from './shell.js';
3
4
  import { logger } from '../logger/index.js';
4
5
 
5
6
  /**
@@ -296,3 +297,26 @@ export function getDiffStat(branchName: string, worktreePath: string, cwd?: stri
296
297
  deletions: committed.deletions + uncommitted.deletions,
297
298
  };
298
299
  }
300
+
301
+ /**
302
+ * 获取暂存区相对于 HEAD 的完整 diff(含二进制文件)
303
+ * 注意:返回原始输出不做 trim,保留 patch 格式完整性
304
+ * @param {string} [cwd] - 工作目录
305
+ * @returns {Buffer} diff 原始输出(Buffer 格式,保留二进制数据完整性)
306
+ */
307
+ export function gitDiffCachedBinary(cwd?: string): Buffer {
308
+ logger.debug(`执行命令: git diff --cached --binary${cwd ? ` (cwd: ${cwd})` : ''}`);
309
+ return execSync('git diff --cached --binary', {
310
+ cwd,
311
+ stdio: ['pipe', 'pipe', 'pipe'],
312
+ });
313
+ }
314
+
315
+ /**
316
+ * 将 patch 内容通过 stdin 应用到暂存区
317
+ * @param {Buffer} patchContent - patch 内容(Buffer 格式)
318
+ * @param {string} [cwd] - 工作目录
319
+ */
320
+ export function gitApplyCachedFromStdin(patchContent: Buffer, cwd?: string): void {
321
+ execCommandWithInput('git', ['apply', '--cached'], { input: patchContent, cwd });
322
+ }
@@ -1,4 +1,4 @@
1
- export { execCommand, spawnProcess, killAllChildProcesses } from './shell.js';
1
+ export { execCommand, spawnProcess, killAllChildProcesses, execCommandWithInput } from './shell.js';
2
2
  export {
3
3
  getGitCommonDir,
4
4
  getGitTopLevel,
@@ -27,6 +27,8 @@ export {
27
27
  hasLocalCommits,
28
28
  getCommitCountAhead,
29
29
  getDiffStat,
30
+ gitDiffCachedBinary,
31
+ gitApplyCachedFromStdin,
30
32
  } from './git.js';
31
33
  export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
32
34
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
@@ -36,3 +38,4 @@ export { printSuccess, printError, printWarning, printInfo, printSeparator, prin
36
38
  export { ensureDir, removeEmptyDir } from './fs.js';
37
39
  export { multilineInput } from './prompt.js';
38
40
  export { launchInteractiveClaude } from './claude.js';
41
+ export { getSnapshotPath, hasSnapshot, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots } from './validate-snapshot.js';
@@ -1,4 +1,4 @@
1
- import { execSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process';
1
+ import { execSync, execFileSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process';
2
2
  import { logger } from '../logger/index.js';
3
3
 
4
4
  /**
@@ -51,3 +51,24 @@ export function killAllChildProcesses(children: ChildProcess[]): void {
51
51
  }
52
52
  }
53
53
  }
54
+
55
+ /**
56
+ * 同步执行命令,通过 stdin 传入数据
57
+ * @param {string} command - 要执行的命令
58
+ * @param {string[]} args - 命令参数
59
+ * @param {object} options - 配置
60
+ * @param {Buffer} options.input - 通过 stdin 传入的数据(Buffer 格式,保留二进制完整性)
61
+ * @param {string} [options.cwd] - 工作目录
62
+ * @returns {string} 命令的标准输出(已 trim)
63
+ * @throws {Error} 命令执行失败时抛出
64
+ */
65
+ export function execCommandWithInput(command: string, args: string[], options: { input: Buffer; cwd?: string }): string {
66
+ logger.debug(`执行命令(stdin): ${command} ${args.join(' ')}${options.cwd ? ` (cwd: ${options.cwd})` : ''}`);
67
+ const result = execFileSync(command, args, {
68
+ cwd: options.cwd,
69
+ input: options.input,
70
+ encoding: 'utf-8',
71
+ stdio: ['pipe', 'pipe', 'pipe'],
72
+ });
73
+ return result.trim();
74
+ }
@@ -0,0 +1,89 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'node:fs';
3
+ import { VALIDATE_SNAPSHOTS_DIR } from '../constants/index.js';
4
+ import { ensureDir } from './fs.js';
5
+ import { logger } from '../logger/index.js';
6
+
7
+ /**
8
+ * 获取指定项目和分支的 validate 快照文件路径
9
+ * @param {string} projectName - 项目名
10
+ * @param {string} branchName - 分支名
11
+ * @returns {string} patch 文件的绝对路径
12
+ */
13
+ export function getSnapshotPath(projectName: string, branchName: string): string {
14
+ return join(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.patch`);
15
+ }
16
+
17
+ /**
18
+ * 判断指定项目和分支是否存在 validate 快照
19
+ * @param {string} projectName - 项目名
20
+ * @param {string} branchName - 分支名
21
+ * @returns {boolean} 快照是否存在
22
+ */
23
+ export function hasSnapshot(projectName: string, branchName: string): boolean {
24
+ return existsSync(getSnapshotPath(projectName, branchName));
25
+ }
26
+
27
+ /**
28
+ * 读取指定项目和分支的 validate 快照内容
29
+ * @param {string} projectName - 项目名
30
+ * @param {string} branchName - 分支名
31
+ * @returns {Buffer} patch 文件内容(Buffer 格式,保留二进制完整性)
32
+ */
33
+ export function readSnapshot(projectName: string, branchName: string): Buffer {
34
+ const snapshotPath = getSnapshotPath(projectName, branchName);
35
+ logger.debug(`读取 validate 快照: ${snapshotPath}`);
36
+ return readFileSync(snapshotPath);
37
+ }
38
+
39
+ /**
40
+ * 写入 validate 快照内容(自动创建目录)
41
+ * @param {string} projectName - 项目名
42
+ * @param {string} branchName - 分支名
43
+ * @param {Buffer} patch - patch 内容(Buffer 格式)
44
+ */
45
+ export function writeSnapshot(projectName: string, branchName: string, patch: Buffer): void {
46
+ const snapshotPath = getSnapshotPath(projectName, branchName);
47
+ const snapshotDir = join(VALIDATE_SNAPSHOTS_DIR, projectName);
48
+ ensureDir(snapshotDir);
49
+ writeFileSync(snapshotPath, patch);
50
+ logger.info(`已保存 validate 快照: ${snapshotPath}`);
51
+ }
52
+
53
+ /**
54
+ * 删除指定项目和分支的 validate 快照
55
+ * @param {string} projectName - 项目名
56
+ * @param {string} branchName - 分支名
57
+ */
58
+ export function removeSnapshot(projectName: string, branchName: string): void {
59
+ const snapshotPath = getSnapshotPath(projectName, branchName);
60
+ if (existsSync(snapshotPath)) {
61
+ unlinkSync(snapshotPath);
62
+ logger.info(`已删除 validate 快照: ${snapshotPath}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 删除指定项目的所有 validate 快照
68
+ * @param {string} projectName - 项目名
69
+ */
70
+ export function removeProjectSnapshots(projectName: string): void {
71
+ const projectDir = join(VALIDATE_SNAPSHOTS_DIR, projectName);
72
+ if (!existsSync(projectDir)) {
73
+ return;
74
+ }
75
+
76
+ const files = readdirSync(projectDir);
77
+ for (const file of files) {
78
+ unlinkSync(join(projectDir, file));
79
+ }
80
+
81
+ // 尝试删除空目录
82
+ try {
83
+ rmdirSync(projectDir);
84
+ } catch {
85
+ // 目录非空或其他原因,忽略
86
+ }
87
+
88
+ logger.info(`已删除项目 ${projectName} 的所有 validate 快照`);
89
+ }