support for chat customizations in parent repo folders (#300916)

* support for parent repo folders for all chat customizations

* update
This commit is contained in:
Martin Aeschlimann
2026-03-12 02:13:34 +01:00
committed by GitHub
parent 3ce494989d
commit bb5835fb94
10 changed files with 1417 additions and 1906 deletions

View File

@@ -23,6 +23,7 @@ import { IPathService } from '../../../../workbench/services/path/common/pathSer
import { ISearchService } from '../../../../workbench/services/search/common/search.js';
import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js';
import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js';
/** URI root for built-in prompts bundled with the Sessions app. */
export const BUILTIN_PROMPTS_URI = FileAccess.asFileUri('vs/sessions/prompts');
@@ -144,6 +145,7 @@ class AgenticPromptFilesLocator extends PromptFilesLocator {
@IUserDataProfileService userDataService: IUserDataProfileService,
@ILogService logService: ILogService,
@IPathService pathService: IPathService,
@IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService,
@IAICustomizationWorkspaceService private readonly customizationWorkspaceService: IAICustomizationWorkspaceService,
) {
super(
@@ -154,7 +156,8 @@ class AgenticPromptFilesLocator extends PromptFilesLocator {
searchService,
userDataService,
logService,
pathService
pathService,
workspaceTrustManagementService
);
}

View File

@@ -995,10 +995,10 @@ configurationRegistry.registerConfiguration({
disallowConfigurationDefault: true,
tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions']
},
[PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS]: {
[PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS]: {
type: 'boolean',
title: nls.localize('chat.searchRootRepositoryCustomizations.title', "Search Root Repository Customizations",),
markdownDescription: nls.localize('chat.searchRootRepositoryCustomizations.description', "Controls whether configuration files should be searched in parent folders of the workspace folder if the parent folder are repositories.",),
title: nls.localize('chat.useCustomizationsInParentRepos.title', "Use Customizations in Parent Repositories",),
markdownDescription: nls.localize('chat.useCustomizationsInParentRepos.description', "Controls whether to use chat customization files in parent repositories.",),
default: false,
restricted: true,
disallowConfigurationDefault: true,

View File

@@ -136,9 +136,9 @@ export namespace PromptsConfig {
export const INCLUDE_REFERENCED_INSTRUCTIONS = 'chat.includeReferencedInstructions';
/**
* Search for configuration files in parent folders of the workspace folder
* Search for configuration files in parent repositories of the workspace folder
*/
export const SEARCH_ROOT_REPO_CUSTOMIZATIONS = 'chat.searchRootRepositoryCustomizations';
export const USE_CUSTOMIZATIONS_IN_PARENT_REPOS = 'chat.useCustomizationsInParentRepositories';
/**
* Get value of the `reusable prompt locations` configuration setting.

View File

@@ -138,6 +138,8 @@ export interface IPromptSourceFolder {
*/
export interface IResolvedPromptSourceFolder {
readonly uri: URI;
readonly parent: URI; // matches the URI when no glob pattern is used
readonly filePattern: string | undefined; // the part of the path with the glob pattern, or undefined if no glob pattern is used
readonly source: PromptFileSource;
readonly storage: PromptsStorage;
/**

View File

@@ -906,8 +906,8 @@ export class PromptsService extends Disposable implements IPromptsService {
const resolvedAgentFiles: IResolvedAgentFile[] = [];
const promises: Promise<IResolvedAgentFile[]>[] = [];
const includeParents = this.configurationService.getValue(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS) === true;
const rootFolders = await this.fileLocator.getWorkspaceFolderRoots(includeParents);
const includeParents = this.configurationService.getValue(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS) === true;
const rootFolders = await this.fileLocator.getWorkspaceFolderRoots(includeParents, logger);
const rootFiles: IWorkspaceInstructionFile[] = [];
const useAgentMD = this.configurationService.getValue(PromptsConfig.USE_AGENT_MD);

View File

@@ -9,7 +9,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js';
import * as nls from '../../../../../../nls.js';
import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js';
import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js';
import { basename, dirname, isEqual, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js';
import { basename, dirname, isEqual, isEqualOrParent, joinPath, extname } from '../../../../../../base/common/resources.js';
import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js';
@@ -17,15 +17,16 @@ import { PromptsType } from '../promptTypes.js';
import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js';
import { Schemas } from '../../../../../../base/common/network.js';
import { getExcludes, IFileQuery, ISearchConfiguration, ISearchService, QueryType } from '../../../../../services/search/common/search.js';
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js';
import { isCancellationError } from '../../../../../../base/common/errors.js';
import { AgentFileType, IResolvedAgentFile, PromptsStorage } from '../service/promptsService.js';
import { AgentFileType, IResolvedAgentFile, Logger, PromptsStorage } from '../service/promptsService.js';
import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { IPathService } from '../../../../../services/path/common/pathService.js';
import { equalsIgnoreCase } from '../../../../../../base/common/strings.js';
import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js';
/**
* Maximum recursion depth when traversing subdirectories for instruction files.
@@ -42,6 +43,8 @@ export interface IWorkspaceInstructionFile {
*/
export class PromptFilesLocator {
private readonly userDataFolder: IResolvedPromptSourceFolder;
constructor(
@IFileService private readonly fileService: IFileService,
@IConfigurationService private readonly configService: IConfigurationService,
@@ -51,7 +54,19 @@ export class PromptFilesLocator {
@IUserDataProfileService private readonly userDataService: IUserDataProfileService,
@ILogService private readonly logService: ILogService,
@IPathService private readonly pathService: IPathService,
@IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService,
) {
const userDataPromptsHome = this.userDataService.currentProfile.promptsHome;
this.userDataFolder = {
uri: userDataPromptsHome,
parent: userDataPromptsHome,
filePattern: undefined,
source: PromptFileSource.CopilotPersonal,
storage: PromptsStorage.user,
displayPath: nls.localize('promptsUserDataFolder', "User Data"),
isDefault: true
};
}
protected getWorkspaceFolders(): readonly IWorkspaceFolder[] {
@@ -66,26 +81,85 @@ export class PromptFilesLocator {
return Event.map(this.workspaceService.onDidChangeWorkspaceFolders, () => undefined);
}
public async getWorkspaceFolderRoots(includeParents: boolean, logger?: Logger): Promise<URI[]> {
const workspaceFolders = this.getWorkspaceFolders();
if (includeParents) {
const roots = new ResourceSet();
const userHome = await this.pathService.userHome();
for (const workspaceFolder of workspaceFolders) {
roots.add(workspaceFolder.uri);
// Walk up from the workspace folder to find the repository root
// (.git folder). Only include parent folders if a repo root is
// actually found; otherwise keep only the workspace folder.
const parents = await this.findParentRepoFolders(workspaceFolder.uri, userHome, roots, logger);
for (const parent of parents) {
roots.add(parent);
}
}
return [...roots];
}
return workspaceFolders.map(f => f.uri);
}
/**
* Walks up from {@link folderUri} collecting parent folders until a
* repository root (a folder containing `.git`) is found. Returns the
* intermediate parent folders only when a repo root is found; returns
* an empty array when the walk reaches the filesystem root, the user
* home directory, or a folder already present in {@link seen}.
*/
private async findParentRepoFolders(folderUri: URI, userHome: URI, seen: ResourceSet, logger?: Logger): Promise<URI[]> {
const candidates: URI[] = [];
let current = folderUri;
let parent = dirname(current);
do {
try {
const isRepoRoot = await this.fileService.exists(joinPath(current, '.git'));
if (isRepoRoot) {
if ((await this.workspaceTrustManagementService.getUriTrustInfo(current)).trusted) {
candidates.push(current);
return candidates;
}
logger?.logInfo(`Repository root found at ${current.toString()}, but it is not trusted. Skipping parent folder inclusion for this workspace folder.`);
return []; // if the repo root isn't trusted, don't include it or any parents
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger?.logInfo(`No repository root found for folder ${folderUri.toString()}. Error accessing ${joinPath(current, '.git')}: ${msg}.`);
return []; // if we can't access the folder, return an empty list to avoid treating it as a non-repository when we might just have a permission issue
}
candidates.push(current);
current = parent;
parent = dirname(current);
} while (!seen.has(current) && current.path !== '/' && !isEqual(userHome, current));
// no repo found
logger?.logInfo(`No repository root found for folder ${folderUri.toString()}.`);
return [];
}
/**
* List all prompt files from the filesystem.
*
* @returns List of prompt files found in the workspace.
*/
public async listFiles(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise<readonly URI[]> {
if (storage === PromptsStorage.local) {
return await this.listFilesInLocal(type, token);
} else if (storage === PromptsStorage.user) {
return await this.listFilesInUserData(type, token);
if (storage !== PromptsStorage.user && storage !== PromptsStorage.local) {
throw new Error(`Unsupported prompt file storage: ${storage}`);
}
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
const absoluteLocations = await this.toAbsoluteLocations(type, configuredLocations.filter(loc => loc.storage === storage));
if (storage === PromptsStorage.user && (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt)) {
absoluteLocations.push(this.userDataFolder);
}
throw new Error(`Unsupported prompt file storage: ${storage}`);
}
private async listFilesInUserData(type: PromptsType, token: CancellationToken): Promise<readonly URI[]> {
const userStorageFolders = await this.getUserStorageFolders(type);
const paths = new ResourceSet();
for (const { uri } of userStorageFolders) {
const files = await this.resolveFilesAtLocation(uri, type, token);
for (const { parent, filePattern } of absoluteLocations) {
const files = (filePattern === undefined)
? await this.resolveFilesAtLocation(parent, type, token) // if the location does not contain a glob pattern, resolve the location directly
: await this.searchFilesInLocation(parent, filePattern, token);
for (const file of files) {
if (getPromptFileType(file) === type) {
paths.add(file);
@@ -95,123 +169,57 @@ export class PromptFilesLocator {
return [];
}
}
return [...paths];
}
/**
* Gets all user storage folders for the given prompt type.
* This includes configured tilde paths and the VS Code user data prompts folder.
*/
private async getUserStorageFolders(type: PromptsType): Promise<readonly IResolvedPromptSourceFolder[]> {
const userHome = await this.pathService.userHome();
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, userHome);
// Filter to only user storage locations
const result = absoluteLocations.filter(loc => loc.storage === PromptsStorage.user);
// Also include the VS Code user data prompts folder for certain types
if (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt) {
const userDataPromptsHome = this.userDataService.currentProfile.promptsHome;
return [
...result,
{
uri: userDataPromptsHome,
source: PromptFileSource.CopilotPersonal,
storage: PromptsStorage.user,
displayPath: nls.localize('promptsUserDataFolder', "User Data"),
isDefault: true
}
];
}
return result;
}
/**
* Gets all source folder URIs for a prompt type (both workspace and user home).
* This is used for file watching to detect changes in all relevant locations.
*/
private getSourceFoldersSync(type: PromptsType, userHome: URI): readonly URI[] {
const result: URI[] = [];
const folders = this.getWorkspaceFolders();
const defaultFileOrFolders = getPromptFileDefaultLocations(type);
const getFolderUri = (type: PromptsType, fileOrFolderPath: URI): URI => {
// For hooks, the paths are sometimes file paths, so get the parent directory in that case
if (type === PromptsType.hook && fileOrFolderPath.path.toLowerCase().endsWith('.json')) {
return dirname(fileOrFolderPath);
}
return fileOrFolderPath;
};
for (const sourceFileOrFolder of defaultFileOrFolders) {
let fileOrFolderPath: URI;
if (sourceFileOrFolder.storage === PromptsStorage.local) {
for (const workspaceFolder of folders) {
fileOrFolderPath = joinPath(workspaceFolder.uri, sourceFileOrFolder.path);
result.push(getFolderUri(type, fileOrFolderPath));
}
} else if (sourceFileOrFolder.storage === PromptsStorage.user) {
// For tilde paths, strip the ~/ prefix before joining with userHome
const relativePath = isTildePath(sourceFileOrFolder.path) ? sourceFileOrFolder.path.substring(2) : sourceFileOrFolder.path;
fileOrFolderPath = joinPath(userHome, relativePath);
result.push(getFolderUri(type, fileOrFolderPath));
}
}
return result;
}
public createFilesUpdatedEvent(type: PromptsType): { readonly event: Event<void>; dispose: () => void } {
const disposables = new DisposableStore();
const eventEmitter = disposables.add(new Emitter<void>());
const userDataFolder = this.userDataService.currentProfile.promptsHome;
const key = getPromptFileLocationsConfigKey(type);
let parentFolders = this.getLocalParentFolders(type);
let allSourceFolders: URI[] = [];
const token = disposables.add(new CancellationTokenSource()).token; // track the disposal of the event listeners so we can cancel any in-flight async operations when the event is disposed
const externalFolderWatchers = disposables.add(new DisposableStore());
const key = getPromptFileLocationsConfigKey(type);
const userDataFolder = this.userDataService.currentProfile.promptsHome;
let parentFolders: readonly IResolvedPromptSourceFolder[] = [];
const updateExternalFolderWatchers = () => {
externalFolderWatchers.clear();
for (const folder of parentFolders) {
if (!this.getWorkspaceFolder(folder.parent)) {
// if the folder is not part of the workspace, we need to watch it
const recursive = folder.filePattern !== undefined;
const recursive = folder.filePattern !== undefined || type === PromptsType.instructions; // instructions can be in subfolders, so watch recursively
externalFolderWatchers.add(this.fileService.watch(folder.parent, { recursive, excludes: [] }));
}
}
// Watch all source folders (including user home if applicable)
for (const folder of allSourceFolders) {
if (!this.getWorkspaceFolder(folder)) {
externalFolderWatchers.add(this.fileService.watch(folder, { recursive: true, excludes: [] }));
}
}
};
// Initialize source folders (async if type has userHome locations)
this.pathService.userHome().then(userHome => {
allSourceFolders = [...this.getSourceFoldersSync(type, userHome)];
updateExternalFolderWatchers();
});
const update = async (emitEvent: boolean) => {
try {
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
parentFolders = await this.toAbsoluteLocations(type, configuredLocations, undefined);
if (token.isCancellationRequested) {
return;
}
updateExternalFolderWatchers();
if (emitEvent) {
eventEmitter.fire();
}
} catch (err) {
this.logService.error(`Error updating prompt file watchers after config change:`, err);
}
};
disposables.add(this.configService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(key)) {
parentFolders = this.getLocalParentFolders(type);
updateExternalFolderWatchers();
eventEmitter.fire();
void update(true);
}
}));
disposables.add(this.onDidChangeWorkspaceFolders()(() => {
parentFolders = this.getLocalParentFolders(type);
this.pathService.userHome().then(userHome => {
allSourceFolders = [...this.getSourceFoldersSync(type, userHome)];
updateExternalFolderWatchers();
});
eventEmitter.fire();
void update(true);
}));
disposables.add(this.workspaceTrustManagementService.onDidChangeTrustedFolders(() => {
void update(true);
}));
disposables.add(this.fileService.onDidFilesChange(e => {
if (e.affects(userDataFolder)) {
@@ -222,13 +230,11 @@ export class PromptFilesLocator {
eventEmitter.fire();
return;
}
if (allSourceFolders.some(folder => e.affects(folder))) {
eventEmitter.fire();
return;
}
}));
disposables.add(this.fileService.watch(userDataFolder));
void update(false);
return { event: eventEmitter.event, dispose: () => disposables.dispose() };
}
@@ -237,7 +243,6 @@ export class PromptFilesLocator {
* Returns folders from config, excluding user storage and Claude paths (which are read-only).
*/
public async getHookSourceFolders(): Promise<readonly URI[]> {
const userHome = await this.pathService.userHome();
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.hook);
// Ignore claude folders since they aren't first-class supported, so we don't want to create invalid formats
@@ -248,19 +253,13 @@ export class PromptFilesLocator {
// Convert to absolute URIs
const result = new ResourceSet();
const absoluteLocations = this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders, userHome);
const absoluteLocations = await this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders);
for (const location of absoluteLocations) {
// For hook configs, entries are directories unless the path ends with .json (specific file)
// Default entries have filePattern, user entries don't but are still directories
const isSpecificFile = location.uri.path.endsWith('.json');
if (isSpecificFile) {
// It's a specific file path (like .github/hooks/hooks.json), use parent directory
result.add(dirname(location.uri));
} else {
// It's a directory path (like .github/hooks or .github/books)
result.add(location.uri);
}
// location.parent points to the directory in both cases, so we can just use that
result.add(location.parent);
}
return [...result];
@@ -279,28 +278,28 @@ export class PromptFilesLocator {
* @returns List of possible unambiguous prompt file folders.
*/
public async getConfigBasedSourceFolders(type: PromptsType): Promise<readonly URI[]> {
const userHome = await this.pathService.userHome();
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, userHome).map(l => l.uri);
const absoluteLocations = await this.toAbsoluteLocations(type, configuredLocations);
// For anything that doesn't support glob patterns, we can return
if (type !== PromptsType.prompt && type !== PromptsType.instructions) {
return absoluteLocations;
return absoluteLocations.map(l => l.uri);
}
// locations in the settings can contain glob patterns so we need
// to process them to get "clean" paths; the goal here is to have
// a list of unambiguous folder paths where prompt files are stored
const result = new ResourceSet();
for (let absoluteLocation of absoluteLocations) {
const baseName = basename(absoluteLocation);
for (const absoluteLocation of absoluteLocations) {
let location = absoluteLocation.uri;
const baseName = basename(location);
// if a path ends with a well-known "any file" pattern, remove
// it so we can get the dirname path of that setting value
const filePatterns = ['*.md', `*${getPromptFileExtension(type)}`];
for (const filePattern of filePatterns) {
if (baseName === filePattern) {
absoluteLocation = dirname(absoluteLocation);
location = dirname(location);
continue;
}
}
@@ -308,16 +307,16 @@ export class PromptFilesLocator {
// likewise, if the pattern ends with single `*` (any file name)
// remove it to get the dirname path of the setting value
if (baseName === '*') {
absoluteLocation = dirname(absoluteLocation);
location = dirname(location);
}
// if after replacing the "file name" glob pattern, the path
// still contains a glob pattern, then ignore the path
if (isValidGlob(absoluteLocation.path) === true) {
if (isValidGlob(location.path) === true) {
continue;
}
result.add(absoluteLocation);
result.add(location);
}
return [...result];
@@ -335,8 +334,10 @@ export class PromptFilesLocator {
* @returns List of resolved source folders with metadata.
*/
public async getResolvedSourceFolders(type: PromptsType): Promise<readonly IResolvedPromptSourceFolder[]> {
const localFolders = await this.getLocalStorageFolders(type);
const userFolders = await this.getUserStorageFolders(type);
const absoluteLocations = await this.getLocalStorageFolders(type);
const localFolders = absoluteLocations.filter(loc => loc.storage === PromptsStorage.local);
const userFolders = absoluteLocations.filter(loc => loc.storage === PromptsStorage.user);
return this.dedupeSourceFolders([...localFolders, ...userFolders]);
}
@@ -347,8 +348,9 @@ export class PromptFilesLocator {
* for debug/diagnostic output so the displayed order is accurate.
*/
public async getSourceFoldersInDiscoveryOrder(type: PromptsType): Promise<readonly IResolvedPromptSourceFolder[]> {
const userFolders = await this.getUserStorageFolders(type);
const localFolders = await this.getLocalStorageFolders(type);
const absoluteLocations = await this.getLocalStorageFolders(type);
const userFolders = absoluteLocations.filter(loc => loc.storage === PromptsStorage.user);
const localFolders = absoluteLocations.filter(loc => loc.storage === PromptsStorage.local);
return this.dedupeSourceFolders([...userFolders, ...localFolders]);
}
@@ -357,7 +359,6 @@ export class PromptFilesLocator {
* This merges default folders with configured locations.
*/
private async getLocalStorageFolders(type: PromptsType): Promise<readonly IResolvedPromptSourceFolder[]> {
const userHome = await this.pathService.userHome();
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
const defaultFolders = getPromptFileDefaultLocations(type);
@@ -367,7 +368,11 @@ export class PromptFilesLocator {
...configuredLocations.filter(loc => !defaultFolders.some(df => df.path === loc.path))
];
return this.toAbsoluteLocations(type, allFolders, userHome, defaultFolders);
const absoluteLocations = await this.toAbsoluteLocations(type, allFolders, defaultFolders);
if (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt) {
absoluteLocations.push(this.userDataFolder);
}
return absoluteLocations;
}
/**
@@ -385,39 +390,6 @@ export class PromptFilesLocator {
return result;
}
/**
* Finds all existent prompt files in the configured local source folders.
*
* @returns List of prompt files found in the local source folders.
*/
private async listFilesInLocal(type: PromptsType, token: CancellationToken): Promise<readonly URI[]> {
// find all prompt files in the provided locations, then match
// the found file paths against (possible) glob patterns
const paths = new ResourceSet();
for (const { parent, filePattern } of this.getLocalParentFolders(type)) {
const files = (filePattern === undefined)
? await this.resolveFilesAtLocation(parent, type, token) // if the location does not contain a glob pattern, resolve the location directly
: await this.searchFilesInLocation(parent, filePattern, token);
for (const file of files) {
if (getPromptFileType(file) === type) {
paths.add(file);
}
}
if (token.isCancellationRequested) {
return [];
}
}
return [...paths];
}
private getLocalParentFolders(type: PromptsType): readonly { parent: URI; filePattern?: string }[] {
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, undefined);
return absoluteLocations.map((location) => firstNonGlobParentAndPattern(location.uri));
}
/**
* Converts locations defined in `settings` to absolute filesystem path URIs with metadata.
* This conversion is needed because locations in settings can be relative,
@@ -425,10 +397,12 @@ export class PromptFilesLocator {
* If userHome is provided, paths starting with `~` will be expanded. Otherwise these paths are ignored.
* Preserves the type and location properties from the source folder definitions.
*/
private toAbsoluteLocations(type: PromptsType, configuredLocations: readonly IPromptSourceFolder[], userHome: URI | undefined, defaultLocations?: readonly IPromptSourceFolder[]): readonly IResolvedPromptSourceFolder[] {
private async toAbsoluteLocations(type: PromptsType, configuredLocations: readonly IPromptSourceFolder[], defaultLocations?: readonly IPromptSourceFolder[]): Promise<IResolvedPromptSourceFolder[]> {
const result: IResolvedPromptSourceFolder[] = [];
const seen = new ResourceSet();
const folders = this.getWorkspaceFolders();
const userHome = await this.pathService.userHome();
const rootFolders = await this.getWorkspaceFolderRoots(this.configService.getValue(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS) === true);
// Create a set of default paths for quick lookup
const defaultPaths = new Set(defaultLocations?.map(loc => loc.path));
@@ -461,13 +435,11 @@ export class PromptFilesLocator {
try {
// Handle tilde paths when userHome is provided
if (isTildePath(configuredLocation)) {
// If userHome is not provided, we cannot resolve tilde paths so we skip this entry
if (userHome) {
const uri = joinPath(userHome, configuredLocation.substring(2));
if (!seen.has(uri)) {
seen.add(uri);
result.push({ uri, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
}
const uri = joinPath(userHome, configuredLocation.substring(2));
if (!seen.has(uri)) {
seen.add(uri);
const { parent, filePattern } = getParentFolder(type, uri);
result.push({ uri, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
}
continue;
}
@@ -482,14 +454,16 @@ export class PromptFilesLocator {
}
if (!seen.has(uri)) {
seen.add(uri);
result.push({ uri, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
const { parent, filePattern } = getParentFolder(type, uri);
result.push({ uri, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
}
} else {
for (const workspaceFolder of folders) {
const absolutePath = joinPath(workspaceFolder.uri, configuredLocation);
for (const folder of rootFolders) {
const absolutePath = joinPath(folder, configuredLocation);
if (!seen.has(absolutePath)) {
seen.add(absolutePath);
result.push({ uri: absolutePath, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
const { parent, filePattern } = getParentFolder(type, absolutePath);
result.push({ uri: absolutePath, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
}
}
}
@@ -671,23 +645,7 @@ export class PromptFilesLocator {
return result;
}
public async getWorkspaceFolderRoots(includeParents: boolean): Promise<URI[]> {
const workspaceFolders = this.getWorkspaceFolders();
if (includeParents) {
const folders: URI[] = [];
const userHome = await this.pathService.userHome();
for (const workspaceFolder of workspaceFolders) {
folders.push(workspaceFolder.uri);
let parent = dirname(workspaceFolder.uri);
while (parent.path !== '/' && !isEqual(userHome, parent) && !folders.some(f => isEqual(f, parent))) {
folders.push(parent);
parent = dirname(parent);
}
}
return folders;
}
return workspaceFolders.map(f => f.uri);
}
public async findFilesInRoots(roots: URI[], folder: string | undefined, paths: IWorkspaceInstructionFile[], token: CancellationToken, result: IResolvedAgentFile[] = []): Promise<IResolvedAgentFile[]> {
const toResolve = roots.map(root => ({ resource: folder !== undefined ? joinPath(root, folder) : root }));
@@ -761,9 +719,8 @@ export class PromptFilesLocator {
* Searches for skills in all configured locations.
*/
public async findAgentSkills(token: CancellationToken): Promise<IResolvedPromptFile[]> {
const userHome = await this.pathService.userHome();
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.skill);
const absoluteLocations = this.toAbsoluteLocations(PromptsType.skill, configuredLocations, userHome);
const absoluteLocations = await this.toAbsoluteLocations(PromptsType.skill, configuredLocations);
const allResults: IResolvedPromptFile[] = [];
for (const { uri, source, storage } of absoluteLocations) {
@@ -861,6 +818,11 @@ export function isValidGlob(pattern: string): boolean {
return false;
}
interface IParentFolderResult {
readonly parent: URI;
readonly filePattern?: string;
}
/**
* Finds the first parent of the provided location that does not contain a `glob pattern`.
*
@@ -870,13 +832,21 @@ export function isValidGlob(pattern: string): boolean {
*
* ```typescript
* assert.strictDeepEqual(
* firstNonGlobParentAndPattern(URI.file('/home/user/{folder1,folder2}/file.md')).path,
* getParentFolder(PromptsType.prompt, URI.file('/home/user/{folder1,folder2}/file.md')),
* { parent: URI.file('/home/user'), filePattern: '{folder1,folder2}/file.md' },
* 'Must find correct non-glob parent dirname.',
* );
* ```
*/
function firstNonGlobParentAndPattern(location: URI): { parent: URI; filePattern?: string } {
function getParentFolder(type: PromptsType, location: URI): IParentFolderResult {
if (type === PromptsType.hook && extname(location) === '.json') {
location = dirname(location);
}
if (type !== PromptsType.instructions && type !== PromptsType.prompt) {
// only instructions and prompts support glob patterns, so we can return the location as is
return { parent: location };
}
const segments = location.path.split('/');
let i = 0;
while (i < segments.length && isValidGlob(segments[i]) === false) {

View File

@@ -60,6 +60,7 @@ suite('ComputeAutomaticInstructions', () => {
let fileService: IFileService;
let toolsService: ILanguageModelToolsService;
let fileSystemProvider: TestInMemoryFileSystemProviderWithRealPath;
let workspaceTrustService: TestWorkspaceTrustManagementService;
setup(async () => {
instaService = disposables.add(new TestInstantiationService());
@@ -76,7 +77,7 @@ suite('ComputeAutomaticInstructions', () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true);
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true);
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS, true);
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, false);
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, [CLAUDE_RULES_SOURCE_FOLDER]: true });
testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true });
testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true });
@@ -92,6 +93,9 @@ suite('ComputeAutomaticInstructions', () => {
activateByEvent: () => Promise.resolve()
});
workspaceTrustService = disposables.add(new TestWorkspaceTrustManagementService());
instaService.stub(IWorkspaceTrustManagementService, workspaceTrustService);
fileService = disposables.add(instaService.createInstance(FileService));
instaService.stub(IFileService, fileService);
@@ -183,8 +187,6 @@ suite('ComputeAutomaticInstructions', () => {
instaService.stub(IContextKeyService, new MockContextKeyService());
instaService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService()));
instaService.stub(IAgentPluginService, {
plugins: observableValue('testPlugins', []),
enablementModel: { readEnabled: () => 2 /* EnabledProfile */, setEnabled: () => { } },
@@ -1638,6 +1640,10 @@ suite('ComputeAutomaticInstructions', () => {
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
await mockFiles(fileService, [
{
path: `${parentFolder}/.git/HEAD`,
contents: ['ref: refs/heads/main'],
},
{
path: `${parentFolder}/CLAUDE.md`,
contents: ['Parent Claude guidelines'],
@@ -1653,7 +1659,9 @@ suite('ComputeAutomaticInstructions', () => {
]);
testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true);
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, false);
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
await workspaceTrustService.setTrustedUris([URI.file(parentFolder)]);
const disabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined);
const disabledParentVariables = new ChatRequestVariableSet();
@@ -1668,7 +1676,7 @@ suite('ComputeAutomaticInstructions', () => {
assert.ok(!paths.includes(`${parentFolder}/.claude/CLAUDE.md`), 'Should not include parent .claude/CLAUDE.md when parent search is disabled');
// Parent folder settings should allow finding both root and .claude CLAUDE files above the workspace folder.
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, true);
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true);
const enabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined);
const enabledParentVariables = new ChatRequestVariableSet();
@@ -1692,6 +1700,10 @@ suite('ComputeAutomaticInstructions', () => {
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
await mockFiles(fileService, [
{
path: `${parentFolder}/.git/HEAD`,
contents: ['ref: refs/heads/main'],
},
{
path: `${parentFolder}/.github/copilot-instructions.md`,
contents: ['Parent copilot instructions'],
@@ -1708,7 +1720,9 @@ suite('ComputeAutomaticInstructions', () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true);
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true);
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, false);
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
await workspaceTrustService.setTrustedUris([URI.file(parentFolder)]);
const disabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined);
const disabledParentVariables = new ChatRequestVariableSet();
@@ -1722,7 +1736,7 @@ suite('ComputeAutomaticInstructions', () => {
assert.ok(!paths.includes(`${parentFolder}/.github/copilot-instructions.md`), 'Should not include parent copilot-instructions.md when parent search is disabled');
assert.ok(!paths.includes(`${parentFolder}/AGENTS.md`), 'Should not include parent AGENTS.md when parent search is disabled');
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, true);
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true);
const enabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined);
const enabledParentVariables = new ChatRequestVariableSet();

View File

@@ -80,7 +80,7 @@ suite('PromptsService', () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_NESTED_AGENT_MD, false);
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS, true);
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true);
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, false);
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true });
testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true });
testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true });
@@ -169,6 +169,7 @@ suite('PromptsService', () => {
instaService.stub(IContextKeyService, new MockContextKeyService());
workspaceTrustService = disposables.add(new TestWorkspaceTrustManagementService());
workspaceTrustService.getUriTrustInfo = (uri: URI) => Promise.resolve({ trusted: true, uri });
instaService.stub(IWorkspaceTrustManagementService, workspaceTrustService);
testPluginsObservable = observableValue<readonly IAgentPlugin[]>('testPlugins', []);
@@ -2049,6 +2050,156 @@ suite('PromptsService', () => {
});
});
suite('listPromptFiles - parent repo folder', () => {
test('should find prompts, instructions, and agents in a parent repo folder', async () => {
const parentFolder = '/repos/collect-prompt-parent-test';
const rootFolder = `${parentFolder}/repo`;
const rootFolderUri = URI.file(rootFolder);
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
await mockFiles(fileService, [
// .git in parent marks it as a repo root
{
path: `${parentFolder}/.git/HEAD`,
contents: ['ref: refs/heads/main'],
},
// Applying instruction in parent
{
path: `${parentFolder}/.github/instructions/typescript.instructions.md`,
contents: [
'---',
'description: \'Parent TypeScript instructions\'',
'applyTo: "**/*.ts"',
'---',
'Parent TypeScript coding standards',
]
},
// Prompt file in parent
{
path: `${parentFolder}/.github/prompts/help.prompt.md`,
contents: [
'---',
'description: \'Parent help prompt\'',
'---',
'Help the user with their question',
]
},
// Agent file in parent
{
path: `${parentFolder}/.github/agents/reviewer.agent.md`,
contents: [
'---',
'description: \'Parent code reviewer agent\'',
'---',
'You are a code reviewer',
]
},
{
path: `${rootFolder}/src/file.ts`,
contents: ['console.log("test");'],
},
]);
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true);
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
// With parent search disabled, should not find parent files
let promptFiles = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None);
let agentFiles = await service.listPromptFiles(PromptsType.agent, CancellationToken.None);
let instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
assert.ok(!promptFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent prompt files when parent search is disabled');
assert.ok(!agentFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent agent files when parent search is disabled');
assert.ok(!instructionFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent instruction files when parent search is disabled');
// With parent search enabled, should find parent files
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true);
promptFiles = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None);
agentFiles = await service.listPromptFiles(PromptsType.agent, CancellationToken.None);
instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
const promptPaths = promptFiles.map(f => f.uri.path);
const agentPaths = agentFiles.map(f => f.uri.path);
const instructionPaths = instructionFiles.map(f => f.uri.path);
assert.ok(promptPaths.includes(`${parentFolder}/.github/prompts/help.prompt.md`), 'Should find parent prompt file when parent search is enabled');
assert.ok(agentPaths.includes(`${parentFolder}/.github/agents/reviewer.agent.md`), 'Should find parent agent file when parent search is enabled');
assert.ok(instructionPaths.includes(`${parentFolder}/.github/instructions/typescript.instructions.md`), 'Should find parent instruction file when parent search is enabled');
});
test('should not find files in an untrusted parent repo folder', async () => {
const parentFolder = '/repos/untrusted-parent-test';
const rootFolder = `${parentFolder}/repo`;
const rootFolderUri = URI.file(rootFolder);
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
await mockFiles(fileService, [
// .git in parent marks it as a repo root
{
path: `${parentFolder}/.git/HEAD`,
contents: ['ref: refs/heads/main'],
},
// Applying instruction in parent
{
path: `${parentFolder}/.github/instructions/typescript.instructions.md`,
contents: [
'---',
'description: \'Parent TypeScript instructions\'',
'applyTo: "**/*.ts"',
'---',
'Parent TypeScript coding standards',
]
},
// Prompt file in parent
{
path: `${parentFolder}/.github/prompts/help.prompt.md`,
contents: [
'---',
'description: \'Parent help prompt\'',
'---',
'Help the user with their question',
]
},
// Agent file in parent
{
path: `${parentFolder}/.github/agents/reviewer.agent.md`,
contents: [
'---',
'description: \'Parent code reviewer agent\'',
'---',
'You are a code reviewer',
]
},
{
path: `${rootFolder}/src/file.ts`,
contents: ['console.log("test");'],
},
]);
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true);
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true);
// Mark the parent repo root as untrusted
workspaceTrustService.getUriTrustInfo = (uri: URI) => {
if (uri.path === parentFolder) {
return Promise.resolve({ trusted: false, uri });
}
return Promise.resolve({ trusted: true, uri });
};
const promptFiles = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None);
const agentFiles = await service.listPromptFiles(PromptsType.agent, CancellationToken.None);
const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
assert.ok(!promptFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent prompt files when parent repo is untrusted');
assert.ok(!agentFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent agent files when parent repo is untrusted');
assert.ok(!instructionFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent instruction files when parent repo is untrusted');
});
});
test('Instructions provider', async () => {
const instructionUri = URI.parse('file://extensions/my-extension/myInstruction.instructions.md');
const extension = {

View File

@@ -9,7 +9,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { Iterable } from '../../../base/common/iterator.js';
import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../base/common/map.js';
import { ResourceMap, ResourceSet } from '../../../base/common/map.js';
import { Schemas } from '../../../base/common/network.js';
import { observableValue } from '../../../base/common/observable.js';
import { join } from '../../../base/common/path.js';
@@ -380,7 +380,8 @@ export class TestWorkspaceTrustManagementService extends Disposable implements I
constructor(
private trusted: boolean = true
private trusted: boolean = true,
private trustedUris: ResourceSet = new ResourceSet()
) {
super();
}
@@ -406,11 +407,11 @@ export class TestWorkspaceTrustManagementService extends Disposable implements I
}
getUriTrustInfo(uri: URI): Promise<IWorkspaceTrustUriInfo> {
throw new Error('Method not implemented.');
return Promise.resolve({ trusted: this.trustedUris.has(uri), uri });
}
async setTrustedUris(folders: URI[]): Promise<void> {
throw new Error('Method not implemented.');
this.trustedUris = new ResourceSet(folders);
}
async setUrisTrust(uris: URI[], trusted: boolean): Promise<void> {