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