From 1f37e5ac544f2d49592bc4d1a9c94159e9decf09 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:36:31 -0400 Subject: [PATCH] fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6967 derived the Windows setup-runner shell from `terminalWindowsShell`. On upgrade, any Windows user whose terminal preference resolved to Git Bash had their existing `orca.yaml` setup script (and issue command) handed to bash instead of cmd.exe. Scripts authored against the cmd runner — `copy`, `xcopy`, `set VAR=value`, `if errorlevel 1`, `%VAR%`, backslash paths — broke with no migration and no warning, and the failure looked like Orca broke the project. The conflation is also wrong in the steady state: a terminal preference is per-user, so two people on the same repo got different interpreters for the same orca.yaml and no project could write a setup script that worked for all of its Windows contributors. The interpreter is now a property of the script, declared the standard way: a leading `#!` line. Native Windows keeps the historical `.cmd` runner unless the script declares a POSIX shell, so no existing script changes behavior. `resolveSetupRunnerShell` keeps its role as the feasibility gate — a bash runner still requires the terminal to resolve to Git Bash, since the launch command is typed into that shell and uses MSYS `/c/...` paths. `buildWindowsRunnerScript` now drops a leading `#!` line rather than `call`ing it, so a declared-bash script that falls back to cmd (Git Bash missing) fails on a real setup line instead of aborting on errorlevel at line one. WSL worktrees, POSIX platforms, and SSH hosts are untouched. --- .gitignore | 1 + AGENTS.md | 1 + docs/reference/windows-setup-shell.md | 47 +++++++++++++ src/main/hooks-runner.test.ts | 15 ++++- src/main/hooks.test.ts | 87 +++++++++++++++++++++---- src/main/hooks.ts | 29 ++++++--- src/shared/setup-script-shebang.test.ts | 40 ++++++++++++ src/shared/setup-script-shebang.ts | 44 +++++++++++++ 8 files changed, 243 insertions(+), 21 deletions(-) create mode 100644 docs/reference/windows-setup-shell.md create mode 100644 src/shared/setup-script-shebang.test.ts create mode 100644 src/shared/setup-script-shebang.ts diff --git a/.gitignore b/.gitignore index 2918ae32a69..b1de9292f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ docs/** !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md !docs/reference/relay-grace-time-reconfiguration.md +!docs/reference/windows-setup-shell.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/AGENTS.md b/AGENTS.md index bfb1617f8ea..9598089df08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. +- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line. Never derive it from the user's terminal-shell preference. See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. ## SSH Use Case diff --git a/docs/reference/windows-setup-shell.md b/docs/reference/windows-setup-shell.md new file mode 100644 index 00000000000..2d465180a8e --- /dev/null +++ b/docs/reference/windows-setup-shell.md @@ -0,0 +1,47 @@ +# Windows setup-runner shell + +On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated +runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by +default**, exactly as it has been since setup hooks shipped. + +A script opts into bash by starting with a `#!` interpreter line: + +```yaml +scripts: + setup: | + #!/usr/bin/env bash + [ -f .env ] || cp .env.example .env + pnpm install +``` + +Without that line the script keeps running under `cmd.exe`: + +```yaml +scripts: + setup: | + copy .env.example .env + xcopy /E assets dist +``` + +## Why the script declares it, not the terminal preference + +`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the +language a project's setup script is written in. Deriving the runner from it had two consequences: + +- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`, + `xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working. +- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project + could write a setup script that worked for all of its Windows contributors. + +A `#!` line is per-project, explicit, and identical for everyone who checks the repo out. + +## Requirements for the bash runner + +A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the +terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner +uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is +typed into whatever shell the terminal opened with. When Git Bash is not available the script falls +back to the `.cmd` runner; the `#!` line is dropped rather than executed. + +WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts +choose their runner from the remote path format, never from local Windows preferences. diff --git a/src/main/hooks-runner.test.ts b/src/main/hooks-runner.test.ts index 79827207aba..fbf0bfe8567 100644 --- a/src/main/hooks-runner.test.ts +++ b/src/main/hooks-runner.test.ts @@ -94,7 +94,7 @@ describe('createSetupRunnerScript', () => { const result = createSetupRunnerScript( { ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' }, 'C:\\repo\\feature', - 'pnpm install', + '#!/usr/bin/env bash\npnpm install', undefined, { family: 'posix' } ) @@ -113,7 +113,7 @@ describe('createSetupRunnerScript', () => { }) expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', - '#!/usr/bin/env bash\nset -e\npnpm install\n', + '#!/usr/bin/env bash\nset -e\n#!/usr/bin/env bash\npnpm install\n', 'utf-8' ) // Why: chmod over a native Windows path is meaningless; only the WSL branch sets the bit. @@ -186,6 +186,17 @@ describe('createSetupRunnerScript', () => { expect(runner).toContain('call echo hello!world!') }) + it('drops a leading shebang instead of calling it as a batch command', async () => { + const { buildWindowsRunnerScript } = await import('./hooks') + + // Why: a shebang script falls back here when Git Bash is unavailable; `call #!/...` + // would abort on errorlevel before any real setup line ran. + const runner = buildWindowsRunnerScript('#!/usr/bin/env bash\npnpm install') + + expect(runner).not.toContain('#!') + expect(runner).toContain('call pnpm install') + }) + it('derives ORCA_WORKSPACE_NAME from a POSIX worktree path', async () => { const originalPlatform = process.platform diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index b51a38e3321..794f82dfeec 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1255,7 +1255,7 @@ describe('createSetupRunnerScript', () => { } }) as unknown as Repo - it('writes POSIX setup runners for Git Bash on native Windows paths', async () => { + it('writes POSIX setup runners for shebang-declared scripts on native Windows paths', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n') const fs = await import('node:fs') @@ -1271,7 +1271,7 @@ describe('createSetupRunnerScript', () => { const result = createSetupRunnerScript( makeRepo(), 'C:\\repo-worktree', - 'pnpm install\r\nnpm run build', + '#!/usr/bin/env bash\r\npnpm install\r\nnpm run build', undefined, { family: 'posix' } ) @@ -1282,7 +1282,7 @@ describe('createSetupRunnerScript', () => { ) expect(writeFileSyncMock).toHaveBeenCalledWith( 'C:\\repo\\.git\\orca\\setup-runner.sh', - '#!/usr/bin/env bash\nset -e\npnpm install\nnpm run build\n', + '#!/usr/bin/env bash\nset -e\n#!/usr/bin/env bash\npnpm install\nnpm run build\n', 'utf-8' ) expect(chmodSyncMock).not.toHaveBeenCalled() @@ -1295,6 +1295,45 @@ describe('createSetupRunnerScript', () => { } }) + it('keeps batch setup scripts on cmd.exe when the terminal is Git Bash', async () => { + // Regression (#6967): a Git Bash terminal preference used to hand pre-existing + // batch setup scripts to bash, where `copy`/`xcopy`/`if errorlevel` do not exist. + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'copy .env.example .env\r\nxcopy /E assets dist', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.cmd', + expect.stringContaining('call copy .env.example .env\r\n'), + 'utf-8' + ) + expect(result).toMatchObject({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + shell: { family: 'cmd' } + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('preserves cmd.exe setup runner semantics for configured cmd users', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') @@ -1409,7 +1448,7 @@ describe('createIssueCommandRunnerScript', () => { hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } } }) as unknown as Repo - it('writes a POSIX issue-command runner when setup resolves to Git Bash', async () => { + it('writes a POSIX issue-command runner when a shebang declares bash and setup resolves to Git Bash', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.sh\n') const fs = await import('node:fs') @@ -1418,6 +1457,37 @@ describe('createIssueCommandRunnerScript', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + '#!/usr/bin/env bash\ngh issue view 42', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/issue-command-runner.sh'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\issue-command-runner.sh', + '#!/usr/bin/env bash\nset -e\n#!/usr/bin/env bash\ngh issue view 42\n', + 'utf-8' + ) + expect(result.shell).toEqual({ family: 'posix' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps a plain issue command on the cmd runner under a Git Bash terminal', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd\n') + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { const { createIssueCommandRunnerScript } = await import('./hooks') const result = createIssueCommandRunnerScript( @@ -1429,15 +1499,10 @@ describe('createIssueCommandRunnerScript', () => { ) expect(gitExecFileSyncMock).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/issue-command-runner.sh'], + ['rev-parse', '--git-path', 'orca/issue-command-runner.cmd'], { cwd: 'C:\\repo-worktree' } ) - expect(writeFileSyncMock).toHaveBeenCalledWith( - 'C:\\repo\\.git\\orca\\issue-command-runner.sh', - '#!/usr/bin/env bash\nset -e\ngh issue view 42\n', - 'utf-8' - ) - expect(result.shell).toEqual({ family: 'posix' }) + expect(result.shell).toEqual({ family: 'cmd' }) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } diff --git a/src/main/hooks.ts b/src/main/hooks.ts index ad73003778f..ab1c61ad106 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -9,6 +9,7 @@ import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-star import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' import { parseOrcaYaml } from '../shared/orca-yaml' import { nativeWindowsPathToPosixShellPath } from '../shared/setup-runner-command' +import { isShebangLine, scriptDeclaresPosixShell } from '../shared/setup-script-shebang' import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from './git-bash' import { gitExecFileSync, promptGuardShellEnv } from './git/runner' @@ -401,9 +402,17 @@ export function buildWindowsRunnerScript(script: string): string { // Why: launchers invoke this runner under `cmd /v:on`, and EnableExtensions does not reset an // inherited delayed-expansion state — without this, every `!` in a user setup line is eaten. let runnerScript = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n' + let isFirstLine = true for (const rawLine of iterateLfScriptLines(script)) { const command = rawLine.trim() + // Why: a leading `#!` declares an interpreter; `call`ing it would abort the run on errorlevel. + if (isFirstLine) { + isFirstLine = false + if (isShebangLine(command)) { + continue + } + } if (!command) { runnerScript += '\r\n' continue @@ -500,8 +509,8 @@ export function createIssueCommandRunnerScript( script: command, runnerBaseName: 'issue-command-runner', runtimeTarget: getHookRuntimeTarget(projectRuntime), - // Why: issue commands run in the same terminal as setup, so a Git Bash setup - // runner must not be paired with a cmd issue runner in one session. + // Why: issue commands share the setup session's shell resolution; the runner still + // needs its own `#!` line to be treated as bash. setupShell }) } @@ -528,8 +537,14 @@ function createWorktreeRunnerScript(args: { // Why: WSL worktrees are Linux fs even though process.platform is 'win32'; use bash for WSL, .cmd for native Windows. const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro) const nativeWindowsWorktree = process.platform === 'win32' && !wslWorktree + // Why: the terminal-shell preference says nothing about the language a project's script is + // written in, and every pre-existing Windows script was authored against the cmd runner. Only a + // `#!` line opts a script into bash, so the same orca.yaml runs identically for every Windows + // user of the repo instead of following whichever terminal each of them happens to prefer. const runnerShell: SetupRunnerShell = nativeWindowsWorktree - ? (setupShell ?? { family: 'cmd' }) + ? setupShell?.family === 'posix' && scriptDeclaresPosixShell(script) + ? setupShell + : { family: 'cmd' } : { family: 'posix' } const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree ? runnerShell @@ -583,8 +598,7 @@ function createWorktreeRunnerScript(args: { envVars, // Why: WSL git returns /mnt paths that Node converts back to C:\ for file // writes; retain the runtime signal so launch converts them to /mnt again. - // Issue-command runners take the same resolved shell, so one session never - // mixes a bash setup runner with a cmd issue runner. + // On native Windows it mirrors the family the runner file was written in. ...(launchShell ? { shell: launchShell } : {}), ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {}) } @@ -615,9 +629,8 @@ export function resolveSetupRunnerShell( options.resolveGitBashShellPath ?? ((shell: string) => resolveWindowsGitBashShellPath(shell, { platform })) if (resolveGitBashShellPath(configuredShell)) { - // Note: Git Bash users with batch-syntax orca.yaml setup content get a bash - // interpreter from here on. The flip is intentional and documented in - // docs/reference/windows-setup-shell.md. + // Note: this only reports that a bash runner *could* launch here. Whether one is + // used is decided per script by its `#!` line — see docs/reference/windows-setup-shell.md. return { family: 'posix' } } } diff --git a/src/shared/setup-script-shebang.test.ts b/src/shared/setup-script-shebang.test.ts new file mode 100644 index 00000000000..5d75deb39c9 --- /dev/null +++ b/src/shared/setup-script-shebang.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { isShebangLine, scriptDeclaresPosixShell } from './setup-script-shebang' + +describe('scriptDeclaresPosixShell', () => { + it('accepts the common env and absolute-path forms', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env bash\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!/bin/sh -e\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!/usr/bin/env -S bash -euo pipefail\npnpm install')).toBe( + true + ) + expect(scriptDeclaresPosixShell('#!/bin/zsh')).toBe(true) + }) + + it('rejects scripts with no interpreter line', () => { + // Regression: batch-syntax setup scripts must stay on the cmd runner. + expect(scriptDeclaresPosixShell('copy .env.example .env\nxcopy /E assets dist')).toBe(false) + expect(scriptDeclaresPosixShell('')).toBe(false) + expect(scriptDeclaresPosixShell('pnpm install\n#!/usr/bin/env bash')).toBe(false) + expect(scriptDeclaresPosixShell('# !/usr/bin/env bash\npnpm install')).toBe(false) + }) + + it('rejects interpreters that are not POSIX shells', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env node\nconsole.log(1)')).toBe(false) + expect(scriptDeclaresPosixShell('#!/usr/bin/env python3\nprint(1)')).toBe(false) + }) + + it('tolerates CRLF and Windows-style interpreter paths', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env bash\r\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!C:\\tools\\git\\bin\\bash.exe\r\npnpm install')).toBe(true) + }) +}) + +describe('isShebangLine', () => { + it('detects interpreter lines regardless of leading whitespace', () => { + expect(isShebangLine('#!/usr/bin/env bash')).toBe(true) + expect(isShebangLine(' #!/bin/sh')).toBe(true) + expect(isShebangLine('#comment')).toBe(false) + expect(isShebangLine('pnpm install')).toBe(false) + }) +}) diff --git a/src/shared/setup-script-shebang.ts b/src/shared/setup-script-shebang.ts new file mode 100644 index 00000000000..7b02594227b --- /dev/null +++ b/src/shared/setup-script-shebang.ts @@ -0,0 +1,44 @@ +// Why: the interpreter a setup/issue-command script is written for is a property of the script, +// not of the user's terminal preference, so a `#!` line is how a project declares it. + +const POSIX_SHELL_BASENAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash']) + +/** True when `line` is a `#!` interpreter line (leading whitespace tolerated). */ +export function isShebangLine(line: string): boolean { + return line.trimStart().startsWith('#!') +} + +/** True when the script's first line is a `#!` line naming a POSIX shell. */ +export function scriptDeclaresPosixShell(script: string): boolean { + const firstLine = script.split('\n', 1)[0] ?? '' + if (!isShebangLine(firstLine)) { + return false + } + + const tokens = firstLine.trim().slice(2).trim().split(/\s+/).filter(Boolean) + const interpreter = shebangInterpreterBasename(tokens) + return interpreter !== null && POSIX_SHELL_BASENAMES.has(interpreter) +} + +function shebangInterpreterBasename(tokens: string[]): string | null { + for (let index = 0; index < tokens.length; index++) { + const basename = executableBasename(tokens[index]) + // Why: `env` (and its `-S` split-string form) only forwards to the real interpreter. + if (basename === 'env' || basename.startsWith('-')) { + continue + } + return basename || null + } + return null +} + +function executableBasename(token: string): string { + return ( + token + .replaceAll('\\', '/') + .split('/') + .pop() + ?.toLowerCase() + .replace(/\.exe$/, '') ?? '' + ) +}