diff --git a/extensions/git/package.json b/extensions/git/package.json index e39c9b95c3f5..8c8e8ce632a5 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -3743,6 +3743,18 @@ "experimental" ] }, + "git.worktreeSymlinkFolders": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "%config.worktreeSymlinkFolders%", + "scope": "resource", + "tags": [ + "experimental" + ] + }, "git.alwaysShowStagedChangesResourceGroup": { "type": "boolean", "scope": "resource", diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index e8c6e09111f9..e8acff6826e7 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -244,6 +244,7 @@ "config.detectWorktrees": "Controls whether to automatically detect Git worktrees.", "config.detectWorktreesLimit": "Controls the limit of Git worktrees detected.", "config.worktreeIncludeFiles": "Configure patterns, using `.gitignore` syntax, for files and folders that are included when creating a new worktree. Only files and folders that match the patterns and are ignored by git (for example, listed in `.gitignore`) will be copied to the newly created worktree.", + "config.worktreeSymlinkFolders": "Configure patterns, using `.gitignore` syntax, for folders to symlink from the current checkout into newly created worktrees. Only folders that match the patterns and are ignored by git (for example, listed in `.gitignore`) will be symlinked. Changes made through a symlink affect the folder in the current checkout.", "config.alwaysShowStagedChangesResourceGroup": "Always show the Staged Changes resource group.", "config.alwaysSignOff": "Controls the signoff flag for all commits.", "config.addAICoAuthor": "Controls whether a 'Co-authored-by' trailer is automatically added to the commit message when AI-generated code is included in the commit.", diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index feaa5126808f..bfd7f79e54c3 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -34,6 +34,7 @@ import { GitArtifactProvider } from './artifactProvider'; import { RepositoryCache } from './repositoryCache'; import { GitQuickDiffProvider, StagedResourceQuickDiffProvider } from './quickDiffProvider'; import { resolveWorktreeIncludePaths, sanitizeWorktreeIncludePatterns } from './worktreeInclude'; +import { createWorktreeSymlink, filterWorktreeSymlinkFolders, getWorktreeSymlinkFolderCandidates, type WorktreeSymlinkStatus } from './worktreeSymlink'; const timeout = (millis: number) => new Promise(c => setTimeout(c, millis)); @@ -1985,20 +1986,143 @@ export class Repository implements Disposable { this.globalState.update(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${this.root}`, newWorktreeRoot); } - this._setupWorktree(worktreePath!); + // Worktree setup is best effort and must not delay or fail creation. + this._setupWorktree(worktreePath!).then(undefined, err => { + this.logger.warn(`[Repository][createWorktree] Failed to set up worktree '${worktreePath}': ${err}`); + }); return worktreePath!; }); } private async _setupWorktree(worktreePath: string): Promise { - // Copy worktree include files and wait for the copy to complete - // before running any worktree-created tasks. - await this._copyWorktreeIncludeFiles(worktreePath); - + // Set up shared and copied worktree files before running any + // worktree-created tasks. + const symlinkFolders = await this._symlinkWorktreeFolders(worktreePath); + await this._copyWorktreeIncludeFiles(worktreePath, symlinkFolders); await this._runWorktreeCreatedTasks(worktreePath); } + private async _symlinkWorktreeFolders(worktreePath: string): Promise { + try { + const directories = await this._getWorktreeSymlinkFolders(); + if (directories.length === 0) { + return []; + } + + const startTime = performance.now(); + const statuses = new Map(); + const createdDirectories: string[] = []; + const errors: { directory: string; error: string }[] = []; + + for (const directory of directories) { + try { + const status = await createWorktreeSymlink(this.root, worktreePath, directory); + statuses.set(status, (statuses.get(status) ?? 0) + 1); + if (status === 'created') { + createdDirectories.push(directory); + } + } catch (err) { + errors.push({ directory, error: String(err) }); + } + } + + const created = statuses.get('created') ?? 0; + this.logger.info(`[Repository][_symlinkWorktreeFolders] Symlinked ${created}/${directories.length} folder(s) to worktree. [${(performance.now() - startTime).toFixed(2)}ms]`); + + const skippedStatuses: WorktreeSymlinkStatus[] = ['sourceContainsWorktree', 'targetExists']; + for (const status of skippedStatuses) { + const count = statuses.get(status) ?? 0; + if (count > 0) { + this.logger.info(`[Repository][_symlinkWorktreeFolders] Skipped ${count} folder(s) (${status}).`); + } + } + + if (errors.length > 0) { + window.showWarningMessage(l10n.t('Failed to create {0} worktree folder symlink(s).', errors.length)); + + this.logger.warn(`[Repository][_symlinkWorktreeFolders] Failed to create ${errors.length} worktree folder symlink(s).`); + for (const error of errors) { + this.logger.warn(` - ${error.directory}: ${error.error}`); + } + } + return createdDirectories; + } catch (err) { + this.logger.warn(`[Repository][_symlinkWorktreeFolders] Failed to symlink folders to worktree: ${err}`); + return []; + } + } + + private async _getWorktreeSymlinkFolders(): Promise { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const worktreeSymlinkFolders = config.get('worktreeSymlinkFolders', []); + + const patterns = sanitizeWorktreeIncludePatterns(worktreeSymlinkFolders); + if (patterns.length !== worktreeSymlinkFolders.length) { + this.logger.warn(`[Repository][_getWorktreeSymlinkFolders] Ignoring ${worktreeSymlinkFolders.length - patterns.length} pattern(s) containing line breaks.`); + } + if (patterns.length === 0) { + return []; + } + + const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'vscode-git-worktree-symlink-')); + const patternsFile = path.join(tempDir, 'patterns'); + + try { + await fsPromises.writeFile(patternsFile, patterns.join('\n') + '\n', 'utf8'); + + const tryExec = async (cwd: string, args: string[], input?: string, allowNoMatches = false): Promise => { + try { + return (await this.repository.git.exec(cwd, args, { input })).stdout; + } catch (err) { + if (allowNoMatches && err instanceof GitError && err.exitCode === 1) { + return ''; + } + this.logger.warn(`[Repository][_getWorktreeSymlinkFolders] Failed to execute 'git ${args.join(' ')}': ${err}`); + return undefined; + } + }; + + const baseArgs = ['ls-files', '--others', '--ignored', '-z']; + const [ignoredOutput, matchedOutput, directoryOutput] = await Promise.all([ + tryExec(this.root, [...baseArgs, '--exclude-standard']), + tryExec(this.root, [...baseArgs, `--exclude-from=${patternsFile}`]), + tryExec(this.root, [...baseArgs, '--exclude-standard', '--directory']) + ]); + if (ignoredOutput === undefined || matchedOutput === undefined || directoryOutput === undefined) { + return []; + } + + const candidates = getWorktreeSymlinkFolderCandidates(ignoredOutput, matchedOutput); + if (candidates.length === 0) { + return []; + } + + const matcherRoot = path.join(tempDir, 'matcher'); + await fsPromises.mkdir(matcherRoot); + if (await tryExec(matcherRoot, ['init', '--quiet']) === undefined) { + return []; + } + + const candidateInput = candidates.map(candidate => `${candidate}/`).join('\0'); + const [ignoredDirectoriesOutput, matchedDirectoriesOutput] = await Promise.all([ + tryExec(this.root, ['check-ignore', '--no-index', '-z', '--stdin'], candidateInput, true), + tryExec(matcherRoot, ['-c', `core.excludesFile=${patternsFile}`, 'check-ignore', '--no-index', '-z', '--stdin'], candidateInput, true) + ]); + if (ignoredDirectoriesOutput === undefined || matchedDirectoriesOutput === undefined) { + return []; + } + + return filterWorktreeSymlinkFolders(candidates, ignoredDirectoriesOutput, matchedDirectoriesOutput, directoryOutput); + } finally { + try { + await fsPromises.rm(tempDir, { recursive: true, force: true }); + } catch { + // best-effort + } + } + } + private async _runWorktreeCreatedTasks(worktreePath: string): Promise { try { const allTasks = await tasks.fetchTasks(); @@ -2025,7 +2149,7 @@ export class Repository implements Disposable { * `git.worktreeIncludeFiles` patterns are matched by git using * `.gitignore` semantics. */ - private async _getWorktreeIncludePaths(worktreePath: string): Promise { + private async _getWorktreeIncludePaths(worktreePath: string, excludedFolders: readonly string[]): Promise { const config = workspace.getConfiguration('git', Uri.file(this.root)); const worktreeIncludeFiles = config.get('worktreeIncludeFiles', []); @@ -2080,7 +2204,7 @@ export class Repository implements Disposable { return []; } - return resolveWorktreeIncludePaths(ignoredOutput, includedOutput, directoryOutput, worktreeOutput); + return resolveWorktreeIncludePaths(ignoredOutput, includedOutput, directoryOutput, worktreeOutput, excludedFolders); } finally { try { await fsPromises.rm(tempDir, { recursive: true, force: true }); @@ -2090,29 +2214,41 @@ export class Repository implements Disposable { } } - private async _copyWorktreeIncludeFiles(worktreePath: string): Promise { + private async _copyWorktreeIncludeFiles(worktreePath: string, excludedFolders: readonly string[]): Promise { try { - const files = await this._getWorktreeIncludePaths(worktreePath); + const files = await this._getWorktreeIncludePaths(worktreePath, excludedFolders); if (files.length === 0) { return; } const startTime = performance.now(); - const limiter = new Limiter(15); + const limiter = new Limiter(15); // Copy files and folders const results = await Promise.allSettled(files.map(file => { return limiter.queue(async () => { const sourcePath = path.join(this.root, file); const targetPath = path.join(worktreePath, file); + + try { + await fsPromises.lstat(targetPath); + return false; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + await fsPromises.mkdir(path.dirname(targetPath), { recursive: true }); await cp(sourcePath, targetPath, { force: true, recursive: true, verbatimSymlinks: true }); + return true; }); })); // Log any failed operations const failedOperations = results.filter(r => r.status === 'rejected'); - this.logger.info(`[Repository][_copyWorktreeIncludeFiles] Copied ${files.length - failedOperations.length}/${files.length} folder(s)/file(s) to worktree. [${(performance.now() - startTime).toFixed(2)}ms]`); + const copiedFiles = results.filter(r => r.status === 'fulfilled' && r.value).length; + this.logger.info(`[Repository][_copyWorktreeIncludeFiles] Copied ${copiedFiles}/${files.length} folder(s)/file(s) to worktree. [${(performance.now() - startTime).toFixed(2)}ms]`); if (failedOperations.length > 0) { window.showWarningMessage(l10n.t('Failed to copy {0} folder(s)/file(s) to the worktree.', failedOperations.length)); diff --git a/extensions/git/src/test/worktreeInclude.test.ts b/extensions/git/src/test/worktreeInclude.test.ts index e6f9a578c224..b5c63b0bb7a5 100644 --- a/extensions/git/src/test/worktreeInclude.test.ts +++ b/extensions/git/src/test/worktreeInclude.test.ts @@ -44,6 +44,7 @@ suite('worktreeInclude', () => { assert.deepStrictEqual({ all: resolveWorktreeIncludePaths(ignored, included, directories, worktree), + nestedSymlink: resolveWorktreeIncludePaths(ignored, included, directories, worktree, ['node_modules/a']), noDirectories: resolveWorktreeIncludePaths(ignored, included, undefined, ''), noIgnored: resolveWorktreeIncludePaths('', included, directories, worktree), noIncluded: resolveWorktreeIncludePaths(ignored, '', directories, worktree), @@ -55,6 +56,13 @@ suite('worktreeInclude', () => { 'partial/keep.txt', 'mixed/ignored.txt' ], + nestedSymlink: [ + '.env', + 'app/.env', + 'node_modules/b/index.js', + 'partial/keep.txt', + 'mixed/ignored.txt' + ], noDirectories: [ '.env', 'app/.env', diff --git a/extensions/git/src/test/worktreeSymlink.test.ts b/extensions/git/src/test/worktreeSymlink.test.ts new file mode 100644 index 000000000000..0222849e0a27 --- /dev/null +++ b/extensions/git/src/test/worktreeSymlink.test.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'mocha'; +import * as assert from 'assert'; +import * as fsPromises from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { Repository } from '../repository'; +import { createWorktreeSymlink, filterWorktreeSymlinkFolders, getWorktreeSymlinkFolderCandidates } from '../worktreeSymlink'; + +interface IWorktreeSymlinkHarness { + logger: { warn(message: string): void }; + _getWorktreeSymlinkFolders(): Promise; +} + +const symlinkWorktreeFolders = Reflect.get(Repository.prototype, '_symlinkWorktreeFolders') as (this: IWorktreeSymlinkHarness, worktreePath: string) => Promise; + +suite('worktreeSymlink', () => { + const nul = (...entries: string[]) => entries.map(entry => `${entry}\x00`).join(''); + + let testRoot: string; + let repositoryRoot: string; + let worktreeRoot: string; + + setup(async () => { + testRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'vscode-git-worktree-symlink-')); + repositoryRoot = path.join(testRoot, 'repository'); + worktreeRoot = path.join(testRoot, 'worktree'); + await Promise.all([ + fsPromises.mkdir(repositoryRoot), + fsPromises.mkdir(worktreeRoot) + ]); + }); + + teardown(async () => { + await fsPromises.rm(testRoot, { recursive: true, force: true }); + }); + + test('resolves directories that match configured and repository ignore patterns', () => { + const ignored = nul( + 'node_modules/a/index.js', + 'node_modules/b/index.js', + 'packages/a/cache/data', + 'unignored/cache/data', + 'tracked-cache/ignored', + 'partial-cache/drop/data' + ); + const matched = nul( + 'node_modules/a/index.js', + 'node_modules/b/index.js', + 'packages/a/cache/data', + 'unignored/cache/data', + 'tracked-cache/ignored', + 'partial-cache/drop/data', + 'partial-cache/keep/data', + 'other/file' + ); + const candidates = getWorktreeSymlinkFolderCandidates(ignored, matched); + + assert.deepStrictEqual({ + candidates, + directories: filterWorktreeSymlinkFolders( + candidates, + nul('node_modules/', 'node_modules/a/', 'node_modules/b/', 'packages/a/cache/', 'unignored/cache/', 'tracked-cache/', 'partial-cache/', 'partial-cache/drop/'), + nul('node_modules/', 'node_modules/a/', 'node_modules/b/', 'packages/', 'packages/a/', 'packages/a/cache/', 'unignored/', 'tracked-cache/', 'partial-cache/', 'partial-cache/drop/'), + nul('node_modules/', 'packages/', 'unignored/', 'partial-cache/drop/') + ), + }, { + candidates: [ + 'node_modules/a', + 'node_modules', + 'node_modules/b', + 'packages/a/cache', + 'packages/a', + 'packages', + 'unignored/cache', + 'unignored', + 'tracked-cache', + 'partial-cache/drop', + 'partial-cache' + ], + directories: [ + 'node_modules', + 'packages/a/cache', + 'partial-cache/drop' + ] + }); + }); + + test('createWorktreeSymlink creates a directory symlink to the repository', async () => { + const sourceDirectory = path.join(repositoryRoot, 'node_modules'); + const targetDirectory = path.join(worktreeRoot, 'node_modules'); + await fsPromises.mkdir(sourceDirectory); + await fsPromises.writeFile(path.join(sourceDirectory, 'package.txt'), 'contents'); + + const status = await createWorktreeSymlink(repositoryRoot, worktreeRoot, 'node_modules'); + + assert.deepStrictEqual({ + status, + isSymbolicLink: (await fsPromises.lstat(targetDirectory)).isSymbolicLink(), + target: await fsPromises.realpath(targetDirectory), + contents: await fsPromises.readFile(path.join(targetDirectory, 'package.txt'), 'utf8') + }, { + status: 'created', + isSymbolicLink: true, + target: await fsPromises.realpath(sourceDirectory), + contents: 'contents' + }); + }); + + test('createWorktreeSymlink skips existing targets', async () => { + await fsPromises.mkdir(path.join(repositoryRoot, 'existing')); + await fsPromises.mkdir(path.join(worktreeRoot, 'existing')); + + assert.strictEqual( + await createWorktreeSymlink(repositoryRoot, worktreeRoot, 'existing'), + 'targetExists' + ); + }); + + test('createWorktreeSymlink rejects a symlinked target parent', async () => { + const externalDirectory = path.join(testRoot, 'external'); + await Promise.all([ + fsPromises.mkdir(path.join(repositoryRoot, 'parent', 'child'), { recursive: true }), + fsPromises.mkdir(externalDirectory) + ]); + await fsPromises.symlink(externalDirectory, path.join(worktreeRoot, 'parent'), process.platform === 'win32' ? 'junction' : 'dir'); + + await assert.rejects( + createWorktreeSymlink(repositoryRoot, worktreeRoot, path.join('parent', 'child')), + /target parent .* is a symbolic link/ + ); + }); + + test('createWorktreeSymlink skips directories containing the worktree', async () => { + const nestedWorktree = path.join(repositoryRoot, '.worktrees', 'feature'); + await fsPromises.mkdir(nestedWorktree, { recursive: true }); + + assert.strictEqual( + await createWorktreeSymlink(repositoryRoot, nestedWorktree, '.worktrees'), + 'sourceContainsWorktree' + ); + }); + + test('symlinking worktree directories is best effort', async () => { + const warnings: string[] = []; + const harness: IWorktreeSymlinkHarness = { + logger: { warn: message => warnings.push(message) }, + _getWorktreeSymlinkFolders: async () => { + throw new Error('detection failed'); + } + }; + + const folders = await symlinkWorktreeFolders.call(harness, worktreeRoot); + + assert.deepStrictEqual({ folders, warnings }, { + folders: [], + warnings: ['[Repository][_symlinkWorktreeFolders] Failed to symlink folders to worktree: Error: detection failed'] + }); + }); +}); diff --git a/extensions/git/src/worktreeInclude.ts b/extensions/git/src/worktreeInclude.ts index b1c065c83a61..ba789f5c6e6a 100644 --- a/extensions/git/src/worktreeInclude.ts +++ b/extensions/git/src/worktreeInclude.ts @@ -21,9 +21,10 @@ export function sanitizeWorktreeIncludePatterns(patterns: readonly string[]): st * @param includedOutput `git ls-files --others --ignored --exclude-from= -z` in the repository. * @param directoryOutput `git ls-files --others --ignored --exclude-standard -z --directory --no-empty-directory` in the repository. * @param worktreeOutput `git ls-files -z` in the newly created worktree. + * @param excludedFolders Repository-relative folders already symlinked into the worktree. * @returns Repository-relative, forward-slash paths (files and folders) to copy. */ -export function resolveWorktreeIncludePaths(ignoredOutput: string, includedOutput: string, directoryOutput: string | undefined, worktreeOutput: string): string[] { +export function resolveWorktreeIncludePaths(ignoredOutput: string, includedOutput: string, directoryOutput: string | undefined, worktreeOutput: string, excludedFolders: readonly string[] = []): string[] { const ignoredFiles = splitNulSeparated(ignoredOutput); if (ignoredFiles.length === 0) { return []; @@ -40,6 +41,7 @@ export function resolveWorktreeIncludePaths(ignoredOutput: string, includedOutpu const includedFiles = new Set(splitNulSeparated(includedOutput)); const wholeDirectories = new Set(splitNulSeparated(directoryOutput).filter(entry => entry.endsWith('/'))); const worktreeFiles = new Set(splitNulSeparated(worktreeOutput)); + const excludedDirectories = new Set(excludedFolders.map(folder => folder.endsWith('/') ? folder : `${folder}/`)); // Every ancestor directory of a tracked path, with the trailing `/` used // by `git ls-files --directory`, so a source path can be checked against @@ -56,7 +58,7 @@ export function resolveWorktreeIncludePaths(ignoredOutput: string, includedOutpu const matchedFiles: string[] = []; const nonCollapsibleDirectories = new Set(); for (const file of ignoredFiles) { - if (includedFiles.has(file) && !hasWorktreePathCollision(file, worktreeFiles, worktreeDirectories)) { + if (includedFiles.has(file) && findContainingDirectory(file, excludedDirectories) === undefined && !hasWorktreePathCollision(file, worktreeFiles, worktreeDirectories)) { matchedFiles.push(file); } else if (wholeDirectories.size > 0) { const containingDirectory = findContainingDirectory(file, wholeDirectories); diff --git a/extensions/git/src/worktreeSymlink.ts b/extensions/git/src/worktreeSymlink.ts new file mode 100644 index 000000000000..43403cad07e5 --- /dev/null +++ b/extensions/git/src/worktreeSymlink.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fsPromises from 'fs/promises'; +import * as path from 'path'; +import { isDescendant } from './util'; + +export type WorktreeSymlinkStatus = 'created' | 'sourceContainsWorktree' | 'targetExists'; + +/** + * Returns the ancestor directories of files that are both git-ignored and + * matched by the configured patterns. + */ +export function getWorktreeSymlinkFolderCandidates(ignoredOutput: string, matchedOutput: string): string[] { + const matchedFiles = new Set(splitNulSeparated(matchedOutput)); + const candidates = new Set(); + + for (const file of splitNulSeparated(ignoredOutput)) { + if (!matchedFiles.has(file)) { + continue; + } + + let index = file.lastIndexOf('/'); + while (index !== -1) { + candidates.add(file.slice(0, index)); + index = file.lastIndexOf('/', index - 1); + } + } + + return Array.from(candidates); +} + +/** + * Selects candidate directories that are themselves ignored by git, match the + * configured patterns, and contain no tracked files. + */ +export function filterWorktreeSymlinkFolders(candidates: readonly string[], ignoredOutput: string, matchedOutput: string, directoryOutput: string): string[] { + const ignoredDirectories = new Set(splitNulSeparated(ignoredOutput)); + const matchedDirectories = new Set(splitNulSeparated(matchedOutput)); + const whollyIgnoredDirectories = new Set(splitNulSeparated(directoryOutput).filter(directory => directory.endsWith('/'))); + + const directories = candidates.filter(directory => + ignoredDirectories.has(`${directory}/`) && + matchedDirectories.has(`${directory}/`) && + hasContainingDirectory(directory, whollyIgnoredDirectories) + ); + const directorySet = new Set(directories.map(directory => `${directory}/`)); + + return directories.filter(directory => + !hasContainingDirectory(directory, directorySet, false) + ); +} + +/** + * Creates a symlink in a worktree to a directory in the repository. + */ +export async function createWorktreeSymlink(repositoryRoot: string, worktreeRoot: string, folder: string): Promise { + folder = folder.split('/').join(path.sep); + const sourcePath = path.join(repositoryRoot, folder); + const targetPath = path.join(worktreeRoot, folder); + + const [sourceRealPath, worktreeRealPath] = await Promise.all([ + fsPromises.realpath(sourcePath), + fsPromises.realpath(worktreeRoot) + ]); + if (isDescendant(sourceRealPath, worktreeRealPath)) { + return 'sourceContainsWorktree'; + } + + await assertTargetParentDoesNotContainSymlink(worktreeRoot, folder); + + try { + await fsPromises.lstat(targetPath); + return 'targetExists'; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + await fsPromises.mkdir(path.dirname(targetPath), { recursive: true }); + await fsPromises.symlink(sourcePath, targetPath, process.platform === 'win32' ? 'junction' : 'dir'); + return 'created'; +} + +async function assertTargetParentDoesNotContainSymlink(worktreeRoot: string, folder: string): Promise { + const parentDirectory = path.dirname(folder); + if (parentDirectory === '.') { + return; + } + + let currentPath = worktreeRoot; + for (const pathSegment of parentDirectory.split(path.sep)) { + currentPath = path.join(currentPath, pathSegment); + + try { + const stat = await fsPromises.lstat(currentPath); + if (stat.isSymbolicLink()) { + throw new Error(`Cannot create worktree symlink because target parent '${currentPath}' is a symbolic link.`); + } + if (!stat.isDirectory()) { + throw new Error(`Cannot create worktree symlink because target parent '${currentPath}' is not a directory.`); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw err; + } + } +} + +function splitNulSeparated(output: string): string[] { + return output.split('\x00').filter(entry => entry.length > 0); +} + +function hasContainingDirectory(directory: string, directories: ReadonlySet, includeSelf = true): boolean { + let index = includeSelf ? directory.length : directory.lastIndexOf('/'); + while (index > 0) { + if (directories.has(`${directory.slice(0, index)}/`)) { + return true; + } + index = directory.lastIndexOf('/', index - 1); + } + return false; +} diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 755d897856ec..bfc1365ad1fa 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -248,7 +248,13 @@ export interface IAgentHostGitService { * `node_modules` is copied as one recursive unit, so its files all land in * a single step. */ - copyWorktreeIncludeFiles(repositoryRoot: URI, worktree: URI, patterns: readonly string[], sessionId: string, onProgress?: (progress: IWorktreeFileProgress) => void): Promise; + copyWorktreeIncludeFiles(repositoryRoot: URI, worktree: URI, patterns: readonly string[], sessionId: string, onProgress?: (progress: IWorktreeFileProgress) => void, excludedFolders?: readonly string[]): Promise; + /** + * Symlinks git-ignored folders matching `patterns` from `repositoryRoot` + * into the worktree. `patterns` use `.gitignore` syntax and are matched by + * git itself. `sessionId` scopes the temporary files used while matching. + */ + symlinkWorktreeFolders(repositoryRoot: URI, worktree: URI, patterns: readonly string[], sessionId: string): Promise; /** * Adds a worktree for an existing branch (no `-b`). Used when restoring * a worktree whose branch was preserved (e.g. unarchiving a session diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index 9a6f3b8af1b7..8687dae5569d 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -33,6 +33,8 @@ export const enum SessionConfigKey { WorktreeBranchPrefix = 'worktreeBranchPrefix', /** `'worktreeIncludeFiles'` — host-owned `.gitignore`-syntax patterns for git-ignored files copied into a new worktree. */ WorktreeIncludeFiles = 'worktreeIncludeFiles', + /** `'worktreeSymlinkFolders'` — host-owned `.gitignore`-syntax patterns for git-ignored folders symlinked into a new worktree. */ + WorktreeSymlinkFolders = 'worktreeSymlinkFolders', /** `'worktreeBranchTrack'` — host-owned branch tracking preference for programmatic session creation. */ WorktreeBranchTrack = 'worktreeBranchTrack', /** `'worktreeCreateNewBranch'` — host-owned choice to create a branch instead of checking out the selected branch. */ @@ -88,6 +90,7 @@ const automationDefinitionOwnedConfigKeys = [ SessionConfigKey.Branch, SessionConfigKey.WorktreeBranchPrefix, SessionConfigKey.WorktreeIncludeFiles, + SessionConfigKey.WorktreeSymlinkFolders, SessionConfigKey.WorktreeBranchTrack, SessionConfigKey.WorktreeCreateNewBranch, SessionConfigKey.AgentMerge, diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 3379cae9afe6..b4bfa74ff68f 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -20,6 +20,7 @@ import { buildGitBlobUri } from './gitDiffContent.js'; import { CheckoutBlockedByLocalChangesError, EMPTY_TREE_OBJECT, IAddWorktreeOptions, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress } from '../common/agentHostGitService.js'; import { LRUCache } from '../../../base/common/map.js'; import { firstParallel, Limiter, SequencerByKey, timeout } from '../../../base/common/async.js'; +import { createWorktreeSymlink } from './worktreeSymlink.js'; /** * `git worktree remove`/`prune` can transiently fail — or, worse, exit 0 while @@ -195,27 +196,41 @@ export class AgentHostGitService implements IAgentHostGitService { }); } - async copyWorktreeIncludeFiles(repositoryRoot: URI, worktree: URI, patterns: readonly string[], sessionId: string, onProgress?: (progress: IWorktreeFileProgress) => void): Promise { + async copyWorktreeIncludeFiles(repositoryRoot: URI, worktree: URI, patterns: readonly string[], sessionId: string, onProgress?: (progress: IWorktreeFileProgress) => void, excludedFolders: readonly string[] = []): Promise { try { - const worktreeIncludePaths = await this._getWorktreeIncludePaths(repositoryRoot, worktree, patterns, sessionId); + const worktreeIncludePaths = await this._getWorktreeIncludePaths(repositoryRoot, worktree, patterns, sessionId, excludedFolders); if (worktreeIncludePaths.length === 0) { return; } const startTime = performance.now(); - const limiter = new Limiter(15); + const limiter = new Limiter(15); const filesTotal = worktreeIncludePaths.reduce((total, entry) => total + entry.fileCount, 0); let filesDone = 0; const results = await Promise.allSettled(worktreeIncludePaths.map(entry => limiter.queue(async () => { const targetPath = path.join(worktree.fsPath, path.relative(repositoryRoot.fsPath, entry.sourcePath)); + + try { + await fsPromises.lstat(targetPath); + filesDone += entry.fileCount; + onProgress?.({ filesDone, filesTotal }); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + await fsPromises.mkdir(path.dirname(targetPath), { recursive: true }); await copyFile(entry.sourcePath, targetPath, { force: true, recursive: true, verbatimSymlinks: true }); filesDone += entry.fileCount; onProgress?.({ filesDone, filesTotal }); + return true; }))); const failedOperations = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); - this._logService.info(`[AgentHostGitService][copyWorktreeIncludeFiles] Copied ${worktreeIncludePaths.length - failedOperations.length}/${worktreeIncludePaths.length} folder(s)/file(s) to worktree ${worktree.fsPath}. [${(performance.now() - startTime).toFixed(2)}ms]`); + const copiedEntries = results.filter(result => result.status === 'fulfilled' && result.value).length; + this._logService.info(`[AgentHostGitService][copyWorktreeIncludeFiles] Copied ${copiedEntries}/${worktreeIncludePaths.length} folder(s)/file(s) to worktree ${worktree.fsPath}. [${(performance.now() - startTime).toFixed(2)}ms]`); if (failedOperations.length > 0) { this._logService.warn(`[AgentHostGitService][copyWorktreeIncludeFiles] Failed to copy ${failedOperations.length} folder(s)/file(s) to worktree ${worktree.fsPath}.`); @@ -228,6 +243,37 @@ export class AgentHostGitService implements IAgentHostGitService { } } + async symlinkWorktreeFolders(repositoryRoot: URI, worktree: URI, patterns: readonly string[], sessionId: string): Promise { + try { + const folders = await this._getWorktreeSymlinkFolders(repositoryRoot, patterns, sessionId); + if (folders.length === 0) { + return []; + } + + const startTime = performance.now(); + const limiter = new Limiter(15); + const results = await Promise.allSettled(folders.map(folder => limiter.queue(() => + createWorktreeSymlink(repositoryRoot, worktree, folder)))); + const failedOperations = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + const symlinkedFolders = results.filter(result => result.status === 'fulfilled' && result.value).length; + this._logService.info(`[AgentHostGitService][symlinkWorktreeFolders] Symlinked ${symlinkedFolders}/${folders.length} folder(s) to worktree ${worktree.fsPath}. [${(performance.now() - startTime).toFixed(2)}ms]`); + + if (failedOperations.length > 0) { + this._logService.warn(`[AgentHostGitService][symlinkWorktreeFolders] Failed to symlink ${failedOperations.length} folder(s) to worktree ${worktree.fsPath}.`); + for (const error of failedOperations) { + this._logService.warn(`[AgentHostGitService][symlinkWorktreeFolders] ${error.reason}`); + } + } + return folders.filter((_, index) => { + const result = results[index]; + return result?.status === 'fulfilled' && result.value; + }); + } catch (error) { + this._logService.warn(`[AgentHostGitService][symlinkWorktreeFolders] Failed to symlink folders to worktree ${worktree.fsPath}: ${error}`); + return []; + } + } + async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { // `-f` (force) so recreation succeeds even when the worktree directory was // deleted out-of-band but git still has it registered ("missing but @@ -652,10 +698,10 @@ export class AgentHostGitService implements IAgentHostGitService { * Resolves the git-ignored paths to copy into a worktree. `patterns` are * matched by git using `.gitignore` semantics. */ - private async _getWorktreeIncludePaths(repositoryRoot: URI, worktreeRoot: URI, patterns: readonly string[], sessionId: string): Promise { + private async _getWorktreeIncludePaths(repositoryRoot: URI, worktreeRoot: URI, patterns: readonly string[], sessionId: string, excludedFolders: readonly string[]): Promise { // Each setting entry must stay a single `.gitignore` line; an embedded // line break would inject additional patterns (e.g. a `!` negation). - const includePatterns = patterns.filter(pattern => !/[\r\n]/.test(pattern)); + const includePatterns = sanitizeWorktreePatterns(patterns); if (includePatterns.length !== patterns.length) { this._logService.warn(`[AgentHostGitService][copyWorktreeIncludeFiles] Ignoring ${patterns.length - includePatterns.length} pattern(s) containing line breaks.`); } @@ -704,7 +750,55 @@ export class AgentHostGitService implements IAgentHostGitService { return []; } - return resolveWorktreeIncludeEntries(repositoryRoot, ignoredFiles, includedOutput, directoryOutput, worktreeOutput); + return resolveWorktreeIncludeEntries(repositoryRoot, ignoredFiles, includedOutput, directoryOutput, worktreeOutput, excludedFolders); + } finally { + try { await this._fileService.del(tempDir, { recursive: true, useTrash: false }); } catch { /* best-effort */ } + } + } + + private async _getWorktreeSymlinkFolders(repositoryRoot: URI, patterns: readonly string[], sessionId: string): Promise { + const symlinkPatterns = sanitizeWorktreePatterns(patterns); + if (symlinkPatterns.length !== patterns.length) { + this._logService.warn(`[AgentHostGitService][symlinkWorktreeFolders] Ignoring ${patterns.length - symlinkPatterns.length} pattern(s) containing line breaks.`); + } + if (symlinkPatterns.length === 0) { + return []; + } + + const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-worktree-symlink-${toFileNameSafeSessionId(sessionId)}`); + const patternsFile = URI.joinPath(tempDir, 'patterns'); + const matcherRoot = URI.joinPath(tempDir, 'matcher'); + await this._fileService.createFolder(matcherRoot); + + try { + await this._fileService.writeFile(patternsFile, VSBuffer.fromString(symlinkPatterns.join('\n') + '\n')); + + const baseArgs = ['ls-files', '--others', '--ignored', '-z']; + const [ignoredOutput, matchedOutput, directoryOutput] = await Promise.all([ + this._runGit(repositoryRoot, [...baseArgs, '--exclude-standard'], { timeout: 60_000 }), + this._runGit(repositoryRoot, [...baseArgs, `--exclude-from=${patternsFile.fsPath}`], { timeout: 60_000 }), + this._runGit(repositoryRoot, [...baseArgs, '--exclude-standard', '--directory'], { timeout: 60_000 }), + ]); + if (ignoredOutput === undefined || matchedOutput === undefined || directoryOutput === undefined) { + return []; + } + + const candidates = getWorktreeSymlinkFolderCandidates(ignoredOutput, matchedOutput); + if (candidates.length === 0) { + return []; + } + + if (await this._runGit(matcherRoot, ['init', '--quiet'], { timeout: 60_000 }) === undefined) { + return []; + } + + const candidateInput = candidates.map(candidate => `${candidate}/`).join('\0'); + const [ignoredFoldersOutput, matchedFoldersOutput] = await Promise.all([ + this._runGit(repositoryRoot, ['check-ignore', '--no-index', '-z', '--stdin'], { timeout: 60_000, input: candidateInput }), + this._runGit(matcherRoot, ['-c', `core.excludesFile=${patternsFile.fsPath}`, 'check-ignore', '--no-index', '-z', '--stdin'], { timeout: 60_000, input: candidateInput }), + ]); + + return filterWorktreeSymlinkFolders(candidates, ignoredFoldersOutput ?? '', matchedFoldersOutput ?? '', directoryOutput); } finally { try { await this._fileService.del(tempDir, { recursive: true, useTrash: false }); } catch { /* best-effort */ } } @@ -1069,7 +1163,7 @@ export class AgentHostGitService implements IAgentHostGitService { return this._runGit(workingDirectory, ['status', ...args], { env: { GIT_OPTIONAL_LOCKS: '0' } }); } - private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean; readonly env?: Record; readonly maxBuffer?: number; readonly onStderr?: (chunk: string) => void }): Promise { + private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean; readonly env?: Record; readonly maxBuffer?: number; readonly onStderr?: (chunk: string) => void; readonly input?: string }): Promise { this._logService.trace(`[agentHostGitService] > git ${args.join(' ')}`); return new Promise((resolve, reject) => { @@ -1116,6 +1210,9 @@ export class AgentHostGitService implements IAgentHostGitService { if (onStderr) { child.stderr?.on('data', (chunk: Buffer | string) => onStderr(chunk.toString())); } + if (options?.input !== undefined) { + child.stdin?.end(options.input, 'utf8'); + } const timer = setTimeout(() => { didTimeOut = true; if (child.pid === undefined) { @@ -1255,12 +1352,67 @@ function toFileNameSafeSessionId(sessionId: string): string { return sessionId.replace(/[^\w.-]/g, '_'); } +function sanitizeWorktreePatterns(patterns: readonly string[]): string[] { + return patterns.filter(pattern => !/[\r\n]/.test(pattern)); +} + +function getWorktreeSymlinkFolderCandidates(ignoredOutput: string, matchedOutput: string): string[] { + const matchedFiles = new Set(splitNulSeparated(matchedOutput)); + const candidates = new Set(); + + for (const file of splitNulSeparated(ignoredOutput)) { + if (!matchedFiles.has(file)) { + continue; + } + + let index = file.lastIndexOf('/'); + while (index !== -1) { + candidates.add(file.slice(0, index)); + index = file.lastIndexOf('/', index - 1); + } + } + + return Array.from(candidates); +} + +function filterWorktreeSymlinkFolders(candidates: readonly string[], ignoredOutput: string, matchedOutput: string, directoryOutput: string): string[] { + const ignoredFolders = new Set(splitNulSeparated(ignoredOutput)); + const matchedFolders = new Set(splitNulSeparated(matchedOutput)); + const whollyIgnoredFolders = new Set(splitNulSeparated(directoryOutput).filter(folder => folder.endsWith('/'))); + + const folders = candidates.filter(folder => + ignoredFolders.has(`${folder}/`) && + matchedFolders.has(`${folder}/`) && + hasContainingFolder(folder, whollyIgnoredFolders) + ); + const folderSet = new Set(folders.map(folder => `${folder}/`)); + + return folders.filter(folder => + !hasContainingFolder(folder, folderSet, false) + ); +} + +function splitNulSeparated(output: string): string[] { + return output.split('\x00').filter(entry => entry.length > 0); +} + +function hasContainingFolder(folder: string, folders: ReadonlySet, includeSelf = true): boolean { + let index = includeSelf ? folder.length : folder.lastIndexOf('/'); + while (index > 0) { + if (folders.has(`${folder.slice(0, index)}/`)) { + return true; + } + index = folder.lastIndexOf('/', index - 1); + } + return false; +} + /** * Selects the ignored files to copy into a worktree from the NUL-separated * `git ls-files` outputs, collapsing wholly-ignored directories whose every * ignored file is included into a single recursive entry. */ -function resolveWorktreeIncludeEntries(repositoryRoot: URI, ignoredFiles: readonly string[], includedOutput: string, directoryOutput: string | undefined, worktreeOutput: string | undefined): IWorktreeIncludeEntry[] { +function resolveWorktreeIncludeEntries(repositoryRoot: URI, ignoredFiles: readonly string[], includedOutput: string, directoryOutput: string | undefined, worktreeOutput: string | undefined, excludedFolders: readonly string[] = []): IWorktreeIncludeEntry[] { // Keep only the ignored files that also match one of the configured // `git.worktreeIncludeFiles` patterns, and — in the same pass — tally // which wholly-ignored directories contain an ignored file that cannot @@ -1275,6 +1427,7 @@ function resolveWorktreeIncludeEntries(repositoryRoot: URI, ignoredFiles: readon .split('\x00').filter(entry => entry.endsWith('/'))); const worktreeFiles = new Set((worktreeOutput ?? '') .split('\x00').filter(entry => entry.length > 0)); + const excludedDirectories = new Set(excludedFolders.map(folder => folder.endsWith('/') ? folder : `${folder}/`)); // Every ancestor directory of a tracked path, with the trailing `/` used // by `git ls-files --directory`, so a source path can be checked against @@ -1293,6 +1446,7 @@ function resolveWorktreeIncludeEntries(repositoryRoot: URI, ignoredFiles: readon for (const file of ignoredFiles) { if ( includedFiles.has(file) && + findContainingDirectory(file, excludedDirectories) === undefined && !hasWorktreePathCollision(file, worktreeFiles, worktreeDirectories) ) { matchedFiles.push(file); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6b2f0a86f290..bba77c899371 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -214,6 +214,7 @@ const HOST_OWNED_SESSION_CONFIG_KEYS = [ SessionConfigKey.Branch, SessionConfigKey.WorktreeBranchPrefix, SessionConfigKey.WorktreeIncludeFiles, + SessionConfigKey.WorktreeSymlinkFolders, SessionConfigKey.WorktreeBranchTrack, SessionConfigKey.WorktreeCreateNewBranch, ] as const; @@ -5690,6 +5691,9 @@ export class AgentService extends Disposable implements IAgentService { if (iso.worktreeIncludeFilesProperty) { properties[SessionConfigKey.WorktreeIncludeFiles] = iso.worktreeIncludeFilesProperty.protocol; } + if (iso.worktreeSymlinkFoldersProperty) { + properties[SessionConfigKey.WorktreeSymlinkFolders] = iso.worktreeSymlinkFoldersProperty.protocol; + } const values = omitHostOwnedSessionConfig(result.values); values[SessionConfigKey.Isolation] = iso.isolationValue; if (iso.branchProperty && iso.branchValue !== undefined) { @@ -5709,6 +5713,11 @@ export class AgentService extends Disposable implements IAgentService { && params.config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string')) { values[SessionConfigKey.WorktreeIncludeFiles] = params.config[SessionConfigKey.WorktreeIncludeFiles]; } + if (iso.worktreeSymlinkFoldersProperty + && Array.isArray(params.config?.[SessionConfigKey.WorktreeSymlinkFolders]) + && params.config[SessionConfigKey.WorktreeSymlinkFolders].every(pattern => typeof pattern === 'string')) { + values[SessionConfigKey.WorktreeSymlinkFolders] = params.config[SessionConfigKey.WorktreeSymlinkFolders]; + } return { schema: { ...result.schema, properties }, values }; } diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts index f507e16b4bbf..fb1c7af08b41 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts @@ -452,6 +452,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe ...(isolationConfig.worktreeBranchTrackProperty ? { [SessionConfigKey.WorktreeBranchTrack]: isolationConfig.worktreeBranchTrackProperty.protocol } : {}), ...(isolationConfig.worktreeCreateNewBranchProperty ? { [SessionConfigKey.WorktreeCreateNewBranch]: isolationConfig.worktreeCreateNewBranchProperty.protocol } : {}), ...(isolationConfig.worktreeIncludeFilesProperty ? { [SessionConfigKey.WorktreeIncludeFiles]: isolationConfig.worktreeIncludeFilesProperty.protocol } : {}), + ...(isolationConfig.worktreeSymlinkFoldersProperty ? { [SessionConfigKey.WorktreeSymlinkFolders]: isolationConfig.worktreeSymlinkFoldersProperty.protocol } : {}), }; this._stateManager.setSessionConfig(session.toString(), { schema: { type: 'object', properties }, diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index ee4d0cde2d8f..5a94c594019c 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -97,6 +97,8 @@ export interface IAgentHostWorktreeIsolation extends IAgentHostWorktreePendingSt const WORKTREE_META_BRANCH = 'copilot.worktree.branchName'; const WORKTREE_META_PATH = 'copilot.worktree.path'; export const WORKTREE_META_REPOSITORY_ROOT = 'copilot.worktree.repositoryRoot'; +const WORKTREE_META_SYMLINK_PATTERNS = 'copilot.worktree.symlinkPatterns'; +const WORKTREE_META_SYMLINK_SOURCE_ROOT = 'copilot.worktree.symlinkSourceRoot'; const WORKTREE_META_CREATION_FAILURE = 'copilot.worktree.creationFailure'; const DETACHED_WORKTREE_OWNER_SCHEME = 'vscode-agent-host-worktree'; const DETACHED_WORKTREE_SCOPE = 'vscode.devContainerWorktree.scope'; @@ -141,6 +143,8 @@ export interface IWorktreeMetadata { readonly branchName: string; readonly worktreePath?: URI; readonly repositoryRoot?: URI; + readonly symlinkPatterns?: readonly string[]; + readonly symlinkSourceRoot?: URI; } /** @@ -229,6 +233,8 @@ export const enum WorktreeCreationPhase { NamingBranch, /** `git worktree add` — the phase that reports file-level progress. */ CheckingOut, + /** Symlinking the git-ignored folders the client asked to share. */ + SymlinkingFolders, /** Copying the git-ignored files the client asked to carry over. */ CopyingIncludeFiles, } @@ -248,6 +254,8 @@ export function buildWorktreeProgressText(phase: WorktreeCreationPhase, percent? return percent === undefined ? localize('agentHost.worktreeCheckingOut', "Creating isolated worktree (checking out files)") : localize('agentHost.worktreeCheckingOutPercent', "Creating isolated worktree (checking out files, {0}%)", percent); + case WorktreeCreationPhase.SymlinkingFolders: + return localize('agentHost.worktreeSymlinkingFolders', "Creating isolated worktree (symlinking folders)"); case WorktreeCreationPhase.CopyingIncludeFiles: return percent === undefined ? localize('agentHost.worktreeCopyingIncludeFiles', "Creating isolated worktree (copying additional files)") @@ -358,6 +366,8 @@ export interface IIsolationConfigContribution { readonly worktreeBranchPrefixProperty: ISchemaProperty | undefined; /** Read-only carrier for the client's `git.worktreeIncludeFiles`. */ readonly worktreeIncludeFilesProperty: ISchemaProperty | undefined; + /** Read-only carrier for the client's `git.worktreeSymlinkFolders`. */ + readonly worktreeSymlinkFoldersProperty: ISchemaProperty | undefined; /** Read-only carrier for the programmatic worktree branch tracking preference. */ readonly worktreeBranchTrackProperty: ISchemaProperty | undefined; /** Read-only carrier for checking out the selected branch directly. */ @@ -759,6 +769,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI let branchValue: string | undefined; let worktreeBranchPrefixProperty: ISchemaProperty | undefined; let worktreeIncludeFilesProperty: ISchemaProperty | undefined; + let worktreeSymlinkFoldersProperty: ISchemaProperty | undefined; let worktreeBranchTrackProperty: ISchemaProperty | undefined; let worktreeCreateNewBranchProperty: ISchemaProperty | undefined; if (gitInfo) { @@ -830,9 +841,21 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI readOnly: true, sessionMutable: false, }); + + worktreeSymlinkFoldersProperty = schemaProperty({ + type: 'array', + title: localize('agentHost.sessionConfig.worktreeSymlinkFolders', "Worktree Symlink Folders"), + description: localize('agentHost.sessionConfig.worktreeSymlinkFoldersDescription', "Patterns, in .gitignore syntax, for git-ignored folders to symlink into the isolated worktree."), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.worktreeSymlinkFoldersItem', "Pattern"), + }, + readOnly: true, + sessionMutable: false, + }); } - return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeCreateNewBranchProperty, worktreeIncludeFilesProperty, isolationValue, branchDefault, branchValue }; + return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeCreateNewBranchProperty, worktreeIncludeFilesProperty, worktreeSymlinkFoldersProperty, isolationValue, branchDefault, branchValue }; } /** @@ -959,11 +982,24 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI && config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string') ? config[SessionConfigKey.WorktreeIncludeFiles] as readonly string[] : undefined; + const worktreeSymlinkFolders = Array.isArray(config[SessionConfigKey.WorktreeSymlinkFolders]) + && config[SessionConfigKey.WorktreeSymlinkFolders].every(pattern => typeof pattern === 'string') + ? config[SessionConfigKey.WorktreeSymlinkFolders] as readonly string[] + : undefined; + let createdSymlinkFolders: readonly string[] = []; + if (worktreeSymlinkFolders?.length) { + try { + onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.SymlinkingFolders)); + createdSymlinkFolders = await this._gitService.symlinkWorktreeFolders(checkoutRoot, worktreePath, worktreeSymlinkFolders, sessionId); + } catch (error) { + this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to symlink worktree folders: ${errorMessage(error)}`); + } + } if (worktreeIncludeFiles?.length) { try { onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CopyingIncludeFiles)); await withPercentProgress(WorktreeCreationPhase.CopyingIncludeFiles, onProgress, progress => - this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktreePath, worktreeIncludeFiles, sessionId, progress)); + this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktreePath, worktreeIncludeFiles, sessionId, progress, createdSymlinkFolders)); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to copy worktree include files: ${errorMessage(error)}`); } @@ -976,7 +1012,11 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); try { - await this._writeWorktreeMetadata(sessionUri, { repositoryRoot, worktreePath, baseBranch, branchName }); + await this._writeWorktreeMetadata( + sessionUri, + { repositoryRoot, worktreePath, baseBranch, branchName }, + { sourceRoot: checkoutRoot, patterns: worktreeSymlinkFolders ?? [] }, + ); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to persist worktree branch metadata: ${errorMessage(error)}`); } @@ -1018,11 +1058,16 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI let recreateFailureReason: string | undefined; if (meta?.worktreePath && meta.repositoryRoot) { - const { branchName, worktreePath, repositoryRoot } = meta; - const recreated = await this._recreateWorktree(sessionId, { branchName, worktreePath, repositoryRoot }); + const recreated = await this._recreateWorktree(sessionId, { + branchName: meta.branchName, + worktreePath: meta.worktreePath, + repositoryRoot: meta.repositoryRoot, + symlinkPatterns: meta.symlinkPatterns, + symlinkSourceRoot: meta.symlinkSourceRoot, + }); if (recreated.ok) { - this._logService.info(`[${this._logLabel}:${sessionId}] Recreated missing worktree '${worktreePath.fsPath}' for a live session on resume`); - return worktreePath; + this._logService.info(`[${this._logLabel}:${sessionId}] Recreated missing worktree '${meta.worktreePath.fsPath}' for a live session on resume`); + return meta.worktreePath; } recreateFailureReason = recreated.reason; } @@ -1157,6 +1202,8 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI WORKTREE_META_BRANCH, WORKTREE_META_PATH, WORKTREE_META_REPOSITORY_ROOT, + WORKTREE_META_SYMLINK_PATTERNS, + WORKTREE_META_SYMLINK_SOURCE_ROOT, WORKTREE_META_CREATION_FAILURE, LEGACY_WORKTREE_META_WORKING_DIRECTORY, META_DIFF_BASE_BRANCH, @@ -1284,12 +1331,17 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI // expected when the worktree was cleaned up on archive } - const { branchName, worktreePath, repositoryRoot } = meta; - await this._recreateWorktree(sessionId, { branchName, worktreePath, repositoryRoot }); + await this._recreateWorktree(sessionId, { + branchName: meta.branchName, + worktreePath: meta.worktreePath, + repositoryRoot: meta.repositoryRoot, + symlinkPatterns: meta.symlinkPatterns, + symlinkSourceRoot: meta.symlinkSourceRoot, + }); } - private async _recreateWorktree(sessionId: string, meta: { readonly branchName: string; readonly worktreePath: URI; readonly repositoryRoot: URI }): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> { - const { branchName, worktreePath, repositoryRoot } = meta; + private async _recreateWorktree(sessionId: string, meta: { readonly branchName: string; readonly worktreePath: URI; readonly repositoryRoot: URI; readonly symlinkPatterns?: readonly string[]; readonly symlinkSourceRoot?: URI }): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> { + const { branchName, worktreePath, repositoryRoot, symlinkPatterns, symlinkSourceRoot } = meta; const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); if (!branchPresent) { const reason = localize('worktreeRecreateBranchMissing', "the branch '{0}' no longer exists", branchName); @@ -1299,6 +1351,13 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI try { await fs.mkdir(URI.joinPath(worktreePath, '..').fsPath, { recursive: true }); await this._gitService.addExistingWorktree(repositoryRoot, worktreePath, branchName); + if (symlinkSourceRoot && symlinkPatterns?.length) { + try { + await this._gitService.symlinkWorktreeFolders(symlinkSourceRoot, worktreePath, symlinkPatterns, sessionId); + } catch (error) { + this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to restore worktree folder symlinks in '${worktreePath.fsPath}': ${errorMessage(error)}`); + } + } this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath, branchName }); this._logService.info(`[${this._logLabel}:${sessionId}] Recreated worktree '${worktreePath.fsPath}'`); return { ok: true }; @@ -1454,7 +1513,11 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI return { currentBranch, defaultBranch }; } - private async _writeWorktreeMetadata(sessionUri: URI, metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }): Promise { + private async _writeWorktreeMetadata( + sessionUri: URI, + metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }, + symlinkSetup?: { readonly sourceRoot: URI; readonly patterns: readonly string[] }, + ): Promise { const dbRef = this._sessionDataService.openDatabase(sessionUri); try { const work: Promise[] = [ @@ -1465,6 +1528,16 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI if (metadata.baseBranch) { work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, metadata.baseBranch)); } + if (symlinkSetup) { + if (symlinkSetup.patterns.length > 0) { + work.push( + dbRef.object.setMetadata(WORKTREE_META_SYMLINK_PATTERNS, JSON.stringify(symlinkSetup.patterns)), + dbRef.object.setMetadata(WORKTREE_META_SYMLINK_SOURCE_ROOT, symlinkSetup.sourceRoot.toString()), + ); + } else { + work.push(dbRef.object.deleteMetadata([WORKTREE_META_SYMLINK_PATTERNS, WORKTREE_META_SYMLINK_SOURCE_ROOT])); + } + } await Promise.all(work); } finally { dbRef.dispose(); @@ -1484,10 +1557,12 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } try { - const [branchName, worktreePathRaw, repositoryRootRaw, legacyWorkingDirectoryRaw] = await Promise.all([ + const [branchName, worktreePathRaw, repositoryRootRaw, symlinkPatternsRaw, symlinkSourceRootRaw, legacyWorkingDirectoryRaw] = await Promise.all([ ref.object.getMetadata(WORKTREE_META_BRANCH), ref.object.getMetadata(WORKTREE_META_PATH), ref.object.getMetadata(WORKTREE_META_REPOSITORY_ROOT), + ref.object.getMetadata(WORKTREE_META_SYMLINK_PATTERNS), + ref.object.getMetadata(WORKTREE_META_SYMLINK_SOURCE_ROOT), ref.object.getMetadata(LEGACY_WORKTREE_META_WORKING_DIRECTORY), ]); if (!branchName) { @@ -1515,7 +1590,20 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } } } - return { branchName, worktreePath, repositoryRoot }; + let symlinkPatterns: readonly string[] | undefined; + let symlinkSourceRoot: URI | undefined; + if (symlinkPatternsRaw && symlinkSourceRootRaw) { + try { + const parsedPatterns = JSON.parse(symlinkPatternsRaw); + if (Array.isArray(parsedPatterns) && parsedPatterns.every(pattern => typeof pattern === 'string')) { + symlinkPatterns = parsedPatterns; + symlinkSourceRoot = URI.parse(symlinkSourceRootRaw); + } + } catch (error) { + this._logService.warn(`[${this._logLabel}] Failed to read worktree symlink metadata for '${sessionUri.toString()}': ${errorMessage(error)}`); + } + } + return { branchName, worktreePath, repositoryRoot, symlinkPatterns, symlinkSourceRoot }; } finally { ref.dispose(); } diff --git a/src/vs/platform/agentHost/node/worktreeSymlink.ts b/src/vs/platform/agentHost/node/worktreeSymlink.ts new file mode 100644 index 000000000000..7cf73a8f4a95 --- /dev/null +++ b/src/vs/platform/agentHost/node/worktreeSymlink.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs/promises'; +import * as path from '../../../base/common/path.js'; +import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; + +export async function createWorktreeSymlink(sourceRoot: URI, worktree: URI, folder: string): Promise { + const relativeFolder = folder.split('/').join(path.sep); + const sourcePath = path.join(sourceRoot.fsPath, relativeFolder); + const targetPath = path.join(worktree.fsPath, relativeFolder); + const [sourceRealPath, worktreeRealPath] = await Promise.all([ + fs.realpath(sourcePath), + fs.realpath(worktree.fsPath), + ]); + if (extUriBiasedIgnorePathCase.isEqualOrParent(URI.file(worktreeRealPath), URI.file(sourceRealPath))) { + return false; + } + + await assertTargetParentDoesNotContainSymlink(worktree.fsPath, relativeFolder); + + try { + await fs.lstat(targetPath); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.symlink(sourcePath, targetPath, process.platform === 'win32' ? 'junction' : 'dir'); + return true; +} + +async function assertTargetParentDoesNotContainSymlink(worktreeRoot: string, folder: string): Promise { + const parentFolder = path.dirname(folder); + if (parentFolder === '.') { + return; + } + + let currentPath = worktreeRoot; + for (const pathSegment of parentFolder.split(path.sep)) { + currentPath = path.join(currentPath, pathSegment); + try { + const stat = await fs.lstat(currentPath); + if (stat.isSymbolicLink()) { + throw new Error(`Cannot create worktree symlink because target parent '${currentPath}' is a symbolic link.`); + } + if (!stat.isDirectory()) { + throw new Error(`Cannot create worktree symlink because target parent '${currentPath}' is not a directory.`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } + } +} diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index cecfeeba97cf..7d963303be81 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -599,6 +599,7 @@ export function createNoopGitService(): import('../../common/agentHostGitService getWorktreeRoots: async () => [], addWorktree: async () => { }, copyWorktreeIncludeFiles: async () => { }, + symlinkWorktreeFolders: async () => [], addExistingWorktree: async () => { }, removeWorktree: async () => { }, branchExists: async () => false, diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index de1008e59a4e..7498f2555156 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -48,6 +48,7 @@ class TestGitService implements IAgentHostGitService { async getWorktreeRoots(): Promise { return []; } async addWorktree(): Promise { } async copyWorktreeIncludeFiles(): Promise { } + async symlinkWorktreeFolders(): Promise { return []; } async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } async branchExists(): Promise { return false; } diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index 1bc4184faa81..509be9d556cd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -36,6 +36,7 @@ class TestGitService implements IAgentHostGitService { async getWorktreeRoots(): Promise { return []; } async addWorktree(): Promise { } async copyWorktreeIncludeFiles(): Promise { } + async symlinkWorktreeFolders(): Promise { return []; } async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } async branchExists(): Promise { return false; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 73b1e48b7f26..2a5d55966d49 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -1304,6 +1304,92 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { } }); + (hasGit ? test : test.skip)('symlinkWorktreeFolders symlinks only matching wholly-ignored folders', async () => { + const dir = initRepo(); + const fs = await import('fs/promises'); + + await fs.writeFile(join(dir, '.gitignore'), 'node_modules/\ncache/\n.cache/\nshared/\ntracked-cache/\npartial-cache/*\n!partial-cache/keep/\n!partial-cache/keep/data\n'); + await fs.mkdir(join(dir, 'node_modules', 'a'), { recursive: true }); + await fs.writeFile(join(dir, 'node_modules', 'a', 'index.js'), 'module'); + await fs.mkdir(join(dir, 'packages', 'a', 'cache'), { recursive: true }); + await fs.writeFile(join(dir, 'packages', 'a', 'cache', 'data'), 'cache'); + await fs.mkdir(join(dir, 'generated'), { recursive: true }); + await fs.writeFile(join(dir, 'generated', 'data'), 'generated'); + await fs.mkdir(join(dir, 'shared'), { recursive: true }); + await fs.writeFile(join(dir, 'shared', 'data'), 'shared'); + await fs.mkdir(join(dir, '.cache', 'deps'), { recursive: true }); + await fs.writeFile(join(dir, '.cache', 'deps', 'package'), 'dependency'); + await fs.writeFile(join(dir, '.cache', 'config.json'), 'config'); + await fs.mkdir(join(dir, 'tracked-cache'), { recursive: true }); + await fs.writeFile(join(dir, 'tracked-cache', 'tracked'), 'tracked'); + await fs.writeFile(join(dir, 'tracked-cache', 'ignored'), 'ignored'); + await fs.mkdir(join(dir, 'partial-cache', 'drop'), { recursive: true }); + await fs.writeFile(join(dir, 'partial-cache', 'drop', 'data'), 'drop'); + await fs.mkdir(join(dir, 'partial-cache', 'keep'), { recursive: true }); + await fs.writeFile(join(dir, 'partial-cache', 'keep', 'data'), 'keep'); + cp.execFileSync('git', ['add', '.gitignore'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['add', '-f', 'tracked-cache/tracked'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['commit', '-q', '-m', 'add ignore rules'], { cwd: dir, env, stdio: 'pipe' }); + + const wtPath = join(dir, '..', `wt-${Date.now()}`); + try { + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/symlink-folders', + track: false, + }); + const symlinkedFolders = await svc!.symlinkWorktreeFolders(URI.file(dir), URI.file(wtPath), [ + 'node_modules/**', + 'packages/**', + '.cache/deps/**', + 'generated/**', + 'shared/**', + 'tracked-cache/**', + 'partial-cache/**', + ], 'symlink-folders-session'); + await svc!.copyWorktreeIncludeFiles(URI.file(dir), URI.file(wtPath), ['shared/**', '.cache/**'], 'symlink-folders-include-session', undefined, symlinkedFolders); + + const nodeModules = join(wtPath, 'node_modules'); + const packageCache = join(wtPath, 'packages', 'a', 'cache'); + const shared = join(wtPath, 'shared'); + const nestedCache = join(wtPath, '.cache', 'deps'); + assert.deepStrictEqual({ + nodeModulesIsSymlink: (await fs.lstat(nodeModules)).isSymbolicLink(), + nodeModulesTarget: await fs.realpath(nodeModules), + packageCacheIsSymlink: (await fs.lstat(packageCache)).isSymbolicLink(), + packageCacheTarget: await fs.realpath(packageCache), + nestedCacheIsSymlink: (await fs.lstat(nestedCache)).isSymbolicLink(), + nestedCacheTarget: await fs.realpath(nestedCache), + cacheConfig: await fs.readFile(join(wtPath, '.cache', 'config.json'), 'utf8'), + sharedIsSymlinkAfterIncludeCopy: (await fs.lstat(shared)).isSymbolicLink(), + generatedExists: existsSync(join(wtPath, 'generated')), + trackedCacheIsSymlink: (await fs.lstat(join(wtPath, 'tracked-cache'))).isSymbolicLink(), + partialCacheIsSymlink: (await fs.lstat(join(wtPath, 'partial-cache'))).isSymbolicLink(), + partialCacheDropIsSymlink: (await fs.lstat(join(wtPath, 'partial-cache', 'drop'))).isSymbolicLink(), + partialCacheKeepExists: existsSync(join(wtPath, 'partial-cache', 'keep')), + }, { + nodeModulesIsSymlink: true, + nodeModulesTarget: await fs.realpath(join(dir, 'node_modules')), + packageCacheIsSymlink: true, + packageCacheTarget: await fs.realpath(join(dir, 'packages', 'a', 'cache')), + nestedCacheIsSymlink: true, + nestedCacheTarget: await fs.realpath(join(dir, '.cache', 'deps')), + cacheConfig: 'config', + sharedIsSymlinkAfterIncludeCopy: true, + generatedExists: false, + trackedCacheIsSymlink: false, + partialCacheIsSymlink: false, + partialCacheDropIsSymlink: true, + partialCacheKeepExists: false, + }); + } finally { + try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ } + await rmDirWithRetry(wtPath); + try { cp.execFileSync('git', ['branch', '-D', 'agents/symlink-folders'], { cwd: dir, env, stdio: 'ignore' }); } catch { /* best-effort cleanup */ } + } + }); + (hasGit ? test : test.skip)('copyWorktreeIncludeFiles matches patterns with .gitignore semantics', async () => { const dir = initRepo(); const logService = new TestLogService(); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 88c765acadec..6b6285194e22 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -95,6 +95,7 @@ class TestGitService implements IAgentHostGitService { async getWorktreeRoots(): Promise { return []; } async addWorktree(): Promise { } async copyWorktreeIncludeFiles(): Promise { } + async symlinkWorktreeFolders(): Promise { return []; } async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } async branchExists(_repositoryRoot: URI, branchName: string): Promise { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index d843776d3238..f9a6db1970f4 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -2113,6 +2113,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); registerTestAgentProvider(localService, agent); const includeFiles = ['.env', '.env.local', 'config/**']; + const symlinkFolders = ['node_modules/**', '.cache/**']; const initialWorktree = await localService.resolveSessionConfig({ provider: 'copilot', @@ -2122,12 +2123,12 @@ suite('AgentService (node dispatcher)', () => { const worktree = await localService.resolveSessionConfig({ provider: 'copilot', workingDirectory, - config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'feature', [SessionConfigKey.WorktreeIncludeFiles]: includeFiles }, + config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'feature', [SessionConfigKey.WorktreeIncludeFiles]: includeFiles, [SessionConfigKey.WorktreeSymlinkFolders]: symlinkFolders }, }); const folder = await localService.resolveSessionConfig({ provider: 'copilot', workingDirectory, - config: { [SessionConfigKey.Isolation]: 'folder', [SessionConfigKey.WorktreeIncludeFiles]: includeFiles }, + config: { [SessionConfigKey.Isolation]: 'folder', [SessionConfigKey.WorktreeIncludeFiles]: includeFiles, [SessionConfigKey.WorktreeSymlinkFolders]: symlinkFolders }, }); assert.deepStrictEqual({ @@ -2136,16 +2137,24 @@ suite('AgentService (node dispatcher)', () => { worktreeBranch: worktree.values[SessionConfigKey.Branch], worktreeReadOnly: worktree.schema.properties[SessionConfigKey.WorktreeIncludeFiles]?.readOnly, worktreeValue: worktree.values[SessionConfigKey.WorktreeIncludeFiles], + worktreeSymlinkReadOnly: worktree.schema.properties[SessionConfigKey.WorktreeSymlinkFolders]?.readOnly, + worktreeSymlinkValue: worktree.values[SessionConfigKey.WorktreeSymlinkFolders], folderReadOnly: folder.schema.properties[SessionConfigKey.WorktreeIncludeFiles]?.readOnly, folderValue: folder.values[SessionConfigKey.WorktreeIncludeFiles], + folderSymlinkReadOnly: folder.schema.properties[SessionConfigKey.WorktreeSymlinkFolders]?.readOnly, + folderSymlinkValue: folder.values[SessionConfigKey.WorktreeSymlinkFolders], }, { initialWorktreeBranch: 'origin/main', initialWorktreeDefault: 'origin/main', worktreeBranch: 'feature', worktreeReadOnly: true, worktreeValue: includeFiles, + worktreeSymlinkReadOnly: true, + worktreeSymlinkValue: symlinkFolders, folderReadOnly: true, folderValue: includeFiles, + folderSymlinkReadOnly: true, + folderSymlinkValue: symlinkFolders, }); }); @@ -2213,6 +2222,7 @@ suite('AgentService (node dispatcher)', () => { [SessionConfigKey.Branch]: 'feature/config', [SessionConfigKey.WorktreeBranchPrefix]: 'users/test/', [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], + [SessionConfigKey.WorktreeSymlinkFolders]: ['node_modules/**'], [SessionConfigKey.WorktreeBranchTrack]: false, [SessionConfigKey.WorktreeCreateNewBranch]: false, providerSetting: 'selected', @@ -2231,6 +2241,7 @@ suite('AgentService (node dispatcher)', () => { [SessionConfigKey.Branch]: 'feature/config', [SessionConfigKey.WorktreeBranchPrefix]: 'users/test/', [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], + [SessionConfigKey.WorktreeSymlinkFolders]: ['node_modules/**'], [SessionConfigKey.WorktreeBranchTrack]: false, [SessionConfigKey.WorktreeCreateNewBranch]: false, providerSetting: 'completion', @@ -2267,6 +2278,7 @@ suite('AgentService (node dispatcher)', () => { branch: selected.values[SessionConfigKey.Branch], branchPrefix: selected.values[SessionConfigKey.WorktreeBranchPrefix], includeFiles: selected.values[SessionConfigKey.WorktreeIncludeFiles], + symlinkFolders: selected.values[SessionConfigKey.WorktreeSymlinkFolders], branchTrack: selected.values[SessionConfigKey.WorktreeBranchTrack], createNewBranch: selected.values[SessionConfigKey.WorktreeCreateNewBranch], providerSetting: selected.values.providerSetting, @@ -2299,7 +2311,7 @@ suite('AgentService (node dispatcher)', () => { agentMergeController: { lastPromptFingerprint: 'fingerprint' }, providerSetting: 'initial', }, - selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, createNewBranch: false, providerSetting: 'selected' }, + selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], symlinkFolders: ['node_modules/**'], branchTrack: false, createNewBranch: false, providerSetting: 'selected' }, folder: { isolation: 'folder', branch: 'feature/config', providerSetting: 'folder' }, }); }); @@ -13620,6 +13632,7 @@ suite('AgentService (node dispatcher)', () => { getWorktreeRoots: async () => [], addWorktree: async () => { }, copyWorktreeIncludeFiles: async () => { }, + symlinkWorktreeFolders: async () => [], addExistingWorktree: async () => { }, removeWorktree: async () => { }, branchExists: async () => false, @@ -13731,6 +13744,7 @@ suite('AgentService (node dispatcher)', () => { getWorktreeRoots: async () => [], addWorktree: async () => { }, copyWorktreeIncludeFiles: async () => { }, + symlinkWorktreeFolders: async () => [], addExistingWorktree: async () => { }, removeWorktree: async () => { }, branchExists: async () => false, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 9a2987047b87..76b2e6a810ba 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -307,6 +307,7 @@ class TestAgentHostGitService implements IAgentHostGitService { } } async copyWorktreeIncludeFiles(): Promise { } + async symlinkWorktreeFolders(): Promise { return []; } async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { this.addedExistingWorktrees.push({ repositoryRoot, worktree, branchName }); } diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 26f621e565e1..0ce1e3b195c1 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -28,6 +28,7 @@ class TestAgentHostGitService implements IAgentHostGitService { } async addWorktree(): Promise { } async copyWorktreeIncludeFiles(): Promise { } + async symlinkWorktreeFolders(): Promise { return []; } async addExistingWorktree(): Promise { } async removeWorktree(): Promise { } async branchExists(): Promise { return false; } diff --git a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts index b8cfd0c06592..0d0a077a44dd 100644 --- a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts @@ -64,6 +64,7 @@ class TestWorktreeIsolation extends NullAgentHostWorktreeIsolation { }), worktreeBranchPrefixProperty: undefined, worktreeIncludeFilesProperty: undefined, + worktreeSymlinkFoldersProperty: undefined, worktreeBranchTrackProperty: undefined, worktreeCreateNewBranchProperty: undefined, isolationValue: 'worktree', diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 9a3c255f3878..659d611400de 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -88,8 +88,11 @@ suite('WorktreeIsolation', () => { let removeCalls: { worktree: URI; force: boolean }[]; let commitCalls: { worktree: URI; message: string }[]; let commitError: Error | undefined; - let copyIncludeCalls: { repositoryRoot: URI; worktree: URI; globs: readonly string[]; sessionId: string }[]; + let copyIncludeCalls: { repositoryRoot: URI; worktree: URI; globs: readonly string[]; sessionId: string; excludedFolders: readonly string[] }[]; let copyIncludeError: Error | undefined; + let symlinkFolderCalls: { repositoryRoot: URI; worktree: URI; patterns: readonly string[]; sessionId: string }[]; + let symlinkFolderError: Error | undefined; + let worktreeSetupCalls: string[]; let branchName: string; let hasUncommittedChanges: boolean; let branchExists: boolean; @@ -123,12 +126,21 @@ suite('WorktreeIsolation', () => { addWorktreeCalls.push(options); mkdirSync(options.path.fsPath, { recursive: true }); }, - copyWorktreeIncludeFiles: async (repositoryRoot, worktree, globs, sessionId) => { - copyIncludeCalls.push({ repositoryRoot, worktree, globs: [...globs], sessionId }); + copyWorktreeIncludeFiles: async (repositoryRoot, worktree, globs, sessionId, _onProgress, excludedFolders = []) => { + worktreeSetupCalls.push('includeFiles'); + copyIncludeCalls.push({ repositoryRoot, worktree, globs: [...globs], sessionId, excludedFolders: [...excludedFolders] }); if (copyIncludeError) { throw copyIncludeError; } }, + symlinkWorktreeFolders: async (repositoryRoot, worktree, patterns, sessionId) => { + worktreeSetupCalls.push('symlinkFolders'); + symlinkFolderCalls.push({ repositoryRoot, worktree, patterns: [...patterns], sessionId }); + if (symlinkFolderError) { + throw symlinkFolderError; + } + return patterns.map(pattern => pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern); + }, addExistingWorktree: async (_root, worktree, branch) => { addExistingCalls.push({ worktree, branchName: branch }); mkdirSync(worktree.fsPath, { recursive: true }); @@ -183,6 +195,9 @@ suite('WorktreeIsolation', () => { commitError = undefined; copyIncludeCalls = []; copyIncludeError = undefined; + symlinkFolderCalls = []; + symlinkFolderError = undefined; + worktreeSetupCalls = []; branchName = 'agents/my-feature'; hasUncommittedChanges = false; branchExists = true; @@ -223,19 +238,19 @@ suite('WorktreeIsolation', () => { const noCommits = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); assert.deepStrictEqual({ - noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty, createNewBranch: noRepo.worktreeCreateNewBranchProperty }, - repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchDynamic: repoWorktree.branchProperty?.protocol.enumDynamic, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly }, + noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, symlinkFolders: noRepo.worktreeSymlinkFoldersProperty, branchTrack: noRepo.worktreeBranchTrackProperty, createNewBranch: noRepo.worktreeCreateNewBranchProperty }, + repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchDynamic: repoWorktree.branchProperty?.protocol.enumDynamic, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, symlinkFoldersReadOnly: repoWorktree.worktreeSymlinkFoldersProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly }, repoWorktreeSelected: { branchDefault: repoWorktreeSelected.branchDefault, branchValue: repoWorktreeSelected.branchValue, branchEnum: repoWorktreeSelected.branchProperty?.protocol.enum }, - repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchDynamic: repoFolder.branchProperty?.protocol.enumDynamic, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty }, + repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchDynamic: repoFolder.branchProperty?.protocol.enumDynamic, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasSymlinkFolders: !!repoFolder.worktreeSymlinkFoldersProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty }, repoFolderSelected: { branchDefault: repoFolderSelected.branchDefault, branchValue: repoFolderSelected.branchValue }, - noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty, createNewBranch: noCommits.worktreeCreateNewBranchProperty }, + noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, symlinkFolders: noCommits.worktreeSymlinkFoldersProperty, branchTrack: noCommits.worktreeBranchTrackProperty, createNewBranch: noCommits.worktreeCreateNewBranchProperty }, }, { - noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, - repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchDynamic: true, branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true }, + noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, symlinkFolders: undefined, branchTrack: undefined, createNewBranch: undefined }, + repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchDynamic: true, branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, symlinkFoldersReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true }, repoWorktreeSelected: { branchDefault: 'main', branchValue: 'feature', branchEnum: ['main'] }, - repoFolder: { value: 'folder', branchDefault: 'feature', branchDynamic: true, branchReadOnly: false, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true, hasCreateNewBranch: true }, + repoFolder: { value: 'folder', branchDefault: 'feature', branchDynamic: true, branchReadOnly: false, hasPrefix: true, hasIncludeFiles: true, hasSymlinkFolders: true, hasBranchTrack: true, hasCreateNewBranch: true }, repoFolderSelected: { branchDefault: 'feature', branchValue: 'main' }, - noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, + noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, symlinkFolders: undefined, branchTrack: undefined, createNewBranch: undefined }, }); }); @@ -647,7 +662,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ dataIds: [...dataIds], removeCalls }, { dataIds: [], removeCalls: [] }); }); - test('resolveWorkingDirectory creates from the primary worktree while copying include files from the selected checkout', async () => { + test('resolveWorkingDirectory creates from the primary worktree while setting up files from the selected checkout', async () => { const checkoutRoot = URI.joinPath(repoRoot, 'linked-checkout'); const gitService = createGitService(); let addWorktreeRoot: URI | undefined; @@ -660,6 +675,7 @@ suite('WorktreeIsolation', () => { }; const isolation = createIsolation(disposables, { gitService }); const includeFiles = ['.env']; + const symlinkFolders = ['node_modules/**']; const worktree = await isolation.resolveWorkingDirectory({ sessionUri, @@ -669,6 +685,7 @@ suite('WorktreeIsolation', () => { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main', [SessionConfigKey.WorktreeIncludeFiles]: includeFiles, + [SessionConfigKey.WorktreeSymlinkFolders]: symlinkFolders, }, }); const meta = await isolation.readWorktreeMetadata(sessionUri); @@ -678,19 +695,64 @@ suite('WorktreeIsolation', () => { worktree: worktree?.toString(), addWorktreeRoot: addWorktreeRoot?.toString(), includeFileRoot: copyIncludeCalls[0]?.repositoryRoot.toString(), + includeExcludedFolders: copyIncludeCalls[0]?.excludedFolders, + symlinkFolderRoot: symlinkFolderCalls[0]?.repositoryRoot.toString(), metaRepositoryRoot: meta?.repositoryRoot?.toString(), + metaSymlinkSourceRoot: meta?.symlinkSourceRoot?.toString(), + metaSymlinkPatterns: meta?.symlinkPatterns, project: worktreeInfo && { uri: worktreeInfo.project.uri.toString(), displayName: worktreeInfo.project.displayName }, branchName: worktreeInfo?.branchName, }, { worktree: URI.joinPath(worktreesRoot, getWorktreeName(branchName)).toString(), addWorktreeRoot: repoRoot.toString(), includeFileRoot: checkoutRoot.toString(), + includeExcludedFolders: ['node_modules'], + symlinkFolderRoot: checkoutRoot.toString(), metaRepositoryRoot: repoRoot.toString(), + metaSymlinkSourceRoot: checkoutRoot.toString(), + metaSymlinkPatterns: symlinkFolders, project: { uri: repoRoot.toString(), displayName: basename(repoRoot) }, branchName, }); }); + test('replays persisted symlink setup after missing-worktree recovery and unarchive', async () => { + const checkoutRoot = URI.joinPath(repoRoot, 'linked-checkout'); + const gitService = createGitService(); + gitService.getRepositoryRoot = async () => checkoutRoot; + gitService.getWorktreeRoots = async () => [repoRoot, checkoutRoot]; + const isolation = createIsolation(disposables, { gitService }); + const symlinkPatterns = ['node_modules/**']; + const worktree = await isolation.resolveWorkingDirectory({ + sessionUri, + sessionId, + workingDirectory: checkoutRoot, + config: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'main', + [SessionConfigKey.WorktreeSymlinkFolders]: symlinkPatterns, + }, + }); + assert.ok(worktree); + rmSync(worktree.fsPath, { recursive: true, force: true }); + symlinkFolderCalls = []; + + const restoredIsolation = createIsolation(disposables, { gitService }); + await restoredIsolation.resolveWorkingDirectoryForResume(sessionUri, sessionId, worktree); + await restoredIsolation.cleanupWorktree(sessionUri, sessionId); + await restoredIsolation.recreateWorktreeOnUnarchive(sessionUri, sessionId); + + assert.deepStrictEqual(symlinkFolderCalls.map(call => ({ + repositoryRoot: call.repositoryRoot.toString(), + worktree: call.worktree.toString(), + patterns: call.patterns, + sessionId: call.sessionId, + })), [ + { repositoryRoot: checkoutRoot.toString(), worktree: worktree.toString(), patterns: symlinkPatterns, sessionId }, + { repositoryRoot: checkoutRoot.toString(), worktree: worktree.toString(), patterns: symlinkPatterns, sessionId }, + ]); + }); + test('resolveWorkingDirectory falls back to the selected checkout when primary worktree resolution fails', async () => { const checkoutRoot = URI.joinPath(repoRoot, 'linked-checkout'); const gitService = createGitService(); @@ -742,6 +804,7 @@ suite('WorktreeIsolation', () => { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main', [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], + [SessionConfigKey.WorktreeSymlinkFolders]: ['node_modules/**'], }, prompt: 'do a thing', onProgress: activity => activities.push(activity), @@ -753,6 +816,7 @@ suite('WorktreeIsolation', () => { 'Creating isolated worktree (checking out files)', 'Creating isolated worktree (checking out files, 12%)', 'Creating isolated worktree (checking out files, 100%)', + 'Creating isolated worktree (symlinking folders)', 'Creating isolated worktree (copying additional files)', 'Creating isolated worktree (copying additional files, 100%)', ]); @@ -885,10 +949,12 @@ suite('WorktreeIsolation', () => { }); }); - test('resolveWorkingDirectory copies configured include files and tolerates copy failures', async () => { + test('resolveWorkingDirectory sets up configured symlinks and include files in order and tolerates failures', async () => { const isolation = createIsolation(disposables); const includeFiles = ['.env', '.env.local', 'config/**']; + const symlinkFolders = ['node_modules/**', '.cache/**']; copyIncludeError = new Error('copy failed'); + symlinkFolderError = new Error('symlink failed'); const worktree = await isolation.resolveWorkingDirectory({ sessionUri, @@ -898,6 +964,7 @@ suite('WorktreeIsolation', () => { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main', [SessionConfigKey.WorktreeIncludeFiles]: includeFiles, + [SessionConfigKey.WorktreeSymlinkFolders]: symlinkFolders, }, }); @@ -908,7 +975,15 @@ suite('WorktreeIsolation', () => { worktree: call.worktree.toString(), globs: call.globs, sessionId: call.sessionId, + excludedFolders: call.excludedFolders, })), + symlinkFolderCalls: symlinkFolderCalls.map(call => ({ + repositoryRoot: call.repositoryRoot.toString(), + worktree: call.worktree.toString(), + patterns: call.patterns, + sessionId: call.sessionId, + })), + worktreeSetupCalls, resolvedWorktree: isolation.getResolvedWorktree(sessionId)?.toString(), }, { worktree: URI.joinPath(worktreesRoot, getWorktreeName(branchName)).toString(), @@ -917,7 +992,15 @@ suite('WorktreeIsolation', () => { worktree: URI.joinPath(worktreesRoot, getWorktreeName(branchName)).toString(), globs: includeFiles, sessionId, + excludedFolders: [], }], + symlinkFolderCalls: [{ + repositoryRoot: repoRoot.toString(), + worktree: URI.joinPath(worktreesRoot, getWorktreeName(branchName)).toString(), + patterns: symlinkFolders, + sessionId, + }], + worktreeSetupCalls: ['symlinkFolders', 'includeFiles'], resolvedWorktree: URI.joinPath(worktreesRoot, getWorktreeName(branchName)).toString(), }); }); @@ -1349,6 +1432,8 @@ suite('WorktreeIsolation', () => { 'copilot.worktree.branchName': true, 'copilot.worktree.path': true, 'copilot.worktree.repositoryRoot': true, + 'copilot.worktree.symlinkPatterns': true, + 'copilot.worktree.symlinkSourceRoot': true, [META_DIFF_BASE_BRANCH]: true, }), }, { @@ -1357,6 +1442,8 @@ suite('WorktreeIsolation', () => { 'copilot.worktree.branchName': undefined, 'copilot.worktree.path': undefined, 'copilot.worktree.repositoryRoot': undefined, + 'copilot.worktree.symlinkPatterns': undefined, + 'copilot.worktree.symlinkSourceRoot': undefined, [META_DIFF_BASE_BRANCH]: undefined, }, }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index b1fd367fa947..ef10b785e7de 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -4534,6 +4534,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (Array.isArray(worktreeIncludeFiles) && worktreeIncludeFiles.length > 0) { config[SessionConfigKey.WorktreeIncludeFiles] = worktreeIncludeFiles; } + + const worktreeSymlinkFolders = this._baseConfigurationService.getValue('git.worktreeSymlinkFolders', { resource }); + if (Array.isArray(worktreeSymlinkFolders) && worktreeSymlinkFolders.length > 0) { + config[SessionConfigKey.WorktreeSymlinkFolders] = worktreeSymlinkFolders; + } return config; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index 904bdfc52c7a..710cead7ad3b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -1155,6 +1155,7 @@ suite('AgentHostAutomationStore', () => { [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, [SessionConfigKey.WorktreeBranchPrefix]: 'host-prefix/', [SessionConfigKey.WorktreeIncludeFiles]: ['host.json'], + [SessionConfigKey.WorktreeSymlinkFolders]: ['node_modules/**'], [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], [SessionConfigKey.AgentMerge]: true, }, @@ -1198,6 +1199,7 @@ suite('AgentHostAutomationStore', () => { [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, [SessionConfigKey.WorktreeBranchPrefix]: 'host-prefix/', [SessionConfigKey.WorktreeIncludeFiles]: ['host.json'], + [SessionConfigKey.WorktreeSymlinkFolders]: ['node_modules/**'], [SessionConfigKey.AgentMerge]: true, }, }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 801bdf953151..4421d8137f5d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -5524,9 +5524,10 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('createNewSession forwards git.worktreeIncludeFiles as derived session config', () => { + test('createNewSession forwards Git worktree file settings as derived session config', () => { const configService = new TestConfigurationService(); configService.setUserConfiguration('git.worktreeIncludeFiles', ['product.overrides.json', '**/node_modules/**']); + configService.setUserConfiguration('git.worktreeSymlinkFolders', ['node_modules/**', '.cache/**']); const provider = createProvider(disposables, agentHost, undefined, { configurationService: configService }); const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); @@ -5534,8 +5535,8 @@ suite('LocalAgentHostSessionsProvider', () => { seededImmediately: provider.getSessionConfig(session.sessionId)?.values, forwardedToAgentHost: agentHost.resolveSessionConfigRequests.at(-1)?.config, }, { - seededImmediately: { isolation: 'worktree', worktreeIncludeFiles: ['product.overrides.json', '**/node_modules/**'] }, - forwardedToAgentHost: { isolation: 'worktree', worktreeIncludeFiles: ['product.overrides.json', '**/node_modules/**'] }, + seededImmediately: { isolation: 'worktree', worktreeIncludeFiles: ['product.overrides.json', '**/node_modules/**'], worktreeSymlinkFolders: ['node_modules/**', '.cache/**'] }, + forwardedToAgentHost: { isolation: 'worktree', worktreeIncludeFiles: ['product.overrides.json', '**/node_modules/**'], worktreeSymlinkFolders: ['node_modules/**', '.cache/**'] }, }); }); @@ -5543,6 +5544,7 @@ suite('LocalAgentHostSessionsProvider', () => { const configService = new TestConfigurationService(); configService.setUserConfiguration('git.branchPrefix', 'automation/'); configService.setUserConfiguration('git.worktreeIncludeFiles', ['product.overrides.json']); + configService.setUserConfiguration('git.worktreeSymlinkFolders', ['node_modules/**']); const provider = createProvider(disposables, agentHost, undefined, { configurationService: configService }); const session = provider.createNewSession( URI.parse('file:///home/user/project'), @@ -5566,12 +5568,14 @@ suite('LocalAgentHostSessionsProvider', () => { seededImmediately: { worktreeBranchPrefix: 'automation/', worktreeIncludeFiles: ['product.overrides.json'], + worktreeSymlinkFolders: ['node_modules/**'], mode: 'plan', autoApprove: 'assisted', }, forwardedToAgentHost: { worktreeBranchPrefix: 'automation/', worktreeIncludeFiles: ['product.overrides.json'], + worktreeSymlinkFolders: ['node_modules/**'], mode: 'plan', autoApprove: 'assisted', }, @@ -5634,6 +5638,7 @@ suite('LocalAgentHostSessionsProvider', () => { [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, [SessionConfigKey.WorktreeBranchPrefix]: 'template-prefix/', [SessionConfigKey.WorktreeIncludeFiles]: ['template.json'], + [SessionConfigKey.WorktreeSymlinkFolders]: ['node_modules/**'], [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], [SessionConfigKey.AgentMerge]: true, }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 74e32be70127..65cb5fb41c5b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -323,6 +323,7 @@ export const WELL_KNOWN_PICKER_PROPERTIES: ReadonlySet = new Set SessionConfigKey.WorktreeBranchTrack, SessionConfigKey.WorktreeCreateNewBranch, SessionConfigKey.WorktreeIncludeFiles, + SessionConfigKey.WorktreeSymlinkFolders, SessionConfigKey.ShellInitScripts, SessionConfigKey.SandboxEnabled, ClaudeSessionConfigKey.PermissionMode,