clawt 2.9.1 → 2.10.1
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 +14 -10
- package/.claude/agents/docs-sync-updater.md +11 -0
- package/README.md +63 -268
- package/dist/index.js +420 -103
- package/dist/postinstall.js +242 -0
- package/docs/spec.md +162 -14
- package/package.json +1 -1
- package/src/commands/remove.ts +21 -28
- package/src/commands/status.ts +327 -0
- package/src/constants/index.ts +1 -1
- package/src/constants/messages/common.ts +41 -0
- package/src/constants/messages/config.ts +5 -0
- package/src/constants/messages/create.ts +5 -0
- package/src/constants/messages/index.ts +29 -0
- package/src/constants/messages/merge.ts +42 -0
- package/src/constants/messages/remove.ts +15 -0
- package/src/constants/messages/reset.ts +7 -0
- package/src/constants/messages/resume.ts +12 -0
- package/src/constants/messages/run.ts +16 -0
- package/src/constants/messages/status.ts +25 -0
- package/src/constants/messages/sync.ts +24 -0
- package/src/constants/messages/validate.ts +25 -0
- package/src/constants/messages.ts +22 -0
- package/src/index.ts +2 -0
- package/src/types/command.ts +6 -0
- package/src/types/index.ts +2 -1
- package/src/types/status.ts +49 -0
- package/src/utils/git.ts +16 -0
- package/src/utils/index.ts +4 -3
- package/src/utils/validate-snapshot.ts +17 -0
- package/src/utils/worktree-matcher.ts +92 -0
- package/tests/unit/commands/config.test.ts +110 -0
- package/tests/unit/commands/create.test.ts +115 -0
- package/tests/unit/commands/list.test.ts +118 -0
- package/tests/unit/commands/merge.test.ts +323 -0
- package/tests/unit/commands/remove.test.ts +240 -0
- package/tests/unit/commands/reset.test.ts +124 -0
- package/tests/unit/commands/resume.test.ts +91 -0
- package/tests/unit/commands/run.test.ts +207 -0
- package/tests/unit/commands/status.test.ts +214 -0
- package/tests/unit/commands/sync.test.ts +208 -0
- package/tests/unit/commands/validate.test.ts +382 -0
- package/tests/unit/constants/messages.test.ts +1 -1
- package/tests/unit/utils/config.test.ts +21 -1
- package/tests/unit/utils/formatter.test.ts +44 -1
- package/tests/unit/utils/git.test.ts +44 -0
- package/tests/unit/utils/validate-snapshot.test.ts +25 -0
- package/tests/unit/utils/worktree-matcher.test.ts +81 -5
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
|
|
4
|
+
vi.mock('../../../src/logger/index.js', () => ({
|
|
5
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('../../../src/constants/index.js', () => ({
|
|
9
|
+
MESSAGES: {
|
|
10
|
+
RESUME_NO_WORKTREES: '没有可用的 worktree',
|
|
11
|
+
RESUME_SELECT_BRANCH: '选择要恢复的分支',
|
|
12
|
+
RESUME_MULTIPLE_MATCHES: (keyword: string) => `找到多个匹配 "${keyword}" 的分支`,
|
|
13
|
+
RESUME_NO_MATCH: (keyword: string, branches: string[]) => `未找到匹配 "${keyword}" 的分支`,
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('../../../src/utils/index.js', () => ({
|
|
18
|
+
validateMainWorktree: vi.fn(),
|
|
19
|
+
validateClaudeCodeInstalled: vi.fn(),
|
|
20
|
+
getProjectWorktrees: vi.fn(),
|
|
21
|
+
launchInteractiveClaude: vi.fn(),
|
|
22
|
+
resolveTargetWorktree: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { registerResumeCommand } from '../../../src/commands/resume.js';
|
|
26
|
+
import {
|
|
27
|
+
validateMainWorktree,
|
|
28
|
+
validateClaudeCodeInstalled,
|
|
29
|
+
getProjectWorktrees,
|
|
30
|
+
launchInteractiveClaude,
|
|
31
|
+
resolveTargetWorktree,
|
|
32
|
+
} from '../../../src/utils/index.js';
|
|
33
|
+
|
|
34
|
+
const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
|
|
35
|
+
const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
|
|
36
|
+
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
37
|
+
const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
|
|
38
|
+
const mockedResolveTargetWorktree = vi.mocked(resolveTargetWorktree);
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mockedValidateMainWorktree.mockReset();
|
|
42
|
+
mockedValidateClaudeCodeInstalled.mockReset();
|
|
43
|
+
mockedGetProjectWorktrees.mockReset();
|
|
44
|
+
mockedLaunchInteractiveClaude.mockReset();
|
|
45
|
+
mockedResolveTargetWorktree.mockReset();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('registerResumeCommand', () => {
|
|
49
|
+
it('注册 resume 命令', () => {
|
|
50
|
+
const program = new Command();
|
|
51
|
+
registerResumeCommand(program);
|
|
52
|
+
const cmd = program.commands.find((c) => c.name() === 'resume');
|
|
53
|
+
expect(cmd).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('handleResume', () => {
|
|
58
|
+
it('成功恢复 Claude Code 会话', async () => {
|
|
59
|
+
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
60
|
+
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
61
|
+
mockedResolveTargetWorktree.mockResolvedValue(worktree);
|
|
62
|
+
|
|
63
|
+
const program = new Command();
|
|
64
|
+
program.exitOverride();
|
|
65
|
+
registerResumeCommand(program);
|
|
66
|
+
await program.parseAsync(['resume', '-b', 'feature'], { from: 'user' });
|
|
67
|
+
|
|
68
|
+
expect(mockedValidateMainWorktree).toHaveBeenCalled();
|
|
69
|
+
expect(mockedValidateClaudeCodeInstalled).toHaveBeenCalled();
|
|
70
|
+
expect(mockedResolveTargetWorktree).toHaveBeenCalled();
|
|
71
|
+
expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('不传 -b 时也能调用 resolveTargetWorktree', async () => {
|
|
75
|
+
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
76
|
+
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
77
|
+
mockedResolveTargetWorktree.mockResolvedValue(worktree);
|
|
78
|
+
|
|
79
|
+
const program = new Command();
|
|
80
|
+
program.exitOverride();
|
|
81
|
+
registerResumeCommand(program);
|
|
82
|
+
await program.parseAsync(['resume'], { from: 'user' });
|
|
83
|
+
|
|
84
|
+
// branchName 参数为 undefined
|
|
85
|
+
expect(mockedResolveTargetWorktree).toHaveBeenCalledWith(
|
|
86
|
+
expect.any(Array),
|
|
87
|
+
expect.any(Object),
|
|
88
|
+
undefined,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { EventEmitter, Readable } from 'node:stream';
|
|
4
|
+
|
|
5
|
+
vi.mock('../../../src/logger/index.js', () => ({
|
|
6
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('../../../src/errors/index.js', () => ({
|
|
10
|
+
ClawtError: class ClawtError extends Error {
|
|
11
|
+
exitCode: number;
|
|
12
|
+
constructor(message: string, exitCode = 1) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.exitCode = exitCode;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('../../../src/constants/index.js', () => ({
|
|
20
|
+
MESSAGES: {
|
|
21
|
+
BRANCH_EXISTS_USE_RESUME: (name: string) => `分支 ${name} 已存在,请使用 resume 恢复`,
|
|
22
|
+
WORKTREE_CREATED: (count: number) => `✓ 已创建 ${count} 个 worktree`,
|
|
23
|
+
INTERRUPTED: '已中断',
|
|
24
|
+
INTERRUPT_AUTO_CLEANED: (count: number) => `已清理 ${count} 个 worktree`,
|
|
25
|
+
INTERRUPT_CONFIRM_CLEANUP: '是否清理已创建的 worktree?',
|
|
26
|
+
INTERRUPT_CLEANED: (count: number) => `已清理 ${count} 个 worktree`,
|
|
27
|
+
INTERRUPT_KEPT: '已保留 worktree',
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../../../src/utils/index.js', () => ({
|
|
32
|
+
validateMainWorktree: vi.fn(),
|
|
33
|
+
validateClaudeCodeInstalled: vi.fn(),
|
|
34
|
+
createWorktrees: vi.fn(),
|
|
35
|
+
sanitizeBranchName: vi.fn(),
|
|
36
|
+
checkBranchExists: vi.fn(),
|
|
37
|
+
spawnProcess: vi.fn(),
|
|
38
|
+
killAllChildProcesses: vi.fn(),
|
|
39
|
+
cleanupWorktrees: vi.fn(),
|
|
40
|
+
getConfigValue: vi.fn(),
|
|
41
|
+
printSuccess: vi.fn(),
|
|
42
|
+
printError: vi.fn(),
|
|
43
|
+
printWarning: vi.fn(),
|
|
44
|
+
printInfo: vi.fn(),
|
|
45
|
+
printSeparator: vi.fn(),
|
|
46
|
+
printDoubleSeparator: vi.fn(),
|
|
47
|
+
confirmAction: vi.fn(),
|
|
48
|
+
launchInteractiveClaude: vi.fn(),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
import { registerRunCommand } from '../../../src/commands/run.js';
|
|
52
|
+
import {
|
|
53
|
+
createWorktrees,
|
|
54
|
+
sanitizeBranchName,
|
|
55
|
+
checkBranchExists,
|
|
56
|
+
spawnProcess,
|
|
57
|
+
printSuccess,
|
|
58
|
+
launchInteractiveClaude,
|
|
59
|
+
} from '../../../src/utils/index.js';
|
|
60
|
+
|
|
61
|
+
const mockedCreateWorktrees = vi.mocked(createWorktrees);
|
|
62
|
+
const mockedSanitizeBranchName = vi.mocked(sanitizeBranchName);
|
|
63
|
+
const mockedCheckBranchExists = vi.mocked(checkBranchExists);
|
|
64
|
+
const mockedSpawnProcess = vi.mocked(spawnProcess);
|
|
65
|
+
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
66
|
+
const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 创建模拟子进程
|
|
70
|
+
* @param {string} stdout - 子进程标准输出内容
|
|
71
|
+
* @param {number} exitCode - 退出码
|
|
72
|
+
* @returns {object} 模拟的子进程对象
|
|
73
|
+
*/
|
|
74
|
+
function createMockChildProcess(stdout: string, exitCode: number) {
|
|
75
|
+
const child = new EventEmitter() as any;
|
|
76
|
+
const stdoutStream = new Readable({ read() {} });
|
|
77
|
+
const stderrStream = new Readable({ read() {} });
|
|
78
|
+
child.stdout = stdoutStream;
|
|
79
|
+
child.stderr = stderrStream;
|
|
80
|
+
child.pid = 12345;
|
|
81
|
+
|
|
82
|
+
// 延迟触发 close 事件
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
stdoutStream.push(stdout);
|
|
85
|
+
stdoutStream.push(null);
|
|
86
|
+
stderrStream.push(null);
|
|
87
|
+
child.emit('close', exitCode);
|
|
88
|
+
}, 10);
|
|
89
|
+
|
|
90
|
+
return child;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
mockedCreateWorktrees.mockReset();
|
|
95
|
+
mockedSanitizeBranchName.mockReset();
|
|
96
|
+
mockedCheckBranchExists.mockReset();
|
|
97
|
+
mockedSpawnProcess.mockReset();
|
|
98
|
+
mockedPrintSuccess.mockReset();
|
|
99
|
+
mockedLaunchInteractiveClaude.mockReset();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('registerRunCommand', () => {
|
|
103
|
+
it('注册 run 命令', () => {
|
|
104
|
+
const program = new Command();
|
|
105
|
+
registerRunCommand(program);
|
|
106
|
+
const cmd = program.commands.find((c) => c.name() === 'run');
|
|
107
|
+
expect(cmd).toBeDefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('handleRun', () => {
|
|
112
|
+
it('未传 --tasks 时创建单个 worktree 并打开交互式界面', async () => {
|
|
113
|
+
mockedSanitizeBranchName.mockReturnValue('feature');
|
|
114
|
+
mockedCheckBranchExists.mockReturnValue(false);
|
|
115
|
+
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
116
|
+
mockedCreateWorktrees.mockReturnValue([worktree]);
|
|
117
|
+
|
|
118
|
+
const program = new Command();
|
|
119
|
+
program.exitOverride();
|
|
120
|
+
registerRunCommand(program);
|
|
121
|
+
await program.parseAsync(['run', '-b', 'feature'], { from: 'user' });
|
|
122
|
+
|
|
123
|
+
expect(mockedCreateWorktrees).toHaveBeenCalledWith('feature', 1);
|
|
124
|
+
expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('分支已存在时提示使用 resume', async () => {
|
|
128
|
+
mockedSanitizeBranchName.mockReturnValue('feature');
|
|
129
|
+
mockedCheckBranchExists.mockReturnValue(true);
|
|
130
|
+
|
|
131
|
+
const program = new Command();
|
|
132
|
+
program.exitOverride();
|
|
133
|
+
registerRunCommand(program);
|
|
134
|
+
|
|
135
|
+
await expect(
|
|
136
|
+
program.parseAsync(['run', '-b', 'feature'], { from: 'user' }),
|
|
137
|
+
).rejects.toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('传 --tasks 时创建对应数量 worktree 并并行执行', async () => {
|
|
141
|
+
const worktrees = [
|
|
142
|
+
{ path: '/path/feat-1', branch: 'feat-1' },
|
|
143
|
+
{ path: '/path/feat-2', branch: 'feat-2' },
|
|
144
|
+
];
|
|
145
|
+
mockedCreateWorktrees.mockReturnValue(worktrees);
|
|
146
|
+
|
|
147
|
+
const jsonOutput = JSON.stringify({
|
|
148
|
+
is_error: false,
|
|
149
|
+
duration_ms: 5000,
|
|
150
|
+
total_cost_usd: 0.05,
|
|
151
|
+
});
|
|
152
|
+
mockedSpawnProcess
|
|
153
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
|
|
154
|
+
.mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
|
|
155
|
+
|
|
156
|
+
const program = new Command();
|
|
157
|
+
program.exitOverride();
|
|
158
|
+
registerRunCommand(program);
|
|
159
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2'], { from: 'user' });
|
|
160
|
+
|
|
161
|
+
expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 2);
|
|
162
|
+
expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
|
|
163
|
+
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('任务执行失败时在通知中报告', async () => {
|
|
167
|
+
const worktrees = [{ path: '/path/feat-1', branch: 'feat-1' }];
|
|
168
|
+
mockedCreateWorktrees.mockReturnValue(worktrees);
|
|
169
|
+
|
|
170
|
+
const jsonOutput = JSON.stringify({
|
|
171
|
+
is_error: true,
|
|
172
|
+
duration_ms: 1000,
|
|
173
|
+
total_cost_usd: 0.01,
|
|
174
|
+
});
|
|
175
|
+
mockedSpawnProcess.mockReturnValueOnce(createMockChildProcess(jsonOutput, 1));
|
|
176
|
+
|
|
177
|
+
const program = new Command();
|
|
178
|
+
program.exitOverride();
|
|
179
|
+
registerRunCommand(program);
|
|
180
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'fail-task'], { from: 'user' });
|
|
181
|
+
|
|
182
|
+
// 应输出汇总(含失败信息)
|
|
183
|
+
expect(mockedSpawnProcess).toHaveBeenCalledTimes(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('子进程发生错误时返回失败结果', async () => {
|
|
187
|
+
const worktrees = [{ path: '/path/feat-1', branch: 'feat-1' }];
|
|
188
|
+
mockedCreateWorktrees.mockReturnValue(worktrees);
|
|
189
|
+
|
|
190
|
+
// 创建会触发 error 事件的子进程
|
|
191
|
+
const child = new EventEmitter() as any;
|
|
192
|
+
child.stdout = new Readable({ read() {} });
|
|
193
|
+
child.stderr = new Readable({ read() {} });
|
|
194
|
+
child.pid = 12345;
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
child.emit('error', new Error('spawn error'));
|
|
197
|
+
}, 10);
|
|
198
|
+
mockedSpawnProcess.mockReturnValueOnce(child);
|
|
199
|
+
|
|
200
|
+
const program = new Command();
|
|
201
|
+
program.exitOverride();
|
|
202
|
+
registerRunCommand(program);
|
|
203
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1'], { from: 'user' });
|
|
204
|
+
|
|
205
|
+
expect(mockedSpawnProcess).toHaveBeenCalledTimes(1);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
|
|
4
|
+
vi.mock('../../../src/logger/index.js', () => ({
|
|
5
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('../../../src/constants/index.js', () => ({
|
|
9
|
+
MESSAGES: {
|
|
10
|
+
STATUS_TITLE: (name: string) => `项目 ${name} 状态`,
|
|
11
|
+
STATUS_MAIN_SECTION: '主 Worktree',
|
|
12
|
+
STATUS_WORKTREES_SECTION: 'Worktree 列表',
|
|
13
|
+
STATUS_SNAPSHOTS_SECTION: 'Validate 快照',
|
|
14
|
+
STATUS_NO_WORKTREES: '无 worktree',
|
|
15
|
+
STATUS_NO_SNAPSHOTS: '无快照',
|
|
16
|
+
STATUS_CHANGE_COMMITTED: '已提交',
|
|
17
|
+
STATUS_CHANGE_UNCOMMITTED: '未提交',
|
|
18
|
+
STATUS_CHANGE_CONFLICT: '冲突',
|
|
19
|
+
STATUS_CHANGE_CLEAN: '干净',
|
|
20
|
+
STATUS_SNAPSHOT_ORPHANED: '(孤立)',
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('../../../src/utils/index.js', () => ({
|
|
25
|
+
validateMainWorktree: vi.fn(),
|
|
26
|
+
getProjectName: vi.fn(),
|
|
27
|
+
getCurrentBranch: vi.fn(),
|
|
28
|
+
isWorkingDirClean: vi.fn(),
|
|
29
|
+
getProjectWorktrees: vi.fn(),
|
|
30
|
+
getCommitCountAhead: vi.fn(),
|
|
31
|
+
getCommitCountBehind: vi.fn(),
|
|
32
|
+
getDiffStat: vi.fn(),
|
|
33
|
+
hasMergeConflict: vi.fn(),
|
|
34
|
+
hasLocalCommits: vi.fn(),
|
|
35
|
+
hasSnapshot: vi.fn(),
|
|
36
|
+
getProjectSnapshotBranches: vi.fn(),
|
|
37
|
+
printInfo: vi.fn(),
|
|
38
|
+
printDoubleSeparator: vi.fn(),
|
|
39
|
+
printSeparator: vi.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import { registerStatusCommand } from '../../../src/commands/status.js';
|
|
43
|
+
import {
|
|
44
|
+
getProjectName,
|
|
45
|
+
getCurrentBranch,
|
|
46
|
+
isWorkingDirClean,
|
|
47
|
+
getProjectWorktrees,
|
|
48
|
+
getCommitCountAhead,
|
|
49
|
+
getCommitCountBehind,
|
|
50
|
+
getDiffStat,
|
|
51
|
+
hasMergeConflict,
|
|
52
|
+
hasLocalCommits,
|
|
53
|
+
hasSnapshot,
|
|
54
|
+
getProjectSnapshotBranches,
|
|
55
|
+
printInfo,
|
|
56
|
+
} from '../../../src/utils/index.js';
|
|
57
|
+
|
|
58
|
+
const mockedGetProjectName = vi.mocked(getProjectName);
|
|
59
|
+
const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
|
|
60
|
+
const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
|
|
61
|
+
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
62
|
+
const mockedGetCommitCountAhead = vi.mocked(getCommitCountAhead);
|
|
63
|
+
const mockedGetCommitCountBehind = vi.mocked(getCommitCountBehind);
|
|
64
|
+
const mockedGetDiffStat = vi.mocked(getDiffStat);
|
|
65
|
+
const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
|
|
66
|
+
const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
|
|
67
|
+
const mockedHasSnapshot = vi.mocked(hasSnapshot);
|
|
68
|
+
const mockedGetProjectSnapshotBranches = vi.mocked(getProjectSnapshotBranches);
|
|
69
|
+
const mockedPrintInfo = vi.mocked(printInfo);
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
mockedGetProjectName.mockReturnValue('test-project');
|
|
73
|
+
mockedGetCurrentBranch.mockReturnValue('main');
|
|
74
|
+
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
75
|
+
mockedGetProjectWorktrees.mockReturnValue([]);
|
|
76
|
+
mockedGetProjectSnapshotBranches.mockReturnValue([]);
|
|
77
|
+
mockedGetCommitCountAhead.mockReturnValue(0);
|
|
78
|
+
mockedGetCommitCountBehind.mockReturnValue(0);
|
|
79
|
+
mockedGetDiffStat.mockReturnValue({ insertions: 0, deletions: 0 });
|
|
80
|
+
mockedHasMergeConflict.mockReturnValue(false);
|
|
81
|
+
mockedHasLocalCommits.mockReturnValue(false);
|
|
82
|
+
mockedHasSnapshot.mockReturnValue(false);
|
|
83
|
+
mockedPrintInfo.mockReset();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('registerStatusCommand', () => {
|
|
87
|
+
it('注册 status 命令', () => {
|
|
88
|
+
const program = new Command();
|
|
89
|
+
registerStatusCommand(program);
|
|
90
|
+
const cmd = program.commands.find((c) => c.name() === 'status');
|
|
91
|
+
expect(cmd).toBeDefined();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('handleStatus', () => {
|
|
96
|
+
it('无 worktree 时文本输出', () => {
|
|
97
|
+
const program = new Command();
|
|
98
|
+
program.exitOverride();
|
|
99
|
+
registerStatusCommand(program);
|
|
100
|
+
program.parse(['status'], { from: 'user' });
|
|
101
|
+
|
|
102
|
+
expect(mockedPrintInfo).toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('--json 输出完整 JSON 结构', () => {
|
|
106
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
107
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
108
|
+
]);
|
|
109
|
+
mockedGetCommitCountAhead.mockReturnValue(2);
|
|
110
|
+
mockedGetDiffStat.mockReturnValue({ insertions: 10, deletions: 5 });
|
|
111
|
+
mockedHasLocalCommits.mockReturnValue(true);
|
|
112
|
+
|
|
113
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
114
|
+
|
|
115
|
+
const program = new Command();
|
|
116
|
+
program.exitOverride();
|
|
117
|
+
registerStatusCommand(program);
|
|
118
|
+
program.parse(['status', '--json'], { from: 'user' });
|
|
119
|
+
|
|
120
|
+
const jsonCall = consoleSpy.mock.calls.find((call) => {
|
|
121
|
+
try { JSON.parse(call[0]); return true; } catch { return false; }
|
|
122
|
+
});
|
|
123
|
+
expect(jsonCall).toBeDefined();
|
|
124
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
125
|
+
expect(parsed.main.projectName).toBe('test-project');
|
|
126
|
+
expect(parsed.main.branch).toBe('main');
|
|
127
|
+
expect(parsed.totalWorktrees).toBe(1);
|
|
128
|
+
expect(parsed.worktrees).toHaveLength(1);
|
|
129
|
+
expect(parsed.worktrees[0].branch).toBe('feature');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('有 worktree 时收集正确的变更状态', () => {
|
|
133
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
134
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
135
|
+
]);
|
|
136
|
+
// 模拟冲突状态
|
|
137
|
+
mockedHasMergeConflict.mockReturnValue(true);
|
|
138
|
+
|
|
139
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
140
|
+
|
|
141
|
+
const program = new Command();
|
|
142
|
+
program.exitOverride();
|
|
143
|
+
registerStatusCommand(program);
|
|
144
|
+
program.parse(['status', '--json'], { from: 'user' });
|
|
145
|
+
|
|
146
|
+
const jsonCall = consoleSpy.mock.calls.find((call) => {
|
|
147
|
+
try { JSON.parse(call[0]); return true; } catch { return false; }
|
|
148
|
+
});
|
|
149
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
150
|
+
expect(parsed.worktrees[0].changeStatus).toBe('conflict');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('主 worktree 不干净时 isClean=false', () => {
|
|
154
|
+
mockedIsWorkingDirClean.mockReturnValue(false);
|
|
155
|
+
|
|
156
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
157
|
+
|
|
158
|
+
const program = new Command();
|
|
159
|
+
program.exitOverride();
|
|
160
|
+
registerStatusCommand(program);
|
|
161
|
+
program.parse(['status', '--json'], { from: 'user' });
|
|
162
|
+
|
|
163
|
+
const jsonCall = consoleSpy.mock.calls.find((call) => {
|
|
164
|
+
try { JSON.parse(call[0]); return true; } catch { return false; }
|
|
165
|
+
});
|
|
166
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
167
|
+
expect(parsed.main.isClean).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('存在快照时标识孤立快照', () => {
|
|
171
|
+
mockedGetProjectSnapshotBranches.mockReturnValue(['feature', 'deleted-branch']);
|
|
172
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
173
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
177
|
+
|
|
178
|
+
const program = new Command();
|
|
179
|
+
program.exitOverride();
|
|
180
|
+
registerStatusCommand(program);
|
|
181
|
+
program.parse(['status', '--json'], { from: 'user' });
|
|
182
|
+
|
|
183
|
+
const jsonCall = consoleSpy.mock.calls.find((call) => {
|
|
184
|
+
try { JSON.parse(call[0]); return true; } catch { return false; }
|
|
185
|
+
});
|
|
186
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
187
|
+
expect(parsed.snapshots).toHaveLength(2);
|
|
188
|
+
expect(parsed.snapshots[0]).toEqual({ branch: 'feature', worktreeExists: true });
|
|
189
|
+
expect(parsed.snapshots[1]).toEqual({ branch: 'deleted-branch', worktreeExists: false });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('uncommitted 变更状态正确检测', () => {
|
|
193
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
194
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
195
|
+
]);
|
|
196
|
+
mockedHasMergeConflict.mockReturnValue(false);
|
|
197
|
+
mockedIsWorkingDirClean
|
|
198
|
+
.mockReturnValueOnce(true) // 主 worktree
|
|
199
|
+
.mockReturnValueOnce(false); // 目标 worktree 不干净
|
|
200
|
+
|
|
201
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
202
|
+
|
|
203
|
+
const program = new Command();
|
|
204
|
+
program.exitOverride();
|
|
205
|
+
registerStatusCommand(program);
|
|
206
|
+
program.parse(['status', '--json'], { from: 'user' });
|
|
207
|
+
|
|
208
|
+
const jsonCall = consoleSpy.mock.calls.find((call) => {
|
|
209
|
+
try { JSON.parse(call[0]); return true; } catch { return false; }
|
|
210
|
+
});
|
|
211
|
+
const parsed = JSON.parse(jsonCall![0]);
|
|
212
|
+
expect(parsed.worktrees[0].changeStatus).toBe('uncommitted');
|
|
213
|
+
});
|
|
214
|
+
});
|