clawt 3.8.1 → 3.8.3

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
@@ -186,11 +186,13 @@ clawt sync -b <branch>
186
186
  ### `clawt merge` — 合并分支到主 worktree
187
187
 
188
188
  ```bash
189
- clawt merge -b <branch> -m "feat: 提交信息" # 有未提交修改时需要 -m
190
- clawt merge -b <branch> # 已提交过可省略 -m
189
+ clawt merge -b <branch> -m "feat: 提交信息" # 通过 -m 直接指定提交信息
190
+ clawt merge -b <branch> # 交互模式下有未提交修改时会询问输入提交信息
191
191
  clawt merge -b <branch> --auto # 遇到冲突直接调用 AI 解决
192
192
  ```
193
193
 
194
+ 交互模式下,如果目标 worktree 有未提交修改或 squash 后需要提交,会自动询问输入提交信息,无需提前指定 `-m`。非交互模式(`-y` / CI)下未提供 `-m` 时会提示退出。
195
+
194
196
  ### `clawt remove` — 移除 worktree
195
197
 
196
198
  ```bash
@@ -339,7 +341,7 @@ clawt alias remove l
339
341
  | `aliases` | `{}` | 命令别名映射(如 `{"l": "list", "r": "run"}`) |
340
342
  | `autoUpdate` | `true` | 自动检查新版本(每 24 小时检查一次 npm registry) |
341
343
  | `conflictResolveMode` | `"ask"` | merge 冲突时的解决模式:`ask`(询问是否使用 AI)、`auto`(自动 AI 解决)、`manual`(手动解决) |
342
- | `conflictResolveTimeoutMs` | `300000` | Claude Code 冲突解决超时时间(毫秒),默认 5 分钟 |
344
+ | `conflictResolveTimeoutMs` | `600000` | Claude Code 冲突解决超时时间(毫秒),默认 10 分钟 |
343
345
 
344
346
  ## postCreate Hook
345
347
 
package/dist/index.js CHANGED
@@ -188,7 +188,11 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
188
188
  \u8BF7\u624B\u52A8\u5904\u7406\uFF1A
189
189
  \u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add . && git merge --continue`,
190
190
  /** --auto 模式下的冲突手动解决(配置为 manual) */
191
- MERGE_CONFLICT_MANUAL: "\u5408\u5E76\u5B58\u5728\u51B2\u7A81\uFF0C\u8BF7\u624B\u52A8\u5904\u7406\uFF1A\n \u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add . && git merge --continue"
191
+ MERGE_CONFLICT_MANUAL: "\u5408\u5E76\u5B58\u5728\u51B2\u7A81\uFF0C\u8BF7\u624B\u52A8\u5904\u7406\uFF1A\n \u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add . && git merge --continue",
192
+ /** 目标 worktree 有未提交修改时的交互式提交信息提示 */
193
+ MERGE_PROMPT_COMMIT_MESSAGE: "\u76EE\u6807 worktree \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u8F93\u5165\u63D0\u4EA4\u4FE1\u606F",
194
+ /** squash 后的交互式提交信息提示 */
195
+ MERGE_SQUASH_PROMPT_COMMIT_MESSAGE: "\u8BF7\u8F93\u5165 squash \u540E\u7684\u63D0\u4EA4\u4FE1\u606F"
192
196
  };
193
197
 
194
198
  // src/constants/messages/validate.ts
@@ -740,8 +744,8 @@ var CONFIG_DEFINITIONS = {
740
744
  allowedValues: ["ask", "auto", "manual"]
741
745
  },
742
746
  conflictResolveTimeoutMs: {
743
- defaultValue: 3e5,
744
- description: "Claude Code \u51B2\u7A81\u89E3\u51B3\u8D85\u65F6\u65F6\u95F4\uFF08\u6BEB\u79D2\uFF09\uFF0C\u9ED8\u8BA4 300000\uFF085 \u5206\u949F\uFF09"
747
+ defaultValue: 6e5,
748
+ description: "Claude Code \u51B2\u7A81\u89E3\u51B3\u8D85\u65F6\u65F6\u95F4\uFF08\u6BEB\u79D2\uFF09\uFF0C\u9ED8\u8BA4 600000\uFF0810 \u5206\u949F\uFF09"
745
749
  }
746
750
  };
