diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 87fab04a98a..80c790eccb4 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -339,7 +339,7 @@ const layer = Layer.effect( ) { const result = yield* proc .run( - ChildProcess.make(gitExecutable, repositoryArgs(repository, args), { + ChildProcess.make(gitExecutable(), repositoryArgs(repository, args), { cwd: repository.worktree, env: options?.index ? { ...options.env, GIT_INDEX_FILE: options.index } : options?.env, extendEnv: true, @@ -549,7 +549,7 @@ const layer = Layer.effect( const result = yield* proc .run( ChildProcess.make( - gitExecutable, + gitExecutable(), repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), { cwd: input.repository.worktree, @@ -1227,7 +1227,7 @@ const layer = Layer.effect( cwd = repository.worktree, ) { const result = yield* proc - .run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" })) + .run(ChildProcess.make(gitExecutable(), args, { cwd, extendEnv: true, stdin: "ignore" })) .pipe( Effect.mapError( (cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }), @@ -1324,7 +1324,7 @@ function run(cwd: string, proc: AppProcess.Interface, args: string[]) { function execute(cwd: string, proc: AppProcess.Interface, args: string[]) { return proc .run( - ChildProcess.make(gitExecutable, args, { + ChildProcess.make(gitExecutable(), args, { cwd, extendEnv: true, stdin: "ignore", diff --git a/packages/core/src/plugin/vcs/git.ts b/packages/core/src/plugin/vcs/git.ts index d44ba281352..f76c15b131e 100644 --- a/packages/core/src/plugin/vcs/git.ts +++ b/packages/core/src/plugin/vcs/git.ts @@ -44,7 +44,7 @@ export const Plugin = define({ init: (input) => Effect.gen(function* () { const result = yield* processes.run( - ChildProcess.make(gitExecutable, ["init"], { cwd: input.worktree, stdin: "ignore" }), + ChildProcess.make(gitExecutable(), ["init"], { cwd: input.worktree, stdin: "ignore" }), ) if (result.exitCode !== 0) return yield* Effect.fail(new Error("Git initialization failed")) }), @@ -209,7 +209,7 @@ function makeGit(proc: AppProcess.Interface, env?: Record) { const run = Effect.fnUntraced( function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) { const result = yield* proc.run( - ChildProcess.make(gitExecutable, [...cfg, ...args], { + ChildProcess.make(gitExecutable(), [...cfg, ...args], { cwd: opts.cwd, env, extendEnv: true, diff --git a/packages/core/src/util/git-executable.ts b/packages/core/src/util/git-executable.ts index 7c932bbc1d7..1a9cf9228a7 100644 --- a/packages/core/src/util/git-executable.ts +++ b/packages/core/src/util/git-executable.ts @@ -1,11 +1,59 @@ +import { existsSync, realpathSync } from "node:fs" import path from "path" +import { lazy } from "./lazy.js" import { which } from "./which.js" -export function resolveGitExecutable(platform: NodeJS.Platform, resolved: string | null) { - if (platform !== "win32" || !resolved) return "git" - return path.resolve(resolved) +const darwinShim = "/usr/bin/git" +const xcodeSelectLink = "/var/db/xcode_select_link" + +const defaultDeveloperDirs = [ + "/Applications/Xcode.app/Contents/Developer", + "/Library/Developer/CommandLineTools", +] + +function readXcodeSelectLink(link: string) { + if (!existsSync(link)) return null + try { + return realpathSync(link) + } catch { + return null + } } -const resolved = process.platform === "win32" ? which("git") : null +export function darwinDeveloperDirs( + developerDir = process.env.DEVELOPER_DIR?.trim(), + readLink: (link: string) => string | null = readXcodeSelectLink, +) { + if (developerDir) return [developerDir] + const selected = readLink(xcodeSelectLink) + return selected ? [selected, ...defaultDeveloperDirs] : defaultDeveloperDirs +} -export const gitExecutable = resolveGitExecutable(process.platform, resolved) +export function resolveGitExecutable( + platform: NodeJS.Platform, + resolved: string | null, + options?: { + developerDirs?: readonly string[] + exists?: (file: string) => boolean + }, +) { + if (platform === "win32") return resolved ? path.resolve(resolved) : "git" + if (platform !== "darwin" || !resolved) return "git" + const full = path.posix.resolve(resolved) + if (full !== darwinShim) return full + const exists = options?.exists ?? existsSync + const dirs = options?.developerDirs ?? darwinDeveloperDirs() + return ( + dirs + .filter((dir) => Boolean(dir) && path.posix.isAbsolute(dir)) + .map((dir) => path.posix.join(dir, "usr/bin/git")) + .find((candidate) => candidate !== darwinShim && exists(candidate)) ?? "git" + ) +} + +export const gitExecutable = lazy(() => + resolveGitExecutable( + process.platform, + process.platform === "win32" || process.platform === "darwin" ? which("git") : null, + ), +) diff --git a/packages/core/test/util/git-executable.test.ts b/packages/core/test/util/git-executable.test.ts index 3f48dd6d0c0..049b9091886 100644 --- a/packages/core/test/util/git-executable.test.ts +++ b/packages/core/test/util/git-executable.test.ts @@ -1,13 +1,76 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { resolveGitExecutable } from "@opencode/core/util/git-executable" +import { darwinDeveloperDirs, resolveGitExecutable } from "@opencode/core/util/git-executable" describe("Git executable", () => { test("uses an absolute executable on Windows", () => { expect(resolveGitExecutable("win32", "bin/git.exe")).toBe(path.resolve("bin/git.exe")) }) - test("leaves lookup to the process outside Windows", () => { + test("uses a non-shim PATH executable on macOS without probing developer directories", () => { + let calls = 0 + const resolved = resolveGitExecutable("darwin", "/opt/homebrew/bin/git", { + developerDirs: ["/Library/Developer/CommandLineTools"], + exists: () => { + calls += 1 + return true + }, + }) + expect(resolved).toBe("/opt/homebrew/bin/git") + expect(calls).toBe(0) + }) + + test("resolves the Apple shim on macOS from developer directories", () => { + const resolved = resolveGitExecutable("darwin", "/usr/bin/git", { + developerDirs: ["/Applications/Xcode.app/Contents/Developer", "/Library/Developer/CommandLineTools"], + exists: (file) => file === "/Library/Developer/CommandLineTools/usr/bin/git", + }) + expect(resolved).toBe("/Library/Developer/CommandLineTools/usr/bin/git") + }) + + test("falls back to PATH lookup on macOS when no developer tools exist or candidate is invalid", () => { + expect( + resolveGitExecutable("darwin", "/usr/bin/git", { + developerDirs: ["/Applications/Xcode.app/Contents/Developer", "/Library/Developer/CommandLineTools"], + exists: () => false, + }), + ).toBe("git") + expect( + resolveGitExecutable("darwin", "/usr/bin/git", { + developerDirs: ["relative/dir"], + exists: () => true, + }), + ).toBe("git") + expect( + resolveGitExecutable("darwin", "/usr/bin/git", { + developerDirs: ["/"], + exists: () => true, + }), + ).toBe("git") + expect( + resolveGitExecutable("darwin", null, { + developerDirs: ["/Library/Developer/CommandLineTools"], + exists: () => true, + }), + ).toBe("git") + }) + + test("orders macOS developer directory candidates by DEVELOPER_DIR then xcode-select link", () => { + expect(darwinDeveloperDirs("/Custom/Developer", () => "/Library/Developer/CommandLineTools")).toEqual([ + "/Custom/Developer", + ]) + expect(darwinDeveloperDirs("", () => "/Applications/Xcode-beta.app/Contents/Developer")).toEqual([ + "/Applications/Xcode-beta.app/Contents/Developer", + "/Applications/Xcode.app/Contents/Developer", + "/Library/Developer/CommandLineTools", + ]) + expect(darwinDeveloperDirs(undefined, () => null)).toEqual([ + "/Applications/Xcode.app/Contents/Developer", + "/Library/Developer/CommandLineTools", + ]) + }) + + test("leaves lookup to the process on Linux", () => { expect(resolveGitExecutable("linux", "/usr/bin/git")).toBe("git") })