clawt 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,13 +72,14 @@ Notes:
72
72
  ## validate 快照机制
73
73
 
74
74
  - validate 命令支持首次/增量两种模式,通过 `hasSnapshot()` 判断
75
- - 快照路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.patch`
75
+ - 快照路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.tree`(存储 git tree 对象 hash)
76
76
  - 常量 `VALIDATE_SNAPSHOTS_DIR` 定义在 `src/constants/paths.ts`
77
77
  - validate 新增 `--clean` 选项(`ValidateOptions.clean?: boolean`)
78
- - 增量模式核心:旧 patch 应用到暂存区 + 新全量变更在工作目录`git diff` 可查看增量差异
79
- - 增量 apply 失败时自动降级为全量模式
80
- - shell 层新增 `execCommandWithInput()`(`execFileSync` + stdin),用于 `gitApplyCachedFromStdin()`
81
- - git 层新增 `gitDiffCachedBinary()`(返回 Buffer)和 `gitApplyCachedFromStdin()`
78
+ - 快照保存:`git add . git write-tree → git restore --staged .`,将 tree hash 写入 `.tree` 文件
79
+ - 增量模式核心:`git read-tree <旧 tree hash>` 将旧快照载入暂存区 + 新全量变更在工作目录 → `git diff` 可查看增量差异
80
+ - tree 对象不依赖主分支 HEAD,无需一致性校验(旧方案需要 `.head` 文件校验 HEAD 一致性)
81
+ - 增量 read-tree 失败时自动降级为全量模式(tree 对象可能被 git gc 回收)
82
+ - git 层有 `gitWriteTree()`(返回 tree hash)和 `gitReadTree()`(载入暂存区)
82
83
  - merge 成功后自动清理对应快照;merge 时主 worktree 脏 + 存在快照会输出警告提示
83
84
  - docs/spec.md 中 validate 章节(5.4)按 `--clean 模式`、`首次 validate`、`增量 validate` 三段描述
84
85
  - CLAUDE.md 中在 validate + merge 工作流章节用缩进列表描述两种模式
package/CLAUDE.md CHANGED
@@ -56,10 +56,11 @@ run 命令有两种模式:
56
56
  ### validate + merge 工作流
57
57
 
58
58
  - `validate`:将目标分支的全量变更(已提交 + 未提交)通过 `git diff HEAD...branch --binary` 的 patch 方式迁移到主 worktree,便于在主 worktree 中测试。支持两种模式:
59
- - **首次 validate**(无历史快照):patch 迁移全量变更 → 保存纯净快照 patch + 主分支 HEAD hash → 结果:暂存区=空,工作目录=全量变更
60
- - **增量 validate**(存在历史快照):校验主分支 HEAD 一致性(不一致则清除旧快照降级为首次模式)→ 读取旧 patch → 确保主 worktree 干净 → patch 迁移最新变更 → 保存新快照 patch 应用到暂存区 → 结果:暂存区=上次快照,工作目录=最新变更(可通过 `git diff` 查看增量差异)
59
+ - **首次 validate**(无历史快照):patch 迁移全量变更 → 通过 `git write-tree` 保存快照为 git tree 对象 → 结果:暂存区=空,工作目录=全量变更
60
+ - **增量 validate**(存在历史快照):读取旧 tree hash → 确保主 worktree 干净 → patch 迁移最新变更 → 保存新 tree 对象快照 `git read-tree` 将旧 tree 载入暂存区 → 结果:暂存区=上次快照,工作目录=最新变更(可通过 `git diff` 查看增量差异)
61
61
  - `--clean` 选项:重置主 worktree + 删除对应快照文件
62
- - 快照存储路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.patch`(patch 文件)+ `<branchName>.head`(主分支 HEAD hash)
62
+ - 快照存储路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.tree`(存储 git tree 对象 hash)
63
+ - tree 对象不依赖主分支 HEAD,无需一致性校验
63
64
  - 变更检测:同时检测目标 worktree 的未提交修改和已提交 commit,两者均无则提示无需验证
64
65
  - 未提交修改处理:有未提交修改时先做临时 commit,diff 完成后通过 `git reset --soft` 撤销恢复原状
65
66
  - `merge`:检测目标 worktree 状态(有修改则需 `-m` 提交,已提交则跳过,无变更则报错)→ **squash 检测**(检查目标分支是否存在 `AUTO_SAVE_COMMIT_MESSAGE` 前缀的 auto-save commit,如有则提示用户是否压缩所有提交:用户确认后通过 `gitMergeBase` 计算分叉点、`gitResetSoftTo` 将所有 commit reset 到暂存区;有 `-m` 则直接提交继续流程,无 `-m` 则提示用户自行提交后退出)→ 合并到主 worktree → pull → push → 可选清理 worktree 和分支(受 `autoDeleteBranch` 配置或交互式确认控制)→ 清理对应的 validate 快照
