Add symlink setting for Git extension and Agent Host (#338136)

This commit is contained in:
Ladislau Szomoru
2026-09-27 16:57:08 +00:00
committed by GitHub
parent 4336606aa9
commit b5d1894c72
28 changed files with 1038 additions and 56 deletions
+12
View File
@@ -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",
+1
View File
@@ -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.",
+147 -11
View File
@@ -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<void> {
// 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<string[]> {
try {
const directories = await this._getWorktreeSymlinkFolders();
if (directories.length === 0) {
return [];
}
const startTime = performance.now();
const statuses = new Map<WorktreeSymlinkStatus, number>();
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<string[]> {
const config = workspace.getConfiguration('git', Uri.file(this.root));
const worktreeSymlinkFolders = config.get<string[]>('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<string | undefined> => {
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<void> {
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<string[]> {
private async _getWorktreeIncludePaths(worktreePath: string, excludedFolders: readonly string[]): Promise<string[]> {
const config = workspace.getConfiguration('git', Uri.file(this.root));
const worktreeIncludeFiles = config.get<string[]>('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<void> {
private async _copyWorktreeIncludeFiles(worktreePath: string, excludedFolders: readonly string[]): Promise<void> {
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<void>(15);
const limiter = new Limiter<boolean>(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));
@@ -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',
@@ -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<string[]>;
}
const symlinkWorktreeFolders = Reflect.get(Repository.prototype, '_symlinkWorktreeFolders') as (this: IWorktreeSymlinkHarness, worktreePath: string) => Promise<string[]>;
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']
});
});
});
+4 -2
View File
@@ -21,9 +21,10 @@ export function sanitizeWorktreeIncludePatterns(patterns: readonly string[]): st
* @param includedOutput `git ls-files --others --ignored --exclude-from=<patterns> -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<string>();
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);
+128
View File
@@ -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<string>();
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<WorktreeSymlinkStatus> {
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<void> {
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<string>, 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;
}
@@ -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<void>;
copyWorktreeIncludeFiles(repositoryRoot: URI, worktree: URI, patterns: readonly string[], sessionId: string, onProgress?: (progress: IWorktreeFileProgress) => void, excludedFolders?: readonly string[]): Promise<void>;
/**
* 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<readonly string[]>;
/**
* Adds a worktree for an existing branch (no `-b`). Used when restoring
* a worktree whose branch was preserved (e.g. unarchiving a session
@@ -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,
@@ -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<void> {
async copyWorktreeIncludeFiles(repositoryRoot: URI, worktree: URI, patterns: readonly string[], sessionId: string, onProgress?: (progress: IWorktreeFileProgress) => void, excludedFolders: readonly string[] = []): Promise<void> {
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<void>(15);
const limiter = new Limiter<boolean>(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<readonly string[]> {
try {
const folders = await this._getWorktreeSymlinkFolders(repositoryRoot, patterns, sessionId);
if (folders.length === 0) {
return [];
}
const startTime = performance.now();
const limiter = new Limiter<boolean>(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<void> {
// `-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<IWorktreeIncludeEntry[]> {
private async _getWorktreeIncludePaths(repositoryRoot: URI, worktreeRoot: URI, patterns: readonly string[], sessionId: string, excludedFolders: readonly string[]): Promise<IWorktreeIncludeEntry[]> {
// 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<string[]> {
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<string, string>; readonly maxBuffer?: number; readonly onStderr?: (chunk: string) => void }): Promise<string | undefined> {
private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean; readonly env?: Record<string, string>; readonly maxBuffer?: number; readonly onStderr?: (chunk: string) => void; readonly input?: string }): Promise<string | undefined> {
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<string>();
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<string>, 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);
@@ -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 };
}
@@ -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 },
@@ -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<string> | undefined;
/** Read-only carrier for the client's `git.worktreeIncludeFiles`. */
readonly worktreeIncludeFilesProperty: ISchemaProperty<readonly string[]> | undefined;
/** Read-only carrier for the client's `git.worktreeSymlinkFolders`. */
readonly worktreeSymlinkFoldersProperty: ISchemaProperty<readonly string[]> | undefined;
/** Read-only carrier for the programmatic worktree branch tracking preference. */
readonly worktreeBranchTrackProperty: ISchemaProperty<boolean> | 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<string> | undefined;
let worktreeIncludeFilesProperty: ISchemaProperty<readonly string[]> | undefined;
let worktreeSymlinkFoldersProperty: ISchemaProperty<readonly string[]> | undefined;
let worktreeBranchTrackProperty: ISchemaProperty<boolean> | undefined;
let worktreeCreateNewBranchProperty: ISchemaProperty<boolean> | undefined;
if (gitInfo) {
@@ -830,9 +841,21 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI
readOnly: true,
sessionMutable: false,
});
worktreeSymlinkFoldersProperty = schemaProperty<readonly string[]>({
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<void> {
private async _writeWorktreeMetadata(
sessionUri: URI,
metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI },
symlinkSetup?: { readonly sourceRoot: URI; readonly patterns: readonly string[] },
): Promise<void> {
const dbRef = this._sessionDataService.openDatabase(sessionUri);
try {
const work: Promise<void>[] = [
@@ -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();
}
@@ -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<boolean> {
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<void> {
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;
}
}
}
@@ -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,
@@ -48,6 +48,7 @@ class TestGitService implements IAgentHostGitService {
async getWorktreeRoots(): Promise<URI[]> { return []; }
async addWorktree(): Promise<void> { }
async copyWorktreeIncludeFiles(): Promise<void> { }
async symlinkWorktreeFolders(): Promise<readonly string[]> { return []; }
async addExistingWorktree(): Promise<void> { }
async removeWorktree(): Promise<void> { }
async branchExists(): Promise<boolean> { return false; }
@@ -36,6 +36,7 @@ class TestGitService implements IAgentHostGitService {
async getWorktreeRoots(): Promise<URI[]> { return []; }
async addWorktree(): Promise<void> { }
async copyWorktreeIncludeFiles(): Promise<void> { }
async symlinkWorktreeFolders(): Promise<readonly string[]> { return []; }
async addExistingWorktree(): Promise<void> { }
async removeWorktree(): Promise<void> { }
async branchExists(): Promise<boolean> { return false; }
@@ -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();
@@ -95,6 +95,7 @@ class TestGitService implements IAgentHostGitService {
async getWorktreeRoots(): Promise<URI[]> { return []; }
async addWorktree(): Promise<void> { }
async copyWorktreeIncludeFiles(): Promise<void> { }
async symlinkWorktreeFolders(): Promise<readonly string[]> { return []; }
async addExistingWorktree(): Promise<void> { }
async removeWorktree(): Promise<void> { }
async branchExists(_repositoryRoot: URI, branchName: string): Promise<boolean> {
@@ -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,
@@ -307,6 +307,7 @@ class TestAgentHostGitService implements IAgentHostGitService {
}
}
async copyWorktreeIncludeFiles(): Promise<void> { }
async symlinkWorktreeFolders(): Promise<readonly string[]> { return []; }
async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise<void> {
this.addedExistingWorktrees.push({ repositoryRoot, worktree, branchName });
}
@@ -28,6 +28,7 @@ class TestAgentHostGitService implements IAgentHostGitService {
}
async addWorktree(): Promise<void> { }
async copyWorktreeIncludeFiles(): Promise<void> { }
async symlinkWorktreeFolders(): Promise<readonly string[]> { return []; }
async addExistingWorktree(): Promise<void> { }
async removeWorktree(): Promise<void> { }
async branchExists(): Promise<boolean> { return false; }
@@ -64,6 +64,7 @@ class TestWorktreeIsolation extends NullAgentHostWorktreeIsolation {
}),
worktreeBranchPrefixProperty: undefined,
worktreeIncludeFilesProperty: undefined,
worktreeSymlinkFoldersProperty: undefined,
worktreeBranchTrackProperty: undefined,
worktreeCreateNewBranchProperty: undefined,
isolationValue: 'worktree',
@@ -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,
},
});
@@ -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<string[]>('git.worktreeSymlinkFolders', { resource });
if (Array.isArray(worktreeSymlinkFolders) && worktreeSymlinkFolders.length > 0) {
config[SessionConfigKey.WorktreeSymlinkFolders] = worktreeSymlinkFolders;
}
return config;
}
@@ -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,
},
});
@@ -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,
},
@@ -323,6 +323,7 @@ export const WELL_KNOWN_PICKER_PROPERTIES: ReadonlySet<string> = new Set<string>
SessionConfigKey.WorktreeBranchTrack,
SessionConfigKey.WorktreeCreateNewBranch,
SessionConfigKey.WorktreeIncludeFiles,
SessionConfigKey.WorktreeSymlinkFolders,
SessionConfigKey.ShellInitScripts,
SessionConfigKey.SandboxEnabled,
ClaudeSessionConfigKey.PermissionMode,