Skip to content

Commit e03656a

Browse files
committed
feat: implement worktree cleanup and recreation on session archive state changes
1 parent 45ff6dc commit e03656a

3 files changed

Lines changed: 176 additions & 0 deletions

File tree

src/extension/chatSessions/common/chatSessionWorktreeService.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,18 @@ export interface IChatSessionWorktreeService {
7171
getWorktreeChanges(sessionId: string): Promise<readonly vscode.ChatSessionChangedFile2[] | undefined>;
7272

7373
handleRequestCompleted(sessionId: string): Promise<void>;
74+
75+
/**
76+
* Attempts to clean up a worktree when a session is archived.
77+
* If auto-commit is enabled and there are uncommitted changes, commits them first.
78+
* Returns whether the worktree was successfully deleted.
79+
*/
80+
cleanupWorktreeOnArchive(sessionId: string): Promise<{ cleaned: boolean; reason?: string }>;
81+
82+
/**
83+
* Recreates a worktree from the session branch when a session is unarchived.
84+
* The branch must still exist (it is preserved during archive cleanup).
85+
* Returns whether the worktree was successfully recreated.
86+
*/
87+
recreateWorktreeOnUnarchive(sessionId: string): Promise<{ recreated: boolean; reason?: string }>;
7488
}

src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,145 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
452452
});
453453
}
454454