@@ -85,7 +86,7 @@ run 命令有两种模式:
85
86
  ### 目录层级
86
87
 
87
88
  - `src/commands/` — 各命令的注册与处理逻辑
88
- - `src/utils/` — 工具函数(git 操作(含三点 diff、分支合并、冲突检测、merge-base 计算、commit message 检测、soft reset 到指定 commit 等)、shell 执行与子进程管理、分支名处理、worktree 管理与批量清理、配置、格式化输出、交互式输入、Claude Code 交互式启动、validate 快照管理(含 HEAD hash 一致性校验))
89
+ - `src/utils/` — 工具函数(git 操作(含三点 diff、分支合并、冲突检测、merge-base 计算、commit message 检测、soft reset 到指定 commit、write-tree/read-tree 等)、shell 执行与子进程管理、分支名处理、worktree 管理与批量清理、配置、格式化输出、交互式输入、Claude Code 交互式启动、validate 快照管理(基于 git tree 对象))
89
90
  - `src/constants/` — 常量定义(路径、退出码、消息模板、分支规则、配置默认值、终端控制序列、validate 快照目录、sync 相关消息、git 常量(如 `AUTO_SAVE_COMMIT_MESSAGE`)、squash 相关消息、reset 相关消息)
90
91
  - `src/types/` — TypeScript 类型定义
91
92
  - `src/errors/` — 自定义 `ClawtError` 错误类(携带退出码)
package/README.md CHANGED
@@ -103,7 +103,7 @@ clawt validate -b <branchName> [--clean]
103
103
 
104
104
  将目标 worktree 的变更通过 `git diff`(三点 diff)迁移到主 worktree,方便在主 worktree 中直接测试,无需重新安装依赖。同时检测未提交修改和已提交 commit,确保所有变更都能被捕获。
105
105
 
106
- 支持增量模式:首次 validate 后会自动保存快照(patch + 主分支 HEAD hash),再次 validate 同一分支时会先校验主分支 HEAD 一致性(不一致则降级为首次模式),然后将上次快照应用到暂存区、最新变更保留在工作目录,用户可通过 `git diff` 查看两次 validate 之间的增量差异。使用 `--clean` 可清理 validate 状态(重置主 worktree 并删除快照文件)。
106
+ 支持增量模式:首次 validate 后会自动保存快照(通过 `git write-tree` 将变更存储为 git tree 对象),再次 validate 同一分支时会通过 `git read-tree` 将上次快照载入暂存区、最新变更保留在工作目录,用户可通过 `git diff` 查看两次 validate 之间的增量差异。使用 `--clean` 可清理 validate 状态(重置主 worktree 并删除快照文件)。
107
107
 
108
108
  > **提示:** 如果 validate 时 patch apply 失败(目标分支与主分支差异过大),可先执行 `clawt sync -b <branchName>` 同步主分支后重试。
109
109
 
package/dist/index.js CHANGED
@@ -345,22 +345,9 @@ function getDiffStat(worktreePath) {
345
345
  const output = execCommand("git diff --shortstat HEAD", { cwd: worktreePath });
346
346
  return parseShortStat(output);
347
347
  }
348
- function gitDiffCachedBinary(cwd) {
349
- logger.debug(`\u6267\u884C\u547D\u4EE4: git diff --cached --binary${cwd ? ` (cwd: ${cwd})` : ""}`);
350
- return execSync2("git diff --cached --binary", {
351
- cwd,
352
- stdio: ["pipe", "pipe", "pipe"]
353
- });
354
- }
355
- function gitApplyCachedFromStdin(patchContent, cwd) {
356
- execCommandWithInput("git", ["apply", "--cached"], { input: patchContent, cwd });
357
- }
358
348
  function getCurrentBranch(cwd) {
359
349
  return execCommand("git rev-parse --abbrev-ref HEAD", { cwd });
360
350
  }
