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,208 @@
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
+ SYNC_NO_WORKTREES: '没有可用的 worktree',
21
+ SYNC_SELECT_BRANCH: '选择要同步的分支',
22
+ SYNC_MULTIPLE_MATCHES: (keyword: string) => `找到多个匹配 "${keyword}" 的分支`,
23
+ SYNC_NO_MATCH: (keyword: string, branches: string[]) => `未找到匹配 "${keyword}" 的分支`,
24
+ SYNC_AUTO_COMMITTED: (branch: string) => `已自动保存 ${branch} 的未提交变更`,
25
+ SYNC_MERGING: (branch: string, mainBranch: string) => `正在将 ${mainBranch} 合并到 ${branch}...`,
26
+ SYNC_CONFLICT: (path: string) => `合并冲突,请手动解决: ${path}`,
27
+ SYNC_SUCCESS: (branch: string, mainBranch: string) => `✓ 已将 ${mainBranch} 同步到 ${branch}`,
28
+ },
29
+ AUTO_SAVE_COMMIT_MESSAGE: 'clawt:auto-save',
30
+ }));
31
+
32
+ vi.mock('../../../src/utils/index.js', () => ({
33
+ validateMainWorktree: vi.fn(),
34
+ getGitTopLevel: vi.fn(),
35
+ getProjectName: vi.fn(),
36
+ getProjectWorktrees: vi.fn(),
37
+ isWorkingDirClean: vi.fn(),
38
+ gitAddAll: vi.fn(),
39
+ gitCommit: vi.fn(),
40
+ gitMerge: vi.fn(),
41
+ hasMergeConflict: vi.fn(),
42
+ getCurrentBranch: vi.fn(),
43
+ hasSnapshot: vi.fn(),
44
+ removeSnapshot: vi.fn(),
45
+ printSuccess: vi.fn(),
46
+ printInfo: vi.fn(),
47
+ printWarning: vi.fn(),
48
+ resolveTargetWorktree: vi.fn(),
49
+ }));
50
+
51
+ import { registerSyncCommand } from '../../../src/commands/sync.js';
52
+ import {
53
+ getGitTopLevel,
54
+ getProjectName,
55
+ getProjectWorktrees,
56
+ isWorkingDirClean,
57
+ gitAddAll,
58
+ gitCommit,
59
+ gitMerge,
60
+ hasMergeConflict,
61
+ getCurrentBranch,
62
+ hasSnapshot,
63
+ removeSnapshot,
64
+ printSuccess,
65
+ printWarning,
66
+ resolveTargetWorktree,
67
+ } from '../../../src/utils/index.js';
68
+
69
+ const mockedGetGitTopLevel = vi.mocked(getGitTopLevel);
70
+ const mockedGetProjectName = vi.mocked(getProjectName);
71
+ const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
72
+ const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
73
+ const mockedGitAddAll = vi.mocked(gitAddAll);
74
+ const mockedGitCommit = vi.mocked(gitCommit);
75
+ const mockedGitMerge = vi.mocked(gitMerge);
76
+ const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
77
+ const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
78
+ const mockedHasSnapshot = vi.mocked(hasSnapshot);
79
+ const mockedRemoveSnapshot = vi.mocked(removeSnapshot);
80
+ const mockedPrintSuccess = vi.mocked(printSuccess);
81
+ const mockedPrintWarning = vi.mocked(printWarning);
82
+ const mockedResolveTargetWorktree = vi.mocked(resolveTargetWorktree);
83
+
84
+ beforeEach(() => {
85
+ mockedGetGitTopLevel.mockReturnValue('/repo');
86
+ mockedGetProjectName.mockReturnValue('test-project');
87
+ mockedGetCurrentBranch.mockReturnValue('main');
88
+ mockedGetProjectWorktrees.mockReset();
89
+ mockedIsWorkingDirClean.mockReset();
90
+ mockedGitAddAll.mockReset();
91
+ mockedGitCommit.mockReset();
92
+ mockedGitMerge.mockReset();
93
+ mockedHasMergeConflict.mockReset();
94
+ mockedHasSnapshot.mockReset();
95
+ mockedRemoveSnapshot.mockReset();
96
+ mockedPrintSuccess.mockReset();
97
+ mockedPrintWarning.mockReset();
98
+ mockedResolveTargetWorktree.mockReset();
99
+ });
100
+
101
+ describe('registerSyncCommand', () => {
102
+ it('注册 sync 命令', () => {
103
+ const program = new Command();
104
+ registerSyncCommand(program);
105
+ const cmd = program.commands.find((c) => c.name() === 'sync');
106
+ expect(cmd).toBeDefined();
107
+ });
108
+ });
109
+
110
+ describe('handleSync', () => {
111
+ it('目标 worktree 干净时直接合并', async () => {
112
+ const worktree = { path: '/path/feature', branch: 'feature' };
113
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
114
+ mockedResolveTargetWorktree.mockResolvedValue(worktree);
115
+ mockedIsWorkingDirClean.mockReturnValue(true);
116
+ mockedHasSnapshot.mockReturnValue(false);
117
+
118
+ const program = new Command();
119
+ program.exitOverride();
120
+ registerSyncCommand(program);
121
+ await program.parseAsync(['sync', '-b', 'feature'], { from: 'user' });
122
+
123
+ expect(mockedGitAddAll).not.toHaveBeenCalled();
124
+ expect(mockedGitMerge).toHaveBeenCalledWith('main', '/path/feature');
125
+ expect(mockedPrintSuccess).toHaveBeenCalled();
126
+ });
127
+
128
+ it('目标 worktree 有未提交变更时自动保存后合并', async () => {
129
+ const worktree = { path: '/path/feature', branch: 'feature' };
130
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
131
+ mockedResolveTargetWorktree.mockResolvedValue(worktree);
132
+ mockedIsWorkingDirClean.mockReturnValue(false);
133
+ mockedHasSnapshot.mockReturnValue(false);
134
+
135
+ const program = new Command();
136
+ program.exitOverride();
137
+ registerSyncCommand(program);
138
+ await program.parseAsync(['sync', '-b', 'feature'], { from: 'user' });
139
+
140
+ expect(mockedGitAddAll).toHaveBeenCalledWith('/path/feature');
141
+ expect(mockedGitCommit).toHaveBeenCalledWith('clawt:auto-save', '/path/feature');
142
+ expect(mockedGitMerge).toHaveBeenCalled();
143
+ });
144
+
145
+ it('合并冲突时输出警告并返回', async () => {
146
+ const worktree = { path: '/path/feature', branch: 'feature' };
147
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
148
+ mockedResolveTargetWorktree.mockResolvedValue(worktree);
149
+ mockedIsWorkingDirClean.mockReturnValue(true);
150
+ mockedGitMerge.mockImplementation(() => { throw new Error('conflict'); });
151
+ mockedHasMergeConflict.mockReturnValue(true);
152
+
153
+ const program = new Command();
154
+ program.exitOverride();
155
+ registerSyncCommand(program);
156
+ await program.parseAsync(['sync', '-b', 'feature'], { from: 'user' });
157
+
158
+ expect(mockedPrintWarning).toHaveBeenCalled();
159
+ expect(mockedPrintSuccess).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('合并成功后清理 validate 快照', async () => {
163
+ const worktree = { path: '/path/feature', branch: 'feature' };
164
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
165
+ mockedResolveTargetWorktree.mockResolvedValue(worktree);
166
+ mockedIsWorkingDirClean.mockReturnValue(true);
167
+ mockedHasSnapshot.mockReturnValue(true);
168
+
169
+ const program = new Command();
170
+ program.exitOverride();
171
+ registerSyncCommand(program);
172
+ await program.parseAsync(['sync', '-b', 'feature'], { from: 'user' });
173
+
174
+ expect(mockedRemoveSnapshot).toHaveBeenCalledWith('test-project', 'feature');
175
+ });
176
+
177
+ it('合并成功且无快照时不调用 removeSnapshot', async () => {
178
+ const worktree = { path: '/path/feature', branch: 'feature' };
179
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
180
+ mockedResolveTargetWorktree.mockResolvedValue(worktree);
181
+ mockedIsWorkingDirClean.mockReturnValue(true);
182
+ mockedHasSnapshot.mockReturnValue(false);
183
+
184
+ const program = new Command();
185
+ program.exitOverride();
186
+ registerSyncCommand(program);
187
+ await program.parseAsync(['sync', '-b', 'feature'], { from: 'user' });
188
+
189
+ expect(mockedRemoveSnapshot).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it('合并失败(非冲突错误)时向上抛出', async () => {
193
+ const worktree = { path: '/path/feature', branch: 'feature' };
194
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
195
+ mockedResolveTargetWorktree.mockResolvedValue(worktree);
196
+ mockedIsWorkingDirClean.mockReturnValue(true);
197
+ mockedGitMerge.mockImplementation(() => { throw new Error('other error'); });
198
+ mockedHasMergeConflict.mockReturnValue(false);
199
+
200
+ const program = new Command();
201
+ program.exitOverride();
202
+ registerSyncCommand(program);
203
+
204
+ await expect(
205
+ program.parseAsync(['sync', '-b', 'feature'], { from: 'user' }),
206
+ ).rejects.toThrow();
207
+ });
208
+ });
@@ -0,0 +1,382 @@
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
+ VALIDATE_NO_WORKTREES: '没有可用的 worktree',
21
+ VALIDATE_SELECT_BRANCH: '选择要验证的分支',
22
+ VALIDATE_MULTIPLE_MATCHES: (keyword: string) => `找到多个匹配 "${keyword}" 的分支`,
23
+ VALIDATE_NO_MATCH: (keyword: string, branches: string[]) => `未找到匹配 "${keyword}" 的分支`,
24
+ TARGET_WORKTREE_CLEAN: '该 worktree 的分支上没有任何更改',
25
+ VALIDATE_SUCCESS: (branch: string) => `✓ 已验证 ${branch}`,
26
+ VALIDATE_CLEANED: (branch: string) => `✓ 已清理 ${branch} 的 validate 状态`,
27
+ VALIDATE_PATCH_APPLY_FAILED: (branch: string) => `patch 应用失败: ${branch}`,
28
+ INCREMENTAL_VALIDATE_SUCCESS: (branch: string) => `✓ 增量验证 ${branch}`,
29
+ INCREMENTAL_VALIDATE_FALLBACK: '降级为全量模式',
30
+ DESTRUCTIVE_OP_CANCELLED: '已取消操作',
31
+ },
32
+ }));
33
+
34
+ // mock enquirer
35
+ vi.mock('enquirer', () => ({
36
+ default: {
37
+ Select: vi.fn(),
38
+ },
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
+ getConfigValue: vi.fn(),
47
+ isWorkingDirClean: vi.fn(),
48
+ gitAddAll: vi.fn(),
49
+ gitCommit: vi.fn(),
50
+ gitStashPush: vi.fn(),
51
+ gitRestoreStaged: vi.fn(),
52
+ gitResetHard: vi.fn(),
53
+ gitCleanForce: vi.fn(),
54
+ gitDiffBinaryAgainstBranch: vi.fn(),
55
+ gitApplyFromStdin: vi.fn(),
56
+ gitApplyCachedFromStdin: vi.fn(),
57
+ gitResetSoft: vi.fn(),
58
+ gitWriteTree: vi.fn(),
59
+ gitReadTree: vi.fn(),
60
+ getHeadCommitHash: vi.fn(),
61
+ getCommitTreeHash: vi.fn(),
62
+ gitDiffTree: vi.fn(),
63
+ gitApplyCachedCheck: vi.fn(),
64
+ hasLocalCommits: vi.fn(),
65
+ hasSnapshot: vi.fn(),
66
+ readSnapshot: vi.fn(),
67
+ writeSnapshot: vi.fn(),
68
+ removeSnapshot: vi.fn(),
69
+ confirmDestructiveAction: vi.fn(),
70
+ printSuccess: vi.fn(),
71
+ printWarning: vi.fn(),
72
+ printInfo: vi.fn(),
73
+ resolveTargetWorktree: vi.fn(),
74
+ }));
75
+
76
+ import { registerValidateCommand } from '../../../src/commands/validate.js';
77
+ import {
78
+ getProjectName,
79
+ getGitTopLevel,
80
+ getProjectWorktrees,
81
+ getConfigValue,
82
+ isWorkingDirClean,
83
+ gitAddAll,
84
+ gitCommit,
85
+ gitDiffBinaryAgainstBranch,
86
+ gitApplyFromStdin,
87
+ gitResetSoft,
88
+ gitWriteTree,
89
+ gitRestoreStaged,
90
+ getHeadCommitHash,
91
+ gitReadTree,
92
+ hasLocalCommits,
93
+ hasSnapshot,
94
+ readSnapshot,
95
+ writeSnapshot,
96
+ removeSnapshot,
97
+ confirmDestructiveAction,
98
+ printSuccess,
99
+ printInfo,
100
+ resolveTargetWorktree,
101
+ gitResetHard,
102
+ gitCleanForce,
103
+ getCommitTreeHash,
104
+ gitDiffTree,
105
+ gitApplyCachedCheck,
106
+ gitApplyCachedFromStdin,
107
+ printWarning,
108
+ } from '../../../src/utils/index.js';
109
+
110
+ const mockedGetProjectName = vi.mocked(getProjectName);
111
+ const mockedGetGitTopLevel = vi.mocked(getGitTopLevel);
112
+ const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
113
+ const mockedGetConfigValue = vi.mocked(getConfigValue);
114
+ const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
115
+ const mockedGitAddAll = vi.mocked(gitAddAll);
116
+ const mockedGitCommit = vi.mocked(gitCommit);
117
+ const mockedGitDiffBinaryAgainstBranch = vi.mocked(gitDiffBinaryAgainstBranch);
118
+ const mockedGitApplyFromStdin = vi.mocked(gitApplyFromStdin);
119
+ const mockedGitResetSoft = vi.mocked(gitResetSoft);
120
+ const mockedGitWriteTree = vi.mocked(gitWriteTree);
121
+ const mockedGitRestoreStaged = vi.mocked(gitRestoreStaged);
122
+ const mockedGetHeadCommitHash = vi.mocked(getHeadCommitHash);
123
+ const mockedGitReadTree = vi.mocked(gitReadTree);
124
+ const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
125
+ const mockedHasSnapshot = vi.mocked(hasSnapshot);
126
+ const mockedReadSnapshot = vi.mocked(readSnapshot);
127
+ const mockedWriteSnapshot = vi.mocked(writeSnapshot);
128
+ const mockedRemoveSnapshot = vi.mocked(removeSnapshot);
129
+ const mockedConfirmDestructiveAction = vi.mocked(confirmDestructiveAction);
130
+ const mockedPrintSuccess = vi.mocked(printSuccess);
131
+ const mockedPrintInfo = vi.mocked(printInfo);
132
+ const mockedResolveTargetWorktree = vi.mocked(resolveTargetWorktree);
133
+ const mockedGitResetHard = vi.mocked(gitResetHard);
134
+ const mockedGitCleanForce = vi.mocked(gitCleanForce);
135
+ const mockedGetCommitTreeHash = vi.mocked(getCommitTreeHash);
136
+ const mockedGitDiffTree = vi.mocked(gitDiffTree);
137
+ const mockedGitApplyCachedCheck = vi.mocked(gitApplyCachedCheck);
138
+ const mockedGitApplyCachedFromStdin = vi.mocked(gitApplyCachedFromStdin);
139
+ const mockedPrintWarning = vi.mocked(printWarning);
140
+
141
+ const worktree = { path: '/path/feature', branch: 'feature' };
142
+
143
+ beforeEach(() => {
144
+ mockedGetGitTopLevel.mockReturnValue('/repo');
145
+ mockedGetProjectName.mockReturnValue('test-project');
146
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
147
+ mockedResolveTargetWorktree.mockResolvedValue(worktree);
148
+ mockedGetConfigValue.mockReturnValue(false);
149
+ mockedHasSnapshot.mockReturnValue(false);
150
+ mockedIsWorkingDirClean.mockReset();
151
+ mockedGitAddAll.mockReset();
152
+ mockedGitCommit.mockReset();
153
+ mockedGitDiffBinaryAgainstBranch.mockReset();
154
+ mockedGitApplyFromStdin.mockReset();
155
+ mockedGitResetSoft.mockReset();
156
+ mockedGitWriteTree.mockReset();
157
+ mockedGitRestoreStaged.mockReset();
158
+ mockedGetHeadCommitHash.mockReset();
159
+ mockedGitReadTree.mockReset();
160
+ mockedHasLocalCommits.mockReset();
161
+ mockedReadSnapshot.mockReset();
162
+ mockedWriteSnapshot.mockReset();
163
+ mockedRemoveSnapshot.mockReset();
164
+ mockedConfirmDestructiveAction.mockReset();
165
+ mockedPrintSuccess.mockReset();
166
+ mockedPrintInfo.mockReset();
167
+ mockedGitResetHard.mockReset();
168
+ mockedGitCleanForce.mockReset();
169
+ mockedGetCommitTreeHash.mockReset();
170
+ mockedGitDiffTree.mockReset();
171
+ mockedGitApplyCachedCheck.mockReset();
172
+ mockedGitApplyCachedFromStdin.mockReset();
173
+ mockedPrintWarning.mockReset();
174
+ });
175
+
176
+ describe('registerValidateCommand', () => {
177
+ it('注册 validate 命令', () => {
178
+ const program = new Command();
179
+ registerValidateCommand(program);
180
+ const cmd = program.commands.find((c) => c.name() === 'validate');
181
+ expect(cmd).toBeDefined();
182
+ });
183
+ });
184
+
185
+ describe('handleValidate', () => {
186
+ it('目标分支无变更时提示并返回', async () => {
187
+ mockedIsWorkingDirClean.mockReturnValue(true);
188
+ mockedHasLocalCommits.mockReturnValue(false);
189
+
190
+ const program = new Command();
191
+ program.exitOverride();
192
+ registerValidateCommand(program);
193
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
194
+
195
+ expect(mockedPrintInfo).toHaveBeenCalled();
196
+ expect(mockedGitDiffBinaryAgainstBranch).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it('首次 validate:有已提交 commit 且主 worktree 干净', async () => {
200
+ // 目标 worktree 干净,但有已提交 commit
201
+ mockedIsWorkingDirClean.mockReturnValue(true); // 所有调用都返回 true
202
+ mockedHasLocalCommits.mockReturnValue(true);
203
+ mockedHasSnapshot.mockReturnValue(false);
204
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
205
+ mockedGitWriteTree.mockReturnValue('treehash123');
206
+ mockedGetHeadCommitHash.mockReturnValue('headhash456');
207
+
208
+ const program = new Command();
209
+ program.exitOverride();
210
+ registerValidateCommand(program);
211
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
212
+
213
+ expect(mockedGitDiffBinaryAgainstBranch).toHaveBeenCalledWith('feature', '/repo');
214
+ expect(mockedGitApplyFromStdin).toHaveBeenCalled();
215
+ expect(mockedWriteSnapshot).toHaveBeenCalledWith('test-project', 'feature', 'treehash123', 'headhash456');
216
+ expect(mockedPrintSuccess).toHaveBeenCalled();
217
+ });
218
+
219
+ it('首次 validate:有未提交修改时做临时 commit 后撤销', async () => {
220
+ // 主 worktree 干净,目标 worktree 有未提交修改
221
+ mockedIsWorkingDirClean
222
+ .mockReturnValueOnce(true) // 主 worktree 调用(collectStatus 或 handleValidate 首次检查目标)
223
+ .mockReturnValueOnce(false); // 目标 worktree 检查
224
+ mockedHasLocalCommits.mockReturnValue(false); // 无已提交 commit,但有未提交修改
225
+ mockedHasSnapshot.mockReturnValue(false);
226
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
227
+ mockedGitWriteTree.mockReturnValue('treehash');
228
+ mockedGetHeadCommitHash.mockReturnValue('headhash');
229
+
230
+ // 注意:hasUncommitted 来自 !isWorkingDirClean(targetWorktreePath)
231
+ // 这里的 mock 链:
232
+ // 第 1 次调用 isWorkingDirClean: 检查 targetWorktreePath => false(有未提交修改)
233
+ // 但是代码中先检查 isWorkingDirClean(mainWorktreePath)
234
+ // 需要更精确的 mock
235
+ mockedIsWorkingDirClean.mockReset();
236
+ mockedIsWorkingDirClean.mockImplementation((cwd?: string) => {
237
+ if (cwd === '/path/feature') return false; // 目标 worktree 不干净
238
+ return true; // 主 worktree 干净
239
+ });
240
+ // 因为 hasUncommitted 依赖 !isWorkingDirClean(targetWorktreePath),
241
+ // 且 !hasUncommitted && !hasCommitted 需要检查 hasLocalCommits
242
+ mockedHasLocalCommits.mockReturnValue(true); // 让它不走"无变更"路径
243
+
244
+ const program = new Command();
245
+ program.exitOverride();
246
+ registerValidateCommand(program);
247
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
248
+
249
+ // 临时 commit
250
+ expect(mockedGitAddAll).toHaveBeenCalledWith('/path/feature');
251
+ expect(mockedGitCommit).toHaveBeenCalledWith('clawt:temp-commit-for-validate', '/path/feature');
252
+ // 撤销临时 commit
253
+ expect(mockedGitResetSoft).toHaveBeenCalledWith(1, '/path/feature');
254
+ expect(mockedGitRestoreStaged).toHaveBeenCalledWith('/path/feature');
255
+ });
256
+ });
257
+
258
+ describe('handleValidateClean', () => {
259
+ it('确认后清理 validate 状态', async () => {
260
+ mockedGetConfigValue.mockReturnValue(true); // confirmDestructiveOps
261
+ mockedConfirmDestructiveAction.mockResolvedValue(true);
262
+ mockedIsWorkingDirClean.mockReturnValue(false);
263
+
264
+ const program = new Command();
265
+ program.exitOverride();
266
+ registerValidateCommand(program);
267
+ await program.parseAsync(['validate', '--clean', '-b', 'feature'], { from: 'user' });
268
+
269
+ expect(mockedGitResetHard).toHaveBeenCalledWith('/repo');
270
+ expect(mockedGitCleanForce).toHaveBeenCalledWith('/repo');
271
+ expect(mockedRemoveSnapshot).toHaveBeenCalledWith('test-project', 'feature');
272
+ expect(mockedPrintSuccess).toHaveBeenCalled();
273
+ });
274
+
275
+ it('用户拒绝时取消操作', async () => {
276
+ mockedGetConfigValue.mockReturnValue(true);
277
+ mockedConfirmDestructiveAction.mockResolvedValue(false);
278
+
279
+ const program = new Command();
280
+ program.exitOverride();
281
+ registerValidateCommand(program);
282
+ await program.parseAsync(['validate', '--clean', '-b', 'feature'], { from: 'user' });
283
+
284
+ expect(mockedGitResetHard).not.toHaveBeenCalled();
285
+ expect(mockedRemoveSnapshot).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it('confirmDestructiveOps=false 时跳过确认', async () => {
289
+ mockedGetConfigValue.mockReturnValue(false);
290
+ mockedIsWorkingDirClean.mockReturnValue(true);
291
+
292
+ const program = new Command();
293
+ program.exitOverride();
294
+ registerValidateCommand(program);
295
+ await program.parseAsync(['validate', '--clean', '-b', 'feature'], { from: 'user' });
296
+
297
+ expect(mockedConfirmDestructiveAction).not.toHaveBeenCalled();
298
+ expect(mockedRemoveSnapshot).toHaveBeenCalledWith('test-project', 'feature');
299
+ });
300
+ });
301
+
302
+ describe('增量 validate', () => {
303
+ it('HEAD 未变化时使用 read-tree 旧快照', async () => {
304
+ mockedIsWorkingDirClean.mockReturnValue(true);
305
+ mockedHasLocalCommits.mockReturnValue(true);
306
+ mockedHasSnapshot.mockReturnValue(true);
307
+ mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash' });
308
+ mockedGetHeadCommitHash.mockReturnValue('headhash'); // HEAD 未变化
309
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
310
+ mockedGitWriteTree.mockReturnValue('newtree');
311
+
312
+ const program = new Command();
313
+ program.exitOverride();
314
+ registerValidateCommand(program);
315
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
316
+
317
+ expect(mockedGitReadTree).toHaveBeenCalledWith('oldtree', '/repo');
318
+ expect(mockedPrintSuccess).toHaveBeenCalled();
319
+ });
320
+
321
+ it('HEAD 变化时通过 patch 重放旧变更到暂存区', async () => {
322
+ mockedIsWorkingDirClean.mockReturnValue(true);
323
+ mockedHasLocalCommits.mockReturnValue(true);
324
+ mockedHasSnapshot.mockReturnValue(true);
325
+ mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'oldhead' });
326
+ mockedGetHeadCommitHash.mockReturnValue('newhead'); // HEAD 已变化
327
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
328
+ mockedGitWriteTree.mockReturnValue('newtree');
329
+ mockedGetCommitTreeHash.mockReturnValue('oldheadtree');
330
+ mockedGitDiffTree.mockReturnValue(Buffer.from('old change patch'));
331
+ mockedGitApplyCachedCheck.mockReturnValue(true);
332
+
333
+ const program = new Command();
334
+ program.exitOverride();
335
+ registerValidateCommand(program);
336
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
337
+
338
+ expect(mockedGetCommitTreeHash).toHaveBeenCalledWith('oldhead', '/repo');
339
+ expect(mockedGitDiffTree).toHaveBeenCalledWith('oldheadtree', 'oldtree', '/repo');
340
+ expect(mockedGitApplyCachedFromStdin).toHaveBeenCalled();
341
+ });
342
+
343
+ it('旧变更 patch 有冲突时降级为全量模式', async () => {
344
+ mockedIsWorkingDirClean.mockReturnValue(true);
345
+ mockedHasLocalCommits.mockReturnValue(true);
346
+ mockedHasSnapshot.mockReturnValue(true);
347
+ mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'oldhead' });
348
+ mockedGetHeadCommitHash.mockReturnValue('newhead');
349
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
350
+ mockedGitWriteTree.mockReturnValue('newtree');
351
+ mockedGetCommitTreeHash.mockReturnValue('oldheadtree');
352
+ mockedGitDiffTree.mockReturnValue(Buffer.from('conflicting patch'));
353
+ mockedGitApplyCachedCheck.mockReturnValue(false); // 有冲突
354
+
355
+ const program = new Command();
356
+ program.exitOverride();
357
+ registerValidateCommand(program);
358
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
359
+
360
+ expect(mockedPrintWarning).toHaveBeenCalled();
361
+ expect(mockedGitApplyCachedFromStdin).not.toHaveBeenCalled();
362
+ });
363
+
364
+ it('read-tree 失败时降级为全量模式', async () => {
365
+ mockedIsWorkingDirClean.mockReturnValue(true);
366
+ mockedHasLocalCommits.mockReturnValue(true);
367
+ mockedHasSnapshot.mockReturnValue(true);
368
+ mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash' });
369
+ mockedGetHeadCommitHash.mockReturnValue('headhash');
370
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
371
+ mockedGitWriteTree.mockReturnValue('newtree');
372
+ mockedGitReadTree.mockImplementation(() => { throw new Error('gc reclaimed'); });
373
+
374
+ const program = new Command();
375
+ program.exitOverride();
376
+ registerValidateCommand(program);
377
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
378
+
379
+ expect(mockedPrintWarning).toHaveBeenCalled();
380
+ expect(mockedPrintSuccess).toHaveBeenCalled();
381
+ });
382
+ });
@@ -30,6 +30,7 @@ describe('DEFAULT_CONFIG', () => {
30
30
  expect(DEFAULT_CONFIG.claudeCodeCommand).toBe('claude');
31
31
  expect(DEFAULT_CONFIG.autoPullPush).toBe(false);
32
32
  expect(DEFAULT_CONFIG.confirmDestructiveOps).toBe(true);
33
+ expect(DEFAULT_CONFIG.maxConcurrency).toBe(0);
33
34
  });
