git-ents.gitmain
⌘K
foforge
commit 01f5f7d
roots: add list/show actions to toolchain, comment, redact, and account

A usability review of git-ents’s CLI surface found three of seven command families (members, effect, inbox) discoverable via a List action enumerating refs/meta/*, while toolchain, comment, and redact had none - git ents toolchain view <name> or comment show <id> required already knowing a name/id with no way to find one. account had only Create, no read command at all.

toolchain list, comment list, and account show follow the exact iter_prefix/commit_tree shape members::list and effect::list already use. redact gains a list action too, which forced redact off its old flat Top::Redact { oid, reason, key } shape: git ents redact list has to route through a subcommand the way every other family does, so Redact is now Redact { action: RedactAction } with Add and List variants, and the old bare git ents redact <oid> --reason …​ is now git ents redact add <oid> --reason …​. This is the only breaking surface change here; the smaller alternative (a sibling top-level command) would have produced redact-list instead of the requested redact list.

New integration tests (tests/toolchain.rs, tests/comment.rs, tests/redact.rs, tests/account.rs) exercise each new read action through a real LocalRoot over a tempdir fixture, matching tests/members.rs’s existing pattern rather than mocking the ref 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/cli.rs @@ -95,21 +95,11 @@ #[facet(args::subcommand)] action: InboxAction, }, - /// Record that `oid` was redacted (`refs/meta/redactions/<id>`), - /// refusing any future push that would refill it - /// (`receive.redaction-ingest`). Admin-only: the gate's default - /// namespace-authorization arm requires admin-registered provenance - /// for `refs/meta/redactions/*`. + /// Manage redactions recorded at `refs/meta/redactions/<id>`. Redact { - /// The object id to redact. - #[facet(args::positional)] - oid: String, - /// A human-readable reason recorded alongside the redaction. - #[facet(args::named)] - reason: String, - /// Key to sign with; defaults to `user.signingkey`. - #[facet(args::named)] - key: Option<PathBuf>, + /// The redaction action to run. + #[facet(args::subcommand)] + action: RedactAction, }, /// Plumbing invoked by git's own hooks on the single-node hosted root /// (`git.ents.cloud`) — not part of the porcelain surface a developer @@ -181,6 +171,8 @@ #[derive(Facet)] #[repr(u8)] pub enum AccountAction { + /// Show this repository's account identity. + Show, /// Create or update this repository's account identity. Create { /// The member this account belongs to; defaults to the signer's @@ -259,6 +251,8 @@ #[derive(Facet)] #[repr(u8)] pub enum ToolchainAction { + /// List the toolchains currently defined. + List, /// Import a local directory as toolchain `name`, embedding its /// contents whole (`ents_effect::Recipe::Embedded`). Import { @@ -292,6 +286,8 @@ #[derive(Facet)] #[repr(u8)] pub enum CommentAction { + /// List the comments recorded in this repository. + List, /// Anchor a comment to a file at a revision. Add { /// Repository-relative path of the file the comment anchors to. @@ -344,6 +340,30 @@ }, } +/// `git ents redact` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum RedactAction { + /// List the redactions recorded in this repository. + List, + /// Record that `oid` was redacted (`refs/meta/redactions/<id>`), + /// refusing any future push that would refill it + /// (`receive.redaction-ingest`). Admin-only: the gate's default + /// namespace-authorization arm requires admin-registered provenance + /// for `refs/meta/redactions/*`. + Add { + /// The object id to redact. + #[facet(args::positional)] + oid: String, + /// A human-readable reason recorded alongside the redaction. + #[facet(args::named)] + reason: String, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, +} + /// Plumbing subcommands the single-node hosted root's git hooks invoke; /// see `crate::hook`'s own doc for what each does and why. #[derive(Facet)]
crates/git-ents/src/exe.rs @@ -9,7 +9,7 @@ use crate::cli::{ AccountAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, MembersAction, - ToolchainAction, Top, + RedactAction, ToolchainAction, Top, }; use crate::commands; use crate::error::Result; @@ -50,12 +50,7 @@ Top::Toolchain { action } => run_toolchain(action, out), Top::Comment { action } => run_comment(action, out), Top::Inbox { action } => run_inbox(action, out), - Top::Redact { oid, reason, key } => { - let root = LocalRoot::discover(".")?; - commands::redact::run(&root, &oid, reason, key)?; - let _ = writeln!(out, "redacted {oid}"); - Ok(()) - } + Top::Redact { action } => run_redact(action, out), Top::Hook { action } => run_hook(action, out), } } @@ -107,6 +102,11 @@ fn run_account(action: AccountAction, out: &mut impl std::io::Write) -> Result<()> { let root = LocalRoot::discover(".")?; match action { + AccountAction::Show => { + let account = commands::account::show(&root)?; + let _ = writeln!(out, "member: {}", account.member); + let _ = writeln!(out, "login: {}", account.login); + } AccountAction::Create { member, login, key } => { commands::account::create(&root, member, login, key)?; let _ = writeln!(out, "account created"); @@ -157,6 +157,11 @@ fn run_toolchain(action: ToolchainAction, out: &mut impl std::io::Write) -> Result<()> { let root = LocalRoot::discover(".")?; match action { + ToolchainAction::List => { + for name in commands::toolchain::list(&root)? { + let _ = writeln!(out, "{name}"); + } + } ToolchainAction::Import { name, bin, key } => { commands::toolchain::import(&root, &name, &bin, key)?; let _ = writeln!(out, "imported {name}"); @@ -178,6 +183,11 @@ fn run_comment(action: CommentAction, out: &mut impl std::io::Write) -> Result<()> { let root = LocalRoot::discover(".")?; match action { + CommentAction::List => { + for (id, comment) in commands::comment::list(&root)? { + let _ = writeln!(out, "{id}\t{}", comment.body); + } + } CommentAction::Add { path, body, @@ -214,6 +224,22 @@ Ok(()) } +fn run_redact(action: RedactAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + RedactAction::List => { + for (id, redaction) in commands::redact::list(&root)? { + let _ = writeln!(out, "{id}\t{}", redaction.reason); + } + } + RedactAction::Add { oid, reason, key } => { + commands::redact::add(&root, &oid, reason, key)?; + let _ = writeln!(out, "redacted {oid}"); + } + } + Ok(()) +} + fn run_hook(action: HookAction, out: &mut impl std::io::Write) -> Result<()> { let root = HostedRoot::open(".")?; match action {
crates/git-ents/src/commands/account.rs @@ -2,12 +2,40 @@ //! fixed `refs/meta/account` ref (`model.account`). use ents_model::{Account, MemberId, namespace}; +use gix_ref_store::RefStoreRead; use super::{actor, signer}; use crate::error::{Error, Result}; use crate::mutate::{Identity, outcome_to_result, propose_entity}; use crate::root::LocalRoot; +/// `git ents account show`: this repository's account identity. +/// +/// # Errors +/// +/// [`Error::NotFound`] if no account has been created yet +/// (`git ents account create` first). +pub fn show(root: &LocalRoot) -> Result<Account> { + #[expect( + clippy::expect_used, + clippy::unwrap_in_result, + reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal" + )] + let name: gix::refs::FullName = namespace::ACCOUNT_REF + .try_into() + .expect("fixed, valid refname"); + let Some(tip) = root.refs.get(name.as_ref())? else { + return Err(Error::NotFound { + what: "account".to_owned(), + }); + }; + let tree = super::commit_tree(&root.objects, tip)?; + Ok(facet_git_tree::deserialize::<Account>( + &tree, + &root.objects, + )?) +} + /// Run `git ents account create`. /// /// # Errors
crates/git-ents/src/commands/comment.rs @@ -12,6 +12,27 @@ use crate::mutate::{Identity, outcome_to_result, propose_entity}; use crate::root::LocalRoot; +/// `git ents comment list`: every comment recorded in this repository. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn list(root: &LocalRoot) -> Result<Vec<(String, Comment)>> { + let mut out = Vec::new(); + for entry in root.refs.iter_prefix("refs/meta/comments/")? { + let (name, tip) = entry?; + let path = name.as_bstr().to_string(); + let Some(id) = path.strip_prefix("refs/meta/comments/") else { + continue; + }; + let tree = super::commit_tree(&root.objects, tip)?; + if let Ok(comment) = facet_git_tree::deserialize::<Comment>(&tree, &root.objects) { + out.push((id.to_owned(), comment)); + } + } + Ok(out) +} + /// `git ents comment add`: anchor `body` to `path` (optionally `lines`) at /// `rev`. ///
crates/git-ents/src/commands/redact.rs @@ -3,13 +3,35 @@ //! (`receive.redaction-ingest`). use ents_model::{Redaction, namespace}; +use gix_ref_store::RefStoreRead; use super::{actor, signer}; use crate::error::{Error, Result}; use crate::mutate::{Identity, outcome_to_result, propose_entity}; use crate::root::LocalRoot; -/// Run `git ents redact <oid> --reason ...`. +/// `git ents redact list`: every redaction recorded in this repository. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn list(root: &LocalRoot) -> Result<Vec<(String, Redaction)>> { + let mut out = Vec::new(); + for entry in root.refs.iter_prefix("refs/meta/redactions/")? { + let (name, tip) = entry?; + let path = name.as_bstr().to_string(); + let Some(id) = path.strip_prefix("refs/meta/redactions/") else { + continue; + }; + let tree = super::commit_tree(&root.objects, tip)?; + if let Ok(redaction) = facet_git_tree::deserialize::<Redaction>(&tree, &root.objects) { + out.push((id.to_owned(), redaction)); + } + } + Ok(out) +} + +/// Run `git ents redact add <oid> --reason ...`. /// /// The record lands at `refs/meta/redactions/<id>`; the gate's default /// namespace-authorization arm requires admin-registered provenance for @@ -21,7 +43,7 @@ /// /// [`Error::InvalidArgument`] if `oid` does not parse as an object id; /// otherwise see [`crate::mutate::outcome_to_result`]. -pub fn run( +pub fn add( root: &LocalRoot, oid: &str, reason: String,
crates/git-ents/src/commands/toolchain.rs @@ -19,6 +19,23 @@ use crate::mutate::{Identity, outcome_to_result, propose_entity}; use crate::root::LocalRoot; +/// `git ents toolchain list`: every toolchain name currently defined. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn list(root: &LocalRoot) -> Result<Vec<String>> { + let mut out = Vec::new(); + for entry in root.refs.iter_prefix("refs/meta/toolchains/")? { + let (name, _) = entry?; + let path = name.as_bstr().to_string(); + if let Some(rest) = path.strip_prefix("refs/meta/toolchains/") { + out.push(rest.to_owned()); + } + } + Ok(out) +} + /// `git ents toolchain import`: embed `bin` whole as toolchain `name`. /// /// # Errors
crates/git-ents/tests/account.rs @@ -1,0 +1,33 @@ +//! Integration coverage for `git ents account` against a real local +//! composition root (`roots.local`) — creating this repository's account +//! identity, then reading it back (`model.account`). + +#![allow(clippy::expect_used, reason = "integration test")] + +mod common; + +use git_ents::commands::{account, members}; +use git_ents::root::LocalRoot; + +/// `git ents account show` reads back exactly what `create` wrote — the +/// only read command against the fixed `refs/meta/account` ref +/// (`model.account`). +// @relation(roots.local, model.account, scope=function, role=Verifies) +#[test] +fn show_reads_back_the_created_identity() { + let fixture = common::Fixture::new(1); + let root = LocalRoot::open(fixture.path()).expect("opens"); + members::add(&root, "jdc", None, Some(fixture.key_path.clone())).expect("bootstrap"); + + account::create( + &root, + Some("jdc".to_owned()), + "joseph.carpinelli@icloud.com".to_owned(), + Some(fixture.key_path.clone()), + ) + .expect("creates"); + + let account = account::show(&root).expect("shows"); + assert_eq!(account.member, ents_model::MemberId::new("jdc")); + assert_eq!(account.login, "joseph.carpinelli@icloud.com"); +}
crates/git-ents/tests/comment.rs @@ -1,0 +1,73 @@ +//! Integration coverage for `git ents comment` against a real local +//! composition root (`roots.local`) — adding a comment, then listing it +//! back (`model.comment`). + +#![allow( + clippy::expect_used, + clippy::indexing_slicing, + reason = "integration test" +)] + +mod common; + +use std::path::Path; +use std::process::Command; + +use git_ents::commands::comment; +use git_ents::root::LocalRoot; + +/// Seed `dir`'s working tree with `path` and commit it under a fixed test +/// identity — the content a comment anchors to, distinct from the signed +/// `refs/meta/*` mutation commits `common::Fixture`'s key produces. +fn commit_file(dir: &Path, path: &str, contents: &str) { + std::fs::write(dir.join(path), contents).expect("write"); + let status = Command::new("git") + .arg("-C") + .arg(dir) + .args(["add", "-A"]) + .status() + .expect("git add"); + assert!(status.success()); + let status = Command::new("git") + .arg("-C") + .arg(dir) + .args([ + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "commit", + "-q", + "-m", + "seed", + ]) + .status() + .expect("git commit"); + assert!(status.success()); +} + +/// `git ents comment list` surfaces every recorded comment's id and body — +/// the only way to discover a comment's id before `show` can be run +/// against it (`model.comment`). +// @relation(roots.local, model.comment, scope=function, role=Verifies) +#[test] +fn list_returns_every_recorded_comment() { + let fixture = common::Fixture::new(1); + commit_file(fixture.path(), "file.txt", "line one\nline two\n"); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let id = comment::add( + &root, + "file.txt", + "looks off by one".to_owned(), + None, + "HEAD", + Some(fixture.key_path.clone()), + ) + .expect("adds"); + + let listed = comment::list(&root).expect("lists"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].0, id); + assert_eq!(listed[0].1.body, "looks off by one"); +}
crates/git-ents/tests/redact.rs @@ -1,0 +1,38 @@ +//! Integration coverage for `git ents redact` against a real local +//! composition root (`roots.local`) — recording a redaction, then listing +//! it back (`model.redaction`). + +#![allow( + clippy::expect_used, + clippy::indexing_slicing, + reason = "integration test" +)] + +mod common; + +use git_ents::commands::redact; +use git_ents::root::LocalRoot; + +/// `git ents redact list` surfaces every recorded redaction's id and +/// reason — the only way to discover one before `git ents redact add`'s +/// own record can be inspected again (`model.redaction`). +// @relation(roots.local, model.redaction, scope=function, role=Verifies) +#[test] +fn list_returns_every_recorded_redaction() { + let fixture = common::Fixture::new(1); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let oid = "abababababababababababababababababababab"; + redact::add( + &root, + oid, + "leaked credential".to_owned(), + Some(fixture.key_path.clone()), + ) + .expect("adds"); + + let listed = redact::list(&root).expect("lists"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].0, oid); + assert_eq!(listed[0].1.reason, "leaked credential"); +}
crates/git-ents/tests/toolchain.rs @@ -1,0 +1,29 @@ +//! Integration coverage for `git ents toolchain` against a real local +//! composition root (`roots.local`) — importing a toolchain, then listing +//! it back. + +#![allow(clippy::expect_used, reason = "integration test")] + +mod common; + +use git_ents::commands::toolchain; +use git_ents::root::LocalRoot; + +/// `git ents toolchain list` names every imported toolchain — the only way +/// to discover a toolchain's name before `view`/`log` can be run against it +/// (`model.toolchain`). +// @relation(roots.local, model.toolchain, scope=function, role=Verifies) +#[test] +fn list_names_every_imported_toolchain() { + let fixture = common::Fixture::new(1); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let bin = fixture.path().join("bin"); + std::fs::create_dir(&bin).expect("mkdir"); + std::fs::write(bin.join("tool"), b"#!/bin/sh\necho hi\n").expect("write"); + + toolchain::import(&root, "rust-stable", &bin, Some(fixture.key_path.clone())).expect("imports"); + + let listed = toolchain::list(&root).expect("lists"); + assert_eq!(listed, vec!["rust-stable".to_owned()]); +}