01e4daa0b5
* checkout_pr: refuse unconditionally on dirty working tree drop the live-HEAD comparison from the guard introduced in #796. any checkout_pr call with staged or unstaged changes now throws, even when HEAD is already on pr-N. no stashing, no idempotent escape hatch. motivation is the zed-industries/cloud (2026-05-18) incident: shared-cwd subagents make "carry edits along" semantics dangerous, and the HEAD-equality predicate let a re-checkout silently inherit working-tree state from a sibling agent. forcing commit/discard before any PR-context operation eliminates the entire carry-forward failure class. error names the PR number, lists dirty paths, and tells the agent to commit/push/restore/clean before retrying. * improve dirty-tree error: precise discard commands copilot caught two sloppy bits in the error string: - "push" alone does not clean a dirty tree (needs commit first) - bare `git clean` is a no-op without `-fd` reword to "commit (then push if needed), or discard with `git restore --staged --worktree .` / `git clean -fd`" so the guidance is actually actionable. * checkout_pr: initial-branch invariant setupGit captures `toolState.initialBranch` at run start via live `git rev-parse --abbrev-ref HEAD`. checkout_pr refuses unless current HEAD matches the run-entry branch or the target `pr-N` (idempotent same-PR re-checkout). uses live rev-parse, not toolState.issueNumber (poisonable per the PR #796 review). refusal error names the current branch, target PR, recovery path (`git checkout <initialBranch>` with the literal branch name), and explicitly states routing around via the `git` tool is not sanctioned. closes the zed-industries/cloud (2026-05-18) shape where a subagent parked HEAD on someone else's `pr-X` and the orchestrator's next checkout_pr inherited that position. * reviewfrog: enforce canonical diff + pre-commit halt; align Build dispatch extend REVIEWER_SYSTEM_PROMPT with two prepended HARD CONSTRAINTS: - first action MUST be `git diff origin/<base>` (single-rev, captures uncommitted). no other diff first; no checkout_pr; no alt-ref fetches; no branch listing; no `gh pr list`. - empty canonical diff + claimed-changes dispatch ⇒ reply exactly with `no changes detected — likely pre-commit Build self-review; orchestrator should commit then re-dispatch` and stop. do not guess PR numbers (the zed thrash that ended in `checkout_pr({2582})`). reshape Build mode reviewfrog dispatch step around a verbatim template that names: (a) the situation is pre-commit, (b) canonical diff command, (c) halt-on-empty-diff rule. orchestrator side now says the same thing as the reviewer's baked-in prompt. delegation-discipline bullets and orchestrator-evaluation guidance kept intact. * checkout_pr: handle detached-HEAD entry in initial-branch invariant pullfrog incremental review caught a defense-in-depth gap: `git rev-parse --abbrev-ref HEAD` returns the sentinel string `"HEAD"` on detached entry, which is the default `actions/checkout` state for `pull_request` events. with the previous string-typed `initialBranch`, both the captured value and the live probe would equal `"HEAD"` on any detached state, trivially satisfying the invariant — including a subagent doing `git checkout --detach <sha>`. discriminate the captured HEAD: probe `git symbolic-ref --short HEAD` first (works on named branches), fall back to `git rev-parse HEAD` (SHA) on detached entry. store as `{ kind: "branch"; name } | { kind: "detached"; sha }`. checkout_pr runs the identical probe at call time and compares like-with-like (branch name vs branch name, SHA vs SHA). refusal error renders both heads via a small `describeHead` helper and chooses the right `git checkout` recovery target (branch name or SHA). no inline-discriminant `as` casts — uses a top-level `headsEqual` that narrows via the discriminator.
881 lines
35 KiB
TypeScript
881 lines
35 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
|
|
import { type } from "arktype";
|
|
import { log } from "../utils/cli.ts";
|
|
import { countLines, createDiffCoverageState } from "../utils/diffCoverage.ts";
|
|
import { $git, $gitFetchWithDeepen } from "../utils/gitAuth.ts";
|
|
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
|
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
|
|
import { retry } from "../utils/retry.ts";
|
|
import { $ } from "../utils/shell.ts";
|
|
import { rejectIfLeadingDash } from "./git.ts";
|
|
import { commentableLinesForFile } from "./review.ts";
|
|
import type { ToolContext } from "./server.ts";
|
|
import { execute, tool } from "./shared.ts";
|
|
|
|
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
|
|
|
export type FormatFilesResult = {
|
|
content: string;
|
|
toc: string;
|
|
};
|
|
|
|
export type FetchAndFormatPrDiffResult = FormatFilesResult & {
|
|
files: PullFile[];
|
|
};
|
|
|
|
/**
|
|
* formats PR files with explicit line numbers for each code line.
|
|
* preserves all original diff info (file headers, hunk headers) and adds:
|
|
* | OLD | NEW | TYPE | code
|
|
* returns both the formatted content and a TOC with line ranges per file.
|
|
*/
|
|
export function formatFilesWithLineNumbers(files: PullFile[]): FormatFilesResult {
|
|
const output: string[] = [];
|
|
const tocEntries: Array<{ filename: string; startLine: number; endLine: number }> = [];
|
|
|
|
// calculate TOC header size: "## Files (N)\n" + N entries + "\n---\n\n"
|
|
const tocHeaderSize = 1 + files.length + 2;
|
|
let currentLine = tocHeaderSize + 1;
|
|
|
|
for (const file of files) {
|
|
const fileStartLine = currentLine;
|
|
|
|
// file header
|
|
output.push(`diff --git a/${file.filename} b/${file.filename}`);
|
|
output.push(`--- a/${file.filename}`);
|
|
output.push(`+++ b/${file.filename}`);
|
|
currentLine += 3;
|
|
|
|
if (!file.patch) {
|
|
output.push("(binary file or no changes)");
|
|
output.push("");
|
|
currentLine += 2;
|
|
tocEntries.push({
|
|
filename: file.filename,
|
|
startLine: fileStartLine,
|
|
endLine: currentLine - 1,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// parse and format the patch with line numbers
|
|
const lines = file.patch.split("\n");
|
|
let oldLine = 0;
|
|
let newLine = 0;
|
|
|
|
for (const line of lines) {
|
|
// hunk header: @@ -OLD,COUNT +NEW,COUNT @@ optional context
|
|
const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
if (hunkMatch) {
|
|
oldLine = parseInt(hunkMatch[1], 10);
|
|
newLine = parseInt(hunkMatch[2], 10);
|
|
output.push(line); // pass through unchanged
|
|
currentLine++;
|
|
continue;
|
|
}
|
|
|
|
// code lines within hunks
|
|
const changeType = line[0] || " ";
|
|
const code = line.slice(1);
|
|
|
|
if (changeType === "-") {
|
|
// removed line: show old line number, no new line number
|
|
output.push(`| ${padNum(oldLine)} | | - | ${code}`);
|
|
oldLine++;
|
|
} else if (changeType === "+") {
|
|
// added line: no old line number, show new line number
|
|
output.push(`| | ${padNum(newLine)} | + | ${code}`);
|
|
newLine++;
|
|
} else if (changeType === " " || changeType === "\\") {
|
|
// context line or "\ No newline at end of file"
|
|
if (changeType === "\\") {
|
|
output.push(line); // pass through as-is
|
|
} else {
|
|
output.push(`| ${padNum(oldLine)} | ${padNum(newLine)} | | ${code}`);
|
|
oldLine++;
|
|
newLine++;
|
|
}
|
|
} else {
|
|
// unknown line type, pass through
|
|
output.push(line);
|
|
}
|
|
currentLine++;
|
|
}
|
|
output.push(""); // blank line between files
|
|
currentLine++;
|
|
|
|
tocEntries.push({
|
|
filename: file.filename,
|
|
startLine: fileStartLine,
|
|
endLine: currentLine - 1,
|
|
});
|
|
}
|
|
|
|
// build TOC. each entry includes the precomputed sha256 anchor used in
|
|
// github PR Files Changed URLs (#diff-<hex>), so the agent never needs to
|
|
// shell out to sha256sum.
|
|
const tocLines = [`## Files (${files.length})`];
|
|
for (const entry of tocEntries) {
|
|
const anchor = createHash("sha256").update(entry.filename).digest("hex");
|
|
tocLines.push(
|
|
`- ${entry.filename} → lines ${entry.startLine}-${entry.endLine} · diff-${anchor}`
|
|
);
|
|
}
|
|
tocLines.push("");
|
|
tocLines.push("---");
|
|
tocLines.push("");
|
|
|
|
const toc = tocLines.join("\n");
|
|
const content = toc + output.join("\n");
|
|
|
|
return { content, toc };
|
|
}
|
|
|
|
function padNum(n: number): string {
|
|
return n.toString().padStart(4, " ");
|
|
}
|
|
|
|
export const CheckoutPr = type({
|
|
pull_number: type.number.describe("the pull request number to checkout"),
|
|
});
|
|
|
|
export type CheckoutPrResult = {
|
|
success: true;
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
base: string;
|
|
localBranch: string;
|
|
remoteBranch: string;
|
|
isFork: boolean;
|
|
maintainerCanModify: boolean;
|
|
url: string;
|
|
headRepo: string;
|
|
diffPath: string;
|
|
incrementalDiffPath?: string | undefined;
|
|
toc: string;
|
|
commitCount: number;
|
|
commitLog: string;
|
|
/** true when commitLog was capped because the PR has more commits than we render */
|
|
commitLogTruncated: boolean;
|
|
/** true when commit metadata could not be computed (e.g. base ref unreachable after shallow fetch). commitCount/commitLog are zero/empty in that case, not "no commits". */
|
|
commitLogUnavailable: boolean;
|
|
/** non-fatal warning from the post-checkout lifecycle hook, if any */
|
|
hookWarning?: string | undefined;
|
|
instructions: string;
|
|
};
|
|
|
|
/**
|
|
* fetches PR files from GitHub and formats them with line numbers and TOC.
|
|
* this is the core diff formatting logic, extracted for testability.
|
|
*/
|
|
export async function fetchAndFormatPrDiff(
|
|
ctx: ToolContext,
|
|
pullNumber: number
|
|
): Promise<FetchAndFormatPrDiffResult> {
|
|
const files = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listFiles, {
|
|
owner: ctx.repo.owner,
|
|
repo: ctx.repo.name,
|
|
pull_number: pullNumber,
|
|
per_page: 100,
|
|
});
|
|
return { ...formatFilesWithLineNumbers(files), files };
|
|
}
|
|
|
|
import { captureInitialHead, type GitContext } from "../utils/setup.ts";
|
|
|
|
export type PrData = {
|
|
number: number;
|
|
headSha: string;
|
|
headRef: string;
|
|
headRepoFullName: string;
|
|
baseRef: string;
|
|
baseRepoFullName: string;
|
|
maintainerCanModify: boolean;
|
|
};
|
|
|
|
type EnsureBeforeShaParams = {
|
|
sha: string;
|
|
octokit: Octokit;
|
|
owner: string;
|
|
repo: string;
|
|
gitToken: string;
|
|
isShallow: boolean;
|
|
};
|
|
|
|
type CreateTempBranchParams = {
|
|
octokit: Octokit;
|
|
owner: string;
|
|
repo: string;
|
|
ref: string;
|
|
sha: string;
|
|
};
|
|
|
|
async function createTempBranch(params: CreateTempBranchParams) {
|
|
const response = await params.octokit.rest.git.createRef({
|
|
owner: params.owner,
|
|
repo: params.repo,
|
|
ref: `refs/heads/${params.ref}`,
|
|
sha: params.sha,
|
|
});
|
|
return {
|
|
data: response.data,
|
|
async [Symbol.asyncDispose]() {
|
|
try {
|
|
await params.octokit.rest.git.deleteRef({
|
|
owner: params.owner,
|
|
repo: params.repo,
|
|
ref: `heads/${params.ref}`,
|
|
});
|
|
log.debug(`» deleted temp branch ${params.ref}`);
|
|
} catch (e) {
|
|
log.debug(
|
|
`» failed to delete temp branch ${params.ref}: ${e instanceof Error ? e.message : String(e)}`
|
|
);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async function ensureBeforeShaReachable(params: EnsureBeforeShaParams): Promise<boolean> {
|
|
try {
|
|
$("git", ["cat-file", "-t", params.sha], { log: false });
|
|
log.debug(`» before_sha ${params.sha.slice(0, 7)} is reachable`);
|
|
return true;
|
|
} catch {
|
|
// not available locally — create a temporary branch to fetch it
|
|
}
|
|
|
|
const tempBranch = `pullfrog/tmp/${params.sha.slice(0, 12)}`;
|
|
try {
|
|
log.debug(`» before_sha ${params.sha.slice(0, 7)} not reachable, creating temp branch...`);
|
|
await using _ref = await createTempBranch({
|
|
octokit: params.octokit,
|
|
owner: params.owner,
|
|
repo: params.repo,
|
|
sha: params.sha,
|
|
ref: tempBranch,
|
|
});
|
|
await $gitFetchWithDeepen(
|
|
["--no-tags", ...(params.isShallow ? ["--depth=1"] : []), "origin", tempBranch],
|
|
{ token: params.gitToken },
|
|
`before_sha temp branch ${tempBranch}`
|
|
);
|
|
log.debug(`» fetched before_sha via temp branch ${tempBranch}`);
|
|
return true;
|
|
} catch (e) {
|
|
log.debug(`» failed to fetch before_sha: ${e instanceof Error ? e.message : String(e)}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type CheckoutPrBranchParams = GitContext & {
|
|
beforeSha?: string | undefined;
|
|
};
|
|
|
|
// stale lock files left over from a crashed/cancelled prior git process block
|
|
// every subsequent fetch with `Unable to create '<path>': File exists`. only
|
|
// sweep locks older than this threshold so we never race a concurrent
|
|
// legitimate git op that's holding the lock.
|
|
const STALE_LOCK_AGE_MS = 30_000;
|
|
|
|
// PR head refs (refs/pull/N/head) sometimes lag the pull_request.opened
|
|
// webhook by a few seconds. retry the missing-ref case with backoff
|
|
// before giving up — see issue #591.
|
|
const PULL_REF_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
|
|
const PULL_REF_MISSING_PATTERN = /couldn't find remote ref pull\/\d+\/head/i;
|
|
|
|
const GIT_LOCK_PATHS = [
|
|
".git/shallow.lock",
|
|
".git/index.lock",
|
|
".git/objects/maintenance.lock",
|
|
] as const;
|
|
|
|
function cleanupStaleGitLocks(): void {
|
|
const now = Date.now();
|
|
for (const relPath of GIT_LOCK_PATHS) {
|
|
let mtimeMs: number;
|
|
try {
|
|
mtimeMs = statSync(relPath).mtimeMs;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (now - mtimeMs < STALE_LOCK_AGE_MS) continue;
|
|
try {
|
|
unlinkSync(relPath);
|
|
log.warning(`» removed stale ${relPath} from prior run`);
|
|
} catch (e) {
|
|
log.debug(
|
|
`» failed to remove stale ${relPath}: ${e instanceof Error ? e.message : String(e)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns false when a PR's current state diverges from what we dispatched
|
|
* on (closed/merged, or head SHA differs from pr.headSha). Used to short-
|
|
* circuit the pull/N/head retry loop when the ref is missing because the
|
|
* PR has moved on, not because of a webhook race.
|
|
*
|
|
* Network failures here are treated as "still valid" — we'd rather burn the
|
|
* retry budget than wrongly abort on a transient API blip.
|
|
*
|
|
* Note: this answers "should we keep trying?", NOT "will the next fetch
|
|
* succeed?". `pulls.get` (REST API) and `pull/N/head` (git ref) are served
|
|
* by independent GitHub replicas with their own propagation lag, so
|
|
* `pulls.get` reporting an open PR with a matching head SHA does not
|
|
* guarantee the git ref is yet visible — and vice versa (see issue #591
|
|
* for the original webhook-vs-ref replication-lag context).
|
|
*/
|
|
async function isPullRequestStillDispatchable(args: {
|
|
octokit: Octokit;
|
|
owner: string;
|
|
repo: string;
|
|
pr: PrData;
|
|
}): Promise<boolean> {
|
|
try {
|
|
const { data } = await args.octokit.rest.pulls.get({
|
|
owner: args.owner,
|
|
repo: args.repo,
|
|
pull_number: args.pr.number,
|
|
});
|
|
if (data.state !== "open") return false;
|
|
if (data.head.sha !== args.pr.headSha) return false;
|
|
return true;
|
|
} catch {
|
|
// lenient — don't abort on API hiccups
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Throws the friendly clean-abort error when the PR has moved on since
|
|
* dispatch. Wraps `isPullRequestStillDispatchable` so the abort message
|
|
* lives in one place and is invoked from the inner `catch` around the
|
|
* `pull/N/head` fetch on every missing-ref failure.
|
|
*/
|
|
async function abortIfPullRequestMoved(args: {
|
|
octokit: Octokit;
|
|
owner: string;
|
|
repo: string;
|
|
pr: PrData;
|
|
}): Promise<void> {
|
|
const stillValid = await isPullRequestStillDispatchable(args);
|
|
if (stillValid) return;
|
|
throw new Error(
|
|
`PR #${args.pr.number} is no longer in the state it was at dispatch (likely closed, merged, or force-pushed between webhook fire and run start). aborting checkout — re-trigger the run if this PR is still active.`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Shared helper to checkout a PR branch and configure fork remotes.
|
|
* Assumes origin remote is already configured with authentication.
|
|
* Updates toolState.issueNumber, toolState.checkoutSha, and toolState.pushUrl (for fork PRs).
|
|
*/
|
|
export async function checkoutPrBranch(
|
|
pr: PrData,
|
|
params: CheckoutPrBranchParams
|
|
): Promise<{ hookWarning?: string | undefined }> {
|
|
const { octokit, owner, name, gitToken, toolState, beforeSha } = params;
|
|
log.info(`» checking out PR #${pr.number}...`);
|
|
|
|
// SECURITY: PR ref names come from GitHub and are attacker-controlled on
|
|
// forks (the PR author picks headRef freely, and baseRef could be a
|
|
// maliciously-named branch on the target repo). reject leading-dash names
|
|
// before they reach any git command — without this, a ref like
|
|
// "-upload-pack=evil" fed into `git fetch origin <ref>` would be parsed as
|
|
// a flag, not a refspec.
|
|
rejectIfLeadingDash(pr.baseRef, "PR base ref");
|
|
rejectIfLeadingDash(pr.headRef, "PR head ref");
|
|
|
|
// self-hosted runners and cancelled jobs frequently leave stale .git/*.lock
|
|
// files behind. without this sweep, the first fetch below aborts with
|
|
// `Unable to create '.git/shallow.lock': File exists` and the agent has to
|
|
// shell out to `rm -f` (issue #564).
|
|
cleanupStaleGitLocks();
|
|
|
|
const isFork = pr.headRepoFullName !== pr.baseRepoFullName;
|
|
|
|
// always use pr-{number} as local branch name for consistency
|
|
// this avoids naming conflicts and makes push config simpler
|
|
const localBranch = `pr-${pr.number}`;
|
|
|
|
const isShallow =
|
|
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
|
|
|
|
toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
|
const alreadyOnBranch = toolState.checkoutSha === pr.headSha;
|
|
|
|
// fetch base branch so origin/<base> exists for diff operations.
|
|
// wrap with deepen-retry: on shallow clones (the actions/checkout default
|
|
// is depth=1), repos with deep PR ancestry can't reach the baseRef tip in
|
|
// a single round trip, surfacing as `Could not read <sha>` / `remote did
|
|
// not send all necessary objects` (issue #656).
|
|
log.debug(`» fetching base branch (${pr.baseRef})...`);
|
|
await $gitFetchWithDeepen(
|
|
["--no-tags", "origin", pr.baseRef],
|
|
{ token: gitToken },
|
|
`base branch ${pr.baseRef}`
|
|
);
|
|
|
|
// alreadyOnBranch only matches for repeated checkout_pr calls for the same PR in one session
|
|
// (without the tip moving), or if an external setup already checked out the PR head.
|
|
// normal PR-triggered runs won't match here — actions/checkout lands on a synthesized
|
|
// merge commit whose SHA differs from pr.headSha.
|
|
//
|
|
// so the fetch+checkout block below will almost always execute, and the fetched HEAD
|
|
// might differ from pr.headSha. toolState.checkoutSha is set after to capture the actual SHA.
|
|
if (!alreadyOnBranch) {
|
|
// checkout base branch first to avoid "refusing to fetch into current branch" error
|
|
// -B creates or resets the branch to match origin/baseBranch
|
|
$("git", ["checkout", "-B", pr.baseRef, `origin/${pr.baseRef}`], { log: false });
|
|
|
|
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs).
|
|
// two transient classes wrap this fetch:
|
|
// - shallow-unreachable (`Could not read <sha>` etc.) — handled by the
|
|
// inner `$gitFetchWithDeepen` deepen-retry (one shot, see issue #656)
|
|
// - pull/N/head webhook race (`couldn't find remote ref pull/N/head`) —
|
|
// handled by the outer retry below (see issue #591)
|
|
log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
|
|
await retry(
|
|
async () => {
|
|
try {
|
|
await $gitFetchWithDeepen(
|
|
["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`],
|
|
{ token: gitToken },
|
|
`PR #${pr.number}`
|
|
);
|
|
} catch (e) {
|
|
// on the webhook race, check whether the PR still matches what we
|
|
// dispatched on. if it's been closed/merged or the head SHA moved,
|
|
// no amount of retrying will populate the expected ref — surface a
|
|
// clean abort error instead of burning the full retry budget.
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
if (PULL_REF_MISSING_PATTERN.test(msg)) {
|
|
await abortIfPullRequestMoved({ octokit, owner, repo: name, pr });
|
|
}
|
|
throw e;
|
|
}
|
|
},
|
|
{
|
|
delaysMs: PULL_REF_RETRY_DELAYS_MS,
|
|
label: `pull/${pr.number}/head fetch`,
|
|
shouldRetry: (e) =>
|
|
PULL_REF_MISSING_PATTERN.test(e instanceof Error ? e.message : String(e)),
|
|
}
|
|
);
|
|
|
|
// checkout the branch
|
|
$("git", ["checkout", localBranch], { log: false });
|
|
log.debug(`» checked out PR #${pr.number}`);
|
|
// make sure toolState.checkoutSha is set to the actual checked-out SHA (which might be different from pr.headSha)
|
|
toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
|
}
|
|
|
|
const beforeShaReachable = beforeSha
|
|
? await ensureBeforeShaReachable({
|
|
sha: beforeSha,
|
|
octokit,
|
|
owner,
|
|
repo: name,
|
|
gitToken,
|
|
isShallow,
|
|
})
|
|
: false;
|
|
|
|
// compute deepen depth for shallow clones. actions/checkout uses depth=1
|
|
// by default, which breaks rebase/log because git can't find the merge base.
|
|
// use the GitHub compare API to fetch exactly enough history.
|
|
// computed after checkout so compareCommits uses the actual checked-out SHA.
|
|
if (isShallow) {
|
|
let deepenDepth = 0;
|
|
try {
|
|
// ahead_by = PR commits past merge base, behind_by = base commits past merge base.
|
|
// --deepen extends ALL shallow roots equally (can't deepen a single branch),
|
|
// so we need the max across both the PR head and before_sha to ensure all
|
|
// three points (base, head, before_sha) reach the merge base in a single deepen call.
|
|
const [prComparison, beforeShaComparison] = await Promise.all([
|
|
octokit.rest.repos.compareCommits({
|
|
owner,
|
|
repo: name,
|
|
base: pr.baseRef,
|
|
head: toolState.checkoutSha,
|
|
}),
|
|
beforeSha && beforeShaReachable
|
|
? octokit.rest.repos.compareCommits({
|
|
owner,
|
|
repo: name,
|
|
base: pr.baseRef,
|
|
head: beforeSha,
|
|
})
|
|
: undefined,
|
|
]);
|
|
deepenDepth =
|
|
Math.max(
|
|
prComparison.data.ahead_by,
|
|
prComparison.data.behind_by,
|
|
beforeShaComparison?.data.ahead_by ?? 0,
|
|
beforeShaComparison?.data.behind_by ?? 0
|
|
) + 10;
|
|
log.debug(
|
|
`» PR: ${prComparison.data.ahead_by} ahead / ${prComparison.data.behind_by} behind` +
|
|
(beforeShaComparison
|
|
? `, before_sha: ${beforeShaComparison.data.ahead_by} ahead / ${beforeShaComparison.data.behind_by} behind`
|
|
: "") +
|
|
`, deepen by ${deepenDepth}`
|
|
);
|
|
} catch {
|
|
deepenDepth = 1000;
|
|
log.debug(`» compare API failed, falling back to --deepen=${deepenDepth}`);
|
|
}
|
|
// deepen after both branches are fetched so the merge base is reachable from both sides
|
|
if (deepenDepth) {
|
|
log.debug(`» deepening by ${deepenDepth} to reach merge base...`);
|
|
await $git("fetch", [`--deepen=${deepenDepth}`, "--no-tags", "origin"], {
|
|
token: gitToken,
|
|
});
|
|
}
|
|
}
|
|
|
|
// configure push remote for this branch
|
|
// NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure
|
|
// fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit.
|
|
if (isFork) {
|
|
const remoteName = `pr-${pr.number}`;
|
|
// SECURITY: fork URL without token - auth is injected via GIT_ASKPASS in $git()
|
|
const forkUrl = `https://github.com/${pr.headRepoFullName}.git`;
|
|
|
|
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
|
|
try {
|
|
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
|
|
log.debug(`» added remote '${remoteName}' for fork ${pr.headRepoFullName}`);
|
|
} catch {
|
|
// remote already exists, update its URL
|
|
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
|
|
log.debug(`» updated remote '${remoteName}' for fork ${pr.headRepoFullName}`);
|
|
}
|
|
|
|
// set branch push config so `git push` knows where to push
|
|
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false });
|
|
// set merge ref so git knows the remote branch name (may differ from local)
|
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
|
|
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${pr.headRef}'`);
|
|
|
|
// warn if maintainer can't modify (push will likely fail)
|
|
if (!pr.maintainerCanModify) {
|
|
log.warning(
|
|
`» fork PR has maintainer_can_modify=false - push operations will fail. ` +
|
|
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
|
|
);
|
|
}
|
|
} else {
|
|
// for same-repo PRs, push to origin
|
|
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"], { log: false });
|
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${pr.headRef}`], { log: false });
|
|
}
|
|
|
|
// update toolState
|
|
toolState.issueNumber = pr.number;
|
|
if (isFork) {
|
|
toolState.pushUrl = `https://github.com/${pr.headRepoFullName}.git`;
|
|
}
|
|
|
|
// store push destination so push_branch can use it directly
|
|
// git config is the primary mechanism, but toolState serves as a reliable fallback
|
|
// in case git config reads fail in certain environments
|
|
toolState.pushDest = {
|
|
remoteName: isFork ? `pr-${pr.number}` : "origin",
|
|
remoteBranch: pr.headRef,
|
|
localBranch,
|
|
};
|
|
|
|
// execute post-checkout lifecycle hook. soft-fail: surface the warning
|
|
// to the agent via the tool response instead of throwing, so a flaky or
|
|
// slightly-broken hook doesn't block checkout entirely.
|
|
const postCheckoutHook = await executeLifecycleHook({
|
|
event: "post-checkout",
|
|
script: params.postCheckoutScript,
|
|
});
|
|
return { hookWarning: postCheckoutHook.warning };
|
|
}
|
|
|
|
/**
|
|
* dedupes concurrent `checkout_pr` calls for the same PR. agents (notably
|
|
* Sonnet/Claude) occasionally emit duplicate parallel tool_use blocks for the
|
|
* same args in one turn; without this, both invocations race
|
|
* `checkoutPrBranch` against the same `.git/shallow.lock` and one fails with
|
|
* `File exists` (issue #642). cleared in `finally` so subsequent same-PR
|
|
* calls re-do the work normally.
|
|
*/
|
|
const inFlightCheckouts = new Map<number, Promise<CheckoutPrResult>>();
|
|
|
|
type InitialHead = NonNullable<ToolContext["toolState"]["initialHead"]>;
|
|
|
|
function headsEqual(a: InitialHead, b: InitialHead): boolean {
|
|
if (a.kind === "branch" && b.kind === "branch") return a.name === b.name;
|
|
if (a.kind === "detached" && b.kind === "detached") return a.sha === b.sha;
|
|
return false;
|
|
}
|
|
|
|
function describeHead(h: InitialHead): string {
|
|
if (h.kind === "branch") return `branch \`${h.name}\``;
|
|
return `detached HEAD \`${h.sha}\``;
|
|
}
|
|
|
|
export function CheckoutPrTool(ctx: ToolContext) {
|
|
const runCheckout = async (pull_number: number): Promise<CheckoutPrResult> => {
|
|
const prResponse = await ctx.octokit.rest.pulls.get({
|
|
owner: ctx.repo.owner,
|
|
repo: ctx.repo.name,
|
|
pull_number,
|
|
});
|
|
|
|
const headRepo = prResponse.data.head.repo;
|
|
if (!headRepo) {
|
|
throw new Error(`PR #${pull_number} source repository was deleted`);
|
|
}
|
|
|
|
const pr: PrData = {
|
|
number: pull_number,
|
|
headSha: prResponse.data.head.sha,
|
|
headRef: prResponse.data.head.ref,
|
|
headRepoFullName: headRepo.full_name,
|
|
baseRef: prResponse.data.base.ref,
|
|
baseRepoFullName: prResponse.data.base.repo.full_name,
|
|
maintainerCanModify: prResponse.data.maintainer_can_modify,
|
|
};
|
|
|
|
const checkoutResult = await checkoutPrBranch(pr, {
|
|
octokit: ctx.octokit,
|
|
owner: ctx.repo.owner,
|
|
name: ctx.repo.name,
|
|
gitToken: ctx.gitToken,
|
|
toolState: ctx.toolState,
|
|
shell: ctx.payload.shell,
|
|
postCheckoutScript: ctx.postCheckoutScript,
|
|
beforeSha: ctx.toolState.beforeSha,
|
|
});
|
|
|
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
|
if (!tempDir) {
|
|
throw new Error(
|
|
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
|
|
);
|
|
}
|
|
|
|
const headShort = ctx.toolState.checkoutSha!.slice(0, 7);
|
|
|
|
// compute incremental diff if we have a beforeSha to compare against
|
|
let incrementalDiffPath: string | undefined;
|
|
if (ctx.toolState.beforeSha && ctx.toolState.checkoutSha) {
|
|
const beforeShort = ctx.toolState.beforeSha.slice(0, 7);
|
|
const incremental = computeIncrementalDiff({
|
|
baseBranch: pr.baseRef,
|
|
beforeSha: ctx.toolState.beforeSha,
|
|
headSha: ctx.toolState.checkoutSha,
|
|
});
|
|
if (incremental) {
|
|
incrementalDiffPath = join(
|
|
tempDir,
|
|
`pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
|
|
);
|
|
writeFileSync(incrementalDiffPath, incremental);
|
|
log.info(
|
|
`» incremental diff computed (${incremental.length} bytes) → ${incrementalDiffPath}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// fetch PR files and format with line numbers
|
|
const formatResult = await fetchAndFormatPrDiff(ctx, pull_number);
|
|
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
|
|
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
|
|
const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`);
|
|
writeFileSync(diffPath, formatResult.content);
|
|
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
|
|
ctx.toolState.diffCoverage = createDiffCoverageState({
|
|
diffPath,
|
|
totalLines: countLines({ content: formatResult.content }),
|
|
toc: formatResult.toc,
|
|
previous: ctx.toolState.diffCoverage,
|
|
});
|
|
log.debug(
|
|
`» diff coverage initialized: diffPath=${diffPath}, totalLines=${ctx.toolState.diffCoverage.totalLines}, tocEntries=${ctx.toolState.diffCoverage.tocEntries.length}`
|
|
);
|
|
|
|
// cache commentable-lines snapshot so review-time validation matches what
|
|
// GitHub will anchor to (commit_id=checkoutSha), even if the PR is updated
|
|
// between checkout and review.
|
|
const cached = new Map<string, ReturnType<typeof commentableLinesForFile>>();
|
|
for (const file of formatResult.files) {
|
|
cached.set(file.filename, commentableLinesForFile(file.patch));
|
|
}
|
|
ctx.toolState.commentableLinesByFile = cached;
|
|
ctx.toolState.commentableLinesPullNumber = pull_number;
|
|
ctx.toolState.commentableLinesCheckoutSha = ctx.toolState.checkoutSha;
|
|
|
|
const incrementalInstructions = incrementalDiffPath
|
|
? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version ` +
|
|
`(computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, ` +
|
|
`then use diffPath for full PR context. do NOT skip the incremental diff.`
|
|
: "";
|
|
|
|
// commit metadata relative to the PR base (e.g. main). use origin/<base>
|
|
// because the local base ref may not exist after a shallow fetch. cap
|
|
// the log so a PR with thousands of commits doesn't blow up the tool
|
|
// response. if the base ref can't be resolved (e.g. shallow fetch that
|
|
// didn't pull down origin/<base>), degrade gracefully rather than
|
|
// failing the whole checkout_pr call over metadata.
|
|
const COMMIT_LOG_MAX = 200;
|
|
const baseRange = `origin/${pr.baseRef}..HEAD`;
|
|
let commitCount = 0;
|
|
let commitLog = "";
|
|
let commitLogUnavailable = false;
|
|
try {
|
|
commitCount = parseInt(
|
|
$("git", ["rev-list", "--count", baseRange], { log: false }).trim() || "0",
|
|
10
|
|
);
|
|
commitLog = $("git", ["log", "--oneline", `--max-count=${COMMIT_LOG_MAX}`, baseRange], {
|
|
log: false,
|
|
});
|
|
} catch (err) {
|
|
commitLogUnavailable = true;
|
|
log.debug(
|
|
`» unable to compute commit metadata for ${baseRange}: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
const commitLogTruncated = commitCount > COMMIT_LOG_MAX;
|
|
|
|
const hookWarningInstructions = checkoutResult.hookWarning
|
|
? ` HOOK WARNING: the post-checkout lifecycle hook reported a non-fatal failure (see hookWarning). ` +
|
|
`decide whether to retry based on the guidance in that field before proceeding.`
|
|
: "";
|
|
|
|
const commitLogInstructions = commitLogUnavailable
|
|
? ` NOTE: commit metadata is partial (base ref unreachable, likely a shallow fetch). ` +
|
|
`commitCount/commitLog may be 0/empty or incomplete; treat them as "unknown" rather than "no commits", ` +
|
|
`and use \`git log\` directly if you need the full history.`
|
|
: commitLogTruncated
|
|
? ` NOTE: commitLog was capped at ${COMMIT_LOG_MAX} entries out of ${commitCount} commits; ` +
|
|
`use \`git log\` directly if you need the full history.`
|
|
: "";
|
|
|
|
return {
|
|
success: true,
|
|
number: prResponse.data.number,
|
|
title: prResponse.data.title,
|
|
body: prResponse.data.body,
|
|
base: pr.baseRef,
|
|
localBranch: `pr-${pull_number}`,
|
|
remoteBranch: `refs/heads/${pr.headRef}`,
|
|
isFork: pr.headRepoFullName !== pr.baseRepoFullName,
|
|
maintainerCanModify: pr.maintainerCanModify,
|
|
url: prResponse.data.html_url,
|
|
headRepo: pr.headRepoFullName,
|
|
diffPath,
|
|
incrementalDiffPath,
|
|
toc: formatResult.toc,
|
|
commitCount,
|
|
commitLog,
|
|
commitLogTruncated,
|
|
commitLogUnavailable,
|
|
hookWarning: checkoutResult.hookWarning,
|
|
instructions:
|
|
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
|
|
`use the TOC line ranges as your checklist and read specific files from the diff instead of reading the entire file. ` +
|
|
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
|
|
`review files selectively based on relevance rather than reading everything sequentially. ` +
|
|
`to inspect the PR's changed files, use diffPath — do NOT run \`git diff <base>..<head>\` to re-derive what's already in diffPath. the formatted diff with line numbers is authoritative. ` +
|
|
`\`git log\` and \`git diff --stat\` are fine for commit-range overview, and \`git diff\` / \`git diff --cached\` are fine for inspecting *your own* uncommitted changes — but PR review content MUST come from diffPath. ` +
|
|
`before your review is submitted, a one-time coverage pre-flight may error listing unread TOC regions. ` +
|
|
`retry the same create_pull_request_review call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session. ` +
|
|
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
|
|
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` +
|
|
incrementalInstructions +
|
|
hookWarningInstructions +
|
|
commitLogInstructions,
|
|
} satisfies CheckoutPrResult;
|
|
};
|
|
|
|
return tool({
|
|
name: "checkout_pr",
|
|
timeoutMs: 600_000,
|
|
description:
|
|
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
|
|
"Returns diffPath pointing to the formatted diff file. " +
|
|
"Example: `checkout_pr({ pull_number: 1234 })`. " +
|
|
"Large repos can take several minutes — wait for the call to finish; do not treat a slow response as failure. " +
|
|
"If you see `MCP error -32001: Request timed out`, retry the same call without touching git lock files first — that error is a client-side abort. " +
|
|
"If the retry then reports `.git/shallow.lock: File exists` or `.git/index.lock: File exists`, remove those lock files via the shell tool and retry again.",
|
|
parameters: CheckoutPr,
|
|
execute: execute(async ({ pull_number }) => {
|
|
const inFlight = inFlightCheckouts.get(pull_number);
|
|
if (inFlight) {
|
|
log.info(`» checkout_pr({pull_number:${pull_number}}) already in flight — sharing result`);
|
|
return inFlight;
|
|
}
|
|
|
|
// unconditional refusal: any dirty working tree blocks checkout_pr, even
|
|
// when HEAD is already on pr-N. no stashing, no live-HEAD escape hatch.
|
|
// shared-cwd subagents made "carry edits along" semantics dangerous
|
|
// (zed-industries/cloud, 2026-05-18) — forcing commit/discard before
|
|
// any PR-context op eliminates the entire carry-forward failure class.
|
|
const dirty = $("git", ["status", "--porcelain"], { log: false }).trim();
|
|
if (dirty) {
|
|
throw new Error(
|
|
`cannot checkout PR #${pull_number} while the working tree has uncommitted changes. ` +
|
|
`commit (then push if needed), or discard with \`git restore --staged --worktree .\` / \`git clean -fd\` before retrying. ` +
|
|
`this refusal is unconditional — even re-checking-out the PR you're already on is refused, ` +
|
|
`because shared-working-tree subagents make carry-forward edits unsafe. dirty paths:\n${dirty}`
|
|
);
|
|
}
|
|
|
|
// initial-branch invariant: the only sanctioned HEAD positions for a
|
|
// checkout_pr call are (a) the run-entry HEAD captured by setupGit, or
|
|
// (b) `pr-${pull_number}` for idempotent same-PR re-checkout (e.g.
|
|
// re-fetch after the PR head moved). anything else means a subagent
|
|
// silently parked HEAD on another PR, which is the zed-industries/cloud
|
|
// (2026-05-18) cross-PR clobber shape. uses the same live probe (not
|
|
// toolState.issueNumber, poisonable per the PR #796 review) and
|
|
// discriminates branch vs detached so detached-entry runs don't get a
|
|
// trivial "any future detached state matches" carve-out.
|
|
const initialHead = ctx.toolState.initialHead;
|
|
if (initialHead) {
|
|
const currentHead = captureInitialHead(process.cwd());
|
|
const targetBranch = `pr-${pull_number}`;
|
|
const onTarget = currentHead.kind === "branch" && currentHead.name === targetBranch;
|
|
const onInitial = headsEqual(currentHead, initialHead);
|
|
if (!onTarget && !onInitial) {
|
|
const recoverCmd =
|
|
initialHead.kind === "branch"
|
|
? `git checkout ${initialHead.name}`
|
|
: `git checkout ${initialHead.sha}`;
|
|
throw new Error(
|
|
`cannot checkout PR #${pull_number} from ${describeHead(currentHead)}. ` +
|
|
`the only sanctioned HEAD positions for checkout_pr are the run-entry HEAD ` +
|
|
`(${describeHead(initialHead)}) or the target PR's branch (\`${targetBranch}\`, idempotent re-checkout). ` +
|
|
`recover with \`${recoverCmd}\` first — if that would carry uncommitted ` +
|
|
`work along, commit or discard it (\`git restore --staged --worktree .\` / \`git clean -fd\`) before switching. ` +
|
|
`routing around this via the \`git\` tool's \`checkout\`/\`switch\` subcommands is not sanctioned: ` +
|
|
`this guard exists to prevent the shared-working-tree cross-PR clobber pattern from the ` +
|
|
`zed-industries/cloud (2026-05-18) incident.`
|
|
);
|
|
}
|
|
}
|
|
|
|
const promise = runCheckout(pull_number);
|
|
inFlightCheckouts.set(pull_number, promise);
|
|
try {
|
|
return await promise;
|
|
} finally {
|
|
inFlightCheckouts.delete(pull_number);
|
|
}
|
|
}),
|
|
});
|
|
}
|