clawt 2.5.1 → 2.7.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/docs/spec.md CHANGED
@@ -143,6 +143,7 @@ git show-ref --verify refs/heads/<branchName> 2>/dev/null
143
143
  ├── validate-snapshots/ # validate 快照目录
144
144
  │ └── <project-name>/ # 以项目名分组
145
145
  │ ├── <branchName>.tree # 每个分支一个 tree hash 快照文件(存储 git tree 对象的 hash)
146
+ │ ├── <branchName>.head # 每个分支一个 HEAD commit hash 快照文件(存储快照时主 worktree 的 HEAD commit hash)
146
147
  │ └── ...
147
148
  └── worktrees/ # 所有 worktree 的统一存放目录
148
149
  └── <project-name>/ # 以项目名分组
@@ -345,14 +346,18 @@ Claude Code CLI 以 `--output-format json` 运行时,退出后会在 stdout
345
346
  **命令:**
346
347
 
347
348
  ```bash
349
+ # 指定分支名(支持模糊匹配)
348
350
  clawt validate -b <branchName> [--clean]
351
+
352
+ # 不指定分支名(列出所有分支供选择)
353
+ clawt validate [--clean]
349
354
  ```
350
355
 
351
356
  **参数:**
352
357
 
353
358
  | 参数 | 必填 | 说明 |
354
359
  | --------- | ---- | ------------------------------------------------------------------------ |
355
- | `-b` | | 要验证的 worktree 分支名(例如 `feature-scheme-1`) |
360
+ | `-b` | | 要验证的 worktree 分支名(支持模糊匹配,不传则列出所有分支供选择) |
356
361
  | `--clean` | 否 | 清理 validate 状态(重置主 worktree 并删除快照) |
357
362
 
358
363
  > **限制:** 单次只能验证一个分支,不支持批量验证。
@@ -363,7 +368,7 @@ Git worktree 不会包含 `node_modules`、`.venv` 等依赖文件,每次安
363
368
 
364
369
  **快照机制:**
365
370
 
366
- validate 命令引入了**快照(snapshot)机制**来支持增量对比。每次 validate 执行成功后,会将当前全量变更通过 `git write-tree` 保存为 git tree 对象,并将 tree hash 记录到文件(`~/.clawt/validate-snapshots/<project>/<branchName>.tree`)。当再次执行 validate 时,通过 `git read-tree` 将上次快照的 tree 对象载入暂存区、最新变更保留在工作目录,用户可通过 `git diff` 直接查看两次 validate 之间的增量差异。由于 tree 对象存储在 git 对象库中,不依赖主分支 HEAD,无需一致性校验。
371
+ validate 命令引入了**快照(snapshot)机制**来支持增量对比。每次 validate 执行成功后,会将当前全量变更通过 `git write-tree` 保存为 git tree 对象,并将 tree hash 记录到文件(`~/.clawt/validate-snapshots/<project>/<branchName>.tree`),同时将主 worktree 的 HEAD commit hash 记录到文件(`~/.clawt/validate-snapshots/<project>/<branchName>.head`),用于增量 validate 时对齐基准。当再次执行 validate 时,如果主分支 HEAD 未变化,通过 `git read-tree` 将上次快照的 tree 对象载入暂存区;如果主分支 HEAD 已变化(如合并了其他分支),则将旧变更 patch(旧 tree 相对于旧 HEAD 的差异)重放到当前 HEAD 暂存区上,避免新旧 tree 基准不同导致 diff 混入 HEAD 变化的内容。最终用户可通过 `git diff` 查看两次 validate 之间的增量差异。
367
372
 
368
373
  **运行流程:**
369
374
 
@@ -372,13 +377,30 @@ validate 命令引入了**快照(snapshot)机制**来支持增量对比。
372
377
  当指定 `--clean` 选项时,执行清理逻辑后直接返回,不进入常规 validate 流程:
373
378
 
374
379
  1. **主 worktree 校验** (2.1)
375
- 2. 如果配置项 `confirmDestructiveOps` `true`,提示确认(显示即将执行的危险指令和操作后果),用户取消则退出
376
- 3. 如果主 worktree 有未提交更改,执行 `git reset --hard` + `git clean -fd` 清空
377
- 4. 删除对应分支的快照文件
378
- 5. 输出清理成功提示
380
+ 2. **解析目标 worktree**:通过模糊匹配解析目标分支(匹配策略同下文常规 validate 流程中的描述)
381
+ 3. 如果配置项 `confirmDestructiveOps` `true`,提示确认(显示即将执行的危险指令和操作后果),用户取消则退出
382
+ 4. 如果主 worktree 有未提交更改,执行 `git reset --hard` + `git clean -fd` 清空
383
+ 5. 删除对应分支的快照文件
384
+ 6. 输出清理成功提示
379
385
 
