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