fix: preserve out-of-scope environments during venv scoped refresh - #15
fix: preserve out-of-scope environments during venv scoped refresh#15StellaHuang95 wants to merge 1 commit into
Conversation
|
🔒 Automated review in progress — @StellaHuang95 is auto-reviewing this PR. |
Reconcile only the owning project's persisted map entry (no global loadEnvMap rebuild), serialize refresh transactions, preserve the prior envId on same-path rediscovery, and exclude configured venvFolders roots from authoritative removal. Events are computed from the scope-local transaction. Full (unscoped) refresh and every other manager remain unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| if (refreshedGlobal) { | ||
| this.globalEnv = refreshedGlobal; | ||
| } | ||
| } |
There was a problem hiding this comment.
Issue · Please address or respond
When an in-scope environment referenced by globalEnv is removed rather than rebuilt, this intentionally retains the deleted object and get(undefined) continues returning it. Clear or safely re-resolve that alias when its environment is removed.
| return ( | ||
| relative === '' || | ||
| (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) | ||
| ); |
There was a problem hiding this comment.
Issue · Please address or respond
This duplicates PythonProjectManagerImpl.isUriMatching, and the implementations already disagree for filesystem-root projects. Consolidate project ownership on the shared containment helper so scoped-refresh ownership matches the project manager.
14d2154 to
d33c589
Compare
d33c589 to
87f7b24
Compare
A URI-scoped venv refresh replaced the entire environment collection with only the scoped discovery results, so in a multi-root workspace refreshing one folder removed sibling and global environments and fired spurious removal events. The scoped branch now partitions the collection by path containment, retains out-of-scope environments untouched, replaces only the in-scope region with freshly discovered envs (deduplicated against retained paths), and emits events for in-scope changes only. Full refresh and all other managers are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
87f7b24 to
6cc58b0
Compare
| discovered: PythonEnvironment[], | ||
| ): Promise<DidChangeEnvironmentsEventArgs> { | ||
| let scopeDir: string; | ||
| try { |
There was a problem hiding this comment.
Issue · Please address or respond
findParentIfFile makes a nested file scope such as project/src/main.py merge at project/src. The existing project/.venv is therefore retained, and its rediscovered replacement is discarded by the retained-path filter. Resolve the owning project for the scope before discovery and merging, and add a nested-subdirectory regression test.
[verified]
| (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) | ||
| ); | ||
| } | ||
|
|
There was a problem hiding this comment.
Issue · Please address or respond
This duplicates PythonProjectManagerImpl.isUriMatching with conflicting filesystem-root semantics. Consolidate project ownership and scoped-refresh containment on one shared implementation so these rules cannot drift.
[verified]
| .filter((env) => !retainedIds.has(env.envId.id)) | ||
| .map((env) => ({ environment: env, kind: EnvironmentChangeKind.add })), | ||
| ]; | ||
| } |
There was a problem hiding this comment.
Issue · Please address or respond
loadEnvMap() clears the complete project map before reading each previousEnv, so refreshing workspace A can report persisted selections in unrelated workspace B as newly selected. Preserve the previous map for comparison, or reconcile only the scoped project during a scoped refresh.
[verified]
|
GitHub cannot anchor PR review comments to unchanged lines in the diff. Falling back to a general PR comment for src/managers/builtin/venvManager.ts:L408.
Overlapping scoped refreshes can mutate [verified] |
|
|
||
| async getEnvironments(scope: GetEnvironmentsScope): Promise<PythonEnvironment[]> { | ||
| await this.initialize(); | ||
|
|
There was a problem hiding this comment.
Warning · Non-blocking recommendation
Path-only partitioning treats configured global environments physically beneath the project scope, including every environment for a filesystem-root workspace, as authoritative in-scope data. Exclude configured global roots from the replaceable partition or classify discovery results by ownership/provenance.
[verified]
| } catch { | ||
| scopeDir = scope.fsPath; | ||
| } | ||
| const inScope = (env: PythonEnvironment): boolean => isPathInside(scopeDir, env.environmentPath.fsPath); |
There was a problem hiding this comment.
Issue · Please address or respond
For a nested file scope such as project/src/main.py, findParentIfFile produces project/src, so the existing project/.venv is retained and the rediscovered replacement is filtered out by path. Resolve the owning Python project URI before partitioning, and cover a file below the project root with a regression test.
|
GitHub cannot anchor PR review comments to unchanged lines in the diff. Falling back to a general PR comment for src/common/utils/pathUtils.ts:L86.
|
| .filter((env) => !retainedIds.has(env.envId.id)) | ||
| .map((env) => ({ environment: env, kind: EnvironmentChangeKind.add })), | ||
| ]; | ||
| } |
There was a problem hiding this comment.
Issue · Please address or respond
Calling the collection-wide loadEnvMap() during a scoped refresh reprocesses retained environments from unrelated projects, which can re-report their persisted selections. Preserve the existing map entries or reconcile only the refreshed project.
| ...this.collection | ||
| .filter((env) => !retainedIds.has(env.envId.id)) | ||
| .map((env) => ({ environment: env, kind: EnvironmentChangeKind.add })), | ||
| ]; |
There was a problem hiding this comment.
Issue · Please address or respond
The merge only deduplicates discovered paths against retained environments; it does not exclude newly discovered out-of-scope venvFolders results. A scoped refresh can therefore add and emit an event for an uncached global environment. Filter discovery results to the authoritative scope, or classify them by project ownership/provenance before merging.
|
|
||
| const collection: PythonEnvironment[] = (manager as any).collection; | ||
| assert.ok(collection.includes(envB) && collection.includes(envGlobal)); | ||
| assert.ok(collection.includes(envANew) && !collection.includes(envAOld)); |
There was a problem hiding this comment.
Issue · Please address or respond
These .includes() assertions can pass when an unexpected environment is also present. Assert the exact collection IDs first, then retain identity assertions for the preserved objects.
Problem
VenvManagersupports URI-scoped refreshes (refresh(scope)), triggered for example after creating an environment in a specific workspace folder (envCommandscallsm.refresh(options?.rootUri)) or viaapi.refreshEnvironments(uri). A scoped discovery is authoritative only for the target scope, but the manager treated its results as authoritative for the entire collection.Reproduction (multi-root workspace)
AandB, each containing its own.venv, plus a global env frompython.venvFolders/~/.virtualenvs.A(create/select an env inA, orapi.refreshEnvironments(Uri.file(A))).B's environment and the global env vanish from the list andonDidChangeEnvironmentsfires spuriousremoveevents for them — even though onlyAwas refreshed.Root cause
internalRefreshranfindVirtualEnvironments(..., [scope])— which searches only the scope plus configuredvenvFolders, skipping global discovery — and then replacedthis.collectionwith just those scoped results, firingremovefor every previously-known environment regardless of whether it was in the scope.Fix
The scoped branch now merges into the collection instead of replacing it (full refresh and all other managers are unchanged):
mergeScopedEnvironmentsresolves it to a directory via the existingfindParentIfFilehelper (falling back to the raw scope if the path can't be inspected).isPathInside(scope, candidate)helper (path.resolve+path.relative, inclusive of the scope, sibling-prefix safe so.../app⊄.../app-2).venvFolders) untouched — same objects, no events.venvFoldersenv that scoped discovery also returns isn't duplicated).removefor the previous in-scope envs andaddfor the newly present ones. Retained siblings/globals never surface.loadEnvMap()and the full remove-all / add-all behavior of an unscoped refresh are kept as-is.Tests
New
venvManager.scopedRefresh.unit.test.ts(hermetic, multi-root; topologyappvsapp-2, global outside root):remove/addfire),venvFoldersduplicate outside the scope from discovery is ignored (existing global object retained, not churned),appvsapp-2),pathUtils.unit.test.tsadds anisPathInsidesuite (equal/child/nested/parent/sibling-prefix/unrelated/relative + Windows drive and case-folding cases).npm run lint,npm run compile-tests, andnpm run unittest(1709 passing) are green.Fixes the scoped-refresh regression for multi-root workspaces.