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>
This commit is contained in:
Josh Spicer
2026-03-03 19:38:45 -08:00
committed by GitHub
parent 76fa9206f1
commit d4ab06fee0
4 changed files with 178 additions and 7 deletions
@@ -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<void> {
/**
* 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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}
@@ -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]);
}
}
}
}
});
@@ -71,6 +71,10 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic
// No-op in core VS Code.
}
async deleteFiles(_projectRoot: URI, _fileUris: URI[]): Promise<void> {
// No-op in core VS Code.
}
async generateCustomization(type: PromptsType): Promise<void> {
const commandIds: Partial<Record<PromptsType, string>> = {
[PromptsType.agent]: GENERATE_AGENT_COMMAND_ID,
@@ -99,6 +99,15 @@ export interface IAICustomizationWorkspaceService {
*/
commitFiles(projectRoot: URI, fileUris: URI[]): Promise<void>;
/**
* 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<void>;
/**
* Launches the AI-guided creation flow for the given customization type.
*/