Skip to main content

car_server_core/coder/
merge.rs

1//! Merge-back: deliver the worktree's changes.
2//!
3//! Three destinations, by session kind:
4//! - **Raw repo** ([`publish_branch`]): the worktree gets one squash commit
5//!   (authored as `car-coder` — honest attribution) and the only thing that
6//!   touches the user's repository is `git branch car/coder/<id> <commit>`.
7//!   Branches are shared across worktrees, so this never disturbs the user's
8//!   checkout, index, or any existing ref; reverting is `git branch -D`.
9//! - **Managed project** ([`commit_to_main`]): the project is fully CAR-owned,
10//!   so there is no separate "user working tree" to protect — approve commits
11//!   straight to the project's `main` (fast-forward only). The non-dev sees
12//!   "Saved", not a branch to merge; reverting is ordinary git history.
13//! - **Headless / pull request** ([`deliver_pr`]): nobody is at the approval
14//!   gate, and the next round is a fresh session that will only ever see what
15//!   is on GitHub. The runtime commits, pushes append-only to a branch that is
16//!   stable across sessions, and reconciles exactly one pull request for it.
17
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use super::contract::OutcomeContract;
22use super::session::IntegratedSubtask;
23
24/// One-line summary of an intent, for a commit subject or a pull-request title.
25/// Capped at 72 characters on a char boundary — the git convention, and long
26/// enough that the truncation is rare.
27fn subject_from_intent(intent: &str) -> String {
28    // Line endings normalized FIRST, and that ordering is the whole point. The
29    // paragraph split below looks for `\n\n`, which a Windows-authored or
30    // Windows-pasted intent never contains — it separates paragraphs with
31    // `\r\n\r\n`. The split then matched nothing, `head` became the entire
32    // document, and the `replace('\n', " ")` that follows left every `\r` in
33    // place: the blank-line handling this function was given was silently off
34    // for exactly the callers most likely to paste a long intent, and the
35    // resulting commit subject and pull-request title carried embedded carriage
36    // returns. `--intent-file` reads bytes, so this is not an exotic path.
37    let trimmed = intent.replace("\r\n", "\n");
38    let trimmed = trimmed.trim();
39    // A blank line is git's own subject/body separator, and a caller that puts
40    // one there is saying where the summary ends. Honour it BEFORE flattening:
41    // `replace('\n', " ")` first destroyed the break, so a caller who correctly
42    // led with a short summary still got the pointer text dragged in behind it
43    // and truncated at 72 — the paragraph structure was gone before the length
44    // check could act on it. With no blank line, the flatten-and-truncate
45    // fallback below behaves exactly as it always has.
46    let head = trimmed.split("\n\n").next().unwrap_or(trimmed).trim();
47    // A lone `\r` (an old-Mac line ending, or one left by a mixed-ending file)
48    // is not a line break to git — it is a control character inside the
49    // subject.
50    let s = head.replace(['\n', '\r'], " ");
51    if s.len() > 72 {
52        let mut end = 69;
53        while !s.is_char_boundary(end) {
54            end -= 1;
55        }
56        format!("{}...", &s[..end])
57    } else {
58        s
59    }
60}
61
62/// What [`commit_worktree`] did, as a value rather than as a phrase inside an
63/// error string.
64///
65/// "The worktree was clean" is a normal outcome on the PR path — a round whose
66/// push failed re-delivers the commit it already made — so `deliver_pr_with` has
67/// to tell it apart from a commit that genuinely failed. It used to do that with
68/// `Err(e) if e.contains("no changes to deliver")`, and that phrase is not the
69/// exclusive property of the clean-worktree check: [`git`] formats every failure
70/// as `git {args:?} failed: {stderr}`, and the commit argv carries `subject` and
71/// `body`, both derived from the caller's `intent`. An intent that merely
72/// MENTIONS the phrase — "fix delivery so it reports 'no changes to deliver'
73/// correctly" is ordinary work in this repo — turned a real commit failure
74/// (`commit.gpgsign` with no agent, a shared `pre-commit` hook) into the
75/// re-delivery branch: the previous commit was pushed, the pull-request body was
76/// refreshed, and delivery returned `Ok` while the round's actual work sat
77/// uncommitted in the worktree. A typed variant cannot be spelled by a caller.
78enum CommitOutcome {
79    /// A commit was made; the new SHA.
80    Made(String),
81    /// The worktree had nothing to commit. Not an error here — the callers
82    /// decide what it means.
83    NothingToCommit,
84}
85
86/// The placement ledger as a commit-message TRAILER block, or `None` when this
87/// commit has no fleet-authored work in it.
88///
89/// The delivered commit's body opens "Authored by CAR Coder." A distributed run
90/// makes that sentence wrong in a way nothing else corrects: the work was
91/// authored across several machines, and the reader deciding on a
92/// `car/coder/<id>` branch has no other artifact to ask. car#1263 settled the
93/// shape of this argument one level up — a verdict's caveats have to reach the
94/// thing the verdict is read on, not only a status call.
95///
96/// **`integrated`, not the ledger.** A [`car_multi::Placement`] is recorded when
97/// a worker RETURNS, before the per-patch gate rules on what it produced. So a
98/// full ledger is compatible with zero fleet-authored hunks — `NothingAccepted`
99/// and `IntegrationRejected` both fall back to a locally-authored diff — and
100/// rendering it would credit peers for a commit they contributed nothing to.
101/// That false attribution is worse than the missing one this closes. `integrated`
102/// is built where patches are applied, so it can only name work that landed.
103///
104/// Trailers, not prose. `git interpret-trailers` and `%(trailers:key=…)` can
105/// read them, they survive a squash by concatenating, and they do not interleave
106/// with the rendered contract. NOT `Co-authored-by:` — GitHub resolves those to
107/// user accounts, and a machine is not a person.
108///
109/// **Nothing peer-supplied is interpolated raw.** A worker id and a subtask id
110/// are sanitized (control characters stripped, length capped); the failure
111/// strings on the ledger are deliberately NOT here at all. They carry a peer's
112/// verbatim response body and git stderr — unbounded, multi-line, and appended
113/// to the last paragraph of a `git commit -m`, which is a trailer-forgery seam,
114/// a NUL byte that makes a green run undeliverable, and an `E2BIG`. They are
115/// diagnostics with a lifetime, not a durable claim about authorship, so they
116/// stay on the session record and the event.
117pub fn placement_provenance(
118    placements: &[car_multi::Placement],
119    integrated: &[IntegratedSubtask],
120    repaired_locally: bool,
121) -> Option<String> {
122    if integrated.is_empty() {
123        return None;
124    }
125    let worker_of = |subtask_id: &str| -> Option<&car_multi::Placement> {
126        placements.iter().find(|p| p.subtask_id == subtask_id)
127    };
128    let mut lines: Vec<String> = Vec::new();
129    let mut any_remote = false;
130    for landed in integrated {
131        let Some(p) = worker_of(&landed.subtask_id) else {
132            continue;
133        };
134        // No worker, no location: `remote` is meaningless when nothing ran, and
135        // such a subtask cannot be in `integrated` anyway.
136        let Some(worker) = p.worker_id.as_deref() else {
137            continue;
138        };
139        any_remote |= p.remote;
140        let mut line = format!(
141            "CAR-Placement: subtask={} worker={} remote={}",
142            trailer_value(&landed.subtask_id),
143            trailer_value(worker),
144            p.remote
145        );
146        if !landed.files.is_empty() {
147            line.push_str(&format!(
148                " files={}",
149                landed
150                    .files
151                    .iter()
152                    .map(|f| trailer_value(f))
153                    .collect::<Vec<_>>()
154                    .join(",")
155            ));
156        }
157        lines.push(line);
158    }
159    if lines.is_empty() {
160        return None;
161    }
162    // A pool always seeds the local worker, so a run whose every peer was
163    // excluded still produces placements — all of them local. Saying "across the
164    // fleet" there would be the same overclaim in a quieter form.
165    if !any_remote && !repaired_locally {
166        return None;
167    }
168    if repaired_locally {
169        lines.push(
170            "CAR-Placement: repaired-locally=true (the union failed the contract              and was repaired here, so not every delivered hunk is listed above)"
171                .to_string(),
172        );
173    }
174    Some(lines.join("\n"))
175}
176
177/// One trailer value: no newlines, no control characters, bounded.
178///
179/// Everything here reaches a `git commit -m` argument. A newline could forge a
180/// paragraph or a `Signed-off-by:` in the delivered message, a NUL makes the
181/// spawn itself fail, and an unbounded value reaches `E2BIG` — turning a green,
182/// gate-passed run into an undeliverable one with no recovery but a hand commit.
183fn trailer_value(raw: &str) -> String {
184    const MAX: usize = 120;
185    let cleaned: String = raw
186        .chars()
187        .map(|c| if c.is_control() { ' ' } else { c })
188        .collect();
189    let cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
190    if cleaned.chars().count() > MAX {
191        cleaned.chars().take(MAX).collect::<String>() + "…"
192    } else {
193        cleaned
194    }
195}
196
197/// Stage all worktree changes and create the CAR-Coder commit. Reports a clean
198/// worktree as [`CommitOutcome::NothingToCommit`]; `Err` is reserved for a real
199/// failure. Shared by all three delivery paths.
200fn commit_worktree(
201    worktree: &Path,
202    intent: &str,
203    contract: &OutcomeContract,
204    // Where a distributed run's subtasks ran. `None` for every local delivery,
205    // which is every delivery that is not `car code --distributed`.
206    provenance: Option<&str>,
207) -> Result<CommitOutcome, String> {
208    // `--untracked-files=normal`, pinned rather than inherited: `status
209    // --porcelain` honours `status.showUntrackedFiles`, and a repository or
210    // global `no` makes a worktree holding nothing BUT new files report clean.
211    // Delivery would then take the re-delivery branch and push a stale commit
212    // while the round's entire output — every new file it wrote — stayed
213    // behind.
214    let status = git(
215        worktree,
216        &["status", "--porcelain", "--untracked-files=normal"],
217    )?;
218    if status.trim().is_empty() {
219        return Ok(CommitOutcome::NothingToCommit);
220    }
221    git(worktree, &["add", "-A"])?;
222
223    let subject = subject_from_intent(intent);
224    let mut body = format!(
225        "Authored by CAR Coder.\n\nIntent:\n{}\n\nOutcome contract (all checks passed):\n{}",
226        intent.trim(),
227        contract.render()
228    );
229    if let Some(provenance) = provenance {
230        body.push_str("\n\n");
231        body.push_str(provenance);
232    }
233    git(
234        worktree,
235        &[
236            "-c",
237            "user.name=car-coder",
238            "-c",
239            "user.email=coder@parslee.ai",
240            "commit",
241            "-m",
242            &subject,
243            "-m",
244            &body,
245        ],
246    )?;
247    Ok(CommitOutcome::Made(
248        git(worktree, &["rev-parse", "HEAD"])?.trim().to_string(),
249    ))
250}
251
252/// The interactive paths' reading of a clean worktree: nothing to publish, and
253/// that is an error. Only the PR path has a second, legitimate meaning for it.
254fn require_commit(outcome: CommitOutcome) -> Result<String, String> {
255    match outcome {
256        CommitOutcome::Made(sha) => Ok(sha),
257        CommitOutcome::NothingToCommit => {
258            Err("no changes to deliver — the worktree is clean".to_string())
259        }
260    }
261}
262
263/// The commit a HEADLESS round delivers when its worktree is already clean.
264///
265/// A clean worktree is not automatically an error on the headless paths, unlike
266/// the interactive ones. The contract keeps a workspace alive across rounds
267/// precisely so that a green session whose DELIVERY failed can re-deliver
268/// without redoing the work: on that second round the commit already exists and
269/// the worktree is clean. Deliver HEAD.
270///
271/// But "clean" has a second cause that must not be confused with it: a first
272/// round where the contract was green with an EMPTY diff (the named check
273/// already passed). There HEAD is still the base tip, and delivering it creates
274/// a branch and a pull request that prove nothing — over the pull-request path,
275/// `gh pr create` then fails with "No commits between …", reported as retriable
276/// and requeued forever with junk branches accumulating. Only a genuine
277/// re-delivery has something to deliver, and that means HEAD is already AHEAD of
278/// the base.
279///
280/// The base is resolved remote-first with the local branch as an honest stand-in
281/// — and the failure to resolve EITHER is an error rather than a `false`.
282/// Reading `is_ok()` on an unresolvable ref collapses "not an ancestor" and
283/// "could not check" into the same answer, i.e. "there is work to deliver": that
284/// fails OPEN, restoring the empty-delivery bug this guard exists to close,
285/// exactly when the network is down and the best-effort fetch did nothing.
286fn head_beyond_base(worktree: &Path, base_branch: &str) -> Result<String, String> {
287    let head = git(worktree, &["rev-parse", "HEAD"])?.trim().to_string();
288    let base_ref = ["origin/", ""]
289        .iter()
290        .map(|p| format!("{p}{base_branch}"))
291        .find(|r| git(worktree, &["rev-parse", "--verify", "--quiet", r]).is_ok())
292        .ok_or_else(|| {
293            format!(
294                "the worktree is clean and neither origin/{base_branch} nor {base_branch} \
295                 resolves, so whether there is anything to deliver cannot be determined"
296            )
297        })?;
298    if git(worktree, &["merge-base", "--is-ancestor", &head, &base_ref]).is_ok() {
299        return Err(format!(
300            "nothing to deliver: the worktree is clean and its HEAD ({head}) is already \
301             contained in {base_ref}, so there is no work to deliver"
302        ));
303    }
304    Ok(head)
305}
306
307/// Result of a publish: the branch name to merge from.
308pub fn publish_branch(
309    repo: &Path,
310    worktree: &Path,
311    short_id: &str,
312    intent: &str,
313    contract: &OutcomeContract,
314    provenance: Option<&str>,
315) -> Result<String, String> {
316    let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
317    let branch = format!("car/coder/{short_id}");
318    // `git branch` (no checkout) in the original repo: refs are shared with
319    // the worktree, so this is pure bookkeeping — no working-tree effects.
320    git(repo, &["branch", &branch, &commit])?;
321    Ok(branch)
322}
323
324/// [`publish_branch`] for the HEADLESS branch mode, where a clean worktree is a
325/// legitimate re-delivery rather than an error.
326///
327/// `car code-task --deliver branch` runs the same kept-workspace lifecycle as
328/// `--deliver pr`, and only the pull-request path was ever taught what a clean
329/// worktree means there. Branch mode went through `require_commit`, so this
330/// sequence parked a healthy goal: round N delivers via `--deliver branch`,
331/// committing the work into a kept workspace; round N+1 reuses it, HEAD is ahead
332/// of the base so the vacuity guard correctly stands down, the contract is
333/// already green so the loop edits nothing — and then the publish failed with
334/// "no changes to deliver". [`DeliveryFailure::Commit`] is hard-coded
335/// non-retriable, so a run whose contract was green and whose work was intact
336/// exited 3, "park it and tell a human", every round.
337///
338/// The empty-first-round case is still refused, by the same rule the
339/// pull-request path uses: see [`head_beyond_base`].
340pub fn publish_branch_headless(
341    repo: &Path,
342    worktree: &Path,
343    short_id: &str,
344    intent: &str,
345    contract: &OutcomeContract,
346    base_branch: &str,
347    provenance: Option<&str>,
348) -> Result<String, String> {
349    let commit = match commit_worktree(worktree, intent, contract, provenance)? {
350        CommitOutcome::Made(sha) => sha,
351        CommitOutcome::NothingToCommit => head_beyond_base(worktree, base_branch)?,
352    };
353    let branch = format!("car/coder/{short_id}");
354    git(repo, &["branch", &branch, &commit])?;
355    Ok(branch)
356}
357
358/// Deliver to a managed project's `main`. The worktree was checked out detached
359/// at `main`'s tip, so its commit is a direct descendant — a fast-forward
360/// updates both the `main` ref and the project's checkout. **ff-only**: if
361/// `main` moved since the session started (something committed underneath us),
362/// this errors instead of rebasing or forcing, preserving the guarantee that
363/// the diff the user approved is exactly what lands. Returns the commit SHA.
364pub fn commit_to_main(
365    repo: &Path,
366    worktree: &Path,
367    intent: &str,
368    contract: &OutcomeContract,
369    provenance: Option<&str>,
370) -> Result<String, String> {
371    let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
372    git(repo, &["merge", "--ff-only", &commit]).map_err(|e| {
373        format!("could not fast-forward the project's main branch (it moved since the session started): {e}")
374    })?;
375    Ok(commit)
376}
377
378/// The staged diff as the approval surface needs it.
379#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
380pub struct StagedDiff {
381    /// `git diff --cached --stat`, never truncated — it is small, and it is the
382    /// one view that stays complete however large the patch gets.
383    pub stat: String,
384    /// The patch, tail-capped to the configured budget.
385    pub patch: String,
386    /// Whether `patch` is a tail rather than the whole thing. Carried as a
387    /// field, not just the `…[truncated]…` marker inside the string, so a UI can
388    /// say so without string-matching (car#706).
389    pub truncated: bool,
390    /// Size of the untruncated patch, so the surface can report how much was
391    /// withheld.
392    pub full_bytes: usize,
393    /// Every repo-relative path the diff touches, sorted and deduped — for
394    /// contract-overlap disclosure and the changed-file summary at the gate.
395    ///
396    /// Includes BOTH endpoints of a rename. See [`parse_name_status_z`] for why
397    /// the destination alone is not an honest answer.
398    pub changed_paths: Vec<String>,
399}
400
401/// Stage everything and collect the diff for the approval UI. Staging is what
402/// `publish_branch` would commit anyway, and it makes untracked files visible.
403///
404/// `patch_cap_bytes` bounds only the patch body. The review surface used to
405/// shrink exactly as the risk grew: the cap was a hardcoded 32 KB tail, so on a
406/// long session — the regime where the automated check is weakest — the human
407/// approved against a partial patch, with the fact of truncation buried in a
408/// marker inside the string.
409pub fn stage_and_diff(worktree: &Path, patch_cap_bytes: usize) -> Result<StagedDiff, String> {
410    git(worktree, &["add", "-A"])?;
411    let stat = git(worktree, &["diff", "--cached", "--stat"])?;
412    let patch = git(worktree, &["diff", "--cached"])?;
413    let names = git(
414        worktree,
415        &[
416            // Redundant insurance, NOT the mechanism: `-z` below already emits
417            // raw bytes, so non-ASCII paths arrive unquoted with or without
418            // this. It matters only if someone later drops `-z`, at which point
419            // paths would come back C-escaped (`café.txt` as the literal
420            // `"caf\303\251.txt"`, quotes included) and match nothing.
421            "-c",
422            "core.quotepath=false",
423            "diff",
424            "--cached",
425            // `--name-status -z`, NOT `--name-only`. Rename detection reports
426            // only the DESTINATION under `--name-only`, so
427            // `git mv secrets/key.txt public_key.txt` yielded exactly
428            // `["public_key.txt"]` and the fact that `secrets/` was touched
429            // vanished. Any consumer reasoning about which paths a session
430            // affected was being told a rename is a creation. `-z` additionally
431            // makes paths NUL-delimited, so a newline in a filename cannot
432            // forge an entry.
433            "--name-status",
434            "-z",
435        ],
436    )?;
437    let changed_paths = parse_name_status_z(&names);
438    let full_bytes = patch.len();
439    Ok(StagedDiff {
440        patch: super::shell_tool::tail(&patch, patch_cap_bytes),
441        truncated: full_bytes > patch_cap_bytes,
442        full_bytes,
443        stat,
444        changed_paths,
445    })
446}
447
448/// Parse `git diff --cached --name-status -z` into every path the diff touches.
449///
450/// The `-z` stream is a flat run of NUL-terminated fields. Most entries are two
451/// fields — a status letter then a path. Rename (`R`) and copy (`C`) entries are
452/// three: the status carries a similarity score, then the SOURCE path, then the
453/// destination. **Both endpoints are returned**, because a caller asking "what
454/// did this session touch" is asking about the source too: a file moved out of a
455/// directory is a change to that directory, and reporting only the destination
456/// is how `--name-only` made `git mv secrets/key.txt public_key.txt` look like
457/// the creation of an unrelated file.
458fn parse_name_status_z(raw: &str) -> Vec<String> {
459    let mut out = Vec::new();
460    let mut fields = raw.split('\0').filter(|f| !f.is_empty());
461    while let Some(status) = fields.next() {
462        // Git's status set is closed — A C D M R T U X B — with `R`/`C`
463        // carrying a similarity score (`R100`) and `M` optionally a
464        // dissimilarity score under `-B`. Validate rather than assume: if a
465        // field that is NOT a status reaches here, the loop reads a path as a
466        // status and every subsequent field shifts by one, emitting a list of
467        // plausible-looking fictional paths — onto a reviewer's approval screen,
468        // as fact. A short list plus a warning is recoverable; silent fiction is
469        // not, so bail loudly instead of guessing.
470        let bytes = status.as_bytes();
471        let well_formed = status.len() <= 4
472            && matches!(
473                bytes[0],
474                b'A' | b'C' | b'D' | b'M' | b'R' | b'T' | b'U' | b'X' | b'B'
475            )
476            && status[1..].bytes().all(|b| b.is_ascii_digit());
477        if !well_formed {
478            tracing::warn!(
479                status = %status,
480                "unexpected field in `git diff --name-status -z`; changed-path list truncated \
481                 rather than risk a desynchronized parse"
482            );
483            break;
484        }
485        // A rename/copy is followed by two paths rather than one.
486        let two_paths = bytes[0] == b'R' || bytes[0] == b'C';
487        let Some(first) = fields.next() else {
488            tracing::warn!(status = %status, "name-status stream ended mid-entry");
489            break;
490        };
491        out.push(first.to_string());
492        if two_paths {
493            match fields.next() {
494                Some(second) => out.push(second.to_string()),
495                // A rename with no destination is a corrupt stream, not an
496                // entry to swallow silently.
497                None => {
498                    tracing::warn!(status = %status, "rename/copy entry missing its destination");
499                    break;
500                }
501            }
502        }
503    }
504    out.sort();
505    out.dedup();
506    out
507}
508
509/// Run git in `dir`, with the repository-selecting environment cleared.
510///
511/// `-C <dir>` selects a directory, not a repository: git resolves the repository
512/// from `GIT_DIR`/`GIT_WORK_TREE` FIRST and only walks up from the directory if
513/// they are unset. So an inherited `GIT_DIR` silently wins over the `-C` this
514/// module relies on, and every caller here — commit, push, `rev-parse HEAD` —
515/// would operate on a repository nobody named while the path-based reasoning
516/// upstream said the worktree was the right one. That is reachable without
517/// anything exotic: a git hook, `git rebase --exec`, or an orchestrator that
518/// exported them once for its own bookkeeping. Clearing them costs nothing and
519/// makes `-C` mean what the rest of this file assumes it means.
520pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
521    let mut cmd = std::process::Command::new("git");
522    cmd.env_remove("GIT_DIR")
523        .env_remove("GIT_WORK_TREE")
524        .env_remove("GIT_INDEX_FILE")
525        .env_remove("GIT_OBJECT_DIRECTORY")
526        .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
527        .env_remove("GIT_COMMON_DIR")
528        .arg("-C")
529        .arg(dir)
530        .args(args);
531    no_interactive_prompts(&mut cmd);
532    let out = run_capped(cmd).map_err(|e| match e {
533        RunFailure::Spawn(io) => format!("git {args:?}: {io}"),
534        RunFailure::TimedOut(secs) => format!(
535            "git {args:?} timed out after {secs}s and was killed; treat it as a transport failure"
536        ),
537    })?;
538    if out.status.success() {
539        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
540    } else {
541        Err(format!(
542            "git {args:?} failed: {}",
543            String::from_utf8_lossy(&out.stderr).trim()
544        ))
545    }
546}
547
548/// Refuse every interactive credential prompt, on every child of this module.
549///
550/// `Command::output()` nulls the child's stdin, and that is NOT enough: git does
551/// not prompt on stdin. `git_terminal_prompt` opens `/dev/tty` directly, so a
552/// `car code-task --deliver pr` started from a shell, tmux or `nohup` with an
553/// inherited controlling terminal — the ordinary way an orchestrator launches
554/// it — blocked forever on `Username for 'https://github.com':` against an
555/// HTTPS remote with no stored credential. The round never completed, never
556/// failed, and emitted no `delivery_failed`, in a command whose entire premise
557/// is that nobody is watching. `GIT_ASKPASS` and `SSH_ASKPASS_REQUIRE` close the
558/// two GUI-helper doors to the same room.
559///
560/// With this set the hang becomes `fatal: could not read Username/Password …`
561/// or `terminal prompts disabled`, all three of which
562/// [`classify_push_error`] now reads as a permanent refusal — which is the
563/// truthful answer: no credential is ever going to appear.
564fn no_interactive_prompts(cmd: &mut std::process::Command) {
565    cmd.env("GIT_TERMINAL_PROMPT", "0")
566        .env("GIT_ASKPASS", "")
567        .env("SSH_ASKPASS", "")
568        .env("SSH_ASKPASS_REQUIRE", "never");
569}
570
571/// How long any subprocess on the delivery path may run before it is killed.
572///
573/// Generous on purpose: a first push of a large repository over a slow link is
574/// legitimately minutes, and killing honest work would be worse than the hang
575/// this bounds. The point is only that "forever" is not one of the outcomes.
576const SUBPROCESS_TIMEOUT_SECS: u64 = 900;
577
578/// Why a subprocess produced no output.
579enum RunFailure {
580    /// It never started.
581    Spawn(std::io::Error),
582    /// It started and outlived [`SUBPROCESS_TIMEOUT_SECS`]; it has been killed.
583    TimedOut(u64),
584}
585
586/// `Command::output()` with a ceiling.
587///
588/// `output()` waits forever. Every network-touching call on this path — push,
589/// fetch, every `gh` round trip — can hang indefinitely on a black-holed
590/// connection or a credential prompt, and this module's callers have no other
591/// clock: `car code-task` reports a hung round as nothing at all, not even an
592/// empty failure. A killed child at least becomes a classified error.
593///
594/// stdout and stderr are drained on their own threads because a child that
595/// fills a pipe buffer blocks on the write while we block on `try_wait`, which
596/// is a deadlock no timeout could observe. On the kill path the threads are
597/// deliberately NOT joined: a grandchild (ssh, a credential helper) can hold the
598/// inherited pipe open after its parent dies, and joining would reintroduce
599/// exactly the unbounded wait this exists to remove.
600fn run_capped(cmd: std::process::Command) -> Result<std::process::Output, RunFailure> {
601    run_capped_for(cmd, std::time::Duration::from_secs(SUBPROCESS_TIMEOUT_SECS))
602}
603
604/// [`run_capped`] with the ceiling injected, so a test can prove the kill path
605/// in a second rather than in fifteen minutes. Production always passes
606/// [`SUBPROCESS_TIMEOUT_SECS`].
607fn run_capped_for(
608    mut cmd: std::process::Command,
609    timeout: std::time::Duration,
610) -> Result<std::process::Output, RunFailure> {
611    use std::io::Read as _;
612    use std::process::Stdio;
613
614    let mut child = cmd
615        .stdin(Stdio::null())
616        .stdout(Stdio::piped())
617        .stderr(Stdio::piped())
618        .spawn()
619        .map_err(RunFailure::Spawn)?;
620
621    let mut child_out = child.stdout.take().expect("stdout piped");
622    let mut child_err = child.stderr.take().expect("stderr piped");
623    let out_reader = std::thread::spawn(move || {
624        let mut buf = Vec::new();
625        let _ = child_out.read_to_end(&mut buf);
626        buf
627    });
628    let err_reader = std::thread::spawn(move || {
629        let mut buf = Vec::new();
630        let _ = child_err.read_to_end(&mut buf);
631        buf
632    });
633
634    let deadline = std::time::Instant::now() + timeout;
635    let status = loop {
636        match child.try_wait() {
637            Ok(Some(status)) => break status,
638            Ok(None) => {}
639            Err(e) => return Err(RunFailure::Spawn(e)),
640        }
641        if std::time::Instant::now() >= deadline {
642            let _ = child.kill();
643            let _ = child.wait();
644            return Err(RunFailure::TimedOut(timeout.as_secs()));
645        }
646        std::thread::sleep(std::time::Duration::from_millis(25));
647    };
648
649    Ok(std::process::Output {
650        status,
651        stdout: out_reader.join().unwrap_or_default(),
652        stderr: err_reader.join().unwrap_or_default(),
653    })
654}
655
656// ---------------------------------------------------------------------------
657// PR delivery — the third mode
658// ---------------------------------------------------------------------------
659//
660// `publish_branch` and `commit_to_main` deliver INTO the local repository and
661// stop there; a human is standing at the approval gate. PR delivery is the
662// headless mode: nobody is watching, the work has to leave this machine, and
663// the next round is a fresh session that will only ever see what is on GitHub.
664// That changes what "deliver" has to guarantee, and every rule below is one of
665// those guarantees rather than a preference.
666
667/// Which of the two things happened to the pull request for the target
668/// branch. Mirrors the `pr_action` field of the delivery event stream.
669///
670/// There is deliberately no `Reopened`. The runtime never closes a pull
671/// request, so it is never the party entitled to undo a close — see
672/// [`closed_pr_refusal`].
673#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
674#[serde(rename_all = "lowercase")]
675pub enum PrAction {
676    /// No pull request existed for this branch, so one was created.
677    Opened,
678    /// An open pull request already existed; the push landed on it.
679    Updated,
680}
681
682impl PrAction {
683    /// The wire spelling used by the `delivery_completed` event.
684    pub fn as_str(&self) -> &'static str {
685        match self {
686            PrAction::Opened => "opened",
687            PrAction::Updated => "updated",
688        }
689    }
690}
691
692impl std::fmt::Display for PrAction {
693    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
694        f.write_str(self.as_str())
695    }
696}
697
698/// GitHub's three terminal states for a pull request, as this module needs to
699/// distinguish them. `Merged` is deliberately NOT folded into `ClosedUnmerged`:
700/// a merge is this branch's work LANDING, while a close is somebody's decision
701/// against it, so the two demand opposite responses — the first lets the next
702/// round open a fresh pull request, the second parks the round.
703#[derive(Debug, Clone, Copy, PartialEq, Eq)]
704pub enum PrState {
705    /// Open — a push to its head branch updates it in place.
706    Open,
707    /// Closed without being merged. Somebody decided against it; the runtime
708    /// does not reopen one (see [`closed_pr_refusal`]).
709    ClosedUnmerged,
710    /// Merged — its work landed, and a new pull request carries the next round.
711    Merged,
712}
713
714/// One pull request as GitHub reports it for a head branch.
715#[derive(Debug, Clone, PartialEq)]
716pub struct PrRecord {
717    pub number: u64,
718    pub state: PrState,
719    pub url: String,
720    pub is_draft: bool,
721    /// The branch this pull request merges INTO, as `gh` reports `baseRefName`.
722    ///
723    /// Carried because GitHub's one-open-pull-request constraint is per (head,
724    /// base) PAIR, not per head — two open pull requests from the same branch
725    /// into different bases are entirely legal. Reconciliation without this
726    /// field picked the highest-numbered open pull request for the head and
727    /// rewrote its body, so a second pull request opened from the same branch
728    /// into `release/2.1` (by a human, or by a round invoked with a different
729    /// `--pr-base`) captured every later round: its description — which this run
730    /// did not author — was replaced wholesale, and the pull request the
731    /// orchestrator actually tracks kept a stale body forever.
732    pub base: String,
733}
734
735/// Everything PR delivery needs. Borrowed rather than owned because every field
736/// already exists in the caller's session state.
737pub struct PrDelivery<'a> {
738    /// The repository. `gh` runs here, so this must be a checkout with the
739    /// GitHub remote configured.
740    pub repo: &'a Path,
741    /// The session's worktree — where the changes are and where the commit is
742    /// made. It shares `repo`'s config, so it can push to the same remote.
743    pub worktree: &'a Path,
744    /// The delivery branch. **Stable across sessions**: round N+1 pushes to the
745    /// same branch, so a second branch is never created for the same goal.
746    pub target_branch: &'a str,
747    /// The base the pull request merges into.
748    pub base_branch: &'a str,
749    /// Open the pull request as a draft. Only ever consulted when a pull
750    /// request is CREATED — see [`deliver_pr`] on why this never flips an
751    /// existing one.
752    pub draft: bool,
753    /// The run intent; becomes the commit subject and the pull-request title.
754    pub intent: &'a str,
755    /// The contract that passed. Embedded in the commit body by
756    /// [`commit_worktree`].
757    pub contract: &'a OutcomeContract,
758    /// The substantive pull-request description: what was done, what remains,
759    /// what the contract proved. It is the next fresh session's context and
760    /// must stand alone without the diff.
761    pub body: &'a str,
762    /// Where a distributed run's subtasks ran, from [`placement_provenance`].
763    ///
764    /// A FIELD rather than a `None` baked into the commit step, even though no
765    /// caller can set it today: no distributed session can open a pull request
766    /// (the heal loop hardcodes local, `car code-task` has no distributed mode),
767    /// so this is out of reach rather than a gap. The day someone adds
768    /// `--distributed` to a pull-request path, a named field makes them decide
769    /// and a buried literal would silently drop the provenance (car#1322).
770    pub provenance: Option<&'a str>,
771}
772
773/// The aggregate and per-check CI verdict vocabulary for one exact commit.
774#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
775#[serde(rename_all = "lowercase")]
776pub enum CiState {
777    Green,
778    Pending,
779    Red,
780}
781
782/// One named forge check, pipeline validation, or commit-status context.
783#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
784pub struct CiCheck {
785    pub name: String,
786    pub state: CiState,
787}
788
789/// Forge checks and status contexts for the delivered head SHA.
790#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
791pub struct CiSummary {
792    /// The exact commit queried. CI from an older or newer branch head must not
793    /// be presented as evidence about this delivery.
794    pub head_sha: String,
795    pub state: CiState,
796    /// Every named check and its typed verdict, sorted by name.
797    pub checks: Vec<CiCheck>,
798    /// CI could not be observed; delivery succeeded but is never green.
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub observation_error: Option<String>,
801}
802
803impl CiSummary {
804    fn from_checks(head_sha: &str, checks: Vec<(String, CiState)>) -> Self {
805        // A provider can expose one check through more than one API. Keep one
806        // name and retain the strongest verdict rather than reporting
807        // contradictory duplicates.
808        let mut by_name = std::collections::BTreeMap::<String, CiState>::new();
809        for (name, state) in checks {
810            by_name
811                .entry(name)
812                .and_modify(|current| {
813                    if ci_severity(state) > ci_severity(*current) {
814                        *current = state;
815                    }
816                })
817                .or_insert(state);
818        }
819
820        let checks: Vec<CiCheck> = by_name
821            .into_iter()
822            .map(|(name, state)| CiCheck { name, state })
823            .collect();
824        let state = if checks.iter().any(|check| check.state == CiState::Red) {
825            CiState::Red
826        } else if checks.is_empty() || checks.iter().any(|check| check.state == CiState::Pending) {
827            // A just-pushed commit may not have pipeline runs yet. Absence is
828            // pending, never evidence that the commit is green.
829            CiState::Pending
830        } else {
831            CiState::Green
832        };
833        Self {
834            head_sha: head_sha.to_string(),
835            state,
836            checks,
837            observation_error: None,
838        }
839    }
840
841    fn names_with_state(&self, state: CiState) -> Vec<String> {
842        self.checks
843            .iter()
844            .filter(|check| check.state == state)
845            .map(|check| check.name.clone())
846            .collect()
847    }
848}
849
850fn ci_severity(state: CiState) -> u8 {
851    match state {
852        CiState::Green => 0,
853        CiState::Pending => 1,
854        CiState::Red => 2,
855    }
856}
857
858/// A successful PR delivery.
859#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
860pub struct PrDeliveryOutcome {
861    /// The target branch the commit landed on.
862    pub branch: String,
863    /// The delivered commit SHA.
864    pub commit: String,
865    /// Whether the push actually ran (always `true` on success today; carried
866    /// as a field because the event stream reports it).
867    pub pushed: bool,
868    pub pr_number: u64,
869    pub pr_url: String,
870    pub pr_action: PrAction,
871    /// The pull request's ACTUAL draft state, not the requested one. On
872    /// `Opened` these agree; on `Updated` this reports what GitHub says, because
873    /// a car worker may have marked the pull request ready in an earlier round
874    /// and delivery must not misreport that.
875    pub draft: bool,
876    /// CI observed for `commit` after the pull request was reconciled.
877    pub ci: CiSummary,
878}
879
880impl PrDeliveryOutcome {
881    /// Human-readable delivery status for logs and reports. The typed `ci`
882    /// remains the source for clients that render their own presentation.
883    pub fn delivery_report(&self) -> String {
884        if let Some(error) = &self.ci.observation_error {
885            return format!("delivered; CI unavailable at {}: {error}", self.commit);
886        }
887        match self.ci.state {
888            CiState::Green if self.draft => format!(
889                "delivered with green checks at {}; pull request remains draft",
890                self.ci.head_sha
891            ),
892            CiState::Green => format!(
893                "delivered with green checks and ready for review at {}",
894                self.ci.head_sha
895            ),
896            CiState::Pending => {
897                let names = named_checks_or(
898                    &self.ci.names_with_state(CiState::Pending),
899                    "no checks reported yet",
900                );
901                format!(
902                    "delivered with pending checks at {}: {names}",
903                    self.ci.head_sha
904                )
905            }
906            CiState::Red => {
907                let names =
908                    named_checks_or(&self.ci.names_with_state(CiState::Red), "unknown check");
909                format!("delivered red on {names} at {}", self.ci.head_sha)
910            }
911        }
912    }
913}
914
915fn named_checks_or(names: &[String], fallback: &str) -> String {
916    if names.is_empty() {
917        fallback.to_string()
918    } else {
919        names.join(", ")
920    }
921}
922
923/// Why a PR delivery stopped, at which stage, and whether the next round should
924/// simply try again.
925///
926/// The stage is not decoration: it is the difference between "the work is safe
927/// on disk, re-push it" and "this goal cannot progress without a human". It
928/// maps 1:1 onto the `delivery_failed` event's `stage` field.
929#[derive(Debug, Clone, PartialEq)]
930pub enum DeliveryFailure {
931    /// Refused before touching anything — a missing GitHub credential, a branch
932    /// name git/`gh` would misread, a target branch equal to the base, or either
933    /// half of the delivery-head policy ([`delivery_head_refusal`]): an
934    /// **ambiguous head**, where the target branch already carries an open pull
935    /// request into some other base that the push would silently add this
936    /// round's commits to ([`ambiguous_head_refusal`]); or a **closed pull
937    /// request** into this run's own base, which is somebody's decision that
938    /// this branch should stop ([`closed_pr_refusal`]). Never retriable: nothing
939    /// about running again changes any of them. Those two are the causes here
940    /// that depend on remote STATE rather than on the invocation, so they are
941    /// also the ones a `stage: "preflight"` reader is most likely to misread as
942    /// a configuration mistake — clearing either means acting on the other pull
943    /// request (closing the ambiguous one, reopening the closed one) or picking
944    /// a different `--target-branch`.
945    Preflight { reason: String },
946    /// The worktree could not be committed. Not retriable — the same worktree
947    /// will fail the same way.
948    Commit { reason: String },
949    /// The push was refused. `retriable` is `true` for the ordinary case (the
950    /// branch moved, so the next round re-cuts from its head and replays) and
951    /// `false` for credential/permission refusals.
952    Push { reason: String, retriable: bool },
953    /// A `gh` pull-request call failed. Usually retriable (a GitHub API blip);
954    /// the work is not lost either way. Reached from two places, and the state
955    /// of the remote differs between them: reconciliation runs after the push,
956    /// so the commit is safely on the branch, while the head-ambiguity listing
957    /// in preflight runs before it, so nothing has been committed or pushed at
958    /// all. Both are the same verdict — retry the delivery — which is why they
959    /// share a variant.
960    Pr { reason: String, retriable: bool },
961}
962
963impl DeliveryFailure {
964    /// The wire spelling of the stage — `preflight` | `commit` | `push` | `pr`.
965    pub fn stage(&self) -> &'static str {
966        match self {
967            DeliveryFailure::Preflight { .. } => "preflight",
968            DeliveryFailure::Commit { .. } => "commit",
969            DeliveryFailure::Push { .. } => "push",
970            DeliveryFailure::Pr { .. } => "pr",
971        }
972    }
973
974    /// Whether the next round should just try the same delivery again.
975    /// Preflight and commit failures are definitionally not retriable.
976    pub fn retriable(&self) -> bool {
977        match self {
978            DeliveryFailure::Preflight { .. } | DeliveryFailure::Commit { .. } => false,
979            DeliveryFailure::Push { retriable, .. } | DeliveryFailure::Pr { retriable, .. } => {
980                *retriable
981            }
982        }
983    }
984
985    /// The human-readable reason, without the stage prefix.
986    pub fn reason(&self) -> &str {
987        match self {
988            DeliveryFailure::Preflight { reason }
989            | DeliveryFailure::Commit { reason }
990            | DeliveryFailure::Push { reason, .. }
991            | DeliveryFailure::Pr { reason, .. } => reason,
992        }
993    }
994}
995
996impl std::fmt::Display for DeliveryFailure {
997    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
998        write!(f, "{} failed: {}", self.stage(), self.reason())
999    }
1000}
1001
1002impl std::error::Error for DeliveryFailure {}
1003
1004/// The forge operations PR delivery needs, behind one vendor-neutral seam.
1005///
1006/// Commit creation and the append-only push remain plain git. This trait owns
1007/// only the review artifact: authentication, pull-request reconciliation, body
1008/// refresh, and checks for one exact head. Keeping those operations together is
1009/// what prevents a second forge from becoming a parallel, weaker delivery path.
1010pub trait ForgeClient: Send + Sync {
1011    /// Fail with a message naming the forge credential or login remedy.
1012    fn auth_status(&self) -> Result<(), ForgeError>;
1013    /// Every pull request whose head is `head_branch`, in any state.
1014    fn list_prs_for_head(&self, dir: &Path, head_branch: &str)
1015        -> Result<Vec<PrRecord>, ForgeError>;
1016    fn create_pr(
1017        &self,
1018        dir: &Path,
1019        head_branch: &str,
1020        base_branch: &str,
1021        title: &str,
1022        body: &str,
1023        draft: bool,
1024    ) -> Result<PrRecord, ForgeError>;
1025    /// Replace a pull request's body wholesale.
1026    fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError>;
1027    /// Reopen a closed pull request when the caller has separately established
1028    /// that doing so is authorized. Current delivery policy deliberately never
1029    /// calls this: a human close parks the round.
1030    fn reopen_pr(&self, _dir: &Path, _number: u64) -> Result<(), ForgeError> {
1031        Err(ForgeError::local(
1032            "this forge client does not implement pull-request reopening",
1033        ))
1034    }
1035    /// Check runs, policies, and commit statuses for one exact PR head.
1036    fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError>;
1037}
1038
1039/// Compatibility name for callers that supplied a fake before the seam became
1040/// forge-neutral. New code should use [`ForgeClient`].
1041pub use ForgeClient as GitHubApi;
1042
1043/// Forge selected for one origin remote.
1044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum ForgeKind {
1046    GitHub,
1047    AzureDevOps,
1048}
1049
1050impl ForgeKind {
1051    fn as_str(self) -> &'static str {
1052        match self {
1053            Self::GitHub => "github",
1054            Self::AzureDevOps => "azure-devops",
1055        }
1056    }
1057}
1058
1059/// Explicit override for self-hosted or otherwise unrecognized forge URLs.
1060pub const FORGE_OVERRIDE_ENV: &str = "CAR_CODER_FORGE";
1061
1062trait ForgeCommandRunner: Send + Sync {
1063    fn run(&self, dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError>;
1064}
1065
1066struct ProcessForgeCommandRunner;
1067
1068impl ForgeCommandRunner for ProcessForgeCommandRunner {
1069    fn run(&self, dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
1070        run_forge_cli(dir, program, args)
1071    }
1072}
1073
1074/// GitHub implementation backed by the `gh` CLI.
1075pub struct GhCli {
1076    runner: Option<Arc<dyn ForgeCommandRunner>>,
1077}
1078
1079/// Preserve the original unit-value construction used by downstream callers:
1080/// `let gh = GhCli`. The type gained injectable state for offline tests, but
1081/// that internal change must not make existing GitHub callers migrate.
1082#[allow(non_upper_case_globals)]
1083pub const GhCli: GhCli = GhCli { runner: None };
1084
1085impl Default for GhCli {
1086    fn default() -> Self {
1087        GhCli
1088    }
1089}
1090
1091impl GhCli {
1092    fn runner(&self) -> &dyn ForgeCommandRunner {
1093        self.runner.as_deref().unwrap_or(&ProcessForgeCommandRunner)
1094    }
1095}
1096
1097/// Azure DevOps implementation backed by the `az repos pr` CLI surface.
1098pub struct AzureDevOpsCli {
1099    runner: Arc<dyn ForgeCommandRunner>,
1100    auth_dir: Option<PathBuf>,
1101}
1102
1103impl Default for AzureDevOpsCli {
1104    fn default() -> Self {
1105        Self {
1106            runner: Arc::new(ProcessForgeCommandRunner),
1107            auth_dir: None,
1108        }
1109    }
1110}
1111
1112impl AzureDevOpsCli {
1113    fn for_repo(repo: &Path) -> Self {
1114        Self {
1115            runner: Arc::new(ProcessForgeCommandRunner),
1116            auth_dir: Some(repo.to_path_buf()),
1117        }
1118    }
1119
1120    fn auth_dir(&self) -> &Path {
1121        self.auth_dir.as_deref().unwrap_or(Path::new("."))
1122    }
1123}
1124
1125#[cfg(test)]
1126impl GhCli {
1127    fn with_runner(runner: Arc<dyn ForgeCommandRunner>) -> Self {
1128        Self {
1129            runner: Some(runner),
1130        }
1131    }
1132}
1133
1134#[cfg(test)]
1135impl AzureDevOpsCli {
1136    fn with_runner(runner: Arc<dyn ForgeCommandRunner>, repo: &Path) -> Self {
1137        Self {
1138            runner,
1139            auth_dir: Some(repo.to_path_buf()),
1140        }
1141    }
1142}
1143
1144/// `gh auth status` args. A separate fn so the guard tests can read the exact
1145/// argv without a network round trip.
1146fn gh_auth_status_args() -> Vec<String> {
1147    vec!["auth".into(), "status".into()]
1148}
1149
1150/// The repository `gh` must act on: the one [`push_args`] pushes to.
1151///
1152/// Every `gh` call used to run with only `current_dir(dir)`, leaving `gh` to
1153/// pick a base repository through its OWN remote preference order — `upstream`
1154/// before `github` before `origin`. On the ordinary contributor layout that
1155/// `gh repo fork --remote` creates (`origin = you/repo`,
1156/// `upstream = acme/repo`), the two halves of delivery then addressed different
1157/// repositories: the commit went to `you/repo`, the listing queried
1158/// `acme/repo` and found nothing, and `gh pr create` opened a pull request on
1159/// `acme/repo` — a repository the operator never named. Round N+1 queried the
1160/// wrong repository again, so "exactly one pull request per branch" did not hold
1161/// either.
1162///
1163/// `None` when `origin` is not a GitHub URL — a local path, as every test in
1164/// this file uses — in which case `gh`'s own resolution is left alone, because
1165/// there is nothing better to say.
1166fn gh_repo_args(dir: &Path) -> Vec<String> {
1167    match git(dir, &["remote", "get-url", "origin"])
1168        .ok()
1169        .and_then(|url| parse_github_repo_spec(url.trim()))
1170    {
1171        Some(spec) => vec!["--repo".into(), spec],
1172        None => Vec::new(),
1173    }
1174}
1175
1176/// `<owner>/<name>` for github.com, `<host>/<owner>/<name>` elsewhere — the
1177/// `[HOST/]OWNER/REPO` spelling `gh --repo` accepts. `None` for anything that is
1178/// not a remote URL naming exactly one repository.
1179fn parse_github_repo_spec(url: &str) -> Option<String> {
1180    // `scheme://[user@]host[:port]/owner/name[.git]`, or scp-style
1181    // `[user@]host:owner/name[.git]`.
1182    let after_scheme = url.split_once("://").map(|(_, rest)| rest);
1183    let (host_part, path) = match after_scheme {
1184        Some(rest) => rest.split_once('/')?,
1185        // No scheme. A leading `/` or `.` is a local path, not a URL.
1186        None if url.starts_with('/') || url.starts_with('.') => return None,
1187        None => url.split_once(':')?,
1188    };
1189    let host = host_part
1190        .rsplit('@')
1191        .next()?
1192        .split(':')
1193        .next()?
1194        .to_ascii_lowercase();
1195    if host.is_empty() {
1196        return None;
1197    }
1198    let path = path
1199        .trim_matches('/')
1200        .strip_suffix(".git")
1201        .unwrap_or(path.trim_matches('/'));
1202    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
1203    // Exactly owner/name. Anything else is a shape this does not understand, and
1204    // guessing at it would aim delivery somewhere nobody asked for.
1205    let [owner, name] = segments[..] else {
1206        return None;
1207    };
1208    if host == "github.com" {
1209        Some(format!("{owner}/{name}"))
1210    } else {
1211        Some(format!("{host}/{owner}/{name}"))
1212    }
1213}
1214
1215/// `gh pr list` args for one head branch, all states.
1216fn gh_pr_list_args(head_branch: &str) -> Vec<String> {
1217    vec![
1218        "pr".into(),
1219        "list".into(),
1220        "--head".into(),
1221        head_branch.to_string(),
1222        "--state".into(),
1223        "all".into(),
1224        "--json".into(),
1225        // `isCrossRepository` is requested so `parse_pr_list` can DROP forks'
1226        // pull requests. `--head` filters on the head ref NAME alone, so an
1227        // outside contributor's `them/repo:goalpool/g_abc` is returned
1228        // alongside — or instead of — ours. Goal-derived branch names collide
1229        // trivially and forks are the normal contribution path on a public
1230        // repo, and the consequences are not cosmetic: the update path would
1231        // `gh pr edit` a third party's description away, and a stranger's
1232        // CLOSED pull request would park every future round on this branch.
1233        //
1234        // `baseRefName` is requested for the neighbouring reason: `--head`
1235        // filters on the head ref alone, and GitHub allows several open pull
1236        // requests from one head into DIFFERENT bases. See [`PrRecord::base`].
1237        "number,state,url,isDraft,isCrossRepository,baseRefName".into(),
1238        // `gh pr list` pages at 30 by default. That was cosmetic while this
1239        // listing only chose which pull request to reconcile; it is not now,
1240        // because the same listing is the sole input to
1241        // [`delivery_head_refusal`] — a truncated page hides an open pull
1242        // request into another base, or a closed one into this base, and the
1243        // harmful push proceeds exactly as it did before the guard existed.
1244        // Few pull requests share one head branch, so this is unlikely, but the
1245        // failure is silent and it defeats the guard entirely, which is the
1246        // wrong pair of properties to leave to a default.
1247        "--limit".into(),
1248        "100".into(),
1249    ]
1250}
1251
1252/// `gh pr create` args. `--draft` appears exactly when `draft` is set.
1253fn gh_pr_create_args(
1254    head_branch: &str,
1255    base_branch: &str,
1256    title: &str,
1257    body: &str,
1258    draft: bool,
1259) -> Vec<String> {
1260    let mut args = vec![
1261        "pr".into(),
1262        "create".into(),
1263        "--head".into(),
1264        head_branch.to_string(),
1265        "--base".into(),
1266        base_branch.to_string(),
1267        "--title".into(),
1268        title.to_string(),
1269        "--body".into(),
1270        body.to_string(),
1271    ];
1272    if draft {
1273        args.push("--draft".into());
1274    }
1275    args
1276}
1277
1278/// `gh pr reopen` args. Delivery policy does not invoke this automatically.
1279fn gh_pr_reopen_args(number: u64) -> Vec<String> {
1280    vec!["pr".into(), "reopen".into(), number.to_string()]
1281}
1282
1283/// `gh pr view` args for check runs and combined-status contexts.
1284fn gh_pr_checks_args(number: u64) -> Vec<String> {
1285    vec![
1286        "pr".into(),
1287        "view".into(),
1288        number.to_string(),
1289        "--json".into(),
1290        "headRefOid,statusCheckRollup".into(),
1291    ]
1292}
1293
1294fn az_common_tail(output: &str) -> Vec<String> {
1295    vec![
1296        "--detect".into(),
1297        "true".into(),
1298        "--output".into(),
1299        output.into(),
1300        "--only-show-errors".into(),
1301    ]
1302}
1303
1304fn az_auth_status_args() -> Vec<String> {
1305    let mut args = vec!["repos".into(), "list".into()];
1306    args.extend(az_common_tail("json"));
1307    args
1308}
1309
1310fn az_pr_list_args(head_branch: &str) -> Vec<String> {
1311    let mut args = vec![
1312        "repos".into(),
1313        "pr".into(),
1314        "list".into(),
1315        "--source-branch".into(),
1316        head_branch.into(),
1317        "--status".into(),
1318        "all".into(),
1319        "--top".into(),
1320        "100".into(),
1321        "--include-links".into(),
1322        "true".into(),
1323    ];
1324    args.extend(az_common_tail("json"));
1325    args
1326}
1327
1328fn az_pr_create_args(
1329    head_branch: &str,
1330    base_branch: &str,
1331    title: &str,
1332    body: &str,
1333    draft: bool,
1334) -> Vec<String> {
1335    let mut args = vec![
1336        "repos".into(),
1337        "pr".into(),
1338        "create".into(),
1339        "--source-branch".into(),
1340        head_branch.into(),
1341        "--target-branch".into(),
1342        base_branch.into(),
1343        "--title".into(),
1344        title.into(),
1345        "--description".into(),
1346        body.into(),
1347        "--draft".into(),
1348        draft.to_string(),
1349    ];
1350    args.extend(az_common_tail("json"));
1351    args
1352}
1353
1354fn az_pr_update_args(number: u64, body: &str) -> Vec<String> {
1355    let mut args = vec![
1356        "repos".into(),
1357        "pr".into(),
1358        "update".into(),
1359        "--id".into(),
1360        number.to_string(),
1361        "--description".into(),
1362        body.into(),
1363    ];
1364    args.extend(az_common_tail("none"));
1365    args
1366}
1367
1368fn az_pr_reopen_args(number: u64) -> Vec<String> {
1369    let mut args = vec![
1370        "repos".into(),
1371        "pr".into(),
1372        "update".into(),
1373        "--id".into(),
1374        number.to_string(),
1375        "--status".into(),
1376        "active".into(),
1377    ];
1378    args.extend(az_common_tail("none"));
1379    args
1380}
1381
1382fn az_pr_show_args(number: u64) -> Vec<String> {
1383    let mut args = vec![
1384        "repos".into(),
1385        "pr".into(),
1386        "show".into(),
1387        "--id".into(),
1388        number.to_string(),
1389    ];
1390    args.extend(az_common_tail("json"));
1391    args
1392}
1393
1394fn az_pr_policy_list_args(number: u64) -> Vec<String> {
1395    let mut args = vec![
1396        "repos".into(),
1397        "pr".into(),
1398        "policy".into(),
1399        "list".into(),
1400        "--id".into(),
1401        number.to_string(),
1402        "--top".into(),
1403        "100".into(),
1404    ];
1405    args.extend(az_common_tail("json"));
1406    args
1407}
1408
1409/// Git's force marker in a refspec, named exactly once.
1410///
1411/// Named so the append-only source guard can ban the bare char literal
1412/// everywhere else in this file. `validate_branch_name` legitimately has to talk
1413/// about `+` — it exists to REFUSE names starting with one — and a scanner that
1414/// trips on the code refusing a force marker is the failure this guard family
1415/// keeps rediscovering. The previous carve-out was "only complain when the same
1416/// LINE also says `format!`", and rustfmt splits `format!(` from its string
1417/// routinely (it does so in `validate_branch_name` itself), so the one spelling
1418/// a real force refspec would take — bind the plus to a name, interpolate it —
1419/// walked straight through. One allowlisted definition line is narrower and has
1420/// no such hole.
1421const FORCE_MARKER: char = '+';
1422
1423/// The push refspec, built as a function so a test can assert on it directly.
1424///
1425/// `<sha>:refs/heads/<branch>` with **no leading `+`**. A leading plus is git's
1426/// force marker; without it git refuses any update that is not a fast-forward,
1427/// which is precisely the guarantee this delivery path sells. The full
1428/// `refs/heads/` prefix is spelled out so a branch name that also matches a tag
1429/// cannot redirect the push.
1430fn push_args(commit: &str, target_branch: &str) -> Vec<String> {
1431    vec![
1432        "push".into(),
1433        "origin".into(),
1434        format!("{commit}:refs/heads/{target_branch}"),
1435    ]
1436}
1437
1438/// A failed forge invocation, with rendered context and raw stderr separated.
1439///
1440/// The split is load-bearing: title and body arguments contain model output,
1441/// while [`classify_pr_error`] must classify only what the forge itself said.
1442#[derive(Debug, Clone)]
1443pub struct ForgeError {
1444    /// Human-readable, names the failing command. Never classified.
1445    pub message: String,
1446    /// The child's stderr, alone. The only text error classification reads.
1447    pub stderr: String,
1448}
1449
1450/// Compatibility name for existing GitHub-specific callers.
1451pub type GhError = ForgeError;
1452
1453impl std::fmt::Display for ForgeError {
1454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1455        f.write_str(&self.message)
1456    }
1457}
1458
1459impl ForgeError {
1460    fn local(message: impl Into<String>) -> Self {
1461        let message = message.into();
1462        Self {
1463            stderr: message.clone(),
1464            message,
1465        }
1466    }
1467}
1468
1469/// Run one forge CLI with an argument array and no interactive credential path.
1470fn run_forge_cli(dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
1471    let mut cmd = std::process::Command::new(program);
1472    cmd.current_dir(dir).args(args);
1473    no_interactive_prompts(&mut cmd);
1474    if program == "gh" {
1475        // Preserve the GitHub implementation's pre-existing no-prompt posture.
1476        cmd.env("GH_PROMPT_DISABLED", "1");
1477    }
1478    let out = run_capped(cmd).map_err(|e| match e {
1479        RunFailure::Spawn(io) if io.kind() == std::io::ErrorKind::NotFound => {
1480            if program == "gh" {
1481                ForgeError::local(
1482                    "`gh` not found on PATH — install the GitHub CLI (https://cli.github.com) \
1483                     and authenticate it",
1484                )
1485            } else {
1486                ForgeError::local(
1487                    "`az` not found on PATH — install Azure CLI and the azure-devops extension, \
1488                     then authenticate it",
1489                )
1490            }
1491        }
1492        RunFailure::Spawn(io) => ForgeError::local(format!(
1493            "failed to run `{program} {}`: {io}",
1494            gh_subcommand_shape(args)
1495        )),
1496        RunFailure::TimedOut(secs) => ForgeError::local(format!(
1497            "`{program} {}` timed out after {secs}s and was killed",
1498            gh_subcommand_shape(args)
1499        )),
1500    })?;
1501    if out.status.success() {
1502        Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1503    } else {
1504        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1505        Err(ForgeError {
1506            message: format!("{program} {} failed: {stderr}", gh_subcommand_shape(args)),
1507            stderr,
1508        })
1509    }
1510}
1511
1512/// GitHub CLI entry point shared with the GitHub-only issue intake modules.
1513pub(super) fn gh(dir: &Path, args: &[String]) -> Result<String, GhError> {
1514    run_forge_cli(dir, "gh", args)
1515}
1516
1517/// The shape of a forge argv for a human: flags kept, operand VALUES elided.
1518///
1519/// Even the human-readable half declines to echo `--title`, `--body`, and
1520/// Azure DevOps' equivalent `--description`. Their values are model output,
1521/// they can be thousands of lines, and a delivery
1522/// failure surfaces in an event stream and a `run_end.error` an orchestrator
1523/// logs. The flags themselves say everything a reader needs about which call
1524/// failed.
1525fn gh_subcommand_shape(args: &[String]) -> String {
1526    let mut out: Vec<String> = Vec::with_capacity(args.len());
1527    let mut elide_next = false;
1528    for arg in args {
1529        if std::mem::take(&mut elide_next) {
1530            out.push("<…>".to_string());
1531            continue;
1532        }
1533        if arg.starts_with("--") {
1534            elide_next = matches!(arg.as_str(), "--title" | "--body" | "--description");
1535        }
1536        out.push(arg.clone());
1537    }
1538    out.join(" ")
1539}
1540
1541/// Parse `gh pr list --json number,state,url,isDraft,isCrossRepository` output.
1542///
1543/// **Cross-repository entries are dropped here**, at the parse boundary, so that
1544/// no downstream branch can reach one. Reconciliation's whole vocabulary —
1545/// update the body, park on a close — is about a pull request whose head branch
1546/// is the one we just pushed to on `origin`. A fork's pull request merely shares
1547/// the ref NAME, which is all `--head` matches on; acting on it would rewrite a
1548/// stranger's description, or let a stranger's close park this branch, while the
1549/// branch we actually pushed still has no pull request at all.
1550fn parse_pr_list(raw: &str) -> Result<Vec<PrRecord>, String> {
1551    let value: serde_json::Value = serde_json::from_str(raw.trim())
1552        .map_err(|e| format!("could not parse `gh pr list` JSON: {e}"))?;
1553    let items = value
1554        .as_array()
1555        .ok_or_else(|| "`gh pr list` did not return a JSON array".to_string())?;
1556    let mut out = Vec::with_capacity(items.len());
1557    for item in items {
1558        // Absent field ⇒ same-repo. `gh` omits nothing it was asked for, and an
1559        // older `gh` that does not know the field would otherwise refuse the
1560        // whole call rather than answer without it.
1561        if item
1562            .get("isCrossRepository")
1563            .and_then(|c| c.as_bool())
1564            .unwrap_or(false)
1565        {
1566            continue;
1567        }
1568        let number = item
1569            .get("number")
1570            .and_then(|n| n.as_u64())
1571            .ok_or_else(|| "pull request entry has no numeric `number`".to_string())?;
1572        let raw_state = item
1573            .get("state")
1574            .and_then(|s| s.as_str())
1575            .ok_or_else(|| "pull request entry has no `state`".to_string())?;
1576        // gh reports MERGED distinctly from CLOSED. An unknown state is not
1577        // guessed at: treating it as closed would park a run that should have
1578        // proceeded.
1579        let state = match raw_state.to_ascii_uppercase().as_str() {
1580            "OPEN" => PrState::Open,
1581            "CLOSED" => PrState::ClosedUnmerged,
1582            "MERGED" => PrState::Merged,
1583            other => return Err(format!("unrecognized pull request state `{other}`")),
1584        };
1585        out.push(PrRecord {
1586            number,
1587            state,
1588            url: item
1589                .get("url")
1590                .and_then(|u| u.as_str())
1591                .unwrap_or_default()
1592                .to_string(),
1593            is_draft: item
1594                .get("isDraft")
1595                .and_then(|d| d.as_bool())
1596                .unwrap_or(false),
1597            // Required, not defaulted. A missing base cannot be guessed at:
1598            // every default is a claim that some pull request merges into the
1599            // branch this round targets, and acting on the wrong one is the
1600            // failure this field was added to stop. `gh` returns every field it
1601            // was asked for.
1602            base: item
1603                .get("baseRefName")
1604                .and_then(|b| b.as_str())
1605                .ok_or_else(|| "pull request entry has no `baseRefName`".to_string())?
1606                .to_string(),
1607        });
1608    }
1609    Ok(out)
1610}
1611
1612/// Parse GitHub check runs and combined-status contexts for one exact SHA.
1613fn parse_github_ci_summary(raw: &str, expected_head_sha: &str) -> Result<CiSummary, String> {
1614    let value: serde_json::Value = serde_json::from_str(raw.trim())
1615        .map_err(|e| format!("could not parse `gh pr view` CI JSON: {e}"))?;
1616    let actual_head = value
1617        .get("headRefOid")
1618        .and_then(|v| v.as_str())
1619        .ok_or_else(|| "`gh pr view` CI response has no `headRefOid`".to_string())?;
1620    if actual_head != expected_head_sha {
1621        return Err(format!(
1622            "pull request head moved while delivery was reading CI: expected {expected_head_sha}, found {actual_head}"
1623        ));
1624    }
1625
1626    let rollup = match value.get("statusCheckRollup") {
1627        None | Some(serde_json::Value::Null) => &[][..],
1628        Some(serde_json::Value::Array(items)) => items.as_slice(),
1629        Some(_) => {
1630            return Err("`gh pr view` CI response has a non-array `statusCheckRollup`".to_string())
1631        }
1632    };
1633    let mut checks = Vec::with_capacity(rollup.len());
1634    for item in rollup {
1635        let kind = item
1636            .get("__typename")
1637            .and_then(|v| v.as_str())
1638            .ok_or_else(|| "CI rollup entry has no `__typename`".to_string())?;
1639        let (name, state) = match kind {
1640            "CheckRun" => {
1641                let name = required_string(item, "name", "CI rollup entry")?;
1642                let status = item
1643                    .get("status")
1644                    .and_then(|v| v.as_str())
1645                    .ok_or_else(|| format!("check run `{name}` has no `status`"))?;
1646                let state = if status.eq_ignore_ascii_case("COMPLETED") {
1647                    let conclusion =
1648                        item.get("conclusion")
1649                            .and_then(|v| v.as_str())
1650                            .ok_or_else(|| {
1651                                format!("completed check run `{name}` has no `conclusion`")
1652                            })?;
1653                    match conclusion.to_ascii_uppercase().as_str() {
1654                        "SUCCESS" | "NEUTRAL" | "SKIPPED" => CiState::Green,
1655                        "ACTION_REQUIRED" | "CANCELLED" | "FAILURE" | "STALE"
1656                        | "STARTUP_FAILURE" | "TIMED_OUT" => CiState::Red,
1657                        other => {
1658                            return Err(format!(
1659                                "check run `{name}` has unrecognized conclusion `{other}`"
1660                            ))
1661                        }
1662                    }
1663                } else {
1664                    // Every non-completed run is conservatively pending. New
1665                    // queued spellings cannot become a false green.
1666                    CiState::Pending
1667                };
1668                (name, state)
1669            }
1670            "StatusContext" => {
1671                let name = required_string(item, "context", "CI rollup entry")?;
1672                let raw_state = item
1673                    .get("state")
1674                    .and_then(|v| v.as_str())
1675                    .ok_or_else(|| format!("status context `{name}` has no `state`"))?;
1676                let state = match raw_state.to_ascii_uppercase().as_str() {
1677                    "SUCCESS" => CiState::Green,
1678                    "EXPECTED" | "PENDING" => CiState::Pending,
1679                    "ERROR" | "FAILURE" => CiState::Red,
1680                    other => {
1681                        return Err(format!(
1682                            "status context `{name}` has unrecognized state `{other}`"
1683                        ))
1684                    }
1685                };
1686                (name, state)
1687            }
1688            other => return Err(format!("unrecognized CI rollup entry type `{other}`")),
1689        };
1690        checks.push((name, state));
1691    }
1692    Ok(CiSummary::from_checks(expected_head_sha, checks))
1693}
1694
1695fn required_string(
1696    value: &serde_json::Value,
1697    field: &str,
1698    subject: &str,
1699) -> Result<String, String> {
1700    value
1701        .get(field)
1702        .and_then(|v| v.as_str())
1703        .filter(|s| !s.trim().is_empty())
1704        .map(str::to_string)
1705        .ok_or_else(|| format!("{subject} has no non-empty `{field}`"))
1706}
1707
1708fn strip_heads_prefix(name: &str) -> String {
1709    name.strip_prefix("refs/heads/").unwrap_or(name).to_string()
1710}
1711
1712fn azure_pr_url(value: &serde_json::Value, number: u64) -> String {
1713    value
1714        .pointer("/_links/web/href")
1715        .and_then(|v| v.as_str())
1716        .or_else(|| value.get("remoteUrl").and_then(|v| v.as_str()))
1717        .map(str::to_string)
1718        .or_else(|| {
1719            value
1720                .pointer("/repository/webUrl")
1721                .and_then(|v| v.as_str())
1722                .map(|base| format!("{}/pullrequest/{number}", base.trim_end_matches('/')))
1723        })
1724        .or_else(|| {
1725            value
1726                .get("url")
1727                .and_then(|v| v.as_str())
1728                .map(str::to_string)
1729        })
1730        .unwrap_or_default()
1731}
1732
1733fn parse_azure_pr(value: &serde_json::Value) -> Result<PrRecord, String> {
1734    let number = value
1735        .get("pullRequestId")
1736        .and_then(|v| v.as_u64())
1737        .ok_or_else(|| "Azure DevOps pull request has no numeric `pullRequestId`".to_string())?;
1738    let raw_state = value
1739        .get("status")
1740        .and_then(|v| v.as_str())
1741        .ok_or_else(|| "Azure DevOps pull request has no `status`".to_string())?;
1742    let state = match raw_state.to_ascii_lowercase().as_str() {
1743        "active" => PrState::Open,
1744        "abandoned" => PrState::ClosedUnmerged,
1745        "completed" => PrState::Merged,
1746        other => {
1747            return Err(format!(
1748                "unrecognized Azure DevOps pull request status `{other}`"
1749            ))
1750        }
1751    };
1752    let target = required_string(value, "targetRefName", "Azure DevOps pull request")?;
1753    Ok(PrRecord {
1754        number,
1755        state,
1756        url: azure_pr_url(value, number),
1757        is_draft: value
1758            .get("isDraft")
1759            .and_then(|v| v.as_bool())
1760            .unwrap_or(false),
1761        base: strip_heads_prefix(&target),
1762    })
1763}
1764
1765fn parse_azure_pr_list(raw: &str) -> Result<Vec<PrRecord>, String> {
1766    let value: serde_json::Value = serde_json::from_str(raw.trim())
1767        .map_err(|e| format!("could not parse `az repos pr list` JSON: {e}"))?;
1768    let items = value
1769        .as_array()
1770        .ok_or_else(|| "`az repos pr list` did not return a JSON array".to_string())?;
1771    items.iter().map(parse_azure_pr).collect()
1772}
1773
1774fn azure_pr_head(pr_raw: &str) -> Result<String, String> {
1775    let pr: serde_json::Value = serde_json::from_str(pr_raw.trim())
1776        .map_err(|e| format!("could not parse `az repos pr show` JSON: {e}"))?;
1777    pr.pointer("/lastMergeSourceCommit/commitId")
1778        .and_then(|v| v.as_str())
1779        .map(str::to_string)
1780        .ok_or_else(|| {
1781            "`az repos pr show` response has no `lastMergeSourceCommit.commitId`".to_string()
1782        })
1783}
1784
1785fn parse_azure_ci_summary(
1786    pr_before_raw: &str,
1787    policies_raw: &str,
1788    pr_after_raw: &str,
1789    expected_head_sha: &str,
1790) -> Result<CiSummary, String> {
1791    // Azure exposes the PR head and policy evaluations through separate CLI
1792    // calls. Bracket the policy read with head reads so a push during this
1793    // sequence cannot attach another commit's result to the delivered SHA.
1794    let before_head = azure_pr_head(pr_before_raw)?;
1795    let after_head = azure_pr_head(pr_after_raw)?;
1796    if before_head != expected_head_sha || after_head != expected_head_sha {
1797        let actual = if before_head != expected_head_sha {
1798            before_head
1799        } else {
1800            after_head
1801        };
1802        return Err(format!(
1803            "pull request head moved while delivery was reading CI: expected {expected_head_sha}, found {actual}"
1804        ));
1805    }
1806
1807    let policies: serde_json::Value = serde_json::from_str(policies_raw.trim())
1808        .map_err(|e| format!("could not parse `az repos pr policy list` JSON: {e}"))?;
1809    let items = policies
1810        .as_array()
1811        .ok_or_else(|| "`az repos pr policy list` did not return a JSON array".to_string())?;
1812    let mut checks = Vec::with_capacity(items.len());
1813    for item in items {
1814        let name = item
1815            .pointer("/configuration/type/displayName")
1816            .and_then(|v| v.as_str())
1817            .or_else(|| item.pointer("/type/displayName").and_then(|v| v.as_str()))
1818            .or_else(|| item.pointer("/context/name").and_then(|v| v.as_str()))
1819            .filter(|s| !s.trim().is_empty())
1820            .map(str::to_string)
1821            .or_else(|| {
1822                item.get("evaluationId")
1823                    .and_then(|v| v.as_str())
1824                    .map(|id| format!("policy {id}"))
1825            })
1826            .ok_or_else(|| "Azure DevOps policy has no name or evaluation id".to_string())?;
1827        let raw_state = item
1828            .get("status")
1829            .and_then(|v| v.as_str())
1830            .ok_or_else(|| format!("Azure DevOps policy `{name}` has no `status`"))?;
1831        let state = match raw_state.to_ascii_lowercase().as_str() {
1832            "approved" | "notapplicable" => CiState::Green,
1833            "queued" | "running" => CiState::Pending,
1834            "rejected" | "broken" => CiState::Red,
1835            other => {
1836                return Err(format!(
1837                    "Azure DevOps policy `{name}` has unrecognized status `{other}`"
1838                ))
1839            }
1840        };
1841        checks.push((name, state));
1842    }
1843    Ok(CiSummary::from_checks(expected_head_sha, checks))
1844}
1845
1846/// Recover a pull request number from the URL `gh pr create` prints. `gh`
1847/// prints only the URL, and the number is its last path segment.
1848fn pr_number_from_url(url: &str) -> Result<u64, String> {
1849    url.trim()
1850        .rsplit('/')
1851        .find(|seg| !seg.is_empty())
1852        .and_then(|seg| seg.parse::<u64>().ok())
1853        .ok_or_else(|| format!("could not read a pull request number out of `{url}`"))
1854}
1855
1856impl ForgeClient for GhCli {
1857    fn auth_status(&self) -> Result<(), ForgeError> {
1858        // Preserve the original GitHub preflight exactly: unlike repository-
1859        // scoped PR operations, `gh auth status` runs in the process cwd.
1860        self.runner()
1861            .run(Path::new("."), "gh", &gh_auth_status_args())
1862            .map(|_| ())
1863            .map_err(|e| ForgeError {
1864                message: format!(
1865                    "no usable GitHub credential: `gh auth status` failed. Authenticate with \
1866                 `gh auth login`, or set GH_TOKEN (or GITHUB_TOKEN) in this process's \
1867                 environment. Underlying error: {e}"
1868                ),
1869                stderr: e.stderr,
1870            })
1871    }
1872
1873    fn list_prs_for_head(
1874        &self,
1875        dir: &Path,
1876        head_branch: &str,
1877    ) -> Result<Vec<PrRecord>, ForgeError> {
1878        let mut args = gh_repo_args(dir);
1879        args.extend(gh_pr_list_args(head_branch));
1880        parse_pr_list(&self.runner().run(dir, "gh", &args)?).map_err(ForgeError::local)
1881    }
1882
1883    fn create_pr(
1884        &self,
1885        dir: &Path,
1886        head_branch: &str,
1887        base_branch: &str,
1888        title: &str,
1889        body: &str,
1890        draft: bool,
1891    ) -> Result<PrRecord, ForgeError> {
1892        let mut args = gh_repo_args(dir);
1893        args.extend(gh_pr_create_args(
1894            head_branch,
1895            base_branch,
1896            title,
1897            body,
1898            draft,
1899        ));
1900        let url = self.runner().run(dir, "gh", &args)?;
1901        Ok(PrRecord {
1902            number: pr_number_from_url(&url).map_err(ForgeError::local)?,
1903            state: PrState::Open,
1904            url: url.trim().to_string(),
1905            is_draft: draft,
1906            base: base_branch.to_string(),
1907        })
1908    }
1909
1910    fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError> {
1911        let mut args = gh_repo_args(dir);
1912        args.extend([
1913            "pr".to_string(),
1914            "edit".to_string(),
1915            number.to_string(),
1916            "--body".to_string(),
1917            body.to_string(),
1918        ]);
1919        self.runner().run(dir, "gh", &args).map(|_| ())
1920    }
1921
1922    fn reopen_pr(&self, dir: &Path, number: u64) -> Result<(), ForgeError> {
1923        let mut args = gh_repo_args(dir);
1924        args.extend(gh_pr_reopen_args(number));
1925        self.runner().run(dir, "gh", &args).map(|_| ())
1926    }
1927
1928    fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError> {
1929        let mut args = gh_repo_args(dir);
1930        args.extend(gh_pr_checks_args(number));
1931        let raw = self.runner().run(dir, "gh", &args)?;
1932        parse_github_ci_summary(&raw, head_sha).map_err(ForgeError::local)
1933    }
1934}
1935
1936impl ForgeClient for AzureDevOpsCli {
1937    fn auth_status(&self) -> Result<(), ForgeError> {
1938        self.runner
1939            .run(self.auth_dir(), "az", &az_auth_status_args())
1940            .map(|_| ())
1941            .map_err(|e| ForgeError {
1942                message: format!(
1943                    "no usable Azure DevOps credential: `az repos list` failed. Install the \
1944                     azure-devops extension and authenticate with `az login` or \
1945                     AZURE_DEVOPS_EXT_PAT. Underlying error: {e}"
1946                ),
1947                stderr: e.stderr,
1948            })
1949    }
1950
1951    fn list_prs_for_head(
1952        &self,
1953        dir: &Path,
1954        head_branch: &str,
1955    ) -> Result<Vec<PrRecord>, ForgeError> {
1956        let raw = self.runner.run(dir, "az", &az_pr_list_args(head_branch))?;
1957        parse_azure_pr_list(&raw).map_err(ForgeError::local)
1958    }
1959
1960    fn create_pr(
1961        &self,
1962        dir: &Path,
1963        head_branch: &str,
1964        base_branch: &str,
1965        title: &str,
1966        body: &str,
1967        draft: bool,
1968    ) -> Result<PrRecord, ForgeError> {
1969        let raw = self.runner.run(
1970            dir,
1971            "az",
1972            &az_pr_create_args(head_branch, base_branch, title, body, draft),
1973        )?;
1974        let value: serde_json::Value = serde_json::from_str(raw.trim()).map_err(|e| {
1975            ForgeError::local(format!("could not parse `az repos pr create` JSON: {e}"))
1976        })?;
1977        parse_azure_pr(&value).map_err(ForgeError::local)
1978    }
1979
1980    fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError> {
1981        self.runner
1982            .run(dir, "az", &az_pr_update_args(number, body))
1983            .map(|_| ())
1984    }
1985
1986    fn reopen_pr(&self, dir: &Path, number: u64) -> Result<(), ForgeError> {
1987        self.runner
1988            .run(dir, "az", &az_pr_reopen_args(number))
1989            .map(|_| ())
1990    }
1991
1992    fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError> {
1993        let before = self.runner.run(dir, "az", &az_pr_show_args(number))?;
1994        let policies = self
1995            .runner
1996            .run(dir, "az", &az_pr_policy_list_args(number))?;
1997        let after = self.runner.run(dir, "az", &az_pr_show_args(number))?;
1998        parse_azure_ci_summary(&before, &policies, &after, head_sha).map_err(ForgeError::local)
1999    }
2000}
2001
2002fn remote_host(remote_url: &str) -> Option<String> {
2003    let raw = remote_url.trim();
2004    let authority = if let Some((_, rest)) = raw.split_once("://") {
2005        rest.split('/').next()?
2006    } else {
2007        if raw.starts_with('/') || raw.starts_with('.') {
2008            return None;
2009        }
2010        let (left, _) = raw.split_once(':')?;
2011        // Do not mistake a Windows drive path for an scp-style remote.
2012        if left.len() == 1 {
2013            return None;
2014        }
2015        left
2016    };
2017    authority
2018        .rsplit('@')
2019        .next()?
2020        .split(':')
2021        .next()
2022        .filter(|host| !host.is_empty())
2023        .map(str::to_ascii_lowercase)
2024}
2025
2026fn forge_kind_from_remote(
2027    remote_url: &str,
2028    override_value: Option<&str>,
2029) -> Result<ForgeKind, String> {
2030    if let Some(value) = override_value {
2031        return match value.trim().to_ascii_lowercase().as_str() {
2032            "github" | "gh" => Ok(ForgeKind::GitHub),
2033            "azure-devops" | "azure_devops" | "azdo" => Ok(ForgeKind::AzureDevOps),
2034            other => Err(format!(
2035                "unsupported {FORGE_OVERRIDE_ENV} value `{other}`; use `github` or `azure-devops`"
2036            )),
2037        };
2038    }
2039
2040    let host = remote_host(remote_url).ok_or_else(|| {
2041        format!(
2042            "cannot identify a pull-request forge from origin `{remote_url}`; set \
2043             {FORGE_OVERRIDE_ENV}=github or {FORGE_OVERRIDE_ENV}=azure-devops"
2044        )
2045    })?;
2046    if matches!(host.as_str(), "github.com" | "ssh.github.com") {
2047        Ok(ForgeKind::GitHub)
2048    } else if matches!(host.as_str(), "dev.azure.com" | "ssh.dev.azure.com")
2049        || host.ends_with(".visualstudio.com")
2050    {
2051        Ok(ForgeKind::AzureDevOps)
2052    } else {
2053        Err(format!(
2054            "cannot identify a pull-request forge for origin host `{host}`; set \
2055             {FORGE_OVERRIDE_ENV}=github or {FORGE_OVERRIDE_ENV}=azure-devops"
2056        ))
2057    }
2058}
2059
2060fn selected_forge(repo: &Path) -> Result<Box<dyn ForgeClient>, String> {
2061    let remote_url = git(repo, &["remote", "get-url", "origin"])
2062        .map_err(|e| format!("cannot read origin remote for forge selection: {e}"))?;
2063    let override_value = match std::env::var(FORGE_OVERRIDE_ENV) {
2064        Ok(value) => Some(value),
2065        Err(std::env::VarError::NotPresent) => None,
2066        Err(std::env::VarError::NotUnicode(_)) => {
2067            return Err(format!(
2068                "{FORGE_OVERRIDE_ENV} is not valid UTF-8; use `github` or `azure-devops`"
2069            ))
2070        }
2071    };
2072    let kind = forge_kind_from_remote(&remote_url, override_value.as_deref())?;
2073    tracing::debug!(forge = kind.as_str(), remote = %remote_url.trim(), "selected PR forge");
2074    Ok(match kind {
2075        ForgeKind::GitHub => Box::new(GhCli::default()),
2076        ForgeKind::AzureDevOps => Box::new(AzureDevOpsCli::for_repo(repo)),
2077    })
2078}
2079
2080/// Reject a branch name git or a forge CLI would misread.
2081///
2082/// Argument arrays already close command injection, but they do NOT stop a name
2083/// beginning with `-` from being parsed as a flag, and a name beginning with `+`
2084/// is a force marker in a refspec. Both are refused here rather than sanitized:
2085/// a delivery to a silently-renamed branch is worse than a delivery that stops.
2086/// Reject a branch name git or `gh` would misread, BEFORE it reaches a command
2087/// line.
2088///
2089/// Public because the check has to run at the caller's preflight, not only
2090/// inside `deliver_pr_with`. `car code-task` hands the raw `--target-branch`
2091/// and `--pr-base` to `git fetch`, `git worktree add` and `git merge` long
2092/// before delivery — and `git fetch` accepts `--upload-pack=<cmd>`, so a name of
2093/// that shape is executed on local and ssh transports. Validating only at
2094/// delivery also spends an entire model session first, which defeats the
2095/// early-refusal design the preflight exists for.
2096pub fn validate_branch_name(label: &str, name: &str) -> Result<(), String> {
2097    if name.is_empty() {
2098        return Err(format!("{label} is empty"));
2099    }
2100    if name.starts_with('-') {
2101        return Err(format!(
2102            "{label} `{name}` starts with '-', which git and gh would read as a flag"
2103        ));
2104    }
2105    if name.starts_with(FORCE_MARKER) {
2106        return Err(format!(
2107            "{label} `{name}` starts with `{FORCE_MARKER}`, git's force marker in a refspec"
2108        ));
2109    }
2110    if let Some(bad) = name
2111        .chars()
2112        .find(|c| c.is_whitespace() || c.is_control() || "~^:?*[]\\".contains(*c))
2113    {
2114        return Err(format!(
2115            "{label} `{name}` contains `{bad}`, which is not legal in a git ref name"
2116        ));
2117    }
2118    // Forms git rejects that the checks above let through. Without these they
2119    // surface as `fatal: invalid refspec` at push time, which matches neither
2120    // the refused nor the moved list in `classify_push_error` and so lands on
2121    // the retriable default — retried forever against a name that can never
2122    // work.
2123    if name.contains("..")
2124        || name.ends_with('/')
2125        || name.starts_with('/')
2126        || name.ends_with(".lock")
2127        // Per COMPONENT, not just the whole name: git rejects `a.lock/b` too,
2128        // and a name that only fails at push time lands on the retriable
2129        // default and is retried forever against something that can never work.
2130        || name.split('/').any(|c| c.ends_with(".lock"))
2131        || name.ends_with('.')
2132        || name.contains("//")
2133        || name.contains("@{")
2134        || name.split('/').any(|c| c.is_empty() || c.starts_with('.'))
2135    {
2136        return Err(format!("{label} `{name}` is not a legal git ref name"));
2137    }
2138    Ok(())
2139}
2140
2141/// Turn a raw push error into a reason plus a retriability verdict.
2142///
2143/// The two verdicts drive opposite behaviour in the orchestrator — retriable
2144/// means "requeue, this is not a no-progress cycle", non-retriable means "park
2145/// the goal and quote the failure class" — so a misread in either direction is
2146/// expensive, and both were happening against git's real output:
2147///
2148/// - A bare `contains("403")` matched the digits inside an echoed commit SHA, so
2149///   an ordinary lost race was parked. A parked goal is never relaunched, which
2150///   also meant the round-N+1 recovery (merging `origin/<target>` back in) never
2151///   got the chance to run. Now anchored on HTTP phrasing, which a branch or
2152///   repository name cannot produce.
2153/// - Git's actual permission wording is `Permission to <repo> denied to <user>`,
2154///   which does NOT contain the contiguous phrase `permission denied`, so a
2155///   genuine refusal matched nothing and fell through to the retriable default —
2156///   retried forever. The old test passed only because it used wording git does
2157///   not emit.
2158/// - Git's lost-race wording (`cannot lock ref …`, `! [remote rejected] …`)
2159///   matched none of the retriable keywords either. Note `[remote rejected]`
2160///   does not contain `[rejected]` — the bracket sits before `remote`. The
2161///   verdict was right by accident, and the reason text said nothing useful.
2162///
2163/// **Refusals are tested first**, because git prints refusal and rejection
2164/// wording on the same line — `! [remote rejected] main -> main (pre-receive
2165/// hook declined)` is a branch-protection refusal, not a race — and "do not
2166/// retry" has to win when both are present. Getting that order wrong is how
2167/// adding the race patterns would have introduced a fresh misclassification.
2168fn classify_push_error(err: &str) -> (String, bool) {
2169    let low = err.to_ascii_lowercase();
2170
2171    let refused = low.contains("permission denied")
2172        || (low.contains("permission to") && low.contains("denied"))
2173        // Anchored on HTTP phrasing. Token-anchoring stopped the digits inside
2174        // a commit SHA matching, but a branch or repository name is delimited
2175        // by `-` `/` `_` `.` — so `feature-403`,
2176        // `goalpool/g_403` and `repo-403.git` all present `403` as a standalone
2177        // token, and git echoes branch and remote in every push error. Since
2178        // refusals are tested before races, an ordinary lost race on such a name
2179        // was parked as a permission refusal.
2180        || low.contains("returned error: 403")
2181        || low.contains("status code 403")
2182        || low.contains("http 403")
2183        || low.contains("error 403")
2184        || low.contains("authentication failed")
2185        // Git's credential layer emits these two SYMMETRICALLY, and which one
2186        // you get depends on whether the remote URL already carries a username:
2187        // `https://github.com/...` asks for a Username, and
2188        // `https://someuser@github.com/...` — the form `gh auth setup-git` and
2189        // most CI clones leave behind — asks for a Password. Only the first was
2190        // matched, so on the second shape a headless box with no credential
2191        // helper produced `fatal: could not read Password for
2192        // 'https://someuser@github.com'`, which matched nothing in `refused`
2193        // and nothing in `moved` and landed on the retriable default: a full
2194        // model session requeued every round, forever, against a credential
2195        // that will never appear. `gh auth status` cannot catch it either — gh
2196        // holds its own token, which git does not use. Whole phrases, so no
2197        // branch or repository name can synthesise one.
2198        || low.contains("could not read username")
2199        || low.contains("could not read password")
2200        // What git says once `GIT_TERMINAL_PROMPT=0` is set (see
2201        // [`no_interactive_prompts`]): the prompt that used to hang forever now
2202        // returns instantly, and it has to be read as the permanent refusal it
2203        // is rather than retried.
2204        || low.contains("terminal prompts disabled")
2205        // SSH's own version of the same permanence: the host key is unknown or
2206        // has changed, and no number of retries alters that. Without it the
2207        // failure fell through to the retriable default.
2208        || low.contains("host key verification failed")
2209        // 401 — a revoked or expired token. Same HTTP anchoring as 403 above,
2210        // for the same reason: the bare digits appear inside SHAs and branch
2211        // names, the phrasings do not.
2212        || low.contains("returned error: 401")
2213        || low.contains("status code 401")
2214        || low.contains("http 401")
2215        || low.contains("error 401")
2216        // 404 — the ordinary shape of a token that cannot see this private
2217        // repository. GitHub says `remote: Repository not found.` over HTTPS
2218        // and `ERROR: Repository not found.` over SSH, and the `gh auth status`
2219        // preflight cannot catch it: gh IS authenticated, it just has no access
2220        // HERE. It matched nothing above and nothing in `moved`, so it landed
2221        // on the retriable default and the orchestrator requeued a full model
2222        // session, forever, against a wall that cannot move. The whole phrase is
2223        // matched, not `not found` alone, so `fatal: pathspec … not found` and
2224        // friends cannot claim it.
2225        || low.contains("repository not found")
2226        // A remote that is missing or is not a repository at all. Permanent by
2227        // the same argument, and `classify_pr_error` already calls the same
2228        // condition (`no such remote`) permanent — so leaving it retriable here
2229        // meant the two classifiers disagreed about one fact.
2230        || low.contains("does not appear to be a git repository")
2231        // Branch protection / server-side policy. Arrives wrapped in rejection
2232        // wording, so it must be recognised before the race patterns below.
2233        || low.contains("pre-receive hook declined")
2234        || low.contains("protected branch")
2235        // `! [remote rejected]` is git's GENERIC server-refusal line; the real
2236        // reason is in the parentheses. These are permanent, and classifying
2237        // them as races told the orchestrator "requeue, nothing was judged" —
2238        // a full model session per round against an identical wall, forever.
2239        || low.contains("refusing to allow")
2240        || low.contains("workflow' scope")
2241        || low.contains("shallow update not allowed")
2242        || low.contains("file size limit")
2243        // A directory/file ref-namespace collision emits BOTH `cannot lock ref`
2244        // and `failed to update ref`, so without this it matches the race set
2245        // and retries forever. It matters specifically here: goalpool branches
2246        // live under a `goalpool/` prefix, so one remote branch literally named
2247        // `goalpool` poisons every goal. Captured from git 2.50.1:
2248        //   remote: error: cannot lock ref 'refs/heads/goalpool/g_1':
2249        //   'refs/heads/goalpool' exists; cannot create 'refs/heads/goalpool/g_1'
2250        || low.contains("exists; cannot create")
2251        // A ruleset or secret-scanning block. GitHub prints this reason inside
2252        // the `[remote rejected]` parentheses, and it is permanent in the
2253        // strongest sense — the same commit can never be pushed.
2254        || low.contains("push declined")
2255        || mentions_github_policy_code(&low);
2256    if refused {
2257        return (
2258            format!("push refused for credential/permission reasons: {err}"),
2259            false,
2260        );
2261    }
2262
2263    let moved = low.contains("non-fast-forward")
2264        || low.contains("fetch first")
2265        || low.contains("[rejected]")
2266        || low.contains("remote rejected")
2267        || low.contains("cannot lock ref")
2268        || low.contains("failed to update ref");
2269    if moved {
2270        return (
2271            format!(
2272                "non-fast-forward: the target branch moved since this worktree was cut \
2273                 (lost a push race) — {err}"
2274            ),
2275            true,
2276        );
2277    }
2278
2279    // Everything else (DNS, TLS, a transient 5xx from the host) is a transport
2280    // problem the next round may well get past.
2281    (err.to_string(), true)
2282}
2283
2284/// Whether the text carries one of GitHub's `GHNNN:` push-policy codes.
2285///
2286/// This was a hand-written list that stopped at `GH008:` — which left `GH013`,
2287/// the code for a repository-rule violation and therefore the one secret-
2288/// scanning push protection emits, matching nothing in the refusal set. It then
2289/// fell through to `remote rejected` in the race set and was reported to the
2290/// orchestrator as "the branch moved, requeue": a full model session per round,
2291/// re-pushing the identical commit at the identical wall, forever. That is the
2292/// precise failure the `[remote rejected]` handling above exists to prevent, and
2293/// push protection is on by default for public repositories, so the gap was not
2294/// on an exotic path. Matching the SHAPE closes the family — including whatever
2295/// code GitHub adds next — rather than the members someone happened to list.
2296///
2297/// Two anchors keep it off ordinary text, because this file has twice shipped a
2298/// predicate that fired on a name. The digits must be followed by `:`, which
2299/// `validate_branch_name` rejects in a ref name, so no branch, tag or remote can
2300/// synthesise one; and they must be preceded by a non-alphanumeric, so no longer
2301/// word ending in `gh` can either.
2302fn mentions_github_policy_code(low: &str) -> bool {
2303    let bytes = low.as_bytes();
2304    bytes.windows(6).enumerate().any(|(i, w)| {
2305        w[0] == b'g'
2306            && w[1] == b'h'
2307            && w[2..5].iter().all(u8::is_ascii_digit)
2308            && w[5] == b':'
2309            && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
2310    })
2311}
2312
2313/// Classify a `gh` reconciliation failure the way [`classify_push_error`]
2314/// classifies a push.
2315///
2316/// Every `map_err` on the reconciliation path used to construct
2317/// `DeliveryFailure::Pr { retriable: true }` without looking at the reason, so
2318/// the stage's retriability field carried no information — the variant's own doc
2319/// said "usually retriable", but no code path could produce the "usually not"
2320/// case. Permanent GitHub refusals therefore returned exit 2, which the
2321/// orchestrator reads as "re-run me, nothing was judged": a whole fresh session
2322/// and another push to reach the identical error.
2323///
2324/// The permanent set is deliberately narrow — an unrecognised failure stays
2325/// retriable, because a GitHub API blip genuinely is the common case and
2326/// wrongly parking a healthy goal is the worse error of the two.
2327fn classify_pr_error(err: &str) -> (String, bool) {
2328    let low = err.to_ascii_lowercase();
2329    // NOT `already exists`. GitHub returns
2330    // `a pull request for branch "X" into branch "Y" already exists: #7`
2331    // precisely when a pull request DOES exist for that head — and reaching
2332    // `create_pr` at all means the listing returned nothing, so the two
2333    // statements contradict each other: a list/create race, replication lag
2334    // right after a push, or a `--head` that did not match. In every one of
2335    // those the commit is already pushed and a usable pull request is open, and
2336    // the error even names its number. It is the one candidate whose own
2337    // trigger condition proves the goal can progress, so parking on it strands
2338    // a goal at the exact moment it has succeeded.
2339    let permanent = low.contains("no commits between")
2340        || low.contains("draft pull requests are not supported")
2341        || low.contains("must be a collaborator")
2342        || low.contains("no such remote")
2343        || low.contains("could not resolve to a repository");
2344    (err.to_string(), !permanent)
2345}
2346
2347/// Turn a failed `gh` call into a typed delivery failure.
2348///
2349/// The one place the split in [`GhError`] is spent: the verdict is taken from
2350/// `stderr` — what GitHub actually said — and the human-readable reason from
2351/// `message`. Every reconciliation `map_err` goes through here so no call site
2352/// can quietly hand the classifier the argv again.
2353fn pr_failure(e: GhError) -> DeliveryFailure {
2354    let (_, retriable) = classify_pr_error(&e.stderr);
2355    DeliveryFailure::Pr {
2356        reason: e.message,
2357        retriable,
2358    }
2359}
2360
2361/// The ambiguous-delivery-head refusal, as a reason string — `None` when the
2362/// head is unambiguous and delivery may proceed.
2363///
2364/// `prs` is everything `gh` reports for the head branch (see
2365/// [`GitHubApi::list_prs_for_head`]); a pull request parks delivery when it is
2366/// **open** into a base other than this run's, because pushing to
2367/// `target_branch` adds this round's commits to it as well.
2368///
2369/// Half of [`delivery_head_refusal`], which is what callers should use; kept
2370/// separate so each half can be read and tested against its own rule.
2371///
2372/// OPEN pull requests only, and only into a FOREIGN base. A merged one is inert
2373/// — it cannot gain commits. A closed one is handled by the other half,
2374/// [`closed_pr_refusal`], on a different rule: it is not about where the push
2375/// lands but about whose decision a close is. Cross-repository pull requests
2376/// never reach here — [`parse_pr_list`] drops them — so a fork whose branch
2377/// happens to share this name cannot park a legitimate run.
2378pub fn ambiguous_head_refusal(
2379    prs: &[PrRecord],
2380    target_branch: &str,
2381    base_branch: &str,
2382) -> Option<String> {
2383    let foreign_open: Vec<&PrRecord> = prs
2384        .iter()
2385        .filter(|p| p.state == PrState::Open && p.base != base_branch)
2386        .collect();
2387    if foreign_open.is_empty() {
2388        return None;
2389    }
2390    let described = foreign_open
2391        .iter()
2392        .map(|p| format!("#{} into `{}`", p.number, p.base))
2393        .collect::<Vec<_>>()
2394        .join(", ");
2395    let numbers = foreign_open
2396        .iter()
2397        .map(|p| format!("#{}", p.number))
2398        .collect::<Vec<_>>()
2399        .join(", ");
2400    let plural = if foreign_open.len() == 1 { "" } else { "s" };
2401    Some(format!(
2402        "branch `{target_branch}` already has open pull request{plural} {described} — not into \
2403         `{base_branch}`, this run's base. Pushing this round's commits to `{target_branch}` \
2404         would add them to {numbers} as well, because a pull request tracks its head branch. \
2405         Close {numbers}, or deliver to a different --target-branch"
2406    ))
2407}
2408
2409/// The closed-pull-request refusal, as a reason string — `None` when no pull
2410/// request into this run's base is closed-unmerged and delivery may proceed.
2411///
2412/// Half of [`delivery_head_refusal`]. Scoped to `base_branch` because that is
2413/// the pull request this run would otherwise reconcile: a pull request from
2414/// this branch into some OTHER base being closed says nothing about this run,
2415/// and letting it park delivery would hand any stale pull request a veto over a
2416/// base it does not merge into.
2417///
2418/// **An OPEN pull request into this base suppresses the veto entirely.** GitHub
2419/// allows at most one open pull request per (head, base) pair, so when one
2420/// exists it is unambiguously the one this run reconciles, and its existence is
2421/// a later human decision than any close: a reviewer who closes #40 as the
2422/// wrong approach and opens #55 from the same branch into the same base has
2423/// carried the work forward, not stopped it. Vetoing there would park every
2424/// subsequent round on the number the reviewer deliberately superseded while
2425/// the live pull request went stale, and the only remedies offered would be
2426/// reopening the dead one or renaming `--target-branch`. This keeps the rule
2427/// that predates car#1055 — an open pull request into the base always wins —
2428/// and narrows the change to the arm that used to reopen.
2429///
2430/// **The runtime never reopens a pull request it did not close** — and it never
2431/// closes one, so it is never the party entitled to undo a close. Until
2432/// car#1055 the closed case was a reopen: a reviewer who read the pull request,
2433/// edited the body and closed it got it reopened on the next round with their
2434/// edits replaced wholesale, every round, and closing it did not stop the
2435/// runtime. Nothing available at this seam distinguishes "closed because it
2436/// went stale" from "closed by a human who read it and said no", and the second
2437/// reading is the one that must win: a close is the cheapest stop signal a
2438/// person has on an agent acting under their account, and it has to hold
2439/// without them deleting a branch.
2440///
2441/// Cross-repository pull requests never reach here — [`parse_pr_list`] drops
2442/// them — so a fork's closed pull request cannot park a legitimate run.
2443pub fn closed_pr_refusal(
2444    prs: &[PrRecord],
2445    target_branch: &str,
2446    base_branch: &str,
2447) -> Option<String> {
2448    // Superseded: reconciliation would update the open one, and that is a
2449    // later decision than the close. See the doc comment.
2450    if prs
2451        .iter()
2452        .any(|p| p.state == PrState::Open && p.base == base_branch)
2453    {
2454        return None;
2455    }
2456    let closed: Vec<&PrRecord> = prs
2457        .iter()
2458        .filter(|p| p.state == PrState::ClosedUnmerged && p.base == base_branch)
2459        .collect();
2460    if closed.is_empty() {
2461        return None;
2462    }
2463    let numbers = closed
2464        .iter()
2465        .map(|p| format!("#{}", p.number))
2466        .collect::<Vec<_>>()
2467        .join(", ");
2468    let plural = if closed.len() == 1 { "" } else { "s" };
2469    let was = if closed.len() == 1 { "was" } else { "were" };
2470    Some(format!(
2471        "pull request{plural} {numbers} from `{target_branch}` into `{base_branch}` {was} \
2472         closed — the runtime does not reopen a pull request it did not close. Reopen {numbers} \
2473         yourself to continue on this branch, or deliver to a different --target-branch"
2474    ))
2475}
2476
2477/// The whole delivery-head policy: [`ambiguous_head_refusal`] first, then
2478/// [`closed_pr_refusal`]. `None` when the head is clear and delivery may
2479/// proceed.
2480///
2481/// `prs` is everything `gh` reports for the head branch (see
2482/// [`GitHubApi::list_prs_for_head`]).
2483///
2484/// Split out of [`deliver_pr_with`] so `car code-task`'s own preflight can apply
2485/// exactly this policy — same inputs, same words — before the model session
2486/// starts. Both call sites are needed and neither is redundant: the early one
2487/// makes an already-parked head cost no session; the delivery-time one is what
2488/// actually stands between the push and a pull request opened or closed DURING
2489/// the session. Callers take this function rather than either half, so the two
2490/// preflights cannot drift apart one rule at a time.
2491pub fn delivery_head_refusal(
2492    prs: &[PrRecord],
2493    target_branch: &str,
2494    base_branch: &str,
2495) -> Option<String> {
2496    ambiguous_head_refusal(prs, target_branch, base_branch)
2497        .or_else(|| closed_pr_refusal(prs, target_branch, base_branch))
2498}
2499
2500/// Deliver the worktree as a pull request on a stable branch.
2501///
2502/// This is the headless third delivery mode, alongside [`publish_branch`] (raw
2503/// repo, local branch) and [`commit_to_main`] (managed project, ff-only into
2504/// `main`). It commits the worktree with the same `car-coder` authorship, pushes
2505/// that commit onto `target_branch` on `origin`, and reconciles exactly one pull
2506/// request for that branch.
2507///
2508/// **This is host code and it is NOT a hole in the coder's tool policy.** The
2509/// model's own shell tool still refuses `git push` through
2510/// [`super::policy`]'s `DenyGitRemoteMutation` inspector, and that inspector is
2511/// deliberately untouched by this function. The distinction is who is acting:
2512/// the inspector chain gates commands the MODEL proposes, which is why it must
2513/// deny remote mutation — a model that can push can exfiltrate a repository and
2514/// can escape every gate downstream of it. `deliver_pr` runs after
2515/// `evaluate_contract` has re-executed the checks and observed their exit codes
2516/// itself, on a commit the runtime made, to a branch the runtime named. Nothing
2517/// the model said is trusted here; only what the runtime verified. Weakening the
2518/// inspector to let the model push would be a hole. Pushing from the runtime,
2519/// after verification, is the mechanism the inspector exists to protect.
2520///
2521/// The guarantees, in order of how badly their absence would hurt:
2522///
2523/// 1. **Preflight before work.** The origin URL selects GitHub or Azure DevOps,
2524///    then that forge's credential is checked before anything is committed or
2525///    pushed. An unknown host fails with the explicit override name. Missing
2526///    credentials produce [`DeliveryFailure::Preflight`]. This never returns
2527///    success without a pull request.
2528/// 2. **Append/fast-forward only.** The push is a plain `<sha>:refs/heads/<b>`
2529///    refspec with no force flag and no leading `+` anywhere on this path (a
2530///    test asserts that against this file's own source text). A non-fast-forward
2531///    rejection is a retriable [`DeliveryFailure::Push`], and the remote is left
2532///    exactly as it was.
2533/// 3. **Base-branch update policy: MERGE.** When the base moves and the branch
2534///    needs updating, the policy for this delivery path is `git merge
2535///    origin/<base>` in the worktree — **never rebase, never force**. Rebase
2536///    rewrites commits a reviewer may already have read and a reviewer may
2537///    already have commented on; force-updating the branch can silently discard
2538///    a round's work. Merge is additive and cannot lose a commit. Note that
2539///    `deliver_pr` does not itself run the merge: it delivers what is in the
2540///    worktree, and bringing the base in is the round orchestrator's step
2541///    before the session starts. The policy is stated here because this is the
2542///    function whose invariants it protects.
2543/// 4. **One pull request per (branch, base), and at most one OPEN pull request
2544///    per branch.** Among the pull requests whose head is `target_branch` AND
2545///    whose base is `base_branch`: an open one receives the push
2546///    ([`PrAction::Updated`]); otherwise one is created ([`PrAction::Opened`]).
2547///    The pair, not the head alone, is the forge's own constraint — see
2548///    [`PrRecord::base`] — and it is the right granularity for choosing WHICH
2549///    pull request to reconcile. It is not sufficient to decide whether
2550///    delivering is safe at all, because a pull request tracks its HEAD: the
2551///    push lands in every open pull request whose head is `target_branch`,
2552///    whatever base each merges into, and no base filter applied afterwards can
2553///    take that back. So a clear head is a PRECONDITION of delivery
2554///    ([`delivery_head_refusal`]), checked at preflight
2555///    ([`DeliveryFailure::Preflight`]) before anything is committed or pushed:
2556///    an open pull request from this branch into any OTHER base is refused,
2557///    naming each number and its base. A run with a changed `--pr-base`
2558///    therefore delivers only once the previous pull request is closed or
2559///    merged; with it still open the run is refused rather than quietly adding
2560///    this round's commits to it.
2561///
2562///    **A CLOSED pull request into this run's own base also parks the round,
2563///    and the runtime never reopens it.** The runtime never closes a pull
2564///    request, so it is never the party entitled to undo a close, and nothing
2565///    at this seam tells "closed because it went stale" from "closed by a
2566///    reviewer who read it and said no". Until car#1055 this path reopened the
2567///    pull request and replaced its body, so closing one did not stop the
2568///    runtime; now a close is a stop, and a human reopens it to continue. A
2569///    MERGED pull request parks nothing: it is inert, it cannot gain commits,
2570///    and its branch gets a fresh pull request for the next round.
2571/// 5. **Draft is a create-time decision only.** `draft` is honored when a pull
2572///    request is created and ignored otherwise. This function NEVER marks a
2573///    pull request ready for review — that is a car worker's judgment in a
2574///    later round, and a runtime that could flip it would be publishing
2575///    unreviewed work on the reviewer's behalf.
2576/// 6. **The body is refreshed, not appended.** On update the pull request body
2577///    is REPLACED with `body`. The body is the next fresh session's standing
2578///    context (it must describe the branch as it is now), and appending would
2579///    grow an unbounded log of stale round-by-round descriptions with the
2580///    current truth buried at the bottom.
2581///
2582/// The selected forge CLI runs in `repo`; git runs in `worktree`. They share
2583/// the same `origin` remote configuration.
2584pub fn deliver_pr(d: PrDelivery<'_>) -> Result<PrDeliveryOutcome, DeliveryFailure> {
2585    let forge = selected_forge(d.repo).map_err(|reason| DeliveryFailure::Preflight { reason })?;
2586    deliver_pr_with(d, forge.as_ref())
2587}
2588
2589/// [`deliver_pr`] with the forge injected. Tests drive this with a fake;
2590/// production selects from `origin` plus [`FORGE_OVERRIDE_ENV`].
2591pub fn deliver_pr_with(
2592    d: PrDelivery<'_>,
2593    forge: &dyn ForgeClient,
2594) -> Result<PrDeliveryOutcome, DeliveryFailure> {
2595    // --- 1. Preflight: nothing is touched until this passes. ---------------
2596    validate_branch_name("target branch", d.target_branch)
2597        .map_err(|reason| DeliveryFailure::Preflight { reason })?;
2598    validate_branch_name("base branch", d.base_branch)
2599        .map_err(|reason| DeliveryFailure::Preflight { reason })?;
2600    // The two names were validated independently and never compared, so `main`
2601    // passed as BOTH. `push_args` then builds `<sha>:refs/heads/main` and the
2602    // model's unreviewed output is published to the base branch; reconciliation
2603    // only fails afterwards (`gh pr create --head main --base main` → "no
2604    // commits between", which `classify_pr_error` marks permanent), so the goal
2605    // parked with the code already on `main` and an append-only path has no way
2606    // to take it back. `--pr-base` defaults to the repository's default branch,
2607    // so `--target-branch main` alone is enough to reach it. This is the one
2608    // preflight refusal that protects the whole point of the mode.
2609    if d.target_branch == d.base_branch {
2610        return Err(DeliveryFailure::Preflight {
2611            reason: format!(
2612                "target branch and base branch are both `{}`; delivering would push \
2613                 unreviewed work directly onto the base instead of opening a pull request",
2614                d.target_branch
2615            ),
2616        });
2617    }
2618    forge
2619        .auth_status()
2620        .map_err(|e| DeliveryFailure::Preflight {
2621            reason: e.to_string(),
2622        })?;
2623
2624    // --- 2. Preflight, continued: the head must be unambiguous. ------------
2625    // The listing that reconciliation needs, taken BEFORE the push, because a
2626    // pull request tracks its HEAD branch: every commit pushed to
2627    // `target_branch` appears in every open pull request whose head it is,
2628    // whatever base each merges into. The base filter at step 5 stops this run
2629    // from rewriting a foreign pull request's body, but it cannot stop the
2630    // push, which by then has already landed the model's commits on it — a
2631    // human's open pull request from this branch into `release/2.1` silently
2632    // gained unreviewed work while the run reported opening a different one
2633    // into `main`. GitHub offers no way to push to a branch without updating
2634    // every open pull request tracking it, so refusal is the only thing that
2635    // prevents it; a warning would go to the JSONL stream a machine
2636    // orchestrator reads, not to the person whose release pull request moved.
2637    //
2638    // The same listing also carries the CLOSED case, on a different rule: a
2639    // closed pull request into this run's own base is somebody's decision that
2640    // this branch should stop, and the runtime does not reopen it (car#1055).
2641    // That refusal belongs HERE, at preflight, and not at reconciliation —
2642    // parking at step 5 would leave the round's commits pushed onto the branch
2643    // with no pull request describing them.
2644    //
2645    // This is the LOAD-BEARING call: `car code-task` applies the same policy in
2646    // its own preflight so an already-parked head costs no model session, but a
2647    // pull request can be opened — or closed — while the session runs, and only
2648    // this one is between that pull request and the push.
2649    let listed = forge
2650        .list_prs_for_head(d.repo, d.target_branch)
2651        .map_err(pr_failure)?;
2652    if let Some(reason) = delivery_head_refusal(&listed, d.target_branch, d.base_branch) {
2653        return Err(DeliveryFailure::Preflight { reason });
2654    }
2655
2656    // --- 3. Commit. --------------------------------------------------------
2657    let commit = match commit_worktree(d.worktree, d.intent, d.contract, d.provenance) {
2658        Ok(CommitOutcome::Made(c)) => c,
2659        Ok(CommitOutcome::NothingToCommit) => head_beyond_base(d.worktree, d.base_branch)
2660            .map_err(|reason| DeliveryFailure::Commit { reason })?,
2661        Err(reason) => return Err(DeliveryFailure::Commit { reason }),
2662    };
2663
2664    // --- 4. Push: append-only. ---------------------------------------------
2665    let args = push_args(&commit, d.target_branch);
2666    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
2667    if let Err(e) = git(d.worktree, &arg_refs) {
2668        let (reason, retriable) = classify_push_error(&e);
2669        return Err(DeliveryFailure::Push { reason, retriable });
2670    }
2671
2672    // --- 5. Reconcile exactly one pull request for the branch. -------------
2673    // Over the `listed` vector step 2 already holds. The push cannot create a
2674    // pull request, so re-listing here would return the same set.
2675
2676    // Restricted to this run's BASE. GitHub's one-open-pull-request constraint
2677    // is per (head, base) pair, so `--head <target>` alone can return several
2678    // legal pull requests that merge into different branches. Reconciling over
2679    // all of them took the highest-numbered one and replaced its body: a pull
2680    // request opened from this same branch into `release/2.1` — by a human, or
2681    // by a round invoked with a different `--pr-base` — captured every
2682    // subsequent round, had a description this run did not author overwritten,
2683    // and left the pull request the orchestrator tracks with a permanently stale
2684    // body. Step 2 now refuses the OPEN case outright, but the filter is still
2685    // load-bearing for the merged one: without it a pull request merged into
2686    // ANOTHER base would suppress creating this run's own. Filtering also makes
2687    // `d.base_branch` mean something on the update path, where it was validated
2688    // in preflight and then silently ignored.
2689    let existing: Vec<&PrRecord> = listed.iter().filter(|p| p.base == d.base_branch).collect();
2690
2691    // No closed arm: step 2 refused every closed pull request into this base
2692    // before the commit, except where an OPEN one into the same base supersedes
2693    // it — and that one is picked up by the open arm just below, which is the
2694    // behaviour the close rule was never meant to change (car#1055).
2695
2696    // Highest number wins when several match: it is the most recent.
2697    let open = existing
2698        .iter()
2699        .filter(|p| p.state == PrState::Open)
2700        .max_by_key(|p| p.number);
2701
2702    let (record, action) = if let Some(pr) = open {
2703        // The push already landed on it. Refresh the body; do NOT touch its
2704        // draft state.
2705        forge
2706            .set_pr_body(d.repo, pr.number, d.body)
2707            .map_err(pr_failure)?;
2708        ((*pr).clone(), PrAction::Updated)
2709    } else {
2710        // None, or only merged ones — a merged pull request's work has landed,
2711        // so the next round needs one of its own.
2712        let created = forge
2713            .create_pr(
2714                d.repo,
2715                d.target_branch,
2716                d.base_branch,
2717                &subject_from_intent(d.intent),
2718                d.body,
2719                d.draft,
2720            )
2721            .map_err(pr_failure)?;
2722        (created, PrAction::Opened)
2723    };
2724
2725    // --- 6. Read CI for the exact commit just delivered. ------------------
2726    // A branch can move between reconciliation and this read. The production
2727    // adapters validate the requested head against the observed PR head, so
2728    // another commit's green checks can never be reported for this one.
2729    // Publication has completed. An observational failure must not discard its
2730    // evidence or make self-heal retry delivery as if no PR had been opened.
2731    let ci = forge
2732        .ci_for_sha(d.repo, record.number, &commit)
2733        .unwrap_or_else(|error| {
2734            let mut ci = CiSummary::from_checks(&commit, Vec::new());
2735            ci.observation_error = Some(error.message);
2736            ci
2737        });
2738
2739    Ok(PrDeliveryOutcome {
2740        branch: d.target_branch.to_string(),
2741        commit,
2742        pushed: true,
2743        pr_number: record.number,
2744        pr_url: record.url,
2745        pr_action: action,
2746        draft: record.is_draft,
2747        ci,
2748    })
2749}
2750
2751#[cfg(test)]
2752mod tests {
2753    use super::*;
2754    use crate::coder::contract::ContractCheck;
2755
2756    fn contract() -> OutcomeContract {
2757        OutcomeContract {
2758            description: "x exists".into(),
2759            checks: vec![ContractCheck {
2760                name: "exists".into(),
2761                command: "test -f x.txt".into(),
2762                expect_exit_zero: true,
2763                output_contains: None,
2764                timeout_secs: 10,
2765                baseline: false,
2766                differential: None,
2767            }],
2768        }
2769    }
2770
2771    fn placement(subtask: &str, worker: Option<&str>, remote: bool) -> car_multi::Placement {
2772        car_multi::Placement {
2773            subtask_id: subtask.to_string(),
2774            worker_id: worker.map(str::to_string),
2775            remote,
2776            attempts: Vec::new(),
2777        }
2778    }
2779
2780    fn landed(subtask: &str, files: &[&str]) -> IntegratedSubtask {
2781        IntegratedSubtask {
2782            subtask_id: subtask.to_string(),
2783            files: files.iter().map(|f| f.to_string()).collect(),
2784        }
2785    }
2786
2787    #[test]
2788    fn a_local_run_renders_no_trailers() {
2789        assert_eq!(placement_provenance(&[], &[], false), None);
2790        // A pool always seeds the local worker, so a distributed run whose every
2791        // peer was excluded still produces placements — all local. Announcing
2792        // the fleet there is the same overclaim in a quieter form.
2793        assert_eq!(
2794            placement_provenance(
2795                &[placement("s1", Some("this-host"), false)],
2796                &[landed("s1", &["a.rs"])],
2797                false
2798            ),
2799            None
2800        );
2801    }
2802
2803    /// The defect this had to be reworked to avoid: a `Placement` is recorded
2804    /// when a worker RETURNS, before the per-patch gate rules on what it
2805    /// produced. `NothingAccepted` and `IntegrationRejected` both fall back to a
2806    /// locally-authored diff with the ledger fully populated, so rendering the
2807    /// ledger would credit peers for a commit they contributed nothing to.
2808    #[test]
2809    fn workers_that_ran_but_whose_patches_never_landed_are_not_credited() {
2810        let ran_everywhere = [
2811            placement("s1", Some("studio"), true),
2812            placement("s2", Some("laptop"), true),
2813        ];
2814        assert_eq!(
2815            placement_provenance(&ran_everywhere, &[], false),
2816            None,
2817            "nothing was integrated, so nothing may be claimed"
2818        );
2819
2820        // And the partial case: two ran, one landed.
2821        let rendered =
2822            placement_provenance(&ran_everywhere, &[landed("s1", &["a.rs"])], false).unwrap();
2823        assert!(rendered.contains("worker=studio"), "{rendered}");
2824        assert!(
2825            !rendered.contains("laptop"),
2826            "a rejected patch's worker must not appear: {rendered}"
2827        );
2828    }
2829
2830    #[test]
2831    fn a_locally_repaired_union_says_so_rather_than_crediting_the_fleet_alone() {
2832        let rendered = placement_provenance(
2833            &[placement("s1", Some("studio"), true)],
2834            &[landed("s1", &["a.rs"])],
2835            true,
2836        )
2837        .unwrap();
2838        assert!(rendered.contains("repaired-locally=true"), "{rendered}");
2839    }
2840
2841    #[test]
2842    fn trailers_name_the_machine_and_the_files_it_wrote() {
2843        let rendered = placement_provenance(
2844            &[
2845                placement("s1", Some("studio"), true),
2846                placement("s2", Some("this-host"), false),
2847            ],
2848            &[landed("s1", &["src/a.rs", "src/b.rs"]), landed("s2", &[])],
2849            false,
2850        )
2851        .expect("a distributed run renders");
2852
2853        // Parseable by `git interpret-trailers` / `%(trailers:key=…)`, rather
2854        // than English a downstream tool would have to regex.
2855        assert!(
2856            rendered.contains("CAR-Placement: subtask=s1 worker=studio remote=true"),
2857            "{rendered}"
2858        );
2859        // A subtask id is opaque model output; the files are what make the row
2860        // reviewable.
2861        assert!(rendered.contains("files=src/a.rs,src/b.rs"), "{rendered}");
2862        assert!(
2863            rendered.contains("CAR-Placement: subtask=s2 worker=this-host remote=false"),
2864            "{rendered}"
2865        );
2866        assert!(rendered.lines().all(|l| l.starts_with("CAR-Placement:")));
2867    }
2868
2869    /// Peer-supplied text reaches a `git commit -m` argument. A newline could
2870    /// forge a paragraph or a `Signed-off-by:` in the delivered message, a NUL
2871    /// makes the spawn itself fail, and an unbounded value reaches `E2BIG` —
2872    /// turning a green, gate-passed run into an undeliverable one.
2873    #[test]
2874    fn nothing_from_a_peer_can_forge_a_trailer_or_break_the_commit() {
2875        let hostile = "studio\n\nSigned-off-by: Someone <x@y.z>";
2876        let rendered = placement_provenance(
2877            &[placement("s1", Some(hostile), true)],
2878            &[landed("s1", &["a.rs"])],
2879            false,
2880        )
2881        .unwrap();
2882        assert!(!rendered.contains('\n') || rendered.lines().count() == 1);
2883        assert!(
2884            !rendered.contains("Signed-off-by:\n") && rendered.lines().count() == 1,
2885            "a peer must not be able to add a paragraph: {rendered}"
2886        );
2887
2888        // Control characters, including the NUL that fails the spawn outright.
2889        let rendered = placement_provenance(
2890            &[placement("s\u{0}1", Some("a\u{0}b"), true)],
2891            &[landed("s\u{0}1", &["x.rs"])],
2892            false,
2893        )
2894        .unwrap();
2895        assert!(!rendered.contains('\u{0}'), "{rendered}");
2896
2897        // And bounded, so a long stderr cannot reach the argv ceiling.
2898        let long = "w".repeat(100_000);
2899        let rendered = placement_provenance(
2900            &[placement("s1", Some(&long), true)],
2901            &[landed("s1", &["x.rs"])],
2902            false,
2903        )
2904        .unwrap();
2905        assert!(rendered.len() < 400, "len {}", rendered.len());
2906    }
2907
2908    /// The ledger's failure strings are the worst of the peer-supplied inputs —
2909    /// a verbatim remote response body and git stderr — and are deliberately not
2910    /// in the commit at all. They stay on the session record and the event.
2911    #[test]
2912    fn peer_failure_prose_never_reaches_the_commit() {
2913        let mut p = placement("s1", Some("studio"), true);
2914        p.attempts = vec![car_multi::FailedAttempt {
2915            worker_id: "laptop".into(),
2916            error: "SECRET-STDERR-abcdef".into(),
2917        }];
2918        let rendered = placement_provenance(&[p], &[landed("s1", &["a.rs"])], false).unwrap();
2919        assert!(!rendered.contains("SECRET-STDERR"), "{rendered}");
2920    }
2921
2922    #[test]
2923    fn a_distributed_deliverys_commit_body_says_where_each_subtask_ran() {
2924        let repo_dir = tempfile::tempdir().unwrap();
2925        let repo = repo_dir.path();
2926        init_repo(repo);
2927        let ws = tempfile::tempdir().unwrap();
2928        git(
2929            repo,
2930            &["worktree", "add", "--detach", &ws.path().to_string_lossy()],
2931        )
2932        .unwrap();
2933        std::fs::write(ws.path().join("x.txt"), "made across the fleet").unwrap();
2934
2935        let provenance = placement_provenance(
2936            &[placement("s1", Some("studio"), true)],
2937            &[landed("s1", &["x.txt"])],
2938            false,
2939        )
2940        .unwrap();
2941        let branch = publish_branch(
2942            repo,
2943            ws.path(),
2944            "fleet0001",
2945            "spread this out",
2946            &contract(),
2947            Some(&provenance),
2948        )
2949        .unwrap();
2950
2951        // The commit is the artifact a reviewer of this branch reads, and
2952        // "Authored by CAR Coder" is wrong on its own for a run authored on
2953        // several machines.
2954        let message = git(repo, &["log", "-1", "--format=%B", &branch]).unwrap();
2955        assert!(message.contains("Authored by CAR Coder."), "{message}");
2956        assert!(
2957            message.contains("CAR-Placement: subtask=s1 worker=studio remote=true"),
2958            "{message}"
2959        );
2960        // The contract it already carried is still there.
2961        assert!(message.contains("Outcome contract"), "{message}");
2962
2963        // And git itself parses it as a trailer, which is the whole reason for
2964        // the format — a downstream reader gets structure, not English.
2965        let trailers = git(
2966            repo,
2967            &[
2968                "log",
2969                "-1",
2970                "--format=%(trailers:key=CAR-Placement,valueonly)",
2971                &branch,
2972            ],
2973        )
2974        .unwrap();
2975        assert!(trailers.contains("subtask=s1 worker=studio"), "{trailers}");
2976    }
2977
2978    /// A local delivery's commit body is byte-identical to what it was before
2979    /// provenance existed. `placement_provenance` returning `None` is only half
2980    /// the claim; this is the other half.
2981    #[test]
2982    fn a_local_deliverys_commit_body_is_unchanged() {
2983        let repo_dir = tempfile::tempdir().unwrap();
2984        let repo = repo_dir.path();
2985        init_repo(repo);
2986        let ws = tempfile::tempdir().unwrap();
2987        git(
2988            repo,
2989            &["worktree", "add", "--detach", &ws.path().to_string_lossy()],
2990        )
2991        .unwrap();
2992        std::fs::write(ws.path().join("x.txt"), "made here").unwrap();
2993
2994        let branch =
2995            publish_branch(repo, ws.path(), "local001", "do it", &contract(), None).unwrap();
2996        let message = git(repo, &["log", "-1", "--format=%B", &branch]).unwrap();
2997        assert!(message.contains("Authored by CAR Coder."), "{message}");
2998        assert!(
2999            !message.contains("CAR-Placement"),
3000            "a local run claims nothing about a fleet: {message}"
3001        );
3002    }
3003
3004    fn init_repo(dir: &Path) {
3005        for args in [
3006            vec!["init", "-q", "-b", "main"],
3007            vec![
3008                "-c",
3009                "user.name=t",
3010                "-c",
3011                "user.email=t@t",
3012                "commit",
3013                "-q",
3014                "--allow-empty",
3015                "-m",
3016                "init",
3017            ],
3018        ] {
3019            let out = std::process::Command::new("git")
3020                .arg("-C")
3021                .arg(dir)
3022                .args(&args)
3023                .output()
3024                .unwrap();
3025            assert!(
3026                out.status.success(),
3027                "{}",
3028                String::from_utf8_lossy(&out.stderr)
3029            );
3030        }
3031    }
3032
3033    #[test]
3034    fn publishes_branch_without_touching_user_checkout() {
3035        let repo_dir = tempfile::tempdir().unwrap();
3036        let repo = repo_dir.path();
3037        init_repo(repo);
3038
3039        // Provision a worktree the way a session does.
3040        let wt_base = tempfile::tempdir().unwrap();
3041        let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3042        let ws = car_multi::AgentWorkspace::provision(&config, "coder-merge-test").unwrap();
3043
3044        std::fs::write(ws.path().join("x.txt"), "made by coder").unwrap();
3045        let branch = publish_branch(
3046            repo,
3047            ws.path(),
3048            "abc12345",
3049            "create x.txt with content",
3050            &contract(),
3051            None,
3052        )
3053        .unwrap();
3054        assert_eq!(branch, "car/coder/abc12345");
3055
3056        // The branch exists in the user's repo and contains the file…
3057        let show = git(repo, &["show", &format!("{branch}:x.txt")]).unwrap();
3058        assert_eq!(show, "made by coder");
3059        // …attributed to the coder…
3060        let author = git(repo, &["log", "-1", "--format=%an", &branch]).unwrap();
3061        assert_eq!(author.trim(), "car-coder");
3062        // …and the user's checkout is untouched.
3063        let status = git(repo, &["status", "--porcelain"]).unwrap();
3064        assert!(status.is_empty(), "user checkout dirtied: {status}");
3065        assert!(!repo.join("x.txt").exists());
3066    }
3067
3068    #[test]
3069    fn clean_worktree_refuses_to_publish() {
3070        let repo_dir = tempfile::tempdir().unwrap();
3071        init_repo(repo_dir.path());
3072        let wt_base = tempfile::tempdir().unwrap();
3073        let config = car_multi::WorkspaceConfig::git_worktree_at(repo_dir.path(), wt_base.path());
3074        let ws = car_multi::AgentWorkspace::provision(&config, "coder-clean-test").unwrap();
3075
3076        let err = publish_branch(repo_dir.path(), ws.path(), "def", "noop", &contract(), None)
3077            .unwrap_err();
3078        assert!(err.contains("no changes"), "{err}");
3079    }
3080
3081    #[test]
3082    fn long_intent_is_truncated_in_subject() {
3083        let repo_dir = tempfile::tempdir().unwrap();
3084        let repo = repo_dir.path();
3085        init_repo(repo);
3086        let wt_base = tempfile::tempdir().unwrap();
3087        let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3088        let ws = car_multi::AgentWorkspace::provision(&config, "coder-long-test").unwrap();
3089        std::fs::write(ws.path().join("y.txt"), "y").unwrap();
3090
3091        let long_intent = "a very ".repeat(40) + "long intent";
3092        let branch =
3093            publish_branch(repo, ws.path(), "fff", &long_intent, &contract(), None).unwrap();
3094        let subject = git(repo, &["log", "-1", "--format=%s", &branch]).unwrap();
3095        assert!(subject.trim().len() <= 72);
3096        assert!(subject.contains("..."));
3097    }
3098
3099    #[test]
3100    fn commit_to_main_fast_forwards_the_checkout() {
3101        let repo_dir = tempfile::tempdir().unwrap();
3102        let repo = repo_dir.path();
3103        init_repo(repo);
3104        let wt_base = tempfile::tempdir().unwrap();
3105        let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3106        let ws = car_multi::AgentWorkspace::provision(&config, "coder-main-test").unwrap();
3107        std::fs::write(ws.path().join("z.txt"), "managed").unwrap();
3108
3109        let commit = commit_to_main(repo, ws.path(), "add z", &contract(), None).unwrap();
3110        // main fast-forwarded to the commit; the file is in the repo checkout.
3111        let head = git(repo, &["rev-parse", "HEAD"]).unwrap();
3112        assert_eq!(head.trim(), commit);
3113        assert_eq!(
3114            std::fs::read_to_string(repo.join("z.txt")).unwrap(),
3115            "managed"
3116        );
3117        // No coder branch.
3118        assert!(git(repo, &["branch", "--list", "car/coder/*"])
3119            .unwrap()
3120            .is_empty());
3121    }
3122
3123    #[test]
3124    fn commit_to_main_errors_when_main_moved() {
3125        let repo_dir = tempfile::tempdir().unwrap();
3126        let repo = repo_dir.path();
3127        init_repo(repo);
3128        let wt_base = tempfile::tempdir().unwrap();
3129        let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3130        let ws = car_multi::AgentWorkspace::provision(&config, "coder-moved-test").unwrap();
3131        std::fs::write(ws.path().join("a.txt"), "from session").unwrap();
3132
3133        // Something commits to main AFTER the worktree was provisioned, so the
3134        // worktree's commit is no longer a fast-forward of main.
3135        std::fs::write(repo.join("b.txt"), "concurrent").unwrap();
3136        for args in [
3137            vec!["-c", "user.name=t", "-c", "user.email=t@t", "add", "-A"],
3138            vec![
3139                "-c",
3140                "user.name=t",
3141                "-c",
3142                "user.email=t@t",
3143                "commit",
3144                "-q",
3145                "-m",
3146                "concurrent",
3147            ],
3148        ] {
3149            assert!(std::process::Command::new("git")
3150                .arg("-C")
3151                .arg(repo)
3152                .args(&args)
3153                .output()
3154                .unwrap()
3155                .status
3156                .success());
3157        }
3158
3159        let err = commit_to_main(repo, ws.path(), "add a", &contract(), None).unwrap_err();
3160        assert!(err.contains("fast-forward"), "{err}");
3161    }
3162
3163    // --- Rename-aware, byte-safe changed paths ---------------------------
3164
3165    /// **The bypass this parser exists to close.** Under `--name-only`, git
3166    /// reports only a rename's destination, so moving a file OUT of a directory
3167    /// erased every trace of that directory from the change record. Anything
3168    /// reasoning about which paths a session touched was told a rename is a
3169    /// creation.
3170    #[test]
3171    fn a_rename_reports_both_endpoints_not_just_the_destination() {
3172        let paths = parse_name_status_z("R100\0secrets/key.txt\0public_key.txt\0");
3173        assert!(
3174            paths.contains(&"secrets/key.txt".to_string()),
3175            "the source directory must not vanish: {paths:?}"
3176        );
3177        assert!(paths.contains(&"public_key.txt".to_string()), "{paths:?}");
3178        assert_eq!(paths.len(), 2);
3179    }
3180
3181    /// A copy carries the same three-field shape as a rename.
3182    #[test]
3183    fn a_copy_also_reports_both_endpoints() {
3184        let paths = parse_name_status_z("C75\0src/a.rs\0src/b.rs\0");
3185        assert_eq!(paths, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
3186    }
3187
3188    /// Ordinary two-field entries, and the mixed stream — a rename's extra
3189    /// field must not desynchronize the ones that follow it.
3190    #[test]
3191    fn mixed_entries_stay_in_sync_after_a_rename() {
3192        let paths = parse_name_status_z("M\0src/a.rs\0R100\0old/x.rs\0new/x.rs\0A\0src/z.rs\0");
3193        assert_eq!(
3194            paths,
3195            vec![
3196                "new/x.rs".to_string(),
3197                "old/x.rs".to_string(),
3198                "src/a.rs".to_string(),
3199                "src/z.rs".to_string(),
3200            ]
3201        );
3202    }
3203
3204    /// A newline inside a filename must not be able to forge an entry — the
3205    /// reason for `-z` over line-splitting.
3206    #[test]
3207    fn a_newline_in_a_filename_does_not_forge_an_entry() {
3208        let paths = parse_name_status_z("A\0we\nird.txt\0");
3209        assert_eq!(paths, vec!["we\nird.txt".to_string()]);
3210    }
3211
3212    #[test]
3213    fn an_empty_diff_yields_no_paths() {
3214        assert!(parse_name_status_z("").is_empty());
3215    }
3216
3217    /// `T` (type change, e.g. file -> symlink) and `U` (unmerged) are
3218    /// single-path entries. Verified against real git; pinned here because the
3219    /// whole parser rests on "only R and C carry two paths".
3220    #[test]
3221    fn type_change_and_unmerged_are_single_path_entries() {
3222        assert_eq!(
3223            parse_name_status_z("T\0src/link.txt\0M\0src/after.rs\0"),
3224            vec!["src/after.rs".to_string(), "src/link.txt".to_string()]
3225        );
3226        assert_eq!(
3227            parse_name_status_z("U\0conflict.txt\0"),
3228            vec!["conflict.txt".to_string()]
3229        );
3230    }
3231
3232    /// **The desync guard.** A field that is not a status means git's format
3233    /// moved under us; continuing would read paths as statuses and emit
3234    /// plausible-looking fiction onto a reviewer's approval screen. Bail.
3235    #[test]
3236    fn an_unrecognized_status_bails_instead_of_desynchronizing() {
3237        // `Z9` is not in git's closed status set.
3238        assert!(parse_name_status_z("Z9\0a.txt\0b.txt\0").is_empty());
3239        // A well-formed prefix is kept; the garbage tail is dropped, not guessed.
3240        assert_eq!(
3241            parse_name_status_z("M\0good.rs\0Z9\0a.txt\0"),
3242            vec!["good.rs".to_string()]
3243        );
3244    }
3245
3246    /// A rename whose destination field never arrived is a corrupt stream, not
3247    /// an entry to swallow.
3248    #[test]
3249    fn a_rename_missing_its_destination_bails() {
3250        assert_eq!(
3251            parse_name_status_z("R100\0only-one.txt\0"),
3252            vec!["only-one.txt".to_string()]
3253        );
3254    }
3255
3256    /// End-to-end against real git: the rename bypass, reproduced and closed.
3257    #[test]
3258    fn stage_and_diff_sees_a_renamed_out_of_directory_source() {
3259        let dir = tempfile::tempdir().unwrap();
3260        let repo = dir.path();
3261        for args in [
3262            vec!["init", "-q", "."],
3263            vec!["config", "user.email", "t@t"],
3264            vec!["config", "user.name", "t"],
3265        ] {
3266            git(repo, &args).unwrap();
3267        }
3268        std::fs::create_dir(repo.join("secrets")).unwrap();
3269        std::fs::write(repo.join("secrets/key.txt"), "k").unwrap();
3270        git(repo, &["add", "-A"]).unwrap();
3271        git(repo, &["commit", "-qm", "init"]).unwrap();
3272        std::fs::rename(repo.join("secrets/key.txt"), repo.join("public_key.txt")).unwrap();
3273
3274        let diff = stage_and_diff(repo, 64 * 1024).unwrap();
3275        assert!(
3276            diff.changed_paths.iter().any(|p| p.starts_with("secrets/")),
3277            "the source directory must appear: {:?}",
3278            diff.changed_paths
3279        );
3280        // Exactly the two endpoints — a parser that emitted status letters as
3281        // paths would also satisfy the assertion above.
3282        assert_eq!(
3283            diff.changed_paths,
3284            vec!["public_key.txt".to_string(), "secrets/key.txt".to_string()],
3285            "both endpoints, and nothing else"
3286        );
3287    }
3288
3289    // --- PR delivery -----------------------------------------------------
3290
3291    use std::path::PathBuf;
3292    use std::sync::Mutex;
3293
3294    /// This file's own source text, for the guard tests below. The point of a
3295    /// source-level guard is that it fails on the WRITING of a forbidden token,
3296    /// not on some execution path a future test might not cover.
3297    const MERGE_RS_SOURCE: &str = include_str!("merge.rs");
3298
3299    #[derive(Debug, Clone, PartialEq, Eq)]
3300    struct ForgeCall {
3301        program: String,
3302        args: Vec<String>,
3303    }
3304
3305    struct FakeForgeCommands {
3306        responses: Mutex<std::collections::VecDeque<Result<String, ForgeError>>>,
3307        calls: Mutex<Vec<ForgeCall>>,
3308    }
3309
3310    impl FakeForgeCommands {
3311        fn answers(responses: &[&str]) -> Arc<Self> {
3312            Arc::new(Self {
3313                responses: Mutex::new(responses.iter().map(|s| Ok((*s).to_string())).collect()),
3314                calls: Mutex::new(Vec::new()),
3315            })
3316        }
3317
3318        fn calls(&self) -> Vec<ForgeCall> {
3319            self.calls.lock().unwrap().clone()
3320        }
3321    }
3322
3323    impl ForgeCommandRunner for FakeForgeCommands {
3324        fn run(&self, _dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
3325            self.calls.lock().unwrap().push(ForgeCall {
3326                program: program.to_string(),
3327                args: args.to_vec(),
3328            });
3329            self.responses
3330                .lock()
3331                .unwrap()
3332                .pop_front()
3333                .expect("a scripted forge response")
3334        }
3335    }
3336
3337    fn repo_with_remote(remote: &str) -> tempfile::TempDir {
3338        let repo = tempfile::tempdir().unwrap();
3339        git(repo.path(), &["init", "-q"]).unwrap();
3340        git(repo.path(), &["remote", "add", "origin", remote]).unwrap();
3341        repo
3342    }
3343
3344    #[test]
3345    fn remote_url_selects_github_or_azure_and_unknown_names_the_override() {
3346        for remote in [
3347            "https://github.com/acme/widgets.git",
3348            "git@github.com:acme/widgets.git",
3349        ] {
3350            assert_eq!(
3351                forge_kind_from_remote(remote, None).unwrap(),
3352                ForgeKind::GitHub
3353            );
3354        }
3355        for remote in [
3356            "https://dev.azure.com/acme/platform/_git/widgets",
3357            "git@ssh.dev.azure.com:v3/acme/platform/widgets",
3358            "https://acme.visualstudio.com/platform/_git/widgets",
3359        ] {
3360            assert_eq!(
3361                forge_kind_from_remote(remote, None).unwrap(),
3362                ForgeKind::AzureDevOps
3363            );
3364        }
3365        let error =
3366            forge_kind_from_remote("ssh://git@git.example.test/acme/widgets", None).unwrap_err();
3367        assert!(error.contains(FORGE_OVERRIDE_ENV), "{error}");
3368        assert_eq!(
3369            forge_kind_from_remote(
3370                "ssh://git@git.example.test/acme/widgets",
3371                Some("azure-devops")
3372            )
3373            .unwrap(),
3374            ForgeKind::AzureDevOps
3375        );
3376    }
3377
3378    #[test]
3379    fn github_client_uses_the_existing_cli_contract_through_a_fake_runner() {
3380        let repo = repo_with_remote("https://github.com/acme/widgets.git");
3381        let runner = FakeForgeCommands::answers(&[
3382            "",
3383            r#"[{"number":7,"state":"OPEN","url":"https://github.com/acme/widgets/pull/7","isDraft":false,"isCrossRepository":false,"baseRefName":"main"}]"#,
3384            "https://github.com/acme/widgets/pull/8",
3385            "",
3386            "",
3387            r#"{"headRefOid":"abc123","statusCheckRollup":[{"__typename":"CheckRun","name":"test","status":"COMPLETED","conclusion":"SUCCESS"},{"__typename":"StatusContext","context":"legacy","state":"PENDING"}]}"#,
3388        ]);
3389        let github = GhCli::with_runner(runner.clone());
3390
3391        github.auth_status().unwrap();
3392        let listed = github.list_prs_for_head(repo.path(), "car/work").unwrap();
3393        assert_eq!(listed[0].number, 7);
3394        let created = github
3395            .create_pr(repo.path(), "car/work", "main", "title", "body", true)
3396            .unwrap();
3397        assert_eq!(created.number, 8);
3398        github.set_pr_body(repo.path(), 7, "new body").unwrap();
3399        github.reopen_pr(repo.path(), 7).unwrap();
3400        let ci = github.ci_for_sha(repo.path(), 7, "abc123").unwrap();
3401        assert_eq!(ci.state, CiState::Pending);
3402        assert_eq!(ci.checks.len(), 2);
3403
3404        let calls = runner.calls();
3405        assert_eq!(calls.len(), 6);
3406        assert!(calls.iter().all(|call| call.program == "gh"));
3407        assert_eq!(calls[0].args, gh_auth_status_args());
3408        let mut expected_list = gh_repo_args(repo.path());
3409        expected_list.extend(gh_pr_list_args("car/work"));
3410        assert_eq!(calls[1].args, expected_list);
3411        let mut expected_create = gh_repo_args(repo.path());
3412        expected_create.extend(gh_pr_create_args("car/work", "main", "title", "body", true));
3413        assert_eq!(calls[2].args, expected_create);
3414        let mut expected_edit = gh_repo_args(repo.path());
3415        expected_edit.extend([
3416            "pr".to_string(),
3417            "edit".to_string(),
3418            "7".to_string(),
3419            "--body".to_string(),
3420            "new body".to_string(),
3421        ]);
3422        assert_eq!(calls[3].args, expected_edit);
3423        let mut expected_reopen = gh_repo_args(repo.path());
3424        expected_reopen.extend(gh_pr_reopen_args(7));
3425        assert_eq!(calls[4].args, expected_reopen);
3426        let mut expected_checks = gh_repo_args(repo.path());
3427        expected_checks.extend(gh_pr_checks_args(7));
3428        assert_eq!(calls[5].args, expected_checks);
3429    }
3430
3431    #[test]
3432    fn azure_client_creates_lists_updates_and_reads_checks_through_a_fake_runner() {
3433        let repo = repo_with_remote("https://dev.azure.com/acme/platform/_git/widgets");
3434        let listed = r#"[{"pullRequestId":41,"status":"active","isDraft":false,"targetRefName":"refs/heads/main","_links":{"web":{"href":"https://dev.azure.com/acme/platform/_git/widgets/pullrequest/41"}}}]"#;
3435        let created = r#"{"pullRequestId":42,"status":"active","isDraft":true,"targetRefName":"refs/heads/main","repository":{"webUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}"#;
3436        let shown = r#"{"lastMergeSourceCommit":{"commitId":"def456"}}"#;
3437        let policies = r#"[
3438            {"status":"approved","configuration":{"type":{"displayName":"Build"}}},
3439            {"status":"running","configuration":{"type":{"displayName":"Security"}}},
3440            {"status":"rejected","configuration":{"type":{"displayName":"Windows"}}}
3441        ]"#;
3442        let runner =
3443            FakeForgeCommands::answers(&["[]", listed, created, "", "", shown, policies, shown]);
3444        let azure = AzureDevOpsCli::with_runner(runner.clone(), repo.path());
3445
3446        azure.auth_status().unwrap();
3447        let prs = azure.list_prs_for_head(repo.path(), "car/work").unwrap();
3448        assert_eq!(prs[0].number, 41);
3449        assert_eq!(prs[0].base, "main");
3450        let pr = azure
3451            .create_pr(repo.path(), "car/work", "main", "title", "body", true)
3452            .unwrap();
3453        assert_eq!(pr.number, 42);
3454        assert!(pr.is_draft);
3455        assert_eq!(
3456            pr.url,
3457            "https://dev.azure.com/acme/platform/_git/widgets/pullrequest/42"
3458        );
3459        azure.set_pr_body(repo.path(), 41, "new body").unwrap();
3460        azure.reopen_pr(repo.path(), 41).unwrap();
3461        let ci = azure.ci_for_sha(repo.path(), 41, "def456").unwrap();
3462        assert_eq!(ci.state, CiState::Red);
3463        assert_eq!(
3464            ci.checks,
3465            vec![
3466                CiCheck {
3467                    name: "Build".into(),
3468                    state: CiState::Green,
3469                },
3470                CiCheck {
3471                    name: "Security".into(),
3472                    state: CiState::Pending,
3473                },
3474                CiCheck {
3475                    name: "Windows".into(),
3476                    state: CiState::Red,
3477                },
3478            ]
3479        );
3480
3481        let calls = runner.calls();
3482        assert_eq!(calls.len(), 8);
3483        assert!(calls.iter().all(|call| call.program == "az"));
3484        assert_eq!(calls[0].args, az_auth_status_args());
3485        assert_eq!(calls[1].args, az_pr_list_args("car/work"));
3486        assert_eq!(
3487            calls[2].args,
3488            az_pr_create_args("car/work", "main", "title", "body", true)
3489        );
3490        assert_eq!(calls[3].args, az_pr_update_args(41, "new body"));
3491        assert_eq!(calls[4].args, az_pr_reopen_args(41));
3492        assert_eq!(calls[5].args, az_pr_show_args(41));
3493        assert_eq!(calls[6].args, az_pr_policy_list_args(41));
3494        assert_eq!(calls[7].args, az_pr_show_args(41));
3495    }
3496
3497    #[test]
3498    fn azure_check_read_refuses_a_moved_head() {
3499        let error = parse_azure_ci_summary(
3500            r#"{"lastMergeSourceCommit":{"commitId":"delivered"}}"#,
3501            "[]",
3502            r#"{"lastMergeSourceCommit":{"commitId":"newer"}}"#,
3503            "delivered",
3504        )
3505        .unwrap_err();
3506        assert!(error.contains("expected delivered, found newer"), "{error}");
3507    }
3508
3509    /// A scriptable stand-in for the `gh` CLI. Records every call so a test can
3510    /// assert on what delivery asked GitHub to do, not just on what came back.
3511    struct FakeGh {
3512        auth: Result<(), GhError>,
3513        prs: Mutex<Vec<PrRecord>>,
3514        calls: Mutex<Vec<String>>,
3515        next_number: Mutex<u64>,
3516        /// Per-method failure knobs. Reconciliation has three distinct `gh`
3517        /// seams and only `auth_status` could be made to fail, so the
3518        /// retriability each seam attaches to a failure — the whole reason
3519        /// `DeliveryFailure::Pr` carries the flag — was never exercised end to
3520        /// end. Each holds the STDERR the fake reports; the fake builds the
3521        /// human-readable half itself, so a test cannot accidentally hand the
3522        /// classifier a message.
3523        fail_list: Mutex<Option<String>>,
3524        fail_create: Mutex<Option<String>>,
3525        fail_set_body: Mutex<Option<String>>,
3526        fail_ci: Mutex<Option<String>>,
3527        checks: Mutex<Vec<(String, CiState)>>,
3528    }
3529
3530    /// Production lines carrying a char-literal plus — a force refspec waiting
3531    /// to be interpolated. Returns 1-based line numbers.
3532    ///
3533    /// A FUNCTION rather than an inline loop so it can be pointed at sources
3534    /// that do violate it. Lives in the test module because its own needle is
3535    /// spelled with the very character it bans, and a production copy would
3536    /// report itself.
3537    ///
3538    /// Scoped to production: the test module below legitimately constructs
3539    /// `+`-prefixed names to prove `validate_branch_name` rejects them. The one
3540    /// production line allowed to name the character is [`FORCE_MARKER`]'s
3541    /// definition.
3542    fn force_char_offenders(src: &str) -> Vec<usize> {
3543        let production = src.split_once("mod tests {").map(|(h, _)| h).unwrap_or(src);
3544        let needle: String = ['\'', '+', '\''].iter().collect();
3545        production
3546            .lines()
3547            .enumerate()
3548            .filter(|(_, line)| line.contains(needle.as_str()))
3549            .filter(|(_, line)| !line.contains("FORCE_MARKER: char"))
3550            .map(|(i, _)| i + 1)
3551            .collect()
3552    }
3553
3554    /// The error shape a real `gh` failure has: a message that names the
3555    /// command (and may echo argv) plus the remote's own stderr.
3556    fn gh_err(message: &str, stderr: &str) -> GhError {
3557        GhError {
3558            message: message.to_string(),
3559            stderr: stderr.to_string(),
3560        }
3561    }
3562
3563    impl FakeGh {
3564        fn ok() -> Self {
3565            Self {
3566                auth: Ok(()),
3567                prs: Mutex::new(Vec::new()),
3568                calls: Mutex::new(Vec::new()),
3569                next_number: Mutex::new(101),
3570                fail_list: Mutex::new(None),
3571                fail_create: Mutex::new(None),
3572                fail_set_body: Mutex::new(None),
3573                fail_ci: Mutex::new(None),
3574                checks: Mutex::new(vec![
3575                    ("lint".to_string(), CiState::Green),
3576                    ("test".to_string(), CiState::Green),
3577                ]),
3578            }
3579        }
3580
3581        fn no_credential() -> Self {
3582            Self {
3583                auth: Err(gh_err(
3584                    "no usable GitHub credential: `gh auth status` failed. Authenticate with \
3585                     `gh auth login`, or set GH_TOKEN (or GITHUB_TOKEN)",
3586                    "gh: To get started with GitHub CLI, please run: gh auth login",
3587                )),
3588                ..Self::ok()
3589            }
3590        }
3591
3592        fn with_prs(prs: Vec<PrRecord>) -> Self {
3593            Self {
3594                prs: Mutex::new(prs),
3595                ..Self::ok()
3596            }
3597        }
3598
3599        fn with_checks(checks: Vec<(&str, CiState)>) -> Self {
3600            let me = Self::ok();
3601            *me.checks.lock().unwrap() = checks
3602                .into_iter()
3603                .map(|(name, state)| (name.to_string(), state))
3604                .collect();
3605            me
3606        }
3607
3608        /// `gh pr edit --body` fails with this stderr.
3609        fn failing_set_body(stderr: &str) -> Self {
3610            let me = Self::ok();
3611            *me.fail_set_body.lock().unwrap() = Some(stderr.to_string());
3612            me
3613        }
3614
3615        fn calls(&self) -> Vec<String> {
3616            self.calls.lock().unwrap().clone()
3617        }
3618    }
3619
3620    /// The message half a real `gh` failure carries — command shape plus
3621    /// stderr. Built here so the fake's message is never the bare stderr, which
3622    /// would make "the classifier read stderr" trivially true.
3623    fn fake_gh_failure(command: &str, stderr: &str, body: &str) -> GhError {
3624        gh_err(
3625            &format!("gh {command} --body {body} failed: {stderr}"),
3626            stderr,
3627        )
3628    }
3629
3630    impl GitHubApi for FakeGh {
3631        fn auth_status(&self) -> Result<(), GhError> {
3632            self.calls.lock().unwrap().push("auth_status".into());
3633            self.auth.clone().map_err(|e| e.clone())
3634        }
3635
3636        fn list_prs_for_head(&self, _dir: &Path, head: &str) -> Result<Vec<PrRecord>, GhError> {
3637            self.calls.lock().unwrap().push(format!("list {head}"));
3638            if let Some(stderr) = self.fail_list.lock().unwrap().clone() {
3639                return Err(gh_err(&format!("gh pr list failed: {stderr}"), &stderr));
3640            }
3641            Ok(self.prs.lock().unwrap().clone())
3642        }
3643
3644        fn create_pr(
3645            &self,
3646            _dir: &Path,
3647            head: &str,
3648            base: &str,
3649            title: &str,
3650            body: &str,
3651            draft: bool,
3652        ) -> Result<PrRecord, GhError> {
3653            self.calls.lock().unwrap().push(format!(
3654                "create head={head} base={base} draft={draft} title={title} body={body}"
3655            ));
3656            if let Some(stderr) = self.fail_create.lock().unwrap().clone() {
3657                return Err(fake_gh_failure("pr create", &stderr, body));
3658            }
3659            let mut n = self.next_number.lock().unwrap();
3660            let record = PrRecord {
3661                number: *n,
3662                state: PrState::Open,
3663                url: format!("https://github.com/acme/repo/pull/{n}"),
3664                is_draft: draft,
3665                base: base.to_string(),
3666            };
3667            *n += 1;
3668            self.prs.lock().unwrap().push(record.clone());
3669            Ok(record)
3670        }
3671
3672        fn set_pr_body(&self, _dir: &Path, number: u64, body: &str) -> Result<(), GhError> {
3673            self.calls
3674                .lock()
3675                .unwrap()
3676                .push(format!("set_body {number} {body}"));
3677            if let Some(stderr) = self.fail_set_body.lock().unwrap().clone() {
3678                return Err(fake_gh_failure("pr edit", &stderr, body));
3679            }
3680            Ok(())
3681        }
3682
3683        fn reopen_pr(&self, _dir: &Path, number: u64) -> Result<(), GhError> {
3684            self.calls.lock().unwrap().push(format!("reopen {number}"));
3685            Ok(())
3686        }
3687
3688        fn ci_for_sha(
3689            &self,
3690            _dir: &Path,
3691            number: u64,
3692            head_sha: &str,
3693        ) -> Result<CiSummary, GhError> {
3694            self.calls
3695                .lock()
3696                .unwrap()
3697                .push(format!("ci {number} {head_sha}"));
3698            if let Some(stderr) = self.fail_ci.lock().unwrap().clone() {
3699                return Err(gh_err(&format!("gh pr view failed: {stderr}"), &stderr));
3700            }
3701            Ok(CiSummary::from_checks(
3702                head_sha,
3703                self.checks.lock().unwrap().clone(),
3704            ))
3705        }
3706    }
3707
3708    /// A bare `origin` plus a working clone that pushes to it — the smallest
3709    /// thing that can tell a fast-forward from a rejection for real.
3710    struct Fixture {
3711        origin: PathBuf,
3712        repo: PathBuf,
3713        wt_base: PathBuf,
3714        _dirs: Vec<tempfile::TempDir>,
3715    }
3716
3717    fn fixture() -> Fixture {
3718        let origin_dir = tempfile::tempdir().unwrap();
3719        let repo_dir = tempfile::tempdir().unwrap();
3720        let wt_dir = tempfile::tempdir().unwrap();
3721        let origin = origin_dir.path().to_path_buf();
3722        let repo = repo_dir.path().to_path_buf();
3723
3724        git(&origin, &["init", "-q", "--bare", "-b", "main"]).unwrap();
3725        git(&repo, &["init", "-q", "-b", "main"]).unwrap();
3726        git(&repo, &["config", "user.name", "t"]).unwrap();
3727        git(&repo, &["config", "user.email", "t@t"]).unwrap();
3728        std::fs::write(repo.join("README.md"), "seed").unwrap();
3729        git(&repo, &["add", "-A"]).unwrap();
3730        git(&repo, &["commit", "-qm", "seed"]).unwrap();
3731        git(
3732            &repo,
3733            &["remote", "add", "origin", origin.to_str().unwrap()],
3734        )
3735        .unwrap();
3736        git(&repo, &["push", "-q", "origin", "main"]).unwrap();
3737
3738        Fixture {
3739            origin,
3740            repo,
3741            wt_base: wt_dir.path().to_path_buf(),
3742            _dirs: vec![origin_dir, repo_dir, wt_dir],
3743        }
3744    }
3745
3746    impl Fixture {
3747        /// Cut a session worktree from `from_ref`, the way a round does.
3748        fn cut(&self, name: &str, from_ref: &str) -> PathBuf {
3749            let path = self.wt_base.join(name);
3750            git(
3751                &self.repo,
3752                &[
3753                    "worktree",
3754                    "add",
3755                    "--detach",
3756                    "-q",
3757                    path.to_str().unwrap(),
3758                    from_ref,
3759                ],
3760            )
3761            .unwrap();
3762            path
3763        }
3764
3765        /// The commit `origin` has on a branch, or `None` when it has no such
3766        /// branch.
3767        fn origin_head(&self, branch: &str) -> Option<String> {
3768            git(
3769                &self.origin,
3770                &["rev-parse", "--verify", &format!("refs/heads/{branch}")],
3771            )
3772            .ok()
3773            .map(|s| s.trim().to_string())
3774        }
3775    }
3776
3777    fn delivery<'a>(
3778        f: &'a Fixture,
3779        worktree: &'a Path,
3780        contract: &'a OutcomeContract,
3781        target: &'a str,
3782        draft: bool,
3783        body: &'a str,
3784    ) -> PrDelivery<'a> {
3785        PrDelivery {
3786            repo: &f.repo,
3787            worktree,
3788            target_branch: target,
3789            base_branch: "main",
3790            draft,
3791            intent: "make x exist",
3792            contract,
3793            body,
3794            provenance: None,
3795        }
3796    }
3797
3798    const TARGET: &str = "goalpool/g_abc123";
3799
3800    #[test]
3801    fn github_rollup_combines_check_runs_and_status_contexts_for_the_exact_head() {
3802        let raw = r#"{
3803            "headRefOid":"abc123",
3804            "statusCheckRollup":[
3805                {"__typename":"CheckRun","name":"lint","status":"COMPLETED","conclusion":"SUCCESS"},
3806                {"__typename":"CheckRun","name":"tests","status":"IN_PROGRESS","conclusion":""},
3807                {"__typename":"StatusContext","context":"deploy","state":"FAILURE"},
3808                {"__typename":"StatusContext","context":"lint","state":"PENDING"}
3809            ]
3810        }"#;
3811
3812        let summary = parse_github_ci_summary(raw, "abc123").unwrap();
3813        assert_eq!(summary.head_sha, "abc123");
3814        assert_eq!(summary.state, CiState::Red);
3815        // Duplicate providers use the strongest observed state for one name.
3816        assert_eq!(
3817            summary.checks,
3818            vec![
3819                CiCheck {
3820                    name: "deploy".into(),
3821                    state: CiState::Red,
3822                },
3823                CiCheck {
3824                    name: "lint".into(),
3825                    state: CiState::Pending,
3826                },
3827                CiCheck {
3828                    name: "tests".into(),
3829                    state: CiState::Pending,
3830                },
3831            ]
3832        );
3833    }
3834
3835    #[test]
3836    fn github_rollup_with_no_checks_is_pending_not_green() {
3837        let summary = parse_github_ci_summary(
3838            r#"{"headRefOid":"abc123","statusCheckRollup":null}"#,
3839            "abc123",
3840        )
3841        .unwrap();
3842        assert_eq!(summary.state, CiState::Pending);
3843        assert!(summary.checks.is_empty());
3844    }
3845
3846    #[test]
3847    fn github_rollup_refuses_ci_from_a_different_head() {
3848        let err = parse_github_ci_summary(
3849            r#"{"headRefOid":"newer","statusCheckRollup":[]}"#,
3850            "delivered",
3851        )
3852        .unwrap_err();
3853        assert!(err.contains("expected delivered, found newer"), "{err}");
3854    }
3855
3856    #[test]
3857    fn ci_lookup_failure_preserves_successful_publication() {
3858        let f = fixture();
3859        let c = contract();
3860        let wt = f.cut("s1", "main");
3861        std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
3862        let gh = FakeGh::ok();
3863        *gh.fail_ci.lock().unwrap() = Some("HTTP 503".into());
3864        let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap();
3865        assert!(out.pushed);
3866        assert_eq!(out.pr_number, 101);
3867        assert_eq!(out.pr_action, PrAction::Opened);
3868        assert!(!out.pr_url.is_empty());
3869        assert_eq!(out.ci.head_sha, out.commit);
3870        assert_eq!(out.ci.state, CiState::Pending);
3871        assert!(out.ci.checks.is_empty());
3872        assert!(out.delivery_report().contains("CI unavailable"));
3873        assert!(out.delivery_report().contains("HTTP 503"));
3874        assert_eq!(gh.prs.lock().unwrap().len(), 1);
3875    }
3876
3877    #[test]
3878    fn a_green_delivery_pushes_the_commit_and_opens_one_pr() {
3879        let f = fixture();
3880        let c = contract();
3881        let wt = f.cut("s1", "main");
3882        std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
3883
3884        let gh = FakeGh::ok();
3885        let out =
3886            deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "round 1 body"), &gh).unwrap();
3887
3888        assert!(out.pushed);
3889        assert_eq!(out.branch, TARGET);
3890        assert_eq!(out.pr_action, PrAction::Opened);
3891        assert_eq!(out.pr_number, 101);
3892        assert!(out.draft, "a draft was requested at create time");
3893        assert_eq!(out.ci.state, CiState::Green);
3894        assert_eq!(out.ci.head_sha, out.commit);
3895        assert_eq!(
3896            out.ci.checks,
3897            [
3898                CiCheck {
3899                    name: "lint".into(),
3900                    state: CiState::Green,
3901                },
3902                CiCheck {
3903                    name: "test".into(),
3904                    state: CiState::Green,
3905                },
3906            ]
3907        );
3908        assert_eq!(
3909            out.delivery_report(),
3910            format!(
3911                "delivered with green checks at {}; pull request remains draft",
3912                out.commit
3913            )
3914        );
3915
3916        // The remote really has the commit, with the file and the coder identity.
3917        assert_eq!(f.origin_head(TARGET).as_deref(), Some(out.commit.as_str()));
3918        assert_eq!(
3919            git(&f.origin, &["show", &format!("refs/heads/{TARGET}:x.txt")]).unwrap(),
3920            "made by coder"
3921        );
3922        assert_eq!(
3923            git(
3924                &f.origin,
3925                &[
3926                    "log",
3927                    "-1",
3928                    "--format=%an <%ae>",
3929                    &format!("refs/heads/{TARGET}")
3930                ]
3931            )
3932            .unwrap()
3933            .trim(),
3934            "car-coder <coder@parslee.ai>"
3935        );
3936        // Preflight ran before anything else, and CI was read for the exact
3937        // commit after the pull request existed.
3938        assert_eq!(gh.calls()[0], "auth_status");
3939        assert_eq!(gh.calls().last(), Some(&format!("ci 101 {}", out.commit)));
3940    }
3941
3942    #[test]
3943    fn a_red_delivery_names_failed_checks_at_the_delivered_head() {
3944        let f = fixture();
3945        let c = contract();
3946        let wt = f.cut("red", "main");
3947        std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
3948        let gh = FakeGh::with_checks(vec![
3949            ("lint", CiState::Green),
3950            ("windows", CiState::Red),
3951            ("test", CiState::Pending),
3952        ]);
3953
3954        let out =
3955            deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "red delivery"), &gh).unwrap();
3956
3957        assert_eq!(out.ci.state, CiState::Red);
3958        assert!(out.ci.checks.contains(&CiCheck {
3959            name: "windows".into(),
3960            state: CiState::Red
3961        }));
3962        assert!(out.ci.checks.contains(&CiCheck {
3963            name: "test".into(),
3964            state: CiState::Pending
3965        }));
3966        assert_eq!(
3967            out.delivery_report(),
3968            format!("delivered red on windows at {}", out.commit)
3969        );
3970    }
3971
3972    #[test]
3973    fn a_second_delivery_appends_to_the_same_branch_and_the_same_pr() {
3974        let f = fixture();
3975        let c = contract();
3976
3977        let wt1 = f.cut("s1", "main");
3978        std::fs::write(wt1.join("x.txt"), "round one").unwrap();
3979        let gh1 = FakeGh::ok();
3980        let first =
3981            deliver_pr_with(delivery(&f, &wt1, &c, TARGET, true, "round 1 body"), &gh1).unwrap();
3982
3983        // Round 2 cuts from the branch's current head, as the contract requires.
3984        git(&f.repo, &["fetch", "-q", "origin"]).unwrap();
3985        let wt2 = f.cut("s2", &format!("origin/{TARGET}"));
3986        std::fs::write(wt2.join("y.txt"), "round two").unwrap();
3987
3988        // GitHub already has the open PR from round 1.
3989        let gh2 = FakeGh::with_prs(vec![PrRecord {
3990            number: 101,
3991            state: PrState::Open,
3992            url: "https://github.com/acme/repo/pull/101".into(),
3993            is_draft: true,
3994            base: "main".into(),
3995        }]);
3996        let second =
3997            deliver_pr_with(delivery(&f, &wt2, &c, TARGET, true, "round 2 body"), &gh2).unwrap();
3998
3999        assert_eq!(second.pr_action, PrAction::Updated);
4000        assert_eq!(second.pr_number, 101);
4001        assert_ne!(second.commit, first.commit);
4002        assert!(
4003            !gh2.calls().iter().any(|c| c.starts_with("create")),
4004            "a second PR must never be created for the same branch: {:?}",
4005            gh2.calls()
4006        );
4007        assert!(gh2.calls().iter().any(|c| c == "set_body 101 round 2 body"));
4008
4009        // One linear history: seed -> round 1 -> round 2, no merges.
4010        assert_eq!(
4011            git(
4012                &f.origin,
4013                &["rev-list", "--count", &format!("refs/heads/{TARGET}")]
4014            )
4015            .unwrap()
4016            .trim(),
4017            "3"
4018        );
4019        assert_eq!(
4020            git(
4021                &f.origin,
4022                &[
4023                    "rev-list",
4024                    "--count",
4025                    "--merges",
4026                    &format!("refs/heads/{TARGET}")
4027                ]
4028            )
4029            .unwrap()
4030            .trim(),
4031            "0"
4032        );
4033        // Round 1's commit is still an ancestor — nothing was replaced.
4034        assert!(git(
4035            &f.origin,
4036            &["merge-base", "--is-ancestor", &first.commit, &second.commit]
4037        )
4038        .is_ok());
4039        // And exactly two branches exist on the remote: main and the target.
4040        let mut branches: Vec<String> = git(
4041            &f.origin,
4042            &["for-each-ref", "--format=%(refname:short)", "refs/heads/"],
4043        )
4044        .unwrap()
4045        .lines()
4046        .map(|l| l.to_string())
4047        .collect();
4048        branches.sort();
4049        assert_eq!(branches, vec![TARGET.to_string(), "main".to_string()]);
4050    }
4051
4052    #[test]
4053    fn a_non_fast_forward_is_retriable_and_leaves_the_remote_alone() {
4054        let f = fixture();
4055        let c = contract();
4056
4057        let wt1 = f.cut("s1", "main");
4058        std::fs::write(wt1.join("x.txt"), "round one").unwrap();
4059        let first =
4060            deliver_pr_with(delivery(&f, &wt1, &c, TARGET, true, "b1"), &FakeGh::ok()).unwrap();
4061
4062        // A session that was cut from the OLD base — its commit is not a
4063        // descendant of what the branch now points at.
4064        let wt2 = f.cut("stale", "main");
4065        std::fs::write(wt2.join("z.txt"), "stale round").unwrap();
4066        let gh = FakeGh::with_prs(vec![PrRecord {
4067            number: 101,
4068            state: PrState::Open,
4069            url: "https://github.com/acme/repo/pull/101".into(),
4070            is_draft: true,
4071            base: "main".into(),
4072        }]);
4073        let err = deliver_pr_with(delivery(&f, &wt2, &c, TARGET, true, "b2"), &gh).unwrap_err();
4074
4075        assert_eq!(err.stage(), "push");
4076        assert!(err.retriable(), "{err}");
4077        assert!(
4078            matches!(
4079                err,
4080                DeliveryFailure::Push {
4081                    retriable: true,
4082                    ..
4083                }
4084            ),
4085            "{err:?}"
4086        );
4087        assert!(
4088            err.reason().contains("non-fast-forward"),
4089            "the reason must name the condition: {}",
4090            err.reason()
4091        );
4092        // The remote still points at round 1.
4093        assert_eq!(
4094            f.origin_head(TARGET).as_deref(),
4095            Some(first.commit.as_str())
4096        );
4097        // And no PR work was attempted after the push failed.
4098        assert!(
4099            !gh.calls().iter().any(|c| c.starts_with("create")),
4100            "{:?}",
4101            gh.calls()
4102        );
4103    }
4104
4105    #[test]
4106    fn a_missing_credential_fails_preflight_and_touches_nothing() {
4107        let f = fixture();
4108        let c = contract();
4109        let wt = f.cut("s1", "main");
4110        std::fs::write(wt.join("x.txt"), "never delivered").unwrap();
4111
4112        let gh = FakeGh::no_credential();
4113        let err = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap_err();
4114
4115        assert_eq!(err.stage(), "preflight");
4116        assert!(!err.retriable(), "a missing credential is not retriable");
4117        assert!(
4118            err.reason().contains("GH_TOKEN") || err.reason().contains("gh auth"),
4119            "the failure must name the missing credential: {}",
4120            err.reason()
4121        );
4122        // Nothing committed, nothing pushed.
4123        assert!(
4124            f.origin_head(TARGET).is_none(),
4125            "the remote gained a branch"
4126        );
4127        assert!(
4128            !git(&wt, &["status", "--porcelain"])
4129                .unwrap()
4130                .trim()
4131                .is_empty(),
4132            "the worktree was committed despite the preflight failure"
4133        );
4134        assert_eq!(gh.calls(), vec!["auth_status".to_string()]);
4135    }
4136
4137    /// car#1055: a closed pull request into this run's base is somebody's
4138    /// decision that this branch should stop. It used to be reopened; now it
4139    /// parks the round at PREFLIGHT, before the commit and before the push,
4140    /// because parking at reconciliation would leave the round's commits on the
4141    /// branch with no pull request describing them.
4142    #[test]
4143    fn a_closed_unmerged_pull_request_parks_the_round() {
4144        let f = fixture();
4145        let c = contract();
4146        let wt = f.cut("s1", "main");
4147        std::fs::write(wt.join("x.txt"), "again").unwrap();
4148
4149        let gh = FakeGh::with_prs(vec![PrRecord {
4150            number: 55,
4151            state: PrState::ClosedUnmerged,
4152            url: "https://github.com/acme/repo/pull/55".into(),
4153            is_draft: false,
4154            base: "main".into(),
4155        }]);
4156        let err = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "revived"), &gh).unwrap_err();
4157
4158        assert!(
4159            matches!(err, DeliveryFailure::Preflight { .. }),
4160            "a closed pull request is refused before the commit: {err:?}"
4161        );
4162        assert!(
4163            !err.retriable(),
4164            "retrying changes nothing — a human reopens #55 or picks another target branch"
4165        );
4166        // Actionable: it names the number and says who reopens it.
4167        assert!(
4168            err.reason().contains("#55") && err.reason().contains("Reopen"),
4169            "{}",
4170            err.reason()
4171        );
4172
4173        // The listing is all it asked GitHub for: no reopen, no create, no body
4174        // rewrite.
4175        assert_eq!(
4176            gh.calls(),
4177            vec!["auth_status".to_string(), format!("list {TARGET}")]
4178        );
4179        // And nothing local moved either.
4180        assert_eq!(f.origin_head(TARGET), None, "nothing may be pushed");
4181        assert!(
4182            !git(&wt, &["status", "--porcelain"])
4183                .unwrap()
4184                .trim()
4185                .is_empty(),
4186            "the worktree was committed despite the preflight failure"
4187        );
4188    }
4189
4190    /// The filed scenario (car#1055): a reviewer read the pull request, EDITED
4191    /// its body, and closed it. The next round must not hand `set_pr_body` this
4192    /// round's `body` and wipe those edits — which is what a reopen did, every
4193    /// round, for as long as the branch existed.
4194    #[test]
4195    fn a_reviewers_edited_body_survives_the_next_round() {
4196        let f = fixture();
4197        let c = contract();
4198        let wt = f.cut("s1", "main");
4199        std::fs::write(wt.join("x.txt"), "round two").unwrap();
4200
4201        let gh = FakeGh::with_prs(vec![PrRecord {
4202            number: 40,
4203            state: PrState::ClosedUnmerged,
4204            url: "https://github.com/acme/repo/pull/40".into(),
4205            is_draft: false,
4206            base: "main".into(),
4207        }]);
4208        let err = deliver_pr_with(
4209            delivery(&f, &wt, &c, TARGET, false, "round two's generated body"),
4210            &gh,
4211        )
4212        .unwrap_err();
4213
4214        assert_eq!(err.stage(), "preflight");
4215        assert!(
4216            !gh.calls().iter().any(|c| c.starts_with("set_body")),
4217            "the reviewer's description was rewritten: {:?}",
4218            gh.calls()
4219        );
4220        // Stronger than "no set_body": this round's generated body never
4221        // reached GitHub on ANY call, so nothing could have replaced the
4222        // reviewer's text by another route. That the pull request is also never
4223        // made live again is asserted by the exact-calls check in
4224        // `a_closed_unmerged_pull_request_parks_the_round` — there is no reopen
4225        // seam left on `GitHubApi` for a call to come from.
4226        assert!(
4227            !gh.calls()
4228                .iter()
4229                .any(|c| c.contains("round two's generated body")),
4230            "this round's body reached GitHub: {:?}",
4231            gh.calls()
4232        );
4233    }
4234
4235    /// car#1055 must not veto a pull request a human has already SUPERSEDED.
4236    /// A reviewer closes #40 ("wrong approach") and opens #55 from the same
4237    /// branch into the same base to carry the work forward. `gh` reports both.
4238    /// The close is the older decision; the open one is what this round
4239    /// reconciles, exactly as it did before the close rule existed. Parking
4240    /// here would name a number the reviewer deliberately retired and leave #55
4241    /// permanently stale.
4242    #[test]
4243    fn a_superseding_open_pull_request_beats_the_closed_one() {
4244        let f = fixture();
4245        let c = contract();
4246        let wt = f.cut("s1", "main");
4247        std::fs::write(wt.join("x.txt"), "carried forward").unwrap();
4248
4249        let gh = FakeGh::with_prs(vec![
4250            PrRecord {
4251                number: 40,
4252                state: PrState::ClosedUnmerged,
4253                url: "https://github.com/acme/repo/pull/40".into(),
4254                is_draft: false,
4255                base: "main".into(),
4256            },
4257            PrRecord {
4258                number: 55,
4259                state: PrState::Open,
4260                url: "https://github.com/acme/repo/pull/55".into(),
4261                is_draft: false,
4262                base: "main".into(),
4263            },
4264        ]);
4265
4266        let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
4267
4268        assert_eq!(out.pr_action, PrAction::Updated);
4269        assert_eq!(out.pr_number, 55, "the live pull request receives the push");
4270        assert!(out.pushed);
4271        assert!(
4272            gh.calls().iter().any(|c| c.starts_with("set_body 55")),
4273            "{:?}",
4274            gh.calls()
4275        );
4276        // #40 is somebody's closed decision and stays that way: nothing is
4277        // created to paper over it and nothing touches it.
4278        assert!(
4279            !gh.calls().iter().any(|c| c.starts_with("create")),
4280            "{:?}",
4281            gh.calls()
4282        );
4283        assert!(
4284            !gh.calls().iter().any(|c| c.contains(" 40 ")),
4285            "{:?}",
4286            gh.calls()
4287        );
4288    }
4289
4290    /// The unit-level statement of the same rule, on the half that carries it:
4291    /// the close veto is suppressed by an open pull request into THIS base, and
4292    /// only that base.
4293    #[test]
4294    fn the_close_veto_is_suppressed_only_by_an_open_pr_into_the_same_base() {
4295        let closed = PrRecord {
4296            number: 40,
4297            state: PrState::ClosedUnmerged,
4298            url: "u40".into(),
4299            is_draft: false,
4300            base: "main".into(),
4301        };
4302        let open_same = PrRecord {
4303            number: 55,
4304            state: PrState::Open,
4305            url: "u55".into(),
4306            is_draft: false,
4307            base: "main".into(),
4308        };
4309        let open_other = PrRecord {
4310            base: "release/2.1".into(),
4311            ..open_same.clone()
4312        };
4313
4314        assert!(
4315            closed_pr_refusal(std::slice::from_ref(&closed), TARGET, "main").is_some(),
4316            "a lone closed pull request still parks the round"
4317        );
4318        assert!(
4319            closed_pr_refusal(&[closed.clone(), open_same], TARGET, "main").is_none(),
4320            "the open pull request into `main` supersedes the close"
4321        );
4322        assert!(
4323            closed_pr_refusal(&[closed, open_other], TARGET, "main").is_some(),
4324            "an open pull request into ANOTHER base says nothing about this base"
4325        );
4326    }
4327
4328    #[test]
4329    fn a_merged_pr_does_not_block_a_new_one() {
4330        let f = fixture();
4331        let c = contract();
4332        let wt = f.cut("s1", "main");
4333        std::fs::write(wt.join("x.txt"), "next chapter").unwrap();
4334
4335        let gh = FakeGh::with_prs(vec![PrRecord {
4336            number: 9,
4337            state: PrState::Merged,
4338            url: "https://github.com/acme/repo/pull/9".into(),
4339            is_draft: false,
4340            base: "main".into(),
4341        }]);
4342        let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "fresh"), &gh).unwrap();
4343
4344        assert_eq!(out.pr_action, PrAction::Opened);
4345        // A merge is this branch's work landing, not a decision against it: it
4346        // neither parks the round nor suppresses the next pull request.
4347        assert!(gh.calls().iter().any(|c| c.starts_with("create")));
4348    }
4349
4350    #[test]
4351    fn an_updated_pr_never_has_its_draft_state_flipped() {
4352        let f = fixture();
4353        let c = contract();
4354        let wt = f.cut("s1", "main");
4355        std::fs::write(wt.join("x.txt"), "more work").unwrap();
4356
4357        // A car worker marked it ready in an earlier round.
4358        let gh = FakeGh::with_prs(vec![PrRecord {
4359            number: 77,
4360            state: PrState::Open,
4361            url: "https://github.com/acme/repo/pull/77".into(),
4362            is_draft: false,
4363            base: "main".into(),
4364        }]);
4365        // Delivery still asks for a draft — it must be ignored on an existing PR.
4366        let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap();
4367
4368        assert_eq!(out.pr_action, PrAction::Updated);
4369        assert!(
4370            !out.draft,
4371            "delivery must report the PR's real state, not re-draft a ready PR"
4372        );
4373    }
4374
4375    #[test]
4376    fn a_clean_worktree_redelivers_head_after_an_earlier_push_failure() {
4377        let f = fixture();
4378        let c = contract();
4379
4380        // Round 1: commit lands locally, push is impossible (no such remote).
4381        let wt = f.cut("s1", "main");
4382        std::fs::write(wt.join("x.txt"), "work").unwrap();
4383        git(
4384            &f.repo,
4385            &["remote", "set-url", "origin", "/nonexistent/nope.git"],
4386        )
4387        .unwrap();
4388        let err =
4389            deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap_err();
4390        assert_eq!(err.stage(), "push");
4391        // A remote that is not a repository is permanent: re-running spends a
4392        // whole model session to reach the identical wall.
4393        assert!(
4394            !err.retriable(),
4395            "a missing remote cannot be fixed by trying again: {err}"
4396        );
4397        let committed = git(&wt, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
4398
4399        // Round 2 reuses the kept workspace. It is now CLEAN, but the work is
4400        // real and must be delivered rather than refused.
4401        git(
4402            &f.repo,
4403            &["remote", "set-url", "origin", f.origin.to_str().unwrap()],
4404        )
4405        .unwrap();
4406        let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
4407        assert_eq!(out.commit, committed);
4408        assert_eq!(f.origin_head(TARGET).as_deref(), Some(committed.as_str()));
4409    }
4410
4411    #[test]
4412    fn a_branch_name_that_looks_like_a_flag_is_refused_at_preflight() {
4413        let f = fixture();
4414        let c = contract();
4415        let wt = f.cut("s1", "main");
4416        std::fs::write(wt.join("x.txt"), "x").unwrap();
4417
4418        for bad in [
4419            "--upload-pack=touch /tmp/pwn",
4420            "goalpool/../../etc",
4421            "has space",
4422        ] {
4423            let err =
4424                deliver_pr_with(delivery(&f, &wt, &c, bad, true, "b"), &FakeGh::ok()).unwrap_err();
4425            assert_eq!(err.stage(), "preflight", "for `{bad}`: {err}");
4426        }
4427        // A leading '+' would be a force marker in a refspec.
4428        let plus = format!("{}goalpool/x", '+');
4429        let err =
4430            deliver_pr_with(delivery(&f, &wt, &c, &plus, true, "b"), &FakeGh::ok()).unwrap_err();
4431        assert_eq!(err.stage(), "preflight", "{err}");
4432    }
4433
4434    // --- Source-level guards ---------------------------------------------
4435
4436    /// **The guarantee this delivery path sells.** A force-push on a shared
4437    /// delivery branch silently discards a round's work and rewrites commits a
4438    /// reviewer already read. The needles are assembled at runtime so this
4439    /// test's own source cannot trip it.
4440    #[test]
4441    fn no_force_push_token_appears_anywhere_in_this_file() {
4442        let dashes = "-".repeat(2);
4443        let plain = format!("{dashes}{}", "force");
4444        let lease = format!("{plain}-with-lease");
4445        // A force refspec is any string literal whose FIRST character is `+`.
4446        //
4447        // This needle used to be the literal `+refs`, which cannot see the force
4448        // refspec this file would actually produce: refspecs here are built by
4449        // interpolation ([`push_args`] formats the commit and branch in), so a
4450        // forced one is a format string beginning with a plus followed by
4451        // `{commit}:refs/heads/...` — no `+refs` substring anywhere in it. The
4452        // guard read as rigorous and could not fail on the one spelling that
4453        // matters. Anchoring on quote-then-plus catches both.
4454        //
4455        // Assembled from chars, like the needles above, so it does not appear
4456        // verbatim in this file and match its own definition.
4457        let plus_refspec: String = ['"', '+'].iter().collect();
4458        // `-f` is the commonest spelling of a force push and was not covered.
4459        // Quoted, so the bare two characters cannot match inside prose.
4460        let short_flag: String = ['"', '-', 'f', '"'].iter().collect();
4461        for needle in [
4462            plain.as_str(),
4463            lease.as_str(),
4464            plus_refspec.as_str(),
4465            short_flag.as_str(),
4466        ] {
4467            assert!(
4468                !MERGE_RS_SOURCE.contains(needle),
4469                "`{needle}` must appear nowhere on the delivery path"
4470            );
4471        }
4472
4473        // A char-literal plus reaches the same force refspec by another route —
4474        // interpolating `'+'` as the first field of a format string — which the
4475        // quote-then-plus needle cannot see.
4476        //
4477        // Scoped to PRODUCTION lines that build a string. Both narrowings are
4478        // load-bearing, and each was found by this check firing on correct code:
4479        // a bare `'+'` is how `the_push_refspec_is_append_only_and_fully_
4480        // qualified` asserts no arg starts with one, and the branch-validation
4481        // test deliberately CONSTRUCTS a `+`-prefixed name to prove preflight
4482        // rejects it. Banning the spelling everywhere would fail on the two
4483        // tests that prove the property — the failure mode this guard family
4484        // keeps rediscovering.
4485        let production = MERGE_RS_SOURCE
4486            .split_once("mod tests {")
4487            .map(|(head, _)| head)
4488            .unwrap_or(MERGE_RS_SOURCE);
4489        // Rebase, in BOTH spellings. merge.rs's delivery path must never
4490        // rebase either — the base-update policy is merge precisely so a
4491        // reviewer's already-read commits are not rewritten — and a bare
4492        // `"rebase"` needle cannot see `"--rebase"`, two dashes sitting between
4493        // the quote and the `r`.
4494        for tok in [
4495            ['"', 'r', 'e', 'b', 'a', 's', 'e', '"']
4496                .iter()
4497                .collect::<String>(),
4498            ['"', '-', '-', 'r', 'e', 'b', 'a', 's', 'e', '"']
4499                .iter()
4500                .collect::<String>(),
4501        ] {
4502            assert!(
4503                !production.contains(tok.as_str()),
4504                "delivery must never rebase: `{tok}`"
4505            );
4506        }
4507
4508        assert_eq!(
4509            force_char_offenders(MERGE_RS_SOURCE),
4510            Vec::<usize>::new(),
4511            "a char-literal plus on the delivery path is a force refspec"
4512        );
4513    }
4514
4515    /// The guard above, run against sources that DO contain the violation.
4516    ///
4517    /// This is the half that was missing, and its absence is the defect: the
4518    /// scanner used to require `format!` and the char literal on the SAME line,
4519    /// and rustfmt routinely splits `format!(` from its string — it does so in
4520    /// this very file, in `validate_branch_name`. So the one spelling a real
4521    /// force refspec would take here (bind the plus to a name, interpolate it)
4522    /// tripped no needle, and no test could have noticed, because the guard was
4523    /// only ever run against a file that does not violate it. A green
4524    /// assertion over one input says nothing about whether the check can fail.
4525    #[test]
4526    fn the_force_char_scanner_catches_the_spellings_that_slipped_past_it() {
4527        // The exact bypass: rustfmt splits the macro from its string, and the
4528        // plus is bound to a name on a line with no `format!` on it.
4529        let wrapped = "fn f() {\n    let plus = '+';\n    let refspec = format!(\n        \
4530                       \"{plus}{commit}:refs/heads/{branch}\"\n    );\n}\n";
4531        assert!(
4532            !force_char_offenders(wrapped).is_empty(),
4533            "a plus bound to a name and interpolated is still a force refspec"
4534        );
4535        // The single-line spelling the old scanner did catch, still caught.
4536        let inline = "fn f() { let r = format!(\"{}{commit}:refs/heads/{b}\", '+'); }\n";
4537        assert!(!force_char_offenders(inline).is_empty());
4538        // And it stops at the test module, which legitimately constructs
4539        // `+`-prefixed names to prove `validate_branch_name` rejects them.
4540        let only_in_tests = "fn f() {}\nmod tests {\n    let plus = '+';\n}\n";
4541        assert!(force_char_offenders(only_in_tests).is_empty());
4542    }
4543
4544    /// Marking a pull request ready for review is a car worker's judgment in a
4545    /// later round, never the runtime's. A string literal `ready` in this file
4546    /// would be a `gh pr ready` argument.
4547    #[test]
4548    fn delivery_never_marks_a_pull_request_ready_for_review() {
4549        // Spelled in pieces so this test's own source does not contain the token.
4550        let ready_arg = String::from('"') + "read" + "y" + "\"";
4551        assert!(
4552            !MERGE_RS_SOURCE.contains(&ready_arg),
4553            "the runtime must not flip a pull request out of draft"
4554        );
4555    }
4556
4557    /// Every subprocess on this path is an argument array. A shell string would
4558    /// let a branch name carry a command.
4559    #[test]
4560    fn no_shell_invocation_appears_on_the_delivery_path() {
4561        for needle in ["Command::new(\"sh\")", "Command::new(\"bash\")"] {
4562            assert!(
4563                !MERGE_RS_SOURCE.contains(needle),
4564                "`{needle}` would reintroduce shell interpolation"
4565            );
4566        }
4567    }
4568
4569    #[test]
4570    fn delivery_has_no_pull_request_merge_invocation() {
4571        let production = MERGE_RS_SOURCE
4572            .split_once("mod tests {")
4573            .map(|(head, _)| head)
4574            .unwrap_or(MERGE_RS_SOURCE);
4575        let lines: Vec<&str> = production.lines().collect();
4576        for (index, line) in lines.iter().enumerate() {
4577            if line.contains("\"pr\"") {
4578                let end = (index + 4).min(lines.len());
4579                let window = lines[index..end].join("\n");
4580                assert!(
4581                    !window.contains("\"merge\""),
4582                    "pull-request merge invocation at production line {}",
4583                    index + 1
4584                );
4585            }
4586        }
4587    }
4588
4589    // --- Argument builders -------------------------------------------------
4590
4591    #[test]
4592    fn pr_check_read_requests_the_rollup_and_head_sha() {
4593        assert_eq!(
4594            gh_pr_checks_args(41),
4595            ["pr", "view", "41", "--json", "headRefOid,statusCheckRollup"]
4596        );
4597    }
4598
4599    #[test]
4600    fn draft_adds_the_draft_flag_and_nothing_else_does() {
4601        let with = gh_pr_create_args("h", "main", "t", "b", true);
4602        assert!(with.contains(&"--draft".to_string()), "{with:?}");
4603        let without = gh_pr_create_args("h", "main", "t", "b", false);
4604        assert!(!without.contains(&"--draft".to_string()), "{without:?}");
4605        // The body and title are passed as their own argv entries.
4606        assert!(with.windows(2).any(|w| w[0] == "--body" && w[1] == "b"));
4607        assert!(with.windows(2).any(|w| w[0] == "--title" && w[1] == "t"));
4608    }
4609
4610    #[test]
4611    fn the_push_refspec_is_append_only_and_fully_qualified() {
4612        let args = push_args("abc123", "goalpool/g_1");
4613        assert_eq!(
4614            args,
4615            vec![
4616                "push".to_string(),
4617                "origin".to_string(),
4618                "abc123:refs/heads/goalpool/g_1".to_string(),
4619            ]
4620        );
4621        let plus = '+';
4622        assert!(
4623            !args.iter().any(|a| a.starts_with(plus)),
4624            "a leading plus is git's force marker: {args:?}"
4625        );
4626
4627        // Ask git itself to validate the fully-qualified destination and reap
4628        // that fixture child explicitly. A previous measurement left the git
4629        // process attached to this test after its assertions had returned.
4630        let mut child = std::process::Command::new("git")
4631            .args(["check-ref-format", "refs/heads/goalpool/g_1"])
4632            .spawn()
4633            .expect("spawn git check-ref-format fixture");
4634        let status = child.wait().expect("reap git fixture child");
4635        assert!(status.success(), "git rejected the destination ref");
4636    }
4637
4638    /// The listing is the sole input to [`ambiguous_head_refusal`], so an
4639    /// explicit `--limit` above `gh`'s default page of 30 is part of the guard:
4640    /// a truncated page hides an open pull request into another base and the
4641    /// push it exists to prevent goes ahead silently.
4642    #[test]
4643    fn pr_list_asks_for_every_state_of_one_head_branch() {
4644        let args = gh_pr_list_args("goalpool/g_1");
4645        assert!(args
4646            .windows(2)
4647            .any(|w| w[0] == "--head" && w[1] == "goalpool/g_1"));
4648        assert!(args.windows(2).any(|w| w[0] == "--state" && w[1] == "all"));
4649        let limit: u32 = args
4650            .windows(2)
4651            .find(|w| w[0] == "--limit")
4652            .map(|w| w[1].parse().expect("--limit is a number"))
4653            .expect("an explicit --limit, or gh silently pages at 30");
4654        assert!(
4655            limit >= 100,
4656            "the head listing must not be truncated below 100: {args:?}"
4657        );
4658    }
4659
4660    // --- gh output parsing -------------------------------------------------
4661
4662    #[test]
4663    fn pr_list_json_maps_merged_apart_from_closed() {
4664        let prs = parse_pr_list(
4665            r#"[{"number":1,"state":"OPEN","url":"u1","isDraft":true,"baseRefName":"main"},
4666                {"number":2,"state":"CLOSED","url":"u2","isDraft":false,"baseRefName":"main"},
4667                {"number":3,"state":"MERGED","url":"u3","isDraft":false,"baseRefName":"main"}]"#,
4668        )
4669        .unwrap();
4670        assert_eq!(prs[0].state, PrState::Open);
4671        assert!(prs[0].is_draft);
4672        assert_eq!(prs[1].state, PrState::ClosedUnmerged);
4673        assert_eq!(prs[2].state, PrState::Merged);
4674    }
4675
4676    #[test]
4677    fn an_unknown_pr_state_is_an_error_not_a_guess() {
4678        assert!(
4679            parse_pr_list(r#"[{"number":1,"state":"WAT","url":"u","baseRefName":"main"}]"#)
4680                .is_err()
4681        );
4682        // A missing base is an error too, for the same reason: every default
4683        // would be a claim that this pull request merges into the branch this
4684        // round targets.
4685        assert!(
4686            parse_pr_list(r#"[{"number":1,"state":"OPEN","url":"u","isDraft":false}]"#).is_err(),
4687            "a pull request with no baseRefName cannot be reconciled against a base"
4688        );
4689        assert!(parse_pr_list("not json").is_err());
4690        assert!(parse_pr_list("[]").unwrap().is_empty());
4691    }
4692
4693    #[test]
4694    fn a_pr_number_is_read_off_the_created_url() {
4695        assert_eq!(
4696            pr_number_from_url("https://github.com/acme/repo/pull/4821\n").unwrap(),
4697            4821
4698        );
4699        assert!(pr_number_from_url("https://github.com/acme/repo").is_err());
4700    }
4701
4702    #[test]
4703    fn push_errors_split_into_retriable_and_not() {
4704        let (reason, retriable) = classify_push_error("! [rejected] abc -> b (non-fast-forward)");
4705        assert!(retriable);
4706        assert!(reason.contains("non-fast-forward"));
4707
4708        let (_, retriable) = classify_push_error("remote: Permission denied to car-coder.");
4709        assert!(!retriable, "a permission refusal must not be retried");
4710
4711        // git's REAL output, trailer included. "Please make sure you have the
4712        // correct access rights / and the repository exists." is the generic
4713        // die_initial_contact trailer printed on EVERY transport failure — a
4714        // DNS miss, an ssh timeout, a laptop off wifi — so matching it as a
4715        // credential refusal parked green rounds on ordinary network blips.
4716        // Reproduced against a scratch repo with an unresolvable ssh remote.
4717        let (_, retriable) = classify_push_error(
4718            "ssh: Could not resolve hostname github.com: nodename nor servname provided, \
4719             or not known\nfatal: Could not read from remote repository.\n\nPlease make \
4720             sure you have the correct access rights\nand the repository exists.",
4721        );
4722        assert!(retriable, "transport failures are worth another round");
4723    }
4724
4725    /// A commit SHA that happens to contain the digits `403` must NOT be read as
4726    /// an HTTP 403.
4727    ///
4728    /// This is not a hypothetical. Git echoes the competing SHA on a lost push
4729    /// race, a 40-char hex string contains `403` roughly 0.9% of the time, and
4730    /// git prints two of them — so on the order of 2% of push failures. A bare
4731    /// `contains("403")` turns those into `retriable: false`, which exits 3, the
4732    /// park-the-goal path. The consequence is worse than the rate suggests: a
4733    /// parked goal is never relaunched, so the round-N+1 recovery that merges
4734    /// `origin/<target>` back in never runs, and a perfectly healthy race
4735    /// becomes a dead goal needing a human.
4736    #[test]
4737    fn a_sha_containing_403_is_not_mistaken_for_a_permission_refusal() {
4738        let (reason, retriable) = classify_push_error(
4739            "! [remote rejected] goalpool/g_1 -> goalpool/g_1 (cannot lock ref \
4740             'refs/heads/goalpool/g_1': is at a4973f07ba3815b8d45b86a7e9633d9fbc5e4403 \
4741             but expected b1c2d3e4f5061728394a5b6c7d8e9f0011223344)",
4742        );
4743        assert!(
4744            retriable,
4745            "a lost push race is retriable; the digits 403 inside a SHA are not an HTTP status"
4746        );
4747        assert!(
4748            reason.contains("race") || reason.contains("moved"),
4749            "the reason must name what actually happened: {reason}"
4750        );
4751    }
4752
4753    /// Git's lost-race wording must be classified because it is recognised, not
4754    /// because it happens to fall through to the retriable default.
4755    ///
4756    /// Note `[remote rejected]` does NOT contain the substring `[rejected]` —
4757    /// the bracket sits before `remote`. Until this was added, none of the
4758    /// retriable keywords matched git's actual lost-race output, so the verdict
4759    /// was right by luck and the reason text said nothing useful.
4760    #[test]
4761    fn gits_lost_race_wording_is_recognised_rather_than_defaulted() {
4762        for message in [
4763            "! [remote rejected] main -> main (failed to update ref)",
4764            "error: cannot lock ref 'refs/heads/goalpool/g_1': is at aaa but expected bbb",
4765            "! [rejected] abc -> b (fetch first)",
4766        ] {
4767            let (reason, retriable) = classify_push_error(message);
4768            assert!(retriable, "{message}");
4769            assert!(
4770                reason.contains("moved") || reason.contains("race"),
4771                "the reason must tell an operator the branch moved, not echo git: {reason}"
4772            );
4773        }
4774    }
4775
4776    /// `403` in a BRANCH or REPOSITORY name is not an HTTP status.
4777    ///
4778    /// Token-anchoring stopped the digits inside a commit SHA matching, but a
4779    /// name is delimited by `-` `/` `_` `.`, so `feature-403` presents `403` as
4780    /// a standalone token — and git echoes the branch and the remote URL in
4781    /// every push error. Since refusals are tested before races, an ordinary
4782    /// lost race on such a branch was parked as a permission refusal with a
4783    /// reason that was factually wrong.
4784    #[test]
4785    fn a_403_in_a_branch_or_repo_name_is_not_a_permission_refusal() {
4786        for message in [
4787            "! [rejected] abc1234 -> feature-403 (non-fast-forward)",
4788            "! [rejected] abc -> goalpool/g_403 (fetch first)",
4789            "! [remote rejected] x -> release_403 (failed to update ref)",
4790        ] {
4791            let (reason, retriable) = classify_push_error(message);
4792            assert!(retriable, "must stay a retriable race: {message}");
4793            assert!(
4794                reason.contains("moved") || reason.contains("race"),
4795                "{reason}"
4796            );
4797        }
4798        // A repository name carrying 403 must not poison an unrelated failure.
4799        let (_, retriable) =
4800            classify_push_error("fatal: unable to access 'https://github.com/org/repo-403.git/'");
4801        assert!(retriable, "a repo name is not a status code");
4802    }
4803
4804    /// Permanent refusals that arrive inside `[remote rejected]` are not races.
4805    ///
4806    /// `! [remote rejected]` is git's GENERIC server-refusal line; the reason is
4807    /// in the parentheses. Classifying the whole class as a lost race returned
4808    /// exit 2 — "requeue, nothing was judged" — so each of these burned a full
4809    /// model session per round against an identical wall, forever.
4810    #[test]
4811    fn permanent_server_refusals_inside_remote_rejected_are_not_retried() {
4812        for message in [
4813            "! [remote rejected] b -> b (refusing to allow an OAuth App to create or update \
4814             workflow '.github/workflows/x.yml' without 'workflow' scope)",
4815            "! [remote rejected] b -> b (shallow update not allowed)",
4816            "remote: error: GH001: Large files detected. File exceeds GitHub's file size limit \
4817             of 100.00 MB",
4818            // Directory/file ref collision — captured from git 2.50.1 against a
4819            // scratch bare remote, not paraphrased. Emits BOTH `cannot lock
4820            // ref` and `failed to update ref`, so it reads as a lost race
4821            // unless refusals are matched first.
4822            "remote: error: cannot lock ref 'refs/heads/goalpool/g_1': \
4823             'refs/heads/goalpool' exists; cannot create 'refs/heads/goalpool/g_1'\n \
4824             ! [remote rejected] HEAD -> goalpool/g_1 (failed to update ref)",
4825        ] {
4826            let (_, retriable) = classify_push_error(message);
4827            assert!(!retriable, "permanent, must not be retried: {message}");
4828        }
4829    }
4830
4831    /// A repository-rule violation — which is what secret-scanning push
4832    /// protection emits — is permanent, and the enumerated code list stopped
4833    /// short of it.
4834    ///
4835    /// `GH013` is the code GitHub returns for a ruleset block, and push
4836    /// protection is on by default for public repositories, so this is not an
4837    /// exotic path: a test fixture that reads like a token is enough. Walking
4838    /// the old predicates against the text below, nothing in the refusal set
4839    /// matched — the list ended at `GH008:` — so it fell through to
4840    /// `remote rejected` in the race set and was reported to the orchestrator as
4841    /// "the branch moved, requeue". Every following round then burned a full
4842    /// model session to re-push the identical commit at the identical wall,
4843    /// which is the exact failure `classify_push_error` says it exists to stop.
4844    ///
4845    /// Captured shape, not paraphrased — the `remote:` lines and the trailing
4846    /// `! [remote rejected]` are what git prints for a declined push.
4847    #[test]
4848    fn a_repository_rule_violation_is_permanent_not_a_lost_race() {
4849        let (reason, retriable) = classify_push_error(
4850            "remote: error: GH013: Repository rule violations found for \
4851             refs/heads/goalpool/g_1.\nremote:\nremote: - GITHUB PUSH PROTECTION\nremote:   \
4852             —— GitHub Personal Access Token ————————————————\nremote:\n \
4853             ! [remote rejected] goalpool/g_1 -> goalpool/g_1 (push declined due to \
4854             repository rule violations)\nerror: failed to push some refs to \
4855             'https://github.com/o/r.git'",
4856        );
4857        assert!(
4858            !retriable,
4859            "a ruleset block cannot be got past by pushing the same commit again"
4860        );
4861        assert!(
4862            !reason.contains("race") && !reason.contains("moved"),
4863            "and it must not be described as a lost push race: {reason}"
4864        );
4865
4866        // The generic decline line carries the same verdict on its own — a
4867        // caller that only kept git's stderr summary still gets it right.
4868        let (_, retriable) = classify_push_error(
4869            "! [remote rejected] b -> b (push declined due to repository rule violations)",
4870        );
4871        assert!(!retriable);
4872
4873        // Codes above the old ceiling are the family, not one member.
4874        for code in ["GH009", "GH011", "GH013"] {
4875            let (_, retriable) =
4876                classify_push_error(&format!("remote: error: {code}: blocked by policy"));
4877            assert!(!retriable, "{code} must be read as a policy refusal");
4878        }
4879    }
4880
4881    /// …and the code shape must not fire on a NAME.
4882    ///
4883    /// This file has twice shipped a predicate that matched text git echoes for
4884    /// unrelated reasons — `403` inside a commit SHA, then inside a branch name.
4885    /// The colon is the anchor: `validate_branch_name` rejects `:` in a ref, so
4886    /// `GH013:` cannot arrive from a branch, a tag, or a remote name.
4887    #[test]
4888    fn a_github_code_in_a_branch_name_is_not_a_policy_refusal() {
4889        for message in [
4890            "! [rejected] abc -> fix-gh013-secret-scanning (non-fast-forward)",
4891            "! [remote rejected] x -> gh001 (failed to update ref)",
4892            "fatal: unable to access 'https://github.com/org/gh013.git/'",
4893        ] {
4894            let (_, retriable) = classify_push_error(message);
4895            assert!(retriable, "a name is not a status code: {message}");
4896        }
4897    }
4898
4899    /// A permanent `gh` failure must not be reported as a retriable blip.
4900    #[test]
4901    fn permanent_pr_reconciliation_failures_are_not_retriable() {
4902        for message in [
4903            "GraphQL: No commits between main and goalpool/g_1",
4904            "GraphQL: Draft pull requests are not supported in this repository",
4905        ] {
4906            let (_, retriable) = classify_pr_error(message);
4907            assert!(!retriable, "permanent, must not be retried: {message}");
4908        }
4909
4910        // `already exists` is RETRIABLE, and this assertion is the whole point:
4911        // the error is only ever returned when a pull request for that head
4912        // exists, while reaching `create_pr` means the listing found none — a
4913        // race, replication lag, or a head mismatch, all of which resolve on the
4914        // next round's re-list. Parking there strands a goal whose commit is
4915        // already pushed and whose pull request number is IN THE ERROR TEXT.
4916        let (_, retriable) = classify_pr_error(
4917            "a pull request for branch \"goalpool/g_1\" into branch \"main\" already exists: #7",
4918        );
4919        assert!(
4920            retriable,
4921            "the error proves a usable pull request exists; the next round adopts it"
4922        );
4923        // An unrecognised failure stays retriable: an API blip is the common
4924        // case, and wrongly parking a healthy goal is the worse error.
4925        let (_, retriable) = classify_pr_error("502 Bad Gateway");
4926        assert!(retriable);
4927    }
4928
4929    /// **The E2E defect.** A caller's paragraph break decides the commit subject.
4930    ///
4931    /// goalpool writes a short summary, a blank line, then a pointer to the goal
4932    /// brief. `subject_from_intent` flattened newlines to spaces BEFORE the
4933    /// length check, so the break was gone before it could do anything and the
4934    /// subject read `"<summary>  Read the goal brief /tmp/..."`, truncated at 72.
4935    /// Leading with a short first line was not enough, because the flattening
4936    /// happened first.
4937    ///
4938    /// Asserted on the delivered COMMIT — `%s` and `%b` — rather than on the
4939    /// helper, because the subject a reviewer sees is the thing that matters and
4940    /// the helper is only how it gets there.
4941    #[test]
4942    fn a_paragraph_break_in_the_intent_ends_the_commit_subject() {
4943        let f = fixture();
4944        let c = contract();
4945        let wt = f.cut("subject-check", "main");
4946        std::fs::write(wt.join("greet.js"), "console.log('hi');\n").unwrap();
4947
4948        let summary = "Goal: implement greet() in the scratch repo";
4949        let body_text = "Read the goal brief /tmp/car-e2e-2/gp-home/logs/brief.md and \
4950                         follow it exactly. This pointer text must not reach the subject.";
4951        let intent = format!("{summary}\n\n{body_text}");
4952
4953        let out = deliver_pr_with(
4954            PrDelivery {
4955                repo: &f.repo,
4956                worktree: &wt,
4957                target_branch: "goalpool/g_subject",
4958                base_branch: "main",
4959                draft: true,
4960                intent: &intent,
4961                contract: &c,
4962                body: "b",
4963                provenance: None,
4964            },
4965            &FakeGh::ok(),
4966        )
4967        .expect("delivery succeeds");
4968
4969        let subject = git(&f.repo, &["log", "-1", "--format=%s", &out.commit]).unwrap();
4970        assert_eq!(
4971            subject.trim(),
4972            summary,
4973            "the subject must be exactly the first paragraph"
4974        );
4975        assert!(
4976            !subject.contains("goal brief"),
4977            "the pointer text must not ride along: {subject}"
4978        );
4979
4980        // Nothing is lost — the rest is in the body, where git expects it.
4981        let body = git(&f.repo, &["log", "-1", "--format=%b", &out.commit]).unwrap();
4982        assert!(
4983            body.contains("goal brief"),
4984            "the remainder belongs in the body: {body}"
4985        );
4986    }
4987
4988    /// An intent with no blank line still flattens and truncates at 72, exactly
4989    /// as before — a single-paragraph caller sees no change.
4990    #[test]
4991    fn a_single_paragraph_intent_still_truncates_as_before() {
4992        let short = "Add a --verbose flag to the CLI";
4993        assert_eq!(subject_from_intent(short), short);
4994
4995        let long = "Add a --verbose flag to the export subcommand and thread it through \
4996                    every downstream call site so the whole pipeline reports progress";
4997        let subject = subject_from_intent(long);
4998        assert!(subject.ends_with("..."), "{subject}");
4999        assert!(subject.len() <= 72, "len {}: {subject}", subject.len());
5000        assert!(!subject.contains('\n'));
5001
5002        // A multi-line first paragraph is still one line in the subject.
5003        assert_eq!(
5004            subject_from_intent("wrapped over\ntwo lines\n\nbody here"),
5005            "wrapped over two lines"
5006        );
5007    }
5008
5009    /// **The E2E defect.** Runtime bookkeeping must not reach the delivered tree.
5010    ///
5011    /// The ownership marker used to be written into the WORKING TREE, and
5012    /// `commit_worktree` stages with `git add -A`, so every pull request this
5013    /// command opened carried a `.car-code-task` file in its diff for a human to
5014    /// review. It now lives in the worktree's gitdir, which `git add` cannot
5015    /// reach — so this asserts against the pushed tree rather than against the
5016    /// mechanism, and would catch any future bookkeeping file the same way.
5017    #[test]
5018    fn no_runtime_bookkeeping_reaches_the_delivered_tree() {
5019        let f = fixture();
5020        let c = contract();
5021        let wt = f.cut("marker-check", "main");
5022
5023        // Whatever the CLI writes to claim a workspace lives here, alongside
5024        // anything else git keeps per-worktree.
5025        let gitdir = std::fs::read_to_string(wt.join(".git"))
5026            .ok()
5027            .and_then(|m| {
5028                m.trim()
5029                    .strip_prefix("gitdir:")
5030                    .map(|g| g.trim().to_string())
5031            })
5032            .map(PathBuf::from)
5033            .expect("a worktree's .git is a file holding a gitdir pointer");
5034        std::fs::write(gitdir.join("car-code-task"), "claimed\n").unwrap();
5035
5036        std::fs::write(wt.join("greet.js"), "console.log('hi');\n").unwrap();
5037        let out = deliver_pr_with(
5038            delivery(&f, &wt, &c, "goalpool/g_marker", true, "b"),
5039            &FakeGh::ok(),
5040        )
5041        .expect("delivery succeeds");
5042
5043        let tree = git(&f.origin, &["ls-tree", "-r", "--name-only", &out.commit]).unwrap();
5044        assert!(
5045            tree.contains("greet.js"),
5046            "the actual work must be there: {tree}"
5047        );
5048        for bookkeeping in ["car-code-task", ".car-code-task"] {
5049            assert!(
5050                !tree.contains(bookkeeping),
5051                "`{bookkeeping}` is runtime bookkeeping and must not reach a reviewed diff: {tree}"
5052            );
5053        }
5054    }
5055
5056    /// A clean worktree whose HEAD is still the base has nothing to deliver.
5057    ///
5058    /// The fallback exists for a re-delivery — a green round whose PUSH failed,
5059    /// re-pushing its existing commit. It could not tell that from a FIRST round
5060    /// that was green with an empty diff, where HEAD is the base tip: the push
5061    /// then succeeded, created a stray remote branch pointing at the base, and
5062    /// `gh pr create` failed with "No commits between …" — retried forever, with
5063    /// junk branches accumulating on the remote.
5064    #[test]
5065    fn a_clean_worktree_at_the_base_is_refused_rather_than_pushed_empty() {
5066        let f = fixture();
5067        let c = contract();
5068        // Cut from main and change NOTHING: HEAD is still the base tip, which is
5069        // exactly the first-round-green-with-an-empty-diff case.
5070        let wt = f.cut("empty-round", "main");
5071        let base_tip = git(&wt, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
5072
5073        let err = deliver_pr_with(
5074            delivery(&f, &wt, &c, "goalpool/g_empty", true, "b"),
5075            &FakeGh::ok(),
5076        )
5077        .expect_err("an empty round must not open a pull request");
5078        assert_eq!(err.stage(), "commit", "{err}");
5079        assert!(
5080            err.reason().contains("nothing to deliver"),
5081            "{}",
5082            err.reason()
5083        );
5084        assert!(err.reason().contains(&base_tip), "{}", err.reason());
5085        assert!(!err.retriable(), "an empty round will be empty again");
5086
5087        // Nothing reached the remote: no stray branch pointing at the base.
5088        assert!(
5089            git(
5090                &f.origin,
5091                &[
5092                    "rev-parse",
5093                    "--verify",
5094                    "--quiet",
5095                    "refs/heads/goalpool/g_empty"
5096                ]
5097            )
5098            .is_err(),
5099            "no stray branch may be created for an empty round"
5100        );
5101    }
5102
5103    /// A refusal wrapped in rejection wording must read as a refusal.
5104    ///
5105    /// Branch protection arrives as `! [remote rejected] … (pre-receive hook
5106    /// declined)`, which matches the lost-race patterns. Adding those patterns
5107    /// without ordering refusals first would have turned every protected-branch
5108    /// rejection into an infinite retry — a fresh bug introduced by the fix.
5109    #[test]
5110    fn a_policy_refusal_wrapped_in_rejection_wording_is_not_a_race() {
5111        let (_, retriable) =
5112            classify_push_error("! [remote rejected] main -> main (pre-receive hook declined)");
5113        assert!(!retriable, "branch protection is not worth retrying");
5114    }
5115
5116    /// The permission signals still fire on the real thing.
5117    #[test]
5118    fn a_genuine_403_is_still_a_non_retriable_refusal() {
5119        for message in [
5120            "fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 403",
5121            "remote: Permission to o/r.git denied to car-coder.",
5122            "fatal: Authentication failed for 'https://github.com/o/r/'",
5123            "fatal: could not read Username for 'https://github.com'",
5124        ] {
5125            let (_, retriable) = classify_push_error(message);
5126            assert!(!retriable, "must not be retried: {message}");
5127        }
5128    }
5129
5130    /// **The blocker.** `main` passed `validate_branch_name` as both the target
5131    /// and the base, and nothing compared them — so the refspec became
5132    /// `<sha>:refs/heads/main` and unreviewed model output was published to the
5133    /// base branch. Reconciliation only failed afterwards, by which time an
5134    /// append-only path has no way to take it back.
5135    #[test]
5136    fn delivering_onto_the_base_branch_is_refused_before_anything_is_pushed() {
5137        let f = fixture();
5138        let c = contract();
5139        let wt = f.cut("s1", "main");
5140        std::fs::write(wt.join("x.txt"), "unreviewed").unwrap();
5141
5142        let gh = FakeGh::ok();
5143        let err = deliver_pr_with(delivery(&f, &wt, &c, "main", true, "b"), &gh).unwrap_err();
5144
5145        assert_eq!(err.stage(), "preflight", "{err}");
5146        assert!(!err.retriable());
5147        assert!(err.reason().contains("base"), "{err}");
5148        // Nothing was committed, nothing was pushed, and `gh` was never even
5149        // asked for a credential — the refusal costs no session.
5150        assert!(
5151            git(&wt, &["log", "-1", "--format=%an"])
5152                .unwrap()
5153                .trim()
5154                .ne("car-coder"),
5155            "the worktree must not have been committed"
5156        );
5157        assert!(gh.calls().is_empty(), "{:?}", gh.calls());
5158    }
5159
5160    /// **The stringly sentinel.** A commit that fails for an ordinary reason
5161    /// must never be mistaken for a clean worktree just because the caller's
5162    /// intent happens to contain the phrase the clean-worktree check used to
5163    /// report.
5164    #[test]
5165    fn a_commit_failure_is_not_mistaken_for_a_clean_worktree() {
5166        let f = fixture();
5167        let c = contract();
5168
5169        // Round 1 delivers for real, so HEAD is ahead of the base and the
5170        // re-delivery branch would have something to push.
5171        let wt = f.cut("s1", "main");
5172        std::fs::write(wt.join("x.txt"), "round one").unwrap();
5173        let first =
5174            deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
5175
5176        // Round 2 has real work — and a commit that cannot succeed. Signing with
5177        // a program that does not exist is the portable stand-in for the real
5178        // causes (a gpg agent that is not running, a shared `pre-commit` hook).
5179        std::fs::write(
5180            wt.join("y.txt"),
5181            "round two — the work that must not be lost",
5182        )
5183        .unwrap();
5184        git(&f.repo, &["config", "commit.gpgsign", "true"]).unwrap();
5185        git(&f.repo, &["config", "gpg.program", "/nonexistent/gpg"]).unwrap();
5186
5187        let intent = "fix delivery so it reports 'no changes to deliver' correctly";
5188        let d = PrDelivery {
5189            intent,
5190            ..delivery(&f, &wt, &c, TARGET, true, "b")
5191        };
5192        let err = deliver_pr_with(d, &FakeGh::ok()).unwrap_err();
5193
5194        assert_eq!(err.stage(), "commit", "{err}");
5195        // The decisive assertion: round 1's commit must NOT have been re-pushed
5196        // and reported as this round's delivery.
5197        assert_eq!(
5198            f.origin_head(TARGET).as_deref(),
5199            Some(first.commit.as_str()),
5200            "the stale commit must not be re-delivered as if it were round 2"
5201        );
5202    }
5203
5204    /// A fork's pull request shares only the head ref NAME. Adopting it would
5205    /// overwrite a stranger's description, or reopen their closed pull request,
5206    /// while our own branch still had none.
5207    #[test]
5208    fn a_cross_repository_pull_request_is_not_adopted() {
5209        let raw = r#"[
5210            {"number":200,"state":"OPEN","url":"https://github.com/acme/repo/pull/200",
5211             "isDraft":false,"isCrossRepository":true,"baseRefName":"main"},
5212            {"number":7,"state":"OPEN","url":"https://github.com/acme/repo/pull/7",
5213             "isDraft":false,"isCrossRepository":false,"baseRefName":"main"}
5214        ]"#;
5215        let prs = parse_pr_list(raw).unwrap();
5216        assert_eq!(
5217            prs.iter().map(|p| p.number).collect::<Vec<_>>(),
5218            vec![7],
5219            "only the same-repository pull request may be reconciled"
5220        );
5221        // The field is actually requested, or the filter has nothing to read.
5222        assert!(gh_pr_list_args("b")
5223            .iter()
5224            .any(|a| a == "number,state,url,isDraft,isCrossRepository,baseRefName"));
5225    }
5226
5227    /// `gh` must be pointed at the repository the push went to. Its own
5228    /// resolution prefers `upstream`, which on a fork clone is a different
5229    /// repository entirely.
5230    #[test]
5231    fn the_github_repository_is_taken_from_the_same_remote_the_push_uses() {
5232        for (url, expect) in [
5233            ("https://github.com/acme/repo.git", Some("acme/repo")),
5234            ("https://github.com/acme/repo", Some("acme/repo")),
5235            ("git@github.com:acme/repo.git", Some("acme/repo")),
5236            ("ssh://git@github.com/acme/repo.git", Some("acme/repo")),
5237            (
5238                "https://x-token@github.com/acme/repo.git",
5239                Some("acme/repo"),
5240            ),
5241            // A GitHub Enterprise host must keep its host, or `--repo` would
5242            // aim at github.com.
5243            (
5244                "git@github.example.com:acme/repo.git",
5245                Some("github.example.com/acme/repo"),
5246            ),
5247            // Not a remote URL naming one repository: say nothing and leave
5248            // `gh`'s own resolution alone.
5249            ("/srv/mirrors/repo.git", None),
5250            ("../sibling", None),
5251        ] {
5252            assert_eq!(
5253                parse_github_repo_spec(url).as_deref(),
5254                expect,
5255                "for `{url}`"
5256            );
5257        }
5258
5259        // And it reaches the argv: the fixture's origin is a local path, so
5260        // nothing is added there, while a GitHub origin adds `--repo`.
5261        let f = fixture();
5262        assert!(gh_repo_args(&f.repo).is_empty());
5263        git(
5264            &f.repo,
5265            &[
5266                "remote",
5267                "set-url",
5268                "origin",
5269                "git@github.com:acme/repo.git",
5270            ],
5271        )
5272        .unwrap();
5273        assert_eq!(
5274            gh_repo_args(&f.repo),
5275            vec!["--repo".to_string(), "acme/repo".to_string()]
5276        );
5277    }
5278
5279    /// The two permanent push failures the refusal set had no spelling for.
5280    /// Both landed on the retriable default, so the orchestrator requeued a full
5281    /// model session against a wall that can never move.
5282    #[test]
5283    fn a_404_or_401_push_failure_is_permanent_not_a_race() {
5284        for message in [
5285            "remote: Repository not found.\nfatal: repository \
5286             'https://github.com/o/private.git/' not found",
5287            "ERROR: Repository not found.\nfatal: Could not read from remote repository.",
5288            "fatal: unable to access 'https://github.com/o/r/': The requested URL returned \
5289             error: 401",
5290            "fatal: 'origin' does not appear to be a git repository",
5291        ] {
5292            let (_, retriable) = classify_push_error(message);
5293            assert!(!retriable, "must not be retried forever: {message}");
5294        }
5295        // And the loosening test: an ordinary lost race is still retriable.
5296        let (_, retriable) =
5297            classify_push_error("! [rejected] goalpool/g_404 -> goalpool/g_404 (non-fast-forward)");
5298        assert!(retriable, "a moved branch is still worth another round");
5299    }
5300
5301    /// A repository-selecting environment variable overrides `-C`, so every
5302    /// path-based guard upstream can say yes while git acts somewhere else.
5303    /// Asserted against the source text: the point is that the clearing is
5304    /// WRITTEN, not that some execution path happened to cover it.
5305    #[test]
5306    fn git_does_not_inherit_the_repository_from_the_environment() {
5307        let production = MERGE_RS_SOURCE
5308            .split_once("mod tests {")
5309            .map(|(head, _)| head)
5310            .unwrap_or(MERGE_RS_SOURCE);
5311        for var in ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"] {
5312            assert!(
5313                production.contains(&format!(".env_remove(\"{var}\")")),
5314                "`git()` must clear {var}, which otherwise overrides `-C`"
5315            );
5316        }
5317    }
5318
5319    /// `status --porcelain` honours `status.showUntrackedFiles`, and a
5320    /// repository or global `no` makes a worktree holding nothing but NEW files
5321    /// look clean — so delivery took the re-delivery branch and pushed a stale
5322    /// commit while the round's whole output stayed behind.
5323    #[test]
5324    fn a_worktree_holding_only_untracked_work_is_not_read_as_clean() {
5325        let f = fixture();
5326        let c = contract();
5327        git(&f.repo, &["config", "status.showUntrackedFiles", "no"]).unwrap();
5328        let wt = f.cut("s1", "main");
5329        std::fs::write(wt.join("brand-new.txt"), "a day of work").unwrap();
5330
5331        let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
5332
5333        assert_eq!(
5334            git(
5335                &f.origin,
5336                &["show", &format!("refs/heads/{TARGET}:brand-new.txt")]
5337            )
5338            .unwrap(),
5339            "a day of work",
5340            "the untracked work must be in the delivered commit"
5341        );
5342        assert_eq!(out.pr_action, PrAction::Opened);
5343    }
5344
5345    // --- Round-5 findings --------------------------------------------------
5346
5347    /// The pull-request body is MODEL output, and it must never be able to
5348    /// decide whether a failure is permanent.
5349    ///
5350    /// Reproduces the reported chain exactly: a goal whose intent names one of
5351    /// the permanent phrases produces a body containing it, the push succeeds,
5352    /// and `gh pr edit --body …` then hits an ordinary 502. Before the split in
5353    /// [`GhError`] the classifier read the whole argv, matched the phrase inside
5354    /// the echoed body, and returned `retriable: false` — exit 3, a goal parked
5355    /// forever with its commit already safely on the remote.
5356    #[test]
5357    fn a_permanent_phrase_in_the_body_cannot_make_a_transient_failure_permanent() {
5358        let f = fixture();
5359        let c = contract();
5360
5361        // Round 1 opens the pull request.
5362        let wt1 = f.cut("s1", "main");
5363        std::fs::write(wt1.join("x.txt"), "one").unwrap();
5364        deliver_pr_with(delivery(&f, &wt1, &c, TARGET, false, "one"), &FakeGh::ok()).unwrap();
5365
5366        // Round 2 updates it, and `gh pr edit` fails transiently.
5367        git(&f.repo, &["fetch", "-q", "origin"]).unwrap();
5368        let wt2 = f.cut("s2", &format!("origin/{TARGET}"));
5369        std::fs::write(wt2.join("y.txt"), "two").unwrap();
5370
5371        let body = "This round fixes the 'no commits between' error on empty deliveries.";
5372        let gh = FakeGh::failing_set_body("HTTP 502: Bad Gateway (https://api.github.com/…)");
5373        *gh.prs.lock().unwrap() = vec![PrRecord {
5374            number: 101,
5375            state: PrState::Open,
5376            url: "https://github.com/acme/repo/pull/101".into(),
5377            is_draft: false,
5378            base: "main".into(),
5379        }];
5380
5381        let err = deliver_pr_with(delivery(&f, &wt2, &c, TARGET, false, body), &gh).unwrap_err();
5382        assert!(
5383            err.retriable(),
5384            "a 502 is retriable however the body is worded: {err:?}"
5385        );
5386
5387        // And the classifier still calls a REAL permanent failure permanent,
5388        // so the fix is not "everything is retriable now".
5389        let gh2 = FakeGh::failing_set_body("GraphQL: No commits between main and goalpool/g_1");
5390        *gh2.prs.lock().unwrap() = vec![PrRecord {
5391            number: 101,
5392            state: PrState::Open,
5393            url: "https://github.com/acme/repo/pull/101".into(),
5394            is_draft: false,
5395            base: "main".into(),
5396        }];
5397        let wt3 = f.cut("s3", &format!("origin/{TARGET}"));
5398        std::fs::write(wt3.join("z.txt"), "three").unwrap();
5399        let err2 =
5400            deliver_pr_with(delivery(&f, &wt3, &c, TARGET, false, "plain"), &gh2).unwrap_err();
5401        assert!(!err2.retriable(), "{err2:?}");
5402    }
5403
5404    /// GitHub's one-open-pull-request rule is per (head, base) PAIR, so two
5405    /// open pull requests from one head into different bases are legal — but a
5406    /// push cannot be aimed at only one of them. The head is ambiguous, and
5407    /// delivery refuses before it commits anything rather than reconciling one
5408    /// pull request while quietly appending to the other.
5409    ///
5410    /// This fixture used to assert the opposite — that reconciliation picked
5411    /// the same-base pull request and left the other alone — which is the
5412    /// behavior car#1054 overturned. Reconciliation's base filter is still
5413    /// there and still load-bearing for the closed and merged cases; its
5414    /// coverage now lives in
5415    /// [`a_changed_base_opens_its_own_pull_request_once_the_old_one_is_closed`].
5416    #[test]
5417    fn an_open_pull_request_into_another_base_is_refused_rather_than_reconciled() {
5418        let f = fixture();
5419        let c = contract();
5420        let wt = f.cut("s1", "main");
5421        std::fs::write(wt.join("x.txt"), "work").unwrap();
5422
5423        // #10 into `main` — ours. #12 into `release/2.1` — somebody else's,
5424        // which the push would have landed on all the same.
5425        let gh = FakeGh::with_prs(vec![
5426            PrRecord {
5427                number: 10,
5428                state: PrState::Open,
5429                url: "https://github.com/acme/repo/pull/10".into(),
5430                is_draft: false,
5431                base: "main".into(),
5432            },
5433            PrRecord {
5434                number: 12,
5435                state: PrState::Open,
5436                url: "https://github.com/acme/repo/pull/12".into(),
5437                is_draft: false,
5438                base: "release/2.1".into(),
5439            },
5440        ]);
5441
5442        let err =
5443            deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap_err();
5444
5445        assert!(
5446            matches!(err, DeliveryFailure::Preflight { .. }),
5447            "an ambiguous head is refused before the commit: {err:?}"
5448        );
5449        assert!(
5450            !err.retriable(),
5451            "retrying changes nothing — a human closes #12 or picks another target branch"
5452        );
5453        // Actionable: it names the offending pull request and its base.
5454        assert!(
5455            err.reason().contains("#12") && err.reason().contains("release/2.1"),
5456            "{}",
5457            err.reason()
5458        );
5459
5460        // Nothing was written anywhere: no branch on the remote, and no pull
5461        // request touched — including the one into our own base.
5462        assert_eq!(f.origin_head(TARGET), None, "nothing may be pushed");
5463        assert!(
5464            !gh.calls()
5465                .iter()
5466                .any(|c| c.starts_with("set_body") || c.starts_with("create")),
5467            "{:?}",
5468            gh.calls()
5469        );
5470    }
5471
5472    /// The issue's scenario verbatim: a human's pull request into `release/2.1`
5473    /// is open on the delivery branch and the round runs `--pr-base main`. The
5474    /// PUSH is what lands the model's commits on the human's pull request, so
5475    /// the assertion that matters is that the remote branch never moves.
5476    #[test]
5477    fn a_human_pull_request_into_another_base_parks_delivery_before_the_push() {
5478        let f = fixture();
5479        let c = contract();
5480        let wt = f.cut("s1", "main");
5481        std::fs::write(wt.join("x.txt"), "unreviewed model output").unwrap();
5482
5483        let gh = FakeGh::with_prs(vec![PrRecord {
5484            number: 200,
5485            state: PrState::Open,
5486            url: "https://github.com/acme/repo/pull/200".into(),
5487            is_draft: false,
5488            base: "release/2.1".into(),
5489        }]);
5490
5491        let err =
5492            deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap_err();
5493
5494        assert_eq!(err.stage(), "preflight");
5495        assert!(!err.retriable(), "{err:?}");
5496        assert!(err.reason().contains("#200"), "{}", err.reason());
5497        assert_eq!(
5498            f.origin_head(TARGET),
5499            None,
5500            "the model's commits must never reach a branch #200 tracks"
5501        );
5502        assert!(
5503            !gh.calls().iter().any(|c| c.starts_with("create")),
5504            "no second pull request is opened to paper over the refusal: {:?}",
5505            gh.calls()
5506        );
5507    }
5508
5509    /// A changed `--pr-base` no longer opens a second pull request while the
5510    /// first is open: the push would land on both.
5511    #[test]
5512    fn a_changed_base_is_refused_while_the_old_pull_request_is_open() {
5513        let f = fixture();
5514        let c = contract();
5515        let wt = f.cut("s1", "main");
5516        std::fs::write(wt.join("x.txt"), "work").unwrap();
5517
5518        let gh = FakeGh::with_prs(vec![PrRecord {
5519            number: 10,
5520            state: PrState::Open,
5521            url: "https://github.com/acme/repo/pull/10".into(),
5522            is_draft: false,
5523            base: "main".into(),
5524        }]);
5525        let mut d = delivery(&f, &wt, &c, TARGET, false, "body");
5526        d.base_branch = "release/2.1";
5527
5528        let err = deliver_pr_with(d, &gh).unwrap_err();
5529        assert_eq!(err.stage(), "preflight");
5530        assert!(err.reason().contains("#10"), "{}", err.reason());
5531        assert_eq!(f.origin_head(TARGET), None);
5532    }
5533
5534    /// …and once that pull request is closed the same run proceeds and opens
5535    /// its own, which is what keeps the base filter and the create path live.
5536    #[test]
5537    fn a_changed_base_opens_its_own_pull_request_once_the_old_one_is_closed() {
5538        let f = fixture();
5539        let c = contract();
5540        let wt = f.cut("s1", "main");
5541        std::fs::write(wt.join("x.txt"), "work").unwrap();
5542
5543        let gh = FakeGh::with_prs(vec![PrRecord {
5544            number: 10,
5545            state: PrState::ClosedUnmerged,
5546            url: "https://github.com/acme/repo/pull/10".into(),
5547            is_draft: false,
5548            base: "main".into(),
5549        }]);
5550        let mut d = delivery(&f, &wt, &c, TARGET, false, "body");
5551        d.base_branch = "release/2.1";
5552
5553        let out = deliver_pr_with(d, &gh).unwrap();
5554        assert_eq!(out.pr_action, PrAction::Opened);
5555        assert_ne!(out.pr_number, 10);
5556        assert!(
5557            gh.calls()
5558                .iter()
5559                .any(|c| c.contains("create head=") && c.contains("base=release/2.1")),
5560            "{:?}",
5561            gh.calls()
5562        );
5563        // #10 is closed but into ANOTHER base, so it neither parks this run
5564        // nor gets touched: [`closed_pr_refusal`] is base-scoped, or any stale
5565        // pull request would hold a veto over a base it does not merge into.
5566        assert!(
5567            !gh.calls().iter().any(|c| c.starts_with("set_body")),
5568            "{:?}",
5569            gh.calls()
5570        );
5571    }
5572
5573    /// Only OPEN pull requests park a run. A merged one into another base is
5574    /// inert — it cannot gain commits — so it is not ambiguity.
5575    #[test]
5576    fn a_merged_pull_request_into_another_base_does_not_park_delivery() {
5577        let f = fixture();
5578        let c = contract();
5579        let wt = f.cut("s1", "main");
5580        std::fs::write(wt.join("x.txt"), "work").unwrap();
5581
5582        let gh = FakeGh::with_prs(vec![
5583            PrRecord {
5584                number: 10,
5585                state: PrState::Open,
5586                url: "https://github.com/acme/repo/pull/10".into(),
5587                is_draft: false,
5588                base: "main".into(),
5589            },
5590            PrRecord {
5591                number: 12,
5592                state: PrState::Merged,
5593                url: "https://github.com/acme/repo/pull/12".into(),
5594                is_draft: false,
5595                base: "release/2.1".into(),
5596            },
5597        ]);
5598
5599        let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
5600        assert_eq!(out.pr_action, PrAction::Updated);
5601        assert_eq!(out.pr_number, 10);
5602    }
5603
5604    /// A fork's pull request that happens to share the head branch NAME cannot
5605    /// park a legitimate run — `parse_pr_list` drops cross-repository entries
5606    /// before delivery ever sees them, and the new preflight must not
5607    /// reintroduce them by reading the raw `gh` output itself.
5608    #[test]
5609    fn a_cross_repository_pull_request_does_not_park_delivery() {
5610        let parsed = parse_pr_list(
5611            r#"[
5612              {"number": 77, "state": "OPEN", "url": "u77", "isDraft": false,
5613               "baseRefName": "release/2.1", "isCrossRepository": true},
5614              {"number": 10, "state": "OPEN", "url": "u10", "isDraft": false,
5615               "baseRefName": "main", "isCrossRepository": false}
5616            ]"#,
5617        )
5618        .unwrap();
5619        assert_eq!(parsed.len(), 1, "the fork entry is dropped: {parsed:?}");
5620
5621        let f = fixture();
5622        let c = contract();
5623        let wt = f.cut("s1", "main");
5624        std::fs::write(wt.join("x.txt"), "work").unwrap();
5625        let gh = FakeGh::with_prs(parsed);
5626
5627        let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
5628        assert_eq!(out.pr_action, PrAction::Updated);
5629        assert_eq!(out.pr_number, 10);
5630    }
5631
5632    /// `could not read Password` is git's message when the remote URL already
5633    /// carries a username — the shape `gh auth setup-git` leaves behind — and it
5634    /// is exactly as permanent as `could not read Username`.
5635    #[test]
5636    fn credential_prompts_that_can_never_be_answered_are_permanent() {
5637        for message in [
5638            "fatal: could not read Username for 'https://github.com': No such device or address",
5639            "fatal: could not read Password for 'https://someuser@github.com': \
5640             No such device or address",
5641            "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
5642            "Host key verification failed.\nfatal: Could not read from remote repository.",
5643        ] {
5644            let (_, retriable) = classify_push_error(message);
5645            assert!(
5646                !retriable,
5647                "a credential that will never appear must not be retried: {message}"
5648            );
5649        }
5650        // Unchanged: an ordinary lost race on a branch whose name contains none
5651        // of those words is still retriable.
5652        let (_, retriable) =
5653            classify_push_error("! [rejected] goalpool/g_pw -> goalpool/g_pw (non-fast-forward)");
5654        assert!(retriable);
5655    }
5656
5657    /// git prompts on `/dev/tty`, not stdin, so nulling stdin is not enough:
5658    /// a delivery launched with an inherited controlling terminal hung forever.
5659    /// Probed through git itself rather than by reading this file.
5660    #[test]
5661    fn git_children_cannot_open_a_terminal_prompt() {
5662        let dir = tempfile::tempdir().unwrap();
5663        git(dir.path(), &["init", "-q", "-b", "main"]).unwrap();
5664        // A `!` alias runs through the shell, so it can report what git handed
5665        // its children.
5666        let seen = git(
5667            dir.path(),
5668            &[
5669                "-c",
5670                "alias.envprobe=!printf %s \"${GIT_TERMINAL_PROMPT-unset}\"",
5671                "envprobe",
5672            ],
5673        )
5674        .unwrap();
5675        assert_eq!(
5676            seen.trim(),
5677            "0",
5678            "GIT_TERMINAL_PROMPT must be 0 for every git this module runs"
5679        );
5680    }
5681
5682    /// A subprocess that never exits is killed and reported, rather than
5683    /// hanging a round that nobody is watching.
5684    #[test]
5685    fn a_subprocess_that_never_finishes_is_killed_and_reported() {
5686        let mut cmd = std::process::Command::new("sleep");
5687        cmd.arg("60");
5688        let started = std::time::Instant::now();
5689        let err = run_capped_for(cmd, std::time::Duration::from_millis(300)).err();
5690        assert!(
5691            matches!(err, Some(RunFailure::TimedOut(_))),
5692            "the child must be killed, not waited on"
5693        );
5694        assert!(
5695            started.elapsed() < std::time::Duration::from_secs(20),
5696            "the kill must not wait for the child's own exit"
5697        );
5698    }
5699
5700    /// The headless branch mode's clean-worktree path: round N committed the
5701    /// work, round N+1 reuses the workspace with nothing left to commit, and
5702    /// that is a re-delivery rather than an error.
5703    #[test]
5704    fn branch_mode_re_delivers_a_clean_worktree_whose_head_is_ahead_of_the_base() {
5705        let f = fixture();
5706        let c = contract();
5707        let wt = f.cut("s1", "main");
5708        std::fs::write(wt.join("x.txt"), "work").unwrap();
5709        // Round N: commits and names a branch.
5710        let first =
5711            publish_branch_headless(&f.repo, &wt, "r1", "make x exist", &c, "main", None).unwrap();
5712        assert_eq!(first, "car/coder/r1");
5713
5714        // Round N+1: the same worktree, now clean, HEAD ahead of the base.
5715        let second =
5716            publish_branch_headless(&f.repo, &wt, "r2", "make x exist", &c, "main", None).unwrap();
5717        assert_eq!(second, "car/coder/r2");
5718        assert_eq!(
5719            git(&f.repo, &["rev-parse", "car/coder/r1"]).unwrap().trim(),
5720            git(&f.repo, &["rev-parse", "car/coder/r2"]).unwrap().trim(),
5721            "the same commit is re-delivered, not redone"
5722        );
5723    }
5724
5725    /// The empty-first-round case is still refused: a clean worktree sitting AT
5726    /// the base has nothing to deliver, and naming a branch for it would prove
5727    /// nothing.
5728    #[test]
5729    fn branch_mode_still_refuses_a_clean_worktree_that_holds_no_work() {
5730        let f = fixture();
5731        let c = contract();
5732        let wt = f.cut("s1", "main");
5733        let err =
5734            publish_branch_headless(&f.repo, &wt, "r1", "noop", &c, "main", None).unwrap_err();
5735        assert!(err.contains("nothing to deliver"), "{err}");
5736    }
5737
5738    /// A Windows-authored intent separates paragraphs with `\r\n\r\n`, which the
5739    /// blank-line split never matched — so the whole document was flattened and
5740    /// truncated at 72, and carriage returns rode into the commit subject.
5741    #[test]
5742    fn a_crlf_intent_still_stops_at_its_blank_line() {
5743        let intent =
5744            "Add the retry shim\r\n\r\nPointers:\r\n- see src/net.rs\r\n- and the docs\r\n";
5745        let subject = subject_from_intent(intent);
5746        assert_eq!(subject, "Add the retry shim");
5747        assert!(!subject.contains('\r'), "{subject:?}");
5748        // Mixed endings, and a lone `\r` inside the summary, are flattened too.
5749        assert!(!subject_from_intent("a\rb\r\nc").contains('\r'));
5750    }
5751
5752    /// The human-readable half never echoes the model's title or body either.
5753    /// It reaches an event stream and a `run_end.error` an orchestrator logs,
5754    /// and those values are unbounded model output.
5755    #[test]
5756    fn forge_command_shapes_elide_the_title_and_body() {
5757        for args in [
5758            gh_pr_create_args("head", "main", "a title", "a very long body", false),
5759            az_pr_create_args("head", "main", "a title", "a very long body", false),
5760        ] {
5761            let shape = gh_subcommand_shape(&args);
5762            assert!(!shape.contains("a title"), "{shape}");
5763            assert!(!shape.contains("a very long body"), "{shape}");
5764            assert!(shape.contains("--title"), "{shape}");
5765        }
5766    }
5767}