refactor: extract shared meta-ref plumbing into a git-store crate
commit 5d10a74
refactor: extract shared meta-ref plumbing into a git-store crate
The typed-document-on-a-ref plumbing was copy-pasted across signers and
checks: open the odb, rev-parse/peel the ref, commit-tree, update-ref,
walk history. It now lives once in a new git-store crate, built on the
high-level gix API (0.84, which unifies with facet-git-tree’s gix-object
0.61 / gix-odb 0.81) so refs no longer go through the git CLI. signers
and checks become thin delegations; their public types and on-disk tree
schema are unchanged.
feat: add git-store crate with a typed refs/meta store over gix 0.84
refactor: delegate signers load/store to git-store
refactor: delegate checks load/store/record/runs to git-store
build: bump workspace gix 0.83 → 0.84 and drop its malformed feature
build: drop facet-git-tree and gix-odb from git-ents deps
Assisted-by: Claude:claude-opus-4-8
crates/git-ents/src/checks.rs
@@ -2,43 +2,40 @@
//!
//! A check is anything a server runs against a push — CI, CD, linting,
//! versioning gates, and so on. Their definitions live in exactly one place:
-//! the `refs/meta/checks` ref. Its tree is a [`Checks`] document whose `checks/`
-//! subtree maps each check name to the command that runs it. The document is
-//! read and written with [`facet_git_tree`], so the check set is a typed value
-//! that lives in git — versioned, auditable, and itself pushable. Keeping it on
-//! a meta ref rather than in the worktree means an untrusted branch cannot
-//! rewrite the checks that gate it.
+//! the `refs/meta/checks` ref. Its tree is a [`Checks`] document mapping each
+//! check name to the command that runs it. The document is read and written
+//! through [`git_store`], so the check set is a typed value that lives in git —
+//! versioned, auditable, and itself pushable. Keeping it on a meta ref rather
+//! than in the worktree means an untrusted branch cannot rewrite the checks
+//! that gate it.
use std::collections::BTreeMap;
use std::path::Path;
-use std::process::Command;
use facet::Facet;
-use facet_git_tree::ObjectId;
/// The ref whose tree holds the configured check set.
pub const CHECKS_REF: &str = "refs/meta/checks";
-/// The check document stored at [`CHECKS_REF`]: `checks/<name>` maps to that
-/// check's definition (its command).
+/// The check document stored at [`CHECKS_REF`]: its `checks/` subtree maps each
+/// check name to that check's definition (its command).
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
struct Checks {
checks: BTreeMap<String, CheckDef>,
}
-/// One check's stored definition under `checks/<name>` in [`CHECKS_REF`]. A
-/// struct (rather than a bare command blob) so each check can grow per-check
-/// settings without a tree-format migration.
+/// One check's stored definition. A struct (rather than a bare command blob) so
+/// each check can grow per-check settings without a tree-format migration.
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
struct CheckDef {
/// The shell command run for the check.
command: String,
}
-/// One configured check recorded under `checks/` in [`CHECKS_REF`].
+/// One configured check recorded in [`CHECKS_REF`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
- /// The `checks/<name>` the definition is stored under — the check's name.
+ /// The name it is stored under.
pub name: String,
/// The shell command run for the check (e.g. `cargo fmt --check`).
pub command: String,
@@ -47,18 +44,9 @@
/// A failure reading or writing the check set.
#[derive(Debug, thiserror::Error)]
pub enum Error {
- /// The repository's object database could not be opened.
- #[error("could not open the repository object database")]
- Odb,
- /// The check set could not be (de)serialized from its git tree.
- #[error("could not (de)serialize the check set: {0}")]
- Facet(#[from] facet_git_tree::Error),
- /// A git invocation needed to read or update the ref failed.
- #[error("git {operation} failed")]
- Git {
- /// The git operation that failed.
- operation: &'static str,
- },
+ /// The check set could not be read from or written to its ref.
+ #[error(transparent)]
+ Store(#[from] git_store::Error),
}
/// Load the configured checks recorded at [`CHECKS_REF`] in `repo`.
@@ -67,12 +55,11 @@
/// been pushed yet. A present but unreadable ref is an error so callers can
/// distinguish corruption from "no checks configured".
pub fn load(repo: &Path) -> Result<Vec<Check>, Error> {
- let Some(tree) = checks_tree(repo) else {
+ let store = git_store::Store::open(repo)?;
+ let Some(document) = store.load::<Checks>(CHECKS_REF)? else {
return Ok(Vec::new());
};
- let odb = open_odb(repo).ok_or(Error::Odb)?;
- let checks: Checks = facet_git_tree::deserialize(&tree, &odb)?;
- Ok(checks
+ Ok(document
.checks
.into_iter()
.map(|(name, def)| Check {
@@ -98,123 +85,8 @@
})
.collect(),
};
- let odb = open_odb(repo).ok_or(Error::Odb)?;
- let tree = facet_git_tree::serialize_into(&document, &odb)?;
- let commit = commit_tree(repo, &tree)?;
- update_ref(repo, &commit)
-}
-
-/// Resolve [`CHECKS_REF`] to the object id of its tree, or `None` when the ref
-/// is absent.
-fn checks_tree(repo: &Path) -> Option<ObjectId> {
- let spec = format!("{CHECKS_REF}^{{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()
-}
-
-/// Open the repository's durable object database as a `gix` `Find`/`Write`
-/// backend.
-///
-/// Resolves the *common* git directory rather than `--git-path objects` so that
-/// inside a hook the durable store is read, never a receive-pack quarantine.
-fn open_odb(repo: &Path) -> Option<gix_odb::Handle> {
- let output = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["rev-parse", "--git-common-dir"])
- .output()
- .ok()?;
- if !output.status.success() {
- return None;
- }
- let git_dir = String::from_utf8(output.stdout).ok()?;
- gix_odb::at(repo.join(git_dir.trim()).join("objects")).ok()
-}
-
-/// Wrap `tree` in a commit, returning its object id. The commit parents on the
-/// current [`CHECKS_REF`] when present so updates fast-forward and accrue
-/// history; a fixed identity keeps the write self-contained, independent of any
-/// ambient git config.
-fn commit_tree(repo: &Path, tree: &ObjectId) -> Result<String, Error> {
- let mut args = vec!["commit-tree".to_owned(), tree.to_string()];
- if let Some(parent) = checks_commit(repo) {
- args.push("-p".to_owned());
- args.push(parent);
- }
- args.push("-m".to_owned());
- args.push("Update checks".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",
- })
-}
-
-/// Resolve [`CHECKS_REF`] to the object id of its commit, or `None` when the ref
-/// is absent.
-fn checks_commit(repo: &Path) -> Option<String> {
- let spec = format!("{CHECKS_REF}^{{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())
- }
-}
-
-/// Point [`CHECKS_REF`] at `commit`.
-fn update_ref(repo: &Path, commit: &str) -> Result<(), Error> {
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", CHECKS_REF, commit])
- .status()
- .map_err(|_source| Error::Git {
- operation: "update-ref",
- })?;
- if status.success() {
- Ok(())
- } else {
- Err(Error::Git {
- operation: "update-ref",
- })
- }
+ git_store::Store::open(repo)?.store(CHECKS_REF, &document, "Update checks")?;
+ Ok(())
}
/// The namespace under which a commit's check runs are recorded: one ref,
@@ -263,21 +135,14 @@
/// Record a run of `outcomes` for `commit` as a new commit on
/// `refs/meta/runs/<commit>`, parented on the prior run so the ref's commit
-/// chain is the run history. The outcomes are written as a [`RunDoc`] tree
-/// through [`facet_git_tree`]; the commit's date is the run time.
+/// chain is the run history. The commit's date is the run time.
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!("{RUNS_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)
+ git_store::Store::open(repo)?.store(
+ &format!("{RUNS_NS}/{commit}"),
+ &run_doc(outcomes),
+ "Record check run",
+ )?;
+ Ok(())
}
/// Advance the latest run recorded for `commit` to `outcomes`, in place. Unlike
@@ -288,72 +153,37 @@
/// When no run has been recorded yet the update starts one, so a worker that
/// advances a run is self-healing even if the `queued` record never landed.
pub fn update_run(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!("{RUNS_NS}/{commit}");
- let parent = ref_parent(repo, &refname);
- let new_commit = commit_run(repo, &tree, parent.as_deref())?;
- update_named_ref(repo, &refname, &new_commit)
-}
-
-/// The first parent of `refname`'s tip commit, or `None` when the tip is a root
-/// commit or the ref is absent.
-fn ref_parent(repo: &Path, refname: &str) -> Option<String> {
- let spec = format!("{refname}^");
- 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())
- }
+ git_store::Store::open(repo)?.amend(
+ &format!("{RUNS_NS}/{commit}"),
+ &run_doc(outcomes),
+ "Record check run",
+ )?;
+ Ok(())
}
/// List the recorded runs per commit, newest commit first. Each commit's runs
/// are the ref's commit chain, newest first, with the run time taken from each
/// commit's date.
pub fn runs(repo: &Path) -> Result<Vec<CommitRuns>, Error> {
- let refs = run_refs(repo)?;
- if refs.is_empty() {
- return Ok(Vec::new());
- }
- let odb = open_odb(repo).ok_or(Error::Odb)?;
+ let store = git_store::Store::open(repo)?;
let prefix = format!("{RUNS_NS}/");
let mut commits = Vec::new();
- for refname in refs {
+ for refname in store.list(&prefix)? {
let Some(commit) = refname.strip_prefix(&prefix) else {
continue;
};
- let mut runs = Vec::new();
- for (run_commit, at) in ref_history(repo, &refname)? {
- let Some(tree) = ref_tree(repo, &run_commit) else {
- continue;
- };
- let doc: RunDoc = facet_git_tree::deserialize(&tree, &odb)?;
- runs.push(Run {
+ let runs = store
+ .history::<RunDoc>(&refname)?
+ .into_iter()
+ .map(|(at, doc)| Run {
at,
results: doc
.results
.into_iter()
.map(|(name, outcome)| RunOutcome { name, outcome })
.collect(),
- });
- }
+ })
+ .collect();
commits.push(CommitRuns {
commit: commit.to_owned(),
runs,
@@ -362,142 +192,13 @@
Ok(commits)
}
-/// The commits on `refname` as `(object id, committer date)` pairs, newest
-/// first — one entry per recorded run.
-fn ref_history(repo: &Path, refname: &str) -> Result<Vec<(String, u64)>, Error> {
- let output = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["log", "--format=%H %ct", refname])
- .output()
- .map_err(|_source| Error::Git { operation: "log" })?;
- if !output.status.success() {
- return Err(Error::Git { operation: "log" });
- }
- Ok(String::from_utf8_lossy(&output.stdout)
- .lines()
- .filter_map(|line| {
- let (hash, ct) = line.split_once(' ')?;
- Some((hash.to_owned(), ct.parse().ok()?))
- })
- .collect())
-}
-
-/// List the `refs/meta/runs/*` 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)",
- RUNS_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",
- })
+/// Build a [`RunDoc`] from a run's `outcomes`.
+fn run_doc(outcomes: &[RunOutcome]) -> RunDoc {
+ RunDoc {
+ results: outcomes
+ .iter()
+ .map(|outcome| (outcome.name.clone(), outcome.outcome.clone()))
+ .collect(),
}
}
@@ -505,14 +206,13 @@
mod tests {
#![allow(
clippy::unwrap_used,
- clippy::panic,
- clippy::arithmetic_side_effects,
clippy::indexing_slicing,
clippy::let_underscore_must_use,
reason = "unit test"
)]
use std::path::PathBuf;
+ use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
crates/git-ents/src/signers.rs
@@ -1,32 +1,30 @@
//! The authorized signer set, sourced from the `refs/meta/auth` ref.
//!
//! Push authentication trusts exactly one place: the `refs/meta/auth` ref. Its
-//! tree is an [`Auth`] document whose `signers/` subtree maps each fingerprint
-//! to its OpenSSH public key. The document is read and written with
-//! [`facet_git_tree`], so the trust list is a typed value that lives in git —
-//! versioned, auditable, and itself pushable.
+//! tree is an [`Auth`] document mapping each fingerprint to its OpenSSH public
+//! key. The document is read and written through [`git_store`], so the trust
+//! list is a typed value that lives in git — versioned, auditable, and itself
+//! pushable.
use std::collections::BTreeMap;
use std::path::Path;
-use std::process::Command;
use facet::Facet;
-use facet_git_tree::ObjectId;
/// The ref whose tree holds the authorized signer set.
pub const AUTH_REF: &str = "refs/meta/auth";
-/// The authorization document stored at [`AUTH_REF`]: `signers/<fingerprint>`
-/// maps to the OpenSSH public key held there.
+/// The authorization document stored at [`AUTH_REF`]: its `signers/` subtree
+/// maps each fingerprint to the OpenSSH public key held there.
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
struct Auth {
signers: BTreeMap<String, String>,
}
-/// One authorized signer recorded under `signers/` in [`AUTH_REF`].
+/// One authorized signer recorded in [`AUTH_REF`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signer {
- /// The `signers/<name>` the key is stored under — its fingerprint.
+ /// The key it is stored under — its fingerprint.
pub fingerprint: String,
/// The OpenSSH public key the blob holds (`<type> <base64> [comment]`).
pub key: String,
@@ -35,18 +33,9 @@
/// A failure reading or writing the signer set.
#[derive(Debug, thiserror::Error)]
pub enum Error {
- /// The repository's object database could not be opened.
- #[error("could not open the repository object database")]
- Odb,
- /// The signer set could not be (de)serialized from its git tree.
- #[error("could not (de)serialize the signer set: {0}")]
- Facet(#[from] facet_git_tree::Error),
- /// A git invocation needed to read or update the ref failed.
- #[error("git {operation} failed")]
- Git {
- /// The git operation that failed.
- operation: &'static str,
- },
+ /// The signer set could not be read from or written to its ref.
+ #[error(transparent)]
+ Store(#[from] git_store::Error),
}
/// Load the authorized signers recorded at [`AUTH_REF`] in `repo`.
@@ -55,11 +44,10 @@
/// not been pushed yet. A present but unreadable ref is an error so callers can
/// fail closed rather than mistake corruption for "no signers".
pub fn load(repo: &Path) -> Result<Vec<Signer>, Error> {
- let Some(tree) = auth_tree(repo) else {
+ let store = git_store::Store::open(repo)?;
+ let Some(auth) = store.load::<Auth>(AUTH_REF)? else {
return Ok(Vec::new());
};
- let odb = open_odb(repo).ok_or(Error::Odb)?;
- let auth: Auth = facet_git_tree::deserialize(&tree, &odb)?;
Ok(auth
.signers
.into_iter()
@@ -78,10 +66,8 @@
.map(|signer| (signer.fingerprint.clone(), signer.key.clone()))
.collect(),
};
- let odb = open_odb(repo).ok_or(Error::Odb)?;
- let tree = facet_git_tree::serialize_into(&auth, &odb)?;
- let commit = commit_tree(repo, &tree)?;
- update_ref(repo, &commit)
+ git_store::Store::open(repo)?.store(AUTH_REF, &auth, "Update authorized signers")?;
+ Ok(())
}
/// Render `signers` as an OpenSSH `allowed_signers` file that authorizes any
@@ -99,132 +85,16 @@
.collect()
}
-/// Resolve [`AUTH_REF`] to the object id of its tree, or `None` when the ref is
-/// absent.
-fn auth_tree(repo: &Path) -> Option<ObjectId> {
- let spec = format!("{AUTH_REF}^{{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()
-}
-
-/// Open the repository's durable object database as a `gix` `Find`/`Write`
-/// backend.
-///
-/// Resolves the *common* git directory rather than `--git-path objects`: inside
-/// a `pre-receive` hook git points the latter at a quarantine holding only the
-/// incoming pack, while the current signer set lives in the durable store — and
-/// authorization is against the pre-push set, never the keys being pushed.
-fn open_odb(repo: &Path) -> Option<gix_odb::Handle> {
- let output = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["rev-parse", "--git-common-dir"])
- .output()
- .ok()?;
- if !output.status.success() {
- return None;
- }
- let git_dir = String::from_utf8(output.stdout).ok()?;
- gix_odb::at(repo.join(git_dir.trim()).join("objects")).ok()
-}
-
-/// Wrap `tree` in a commit, returning its object id. The commit parents on the
-/// current [`AUTH_REF`] when present so updates fast-forward and accrue history;
-/// a fixed identity keeps the write self-contained, independent of any ambient
-/// git config.
-fn commit_tree(repo: &Path, tree: &ObjectId) -> Result<String, Error> {
- let mut args = vec!["commit-tree".to_owned(), tree.to_string()];
- if let Some(parent) = auth_commit(repo) {
- args.push("-p".to_owned());
- args.push(parent);
- }
- args.push("-m".to_owned());
- args.push("Update authorized signers".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",
- })
-}
-
-/// Resolve [`AUTH_REF`] to the object id of its commit, or `None` when the ref
-/// is absent.
-fn auth_commit(repo: &Path) -> Option<String> {
- let spec = format!("{AUTH_REF}^{{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())
- }
-}
-
-/// Point [`AUTH_REF`] at `commit`.
-fn update_ref(repo: &Path, commit: &str) -> Result<(), Error> {
- let status = Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(["update-ref", AUTH_REF, 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::let_underscore_must_use,
reason = "unit test"
)]
use std::path::PathBuf;
+ use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
crates/git-store/src/lib.rs
@@ -1,0 +1,219 @@
+//! Typed documents on `refs/meta/*` refs, stored as git object graphs.
+//!
+//! A [`Store`] reads and writes [`Facet`] values to a ref's tree through
+//! [`facet_git_tree`]: the value becomes a git tree, the tree is wrapped in a
+//! commit parented on the ref's prior tip, and the ref is moved to it. The
+//! commit chain is the document's history and each commit's date is its
+//! timestamp, so nothing about versioning has to be modeled in the tree
+//! itself. This is the single home for the plumbing that the signer set, the
+//! check set, and the run log all share.
+
+use std::cmp::Reverse;
+use std::path::Path;
+
+use facet::Facet;
+use gix::ObjectId;
+use gix::objs::{Commit, FindExt as _, Write as _};
+use gix::refs::transaction::PreviousValue;
+
+/// The author and committer identity stamped on every write, fixed so a write
+/// is self-contained and independent of any ambient git config.
+const IDENTITY_NAME: &str = "git-ents";
+/// The email paired with [`IDENTITY_NAME`].
+const IDENTITY_EMAIL: &str = "git-ents@localhost";
+
+/// A failure opening the store or reading or writing one of its refs.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The repository could not be opened.
+ #[error("could not open the repository")]
+ Open(#[from] Box<gix::open::Error>),
+ /// The repository's object database could not be opened.
+ #[error("could not open the repository object database")]
+ Odb,
+ /// A document could not be (de)serialized from its git tree.
+ #[error("could not (de)serialize the document: {0}")]
+ Facet(#[from] facet_git_tree::Error),
+ /// A ref could not be read, listed, or updated.
+ #[error("git ref operation failed: {0}")]
+ Ref(String),
+ /// A git object could not be read or written.
+ #[error("git object operation failed: {0}")]
+ Object(String),
+}
+
+/// A repository's typed `refs/meta/*` store.
+///
+/// Refs are read and updated through the high-level [`gix`] API, while all
+/// object IO uses an object database opened on the *common* git directory
+/// rather than `--git-path objects`: inside a hook git points the latter at a
+/// receive-pack quarantine holding only the incoming pack, while the documents
+/// we read and write live in the durable store.
+pub struct Store {
+ repo: gix::Repository,
+ odb: gix::odb::Handle,
+}
+
+impl Store {
+ /// Open the typed store for the repository at `repo`.
+ pub fn open(repo: &Path) -> Result<Self, Error> {
+ let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?;
+ let odb = gix::odb::at(repo.common_dir().join("objects")).map_err(|_io| Error::Odb)?;
+ Ok(Self { repo, odb })
+ }
+
+ /// Load the document on `refname`, or `None` when the ref is absent.
+ pub fn load<T: for<'a> Facet<'a>>(&self, refname: &str) -> Result<Option<T>, Error> {
+ let Some(commit) = self.ref_commit(refname)? else {
+ return Ok(None);
+ };
+ let tree = self.read_commit(&commit)?.tree;
+ Ok(Some(facet_git_tree::deserialize(&tree, &self.odb)?))
+ }
+
+ /// Write `value` to `refname` as a new commit on top of the ref's current
+ /// tip, so the update fast-forwards and accrues history.
+ pub fn store<T: for<'a> Facet<'a>>(
+ &self,
+ refname: &str,
+ value: &T,
+ message: &str,
+ ) -> Result<(), Error> {
+ let tree = facet_git_tree::serialize_into(value, &self.odb)?;
+ let parents = self.ref_commit(refname)?.into_iter().collect();
+ let commit = self.write_commit(tree, parents, message)?;
+ self.set_ref(refname, commit)
+ }
+
+ /// Write `value` to `refname` in place, replacing the ref's tip commit
+ /// (re-parented on the tip's own parents) rather than appending. Lets a
+ /// single document advance through intermediate states without a commit per
+ /// transition. When the ref is absent this starts a fresh history.
+ pub fn amend<T: for<'a> Facet<'a>>(
+ &self,
+ refname: &str,
+ value: &T,
+ message: &str,
+ ) -> Result<(), Error> {
+ let tree = facet_git_tree::serialize_into(value, &self.odb)?;
+ let parents = match self.ref_commit(refname)? {
+ Some(tip) => self.read_commit(&tip)?.parents,
+ None => Vec::new(),
+ };
+ let commit = self.write_commit(tree, parents, message)?;
+ self.set_ref(refname, commit)
+ }
+
+ /// The documents on `refname`'s commit chain as `(committer date, value)`
+ /// pairs, newest first — one entry per commit, following first parents.
+ pub fn history<T: for<'a> Facet<'a>>(&self, refname: &str) -> Result<Vec<(u64, T)>, Error> {
+ let mut out = Vec::new();
+ let mut cursor = self.ref_commit(refname)?;
+ while let Some(oid) = cursor {
+ let commit = self.read_commit(&oid)?;
+ let value = facet_git_tree::deserialize(&commit.tree, &self.odb)?;
+ out.push((commit.seconds, value));
+ cursor = commit.parents.into_iter().next();
+ }
+ Ok(out)
+ }
+
+ /// The full names of the refs under `prefix`, newest committer date first.
+ pub fn list(&self, prefix: &str) -> Result<Vec<String>, Error> {
+ let platform = self
+ .repo
+ .references()
+ .map_err(|error| Error::Ref(error.to_string()))?;
+ let iter = platform
+ .prefixed(prefix)
+ .map_err(|error| Error::Ref(error.to_string()))?;
+ let mut refs = Vec::new();
+ for reference in iter {
+ let mut reference = reference.map_err(|error| Error::Ref(error.to_string()))?;
+ let name = reference.name().as_bstr().to_string();
+ let oid = reference
+ .peel_to_id()
+ .map_err(|error| Error::Ref(error.to_string()))?
+ .detach();
+ refs.push((self.read_commit(&oid)?.seconds, name));
+ }
+ refs.sort_by_key(|(seconds, _name)| Reverse(*seconds));
+ Ok(refs.into_iter().map(|(_seconds, name)| name).collect())
+ }
+
+ /// Resolve `refname` to the object id of its commit, or `None` when absent.
+ fn ref_commit(&self, refname: &str) -> Result<Option<ObjectId>, Error> {
+ match self
+ .repo
+ .try_find_reference(refname)
+ .map_err(|error| Error::Ref(error.to_string()))?
+ {
+ Some(mut reference) => {
+ let id = reference
+ .peel_to_id()
+ .map_err(|error| Error::Ref(error.to_string()))?;
+ Ok(Some(id.detach()))
+ }
+ None => Ok(None),
+ }
+ }
+
+ /// Read `oid`'s tree, parents, and committer date from the durable store.
+ fn read_commit(&self, oid: &ObjectId) -> Result<CommitFacts, Error> {
+ let mut buffer = Vec::new();
+ let commit = self
+ .odb
+ .find_commit(oid, &mut buffer)
+ .map_err(|error| Error::Object(error.to_string()))?;
+ let seconds = commit
+ .committer()
+ .map_err(|error| Error::Object(error.to_string()))?
+ .seconds();
+ Ok(CommitFacts {
+ tree: commit.tree(),
+ parents: commit.parents().collect(),
+ seconds: u64::try_from(seconds).unwrap_or(0),
+ })
+ }
+
+ /// Wrap `tree` in a commit over `parents` and write it to the durable store.
+ fn write_commit(
+ &self,
+ tree: ObjectId,
+ parents: Vec<ObjectId>,
+ message: &str,
+ ) -> Result<ObjectId, Error> {
+ let signature = gix::actor::Signature {
+ name: IDENTITY_NAME.into(),
+ email: IDENTITY_EMAIL.into(),
+ time: gix::date::Time::now_utc(),
+ };
+ let commit = Commit {
+ tree,
+ parents: parents.into(),
+ author: signature.clone(),
+ committer: signature,
+ encoding: None,
+ message: message.into(),
+ extra_headers: Vec::new(),
+ };
+ self.odb
+ .write(&commit)
+ .map_err(|error| Error::Object(error.to_string()))
+ }
+
+ /// Point `refname` at `commit`, creating or force-updating it.
+ fn set_ref(&self, refname: &str, commit: ObjectId) -> Result<(), Error> {
+ self.repo
+ .reference(refname, commit, PreviousValue::Any, "git-ents: update")
+ .map_err(|error| Error::Ref(error.to_string()))?;
+ Ok(())
+ }
+}
+
+/// The facts read off a commit: its tree, its parents, and its committer date.
+struct CommitFacts {
+ tree: ObjectId,
+ parents: Vec<ObjectId>,
+ seconds: u64,
+}