Files
fusion/packages/core/src/git-repository.ts
gsxdsm c1b5be7d69 fix(workspace): write config.json before workspace.json, validate settings object
Address PR #1739 review round 3:

- Major (coderabbit): Reorder writes so setWorkspaceModeInConfig runs
  before saveWorkspaceConfig. If the config write fails, no stale
  workspace.json is left behind.
- Major (coderabbit): setWorkspaceModeInConfig only treats ENOENT as
  empty config (not parse errors or permission errors). Validates
  settings is a plain object before merging to prevent clobbering.
2026-06-24 09:02:02 -07:00

306 lines
12 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const DEFAULT_GIT_TIMEOUT_MS = 10_000;
export type GitRepositoryEnsureOutcome = "existing" | "initialized";
export interface GitRepositoryCommandResult {
stdout: string;
stderr: string;
}
export type GitRepositoryCommandRunner = (
command: string,
args: string[],
options: { cwd?: string; timeout: number },
) => Promise<GitRepositoryCommandResult>;
export interface EnsureGitRepositoryOptions {
runner?: GitRepositoryCommandRunner;
timeoutMs?: number;
}
export class GitRepositoryInitializationError extends Error {
readonly path: string;
readonly causeMessage: string;
constructor(path: string, causeMessage: string) {
super(`Could not initialize Git repository at ${path}: ${causeMessage}`);
this.name = "GitRepositoryInitializationError";
this.path = path;
this.causeMessage = causeMessage;
}
}
export async function ensureGitRepositoryForProjectPath(
projectPath: string,
options: EnsureGitRepositoryOptions = {},
): Promise<GitRepositoryEnsureOutcome> {
const runner = options.runner ?? runGitCommand;
const timeout = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
/*
FNXC:Workspace 2026-06-24-10:00:
A workspace-mode project root is intentionally NOT a git repository — it is a parent
directory containing multiple git sub-repos (detected at init time and recorded in
.fusion/workspace.json). Running `git init` here would create a stray empty repo at the
workspace root, poisoning every downstream git command: the executor sets the session cwd
to this root (browse-only), and `git rev-parse --abbrev-ref HEAD` fails on the unborn HEAD
with "ambiguous argument 'HEAD'". Detect workspace mode via the config file and skip the
git-init so the root stays non-git, matching the workspace execution contract (KTD1).
*/
if (await loadWorkspaceConfig(projectPath)) {
return "existing";
}
if (await isInsideGitWorkTree(projectPath, runner, timeout)) {
return "existing";
}
/*
FNXC:Workspace 2026-06-24-14:30:
Fallback workspace detection: when workspace.json is missing (e.g. project added via
dashboard or `fn project add`, which don't run the interactive workspace detection flow),
probe for git sub-repos. If found, persist workspace.json AND set workspaceMode: true in
config.json so the dashboard toggle reflects the actual state. This covers all registration
surfaces: the CLI interactive setup writes workspace.json explicitly, but dashboard POST
/api/projects and `fn project add` do not — without this fallback they would create a stray
.git at the workspace root because loadWorkspaceConfig returned null.
FNXC:Workspace 2026-06-24-17:00:
If the user has explicitly disabled workspace mode (workspaceMode: false in config.json),
skip auto-detection and proceed to git init. Without this guard, toggling workspace mode off
via the dashboard would have no lasting effect — the fallback would re-detect sub-repos and
re-create workspace.json on the next registration call.
*/
if (!(await isWorkspaceModeExplicitlyDisabled(projectPath))) {
const detectedRepos = await detectWorkspaceRepos(projectPath, runner, timeout);
if (detectedRepos.length > 0) {
// Write config.json first so a failure here doesn't leave a stale workspace.json
// that would short-circuit loadWorkspaceConfig on the next call without the
// workspaceMode setting being persisted.
await setWorkspaceModeInConfig(projectPath, true);
await saveWorkspaceConfig(projectPath, { repos: detectedRepos });
return "existing";
}
}
try {
await runner("git", ["-C", projectPath, "init"], { timeout });
return "initialized";
} catch (error) {
throw new GitRepositoryInitializationError(projectPath, extractCommandErrorMessage(error));
}
}
async function isInsideGitWorkTree(
projectPath: string,
runner: GitRepositoryCommandRunner,
timeout: number,
): Promise<boolean> {
try {
const result = await runner("git", ["-C", projectPath, "rev-parse", "--is-inside-work-tree"], { timeout });
return result.stdout.trim() === "true";
} catch {
return false;
}
}
async function runGitCommand(
command: string,
args: string[],
options: { cwd?: string; timeout: number },
): Promise<GitRepositoryCommandResult> {
const result = await execFileAsync(command, args, {
cwd: options.cwd,
timeout: options.timeout,
encoding: "utf-8",
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}
function extractCommandErrorMessage(error: unknown): string {
if (error && typeof error === "object") {
const maybe = error as { stderr?: unknown; stdout?: unknown; message?: unknown; code?: unknown };
for (const value of [maybe.stderr, maybe.stdout, maybe.message]) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
if (maybe.code !== undefined) {
return `git exited with code ${String(maybe.code)}`;
}
}
return String(error);
}
/**
* Scans `dir` one level deep for sub-directories that are git repositories.
* Returns relative paths of found repos, sorted alphabetically.
*
* Excludes `node_modules`, `.fusion`, and other known non-workspace directories so that
* packages installed from git sources (which leave real `.git` dirs) do not produce
* false-positive workspace members.
*/
export async function detectWorkspaceRepos(
dir: string,
runner: GitRepositoryCommandRunner = runGitCommand,
timeout: number = DEFAULT_GIT_TIMEOUT_MS,
): Promise<string[]> {
let entries: string[];
try {
const { readdir } = await import("node:fs/promises");
entries = await readdir(dir);
} catch {
return [];
}
const { stat } = await import("node:fs/promises");
const { join } = await import("node:path");
const found: string[] = [];
/*
FNXC:Workspace 2026-06-22-00:00:
A bare `.git` marker (e.g. a stray file copied in, or an unrelated tool's artifact) is not
proof of a git repository. Each candidate child is validated with a real `git rev-parse`
work-tree probe before it counts, so stray `.git` entries do not yield false-positive repos.
*/
/*
FNXC:Workspace 2026-06-24-15:00:
Exclude node_modules and .fusion so that npm packages installed from git sources (which
leave real .git directories inside node_modules/<package>) and Fusion's own state directory
do not produce false-positive workspace members. A workspace root is a plain directory whose
immediate children are the intended sub-repos, not transitive dependency artifacts.
*/
const EXCLUDED_ENTRIES = new Set(["node_modules", ".fusion", ".git", ".pi"]);
for (const entry of entries) {
if (EXCLUDED_ENTRIES.has(entry)) continue;
const childDir = join(dir, entry);
// Cheap pre-filter: skip children with no `.git` marker at all before spawning git.
try {
const s = await stat(join(childDir, ".git"));
if (!s.isDirectory() && !s.isFile()) continue;
} catch {
continue;
}
if (await isInsideGitWorkTree(childDir, runner, timeout)) {
found.push(entry);
}
}
return found.sort();
}
export interface WorkspaceConfig {
repos: string[];
}
const WORKSPACE_CONFIG_FILENAME = "workspace.json";
/**
* Reads .fusion/config.json and returns true when `workspaceMode` is explicitly
* set to `false`. This guards the auto-detection fallback so a user who has
* intentionally disabled workspace mode doesn't get it silently re-enabled.
*/
async function isWorkspaceModeExplicitlyDisabled(projectPath: string): Promise<boolean> {
try {
const { readFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const raw = await readFile(join(projectPath, ".fusion", "config.json"), "utf-8");
const config = JSON.parse(raw) as { settings?: { workspaceMode?: boolean } };
return config.settings?.workspaceMode === false;
} catch {
return false;
}
}
/**
* FNXC:Workspace 2026-06-24-17:15:
* Writes `workspaceMode: true` into .fusion/config.json so the dashboard toggle
* reflects that workspace mode is active after auto-detection. Reads-merges-writes
* to avoid clobbering existing config settings.
*/
async function setWorkspaceModeInConfig(projectPath: string, value: boolean): Promise<void> {
const { readFile, writeFile, mkdir } = await import("node:fs/promises");
const { join } = await import("node:path");
const configPath = join(projectPath, ".fusion", "config.json");
let config: Record<string, unknown> = {};
try {
config = JSON.parse(await readFile(configPath, "utf-8")) as Record<string, unknown>;
} catch (err) {
// Only treat "file not found" as empty config; re-throw parse/permission errors
// so a corrupted config.json doesn't get silently clobbered with a fresh object.
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
}
// Validate settings is a plain object before merging
if (typeof config.settings !== "object" || config.settings === null || Array.isArray(config.settings)) {
config.settings = {};
}
const settings = config.settings as Record<string, unknown>;
settings.workspaceMode = value;
await mkdir(join(projectPath, ".fusion"), { recursive: true });
await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
}
/*
FNXC:Workspace 2026-06-22-00:00:
Workspace repo entries are later joined onto the workspace root to resolve worktrees, so an
attacker-controlled or corrupted workspace.json with an absolute path or a `..` escape
(`../outside-repo`) would resolve outside the workspace root. Each entry must be a normalized,
relative, in-root path; absolute paths, `..` escapes, and non-string entries are rejected.
*/
function isInRootRelativePath(entry: unknown, pathMod: typeof import("node:path")): entry is string {
if (typeof entry !== "string" || entry.length === 0) return false;
if (pathMod.isAbsolute(entry)) return false;
const normalized = pathMod.normalize(entry);
if (normalized === ".." || normalized.startsWith(`..${pathMod.sep}`) || normalized.startsWith("../")) {
return false;
}
return true;
}
export async function loadWorkspaceConfig(rootDir: string): Promise<WorkspaceConfig | null> {
const { readFile } = await import("node:fs/promises");
const pathMod = await import("node:path");
const { join } = pathMod;
const configPath = join(rootDir, ".fusion", WORKSPACE_CONFIG_FILENAME);
try {
const raw = await readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
// FNXC:Workspace 2026-06-22-09:30 (Phase C review nit): validate that `repos` is an array
// OF STRINGS, not merely an array. A malformed config (`{ repos: [123, null] }`) would
// otherwise pass and feed non-string values into path joins downstream.
if (
parsed !== null &&
typeof parsed === "object" &&
"repos" in parsed &&
Array.isArray((parsed as { repos: unknown }).repos) &&
(parsed as { repos: unknown[] }).repos.every((r) => typeof r === "string")
) {
const rawRepos = (parsed as { repos: unknown[] }).repos;
const repos = rawRepos.filter((entry): entry is string => isInRootRelativePath(entry, pathMod));
return { ...(parsed as object), repos };
}
return null;
} catch {
return null;
}
}
export async function saveWorkspaceConfig(rootDir: string, config: WorkspaceConfig): Promise<void> {
const { mkdir, writeFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const fusionDir = join(rootDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
await writeFile(
join(fusionDir, WORKSPACE_CONFIG_FILENAME),
JSON.stringify(config, null, 2),
"utf-8",
);
}