import { spawn } from "node:child_process";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { AlwaysOnError } from "../protocol/errors.js";
import type { WorkspaceHandle } from "../protocol/types.js";
import type { WorkspaceProvider, WorkspacePrepareInput, WorkspacePublishOutput } from "./WorkspaceProvider.js";
export type GitWorktreeProviderOptions = {
baseDir: string;
refuseDirty?: boolean;
gitBin?: string;
onWorktreeCreated?: (runId: string, cwd: string) => void;
onWorktreeRemoved?: (cwd: string) => void;
};
export class GitWorktreeProvider implements WorkspaceProvider {
readonly id = "git-worktree" as const;
readonly priority = 1;
constructor(private readonly options: GitWorktreeProviderOptions) {}
async isApplicable(projectRoot: string): Promise<boolean> {
const top = await runGit(this.git(), ["-C", projectRoot, "rev-parse", "--show-toplevel"]).catch(() => undefined);
if (!top || top.exitCode !== 0) return false;
const head = await runGit(this.git(), ["-C", projectRoot, "rev-parse", "HEAD"]).catch(() => undefined);
if (!head || head.exitCode !== 0) return false;
if (this.options.refuseDirty !== false) {
const status = await runGit(this.git(), ["-C", projectRoot, "status", "--porcelain"]).catch(() => undefined);
if (!status || status.exitCode !== 0) return false;
if (status.stdout.trim().length > 0) return false;
}
return true;
}
async prepare(input: WorkspacePrepareInput): Promise<WorkspaceHandle> {
const top = await runGit(this.git(), ["-C", input.projectRoot, "rev-parse", "--show-toplevel"]);
expectOk(top, "git rev-parse --show-toplevel");
const repoRoot = top.stdout.trim();
const branchRes = await runGit(this.git(), ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
expectOk(branchRes, "git rev-parse --abbrev-ref HEAD");
const baseBranch = branchRes.stdout.trim();
const commitRes = await runGit(this.git(), ["-C", repoRoot, "rev-parse", "HEAD"]);
expectOk(commitRes, "git rev-parse HEAD");
const baseCommit = commitRes.stdout.trim();
const worktreePath = resolve(this.options.baseDir, input.runId);
const branchName = `always-on/${input.runId}`;
const add = await runGit(this.git(), [
"-C",
repoRoot,
"worktree",
"add",
"-b",
branchName,
worktreePath,
baseCommit,
]);
if (add.exitCode !== 0) {
throw new AlwaysOnError(
"workspace_prepare_failed",
`git worktree add failed: ${add.stderr || add.stdout}`,
{ repoRoot, worktreePath },
);
}
this.options.onWorktreeCreated?.(input.runId, worktreePath);
return {
runId: input.runId,
projectKey: input.projectRoot,
strategy: this.id,
cwd: worktreePath,
metadata: { repoRoot, baseBranch, baseCommit, branchName },
};
}
async publish(handle: WorkspaceHandle): Promise<WorkspacePublishOutput> {
const repoRoot = handle.metadata.repoRoot ?? handle.cwd;
const diff = await runGit(this.git(), ["-C", handle.cwd, "diff", "--stat"]).catch(() => undefined);
return {
diff: diff && diff.exitCode === 0 ? diff.stdout : undefined,
commit: undefined,
...(repoRoot ? {} : {}),
};
}
async dispose(handle: WorkspaceHandle, options: { keep: boolean }): Promise<void> {
if (options.keep) return;
this.options.onWorktreeRemoved?.(handle.cwd);
const repoRoot = handle.metadata.repoRoot ?? handle.cwd;
const remove = await runGit(this.git(), [
"-C",
repoRoot,
"worktree",
"remove",
"--force",
handle.cwd,
]).catch(() => undefined);
if (!remove || remove.exitCode !== 0) {
await rm(handle.cwd, { recursive: true, force: true });
await runGit(this.git(), ["-C", repoRoot, "worktree", "prune"]).catch(() => undefined);
}
const branchName = handle.metadata.branchName as string | undefined;
if (branchName) {
await runGit(this.git(), ["-C", repoRoot, "branch", "-D", branchName]).catch(() => undefined);
}
}
private git(): string {
return this.options.gitBin ?? "git";
}
}
type GitResult = { exitCode: number; stdout: string; stderr: string };
async function runGit(bin: string, args: string[]): Promise<GitResult> {
return new Promise<GitResult>((resolvePromise) => {
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (error) => {
resolvePromise({ exitCode: -1, stdout, stderr: error.message });
});
child.on("close", (code) => {
resolvePromise({ exitCode: code ?? -1, stdout, stderr });
});
});
}
function expectOk(result: GitResult, label: string): void {
if (result.exitCode !== 0) {
throw new AlwaysOnError(
"workspace_prepare_failed",
`${label} failed: ${result.stderr || result.stdout}`,
);
}
}