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:
7
.changeset/git-stale-path-recovery.md
Normal file
7
.changeset/git-stale-path-recovery.md
Normal 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."
|
||||||
@@ -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, "", "");
|
||||||
|
|||||||
76
packages/core/src/git-binary.ts
Normal file
76
packages/core/src/git-binary.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user