Add Linear issue worktree workflow
This commit is contained in:
parent
bbd082947e
commit
bc5bf77963
9 changed files with 581 additions and 0 deletions
93
config/pi/extensions/worktree-agent/worktree.ts
Normal file
93
config/pi/extensions/worktree-agent/worktree.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue