diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts index 62a5281a15d..3a750bdb6b8 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts @@ -105,6 +105,12 @@ export interface ChatSessionMetadataFile { * session or if the session is a child session created from the Agents app. */ parentSessionId?: string; + /** + * Whether the session is currently archived. Tracked here so worktree-sharing + * checks can ignore archived siblings (whose worktrees are reconstructed on + * un-archive via {@link IChatSessionWorktreeService.recreateWorktreeOnUnarchive}). + */ + archived?: boolean; /** Milliseconds since epoch when this metadata was first written. */ created?: number; /** Milliseconds since epoch of the last write. Used for top-N trim sort and cross-process merge. */ @@ -152,7 +158,19 @@ export interface IChatSessionMetadataStore { setSessionOrigin(sessionId: string): Promise; getSessionOrigin(sessionId: string): Promise<'vscode' | 'other'>; setSessionParentId(sessionId: string, parentSessionId: string): Promise; - getSessionParentId(sessionId: string): Promise; + /** + * Returns the parent lineage info for a session, distinguishing forked sessions + * (created via the fork action) from sub-sessions (spawned by a parent session + * to do work on its behalf). Returns `undefined` for top-level sessions with no + * stored parent. + */ + getSessionParentId(sessionId: string): Promise<{ parentSessionId: string; kind: 'forked' | 'sub-session' } | undefined>; + /** + * Persist the archived state of a session. Called from the chat session item state + * change handler so worktree-sharing checks can later ignore archived siblings. + */ + setSessionArchived(sessionId: string, archived: boolean): Promise; + getSessionArchived(sessionId: string): Promise; /** * Re-read the shared bulk metadata file from disk and merge into the in-memory cache. * Wired to the chat-sessions UI refresh action so cross-process writes become visible diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts index a1ed1043cd5..d4b4c0399e4 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts @@ -75,4 +75,9 @@ export interface IChatSessionWorkspaceFolderService { clearWorkspaceChanges(folderUri: vscode.Uri): string[]; hasCachedChanges(sessionId: string): Promise; + + /** + * Returns the ids of sessions whose tracked workspace folder matches the given URI. + */ + getAssociatedSessions(folderUri: vscode.Uri): string[]; } diff --git a/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts index ed442e5a4e3..57280e9130b 100644 --- a/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts @@ -149,10 +149,24 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore { return Promise.resolve(); } - getSessionParentId(_sessionId: string): Promise { + getSessionParentId(_sessionId: string): Promise<{ parentSessionId: string; kind: 'forked' | 'sub-session' } | undefined> { return Promise.resolve(undefined); } + private readonly _archived = new Set(); + + async setSessionArchived(sessionId: string, archived: boolean): Promise { + if (archived) { + this._archived.add(sessionId); + } else { + this._archived.delete(sessionId); + } + } + + async getSessionArchived(sessionId: string): Promise { + return this._archived.has(sessionId); + } + getSessionIdsForFolder(folder: vscode.Uri): string[] { const folderPath = folder.fsPath; const sessionIds: string[] = []; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/chatSessionMetadataStoreImpl.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/chatSessionMetadataStoreImpl.ts index 5a30eff422c..31bb9a75609 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/chatSessionMetadataStoreImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/chatSessionMetadataStoreImpl.ts @@ -393,6 +393,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession parentSessionId: sourceSessionId, origin: 'vscode', kind: 'forked', + archived: false, }; await this.updateMetadataFields(targetSessionId, forkedMetadata); } @@ -423,9 +424,22 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession await this.updateMetadataFields(sessionId, { parentSessionId, kind: 'sub-session' }); } - public async getSessionParentId(sessionId: string): Promise { + public async getSessionParentId(sessionId: string): Promise<{ parentSessionId: string; kind: 'forked' | 'sub-session' } | undefined> { const metadata = await this.getSessionMetadata(sessionId, false); - return metadata?.parentSessionId; + if (!metadata?.parentSessionId || !metadata.kind) { + return undefined; + } + return { parentSessionId: metadata.parentSessionId, kind: metadata.kind }; + } + + public async setSessionArchived(sessionId: string, archived: boolean): Promise { + await this._ready; + await this.updateMetadataFields(sessionId, { archived }); + } + + public async getSessionArchived(sessionId: string): Promise { + const metadata = await this.getSessionMetadata(sessionId, false); + return metadata?.archived === true; } private async getSessionMetadata(sessionId: string, createMetadataFileIfNotFound = true): Promise { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 3a85bd8ff19..6fa682df983 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -73,6 +73,7 @@ import { ClaudeCustomizationProvider } from './claudeCustomizationProvider'; import { CopilotCLIChatSessionInitializer, ICopilotCLIChatSessionInitializer } from '../copilotcli/vscode-node/copilotCLIChatSessionInitializer'; import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessions'; import { CopilotCLIChatSessionContentProvider as CopilotCLIChatSessionContentProviderV1, CopilotCLIChatSessionItemProvider as CopilotCLIChatSessionItemProviderV1, CopilotCLIChatSessionParticipant as CopilotCLIChatSessionParticipantV1, registerCLIChatCommands as registerCLIChatCommandsV1 } from './copilotCLIChatSessionsContribution'; +import { getBlockingSiblingSessionsForFolder } from './worktreeSharing'; import { CopilotCLICustomizationProvider } from '../copilotcli/vscode-node/copilotCLICustomizationProvider'; import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; @@ -231,6 +232,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels)); const copilotCLIFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatFolderMruService)); const pullRequestCreationService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IPullRequestCreationService)); + const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore)); this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker))); this._register(copilotcliAgentInstaService.createInstance(CopilotCLIContrib)); @@ -241,10 +243,9 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider)); this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider)); - this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, copilotCLIWorktreeCheckpointService, gitService, gitCommitMessageService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, pullRequestCreationService, logService)); + this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, copilotCLIWorktreeCheckpointService, gitService, gitCommitMessageService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, pullRequestCreationService, sessionMetadata, logService)); // #endregion - const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore)); return { sessionMetadata }; } @@ -301,13 +302,32 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotCLISessionService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionService)); const copilotCLIWorktreeManagerService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorktreeService)); const copilotCLIWorktreeCheckpointService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorktreeCheckpointService)); + const copilotCLIWorkspaceFolderSessions = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorkspaceFolderService)); + const copilotCLIMetadataStore = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore)); // Handle worktree cleanup/recreation when archive state changes const onDidChangeChatSessionItemState = (providerRegistration as { onDidChangeChatSessionItemState?: vscode.Event }).onDidChangeChatSessionItemState; if (onDidChangeChatSessionItemState) { this._register(onDidChangeChatSessionItemState(async (item) => { const sessionId = SessionIdForCLI.parse(item.resource); + // Persist archived state first so worktree-sharing checks (delete/archive) + // can ignore archived siblings — their worktrees are reconstructed on + // un-archive via `recreateWorktreeOnUnarchive`. + try { + await copilotCLIMetadataStore.setSessionArchived(sessionId, !!item.archived); + } catch (error) { + logService.error(`[CopilotCLI] Failed to persist archived state for session ${sessionId}:`, error); + } if (item.archived) { + // Skip worktree cleanup if other live sessions still depend on this worktree. + const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); + if (worktreePath) { + const siblings = await getBlockingSiblingSessionsForFolder(worktreePath, sessionId, copilotCLIMetadataStore, copilotCLIWorkspaceFolderSessions); + if (siblings.length > 0) { + logService.trace(`[CopilotCLI] Skipping worktree cleanup for archived session ${sessionId}: ${siblings.length} other session(s) still use the worktree`); + return; + } + } try { const result = await copilotCLIWorktreeManagerService.cleanupWorktreeOnArchive(sessionId); logService.trace(`[CopilotCLI] Worktree cleanup for session ${sessionId}: ${result.cleaned ? 'cleaned' : result.reason}`); @@ -328,7 +348,6 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib })); } - const copilotCLIWorkspaceFolderSessions = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorkspaceFolderService)); const folderRepositoryManager = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFolderRepositoryManager)); const nativeEnvService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(INativeEnvService)); const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService)); @@ -345,7 +364,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider)); this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider)); - this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, copilotCLIWorktreeCheckpointService, gitService, gitCommitMessageService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, pullRequestCreationService, logService)); + this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, copilotCLIWorktreeCheckpointService, gitService, gitCommitMessageService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, pullRequestCreationService, copilotCLIMetadataStore, logService)); // #endregion const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore)); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index eca4c329639..070b626513b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -51,6 +51,7 @@ import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotC import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl'; import { IPullRequestDetectionService } from './pullRequestDetectionService'; +import { getBlockingSiblingSessionsForFolder } from './worktreeSharing'; import { getCopilotCLIModelDetails, persistCopilotCLIResponseModelId } from './copilotCLIModelDetails'; import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; import { ISessionRequestLifecycle } from './sessionRequestLifecycle'; @@ -259,7 +260,24 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements if (controller.onDidChangeChatSessionItemState) { this._register(controller.onDidChangeChatSessionItemState(async (item) => { const sessionId = SessionIdForCLI.parse(item.resource); + // Persist archived state first so worktree-sharing checks (delete/archive) + // can ignore archived siblings — their worktrees are reconstructed on + // un-archive via `recreateWorktreeOnUnarchive`. + try { + await this._metadataStore.setSessionArchived(sessionId, !!item.archived); + } catch (error) { + this.logService.error(`[CopilotCLI] Failed to persist archived state for session ${sessionId}:`, error); + } if (item.archived) { + // Skip worktree cleanup if other live sessions still depend on this worktree. + const worktreePath = await this.copilotCLIWorktreeManagerService.getWorktreePath(sessionId); + if (worktreePath) { + const siblings = await getBlockingSiblingSessionsForFolder(worktreePath, sessionId, this._metadataStore, this._workspaceFolderService); + if (siblings.length > 0) { + this.logService.trace(`[CopilotCLI] Skipping worktree cleanup for archived session ${sessionId}: ${siblings.length} other session(s) still use the worktree`); + return; + } + } try { const result = await this.copilotCLIWorktreeManagerService.cleanupWorktreeOnArchive(sessionId); this.logService.trace(`[CopilotCLI] Worktree cleanup for session ${sessionId}: ${result.cleaned ? 'cleaned' : result.reason}`); @@ -479,7 +497,8 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements workingDirectory: vscode.Uri | undefined, ): Promise<{ readonly [key: string]: unknown }> { if (worktreeProperties) { - const sessionParentId = await this._metadataStore.getSessionParentId(sessionId); + const parentInfo = await this._metadataStore.getSessionParentId(sessionId); + const sessionParentId = parentInfo?.kind === 'sub-session' ? parentInfo.parentSessionId : undefined; return { sessionParentId, @@ -531,11 +550,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } satisfies { readonly [key: string]: unknown }; } - const [sessionParentId, sessionRequestDetails, repositoryProperties] = await Promise.all([ + const [parentInfo, sessionRequestDetails, repositoryProperties] = await Promise.all([ this._metadataStore.getSessionParentId(sessionId), this._metadataStore.getRequestDetails(sessionId), this._metadataStore.getRepositoryProperties(sessionId) ]); + const sessionParentId = parentInfo?.kind === 'sub-session' ? parentInfo.parentSessionId : undefined; let lastCheckpointRef: string | undefined; for (let i = sessionRequestDetails.length - 1; i >= 0; i--) { @@ -1038,18 +1058,19 @@ export function registerCLIChatCommands( sessionTracker: ICopilotCLISessionTracker, terminalIntegration: ICopilotCLITerminalIntegration, pullRequestCreationService: IPullRequestCreationService, + metadataStore: IChatSessionMetadataStore, logService: ILogService ): IDisposable { const disposableStore = new DisposableStore(); - async function deleteSessionById(id: string): Promise { - const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(id); - const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id); + async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise { + const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); + const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); - await copilotCLISessionService.deleteSession(id); - await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(id); + await copilotCLISessionService.deleteSession(sessionId); + await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(sessionId); - if (worktreePath) { + if (worktreePath && !options?.keepWorktree) { const worktreeExists = await fileSystemService.stat(worktreePath).then(() => true, () => false); if (worktreeExists) { try { @@ -1064,7 +1085,21 @@ export function registerCLIChatCommands( } } - await contentProvider.refreshSession({ reason: 'delete', sessionId: id }); + await contentProvider.refreshSession({ reason: 'delete', sessionId }); + } + + /** + * Determine whether the worktree at `worktreePath` is still in use by another + * non-archived, non-sub-session sibling. Used to gate worktree deletion. + */ + async function shouldKeepWorktreeForOtherSessions(sessionId: string, worktreePath: vscode.Uri): Promise { + const siblings = await getBlockingSiblingSessionsForFolder( + worktreePath, + sessionId, + metadataStore, + copilotCliWorkspaceSession, + ); + return siblings.length > 0; } // Terminal integration setup: resolve session dirs for terminal links. @@ -1076,8 +1111,9 @@ export function registerCLIChatCommands( if (sessionItem?.resource) { const id = SessionIdForCLI.parse(sessionItem.resource); const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id); + const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(id, worktreePath); - const confirmMessage = worktreePath + const confirmMessage = (worktreePath && !keepWorktree) ? l10n.t('Are you sure you want to delete the session and its associated worktree?') : l10n.t('Are you sure you want to delete the session?'); @@ -1089,7 +1125,7 @@ export function registerCLIChatCommands( ); if (result === deleteLabel) { - await deleteSessionById(id); + await deleteSessionById(id, { keepWorktree }); } } })); @@ -1116,7 +1152,9 @@ export function registerCLIChatCommands( for (const sessionItem of sessionItems) { if (sessionItem.resource) { const id = SessionIdForCLI.parse(sessionItem.resource); - await deleteSessionById(id); + const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id); + const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(id, worktreePath); + await deleteSessionById(id, { keepWorktree }); } } })); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index aa6340a9165..377e9181a6d 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -58,6 +58,7 @@ import { getCopilotCLIModelDetails, persistCopilotCLIResponseModelId } from './c import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; import { IPullRequestCreationService } from './pullRequestCreationService'; +import { getBlockingSiblingSessionsForFolder } from './worktreeSharing'; import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences'; import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker'; @@ -346,7 +347,10 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc // Metadata let metadata: { readonly [key: string]: unknown }; - const sessionParentId = await raceCancellation(this.chatSessionMetadataStore.getSessionParentId(session.id), token); + const parentInfo = await raceCancellation(this.chatSessionMetadataStore.getSessionParentId(session.id), token); + // Only sub-sessions are surfaced as children of their parent in the UI; forked + // sessions keep their lineage internally but appear as top-level items. + const sessionParentId = parentInfo?.kind === 'sub-session' ? parentInfo.parentSessionId : undefined; if (worktreeProperties) { // Worktree @@ -2139,17 +2143,27 @@ export function registerCLIChatCommands( envService: INativeEnvService, fileSystemService: IFileSystemService, pullRequestCreationService: IPullRequestCreationService, + metadataStore: IChatSessionMetadataStore, logService: ILogService ): IDisposable { const disposableStore = new DisposableStore(); - async function deleteSessionById(sessionId: string): Promise { + async function shouldKeepWorktreeForOtherSessions(sessionId: string, worktreePath: vscode.Uri): Promise { + const siblings = await getBlockingSiblingSessionsForFolder( + worktreePath, + sessionId, + metadataStore, + copilotCliWorkspaceSession, + ); + return siblings.length > 0; + } + async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise { const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); await copilotCLISessionService.deleteSession(sessionId); await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(sessionId); - if (worktreePath) { + if (worktreePath && !options?.keepWorktree) { const worktreeExists = await fileSystemService.stat(worktreePath).then(() => true, () => false); if (worktreeExists) { try { @@ -2169,8 +2183,9 @@ export function registerCLIChatCommands( const id = SessionIdForCLI.parse(sessionItem.resource); const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id; const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); + const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(sessionId, worktreePath); - const confirmMessage = worktreePath + const confirmMessage = (worktreePath && !keepWorktree) ? l10n.t('Are you sure you want to delete the session and its associated worktree?') : l10n.t('Are you sure you want to delete the session?'); @@ -2182,7 +2197,7 @@ export function registerCLIChatCommands( ); if (result === deleteLabel) { - await deleteSessionById(sessionId); + await deleteSessionById(sessionId, { keepWorktree }); copilotcliSessionItemProvider.notifySessionsChange(); } } @@ -2211,7 +2226,9 @@ export function registerCLIChatCommands( if (sessionItem.resource) { const id = SessionIdForCLI.parse(sessionItem.resource); const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id; - await deleteSessionById(sessionId); + const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); + const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(sessionId, worktreePath); + await deleteSessionById(sessionId, { keepWorktree }); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index b2323745e9d..dd712cf0895 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -170,7 +170,7 @@ function createProvider() { const metadataStore = new class extends mock() { override getRequestDetails = vi.fn(async () => []); override getRepositoryProperties = vi.fn(async () => undefined); - override getSessionParentId = vi.fn(async () => undefined); + override getSessionParentId = vi.fn(async () => undefined); }; const gitService = new TestGitService(); const folderRepositoryManager = new TestFolderRepositoryManager(); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSharing.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSharing.spec.ts new file mode 100644 index 00000000000..d43207d16b3 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSharing.spec.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Uri } from 'vscode'; +import { mock } from '../../../../util/common/test/simpleMock'; +import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore'; +import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService'; +import { getBlockingSiblingSessionsForFolder } from '../worktreeSharing'; + +class TestMetadataStore extends mock() { + sessionIdsForFolder: string[] = []; + parentBySessionId = new Map>>(); + archivedBySessionId = new Map(); + + override getSessionIdsForFolder = vi.fn(() => this.sessionIdsForFolder); + override getSessionParentId = vi.fn(async (sessionId: string) => this.parentBySessionId.get(sessionId)); + override getSessionArchived = vi.fn(async (sessionId: string) => this.archivedBySessionId.get(sessionId) ?? false); +} + +class TestWorkspaceFolderService extends mock() { + associatedSessions: string[] = []; + override getAssociatedSessions = vi.fn(() => this.associatedSessions); +} + +describe('getBlockingSiblingSessionsForFolder', () => { + const folder = Uri.file('/workspace/repo'); + let metadataStore: TestMetadataStore; + let workspaceFolderService: TestWorkspaceFolderService; + + beforeEach(() => { + metadataStore = new TestMetadataStore(); + workspaceFolderService = new TestWorkspaceFolderService(); + }); + + it('returns no blockers when only the excluded session matches', async () => { + metadataStore.sessionIdsForFolder = ['session-1']; + + const blockers = await getBlockingSiblingSessionsForFolder(folder, 'session-1', metadataStore, workspaceFolderService); + + expect(blockers).toEqual([]); + expect(metadataStore.getSessionParentId).not.toHaveBeenCalled(); + expect(metadataStore.getSessionArchived).not.toHaveBeenCalled(); + }); + + it('filters out archived and sub-session siblings', async () => { + metadataStore.sessionIdsForFolder = ['excluded', 'active', 'archived', 'sub', 'forked']; + workspaceFolderService.associatedSessions = ['sub']; + metadataStore.archivedBySessionId.set('archived', true); + metadataStore.parentBySessionId.set('sub', { parentSessionId: 'parent', kind: 'sub-session' }); + metadataStore.parentBySessionId.set('forked', { parentSessionId: 'parent', kind: 'forked' }); + + const blockers = await getBlockingSiblingSessionsForFolder(folder, 'excluded', metadataStore, workspaceFolderService); + + expect(blockers.sort()).toEqual(['active', 'forked']); + }); + + it('de-duplicates session ids across metadata and workspace associated sessions', async () => { + metadataStore.sessionIdsForFolder = ['excluded', 'shared', 'metadata-only']; + workspaceFolderService.associatedSessions = ['shared', 'workspace-only', 'excluded']; + + const blockers = await getBlockingSiblingSessionsForFolder(folder, 'excluded', metadataStore, workspaceFolderService); + + expect(blockers.sort()).toEqual(['metadata-only', 'shared', 'workspace-only']); + expect(metadataStore.getSessionArchived).toHaveBeenCalledTimes(3); + expect(metadataStore.getSessionParentId).toHaveBeenCalledTimes(3); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSharing.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSharing.ts new file mode 100644 index 00000000000..2262a36b59b --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSharing.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; +import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; + +/** + * Returns the ids of "blocking" sibling sessions that share the same worktree + * (or workspace folder) as `excludeSessionId` and would be broken if the + * worktree directory were deleted. + * + * A candidate is treated as blocking iff it is *not* the excluded session, *not* + * a `sub-session` (sub-sessions don't independently keep a worktree alive — they + * follow their parent), and *not* archived (archived sessions have their + * worktree reconstructed via `recreateWorktreeOnUnarchive`, so they don't block + * cleanup either). + */ +export async function getBlockingSiblingSessionsForFolder( + folder: vscode.Uri, + excludeSessionId: string, + metadataStore: IChatSessionMetadataStore, + workspaceFolderService: IChatSessionWorkspaceFolderService, +): Promise { + const candidates = new Set([ + ...metadataStore.getSessionIdsForFolder(folder), + ...workspaceFolderService.getAssociatedSessions(folder), + ]); + candidates.delete(excludeSessionId); + + const results: string[] = []; + await Promise.all(Array.from(candidates).map(async id => { + const [parent, archived] = await Promise.all([ + metadataStore.getSessionParentId(id), + metadataStore.getSessionArchived(id), + ]); + if (archived) { + return; + } + if (parent?.kind === 'sub-session') { + return; + } + results.push(id); + })); + return results; +} diff --git a/extensions/copilot/test/e2e/cli.stest.ts b/extensions/copilot/test/e2e/cli.stest.ts index b6be6a79587..787c66e8229 100644 --- a/extensions/copilot/test/e2e/cli.stest.ts +++ b/extensions/copilot/test/e2e/cli.stest.ts @@ -208,6 +208,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl async getWorkspaceChanges() { return undefined; }, async hasCachedChanges() { return false; }, clearWorkspaceChanges() { return []; }, + getAssociatedSessions() { return []; }, onDidChangeWorkspaceFolderChanges: () => ({ dispose() { } }), } as IChatSessionWorkspaceFolderService); testingServiceCollection.define(IChatSessionWorktreeService, {