747
751
  function deriveDefaultConfig(definitions) {
@@ -1922,6 +1926,19 @@ function parseConcurrency(optionValue, configValue) {
1922
1926
 
1923
1927
  // src/utils/prompt.ts
1924
1928
  import Enquirer2 from "enquirer";
1929
+ async function promptCommitMessage(promptMessage, nonInteractiveErrorMessage) {
1930
+ if (isNonInteractive()) {
1931
+ throw new ClawtError(nonInteractiveErrorMessage);
1932
+ }
1933
+ const prompt = new Enquirer2.Input({
1934
+ message: promptMessage
1935
+ });
1936
+ const result = await prompt.run();
1937
+ if (!result.trim()) {
1938
+ throw new ClawtError("\u63D0\u4EA4\u4FE1\u606F\u4E0D\u80FD\u4E3A\u7A7A");
1939
+ }
1940
+ return result.trim();
1941
+ }
1925
1942
 
1926
1943
  // src/utils/claude.ts
1927
1944
  import { spawnSync as spawnSync3 } from "child_process";
@@ -4275,7 +4292,7 @@ var InteractivePanel = class {
4275
4292
 
4276
4293
  // src/utils/conflict-resolver.ts
4277
4294
  import { execFileSync as execFileSync4 } from "child_process";
4278
- var DEFAULT_CONFLICT_RESOLVE_TIMEOUT_MS = 3e5;
4295
+ var DEFAULT_CONFLICT_RESOLVE_TIMEOUT_MS = 6e5;
4279
4296
  function buildConflictResolvePrompt() {
4280
4297
  return CONFLICT_RESOLVE_PROMPT;
4281
4298
  }
@@ -5104,6 +5121,11 @@ var MERGE_RESOLVE_MESSAGES = {
5104
5121
  multipleMatches: MESSAGES.MERGE_MULTIPLE_MATCHES,
5105
5122
  noMatch: MESSAGES.MERGE_NO_MATCH
5106
5123
  };
5124
+ function commitSquash(message, worktreePath, branchName) {
5125
+ gitAddAll(worktreePath);
5126
+ gitCommit(message, worktreePath);
5127
+ printSuccess(MESSAGES.MERGE_SQUASH_COMMITTED(branchName));
5128
+ }
5107
5129
  async function handleSquashIfNeeded(targetWorktreePath, mainWorktreePath, branchName, commitMessage) {
5108
5130
  if (!hasCommitWithMessage(branchName, AUTO_SAVE_COMMIT_MESSAGE, mainWorktreePath)) {
5109
5131
  return false;
@@ -5117,12 +5139,19 @@ async function handleSquashIfNeeded(targetWorktreePath, mainWorktreePath, branch
5117
5139
  logger.info(`squash: merge-base = ${mergeBase}, \u5206\u652F = ${branchName}`);
5118
5140
  gitResetSoftTo(mergeBase, targetWorktreePath);
5119
5141
  if (commitMessage) {
5120
- gitCommit(commitMessage, targetWorktreePath);
5121
- printSuccess(MESSAGES.MERGE_SQUASH_COMMITTED(branchName));
5142
+ commitSquash(commitMessage, targetWorktreePath, branchName);
5122
5143
  return false;
5123
5144
  }
5124
- printInfo(MESSAGES.MERGE_SQUASH_PENDING(targetWorktreePath, branchName));
5125
- return true;
5145
+ if (isNonInteractive()) {
5146
+ printInfo(MESSAGES.MERGE_SQUASH_PENDING(targetWorktreePath, branchName));
5147
+ return true;
5148
+ }
5149
+ const userCommitMessage = await promptCommitMessage(
5150
+ MESSAGES.MERGE_SQUASH_PROMPT_COMMIT_MESSAGE,
5151
+ MESSAGES.MERGE_SQUASH_PENDING(targetWorktreePath, branchName)
5152
+ );
5153
+ commitSquash(userCommitMessage, targetWorktreePath, branchName);
5154
+ return false;
5126
5155
  }
5127
5156
  async function shouldCleanupAfterMerge(branchName) {
5128
5157
  const autoDelete = getConfigValue("autoDeleteBranch");
@@ -5156,11 +5185,12 @@ async function handleMerge(options) {
5156
5185
  }
5157
5186
  const targetClean = isWorkingDirClean(targetWorktreePath);
5158
5187
  if (!targetClean) {
5159
- if (!options.message) {
5160
- throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE(targetWorktreePath));
5161
- }
5188
+ const commitMessage = options.message ?? await promptCommitMessage(
5189
+ MESSAGES.MERGE_PROMPT_COMMIT_MESSAGE,
5190
+ MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE(targetWorktreePath)
5191
+ );
5162
5192
  gitAddAll(targetWorktreePath);
5163
- gitCommit(options.message, targetWorktreePath);
5193
+ gitCommit(commitMessage, targetWorktreePath);
5164
5194
  } else {
5165
5195
  if (!hasLocalCommits(branch, mainWorktreePath)) {
5166
5196
  throw new ClawtError(MESSAGES.TARGET_WORKTREE_NO_CHANGES);
@@ -179,7 +179,11 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
179
179
  \u8BF7\u624B\u52A8\u5904\u7406\uFF1A
180
180
  \u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add . && git merge --continue`,
181
181
  /** --auto 模式下的冲突手动解决(配置为 manual) */
182
- MERGE_CONFLICT_MANUAL: "\u5408\u5E76\u5B58\u5728\u51B2\u7A81\uFF0C\u8BF7\u624B\u52A8\u5904\u7406\uFF1A\n \u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add . && git merge --continue"
182
+ MERGE_CONFLICT_MANUAL: "\u5408\u5E76\u5B58\u5728\u51B2\u7A81\uFF0C\u8BF7\u624B\u52A8\u5904\u7406\uFF1A\n \u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add . && git merge --continue",
183
+ /** 目标 worktree 有未提交修改时的交互式提交信息提示 */
184
+ MERGE_PROMPT_COMMIT_MESSAGE: "\u76EE\u6807 worktree \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u8F93\u5165\u63D0\u4EA4\u4FE1\u606F",
185
+ /** squash 后的交互式提交信息提示 */
186
+ MERGE_SQUASH_PROMPT_COMMIT_MESSAGE: "\u8BF7\u8F93\u5165 squash \u540E\u7684\u63D0\u4EA4\u4FE1\u606F"
183
187
  };
184
188
 
185
189
  // src/constants/messages/validate.ts
@@ -676,8 +680,8 @@ var CONFIG_DEFINITIONS = {
676
680
  allowedValues: ["ask", "auto", "manual"]
677
681
  },
678
682
  conflictResolveTimeoutMs: {
679
- defaultValue: 3e5,
680
- description: "Claude Code \u51B2\u7A81\u89E3\u51B3\u8D85\u65F6\u65F6\u95F4\uFF08\u6BEB\u79D2\uFF09\uFF0C\u9ED8\u8BA4 300000\uFF085 \u5206\u949F\uFF09"
683
+ defaultValue: 6e5,
684
+ description: "Claude Code \u51B2\u7A81\u89E3\u51B3\u8D85\u65F6\u65F6\u95F4\uFF08\u6BEB\u79D2\uFF09\uFF0C\u9ED8\u8BA4 600000\uFF0810 \u5206\u949F\uFF09"
681
685
  }
682
686
  };
683
687
  function deriveDefaultConfig(definitions) {
@@ -26,7 +26,7 @@
26
26
  "aliases": {},
27
27
  "autoUpdate": true,
28
28
  "conflictResolveMode": "ask",
29
- "conflictResolveTimeoutMs": 300000
29
+ "conflictResolveTimeoutMs": 600000
30
30
  }
31
31
  ```
32
32
 
@@ -44,6 +44,6 @@
44
44
  | `aliases` | `Record<string, string>` | `{}` | 命令别名映射,键为别名,值为目标内置命令名 |
45
45
  | `autoUpdate` | `boolean` | `true` | 是否启用自动更新检查(每 24 小时通过 npm registry 检查一次新版本) |
46
46
  | `conflictResolveMode` | `string` | `"ask"` | merge 冲突时的解决模式:`ask`(询问是否使用 AI)、`auto`(自动 AI 解决)、`manual`(手动解决) |
47
- | `conflictResolveTimeoutMs` | `number` | `300000` | Claude Code 冲突解决超时时间(毫秒),默认 3000005 分钟) |
47
+ | `conflictResolveTimeoutMs` | `number` | `600000` | Claude Code 冲突解决超时时间(毫秒),默认 60000010 分钟) |
48
48
 
49
49
  ---
package/docs/merge.md CHANGED
@@ -15,7 +15,7 @@ clawt merge [-m <commitMessage>]
15
15
  | 参数 | 必填 | 说明 |
16
16
  | ---- | ---- | ------------------------------------------------------------------------ |
17
17
  | `-b` | 否 | 要合并的分支名(支持模糊匹配,不传则列出所有分支供选择) |
18
- | `-m` | 否 | 提交信息(目标 worktree 工作区有修改时必填) |
18
+ | `-m` | 否 | 提交信息(目标 worktree 工作区有修改时使用;交互模式下未提供会询问输入) |
19
19
  | `--auto` | 否 | 遇到冲突直接调用 AI 解决,不再询问 |
20
20
 
21
21
  **运行流程:**
@@ -54,25 +54,30 @@ clawt merge [-m <commitMessage>]
54
54
  1. 获取主分支名(从项目级配置 `clawtMainWorkBranch` 获取)
55
55
  2. 计算分叉点:`git merge-base <mainBranch> <branchName>`
56
56
  3. 在目标 worktree 中执行 `git reset --soft <merge-base>`,将所有 commit 撤销到暂存区
57
- 4. 如果用户提供了 `-m` → 直接在目标 worktree 执行 `git commit -m '<commitMessage>'`,输出成功提示,继续步骤 7
58
- 5. 如果用户未提供 `-m` → 提示用户前往目标 worktree 自行提交后重新执行 `clawt merge`:
59
- ```
60
- 已将所有提交压缩到暂存区
61
- 请在目标 worktree 中提交后重新执行 merge:
62
- cd <worktreePath>
63
- 提交完成后执行:clawt merge -b <branch>
64
- ```
65
- **退出流程**
57
+ 4. 如果用户提供了 `-m` → 在目标 worktree 执行 `git add . && git commit -m '<commitMessage>'`,输出成功提示,继续步骤 7
58
+ 5. 如果用户未提供 `-m`:
59
+ - **交互模式** → 通过 `promptCommitMessage` 交互式询问用户输入提交信息,用户输入后执行 `git add . && git commit`,继续步骤 7
60
+ - **非交互模式** → 提示用户前往目标 worktree 自行提交后重新执行 `clawt merge`,**退出流程**(不执行 `git add`,不改变暂存区):
61
+ ```
62
+ 已将所有提交压缩到暂存区
63
+ 请在目标 worktree 中提交后重新执行 merge:
64
+ cd <worktreePath>
65
+ 提交完成后执行:clawt merge -b <branch>
66
+ ```
67
+
68
+ > **最小副作用原则**:`git add .` 延后到确定要提交时才执行,紧邻 `git commit` 之前。非交互模式温和退出时不触发 `git add .`,避免不必要地改变暂存区状态。提交逻辑通过 `commitSquash` 函数统一封装(`git add . → git commit → 输出成功提示`),确保单一职责。
66
69
  7. **根据目标 worktree 状态决定是否需要提交**
67
70
  - 检测目标 worktree 工作区是否干净(`git status --porcelain`)
68
71
  - **工作区有未提交修改**:
69
- - 如果用户未提供 `-m`,提示 `<worktreePath> 有未提交的修改,请通过 -m 参数提供提交信息`(其中 `<worktreePath>` 为目标 worktree 的完整路径),退出
70
72
  - 提供了 `-m` → 执行提交:
71
73
  ```bash
72
74
  cd ~/.clawt/worktrees/<project>/<branchName>
73
75
  git add .
74
76
  git commit -m '<commitMessage>'
75
77
  ```
78
+ - 未提供 `-m`:
79
+ - **交互模式** → 通过 `promptCommitMessage` 交互式询问用户输入提交信息,输入后执行 `git add . && git commit`
80
+ - **非交互模式** → 提示 `<worktreePath> 有未提交的修改,请通过 -m 参数提供提交信息`,退出
76
81
  - **工作区干净**:
77
82
  - 检查目标分支相对于主分支是否有本地提交(`git log HEAD..<branchName> --oneline`)
78
83
  - 有本地提交 → 跳过提交步骤,直接进入合并
@@ -99,7 +104,7 @@ clawt merge [-m <commitMessage>]
99
104
  | `auto` | — | 直接调用 AI 解决,不询问 |
100
105
  | `manual` | — | 输出冲突提示信息,用户手动解决 |
101
106
 
102
- AI 解决冲突时,调用 Claude Code CLI 在主 worktree 中分析并解决冲突文件,超时时间由配置项 `conflictResolveTimeoutMs` 控制(默认 5 分钟)。AI 解决成功后自动执行 `git add . && git merge --continue` 完成合并。
107
+ AI 解决冲突时,调用 Claude Code CLI 在主 worktree 中分析并解决冲突文件,超时时间由配置项 `conflictResolveTimeoutMs` 控制(默认 10 分钟)。AI 解决成功后自动执行 `git add . && git merge --continue` 完成合并。
103
108
  10. **推送(受 `autoPullPush` 配置控制)**
104
109
  - `autoPullPush` 为 `false` → 输出提示 `已跳过自动 pull/push,请手动执行 git pull && git push`
105
110
  - `autoPullPush` 为 `true` → 执行 `git pull` + `git push`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.8.1",
3
+ "version": "3.8.3",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,6 +35,8 @@ import {
35
35
  getMainWorkBranch,
36
36
  getCurrentBranch,
37
37
  handleMergeConflict,
38
+ promptCommitMessage,
39
+ isNonInteractive,
38
40
  } from '../utils/index.js';
39
41
  import type { WorktreeResolveMessages } from '../utils/index.js';
40
42
 
@@ -62,6 +64,18 @@ const MERGE_RESOLVE_MESSAGES: WorktreeResolveMessages = {
62
64
  noMatch: MESSAGES.MERGE_NO_MATCH,
63
65
  };
64
66
 
67
+ /**
68
+ * 执行 squash 提交:暂存所有变更并提交
69
+ * @param {string} message - 提交信息
70
+ * @param {string} worktreePath - 目标 worktree 路径
71
+ * @param {string} branchName - 分支名(用于输出提示)
72
+ */
73
+ function commitSquash(message: string, worktreePath: string, branchName: string): void {
74
+ gitAddAll(worktreePath);
75
+ gitCommit(message, worktreePath);
76
+ printSuccess(MESSAGES.MERGE_SQUASH_COMMITTED(branchName));
77
+ }
78
+
65
79
  /**
66
80
  * 检测并处理目标分支的 auto-save 提交压缩
67
81
  * 如果检测到 sync 产生的临时提交,提示用户是否将所有提交压缩为一个
@@ -98,14 +112,24 @@ async function handleSquashIfNeeded(
98
112
 
99
113
  if (commitMessage) {
100
114
  // 有 -m 参数,直接提交
101
- gitCommit(commitMessage, targetWorktreePath);
102
- printSuccess(MESSAGES.MERGE_SQUASH_COMMITTED(branchName));
115
+ commitSquash(commitMessage, targetWorktreePath, branchName);
103
116
  return false;
104
117
  }
105
118
 
106
- // 没有 -m 参数,提示用户自行提交
107
- printInfo(MESSAGES.MERGE_SQUASH_PENDING(targetWorktreePath, branchName));
108
- return true;
119
+ // 非交互模式下保持原有温和退出行为(不执行 gitAddAll,不改变暂存区)
120
+ if (isNonInteractive()) {
121
+ printInfo(MESSAGES.MERGE_SQUASH_PENDING(targetWorktreePath, branchName));
122
+ return true;
123
+ }
124
+
125
+ // 交互式询问用户输入提交信息
126
+ const userCommitMessage = await promptCommitMessage(
127
+ MESSAGES.MERGE_SQUASH_PROMPT_COMMIT_MESSAGE,
128
+ MESSAGES.MERGE_SQUASH_PENDING(targetWorktreePath, branchName),
129
+ );
130
+ // 用户输入完成后再提交,避免用户退出时暂存区被不必要地改变
131
+ commitSquash(userCommitMessage, targetWorktreePath, branchName);
132
+ return false;
109
133
  }
110
134
 
111
135
  /**
@@ -171,12 +195,14 @@ async function handleMerge(options: MergeOptions): Promise<void> {
171
195
  const targetClean = isWorkingDirClean(targetWorktreePath);
172
196
 
173
197
  if (!targetClean) {
174
- // 目标 worktree 有未提交修改,必须提供 -m
175
- if (!options.message) {
176
- throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE(targetWorktreePath));
177
- }
198
+ // 目标 worktree 有未提交修改,需要提交信息
199
+ const commitMessage = options.message
200
+ ?? await promptCommitMessage(
201
+ MESSAGES.MERGE_PROMPT_COMMIT_MESSAGE,
202
+ MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE(targetWorktreePath),
203
+ );
178
204
  gitAddAll(targetWorktreePath);
179
- gitCommit(options.message, targetWorktreePath);
205
+ gitCommit(commitMessage, targetWorktreePath);
180
206
  } else {
181
207
  // 目标 worktree 干净,检查是否有本地提交
182
208
  if (!hasLocalCommits(branch, mainWorktreePath)) {
@@ -53,8 +53,8 @@ export const CONFIG_DEFINITIONS: ConfigDefinitions = {
53
53
  allowedValues: ['ask', 'auto', 'manual'] as const,
54
54
  },
55
55
  conflictResolveTimeoutMs: {
56
- defaultValue: 300000,
57
- description: 'Claude Code 冲突解决超时时间(毫秒),默认 3000005 分钟)',
56
+ defaultValue: 600000,
57
+ description: 'Claude Code 冲突解决超时时间(毫秒),默认 60000010 分钟)',
58
58
  },
59
59
  };
60
60
 
@@ -55,4 +55,8 @@ export const MERGE_MESSAGES = {
55
55
  `Claude Code 解决冲突失败: ${errorMsg}\n 请手动处理:\n 解决冲突后执行 git add . && git merge --continue`,
56
56
  /** --auto 模式下的冲突手动解决(配置为 manual) */
57
57
  MERGE_CONFLICT_MANUAL: '合并存在冲突,请手动处理:\n 解决冲突后执行 git add . && git merge --continue',
58
+ /** 目标 worktree 有未提交修改时的交互式提交信息提示 */
59
+ MERGE_PROMPT_COMMIT_MESSAGE: '目标 worktree 有未提交的修改,请输入提交信息',
60
+ /** squash 后的交互式提交信息提示 */
61
+ MERGE_SQUASH_PROMPT_COMMIT_MESSAGE: '请输入 squash 后的提交信息',
58
62
  } as const;
@@ -10,7 +10,7 @@ import { CONFLICT_RESOLVE_PROMPT } from '../constants/ai-prompts.js';
10
10
  import { getEnvWithoutNestedSessionFlag } from './shell.js';
11
11
 
12
12
  /** 默认 Claude Code 冲突解决超时时间(毫秒) */
13
- const DEFAULT_CONFLICT_RESOLVE_TIMEOUT_MS = 300000;
13
+ const DEFAULT_CONFLICT_RESOLVE_TIMEOUT_MS = 600000;
14
14
 
15
15
  /**
16
16
  * 构建发送给 Claude Code 的冲突解决 prompt
@@ -64,7 +64,7 @@ export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWor
64
64
  export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
65
65
  export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration, formatRelativeTime, formatDiskSize, formatLocalISOString, generateTaskFilename } from './formatter.js';
66
66
  export { ensureDir, removeEmptyDir, calculateDirSize } from './fs.js';
67
- export { multilineInput } from './prompt.js';
67
+ export { multilineInput, promptCommitMessage } from './prompt.js';
68
68
  export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
69
69
  export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
70
70
  export { findExactMatch, findFuzzyMatches, promptGroupedMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees, groupWorktreesByDate, buildGroupedChoices, buildGroupMembershipMap, formatRelativeDate, getWorktreeCreatedDate, getWorktreeCreatedTime } from './worktree-matcher.js';
@@ -1,4 +1,6 @@
1
1
  import Enquirer from 'enquirer';
2
+ import { isNonInteractive } from './interactive.js';
3
+ import { ClawtError } from '../errors/index.js';
2
4
 
3
5
  /**
4
6
  * 多行交互式输入框
@@ -16,3 +18,35 @@ export async function multilineInput(message: string): Promise<string> {
16
18
  const result: string = await prompt.run();
17
19
  return result;
18
20
  }
21
+
22
+ /**
23
+ * 交互式询问提交信息(单行输入)
24
+ * 非交互模式下直接抛出 ClawtError,空输入同样抛错
25
+ * @param {string} promptMessage - 交互式提示信息
26
+ * @param {string} nonInteractiveErrorMessage - 非交互模式下的错误消息
27
+ * @returns {Promise<string>} 用户输入的提交信息
28
+ * @throws {ClawtError} 非交互模式或用户输入为空时抛出
29
+ */
30
+ export async function promptCommitMessage(
31
+ promptMessage: string,
32
+ nonInteractiveErrorMessage: string,
33
+ ): Promise<string> {
34
+ // 非交互模式下无法弹出输入框,直接抛错
35
+ if (isNonInteractive()) {
36
+ throw new ClawtError(nonInteractiveErrorMessage);
37
+ }
38
+
39
+ // @ts-expect-error enquirer 类型声明未导出 Input 类,但运行时存在
40
+ const prompt = new Enquirer.Input({
41
+ message: promptMessage,
42
+ });
43
+
44
+ const result: string = await prompt.run();
45
+
46
+ // 空输入视为无效
47
+ if (!result.trim()) {
48
+ throw new ClawtError('提交信息不能为空');
49
+ }
50
+
51
+ return result.trim();
52
+ }
@@ -28,6 +28,8 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
28
28
  MERGE_SQUASH_COMMITTED: (branch: string) => `已压缩提交: ${branch}`,
29
29
  MERGE_SQUASH_PENDING: (path: string, branch: string) => `请手动提交: ${path}`,
30
30
  MERGE_VALIDATE_STATE_HINT: (branch: string) => `分支 ${branch} 存在 validate 状态`,
31
+ MERGE_PROMPT_COMMIT_MESSAGE: '目标 worktree 有未提交的修改,请输入提交信息',
32
+ MERGE_SQUASH_PROMPT_COMMIT_MESSAGE: '请输入 squash 后的提交信息',
31
33
  MAIN_WORKTREE_DIRTY: '主 worktree 有未提交的更改',
32
34
  TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath: string) =>
33
35
  `${worktreePath} 有未提交修改,请提供 -m 参数`,
@@ -79,6 +81,7 @@ vi.mock('../../../src/utils/index.js', () => ({
79
81
  guardMainWorkBranchExists: vi.fn(),
80
82
  handleMergeConflict: vi.fn(),
81
83
  isNonInteractive: vi.fn().mockReturnValue(false),
84
+ promptCommitMessage: vi.fn(),
82
85
  }));
83
86
 
84
87
  import { registerMergeCommand } from '../../../src/commands/merge.js';
@@ -104,6 +107,8 @@ import {
104
107
  hasCommitWithMessage,
105
108
  resolveTargetWorktree,
106
109
  handleMergeConflict,
110
+ promptCommitMessage,
111
+ isNonInteractive,
107
112
  } from '../../../src/utils/index.js';
108
113
 
109
114
  const mockedGetProjectName = vi.mocked(getProjectName);
@@ -127,6 +132,8 @@ const mockedCleanupWorktrees = vi.mocked(cleanupWorktrees);
127
132
  const mockedHasCommitWithMessage = vi.mocked(hasCommitWithMessage);
128
133
  const mockedResolveTargetWorktree = vi.mocked(resolveTargetWorktree);
129
134
  const mockedHandleMergeConflict = vi.mocked(handleMergeConflict);
135
+ const mockedPromptCommitMessage = vi.mocked(promptCommitMessage);
136
+ const mockedIsNonInteractive = vi.mocked(isNonInteractive);
130
137
 
131
138
  const worktree = { path: '/path/feature', branch: 'feature' };
132
139
 
@@ -153,6 +160,9 @@ beforeEach(() => {
153
160
  mockedCleanupWorktrees.mockReset();
154
161
  mockedHandleMergeConflict.mockReset();
155
162
  mockedHandleMergeConflict.mockResolvedValue(true); // 默认 AI 解决成功
163
+ mockedPromptCommitMessage.mockReset();
164
+ mockedIsNonInteractive.mockReset();
165
+ mockedIsNonInteractive.mockReturnValue(false); // 默认交互模式
156
166
  });
157
167
 
158
168
  describe('registerMergeCommand', () => {
@@ -177,18 +187,22 @@ describe('handleMerge', () => {
177
187
  ).rejects.toThrow();
178
188
  });
179
189
 
180
- it('目标 worktree 有未提交修改但未提供 -m 时抛出', async () => {
190
+ it('目标 worktree 有未提交修改且无 -m 时交互式询问提交信息', async () => {
181
191
  mockedIsWorkingDirClean
182
192
  .mockReturnValueOnce(true) // 主 worktree 干净
183
193
  .mockReturnValueOnce(false); // 目标 worktree 不干净
194
+ mockedPromptCommitMessage.mockResolvedValue('用户输入的提交信息');
195
+ mockedConfirmAction.mockResolvedValue(false);
184
196
 
185
197
  const program = new Command();
186
198
  program.exitOverride();
187
199
  registerMergeCommand(program);
200
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
188
201
 
189
- await expect(
190
- program.parseAsync(['merge', '-b', 'feature'], { from: 'user' }),
191
- ).rejects.toThrow();
202
+ expect(mockedPromptCommitMessage).toHaveBeenCalled();
203
+ expect(mockedGitAddAll).toHaveBeenCalledWith('/path/feature');
204
+ expect(mockedGitCommit).toHaveBeenCalledWith('用户输入的提交信息', '/path/feature');
205
+ expect(mockedGitMerge).toHaveBeenCalledWith('feature', '/repo');
192
206
  });
193
207
 
194
208
  it('目标 worktree 有未提交修改且提供 -m 时先提交再合并', async () => {
@@ -390,4 +404,94 @@ describe('handleMerge', () => {
390
404
 
391
405
  expect(mockedPrintWarning).toHaveBeenCalled();
392
406
  });
407
+
408
+ it('非交互模式下目标 worktree 有未提交修改且无 -m 时 promptCommitMessage 抛错', async () => {
409
+ mockedIsWorkingDirClean
410
+ .mockReturnValueOnce(true) // 主 worktree 干净
411
+ .mockReturnValueOnce(false); // 目标 worktree 不干净
412
+ mockedPromptCommitMessage.mockRejectedValue(new Error('/path/feature 有未提交修改,请提供 -m 参数'));
413
+
414
+ const program = new Command();
415
+ program.exitOverride();
416
+ registerMergeCommand(program);
417
+
418
+ await expect(
419
+ program.parseAsync(['merge', '-b', 'feature'], { from: 'user' }),
420
+ ).rejects.toThrow();
421
+ });
422
+
423
+ it('squash 后无 -m 交互模式下询问用户输入并继续合并', async () => {
424
+ // 主 worktree 干净
425
+ mockedIsWorkingDirClean.mockReturnValue(true);
426
+ // 存在 auto-save commit
427
+ mockedHasCommitWithMessage.mockReturnValue(true);
428
+ // 用户选择压缩
429
+ mockedConfirmAction.mockResolvedValueOnce(true) // squash 确认
430
+ .mockResolvedValueOnce(false); // 不清理 worktree
431
+ // mock promptCommitMessage 返回用户输入
432
+ mockedPromptCommitMessage.mockResolvedValue('squash 提交信息');
433
+ // 有本地提交
434
+ mockedHasLocalCommits.mockReturnValue(true);
435
+
436
+ const program = new Command();
437
+ program.exitOverride();
438
+ registerMergeCommand(program);
439
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
440
+
441
+ expect(mockedPromptCommitMessage).toHaveBeenCalled();
442
+ expect(mockedGitAddAll).toHaveBeenCalledWith('/path/feature');
443
+ expect(mockedGitCommit).toHaveBeenCalledWith('squash 提交信息', '/path/feature');
444
+ expect(mockedGitMerge).toHaveBeenCalledWith('feature', '/repo');
445
+
446
+ // 验证 gitAddAll 在 promptCommitMessage 之后调用(延后到确定要提交时才执行)
447
+ const addAllOrder = mockedGitAddAll.mock.invocationCallOrder[0];
448
+ const promptOrder = mockedPromptCommitMessage.mock.invocationCallOrder[0];
449
+ const commitOrder = mockedGitCommit.mock.invocationCallOrder[0];
450
+ expect(addAllOrder).toBeGreaterThan(promptOrder);
451
+ expect(addAllOrder).toBeLessThan(commitOrder);
452
+ });
453
+
454
+ it('squash 后 gitAddAll 应在 gitResetSoftTo 之后被调用以暂存工作区变更', async () => {
455
+ // 主 worktree 干净
456
+ mockedIsWorkingDirClean.mockReturnValue(true);
457
+ // 存在 auto-save commit
458
+ mockedHasCommitWithMessage.mockReturnValue(true);
459
+ // 用户选择压缩
460
+ mockedConfirmAction.mockResolvedValueOnce(true) // squash 确认
461
+ .mockResolvedValueOnce(false); // 不清理 worktree
462
+ // 有本地提交
463
+ mockedHasLocalCommits.mockReturnValue(true);
464
+
465
+ const program = new Command();
466
+ program.exitOverride();
467
+ registerMergeCommand(program);
468
+ await program.parseAsync(['merge', '-b', 'feature', '-m', 'squash msg'], { from: 'user' });
469
+
470
+ // 验证 squash 流程中 gitAddAll 在 gitCommit 之前被调用
471
+ expect(mockedGitAddAll).toHaveBeenCalledWith('/path/feature');
472
+ expect(mockedGitCommit).toHaveBeenCalledWith('squash msg', '/path/feature');
473
+
474
+ // 步骤 4 不应再触发(squash 已处理完毕,工作区应干净)
475
+ expect(mockedGitMerge).toHaveBeenCalledWith('feature', '/repo');
476
+ });
477
+
478
+ it('squash 后无 -m 非交互模式下提示自行处理并退出', async () => {
479
+ // 主 worktree 干净
480
+ mockedIsWorkingDirClean.mockReturnValue(true);
481
+ // 存在 auto-save commit
482
+ mockedHasCommitWithMessage.mockReturnValue(true);
483
+ // 用户选择压缩
484
+ mockedConfirmAction.mockResolvedValue(true);
485
+ // 非交互模式
486
+ mockedIsNonInteractive.mockReturnValue(true);
487
+
488
+ const program = new Command();
489
+ program.exitOverride();
490
+ registerMergeCommand(program);
491
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
492
+
493
+ // 非交互模式下不执行 gitAddAll 和 gitMerge,提前退出
494
+ expect(mockedGitAddAll).not.toHaveBeenCalled();
495
+ expect(mockedGitMerge).not.toHaveBeenCalled();
496
+ });
393
497
  });
@@ -1,5 +1,24 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
 
3
+ // mock 非交互模式判断函数
4
+ const { mockIsNonInteractive } = vi.hoisted(() => {
5
+ return { mockIsNonInteractive: vi.fn().mockReturnValue(false) };
6
+ });
7
+
8
+ vi.mock('../../../src/utils/interactive.js', () => ({
9
+ isNonInteractive: mockIsNonInteractive,
10
+ }));
11
+
12
+ vi.mock('../../../src/errors/index.js', () => ({
13
+ ClawtError: class ClawtError extends Error {
14
+ exitCode: number;
15
+ constructor(message: string, exitCode = 1) {
16
+ super(message);
17
+ this.exitCode = exitCode;
18
+ }
19
+ },
20
+ }));
21
+
3
22
  // mock enquirer - 使用 vi.hoisted 确保变量在 vi.mock 提升后仍可访问
4
23
  const { mockRun, MockInput } = vi.hoisted(() => {
5
24
  const mockRun = vi.fn();
@@ -16,7 +35,7 @@ vi.mock('enquirer', () => ({
16
35
  },
17
36
  }));
18
37
 
19
- import { multilineInput } from '../../../src/utils/prompt.js';
38
+ import { multilineInput, promptCommitMessage } from '../../../src/utils/prompt.js';
20
39
 
21
40
  describe('multilineInput', () => {
22
41
  it('返回用户输入的内容', async () => {
@@ -51,3 +70,49 @@ describe('multilineInput', () => {
51
70
  expect(result).toBe('第一行\n第二行\n第三行');
52
71
  });
53
72
  });
73
+
74
+ describe('promptCommitMessage', () => {
75
+ it('交互模式下正常返回用户输入', async () => {
76
+ mockIsNonInteractive.mockReturnValue(false);
77
+ mockRun.mockResolvedValue('用户输入的提交信息');
78
+
79
+ const result = await promptCommitMessage('请输入提交信息', '非交互模式错误');
80
+
81
+ expect(result).toBe('用户输入的提交信息');
82
+ });
83
+
84
+ it('非交互模式下抛出 ClawtError', async () => {
85
+ mockIsNonInteractive.mockReturnValue(true);
86
+
87
+ await expect(
88
+ promptCommitMessage('请输入提交信息', '非交互模式下不可用'),
89
+ ).rejects.toThrow('非交互模式下不可用');
90
+ });
91
+
92
+ it('用户输入为空时抛出 ClawtError', async () => {
93
+ mockIsNonInteractive.mockReturnValue(false);
94
+ mockRun.mockResolvedValue('');
95
+
96
+ await expect(
97
+ promptCommitMessage('请输入提交信息', '非交互模式错误'),
98
+ ).rejects.toThrow('提交信息不能为空');
99
+ });
100
+
101
+ it('用户输入仅空白字符时抛出 ClawtError', async () => {
102
+ mockIsNonInteractive.mockReturnValue(false);
103
+ mockRun.mockResolvedValue(' ');
104
+
105
+ await expect(
106
+ promptCommitMessage('请输入提交信息', '非交互模式错误'),
107
+ ).rejects.toThrow('提交信息不能为空');
108
+ });
109
+
110
+ it('返回 trim 后的用户输入', async () => {
111
+ mockIsNonInteractive.mockReturnValue(false);
112
+ mockRun.mockResolvedValue(' 提交信息 ');
113
+
114
+ const result = await promptCommitMessage('请输入提交信息', '非交互模式错误');
115
+
116
+ expect(result).toBe('提交信息');
117
+ });
118
+ });