clawt 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude/agent-memory/docs-sync-updater/MEMORY.md +14 -7
  2. package/.claude/agents/docs-sync-updater.md +11 -0
  3. package/README.md +80 -284
  4. package/dist/index.js +839 -307
  5. package/dist/postinstall.js +272 -0
  6. package/docs/spec.md +84 -22
  7. package/package.json +1 -1
  8. package/src/commands/remove.ts +21 -28
  9. package/src/commands/run.ts +68 -206
  10. package/src/constants/config.ts +4 -0
  11. package/src/constants/index.ts +11 -1
  12. package/src/constants/messages/common.ts +41 -0
  13. package/src/constants/messages/config.ts +5 -0
  14. package/src/constants/messages/create.ts +5 -0
  15. package/src/constants/messages/index.ts +29 -0
  16. package/src/constants/messages/merge.ts +42 -0
  17. package/src/constants/messages/remove.ts +15 -0
  18. package/src/constants/messages/reset.ts +7 -0
  19. package/src/constants/messages/resume.ts +12 -0
  20. package/src/constants/messages/run.ts +46 -0
  21. package/src/constants/messages/status.ts +25 -0
  22. package/src/constants/messages/sync.ts +24 -0
  23. package/src/constants/messages/validate.ts +25 -0
  24. package/src/constants/progress.ts +39 -0
  25. package/src/types/command.ts +4 -0
  26. package/src/types/config.ts +2 -0
  27. package/src/types/index.ts +1 -0
  28. package/src/types/taskFile.ts +13 -0
  29. package/src/utils/formatter.ts +16 -0
  30. package/src/utils/index.ts +8 -4
  31. package/src/utils/progress-render.ts +90 -0
  32. package/src/utils/progress.ts +213 -0
  33. package/src/utils/task-executor.ts +365 -0
  34. package/src/utils/task-file.ts +87 -0
  35. package/src/utils/worktree-matcher.ts +92 -0
  36. package/src/utils/worktree.ts +27 -0
  37. package/tests/unit/commands/config.test.ts +110 -0
  38. package/tests/unit/commands/create.test.ts +115 -0
  39. package/tests/unit/commands/list.test.ts +118 -0
  40. package/tests/unit/commands/merge.test.ts +323 -0
  41. package/tests/unit/commands/remove.test.ts +240 -0
  42. package/tests/unit/commands/reset.test.ts +124 -0
  43. package/tests/unit/commands/resume.test.ts +91 -0
  44. package/tests/unit/commands/run.test.ts +456 -0
  45. package/tests/unit/commands/status.test.ts +214 -0
  46. package/tests/unit/commands/sync.test.ts +208 -0
  47. package/tests/unit/commands/validate.test.ts +382 -0
  48. package/tests/unit/constants/config.test.ts +1 -0
  49. package/tests/unit/constants/messages.test.ts +1 -1
  50. package/tests/unit/utils/config.test.ts +21 -1
  51. package/tests/unit/utils/formatter.test.ts +70 -1
  52. package/tests/unit/utils/git.test.ts +44 -0
  53. package/tests/unit/utils/progress.test.ts +255 -0
  54. package/tests/unit/utils/task-file.test.ts +236 -0
  55. package/tests/unit/utils/validate-snapshot.test.ts +25 -0
  56. package/tests/unit/utils/worktree-matcher.test.ts +81 -5
  57. package/tests/unit/utils/worktree.test.ts +26 -1
