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