clawt 2.10.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude/agent-memory/docs-sync-updater/MEMORY.md +9 -6
  2. package/.claude/agents/docs-sync-updater.md +11 -0
  3. package/README.md +63 -290
  4. package/dist/index.js +203 -104
  5. package/dist/postinstall.js +242 -0
  6. package/docs/spec.md +27 -14
  7. package/package.json +1 -1
  8. package/src/commands/remove.ts +21 -28
  9. package/src/constants/index.ts +1 -1
  10. package/src/constants/messages/common.ts +41 -0
  11. package/src/constants/messages/config.ts +5 -0
  12. package/src/constants/messages/create.ts +5 -0
  13. package/src/constants/messages/index.ts +29 -0
  14. package/src/constants/messages/merge.ts +42 -0
  15. package/src/constants/messages/remove.ts +15 -0
  16. package/src/constants/messages/reset.ts +7 -0
  17. package/src/constants/messages/resume.ts +12 -0
  18. package/src/constants/messages/run.ts +16 -0
  19. package/src/constants/messages/status.ts +25 -0
  20. package/src/constants/messages/sync.ts +24 -0
  21. package/src/constants/messages/validate.ts +25 -0
  22. package/src/utils/index.ts +2 -2
  23. package/src/utils/worktree-matcher.ts +92 -0
  24. package/tests/unit/commands/config.test.ts +110 -0
  25. package/tests/unit/commands/create.test.ts +115 -0
  26. package/tests/unit/commands/list.test.ts +118 -0
  27. package/tests/unit/commands/merge.test.ts +323 -0
  28. package/tests/unit/commands/remove.test.ts +240 -0
  29. package/tests/unit/commands/reset.test.ts +124 -0
  30. package/tests/unit/commands/resume.test.ts +91 -0
  31. package/tests/unit/commands/run.test.ts +207 -0
  32. package/tests/unit/commands/status.test.ts +214 -0
  33. package/tests/unit/commands/sync.test.ts +208 -0
  34. package/tests/unit/commands/validate.test.ts +382 -0
  35. package/tests/unit/constants/messages.test.ts +1 -1
  36. package/tests/unit/utils/config.test.ts +21 -1
  37. package/tests/unit/utils/formatter.test.ts +44 -1
  38. package/tests/unit/utils/git.test.ts +44 -0
  39. package/tests/unit/utils/validate-snapshot.test.ts +25 -0
  40. package/tests/unit/utils/worktree-matcher.test.ts +81 -5
@@ -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
+ });
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo } from '../../../src/utils/formatter.js';
2
+ import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle } from '../../../src/utils/formatter.js';
3
3
  import { createWorktreeStatus } from '../../helpers/fixtures.js';
4
4
 
5
5
  describe('formatWorktreeStatus', () => {
@@ -38,6 +38,49 @@ describe('formatWorktreeStatus', () => {
38
38
  });
39
39
  });
40
40
 
