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 +5 -3
- package/dist/index.js +42 -12
- package/dist/postinstall.js +7 -3
- package/docs/config-file.md +2 -2
- package/docs/merge.md +17 -12
- package/package.json +1 -1
- package/src/commands/merge.ts +36 -10
- package/src/constants/config.ts +2 -2
- package/src/constants/messages/merge.ts +4 -0
- package/src/utils/conflict-resolver.ts +1 -1
- package/src/utils/index.ts +1 -1
- package/src/utils/prompt.ts +34 -0
- package/tests/unit/commands/merge.test.ts +108 -4
- package/tests/unit/utils/prompt.test.ts +66 -1
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: 提交信息" #
|
|
190
|
-
clawt merge -b <branch> #
|
|
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` | `
|
|
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:
|
|
744
|
-
description: "Claude Code \u51B2\u7A81\u89E3\u51B3\u8D85\u65F6\u65F6\u95F4\uFF08\u6BEB\u79D2\uFF09\uFF0C\u9ED8\u8BA4
|
|
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 =
|
|
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
|
-
|
|
5121
|
-
printSuccess(MESSAGES.MERGE_SQUASH_COMMITTED(branchName));
|
|
5142
|
+
commitSquash(commitMessage, targetWorktreePath, branchName);
|
|
5122
5143
|
return false;
|
|
5123
5144
|
}
|
|
5124
|
-
|
|
5125
|
-
|
|
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
|
-
|
|
5160
|
-
|
|
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(
|
|
5193
|
+
gitCommit(commitMessage, targetWorktreePath);
|
|
5164
5194
|
} else {
|
|
5165
5195
|
if (!hasLocalCommits(branch, mainWorktreePath)) {
|
|
5166
5196
|
throw new ClawtError(MESSAGES.TARGET_WORKTREE_NO_CHANGES);
|
package/dist/postinstall.js
CHANGED
|
@@ -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:
|
|
680
|
-
description: "Claude Code \u51B2\u7A81\u89E3\u51B3\u8D85\u65F6\u65F6\u95F4\uFF08\u6BEB\u79D2\uFF09\uFF0C\u9ED8\u8BA4
|
|
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) {
|
package/docs/config-file.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"aliases": {},
|
|
27
27
|
"autoUpdate": true,
|
|
28
28
|
"conflictResolveMode": "ask",
|
|
29
|
-
"conflictResolveTimeoutMs":
|
|
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` | `
|
|
47
|
+
| `conflictResolveTimeoutMs` | `number` | `600000` | Claude Code 冲突解决超时时间(毫秒),默认 600000(10 分钟) |
|
|
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` →
|
|
58
|
-
5. 如果用户未提供 `-m
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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` 控制(默认
|
|
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
package/src/commands/merge.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
printSuccess(MESSAGES.MERGE_SQUASH_COMMITTED(branchName));
|
|
115
|
+
commitSquash(commitMessage, targetWorktreePath, branchName);
|
|
103
116
|
return false;
|
|
104
117
|
}
|
|
105
118
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
205
|
+
gitCommit(commitMessage, targetWorktreePath);
|
|
180
206
|
} else {
|
|
181
207
|
// 目标 worktree 干净,检查是否有本地提交
|
|
182
208
|
if (!hasLocalCommits(branch, mainWorktreePath)) {
|
package/src/constants/config.ts
CHANGED
|
@@ -53,8 +53,8 @@ export const CONFIG_DEFINITIONS: ConfigDefinitions = {
|
|
|
53
53
|
allowedValues: ['ask', 'auto', 'manual'] as const,
|
|
54
54
|
},
|
|
55
55
|
conflictResolveTimeoutMs: {
|
|
56
|
-
defaultValue:
|
|
57
|
-
description: 'Claude Code 冲突解决超时时间(毫秒),默认
|
|
56
|
+
defaultValue: 600000,
|
|
57
|
+
description: 'Claude Code 冲突解决超时时间(毫秒),默认 600000(10 分钟)',
|
|
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 =
|
|
13
|
+
const DEFAULT_CONFLICT_RESOLVE_TIMEOUT_MS = 600000;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* 构建发送给 Claude Code 的冲突解决 prompt
|
package/src/utils/index.ts
CHANGED
|
@@ -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';
|
package/src/utils/prompt.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
).
|
|
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
|
+
});
|