diff --git a/src/cli/install-codex/git-bash.test.ts b/src/cli/install-codex/git-bash.test.ts
new file mode 100644
index 000000000..5407052fd
--- /dev/null
+++ b/src/cli/install-codex/git-bash.test.ts
@@ -0,0 +1,118 @@
+///
+///
+
+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`")
+ })
+})
diff --git a/src/cli/install-codex/git-bash.ts b/src/cli/install-codex/git-bash.ts
new file mode 100644
index 000000000..f0d75af25
--- /dev/null
+++ b/src/cli/install-codex/git-bash.ts
@@ -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
+ }
+}