380
386
  #### 首次 validate(无历史快照)
381
387
 
388
+ ##### 步骤 0:解析目标 worktree
389
+
390
+ 根据 `-b` 参数解析目标 worktree,匹配策略如下:
391
+
392
+ - **未传 `-b` 参数**:
393
+ - 获取当前项目所有 worktree
394
+ - 无可用 worktree → 报错退出
395
+ - 仅 1 个 worktree → 直接使用,无需选择
396
+ - 多个 worktree → 通过交互式列表(Enquirer.Select)让用户选择
397
+ - **传了 `-b` 参数**:
398
+ 1. **精确匹配优先**:在 worktree 列表中查找分支名完全相同的 worktree,找到则直接使用
399
+ 2. **模糊匹配**(子串匹配,大小写不敏感):
400
+ - 唯一匹配 → 直接使用
401
+ - 多个匹配 → 通过交互式列表让用户从匹配结果中选择
402
+ 3. **无匹配** → 报错退出,并列出所有可用分支名
403
+
382
404
  ##### 步骤 1:检测主 worktree 工作区状态
383
405
 
384
406
  执行 `git status --porcelain`,判断主 worktree 是否有未提交的更改。
@@ -444,11 +466,12 @@ git restore --staged .
444
466
 
445
467
  ##### 步骤 4:保存快照为 git tree 对象
446
468
 
447
- 将主 worktree 工作目录的全量变更保存为 git tree 对象:
469
+ 将主 worktree 工作目录的全量变更保存为 git tree 对象,同时记录当前 HEAD commit hash:
448
470
 
449
471
  ```bash
450
472
  git add .
451
473
  git write-tree # → 返回 tree hash,写入 ~/.clawt/validate-snapshots/<project>/<branchName>.tree
474
+ git rev-parse HEAD # → 返回 HEAD commit hash,写入 ~/.clawt/validate-snapshots/<project>/<branchName>.head
452
475
  git restore --staged .
453
476
  ```
454
477
 
@@ -465,9 +488,9 @@ git restore --staged .
465
488
 
466
489
  当 `~/.clawt/validate-snapshots/<project>/<branchName>.tree` 存在时,自动进入增量模式:
467
490
 
468
- ##### 步骤 1:读取旧 tree hash
491
+ ##### 步骤 1:读取旧快照
469
492
 
470
- 在清空主 worktree 之前,读取上次保存的快照 tree hash。
493
+ 在清空主 worktree 之前,读取上次保存的快照 tree hash 及当时的 HEAD commit hash
471
494
 
472
495
  ##### 步骤 2:确保主 worktree 干净
473
496
 
@@ -479,9 +502,15 @@ git restore --staged .
479
502
 
480
503
  ##### 步骤 4:保存最新快照为 git tree 对象
481
504
 
482
- 将最新全量变更保存为新的 tree 对象(覆盖旧快照,流程同首次 validate 的步骤 4)。
505
+ 将最新全量变更保存为新的 tree 对象(覆盖旧快照),同时记录当前 HEAD commit hash(流程同首次 validate 的步骤 4)。
506
+
507
+ ##### 步骤 5:将旧变更状态载入暂存区
508
+
509
+ 根据主分支 HEAD 是否发生变化,选择不同的策略将旧变更载入暂存区:
510
+
511
+ **情况 A:HEAD 未变化(或旧版快照无 HEAD 信息)**
483
512
 
484
- ##### 步骤 5:将旧 tree 对象载入暂存区
513
+ 直接通过 `git read-tree` 将旧 tree 对象载入暂存区:
485
514
 
