mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* Honor configured shells during worktree setup
* Align setup launch paths with selected Windows shells
* Carry setup shell selection through deferred launches
* Prove Windows setup shell routing at its real adapters
* Ground remote PowerShell proof in the real writer
* Preserve Git Bash across deferred setup launches
* Harden Windows setup runner shell selection
- Resolve remote PowerShell binary without local pwsh probe: for SSH/remote
Windows worktrees, isPwshAvailable() reflects only the LOCAL host, so an
'auto' implementation could route the remote runner to a pwsh.exe the remote
lacks. Add resolveSetupRunnerShell(..., { probeLocalPwsh: false }) so remote
auto keeps the always-present powershell.exe; explicit pwsh.exe still honored.
- Preserve native exit codes in the PowerShell runner by checking
$LASTEXITCODE before $?, so a failing native command surfaces its real code
instead of a generic exit 1; $? still catches cmdlet soft-failures.
- Write the PowerShell runner with a UTF-8 BOM so Windows PowerShell 5.1 (the
new default powershell.exe) reads it as UTF-8 instead of ANSI, preventing
non-ASCII setup-script corruption.
- Add unit tests for the remote-probe behavior.
* Restore setup-shell scope narrowing over the rebase
The force-pushed rebase dropped five review-fix commits that were already
on this branch; this reapplies their combined effect on top of the new
base and the hardening commit:
- Keep SSH setup shell selection remote-owned (no local terminalWindowsShell
or pwsh routing for remote hosts; supersedes the probeLocalPwsh guard)
- Preserve cmd setup compatibility outside POSIX shells (no .ps1 runner
family, so the BOM/exit-code hardening is no longer applicable)
- Route WSL setup runners from the project runtime
- Avoid blocking PowerShell probes during setup creation
- Correct SSH and WSL background setup fixtures
* Satisfy the changed-code gates for the setup-shell runner
- createWorktreeRunnerScript took 7 positional parameters, tripping the
changed-code max-params gate; move it to a single options object.
- hooks-runner.test.ts deep-equals the createSetupRunnerScript result, so
assert the cmd shell now returned for native Windows worktrees.
* Carry the setup launch shell through observed and issue runners
- buildObservedSetupCommand takes the runner's launch shell so WSL-routed
Windows-drive setup replays use /mnt/c instead of Git Bash /c
- resolveSetupRunnerShell gates the posix runner on the same Git Bash
resolution the PTY uses, so a missing or non-MSYS bash keeps the cmd runner
- issue-command runners carry their launch shell, and the renderer passes it
when building the queued command
- treat a bare `bash` shell setting as POSIX like `bash.exe`
Co-authored-by: Orca <help@stably.ai>
* fix(worktrees): close counsel P1 gaps for Windows setup shells
Route windowless/headless creates through the shell-aware setup runner when a
PTY controller is available, existence-check explicit Git Bash paths before
committing to .sh runners, thread the resolved shell into issue-command
runners, and document the intentional Git Bash interpreter flip with a narrow
scope table.
* Convert setup env to MSYS form and harden the bare cmd runner launch
C3: a Git Bash setup runner now receives ORCA_*/CONDUCTOR_*/GHOSTX_* path
values in /c/... form, matching the runner path and the shell's own HOME/PWD.
C5: extension-less `bash` resolves to Git Bash everywhere, matching how
resolveWindowsShellStartupFamily already classifies it.
C7: runner paths carrying characters that cannot be quoted on a cmd command
line launch through a delayed-expansion PowerShell shim instead, and the batch
runner disables inherited delayed expansion so `!` in setup lines survives.
Co-authored-by: Orca <help@stably.ai>
* docs: note MSYS ORCA_* paths and bare bash Git Bash resolution
Keep the setup-shell release note aligned with C3 env conversion and C5 bare
bash resolution so the published claim matches runtime behavior.
* revert: drop windows-setup-shell doc allowlist and AGENTS link
Keep the counsel P1/P2 product fixes without expanding the docs allowlist
or AGENTS.md guidance surface.
* fix(plugins): contain Parcel unsubscribe rejections under Vitest
Dev plugin watchers fire-and-forget unsubscribe, and in-process Parcel
can reject when temp watch roots are already deleted. Catch those
rejections so they cannot fail the suite as unhandled errors.
* fix(plugins): keep in-process unsubscribe rejection surface
Swallowing Parcel unsubscribe errors broke mocked unsubscribe tests
that return non-Promises and expect rejections. Contain failures only
in PluginDevWatcher fire-and-forget paths.
---------
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
import { win32 as pathWin32 } from 'node:path'
|
|
import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell'
|
|
|
|
type GitBashPathOptions = {
|
|
env?: NodeJS.ProcessEnv
|
|
exists?: (path: string) => boolean
|
|
platform?: NodeJS.Platform
|
|
}
|
|
|
|
function readEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined {
|
|
for (const name of names) {
|
|
const value = env[name]
|
|
if (value) {
|
|
return value
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function normalizePathSegment(segment: string): string {
|
|
const trimmed = segment.trim()
|
|
return trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed
|
|
}
|
|
|
|
function pushCandidate(
|
|
candidates: string[],
|
|
seen: Set<string>,
|
|
candidate: string | undefined
|
|
): void {
|
|
if (!candidate) {
|
|
return
|
|
}
|
|
const normalized = pathWin32.normalize(candidate)
|
|
const key = normalized.toLowerCase()
|
|
if (!seen.has(key)) {
|
|
seen.add(key)
|
|
candidates.push(normalized)
|
|
}
|
|
}
|
|
|
|
export function getGitBashCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
const candidates: string[] = []
|
|
const seen = new Set<string>()
|
|
const roots = [
|
|
readEnv(env, ['ProgramFiles', 'PROGRAMFILES']),
|
|
readEnv(env, ['ProgramW6432', 'PROGRAMW6432']),
|
|
readEnv(env, ['ProgramFiles(x86)', 'PROGRAMFILES(X86)']),
|
|
readEnv(env, ['LOCALAPPDATA', 'LocalAppData'])
|
|
]
|
|
|
|
for (const root of roots) {
|
|
if (!root) {
|
|
continue
|
|
}
|
|
pushCandidate(candidates, seen, pathWin32.join(root, 'Git', 'bin', 'bash.exe'))
|
|
pushCandidate(candidates, seen, pathWin32.join(root, 'Git', 'usr', 'bin', 'bash.exe'))
|
|
pushCandidate(candidates, seen, pathWin32.join(root, 'Programs', 'Git', 'bin', 'bash.exe'))
|
|
pushCandidate(
|
|
candidates,
|
|
seen,
|
|
pathWin32.join(root, 'Programs', 'Git', 'usr', 'bin', 'bash.exe')
|
|
)
|
|
}
|
|
|
|
const pathValue = readEnv(env, ['Path', 'PATH'])
|
|
if (pathValue) {
|
|
for (const rawSegment of pathValue.split(pathWin32.delimiter)) {
|
|
const segment = normalizePathSegment(rawSegment)
|
|
if (!segment) {
|
|
continue
|
|
}
|
|
const directBashCandidate = pathWin32.join(segment, 'bash.exe')
|
|
if (isGitForWindowsBashPath(directBashCandidate)) {
|
|
pushCandidate(candidates, seen, directBashCandidate)
|
|
}
|
|
|
|
const basename = pathWin32.basename(segment).toLowerCase()
|
|
const parent = pathWin32.dirname(segment)
|
|
const parentBasename = pathWin32.basename(parent).toLowerCase()
|
|
if (basename === 'cmd' && (parentBasename === 'git' || parentBasename === 'portablegit')) {
|
|
pushCandidate(candidates, seen, pathWin32.join(parent, 'bin', 'bash.exe'))
|
|
pushCandidate(candidates, seen, pathWin32.join(parent, 'usr', 'bin', 'bash.exe'))
|
|
} else if (basename === 'git' || basename === 'portablegit') {
|
|
pushCandidate(candidates, seen, pathWin32.join(segment, 'bin', 'bash.exe'))
|
|
pushCandidate(candidates, seen, pathWin32.join(segment, 'usr', 'bin', 'bash.exe'))
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
export function resolveGitBashPath(options: GitBashPathOptions = {}): string | null {
|
|
const platform = options.platform ?? process.platform
|
|
if (platform !== 'win32') {
|
|
return null
|
|
}
|
|
const exists = options.exists ?? existsSync
|
|
for (const candidate of getGitBashCandidatePaths(options.env ?? process.env)) {
|
|
if (isGitForWindowsBashPath(candidate) && exists(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function isGitBashAvailable(): boolean {
|
|
return resolveGitBashPath() !== null
|
|
}
|
|
|
|
export function isGitForWindowsBashPath(shellPath: string): boolean {
|
|
const normalized = pathWin32.normalize(shellPath).toLowerCase()
|
|
return /(?:^|\\)(?:git|portablegit)(?:\\usr)?\\bin\\bash\.exe$/.test(normalized)
|
|
}
|
|
|
|
export function resolveWindowsGitBashShellPath(
|
|
shell: string,
|
|
options: GitBashPathOptions = {}
|
|
): string | null {
|
|
const trimmed = shell.trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
if (trimmed === WINDOWS_GIT_BASH_SHELL) {
|
|
return resolveGitBashPath(options)
|
|
}
|
|
|
|
// Why: resolveWindowsShellStartupFamily classifies extension-less `bash` as POSIX too, so both
|
|
// spellings must resolve here or setup/PTY shell selection disagrees with the quoting family.
|
|
const shellBasename = pathWin32.basename(trimmed).toLowerCase()
|
|
if (shellBasename !== 'bash.exe' && shellBasename !== 'bash') {
|
|
return null
|
|
}
|
|
|
|
if (pathWin32.isAbsolute(trimmed) || trimmed.includes('\\') || trimmed.includes('/')) {
|
|
// Why: an uninstalled/stale configured path must resolve to null like the discovery
|
|
// branch above, so setup does not commit to a bash the PTY will never spawn.
|
|
const exists = options.exists ?? existsSync
|
|
if (shellBasename === 'bash') {
|
|
// Why: Git for Windows ships only bash.exe, so an extension-less path is a request for it.
|
|
// This branch synthesizes a path the user never typed, so it must confirm the file is there.
|
|
const candidate = `${trimmed}.exe`
|
|
return isGitForWindowsBashPath(candidate) && exists(candidate) ? candidate : null
|
|
}
|
|
return isGitForWindowsBashPath(trimmed) && exists(trimmed) ? trimmed : null
|
|
}
|
|
|
|
return resolveGitBashPath(options)
|
|
}
|
|
|
|
export function isWindowsGitBashShellPath(shellPath: string): boolean {
|
|
return isGitForWindowsBashPath(shellPath)
|
|
}
|