clawt 2.19.0 → 3.0.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.
Files changed (75) hide show
  1. package/README.md +12 -2
  2. package/dist/index.js +626 -219
  3. package/dist/postinstall.js +39 -6
  4. package/docs/alias.md +108 -0
  5. package/docs/completion.md +55 -0
  6. package/docs/config-file.md +43 -0
  7. package/docs/config.md +91 -0
  8. package/docs/create.md +85 -0
  9. package/docs/init.md +65 -0
  10. package/docs/list.md +67 -0
  11. package/docs/log.md +67 -0
  12. package/docs/merge.md +137 -0
  13. package/docs/notification.md +94 -0
  14. package/docs/projects.md +135 -0
  15. package/docs/remove.md +79 -0
  16. package/docs/reset.md +35 -0
  17. package/docs/resume.md +99 -0
  18. package/docs/run.md +146 -0
  19. package/docs/spec.md +156 -1879
  20. package/docs/status.md +155 -0
  21. package/docs/sync.md +114 -0
  22. package/docs/update-check.md +95 -0
  23. package/docs/validate.md +368 -0
  24. package/package.json +1 -1
  25. package/src/commands/alias.ts +1 -1
  26. package/src/commands/create.ts +10 -5
  27. package/src/commands/init.ts +75 -0
  28. package/src/commands/list.ts +1 -1
  29. package/src/commands/merge.ts +11 -4
  30. package/src/commands/remove.ts +10 -3
  31. package/src/commands/reset.ts +3 -0
  32. package/src/commands/resume.ts +9 -3
  33. package/src/commands/run.ts +9 -3
  34. package/src/commands/status.ts +1 -1
  35. package/src/commands/sync.ts +18 -6
  36. package/src/commands/validate.ts +46 -52
  37. package/src/constants/branch.ts +3 -0
  38. package/src/constants/config.ts +1 -1
  39. package/src/constants/index.ts +3 -3
  40. package/src/constants/messages/completion.ts +1 -1
  41. package/src/constants/messages/create.ts +3 -0
  42. package/src/constants/messages/index.ts +2 -0
  43. package/src/constants/messages/init.ts +18 -0
  44. package/src/constants/messages/remove.ts +2 -0
  45. package/src/constants/messages/sync.ts +3 -0
  46. package/src/constants/messages/validate.ts +6 -0
  47. package/src/constants/paths.ts +3 -0
  48. package/src/constants/prompt.ts +28 -0
  49. package/src/index.ts +2 -0
  50. package/src/types/command.ts +7 -1
  51. package/src/types/index.ts +2 -1
  52. package/src/types/projectConfig.ts +5 -0
  53. package/src/utils/config.ts +2 -1
  54. package/src/utils/git.ts +18 -0
  55. package/src/utils/index.ts +6 -1
  56. package/src/utils/json.ts +67 -0
  57. package/src/utils/project-config.ts +77 -0
  58. package/src/utils/validate-branch.ts +166 -0
  59. package/src/utils/worktree-matcher.ts +268 -1
  60. package/src/utils/worktree.ts +6 -2
  61. package/tests/unit/commands/create.test.ts +20 -16
  62. package/tests/unit/commands/init.test.ts +146 -0
  63. package/tests/unit/commands/merge.test.ts +7 -1
  64. package/tests/unit/commands/remove.test.ts +4 -0
  65. package/tests/unit/commands/reset.test.ts +2 -0
  66. package/tests/unit/commands/resume.test.ts +29 -8
  67. package/tests/unit/commands/run.test.ts +2 -0
  68. package/tests/unit/commands/sync.test.ts +6 -0
  69. package/tests/unit/commands/validate.test.ts +13 -0
  70. package/tests/unit/utils/config.test.ts +2 -2
  71. package/tests/unit/utils/project-config.test.ts +136 -0
  72. package/tests/unit/utils/update-checker.test.ts +28 -7
  73. package/tests/unit/utils/validate-branch.test.ts +272 -0
  74. package/tests/unit/utils/worktree-matcher.test.ts +142 -1
  75. package/tests/unit/utils/worktree.test.ts +6 -0
