fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash

#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.
This commit is contained in:
OrcaWin
2026-08-03 22:36:31 -04:00
parent a69ca6d5b7
commit 1f37e5ac54
8 changed files with 243 additions and 21 deletions
+1
View File
@@ -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/*
+1
View File
@@ -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
+47
View File
@@ -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.
+13 -2
View File
@@ -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
+76 -11
View File
@@ -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 })
}
+21 -8
View File
@@ -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' }
}
}
+40
View File
@@ -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)
})
})
+44
View File
@@ -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$/, '') ?? ''
)
}