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
55 changes: 31 additions & 24 deletions src/features/terminal/shells/common/shellUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,45 @@ import { getConfiguration } from '../../../../common/workspace.apis';
import { ShellConstants } from '../../../common/shellConstants';
import { quoteArgs } from '../../../execution/execUtils';

function getCommandAsString(command: PythonCommandRunConfiguration[], shell: string, delimiter: string): string {
/**
* Shells that support a leading space to prevent command from being saved in history.
* - Bash: When HISTCONTROL contains 'ignorespace' or 'ignoreboth'
* - Zsh: When setopt HIST_IGNORE_SPACE is enabled
* - Git Bash: Uses bash under the hood, same behavior as Bash
*/
export const shellsWithLeadingSpaceHistorySupport = new Set([
ShellConstants.BASH,
ShellConstants.ZSH,
ShellConstants.GITBASH,
]);

const defaultShellDelimiter = '&&';
const shellDelimiterByShell = new Map<string, string>([
[ShellConstants.PWSH, ';'],
[ShellConstants.NU, ';'],
[ShellConstants.FISH, '; and'],
]);

export function getShellCommandAsString(shell: string, command: PythonCommandRunConfiguration[]): string {
const delimiter = shellDelimiterByShell.get(shell) ?? defaultShellDelimiter;
const parts = [];
for (const cmd of command) {
const args = cmd.args ?? [];
parts.push(quoteArgs([normalizeShellPath(cmd.executable, shell), ...args]).join(' '));
}
if (shell === ShellConstants.PWSH) {
if (parts.length === 1) {
return parts[0];
}
return parts.map((p) => `(${p})`).join(` ${delimiter} `);

let commandStr = parts.join(` ${delimiter} `);
if (shell === ShellConstants.PWSH && parts.length > 1) {
commandStr = parts.map((p) => `(${p})`).join(` ${delimiter} `);
}
return parts.join(` ${delimiter} `);
}

export function getShellCommandAsString(shell: string, command: PythonCommandRunConfiguration[]): string {
switch (shell) {
case ShellConstants.PWSH:
return getCommandAsString(command, shell, ';');
case ShellConstants.NU:
return getCommandAsString(command, shell, ';');
case ShellConstants.FISH:
return getCommandAsString(command, shell, '; and');
case ShellConstants.BASH:
case ShellConstants.SH:
case ShellConstants.ZSH:

case ShellConstants.CMD:
case ShellConstants.GITBASH:
default:
return getCommandAsString(command, shell, '&&');
// Add a leading space for shells that support history ignore with leading space.
// This prevents the activation command from being saved in bash/zsh history
// when HISTCONTROL=ignorespace (bash) or setopt HIST_IGNORE_SPACE (zsh) is set.
if (shellsWithLeadingSpaceHistorySupport.has(shell)) {
return ` ${commandStr}`;
}
return commandStr;
}

export function normalizeShellPath(filePath: string, shellType?: string): string {
Expand Down
108 changes: 108 additions & 0 deletions src/test/features/terminal/shells/common/shellUtils.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import * as assert from 'assert';
import { PythonCommandRunConfiguration } from '../../../../../api';
import { ShellConstants } from '../../../../../features/common/shellConstants';
import {
extractProfilePath,
getShellCommandAsString,
PROFILE_TAG_END,
PROFILE_TAG_START,
shellsWithLeadingSpaceHistorySupport,
} from '../../../../../features/terminal/shells/common/shellUtils';

suite('Shell Utils', () => {
Expand Down Expand Up @@ -77,4 +81,108 @@ suite('Shell Utils', () => {
assert.strictEqual(result, expectedPath);
});
});

suite('shellsWithLeadingSpaceHistorySupport', () => {
test('should include bash, zsh, and gitbash', () => {
assert.ok(shellsWithLeadingSpaceHistorySupport.has(ShellConstants.BASH));
assert.ok(shellsWithLeadingSpaceHistorySupport.has(ShellConstants.ZSH));
assert.ok(shellsWithLeadingSpaceHistorySupport.has(ShellConstants.GITBASH));
});

test('should not include shells without leading space history support', () => {
assert.ok(!shellsWithLeadingSpaceHistorySupport.has(ShellConstants.PWSH));
assert.ok(!shellsWithLeadingSpaceHistorySupport.has(ShellConstants.CMD));
assert.ok(!shellsWithLeadingSpaceHistorySupport.has(ShellConstants.FISH));
assert.ok(!shellsWithLeadingSpaceHistorySupport.has(ShellConstants.SH));
assert.ok(!shellsWithLeadingSpaceHistorySupport.has(ShellConstants.NU));
});
});

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

suite('leading space for history ignore', () => {
test('should add leading space for bash commands', () => {
const result = getShellCommandAsString(ShellConstants.BASH, sampleCommand);
assert.ok(result.startsWith(' '), 'Bash command should start with a leading space');
assert.ok(result.includes('source'), 'Command should contain source');
});

test('should add leading space for zsh commands', () => {
const result = getShellCommandAsString(ShellConstants.ZSH, sampleCommand);
assert.ok(result.startsWith(' '), 'Zsh command should start with a leading space');
assert.ok(result.includes('source'), 'Command should contain source');
});

test('should add leading space for gitbash commands', () => {
const result = getShellCommandAsString(ShellConstants.GITBASH, sampleCommand);
assert.ok(result.startsWith(' '), 'Git Bash command should start with a leading space');
assert.ok(result.includes('source'), 'Command should contain source');
});

test('should not add leading space for pwsh commands', () => {
const result = getShellCommandAsString(ShellConstants.PWSH, sampleCommand);
assert.ok(!result.startsWith(' '), 'PowerShell command should not start with a leading space');
});

test('should not add leading space for cmd commands', () => {
const result = getShellCommandAsString(ShellConstants.CMD, sampleCommand);
assert.ok(!result.startsWith(' '), 'CMD command should not start with a leading space');
});

test('should not add leading space for fish commands', () => {
const result = getShellCommandAsString(ShellConstants.FISH, sampleCommand);
assert.ok(!result.startsWith(' '), 'Fish command should not start with a leading space');
});

test('should not add leading space for sh commands', () => {
const result = getShellCommandAsString(ShellConstants.SH, sampleCommand);
assert.ok(!result.startsWith(' '), 'SH command should not start with a leading space');
});

test('should not add leading space for nu commands', () => {
const result = getShellCommandAsString(ShellConstants.NU, sampleCommand);
assert.ok(!result.startsWith(' '), 'Nu command should not start with a leading space');
});

test('should not add leading space for unknown shells', () => {
const result = getShellCommandAsString('unknown', sampleCommand);
assert.ok(!result.startsWith(' '), 'Unknown shell command should not start with a leading space');
});
});

suite('command formatting', () => {
test('should format multiple commands with && for bash', () => {
const multiCommand: PythonCommandRunConfiguration[] = [
{ executable: 'source', args: ['/path/to/init'] },
{ executable: 'conda', args: ['activate', 'myenv'] },
];
const result = getShellCommandAsString(ShellConstants.BASH, multiCommand);
assert.ok(result.includes('&&'), 'Bash should use && to join commands');
assert.ok(result.startsWith(' '), 'Bash command should start with a leading space');
});

test('should format multiple commands with ; for pwsh', () => {
const multiCommand: PythonCommandRunConfiguration[] = [
{ executable: 'source', args: ['/path/to/init'] },
{ executable: 'conda', args: ['activate', 'myenv'] },
];
const result = getShellCommandAsString(ShellConstants.PWSH, multiCommand);
assert.ok(result.includes(';'), 'PowerShell should use ; to join commands');
assert.ok(!result.startsWith(' '), 'PowerShell command should not start with a leading space');
});

test('should format multiple commands with "; and" for fish', () => {
const multiCommand: PythonCommandRunConfiguration[] = [
{ executable: 'source', args: ['/path/to/init'] },
{ executable: 'conda', args: ['activate', 'myenv'] },
];
const result = getShellCommandAsString(ShellConstants.FISH, multiCommand);
assert.ok(result.includes('; and'), 'Fish should use "; and" to join commands');
assert.ok(!result.startsWith(' '), 'Fish command should not start with a leading space');
});
});
});
});