clawt 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- 八个命令:`create`、`run`、`resume`、`list`、`remove`、`validate`、`merge`、`config`。
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`:将目标 worktree 的变更通过 git stash 迁移到主 worktree,便于在主 worktree 中测试。支持两种模式:
59
- - **首次 validate**(无历史快照):stash 迁移 → 保存纯净快照 patch → 结果:暂存区=空,工作目录=全量变更
60
- - **增量 validate**(存在历史快照):读取旧 patch → 清空主 worktree → stash 迁移最新变更 → 保存新快照 → 旧 patch 应用到暂存区 → 结果:暂存区=上次快照,工作目录=最新变更(可通过 `git diff` 查看增量差异)
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 操作、shell 执行与子进程管理、分支名处理、worktree 管理与批量清理、配置、格式化输出、交互式输入、Claude Code 交互式启动、validate 快照管理)
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 stash` 迁移到主 worktree,方便在主 worktree 中直接测试,无需重新安装依赖。
104
+ 将目标 worktree 的变更通过 `git diff`(三点 diff)迁移到主 worktree,方便在主 worktree 中直接测试,无需重新安装依赖。同时检测未提交修改和已提交 commit,确保所有变更都能被捕获。
105
105
 
106
- 支持增量模式:首次 validate 后会自动保存快照,再次 validate 同一分支时会将上次快照应用到暂存区、最新变更保留在工作目录,用户可通过 `git diff` 查看两次 validate 之间的增量差异。使用 `--clean` 可清理 validate 状态(重置主 worktree 并删除快照文件)。
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
@@ -149,11 +168,11 @@ clawt remove [options]
149
168
  # 移除当前项目下所有 worktree
150
169
  clawt remove --all
151
170
 
152
- # 移除指定分支名下的所有 worktree
171
+ # 移除指定分支名下的所有 worktree(匹配 feature-scheme 和 feature-scheme-*)
153
172
  clawt remove -b feature-scheme
154
173
 
155
- # 移除指定分支名的某一个 worktree
156
- clawt remove -b feature-scheme -i 2
174
+ # 移除单个 worktree(直接写完整分支名)
175
+ clawt remove -b feature-scheme-2
157
176
  ```
158
177
 
159
178
  移除时会询问是否同时删除对应的本地分支。
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
  }
@@ -329,15 +325,9 @@ function parseShortStat(output) {
329
325
  }
330
326
  return { insertions, deletions };
331
327
  }
332
- function getDiffStat(branchName, worktreePath, cwd) {
333
- const committedOutput = execCommand(`git diff --shortstat HEAD...${branchName}`, { cwd });
334
- const committed = parseShortStat(committedOutput);
335
- const uncommittedOutput = execCommand("git diff --shortstat HEAD", { cwd: worktreePath });
336
- const uncommitted = parseShortStat(uncommittedOutput);
337
- return {
338
- insertions: committed.insertions + uncommitted.insertions,
339
- deletions: committed.deletions + uncommitted.deletions
340
- };
328
+ function getDiffStat(worktreePath) {
329
+ const output = execCommand("git diff --shortstat HEAD", { cwd: worktreePath });
330
+ return parseShortStat(output);
341
331
  }
342
332
  function gitDiffCachedBinary(cwd) {
343
333
  logger.debug(`\u6267\u884C\u547D\u4EE4: git diff --cached --binary${cwd ? ` (cwd: ${cwd})` : ""}`);
@@ -349,6 +339,25 @@ function gitDiffCachedBinary(cwd) {
349
339
  function gitApplyCachedFromStdin(patchContent, cwd) {
350
340
  execCommandWithInput("git", ["apply", "--cached"], { input: patchContent, cwd });
351
341
  }
342
+ function getCurrentBranch(cwd) {
343
+ return execCommand("git rev-parse --abbrev-ref HEAD", { cwd });
344
+ }
345
+ function getHeadCommitHash(cwd) {
346
+ return execCommand("git rev-parse HEAD", { cwd });
347
+ }
348
+ function gitDiffBinaryAgainstBranch(branchName, cwd) {
349
+ logger.debug(`\u6267\u884C\u547D\u4EE4: git diff HEAD...${branchName} --binary${cwd ? ` (cwd: ${cwd})` : ""}`);
350
+ return execSync2(`git diff HEAD...${branchName} --binary`, {
351
+ cwd,
352
+ stdio: ["pipe", "pipe", "pipe"]
353
+ });
354
+ }
355
+ function gitApplyFromStdin(patchContent, cwd) {
356
+ execCommandWithInput("git", ["apply"], { input: patchContent, cwd });
357
+ }
358
+ function gitResetSoft(count = 1, cwd) {
359
+ execCommand(`git reset --soft HEAD~${count}`, { cwd });
360
+ }
352
361
 
353
362
  // src/utils/formatter.ts
354
363
  import chalk from "chalk";
@@ -530,7 +539,7 @@ function cleanupWorktrees(worktrees) {
530
539
  function getWorktreeStatus(worktree) {
531
540
  try {
532
541
  const commitCount = getCommitCountAhead(worktree.branch);
533
- const { insertions, deletions } = getDiffStat(worktree.branch, worktree.path);
542
+ const { insertions, deletions } = getDiffStat(worktree.path);
534
543
  const hasDirtyFiles = !isWorkingDirClean(worktree.path);
535
544
  return { commitCount, insertions, deletions, hasDirtyFiles };
536
545
  } catch (error) {
@@ -604,6 +613,9 @@ import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync
604
613
  function getSnapshotPath(projectName, branchName) {
605
614
  return join3(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.patch`);
