93 lines
3.1 KiB
TypeScript
93 lines
3.1 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts";
|
|
|
|
type ExecResult = { stdout: string; stderr: string; code: number | null };
|
|
|
|
async function git(
|
|
pi: ExtensionAPI,
|
|
cwd: string,
|
|
args: string[],
|
|
options: { allowFailure?: boolean; timeout?: number } = {},
|
|
): Promise<ExecResult> {
|
|
const result = await pi.exec("git", ["-C", cwd, ...args], { timeout: options.timeout ?? 30_000 });
|
|
if (!options.allowFailure && result.code !== 0) {
|
|
const detail = (result.stderr || result.stdout).trim();
|
|
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function slugify(title: string): string {
|
|
const slug = title
|
|
.normalize("NFKD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 48)
|
|
.replace(/-+$/g, "");
|
|
|
|
return slug || "work";
|
|
}
|
|
|
|
export async function prepareWorktree(
|
|
pi: ExtensionAPI,
|
|
cwd: string,
|
|
input: WorktreeAgentInput,
|
|
report: ProgressReporter,
|
|
): Promise<WorktreeContext> {
|
|
if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(input.issueId.trim())) {
|
|
throw new Error(`Invalid Linear issue identifier: ${input.issueId}`);
|
|
}
|
|
|
|
const repoResult = await git(pi, cwd, ["rev-parse", "--show-toplevel"]);
|
|
const repoRoot = fs.realpathSync(repoResult.stdout.trim());
|
|
const statusBefore = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
if (statusBefore.stdout.trim()) {
|
|
throw new Error("The parent working tree is not clean. Commit, stash, or remove local changes first.");
|
|
}
|
|
|
|
const issueId = input.issueId.trim().toLowerCase();
|
|
const branchName = `issue/${issueId}-${slugify(input.title)}`;
|
|
const worktreePath = path.resolve(repoRoot, "..", "worktrees", branchName);
|
|
const baseRef = input.baseRef?.trim() || "HEAD";
|
|
|
|
const branchExists = await git(
|
|
pi,
|
|
repoRoot,
|
|
["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
|
|
{ allowFailure: true },
|
|
);
|
|
if (branchExists.code === 0) {
|
|
throw new Error(`Branch already exists: ${branchName}`);
|
|
}
|
|
if (fs.existsSync(worktreePath)) {
|
|
throw new Error(`Worktree path already exists: ${worktreePath}`);
|
|
}
|
|
|
|
const baseExists = await git(pi, repoRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`], {
|
|
allowFailure: true,
|
|
});
|
|
if (baseExists.code !== 0) {
|
|
throw new Error(`Base ref does not resolve to a commit: ${baseRef}`);
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
report(`Creating ${branchName} at ${worktreePath}`, {
|
|
phase: "creating-worktree",
|
|
branchName,
|
|
worktreePath,
|
|
});
|
|
await git(pi, repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseRef], {
|
|
timeout: 60_000,
|
|
});
|
|
|
|
return { repoRoot, branchName, worktreePath, baseRef };
|
|
}
|
|
|
|
export async function isParentClean(pi: ExtensionAPI, repoRoot: string): Promise<boolean> {
|
|
const status = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
return !status.stdout.trim();
|
|
}
|