Skip to content

Commit 3c072d1

Browse files
Copilotedvilme
andauthored
fix: read workspaceSearchPaths scoped per folder in multi-root workspaces (#1448)
In a multi-root workspace, `python-envs.workspaceSearchPaths` set in a folder's `.vscode/settings.json` was silently ignored. `getWorkspaceSearchPaths()` called `getConfiguration('python-envs')` without a scope URI, so `inspect().workspaceFolderValue` was always `undefined` — VS Code needs a folder URI to resolve per-folder settings. ## Changes - **`getWorkspaceSearchPaths(scope?: Uri)`** — accepts an optional folder URI and passes it to `getConfiguration`, allowing VS Code to return the correct `workspaceFolderValue` per folder. - **`getAllExtraSearchPaths()`** — replaced the single unscoped call with a per-folder loop. Each workspace folder's paths are resolved relative to *that specific folder*, not broadcast against all folders. ```typescript // Before — one unscoped call; workspaceFolderValue always undefined in multi-root const workspaceSearchPaths = getWorkspaceSearchPaths(); // relative paths then resolved against every workspace folder // After — one call per folder, scope-aware for (const folder of workspaceFolders) { const paths = getWorkspaceSearchPaths(folder.uri); // scoped ✓ workspaceSearchPathsPerFolder.push({ paths, folder: folder.uri }); } // each relative path resolved only against the folder it came from ``` - **New unit tests** — two multi-root scenarios: (1) verifies folder-specific configs are read independently via scoped calls, (2) verifies a relative path from `folderA` is not resolved against `folderB`. > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `https://api.github.com/graphql` > - Triggering command: `/usr/bin/gh gh issue list --state open --limit 5 --json number,title,labels` (http block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/microsoft/vscode-python-environments/settings/copilot/coding_agent) (admins only) > > </details> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> ## Problem In a multi-root workspace, `python-envs.workspaceSearchPaths` configured in a folder's `.vscode/settings.json` is silently ignored. This is because `getWorkspaceSearchPaths()` in `src/managers/common/nativePythonFinder.ts` calls `getConfiguration('python-envs')` **without passing a scope URI**. In VS Code's API, when no scope is provided, `inspect().workspaceFolderValue` is `undefined` in multi-root workspaces — VS Code doesn't know which folder to inspect. Fixes #1417 ## Root Cause In `src/managers/common/nativePythonFinder.ts`: 1. **`getWorkspaceSearchPaths()`** (around line 900) calls `getConfiguration('python-envs')` without a scope parameter. In a multi-root workspace, `inspect('workspaceSearchPaths').workspaceFolderValue` is always `undefined` because VS Code needs a folder URI to know which folder's settings to return. 2. **`getAllExtraSearchPaths()`** (around line 824) calls `getWorkspaceSearchPaths()` only once (unscoped), then resolves relative paths against *all* workspace folders. This is incorrect for multi-root: if folderA configures `./envs` and folderB configures `./venvs`, you don't want `./envs` resolved against folderB. ## Required Fix ### 1. `getWorkspaceSearchPaths()` — add an optional `scope` parameter Change the function signature to accept an optional `Uri` scope and pass it to `getConfiguration`: ```typescript function getWorkspaceSearchPaths(scope?: Uri): string[] { try { const envConfig = getConfiguration('python-envs', scope); // ← pass scope const inspection = envConfig.inspect<string[]>('workspaceSearchPaths'); // ... rest of function stays the same ``` ### 2. `getAllExtraSearchPaths()` — iterate over workspace folders Replace the current single unscoped call with a per-folder loop. Instead of: ```typescript const workspaceSearchPaths = getWorkspaceSearchPaths(); ``` Do: ```typescript const workspaceFolders = getWorkspaceFolders(); const workspaceSearchPathsPerFolder: { paths: string[]; folder?: Uri }[] = []; if (workspaceFolders && workspaceFolders.length > 0) { for (const folder of workspaceFolders) { const paths = getWorkspaceSearchPaths(folder.uri); workspaceSearchPathsPerFolder.push({ paths, folder: folder.uri }); } } else { // No workspace folders — fall back to unscoped workspaceSearchPathsPerFolder.push({ paths: getWorkspaceSearchPaths() }); } // Resolve relative paths against the *specific* folder they came from for (const { paths, folder } of workspaceSearchPathsPerFolder) { for (const searchPath of paths) { if (!searchPath || searchPath.trim() === '') continue; const trimmedPath = searchPath.trim(); if (isAbsolutePath(trimmedPath)) { searchDirectories.push(trimmedPath); } else if (folder) { const resolvedPath = path.resolve(folder.fsPath, trimmedPath); searchDirectories.push(resolvedPath); } else { traceWarn('No workspace folder for relative search path:', trimmedPath); } } } ``` ### 3. Update the existing unit tests The existing tests in `src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts` mock `getConfiguration` without a scope. The tests that exercise `workspaceFolderValue` will need to be updated so that the mock returns the correct `workspaceFolderValue` when called with a specific folder URI scope. Make sure all existing tests still pass, and add a new test that verifies multi-root folder-level settings are read correctly (i.e., different folders returning different `workspaceSearchPaths`). The following is the prior conversation context from the user's chat exploration (may be truncated): User: Tell me how to repro and debug this. Assistant: Let me investigate the issue and the codebase to understand how `python-envs.workspaceSearchPaths` is handled and why it might be silently ignored in multi-root workspaces. Assistant: Now I have a thorough understanding of the code. Here's the root cause and how to repro/debug: --- ## Root Cause The bug is in `getWorkspaceSearchPaths()` at [`nativePythonFinder.ts` lines 900–927](https://github.com/microsoft/vscode-python-environments/blob/d9e20315f1947bae6dc1b753a11e7fc21255970a/src/managers/common/nativePythonFinder.ts#L900-L927). The function calls `getConfiguration('python-envs')` **without passing a scope** (i.e., no workspace-folder `Uri`): ```typescript name=src/managers/common/nativePythonFinder.ts url=https://github.com/microsoft/vscode-python-environments/blob/d9e20315f1947bae6dc1b753a11e7fc21255970a/src/managers/common/nativePythonFinder.ts#L900-L927 function getWorkspaceSearchPaths(): string[] { try { const envConfig = getConfiguration('python-envs'); // ← no scope! const inspection = envConfig.inspect<string[]>('workspaceSearchPaths'); // ... if (inspection?.workspaceFolderValue) ... </details> <!-- START COPILOT CODING AGENT SUFFIX --> *This pull request was created from Copilot chat.* > --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> Co-authored-by: Eduardo Villalpando Mello <eduardo.villalpando.mello@gmail.com>
1 parent c9b94cb commit 3c072d1

2 files changed

Lines changed: 158 additions & 22 deletions

File tree

src/managers/common/nativePythonFinder.ts

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,30 +1233,38 @@ export async function getAllExtraSearchPaths(): Promise<string[]> {
12331233
const globalSearchPaths = getGlobalSearchPaths().filter((path) => path && path.trim() !== '');
12341234
searchDirectories.push(...globalSearchPaths);
12351235

1236-
// Get workspaceSearchPaths
1237-
const workspaceSearchPaths = getWorkspaceSearchPaths();
1238-
1239-
// Resolve relative paths against workspace folders
1240-
for (const searchPath of workspaceSearchPaths) {
1241-
if (!searchPath || searchPath.trim() === '') {
1242-
continue;
1236+
// Get workspaceSearchPaths — scoped per workspace folder in multi-root workspaces
1237+
const workspaceFolders = getWorkspaceFolders();
1238+
const workspaceSearchPathsPerFolder: { paths: string[]; folder?: Uri }[] = [];
1239+
1240+
if (workspaceFolders && workspaceFolders.length > 0) {
1241+
for (const folder of workspaceFolders) {
1242+
const paths = getWorkspaceSearchPaths(folder.uri);
1243+
workspaceSearchPathsPerFolder.push({ paths, folder: folder.uri });
12431244
}
1245+
} else {
1246+
// No workspace folders — fall back to unscoped call
1247+
workspaceSearchPathsPerFolder.push({ paths: getWorkspaceSearchPaths() });
1248+
}
1249+
1250+
// Resolve relative paths against the specific folder they came from
1251+
for (const { paths, folder } of workspaceSearchPathsPerFolder) {
1252+
for (const searchPath of paths) {
1253+
if (!searchPath || searchPath.trim() === '') {
1254+
continue;
1255+
}
12441256

1245-
const trimmedPath = searchPath.trim();
1257+
const trimmedPath = searchPath.trim();
12461258

1247-
if (isAbsolutePath(trimmedPath)) {
1248-
// Absolute path - use as is
1249-
searchDirectories.push(trimmedPath);
1250-
} else {
1251-
// Relative path - resolve against all workspace folders
1252-
const workspaceFolders = getWorkspaceFolders();
1253-
if (workspaceFolders) {
1254-
for (const workspaceFolder of workspaceFolders) {
1255-
const resolvedPath = path.resolve(workspaceFolder.uri.fsPath, trimmedPath);
1256-
searchDirectories.push(resolvedPath);
1257-
}
1259+
if (isAbsolutePath(trimmedPath)) {
1260+
// Absolute path - use as is
1261+
searchDirectories.push(trimmedPath);
1262+
} else if (folder) {
1263+
// Relative path - resolve against the specific folder it came from
1264+
const resolvedPath = path.resolve(folder.fsPath, trimmedPath);
1265+
searchDirectories.push(resolvedPath);
12581266
} else {
1259-
traceWarn('No workspace folders found for relative search path:', trimmedPath);
1267+
traceWarn('No workspace folder for relative search path:', trimmedPath);
12601268
}
12611269
}
12621270
}
@@ -1298,9 +1306,9 @@ export function resetWorkspaceSearchPathsGlobalWarningFlag(): void {
12981306
* Gets the most specific workspace-level setting available for workspaceSearchPaths.
12991307
* Supports glob patterns which are expanded by PET.
13001308
*/
1301-
function getWorkspaceSearchPaths(): string[] {
1309+
function getWorkspaceSearchPaths(scope?: Uri): string[] {
13021310
try {
1303-
const envConfig = getConfiguration('python-envs');
1311+
const envConfig = getConfiguration('python-envs', scope);
13041312
const inspection = envConfig.inspect<string[]>('workspaceSearchPaths');
13051313

13061314
if (inspection?.globalValue && !workspaceSearchPathsGlobalWarningShown) {

src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from 'node:assert';
2+
import * as path from 'node:path';
23
import * as sinon from 'sinon';
34
import { Uri } from 'vscode';
45
import * as logging from '../../../common/logging';
@@ -480,6 +481,133 @@ suite('getAllExtraSearchPaths Integration Tests', () => {
480481
assert.ok(mockTraceWarn.called, 'Should warn about missing workspace folders');
481482
});
482483

484+
test('Multi-root workspace - each folder reads its own workspaceSearchPaths', async () => {
485+
// Mock → Two folders with different folder-level workspaceSearchPaths
486+
const workspace1 = Uri.file('/workspace/project1');
487+
const workspace2 = Uri.file('/workspace/project2');
488+
489+
// Create separate config objects for each folder
490+
const envConfig1: MockWorkspaceConfig = {
491+
get: sinon.stub(),
492+
inspect: sinon.stub(),
493+
update: sinon.stub(),
494+
};
495+
const envConfig2: MockWorkspaceConfig = {
496+
get: sinon.stub(),
497+
inspect: sinon.stub(),
498+
update: sinon.stub(),
499+
};
500+
501+
envConfig1.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
502+
envConfig1.inspect.withArgs('workspaceSearchPaths').returns({
503+
workspaceFolderValue: ['/envs/project1'],
504+
});
505+
506+
envConfig2.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
507+
envConfig2.inspect.withArgs('workspaceSearchPaths').returns({
508+
workspaceFolderValue: ['/envs/project2'],
509+
});
510+
511+
// Return folder-specific configs based on the scope URI passed to getConfiguration
512+
mockGetConfiguration.callsFake((section: string, scope?: unknown) => {
513+
if (section === 'python') {
514+
return pythonConfig;
515+
}
516+
if (section === 'python-envs') {
517+
if (scope && (scope as Uri).fsPath === workspace1.fsPath) {
518+
return envConfig1;
519+
}
520+
if (scope && (scope as Uri).fsPath === workspace2.fsPath) {
521+
return envConfig2;
522+
}
523+
return envConfig; // fallback for unscoped calls
524+
}
525+
throw new Error(`Unexpected configuration section: ${section}`);
526+
});
527+
528+
pythonConfig.get.withArgs('venvPath').returns(undefined);
529+
pythonConfig.get.withArgs('venvFolders').returns(undefined);
530+
mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]);
531+
532+
// Run
533+
const result = await getAllExtraSearchPaths();
534+
535+
// Assert - each folder's workspaceSearchPaths is read independently
536+
assert.ok(result.includes('/envs/project1'), 'Should include project1 env path');
537+
assert.ok(result.includes('/envs/project2'), 'Should include project2 env path');
538+
assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)');
539+
});
540+
541+
test('Multi-root workspace - relative paths resolved against the correct folder', async () => {
542+
// Mock → Two folders, each with a relative workspaceSearchPaths
543+
const workspace1 = Uri.file('/workspace/project1');
544+
const workspace2 = Uri.file('/workspace/project2');
545+
546+
const envConfig1: MockWorkspaceConfig = {
547+
get: sinon.stub(),
548+
inspect: sinon.stub(),
549+
update: sinon.stub(),
550+
};
551+
const envConfig2: MockWorkspaceConfig = {
552+
get: sinon.stub(),
553+
inspect: sinon.stub(),
554+
update: sinon.stub(),
555+
};
556+
557+
envConfig1.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
558+
envConfig1.inspect.withArgs('workspaceSearchPaths').returns({
559+
workspaceFolderValue: ['envs'],
560+
});
561+
562+
envConfig2.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
563+
envConfig2.inspect.withArgs('workspaceSearchPaths').returns({
564+
workspaceFolderValue: ['venvs'],
565+
});
566+
567+
mockGetConfiguration.callsFake((section: string, scope?: unknown) => {
568+
if (section === 'python') {
569+
return pythonConfig;
570+
}
571+
if (section === 'python-envs') {
572+
if (scope && (scope as Uri).fsPath === workspace1.fsPath) {
573+
return envConfig1;
574+
}
575+
if (scope && (scope as Uri).fsPath === workspace2.fsPath) {
576+
return envConfig2;
577+
}
578+
return envConfig;
579+
}
580+
throw new Error(`Unexpected configuration section: ${section}`);
581+
});
582+
583+
pythonConfig.get.withArgs('venvPath').returns(undefined);
584+
pythonConfig.get.withArgs('venvFolders').returns(undefined);
585+
mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]);
586+
587+
// Run
588+
const result = await getAllExtraSearchPaths();
589+
590+
// Assert - relative paths resolved only against their own folder
591+
// .replace(/\\/g, '/') mirrors the normalization getAllExtraSearchPaths() applies to all
592+
// returned paths, so results always use forward slashes regardless of platform.
593+
const expected1 = path.resolve(workspace1.fsPath, 'envs').replace(/\\/g, '/');
594+
const expected2 = path.resolve(workspace2.fsPath, 'venvs').replace(/\\/g, '/');
595+
const wrong1In2 = path.resolve(workspace2.fsPath, 'envs').replace(/\\/g, '/');
596+
const wrong2In1 = path.resolve(workspace1.fsPath, 'venvs').replace(/\\/g, '/');
597+
598+
assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)');
599+
assert.ok(result.includes(expected1), 'project1/envs should come from project1 config');
600+
assert.ok(result.includes(expected2), 'project2/venvs should come from project2 config');
601+
assert.ok(
602+
!result.includes(wrong1In2),
603+
'project1 relative path should not be resolved against project2',
604+
);
605+
assert.ok(
606+
!result.includes(wrong2In1),
607+
'project2 relative path should not be resolved against project1',
608+
);
609+
});
610+
483611
test('Empty and whitespace paths are skipped', async () => {
484612
// Mock → Mix of valid and invalid paths
485613
pythonConfig.get.withArgs('venvPath').returns(undefined);

0 commit comments

Comments
 (0)