Add archived state management and worktree sharing for chat sessions (#316561)

* Add archived state management and worktree sharing for chat sessions

- Introduced `archived` property in `ChatSessionMetadataFile` to track archived sessions.
- Updated `IChatSessionMetadataStore` interface to include methods for setting and getting archived state.
- Implemented logic in `ChatSessionMetadataStore` and `MockChatSessionMetadataStore` to handle archived sessions.
- Added `getBlockingSiblingSessionsForFolder` utility to identify sessions that share worktrees.
- Modified CLI chat session commands to respect archived state during session deletion and worktree cleanup.
- Updated tests to cover new functionality and ensure proper behavior of session management.

* Add worktree sharing edge-case unit tests

Co-authored-by: DonJayamanne <1948812+DonJayamanne@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Don Jayamanne
2026-05-15 15:21:16 +10:00
committed by GitHub
parent 0b181f5821
commit d1e403ebbf
11 changed files with 271 additions and 27 deletions
@@ -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<void>;
getSessionOrigin(sessionId: string): Promise<'vscode' | 'other'>;
setSessionParentId(sessionId: string, parentSessionId: string): Promise<void>;
getSessionParentId(sessionId: string): Promise<string | undefined>;
/**
* 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<void>;
getSessionArchived(sessionId: string): Promise<boolean>;
/**
* 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
@@ -75,4 +75,9 @@ export interface IChatSessionWorkspaceFolderService {
clearWorkspaceChanges(folderUri: vscode.Uri): string[];
hasCachedChanges(sessionId: string): Promise<boolean>;
/**
* Returns the ids of sessions whose tracked workspace folder matches the given URI.
*/
getAssociatedSessions(folderUri: vscode.Uri): string[];
}
@@ -149,10 +149,24 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
return Promise.resolve();
}
getSessionParentId(_sessionId: string): Promise<string | undefined> {
getSessionParentId(_sessionId: string): Promise<{ parentSessionId: string; kind: 'forked' | 'sub-session' } | undefined> {
return Promise.resolve(undefined);
}
private readonly _archived = new Set<string>();
async setSessionArchived(sessionId: string, archived: boolean): Promise<void> {
if (archived) {
this._archived.add(sessionId);
} else {
this._archived.delete(sessionId);
}
}
async getSessionArchived(sessionId: string): Promise<boolean> {
return this._archived.has(sessionId);
}
getSessionIdsForFolder(folder: vscode.Uri): string[] {
const folderPath = folder.fsPath;
const sessionIds: string[] = [];
@@ -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<string | undefined> {
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<void> {
await this._ready;
await this.updateMetadataFields(sessionId, { archived });
}
public async getSessionArchived(sessionId: string): Promise<boolean> {
const metadata = await this.getSessionMetadata(sessionId, false);
return metadata?.archived === true;
}
private async getSessionMetadata(sessionId: string, createMetadataFileIfNotFound = true): Promise<ChatSessionMetadataFile | undefined> {
@@ -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<vscode.ChatSessionItem> }).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));
@@ -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<void> {
const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(id);
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id);
async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise<void> {
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<boolean> {
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 });
}
}
}));
@@ -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<void> {
async function shouldKeepWorktreeForOtherSessions(sessionId: string, worktreePath: vscode.Uri): Promise<boolean> {
const siblings = await getBlockingSiblingSessionsForFolder(
worktreePath,
sessionId,
metadataStore,
copilotCliWorkspaceSession,
);
return siblings.length > 0;
}
async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise<void> {
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 });
}
}
@@ -170,7 +170,7 @@ function createProvider() {
const metadataStore = new class extends mock<IChatSessionMetadataStore>() {
override getRequestDetails = vi.fn(async () => []);
override getRepositoryProperties = vi.fn(async () => undefined);
override getSessionParentId = vi.fn(async () => undefined);
override getSessionParentId = vi.fn<IChatSessionMetadataStore['getSessionParentId']>(async () => undefined);
};
const gitService = new TestGitService();
const folderRepositoryManager = new TestFolderRepositoryManager();
@@ -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<IChatSessionMetadataStore>() {
sessionIdsForFolder: string[] = [];
parentBySessionId = new Map<string, Awaited<ReturnType<IChatSessionMetadataStore['getSessionParentId']>>>();
archivedBySessionId = new Map<string, boolean>();
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<IChatSessionWorkspaceFolderService>() {
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);
});
});
@@ -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<string[]> {
const candidates = new Set<string>([
...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;
}
+1
View File
@@ -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, {