clawt 2.20.0 → 3.1.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 (77) hide show
  1. package/.claude/agents/docs-sync-updater.md +29 -11
  2. package/README.md +19 -30
  3. package/dist/index.js +1127 -222
  4. package/dist/postinstall.js +73 -8
  5. package/docs/alias.md +108 -0
  6. package/docs/completion.md +55 -0
  7. package/docs/config-file.md +43 -0
  8. package/docs/config.md +91 -0
  9. package/docs/create.md +85 -0
  10. package/docs/init.md +65 -0
  11. package/docs/list.md +67 -0
  12. package/docs/log.md +67 -0
  13. package/docs/merge.md +137 -0
  14. package/docs/notification.md +94 -0
  15. package/docs/projects.md +135 -0
  16. package/docs/remove.md +79 -0
  17. package/docs/reset.md +35 -0
  18. package/docs/resume.md +99 -0
  19. package/docs/run.md +146 -0
  20. package/docs/spec.md +157 -1906
  21. package/docs/status.md +298 -0
  22. package/docs/sync.md +114 -0
  23. package/docs/update-check.md +95 -0
  24. package/docs/validate.md +368 -0
  25. package/package.json +1 -1
  26. package/src/commands/alias.ts +1 -1
  27. package/src/commands/create.ts +10 -5
  28. package/src/commands/init.ts +75 -0
  29. package/src/commands/list.ts +1 -1
  30. package/src/commands/merge.ts +11 -4
  31. package/src/commands/remove.ts +10 -3
  32. package/src/commands/reset.ts +3 -0
  33. package/src/commands/resume.ts +1 -1
  34. package/src/commands/run.ts +9 -3
  35. package/src/commands/status.ts +14 -5
  36. package/src/commands/sync.ts +18 -6
  37. package/src/commands/validate.ts +46 -52
  38. package/src/constants/branch.ts +3 -0
  39. package/src/constants/config.ts +1 -1
  40. package/src/constants/index.ts +14 -2
  41. package/src/constants/interactive-panel.ts +44 -0
  42. package/src/constants/messages/completion.ts +1 -1
  43. package/src/constants/messages/create.ts +3 -0
  44. package/src/constants/messages/index.ts +4 -0
  45. package/src/constants/messages/init.ts +18 -0
  46. package/src/constants/messages/interactive-panel.ts +61 -0
  47. package/src/constants/messages/remove.ts +2 -0
  48. package/src/constants/messages/sync.ts +3 -0
  49. package/src/constants/messages/validate.ts +6 -0
  50. package/src/constants/paths.ts +3 -0
  51. package/src/index.ts +2 -0
  52. package/src/types/command.ts +9 -1
  53. package/src/types/index.ts +2 -1
  54. package/src/types/projectConfig.ts +5 -0
  55. package/src/utils/config.ts +2 -1
  56. package/src/utils/git.ts +18 -0
  57. package/src/utils/index.ts +9 -1
  58. package/src/utils/interactive-panel-render.ts +315 -0
  59. package/src/utils/interactive-panel.ts +590 -0
  60. package/src/utils/json.ts +67 -0
  61. package/src/utils/project-config.ts +77 -0
  62. package/src/utils/validate-branch.ts +166 -0
  63. package/src/utils/worktree-matcher.ts +2 -2
  64. package/src/utils/worktree.ts +6 -2
  65. package/tests/unit/commands/create.test.ts +20 -16
  66. package/tests/unit/commands/init.test.ts +146 -0
  67. package/tests/unit/commands/merge.test.ts +7 -1
  68. package/tests/unit/commands/remove.test.ts +4 -0
  69. package/tests/unit/commands/reset.test.ts +2 -0
  70. package/tests/unit/commands/run.test.ts +2 -0
  71. package/tests/unit/commands/sync.test.ts +6 -0
  72. package/tests/unit/commands/validate.test.ts +13 -0
  73. package/tests/unit/utils/config.test.ts +2 -2
  74. package/tests/unit/utils/project-config.test.ts +136 -0
  75. package/tests/unit/utils/update-checker.test.ts +28 -7
  76. package/tests/unit/utils/validate-branch.test.ts +272 -0
  77. package/tests/unit/utils/worktree.test.ts +6 -0
