feat: record check runs as refs and show them on the Checks tab
commit
64cda92feat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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 {