Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.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 { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';8import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';9import { ILogService } from '../../../../platform/log/common/logService';10import { NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';11import { mock } from '../../../../util/common/test/simpleMock';12import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';13import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';14import { URI } from '../../../../util/vs/base/common/uri';15import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes';16import { MockChatResponseStream } from '../../../test/node/testHelpers';17import type { IToolsService } from '../../../tools/common/toolsService';18import { RepositoryProperties } from '../../common/chatSessionMetadataStore';19import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';20import { ChatSessionWorktreeFile, ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';21import { IFolderRepositoryManager } from '../../common/folderRepositoryManager';22import { ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';23import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl';24import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore';25import type { IClaudeSessionStateService } from '../../claude/common/claudeSessionStateService';26import type { ClaudeFolderInfo } from '../../claude/common/claudeFolderInfo';2728/**29* Fake implementation of IChatSessionWorktreeService for testing.30*/31class FakeChatSessionWorktreeService extends mock<IChatSessionWorktreeService>() {32private _worktreeProperties = new Map<string, ChatSessionWorktreeProperties>();3334override createWorktree = vi.fn(async (_repositoryPath: vscode.Uri, _stream?: vscode.ChatResponseStream, _baseBranch?: string): Promise<ChatSessionWorktreeProperties | undefined> => {35return undefined;36});3738override getWorktreeProperties = vi.fn(async (sessionId: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> => {39return this._worktreeProperties.get(typeof sessionId === 'string' ? sessionId : sessionId.fsPath);40});4142override setWorktreeProperties = vi.fn(async (sessionId: string, properties: string | ChatSessionWorktreeProperties): Promise<void> => {43if (typeof properties === 'string') {44return;45}46this._worktreeProperties.set(sessionId, properties);47});4849override getWorktreePath = vi.fn(async (sessionId: string): Promise<vscode.Uri | undefined> => {50const props = this._worktreeProperties.get(sessionId);51return props ? vscode.Uri.file(props.worktreePath) : undefined;52});5354setTestWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): void {55this._worktreeProperties.set(sessionId, properties);56}57}5859/**60* Fake implementation of IChatSessionWorkspaceFolderService for testing.61*/62class FakeChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {63private _sessionWorkspaceFolders = new Map<string, vscode.Uri>();64private _sessionWorkspaceFolderRepositories = new Map<string, vscode.Uri | undefined>();65private _workspaceChanges = new Map<string, readonly ChatSessionWorktreeFile[] | undefined>();6667override trackSessionWorkspaceFolder = vi.fn(async (sessionId: string, workspaceFolderUri: string, repositoryProperties?: RepositoryProperties): Promise<void> => {68this._sessionWorkspaceFolders.set(sessionId, vscode.Uri.file(workspaceFolderUri));69this._sessionWorkspaceFolderRepositories.set(sessionId, repositoryProperties?.repositoryPath ? vscode.Uri.file(repositoryProperties.repositoryPath) : undefined);70});7172override deleteTrackedWorkspaceFolder = vi.fn(async (sessionId: string): Promise<void> => {73this._sessionWorkspaceFolders.delete(sessionId);74this._sessionWorkspaceFolderRepositories.delete(sessionId);75});7677override getSessionWorkspaceFolder = vi.fn(async (sessionId: string): Promise<vscode.Uri | undefined> => {78return this._sessionWorkspaceFolders.get(sessionId);79});8081override getSessionWorkspaceFolderEntry = vi.fn(async (sessionId: string) => {82const folder = this._sessionWorkspaceFolders.get(sessionId);83if (!folder) {84return undefined;85}8687return {88folderPath: folder.fsPath,89timestamp: Date.now()90};91});9293override getRepositoryProperties = vi.fn(async (_sessionId: string): Promise<RepositoryProperties | undefined> => {94return undefined;95});9697override handleRequestCompleted = vi.fn(async (_sessionId: string): Promise<void> => { });9899override getWorkspaceChanges = vi.fn(async (sessionId: string): Promise<readonly ChatSessionWorktreeFile[] | undefined> => {100return this._workspaceChanges.get(sessionId);101});102103setTestSessionWorkspaceFolder(sessionId: string, folder: vscode.Uri): void {104this._sessionWorkspaceFolders.set(sessionId, folder);105}106107override clearWorkspaceChanges(sessionIdOrFolderUri: string | vscode.Uri): string[] {108if (typeof sessionIdOrFolderUri === 'string') {109this._workspaceChanges.delete(sessionIdOrFolderUri);110}111return [];112}113}114115/**116* Fake implementation of ICopilotCLISessionService for testing.117*/118class FakeCopilotCLISessionService extends mock<ICopilotCLISessionService>() {119private _sessionWorkingDirs = new Map<string, vscode.Uri>();120121override getSessionWorkingDirectory = vi.fn((sessionId: string): vscode.Uri | undefined => {122return this._sessionWorkingDirs.get(sessionId);123});124125setTestSessionWorkingDirectory(sessionId: string, uri: vscode.Uri): void {126this._sessionWorkingDirs.set(sessionId, uri);127}128}129130/**131* Fake implementation of IGitService for testing.132*/133class FakeGitService extends mock<IGitService>() {134private _repositories = new Map<string, RepoContext>();135private _recentRepositories: { rootUri: vscode.Uri; lastAccessTime: number }[] = [];136private _activeRepo: RepoContext | undefined;137138override activeRepository = {139get: () => this._activeRepo140} as unknown as IGitService['activeRepository'];141142override repositories: RepoContext[] = [];143144override async getRepository(uri: vscode.Uri, _forceOpen?: boolean): Promise<RepoContext | undefined> {145return this._repositories.get(uri.fsPath);146}147148override getRecentRepositories = vi.fn((): { rootUri: vscode.Uri; lastAccessTime: number }[] => {149return this._recentRepositories;150});151152setTestRepository(uri: vscode.Uri, repo: RepoContext): void {153this._repositories.set(uri.fsPath, repo);154}155156setTestRecentRepositories(repos: { rootUri: vscode.Uri; lastAccessTime: number }[]): void {157this._recentRepositories = repos;158}159160setTestActiveRepository(repo: RepoContext | undefined): void {161this._activeRepo = repo;162if (repo) {163this._repositories.set(repo.rootUri.fsPath, repo);164}165}166}167168/**169* Mock workspace service that tracks trust requests.170*/171/**172* Fake implementation of IToolsService for testing.173*/174class FakeToolsService extends mock<IToolsService>() {175nextConfirmationButton: string | undefined = undefined;176override getTool(name: string) {177if (name === 'vscode_get_modified_files_confirmation') {178return { name } as any;179}180return undefined;181}182override invokeTool = vi.fn(async (name: string, _options: unknown, _token: unknown) => {183if (name === 'vscode_get_modified_files_confirmation') {184const button = this.nextConfirmationButton;185if (button !== undefined) {186return new LanguageModelToolResult2([new LanguageModelTextPart(button)]);187}188return new LanguageModelToolResult2([]);189}190return new LanguageModelToolResult2([]);191});192}193194/**195* Mock workspace service that tracks trust requests.196*/197class MockWorkspaceService extends NullWorkspaceService {198public trustRequests: vscode.Uri[] = [];199public trustResponse = true;200201constructor(folders: vscode.Uri[] = []) {202super(folders);203}204205override async requestResourceTrust(options: { uri: vscode.Uri; message: string }): Promise<boolean> {206this.trustRequests.push(options.uri);207return this.trustResponse;208}209}210211/**212* FakeFolderRepositoryManager for use in other tests.213* Provides a configurable mock of IFolderRepositoryManager.214*/215export class FakeFolderRepositoryManager extends mock<IFolderRepositoryManager>() {216private _untitledSessionFolders = new Map<string, vscode.Uri>();217private _folderRepoInfo = new Map<string, {218folder: vscode.Uri | undefined;219repository: vscode.Uri | undefined;220repositoryProperties?: RepositoryProperties;221worktree: vscode.Uri | undefined;222trusted: boolean | undefined;223worktreeProperties: ChatSessionWorktreeProperties | undefined;224}>();225226override setNewSessionFolder = vi.fn((sessionId: string, folderUri: vscode.Uri): void => {227if (!sessionId.startsWith('untitled:') && !sessionId.startsWith('untitled-')) {228throw new Error(`Cannot set folder for non-untitled session: ${sessionId}`);229}230this._untitledSessionFolders.set(sessionId, folderUri);231});232233override getFolderRepository = vi.fn(async (234sessionId: string,235_options: { promptForTrust: true; stream: vscode.ChatResponseStream } | undefined,236_token: vscode.CancellationToken237) => {238const info = this._folderRepoInfo.get(sessionId);239return info ?? { folder: undefined, repository: undefined, repositoryProperties: undefined, worktree: undefined, trusted: undefined, worktreeProperties: undefined };240});241242override initializeFolderRepository = vi.fn(async (243sessionId: string | undefined,244_options: { stream: vscode.ChatResponseStream; toolInvocationToken: vscode.ChatParticipantToolToken },245_token: vscode.CancellationToken246) => {247const info = sessionId ? this._folderRepoInfo.get(sessionId) : undefined;248return {249folder: info?.folder,250repository: info?.repository,251repositoryProperties: info?.repositoryProperties,252worktree: info?.worktree,253trusted: info?.trusted ?? true,254worktreeProperties: info?.worktreeProperties255};256});257258override getFolderMRU = vi.fn(() => {259return Promise.resolve([]);260});261262override deleteNewSessionFolder = vi.fn((sessionId: string): void => {263this._untitledSessionFolders.delete(sessionId);264});265266override getRepositoryInfo = vi.fn(async (267_folder: vscode.Uri,268_token: vscode.CancellationToken269) => {270return { repository: undefined, headBranchName: undefined };271});272273setTestFolderRepositoryInfo(sessionId: string, info: {274folder: vscode.Uri | undefined;275repository: vscode.Uri | undefined;276repositoryProperties?: RepositoryProperties;277worktree: vscode.Uri | undefined;278trusted: boolean | undefined;279worktreeProperties: ChatSessionWorktreeProperties | undefined;280}): void {281this._folderRepoInfo.set(sessionId, info);282}283}284285describe('CopilotCLIFolderRepositoryManager', () => {286const disposables = new DisposableStore();287let manager: CopilotCLIFolderRepositoryManager;288let worktreeService: FakeChatSessionWorktreeService;289let workspaceFolderService: FakeChatSessionWorkspaceFolderService;290let sessionService: FakeCopilotCLISessionService;291let gitService: FakeGitService;292let workspaceService: MockWorkspaceService;293let logService: ILogService;294let toolsService: FakeToolsService;295let fileSystem: MockFileSystemService;296297beforeEach(() => {298worktreeService = new FakeChatSessionWorktreeService();299workspaceFolderService = new FakeChatSessionWorkspaceFolderService();300sessionService = new FakeCopilotCLISessionService();301gitService = new FakeGitService();302workspaceService = new MockWorkspaceService([URI.file('/workspace')]);303logService = new class extends mock<ILogService>() {304override trace = vi.fn();305override info = vi.fn();306override warn = vi.fn();307override error = vi.fn();308}();309toolsService = new FakeToolsService();310fileSystem = new MockFileSystemService();311312manager = new CopilotCLIFolderRepositoryManager(313worktreeService,314workspaceFolderService,315sessionService,316gitService,317workspaceService,318logService,319toolsService,320fileSystem,321new MockChatSessionMetadataStore()322);323});324325afterEach(() => {326vi.restoreAllMocks();327disposables.clear();328});329330describe('getFolderRepository', () => {331it('returns folder info from memory for untitled sessions', async () => {332const sessionId = 'untitled:test-123';333const folderUri = vscode.Uri.file('/my/folder');334const token = disposables.add(new CancellationTokenSource()).token;335336manager.setNewSessionFolder(sessionId, folderUri);337338const result = await manager.getFolderRepository(sessionId, undefined, token);339340expect(result.folder?.fsPath).toBe(vscode.Uri.file('/my/folder').fsPath);341expect(result.repository).toBeUndefined();342expect(result.worktree).toBeUndefined();343expect(result.trusted).toBeUndefined();344});345346it('returns worktree info for sessions with worktrees', async () => {347const sessionId = 'cli-123';348const token = disposables.add(new CancellationTokenSource()).token;349350worktreeService.setTestWorktreeProperties(sessionId, {351autoCommit: true,352baseCommit: 'abc123',353branchName: 'copilot-worktree',354repositoryPath: '/repo',355worktreePath: '/repo-worktree',356version: 1357});358359const result = await manager.getFolderRepository(sessionId, undefined, token);360361expect(result.folder?.fsPath).toBe(vscode.Uri.file('/repo').fsPath);362expect(result.repository?.fsPath).toBe(vscode.Uri.file('/repo').fsPath);363expect(result.worktree?.fsPath).toBe(vscode.Uri.file('/repo-worktree').fsPath);364});365366it('returns workspace folder for sessions without worktrees', async () => {367const sessionId = 'cli-456';368const token = disposables.add(new CancellationTokenSource()).token;369const folderUri = vscode.Uri.file('/workspace/project');370371workspaceFolderService.setTestSessionWorkspaceFolder(sessionId, folderUri);372373const result = await manager.getFolderRepository(sessionId, undefined, token);374375expect(result.folder?.fsPath).toBe(vscode.Uri.file('/workspace/project').fsPath);376expect(result.repository).toBeUndefined();377expect(result.worktree).toBeUndefined();378});379380it('falls back to CLI session working directory', async () => {381const sessionId = 'cli-789';382const token = disposables.add(new CancellationTokenSource()).token;383const cwdUri = vscode.Uri.file('/terminal/cwd');384385sessionService.setTestSessionWorkingDirectory(sessionId, cwdUri);386await fileSystem.createDirectory(URI.file('/terminal/cwd'));387388const result = await manager.getFolderRepository(sessionId, undefined, token);389390expect(result.folder?.fsPath).toBe(vscode.Uri.file('/terminal/cwd').fsPath);391});392393it('prompts for trust when option is set', async () => {394const sessionId = 'cli-123';395const token = disposables.add(new CancellationTokenSource()).token;396const stream = new MockChatResponseStream();397398worktreeService.setTestWorktreeProperties(sessionId, {399autoCommit: true,400baseCommit: 'abc123',401branchName: 'copilot-worktree',402repositoryPath: '/repo',403worktreePath: '/repo-worktree',404version: 1405});406407const result = await manager.getFolderRepository(408sessionId,409{ promptForTrust: true, stream },410token411);412413expect(result.trusted).toBe(true);414expect(workspaceService.trustRequests.length).toBe(1);415});416417it('returns trusted: false when trust denied', async () => {418const sessionId = 'cli-123';419const token = disposables.add(new CancellationTokenSource()).token;420const stream = new MockChatResponseStream();421workspaceService.trustResponse = false;422423worktreeService.setTestWorktreeProperties(sessionId, {424autoCommit: true,425baseCommit: 'abc123',426branchName: 'copilot-worktree',427repositoryPath: '/repo',428worktreePath: '/repo-worktree',429version: 1430});431432const result = await manager.getFolderRepository(433sessionId,434{ promptForTrust: true, stream },435token436);437438expect(result.trusted).toBe(false);439});440441it('checks trust on repository path, not worktree path', async () => {442const sessionId = 'cli-123';443const token = disposables.add(new CancellationTokenSource()).token;444const stream = new MockChatResponseStream();445446worktreeService.setTestWorktreeProperties(sessionId, {447autoCommit: true,448baseCommit: 'abc123',449branchName: 'copilot-worktree',450repositoryPath: '/original-repo',451worktreePath: '/worktree-path',452version: 1453});454455await manager.getFolderRepository(456sessionId,457{ promptForTrust: true, stream },458token459);460461// Trust should be checked on repository path, not worktree path462expect(workspaceService.trustRequests[0].fsPath).toBe(vscode.Uri.file('/original-repo').fsPath);463});464});465466describe('initializeFolderRepository', () => {467const mockToolInvocationToken = {} as vscode.ChatParticipantToolToken;468469it('creates worktree when git repo selected', async () => {470const sessionId = 'untitled:test-123';471const token = disposables.add(new CancellationTokenSource()).token;472const stream = new MockChatResponseStream();473const folderUri = vscode.Uri.file('/my/repo');474475manager.setNewSessionFolder(sessionId, folderUri);476gitService.setTestRepository(folderUri, {477rootUri: folderUri,478remotes: [] as string[],479kind: 'repository'480} as RepoContext);481482(worktreeService.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({483autoCommit: true,484baseCommit: 'abc123',485branchName: 'copilot-worktree',486repositoryPath: '/my/repo',487worktreePath: '/my/repo-worktree',488version: 1489} satisfies ChatSessionWorktreeProperties);490491const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);492493expect(result.worktree?.fsPath).toBe(vscode.Uri.file('/my/repo-worktree').fsPath);494expect(result.repository?.fsPath).toBe(vscode.Uri.file('/my/repo').fsPath);495expect(result.trusted).toBe(true);496});497498it('falls back to folder when worktree creation fails', async () => {499const sessionId = 'untitled:test-123';500const token = disposables.add(new CancellationTokenSource()).token;501const stream = new MockChatResponseStream();502const folderUri = vscode.Uri.file('/my/repo');503504manager.setNewSessionFolder(sessionId, folderUri);505gitService.setTestRepository(folderUri, {506rootUri: folderUri,507remotes: [] as string[],508kind: 'repository'509} as RepoContext);510511(worktreeService.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);512513const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);514515expect(result.worktree).toBeUndefined();516expect(result.repository?.fsPath).toBe(vscode.Uri.file('/my/repo').fsPath);517expect(stream.output.some(o => /failed to create worktree/i.test(o))).toBe(true);518});519520it('handles workspace folder without git repo', async () => {521const sessionId = 'untitled:test-123';522const token = disposables.add(new CancellationTokenSource()).token;523const stream = new MockChatResponseStream();524const folderUri = vscode.Uri.file('/plain/folder');525526manager.setNewSessionFolder(sessionId, folderUri);527// No git repo set for this folder528529const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);530531expect(result.folder?.fsPath).toBe(vscode.Uri.file('/plain/folder').fsPath);532expect(result.repository).toBeUndefined();533expect(result.worktree).toBeUndefined();534expect(result.trusted).toBe(true);535});536537it('returns trusted: false when trust denied', async () => {538const sessionId = 'untitled:test-123';539const token = disposables.add(new CancellationTokenSource()).token;540const stream = new MockChatResponseStream();541const folderUri = vscode.Uri.file('/my/repo');542workspaceService.trustResponse = false;543544// Use empty workspace to trigger trust check545workspaceService = new MockWorkspaceService([]);546workspaceService.trustResponse = false;547manager = new CopilotCLIFolderRepositoryManager(548worktreeService,549workspaceFolderService,550sessionService,551gitService,552workspaceService,553logService,554toolsService,555new MockFileSystemService(),556new MockChatSessionMetadataStore()557);558559manager.setNewSessionFolder(sessionId, folderUri);560561const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);562563expect(result.trusted).toBe(false);564});565});566567describe('uncommitted changes prompting in initializeFolderRepository', () => {568const mockToolInvocationToken = {} as vscode.ChatParticipantToolToken;569570it('prompts when untitled session has repo with index changes', async () => {571const sessionId = 'untitled:test-123';572const folderUri = vscode.Uri.file('/my/repo');573const token = disposables.add(new CancellationTokenSource()).token;574const stream = new MockChatResponseStream();575toolsService.nextConfirmationButton = 'Copy Changes';576577manager.setNewSessionFolder(sessionId, folderUri);578gitService.setTestRepository(folderUri, {579rootUri: folderUri,580kind: 'repository',581remotes: [] as string[],582changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [], mergeChanges: [], untrackedChanges: [] }583} as unknown as RepoContext);584585await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);586expect(toolsService.invokeTool).toHaveBeenCalledWith(587'vscode_get_modified_files_confirmation',588expect.objectContaining({589input: expect.objectContaining({590title: 'Uncommitted Changes',591options: ['Copy Changes', 'Move Changes', 'Skip Changes'],592modifiedFiles: [593expect.objectContaining({594uri: expect.objectContaining({ path: '/my/repo/file.ts', scheme: 'file' })595})596]597})598}),599token600);601});602603it('prompts when untitled session has repo with working tree changes', async () => {604const sessionId = 'untitled:test-123';605const folderUri = vscode.Uri.file('/my/repo');606const token = disposables.add(new CancellationTokenSource()).token;607const stream = new MockChatResponseStream();608toolsService.nextConfirmationButton = 'Copy Changes';609610manager.setNewSessionFolder(sessionId, folderUri);611gitService.setTestRepository(folderUri, {612rootUri: folderUri,613kind: 'repository',614remotes: [] as string[],615changes: { indexChanges: [], workingTree: [{ path: 'file.ts' }], mergeChanges: [], untrackedChanges: [] }616} as unknown as RepoContext);617618await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);619expect(toolsService.invokeTool).toHaveBeenCalled();620});621622it('does not prompt when untitled session has repo with no changes', async () => {623const sessionId = 'untitled:test-123';624const folderUri = vscode.Uri.file('/my/repo');625const token = disposables.add(new CancellationTokenSource()).token;626const stream = new MockChatResponseStream();627628manager.setNewSessionFolder(sessionId, folderUri);629gitService.setTestRepository(folderUri, {630rootUri: folderUri,631kind: 'repository',632remotes: [] as string[],633changes: { indexChanges: [], workingTree: [], mergeChanges: [], untrackedChanges: [] }634} as unknown as RepoContext);635636await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);637expect(toolsService.invokeTool).not.toHaveBeenCalled();638});639640it('does not prompt when untitled session folder has no git repo', async () => {641const sessionId = 'untitled:test-123';642const folderUri = vscode.Uri.file('/plain/folder');643const token = disposables.add(new CancellationTokenSource()).token;644const stream = new MockChatResponseStream();645646manager.setNewSessionFolder(sessionId, folderUri);647648await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);649expect(toolsService.invokeTool).not.toHaveBeenCalled();650});651652it('returns cancelled when user cancels', async () => {653const sessionId = 'untitled:test-123';654const folderUri = vscode.Uri.file('/my/repo');655const token = disposables.add(new CancellationTokenSource()).token;656const stream = new MockChatResponseStream();657toolsService.nextConfirmationButton = 'Cancel';658659manager.setNewSessionFolder(sessionId, folderUri);660gitService.setTestRepository(folderUri, {661rootUri: folderUri,662kind: 'repository',663remotes: [] as string[],664changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [], mergeChanges: [], untrackedChanges: [] }665} as unknown as RepoContext);666667const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);668expect(result.cancelled).toBe(true);669});670671it('uses delegation title when no session ID', async () => {672const token = disposables.add(new CancellationTokenSource()).token;673const stream = new MockChatResponseStream();674toolsService.nextConfirmationButton = 'Copy Changes';675676gitService.setTestActiveRepository({677rootUri: vscode.Uri.file('/workspace'),678remotes: [] as string[],679kind: 'repository',680changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [], mergeChanges: [], untrackedChanges: [] }681} as unknown as RepoContext);682683await manager.initializeFolderRepository(undefined, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);684expect(toolsService.invokeTool).toHaveBeenCalledWith(685'vscode_get_modified_files_confirmation',686expect.objectContaining({687input: expect.objectContaining({688title: 'Delegate to Copilot CLI',689modifiedFiles: [690expect.objectContaining({691uri: expect.objectContaining({ path: '/workspace/file.ts', scheme: 'file' })692})693]694})695}),696token697);698});699700it('does not prompt for delegation without active repository', async () => {701const token = disposables.add(new CancellationTokenSource()).token;702const stream = new MockChatResponseStream();703704await manager.initializeFolderRepository(undefined, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);705expect(toolsService.invokeTool).not.toHaveBeenCalled();706});707708it('does not prompt for delegation in welcome view (no workspace folders)', async () => {709workspaceService = new MockWorkspaceService([]);710manager = new CopilotCLIFolderRepositoryManager(711worktreeService,712workspaceFolderService,713sessionService,714gitService,715workspaceService,716logService,717toolsService,718new MockFileSystemService(),719new MockChatSessionMetadataStore()720);721const token = disposables.add(new CancellationTokenSource()).token;722const stream = new MockChatResponseStream();723toolsService.nextConfirmationButton = 'Copy Changes';724725gitService.setTestActiveRepository({726rootUri: vscode.Uri.file('/workspace'),727remotes: [] as string[],728kind: 'repository',729changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [], mergeChanges: [], untrackedChanges: [] }730} as unknown as RepoContext);731732await manager.initializeFolderRepository(undefined, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);733expect(toolsService.invokeTool).not.toHaveBeenCalled();734});735});736737describe('worktree folder opened as workspace folder', () => {738const mockToolInvocationToken = {} as vscode.ChatParticipantToolToken;739const worktreeFolderPath = vscode.Uri.file('/repo-worktree').fsPath;740const originalRepoPath = vscode.Uri.file('/original-repo').fsPath;741const defaultWorktreeProps: ChatSessionWorktreeProperties = {742autoCommit: true,743baseCommit: 'abc123',744branchName: 'copilot-worktree',745repositoryPath: originalRepoPath,746worktreePath: worktreeFolderPath,747version: 1748};749750describe('initializeFolderRepository', () => {751function createMetadataStoreWithWorktree(): MockChatSessionMetadataStore {752const store = new MockChatSessionMetadataStore();753// Register a session whose worktree path matches worktreeFolderPath so that754// getWorktreeSessions(folderUri) returns a session ID that the worktreeService755// can resolve via getWorktreeProperties.756void store.storeWorktreeInfo(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);757return store;758}759760it('skips worktree creation when single workspace folder is already a tracked worktree', async () => {761workspaceService = new MockWorkspaceService([URI.file(worktreeFolderPath)]);762gitService.setTestActiveRepository({763rootUri: vscode.Uri.file(worktreeFolderPath),764kind: 'repository'765} as RepoContext);766worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);767manager = new CopilotCLIFolderRepositoryManager(768worktreeService, workspaceFolderService, sessionService,769gitService, workspaceService, logService, toolsService,770new MockFileSystemService(),771createMetadataStoreWithWorktree()772);773774const sessionId = 'untitled:wt-test-1';775const token = disposables.add(new CancellationTokenSource()).token;776const stream = new MockChatResponseStream();777778const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);779780expect(worktreeService.createWorktree).not.toHaveBeenCalled();781expect(result.worktreeProperties).toBeDefined();782expect(result.worktree?.fsPath).toBe(vscode.Uri.file(worktreeFolderPath).fsPath);783expect(result.repository?.fsPath).toBe(vscode.Uri.file(originalRepoPath).fsPath);784expect(result.trusted).toBe(true);785});786787it('skips worktree creation when explicitly selected folder is a tracked worktree', async () => {788worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);789manager = new CopilotCLIFolderRepositoryManager(790worktreeService, workspaceFolderService, sessionService,791gitService, workspaceService, logService, toolsService,792new MockFileSystemService(),793createMetadataStoreWithWorktree()794);795796const sessionId = 'untitled:wt-test-2';797const token = disposables.add(new CancellationTokenSource()).token;798const stream = new MockChatResponseStream();799800manager.setNewSessionFolder(sessionId, vscode.Uri.file(worktreeFolderPath));801802const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);803804expect(worktreeService.createWorktree).not.toHaveBeenCalled();805expect(result.worktreeProperties).toBeDefined();806expect(result.worktree?.fsPath).toBe(vscode.Uri.file(worktreeFolderPath).fsPath);807expect(result.repository?.fsPath).toBe(vscode.Uri.file(originalRepoPath).fsPath);808expect(result.trusted).toBe(true);809});810811it('skips uncommitted changes prompt when worktree already detected via single workspace folder', async () => {812workspaceService = new MockWorkspaceService([URI.file(worktreeFolderPath)]);813gitService.setTestActiveRepository({814rootUri: vscode.Uri.file(worktreeFolderPath),815kind: 'repository',816remotes: [] as string[],817changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [{ path: 'other.ts' }], mergeChanges: [], untrackedChanges: [] }818} as unknown as RepoContext);819worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);820manager = new CopilotCLIFolderRepositoryManager(821worktreeService, workspaceFolderService, sessionService,822gitService, workspaceService, logService, toolsService,823new MockFileSystemService(),824createMetadataStoreWithWorktree()825);826827const sessionId = 'untitled:wt-test-3';828const token = disposables.add(new CancellationTokenSource()).token;829const stream = new MockChatResponseStream();830831await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);832833expect(toolsService.invokeTool).not.toHaveBeenCalled();834expect(worktreeService.createWorktree).not.toHaveBeenCalled();835});836837it('skips uncommitted changes prompt when worktree already detected via explicit selection', async () => {838worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);839gitService.setTestRepository(vscode.Uri.file(worktreeFolderPath), {840rootUri: vscode.Uri.file(worktreeFolderPath),841kind: 'repository',842remotes: [] as string[],843changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [{ path: 'other.ts' }], mergeChanges: [], untrackedChanges: [] }844} as unknown as RepoContext);845manager = new CopilotCLIFolderRepositoryManager(846worktreeService, workspaceFolderService, sessionService,847gitService, workspaceService, logService, toolsService,848new MockFileSystemService(),849createMetadataStoreWithWorktree()850);851852const sessionId = 'untitled:wt-test-4';853const token = disposables.add(new CancellationTokenSource()).token;854const stream = new MockChatResponseStream();855856manager.setNewSessionFolder(sessionId, vscode.Uri.file(worktreeFolderPath));857858await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);859860expect(toolsService.invokeTool).not.toHaveBeenCalled();861expect(worktreeService.createWorktree).not.toHaveBeenCalled();862});863864it('resolves repository path from worktree properties instead of git service', async () => {865const differentRepo = '/different-repo';866worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);867// Git service would return a different repo for this folder868gitService.setTestRepository(vscode.Uri.file(worktreeFolderPath), {869rootUri: vscode.Uri.file(differentRepo),870kind: 'repository',871remotes: [] as string[],872} as RepoContext);873manager = new CopilotCLIFolderRepositoryManager(874worktreeService, workspaceFolderService, sessionService,875gitService, workspaceService, logService, toolsService,876new MockFileSystemService(),877createMetadataStoreWithWorktree()878);879880const sessionId = 'untitled:wt-test-5';881const token = disposables.add(new CancellationTokenSource()).token;882const stream = new MockChatResponseStream();883884manager.setNewSessionFolder(sessionId, vscode.Uri.file(worktreeFolderPath));885886const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);887888// Should use repositoryPath from worktreeProperties, not from git service889expect(result.repository?.fsPath).toBe(vscode.Uri.file(originalRepoPath).fsPath);890});891892it('verifies trust on original repository path from worktree properties', async () => {893workspaceService = new MockWorkspaceService([URI.file(worktreeFolderPath)]);894workspaceService.trustResponse = false;895gitService.setTestActiveRepository({896rootUri: vscode.Uri.file(worktreeFolderPath),897remotes: [] as string[],898kind: 'repository'899} as RepoContext);900worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);901manager = new CopilotCLIFolderRepositoryManager(902worktreeService, workspaceFolderService, sessionService,903gitService, workspaceService, logService, toolsService,904new MockFileSystemService(),905createMetadataStoreWithWorktree()906);907908const sessionId = 'untitled:wt-test-6';909const token = disposables.add(new CancellationTokenSource()).token;910const stream = new MockChatResponseStream();911912const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);913914// Trust should be checked on the original repository path, not the worktree folder915expect(workspaceService.trustRequests.some(uri => uri.fsPath === vscode.Uri.file(originalRepoPath).fsPath)).toBe(true);916expect(result.trusted).toBe(false);917});918919it('still creates worktree when folder is not a tracked worktree', async () => {920const regularRepo = vscode.Uri.file('/regular-repo');921workspaceService = new MockWorkspaceService([URI.file('/regular-repo')]);922gitService.setTestActiveRepository({923rootUri: regularRepo,924remotes: [] as string[],925kind: 'repository'926} as RepoContext);927// NO worktree properties registered — folder is not a tracked worktree928manager = new CopilotCLIFolderRepositoryManager(929worktreeService, workspaceFolderService, sessionService,930gitService, workspaceService, logService, toolsService,931new MockFileSystemService(),932new MockChatSessionMetadataStore()933);934935(worktreeService.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({936autoCommit: true,937baseCommit: 'def456',938branchName: 'copilot-new-wt',939repositoryPath: '/regular-repo',940worktreePath: '/regular-repo-worktree',941version: 1942} satisfies ChatSessionWorktreeProperties);943944const sessionId = 'untitled:wt-test-7';945const token = disposables.add(new CancellationTokenSource()).token;946const stream = new MockChatResponseStream();947948const result = await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);949950expect(worktreeService.createWorktree).toHaveBeenCalled();951expect(result.worktreeProperties).toBeDefined();952expect(result.worktree?.fsPath).toBe(vscode.Uri.file('/regular-repo-worktree').fsPath);953});954});955});956957describe('getRepositoryInfo', () => {958it('returns repository and head branch for a git repo folder', async () => {959const folderUri = vscode.Uri.file('/my/repo');960const token = disposables.add(new CancellationTokenSource()).token;961962gitService.setTestRepository(folderUri, {963rootUri: folderUri,964kind: 'repository',965headBranchName: 'main',966headCommitHash: 'abc123',967remotes: [] as string[]968} as RepoContext);969970const result = await manager.getRepositoryInfo(folderUri, token);971972expect(result.repository?.fsPath).toBe(vscode.Uri.file('/my/repo').fsPath);973expect(result.headBranchName).toBe('main');974});975976it('returns undefined repository for a non-git folder', async () => {977const folderUri = vscode.Uri.file('/plain/folder');978const token = disposables.add(new CancellationTokenSource()).token;979980const result = await manager.getRepositoryInfo(folderUri, token);981982expect(result.repository).toBeUndefined();983expect(result.headBranchName).toBeUndefined();984});985});986987describe('initializeFolderRepository with branch', () => {988const mockToolInvocationToken = {} as vscode.ChatParticipantToolToken;989990it('passes branch to createWorktree when provided', async () => {991const sessionId = 'untitled:test-branch';992const token = disposables.add(new CancellationTokenSource()).token;993const stream = new MockChatResponseStream();994const folderUri = vscode.Uri.file('/my/repo');995996manager.setNewSessionFolder(sessionId, folderUri);997gitService.setTestRepository(folderUri, {998rootUri: folderUri,999kind: 'repository',1000remotes: [] as string[]1001} as RepoContext);10021003(worktreeService.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({1004autoCommit: true,1005baseCommit: 'abc123',1006branchName: 'copilot-worktree',1007repositoryPath: '/my/repo',1008worktreePath: '/my/repo-worktree',1009version: 11010} satisfies ChatSessionWorktreeProperties);10111012await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, branch: 'feature-branch', folder: undefined }, token);10131014expect(worktreeService.createWorktree).toHaveBeenCalledWith(1015expect.anything(),1016expect.anything(),1017'feature-branch',1018undefined,1019);1020});10211022it('passes undefined branch when not provided', async () => {1023const sessionId = 'untitled:test-no-branch';1024const token = disposables.add(new CancellationTokenSource()).token;1025const stream = new MockChatResponseStream();1026const folderUri = vscode.Uri.file('/my/repo');10271028manager.setNewSessionFolder(sessionId, folderUri);1029gitService.setTestRepository(folderUri, {1030rootUri: folderUri,1031kind: 'repository',1032remotes: [] as string[]1033} as RepoContext);10341035(worktreeService.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({1036autoCommit: true,1037baseCommit: 'abc123',1038branchName: 'copilot-worktree',1039repositoryPath: '/my/repo',1040worktreePath: '/my/repo-worktree',1041version: 11042} satisfies ChatSessionWorktreeProperties);10431044await manager.initializeFolderRepository(sessionId, { stream, toolInvocationToken: mockToolInvocationToken, folder: undefined }, token);10451046expect(worktreeService.createWorktree).toHaveBeenCalledWith(1047expect.anything(),1048expect.anything(),1049undefined,1050undefined1051);1052});1053});10541055describe('edge cases', () => {1056it('handles empty workspace scenarios', async () => {1057// Create manager with no workspace folders1058workspaceService = new MockWorkspaceService([]);1059manager = new CopilotCLIFolderRepositoryManager(1060worktreeService,1061workspaceFolderService,1062sessionService,1063gitService,1064workspaceService,1065logService,1066toolsService,1067new MockFileSystemService(),1068new MockChatSessionMetadataStore()1069);10701071const sessionId = 'untitled:empty-test';1072const folderUri = vscode.Uri.file('/selected/folder');1073const token = disposables.add(new CancellationTokenSource()).token;10741075manager.setNewSessionFolder(sessionId, folderUri);10761077const result = await manager.getFolderRepository(sessionId, undefined, token);10781079expect(result.folder?.fsPath).toBe(vscode.Uri.file('/selected/folder').fsPath);1080});10811082it('returns undefined for unknown session', async () => {1083const sessionId = 'unknown-session';1084const token = disposables.add(new CancellationTokenSource()).token;10851086const result = await manager.getFolderRepository(sessionId, undefined, token);10871088expect(result.folder).toBeUndefined();1089expect(result.repository).toBeUndefined();1090expect(result.worktree).toBeUndefined();1091});1092});1093});10941095describe('ClaudeFolderRepositoryManager', () => {1096const disposables = new DisposableStore();1097let manager: ClaudeFolderRepositoryManager;1098let worktreeService: FakeChatSessionWorktreeService;1099let workspaceFolderService: FakeChatSessionWorkspaceFolderService;1100let gitService: FakeGitService;1101let workspaceService: MockWorkspaceService;1102let logService: ILogService;1103let toolsService: FakeToolsService;1104let sessionStateService: IClaudeSessionStateService;1105let folderInfoMap: Map<string, ClaudeFolderInfo>;1106let fileSystem: MockFileSystemService;11071108beforeEach(() => {1109worktreeService = new FakeChatSessionWorktreeService();1110workspaceFolderService = new FakeChatSessionWorkspaceFolderService();1111gitService = new FakeGitService();1112workspaceService = new MockWorkspaceService([URI.file('/workspace')]);1113logService = new class extends mock<ILogService>() {1114override trace = vi.fn();1115override info = vi.fn();1116override warn = vi.fn();1117override error = vi.fn();1118}();1119toolsService = new FakeToolsService();1120fileSystem = new MockFileSystemService();11211122folderInfoMap = new Map();1123sessionStateService = new class extends mock<IClaudeSessionStateService>() {1124override getFolderInfoForSession(sessionId: string): ClaudeFolderInfo | undefined {1125return folderInfoMap.get(sessionId);1126}1127}();11281129manager = new ClaudeFolderRepositoryManager(1130worktreeService,1131workspaceFolderService,1132gitService,1133workspaceService,1134logService,1135toolsService,1136sessionStateService,1137fileSystem,1138new MockChatSessionMetadataStore()1139);1140});11411142afterEach(() => {1143vi.restoreAllMocks();1144disposables.clear();1145});11461147describe('getFolderRepository', () => {1148it('returns worktree info for sessions with worktrees', async () => {1149const sessionId = 'test-session';1150const token = disposables.add(new CancellationTokenSource()).token;11511152worktreeService.setTestWorktreeProperties(sessionId, {1153autoCommit: true,1154baseCommit: 'abc123',1155branchName: 'test-branch',1156repositoryPath: '/repo/path',1157worktreePath: '/worktree/path',1158version: 11159});11601161const result = await manager.getFolderRepository(sessionId, undefined, token);11621163expect(result.folder?.fsPath).toBe(vscode.Uri.file('/repo/path').fsPath);1164expect(result.worktree?.fsPath).toBe(vscode.Uri.file('/worktree/path').fsPath);1165});11661167it('returns workspace folder for sessions without worktrees', async () => {1168const sessionId = 'test-session';1169const token = disposables.add(new CancellationTokenSource()).token;11701171workspaceFolderService.setTestSessionWorkspaceFolder(sessionId, vscode.Uri.file('/workspace/folder'));11721173const result = await manager.getFolderRepository(sessionId, undefined, token);11741175expect(result.folder?.fsPath).toBe(vscode.Uri.file('/workspace/folder').fsPath);1176});11771178it('falls back to session state folder info', async () => {1179const sessionId = 'test-session';1180const token = disposables.add(new CancellationTokenSource()).token;11811182folderInfoMap.set(sessionId, { cwd: '/claude/project', additionalDirectories: [] });1183await fileSystem.createDirectory(URI.file('/claude/project'));11841185const result = await manager.getFolderRepository(sessionId, undefined, token);11861187expect(result.folder?.fsPath).toBe(vscode.Uri.file('/claude/project').fsPath);1188});11891190it('returns empty result when fallback folder does not exist', async () => {1191const sessionId = 'test-session';1192const token = disposables.add(new CancellationTokenSource()).token;11931194folderInfoMap.set(sessionId, { cwd: '/nonexistent/path', additionalDirectories: [] });11951196const result = await manager.getFolderRepository(sessionId, undefined, token);11971198expect(result.folder).toBeUndefined();1199});12001201it('returns empty result when no folder info available', async () => {1202const sessionId = 'unknown-session';1203const token = disposables.add(new CancellationTokenSource()).token;12041205const result = await manager.getFolderRepository(sessionId, undefined, token);12061207expect(result.folder).toBeUndefined();1208expect(result.repository).toBeUndefined();1209expect(result.worktree).toBeUndefined();1210});1211});1212});121312141215