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 { 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 { 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 { const status = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]); return !status.stdout.trim(); }