@@ -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', () => ({
@@ -0,0 +1,272 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // mock logger
4
+ vi.mock('../../../src/logger/index.js', () => ({
5
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
+ }));
7
+
8
+ // mock errors
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
+ // mock constants
20
+ vi.mock('../../../src/constants/index.js', () => ({
21
+ VALIDATE_BRANCH_PREFIX: 'clawt-validate-',
22
+ }));
23
+
24
+ // mock enquirer(必须在所有 import 之前)
25
+ const { mockSelectRun } = vi.hoisted(() => {
26
+ const mockSelectRun = vi.fn();
27
+ return { mockSelectRun };
28
+ });
29
+
30
+ vi.mock('enquirer', () => ({
31
+ default: {
32
+ Select: function MockSelect() { return { run: mockSelectRun }; },
33
+ },
34
+ }));
35
+
36
+ // mock git
37
+ vi.mock('../../../src/utils/git.js', () => ({
38
+ checkBranchExists: vi.fn(),
39
+ createBranch: vi.fn(),
40
+ deleteBranch: vi.fn(),
41
+ getCurrentBranch: vi.fn(),
42
+ gitCheckout: vi.fn(),
43
+ gitResetHard: vi.fn(),
44
+ gitCleanForce: vi.fn(),
45
+ isWorkingDirClean: vi.fn().mockReturnValue(true),
46
+ gitAddAll: vi.fn(),
47
+ gitStashPush: vi.fn(),
48
+ }));
49
+
50
+ // mock project-config
51
+ vi.mock('../../../src/utils/project-config.js', () => ({
52
+ getMainWorkBranch: vi.fn().mockReturnValue('main'),
53
+ }));
54
+
55
+ // mock formatter
56
+ vi.mock('../../../src/utils/formatter.js', () => ({
57
+ printWarning: vi.fn(),
58
+ }));
59
+
60
+ import {
61
+ checkBranchExists,
62
+ createBranch,
63
+ deleteBranch,
64
+ getCurrentBranch,
65
+ gitCheckout,
66
+ gitResetHard,
67
+ gitCleanForce,
68
+ isWorkingDirClean,
69
+ gitAddAll,
70
+ gitStashPush,
71
+ } from '../../../src/utils/git.js';
72
+ import {
73
+ getValidateBranchName,
74
+ createValidateBranch,
75
+ deleteValidateBranch,
76
+ rebuildValidateBranch,
77
+ ensureOnMainWorkBranch,
78
+ handleDirtyWorkingDir,
79
+ } from '../../../src/utils/validate-branch.js';
80
+ const mockedCheckBranchExists = vi.mocked(checkBranchExists);
81
+ const mockedCreateBranch = vi.mocked(createBranch);
82
+ const mockedDeleteBranch = vi.mocked(deleteBranch);
83
+ const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
84
+ const mockedGitCheckout = vi.mocked(gitCheckout);
85
+ const mockedGitResetHard = vi.mocked(gitResetHard);
86
+ const mockedGitCleanForce = vi.mocked(gitCleanForce);
87
+ const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
88
+ const mockedGitAddAll = vi.mocked(gitAddAll);
89
+ const mockedGitStashPush = vi.mocked(gitStashPush);
90
+
91
+ beforeEach(() => {
92
+ mockedCheckBranchExists.mockReset();
93
+ mockedCreateBranch.mockReset();
94
+ mockedDeleteBranch.mockReset();
95
+ mockedGetCurrentBranch.mockReset();
96
+ mockedGitCheckout.mockReset();
97
+ mockedGitResetHard.mockReset();
98
+ mockedGitCleanForce.mockReset();
99
+ mockedIsWorkingDirClean.mockReset().mockReturnValue(true);
100
+ mockedGitAddAll.mockReset();
101
+ mockedGitStashPush.mockReset();
102
+ mockSelectRun.mockReset();
103
+ });
104
+
105
+ describe('getValidateBranchName', () => {
106
+ it('生成正确的验证分支名', () => {
107
+ expect(getValidateBranchName('feature')).toBe('clawt-validate-feature');
108
+ });
109
+
110
+ it('处理含连字符的分支名', () => {
111
+ expect(getValidateBranchName('my-feature-1')).toBe('clawt-validate-my-feature-1');
112
+ });
113
+ });
114
+
115
+ describe('createValidateBranch', () => {
116
+ it('分支不存在时创建', () => {
117
+ mockedCheckBranchExists.mockReturnValue(false);
118
+ createValidateBranch('feature');
119
+ expect(mockedCreateBranch).toHaveBeenCalledWith('clawt-validate-feature', undefined);
120
+ });
121
+
122
+ it('分支已存在时跳过', () => {
123
+ mockedCheckBranchExists.mockReturnValue(true);
124
+ createValidateBranch('feature');
125
+ expect(mockedCreateBranch).not.toHaveBeenCalled();
126
+ });
127
+ });
128
+
129
+ describe('deleteValidateBranch', () => {
130
+ it('分支存在时删除', () => {
131
+ mockedCheckBranchExists.mockReturnValue(true);
132
+ deleteValidateBranch('feature');
133
+ expect(mockedDeleteBranch).toHaveBeenCalledWith('clawt-validate-feature', undefined);
134
+ });
135
+
136
+ it('分支不存在时跳过', () => {
137
+ mockedCheckBranchExists.mockReturnValue(false);
138
+ deleteValidateBranch('feature');
139
+ expect(mockedDeleteBranch).not.toHaveBeenCalled();
140
+ });
141
+ });
142
+
143
+ describe('rebuildValidateBranch', () => {
144
+ it('重建时先删除再创建', async () => {
145
+ mockedGetCurrentBranch.mockReturnValue('main');
146
+ // 第一次调用 (deleteValidateBranch) 检查存在 → 删除
147
+ // 第二次调用 (createValidateBranch) 检查不存在 → 创建
148
+ mockedCheckBranchExists
149
+ .mockReturnValueOnce(true) // deleteValidateBranch 检查
150
+ .mockReturnValueOnce(false); // createValidateBranch 检查
151
+ await rebuildValidateBranch('feature');
152
+ expect(mockedDeleteBranch).toHaveBeenCalled();
153
+ expect(mockedCreateBranch).toHaveBeenCalled();
154
+ });
155
+
156
+ it('当前在验证分支上时先执行 reset+clean 再切回', async () => {
157
+ // 第一次 getCurrentBranch(rebuildValidateBranch 内部检测)返回验证分支
158
+ // 第二次 getCurrentBranch(switchBackIfOnValidateBranch 内部)返回验证分支
159
+ mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
160
+ mockedCheckBranchExists
161
+ .mockReturnValueOnce(true) // deleteValidateBranch 检查
162
+ .mockReturnValueOnce(false); // createValidateBranch 检查
163
+ await rebuildValidateBranch('feature');
164
+ expect(mockedGitResetHard).toHaveBeenCalledWith(undefined);
165
+ expect(mockedGitCleanForce).toHaveBeenCalledWith(undefined);
166
+ expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
167
+ });
168
+
169
+ it('当前不在验证分支上时不执行 reset+clean', async () => {
170
+ mockedGetCurrentBranch.mockReturnValue('main');
171
+ mockedCheckBranchExists
172
+ .mockReturnValueOnce(true)
173
+ .mockReturnValueOnce(false);
174
+ await rebuildValidateBranch('feature');
175
+ expect(mockedGitResetHard).not.toHaveBeenCalled();
176
+ expect(mockedGitCleanForce).not.toHaveBeenCalled();
177
+ });
178
+ });
179
+
180
+ describe('handleDirtyWorkingDir', () => {
181
+ it('用户选择 reset 时执行 gitResetHard 和 gitCleanForce', async () => {
182
+ mockSelectRun.mockResolvedValue('reset');
183
+ // 第一次调用(handleDirtyWorkingDir 末尾检查)返回干净
184
+ mockedIsWorkingDirClean.mockReturnValue(true);
185
+ await handleDirtyWorkingDir();
186
+ expect(mockedGitResetHard).toHaveBeenCalledWith(undefined);
187
+ expect(mockedGitCleanForce).toHaveBeenCalledWith(undefined);
188
+ });
189
+
190
+ it('用户选择 stash 时执行 gitAddAll 和 gitStashPush', async () => {
191
+ mockSelectRun.mockResolvedValue('stash');
192
+ mockedIsWorkingDirClean.mockReturnValue(true);
193
+ await handleDirtyWorkingDir();
194
+ expect(mockedGitAddAll).toHaveBeenCalledWith(undefined);
195
+ expect(mockedGitStashPush).toHaveBeenCalledWith('clawt:auto-stash', undefined);
196
+ });
197
+
198
+ it('用户选择 exit 时抛出错误', async () => {
199
+ mockSelectRun.mockResolvedValue('exit');
200
+ await expect(handleDirtyWorkingDir()).rejects.toThrow('用户选择退出');
201
+ });
202
+
203
+ it('处理后工作区仍不干净时抛出错误', async () => {
204
+ mockSelectRun.mockResolvedValue('reset');
205
+ mockedIsWorkingDirClean.mockReturnValue(false);
206
+ await expect(handleDirtyWorkingDir()).rejects.toThrow('工作区仍然不干净');
207
+ });
208
+
209
+ it('传入 cwd 参数时透传给 git 操作', async () => {
210
+ mockSelectRun.mockResolvedValue('reset');
211
+ mockedIsWorkingDirClean.mockReturnValue(true);
212
+ await handleDirtyWorkingDir('/some/path');
213
+ expect(mockedGitResetHard).toHaveBeenCalledWith('/some/path');
214
+ expect(mockedGitCleanForce).toHaveBeenCalledWith('/some/path');
215
+ });
216
+ });
217
+
218
+ describe('ensureOnMainWorkBranch', () => {
219
+ it('当前在主工作分支上时直接返回', async () => {
220
+ mockedGetCurrentBranch.mockReturnValue('main');
221
+ await ensureOnMainWorkBranch();
222
+ expect(mockedGitCheckout).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it('当前在验证分支上且工作区干净时自动切回主工作分支', async () => {
226
+ mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
227
+ mockedIsWorkingDirClean.mockReturnValue(true);
228
+ await ensureOnMainWorkBranch();
229
+ expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
230
+ // 工作区干净时不应调用 resetHard/cleanForce
231
+ expect(mockedGitResetHard).not.toHaveBeenCalled();
232
+ expect(mockedGitCleanForce).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('当前在验证分支上且工作区脏时先清理再切回', async () => {
236
+ mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
237
+ mockedIsWorkingDirClean.mockReturnValue(false);
238
+ await ensureOnMainWorkBranch();
239
+ // 验证分支上的修改直接丢弃
240
+ expect(mockedGitResetHard).toHaveBeenCalledWith(undefined);
241
+ expect(mockedGitCleanForce).toHaveBeenCalledWith(undefined);
242
+ expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
243
+ });
244
+
245
+ it('当前在其他分支上且工作区干净时直接切换到主工作分支', async () => {
246
+ mockedGetCurrentBranch.mockReturnValue('feature');
247
+ mockedIsWorkingDirClean.mockReturnValue(true);
248
+ await ensureOnMainWorkBranch();
249
+ expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
250
+ });
251
+
252
+ it('当前在其他分支上且工作区脏时先处理再切换', async () => {
253
+ mockedGetCurrentBranch.mockReturnValue('feature');
254
+ // 第一次调用(ensureOnMainWorkBranch 检测)返回脏
255
+ // 第二次调用(handleDirtyWorkingDir 末尾检查)返回干净
256
+ mockedIsWorkingDirClean.mockReturnValueOnce(false).mockReturnValueOnce(true);
257
+ mockSelectRun.mockResolvedValue('reset');
258
+ await ensureOnMainWorkBranch();
259
+ expect(mockedGitResetHard).toHaveBeenCalled();
260
+ expect(mockedGitCleanForce).toHaveBeenCalled();
261
+ expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
262
+ });
263
+
264
+ it('传入 cwd 参数时透传给 git 操作', async () => {
265
+ mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
266
+ mockedIsWorkingDirClean.mockReturnValue(false);
267
+ await ensureOnMainWorkBranch('/some/path');
268
+ expect(mockedGitResetHard).toHaveBeenCalledWith('/some/path');
269
+ expect(mockedGitCleanForce).toHaveBeenCalledWith('/some/path');
270
+ expect(mockedGitCheckout).toHaveBeenCalledWith('main', '/some/path');
271
+ });
272
+ });
@@ -48,6 +48,12 @@ vi.mock('../../../src/logger/index.js', () => ({
48
48
  logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
49
49
  }));
50
50
 
51
+ // mock validate-branch
52
+ vi.mock('../../../src/utils/validate-branch.js', () => ({
53
+ createValidateBranch: vi.fn(),
54
+ deleteValidateBranch: vi.fn(),
55
+ }));
56
+
51
57
  import { existsSync, readdirSync } from 'node:fs';
52
58
  import {
53
59
  getProjectName,