455+
async cleanupWorktreeOnArchive(sessionId: string): Promise<{ cleaned: boolean; reason?: string }> {
456+
const worktreeProperties = await this.getWorktreeProperties(sessionId);
457+
if (!worktreeProperties) {
458+
return { cleaned: false, reason: 'no-worktree' };
459+
}
460+
461+
const worktreePath = worktreeProperties.worktreePath;
462+
463+
// Check if the worktree directory exists
464+
try {
465+
await fs.access(worktreePath);
466+
} catch {
467+
this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Worktree path does not exist: ${worktreePath}`);
468+
return { cleaned: false, reason: 'worktree-not-found' };
469+
}
470+
471+
// Get the git repository for the worktree
472+
const repository = await this.gitCommitMessageService.getRepository(vscode.Uri.file(worktreePath));
473+
if (!repository) {
474+
this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Unable to find repository for worktree ${worktreePath}`);
475+
return { cleaned: false, reason: 'no-repository' };
476+
}
477+
478+
const hasUncommittedChanges = repository.state.workingTreeChanges.length > 0
479+
|| repository.state.indexChanges.length > 0
480+
|| repository.state.untrackedChanges.length > 0;
481+
482+
if (hasUncommittedChanges) {
483+
// For auto-commit sessions, commit changes before cleanup
484+
if (worktreeProperties.autoCommit !== false) {
485+
this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Auto-committing changes before cleanup for session ${sessionId}`);
486+
try {
487+
await this.handleRequestCompleted(sessionId);
488+
} catch (error) {
489+
const errorMessage = error instanceof Error ? error.message : String(error);
490+
this.logService.error(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to auto-commit: ${errorMessage}`);
491+
return { cleaned: false, reason: 'auto-commit-failed' };
492+
}
493+
} else {
494+
// Non-auto-commit sessions with uncommitted changes: skip cleanup
495+
this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Skipping cleanup for session ${sessionId}: has uncommitted changes and auto-commit is disabled`);
496+
return { cleaned: false, reason: 'uncommitted-changes' };
497+
}
498+
}
499+
500+
// Verify the branch exists before deleting the worktree
501+
try {
502+
const refs = await this.gitService.getRefs(
503+
vscode.Uri.file(worktreeProperties.repositoryPath),
504+
{ pattern: `refs/heads/${worktreeProperties.branchName}` }
505+
);
506+
if (!refs || refs.length === 0) {
507+
this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Branch ${worktreeProperties.branchName} not found, skipping cleanup`);
508+
return { cleaned: false, reason: 'branch-not-found' };
509+
}
510+
} catch (error) {
511+
const errorMessage = error instanceof Error ? error.message : String(error);
512+
this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to verify branch: ${errorMessage}`);
513+
return { cleaned: false, reason: 'branch-check-failed' };
514+
}
515+
516+
// Delete the worktree
517+
try {
518+
const parentRepository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath), true);
519+
if (!parentRepository) {
520+
this.logService.warn(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] No parent repository found for ${worktreeProperties.repositoryPath}`);
521+
return { cleaned: false, reason: 'no-parent-repository' };
522+
}
523+
await this.gitService.deleteWorktree(parentRepository.rootUri, worktreePath);
524+
this.logService.trace(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Deleted worktree ${worktreePath} for session ${sessionId}`);
525+
return { cleaned: true };
526+
} catch (error) {
527+
const errorMessage = error instanceof Error ? error.message : String(error);
528+
this.logService.error(`[ChatSessionWorktreeService][cleanupWorktreeOnArchive] Failed to delete worktree: ${errorMessage}`);
529+
return { cleaned: false, reason: 'delete-failed' };
530+
}
531+
}
532+
533+
async recreateWorktreeOnUnarchive(sessionId: string): Promise<{ recreated: boolean; reason?: string }> {
534+
const worktreeProperties = await this.getWorktreeProperties(sessionId);
535+
if (!worktreeProperties) {
536+
return { recreated: false, reason: 'no-worktree-properties' };
537+
}
538+
539+
const worktreePath = worktreeProperties.worktreePath;
540+
541+
// Check if the worktree already exists on disk
542+
try {
543+
await fs.access(worktreePath);
544+
this.logService.trace(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Worktree already exists at ${worktreePath}`);
545+
return { recreated: false, reason: 'already-exists' };
546+
} catch {
547+
// Expected — worktree was cleaned up on archive
548+
}
549+
550+
// Verify the branch still exists in the parent repository
551+
try {
552+
const refs = await this.gitService.getRefs(
553+
vscode.Uri.file(worktreeProperties.repositoryPath),
554+
{ pattern: `refs/heads/${worktreeProperties.branchName}` }
555+
);
556+
if (!refs || refs.length === 0) {
557+
this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Branch ${worktreeProperties.branchName} no longer exists`);
558+
return { recreated: false, reason: 'branch-not-found' };
559+
}
560+
} catch (error) {
561+
const errorMessage = error instanceof Error ? error.message : String(error);
562+
this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Failed to verify branch: ${errorMessage}`);
563+
return { recreated: false, reason: 'branch-check-failed' };
564+
}
565+
566+
// Recreate the worktree from the existing branch
567+
try {
568+
const parentRepository = await this.gitService.getRepository(vscode.Uri.file(worktreeProperties.repositoryPath), true);
569+
if (!parentRepository) {
570+
this.logService.warn(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] No parent repository found for ${worktreeProperties.repositoryPath}`);
571+
return { recreated: false, reason: 'no-parent-repository' };
572+
}
573+
574+
// Use commitish (existing branch) without branch (no -b flag) to checkout the existing branch
575+
const createdPath = await this.gitService.createWorktree(parentRepository.rootUri, {
576+
path: worktreePath,
577+
commitish: worktreeProperties.branchName,
578+
});
579+
580+
if (!createdPath) {
581+
this.logService.error(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] createWorktree returned no path`);
582+
return { recreated: false, reason: 'create-failed' };
583+
}
584+
585+
this.logService.trace(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Recreated worktree at ${createdPath} for session ${sessionId}`);
586+
return { recreated: true };
587+
} catch (error) {
588+
const errorMessage = error instanceof Error ? error.message : String(error);
589+
this.logService.error(`[ChatSessionWorktreeService][recreateWorktreeOnUnarchive] Failed to recreate worktree: ${errorMessage}`);
590+
return { recreated: false, reason: 'create-failed' };
591+
}
592+
}
593+
455594
private async _getWorktreeChangesFromIndex(worktreeProperties: ChatSessionWorktreeProperties): Promise<readonly ChatSessionWorktreeFile[] | undefined> {
456595
const worktreePath = vscode.Uri.file(worktreeProperties.worktreePath);
457596
const worktreeRepository = await this.gitService.getRepository(worktreePath);

src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,29 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
312312
controller.items.add(item);
313313
}));
314314

315+
// Handle worktree cleanup/recreation when archive state changes
316+
this._register(controller.onDidChangeChatSessionItemState(async (item) => {
317+
const sessionId = SessionIdForCLI.parse(item.resource);
318+
if (item.archived) {
319+
try {
320+
const result = await this.copilotCLIWorktreeManagerService.cleanupWorktreeOnArchive(sessionId);
321+
if (result.cleaned) {
322+
await this.workspaceFolderService.deleteTrackedWorkspaceFolder(sessionId);
323+
}
324+
this.logService.trace(`[CopilotCLI] Worktree cleanup for session ${sessionId}: ${result.cleaned ? 'cleaned' : result.reason}`);
325+
} catch (error) {
326+
this.logService.error(`[CopilotCLI] Failed to cleanup worktree for archived session ${sessionId}:`, error);
327+
}
328+
} else {
329+
try {
330+
const result = await this.copilotCLIWorktreeManagerService.recreateWorktreeOnUnarchive(sessionId);
331+
this.logService.trace(`[CopilotCLI] Worktree recreation for session ${sessionId}: ${result.recreated ? 'recreated' : result.reason}`);
332+
} catch (error) {
333+
this.logService.error(`[CopilotCLI] Failed to recreate worktree for unarchived session ${sessionId}:`, error);
334+
}
335+
}
336+
}));
337+
315338
controller.getChatSessionInputState = async (sessionResource, context, token) => {
316339
const groups = sessionResource ? await this.buildExistingSessionInputStateGroups(sessionResource, token) : await this.provideChatSessionProviderOptionGroups(context.previousInputState);
317340
return controller.createChatSessionInputState(groups);

0 commit comments

Comments
 (0)