clawt 2.16.1 → 2.16.3

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.
@@ -17,7 +17,11 @@ vi.mock('../../../src/constants/index.js', () => ({
17
17
  STATUS_CHANGE_UNCOMMITTED: '未提交',
18
18
  STATUS_CHANGE_CONFLICT: '冲突',
19
19
  STATUS_CHANGE_CLEAN: '干净',
20
- STATUS_SNAPSHOT_ORPHANED: '(孤立)',
20
+ STATUS_SNAPSHOT_ORPHANED: (count: number) => `其中 ${count} 个快照对应的 worktree 已不存在`,
21
+ STATUS_CREATED_AT: (relativeTime: string) => `创建于 ${relativeTime}`,
22
+ STATUS_NO_DIVERGED_COMMITS: '尚无分叉提交',
23
+ STATUS_LAST_VALIDATED: (relativeTime: string) => `上次验证: ${relativeTime}`,
24
+ STATUS_NOT_VALIDATED: '✗ 未验证',
21
25
  },
22
26
  }));
23
27
 
@@ -32,8 +36,10 @@ vi.mock('../../../src/utils/index.js', () => ({
32
36
  getDiffStat: vi.fn(),
33
37
  hasMergeConflict: vi.fn(),
34
38
  hasLocalCommits: vi.fn(),
35
- hasSnapshot: vi.fn(),
39
+ getSnapshotModifiedTime: vi.fn(),
36
40
  getProjectSnapshotBranches: vi.fn(),
41
+ getBranchCreatedAt: vi.fn(),
42
+ formatRelativeTime: vi.fn(),
37
43
  printInfo: vi.fn(),
38
44
  printDoubleSeparator: vi.fn(),
39
45
  printSeparator: vi.fn(),
@@ -50,8 +56,10 @@ import {
50
56
  getDiffStat,
51
57
  hasMergeConflict,
52
58
  hasLocalCommits,
53
- hasSnapshot,
59
+ getSnapshotModifiedTime,
54
60
  getProjectSnapshotBranches,
61
+ getBranchCreatedAt,
62
+ formatRelativeTime,
55
63
  printInfo,
56
64
  } from '../../../src/utils/index.js';
57
65
 
@@ -64,8 +72,10 @@ const mockedGetCommitCountBehind = vi.mocked(getCommitCountBehind);
64
72
  const mockedGetDiffStat = vi.mocked(getDiffStat);
65
73
  const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
66
74
  const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
67
- const mockedHasSnapshot = vi.mocked(hasSnapshot);
75
+ const mockedGetSnapshotModifiedTime = vi.mocked(getSnapshotModifiedTime);
68
76
  const mockedGetProjectSnapshotBranches = vi.mocked(getProjectSnapshotBranches);
77
+ const mockedGetBranchCreatedAt = vi.mocked(getBranchCreatedAt);
78
+ const mockedFormatRelativeTime = vi.mocked(formatRelativeTime);
69
79
  const mockedPrintInfo = vi.mocked(printInfo);
70
80
 
71
81
  beforeEach(() => {
@@ -79,7 +89,9 @@ beforeEach(() => {
79
89
  mockedGetDiffStat.mockReturnValue({ insertions: 0, deletions: 0 });
80
90
  mockedHasMergeConflict.mockReturnValue(false);
81
91
  mockedHasLocalCommits.mockReturnValue(false);
82
- mockedHasSnapshot.mockReturnValue(false);
92
+ mockedGetSnapshotModifiedTime.mockReturnValue(null);
93
+ mockedGetBranchCreatedAt.mockReturnValue(null);
94
+ mockedFormatRelativeTime.mockReturnValue('3 天前');
83
95
  mockedPrintInfo.mockReset();
84
96
  });
85
97
 
@@ -167,7 +179,7 @@ describe('handleStatus', () => {
167
179
  expect(parsed.main.isClean).toBe(false);
168
180
  });
169
181
 
170
- it('存在快照时标识孤立快照', () => {
182
+ it('快照摘要包含总数和孤立数', () => {
171
183
  mockedGetProjectSnapshotBranches.mockReturnValue(['feature', 'deleted-branch']);
172
184
  mockedGetProjectWorktrees.mockReturnValue([
173
185
  { path: '/path/feature', branch: 'feature' },
@@ -184,9 +196,8 @@ describe('handleStatus', () => {
184
196
  try { JSON.parse(call[0]); return true; } catch { return false; }
185
197
  });
186
198
  const parsed = JSON.parse(jsonCall![0]);
187
- expect(parsed.snapshots).toHaveLength(2);
188
- expect(parsed.snapshots[0]).toEqual({ branch: 'feature', worktreeExists: true });
189
- expect(parsed.snapshots[1]).toEqual({ branch: 'deleted-branch', worktreeExists: false });
199
+ expect(parsed.snapshots.total).toBe(2);
200
+ expect(parsed.snapshots.orphaned).toBe(1);
190
201
  });
191
202
 
192
203
  it('uncommitted 变更状态正确检测', () => {
@@ -211,4 +222,112 @@ describe('handleStatus', () => {
211
222
  const parsed = JSON.parse(jsonCall![0]);
212
223
  expect(parsed.worktrees[0].changeStatus).toBe('uncommitted');
213
224
  });
225
+
226
+ it('createdAt 字段包含在 JSON 输出中', () => {
227
+ mockedGetProjectWorktrees.mockReturnValue([
228
+ { path: '/path/feature', branch: 'feature' },
229
+ ]);
230
+ mockedGetBranchCreatedAt.mockReturnValue('2026-02-20T14:30:00+08:00');
231
+
232
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
233
+
234
+ const program = new Command();
235
+ program.exitOverride();
236
+ registerStatusCommand(program);
237
+ program.parse(['status', '--json'], { from: 'user' });
238
+
239
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
240
+ try { JSON.parse(call[0]); return true; } catch { return false; }
241
+ });
242
+ const parsed = JSON.parse(jsonCall![0]);
243
+ expect(parsed.worktrees[0].createdAt).toBe('2026-02-20T14:30:00+08:00');
244
+ });
245
+
246
+ it('snapshotTime 字段包含在 JSON 输出中', () => {
247
+ mockedGetProjectWorktrees.mockReturnValue([
248
+ { path: '/path/feature', branch: 'feature' },
249
+ ]);
250
+ mockedGetSnapshotModifiedTime.mockReturnValue('2026-02-22T10:00:00.000Z');
251
+
252
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
253
+
254
+ const program = new Command();
255
+ program.exitOverride();
256
+ registerStatusCommand(program);
257
+ program.parse(['status', '--json'], { from: 'user' });
258
+
259
+ const jsonCall = consoleSpy.mock.calls.find((call) => {
260
+ try { JSON.parse(call[0]); return true; } catch { return false; }
261
+ });
262
+ const parsed = JSON.parse(jsonCall![0]);
263
+ expect(parsed.worktrees[0].snapshotTime).toBe('2026-02-22T10:00:00.000Z');
264
+ });
265
+
266
+ it('文本模式显示分支创建时间', () => {
267
+ mockedGetProjectWorktrees.mockReturnValue([
268
+ { path: '/path/feature', branch: 'feature' },
269
+ ]);
270
+ mockedGetBranchCreatedAt.mockReturnValue('2026-02-20T14:30:00+08:00');
271
+ mockedFormatRelativeTime.mockReturnValue('2 天前');
272
+
273
+ const program = new Command();
274
+ program.exitOverride();
275
+ registerStatusCommand(program);
276
+ program.parse(['status'], { from: 'user' });
277
+
278
+ const printedLines = mockedPrintInfo.mock.calls.map((call) => call[0]);
279
+ const createdAtLine = printedLines.find((line) => line.includes('创建于'));
280
+ expect(createdAtLine).toBeDefined();
281
+ });
282
+
283
+ it('文本模式 createdAt 为 null 时不显示创建时间', () => {
284
+ mockedGetProjectWorktrees.mockReturnValue([
285
+ { path: '/path/feature', branch: 'feature' },
286
+ ]);
287
+ mockedGetBranchCreatedAt.mockReturnValue(null);
288
+
289
+ const program = new Command();
290
+ program.exitOverride();
291
+ registerStatusCommand(program);
292
+ program.parse(['status'], { from: 'user' });
293
+
294
+ const printedLines = mockedPrintInfo.mock.calls.map((call) => call[0]);
295
+ const createdAtLine = printedLines.find((line) => line.includes('创建于'));
296
+ expect(createdAtLine).toBeUndefined();
297
+ });
298
+
299
+ it('文本模式无快照时显示未验证警示', () => {
300
+ mockedGetProjectWorktrees.mockReturnValue([
301
+ { path: '/path/feature', branch: 'feature' },
302
+ ]);
303
+ mockedGetSnapshotModifiedTime.mockReturnValue(null);
304
+
305
+ const program = new Command();
306
+ program.exitOverride();
307
+ registerStatusCommand(program);
308
+ program.parse(['status'], { from: 'user' });
309
+
310
+ const printedLines = mockedPrintInfo.mock.calls.map((call) => call[0]);
311
+ const unverifiedLine = printedLines.find((line) => line.includes('未验证'));
312
+ expect(unverifiedLine).toBeDefined();
313
+ });
314
+
315
+ it('文本模式有快照时显示上次验证时间', () => {
316
+ mockedGetProjectWorktrees.mockReturnValue([
317
+ { path: '/path/feature', branch: 'feature' },
318
+ ]);
319
+ mockedGetSnapshotModifiedTime.mockReturnValue('2026-02-22T10:00:00.000Z');
320
+ mockedFormatRelativeTime.mockReturnValue('5 小时前');
321
+
322
+ const program = new Command();
323
+ program.exitOverride();
324
+ registerStatusCommand(program);
325
+ program.parse(['status'], { from: 'user' });
326
+
327
+ const printedLines = mockedPrintInfo.mock.calls.map((call) => call[0]);
328
+ const validatedLine = printedLines.find((line) => line.includes('上次验证'));
329
+ expect(validatedLine).toBeDefined();
330
+ const unverifiedLine = printedLines.find((line) => line.includes('未验证'));
331
+ expect(unverifiedLine).toBeUndefined();
332
+ });
214
333
  });
@@ -32,6 +32,13 @@ vi.mock('../../../src/constants/index.js', () => ({
32
32
  VALIDATE_RUN_SUCCESS: (cmd: string) => `✓ 命令执行完成: ${cmd}`,
33
33
  VALIDATE_RUN_FAILED: (cmd: string, code: number) => `✗ 命令失败: ${cmd},退出码: ${code}`,
34
34
  VALIDATE_RUN_ERROR: (cmd: string, msg: string) => `✗ 命令出错: ${msg}`,
35
+ VALIDATE_PARALLEL_RUN_START: (count: number) => `正在并行执行 ${count} 个命令...`,
36
+ VALIDATE_PARALLEL_CMD_START: (index: number, total: number, cmd: string) => `[${index}/${total}] ${cmd}`,
37
+ VALIDATE_PARALLEL_RUN_ALL_SUCCESS: (count: number) => `✓ 全部 ${count} 个命令执行成功`,
38
+ VALIDATE_PARALLEL_RUN_SUMMARY: (s: number, f: number) => `共 ${s + f} 个命令,${s} 个成功,${f} 个失败`,
39
+ VALIDATE_PARALLEL_CMD_SUCCESS: (cmd: string) => ` ✓ ${cmd}`,
40
+ VALIDATE_PARALLEL_CMD_FAILED: (cmd: string, code: number) => ` ✗ ${cmd}(退出码: ${code})`,
41
+ VALIDATE_PARALLEL_CMD_ERROR: (cmd: string, msg: string) => ` ✗ ${cmd}(错误: ${msg})`,
35
42
  SEPARATOR: '────',
36
43
  },
37
44
  }));
@@ -79,6 +86,8 @@ vi.mock('../../../src/utils/index.js', () => ({
79
86
  runCommandInherited: vi.fn(),
80
87
  printError: vi.fn(),
81
88
  printSeparator: vi.fn(),
89
+ parseParallelCommands: vi.fn(),
90
+ runParallelCommands: vi.fn(),
82
91
  }));
83
92
 
84
93
  import { registerValidateCommand } from '../../../src/commands/validate.js';
@@ -116,6 +125,8 @@ import {
116
125
  runCommandInherited,
117
126
  printError,
118
127
  printSeparator,
128
+ parseParallelCommands,
129
+ runParallelCommands,
119
130
  } from '../../../src/utils/index.js';
120
131
 
121
132
  const mockedGetProjectName = vi.mocked(getProjectName);
@@ -151,6 +162,8 @@ const mockedPrintWarning = vi.mocked(printWarning);
151
162
  const mockedRunCommandInherited = vi.mocked(runCommandInherited);
152
163
  const mockedPrintError = vi.mocked(printError);
153
164
  const mockedPrintSeparator = vi.mocked(printSeparator);
165
+ const mockedParseParallelCommands = vi.mocked(parseParallelCommands);
166
+ const mockedRunParallelCommands = vi.mocked(runParallelCommands);
154
167
 
155
168
  const worktree = { path: '/path/feature', branch: 'feature' };
156
169
 
@@ -188,6 +201,10 @@ beforeEach(() => {
188
201
  mockedRunCommandInherited.mockReset();
189
202
  mockedPrintError.mockReset();
190
203
  mockedPrintSeparator.mockReset();
204
+ mockedParseParallelCommands.mockReset();
205
+ mockedRunParallelCommands.mockReset();
206
+ // 默认让 parseParallelCommands 返回单命令数组,保持旧测试兼容
207
+ mockedParseParallelCommands.mockImplementation((cmd: string) => [cmd]);
191
208
  });
192
209
 
193
210
  describe('registerValidateCommand', () => {
@@ -516,3 +533,89 @@ describe('--run 选项', () => {
516
533
  expect(mockedRunCommandInherited).not.toHaveBeenCalled();
517
534
  });
518
535
  });
536
+
537
+ describe('--run 并行命令', () => {
538
+ /** 设置首次 validate 成功的公共 mock */
539
+ function setupSuccessfulFirstValidate(): void {
540
+ mockedIsWorkingDirClean.mockReturnValue(true);
541
+ mockedHasLocalCommits.mockReturnValue(true);
542
+ mockedHasSnapshot.mockReturnValue(false);
543
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
544
+ mockedGitWriteTree.mockReturnValue('treehash');
545
+ mockedGetHeadCommitHash.mockReturnValue('headhash');
546
+ }
547
+
548
+ it('& 分隔的命令触发并行执行', async () => {
549
+ setupSuccessfulFirstValidate();
550
+ mockedParseParallelCommands.mockReturnValue(['pnpm test', 'pnpm build']);
551
+ mockedRunParallelCommands.mockResolvedValue([
552
+ { command: 'pnpm test', exitCode: 0 },
553
+ { command: 'pnpm build', exitCode: 0 },
554
+ ]);
555
+
556
+ const program = new Command();
557
+ program.exitOverride();
558
+ registerValidateCommand(program);
559
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm test & pnpm build'], { from: 'user' });
560
+
561
+ // 应该调用并行执行而非同步执行
562
+ expect(mockedRunParallelCommands).toHaveBeenCalledWith(['pnpm test', 'pnpm build'], { cwd: '/repo' });
563
+ expect(mockedRunCommandInherited).not.toHaveBeenCalled();
564
+ // 全部成功,printSuccess 被调用(validate 成功 + 各命令成功 + 汇总成功)
565
+ expect(mockedPrintSuccess).toHaveBeenCalled();
566
+ });
567
+
568
+ it('并行执行部分失败时输出错误汇总', async () => {
569
+ setupSuccessfulFirstValidate();
570
+ mockedParseParallelCommands.mockReturnValue(['pnpm test', 'pnpm build']);
571
+ mockedRunParallelCommands.mockResolvedValue([
572
+ { command: 'pnpm test', exitCode: 1 },
573
+ { command: 'pnpm build', exitCode: 0 },
574
+ ]);
575
+
576
+ const program = new Command();
577
+ program.exitOverride();
578
+ registerValidateCommand(program);
579
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm test & pnpm build'], { from: 'user' });
580
+
581
+ expect(mockedRunParallelCommands).toHaveBeenCalled();
582
+ // 部分失败,应有错误输出
583
+ expect(mockedPrintError).toHaveBeenCalled();
584
+ });
585
+
586
+ it('单命令走原有同步路径不触发并行', async () => {
587
+ setupSuccessfulFirstValidate();
588
+ mockedParseParallelCommands.mockReturnValue(['npm test']);
589
+ mockedRunCommandInherited.mockReturnValue({
590
+ pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
591
+ status: 0, signal: null, error: undefined,
592
+ });
593
+
594
+ const program = new Command();
595
+ program.exitOverride();
596
+ registerValidateCommand(program);
597
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'npm test'], { from: 'user' });
598
+
599
+ // 单命令应该走同步路径
600
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('npm test', { cwd: '/repo' });
601
+ expect(mockedRunParallelCommands).not.toHaveBeenCalled();
602
+ });
603
+
604
+ it('&& 命令不触发并行执行', async () => {
605
+ setupSuccessfulFirstValidate();
606
+ mockedParseParallelCommands.mockReturnValue(['pnpm lint && pnpm test']);
607
+ mockedRunCommandInherited.mockReturnValue({
608
+ pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
609
+ status: 0, signal: null, error: undefined,
610
+ });
611
+
612
+ const program = new Command();
613
+ program.exitOverride();
614
+ registerValidateCommand(program);
615
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm lint && pnpm test'], { from: 'user' });
616
+
617
+ // && 不拆分,走同步路径
618
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm lint && pnpm test', { cwd: '/repo' });
619
+ expect(mockedRunParallelCommands).not.toHaveBeenCalled();
620
+ });
621
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration } from '../../../src/utils/formatter.js';
2
+ import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration, formatRelativeTime } from '../../../src/utils/formatter.js';
3
3
  import { createWorktreeStatus } from '../../helpers/fixtures.js';
4
4
 
5
5
  describe('formatWorktreeStatus', () => {
@@ -136,3 +136,44 @@ describe('formatDuration', () => {
136
136
  expect(formatDuration(3661000)).toBe('61m01s');
137
137
  });
138
138
  });
139
+
140
+ describe('formatRelativeTime', () => {
141
+ it('不到 1 分钟时返回"刚刚"', () => {
142
+ const now = new Date();
143
+ expect(formatRelativeTime(now.toISOString())).toBe('刚刚');
144
+ });
145
+
146
+ it('数分钟前', () => {
147
+ const date = new Date(Date.now() - 5 * 60 * 1000);
148
+ expect(formatRelativeTime(date.toISOString())).toBe('5 分钟前');
149
+ });
150
+
151
+ it('数小时前', () => {
152
+ const date = new Date(Date.now() - 3 * 60 * 60 * 1000);
153
+ expect(formatRelativeTime(date.toISOString())).toBe('3 小时前');
154
+ });
155
+
156
+ it('数天前', () => {
157
+ const date = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
158
+ expect(formatRelativeTime(date.toISOString())).toBe('7 天前');
159
+ });
160
+
161
+ it('数月前', () => {
162
+ const date = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
163
+ expect(formatRelativeTime(date.toISOString())).toBe('2 个月前');
164
+ });
165
+
166
+ it('数年前', () => {
167
+ const date = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000);
168
+ expect(formatRelativeTime(date.toISOString())).toBe('1 年前');
169
+ });
170
+
171
+ it('无效日期返回 null', () => {
172
+ expect(formatRelativeTime('invalid-date')).toBeNull();
173
+ });
174
+
175
+ it('未来时间返回"刚刚"', () => {
176
+ const future = new Date(Date.now() + 60 * 60 * 1000);
177
+ expect(formatRelativeTime(future.toISOString())).toBe('刚刚');
178
+ });
179
+ });
@@ -63,6 +63,7 @@ import {
63
63
  getCommitTreeHash,
64
64
  gitDiffTree,
65
65
  gitApplyCachedCheck,
66
+ getBranchCreatedAt,
66
67
  } from '../../../src/utils/git.js';
67
68
 
68
69
  const mockedExecCommand = vi.mocked(execCommand);
@@ -574,3 +575,38 @@ describe('getCommitCountBehind', () => {
574
575
  expect(getCommitCountBehind('feature')).toBe(0);
575
576
  });
576
577
  });
578
+
579
+ describe('getBranchCreatedAt', () => {
580
+ it('多条 reflog 记录时返回最后一条(分支创建时间)', () => {
581
+ mockedExecCommand.mockReturnValue('2026-02-21T10:00:00+08:00\n2026-02-20T14:30:00+08:00');
582
+ expect(getBranchCreatedAt('feature')).toBe('2026-02-20T14:30:00+08:00');
583
+ expect(mockedExecCommand).toHaveBeenCalledWith(
584
+ 'git reflog show feature --format=%cI',
585
+ { cwd: undefined },
586
+ );
587
+ });
588
+
589
+ it('单条 reflog 记录时返回该时间', () => {
590
+ mockedExecCommand.mockReturnValue('2026-02-20T14:30:00+08:00');
591
+ expect(getBranchCreatedAt('feature')).toBe('2026-02-20T14:30:00+08:00');
592
+ });
593
+
594
+ it('无 reflog 记录时返回 null', () => {
595
+ mockedExecCommand.mockReturnValue('');
596
+ expect(getBranchCreatedAt('feature')).toBeNull();
597
+ });
598
+
599
+ it('命令失败时返回 null', () => {
600
+ mockedExecCommand.mockImplementation(() => { throw new Error('fail'); });
601
+ expect(getBranchCreatedAt('feature')).toBeNull();
602
+ });
603
+
604
+ it('传递 cwd 参数', () => {
605
+ mockedExecCommand.mockReturnValue('2026-02-20T14:30:00+08:00');
606
+ getBranchCreatedAt('feature', '/repo');
607
+ expect(mockedExecCommand).toHaveBeenCalledWith(
608
+ 'git reflog show feature --format=%cI',
609
+ { cwd: '/repo' },
610
+ );
611
+ });
612
+ });
@@ -13,7 +13,7 @@ vi.mock('../../../src/logger/index.js', () => ({
13
13
  }));
14
14
 
15
15
  import { execSync, execFileSync, spawn } from 'node:child_process';
16
- import { execCommand, execCommandWithInput, spawnProcess, killAllChildProcesses } from '../../../src/utils/shell.js';
16
+ import { execCommand, execCommandWithInput, spawnProcess, killAllChildProcesses, parseParallelCommands } from '../../../src/utils/shell.js';
17
17
 
18
18
  const mockedExecSync = vi.mocked(execSync);
19
19
  const mockedExecFileSync = vi.mocked(execFileSync);
@@ -106,3 +106,44 @@ describe('killAllChildProcesses', () => {
106
106
  expect(() => killAllChildProcesses([])).not.toThrow();
107
107
  });
108
108
  });
