Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
import * as vscode from 'vscode';
8
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
9
import { RepoContext } from '../../../../platform/git/common/gitService';
10
import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';
11
import { ILogService } from '../../../../platform/log/common/logService';
12
import { mock } from '../../../../util/common/test/simpleMock';
13
import { constObservable, observableValue } from '../../../../util/vs/base/common/observableInternal';
14
import { URI } from '../../../../util/vs/base/common/uri';
15
import { IChatSessionMetadataStore, RepositoryProperties, WorkspaceFolderEntry } from '../../common/chatSessionMetadataStore';
16
import { ChatSessionWorkspaceFolderService } from '../chatSessionWorkspaceFolderServiceImpl';
17
18
/**
19
* Mock implementation of globalState for testing
20
*/
21
class MockGlobalState implements vscode.Memento {
22
private data = new Map<string, unknown>();
23
24
get<T>(key: string, defaultValue?: T): T {
25
const value = this.data.get(key);
26
return (value ?? defaultValue) as T;
27
}
28
29
async update(key: string, value: unknown): Promise<void> {
30
if (value === undefined) {
31
this.data.delete(key);
32
} else {
33
this.data.set(key, value);
34
}
35
}
36
37
keys(): readonly string[] {
38
return Array.from(this.data.keys());
39
}
40
41
setKeysForSync(_keys: readonly string[]): void {
42
// No-op for testing
43
}
44
}
45
46
/**
47
* Mock implementation of IVSCodeExtensionContext for testing
48
*/
49
class MockExtensionContext extends mock<IVSCodeExtensionContext>() {
50
public override globalState = new MockGlobalState();
51
52
override extensionPath = vscode.Uri.file('/mock/extension/path').fsPath;
53
override globalStorageUri = vscode.Uri.file('/mock/global/storage');
54
override storagePath = vscode.Uri.file('/mock/storage/path').fsPath;
55
override globalStoragePath = vscode.Uri.file('/mock/global/storage/path').fsPath;
56
override logPath = vscode.Uri.file('/mock/log/path').fsPath;
57
override logUri = vscode.Uri.file('/mock/log/uri');
58
override extensionUri = vscode.Uri.file('/mock/extension');
59
}
60
61
/**
62
* Mock implementation of ILogService for testing
63
*/
64
class MockLogService extends mock<ILogService>() {
65
override trace = vi.fn();
66
override info = vi.fn();
67
override warn = vi.fn();
68
override error = vi.fn();
69
override debug = vi.fn();
70
}
71
72
class MockMetadataStore extends mock<IChatSessionMetadataStore>() {
73
private readonly _data = new Map<string, WorkspaceFolderEntry>();
74
private readonly _repoData = new Map<string, RepositoryProperties>();
75
override storeWorktreeInfo = vi.fn(async () => { });
76
override storeWorkspaceFolderInfo = vi.fn(async (_sessionId: string, _entry: WorkspaceFolderEntry) => {
77
this._data.set(_sessionId, _entry);
78
});
79
override storeRepositoryProperties = vi.fn(async (_sessionId: string, properties: RepositoryProperties) => {
80
this._repoData.set(_sessionId, properties);
81
});
82
override getWorktreeProperties = vi.fn(async () => undefined);
83
override getRepositoryProperties = vi.fn(async (_sessionId: string) => this._repoData.get(_sessionId));
84
override getSessionWorkspaceFolder = vi.fn(async (_sessionId: string): Promise<vscode.Uri | undefined> => {
85
const entry = this._data.get(_sessionId);
86
if (entry?.folderPath) {
87
return vscode.Uri.file(entry.folderPath);
88
}
89
return undefined;
90
});
91
override deleteSessionMetadata = vi.fn(async (_sessionId: string) => {
92
this._data.delete(_sessionId);
93
this._repoData.delete(_sessionId);
94
});
95
}
96
97
describe('ChatSessionWorkspaceFolderService', () => {
98
let service: ChatSessionWorkspaceFolderService;
99
let extensionContext: MockExtensionContext;
100
let gitService: MockGitService;
101
let logService: MockLogService;
102
let metadataStore: MockMetadataStore;
103
104
beforeEach(() => {
105
extensionContext = new MockExtensionContext();
106
logService = new MockLogService();
107
gitService = new MockGitService();
108
metadataStore = new MockMetadataStore();
109
service = new ChatSessionWorkspaceFolderService(gitService, logService, metadataStore, extensionContext);
110
});
111
112
afterEach(() => {
113
vi.clearAllMocks();
114
});
115
116
describe('trackSessionWorkspaceFolder', () => {
117
it('should track a workspace folder for a session', async () => {
118
const sessionId = 'session-1';
119
const folderPath = vscode.Uri.file('/path/to/folder').fsPath;
120
121
await service.trackSessionWorkspaceFolder(sessionId, folderPath);
122
123
const tracked = await service.getSessionWorkspaceFolder(sessionId);
124
expect(tracked?.fsPath).toBe(folderPath);
125
});
126
127
it('should update timestamp when tracking a folder', async () => {
128
const sessionId = 'session-1';
129
const folderPath = vscode.Uri.file('/path/to/folder').fsPath;
130
131
const beforeTime = Date.now();
132
await service.trackSessionWorkspaceFolder(sessionId, folderPath);
133
const afterTime = Date.now();
134
135
// Verify that metadataStore was called with correct timestamp
136
expect(metadataStore.storeWorkspaceFolderInfo).toHaveBeenCalledWith(
137
sessionId,
138
expect.objectContaining({ folderPath })
139
);
140
const entry = metadataStore.storeWorkspaceFolderInfo.mock.calls[0][1];
141
expect(entry.timestamp).toBeGreaterThanOrEqual(beforeTime);
142
expect(entry.timestamp).toBeLessThanOrEqual(afterTime);
143
});
144
145
it('should persist data to metadata store', async () => {
146
const sessionId = 'session-1';
147
const folderPath = vscode.Uri.file('/path/to/folder').fsPath;
148
149
await service.trackSessionWorkspaceFolder(sessionId, folderPath);
150
151
// Verify metadata store was called
152
expect(metadataStore.storeWorkspaceFolderInfo).toHaveBeenCalledWith(
153
sessionId,
154
expect.objectContaining({ folderPath })
155
);
156
});
157
158
it('should handle multiple concurrent tracking calls', async () => {
159
const sessionIds = ['session-1', 'session-2', 'session-3'];
160
const folderPaths = [vscode.Uri.file('/path/1').fsPath, vscode.Uri.file('/path/2').fsPath, vscode.Uri.file('/path/3').fsPath];
161
162
await Promise.all(
163
sessionIds.map((sessionId, idx) => service.trackSessionWorkspaceFolder(sessionId, folderPaths[idx]))
164
);
165
166
for (let i = 0; i < sessionIds.length; i++) {
167
const tracked = await service.getSessionWorkspaceFolder(sessionIds[i]);
168
expect(tracked?.fsPath).toBe(folderPaths[i]);
169
}
170
});
171
172
it('should trigger cleanup when exceeding MAX_ENTRIES', async () => {
173
// Track MAX_ENTRIES + 1 entries to trigger cleanup
174
const MAX_ENTRIES = 1500;
175
176
// Pre-fill globalState with old entries
177
const oldData: Record<string, unknown> = {};
178
for (let i = 0; i < MAX_ENTRIES; i++) {
179
oldData[`session-old-${i}`] = {
180
folderPath: vscode.Uri.file(`/old/path/${i}`).fsPath,
181
timestamp: Date.now() - 10000 + i // Incrementing timestamps
182
};
183
}
184
await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', oldData);
185
186
// Add one more entry to trigger cleanup
187
await service.trackSessionWorkspaceFolder('session-new', vscode.Uri.file('/new/path').fsPath);
188
189
// Verify that cleanup occurred (some old entries should be gone)
190
const data = extensionContext.globalState.get<Record<string, unknown>>('github.copilot.cli.sessionWorkspaceFolders', {});
191
const entryCount = Object.keys(data).length;
192
expect(entryCount).toBeLessThan(MAX_ENTRIES + 1);
193
});
194
});
195
196
describe('getSessionWorkspaceFolder', () => {
197
it('should return undefined for non-existent session', async () => {
198
const result = await service.getSessionWorkspaceFolder('non-existent-session');
199
expect(result).toBeUndefined();
200
});
201
202
it('should return correct URI for tracked session', async () => {
203
const sessionId = 'session-1';
204
const folderPath = vscode.Uri.file('/path/to/folder').fsPath;
205
206
await service.trackSessionWorkspaceFolder(sessionId, folderPath);
207
const result = await service.getSessionWorkspaceFolder(sessionId);
208
209
expect(result).toBeDefined();
210
expect(result?.fsPath).toBe(folderPath);
211
});
212
213
it('should return URI object with correct properties', async () => {
214
const sessionId = 'session-1';
215
const folderPath = vscode.Uri.file('/path/to/folder').fsPath;
216
217
await service.trackSessionWorkspaceFolder(sessionId, folderPath);
218
const result = await service.getSessionWorkspaceFolder(sessionId);
219
220
expect(result).toBeInstanceOf(vscode.Uri);
221
expect(result?.scheme).toBe('file');
222
});
223
224
it('should handle malformed data gracefully', async () => {
225
// Manually inject malformed data
226
await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', {
227
'session-bad': {} // Missing folderPath
228
});
229
230
const result = await service.getSessionWorkspaceFolder('session-bad');
231
expect(result).toBeUndefined();
232
});
233
234
it('should return undefined if folderPath is empty string', async () => {
235
// Manually inject entry with empty folderPath
236
await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', {
237
'session-empty': { folderPath: '', timestamp: Date.now() }
238
});
239
240
const result = await service.getSessionWorkspaceFolder('session-empty');
241
expect(result).toBeUndefined();
242
});
243
244
it('should fall back to metadata store when session is not in memory', async () => {
245
// Session not tracked in-memory, but metadata store has it
246
const folderPath = vscode.Uri.file('/metadata-store/folder').fsPath;
247
metadataStore.getSessionWorkspaceFolder.mockResolvedValueOnce(vscode.Uri.file(folderPath));
248
249
const result = await service.getSessionWorkspaceFolder('session-from-store');
250
251
expect(result?.fsPath).toBe(folderPath);
252
expect(metadataStore.getSessionWorkspaceFolder).toHaveBeenCalledWith('session-from-store');
253
});
254
255
it('should prefer in-memory state over metadata store', async () => {
256
const sessionId = 'session-both';
257
const inMemoryPath = vscode.Uri.file('/in-memory/folder').fsPath;
258
259
await service.trackSessionWorkspaceFolder(sessionId, inMemoryPath);
260
261
// Even if metadata store would return something different
262
metadataStore.getSessionWorkspaceFolder.mockResolvedValueOnce(vscode.Uri.file('/store/different'));
263
264
const result = await service.getSessionWorkspaceFolder(sessionId);
265
expect(result?.fsPath).toBe(inMemoryPath);
266
});
267
});
268
269
describe('deleteTrackedWorkspaceFolder', () => {
270
it('should delete tracked folder for session', async () => {
271
const sessionId = 'session-1';
272
const folderPath = vscode.Uri.file('/path/to/folder').fsPath;
273
274
await service.trackSessionWorkspaceFolder(sessionId, folderPath);
275
expect(await service.getSessionWorkspaceFolder(sessionId)).toBeDefined();
276
277
await service.deleteTrackedWorkspaceFolder(sessionId);
278
expect(await service.getSessionWorkspaceFolder(sessionId)).toBeUndefined();
279
});
280
281
it('should call metadata store when deleting', async () => {
282
const sessionId = 'session-1';
283
await service.trackSessionWorkspaceFolder(sessionId, vscode.Uri.file('/path/to/folder').fsPath);
284
285
await service.deleteTrackedWorkspaceFolder(sessionId);
286
287
expect(metadataStore.deleteSessionMetadata).toHaveBeenCalledWith(sessionId);
288
});
289
290
it('should invalidate workspace changes cache when deleting a tracked folder', async () => {
291
const repo = {
292
rootUri: URI.file('/repo'),
293
kind: 'repository' as const,
294
isUsingVirtualFileSystem: false,
295
headBranchName: 'main',
296
headCommitHash: 'abc123',
297
headIncomingChanges: 0,
298
headOutgoingChanges: 0,
299
upstreamBranchName: undefined,
300
upstreamRemote: undefined,
301
isRebasing: false,
302
remotes: [],
303
remoteFetchUrls: [],
304
worktrees: [],
305
changes: { mergeChanges: [], indexChanges: [], workingTree: [], untrackedChanges: [] },
306
headBranchNameObs: constObservable('main'),
307
headCommitHashObs: observableValue('test-head-commit', 'abc123'),
308
upstreamBranchNameObs: constObservable(undefined),
309
upstreamRemoteObs: constObservable(undefined),
310
isRebasingObs: constObservable(false),
311
isIgnored: async () => false,
312
} as RepoContext;
313
314
gitService.getRepository = vi.fn().mockResolvedValue(repo);
315
316
const sessionId1 = 'session-1';
317
const sessionId2 = 'session-2';
318
const sharedProperties = {
319
repositoryPath: '/repo',
320
branchName: 'main',
321
baseBranchName: 'origin/main',
322
};
323
324
await service.trackSessionWorkspaceFolder(sessionId1, '/repo', sharedProperties);
325
await service.trackSessionWorkspaceFolder(sessionId2, '/repo', sharedProperties);
326
327
await service.getWorkspaceChanges(sessionId1);
328
await service.deleteTrackedWorkspaceFolder(sessionId1);
329
await service.getWorkspaceChanges(sessionId2);
330
331
expect(gitService.getRepository).toHaveBeenCalledTimes(2);
332
});
333
334
it('should handle deletion of non-existent session', async () => {
335
// Should not throw
336
await expect(service.deleteTrackedWorkspaceFolder('non-existent')).resolves.toBeUndefined();
337
});
338
339
it('should not affect other sessions when deleting one', async () => {
340
const session1 = 'session-1';
341
const session2 = 'session-2';
342
343
await service.trackSessionWorkspaceFolder(session1, vscode.Uri.file('/path/1').fsPath);
344
await service.trackSessionWorkspaceFolder(session2, vscode.Uri.file('/path/2').fsPath);
345
346
await service.deleteTrackedWorkspaceFolder(session1);
347
348
expect(await service.getSessionWorkspaceFolder(session1)).toBeUndefined();
349
expect(await service.getSessionWorkspaceFolder(session2)).toBeDefined();
350
});
351
});
352
353
describe('cleanupOldEntries', () => {
354
it('should keep newer entries and remove older ones', async () => {
355
const MAX_ENTRIES = 1500;
356
357
// Create old entries with predictable timestamps
358
const oldData: Record<string, unknown> = {};
359
for (let i = 0; i < MAX_ENTRIES; i++) {
360
oldData[`session-old-${i}`] = {
361
folderPath: vscode.Uri.file(`/old/path/${i}`).fsPath,
362
timestamp: 1000 + i // Older timestamps
363
};
364
}
365
await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', oldData);
366
367
// Add a new entry with current timestamp
368
const now = Date.now();
369
const data = extensionContext.globalState.get<Record<string, unknown>>('github.copilot.cli.sessionWorkspaceFolders', {});
370
(data as any)['session-new'] = {
371
folderPath: vscode.Uri.file('/new/path').fsPath,
372
timestamp: now
373
};
374
await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', data);
375
376
// Trigger cleanup by adding another entry
377
await service.trackSessionWorkspaceFolder('session-trigger', vscode.Uri.file('/trigger/path').fsPath);
378
379
const finalData = extensionContext.globalState.get<Record<string, unknown>>('github.copilot.cli.sessionWorkspaceFolders', {});
380
381
// The newest entries should be preserved
382
expect(finalData['session-new']).toBeDefined();
383
});
384
});
385
386
describe('integration scenarios', () => {
387
describe('getWorkspaceChanges - cache invalidation', () => {
388
let headCommitHash: ReturnType<typeof observableValue<string | undefined>>;
389
390
function makeRepoContext(overrides?: Partial<RepoContext>): RepoContext {
391
headCommitHash = observableValue('test-head-commit', 'abc123');
392
return {
393
rootUri: URI.file('/repo'),
394
kind: 'repository',
395
headBranchName: 'main',
396
headCommitHash: 'abc123',
397
upstreamBranchName: undefined,
398
upstreamRemote: undefined,
399
isRebasing: false,
400
remotes: [],
401
remoteFetchUrls: [],
402
worktrees: [],
403
changes: { mergeChanges: [], indexChanges: [], workingTree: [], untrackedChanges: [] },
404
headBranchNameObs: constObservable('main'),
405
headCommitHashObs: headCommitHash,
406
upstreamBranchNameObs: constObservable(undefined),
407
upstreamRemoteObs: constObservable(undefined),
408
isRebasingObs: constObservable(false),
409
isIgnored: async () => false,
410
...overrides,
411
} as RepoContext;
412
}
413
414
it('should return cached changes on second call', async () => {
415
const repo = makeRepoContext();
416
gitService.getRepository = vi.fn().mockResolvedValue(repo);
417
gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });
418
419
const sessionId = 'session-1';
420
await metadataStore.storeRepositoryProperties(sessionId, {
421
repositoryPath: '/repo',
422
branchName: 'main',
423
});
424
425
const first = await service.getWorkspaceChanges(sessionId);
426
const second = await service.getWorkspaceChanges(sessionId);
427
428
expect(first).toBe(second);
429
// getRepository is called once for the first call, the second uses cache
430
expect(gitService.getRepository).toHaveBeenCalledTimes(1);
431
});
432
433
it('should invalidate cache when clearWorkspaceChanges is called', async () => {
434
const repo = makeRepoContext();
435
gitService.getRepository = vi.fn().mockResolvedValue(repo);
436
gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });
437
438
const sessionId = 'session-1';
439
await metadataStore.storeRepositoryProperties(sessionId, {
440
repositoryPath: '/repo',
441
branchName: 'main',
442
});
443
444
await service.getWorkspaceChanges(sessionId);
445
service.clearWorkspaceChanges(sessionId);
446
447
await service.getWorkspaceChanges(sessionId);
448
expect(gitService.getRepository).toHaveBeenCalledTimes(2);
449
});
450
451
it('should invalidate cache when handleRequestCompleted is called', async () => {
452
const repo = makeRepoContext();
453
gitService.getRepository = vi.fn().mockResolvedValue(repo);
454
gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });
455
456
const sessionId = 'session-1';
457
await metadataStore.storeRepositoryProperties(sessionId, {
458
repositoryPath: '/repo',
459
branchName: 'main',
460
});
461
462
await service.getWorkspaceChanges(sessionId);
463
await service.handleRequestCompleted(sessionId);
464
465
await service.getWorkspaceChanges(sessionId);
466
expect(gitService.getRepository).toHaveBeenCalledTimes(2);
467
});
468
469
it('should return empty array when repository has no changes', async () => {
470
const repo = makeRepoContext({ changes: undefined });
471
gitService.getRepository = vi.fn().mockResolvedValue(repo);
472
await metadataStore.storeRepositoryProperties('session-1', {
473
repositoryPath: '/repo',
474
branchName: 'main',
475
});
476
477
const result = await service.getWorkspaceChanges('session-1');
478
expect(result).toEqual([]);
479
});
480
481
it('should return empty array when no repository is found', async () => {
482
gitService.getRepository = vi.fn().mockResolvedValue(undefined);
483
await metadataStore.storeRepositoryProperties('session-1', {
484
repositoryPath: '/repo',
485
branchName: 'main',
486
});
487
488
const result = await service.getWorkspaceChanges('session-1');
489
expect(result).toEqual([]);
490
});
491
492
it('should cache empty result when session has no repository properties', async () => {
493
// Session with no stored repository properties
494
const result1 = await service.getWorkspaceChanges('no-repo-session');
495
const result2 = await service.getWorkspaceChanges('no-repo-session');
496
497
expect(result1).toEqual([]);
498
expect(result2).toEqual([]);
499
// Should only read metadata once — subsequent call uses the negative cache
500
expect(metadataStore.getRepositoryProperties).toHaveBeenCalledTimes(1);
501
});
502
503
it('should clear negative cache when repository properties are later provided via trackSessionWorkspaceFolder', async () => {
504
const repo = makeRepoContext();
505
gitService.getRepository = vi.fn().mockResolvedValue(repo);
506
507
// First call: no repo properties → negative-cached, returns []
508
const result1 = await service.getWorkspaceChanges('late-init-session');
509
expect(result1).toEqual([]);
510
511
// Later: repo properties are provided via trackSessionWorkspaceFolder
512
await service.trackSessionWorkspaceFolder('late-init-session', '/repo', {
513
repositoryPath: '/repo',
514
branchName: 'main',
515
});
516
517
// Second call: negative cache should be cleared, should re-read metadata
518
const result2 = await service.getWorkspaceChanges('late-init-session');
519
expect(result2).toBeDefined();
520
expect(metadataStore.getRepositoryProperties).toHaveBeenCalledTimes(2);
521
});
522
523
it('should not re-fetch when cache is valid for a folder', async () => {
524
const repo = makeRepoContext();
525
gitService.getRepository = vi.fn().mockResolvedValue(repo);
526
gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });
527
528
const sessionId = 'session-1';
529
await metadataStore.storeRepositoryProperties(sessionId, {
530
repositoryPath: '/repo',
531
branchName: 'main',
532
});
533
534
// Clear cache between calls to force re-entry into getWorkspaceChanges
535
await service.getWorkspaceChanges(sessionId);
536
service.clearWorkspaceChanges(sessionId);
537
await service.getWorkspaceChanges(sessionId);
538
539
service.clearWorkspaceChanges(sessionId);
540
await service.getWorkspaceChanges(sessionId);
541
542
// All 3 calls should have hit getRepository (cache was manually cleared each time)
543
expect(gitService.getRepository).toHaveBeenCalledTimes(3);
544
});
545
546
it('should track changes per workspace folder independently', async () => {
547
const repo1 = makeRepoContext();
548
const repo2 = makeRepoContext();
549
550
const folder1 = vscode.Uri.file('/repo1');
551
const folder2 = vscode.Uri.file('/repo2');
552
553
gitService.getRepository = vi.fn()
554
.mockImplementation((uri: URI) => {
555
if (uri.fsPath === folder1.fsPath) {
556
return Promise.resolve(repo1);
557
}
558
return Promise.resolve(repo2);
559
});
560
gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 0, deletions: 0 });
561
562
const sessionId1 = 'session-1';
563
const sessionId2 = 'session-2';
564
565
await service.trackSessionWorkspaceFolder(sessionId1, folder1.fsPath, {
566
repositoryPath: folder1.fsPath,
567
branchName: 'main',
568
});
569
await service.trackSessionWorkspaceFolder(sessionId2, folder2.fsPath, {
570
repositoryPath: folder2.fsPath,
571
branchName: 'main',
572
});
573
574
await service.getWorkspaceChanges(sessionId1);
575
await service.getWorkspaceChanges(sessionId2);
576
577
// Invalidate only sessionId1's cache
578
service.clearWorkspaceChanges(sessionId1);
579
580
// sessionId2 should still use cache
581
await service.getWorkspaceChanges(sessionId2);
582
// sessionId1 needs refresh
583
await service.getWorkspaceChanges(sessionId1);
584
585
// sessionId1: called twice (initial + after invalidation), sessionId2: called once (cached)
586
const calls = (gitService.getRepository as ReturnType<typeof vi.fn>).mock.calls;
587
const sessionId1Calls = calls.filter((c: URI[]) => c[0].fsPath === folder1.fsPath).length;
588
const sessionId2Calls = calls.filter((c: URI[]) => c[0].fsPath === folder2.fsPath).length;
589
expect(sessionId1Calls).toBe(2);
590
expect(sessionId2Calls).toBe(1);
591
});
592
593
it('should serialize git operations for different sessions sharing the same repo, base branch and branch', async () => {
594
const repo = makeRepoContext();
595
const repoPath = '/shared-repo';
596
597
gitService.getRepository = vi.fn().mockImplementation(async () => {
598
// Simulate async work
599
await new Promise(resolve => setTimeout(resolve, 10));
600
return repo;
601
});
602
603
const sessionId1 = 'session-A';
604
const sessionId2 = 'session-B';
605
606
await metadataStore.storeRepositoryProperties(sessionId1, {
607
repositoryPath: repoPath,
608
branchName: 'feature',
609
baseBranchName: 'main',
610
});
611
await metadataStore.storeRepositoryProperties(sessionId2, {
612
repositoryPath: repoPath,
613
branchName: 'feature',
614
baseBranchName: 'main',
615
});
616
617
// Fire both concurrently — they share the same repo+baseBranch
618
const [result1, result2] = await Promise.all([
619
service.getWorkspaceChanges(sessionId1),
620
service.getWorkspaceChanges(sessionId2),
621
]);
622
623
expect(result1).toBeDefined();
624
expect(result2).toBeDefined();
625
626
// Session B should reuse the result computed by session A via shared repo-level cache
627
expect(result1).toBe(result2);
628
expect(gitService.getRepository).toHaveBeenCalledTimes(1);
629
});
630
631
it('should not share cache for sessions with different branch names in the same repo and base branch', async () => {
632
const repo = makeRepoContext();
633
const repoPath = '/shared-repo';
634
635
gitService.getRepository = vi.fn().mockImplementation(async () => {
636
await new Promise(resolve => setTimeout(resolve, 10));
637
return repo;
638
});
639
640
await metadataStore.storeRepositoryProperties('session-main', {
641
repositoryPath: repoPath,
642
branchName: 'main',
643
baseBranchName: 'origin/main',
644
});
645
await metadataStore.storeRepositoryProperties('session-feature', {
646
repositoryPath: repoPath,
647
branchName: 'feature',
648
baseBranchName: 'origin/main',
649
});
650
651
await Promise.all([
652
service.getWorkspaceChanges('session-main'),
653
service.getWorkspaceChanges('session-feature'),
654
]);
655
656
expect(gitService.getRepository).toHaveBeenCalledTimes(2);
657
});
658
659
it('should invalidate cache for all sessions when clearWorkspaceChanges is called with folder URI', async () => {
660
const folder = vscode.Uri.file('/shared-folder');
661
const repo = makeRepoContext({ rootUri: URI.file('/shared-folder') });
662
663
gitService.getRepository = vi.fn().mockResolvedValue(repo);
664
gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });
665
666
const sessionId1 = 'session-1';
667
const sessionId2 = 'session-2';
668
669
await service.trackSessionWorkspaceFolder(sessionId1, folder.fsPath, {
670
repositoryPath: folder.fsPath,
671
branchName: 'main',
672
});
673
await service.trackSessionWorkspaceFolder(sessionId2, folder.fsPath, {
674
repositoryPath: folder.fsPath,
675
branchName: 'develop',
676
});
677
678
// Populate caches
679
await service.getWorkspaceChanges(sessionId1);
680
await service.getWorkspaceChanges(sessionId2);
681
expect(gitService.getRepository).toHaveBeenCalledTimes(2);
682
683
// Clear via folder URI
684
const clearedIds = service.clearWorkspaceChanges(folder);
685
expect(clearedIds).toContain(sessionId1);
686
expect(clearedIds).toContain(sessionId2);
687
688
// Both sessions should need to re-fetch
689
await service.getWorkspaceChanges(sessionId1);
690
await service.getWorkspaceChanges(sessionId2);
691
expect(gitService.getRepository).toHaveBeenCalledTimes(4);
692
});
693
694
it('should return empty array when clearWorkspaceChanges is called with untracked folder URI', () => {
695
const unknownFolder = vscode.Uri.file('/unknown-folder');
696
const result = service.clearWorkspaceChanges(unknownFolder);
697
expect(result).toEqual([]);
698
});
699
700
it('should populate folder associations eagerly on trackSessionWorkspaceFolder', async () => {
701
const folder = vscode.Uri.file('/my-folder');
702
const sessionId = 'session-eager';
703
704
// Before tracking, no associations
705
expect(service.clearWorkspaceChanges(folder)).toEqual([]);
706
707
await service.trackSessionWorkspaceFolder(sessionId, folder.fsPath);
708
709
// After tracking, association exists immediately (no need to call getWorkspaceChanges first)
710
expect(service.clearWorkspaceChanges(folder)).toEqual([sessionId]);
711
});
712
713
it('should clean up folder associations on deleteTrackedWorkspaceFolder', async () => {
714
const folder = vscode.Uri.file('/cleanup-folder');
715
const sessionId = 'session-cleanup';
716
717
await service.trackSessionWorkspaceFolder(sessionId, folder.fsPath);
718
expect(service.clearWorkspaceChanges(folder)).toEqual([sessionId]);
719
720
await service.deleteTrackedWorkspaceFolder(sessionId);
721
expect(service.clearWorkspaceChanges(folder)).toEqual([]);
722
});
723
});
724
});
725
});
726
727