Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';6import * as vscode from 'vscode';7import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';8import { RepoContext } from '../../../../platform/git/common/gitService';9import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';10import { ILogService } from '../../../../platform/log/common/logService';11import { mock } from '../../../../util/common/test/simpleMock';12import { constObservable, observableValue } from '../../../../util/vs/base/common/observableInternal';13import { URI } from '../../../../util/vs/base/common/uri';14import { IChatSessionMetadataStore, RepositoryProperties, WorkspaceFolderEntry } from '../../common/chatSessionMetadataStore';15import { ChatSessionWorkspaceFolderService } from '../chatSessionWorkspaceFolderServiceImpl';1617/**18* Mock implementation of globalState for testing19*/20class MockGlobalState implements vscode.Memento {21private data = new Map<string, unknown>();2223get<T>(key: string, defaultValue?: T): T {24const value = this.data.get(key);25return (value ?? defaultValue) as T;26}2728async update(key: string, value: unknown): Promise<void> {29if (value === undefined) {30this.data.delete(key);31} else {32this.data.set(key, value);33}34}3536keys(): readonly string[] {37return Array.from(this.data.keys());38}3940setKeysForSync(_keys: readonly string[]): void {41// No-op for testing42}43}4445/**46* Mock implementation of IVSCodeExtensionContext for testing47*/48class MockExtensionContext extends mock<IVSCodeExtensionContext>() {49public override globalState = new MockGlobalState();5051override extensionPath = vscode.Uri.file('/mock/extension/path').fsPath;52override globalStorageUri = vscode.Uri.file('/mock/global/storage');53override storagePath = vscode.Uri.file('/mock/storage/path').fsPath;54override globalStoragePath = vscode.Uri.file('/mock/global/storage/path').fsPath;55override logPath = vscode.Uri.file('/mock/log/path').fsPath;56override logUri = vscode.Uri.file('/mock/log/uri');57override extensionUri = vscode.Uri.file('/mock/extension');58}5960/**61* Mock implementation of ILogService for testing62*/63class MockLogService extends mock<ILogService>() {64override trace = vi.fn();65override info = vi.fn();66override warn = vi.fn();67override error = vi.fn();68override debug = vi.fn();69}7071class MockMetadataStore extends mock<IChatSessionMetadataStore>() {72private readonly _data = new Map<string, WorkspaceFolderEntry>();73private readonly _repoData = new Map<string, RepositoryProperties>();74override storeWorktreeInfo = vi.fn(async () => { });75override storeWorkspaceFolderInfo = vi.fn(async (_sessionId: string, _entry: WorkspaceFolderEntry) => {76this._data.set(_sessionId, _entry);77});78override storeRepositoryProperties = vi.fn(async (_sessionId: string, properties: RepositoryProperties) => {79this._repoData.set(_sessionId, properties);80});81override getWorktreeProperties = vi.fn(async () => undefined);82override getRepositoryProperties = vi.fn(async (_sessionId: string) => this._repoData.get(_sessionId));83override getSessionWorkspaceFolder = vi.fn(async (_sessionId: string): Promise<vscode.Uri | undefined> => {84const entry = this._data.get(_sessionId);85if (entry?.folderPath) {86return vscode.Uri.file(entry.folderPath);87}88return undefined;89});90override deleteSessionMetadata = vi.fn(async (_sessionId: string) => {91this._data.delete(_sessionId);92this._repoData.delete(_sessionId);93});94}9596describe('ChatSessionWorkspaceFolderService', () => {97let service: ChatSessionWorkspaceFolderService;98let extensionContext: MockExtensionContext;99let gitService: MockGitService;100let logService: MockLogService;101let metadataStore: MockMetadataStore;102103beforeEach(() => {104extensionContext = new MockExtensionContext();105logService = new MockLogService();106gitService = new MockGitService();107metadataStore = new MockMetadataStore();108service = new ChatSessionWorkspaceFolderService(gitService, logService, metadataStore, extensionContext);109});110111afterEach(() => {112vi.clearAllMocks();113});114115describe('trackSessionWorkspaceFolder', () => {116it('should track a workspace folder for a session', async () => {117const sessionId = 'session-1';118const folderPath = vscode.Uri.file('/path/to/folder').fsPath;119120await service.trackSessionWorkspaceFolder(sessionId, folderPath);121122const tracked = await service.getSessionWorkspaceFolder(sessionId);123expect(tracked?.fsPath).toBe(folderPath);124});125126it('should update timestamp when tracking a folder', async () => {127const sessionId = 'session-1';128const folderPath = vscode.Uri.file('/path/to/folder').fsPath;129130const beforeTime = Date.now();131await service.trackSessionWorkspaceFolder(sessionId, folderPath);132const afterTime = Date.now();133134// Verify that metadataStore was called with correct timestamp135expect(metadataStore.storeWorkspaceFolderInfo).toHaveBeenCalledWith(136sessionId,137expect.objectContaining({ folderPath })138);139const entry = metadataStore.storeWorkspaceFolderInfo.mock.calls[0][1];140expect(entry.timestamp).toBeGreaterThanOrEqual(beforeTime);141expect(entry.timestamp).toBeLessThanOrEqual(afterTime);142});143144it('should persist data to metadata store', async () => {145const sessionId = 'session-1';146const folderPath = vscode.Uri.file('/path/to/folder').fsPath;147148await service.trackSessionWorkspaceFolder(sessionId, folderPath);149150// Verify metadata store was called151expect(metadataStore.storeWorkspaceFolderInfo).toHaveBeenCalledWith(152sessionId,153expect.objectContaining({ folderPath })154);155});156157it('should handle multiple concurrent tracking calls', async () => {158const sessionIds = ['session-1', 'session-2', 'session-3'];159const folderPaths = [vscode.Uri.file('/path/1').fsPath, vscode.Uri.file('/path/2').fsPath, vscode.Uri.file('/path/3').fsPath];160161await Promise.all(162sessionIds.map((sessionId, idx) => service.trackSessionWorkspaceFolder(sessionId, folderPaths[idx]))163);164165for (let i = 0; i < sessionIds.length; i++) {166const tracked = await service.getSessionWorkspaceFolder(sessionIds[i]);167expect(tracked?.fsPath).toBe(folderPaths[i]);168}169});170171it('should trigger cleanup when exceeding MAX_ENTRIES', async () => {172// Track MAX_ENTRIES + 1 entries to trigger cleanup173const MAX_ENTRIES = 1500;174175// Pre-fill globalState with old entries176const oldData: Record<string, unknown> = {};177for (let i = 0; i < MAX_ENTRIES; i++) {178oldData[`session-old-${i}`] = {179folderPath: vscode.Uri.file(`/old/path/${i}`).fsPath,180timestamp: Date.now() - 10000 + i // Incrementing timestamps181};182}183await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', oldData);184185// Add one more entry to trigger cleanup186await service.trackSessionWorkspaceFolder('session-new', vscode.Uri.file('/new/path').fsPath);187188// Verify that cleanup occurred (some old entries should be gone)189const data = extensionContext.globalState.get<Record<string, unknown>>('github.copilot.cli.sessionWorkspaceFolders', {});190const entryCount = Object.keys(data).length;191expect(entryCount).toBeLessThan(MAX_ENTRIES + 1);192});193});194195describe('getSessionWorkspaceFolder', () => {196it('should return undefined for non-existent session', async () => {197const result = await service.getSessionWorkspaceFolder('non-existent-session');198expect(result).toBeUndefined();199});200201it('should return correct URI for tracked session', async () => {202const sessionId = 'session-1';203const folderPath = vscode.Uri.file('/path/to/folder').fsPath;204205await service.trackSessionWorkspaceFolder(sessionId, folderPath);206const result = await service.getSessionWorkspaceFolder(sessionId);207208expect(result).toBeDefined();209expect(result?.fsPath).toBe(folderPath);210});211212it('should return URI object with correct properties', async () => {213const sessionId = 'session-1';214const folderPath = vscode.Uri.file('/path/to/folder').fsPath;215216await service.trackSessionWorkspaceFolder(sessionId, folderPath);217const result = await service.getSessionWorkspaceFolder(sessionId);218219expect(result).toBeInstanceOf(vscode.Uri);220expect(result?.scheme).toBe('file');221});222223it('should handle malformed data gracefully', async () => {224// Manually inject malformed data225await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', {226'session-bad': {} // Missing folderPath227});228229const result = await service.getSessionWorkspaceFolder('session-bad');230expect(result).toBeUndefined();231});232233it('should return undefined if folderPath is empty string', async () => {234// Manually inject entry with empty folderPath235await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', {236'session-empty': { folderPath: '', timestamp: Date.now() }237});238239const result = await service.getSessionWorkspaceFolder('session-empty');240expect(result).toBeUndefined();241});242243it('should fall back to metadata store when session is not in memory', async () => {244// Session not tracked in-memory, but metadata store has it245const folderPath = vscode.Uri.file('/metadata-store/folder').fsPath;246metadataStore.getSessionWorkspaceFolder.mockResolvedValueOnce(vscode.Uri.file(folderPath));247248const result = await service.getSessionWorkspaceFolder('session-from-store');249250expect(result?.fsPath).toBe(folderPath);251expect(metadataStore.getSessionWorkspaceFolder).toHaveBeenCalledWith('session-from-store');252});253254it('should prefer in-memory state over metadata store', async () => {255const sessionId = 'session-both';256const inMemoryPath = vscode.Uri.file('/in-memory/folder').fsPath;257258await service.trackSessionWorkspaceFolder(sessionId, inMemoryPath);259260// Even if metadata store would return something different261metadataStore.getSessionWorkspaceFolder.mockResolvedValueOnce(vscode.Uri.file('/store/different'));262263const result = await service.getSessionWorkspaceFolder(sessionId);264expect(result?.fsPath).toBe(inMemoryPath);265});266});267268describe('deleteTrackedWorkspaceFolder', () => {269it('should delete tracked folder for session', async () => {270const sessionId = 'session-1';271const folderPath = vscode.Uri.file('/path/to/folder').fsPath;272273await service.trackSessionWorkspaceFolder(sessionId, folderPath);274expect(await service.getSessionWorkspaceFolder(sessionId)).toBeDefined();275276await service.deleteTrackedWorkspaceFolder(sessionId);277expect(await service.getSessionWorkspaceFolder(sessionId)).toBeUndefined();278});279280it('should call metadata store when deleting', async () => {281const sessionId = 'session-1';282await service.trackSessionWorkspaceFolder(sessionId, vscode.Uri.file('/path/to/folder').fsPath);283284await service.deleteTrackedWorkspaceFolder(sessionId);285286expect(metadataStore.deleteSessionMetadata).toHaveBeenCalledWith(sessionId);287});288289it('should invalidate workspace changes cache when deleting a tracked folder', async () => {290const repo = {291rootUri: URI.file('/repo'),292kind: 'repository' as const,293isUsingVirtualFileSystem: false,294headBranchName: 'main',295headCommitHash: 'abc123',296headIncomingChanges: 0,297headOutgoingChanges: 0,298upstreamBranchName: undefined,299upstreamRemote: undefined,300isRebasing: false,301remotes: [],302remoteFetchUrls: [],303worktrees: [],304changes: { mergeChanges: [], indexChanges: [], workingTree: [], untrackedChanges: [] },305headBranchNameObs: constObservable('main'),306headCommitHashObs: observableValue('test-head-commit', 'abc123'),307upstreamBranchNameObs: constObservable(undefined),308upstreamRemoteObs: constObservable(undefined),309isRebasingObs: constObservable(false),310isIgnored: async () => false,311} as RepoContext;312313gitService.getRepository = vi.fn().mockResolvedValue(repo);314315const sessionId1 = 'session-1';316const sessionId2 = 'session-2';317const sharedProperties = {318repositoryPath: '/repo',319branchName: 'main',320baseBranchName: 'origin/main',321};322323await service.trackSessionWorkspaceFolder(sessionId1, '/repo', sharedProperties);324await service.trackSessionWorkspaceFolder(sessionId2, '/repo', sharedProperties);325326await service.getWorkspaceChanges(sessionId1);327await service.deleteTrackedWorkspaceFolder(sessionId1);328await service.getWorkspaceChanges(sessionId2);329330expect(gitService.getRepository).toHaveBeenCalledTimes(2);331});332333it('should handle deletion of non-existent session', async () => {334// Should not throw335await expect(service.deleteTrackedWorkspaceFolder('non-existent')).resolves.toBeUndefined();336});337338it('should not affect other sessions when deleting one', async () => {339const session1 = 'session-1';340const session2 = 'session-2';341342await service.trackSessionWorkspaceFolder(session1, vscode.Uri.file('/path/1').fsPath);343await service.trackSessionWorkspaceFolder(session2, vscode.Uri.file('/path/2').fsPath);344345await service.deleteTrackedWorkspaceFolder(session1);346347expect(await service.getSessionWorkspaceFolder(session1)).toBeUndefined();348expect(await service.getSessionWorkspaceFolder(session2)).toBeDefined();349});350});351352describe('cleanupOldEntries', () => {353it('should keep newer entries and remove older ones', async () => {354const MAX_ENTRIES = 1500;355356// Create old entries with predictable timestamps357const oldData: Record<string, unknown> = {};358for (let i = 0; i < MAX_ENTRIES; i++) {359oldData[`session-old-${i}`] = {360folderPath: vscode.Uri.file(`/old/path/${i}`).fsPath,361timestamp: 1000 + i // Older timestamps362};363}364await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', oldData);365366// Add a new entry with current timestamp367const now = Date.now();368const data = extensionContext.globalState.get<Record<string, unknown>>('github.copilot.cli.sessionWorkspaceFolders', {});369(data as any)['session-new'] = {370folderPath: vscode.Uri.file('/new/path').fsPath,371timestamp: now372};373await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', data);374375// Trigger cleanup by adding another entry376await service.trackSessionWorkspaceFolder('session-trigger', vscode.Uri.file('/trigger/path').fsPath);377378const finalData = extensionContext.globalState.get<Record<string, unknown>>('github.copilot.cli.sessionWorkspaceFolders', {});379380// The newest entries should be preserved381expect(finalData['session-new']).toBeDefined();382});383});384385describe('integration scenarios', () => {386describe('getWorkspaceChanges - cache invalidation', () => {387let headCommitHash: ReturnType<typeof observableValue<string | undefined>>;388389function makeRepoContext(overrides?: Partial<RepoContext>): RepoContext {390headCommitHash = observableValue('test-head-commit', 'abc123');391return {392rootUri: URI.file('/repo'),393kind: 'repository',394headBranchName: 'main',395headCommitHash: 'abc123',396upstreamBranchName: undefined,397upstreamRemote: undefined,398isRebasing: false,399remotes: [],400remoteFetchUrls: [],401worktrees: [],402changes: { mergeChanges: [], indexChanges: [], workingTree: [], untrackedChanges: [] },403headBranchNameObs: constObservable('main'),404headCommitHashObs: headCommitHash,405upstreamBranchNameObs: constObservable(undefined),406upstreamRemoteObs: constObservable(undefined),407isRebasingObs: constObservable(false),408isIgnored: async () => false,409...overrides,410} as RepoContext;411}412413it('should return cached changes on second call', async () => {414const repo = makeRepoContext();415gitService.getRepository = vi.fn().mockResolvedValue(repo);416gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });417418const sessionId = 'session-1';419await metadataStore.storeRepositoryProperties(sessionId, {420repositoryPath: '/repo',421branchName: 'main',422});423424const first = await service.getWorkspaceChanges(sessionId);425const second = await service.getWorkspaceChanges(sessionId);426427expect(first).toBe(second);428// getRepository is called once for the first call, the second uses cache429expect(gitService.getRepository).toHaveBeenCalledTimes(1);430});431432it('should invalidate cache when clearWorkspaceChanges is called', async () => {433const repo = makeRepoContext();434gitService.getRepository = vi.fn().mockResolvedValue(repo);435gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });436437const sessionId = 'session-1';438await metadataStore.storeRepositoryProperties(sessionId, {439repositoryPath: '/repo',440branchName: 'main',441});442443await service.getWorkspaceChanges(sessionId);444service.clearWorkspaceChanges(sessionId);445446await service.getWorkspaceChanges(sessionId);447expect(gitService.getRepository).toHaveBeenCalledTimes(2);448});449450it('should invalidate cache when handleRequestCompleted is called', async () => {451const repo = makeRepoContext();452gitService.getRepository = vi.fn().mockResolvedValue(repo);453gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });454455const sessionId = 'session-1';456await metadataStore.storeRepositoryProperties(sessionId, {457repositoryPath: '/repo',458branchName: 'main',459});460461await service.getWorkspaceChanges(sessionId);462await service.handleRequestCompleted(sessionId);463464await service.getWorkspaceChanges(sessionId);465expect(gitService.getRepository).toHaveBeenCalledTimes(2);466});467468it('should return empty array when repository has no changes', async () => {469const repo = makeRepoContext({ changes: undefined });470gitService.getRepository = vi.fn().mockResolvedValue(repo);471await metadataStore.storeRepositoryProperties('session-1', {472repositoryPath: '/repo',473branchName: 'main',474});475476const result = await service.getWorkspaceChanges('session-1');477expect(result).toEqual([]);478});479480it('should return empty array when no repository is found', async () => {481gitService.getRepository = vi.fn().mockResolvedValue(undefined);482await metadataStore.storeRepositoryProperties('session-1', {483repositoryPath: '/repo',484branchName: 'main',485});486487const result = await service.getWorkspaceChanges('session-1');488expect(result).toEqual([]);489});490491it('should cache empty result when session has no repository properties', async () => {492// Session with no stored repository properties493const result1 = await service.getWorkspaceChanges('no-repo-session');494const result2 = await service.getWorkspaceChanges('no-repo-session');495496expect(result1).toEqual([]);497expect(result2).toEqual([]);498// Should only read metadata once — subsequent call uses the negative cache499expect(metadataStore.getRepositoryProperties).toHaveBeenCalledTimes(1);500});501502it('should clear negative cache when repository properties are later provided via trackSessionWorkspaceFolder', async () => {503const repo = makeRepoContext();504gitService.getRepository = vi.fn().mockResolvedValue(repo);505506// First call: no repo properties → negative-cached, returns []507const result1 = await service.getWorkspaceChanges('late-init-session');508expect(result1).toEqual([]);509510// Later: repo properties are provided via trackSessionWorkspaceFolder511await service.trackSessionWorkspaceFolder('late-init-session', '/repo', {512repositoryPath: '/repo',513branchName: 'main',514});515516// Second call: negative cache should be cleared, should re-read metadata517const result2 = await service.getWorkspaceChanges('late-init-session');518expect(result2).toBeDefined();519expect(metadataStore.getRepositoryProperties).toHaveBeenCalledTimes(2);520});521522it('should not re-fetch when cache is valid for a folder', async () => {523const repo = makeRepoContext();524gitService.getRepository = vi.fn().mockResolvedValue(repo);525gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });526527const sessionId = 'session-1';528await metadataStore.storeRepositoryProperties(sessionId, {529repositoryPath: '/repo',530branchName: 'main',531});532533// Clear cache between calls to force re-entry into getWorkspaceChanges534await service.getWorkspaceChanges(sessionId);535service.clearWorkspaceChanges(sessionId);536await service.getWorkspaceChanges(sessionId);537538service.clearWorkspaceChanges(sessionId);539await service.getWorkspaceChanges(sessionId);540541// All 3 calls should have hit getRepository (cache was manually cleared each time)542expect(gitService.getRepository).toHaveBeenCalledTimes(3);543});544545it('should track changes per workspace folder independently', async () => {546const repo1 = makeRepoContext();547const repo2 = makeRepoContext();548549const folder1 = vscode.Uri.file('/repo1');550const folder2 = vscode.Uri.file('/repo2');551552gitService.getRepository = vi.fn()553.mockImplementation((uri: URI) => {554if (uri.fsPath === folder1.fsPath) {555return Promise.resolve(repo1);556}557return Promise.resolve(repo2);558});559gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 0, deletions: 0 });560561const sessionId1 = 'session-1';562const sessionId2 = 'session-2';563564await service.trackSessionWorkspaceFolder(sessionId1, folder1.fsPath, {565repositoryPath: folder1.fsPath,566branchName: 'main',567});568await service.trackSessionWorkspaceFolder(sessionId2, folder2.fsPath, {569repositoryPath: folder2.fsPath,570branchName: 'main',571});572573await service.getWorkspaceChanges(sessionId1);574await service.getWorkspaceChanges(sessionId2);575576// Invalidate only sessionId1's cache577service.clearWorkspaceChanges(sessionId1);578579// sessionId2 should still use cache580await service.getWorkspaceChanges(sessionId2);581// sessionId1 needs refresh582await service.getWorkspaceChanges(sessionId1);583584// sessionId1: called twice (initial + after invalidation), sessionId2: called once (cached)585const calls = (gitService.getRepository as ReturnType<typeof vi.fn>).mock.calls;586const sessionId1Calls = calls.filter((c: URI[]) => c[0].fsPath === folder1.fsPath).length;587const sessionId2Calls = calls.filter((c: URI[]) => c[0].fsPath === folder2.fsPath).length;588expect(sessionId1Calls).toBe(2);589expect(sessionId2Calls).toBe(1);590});591592it('should serialize git operations for different sessions sharing the same repo, base branch and branch', async () => {593const repo = makeRepoContext();594const repoPath = '/shared-repo';595596gitService.getRepository = vi.fn().mockImplementation(async () => {597// Simulate async work598await new Promise(resolve => setTimeout(resolve, 10));599return repo;600});601602const sessionId1 = 'session-A';603const sessionId2 = 'session-B';604605await metadataStore.storeRepositoryProperties(sessionId1, {606repositoryPath: repoPath,607branchName: 'feature',608baseBranchName: 'main',609});610await metadataStore.storeRepositoryProperties(sessionId2, {611repositoryPath: repoPath,612branchName: 'feature',613baseBranchName: 'main',614});615616// Fire both concurrently — they share the same repo+baseBranch617const [result1, result2] = await Promise.all([618service.getWorkspaceChanges(sessionId1),619service.getWorkspaceChanges(sessionId2),620]);621622expect(result1).toBeDefined();623expect(result2).toBeDefined();624625// Session B should reuse the result computed by session A via shared repo-level cache626expect(result1).toBe(result2);627expect(gitService.getRepository).toHaveBeenCalledTimes(1);628});629630it('should not share cache for sessions with different branch names in the same repo and base branch', async () => {631const repo = makeRepoContext();632const repoPath = '/shared-repo';633634gitService.getRepository = vi.fn().mockImplementation(async () => {635await new Promise(resolve => setTimeout(resolve, 10));636return repo;637});638639await metadataStore.storeRepositoryProperties('session-main', {640repositoryPath: repoPath,641branchName: 'main',642baseBranchName: 'origin/main',643});644await metadataStore.storeRepositoryProperties('session-feature', {645repositoryPath: repoPath,646branchName: 'feature',647baseBranchName: 'origin/main',648});649650await Promise.all([651service.getWorkspaceChanges('session-main'),652service.getWorkspaceChanges('session-feature'),653]);654655expect(gitService.getRepository).toHaveBeenCalledTimes(2);656});657658it('should invalidate cache for all sessions when clearWorkspaceChanges is called with folder URI', async () => {659const folder = vscode.Uri.file('/shared-folder');660const repo = makeRepoContext({ rootUri: URI.file('/shared-folder') });661662gitService.getRepository = vi.fn().mockResolvedValue(repo);663gitService.diffIndexWithHEADShortStats = vi.fn().mockResolvedValue({ insertions: 1, deletions: 0 });664665const sessionId1 = 'session-1';666const sessionId2 = 'session-2';667668await service.trackSessionWorkspaceFolder(sessionId1, folder.fsPath, {669repositoryPath: folder.fsPath,670branchName: 'main',671});672await service.trackSessionWorkspaceFolder(sessionId2, folder.fsPath, {673repositoryPath: folder.fsPath,674branchName: 'develop',675});676677// Populate caches678await service.getWorkspaceChanges(sessionId1);679await service.getWorkspaceChanges(sessionId2);680expect(gitService.getRepository).toHaveBeenCalledTimes(2);681682// Clear via folder URI683const clearedIds = service.clearWorkspaceChanges(folder);684expect(clearedIds).toContain(sessionId1);685expect(clearedIds).toContain(sessionId2);686687// Both sessions should need to re-fetch688await service.getWorkspaceChanges(sessionId1);689await service.getWorkspaceChanges(sessionId2);690expect(gitService.getRepository).toHaveBeenCalledTimes(4);691});692693it('should return empty array when clearWorkspaceChanges is called with untracked folder URI', () => {694const unknownFolder = vscode.Uri.file('/unknown-folder');695const result = service.clearWorkspaceChanges(unknownFolder);696expect(result).toEqual([]);697});698699it('should populate folder associations eagerly on trackSessionWorkspaceFolder', async () => {700const folder = vscode.Uri.file('/my-folder');701const sessionId = 'session-eager';702703// Before tracking, no associations704expect(service.clearWorkspaceChanges(folder)).toEqual([]);705706await service.trackSessionWorkspaceFolder(sessionId, folder.fsPath);707708// After tracking, association exists immediately (no need to call getWorkspaceChanges first)709expect(service.clearWorkspaceChanges(folder)).toEqual([sessionId]);710});711712it('should clean up folder associations on deleteTrackedWorkspaceFolder', async () => {713const folder = vscode.Uri.file('/cleanup-folder');714const sessionId = 'session-cleanup';715716await service.trackSessionWorkspaceFolder(sessionId, folder.fsPath);717expect(service.clearWorkspaceChanges(folder)).toEqual([sessionId]);718719await service.deleteTrackedWorkspaceFolder(sessionId);720expect(service.clearWorkspaceChanges(folder)).toEqual([]);721});722});723});724});725726727