feat: add Document/MapDocument/Collection storage traits to git-ents
commit
1892fa8feat: add Document/MapDocument/Collection storage traits to git-ents
First phase of the component abstraction plan: one compile-time trait per meta-ref storage layout, so each module load/store shrinks to a one-line delegation instead of hand-formatting a ref name.
feat: add component::{Document, MapDocument, Collection, Component} traits refactor: implement Document for Config/Account refactor: implement MapDocument for Check/Revocation refactor: implement Collection for Member/Issue Assisted-by: Claude:claude-sonnet-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/git-ents/src/account.rs
@@ -11,6 +11,8 @@
use facet::Facet;
+use crate::component;
+
/// The ref whose tree holds the account profile, and whose mere presence marks a
/// repository as an account repo.
pub const ACCOUNT_REF: &str = "refs/meta/account";
@@ -29,16 +31,25 @@
pub created_at: u64,
}
+impl component::Document for Account {
+ const REF: &'static str = ACCOUNT_REF;
+}
+
+impl component::Component for Account {
+ const NOUN: &'static str = "account";
+ const PLURAL: &'static str = "account";
+}
+
/// Load the account profile at [`ACCOUNT_REF`] in `repo`, or `None` when the
/// ref is absent — i.e. when the repository is not an account repo.
pub fn load(repo: &Path) -> Result<Option<Account>, git_store::Error> {
- git_store::Store::open(repo)?.load::<Account>(ACCOUNT_REF)
+ component::load(&git_store::Store::open(repo)?)
}
/// Write `account` to [`ACCOUNT_REF`] in `repo`, replacing any existing value
/// as a new commit.
pub fn store(repo: &Path, account: &Account) -> Result<(), git_store::Error> {
- git_store::Store::open(repo)?.store(ACCOUNT_REF, account, "Update account")
+ component::store(&git_store::Store::open(repo)?, account, "Update account")
}
/// Whether `repo` is an account repo — whether it carries [`ACCOUNT_REF`].
crates/git-ents/src/checks.rs
@@ -26,13 +26,17 @@
use facet::Facet;
use gix::ObjectId;
+use crate::component;
+
/// The ref whose tree holds the configured check set.
pub const CHECKS_REF: &str = "refs/meta/checks";
/// A configured check's on-disk body. The map key (its name) is the check's
-/// identity, so it is not duplicated inside the body.
+/// identity, so it is not duplicated inside the body. `pub` only because it
+/// is [`component::MapDocument::Body`] for [`Check`]; nothing outside this
+/// module constructs one directly.
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-struct CheckBody {
+pub struct CheckBody {
/// The shell command run for the check (e.g. `cargo fmt --check`), or
/// `None` for a composite check that only aggregates its `depends`.
command: Option<String>,
@@ -57,42 +61,53 @@
pub depends: Vec<String>,
}
+impl component::MapDocument for Check {
+ const REF: &'static str = CHECKS_REF;
+ type Body = CheckBody;
+
+ fn compose(name: String, body: CheckBody) -> Self {
+ Check {
+ name,
+ command: body.command,
+ image: body.image,
+ depends: body.depends.unwrap_or_default(),
+ }
+ }
+
+ fn decompose(&self) -> (&str, CheckBody) {
+ (
+ &self.name,
+ CheckBody {
+ command: self.command.clone(),
+ image: self.image.clone(),
+ depends: if self.depends.is_empty() {
+ None
+ } else {
+ Some(self.depends.clone())
+ },
+ },
+ )
+ }
+}
+
+impl component::Component for Check {
+ const NOUN: &'static str = "check";
+ const PLURAL: &'static str = "checks";
+}
+
/// Load the configured checks recorded at [`CHECKS_REF`] in `repo`.
///
/// An absent ref yields an empty set, as on a server whose check set has not
/// 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>, git_store::Error> {
- git_store::Store::open(repo)?.load_map(CHECKS_REF, |name, body: CheckBody| Check {
- name,
- command: body.command,
- image: body.image,
- depends: body.depends.unwrap_or_default(),
- })
+ component::load_map(&git_store::Store::open(repo)?)
}
/// Write `checks` to [`CHECKS_REF`] in `repo`, replacing any existing set as a
/// new commit.
pub fn store(repo: &Path, checks: &[Check]) -> Result<(), git_store::Error> {
- git_store::Store::open(repo)?.store_map(
- CHECKS_REF,
- checks,
- |check| {
- (
- check.name.clone(),
- CheckBody {
- command: check.command.clone(),
- image: check.image.clone(),
- depends: if check.depends.is_empty() {
- None
- } else {
- Some(check.depends.clone())
- },
- },
- )
- },
- "Update checks",
- )
+ component::store_map(&git_store::Store::open(repo)?, checks, "Update checks")
}
/// Validate `checks` as a static dependency graph and return them in an order
crates/git-ents/src/config.rs
@@ -13,6 +13,8 @@
use facet::Facet;
+use crate::component;
+
/// The ref whose tree holds the repository configuration.
pub const CONFIG_REF: &str = "refs/meta/config";
@@ -33,6 +35,15 @@
pub roles: BTreeMap<String, RoleRules>,
}
+impl component::Document for Config {
+ const REF: &'static str = CONFIG_REF;
+}
+
+impl component::Component for Config {
+ const NOUN: &'static str = "configuration";
+ const PLURAL: &'static str = "configuration";
+}
+
/// The ref-push rules for one role: glob patterns (`*` matches any run of
/// characters) matched against the full ref name (e.g. `refs/heads/*`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)]
@@ -99,7 +110,7 @@
/// has not been set yet. A present but unreadable ref is an error so callers can
/// distinguish corruption from "no configuration set".
pub fn load_with(store: &git_store::Store) -> Result<Config, git_store::Error> {
- Ok(store.load::<Config>(CONFIG_REF)?.unwrap_or_default())
+ Ok(component::load::<Config>(store)?.unwrap_or_default())
}
/// Load the configuration recorded at [`CONFIG_REF`] in `repo`. See
@@ -116,7 +127,11 @@
/// throwaway ref and pushes it onto [`CONFIG_REF`] through a signed push, so
/// the `pre-receive` gate judges the change rather than a direct write.
pub fn store(repo: &Path, config: &Config) -> Result<(), git_store::Error> {
- git_store::Store::open(repo)?.store(CONFIG_REF, config, "Update configuration")
+ component::store(
+ &git_store::Store::open(repo)?,
+ config,
+ "Update configuration",
+ )
}
#[cfg(test)]
crates/git-ents/src/issues.rs
@@ -29,6 +29,8 @@
use facet::Facet;
+use crate::component;
+
/// The namespace under which issues are recorded: one ref,
/// `refs/meta/issues/<id>`, per issue.
pub const ISSUES_NS: &str = "refs/meta/issues";
@@ -67,6 +69,15 @@
pub id: Option<String>,
}
+impl component::Collection for Issue {
+ const NS: &'static str = ISSUES_NS;
+}
+
+impl component::Component for Issue {
+ const NOUN: &'static str = "issue";
+ const PLURAL: &'static str = "issues";
+}
+
impl Issue {
/// Whether the issue is open (any state other than [`State::Closed`]).
#[must_use]
@@ -93,18 +104,18 @@
/// Load the issue recorded at `refs/meta/issues/<id>` in `repo`, or `None` when
/// no such issue exists.
pub fn load(repo: &Path, id: &str) -> Result<Option<Issue>, git_store::Error> {
- git_store::Store::open(repo)?.load_item(ISSUES_NS, id)
+ component::load_item(&git_store::Store::open(repo)?, id)
}
/// Write `issue` to `refs/meta/issues/<id>` in `repo`, replacing any existing
/// value as a new commit so the ref's commit chain is the issue's edit history.
pub fn store(repo: &Path, id: &str, issue: &Issue) -> Result<(), git_store::Error> {
- git_store::Store::open(repo)?.store_item(ISSUES_NS, id, issue, "Update issue")
+ component::store_item(&git_store::Store::open(repo)?, id, issue, "Update issue")
}
/// List every issue in `repo` as `(id, issue)` pairs, newest issue ref first.
pub fn list(repo: &Path) -> Result<Vec<(String, Issue)>, git_store::Error> {
- git_store::Store::open(repo)?.list_items(ISSUES_NS)
+ component::list(&git_store::Store::open(repo)?)
}
/// The number of open issues in `repo`.
@@ -164,11 +175,10 @@
}
let number = number.ok_or(git_store::Error::Conflict)?.to_string();
- let mut issue = store
- .load_item::<Issue>(ISSUES_NS, id)?
+ let mut issue = component::load_item::<Issue>(&store, id)?
.ok_or_else(|| PromoteError::NotFound(id.to_owned()))?;
issue.id = Some(number.clone());
- store.store_item(ISSUES_NS, id, &issue, "Update issue")?;
+ component::store_item(&store, id, &issue, "Update issue")?;
Ok(number)
}
crates/git-ents/src/lib.rs
@@ -2,6 +2,7 @@
pub mod account;
pub mod checks;
+pub mod component;
pub mod config;
pub mod issues;
pub mod members;
crates/git-ents/src/members.rs
@@ -38,6 +38,8 @@
use facet::Facet;
+use crate::component;
+
/// The namespace whose refs hold the member set — the push trust root. One
/// `refs/meta/member/<username>` ref per person.
pub const MEMBER_NS: &str = "refs/meta/member";
@@ -140,6 +142,15 @@
}
}
+impl component::Collection for Member {
+ const NS: &'static str = MEMBER_NS;
+}
+
+impl component::Component for Member {
+ const NOUN: &'static str = "member";
+ const PLURAL: &'static str = "members";
+}
+
impl iddqd::IdOrdItem for Member {
type Key<'a> = &'a str;
@@ -272,7 +283,7 @@
store: &git_store::Store,
username: &str,
) -> Result<Option<Member>, git_store::Error> {
- store.load_item(MEMBER_NS, username)
+ component::load_item(store, username)
}
/// Load the member named `username` in `repo`, or `None` when the ref is absent.
@@ -287,8 +298,7 @@
/// present but unreadable member ref is an error so callers can fail closed
/// rather than mistake corruption for "no members".
pub fn load_all_with(store: &git_store::Store) -> Result<Vec<Member>, git_store::Error> {
- Ok(store
- .list_items::<Member>(MEMBER_NS)?
+ Ok(component::list::<Member>(store)?
.into_iter()
.map(|(_id, member)| member)
.collect())
@@ -321,7 +331,7 @@
/// validity window is malformed or inverted — see [`Member::validate`].
pub fn store(repo: &Path, member: &Member) -> Result<(), git_store::Error> {
member.validate().map_err(git_store::Error::Invalid)?;
- git_store::Store::open(repo)?.store_keyed(MEMBER_NS, member, "Update member")
+ component::store_keyed(&git_store::Store::open(repo)?, member, "Update member")
}
/// Drop every `revoked` fingerprint from `members`, returning the trust set the
crates/git-ents/src/revocations.rs
@@ -28,14 +28,18 @@
use facet::Facet;
+use crate::component;
+
/// The ref whose tree holds the revocation list — the deny overlay on the trust
/// set.
pub const REVOKED_REF: &str = "refs/meta/revoked";
/// A revoked key's on-disk body. The map key (its fingerprint) is its
-/// identity, so it is not duplicated inside the body.
+/// identity, so it is not duplicated inside the body. `pub` only because it
+/// is [`component::MapDocument::Body`] for [`Revocation`]; nothing outside
+/// this module constructs one directly.
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-struct RevocationBody {
+pub struct RevocationBody {
/// A free-text reason, or `""` when none was given.
reason: String,
}
@@ -49,31 +53,44 @@
pub reason: String,
}
-/// Load the revocations recorded at [`REVOKED_REF`] in `repo`. An absent ref
-/// yields an empty list — nothing is revoked.
-pub fn load(repo: &Path) -> Result<Vec<Revocation>, git_store::Error> {
- git_store::Store::open(repo)?.load_map(REVOKED_REF, |fingerprint, body: RevocationBody| {
+impl component::MapDocument for Revocation {
+ const REF: &'static str = REVOKED_REF;
+ type Body = RevocationBody;
+
+ fn compose(fingerprint: String, body: RevocationBody) -> Self {
Revocation {
fingerprint,
reason: body.reason,
}
- })
+ }
+
+ fn decompose(&self) -> (&str, RevocationBody) {
+ (
+ &self.fingerprint,
+ RevocationBody {
+ reason: self.reason.clone(),
+ },
+ )
+ }
+}
+
+impl component::Component for Revocation {
+ const NOUN: &'static str = "revocation";
+ const PLURAL: &'static str = "revocations";
+}
+
+/// Load the revocations recorded at [`REVOKED_REF`] in `repo`. An absent ref
+/// yields an empty list — nothing is revoked.
+pub fn load(repo: &Path) -> Result<Vec<Revocation>, git_store::Error> {
+ component::load_map(&git_store::Store::open(repo)?)
}
/// Write `revocations` to [`REVOKED_REF`] in `repo`, replacing any existing
/// list as a new commit.
pub fn store(repo: &Path, revocations: &[Revocation]) -> Result<(), git_store::Error> {
- git_store::Store::open(repo)?.store_map(
- REVOKED_REF,
+ component::store_map(
+ &git_store::Store::open(repo)?,
revocations,
- |revocation| {
- (
- revocation.fingerprint.clone(),
- RevocationBody {
- reason: revocation.reason.clone(),
- },
- )
- },
"Update revocations",
)
}
crates/git-ents/src/component.rs
@@ -1,0 +1,126 @@
+//! The storage-layout traits every meta-ref component implements, plus the
+//! identity metadata the CLI and server share.
+//!
+//! A component stores itself one of three ways — [`Document`] (a single
+//! document on one ref), [`MapDocument`] (named entries in one scalar-keyed
+//! map on one ref), or [`Collection`] (one ref per item under a namespace) —
+//! and the free functions here are the single place that turns each trait
+//! into the matching [`git_store::Store`] call, so a module's own
+//! `load`/`store` shrinks to a one-line delegation instead of hand-formatting
+//! a ref name.
+
+use facet::Facet;
+
+/// A type stored whole on a single meta ref (e.g. [`crate::config::Config`],
+/// [`crate::account::Account`]).
+pub trait Document: for<'a> Facet<'a> {
+ /// The ref the document lives on.
+ const REF: &'static str;
+}
+
+/// Load the document at [`Document::REF`], or `None` when the ref is absent.
+pub fn load<T: Document>(store: &git_store::Store) -> Result<Option<T>, git_store::Error> {
+ store.load(T::REF)
+}
+
+/// Write `value` to [`Document::REF`], replacing any existing value as a new
+/// commit.
+pub fn store<T: Document>(
+ store: &git_store::Store,
+ value: &T,
+ message: &str,
+) -> Result<(), git_store::Error> {
+ store.store(T::REF, value, message)
+}
+
+/// A type stored as one `<key> -> body` map document on a single ref (e.g.
+/// [`crate::checks::Check`], [`crate::revocations::Revocation`]).
+pub trait MapDocument: Sized {
+ /// The ref the map document lives on.
+ const REF: &'static str;
+ /// The value type stored per map key.
+ type Body: for<'a> Facet<'a>;
+ /// Assemble the public item from its map key and stored body.
+ fn compose(key: String, body: Self::Body) -> Self;
+ /// Split the item back into its map key and stored body.
+ fn decompose(&self) -> (&str, Self::Body);
+}
+
+/// Load [`MapDocument::REF`]'s entries as their flattened item list. An
+/// absent ref yields an empty list.
+pub fn load_map<T: MapDocument>(store: &git_store::Store) -> Result<Vec<T>, git_store::Error> {
+ store.load_map(T::REF, T::compose)
+}
+
+/// Replace [`MapDocument::REF`]'s entries with `items`.
+pub fn store_map<T: MapDocument>(
+ store: &git_store::Store,
+ items: &[T],
+ message: &str,
+) -> Result<(), git_store::Error> {
+ store.store_map(
+ T::REF,
+ items,
+ |item| {
+ let (key, body) = item.decompose();
+ (key.to_owned(), body)
+ },
+ message,
+ )
+}
+
+/// A type stored decomposed, one ref per item, under a namespace (e.g.
+/// [`crate::members::Member`], [`crate::issues::Issue`]). Deliberately not
+/// bound on [`git_store::HasId`]: an issue's ref key is its genesis hash, a
+/// value never stored inside the document itself, so [`load_item`]/
+/// [`store_item`] take the id explicitly; [`store_keyed`] is the add-on for a
+/// collection (like [`crate::members::Member`]) whose item legitimately
+/// carries its own key.
+pub trait Collection: for<'a> Facet<'a> {
+ /// The ref namespace (`{NS}/{id}` per item) its items live under.
+ const NS: &'static str;
+}
+
+/// Load the item `id` under [`Collection::NS`], or `None` when its ref is
+/// absent.
+pub fn load_item<T: Collection>(
+ store: &git_store::Store,
+ id: &str,
+) -> Result<Option<T>, git_store::Error> {
+ store.load_item(T::NS, id)
+}
+
+/// Store `value` as item `id` under [`Collection::NS`].
+pub fn store_item<T: Collection>(
+ store: &git_store::Store,
+ id: &str,
+ value: &T,
+ message: &str,
+) -> Result<(), git_store::Error> {
+ store.store_item(T::NS, id, value, message)
+}
+
+/// Store `value` as item [`git_store::HasId::id`] under [`Collection::NS`],
+/// for a collection whose item carries its own key.
+pub fn store_keyed<T: Collection + git_store::HasId>(
+ store: &git_store::Store,
+ value: &T,
+ message: &str,
+) -> Result<(), git_store::Error> {
+ store.store_keyed(T::NS, value, message)
+}
+
+/// Every item under [`Collection::NS`], paired with the id its ref was stored
+/// under, newest first.
+pub fn list<T: Collection>(store: &git_store::Store) -> Result<Vec<(String, T)>, git_store::Error> {
+ store.list_items(T::NS)
+}
+
+/// Identity metadata a component carries for messages and UI chrome, shared
+/// by the CLI and the server.
+pub trait Component {
+ /// The singular noun used in messages ("member", "check", "issue").
+ const NOUN: &'static str;
+ /// The plural noun ("members", "checks", "issues").
+ const PLURAL: &'static str;
+}