diff --git a/src/__tests__/env-handler.test.ts b/src/__tests__/env-handler.test.ts index 1a26b106b..3be767c53 100644 --- a/src/__tests__/env-handler.test.ts +++ b/src/__tests__/env-handler.test.ts @@ -234,6 +234,66 @@ scope: 'user', }); }); + // ─── detectShellProfile ────────────────────────────────── + + describe('detectShellProfile', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return .zshrc when SHELL contains zsh', () => { + vi.stubEnv('SHELL', '/bin/zsh'); + expect(handler.detectShellProfile()).toBe(path.join(homeDir, '.zshrc')); + }); + + it('should return .bashrc on non-Windows when SHELL is unset (unchanged behavior)', () => { + vi.stubEnv('SHELL', ''); + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + expect(handler.detectShellProfile()).toBe(path.join(homeDir, '.bashrc')); + }); + + it('should prefer an existing .bash_profile on Windows over .bashrc (#682)', async () => { + // Windows never sets SHELL, and Git Bash's default launch is a login + // shell, which reads .bash_profile — never .bashrc. + vi.stubEnv('SHELL', ''); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + await fse.writeFile(path.join(homeDir, '.bash_profile'), '# existing\n'); + + expect(handler.detectShellProfile()).toBe(path.join(homeDir, '.bash_profile')); + }); + + it('should fall back to .bash_login on Windows when only that exists', async () => { + vi.stubEnv('SHELL', ''); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + await fse.writeFile(path.join(homeDir, '.bash_login'), '# existing\n'); + + expect(handler.detectShellProfile()).toBe(path.join(homeDir, '.bash_login')); + }); + + it('should fall back to .profile on Windows when only that exists', async () => { + vi.stubEnv('SHELL', ''); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + await fse.writeFile(path.join(homeDir, '.profile'), '# existing\n'); + + expect(handler.detectShellProfile()).toBe(path.join(homeDir, '.profile')); + }); + + it('should default to .bash_profile on Windows when none of the login files exist', () => { + vi.stubEnv('SHELL', ''); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + + expect(handler.detectShellProfile()).toBe(path.join(homeDir, '.bash_profile')); + }); + + it('should never return .bashrc on Windows, even if it exists alongside login files', async () => { + vi.stubEnv('SHELL', ''); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + await fse.writeFile(path.join(homeDir, '.bashrc'), '# existing\n'); + + expect(handler.detectShellProfile()).not.toBe(path.join(homeDir, '.bashrc')); + }); + }); + // ─── pullItem ──────────────────────────────────────────── describe('pullItem', () => { diff --git a/src/resources/env.ts b/src/resources/env.ts index 7ba3a2f5a..b93f27ede 100644 --- a/src/resources/env.ts +++ b/src/resources/env.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { existsSync } from 'node:fs'; import { z } from 'zod'; import YAML from 'yaml'; import { ResourceHandler } from './base.js'; @@ -345,6 +346,23 @@ export class EnvHandler extends ResourceHandler { if (shell.includes('zsh')) { return path.join(home, '.zshrc'); } + + // SHELL is a POSIX convention that Windows never sets, so this bash + // fallback used to always mean .bashrc — but Git Bash on Windows starts + // as a login shell (its default launch is effectively `bash --login`), + // and a login shell reads .bash_profile / .bash_login / .profile, never + // .bashrc. Prefer whichever of those already exists (bash's own lookup + // order); if none do, create .bash_profile, the file most Windows dev + // tooling (nvm-windows, etc.) already targets. Scoped to win32 only — + // non-Windows bash is correctly a non-login shell here and keeps .bashrc. + if (process.platform === 'win32') { + for (const name of ['.bash_profile', '.bash_login', '.profile']) { + const candidate = path.join(home, name); + if (existsSync(candidate)) return candidate; + } + return path.join(home, '.bash_profile'); + } + return path.join(home, '.bashrc'); }