mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-14 12:11:43 +01:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+4
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user