diff --git a/src/features/terminal/shells/common/shellUtils.ts b/src/features/terminal/shells/common/shellUtils.ts index e662b337..09867f29 100644 --- a/src/features/terminal/shells/common/shellUtils.ts +++ b/src/features/terminal/shells/common/shellUtils.ts @@ -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([ + [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 { diff --git a/src/test/features/terminal/shells/common/shellUtils.unit.test.ts b/src/test/features/terminal/shells/common/shellUtils.unit.test.ts index 12eedfdb..97281b80 100644 --- a/src/test/features/terminal/shells/common/shellUtils.unit.test.ts +++ b/src/test/features/terminal/shells/common/shellUtils.unit.test.ts @@ -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', () => { @@ -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'); + }); + }); + }); });