clawt 3.3.0 → 3.4.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
@@ -148,6 +148,14 @@ clawt validate -b <branch> -r "pnpm test & pnpm build" # 并行执行多个命
148
148
  | `-r "npm lint && npm test"` | `&&` 不拆分,同步执行 |
149
149
  | `-r "pnpm test & pnpm build"` | 并行执行,等全部完成后汇总结果 |
150
150
 
151
+ ### `clawt cover` — 将验证分支修改覆盖回目标 worktree
152
+
153
+ ```bash
154
+ clawt cover # 在验证分支上执行,自动推导目标分支
155
+ ```
156
+
157
+ 在 validate 验证过程中,如果在主 worktree(验证分支上)修改了代码,可通过 `cover` 将修改覆盖回目标 worktree。当工作区无修改时会提示确认,避免误操作。
158
+
151
159
  ### `clawt sync` — 同步主分支代码到目标 worktree
152
160
 
153
161
  ```bash
package/dist/index.js CHANGED
@@ -473,6 +473,26 @@ var INIT_MESSAGES = {
473
473
  INIT_SET_SUCCESS: (key, value) => `\u2713 \u9879\u76EE\u914D\u7F6E ${key} \u5DF2\u8BBE\u7F6E\u4E3A ${value}`
474
474
  };
475
475
 
476
+ // src/constants/messages/cover-validate.ts
477
+ var COVER_VALIDATE_MESSAGES = {
478
+ /** 当前不在验证分支上 */
479
+ COVER_VALIDATE_NOT_ON_VALIDATE_BRANCH: "\u5F53\u524D\u5206\u652F\u4E0D\u662F\u9A8C\u8BC1\u5206\u652F\uFF08\u9700\u4EE5 clawt-validate- \u5F00\u5934\uFF09\n \u8BF7\u5148\u901A\u8FC7 clawt validate \u5207\u6362\u5230\u9A8C\u8BC1\u5206\u652F",
480
+ /** 无增量修改 */
481
+ COVER_VALIDATE_NO_CHANGES: "\u9A8C\u8BC1\u5206\u652F\u4E0A\u6CA1\u6709\u76F8\u5BF9\u4E8E\u5FEB\u7167\u7684\u589E\u91CF\u4FEE\u6539\uFF0C\u65E0\u9700\u8986\u76D6",
482
+ /** 目标 worktree 不存在 */
483
+ COVER_VALIDATE_TARGET_NOT_FOUND: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u5BF9\u5E94\u7684 worktree\uFF0C\u8BF7\u786E\u8BA4\u8BE5 worktree \u5C1A\u672A\u88AB\u79FB\u9664`,
484
+ /** 无快照,提示先执行 validate */
485
+ COVER_VALIDATE_NO_SNAPSHOT: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167
486
+ \u8BF7\u5148\u6267\u884C clawt validate -b ${branch} \u521B\u5EFA\u5FEB\u7167`,
487
+ /** patch 应用失败 */
488
+ COVER_VALIDATE_APPLY_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Apatch \u5E94\u7528\u51FA\u9519
489
+ \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u5DE5\u4F5C\u533A\u72B6\u6001\u540E\u91CD\u8BD5`,
490
+ /** 工作区和暂存区无修改,可能为误操作 */
491
+ COVER_VALIDATE_WORKING_DIR_CLEAN: "\u5F53\u524D\u9A8C\u8BC1\u5206\u652F\u7684\u5DE5\u4F5C\u533A\u548C\u6682\u5B58\u533A\u6CA1\u6709\u4EFB\u4F55\u4FEE\u6539\uFF0C\u53EF\u80FD\u4E3A\u8BEF\u64CD\u4F5C",
492
+ /** 覆盖成功 */
493
+ COVER_VALIDATE_SUCCESS: (branch) => `\u2713 \u5DF2\u5C06\u9A8C\u8BC1\u5206\u652F\u4E0A\u7684\u4FEE\u6539\u8986\u76D6\u5230 worktree => ${branch}`
494
+ };
495
+
476
496
  // src/constants/messages/home.ts
477
497
  var HOME_MESSAGES = {
478
498
  /** 已在主工作分支上 */
@@ -554,6 +574,7 @@ var MESSAGES = {
554
574
  ...PROJECTS_MESSAGES,
555
575
  ...COMPLETION_MESSAGES,
556
576
  ...INIT_MESSAGES,
577
+ ...COVER_VALIDATE_MESSAGES,
557
578
  ...HOME_MESSAGES
558
579
  };
559
580
 
@@ -1709,16 +1730,19 @@ function readSnapshot(projectName, branchName) {
1709
1730
  const stagedTreeHash = existsSync8(stagedPath) ? readFileSync3(stagedPath, "utf-8").trim() : "";
1710
1731
  return { treeHash, headCommitHash, stagedTreeHash };
1711
1732
  }
1712
- function writeSnapshot(projectName, branchName, treeHash, headCommitHash, stagedTreeHash = "") {
1713
- const snapshotPath = getSnapshotPath(projectName, branchName);
1714
- const headPath = getSnapshotHeadPath(projectName, branchName);
1715
- const stagedPath = getSnapshotStagedPath(projectName, branchName);
1733
+ function writeSnapshot(projectName, branchName, treeHash, headCommitHash, stagedTreeHash) {
1716
1734
  const snapshotDir = join6(VALIDATE_SNAPSHOTS_DIR, projectName);
1717
1735
  ensureDir(snapshotDir);
1718
- writeFileSync3(snapshotPath, treeHash, "utf-8");
1719
- writeFileSync3(headPath, headCommitHash, "utf-8");
1720
- writeFileSync3(stagedPath, stagedTreeHash, "utf-8");
1721
- logger.info(`\u5DF2\u4FDD\u5B58 validate \u5FEB\u7167: ${snapshotPath}, ${headPath}, ${stagedPath}`);
1736
+ if (treeHash !== void 0) {
1737
+ writeFileSync3(getSnapshotPath(projectName, branchName), treeHash, "utf-8");
1738
+ }
1739
+ if (headCommitHash !== void 0) {
1740
+ writeFileSync3(getSnapshotHeadPath(projectName, branchName), headCommitHash, "utf-8");
1741
+ }
1742
+ if (stagedTreeHash !== void 0) {
1743
+ writeFileSync3(getSnapshotStagedPath(projectName, branchName), stagedTreeHash, "utf-8");
1744
+ }
1745
+ logger.info(`\u5DF2\u5199\u5165 validate \u5FEB\u7167 (project=${projectName}, branch=${branchName})`);
1722
1746
  }
1723
1747
  function removeSnapshot(projectName, branchName) {
1724
1748
  const snapshotPath = getSnapshotPath(projectName, branchName);
@@ -4074,11 +4098,6 @@ async function executeSyncForBranch(targetWorktreePath, branch) {
4074
4098
  printWarning(MESSAGES.SYNC_CONFLICT(targetWorktreePath));
4075
4099
  return { success: false, hasConflict: true };
4076
4100
  }
4077
- const projectName = getProjectName();
4078
- if (hasSnapshot(projectName, branch)) {
4079
- removeSnapshot(projectName, branch);
4080
- logger.info(`\u5DF2\u6E05\u9664\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167`);
4081
- }
4082
4101
  printSuccess(MESSAGES.SYNC_SUCCESS(branch, mainBranch));
4083
4102
  await rebuildValidateBranch(branch, mainWorktreePath);
4084
4103
  const validateBranchName = getValidateBranchName(branch);
@@ -4239,6 +4258,74 @@ async function handleValidate(options) {
4239
4258
  }
4240
4259
  }
4241
4260
 
4261
+ // src/commands/cover-validate.ts
4262
+ function registerCoverValidateCommand(program2) {
4263
+ program2.command("cover").description("\u5C06\u9A8C\u8BC1\u5206\u652F\u4E0A\u7684\u4FEE\u6539\u8986\u76D6\u56DE\u76EE\u6807 worktree\uFF08\u81EA\u52A8\u63A8\u5BFC\u76EE\u6807\u5206\u652F\uFF09").action(async () => {
4264
+ await handleCoverValidate();
4265
+ });
4266
+ }
4267
+ function extractTargetBranchName(currentBranch) {
4268
+ return currentBranch.slice(VALIDATE_BRANCH_PREFIX.length);
4269
+ }
4270
+ function findTargetWorktreePath(branchName) {
4271
+ const worktrees = getProjectWorktrees();
4272
+ const match = findExactMatch(worktrees, branchName);
4273
+ if (!match) {
4274
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_TARGET_NOT_FOUND(branchName));
4275
+ }
4276
+ return match.path;
4277
+ }
4278
+ function computeIncrementalPatch(snapshotTreeHash, mainWorktreePath) {
4279
+ const savedIndexTreeHash = gitWriteTree(mainWorktreePath);
4280
+ let currentTreeHash;
4281
+ try {
4282
+ gitAddAll(mainWorktreePath);
4283
+ currentTreeHash = gitWriteTree(mainWorktreePath);
4284
+ } finally {
4285
+ gitReadTree(savedIndexTreeHash, mainWorktreePath);
4286
+ }
4287
+ if (snapshotTreeHash === currentTreeHash) {
4288
+ return null;
4289
+ }
4290
+ const patch = gitDiffTree(snapshotTreeHash, currentTreeHash, mainWorktreePath);
4291
+ return { patch, currentTreeHash };
4292
+ }
4293
+ async function handleCoverValidate() {
4294
+ validateMainWorktree();
4295
+ requireProjectConfig();
4296
+ const projectName = getProjectName();
4297
+ const mainWorktreePath = getGitTopLevel();
4298
+ const currentBranch = getCurrentBranch(mainWorktreePath);
4299
+ if (!currentBranch.startsWith(VALIDATE_BRANCH_PREFIX)) {
4300
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_NOT_ON_VALIDATE_BRANCH);
4301
+ }
4302
+ const targetBranchName = extractTargetBranchName(currentBranch);
4303
+ logger.info(`cover-validate \u547D\u4EE4\u6267\u884C\uFF0C\u76EE\u6807\u5206\u652F: ${targetBranchName}`);
4304
+ const targetWorktreePath = findTargetWorktreePath(targetBranchName);
4305
+ if (!hasSnapshot(projectName, targetBranchName)) {
4306
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_NO_SNAPSHOT(targetBranchName));
4307
+ }
4308
+ const { treeHash: snapshotTreeHash } = readSnapshot(projectName, targetBranchName);
4309
+ if (isWorkingDirClean(mainWorktreePath)) {
4310
+ printInfo(MESSAGES.COVER_VALIDATE_WORKING_DIR_CLEAN);
4311
+ const confirmed = await confirmAction("\u662F\u5426\u7EE7\u7EED\u6267\u884C\u8986\u76D6\uFF1F");
4312
+ if (!confirmed) return;
4313
+ }
4314
+ const result = computeIncrementalPatch(snapshotTreeHash, mainWorktreePath);
4315
+ if (!result) {
4316
+ printInfo(MESSAGES.COVER_VALIDATE_NO_CHANGES);
4317
+ return;
4318
+ }
4319
+ try {
4320
+ gitApplyFromStdin(result.patch, targetWorktreePath);
4321
+ } catch (error) {
4322
+ logger.error(`cover-validate patch apply \u5931\u8D25: ${error}`);
4323
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_APPLY_FAILED(targetBranchName));
4324
+ }
4325
+ writeSnapshot(projectName, targetBranchName, result.currentTreeHash);
4326
+ printSuccess(MESSAGES.COVER_VALIDATE_SUCCESS(targetBranchName));
4327
+ }
4328
+
4242
4329
  // src/commands/merge.ts