486
515
  ```bash
487
516
  git read-tree <旧 tree hash>
@@ -490,6 +519,28 @@ git read-tree <旧 tree hash>
490
519
  - **读取成功** → 结果:暂存区=上次快照,工作目录=最新全量变更(用户可通过 `git diff` 查看增量差异)
491
520
  - **读取失败**(tree 对象可能被 git gc 回收)→ 降级为全量模式,暂存区保持为空,等同于首次 validate 的结果
492
521
 
522
+ **情况 B:HEAD 发生了变化(如主分支合并了其他分支)**
523
+
524
+ 此时旧 tree 对象基于旧 HEAD,直接 read-tree 会导致 diff 混入 HEAD 变化的内容。需要将旧变更 patch(旧 tree 相对于旧 HEAD 的差异)重放到当前 HEAD 暂存区上:
525
+
526
+ ```bash
527
+ # 获取旧 HEAD 对应的 tree hash
528
+ git rev-parse <旧 HEAD commit hash>^{tree} # → 旧 HEAD tree hash
529
+
530
+ # 提取旧变更 patch(旧 HEAD tree → 旧快照 tree 的差异)
531
+ git diff-tree -p --binary <旧 HEAD tree hash> <旧快照 tree hash>
532
+
533
+ # 检测 patch 能否无冲突地应用到暂存区
534
+ git apply --cached --check < patch
535
+
536
+ # 无冲突:apply --cached 到当前 HEAD 暂存区
537
+ git apply --cached < patch
538
+ ```
539
+
540
+ - **patch 为空**(旧变更为空)→ 暂存区保持干净
541
+ - **无冲突** → apply --cached 到当前 HEAD 暂存区,结果与情况 A 一致
542
+ - **有冲突** → 降级为全量模式(暂存区保持为空),等同于首次 validate 的结果
543
+
493
544
  ##### 步骤 6:输出成功提示
494
545
 
495
546
  ```
@@ -670,7 +721,7 @@ clawt merge -b <branchName> [-m <commitMessage>]
670
721
  - 输出清理成功提示:`✓ 已清理 worktree 和分支: <branchName>`
671
722
 
672
723
  10. **清理 validate 快照**
673
- - merge 成功后,如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.tree`),自动删除该快照文件(merge 成功后快照已无意义)
724
+ - merge 成功后,如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.tree` 和 `<branchName>.head`),自动删除这些快照文件(merge 成功后快照已无意义)
674
725
 
675
726
  > **注意:** 清理确认和清理操作均在 merge 成功后执行。只有 merge 成功才会询问用户是否清理 worktree 和分支,避免 merge 冲突时用户被提前询问造成困惑。
676
727
 
@@ -936,7 +987,7 @@ clawt sync -b <branchName>
936
987
  clawt validate -b <branch> 验证变更
937
988
  ```
938
989
  - **无冲突** → 继续
939
- 7. **清除 validate 快照**:合并成功后,如果该分支存在 validate 快照(`.tree` 文件),自动删除(代码基础已变化,旧快照无效)
990
+ 7. **清除 validate 快照**:合并成功后,如果该分支存在 validate 快照(`.tree` 和 `.head` 文件),自动删除(代码基础已变化,旧快照无效)
940
991
  8. **输出成功提示**:
941
992
  ```
942
993
  ✓ 已将 <mainBranch> 的最新代码同步到 <branchName>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,15 +1,23 @@
1
1
  import type { Command } from 'commander';
2
- import Enquirer from 'enquirer';
3
2
  import { logger } from '../logger/index.js';
4
- import { ClawtError } from '../errors/index.js';
5
3
  import { MESSAGES } from '../constants/index.js';
6
- import type { ResumeOptions, WorktreeInfo } from '../types/index.js';
4
+ import type { ResumeOptions } from '../types/index.js';
7
5
  import {
8
6
  validateMainWorktree,
9
7
  validateClaudeCodeInstalled,
10
8
  getProjectWorktrees,
11
9
  launchInteractiveClaude,
10
+ resolveTargetWorktree,
12
11
  } from '../utils/index.js';
12
+ import type { WorktreeResolveMessages } from '../utils/index.js';
13
+
14
+ /** resume 命令的分支解析消息配置 */
15
+ const RESUME_RESOLVE_MESSAGES: WorktreeResolveMessages = {
16
+ noWorktrees: MESSAGES.RESUME_NO_WORKTREES,
17
+ selectBranch: MESSAGES.RESUME_SELECT_BRANCH,
18
+ multipleMatches: MESSAGES.RESUME_MULTIPLE_MATCHES,
19
+ noMatch: MESSAGES.RESUME_NO_MATCH,
20
+ };
13
21
 
