Fold Runs and CI operations into a single Ci type
Repo::ci() returns a Ci handle that owns both the run lifecycle
(previously Runs) and the eval/validation methods (previously free
functions in ci/mod.rs). has_ci_fnl and ci_fnl_source move from
Repo to Ci as private helpers. The Runs type is removed entirely.

Assisted-by: GLM-5.1 via pi
change plryxxxtzyrormnnyknruuoozmwotnxp
commit 7a473ad43b2c7c17cde489f301aca1d14dd31a41
author Alpha Chen <alpha@kejadlen.dev>
date
parent zmtolvyz
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 2d762fb..7480dd9 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -1,7 +1,6 @@
-use miette::{IntoDiagnostic, Result};
+use miette::Result;
 
-use quire::ci::{ValidationError, eval_ci};
-use quire::fennel::Fennel;
+use quire::ci::{Ci, ValidationError};
 
 /// Validate a ci.fnl file without executing any jobs.
 ///
@@ -9,11 +8,7 @@ use quire::fennel::Fennel;
 /// then runs the four structural validations. Prints each job found
 /// and any validation errors.
 pub async fn validate(path: &std::path::Path) -> Result<()> {
-    let source = fs_err::read_to_string(path).into_diagnostic()?;
-    let name = path.display().to_string();
-
-    let fennel = Fennel::new()?;
-    let result = eval_ci(&fennel, &source, &name)?;
+    let result = Ci::validate_file(path)?;
 
     if result.jobs.is_empty() {
         println!("No jobs registered.");
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index a9882e9..f35e580 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -4,13 +4,216 @@ pub mod graph;
 pub mod run;
 
 pub use graph::{EvalResult, JobDef, ValidationError, eval_ci, validate};
-pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
+pub use run::{Run, RunMeta, RunState, RunTimes};
+
+use std::path::{Path, PathBuf};
 
 use crate::Result;
 use crate::event::{PushEvent, PushRef};
 use crate::fennel::Fennel;
 use crate::quire::Repo;
 
+/// Access to CI operations for a single repo.
+///
+/// Owns the base path for runs (`runs/<repo>/`) and provides eval,
+/// validation, and run lifecycle methods. Obtain one via `Repo::ci()`.
+pub struct Ci {
+    repo_path: PathBuf,
+    runs_base: PathBuf,
+}
+
+impl Ci {
+    pub(crate) fn new(repo_path: PathBuf, runs_base: PathBuf) -> Self {
+        Self {
+            repo_path,
+            runs_base,
+        }
+    }
+
+    // --- Eval & validation ---
+
+    /// Evaluate ci.fnl at a given SHA and return the registration table.
+    pub fn eval(&self, sha: &str) -> Result<EvalResult> {
+        let source = self.ci_fnl_source(sha)?;
+        let fennel = Fennel::new()?;
+        let name = format!("{sha}:.quire/ci.fnl");
+        let result = eval_ci(&fennel, &source, &name)?;
+        Ok(result)
+    }
+
+    /// Evaluate ci.fnl at a given SHA and validate the job graph.
+    pub fn validate_at(&self, sha: &str) -> Result<EvalResult> {
+        let result = self.eval(sha)?;
+        validate(&result.jobs)?;
+        Ok(result)
+    }
+
+    /// Evaluate a ci.fnl file from disk and validate the job graph.
+    pub fn validate_file(path: &Path) -> Result<EvalResult> {
+        let source = fs_err::read_to_string(path)?;
+        let name = path.display().to_string();
+        let fennel = Fennel::new()?;
+        let result = eval_ci(&fennel, &source, &name)?;
+        validate(&result.jobs)?;
+        Ok(result)
+    }
+
+    /// Check whether this bare repo has `.quire/ci.fnl` at a given commit SHA.
+    fn has_ci_fnl(&self, sha: &str) -> bool {
+        self.git(&["show", &format!("{sha}:.quire/ci.fnl")])
+            .stdout(std::process::Stdio::null())
+            .stderr(std::process::Stdio::null())
+            .status()
+            .map(|s| s.success())
+            .unwrap_or(false)
+    }
+
+    /// Read the contents of `.quire/ci.fnl` at a given commit SHA.
+    fn ci_fnl_source(&self, sha: &str) -> Result<String> {
+        let output = self
+            .git(&["show", &format!("{sha}:.quire/ci.fnl")])
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::piped())
+            .output()?;
+
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr);
+            return Err(crate::Error::Git(format!(
+                "failed to read ci.fnl at {sha}: {stderr}"
+            )));
+        }
+
+        Ok(String::from_utf8(output.stdout)?)
+    }
+
+    /// Start a git command rooted in this repo.
+    fn git(&self, args: &[&str]) -> std::process::Command {
+        let mut cmd = std::process::Command::new("git");
+        cmd.args(args).current_dir(&self.repo_path);
+        cmd
+    }
+
+    // --- Run lifecycle ---
+
+    /// Create a new run record in the `pending` state.
+    ///
+    /// Writes `meta.yml` and `times.yml` atomically (temp dir + rename).
+    pub fn create_run(&self, meta: &RunMeta) -> Result<Run> {
+        let pending_dir = self.runs_base.join(RunState::Pending.dir_name());
+        let id = uuid::Uuid::now_v7().to_string();
+
+        fs_err::create_dir_all(&pending_dir)?;
+
+        let tmp_dir = pending_dir.join(format!(".tmp-{id}"));
+        fs_err::create_dir_all(&tmp_dir)?;
+
+        run::write_yaml(&tmp_dir.join("meta.yml"), meta)?;
+        run::write_yaml(&tmp_dir.join("times.yml"), &RunTimes::default())?;
+
+        let final_dir = pending_dir.join(&id);
+        fs_err::rename(&tmp_dir, &final_dir)?;
+
+        Run::open(self.runs_base.clone(), RunState::Pending, id)
+    }
+
+    /// Scan for orphaned runs in `pending/` and `active/` directories.
+    pub fn scan_orphans(&self) -> Result<Vec<Run>> {
+        let mut orphans = Vec::new();
+
+        for &state in &[RunState::Pending, RunState::Active] {
+            let state_path = self.runs_base.join(state.dir_name());
+            let entries = match fs_err::read_dir(&state_path) {
+                Ok(entries) => entries,
+                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+                Err(e) => return Err(e.into()),
+            };
+
+            for entry in entries {
+                let entry = entry?;
+                let name = match entry.file_name().to_str() {
+                    Some(n) => n.to_string(),
+                    None => continue,
+                };
+
+                if name.starts_with('.') {
+                    continue;
+                }
+
+                match Run::open(self.runs_base.clone(), state, name.clone()) {
+                    Ok(run) => orphans.push(run),
+                    Err(e) => {
+                        tracing::warn!(
+                            state = ?state,
+                            run_id = %name,
+                            %e,
+                            "quarantining unreadable run to failed/"
+                        );
+                        self.quarantine(&state_path.join(&name), &name)?;
+                    }
+                }
+            }
+        }
+
+        Ok(orphans)
+    }
+
+    /// Move a broken run directory into `failed/`.
+    fn quarantine(&self, src: &Path, id: &str) -> Result<()> {
+        let failed_dir = self.runs_base.join(RunState::Failed.dir_name());
+        fs_err::create_dir_all(&failed_dir)?;
+        fs_err::rename(src, failed_dir.join(id))?;
+        Ok(())
+    }
+
+    /// Reconcile orphaned runs from a previous server instance.
+    pub fn reconcile_orphans(&self) -> Result<()> {
+        let orphans = self.scan_orphans()?;
+        for orphan in &orphans {
+            tracing::warn!(
+                run_id = %orphan.id(),
+                state = ?orphan.state(),
+                "found orphaned run"
+            );
+        }
+
+        for mut orphan in orphans {
+            match orphan.state() {
+                RunState::Pending => {
+                    tracing::warn!(
+                        run_id = %orphan.id(),
+                        "completing orphaned pending run"
+                    );
+                    if let Err(e) = orphan.transition(RunState::Complete) {
+                        tracing::error!(
+                            run_id = %orphan.id(),
+                            %e,
+                            "failed to transition orphaned pending run"
+                        );
+                    }
+                }
+                RunState::Active => {
+                    tracing::warn!(
+                        run_id = %orphan.id(),
+                        "marking orphaned active run as failed"
+                    );
+                    if let Err(e) = orphan.transition(RunState::Failed) {
+                        tracing::error!(
+                            run_id = %orphan.id(),
+                            %e,
+                            "failed to transition orphaned active run to failed"
+                        );
+                    }
+                }
+                RunState::Complete | RunState::Failed => {
+                    unreachable!("scan_orphans only returns pending/active")
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
 /// Trigger CI for a push event: scan each updated ref for `.quire/ci.fnl`,
 /// create a run, and evaluate + validate it.
 pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
@@ -39,11 +242,10 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
 }
 
 /// Create and run CI for a single updated ref.
-///
-/// Returns `Ok(())` if CI ran (regardless of whether the run succeeded
-/// or failed), or `Err` if the trigger itself failed.
 fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> Result<()> {
-    if !repo.has_ci_fnl(&push_ref.new_sha) {
+    let ci = repo.ci();
+
+    if !ci.has_ci_fnl(&push_ref.new_sha) {
         return Ok(());
     }
 
@@ -53,7 +255,7 @@ fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> R
         pushed_at,
     };
 
-    let mut run = repo.runs().create(&meta)?;
+    let mut run = ci.create_run(&meta)?;
 
     tracing::info!(
         run_id = %run.id(),
@@ -64,26 +266,16 @@ fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> R
 
     run.transition(RunState::Active)?;
 
-    let result = eval_and_validate(repo, &push_ref.new_sha);
+    let result = ci.validate_at(&push_ref.new_sha);
     match result {
-        Ok(()) => {
+        Ok(_) => {
             run.transition(RunState::Complete)?;
         }
         Err(e) => {
             run.transition(RunState::Failed)?;
-            // Return the eval/validation error as the dispatch error.
             Err(e)?;
         }
     }
 
     Ok(())
 }
-
-/// Evaluate ci.fnl at a given SHA and validate the job graph.
-fn eval_and_validate(repo: &Repo, sha: &str) -> Result<()> {
-    let source = repo.ci_fnl_source(sha)?;
-    let fennel = Fennel::new()?;
-    let eval_result = eval_ci(&fennel, &source, &format!("{sha}:.quire/ci.fnl"))?;
-    validate(&eval_result.jobs)?;
-    Ok(())
-}
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 6fc5c6c..4fe7a37 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -55,158 +55,6 @@ pub struct RunTimes {
     pub finished_at: Option<Timestamp>,
 }
 
-/// Access to CI runs for a single repo.
-///
-/// Owns the base path (`runs/<repo>/`) and provides run creation.
-/// Obtain one via `Repo::runs()`.
-#[derive(Debug)]
-pub struct Runs {
-    base: PathBuf,
-}
-
-impl Runs {
-    pub fn new(base: PathBuf) -> Self {
-        Self { base }
-    }
-
-    /// Create a new run record in the `pending` state.
-    ///
-    /// Writes `meta.yml` and `times.yml` atomically (temp dir + rename).
-    pub fn create(&self, meta: &RunMeta) -> Result<Run> {
-        let pending_dir = self.base.join(RunState::Pending.dir_name());
-        let id = uuid::Uuid::now_v7().to_string();
-
-        fs_err::create_dir_all(&pending_dir)?;
-
-        let tmp_dir = pending_dir.join(format!(".tmp-{id}"));
-        fs_err::create_dir_all(&tmp_dir)?;
-
-        write_yaml(&tmp_dir.join("meta.yml"), meta)?;
-        write_yaml(&tmp_dir.join("times.yml"), &RunTimes::default())?;
-
-        let final_dir = pending_dir.join(&id);
-        fs_err::rename(&tmp_dir, &final_dir)?;
-
-        Ok(Run {
-            base: self.base.clone(),
-            state: RunState::Pending,
-            id,
-        })
-    }
-
-    /// Scan for orphaned runs in `pending/` and `active/` directories.
-    ///
-    /// Entries that cannot be opened (missing/unreadable `meta.yml` or
-    /// `times.yml`) are quarantined to `failed/` so they don't stay
-    /// stuck in pending/active forever.
-    ///
-    /// The caller decides how to reconcile the returned runs:
-    /// - `pending/` entries should be re-enqueued.
-    /// - `active/` entries with no live runner should be marked failed.
-    pub fn scan_orphans(&self) -> Result<Vec<Run>> {
-        let mut orphans = Vec::new();
-
-        for &state in &[RunState::Pending, RunState::Active] {
-            let state_path = self.base.join(state.dir_name());
-            let entries = match fs_err::read_dir(&state_path) {
-                Ok(entries) => entries,
-                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
-                Err(e) => return Err(e.into()),
-            };
-
-            for entry in entries {
-                let entry = entry?;
-                let name = match entry.file_name().to_str() {
-                    Some(n) => n.to_string(),
-                    None => continue,
-                };
-
-                // Skip temp files.
-                if name.starts_with('.') {
-                    continue;
-                }
-
-                match Run::open(self.base.clone(), state, name.clone()) {
-                    Ok(run) => orphans.push(run),
-                    Err(e) => {
-                        tracing::warn!(
-                            state = ?state,
-                            run_id = %name,
-                            %e,
-                            "quarantining unreadable run to failed/"
-                        );
-                        self.quarantine(&state_path.join(&name), &name)?;
-                    }
-                }
-            }
-        }
-
-        Ok(orphans)
-    }
-
-    /// Move a broken run directory into `failed/` so it stops blocking
-    /// pending/active. The contents may be unreadable; we only care
-    /// about getting it out of the active state buckets.
-    fn quarantine(&self, src: &Path, id: &str) -> Result<()> {
-        let failed_dir = self.base.join(RunState::Failed.dir_name());
-        fs_err::create_dir_all(&failed_dir)?;
-        fs_err::rename(src, failed_dir.join(id))?;
-        Ok(())
-    }
-
-    /// Reconcile orphaned runs from a previous server instance.
-    ///
-    /// - `pending/` orphans are moved to `complete/` (will be re-enqueued when
-    ///   the runner exists; for now, immediately completed).
-    /// - `active/` orphans are moved to `failed/` (no live runner).
-    pub fn reconcile_orphans(&self) -> Result<()> {
-        let orphans = self.scan_orphans()?;
-        for orphan in &orphans {
-            tracing::warn!(
-                run_id = %orphan.id(),
-                state = ?orphan.state(),
-                "found orphaned run"
-            );
-        }
-
-        for mut orphan in orphans {
-            match orphan.state() {
-                RunState::Pending => {
-                    tracing::warn!(
-                        run_id = %orphan.id(),
-                        "completing orphaned pending run"
-                    );
-                    if let Err(e) = orphan.transition(RunState::Complete) {
-                        tracing::error!(
-                            run_id = %orphan.id(),
-                            %e,
-                            "failed to transition orphaned pending run"
-                        );
-                    }
-                }
-                RunState::Active => {
-                    tracing::warn!(
-                        run_id = %orphan.id(),
-                        "marking orphaned active run as failed"
-                    );
-                    if let Err(e) = orphan.transition(RunState::Failed) {
-                        tracing::error!(
-                            run_id = %orphan.id(),
-                            %e,
-                            "failed to transition orphaned active run to failed"
-                        );
-                    }
-                }
-                RunState::Complete | RunState::Failed => {
-                    unreachable!("scan_orphans only returns pending/active")
-                }
-            }
-        }
-
-        Ok(())
-    }
-}
-
 /// A CI run on disk.
 ///
 /// Owns the path to the run directory. Tracks current state so that
@@ -311,7 +159,7 @@ impl Run {
 }
 
 /// Write a serializable value to a YAML file atomically (temp file + rename).
-fn write_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
+pub(crate) fn write_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
     let tmp_path = path.with_extension("yml.tmp");
     let f = fs_err::File::create(&tmp_path)?;
     serde_yaml_ng::to_writer(std::io::BufWriter::new(f), value)?;
@@ -329,6 +177,7 @@ fn read_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
 mod tests {
     use super::*;
     use crate::Quire;
+    use crate::ci::Ci;
 
     fn tmp_quire() -> (tempfile::TempDir, Quire) {
         let dir = tempfile::tempdir().expect("tempdir");
@@ -336,6 +185,13 @@ mod tests {
         (dir, quire)
     }
 
+    fn test_ci(quire: &Quire) -> Ci {
+        Ci::new(
+            quire.repos_dir().join("test.git"),
+            quire.base_dir().join("runs").join("test.git"),
+        )
+    }
+
     fn test_meta() -> RunMeta {
         RunMeta {
             sha: "abc123".to_string(),
@@ -355,8 +211,8 @@ mod tests {
     #[test]
     fn create_generates_uuidv7_id() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let run = ci.create_run(&test_meta()).expect("create");
         let parsed = uuid::Uuid::parse_str(run.id()).expect("should be valid UUID");
         assert_eq!(parsed.get_version(), Some(uuid::Version::SortRand));
     }
@@ -364,8 +220,8 @@ mod tests {
     #[test]
     fn create_writes_files_in_pending() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let run = ci.create_run(&test_meta()).expect("create");
 
         let path = run.path();
         assert!(path.exists(), "run directory should exist");
@@ -384,8 +240,8 @@ mod tests {
     #[test]
     fn transition_moves_directory() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let mut run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let mut run = ci.create_run(&test_meta()).expect("create");
         let id = run.id().to_string();
 
         let old_path = run.path();
@@ -406,8 +262,8 @@ mod tests {
     #[test]
     fn transition_stamps_started_at_on_active() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let mut run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let mut run = ci.create_run(&test_meta()).expect("create");
 
         run.transition(RunState::Active).expect("to active");
         let times = run.read_times().expect("read state");
@@ -418,9 +274,9 @@ mod tests {
     #[test]
     fn transition_stamps_finished_at_on_complete_and_failed() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let ci = test_ci(&quire);
 
-        let mut completed = runs.create(&test_meta()).expect("create");
+        let mut completed = ci.create_run(&test_meta()).expect("create");
         completed.transition(RunState::Active).expect("to active");
         completed
             .transition(RunState::Complete)
@@ -428,7 +284,7 @@ mod tests {
         let times = completed.read_times().expect("read state");
         assert!(times.finished_at.is_some());
 
-        let mut failed = runs.create(&test_meta()).expect("create");
+        let mut failed = ci.create_run(&test_meta()).expect("create");
         failed.transition(RunState::Active).expect("to active");
         failed.transition(RunState::Failed).expect("to failed");
         let failed_times = failed.read_times().expect("read state");
@@ -438,14 +294,14 @@ mod tests {
     #[test]
     fn transition_rejects_invalid_transitions() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let ci = test_ci(&quire);
 
         // Pending -> Failed is not allowed (must go via Active).
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = ci.create_run(&test_meta()).expect("create");
         assert!(run.transition(RunState::Failed).is_err());
 
         // Terminal -> anything is not allowed.
-        let mut completed = runs.create(&test_meta()).expect("create");
+        let mut completed = ci.create_run(&test_meta()).expect("create");
         completed.transition(RunState::Active).expect("to active");
         completed
             .transition(RunState::Complete)
@@ -457,8 +313,8 @@ mod tests {
     #[test]
     fn transition_preserves_started_at_through_completion() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let mut run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let mut run = ci.create_run(&test_meta()).expect("create");
 
         run.transition(RunState::Active).expect("to active");
         let active_times = run.read_times().expect("read state");
@@ -472,8 +328,8 @@ mod tests {
     #[test]
     fn transition_full_lifecycle() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let mut run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let mut run = ci.create_run(&test_meta()).expect("create");
 
         run.transition(RunState::Active).expect("to active");
         run.transition(RunState::Complete).expect("to complete");
@@ -497,10 +353,10 @@ mod tests {
     #[test]
     fn scan_orphans_finds_pending() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let run = ci.create_run(&test_meta()).expect("create");
 
-        let orphans = runs.scan_orphans().expect("scan");
+        let orphans = ci.scan_orphans().expect("scan");
         assert_eq!(orphans.len(), 1);
         assert_eq!(orphans[0].id(), run.id());
         assert_eq!(orphans[0].state(), RunState::Pending);
@@ -509,11 +365,11 @@ mod tests {
     #[test]
     fn scan_orphans_finds_active() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let mut run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let mut run = ci.create_run(&test_meta()).expect("create");
         run.transition(RunState::Active).expect("transition");
 
-        let orphans = runs.scan_orphans().expect("scan");
+        let orphans = ci.scan_orphans().expect("scan");
         assert_eq!(orphans.len(), 1);
         assert_eq!(orphans[0].state(), RunState::Active);
     }
@@ -521,28 +377,28 @@ mod tests {
     #[test]
     fn scan_orphans_skips_complete() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let mut run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let mut run = ci.create_run(&test_meta()).expect("create");
         run.transition(RunState::Complete).expect("transition");
 
-        let orphans = runs.scan_orphans().expect("scan");
+        let orphans = ci.scan_orphans().expect("scan");
         assert!(orphans.is_empty(), "complete runs are not orphans");
     }
 
     #[test]
     fn scan_orphans_quarantines_unreadable_runs() {
         let (_dir, quire) = tmp_quire();
-        let base = quire.base_dir().join("runs").join("test.git");
-        let runs = Runs::new(base.clone());
+        let ci = test_ci(&quire);
 
         // Create a run, then break it by removing meta.yml.
-        let run = runs.create(&test_meta()).expect("create");
+        let run = ci.create_run(&test_meta()).expect("create");
         let id = run.id().to_string();
         fs_err::remove_file(run.path().join("meta.yml")).expect("remove meta");
 
-        let orphans = runs.scan_orphans().expect("scan");
+        let orphans = ci.scan_orphans().expect("scan");
         assert!(orphans.is_empty(), "broken run should not be returned");
 
+        let base = quire.base_dir().join("runs").join("test.git");
         let pending = base.join(RunState::Pending.dir_name()).join(&id);
         assert!(!pending.exists(), "broken run should leave pending/");
 
@@ -553,15 +409,15 @@ mod tests {
     #[test]
     fn scan_orphans_empty_when_no_runs_dir() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        assert!(runs.scan_orphans().expect("scan").is_empty());
+        let ci = test_ci(&quire);
+        assert!(ci.scan_orphans().expect("scan").is_empty());
     }
 
     #[test]
     fn write_times_updates_in_place() {
         let (_dir, quire) = tmp_quire();
-        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
-        let run = runs.create(&test_meta()).expect("create");
+        let ci = test_ci(&quire);
+        let run = ci.create_run(&test_meta()).expect("create");
 
         let started: Timestamp = "2026-04-28T12:00:01Z".parse().expect("parse");
         run.write_times(&RunTimes {
diff --git a/src/quire.rs b/src/quire.rs
index f7dcbc5..2e11cd5 100644
--- a/src/quire.rs
+++ b/src/quire.rs
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
 
 use miette::{Context, IntoDiagnostic, Result, ensure};
 
-use crate::ci::Runs;
+use crate::ci::Ci;
 use crate::fennel::Fennel;
 use crate::secret::SecretString;
 use crate::{Error, Result as AppResult};
@@ -149,39 +149,9 @@ impl Repo {
         cmd
     }
 
-    /// Access CI runs for this repo.
-    pub fn runs(&self) -> Runs {
-        Runs::new(self.base_dir.join("runs").join(&self.name))
-    }
-
-    /// Check whether this bare repo has `.quire/ci.fnl` at a given commit SHA.
-    ///
-    /// Returns true if the file exists (git show exit code 0), false otherwise.
-    pub fn has_ci_fnl(&self, sha: &str) -> bool {
-        self.git(&["show", &format!("{sha}:.quire/ci.fnl")])
-            .stdout(std::process::Stdio::null())
-            .stderr(std::process::Stdio::null())
-            .status()
-            .map(|s| s.success())
-            .unwrap_or(false)
-    }
-
-    /// Read the contents of `.quire/ci.fnl` at a given commit SHA.
-    pub fn ci_fnl_source(&self, sha: &str) -> AppResult<String> {
-        let output = self
-            .git(&["show", &format!("{sha}:.quire/ci.fnl")])
-            .stdout(std::process::Stdio::piped())
-            .stderr(std::process::Stdio::piped())
-            .output()?;
-
-        if !output.status.success() {
-            let stderr = String::from_utf8_lossy(&output.stderr);
-            return Err(Error::Git(format!(
-                "failed to read ci.fnl at {sha}: {stderr}"
-            )));
-        }
-
-        Ok(String::from_utf8(output.stdout)?)
+    /// Access CI operations for this repo.
+    pub fn ci(&self) -> Ci {
+        Ci::new(self.path(), self.base_dir.join("runs").join(&self.name))
     }
 
     /// Push `main` to the configured mirror, injecting the GitHub token via
diff --git a/src/server.rs b/src/server.rs
index d307f0a..a036e89 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -42,7 +42,7 @@ pub async fn run(quire: &Quire) -> Result<()> {
 
     // Scan for orphaned runs from a previous server instance.
     for repo in quire.repos().context("failed to list repos")? {
-        repo.runs().reconcile_orphans()?;
+        repo.ci().reconcile_orphans()?;
     }
 
     let quire_handle = quire.clone();