Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions src/features/terminal/shells/common/shellUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const shellDelimiterByShell = new Map<string, string>([
]);

export function getShellCommandAsString(shell: string, command: PythonCommandRunConfiguration[]): string {
// Return empty string for empty command arrays (e.g., when activation is intentionally skipped)
if (command.length === 0) {
return '';
}

const delimiter = shellDelimiterByShell.get(shell) ?? defaultShellDelimiter;
const parts = [];
for (const cmd of command) {
Expand Down
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;
}

/**
* 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 {
bashActivate = [{ executable: 'source', args: [sourceInitPath.replace(/\\/g, '/'), quotedPrefix] }];
}
traceVerbose(
`Windows activation commands:
PowerShell: ${JSON.stringify(pwshActivate)},
Expand Down
26 changes: 23 additions & 3 deletions src/test/features/terminal/shells/common/shellUtils.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,7 @@ suite('Shell Utils', () => {
});

suite('getShellCommandAsString', () => {
const sampleCommand: PythonCommandRunConfiguration[] = [
{ executable: 'source', args: ['/path/to/activate'] },
];
const sampleCommand: PythonCommandRunConfiguration[] = [{ executable: 'source', args: ['/path/to/activate'] }];

suite('leading space for history ignore', () => {
test('should add leading space for bash commands', () => {
Expand Down Expand Up @@ -184,5 +182,27 @@ suite('Shell Utils', () => {
assert.ok(!result.startsWith(' '), 'Fish command should not start with a leading space');
});
});

suite('empty command handling', () => {
test('should return empty string for empty command array (bash)', () => {
const result = getShellCommandAsString(ShellConstants.BASH, []);
assert.strictEqual(result, '', 'Empty command array should return empty string');
});

test('should return empty string for empty command array (gitbash)', () => {
const result = getShellCommandAsString(ShellConstants.GITBASH, []);
assert.strictEqual(result, '', 'Empty command array should return empty string');
});

test('should return empty string for empty command array (pwsh)', () => {
const result = getShellCommandAsString(ShellConstants.PWSH, []);
assert.strictEqual(result, '', 'Empty command array should return empty string');
});

test('should return empty string for empty command array (cmd)', () => {
const result = getShellCommandAsString(ShellConstants.CMD, []);
assert.strictEqual(result, '', 'Empty command array should return empty string');
});
});
});
});
Loading