clawt 2.9.1 → 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.
- package/.claude/agent-memory/docs-sync-updater/MEMORY.md +14 -10
- package/.claude/agents/docs-sync-updater.md +11 -0
- package/README.md +63 -268
- package/dist/index.js +420 -103
- package/dist/postinstall.js +242 -0
- package/docs/spec.md +162 -14
- package/package.json +1 -1
- package/src/commands/remove.ts +21 -28
- package/src/commands/status.ts +327 -0
- package/src/constants/index.ts +1 -1
- package/src/constants/messages/common.ts +41 -0
- package/src/constants/messages/config.ts +5 -0
- package/src/constants/messages/create.ts +5 -0
- package/src/constants/messages/index.ts +29 -0
- package/src/constants/messages/merge.ts +42 -0
- package/src/constants/messages/remove.ts +15 -0
- package/src/constants/messages/reset.ts +7 -0
- package/src/constants/messages/resume.ts +12 -0
- package/src/constants/messages/run.ts +16 -0
- package/src/constants/messages/status.ts +25 -0
- package/src/constants/messages/sync.ts +24 -0
- package/src/constants/messages/validate.ts +25 -0
- package/src/constants/messages.ts +22 -0
- package/src/index.ts +2 -0
- package/src/types/command.ts +6 -0
- package/src/types/index.ts +2 -1
- package/src/types/status.ts +49 -0
- package/src/utils/git.ts +16 -0
- package/src/utils/index.ts +4 -3
- package/src/utils/validate-snapshot.ts +17 -0
- package/src/utils/worktree-matcher.ts +92 -0
- package/tests/unit/commands/config.test.ts +110 -0
- package/tests/unit/commands/create.test.ts +115 -0
- package/tests/unit/commands/list.test.ts +118 -0
- package/tests/unit/commands/merge.test.ts +323 -0
- package/tests/unit/commands/remove.test.ts +240 -0
- package/tests/unit/commands/reset.test.ts +124 -0
- package/tests/unit/commands/resume.test.ts +91 -0
- package/tests/unit/commands/run.test.ts +207 -0
- package/tests/unit/commands/status.test.ts +214 -0
- package/tests/unit/commands/sync.test.ts +208 -0
- package/tests/unit/commands/validate.test.ts +382 -0
- package/tests/unit/constants/messages.test.ts +1 -1
- package/tests/unit/utils/config.test.ts +21 -1
- package/tests/unit/utils/formatter.test.ts +44 -1
- package/tests/unit/utils/git.test.ts +44 -0
- package/tests/unit/utils/validate-snapshot.test.ts +25 -0
- package/tests/unit/utils/worktree-matcher.test.ts +81 -5
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
NO_WORKTREES: '(无 worktree)',
|
|
21
|
+
WORKTREE_REMOVED: (path: string) => `✓ 已移除 worktree: ${path}`,
|
|
22
|
+
REMOVE_PARTIAL_FAILURE: (failures: Array<{ path: string; error: string }>) => `${failures.length} 个移除失败`,
|
|
23
|
+
REMOVE_NO_WORKTREES: '当前项目没有可用的 worktree,无需移除',
|
|
24
|
+
REMOVE_SELECT_BRANCH: '请选择要移除的分支(空格选择,回车确认)',
|
|
25
|
+
REMOVE_MULTIPLE_MATCHES: (name: string) => `"${name}" 匹配到多个分支`,
|
|
26
|
+
REMOVE_NO_MATCH: (name: string, branches: string[]) => `未找到与 "${name}" 匹配的分支,可用:${branches.join(', ')}`,
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('../../../src/utils/index.js', () => ({
|
|
31
|
+
validateMainWorktree: vi.fn(),
|
|
32
|
+
getProjectName: vi.fn(),
|
|
33
|
+
getProjectWorktreeDir: vi.fn(),
|
|
34
|
+
getProjectWorktrees: vi.fn(),
|
|
35
|
+
removeWorktreeByPath: vi.fn(),
|
|
36
|
+
deleteBranch: vi.fn(),
|
|
37
|
+
getConfigValue: vi.fn(),
|
|
38
|
+
gitWorktreePrune: vi.fn(),
|
|
39
|
+
removeEmptyDir: vi.fn(),
|
|
40
|
+
printInfo: vi.fn(),
|
|
41
|
+
printSuccess: vi.fn(),
|
|
42
|
+
printError: vi.fn(),
|
|
43
|
+
confirmAction: vi.fn(),
|
|
44
|
+
removeSnapshot: vi.fn(),
|
|
45
|
+
removeProjectSnapshots: vi.fn(),
|
|
46
|
+
resolveTargetWorktrees: vi.fn(),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
import { registerRemoveCommand } from '../../../src/commands/remove.js';
|
|
50
|
+
import {
|
|
51
|
+
validateMainWorktree,
|
|
52
|
+
getProjectName,
|
|
53
|
+
getProjectWorktrees,
|
|
54
|
+
removeWorktreeByPath,
|
|
55
|
+
deleteBranch,
|
|
56
|
+
getConfigValue,
|
|
57
|
+
confirmAction,
|
|
58
|
+
removeSnapshot,
|
|
59
|
+
removeProjectSnapshots,
|
|
60
|
+
printSuccess,
|
|
61
|
+
printError,
|
|
62
|
+
resolveTargetWorktrees,
|
|
63
|
+
} from '../../../src/utils/index.js';
|
|
64
|
+
|
|
65
|
+
const mockedGetProjectName = vi.mocked(getProjectName);
|
|
66
|
+
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
67
|
+
const mockedRemoveWorktreeByPath = vi.mocked(removeWorktreeByPath);
|
|
68
|
+
const mockedDeleteBranch = vi.mocked(deleteBranch);
|
|
69
|
+
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
70
|
+
const mockedConfirmAction = vi.mocked(confirmAction);
|
|
71
|
+
const mockedRemoveSnapshot = vi.mocked(removeSnapshot);
|
|
72
|
+
const mockedRemoveProjectSnapshots = vi.mocked(removeProjectSnapshots);
|
|
73
|
+
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
74
|
+
const mockedPrintError = vi.mocked(printError);
|
|
75
|
+
const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
vi.mocked(validateMainWorktree).mockReset();
|
|
79
|
+
mockedGetProjectName.mockReturnValue('test-project');
|
|
80
|
+
mockedGetProjectWorktrees.mockReset();
|
|
81
|
+
mockedRemoveWorktreeByPath.mockReset();
|
|
82
|
+
mockedDeleteBranch.mockReset();
|
|
83
|
+
mockedGetConfigValue.mockReset();
|
|
84
|
+
mockedConfirmAction.mockReset();
|
|
85
|
+
mockedRemoveSnapshot.mockReset();
|
|
86
|
+
mockedRemoveProjectSnapshots.mockReset();
|
|
87
|
+
mockedPrintSuccess.mockReset();
|
|
88
|
+
mockedPrintError.mockReset();
|
|
89
|
+
mockedResolveTargetWorktrees.mockReset();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('registerRemoveCommand', () => {
|
|
93
|
+
it('注册 remove 命令', () => {
|
|
94
|
+
const program = new Command();
|
|
95
|
+
registerRemoveCommand(program);
|
|
96
|
+
const cmd = program.commands.find((c) => c.name() === 'remove');
|
|
97
|
+
expect(cmd).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('handleRemove', () => {
|
|
102
|
+
it('--all 移除所有 worktree(autoDeleteBranch=true)', async () => {
|
|
103
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
104
|
+
{ path: '/path/feature-1', branch: 'feature-1' },
|
|
105
|
+
{ path: '/path/feature-2', branch: 'feature-2' },
|
|
106
|
+
]);
|
|
107
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
108
|
+
|
|
109
|
+
const program = new Command();
|
|
110
|
+
program.exitOverride();
|
|
111
|
+
registerRemoveCommand(program);
|
|
112
|
+
await program.parseAsync(['remove', '--all'], { from: 'user' });
|
|
113
|
+
|
|
114
|
+
expect(mockedRemoveWorktreeByPath).toHaveBeenCalledTimes(2);
|
|
115
|
+
expect(mockedDeleteBranch).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(mockedRemoveProjectSnapshots).toHaveBeenCalledWith('test-project');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('-b 精确匹配时通过 resolveTargetWorktrees 解析并移除', async () => {
|
|
120
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
121
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
122
|
+
{ path: '/path/other', branch: 'other' },
|
|
123
|
+
]);
|
|
124
|
+
mockedResolveTargetWorktrees.mockResolvedValue([
|
|
125
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
126
|
+
]);
|
|
127
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
128
|
+
|
|
129
|
+
const program = new Command();
|
|
130
|
+
program.exitOverride();
|
|
131
|
+
registerRemoveCommand(program);
|
|
132
|
+
await program.parseAsync(['remove', '-b', 'feature'], { from: 'user' });
|
|
133
|
+
|
|
134
|
+
expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
|
|
135
|
+
expect(mockedRemoveWorktreeByPath).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(mockedRemoveWorktreeByPath).toHaveBeenCalledWith('/path/feature');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('-b 模糊匹配多个时通过 resolveTargetWorktrees 解析并批量移除', async () => {
|
|
140
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
141
|
+
{ path: '/path/feature-1', branch: 'feature-1' },
|
|
142
|
+
{ path: '/path/feature-2', branch: 'feature-2' },
|
|
143
|
+
{ path: '/path/other', branch: 'other' },
|
|
144
|
+
]);
|
|
145
|
+
// 模拟用户多选了两个
|
|
146
|
+
mockedResolveTargetWorktrees.mockResolvedValue([
|
|
147
|
+
{ path: '/path/feature-1', branch: 'feature-1' },
|
|
148
|
+
{ path: '/path/feature-2', branch: 'feature-2' },
|
|
149
|
+
]);
|
|
150
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
151
|
+
|
|
152
|
+
const program = new Command();
|
|
153
|
+
program.exitOverride();
|
|
154
|
+
registerRemoveCommand(program);
|
|
155
|
+
await program.parseAsync(['remove', '-b', 'feature'], { from: 'user' });
|
|
156
|
+
|
|
157
|
+
expect(mockedRemoveWorktreeByPath).toHaveBeenCalledTimes(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('未指定 --all 或 -b 时通过 resolveTargetWorktrees 展示多选列表', async () => {
|
|
161
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
162
|
+
{ path: '/path/feature-1', branch: 'feature-1' },
|
|
163
|
+
{ path: '/path/feature-2', branch: 'feature-2' },
|
|
164
|
+
]);
|
|
165
|
+
mockedResolveTargetWorktrees.mockResolvedValue([
|
|
166
|
+
{ path: '/path/feature-1', branch: 'feature-1' },
|
|
167
|
+
]);
|
|
168
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
169
|
+
|
|
170
|
+
const program = new Command();
|
|
171
|
+
program.exitOverride();
|
|
172
|
+
registerRemoveCommand(program);
|
|
173
|
+
await program.parseAsync(['remove'], { from: 'user' });
|
|
174
|
+
|
|
175
|
+
// 未传 branch 参数,resolveTargetWorktrees 的第三个参数应为 undefined
|
|
176
|
+
expect(mockedResolveTargetWorktrees).toHaveBeenCalledWith(
|
|
177
|
+
expect.any(Array),
|
|
178
|
+
expect.any(Object),
|
|
179
|
+
undefined,
|
|
180
|
+
);
|
|
181
|
+
expect(mockedRemoveWorktreeByPath).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('autoDeleteBranch=false 时询问用户是否删除分支', async () => {
|
|
185
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
186
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
187
|
+
]);
|
|
188
|
+
mockedResolveTargetWorktrees.mockResolvedValue([
|
|
189
|
+
{ path: '/path/feature', branch: 'feature' },
|
|
190
|
+
]);
|
|
191
|
+
mockedGetConfigValue.mockReturnValue(false);
|
|
192
|
+
mockedConfirmAction.mockResolvedValue(false);
|
|
193
|
+
|
|
194
|
+
const program = new Command();
|
|
195
|
+
program.exitOverride();
|
|
196
|
+
registerRemoveCommand(program);
|
|
197
|
+
await program.parseAsync(['remove', '-b', 'feature'], { from: 'user' });
|
|
198
|
+
|
|
199
|
+
expect(mockedConfirmAction).toHaveBeenCalled();
|
|
200
|
+
// 用户拒绝删除分支
|
|
201
|
+
expect(mockedDeleteBranch).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('-b 指定不存在的分支时 resolveTargetWorktrees 抛出错误', async () => {
|
|
205
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
206
|
+
{ path: '/path/other', branch: 'other' },
|
|
207
|
+
]);
|
|
208
|
+
mockedResolveTargetWorktrees.mockRejectedValue(new Error('未找到与 "nonexistent" 匹配的分支'));
|
|
209
|
+
|
|
210
|
+
const program = new Command();
|
|
211
|
+
program.exitOverride();
|
|
212
|
+
registerRemoveCommand(program);
|
|
213
|
+
|
|
214
|
+
await expect(
|
|
215
|
+
program.parseAsync(['remove', '-b', 'nonexistent'], { from: 'user' }),
|
|
216
|
+
).rejects.toThrow();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('移除过程中部分失败时汇报并抛出错误', async () => {
|
|
220
|
+
mockedGetProjectWorktrees.mockReturnValue([
|
|
221
|
+
{ path: '/path/feature-1', branch: 'feature-1' },
|
|
222
|
+
{ path: '/path/feature-2', branch: 'feature-2' },
|
|
223
|
+
]);
|
|
224
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
225
|
+
// 第一个成功,第二个失败
|
|
226
|
+
mockedRemoveWorktreeByPath
|
|
227
|
+
.mockImplementationOnce(() => {})
|
|
228
|
+
.mockImplementationOnce(() => { throw new Error('remove failed'); });
|
|
229
|
+
|
|
230
|
+
const program = new Command();
|
|
231
|
+
program.exitOverride();
|
|
232
|
+
registerRemoveCommand(program);
|
|
233
|
+
|
|
234
|
+
await expect(
|
|
235
|
+
program.parseAsync(['remove', '--all'], { from: 'user' }),
|
|
236
|
+
).rejects.toThrow();
|
|
237
|
+
|
|
238
|
+
expect(mockedPrintError).toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
RESET_SUCCESS: '✓ 已重置主 worktree',
|
|
11
|
+
RESET_ALREADY_CLEAN: '主 worktree 已经是干净状态',
|
|
12
|
+
DESTRUCTIVE_OP_CANCELLED: '已取消操作',
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../../../src/utils/index.js', () => ({
|
|
17
|
+
validateMainWorktree: vi.fn(),
|
|
18
|
+
getGitTopLevel: vi.fn(),
|
|
19
|
+
getConfigValue: vi.fn(),
|
|
20
|
+
isWorkingDirClean: vi.fn(),
|
|
21
|
+
gitResetHard: vi.fn(),
|
|
22
|
+
gitCleanForce: vi.fn(),
|
|
23
|
+
confirmDestructiveAction: vi.fn(),
|
|
24
|
+
printSuccess: vi.fn(),
|
|
25
|
+
printInfo: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { registerResetCommand } from '../../../src/commands/reset.js';
|
|
29
|
+
import {
|
|
30
|
+
getGitTopLevel,
|
|
31
|
+
getConfigValue,
|
|
32
|
+
isWorkingDirClean,
|
|
33
|
+
gitResetHard,
|
|
34
|
+
gitCleanForce,
|
|
35
|
+
confirmDestructiveAction,
|
|
36
|
+
printSuccess,
|
|
37
|
+
printInfo,
|
|
38
|
+
} from '../../../src/utils/index.js';
|
|
39
|
+
|
|
40
|
+
const mockedGetGitTopLevel = vi.mocked(getGitTopLevel);
|
|
41
|
+
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
42
|
+
const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
|
|
43
|
+
const mockedGitResetHard = vi.mocked(gitResetHard);
|
|
44
|
+
const mockedGitCleanForce = vi.mocked(gitCleanForce);
|
|
45
|
+
const mockedConfirmDestructiveAction = vi.mocked(confirmDestructiveAction);
|
|
46
|
+
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
47
|
+
const mockedPrintInfo = vi.mocked(printInfo);
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
mockedGetGitTopLevel.mockReturnValue('/repo');
|
|
51
|
+
mockedGetConfigValue.mockReset();
|
|
52
|
+
mockedIsWorkingDirClean.mockReset();
|
|
53
|
+
mockedGitResetHard.mockReset();
|
|
54
|
+
mockedGitCleanForce.mockReset();
|
|
55
|
+
mockedConfirmDestructiveAction.mockReset();
|
|
56
|
+
mockedPrintSuccess.mockReset();
|
|
57
|
+
mockedPrintInfo.mockReset();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('registerResetCommand', () => {
|
|
61
|
+
it('注册 reset 命令', () => {
|
|
62
|
+
const program = new Command();
|
|
63
|
+
registerResetCommand(program);
|
|
64
|
+
const cmd = program.commands.find((c) => c.name() === 'reset');
|
|
65
|
+
expect(cmd).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('handleReset', () => {
|
|
70
|
+
it('工作区已干净时提示', async () => {
|
|
71
|
+
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
72
|
+
|
|
73
|
+
const program = new Command();
|
|
74
|
+
program.exitOverride();
|
|
75
|
+
registerResetCommand(program);
|
|
76
|
+
await program.parseAsync(['reset'], { from: 'user' });
|
|
77
|
+
|
|
78
|
+
expect(mockedGitResetHard).not.toHaveBeenCalled();
|
|
79
|
+
expect(mockedPrintInfo).toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('工作区不干净且确认后执行重置', async () => {
|
|
83
|
+
mockedIsWorkingDirClean.mockReturnValue(false);
|
|
84
|
+
mockedGetConfigValue.mockReturnValue(true); // confirmDestructiveOps = true
|
|
85
|
+
mockedConfirmDestructiveAction.mockResolvedValue(true);
|
|
86
|
+
|
|
87
|
+
const program = new Command();
|
|
88
|
+
program.exitOverride();
|
|
89
|
+
registerResetCommand(program);
|
|
90
|
+
await program.parseAsync(['reset'], { from: 'user' });
|
|
91
|
+
|
|
92
|
+
expect(mockedGitResetHard).toHaveBeenCalledWith('/repo');
|
|
93
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith('/repo');
|
|
94
|
+
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('用户拒绝确认时不执行重置', async () => {
|
|
98
|
+
mockedIsWorkingDirClean.mockReturnValue(false);
|
|
99
|
+
mockedGetConfigValue.mockReturnValue(true);
|
|
100
|
+
mockedConfirmDestructiveAction.mockResolvedValue(false);
|
|
101
|
+
|
|
102
|
+
const program = new Command();
|
|
103
|
+
program.exitOverride();
|
|
104
|
+
registerResetCommand(program);
|
|
105
|
+
await program.parseAsync(['reset'], { from: 'user' });
|
|
106
|
+
|
|
107
|
+
expect(mockedGitResetHard).not.toHaveBeenCalled();
|
|
108
|
+
expect(mockedPrintInfo).toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('confirmDestructiveOps=false 时跳过确认直接重置', async () => {
|
|
112
|
+
mockedIsWorkingDirClean.mockReturnValue(false);
|
|
113
|
+
mockedGetConfigValue.mockReturnValue(false);
|
|
114
|
+
|
|
115
|
+
const program = new Command();
|
|
116
|
+
program.exitOverride();
|
|
117
|
+
registerResetCommand(program);
|
|
118
|
+
await program.parseAsync(['reset'], { from: 'user' });
|
|
119
|
+
|
|
120
|
+
expect(mockedConfirmDestructiveAction).not.toHaveBeenCalled();
|
|
121
|
+
expect(mockedGitResetHard).toHaveBeenCalledWith('/repo');
|
|
122
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith('/repo');
|
|
123
|
+
});
|
|
124
|
+
});
|