Files
orca/src/main/git-bash.ts
T
Jinwoo Hong a05649de91 fix(terminal): prove an idle Git Bash prompt through its bin launcher (#22752)
* fix(terminal): prove an idle Git Bash prompt through its bin launcher

Git for Windows' bin\bash.exe is a launcher that runs usr\bin\bash.exe as a
child and waits, so an idle Git Bash pane's job always holds two pids and the
Windows shell proof never confirmed it. Accept exactly the launcher plus its
direct bash.exe child, checked against the identity process table.

* test(terminal): wait for the Git Bash prompt before asserting the hand-off job

* fix(terminal): prove a Git Bash prompt as one unbranched MSYS bash chain

Orca launches Git Bash as bin\bash.exe -c "chcp.com ...; exec \"$BASH\" ... -i",
and each MSYS exec leaves its pre-exec process alive as a stub, so an idle
pane's job is launcher -> stub -> interactive bash. Accept any job that is one
parent-to-child chain rooted at the launcher whose every later member is
bash.exe, instead of a fixed two-process shape.

* ci: register the Git Bash shell-proof win32 test in the package-test list

* fix(terminal): read the spawned shell as a path, and keep one shell map

A spawned shell path with a space (/Users/John Doe/bin/zsh) was split as a
command line, so the POSIX proof compared against "john" and never
confirmed. Local panes now keep only the spawned shell path and derive the
name from it; the Git Bash chain walk drops guards the member check already
covers.
2026-09-25 01:00:36 -04:00

162 lines
5.5 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)
}
/** Git for Windows' `bin\bash.exe` is a launcher: it runs `..\usr\bin\bash.exe` as a child and waits. */
export function isGitForWindowsBashLauncherPath(shellPath: string): boolean {
return /(?:^|\\)(?:git|portablegit)\\bin\\bash\.exe$/.test(
pathWin32.normalize(shellPath).toLowerCase()
)
}
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)
}