git-ents.gitmain
⌘K
foforge
commit 1892fa8
feat: 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

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

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; +}