|
1 | 1 | import fs from "node:fs"; |
2 | 2 | import path from "node:path"; |
3 | | -import { spawn } from "node:child_process"; |
| 3 | +import { execFileSync, spawn } from "node:child_process"; |
4 | 4 | import type { ConflictFileType } from "../../../shared/types"; |
5 | 5 | import { terminateProcessTree } from "../shared/processExecution"; |
| 6 | +import { resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; |
| 7 | + |
| 8 | +// Electron apps launched from Finder/Dock can have a stripped PATH that misses |
| 9 | +// where the user actually installed git (e.g. /opt/homebrew/bin on Apple |
| 10 | +// Silicon when shell PATH probe times out). Resolve git's absolute path once |
| 11 | +// and reuse it so spawn never throws ENOENT. |
| 12 | +let cachedGitExecutable: string | null = null; |
| 13 | +function resolveGitExecutable(): string { |
| 14 | + if (cachedGitExecutable) return cachedGitExecutable; |
| 15 | + if (process.env.ADE_GIT_EXECUTABLE && fs.existsSync(process.env.ADE_GIT_EXECUTABLE)) { |
| 16 | + cachedGitExecutable = process.env.ADE_GIT_EXECUTABLE; |
| 17 | + return cachedGitExecutable; |
| 18 | + } |
| 19 | + const fromKnown = resolveExecutableFromKnownLocations(process.platform === "win32" ? "git.exe" : "git"); |
| 20 | + if (fromKnown?.path) { |
| 21 | + cachedGitExecutable = fromKnown.path; |
| 22 | + return cachedGitExecutable; |
| 23 | + } |
| 24 | + // Last resort: ask the user's login shell. Slower, but only runs if the |
| 25 | + // direct probe missed (e.g. git lives in an unusual nvm/asdf shim dir). |
| 26 | + if (process.platform !== "win32") { |
| 27 | + try { |
| 28 | + const shell = process.env.SHELL?.trim() || "/bin/sh"; |
| 29 | + const out = execFileSync(shell, ["-lc", "command -v git"], { |
| 30 | + encoding: "utf8", |
| 31 | + timeout: 3_000, |
| 32 | + }).trim(); |
| 33 | + if (out && fs.existsSync(out)) { |
| 34 | + cachedGitExecutable = out; |
| 35 | + return cachedGitExecutable; |
| 36 | + } |
| 37 | + } catch { |
| 38 | + // fall through |
| 39 | + } |
| 40 | + } |
| 41 | + // Fall back to bare "git" — spawn will surface the original ENOENT with a |
| 42 | + // clearer pre-check error message wrapped around it. |
| 43 | + cachedGitExecutable = process.platform === "win32" ? "git.exe" : "git"; |
| 44 | + return cachedGitExecutable; |
| 45 | +} |
| 46 | + |
| 47 | +function gitExecutableNotFoundMessage(executable: string): string { |
| 48 | + if (process.platform === "darwin") { |
| 49 | + return `git executable not found (tried ${executable}). Install Xcode Command Line Tools or set ADE_GIT_EXECUTABLE to git's absolute path.`; |
| 50 | + } |
| 51 | + if (process.platform === "win32") { |
| 52 | + return `git executable not found (tried ${executable}). Install Git for Windows or set ADE_GIT_EXECUTABLE to git's absolute path.`; |
| 53 | + } |
| 54 | + if (process.platform === "linux") { |
| 55 | + return `git executable not found (tried ${executable}). Install git with your package manager or set ADE_GIT_EXECUTABLE to git's absolute path.`; |
| 56 | + } |
| 57 | + return `git executable not found (tried ${executable}). Install git or set ADE_GIT_EXECUTABLE to git's absolute path.`; |
| 58 | +} |
6 | 59 |
|
7 | 60 | export type GitRunOptions = { |
8 | 61 | cwd: string; |
@@ -109,7 +162,7 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise<GitRunRe |
109 | 162 | : DEFAULT_MAX_OUTPUT_BYTES; |
110 | 163 |
|
111 | 164 | return await new Promise<GitRunResult>((resolve) => { |
112 | | - const child = spawn("git", args, { |
| 165 | + const child = spawn(resolveGitExecutable(), args, { |
113 | 166 | cwd: opts.cwd, |
114 | 167 | env: { ...process.env, ...(opts.env ?? {}) }, |
115 | 168 | stdio: ["ignore", "pipe", "pipe"] |
@@ -176,10 +229,15 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise<GitRunRe |
176 | 229 | }); |
177 | 230 |
|
178 | 231 | child.on("error", (error) => { |
| 232 | + const code = (error as NodeJS.ErrnoException)?.code; |
| 233 | + const friendlyMessage = |
| 234 | + code === "ENOENT" |
| 235 | + ? gitExecutableNotFoundMessage(resolveGitExecutable()) |
| 236 | + : error.message; |
179 | 237 | finish({ |
180 | 238 | exitCode: 1, |
181 | 239 | stdout, |
182 | | - stderr: stderr.length ? stderr : error.message, |
| 240 | + stderr: stderr.length ? stderr : friendlyMessage, |
183 | 241 | stdoutTruncated, |
184 | 242 | stderrTruncated |
185 | 243 | }); |
|
0 commit comments