Skip to content

Commit 9ee9e22

Browse files
Preserve shell-startup activation when environment files change (#1903)
Creating a project `.env` file could clear the terminal’s scoped variable collection, removing shell-startup activation even when environment-file injection was disabled. - **Event handling:** Refresh environment-file variables on create, edit, and delete events instead of clearing the entire workspace scope. - **File selection:** Continue using the project `.env` when a configured environment file is removed, preserving variables from files that remain. - **Regression coverage:** Cover injection enabled and disabled, variable updates and removal, and deletion when another environment file remains. - **Concurrency:** Ignore stale asynchronous refreshes when a newer environment-file event has already completed. Tests now synchronize directly with refresh completion and cover out-of-order results.\n\n<!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #1863 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent fe8ecda commit 9ee9e22

2 files changed

Lines changed: 176 additions & 32 deletions

File tree

‎src/features/terminal/terminalEnvVarInjector.ts‎

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class TerminalEnvVarInjector implements Disposable {
2929
private disposables: Disposable[] = [];
3030
// Track which .env variables we've set for each workspace to avoid clearing shell activation variables
3131
private envVarKeys: Map<string, Set<string>> = new Map();
32+
private refreshGenerations: Map<string, number> = new Map();
3233
private envFileNotificationShown = false;
3334

3435
constructor(
@@ -49,19 +50,17 @@ export class TerminalEnvVarInjector implements Disposable {
4950
this.envVarManager.onDidChangeEnvironmentVariables((args) => {
5051
if (!args.uri) {
5152
// No specific URI, reload all workspaces
52-
this.updateEnvironmentVariables().catch((error) => {
53+
return this.updateEnvironmentVariables().catch((error) => {
5354
traceError('Failed to update environment variables:', error);
5455
});
55-
return;
5656
}
5757

5858
const affectedWorkspace = getWorkspaceFolder(args.uri);
5959
if (!affectedWorkspace) {
6060
// No workspace folder found for this URI, reloading all workspaces
61-
this.updateEnvironmentVariables().catch((error) => {
61+
return this.updateEnvironmentVariables().catch((error) => {
6262
traceError('Failed to update environment variables:', error);
6363
});
64-
return;
6564
}
6665

6766
// Check if env file injection is enabled when variables change
@@ -76,14 +75,9 @@ export class TerminalEnvVarInjector implements Disposable {
7675
});
7776
}
7877

79-
if (args.changeType === 2) {
80-
// FileChangeType.Deleted
81-
this.clearWorkspaceVariables(affectedWorkspace);
82-
} else {
83-
this.updateEnvironmentVariables(affectedWorkspace).catch((error) => {
84-
traceError('Failed to update environment variables:', error);
85-
});
86-
}
78+
return this.updateEnvironmentVariables(affectedWorkspace).catch((error) => {
79+
traceError('Failed to update environment variables:', error);
80+
});
8781
}),
8882
);
8983

@@ -110,12 +104,17 @@ export class TerminalEnvVarInjector implements Disposable {
110104
*/
111105
private async updateEnvironmentVariables(workspaceFolder?: WorkspaceFolder): Promise<void> {
112106
try {
107+
let workspaceUpdates: { folder: WorkspaceFolder; generation: number }[];
113108
if (workspaceFolder) {
114-
// Update only the specified workspace
109+
workspaceUpdates = [
110+
{
111+
folder: workspaceFolder,
112+
generation: this.beginWorkspaceRefresh(workspaceFolder),
113+
},
114+
];
115115
traceVerbose(
116116
`TerminalEnvVarInjector: Updating environment variables for workspace: ${workspaceFolder.uri.fsPath}`,
117117
);
118-
await this.injectEnvironmentVariablesForWorkspace(workspaceFolder);
119118
} else {
120119
// No provided workspace - update all workspaces
121120

@@ -126,9 +125,14 @@ export class TerminalEnvVarInjector implements Disposable {
126125
}
127126

128127
traceVerbose('TerminalEnvVarInjector: Updating environment variables for all workspaces');
129-
for (const folder of workspaceFolders) {
130-
await this.injectEnvironmentVariablesForWorkspace(folder);
131-
}
128+
workspaceUpdates = workspaceFolders.map((folder) => ({
129+
folder,
130+
generation: this.beginWorkspaceRefresh(folder),
131+
}));
132+
}
133+
134+
for (const { folder, generation } of workspaceUpdates) {
135+
await this.injectEnvironmentVariablesForWorkspace(folder, generation);
132136
}
133137

134138
traceVerbose('TerminalEnvVarInjector: Environment variable injection completed');
@@ -140,7 +144,10 @@ export class TerminalEnvVarInjector implements Disposable {
140144
/**
141145
* Inject environment variables for a specific workspace.
142146
*/
143-
private async injectEnvironmentVariablesForWorkspace(workspaceFolder: WorkspaceFolder): Promise<void> {
147+
private async injectEnvironmentVariablesForWorkspace(
148+
workspaceFolder: WorkspaceFolder,
149+
generation: number,
150+
): Promise<void> {
144151
const workspaceUri = workspaceFolder.uri;
145152
const workspaceKey = workspaceUri.fsPath;
146153

@@ -159,6 +166,9 @@ export class TerminalEnvVarInjector implements Disposable {
159166
traceVerbose(
160167
`TerminalEnvVarInjector: Env file injection disabled for workspace: ${workspaceUri.fsPath}`,
161168
);
169+
if (!this.isCurrentWorkspaceRefresh(workspaceFolder, generation)) {
170+
return;
171+
}
162172
// Clear only the .env variables we previously set, not shell activation variables
163173
this.clearTrackedEnvVariables(envVarScope, workspaceKey);
164174
return;
@@ -170,8 +180,16 @@ export class TerminalEnvVarInjector implements Disposable {
170180
: undefined;
171181
const defaultEnvFilePath: string = path.join(workspaceUri.fsPath, '.env');
172182

173-
let activeEnvFilePath: string = resolvedEnvFilePath || defaultEnvFilePath;
174-
if (activeEnvFilePath && (await fse.pathExists(activeEnvFilePath))) {
183+
const activeEnvFilePath =
184+
resolvedEnvFilePath && (await fse.pathExists(resolvedEnvFilePath))
185+
? resolvedEnvFilePath
186+
: (await fse.pathExists(defaultEnvFilePath))
187+
? defaultEnvFilePath
188+
: undefined;
189+
if (!this.isCurrentWorkspaceRefresh(workspaceFolder, generation)) {
190+
return;
191+
}
192+
if (activeEnvFilePath) {
175193
traceVerbose(`TerminalEnvVarInjector: Using env file: ${activeEnvFilePath}`);
176194
} else {
177195
traceVerbose(
@@ -214,6 +232,17 @@ export class TerminalEnvVarInjector implements Disposable {
214232
}
215233
}
216234

235+
private beginWorkspaceRefresh(workspaceFolder: WorkspaceFolder): number {
236+
const workspaceKey = workspaceFolder.uri.fsPath;
237+
const generation = (this.refreshGenerations.get(workspaceKey) ?? 0) + 1;
238+
this.refreshGenerations.set(workspaceKey, generation);
239+
return generation;
240+
}
241+
242+
private isCurrentWorkspaceRefresh(workspaceFolder: WorkspaceFolder, generation: number): boolean {
243+
return this.refreshGenerations.get(workspaceFolder.uri.fsPath) === generation;
244+
}
245+
217246
/**
218247
* Offer to enable env file injection for the affected workspace folder or suppress future reminders.
219248
*/
@@ -265,18 +294,6 @@ export class TerminalEnvVarInjector implements Disposable {
265294
return envVarCollection.getScoped(scope);
266295
}
267296

268-
/**
269-
* Clear all environment variables for a workspace.
270-
*/
271-
private clearWorkspaceVariables(workspaceFolder: WorkspaceFolder): void {
272-
try {
273-
const scope = this.getEnvironmentVariableCollectionScoped({ workspaceFolder });
274-
scope.clear();
275-
} catch (error) {
276-
traceError(`Failed to clear environment variables for workspace ${workspaceFolder.uri.fsPath}:`, error);
277-
}
278-
}
279-
280297
/**
281298
* Clear only the .env variables we've tracked, not shell activation variables.
282299
*/

‎src/test/features/terminalEnvVarInjector.unit.test.ts‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
import * as assert from 'assert';
5+
import fse from 'fs-extra';
56
import * as path from 'path';
67
import * as sinon from 'sinon';
78
import * as typeMoq from 'typemoq';
@@ -18,6 +19,7 @@ import {
1819
import { ActivationStrings, Common } from '../../common/localize';
1920
import * as logging from '../../common/logging';
2021
import * as persistentState from '../../common/persistentState';
22+
import { createDeferred } from '../../common/utils/deferred';
2123
import * as windowApis from '../../common/window.apis';
2224
import * as workspaceApis from '../../common/workspace.apis';
2325
import { EnvVarManager } from '../../features/execution/envVariableManager';
@@ -151,6 +153,131 @@ suite('TerminalEnvVarInjector', () => {
151153
assert.strictEqual(mockScopedCollection.replace.called, false);
152154
});
153155

156+
suite('env file changes', () => {
157+
let envChangeCallback: ((args: { uri?: Uri; changeType: number }) => Promise<void>) | undefined;
158+
let variables: Record<string, string>;
159+
let variableReads: Promise<Record<string, string>>[];
160+
let existingFiles: Set<string>;
161+
const defaultEnvFile = path.join(testWorkspaceFolder.uri.fsPath, '.env');
162+
const configuredEnvFile = path.join(testWorkspaceFolder.uri.fsPath, 'configured.env');
163+
164+
setup(() => {
165+
variables = {};
166+
variableReads = [];
167+
existingFiles = new Set();
168+
workspaceFoldersValue = undefined;
169+
sinon
170+
.stub(fse, 'pathExists')
171+
.callsFake(async (filePath) => existingFiles.has(path.resolve(filePath.toString())));
172+
sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(testWorkspaceFolder);
173+
envVarManager.reset();
174+
envVarManager.setup((m) => m.onDidChangeEnvironmentVariables).returns(
175+
() => (listener) => {
176+
envChangeCallback = listener;
177+
return new Disposable(() => {});
178+
},
179+
);
180+
envVarManager
181+
.setup((m) => m.getEnvironmentVariables(typeMoq.It.isAny()))
182+
.returns(() => variableReads.shift() ?? Promise.resolve({ ...variables }));
183+
});
184+
185+
async function fireChange(changeType: number, filePath = defaultEnvFile): Promise<void> {
186+
assert.ok(envChangeCallback);
187+
await envChangeCallback({ uri: Uri.file(filePath), changeType });
188+
}
189+
190+
test('creating an env file with injection disabled preserves shell activation variables', async () => {
191+
getConfigurationStub.returns(createMockConfig({ useEnvFile: false }) as WorkspaceConfiguration);
192+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
193+
await fireChange(2);
194+
195+
sinon.assert.notCalled(mockScopedCollection.clear);
196+
sinon.assert.notCalled(mockScopedCollection.delete);
197+
sinon.assert.notCalled(mockScopedCollection.replace);
198+
});
199+
200+
test('creating, editing, and deleting an env file updates only its injected variables', async () => {
201+
getConfigurationStub.returns(createMockConfig({ useEnvFile: true }) as WorkspaceConfiguration);
202+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
203+
204+
existingFiles.add(defaultEnvFile);
205+
variables = { TERMINAL_PROBE_VALUE: 'created' };
206+
await fireChange(2);
207+
sinon.assert.calledWith(mockScopedCollection.replace, 'TERMINAL_PROBE_VALUE', 'created');
208+
209+
variables = { OTHER_VALUE: 'edited' };
210+
await fireChange(1);
211+
sinon.assert.calledWith(mockScopedCollection.delete, 'TERMINAL_PROBE_VALUE');
212+
sinon.assert.calledWith(mockScopedCollection.replace, 'OTHER_VALUE', 'edited');
213+
214+
existingFiles.delete(defaultEnvFile);
215+
variables = {};
216+
await fireChange(3);
217+
sinon.assert.calledWith(mockScopedCollection.delete, 'OTHER_VALUE');
218+
sinon.assert.notCalled(mockScopedCollection.clear);
219+
});
220+
221+
test('deleting one env file retains variables from the other configured file', async () => {
222+
getConfigurationStub.returns(
223+
createMockConfig({ useEnvFile: true, envFilePath: configuredEnvFile }) as WorkspaceConfiguration,
224+
);
225+
existingFiles.add(defaultEnvFile);
226+
existingFiles.add(configuredEnvFile);
227+
variables = { CONFIGURED_VALUE: 'configured', PROJECT_VALUE: 'project' };
228+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
229+
await fireChange(2, configuredEnvFile);
230+
231+
existingFiles.delete(configuredEnvFile);
232+
variables = { PROJECT_VALUE: 'project' };
233+
await fireChange(3, configuredEnvFile);
234+
235+
sinon.assert.calledWith(mockScopedCollection.delete, 'CONFIGURED_VALUE');
236+
sinon.assert.neverCalledWith(mockScopedCollection.delete, 'PROJECT_VALUE');
237+
sinon.assert.notCalled(mockScopedCollection.clear);
238+
});
239+
240+
test('deleting the project env file retains variables from the configured file', async () => {
241+
getConfigurationStub.returns(
242+
createMockConfig({ useEnvFile: true, envFilePath: configuredEnvFile }) as WorkspaceConfiguration,
243+
);
244+
existingFiles.add(defaultEnvFile);
245+
existingFiles.add(configuredEnvFile);
246+
variables = { CONFIGURED_VALUE: 'configured', PROJECT_VALUE: 'project' };
247+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
248+
await fireChange(2);
249+
250+
existingFiles.delete(defaultEnvFile);
251+
variables = { CONFIGURED_VALUE: 'configured' };
252+
await fireChange(3);
253+
254+
sinon.assert.calledWith(mockScopedCollection.delete, 'PROJECT_VALUE');
255+
sinon.assert.neverCalledWith(mockScopedCollection.delete, 'CONFIGURED_VALUE');
256+
sinon.assert.notCalled(mockScopedCollection.clear);
257+
});
258+
259+
test('does not let an older env file refresh overwrite newer variables', async () => {
260+
getConfigurationStub.returns(createMockConfig({ useEnvFile: true }) as WorkspaceConfiguration);
261+
existingFiles.add(defaultEnvFile);
262+
const olderRefresh = createDeferred<Record<string, string>>();
263+
const newerRefresh = createDeferred<Record<string, string>>();
264+
variableReads.push(olderRefresh.promise, newerRefresh.promise);
265+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
266+
267+
assert.ok(envChangeCallback);
268+
const firstChange = envChangeCallback({ uri: Uri.file(defaultEnvFile), changeType: 3 });
269+
const secondChange = envChangeCallback({ uri: Uri.file(defaultEnvFile), changeType: 2 });
270+
271+
newerRefresh.resolve({ NEW_VALUE: 'new' });
272+
await secondChange;
273+
olderRefresh.resolve({ OLD_VALUE: 'old' });
274+
await firstChange;
275+
276+
sinon.assert.calledOnceWithExactly(mockScopedCollection.replace, 'NEW_VALUE', 'new');
277+
sinon.assert.neverCalledWith(mockScopedCollection.replace, 'OLD_VALUE', 'old');
278+
});
279+
});
280+
154281
test('should NOT inject when useEnvFile is false even with python.envFile configured', async () => {
155282
getConfigurationStub.returns(
156283
createMockConfig({

0 commit comments

Comments
 (0)