clawt 3.8.0 → 3.8.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 +5 -3
- package/dist/index.js +40 -10
- package/dist/postinstall.js +5 -1
- package/docs/merge.md +16 -11
- package/docs/tasks.md +4 -4
- package/package.json +1 -1
- package/src/commands/merge.ts +36 -10
- package/src/commands/tasks.ts +1 -1
- package/src/constants/messages/merge.ts +4 -0
- package/src/constants/tasks-template.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/commands/tasks.test.ts +5 -5
- 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
|
|
@@ -242,7 +244,7 @@ clawt home
|
|
|
242
244
|
### `clawt tasks` — 任务文件管理
|
|
243
245
|
|
|
244
246
|
```bash
|
|
245
|
-
clawt tasks init # 生成任务模板文件(默认输出到 clawt/tasks/ 目录)
|
|
247
|
+
clawt tasks init # 生成任务模板文件(默认输出到 .clawt/tasks/ 目录)
|
|
246
248
|
clawt tasks init [path] # 指定输出路径
|
|
247
249
|
```
|
|
248
250
|
|
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
|
|
@@ -890,7 +894,7 @@ var PRE_CHECK_RESUME = {
|
|
|
890
894
|
};
|
|
891
895
|
|
|
892
896
|
// src/constants/tasks-template.ts
|
|
893
|
-
var TASK_TEMPLATE_OUTPUT_DIR = "clawt/tasks";
|
|
897
|
+
var TASK_TEMPLATE_OUTPUT_DIR = ".clawt/tasks";
|
|
894
898
|
var TASK_TEMPLATE_FILENAME_PREFIX = "clawt-tasks";
|
|
895
899
|
var TASK_TEMPLATE_CONTENT = `# Clawt \u4EFB\u52A1\u6587\u4EF6
|
|
896
900
|
#
|
|
@@ -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";
|
|
@@ -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
|
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
|
- 有本地提交 → 跳过提交步骤,直接进入合并
|
package/docs/tasks.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**命令:**
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
# 生成任务模板文件(默认输出到 clawt/tasks/ 目录)
|
|
6
|
+
# 生成任务模板文件(默认输出到 .clawt/tasks/ 目录)
|
|
7
7
|
clawt tasks init
|
|
8
8
|
|
|
9
9
|
# 指定输出路径
|
|
@@ -20,7 +20,7 @@ clawt tasks init [path]
|
|
|
20
20
|
|
|
21
21
|
| 参数 | 必填 | 说明 |
|
|
22
22
|
| ---- | ---- | ---- |
|
|
23
|
-
| `[path]` | 否 | 输出文件路径。不传时默认输出到
|
|
23
|
+
| `[path]` | 否 | 输出文件路径。不传时默认输出到 `.clawt/tasks/clawt-tasks-<时间戳>.md` |
|
|
24
24
|
|
|
25
25
|
**功能说明:**
|
|
26
26
|
|
|
@@ -30,7 +30,7 @@ clawt tasks init [path]
|
|
|
30
30
|
|
|
31
31
|
1. **确定输出路径**:
|
|
32
32
|
- 传了 `[path]` → 使用指定路径
|
|
33
|
-
- 未传 → 默认输出到
|
|
33
|
+
- 未传 → 默认输出到 `.clawt/tasks/clawt-tasks-<时间戳>.md`(由 `generateTaskFilename` 生成唯一文件名)
|
|
34
34
|
2. **路径转换**:将路径转为绝对路径(`path.resolve`)
|
|
35
35
|
3. **文件存在性校验**:如果目标文件已存在,抛出错误退出
|
|
36
36
|
4. **创建父目录**:确保输出路径的父目录存在(`ensureDir`)
|
|
@@ -66,7 +66,7 @@ clawt tasks init [path]
|
|
|
66
66
|
|
|
67
67
|
- 命令注册函数 `registerTasksCommand` 位于 `src/commands/tasks.ts`
|
|
68
68
|
- 模板内容和常量定义在 `src/constants/tasks-template.ts`:
|
|
69
|
-
- `TASK_TEMPLATE_OUTPUT_DIR
|
|
69
|
+
- `TASK_TEMPLATE_OUTPUT_DIR`(`.clawt/tasks`):默认输出目录
|
|
70
70
|
- `TASK_TEMPLATE_FILENAME_PREFIX`(`clawt-tasks`):文件名前缀
|
|
71
71
|
- `TASK_TEMPLATE_CONTENT`:模板文件内容
|
|
72
72
|
- 消息常量定义在 `src/constants/messages/tasks.ts`
|
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/commands/tasks.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function registerTasksCommand(program: Command): void {
|
|
|
20
20
|
.description('生成任务模板文件')
|
|
21
21
|
.argument('[path]', '输出文件路径')
|
|
22
22
|
.action(async (path?: string) => {
|
|
23
|
-
// 未指定路径时,默认输出到 clawt/tasks/ 目录下
|
|
23
|
+
// 未指定路径时,默认输出到 .clawt/tasks/ 目录下
|
|
24
24
|
const filePath = path ?? join(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
|
|
25
25
|
await handleTasksInit(filePath);
|
|
26
26
|
});
|
|
@@ -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;
|
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
|
});
|
|
@@ -11,7 +11,7 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
11
11
|
TASK_INIT_SUCCESS: (path: string) => `✓ 任务模板已生成: ${path}`,
|
|
12
12
|
TASK_INIT_HINT: (path: string) => `使用 clawt run -f ${path} 执行任务`,
|
|
13
13
|
},
|
|
14
|
-
TASK_TEMPLATE_OUTPUT_DIR: 'clawt/tasks',
|
|
14
|
+
TASK_TEMPLATE_OUTPUT_DIR: '.clawt/tasks',
|
|
15
15
|
TASK_TEMPLATE_FILENAME_PREFIX: 'clawt-tasks',
|
|
16
16
|
TASK_TEMPLATE_CONTENT: '# 模板内容',
|
|
17
17
|
}));
|
|
@@ -90,17 +90,17 @@ describe('handleTasksInit', () => {
|
|
|
90
90
|
await program.parseAsync(['tasks', 'init'], { from: 'user' });
|
|
91
91
|
|
|
92
92
|
expect(mockGenerateTaskFilename).toHaveBeenCalledWith('clawt-tasks');
|
|
93
|
-
// 默认路径应输出到 clawt/tasks/ 目录下
|
|
93
|
+
// 默认路径应输出到 .clawt/tasks/ 目录下
|
|
94
94
|
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
|
95
|
-
expect.stringContaining('clawt/tasks/clawt-tasks-2026-01-01-00-00-00.md'),
|
|
95
|
+
expect.stringContaining('.clawt/tasks/clawt-tasks-2026-01-01-00-00-00.md'),
|
|
96
96
|
'# 模板内容',
|
|
97
97
|
'utf-8',
|
|
98
98
|
);
|
|
99
99
|
expect(mockedPrintSuccess).toHaveBeenCalledWith(
|
|
100
|
-
expect.stringContaining('clawt/tasks/clawt-tasks-2026-01-01-00-00-00.md'),
|
|
100
|
+
expect.stringContaining('.clawt/tasks/clawt-tasks-2026-01-01-00-00-00.md'),
|
|
101
101
|
);
|
|
102
102
|
expect(mockedPrintHint).toHaveBeenCalledWith(
|
|
103
|
-
expect.stringContaining('clawt/tasks/clawt-tasks-2026-01-01-00-00-00.md'),
|
|
103
|
+
expect.stringContaining('.clawt/tasks/clawt-tasks-2026-01-01-00-00-00.md'),
|
|
104
104
|
);
|
|
105
105
|
});
|
|
106
106
|
|
|
@@ -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
|
+
});
|