git-ents.gitmain
⌘K
foforge
commit 64cda92
feat: record check runs as refs and show them on the Checks tab

Each push’s outcomes are now persisted as a ref of their own, refs/checks/<commit>, whose tree is a RunDoc written through facet-git-tree — a run is a typed value living in git, like the check set. The post-receive runner records every check’s pass/fail/error after running it, and the Checks tab’s "Recent runs" card lists them newest first.

feat: add checks::{record,runs} storing runs at refs/checks/<commit> feat: record outcomes from the post-receive runner feat: render recent runs on the Checks tab Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents-server/src/checks.rs @@ -19,7 +19,7 @@ use std::path::Path; use std::process::{Command, Stdio}; -use git_ents::checks::{self, Check}; +use git_ents::checks::{self, Check, RunOutcome}; /// Where the pushed tree is unpacked inside the Sprite. const WORKDIR: &str = "/work"; @@ -59,7 +59,12 @@ update.ref_name ); sync_tree(&repo, &sprite, update.new)?; - run_checks(&sprite, &checks); + let outcomes = run_checks(&sprite, &checks); + // Persist the run as a ref (`refs/checks/<commit>`); a recording hiccup + // is reported but never fails the hook. + if let Err(e) = checks::record(&repo, update.new, &outcomes) { + eprintln!("checks: could not record run for {}: {e}", update.new); + } } Ok(()) } @@ -166,8 +171,10 @@ } /// Run each check in the Sprite's [`WORKDIR`], printing a `PASS`/`FAIL` line per -/// check and echoing the output of any that fail so the pusher sees why. -fn run_checks(sprite: &str, checks: &[Check]) { +/// check and echoing the output of any that fail so the pusher sees why. Returns +/// each check's outcome (`pass`/`fail`/`error`) for recording. +fn run_checks(sprite: &str, checks: &[Check]) -> Vec<RunOutcome> { + let mut outcomes = Vec::with_capacity(checks.len()); for check in checks { let output = Command::new("sprite") .args([ @@ -182,9 +189,10 @@ &check.command, ]) .output(); - match output { + let outcome = match output { Ok(output) if output.status.success() => { println!("checks: PASS {}", check.name); + "pass" } Ok(output) => { println!("checks: FAIL {} ({})", check.name, check.command); @@ -197,10 +205,17 @@ for line in logs.lines() { println!("checks: {line}"); } + "fail" } Err(e) => { println!("checks: ERROR {} (could not run: {e})", check.name); + "error" } - } + }; + outcomes.push(RunOutcome { + name: check.name.clone(), + outcome: outcome.to_owned(), + }); } + outcomes }
crates/git-ents/src/checks.rs @@ -201,12 +201,209 @@ } } +/// The namespace under which a push's check outcomes are recorded: one ref, +/// `refs/checks/<commit>`, per checked commit. The ref points at a commit whose +/// tree is a [`RunDoc`] — a recorded run *is* a git ref. +pub const RESULTS_NS: &str = "refs/checks"; + +/// The recorded outcomes for one checked commit, stored at the run's ref: +/// `results/<name>` maps to that check's outcome. +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +struct RunDoc { + results: BTreeMap<String, String>, +} + +/// One check's outcome within a [`CheckRun`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunOutcome { + /// The check's name (its `checks/<name>` in [`CHECKS_REF`]). + pub name: String, + /// The outcome recorded for it — `pass`, `fail`, or `error`. + pub outcome: String, +} + +/// A recorded run: the commit that was checked and each check's outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckRun { + /// The checked commit's object id. + pub commit: String, + /// Each check's outcome, in name order. + pub results: Vec<RunOutcome>, +} + +/// Record `outcomes` for `commit` at `refs/checks/<commit>`, replacing any +/// previous run for that commit, as a new commit. The outcomes are written as a +/// [`RunDoc`] git tree through [`facet_git_tree`], so the run is a typed value +/// living in git, like the check set itself. +pub fn record(repo: &Path, commit: &str, outcomes: &[RunOutcome]) -> Result<(), Error> { + let doc = RunDoc { + results: outcomes + .iter() + .map(|outcome| (outcome.name.clone(), outcome.outcome.clone())) + .collect(), + }; + let odb = open_odb(repo).ok_or(Error::Odb)?; + let tree = facet_git_tree::serialize_into(&doc, &odb)?; + let refname = format!("{RESULTS_NS}/{commit}"); + let parent = ref_commit(repo, &refname); + let new_commit = commit_run(repo, &tree, parent.as_deref())?; + update_named_ref(repo, &refname, &new_commit) +} + +/// List the recorded runs, newest first. +pub fn runs(repo: &Path) -> Result<Vec<CheckRun>, Error> { + let refs = run_refs(repo)?; + if refs.is_empty() { + return Ok(Vec::new()); + } + let odb = open_odb(repo).ok_or(Error::Odb)?; + let prefix = format!("{RESULTS_NS}/"); + let mut runs = Vec::new(); + for refname in refs { + let Some(commit) = refname.strip_prefix(&prefix) else { + continue; + }; + let Some(tree) = ref_tree(repo, &refname) else { + continue; + }; + let doc: RunDoc = facet_git_tree::deserialize(&tree, &odb)?; + runs.push(CheckRun { + commit: commit.to_owned(), + results: doc + .results + .into_iter() + .map(|(name, outcome)| RunOutcome { name, outcome }) + .collect(), + }); + } + Ok(runs) +} + +/// List the `refs/checks/*` refs, newest committed first. +fn run_refs(repo: &Path) -> Result<Vec<String>, Error> { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args([ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname)", + RESULTS_NS, + ]) + .output() + .map_err(|_source| Error::Git { + operation: "for-each-ref", + })?; + if !output.status.success() { + return Err(Error::Git { + operation: "for-each-ref", + }); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::to_owned) + .collect()) +} + +/// Resolve `refname` to the object id of its tree, or `None` when it is absent. +fn ref_tree(repo: &Path, refname: &str) -> Option<ObjectId> { + let spec = format!("{refname}^{{tree}}"); + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-parse", "--verify", "--quiet", &spec]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let hex = String::from_utf8(output.stdout).ok()?; + ObjectId::from_hex(hex.trim().as_bytes()).ok() +} + +/// Resolve `refname` to the object id of its commit, or `None` when it is +/// absent. +fn ref_commit(repo: &Path, refname: &str) -> Option<String> { + let spec = format!("{refname}^{{commit}}"); + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-parse", "--verify", "--quiet", &spec]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let hex = String::from_utf8(output.stdout).ok()?; + let hex = hex.trim(); + if hex.is_empty() { + None + } else { + Some(hex.to_owned()) + } +} + +/// Wrap a run `tree` in a commit, parenting on the run's previous ref when +/// present so re-runs accrue history. A fixed identity keeps the write +/// self-contained, independent of any ambient git config. +fn commit_run(repo: &Path, tree: &ObjectId, parent: Option<&str>) -> Result<String, Error> { + let mut args = vec!["commit-tree".to_owned(), tree.to_string()]; + if let Some(parent) = parent { + args.push("-p".to_owned()); + args.push(parent.to_owned()); + } + args.push("-m".to_owned()); + args.push("Record check run".to_owned()); + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(&args) + .env("GIT_AUTHOR_NAME", "git-ents") + .env("GIT_AUTHOR_EMAIL", "git-ents@localhost") + .env("GIT_COMMITTER_NAME", "git-ents") + .env("GIT_COMMITTER_EMAIL", "git-ents@localhost") + .output() + .map_err(|_source| Error::Git { + operation: "commit-tree", + })?; + if !output.status.success() { + return Err(Error::Git { + operation: "commit-tree", + }); + } + String::from_utf8(output.stdout) + .map(|stdout| stdout.trim().to_owned()) + .map_err(|_invalid| Error::Git { + operation: "commit-tree", + }) +} + +/// Point `refname` at `commit`. +fn update_named_ref(repo: &Path, refname: &str, commit: &str) -> Result<(), Error> { + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(["update-ref", refname, commit]) + .status() + .map_err(|_source| Error::Git { + operation: "update-ref", + })?; + if status.success() { + Ok(()) + } else { + Err(Error::Git { + operation: "update-ref", + }) + } +} + #[cfg(test)] mod tests { #![allow( clippy::unwrap_used, clippy::panic, clippy::arithmetic_side_effects, + clippy::indexing_slicing, clippy::let_underscore_must_use, reason = "unit test" )] @@ -271,4 +468,51 @@ assert!(load(&repo).unwrap().is_empty()); let _ = std::fs::remove_dir_all(&repo); } + + fn outcome(name: &str, outcome: &str) -> RunOutcome { + RunOutcome { + name: name.to_owned(), + outcome: outcome.to_owned(), + } + } + + #[test] + fn record_then_runs_round_trips_a_run() { + let repo = unique_repo(); + let commit = "0123456789012345678901234567890123456789"; + record( + &repo, + commit, + &[outcome("fmt", "pass"), outcome("test", "fail")], + ) + .unwrap(); + + let runs = runs(&repo).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].commit, commit); + assert_eq!( + runs[0].results, + vec![outcome("fmt", "pass"), outcome("test", "fail")] + ); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn recording_a_commit_again_replaces_its_run() { + let repo = unique_repo(); + let commit = "0123456789012345678901234567890123456789"; + record(&repo, commit, &[outcome("fmt", "fail")]).unwrap(); + record(&repo, commit, &[outcome("fmt", "pass")]).unwrap(); + let runs = runs(&repo).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].results, vec![outcome("fmt", "pass")]); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn empty_when_no_runs_recorded() { + let repo = unique_repo(); + assert!(runs(&repo).unwrap().is_empty()); + let _ = std::fs::remove_dir_all(&repo); + } }
crates/git-ents-server/src/web/pages.rs @@ -634,6 +634,7 @@ /// reflects the live set; runs are not yet recorded, so that panel is empty. pub(super) async fn checks_page(repo: &Path, meta: &RepoMeta) -> Markup { let checks = load_checks(repo).await; + let runs = load_runs(repo).await; repo_shell( meta, Tab::Checks, @@ -642,12 +643,24 @@ div.page-header { h1.page-title { "Checks" } } p.shell-note { "Checks are configured on " code { "refs/meta/checks" } - " (" code { "git ents checks list" } ") and run in a Sprite on each push." + " (" code { "git ents checks list" } ") and run in a Sprite on each push; " + "each run is recorded under " code { "refs/checks/<commit>" } "." } div.checks-grid { div.card { div.card-header { "Recent runs" } - div.card-row.muted { "No runs recorded yet." } + @match &runs { + Err(err) => div.card-row.muted { "Could not read runs: " (err) } + Ok(runs) if runs.is_empty() => div.card-row.muted { "No runs recorded yet." } + Ok(runs) => { + @for run in runs.iter().take(25) { + div.card-row.signer-row { + code.key { (run.commit.get(..8).unwrap_or(&run.commit)) } + span.muted { (run_summary(run)) } + } + } + } + } } div.card { div.card-header { @@ -686,6 +699,24 @@ .map_err(|err| err.to_string()) } +/// Load the recorded runs off the async runtime, like [`load_checks`]. +async fn load_runs(repo: &Path) -> Result<Vec<git_ents::checks::CheckRun>, String> { + let repo = repo.to_owned(); + tokio::task::spawn_blocking(move || git_ents::checks::runs(&repo)) + .await + .map_err(|err| err.to_string())? + .map_err(|err| err.to_string()) +} + +/// A one-line summary of a run's outcomes, e.g. `fmt pass · test fail`. +fn run_summary(run: &git_ents::checks::CheckRun) -> String { + run.results + .iter() + .map(|result| format!("{} {}", result.name, result.outcome)) + .collect::<Vec<_>>() + .join(" · ") +} + /// The Issues ("Bug reports") tab. There is no issue store yet, so the filters /// are present for the design and the list is an empty state. pub(super) fn issues_page(meta: &RepoMeta) -> Markup {