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.
Files changed (48) hide show
  1. package/.claude/agent-memory/docs-sync-updater/MEMORY.md +14 -10
  2. package/.claude/agents/docs-sync-updater.md +11 -0
  3. package/README.md +63 -268
  4. package/dist/index.js +420 -103
  5. package/dist/postinstall.js +242 -0
  6. package/docs/spec.md +162 -14
  7. package/package.json +1 -1
  8. package/src/commands/remove.ts +21 -28
  9. package/src/commands/status.ts +327 -0
  10. package/src/constants/index.ts +1 -1
  11. package/src/constants/messages/common.ts +41 -0
  12. package/src/constants/messages/config.ts +5 -0
  13. package/src/constants/messages/create.ts +5 -0
  14. package/src/constants/messages/index.ts +29 -0
  15. package/src/constants/messages/merge.ts +42 -0
  16. package/src/constants/messages/remove.ts +15 -0
  17. package/src/constants/messages/reset.ts +7 -0
  18. package/src/constants/messages/resume.ts +12 -0
  19. package/src/constants/messages/run.ts +16 -0
  20. package/src/constants/messages/status.ts +25 -0
  21. package/src/constants/messages/sync.ts +24 -0
  22. package/src/constants/messages/validate.ts +25 -0
  23. package/src/constants/messages.ts +22 -0
  24. package/src/index.ts +2 -0
  25. package/src/types/command.ts +6 -0
  26. package/src/types/index.ts +2 -1
  27. package/src/types/status.ts +49 -0
  28. package/src/utils/git.ts +16 -0
  29. package/src/utils/index.ts +4 -3
  30. package/src/utils/validate-snapshot.ts +17 -0
  31. package/src/utils/worktree-matcher.ts +92 -0
  32. package/tests/unit/commands/config.test.ts +110 -0
  33. package/tests/unit/commands/create.test.ts +115 -0
  34. package/tests/unit/commands/list.test.ts +118 -0
  35. package/tests/unit/commands/merge.test.ts +323 -0
  36. package/tests/unit/commands/remove.test.ts +240 -0
  37. package/tests/unit/commands/reset.test.ts +124 -0
  38. package/tests/unit/commands/resume.test.ts +91 -0
  39. package/tests/unit/commands/run.test.ts +207 -0
  40. package/tests/unit/commands/status.test.ts +214 -0
  41. package/tests/unit/commands/sync.test.ts +208 -0
  42. package/tests/unit/commands/validate.test.ts +382 -0
  43. package/tests/unit/constants/messages.test.ts +1 -1
  44. package/tests/unit/utils/config.test.ts +21 -1
  45. package/tests/unit/utils/formatter.test.ts +44 -1
  46. package/tests/unit/utils/git.test.ts +44 -0
  47. package/tests/unit/utils/validate-snapshot.test.ts +25 -0
  48. package/tests/unit/utils/worktree-matcher.test.ts +81 -5
@@ -0,0 +1,49 @@
1
+ /** 单个 worktree 的详细状态信息 */
2
+ export interface WorktreeDetailedStatus {
3
+ /** worktree 路径 */
4
+ path: string;
5
+ /** 分支名 */
6
+ branch: string;
7
+ /** 变更状态: committed(有已提交内容) / uncommitted(有未提交修改) / conflict(存在合并冲突) / clean(无变更) */
8
+ changeStatus: 'committed' | 'uncommitted' | 'conflict' | 'clean';
9
+ /** 相对于主分支的新增提交数(领先) */
10
+ commitsAhead: number;
11
+ /** 落后于主分支的提交数 */
12
+ commitsBehind: number;
13
+ /** 是否存在 validate 快照 */
14
+ hasSnapshot: boolean;
15
+ /** 工作区和暂存区的新增行数 */
16
+ insertions: number;
17
+ /** 工作区和暂存区的删除行数 */
18
+ deletions: number;
19
+ }
20
+
21
+ /** 主 worktree 状态信息 */
22
+ export interface MainWorktreeStatus {
23
+ /** 当前分支名 */
24
+ branch: string;
25
+ /** 工作区是否干净 */
26
+ isClean: boolean;
27
+ /** 项目名 */
28
+ projectName: string;
29
+ }
30
+
31
+ /** validate 快照信息 */
32
+ export interface SnapshotInfo {
33
+ /** 分支名 */
34
+ branch: string;
35
+ /** 是否对应的 worktree 仍然存在 */
36
+ worktreeExists: boolean;
37
+ }
38
+
39
+ /** status 命令的完整输出结构 */
40
+ export interface StatusResult {
41
+ /** 主 worktree 状态 */
42
+ main: MainWorktreeStatus;
43
+ /** 各 worktree 的详细状态 */
44
+ worktrees: WorktreeDetailedStatus[];
45
+ /** 未清理的 validate 快照列表 */
46
+ snapshots: SnapshotInfo[];
47
+ /** worktree 总数 */
48
+ totalWorktrees: number;
49
+ }
package/src/utils/git.ts CHANGED
@@ -263,6 +263,22 @@ export function getCommitCountAhead(branchName: string, cwd?: string): number {
263
263
  return parseInt(output, 10) || 0;
264
264
  }
