Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions src/managers/conda/condaSourcingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import * as path from 'path';
import { traceError, traceInfo, traceVerbose } from '../../common/logging';
import { isWindows } from '../../common/utils/platformUtils';

/**
* Shell-specific sourcing scripts for conda activation.
* Each field is optional since not all scripts may be available on all systems.
*/
export interface ShellSourcingScripts {
/** PowerShell hook script (conda-hook.ps1) */
ps1?: string;
/** Bash/sh initialization script (conda.sh) */
sh?: string;
/** Windows CMD batch file (activate.bat) */
cmd?: string;
}
Comment thread
karthiknadig marked this conversation as resolved.

/**
* Represents the status of conda sourcing in the current environment
*/
Expand All @@ -16,14 +29,14 @@ export class CondaSourcingStatus {
* @param condaFolder Path to the conda installation folder (derived from condaPath)
* @param isActiveOnLaunch Whether conda was activated before VS Code launch
* @param globalSourcingScript Path to the global sourcing script (if exists)
* @param shellSourcingScripts List of paths to shell-specific sourcing scripts
* @param shellSourcingScripts Shell-specific sourcing scripts (if found)
*/
constructor(
public readonly condaPath: string,
public readonly condaFolder: string,
public isActiveOnLaunch?: boolean,
public globalSourcingScript?: string,
public shellSourcingScripts?: string[],
public shellSourcingScripts?: ShellSourcingScripts,
) {}

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

if (this.shellSourcingScripts?.length) {
lines.push('└─ Shell-specific Sourcing Scripts:');
this.shellSourcingScripts.forEach((script, index, array) => {
const isLast = index === array.length - 1;
if (script) {
// Only include scripts that exist
lines.push(` ${isLast ? '└─' : '├─'} ${script}`);
}
});
if (this.shellSourcingScripts) {
const scripts = this.shellSourcingScripts;
const entries = [
scripts.ps1 && `PowerShell: ${scripts.ps1}`,
scripts.sh && `Bash/sh: ${scripts.sh}`,
scripts.cmd && `CMD: ${scripts.cmd}`,
].filter(Boolean);

if (entries.length > 0) {
lines.push('└─ Shell-specific Sourcing Scripts:');
entries.forEach((entry, index, array) => {
const isLast = index === array.length - 1;
lines.push(` ${isLast ? '└─' : '├─'} ${entry}`);
});
} else {
lines.push('└─ No Shell-specific Sourcing Scripts Found');
}
} else {
lines.push('└─ No Shell-specific Sourcing Scripts Found');
}
Expand Down Expand Up @@ -120,7 +141,7 @@ export async function findGlobalSourcingScript(condaFolder: string): Promise<str
}
}

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

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

return [ps1Script, shScript, cmdActivate] as string[];
return { ps1: ps1Script, sh: shScript, cmd: cmdActivate };
}

