Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts
13399 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 { promises as fs } from 'fs';6import * as vscode from 'vscode';7import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';8import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';9import { buildTempIndexEnv, getUncommittedFilePaths, parseGitChangesRaw } from '../../../platform/git/vscode-node/utils';10import { DiffChange } from '../../../platform/git/vscode/git';11import { ILogService } from '../../../platform/log/common/logService';12import { SequencerByKey } from '../../../util/vs/base/common/async';13import { Disposable } from '../../../util/vs/base/common/lifecycle';14import { ResourceMap } from '../../../util/vs/base/common/map';15import * as path from '../../../util/vs/base/common/path';16import { generateUuid } from '../../../util/vs/base/common/uuid';17import { IChatSessionMetadataStore, RepositoryProperties, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore';18import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';19import { ChatSessionWorktreeFile } from '../common/chatSessionWorktreeService';2021/**22* Service for tracking workspace folder selections for chat sessions.23* This is used in multi-root workspaces where some folders may not have git repositories.24*/25export class ChatSessionWorkspaceFolderService extends Disposable implements IChatSessionWorkspaceFolderService {26declare _serviceBrand: undefined;2728private static readonly EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';29private readonly _onDidChangeWorkspaceFolderChanges = this._register(new vscode.EventEmitter<{ sessionId: string }>());30readonly onDidChangeWorkspaceFolderChanges = this._onDidChangeWorkspaceFolderChanges.event;3132private readonly workspaceState = new Map<string, WorkspaceFolderEntry>();33private readonly sessionRepoKeys = new Map<string, string>();34private readonly sessionsWithNoRepoProperties = new Set<string>();35private readonly workspaceFolderChanges = new Map<string, ChatSessionWorktreeFile[]>();36private readonly sessionsAssociatedWithFolders = new ResourceMap<Set<string>>();3738private readonly workspaceChangesSequencer = new SequencerByKey<string>();39private readonly repoChangesSequencer = new SequencerByKey<string>();4041constructor(42@IGitService private readonly gitService: IGitService,43@ILogService private readonly logService: ILogService,44@IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore,45@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,46) {47super();48}4950async deleteTrackedWorkspaceFolder(sessionId: string): Promise<void> {51this.invalidateSessionCache(sessionId);52const entry = this.workspaceState.get(sessionId);53if (entry?.folderPath) {54const folderUri = vscode.Uri.file(entry.folderPath);55this.sessionsAssociatedWithFolders.get(folderUri)?.delete(sessionId);56}57this.workspaceState.delete(sessionId);58await this.metadataStore.deleteSessionMetadata(sessionId);59}6061async trackSessionWorkspaceFolder(sessionId: string, workspaceFolderUri: string, repositoryProperties?: RepositoryProperties): Promise<void> {62const entry: WorkspaceFolderEntry = {63folderPath: workspaceFolderUri,64timestamp: Date.now()65};66this.workspaceState.set(sessionId, entry);6768// Associate session with workspace folder for cache invalidation69const folderUri = vscode.Uri.file(workspaceFolderUri);70const sessionIds = this.sessionsAssociatedWithFolders.get(folderUri) ?? new Set<string>();71sessionIds.add(sessionId);72this.sessionsAssociatedWithFolders.set(folderUri, sessionIds);7374await this.metadataStore.storeWorkspaceFolderInfo(sessionId, entry);75if (repositoryProperties) {76this.sessionsWithNoRepoProperties.delete(sessionId);77await this.metadataStore.storeRepositoryProperties(sessionId, repositoryProperties);78}79this.logService.trace(`[ChatSessionWorkspaceFolderService] Tracked workspace folder ${workspaceFolderUri} for session ${sessionId}`);80}8182async getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined> {83const entry = this.workspaceState.get(sessionId);84if (entry?.folderPath) {85return vscode.Uri.file(entry.folderPath);86}87return await this.metadataStore.getSessionWorkspaceFolder(sessionId);88}8990async getSessionWorkspaceFolderEntry(sessionId: string): Promise<WorkspaceFolderEntry | undefined> {91const entry = this.workspaceState.get(sessionId);92if (entry) {93return entry;94}95return await this.metadataStore.getSessionWorkspaceFolderEntry(sessionId);96}9798async getRepositoryProperties(sessionId: string): Promise<RepositoryProperties | undefined> {99return await this.metadataStore.getRepositoryProperties(sessionId);100}101102async handleRequestCompleted(sessionId: string): Promise<void> {103// Clear changes cache104this.invalidateSessionCache(sessionId);105}106107async hasCachedChanges(sessionId: string): Promise<boolean> {108const existingRepoKey = this.sessionRepoKeys.get(sessionId);109const cachedChanges = existingRepoKey ? this.workspaceFolderChanges.get(existingRepoKey) : undefined;110return !!cachedChanges;111}112113async getWorkspaceChanges(sessionId: string): Promise<readonly ChatSessionWorktreeFile[] | undefined> {114return this.workspaceChangesSequencer.queue(sessionId, async () => {115116// Fast path: session previously had no repository properties117if (this.sessionsWithNoRepoProperties.has(sessionId)) {118return [];119}120121// Fast path: check if we already have the repo key and a cached result122const existingRepoKey = this.sessionRepoKeys.get(sessionId);123const cachedChanges = existingRepoKey ? this.workspaceFolderChanges.get(existingRepoKey) : undefined;124if (cachedChanges) {125return cachedChanges;126}127128const repositoryProperties = await this.getRepositoryProperties(sessionId);129if (!repositoryProperties) {130this.logService.warn(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] No repository properties found for session ${sessionId}`);131this.sessionsWithNoRepoProperties.add(sessionId);132return [];133}134135const repoKey = `${repositoryProperties.repositoryPath}\0${repositoryProperties.baseBranchName ?? ''}\0${repositoryProperties.branchName ?? ''}`;136this.sessionRepoKeys.set(sessionId, repoKey);137138return this.repoChangesSequencer.queue(repoKey, async () => {139// Check cache again — another session may have computed it while we waited in the repo sequencer140const cachedChanges = this.workspaceFolderChanges.get(repoKey);141if (cachedChanges) {142return cachedChanges;143}144145const properties = await this.computeWorkspaceChanges(repositoryProperties, sessionId);146this.workspaceFolderChanges.set(repoKey, properties?.changes ?? []);147148if (properties) {149await this.metadataStore.storeRepositoryProperties(sessionId, {150...repositoryProperties,151mergeBaseCommit: properties.mergeBaseCommit,152hasGitHubRemote: properties.hasGitHubRemote,153upstreamBranchName: properties.upstreamBranchName,154incomingChanges: properties.incomingChanges,155outgoingChanges: properties.outgoingChanges,156uncommittedChanges: properties.uncommittedChanges157});158}159160return properties?.changes ?? [];161});162});163}164165private async computeWorkspaceChanges(repositoryProperties: RepositoryProperties, sessionId: string): Promise<{166readonly changes: ChatSessionWorktreeFile[];167readonly mergeBaseCommit?: string;168readonly hasGitHubRemote?: boolean;169readonly upstreamBranchName?: string;170readonly incomingChanges?: number;171readonly outgoingChanges?: number;172readonly uncommittedChanges?: number;173} | undefined> {174const repository = await this.gitService.getRepository(vscode.Uri.file(repositoryProperties.repositoryPath));175if (repository) {176const sessionIds = this.sessionsAssociatedWithFolders.get(repository.rootUri) ?? new Set<string>();177sessionIds.add(sessionId);178this.sessionsAssociatedWithFolders.set(repository.rootUri, sessionIds);179}180if (!repository?.changes) {181this.logService.warn(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] No repository found for session ${sessionId}`);182return undefined;183}184185// Check for untracked changes, only if the session branch matches the current branch186const hasUntrackedChanges = repositoryProperties.branchName === repository.headBranchName187? [188...repository.changes?.workingTree ?? [],189...repository.changes?.untrackedChanges ?? [],190].some(change => change.status === 7 /* UNTRACKED */)191: false;192193const diffChanges: DiffChange[] = [];194195// If the repository is using a virtual file system, we need to196// disable rename detection to avoid expensive git operations197const noRenamesArg = repository.isUsingVirtualFileSystem198? ['--no-renames']199: [];200201const mergeBaseArg = repositoryProperties.baseBranchName202? ['--merge-base', repositoryProperties.baseBranchName]203: [];204205if (hasUntrackedChanges) {206// Tracked + untracked changes207const tmpDirName = `vscode-sessions-${generateUuid()}`;208const diffIndexFile = path.join(this.extensionContext.globalStorageUri.fsPath, tmpDirName, 'diff.index');209const pathspecFile = path.join(this.extensionContext.globalStorageUri.fsPath, tmpDirName, `pathspec.txt`);210211const env = buildTempIndexEnv(repository, diffIndexFile);212213try {214// Create temp index file directory215await fs.mkdir(path.dirname(diffIndexFile), { recursive: true });216217try {218// Populate temp index from HEAD, fall back to empty tree if no commits exist219await this.gitService.exec(repository.rootUri, ['read-tree', 'HEAD'], env);220} catch {221// Fall back to empty tree for repositories with no commits222await this.gitService.exec(repository.rootUri, ['read-tree', ChatSessionWorkspaceFolderService.EMPTY_TREE_OBJECT], env);223}224225// Stage entire working directory into temp index226const uncommittedFilePaths = getUncommittedFilePaths(repository);227await fs.writeFile(pathspecFile, uncommittedFilePaths.join('\n'), 'utf8');228await this.gitService.exec(repository.rootUri, ['add', '-A', `--pathspec-from-file=${pathspecFile}`], env);229230// Diff the temp index with the base branch231const result = await this.gitService.exec(repository.rootUri, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--'], env);232diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result));233} catch (error) {234this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while processing workspace changes: ${error}`);235return undefined;236} finally {237try {238await fs.rm(path.dirname(diffIndexFile), { recursive: true, force: true });239} catch (error) {240this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while cleaning up temp index file: ${error}`);241}242}243} else {244// Tracked changes245try {246const result = await this.gitService.exec(repository.rootUri, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--']);247diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result));248} catch (error) {249this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while processing workspace changes: ${error}`);250return undefined;251}252}253254// Since the diff may be computed using the merge base commit of the current255// branch and the base branch, we need to compute it as well so that we can use256// it as the originalRef (left-hand side) of the diff editor257let mergeBaseCommit: string | undefined;258try {259if (repositoryProperties.branchName && repositoryProperties.baseBranchName) {260mergeBaseCommit = await this.gitService.getMergeBase(repository.rootUri, repositoryProperties.branchName, repositoryProperties.baseBranchName);261}262} catch (error) {263this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while getting merge base (${repositoryProperties.branchName}, ${repositoryProperties.baseBranchName}): ${error}`);264}265266const changes = diffChanges.map(change => ({267filePath: change.uri.fsPath,268originalFilePath: change.status !== 1 /* INDEX_ADDED */269? change.originalUri?.fsPath270: undefined,271modifiedFilePath: change.status !== 6 /* DELETED */272? change.uri.fsPath273: undefined,274statistics: {275additions: change.insertions,276deletions: change.deletions277}278} satisfies ChatSessionWorktreeFile));279280const repositoryState = {281mergeBaseCommit,282hasGitHubRemote: getGitHubRepoInfoFromContext(repository) !== undefined,283upstreamBranchName: repository.upstreamRemote && repository.upstreamBranchName284? `${repository.upstreamRemote}/${repository.upstreamBranchName}`285: undefined,286incomingChanges: repository.headIncomingChanges ?? 0,287outgoingChanges: repository.headOutgoingChanges ?? 0,288uncommittedChanges:289(repository.changes?.mergeChanges.length ?? 0) +290(repository.changes?.indexChanges.length ?? 0) +291(repository.changes?.workingTree.length ?? 0) +292(repository.changes?.untrackedChanges.length ?? 0)293};294295return { changes, ...repositoryState };296}297298clearWorkspaceChanges(sessionId: string): string[];299clearWorkspaceChanges(folderUri: vscode.Uri): string[];300clearWorkspaceChanges(sessionIdOrFolderUri: string | vscode.Uri): string[] {301const sessionIds = typeof sessionIdOrFolderUri === 'string' ? [sessionIdOrFolderUri] : this.getAssociatedSessions(sessionIdOrFolderUri);302for (const sessionId of sessionIds) {303this.invalidateSessionCache(sessionId);304}305return sessionIds;306}307308private invalidateSessionCache(sessionId: string): void {309const repoKey = this.sessionRepoKeys.get(sessionId);310this.sessionRepoKeys.delete(sessionId);311this.sessionsWithNoRepoProperties.delete(sessionId);312if (repoKey) {313this.workspaceFolderChanges.delete(repoKey);314}315this._onDidChangeWorkspaceFolderChanges.fire({ sessionId });316}317318getAssociatedSessions(folderUri: vscode.Uri): string[] {319const folderSessionIds = this.sessionsAssociatedWithFolders.get(folderUri) ?? new Set<string>();320return Array.from(folderSessionIds);321}322}323324325