606
615
  }
616
+ function getSnapshotHeadPath(projectName, branchName) {
617
+ return join3(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
618
+ }
607
619
  function hasSnapshot(projectName, branchName) {
608
620
  return existsSync5(getSnapshotPath(projectName, branchName));
609
621
  }
@@ -612,11 +624,14 @@ function readSnapshot(projectName, branchName) {
612
624
  logger.debug(`\u8BFB\u53D6 validate \u5FEB\u7167: ${snapshotPath}`);
613
625
  return readFileSync2(snapshotPath);
614
626
  }
615
- function writeSnapshot(projectName, branchName, patch) {
627
+ function writeSnapshot(projectName, branchName, patch, headHash) {
616
628
  const snapshotPath = getSnapshotPath(projectName, branchName);
617
629
  const snapshotDir = join3(VALIDATE_SNAPSHOTS_DIR, projectName);
618
630
  ensureDir(snapshotDir);
619
631
  writeFileSync2(snapshotPath, patch);
632
+ if (headHash) {
633
+ writeFileSync2(getSnapshotHeadPath(projectName, branchName), headHash, "utf-8");
634
+ }
620
635
  logger.info(`\u5DF2\u4FDD\u5B58 validate \u5FEB\u7167: ${snapshotPath}`);
621
636
  }
622
637
  function removeSnapshot(projectName, branchName) {
@@ -625,6 +640,17 @@ function removeSnapshot(projectName, branchName) {
625
640
  unlinkSync(snapshotPath);
626
641
  logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
627
642
  }
643
+ const headPath = getSnapshotHeadPath(projectName, branchName);
644
+ if (existsSync5(headPath)) {
645
+ unlinkSync(headPath);
646
+ }
647
+ }
648
+ function readSnapshotHead(projectName, branchName) {
649
+ const headPath = getSnapshotHeadPath(projectName, branchName);
650
+ if (!existsSync5(headPath)) {
651
+ return null;
652
+ }
653
+ return readFileSync2(headPath, "utf-8").trim();
628
654
  }
629
655
 
630
656
  // src/commands/list.ts
@@ -686,14 +712,12 @@ function handleCreate(options) {
686
712
  }
687
713
 
688
714
  // src/commands/remove.ts
689
- import { join as join4 } from "path";
690
715
  function registerRemoveCommand(program2) {
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) => {
716
+ 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\uFF08\u5B8C\u6574\u5206\u652F\u540D\u7CBE\u786E\u5339\u914D\uFF09").action(async (options) => {
692
717
  await handleRemove(options);
693
718
  });
694
719
  }
