clawt 3.9.0 → 3.9.2

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/README.md CHANGED
@@ -338,7 +338,7 @@ Configuration file is located at `~/.clawt/config.json`, auto-generated after in
338
338
  | `autoPullPush` | `false` | Auto pull/push after merge |
339
339
  | `confirmDestructiveOps` | `true` | Confirm before destructive operations |
340
340
  | `maxConcurrency` | `0` | Max concurrency for run command, `0` means unlimited |
341
- | `terminalApp` | `"auto"` | Terminal for batch resume: `auto` / `iterm2` / `terminal` |
341
+ | `terminalApp` | `"auto"` | Terminal for batch resume: `auto` / `iterm2` / `terminal` / `cmux` |
342
342
  | `resumeInPlace` | `false` | Resume single selection in current terminal; `false` opens in new tab |
343
343
  | `aliases` | `{}` | Command alias mapping (e.g., `{"l": "list", "r": "run"}`) |
344
344
  | `autoUpdate` | `true` | Auto-check for new versions (checks npm registry every 24 hours) |
package/dist/index.js CHANGED
@@ -545,9 +545,9 @@ var COVER_VALIDATE_MESSAGES = {
545
545
  /** 无快照,提示先执行 validate */
546
546
  COVER_VALIDATE_NO_SNAPSHOT: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167
547
547
  \u8BF7\u5148\u6267\u884C clawt validate -b ${branch} \u521B\u5EFA\u5FEB\u7167`,
548
- /** patch 应用失败 */
549
- COVER_VALIDATE_APPLY_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Apatch \u5E94\u7528\u51FA\u9519
550
- \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u5DE5\u4F5C\u533A\u72B6\u6001\u540E\u91CD\u8BD5`,
548
+ /** 覆盖失败(tree checkout/clean 失败) */
549
+ COVER_VALIDATE_COVER_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Atree checkout \u6216\u6E05\u7406\u64CD\u4F5C\u51FA\u9519
550
+ \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u72B6\u6001\u540E\u91CD\u8BD5`,
551
551
  /** 工作区和暂存区无修改,可能为误操作 */
552
552
  COVER_VALIDATE_WORKING_DIR_CLEAN: "\u5F53\u524D\u9A8C\u8BC1\u5206\u652F\u7684\u5DE5\u4F5C\u533A\u548C\u6682\u5B58\u533A\u6CA1\u6709\u4EFB\u4F55\u4FEE\u6539\uFF0C\u53EF\u80FD\u4E3A\u8BEF\u64CD\u4F5C",
553
553
  /** 覆盖成功 */
@@ -649,13 +649,6 @@ var PANEL_FOOTER_SHORTCUTS = Object.entries(SHORTCUT_LABELS).map(([key, label])
649
649
  var PANEL_FOOTER_COUNTDOWN = (seconds) => chalk.gray(`(${seconds}s \u540E\u5237\u65B0)`);
650
650
  var PANEL_OVERFLOW_DOWN_HINT = chalk.gray("\u2193 \u66F4\u591A worktree...");
651
651
  var PANEL_OVERFLOW_UP_HINT = chalk.gray("\u2191 \u66F4\u591A worktree...");
652
- var PANEL_SNAPSHOT_SUMMARY = (total, orphaned) => {
653
- const base = `\u5FEB\u7167: ${total} \u4E2A`;
654
- if (orphaned > 0) {
655
- return `${base}\uFF08${chalk.yellow(`${orphaned} \u4E2A\u5B64\u7ACB`)}\uFF09`;
656
- }
657
- return base;
658
- };
659
652
  var PANEL_NO_WORKTREES = "(\u65E0\u6D3B\u8DC3 worktree)";
660
653
  var PANEL_PRESS_ENTER_TO_RETURN = chalk.gray("\n\u6309 Enter \u8FD4\u56DE\u9762\u677F...");
661
654
  var PANEL_NOT_TTY = "\u4EA4\u4E92\u5F0F\u9762\u677F\u9700\u8981 TTY \u7EC8\u7AEF\u73AF\u5883\uFF0C\u8BF7\u76F4\u63A5\u5728\u7EC8\u7AEF\u4E2D\u8FD0\u884C clawt status -i";
@@ -1252,6 +1245,9 @@ function gitResetHard(cwd) {
1252
1245
  function gitCleanForce(cwd) {
1253
1246
  execCommand("git clean -fd", { cwd });
1254
1247
  }
1248
+ function gitCheckoutIndexForce(cwd) {
1249
+ execCommand("git checkout-index -f -a", { cwd });
1250
+ }
1255
1251
  function gitStashPush(message, cwd) {
1256
1252
  execCommand(`git stash push -m "${message}"`, { cwd });
1257
1253
  }
@@ -3758,7 +3754,7 @@ function buildPanelFrame(statusResult, selectedIndex, scrollOffset, rows, cols,
3758
3754
  const lines = [];
3759
3755
  lines.push(PANEL_TITLE(statusResult.main.projectName));
3760
3756
  lines.push(renderConfiguredBranchLine(statusResult.main));
3761
- lines.push(renderSnapshotSummary(statusResult.snapshots.total, statusResult.snapshots.orphaned));
3757
+ lines.push(renderMainBranchDiff(statusResult.main));
3762
3758
  const visibleRows = calculateVisibleRows(rows);
3763
3759
  if (statusResult.worktrees.length === 0) {
3764
3760
  lines.push(buildSeparatorWithHint(cols, ""));
@@ -3893,8 +3889,13 @@ function renderConfiguredBranchLine(main2) {
3893
3889
  }
3894
3890
  return PANEL_CONFIGURED_BRANCH(main2.configuredMainBranch);
3895
3891
  }
3896
- function renderSnapshotSummary(total, orphaned) {
3897
- return PANEL_SNAPSHOT_SUMMARY(total, orphaned);
3892
+ function renderMainBranchDiff(main2) {
3893
+ if (main2.insertions === 0 && main2.deletions === 0) {
3894
+ return `\u5DE5\u4F5C\u533A: ${chalk9.green(MESSAGES.STATUS_CHANGE_CLEAN)}`;
3895
+ }
3896
+ const insertText = chalk9.green(`+${main2.insertions}`);
3897
+ const deleteText = chalk9.red(`-${main2.deletions}`);
3898
+ return `\u5DE5\u4F5C\u533A: ${insertText} ${deleteText}`;
3898
3899
  }
3899
3900
  function renderFooter(countdown) {
3900
3901
  return `${PANEL_FOOTER_SHORTCUTS} ${PANEL_FOOTER_COUNTDOWN(countdown)}`;
@@ -5150,7 +5151,7 @@ function findTargetWorktreePath(branchName) {
5150
5151
  }
5151
5152
  return match.path;
5152
5153
  }
5153
- function computeIncrementalPatch(snapshotTreeHash, mainWorktreePath) {
5154
+ function computeWorktreeTreeHash(mainWorktreePath) {
5154
5155
  const savedIndexTreeHash = gitWriteTree(mainWorktreePath);
5155
5156
  let currentTreeHash;
5156
5157
  try {
@@ -5159,11 +5160,7 @@ function computeIncrementalPatch(snapshotTreeHash, mainWorktreePath) {
5159
5160
  } finally {
5160
5161
  gitReadTree(savedIndexTreeHash, mainWorktreePath);
5161
5162
  }
5162
- if (snapshotTreeHash === currentTreeHash) {
5163
- return null;
5164
- }
5165
- const patch = gitDiffTree(snapshotTreeHash, currentTreeHash, mainWorktreePath);
5166
- return { patch, currentTreeHash };
5163
+ return currentTreeHash;
5167
5164
  }
5168
5165
  async function handleCoverValidate() {
5169
5166
  await runPreChecks({ requireMainWorktree: true, requireHead: true, requireProjectConfig: true });
@@ -5185,18 +5182,20 @@ async function handleCoverValidate() {
5185
5182
  const confirmed = await confirmAction("\u662F\u5426\u7EE7\u7EED\u6267\u884C\u8986\u76D6\uFF1F");
5186
5183
  if (!confirmed) return;
5187
5184
  }
5188
- const result = computeIncrementalPatch(snapshotTreeHash, mainWorktreePath);
5189
- if (!result) {
5185
+ const currentTreeHash = computeWorktreeTreeHash(mainWorktreePath);
5186
+ if (snapshotTreeHash === currentTreeHash) {
5190
5187
  printInfo(MESSAGES.COVER_VALIDATE_NO_CHANGES);
5191
5188
  return;
5192
5189
  }
5193
5190
  try {
5194
- gitApplyFromStdin(result.patch, targetWorktreePath);
5191
+ gitReadTree(currentTreeHash, targetWorktreePath);
5192
+ gitCheckoutIndexForce(targetWorktreePath);
5193
+ gitCleanForce(targetWorktreePath);
5195
5194
  } catch (error) {
5196
- logger.error(`cover-validate patch apply \u5931\u8D25: ${error}`);
5197
- throw new ClawtError(MESSAGES.COVER_VALIDATE_APPLY_FAILED(targetBranchName));
5195
+ logger.error(`cover-validate \u8986\u76D6\u5931\u8D25: ${error}`);
5196
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_COVER_FAILED(targetBranchName));
5198
5197
  }
5199
- writeSnapshot(projectName, targetBranchName, result.currentTreeHash);
5198
+ writeSnapshot(projectName, targetBranchName, currentTreeHash);
5200
5199
  printSuccess(MESSAGES.COVER_VALIDATE_SUCCESS(targetBranchName));
5201
5200
  }
5202
5201
 
@@ -5478,12 +5477,15 @@ function collectStatus() {
5478
5477
  const projectConfig = loadProjectConfig();
5479
5478
  const configuredMainBranch = projectConfig?.clawtMainWorkBranch || null;
5480
5479
  const configuredBranchExists = configuredMainBranch ? checkBranchExists(configuredMainBranch) : null;
5480
+ const { insertions, deletions } = countDiffStat(process.cwd());
5481
5481
  const main2 = {
5482
5482
  branch: currentBranch,
5483
5483
  isClean,
5484
5484
  projectName,
5485
5485
  configuredMainBranch,
5486
- configuredBranchExists
5486
+ configuredBranchExists,
5487
+ insertions,
5488
+ deletions
5487
5489
  };
5488
5490
  const worktrees = getProjectWorktrees();
5489
5491
  const worktreeStatuses = worktrees.map((wt) => collectWorktreeDetailedStatus(wt, projectName));
@@ -522,9 +522,9 @@ var COVER_VALIDATE_MESSAGES = {
522
522
  /** 无快照,提示先执行 validate */
523
523
  COVER_VALIDATE_NO_SNAPSHOT: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167
524
524
  \u8BF7\u5148\u6267\u884C clawt validate -b ${branch} \u521B\u5EFA\u5FEB\u7167`,
525
- /** patch 应用失败 */
526
- COVER_VALIDATE_APPLY_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Apatch \u5E94\u7528\u51FA\u9519
527
- \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u5DE5\u4F5C\u533A\u72B6\u6001\u540E\u91CD\u8BD5`,
525
+ /** 覆盖失败(tree checkout/clean 失败) */
526
+ COVER_VALIDATE_COVER_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Atree checkout \u6216\u6E05\u7406\u64CD\u4F5C\u51FA\u9519
527
+ \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u72B6\u6001\u540E\u91CD\u8BD5`,
528
528
  /** 工作区和暂存区无修改,可能为误操作 */
529
529
  COVER_VALIDATE_WORKING_DIR_CLEAN: "\u5F53\u524D\u9A8C\u8BC1\u5206\u652F\u7684\u5DE5\u4F5C\u533A\u548C\u6682\u5B58\u533A\u6CA1\u6709\u4EFB\u4F55\u4FEE\u6539\uFF0C\u53EF\u80FD\u4E3A\u8BEF\u64CD\u4F5C",
530
530
  /** 覆盖成功 */
@@ -39,7 +39,7 @@
39
39
  | `autoPullPush` | `boolean` | `false` | merge 成功后是否自动执行 git pull 和 git push |
40
40
  | `confirmDestructiveOps` | `boolean` | `true` | 执行破坏性操作(reset、validate --clean)前是否提示确认 |
41
41
  | `maxConcurrency` | `number` | `0` | run 命令默认最大并发数,`0` 表示不限制 |
42
- | `terminalApp` | `string` | `"auto"` | 批量 resume 使用的终端应用:`auto`(自动检测)、`iterm2`、`terminal`(macOS) |
42
+ | `terminalApp` | `string` | `"auto"` | 批量 resume 使用的终端应用:`auto`(自动检测)、`iterm2`、`terminal`、`cmux`(macOS) |
43
43
  | `resumeInPlace` | `boolean` | `false` | resume 单选时是否在当前终端就地打开,`false` 则通过 `terminalApp` 在新 Tab 中打开 |
44
44
  | `aliases` | `Record<string, string>` | `{}` | 命令别名映射,键为别名,值为目标内置命令名 |
45
45
  | `autoUpdate` | `boolean` | `true` | 是否启用自动更新检查(每 24 小时通过 npm registry 检查一次新版本) |
@@ -44,20 +44,25 @@ clawt cover
44
44
 
45
45
  > 工作区干净时通常意味着用户没有在验证分支上做任何修改就执行了 cover,这大概率是误操作。增加确认提示可以避免不必要的覆盖操作。
46
46
 
47
- ##### 步骤 4:计算增量 patch
47
+ ##### 步骤 4:计算当前 tree hash
48
48
 
49
- 通过 `computeIncrementalPatch()` 计算验证分支上相对于快照的增量变更:
49
+ 通过 `computeWorktreeTreeHash()` 计算验证分支当前的完整 tree hash:
50
50
 
51
- 1. 保存当前暂存区的 tree hash(`savedIndexTreeHash`),用于后续恢复
52
- 2. `git add .` + `git write-tree` 获取当前工作区的完整 tree hash(`currentTreeHash`)
53
- 3. 通过 `git read-tree` 恢复原始暂存区状态(无论成功失败都执行,在 `finally` 块中)
54
- 4. 比较 `snapshotTreeHash` 与 `currentTreeHash`:
55
- - **相同** → 无增量变更,输出提示后返回
56
- - **不同** → 通过 `git diff-tree` 生成 patch
51
+ 1. 保存当前暂存区的 tree hash,用于后续恢复
52
+ 2. `git add .` + `git write-tree` 获取当前工作区的完整 tree hash
53
+ 3. 通过 `git read-tree` 恢复原始暂存区状态
57
54
 
58
- ##### 步骤 5:应用 patch 到目标 worktree
55
+ 比较 snapshotTreeHash currentTreeHash,如果相同则无变更,输出提示后返回。
59
56
 
60
- 将增量 patch 通过 `git apply --binary` 应用到目标 worktree 的工作区。如果 patch apply 失败,报错退出并提示用户检查目标 worktree 工作区状态。
57
+ ##### 步骤 5:直接覆盖目标 worktree
58
+
59
+ 采用 **直接 checkout tree** 方式,实现真正的覆盖语义:
60
+
61
+ 1. **写入暂存区**:通过 `git read-tree <currentTreeHash>` 将验证分支的完整 tree 写入目标 worktree 的暂存区
62
+ 2. **强制检出工作区**:通过 `git checkout-index -f -a` 将暂存区内容强制写入工作区
63
+ 3. **清理残留文件**:通过 `git clean -fd` 删除目标 worktree 中未跟踪文件
64
+
65
+ > **关键优势**:无基准依赖,无条件覆盖目标 worktree,符合 cover 的语义。
61
66
 
62
67
  ##### 步骤 6:更新快照
63
68
 
@@ -78,7 +83,7 @@ clawt cover
78
83
  | `COVER_VALIDATE_NO_SNAPSHOT` | 无快照 | 提示先执行 `clawt validate -b <branch>` 创建快照 |
79
84
  | `COVER_VALIDATE_NO_CHANGES` | 无增量变更 | 提示无需覆盖 |
80
85
  | `COVER_VALIDATE_WORKING_DIR_CLEAN` | 工作区干净 | 提示可能为误操作,需确认是否继续 |
81
- | `COVER_VALIDATE_APPLY_FAILED` | patch 应用失败 | 提示检查目标 worktree 工作区状态后重试 |
86
+ | `COVER_VALIDATE_COVER_FAILED` | tree checkout/clean 失败 | 提示检查目标 worktree 状态后重试 |
82
87
 
83
88
  **实现要点:**
84
89
 
@@ -87,7 +92,11 @@ clawt cover
87
92
  - 辅助函数:
88
93
  - `extractTargetBranchName()`:从验证分支名提取目标分支名
89
94
  - `findTargetWorktreePath()`:查找目标 worktree 路径
90
- - `computeIncrementalPatch()`:计算增量 patch
95
+ - `computeWorktreeTreeHash()`:计算当前工作区 tree hash(保存并恢复暂存区状态)
96
+ - Git 工具函数:
97
+ - `gitReadTree()`:将 tree 写入暂存区
98
+ - `gitCheckoutIndexForce()`:强制检出暂存区到工作区
99
+ - `gitCleanForce()`:清理未跟踪文件
91
100
  - 消息常量:`COVER_VALIDATE_MESSAGES`(`src/constants/messages/cover-validate.ts`)
92
101
  - `writeSnapshot` 调用时只传 `treeHash`,利用其可选参数特性保留磁盘上的 `headCommitHash` 和 `stagedTreeHash` 原值
93
102
 
package/docs/resume.md CHANGED
@@ -88,17 +88,18 @@ clawt resume -f tasks.md -c 2
88
88
 
89
89
  | 配置值 | 行为 |
90
90
  | ---------- | ------------------------------------------------------------ |
91
- | `auto` | 自动检测:优先检测 cmux 环境(通过 `CMUX_PANEL_ID` 环境变量),在 cmux 环境时在当前 pane 创建新 surface;否则检测 iTerm2 是否已安装,已安装则使用 iTerm2,否则降级到 Terminal.app |
92
- | `cmux` | 在当前 cmux pane 中创建新 surface 执行命令 |
91
+ | `auto` | 自动检测:优先检测 cmux 环境(通过 `CMUX_WORKSPACE_ID` 环境变量),在 cmux 环境时在当前 workspace 创建新 surface;否则检测 iTerm2 是否已安装,已安装则使用 iTerm2,否则降级到 Terminal.app |
92
+ | `cmux` | 在当前 cmux workspace 中创建新 surface 执行命令 |
93
93
  | `iterm2` | 强制使用 iTerm2 创建新 Tab |
94
94
  | `terminal` | 强制使用 Terminal.app 创建新 Tab |
95
95
 
96
96
  **平台限制:** 批量恢复目前仅支持 macOS 平台。非 macOS 平台会抛出错误。
97
97
 
98
98
  **cmux 集成说明:**
99
- - cmux 终端通过 `cmux new-surface --type terminal --pane <pane-id>` 创建新 surface
100
- - 创建后通过 `cmux send --surface <surface-id> <command>` 发送启动命令
101
- - 环境变量 `CMUX_PANEL_ID` 用于识别当前 pane,`CMUX_SURFACE_ID` 作为回退
99
+ - 环境检测:通过 `CMUX_WORKSPACE_ID` 环境变量判断是否在 cmux 环境中
100
+ - 创建 surface:使用 `cmux new-split right` 在右侧创建新 surface
101
+ - 执行命令:通过 `cmux send --surface <surface-id> <command>\n` 发送命令(`\n` 触发自动执行)
102
+ - 输出解析:支持简短格式 `surface:24` 和完整格式 `OK surface:24 pane:14 workspace:5`
102
103
 
103
104
  **权限要求:** Terminal.app 通过 System Events 模拟键盘操作(`Cmd+T`)新建 Tab,需要在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用。iTerm2 使用原生 AppleScript 接口,无需辅助功能权限。cmux 通过 CLI 命令操作,无需特殊权限。
104
105
 
package/docs/status.md CHANGED
@@ -131,7 +131,9 @@ clawt status [--json] [-i | --interactive]
131
131
  "isClean": true,
132
132
  "projectName": "main-project",
133
133
  "configuredMainBranch": "main",
134
- "configuredBranchExists": true
134
+ "configuredBranchExists": true,
135
+ "insertions": 185,
136
+ "deletions": 42
135
137
  },