109
+
110
+ describe('parseParallelCommands', () => {
111
+ it('单个命令返回包含该命令的数组', () => {
112
+ expect(parseParallelCommands('pnpm test')).toEqual(['pnpm test']);
113
+ });
114
+
115
+ it('使用 & 分隔的多个命令被正确拆分', () => {
116
+ expect(parseParallelCommands('pnpm test & pnpm build')).toEqual(['pnpm test', 'pnpm build']);
117
+ });
118
+
119
+ it('&& 不会被拆分,保持为单条命令', () => {
120
+ expect(parseParallelCommands('pnpm lint && pnpm test')).toEqual(['pnpm lint && pnpm test']);
121
+ });
122
+
123
+ it('混合场景:&& 和 & 同时存在', () => {
124
+ expect(parseParallelCommands('pnpm lint && pnpm test & pnpm build')).toEqual([
125
+ 'pnpm lint && pnpm test',
126
+ 'pnpm build',
127
+ ]);
128
+ });
129
+
130
+ it('多个 & 分隔的命令', () => {
131
+ expect(parseParallelCommands('cmd1 & cmd2 & cmd3')).toEqual(['cmd1', 'cmd2', 'cmd3']);
132
+ });
133
+
134
+ it('空字符串返回空数组', () => {
135
+ expect(parseParallelCommands('')).toEqual([]);
136
+ });
137
+
138
+ it('去除命令首尾空白', () => {
139
+ expect(parseParallelCommands(' pnpm test & pnpm build ')).toEqual(['pnpm test', 'pnpm build']);
140
+ });
141
+
142
+ it('多个 && 不拆分', () => {
143
+ expect(parseParallelCommands('cmd1 && cmd2 && cmd3')).toEqual(['cmd1 && cmd2 && cmd3']);
144
+ });
145
+
146
+ it('尾部 & 后无内容时过滤空字符串', () => {
147
+ expect(parseParallelCommands('pnpm test & ')).toEqual(['pnpm test']);
148
+ });
149
+ });
@@ -8,6 +8,7 @@ vi.mock('node:fs', () => ({
8
8
  unlinkSync: vi.fn(),
9
9
  readdirSync: vi.fn(),
10
10
  rmdirSync: vi.fn(),
11
+ statSync: vi.fn(),
11
12
  }));
