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
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();