git-ents.gitmain
⌘K
foforge
commit b3fbe43
feat: add the refs/meta/config repository metadata document

Promote the repository’s description, homepage, and topics to a typed, members-gated Config document on its own refs/meta/config ref, read and written through git-store. Pinned against its on-disk layout with a fixed-format load test built by a new write_config_doc testutil helper.

feat: add git_ents::config with Config/load/store test: pin the on-disk config format against a fixed fixture Assisted-by: Claude:claude-opus-4-8

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/lib.rs @@ -1,6 +1,7 @@ //! Git Ents — helpful guardians of your git trees. pub mod checks; +pub mod config; pub mod signers; #[cfg(test)] mod testutil;
crates/git-ents/src/testutil.rs @@ -61,6 +61,44 @@ assert!(status.success()); } +/// Lay a `Config` document out at `refname` as the real on-disk format: a +/// `description` blob, a `homepage` blob, and a `topics/` subtree of index-keyed +/// (`0000`, `0001`, …) blobs, committed and pointed to by the ref. Asserts the +/// loader still reads the format independent of the writer. +pub(crate) fn write_config_doc( + repo: &Path, + refname: &str, + description: &str, + homepage: &str, + topics: &[&str], +) { + let description_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], description); + let homepage_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], homepage); + let mut topic_entries = String::new(); + for (index, topic) in topics.iter().enumerate() { + let blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], topic); + topic_entries.push_str(&format!("100644 blob {blob}\t{index:04}\n")); + } + let topics_tree = git_with_stdin(repo, &["mktree"], &topic_entries); + let root = git_with_stdin( + repo, + &["mktree"], + &format!( + "100644 blob {description_blob}\tdescription\n\ + 100644 blob {homepage_blob}\thomepage\n\ + 040000 tree {topics_tree}\ttopics\n" + ), + ); + let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], ""); + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(["update-ref", refname, &commit]) + .status() + .unwrap(); + assert!(status.success()); +} + /// Run git in `repo` with `input` on stdin, returning its trimmed stdout. fn git_with_stdin(repo: &Path, args: &[&str], input: &str) -> String { let mut child = Command::new("git")
crates/git-ents/src/config.rs @@ -1,0 +1,119 @@ +//! The repository's metadata, sourced from the `refs/meta/config` ref. +//! +//! A repository's loose metadata — its description, homepage, and topics — is +//! first-class, members-gated, versioned data rather than worktree content or +//! a loose git file. It lives on exactly one ref, `refs/meta/config`, whose +//! tree is a [`Config`] document read and written through [`git_store`]. Keeping +//! it on a meta ref (not in the worktree) means anyone who can push content +//! cannot rewrite the repository's metadata, and the metadata carries its own +//! independent history. + +use std::path::Path; + +use facet::Facet; + +/// The ref whose tree holds the repository configuration. +pub const CONFIG_REF: &str = "refs/meta/config"; + +/// The repository configuration stored at [`CONFIG_REF`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)] +pub struct Config { + /// The repository's description (was git's `.git/description` file). + pub description: String, + /// The repository's homepage URL; `""` when unset. + pub homepage: String, + /// The repository's topics (migrated off the tracked `HEAD:.gitents/topics`). + pub topics: Vec<String>, +} + +/// A failure reading or writing the configuration. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The configuration could not be read from or written to its ref. + #[error(transparent)] + Store(#[from] git_store::Error), +} + +/// Load the configuration recorded at [`CONFIG_REF`] in `repo`. +/// +/// An absent ref yields [`Config::default`], as on a repository whose metadata +/// 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(repo: &Path) -> Result<Config, Error> { + Ok(git_store::Store::open(repo)? + .load::<Config>(CONFIG_REF)? + .unwrap_or_default()) +} + +/// Write `config` to [`CONFIG_REF`], replacing any existing value, as a new +/// commit. +pub fn store(repo: &Path, config: &Config) -> Result<(), Error> { + git_store::Store::open(repo)?.store(CONFIG_REF, config, "Update configuration")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "unit test" + )] + + use super::*; + use crate::testutil::{unique_repo as new_repo, write_config_doc}; + + fn unique_repo() -> std::path::PathBuf { + new_repo("config") + } + + fn config() -> Config { + Config { + description: "A repository".to_owned(), + homepage: "https://example.com".to_owned(), + topics: vec!["rust".to_owned(), "git".to_owned()], + } + } + + #[test] + fn store_then_load_round_trips_the_config() { + let repo = unique_repo(); + store(&repo, &config()).unwrap(); + assert_eq!(load(&repo).unwrap(), config()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn store_replaces_the_previous_config() { + let repo = unique_repo(); + store(&repo, &config()).unwrap(); + store(&repo, &Config::default()).unwrap(); + assert_eq!(load(&repo).unwrap(), Config::default()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn loads_the_on_disk_config_format() { + // A fixture written as the real on-disk layout — `description` and + // `homepage` blobs plus an index-keyed `topics/` subtree — must keep + // loading, guarding the Config document's shape against an incompatible + // change to data already on a ref. + let repo = unique_repo(); + write_config_doc( + &repo, + CONFIG_REF, + "A repository", + "https://example.com", + &["rust", "git"], + ); + assert_eq!(load(&repo).unwrap(), config()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn default_when_the_config_ref_is_absent() { + let repo = unique_repo(); + assert_eq!(load(&repo).unwrap(), Config::default()); + let _ = std::fs::remove_dir_all(&repo); + } +}