14
22
  /**
15
23
  * 注册 resume 命令:在已有 worktree 中恢复 Claude Code 会话
@@ -25,95 +33,6 @@ export function registerResumeCommand(program: Command): void {
25
33
  });
26
34
  }
27
35
 
28
- /**
29
- * 在 worktree 列表中精确匹配分支名
30
- * @param {WorktreeInfo[]} worktrees - worktree 列表
31
- * @param {string} branchName - 目标分支名
32
- * @returns {WorktreeInfo | undefined} 匹配的 worktree,未找到返回 undefined
33
- */
34
- function findExactMatch(worktrees: WorktreeInfo[], branchName: string): WorktreeInfo | undefined {
35
- return worktrees.find((wt) => wt.branch === branchName);
36
- }
37
-
38
- /**
39
- * 在 worktree 列表中进行模糊匹配(子串匹配,大小写不敏感)
40
- * @param {WorktreeInfo[]} worktrees - worktree 列表
41
- * @param {string} keyword - 匹配关键词
42
- * @returns {WorktreeInfo[]} 匹配到的 worktree 列表
43
- */
44
- function findFuzzyMatches(worktrees: WorktreeInfo[], keyword: string): WorktreeInfo[] {
45
- const lowerKeyword = keyword.toLowerCase();
46
- return worktrees.filter((wt) => wt.branch.toLowerCase().includes(lowerKeyword));
47
- }
48
-
49
- /**
50
- * 通过交互式列表让用户从 worktree 列表中选择一个分支
51
- * @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
52
- * @param {string} message - 选择提示信息
53
- * @returns {Promise<WorktreeInfo>} 用户选择的 worktree
54
- */
55
- async function promptSelectBranch(worktrees: WorktreeInfo[], message: string): Promise<WorktreeInfo> {
56
- // @ts-expect-error enquirer 类型声明未导出 Select 类,但运行时存在
57
- const selectedBranch: string = await new Enquirer.Select({
58
- message,
59
- choices: worktrees.map((wt) => ({
60
- name: wt.branch,
61
- message: wt.branch,
62
- })),
63
- }).run();
64
-
65
- return worktrees.find((wt) => wt.branch === selectedBranch)!;
66
- }
67
-
68
- /**
69
- * 根据用户输入解析目标 worktree
70
- * 匹配策略:精确匹配 → 模糊匹配(唯一直接使用,多个交互选择) → 无匹配报错
71
- * 不传分支名时列出所有可用分支供选择
72
- * @param {string} [branchName] - 用户输入的分支名(可选)
73
- * @returns {Promise<WorktreeInfo>} 解析后的目标 worktree
74
- * @throws {ClawtError} 无可用 worktree 或无匹配结果时抛出
75
- */
76
- async function resolveTargetWorktree(branchName?: string): Promise<WorktreeInfo> {
77
- const worktrees = getProjectWorktrees();
78
-
79
- // 无可用 worktree,直接报错
80
- if (worktrees.length === 0) {
81
- throw new ClawtError(MESSAGES.RESUME_NO_WORKTREES);
82
- }
83
-
84
- // 未传 -b 参数:列出所有分支供选择
85
- if (!branchName) {
86
- // 只有一个 worktree 时直接使用,无需选择
87
- if (worktrees.length === 1) {
88
- return worktrees[0];
89
- }
90
- return promptSelectBranch(worktrees, MESSAGES.RESUME_SELECT_BRANCH);
91
- }
92
-
93
- // 1. 精确匹配优先
94
- const exactMatch = findExactMatch(worktrees, branchName);
95
- if (exactMatch) {
96
- return exactMatch;
97
- }
98
-
99
- // 2. 模糊匹配
100
- const fuzzyMatches = findFuzzyMatches(worktrees, branchName);
101
-
102
- // 2a. 唯一匹配,直接使用
103
- if (fuzzyMatches.length === 1) {
104
- return fuzzyMatches[0];
105
- }
106
-
107
- // 2b. 多个匹配,交互选择
108
- if (fuzzyMatches.length > 1) {
109
- return promptSelectBranch(fuzzyMatches, MESSAGES.RESUME_MULTIPLE_MATCHES(branchName));
110
- }
111
-
112
- // 3. 无匹配,抛出错误并列出所有可用分支
113
- const allBranches = worktrees.map((wt) => wt.branch);
114
- throw new ClawtError(MESSAGES.RESUME_NO_MATCH(branchName, allBranches));
115
- }
116
-
117
36
  /**
118
37
  * 执行 resume 命令的核心逻辑
119
38
  * 解析目标 worktree 并恢复 Claude Code 会话
@@ -126,7 +45,8 @@ async function handleResume(options: ResumeOptions): Promise<void> {
126
45
  logger.info(`resume 命令执行,分支: ${options.branch ?? '(未指定)'}`);
127
46
 
128
47
  // 解析目标 worktree(精确匹配 / 模糊匹配 / 交互选择)
129
- const worktree = await resolveTargetWorktree(options.branch);
48
+ const worktrees = getProjectWorktrees();
49
+ const worktree = await resolveTargetWorktree(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
130
50
 
131
51
  // 启动 Claude Code 交互式界面
132
52
  launchInteractiveClaude(worktree);
@@ -1,6 +1,4 @@
1
1
  import type { Command } from 'commander';
2
- import { join } from 'node:path';
3
- import { existsSync } from 'node:fs';
4
2
  import Enquirer from 'enquirer';
5
3
  import { logger } from '../logger/index.js';
6
4
  import { ClawtError } from '../errors/index.js';
@@ -10,7 +8,7 @@ import {
10
8
  validateMainWorktree,
11
9
  getProjectName,
12
10
  getGitTopLevel,
13
- getProjectWorktreeDir,
11
+ getProjectWorktrees,
14
12
  getConfigValue,
15
13
  isWorkingDirClean,
16
14
  gitAddAll,
@@ -21,19 +19,34 @@ import {
21
19
  gitCleanForce,
22
20
  gitDiffBinaryAgainstBranch,
23
21
  gitApplyFromStdin,
22
+ gitApplyCachedFromStdin,
24
23
  gitResetSoft,
25
24
  gitWriteTree,
26
25
  gitReadTree,
26
+ getHeadCommitHash,
27
+ getCommitTreeHash,
28
+ gitDiffTree,
29
+ gitApplyCachedCheck,
27
30
  hasLocalCommits,
28
31
  hasSnapshot,
29
- readSnapshotTreeHash,
32
+ readSnapshot,
30
33
  writeSnapshot,
31
34
  removeSnapshot,
32
35
  confirmDestructiveAction,
33
36
  printSuccess,
34
37
  printWarning,
35
38
  printInfo,
39
+ resolveTargetWorktree,
36
40
  } from '../utils/index.js';
41
+ import type { WorktreeResolveMessages } from '../utils/index.js';
42
+
43
+ /** validate 命令的分支解析消息配置 */
44
+ const VALIDATE_RESOLVE_MESSAGES: WorktreeResolveMessages = {
45
+ noWorktrees: MESSAGES.VALIDATE_NO_WORKTREES,
46
+ selectBranch: MESSAGES.VALIDATE_SELECT_BRANCH,
47
+ multipleMatches: MESSAGES.VALIDATE_MULTIPLE_MATCHES,
48
+ noMatch: MESSAGES.VALIDATE_NO_MATCH,
49
+ };
37
50
 
