roots: add list/show actions to toolchain, comment, redact, and account
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.
No reviews of this commit yet — record a verdict below.
Start a review
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/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()]);
+}