clawt 3.1.3 → 3.2.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/dist/index.js +176 -129
- package/dist/postinstall.js +2 -0
- package/docs/spec.md +1 -0
- package/docs/validate.md +51 -10
- package/package.json +1 -1
- package/src/commands/validate.ts +38 -230
- package/src/constants/messages/validate.ts +3 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/validate-core.ts +174 -0
- package/src/utils/validate-runner.ts +105 -0
- package/src/utils/validate-snapshot.ts +29 -9
- package/tests/unit/commands/validate.test.ts +79 -289
- package/tests/unit/utils/validate-snapshot.test.ts +8 -3
|
@@ -27,6 +27,7 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
27
27
|
VALIDATE_PATCH_APPLY_FAILED: (branch: string) => `patch 应用失败: ${branch}`,
|
|
28
28
|
INCREMENTAL_VALIDATE_SUCCESS: (branch: string) => `✓ 增量验证 ${branch}`,
|
|
29
29
|
INCREMENTAL_VALIDATE_FALLBACK: '降级为全量模式',
|
|
30
|
+
INCREMENTAL_VALIDATE_NO_CHANGES: (branch: string) => `${branch} 无新变更`,
|
|
30
31
|
DESTRUCTIVE_OP_CANCELLED: '已取消操作',
|
|
31
32
|
VALIDATE_RUN_START: (cmd: string) => `正在执行命令: ${cmd}`,
|
|
32
33
|
VALIDATE_RUN_SUCCESS: (cmd: string) => `✓ 命令执行完成: ${cmd}`,
|
|
@@ -62,22 +63,10 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
62
63
|
getProjectWorktrees: vi.fn(),
|
|
63
64
|
getConfigValue: vi.fn(),
|
|
64
65
|
isWorkingDirClean: vi.fn(),
|
|
65
|
-
|
|
66
|
-
gitCommit: vi.fn(),
|
|
67
|
-
gitStashPush: vi.fn(),
|
|
68
|
-
gitRestoreStaged: vi.fn(),
|
|
66
|
+
gitReadTree: vi.fn(),
|
|
69
67
|
gitResetHard: vi.fn(),
|
|
70
68
|
gitCleanForce: vi.fn(),
|
|
71
|
-
gitDiffBinaryAgainstBranch: vi.fn(),
|
|
72
|
-
gitApplyFromStdin: vi.fn(),
|
|
73
|
-
gitApplyCachedFromStdin: vi.fn(),
|
|
74
|
-
gitResetSoft: vi.fn(),
|
|
75
|
-
gitWriteTree: vi.fn(),
|
|
76
|
-
gitReadTree: vi.fn(),
|
|
77
69
|
getHeadCommitHash: vi.fn(),
|
|
78
|
-
getCommitTreeHash: vi.fn(),
|
|
79
|
-
gitDiffTree: vi.fn(),
|
|
80
|
-
gitApplyCachedCheck: vi.fn(),
|
|
81
70
|
hasLocalCommits: vi.fn(),
|
|
82
71
|
hasSnapshot: vi.fn(),
|
|
83
72
|
readSnapshot: vi.fn(),
|
|
@@ -89,19 +78,20 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
89
78
|
printWarning: vi.fn(),
|
|
90
79
|
printInfo: vi.fn(),
|
|
91
80
|
resolveTargetWorktree: vi.fn(),
|
|
92
|
-
runCommandInherited: vi.fn(),
|
|
93
|
-
printError: vi.fn(),
|
|
94
|
-
printSeparator: vi.fn(),
|
|
95
|
-
parseParallelCommands: vi.fn(),
|
|
96
|
-
runParallelCommands: vi.fn(),
|
|
97
81
|
requireProjectConfig: vi.fn().mockReturnValue({ clawtMainWorkBranch: 'main' }),
|
|
98
|
-
getValidateBranchName: vi.fn((name: string) => `clawt-validate-${name}`),
|
|
99
|
-
gitCheckout: vi.fn(),
|
|
100
82
|
ensureOnMainWorkBranch: vi.fn(),
|
|
101
83
|
handleDirtyWorkingDir: vi.fn(),
|
|
102
84
|
checkBranchExists: vi.fn().mockReturnValue(true),
|
|
103
85
|
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
|
104
86
|
getValidateRunCommand: vi.fn(),
|
|
87
|
+
// validate-core.ts 抽离的函数
|
|
88
|
+
migrateChangesViaPatch: vi.fn().mockReturnValue({ success: true }),
|
|
89
|
+
computeCurrentTreeHash: vi.fn().mockReturnValue('treehash'),
|
|
90
|
+
saveCurrentSnapshotTree: vi.fn().mockReturnValue('treehash'),
|
|
91
|
+
loadOldSnapshotToStage: vi.fn().mockReturnValue({ success: true, stagedTreeHash: '' }),
|
|
92
|
+
switchToValidateBranch: vi.fn((name: string) => `clawt-validate-${name}`),
|
|
93
|
+
// validate-runner.ts 抽离的函数
|
|
94
|
+
executeRunCommand: vi.fn(),
|
|
105
95
|
}));
|
|
106
96
|
|
|
107
97
|
import { registerValidateCommand } from '../../../src/commands/validate.js';
|
|
@@ -111,15 +101,10 @@ import {
|
|
|
111
101
|
getProjectWorktrees,
|
|
112
102
|
getConfigValue,
|
|
113
103
|
isWorkingDirClean,
|
|
114
|
-
gitAddAll,
|
|
115
|
-
gitCommit,
|
|
116
|
-
gitDiffBinaryAgainstBranch,
|
|
117
|
-
gitApplyFromStdin,
|
|
118
|
-
gitResetSoft,
|
|
119
|
-
gitWriteTree,
|
|
120
|
-
gitRestoreStaged,
|
|
121
|
-
getHeadCommitHash,
|
|
122
104
|
gitReadTree,
|
|
105
|
+
gitResetHard,
|
|
106
|
+
gitCleanForce,
|
|
107
|
+
getHeadCommitHash,
|
|
123
108
|
hasLocalCommits,
|
|
124
109
|
hasSnapshot,
|
|
125
110
|
readSnapshot,
|
|
@@ -128,19 +113,14 @@ import {
|
|
|
128
113
|
confirmDestructiveAction,
|
|
129
114
|
printSuccess,
|
|
130
115
|
printInfo,
|
|
131
|
-
resolveTargetWorktree,
|
|
132
|
-
gitResetHard,
|
|
133
|
-
gitCleanForce,
|
|
134
|
-
getCommitTreeHash,
|
|
135
|
-
gitDiffTree,
|
|
136
|
-
gitApplyCachedCheck,
|
|
137
|
-
gitApplyCachedFromStdin,
|
|
138
116
|
printWarning,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
117
|
+
resolveTargetWorktree,
|
|
118
|
+
migrateChangesViaPatch,
|
|
119
|
+
computeCurrentTreeHash,
|
|
120
|
+
saveCurrentSnapshotTree,
|
|
121
|
+
loadOldSnapshotToStage,
|
|
122
|
+
switchToValidateBranch,
|
|
123
|
+
executeRunCommand,
|
|
144
124
|
getValidateRunCommand,
|
|
145
125
|
} from '../../../src/utils/index.js';
|
|
146
126
|
|
|
@@ -149,13 +129,6 @@ const mockedGetGitTopLevel = vi.mocked(getGitTopLevel);
|
|
|
149
129
|
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
150
130
|
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
151
131
|
const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
|
|
152
|
-
const mockedGitAddAll = vi.mocked(gitAddAll);
|
|
153
|
-
const mockedGitCommit = vi.mocked(gitCommit);
|
|
154
|
-
const mockedGitDiffBinaryAgainstBranch = vi.mocked(gitDiffBinaryAgainstBranch);
|
|
155
|
-
const mockedGitApplyFromStdin = vi.mocked(gitApplyFromStdin);
|
|
156
|
-
const mockedGitResetSoft = vi.mocked(gitResetSoft);
|
|
157
|
-
const mockedGitWriteTree = vi.mocked(gitWriteTree);
|
|
158
|
-
const mockedGitRestoreStaged = vi.mocked(gitRestoreStaged);
|
|
159
132
|
const mockedGetHeadCommitHash = vi.mocked(getHeadCommitHash);
|
|
160
133
|
const mockedGitReadTree = vi.mocked(gitReadTree);
|
|
161
134
|
const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
|
|
@@ -166,19 +139,16 @@ const mockedRemoveSnapshot = vi.mocked(removeSnapshot);
|
|
|
166
139
|
const mockedConfirmDestructiveAction = vi.mocked(confirmDestructiveAction);
|
|
167
140
|
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
168
141
|
const mockedPrintInfo = vi.mocked(printInfo);
|
|
142
|
+
const mockedPrintWarning = vi.mocked(printWarning);
|
|
169
143
|
const mockedResolveTargetWorktree = vi.mocked(resolveTargetWorktree);
|
|
170
144
|
const mockedGitResetHard = vi.mocked(gitResetHard);
|
|
171
145
|
const mockedGitCleanForce = vi.mocked(gitCleanForce);
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const mockedPrintError = vi.mocked(printError);
|
|
179
|
-
const mockedPrintSeparator = vi.mocked(printSeparator);
|
|
180
|
-
const mockedParseParallelCommands = vi.mocked(parseParallelCommands);
|
|
181
|
-
const mockedRunParallelCommands = vi.mocked(runParallelCommands);
|
|
146
|
+
const mockedMigrateChangesViaPatch = vi.mocked(migrateChangesViaPatch);
|
|
147
|
+
const mockedComputeCurrentTreeHash = vi.mocked(computeCurrentTreeHash);
|
|
148
|
+
const mockedSaveCurrentSnapshotTree = vi.mocked(saveCurrentSnapshotTree);
|
|
149
|
+
const mockedLoadOldSnapshotToStage = vi.mocked(loadOldSnapshotToStage);
|
|
150
|
+
const mockedSwitchToValidateBranch = vi.mocked(switchToValidateBranch);
|
|
151
|
+
const mockedExecuteRunCommand = vi.mocked(executeRunCommand);
|
|
182
152
|
const mockedGetValidateRunCommand = vi.mocked(getValidateRunCommand);
|
|
183
153
|
|
|
184
154
|
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
@@ -191,13 +161,6 @@ beforeEach(() => {
|
|
|
191
161
|
mockedGetConfigValue.mockReturnValue(false);
|
|
192
162
|
mockedHasSnapshot.mockReturnValue(false);
|
|
193
163
|
mockedIsWorkingDirClean.mockReset();
|
|
194
|
-
mockedGitAddAll.mockReset();
|
|
195
|
-
mockedGitCommit.mockReset();
|
|
196
|
-
mockedGitDiffBinaryAgainstBranch.mockReset();
|
|
197
|
-
mockedGitApplyFromStdin.mockReset();
|
|
198
|
-
mockedGitResetSoft.mockReset();
|
|
199
|
-
mockedGitWriteTree.mockReset();
|
|
200
|
-
mockedGitRestoreStaged.mockReset();
|
|
201
164
|
mockedGetHeadCommitHash.mockReset();
|
|
202
165
|
mockedGitReadTree.mockReset();
|
|
203
166
|
mockedHasLocalCommits.mockReset();
|
|
@@ -207,21 +170,16 @@ beforeEach(() => {
|
|
|
207
170
|
mockedConfirmDestructiveAction.mockReset();
|
|
208
171
|
mockedPrintSuccess.mockReset();
|
|
209
172
|
mockedPrintInfo.mockReset();
|
|
173
|
+
mockedPrintWarning.mockReset();
|
|
210
174
|
mockedGitResetHard.mockReset();
|
|
211
175
|
mockedGitCleanForce.mockReset();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
mockedPrintError.mockReset();
|
|
219
|
-
mockedPrintSeparator.mockReset();
|
|
220
|
-
mockedParseParallelCommands.mockReset();
|
|
221
|
-
mockedRunParallelCommands.mockReset();
|
|
176
|
+
mockedMigrateChangesViaPatch.mockReset().mockReturnValue({ success: true });
|
|
177
|
+
mockedComputeCurrentTreeHash.mockReset().mockReturnValue('treehash');
|
|
178
|
+
mockedSaveCurrentSnapshotTree.mockReset().mockReturnValue('treehash');
|
|
179
|
+
mockedLoadOldSnapshotToStage.mockReset().mockReturnValue({ success: true, stagedTreeHash: '' });
|
|
180
|
+
mockedSwitchToValidateBranch.mockReset().mockImplementation((name: string) => `clawt-validate-${name}`);
|
|
181
|
+
mockedExecuteRunCommand.mockReset();
|
|
222
182
|
mockedGetValidateRunCommand.mockReset();
|
|
223
|
-
// 默认让 parseParallelCommands 返回单命令数组,保持旧测试兼容
|
|
224
|
-
mockedParseParallelCommands.mockImplementation((cmd: string) => [cmd]);
|
|
225
183
|
});
|
|
226
184
|
|
|
227
185
|
describe('registerValidateCommand', () => {
|
|
@@ -244,7 +202,7 @@ describe('handleValidate', () => {
|
|
|
244
202
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
245
203
|
|
|
246
204
|
expect(mockedPrintInfo).toHaveBeenCalled();
|
|
247
|
-
expect(
|
|
205
|
+
expect(mockedMigrateChangesViaPatch).not.toHaveBeenCalled();
|
|
248
206
|
});
|
|
249
207
|
|
|
250
208
|
it('首次 validate:有已提交 commit 且主 worktree 干净', async () => {
|
|
@@ -252,57 +210,34 @@ describe('handleValidate', () => {
|
|
|
252
210
|
mockedIsWorkingDirClean.mockReturnValue(true); // 所有调用都返回 true
|
|
253
211
|
mockedHasLocalCommits.mockReturnValue(true);
|
|
254
212
|
mockedHasSnapshot.mockReturnValue(false);
|
|
255
|
-
mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
|
|
256
|
-
mockedGitWriteTree.mockReturnValue('treehash123');
|
|
257
|
-
mockedGetHeadCommitHash.mockReturnValue('headhash456');
|
|
258
213
|
|
|
259
214
|
const program = new Command();
|
|
260
215
|
program.exitOverride();
|
|
261
216
|
registerValidateCommand(program);
|
|
262
217
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
263
218
|
|
|
264
|
-
expect(
|
|
265
|
-
expect(
|
|
266
|
-
expect(
|
|
219
|
+
expect(mockedSwitchToValidateBranch).toHaveBeenCalledWith('feature', '/repo');
|
|
220
|
+
expect(mockedMigrateChangesViaPatch).toHaveBeenCalledWith('/path/feature', '/repo', 'feature', false);
|
|
221
|
+
expect(mockedSaveCurrentSnapshotTree).toHaveBeenCalledWith('/repo', 'test-project', 'feature');
|
|
267
222
|
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
268
223
|
});
|
|
269
224
|
|
|
270
|
-
it('首次 validate
|
|
225
|
+
it('首次 validate:有未提交修改时传入 hasUncommitted=true', async () => {
|
|
271
226
|
// 主 worktree 干净,目标 worktree 有未提交修改
|
|
272
|
-
mockedIsWorkingDirClean
|
|
273
|
-
.mockReturnValueOnce(true) // 主 worktree 调用(collectStatus 或 handleValidate 首次检查目标)
|
|
274
|
-
.mockReturnValueOnce(false); // 目标 worktree 检查
|
|
275
|
-
mockedHasLocalCommits.mockReturnValue(false); // 无已提交 commit,但有未提交修改
|
|
276
|
-
mockedHasSnapshot.mockReturnValue(false);
|
|
277
|
-
mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
|
|
278
|
-
mockedGitWriteTree.mockReturnValue('treehash');
|
|
279
|
-
mockedGetHeadCommitHash.mockReturnValue('headhash');
|
|
280
|
-
|
|
281
|
-
// 注意:hasUncommitted 来自 !isWorkingDirClean(targetWorktreePath)
|
|
282
|
-
// 这里的 mock 链:
|
|
283
|
-
// 第 1 次调用 isWorkingDirClean: 检查 targetWorktreePath => false(有未提交修改)
|
|
284
|
-
// 但是代码中先检查 isWorkingDirClean(mainWorktreePath)
|
|
285
|
-
// 需要更精确的 mock
|
|
286
|
-
mockedIsWorkingDirClean.mockReset();
|
|
287
227
|
mockedIsWorkingDirClean.mockImplementation((cwd?: string) => {
|
|
288
228
|
if (cwd === '/path/feature') return false; // 目标 worktree 不干净
|
|
289
229
|
return true; // 主 worktree 干净
|
|
290
230
|
});
|
|
291
|
-
// 因为 hasUncommitted 依赖 !isWorkingDirClean(targetWorktreePath),
|
|
292
|
-
// 且 !hasUncommitted && !hasCommitted 需要检查 hasLocalCommits
|
|
293
231
|
mockedHasLocalCommits.mockReturnValue(true); // 让它不走"无变更"路径
|
|
232
|
+
mockedHasSnapshot.mockReturnValue(false);
|
|
294
233
|
|
|
295
234
|
const program = new Command();
|
|
296
235
|
program.exitOverride();
|
|
297
236
|
registerValidateCommand(program);
|
|
298
237
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
299
238
|
|
|
300
|
-
//
|
|
301
|
-
expect(
|
|
302
|
-
expect(mockedGitCommit).toHaveBeenCalledWith('clawt:temp-commit-for-validate', '/path/feature');
|
|
303
|
-
// 撤销临时 commit
|
|
304
|
-
expect(mockedGitResetSoft).toHaveBeenCalledWith(1, '/path/feature');
|
|
305
|
-
expect(mockedGitRestoreStaged).toHaveBeenCalledWith('/path/feature');
|
|
239
|
+
// migrateChangesViaPatch 应接收 hasUncommitted=true
|
|
240
|
+
expect(mockedMigrateChangesViaPatch).toHaveBeenCalledWith('/path/feature', '/repo', 'feature', true);
|
|
306
241
|
});
|
|
307
242
|
});
|
|
308
243
|
|
|
@@ -355,53 +290,50 @@ describe('增量 validate', () => {
|
|
|
355
290
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
356
291
|
mockedHasLocalCommits.mockReturnValue(true);
|
|
357
292
|
mockedHasSnapshot.mockReturnValue(true);
|
|
358
|
-
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash' });
|
|
293
|
+
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash', stagedTreeHash: '' });
|
|
359
294
|
mockedGetHeadCommitHash.mockReturnValue('headhash'); // HEAD 未变化
|
|
360
|
-
|
|
361
|
-
mockedGitWriteTree.mockReturnValue('newtree');
|
|
295
|
+
mockedComputeCurrentTreeHash.mockReturnValue('newtree');
|
|
362
296
|
|
|
363
297
|
const program = new Command();
|
|
364
298
|
program.exitOverride();
|
|
365
299
|
registerValidateCommand(program);
|
|
366
300
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
367
301
|
|
|
368
|
-
expect(
|
|
302
|
+
expect(mockedSwitchToValidateBranch).toHaveBeenCalledWith('feature', '/repo');
|
|
303
|
+
expect(mockedMigrateChangesViaPatch).toHaveBeenCalled();
|
|
304
|
+
expect(mockedComputeCurrentTreeHash).toHaveBeenCalledWith('/repo');
|
|
305
|
+
// 有新变更(newtree !== oldtree),调用 loadOldSnapshotToStage
|
|
306
|
+
expect(mockedLoadOldSnapshotToStage).toHaveBeenCalledWith('oldtree', 'headhash', 'headhash', '/repo');
|
|
369
307
|
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
370
308
|
});
|
|
371
309
|
|
|
372
|
-
it('HEAD
|
|
310
|
+
it('HEAD 变化时调用 loadOldSnapshotToStage', async () => {
|
|
373
311
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
374
312
|
mockedHasLocalCommits.mockReturnValue(true);
|
|
375
313
|
mockedHasSnapshot.mockReturnValue(true);
|
|
376
|
-
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'oldhead' });
|
|
314
|
+
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'oldhead', stagedTreeHash: '' });
|
|
377
315
|
mockedGetHeadCommitHash.mockReturnValue('newhead'); // HEAD 已变化
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
mockedGetCommitTreeHash.mockReturnValue('oldheadtree');
|
|
381
|
-
mockedGitDiffTree.mockReturnValue(Buffer.from('old change patch'));
|
|
382
|
-
mockedGitApplyCachedCheck.mockReturnValue(true);
|
|
316
|
+
mockedComputeCurrentTreeHash.mockReturnValue('newtree');
|
|
317
|
+
mockedLoadOldSnapshotToStage.mockReturnValue({ success: true, stagedTreeHash: 'staged123' });
|
|
383
318
|
|
|
384
319
|
const program = new Command();
|
|
385
320
|
program.exitOverride();
|
|
386
321
|
registerValidateCommand(program);
|
|
387
322
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
388
323
|
|
|
389
|
-
expect(
|
|
390
|
-
expect(
|
|
391
|
-
expect(
|
|
324
|
+
expect(mockedLoadOldSnapshotToStage).toHaveBeenCalledWith('oldtree', 'oldhead', 'newhead', '/repo');
|
|
325
|
+
expect(mockedWriteSnapshot).toHaveBeenCalledWith('test-project', 'feature', 'newtree', 'newhead', 'staged123');
|
|
326
|
+
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
392
327
|
});
|
|
393
328
|
|
|
394
|
-
it('
|
|
329
|
+
it('loadOldSnapshotToStage 失败时降级为全量模式', async () => {
|
|
395
330
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
396
331
|
mockedHasLocalCommits.mockReturnValue(true);
|
|
397
332
|
mockedHasSnapshot.mockReturnValue(true);
|
|
398
|
-
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'oldhead' });
|
|
333
|
+
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'oldhead', stagedTreeHash: '' });
|
|
399
334
|
mockedGetHeadCommitHash.mockReturnValue('newhead');
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
mockedGetCommitTreeHash.mockReturnValue('oldheadtree');
|
|
403
|
-
mockedGitDiffTree.mockReturnValue(Buffer.from('conflicting patch'));
|
|
404
|
-
mockedGitApplyCachedCheck.mockReturnValue(false); // 有冲突
|
|
335
|
+
mockedComputeCurrentTreeHash.mockReturnValue('newtree');
|
|
336
|
+
mockedLoadOldSnapshotToStage.mockReturnValue({ success: false, stagedTreeHash: '' });
|
|
405
337
|
|
|
406
338
|
const program = new Command();
|
|
407
339
|
program.exitOverride();
|
|
@@ -409,25 +341,27 @@ describe('增量 validate', () => {
|
|
|
409
341
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
410
342
|
|
|
411
343
|
expect(mockedPrintWarning).toHaveBeenCalled();
|
|
412
|
-
expect(
|
|
344
|
+
expect(mockedWriteSnapshot).toHaveBeenCalledWith('test-project', 'feature', 'newtree', 'newhead', '');
|
|
345
|
+
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
413
346
|
});
|
|
414
347
|
|
|
415
|
-
it('
|
|
348
|
+
it('无新变更时恢复旧暂存区状态', async () => {
|
|
416
349
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
417
350
|
mockedHasLocalCommits.mockReturnValue(true);
|
|
418
351
|
mockedHasSnapshot.mockReturnValue(true);
|
|
419
|
-
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash' });
|
|
352
|
+
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash', stagedTreeHash: 'staged456' });
|
|
420
353
|
mockedGetHeadCommitHash.mockReturnValue('headhash');
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
mockedGitReadTree.mockImplementation(() => { throw new Error('gc reclaimed'); });
|
|
354
|
+
// tree hash 相同且 HEAD 未变化 → 无新变更
|
|
355
|
+
mockedComputeCurrentTreeHash.mockReturnValue('oldtree');
|
|
424
356
|
|
|
425
357
|
const program = new Command();
|
|
426
358
|
program.exitOverride();
|
|
427
359
|
registerValidateCommand(program);
|
|
428
360
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
429
361
|
|
|
430
|
-
expect(
|
|
362
|
+
expect(mockedGitReadTree).toHaveBeenCalledWith('staged456', '/repo');
|
|
363
|
+
expect(mockedLoadOldSnapshotToStage).not.toHaveBeenCalled();
|
|
364
|
+
expect(mockedPrintInfo).toHaveBeenCalled();
|
|
431
365
|
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
432
366
|
});
|
|
433
367
|
});
|
|
@@ -438,62 +372,17 @@ describe('--run 选项', () => {
|
|
|
438
372
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
439
373
|
mockedHasLocalCommits.mockReturnValue(true);
|
|
440
374
|
mockedHasSnapshot.mockReturnValue(false);
|
|
441
|
-
mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
|
|
442
|
-
mockedGitWriteTree.mockReturnValue('treehash');
|
|
443
|
-
mockedGetHeadCommitHash.mockReturnValue('headhash');
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** 构造 spawnSync 返回值的辅助函数 */
|
|
447
|
-
function createSpawnResult(overrides: { status?: number | null; error?: Error }) {
|
|
448
|
-
return {
|
|
449
|
-
pid: 0,
|
|
450
|
-
output: [],
|
|
451
|
-
stdout: Buffer.alloc(0),
|
|
452
|
-
stderr: Buffer.alloc(0),
|
|
453
|
-
status: overrides.status ?? null,
|
|
454
|
-
signal: null,
|
|
455
|
-
error: overrides.error,
|
|
456
|
-
};
|
|
457
375
|
}
|
|
458
376
|
|
|
459
377
|
it('validate 成功后执行 --run 指定的命令', async () => {
|
|
460
378
|
setupSuccessfulFirstValidate();
|
|
461
|
-
mockedRunCommandInherited.mockReturnValue(createSpawnResult({ status: 0 }));
|
|
462
379
|
|
|
463
380
|
const program = new Command();
|
|
464
381
|
program.exitOverride();
|
|
465
382
|
registerValidateCommand(program);
|
|
466
383
|
await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
|
|
467
384
|
|
|
468
|
-
expect(
|
|
469
|
-
expect(mockedPrintSuccess).toHaveBeenCalledTimes(2);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it('--run 命令失败时输出错误信息但不抛异常', async () => {
|
|
473
|
-
setupSuccessfulFirstValidate();
|
|
474
|
-
mockedRunCommandInherited.mockReturnValue(createSpawnResult({ status: 1 }));
|
|
475
|
-
|
|
476
|
-
const program = new Command();
|
|
477
|
-
program.exitOverride();
|
|
478
|
-
registerValidateCommand(program);
|
|
479
|
-
await program.parseAsync(['validate', '-b', 'feature', '--run', 'npm test'], { from: 'user' });
|
|
480
|
-
|
|
481
|
-
expect(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
|
|
482
|
-
expect(mockedPrintError).toHaveBeenCalled();
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it('--run 命令进程启动失败时输出错误信息', async () => {
|
|
486
|
-
setupSuccessfulFirstValidate();
|
|
487
|
-
mockedRunCommandInherited.mockReturnValue(createSpawnResult({ error: new Error('spawn ENOENT') }));
|
|
488
|
-
|
|
489
|
-
const program = new Command();
|
|
490
|
-
program.exitOverride();
|
|
491
|
-
registerValidateCommand(program);
|
|
492
|
-
await program.parseAsync(['validate', '-b', 'feature', '-r', 'nonexistent'], { from: 'user' });
|
|
493
|
-
|
|
494
|
-
expect(mockedPrintError).toHaveBeenCalled();
|
|
495
|
-
// validate 成功 + run 出错,printSuccess 只被调用 1 次(validate 成功)
|
|
496
|
-
expect(mockedPrintSuccess).toHaveBeenCalledTimes(1);
|
|
385
|
+
expect(mockedExecuteRunCommand).toHaveBeenCalledWith('npm test', '/repo');
|
|
497
386
|
});
|
|
498
387
|
|
|
499
388
|
it('未传 --run 时不执行任何命令', async () => {
|
|
@@ -504,7 +393,7 @@ describe('--run 选项', () => {
|
|
|
504
393
|
registerValidateCommand(program);
|
|
505
394
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
506
395
|
|
|
507
|
-
expect(
|
|
396
|
+
expect(mockedExecuteRunCommand).not.toHaveBeenCalled();
|
|
508
397
|
});
|
|
509
398
|
|
|
510
399
|
it('--clean 与 --run 同时传入时只执行 clean 不执行 run', async () => {
|
|
@@ -517,25 +406,23 @@ describe('--run 选项', () => {
|
|
|
517
406
|
await program.parseAsync(['validate', '--clean', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
|
|
518
407
|
|
|
519
408
|
expect(mockedRemoveSnapshot).toHaveBeenCalled();
|
|
520
|
-
expect(
|
|
409
|
+
expect(mockedExecuteRunCommand).not.toHaveBeenCalled();
|
|
521
410
|
});
|
|
522
411
|
|
|
523
412
|
it('增量 validate 成功后也执行 --run 命令', async () => {
|
|
524
413
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
525
414
|
mockedHasLocalCommits.mockReturnValue(true);
|
|
526
415
|
mockedHasSnapshot.mockReturnValue(true);
|
|
527
|
-
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash' });
|
|
416
|
+
mockedReadSnapshot.mockReturnValue({ treeHash: 'oldtree', headCommitHash: 'headhash', stagedTreeHash: '' });
|
|
528
417
|
mockedGetHeadCommitHash.mockReturnValue('headhash');
|
|
529
|
-
|
|
530
|
-
mockedGitWriteTree.mockReturnValue('newtree');
|
|
531
|
-
mockedRunCommandInherited.mockReturnValue(createSpawnResult({ status: 0 }));
|
|
418
|
+
mockedComputeCurrentTreeHash.mockReturnValue('newtree');
|
|
532
419
|
|
|
533
420
|
const program = new Command();
|
|
534
421
|
program.exitOverride();
|
|
535
422
|
registerValidateCommand(program);
|
|
536
423
|
await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
|
|
537
424
|
|
|
538
|
-
expect(
|
|
425
|
+
expect(mockedExecuteRunCommand).toHaveBeenCalledWith('npm test', '/repo');
|
|
539
426
|
});
|
|
540
427
|
|
|
541
428
|
it('目标分支无变更时不执行 --run', async () => {
|
|
@@ -547,93 +434,7 @@ describe('--run 选项', () => {
|
|
|
547
434
|
registerValidateCommand(program);
|
|
548
435
|
await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
|
|
549
436
|
|
|
550
|
-
expect(
|
|
551
|
-
});
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
describe('--run 并行命令', () => {
|
|
555
|
-
/** 设置首次 validate 成功的公共 mock */
|
|
556
|
-
function setupSuccessfulFirstValidate(): void {
|
|
557
|
-
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
558
|
-
mockedHasLocalCommits.mockReturnValue(true);
|
|
559
|
-
mockedHasSnapshot.mockReturnValue(false);
|
|
560
|
-
mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
|
|
561
|
-
mockedGitWriteTree.mockReturnValue('treehash');
|
|
562
|
-
mockedGetHeadCommitHash.mockReturnValue('headhash');
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
it('& 分隔的命令触发并行执行', async () => {
|
|
566
|
-
setupSuccessfulFirstValidate();
|
|
567
|
-
mockedParseParallelCommands.mockReturnValue(['pnpm test', 'pnpm build']);
|
|
568
|
-
mockedRunParallelCommands.mockResolvedValue([
|
|
569
|
-
{ command: 'pnpm test', exitCode: 0 },
|
|
570
|
-
{ command: 'pnpm build', exitCode: 0 },
|
|
571
|
-
]);
|
|
572
|
-
|
|
573
|
-
const program = new Command();
|
|
574
|
-
program.exitOverride();
|
|
575
|
-
registerValidateCommand(program);
|
|
576
|
-
await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm test & pnpm build'], { from: 'user' });
|
|
577
|
-
|
|
578
|
-
// 应该调用并行执行而非同步执行
|
|
579
|
-
expect(mockedRunParallelCommands).toHaveBeenCalledWith(['pnpm test', 'pnpm build'], { cwd: '/repo' });
|
|
580
|
-
expect(mockedRunCommandInherited).not.toHaveBeenCalled();
|
|
581
|
-
// 全部成功,printSuccess 被调用(validate 成功 + 各命令成功 + 汇总成功)
|
|
582
|
-
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
it('并行执行部分失败时输出错误汇总', async () => {
|
|
586
|
-
setupSuccessfulFirstValidate();
|
|
587
|
-
mockedParseParallelCommands.mockReturnValue(['pnpm test', 'pnpm build']);
|
|
588
|
-
mockedRunParallelCommands.mockResolvedValue([
|
|
589
|
-
{ command: 'pnpm test', exitCode: 1 },
|
|
590
|
-
{ command: 'pnpm build', exitCode: 0 },
|
|
591
|
-
]);
|
|
592
|
-
|
|
593
|
-
const program = new Command();
|
|
594
|
-
program.exitOverride();
|
|
595
|
-
registerValidateCommand(program);
|
|
596
|
-
await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm test & pnpm build'], { from: 'user' });
|
|
597
|
-
|
|
598
|
-
expect(mockedRunParallelCommands).toHaveBeenCalled();
|
|
599
|
-
// 部分失败,应有错误输出
|
|
600
|
-
expect(mockedPrintError).toHaveBeenCalled();
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
it('单命令走原有同步路径不触发并行', async () => {
|
|
604
|
-
setupSuccessfulFirstValidate();
|
|
605
|
-
mockedParseParallelCommands.mockReturnValue(['npm test']);
|
|
606
|
-
mockedRunCommandInherited.mockReturnValue({
|
|
607
|
-
pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
|
|
608
|
-
status: 0, signal: null, error: undefined,
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
const program = new Command();
|
|
612
|
-
program.exitOverride();
|
|
613
|
-
registerValidateCommand(program);
|
|
614
|
-
await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
|
|
615
|
-
|
|
616
|
-
// 单命令应该走同步路径
|
|
617
|
-
expect(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
|
|
618
|
-
expect(mockedRunParallelCommands).not.toHaveBeenCalled();
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
it('&& 命令不触发并行执行', async () => {
|
|
622
|
-
setupSuccessfulFirstValidate();
|
|
623
|
-
mockedParseParallelCommands.mockReturnValue(['pnpm lint && pnpm test']);
|
|
624
|
-
mockedRunCommandInherited.mockReturnValue({
|
|
625
|
-
pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
|
|
626
|
-
status: 0, signal: null, error: undefined,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
const program = new Command();
|
|
630
|
-
program.exitOverride();
|
|
631
|
-
registerValidateCommand(program);
|
|
632
|
-
await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm lint && pnpm test'], { from: 'user' });
|
|
633
|
-
|
|
634
|
-
// && 不拆分,走同步路径
|
|
635
|
-
expect(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm lint && pnpm test', { cwd: '/repo' });
|
|
636
|
-
expect(mockedRunParallelCommands).not.toHaveBeenCalled();
|
|
437
|
+
expect(mockedExecuteRunCommand).not.toHaveBeenCalled();
|
|
637
438
|
});
|
|
638
439
|
});
|
|
639
440
|
|
|
@@ -643,18 +444,11 @@ describe('配置读取 fallback(resolveRunCommand)', () => {
|
|
|
643
444
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
644
445
|
mockedHasLocalCommits.mockReturnValue(true);
|
|
645
446
|
mockedHasSnapshot.mockReturnValue(false);
|
|
646
|
-
mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
|
|
647
|
-
mockedGitWriteTree.mockReturnValue('treehash');
|
|
648
|
-
mockedGetHeadCommitHash.mockReturnValue('headhash');
|
|
649
447
|
}
|
|
650
448
|
|
|
651
449
|
it('未传 -r 但项目配置有 validateRunCommand 时从配置读取执行', async () => {
|
|
652
450
|
setupSuccessfulFirstValidate();
|
|
653
451
|
mockedGetValidateRunCommand.mockReturnValue('pnpm test');
|
|
654
|
-
mockedRunCommandInherited.mockReturnValue({
|
|
655
|
-
pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
|
|
656
|
-
status: 0, signal: null, error: undefined,
|
|
657
|
-
});
|
|
658
452
|
|
|
659
453
|
const program = new Command();
|
|
660
454
|
program.exitOverride();
|
|
@@ -662,16 +456,12 @@ describe('配置读取 fallback(resolveRunCommand)', () => {
|
|
|
662
456
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
663
457
|
|
|
664
458
|
// 应该从配置读取命令并执行
|
|
665
|
-
expect(
|
|
459
|
+
expect(mockedExecuteRunCommand).toHaveBeenCalledWith('pnpm test', '/repo');
|
|
666
460
|
});
|
|
667
461
|
|
|
668
462
|
it('传了 -r 时以用户参数为准,忽略项目配置', async () => {
|
|
669
463
|
setupSuccessfulFirstValidate();
|
|
670
464
|
mockedGetValidateRunCommand.mockReturnValue('pnpm test');
|
|
671
|
-
mockedRunCommandInherited.mockReturnValue({
|
|
672
|
-
pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
|
|
673
|
-
status: 0, signal: null, error: undefined,
|
|
674
|
-
});
|
|
675
465
|
|
|
676
466
|
const program = new Command();
|
|
677
467
|
program.exitOverride();
|
|
@@ -679,7 +469,7 @@ describe('配置读取 fallback(resolveRunCommand)', () => {
|
|
|
679
469
|
await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm build'], { from: 'user' });
|
|
680
470
|
|
|
681
471
|
// 应该使用用户传入的命令,而非配置中的
|
|
682
|
-
expect(
|
|
472
|
+
expect(mockedExecuteRunCommand).toHaveBeenCalledWith('pnpm build', '/repo');
|
|
683
473
|
});
|
|
684
474
|
|
|
685
475
|
it('未传 -r 且项目配置无 validateRunCommand 时不执行命令', async () => {
|
|
@@ -692,6 +482,6 @@ describe('配置读取 fallback(resolveRunCommand)', () => {
|
|
|
692
482
|
await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
|
|
693
483
|
|
|
694
484
|
// 没有命令可执行
|
|
695
|
-
expect(
|
|
485
|
+
expect(mockedExecuteRunCommand).not.toHaveBeenCalled();
|
|
696
486
|
});
|
|
697
487
|
});
|
|
@@ -106,10 +106,10 @@ describe('readSnapshotTreeHash', () => {
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
describe('writeSnapshot', () => {
|
|
109
|
-
it('
|
|
109
|
+
it('正确写入三个文件', () => {
|
|
110
110
|
writeSnapshot('proj', 'branch', 'tree123', 'head456');
|
|
111
111
|
expect(mockedEnsureDir).toHaveBeenCalledWith('/tmp/test-snapshots/proj');
|
|
112
|
-
expect(mockedWriteFileSync).toHaveBeenCalledTimes(
|
|
112
|
+
expect(mockedWriteFileSync).toHaveBeenCalledTimes(3);
|
|
113
113
|
expect(mockedWriteFileSync).toHaveBeenCalledWith(
|
|
114
114
|
'/tmp/test-snapshots/proj/branch.tree',
|
|
115
115
|
'tree123',
|
|
@@ -120,6 +120,11 @@ describe('writeSnapshot', () => {
|
|
|
120
120
|
'head456',
|
|
121
121
|
'utf-8',
|
|
122
122
|
);
|
|
123
|
+
expect(mockedWriteFileSync).toHaveBeenCalledWith(
|
|
124
|
+
'/tmp/test-snapshots/proj/branch.staged',
|
|
125
|
+
'',
|
|
126
|
+
'utf-8',
|
|
127
|
+
);
|
|
123
128
|
});
|
|
124
129
|
});
|
|
125
130
|
|
|
@@ -127,7 +132,7 @@ describe('removeSnapshot', () => {
|
|
|
127
132
|
it('删除存在的文件', () => {
|
|
128
133
|
mockedExistsSync.mockReturnValue(true);
|
|
129
134
|
removeSnapshot('proj', 'branch');
|
|
130
|
-
expect(mockedUnlinkSync).toHaveBeenCalledTimes(
|
|
135
|
+
expect(mockedUnlinkSync).toHaveBeenCalledTimes(3);
|
|
131
136
|
});
|
|
132
137
|
|
|
133
138
|
it('文件不存在时不抛错', () => {
|