@@ -26,6 +26,10 @@ vi.mock('../../../src/constants/index.js', () => ({
26
26
  vi.mock('../../../src/utils/index.js', () => ({
27
27
  validateMainWorktree: vi.fn(),
28
28
  createWorktrees: vi.fn(),
29
+ getConfigValue: vi.fn().mockReturnValue(true),
30
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
31
+ ensureOnMainWorkBranch: vi.fn().mockResolvedValue(undefined),
32
+ getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
29
33
  printSuccess: vi.fn(),
30
34
  printInfo: vi.fn(),
31
35
  printSeparator: vi.fn(),
@@ -54,7 +58,7 @@ describe('registerCreateCommand', () => {
54
58
  });
55
59
 
56
60
  describe('handleCreate', () => {
57
- it('成功创建 worktree', () => {
61
+ it('成功创建 worktree', async () => {
58
62
  mockedCreateWorktrees.mockReturnValue([
59
63
  { path: '/path/feature', branch: 'feature' },
60
64
  ]);
@@ -62,14 +66,14 @@ describe('handleCreate', () => {
62
66
  const program = new Command();
63
67
  program.exitOverride();
64
68
  registerCreateCommand(program);
65
- program.parse(['create', '-b', 'feature'], { from: 'user' });
69
+ await program.parseAsync(['create', '-b', 'feature'], { from: 'user' });
66
70
 
67
71
  expect(mockedValidateMainWorktree).toHaveBeenCalled();
68
72
  expect(mockedCreateWorktrees).toHaveBeenCalledWith('feature', 1);
69
73
  expect(mockedPrintSuccess).toHaveBeenCalled();
70
74
  });
71
75
 
72
- it('支持 -n 指定创建数量', () => {
76
+ it('支持 -n 指定创建数量', async () => {
73
77
  mockedCreateWorktrees.mockReturnValue([
74
78
  { path: '/path/feature-1', branch: 'feature-1' },
75
79
  { path: '/path/feature-2', branch: 'feature-2' },
@@ -78,38 +82,38 @@ describe('handleCreate', () => {
78
82
  const program = new Command();
79
83
  program.exitOverride();
80
84
  registerCreateCommand(program);
81
- program.parse(['create', '-b', 'feature', '-n', '2'], { from: 'user' });
85
+ await program.parseAsync(['create', '-b', 'feature', '-n', '2'], { from: 'user' });
82
86
 
83
87
  expect(mockedCreateWorktrees).toHaveBeenCalledWith('feature', 2);
84
88
  });
85
89
 
86
- it('无效数量抛出 ClawtError', () => {
90
+ it('无效数量抛出 ClawtError', async () => {
87
91
  const program = new Command();
88
92
  program.exitOverride();
89
93
  registerCreateCommand(program);
90
94
 
91
- expect(() => {
92
- program.parse(['create', '-b', 'feature', '-n', 'abc'], { from: 'user' });
93
- }).toThrow();
95
+ await expect(
96
+ program.parseAsync(['create', '-b', 'feature', '-n', 'abc'], { from: 'user' }),
97
+ ).rejects.toThrow();
94
98
  });
95
99
 
96
- it('数量为 0 时抛出 ClawtError', () => {
100
+ it('数量为 0 时抛出 ClawtError', async () => {
97
101
  const program = new Command();
98
102
  program.exitOverride();
99
103
  registerCreateCommand(program);
100
104
 
101
- expect(() => {
102
- program.parse(['create', '-b', 'feature', '-n', '0'], { from: 'user' });
103
- }).toThrow();
105
+ await expect(
106
+ program.parseAsync(['create', '-b', 'feature', '-n', '0'], { from: 'user' }),
107
+ ).rejects.toThrow();
104
108
  });
105
109
 
106
- it('负数数量抛出 ClawtError', () => {
110
+ it('负数数量抛出 ClawtError', async () => {
107
111
  const program = new Command();
108
112
  program.exitOverride();
109
113
  registerCreateCommand(program);
110
114
 
111
- expect(() => {
112
- program.parse(['create', '-b', 'feature', '-n', '-1'], { from: 'user' });
113
- }).toThrow();
115
+ await expect(
116
+ program.parseAsync(['create', '-b', 'feature', '-n', '-1'], { from: 'user' }),
117
+ ).rejects.toThrow();
114
118
  });
115
119
  });
@@ -0,0 +1,146 @@
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
+ INIT_SUCCESS: (branch: string) => `✓ 项目初始化成功,主工作分支设置为: ${branch}`,
11
+ INIT_UPDATED: (oldBranch: string, newBranch: string) => `✓ 已将主工作分支从 ${oldBranch} 更新为 ${newBranch}`,
12
+ INIT_SHOW: (configJson: string) => `当前项目配置:\n${configJson}`,
13
+ PROJECT_NOT_INITIALIZED: '项目尚未初始化,请先执行 clawt init 设置主工作分支',
14
+ PROJECT_CONFIG_MISSING_BRANCH: '项目配置缺少主工作分支信息,请重新执行 clawt init 设置主工作分支',
15
+ },
16
+ }));
17
+
18
+ vi.mock('../../../src/utils/index.js', () => ({
19
+ validateMainWorktree: vi.fn(),
20
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
21
+ loadProjectConfig: vi.fn(),
22
+ saveProjectConfig: vi.fn(),
23
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
24
+ printSuccess: vi.fn(),
25
+ printInfo: vi.fn(),
26
+ safeStringify: vi.fn((value: unknown, indent: number = 2) => JSON.stringify(value, null, indent)),
27
+ }));
28
+
29
+ import { registerInitCommand } from '../../../src/commands/init.js';
30
+ import {
31
+ loadProjectConfig,
32
+ saveProjectConfig,
33
+ requireProjectConfig,
34
+ printSuccess,
35
+ printInfo,
36
+ getCurrentBranch,
37
+ } from '../../../src/utils/index.js';
38
+
39
+ const mockedLoadProjectConfig = vi.mocked(loadProjectConfig);
40
+ const mockedSaveProjectConfig = vi.mocked(saveProjectConfig);
41
+ const mockedRequireProjectConfig = vi.mocked(requireProjectConfig);
42
+ const mockedPrintSuccess = vi.mocked(printSuccess);
43
+ const mockedPrintInfo = vi.mocked(printInfo);
44
+ const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
45
+
46
+ beforeEach(() => {
47
+ mockedLoadProjectConfig.mockReset();
48
+ mockedSaveProjectConfig.mockReset();
49
+ mockedRequireProjectConfig.mockReset();
50
+ mockedRequireProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'main' });
51
+ mockedPrintSuccess.mockReset();
52
+ mockedPrintInfo.mockReset();
53
+ mockedGetCurrentBranch.mockReturnValue('main');
54
+ });
55
+
56
+ describe('registerInitCommand', () => {
57
+ it('注册 init 命令', () => {
58
+ const program = new Command();
59
+ registerInitCommand(program);
60
+ const cmd = program.commands.find((c) => c.name() === 'init');
61
+ expect(cmd).toBeDefined();
62
+ });
63
+
64
+ it('注册 init show 子命令', () => {
65
+ const program = new Command();
66
+ registerInitCommand(program);
67
+ const initCmd = program.commands.find((c) => c.name() === 'init');
68
+ const showCmd = initCmd?.commands.find((c) => c.name() === 'show');
69
+ expect(showCmd).toBeDefined();
70
+ });
71
+ });
72
+
73
+ describe('handleInit', () => {
74
+ it('无参数且已初始化时使用当前分支切换主工作分支', async () => {
75
+ mockedLoadProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'develop' });
76
+
77
+ const program = new Command();
78
+ program.exitOverride();
79
+ registerInitCommand(program);
80
+ await program.parseAsync(['init'], { from: 'user' });
81
+
82
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({ clawtMainWorkBranch: 'main' });
83
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
84
+ expect.stringContaining('develop'),
85
+ );
86
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
87
+ expect.stringContaining('main'),
88
+ );
89
+ });
90
+
91
+ it('无参数且未初始化时使用当前分支初始化', async () => {
92
+ mockedLoadProjectConfig.mockReturnValue(null);
93
+
94
+ const program = new Command();
95
+ program.exitOverride();
96
+ registerInitCommand(program);
97
+ await program.parseAsync(['init'], { from: 'user' });
98
+
99
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({ clawtMainWorkBranch: 'main' });
100
+ expect(mockedPrintSuccess).toHaveBeenCalled();
101
+ });
102
+
103
+ it('有 -b 参数时设置指定分支', async () => {
104
+ mockedLoadProjectConfig.mockReturnValue(null);
105
+
106
+ const program = new Command();
107
+ program.exitOverride();
108
+ registerInitCommand(program);
109
+ await program.parseAsync(['init', '-b', 'develop'], { from: 'user' });
110
+
111
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({ clawtMainWorkBranch: 'develop' });
112
+ expect(mockedPrintSuccess).toHaveBeenCalled();
113
+ });
114
+
115
+ it('有 -b 参数且已初始化时更新配置', async () => {
116
+ mockedLoadProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'main' });
117
+
118
+ const program = new Command();
119
+ program.exitOverride();
120
+ registerInitCommand(program);
121
+ await program.parseAsync(['init', '-b', 'develop'], { from: 'user' });
122
+
123
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({ clawtMainWorkBranch: 'develop' });
124
+ // 验证 INIT_UPDATED 传入了旧分支名和新分支名
125
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
126
+ expect.stringContaining('main'),
127
+ );
128
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
129
+ expect.stringContaining('develop'),
130
+ );
131
+ });
132
+ });
133
+
134
+ describe('handleInitShow (show 子命令)', () => {
135
+ it('clawt init show 展示当前配置', async () => {
136
+ mockedRequireProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'develop' });
137
+
138
+ const program = new Command();
139
+ program.exitOverride();
140
+ registerInitCommand(program);
141
+ await program.parseAsync(['init', 'show'], { from: 'user' });
142
+
143
+ expect(mockedPrintInfo).toHaveBeenCalled();
144
+ expect(mockedSaveProjectConfig).not.toHaveBeenCalled();
145
+ });
146
+ });
@@ -64,7 +64,13 @@ vi.mock('../../../src/utils/index.js', () => ({
64
64
  gitMergeBase: vi.fn(),
65
65
  gitResetSoftTo: vi.fn(),
66
66
  getCurrentBranch: vi.fn(),
67
+ gitResetHard: vi.fn(),
68
+ gitCleanForce: vi.fn(),
69
+ gitCheckout: vi.fn(),
67
70
  resolveTargetWorktree: vi.fn(),
71
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
72
+ getMainWorkBranch: vi.fn().mockReturnValue('main'),
73
+ ensureOnMainWorkBranch: vi.fn(),
68
74
  }));
69
75
 
70
76
  import { registerMergeCommand } from '../../../src/commands/merge.js';
@@ -175,7 +181,7 @@ describe('handleMerge', () => {
175
181
 
176
182
  it('目标 worktree 有未提交修改且提供 -m 时先提交再合并', async () => {
177
183
  mockedIsWorkingDirClean
178
- .mockReturnValueOnce(true) // 主 worktree 干净
184
+ .mockReturnValueOnce(true) // 主 worktree 状态检测:干净
179
185
  .mockReturnValueOnce(false); // 目标 worktree 不干净
180
186
  mockedConfirmAction.mockResolvedValue(false); // 不清理
181
187
 
@@ -46,6 +46,10 @@ vi.mock('../../../src/utils/index.js', () => ({
46
46
  removeSnapshot: vi.fn(),
47
47
  removeProjectSnapshots: vi.fn(),
48
48
  resolveTargetWorktrees: vi.fn(),
49
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
50
+ getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
51
+ deleteValidateBranch: vi.fn(),
52
+ ensureOnMainWorkBranch: vi.fn(),
49
53
  }));
50
54
 
51
55
  import { registerRemoveCommand } from '../../../src/commands/remove.js';
@@ -23,6 +23,8 @@ vi.mock('../../../src/utils/index.js', () => ({
23
23
  confirmDestructiveAction: vi.fn(),
24
24
  printSuccess: vi.fn(),
25
25
  printInfo: vi.fn(),
26
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
27
+ switchBackIfOnValidateBranch: vi.fn(),
26
28
  }));
27
29
 
28
30
  import { registerResetCommand } from '../../../src/commands/reset.js';
@@ -20,6 +20,7 @@ vi.mock('../../../src/utils/index.js', () => ({
20
20
  getProjectWorktrees: vi.fn(),
21
21
  launchInteractiveClaude: vi.fn(),
22
22
  resolveTargetWorktrees: vi.fn(),
23
+ promptGroupedMultiSelectBranches: vi.fn(),
23
24
  }));
24
25
 
25
26
  import { registerResumeCommand } from '../../../src/commands/resume.js';
@@ -29,6 +30,7 @@ import {
29
30
  getProjectWorktrees,
30
31
  launchInteractiveClaude,
31
32
  resolveTargetWorktrees,
33
+ promptGroupedMultiSelectBranches,
32
34
  } from '../../../src/utils/index.js';
33
35
 
34
36
  const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
@@ -36,6 +38,7 @@ const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled)
36
38
  const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
37
39
  const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
38
40
  const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
41
+ const mockedPromptGroupedMultiSelectBranches = vi.mocked(promptGroupedMultiSelectBranches);
39
42
 
40
43
  beforeEach(() => {
41
44
  mockedValidateMainWorktree.mockReset();
@@ -43,6 +46,7 @@ beforeEach(() => {
43
46
  mockedGetProjectWorktrees.mockReset();
44
47
  mockedLaunchInteractiveClaude.mockReset();
45
48
  mockedResolveTargetWorktrees.mockReset();
49
+ mockedPromptGroupedMultiSelectBranches.mockReset();
46
50
  });
47
51
 
48
52
  describe('registerResumeCommand', () => {
@@ -55,7 +59,7 @@ describe('registerResumeCommand', () => {
55
59
  });
56
60
 
57
61
  describe('handleResume', () => {
58
- it('成功恢复 Claude Code 会话', async () => {
62
+ it(' -b 时走标准解析流程', async () => {
59
63
  const worktree = { path: '/path/feature', branch: 'feature' };
60
64
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
61
65
  mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
@@ -68,10 +72,31 @@ describe('handleResume', () => {
68
72
  expect(mockedValidateMainWorktree).toHaveBeenCalled();
69
73
  expect(mockedValidateClaudeCodeInstalled).toHaveBeenCalled();
70
74
  expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
75
+ expect(mockedPromptGroupedMultiSelectBranches).not.toHaveBeenCalled();
71
76
  expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
72
77
  });
73
78
 
74
- it('不传 -b 时也能调用 resolveTargetWorktrees', async () => {
79
+ it('不传 -b 且多个 worktree 时默认使用分组多选', async () => {
80
+ const worktrees = [
81
+ { path: '/path/feature-a', branch: 'feature-a' },
82
+ { path: '/path/feature-b', branch: 'feature-b' },
83
+ ];
84
+ mockedGetProjectWorktrees.mockReturnValue(worktrees);
85
+ mockedPromptGroupedMultiSelectBranches.mockResolvedValue([worktrees[0]]);
86
+
87
+ const program = new Command();
88
+ program.exitOverride();
89
+ registerResumeCommand(program);
90
+ await program.parseAsync(['resume'], { from: 'user' });
91
+
92
+ expect(mockedPromptGroupedMultiSelectBranches).toHaveBeenCalledWith(
93
+ worktrees,
94
+ expect.any(String),
95
+ );
96
+ expect(mockedResolveTargetWorktrees).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('仅 1 个 worktree 时走标准流程', async () => {
75
100
  const worktree = { path: '/path/feature', branch: 'feature' };
76
101
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
77
102
  mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
@@ -81,11 +106,7 @@ describe('handleResume', () => {
81
106
  registerResumeCommand(program);
82
107
  await program.parseAsync(['resume'], { from: 'user' });
83
108
 
84
- // branchName 参数为 undefined
85
- expect(mockedResolveTargetWorktrees).toHaveBeenCalledWith(
86
- expect.any(Array),
87
- expect.any(Object),
88
- undefined,
89
- );
109
+ expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
110
+ expect(mockedPromptGroupedMultiSelectBranches).not.toHaveBeenCalled();
90
111
  });
91
112
  });
@@ -71,6 +71,8 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
71
71
  parseTasksFromOptions: vi.fn(),
72
72
  createWorktreesByBranches: vi.fn(),
73
73
  printDryRunPreview: vi.fn(),
74
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
75
+ ensureOnMainWorkBranch: vi.fn().mockResolvedValue(undefined),
74
76
  };
75
77
  });
76
78
 
@@ -25,6 +25,7 @@ vi.mock('../../../src/constants/index.js', () => ({
25
25
  SYNC_MERGING: (branch: string, mainBranch: string) => `正在将 ${mainBranch} 合并到 ${branch}...`,
26
26
  SYNC_CONFLICT: (path: string) => `合并冲突,请手动解决: ${path}`,
27
27
  SYNC_SUCCESS: (branch: string, mainBranch: string) => `✓ 已将 ${mainBranch} 同步到 ${branch}`,
28
+ SYNC_VALIDATE_BRANCH_REBUILT: (validateBranch: string) => `验证分支 ${validateBranch} 已重建`,
28
29
  },
29
30
  AUTO_SAVE_COMMIT_MESSAGE: 'clawt:auto-save',
30
31
  }));
@@ -46,6 +47,11 @@ vi.mock('../../../src/utils/index.js', () => ({
46
47
  printInfo: vi.fn(),
47
48
  printWarning: vi.fn(),
48
49
  resolveTargetWorktree: vi.fn(),
50
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
51
+ getMainWorkBranch: vi.fn().mockReturnValue('main'),
52
+ rebuildValidateBranch: vi.fn(),
53
+ getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
54
+ ensureOnMainWorkBranch: vi.fn(),
49
55
  }));
50
56
 
51
57
  import { registerSyncCommand } from '../../../src/commands/sync.js';
@@ -40,6 +40,11 @@ vi.mock('../../../src/constants/index.js', () => ({
40
40
  VALIDATE_PARALLEL_CMD_FAILED: (cmd: string, code: number) => ` ✗ ${cmd}(退出码: ${code})`,
41
41
  VALIDATE_PARALLEL_CMD_ERROR: (cmd: string, msg: string) => ` ✗ ${cmd}(错误: ${msg})`,
42
42
  SEPARATOR: '────',
43
+ VALIDATE_BRANCH_NOT_FOUND: (validateBranch: string, branch: string) => `验证分支 ${validateBranch} 不存在`,
44
+ VALIDATE_SUCCESS_WITH_BRANCH: (branch: string, validateBranch: string) => `✓ 已切换到验证分支 ${validateBranch} 并验证 ${branch}`,
45
+ VALIDATE_CONFIRM_AUTO_SYNC: (branch: string) => `是否自动 sync ${branch}`,
46
+ VALIDATE_AUTO_SYNC_DECLINED: (branch: string) => `已跳过 ${branch} 的自动 sync`,
47
+ VALIDATE_AUTO_SYNC_START: (branch: string) => `正在自动 sync ${branch}`,
43
48
  },
44
49
  }));
45
50
 
@@ -79,6 +84,7 @@ vi.mock('../../../src/utils/index.js', () => ({
79
84
  writeSnapshot: vi.fn(),
80
85
  removeSnapshot: vi.fn(),
81
86
  confirmDestructiveAction: vi.fn(),
87
+ confirmAction: vi.fn(),
82
88
  printSuccess: vi.fn(),
83
89
  printWarning: vi.fn(),
84
90
  printInfo: vi.fn(),
@@ -88,6 +94,13 @@ vi.mock('../../../src/utils/index.js', () => ({
88
94
  printSeparator: vi.fn(),
89
95
  parseParallelCommands: vi.fn(),
90
96
  runParallelCommands: vi.fn(),
97
+ requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
98
+ getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
99
+ gitCheckout: vi.fn(),
100
+ ensureOnMainWorkBranch: vi.fn(),
101
+ handleDirtyWorkingDir: vi.fn(),
102
+ checkBranchExists: vi.fn().mockReturnValue(true),
103
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
91
104
  }));
92
105
 
93
106
  import { registerValidateCommand } from '../../../src/commands/validate.js';
@@ -90,9 +90,9 @@ describe('writeConfig', () => {
90
90
  });
91
91
 
92
92
  describe('ensureClawtDirs', () => {
93
- it('确保三个全局目录存在', () => {
93
+ it('确保四个全局目录存在', () => {
94
94
  ensureClawtDirs();
95
- expect(mockedEnsureDir).toHaveBeenCalledTimes(3);
95
+ expect(mockedEnsureDir).toHaveBeenCalledTimes(4);
96
96
  });
97
97
  });
98
98
 
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // mock node:fs
4
+ vi.mock('node:fs', () => ({
5
+ existsSync: vi.fn(),
6
+ readFileSync: vi.fn(),
7
+ writeFileSync: vi.fn(),
8
+ }));
9
+
10
+ // mock logger
11
+ vi.mock('../../../src/logger/index.js', () => ({
12
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
13
+ }));
14
+
15
+ // mock errors
16
+ vi.mock('../../../src/errors/index.js', () => ({
17
+ ClawtError: class ClawtError extends Error {
18
+ exitCode: number;
19
+ constructor(message: string, exitCode = 1) {
20
+ super(message);
21
+ this.exitCode = exitCode;
22
+ }
23
+ },
24
+ }));
25
+
26
+ // mock constants
27
+ vi.mock('../../../src/constants/index.js', () => ({
28
+ PROJECTS_CONFIG_DIR: '/mock/.clawt/projects',
29
+ MESSAGES: {
30
+ PROJECT_NOT_INITIALIZED: '项目尚未初始化,请先执行 clawt init 设置主工作分支',
31
+ PROJECT_CONFIG_MISSING_BRANCH: '项目配置缺少主工作分支信息,请重新执行 clawt init 设置主工作分支',
32
+ },
33
+ }));
34
+
35
+ // mock git
36
+ vi.mock('../../../src/utils/git.js', () => ({
37
+ getProjectName: vi.fn().mockReturnValue('test-project'),
38
+ }));
39
+
40
+ // mock fs utils
41
+ vi.mock('../../../src/utils/fs.js', () => ({
42
+ ensureDir: vi.fn(),
43
+ }));
44
+
45
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
46
+ import {
47
+ getProjectConfigPath,
48
+ loadProjectConfig,
49
+ saveProjectConfig,
50
+ requireProjectConfig,
51
+ getMainWorkBranch,
52
+ } from '../../../src/utils/project-config.js';
53
+
54
+ const mockedExistsSync = vi.mocked(existsSync);
55
+ const mockedReadFileSync = vi.mocked(readFileSync);
56
+ const mockedWriteFileSync = vi.mocked(writeFileSync);
57
+
58
+ beforeEach(() => {
59
+ mockedExistsSync.mockReset();
60
+ mockedReadFileSync.mockReset();
61
+ mockedWriteFileSync.mockReset();
62
+ });
63
+
64
+ describe('getProjectConfigPath', () => {
65
+ it('返回正确的配置文件路径', () => {
66
+ const path = getProjectConfigPath('my-project');
67
+ // 路径格式:<PROJECTS_CONFIG_DIR>/<projectName>/config.json
68
+ expect(path).toContain('my-project');
69
+ expect(path).toContain('config.json');
70
+ expect(path).toContain('projects');
71
+ });
72
+ });
73
+
74
+ describe('loadProjectConfig', () => {
75
+ it('配置文件不存在时返回 null', () => {
76
+ mockedExistsSync.mockReturnValue(false);
77
+ expect(loadProjectConfig()).toBeNull();
78
+ });
79
+
80
+ it('配置文件存在时正确解析', () => {
81
+ mockedExistsSync.mockReturnValue(true);
82
+ mockedReadFileSync.mockReturnValue(JSON.stringify({ clawtMainWorkBranch: 'develop' }));
83
+ const config = loadProjectConfig();
84
+ expect(config).toEqual({ clawtMainWorkBranch: 'develop' });
85
+ });
86
+
87
+ it('配置文件损坏时返回 null', () => {
88
+ mockedExistsSync.mockReturnValue(true);
89
+ mockedReadFileSync.mockReturnValue('invalid json');
90
+ expect(loadProjectConfig()).toBeNull();
91
+ });
92
+ });
93
+
94
+ describe('saveProjectConfig', () => {
95
+ it('将配置写入文件', () => {
96
+ saveProjectConfig({ clawtMainWorkBranch: 'main' });
97
+ expect(mockedWriteFileSync).toHaveBeenCalledWith(
98
+ expect.stringContaining('config.json'),
99
+ JSON.stringify({ clawtMainWorkBranch: 'main' }, null, 2),
100
+ 'utf-8',
101
+ );
102
+ });
103
+ });
104
+
105
+ describe('requireProjectConfig', () => {
106
+ it('配置存在且包含 clawtMainWorkBranch 时返回配置', () => {
107
+ mockedExistsSync.mockReturnValue(true);
108
+ mockedReadFileSync.mockReturnValue(JSON.stringify({ clawtMainWorkBranch: 'main' }));
109
+ expect(requireProjectConfig()).toEqual({ clawtMainWorkBranch: 'main' });
110
+ });
111
+
112
+ it('配置不存在时抛出错误', () => {
113
+ mockedExistsSync.mockReturnValue(false);
114
+ expect(() => requireProjectConfig()).toThrow();
115
+ });
116
+
117
+ it('配置存在但缺少 clawtMainWorkBranch 字段时抛出错误', () => {
118
+ mockedExistsSync.mockReturnValue(true);
119
+ mockedReadFileSync.mockReturnValue(JSON.stringify({}));
120
+ expect(() => requireProjectConfig()).toThrow('项目配置缺少主工作分支信息');
121
+ });
122
+
123
+ it('配置存在但 clawtMainWorkBranch 为空字符串时抛出错误', () => {
124
+ mockedExistsSync.mockReturnValue(true);
125
+ mockedReadFileSync.mockReturnValue(JSON.stringify({ clawtMainWorkBranch: '' }));
126
+ expect(() => requireProjectConfig()).toThrow('项目配置缺少主工作分支信息');
127
+ });
128
+ });
129
+
130
+ describe('getMainWorkBranch', () => {
131
+ it('返回主工作分支名', () => {
132
+ mockedExistsSync.mockReturnValue(true);
133
+ mockedReadFileSync.mockReturnValue(JSON.stringify({ clawtMainWorkBranch: 'develop' }));
134
+ expect(getMainWorkBranch()).toBe('develop');
135
+ });
136
+ });
@@ -19,13 +19,34 @@ vi.mock('node:https', () => ({
19
19
  }));
20
20
 
21
21
  // mock chalk(测试环境已通过 FORCE_COLOR=0 禁用颜色,但仍需确保不产生转义码)
22
- vi.mock('chalk', () => ({
23
- default: {
24
- red: (s: string) => s,
25
- green: (s: string) => s,
26
- cyan: (s: string) => s,
27
- },
28
- }));
22
+ // 使用 Proxy 实现链式调用,支持 chalk.bold.hex('#color')('text') 等任意嵌套
23
+ vi.mock('chalk', () => {
24
+ /**
25
+ * 创建可链式调用的 chalk Proxy
26
+ * - 属性访问(如 .bold、.red)返回新 Proxy
27
+ * - 函数调用返回新 Proxy,但记录最后传入的字符串参数
28
+ * - toString / Symbol.toPrimitive 返回最后记录的字符串,支持模板字符串插值
29
+ * @param {string} [value] - 内部记录的字符串值
30
+ * @returns {unknown} 链式 Proxy 对象
31
+ */
32
+ const createChainProxy = (value = ''): unknown => {
33
+ const fn = (..._args: unknown[]) => {};
34
+ return new Proxy(fn, {
35
+ get: (_target, prop) => {
36
+ if (prop === '__esModule') return true;
37
+ if (prop === Symbol.toPrimitive || prop === 'toString') return () => value;
38
+ if (prop === 'length') return 0;
39
+ return createChainProxy(value);
40
+ },
41
+ apply: (_target, _thisArg, args) => {
42
+ // 记录最后传入的字符串参数作为输出值
43
+ const newValue = typeof args[0] === 'string' ? args[0] : value;
44
+ return createChainProxy(newValue);
45
+ },
46
+ });
47
+ };
48
+ return { default: createChainProxy(), __esModule: true };
49
+ });
29
50
 
30
51
  // mock string-width(纯 ASCII 场景下直接返回字符串长度即可)
31
52
  vi.mock('string-width', () => ({