Skip to content

Commit 3bcc859

Browse files
fix: skip terminal activation when there is no activate script (#1801)
Fixes #1775 ## Problem When an interpreter's `bin/` has no `activate` script, `getShellActivationCommands(binDir)` still emitted `source <bin>/activate` for sh, bash, zsh, Git Bash, ksh and the `unknown` fallback. `uv python install` toolchains are an example: they're base interpreters, not virtual environments. Every new terminal then ran a failing command: ``` source: no such file or directory: ~/.local/share/uv/python/cpython-3.13.9-macos-aarch64-none/bin/activate ``` The other shells (csh, fish, xonsh, nu, pwsh, cmd) were already gated on their script existing. ## Fix - `getShellActivationCommands` only adds the `activate`-based entries (`unknown`, sh, bash, Git Bash, zsh, ksh) when `<binDir>/activate` exists. This matches how the other shells are handled. Real venvs, pipenv and poetry environments always ship `activate`, so they're unaffected. - `isActivatableEnvironment` now requires a non-empty `shellActivation` map. It used to check only that the map existed, so an environment with no activation commands was still offered for activation (the terminal activate button and command-based auto-activation). ## Tests - New tests in `utils.getShellActivationCommands.unit.test.ts`: - no POSIX or `unknown` activation without an `activate` script, on non-Windows and on Windows; - activation is still emitted when the script exists. - New `src/test/features/common/activation.unit.test.ts` covers `isActivatableEnvironment`, including the empty-map case. - Two existing tests ("Bash activation does not include Set-ExecutionPolicy" and "Windows unknown shell uses activate…") now create the `activate` file they rely on. - Unit tests (`tsc` + mocha, Node 22): - with the change: 2411 passing; - without it: the 3 new tests covering the bug fail. - `eslint` and `prettier --check` are clean on the changed files. I did not test this manually in VS Code with a real uv toolchain. The fix is covered by the unit tests above. This change was written with help from an AI coding assistant (Claude Code). I reviewed and tested it as described above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 2f50562 commit 3bcc859

4 files changed

Lines changed: 110 additions & 22 deletions

File tree

‎src/features/common/activation.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { identifyTerminalShell } from './shellDetector';
1010

1111
export function isActivatableEnvironment(environment: PythonEnvironment): boolean {
12-
return !!environment.execInfo?.activation || !!environment.execInfo?.shellActivation;
12+
return !!environment.execInfo?.activation || (environment.execInfo?.shellActivation?.size ?? 0) > 0;
1313
}
1414

1515
export function isActivatedRunAvailable(environment: PythonEnvironment): boolean {

‎src/managers/common/utils.ts‎

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -115,30 +115,34 @@ export async function getShellActivationCommands(binDir: string): Promise<{
115115
const shellActivation: Map<string, PythonCommandRunConfiguration[]> = new Map();
116116
const shellDeactivation: Map<string, PythonCommandRunConfiguration[]> = new Map();
117117

118-
if (isWindows()) {
119-
shellActivation.set('unknown', [{ executable: path.join(binDir, `activate`) }]);
120-
shellDeactivation.set('unknown', [{ executable: path.join(binDir, `deactivate`) }]);
121-
} else {
122-
shellActivation.set('unknown', [{ executable: 'source', args: [path.join(binDir, `activate`)] }]);
123-
shellDeactivation.set('unknown', [{ executable: 'deactivate' }]);
124-
}
118+
// Base interpreters (for example `uv python install` toolchains) share this layout but have no
119+
// activation scripts, so only offer activation for scripts that exist, like the shells below.
120+
if (await fs.pathExists(path.join(binDir, 'activate'))) {
121+
if (isWindows()) {
122+
shellActivation.set('unknown', [{ executable: path.join(binDir, `activate`) }]);
123+
shellDeactivation.set('unknown', [{ executable: path.join(binDir, `deactivate`) }]);
124+
} else {
125+
shellActivation.set('unknown', [{ executable: 'source', args: [path.join(binDir, `activate`)] }]);
126+
shellDeactivation.set('unknown', [{ executable: 'deactivate' }]);
127+
}
125128

126-
shellActivation.set(ShellConstants.SH, [{ executable: 'source', args: [path.join(binDir, `activate`)] }]);
127-
shellDeactivation.set(ShellConstants.SH, [{ executable: 'deactivate' }]);
129+
shellActivation.set(ShellConstants.SH, [{ executable: 'source', args: [path.join(binDir, `activate`)] }]);
130+
shellDeactivation.set(ShellConstants.SH, [{ executable: 'deactivate' }]);
128131

129-
shellActivation.set(ShellConstants.BASH, [{ executable: 'source', args: [path.join(binDir, `activate`)] }]);
130-
shellDeactivation.set(ShellConstants.BASH, [{ executable: 'deactivate' }]);
132+
shellActivation.set(ShellConstants.BASH, [{ executable: 'source', args: [path.join(binDir, `activate`)] }]);
133+
shellDeactivation.set(ShellConstants.BASH, [{ executable: 'deactivate' }]);
131134

132-
shellActivation.set(ShellConstants.GITBASH, [
133-
{ executable: 'source', args: [pathForGitBash(path.join(binDir, `activate`))] },
134-
]);
135-
shellDeactivation.set(ShellConstants.GITBASH, [{ executable: 'deactivate' }]);
135+
shellActivation.set(ShellConstants.GITBASH, [
136+
{ executable: 'source', args: [pathForGitBash(path.join(binDir, `activate`))] },
137+
]);
138+
shellDeactivation.set(ShellConstants.GITBASH, [{ executable: 'deactivate' }]);
136139

137-
shellActivation.set(ShellConstants.ZSH, [{ executable: 'source', args: [path.join(binDir, `activate`)] }]);
138-
shellDeactivation.set(ShellConstants.ZSH, [{ executable: 'deactivate' }]);
140+
shellActivation.set(ShellConstants.ZSH, [{ executable: 'source', args: [path.join(binDir, `activate`)] }]);
141+
shellDeactivation.set(ShellConstants.ZSH, [{ executable: 'deactivate' }]);
139142

140-
shellActivation.set(ShellConstants.KSH, [{ executable: '.', args: [path.join(binDir, `activate`)] }]);
141-
shellDeactivation.set(ShellConstants.KSH, [{ executable: 'deactivate' }]);
143+
shellActivation.set(ShellConstants.KSH, [{ executable: '.', args: [path.join(binDir, `activate`)] }]);
144+
shellDeactivation.set(ShellConstants.KSH, [{ executable: 'deactivate' }]);
145+
}
142146

143147
if (await fs.pathExists(path.join(binDir, 'Activate.ps1'))) {
144148
shellActivation.set(ShellConstants.PWSH, buildPwshActivationCommands(path.join(binDir, 'Activate.ps1')));
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import assert from 'assert';
2+
import { PythonEnvironment } from '../../../api';
3+
import { isActivatableEnvironment } from '../../../features/common/activation';
4+
5+
suite('isActivatableEnvironment', () => {
6+
function env(execInfo: Partial<PythonEnvironment['execInfo']>): PythonEnvironment {
7+
return { execInfo: { run: { executable: 'python' }, ...execInfo } } as PythonEnvironment;
8+
}
9+
10+
test('Environment without activation is not activatable', () => {
11+
assert.strictEqual(isActivatableEnvironment(env({})), false);
12+
});
13+
14+
test('Environment with an empty shell activation map is not activatable', () => {
15+
assert.strictEqual(isActivatableEnvironment(env({ shellActivation: new Map() })), false);
16+
});
17+
18+
test('Environment with shell activation commands is activatable', () => {
19+
const shellActivation = new Map([['bash', [{ executable: 'source', args: ['activate'] }]]]);
20+
assert.strictEqual(isActivatableEnvironment(env({ shellActivation })), true);
21+
});
22+
23+
test('Environment with an activation command is activatable', () => {
24+
assert.strictEqual(isActivatableEnvironment(env({ activation: [{ executable: 'activate' }] })), true);
25+
});
26+
});

‎src/test/managers/common/utils.getShellActivationCommands.unit.test.ts‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,11 @@ suite('getShellActivationCommands', () => {
8989
const pwshActivation = result.shellActivation.get(ShellConstants.PWSH);
9090

9191
assert.ok(pwshActivation, 'PowerShell activation should be defined');
92-
assert.strictEqual(pwshActivation.length, 1, 'Should have only 1 command: activate (no Set-ExecutionPolicy)');
92+
assert.strictEqual(
93+
pwshActivation.length,
94+
1,
95+
'Should have only 1 command: activate (no Set-ExecutionPolicy)',
96+
);
9397
assert.strictEqual(pwshActivation[0].executable, '&');
9498
assert.ok(pwshActivation[0].args);
9599
assert.ok(
@@ -106,7 +110,11 @@ suite('getShellActivationCommands', () => {
106110
const pwshActivation = result.shellActivation.get(ShellConstants.PWSH);
107111

108112
assert.ok(pwshActivation, 'PowerShell activation should be defined');
109-
assert.strictEqual(pwshActivation.length, 1, 'Should have only 1 command: activate (no Set-ExecutionPolicy)');
113+
assert.strictEqual(
114+
pwshActivation.length,
115+
1,
116+
'Should have only 1 command: activate (no Set-ExecutionPolicy)',
117+
);
110118
assert.strictEqual(pwshActivation[0].executable, '&');
111119
assert.ok(pwshActivation[0].args);
112120
assert.ok(
@@ -131,6 +139,7 @@ suite('getShellActivationCommands', () => {
131139
suite('Other shells are not affected by execution policy change', () => {
132140
test('Bash activation does not include Set-ExecutionPolicy', async () => {
133141
isWindowsStub.returns(false);
142+
await fs.writeFile(path.join(tmpDir, 'activate'), '');
134143

135144
const result = await getShellActivationCommands(tmpDir);
136145
const bashActivation = result.shellActivation.get(ShellConstants.BASH);
@@ -156,6 +165,7 @@ suite('getShellActivationCommands', () => {
156165
suite('Windows unknown shell fallback', () => {
157166
test('Windows unknown shell uses activate without Set-ExecutionPolicy', async () => {
158167
isWindowsStub.returns(true);
168+
await fs.writeFile(path.join(tmpDir, 'activate'), '');
159169

160170
const result = await getShellActivationCommands(tmpDir);
161171
const unknownActivation = result.shellActivation.get('unknown');
@@ -165,4 +175,52 @@ suite('getShellActivationCommands', () => {
165175
assert.ok(unknownActivation[0].executable.endsWith('activate'));
166176
});
167177
});
178+
179+
suite('No activation without activation scripts', () => {
180+
test('No POSIX shell activation when bin has no activate script (e.g. uv python toolchain)', async () => {
181+
isWindowsStub.returns(false);
182+
await fs.writeFile(path.join(tmpDir, 'python3'), '');
183+
184+
const result = await getShellActivationCommands(tmpDir);
185+
186+
for (const shell of [
187+
'unknown',
188+
ShellConstants.SH,
189+
ShellConstants.BASH,
190+
ShellConstants.GITBASH,
191+
ShellConstants.ZSH,
192+
ShellConstants.KSH,
193+
]) {
194+
assert.strictEqual(result.shellActivation.get(shell), undefined, `${shell} should not be activated`);
195+
assert.strictEqual(
196+
result.shellDeactivation.get(shell),
197+
undefined,
198+
`${shell} should not be deactivated`,
199+
);
200+
}
201+
assert.strictEqual(result.shellActivation.size, 0);
202+
});
203+
204+
test('No Windows unknown shell activation when Scripts has no activate script', async () => {
205+
isWindowsStub.returns(true);
206+
207+
const result = await getShellActivationCommands(tmpDir);
208+
209+
assert.strictEqual(result.shellActivation.get('unknown'), undefined);
210+
assert.strictEqual(result.shellActivation.size, 0);
211+
});
212+
213+
test('POSIX shells are activated when the activate script exists', async () => {
214+
isWindowsStub.returns(false);
215+
await fs.writeFile(path.join(tmpDir, 'activate'), '');
216+
217+
const result = await getShellActivationCommands(tmpDir);
218+
219+
for (const shell of [ShellConstants.SH, ShellConstants.BASH, ShellConstants.ZSH]) {
220+
assert.deepStrictEqual(result.shellActivation.get(shell), [
221+
{ executable: 'source', args: [path.join(tmpDir, 'activate')] },
222+
]);
223+
}
224+
});
225+
});
168226
});

0 commit comments

Comments
 (0)