git-ents.gitmain
⌘K
foforge
commit 85e43ea
feat: link members to accounts and make add commands interactive

An account repo can move, so a member’s link to it needs an identifier that outlives the path: the genesis hash of the account’s first-ever recorded document, mirroring the genesis-key idiom issues.rs already uses. checks add, members add, and account create also lose their hard requirement for CLI flags — any field left unset is now prompted for at an interactive terminal via inquire, while a script or CI invocation without a TTY still gets a clear error naming the missing field instead of hanging.

feat: add account::genesis as a stable, path-independent account identity feat: add Member.account to @-mention an account by its genesis hash feat: add members add --account and print the genesis hash from account create feat: prompt interactively for omitted checks add/members add/account create fields at a TTY 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

Cargo.lock @@ -928,6 +928,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1248,6 +1254,7 @@ "futures-util", "git-store", "iddqd", + "inquire", "signal-hook 0.3.18", "tempfile", "thiserror 2.0.18", @@ -2489,6 +2496,19 @@ "hashbrown 0.17.1", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.13.0", + "crossterm", + "dyn-clone", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "io-close" version = "0.3.7" @@ -3711,6 +3731,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsynn" version = "0.3.0"
crates/git-ents/Cargo.toml @@ -13,6 +13,7 @@ futures-util = { version = "0.3.32", default-features = false, features = ["sink", "std"] } git-store = { workspace = true } iddqd = { workspace = true } +inquire = { version = "0.9.4", default-features = false, features = ["crossterm"] } signal-hook = { version = "0.3", features = ["iterator"] } tempfile = { workspace = true } thiserror = { workspace = true }
crates/git-ents/src/account.rs @@ -46,6 +46,21 @@ Ok(load(repo)?.is_some()) } +/// The account's stable, path-independent identity: the content hash of the +/// very first [`Account`] ever recorded on [`ACCOUNT_REF`] — fixed at +/// creation, so later profile edits (`display_name`, `bio`) never change it +/// and a member's `@`-mention of it survives the account repo moving. `None` +/// when the repo is not (yet) an account repo. Mirrors the genesis-key idiom +/// `issues::new_id` uses: an identifier derived from content, never a stored +/// field. +pub fn genesis(repo: &Path) -> Result<Option<String>, git_store::Error> { + git_store::Store::open(repo)? + .history::<Account>(ACCOUNT_REF)? + .last() + .map(|(_at, account)| git_store::content_hash(account)) + .transpose() +} + #[cfg(test)] mod tests { #![allow(
crates/git-ents/src/main.rs @@ -9,6 +9,7 @@ //! [`git_ents::members`], and pushing them back. mod debug_session; +mod interactive; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -75,11 +76,12 @@ #[arg(default_value = "origin")] remote: String, }, - /// Authorize a key for a member on a remote and push the update. + /// Authorize a key for a member on a remote and push the update. Prompts + /// for any field left unset when run at an interactive terminal. Add { /// Member (username) to authorize the key under — its /// `refs/meta/member/<username>` ref. - username: String, + username: Option<String>, /// Remote whose member refs to update. #[arg(default_value = "origin")] remote: String, @@ -98,6 +100,10 @@ /// that never lapses on its own. #[arg(long, value_name = "TIMESTAMP")] valid_before: Option<String>, + /// Link this member to an account by its genesis hash (`git ents + /// account create` prints one). + #[arg(long, value_name = "GENESIS_HASH")] + account: Option<String>, }, /// Remove a member, deleting its ref on a remote and pushing the update. Remove { @@ -143,9 +149,10 @@ enum AccountAction { /// Create or update this repository's account identity and push it. The /// presence of `refs/meta/account` is what marks the repo as an account. + /// Prompts for any field left unset when run at an interactive terminal. Create { /// The account username — by convention the `user/<username>` repo name. - username: String, + username: Option<String>, /// Remote whose `refs/meta/account` to update. #[arg(default_value = "origin")] remote: String, @@ -153,8 +160,8 @@ #[arg(long)] display_name: Option<String>, /// Short free-text bio. - #[arg(long, default_value = "")] - bio: String, + #[arg(long)] + bio: Option<String>, }, } @@ -167,11 +174,12 @@ remote: String, }, /// Add (or replace) a check on a remote's set and push the update. + /// Prompts for any field left unset when run at an interactive terminal. Add { /// Name to record the check under (`checks/<name>`). - name: String, + name: Option<String>, /// Command the check runs (e.g. `cargo fmt --check`). - command: String, + command: Option<String>, /// Remote whose `refs/meta/checks` to update. #[arg(default_value = "origin")] remote: String, @@ -222,13 +230,15 @@ cert_authority, valid_after, valid_before, + account, } => members_add( - &username, + username, &remote, - key.as_deref(), - cert_authority.as_deref(), + key, + cert_authority, valid_after, valid_before, + account, ), Action::Remove { username, remote } => members_remove(&username, &remote), Action::Revoke { @@ -251,7 +261,7 @@ remote, display_name, bio, - } => account_create(&username, &remote, display_name, bio), + } => account_create(username, &remote, display_name, bio), } } @@ -262,7 +272,7 @@ name, command, remote, - } => add_check(&name, &command, &remote), + } => add_check(name, command, &remote), ChecksAction::Remove { name, remote } => remove::<Checks>(&name, &remote), ChecksAction::Debug { remote } => checks_debug(&remote), } @@ -376,15 +386,18 @@ } /// Add `name` running `command` to `remote`'s set, replacing any check already -/// recorded under that name, and push the update. -fn add_check(name: &str, command: &str, remote: &str) -> Result<(), String> { +/// recorded under that name, and push the update. Prompts for either field +/// left `None` when run at an interactive terminal. +fn add_check(name: Option<String>, command: Option<String>, remote: &str) -> Result<(), String> { + let name = interactive::text_or(name, "Check name")?; + let command = interactive::text_or(command, "Command")?; let repo = repo()?; let expected = sync(remote, CHECKS_REF)?; let mut checks = checks::load(&repo).map_err(|error| error.to_string())?; checks.retain(|check| check.name != name); checks.push(Check { - name: name.to_owned(), - command: command.to_owned(), + name: name.clone(), + command, }); checks::store(&repo, &checks).map_err(|error| error.to_string())?; push_signed(remote, CHECKS_REF, expected.as_deref())?; @@ -629,16 +642,46 @@ Ok(()) } +/// The `key`/`cert_authority` pair for [`members_add`]. Used as given when +/// either is already set or the terminal is non-interactive, so +/// `--key`/`--cert-authority` and scripted runs are unchanged; otherwise +/// prompts for which kind of trust to add. +fn resolve_trust( + key: Option<PathBuf>, + cert_authority: Option<PathBuf>, +) -> Result<(Option<PathBuf>, Option<PathBuf>), String> { + if key.is_some() || cert_authority.is_some() || !interactive::available() { + return Ok((key, cert_authority)); + } + let choice = interactive::select_or("Trust", &["Signing key", "Certificate authority"], 0)?; + if choice == 1 { + let path = interactive::text_or(None, "Certificate authority public key path")?; + Ok((None, Some(PathBuf::from(path)))) + } else { + let path = + interactive::optional_text_or(None, "Signing key path (blank for user.signingkey)")?; + Ok((path.map(PathBuf::from), None)) + } +} + /// Authorize a key (or pin a CA) for the member `username` on `remote`, trusting /// the member within the given validity window, and push the updated member ref. fn members_add( - username: &str, + username: Option<String>, remote: &str, - key: Option<&Path>, - cert_authority: Option<&Path>, + key: Option<PathBuf>, + cert_authority: Option<PathBuf>, valid_after: Option<String>, valid_before: Option<String>, + account: Option<String>, ) -> Result<(), String> { + let username = interactive::text_or(username, "Username")?; + let (key, cert_authority) = resolve_trust(key, cert_authority)?; + let valid_after = interactive::optional_text_or(valid_after, "Valid after (blank for none)")?; + let valid_before = + interactive::optional_text_or(valid_before, "Valid before (blank for none)")?; + let account = + interactive::optional_text_or(account, "Link to account (genesis hash, blank to skip)")?; if let Some(after) = &valid_after { validate_timestamp(after)?; } @@ -646,22 +689,25 @@ validate_timestamp(before)?; } let repo = repo()?; - let refname = member_ref(username); + let refname = member_ref(&username); let expected = sync(remote, &refname)?; - let mut member = members::load(&repo, username) + let mut member = members::load(&repo, &username) .map_err(|error| error.to_string())? - .unwrap_or_else(|| Member::with_keys(username.to_owned(), BTreeMap::new())); + .unwrap_or_else(|| Member::with_keys(username.clone(), BTreeMap::new())); if valid_after.is_some() { member.valid_after = valid_after; } if valid_before.is_some() { member.valid_before = valid_before; } + if account.is_some() { + member.account = account; + } // Pinning a CA replaces the member's trust wholesale — a member is either // leaf keys or a CA, never both. if let Some(ca_path) = cert_authority { - let ca = read_public_key(ca_path)?; + let ca = read_public_key(&ca_path)?; member.trust = Trust::CertAuthority(ca); members::store(&repo, &member).map_err(|error| error.to_string())?; push_signed(remote, &refname, expected.as_deref())?; @@ -669,7 +715,7 @@ return Ok(()); } - let public_key = public_key(key)?; + let public_key = public_key(key.as_deref())?; let fingerprint = fingerprint(&public_key)?; let keys = match &mut member.trust { Trust::Keys(keys) => keys, @@ -714,24 +760,32 @@ /// Create or update this repository's account identity on `remote` and push it. fn account_create( - username: &str, + username: Option<String>, remote: &str, display_name: Option<String>, - bio: String, + bio: Option<String>, ) -> Result<(), String> { + let username = interactive::text_or(username, "Username")?; + let display_name = + interactive::optional_text_or(display_name, "Display name (blank to use username)")?; + let bio = interactive::optional_text_or(bio, "Bio (blank to skip)")?.unwrap_or_default(); let repo = repo()?; let expected = sync(remote, account::ACCOUNT_REF)?; let existing = account::load(&repo).map_err(|error| error.to_string())?; let account = Account { - username: username.to_owned(), - display_name: display_name.unwrap_or_else(|| username.to_owned()), + username: username.clone(), + display_name: display_name.unwrap_or_else(|| username.clone()), bio, // Preserve the original creation time when updating an existing account. created_at: existing.map_or_else(now_seconds, |account| account.created_at), }; account::store(&repo, &account).map_err(|error| error.to_string())?; push_signed(remote, account::ACCOUNT_REF, expected.as_deref())?; + let genesis = account::genesis(&repo).map_err(|error| error.to_string())?; println!("created account {username}"); + if let Some(genesis) = genesis { + println!("genesis: {genesis} (pass to `members add --account` to link a member)"); + } Ok(()) }
crates/git-ents/src/members.rs @@ -73,6 +73,12 @@ /// `loads_a_member_ref_with_no_provenance_entry`. #[facet(default)] pub provenance: Provenance, + /// The `@`-mentioned account this member is, by its stable + /// [`crate::account::genesis`] hash — `None` until an admin links one. + /// Plain `Option`, which `facet-git-tree` auto-defaults on an absent + /// entry, so a member ref written before this field existed keeps + /// loading unchanged. + pub account: Option<String>, } /// Whether a member was admin-registered or self-attested via web onboarding. @@ -147,6 +153,7 @@ valid_before: None, trust: Trust::Keys(keys), provenance: Provenance::AdminRegistered, + account: None, } } @@ -160,6 +167,7 @@ valid_before: None, trust: Trust::CertAuthority(ca), provenance: Provenance::AdminRegistered, + account: None, } } @@ -173,6 +181,7 @@ valid_before: None, trust: Trust::WebAuthn(keys), provenance: Provenance::SelfAttestedWeb, + account: None, } }
crates/git-ents/src/interactive.rs @@ -1,0 +1,61 @@ +//! Prompting for `add` commands left with unset fields. +//! +//! An omitted field is filled interactively when the terminal supports it, +//! so `git ents checks add` alone walks a user through every field; a script +//! or CI invocation without a TTY gets a clear error instead of a hang. + +use std::io::IsTerminal as _; + +/// Whether prompting is possible: both stdin and stdout are a terminal. +#[must_use] +pub fn available() -> bool { + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() +} + +/// `existing`, or a required text prompt for `message` when interactive; an +/// error naming `message` when not, so a script never hangs on a missing +/// argument. +pub fn text_or(existing: Option<String>, message: &str) -> Result<String, String> { + if let Some(value) = existing { + return Ok(value); + } + if !available() { + return Err(format!( + "{message} is required (not an interactive terminal)" + )); + } + inquire::Text::new(message) + .prompt() + .map_err(|error| error.to_string()) +} + +/// `existing`, or an optional text prompt for `message` when interactive — +/// an empty reply is `None`. Non-interactive with no `existing` value stays +/// `None` rather than erroring, since the field is optional. +pub fn optional_text_or(existing: Option<String>, message: &str) -> Result<Option<String>, String> { + if existing.is_some() { + return Ok(existing); + } + if !available() { + return Ok(None); + } + let value = inquire::Text::new(message) + .prompt() + .map_err(|error| error.to_string())?; + Ok((!value.is_empty()).then_some(value)) +} + +/// A `Select` prompt among `options`, run only when interactive; `default` +/// otherwise. +pub fn select_or(message: &str, options: &[&str], default: usize) -> Result<usize, String> { + if !available() { + return Ok(default); + } + let choice = inquire::Select::new(message, options.to_vec()) + .prompt() + .map_err(|error| error.to_string())?; + Ok(options + .iter() + .position(|option| *option == choice) + .unwrap_or(default)) +}