clawt 2.19.0 → 3.0.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.
- package/README.md +12 -2
- package/dist/index.js +626 -219
- package/dist/postinstall.js +39 -6
- package/docs/alias.md +108 -0
- package/docs/completion.md +55 -0
- package/docs/config-file.md +43 -0
- package/docs/config.md +91 -0
- package/docs/create.md +85 -0
- package/docs/init.md +65 -0
- package/docs/list.md +67 -0
- package/docs/log.md +67 -0
- package/docs/merge.md +137 -0
- package/docs/notification.md +94 -0
- package/docs/projects.md +135 -0
- package/docs/remove.md +79 -0
- package/docs/reset.md +35 -0
- package/docs/resume.md +99 -0
- package/docs/run.md +146 -0
- package/docs/spec.md +156 -1879
- package/docs/status.md +155 -0
- package/docs/sync.md +114 -0
- package/docs/update-check.md +95 -0
- package/docs/validate.md +368 -0
- package/package.json +1 -1
- package/src/commands/alias.ts +1 -1
- package/src/commands/create.ts +10 -5
- package/src/commands/init.ts +75 -0
- package/src/commands/list.ts +1 -1
- package/src/commands/merge.ts +11 -4
- package/src/commands/remove.ts +10 -3
- package/src/commands/reset.ts +3 -0
- package/src/commands/resume.ts +9 -3
- package/src/commands/run.ts +9 -3
- package/src/commands/status.ts +1 -1
- package/src/commands/sync.ts +18 -6
- package/src/commands/validate.ts +46 -52
- package/src/constants/branch.ts +3 -0
- package/src/constants/config.ts +1 -1
- package/src/constants/index.ts +3 -3
- package/src/constants/messages/completion.ts +1 -1
- package/src/constants/messages/create.ts +3 -0
- package/src/constants/messages/index.ts +2 -0
- package/src/constants/messages/init.ts +18 -0
- package/src/constants/messages/remove.ts +2 -0
- package/src/constants/messages/sync.ts +3 -0
- package/src/constants/messages/validate.ts +6 -0
- package/src/constants/paths.ts +3 -0
- package/src/constants/prompt.ts +28 -0
- package/src/index.ts +2 -0
- package/src/types/command.ts +7 -1
- package/src/types/index.ts +2 -1
- package/src/types/projectConfig.ts +5 -0
- package/src/utils/config.ts +2 -1
- package/src/utils/git.ts +18 -0
- package/src/utils/index.ts +6 -1
- package/src/utils/json.ts +67 -0
- package/src/utils/project-config.ts +77 -0
- package/src/utils/validate-branch.ts +166 -0
- package/src/utils/worktree-matcher.ts +268 -1
- package/src/utils/worktree.ts +6 -2
- package/tests/unit/commands/create.test.ts +20 -16
- package/tests/unit/commands/init.test.ts +146 -0
- package/tests/unit/commands/merge.test.ts +7 -1
- package/tests/unit/commands/remove.test.ts +4 -0
- package/tests/unit/commands/reset.test.ts +2 -0
- package/tests/unit/commands/resume.test.ts +29 -8
- package/tests/unit/commands/run.test.ts +2 -0
- package/tests/unit/commands/sync.test.ts +6 -0
- package/tests/unit/commands/validate.test.ts +13 -0
- package/tests/unit/utils/config.test.ts +2 -2
- package/tests/unit/utils/project-config.test.ts +136 -0
- package/tests/unit/utils/update-checker.test.ts +28 -7
- package/tests/unit/utils/validate-branch.test.ts +272 -0
- package/tests/unit/utils/worktree-matcher.test.ts +142 -1
- package/tests/unit/utils/worktree.test.ts +6 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// mock logger
|
|
4
|
+
vi.mock('../../../src/logger/index.js', () => ({
|
|
5
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// mock errors
|
|
9
|
+
vi.mock('../../../src/errors/index.js', () => ({
|
|
10
|
+
ClawtError: class ClawtError extends Error {
|
|
11
|
+
exitCode: number;
|
|
12
|
+
constructor(message: string, exitCode = 1) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.exitCode = exitCode;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// mock constants
|
|
20
|
+
vi.mock('../../../src/constants/index.js', () => ({
|
|
21
|
+
VALIDATE_BRANCH_PREFIX: 'clawt-validate-',
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// mock enquirer(必须在所有 import 之前)
|
|
25
|
+
const { mockSelectRun } = vi.hoisted(() => {
|
|
26
|
+
const mockSelectRun = vi.fn();
|
|
27
|
+
return { mockSelectRun };
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
vi.mock('enquirer', () => ({
|
|
31
|
+
default: {
|
|
32
|
+
Select: function MockSelect() { return { run: mockSelectRun }; },
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// mock git
|
|
37
|
+
vi.mock('../../../src/utils/git.js', () => ({
|
|
38
|
+
checkBranchExists: vi.fn(),
|
|
39
|
+
createBranch: vi.fn(),
|
|
40
|
+
deleteBranch: vi.fn(),
|
|
41
|
+
getCurrentBranch: vi.fn(),
|
|
42
|
+
gitCheckout: vi.fn(),
|
|
43
|
+
gitResetHard: vi.fn(),
|
|
44
|
+
gitCleanForce: vi.fn(),
|
|
45
|
+
isWorkingDirClean: vi.fn().mockReturnValue(true),
|
|
46
|
+
gitAddAll: vi.fn(),
|
|
47
|
+
gitStashPush: vi.fn(),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// mock project-config
|
|
51
|
+
vi.mock('../../../src/utils/project-config.js', () => ({
|
|
52
|
+
getMainWorkBranch: vi.fn().mockReturnValue('main'),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// mock formatter
|
|
56
|
+
vi.mock('../../../src/utils/formatter.js', () => ({
|
|
57
|
+
printWarning: vi.fn(),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
checkBranchExists,
|
|
62
|
+
createBranch,
|
|
63
|
+
deleteBranch,
|
|
64
|
+
getCurrentBranch,
|
|
65
|
+
gitCheckout,
|
|
66
|
+
gitResetHard,
|
|
67
|
+
gitCleanForce,
|
|
68
|
+
isWorkingDirClean,
|
|
69
|
+
gitAddAll,
|
|
70
|
+
gitStashPush,
|
|
71
|
+
} from '../../../src/utils/git.js';
|
|
72
|
+
import {
|
|
73
|
+
getValidateBranchName,
|
|
74
|
+
createValidateBranch,
|
|
75
|
+
deleteValidateBranch,
|
|
76
|
+
rebuildValidateBranch,
|
|
77
|
+
ensureOnMainWorkBranch,
|
|
78
|
+
handleDirtyWorkingDir,
|
|
79
|
+
} from '../../../src/utils/validate-branch.js';
|
|
80
|
+
const mockedCheckBranchExists = vi.mocked(checkBranchExists);
|
|
81
|
+
const mockedCreateBranch = vi.mocked(createBranch);
|
|
82
|
+
const mockedDeleteBranch = vi.mocked(deleteBranch);
|
|
83
|
+
const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
|
|
84
|
+
const mockedGitCheckout = vi.mocked(gitCheckout);
|
|
85
|
+
const mockedGitResetHard = vi.mocked(gitResetHard);
|
|
86
|
+
const mockedGitCleanForce = vi.mocked(gitCleanForce);
|
|
87
|
+
const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
|
|
88
|
+
const mockedGitAddAll = vi.mocked(gitAddAll);
|
|
89
|
+
const mockedGitStashPush = vi.mocked(gitStashPush);
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
mockedCheckBranchExists.mockReset();
|
|
93
|
+
mockedCreateBranch.mockReset();
|
|
94
|
+
mockedDeleteBranch.mockReset();
|
|
95
|
+
mockedGetCurrentBranch.mockReset();
|
|
96
|
+
mockedGitCheckout.mockReset();
|
|
97
|
+
mockedGitResetHard.mockReset();
|
|
98
|
+
mockedGitCleanForce.mockReset();
|
|
99
|
+
mockedIsWorkingDirClean.mockReset().mockReturnValue(true);
|
|
100
|
+
mockedGitAddAll.mockReset();
|
|
101
|
+
mockedGitStashPush.mockReset();
|
|
102
|
+
mockSelectRun.mockReset();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('getValidateBranchName', () => {
|
|
106
|
+
it('生成正确的验证分支名', () => {
|
|
107
|
+
expect(getValidateBranchName('feature')).toBe('clawt-validate-feature');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('处理含连字符的分支名', () => {
|
|
111
|
+
expect(getValidateBranchName('my-feature-1')).toBe('clawt-validate-my-feature-1');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('createValidateBranch', () => {
|
|
116
|
+
it('分支不存在时创建', () => {
|
|
117
|
+
mockedCheckBranchExists.mockReturnValue(false);
|
|
118
|
+
createValidateBranch('feature');
|
|
119
|
+
expect(mockedCreateBranch).toHaveBeenCalledWith('clawt-validate-feature', undefined);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('分支已存在时跳过', () => {
|
|
123
|
+
mockedCheckBranchExists.mockReturnValue(true);
|
|
124
|
+
createValidateBranch('feature');
|
|
125
|
+
expect(mockedCreateBranch).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('deleteValidateBranch', () => {
|
|
130
|
+
it('分支存在时删除', () => {
|
|
131
|
+
mockedCheckBranchExists.mockReturnValue(true);
|
|
132
|
+
deleteValidateBranch('feature');
|
|
133
|
+
expect(mockedDeleteBranch).toHaveBeenCalledWith('clawt-validate-feature', undefined);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('分支不存在时跳过', () => {
|
|
137
|
+
mockedCheckBranchExists.mockReturnValue(false);
|
|
138
|
+
deleteValidateBranch('feature');
|
|
139
|
+
expect(mockedDeleteBranch).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('rebuildValidateBranch', () => {
|
|
144
|
+
it('重建时先删除再创建', async () => {
|
|
145
|
+
mockedGetCurrentBranch.mockReturnValue('main');
|
|
146
|
+
// 第一次调用 (deleteValidateBranch) 检查存在 → 删除
|
|
147
|
+
// 第二次调用 (createValidateBranch) 检查不存在 → 创建
|
|
148
|
+
mockedCheckBranchExists
|
|
149
|
+
.mockReturnValueOnce(true) // deleteValidateBranch 检查
|
|
150
|
+
.mockReturnValueOnce(false); // createValidateBranch 检查
|
|
151
|
+
await rebuildValidateBranch('feature');
|
|
152
|
+
expect(mockedDeleteBranch).toHaveBeenCalled();
|
|
153
|
+
expect(mockedCreateBranch).toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('当前在验证分支上时先执行 reset+clean 再切回', async () => {
|
|
157
|
+
// 第一次 getCurrentBranch(rebuildValidateBranch 内部检测)返回验证分支
|
|
158
|
+
// 第二次 getCurrentBranch(switchBackIfOnValidateBranch 内部)返回验证分支
|
|
159
|
+
mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
|
|
160
|
+
mockedCheckBranchExists
|
|
161
|
+
.mockReturnValueOnce(true) // deleteValidateBranch 检查
|
|
162
|
+
.mockReturnValueOnce(false); // createValidateBranch 检查
|
|
163
|
+
await rebuildValidateBranch('feature');
|
|
164
|
+
expect(mockedGitResetHard).toHaveBeenCalledWith(undefined);
|
|
165
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith(undefined);
|
|
166
|
+
expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('当前不在验证分支上时不执行 reset+clean', async () => {
|
|
170
|
+
mockedGetCurrentBranch.mockReturnValue('main');
|
|
171
|
+
mockedCheckBranchExists
|
|
172
|
+
.mockReturnValueOnce(true)
|
|
173
|
+
.mockReturnValueOnce(false);
|
|
174
|
+
await rebuildValidateBranch('feature');
|
|
175
|
+
expect(mockedGitResetHard).not.toHaveBeenCalled();
|
|
176
|
+
expect(mockedGitCleanForce).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('handleDirtyWorkingDir', () => {
|
|
181
|
+
it('用户选择 reset 时执行 gitResetHard 和 gitCleanForce', async () => {
|
|
182
|
+
mockSelectRun.mockResolvedValue('reset');
|
|
183
|
+
// 第一次调用(handleDirtyWorkingDir 末尾检查)返回干净
|
|
184
|
+
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
185
|
+
await handleDirtyWorkingDir();
|
|
186
|
+
expect(mockedGitResetHard).toHaveBeenCalledWith(undefined);
|
|
187
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith(undefined);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('用户选择 stash 时执行 gitAddAll 和 gitStashPush', async () => {
|
|
191
|
+
mockSelectRun.mockResolvedValue('stash');
|
|
192
|
+
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
193
|
+
await handleDirtyWorkingDir();
|
|
194
|
+
expect(mockedGitAddAll).toHaveBeenCalledWith(undefined);
|
|
195
|
+
expect(mockedGitStashPush).toHaveBeenCalledWith('clawt:auto-stash', undefined);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('用户选择 exit 时抛出错误', async () => {
|
|
199
|
+
mockSelectRun.mockResolvedValue('exit');
|
|
200
|
+
await expect(handleDirtyWorkingDir()).rejects.toThrow('用户选择退出');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('处理后工作区仍不干净时抛出错误', async () => {
|
|
204
|
+
mockSelectRun.mockResolvedValue('reset');
|
|
205
|
+
mockedIsWorkingDirClean.mockReturnValue(false);
|
|
206
|
+
await expect(handleDirtyWorkingDir()).rejects.toThrow('工作区仍然不干净');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('传入 cwd 参数时透传给 git 操作', async () => {
|
|
210
|
+
mockSelectRun.mockResolvedValue('reset');
|
|
211
|
+
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
212
|
+
await handleDirtyWorkingDir('/some/path');
|
|
213
|
+
expect(mockedGitResetHard).toHaveBeenCalledWith('/some/path');
|
|
214
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith('/some/path');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('ensureOnMainWorkBranch', () => {
|
|
219
|
+
it('当前在主工作分支上时直接返回', async () => {
|
|
220
|
+
mockedGetCurrentBranch.mockReturnValue('main');
|
|
221
|
+
await ensureOnMainWorkBranch();
|
|
222
|
+
expect(mockedGitCheckout).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('当前在验证分支上且工作区干净时自动切回主工作分支', async () => {
|
|
226
|
+
mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
|
|
227
|
+
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
228
|
+
await ensureOnMainWorkBranch();
|
|
229
|
+
expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
|
|
230
|
+
// 工作区干净时不应调用 resetHard/cleanForce
|
|
231
|
+
expect(mockedGitResetHard).not.toHaveBeenCalled();
|
|
232
|
+
expect(mockedGitCleanForce).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('当前在验证分支上且工作区脏时先清理再切回', async () => {
|
|
236
|
+
mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
|
|
237
|
+
mockedIsWorkingDirClean.mockReturnValue(false);
|
|
238
|
+
await ensureOnMainWorkBranch();
|
|
239
|
+
// 验证分支上的修改直接丢弃
|
|
240
|
+
expect(mockedGitResetHard).toHaveBeenCalledWith(undefined);
|
|
241
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith(undefined);
|
|
242
|
+
expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('当前在其他分支上且工作区干净时直接切换到主工作分支', async () => {
|
|
246
|
+
mockedGetCurrentBranch.mockReturnValue('feature');
|
|
247
|
+
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
248
|
+
await ensureOnMainWorkBranch();
|
|
249
|
+
expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('当前在其他分支上且工作区脏时先处理再切换', async () => {
|
|
253
|
+
mockedGetCurrentBranch.mockReturnValue('feature');
|
|
254
|
+
// 第一次调用(ensureOnMainWorkBranch 检测)返回脏
|
|
255
|
+
// 第二次调用(handleDirtyWorkingDir 末尾检查)返回干净
|
|
256
|
+
mockedIsWorkingDirClean.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
|
257
|
+
mockSelectRun.mockResolvedValue('reset');
|
|
258
|
+
await ensureOnMainWorkBranch();
|
|
259
|
+
expect(mockedGitResetHard).toHaveBeenCalled();
|
|
260
|
+
expect(mockedGitCleanForce).toHaveBeenCalled();
|
|
261
|
+
expect(mockedGitCheckout).toHaveBeenCalledWith('main', undefined);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('传入 cwd 参数时透传给 git 操作', async () => {
|
|
265
|
+
mockedGetCurrentBranch.mockReturnValue('clawt-validate-feature');
|
|
266
|
+
mockedIsWorkingDirClean.mockReturnValue(false);
|
|
267
|
+
await ensureOnMainWorkBranch('/some/path');
|
|
268
|
+
expect(mockedGitResetHard).toHaveBeenCalledWith('/some/path');
|
|
269
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith('/some/path');
|
|
270
|
+
expect(mockedGitCheckout).toHaveBeenCalledWith('main', '/some/path');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { findExactMatch, findFuzzyMatches, resolveTargetWorktree, resolveTargetWorktrees } from '../../../src/utils/worktree-matcher.js';
|
|
2
|
+
import { findExactMatch, findFuzzyMatches, resolveTargetWorktree, resolveTargetWorktrees, groupWorktreesByDate, buildGroupedChoices, buildGroupMembershipMap } 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 { SELECT_ALL_NAME, GROUP_SELECT_ALL_PREFIX, UNKNOWN_DATE_GROUP } from '../../../src/constants/index.js';
|
|
5
6
|
import type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from '../../../src/utils/worktree-matcher.js';
|
|
6
7
|
|
|
7
8
|
// mock enquirer
|
|
@@ -16,6 +17,19 @@ vi.mock('enquirer', () => ({
|
|
|
16
17
|
},
|
|
17
18
|
}));
|
|
18
19
|
|
|
20
|
+
// mock getBranchCreatedAt,分组测试中按需控制返回值
|
|
21
|
+
vi.mock('../../../src/utils/git.js', () => ({
|
|
22
|
+
getBranchCreatedAt: vi.fn().mockReturnValue(null),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// mock node:fs 的 statSync,分组测试中按需控制返回值
|
|
26
|
+
vi.mock('node:fs', () => ({
|
|
27
|
+
statSync: vi.fn().mockReturnValue({ birthtime: new Date('2026-02-27T10:00:00') }),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { statSync } from 'node:fs';
|
|
31
|
+
const mockedStatSync = vi.mocked(statSync);
|
|
32
|
+
|
|
19
33
|
/** 测试用消息配置 */
|
|
20
34
|
const testMessages: WorktreeResolveMessages = {
|
|
21
35
|
noWorktrees: '无可用 worktree',
|
|
@@ -190,3 +204,130 @@ describe('resolveTargetWorktrees', () => {
|
|
|
190
204
|
await expect(resolveTargetWorktrees(worktrees, testMultiMessages, 'xyz')).rejects.toThrow(ClawtError);
|
|
191
205
|
});
|
|
192
206
|
});
|
|
207
|
+
|
|
208
|
+
describe('groupWorktreesByDate', () => {
|
|
209
|
+
it('多日期分组:不同日期的分支被正确分组,最新日期在前', () => {
|
|
210
|
+
const worktrees = [
|
|
211
|
+
createWorktreeInfo({ path: '/worktrees/feature-auth', branch: 'feature-auth' }),
|
|
212
|
+
createWorktreeInfo({ path: '/worktrees/feature-login', branch: 'feature-login' }),
|
|
213
|
+
createWorktreeInfo({ path: '/worktrees/bugfix-nav', branch: 'bugfix-nav' }),
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
mockedStatSync
|
|
217
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
|
|
218
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T14:00:00') } as any)
|
|
219
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-25T09:00:00') } as any);
|
|
220
|
+
|
|
221
|
+
const groups = groupWorktreesByDate(worktrees);
|
|
222
|
+
const keys = [...groups.keys()];
|
|
223
|
+
|
|
224
|
+
// 最新日期在前
|
|
225
|
+
expect(keys).toEqual(['2026-02-26', '2026-02-25']);
|
|
226
|
+
// 2026-02-26 组有 2 个分支
|
|
227
|
+
expect(groups.get('2026-02-26')).toHaveLength(2);
|
|
228
|
+
expect(groups.get('2026-02-26')!.map((wt) => wt.branch)).toEqual(['feature-auth', 'feature-login']);
|
|
229
|
+
// 2026-02-25 组有 1 个分支
|
|
230
|
+
expect(groups.get('2026-02-25')).toHaveLength(1);
|
|
231
|
+
expect(groups.get('2026-02-25')![0].branch).toBe('bugfix-nav');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('statSync 异常时归入"未知日期"组', () => {
|
|
235
|
+
const worktrees = [
|
|
236
|
+
createWorktreeInfo({ path: '/worktrees/feature-auth', branch: 'feature-auth' }),
|
|
237
|
+
createWorktreeInfo({ path: '/worktrees/old-branch', branch: 'old-branch' }),
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
mockedStatSync
|
|
241
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
|
|
242
|
+
.mockImplementationOnce(() => { throw new Error('ENOENT'); });
|
|
243
|
+
|
|
244
|
+
const groups = groupWorktreesByDate(worktrees);
|
|
245
|
+
const keys = [...groups.keys()];
|
|
246
|
+
|
|
247
|
+
// 未知日期在最后
|
|
248
|
+
expect(keys).toEqual(['2026-02-26', UNKNOWN_DATE_GROUP]);
|
|
249
|
+
expect(groups.get(UNKNOWN_DATE_GROUP)).toHaveLength(1);
|
|
250
|
+
expect(groups.get(UNKNOWN_DATE_GROUP)![0].branch).toBe('old-branch');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('单日期分组:所有分支同一天', () => {
|
|
254
|
+
const worktrees = [
|
|
255
|
+
createWorktreeInfo({ path: '/worktrees/feature-a', branch: 'feature-a' }),
|
|
256
|
+
createWorktreeInfo({ path: '/worktrees/feature-b', branch: 'feature-b' }),
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
mockedStatSync
|
|
260
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
|
|
261
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T15:00:00') } as any);
|
|
262
|
+
|
|
263
|
+
const groups = groupWorktreesByDate(worktrees);
|
|
264
|
+
const keys = [...groups.keys()];
|
|
265
|
+
|
|
266
|
+
expect(keys).toEqual(['2026-02-26']);
|
|
267
|
+
expect(groups.get('2026-02-26')).toHaveLength(2);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('buildGroupedChoices', () => {
|
|
272
|
+
it('构建的 choices 包含全局全选、分隔线、组全选和分支', () => {
|
|
273
|
+
const groups = new Map<string, Array<{ path: string; branch: string }>>([
|
|
274
|
+
['2026-02-26', [
|
|
275
|
+
createWorktreeInfo({ branch: 'feature-auth' }),
|
|
276
|
+
createWorktreeInfo({ branch: 'feature-login' }),
|
|
277
|
+
]],
|
|
278
|
+
[UNKNOWN_DATE_GROUP, [
|
|
279
|
+
createWorktreeInfo({ branch: 'old-branch' }),
|
|
280
|
+
]],
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
const choices = buildGroupedChoices(groups);
|
|
284
|
+
|
|
285
|
+
// 全局全选在顶部
|
|
286
|
+
expect(choices[0]).toEqual({ name: SELECT_ALL_NAME, message: '[select-all]' });
|
|
287
|
+
|
|
288
|
+
// 第一组分隔线
|
|
289
|
+
expect(choices[1]).toHaveProperty('role', 'separator');
|
|
290
|
+
|
|
291
|
+
// 第一组组全选
|
|
292
|
+
expect(choices[2]).toEqual({
|
|
293
|
+
name: `${GROUP_SELECT_ALL_PREFIX}2026-02-26`,
|
|
294
|
+
message: '[select-all: 2026-02-26]',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// 第一组分支
|
|
298
|
+
expect(choices[3]).toEqual({ name: 'feature-auth', message: 'feature-auth' });
|
|
299
|
+
expect(choices[4]).toEqual({ name: 'feature-login', message: 'feature-login' });
|
|
300
|
+
|
|
301
|
+
// 未知日期组分隔线(含 chalk 高亮,使用 stringContaining 匹配)
|
|
302
|
+
expect(choices[5]).toHaveProperty('role', 'separator');
|
|
303
|
+
expect((choices[5] as { message: string }).message).toContain('未知日期');
|
|
304
|
+
|
|
305
|
+
// 未知日期组全选
|
|
306
|
+
expect(choices[6]).toEqual({
|
|
307
|
+
name: `${GROUP_SELECT_ALL_PREFIX}${UNKNOWN_DATE_GROUP}`,
|
|
308
|
+
message: `[select-all: ${UNKNOWN_DATE_GROUP}]`,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// 未知日期组分支
|
|
312
|
+
expect(choices[7]).toEqual({ name: 'old-branch', message: 'old-branch' });
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('buildGroupMembershipMap', () => {
|
|
317
|
+
it('构建组全选 name 到分支 name 列表的正确映射', () => {
|
|
318
|
+
const groups = new Map<string, Array<{ path: string; branch: string }>>([
|
|
319
|
+
['2026-02-26', [
|
|
320
|
+
createWorktreeInfo({ branch: 'feature-auth' }),
|
|
321
|
+
createWorktreeInfo({ branch: 'feature-login' }),
|
|
322
|
+
]],
|
|
323
|
+
['2026-02-25', [
|
|
324
|
+
createWorktreeInfo({ branch: 'bugfix-nav' }),
|
|
325
|
+
]],
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
const membershipMap = buildGroupMembershipMap(groups);
|
|
329
|
+
|
|
330
|
+
expect(membershipMap.get(`${GROUP_SELECT_ALL_PREFIX}2026-02-26`)).toEqual(['feature-auth', 'feature-login']);
|
|
331
|
+
expect(membershipMap.get(`${GROUP_SELECT_ALL_PREFIX}2026-02-25`)).toEqual(['bugfix-nav']);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -48,6 +48,12 @@ vi.mock('../../../src/logger/index.js', () => ({
|
|
|
48
48
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
49
49
|
}));
|
|
50
50
|
|
|
51
|
+
// mock validate-branch
|
|
52
|
+
vi.mock('../../../src/utils/validate-branch.js', () => ({
|
|
53
|
+
createValidateBranch: vi.fn(),
|
|
54
|
+
deleteValidateBranch: vi.fn(),
|
|
55
|
+
}));
|
|
56
|
+
|
|
51
57
|
import { existsSync, readdirSync } from 'node:fs';
|
|
52
58
|
import {
|
|
53
59
|
getProjectName,
|