/**
Expand Down
35 changes: 33 additions & 2 deletions src/managers/conda/condaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,10 +512,16 @@ async function buildShellActivationMapForConda(
// P3: Handle Windows specifically ;this is carryover from vscode-python
if (isWindows()) {
logs.push('✓ Using Windows-specific activation configuration');
// Get conda.sh for bash-based shells on Windows (e.g., Git Bash)
const condaShPath = envManager.sourcingInformation.shellSourcingScripts?.sh;
if (!condaShPath) {
logs.push('conda.sh not found, using preferred sourcing script path for bash activation');
}
shellMaps = await windowsExceptionGenerateConfig(
preferredSourcingPath,
envIdentifier,
envManager.sourcingInformation.condaFolder,
condaShPath,
);
return shellMaps;
}
Expand Down Expand Up @@ -576,10 +582,16 @@ async function generateShellActivationMapFromConfig(
return { shellActivation, shellDeactivation };
}

async function windowsExceptionGenerateConfig(
/**
* Generates shell-specific activation configuration for Windows.
* Handles PowerShell, CMD, and Git Bash with appropriate scripts.
* @internal Exported for testing
*/
export async function windowsExceptionGenerateConfig(
sourceInitPath: string,
prefix: string,
condaFolder: string,
condaShPath?: string,
): Promise<ShellCommandMaps> {
const shellActivation: Map<string, PythonCommandRunConfiguration[]> = new Map();
const shellDeactivation: Map<string, PythonCommandRunConfiguration[]> = new Map();
Expand All @@ -593,7 +605,26 @@ async function windowsExceptionGenerateConfig(
const pwshActivate = [{ executable: activation }, { executable: 'conda', args: ['activate', quotedPrefix] }];
const cmdActivate = [{ executable: sourceInitPath }, { executable: 'conda', args: ['activate', quotedPrefix] }];

const bashActivate = [{ executable: 'source', args: [sourceInitPath.replace(/\\/g, '/'), quotedPrefix] }];
// When condaShPath is available, it is an initialization script (conda.sh) and does not
// itself activate an environment. In that case, first source conda.sh, then
// run "conda activate <envIdentifier>".
// When falling back to sourceInitPath, only emit a bash "source" command if the script
// is bash-compatible; on Windows, sourceInitPath may point to "activate.bat", which
// cannot be sourced by Git Bash, so in that case we skip emitting a Git Bash activation.
let bashActivate: PythonCommandRunConfiguration[];
if (condaShPath) {
bashActivate = [
{ executable: 'source', args: [condaShPath.replace(/\\/g, '/')] },
{ executable: 'conda', args: ['activate', quotedPrefix] },
];
} else if (sourceInitPath.toLowerCase().endsWith('.bat')) {
traceVerbose(
`Skipping Git Bash activation fallback because sourceInitPath is a batch script: ${sourceInitPath}`,
);
bashActivate = [];
} else {
Comment thread
karthiknadig marked this conversation as resolved.
bashActivate = [{ executable: 'source', args: [sourceInitPath.replace(/\\/g, '/'), quotedPrefix] }];
}
traceVerbose(
`Windows activation commands:
PowerShell: ${JSON.stringify(pwshActivate)},
Expand Down
201 changes: 201 additions & 0 deletions src/test/managers/conda/condaUtils.windowsActivation.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import assert from 'assert';
import * as sinon from 'sinon';
import { ShellConstants } from '../../../features/common/shellConstants';
import * as condaSourcingUtils from '../../../managers/conda/condaSourcingUtils';
import { windowsExceptionGenerateConfig } from '../../../managers/conda/condaUtils';

/**
* Tests for windowsExceptionGenerateConfig - Windows shell activation commands.
*
* Key behavior tested:
* - Git Bash uses conda.sh (initialization script) + conda activate when condaShPath is available
* - Git Bash skips activation when condaShPath is not available and sourceInitPath is .bat
* - Git Bash falls back to source <activate-script> <env> when condaShPath is not available and source is not .bat
* - PowerShell uses ps1 hook + conda activate
* - CMD uses activate.bat + conda activate
*/
suite('Conda Utils - windowsExceptionGenerateConfig', () => {
let getCondaHookPs1PathStub: sinon.SinonStub;

setup(() => {
// Mock getCondaHookPs1Path to avoid filesystem access
getCondaHookPs1PathStub = sinon.stub(condaSourcingUtils, 'getCondaHookPs1Path');
});

teardown(() => {
sinon.restore();
});

suite('Git Bash activation with conda.sh', () => {
test('Uses source conda.sh + conda activate when condaShPath is provided', async () => {
// Arrange
getCondaHookPs1PathStub.resolves('C:\\conda\\shell\\condabin\\conda-hook.ps1');
const sourceInitPath = 'C:\\conda\\Scripts\\activate.bat';
const prefix = 'myenv';
const condaFolder = 'C:\\conda';
const condaShPath = 'C:\\conda\\etc\\profile.d\\conda.sh';

// Act
const result = await windowsExceptionGenerateConfig(sourceInitPath, prefix, condaFolder, condaShPath);

// Assert
const gitBashActivation = result.shellActivation.get(ShellConstants.GITBASH);
assert.ok(gitBashActivation, 'Git Bash activation should be defined');
assert.strictEqual(gitBashActivation.length, 2, 'Should have 2 commands: source conda.sh + conda activate');

// First command: source conda.sh (no env arg - it's an initialization script)
assert.strictEqual(gitBashActivation[0].executable, 'source');
assert.deepStrictEqual(gitBashActivation[0].args, ['C:/conda/etc/profile.d/conda.sh']);

// Second command: conda activate <env>
assert.strictEqual(gitBashActivation[1].executable, 'conda');
assert.deepStrictEqual(gitBashActivation[1].args, ['activate', 'myenv']);
});

test('Skips Git Bash activation when condaShPath is undefined and sourceInitPath is .bat', async () => {
// Arrange: sourceInitPath is a .bat file which Git Bash cannot source
getCondaHookPs1PathStub.resolves(undefined);
const sourceInitPath = 'C:\\conda\\Scripts\\activate.bat';
const prefix = 'myenv';
const condaFolder = 'C:\\conda';
const condaShPath = undefined; // Not available

// Act
const result = await windowsExceptionGenerateConfig(sourceInitPath, prefix, condaFolder, condaShPath);

// Assert: Git Bash activation should be empty since .bat cannot be sourced
const gitBashActivation = result.shellActivation.get(ShellConstants.GITBASH);
assert.ok(gitBashActivation, 'Git Bash activation should be defined');
assert.strictEqual(
gitBashActivation.length,
0,
'Git Bash activation should be empty when sourceInitPath is .bat',
);
});

test('Falls back to single source command when condaShPath is undefined and source is not .bat', async () => {
// Arrange: sourceInitPath is a bash-compatible script (no .bat extension)
getCondaHookPs1PathStub.resolves(undefined);
const sourceInitPath = 'C:\\conda\\Scripts\\activate'; // No .bat extension
const prefix = 'myenv';
const condaFolder = 'C:\\conda';
const condaShPath = undefined; // Not available

// Act
const result = await windowsExceptionGenerateConfig(sourceInitPath, prefix, condaFolder, condaShPath);

// Assert
const gitBashActivation = result.shellActivation.get(ShellConstants.GITBASH);
assert.ok(gitBashActivation, 'Git Bash activation should be defined');
assert.strictEqual(gitBashActivation.length, 1, 'Should have 1 command when source is bash-compatible');

// Single command: source <activate-script> <env>
assert.strictEqual(gitBashActivation[0].executable, 'source');
assert.deepStrictEqual(gitBashActivation[0].args, ['C:/conda/Scripts/activate', 'myenv']);
});

test('Converts Windows backslashes to forward slashes for bash', async () => {
// Arrange
getCondaHookPs1PathStub.resolves(undefined);
const condaShPath = 'C:\\Tools\\miniforge3\\etc\\profile.d\\conda.sh';

// Act
const result = await windowsExceptionGenerateConfig(
'C:\\Tools\\miniforge3\\Scripts\\activate.bat',
'pipes',
'C:\\Tools\\miniforge3',
condaShPath,
);

// Assert
const gitBashActivation = result.shellActivation.get(ShellConstants.GITBASH);
assert.ok(gitBashActivation, 'Git Bash activation should be defined');
// Verify forward slashes are used
const sourcePath = gitBashActivation[0].args?.[0];
assert.ok(sourcePath, 'Source path should be defined');
assert.ok(!sourcePath.includes('\\'), 'Path should not contain backslashes');
assert.ok(sourcePath.includes('/'), 'Path should contain forward slashes');
});
});

suite('PowerShell activation', () => {
test('Uses ps1 hook when available', async () => {
// Arrange
const ps1HookPath = 'C:\\conda\\shell\\condabin\\conda-hook.ps1';
getCondaHookPs1PathStub.resolves(ps1HookPath);

// Act
const result = await windowsExceptionGenerateConfig(
'C:\\conda\\Scripts\\activate.bat',
'myenv',
'C:\\conda',
undefined,
);

// Assert
const pwshActivation = result.shellActivation.get(ShellConstants.PWSH);
assert.ok(pwshActivation, 'PowerShell activation should be defined');
assert.strictEqual(pwshActivation.length, 2, 'Should have 2 commands');
assert.strictEqual(pwshActivation[0].executable, ps1HookPath);
assert.strictEqual(pwshActivation[1].executable, 'conda');
assert.deepStrictEqual(pwshActivation[1].args, ['activate', 'myenv']);
});

test('Falls back to sourceInitPath when ps1 hook not found', async () => {
// Arrange
getCondaHookPs1PathStub.resolves(undefined);
const sourceInitPath = 'C:\\conda\\Scripts\\activate.bat';

// Act
const result = await windowsExceptionGenerateConfig(sourceInitPath, 'myenv', 'C:\\conda', undefined);

// Assert
const pwshActivation = result.shellActivation.get(ShellConstants.PWSH);
assert.ok(pwshActivation, 'PowerShell activation should be defined');
assert.strictEqual(pwshActivation[0].executable, sourceInitPath);
});
});

suite('CMD activation', () => {
test('Uses activate.bat + conda activate', async () => {
// Arrange
getCondaHookPs1PathStub.resolves(undefined);
const sourceInitPath = 'C:\\conda\\Scripts\\activate.bat';

// Act
const result = await windowsExceptionGenerateConfig(sourceInitPath, 'myenv', 'C:\\conda', undefined);

// Assert
const cmdActivation = result.shellActivation.get(ShellConstants.CMD);
assert.ok(cmdActivation, 'CMD activation should be defined');
assert.strictEqual(cmdActivation.length, 2, 'Should have 2 commands');
assert.strictEqual(cmdActivation[0].executable, sourceInitPath);
assert.strictEqual(cmdActivation[1].executable, 'conda');
assert.deepStrictEqual(cmdActivation[1].args, ['activate', 'myenv']);
});
});

suite('Deactivation commands', () => {
test('All shells use conda deactivate', async () => {
// Arrange
getCondaHookPs1PathStub.resolves(undefined);

// Act
const result = await windowsExceptionGenerateConfig(
'C:\\conda\\Scripts\\activate.bat',
'myenv',
'C:\\conda',
undefined,
);

// Assert: All shells should have conda deactivate
for (const shell of [ShellConstants.GITBASH, ShellConstants.CMD, ShellConstants.PWSH]) {
const deactivation = result.shellDeactivation.get(shell);
assert.ok(deactivation, `${shell} deactivation should be defined`);
assert.strictEqual(deactivation.length, 1, `${shell} should have 1 deactivation command`);
assert.strictEqual(deactivation[0].executable, 'conda');
assert.deepStrictEqual(deactivation[0].args, ['deactivate']);
}
});
});
});
Loading