695
720
  function resolveWorktreesToRemove(options) {
696
- const projectDir = getProjectWorktreeDir();
697
721
  const allWorktrees = getProjectWorktrees();
698
722
  if (options.all) {
699
723
  return allWorktrees;
@@ -701,15 +725,6 @@ function resolveWorktreesToRemove(options) {
701
725
  if (!options.branch) {
702
726
  throw new ClawtError("\u8BF7\u6307\u5B9A --all \u6216 -b <branchName> \u53C2\u6570");
703
727
  }
704
- if (options.index !== void 0) {
705
- const targetName = `${options.branch}-${options.index}`;
706
- const targetPath = join4(projectDir, targetName);
707
- const found = allWorktrees.find((wt) => wt.path === targetPath);
708
- if (!found) {
709
- throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(targetName));
710
- }
711
- return [found];
712
- }
713
728
  const matched = allWorktrees.filter(
714
729
  (wt) => wt.branch === options.branch || wt.branch.startsWith(`${options.branch}-`)
715
730
  );
@@ -943,7 +958,7 @@ async function handleResume(options) {
943
958
  }
944
959
 
945
960
  // src/commands/validate.ts
946
- import { join as join5 } from "path";
961
+ import { join as join4 } from "path";
947
962
  import { existsSync as existsSync6 } from "fs";
948
963
  import Enquirer2 from "enquirer";
949
964
  function registerValidateCommand(program2) {
@@ -985,24 +1000,37 @@ async function handleDirtyMainWorktree(mainWorktreePath) {
985
1000
  throw new ClawtError("\u5DE5\u4F5C\u533A\u4ECD\u7136\u4E0D\u5E72\u51C0\uFF0C\u8BF7\u624B\u52A8\u5904\u7406");
986
1001
  }
987
1002
  }
988
- function migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName) {
989
- const stashMessage = `clawt:validate:${branchName}`;
990
- gitAddAll(targetWorktreePath);
991
- gitStashPush(stashMessage, targetWorktreePath);
992
- gitStashApply(targetWorktreePath);
993
- gitRestoreStaged(targetWorktreePath);
994
- const stashList = gitStashList(mainWorktreePath);
995
- const firstLine = stashList.split("\n")[0] || "";
996
- if (!firstLine.includes(stashMessage)) {
997
- throw new ClawtError(MESSAGES.STASH_CHANGED);
1003
+ function migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted) {
1004
+ let didTempCommit = false;
1005
+ try {
1006
+ if (hasUncommitted) {
1007
+ gitAddAll(targetWorktreePath);
1008
+ gitCommit("clawt:temp-commit-for-validate", targetWorktreePath);
1009
+ didTempCommit = true;
1010
+ }
1011
+ const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
1012
+ if (patch.length > 0) {
1013
+ try {
1014
+ gitApplyFromStdin(patch, mainWorktreePath);
1015
+ } catch (error) {
1016
+ logger.warn(`patch apply \u5931\u8D25: ${error}`);
1017
+ printWarning(MESSAGES.VALIDATE_PATCH_APPLY_FAILED(branchName));
1018
+ throw error;
1019
+ }
1020
+ }
1021
+ } finally {
1022
+ if (didTempCommit) {
1023
+ gitResetSoft(1, targetWorktreePath);
1024
+ gitRestoreStaged(targetWorktreePath);
1025
+ }
998
1026
  }
999
- gitStashPop(0, mainWorktreePath);
1000
1027
  }
1001
1028
  function saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName) {
1002
1029
  gitAddAll(mainWorktreePath);
1003
1030
  const patch = gitDiffCachedBinary(mainWorktreePath);
1004
1031
  gitRestoreStaged(mainWorktreePath);
1005
- writeSnapshot(projectName, branchName, patch);
1032
+ const headHash = getHeadCommitHash(mainWorktreePath);
1033
+ writeSnapshot(projectName, branchName, patch, headHash);
1006
1034
  return patch;
1007
1035
  }
1008
1036
  function handleValidateClean(options) {
@@ -1017,18 +1045,19 @@ function handleValidateClean(options) {
1017
1045
  removeSnapshot(projectName, options.branch);
1018
1046
  printSuccess(MESSAGES.VALIDATE_CLEANED(options.branch));
1019
1047
  }
1020
- function handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName) {
1021
- migrateChangesViaStash(targetWorktreePath, mainWorktreePath, branchName);
1048
+ function handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted) {
1049
+ migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
1022
1050
  saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
1023
1051
  printSuccess(MESSAGES.VALIDATE_SUCCESS(branchName));
1024
1052
  }
1025
- function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, branchName) {
1053
+ function handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, branchName, hasUncommitted) {
1026
1054
  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);
