Rebase ci validate onto bare repo via sha
quire ci validate now works against the git repo in the current
directory, reading ci.fnl from a commit SHA (default HEAD). Removes
validate_file from Ci since all validation goes through git show.
Discovers the repo via git rev-parse --show-toplevel.

Assisted-by: GLM-5.1 via pi
change rqxoxottnkpuzmrqwtmzostmsswuoxpp
commit b4d49a858f69b39ab6e23a92ec423e4ca5f04984
author Alpha Chen <alpha@kejadlen.dev>
date
parent rwpowywm
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 7480dd9..a286685 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -1,14 +1,22 @@
-use miette::Result;
+use std::path::PathBuf;
 
-use quire::ci::{Ci, ValidationError};
+use miette::{IntoDiagnostic, Result};
+use quire::ci::Ci;
 
-/// Validate a ci.fnl file without executing any jobs.
+/// Validate a repo's ci.fnl without executing any jobs.
 ///
-/// Evaluates the Fennel source to extract the job registration table,
-/// then runs the four structural validations. Prints each job found
-/// and any validation errors.
-pub async fn validate(path: &std::path::Path) -> Result<()> {
-    let result = Ci::validate_file(path)?;
+/// Evaluates the Fennel source at the given SHA (or HEAD) to extract
+/// the job registration table, then runs the four structural validations.
+/// Prints each job found and any validation errors.
+pub async fn validate(sha: Option<&str>) -> Result<()> {
+    let repo_path = discover_repo()?;
+    let sha = sha.unwrap_or("HEAD");
+    let ci = Ci::new(repo_path);
+
+    let Some(result) = ci.eval(sha)? else {
+        println!("No ci.fnl found at {sha}.");
+        return Ok(());
+    };
 
     if result.jobs.is_empty() {
         println!("No jobs registered.");
@@ -29,16 +37,16 @@ pub async fn validate(path: &std::path::Path) -> Result<()> {
             println!("\nValidation errors:");
             for err in &errors {
                 let label = match err {
-                    ValidationError::Cycle { cycle_jobs } => {
+                    quire::ci::ValidationError::Cycle { cycle_jobs } => {
                         format!("cycle: {}", cycle_jobs.join(" → "))
                     }
-                    ValidationError::EmptyInputs { job_id } => {
+                    quire::ci::ValidationError::EmptyInputs { job_id } => {
                         format!("{job_id}: empty inputs")
                     }
-                    ValidationError::Unreachable { job_id } => {
+                    quire::ci::ValidationError::Unreachable { job_id } => {
                         format!("{job_id}: unreachable from any source ref")
                     }
-                    ValidationError::ReservedSlash { job_id } => {
+                    quire::ci::ValidationError::ReservedSlash { job_id } => {
                         format!("{job_id}: '/' in job id")
                     }
                 };
@@ -50,3 +58,19 @@ pub async fn validate(path: &std::path::Path) -> Result<()> {
 
     Ok(())
 }
+
+/// Find the git repo root from the current working directory.
+fn discover_repo() -> Result<PathBuf> {
+    let output = std::process::Command::new("git")
+        .args(["rev-parse", "--show-toplevel"])
+        .output()
+        .into_diagnostic()?;
+
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        miette::bail!("not in a git repository: {stderr}");
+    }
+
+    let path = String::from_utf8(output.stdout).into_diagnostic()?;
+    Ok(PathBuf::from(path.trim()))
+}
diff --git a/src/bin/quire/main.rs b/src/bin/quire/main.rs
index 9c3621b..de0eedc 100644
--- a/src/bin/quire/main.rs
+++ b/src/bin/quire/main.rs
@@ -78,11 +78,11 @@ enum RepoCommands {
 
 #[derive(Subcommand)]
 enum CiCommands {
-    /// Validate a ci.fnl file without running any jobs.
+    /// Validate a repo's ci.fnl without running any jobs.
     Validate {
-        /// Path to the ci.fnl file to validate.
-        #[arg(default_value = ".quire/ci.fnl")]
-        path: std::path::PathBuf,
+        /// Commit SHA to validate. Defaults to HEAD.
+        #[arg(short, long)]
+        sha: Option<String>,
     },
 }
 
@@ -152,7 +152,7 @@ async fn main() -> Result<()> {
             RepoCommands::Rm { name } => commands::repo::rm(&quire, &name).await?,
         },
         Commands::Ci { command } => match command {
-            CiCommands::Validate { path } => commands::ci::validate(&path).await?,
+            CiCommands::Validate { sha } => commands::ci::validate(sha.as_deref()).await?,
         },
     }
 
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 189d65d..1495d78 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -6,7 +6,7 @@ pub mod run;
 pub use graph::{EvalResult, JobDef, ValidationError, eval_ci, validate};
 pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
 
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
 
 use crate::Result;
 use crate::event::{PushEvent, PushRef};
@@ -25,7 +25,7 @@ pub struct Ci {
 }
 
 impl Ci {
-    pub(crate) fn new(repo_path: PathBuf) -> Self {
+    pub fn new(repo_path: PathBuf) -> Self {
         Self { repo_path }
     }
 
@@ -58,16 +58,6 @@ impl Ci {
         Ok(Some(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 = crate::fennel::Fennel::new()?;
-        let result = eval_ci(&fennel, &source, &name)?;
-        validate(&result.jobs)?;
-        Ok(result)
-    }
-
     /// Read the contents of `.quire/ci.fnl` at a given commit SHA.
     ///
     /// Returns `Ok(None)` if the file does not exist at that commit,