12
13
 
13
14
  // mock logger
@@ -29,11 +30,12 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
29
30
  };
30
31
  });
31
32
 
32
- import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'node:fs';
33
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync, statSync } from 'node:fs';
33
34
  import { ensureDir } from '../../../src/utils/fs.js';
34
35
  import {
35
36
  getSnapshotPath,
36
37
  hasSnapshot,
38
+ getSnapshotModifiedTime,
37
39
  readSnapshot,
38
40
  readSnapshotTreeHash,
39
41
  writeSnapshot,
@@ -48,6 +50,7 @@ const mockedWriteFileSync = vi.mocked(writeFileSync);
48
50
  const mockedUnlinkSync = vi.mocked(unlinkSync);
49
51
  const mockedReaddirSync = vi.mocked(readdirSync);
50
52
  const mockedRmdirSync = vi.mocked(rmdirSync);
53
+ const mockedStatSync = vi.mocked(statSync);
51
54
  const mockedEnsureDir = vi.mocked(ensureDir);
52
55
 
53
56
  describe('getSnapshotPath', () => {
@@ -174,3 +177,20 @@ describe('getProjectSnapshotBranches', () => {
174
177
  expect(result).toEqual([]);
175
178
  });
176
179
  });
180
+
181
+ describe('getSnapshotModifiedTime', () => {
182
+ it('快照存在时返回 ISO 时间字符串', () => {
183
+ mockedExistsSync.mockReturnValue(true);
184
+ const testDate = new Date('2026-02-22T10:00:00.000Z');
185
+ // @ts-expect-error statSync 返回类型简化
186
+ mockedStatSync.mockReturnValue({ mtime: testDate });
187
+ const result = getSnapshotModifiedTime('proj', 'branch');
188
+ expect(result).toBe('2026-02-22T10:00:00.000Z');
189
+ });
190
+
191
+ it('快照不存在时返回 null', () => {
192
+ mockedExistsSync.mockReturnValue(false);
193
+ const result = getSnapshotModifiedTime('proj', 'branch');
194
+ expect(result).toBeNull();
195
+ });
196
+ });