clawt 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +19 -7
- package/README.md +21 -2
- package/dist/index.js +158 -49
- package/docs/spec.md +99 -43
- package/package.json +1 -1
- package/src/commands/sync.ts +116 -0
- package/src/commands/validate.ts +84 -44
- package/src/constants/messages.ts +15 -4
- package/src/index.ts +2 -0
- package/src/types/command.ts +6 -0
- package/src/types/index.ts +1 -1
- package/src/utils/git.ts +60 -0
- package/src/utils/index.ts +7 -1
- package/src/utils/validate-snapshot.ts +35 -1
package/CLAUDE.md
CHANGED
|
@@ -24,7 +24,7 @@ npm i -g . # 本地全局安装进行测试
|
|
|
24
24
|
|
|
25
25
|
每个命令为独立文件 `src/commands/<name>.ts`,导出 `registerXxxCommand(program)` 函数,在 `src/index.ts` 中统一注册到 Commander。命令内部逻辑封装在对应的 `handleXxx` 函数中。
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
九个命令:`create`、`run`、`resume`、`list`、`remove`、`validate`、`merge`、`config`、`sync`。
|
|
28
28
|
|
|
29
29
|
### 核心流程(run 命令)
|
|
30
30
|
|
|
@@ -55,19 +55,31 @@ run 命令有两种模式:
|
|
|
55
55
|
|
|
56
56
|
### validate + merge 工作流
|
|
57
57
|
|
|
58
|
-
- `validate
|
|
59
|
-
- **首次 validate**(无历史快照):
|
|
60
|
-
- **增量 validate
|
|
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` 查看增量差异)
|
|
61
61
|
- `--clean` 选项:重置主 worktree + 删除对应快照文件
|
|
62
|
-
- 快照存储路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.patch
|
|
62
|
+
- 快照存储路径:`~/.clawt/validate-snapshots/<projectName>/<branchName>.patch`(patch 文件)+ `<branchName>.head`(主分支 HEAD hash)
|
|
63
|
+
- 变更检测:同时检测目标 worktree 的未提交修改和已提交 commit,两者均无则提示无需验证
|
|
64
|
+
- 未提交修改处理:有未提交修改时先做临时 commit,diff 完成后通过 `git reset --soft` 撤销恢复原状
|
|
63
65
|
- `merge`:检测目标 worktree 状态(有修改则需 `-m` 提交,已提交则跳过,无变更则报错)→ 合并到主 worktree → pull → push → 可选清理 worktree 和分支(受 `autoDeleteBranch` 配置或交互式确认控制)→ 清理对应的 validate 快照
|
|
64
66
|
- `run` 中断清理:Ctrl+C 终止所有子进程后,根据 `autoDeleteBranch` 配置自动清理或交互式确认清理本次创建的 worktree 和分支
|
|
65
67
|
|
|
68
|
+
### sync 命令流程
|
|
69
|
+
|
|
70
|
+
1. `validateMainWorktree()` 确认在主 worktree 根目录
|
|
71
|
+
2. 检查目标 worktree 是否存在
|
|
72
|
+
3. 获取主分支名(`getCurrentBranch()`,不硬编码 main/master)
|
|
73
|
+
4. 如果目标 worktree 有未提交变更,自动 `git add . && git commit` 保存
|
|
74
|
+
5. 在目标 worktree 中执行 `git merge <mainBranch>` 合并主分支
|
|
75
|
+
6. 冲突处理:有冲突时提示用户手动解决,无冲突则输出成功
|
|
76
|
+
7. 合并成功后清除该分支的 validate 快照(代码基础已变化,旧快照无效)
|
|
77
|
+
|
|
66
78
|
### 目录层级
|
|
67
79
|
|
|
68
80
|
- `src/commands/` — 各命令的注册与处理逻辑
|
|
69
|
-
- `src/utils/` — 工具函数(git
|
|
70
|
-
- `src/constants/` — 常量定义(路径、退出码、消息模板、分支规则、配置默认值、终端控制序列、validate
|
|
81
|
+
- `src/utils/` — 工具函数(git 操作(含三点 diff、分支合并、冲突检测等)、shell 执行与子进程管理、分支名处理、worktree 管理与批量清理、配置、格式化输出、交互式输入、Claude Code 交互式启动、validate 快照管理(含 HEAD hash 一致性校验))
|
|
82
|
+
- `src/constants/` — 常量定义(路径、退出码、消息模板、分支规则、配置默认值、终端控制序列、validate 快照目录、sync 相关消息)
|
|
71
83
|
- `src/types/` — TypeScript 类型定义
|
|
72
84
|
- `src/errors/` — 自定义 `ClawtError` 错误类(携带退出码)
|
|
73
85
|
- `src/logger/` — winston 日志(按日期滚动,写入 `~/.clawt/logs/`)
|
package/README.md
CHANGED
|
@@ -101,9 +101,11 @@ clawt validate -b <branchName> [--clean]
|
|
|
101
101
|
| `-b` | 是 | 要验证的分支名 |
|
|
102
102
|
| `--clean` | 否 | 清理 validate 状态(重置主 worktree 并删除快照) |
|
|
103
103
|
|
|
104
|
-
将目标 worktree 的变更通过 `git
|
|
104
|
+
将目标 worktree 的变更通过 `git diff`(三点 diff)迁移到主 worktree,方便在主 worktree 中直接测试,无需重新安装依赖。同时检测未提交修改和已提交 commit,确保所有变更都能被捕获。
|
|
105
105
|
|
|
106
|
-
支持增量模式:首次 validate
|
|
106
|
+
支持增量模式:首次 validate 后会自动保存快照(patch + 主分支 HEAD hash),再次 validate 同一分支时会先校验主分支 HEAD 一致性(不一致则降级为首次模式),然后将上次快照应用到暂存区、最新变更保留在工作目录,用户可通过 `git diff` 查看两次 validate 之间的增量差异。使用 `--clean` 可清理 validate 状态(重置主 worktree 并删除快照文件)。
|
|
107
|
+
|
|
108
|
+
> **提示:** 如果 validate 时 patch apply 失败(目标分支与主分支差异过大),可先执行 `clawt sync -b <branchName>` 同步主分支后重试。
|
|
107
109
|
|
|
108
110
|
```bash
|
|
109
111
|
# 首次验证
|
|
@@ -116,6 +118,23 @@ clawt validate -b feature-scheme-1
|
|
|
116
118
|
clawt validate -b feature-scheme-1 --clean
|
|
117
119
|
```
|
|
118
120
|
|
|
121
|
+
### `clawt sync` — 将主分支代码同步到目标 worktree
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
clawt sync -b <branchName>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| 参数 | 必填 | 说明 |
|
|
128
|
+
| ---- | ---- | ---- |
|
|
129
|
+
| `-b` | 是 | 要同步的分支名 |
|
|
130
|
+
|
|
131
|
+
将主分支最新代码合并到目标 worktree 的分支中。如果目标 worktree 有未提交的修改,会自动保存后再合并。存在冲突时会提示用户手动解决。合并成功后会自动清除该分支的 validate 快照(代码基础已变化,旧快照无效)。
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# 将主分支最新代码同步到目标 worktree
|
|
135
|
+
clawt sync -b feature-scheme-1
|
|
136
|
+
```
|
|
137
|
+
|
|
119
138
|
### `clawt merge` — 合并分支到主 worktree
|
|
120
139
|
|
|
121
140
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -42,8 +42,6 @@ var MESSAGES = {
|
|
|
42
42
|
MAIN_WORKTREE_DIRTY: "\u4E3B worktree \u6709\u672A\u63D0\u4EA4\u7684\u66F4\u6539\uFF0C\u8BF7\u5148\u5904\u7406",
|
|
43
43
|
/** 目标 worktree 无更改 */
|
|
44
44
|
TARGET_WORKTREE_CLEAN: "\u8BE5 worktree \u7684\u5206\u652F\u4E0A\u6CA1\u6709\u4EFB\u4F55\u66F4\u6539\uFF0C\u65E0\u9700\u9A8C\u8BC1",
|
|
45
|
-
/** stash 已变更 */
|
|
46
|
-
STASH_CHANGED: "git stash list \u5DF2\u53D8\u66F4\uFF0C\u8BF7\u91CD\u65B0\u6267\u884C",
|
|
47
45
|
/** validate 成功 */
|
|
48
46
|
VALIDATE_SUCCESS: (branch) => `\u2713 \u5DF2\u5C06\u5206\u652F ${branch} \u7684\u53D8\u66F4\u5E94\u7528\u5230\u4E3B worktree
|
|
49
47
|
\u53EF\u4EE5\u5F00\u59CB\u9A8C\u8BC1\u4E86`,
|
|
@@ -90,10 +88,21 @@ var MESSAGES = {
|
|
|
90
88
|
INCREMENTAL_VALIDATE_FALLBACK: "\u589E\u91CF\u5BF9\u6BD4\u5931\u8D25\uFF0C\u5DF2\u964D\u7EA7\u4E3A\u5168\u91CF\u6A21\u5F0F",
|
|
91
89
|
/** validate 状态已清理 */
|
|
92
90
|
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
91
|
/** 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
|
|
92
|
+
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`,
|
|
93
|
+
/** sync 自动保存未提交变更 */
|
|
94
|
+
SYNC_AUTO_COMMITTED: (branch) => `\u5DF2\u81EA\u52A8\u4FDD\u5B58 ${branch} \u5206\u652F\u7684\u672A\u63D0\u4EA4\u53D8\u66F4`,
|
|
95
|
+
/** sync 开始合并 */
|
|
96
|
+
SYNC_MERGING: (targetBranch, mainBranch) => `\u6B63\u5728\u5C06 ${mainBranch} \u5408\u5E76\u5230 ${targetBranch} ...`,
|
|
97
|
+
/** sync 成功 */
|
|
98
|
+
SYNC_SUCCESS: (targetBranch, mainBranch) => `\u2713 \u5DF2\u5C06 ${mainBranch} \u7684\u6700\u65B0\u4EE3\u7801\u540C\u6B65\u5230 ${targetBranch}`,
|
|
99
|
+
/** sync 冲突 */
|
|
100
|
+
SYNC_CONFLICT: (worktreePath) => `\u5408\u5E76\u5B58\u5728\u51B2\u7A81\uFF0C\u8BF7\u8FDB\u5165\u76EE\u6807 worktree \u624B\u52A8\u89E3\u51B3\uFF1A
|
|
101
|
+
cd ${worktreePath}
|
|
102
|
+
\u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add . && git merge --continue`,
|
|
103
|
+
/** validate patch apply 失败,提示用户同步主分支 */
|
|
104
|
+
VALIDATE_PATCH_APPLY_FAILED: (branch) => `\u53D8\u66F4\u8FC1\u79FB\u5931\u8D25\uFF1A\u76EE\u6807\u5206\u652F\u4E0E\u4E3B\u5206\u652F\u5DEE\u5F02\u8FC7\u5927
|
|
105
|
+
\u8BF7\u5148\u6267\u884C clawt sync -b ${branch} \u540C\u6B65\u4E3B\u5206\u652F\u540E\u91CD\u8BD5`
|
|
97
106
|
};
|
|
98
107
|
|
|
99
108
|
// src/constants/exitCodes.ts
|
|
@@ -282,19 +291,6 @@ function gitCleanForce(cwd) {
|
|
|
282
291
|
function gitStashPush(message, cwd) {
|
|
283
292
|
execCommand(`git stash push -m "${message}"`, { cwd });
|
|
284
293
|
}
|
|
285
|
-
function gitStashApply(cwd) {
|
|
286
|
-
execCommand("git stash apply", { cwd });
|
|
287
|
-
}
|
|
288
|
-
function gitStashPop(index = 0, cwd) {
|
|
289
|
-
execCommand(`git stash pop stash@{${index}}`, { cwd });
|
|
290
|
-
}
|
|
291
|
-
function gitStashList(cwd) {
|
|
292
|
-
try {
|
|
293
|
-
return execCommand("git stash list", { cwd });
|
|
294
|
-
} catch {
|
|
295
|
-
return "";
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
294
|
function gitRestoreStaged(cwd) {
|
|
299
295
|
execCommand("git restore --staged .", { cwd });
|
|
300
296
|
}
|
|
@@ -349,6 +345,25 @@ function gitDiffCachedBinary(cwd) {
|
|
|
349
345
|
function gitApplyCachedFromStdin(patchContent, cwd) {
|
|
350
346
|
execCommandWithInput("git", ["apply", "--cached"], { input: patchContent, cwd });
|
|
351
347
|
}
|
|
348
|
+
function getCurrentBranch(cwd) {
|
|
349
|
+
return execCommand("git rev-parse --abbrev-ref HEAD", { cwd });
|
|
350
|
+
}
|
|
351
|
+
function getHeadCommitHash(cwd) {
|
|
352
|
+
return execCommand("git rev-parse HEAD", { cwd });
|
|
353
|
+
}
|
|
354
|
+
function gitDiffBinaryAgainstBranch(branchName, cwd) {
|
|
355
|
+
logger.debug(`\u6267\u884C\u547D\u4EE4: git diff HEAD...${branchName} --binary${cwd ? ` (cwd: ${cwd})` : ""}`);
|
|
356
|
+
return execSync2(`git diff HEAD...${branchName} --binary`, {
|
|
357
|
+
cwd,
|
|
358
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function gitApplyFromStdin(patchContent, cwd) {
|
|
362
|
+
execCommandWithInput("git", ["apply"], { input: patchContent, cwd });
|
|
363
|
+
}
|
|
364
|
+
function gitResetSoft(count = 1, cwd) {
|
|
365
|
+
execCommand(`git reset --soft HEAD~${count}`, { cwd });
|
|
366
|
+
}
|
|
352
367
|
|
|
353
368
|
// src/utils/formatter.ts
|
|
354
369
|
import chalk from "chalk";
|
|
@@ -604,6 +619,9 @@ import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync
|
|
|
604
619
|
function getSnapshotPath(projectName, branchName) {
|
|
605
620
|
return join3(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.patch`);
|
|
606
621
|
}
|
|
622
|
+
function getSnapshotHeadPath(projectName, branchName) {
|
|
623
|
+
return join3(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
|
|
624
|
+
}
|
|
607
625
|
function hasSnapshot(projectName, branchName) {
|
|
608
626
|
return existsSync5(getSnapshotPath(projectName, branchName));
|
|
609
627
|
}
|
|
@@ -612,11 +630,14 @@ function readSnapshot(projectName, branchName) {
|
|
|
612
630
|
logger.debug(`\u8BFB\u53D6 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
613
631
|
return readFileSync2(snapshotPath);
|
|
614
632
|
}
|
|
615
|
-
function writeSnapshot(projectName, branchName, patch) {
|
|
633
|
+
function writeSnapshot(projectName, branchName, patch, headHash) {
|
|
616
634
|
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
617
635
|
const snapshotDir = join3(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
618
636
|
ensureDir(snapshotDir);
|
|
619
637
|
writeFileSync2(snapshotPath, patch);
|
|
638
|
+
if (headHash) {
|
|
639
|
+
writeFileSync2(getSnapshotHeadPath(projectName, branchName), headHash, "utf-8");
|
|
640
|
+
}
|
|
620
641
|
logger.info(`\u5DF2\u4FDD\u5B58 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
621
642
|
}
|
|
622
643
|
function removeSnapshot(projectName, branchName) {
|
|
@@ -625,6 +646,17 @@ function removeSnapshot(projectName, branchName) {
|
|
|
625
646
|
unlinkSync(snapshotPath);
|
|
626
647
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
627
648
|
}
|
|
649
|
+
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
650
|
+
if (existsSync5(headPath)) {
|
|
651
|
+
unlinkSync(headPath);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function readSnapshotHead(projectName, branchName) {
|
|
655
|
+
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
656
|
+
if (!existsSync5(headPath)) {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
return readFileSync2(headPath, "utf-8").trim();
|
|
628
660
|
}
|
|
629
661
|
|
|
630
662
|
// src/commands/list.ts
|
|
@@ -985,24 +1017,37 @@ async function handleDirtyMainWorktree(mainWorktreePath) {
|
|
|
985
1017
|
throw new ClawtError("\u5DE5\u4F5C\u533A\u4ECD\u7136\u4E0D\u5E72\u51C0\uFF0C\u8BF7\u624B\u52A8\u5904\u7406");
|
|
986
1018
|
}
|
|
987
1019
|
}
|
|
988
|
-
function
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1020
|
+
function migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted) {
|
|
1021
|
+
let didTempCommit = false;
|
|
1022
|
+
try {
|
|
1023
|
+
if (hasUncommitted) {
|
|
1024
|
+
gitAddAll(targetWorktreePath);
|
|
1025
|
+
gitCommit("clawt:temp-commit-for-validate", targetWorktreePath);
|
|
1026
|
+
didTempCommit = true;
|
|
1027
|
+
}
|
|
1028
|
+
const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
|
|
1029
|
+
if (patch.length > 0) {
|
|
1030
|
+
try {
|
|
1031
|
+
gitApplyFromStdin(patch, mainWorktreePath);
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
logger.warn(`patch apply \u5931\u8D25: ${error}`);
|
|
1034
|
+
printWarning(MESSAGES.VALIDATE_PATCH_APPLY_FAILED(branchName));
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
} finally {
|
|
1039
|
+
if (didTempCommit) {
|
|
1040
|
+
gitResetSoft(1, targetWorktreePath);
|
|
1041
|
+
gitRestoreStaged(targetWorktreePath);
|
|
1042
|
+
}
|
|
998
1043
|
}
|
|
999
|
-
gitStashPop(0, mainWorktreePath);
|
|
1000
1044
|
}
|
|
1001
1045
|
function saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName) {
|
|
1002
1046
|
gitAddAll(mainWorktreePath);
|
|
1003
1047
|
const patch = gitDiffCachedBinary(mainWorktreePath);
|
|
1004
1048
|
gitRestoreStaged(mainWorktreePath);
|
|
1005
|
-
|
|
1049
|
+
const headHash = getHeadCommitHash(mainWorktreePath);
|
|
1050
|
+
writeSnapshot(projectName, branchName, patch, headHash);
|
|
1006
1051
|
return patch;
|
|
1007
1052
|
}
|
|
1008
1053
|
function handleValidateClean(options) {
|
|
@@ -1017,18 +1062,19 @@ function handleValidateClean(options) {
|
|
|
1017
1062
|
removeSnapshot(projectName, options.branch);
|
|
1018
1063
|
printSuccess(MESSAGES.VALIDATE_CLEANED(options.branch));
|
|
1019
1064
|
}
|
|
1020
|
-
function handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName) {
|
|
1021
|
-
|
|
1065
|
+
function handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted) {
|
|
1066
|
+
migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
|
|
1022
1067
|
saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
|
|
1023
1068
|
printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
|
|
1024
1069
|
}
|
|
1025
|
-
function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, branchName) {
|
|
1070
|
+
function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted) {
|
|
1026
1071
|
const oldPatch = readSnapshot(projectName, branchName);
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1072
|
+
if (!isWorkingDirClean(mainWorktreePath)) {
|
|
1073
|
+
gitResetHard(mainWorktreePath);
|
|
1074
|
+
gitCleanForce(mainWorktreePath);
|
|
1075
|
+
}
|
|
1076
|
+
migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
|
|
1077
|
+
saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
|
|
1032
1078
|
if (oldPatch.length > 0) {
|
|
1033
1079
|
try {
|
|
1034
1080
|
gitApplyCachedFromStdin(oldPatch, mainWorktreePath);
|
|
@@ -1055,22 +1101,32 @@ async function handleValidate(options) {
|
|
|
1055
1101
|
if (!existsSync6(targetWorktreePath)) {
|
|
1056
1102
|
throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
|
|
1057
1103
|
}
|
|
1058
|
-
const
|
|
1104
|
+
const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
|
|
1105
|
+
const hasCommitted = hasLocalCommits(options.branch, mainWorktreePath);
|
|
1106
|
+
if (!hasUncommitted && !hasCommitted) {
|
|
1107
|
+
printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
let isIncremental = hasSnapshot(projectName, options.branch);
|
|
1059
1111
|
if (isIncremental) {
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1112
|
+
const savedHead = readSnapshotHead(projectName, options.branch);
|
|
1113
|
+
const currentHead = getHeadCommitHash(mainWorktreePath);
|
|
1114
|
+
if (!savedHead || savedHead !== currentHead) {
|
|
1115
|
+
logger.info(`\u4E3B\u5206\u652F HEAD \u4E0D\u5339\u914D (${savedHead ?? "null"} \u2192 ${currentHead})\uFF0C\u6E05\u9664\u65E7\u5FEB\u7167`);
|
|
1116
|
+
removeSnapshot(projectName, options.branch);
|
|
1117
|
+
isIncremental = false;
|
|
1063
1118
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1119
|
+
}
|
|
1120
|
+
if (isIncremental) {
|
|
1066
1121
|
if (!isWorkingDirClean(mainWorktreePath)) {
|
|
1067
1122
|
await handleDirtyMainWorktree(mainWorktreePath);
|
|
1068
1123
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1124
|
+
handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
|
|
1125
|
+
} else {
|
|
1126
|
+
if (!isWorkingDirClean(mainWorktreePath)) {
|
|
1127
|
+
await handleDirtyMainWorktree(mainWorktreePath);
|
|
1072
1128
|
}
|
|
1073
|
-
handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
|
|
1129
|
+
handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
|
|
1074
1130
|
}
|
|
1075
1131
|
}
|
|
1076
1132
|
|
|
@@ -1187,6 +1243,58 @@ function formatConfigValue(value) {
|
|
|
1187
1243
|
return chalk3.cyan(String(value));
|
|
1188
1244
|
}
|
|
1189
1245
|
|
|
1246
|
+
// src/commands/sync.ts
|
|
1247
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1248
|
+
function registerSyncCommand(program2) {
|
|
1249
|
+
program2.command("sync").description("\u5C06\u4E3B\u5206\u652F\u6700\u65B0\u4EE3\u7801\u540C\u6B65\u5230\u76EE\u6807 worktree").requiredOption("-b, --branch <branchName>", "\u8981\u540C\u6B65\u7684\u5206\u652F\u540D").action(async (options) => {
|
|
1250
|
+
await handleSync(options);
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
function autoSaveChanges(worktreePath, branch) {
|
|
1254
|
+
gitAddAll(worktreePath);
|
|
1255
|
+
gitCommit("chore: auto-save before sync", worktreePath);
|
|
1256
|
+
printInfo(MESSAGES.SYNC_AUTO_COMMITTED(branch));
|
|
1257
|
+
logger.info(`\u5DF2\u81EA\u52A8\u4FDD\u5B58 ${branch} \u5206\u652F\u7684\u672A\u63D0\u4EA4\u53D8\u66F4`);
|
|
1258
|
+
}
|
|
1259
|
+
function mergeMainBranch(worktreePath, mainBranch) {
|
|
1260
|
+
try {
|
|
1261
|
+
gitMerge(mainBranch, worktreePath);
|
|
1262
|
+
return false;
|
|
1263
|
+
} catch {
|
|
1264
|
+
if (hasMergeConflict(worktreePath)) {
|
|
1265
|
+
return true;
|
|
1266
|
+
}
|
|
1267
|
+
throw new ClawtError(`\u5408\u5E76 ${mainBranch} \u5931\u8D25`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
async function handleSync(options) {
|
|
1271
|
+
validateMainWorktree();
|
|
1272
|
+
const { branch } = options;
|
|
1273
|
+
logger.info(`sync \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${branch}`);
|
|
1274
|
+
const projectWorktreeDir = getProjectWorktreeDir();
|
|
1275
|
+
const targetWorktreePath = `${projectWorktreeDir}/${branch}`;
|
|
1276
|
+
if (!existsSync8(targetWorktreePath)) {
|
|
1277
|
+
throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(branch));
|
|
1278
|
+
}
|
|
1279
|
+
const mainWorktreePath = getGitTopLevel();
|
|
1280
|
+
const mainBranch = getCurrentBranch(mainWorktreePath);
|
|
1281
|
+
if (!isWorkingDirClean(targetWorktreePath)) {
|
|
1282
|
+
autoSaveChanges(targetWorktreePath, branch);
|
|
1283
|
+
}
|
|
1284
|
+
printInfo(MESSAGES.SYNC_MERGING(branch, mainBranch));
|
|
1285
|
+
const hasConflict = mergeMainBranch(targetWorktreePath, mainBranch);
|
|
1286
|
+
if (hasConflict) {
|
|
1287
|
+
printWarning(MESSAGES.SYNC_CONFLICT(targetWorktreePath));
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const projectName = getProjectName();
|
|
1291
|
+
if (hasSnapshot(projectName, branch)) {
|
|
1292
|
+
removeSnapshot(projectName, branch);
|
|
1293
|
+
logger.info(`\u5DF2\u6E05\u9664\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167`);
|
|
1294
|
+
}
|
|
1295
|
+
printSuccess(MESSAGES.SYNC_SUCCESS(branch, mainBranch));
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1190
1298
|
// src/index.ts
|
|
1191
1299
|
var require2 = createRequire(import.meta.url);
|
|
1192
1300
|
var { version } = require2("../package.json");
|
|
@@ -1201,6 +1309,7 @@ registerResumeCommand(program);
|
|
|
1201
1309
|
registerValidateCommand(program);
|
|
1202
1310
|
registerMergeCommand(program);
|
|
1203
1311
|
registerConfigCommand(program);
|
|
1312
|
+
registerSyncCommand(program);
|
|
1204
1313
|
process.on("uncaughtException", (error) => {
|
|
1205
1314
|
if (error instanceof ClawtError) {
|
|
1206
1315
|
printError(error.message);
|
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
|
|
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
|
|
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
|
|
402
|
+
##### 步骤 2:检测目标分支变更
|
|
403
|
+
|
|
404
|
+
统一检测目标 worktree 的未提交修改和已提交 commit:
|
|
400
405
|
|
|
401
406
|
```bash
|
|
402
|
-
#
|
|
407
|
+
# 检测未提交修改
|
|
403
408
|
cd ~/.clawt/worktrees/<project>/<branchName>
|
|
404
|
-
|
|
405
|
-
# 校验目标 worktree 是否有更改
|
|
406
409
|
git status --porcelain
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
- **无更改** → 输出提示 `该 worktree 的分支上没有任何更改,无需验证`,退出
|
|
410
|
-
- **有更改** → 继续
|
|
411
410
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
git
|
|
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
|
-
|
|
416
|
+
- **两者均无** → 输出提示 `该 worktree 的分支上没有任何更改,无需验证`,退出
|
|
417
|
+
- **至少有一项** → 继续
|
|
420
418
|
|
|
421
|
-
|
|
419
|
+
##### 步骤 3:通过 patch 迁移目标分支全量变更
|
|
422
420
|
|
|
423
|
-
|
|
424
|
-
cd <主 worktree 路径>
|
|
425
|
-
git stash list
|
|
426
|
-
```
|
|
421
|
+
使用三点 diff(`git diff HEAD...branchName --binary`)获取目标分支自分叉点以来的全量变更。如果目标 worktree 有未提交修改,先做临时 commit 以便 diff 能捕获全部变更,diff 完成后撤销临时 commit 恢复原状。
|
|
427
422
|
|
|
428
|
-
|
|
423
|
+
```bash
|
|
424
|
+
# 如果有未提交修改,先临时提交
|
|
425
|
+
cd ~/.clawt/worktrees/<project>/<branchName>
|
|
426
|
+
git add .
|
|
427
|
+
git commit -m "clawt:temp-commit-for-validate"
|
|
429
428
|
|
|
430
|
-
|
|
431
|
-
|
|
429
|
+
# 在主 worktree 中执行三点 diff
|
|
430
|
+
cd <主 worktree 路径>
|
|
431
|
+
git diff HEAD...<branchName> --binary | git apply
|
|
432
432
|
|
|
433
|
-
|
|
434
|
-
|
|
433
|
+
# 撤销临时 commit,恢复目标 worktree 原状
|
|
434
|
+
cd ~/.clawt/worktrees/<project>/<branchName>
|
|
435
|
+
git reset --soft HEAD~1
|
|
436
|
+
git restore --staged .
|
|
435
437
|
```
|
|
436
438
|
|
|
437
|
-
|
|
439
|
+
> 此步骤结束后,目标 worktree 的代码保持原样,主 worktree 工作目录包含目标分支的全量变更。
|
|
440
|
+
> 如果 patch apply 失败(目标分支与主分支差异过大),会提示用户先执行 `clawt sync -b <branchName>` 同步主分支后重试。
|
|
438
441
|
|
|
439
|
-
|
|
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
|
-
##### 步骤
|
|
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
|
|
466
|
+
##### 步骤 1:校验主分支 HEAD 一致性
|
|
461
467
|
|
|
462
|
-
|
|
468
|
+
读取 `.head` 文件中保存的主分支 HEAD hash,与当前主分支 HEAD 对比:
|
|
463
469
|
|
|
464
|
-
|
|
470
|
+
- **不一致或 `.head` 文件不存在** → 清除旧快照(`.patch` + `.head`),降级为首次 validate 模式
|
|
471
|
+
- **一致** → 继续增量流程
|
|
465
472
|
|
|
466
|
-
|
|
473
|
+
##### 步骤 2:读取旧 patch
|
|
467
474
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
```
|
|
475
|
+
在清空主 worktree 之前,读取上次保存的快照 patch 内容。
|
|
476
|
+
|
|
477
|
+
##### 步骤 3:确保主 worktree 干净
|
|
472
478
|
|
|
473
|
-
|
|
479
|
+
如果主 worktree 有残留状态,让用户选择处理方式(同首次 validate 步骤 1 的交互),做兜底清理。
|
|
474
480
|
|
|
475
|
-
|
|
481
|
+
##### 步骤 4:从目标分支获取最新全量变更
|
|
476
482
|
|
|
477
|
-
|
|
483
|
+
通过 patch 方式从目标分支获取最新全量变更(流程同首次 validate 的步骤 3)。
|
|
478
484
|
|
|
479
|
-
|
|
485
|
+
##### 步骤 5:保存最新快照
|
|
480
486
|
|
|
481
|
-
|
|
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
|
-
##### 步骤
|
|
498
|
+
##### 步骤 7:输出成功提示
|
|
491
499
|
|
|
492
500
|
```
|
|
493
501
|
# 增量模式成功
|
|
@@ -819,6 +827,54 @@ clawt resume -b <branchName>
|
|
|
819
827
|
|
|
820
828
|
---
|
|
821
829
|
|
|
830
|
+
### 5.12 将主分支代码同步到目标 Worktree
|
|
831
|
+
|
|
832
|
+
**命令:**
|
|
833
|
+
|
|
834
|
+
```bash
|
|
835
|
+
clawt sync -b <branchName>
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
**参数:**
|
|
839
|
+
|
|
840
|
+
| 参数 | 必填 | 说明 |
|
|
841
|
+
| ---- | ---- | ----------------------------------------------------- |
|
|
842
|
+
| `-b` | 是 | 要同步的分支名(对应已有 worktree 的分支) |
|
|
843
|
+
|
|
844
|
+
**使用场景:**
|
|
845
|
+
|
|
846
|
+
当目标 worktree 的分支与主分支差异过大(例如主分支有了新的提交),导致 `clawt validate` 的 patch apply 失败时,可以通过 `clawt sync` 将主分支最新代码合并到目标 worktree,使其保持与主分支同步。
|
|
847
|
+
|
|
848
|
+
**运行流程:**
|
|
849
|
+
|
|
850
|
+
1. **主 worktree 校验** (2.1)
|
|
851
|
+
2. **检查目标 worktree 是否存在**:确认 `~/.clawt/worktrees/<project>/<branchName>` 目录存在
|
|
852
|
+
- 不存在 → 报错退出
|
|
853
|
+
3. **获取主分支名**:通过 `git rev-parse --abbrev-ref HEAD` 获取主 worktree 当前分支名(不硬编码 main/master)
|
|
854
|
+
4. **自动保存未提交变更**:检查目标 worktree 是否有未提交修改
|
|
855
|
+
- 有修改 → 自动执行 `git add . && git commit -m "chore: auto-save before sync"` 保存变更
|
|
856
|
+
- 无修改 → 跳过
|
|
857
|
+
5. **在目标 worktree 中合并主分支**:
|
|
858
|
+
```bash
|
|
859
|
+
cd ~/.clawt/worktrees/<project>/<branchName>
|
|
860
|
+
git merge <mainBranch>
|
|
861
|
+
```
|
|
862
|
+
6. **冲突处理**:
|
|
863
|
+
- **有冲突** → 输出警告,提示用户进入目标 worktree 手动解决:
|
|
864
|
+
```
|
|
865
|
+
合并存在冲突,请进入目标 worktree 手动解决:
|
|
866
|
+
cd ~/.clawt/worktrees/<project>/<branchName>
|
|
867
|
+
解决冲突后执行 git add . && git merge --continue
|
|
868
|
+
```
|
|
869
|
+
- **无冲突** → 继续
|
|
870
|
+
7. **清除 validate 快照**:合并成功后,如果该分支存在 validate 快照(`.patch` + `.head`),自动删除(代码基础已变化,旧快照无效)
|
|
871
|
+
8. **输出成功提示**:
|
|
872
|
+
```
|
|
873
|
+
✓ 已将 <mainBranch> 的最新代码同步到 <branchName>
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
822
878
|
## 6. 错误处理规范
|
|
823
879
|
|
|
824
880
|
### 6.1 通用错误处理
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/validate.ts
CHANGED
|
@@ -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
|
-
* 通过
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// 通过
|
|
167
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
212
|
+
// 步骤 2:确保主 worktree 干净(调用方已通过 handleDirtyMainWorktree 处理)
|
|
213
|
+
// 这里做兜底清理,防止 handleDirtyMainWorktree 之后仍有残留
|
|
214
|
+
if (!isWorkingDirClean(mainWorktreePath)) {
|
|
215
|
+
gitResetHard(mainWorktreePath);
|
|
216
|
+
gitCleanForce(mainWorktreePath);
|
|
217
|
+
}
|
|
191
218
|
|
|
192
|
-
// 步骤 3
|
|
193
|
-
|
|
219
|
+
// 步骤 3:通过 patch 从目标分支获取最新全量变更
|
|
220
|
+
migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
|
|
194
221
|
|
|
195
222
|
// 步骤 4:保存最新快照
|
|
196
|
-
|
|
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
|
-
|
|
278
|
+
let isIncremental = hasSnapshot(projectName, options.branch);
|
|
243
279
|
|
|
280
|
+
// 主分支 HEAD 发生变化或旧快照无 .head 记录时,清除后走首次全量模式
|
|
244
281
|
if (isIncremental) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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;
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { registerResumeCommand } from './commands/resume.js';
|
|
|
12
12
|
import { registerValidateCommand } from './commands/validate.js';
|
|
13
13
|
import { registerMergeCommand } from './commands/merge.js';
|
|
14
14
|
import { registerConfigCommand } from './commands/config.js';
|
|
15
|
+
import { registerSyncCommand } from './commands/sync.js';
|
|
15
16
|
|
|
16
17
|
// 从 package.json 读取版本号,避免硬编码
|
|
17
18
|
const require = createRequire(import.meta.url);
|
|
@@ -36,6 +37,7 @@ registerResumeCommand(program);
|
|
|
36
37
|
registerValidateCommand(program);
|
|
37
38
|
registerMergeCommand(program);
|
|
38
39
|
registerConfigCommand(program);
|
|
40
|
+
registerSyncCommand(program);
|
|
39
41
|
|
|
40
42
|
// 全局未捕获异常处理
|
|
41
43
|
process.on('uncaughtException', (error) => {
|
package/src/types/command.ts
CHANGED
package/src/types/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { ClawtConfig, ConfigItemDefinition, ConfigDefinitions } from './config.js';
|
|
2
|
-
export type { CreateOptions, RunOptions, ValidateOptions, MergeOptions, RemoveOptions, ResumeOptions } from './command.js';
|
|
2
|
+
export type { CreateOptions, RunOptions, ValidateOptions, MergeOptions, RemoveOptions, ResumeOptions, SyncOptions } from './command.js';
|
|
3
3
|
export type { WorktreeInfo, WorktreeStatus } from './worktree.js';
|
|
4
4
|
export type { ClaudeCodeResult } from './claudeCode.js';
|
|
5
5
|
export type { TaskResult, TaskSummary } from './taskResult.js';
|
package/src/utils/git.ts
CHANGED
|
@@ -190,6 +190,15 @@ export function gitStashPop(index: number = 0, cwd?: string): void {
|
|
|
190
190
|
execCommand(`git stash pop stash@{${index}}`, { cwd });
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* git stash drop stash@{index}
|
|
195
|
+
* @param {number} index - stash 索引
|
|
196
|
+
* @param {string} [cwd] - 工作目录
|
|
197
|
+
*/
|
|
198
|
+
export function gitStashDrop(index: number = 0, cwd?: string): void {
|
|
199
|
+
execCommand(`git stash drop stash@{${index}}`, { cwd });
|
|
200
|
+
}
|
|
201
|
+
|
|
193
202
|
/**
|
|
194
203
|
* git stash list
|
|
195
204
|
* @param {string} cwd - 工作目录
|
|
@@ -320,3 +329,54 @@ export function gitDiffCachedBinary(cwd?: string): Buffer {
|
|
|
320
329
|
export function gitApplyCachedFromStdin(patchContent: Buffer, cwd?: string): void {
|
|
321
330
|
execCommandWithInput('git', ['apply', '--cached'], { input: patchContent, cwd });
|
|
322
331
|
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 获取当前分支名
|
|
335
|
+
* @param {string} [cwd] - 工作目录
|
|
336
|
+
* @returns {string} 当前分支名
|
|
337
|
+
*/
|
|
338
|
+
export function getCurrentBranch(cwd?: string): string {
|
|
339
|
+
return execCommand('git rev-parse --abbrev-ref HEAD', { cwd });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 获取当前 HEAD 的 commit hash
|
|
344
|
+
* @param {string} [cwd] - 工作目录
|
|
345
|
+
* @returns {string} commit hash
|
|
346
|
+
*/
|
|
347
|
+
export function getHeadCommitHash(cwd?: string): string {
|
|
348
|
+
return execCommand('git rev-parse HEAD', { cwd });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 获取目标分支相对于当前分支的已提交变更(含二进制文件)
|
|
353
|
+
* 使用三点 diff(HEAD...branchName)获取自分叉点以来的变更
|
|
354
|
+
* @param {string} branchName - 目标分支名
|
|
355
|
+
* @param {string} [cwd] - 工作目录(应在主 worktree 中执行)
|
|
356
|
+
* @returns {Buffer} diff 原始输出
|
|
357
|
+
*/
|
|
358
|
+
export function gitDiffBinaryAgainstBranch(branchName: string, cwd?: string): Buffer {
|
|
359
|
+
logger.debug(`执行命令: git diff HEAD...${branchName} --binary${cwd ? ` (cwd: ${cwd})` : ''}`);
|
|
360
|
+
return execSync(`git diff HEAD...${branchName} --binary`, {
|
|
361
|
+
cwd,
|
|
362
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 将 patch 内容通过 stdin 应用到工作目录(不带 --cached)
|
|
368
|
+
* @param {Buffer} patchContent - patch 内容
|
|
369
|
+
* @param {string} [cwd] - 工作目录
|
|
370
|
+
*/
|
|
371
|
+
export function gitApplyFromStdin(patchContent: Buffer, cwd?: string): void {
|
|
372
|
+
execCommandWithInput('git', ['apply'], { input: patchContent, cwd });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* git reset --soft HEAD~<count>,撤销 commit 但保留变更在暂存区
|
|
377
|
+
* @param {number} count - 撤销的 commit 数量
|
|
378
|
+
* @param {string} [cwd] - 工作目录
|
|
379
|
+
*/
|
|
380
|
+
export function gitResetSoft(count: number = 1, cwd?: string): void {
|
|
381
|
+
execCommand(`git reset --soft HEAD~${count}`, { cwd });
|
|
382
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
gitStashPush,
|
|
21
21
|
gitStashApply,
|
|
22
22
|
gitStashPop,
|
|
23
|
+
gitStashDrop,
|
|
23
24
|
gitStashList,
|
|
24
25
|
gitRestoreStaged,
|
|
25
26
|
gitWorktreeList,
|
|
@@ -29,6 +30,11 @@ export {
|
|
|
29
30
|
getDiffStat,
|
|
30
31
|
gitDiffCachedBinary,
|
|
31
32
|
gitApplyCachedFromStdin,
|
|
33
|
+
getCurrentBranch,
|
|
34
|
+
getHeadCommitHash,
|
|
35
|
+
gitDiffBinaryAgainstBranch,
|
|
36
|
+
gitApplyFromStdin,
|
|
37
|
+
gitResetSoft,
|
|
32
38
|
} from './git.js';
|
|
33
39
|
export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
|
|
34
40
|
export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
|
|
@@ -38,4 +44,4 @@ export { printSuccess, printError, printWarning, printInfo, printSeparator, prin
|
|
|
38
44
|
export { ensureDir, removeEmptyDir } from './fs.js';
|
|
39
45
|
export { multilineInput } from './prompt.js';
|
|
40
46
|
export { launchInteractiveClaude } from './claude.js';
|
|
41
|
-
export { getSnapshotPath, hasSnapshot, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots } from './validate-snapshot.js';
|
|
47
|
+
export { getSnapshotPath, hasSnapshot, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, readSnapshotHead } from './validate-snapshot.js';
|
|
@@ -14,6 +14,16 @@ export function getSnapshotPath(projectName: string, branchName: string): string
|
|
|
14
14
|
return join(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.patch`);
|
|
15
15
|
}
|
|
16
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`);
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
/**
|
|
18
28
|
* 判断指定项目和分支是否存在 validate 快照
|
|
19
29
|
* @param {string} projectName - 项目名
|
|
@@ -41,12 +51,17 @@ export function readSnapshot(projectName: string, branchName: string): Buffer {
|
|
|
41
51
|
* @param {string} projectName - 项目名
|
|
42
52
|
* @param {string} branchName - 分支名
|
|
43
53
|
* @param {Buffer} patch - patch 内容(Buffer 格式)
|
|
54
|
+
* @param {string} [headHash] - 主分支 HEAD commit hash(用于增量 validate 一致性校验)
|
|
44
55
|
*/
|
|
45
|
-
export function writeSnapshot(projectName: string, branchName: string, patch: Buffer): void {
|
|
56
|
+
export function writeSnapshot(projectName: string, branchName: string, patch: Buffer, headHash?: string): void {
|
|
46
57
|
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
47
58
|
const snapshotDir = join(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
48
59
|
ensureDir(snapshotDir);
|
|
49
60
|
writeFileSync(snapshotPath, patch);
|
|
61
|
+
// 保存主分支 HEAD hash,用于下次增量 validate 时校验一致性
|
|
62
|
+
if (headHash) {
|
|
63
|
+
writeFileSync(getSnapshotHeadPath(projectName, branchName), headHash, 'utf-8');
|
|
64
|
+
}
|
|
50
65
|
logger.info(`已保存 validate 快照: ${snapshotPath}`);
|
|
51
66
|
}
|
|
52
67
|
|
|
@@ -61,6 +76,25 @@ export function removeSnapshot(projectName: string, branchName: string): void {
|
|
|
61
76
|
unlinkSync(snapshotPath);
|
|
62
77
|
logger.info(`已删除 validate 快照: ${snapshotPath}`);
|
|
63
78
|
}
|
|
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();
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
/**
|