/** * Target workspace prep — clone (or worktree) the target repository so * reviewer children can read its source, history and unchanged files. * * Real failure (PR #18689): reviewers shared cwd with the plugin repo, so * `history-context` and `code-comments` had no relevant code. The plugin now * - for PRs: shallow-clones `owner/repo` into a scratch dir (gh first so * private repos use the gh credential), checks out the PR head from * FETCH_HEAD, and verifies the landed SHA against the diff's head SHA. * - for local-git dirty: uses the user's cwd directly (already correct). * - for local-git clean vs default branch: uses the user's cwd after * `git fetch origin `. * * The workspace is **read-only by convention**: reviewers are not given * write tools and the plugin never modifies it. * * A failed PR clone is a hard error (not a silent fallback to the user's * cwd): a fresh GitHub diff plus a stale local checkout is the #1 false * positive source observed in the field (diff@new, files@old). */ import { spawn } from "node:child_process"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parsePrRepo } from "./review-report.js"; export type RunCmd = ( cmd: string, args: string[], opts: { cwd: string }, ) => Promise<{ stdout: string; stderr: string; exitCode: number }>; let _runCmd: RunCmd = defaultRunCmd; export function setTargetWorkspaceCmd(fn: RunCmd): void { _runCmd = fn; } export function resetTargetWorkspaceCmd(): void { _runCmd = defaultRunCmd; } async function defaultRunCmd( cmd: string, args: string[], opts: { cwd: string }, ): Promise<{ stdout: string; stderr: string; exitCode: number }> { return new Promise((resolve) => { try { const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout?.setEncoding("utf-8"); child.stderr?.setEncoding("utf-8"); child.stdout?.on("data", (d: string) => (stdout += d)); child.stderr?.on("data", (d: string) => (stderr += d)); child.on("error", () => resolve({ stdout, stderr, exitCode: 1 })); child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 })); } catch { resolve({ stdout: "", stderr: "spawn failed", exitCode: 1 }); } }); } /** Allocate a scratch root under the OS tmpdir; created on disk. */ export function allocateWorkspaceRoot(prefix = "pi-review-ws"): string { const root = join( tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); mkdirSync(root, { recursive: true }); return root; } /** Best-effort cleanup; ignores errors. */ export function removeWorkspaceRoot(path: string): void { try { rmSync(path, { recursive: true, force: true }); } catch { /* ignore */ } } export interface WorkspaceResult { /** Absolute path reviewers should use as cwd. */ workspacePath: string; /** True when `git log` / `blame` will work in the workspace. */ historyAvailable: boolean; /** Optional failure note — when set, reviewers must `skipped` the affected lane. */ warning?: string; /** Whether we cloned (true) or reused the user cwd (false). */ cloned: boolean; /** HEAD SHA landed in the workspace, when determinable. */ workspaceHeadSha?: string; } /** * Resolve the right workspace for a given target. `cwd` is the user's cwd. * Pure function with side effects limited to fs + git/gh subprocesses. */ export async function prepareWorkspace(input: { cwd: string; target: { kind: "pr" | "diff-file" | "local-git"; prRef?: string; /** Diff-side head SHA to verify the checkout against. */ expectedHeadSha?: string; }; }): Promise { const { cwd, target } = input; if (target.kind === "local-git") { // Reuse the user's cwd; reviewers will read source directly. const inRepo = await isGitRepo(cwd); return { workspacePath: cwd, historyAvailable: inRepo, cloned: false, warning: inRepo ? undefined : "Not a git repository — history-context will skip.", }; } if (target.kind !== "pr" || !target.prRef) { return { workspacePath: cwd, historyAvailable: false, cloned: false, warning: "PR target missing prRef — reviewers will see only the diff.", }; } const parsed = parsePrRepo(target.prRef); if (!parsed) { return { workspacePath: cwd, historyAvailable: false, cloned: false, warning: "Could not parse PR URL — reviewers will see only the diff.", }; } const root = allocateWorkspaceRoot(); const cloneDir = join(root, `${parsed.repo}-${parsed.number}`); mkdirSync(cloneDir, { recursive: true }); // Clone (gh first so private repos ride the gh credential; plain https // fallback for anonymous/public setups). depth 50 keeps history-context // usable without a full clone. const url = `https://github.com/${parsed.owner}/${parsed.repo}.git`; const ghClone = await _runCmd( "gh", ["repo", "clone", `${parsed.owner}/${parsed.repo}`, cloneDir, "--", "--depth", "50"], { cwd: root }, ); const clone = ghClone.exitCode === 0 ? ghClone : await _runCmd("git", ["clone", "--depth", "50", url, cloneDir], { cwd: root }); if (clone.exitCode !== 0) { removeWorkspaceRoot(root); throw new Error( `pi-review: could not clone ${parsed.owner}/${parsed.repo} (${(ghClone.stderr || clone.stderr).trim().slice(0, 200)}). A fresh diff over a stale local checkout produces false positives, so the review stops here — check gh auth / network and re-run.`, ); } // Fetch the PR head into FETCH_HEAD and detach onto it (no named branch → // nothing stale can survive between runs). const headFetch = await _runCmd( "git", ["fetch", "origin", `pull/${parsed.number}/head`, "--quiet"], { cwd: cloneDir }, ); if (headFetch.exitCode !== 0) { removeWorkspaceRoot(root); throw new Error( `pi-review: git fetch pull/${parsed.number}/head failed (${headFetch.stderr.trim().slice(0, 200)}) — aborting instead of reviewing a mismatched checkout.`, ); } const fetchHead = (await _runCmd("git", ["rev-parse", "FETCH_HEAD"], { cwd: cloneDir })).stdout.trim(); if ( target.expectedHeadSha && fetchHead && fetchHead !== target.expectedHeadSha ) { // One refetch — a force-push may have raced the clone. const retry = await _runCmd( "git", ["fetch", "origin", `pull/${parsed.number}/head`, "--quiet"], { cwd: cloneDir }, ); const retryHead = retry.exitCode === 0 ? (await _runCmd("git", ["rev-parse", "FETCH_HEAD"], { cwd: cloneDir })).stdout.trim() : ""; if (retryHead && retryHead !== target.expectedHeadSha) { removeWorkspaceRoot(root); throw new Error( `pi-review: PR ${parsed.number} head moved to ${retryHead.slice(0, 12)} while the diff was captured at ${target.expectedHeadSha.slice(0, 12)} — re-run /review to get a consistent pair.`, ); } } const headCheckout = await _runCmd("git", ["checkout", "--detach", "FETCH_HEAD"], { cwd: cloneDir }); if (headCheckout.exitCode !== 0) { removeWorkspaceRoot(root); throw new Error( `pi-review: checkout of PR ${parsed.number} head failed (${headCheckout.stderr.trim().slice(0, 200)}).`, ); } const workspaceHeadSha = await safeHead(cloneDir); return { workspacePath: cloneDir, historyAvailable: true, cloned: true, workspaceHeadSha }; } async function safeHead(cwd: string): Promise { const r = await _runCmd("git", ["rev-parse", "HEAD"], { cwd }); if (r.exitCode !== 0) return undefined; return r.stdout.trim() || undefined; } async function isGitRepo(cwd: string): Promise { const r = await _runCmd("git", ["rev-parse", "--git-dir"], { cwd }); return r.exitCode === 0; } /** Optional: write a `.pi-review-meta.json` so reviewers can find the run dir. */ export function writeWorkspaceMarker(workspacePath: string, payload: Record): void { const path = join(workspacePath, ".pi-review-meta.json"); try { writeFileSync(path, JSON.stringify(payload, null, 2) + "\n", "utf-8"); } catch { /* read-only fs, etc. */ } } /** Quiet helper to remove the marker when the workspace is torn down. */ export function clearWorkspaceMarker(workspacePath: string): void { try { rmSync(join(workspacePath, ".pi-review-meta.json"), { force: true }); } catch { /* ignore */ } }