clawt 2.10.1 → 2.11.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/.claude/agent-memory/docs-sync-updater/MEMORY.md +5 -1
- package/README.md +25 -2
- package/dist/index.js +603 -170
- package/dist/postinstall.js +31 -1
- package/docs/spec.md +57 -8
- package/package.json +1 -1
- package/src/commands/run.ts +68 -206
- package/src/constants/config.ts +4 -0
- package/src/constants/index.ts +10 -0
- package/src/constants/messages/run.ts +30 -0
- package/src/constants/progress.ts +39 -0
- package/src/types/command.ts +4 -0
- package/src/types/config.ts +2 -0
- package/src/types/index.ts +1 -0
- package/src/types/taskFile.ts +13 -0
- package/src/utils/formatter.ts +16 -0
- package/src/utils/index.ts +6 -2
- package/src/utils/progress-render.ts +90 -0
- package/src/utils/progress.ts +213 -0
- package/src/utils/task-executor.ts +365 -0
- package/src/utils/task-file.ts +87 -0
- package/src/utils/worktree.ts +27 -0
- package/tests/unit/commands/run.test.ts +259 -10
- package/tests/unit/constants/config.test.ts +1 -0
- package/tests/unit/utils/formatter.test.ts +27 -1
- package/tests/unit/utils/progress.test.ts +255 -0
- package/tests/unit/utils/task-file.test.ts +236 -0
- package/tests/unit/utils/worktree.test.ts +26 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { ClawtError } from '../errors/index.js';
|
|
4
|
+
import { MESSAGES } from '../constants/index.js';
|
|
5
|
+
import type { TaskFileEntry, ParseTaskFileOptions } from '../types/index.js';
|
|
6
|
+
|
|
7
|
+
/** 匹配任务块的正则:<!-- CLAWT-TASKS:START --> ... <!-- CLAWT-TASKS:END --> */
|
|
8
|
+
const TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
|
|
9
|
+
|
|
10
|
+
/** 匹配分支名行的正则:# branch: <name> */
|
|
11
|
+
const BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 解析任务文件内容,提取所有任务块
|
|
15
|
+
* 每个块内 `# branch: <name>` 为分支名,其余行为任务描述
|
|
16
|
+
* @param {string} content - 文件内容
|
|
17
|
+
* @param {ParseTaskFileOptions} [options] - 解析选项
|
|
18
|
+
* @returns {TaskFileEntry[]} 解析出的任务列表
|
|
19
|
+
*/
|
|
20
|
+
export function parseTaskFile(content: string, options?: ParseTaskFileOptions): TaskFileEntry[] {
|
|
21
|
+
const branchRequired = options?.branchRequired ?? true;
|
|
22
|
+
const entries: TaskFileEntry[] = [];
|
|
23
|
+
let match: RegExpExecArray | null;
|
|
24
|
+
let blockIndex = 0;
|
|
25
|
+
|
|
26
|
+
// 重置正则 lastIndex,避免模块级 /g 正则跨调用残留状态
|
|
27
|
+
TASK_BLOCK_REGEX.lastIndex = 0;
|
|
28
|
+
|
|
29
|
+
while ((match = TASK_BLOCK_REGEX.exec(content)) !== null) {
|
|
30
|
+
blockIndex++;
|
|
31
|
+
const blockContent = match[1].trim();
|
|
32
|
+
const lines = blockContent.split('\n');
|
|
33
|
+
|
|
34
|
+
let branch: string | undefined;
|
|
35
|
+
const taskLines: string[] = [];
|
|
36
|
+
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
const branchMatch = line.trim().match(BRANCH_LINE_REGEX);
|
|
39
|
+
if (branchMatch) {
|
|
40
|
+
branch = branchMatch[1].trim();
|
|
41
|
+
} else {
|
|
42
|
+
taskLines.push(line);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (branchRequired && !branch) {
|
|
47
|
+
throw new ClawtError(MESSAGES.TASK_FILE_MISSING_BRANCH(blockIndex));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const task = taskLines.join('\n').trim();
|
|
51
|
+
if (!task) {
|
|
52
|
+
throw new ClawtError(
|
|
53
|
+
branch
|
|
54
|
+
? MESSAGES.TASK_FILE_MISSING_TASK(branch)
|
|
55
|
+
: MESSAGES.TASK_FILE_MISSING_TASK_BY_INDEX(blockIndex),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
entries.push({ branch, task });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return entries;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 读取并解析任务文件
|
|
67
|
+
* 支持相对路径(基于 cwd)和绝对路径
|
|
68
|
+
* @param {string} filePath - 文件路径
|
|
69
|
+
* @param {ParseTaskFileOptions} [options] - 解析选项
|
|
70
|
+
* @returns {TaskFileEntry[]} 解析出的任务列表
|
|
71
|
+
*/
|
|
72
|
+
export function loadTaskFile(filePath: string, options?: ParseTaskFileOptions): TaskFileEntry[] {
|
|
73
|
+
const absolutePath = resolve(filePath);
|
|
74
|
+
|
|
75
|
+
if (!existsSync(absolutePath)) {
|
|
76
|
+
throw new ClawtError(MESSAGES.TASK_FILE_NOT_FOUND(absolutePath));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const content = readFileSync(absolutePath, 'utf-8');
|
|
80
|
+
const entries = parseTaskFile(content, options);
|
|
81
|
+
|
|
82
|
+
if (entries.length === 0) {
|
|
83
|
+
throw new ClawtError(MESSAGES.TASK_FILE_EMPTY);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
package/src/utils/worktree.ts
CHANGED
|
@@ -49,6 +49,33 @@ export function createWorktrees(branchName: string, count: number): WorktreeInfo
|
|
|
49
49
|
return results;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 根据独立分支名列表逐个创建 worktree(不自动编号)
|
|
54
|
+
* 与 createWorktrees 不同,不使用 generateBranchNames 自动编号
|
|
55
|
+
* 调用方负责分支名清理(sanitizeBranchName)
|
|
56
|
+
* @param {string[]} branchNames - 已清理的分支名列表
|
|
57
|
+
* @returns {WorktreeInfo[]} 创建的 worktree 信息列表
|
|
58
|
+
*/
|
|
59
|
+
export function createWorktreesByBranches(branchNames: string[]): WorktreeInfo[] {
|
|
60
|
+
// 1. 校验所有分支是否都不存在
|
|
61
|
+
validateBranchesNotExist(branchNames);
|
|
62
|
+
|
|
63
|
+
// 2. 确保项目 worktree 目录存在
|
|
64
|
+
const projectDir = getProjectWorktreeDir();
|
|
65
|
+
ensureDir(projectDir);
|
|
66
|
+
|
|
67
|
+
// 3. 串行创建 worktree
|
|
68
|
+
const results: WorktreeInfo[] = [];
|
|
69
|
+
for (const name of branchNames) {
|
|
70
|
+
const worktreePath = join(projectDir, name);
|
|
71
|
+
gitCreateWorktree(name, worktreePath);
|
|
72
|
+
results.push({ path: worktreePath, branch: name });
|
|
73
|
+
logger.info(`worktree 创建完成: ${worktreePath} (分支: ${name})`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
/**
|
|
53
80
|
* 获取当前项目在 ~/.clawt/worktrees/<project>/ 下的所有 worktree
|
|
54
81
|
* 通过与 git worktree list 交叉验证确认有效性
|
|
@@ -25,19 +25,67 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
25
25
|
INTERRUPT_CONFIRM_CLEANUP: '是否清理已创建的 worktree?',
|
|
26
26
|
INTERRUPT_CLEANED: (count: number) => `已清理 ${count} 个 worktree`,
|
|
27
27
|
INTERRUPT_KEPT: '已保留 worktree',
|
|
28
|
+
CONCURRENCY_INFO: (concurrency: number, total: number) => `并发限制: ${concurrency},共 ${total} 个任务`,
|
|
29
|
+
CONCURRENCY_INVALID: '并发数必须为正整数',
|
|
30
|
+
FILE_AND_TASKS_CONFLICT: '--file 和 --tasks 不能同时使用',
|
|
31
|
+
BRANCH_OR_FILE_REQUIRED: '请指定 -b 或 -f',
|
|
32
|
+
TASK_FILE_LOADED: (count: number, path: string) => `✓ 从 ${path} 加载了 ${count} 个任务`,
|
|
33
|
+
TASK_FILE_MISSING_TASK_BY_INDEX: (blockIndex: number) => `第 ${blockIndex} 个任务块缺少任务描述`,
|
|
28
34
|
},
|
|
29
35
|
}));
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
/**
|
|
38
|
+
* mock utils/index.js —— run.ts 的直接依赖
|
|
39
|
+
* 注意:executeBatchTasks 不在此 mock,由 utils/index.js 的 re-export 指向 task-executor.js 的真实实现
|
|
40
|
+
*/
|
|
41
|
+
vi.mock('../../../src/utils/index.js', async (importOriginal) => {
|
|
42
|
+
const actual = await importOriginal<typeof import('../../../src/utils/index.js')>();
|
|
43
|
+
return {
|
|
44
|
+
...actual,
|
|
45
|
+
validateMainWorktree: vi.fn(),
|
|
46
|
+
validateClaudeCodeInstalled: vi.fn(),
|
|
47
|
+
createWorktrees: vi.fn(),
|
|
48
|
+
sanitizeBranchName: vi.fn(),
|
|
49
|
+
checkBranchExists: vi.fn(),
|
|
50
|
+
getConfigValue: vi.fn().mockReturnValue(0),
|
|
51
|
+
printSuccess: vi.fn(),
|
|
52
|
+
printError: vi.fn(),
|
|
53
|
+
printWarning: vi.fn(),
|
|
54
|
+
printInfo: vi.fn(),
|
|
55
|
+
printSeparator: vi.fn(),
|
|
56
|
+
printDoubleSeparator: vi.fn(),
|
|
57
|
+
confirmAction: vi.fn(),
|
|
58
|
+
launchInteractiveClaude: vi.fn(),
|
|
59
|
+
loadTaskFile: vi.fn(),
|
|
60
|
+
createWorktreesByBranches: vi.fn(),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** task-executor.ts 内部依赖的具体模块 mock */
|
|
65
|
+
vi.mock('../../../src/utils/shell.js', () => ({
|
|
37
66
|
spawnProcess: vi.fn(),
|
|
38
67
|
killAllChildProcesses: vi.fn(),
|
|
68
|
+
execCommand: vi.fn(),
|
|
69
|
+
execCommandWithInput: vi.fn(),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
vi.mock('../../../src/utils/worktree.js', () => ({
|
|
39
73
|
cleanupWorktrees: vi.fn(),
|
|
40
|
-
|
|
74
|
+
createWorktrees: vi.fn(),
|
|
75
|
+
getProjectWorktrees: vi.fn(),
|
|
76
|
+
getProjectWorktreeDir: vi.fn(),
|
|
77
|
+
getWorktreeStatus: vi.fn(),
|
|
78
|
+
createWorktreesByBranches: vi.fn(),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
vi.mock('../../../src/utils/config.js', () => ({
|
|
82
|
+
getConfigValue: vi.fn().mockReturnValue(0),
|
|
83
|
+
loadConfig: vi.fn(),
|
|
84
|
+
writeDefaultConfig: vi.fn(),
|
|
85
|
+
ensureClawtDirs: vi.fn(),
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
vi.mock('../../../src/utils/formatter.js', () => ({
|
|
41
89
|
printSuccess: vi.fn(),
|
|
42
90
|
printError: vi.fn(),
|
|
43
91
|
printWarning: vi.fn(),
|
|
@@ -45,25 +93,47 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
45
93
|
printSeparator: vi.fn(),
|
|
46
94
|
printDoubleSeparator: vi.fn(),
|
|
47
95
|
confirmAction: vi.fn(),
|
|
48
|
-
|
|
96
|
+
confirmDestructiveAction: vi.fn(),
|
|
97
|
+
formatWorktreeStatus: vi.fn(),
|
|
98
|
+
isWorktreeIdle: vi.fn(),
|
|
99
|
+
formatDuration: vi.fn(),
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
vi.mock('../../../src/utils/progress.js', () => ({
|
|
103
|
+
ProgressRenderer: class {
|
|
104
|
+
start = vi.fn();
|
|
105
|
+
stop = vi.fn();
|
|
106
|
+
updateActivity = vi.fn();
|
|
107
|
+
markRunning = vi.fn();
|
|
108
|
+
markDone = vi.fn();
|
|
109
|
+
markFailed = vi.fn();
|
|
110
|
+
},
|
|
49
111
|
}));
|
|
50
112
|
|
|
51
113
|
import { registerRunCommand } from '../../../src/commands/run.js';
|
|
52
114
|
import {
|
|
53
115
|
createWorktrees,
|
|
116
|
+
createWorktreesByBranches,
|
|
54
117
|
sanitizeBranchName,
|
|
55
118
|
checkBranchExists,
|
|
56
|
-
spawnProcess,
|
|
57
119
|
printSuccess,
|
|
58
120
|
launchInteractiveClaude,
|
|
121
|
+
getConfigValue,
|
|
122
|
+
loadTaskFile,
|
|
59
123
|
} from '../../../src/utils/index.js';
|
|
124
|
+
import { spawnProcess } from '../../../src/utils/shell.js';
|
|
125
|
+
import { printInfo } from '../../../src/utils/formatter.js';
|
|
60
126
|
|
|
61
127
|
const mockedCreateWorktrees = vi.mocked(createWorktrees);
|
|
128
|
+
const mockedCreateWorktreesByBranches = vi.mocked(createWorktreesByBranches);
|
|
62
129
|
const mockedSanitizeBranchName = vi.mocked(sanitizeBranchName);
|
|
63
130
|
const mockedCheckBranchExists = vi.mocked(checkBranchExists);
|
|
64
131
|
const mockedSpawnProcess = vi.mocked(spawnProcess);
|
|
65
132
|
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
133
|
+
const mockedPrintInfo = vi.mocked(printInfo);
|
|
66
134
|
const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
|
|
135
|
+
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
136
|
+
const mockedLoadTaskFile = vi.mocked(loadTaskFile);
|
|
67
137
|
|
|
68
138
|
/**
|
|
69
139
|
* 创建模拟子进程
|
|
@@ -78,12 +148,14 @@ function createMockChildProcess(stdout: string, exitCode: number) {
|
|
|
78
148
|
child.stdout = stdoutStream;
|
|
79
149
|
child.stderr = stderrStream;
|
|
80
150
|
child.pid = 12345;
|
|
151
|
+
child.exitCode = null;
|
|
81
152
|
|
|
82
153
|
// 延迟触发 close 事件
|
|
83
154
|
setTimeout(() => {
|
|
84
155
|
stdoutStream.push(stdout);
|
|
85
156
|
stdoutStream.push(null);
|
|
86
157
|
stderrStream.push(null);
|
|
158
|
+
child.exitCode = exitCode;
|
|
87
159
|
child.emit('close', exitCode);
|
|
88
160
|
}, 10);
|
|
89
161
|
|
|
@@ -92,11 +164,18 @@ function createMockChildProcess(stdout: string, exitCode: number) {
|
|
|
92
164
|
|
|
93
165
|
beforeEach(() => {
|
|
94
166
|
mockedCreateWorktrees.mockReset();
|
|
167
|
+
mockedCreateWorktreesByBranches.mockReset();
|
|
95
168
|
mockedSanitizeBranchName.mockReset();
|
|
96
169
|
mockedCheckBranchExists.mockReset();
|
|
97
170
|
mockedSpawnProcess.mockReset();
|
|
98
171
|
mockedPrintSuccess.mockReset();
|
|
172
|
+
mockedPrintInfo.mockReset();
|
|
99
173
|
mockedLaunchInteractiveClaude.mockReset();
|
|
174
|
+
mockedGetConfigValue.mockReset();
|
|
175
|
+
mockedGetConfigValue.mockReturnValue(0 as any);
|
|
176
|
+
mockedLoadTaskFile.mockReset();
|
|
177
|
+
// sanitizeBranchName 默认返回输入值
|
|
178
|
+
mockedSanitizeBranchName.mockImplementation((name: string) => name);
|
|
100
179
|
});
|
|
101
180
|
|
|
102
181
|
describe('registerRunCommand', () => {
|
|
@@ -160,7 +239,6 @@ describe('handleRun', () => {
|
|
|
160
239
|
|
|
161
240
|
expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 2);
|
|
162
241
|
expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
|
|
163
|
-
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
164
242
|
});
|
|
165
243
|
|
|
166
244
|
it('任务执行失败时在通知中报告', async () => {
|
|
@@ -192,6 +270,7 @@ describe('handleRun', () => {
|
|
|
192
270
|
child.stdout = new Readable({ read() {} });
|
|
193
271
|
child.stderr = new Readable({ read() {} });
|
|
194
272
|
child.pid = 12345;
|
|
273
|
+
child.exitCode = null;
|
|
195
274
|
setTimeout(() => {
|
|
196
275
|
child.emit('error', new Error('spawn error'));
|
|
197
276
|
}, 10);
|
|
@@ -204,4 +283,174 @@ describe('handleRun', () => {
|
|
|
204
283
|
|
|
205
284
|
expect(mockedSpawnProcess).toHaveBeenCalledTimes(1);
|
|
206
285
|
});
|
|
286
|
+
|
|
287
|
+
it('传 --concurrency 限制并发数', async () => {
|
|
288
|
+
const worktrees = [
|
|
289
|
+
{ path: '/path/feat-1', branch: 'feat-1' },
|
|
290
|
+
{ path: '/path/feat-2', branch: 'feat-2' },
|
|
291
|
+
{ path: '/path/feat-3', branch: 'feat-3' },
|
|
292
|
+
];
|
|
293
|
+
mockedCreateWorktrees.mockReturnValue(worktrees);
|
|
294
|
+
|
|
295
|
+
const jsonOutput = JSON.stringify({
|
|
296
|
+
is_error: false,
|
|
297
|
+
duration_ms: 5000,
|
|
298
|
+
total_cost_usd: 0.05,
|
|
299
|
+
});
|
|
300
|
+
mockedSpawnProcess
|
|
301
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
|
|
302
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
|
|
303
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
|
|
304
|
+
|
|
305
|
+
const program = new Command();
|
|
306
|
+
program.exitOverride();
|
|
307
|
+
registerRunCommand(program);
|
|
308
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', 'task3', '-c', '1'], { from: 'user' });
|
|
309
|
+
|
|
310
|
+
expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 3);
|
|
311
|
+
// 所有任务都应执行完毕
|
|
312
|
+
expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
|
|
313
|
+
// 应输出并发限制提示
|
|
314
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('--concurrency 为 0 时不限制并发', async () => {
|
|
318
|
+
const worktrees = [
|
|
319
|
+
{ path: '/path/feat-1', branch: 'feat-1' },
|
|
320
|
+
{ path: '/path/feat-2', branch: 'feat-2' },
|
|
321
|
+
];
|
|
322
|
+
mockedCreateWorktrees.mockReturnValue(worktrees);
|
|
323
|
+
|
|
324
|
+
const jsonOutput = JSON.stringify({
|
|
325
|
+
is_error: false,
|
|
326
|
+
duration_ms: 5000,
|
|
327
|
+
total_cost_usd: 0.05,
|
|
328
|
+
});
|
|
329
|
+
mockedSpawnProcess
|
|
330
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
|
|
331
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
|
|
332
|
+
|
|
333
|
+
const program = new Command();
|
|
334
|
+
program.exitOverride();
|
|
335
|
+
registerRunCommand(program);
|
|
336
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', '-c', '0'], { from: 'user' });
|
|
337
|
+
|
|
338
|
+
// 不限制并发时不输出并发限制提示
|
|
339
|
+
expect(mockedPrintInfo).not.toHaveBeenCalledWith(expect.stringContaining('并发限制'));
|
|
340
|
+
expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('未传 -c 时使用全局配置的 maxConcurrency', async () => {
|
|
344
|
+
mockedGetConfigValue.mockReturnValue(2 as any);
|
|
345
|
+
|
|
346
|
+
const worktrees = [
|
|
347
|
+
{ path: '/path/feat-1', branch: 'feat-1' },
|
|
348
|
+
{ path: '/path/feat-2', branch: 'feat-2' },
|
|
349
|
+
{ path: '/path/feat-3', branch: 'feat-3' },
|
|
350
|
+
];
|
|
351
|
+
mockedCreateWorktrees.mockReturnValue(worktrees);
|
|
352
|
+
|
|
353
|
+
const jsonOutput = JSON.stringify({
|
|
354
|
+
is_error: false,
|
|
355
|
+
duration_ms: 5000,
|
|
356
|
+
total_cost_usd: 0.05,
|
|
357
|
+
});
|
|
358
|
+
mockedSpawnProcess
|
|
359
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
|
|
360
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
|
|
361
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
|
|
362
|
+
|
|
363
|
+
const program = new Command();
|
|
364
|
+
program.exitOverride();
|
|
365
|
+
registerRunCommand(program);
|
|
366
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', 'task3'], { from: 'user' });
|
|
367
|
+
|
|
368
|
+
// 应输出并发限制提示(使用配置值 2)
|
|
369
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
|
|
370
|
+
expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('-f 从文件加载任务并执行(无 -b,使用文件中分支名)', async () => {
|
|
374
|
+
mockedLoadTaskFile.mockReturnValue([
|
|
375
|
+
{ branch: 'feat-login', task: '实现登录功能' },
|
|
376
|
+
{ branch: 'fix-bug', task: '修复问题' },
|
|
377
|
+
]);
|
|
378
|
+
const worktrees = [
|
|
379
|
+
{ path: '/path/feat-login', branch: 'feat-login' },
|
|
380
|
+
{ path: '/path/fix-bug', branch: 'fix-bug' },
|
|
381
|
+
];
|
|
382
|
+
mockedCreateWorktreesByBranches.mockReturnValue(worktrees);
|
|
383
|
+
|
|
384
|
+
const jsonOutput = JSON.stringify({
|
|
385
|
+
is_error: false,
|
|
386
|
+
duration_ms: 5000,
|
|
387
|
+
total_cost_usd: 0.05,
|
|
388
|
+
});
|
|
389
|
+
mockedSpawnProcess
|
|
390
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
|
|
391
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
|
|
392
|
+
|
|
393
|
+
const program = new Command();
|
|
394
|
+
program.exitOverride();
|
|
395
|
+
registerRunCommand(program);
|
|
396
|
+
await program.parseAsync(['run', '-f', 'tasks.md'], { from: 'user' });
|
|
397
|
+
|
|
398
|
+
expect(mockedLoadTaskFile).toHaveBeenCalledWith('tasks.md', { branchRequired: true });
|
|
399
|
+
expect(mockedCreateWorktreesByBranches).toHaveBeenCalledWith(['feat-login', 'fix-bug']);
|
|
400
|
+
expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('-f + -b 模式使用 -b 自动编号', async () => {
|
|
404
|
+
mockedLoadTaskFile.mockReturnValue([
|
|
405
|
+
{ task: '任务1' },
|
|
406
|
+
{ task: '任务2' },
|
|
407
|
+
]);
|
|
408
|
+
const worktrees = [
|
|
409
|
+
{ path: '/path/feat-1', branch: 'feat-1' },
|
|
410
|
+
{ path: '/path/feat-2', branch: 'feat-2' },
|
|
411
|
+
];
|
|
412
|
+
mockedCreateWorktrees.mockReturnValue(worktrees);
|
|
413
|
+
|
|
414
|
+
const jsonOutput = JSON.stringify({
|
|
415
|
+
is_error: false,
|
|
416
|
+
duration_ms: 5000,
|
|
417
|
+
total_cost_usd: 0.05,
|
|
418
|
+
});
|
|
419
|
+
mockedSpawnProcess
|
|
420
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
|
|
421
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
|
|
422
|
+
|
|
423
|
+
const program = new Command();
|
|
424
|
+
program.exitOverride();
|
|
425
|
+
registerRunCommand(program);
|
|
426
|
+
await program.parseAsync(['run', '-b', 'feat', '-f', 'tasks.md'], { from: 'user' });
|
|
427
|
+
|
|
428
|
+
// 应使用 createWorktrees(带 -b 自动编号),而非 createWorktreesByBranches
|
|
429
|
+
expect(mockedLoadTaskFile).toHaveBeenCalledWith('tasks.md', { branchRequired: false });
|
|
430
|
+
expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 2);
|
|
431
|
+
expect(mockedCreateWorktreesByBranches).not.toHaveBeenCalled();
|
|
432
|
+
expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('-f 和 --tasks 互斥时报错', async () => {
|
|
436
|
+
mockedLoadTaskFile.mockReturnValue([{ branch: 'feat', task: '任务' }]);
|
|
437
|
+
|
|
438
|
+
const program = new Command();
|
|
439
|
+
program.exitOverride();
|
|
440
|
+
registerRunCommand(program);
|
|
441
|
+
|
|
442
|
+
await expect(
|
|
443
|
+
program.parseAsync(['run', '-f', 'tasks.md', '--tasks', 'task1'], { from: 'user' }),
|
|
444
|
+
).rejects.toThrow();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('未传 -b 和 -f 时报错', async () => {
|
|
448
|
+
const program = new Command();
|
|
449
|
+
program.exitOverride();
|
|
450
|
+
registerRunCommand(program);
|
|
451
|
+
|
|
452
|
+
await expect(
|
|
453
|
+
program.parseAsync(['run', '--tasks', 'task1'], { from: 'user' }),
|
|
454
|
+
).rejects.toThrow();
|
|
455
|
+
});
|
|
207
456
|
});
|
|
@@ -30,6 +30,7 @@ describe('DEFAULT_CONFIG', () => {
|
|
|
30
30
|
expect(DEFAULT_CONFIG.claudeCodeCommand).toBe('claude');
|
|
31
31
|
expect(DEFAULT_CONFIG.autoPullPush).toBe(false);
|
|
32
32
|
expect(DEFAULT_CONFIG.confirmDestructiveOps).toBe(true);
|
|
33
|
+
expect(DEFAULT_CONFIG.maxConcurrency).toBe(0);
|
|
33
34
|
});
|
|
34
35
|
});
|
|
35
36
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle } from '../../../src/utils/formatter.js';
|
|
2
|
+
import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration } from '../../../src/utils/formatter.js';
|
|
3
3
|
import { createWorktreeStatus } from '../../helpers/fixtures.js';
|
|
4
4
|
|
|
5
5
|
describe('formatWorktreeStatus', () => {
|
|
@@ -110,3 +110,29 @@ describe('print 函数', () => {
|
|
|
110
110
|
expect(spy.mock.calls[0][0]).toBe('普通消息');
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
describe('formatDuration', () => {
|
|
115
|
+
it('小于 60 秒时显示秒数(保留一位小数)', () => {
|
|
116
|
+
expect(formatDuration(5200)).toBe('5.2s');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('0 毫秒时显示 0.0s', () => {
|
|
120
|
+
expect(formatDuration(0)).toBe('0.0s');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('59.9 秒时仍显示秒数', () => {
|
|
124
|
+
expect(formatDuration(59900)).toBe('59.9s');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('大于等于 60 秒时显示分秒格式', () => {
|
|
128
|
+
expect(formatDuration(83000)).toBe('1m23s');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('整分钟时秒数补零', () => {
|
|
132
|
+
expect(formatDuration(120000)).toBe('2m00s');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('大数值时正确格式化', () => {
|
|
136
|
+
expect(formatDuration(3661000)).toBe('61m01s');
|
|
137
|
+
});
|
|
138
|
+
});
|