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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-17 23:56:27 -07:00
parent 38c6fdc60d
commit 924e0df5ab
7 changed files with 193 additions and 24 deletions

View File

@@ -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."

View File

@@ -28,6 +28,12 @@ function callbackFromExecFileCall() {
return callback as (error: ExecFileException | null, stdout: string, stderr: string) => void; return callback as (error: ExecFileException | null, stdout: string, stderr: string) => void;
} }
async function waitForExecFileCall(count: number): Promise<void> {
for (let i = 0; i < 50 && mockExecFile.mock.calls.length < count; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
describe("probeGitCliStatus", () => { describe("probeGitCliStatus", () => {
beforeEach(() => { beforeEach(() => {
mockExecFile.mockReset(); mockExecFile.mockReset();
@@ -52,7 +58,8 @@ describe("probeGitCliStatus", () => {
}); });
it("reports missing git as unavailable with the install URL", async () => { 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, "", ""); callbackFromExecFileCall()(Object.assign(new Error("ENOENT"), { code: "ENOENT" }) as ExecFileException, "", "");
await expect(resultPromise).resolves.toEqual({ 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 () => { it("reports probe errors as unavailable without throwing", async () => {
const resultPromise = probeGitCliStatus(); const resultPromise = probeGitCliStatus();
callbackFromExecFileCall()(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }) as ExecFileException, "", ""); callbackFromExecFileCall()(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }) as ExecFileException, "", "");

View File

@@ -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<string> {
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;
}

View File

@@ -1,5 +1,7 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import type { ExecFileException } 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 GIT_INSTALL_URL = "https://git-scm.com/downloads";
export const DEFAULT_GIT_CLI_STATUS_TIMEOUT_MS = 2_500; export const DEFAULT_GIT_CLI_STATUS_TIMEOUT_MS = 2_500;
@@ -12,6 +14,13 @@ export interface GitCliStatus {
export interface ProbeGitCliStatusOptions { export interface ProbeGitCliStatusOptions {
timeoutMs?: number; 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 { function parseGitVersion(stdout: string): string | undefined {
@@ -21,17 +30,12 @@ function parseGitVersion(stdout: string): string | undefined {
return match?.[1]?.trim() || trimmed; return match?.[1]?.trim() || trimmed;
} }
/** type ProbeAttempt = { status: GitCliStatus } | "enoent" | "error";
* 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<GitCliStatus> {
const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_CLI_STATUS_TIMEOUT_MS;
function attemptGitVersion(binary: string, timeoutMs: number): Promise<ProbeAttempt> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = execFile( const child = execFile(
"git", binary,
["--version"], ["--version"],
{ {
encoding: "utf-8", encoding: "utf-8",
@@ -40,13 +44,15 @@ export async function probeGitCliStatus(options: ProbeGitCliStatusOptions = {}):
}, },
(error: ExecFileException | null, stdout: string | Buffer) => { (error: ExecFileException | null, stdout: string | Buffer) => {
if (error) { if (error) {
resolve({ available: false, installUrl: GIT_INSTALL_URL }); resolve(error.code === "ENOENT" ? "enoent" : "error");
return; return;
} }
resolve({ resolve({
available: true, status: {
version: parseGitVersion(String(stdout)), available: true,
installUrl: GIT_INSTALL_URL, version: parseGitVersion(String(stdout)),
installUrl: GIT_INSTALL_URL,
},
}); });
}, },
); );
@@ -54,3 +60,29 @@ export async function probeGitCliStatus(options: ProbeGitCliStatusOptions = {}):
child.stdin?.end(); 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<GitCliStatus> {
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 };
}

View File

@@ -1,5 +1,6 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { invalidateGitBinaryCache, isSpawnGitEnoent, resolveGitBinary } from "./git-binary.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const DEFAULT_GIT_TIMEOUT_MS = 10_000; const DEFAULT_GIT_TIMEOUT_MS = 10_000;
@@ -113,16 +114,39 @@ async function runGitCommand(
args: string[], args: string[],
options: { cwd?: string; timeout: number }, options: { cwd?: string; timeout: number },
): Promise<GitRepositoryCommandResult> { ): Promise<GitRepositoryCommandResult> {
const result = await execFileAsync(command, args, { /*
cwd: options.cwd, FNXC:Onboarding 2026-07-18-03:20:
timeout: options.timeout, Route "git" through resolveGitBinary so a git installed AFTER the server
encoding: "utf-8", 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.
return { */
stdout: result.stdout ?? "", const binary = command === "git" ? await resolveGitBinary() : command;
stderr: result.stderr ?? "", 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 { function extractCommandErrorMessage(error: unknown): string {

View File

@@ -1104,6 +1104,7 @@ export {
type GitCliStatus, type GitCliStatus,
type ProbeGitCliStatusOptions, type ProbeGitCliStatusOptions,
} from "./git-cli-status.js"; } from "./git-cli-status.js";
export { resolveGitBinary, invalidateGitBinaryCache, isSpawnGitEnoent, wellKnownGitBinaryPaths } from "./git-binary.js";
export { export {
parseRepoSlug, parseRepoSlug,
isValidRepoSlug, isValidRepoSlug,

View File

@@ -1,6 +1,7 @@
import * as fsPromises from "node:fs/promises"; import * as fsPromises from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path"; import { dirname, isAbsolute, join } from "node:path";
import { import {
resolveGitBinary,
countRunningAgentTasks, countRunningAgentTasks,
ensureMemoryFileWithBackend, ensureMemoryFileWithBackend,
isValidSqliteDatabaseFile, isValidSqliteDatabaseFile,
@@ -370,7 +371,7 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
} }
try { try {
await execFileAsync("git", ["clone", cloneSource, normalizedPath], { await execFileAsync(await resolveGitBinary(), ["clone", cloneSource, normalizedPath], {
timeout: 90_000, timeout: 90_000,
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8", encoding: "utf-8",