265
265
 
266
+ /**
267
+ * 获取目标分支落后于当前分支的提交数
268
+ * 即当前分支有多少提交是目标分支没有的
269
+ * @param {string} branchName - 目标分支名
270
+ * @param {string} [cwd] - 工作目录
271
+ * @returns {number} 落后的提交数
272
+ */
273
+ export function getCommitCountBehind(branchName: string, cwd?: string): number {
274
+ try {
275
+ const output = execCommand(`git rev-list --count ${branchName}..HEAD`, { cwd });
276
+ return parseInt(output, 10) || 0;
277
+ } catch {
278
+ return 0;
279
+ }
280
+ }
281
+
266
282
  /**
267
283
  * 解析 git diff --shortstat 输出,提取新增行数和删除行数
268
284
  * @param {string} output - shortstat 输出字符串
@@ -27,6 +27,7 @@ export {
27
27
  gitWorktreePrune,
28
28
  hasLocalCommits,
29
29
  getCommitCountAhead,
30
+ getCommitCountBehind,
30
31
  getDiffStat,
31
32
  gitDiffCachedBinary,
32
33
  gitApplyCachedFromStdin,
@@ -52,6 +53,6 @@ export { printSuccess, printError, printWarning, printInfo, printSeparator, prin
52
53
  export { ensureDir, removeEmptyDir } from './fs.js';
53
54
  export { multilineInput } from './prompt.js';
54
55
  export { launchInteractiveClaude } from './claude.js';
55
- export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots } from './validate-snapshot.js';
56
- export { findExactMatch, findFuzzyMatches, promptSelectBranch, resolveTargetWorktree } from './worktree-matcher.js';
57
- export type { WorktreeResolveMessages } from './worktree-matcher.js';
56
+ export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
57
+ export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
58
+ export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
@@ -98,6 +98,23 @@ export function removeSnapshot(projectName: string, branchName: string): void {
98
98
  }
99
99
  }
100
100
 
101
+ /**
102
+ * 获取指定项目所有存在 validate 快照的分支名列表
103
+ * 通过扫描快照目录下的 .tree 文件名提取
104
+ * @param {string} projectName - 项目名
105
+ * @returns {string[]} 存在快照的分支名列表
106
+ */
107
+ export function getProjectSnapshotBranches(projectName: string): string[] {
108
+ const projectDir = join(VALIDATE_SNAPSHOTS_DIR, projectName);
109
+ if (!existsSync(projectDir)) {
110
+ return [];
111
+ }
112
+ const files = readdirSync(projectDir);
113
+ return files
114
+ .filter((f: string) => f.endsWith('.tree'))
115
+ .map((f: string) => f.replace(/\.tree$/, ''));
116
+ }
117
+
101
118
  /**
102
119
  * 删除指定项目的所有 validate 快照
103
120
  * @param {string} projectName - 项目名
@@ -38,6 +38,21 @@ export function findFuzzyMatches(worktrees: WorktreeInfo[], keyword: string): Wo
38
38
  return worktrees.filter((wt) => wt.branch.toLowerCase().includes(lowerKeyword));
39
39
  }
40
40
 
41
+ /**
42
+ * 多选场景下的分支解析消息文案配置
43
+ * 与 WorktreeResolveMessages 类似,但用于需要多选的命令(如 remove)
44
+ */
45
+ export interface WorktreeMultiResolveMessages {
46
+ /** 无可用 worktree 时的错误消息 */
47
+ noWorktrees: string;
48
+ /** 未传分支名时的多选交互提示 */
49
+ selectBranch: string;
50
+ /** 模糊匹配到多个结果时的多选交互提示 */
51
+ multipleMatches: (keyword: string) => string;
52
+ /** 无匹配结果时的错误消息 */
53
+ noMatch: (keyword: string, branches: string[]) => string;
54
+ }
55
+
41
56
  /**
42
57
  * 通过交互式列表让用户从 worktree 列表中选择一个分支
43
58
  * @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
@@ -57,6 +72,83 @@ export async function promptSelectBranch(worktrees: WorktreeInfo[], message: str
57
72
  return worktrees.find((wt) => wt.branch === selectedBranch)!;
58
73
  }
59
74
 
75
+ /**
76
+ * 通过交互式多选列表让用户从 worktree 列表中选择多个分支
77
+ * 用户可通过空格键选择/取消,回车键确认
78
+ * @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
79
+ * @param {string} message - 选择提示信息
80
+ * @returns {Promise<WorktreeInfo[]>} 用户选择的 worktree 列表
81
+ */
82
+ export async function promptMultiSelectBranches(worktrees: WorktreeInfo[], message: string): Promise<WorktreeInfo[]> {
83
+ // @ts-expect-error enquirer 类型声明未导出 MultiSelect 类,但运行时存在
84
+ const selectedBranches: string[] = await new Enquirer.MultiSelect({
85
+ message,
86
+ choices: worktrees.map((wt) => ({
87
+ name: wt.branch,
88
+ message: wt.branch,
89
+ })),
90
+ // 使用空心圆/实心圆作为选中指示符
91
+ symbols: {
92
+ indicator: { on: '●', off: '○' },
93
+ },
94
+ }).run();
95
+
96
+ return worktrees.filter((wt) => selectedBranches.includes(wt.branch));
97
+ }
98
+
99
+ /**
100
+ * 根据用户输入解析目标 worktree(多选版本)
101
+ * 匹配策略:精确匹配 → 模糊匹配(唯一直接使用,多个交互多选) → 无匹配报错
102
+ * 不传分支名时列出所有可用分支供多选
103
+ * @param {WorktreeInfo[]} worktrees - 可用的 worktree 列表
104
+ * @param {WorktreeMultiResolveMessages} messages - 命令专属的消息文案
105
+ * @param {string} [branchName] - 用户输入的分支名(可选)
106
+ * @returns {Promise<WorktreeInfo[]>} 解析后的目标 worktree 列表
107
+ * @throws {ClawtError} 无可用 worktree 或无匹配结果时抛出
108
+ */
109
+ export async function resolveTargetWorktrees(
110
+ worktrees: WorktreeInfo[],
111
+ messages: WorktreeMultiResolveMessages,
112
+ branchName?: string,
113
+ ): Promise<WorktreeInfo[]> {
114
+ // 无可用 worktree,直接报错
115
+ if (worktrees.length === 0) {
116
+ throw new ClawtError(messages.noWorktrees);
117
+ }
118
+
119
+ // 未传 -b 参数:列出所有分支供多选
120
+ if (!branchName) {
121
+ // 只有一个 worktree 时直接使用,无需选择
122
+ if (worktrees.length === 1) {
123
+ return [worktrees[0]];
124
+ }
125
+ return promptMultiSelectBranches(worktrees, messages.selectBranch);
126
+ }
127
+
128
+ // 1. 精确匹配优先
129
+ const exactMatch = findExactMatch(worktrees, branchName);
130
+ if (exactMatch) {
131
+ return [exactMatch];
132
+ }
133
+
134
+ // 2. 模糊匹配
135
+ const fuzzyMatches = findFuzzyMatches(worktrees, branchName);
136
+
137
+ // 2a. 唯一匹配,直接使用
138
+ if (fuzzyMatches.length === 1) {
139
+ return [fuzzyMatches[0]];
140
+ }
141
+
142
+ // 2b. 多个匹配,交互多选
143
+ if (fuzzyMatches.length > 1) {
144
+ return promptMultiSelectBranches(fuzzyMatches, messages.multipleMatches(branchName));
145
+ }
146
+
147
+ // 3. 无匹配,抛出错误并列出所有可用分支
148
+ const allBranches = worktrees.map((wt) => wt.branch);
149
+ throw new ClawtError(messages.noMatch(branchName, allBranches));
150
+ }
151
+
60
152
  /**
61
153
  * 根据用户输入解析目标 worktree
62
154
  * 匹配策略:精确匹配 → 模糊匹配(唯一直接使用,多个交互选择) → 无匹配报错
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+
4
+ // mock 依赖模块
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/utils/index.js', () => ({
10
+ loadConfig: vi.fn(),
11
+ writeDefaultConfig: vi.fn(),
12
+ printInfo: vi.fn(),
13
+ printSuccess: vi.fn(),
14
+ printSeparator: vi.fn(),
15
+ confirmDestructiveAction: vi.fn(),
16
+ }));
17
+
18
+ vi.mock('../../../src/constants/index.js', () => ({
19
+ CONFIG_PATH: '/mock/.clawt/config.json',
20
+ DEFAULT_CONFIG: {
21
+ claudeCodeCommand: 'claude',
22
+ autoDeleteBranch: false,
23
+ autoPullPush: false,
24
+ confirmDestructiveOps: true,
25
+ },
26
+ CONFIG_DESCRIPTIONS: {
27
+ claudeCodeCommand: 'Claude Code CLI 命令',
28
+ autoDeleteBranch: '自动删除分支',
29
+ autoPullPush: '自动 pull/push',
30
+ confirmDestructiveOps: '破坏性操作确认',
31
+ },
32
+ MESSAGES: {
33
+ CONFIG_RESET_SUCCESS: '配置已恢复为默认值',
34
+ DESTRUCTIVE_OP_CANCELLED: '已取消操作',
35
+ },
36
+ }));
37
+
38
+ import { registerConfigCommand } from '../../../src/commands/config.js';
39
+ import { loadConfig, writeDefaultConfig, printInfo, printSuccess, confirmDestructiveAction } from '../../../src/utils/index.js';
40
+
41
+ const mockedLoadConfig = vi.mocked(loadConfig);
42
+ const mockedWriteDefaultConfig = vi.mocked(writeDefaultConfig);
43
+ const mockedPrintInfo = vi.mocked(printInfo);
44
+ const mockedPrintSuccess = vi.mocked(printSuccess);
45
+ const mockedConfirmDestructiveAction = vi.mocked(confirmDestructiveAction);
46
+
47
+ beforeEach(() => {
48
+ mockedLoadConfig.mockReset();
49
+ mockedWriteDefaultConfig.mockReset();
50
+ mockedPrintInfo.mockReset();
51
+ mockedPrintSuccess.mockReset();
52
+ mockedConfirmDestructiveAction.mockReset();
53
+ });
54
+
55
+ describe('registerConfigCommand', () => {
56
+ it('注册 config 命令和 config reset 子命令', () => {
57
+ const program = new Command();
58
+ registerConfigCommand(program);
59
+ const configCmd = program.commands.find((c) => c.name() === 'config');
60
+ expect(configCmd).toBeDefined();
61
+ const resetCmd = configCmd!.commands.find((c) => c.name() === 'reset');
62
+ expect(resetCmd).toBeDefined();
63
+ });
64
+ });
65
+
66
+ describe('handleConfig(通过 action 间接测试)', () => {
67
+ it('展示配置列表', () => {
68
+ mockedLoadConfig.mockReturnValue({
69
+ claudeCodeCommand: 'claude',
70
+ autoDeleteBranch: false,
71
+ autoPullPush: false,
72
+ confirmDestructiveOps: true,
73
+ });
74
+
75
+ const program = new Command();
76
+ program.exitOverride();
77
+ registerConfigCommand(program);
78
+ program.parse(['config'], { from: 'user' });
79
+
80
+ expect(mockedLoadConfig).toHaveBeenCalled();
81
+ // 应输出配置信息
82
+ expect(mockedPrintInfo).toHaveBeenCalled();
83
+ });
84
+ });
85
+
86
+ describe('handleConfigReset(通过 action 间接测试)', () => {
87
+ it('用户确认后恢复默认配置', async () => {
88
+ mockedConfirmDestructiveAction.mockResolvedValue(true);
89
+
90
+ const program = new Command();
91
+ program.exitOverride();
92
+ registerConfigCommand(program);
93
+ await program.parseAsync(['config', 'reset'], { from: 'user' });
94
+
95
+ expect(mockedWriteDefaultConfig).toHaveBeenCalled();
96
+ expect(mockedPrintSuccess).toHaveBeenCalled();
97
+ });
98
+
99
+ it('用户取消操作时不写入', async () => {
100
+ mockedConfirmDestructiveAction.mockResolvedValue(false);
101
+
102
+ const program = new Command();
103
+ program.exitOverride();
104
+ registerConfigCommand(program);
105
+ await program.parseAsync(['config', 'reset'], { from: 'user' });
106
+
107
+ expect(mockedWriteDefaultConfig).not.toHaveBeenCalled();
108
+ expect(mockedPrintInfo).toHaveBeenCalled();
109
+ });
110
+ });
@@ -0,0 +1,115 @@
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/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
+
18
+ vi.mock('../../../src/constants/index.js', () => ({
19
+ MESSAGES: {
20
+ INVALID_COUNT: (val: string) => `数量必须为正整数: ${val}`,
21
+ WORKTREE_CREATED: (count: number) => `✓ 已创建 ${count} 个 worktree`,
22
+ },
23
+ EXIT_CODES: { SUCCESS: 0, ERROR: 1, ARGUMENT_ERROR: 2 },
24
+ }));
25
+
26
+ vi.mock('../../../src/utils/index.js', () => ({
27
+ validateMainWorktree: vi.fn(),
28
+ createWorktrees: vi.fn(),
29
+ printSuccess: vi.fn(),
30
+ printInfo: vi.fn(),
31
+ printSeparator: vi.fn(),
32
+ }));
33
+
34
+ import { registerCreateCommand } from '../../../src/commands/create.js';
35
+ import { validateMainWorktree, createWorktrees, printSuccess } from '../../../src/utils/index.js';
36
+
37
+ const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
38
+ const mockedCreateWorktrees = vi.mocked(createWorktrees);
39
+ const mockedPrintSuccess = vi.mocked(printSuccess);
40
+
41
+ beforeEach(() => {
42
+ mockedValidateMainWorktree.mockReset();
43
+ mockedCreateWorktrees.mockReset();
44
+ mockedPrintSuccess.mockReset();
45
+ });
46
+
47
+ describe('registerCreateCommand', () => {
48
+ it('注册 create 命令', () => {
49
+ const program = new Command();
50
+ registerCreateCommand(program);
51
+ const cmd = program.commands.find((c) => c.name() === 'create');
52
+ expect(cmd).toBeDefined();
53
+ });
54
+ });
55
+
56
+ describe('handleCreate', () => {
57
+ it('成功创建 worktree', () => {
58
+ mockedCreateWorktrees.mockReturnValue([
59
+ { path: '/path/feature', branch: 'feature' },
60
+ ]);
61
+
62
+ const program = new Command();
63
+ program.exitOverride();
64
+ registerCreateCommand(program);
65
+ program.parse(['create', '-b', 'feature'], { from: 'user' });
66
+
67
+ expect(mockedValidateMainWorktree).toHaveBeenCalled();
68
+ expect(mockedCreateWorktrees).toHaveBeenCalledWith('feature', 1);
69
+ expect(mockedPrintSuccess).toHaveBeenCalled();
70
+ });
71
+
72
+ it('支持 -n 指定创建数量', () => {
73
+ mockedCreateWorktrees.mockReturnValue([
74
+ { path: '/path/feature-1', branch: 'feature-1' },
75
+ { path: '/path/feature-2', branch: 'feature-2' },
76
+ ]);
77
+
78
+ const program = new Command();
79
+ program.exitOverride();
80
+ registerCreateCommand(program);
81
+ program.parse(['create', '-b', 'feature', '-n', '2'], { from: 'user' });
82
+
83
+ expect(mockedCreateWorktrees).toHaveBeenCalledWith('feature', 2);
84
+ });
85
+
86
+ it('无效数量抛出 ClawtError', () => {
87
+ const program = new Command();
88
+ program.exitOverride();
89
+ registerCreateCommand(program);
90
+
91
+ expect(() => {
92
+ program.parse(['create', '-b', 'feature', '-n', 'abc'], { from: 'user' });
93
+ }).toThrow();
94
+ });
95
+
96
+ it('数量为 0 时抛出 ClawtError', () => {
97
+ const program = new Command();
98
+ program.exitOverride();
99
+ registerCreateCommand(program);
100
+
101
+ expect(() => {
102
+ program.parse(['create', '-b', 'feature', '-n', '0'], { from: 'user' });
103
+ }).toThrow();
104
+ });
105
+
106
+ it('负数数量抛出 ClawtError', () => {
107
+ const program = new Command();
108
+ program.exitOverride();
109
+ registerCreateCommand(program);
110
+
111
+ expect(() => {
112
+ program.parse(['create', '-b', 'feature', '-n', '-1'], { from: 'user' });
113
+ }).toThrow();
114
+ });
115
+ });
@@ -0,0 +1,118 @@
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
+ NO_WORKTREES: '(无 worktree)',
11
+ WORKTREE_STATUS_UNAVAILABLE: '(状态不可用)',
12
+ },
13
+ }));
14
+
15
+ vi.mock('../../../src/utils/index.js', () => ({
16
+ validateMainWorktree: vi.fn(),
17
+ getProjectName: vi.fn(),
18
+ getProjectWorktrees: vi.fn(),
19
+ getWorktreeStatus: vi.fn(),
20
+ formatWorktreeStatus: vi.fn(),
21
+ isWorktreeIdle: vi.fn(),
22
+ printInfo: vi.fn(),
23
+ }));
24
+
25
+ import { registerListCommand } from '../../../src/commands/list.js';
26
+ import { validateMainWorktree, getProjectName, getProjectWorktrees, getWorktreeStatus, printInfo } from '../../../src/utils/index.js';
27
+
28
+ const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
29
+ const mockedGetProjectName = vi.mocked(getProjectName);
30
+ const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
31
+ const mockedGetWorktreeStatus = vi.mocked(getWorktreeStatus);
32
+ const mockedPrintInfo = vi.mocked(printInfo);
33
+
34
+ beforeEach(() => {
35
+ mockedValidateMainWorktree.mockReset();
36
+ mockedGetProjectName.mockReset();
37
+ mockedGetProjectWorktrees.mockReset();
38
+ mockedGetWorktreeStatus.mockReset();
39
+ mockedPrintInfo.mockReset();
40
+ });
41
+
42
+ describe('registerListCommand', () => {
43
+ it('注册 list 命令', () => {
44
+ const program = new Command();
45
+ registerListCommand(program);
46
+ const cmd = program.commands.find((c) => c.name() === 'list');
47
+ expect(cmd).toBeDefined();
48
+ });
49
+ });
50
+
51
+ describe('handleList', () => {
52
+ it('无 worktree 时文本输出', () => {
53
+ mockedGetProjectName.mockReturnValue('test-project');
54
+ mockedGetProjectWorktrees.mockReturnValue([]);
55
+
56
+ const program = new Command();
57
+ program.exitOverride();
58
+ registerListCommand(program);
59
+ program.parse(['list'], { from: 'user' });
60
+
61
+ expect(mockedValidateMainWorktree).toHaveBeenCalled();
62
+ expect(mockedPrintInfo).toHaveBeenCalled();
63
+ });
64
+
65
+ it('有 worktree 时文本输出', () => {
66
+ mockedGetProjectName.mockReturnValue('test-project');
67
+ mockedGetProjectWorktrees.mockReturnValue([
68
+ { path: '/path/feature', branch: 'feature' },
69
+ ]);
70
+ mockedGetWorktreeStatus.mockReturnValue({
71
+ commitCount: 3, insertions: 10, deletions: 5, hasDirtyFiles: false,
72
+ });
73
+
74
+ const program = new Command();
75
+ program.exitOverride();
76
+ registerListCommand(program);
77
+ program.parse(['list'], { from: 'user' });
78
+
79
+ expect(mockedGetWorktreeStatus).toHaveBeenCalled();
80
+ });
81
+
82
+ it('--json 输出 JSON 格式', () => {
83
+ mockedGetProjectName.mockReturnValue('test-project');
84
+ mockedGetProjectWorktrees.mockReturnValue([
85
+ { path: '/path/feature', branch: 'feature' },
86
+ ]);
87
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
88
+
89
+ const program = new Command();
90
+ program.exitOverride();
91
+ registerListCommand(program);
92
+ program.parse(['list', '--json'], { from: 'user' });
93
+
94
+ // 应通过 console.log 输出 JSON
95
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
96
+ try { JSON.parse(call[0]); return true; } catch { return false; }
97
+ });
98
+ expect(jsonCall).toBeDefined();
99
+ const parsed = JSON.parse(jsonCall![0]);
100
+ expect(parsed.project).toBe('test-project');
101
+ expect(parsed.total).toBe(1);
102
+ });
103
+
104
+ it('worktree 状态不可用时显示提示', () => {
105
+ mockedGetProjectName.mockReturnValue('test-project');
106
+ mockedGetProjectWorktrees.mockReturnValue([
107
+ { path: '/path/feature', branch: 'feature' },
108
+ ]);
109
+ mockedGetWorktreeStatus.mockReturnValue(null);
110
+
111
+ const program = new Command();
112
+ program.exitOverride();
113
+ registerListCommand(program);
114
+ program.parse(['list'], { from: 'user' });
115
+
116
+ expect(mockedGetWorktreeStatus).toHaveBeenCalled();
117
+ });
118
+ });