- 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
94 lines
3.1 KiB
TypeScript
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 : [],
|
|
};
|
|
}
|