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
@@ -221,5 +221,37 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||
// disable credential helpers to prevent prompts and ensure clean auth state
|
||||
$("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir });
|
||||
|
||||
// pin the run-entry HEAD for the checkout_pr initial-branch invariant; see
|
||||
// captureInitialHead for the named-branch vs detached split and why it
|
||||
// matters (zed-industries/cloud 2026-05-18 cross-PR clobber shape).
|
||||
params.toolState.initialHead = captureInitialHead(repoDir);
|
||||
|
||||
log.info("» git authentication configured");
|
||||
}
|
||||
|
||||
/**
|
||||
* snapshot the current HEAD as either a branch name (when on a named branch)
|
||||
* or a literal SHA (when detached). used by setupGit to pin the run-entry
|
||||
* position and by checkout_pr to compare the live HEAD against it.
|
||||
*
|
||||
* splitting the two cases is load-bearing: `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. storing that
|
||||
* raw string would make any future detached state (including a subagent's
|
||||
* `git checkout --detach <sha>`) compare equal.
|
||||
*/
|
||||
export function captureInitialHead(
|
||||
repoDir: string
|
||||
): { kind: "branch"; name: string } | { kind: "detached"; sha: string } {
|
||||
try {
|
||||
const name = $("git", ["symbolic-ref", "--short", "HEAD"], {
|
||||
cwd: repoDir,
|
||||
log: false,
|
||||
}).trim();
|
||||
if (name) return { kind: "branch", name };
|
||||
} catch {
|
||||
// detached HEAD — fall through
|
||||
}
|
||||
const sha = $("git", ["rev-parse", "HEAD"], { cwd: repoDir, log: false }).trim();
|
||||
return { kind: "detached", sha };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user