checkout_pr: refuse unconditionally on dirty working tree (#808)
* 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.
This commit is contained in:
committed by
pullfrog[bot]
parent
d3b5340583
commit
01e4daa0b5
+57
-13
@@ -185,7 +185,7 @@ export async function fetchAndFormatPrDiff(
|
||||
return { ...formatFilesWithLineNumbers(files), files };
|
||||
}
|
||||
|
||||
import type { GitContext } from "../utils/setup.ts";
|
||||
import { captureInitialHead, type GitContext } from "../utils/setup.ts";
|
||||
|
||||
export type PrData = {
|
||||
number: number;
|
||||
@@ -613,6 +613,19 @@ export async function checkoutPrBranch(
|
||||
*/
|
||||
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({
|
||||
@@ -807,19 +820,50 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
// refuse to clobber an uncommitted tree whenever this call would move
|
||||
// HEAD away from the target pr-N branch. keyed off the live current
|
||||
// branch (not toolState.issueNumber, which is also written by
|
||||
// get_issue / get_issue_comments / get_issue_events and so doesn't
|
||||
// mean "currently checked out"). catches the subagent-sharing-cwd
|
||||
// case from zed-industries/cloud (2026-05-18).
|
||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
|
||||
if (currentBranch !== `pr-${pull_number}`) {
|
||||
const dirty = $("git", ["status", "--porcelain"], { log: false }).trim();
|
||||
if (dirty) {
|
||||
// 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} while the working tree has uncommitted changes. ` +
|
||||
`commit, push, or discard them before switching. dirty paths:\n${dirty}`
|
||||
`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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user