Skip to main content

choir_bridge/
queue.rs

1//! Queue-as-bot v0 (DECISIONS.md D21, queue stage): speculative merge
2//! trains, checked either by the host forge's CI or by our own (D18).
3//!
4//! v0 is verdict-only: it builds a train commit (base tip + each open
5//! PR merged in submission order, conflicts excluded), gets a verdict
6//! on it, then reports a per-PR verdict as a commit status. It never
7//! moves the protected branch on its own — landing the train is a
8//! separate, explicitly requested step.
9//!
10//! The check signal has two shapes. The forge one publishes the train
11//! as the `choir/train` branch and polls the forge's check API, which
12//! means the speculative merge of every open PR is pushed to a public
13//! remote and tested by someone else's runners. [`run_train_ci`] is the
14//! other: it hands the train to a [`choir_queue::executor::CiExecutor`],
15//! so the same train is checked here, on a provider we chose, and
16//! nothing speculative leaves the machine.
17//!
18//! Everything in this module is local git; the forge API glue lives in
19//! [`crate::github`] so the train mechanics stay testable offline.
20
21use choir_queue::differential_ledger::{effective_environment, CommandSpec};
22use choir_queue::executor::{CiExecutor, Job, Verdict, PROTOCOL};
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::time::Duration;
26
27static DIFFERENTIAL_RUN_ID: AtomicU64 = AtomicU64::new(0);
28
29/// One PR's fate in a train build.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TrainEntry {
32    /// PR number (or any caller-chosen id in tests).
33    pub id: u64,
34    /// The PR head oid this entry was built from.
35    pub head: String,
36    /// Whether the merge onto the train succeeded.
37    pub merged: bool,
38    /// The speculative merge commit, absent when the PR conflicted.
39    pub merge: Option<String>,
40    /// Human-readable note (merge position or the conflict reason).
41    pub note: String,
42    /// The PR's every commit is already in the train by patch identity
43    /// (`git patch-id --stable` equivalence via
44    /// `git cherry`): the train rewrote or landed this change earlier,
45    /// so it was recognized rather than re-merged — a success, not a
46    /// conflict, despite `merged` being false.
47    pub already_landed: bool,
48}
49
50/// Result of building one speculative train.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Train {
53    /// Tip commit of the train (== base when nothing merged).
54    pub tip: String,
55    /// Per-PR outcomes, in build order.
56    pub entries: Vec<TrainEntry>,
57}
58
59/// Runs git in `dir` with a fixed bot identity, returning stdout.
60fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
61    let out = std::process::Command::new("git")
62        .arg("-c")
63        .arg("user.name=choir-queue")
64        .arg("-c")
65        .arg("user.email=queue@choir.invalid")
66        .arg("-c")
67        .arg("commit.gpgsign=false")
68        .args(args)
69        .current_dir(dir)
70        .env("GIT_TERMINAL_PROMPT", "0")
71        .output()
72        .map_err(|e| format!("spawn git: {e}"))?;
73    if out.status.success() {
74        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
75    } else {
76        Err(format!(
77            "git {args:?}: {}",
78            String::from_utf8_lossy(&out.stderr)
79        ))
80    }
81}
82
83/// Builds a speculative train in `repo` (a non-bare clone whose
84/// worktree this function owns): detaches at `base`, then merges each
85/// `(id, head)` in order with `--no-ff`. A conflicting PR is excluded
86/// (merge aborted) and the train continues without it.
87///
88/// # Errors
89///
90/// Git failures other than merge conflicts (missing oids, dirty repo).
91pub fn build_train(repo: &Path, base: &str, prs: &[(u64, String)]) -> Result<Train, String> {
92    git(repo, &["checkout", "-q", "--detach", base])?;
93    let mut entries = Vec::new();
94    let mut position = 0usize;
95    for (id, head) in prs {
96        // Stable change identity: a PR whose every commit
97        // is already in the train by patch identity — the train landed a
98        // rewritten form of it, or it was rebased in — is recognized,
99        // not re-merged. Checked against the current tip so a duplicate
100        // of an earlier train member is caught too.
101        if already_in(repo, "HEAD", head)? {
102            entries.push(TrainEntry {
103                id: *id,
104                head: head.clone(),
105                merged: false,
106                merge: None,
107                note: "already landed (patch identity)".to_string(),
108                already_landed: true,
109            });
110            continue;
111        }
112        let msg = format!("choir train: PR #{id}");
113        match git(repo, &["merge", "--no-ff", "-q", "-m", &msg, head]) {
114            Ok(_) => {
115                position += 1;
116                let merge = git(repo, &["rev-parse", "HEAD"])?.trim().to_string();
117                entries.push(TrainEntry {
118                    id: *id,
119                    head: head.clone(),
120                    merged: true,
121                    merge: Some(merge),
122                    note: format!("train position {position}"),
123                    already_landed: false,
124                });
125            }
126            Err(_) => {
127                // Conflict (or unmergeable): drop this PR from the train.
128                git(repo, &["merge", "--abort"]).ok();
129                entries.push(TrainEntry {
130                    id: *id,
131                    head: head.clone(),
132                    merged: false,
133                    merge: None,
134                    note: "conflicts with train".to_string(),
135                    already_landed: false,
136                });
137            }
138        }
139    }
140    let tip = git(repo, &["rev-parse", "HEAD"])?.trim().to_string();
141    Ok(Train { tip, entries })
142}
143
144/// Whether every commit of `head` is already contained in `upstream` by
145/// patch identity. `git cherry` marks a commit `-` when an equivalent
146/// change (same `git patch-id --stable`) is upstream, `+` when it is
147/// not; no `+` lines — including the empty output of a plain ancestor —
148/// means there is nothing left to merge.
149pub fn already_in(repo: &Path, upstream: &str, head: &str) -> Result<bool, String> {
150    let out = git(repo, &["cherry", upstream, head])?;
151    Ok(out.lines().all(|line| !line.starts_with('+')))
152}
153
154/// Structured advisory result returned by the separately built D23 runner.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct DifferentialOutcome {
157    /// Monotonic calibration-ledger observation id.
158    pub observation_id: u64,
159    /// Stable classifier spelling from the runner.
160    pub verdict: DifferentialVerdict,
161    /// Number of interaction flags still awaiting ground truth.
162    pub pending_interactions: u64,
163}
164
165/// Closed set of D23 classifications accepted from the runner.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum DifferentialVerdict {
168    /// Both parents and the merge passed.
169    Clean,
170    /// Both parents passed and the merge failed.
171    InteractionFailure,
172    /// At least one parent failed.
173    InconclusiveParentFailure,
174}
175
176impl DifferentialVerdict {
177    /// The ledger's stable spelling. CLI output prints this rather than the
178    /// Rust `Debug` name so every consumer — ledger rows, receipts, stdout —
179    /// parses one vocabulary; the two spellings once cost a downstream
180    /// summarizer a silent zero.
181    #[must_use]
182    pub const fn as_str(self) -> &'static str {
183        match self {
184            Self::Clean => "clean",
185            Self::InteractionFailure => "interaction_failure",
186            Self::InconclusiveParentFailure => "inconclusive_parent_failure",
187        }
188    }
189}
190
191fn compatible_confidence_policy(value: &serde_json::Value) -> bool {
192    value["format_version"].as_u64() == Some(1)
193        && value["method"].as_str() == Some("one_sided_exact_binomial_zero_spurious")
194        && value["confidence"]["numerator"].as_u64() == Some(95)
195        && value["confidence"]["denominator"].as_u64() == Some(100)
196        && value["target"]["numerator"].as_u64() == Some(1)
197        && value["target"]["denominator"].as_u64() == Some(1000)
198        && value["target"]["comparison"].as_str() == Some("strictly_less_than")
199        && value["minimum_evaluated_merges"].as_u64() == Some(2_995)
200        && value["requires_zero_spurious_failures"].as_bool() == Some(true)
201        && value["assumptions"].as_array().is_some_and(|assumptions| {
202            assumptions
203                == &[
204                    serde_json::Value::String("independent_runs".to_string()),
205                    serde_json::Value::String(
206                        "representative_queue_command_and_merge_population".to_string(),
207                    ),
208                ]
209        })
210}
211
212fn cleanup_worktrees(repo: &Path, root: &Path, paths: &[PathBuf]) -> Result<(), String> {
213    let mut first_error = None;
214    for path in paths.iter().rev() {
215        let path_text = path.to_string_lossy().into_owned();
216        if let Err(error) = git(repo, &["worktree", "remove", "--force", &path_text]) {
217            first_error.get_or_insert(error);
218        }
219    }
220    if root.exists() {
221        if let Err(error) = std::fs::remove_dir_all(root) {
222            first_error
223                .get_or_insert_with(|| format!("remove differential checkout root: {error}"));
224        }
225    }
226    if let Some(parent) = root.parent() {
227        std::fs::remove_dir(parent).ok();
228    }
229    first_error.map_or(Ok(()), Err)
230}
231
232/// Three revision worktrees whose lifetime is a whole **calibration run**
233/// rather than a single observation.
234///
235/// The observation cost the harness cannot avoid is compiling the project
236/// under test three times. The cost it *can* avoid is compiling the project's
237/// dependencies three times per observation, which is what a fresh worktree
238/// per observation forces: the operator's `--target-dir` is tree-relative (the
239/// ledger refuses any target directory that escapes its worktree), so
240/// destroying the tree destroys the build directory with it and the next
241/// observation starts from zero. Holding the same three trees open across
242/// observations and moving them with `git checkout` keeps those build
243/// directories alive. Measured on `rust-lang/log`, one run: **16.1 s** in a
244/// fresh tree against **12.0-13.4 s** in a held tree switched to a new
245/// revision.
246///
247/// Three properties are deliberately preserved, because each of them is load
248/// bearing for what the calibration claims:
249///
250/// - **Every run is still really run.** A held tree makes a run cheaper, never
251///   skipped: `cargo test` re-links and re-executes the test binaries even when
252///   nothing changed (measured: four test binaries execute in a fully warm
253///   tree). That is what the ledger's `independent_runs` assumption needs, and
254///   it is why this is not a result cache.
255/// - **The three trees stay separate.** Each keeps its own build directory, so
256///   the cross-tree artifact contamination that invalidated an earlier receipt
257///   cannot recur, and the three concurrent runs still cannot contend on one
258///   tool lock.
259/// - **Nothing survives the run.** The worktrees and their root are removed by
260///   [`Self::close`], and again by `Drop` if a caller returns early, so an
261///   operator's repository is left as it was found.
262///
263/// What it does change, stated rather than buried: an observation now starts in
264/// a tree that holds an *earlier* observation's untracked build output.
265/// Tracked content is exact — the checkout is forced, so the tree matches the
266/// revision — but a command that writes untracked files into its tree will see
267/// them again. Callers that need a pristine tree per observation still have
268/// one: [`run_differential`] opens and closes a session around a single
269/// observation, and `choir-bridge calibrate --fresh-worktrees` selects that
270/// path for a whole run.
271///
272/// Which tree plays which role is decided per observation: a revision is
273/// assigned to a tree that is already sitting on it, when one is. Replaying
274/// consecutive first-parent merges — the shape of a calibration corpus — makes
275/// this pay every observation, because a merge's first parent *is* the
276/// previous observation's merge: the tree that just ran that merge becomes the
277/// parent-a tree with a no-op checkout, so its command rebuilds nothing at
278/// all. Measured on `rust-lang/log`, one warm tree, solo: **8.9 s** for a
279/// no-op revision against **15.0 s** for a one-merge move.
280pub struct DifferentialSession {
281    repo: PathBuf,
282    root: PathBuf,
283    /// `None` until the first observation names the revisions to check out.
284    /// Worktree creation needs a revision, so there is nothing useful to
285    /// create at open time. Each entry pairs a worktree path with the
286    /// revision the tree currently holds, which is what role assignment
287    /// matches against.
288    trees: Option<[(PathBuf, String); 3]>,
289}
290
291impl DifferentialSession {
292    /// Names a session root under `repo`. Creates nothing until the first
293    /// observation; opening cannot fail.
294    #[must_use]
295    pub fn open(repo: &Path) -> Self {
296        Self {
297            repo: repo.to_path_buf(),
298            root: repo.join(".choir-differential").join(format!(
299                "run-{}-{}",
300                std::process::id(),
301                DIFFERENTIAL_RUN_ID.fetch_add(1, Ordering::Relaxed)
302            )),
303            trees: None,
304        }
305    }
306
307    /// Puts the three trees on the three requested revisions, creating them on
308    /// the first call and moving them on every later one.
309    fn prepare(
310        &mut self,
311        parent_a: &str,
312        parent_b: &str,
313        merged: &str,
314    ) -> Result<[PathBuf; 3], String> {
315        let revisions = [parent_a, parent_b, merged];
316        if let Some(trees) = &mut self.trees {
317            // A tree already holding a requested revision keeps it, so the
318            // checkout below is a no-op there and the command that follows
319            // rebuilds nothing. Roles are not pinned to trees: on a corpus of
320            // consecutive first-parent merges, parent a of this observation is
321            // the previous observation's merge, and this hands that revision
322            // its still-built tree. Unmatched roles take the leftover trees in
323            // order, which keeps the assignment stable when nothing matches.
324            let mut assigned = [usize::MAX; 3];
325            let mut used = [false; 3];
326            for (role, revision) in revisions.iter().enumerate() {
327                if let Some(index) =
328                    (0..trees.len()).find(|&index| !used[index] && trees[index].1 == *revision)
329                {
330                    assigned[role] = index;
331                    used[index] = true;
332                }
333            }
334            for slot in &mut assigned {
335                if *slot == usize::MAX {
336                    let index = used
337                        .iter()
338                        .position(|taken| !taken)
339                        .expect("three roles cannot exhaust three trees");
340                    *slot = index;
341                    used[index] = true;
342                }
343            }
344            let mut paths = Vec::with_capacity(revisions.len());
345            for (role, revision) in revisions.iter().enumerate() {
346                let (path, head) = &mut trees[assigned[role]];
347                // `--force` even when the tree already holds the revision: the
348                // tree is the harness's own scratch checkout and an
349                // observation must start from exactly this revision's tracked
350                // content, so a command that dirtied a tracked file (a
351                // lockfile, say) must not be able to leak it into the next
352                // observation. It leaves untracked build output alone, which
353                // is the point of holding the tree at all.
354                git(path, &["checkout", "-q", "--detach", "--force", revision])?;
355                *head = (*revision).to_string();
356                paths.push(path.clone());
357            }
358            return Ok(paths
359                .try_into()
360                .expect("three roles produce three tree paths"));
361        }
362        std::fs::create_dir_all(&self.root)
363            .map_err(|error| format!("create differential checkout root: {error}"))?;
364        let mut created: Vec<(PathBuf, String)> = Vec::with_capacity(revisions.len());
365        // Neutral names: role assignment above may hand any tree to any role
366        // from the second observation on, so role-named directories would lie.
367        for (name, revision) in [
368            ("tree-0", parent_a),
369            ("tree-1", parent_b),
370            ("tree-2", merged),
371        ] {
372            let path = self.root.join(name);
373            let path_text = path.to_string_lossy().into_owned();
374            if let Err(error) = git(
375                &self.repo,
376                &["worktree", "add", "-q", "--detach", &path_text, revision],
377            ) {
378                let paths: Vec<PathBuf> = created.into_iter().map(|(path, _)| path).collect();
379                cleanup_worktrees(&self.repo, &self.root, &paths).ok();
380                return Err(error);
381            }
382            created.push((path, revision.to_string()));
383        }
384        let trees: [(PathBuf, String); 3] = created
385            .try_into()
386            .map_err(|_| "differential session needs exactly three worktrees".to_string())?;
387        let paths = [trees[0].0.clone(), trees[1].0.clone(), trees[2].0.clone()];
388        self.trees = Some(trees);
389        Ok(paths)
390    }
391
392    fn cleanup(&mut self) -> Result<(), String> {
393        let paths: Vec<PathBuf> = self
394            .trees
395            .take()
396            .into_iter()
397            .flatten()
398            .map(|(path, _)| path)
399            .collect();
400        cleanup_worktrees(&self.repo, &self.root, &paths)
401    }
402
403    /// Removes the three worktrees and the session root, reporting the first
404    /// failure. `Drop` repeats this as a best-effort backstop, so a caller that
405    /// returns early still leaves nothing behind — but only `close` can tell
406    /// the caller that cleanup failed.
407    ///
408    /// # Errors
409    ///
410    /// A worktree or the session root could not be removed.
411    pub fn close(mut self) -> Result<(), String> {
412        self.cleanup()
413    }
414}
415
416impl Drop for DifferentialSession {
417    fn drop(&mut self) {
418        // Best effort: `close` is the reporting path. This exists so an early
419        // return or a panic mid-run cannot leave `.choir-differential` in an
420        // operator's repository.
421        self.cleanup().ok();
422    }
423}
424
425/// Resolves a merge commit to its canonical oid and its exact first and second
426/// parents. Resolution happens before any checkout so an abbreviated or
427/// non-canonical spelling cannot reach the ledger.
428fn resolve_merge_revisions(repo: &Path, merge: &str) -> Result<(String, String, String), String> {
429    let commit = format!("{merge}^{{commit}}");
430    let merged_revision = git(repo, &["rev-parse", "--verify", &commit])?
431        .trim()
432        .to_string();
433    let first = format!("{merged_revision}^1");
434    let second = format!("{merged_revision}^2");
435    let parent_a = git(repo, &["rev-parse", &first])?.trim().to_string();
436    let parent_b = git(repo, &["rev-parse", &second])?.trim().to_string();
437    Ok((parent_a, parent_b, merged_revision))
438}
439
440/// Runs one observation in an already-open session's three worktrees.
441///
442/// This is the loop body of a calibration run: the session is opened once and
443/// reused, so the build directories the operator's command creates survive
444/// between observations. Everything about the result is unchanged — the same
445/// runner, the same argv, the same structured receipt validation as the
446/// single-observation [`run_differential`].
447///
448/// The result is always advisory. This function only validates and returns a
449/// closed structured result; callers have no landing-gate output to consume.
450/// The state directory and command file are explicit arguments, never
451/// environment configuration.
452///
453/// # Errors
454///
455/// The merge does not have two parents, a worktree cannot be created or moved
456/// to the requested revision, the runner fails operationally, or its structured
457/// result is malformed/claims that landing gating is enabled.
458pub fn run_differential_in(
459    session: &mut DifferentialSession,
460    merge: &str,
461    runner: &Path,
462    command_file: &Path,
463    state_dir: &Path,
464) -> Result<DifferentialOutcome, String> {
465    let (parent_a, parent_b, merged_revision) = resolve_merge_revisions(&session.repo, merge)?;
466    let trees = session.prepare(&parent_a, &parent_b, &merged_revision)?;
467    let output = std::process::Command::new(runner)
468        .arg("run")
469        .arg(command_file)
470        .arg(state_dir)
471        .arg(&parent_a)
472        .arg(&trees[0])
473        .arg(&parent_b)
474        .arg(&trees[1])
475        .arg(&merged_revision)
476        .arg(&trees[2])
477        .output()
478        .map_err(|error| format!("spawn differential runner: {error}"))?;
479    if !output.status.success() {
480        return Err("differential runner exited unsuccessfully".to_string());
481    }
482    let value: serde_json::Value = serde_json::from_slice(&output.stdout)
483        .map_err(|_| "differential runner returned malformed JSON".to_string())?;
484    if value["format_version"].as_u64() != Some(1)
485        || value["merge"].as_str() != Some(merged_revision.as_str())
486        || value["calibration"]["landing_gate_enabled"].as_bool() != Some(false)
487        || !matches!(
488            &value["calibration"]["confidence_claim"],
489            serde_json::Value::Null | serde_json::Value::Bool(_)
490        )
491        || !compatible_confidence_policy(&value["calibration"]["confidence_policy"])
492    {
493        return Err("differential runner returned an incompatible receipt".to_string());
494    }
495    let verdict = match value["report"]["verdict"].as_str() {
496        Some("clean") => DifferentialVerdict::Clean,
497        Some("interaction_failure") => DifferentialVerdict::InteractionFailure,
498        Some("inconclusive_parent_failure") => DifferentialVerdict::InconclusiveParentFailure,
499        _ => return Err("differential runner returned an unknown verdict".to_string()),
500    };
501    if !matches!(
502        &value["calibration"]["target"]["met"],
503        serde_json::Value::Null | serde_json::Value::Bool(_)
504    ) {
505        return Err("differential target verdict must be boolean or null".to_string());
506    }
507    Ok(DifferentialOutcome {
508        observation_id: value["observation_id"]
509            .as_u64()
510            .ok_or("differential result needs an observation id")?,
511        verdict,
512        pending_interactions: value["calibration"]["pending_interactions"]
513            .as_u64()
514            .ok_or("differential receipt needs pending_interactions")?,
515    })
516}
517
518/// Builds isolated worktrees for a speculative merge's two parents and the
519/// merge itself, then invokes the explicit `choir-differential` runner.
520///
521/// This is [`run_differential_in`] wrapped in a session of its own, so the
522/// three worktrees are created and destroyed around this one observation and
523/// the command sees a pristine tree. That is the right shape for a speculative
524/// train, where each merge is checked once, and it is the escape hatch for an
525/// operator re-checking a flagged interaction without the previous
526/// observation's build output present.
527///
528/// # Errors
529///
530/// As [`run_differential_in`], plus a failure to clean the worktrees up.
531pub fn run_differential(
532    repo: &Path,
533    merge: &str,
534    runner: &Path,
535    command_file: &Path,
536    state_dir: &Path,
537) -> Result<DifferentialOutcome, String> {
538    let mut session = DifferentialSession::open(repo);
539    let result = run_differential_in(&mut session, merge, runner, command_file, state_dir);
540    let cleanup = session.close();
541    match (result, cleanup) {
542        (Ok(value), Ok(())) => Ok(value),
543        (Err(error), _) | (Ok(_), Err(error)) => Err(error),
544    }
545}
546
547/// What one train verdict means for the PRs riding it.
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub struct TrainReport {
550    /// Whether the train may land. The only input to that decision, so
551    /// that neither check signal gets a landing rule of its own.
552    pub green: bool,
553    /// Commit-status state: `success`, `failure`, or `error`.
554    pub state: &'static str,
555    /// Commit-status description. Fixed strings, never anything an
556    /// executor or a pull request wrote — the Rule of Two applies to
557    /// what we say as well as to what we read.
558    pub description: &'static str,
559}
560
561/// Reads a train's verdict as the report its PRs get (D18, D49).
562///
563/// The four cases collapse to three states, and which two share one is
564/// the whole point. `failure` blames the change, and only
565/// [`Verdict::Failed`] may: an outage or a deadline posts `error`,
566/// because telling an author their work is red when our provider fell
567/// over is the confusion the seam exists to end. `green` is true for
568/// exactly one case, so a train never lands on a verdict that merely
569/// failed to say no.
570#[must_use]
571pub fn train_report(verdict: &Verdict) -> TrainReport {
572    match verdict {
573        Verdict::Passed => TrainReport {
574            green: true,
575            state: "success",
576            description: "speculative train green",
577        },
578        Verdict::Failed { .. } => TrainReport {
579            green: false,
580            state: "failure",
581            description: "train CI failed",
582        },
583        Verdict::Errored { .. } | Verdict::TimedOut => TrainReport {
584            green: false,
585            state: "error",
586            description: "train CI could not run",
587        },
588    }
589}
590
591/// The report for a train we could not get a verdict on at all.
592///
593/// Same shape as a provider fault, because it is one: an executor that
594/// could not be reached and an executor that failed to boot a VM are
595/// the same news to the change riding the train.
596#[must_use]
597pub fn train_unavailable() -> TrainReport {
598    train_report(&Verdict::Errored {
599        provider: String::new(),
600        detail: String::new(),
601    })
602}
603
604/// Checks one train commit with a [`CiExecutor`] instead of asking the
605/// host forge (D18).
606///
607/// The forge path and this one answer the same question by opposite
608/// means. Asking the forge requires publishing the speculative merge of
609/// every open PR to a remote and trusting whatever ran there; this
610/// checks the train in the worktree that just built it, with an
611/// operator-declared command, on a provider the operator picked. What
612/// comes back is a [`Verdict`] rather than a bool, so the caller can
613/// still tell "the train is bad" from "we could not find out" — the
614/// distinction the whole seam exists for, and one the forge's
615/// `status`/`conclusion` enums also make and the bool did not.
616///
617/// `repo` is checked out at `tip` first, forcibly: the train build owns
618/// this worktree and left it detached at the train it built, but an
619/// advisory differential run between the two may have moved it.
620///
621/// The job is content-addressed by `tip` under git's own codec, so the
622/// subject a cache keys on and the subject a `RecordCheck` names are
623/// the commit itself rather than a queue-local id. `may_write_cache`
624/// stays false: a train holds unreviewed code from every open PR, which
625/// is exactly the untrusted case the flag is for.
626///
627/// # Errors
628///
629/// Git could not resolve or check out `tip`, the executor could not be
630/// reached, or it answered with something other than one verdict for
631/// the one job. All three mean we did not find out, never that the
632/// train is bad — a caller must not land on this and must not blame a
633/// change for it.
634pub fn run_train_ci(
635    repo: &Path,
636    tip: &str,
637    spec: &CommandSpec,
638    ci: &mut dyn CiExecutor,
639) -> Result<Verdict, String> {
640    let oid = git(
641        repo,
642        &["rev-parse", "--verify", &format!("{tip}^{{commit}}")],
643    )?
644    .trim()
645    .to_string();
646    let subject = choir_hash::ContentHash::from_git_oid(&oid)
647        .ok_or_else(|| format!("train tip {oid} is not a git object id"))?;
648    git(repo, &["checkout", "-q", "--detach", "--force", &oid])?;
649
650    let mut command = Vec::with_capacity(spec.args.len() + 1);
651    command.push(spec.program.clone());
652    command.extend(spec.args.iter().cloned());
653    let mut job = Job::new(subject, command);
654    job.label = "train".to_string();
655    job.environment = effective_environment(&spec.env);
656    job.directory = Some(repo.to_path_buf());
657    if let Some(seconds) = spec.timeout_seconds {
658        job.deadline = Duration::from_secs(seconds);
659    }
660
661    // The handshake before the work, so a provider speaking another
662    // protocol is refused rather than believed.
663    let info = ci.info().map_err(|error| error.to_string())?;
664    if info.protocol != PROTOCOL {
665        return Err(format!(
666            "executor `{}` speaks protocol {} and this build speaks {PROTOCOL}",
667            info.name, info.protocol
668        ));
669    }
670    let verdicts = ci.run(&[job]).map_err(|error| error.to_string())?;
671    match verdicts.len() {
672        1 => Ok(verdicts.into_iter().next().expect("length checked")),
673        n => Err(format!(
674            "executor `{}` answered {n} times for one job",
675            info.name
676        )),
677    }
678}
679
680/// Runs the advisory detector for every merge commit in a train, preserving
681/// train order and returning operational errors per entry instead of turning
682/// them into a landing decision.
683#[must_use]
684pub fn run_train_differentials(
685    repo: &Path,
686    train: &Train,
687    runner: &Path,
688    command_file: &Path,
689    state_dir: &Path,
690) -> Vec<(u64, Result<DifferentialOutcome, String>)> {
691    train
692        .entries
693        .iter()
694        .filter(|entry| entry.merged)
695        .map(|entry| {
696            let result = entry
697                .merge
698                .as_deref()
699                .ok_or("merged train entry is missing its merge commit".to_string())
700                .and_then(|merge| run_differential(repo, merge, runner, command_file, state_dir));
701            (entry.id, result)
702        })
703        .collect()
704}
705
706/// Two-parent merge commits along `repo`'s first-parent history, newest
707/// first: the population `choir-bridge harvest` replays (D27).
708///
709/// Exactly two parents because the differential adapter seats exactly
710/// three worktrees — parent a, parent b, merged — so an octopus merge has
711/// no seat for its third parent and is enumerated past rather than failed
712/// on. First-parent order for the same reason it is load-bearing in
713/// choir-queue's corpus module: it lists the merges that landed on this
714/// branch and skips commits internal to the branches they merged, which
715/// is the population D23 cares about.
716///
717/// `limit` keeps only the most recent `limit` merges; 0 keeps them all.
718/// Replay order is the caller's: oldest-first pays best with a held
719/// [`DifferentialSession`], whose docs explain why.
720///
721/// # Errors
722///
723/// Git failing to spawn or exiting nonzero.
724pub fn harvestable_merges(repo: &Path, limit: usize) -> Result<Vec<String>, String> {
725    let log = git(
726        repo,
727        &["log", "--first-parent", "--merges", "--format=%H %P"],
728    )?;
729    let mut merges = parse_merge_list(&log);
730    if limit > 0 {
731        merges.truncate(limit);
732    }
733    Ok(merges)
734}
735
736/// Parses `git log --format="%H %P"` output into the ids of commits with
737/// exactly two parents, preserving order.
738///
739/// Pure, so the format contract is testable without a repository — the
740/// same parse/shell-out split choir-queue's corpus module uses.
741#[must_use]
742pub fn parse_merge_list(log: &str) -> Vec<String> {
743    log.lines()
744        .filter_map(|line| {
745            let mut fields = line.split_whitespace();
746            let id = fields.next()?;
747            (fields.count() == 2).then(|| id.to_string())
748        })
749        .collect()
750}
751
752/// Merge commits already recorded in `state_dir`'s observation ledger,
753/// keyed by the ledger row's `revisions.merged` oid.
754///
755/// This is what makes `choir-bridge harvest` incremental (D27): a re-run
756/// against an updated mirror consults the ledger it is about to extend and
757/// replays only the merges it has never seen. A missing or unreadable
758/// ledger is an empty set — the first harvest into a fresh state directory
759/// must not fail on its own absence — and a malformed row is skipped
760/// rather than trusted, so it can never suppress a replay.
761#[must_use]
762pub fn observed_merges(state_dir: &Path) -> std::collections::BTreeSet<String> {
763    std::fs::read_to_string(state_dir.join("observations.jsonl"))
764        .map(|text| parse_observed_merges(&text))
765        .unwrap_or_default()
766}
767
768/// Parses observation-ledger lines into the set of merged-revision oids.
769///
770/// Pure, so the ledger-row contract is testable without a state directory.
771#[must_use]
772pub fn parse_observed_merges(text: &str) -> std::collections::BTreeSet<String> {
773    text.lines()
774        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
775        .filter_map(|row| row["revisions"]["merged"].as_str().map(str::to_string))
776        .collect()
777}
778
779/// Whether a path cannot change what a build-and-test command observes.
780///
781/// Deliberately a short, conservative allowlist rather than a guess at what
782/// matters: documentation, licences, and forge bookkeeping. Everything else
783/// — including `Cargo.toml`, `build.rs`, and any `.txt` that might be a test
784/// fixture — counts as relevant, because the cost of wrongly skipping a
785/// merge is a semantic conflict that never enters the corpus, while the cost
786/// of wrongly keeping one is three builds.
787///
788/// # Examples
789///
790/// ```
791/// use choir_bridge::queue::path_is_inert;
792///
793/// // Documentation and forge bookkeeping cannot move a test result.
794/// assert!(path_is_inert("README.md"));
795/// assert!(path_is_inert("docs/operating/limits.md"));
796/// assert!(path_is_inert(".github/workflows/ci.yml"));
797///
798/// // Everything else counts, including the files that only look inert.
799/// assert!(!path_is_inert("src/lib.rs"));
800/// assert!(!path_is_inert("Cargo.toml"));
801/// assert!(!path_is_inert("tests/fixtures/input.txt"));
802/// ```
803#[must_use]
804pub fn path_is_inert(path: &str) -> bool {
805    if path.starts_with(".github/") {
806        return true;
807    }
808    let name = path.rsplit('/').next().unwrap_or(path);
809    if name.ends_with(".md") {
810        return true;
811    }
812    // `LICENSE-MIT` and `LICENSE-APACHE` are the usual dual-licence spelling,
813    // so the licence names match by prefix; the rest match exactly, because a
814    // prefix rule on them would swallow real source files.
815    if name.starts_with("LICENSE") || name.starts_with("LICENCE") {
816        return true;
817    }
818    let stem = name.split('.').next().unwrap_or(name);
819    matches!(stem, "COPYING" | "NOTICE" | "AUTHORS" | "CHANGELOG")
820        || matches!(name, ".gitignore" | ".gitattributes" | ".mailmap")
821}
822
823/// Whether every file this merge changed, against **either** parent, is
824/// inert by [`path_is_inert`].
825///
826/// A semantic conflict is an interaction between the two branches' changes,
827/// so the population that can possibly hold one is the union of the two
828/// parent diffs. A merge whose whole union is documentation cannot fail a
829/// build-and-test command in a way the parents pass, so harvesting it buys
830/// three builds' worth of nothing. Skipping it is a **population
831/// restriction**, not an optimization detail: callers record it rather than
832/// applying it silently, because it changes which merges the denominator
833/// counts.
834///
835/// An empty union — a merged tree identical to both parents — returns
836/// `false`: it is strange enough to be worth observing rather than assuming
837/// away.
838///
839/// # Errors
840///
841/// The merge cannot be resolved to two parents, or git fails.
842pub fn merge_changes_only_inert_paths(repo: &Path, merge: &str) -> Result<bool, String> {
843    let (parent_a, parent_b, merged) = resolve_merge_revisions(repo, merge)?;
844    let mut any = false;
845    for parent in [&parent_a, &parent_b] {
846        let diff = git(repo, &["diff", "--name-only", parent, &merged])?;
847        for path in diff.lines().map(str::trim).filter(|p| !p.is_empty()) {
848            any = true;
849            if !path_is_inert(path) {
850                return Ok(false);
851            }
852        }
853    }
854    Ok(any)
855}
856
857/// Records, beside the ledger, every way a harvest narrowed the population
858/// it walked: the merges excluded as inert, and the consecutive-inconclusive
859/// run that stopped the walk early.
860///
861/// This exists so a corpus reader can tell a merge that was *observed and
862/// found clean* from one that was *never observed*, which a ledger of
863/// observations alone cannot express. Appends one versioned line per run, so
864/// an incremental re-run's restrictions accumulate rather than overwrite.
865/// Writes nothing when a run restricted nothing.
866///
867/// # Errors
868///
869/// The state directory cannot be created or the record cannot be appended.
870pub fn record_population_restrictions(
871    state_dir: &Path,
872    inert: &[String],
873    stopped_after_inconclusive: Option<usize>,
874) -> Result<(), String> {
875    if inert.is_empty() && stopped_after_inconclusive.is_none() {
876        return Ok(());
877    }
878    let record = serde_json::json!({
879        "format_version": 1,
880        "inert_merges": inert,
881        "inert_rule": "every path in the union of both parent diffs is documentation, licence, or forge bookkeeping",
882        "stopped_after_consecutive_inconclusive": stopped_after_inconclusive,
883    });
884    std::fs::create_dir_all(state_dir).map_err(|error| format!("create state dir: {error}"))?;
885    let mut line = serde_json::to_vec(&record)
886        .map_err(|error| format!("serialize population restrictions: {error}"))?;
887    line.push(b'\n');
888    std::fs::OpenOptions::new()
889        .create(true)
890        .append(true)
891        .open(state_dir.join("population.jsonl"))
892        .and_then(|mut file| std::io::Write::write_all(&mut file, &line))
893        .map_err(|error| format!("append population restrictions: {error}"))
894}
895
896/// Reproduction runs a harvest adds after a first `interaction_failure`
897/// verdict, so a specimen records six runs in total — the bar specimen #1
898/// (rust-lang/log `11eda98d`, reproduced 6/6) set for calling a flag a
899/// semantic conflict rather than a flake.
900pub const SPECIMEN_REPRODUCTION_RUNS: usize = 5;
901
902/// Packages a flagged merge and its reproduction runs as a corpus
903/// specimen under `state_dir/specimens/<merge>.json` (D27).
904///
905/// The specimen is metadata, not evidence: the observation rows it points
906/// at (by id) stay in the ledger, and the JSON records how often the
907/// interaction failure reproduced so a reader can tell a 6/6 conflict
908/// from a 1/6 flake without replaying anything. Nothing is published;
909/// the file lives in the operator's state directory.
910///
911/// # Errors
912///
913/// The merge cannot be resolved to two parents, or the specimen file
914/// cannot be written.
915pub fn write_specimen(
916    repo: &Path,
917    merge: &str,
918    outcomes: &[Result<DifferentialOutcome, String>],
919    state_dir: &Path,
920) -> Result<PathBuf, String> {
921    let (parent_a, parent_b, merged_revision) = resolve_merge_revisions(repo, merge)?;
922    let mut interaction_failures = 0u64;
923    let mut clean = 0u64;
924    let mut inconclusive = 0u64;
925    let mut errors = 0u64;
926    let mut observation_ids = Vec::new();
927    for outcome in outcomes {
928        match outcome {
929            Ok(outcome) => {
930                observation_ids.push(outcome.observation_id);
931                match outcome.verdict {
932                    DifferentialVerdict::Clean => clean += 1,
933                    DifferentialVerdict::InteractionFailure => interaction_failures += 1,
934                    DifferentialVerdict::InconclusiveParentFailure => inconclusive += 1,
935                }
936            }
937            Err(_) => errors += 1,
938        }
939    }
940    let specimen = serde_json::json!({
941        "format_version": 1,
942        "merge": merged_revision,
943        "parent_a": parent_a,
944        "parent_b": parent_b,
945        "runs": outcomes.len(),
946        "interaction_failures": interaction_failures,
947        "clean": clean,
948        "inconclusive_parent_failures": inconclusive,
949        "run_errors": errors,
950        "observation_ids": observation_ids,
951    });
952    let dir = state_dir.join("specimens");
953    std::fs::create_dir_all(&dir).map_err(|error| format!("create specimens dir: {error}"))?;
954    let path = dir.join(format!("{merged_revision}.json"));
955    let body = serde_json::to_vec_pretty(&specimen)
956        .map_err(|error| format!("serialize specimen: {error}"))?;
957    std::fs::write(&path, body).map_err(|error| format!("write specimen: {error}"))?;
958    Ok(path)
959}
960
961/// Lands a green train: pushes `tip` to `branch` on the remote at
962/// `url` WITHOUT force, so git's fast-forward rule is the race guard —
963/// if the branch moved since the train was built, the push is rejected
964/// and the caller should rebuild on the next round.
965///
966/// # Errors
967///
968/// Push failures, including the non-fast-forward rejection.
969pub fn land(repo: &Path, url: &str, tip: &str, branch: &str) -> Result<(), String> {
970    git(
971        repo,
972        &["push", "-q", url, &format!("{tip}:refs/heads/{branch}")],
973    )
974    .map(|_| ())
975}
976
977/// Reverts a landed train (D23 auto-revert arm): reverts each of the
978/// train's merge commits (`base..tip`, first-parent, newest first) on
979/// top of `tip`, then pushes the result to `branch` WITHOUT force — if
980/// the branch moved past `tip` since landing, the push is rejected and
981/// a human decides. Returns the new branch tip. The reverted tree is
982/// byte-identical to `base`'s tree; history keeps the full record.
983///
984/// # Errors
985///
986/// Git failures, including a revert that itself conflicts (possible
987/// when later commits touched the same lines) and the non-fast-forward
988/// rejection — both leave the remote branch untouched.
989pub fn revert_train(
990    repo: &Path,
991    url: &str,
992    base: &str,
993    tip: &str,
994    branch: &str,
995) -> Result<String, String> {
996    let merges = git(
997        repo,
998        &[
999            "rev-list",
1000            "--first-parent",
1001            "--merges",
1002            &format!("{base}..{tip}"),
1003        ],
1004    )?;
1005    let merges: Vec<&str> = merges.split_whitespace().collect();
1006    if merges.is_empty() {
1007        return Err("no train merges between base and tip".to_string());
1008    }
1009    git(repo, &["checkout", "-q", "--detach", tip])?;
1010    for merge in &merges {
1011        // -m 1 = revert to the first parent (the train spine).
1012        if let Err(e) = git(repo, &["revert", "-m", "1", "--no-edit", merge]) {
1013            git(repo, &["revert", "--abort"]).ok();
1014            return Err(format!(
1015                "revert of {merge} conflicts, leaving branch alone: {e}"
1016            ));
1017        }
1018    }
1019    let new_tip = git(repo, &["rev-parse", "HEAD"])?.trim().to_string();
1020    git(
1021        repo,
1022        &["push", "-q", url, &format!("{new_tip}:refs/heads/{branch}")],
1023    )?;
1024    Ok(new_tip)
1025}
1026
1027/// One pull request's fate in a queued round.
1028///
1029/// The queue answers per change rather than per train, which is the
1030/// difference [`run_queue`] buys over [`build_train`]: a red build
1031/// blames the change that was red, and the changes ahead of it still
1032/// land in the same round.
1033#[derive(Debug, Clone, PartialEq, Eq)]
1034pub enum PrOutcome {
1035    /// Merged, tested green on its own speculative state, and part of
1036    /// [`QueuedRound::tip`].
1037    Landed,
1038    /// Merge conflict: evicted for its author to resolve, first-class,
1039    /// without blocking anything behind it (D6).
1040    Conflicted,
1041    /// CI failed on this change's own speculative state.
1042    Failed,
1043    /// Ejected because it declared a dependency on a change that
1044    /// failed. Not a verdict on this change.
1045    Ejected {
1046        /// The failing change it depends on.
1047        on: u64,
1048    },
1049    /// Already in by patch identity: the train rewrote or landed this
1050    /// edit earlier, so it was recognized rather than re-merged.
1051    AlreadyLanded,
1052    /// A merge strategy resolved beyond what the change proposed. Git's
1053    /// merge cannot produce this; it is here because the map from
1054    /// [`choir_queue::Rejection`] must be total, and a case dropped on
1055    /// the floor is a PR that gets no status at all.
1056    Unsafe,
1057    /// The round stopped before reaching this change. Nothing was
1058    /// decided about it and it is still waiting.
1059    Waiting,
1060}
1061
1062/// Result of one queued round.
1063#[derive(Debug, Clone, PartialEq, Eq)]
1064pub struct QueuedRound {
1065    /// The state the queue ended on: the commit to land, equal to the
1066    /// base when nothing landed.
1067    pub tip: String,
1068    /// Every pull request the round was given, in submission order.
1069    pub outcomes: Vec<(u64, PrOutcome)>,
1070    /// Why the round stopped early without blaming anybody: an
1071    /// executor that could not answer, or a merge we could not run.
1072    /// Distinct from every outcome above, because it is about us.
1073    pub stalled: Option<String>,
1074}
1075
1076/// Reads one change's outcome as the status its pull request gets.
1077///
1078/// Total by construction, and asserted as a correspondence rather than
1079/// as a table of literals: `green` exactly where the change is in, and
1080/// `failure` -- the state that asks an author to do something --
1081/// exactly where the change itself is what went wrong.
1082#[must_use]
1083pub fn pr_report(outcome: &PrOutcome) -> TrainReport {
1084    match outcome {
1085        PrOutcome::Landed => TrainReport {
1086            green: true,
1087            state: "success",
1088            description: "merged and green on the speculative train",
1089        },
1090        PrOutcome::AlreadyLanded => TrainReport {
1091            green: true,
1092            state: "success",
1093            description: "already landed (patch identity)",
1094        },
1095        PrOutcome::Conflicted => TrainReport {
1096            green: false,
1097            state: "failure",
1098            description: "merge conflict; resolve and resubmit",
1099        },
1100        PrOutcome::Failed => TrainReport {
1101            green: false,
1102            state: "failure",
1103            description: "train CI failed on this change",
1104        },
1105        PrOutcome::Unsafe => TrainReport {
1106            green: false,
1107            state: "failure",
1108            description: "merge resolution edited beyond the change",
1109        },
1110        PrOutcome::Ejected { .. } => TrainReport {
1111            green: false,
1112            state: "error",
1113            description: "ejected: a change it depends on failed",
1114        },
1115        PrOutcome::Waiting => TrainReport {
1116            green: false,
1117            state: "error",
1118            description: "queue stalled before reaching this change",
1119        },
1120    }
1121}
1122
1123/// Runs one speculative round through the D5 merge queue.
1124///
1125/// This is [`build_train`]'s successor and differs from it in what a
1126/// verdict is about. `build_train` merges everything and asks CI once,
1127/// so one red change makes every PR in the train red. The queue tests
1128/// each change against the state produced by everything ahead of it,
1129/// which is the "exactly as if they had been tested one at a time"
1130/// property D5 exists for: the green prefix lands, the failure is
1131/// blamed on the change that failed, and the window narrows.
1132///
1133/// Nothing speculative leaves the machine and no ref is moved here. The
1134/// caller decides whether to land [`QueuedRound::tip`].
1135///
1136/// # Errors
1137///
1138/// The base or a pull request head does not resolve, or the executor
1139/// speaks a protocol this build does not.
1140pub fn run_queue(
1141    repo: &Path,
1142    base: &str,
1143    prs: &[(u64, String)],
1144    spec: &CommandSpec,
1145    ci: &mut dyn CiExecutor,
1146) -> Result<QueuedRound, String> {
1147    let info = ci.info().map_err(|error| error.to_string())?;
1148    if info.protocol != PROTOCOL {
1149        return Err(format!(
1150            "executor `{}` speaks protocol {} and this build speaks {PROTOCOL}",
1151            info.name, info.protocol
1152        ));
1153    }
1154
1155    let speculator = choir_queue::git::GitSpeculator::new(repo.to_path_buf());
1156    let base_oid = speculator.verify(base)?;
1157    let mut queue = choir_queue::MergeQueue::with_speculator(
1158        &base_oid,
1159        Box::new(choir_queue::git::GitSpeculator::new(repo.to_path_buf())),
1160    );
1161
1162    let mut command = Vec::with_capacity(spec.args.len() + 1);
1163    command.push(spec.program.clone());
1164    command.extend(spec.args.iter().cloned());
1165    queue.set_job_template(choir_queue::JobTemplate {
1166        command,
1167        environment: effective_environment(&spec.env),
1168        deadline: spec.timeout_seconds.map(Duration::from_secs),
1169        // The train is speculative by definition: a shared cache
1170        // written from a state nobody approved is the CREEP shape, and
1171        // the queue is exactly where an attacker would reach it.
1172        may_write_cache: false,
1173    });
1174
1175    for (id, head) in prs {
1176        let oid = speculator.verify(head)?;
1177        queue.submit(choir_queue::Change {
1178            id: *id,
1179            workspace: format!("pr/{id}"),
1180            base: base_oid.clone(),
1181            proposed: oid,
1182            depends: Vec::new(),
1183        });
1184    }
1185
1186    // In memory, because the forge is canonical here and choir mirrors
1187    // it (D21): the landings this records are the queue's own ordering
1188    // of a round, not a claim about the repository, and nothing outside
1189    // this call reads them.
1190    let report = queue.drain_in_memory(ci);
1191
1192    let mut outcomes: Vec<(u64, PrOutcome)> = Vec::with_capacity(prs.len());
1193    for (id, _) in prs {
1194        let outcome = if report.merged.contains(id) {
1195            PrOutcome::Landed
1196        } else if let Some((_, why)) = report.rejected.iter().find(|(r, _)| r == id) {
1197            match why {
1198                choir_queue::Rejection::Conflict => PrOutcome::Conflicted,
1199                choir_queue::Rejection::CiFailure => PrOutcome::Failed,
1200                choir_queue::Rejection::SafetyViolation { .. } => PrOutcome::Unsafe,
1201                choir_queue::Rejection::DependencyEjection { on } => PrOutcome::Ejected { on: *on },
1202                choir_queue::Rejection::AlreadyLanded => PrOutcome::AlreadyLanded,
1203            }
1204        } else {
1205            PrOutcome::Waiting
1206        };
1207        outcomes.push((*id, outcome));
1208    }
1209
1210    Ok(QueuedRound {
1211        tip: report.final_state,
1212        outcomes,
1213        stalled: report.provider_error,
1214    })
1215}