From 924e0df5ab422b9bdda0d7ab55cfaf4ae3bace67 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 23:56:27 -0700 Subject: [PATCH] fix(core): find git installed mid-session despite the stale PATH snapshot A git installed DURING first-run setup stayed invisible (spawn git ENOENT on project creation; onboarding indicator stuck on missing) because the server's PATH snapshot predates the install. New resolver probes well-known install locations on PATH ENOENT with cache invalidation, wired into project git init/workspace detection, the onboarding git status probe, and the dashboard clone route. Co-Authored-By: Claude Fable 5 --- .changeset/git-stale-path-recovery.md | 7 ++ .../core/src/__tests__/git-cli-status.test.ts | 30 +++++++- packages/core/src/git-binary.ts | 76 +++++++++++++++++++ packages/core/src/git-cli-status.ts | 56 +++++++++++--- packages/core/src/git-repository.ts | 44 ++++++++--- packages/core/src/index.ts | 1 + .../src/routes/register-project-routes.ts | 3 +- 7 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 .changeset/git-stale-path-recovery.md create mode 100644 packages/core/src/git-binary.ts diff --git a/.changeset/git-stale-path-recovery.md b/.changeset/git-stale-path-recovery.md new file mode 100644 index 0000000000..ee386e141c --- /dev/null +++ b/.changeset/git-stale-path-recovery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Git installed while Fusion is running is now detected without a restart — project setup no longer fails with spawn git ENOENT. +category: fix +dev: "New core git-binary resolver: on PATH ENOENT, probes well-known install locations (win32 Program Files/LocalAppData Git\\cmd, macOS homebrew//usr/local//usr/bin, linux /usr/bin//usr/local/bin) with caching + invalidation on later ENOENT. Wired into ensureGitRepositoryForProjectPath's runner, probeGitCliStatus (onboarding git indicator), and the dashboard clone route." diff --git a/packages/core/src/__tests__/git-cli-status.test.ts b/packages/core/src/__tests__/git-cli-status.test.ts index c30bb51f87..07790a0040 100644 --- a/packages/core/src/__tests__/git-cli-status.test.ts +++ b/packages/core/src/__tests__/git-cli-status.test.ts @@ -28,6 +28,12 @@ function callbackFromExecFileCall() { return callback as (error: ExecFileException | null, stdout: string, stderr: string) => void; } +async function waitForExecFileCall(count: number): Promise { + for (let i = 0; i < 50 && mockExecFile.mock.calls.length < count; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + describe("probeGitCliStatus", () => { beforeEach(() => { mockExecFile.mockReset(); @@ -52,7 +58,8 @@ describe("probeGitCliStatus", () => { }); it("reports missing git as unavailable with the install URL", async () => { - const resultPromise = probeGitCliStatus(); + // Empty fallback list keeps the test hermetic (the default probes the real filesystem). + const resultPromise = probeGitCliStatus({ fallbackGitPaths: [] }); callbackFromExecFileCall()(Object.assign(new Error("ENOENT"), { code: "ENOENT" }) as ExecFileException, "", ""); await expect(resultPromise).resolves.toEqual({ @@ -61,6 +68,27 @@ describe("probeGitCliStatus", () => { }); }); + /* + FNXC:Onboarding 2026-07-18-03:20: + A git installed mid-session is not on the server's stale PATH snapshot; the + probe must find it at a well-known absolute location instead of reporting + "missing" until Fusion restarts. + */ + it("falls back to well-known install locations when PATH lookup ENOENTs", async () => { + const resultPromise = probeGitCliStatus({ fallbackGitPaths: ["C:\\Program Files\\Git\\cmd\\git.exe"] }); + callbackFromExecFileCall()(Object.assign(new Error("ENOENT"), { code: "ENOENT" }) as ExecFileException, "", ""); + await waitForExecFileCall(2); + const secondCallback = mockExecFile.mock.calls[1]?.[3] as (error: ExecFileException | null, stdout: string, stderr: string) => void; + secondCallback(null, "git version 2.50.0\n", ""); + + await expect(resultPromise).resolves.toEqual({ + available: true, + version: "2.50.0", + installUrl: GIT_INSTALL_URL, + }); + expect(mockExecFile.mock.calls[1]?.[0]).toBe("C:\\Program Files\\Git\\cmd\\git.exe"); + }); + it("reports probe errors as unavailable without throwing", async () => { const resultPromise = probeGitCliStatus(); callbackFromExecFileCall()(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }) as ExecFileException, "", ""); diff --git a/packages/core/src/git-binary.ts b/packages/core/src/git-binary.ts new file mode 100644 index 0000000000..7d0e5798c3 --- /dev/null +++ b/packages/core/src/git-binary.ts @@ -0,0 +1,76 @@ +/* +FNXC:Onboarding 2026-07-18-03:20: +Field report: a user installed git DURING first-time setup, but project +creation kept failing with spawn git ENOENT until Fusion was restarted. The +running server's PATH snapshot predates the install (on Windows the installer +updates the registry PATH, which running processes never see), so bare +execFile("git", ...) cannot find a just-installed git. Resolve git once via +PATH and, on ENOENT, fall back to the platform's well-known install +locations; cache the winner and invalidate the cache on a later ENOENT so an +install or uninstall mid-session is picked up without a restart. +*/ + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +let cachedGitBinary: string | null = null; + +/** Well-known absolute git locations checked when PATH resolution fails. */ +export function wellKnownGitBinaryPaths( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string[] { + if (platform === "win32") { + const roots = [ + env.ProgramFiles, + env["ProgramFiles(x86)"], + env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "Programs") : undefined, + ].filter((root): root is string => Boolean(root)); + return roots.map((root) => join(root, "Git", "cmd", "git.exe")); + } + if (platform === "darwin") { + return ["/opt/homebrew/bin/git", "/usr/local/bin/git", "/usr/bin/git"]; + } + return ["/usr/bin/git", "/usr/local/bin/git"]; +} + +/** True when the error is a spawn-level "binary not found". */ +export function isSpawnGitEnoent(error: unknown): boolean { + return Boolean(error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT"); +} + +/** + * Resolve the git binary to spawn: "git" (PATH) when it works, otherwise the + * first existing well-known install location. Returns "git" as the final + * fallback so callers surface the ordinary ENOENT when git is truly absent. + */ +export async function resolveGitBinary(): Promise { + if (cachedGitBinary) return cachedGitBinary; + try { + await execFileAsync("git", ["--version"], { timeout: 5_000, windowsHide: true }); + cachedGitBinary = "git"; + return cachedGitBinary; + } catch (error) { + if (!isSpawnGitEnoent(error)) { + // git exists but errored (e.g. timeout) — PATH resolution itself works. + cachedGitBinary = "git"; + return cachedGitBinary; + } + } + for (const candidate of wellKnownGitBinaryPaths()) { + if (existsSync(candidate)) { + cachedGitBinary = candidate; + return cachedGitBinary; + } + } + return "git"; +} + +/** Drop the cached resolution (call on a later ENOENT so installs/uninstalls mid-session are re-probed). */ +export function invalidateGitBinaryCache(): void { + cachedGitBinary = null; +} diff --git a/packages/core/src/git-cli-status.ts b/packages/core/src/git-cli-status.ts index b62cb8d5d7..3004cf95e3 100644 --- a/packages/core/src/git-cli-status.ts +++ b/packages/core/src/git-cli-status.ts @@ -1,5 +1,7 @@ import { execFile } from "node:child_process"; import type { ExecFileException } from "node:child_process"; +import { existsSync } from "node:fs"; +import { wellKnownGitBinaryPaths } from "./git-binary.js"; export const GIT_INSTALL_URL = "https://git-scm.com/downloads"; export const DEFAULT_GIT_CLI_STATUS_TIMEOUT_MS = 2_500; @@ -12,6 +14,13 @@ export interface GitCliStatus { export interface ProbeGitCliStatusOptions { timeoutMs?: number; + /** + * FNXC:Onboarding 2026-07-18-03:20: + * Absolute git candidates probed when the PATH lookup ENOENTs. Defaults to + * the platform's existing well-known install locations. Injectable so tests + * stay hermetic (the default probes the real filesystem). + */ + fallbackGitPaths?: readonly string[]; } function parseGitVersion(stdout: string): string | undefined { @@ -21,17 +30,12 @@ function parseGitVersion(stdout: string): string | undefined { return match?.[1]?.trim() || trimmed; } -/** - * FNXC:Onboarding 2026-07-03-00:00: - * First-run GitHub onboarding must detect whether `git` is available on the Fusion server host before clone/init flows fail later. - * Keep this probe bounded and argument-vector based so auth status can include prerequisite guidance without shell interpolation or long subprocess hangs. - */ -export async function probeGitCliStatus(options: ProbeGitCliStatusOptions = {}): Promise { - const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_CLI_STATUS_TIMEOUT_MS; +type ProbeAttempt = { status: GitCliStatus } | "enoent" | "error"; +function attemptGitVersion(binary: string, timeoutMs: number): Promise { return new Promise((resolve) => { const child = execFile( - "git", + binary, ["--version"], { encoding: "utf-8", @@ -40,13 +44,15 @@ export async function probeGitCliStatus(options: ProbeGitCliStatusOptions = {}): }, (error: ExecFileException | null, stdout: string | Buffer) => { if (error) { - resolve({ available: false, installUrl: GIT_INSTALL_URL }); + resolve(error.code === "ENOENT" ? "enoent" : "error"); return; } resolve({ - available: true, - version: parseGitVersion(String(stdout)), - installUrl: GIT_INSTALL_URL, + status: { + available: true, + version: parseGitVersion(String(stdout)), + installUrl: GIT_INSTALL_URL, + }, }); }, ); @@ -54,3 +60,29 @@ export async function probeGitCliStatus(options: ProbeGitCliStatusOptions = {}): child.stdin?.end(); }); } + +/** + * FNXC:Onboarding 2026-07-03-00:00: + * First-run GitHub onboarding must detect whether `git` is available on the Fusion server host before clone/init flows fail later. + * Keep this probe bounded and argument-vector based so auth status can include prerequisite guidance without shell interpolation or long subprocess hangs. + * + * FNXC:Onboarding 2026-07-18-03:20: + * Field report: git installed DURING first-run setup stayed "missing" (and project + * setup kept failing spawn-git ENOENT) because the server's PATH snapshot predates + * the install. On a PATH ENOENT, probe the platform's well-known install locations + * so a just-installed git is detected without restarting Fusion. + */ +export async function probeGitCliStatus(options: ProbeGitCliStatusOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_CLI_STATUS_TIMEOUT_MS; + + const first = await attemptGitVersion("git", timeoutMs); + if (typeof first === "object") return first.status; + if (first === "enoent") { + const candidates = options.fallbackGitPaths ?? wellKnownGitBinaryPaths().filter((p) => existsSync(p)); + for (const candidate of candidates) { + const attempt = await attemptGitVersion(candidate, timeoutMs); + if (typeof attempt === "object") return attempt.status; + } + } + return { available: false, installUrl: GIT_INSTALL_URL }; +} diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index 063190b307..7793a7ed17 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { invalidateGitBinaryCache, isSpawnGitEnoent, resolveGitBinary } from "./git-binary.js"; const execFileAsync = promisify(execFile); const DEFAULT_GIT_TIMEOUT_MS = 10_000; @@ -113,16 +114,39 @@ async function runGitCommand( args: string[], options: { cwd?: string; timeout: number }, ): Promise { - const result = await execFileAsync(command, args, { - cwd: options.cwd, - timeout: options.timeout, - encoding: "utf-8", - }); - - return { - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - }; + /* + FNXC:Onboarding 2026-07-18-03:20: + Route "git" through resolveGitBinary so a git installed AFTER the server + started (stale PATH snapshot — spawn git ENOENT during first-run project + setup) is found at its well-known install location; on ENOENT re-resolve + once so a mid-session install is picked up without restarting Fusion. + */ + const binary = command === "git" ? await resolveGitBinary() : command; + try { + const result = await execFileAsync(binary, args, { + cwd: options.cwd, + timeout: options.timeout, + encoding: "utf-8", + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + } catch (error) { + if (command !== "git" || !isSpawnGitEnoent(error)) throw error; + invalidateGitBinaryCache(); + const retryBinary = await resolveGitBinary(); + if (retryBinary === binary) throw error; + const result = await execFileAsync(retryBinary, args, { + cwd: options.cwd, + timeout: options.timeout, + encoding: "utf-8", + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + } } function extractCommandErrorMessage(error: unknown): string { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 807d41c317..d1bc7a1041 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1104,6 +1104,7 @@ export { type GitCliStatus, type ProbeGitCliStatusOptions, } from "./git-cli-status.js"; +export { resolveGitBinary, invalidateGitBinaryCache, isSpawnGitEnoent, wellKnownGitBinaryPaths } from "./git-binary.js"; export { parseRepoSlug, isValidRepoSlug, diff --git a/packages/dashboard/src/routes/register-project-routes.ts b/packages/dashboard/src/routes/register-project-routes.ts index a9176ab95b..4f80725f30 100644 --- a/packages/dashboard/src/routes/register-project-routes.ts +++ b/packages/dashboard/src/routes/register-project-routes.ts @@ -1,6 +1,7 @@ import * as fsPromises from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import { + resolveGitBinary, countRunningAgentTasks, ensureMemoryFileWithBackend, isValidSqliteDatabaseFile, @@ -370,7 +371,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { } try { - await execFileAsync("git", ["clone", cloneSource, normalizedPath], { + await execFileAsync(await resolveGitBinary(), ["clone", cloneSource, normalizedPath], { timeout: 90_000, maxBuffer: 10 * 1024 * 1024, encoding: "utf-8",