136
138
  "worktrees": [
137
139
  {
@@ -163,10 +165,12 @@ clawt status [--json] [-i | --interactive]
163
165
  | `projectName` | `string` | 项目名 |
164
166
  | `configuredMainBranch` | `string \| null` | 配置的主工作分支名(项目未初始化时为 null) |
165
167
  | `configuredBranchExists`| `boolean \| null` | 配置的主工作分支是否存在(项目未初始化时为 null)|
168
+ | `insertions` | `number` | 工作区和暂存区的新增行数 |
169
+ | `deletions` | `number` | 工作区和暂存区的删除行数 |
166
170
 
167
171
  **实现要点:**
168
172
 
169
- - 类型定义在 `src/types/status.ts`:`WorktreeDetailedStatus`(`snapshotTime: string | null`、`createdAt: string | null`)、`MainWorktreeStatus`(包含 `configuredMainBranch` 和 `configuredBranchExists`)、`SnapshotInfo`、`SnapshotSummary`(包含 `total` 和 `orphaned`)、`StatusResult`(`snapshots` 为 `SnapshotSummary` 类型)
173
+ - 类型定义在 `src/types/status.ts`:`WorktreeDetailedStatus`(`snapshotTime: string | null`、`createdAt: string | null`)、`MainWorktreeStatus`(包含 `configuredMainBranch`、`configuredBranchExists`、`insertions`、`deletions`)、`SnapshotInfo`、`SnapshotSummary`(包含 `total` 和 `orphaned`)、`StatusResult`(`snapshots` 为 `SnapshotSummary` 类型)
170
174
  - 消息常量在 `MESSAGES.STATUS_*` 系列:
171
175
  - `STATUS_TITLE(projectName)`:标题文本
172
176
  - `STATUS_MAIN_SECTION`:主 worktree 区块标题
@@ -202,7 +206,7 @@ clawt status [--json] [-i | --interactive]
202
206
  ```
203
207
  项目状态总览: my-project
204
208
  主工作分支: main
205
- 快照: 3 个(1 个孤立)
209
+ 工作区: +185 -42
206
210
  ──────── ↑ 更多 worktree... ────────
207
211
  ════ 2026-03-01(2 天前) ════
208
212
 
@@ -239,7 +243,7 @@ clawt status [--json] [-i | --interactive]
239
243
  - 分支已删除(红色):`✗ 主工作分支: <branchName>(已不存在)`
240
244
  - 分支不一致(黄色):`⚠ 主工作分支: <branchName>(不一致)`
241
245
  - 未初始化(灰色):`未初始化(执行 clawt init 设置主工作分支)`
242
- 3. **快照摘要行**:显示快照总数和孤立快照数
246
+ 3. **工作区 diff 信息行**:显示主工作分支的工作区 diff 统计,有变更时格式为 `工作区: +N -M`(新增行数绿色,删除行数红色),无变更时显示 `工作区: 无变更`(绿色)
243
247
  4. **顶部分隔线**:当存在向上溢出时,分隔线中间嵌入 `↑ 更多 worktree...` 提示
244
248
  5. **Worktree 滚动区域**:按日期分组显示 worktree 列表,支持上下滚动
245
249
  6. **底部分隔线**:当存在向下溢出时,分隔线中间嵌入 `↓ 更多 worktree...` 提示
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.9.0",
3
+ "version": "3.9.2",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,8 +15,8 @@ import {
15
15
  gitAddAll,
16
16
  gitWriteTree,
17
17
  gitReadTree,
18
- gitDiffTree,
19
- gitApplyFromStdin,
18
+ gitCheckoutIndexForce,
19
+ gitCleanForce,
20
20
  printSuccess,
21
21
  printInfo,
22
22
  isWorkingDirClean,
@@ -62,15 +62,12 @@ export function findTargetWorktreePath(branchName: string): string {
62
62
  }
63
63
 
64
64
  /**
65
- * 计算验证分支上相对于快照的增量 patch
65
+ * 计算验证分支当前的 tree hash(保存并恢复暂存区状态)
66
66
  * 操作序列:git write-tree(保存暂存区)→ git add . → git write-tree(获取工作区 tree)→ git read-tree(恢复暂存区)
67
- * 当 snapshotTreeHash 与当前 tree hash 相同时返回 null 表示无变更
68
- * @param {string} snapshotTreeHash - 快照中记录的 tree hash
69
67
  * @param {string} mainWorktreePath - 主 worktree 路径
70
- * @returns {{ patch: Buffer; currentTreeHash: string } | null} 增量 patch 和当前 tree hash,无变更时返回 null
68
+ * @returns {string} 当前工作区对应的 tree hash
71
69
  */
72
- export function computeIncrementalPatch(snapshotTreeHash: string, mainWorktreePath: string): { patch: Buffer; currentTreeHash: string } | null {
73
- // 先保存当前暂存区的 tree hash,用于后续恢复
70
+ export function computeWorktreeTreeHash(mainWorktreePath: string): string {
74
71
  const savedIndexTreeHash = gitWriteTree(mainWorktreePath);
75
72
  let currentTreeHash: string;
76
73
 
@@ -78,17 +75,10 @@ export function computeIncrementalPatch(snapshotTreeHash: string, mainWorktreePa
78
75
  gitAddAll(mainWorktreePath);
79
76
  currentTreeHash = gitWriteTree(mainWorktreePath);
80
77
  } finally {
81
- // 无论成功或失败,都通过 git read-tree 恢复原始暂存区状态
82
78
  gitReadTree(savedIndexTreeHash, mainWorktreePath);
83
79
  }
84
80
 
85
- // 快照 tree 与当前 tree 相同,无增量变更
86
- if (snapshotTreeHash === currentTreeHash) {
87
- return null;
88
- }
89
-
90
- const patch = gitDiffTree(snapshotTreeHash, currentTreeHash, mainWorktreePath);
91
- return { patch, currentTreeHash };
81
+ return currentTreeHash;
92
82
  }
93
83
 
94
84
  /**
@@ -127,23 +117,26 @@ async function handleCoverValidate(): Promise<void> {
127
117
  if (!confirmed) return;
128
118
  }
129
119
 
130
- // 步骤 4:计算增量 patch
131
- const result = computeIncrementalPatch(snapshotTreeHash, mainWorktreePath);
132
- if (!result) {
120
+ // 步骤 4:计算当前 tree hash
121
+ const currentTreeHash = computeWorktreeTreeHash(mainWorktreePath);
122
+
123
+ if (snapshotTreeHash === currentTreeHash) {
133
124
  printInfo(MESSAGES.COVER_VALIDATE_NO_CHANGES);
134
125
  return;
135
126
  }
136
127
 
137
- // 步骤 5:应用 patch 到目标 worktree
128
+ // 步骤 5:直接将 tree 应用到目标 worktree
138
129
  try {
139
- gitApplyFromStdin(result.patch, targetWorktreePath);
130
+ gitReadTree(currentTreeHash, targetWorktreePath);
131
+ gitCheckoutIndexForce(targetWorktreePath);
132
+ gitCleanForce(targetWorktreePath);
140
133
  } catch (error) {
141
- logger.error(`cover-validate patch apply 失败: ${error}`);
142
- throw new ClawtError(MESSAGES.COVER_VALIDATE_APPLY_FAILED(targetBranchName));
134
+ logger.error(`cover-validate 覆盖失败: ${error}`);
135
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_COVER_FAILED(targetBranchName));
143
136
  }
144
137
 
145
138
  // 步骤 6:更新快照 treeHash(使后续再次 cover 的基准正确),HEAD 和 stagedTreeHash 不变
146
- writeSnapshot(projectName, targetBranchName, result.currentTreeHash);
139
+ writeSnapshot(projectName, targetBranchName, currentTreeHash);
147
140
 
148
141
  printSuccess(MESSAGES.COVER_VALIDATE_SUCCESS(targetBranchName));
149
142
  }
@@ -81,6 +81,9 @@ export function collectStatus(): StatusResult {
81
81
  const configuredMainBranch = projectConfig?.clawtMainWorkBranch || null;
82
82
  const configuredBranchExists = configuredMainBranch ? checkBranchExists(configuredMainBranch) : null;
83
83
 
84
+ // 主 worktree 的 diff 统计
85
+ const { insertions, deletions } = countDiffStat(process.cwd());
86
+
84
87
  // 主 worktree 状态
85
88
  const main: MainWorktreeStatus = {
86
89
  branch: currentBranch,
@@ -88,6 +91,8 @@ export function collectStatus(): StatusResult {
88
91
  projectName,
89
92
  configuredMainBranch,
90
93
  configuredBranchExists,
94
+ insertions,
95
+ deletions,
91
96
  };
92
97
 
93
98
  // 各 worktree 详细状态
@@ -10,9 +10,9 @@ export const COVER_VALIDATE_MESSAGES = {
10
10
  /** 无快照,提示先执行 validate */
11
11
  COVER_VALIDATE_NO_SNAPSHOT: (branch: string) =>
12
12
  `未找到分支 ${branch} 的 validate 快照\n 请先执行 clawt validate -b ${branch} 创建快照`,
13
- /** patch 应用失败 */
14
- COVER_VALIDATE_APPLY_FAILED: (branch: string) =>
15
- `覆盖变更到 worktree ${branch} 失败:patch 应用出错\n 请检查目标 worktree 工作区状态后重试`,
13
+ /** 覆盖失败(tree checkout/clean 失败) */
14
+ COVER_VALIDATE_COVER_FAILED: (branch: string) =>
15
+ `覆盖变更到 worktree ${branch} 失败:tree checkout 或清理操作出错\n 请检查目标 worktree 状态后重试`,
16
16
  /** 工作区和暂存区无修改,可能为误操作 */
17
17
  COVER_VALIDATE_WORKING_DIR_CLEAN: '当前验证分支的工作区和暂存区没有任何修改,可能为误操作',
18
18
  /** 覆盖成功 */
@@ -32,6 +32,10 @@ export interface MainWorktreeStatus {
32
32
  configuredMainBranch: string | null;
33
33
  /** 配置的主工作分支是否存在(项目未初始化时为 null) */
34
34
  configuredBranchExists: boolean | null;
35
+ /** 工作区和暂存区的新增行数 */
36
+ insertions: number;
37
+ /** 工作区和暂存区的删除行数 */
38
+ deletions: number;
35
39
  }
36
40
 
37
41
  /** validate 快照信息 */
@@ -159,6 +159,14 @@ export function gitCleanForce(cwd?: string): void {
159
159
  execCommand('git clean -fd', { cwd });
160
160
  }
161
161
 
162
+ /**
163
+ * git checkout-index -f -a,将暂存区内容强制写入工作区(覆盖工作区文件)
164
+ * @param {string} [cwd] - 工作目录
165
+ */
166
+ export function gitCheckoutIndexForce(cwd?: string): void {
167
+ execCommand('git checkout-index -f -a', { cwd });
168
+ }
169
+
162
170
  /**
163
171
  * git stash push -m <message>
164
172
  * @param {string} message - stash 消息
@@ -22,6 +22,7 @@ export {
22
22
  gitPush,
23
23
  gitResetHard,
24
24
  gitCleanForce,
25
+ gitCheckoutIndexForce,
25
26
  gitStashPush,
26
27
  gitStashApply,
27
28
  gitStashPop,
@@ -87,8 +87,8 @@ export function buildPanelFrame(
87
87
  // 配置分支信息行
88
88
  lines.push(renderConfiguredBranchLine(statusResult.main));
89
89
 
90
- // 快照摘要行
91
- lines.push(renderSnapshotSummary(statusResult.snapshots.total, statusResult.snapshots.orphaned));
90
+ // 主工作分支 diff 信息行
91
+ lines.push(renderMainBranchDiff(statusResult.main));
92
92
 
93
93
  // 计算可用的 worktree 显示区域行数
94
94
  const visibleRows = calculateVisibleRows(rows);
@@ -315,6 +315,21 @@ function renderConfiguredBranchLine(main: MainWorktreeStatus): string {
315
315
  return PANEL_CONFIGURED_BRANCH(main.configuredMainBranch);
316
316
  }
317
317
 
318
+ /**
319
+ * 渲染主工作分支的 diff 信息行
320
+ * 格式:工作区: +N -M(绿色新增,红色删除)或 工作区: 无变更(绿色)
321
+ * @param {MainWorktreeStatus} main - 主 worktree 状态
322
+ * @returns {string} 格式化的 diff 信息
323
+ */
324
+ function renderMainBranchDiff(main: MainWorktreeStatus): string {
325
+ if (main.insertions === 0 && main.deletions === 0) {
326
+ return `工作区: ${chalk.green(MESSAGES.STATUS_CHANGE_CLEAN)}`;
327
+ }
328
+ const insertText = chalk.green(`+${main.insertions}`);
329
+ const deleteText = chalk.red(`-${main.deletions}`);
330
+ return `工作区: ${insertText} ${deleteText}`;
331
+ }
332
+
318
333
  /**
319
334
  * 渲染快照摘要行
320
335
  * @param {number} total - 快照总数
@@ -21,7 +21,7 @@ vi.mock('../../../src/constants/index.js', () => ({
21
21
  COVER_VALIDATE_NO_CHANGES: '验证分支上没有相对于快照的增量修改,无需覆盖',
22
22
  COVER_VALIDATE_TARGET_NOT_FOUND: (branch: string) => `未找到分支 ${branch} 对应的 worktree`,
23
23
  COVER_VALIDATE_NO_SNAPSHOT: (branch: string) => `未找到分支 ${branch} 的 validate 快照`,
24
- COVER_VALIDATE_APPLY_FAILED: (branch: string) => `覆盖变更到 worktree ${branch} 失败`,
24
+ COVER_VALIDATE_COVER_FAILED: (branch: string) => `覆盖变更到 worktree ${branch} 失败`,
25
25
  COVER_VALIDATE_SUCCESS: (branch: string) => `✓ 已将验证分支上的修改覆盖到 worktree => ${branch}`,
26
26
  COVER_VALIDATE_WORKING_DIR_CLEAN: '当前验证分支的工作区和暂存区没有任何修改,可能为误操作',
27
27
  },
@@ -42,8 +42,8 @@ vi.mock('../../../src/utils/index.js', () => ({
42
42
  gitAddAll: vi.fn(),
43
43
  gitWriteTree: vi.fn().mockReturnValue('current-tree-hash'),
44
44
  gitReadTree: vi.fn(),
45
- gitDiffTree: vi.fn().mockReturnValue(Buffer.from('fake-patch')),
46
- gitApplyFromStdin: vi.fn(),
45
+ gitCheckoutIndexForce: vi.fn(),
46
+ gitCleanForce: vi.fn(),
47
47
  printSuccess: vi.fn(),
48
48
  printInfo: vi.fn(),
49
49
  isWorkingDirClean: vi.fn().mockReturnValue(false),
@@ -53,7 +53,7 @@ vi.mock('../../../src/utils/index.js', () => ({
53
53
  isNonInteractive: vi.fn().mockReturnValue(false),
54
54
  }));
55
55
 
56
- import { registerCoverValidateCommand, extractTargetBranchName, findTargetWorktreePath, computeIncrementalPatch } from '../../../src/commands/cover-validate.js';
56
+ import { registerCoverValidateCommand, extractTargetBranchName, findTargetWorktreePath, computeWorktreeTreeHash } from '../../../src/commands/cover-validate.js';
57
57
  import {
58
58
  getCurrentBranch,
59
59
  getProjectWorktrees,
@@ -64,8 +64,8 @@ import {
64
64
  gitAddAll,
65
65
  gitWriteTree,
66
66
  gitReadTree,
67
- gitDiffTree,
68
- gitApplyFromStdin,
67
+ gitCheckoutIndexForce,
68
+ gitCleanForce,
69
69
  printSuccess,
70
70
  printInfo,
71
71
  isWorkingDirClean,
@@ -81,8 +81,8 @@ const mockedWriteSnapshot = vi.mocked(writeSnapshot);
81
81
  const mockedGitAddAll = vi.mocked(gitAddAll);
82
82
  const mockedGitWriteTree = vi.mocked(gitWriteTree);
83
83
  const mockedGitReadTree = vi.mocked(gitReadTree);
84
- const mockedGitDiffTree = vi.mocked(gitDiffTree);
85
- const mockedGitApplyFromStdin = vi.mocked(gitApplyFromStdin);
84
+ const mockedGitCheckoutIndexForce = vi.mocked(gitCheckoutIndexForce);
85
+ const mockedGitCleanForce = vi.mocked(gitCleanForce);
86
86
  const mockedPrintSuccess = vi.mocked(printSuccess);
87
87
  const mockedPrintInfo = vi.mocked(printInfo);
88
88
  const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
@@ -99,7 +99,6 @@ beforeEach(() => {
99
99
  mockedIsWorkingDirClean.mockReturnValue(false);
100
100
  mockedConfirmAction.mockResolvedValue(true);
101
101
  mockedGitWriteTree.mockReturnValue('current-tree-hash');
102
- mockedGitDiffTree.mockReturnValue(Buffer.from('fake-patch'));
103
102
  });
104
103
 
105
104
  describe('registerCoverValidateCommand', () => {
@@ -130,27 +129,16 @@ describe('findTargetWorktreePath', () => {
130
129
  });
131
130
  });
132
131
 
133
- describe('computeIncrementalPatch', () => {
134
- it('有增量变更时返回 patch 和 currentTreeHash', () => {
132
+ describe('computeWorktreeTreeHash', () => {
133
+ it('返回当前工作区的 tree hash', () => {
135
134
  mockedGitWriteTree
136
135
  .mockReturnValueOnce('saved-index-tree') // 保存暂存区
137
136
  .mockReturnValueOnce('new-tree-hash'); // git add . 后的 tree
138
137
 
139
- const result = computeIncrementalPatch('snapshot-tree-hash', '/repo');
140
- expect(result).not.toBeNull();
141
- expect(result!.currentTreeHash).toBe('new-tree-hash');
138
+ const result = computeWorktreeTreeHash('/repo');
139
+ expect(result).toBe('new-tree-hash');
142
140
  expect(mockedGitAddAll).toHaveBeenCalledWith('/repo');
143
141
  expect(mockedGitReadTree).toHaveBeenCalledWith('saved-index-tree', '/repo');
144
- expect(mockedGitDiffTree).toHaveBeenCalledWith('snapshot-tree-hash', 'new-tree-hash', '/repo');
145
- });
146
-
147
- it('无增量变更时返回 null', () => {
148
- mockedGitWriteTree
149
- .mockReturnValueOnce('saved-index-tree')
150
- .mockReturnValueOnce('snapshot-tree-hash'); // 与快照相同
151
-
152
- const result = computeIncrementalPatch('snapshot-tree-hash', '/repo');
153
- expect(result).toBeNull();
154
142
  });
155
143
  });
156
144
 
@@ -163,7 +151,7 @@ describe('handleCoverValidate - 工作区干净检查', () => {
163
151
  await program.parseAsync(['cover'], { from: 'user' });
164
152
  }
165
153
 
166
- it('工作区干净且用户取消时不执行 patch', async () => {
154
+ it('工作区干净且用户取消时不执行覆盖', async () => {
167
155
  mockedIsWorkingDirClean.mockReturnValue(true);
168
156
  mockedConfirmAction.mockResolvedValue(false);
169
157
 
@@ -173,14 +161,14 @@ describe('handleCoverValidate - 工作区干净检查', () => {
173
161
  expect(mockedConfirmAction).toHaveBeenCalledWith('是否继续执行覆盖?');
174
162
  // 用户取消后不应继续执行后续逻辑
175
163
  expect(mockedGitAddAll).not.toHaveBeenCalled();
176
- expect(mockedGitApplyFromStdin).not.toHaveBeenCalled();
164
+ expect(mockedGitCheckoutIndexForce).not.toHaveBeenCalled();
165
+ expect(mockedGitCleanForce).not.toHaveBeenCalled();
177
166
  expect(mockedPrintSuccess).not.toHaveBeenCalled();
178
167
  });
179
168
 
180
169
  it('工作区干净且用户确认继续时正常执行', async () => {
181
170
  mockedIsWorkingDirClean.mockReturnValue(true);
182
171
  mockedConfirmAction.mockResolvedValue(true);
183
- // computeIncrementalPatch 返回有 patch 的结果
184
172
  mockedGitWriteTree
185
173
  .mockReturnValueOnce('saved-index-tree')
186
174
  .mockReturnValueOnce('new-tree-hash');
@@ -188,7 +176,9 @@ describe('handleCoverValidate - 工作区干净检查', () => {
188
176
  await runCover();
189
177
 
190
178
  expect(mockedConfirmAction).toHaveBeenCalledWith('是否继续执行覆盖?');
191
- expect(mockedGitApplyFromStdin).toHaveBeenCalled();
179
+ expect(mockedGitReadTree).toHaveBeenCalledWith('new-tree-hash', '/path/feature');
180
+ expect(mockedGitCheckoutIndexForce).toHaveBeenCalledWith('/path/feature');
181
+ expect(mockedGitCleanForce).toHaveBeenCalledWith('/path/feature');
192
182
  expect(mockedPrintSuccess).toHaveBeenCalled();
193
183
  });
194
184
 
@@ -201,7 +191,9 @@ describe('handleCoverValidate - 工作区干净检查', () => {
201
191
  await runCover();
202
192
 
203
193
  expect(mockedConfirmAction).not.toHaveBeenCalled();
204
- expect(mockedGitApplyFromStdin).toHaveBeenCalled();
194
+ expect(mockedGitReadTree).toHaveBeenCalledWith('new-tree-hash', '/path/feature');
195
+ expect(mockedGitCheckoutIndexForce).toHaveBeenCalledWith('/path/feature');
196
+ expect(mockedGitCleanForce).toHaveBeenCalledWith('/path/feature');
205
197
  expect(mockedPrintSuccess).toHaveBeenCalled();
206
198
  });
207
199
  });
@@ -340,4 +340,41 @@ describe('handleStatus', () => {
340
340
  const unverifiedLine = printedLines.find((line) => line.includes('未验证'));
341
341
  expect(unverifiedLine).toBeUndefined();
342
342
  });
343
+
344
+ it('主 worktree diff 统计包含在 JSON 输出中', async () => {
345
+ // 模拟主 worktree 有变更
346
+ mockedGetDiffStat.mockReturnValue({ insertions: 185, deletions: 42 });
347
+
348
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
349
+
350
+ const program = new Command();
351
+ program.exitOverride();
352
+ registerStatusCommand(program);
353
+ await program.parseAsync(['status', '--json'], { from: 'user' });
354
+
355
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
356
+ try { JSON.parse(call[0]); return true; } catch { return false; }
357
+ });
358
+ const parsed = JSON.parse(jsonCall![0]);
359
+ expect(parsed.main.insertions).toBe(185);
360
+ expect(parsed.main.deletions).toBe(42);
361
+ });
362
+
363
+ it('主 worktree 无变更时 diff 统计为 0', async () => {
364
+ mockedGetDiffStat.mockReturnValue({ insertions: 0, deletions: 0 });
365
+
366
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
367
+
368
+ const program = new Command();
369
+ program.exitOverride();
370
+ registerStatusCommand(program);
371
+ await program.parseAsync(['status', '--json'], { from: 'user' });
372
+
373
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
374
+ try { JSON.parse(call[0]); return true; } catch { return false; }
375
+ });
376
+ const parsed = JSON.parse(jsonCall![0]);
377
+ expect(parsed.main.insertions).toBe(0);
378
+ expect(parsed.main.deletions).toBe(0);
379
+ });
343
380
  });
@@ -36,6 +36,7 @@ import {
36
36
  gitPush,
37
37
  gitResetHard,
38
38
  gitCleanForce,
39
+ gitCheckoutIndexForce,
39
40
  gitStashPush,
40
41
  gitStashApply,
41
42
  gitStashPop,
@@ -251,6 +252,13 @@ describe('gitCleanForce', () => {
251
252
  });
252
253
  });
253
254
 
255
+ describe('gitCheckoutIndexForce', () => {
256
+ it('执行 git checkout-index -f -a', () => {
257
+ gitCheckoutIndexForce('/repo');
258
+ expect(mockedExecCommand).toHaveBeenCalledWith('git checkout-index -f -a', { cwd: '/repo' });
259
+ });
260
+ });
261
+
254
262
  describe('gitStashPush', () => {
255
263
  it('执行 git stash push -m', () => {
256
264
  gitStashPush('auto-stash', '/repo');