Files
orca/src/main/hooks.ts
T
OrcaWinandOrcaWin ed7849eb7b fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash (#12406)
* 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.

* fix(worktrees): keep the cmd setup runner launchable from a Git Bash pane

Adversarial review of this PR found that pinning the runner format per script
reopened issue #6896 one layer down.

- `WorktreeSetupLaunch.shell` had been redefined to mean "the format the runner
  file was written in". `resolveSetupRunnerCommand` consumes it as "the shell
  that types the launch command", so a Git Bash terminal with a batch setup
  script produced `cmd.exe /c "C:\...\setup-runner.cmd"` typed into a bash pane,
  where MSYS rewrites the `/c` switch into a drive path: cmd opens interactively
  and setup never runs. `shell` is the terminal's family again; the runner file's
  .cmd/.sh extension carries the format, and a batch runner launched from a POSIX
  pane reuses the existing PowerShell ProcessStartInfo launcher.
- The cmd runner dropped a leading `#!` line and ran the rest as batch, so a bash
  script reaching cmd (PowerShell/cmd terminal, or any SSH-to-Windows host) got
  its interpreter-agnostic prefix executed before failing mid-way. It now prints
  why and exits 1 without running anything.
- A `#!` line's option flags were discarded: `#!/usr/bin/env -S bash -euo
  pipefail` lost pipefail because the runner is launched as `bash <path>`. The
  generated posix runner now replays declared flags via `set` and drops the
  duplicate interpreter line.
- Docs cover the per-user setup command in repository hook settings, which goes
  through the same `#!` rule, and describe what the `#!` line does and does not
  select.

Tests: composed launch command for a POSIX pane + cmd runner (hooks, shared
runner command, setup sequencing gate, observed-setup signal), the cmd runner's
shebang refusal, and shebang flag replay. Each fails with the source reverted.

* fix(worktrees): replay only real `set` flags and keep the gate in the pane's shell

Two round-2 review findings:

- `#!/bin/bash -l` replayed `set -l`, which exits 2 and aborted the runner under
  its own `set -e` before a single setup line ran (all platforms). Only the flags
  `set` documents are replayed now; a bare `-o` with no option name is dropped
  instead of dumping the shell-option table.
- The wait-for-setup gate picked its language from the runner file, so a batch
  runner launched from a Git Bash pane got the PowerShell gate while the agent
  startup command was already POSIX-quoted — `Invoke-Expression` cannot parse
  `'\''`. The gate now follows the pane; the runner still launches through the
  ProcessStartInfo launcher, never through bash.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:50 -07:00

797 lines
28 KiB
TypeScript

/* eslint-disable max-lines -- Why: hook parsing, layered issue-command resolution, and cross-platform runner setup share one execution surface, so keeping them together avoids subtle drift across create/read/write paths. */
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { exec, execFile } from 'node:child_process'
import { getDefaultRepoHookSettings } from '../shared/constants'
import { getRuntimePathBasename } from '../shared/cross-platform-path'
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy'
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,
parseSetupScriptShebang,
scriptDeclaresPosixShell,
stripLeadingShebangLine
} from '../shared/setup-script-shebang'
import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell'
import { resolveWindowsGitBashShellPath } from './git-bash'
import { gitExecFileSync, promptGuardShellEnv } from './git/runner'
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
import { addWorktreeSetupWslInteropEnv } from './pty/wsl-orca-env'
import type {
HookCommandSourcePolicy,
OrcaHooks,
Repo,
SetupDecision,
SetupRunPolicy,
WorktreeDefaultTabsLaunch,
WorktreeSetupLaunch
} from '../shared/types'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
import type { SetupRunnerShell } from '../shared/setup-runner-command'
const HOOK_TIMEOUT = 120_000 // 2 minutes
export type HookRuntimeTarget = {
wslDistro?: string | null
}
type SetupRunnerShellSettings = Record<string, unknown> | undefined
function getHookShell(): string | undefined {
if (process.platform === 'win32') {
return process.env.ComSpec || 'cmd.exe'
}
return '/bin/bash'
}
export { parseOrcaYaml }
/**
* Load hooks from orca.yaml in the given repo root.
*/
export function loadHooks(repoPath: string): OrcaHooks | null {
const yamlPath = join(repoPath, 'orca.yaml')
if (!existsSync(yamlPath)) {
return null
}
try {
const content = readFileSync(yamlPath, 'utf-8')
return parseOrcaYaml(content)
} catch {
return null
}
}
/**
* Check whether an orca.yaml exists for a repo.
*/
export function hasHooksFile(repoPath: string): boolean {
return existsSync(join(repoPath, 'orca.yaml'))
}
// Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
'scripts',
'issueCommand',
'defaultTabs',
'environmentRecipes',
'worktree'
])
/** True when `orca.yaml` has a top-level key this version of Orca does not handle. */
export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
try {
const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8')
for (const line of iterateLfScriptLines(content)) {
// Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML.
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) {
return true
}
}
return false
} catch {
return false
}
}
// ─── Issue command files ────────────────────────────────────────────────
// Why: `.orca/issue-command` is the per-user override; `orca.yaml` is the tracked project default.
const ORCA_DIR = '.orca'
const ISSUE_COMMAND_FILENAME = 'issue-command'
export function getIssueCommandFilePath(repoPath: string): string {
return join(repoPath, ORCA_DIR, ISSUE_COMMAND_FILENAME)
}
export function getSharedIssueCommand(repoPath: string): string | null {
return loadHooks(repoPath)?.issueCommand?.trim() || null
}
export type ResolvedIssueCommand = {
localContent: string | null
sharedContent: string | null
effectiveContent: string | null
localFilePath: string
source: 'local' | 'shared' | 'none'
}
/**
* Resolve the GitHub issue command using local override first, then tracked repo config.
*/
export function readIssueCommand(repoPath: string): ResolvedIssueCommand {
const filePath = getIssueCommandFilePath(repoPath)
let localContent: string | null = null
if (existsSync(filePath)) {
try {
const content = readFileSync(filePath, 'utf-8').trim()
localContent = content || null
} catch {
localContent = null
}
}
const sharedContent = getSharedIssueCommand(repoPath)
const effectiveContent = localContent ?? sharedContent
return {
localContent,
sharedContent,
effectiveContent,
localFilePath: filePath,
source: localContent ? 'local' : sharedContent ? 'shared' : 'none'
}
}
/**
* Write the per-user issue command override to `{repoRoot}/.orca/issue-command`.
* Empty content deletes the override so the shared `orca.yaml` command applies again.
*/
export function writeIssueCommand(repoPath: string, content: string): void {
const filePath = getIssueCommandFilePath(repoPath)
const trimmed = content.trim()
try {
if (!trimmed) {
rmSync(filePath, { force: true })
return
}
const orcaDir = join(repoPath, ORCA_DIR)
if (!existsSync(orcaDir)) {
mkdirSync(orcaDir, { recursive: true })
}
ensureOrcaDirIgnored(repoPath)
writeFileSync(filePath, `${trimmed}\n`, 'utf-8')
} catch (err) {
console.error('[hooks] Failed to write issue command:', err)
// Why: re-throw so the IPC handler surfaces the write failure to the renderer's .catch().
throw err
}
}
/** Ensure `.orca` is in `.gitignore` so the per-user directory is never committed. */
function ensureOrcaDirIgnored(repoPath: string): void {
const gitignorePath = join(repoPath, '.gitignore')
try {
if (existsSync(gitignorePath)) {
const content = readFileSync(gitignorePath, 'utf-8')
if (/^\.orca\/?$/m.test(content)) {
return
}
const separator = content.endsWith('\n') ? '' : '\n'
writeFileSync(gitignorePath, `${content}${separator}.orca\n`, 'utf-8')
} else {
writeFileSync(gitignorePath, '.orca\n', 'utf-8')
}
} catch {
console.warn('[hooks] Could not update .gitignore to exclude .orca')
}
}
function getEffectiveHookScript(
yamlScript: string | undefined,
localScript: string | undefined,
policy: HookCommandSourcePolicy
): string | undefined {
const shared = yamlScript?.trim()
const local = localScript?.trim()
if (policy === 'local-only') {
return local || undefined
}
if (policy === 'run-both') {
return [shared, local].filter(Boolean).join('\n') || undefined
}
return shared || undefined
}
export function getEffectiveHooksFromConfig(
repo: Repo,
yamlHooks: OrcaHooks | null
): OrcaHooks | null {
const localSetup = repo.hookSettings?.scripts.setup
const localArchive = repo.hookSettings?.scripts.archive
const rawPolicy = repo.hookSettings?.commandSourcePolicy
const setupPolicy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localSetup?.trim())
})
const archivePolicy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localArchive?.trim())
})
const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, setupPolicy)
const archive = getEffectiveHookScript(yamlHooks?.scripts.archive, localArchive, archivePolicy)
if (!setup && !archive) {
return null
}
// Why: committed `orca.yaml` and local Settings can coexist; the source policy decides which is authoritative.
return {
scripts: {
...(setup ? { setup } : {}),
...(archive ? { archive } : {})
}
}
}
export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null {
const hooksRoot = worktreePath ?? repo.path
return getEffectiveHooksFromConfig(repo, loadHooks(hooksRoot))
}
export function getEffectiveSetupRunPolicy(repo: Repo): SetupRunPolicy {
return repo.hookSettings?.setupRunPolicy ?? getDefaultRepoHookSettings().setupRunPolicy!
}
export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'inherit'): boolean {
if (decision === 'run') {
return true
}
if (decision === 'skip') {
return false
}
const policy = getEffectiveSetupRunPolicy(repo)
if (policy === 'ask') {
throw new Error('Setup decision required for this repository')
}
return policy === 'run-by-default'
}
export function getDefaultTabCommandTrustContent(hooks: OrcaHooks | null): string {
const commands = (hooks?.defaultTabs ?? [])
.map((tab, index) => {
const command = tab.command?.trim()
if (!command) {
return null
}
const label = tab.title ? ` ${tab.title}` : ''
return `# defaultTabs[${index + 1}]${label}\n${command}`
})
.filter((entry): entry is string => entry !== null)
return [hooks?.scripts.setup?.trim(), ...commands].filter(Boolean).join('\n\n')
}
export function getDefaultTabsLaunch(
hooks: OrcaHooks | null,
repo: Repo,
decision: SetupDecision = 'inherit'
): WorktreeDefaultTabsLaunch | undefined {
const tabs = hooks?.defaultTabs ?? []
if (tabs.length === 0) {
return undefined
}
const hasCommands = tabs.some((tab) => Boolean(tab.command?.trim()))
const sharedCommandPolicy = resolveHookCommandSourcePolicy(
repo.hookSettings?.commandSourcePolicy,
{
hasLocalScript: Boolean(repo.hookSettings?.scripts.setup?.trim())
}
)
// Why: local-only repos may use shared tab titles/colors but must not run the committed orca.yaml commands.
const canRunSharedCommands = sharedCommandPolicy !== 'local-only'
const runCommands =
hasCommands && canRunSharedCommands ? shouldRunSetupForCreate(repo, decision) : false
return { tabs, runCommands }
}
export function getSetupCommandSource(
repo: Repo,
worktreePath?: string
): { source: 'yaml' | 'local' | 'both'; command: string } | null {
const hooksRoot = worktreePath ?? repo.path
const yamlHooks = loadHooks(hooksRoot)
const yamlSetup = yamlHooks?.scripts.setup?.trim()
const localSetup = repo.hookSettings?.scripts.setup?.trim()
const rawPolicy = repo.hookSettings?.commandSourcePolicy
const policy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localSetup)
})
if (policy === 'local-only') {
return localSetup ? { source: 'local', command: localSetup } : null
}
if (policy === 'run-both' && yamlSetup && localSetup) {
return { source: 'both', command: `${yamlSetup}\n${localSetup}` }
}
if (yamlSetup) {
return { source: 'yaml', command: yamlSetup }
}
return null
}
// Why: kept in sync with pty/wsl-orca-env.ts, which path-translates the same keys for WSL.
// ORCA_WORKSPACE_NAME is a display name and the credential-guard policy is an enum — never paths.
const SETUP_RUNNER_PATH_ENV_KEYS = [
'ORCA_ROOT_PATH',
'ORCA_WORKTREE_PATH',
'CONDUCTOR_ROOT_PATH',
'GHOSTX_ROOT_PATH'
] as const
function getSetupEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return {
ORCA_ROOT_PATH: repo.path,
ORCA_WORKTREE_PATH: worktreePath,
ORCA_WORKSPACE_NAME: getRuntimePathBasename(worktreePath),
// Compat with conductor.json users
CONDUCTOR_ROOT_PATH: repo.path,
GHOSTX_ROOT_PATH: repo.path
}
}
function getGitPath(cwd: string, relativePath: string, runtimeTarget?: HookRuntimeTarget): string {
return gitExecFileSync(['rev-parse', '--git-path', relativePath], {
cwd,
...(runtimeTarget?.wslDistro ? { wslDistro: runtimeTarget.wslDistro } : {})
}).trim()
}
function getHookRuntimeTarget(
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
): HookRuntimeTarget | undefined {
if (!projectRuntime) {
return undefined
}
if ('status' in projectRuntime) {
if (projectRuntime.status === 'repair-required') {
return projectRuntime.repair.preferredRuntime.kind === 'wsl'
? { wslDistro: projectRuntime.repair.preferredRuntime.distro }
: undefined
}
return projectRuntime.runtime.kind === 'wsl'
? { wslDistro: projectRuntime.runtime.distro }
: undefined
}
return projectRuntime.wslDistro ? { wslDistro: projectRuntime.wslDistro } : undefined
}
function getHookWslContext(
cwd: string,
runtimeTarget?: HookRuntimeTarget
): { distro: string | null; linuxPath: string } | null {
const pathInfo = parseWslPath(cwd)
if (pathInfo) {
return pathInfo
}
const wslDistro = runtimeTarget?.wslDistro?.trim()
if (!wslDistro) {
return null
}
// Why: project runtime can route a Windows checkout through WSL, so hooks need the Linux view of the path.
return {
distro: wslDistro,
linuxPath: toLinuxPath(cwd)
}
}
// Why: cmd cannot run a POSIX script, and executing its interpreter-agnostic prefix (`pnpm
// install`, `git submodule update`) before dying on the first bash-only line is worse than not
// starting: half-applied setup looks like a working worktree.
const WINDOWS_RUNNER_SHEBANG_REFUSAL = [
'echo Orca setup: this script starts with a "#!" interpreter line, so it needs a POSIX shell. 1>&2',
'echo Orca setup: this worktree runs setup through cmd.exe, which cannot execute it. 1>&2',
'echo Orca setup: set the Windows terminal shell to Git Bash, or rewrite the script in cmd syntax. 1>&2',
'exit /b 1',
''
].join('\r\n')
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.
const header = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n'
let runnerScript = header
let isFirstLine = true
for (const rawLine of iterateLfScriptLines(script)) {
const command = rawLine.trim()
if (isFirstLine) {
isFirstLine = false
if (isShebangLine(command)) {
return `${header}${WINDOWS_RUNNER_SHEBANG_REFUSAL}`
}
}
if (!command) {
runnerScript += '\r\n'
continue
}
// Why: npm/pnpm are Windows batch files; `call` each line and bail on errorlevel for set -e fail-fast behavior.
runnerScript += `call ${command}\r\nif errorlevel 1 exit /b %errorlevel%\r\n`
}
return runnerScript
}
function* iterateLfScriptLines(script: string): Generator<string> {
let lineStart = 0
for (let index = 0; index < script.length; index++) {
if (script.charCodeAt(index) !== 10) {
continue
}
const lineEnd = index > lineStart && script.charCodeAt(index - 1) === 13 ? index - 1 : index
yield script.slice(lineStart, lineEnd)
lineStart = index + 1
}
if (lineStart <= script.length) {
yield script.slice(lineStart)
}
}
export function createSetupRunnerScript(
repo: Repo,
worktreePath: string,
script: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
setupShell?: SetupRunnerShell
): WorktreeSetupLaunch {
return createWorktreeRunnerScript({
repo,
worktreePath,
script,
runnerBaseName: 'setup-runner',
runtimeTarget: getHookRuntimeTarget(projectRuntime),
waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup(
repo.hookSettings?.setupAgentStartupPolicy
),
setupShell
})
}
export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return {
...getSetupEnvVars(repo, worktreePath),
// Why: the Setup terminal is unattended automation, so force the credential guard regardless of user opt-out.
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard'
}
}
export function buildPosixRunnerScript(script: string): string {
// Why: the runner is always launched as `bash <path>`, so flags on the script's own `#!` line
// are never seen by the interpreter — replay them through `set` or a script that asked for
// `-euo pipefail` silently loses pipefail, and drop the now-duplicate interpreter line.
const shebang = parseSetupScriptShebang(script)
const declaredOptions = shebang?.shellOptions.length
? `set ${shebang.shellOptions.join(' ')}\n`
: ''
const body = shebang ? stripLeadingShebangLine(script) : script
return `#!/usr/bin/env bash\nset -e\n${declaredOptions}${normalizeCrlfScriptLineEndings(body)}\n`
}
function normalizeCrlfScriptLineEndings(script: string): string {
let crlfStart = script.indexOf('\r\n')
if (crlfStart === -1) {
return script
}
let normalized = script.slice(0, crlfStart)
let chunkStart = crlfStart + 2
normalized += '\n'
crlfStart = script.indexOf('\r\n', chunkStart)
while (crlfStart !== -1) {
normalized += script.slice(chunkStart, crlfStart)
normalized += '\n'
chunkStart = crlfStart + 2
crlfStart = script.indexOf('\r\n', chunkStart)
}
return `${normalized}${script.slice(chunkStart)}`
}
export function createIssueCommandRunnerScript(
repo: Repo,
worktreePath: string,
command: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
setupShell?: SetupRunnerShell
): WorktreeSetupLaunch {
// Why: writing long commands into a runner script avoids the PTY line editor wrapping/truncating them.
return createWorktreeRunnerScript({
repo,
worktreePath,
script: command,
runnerBaseName: 'issue-command-runner',
runtimeTarget: getHookRuntimeTarget(projectRuntime),
// Why: issue commands share the setup session's shell resolution; the runner still
// needs its own `#!` line to be treated as bash.
setupShell
})
}
function createWorktreeRunnerScript(args: {
repo: Repo
worktreePath: string
script: string
runnerBaseName: 'setup-runner' | 'issue-command-runner'
runtimeTarget?: HookRuntimeTarget
waitForAgentStartup?: boolean
setupShell?: SetupRunnerShell
}): WorktreeSetupLaunch {
const {
repo,
worktreePath,
script,
runnerBaseName,
runtimeTarget,
waitForAgentStartup,
setupShell
} = args
const envVars = getSetupRunnerEnvVars(repo, worktreePath)
// 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 === 'posix' && scriptDeclaresPosixShell(script)
? setupShell
: { family: 'cmd' }
: { family: 'posix' }
// Why: `shell` tells the launcher which shell types the command, not which format the runner
// file is in — the .cmd/.sh extension already carries that. Reporting the runner family here
// would make a Git Bash pane receive `cmd.exe /c ...`, whose `/c` MSYS rewrites into a drive
// path (issue #6896), so setup would open an interactive cmd and never run.
const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree
? (setupShell ?? { family: 'cmd' })
: process.platform === 'win32' && runtimeTarget?.wslDistro
? { family: 'posix', executable: 'wsl.exe' }
: undefined
// Why: linked worktrees use a `.git` file, so resolve the real per-worktree gitdir via git rev-parse --git-path.
const runnerExtension = runnerShell.family === 'cmd' ? 'cmd' : 'sh'
const gitRelPath = `orca/${runnerBaseName}.${runnerExtension}`
let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget)
// Why: git runs inside WSL and returns a Linux path; convert to a UNC path so the Windows fs calls can reach it.
if (wslWorktree) {
const wslInfo = getHookWslContext(worktreePath, runtimeTarget)
if (wslInfo?.distro) {
runnerScriptPath = toWindowsWslPath(runnerScriptPath.trim(), wslInfo.distro)
}
}
mkdirSync(dirname(runnerScriptPath), { recursive: true })
if (runnerShell.family === 'cmd') {
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8')
} else {
writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8')
if (!nativeWindowsWorktree) {
// Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL.
chmodSync(runnerScriptPath, 0o755)
}
}
// Why: setup script runs inside WSL bash, so translate the Windows UNC env-var paths to Linux paths.
if (wslWorktree) {
for (const key of Object.keys(envVars)) {
envVars[key] = toLinuxPath(envVars[key])
}
} else if (nativeWindowsWorktree && runnerShell.family === 'posix') {
// Why: a Git Bash runner already receives its own path as /c/..., and the shell exports HOME
// and PWD the same way, so leaving ORCA_* in C:\ form would make them the lone exception.
// Only path-valued keys convert; the workspace name and policy values are not paths.
for (const key of SETUP_RUNNER_PATH_ENV_KEYS) {
const value = envVars[key]
if (value) {
envVars[key] = nativeWindowsPathToPosixShellPath(value)
}
}
}
return {
runnerScriptPath,
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.
// On native Windows it is the terminal's family, i.e. the shell that types the command.
...(launchShell ? { shell: launchShell } : {}),
...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {})
}
}
export function resolveSetupRunnerShell(
settings: SetupRunnerShellSettings,
platform: NodeJS.Platform = process.platform,
options: { resolveGitBashShellPath?: (shell: string) => string | null } = {}
): SetupRunnerShell | undefined {
if (platform !== 'win32') {
return undefined
}
const terminalWindowsShell = settings?.terminalWindowsShell
const configuredShell =
typeof terminalWindowsShell === 'string' && terminalWindowsShell.trim()
? terminalWindowsShell.trim()
: 'powershell.exe'
const shellBasename = configuredShell.replaceAll('\\', '/').split('/').pop()?.toLowerCase()
const family = resolveWindowsShellStartupFamily(configuredShell)
if (family === 'posix' && shellBasename !== 'wsl.exe' && shellBasename !== 'wsl') {
// Why: the PTY resolves Git Bash independently and falls back to PowerShell when it
// is missing, so gate the .sh runner on the same resolution. This also keeps
// non-Git bash flavors (Cygwin's /cygdrive, the System32 WSL shim's /mnt) off the
// MSYS-only /c/... form the posix runner emits.
const resolveGitBashShellPath =
options.resolveGitBashShellPath ??
((shell: string) => resolveWindowsGitBashShellPath(shell, { platform }))
if (resolveGitBashShellPath(configuredShell)) {
// Note: this reports the terminal's family — the shell that types the launch command, and
// therefore also that a bash runner *could* launch here. Whether one is written is decided
// per script by its `#!` line — see docs/reference/windows-setup-shell.md.
return { family: 'posix' }
}
}
// Why: existing Windows setup scripts were authored for Orca's cmd runner;
// PowerShell, wsl.exe-as-terminal, and Windows-host projects can invoke it
// without changing syntax, so they intentionally stay on the cmd runner.
return { family: 'cmd' }
}
/**
* Run a named hook script in the given working directory.
*/
export function runHook(
hookName: 'setup' | 'archive',
cwd: string,
repo: Repo,
hooksPath?: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
): Promise<{ success: boolean; output: string }> {
const hooks = getEffectiveHooks(repo, hooksPath)
const script = hooks?.scripts[hookName]
if (!script) {
return Promise.resolve({ success: true, output: '' })
}
const runtimeTarget = getHookRuntimeTarget(projectRuntime)
const wslInfo = getHookWslContext(cwd, runtimeTarget)
if (wslInfo) {
// Why: use execFile to bypass cmd.exe, which mangles single-quote escaping of %, ^, &, |, etc.
const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
const escapedScript = script.replace(/'/g, "'\\''")
const bashCmd = `cd '${escapedCwd}' && ${escapedScript}`
// Why: hook scripts run inside WSL, so translate the ORCA_* Windows UNC paths to Linux paths.
const envVars = getSetupEnvVars(repo, cwd)
const wslEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(envVars)) {
wslEnv[key] = toLinuxPath(value)
}
const hookEnv: NodeJS.ProcessEnv = { ...process.env, ...wslEnv }
// Why: wsl.exe only imports Windows env vars named in WSLENV; without
// registering them the setup vars never reach the guest (#9206).
addWorktreeSetupWslInteropEnv(hookEnv)
return new Promise((resolve) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
const finish = (error: Error | null, stdout = '', stderr = ''): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (error) {
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message)
resolve({
success: false,
output: `${stdout}\n${stderr}\n${error.message}`.trim()
})
} else {
console.log(`[hooks] ${hookName} hook completed in ${cwd}`)
resolve({
success: true,
output: `${stdout}\n${stderr}`.trim()
})
}
}
// Why: execFile's timeout only signals wsl.exe; force-unblock after HOOK_TIMEOUT if no callback arrives.
const timeout = setTimeout(() => {
child?.kill()
finish(new Error(`Hook timed out after ${HOOK_TIMEOUT}ms.`))
}, HOOK_TIMEOUT)
try {
const distroArgs = wslInfo.distro ? ['-d', wslInfo.distro] : []
child = execFile(
'wsl.exe',
[...distroArgs, '--', 'bash', '-c', bashCmd],
{
timeout: HOOK_TIMEOUT,
encoding: 'utf-8',
// Why: same unattended-git guard as the non-WSL branch below
// (issue #7652) — WSL repos are the likeliest to hit the GCM
// popup, and the guard's WSLENV registration is what carries it
// across the wsl.exe boundary. Wrap hookEnv (not a fresh env) so
// the setup-var WSLENV entries registered above (#9206) are kept —
// promptGuardShellEnv appends its own keys to the existing WSLENV.
env: promptGuardShellEnv(hookEnv)
},
(error, stdout, stderr) => {
finish(error ?? null, stdout, stderr)
}
)
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
}
})
}
return new Promise((resolve) => {
exec(
script,
{
cwd,
timeout: HOOK_TIMEOUT,
shell: getHookShell(),
// Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652).
env: promptGuardShellEnv({
...process.env,
...getSetupEnvVars(repo, cwd)
})
},
(error, stdout, stderr) => {
if (error) {
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message)
resolve({
success: false,
output: `${stdout}\n${stderr}\n${error.message}`.trim()
})
} else {
console.log(`[hooks] ${hookName} hook completed in ${cwd}`)
resolve({
success: true,
output: `${stdout}\n${stderr}`.trim()
})
}
}
)
})
}