41
+ describe('isWorktreeIdle', () => {
42
+ it('全部为零且无未提交修改时返回 true', () => {
43
+ const status = createWorktreeStatus({ commitCount: 0, insertions: 0, deletions: 0, hasDirtyFiles: false });
44
+ expect(isWorktreeIdle(status)).toBe(true);
45
+ });
46
+
47
+ it('有提交时返回 false', () => {
48
+ const status = createWorktreeStatus({ commitCount: 1, insertions: 0, deletions: 0, hasDirtyFiles: false });
49
+ expect(isWorktreeIdle(status)).toBe(false);
50
+ });
51
+
52
+ it('有 insertions 时返回 false', () => {
53
+ const status = createWorktreeStatus({ commitCount: 0, insertions: 1, deletions: 0, hasDirtyFiles: false });
54
+ expect(isWorktreeIdle(status)).toBe(false);
55
+ });
56
+
57
+ it('有 deletions 时返回 false', () => {
58
+ const status = createWorktreeStatus({ commitCount: 0, insertions: 0, deletions: 1, hasDirtyFiles: false });
59
+ expect(isWorktreeIdle(status)).toBe(false);
60
+ });
61
+
62
+ it('有未提交修改时返回 false', () => {
63
+ const status = createWorktreeStatus({ commitCount: 0, insertions: 0, deletions: 0, hasDirtyFiles: true });
64
+ expect(isWorktreeIdle(status)).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe('printSeparator', () => {
69
+ it('调用 console.log 输出分隔线', () => {
70
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
71
+ printSeparator();
72
+ expect(spy).toHaveBeenCalledTimes(1);
73
+ });
74
+ });
75
+
76
+ describe('printDoubleSeparator', () => {
77
+ it('调用 console.log 输出粗分隔线', () => {
78
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
79
+ printDoubleSeparator();
80
+ expect(spy).toHaveBeenCalledTimes(1);
81
+ });
82
+ });
83
+
41
84
  describe('print 函数', () => {
42
85
  it('printSuccess 调用 console.log', () => {
43
86
  const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -32,6 +32,8 @@ import {
32
32
  gitCommit,
33
33
  gitMerge,
34
34
  hasMergeConflict,
35
+ gitPull,
36
+ gitPush,
35
37
  gitResetHard,
36
38
  gitCleanForce,
37
39
  gitStashPush,
@@ -44,6 +46,7 @@ import {
44
46
  gitWorktreePrune,
45
47
  hasLocalCommits,
46
48
  getCommitCountAhead,
49
+ getCommitCountBehind,
47
50
  getDiffStat,
48
51
  gitDiffCachedBinary,
49
52
  gitApplyCachedFromStdin,
@@ -530,3 +533,44 @@ describe('gitApplyCachedCheck', () => {
530
533
  expect(gitApplyCachedCheck(patch, '/repo')).toBe(false);
531
534
  });
532
535
  });
536
+
537
+ describe('gitPull', () => {
538
+ it('执行 git pull', () => {
539
+ gitPull('/repo');
540
+ expect(mockedExecCommand).toHaveBeenCalledWith('git pull', { cwd: '/repo' });
541
+ });
542
+
543
+ it('不传 cwd 时 cwd 为 undefined', () => {
544
+ gitPull();
545
+ expect(mockedExecCommand).toHaveBeenCalledWith('git pull', { cwd: undefined });
546
+ });
547
+ });
548
+
549
+ describe('gitPush', () => {
550
+ it('执行 git push', () => {
551
+ gitPush('/repo');
552
+ expect(mockedExecCommand).toHaveBeenCalledWith('git push', { cwd: '/repo' });
553
+ });
554
+
555
+ it('不传 cwd 时 cwd 为 undefined', () => {
556
+ gitPush();
557
+ expect(mockedExecCommand).toHaveBeenCalledWith('git push', { cwd: undefined });
558
+ });
559
+ });
560
+
561
+ describe('getCommitCountBehind', () => {
562
+ it('返回正确的落后提交数', () => {
563
+ mockedExecCommand.mockReturnValue('3');
564
+ expect(getCommitCountBehind('feature')).toBe(3);
565
+ });
566
+
567
+ it('返回 0 当输出无法解析', () => {
568
+ mockedExecCommand.mockReturnValue('');
569
+ expect(getCommitCountBehind('feature')).toBe(0);
570
+ });
571
+
572
+ it('命令失败时返回 0', () => {
573
+ mockedExecCommand.mockImplementation(() => { throw new Error('fail'); });
574
+ expect(getCommitCountBehind('feature')).toBe(0);
575
+ });
576
+ });
@@ -39,6 +39,7 @@ import {
39
39
  writeSnapshot,
40
40
  removeSnapshot,
41
41
  removeProjectSnapshots,
42
+ getProjectSnapshotBranches,
42
43
  } from '../../../src/utils/validate-snapshot.js';
43
44
 
44
45
  const mockedExistsSync = vi.mocked(existsSync);
@@ -149,3 +150,27 @@ describe('removeProjectSnapshots', () => {
149
150
  expect(mockedUnlinkSync).not.toHaveBeenCalled();
150
151
  });
151
152
  });
153
+
154
+ describe('getProjectSnapshotBranches', () => {
155
+ it('返回所有存在快照的分支名', () => {
156
+ mockedExistsSync.mockReturnValue(true);
157
+ // @ts-expect-error readdirSync 返回类型简化
158
+ mockedReaddirSync.mockReturnValue(['feat-a.tree', 'feat-a.head', 'feat-b.tree', 'feat-b.head']);
159
+ const result = getProjectSnapshotBranches('proj');
160
+ expect(result).toEqual(['feat-a', 'feat-b']);
161
+ });
162
+
163
+ it('没有 .tree 文件时返回空数组', () => {
164
+ mockedExistsSync.mockReturnValue(true);
165
+ // @ts-expect-error readdirSync 返回类型简化
166
+ mockedReaddirSync.mockReturnValue(['feat-a.head']);
167
+ const result = getProjectSnapshotBranches('proj');
168
+ expect(result).toEqual([]);
169
+ });
170
+
171
+ it('项目目录不存在时返回空数组', () => {
172
+ mockedExistsSync.mockReturnValue(false);
173
+ const result = getProjectSnapshotBranches('proj');
174
+ expect(result).toEqual([]);
175
+ });
176
+ });
@@ -1,15 +1,18 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { findExactMatch, findFuzzyMatches, resolveTargetWorktree } from '../../../src/utils/worktree-matcher.js';
2
+ import { findExactMatch, findFuzzyMatches, resolveTargetWorktree, resolveTargetWorktrees } from '../../../src/utils/worktree-matcher.js';
3
3
  import { createWorktreeInfo, createWorktreeList } from '../../helpers/fixtures.js';
4
4
  import { ClawtError } from '../../../src/errors/index.js';
5
- import type { WorktreeResolveMessages } from '../../../src/utils/worktree-matcher.js';
5
+ import type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from '../../../src/utils/worktree-matcher.js';
6
6
 
7
7
  // mock enquirer
8
8
  vi.mock('enquirer', () => ({
9
9
  default: {
10
- Select: vi.fn().mockImplementation(({ choices }: { choices: Array<{ name: string }> }) => ({
11
- run: vi.fn().mockResolvedValue(choices[0].name),
12
- })),
10
+ Select: vi.fn().mockImplementation(function({ choices }: { choices: Array<{ name: string }> }) {
11
+ this.run = vi.fn().mockResolvedValue(choices[0].name);
12
+ }),
13
+ MultiSelect: vi.fn().mockImplementation(function({ choices }: { choices: Array<{ name: string }> }) {
14
+ this.run = vi.fn().mockResolvedValue(choices.map((c: { name: string }) => c.name));
15
+ }),
13
16
  },
14
17
  }));
15
18
 
@@ -114,3 +117,76 @@ describe('resolveTargetWorktree', () => {
114
117
  await expect(resolveTargetWorktree(worktrees, testMessages, 'xyz')).rejects.toThrow(ClawtError);
115
118
  });
116
119
  });
120
+
121
+ /** 多选场景测试用消息配置 */
122
+ const testMultiMessages: WorktreeMultiResolveMessages = {
123
+ noWorktrees: '无可用 worktree',
124
+ selectBranch: '请选择要移除的分支',
125
+ multipleMatches: (keyword: string) => `"${keyword}" 匹配到多个分支`,
126
+ noMatch: (keyword: string, branches: string[]) =>
127
+ `未找到匹配 "${keyword}",可用:${branches.join(', ')}`,
128
+ };
129
+
130
+ describe('resolveTargetWorktrees', () => {
131
+ it('空列表抛出 ClawtError', async () => {
132
+ await expect(resolveTargetWorktrees([], testMultiMessages, 'any')).rejects.toThrow(ClawtError);
133
+ await expect(resolveTargetWorktrees([], testMultiMessages, 'any')).rejects.toThrow('无可用 worktree');
134
+ });
135
+
136
+ it('单个 worktree 且不传分支名时直接返回数组', async () => {
137
+ const worktrees = [createWorktreeInfo({ branch: 'only-one' })];
138
+ const result = await resolveTargetWorktrees(worktrees, testMultiMessages);
139
+ expect(result).toHaveLength(1);
140
+ expect(result[0].branch).toBe('only-one');
141
+ });
142
+
143
+ it('精确匹配优先,返回单元素数组', async () => {
144
+ const worktrees = [
145
+ createWorktreeInfo({ branch: 'feat' }),
146
+ createWorktreeInfo({ branch: 'feature' }),
147
+ ];
148
+ const result = await resolveTargetWorktrees(worktrees, testMultiMessages, 'feat');
149
+ expect(result).toHaveLength(1);
150
+ expect(result[0].branch).toBe('feat');
151
+ });
152
+
153
+ it('模糊匹配唯一结果直接返回单元素数组', async () => {
154
+ const worktrees = [
155
+ createWorktreeInfo({ branch: 'feature-login' }),
156
+ createWorktreeInfo({ branch: 'bugfix-auth' }),
157
+ ];
158
+ const result = await resolveTargetWorktrees(worktrees, testMultiMessages, 'login');
159
+ expect(result).toHaveLength(1);
160
+ expect(result[0].branch).toBe('feature-login');
161
+ });
162
+
163
+ it('模糊匹配多个结果时调用多选交互', async () => {
164
+ const worktrees = [
165
+ createWorktreeInfo({ branch: 'feature-login' }),
166
+ createWorktreeInfo({ branch: 'feature-logout' }),
167
+ createWorktreeInfo({ branch: 'bugfix-auth' }),
168
+ ];
169
+ const result = await resolveTargetWorktrees(worktrees, testMultiMessages, 'log');
170
+ // mock 的 MultiSelect 返回所有 choices,因此应返回匹配到的 2 个
171
+ expect(result).toHaveLength(2);
172
+ expect(result.map((w) => w.branch)).toEqual(['feature-login', 'feature-logout']);
173
+ });
174
+
175
+ it('不传分支名时多个 worktree 调用多选交互', async () => {
176
+ const worktrees = [
177
+ createWorktreeInfo({ branch: 'feature-a' }),
178
+ createWorktreeInfo({ branch: 'feature-b' }),
179
+ ];
180
+ const result = await resolveTargetWorktrees(worktrees, testMultiMessages);
181
+ // mock 的 MultiSelect 返回所有 choices
182
+ expect(result).toHaveLength(2);
183
+ });
184
+
185
+ it('无匹配抛出 ClawtError 并包含可用分支', async () => {
186
+ const worktrees = [
187
+ createWorktreeInfo({ branch: 'feature-a' }),
188
+ createWorktreeInfo({ branch: 'feature-b' }),
189
+ ];
190
+ await expect(resolveTargetWorktrees(worktrees, testMultiMessages, 'xyz')).rejects.toThrow(ClawtError);
191
+ });
192
+ });