evOo-local/packages/core/src/git-checkout.ts
Ingo Wagner fccb4cc6c7 fix(core): lazy-load child_process to prevent browser bundling
- Move spawn import in git-checkout.ts to lazy require() to prevent
  node:child_process from being included in browser bundles
- Add ./git-checkout subpath export in package.json
- Update project-workspace.ts to use direct subpath import
- Update test mock to match new import path

Fixes: Vite crash from child_process externalization error,
       API crashloop from missing readGitCheckout export
2026-09-23 13:48:37 +00:00

94 lines
3.1 KiB
TypeScript

import { access } from "node:fs/promises";
import path from "node:path";
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
// Lazy import to avoid bundling `node:child_process` in browser bundles.
// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works.
let _spawn: typeof import("node:child_process").spawn | null = null;
const getSpawn = (): typeof import("node:child_process").spawn => {
if (!_spawn) _spawn = require("node:child_process").spawn;
return _spawn;
};
export interface GitCheckoutStatus {
present: boolean;
remoteUrl: string | null;
defaultBranch: string | null;
headCommit: string | null;
dirty: boolean;
dirtyPaths: string[];
}
export interface GitRunnerResult {
ok: boolean;
output: string;
}
export type GitRunner = (args: string[], cwd: string) => Promise<GitRunnerResult>;
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
return new Promise((resolve) => {
const spawn = getSpawn();
const child = spawn("git", args, { cwd, env: process.env });
let output = "";
child.stdout.on("data", (chunk: Buffer | string) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer | string) => {
output += chunk.toString();
});
child.on("error", () => resolve({ ok: false, output }));
child.on("close", (code) => resolve({ ok: code === 0, output }));
});
}
async function hasGitMetadata(directory: string): Promise<boolean> {
try {
await access(path.join(directory, ".git"));
return true;
} catch {
return false;
}
}
/** Reads local git metadata from an existing checkout without fetching remotes. */
export async function readGitCheckout(
directory: string,
runGit: GitRunner = defaultGitRunner,
): Promise<GitCheckoutStatus> {
const root = path.resolve(directory);
if (!(await hasGitMetadata(root))) {
return {
present: false,
remoteUrl: null,
defaultBranch: null,
headCommit: null,
dirty: false,
dirtyPaths: [],
};
}
const [head, branch, remote, status, untracked] = await Promise.all([
runGit(["rev-parse", "HEAD"], root),
runGit(["rev-parse", "--abbrev-ref", "HEAD"], root),
runGit(["remote", "get-url", DEFAULT_UPDATE_REMOTE], root),
runGit(["--no-optional-locks", ...gitStatusArgv()], root),
runGit(["--no-optional-locks", ...gitUntrackedFilesArgv()], root),
]);
const porcelain = parseGitStatusPorcelain(status.ok ? status.output : "");
const untrackedPaths = parseGitNameOnly(untracked.ok ? untracked.output : "");
const dirtyPaths = [...new Set([...porcelain.changed, ...untrackedPaths])];
const stateReadable = status.ok && untracked.ok;
return {
present: true,
remoteUrl: remote.ok ? remote.output.trim() || null : null,
defaultBranch: branch.ok ? branch.output.trim() || null : null,
headCommit: head.ok ? head.output.trim() || null : null,
dirty: stateReadable ? dirtyPaths.length > 0 : true,
dirtyPaths: stateReadable ? dirtyPaths : [],
};
}