4243
4330
  function registerMergeCommand(program2) {
4244
4331
  program2.command("merge").description("\u5408\u5E76\u67D0\u4E2A\u5DF2\u9A8C\u8BC1\u7684 worktree \u5206\u652F\u5230\u4E3B worktree").option("-b, --branch <branchName>", "\u8981\u5408\u5E76\u7684\u5206\u652F\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF0C\u4E0D\u4F20\u5219\u5217\u51FA\u6240\u6709\u5206\u652F\u4F9B\u9009\u62E9\uFF09").option("-m, --message <commitMessage>", "\u63D0\u4EA4\u4FE1\u606F\uFF08\u76EE\u6807 worktree \u5DE5\u4F5C\u533A\u6709\u4FEE\u6539\u65F6\u5FC5\u586B\uFF09").action(async (options) => {
@@ -5203,6 +5290,7 @@ registerRemoveCommand(program);
5203
5290
  registerRunCommand(program);
5204
5291
  registerResumeCommand(program);
5205
5292
  registerValidateCommand(program);
5293
+ registerCoverValidateCommand(program);
5206
5294
  registerMergeCommand(program);
5207
5295
  registerConfigCommand(program);
5208
5296
  registerSyncCommand(program);
@@ -450,6 +450,26 @@ var INIT_MESSAGES = {
450
450
  INIT_SET_SUCCESS: (key, value) => `\u2713 \u9879\u76EE\u914D\u7F6E ${key} \u5DF2\u8BBE\u7F6E\u4E3A ${value}`
451
451
  };
452
452
 
453
+ // src/constants/messages/cover-validate.ts
454
+ var COVER_VALIDATE_MESSAGES = {
455
+ /** 当前不在验证分支上 */
456
+ COVER_VALIDATE_NOT_ON_VALIDATE_BRANCH: "\u5F53\u524D\u5206\u652F\u4E0D\u662F\u9A8C\u8BC1\u5206\u652F\uFF08\u9700\u4EE5 clawt-validate- \u5F00\u5934\uFF09\n \u8BF7\u5148\u901A\u8FC7 clawt validate \u5207\u6362\u5230\u9A8C\u8BC1\u5206\u652F",
457
+ /** 无增量修改 */
458
+ COVER_VALIDATE_NO_CHANGES: "\u9A8C\u8BC1\u5206\u652F\u4E0A\u6CA1\u6709\u76F8\u5BF9\u4E8E\u5FEB\u7167\u7684\u589E\u91CF\u4FEE\u6539\uFF0C\u65E0\u9700\u8986\u76D6",
459
+ /** 目标 worktree 不存在 */
460
+ COVER_VALIDATE_TARGET_NOT_FOUND: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u5BF9\u5E94\u7684 worktree\uFF0C\u8BF7\u786E\u8BA4\u8BE5 worktree \u5C1A\u672A\u88AB\u79FB\u9664`,
461
+ /** 无快照,提示先执行 validate */
462
+ COVER_VALIDATE_NO_SNAPSHOT: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167
463
+ \u8BF7\u5148\u6267\u884C clawt validate -b ${branch} \u521B\u5EFA\u5FEB\u7167`,
464
+ /** patch 应用失败 */
465
+ COVER_VALIDATE_APPLY_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Apatch \u5E94\u7528\u51FA\u9519
466
+ \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u5DE5\u4F5C\u533A\u72B6\u6001\u540E\u91CD\u8BD5`,
467
+ /** 工作区和暂存区无修改,可能为误操作 */
468
+ COVER_VALIDATE_WORKING_DIR_CLEAN: "\u5F53\u524D\u9A8C\u8BC1\u5206\u652F\u7684\u5DE5\u4F5C\u533A\u548C\u6682\u5B58\u533A\u6CA1\u6709\u4EFB\u4F55\u4FEE\u6539\uFF0C\u53EF\u80FD\u4E3A\u8BEF\u64CD\u4F5C",
469
+ /** 覆盖成功 */
470
+ COVER_VALIDATE_SUCCESS: (branch) => `\u2713 \u5DF2\u5C06\u9A8C\u8BC1\u5206\u652F\u4E0A\u7684\u4FEE\u6539\u8986\u76D6\u5230 worktree => ${branch}`
471
+ };
472
+
453
473
  // src/constants/messages/home.ts
454
474
  var HOME_MESSAGES = {
455
475
  /** 已在主工作分支上 */
@@ -511,6 +531,7 @@ var MESSAGES = {
511
531
  ...PROJECTS_MESSAGES,
512
532
  ...COMPLETION_MESSAGES,
513
533
  ...INIT_MESSAGES,
534
+ ...COVER_VALIDATE_MESSAGES,
514
535
  ...HOME_MESSAGES
515
536
  };
516
537
 
@@ -0,0 +1,93 @@
1
+ ### 5.21 将验证分支修改覆盖回目标 Worktree
2
+
3
+ **命令:**
4
+
5
+ ```bash
6
+ clawt cover
7
+ ```
8
+
9
+ > 无需指定分支名,自动从当前验证分支名(`clawt-validate-<branchName>`)中推导目标分支。
10
+
11
+ **参数:**
12
+
13
+ 无额外参数。必须在验证分支上执行。
14
+
15
+ **使用场景:**
16
+
17
+ 在 `validate` 验证过程中,用户可能会在主 worktree(验证分支上)对代码进行修改(如修复测试失败、调整逻辑等)。`cover` 命令用于将这些修改覆盖回目标 worktree,使目标 worktree 的代码与验证分支上的最新状态同步。
18
+
19
+ **运行流程:**
20
+
21
+ ##### 步骤 1:前置校验
22
+
23
+ 1. **主 worktree 校验**(2.1)
24
+ 2. **项目级配置校验**(`requireProjectConfig`)
25
+ 3. **验证分支校验**:当前分支必须以 `clawt-validate-` 开头,否则报错退出
26
+
27
+ ##### 步骤 2:查找目标 worktree
28
+
29
+ 从验证分支名中提取目标分支名(去掉 `clawt-validate-` 前缀),然后在项目的 worktree 列表中精确匹配目标分支对应的 worktree。如果目标 worktree 不存在(可能已被移除),报错退出。
30
+
31
+ ##### 步骤 3:校验快照存在并读取
32
+
33
+ 校验目标分支的 validate 快照是否存在。如果快照不存在,提示用户先执行 `clawt validate -b <branch>` 创建快照。读取快照中的 tree hash(`snapshotTreeHash`),作为增量计算的基准。
34
+
35
+ ##### 步骤 3.5:工作区干净检查
36
+
37
+ 检测主 worktree(验证分支上)的工作区和暂存区是否干净(`isWorkingDirClean`):
38
+
39
+ - **不干净**(有修改)→ 正常继续,这是 cover 的典型使用场景
40
+ - **干净**(无修改)→ 输出提示信息 `当前验证分支的工作区和暂存区没有任何修改,可能为误操作`,并通过 `confirmAction` 询问用户 `是否继续执行覆盖?`:
41
+ - 用户确认 → 继续执行
42
+ - 用户取消 → 直接返回,不执行后续步骤
43
+
44
+ > 工作区干净时通常意味着用户没有在验证分支上做任何修改就执行了 cover,这大概率是误操作。增加确认提示可以避免不必要的覆盖操作。
45
+
46
+ ##### 步骤 4:计算增量 patch
47
+
48
+ 通过 `computeIncrementalPatch()` 计算验证分支上相对于快照的增量变更:
49
+
50
+ 1. 保存当前暂存区的 tree hash(`savedIndexTreeHash`),用于后续恢复
51
+ 2. `git add .` + `git write-tree` 获取当前工作区的完整 tree hash(`currentTreeHash`)
52
+ 3. 通过 `git read-tree` 恢复原始暂存区状态(无论成功失败都执行,在 `finally` 块中)
53
+ 4. 比较 `snapshotTreeHash` 与 `currentTreeHash`:
54
+ - **相同** → 无增量变更,输出提示后返回
55
+ - **不同** → 通过 `git diff-tree` 生成 patch
56
+
57
+ ##### 步骤 5:应用 patch 到目标 worktree
58
+
59
+ 将增量 patch 通过 `git apply --binary` 应用到目标 worktree 的工作区。如果 patch apply 失败,报错退出并提示用户检查目标 worktree 工作区状态。
60
+
61
+ ##### 步骤 6:更新快照
62
+
63
+ 将 `currentTreeHash` 写入快照的 `.tree` 文件,使后续再次 cover 时的基准正确。**只更新 `treeHash`,不更新 `headCommitHash` 和 `stagedTreeHash`**(保留 validate 时写入的原值)。
64
+
65
+ ##### 步骤 7:输出成功提示
66
+
67
+ ```
68
+ ✓ 已将验证分支上的修改覆盖到 worktree => <branchName>
69
+ ```
70
+
71
+ **错误消息:**
72
+
73
+ | 消息常量 | 触发条件 | 提示内容 |
74
+ | -------- | -------- | -------- |
75
+ | `COVER_VALIDATE_NOT_ON_VALIDATE_BRANCH` | 当前分支不是验证分支 | 提示先通过 `clawt validate` 切换到验证分支 |
76
+ | `COVER_VALIDATE_TARGET_NOT_FOUND` | 目标 worktree 不存在 | 提示确认该 worktree 尚未被移除 |
77
+ | `COVER_VALIDATE_NO_SNAPSHOT` | 无快照 | 提示先执行 `clawt validate -b <branch>` 创建快照 |
78
+ | `COVER_VALIDATE_NO_CHANGES` | 无增量变更 | 提示无需覆盖 |
79
+ | `COVER_VALIDATE_WORKING_DIR_CLEAN` | 工作区干净 | 提示可能为误操作,需确认是否继续 |
80
+ | `COVER_VALIDATE_APPLY_FAILED` | patch 应用失败 | 提示检查目标 worktree 工作区状态后重试 |
81
+
82
+ **实现要点:**
83
+
84
+ - 命令注册名为 `cover`(非 `cover-validate`),用户通过 `clawt cover` 调用
85
+ - 核心函数:`handleCoverValidate()`(`src/commands/cover-validate.ts`)
86
+ - 辅助函数:
87
+ - `extractTargetBranchName()`:从验证分支名提取目标分支名
88
+ - `findTargetWorktreePath()`:查找目标 worktree 路径
89
+ - `computeIncrementalPatch()`:计算增量 patch
90
+ - 消息常量:`COVER_VALIDATE_MESSAGES`(`src/constants/messages/cover-validate.ts`)
91
+ - `writeSnapshot` 调用时只传 `treeHash`,利用其可选参数特性保留磁盘上的 `headCommitHash` 和 `stagedTreeHash` 原值
92
+
93
+ ---
package/docs/spec.md CHANGED
@@ -35,6 +35,7 @@
35
35
  - [5.18 跨项目 Worktree 概览](./projects.md)
36
36
  - [5.19 初始化项目级配置](./init.md)
37
37
  - [5.20 切换回主工作分支](./home.md)
38
+ - [5.21 将验证分支修改覆盖回目标 Worktree](./cover-validate.md)
38
39
  - [6. 验证架构规则](#6-验证架构规则)
39
40
  - [7. 错误处理规范](#7-错误处理规范)
40
41
  - [8. 非功能性需求](#8-非功能性需求)
@@ -294,6 +295,7 @@ async function interactiveConfigEditor<T extends object>(
294
295
  | `clawt completion` | 为终端提供 shell 自动补全功能(bash/zsh) | 5.16 |
295
296
  | `clawt projects` | 展示所有项目的 worktree 概览,或查看指定项目的 worktree 详情 | 5.17 |
296
297
  | `clawt home` | 快速切换回主工作分支 | 5.20 |
298
+ | `clawt cover` | 将验证分支上的修改覆盖回目标 worktree(自动推导目标分支) | 5.21 |
297
299
 
298
300
  **全局选项:**
299
301
 
@@ -328,6 +330,7 @@ async function interactiveConfigEditor<T extends object>(
328
330
  - [5.18 跨项目 Worktree 概览](./projects.md)
329
331
  - [5.19 初始化项目级配置](./init.md)
330
332
  - [5.20 切换回主工作分支](./home.md)
333
+ - [5.21 将验证分支修改覆盖回目标 Worktree](./cover-validate.md)
331
334
 
332
335
  ---
333
336
 
@@ -336,12 +339,12 @@ async function interactiveConfigEditor<T extends object>(
336
339
  以下规则适用于验证分支架构的所有实现工作:
337
340
 
338
341
  1. **不兼容旧版本**:本次重构不考虑旧版本数据、旧版本创建的 worktree 或旧版本配置的兼容性。所有命令均假定验证分支和项目级配置已按新架构存在。用户需删除旧 worktree 后重新创建。
339
- 2. **项目级配置前置校验**:仅对 create、run、validate、sync、remove、merge、reset、home 这 8 个核心命令添加检测,执行时必须先检查项目级配置(`~/.clawt/projects/<projectName>/config.json`)是否存在且包含 `clawtMainWorkBranch`。如果不存在,直接报错退出并提示用户先执行 `clawt init`:
342
+ 2. **项目级配置前置校验**:仅对 create、run、validate、cover、sync、remove、merge、reset、home 这 9 个核心命令添加检测,执行时必须先检查项目级配置(`~/.clawt/projects/<projectName>/config.json`)是否存在且包含 `clawtMainWorkBranch`。如果不存在,直接报错退出并提示用户先执行 `clawt init`:
340
343
  ```
341
344
  ✗ 该项目尚未初始化,请先执行 clawt init -b<branchName>设置主工作分支
342
345
  ```
343
346
  其他命令(list、resume、config、status、alias、projects、completion)不受影响,无需添加该校验。
344
- > **实现细节**:`ensureOnMainWorkBranch()` 内部已通过 `getMainWorkBranch()` → `requireProjectConfig()` 完成了项目配置校验,因此调用了 `ensureOnMainWorkBranch` 的命令(create、run、validate、merge)**无需再显式调用 `requireProjectConfig()`**,避免重复校验。sync 和 remove 命令因不依赖主 worktree 的分支状态而不调用 `ensureOnMainWorkBranch`,需自行显式调用 `requireProjectConfig()`。reset 和 home 命令同理,也需自行调用 `requireProjectConfig()`。
347
+ > **实现细节**:`ensureOnMainWorkBranch()` 内部已通过 `getMainWorkBranch()` → `requireProjectConfig()` 完成了项目配置校验,因此调用了 `ensureOnMainWorkBranch` 的命令(create、run、validate、merge)**无需再显式调用 `requireProjectConfig()`**,避免重复校验。sync、removecover 命令因不依赖主 worktree 的分支状态而不调用 `ensureOnMainWorkBranch`,需自行显式调用 `requireProjectConfig()`。reset 和 home 命令同理,也需自行调用 `requireProjectConfig()`。
345
348
  3. **主分支名统一从项目级配置获取**:所有需要获取主分支名的场景(sync 中合并主分支、merge 中计算 merge-base、切回主分支等),统一使用项目级配置中的 `clawtMainWorkBranch`,不再通过 `getCurrentBranch(mainWorktreePath)` 动态获取。因为在新架构下,主 worktree 可能处于验证分支上,`getCurrentBranch` 会返回验证分支名而非真正的主工作分支名。
346
349
  4. **测试文件全量更新**:本次重构涉及的所有命令(init、create、run、validate、sync、remove、merge、reset),其对应的测试文件必须同步更新,确保覆盖新增的验证分支逻辑、项目级配置逻辑和变更后的流程。
347
350
 
package/docs/sync.md CHANGED
@@ -76,7 +76,21 @@ export interface SyncResult {
76
76
  ```
77
77
  - 返回 `{ success: false, hasConflict: true }`
78
78
  - **无冲突** → 继续
79
- 5. **清除 validate 快照**:合并成功后,如果该分支存在 validate 快照(`.tree` `.head` 文件),自动删除(代码基础已变化,旧快照无效)
79
+ 5. **保留 validate 快照**:sync 合并成功后,不清除该分支的 validate 快照。因为 validate 使用三点 diff(`main...feature`),sync 后 merge-base 更新为合并提交,三点 diff 仍然只包含 feature 分支自身的修改,旧快照依然有效。增量 validate 时若检测到 HEAD 变化,会自动通过 diff-tree + apply 路径正确恢复暂存区状态。
80
+ 示意图:
81
+ 场景:将 HEAD(master) 合并到 branchName
82
+
83
+ 执行 git checkout branchName && git merge master 后:
84
+
85
+ A -- B -- C (HEAD/master)
86
+ / \
87
+ * M (branchName, merge commit)
88
+ \ /
89
+ D -- E ----
90
+
91
+ 此时执行 git diff HEAD...branchName:
92
+
93
+ - merge-base 变成了 C(因为合并后,HEAD 和 branchName 的最近共同祖先就是 C)
80
94
  6. **重建验证分支**(`rebuildValidateBranch`,async 函数):sync 将主分支合并到目标 worktree 后,目标分支的代码基点发生变化。为保持验证分支与目标分支基点一致,需要重建验证分支。
81
95
  - 确保在主工作分支上创建验证分支,处理三种情况:
82
96
  - **已在主工作分支上** → 直接重建
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,151 @@
1
+ import type { Command } from 'commander';
2
+ import { logger } from '../logger/index.js';
3
+ import { ClawtError } from '../errors/index.js';
4
+ import { MESSAGES, VALIDATE_BRANCH_PREFIX } from '../constants/index.js';
5
+ import {
6
+ validateMainWorktree,
7
+ requireProjectConfig,
8
+ getProjectName,
9
+ getGitTopLevel,
10
+ getCurrentBranch,
11
+ getProjectWorktrees,
12
+ findExactMatch,
13
+ hasSnapshot,
14
+ readSnapshot,
15
+ writeSnapshot,
16
+ gitAddAll,
17
+ gitWriteTree,
18
+ gitReadTree,
19
+ gitDiffTree,
20
+ gitApplyFromStdin,
21
+ printSuccess,
22
+ printInfo,
23
+ isWorkingDirClean,
24
+ confirmAction,
25
+ } from '../utils/index.js';
26
+
27
+ /**
28
+ * 注册 cover-validate 命令:将验证分支上的修改覆盖回目标 worktree
29
+ * @param {Command} program - Commander 实例
30
+ */
31
+ export function registerCoverValidateCommand(program: Command): void {
32
+ program
33
+ .command('cover')
34
+ .description('将验证分支上的修改覆盖回目标 worktree(自动推导目标分支)')
35
+ .action(async () => {
36
+ await handleCoverValidate();
37
+ });
38
+ }
39
+
40
+ /**
41
+ * 从验证分支名中提取目标分支名
42
+ * 验证分支名格式为 clawt-validate-<targetBranch>,去掉前缀即为目标分支名
43
+ * @param {string} currentBranch - 当前分支名
44
+ * @returns {string} 目标分支名
45
+ */
46
+ export function extractTargetBranchName(currentBranch: string): string {
47
+ return currentBranch.slice(VALIDATE_BRANCH_PREFIX.length);
48
+ }
49
+
50
+ /**
51
+ * 在项目 worktree 列表中查找目标分支对应的 worktree 路径
52
+ * @param {string} branchName - 目标分支名
53
+ * @returns {string} 目标 worktree 的绝对路径
54
+ * @throws {ClawtError} 目标 worktree 不存在时抛出
55
+ */
56
+ export function findTargetWorktreePath(branchName: string): string {
57
+ const worktrees = getProjectWorktrees();
58
+ const match = findExactMatch(worktrees, branchName);
59
+ if (!match) {
60
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_TARGET_NOT_FOUND(branchName));
61
+ }
62
+ return match.path;
63
+ }
64
+
65
+ /**
66
+ * 计算验证分支上相对于快照的增量 patch
67
+ * 操作序列:git write-tree(保存暂存区)→ git add . → git write-tree(获取工作区 tree)→ git read-tree(恢复暂存区)
68
+ * 当 snapshotTreeHash 与当前 tree hash 相同时返回 null 表示无变更
69
+ * @param {string} snapshotTreeHash - 快照中记录的 tree hash
70
+ * @param {string} mainWorktreePath - 主 worktree 路径
71
+ * @returns {{ patch: Buffer; currentTreeHash: string } | null} 增量 patch 和当前 tree hash,无变更时返回 null
72
+ */
73
+ export function computeIncrementalPatch(snapshotTreeHash: string, mainWorktreePath: string): { patch: Buffer; currentTreeHash: string } | null {
74
+ // 先保存当前暂存区的 tree hash,用于后续恢复
75
+ const savedIndexTreeHash = gitWriteTree(mainWorktreePath);
76
+ let currentTreeHash: string;
77
+
78
+ try {
79
+ gitAddAll(mainWorktreePath);
80
+ currentTreeHash = gitWriteTree(mainWorktreePath);
81
+ } finally {
82
+ // 无论成功或失败,都通过 git read-tree 恢复原始暂存区状态
83
+ gitReadTree(savedIndexTreeHash, mainWorktreePath);
84
+ }
85
+
86
+ // 快照 tree 与当前 tree 相同,无增量变更
87
+ if (snapshotTreeHash === currentTreeHash) {
88
+ return null;
89
+ }
90
+
91
+ const patch = gitDiffTree(snapshotTreeHash, currentTreeHash, mainWorktreePath);
92
+ return { patch, currentTreeHash };
93
+ }
94
+
95
+ /**
96
+ * 执行 cover-validate 命令的核心逻辑
97
+ * 将验证分支上的增量修改(相对于 validate 快照)覆盖到目标 worktree 工作区
98
+ */
99
+ async function handleCoverValidate(): Promise<void> {
100
+ // 步骤 1:前置校验
101
+ validateMainWorktree();
102
+ requireProjectConfig();
103
+ const projectName = getProjectName();
104
+ const mainWorktreePath = getGitTopLevel();
105
+ const currentBranch = getCurrentBranch(mainWorktreePath);
106
+
107
+ // 校验当前分支是验证分支
108
+ if (!currentBranch.startsWith(VALIDATE_BRANCH_PREFIX)) {
109
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_NOT_ON_VALIDATE_BRANCH);
110
+ }
111
+
112
+ // 从验证分支名推导目标分支名
113
+ const targetBranchName = extractTargetBranchName(currentBranch);
114
+ logger.info(`cover-validate 命令执行,目标分支: ${targetBranchName}`);
115
+
116
+ // 步骤 2:查找目标 worktree
117
+ const targetWorktreePath = findTargetWorktreePath(targetBranchName);
118
+
119
+ // 步骤 3:校验快照存在并读取
120
+ if (!hasSnapshot(projectName, targetBranchName)) {
121
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_NO_SNAPSHOT(targetBranchName));
122
+ }
123
+ const { treeHash: snapshotTreeHash } = readSnapshot(projectName, targetBranchName);
124
+
125
+ // 步骤 3.5:工作区干净时提示确认,避免误操作
126
+ if (isWorkingDirClean(mainWorktreePath)) {
127
+ printInfo(MESSAGES.COVER_VALIDATE_WORKING_DIR_CLEAN);
128
+ const confirmed = await confirmAction('是否继续执行覆盖?');
129
+ if (!confirmed) return;
130
+ }
131
+
132
+ // 步骤 4:计算增量 patch
133
+ const result = computeIncrementalPatch(snapshotTreeHash, mainWorktreePath);
134
+ if (!result) {
135
+ printInfo(MESSAGES.COVER_VALIDATE_NO_CHANGES);
136
+ return;
137
+ }
138
+
139
+ // 步骤 5:应用 patch 到目标 worktree
140
+ try {
141
+ gitApplyFromStdin(result.patch, targetWorktreePath);
142
+ } catch (error) {
143
+ logger.error(`cover-validate patch apply 失败: ${error}`);
144
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_APPLY_FAILED(targetBranchName));
145
+ }
146
+
147
+ // 步骤 6:更新快照 treeHash(使后续再次 cover 的基准正确),HEAD 和 stagedTreeHash 不变
148
+ writeSnapshot(projectName, targetBranchName, result.currentTreeHash);
149
+
150
+ printSuccess(MESSAGES.COVER_VALIDATE_SUCCESS(targetBranchName));
151
+ }
@@ -6,15 +6,12 @@ import type { SyncOptions } from '../types/index.js';
6
6
  import {
7
7
  validateMainWorktree,
8
8
  getGitTopLevel,
9
- getProjectName,
10
9
  getProjectWorktrees,
11
10
  isWorkingDirClean,
12
11
  gitAddAll,
13
12
  gitCommit,
14
13
  gitMerge,
15
14
  hasMergeConflict,
16
- hasSnapshot,
17
- removeSnapshot,
18
15
  printSuccess,
19
16
  printInfo,
20
17
  printWarning,
@@ -89,7 +86,7 @@ export interface SyncResult {
89
86
  }
90
87
 
91
88
  /**
92
- * 执行 sync 核心操作(检查未提交→自动保存→merge 主分支→清除快照)
89
+ * 执行 sync 核心操作(检查未提交→自动保存→merge 主分支)
93
90
  * 不包含 worktree 解析交互,供 validate 等命令复用
94
91
  * @param {string} targetWorktreePath - 目标 worktree 路径
95
92
  * @param {string} branch - 分支名
@@ -114,13 +111,6 @@ export async function executeSyncForBranch(targetWorktreePath: string, branch: s
114
111
  return { success: false, hasConflict: true };
115
112
  }
116
113
 
117
- // 合并成功后清除该分支的 validate 快照(代码基础已变化,旧快照无效)
118
- const projectName = getProjectName();
119
- if (hasSnapshot(projectName, branch)) {
120
- removeSnapshot(projectName, branch);
121
- logger.info(`已清除分支 ${branch} 的 validate 快照`);
122
- }
123
-
124
114
  printSuccess(MESSAGES.SYNC_SUCCESS(branch, mainBranch));
125
115
 
126
116
  // 合并成功后重建验证分支(基于最新的主分支 HEAD)
@@ -0,0 +1,21 @@
1
+ /** cover-validate 命令专属提示消息 */
2
+ export const COVER_VALIDATE_MESSAGES = {
3
+ /** 当前不在验证分支上 */
4
+ COVER_VALIDATE_NOT_ON_VALIDATE_BRANCH: '当前分支不是验证分支(需以 clawt-validate- 开头)\n 请先通过 clawt validate 切换到验证分支',
5
+ /** 无增量修改 */
6
+ COVER_VALIDATE_NO_CHANGES: '验证分支上没有相对于快照的增量修改,无需覆盖',
7
+ /** 目标 worktree 不存在 */
8
+ COVER_VALIDATE_TARGET_NOT_FOUND: (branch: string) =>
9
+ `未找到分支 ${branch} 对应的 worktree,请确认该 worktree 尚未被移除`,
10
+ /** 无快照,提示先执行 validate */
11
+ COVER_VALIDATE_NO_SNAPSHOT: (branch: string) =>
12
+ `未找到分支 ${branch} 的 validate 快照\n 请先执行 clawt validate -b ${branch} 创建快照`,
13
+ /** patch 应用失败 */
14
+ COVER_VALIDATE_APPLY_FAILED: (branch: string) =>
15
+ `覆盖变更到 worktree ${branch} 失败:patch 应用出错\n 请检查目标 worktree 工作区状态后重试`,
16
+ /** 工作区和暂存区无修改,可能为误操作 */
17
+ COVER_VALIDATE_WORKING_DIR_CLEAN: '当前验证分支的工作区和暂存区没有任何修改,可能为误操作',
18
+ /** 覆盖成功 */
19
+ COVER_VALIDATE_SUCCESS: (branch: string) =>
20
+ `✓ 已将验证分支上的修改覆盖到 worktree => ${branch}`,
21
+ } as const;
@@ -14,6 +14,7 @@ import { PROJECTS_MESSAGES } from './projects.js';
14
14
  import { COMPLETION_MESSAGES } from './completion.js';
15
15
  import { UPDATE_MESSAGES, UPDATE_COMMANDS } from './update.js';
16
16
  import { INIT_MESSAGES } from './init.js';
17
+ import { COVER_VALIDATE_MESSAGES } from './cover-validate.js';
17
18
  import { HOME_MESSAGES } from './home.js';
18
19
  import { PANEL_FOOTER_SHORTCUTS, PANEL_FOOTER_COUNTDOWN, PANEL_OVERFLOW_DOWN_HINT, PANEL_OVERFLOW_UP_HINT, PANEL_SNAPSHOT_SUMMARY, PANEL_NO_WORKTREES as PANEL_NO_WORKTREES_MSG, PANEL_PRESS_ENTER_TO_RETURN, PANEL_NOT_TTY, PANEL_TITLE } from './interactive-panel.js';
19
20
 
@@ -41,5 +42,6 @@ export const MESSAGES = {
41
42
  ...PROJECTS_MESSAGES,
42
43
  ...COMPLETION_MESSAGES,
43
44
  ...INIT_MESSAGES,
45
+ ...COVER_VALIDATE_MESSAGES,
44
46
  ...HOME_MESSAGES,
45
47
  } as const;
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { registerRemoveCommand } from './commands/remove.js';
10
10
  import { registerRunCommand } from './commands/run.js';
11
11
  import { registerResumeCommand } from './commands/resume.js';
12
12
  import { registerValidateCommand } from './commands/validate.js';
13
+ import { registerCoverValidateCommand } from './commands/cover-validate.js';
13
14
  import { registerMergeCommand } from './commands/merge.js';
14
15
  import { registerConfigCommand } from './commands/config.js';
15
16
  import { registerSyncCommand } from './commands/sync.js';
@@ -50,6 +51,7 @@ registerRemoveCommand(program);
50
51
  registerRunCommand(program);
51
52
  registerResumeCommand(program);
52
53
  registerValidateCommand(program);
54
+ registerCoverValidateCommand(program);
53
55
  registerMergeCommand(program);
54
56
  registerConfigCommand(program);
55
57
  registerSyncCommand(program);
@@ -90,22 +90,26 @@ export function readSnapshot(projectName: string, branchName: string): { treeHas
90
90
  /**
91
91
  * 写入 validate 快照内容(自动创建目录)
92
92
  * tree hash 写入 .tree 文件,HEAD commit hash 写入 .head 文件,staged tree hash 写入 .staged 文件
93
+ * 未提供的可选字段(值为 undefined)不会覆盖,保留磁盘上的原值
93
94
  * @param {string} projectName - 项目名
94
95
  * @param {string} branchName - 分支名
95
- * @param {string} treeHash - git tree 对象的 hash
96
- * @param {string} headCommitHash - 快照时主 worktree 的 HEAD commit hash
97
- * @param {string} [stagedTreeHash=''] - validate 结束时暂存区对应的 tree hash
96
+ * @param {string} [treeHash] - git tree 对象的 hash,不传则保留原值
97
+ * @param {string} [headCommitHash] - 快照时主 worktree 的 HEAD commit hash,不传则保留原值
98
+ * @param {string} [stagedTreeHash] - validate 结束时暂存区对应的 tree hash,不传则保留原值
98
99
  */
99
- export function writeSnapshot(projectName: string, branchName: string, treeHash: string, headCommitHash: string, stagedTreeHash = ''): void {
100
- const snapshotPath = getSnapshotPath(projectName, branchName);
101
- const headPath = getSnapshotHeadPath(projectName, branchName);
102
- const stagedPath = getSnapshotStagedPath(projectName, branchName);
100
+ export function writeSnapshot(projectName: string, branchName: string, treeHash?: string, headCommitHash?: string, stagedTreeHash?: string): void {
103
101
  const snapshotDir = join(VALIDATE_SNAPSHOTS_DIR, projectName);
104
102
  ensureDir(snapshotDir);
105
- writeFileSync(snapshotPath, treeHash, 'utf-8');
106
- writeFileSync(headPath, headCommitHash, 'utf-8');
107
- writeFileSync(stagedPath, stagedTreeHash, 'utf-8');
108
- logger.info(`已保存 validate 快照: ${snapshotPath}, ${headPath}, ${stagedPath}`);
103
+ if (treeHash !== undefined) {
104
+ writeFileSync(getSnapshotPath(projectName, branchName), treeHash, 'utf-8');
105
+ }
106
+ if (headCommitHash !== undefined) {
107
+ writeFileSync(getSnapshotHeadPath(projectName, branchName), headCommitHash, 'utf-8');
108
+ }
109
+ if (stagedTreeHash !== undefined) {
110
+ writeFileSync(getSnapshotStagedPath(projectName, branchName), stagedTreeHash, 'utf-8');
111
+ }
112
+ logger.info(`已写入 validate 快照 (project=${projectName}, branch=${branchName})`);
109
113
  }
110
114
 
111
115
  /**
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+
4
+ vi.mock('../../../src/logger/index.js', () => ({
5
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
+ }));
7
+
8
+ vi.mock('../../../src/errors/index.js', () => ({
9
+ ClawtError: class ClawtError extends Error {
10
+ exitCode: number;
11
+ constructor(message: string, exitCode = 1) {
12
+ super(message);
13
+ this.exitCode = exitCode;
14
+ }
15
+ },
16
+ }));
17
+
18
+ vi.mock('../../../src/constants/index.js', () => ({
19
+ MESSAGES: {
20
+ COVER_VALIDATE_NOT_ON_VALIDATE_BRANCH: '当前分支不是验证分支',
21
+ COVER_VALIDATE_NO_CHANGES: '验证分支上没有相对于快照的增量修改,无需覆盖',
22
+ COVER_VALIDATE_TARGET_NOT_FOUND: (branch: string) => `未找到分支 ${branch} 对应的 worktree`,
23
+ COVER_VALIDATE_NO_SNAPSHOT: (branch: string) => `未找到分支 ${branch} 的 validate 快照`,
24
+ COVER_VALIDATE_APPLY_FAILED: (branch: string) => `覆盖变更到 worktree ${branch} 失败`,
25
+ COVER_VALIDATE_SUCCESS: (branch: string) => `✓ 已将验证分支上的修改覆盖到 worktree => ${branch}`,
26
+ COVER_VALIDATE_WORKING_DIR_CLEAN: '当前验证分支的工作区和暂存区没有任何修改,可能为误操作',
27
+ },
28
+ VALIDATE_BRANCH_PREFIX: 'clawt-validate-',
29
+ }));
30
+
31
+ vi.mock('../../../src/utils/index.js', () => ({
32
+ validateMainWorktree: vi.fn(),
33
+ requireProjectConfig: vi.fn(),
34
+ getProjectName: vi.fn().mockReturnValue('test-project'),
35
+ getGitTopLevel: vi.fn().mockReturnValue('/repo'),
36
+ getCurrentBranch: vi.fn().mockReturnValue('clawt-validate-feature'),
37
+ getProjectWorktrees: vi.fn().mockReturnValue([{ path: '/path/feature', branch: 'feature' }]),
38
+ findExactMatch: vi.fn().mockReturnValue({ path: '/path/feature', branch: 'feature' }),
39
+ hasSnapshot: vi.fn().mockReturnValue(true),
40
+ readSnapshot: vi.fn().mockReturnValue({ treeHash: 'snapshot-tree-hash' }),
41
+ writeSnapshot: vi.fn(),
42
+ gitAddAll: vi.fn(),
43
+ gitWriteTree: vi.fn().mockReturnValue('current-tree-hash'),
44
+ gitReadTree: vi.fn(),
45
+ gitDiffTree: vi.fn().mockReturnValue(Buffer.from('fake-patch')),
46
+ gitApplyFromStdin: vi.fn(),
47
+ printSuccess: vi.fn(),
48
+ printInfo: vi.fn(),
49
+ isWorkingDirClean: vi.fn().mockReturnValue(false),
50
+ confirmAction: vi.fn().mockResolvedValue(true),
51
+ }));
52
+
53
+ import { registerCoverValidateCommand, extractTargetBranchName, findTargetWorktreePath, computeIncrementalPatch } from '../../../src/commands/cover-validate.js';
54
+ import {
55
+ getCurrentBranch,
56
+ getProjectWorktrees,
57
+ findExactMatch,
58
+ hasSnapshot,
59
+ readSnapshot,
60
+ writeSnapshot,
61
+ gitAddAll,
62
+ gitWriteTree,
63
+ gitReadTree,
64
+ gitDiffTree,
65
+ gitApplyFromStdin,
66
+ printSuccess,
67
+ printInfo,
68
+ isWorkingDirClean,
69
+ confirmAction,
70
+ } from '../../../src/utils/index.js';
71
+
72
+ const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
73
+ const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
74
+ const mockedFindExactMatch = vi.mocked(findExactMatch);
75
+ const mockedHasSnapshot = vi.mocked(hasSnapshot);
76
+ const mockedReadSnapshot = vi.mocked(readSnapshot);
77
+ const mockedWriteSnapshot = vi.mocked(writeSnapshot);
78
+ const mockedGitAddAll = vi.mocked(gitAddAll);
79
+ const mockedGitWriteTree = vi.mocked(gitWriteTree);
80
+ const mockedGitReadTree = vi.mocked(gitReadTree);
81
+ const mockedGitDiffTree = vi.mocked(gitDiffTree);
82
+ const mockedGitApplyFromStdin = vi.mocked(gitApplyFromStdin);
83
+ const mockedPrintSuccess = vi.mocked(printSuccess);
84
+ const mockedPrintInfo = vi.mocked(printInfo);
85
+ const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
86
+ const mockedConfirmAction = vi.mocked(confirmAction);
87
+
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ // 恢复默认 mock 值
91
+ mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
92
+ mockedGetProjectWorktrees.mockReturnValue([{ path: '/path/feature', branch: 'feature' }]);
93
+ mockedFindExactMatch.mockReturnValue({ path: '/path/feature', branch: 'feature' });
94
+ mockedHasSnapshot.mockReturnValue(true);
95
+ mockedReadSnapshot.mockReturnValue({ treeHash: 'snapshot-tree-hash' });
96
+ mockedIsWorkingDirClean.mockReturnValue(false);
97
+ mockedConfirmAction.mockResolvedValue(true);
98
+ mockedGitWriteTree.mockReturnValue('current-tree-hash');
99
+ mockedGitDiffTree.mockReturnValue(Buffer.from('fake-patch'));
100
+ });
101
+
102
+ describe('registerCoverValidateCommand', () => {
103
+ it('注册 cover 命令', () => {
104
+ const program = new Command();
105
+ registerCoverValidateCommand(program);
106
+ const cmd = program.commands.find((c) => c.name() === 'cover');
107
+ expect(cmd).toBeDefined();
108
+ });
109
+ });
110
+
111
+ describe('extractTargetBranchName', () => {
112
+ it('从验证分支名中提取目标分支名', () => {
113
+ expect(extractTargetBranchName('clawt-validate-feature')).toBe('feature');
114
+ expect(extractTargetBranchName('clawt-validate-fix/bug-123')).toBe('fix/bug-123');
115
+ });
116
+ });
117
+
118
+ describe('findTargetWorktreePath', () => {
119
+ it('找到目标 worktree 时返回路径', () => {
120
+ const path = findTargetWorktreePath('feature');
121
+ expect(path).toBe('/path/feature');
122
+ });
123
+
124
+ it('未找到目标 worktree 时抛出错误', () => {
125
+ mockedFindExactMatch.mockReturnValue(undefined);
126
+ expect(() => findTargetWorktreePath('nonexistent')).toThrow();
127
+ });
128
+ });
129
+
130
+ describe('computeIncrementalPatch', () => {
131
+ it('有增量变更时返回 patch 和 currentTreeHash', () => {
132
+ mockedGitWriteTree
133
+ .mockReturnValueOnce('saved-index-tree') // 保存暂存区
134
+ .mockReturnValueOnce('new-tree-hash'); // git add . 后的 tree
135
+
136
+ const result = computeIncrementalPatch('snapshot-tree-hash', '/repo');
137
+ expect(result).not.toBeNull();
138
+ expect(result!.currentTreeHash).toBe('new-tree-hash');
139
+ expect(mockedGitAddAll).toHaveBeenCalledWith('/repo');
140
+ expect(mockedGitReadTree).toHaveBeenCalledWith('saved-index-tree', '/repo');
141
+ expect(mockedGitDiffTree).toHaveBeenCalledWith('snapshot-tree-hash', 'new-tree-hash', '/repo');
142
+ });
143
+
144
+ it('无增量变更时返回 null', () => {
145
+ mockedGitWriteTree
146
+ .mockReturnValueOnce('saved-index-tree')
147
+ .mockReturnValueOnce('snapshot-tree-hash'); // 与快照相同
148
+
149
+ const result = computeIncrementalPatch('snapshot-tree-hash', '/repo');
150
+ expect(result).toBeNull();
151
+ });
152
+ });
153
+
154
+ describe('handleCoverValidate - 工作区干净检查', () => {
155
+ /** 辅助函数:执行 cover 命令 */
156
+ async function runCover(): Promise<void> {
157
+ const program = new Command();
158
+ program.exitOverride();
159
+ registerCoverValidateCommand(program);
160
+ await program.parseAsync(['cover'], { from: 'user' });
161
+ }
162
+
163
+ it('工作区干净且用户取消时不执行 patch', async () => {
164
+ mockedIsWorkingDirClean.mockReturnValue(true);
165
+ mockedConfirmAction.mockResolvedValue(false);
166
+
167
+ await runCover();
168
+
169
+ expect(mockedPrintInfo).toHaveBeenCalled();
170
+ expect(mockedConfirmAction).toHaveBeenCalledWith('是否继续执行覆盖?');
171
+ // 用户取消后不应继续执行后续逻辑
172
+ expect(mockedGitAddAll).not.toHaveBeenCalled();
173
+ expect(mockedGitApplyFromStdin).not.toHaveBeenCalled();
174
+ expect(mockedPrintSuccess).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('工作区干净且用户确认继续时正常执行', async () => {
178
+ mockedIsWorkingDirClean.mockReturnValue(true);
179
+ mockedConfirmAction.mockResolvedValue(true);
180
+ // computeIncrementalPatch 返回有 patch 的结果
181
+ mockedGitWriteTree
182
+ .mockReturnValueOnce('saved-index-tree')
183
+ .mockReturnValueOnce('new-tree-hash');
184
+
185
+ await runCover();
186
+
187
+ expect(mockedConfirmAction).toHaveBeenCalledWith('是否继续执行覆盖?');
188
+ expect(mockedGitApplyFromStdin).toHaveBeenCalled();
189
+ expect(mockedPrintSuccess).toHaveBeenCalled();
190
+ });
191
+
192
+ it('工作区不干净时跳过确认直接执行', async () => {
193
+ mockedIsWorkingDirClean.mockReturnValue(false);
194
+ mockedGitWriteTree
195
+ .mockReturnValueOnce('saved-index-tree')
196
+ .mockReturnValueOnce('new-tree-hash');
197
+
198
+ await runCover();
199
+
200
+ expect(mockedConfirmAction).not.toHaveBeenCalled();
201
+ expect(mockedGitApplyFromStdin).toHaveBeenCalled();
202
+ expect(mockedPrintSuccess).toHaveBeenCalled();
203
+ });
204
+ });
@@ -33,7 +33,6 @@ vi.mock('../../../src/constants/index.js', () => ({
33
33
  vi.mock('../../../src/utils/index.js', () => ({
34
34
  validateMainWorktree: vi.fn(),
35
35
  getGitTopLevel: vi.fn(),
36
- getProjectName: vi.fn(),
37
36
  getProjectWorktrees: vi.fn(),
38
37
  isWorkingDirClean: vi.fn(),
39
38
  gitAddAll: vi.fn(),
@@ -41,8 +40,6 @@ vi.mock('../../../src/utils/index.js', () => ({
41
40
  gitMerge: vi.fn(),
42
41
  hasMergeConflict: vi.fn(),
43
42
  getCurrentBranch: vi.fn(),
44
- hasSnapshot: vi.fn(),
45
- removeSnapshot: vi.fn(),
46
43
  printSuccess: vi.fn(),
47
44
  printInfo: vi.fn(),
48
45
  printWarning: vi.fn(),
@@ -56,7 +53,6 @@ vi.mock('../../../src/utils/index.js', () => ({
56
53
  import { registerSyncCommand } from '../../../src/commands/sync.js';
57
54
  import {
58
55
  getGitTopLevel,
59
- getProjectName,
60
56
  getProjectWorktrees,
61
57
  isWorkingDirClean,
62
58
  gitAddAll,
@@ -64,15 +60,12 @@ import {
64
60
  gitMerge,
65
61
  hasMergeConflict,
66
62
  getCurrentBranch,
67
- hasSnapshot,
68
- removeSnapshot,
69
63
  printSuccess,
70
64
  printWarning,
71
65
  resolveTargetWorktree,
72
66
  } from '../../../src/utils/index.js';
73
67
 
74
68
  const mockedGetGitTopLevel = vi.mocked(getGitTopLevel);
75
- const mockedGetProjectName = vi.mocked(getProjectName);
76
69
  const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
77
70
  const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
78
71
  const mockedGitAddAll = vi.mocked(gitAddAll);
@@ -80,15 +73,12 @@ const mockedGitCommit = vi.mocked(gitCommit);
80
73
  const mockedGitMerge = vi.mocked(gitMerge);
81
74
  const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
82
75
  const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
83
- const mockedHasSnapshot = vi.mocked(hasSnapshot);
84
- const mockedRemoveSnapshot = vi.mocked(removeSnapshot);
85
76
  const mockedPrintSuccess = vi.mocked(printSuccess);
86
77
  const mockedPrintWarning = vi.mocked(printWarning);
87
78
  const mockedResolveTargetWorktree = vi.mocked(resolveTargetWorktree);
88
79
 
89
80
  beforeEach(() => {
90
81
  mockedGetGitTopLevel.mockReturnValue('/repo');
91
- mockedGetProjectName.mockReturnValue('test-project');
92
82
  mockedGetCurrentBranch.mockReturnValue('main');
93
83
  mockedGetProjectWorktrees.mockReset();
94
84
  mockedIsWorkingDirClean.mockReset();
@@ -96,8 +86,6 @@ beforeEach(() => {
96
86
  mockedGitCommit.mockReset();
97
87
  mockedGitMerge.mockReset();
98
88
  mockedHasMergeConflict.mockReset();
99
- mockedHasSnapshot.mockReset();
100
- mockedRemoveSnapshot.mockReset();
101
89
  mockedPrintSuccess.mockReset();
102
90
  mockedPrintWarning.mockReset();
103
91
  mockedResolveTargetWorktree.mockReset();
@@ -118,7 +106,6 @@ describe('handleSync', () => {
118
106
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
119
107
  mockedResolveTargetWorktree.mockResolvedValue(worktree);
120
108
  mockedIsWorkingDirClean.mockReturnValue(true);
121
- mockedHasSnapshot.mockReturnValue(false);
122
109
 
123
110
  const program = new Command();
124
111
  program.exitOverride();
@@ -135,7 +122,6 @@ describe('handleSync', () => {
135
122
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
136
123
  mockedResolveTargetWorktree.mockResolvedValue(worktree);
137
124
  mockedIsWorkingDirClean.mockReturnValue(false);
138
- mockedHasSnapshot.mockReturnValue(false);
139
125
 
140
126
  const program = new Command();
141
127
  program.exitOverride();
@@ -164,36 +150,6 @@ describe('handleSync', () => {
164
150
  expect(mockedPrintSuccess).not.toHaveBeenCalled();
165
151
  });
166
152
 
167
- it('合并成功后清理 validate 快照', async () => {
168
- const worktree = { path: '/path/feature', branch: 'feature' };
169
- mockedGetProjectWorktrees.mockReturnValue([worktree]);
170
- mockedResolveTargetWorktree.mockResolvedValue(worktree);
171
- mockedIsWorkingDirClean.mockReturnValue(true);
172
- mockedHasSnapshot.mockReturnValue(true);
173
-
174
- const program = new Command();
175
- program.exitOverride();
176
- registerSyncCommand(program);
177
- await program.parseAsync(['sync', '-b', 'feature'], { from: 'user' });
178
-
179
- expect(mockedRemoveSnapshot).toHaveBeenCalledWith('test-project', 'feature');
180
- });
181
-
182
- it('合并成功且无快照时不调用 removeSnapshot', async () => {
183
- const worktree = { path: '/path/feature', branch: 'feature' };
184
- mockedGetProjectWorktrees.mockReturnValue([worktree]);
185
- mockedResolveTargetWorktree.mockResolvedValue(worktree);
186
- mockedIsWorkingDirClean.mockReturnValue(true);
187
- mockedHasSnapshot.mockReturnValue(false);
188
-
189
- const program = new Command();
190
- program.exitOverride();
191
- registerSyncCommand(program);
192
- await program.parseAsync(['sync', '-b', 'feature'], { from: 'user' });
193
-
194
- expect(mockedRemoveSnapshot).not.toHaveBeenCalled();
195
- });
196
-
197
153
  it('合并失败(非冲突错误)时向上抛出', async () => {
198
154
  const worktree = { path: '/path/feature', branch: 'feature' };
199
155
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
@@ -107,7 +107,7 @@ describe('readSnapshotTreeHash', () => {
107
107
 
108
108
  describe('writeSnapshot', () => {
109
109
  it('正确写入三个文件', () => {
110
- writeSnapshot('proj', 'branch', 'tree123', 'head456');
110
+ writeSnapshot('proj', 'branch', 'tree123', 'head456', 'staged789');
111
111
  expect(mockedEnsureDir).toHaveBeenCalledWith('/tmp/test-snapshots/proj');
112
112
  expect(mockedWriteFileSync).toHaveBeenCalledTimes(3);
113
113
  expect(mockedWriteFileSync).toHaveBeenCalledWith(
@@ -122,10 +122,26 @@ describe('writeSnapshot', () => {
122
122
  );
123
123
  expect(mockedWriteFileSync).toHaveBeenCalledWith(
124
124
  '/tmp/test-snapshots/proj/branch.staged',
125
- '',
125
+ 'staged789',
126
126
  'utf-8',
127
127
  );
128
128
  });
129
+
130
+ it('未传 headCommitHash 和 stagedTreeHash 时只写入 treeHash', () => {
131
+ writeSnapshot('proj', 'branch', 'tree123');
132
+ expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
133
+ expect(mockedWriteFileSync).toHaveBeenCalledWith(
134
+ '/tmp/test-snapshots/proj/branch.tree',
135
+ 'tree123',
136
+ 'utf-8',
137
+ );
138
+ });
139
+
140
+ it('所有字段都未传时不写入任何文件', () => {
141
+ writeSnapshot('proj', 'branch');
142
+ expect(mockedEnsureDir).toHaveBeenCalledWith('/tmp/test-snapshots/proj');
143
+ expect(mockedWriteFileSync).not.toHaveBeenCalled();
144
+ });
129
145
  });
130
146
 
131
147
  describe('removeSnapshot', () => {