38
51
  /**
39
52
  * 注册 validate 命令:在主 worktree 验证其他分支的变更
@@ -43,7 +56,7 @@ export function registerValidateCommand(program: Command): void {
43
56
  program
44
57
  .command('validate')
45
58
  .description('在主 worktree 验证某个 worktree 分支的变更')
46
- .requiredOption('-b, --branch <branchName>', '要验证的分支名')
59
+ .option('-b, --branch <branchName>', '要验证的分支名(支持模糊匹配,不传则列出所有分支)')
47
60
  .option('--clean', '清理 validate 状态(重置主 worktree 并删除快照)')
48
61
  .action(async (options: ValidateOptions) => {
49
62
  await handleValidate(options);
@@ -148,6 +161,7 @@ function migrateChangesViaPatch(targetWorktreePath: string, mainWorktreePath: st
148
161
  /**
149
162
  * 保存当前主 worktree 工作目录变更为 git tree 对象快照
150
163
  * 操作序列:git add . → git write-tree → git restore --staged .
164
+ * 同时保存当前 HEAD commit hash,用于增量 validate 时对齐基准
151
165
  * @param {string} mainWorktreePath - 主 worktree 路径
152
166
  * @param {string} projectName - 项目名
153
167
  * @param {string} branchName - 分支名
@@ -157,7 +171,8 @@ function saveCurrentSnapshotTree(mainWorktreePath: string, projectName: string,
157
171
  gitAddAll(mainWorktreePath);
158
172
  const treeHash = gitWriteTree(mainWorktreePath);
159
173
  gitRestoreStaged(mainWorktreePath);
160
- writeSnapshot(projectName, branchName, treeHash);
174
+ const headCommitHash = getHeadCommitHash(mainWorktreePath);
175
+ writeSnapshot(projectName, branchName, treeHash, headCommitHash);
161
176
  return treeHash;
162
177
  }
163
178
 
@@ -171,13 +186,18 @@ async function handleValidateClean(options: ValidateOptions): Promise<void> {
171
186
  const projectName = getProjectName();
172
187
  const mainWorktreePath = getGitTopLevel();
173
188
 
174
- logger.info(`validate --clean 执行,分支: ${options.branch}`);
189
+ // 通过模糊匹配解析目标 worktree
190
+ const worktrees = getProjectWorktrees();
191
+ const worktree = await resolveTargetWorktree(worktrees, VALIDATE_RESOLVE_MESSAGES, options.branch);
192
+ const branchName = worktree.branch;
193
+
194
+ logger.info(`validate --clean 执行,分支: ${branchName}`);
175
195
 
176
196
  // 根据配置决定是否需要确认
177
197
  if (getConfigValue('confirmDestructiveOps')) {
178
198
  const confirmed = await confirmDestructiveAction(
179
199
  'git reset --hard + git clean -fd',
180
- `重置主 worktree 并删除分支 ${options.branch} 的 validate 快照`,
200
+ `重置主 worktree 并删除分支 ${branchName} 的 validate 快照`,
181
201
  );
182
202
  if (!confirmed) {
183
203
  printInfo(MESSAGES.DESTRUCTIVE_OP_CANCELLED);
@@ -192,9 +212,9 @@ async function handleValidateClean(options: ValidateOptions): Promise<void> {
192
212
  }
193
213
 
194
214
  // 删除对应的快照文件
195
- removeSnapshot(projectName, options.branch);
215
+ removeSnapshot(projectName, branchName);
196
216
 
197
- printSuccess(MESSAGES.VALIDATE_CLEANED(options.branch));
217
+ printSuccess(MESSAGES.VALIDATE_CLEANED(branchName));
198
218
  }
199
219
 
200
220
  /**
@@ -225,8 +245,8 @@ function handleFirstValidate(targetWorktreePath: string, mainWorktreePath: strin
225
245
  * @param {boolean} hasUncommitted - 目标 worktree 是否有未提交修改
226
246
  */
