clawt 3.5.3 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +74 -11
- package/dist/postinstall.js +11 -1
- package/package.json +1 -1
- package/src/commands/resume.ts +91 -0
- package/src/constants/messages/resume.ts +10 -0
- package/src/types/command.ts +6 -0
- package/src/utils/task-executor.ts +34 -8
- package/tests/unit/commands/resume.test.ts +241 -3
package/dist/index.js
CHANGED
|
@@ -311,7 +311,17 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
|
|
|
311
311
|
/** 批量 resume 非 macOS 平台提示 */
|
|
312
312
|
RESUME_ALL_PLATFORM_UNSUPPORTED: "\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09",
|
|
313
313
|
/** 批量 resume 无匹配分支提示 */
|
|
314
|
-
RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F
|
|
314
|
+
RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`,
|
|
315
|
+
/** --prompt 必须配合 -b 指定目标分支 */
|
|
316
|
+
RESUME_PROMPT_REQUIRES_BRANCH: "--prompt \u5FC5\u987B\u914D\u5408 -b \u6307\u5B9A\u76EE\u6807\u5206\u652F",
|
|
317
|
+
/** --prompt 和 -f 不能同时使用 */
|
|
318
|
+
RESUME_PROMPT_FILE_CONFLICT: "--prompt \u548C -f \u4E0D\u80FD\u540C\u65F6\u4F7F\u7528",
|
|
319
|
+
/** 未找到对应 worktree */
|
|
320
|
+
RESUME_WORKTREE_NOT_FOUND: (branch, available) => `\u672A\u627E\u5230\u5206\u652F "${branch}" \u5BF9\u5E94\u7684 worktree
|
|
321
|
+
\u53EF\u7528\u5206\u652F\uFF1A
|
|
322
|
+
${available.map((b) => ` - ${b}`).join("\n")}`,
|
|
323
|
+
/** 追问文件加载完成 */
|
|
324
|
+
RESUME_FOLLOW_UP_FILE_LOADED: (count, path2) => `\u4ECE ${path2} \u52A0\u8F7D\u4E86 ${count} \u4E2A\u8FFD\u95EE\u4EFB\u52A1`
|
|
315
325
|
};
|
|
316
326
|
|
|
317
327
|
// src/constants/messages/remove.ts
|
|
@@ -2762,10 +2772,14 @@ function parseStreamEvent(event) {
|
|
|
2762
2772
|
}
|
|
2763
2773
|
|
|
2764
2774
|
// src/utils/task-executor.ts
|
|
2765
|
-
function executeClaudeTask(worktree, task, onActivity) {
|
|
2775
|
+
function executeClaudeTask(worktree, task, onActivity, continueSession) {
|
|
2776
|
+
const args = ["-p", task, "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"];
|
|
2777
|
+
if (continueSession) {
|
|
2778
|
+
args.push("--continue");
|
|
2779
|
+
}
|
|
2766
2780
|
const child = spawnProcess(
|
|
2767
2781
|
"claude",
|
|
2768
|
-
|
|
2782
|
+
args,
|
|
2769
2783
|
{
|
|
2770
2784
|
cwd: worktree.path,
|
|
2771
2785
|
// stdin 必须设置为 'ignore',不能用 'pipe'
|
|
@@ -2875,7 +2889,7 @@ function updateRendererStatus(renderer, index, result, startTime) {
|
|
|
2875
2889
|
);
|
|
2876
2890
|
}
|
|
2877
2891
|
}
|
|
2878
|
-
async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses) {
|
|
2892
|
+
async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses, continueFlags) {
|
|
2879
2893
|
const total = tasks.length;
|
|
2880
2894
|
const results = new Array(total);
|
|
2881
2895
|
let nextIndex = 0;
|
|
@@ -2891,7 +2905,7 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
|
|
|
2891
2905
|
renderer.markRunning(index);
|
|
2892
2906
|
const handle = executeClaudeTask(wt, task, (activityText) => {
|
|
2893
2907
|
renderer.updateActivityText(index, activityText);
|
|
2894
|
-
});
|
|
2908
|
+
}, continueFlags?.[index]);
|
|
2895
2909
|
childProcesses.push(handle.child);
|
|
2896
2910
|
handle.promise.then((result) => {
|
|
2897
2911
|
results[index] = result;
|
|
@@ -2911,13 +2925,13 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
|
|
|
2911
2925
|
}
|
|
2912
2926
|
});
|
|
2913
2927
|
}
|
|
2914
|
-
async function executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses) {
|
|
2928
|
+
async function executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses, continueFlags) {
|
|
2915
2929
|
const handles = worktrees.map((wt, index) => {
|
|
2916
2930
|
const task = tasks[index];
|
|
2917
2931
|
logger.info(`\u542F\u52A8\u4EFB\u52A1 ${index + 1}: ${task} (worktree: ${wt.path})`);
|
|
2918
2932
|
const handle = executeClaudeTask(wt, task, (activityText) => {
|
|
2919
2933
|
renderer.updateActivityText(index, activityText);
|
|
2920
|
-
});
|
|
2934
|
+
}, continueFlags?.[index]);
|
|
2921
2935
|
childProcesses.push(handle.child);
|
|
2922
2936
|
return handle;
|
|
2923
2937
|
});
|
|
@@ -2933,8 +2947,13 @@ async function executeAllParallel(worktrees, tasks, renderer, startTime, isInter
|
|
|
2933
2947
|
);
|
|
2934
2948
|
return results;
|
|
2935
2949
|
}
|
|
2936
|
-
async function executeBatchTasks(worktrees, tasks, concurrency) {
|
|
2950
|
+
async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
|
|
2937
2951
|
const count = tasks.length;
|
|
2952
|
+
if (continueFlags && continueFlags.length !== count) {
|
|
2953
|
+
throw new ClawtError(
|
|
2954
|
+
`continueFlags \u957F\u5EA6 (${continueFlags.length}) \u4E0E\u4EFB\u52A1\u6570 (${count}) \u4E0D\u4E00\u81F4`
|
|
2955
|
+
);
|
|
2956
|
+
}
|
|
2938
2957
|
if (concurrency > 0) {
|
|
2939
2958
|
printInfo(MESSAGES.CONCURRENCY_INFO(concurrency, count));
|
|
2940
2959
|
printInfo("");
|
|
@@ -2968,10 +2987,10 @@ async function executeBatchTasks(worktrees, tasks, concurrency) {
|
|
|
2968
2987
|
process.exit(1);
|
|
2969
2988
|
};
|
|
2970
2989
|
process.on("SIGINT", sigintHandler);
|
|
2971
|
-
const results = concurrency > 0 ? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses) : await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses);
|
|
2990
|
+
const results = concurrency > 0 ? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses, continueFlags) : await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses, continueFlags);
|
|
2972
2991
|
renderer.stop();
|
|
2973
2992
|
process.removeListener("SIGINT", sigintHandler);
|
|
2974
|
-
if (interrupted) return;
|
|
2993
|
+
if (interrupted) return [];
|
|
2975
2994
|
const totalDurationMs = Date.now() - startTime;
|
|
2976
2995
|
const summary = {
|
|
2977
2996
|
total: results.length,
|
|
@@ -2981,6 +3000,7 @@ async function executeBatchTasks(worktrees, tasks, concurrency) {
|
|
|
2981
3000
|
totalCostUsd: results.reduce((sum, r) => sum + (r.result?.total_cost_usd ?? 0), 0)
|
|
2982
3001
|
};
|
|
2983
3002
|
printTaskSummary(summary);
|
|
3003
|
+
return results;
|
|
2984
3004
|
}
|
|
2985
3005
|
|
|
2986
3006
|
// src/utils/dry-run.ts
|
|
@@ -4439,12 +4459,26 @@ var RESUME_RESOLVE_MESSAGES = {
|
|
|
4439
4459
|
noMatch: MESSAGES.RESUME_NO_MATCH
|
|
4440
4460
|
};
|
|
4441
4461
|
function registerResumeCommand(program2) {
|
|
4442
|
-
program2.command("resume").description("\u5728\u5DF2\u6709 worktree \u4E2D\u6062\u590D Claude Code \u4F1A\u8BDD\uFF08\u652F\u6301\u591A\u9009\u6279\u91CF\u6062\u590D\uFF09").option("-b, --branch <branchName>", "\u8981\u6062\u590D\u7684\u5206\u652F\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF0C\u4E0D\u4F20\u5219\u5217\u51FA\u6240\u6709\u5206\u652F\uFF09").action(async (options) => {
|
|
4462
|
+
program2.command("resume").description("\u5728\u5DF2\u6709 worktree \u4E2D\u6062\u590D Claude Code \u4F1A\u8BDD\uFF08\u652F\u6301\u591A\u9009\u6279\u91CF\u6062\u590D\uFF09").option("-b, --branch <branchName>", "\u8981\u6062\u590D\u7684\u5206\u652F\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF0C\u4E0D\u4F20\u5219\u5217\u51FA\u6240\u6709\u5206\u652F\uFF09").option("--prompt <content>", "\u975E\u4EA4\u4E92\u5F0F\u8FFD\u95EE\uFF08\u9700\u914D\u5408 -b \u6307\u5B9A\u5206\u652F\uFF09").option("-f, --file <path>", "\u4ECE\u4EFB\u52A1\u6587\u4EF6\u6279\u91CF\u8FFD\u95EE\uFF08\u901A\u8FC7 branch \u540D\u5339\u914D\u5DF2\u6709 worktree\uFF09").option("-c, --concurrency <n>", "\u6279\u91CF\u8FFD\u95EE\u6700\u5927\u5E76\u53D1\u6570\uFF0C0 \u8868\u793A\u4E0D\u9650\u5236").action(async (options) => {
|
|
4443
4463
|
await handleResume(options);
|
|
4444
4464
|
});
|
|
4445
4465
|
}
|
|
4446
4466
|
async function handleResume(options) {
|
|
4447
4467
|
await runPreChecks(PRE_CHECK_RESUME);
|
|
4468
|
+
if (options.prompt && options.file) {
|
|
4469
|
+
throw new ClawtError(MESSAGES.RESUME_PROMPT_FILE_CONFLICT);
|
|
4470
|
+
}
|
|
4471
|
+
if (options.prompt) {
|
|
4472
|
+
if (!options.branch) {
|
|
4473
|
+
throw new ClawtError(MESSAGES.RESUME_PROMPT_REQUIRES_BRANCH);
|
|
4474
|
+
}
|
|
4475
|
+
const worktrees2 = getProjectWorktrees();
|
|
4476
|
+
const worktree = resolveWorktreeByBranch(options.branch, worktrees2);
|
|
4477
|
+
return handleNonInteractiveSingleResume(worktree, options.prompt);
|
|
4478
|
+
}
|
|
4479
|
+
if (options.file) {
|
|
4480
|
+
return handleNonInteractiveBatchResume(options.file, options);
|
|
4481
|
+
}
|
|
4448
4482
|
logger.info(`resume \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F\u8FC7\u6EE4: ${options.branch ?? "(\u65E0)"}`);
|
|
4449
4483
|
const worktrees = getProjectWorktrees();
|
|
4450
4484
|
let targetWorktrees;
|
|
@@ -4468,6 +4502,35 @@ async function handleResume(options) {
|
|
|
4468
4502
|
await handleBatchResume(targetWorktrees);
|
|
4469
4503
|
}
|
|
4470
4504
|
}
|
|
4505
|
+
function resolveWorktreeByBranch(branch, worktrees) {
|
|
4506
|
+
const match = findExactMatch(worktrees, branch);
|
|
4507
|
+
if (!match) {
|
|
4508
|
+
const available = worktrees.map((wt) => wt.branch);
|
|
4509
|
+
throw new ClawtError(MESSAGES.RESUME_WORKTREE_NOT_FOUND(branch, available));
|
|
4510
|
+
}
|
|
4511
|
+
return match;
|
|
4512
|
+
}
|
|
4513
|
+
async function handleNonInteractiveSingleResume(worktree, prompt) {
|
|
4514
|
+
const hasPrevious = hasClaudeSessionHistory(worktree.path);
|
|
4515
|
+
await executeBatchTasks([worktree], [prompt], 0, [hasPrevious]);
|
|
4516
|
+
}
|
|
4517
|
+
async function handleNonInteractiveBatchResume(filePath, options) {
|
|
4518
|
+
const entries = loadTaskFile(filePath, { branchRequired: true });
|
|
4519
|
+
printSuccess(MESSAGES.RESUME_FOLLOW_UP_FILE_LOADED(entries.length, filePath));
|
|
4520
|
+
const allWorktrees = getProjectWorktrees();
|
|
4521
|
+
const worktrees = [];
|
|
4522
|
+
const tasks = [];
|
|
4523
|
+
const continueFlags = [];
|
|
4524
|
+
for (const entry of entries) {
|
|
4525
|
+
const worktree = resolveWorktreeByBranch(entry.branch, allWorktrees);
|
|
4526
|
+
worktrees.push(worktree);
|
|
4527
|
+
tasks.push(entry.task);
|
|
4528
|
+
continueFlags.push(hasClaudeSessionHistory(worktree.path));
|
|
4529
|
+
}
|
|
4530
|
+
const concurrency = parseConcurrency(options.concurrency, getConfigValue("maxConcurrency"));
|
|
4531
|
+
logger.info(`resume \u547D\u4EE4\uFF08\u6279\u91CF\u8FFD\u95EE\u6A21\u5F0F\uFF09\u6267\u884C\uFF0C\u4EFB\u52A1\u6570: ${entries.length}\uFF0C\u5E76\u53D1\u6570: ${concurrency || "\u4E0D\u9650\u5236"}`);
|
|
4532
|
+
await executeBatchTasks(worktrees, tasks, concurrency, continueFlags);
|
|
4533
|
+
}
|
|
4471
4534
|
function printBatchResumePreview(worktrees, sessionMap) {
|
|
4472
4535
|
printInfo("\u5373\u5C06\u6062\u590D\u7684\u5206\u652F\uFF1A");
|
|
4473
4536
|
for (const wt of worktrees) {
|
package/dist/postinstall.js
CHANGED
|
@@ -302,7 +302,17 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
|
|
|
302
302
|
/** 批量 resume 非 macOS 平台提示 */
|
|
303
303
|
RESUME_ALL_PLATFORM_UNSUPPORTED: "\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09",
|
|
304
304
|
/** 批量 resume 无匹配分支提示 */
|
|
305
|
-
RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F
|
|
305
|
+
RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`,
|
|
306
|
+
/** --prompt 必须配合 -b 指定目标分支 */
|
|
307
|
+
RESUME_PROMPT_REQUIRES_BRANCH: "--prompt \u5FC5\u987B\u914D\u5408 -b \u6307\u5B9A\u76EE\u6807\u5206\u652F",
|
|
308
|
+
/** --prompt 和 -f 不能同时使用 */
|
|
309
|
+
RESUME_PROMPT_FILE_CONFLICT: "--prompt \u548C -f \u4E0D\u80FD\u540C\u65F6\u4F7F\u7528",
|
|
310
|
+
/** 未找到对应 worktree */
|
|
311
|
+
RESUME_WORKTREE_NOT_FOUND: (branch, available) => `\u672A\u627E\u5230\u5206\u652F "${branch}" \u5BF9\u5E94\u7684 worktree
|
|
312
|
+
\u53EF\u7528\u5206\u652F\uFF1A
|
|
313
|
+
${available.map((b) => ` - ${b}`).join("\n")}`,
|
|
314
|
+
/** 追问文件加载完成 */
|
|
315
|
+
RESUME_FOLLOW_UP_FILE_LOADED: (count, path) => `\u4ECE ${path} \u52A0\u8F7D\u4E86 ${count} \u4E2A\u8FFD\u95EE\u4EFB\u52A1`
|
|
306
316
|
};
|
|
307
317
|
|
|
308
318
|
// src/constants/messages/remove.ts
|
package/package.json
CHANGED
package/src/commands/resume.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import { logger } from '../logger/index.js';
|
|
3
|
+
import { ClawtError } from '../errors/index.js';
|
|
3
4
|
import { MESSAGES } from '../constants/index.js';
|
|
4
5
|
import type { ResumeOptions } from '../types/index.js';
|
|
5
6
|
import type { WorktreeInfo } from '../types/index.js';
|
|
@@ -12,10 +13,14 @@ import {
|
|
|
12
13
|
hasClaudeSessionHistory,
|
|
13
14
|
resolveTargetWorktrees,
|
|
14
15
|
promptGroupedMultiSelectBranches,
|
|
16
|
+
findExactMatch,
|
|
15
17
|
printInfo,
|
|
16
18
|
printSuccess,
|
|
17
19
|
confirmAction,
|
|
18
20
|
getConfigValue,
|
|
21
|
+
parseConcurrency,
|
|
22
|
+
loadTaskFile,
|
|
23
|
+
executeBatchTasks,
|
|
19
24
|
} from '../utils/index.js';
|
|
20
25
|
import type { WorktreeMultiResolveMessages } from '../utils/index.js';
|
|
21
26
|
|
|
@@ -36,6 +41,9 @@ export function registerResumeCommand(program: Command): void {
|
|
|
36
41
|
.command('resume')
|
|
37
42
|
.description('在已有 worktree 中恢复 Claude Code 会话(支持多选批量恢复)')
|
|
38
43
|
.option('-b, --branch <branchName>', '要恢复的分支名(支持模糊匹配,不传则列出所有分支)')
|
|
44
|
+
.option('--prompt <content>', '非交互式追问(需配合 -b 指定分支)')
|
|
45
|
+
.option('-f, --file <path>', '从任务文件批量追问(通过 branch 名匹配已有 worktree)')
|
|
46
|
+
.option('-c, --concurrency <n>', '批量追问最大并发数,0 表示不限制')
|
|
39
47
|
.action(async (options: ResumeOptions) => {
|
|
40
48
|
await handleResume(options);
|
|
41
49
|
});
|
|
@@ -49,6 +57,25 @@ export function registerResumeCommand(program: Command): void {
|
|
|
49
57
|
async function handleResume(options: ResumeOptions): Promise<void> {
|
|
50
58
|
await runPreChecks(PRE_CHECK_RESUME);
|
|
51
59
|
|
|
60
|
+
// 非交互式追问逻辑分支
|
|
61
|
+
if (options.prompt && options.file) {
|
|
62
|
+
throw new ClawtError(MESSAGES.RESUME_PROMPT_FILE_CONFLICT);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options.prompt) {
|
|
66
|
+
if (!options.branch) {
|
|
67
|
+
throw new ClawtError(MESSAGES.RESUME_PROMPT_REQUIRES_BRANCH);
|
|
68
|
+
}
|
|
69
|
+
const worktrees = getProjectWorktrees();
|
|
70
|
+
const worktree = resolveWorktreeByBranch(options.branch, worktrees);
|
|
71
|
+
return handleNonInteractiveSingleResume(worktree, options.prompt);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.file) {
|
|
75
|
+
return handleNonInteractiveBatchResume(options.file, options);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 原有交互式逻辑
|
|
52
79
|
logger.info(`resume 命令执行,分支过滤: ${options.branch ?? '(无)'}`);
|
|
53
80
|
const worktrees = getProjectWorktrees();
|
|
54
81
|
|
|
@@ -82,6 +109,70 @@ async function handleResume(options: ResumeOptions): Promise<void> {
|
|
|
82
109
|
}
|
|
83
110
|
}
|
|
84
111
|
|
|
112
|
+
/**
|
|
113
|
+
* 精确匹配分支对应的 worktree
|
|
114
|
+
* 找不到时抛出包含可用分支列表的错误
|
|
115
|
+
* @param {string} branch - 目标分支名
|
|
116
|
+
* @param {WorktreeInfo[]} worktrees - 可用的 worktree 列表
|
|
117
|
+
* @returns {WorktreeInfo} 匹配的 worktree
|
|
118
|
+
* @throws {ClawtError} 未找到匹配分支时抛出
|
|
119
|
+
*/
|
|
120
|
+
function resolveWorktreeByBranch(branch: string, worktrees: WorktreeInfo[]): WorktreeInfo {
|
|
121
|
+
const match = findExactMatch(worktrees, branch);
|
|
122
|
+
if (!match) {
|
|
123
|
+
const available = worktrees.map((wt) => wt.branch);
|
|
124
|
+
throw new ClawtError(MESSAGES.RESUME_WORKTREE_NOT_FOUND(branch, available));
|
|
125
|
+
}
|
|
126
|
+
return match;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 非交互式单分支追问
|
|
131
|
+
* 检查 worktree 是否有历史会话,有则通过 --continue 继续最新会话
|
|
132
|
+
* @param {WorktreeInfo} worktree - 目标 worktree
|
|
133
|
+
* @param {string} prompt - 追问内容
|
|
134
|
+
*/
|
|
135
|
+
async function handleNonInteractiveSingleResume(worktree: WorktreeInfo, prompt: string): Promise<void> {
|
|
136
|
+
// 检查是否有历史会话,有则追加 --continue
|
|
137
|
+
const hasPrevious = hasClaudeSessionHistory(worktree.path);
|
|
138
|
+
await executeBatchTasks([worktree], [prompt], 0, [hasPrevious]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 非交互式批量追问
|
|
143
|
+
* 从任务文件解析追问内容,按 branch 名精确匹配已有 worktree,批量执行
|
|
144
|
+
* @param {string} filePath - 追问任务文件路径
|
|
145
|
+
* @param {ResumeOptions} options - 命令选项(含并发数配置)
|
|
146
|
+
*/
|
|
147
|
+
async function handleNonInteractiveBatchResume(filePath: string, options: ResumeOptions): Promise<void> {
|
|
148
|
+
// 解析追问文件,branch 为必填字段
|
|
149
|
+
const entries = loadTaskFile(filePath, { branchRequired: true });
|
|
150
|
+
printSuccess(MESSAGES.RESUME_FOLLOW_UP_FILE_LOADED(entries.length, filePath));
|
|
151
|
+
|
|
152
|
+
// 获取所有已有 worktree
|
|
153
|
+
const allWorktrees = getProjectWorktrees();
|
|
154
|
+
|
|
155
|
+
// 按 branch 名精确匹配 worktree,构建执行参数
|
|
156
|
+
const worktrees: WorktreeInfo[] = [];
|
|
157
|
+
const tasks: string[] = [];
|
|
158
|
+
const continueFlags: boolean[] = [];
|
|
159
|
+
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const worktree = resolveWorktreeByBranch(entry.branch!, allWorktrees);
|
|
162
|
+
worktrees.push(worktree);
|
|
163
|
+
tasks.push(entry.task);
|
|
164
|
+
// 按 worktree 独立检查是否有历史会话
|
|
165
|
+
continueFlags.push(hasClaudeSessionHistory(worktree.path));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 解析并发数
|
|
169
|
+
const concurrency = parseConcurrency(options.concurrency, getConfigValue('maxConcurrency'));
|
|
170
|
+
|
|
171
|
+
logger.info(`resume 命令(批量追问模式)执行,任务数: ${entries.length},并发数: ${concurrency || '不限制'}`);
|
|
172
|
+
|
|
173
|
+
await executeBatchTasks(worktrees, tasks, concurrency, continueFlags);
|
|
174
|
+
}
|
|
175
|
+
|
|
85
176
|
/**
|
|
86
177
|
* 输出即将恢复的分支列表(含会话状态:继续/新对话)
|
|
87
178
|
* @param {WorktreeInfo[]} worktrees - 待恢复的 worktree 列表
|
|
@@ -18,4 +18,14 @@ export const RESUME_MESSAGES = {
|
|
|
18
18
|
RESUME_ALL_PLATFORM_UNSUPPORTED: '批量 resume 目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)',
|
|
19
19
|
/** 批量 resume 无匹配分支提示 */
|
|
20
20
|
RESUME_ALL_NO_MATCH: (keyword: string) => `未找到与 "${keyword}" 匹配的分支`,
|
|
21
|
+
|
|
22
|
+
/** --prompt 必须配合 -b 指定目标分支 */
|
|
23
|
+
RESUME_PROMPT_REQUIRES_BRANCH: '--prompt 必须配合 -b 指定目标分支',
|
|
24
|
+
/** --prompt 和 -f 不能同时使用 */
|
|
25
|
+
RESUME_PROMPT_FILE_CONFLICT: '--prompt 和 -f 不能同时使用',
|
|
26
|
+
/** 未找到对应 worktree */
|
|
27
|
+
RESUME_WORKTREE_NOT_FOUND: (branch: string, available: string[]) =>
|
|
28
|
+
`未找到分支 "${branch}" 对应的 worktree\n 可用分支:\n${available.map((b) => ` - ${b}`).join('\n')}`,
|
|
29
|
+
/** 追问文件加载完成 */
|
|
30
|
+
RESUME_FOLLOW_UP_FILE_LOADED: (count: number, path: string) => `从 ${path} 加载了 ${count} 个追问任务`,
|
|
21
31
|
} as const;
|
package/src/types/command.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface RemoveOptions {
|
|
|
52
52
|
export interface ResumeOptions {
|
|
53
53
|
/** 要恢复的分支名(可选,不传则列出所有分支供选择) */
|
|
54
54
|
branch?: string;
|
|
55
|
+
/** 非交互式追问内容(需配合 -b 指定分支) */
|
|
56
|
+
prompt?: string;
|
|
57
|
+
/** 从任务文件批量追问(通过 branch 名匹配已有 worktree) */
|
|
58
|
+
file?: string;
|
|
59
|
+
/** 批量追问最大并发数,0 表示不限制(Commander 传入为字符串) */
|
|
60
|
+
concurrency?: string;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/** sync 命令选项 */
|
|
@@ -9,6 +9,7 @@ import { printSuccess, printWarning, printInfo, printDoubleSeparator, confirmAct
|
|
|
9
9
|
import { ProgressRenderer } from './progress.js';
|
|
10
10
|
import { createLineBuffer, parseStreamLine, parseStreamEvent, truncateText } from './stream-parser.js';
|
|
11
11
|
import { RESULT_PREVIEW_MAX_LENGTH } from '../constants/index.js';
|
|
12
|
+
import { ClawtError } from '../errors/index.js';
|
|
12
13
|
|
|
13
14
|
/** executeClaudeTask 的返回结构,包含子进程引用和结果 Promise */
|
|
14
15
|
interface ClaudeTaskHandle {
|
|
@@ -29,13 +30,21 @@ type ActivityCallback = (activityText: string) => void;
|
|
|
29
30
|
* @param {WorktreeInfo} worktree - worktree 信息
|
|
30
31
|
* @param {string} task - 任务描述
|
|
31
32
|
* @param {ActivityCallback} [onActivity] - 活动更新回调(可选)
|
|
33
|
+
* @param {boolean} [continueSession] - 是否继续该 worktree 目录下最新的会话
|
|
32
34
|
* @returns {ClaudeTaskHandle} 包含子进程引用和结果 Promise
|
|
33
35
|
*/
|
|
34
|
-
function executeClaudeTask(worktree: WorktreeInfo, task: string, onActivity?: ActivityCallback): ClaudeTaskHandle {
|
|
36
|
+
function executeClaudeTask(worktree: WorktreeInfo, task: string, onActivity?: ActivityCallback, continueSession?: boolean): ClaudeTaskHandle {
|
|
35
37
|
// 旧版使用 --output-format json,现改为 stream-json --verbose 以支持实时活动信息
|
|
38
|
+
const args = ['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions'];
|
|
39
|
+
|
|
40
|
+
// 追问模式:追加 --continue 继续该目录下最新会话
|
|
41
|
+
if (continueSession) {
|
|
42
|
+
args.push('--continue');
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
const child = spawnProcess(
|
|
37
46
|
'claude',
|
|
38
|
-
|
|
47
|
+
args,
|
|
39
48
|
{
|
|
40
49
|
cwd: worktree.path,
|
|
41
50
|
// stdin 必须设置为 'ignore',不能用 'pipe'
|
|
@@ -198,6 +207,7 @@ function updateRendererStatus(renderer: ProgressRenderer, index: number, result:
|
|
|
198
207
|
* @param {number} startTime - 任务批次启动时间戳
|
|
199
208
|
* @param {() => boolean} isInterrupted - 检查是否已中断的函数
|
|
200
209
|
* @param {ChildProcess[]} childProcesses - 共享子进程数组,执行过程中动态追加
|
|
210
|
+
* @param {boolean[]} [continueFlags] - 按索引对应每个任务是否继续已有会话(追问模式,追加 --continue)
|
|
201
211
|
* @returns {Promise<TaskResult[]>} 所有任务结果
|
|
202
212
|
*/
|
|
203
213
|
async function executeWithConcurrency(
|
|
@@ -208,6 +218,7 @@ async function executeWithConcurrency(
|
|
|
208
218
|
startTime: number,
|
|
209
219
|
isInterrupted: () => boolean,
|
|
210
220
|
childProcesses: ChildProcess[],
|
|
221
|
+
continueFlags?: boolean[],
|
|
211
222
|
): Promise<TaskResult[]> {
|
|
212
223
|
const total = tasks.length;
|
|
213
224
|
const results: TaskResult[] = new Array(total);
|
|
@@ -234,7 +245,7 @@ async function executeWithConcurrency(
|
|
|
234
245
|
|
|
235
246
|
const handle = executeClaudeTask(wt, task, (activityText) => {
|
|
236
247
|
renderer.updateActivityText(index, activityText);
|
|
237
|
-
});
|
|
248
|
+
}, continueFlags?.[index]);
|
|
238
249
|
childProcesses.push(handle.child);
|
|
239
250
|
|
|
240
251
|
handle.promise.then((result) => {
|
|
@@ -272,6 +283,7 @@ async function executeWithConcurrency(
|
|
|
272
283
|
* @param {number} startTime - 任务批次启动时间戳
|
|
273
284
|
* @param {() => boolean} isInterrupted - 检查是否已中断的函数
|
|
274
285
|
* @param {ChildProcess[]} childProcesses - 共享子进程数组,启动时追加
|
|
286
|
+
* @param {boolean[]} [continueFlags] - 按索引对应每个任务是否继续已有会话(追问模式,追加 --continue)
|
|
275
287
|
* @returns {Promise<TaskResult[]>} 所有任务结果
|
|
276
288
|
*/
|
|
277
289
|
async function executeAllParallel(
|
|
@@ -281,13 +293,14 @@ async function executeAllParallel(
|
|
|
281
293
|
startTime: number,
|
|
282
294
|
isInterrupted: () => boolean,
|
|
283
295
|
childProcesses: ChildProcess[],
|
|
296
|
+
continueFlags?: boolean[],
|
|
284
297
|
): Promise<TaskResult[]> {
|
|
285
298
|
const handles = worktrees.map((wt, index) => {
|
|
286
299
|
const task = tasks[index];
|
|
287
300
|
logger.info(`启动任务 ${index + 1}: ${task} (worktree: ${wt.path})`);
|
|
288
301
|
const handle = executeClaudeTask(wt, task, (activityText) => {
|
|
289
302
|
renderer.updateActivityText(index, activityText);
|
|
290
|
-
});
|
|
303
|
+
}, continueFlags?.[index]);
|
|
291
304
|
childProcesses.push(handle.child);
|
|
292
305
|
|
|
293
306
|
return handle;
|
|
@@ -314,14 +327,25 @@ async function executeAllParallel(
|
|
|
314
327
|
* @param {WorktreeInfo[]} worktrees - worktree 列表
|
|
315
328
|
* @param {string[]} tasks - 任务描述列表
|
|
316
329
|
* @param {number} concurrency - 最大并发数,0 表示不限制
|
|
330
|
+
* @param {boolean[]} [continueFlags] - 按索引对应每个任务是否继续已有会话(追问模式,追加 --continue)
|
|
331
|
+
* @returns {Promise<TaskResult[]>} 所有任务的执行结果
|
|
332
|
+
* @throws {ClawtError} continueFlags 长度与任务数不一致时抛出
|
|
317
333
|
*/
|
|
318
334
|
export async function executeBatchTasks(
|
|
319
335
|
worktrees: WorktreeInfo[],
|
|
320
336
|
tasks: string[],
|
|
321
337
|
concurrency: number,
|
|
322
|
-
|
|
338
|
+
continueFlags?: boolean[],
|
|
339
|
+
): Promise<TaskResult[]> {
|
|
323
340
|
const count = tasks.length;
|
|
324
341
|
|
|
342
|
+
// 校验 continueFlags 长度与 worktrees 一致,防止调用方传入不匹配的数组
|
|
343
|
+
if (continueFlags && continueFlags.length !== count) {
|
|
344
|
+
throw new ClawtError(
|
|
345
|
+
`continueFlags 长度 (${continueFlags.length}) 与任务数 (${count}) 不一致`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
325
349
|
// 有并发限制时输出提示
|
|
326
350
|
if (concurrency > 0) {
|
|
327
351
|
printInfo(MESSAGES.CONCURRENCY_INFO(concurrency, count));
|
|
@@ -374,15 +398,15 @@ export async function executeBatchTasks(
|
|
|
374
398
|
|
|
375
399
|
// 根据并发限制选择执行模式
|
|
376
400
|
const results = concurrency > 0
|
|
377
|
-
? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses)
|
|
378
|
-
: await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses);
|
|
401
|
+
? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses, continueFlags)
|
|
402
|
+
: await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses, continueFlags);
|
|
379
403
|
|
|
380
404
|
// 正常完成,停止进度面板并移除 SIGINT 监听器
|
|
381
405
|
renderer.stop();
|
|
382
406
|
process.removeListener('SIGINT', sigintHandler);
|
|
383
407
|
|
|
384
408
|
// 被中断时不输出汇总(已在 sigintHandler 中处理退出)
|
|
385
|
-
if (interrupted) return;
|
|
409
|
+
if (interrupted) return [];
|
|
386
410
|
|
|
387
411
|
const totalDurationMs = Date.now() - startTime;
|
|
388
412
|
|
|
@@ -396,4 +420,6 @@ export async function executeBatchTasks(
|
|
|
396
420
|
};
|
|
397
421
|
|
|
398
422
|
printTaskSummary(summary);
|
|
423
|
+
|
|
424
|
+
return results;
|
|
399
425
|
}
|
|
@@ -5,6 +5,16 @@ vi.mock('../../../src/logger/index.js', () => ({
|
|
|
5
5
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
6
6
|
}));
|
|
7
7
|
|
|
8
|
+
vi.mock('../../../src/errors/index.js', () => ({
|
|
9
|
+
ClawtError: class ClawtError extends Error {
|
|
10
|
+
exitCode: number;
|
|
11
|
+
constructor(message: string, exitCode = 1) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.exitCode = exitCode;
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
8
18
|
vi.mock('../../../src/constants/index.js', async (importOriginal) => {
|
|
9
19
|
const actual = await importOriginal<typeof import('../../../src/constants/index.js')>();
|
|
10
20
|
return {
|
|
@@ -16,6 +26,11 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
|
|
|
16
26
|
RESUME_NO_MATCH: (keyword: string, branches: string[]) => `未找到匹配 "${keyword}" 的分支`,
|
|
17
27
|
RESUME_ALL_CONFIRM: (count: number) => `确认恢复 ${count} 个分支?`,
|
|
18
28
|
RESUME_ALL_SUCCESS: (count: number) => `已恢复 ${count} 个分支`,
|
|
29
|
+
RESUME_PROMPT_REQUIRES_BRANCH: '--prompt 必须配合 -b 指定目标分支',
|
|
30
|
+
RESUME_PROMPT_FILE_CONFLICT: '--prompt 和 -f 不能同时使用',
|
|
31
|
+
RESUME_WORKTREE_NOT_FOUND: (branch: string, available: string[]) => `未找到分支 "${branch}" 对应的 worktree`,
|
|
32
|
+
RESUME_FOLLOW_UP_FILE_LOADED: (count: number, path: string) => `从 ${path} 加载了 ${count} 个追问任务`,
|
|
33
|
+
CONCURRENCY_INFO: (concurrency: number, total: number) => `并发限制: ${concurrency},共 ${total} 个任务`,
|
|
19
34
|
},
|
|
20
35
|
};
|
|
21
36
|
});
|
|
@@ -29,48 +44,64 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
29
44
|
hasClaudeSessionHistory: vi.fn(),
|
|
30
45
|
resolveTargetWorktrees: vi.fn(),
|
|
31
46
|
promptGroupedMultiSelectBranches: vi.fn(),
|
|
47
|
+
findExactMatch: vi.fn(),
|
|
32
48
|
printInfo: vi.fn(),
|
|
33
49
|
printSuccess: vi.fn(),
|
|
50
|
+
printWarning: vi.fn(),
|
|
34
51
|
confirmAction: vi.fn(),
|
|
35
52
|
getConfigValue: vi.fn(),
|
|
53
|
+
parseConcurrency: vi.fn().mockReturnValue(0),
|
|
54
|
+
loadTaskFile: vi.fn(),
|
|
55
|
+
executeBatchTasks: vi.fn().mockResolvedValue([]),
|
|
36
56
|
}));
|
|
37
57
|
|
|
38
58
|
import { registerResumeCommand } from '../../../src/commands/resume.js';
|
|
39
59
|
import {
|
|
40
60
|
runPreChecks,
|
|
41
|
-
validateClaudeCodeInstalled,
|
|
42
61
|
getProjectWorktrees,
|
|
43
62
|
launchInteractiveClaude,
|
|
44
63
|
launchInteractiveClaudeInNewTerminal,
|
|
45
64
|
hasClaudeSessionHistory,
|
|
46
65
|
resolveTargetWorktrees,
|
|
47
66
|
promptGroupedMultiSelectBranches,
|
|
67
|
+
findExactMatch,
|
|
48
68
|
confirmAction,
|
|
49
69
|
getConfigValue,
|
|
70
|
+
parseConcurrency,
|
|
71
|
+
loadTaskFile,
|
|
72
|
+
executeBatchTasks,
|
|
50
73
|
} from '../../../src/utils/index.js';
|
|
51
74
|
|
|
52
75
|
const mockedRunPreChecks = vi.mocked(runPreChecks);
|
|
53
|
-
const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
|
|
54
76
|
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
55
77
|
const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
|
|
56
78
|
const mockedLaunchInteractiveClaudeInNewTerminal = vi.mocked(launchInteractiveClaudeInNewTerminal);
|
|
57
79
|
const mockedHasClaudeSessionHistory = vi.mocked(hasClaudeSessionHistory);
|
|
58
80
|
const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
|
|
59
81
|
const mockedPromptGroupedMultiSelectBranches = vi.mocked(promptGroupedMultiSelectBranches);
|
|
82
|
+
const mockedFindExactMatch = vi.mocked(findExactMatch);
|
|
60
83
|
const mockedConfirmAction = vi.mocked(confirmAction);
|
|
61
84
|
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
85
|
+
const mockedParseConcurrency = vi.mocked(parseConcurrency);
|
|
86
|
+
const mockedLoadTaskFile = vi.mocked(loadTaskFile);
|
|
87
|
+
const mockedExecuteBatchTasks = vi.mocked(executeBatchTasks);
|
|
62
88
|
|
|
63
89
|
beforeEach(() => {
|
|
64
90
|
mockedRunPreChecks.mockReset();
|
|
65
|
-
mockedValidateClaudeCodeInstalled.mockReset();
|
|
66
91
|
mockedGetProjectWorktrees.mockReset();
|
|
67
92
|
mockedLaunchInteractiveClaude.mockReset();
|
|
68
93
|
mockedLaunchInteractiveClaudeInNewTerminal.mockReset();
|
|
69
94
|
mockedHasClaudeSessionHistory.mockReset();
|
|
70
95
|
mockedResolveTargetWorktrees.mockReset();
|
|
71
96
|
mockedPromptGroupedMultiSelectBranches.mockReset();
|
|
97
|
+
mockedFindExactMatch.mockReset();
|
|
72
98
|
mockedConfirmAction.mockReset();
|
|
73
99
|
mockedGetConfigValue.mockReset();
|
|
100
|
+
mockedParseConcurrency.mockReset();
|
|
101
|
+
mockedParseConcurrency.mockReturnValue(0);
|
|
102
|
+
mockedLoadTaskFile.mockReset();
|
|
103
|
+
mockedExecuteBatchTasks.mockReset();
|
|
104
|
+
mockedExecuteBatchTasks.mockResolvedValue([]);
|
|
74
105
|
});
|
|
75
106
|
|
|
76
107
|
describe('registerResumeCommand', () => {
|
|
@@ -80,6 +111,16 @@ describe('registerResumeCommand', () => {
|
|
|
80
111
|
const cmd = program.commands.find((c) => c.name() === 'resume');
|
|
81
112
|
expect(cmd).toBeDefined();
|
|
82
113
|
});
|
|
114
|
+
|
|
115
|
+
it('注册 --prompt、-f、-c 选项', () => {
|
|
116
|
+
const program = new Command();
|
|
117
|
+
registerResumeCommand(program);
|
|
118
|
+
const cmd = program.commands.find((c) => c.name() === 'resume');
|
|
119
|
+
const options = cmd!.options.map((o) => o.long);
|
|
120
|
+
expect(options).toContain('--prompt');
|
|
121
|
+
expect(options).toContain('--file');
|
|
122
|
+
expect(options).toContain('--concurrency');
|
|
123
|
+
});
|
|
83
124
|
});
|
|
84
125
|
|
|
85
126
|
describe('handleResume', () => {
|
|
@@ -226,3 +267,200 @@ describe('handleResume — resumeInPlace 配置', () => {
|
|
|
226
267
|
expect(mockedGetConfigValue).not.toHaveBeenCalled();
|
|
227
268
|
});
|
|
228
269
|
});
|
|
270
|
+
|
|
271
|
+
describe('handleResume — 非交互式追问', () => {
|
|
272
|
+
it('--prompt + -b 有历史会话时传 [true]', async () => {
|
|
273
|
+
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
274
|
+
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
275
|
+
mockedFindExactMatch.mockReturnValue(worktree);
|
|
276
|
+
mockedHasClaudeSessionHistory.mockReturnValue(true);
|
|
277
|
+
mockedExecuteBatchTasks.mockResolvedValue([]);
|
|
278
|
+
|
|
279
|
+
const program = new Command();
|
|
280
|
+
program.exitOverride();
|
|
281
|
+
registerResumeCommand(program);
|
|
282
|
+
await program.parseAsync(['resume', '-b', 'feature', '--prompt', '加上单元测试'], { from: 'user' });
|
|
283
|
+
|
|
284
|
+
expect(mockedFindExactMatch).toHaveBeenCalled();
|
|
285
|
+
expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith(worktree.path);
|
|
286
|
+
// 有历史会话时使用 --continue 模式
|
|
287
|
+
expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
|
|
288
|
+
[worktree],
|
|
289
|
+
['加上单元测试'],
|
|
290
|
+
0,
|
|
291
|
+
[true],
|
|
292
|
+
);
|
|
293
|
+
// 不应走交互式流程
|
|
294
|
+
expect(mockedResolveTargetWorktrees).not.toHaveBeenCalled();
|
|
295
|
+
expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('--prompt + -b 无历史会话时传 [false]', async () => {
|
|
299
|
+
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
300
|
+
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
301
|
+
mockedFindExactMatch.mockReturnValue(worktree);
|
|
302
|
+
mockedHasClaudeSessionHistory.mockReturnValue(false);
|
|
303
|
+
mockedExecuteBatchTasks.mockResolvedValue([]);
|
|
304
|
+
|
|
305
|
+
const program = new Command();
|
|
306
|
+
program.exitOverride();
|
|
307
|
+
registerResumeCommand(program);
|
|
308
|
+
await program.parseAsync(['resume', '-b', 'feature', '--prompt', '加上单元测试'], { from: 'user' });
|
|
309
|
+
|
|
310
|
+
expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith(worktree.path);
|
|
311
|
+
// 无历史会话时不传 --continue
|
|
312
|
+
expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
|
|
313
|
+
[worktree],
|
|
314
|
+
['加上单元测试'],
|
|
315
|
+
0,
|
|
316
|
+
[false],
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('--prompt 无 -b 时报错', async () => {
|
|
321
|
+
const program = new Command();
|
|
322
|
+
program.exitOverride();
|
|
323
|
+
registerResumeCommand(program);
|
|
324
|
+
|
|
325
|
+
await expect(
|
|
326
|
+
program.parseAsync(['resume', '--prompt', '加上单元测试'], { from: 'user' }),
|
|
327
|
+
).rejects.toThrow('--prompt 必须配合 -b 指定目标分支');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('--prompt 和 -f 同时使用时报错', async () => {
|
|
331
|
+
const program = new Command();
|
|
332
|
+
program.exitOverride();
|
|
333
|
+
registerResumeCommand(program);
|
|
334
|
+
|
|
335
|
+
await expect(
|
|
336
|
+
program.parseAsync(['resume', '-b', 'feature', '--prompt', '追问', '-f', 'tasks.md'], { from: 'user' }),
|
|
337
|
+
).rejects.toThrow('--prompt 和 -f 不能同时使用');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('--prompt 指定的分支不存在时报错', async () => {
|
|
341
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
342
|
+
{ path: '/path/other', branch: 'other' },
|
|
343
|
+
]);
|
|
344
|
+
mockedFindExactMatch.mockReturnValue(undefined);
|
|
345
|
+
|
|
346
|
+
const program = new Command();
|
|
347
|
+
program.exitOverride();
|
|
348
|
+
registerResumeCommand(program);
|
|
349
|
+
|
|
350
|
+
await expect(
|
|
351
|
+
program.parseAsync(['resume', '-b', 'nonexistent', '--prompt', '追问'], { from: 'user' }),
|
|
352
|
+
).rejects.toThrow('未找到分支');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('-f 批量追问模式', async () => {
|
|
356
|
+
const worktrees = [
|
|
357
|
+
{ path: '/path/feat-a', branch: 'feat-a' },
|
|
358
|
+
{ path: '/path/feat-b', branch: 'feat-b' },
|
|
359
|
+
];
|
|
360
|
+
mockedLoadTaskFile.mockReturnValue([
|
|
361
|
+
{ branch: 'feat-a', task: '追问任务A' },
|
|
362
|
+
{ branch: 'feat-b', task: '追问任务B' },
|
|
363
|
+
]);
|
|
364
|
+
mockedGetProjectWorktrees.mockReturnValue(worktrees);
|
|
365
|
+
mockedFindExactMatch
|
|
366
|
+
.mockReturnValueOnce(worktrees[0])
|
|
367
|
+
.mockReturnValueOnce(worktrees[1]);
|
|
368
|
+
mockedHasClaudeSessionHistory.mockReturnValue(true);
|
|
369
|
+
mockedExecuteBatchTasks.mockResolvedValue([]);
|
|
370
|
+
|
|
371
|
+
const program = new Command();
|
|
372
|
+
program.exitOverride();
|
|
373
|
+
registerResumeCommand(program);
|
|
374
|
+
await program.parseAsync(['resume', '-f', 'follow-up.md'], { from: 'user' });
|
|
375
|
+
|
|
376
|
+
expect(mockedLoadTaskFile).toHaveBeenCalledWith('follow-up.md', { branchRequired: true });
|
|
377
|
+
// 按 worktree 独立检查会话历史
|
|
378
|
+
expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith('/path/feat-a');
|
|
379
|
+
expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith('/path/feat-b');
|
|
380
|
+
expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
|
|
381
|
+
worktrees,
|
|
382
|
+
['追问任务A', '追问任务B'],
|
|
383
|
+
0,
|
|
384
|
+
[true, true],
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('-f 批量追问分支不存在时报错', async () => {
|
|
389
|
+
mockedLoadTaskFile.mockReturnValue([
|
|
390
|
+
{ branch: 'nonexistent', task: '追问任务' },
|
|
391
|
+
]);
|
|
392
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
393
|
+
{ path: '/path/feat-a', branch: 'feat-a' },
|
|
394
|
+
]);
|
|
395
|
+
mockedFindExactMatch.mockReturnValue(undefined);
|
|
396
|
+
|
|
397
|
+
const program = new Command();
|
|
398
|
+
program.exitOverride();
|
|
399
|
+
registerResumeCommand(program);
|
|
400
|
+
|
|
401
|
+
await expect(
|
|
402
|
+
program.parseAsync(['resume', '-f', 'follow-up.md'], { from: 'user' }),
|
|
403
|
+
).rejects.toThrow('未找到分支');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('-f + -c 传递并发数', async () => {
|
|
407
|
+
const worktree = { path: '/path/feat-a', branch: 'feat-a' };
|
|
408
|
+
mockedLoadTaskFile.mockReturnValue([
|
|
409
|
+
{ branch: 'feat-a', task: '追问' },
|
|
410
|
+
]);
|
|
411
|
+
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
412
|
+
mockedFindExactMatch.mockReturnValue(worktree);
|
|
413
|
+
mockedHasClaudeSessionHistory.mockReturnValue(true);
|
|
414
|
+
mockedParseConcurrency.mockReturnValue(2);
|
|
415
|
+
mockedExecuteBatchTasks.mockResolvedValue([]);
|
|
416
|
+
|
|
417
|
+
const program = new Command();
|
|
418
|
+
program.exitOverride();
|
|
419
|
+
registerResumeCommand(program);
|
|
420
|
+
await program.parseAsync(['resume', '-f', 'follow-up.md', '-c', '2'], { from: 'user' });
|
|
421
|
+
|
|
422
|
+
expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
|
|
423
|
+
[worktree],
|
|
424
|
+
['追问'],
|
|
425
|
+
2,
|
|
426
|
+
[true],
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('-f 批量追问按 worktree 独立检查会话历史', async () => {
|
|
431
|
+
const worktrees = [
|
|
432
|
+
{ path: '/path/feat-a', branch: 'feat-a' },
|
|
433
|
+
{ path: '/path/feat-b', branch: 'feat-b' },
|
|
434
|
+
{ path: '/path/feat-c', branch: 'feat-c' },
|
|
435
|
+
];
|
|
436
|
+
mockedLoadTaskFile.mockReturnValue([
|
|
437
|
+
{ branch: 'feat-a', task: '任务A' },
|
|
438
|
+
{ branch: 'feat-b', task: '任务B' },
|
|
439
|
+
{ branch: 'feat-c', task: '任务C' },
|
|
440
|
+
]);
|
|
441
|
+
mockedGetProjectWorktrees.mockReturnValue(worktrees);
|
|
442
|
+
mockedFindExactMatch
|
|
443
|
+
.mockReturnValueOnce(worktrees[0])
|
|
444
|
+
.mockReturnValueOnce(worktrees[1])
|
|
445
|
+
.mockReturnValueOnce(worktrees[2]);
|
|
446
|
+
// feat-a 有历史会话,feat-b 无,feat-c 有
|
|
447
|
+
mockedHasClaudeSessionHistory
|
|
448
|
+
.mockReturnValueOnce(true)
|
|
449
|
+
.mockReturnValueOnce(false)
|
|
450
|
+
.mockReturnValueOnce(true);
|
|
451
|
+
mockedExecuteBatchTasks.mockResolvedValue([]);
|
|
452
|
+
|
|
453
|
+
const program = new Command();
|
|
454
|
+
program.exitOverride();
|
|
455
|
+
registerResumeCommand(program);
|
|
456
|
+
await program.parseAsync(['resume', '-f', 'follow-up.md'], { from: 'user' });
|
|
457
|
+
|
|
458
|
+
// continueFlags 应按 worktree 独立反映各自的会话历史状态
|
|
459
|
+
expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
|
|
460
|
+
worktrees,
|
|
461
|
+
['任务A', '任务B', '任务C'],
|
|
462
|
+
0,
|
|
463
|
+
[true, false, true],
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
});
|