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.
@@ -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,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
- runCommandInherited,
140
- printError,
141
- printSeparator,
142
- parseParallelCommands,
143
- runParallelCommands,
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 mockedGetCommitTreeHash = vi.mocked(getCommitTreeHash);
173
- const mockedGitDiffTree = vi.mocked(gitDiffTree);
174
- const mockedGitApplyCachedCheck = vi.mocked(gitApplyCachedCheck);
175
- const mockedGitApplyCachedFromStdin = vi.mocked(gitApplyCachedFromStdin);
176
- const mockedPrintWarning = vi.mocked(printWarning);
177
- const mockedRunCommandInherited = vi.mocked(runCommandInherited);
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
- mockedGetCommitTreeHash.mockReset();
213
- mockedGitDiffTree.mockReset();
214
- mockedGitApplyCachedCheck.mockReset();
215
- mockedGitApplyCachedFromStdin.mockReset();
216
- mockedPrintWarning.mockReset();
217
- mockedRunCommandInherited.mockReset();
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(mockedGitDiffBinaryAgainstBranch).not.toHaveBeenCalled();
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(mockedGitDiffBinaryAgainstBranch).toHaveBeenCalledWith('feature', '/repo');
265
- expect(mockedGitApplyFromStdin).toHaveBeenCalled();
266
- 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');
267
222
  expect(mockedPrintSuccess).toHaveBeenCalled();
268
223
  });
269
224
 
270
- it('首次 validate:有未提交修改时做临时 commit 后撤销', async () => {
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
- // 临时 commit
301
- expect(mockedGitAddAll).toHaveBeenCalledWith('/path/feature');
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
- mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
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(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');
369
307
  expect(mockedPrintSuccess).toHaveBeenCalled();
370
308
  });
371
309
 
372
- it('HEAD 变化时通过 patch 重放旧变更到暂存区', async () => {
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
- mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
379
- mockedGitWriteTree.mockReturnValue('newtree');
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(mockedGetCommitTreeHash).toHaveBeenCalledWith('oldhead', '/repo');
390
- expect(mockedGitDiffTree).toHaveBeenCalledWith('oldheadtree', 'oldtree', '/repo');
391
- 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();
392
327
  });
393
328
 
394
- it('旧变更 patch 有冲突时降级为全量模式', async () => {
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
- mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
401
- mockedGitWriteTree.mockReturnValue('newtree');
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(mockedGitApplyCachedFromStdin).not.toHaveBeenCalled();
344
+ expect(mockedWriteSnapshot).toHaveBeenCalledWith('test-project', 'feature', 'newtree', 'newhead', '');
345
+ expect(mockedPrintSuccess).toHaveBeenCalled();
413
346
  });
414
347
 
415
- it('read-tree 失败时降级为全量模式', async () => {
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
- mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
422
- mockedGitWriteTree.mockReturnValue('newtree');
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(mockedPrintWarning).toHaveBeenCalled();
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(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
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(mockedRunCommandInherited).not.toHaveBeenCalled();
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(mockedRunCommandInherited).not.toHaveBeenCalled();
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
- mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
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(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
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(mockedRunCommandInherited).not.toHaveBeenCalled();
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(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm test', { cwd: '/repo' });
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(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm build', { cwd: '/repo' });
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(mockedRunCommandInherited).not.toHaveBeenCalled();
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(2);
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(2);
135
+ expect(mockedUnlinkSync).toHaveBeenCalledTimes(3);
131
136
  });
132
137
 
133
138
  it('文件不存在时不抛错', () => {