Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.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 * as vscode from 'vscode';6import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';7import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';8import { IGitService } from '../../../platform/git/common/gitService';9import { toGitUri } from '../../../platform/git/common/utils';10import { buildTempIndexEnv, getUncommittedFilePaths, parseGitChangesRaw } from '../../../platform/git/vscode-node/utils';11import { DiffChange } from '../../../platform/git/vscode/git';12import { ILogService } from '../../../platform/log/common/logService';13import * as path from '../../../util/vs/base/common/path';14import { Disposable } from '../../../util/vs/base/common/lifecycle';15import { generateUuid } from '../../../util/vs/base/common/uuid';16import { ChatSessionWorktreeFile } from '../common/chatSessionWorktreeService';17import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService';1819// #region Constants2021const EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';2223// #endregion2425export class ClaudeWorkspaceFolderService extends Disposable implements IClaudeWorkspaceFolderService {26declare _serviceBrand: undefined;2728private readonly _cache = new Map<string, vscode.ChatSessionChangedFile[]>();29private readonly _inflight = new Map<string, Promise<vscode.ChatSessionChangedFile[]>>();3031constructor(32@IGitService private readonly _gitService: IGitService,33@ILogService private readonly _logService: ILogService,34@IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext,35@IFileSystemService private readonly _fileSystemService: IFileSystemService,36) {37super();38}3940override dispose(): void {41this._cache.clear();42this._inflight.clear();43super.dispose();44}4546async getWorkspaceChanges(47cwd: string,48gitBranch: string | undefined,49gitBaseBranch: string | undefined,50forceRefresh?: boolean,51): Promise<vscode.ChatSessionChangedFile[]> {52const cacheKey = `${cwd}\0${gitBranch ?? ''}\0${gitBaseBranch ?? ''}`;5354if (!forceRefresh) {55const cached = this._cache.get(cacheKey);56if (cached) {57return cached;58}59}6061const existing = this._inflight.get(cacheKey);62if (existing) {63return existing;64}6566const promise = this._computeAndCacheChanges(cacheKey, cwd, gitBranch, gitBaseBranch);67this._inflight.set(cacheKey, promise);68try {69return await promise;70} finally {71this._inflight.delete(cacheKey);72}73}7475private async _computeAndCacheChanges(76cacheKey: string,77cwd: string,78gitBranch: string | undefined,79gitBaseBranch: string | undefined,80): Promise<vscode.ChatSessionChangedFile[]> {81const result = await this.computeRepositoryChanges(cwd, gitBranch, gitBaseBranch);82if (!result) {83return [];84}8586const originalRef = result.mergeBaseCommit ?? 'HEAD';87const changes = result.changes.map(change => new vscode.ChatSessionChangedFile(88vscode.Uri.file(change.filePath),89change.originalFilePath90? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef)91: undefined,92change.modifiedFilePath93? vscode.Uri.file(change.modifiedFilePath)94: undefined,95change.statistics.additions,96change.statistics.deletions,97));9899this._cache.set(cacheKey, changes);100return changes;101}102103private async computeRepositoryChanges(104repositoryPath: string,105branchName: string | undefined,106baseBranchName: string | undefined,107): Promise<{108readonly changes: ChatSessionWorktreeFile[];109readonly mergeBaseCommit?: string;110} | undefined> {111const repository = await this._gitService.getRepository(vscode.Uri.file(repositoryPath));112if (!repository?.changes) {113this._logService.warn(`[ClaudeWorkspaceFolderService] No repository found at ${repositoryPath}`);114return undefined;115}116117let resolvedBaseBranchName = baseBranchName;118if (!resolvedBaseBranchName && branchName && repository.headCommitHash) {119try {120const branchBase = await this._gitService.getBranchBase(repository.rootUri, branchName);121resolvedBaseBranchName = branchBase?.name;122} catch (error) {123this._logService.warn(`[ClaudeWorkspaceFolderService] Failed to resolve base branch for ${branchName}: ${error}`);124}125}126127// Check for untracked changes, only if the session branch matches the current branch128const hasUntrackedChanges = branchName === repository.headBranchName129? [130...repository.changes?.workingTree ?? [],131...repository.changes?.untrackedChanges ?? [],132].some(change => change.status === 7 /* UNTRACKED */)133: false;134135const diffChanges: DiffChange[] = [];136137// If the repository is using a virtual file system, we need to138// disable rename detection to avoid expensive git operations139const noRenamesArg = repository.isUsingVirtualFileSystem140? ['--no-renames']141: [];142143const mergeBaseArg = resolvedBaseBranchName144? ['--merge-base', resolvedBaseBranchName]145: ['HEAD'];146147if (hasUntrackedChanges) {148// Tracked + untracked changes149const tmpDirName = `vscode-sessions-${generateUuid()}`;150const diffIndexFile = path.join(this._extensionContext.globalStorageUri.fsPath, tmpDirName, 'diff.index');151const pathspecFile = path.join(this._extensionContext.globalStorageUri.fsPath, tmpDirName, `pathspec.txt`);152153const env = buildTempIndexEnv(repository, diffIndexFile);154155try {156// Create temp index file directory157await this._fileSystemService.createDirectory(vscode.Uri.file(path.dirname(diffIndexFile)));158159try {160// Populate temp index from HEAD, fall back to empty tree if no commits exist161await this._gitService.exec(repository.rootUri, ['read-tree', 'HEAD'], env);162} catch {163// Fall back to empty tree for repositories with no commits164await this._gitService.exec(repository.rootUri, ['read-tree', EMPTY_TREE_OBJECT], env);165}166167// Stage entire working directory into temp index168const uncommittedFilePaths = getUncommittedFilePaths(repository);169await this._fileSystemService.writeFile(vscode.Uri.file(pathspecFile), new TextEncoder().encode(uncommittedFilePaths.join('\n')));170await this._gitService.exec(repository.rootUri, ['add', '-A', `--pathspec-from-file=${pathspecFile}`], env);171172// Diff the temp index with the base branch173const result = await this._gitService.exec(repository.rootUri, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--'], env);174diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result));175} catch (error) {176this._logService.error(`[ClaudeWorkspaceFolderService] Error while processing workspace changes: ${error}`);177return undefined;178} finally {179try {180await this._fileSystemService.delete(vscode.Uri.file(path.dirname(diffIndexFile)), { recursive: true });181} catch (error) {182this._logService.error(`[ClaudeWorkspaceFolderService] Error while cleaning up temp index file: ${error}`);183}184}185} else {186// Tracked changes187try {188const result = await this._gitService.exec(repository.rootUri, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--']);189diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result));190} catch (error) {191this._logService.error(`[ClaudeWorkspaceFolderService] Error while processing workspace changes: ${error}`);192return undefined;193}194}195196// Since the diff may be computed using the merge base commit of the current197// branch and the base branch, we need to compute it as well so that we can use198// it as the originalRef (left-hand side) of the diff editor199let mergeBaseCommit: string | undefined;200try {201if (branchName && resolvedBaseBranchName) {202mergeBaseCommit = await this._gitService.getMergeBase(repository.rootUri, branchName, resolvedBaseBranchName);203}204} catch (error) {205this._logService.error(`[ClaudeWorkspaceFolderService] Error while getting merge base (${branchName}, ${resolvedBaseBranchName}): ${error}`);206}207208const changes = diffChanges.map(change => ({209filePath: change.uri.fsPath,210originalFilePath: change.status !== 1 /* INDEX_ADDED */211? change.originalUri?.fsPath212: undefined,213modifiedFilePath: change.status !== 6 /* DELETED */214? change.uri.fsPath215: undefined,216statistics: {217additions: change.insertions,218deletions: change.deletions219}220} satisfies ChatSessionWorktreeFile));221222return { changes, mergeBaseCommit };223}224}225226227