clawt 1.3.0 → 2.0.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
@@ -23,6 +23,7 @@
23
23
  - [5.9 日志系统](#59-日志系统)
24
24
  - [5.10 查看全局配置](#510-查看全局配置)
25
25
  - [5.11 在已有 Worktree 中恢复会话](#511-在已有-worktree-中恢复会话)
26
+ - [5.12 将主分支代码同步到目标 Worktree](#512-将主分支代码同步到目标-worktree)
26
27
  - [6. 错误处理规范](#6-错误处理规范)
27
28
  - [7. 非功能性需求](#7-非功能性需求)
28
29
 
@@ -140,6 +141,7 @@ git show-ref --verify refs/heads/<branchName> 2>/dev/null
140
141
  ├── validate-snapshots/ # validate 快照目录
141
142
  │ └── <project-name>/ # 以项目名分组
142
143
  │ ├── <branchName>.patch # 每个分支一个 patch 快照文件
144
+ │ ├── <branchName>.head # 对应的主分支 HEAD commit hash(用于增量 validate 一致性校验)
143
145
  │ └── ...
144
146
  └── worktrees/ # 所有 worktree 的统一存放目录
145
147
  └── <project-name>/ # 以项目名分组
@@ -165,6 +167,7 @@ git show-ref --verify refs/heads/<branchName> 2>/dev/null
165
167
  | `clawt list` | 列出当前项目所有 worktree | 5.8 |
166
168
  | `clawt config` | 查看全局配置 | 5.10 |
167
169
  | `clawt resume` | 在已有 worktree 中恢复 Claude Code 交互式会话 | 5.11 |
170
+ | `clawt sync` | 将主分支最新代码同步到目标 worktree | 5.12 |
168
171
 
169
172
  所有命令执行前,都必须先执行**主 worktree 校验**(见 [2.1](#21-主-worktree-的定义与定位规则))。
170
173
 
@@ -354,11 +357,11 @@ clawt validate -b <branchName> [--clean]
354
357
 
355
358
  **背景说明:**
356
359
 
357
- Git worktree 不会包含 `node_modules`、`.venv` 等依赖文件,每次安装依赖耗时较长。利用 `git stash` 可以在所有 worktree 间共享的特性,将目标 worktree 的变更迁移到主 worktree 进行测试,无需重新安装依赖。
360
+ Git worktree 不会包含 `node_modules`、`.venv` 等依赖文件,每次安装依赖耗时较长。利用 `git diff HEAD...branch --binary`(三点 diff)可以获取目标分支自分叉点以来的全量变更(包含已提交和未提交的修改),将其作为 patch 应用到主 worktree 中进行测试,无需重新安装依赖。
358
361
 
359
362
  **快照机制:**
360
363
 
361
- validate 命令引入了**快照(snapshot)机制**来支持增量对比。每次 validate 执行成功后,会将当前全量变更保存为 patch 文件(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`)。当再次执行 validate 时,通过对比新旧快照,将上次快照应用到暂存区、最新变更保留在工作目录,用户可通过 `git diff` 直接查看两次 validate 之间的增量差异。
364
+ validate 命令引入了**快照(snapshot)机制**来支持增量对比。每次 validate 执行成功后,会将当前全量变更保存为 patch 文件(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`),同时保存主分支 HEAD commit hash 到 `.head` 文件(用于一致性校验)。当再次执行 validate 时,先校验主分支 HEAD 是否与快照记录一致(不一致则清除旧快照降级为首次模式),然后通过对比新旧快照,将上次快照应用到暂存区、最新变更保留在工作目录,用户可通过 `git diff` 直接查看两次 validate 之间的增量差异。
362
365
 
363
366
  **运行流程:**
364
367
 
@@ -396,57 +399,60 @@ validate 命令引入了**快照(snapshot)机制**来支持增量对比。
396
399
 
397
400
  执行完毕后,通过 `git status --porcelain` 再次检测状态,确保工作区干净。如果仍然不干净,报错退出。
398
401
 
399
- ##### 步骤 2:通过 stash 迁移目标 worktree 变更
402
+ ##### 步骤 2:检测目标分支变更
403
+
404
+ 统一检测目标 worktree 的未提交修改和已提交 commit:
400
405
 
401
406
  ```bash
402
- # 定位目标 worktree
407
+ # 检测未提交修改
403
408
  cd ~/.clawt/worktrees/<project>/<branchName>
404
-
405
- # 校验目标 worktree 是否有更改
406
409
  git status --porcelain
407
- ```
408
-
409
- - **无更改** → 输出提示 `该 worktree 的分支上没有任何更改,无需验证`,退出
410
- - **有更改** → 继续
411
410
 
412
- ```bash
413
- git add .
414
- git stash push -m "clawt:validate:<branchName>"
415
- git stash apply
416
- git restore --staged .
411
+ # 检测已提交 commit(在主 worktree 中执行)
412
+ cd <主 worktree 路径>
413
+ git log HEAD..<branchName> --oneline
417
414
  ```
418
415
 
419
- > 此步骤结束后,目标 worktree 的代码保持原样(变更仍然存在于工作区),同时变更已被记录到共享的 stash 中。
416
+ - **两者均无** 输出提示 `该 worktree 的分支上没有任何更改,无需验证`,退出
417
+ - **至少有一项** → 继续
420
418
 
421
- 在主 worktree 中应用 stash:
419
+ ##### 步骤 3:通过 patch 迁移目标分支全量变更
422
420
 
423
- ```bash
424
- cd <主 worktree 路径>
425
- git stash list
426
- ```
421
+ 使用三点 diff(`git diff HEAD...branchName --binary`)获取目标分支自分叉点以来的全量变更。如果目标 worktree 有未提交修改,先做临时 commit 以便 diff 能捕获全部变更,diff 完成后撤销临时 commit 恢复原状。
427
422
 
428
- 检查 `stash@{0}` 的消息是否包含 `clawt:validate:<branchName>`:
423
+ ```bash
424
+ # 如果有未提交修改,先临时提交
425
+ cd ~/.clawt/worktrees/<project>/<branchName>
426
+ git add .
427
+ git commit -m "clawt:temp-commit-for-validate"
429
428
 
430
- - **不包含** 报错:`git stash list 已变更,请重新执行`,退出
431
- - **包含** 继续
429
+ # 在主 worktree 中执行三点 diff
430
+ cd <主 worktree 路径>
431
+ git diff HEAD...<branchName> --binary | git apply
432
432
 
433
- ```bash
434
- git stash pop stash@{0}
433
+ # 撤销临时 commit,恢复目标 worktree 原状
434
+ cd ~/.clawt/worktrees/<project>/<branchName>
435
+ git reset --soft HEAD~1
436
+ git restore --staged .
435
437
  ```
436
438
 
437
- ##### 步骤 3:保存纯净快照
439
+ > 此步骤结束后,目标 worktree 的代码保持原样,主 worktree 工作目录包含目标分支的全量变更。
440
+ > 如果 patch apply 失败(目标分支与主分支差异过大),会提示用户先执行 `clawt sync -b <branchName>` 同步主分支后重试。
438
441
 
439
- 将主 worktree 工作目录的全量变更保存为 patch 文件:
442
+ ##### 步骤 4:保存纯净快照
443
+
444
+ 将主 worktree 工作目录的全量变更保存为 patch 文件,同时记录主分支 HEAD commit hash:
440
445
 
441
446
  ```bash
442
447
  git add .
443
448
  git diff --cached --binary > ~/.clawt/validate-snapshots/<project>/<branchName>.patch
449
+ git rev-parse HEAD > ~/.clawt/validate-snapshots/<project>/<branchName>.head
444
450
  git restore --staged .
445
451
  ```
446
452
 
447
453
  > 结果:暂存区=空,工作目录=全量变更。
448
454
 
449
- ##### 步骤 4:输出成功提示
455
+ ##### 步骤 5:输出成功提示
450
456
 
451
457
  ```
452
458
  ✓ 已将分支 feature-scheme-1 的变更应用到主 worktree
@@ -457,28 +463,30 @@ git restore --staged .
457
463
 
458
464
  当 `~/.clawt/validate-snapshots/<project>/<branchName>.patch` 存在时,自动进入增量模式:
459
465
 
460
- ##### 步骤 1:读取旧 patch
466
+ ##### 步骤 1:校验主分支 HEAD 一致性
461
467
 
462
- 在清空主 worktree 之前,读取上次保存的快照 patch 内容。
468
+ 读取 `.head` 文件中保存的主分支 HEAD hash,与当前主分支 HEAD 对比:
463
469
 
464
- ##### 步骤 2:清空主 worktree
470
+ - **不一致或 `.head` 文件不存在** → 清除旧快照(`.patch` + `.head`),降级为首次 validate 模式
471
+ - **一致** → 继续增量流程
465
472
 
466
- 丢弃上次 validate 留下的变更和用户手动修改:
473
+ ##### 步骤 2:读取旧 patch
467
474
 
468
- ```bash
469
- git reset --hard
470
- git clean -fd
471
- ```
475
+ 在清空主 worktree 之前,读取上次保存的快照 patch 内容。
476
+
477
+ ##### 步骤 3:确保主 worktree 干净
472
478
 
473
- ##### 步骤 3:从目标 worktree 获取最新全量变更
479
+ 如果主 worktree 有残留状态,让用户选择处理方式(同首次 validate 步骤 1 的交互),做兜底清理。
474
480
 
475
- 检查目标 worktree 是否有更改(无更改则退出)。通过 stash 迁移目标 worktree 的最新变更到主 worktree(流程同首次 validate 的步骤 2)。
481
+ ##### 步骤 4:从目标分支获取最新全量变更
476
482
 
477
- ##### 步骤 4:保存最新快照
483
+ 通过 patch 方式从目标分支获取最新全量变更(流程同首次 validate 的步骤 3)。
478
484
 
479
- 将最新全量变更保存为新的 patch 文件(覆盖旧快照,流程同首次 validate 的步骤 3)。
485
+ ##### 步骤 5:保存最新快照
480
486
 
481
- ##### 步骤 5:将旧 patch 应用到暂存区
487
+ 将最新全量变更保存为新的 patch 文件 + HEAD hash(覆盖旧快照,流程同首次 validate 的步骤 4)。
488
+
489
+ ##### 步骤 6:将旧 patch 应用到暂存区
482
490
 
483
491
  ```bash
484
492
  git apply --cached < <旧 patch 内容>
@@ -487,7 +495,7 @@ git apply --cached < <旧 patch 内容>
487
495
  - **应用成功** → 结果:暂存区=上次快照,工作目录=最新全量变更(用户可通过 `git diff` 查看增量差异)
488
496
  - **应用失败**(文件结构变化过大)→ 降级为全量模式,暂存区保持为空,等同于首次 validate 的结果
489
497
 
490
- ##### 步骤 6:输出成功提示
498
+ ##### 步骤 7:输出成功提示
491
499
 
492
500
  ```
493
501
  # 增量模式成功
@@ -514,8 +522,7 @@ clawt remove [options]
514
522
  | 参数 | 说明 |
515
523
  | --------- | ---------------------------------------------------------- |
516
524
  | `--all` | 移除当前项目 (`~/.clawt/worktrees/<project>/`) 下所有 worktree |
517
- | `-b <branchName>` | 移除指定 branchName 下的所有 worktree |
518
- | `-b <branchName> -i <index>` | 移除指定 branchName 的某一个 worktree (如 `branchName-2`) |
525
+ | `-b <branchName>` | 移除匹配 branchName branchName-* 的 worktree |
519
526
 
520
527
  **三种移除粒度:**
521
528
 
@@ -523,7 +530,7 @@ clawt remove [options]
523
530
  | ---- | ---------------------------------------- | ------------------------------------------------------------- |
524
531
  | 全部 | `clawt remove --all` | `~/.clawt/worktrees/<project>/` 下所有 worktree |
525
532
  | 分支 | `clawt remove -b feature-scheme` | `~/.clawt/worktrees/<project>/feature-scheme-*` 的所有 worktree |
526
- | 单个 | `clawt remove -b feature-scheme -i 2` | 仅移除 `~/.clawt/worktrees/<project>/feature-scheme-2` |
533
+ | 单个 | `clawt remove -b feature-scheme-2` | 仅移除 `feature-scheme-2` 对应的 worktree(完整分支名精确匹配) |
527
534
 
528
535
  **运行流程:**
529
536
 
@@ -819,6 +826,54 @@ clawt resume -b <branchName>
819
826
 
820
827
  ---
821
828
 
829
+ ### 5.12 将主分支代码同步到目标 Worktree
830
+
831
+ **命令:**
832
+
833
+ ```bash
834
+ clawt sync -b <branchName>
835
+ ```
836
+
837
+ **参数:**
838
+
839
+ | 参数 | 必填 | 说明 |
840
+ | ---- | ---- | ----------------------------------------------------- |
841
+ | `-b` | 是 | 要同步的分支名(对应已有 worktree 的分支) |
842
+
843
+ **使用场景:**
844
+
845
+ 当目标 worktree 的分支与主分支差异过大(例如主分支有了新的提交),导致 `clawt validate` 的 patch apply 失败时,可以通过 `clawt sync` 将主分支最新代码合并到目标 worktree,使其保持与主分支同步。
846
+
847
+ **运行流程:**
848
+
849
+ 1. **主 worktree 校验** (2.1)
850
+ 2. **检查目标 worktree 是否存在**:确认 `~/.clawt/worktrees/<project>/<branchName>` 目录存在
851
+ - 不存在 → 报错退出
852
+ 3. **获取主分支名**:通过 `git rev-parse --abbrev-ref HEAD` 获取主 worktree 当前分支名(不硬编码 main/master)
853
+ 4. **自动保存未提交变更**:检查目标 worktree 是否有未提交修改
854
+ - 有修改 → 自动执行 `git add . && git commit -m "chore: auto-save before sync"` 保存变更
855
+ - 无修改 → 跳过
856
+ 5. **在目标 worktree 中合并主分支**:
857
+ ```bash
858
+ cd ~/.clawt/worktrees/<project>/<branchName>
859
+ git merge <mainBranch>
860
+ ```
861
+ 6. **冲突处理**:
862
+ - **有冲突** → 输出警告,提示用户进入目标 worktree 手动解决:
863
+ ```
864
+ 合并存在冲突,请进入目标 worktree 手动解决:
865
+ cd ~/.clawt/worktrees/<project>/<branchName>
866
+ 解决冲突后执行 git add . && git merge --continue
867
+ ```
868
+ - **无冲突** → 继续
869
+ 7. **清除 validate 快照**:合并成功后,如果该分支存在 validate 快照(`.patch` + `.head`),自动删除(代码基础已变化,旧快照无效)
870
+ 8. **输出成功提示**:
871
+ ```
872
+ ✓ 已将 <mainBranch> 的最新代码同步到 <branchName>
873
+ ```
874
+
875
+ ---
876
+
822
877
  ## 6. 错误处理规范
823
878
 
824
879
  ### 6.1 通用错误处理
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,4 @@
1
1
  import type { Command } from 'commander';
2
- import { join } from 'node:path';
3
- import { existsSync, readdirSync } from 'node:fs';
4
2
  import { logger } from '../logger/index.js';
5
3
  import { ClawtError } from '../errors/index.js';
6
4
  import { MESSAGES } from '../constants/index.js';
@@ -29,8 +27,7 @@ export function registerRemoveCommand(program: Command): void {
29
27
  .command('remove')
30
28
  .description('移除 worktree(支持单个/批量/全部)')
31
29
  .option('--all', '移除当前项目下所有 worktree')
32
- .option('-b, --branch <branchName>', '指定分支名')
33
- .option('-i, --index <index>', '指定索引(配合 -b 使用)')
30
+ .option('-b, --branch <branchName>', '指定分支名(完整分支名精确匹配)')
34
31
  .action(async (options: RemoveOptions) => {
35
32
  await handleRemove(options);
36
33
  });
@@ -42,7 +39,6 @@ export function registerRemoveCommand(program: Command): void {
42
39
  * @returns {Array<{path: string, branch: string}>} 待移除的 worktree 列表
43
40
  */
44
41
  function resolveWorktreesToRemove(options: RemoveOptions): Array<{ path: string; branch: string }> {
45
- const projectDir = getProjectWorktreeDir();
46
42
  const allWorktrees = getProjectWorktrees();
47
43
 
48
44
  if (options.all) {
@@ -53,23 +49,12 @@ function resolveWorktreesToRemove(options: RemoveOptions): Array<{ path: string;
53
49
  throw new ClawtError('请指定 --all 或 -b <branchName> 参数');
54
50
  }
55
51
 
56
- if (options.index !== undefined) {
57
- // 单个移除:branchName-<index>
58
- const targetName = `${options.branch}-${options.index}`;
59
- const targetPath = join(projectDir, targetName);
60
- const found = allWorktrees.find((wt) => wt.path === targetPath);
61
- if (!found) {
62
- throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(targetName));
63
- }
64
- return [found];
65
- }
66
-
67
52
  // 分支级移除:匹配 branchName 或 branchName-*
68
53
  const matched = allWorktrees.filter(
69
54
  (wt) => wt.branch === options.branch || wt.branch.startsWith(`${options.branch}-`),
70
55
  );
71
56
  if (matched.length === 0) {
72
- throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch!));
57
+ throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
73
58
  }
74
59
  return matched;
75
60
  }
@@ -0,0 +1,116 @@
1
+ import { existsSync } from 'node:fs';
2
+ import type { Command } from 'commander';
3
+ import { logger } from '../logger/index.js';
4
+ import { ClawtError } from '../errors/index.js';
5
+ import { MESSAGES } from '../constants/index.js';
6
+ import type { SyncOptions } from '../types/index.js';
7
+ import {
8
+ validateMainWorktree,
9
+ getGitTopLevel,
10
+ getProjectName,
11
+ getProjectWorktreeDir,
12
+ isWorkingDirClean,
13
+ gitAddAll,
14
+ gitCommit,
15
+ gitMerge,
16
+ hasMergeConflict,
17
+ getCurrentBranch,
18
+ hasSnapshot,
19
+ removeSnapshot,
20
+ printSuccess,
21
+ printInfo,
22
+ printWarning,
23
+ } from '../utils/index.js';
24
+
25
+ /**
26
+ * 注册 sync 命令:将主分支最新代码同步到目标 worktree
27
+ * @param {Command} program - Commander 实例
28
+ */
29
+ export function registerSyncCommand(program: Command): void {
30
+ program
31
+ .command('sync')
32
+ .description('将主分支最新代码同步到目标 worktree')
33
+ .requiredOption('-b, --branch <branchName>', '要同步的分支名')
34
+ .action(async (options: SyncOptions) => {
35
+ await handleSync(options);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * 自动保存目标 worktree 中的未提交变更
41
+ * @param {string} worktreePath - 目标 worktree 路径
42
+ * @param {string} branch - 分支名
43
+ */
44
+ function autoSaveChanges(worktreePath: string, branch: string): void {
45
+ gitAddAll(worktreePath);
46
+ gitCommit('chore: auto-save before sync', worktreePath);
47
+ printInfo(MESSAGES.SYNC_AUTO_COMMITTED(branch));
48
+ logger.info(`已自动保存 ${branch} 分支的未提交变更`);
49
+ }
50
+
51
+ /**
52
+ * 在目标 worktree 中合并主分支
53
+ * @param {string} worktreePath - 目标 worktree 路径
54
+ * @param {string} mainBranch - 主分支名
55
+ * @returns {boolean} 是否存在冲突(true 表示有冲突)
56
+ */
57
+ function mergeMainBranch(worktreePath: string, mainBranch: string): boolean {
58
+ try {
59
+ gitMerge(mainBranch, worktreePath);
60
+ return false;
61
+ } catch {
62
+ // 合并失败时检查是否为冲突
63
+ if (hasMergeConflict(worktreePath)) {
64
+ return true;
65
+ }
66
+ // 非冲突错误则向上抛出
67
+ throw new ClawtError(`合并 ${mainBranch} 失败`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 执行 sync 命令的核心逻辑
73
+ * 将主分支最新代码同步到目标 worktree
74
+ * @param {SyncOptions} options - 命令选项
75
+ */
76
+ async function handleSync(options: SyncOptions): Promise<void> {
77
+ validateMainWorktree();
78
+
79
+ const { branch } = options;
80
+ logger.info(`sync 命令执行,分支: ${branch}`);
81
+
82
+ // 检查目标 worktree 是否存在
83
+ const projectWorktreeDir = getProjectWorktreeDir();
84
+ const targetWorktreePath = `${projectWorktreeDir}/${branch}`;
85
+
86
+ if (!existsSync(targetWorktreePath)) {
87
+ throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(branch));
88
+ }
89
+
90
+ // 获取主分支名(不硬编码 main/master)
91
+ const mainWorktreePath = getGitTopLevel();
92
+ const mainBranch = getCurrentBranch(mainWorktreePath);
93
+
94
+ // 检查目标 worktree 是否有未提交变更,有则自动保存
95
+ if (!isWorkingDirClean(targetWorktreePath)) {
96
+ autoSaveChanges(targetWorktreePath, branch);
97
+ }
98
+
99
+ // 在目标 worktree 中合并主分支
100
+ printInfo(MESSAGES.SYNC_MERGING(branch, mainBranch));
101
+ const hasConflict = mergeMainBranch(targetWorktreePath, mainBranch);
102
+
103
+ if (hasConflict) {
104
+ printWarning(MESSAGES.SYNC_CONFLICT(targetWorktreePath));
105
+ return;
106
+ }
107
+
108
+ // 合并成功后清除该分支的 validate 快照(代码基础已变化,旧快照无效)
109
+ const projectName = getProjectName();
110
+ if (hasSnapshot(projectName, branch)) {
111
+ removeSnapshot(projectName, branch);
112
+ logger.info(`已清除分支 ${branch} 的 validate 快照`);
113
+ }
114
+
115
+ printSuccess(MESSAGES.SYNC_SUCCESS(branch, mainBranch));
116
+ }
@@ -13,17 +13,21 @@ import {
13
13
  getProjectWorktreeDir,
14
14
  isWorkingDirClean,
15
15
  gitAddAll,
16
+ gitCommit,
16
17
  gitStashPush,
17
- gitStashApply,
18
- gitStashPop,
19
- gitStashList,
20
18
  gitRestoreStaged,
21
19
  gitResetHard,
22
20
  gitCleanForce,
23
21
  gitDiffCachedBinary,
24
22
  gitApplyCachedFromStdin,
23
+ gitDiffBinaryAgainstBranch,
24
+ gitApplyFromStdin,
25
+ gitResetSoft,
26
+ getHeadCommitHash,
27
+ hasLocalCommits,
25
28
  hasSnapshot,
26
29
  readSnapshot,
30
+ readSnapshotHead,
27
31
  writeSnapshot,
28
32
  removeSnapshot,
29
33
  printSuccess,
@@ -92,32 +96,50 @@ async function handleDirtyMainWorktree(mainWorktreePath: string): Promise<void>
92
96
  }
93
97
 
94
98
  /**
95
- * 通过 stash 将目标 worktree 的变更迁移到主 worktree
99
+ * 通过 patch 将目标分支的全量变更(已提交 + 未提交)迁移到主 worktree
100
+ * 使用 git diff HEAD...branch --binary 获取变更,避免 stash 方式无法检测已提交 commit 的问题
96
101
  * @param {string} targetWorktreePath - 目标 worktree 路径
97
102
  * @param {string} mainWorktreePath - 主 worktree 路径
98
103
  * @param {string} branchName - 分支名
104
+ * @param {boolean} hasUncommitted - 目标 worktree 是否有未提交修改
99
105
  */
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
- }
106
+ function migrateChangesViaPatch(targetWorktreePath: string, mainWorktreePath: string, branchName: string, hasUncommitted: boolean): void {
107
+ let didTempCommit = false;
108
+
109
+ try {
110
+ // 如果有未提交修改,先做临时 commit 以便 diff 能捕获全部变更
111
+ if (hasUncommitted) {
112
+ gitAddAll(targetWorktreePath);
113
+ gitCommit('clawt:temp-commit-for-validate', targetWorktreePath);
114
+ didTempCommit = true;
115
+ }
114
116
 
115
- gitStashPop(0, mainWorktreePath);
117
+ // 在主 worktree 执行三点 diff,获取目标分支自分叉点以来的全量变更
118
+ const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
119
+
120
+ // 应用 patch 到主 worktree 工作目录
121
+ if (patch.length > 0) {
122
+ try {
123
+ gitApplyFromStdin(patch, mainWorktreePath);
124
+ } catch (error) {
125
+ logger.warn(`patch apply 失败: ${error}`);
126
+ printWarning(MESSAGES.VALIDATE_PATCH_APPLY_FAILED(branchName));
127
+ throw error;
128
+ }
129
+ }
130
+ } finally {
131
+ // 确保临时 commit 一定会被撤销,恢复目标 worktree 原状
132
+ if (didTempCommit) {
133
+ gitResetSoft(1, targetWorktreePath);
134
+ gitRestoreStaged(targetWorktreePath);
135
+ }
136
+ }
116
137
  }
117
138
 
118
139
  /**
119
140
  * 保存当前主 worktree 工作目录变更为纯净快照 patch
120
141
  * 操作序列:git add . → git diff --cached --binary → git restore --staged .
142
+ * 同时记录主分支 HEAD hash,用于增量 validate 一致性校验
121
143
  * @param {string} mainWorktreePath - 主 worktree 路径
122
144
  * @param {string} projectName - 项目名
123
145
  * @param {string} branchName - 分支名
@@ -127,7 +149,8 @@ function saveCurrentSnapshotPatch(mainWorktreePath: string, projectName: string,
127
149
  gitAddAll(mainWorktreePath);
128
150
  const patch = gitDiffCachedBinary(mainWorktreePath);
129
151
  gitRestoreStaged(mainWorktreePath);
130
- writeSnapshot(projectName, branchName, patch);
152
+ const headHash = getHeadCommitHash(mainWorktreePath);
153
+ writeSnapshot(projectName, branchName, patch, headHash);
131
154
  return patch;
132
155
  }
133
156
 
@@ -161,10 +184,11 @@ function handleValidateClean(options: ValidateOptions): void {
161
184
  * @param {string} mainWorktreePath - 主 worktree 路径
162
185
  * @param {string} projectName - 项目名
163
186
  * @param {string} branchName - 分支名
187
+ * @param {boolean} hasUncommitted - 目标 worktree 是否有未提交修改
164
188
  */
165
- function handleFirstValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string): void {
166
- // 通过 stash 迁移目标 worktree 变更到主 worktree
167
- migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName);
189
+ function handleFirstValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string, hasUncommitted: boolean): void {
190
+ // 通过 patch 迁移目标分支全量变更到主 worktree
191
+ migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
168
192
 
169
193
  // 保存纯净快照到 patch 文件
170
194
  saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
@@ -179,21 +203,24 @@ function handleFirstValidate(targetWorktreePath: string, mainWorktreePath: strin
179
203
  * @param {string} mainWorktreePath - 主 worktree 路径
180
204
  * @param {string} projectName - 项目名
181
205
  * @param {string} branchName - 分支名
206
+ * @param {boolean} hasUncommitted - 目标 worktree 是否有未提交修改
182
207
  */
183
- function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string): void {
208
+ function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string, hasUncommitted: boolean): void {
184
209
  // 步骤 1:读取旧 patch(在清空前读取)
185
210
  const oldPatch = readSnapshot(projectName, branchName);
186
211
 
187
- // 步骤 2:清空主 worktree(丢弃手动修改和上次 validate 留下的变更)
188
- printInfo(MESSAGES.INCREMENTAL_VALIDATE_RESET);
189
- gitResetHard(mainWorktreePath);
190
- gitCleanForce(mainWorktreePath);
212
+ // 步骤 2:确保主 worktree 干净(调用方已通过 handleDirtyMainWorktree 处理)
213
+ // 这里做兜底清理,防止 handleDirtyMainWorktree 之后仍有残留
214
+ if (!isWorkingDirClean(mainWorktreePath)) {
215
+ gitResetHard(mainWorktreePath);
216
+ gitCleanForce(mainWorktreePath);
217
+ }
191
218
 
192
- // 步骤 3:从目标 worktree 获取最新全量变更
193
- migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName);
219
+ // 步骤 3:通过 patch 从目标分支获取最新全量变更
220
+ migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
194
221
 
195
222
  // 步骤 4:保存最新快照
196
- const newPatch = saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
223
+ saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
197
224
 
198
225
  // 步骤 5:将旧 patch 应用到暂存区
199
226
  if (oldPatch.length > 0) {
@@ -238,28 +265,41 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
238
265
  throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
239
266
  }
240
267
 
268
+ // 统一检测未提交修改 + 已提交 commit
269
+ const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
270
+ const hasCommitted = hasLocalCommits(options.branch, mainWorktreePath);
271
+
272
+ if (!hasUncommitted && !hasCommitted) {
273
+ printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
274
+ return;
275
+ }
276
+
241
277
  // 判断是否为增量 validate
242
- const isIncremental = hasSnapshot(projectName, options.branch);
278
+ let isIncremental = hasSnapshot(projectName, options.branch);
243
279
 
280
+ // 主分支 HEAD 发生变化或旧快照无 .head 记录时,清除后走首次全量模式
244
281
  if (isIncremental) {
245
- // 增量模式:检查目标 worktree 是否有变更
246
- if (isWorkingDirClean(targetWorktreePath)) {
247
- printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
248
- return;
282
+ const savedHead = readSnapshotHead(projectName, options.branch);
283
+ const currentHead = getHeadCommitHash(mainWorktreePath);
284
+ if (!savedHead || savedHead !== currentHead) {
285
+ logger.info(`主分支 HEAD 不匹配 (${savedHead ?? 'null'} → ${currentHead}),清除旧快照`);
286
+ removeSnapshot(projectName, options.branch);
287
+ isIncremental = false;
288
+ }
289
+ }
290
+
291
+ if (isIncremental) {
292
+ // 增量模式:主 worktree 有残留状态时让用户选择处理方式
293
+ if (!isWorkingDirClean(mainWorktreePath)) {
294
+ await handleDirtyMainWorktree(mainWorktreePath);
249
295
  }
250
- handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
296
+ handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
251
297
  } else {
252
298
  // 首次模式:先确保主 worktree 干净
253
299
  if (!isWorkingDirClean(mainWorktreePath)) {
254
300
  await handleDirtyMainWorktree(mainWorktreePath);
255
301
  }
256
302
 
257
- // 检查目标 worktree 是否有变更
258
- if (isWorkingDirClean(targetWorktreePath)) {
259
- printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
260
- return;
261
- }
262
-
263
- handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
303
+ handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
264
304
  }
265
305
  }
@@ -23,8 +23,6 @@ export const MESSAGES = {
23
23
  MAIN_WORKTREE_DIRTY: '主 worktree 有未提交的更改,请先处理',
24
24
  /** 目标 worktree 无更改 */
25
25
  TARGET_WORKTREE_CLEAN: '该 worktree 的分支上没有任何更改,无需验证',
26
- /** stash 已变更 */
27
- STASH_CHANGED: 'git stash list 已变更,请重新执行',
28
26
  /** validate 成功 */
29
27
  VALIDATE_SUCCESS: (branch: string) =>
30
28
  `✓ 已将分支 ${branch} 的变更应用到主 worktree\n 可以开始验证了`,
@@ -71,9 +69,22 @@ export const MESSAGES = {
71
69
  INCREMENTAL_VALIDATE_FALLBACK: '增量对比失败,已降级为全量模式',
72
70
  /** validate 状态已清理 */
73
71
  VALIDATE_CLEANED: (branch: string) => `✓ 分支 ${branch} 的 validate 状态已清理`,
74
- /** 增量 validate 检测到脏状态,即将清空 */
75
- INCREMENTAL_VALIDATE_RESET: '检测到上次 validate 的残留状态,将清空主 worktree 并重新应用',
76
72
  /** merge 命令检测到 validate 状态的提示 */
77
73
  MERGE_VALIDATE_STATE_HINT: (branch: string) =>
78
74
  `主 worktree 可能存在 validate 残留状态,可先执行 clawt validate -b ${branch} --clean 清理`,
75
+ /** sync 自动保存未提交变更 */
76
+ SYNC_AUTO_COMMITTED: (branch: string) =>
77
+ `已自动保存 ${branch} 分支的未提交变更`,
78
+ /** sync 开始合并 */
79
+ SYNC_MERGING: (targetBranch: string, mainBranch: string) =>
80
+ `正在将 ${mainBranch} 合并到 ${targetBranch} ...`,
81
+ /** sync 成功 */
82
+ SYNC_SUCCESS: (targetBranch: string, mainBranch: string) =>
83
+ `✓ 已将 ${mainBranch} 的最新代码同步到 ${targetBranch}`,
84
+ /** sync 冲突 */
85
+ SYNC_CONFLICT: (worktreePath: string) =>
86
+ `合并存在冲突,请进入目标 worktree 手动解决:\n cd ${worktreePath}\n 解决冲突后执行 git add . && git merge --continue`,
87
+ /** validate patch apply 失败,提示用户同步主分支 */
88
+ VALIDATE_PATCH_APPLY_FAILED: (branch: string) =>
89
+ `变更迁移失败:目标分支与主分支差异过大\n 请先执行 clawt sync -b ${branch} 同步主分支后重试`,
79
90
  } as const;