git-ents.gitmain
⌘K
foforge
commit 7d369b0
feat: add Store collection helpers and a HasId trait for Member

Every decomposed-ref collection (members today, checks/issues/comments later) hand-formats its own {prefix}/{id} ref name at each load/store call site. load_item/store_item/list_items centralize that, inheriting store’s CAS-and-merge behavior for free. HasId lets a type that legitimately stores its own key (a member’s principal is both the ref segment and an allowed_signers field) avoid passing that key twice.

feat: add Store::load_item/store_item/list_items feat: add Store::HasId and Store::store_keyed feat: implement HasId for Member and use store_keyed/list_items in members.rs refactor: add members::load_with/load_all_with/store_with to thread a Store 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/members.rs @@ -71,6 +71,12 @@ CertAuthority(String), } +impl git_store::HasId for Member { + fn id(&self) -> &str { + &self.principal + } +} + impl Member { /// A member trusting `keys` with no validity window. #[must_use] @@ -116,32 +122,48 @@ } } -/// Load the member named `username` in `repo`, or `None` when the ref is absent. -pub fn load(repo: &Path, username: &str) -> Result<Option<Member>, git_store::Error> { - git_store::Store::open(repo)?.load::<Member>(&member_ref(username)) +/// Load the member named `username` from an already-open `store`. +pub fn load_with( + store: &git_store::Store, + username: &str, +) -> Result<Option<Member>, git_store::Error> { + store.load_item(MEMBER_NS, username) } -/// Load every member recorded under [`MEMBER_NS`] in `repo`, newest ref first. +/// Load the member named `username` in `repo`, or `None` when the ref is absent. +pub fn load(repo: &Path, username: &str) -> Result<Option<Member>, git_store::Error> { + load_with(&git_store::Store::open(repo)?, username) +} + +/// Load every member recorded under [`MEMBER_NS`] from an already-open +/// `store`, newest ref first. /// /// An empty result is a fresh server whose trust list has not been pushed yet. A /// present but unreadable member ref is an error so callers can fail closed /// rather than mistake corruption for "no members". -pub fn load_all(repo: &Path) -> Result<Vec<Member>, git_store::Error> { - let store = git_store::Store::open(repo)?; - let mut members = Vec::new(); - for refname in store.list(&format!("{MEMBER_NS}/"))? { - if let Some(member) = store.load::<Member>(&refname)? { - members.push(member); - } - } - Ok(members) +pub fn load_all_with(store: &git_store::Store) -> Result<Vec<Member>, git_store::Error> { + Ok(store + .list_items::<Member>(MEMBER_NS)? + .into_iter() + .map(|(_id, member)| member) + .collect()) } -/// Write `member` to its `refs/meta/member/<principal>` ref, replacing any prior -/// value, as a new commit. +/// Load every member recorded under [`MEMBER_NS`] in `repo`. See +/// [`load_all_with`]. +pub fn load_all(repo: &Path) -> Result<Vec<Member>, git_store::Error> { + load_all_with(&git_store::Store::open(repo)?) +} + +/// Write `member` to its `refs/meta/member/<principal>` ref, replacing any +/// prior value, as a new commit, through an already-open `store`. +pub fn store_with(store: &git_store::Store, member: &Member) -> Result<(), git_store::Error> { + store.store_keyed(MEMBER_NS, member, "Update member") +} + +/// Write `member` to its `refs/meta/member/<principal>` ref. See [`store_with`]. pub fn store(repo: &Path, member: &Member) -> Result<(), git_store::Error> { - git_store::Store::open(repo)?.store(&member_ref(&member.principal), member, "Update member")?; - Ok(()) + store_with(&git_store::Store::open(repo)?, member) } /// Drop every `revoked` fingerprint from `members`, returning the trust set the
crates/git-store/src/lib.rs @@ -86,6 +86,19 @@ fn into_pair(self) -> (String, String); } +/// A document that legitimately stores its own collection key. +/// +/// Most decomposed-ref collections must *not* implement this: an issue's +/// stable key is its ref's genesis hash, never a stored field, and duplicating +/// it inside the document would let the stored copy disagree with the ref. A +/// member is the exception — its principal is both the ref segment and a +/// field genuinely used elsewhere (rendering `allowed_signers`) — so `HasId` +/// lets a caller store one without passing the principal twice. +pub trait HasId { + /// The value's collection key — the segment its ref is stored under. + fn id(&self) -> &str; +} + /// A repository's typed `refs/meta/*` store. /// /// Refs are read and updated through the high-level [`gix`] API, while all @@ -202,6 +215,58 @@ self.try_set_ref(refname, expected, commit) } + /// Load the item `id` under the collection ref namespace `prefix` + /// (`{prefix}/{id}`), or `None` when its ref is absent. The thin wrapper + /// every decomposed-ref collection (members, checks, issues, comments, …) + /// shares instead of hand-formatting its own ref name. + pub fn load_item<T: for<'a> Facet<'a>>( + &self, + prefix: &str, + id: &str, + ) -> Result<Option<T>, Error> { + self.load(&format!("{prefix}/{id}")) + } + + /// Store `value` as item `id` under the collection ref namespace `prefix` + /// (`{prefix}/{id}`), inheriting [`store`](Self::store)'s CAS-and-merge + /// behavior. + pub fn store_item<T: for<'a> Facet<'a>>( + &self, + prefix: &str, + id: &str, + value: &T, + message: &str, + ) -> Result<(), Error> { + self.store(&format!("{prefix}/{id}"), value, message) + } + + /// Like [`store_item`](Self::store_item), but for a [`HasId`] value that + /// carries its own collection key, so the caller does not pass it twice. + pub fn store_keyed<T: for<'a> Facet<'a> + HasId>( + &self, + prefix: &str, + value: &T, + message: &str, + ) -> Result<(), Error> { + self.store_item(prefix, value.id(), value, message) + } + + /// Every item under the collection ref namespace `prefix`, paired with the + /// id (the ref's last path segment) it was stored under, newest first. + pub fn list_items<T: for<'a> Facet<'a>>( + &self, + prefix: &str, + ) -> Result<Vec<(String, T)>, Error> { + let mut items = Vec::new(); + for refname in self.list(&format!("{prefix}/"))? { + let id = refname.rsplit('/').next().unwrap_or(&refname).to_owned(); + if let Some(value) = self.load::<T>(&refname)? { + items.push((id, value)); + } + } + Ok(items) + } + /// Load the [`MapDoc`] on `refname` as its `(key, value)` entries, or an /// empty vec when the ref is absent. Centralizes the "missing ref reads /// empty" policy the set documents share. @@ -490,6 +555,94 @@ assert_eq!(loaded, entries(&[("b", "2")])); } + /// A minimal keyed item, used to exercise `load_item`/`store_item`/ + /// `list_items`/`store_keyed`. + #[derive(Facet, Clone, Debug, PartialEq)] + struct Item { + id: String, + value: String, + } + + impl HasId for Item { + fn id(&self) -> &str { + &self.id + } + } + + #[test] + fn store_item_then_load_item_round_trips() { + let dir = repo(); + let store = Store::open(dir.path()).unwrap(); + let item = Item { + id: "a".into(), + value: "1".into(), + }; + store + .store_item("refs/meta/items", "a", &item, "write") + .unwrap(); + assert_eq!( + store.load_item::<Item>("refs/meta/items", "a").unwrap(), + Some(item) + ); + assert_eq!( + store + .load_item::<Item>("refs/meta/items", "missing") + .unwrap(), + None + ); + } + + #[test] + fn store_keyed_uses_the_value_own_id() { + let dir = repo(); + let store = Store::open(dir.path()).unwrap(); + let item = Item { + id: "a".into(), + value: "1".into(), + }; + store + .store_keyed("refs/meta/items", &item, "write") + .unwrap(); + assert_eq!( + store.load_item::<Item>("refs/meta/items", "a").unwrap(), + Some(item) + ); + } + + #[test] + fn list_items_returns_every_item_keyed_by_id() { + let dir = repo(); + let store = Store::open(dir.path()).unwrap(); + store + .store_keyed( + "refs/meta/items", + &Item { + id: "a".into(), + value: "1".into(), + }, + "write", + ) + .unwrap(); + store + .store_keyed( + "refs/meta/items", + &Item { + id: "b".into(), + value: "2".into(), + }, + "write", + ) + .unwrap(); + let mut ids: Vec<String> = store + .list_items::<Item>("refs/meta/items") + .unwrap() + .into_iter() + .map(|(id, _item)| id) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a".to_owned(), "b".to_owned()]); + } + /// A small multi-field, multi-collection document used to exercise the /// structural merge: two scalar fields plus a scalar-keyed map. #[derive(Facet, Clone, Debug, PartialEq)]