Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.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 l10n from '@vscode/l10n';6import * as vscode from 'vscode';7import { LanguageModelTextPart } from 'vscode';8import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';9import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';10import { ILogService } from '../../../platform/log/common/logService';11import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';12import { raceCancellation } from '../../../util/vs/base/common/async';13import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle';14import { ResourceSet } from '../../../util/vs/base/common/map';15import { isEqual } from '../../../util/vs/base/common/resources';16import { createTimeout } from '../../inlineEdits/common/common';17import { IToolsService } from '../../tools/common/toolsService';18import { RepositoryProperties, IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';19import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';20import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';21import {22FolderRepositoryInfo,23FolderRepositoryMRUEntry,24GetFolderRepositoryOptions,25IFolderRepositoryManager,26InitializeFolderRepositoryOptions27} from '../common/folderRepositoryManager';28import { isUntitledSessionId } from '../common/utils';29import { isWelcomeView } from '../copilotcli/node/copilotCli';30import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService';31import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';3233/**34* Message shown when user needs to trust a folder to continue.35*/36export const UNTRUSTED_FOLDER_MESSAGE = l10n.t('The selected folder is not trusted. Please trust the folder to continue with the {0}.', 'Copilot CLI');3738// #region FolderRepositoryManager (abstract base)3940/**41* Abstract base implementation of IFolderRepositoryManager.42*43* This service centralizes all shared folder/repository management logic including:44* - Tracking folder selection for untitled sessions45* - Resolving folder/repository/worktree information for new sessions46* - Creating worktrees for git repositories47* - Verifying trust status48* - Tracking MRU (Most Recently Used) folders49*50* Subclasses must implement {@link getFolderRepository} to provide session-type-specific51* resolution of folder information for existing (named) sessions.52*/53export abstract class FolderRepositoryManager extends Disposable implements IFolderRepositoryManager {54declare _serviceBrand: undefined;5556/**57* In-memory storage for new session folder selections.58* Maps session ID → folder URI.59*/60protected readonly _newSessionFolders = new Map<string, { uri: vscode.Uri; lastAccessTime: number }>();6162constructor(63protected readonly worktreeService: IChatSessionWorktreeService,64protected readonly workspaceFolderService: IChatSessionWorkspaceFolderService,65protected readonly gitService: IGitService,66protected readonly workspaceService: IWorkspaceService,67protected readonly logService: ILogService,68protected readonly toolsService: IToolsService,69protected readonly metadataStore: IChatSessionMetadataStore7071) {72super();73}7475/**76* @deprecated77*/78setNewSessionFolder(sessionId: string, folderUri: vscode.Uri): void {79this._newSessionFolders.set(sessionId, { uri: folderUri, lastAccessTime: Date.now() });80}8182/**83* @deprecated84*/85deleteNewSessionFolder(sessionId: string): void {86this._newSessionFolders.delete(sessionId);87}8889/**90* Subclasses provide a fallback folder URI when no worktree or workspace91* folder is found for a named session.92*/93protected abstract getSessionFallbackFolder(sessionId: string): Promise<vscode.Uri | undefined>;9495/**96* @inheritdoc97*/98async getFolderRepository(99sessionId: string,100options: GetFolderRepositoryOptions | undefined,101_token: vscode.CancellationToken102): Promise<FolderRepositoryInfo> {103// For untitled sessions, use whatever is in memory.104if (isUntitledSessionId(sessionId)) {105if (options) {106const { folder, repository, repositoryProperties, trusted } = await this.getFolderRepositoryForNewSession(sessionId, undefined, options.stream, _token);107return { folder, repository, repositoryProperties, worktree: undefined, worktreeProperties: undefined, trusted };108} else {109const folder = this._newSessionFolders.get(sessionId)?.uri110?? await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId);111return { folder, repository: undefined, repositoryProperties: undefined, worktree: undefined, trusted: undefined, worktreeProperties: undefined };112}113}114115// For named sessions, check worktree properties first116const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId);117if (worktreeProperties) {118const repositoryUri = vscode.Uri.file(worktreeProperties.repositoryPath);119const worktreeUri = vscode.Uri.file(worktreeProperties.worktreePath);120121// Trust check on repository path (not worktree path)122let trusted: boolean | undefined;123if (options) {124trusted = await this.verifyTrust(repositoryUri, options.stream);125}126127return {128folder: repositoryUri,129repository: repositoryUri,130repositoryProperties: undefined,131worktree: worktreeUri,132worktreeProperties,133trusted134};135}136137// Check session workspace folder138const sessionWorkspaceFolderEntry = await this.workspaceFolderService.getSessionWorkspaceFolderEntry(sessionId);139if (sessionWorkspaceFolderEntry) {140const repositoryProperties = await this.workspaceFolderService.getRepositoryProperties(sessionId);141let trusted: boolean | undefined;142if (options) {143trusted = await this.verifyTrust(vscode.Uri.file(sessionWorkspaceFolderEntry.folderPath), options.stream);144}145146return {147folder: vscode.Uri.file(sessionWorkspaceFolderEntry.folderPath),148repository: repositoryProperties?.repositoryPath149? vscode.Uri.file(repositoryProperties.repositoryPath)150: undefined,151repositoryProperties,152worktree: undefined,153worktreeProperties: undefined,154trusted155};156}157158// Fall back to subclass-specific folder resolution159const fallbackFolder = await this.getSessionFallbackFolder(sessionId);160if (fallbackFolder) {161let trusted: boolean | undefined;162if (options) {163trusted = await this.verifyTrust(fallbackFolder, options.stream);164}165166return {167folder: fallbackFolder,168repository: undefined,169repositoryProperties: undefined,170worktree: undefined,171worktreeProperties: undefined,172trusted173};174}175176return { folder: undefined, repository: undefined, repositoryProperties: undefined, worktree: undefined, trusted: undefined, worktreeProperties: undefined };177}178179/**180* @inheritdoc181*/182async getRepositoryInfo(183folder: vscode.Uri,184_token: vscode.CancellationToken185): Promise<{ repository: vscode.Uri | undefined; headBranchName: string | undefined }> {186const repoContext = await this.gitService.getRepository(folder, true);187return {188repository: repoContext?.rootUri,189headBranchName: repoContext?.headBranchName190};191}192193protected async getFolderRepositoryForNewSession(sessionId: string | undefined, selectedFolder: vscode.Uri | undefined, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<FolderRepositoryInfo> {194// Use the explicitly provided folder, or fall back to the session's stored folder195selectedFolder = selectedFolder ?? (sessionId ? (this._newSessionFolders.get(sessionId)?.uri196?? await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId)) : undefined);197198// If no folder selected and we have a single workspace folder, use active repository199let repositoryUri: vscode.Uri | undefined;200let folderUri = selectedFolder;201let worktree: vscode.Uri | undefined = undefined;202let worktreeProperties: ChatSessionWorktreeProperties | undefined = undefined;203let repositoryProperties: RepositoryProperties | undefined = undefined;204205// If we have just one folder opened in workspace, use that as default206// TODO: @DonJayamanne Handle Session View.207if (!selectedFolder && !isWelcomeView(this.workspaceService) && this.workspaceService.getWorkspaceFolders().length === 1) {208const activeRepo = this.gitService.activeRepository.get();209repositoryUri = activeRepo?.rootUri;210folderUri = repositoryUri ?? this.workspaceService.getWorkspaceFolders()[0];211212// If we're in a single folder workspace, possible the user has opened the worktree folder directly.213if (sessionId && folderUri) {214const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri);215worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined;216worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined;217repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri;218}219} else if (selectedFolder) {220// First check if user trusts the folder.221// We need to do this before looking for git repos to avoid prompting for trust twice.222// Using getRepository will prompt user to trust the repo, and if not trusted223// then undefined is returned and we cannot distinguish between "not a git repo" and "not trusted".224const trusted = await this.workspaceService.requestResourceTrust({225uri: selectedFolder,226message: UNTRUSTED_FOLDER_MESSAGE227});228229if (!trusted) {230stream.warning(l10n.t('The selected folder is not trusted.'));231return {232folder: selectedFolder,233repository: undefined,234repositoryProperties: undefined,235trusted: false,236worktree,237worktreeProperties238};239}240241// If we're in a single folder workspace, possible the user has opened the worktree folder directly.242if (sessionId && folderUri) {243const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri);244worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined;245worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined;246repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri;247}248249// Now look for a git repository in the selected folder.250// If found, use it. If not, proceed without isolation.`251if (worktreeProperties) {252repositoryUri = vscode.Uri.file(worktreeProperties.repositoryPath);253} else {254const repoContext = await this.gitService.getRepository(selectedFolder);255const branchBase = repoContext?.headBranchName && repoContext.headCommitHash256? await this.gitService.getBranchBase(repoContext.rootUri, repoContext.headBranchName)257: undefined;258259const mergeBaseCommit = repoContext?.headBranchName && branchBase?.commit260? await this.gitService.getMergeBase(repoContext.rootUri, repoContext.headBranchName, branchBase.commit)261: undefined;262263const gitHubRemote = repoContext264? getGitHubRepoInfoFromContext(repoContext)265: undefined;266const incomingChanges = repoContext?.headIncomingChanges ?? 0;267const outgoingChanges = repoContext?.headOutgoingChanges ?? 0;268const uncommittedChanges = (repoContext?.changes?.mergeChanges.length ?? 0) +269(repoContext?.changes?.indexChanges.length ?? 0) +270(repoContext?.changes?.workingTree.length ?? 0) +271(repoContext?.changes?.untrackedChanges.length ?? 0);272273repositoryUri = repoContext?.rootUri;274repositoryProperties = repoContext275? {276repositoryPath: repoContext.rootUri.fsPath,277branchName: repoContext.headBranchName,278baseBranchName: branchBase && branchBase.remote && branchBase.name279? `${branchBase.remote}/${branchBase.name}`280: undefined,281upstreamBranchName: repoContext?.upstreamRemote && repoContext?.upstreamBranchName282? `${repoContext.upstreamRemote}/${repoContext.upstreamBranchName}`283: undefined,284baseCommit: repoContext.headCommitHash,285mergeBaseCommit,286hasGitHubRemote: gitHubRemote !== undefined,287incomingChanges,288outgoingChanges,289uncommittedChanges290} satisfies RepositoryProperties291: undefined;292}293294// If no git repo found, use folder directly without isolation295if (!repositoryUri) {296return {297folder: selectedFolder,298repository: undefined,299repositoryProperties: undefined,300trusted: true,301worktree,302worktreeProperties303};304}305}306307if (!repositoryUri) {308// No folder or repository selected309if (folderUri) {310const trusted = await this.verifyTrust(folderUri, stream);311return {312folder: folderUri,313repository: undefined,314repositoryProperties: undefined,315trusted,316worktree,317worktreeProperties318};319}320321return {322folder: undefined,323repository: undefined,324repositoryProperties: undefined,325trusted: true,326worktree,327worktreeProperties328};329}330331// Verify trust on repository path332const trusted = await this.verifyTrust(repositoryUri, stream);333334if (!trusted) {335return {336folder: folderUri ?? repositoryUri,337repository: repositoryUri,338repositoryProperties,339trusted: false,340worktree,341worktreeProperties342};343}344345return {346folder: folderUri ?? repositoryUri,347repository: repositoryUri,348repositoryProperties,349trusted: true,350worktree,351worktreeProperties352};353}354355/**356* @inheritdoc357*/358async initializeFolderRepository(359sessionId: string | undefined,360options: InitializeFolderRepositoryOptions,361token: vscode.CancellationToken362): Promise<FolderRepositoryInfo> {363const { stream, toolInvocationToken, branch, isolation } = options;364365let { folder, repository, repositoryProperties, trusted, worktree, worktreeProperties } = await this.getFolderRepositoryForNewSession(sessionId, options.folder, stream, token);366if (trusted === false) {367return { folder, repository, repositoryProperties, worktree, worktreeProperties, trusted };368}369if (!repository) {370// No git repository found, proceed without isolation371return { folder, repository, repositoryProperties, worktree, worktreeProperties, trusted: true };372}373374// If user explicitly chose workspace mode, skip worktree creation375if (isolation === 'workspace') {376this.logService.info(`[FolderRepositoryManager] Workspace isolation mode selected for session ${sessionId}, skipping worktree creation`);377return {378folder: folder ?? repository,379repository,380repositoryProperties,381worktree: undefined,382worktreeProperties: undefined,383trusted: true384};385}386387// Check for uncommitted changes and prompt user before creating worktree388let uncommittedChangesAction: 'move' | 'copy' | 'skip' | 'cancel' | undefined = undefined;389if (!worktreeProperties) {390uncommittedChangesAction = await this.promptForUncommittedChangesAction(sessionId, repository, branch, toolInvocationToken, token);391if (uncommittedChangesAction === 'cancel') {392return { folder, repository, repositoryProperties, worktree, worktreeProperties, trusted: true, cancelled: true };393}394}395396// Create worktree for the git repository397let newBranchName: string | undefined = undefined;398try {399newBranchName = options.newBranch ? await options.newBranch : undefined;400} catch (ex) {401const error = ex instanceof Error ? ex : new Error(String(ex));402this.logService.error(error, 'Failed to generate a new branch name for worktree creation');403}404worktreeProperties = worktreeProperties ?? await this.worktreeService.createWorktree(repository, stream, branch, newBranchName);405406if (!worktreeProperties) {407stream.warning(l10n.t('Failed to create worktree. Proceeding without isolation.'));408409return {410folder: folder ?? repository,411repository,412repositoryProperties,413worktree,414worktreeProperties,415trusted416};417}418419// Store worktree properties for the session420// Note: The caller is responsible for calling setWorktreeProperties after getting the real session ID421422this.logService.info(`[FolderRepositoryManager] Created worktree for session ${sessionId}: ${worktreeProperties.worktreePath}`);423424// Migrate changes from active repository to worktree if requested425if (uncommittedChangesAction === 'move' || uncommittedChangesAction === 'copy') {426await this.moveOrCopyChangesToWorkTree(427repository,428worktree ?? vscode.Uri.file(worktreeProperties.worktreePath),429uncommittedChangesAction,430stream,431token432);433}434435return {436folder: folder ?? repository,437repository,438repositoryProperties,439worktree: worktree ?? vscode.Uri.file(worktreeProperties.worktreePath),440worktreeProperties,441trusted: true442};443}444445async initializeMultiRootFolderRepositories(446sessionId: string,447primaryFolder: vscode.Uri,448additionalFolders: vscode.Uri[],449options: InitializeFolderRepositoryOptions,450token: vscode.CancellationToken451): Promise<{ primary: FolderRepositoryInfo; additional: FolderRepositoryInfo[] }> {452const { stream, toolInvocationToken, isolation } = options;453const allFolders = [primaryFolder, ...additionalFolders];454455// 1. Resolve all folder/repo info456const folderInfos = await Promise.all(457allFolders.map(folder => this.getFolderRepositoryForNewSession(sessionId, folder, stream, token))458);459460// 2. Filter out untrusted folders461const trustedInfos: { folder: vscode.Uri; info: FolderRepositoryInfo }[] = [];462for (let i = 0; i < allFolders.length; i++) {463if (folderInfos[i].trusted === false) {464this.logService.warn(`[FolderRepositoryManager] Multi-root: folder ${allFolders[i].fsPath} is not trusted, excluding`);465continue;466}467trustedInfos.push({ folder: allFolders[i], info: folderInfos[i] });468}469470if (trustedInfos.length === 0) {471return {472primary: { folder: primaryFolder, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined, trusted: false },473additional: []474};475}476477// 3. If workspace mode, skip worktree creation — return all as-is478if (isolation === 'workspace') {479this.logService.info(`[FolderRepositoryManager] Multi-root: workspace isolation mode, skipping worktree creation for all folders`);480const primary = trustedInfos.find(t => t.folder.fsPath === primaryFolder.fsPath)?.info481?? { folder: primaryFolder, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined, trusted: true };482const additional = trustedInfos483.filter(t => t.folder.fsPath !== primaryFolder.fsPath)484.map(t => ({485folder: t.info.folder ?? t.folder,486repository: undefined,487repositoryProperties: undefined,488worktree: undefined,489worktreeProperties: undefined,490trusted: true as boolean | undefined,491}));492return {493primary: { ...primary, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined },494additional495};496}497498// 4. Collect uncommitted changes from ALL git repos into one combined list499const reposWithChanges: { folder: vscode.Uri; repository: vscode.Uri; modifiedFiles: Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }> }[] = [];500for (const { folder, info } of trustedInfos) {501if (!info.repository) {502continue;503}504const repo = await this.gitService.getRepository(info.repository, false);505if (!repo) {506continue;507}508const modifiedFiles = await this.getModifiedFilesForConfirmation(info.repository, repo, token);509if (modifiedFiles.length > 0) {510reposWithChanges.push({ folder, repository: info.repository, modifiedFiles });511}512}513514// 5. Show ONE combined prompt if any repo has uncommitted changes515let uncommittedChangesAction: 'move' | 'copy' | 'skip' | 'cancel' | undefined = undefined;516if (reposWithChanges.length > 0) {517const allModifiedFiles = reposWithChanges.flatMap(r => r.modifiedFiles);518uncommittedChangesAction = await this._promptForMultiRootUncommittedChanges(toolInvocationToken, allModifiedFiles, token);519if (uncommittedChangesAction === 'cancel') {520return {521primary: { folder: primaryFolder, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined, trusted: true, cancelled: true },522additional: []523};524}525}526527// 6. Create worktrees for all git repo folders in parallel528const results: { folder: vscode.Uri; info: FolderRepositoryInfo }[] = [];529const worktreeCreationResults = await Promise.allSettled(530trustedInfos.map(async ({ folder, info }) => {531if (!info.repository) {532// Non-git folder — keep as plain folder533return { folder, info };534}535536const worktreeProperties = await this.worktreeService.createWorktree(info.repository, stream);537if (!worktreeProperties) {538this.logService.warn(`[FolderRepositoryManager] Multi-root: failed to create worktree for ${info.repository.fsPath}, proceeding without isolation`);539return { folder, info };540}541542this.logService.info(`[FolderRepositoryManager] Multi-root: created worktree for ${info.repository.fsPath}: ${worktreeProperties.worktreePath}`);543return {544folder,545info: {546folder: info.folder ?? info.repository,547repository: info.repository,548repositoryProperties: info.repositoryProperties,549worktree: vscode.Uri.file(worktreeProperties.worktreePath),550worktreeProperties,551trusted: true as boolean | undefined,552}553};554})555);556557for (const result of worktreeCreationResults) {558if (result.status === 'fulfilled') {559results.push(result.value);560} else {561this.logService.error(`[FolderRepositoryManager] Multi-root: worktree creation failed: ${result.reason}`);562}563}564565// 7. Migrate changes to worktrees if requested566if (uncommittedChangesAction === 'move' || uncommittedChangesAction === 'copy') {567const reposWithChangesSet = new Set(reposWithChanges.map(r => r.repository.fsPath));568await Promise.allSettled(569results570.filter(r => r.info.repository && r.info.worktree && reposWithChangesSet.has(r.info.repository.fsPath))571.map(r => this.moveOrCopyChangesToWorkTree(r.info.repository!, r.info.worktree!, uncommittedChangesAction!, stream, token))572);573}574575// 8. Build result576const primaryResult = results.find(r => r.folder.fsPath === primaryFolder.fsPath)?.info577?? { folder: primaryFolder, repository: undefined, repositoryProperties: undefined, worktree: undefined, worktreeProperties: undefined, trusted: true };578const additionalResults = results579.filter(r => r.folder.fsPath !== primaryFolder.fsPath)580.map(r => r.info);581582return { primary: primaryResult, additional: additionalResults };583}584585private async _promptForMultiRootUncommittedChanges(586toolInvocationToken: vscode.ChatParticipantToolToken,587modifiedFiles: Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }>,588token: vscode.CancellationToken589): Promise<'move' | 'copy' | 'skip' | 'cancel'> {590const title = l10n.t('Uncommitted Changes');591const message = l10n.t('Some repositories have uncommitted changes. Should these changes be included in the new worktrees?');592const copyChanges = l10n.t('Copy Changes');593const moveChanges = l10n.t('Move Changes');594const skipChanges = l10n.t('Skip Changes');595const options = [copyChanges, moveChanges, skipChanges];596const input = { title, message, options, modifiedFiles };597const result = await this.toolsService.invokeTool('vscode_get_modified_files_confirmation', { input, toolInvocationToken }, token);598const selection = this.getSelectedUncommittedChangesAction(result, options);599switch (selection?.toUpperCase()) {600case moveChanges.toUpperCase(): return 'move';601case copyChanges.toUpperCase(): return 'copy';602case skipChanges.toUpperCase(): return 'skip';603default: return 'cancel';604}605}606607/**608* @inheritdoc609*/610async getFolderMRU(): Promise<FolderRepositoryMRUEntry[]> {611const latestReposAndFolders: FolderRepositoryMRUEntry[] = [];612const seenUris = new ResourceSet();613614for (const { uri, lastAccessTime } of this._newSessionFolders.values()) {615if (seenUris.has(uri)) {616continue;617}618seenUris.add(uri);619latestReposAndFolders.push({620folder: uri,621repository: undefined,622lastAccessed: lastAccessTime,623});624}625626// Add recent git repositories627for (const repo of this.gitService.getRecentRepositories()) {628if (seenUris.has(repo.rootUri)) {629continue;630}631seenUris.add(repo.rootUri);632latestReposAndFolders.push({633folder: repo.rootUri,634repository: repo.rootUri,635lastAccessed: repo.lastAccessTime,636});637}638639// Sort by last access time descending and limit640latestReposAndFolders.sort((a, b) => b.lastAccessed - a.lastAccessed);641642return latestReposAndFolders;643}644645/**646* Check for uncommitted changes and prompt user for action.647*648* @returns The user's chosen action, or `undefined` if there are no uncommitted changes.649*/650private async promptForUncommittedChangesAction(651sessionId: string | undefined,652repositoryUri: vscode.Uri,653branch: string | undefined,654toolInvocationToken: vscode.ChatParticipantToolToken,655token: vscode.CancellationToken656): Promise<'move' | 'copy' | 'skip' | 'cancel' | undefined> {657const uncommittedChanges = await this.getUncommittedChanges(repositoryUri, branch, token);658if (!uncommittedChanges) {659return undefined;660}661662const isDelegation = !sessionId;663const title = isDelegation664? l10n.t('Delegate to Copilot CLI')665: l10n.t('Uncommitted Changes');666const message = isDelegation667? l10n.t('Copilot CLI will work in an isolated worktree to implement your requested changes.')668+ '\n\n'669+ l10n.t('The selected repository has uncommitted changes. Should these changes be included in the new worktree?')670: l10n.t('The selected repository has uncommitted changes. Should these changes be included in the new worktree?');671672const copyChanges = l10n.t('Copy Changes');673const moveChanges = l10n.t('Move Changes');674const skipChanges = l10n.t('Skip Changes');675const options = [copyChanges, moveChanges, skipChanges];676const input = {677title,678message,679options,680modifiedFiles: uncommittedChanges.modifiedFiles681};682const result = await this.toolsService.invokeTool('vscode_get_modified_files_confirmation', { input, toolInvocationToken }, token);683684const selection = this.getSelectedUncommittedChangesAction(result, options);685686switch (selection?.toUpperCase()) {687case moveChanges.toUpperCase():688return 'move';689case copyChanges.toUpperCase():690return 'copy';691case skipChanges.toUpperCase():692return 'skip';693default:694return 'cancel';695}696}697698private getSelectedUncommittedChangesAction(699result: vscode.LanguageModelToolResult,700options: readonly string[]701): string | undefined {702for (const part of result.content) {703if (!(part instanceof LanguageModelTextPart)) {704continue;705}706707const matchedOption = options.find(option => option.toUpperCase() === part.value.toUpperCase());708if (matchedOption) {709return matchedOption;710}711}712713return undefined;714}715716private async getUncommittedChanges(717folderPath: vscode.Uri,718branch: string | undefined,719token: vscode.CancellationToken720): Promise<{ repository: vscode.Uri; modifiedFiles: Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }> } | undefined> {721const repository = await this.gitService.getRepository(folderPath);722if (!repository) {723return undefined;724}725726// If the current branch is not the same as the requested branch, we cannot reliably determine the uncommitted changes, so skip the confirmation.727if (branch && repository.headBranchName !== branch) {728return undefined;729}730731const modifiedFiles = await this.getModifiedFilesForConfirmation(repository.rootUri, repository, token);732if (modifiedFiles.length === 0) {733return undefined;734}735736return {737repository: repository.rootUri,738modifiedFiles739};740}741742private async getModifiedFilesForConfirmation(743repositoryUri: vscode.Uri,744repository: NonNullable<ReturnType<IGitService['activeRepository']['get']>>,745token: vscode.CancellationToken746): Promise<Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }>> {747748if (token.isCancellationRequested || !repository.changes) {749return [];750}751752const modifiedFiles = new Map<string, { uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }>();753for (const change of [...repository.changes.indexChanges, ...repository.changes.workingTree]) {754const changePath = (change as { path?: string }).path;755const fileUri = change.uri ?? (changePath ? vscode.Uri.joinPath(repositoryUri, changePath) : undefined);756modifiedFiles.set(fileUri.toString(), {757uri: fileUri,758originalUri: change.originalUri759});760}761762return [...modifiedFiles.values()];763}764765/**766* Verify trust for a folder/repository and report via stream if not trusted.767*/768protected async verifyTrust(folderUri: vscode.Uri, stream: vscode.ChatResponseStream): Promise<boolean> {769const trusted = await this.workspaceService.requestResourceTrust({770uri: folderUri,771message: UNTRUSTED_FOLDER_MESSAGE772});773774if (!trusted) {775stream.warning(l10n.t('The selected folder is not trusted.'));776return false;777}778779return true;780}781782/**783* Move or copy uncommitted changes from the active repository to the worktree.784*/785private async moveOrCopyChangesToWorkTree(786repositoryPath: vscode.Uri,787worktreePath: vscode.Uri,788moveOrCopyChanges: 'move' | 'copy',789stream: vscode.ChatResponseStream,790token: vscode.CancellationToken791): Promise<void> {792// Migrate changes from active repository to worktree793const activeRepository = await this.gitService.getRepository(repositoryPath);794if (!activeRepository) {795return;796}797const hasUncommittedChanges = activeRepository.changes798? (activeRepository.changes.indexChanges.length > 0 || activeRepository.changes.workingTree.length > 0)799: false;800if (!hasUncommittedChanges) {801return;802}803804const disposables = new DisposableStore();805try {806// Wait for the worktree repository to be ready807stream.progress(l10n.t('Migrating changes to worktree...'));808const worktreeRepo = await raceCancellation(new Promise<typeof activeRepository | undefined>((resolve) => {809disposables.add(this.gitService.onDidOpenRepository(repo => {810if (isEqual(repo.rootUri, worktreePath)) {811resolve(repo);812}813}));814815this.gitService.getRepository(worktreePath).then(repo => {816if (repo) {817resolve(repo);818}819});820821disposables.add(createTimeout(10_000, () => resolve(undefined)));822}), token);823824if (!worktreeRepo) {825stream.warning(l10n.t('Failed to get worktree repository. Proceeding without migration.'));826} else {827await this.gitService.migrateChanges(worktreeRepo.rootUri, activeRepository.rootUri, {828confirmation: false,829deleteFromSource: moveOrCopyChanges === 'move',830untracked: true831});832stream.markdown(l10n.t('Changes migrated to worktree.\n'));833}834} catch (error) {835// Continue even if migration fails836stream.warning(l10n.t('Failed to migrate some changes: {0}. Continuing with worktree creation.', error instanceof Error ? error.message : String(error)));837} finally {838disposables.dispose();839}840}841}842843// #endregion844845// #region CopilotCLIFolderRepositoryManager846847/**848* CopilotCLI-specific implementation that resolves folder information for849* existing sessions using the CLI session service as a fallback.850*/851export class CopilotCLIFolderRepositoryManager extends FolderRepositoryManager {852constructor(853@IChatSessionWorktreeService worktreeService: IChatSessionWorktreeService,854@IChatSessionWorkspaceFolderService workspaceFolderService: IChatSessionWorkspaceFolderService,855@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,856@IGitService gitService: IGitService,857@IWorkspaceService workspaceService: IWorkspaceService,858@ILogService logService: ILogService,859@IToolsService toolsService: IToolsService,860@IFileSystemService private readonly fileSystem: IFileSystemService,861@IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore862) {863super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore);864}865866/**867* @inheritdoc868*/869protected async getSessionFallbackFolder(sessionId: string): Promise<vscode.Uri | undefined> {870const cwd = this.sessionService.getSessionWorkingDirectory(sessionId);871if (cwd && (await checkPathExists(cwd, this.fileSystem))) {872return cwd;873}874return undefined;875}876}877878async function checkPathExists(filePath: vscode.Uri, fileSystem: IFileSystemService): Promise<boolean> {879try {880await fileSystem.stat(filePath);881return true;882} catch (error) {883return false;884}885}886887// #endregion888889// #region ClaudeFolderRepositoryManager890891/**892* Claude-specific implementation that resolves folder information for893* existing sessions using the Claude session state service as a fallback.894*/895export class ClaudeFolderRepositoryManager extends FolderRepositoryManager {896constructor(897@IChatSessionWorktreeService worktreeService: IChatSessionWorktreeService,898@IChatSessionWorkspaceFolderService workspaceFolderService: IChatSessionWorkspaceFolderService,899@IGitService gitService: IGitService,900@IWorkspaceService workspaceService: IWorkspaceService,901@ILogService logService: ILogService,902@IToolsService toolsService: IToolsService,903@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,904@IFileSystemService private readonly fileSystem: IFileSystemService,905@IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore906) {907super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore);908}909910/**911* @inheritdoc912*/913protected async getSessionFallbackFolder(sessionId: string): Promise<vscode.Uri | undefined> {914const folderInfo = this.sessionStateService.getFolderInfoForSession(sessionId);915if (folderInfo && (await checkPathExists(vscode.Uri.file(folderInfo.cwd), this.fileSystem))) {916return vscode.Uri.file(folderInfo.cwd);917}918return undefined;919}920}921922// #endregion923924925