From d4ab06fee0bc7915086e4e77cb4efb2e97757f2f Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:38:45 -0800 Subject: [PATCH] Commit customization files to main repo for worktree persistence (#299094) * Commit customization files to main repo for worktree persistence Customization files (agents, skills, instructions, prompts, hooks) are now always committed to the main repository so they persist across worktrees. When a worktree session is active, the file is also copied and committed there so the running session picks it up immediately. - Rewrite SessionsAICustomizationWorkspaceService.commitFiles() with dual-commit logic (main repo + worktree) - Add deleteFiles() to IAICustomizationWorkspaceService interface - Wire delete action to commit removals to git - Show friendly warning when main repo commit fails from a worktree * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../aiCustomizationWorkspaceService.ts | 162 +++++++++++++++++- .../aiCustomizationManagement.contribution.ts | 10 ++ .../aiCustomizationWorkspaceService.ts | 4 + .../common/aiCustomizationWorkspaceService.ts | 9 + 4 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 194d9ceb84b..0f874a7d7f8 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { derived, IObservable, observableValue, ISettableObservable } from '../../../../base/common/observable.js'; -import { joinPath } from '../../../../base/common/resources.js'; +import { joinPath, relativePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; @@ -13,11 +13,21 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { CustomizationCreatorService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { localize } from '../../../../nls.js'; /** * Agent Sessions override of IAICustomizationWorkspaceService. * Delegates to ISessionsManagementService to provide the active session's * worktree/repository as the project root, and supports worktree commit. + * + * Customization files are always committed to the main repository so they + * persist across worktrees. When a worktree is active the file is also + * copied into the worktree and committed there so the running session + * picks it up immediately. */ export class SessionsAICustomizationWorkspaceService implements IAICustomizationWorkspaceService { declare readonly _serviceBrand: undefined; @@ -45,6 +55,10 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization @ISessionsManagementService private readonly sessionsService: ISessionsManagementService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IPathService pathService: IPathService, + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, + @IFileService private readonly fileService: IFileService, + @INotificationService private readonly notificationService: INotificationService, ) { const userHome = pathService.userHome({ preferLocal: true }); this._cliUserRoots = [ @@ -119,15 +133,149 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization return this._cliUserFilter; } - /** - * Returns the CLI-accessible user directories (~/.copilot, ~/.claude, ~/.agents). - */ readonly isSessionsWindow = true; - async commitFiles(projectRoot: URI, fileUris: URI[]): Promise { + /** + * Commits customization files. Always commits to the main repository + * so the change persists across worktrees. When a worktree is active + * the file is also committed there so the session sees it immediately. + */ + async commitFiles(_projectRoot: URI, fileUris: URI[]): Promise { const session = this.sessionsService.getActiveSession(); - if (session) { - await this.sessionsService.commitWorktreeFiles(session, fileUris); + if (!session?.repository) { + return; + } + + for (const fileUri of fileUris) { + await this.commitFileToRepos(fileUri, session.repository, session.worktree); + } + } + + /** + * Commits the deletion of files that have already been removed from disk. + * Always stages + commits the removal in the main repository, and also + * in the worktree if one is active. + */ + async deleteFiles(_projectRoot: URI, fileUris: URI[]): Promise { + const session = this.sessionsService.getActiveSession(); + if (!session?.repository) { + return; + } + + for (const fileUri of fileUris) { + await this.commitDeletionToRepos(fileUri, session.repository, session.worktree); + } + } + + /** + * Computes the repository-relative path for a file. The file may be + * located under the worktree or the repository root. + */ + private getRelativePath(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): string | undefined { + // Try worktree first (when active, files are written under it) + if (worktreeUri) { + const rel = relativePath(worktreeUri, fileUri); + if (rel) { + return rel; + } + } + return relativePath(repositoryUri, fileUri); + } + + /** + * Commits a single file to the main repository and optionally the worktree. + * Copies the file content between trees when needed. + */ + private async commitFileToRepos(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): Promise { + const relPath = this.getRelativePath(fileUri, repositoryUri, worktreeUri); + if (!relPath) { + return; + } + + const repoFileUri = URI.joinPath(repositoryUri, relPath); + + // 1. Always commit to main repository + try { + if (repoFileUri.toString() !== fileUri.toString()) { + const content = await this.fileService.readFile(fileUri); + await this.fileService.writeFile(repoFileUri, content.value); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToRepository', + { repositoryUri, fileUri: repoFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit to repository:', error); + if (worktreeUri) { + this.notificationService.notify({ + severity: Severity.Warning, + message: localize('commitToRepoFailed', "Your customization was saved to this session's worktree, but we couldn't apply it to the default branch. You may need to apply it manually."), + }); + } + } + + // 2. Also commit to the worktree if active + if (worktreeUri) { + const worktreeFileUri = URI.joinPath(worktreeUri, relPath); + try { + if (worktreeFileUri.toString() !== fileUri.toString()) { + const content = await this.fileService.readFile(fileUri); + await this.fileService.writeFile(worktreeFileUri, content.value); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToWorktree', + { worktreeUri, fileUri: worktreeFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit to worktree:', error); + } + } + } + + /** + * Commits the deletion of a file to the main repository and optionally + * the worktree. The file is already deleted from disk before this is called; + * `git add` on a deleted path stages the removal. + */ + private async commitDeletionToRepos(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): Promise { + const relPath = this.getRelativePath(fileUri, repositoryUri, worktreeUri); + if (!relPath) { + return; + } + + const repoFileUri = URI.joinPath(repositoryUri, relPath); + + // 1. Delete from main repository if it exists there, then commit + try { + if (await this.fileService.exists(repoFileUri)) { + await this.fileService.del(repoFileUri, { useTrash: true, recursive: true }); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToRepository', + { repositoryUri, fileUri: repoFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit deletion to repository:', error); + if (worktreeUri) { + this.notificationService.notify({ + severity: Severity.Warning, + message: localize('deleteFromRepoFailed', "Your customization was removed from this session's worktree, but we couldn't apply the change to the default branch. You may need to remove it manually."), + }); + } + } + + // 2. Also commit the deletion in the worktree if active + if (worktreeUri) { + const worktreeFileUri = URI.joinPath(worktreeUri, relPath); + try { + // The file may already be deleted from the worktree by the caller + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToWorktree', + { worktreeUri, fileUri: worktreeFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit deletion to worktree:', error); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 6e0924c3c46..7011d2094f6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -30,6 +30,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatConfiguration } from '../../common/constants.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -233,6 +234,15 @@ registerAction2(class extends Action2 { // since each skill is a folder containing SKILL.md. const deleteTarget = isSkill ? dirname(uri) : uri; await fileService.del(deleteTarget, { useTrash: true, recursive: isSkill }); + + // Commit the deletion to git (sessions: main repo + worktree) + if (storage === PromptsStorage.local) { + const workspaceService = accessor.get(IAICustomizationWorkspaceService); + const projectRoot = workspaceService.getActiveProjectRoot(); + if (projectRoot) { + await workspaceService.deleteFiles(projectRoot, [deleteTarget]); + } + } } } }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index 6d867728d37..bd55fdf5449 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -71,6 +71,10 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic // No-op in core VS Code. } + async deleteFiles(_projectRoot: URI, _fileUris: URI[]): Promise { + // No-op in core VS Code. + } + async generateCustomization(type: PromptsType): Promise { const commandIds: Partial> = { [PromptsType.agent]: GENERATE_AGENT_COMMAND_ID, diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 26af14f6561..df9060677f2 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -99,6 +99,15 @@ export interface IAICustomizationWorkspaceService { */ commitFiles(projectRoot: URI, fileUris: URI[]): Promise; + /** + * Commits the deletion of resources that have already been removed from disk. + * The URIs may point to individual files or to directories (for example, when + * deleting a skill, the entire customization folder is removed). Implementations + * should ensure that directory deletions are handled recursively as needed. + * In sessions this stages and commits the removal in the relevant repositories. + */ + deleteFiles(projectRoot: URI, fileUris: URI[]): Promise; + /** * Launches the AI-guided creation flow for the given customization type. */