227
247
  function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string, hasUncommitted: boolean): void {
228
- // 步骤 1:读取旧 tree hash(在清空前读取)
229
- const oldTreeHash = readSnapshotTreeHash(projectName, branchName);
248
+ // 步骤 1:读取旧快照(tree hash + 当时的 HEAD commit hash)
249
+ const { treeHash: oldTreeHash, headCommitHash: oldHeadCommitHash } = readSnapshot(projectName, branchName);
230
250
 
231
251
  // 步骤 2:确保主 worktree 干净(调用方已通过 handleDirtyMainWorktree 处理)
232
252
  // 这里做兜底清理,防止 handleDirtyMainWorktree 之后仍有残留
@@ -238,12 +258,35 @@ function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath:
238
258
  // 步骤 3:通过 patch 从目标分支获取最新全量变更
239
259
  migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
240
260
 
241
- // 步骤 4:保存最新快照为 git tree 对象
261
+ // 步骤 4:保存最新快照为 git tree 对象(同时记录当前 HEAD)
242
262
  saveCurrentSnapshotTree(mainWorktreePath, projectName, branchName);
243
263
 
244
- // 步骤 5:将旧 tree 对象载入暂存区(恢复上次快照状态)
264
+ // 步骤 5:将旧变更状态载入暂存区
245
265
  try {
246
- gitReadTree(oldTreeHash, mainWorktreePath);
266
+ const currentHeadCommitHash = getHeadCommitHash(mainWorktreePath);
267
+
268
+ if (oldHeadCommitHash && oldHeadCommitHash !== currentHeadCommitHash) {
269
+ // HEAD 发生了变化(如主分支合并了其他分支):
270
+ // 将旧变更 patch(旧 tree 相对于旧 HEAD 的差异)重放到当前 HEAD 暂存区上,
271
+ // 避免新旧 tree 基准不同导致 diff 混入 HEAD 变化的内容
272
+ const oldHeadTreeHash = getCommitTreeHash(oldHeadCommitHash, mainWorktreePath);
273
+ const oldChangePatch = gitDiffTree(oldHeadTreeHash, oldTreeHash, mainWorktreePath);
274
+
275
+ if (oldChangePatch.length > 0 && gitApplyCachedCheck(oldChangePatch, mainWorktreePath)) {
276
+ // 无冲突:apply --cached 到当前 HEAD 暂存区
277
+ gitApplyCachedFromStdin(oldChangePatch, mainWorktreePath);
278
+ } else if (oldChangePatch.length > 0) {
279
+ // 有冲突:降级为全量模式(暂存区保持为空)
280
+ logger.warn('旧变更 patch 与当前 HEAD 冲突,降级为全量模式');
281
+ printWarning(MESSAGES.INCREMENTAL_VALIDATE_FALLBACK);
282
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
283
+ return;
284
+ }
285
+ // oldChangePatch 为空表示旧变更为空,暂存区保持干净即可
286
+ } else {
287
+ // HEAD 未变化(或旧版快照无 HEAD 信息):直接 read-tree 旧快照
288
+ gitReadTree(oldTreeHash, mainWorktreePath);
289
+ }
247
290
  } catch (error) {
248
291
  // 旧 tree 对象无法读取(可能被 git gc 回收),降级为全量模式
249
292
  logger.warn(`增量 read-tree 失败: ${error}`);
@@ -272,19 +315,18 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
272
315
 
273
316
  const projectName = getProjectName();
274
317
  const mainWorktreePath = getGitTopLevel();
275
- const projectDir = getProjectWorktreeDir();
276
- const targetWorktreePath = join(projectDir, options.branch);
277
318
 
278
- logger.info(`validate 命令执行,分支: ${options.branch}`);
319
+ // 通过模糊匹配解析目标 worktree
320
+ const worktrees = getProjectWorktrees();
321
+ const worktree = await resolveTargetWorktree(worktrees, VALIDATE_RESOLVE_MESSAGES, options.branch);
322
+ const branchName = worktree.branch;
323
+ const targetWorktreePath = worktree.path;
279
324
 
280
- // 检查目标 worktree 是否存在
281
- if (!existsSync(targetWorktreePath)) {
282
- throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
283
- }
325
+ logger.info(`validate 命令执行,分支: ${branchName}`);
284
326
 
285
327
  // 统一检测未提交修改 + 已提交 commit
286
328
  const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
287
- const hasCommitted = hasLocalCommits(options.branch, mainWorktreePath);
329
+ const hasCommitted = hasLocalCommits(branchName, mainWorktreePath);
288
330
 
289
331
  if (!hasUncommitted && !hasCommitted) {
290
332
  printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
@@ -292,20 +334,20 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
292
334
  }
293
335
 
294
336
  // 判断是否为增量 validate(tree 对象不依赖主分支 HEAD,无需一致性校验)
295
- const isIncremental = hasSnapshot(projectName, options.branch);
337
+ const isIncremental = hasSnapshot(projectName, branchName);
296
338
 
297
339
  if (isIncremental) {
298
340
  // 增量模式:主 worktree 有残留状态时让用户选择处理方式
299
341
  if (!isWorkingDirClean(mainWorktreePath)) {
300
342
  await handleDirtyMainWorktree(mainWorktreePath);
301
343
  }
302
- handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
344
+ handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted);
303
345
  } else {
304
346
  // 首次模式:先确保主 worktree 干净
305
347
  if (!isWorkingDirClean(mainWorktreePath)) {
306
348
  await handleDirtyMainWorktree(mainWorktreePath);
307
349
  }
308
350
 
309
- handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
351
+ handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted);
310
352
  }
