From b35b443696e5b1baf5f4117efc403be8f8cc8667 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:00:32 +0000 Subject: [PATCH 1/4] Initial plan From 7c5897c3cde6e5f73d435cf7823adedb9b840508 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:06:37 +0000 Subject: [PATCH 2/4] fix: pass scope URI to getConfiguration in getWorkspaceSearchPaths for multi-root support Agent-Logs-Url: https://github.com/microsoft/vscode-python-environments/sessions/dc6f6330-b142-4812-ad6e-de7204f2bb93 Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --- src/managers/common/nativePythonFinder.ts | 52 ++++--- ...Finder.getAllExtraSearchPaths.unit.test.ts | 128 ++++++++++++++++++ 2 files changed, 158 insertions(+), 22 deletions(-) diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index afd5946b..5e288ef2 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -832,30 +832,38 @@ export async function getAllExtraSearchPaths(): Promise { const globalSearchPaths = getGlobalSearchPaths().filter((path) => path && path.trim() !== ''); searchDirectories.push(...globalSearchPaths); - // Get workspaceSearchPaths - const workspaceSearchPaths = getWorkspaceSearchPaths(); + // Get workspaceSearchPaths — scoped per workspace folder in multi-root workspaces + 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 call + workspaceSearchPathsPerFolder.push({ paths: getWorkspaceSearchPaths() }); + } - // Resolve relative paths against workspace folders - for (const searchPath of workspaceSearchPaths) { - if (!searchPath || searchPath.trim() === '') { - continue; - } + // 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(); + const trimmedPath = searchPath.trim(); - if (isAbsolutePath(trimmedPath)) { - // Absolute path - use as is - searchDirectories.push(trimmedPath); - } else { - // Relative path - resolve against all workspace folders - const workspaceFolders = getWorkspaceFolders(); - if (workspaceFolders) { - for (const workspaceFolder of workspaceFolders) { - const resolvedPath = path.resolve(workspaceFolder.uri.fsPath, trimmedPath); - searchDirectories.push(resolvedPath); - } + if (isAbsolutePath(trimmedPath)) { + // Absolute path - use as is + searchDirectories.push(trimmedPath); + } else if (folder) { + // Relative path - resolve against the specific folder it came from + const resolvedPath = path.resolve(folder.fsPath, trimmedPath); + searchDirectories.push(resolvedPath); } else { - traceWarn('No workspace folders found for relative search path:', trimmedPath); + traceWarn('No workspace folder for relative search path:', trimmedPath); } } } @@ -897,9 +905,9 @@ export function resetWorkspaceSearchPathsGlobalWarningFlag(): void { * Gets the most specific workspace-level setting available for workspaceSearchPaths. * Supports glob patterns which are expanded by PET. */ -function getWorkspaceSearchPaths(): string[] { +function getWorkspaceSearchPaths(scope?: Uri): string[] { try { - const envConfig = getConfiguration('python-envs'); + const envConfig = getConfiguration('python-envs', scope); const inspection = envConfig.inspect('workspaceSearchPaths'); if (inspection?.globalValue && !workspaceSearchPathsGlobalWarningShown) { diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index 6159fdc2..9808671b 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -480,6 +480,134 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.ok(mockTraceWarn.called, 'Should warn about missing workspace folders'); }); + test('Multi-root workspace - each folder reads its own workspaceSearchPaths', async () => { + // Mock → Two folders with different folder-level workspaceSearchPaths + const workspace1 = Uri.file('/workspace/project1'); + const workspace2 = Uri.file('/workspace/project2'); + + // Create separate config objects for each folder + const envConfig1: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + const envConfig2: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + + envConfig1.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig1.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['/envs/project1'], + }); + + envConfig2.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig2.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['/envs/project2'], + }); + + // Return folder-specific configs based on the scope URI passed to getConfiguration + mockGetConfiguration.callsFake((section: string, scope?: unknown) => { + if (section === 'python') { + return pythonConfig; + } + if (section === 'python-envs') { + if (scope && (scope as Uri).fsPath === workspace1.fsPath) { + return envConfig1; + } + if (scope && (scope as Uri).fsPath === workspace2.fsPath) { + return envConfig2; + } + return envConfig; // fallback for unscoped calls + } + throw new Error(`Unexpected configuration section: ${section}`); + }); + + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - each folder's workspaceSearchPaths is read independently + assert.ok(result.includes('/envs/project1'), 'Should include project1 env path'); + assert.ok(result.includes('/envs/project2'), 'Should include project2 env path'); + assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)'); + }); + + test('Multi-root workspace - relative paths resolved against the correct folder', async () => { + // Mock → Two folders, each with a relative workspaceSearchPaths + const workspace1 = Uri.file('/workspace/project1'); + const workspace2 = Uri.file('/workspace/project2'); + + const envConfig1: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + const envConfig2: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + + envConfig1.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig1.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['envs'], + }); + + envConfig2.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig2.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['venvs'], + }); + + mockGetConfiguration.callsFake((section: string, scope?: unknown) => { + if (section === 'python') { + return pythonConfig; + } + if (section === 'python-envs') { + if (scope && (scope as Uri).fsPath === workspace1.fsPath) { + return envConfig1; + } + if (scope && (scope as Uri).fsPath === workspace2.fsPath) { + return envConfig2; + } + return envConfig; + } + throw new Error(`Unexpected configuration section: ${section}`); + }); + + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - relative paths resolved only against their own folder + assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)'); + assert.ok( + result.some((p) => p.includes('project1') && p.endsWith('/envs')), + 'project1/envs should come from project1 config', + ); + assert.ok( + result.some((p) => p.includes('project2') && p.endsWith('/venvs')), + 'project2/venvs should come from project2 config', + ); + // project1 relative path must NOT be resolved against project2 + assert.ok( + !result.some((p) => p.includes('project2') && p.endsWith('/envs')), + 'project1 relative path should not be resolved against project2', + ); + // project2 relative path must NOT be resolved against project1 + assert.ok( + !result.some((p) => p.includes('project1') && p.endsWith('/venvs')), + 'project2 relative path should not be resolved against project1', + ); + }); + test('Empty and whitespace paths are skipped', async () => { // Mock → Mix of valid and invalid paths pythonConfig.get.withArgs('venvPath').returns(undefined); From 50c58560f2809242b986da9b12fc1142c4d5719e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:09:14 +0000 Subject: [PATCH 3/4] fix: use cross-platform path computation in multi-root test assertions Agent-Logs-Url: https://github.com/microsoft/vscode-python-environments/sessions/dc6f6330-b142-4812-ad6e-de7204f2bb93 Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --- ...Finder.getAllExtraSearchPaths.unit.test.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index 9808671b..4198962f 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert'; +import * as path from 'node:path'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; import * as logging from '../../../common/logging'; @@ -587,23 +588,20 @@ suite('getAllExtraSearchPaths Integration Tests', () => { const result = await getAllExtraSearchPaths(); // Assert - relative paths resolved only against their own folder + const expected1 = path.resolve(workspace1.fsPath, 'envs').replace(/\\/g, '/'); + const expected2 = path.resolve(workspace2.fsPath, 'venvs').replace(/\\/g, '/'); + const wrong1In2 = path.resolve(workspace2.fsPath, 'envs').replace(/\\/g, '/'); + const wrong2In1 = path.resolve(workspace1.fsPath, 'venvs').replace(/\\/g, '/'); + assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)'); + assert.ok(result.includes(expected1), 'project1/envs should come from project1 config'); + assert.ok(result.includes(expected2), 'project2/venvs should come from project2 config'); assert.ok( - result.some((p) => p.includes('project1') && p.endsWith('/envs')), - 'project1/envs should come from project1 config', - ); - assert.ok( - result.some((p) => p.includes('project2') && p.endsWith('/venvs')), - 'project2/venvs should come from project2 config', - ); - // project1 relative path must NOT be resolved against project2 - assert.ok( - !result.some((p) => p.includes('project2') && p.endsWith('/envs')), + !result.includes(wrong1In2), 'project1 relative path should not be resolved against project2', ); - // project2 relative path must NOT be resolved against project1 assert.ok( - !result.some((p) => p.includes('project1') && p.endsWith('/venvs')), + !result.includes(wrong2In1), 'project2 relative path should not be resolved against project1', ); }); From a237618090a6830a94dad4b5b9cac20bfabe3569 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:11:17 +0000 Subject: [PATCH 4/4] test: clarify forward-slash normalization in multi-root test assertions Agent-Logs-Url: https://github.com/microsoft/vscode-python-environments/sessions/dc6f6330-b142-4812-ad6e-de7204f2bb93 Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com> --- .../nativePythonFinder.getAllExtraSearchPaths.unit.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index 4198962f..617d38d7 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -588,6 +588,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { const result = await getAllExtraSearchPaths(); // Assert - relative paths resolved only against their own folder + // .replace(/\\/g, '/') mirrors the normalization getAllExtraSearchPaths() applies to all + // returned paths, so results always use forward slashes regardless of platform. const expected1 = path.resolve(workspace1.fsPath, 'envs').replace(/\\/g, '/'); const expected2 = path.resolve(workspace2.fsPath, 'venvs').replace(/\\/g, '/'); const wrong1In2 = path.resolve(workspace2.fsPath, 'envs').replace(/\\/g, '/');