feat(codex): detect git bash on windows
Plan: plans/codex-windows-git-bash-profile.md
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveGitBash } from "./git-bash"
|
||||
|
||||
const PROGRAM_FILES_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\Git\\bin\\bash.exe"
|
||||
|
||||
describe("git-bash", () => {
|
||||
test("#given non-Windows platform #when resolving Git Bash #then no preflight is required", () => {
|
||||
// given / when
|
||||
const result = resolveGitBash({
|
||||
platform: "darwin",
|
||||
env: {},
|
||||
exists: () => false,
|
||||
where: () => [],
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ found: true, path: null, source: "not-required" })
|
||||
})
|
||||
|
||||
test("#given Windows env override to bash.exe #when the file exists #then env path wins", () => {
|
||||
// given
|
||||
const overridePath = "D:\\Tools\\Git\\bin\\bash.exe"
|
||||
|
||||
// when
|
||||
const result = resolveGitBash({
|
||||
platform: "win32",
|
||||
env: { OMO_CODEX_GIT_BASH_PATH: overridePath },
|
||||
exists: (path: string) => path === overridePath,
|
||||
where: () => [PROGRAM_FILES_GIT_BASH],
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ found: true, path: overridePath, source: "env" })
|
||||
})
|
||||
|
||||
test("#given Windows env override not pointing to bash.exe #when resolving #then reports invalid override and stops", () => {
|
||||
// given
|
||||
const overridePath = "D:\\Tools\\Git\\bin\\git.exe"
|
||||
|
||||
// when
|
||||
const result = resolveGitBash({
|
||||
platform: "win32",
|
||||
env: { OMO_CODEX_GIT_BASH_PATH: overridePath },
|
||||
exists: () => true,
|
||||
where: () => [PROGRAM_FILES_GIT_BASH],
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.found).toBe(false)
|
||||
if (result.found) return
|
||||
expect(result.checkedPaths).toContain(overridePath)
|
||||
expect(result.installHint).toContain("OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash.exe")
|
||||
})
|
||||
|
||||
test("#given Windows standard 64-bit Git Bash exists #when resolving #then uses Program Files path", () => {
|
||||
// given / when
|
||||
const result = resolveGitBash({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
exists: (path: string) => path === PROGRAM_FILES_GIT_BASH,
|
||||
where: () => [],
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ found: true, path: PROGRAM_FILES_GIT_BASH, source: "program-files" })
|
||||
})
|
||||
|
||||
test("#given Windows standard 32-bit Git Bash exists #when resolving #then uses Program Files x86 path", () => {
|
||||
// given / when
|
||||
const result = resolveGitBash({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
exists: (path: string) => path === PROGRAM_FILES_X86_GIT_BASH,
|
||||
where: () => [],
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ found: true, path: PROGRAM_FILES_X86_GIT_BASH, source: "program-files-x86" })
|
||||
})
|
||||
|
||||
test("#given Windows bash on PATH #when standard paths are missing #then uses where bash candidate", () => {
|
||||
// given
|
||||
const pathCandidate = "E:\\Git\\bin\\bash.exe"
|
||||
|
||||
// when
|
||||
const result = resolveGitBash({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
exists: (path: string) => path === pathCandidate,
|
||||
where: () => ["C:\\Windows\\System32\\bash.exe", pathCandidate],
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toEqual({ found: true, path: pathCandidate, source: "path" })
|
||||
})
|
||||
|
||||
test("#given Windows without Git Bash #when resolving #then returns install guidance", () => {
|
||||
// given / when
|
||||
const result = resolveGitBash({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
exists: () => false,
|
||||
where: () => [],
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.found).toBe(false)
|
||||
if (result.found) return
|
||||
expect(result.checkedPaths).toEqual([PROGRAM_FILES_GIT_BASH, PROGRAM_FILES_X86_GIT_BASH])
|
||||
expect(result.installHint).toContain("winget install --id Git.Git -e --source winget")
|
||||
expect(result.installHint).toContain("OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash.exe")
|
||||
expect(result.installHint).toContain("rerun `bunx omo install --platform=codex`")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
|
||||
const GIT_BASH_ENV_KEY = "OMO_CODEX_GIT_BASH_PATH"
|
||||
const PROGRAM_FILES_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\Git\\bin\\bash.exe"
|
||||
|
||||
export type GitBashSource = "not-required" | "env" | "program-files" | "program-files-x86" | "path"
|
||||
|
||||
export type GitBashResolution =
|
||||
| {
|
||||
readonly found: true
|
||||
readonly path: string | null
|
||||
readonly source: GitBashSource
|
||||
}
|
||||
| {
|
||||
readonly found: false
|
||||
readonly checkedPaths: readonly string[]
|
||||
readonly installHint: string
|
||||
}
|
||||
|
||||
export interface GitBashResolverInput {
|
||||
readonly platform: string
|
||||
readonly env: { readonly [key: string]: string | undefined }
|
||||
readonly exists: (path: string) => boolean
|
||||
readonly where: (command: "bash") => readonly string[]
|
||||
}
|
||||
|
||||
export function resolveGitBash(input: GitBashResolverInput): GitBashResolution {
|
||||
if (input.platform !== "win32") return { found: true, path: null, source: "not-required" }
|
||||
|
||||
const checkedPaths: string[] = []
|
||||
const envPath = nonEmptyEnvValue(input.env, GIT_BASH_ENV_KEY)
|
||||
if (envPath !== undefined) {
|
||||
checkedPaths.push(envPath)
|
||||
if (isBashExePath(envPath) && input.exists(envPath)) return { found: true, path: envPath, source: "env" }
|
||||
return missingGitBash(checkedPaths)
|
||||
}
|
||||
|
||||
for (const candidate of [
|
||||
{ path: PROGRAM_FILES_GIT_BASH, source: "program-files" },
|
||||
{ path: PROGRAM_FILES_X86_GIT_BASH, source: "program-files-x86" },
|
||||
] as const) {
|
||||
checkedPaths.push(candidate.path)
|
||||
if (input.exists(candidate.path)) return { found: true, path: candidate.path, source: candidate.source }
|
||||
}
|
||||
|
||||
for (const pathCandidate of input.where("bash")) {
|
||||
const candidate = pathCandidate.trim()
|
||||
if (candidate.length === 0) continue
|
||||
checkedPaths.push(candidate)
|
||||
if (isBashExePath(candidate) && input.exists(candidate)) return { found: true, path: candidate, source: "path" }
|
||||
}
|
||||
|
||||
return missingGitBash(checkedPaths)
|
||||
}
|
||||
|
||||
export function resolveGitBashForCurrentProcess(input: {
|
||||
readonly platform?: string
|
||||
readonly env?: { readonly [key: string]: string | undefined }
|
||||
} = {}): GitBashResolution {
|
||||
return resolveGitBash({
|
||||
platform: input.platform ?? process.platform,
|
||||
env: input.env ?? process.env,
|
||||
exists: existsSync,
|
||||
where: whereCommand,
|
||||
})
|
||||
}
|
||||
|
||||
function missingGitBash(checkedPaths: readonly string[]): GitBashResolution {
|
||||
return {
|
||||
found: false,
|
||||
checkedPaths,
|
||||
installHint: [
|
||||
"Git Bash is required for native Windows Codex profile installs.",
|
||||
"Install it with: winget install --id Git.Git -e --source winget",
|
||||
`For a custom install, set ${GIT_BASH_ENV_KEY}=C:\\path\\to\\bash.exe`,
|
||||
"Then rerun `bunx omo install --platform=codex`.",
|
||||
].join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyEnvValue(env: { readonly [key: string]: string | undefined }, key: string): string | undefined {
|
||||
const value = env[key]
|
||||
if (value === undefined) return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length === 0 ? undefined : trimmed
|
||||
}
|
||||
|
||||
function isBashExePath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith("bash.exe")
|
||||
}
|
||||
|
||||
function whereCommand(command: "bash"): readonly string[] {
|
||||
try {
|
||||
return execFileSync("where", [command], { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user