dotfiles/config/pi/extensions/worktree-agent/worktree.ts
2026-07-18 19:59:58 +07:00

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();
}