361
- function getHeadCommitHash(cwd) {
362
- return execCommand("git rev-parse HEAD", { cwd });
363
- }
364
351
  function gitDiffBinaryAgainstBranch(branchName, cwd) {
365
352
  logger.debug(`\u6267\u884C\u547D\u4EE4: git diff HEAD...${branchName} --binary${cwd ? ` (cwd: ${cwd})` : ""}`);
366
353
  return execSync2(`git diff HEAD...${branchName} --binary`, {
@@ -389,6 +376,12 @@ function hasCommitWithMessage(branchName, messagePrefix, cwd) {
389
376
  function gitResetSoftTo(commitHash, cwd) {
390
377
  execCommand(`git reset --soft ${commitHash}`, { cwd });
391
378
  }
379
+ function gitWriteTree(cwd) {
380
+ return execCommand("git write-tree", { cwd });
381
+ }
382
+ function gitReadTree(treeHash, cwd) {
383
+ execCommand(`git read-tree ${treeHash}`, { cwd });
384
+ }
392
385
 
393
386
  // src/utils/formatter.ts
394
387
  import chalk from "chalk";
@@ -642,27 +635,21 @@ function launchInteractiveClaude(worktree) {
642
635
  import { join as join3 } from "path";
643
636
  import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync as readdirSync3, rmdirSync as rmdirSync2 } from "fs";
644
637
  function getSnapshotPath(projectName, branchName) {
645
- return join3(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.patch`);
646
- }
647
- function getSnapshotHeadPath(projectName, branchName) {
648
- return join3(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
638
+ return join3(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
649
639
  }
650
640
  function hasSnapshot(projectName, branchName) {
651
641
  return existsSync5(getSnapshotPath(projectName, branchName));
652
642
  }
653
- function readSnapshot(projectName, branchName) {
643
+ function readSnapshotTreeHash(projectName, branchName) {
654
644
  const snapshotPath = getSnapshotPath(projectName, branchName);
655
645
  logger.debug(`\u8BFB\u53D6 validate \u5FEB\u7167: ${snapshotPath}`);
656
- return readFileSync2(snapshotPath);
646
+ return readFileSync2(snapshotPath, "utf-8").trim();
657
647
  }
658
- function writeSnapshot(projectName, branchName, patch, headHash) {
648
+ function writeSnapshot(projectName, branchName, treeHash) {
659
649
  const snapshotPath = getSnapshotPath(projectName, branchName);
660
650
  const snapshotDir = join3(VALIDATE_SNAPSHOTS_DIR, projectName);
661
651
  ensureDir(snapshotDir);
662
- writeFileSync2(snapshotPath, patch);
663
- if (headHash) {
664
- writeFileSync2(getSnapshotHeadPath(projectName, branchName), headHash, "utf-8");
665
- }
652
+ writeFileSync2(snapshotPath, treeHash, "utf-8");
666
653
  logger.info(`\u5DF2\u4FDD\u5B58 validate \u5FEB\u7167: ${snapshotPath}`);
667
654
  }
668
655
  function removeSnapshot(projectName, branchName) {
@@ -671,17 +658,6 @@ function removeSnapshot(projectName, branchName) {
671
658
  unlinkSync(snapshotPath);
672
659
  logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
673
660
  }
674
- const headPath = getSnapshotHeadPath(projectName, branchName);
675
- if (existsSync5(headPath)) {
676
- unlinkSync(headPath);
677
- }
678
- }
679
- function readSnapshotHead(projectName, branchName) {
680
- const headPath = getSnapshotHeadPath(projectName, branchName);
681
- if (!existsSync5(headPath)) {
682
- return null;
683
- }
684
- return readFileSync2(headPath, "utf-8").trim();
685
661
  }
686
662
 
687
663
  // src/commands/list.ts
@@ -1056,13 +1032,12 @@ function migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName
1056
1032
  }
1057
1033
  }
1058
1034
  }
1059
- function saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName) {
1035
+ function saveCurrentSnapshotTree(mainWorktreePath, projectName, branchName) {
1060
1036
  gitAddAll(mainWorktreePath);
1061
- const patch = gitDiffCachedBinary(mainWorktreePath);
1037
+ const treeHash = gitWriteTree(mainWorktreePath);
1062
1038
  gitRestoreStaged(mainWorktreePath);
1063
- const headHash = getHeadCommitHash(mainWorktreePath);
1064
- writeSnapshot(projectName, branchName, patch, headHash);
1065
- return patch;
1039
+ writeSnapshot(projectName, branchName, treeHash);
1040
+ return treeHash;
1066
1041
  }
1067
1042
  function handleValidateClean(options) {
1068
1043
  validateMainWorktree();
@@ -1078,26 +1053,24 @@ function handleValidateClean(options) {
1078
1053
  }
1079
1054
  function handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted) {
1080
1055
  migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
1081
- saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
1056
+ saveCurrentSnapshotTree(mainWorktreePath, projectName, branchName);
1082
1057
  printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
1083
1058
  }
1084
1059
  function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted) {
1085
- const oldPatch = readSnapshot(projectName, branchName);
1060
+ const oldTreeHash = readSnapshotTreeHash(projectName, branchName);
1086
1061
  if (!isWorkingDirClean(mainWorktreePath)) {
1087
1062
  gitResetHard(mainWorktreePath);
1088
1063
  gitCleanForce(mainWorktreePath);
1089
1064
  }
1090
1065
  migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
1091
- saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
1092
- if (oldPatch.length > 0) {
1093
- try {
1094
- gitApplyCachedFromStdin(oldPatch, mainWorktreePath);
1095
- } catch (error) {
1096
- logger.warn(`\u589E\u91CF apply \u5931\u8D25: ${error}`);
1097
- printWarning(MESSAGES.INCREMENTAL_VALIDATE_FALLBACK);
1098
- printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
1099
- return;
1100
- }
1066
+ saveCurrentSnapshotTree(mainWorktreePath, projectName, branchName);
1067
+ try {
1068
+ gitReadTree(oldTreeHash, mainWorktreePath);
1069
+ } catch (error) {
1070
+ logger.warn(`\u589E\u91CF read-tree \u5931\u8D25: ${error}`);
1071
+ printWarning(MESSAGES.INCREMENTAL_VALIDATE_FALLBACK);
1072
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
1073
+ return;
1101
1074
  }
1102
1075
  printSuccess(MESSAGES.INCREMENTAL_VALIDATE_SUCCESS(branchName));
1103
1076
  }
@@ -1121,16 +1094,7 @@ async function handleValidate(options) {
1121
1094
  printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
1122
1095
  return;
1123
1096
  }
1124
- let isIncremental = hasSnapshot(projectName, options.branch);
1125
- if (isIncremental) {
1126
- const savedHead = readSnapshotHead(projectName, options.branch);
1127
- const currentHead = getHeadCommitHash(mainWorktreePath);
1128
- if (!savedHead || savedHead !== currentHead) {
1129
- logger.info(`\u4E3B\u5206\u652F HEAD \u4E0D\u5339\u914D (${savedHead ?? "null"} \u2192 ${currentHead})\uFF0C\u6E05\u9664\u65E7\u5FEB\u7167`);
1130
- removeSnapshot(projectName, options.branch);
1131
- isIncremental = false;
1132
- }
1133
- }
1097
+ const isIncremental = hasSnapshot(projectName, options.branch);
1134
1098
  if (isIncremental) {
1135
1099
  if (!isWorkingDirClean(mainWorktreePath)) {
1136
1100
  await handleDirtyMainWorktree(mainWorktreePath);
package/docs/spec.md CHANGED
@@ -141,8 +141,7 @@ git show-ref --verify refs/heads/<branchName> 2>/dev/null
141
141
  │ └── ...
142
142
  ├── validate-snapshots/ # validate 快照目录
143
143
  │ └── <project-name>/ # 以项目名分组
144
- │ ├── <branchName>.patch # 每个分支一个 patch 快照文件
145
- │ ├── <branchName>.head # 对应的主分支 HEAD commit hash(用于增量 validate 一致性校验)
144
+ │ ├── <branchName>.tree # 每个分支一个 tree hash 快照文件(存储 git tree 对象的 hash)
146
145
  │ └── ...
147
146
  └── worktrees/ # 所有 worktree 的统一存放目录
148
147
  └── <project-name>/ # 以项目名分组
@@ -363,7 +362,7 @@ Git worktree 不会包含 `node_modules`、`.venv` 等依赖文件,每次安
363
362
 
364
363
  **快照机制:**
365
364
 
366
- validate 命令引入了**快照(snapshot)机制**来支持增量对比。每次 validate 执行成功后,会将当前全量变更保存为 patch 文件(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`),同时保存主分支 HEAD commit hash 到 `.head` 文件(用于一致性校验)。当再次执行 validate 时,先校验主分支 HEAD 是否与快照记录一致(不一致则清除旧快照降级为首次模式),然后通过对比新旧快照,将上次快照应用到暂存区、最新变更保留在工作目录,用户可通过 `git diff` 直接查看两次 validate 之间的增量差异。
365
+ 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,无需一致性校验。
367
366
 
368
367
  **运行流程:**
369
368
 
@@ -373,7 +372,7 @@ validate 命令引入了**快照(snapshot)机制**来支持增量对比。
373
372
 
374
373
  1. **主 worktree 校验** (2.1)
375
374
  2. 如果主 worktree 有未提交更改,执行 `git reset --hard` + `git clean -fd` 清空
376
- 3. 删除对应分支的 patch 快照文件
375
+ 3. 删除对应分支的快照文件
377
376
  4. 输出清理成功提示
378
377
 
379
378
  #### 首次 validate(无历史快照)
@@ -441,14 +440,13 @@ git restore --staged .
441
440
  > 此步骤结束后,目标 worktree 的代码保持原样,主 worktree 工作目录包含目标分支的全量变更。
442
441
  > 如果 patch apply 失败(目标分支与主分支差异过大),会提示用户先执行 `clawt sync -b <branchName>` 同步主分支后重试。
443
442
 
444
- ##### 步骤 4:保存纯净快照
443
+ ##### 步骤 4:保存快照为 git tree 对象
445
444
 
446
- 将主 worktree 工作目录的全量变更保存为 patch 文件,同时记录主分支 HEAD commit hash:
445
+ 将主 worktree 工作目录的全量变更保存为 git tree 对象:
447
446
 
448
447
  ```bash
449
448
  git add .
450
- git diff --cached --binary > ~/.clawt/validate-snapshots/<project>/<branchName>.patch
451
- git rev-parse HEAD > ~/.clawt/validate-snapshots/<project>/<branchName>.head
449
+ git write-tree # 返回 tree hash,写入 ~/.clawt/validate-snapshots/<project>/<branchName>.tree
452
450
  git restore --staged .
453
451
  ```
454
452
 
@@ -463,41 +461,34 @@ git restore --staged .
463
461
 
464
462
  #### 增量 validate(存在历史快照)
465
463
 
466
- 当 `~/.clawt/validate-snapshots/<project>/<branchName>.patch` 存在时,自动进入增量模式:
464
+ 当 `~/.clawt/validate-snapshots/<project>/<branchName>.tree` 存在时,自动进入增量模式:
467
465
 
468
- ##### 步骤 1:校验主分支 HEAD 一致性
466
+ ##### 步骤 1:读取旧 tree hash
469
467
 
470
- 读取 `.head` 文件中保存的主分支 HEAD hash,与当前主分支 HEAD 对比:
468
+ 在清空主 worktree 之前,读取上次保存的快照 tree hash
471
469
 
472
- - **不一致或 `.head` 文件不存在** → 清除旧快照(`.patch` + `.head`),降级为首次 validate 模式
473
- - **一致** → 继续增量流程
474
-
475
- ##### 步骤 2:读取旧 patch
476
-
477
- 在清空主 worktree 之前,读取上次保存的快照 patch 内容。
478
-
479
- ##### 步骤 3:确保主 worktree 干净
470
+ ##### 步骤 2:确保主 worktree 干净
480
471
 
481
472
  如果主 worktree 有残留状态,让用户选择处理方式(同首次 validate 步骤 1 的交互),做兜底清理。
482
473
 
483
- ##### 步骤 4:从目标分支获取最新全量变更
474
+ ##### 步骤 3:从目标分支获取最新全量变更
484
475
 
485
476
  通过 patch 方式从目标分支获取最新全量变更(流程同首次 validate 的步骤 3)。
486
477
 
487
- ##### 步骤 5:保存最新快照
478
+ ##### 步骤 4:保存最新快照为 git tree 对象
488
479
 
489
- 将最新全量变更保存为新的 patch 文件 + HEAD hash(覆盖旧快照,流程同首次 validate 的步骤 4)。
480
+ 将最新全量变更保存为新的 tree 对象(覆盖旧快照,流程同首次 validate 的步骤 4)。
490
481
 
491
- ##### 步骤 6:将旧 patch 应用到暂存区
482
+ ##### 步骤 5:将旧 tree 对象载入暂存区
492
483
 
493
484
  ```bash
494
- git apply --cached < <旧 patch 内容>
485
+ git read-tree <旧 tree hash>
495
486
  ```
496
487
 
497
- - **应用成功** → 结果:暂存区=上次快照,工作目录=最新全量变更(用户可通过 `git diff` 查看增量差异)
498
- - **应用失败**(文件结构变化过大)→ 降级为全量模式,暂存区保持为空,等同于首次 validate 的结果
488
+ - **读取成功** → 结果:暂存区=上次快照,工作目录=最新全量变更(用户可通过 `git diff` 查看增量差异)
489
+ - **读取失败**(tree 对象可能被 git gc 回收)→ 降级为全量模式,暂存区保持为空,等同于首次 validate 的结果
499
490
 
500
- ##### 步骤 7:输出成功提示
491
+ ##### 步骤 6:输出成功提示
501
492
 
502
493
  ```
503
494
  # 增量模式成功
@@ -588,7 +579,7 @@ clawt merge -b <branchName> [-m <commitMessage>]
588
579
  2. **主 worktree 状态检测**
589
580
  - 执行 `git status --porcelain`
590
581
  - 如果有更改:
591
- - 如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`),额外输出警告提示用户可先执行 `clawt validate -b <branchName> --clean` 清理
582
+ - 如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.tree`),额外输出警告提示用户可先执行 `clawt validate -b <branchName> --clean` 清理
592
583
  - 提示 `主 worktree 有未提交的更改,请先处理`,退出
593
584
  - 无更改 → 继续
594
585
  3. **Squash 检测与执行(auto-save 临时提交压缩)**
@@ -663,7 +654,7 @@ clawt merge -b <branchName> [-m <commitMessage>]
663
654
  - 输出清理成功提示:`✓ 已清理 worktree 和分支: <branchName>`
664
655
 
665
656
  10. **清理 validate 快照**
666
- - merge 成功后,如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.patch`),自动删除该快照文件(merge 成功后快照已无意义)
657
+ - merge 成功后,如果存在该分支的 validate 快照(`~/.clawt/validate-snapshots/<project>/<branchName>.tree`),自动删除该快照文件(merge 成功后快照已无意义)
667
658
 
668
659
  > **注意:** 清理确认在 merge 操作之前询问(避免 merge 成功后因交互中断而遗留未清理的 worktree),但清理操作在 merge 成功后才执行。
669
660
 
@@ -883,7 +874,7 @@ clawt sync -b <branchName>
883
874
  解决冲突后执行 git add . && git merge --continue
884
875
  ```
885
876
  - **无冲突** → 继续
886
- 7. **清除 validate 快照**:合并成功后,如果该分支存在 validate 快照(`.patch` + `.head`),自动删除(代码基础已变化,旧快照无效)
877
+ 7. **清除 validate 快照**:合并成功后,如果该分支存在 validate 快照(`.tree` 文件),自动删除(代码基础已变化,旧快照无效)
887
878
  8. **输出成功提示**:
888
879
  ```
889
880
  ✓ 已将 <mainBranch> 的最新代码同步到 <branchName>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,16 +18,14 @@ import {
18
18
  gitRestoreStaged,
19
19
  gitResetHard,
20
20
  gitCleanForce,
21
- gitDiffCachedBinary,
22
- gitApplyCachedFromStdin,
23
21
  gitDiffBinaryAgainstBranch,
24
22
  gitApplyFromStdin,
25
23
  gitResetSoft,
26
- getHeadCommitHash,
24
+ gitWriteTree,
25
+ gitReadTree,
27
26
  hasLocalCommits,
28
27
  hasSnapshot,
29
- readSnapshot,
30
- readSnapshotHead,
28
+ readSnapshotTreeHash,
31
29
  writeSnapshot,
32
30
  removeSnapshot,
33
31
  printSuccess,
@@ -137,21 +135,19 @@ function migrateChangesViaPatch(targetWorktreePath: string, mainWorktreePath: st
137
135
  }
138
136
 
139
137
  /**
140
- * 保存当前主 worktree 工作目录变更为纯净快照 patch
141
- * 操作序列:git add . → git diff --cached --binary → git restore --staged .
142
- * 同时记录主分支 HEAD hash,用于增量 validate 一致性校验
138
+ * 保存当前主 worktree 工作目录变更为 git tree 对象快照
139
+ * 操作序列:git add . → git write-tree → git restore --staged .
143
140
  * @param {string} mainWorktreePath - 主 worktree 路径
144
141
  * @param {string} projectName - 项目名
145
142
  * @param {string} branchName - 分支名
146
- * @returns {Buffer} 生成的 patch 内容
143
+ * @returns {string} 生成的 tree hash
147
144
  */
148
- function saveCurrentSnapshotPatch(mainWorktreePath: string, projectName: string, branchName: string): Buffer {
145
+ function saveCurrentSnapshotTree(mainWorktreePath: string, projectName: string, branchName: string): string {
149
146
  gitAddAll(mainWorktreePath);
150
- const patch = gitDiffCachedBinary(mainWorktreePath);
147
+ const treeHash = gitWriteTree(mainWorktreePath);
151
148
  gitRestoreStaged(mainWorktreePath);
152
- const headHash = getHeadCommitHash(mainWorktreePath);
153
- writeSnapshot(projectName, branchName, patch, headHash);
154
- return patch;
149
+ writeSnapshot(projectName, branchName, treeHash);
150
+ return treeHash;
155
151
  }
156
152
 
157
153
  /**
@@ -172,7 +168,7 @@ function handleValidateClean(options: ValidateOptions): void {
172
168
  gitCleanForce(mainWorktreePath);
173
169
  }
174
170
 
175
- // 删除对应的 patch 文件
171
+ // 删除对应的快照文件
176
172
  removeSnapshot(projectName, options.branch);
177
173
 
178
174
  printSuccess(MESSAGES.VALIDATE_CLEANED(options.branch));
@@ -190,8 +186,8 @@ function handleFirstValidate(targetWorktreePath: string, mainWorktreePath: strin
190
186
  // 通过 patch 迁移目标分支全量变更到主 worktree
191
187
  migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
192
188
 
193
- // 保存纯净快照到 patch 文件
194
- saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
189
+ // 保存快照为 git tree 对象
190
+ saveCurrentSnapshotTree(mainWorktreePath, projectName, branchName);
195
191
 
196
192
  // 结果:暂存区=空,工作目录=全量变更
197
193
  printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
@@ -206,8 +202,8 @@ function handleFirstValidate(targetWorktreePath: string, mainWorktreePath: strin
206
202
  * @param {boolean} hasUncommitted - 目标 worktree 是否有未提交修改
207
203
  */
208
204
  function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath: string, projectName: string, branchName: string, hasUncommitted: boolean): void {
209
- // 步骤 1:读取旧 patch(在清空前读取)
210
- const oldPatch = readSnapshot(projectName, branchName);
205
+ // 步骤 1:读取旧 tree hash(在清空前读取)
206
+ const oldTreeHash = readSnapshotTreeHash(projectName, branchName);
211
207
 
212
208
  // 步骤 2:确保主 worktree 干净(调用方已通过 handleDirtyMainWorktree 处理)
213
209
  // 这里做兜底清理,防止 handleDirtyMainWorktree 之后仍有残留
@@ -219,21 +215,19 @@ function handleIncrementalValidate(targetWorktreePath: string, mainWorktreePath:
219
215
  // 步骤 3:通过 patch 从目标分支获取最新全量变更
220
216
  migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
221
217
 
222
- // 步骤 4:保存最新快照
223
- saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
224
-
225
- // 步骤 5:将旧 patch 应用到暂存区
226
- if (oldPatch.length > 0) {
227
- try {
228
- gitApplyCachedFromStdin(oldPatch, mainWorktreePath);
229
- } catch (error) {
230
- // patch 无法应用(可能文件结构变化太大),降级为全量模式
231
- logger.warn(`增量 apply 失败: ${error}`);
232
- printWarning(MESSAGES.INCREMENTAL_VALIDATE_FALLBACK);
233
- // 降级后暂存区保持为空,工作目录为最新全量变更,与首次 validate 一致
234
- printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
235
- return;
236
- }
218
+ // 步骤 4:保存最新快照为 git tree 对象
219
+ saveCurrentSnapshotTree(mainWorktreePath, projectName, branchName);
220
+
221
+ // 步骤 5:将旧 tree 对象载入暂存区(恢复上次快照状态)
222
+ try {
223
+ gitReadTree(oldTreeHash, mainWorktreePath);
224
+ } catch (error) {
225
+ // tree 对象无法读取(可能被 git gc 回收),降级为全量模式
226
+ logger.warn(`增量 read-tree 失败: ${error}`);
227
+ printWarning(MESSAGES.INCREMENTAL_VALIDATE_FALLBACK);
228
+ // 降级后暂存区保持为空,工作目录为最新全量变更,与首次 validate 一致
229
+ printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
230
+ return;
237
231
  }
238
232
 
239
233
  // 结果:暂存区=上次快照,工作目录=最新全量变更
@@ -274,19 +268,8 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
274
268
  return;
275
269
  }
276
270
 
277
- // 判断是否为增量 validate
278
- let isIncremental = hasSnapshot(projectName, options.branch);
279
-
280
- // 主分支 HEAD 发生变化或旧快照无 .head 记录时,清除后走首次全量模式
281
- if (isIncremental) {
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
- }
271
+ // 判断是否为增量 validate(tree 对象不依赖主分支 HEAD,无需一致性校验)
272
+ const isIncremental = hasSnapshot(projectName, options.branch);
290
273
 
291
274
  if (isIncremental) {
292
275
  // 增量模式:主 worktree 有残留状态时让用户选择处理方式
package/src/utils/git.ts CHANGED
@@ -407,3 +407,21 @@ export function hasCommitWithMessage(branchName: string, messagePrefix: string,
407
407
  export function gitResetSoftTo(commitHash: string, cwd?: string): void {
408
408
  execCommand(`git reset --soft ${commitHash}`, { cwd });
409
409
  }
410
+
411
+ /**
412
+ * 将当前暂存区内容写入 git tree 对象并返回其 hash
413
+ * @param {string} [cwd] - 工作目录
414
+ * @returns {string} tree 对象的 hash
415
+ */
416
+ export function gitWriteTree(cwd?: string): string {
417
+ return execCommand('git write-tree', { cwd });
418
+ }
419
+
420
+ /**
421
+ * 将指定 tree 对象的内容载入暂存区(不影响工作目录)
422
+ * @param {string} treeHash - tree 对象的 hash
423
+ * @param {string} [cwd] - 工作目录
424
+ */
425
+ export function gitReadTree(treeHash: string, cwd?: string): void {
426
+ execCommand(`git read-tree ${treeHash}`, { cwd });
427
+ }
@@ -38,6 +38,8 @@ export {
38
38
  gitMergeBase,
39
39
  hasCommitWithMessage,
40
40
  gitResetSoftTo,
41
+ gitWriteTree,
42
+ gitReadTree,
41
43
  } from './git.js';
42
44
  export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
43
45
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
@@ -47,4 +49,4 @@ export { printSuccess, printError, printWarning, printInfo, printSeparator, prin
47
49
  export { ensureDir, removeEmptyDir } from './fs.js';
48
50
  export { multilineInput } from './prompt.js';
49
51
  export { launchInteractiveClaude } from './claude.js';
50
- export { getSnapshotPath, hasSnapshot, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, readSnapshotHead } from './validate-snapshot.js';
52
+ export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, writeSnapshot, removeSnapshot, removeProjectSnapshots } from './validate-snapshot.js';
@@ -8,20 +8,10 @@ import { logger } from '../logger/index.js';
8
8
  * 获取指定项目和分支的 validate 快照文件路径
9
9
  * @param {string} projectName - 项目名
10
10
  * @param {string} branchName - 分支名
11
- * @returns {string} patch 文件的绝对路径
11
+ * @returns {string} tree hash 文件的绝对路径
12
12
  */
13
13
  export function getSnapshotPath(projectName: string, branchName: string): string {
14
- return join(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.patch`);
15
- }
16
-
17
- /**
18
- * 获取指定项目和分支的快照 HEAD hash 文件路径
19
- * @param {string} projectName - 项目名
20
- * @param {string} branchName - 分支名
21
- * @returns {string} head 文件的绝对路径
22
- */
23
- function getSnapshotHeadPath(projectName: string, branchName: string): string {
24
- return join(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
14
+ return join(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
25
15
  }
26
16
 
27
17
  /**
@@ -35,33 +25,28 @@ export function hasSnapshot(projectName: string, branchName: string): boolean {
35
25
  }
36
26
 
37
27
  /**
38
- * 读取指定项目和分支的 validate 快照内容
28
+ * 读取指定项目和分支的 validate 快照中存储的 tree hash
39
29
  * @param {string} projectName - 项目名
40
30
  * @param {string} branchName - 分支名
41
- * @returns {Buffer} patch 文件内容(Buffer 格式,保留二进制完整性)
31
+ * @returns {string} tree 对象的 hash
42
32
  */
43
- export function readSnapshot(projectName: string, branchName: string): Buffer {
33
+ export function readSnapshotTreeHash(projectName: string, branchName: string): string {
44
34
  const snapshotPath = getSnapshotPath(projectName, branchName);
45
35
  logger.debug(`读取 validate 快照: ${snapshotPath}`);
46
- return readFileSync(snapshotPath);
36
+ return readFileSync(snapshotPath, 'utf-8').trim();
47
37
  }
48
38
 
49
39
  /**
50
40
  * 写入 validate 快照内容(自动创建目录)
51
41
  * @param {string} projectName - 项目名
52
42
  * @param {string} branchName - 分支名
53
- * @param {Buffer} patch - patch 内容(Buffer 格式)
54
- * @param {string} [headHash] - 主分支 HEAD commit hash(用于增量 validate 一致性校验)
43
+ * @param {string} treeHash - git tree 对象的 hash
55
44
  */
56
- export function writeSnapshot(projectName: string, branchName: string, patch: Buffer, headHash?: string): void {
45
+ export function writeSnapshot(projectName: string, branchName: string, treeHash: string): void {
57
46
  const snapshotPath = getSnapshotPath(projectName, branchName);
58
47
  const snapshotDir = join(VALIDATE_SNAPSHOTS_DIR, projectName);
59
48
  ensureDir(snapshotDir);
60
- writeFileSync(snapshotPath, patch);
61
- // 保存主分支 HEAD hash,用于下次增量 validate 时校验一致性
62
- if (headHash) {
63
- writeFileSync(getSnapshotHeadPath(projectName, branchName), headHash, 'utf-8');
64
- }
49
+ writeFileSync(snapshotPath, treeHash, 'utf-8');
65
50
  logger.info(`已保存 validate 快照: ${snapshotPath}`);
66
51
  }
67
52
 
@@ -76,25 +61,6 @@ export function removeSnapshot(projectName: string, branchName: string): void {
76
61
  unlinkSync(snapshotPath);
77
62
  logger.info(`已删除 validate 快照: ${snapshotPath}`);
78
63
  }
79
- // 同时删除对应的 .head 文件
80
- const headPath = getSnapshotHeadPath(projectName, branchName);
81
- if (existsSync(headPath)) {
82
- unlinkSync(headPath);
83
- }
84
- }
85
-
86
- /**
87
- * 读取快照保存时的主分支 HEAD commit hash
88
- * @param {string} projectName - 项目名
89
- * @param {string} branchName - 分支名
90
- * @returns {string | null} HEAD hash,不存在则返回 null
91
- */
92
- export function readSnapshotHead(projectName: string, branchName: string): string | null {
93
- const headPath = getSnapshotHeadPath(projectName, branchName);
94
- if (!existsSync(headPath)) {
95
- return null;
96
- }
97
- return readFileSync(headPath, 'utf-8').trim();
98
64
  }
99
65
 
100
66
  /**