34
35
  });
35
36
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { MESSAGES } from '../../../src/constants/messages.js';
2
+ import { MESSAGES } from '../../../src/constants/messages/index.js';
3
3
 
4
4
  describe('MESSAGES', () => {
5
5
  describe('纯字符串消息', () => {
@@ -18,12 +18,14 @@ vi.mock('../../../src/utils/fs.js', () => ({
18
18
  }));
19
19
 
20
20
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
- import { loadConfig, getConfigValue } from '../../../src/utils/config.js';
21
+ import { loadConfig, getConfigValue, writeDefaultConfig, ensureClawtDirs } from '../../../src/utils/config.js';
22
22
  import { DEFAULT_CONFIG } from '../../../src/constants/index.js';
23
+ import { ensureDir } from '../../../src/utils/fs.js';
23
24
 
24
25
  const mockedExistsSync = vi.mocked(existsSync);
25
26
  const mockedReadFileSync = vi.mocked(readFileSync);
26
27
  const mockedWriteFileSync = vi.mocked(writeFileSync);
28
+ const mockedEnsureDir = vi.mocked(ensureDir);
27
29
 
28
30
  describe('loadConfig', () => {
29
31
  it('配置文件不存在时返回默认配置', () => {
@@ -63,3 +65,21 @@ describe('getConfigValue', () => {
63
65
  expect(getConfigValue('confirmDestructiveOps')).toBe(true);
64
66
  });
65
67
  });
68
+
69
+ describe('writeDefaultConfig', () => {
70
+ it('将默认配置写入配置文件', () => {
71
+ writeDefaultConfig();
72
+ expect(mockedWriteFileSync).toHaveBeenCalledWith(
73
+ expect.any(String),
74
+ JSON.stringify(DEFAULT_CONFIG, null, 2),
75
+ 'utf-8',
76
+ );
77
+ });
78
+ });
79
+
80
+ describe('ensureClawtDirs', () => {
81
+ it('确保三个全局目录存在', () => {
82
+ ensureClawtDirs();
83
+ expect(mockedEnsureDir).toHaveBeenCalledTimes(3);
84
+ });
85
+ });