311
353
  }
@@ -119,4 +119,13 @@ export const MESSAGES = {
119
119
  RESUME_SELECT_BRANCH: '请选择要恢复的分支',
120
120
  /** resume 模糊匹配到多个结果提示 */
121
121
  RESUME_MULTIPLE_MATCHES: (name: string) => `"${name}" 匹配到多个分支,请选择:`,
122
+ /** validate 无可用 worktree */
123
+ VALIDATE_NO_WORKTREES: '当前项目没有可用的 worktree,请先通过 clawt run 或 clawt create 创建',
124
+ /** validate 模糊匹配无结果,列出可用分支 */
125
+ VALIDATE_NO_MATCH: (name: string, branches: string[]) =>
126
+ `未找到与 "${name}" 匹配的分支\n 可用分支:\n${branches.map((b) => ` - ${b}`).join('\n')}`,
127
+ /** validate 交互选择提示 */
128
+ VALIDATE_SELECT_BRANCH: '请选择要验证的分支',
129
+ /** validate 模糊匹配到多个结果提示 */
130
+ VALIDATE_MULTIPLE_MATCHES: (name: string) => `"${name}" 匹配到多个分支,请选择:`,
122
131
  } as const;
@@ -16,8 +16,8 @@ export interface RunOptions {
16
16
 
17
17
  /** validate 命令选项 */
18
18
  export interface ValidateOptions {
19
- /** 要验证的分支名 */
20
- branch: string;
19
+ /** 要验证的分支名(可选,支持模糊匹配,不传则列出所有分支供选择) */
20
+ branch?: string;
21
21
  /** 清理 validate 状态 */
22
22
  clean?: boolean;
23
23
  }
package/src/utils/git.ts CHANGED
@@ -425,3 +425,43 @@ export function gitWriteTree(cwd?: string): string {
425
425
  export function gitReadTree(treeHash: string, cwd?: string): void {
426
426
  execCommand(`git read-tree ${treeHash}`, { cwd });
427
427
  }
428
+
429
+ /**
430
+ * 获取指定 commit 对应的 tree 对象 hash
431
+ * @param {string} commitHash - commit hash
432
+ * @param {string} [cwd] - 工作目录
433
+ * @returns {string} tree 对象的 hash
434
+ */
435
+ export function getCommitTreeHash(commitHash: string, cwd?: string): string {
436
+ return execCommand(`git rev-parse ${commitHash}^{tree}`, { cwd });
437
+ }
438
+
439
+ /**
440
+ * 获取两个 tree 对象之间的 diff(patch 格式,含二进制)
441
+ * @param {string} baseTreeHash - 基准 tree hash
442
+ * @param {string} targetTreeHash - 目标 tree hash
443
+ * @param {string} [cwd] - 工作目录
444
+ * @returns {Buffer} diff patch 内容
445
+ */
446
+ export function gitDiffTree(baseTreeHash: string, targetTreeHash: string, cwd?: string): Buffer {
447
+ logger.debug(`执行命令: git diff-tree -p --binary ${baseTreeHash} ${targetTreeHash}${cwd ? ` (cwd: ${cwd})` : ''}`);
448
+ return execSync(`git diff-tree -p --binary ${baseTreeHash} ${targetTreeHash}`, {
449
+ cwd,
450
+ stdio: ['pipe', 'pipe', 'pipe'],
451
+ });
452
+ }
453
+
454
+ /**
455
+ * 检测 patch 能否无冲突地应用到暂存区(干运行,不实际修改)
456
+ * @param {Buffer} patchContent - patch 内容(Buffer 格式)
457
+ * @param {string} [cwd] - 工作目录
458
+ * @returns {boolean} patch 能否成功应用
459
+ */
460
+ export function gitApplyCachedCheck(patchContent: Buffer, cwd?: string): boolean {
461
+ try {
462
+ execCommandWithInput('git', ['apply', '--cached', '--check'], { input: patchContent, cwd });
463
+ return true;
464
+ } catch {
465
+ return false;
466
+ }
467
+ }
@@ -40,6 +40,9 @@ export {
40
40
  gitResetSoftTo,
41
41
  gitWriteTree,
42
42
  gitReadTree,
43
+ getCommitTreeHash,
44
+ gitDiffTree,
45
+ gitApplyCachedCheck,
43
46
  } from './git.js';
44
47
  export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
45
48
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
@@ -49,4 +52,6 @@ export { printSuccess, printError, printWarning, printInfo, printSeparator, prin
49
52
  export { ensureDir, removeEmptyDir } from './fs.js';
50
53
  export { multilineInput } from './prompt.js';
51
54
  export { launchInteractiveClaude } from './claude.js';
52
- export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, writeSnapshot, removeSnapshot, removeProjectSnapshots } from './validate-snapshot.js';
55
+ export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots } from './validate-snapshot.js';
56
+ export { findExactMatch, findFuzzyMatches, promptSelectBranch, resolveTargetWorktree } from './worktree-matcher.js';
57
+ export type { WorktreeResolveMessages } from './worktree-matcher.js';