mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-02 05:51:37 +01:00
Add symlink setting for Git extension and Agent Host (#338136)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+8
-3
@@ -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,
|
||||
},
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user