@@ -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
+ });
@@ -0,0 +1,323 @@
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
+ MERGE_NO_WORKTREES: '没有可用的 worktree',
21
+ MERGE_SELECT_BRANCH: '选择要合并的分支',
22
+ MERGE_MULTIPLE_MATCHES: (keyword: string) => `找到多个匹配 "${keyword}" 的分支`,
23
+ MERGE_NO_MATCH: (keyword: string, branches: string[]) => `未找到匹配 "${keyword}" 的分支`,
24
+ MERGE_SQUASH_PROMPT: '是否压缩提交?',
25
+ MERGE_SQUASH_COMMITTED: (branch: string) => `已压缩提交: ${branch}`,
26
+ MERGE_SQUASH_PENDING: (path: string, branch: string) => `请手动提交: ${path}`,
27
+ MERGE_VALIDATE_STATE_HINT: (branch: string) => `分支 ${branch} 存在 validate 状态`,
28
+ MAIN_WORKTREE_DIRTY: '主 worktree 有未提交的更改',
29
+ TARGET_WORKTREE_DIRTY_NO_MESSAGE: '目标 worktree 有未提交修改,请提供 -m 参数',
30
+ TARGET_WORKTREE_NO_CHANGES: '没有可合并的变更',
31
+ MERGE_CONFLICT: '合并冲突',
32
+ PULL_CONFLICT: 'pull 冲突',
33
+ PUSH_FAILED: 'push 失败',
34
+ MERGE_SUCCESS: (branch: string, message: string, autoPullPush: boolean) => `合并成功: ${branch}`,
35
+ MERGE_SUCCESS_NO_MESSAGE: (branch: string, autoPullPush: boolean) => `合并成功: ${branch}`,
36
+ WORKTREE_CLEANED: (branch: string) => `已清理: ${branch}`,
37
+ },
38
+ AUTO_SAVE_COMMIT_MESSAGE: 'clawt:auto-save',
39
+ }));
40
+
41
+ vi.mock('../../../src/utils/index.js', () => ({
42
+ validateMainWorktree: vi.fn(),
43
+ getProjectName: vi.fn(),
44
+ getGitTopLevel: vi.fn(),
45
+ getProjectWorktrees: vi.fn(),
46
+ isWorkingDirClean: vi.fn(),
47
+ gitAddAll: vi.fn(),
48
+ gitCommit: vi.fn(),
49
+ gitMerge: vi.fn(),
50
+ hasMergeConflict: vi.fn(),
51
+ gitPull: vi.fn(),
52
+ gitPush: vi.fn(),
53
+ hasLocalCommits: vi.fn(),
54
+ hasSnapshot: vi.fn(),
55
+ removeSnapshot: vi.fn(),
56
+ printSuccess: vi.fn(),
57
+ printInfo: vi.fn(),
58
+ printWarning: vi.fn(),
59
+ getConfigValue: vi.fn(),
60
+ confirmAction: vi.fn(),
61
+ cleanupWorktrees: vi.fn(),
62
+ hasCommitWithMessage: vi.fn(),
63
+ gitMergeBase: vi.fn(),
64
+ gitResetSoftTo: vi.fn(),
65
+ getCurrentBranch: vi.fn(),
66
+ resolveTargetWorktree: vi.fn(),
67
+ }));
68
+
69
+ import { registerMergeCommand } from '../../../src/commands/merge.js';
70
+ import {
71
+ getProjectName,
72
+ getGitTopLevel,
73
+ getProjectWorktrees,
74
+ isWorkingDirClean,
75
+ gitAddAll,
76
+ gitCommit,
77
+ gitMerge,
78
+ hasMergeConflict,
79
+ gitPull,
80
+ gitPush,
81
+ hasLocalCommits,
82
+ hasSnapshot,
83
+ removeSnapshot,
84
+ printSuccess,
85
+ printWarning,
86
+ getConfigValue,
87
+ confirmAction,
88
+ cleanupWorktrees,
89
+ hasCommitWithMessage,
90
+ resolveTargetWorktree,
91
+ } from '../../../src/utils/index.js';
92
+
93
+ const mockedGetProjectName = vi.mocked(getProjectName);
94
+ const mockedGetGitTopLevel = vi.mocked(getGitTopLevel);
95
+ const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
96
+ const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
97
+ const mockedGitAddAll = vi.mocked(gitAddAll);
98
+ const mockedGitCommit = vi.mocked(gitCommit);
99
+ const mockedGitMerge = vi.mocked(gitMerge);
100
+ const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
101
+ const mockedGitPull = vi.mocked(gitPull);
102
+ const mockedGitPush = vi.mocked(gitPush);
103
+ const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
104
+ const mockedHasSnapshot = vi.mocked(hasSnapshot);
105
+ const mockedRemoveSnapshot = vi.mocked(removeSnapshot);
106
+ const mockedPrintSuccess = vi.mocked(printSuccess);
107
+ const mockedPrintWarning = vi.mocked(printWarning);
108
+ const mockedGetConfigValue = vi.mocked(getConfigValue);
109
+ const mockedConfirmAction = vi.mocked(confirmAction);
110
+ const mockedCleanupWorktrees = vi.mocked(cleanupWorktrees);
111
+ const mockedHasCommitWithMessage = vi.mocked(hasCommitWithMessage);
112
+ const mockedResolveTargetWorktree = vi.mocked(resolveTargetWorktree);
113
+
114
+ const worktree = { path: '/path/feature', branch: 'feature' };
115
+
116
+ beforeEach(() => {
117
+ mockedGetGitTopLevel.mockReturnValue('/repo');
118
+ mockedGetProjectName.mockReturnValue('test-project');
119
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
120
+ mockedResolveTargetWorktree.mockResolvedValue(worktree);
121
+ mockedHasCommitWithMessage.mockReturnValue(false);
122
+ // 显式重置所有可能泄漏的 mock
123
+ mockedHasMergeConflict.mockReset();
124
+ mockedHasMergeConflict.mockReturnValue(false);
125
+ mockedHasSnapshot.mockReturnValue(false);
126
+ mockedGetConfigValue.mockReturnValue(false); // autoPullPush, autoDeleteBranch
127
+ mockedConfirmAction.mockResolvedValue(false);
128
+ mockedGitMerge.mockReset();
129
+ mockedGitPull.mockReset();
130
+ mockedGitPush.mockReset();
131
+ mockedIsWorkingDirClean.mockReset();
132
+ mockedHasLocalCommits.mockReset();
133
+ mockedPrintSuccess.mockReset();
134
+ mockedPrintWarning.mockReset();
135
+ mockedRemoveSnapshot.mockReset();
136
+ mockedCleanupWorktrees.mockReset();
137
+ });
138
+
139
+ describe('registerMergeCommand', () => {
140
+ it('注册 merge 命令', () => {
141
+ const program = new Command();
142
+ registerMergeCommand(program);
143
+ const cmd = program.commands.find((c) => c.name() === 'merge');
144
+ expect(cmd).toBeDefined();
145
+ });
146
+ });
147
+
148
+ describe('handleMerge', () => {
149
+ it('主 worktree 不干净时抛出错误', async () => {
150
+ mockedIsWorkingDirClean.mockReturnValue(false);
151
+
152
+ const program = new Command();
153
+ program.exitOverride();
154
+ registerMergeCommand(program);
155
+
156
+ await expect(
157
+ program.parseAsync(['merge', '-b', 'feature'], { from: 'user' }),
158
+ ).rejects.toThrow();
159
+ });
160
+
161
+ it('目标 worktree 有未提交修改但未提供 -m 时抛出', async () => {
162
+ mockedIsWorkingDirClean
163
+ .mockReturnValueOnce(true) // 主 worktree 干净
164
+ .mockReturnValueOnce(false); // 目标 worktree 不干净
165
+
166
+ const program = new Command();
167
+ program.exitOverride();
168
+ registerMergeCommand(program);
169
+
170
+ await expect(
171
+ program.parseAsync(['merge', '-b', 'feature'], { from: 'user' }),
172
+ ).rejects.toThrow();
173
+ });
174
+
175
+ it('目标 worktree 有未提交修改且提供 -m 时先提交再合并', async () => {
176
+ mockedIsWorkingDirClean
177
+ .mockReturnValueOnce(true) // 主 worktree 干净
178
+ .mockReturnValueOnce(false); // 目标 worktree 不干净
179
+ mockedConfirmAction.mockResolvedValue(false); // 不清理
180
+
181
+ const program = new Command();
182
+ program.exitOverride();
183
+ registerMergeCommand(program);
184
+ await program.parseAsync(['merge', '-b', 'feature', '-m', 'test commit'], { from: 'user' });
185
+
186
+ expect(mockedGitAddAll).toHaveBeenCalledWith('/path/feature');
187
+ expect(mockedGitCommit).toHaveBeenCalledWith('test commit', '/path/feature');
188
+ expect(mockedGitMerge).toHaveBeenCalledWith('feature', '/repo');
189
+ expect(mockedPrintSuccess).toHaveBeenCalled();
190
+ });
191
+
192
+ it('目标 worktree 干净但无本地提交时抛出', async () => {
193
+ mockedIsWorkingDirClean.mockReturnValue(true);
194
+ mockedHasLocalCommits.mockReturnValue(false);
195
+
196
+ const program = new Command();
197
+ program.exitOverride();
198
+ registerMergeCommand(program);
199
+
200
+ await expect(
201
+ program.parseAsync(['merge', '-b', 'feature'], { from: 'user' }),
202
+ ).rejects.toThrow();
203
+ });
204
+
205
+ it('目标 worktree 干净且有本地提交时直接合并', async () => {
206
+ mockedIsWorkingDirClean.mockReturnValue(true);
207
+ mockedHasLocalCommits.mockReturnValue(true);
208
+ mockedConfirmAction.mockResolvedValue(false);
209
+
210
+ const program = new Command();
211
+ program.exitOverride();
212
+ registerMergeCommand(program);
213
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
214
+
215
+ expect(mockedGitMerge).toHaveBeenCalledWith('feature', '/repo');
216
+ expect(mockedPrintSuccess).toHaveBeenCalled();
217
+ });
218
+
219
+ it('合并冲突时抛出错误', async () => {
220
+ mockedIsWorkingDirClean.mockReturnValue(true);
221
+ mockedHasLocalCommits.mockReturnValue(true);
222
+ mockedGitMerge.mockImplementation(() => { throw new Error('merge conflict'); });
223
+ mockedHasMergeConflict.mockReturnValue(true);
224
+
225
+ const program = new Command();
226
+ program.exitOverride();
227
+ registerMergeCommand(program);
228
+
229
+ await expect(
230
+ program.parseAsync(['merge', '-b', 'feature'], { from: 'user' }),
231
+ ).rejects.toThrow();
232
+ });
233
+
234
+ it('autoPullPush=true 时执行 pull 和 push', async () => {
235
+ mockedIsWorkingDirClean.mockReturnValue(true);
236
+ mockedHasLocalCommits.mockReturnValue(true);
237
+ mockedGetConfigValue.mockImplementation((key: string) => {
238
+ if (key === 'autoPullPush') return true;
239
+ if (key === 'autoDeleteBranch') return false;
240
+ return false;
241
+ });
242
+ mockedConfirmAction.mockResolvedValue(false);
243
+
244
+ const program = new Command();
245
+ program.exitOverride();
246
+ registerMergeCommand(program);
247
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
248
+
249
+ expect(mockedGitPull).toHaveBeenCalledWith('/repo');
250
+ expect(mockedGitPush).toHaveBeenCalledWith('/repo');
251
+ });
252
+
253
+ it('autoDeleteBranch=true 时自动清理', async () => {
254
+ mockedIsWorkingDirClean.mockReturnValue(true);
255
+ mockedHasLocalCommits.mockReturnValue(true);
256
+ mockedGetConfigValue.mockImplementation((key: string) => {
257
+ if (key === 'autoPullPush') return false;
258
+ if (key === 'autoDeleteBranch') return true;
259
+ return false;
260
+ });
261
+
262
+ const program = new Command();
263
+ program.exitOverride();
264
+ registerMergeCommand(program);
265
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
266
+
267
+ expect(mockedCleanupWorktrees).toHaveBeenCalled();
268
+ });
269
+
270
+ it('merge 成功后清理 validate 快照', async () => {
271
+ mockedIsWorkingDirClean.mockReturnValue(true);
272
+ mockedHasLocalCommits.mockReturnValue(true);
273
+ mockedHasSnapshot.mockReturnValue(true);
274
+ mockedConfirmAction.mockResolvedValue(false);
275
+
276
+ const program = new Command();
277
+ program.exitOverride();
278
+ registerMergeCommand(program);
279
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
280
+
281
+ expect(mockedRemoveSnapshot).toHaveBeenCalledWith('test-project', 'feature');
282
+ });
283
+
284
+ it('pull 冲突时输出警告', async () => {
285
+ mockedIsWorkingDirClean.mockReturnValue(true);
286
+ mockedHasLocalCommits.mockReturnValue(true);
287
+ mockedGetConfigValue.mockImplementation((key: string) => {
288
+ if (key === 'autoPullPush') return true;
289
+ return false;
290
+ });
291
+ mockedGitPull.mockImplementation(() => { throw new Error('pull failed'); });
292
+ // merge 成功后的调用顺序:
293
+ // 1. 步骤 6 二次确认 hasMergeConflict → false
294
+ // 2. pull catch 中 hasMergeConflict → true(触发 printWarning 并 return)
295
+ mockedHasMergeConflict.mockReturnValueOnce(false)
296
+ .mockReturnValueOnce(true);
297
+
298
+ const program = new Command();
299
+ program.exitOverride();
300
+ registerMergeCommand(program);
301
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
302
+
303
+ expect(mockedPrintWarning).toHaveBeenCalled();
304
+ });
305
+
306
+ it('push 失败时输出警告', async () => {
307
+ mockedIsWorkingDirClean.mockReturnValue(true);
308
+ mockedHasLocalCommits.mockReturnValue(true);
309
+ mockedGetConfigValue.mockImplementation((key: string) => {
310
+ if (key === 'autoPullPush') return true;
311
+ return false;
312
+ });
313
+ mockedGitPush.mockImplementation(() => { throw new Error('push failed'); });
314
+ mockedConfirmAction.mockResolvedValue(false);
315
+
316
+ const program = new Command();
317
+ program.exitOverride();
318
+ registerMergeCommand(program);
319
+ await program.parseAsync(['merge', '-b', 'feature'], { from: 'user' });
320
+
321
+ expect(mockedPrintWarning).toHaveBeenCalled();
322
+ });
323
+ });