1055
+ if (!isWorkingDirClean(mainWorktreePath)) {
1056
+ gitResetHard(mainWorktreePath);
1057
+ gitCleanForce(mainWorktreePath);
1058
+ }
1059
+ migrateChangesViaPatch(targetWorktreePath, mainWorktreePath, branchName, hasUncommitted);
1060
+ saveCurrentSnapshotPatch(mainWorktreePath, projectName, branchName);
1032
1061
  if (oldPatch.length > 0) {
1033
1062
  try {
1034
1063
  gitApplyCachedFromStdin(oldPatch, mainWorktreePath);
@@ -1050,32 +1079,42 @@ async function handleValidate(options) {
1050
1079
  const projectName = getProjectName();
1051
1080
  const mainWorktreePath = getGitTopLevel();
1052
1081
  const projectDir = getProjectWorktreeDir();
1053
- const targetWorktreePath = join5(projectDir, options.branch);
1082
+ const targetWorktreePath = join4(projectDir, options.branch);
1054
1083
  logger.info(`validate \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch}`);
1055
1084
  if (!existsSync6(targetWorktreePath)) {
1056
1085
  throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
1057
1086
  }
1058
- const isIncremental = hasSnapshot(projectName, options.branch);
1087
+ const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
1088
+ const hasCommitted = hasLocalCommits(options.branch, mainWorktreePath);
1089
+ if (!hasUncommitted && !hasCommitted) {
1090
+ printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
1091
+ return;
1092
+ }
1093
+ let isIncremental = hasSnapshot(projectName, options.branch);
1059
1094
  if (isIncremental) {
1060
- if (isWorkingDirClean(targetWorktreePath)) {
1061
- printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
1062
- return;
1095
+ const savedHead = readSnapshotHead(projectName, options.branch);
1096
+ const currentHead = getHeadCommitHash(mainWorktreePath);
1097
+ if (!savedHead || savedHead !== currentHead) {
1098
+ logger.info(`\u4E3B\u5206\u652F HEAD \u4E0D\u5339\u914D (${savedHead ?? "null"} \u2192 ${currentHead})\uFF0C\u6E05\u9664\u65E7\u5FEB\u7167`);
1099
+ removeSnapshot(projectName, options.branch);
1100
+ isIncremental = false;
1063
1101
  }
1064
- handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
1065
- } else {
1102
+ }
1103
+ if (isIncremental) {
1066
1104
  if (!isWorkingDirClean(mainWorktreePath)) {
1067
1105
  await handleDirtyMainWorktree(mainWorktreePath);
1068
1106
  }
1069
- if (isWorkingDirClean(targetWorktreePath)) {
1070
- printInfo(MESSAGES.TARGET_WORKTREE_CLEAN);
1071
- return;
1107
+ handleIncrementalValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
1108
+ } else {
1109
+ if (!isWorkingDirClean(mainWorktreePath)) {
1110
+ await handleDirtyMainWorktree(mainWorktreePath);
1072
1111
  }
1073
- handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch);
1112
+ handleFirstValidate(targetWorktreePath, mainWorktreePath, projectName, options.branch, hasUncommitted);
1074
1113
  }
1075
1114
  }
1076
1115
 
1077
1116
  // src/commands/merge.ts
1078
- import { join as join6 } from "path";
1117
+ import { join as join5 } from "path";
1079
1118
  import { existsSync as existsSync7 } from "fs";
1080
1119
  function registerMergeCommand(program2) {
1081
1120
  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) => {
@@ -1098,7 +1137,7 @@ async function handleMerge(options) {
1098
1137
  validateMainWorktree();
1099
1138
  const mainWorktreePath = getGitTopLevel();
1100
1139
  const projectDir = getProjectWorktreeDir();
1101
- const targetWorktreePath = join6(projectDir, options.branch);
1140
+ const targetWorktreePath = join5(projectDir, options.branch);
1102
1141
  logger.info(`merge \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch}\uFF0C\u63D0\u4EA4\u4FE1\u606F: ${options.message ?? "(\u672A\u63D0\u4F9B)"}`);
1103
1142
  if (!existsSync7(targetWorktreePath)) {
1104
1143
  throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(options.branch));
@@ -1187,6 +1226,58 @@ function formatConfigValue(value) {
1187
1226
  return chalk3.cyan(String(value));
1188
1227
  }
1189
1228
 
1229
+ // src/commands/sync.ts
1230
+ import { existsSync as existsSync8 } from "fs";
1231
+ function registerSyncCommand(program2) {
1232
+ 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) => {
1233
+ await handleSync(options);
1234
+ });
1235
+ }
1236
+ function autoSaveChanges(worktreePath, branch) {
1237
+ gitAddAll(worktreePath);
1238
+ gitCommit("chore: auto-save before sync", worktreePath);
1239
+ printInfo(MESSAGES.SYNC_AUTO_COMMITTED(branch));
1240
+ logger.info(`\u5DF2\u81EA\u52A8\u4FDD\u5B58 ${branch} \u5206\u652F\u7684\u672A\u63D0\u4EA4\u53D8\u66F4`);
1241
+ }
1242
+ function mergeMainBranch(worktreePath, mainBranch) {
1243
+ try {
1244
+ gitMerge(mainBranch, worktreePath);
1245
+ return false;
1246
+ } catch {
1247
+ if (hasMergeConflict(worktreePath)) {
1248
+ return true;
1249
+ }
1250
+ throw new ClawtError(`\u5408\u5E76 ${mainBranch} \u5931\u8D25`);
1251
+ }
1252
+ }
1253
+ async function handleSync(options) {
1254
+ validateMainWorktree();
1255
+ const { branch } = options;
1256
+ logger.info(`sync \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${branch}`);
1257
+ const projectWorktreeDir = getProjectWorktreeDir();
1258
+ const targetWorktreePath = `${projectWorktreeDir}/${branch}`;
1259
+ if (!existsSync8(targetWorktreePath)) {
1260
+ throw new ClawtError(MESSAGES.WORKTREE_NOT_FOUND(branch));
1261
+ }
1262
+ const mainWorktreePath = getGitTopLevel();
1263
+ const mainBranch = getCurrentBranch(mainWorktreePath);
1264
+ if (!isWorkingDirClean(targetWorktreePath)) {
1265
+ autoSaveChanges(targetWorktreePath, branch);
1266
+ }
1267
+ printInfo(MESSAGES.SYNC_MERGING(branch, mainBranch));
1268
+ const hasConflict = mergeMainBranch(targetWorktreePath, mainBranch);
1269
+ if (hasConflict) {
1270
+ printWarning(MESSAGES.SYNC_CONFLICT(targetWorktreePath));
1271
+ return;
1272
+ }
1273
+ const projectName = getProjectName();
1274
+ if (hasSnapshot(projectName, branch)) {
1275
+ removeSnapshot(projectName, branch);
1276
+ logger.info(`\u5DF2\u6E05\u9664\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167`);
1277
+ }
1278
+ printSuccess(MESSAGES.SYNC_SUCCESS(branch, mainBranch));
1279
+ }
1280
+
1190
1281
  // src/index.ts
1191
1282
  var require2 = createRequire(import.meta.url);
1192
1283
  var { version } = require2("../package.json");
@@ -1201,6 +1292,7 @@ registerResumeCommand(program);
1201
1292
  registerValidateCommand(program);
1202
1293
  registerMergeCommand(program);
1203
1294
  registerConfigCommand(program);
1295
+ registerSyncCommand(program);
1204
1296
  process.on("uncaughtException", (error) => {
1205
1297
  if (error instanceof ClawtError) {
1206
1298
  printError(error.message);