diff --git a/src/hooks/non-interactive-env/index.test.ts b/src/hooks/non-interactive-env/index.test.ts index 16087f9e2..6f925d5ee 100644 --- a/src/hooks/non-interactive-env/index.test.ts +++ b/src/hooks/non-interactive-env/index.test.ts @@ -178,7 +178,11 @@ describe("non-interactive-env hook", () => { }) }) - describe("cross-platform shell support", () => { + describe("bash tool always uses unix shell syntax", () => { + // The bash tool always runs in a Unix-like shell (bash/sh), even on Windows + // (via Git Bash, WSL, etc.), so we should always use unix export syntax. + // This fixes GitHub issues #983 and #889. + test("#given macOS platform #when git command executes #then uses unix export syntax", async () => { delete process.env.PSModulePath process.env.SHELL = "/bin/zsh" @@ -221,7 +225,9 @@ describe("non-interactive-env hook", () => { expect(cmd).toContain("; git commit") }) - test("#given Windows with PowerShell #when git command executes #then uses powershell $env syntax", async () => { + test("#given Windows with PowerShell env #when bash tool git command executes #then still uses unix export syntax", async () => { + // Even when PSModulePath is set (indicating PowerShell environment), + // the bash tool runs in a Unix-like shell, so we use export syntax process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" Object.defineProperty(process, "platform", { value: "win32" }) @@ -236,13 +242,16 @@ describe("non-interactive-env hook", () => { ) const cmd = output.args.command as string - expect(cmd).toContain("$env:") + // Should use unix export syntax, NOT PowerShell $env: syntax + expect(cmd).toStartWith("export ") expect(cmd).toContain("; git status") - expect(cmd).not.toStartWith("export ") + expect(cmd).not.toContain("$env:") expect(cmd).not.toContain("set ") }) - test("#given Windows without PowerShell #when git command executes #then uses cmd set syntax", async () => { + test("#given Windows without SHELL env #when bash tool git command executes #then still uses unix export syntax", async () => { + // Even when detectShellType() would return "cmd" (no SHELL, no PSModulePath, win32), + // the bash tool runs in a Unix-like shell, so we use export syntax delete process.env.PSModulePath delete process.env.SHELL Object.defineProperty(process, "platform", { value: "win32" }) @@ -258,14 +267,18 @@ describe("non-interactive-env hook", () => { ) const cmd = output.args.command as string - expect(cmd).toContain("set ") - expect(cmd).toContain("&&") - expect(cmd).not.toStartWith("export ") + // Should use unix export syntax, NOT cmd.exe set syntax + expect(cmd).toStartWith("export ") + expect(cmd).toContain("; git log") + expect(cmd).not.toContain("set ") + expect(cmd).not.toContain("&&") expect(cmd).not.toContain("$env:") }) - test("#given PowerShell #when values contain quotes #then escapes correctly", async () => { - process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" + test("#given Windows Git Bash environment #when git command executes #then uses unix export syntax", async () => { + // Simulating Git Bash on Windows: SHELL might be set to /usr/bin/bash + delete process.env.PSModulePath + process.env.SHELL = "/usr/bin/bash" Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) @@ -279,32 +292,16 @@ describe("non-interactive-env hook", () => { ) const cmd = output.args.command as string - expect(cmd).toMatch(/\$env:\w+='[^']*'/) + expect(cmd).toStartWith("export ") + expect(cmd).toContain("; git status") }) - test("#given cmd.exe #when values contain spaces #then escapes correctly", async () => { + test("#given any platform #when chained git commands via bash tool #then uses unix export syntax", async () => { + // Even on Windows, chained commands should use unix syntax delete process.env.PSModulePath delete process.env.SHELL Object.defineProperty(process, "platform", { value: "win32" }) - const hook = createNonInteractiveEnvHook(mockCtx) - const output: { args: Record; message?: string } = { - args: { command: "git status" }, - } - - await hook["tool.execute.before"]( - { tool: "bash", sessionID: "test", callID: "1" }, - output - ) - - const cmd = output.args.command as string - expect(cmd).toMatch(/set \w+="[^"]*"/) - }) - - test("#given PowerShell #when chained git commands #then env vars apply to all commands", async () => { - process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" - Object.defineProperty(process, "platform", { value: "win32" }) - const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; message?: string } = { args: { command: "git add file && git commit -m 'test'" }, @@ -316,7 +313,7 @@ describe("non-interactive-env hook", () => { ) const cmd = output.args.command as string - expect(cmd).toContain("$env:") + expect(cmd).toStartWith("export ") expect(cmd).toContain("; git add file && git commit") }) }) diff --git a/src/hooks/non-interactive-env/index.ts b/src/hooks/non-interactive-env/index.ts index 5c86fcef2..00c1e19c1 100644 --- a/src/hooks/non-interactive-env/index.ts +++ b/src/hooks/non-interactive-env/index.ts @@ -1,7 +1,8 @@ import type { PluginInput } from "@opencode-ai/plugin" +import type { ShellType } from "../../shared" import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants" import { isNonInteractive } from "./detector" -import { log, detectShellType, buildEnvPrefix } from "../../shared" +import { log, buildEnvPrefix } from "../../shared" export * from "./constants" export * from "./detector" @@ -50,7 +51,10 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) { return } - const shellType = detectShellType() + // The bash tool always runs in a Unix-like shell (bash/sh), even on Windows + // (via Git Bash, WSL, etc.), so we always use unix export syntax. + // This fixes GitHub issues #983 and #889. + const shellType: ShellType = "unix" const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType) output.args.command = `${envPrefix} ${command}`