Skip to content

Commit c384220

Browse files
authored
fix: use conda.sh for Git Bash activation on Windows (Fixes #1247) (#1248)
## Summary On Windows with Git Bash as the default terminal, the extension was attempting to activate conda environments using `activate.bat` (CMD batch script), which fails in bash: ```bash $ source C:/Tools/miniforge3/Scripts/activate.bat pipes bash: C:/Tools/miniforge3/Scripts/activate.bat: line 1: syntax error near unexpected token `(' ``` ## Root Cause The `windowsExceptionGenerateConfig` function was using the same `sourceInitPath` (which is `activate.bat` on Windows) for all shells, including Git Bash. ## Fix - Pass `conda.sh` path to `windowsExceptionGenerateConfig` for proper bash-based shell activation - Use two commands for bash when `conda.sh` is available: `source <conda.sh>` + `conda activate <env>` - Skip Git Bash activation entirely when `conda.sh` is unavailable and `sourceInitPath` is `.bat` - Refactored `shellSourcingScripts` from array with fragile indices to typed `ShellSourcingScripts` interface ## Changes - [condaUtils.ts](src/managers/conda/condaUtils.ts): Extract and pass `condaShPath` to `windowsExceptionGenerateConfig`, use two-command activation for bash, skip activation when only `.bat` is available - [condaSourcingUtils.ts](src/managers/conda/condaSourcingUtils.ts): Add `ShellSourcingScripts` interface replacing fragile array indices - [condaUtils.windowsActivation.unit.test.ts](src/test/managers/conda/condaUtils.windowsActivation.unit.test.ts): Add unit tests for Windows shell activation ## Testing - Pre-commit checks pass (lint, tsc, unit tests) - Manual testing needed on Windows with Git Bash + conda Fixes #1247
1 parent 0b9d327 commit c384220

File tree

5 files changed

+296
-18
lines changed

5 files changed

+296
-18
lines changed

src/features/terminal/shells/common/shellUtils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ const shellDelimiterByShell = new Map<string, string>([
2424
]);
2525

2626
export function getShellCommandAsString(shell: string, command: PythonCommandRunConfiguration[]): string {
27+
// Return empty string for empty command arrays (e.g., when activation is intentionally skipped)
28+
if (command.length === 0) {
29+
return '';
30+
}
31+
2732
const delimiter = shellDelimiterByShell.get(shell) ?? defaultShellDelimiter;
2833
const parts = [];
2934
for (const cmd of command) {

src/managers/conda/condaSourcingUtils.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ import * as path from 'path';
66
import { traceError, traceInfo, traceVerbose } from '../../common/logging';
77
import { isWindows } from '../../common/utils/platformUtils';
88

9+
/**
10+
* Shell-specific sourcing scripts for conda activation.
11+
* Each field is optional since not all scripts may be available on all systems.
12+
*/
13+
export interface ShellSourcingScripts {
14+
/** PowerShell hook script (conda-hook.ps1) */
15+
ps1?: string;
16+
/** Bash/sh initialization script (conda.sh) */
17+
sh?: string;
18+
/** Windows CMD batch file (activate.bat) */
19+
cmd?: string;
20+
}
21+
922
/**
1023
* Represents the status of conda sourcing in the current environment
1124
*/
@@ -16,14 +29,14 @@ export class CondaSourcingStatus {
1629
* @param condaFolder Path to the conda installation folder (derived from condaPath)
1730
* @param isActiveOnLaunch Whether conda was activated before VS Code launch
1831
* @param globalSourcingScript Path to the global sourcing script (if exists)
19-
* @param shellSourcingScripts List of paths to shell-specific sourcing scripts
32+
* @param shellSourcingScripts Shell-specific sourcing scripts (if found)
2033
*/
2134
constructor(
2235
public readonly condaPath: string,
2336
public readonly condaFolder: string,
2437
public isActiveOnLaunch?: boolean,
2538
public globalSourcingScript?: string,
26-
public shellSourcingScripts?: string[],
39+
public shellSourcingScripts?: ShellSourcingScripts,
2740
) {}
2841

2942
/**
@@ -40,15 +53,23 @@ export class CondaSourcingStatus {
4053
lines.push(`├─ Global Sourcing Script: ${this.globalSourcingScript}`);
4154
}
4255

43-
if (this.shellSourcingScripts?.length) {
44-
lines.push('└─ Shell-specific Sourcing Scripts:');
45-
this.shellSourcingScripts.forEach((script, index, array) => {
46-
const isLast = index === array.length - 1;
47-
if (script) {
48-
// Only include scripts that exist
49-
lines.push(` ${isLast ? '└─' : '├─'} ${script}`);
50-
}
51-
});
56+
if (this.shellSourcingScripts) {
57+
const scripts = this.shellSourcingScripts;
58+
const entries = [
59+
scripts.ps1 && `PowerShell: ${scripts.ps1}`,
60+
scripts.sh && `Bash/sh: ${scripts.sh}`,
61+
scripts.cmd && `CMD: ${scripts.cmd}`,
62+
].filter(Boolean);
63+
64+
if (entries.length > 0) {
65+
lines.push('└─ Shell-specific Sourcing Scripts:');
66+
entries.forEach((entry, index, array) => {
67+
const isLast = index === array.length - 1;
68+
lines.push(` ${isLast ? '└─' : '├─'} ${entry}`);
69+
});
70+
} else {
71+
lines.push('└─ No Shell-specific Sourcing Scripts Found');
72+
}
5273
} else {
5374
lines.push('└─ No Shell-specific Sourcing Scripts Found');
5475
}
@@ -120,7 +141,7 @@ export async function findGlobalSourcingScript(condaFolder: string): Promise<str
120141
}
121142
}
122143

123-
export async function findShellSourcingScripts(sourcingStatus: CondaSourcingStatus): Promise<string[]> {
144+
export async function findShellSourcingScripts(sourcingStatus: CondaSourcingStatus): Promise<ShellSourcingScripts> {
124145
const logs: string[] = [];
125146
logs.push('=== Conda Sourcing Shell Script Search ===');
126147

@@ -170,7 +191,7 @@ export async function findShellSourcingScripts(sourcingStatus: CondaSourcingStat
170191
traceVerbose(logs.join('\n'));
171192
}
172193

173-
return [ps1Script, shScript, cmdActivate] as string[];
194+
return { ps1: ps1Script, sh: shScript, cmd: cmdActivate };
174195
}
175196

176197
/**

src/managers/conda/condaUtils.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -512,10 +512,16 @@ async function buildShellActivationMapForConda(
512512
// P3: Handle Windows specifically ;this is carryover from vscode-python
513513
if (isWindows()) {
514514
logs.push('✓ Using Windows-specific activation configuration');
515+
// Get conda.sh for bash-based shells on Windows (e.g., Git Bash)
516+
const condaShPath = envManager.sourcingInformation.shellSourcingScripts?.sh;
517+
if (!condaShPath) {
518+
logs.push('conda.sh not found, using preferred sourcing script path for bash activation');
519+
}
515520
shellMaps = await windowsExceptionGenerateConfig(
516521
preferredSourcingPath,
517522
envIdentifier,
518523
envManager.sourcingInformation.condaFolder,
524+
condaShPath,
519525
);
520526
return shellMaps;
521527
}
@@ -576,10 +582,16 @@ async function generateShellActivationMapFromConfig(
576582
return { shellActivation, shellDeactivation };
577583
}
578584

579-
async function windowsExceptionGenerateConfig(
585+
/**
586+
* Generates shell-specific activation configuration for Windows.
587+
* Handles PowerShell, CMD, and Git Bash with appropriate scripts.
588+
* @internal Exported for testing
589+
*/
590+
export async function windowsExceptionGenerateConfig(
580591
sourceInitPath: string,
581592
prefix: string,
582593
condaFolder: string,
594+
condaShPath?: string,
583595
): Promise<ShellCommandMaps> {
584596
const shellActivation: Map<string, PythonCommandRunConfiguration[]> = new Map();
585597
const shellDeactivation: Map<string, PythonCommandRunConfiguration[]> = new Map();
@@ -593,7 +605,26 @@ async function windowsExceptionGenerateConfig(
593605
const pwshActivate = [{ executable: activation }, { executable: 'conda', args: ['activate', quotedPrefix] }];
594606
const cmdActivate = [{ executable: sourceInitPath }, { executable: 'conda', args: ['activate', quotedPrefix] }];
595607

596-
const bashActivate = [{ executable: 'source', args: [sourceInitPath.replace(/\\/g, '/'), quotedPrefix] }];
608+
// When condaShPath is available, it is an initialization script (conda.sh) and does not
609+
// itself activate an environment. In that case, first source conda.sh, then
610+
// run "conda activate <envIdentifier>".
611+
// When falling back to sourceInitPath, only emit a bash "source" command if the script
612+
// is bash-compatible; on Windows, sourceInitPath may point to "activate.bat", which
613+
// cannot be sourced by Git Bash, so in that case we skip emitting a Git Bash activation.
614+
let bashActivate: PythonCommandRunConfiguration[];
615+
if (condaShPath) {
616+
bashActivate = [
617+
{ executable: 'source', args: [condaShPath.replace(/\\/g, '/')] },
618+
{ executable: 'conda', args: ['activate', quotedPrefix] },
619+
];
620+
} else if (sourceInitPath.toLowerCase().endsWith('.bat')) {
621+
traceVerbose(
622+
`Skipping Git Bash activation fallback because sourceInitPath is a batch script: ${sourceInitPath}`,
623+
);
624+
bashActivate = [];
625+
} else {
626+
bashActivate = [{ executable: 'source', args: [sourceInitPath.replace(/\\/g, '/'), quotedPrefix] }];
627+
}
597628
traceVerbose(
598629
`Windows activation commands:
599630
PowerShell: ${JSON.stringify(pwshActivate)},

src/test/features/terminal/shells/common/shellUtils.unit.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,7 @@ suite('Shell Utils', () => {
9999
});
100100

101101
suite('getShellCommandAsString', () => {
102-
const sampleCommand: PythonCommandRunConfiguration[] = [
103-
{ executable: 'source', args: ['/path/to/activate'] },
104-
];
102+
const sampleCommand: PythonCommandRunConfiguration[] = [{ executable: 'source', args: ['/path/to/activate'] }];
105103

106104
suite('leading space for history ignore', () => {
107105
test('should add leading space for bash commands', () => {
@@ -184,5 +182,27 @@ suite('Shell Utils', () => {
184182
assert.ok(!result.startsWith(' '), 'Fish command should not start with a leading space');
185183
});
186184
});
185+
186+
suite('empty command handling', () => {
187+
test('should return empty string for empty command array (bash)', () => {
188+
const result = getShellCommandAsString(ShellConstants.BASH, []);
189+
assert.strictEqual(result, '', 'Empty command array should return empty string');
190+
});
191+
192+
test('should return empty string for empty command array (gitbash)', () => {
193+
const result = getShellCommandAsString(ShellConstants.GITBASH, []);
194+
assert.strictEqual(result, '', 'Empty command array should return empty string');
195+
});
196+
197+
test('should return empty string for empty command array (pwsh)', () => {
198+
const result = getShellCommandAsString(ShellConstants.PWSH, []);
199+
assert.strictEqual(result, '', 'Empty command array should return empty string');
200+
});
201+
202+
test('should return empty string for empty command array (cmd)', () => {
203+
const result = getShellCommandAsString(ShellConstants.CMD, []);
204+
assert.strictEqual(result, '', 'Empty command array should return empty string');
205+
});
206+
});
187207
});
188208
});

0 commit comments

Comments
 (0)