model: migrate the forge and surface crates off uuid onto derived identity
commit b1e29b9
model: migrate the forge and surface crates off uuid onto derived identity
The kernel (phase-10-kernel) already recomputes every meta-ref name from
its signed content; this closes the loop on the crates that write those
refs. A comment’s and an issue’s id becomes the oid of its own genesis
commit (sign-then-name via ents_receive::propose_genesis), never a
locally minted uuid. A review moves to the composite
reviews/<target>/<member> key ents-model already exposes: its reviewed-
commit field is renamed target to mirror ResultRecord/Redaction’s own
accessor convention, and re-reviewing a commit whose ancestor this
member already reviewed advances the same two refs (entity + retention
pin) fast-forward rather than minting an unrelated review
(model.review-pin). git-ents and ents-web pick up Member’s new id field,
Effect’s new name field, and read results back as ResultRecord instead
of a bare Status. Porcelain and web abbreviate a comment/issue id to
seven hex characters for display, the way git abbreviates a commit oid;
full ids remain in refnames and machine-readable (--porcelain) output.
feat: add ents_forge::abbreviate_id for git-style short-oid display
feat: add review::new’s fast-forward re-review path via ancestor lookup
feat: resolve a review’s own member id from the signer’s enrolled key,
falling back to a fingerprint placeholder when unenrolled (mirrors
`git_ents::commands::serve::build_state’s identical fallback)
remove: `uuid from the workspace — no crate mints an id anymore
Assisted-by: Claude:claude-sonnet-5
crates/cli/git-ents/src/cli.rs
@@ -99,9 +99,10 @@
#[facet(args::subcommand)]
action: IssueAction,
},
- /// Review a commit: a verdict plus a body at `refs/meta/reviews/<id>`,
- /// with a retention pin at `refs/meta/pins/reviews/<id>` keeping the
- /// reviewed commit reachable.
+ /// Review a commit: a verdict plus a body at
+ /// `refs/meta/reviews/<target>/<member>`, with a retention pin at
+ /// `refs/meta/pins/reviews/<target>/<member>` keeping the reviewed
+ /// commit reachable.
Review {
/// The review action to run.
#[facet(args::subcommand)]
crates/cli/git-ents/src/exe.rs
@@ -223,7 +223,9 @@
let _ = writeln!(
out,
"{}\t{}\t{}",
- row.id, row.comment.state, row.comment.body
+ ents_forge::abbreviate_id(&row.id),
+ row.comment.state,
+ row.comment.body
);
}
}
@@ -287,7 +289,13 @@
match action {
IssueAction::List => {
for (id, issue) in commands::issue::list(&root)? {
- let _ = writeln!(out, "{id}\t{}\t{}", issue.state, issue.title);
+ let _ = writeln!(
+ out,
+ "{}\t{}\t{}",
+ ents_forge::abbreviate_id(&id),
+ issue.state,
+ issue.title
+ );
}
}
IssueAction::Show { id } => {
@@ -342,24 +350,32 @@
verdict,
body,
};
- let id = commands::review::new(&root, new, key)?;
- let _ = writeln!(out, "reviewed {id}");
+ let target = commands::review::new(&root, new, key)?;
+ let _ = writeln!(out, "reviewed {}", ents_forge::abbreviate_id(&target));
}
ReviewAction::List { target } => {
- for (id, review) in commands::review::list(&root, target)? {
- let _ = writeln!(out, "{id}\t{}\t{}", review.commit(), review.verdict);
+ for ((review_target, member), review) in commands::review::list(&root, target)? {
+ let _ = writeln!(
+ out,
+ "{}\t{member}\t{}\t{}",
+ ents_forge::abbreviate_id(&review_target),
+ review.target(),
+ review.verdict
+ );
}
}
- ReviewAction::Show { id } => {
- let (review, thread) = commands::review::show(&root, &id)?;
- let _ = writeln!(out, "commit: {}", review.commit());
+ ReviewAction::Show { target, member } => {
+ let (review, thread) = commands::review::show(&root, &target, &member)?;
+ let _ = writeln!(out, "target: {}", review.target());
let _ = writeln!(out, "verdict: {}", review.verdict);
let _ = writeln!(out, "body: {}", review.body);
for (comment_id, comment) in thread {
let _ = writeln!(
out,
- "comment {comment_id}\t{}\t{}",
- comment.state, comment.body
+ "comment {}\t{}\t{}",
+ ents_forge::abbreviate_id(&comment_id),
+ comment.state,
+ comment.body
);
}
}
crates/cli/git-ents/src/lib.rs
@@ -63,7 +63,7 @@
//! };
//! let identity = Identity { actor, sign: &|payload| signer.sign(payload) };
//!
-//! let member = ents_model::Member::new(signer.public_openssh(), Provenance::AdminRegistered);
+//! let member = ents_model::Member::new("jdc", signer.public_openssh(), Provenance::AdminRegistered);
//! let name = ents_model::namespace::member_ref(&MemberId::new("jdc")).expect("valid");
//! let outcome = propose_entity(
//! &root.refs, &root.objects, &root.events, name.clone(), &member,
crates/cli/git-ents/tests/reconcile.rs
@@ -18,7 +18,7 @@
mod common;
-use ents_model::Effect;
+use ents_model::{Effect, ResultRecord, Status};
use git_ents::root::HostedRoot;
use gix_object::{Commit, Kind, Write as _};
use gix_ref_store::{Expected, RefEdit, RefStore};
@@ -65,6 +65,7 @@
fn define_effect(root: &HostedRoot, name: &str, trigger: &str) {
let tree = facet_git_tree::serialize_into(
&Effect {
+ name: name.to_owned(),
trigger: trigger.to_owned(),
toolchains: vec![],
run: "true".to_owned(),
@@ -158,10 +159,15 @@
let oid = advance_branch(&root, "refs/heads/main", 100);
// Record a result directly (bypassing `write_result`'s signing
- // requirement — this test only needs the ref to exist).
+ // requirement) — a full `ResultRecord`, not a bare `Status`, since
+ // `Evaluator::outstanding` strictly decodes the results tree as one
+ // (`model.result-identity`).
let short = &oid.to_string()[..12];
- let status_tree = facet_git_tree::serialize_into(&ents_model::Status::Pass, &root.objects)
- .expect("serialize");
+ let status_tree = facet_git_tree::serialize_into(
+ &ResultRecord::new("unit", oid, Status::Pass),
+ &root.objects,
+ )
+ .expect("serialize");
let commit = gix_object::Commit {
tree: status_tree,
parents: Default::default(),
crates/cli/git-ents/tests/review.rs
@@ -2,8 +2,10 @@
//! composition root (`roots.local`): reviewing a commit writes both refs
//! `model.review` requires — the entity ref and its retention pin
//! (`model.review-pin`), the pin's parents including the reviewed commit
-//! and its tree the empty tree — and a review's discussion thread
-//! surfaces comments naming it as their context (`model.comment-context`).
+//! and its tree the empty tree — a review's discussion thread surfaces
+//! comments naming it as their context (`model.comment-context`), and
+//! re-reviewing a descendant advances the same composite-keyed ref
+//! fast-forward rather than minting a new one (`model.review-pin`).
#![allow(
clippy::expect_used,
@@ -18,7 +20,7 @@
use ents_forge::comment::NewComment;
use ents_forge::review::NewReview;
-use git_ents::commands::{comment, review};
+use git_ents::commands::{comment, members, review};
use git_ents::root::LocalRoot;
use gix_object::{CommitRef, Find, Write as _};
use gix_ref_store::RefStoreRead as _;
@@ -61,34 +63,38 @@
}
/// `model.review`, `model.review-pin`: `git ents review new` writes both
-/// the review's own entity ref and its retention pin, and the pin's tip
-/// commit is a merge-shaped, empty-tree commit whose parents include the
-/// reviewed commit — the reachability edge `model.review-pin` requires.
-// @relation(model.review, model.review-pin, roots.local, scope=function, role=Verifies)
+/// the review's own entity ref (keyed `reviews/<target>/<member>`) and its
+/// retention pin, and the pin's tip commit is a merge-shaped, empty-tree
+/// commit whose parents include the reviewed commit — the reachability
+/// edge `model.review-pin` requires.
+// @relation(model.review, model.review-pin, meta-ref.identity-binding, roots.local, scope=function, role=Verifies)
#[test]
fn review_new_writes_both_refs_with_the_pin_parented_on_the_reviewed_commit() {
let fixture = common::Fixture::new(1);
let reviewed = commit_file(fixture.path(), "file.txt", "line one\n");
let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
let new = NewReview {
target: "HEAD".to_owned(),
verdict: "approve".to_owned(),
body: "looks good".to_owned(),
};
- let id = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews");
+ let target = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews");
// The entity ref exists and reads back verdict, body, and the
// reviewed commit as a plain data field — no pin read required.
- let (found, _thread) = review::show(&root, &id).expect("shows");
+ let (found, _thread) = review::show(&root, &target, "reviewer").expect("shows");
assert_eq!(found.verdict, "approve");
assert_eq!(found.body, "looks good");
- assert_eq!(found.commit(), reviewed);
+ assert_eq!(found.target(), reviewed);
// The pin ref exists; its tip's parents include the reviewed commit,
// and its tree is the empty tree — the sole exception to
// `meta-ref.namespace`'s tree-is-the-entity shape.
- let pin_ref = ents_model::namespace::review_pin_ref(&id).expect("valid");
+ let pin_ref =
+ ents_model::namespace::review_pin_ref(&target, &ents_model::MemberId::new("reviewer"))
+ .expect("valid");
let pin_tip = root
.refs
.get(pin_ref.as_ref())
@@ -113,21 +119,22 @@
}
/// `model.comment-context`, `model.review`: a comment naming
-/// `reviews/<id>` as its context surfaces in `git ents review show`'s
-/// thread — the review itself stores no list of its comments.
+/// `reviews/<target>/<member>` as its context surfaces in `git ents review
+/// show`'s thread — the review itself stores no list of its comments.
// @relation(model.review, model.comment-context, roots.local, scope=function, role=Verifies)
#[test]
fn review_show_surfaces_a_context_comment() {
let fixture = common::Fixture::new(1);
commit_file(fixture.path(), "file.txt", "line one\n");
let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
let new = NewReview {
target: "HEAD".to_owned(),
verdict: "request-changes".to_owned(),
body: "one nit".to_owned(),
};
- let id = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews");
+ let target = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews");
let draft = NewComment {
body: "please rename this".to_owned(),
@@ -135,44 +142,104 @@
lines: None,
rev: "HEAD".to_owned(),
worktree: false,
- context: Some(format!("reviews/{id}")),
+ context: Some(format!("reviews/{target}/reviewer")),
parent: None,
};
comment::add(&root, draft, Some(fixture.key_path.clone())).expect("comments");
- let (_review, thread) = review::show(&root, &id).expect("shows");
+ let (_review, thread) = review::show(&root, &target, "reviewer").expect("shows");
assert_eq!(thread.len(), 1);
assert_eq!(thread[0].1.body, "please rename this");
}
/// `git ents review list [--target rev]`: filtering by target keeps only
-/// reviews of that commit.
+/// reviews of that commit — exercised across two different reviewers
+/// (rather than one reviewer reviewing two commits) since, per
+/// `model.review-pin`, one member reviewing a descendant commit advances
+/// their existing thread rather than opening a second one; two distinct
+/// review entities need two distinct reviewers.
// @relation(model.review, roots.local, scope=function, role=Verifies)
#[test]
fn review_list_filters_by_target() {
let fixture = common::Fixture::new(1);
+ let other_key = fixture.path().join(".id_ed25519_bob");
+ common::write_key(&other_key, 2);
let first = commit_file(fixture.path(), "file.txt", "line one\n");
let second = commit_file(fixture.path(), "file.txt", "line one\nline two\n");
let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "alice", None, Some(fixture.key_path.clone())).expect("enrolls alice");
+ members::add(&root, "bob", None, Some(other_key.clone())).expect("enrolls bob");
let review_of_first = NewReview {
target: first.to_string(),
verdict: "approve".to_owned(),
body: String::new(),
};
- let first_id =
+ let first_target =
review::new(&root, review_of_first, Some(fixture.key_path.clone())).expect("reviews");
let review_of_second = NewReview {
target: second.to_string(),
verdict: "approve".to_owned(),
body: String::new(),
};
- review::new(&root, review_of_second, Some(fixture.key_path.clone())).expect("reviews");
+ review::new(&root, review_of_second, Some(other_key)).expect("reviews");
let all = review::list(&root, None).expect("lists");
assert_eq!(all.len(), 2);
let filtered = review::list(&root, Some(first.to_string())).expect("lists");
assert_eq!(filtered.len(), 1);
- assert_eq!(filtered[0].0, first_id);
+ assert_eq!(filtered[0].0.0, first_target);
+ assert_eq!(filtered[0].0.1, ents_model::MemberId::new("alice"));
+}
+
+/// `model.review-pin`: re-reviewing a descendant of a commit this member
+/// already reviewed advances the *same* two refs fast-forward — the
+/// composite key stays anchored at the original genesis target, and
+/// [`ents_forge::review::Review::target`] moves to the newly reviewed
+/// commit — rather than minting a second, unrelated review.
+// @relation(model.review, model.review-pin, meta-ref.identity-binding, roots.local, scope=function, role=Verifies)
+#[test]
+fn re_reviewing_a_descendant_advances_the_same_ref_fast_forward() {
+ let fixture = common::Fixture::new(1);
+ let first = commit_file(fixture.path(), "file.txt", "line one\n");
+ let second = commit_file(fixture.path(), "file.txt", "line one\nline two\n");
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
+
+ let initial = NewReview {
+ target: first.to_string(),
+ verdict: "request-changes".to_owned(),
+ body: "please address this".to_owned(),
+ };
+ let first_target =
+ review::new(&root, initial, Some(fixture.key_path.clone())).expect("reviews");
+ assert_eq!(first_target, first.to_string());
+
+ // Re-review the descendant: the CLI's own signer/member resolution
+ // finds the existing "reviewer" review of `first`, an ancestor of
+ // `second`, and advances it in place.
+ let follow_up = NewReview {
+ target: second.to_string(),
+ verdict: "approve".to_owned(),
+ body: "looks good now".to_owned(),
+ };
+ let advanced_target =
+ review::new(&root, follow_up, Some(fixture.key_path.clone())).expect("re-reviews");
+
+ // The composite key's target segment is unchanged (still genesis-keyed
+ // at `first`), but the entity's own recorded target has moved to
+ // `second`, and there is still exactly one review by this reviewer.
+ assert_eq!(advanced_target, first_target);
+ let (review, _thread) = review::show(&root, &first_target, "reviewer").expect("shows");
+ assert_eq!(review.target(), second);
+ assert_eq!(review.verdict, "approve");
+ assert_eq!(review.body, "looks good now");
+
+ let all = review::list(&root, None).expect("lists");
+ assert_eq!(
+ all.len(),
+ 1,
+ "re-review advances in place, not a second row"
+ );
}
crates/forge/ents-forge/src/lib.rs
@@ -30,11 +30,13 @@
//! [`review::new`] (writes both the entity ref and the retention pin),
//! [`review::list`], [`review::show`] (reusing [`comment::thread`] for
//! the review's discussion rather than a second aggregation).
-//! - `meta-ref.granularity` — one ref per issue/comment/review
-//! (`refs/meta/issues/<id>`, `refs/meta/comments/<id>`,
-//! `refs/meta/reviews/<id>`); see [`comment::add`] and [`review::new`]
-//! for how an id is generated locally rather than derived from the
-//! entity itself.
+//! - `meta-ref.granularity`, `meta-ref.identity-binding` — one ref per
+//! issue/comment (`refs/meta/issues/<id>`, `refs/meta/comments/<id>`,
+//! `<id>` the oid of the entity's own genesis commit), and one ref per
+//! `(target, reviewer)` composite key for a review
+//! (`refs/meta/reviews/<target>/<member>`); see [`comment::add`],
+//! [`issue::new`], and [`review::new`] for how each id derives from the
+//! signed content itself rather than being minted.
//! - `meta-ref.typed-tree` — every entity module's round-trip test.
//! - `anchor.definition`, `anchor.projection`, `anchor.working-tree` —
//! [`comment::add`], [`comment::show`], and [`comment::list_projected`],
@@ -93,6 +95,47 @@
pub use error::{Error, Result};
pub use issue::Issue;
+/// The genesis oid a comment or issue ref's name binds to — the final
+/// segment of `refs/meta/comments/<id>` or `refs/meta/issues/<id>`
+/// (`meta-ref.identity-binding`), read back from the
+/// [`gix::refs::FullName`] `ents_receive::propose_genesis` returns rather
+/// than tracked separately, since the ref name and the id are the same
+/// string by construction. Shared by [`comment::add`], [`comment::reply`],
+/// and [`issue::new`] rather than duplicated per module, unlike this
+/// crate's accepted small `commit_tree` copies: this one is a single
+/// one-liner with no domain logic to diverge per caller.
+pub(crate) fn genesis_id(ref_name: &gix::refs::FullName) -> String {
+ ref_name
+ .as_bstr()
+ .to_string()
+ .rsplit('/')
+ .next()
+ .unwrap_or_default()
+ .to_owned()
+}
+
+/// Abbreviate a genesis-oid entity id (`model.comment`, `model.issue`) to a
+/// short prefix for display — the same seven-hex-character length git's own
+/// short object id uses (`model.issue`: "porcelain abbreviates ids the way
+/// git abbreviates commit oids"). The full id, never this, belongs in a
+/// refname or in machine-readable output (`lens.parity`).
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::abbreviate_id;
+///
+/// assert_eq!(
+/// abbreviate_id("0123456789abcdef0123456789abcdef01234567"),
+/// "0123456"
+/// );
+/// assert_eq!(abbreviate_id("abc"), "abc");
+/// ```
+#[must_use]
+pub fn abbreviate_id(id: &str) -> &str {
+ id.get(..7).unwrap_or(id)
+}
+
#[cfg(test)]
mod tests {
use facet::Facet as _;
crates/cli/ents-web/src/pages/account.rs
@@ -157,7 +157,20 @@
)?))
}
-fn resolve_member_by_key<O: Find>(state: &AppState<O>, pubkey: &str) -> Result<MemberId> {
+/// Resolve `pubkey` to the enrolled member whose stored key matches it, or
+/// [`Error::NotFound`] when none does — shared with
+/// `crate::pages::commits::review`, which needs the same "which member is
+/// this session" lookup to key a review's composite
+/// `refs/meta/reviews/<target>/<member>` ref (`model.review`).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if no enrolled member's key matches `pubkey`;
+/// otherwise propagates a ref-store or object read failure.
+pub(crate) fn resolve_member_by_key<O: Find>(
+ state: &AppState<O>,
+ pubkey: &str,
+) -> Result<MemberId> {
for entry in state.refs.iter_prefix("refs/meta/member/")? {
let (name, tip) = entry?;
let path = name.as_bstr().to_string();
crates/cli/ents-web/src/pages/commits.rs
@@ -307,8 +307,9 @@
/// Every review targeting `commit_id` (`ents_forge::review::list` filtered
/// to this commit, `model.review`), each rendering its verdict prominently,
/// its body as AsciiDoc, and its reviewer (from the review ref's own tip
-/// commit chain, `meta-ref.trailers` -- a review stores no author field),
-/// followed by its discussion: the comments naming `reviews/<id>` as their
+/// commit chain, `meta-ref.identity-binding` -- a review stores no author
+/// field, only its composite `(target, member)` key), followed by its
+/// discussion: the comments naming `reviews/<target>/<member>` as their
/// context (`ents_forge::comment::thread`, `model.comment-context`),
/// rendered through the same shared `super::comments::thread_section` an
/// issue's thread uses. A "start a review" form closes the section
@@ -330,16 +331,16 @@
let return_to = format!("/commit/{oid}");
html! {
h2 { "reviews" }
- @for (id, review) in &reviews {
+ @for ((target, member), review) in &reviews {
div.card {
div.comment-meta {
span.verdict { (review.verdict) }
- @let reviewer = ents_model::namespace::review_ref(id)
+ span.author { (member) }
+ @let reviewer = ents_model::namespace::review_ref(target, member)
.ok()
.and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
.and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
- @if let Some((author, seconds)) = &reviewer {
- span.author { (author) }
+ @if let Some((_author, seconds)) = &reviewer {
span { (super::ago(*seconds)) }
}
}
@@ -349,22 +350,28 @@
@let thread = ents_forge::comment::thread(
state.refs.as_ref(),
&*state.objects(),
- &format!("reviews/{id}"),
+ &format!("reviews/{target}/{member}"),
).unwrap_or_default();
(super::comments::thread_section(state, session, &thread, &return_to))
- (review_comment_form(session, id, &return_to))
+ (review_comment_form(session, target, member, &return_to))
}
}
(start_review_form(session, oid))
}
}
-/// The comment-on-this-review form (`POST /reviews/{id}/comment`): a
-/// contextual comment naming `reviews/<id>` (`model.comment-context`), so a
-/// review's discussion can start from the web and not only the CLI or lens.
-fn review_comment_form(session: &Session, id: &str, return_to: &str) -> Markup {
+/// The comment-on-this-review form (`POST /reviews/{target}/{member}/comment`):
+/// a contextual comment naming `reviews/<target>/<member>`
+/// (`model.comment-context`), so a review's discussion can start from the
+/// web and not only the CLI or lens.
+fn review_comment_form(
+ session: &Session,
+ target: &str,
+ member: &ents_model::MemberId,
+ return_to: &str,
+) -> Markup {
html! {
- form method="post" action=(format!("/reviews/{id}/comment")) {
+ form method="post" action=(format!("/reviews/{target}/{member}/comment")) {
(super::csrf_input(session))
input type="hidden" name="return_to" value=(return_to);
label { "comment on this review" textarea name="body" {} }
@@ -373,7 +380,7 @@
}
}
-/// The form fields `POST /reviews/{id}/comment` accepts.
+/// The form fields `POST /reviews/{target}/{member}/comment` accepts.
#[derive(Debug, Deserialize)]
pub struct ReviewCommentForm {
/// The comment's body text.
@@ -386,10 +393,10 @@
return_to: String,
}
-/// `POST /reviews/{id}/comment`: a comment naming `reviews/<id>` as its
-/// context (`model.comment-context`) -- an ordinary
-/// [`ents_forge::comment::add`], contextual and unanchored, joining the
-/// review's discussion thread the moment it lands.
+/// `POST /reviews/{target}/{member}/comment`: a comment naming
+/// `reviews/<target>/<member>` as its context (`model.comment-context`) --
+/// an ordinary [`ents_forge::comment::add`], contextual and unanchored,
+/// joining the review's discussion thread the moment it lands.
///
/// # Errors
///
@@ -399,7 +406,7 @@
pub async fn review_comment<O>(
State(state): State<Arc<AppState<O>>>,
axum::Extension(session): axum::Extension<Session>,
- Path(id): Path<String>,
+ Path((target, member)): Path<(String, String)>,
Form(form): Form<ReviewCommentForm>,
) -> Result<impl IntoResponse>
where
@@ -413,7 +420,7 @@
lines: None,
rev: "HEAD".to_owned(),
worktree: false,
- context: Some(format!("reviews/{id}")),
+ context: Some(format!("reviews/{target}/{member}")),
parent: None,
};
let (_comment_id, outcome) = ents_forge::comment::add(
@@ -483,18 +490,20 @@
O: Find + Write + Send + 'static,
{
super::require_csrf(&session, &form.csrf)?;
+ let member = reviewer_member_id(&state);
let identity = state.identity.as_ref();
let new = ents_forge::review::NewReview {
target: oid.clone(),
verdict: form.verdict,
body: form.body,
};
- let (_id, outcome) = ents_forge::review::new(
+ let (_target, outcome) = ents_forge::review::new(
state.refs.as_ref(),
&*state.objects(),
state.events.as_ref(),
&state.path,
new,
+ &member,
&crate::receive_identity!(identity),
state.mode,
)?;
@@ -502,6 +511,36 @@
Ok(Redirect::to(&format!("/commit/{oid}")))
}
+/// The acting session's member id -- the composite review key's
+/// `<member>` segment -- resolved the same way
+/// [`super::account::resolve_member_by_key`] does, falling back to a short
+/// hash of the public key when no enrolled member matches: mirrors
+/// `git_ents::commands::serve::build_state`'s identical fallback
+/// (`roots.web-signing`: an unenrolled local identity may still review,
+/// exactly as it may still browse and comment).
+fn reviewer_member_id<O: Find>(state: &AppState<O>) -> ents_model::MemberId {
+ let pubkey = state.identity.public_openssh();
+ super::account::resolve_member_by_key(state, &pubkey)
+ .unwrap_or_else(|_source| ents_model::MemberId::new(short_key_fingerprint(&pubkey)))
+}
+
+/// The first twelve characters of `pubkey`'s key-material token -- mirrors
+/// `git_ents::commands::short_fingerprint`'s identical fallback label.
+fn short_key_fingerprint(pubkey: &str) -> String {
+ let hex: String = pubkey
+ .split_whitespace()
+ .nth(1)
+ .unwrap_or(pubkey)
+ .chars()
+ .take(12)
+ .collect();
+ if hex.is_empty() {
+ "member".to_owned()
+ } else {
+ hex
+ }
+}
+
/// Validate `text` as a full, well-formed object id -- hex characters only,
/// at the exact length the served repository's hash kind expects (this
/// page does not resolve abbreviated prefixes; [`super::commits::list`]'s
crates/cli/git-ents/src/commands/effect.rs
@@ -2,7 +2,7 @@
//! (`model.effect-definition`, `effect.local-run`).
use ents_effect::run::{run_effect, short_oid};
-use ents_model::{Effect, Status, namespace};
+use ents_model::{Effect, ResultRecord, Status, namespace};
use ents_receive::{Identity, propose_entity};
use gix_ref_store::RefStoreRead;
@@ -57,6 +57,7 @@
let signer = signer(root, key)?;
let effect = Effect {
+ name: name.to_owned(),
trigger: on,
toolchains,
run,
@@ -105,7 +106,9 @@
None => None,
Some(result_tip) => {
let tree = super::commit_tree(&root.objects, result_tip)?;
- facet_git_tree::deserialize::<Status>(&tree, &root.objects).ok()
+ facet_git_tree::deserialize::<ResultRecord>(&tree, &root.objects)
+ .ok()
+ .map(|record| record.status)
}
}
}
@@ -216,8 +219,8 @@
for entry in root.refs.iter_prefix(&prefix)? {
let (_, tip) = entry?;
let tree = super::commit_tree(&root.objects, tip)?;
- if let Ok(status) = facet_git_tree::deserialize::<Status>(&tree, &root.objects) {
- out.push((tip, status));
+ if let Ok(record) = facet_git_tree::deserialize::<ResultRecord>(&tree, &root.objects) {
+ out.push((tip, record.status));
}
}
Ok(out)
crates/cli/git-ents/src/commands/members.rs
@@ -45,7 +45,7 @@
) -> Result<()> {
let signer = signer(root, key)?;
let pubkey = pubkey.unwrap_or_else(|| signer.public_openssh());
- let member = Member::new(pubkey, Provenance::AdminRegistered);
+ let member = Member::new(MemberId::new(username), pubkey, Provenance::AdminRegistered);
let name = namespace::member_ref(&MemberId::new(username))?;
let identity = Identity {
actor: actor(&signer),
crates/cli/git-ents/src/commands/review.rs
@@ -1,13 +1,16 @@
//! `git ents review`: a thin wrapper around `ents_forge::review`'s
//! business logic — this module only resolves the signer/actor identity
-//! against [`LocalRoot`] and translates a reached `Outcome` into a
-//! CLI-facing [`Result`] (`crate::mutate::outcome_to_result`), exactly as
-//! every other mutation command does. Every operation is the library call
-//! itself (`lens.parity`); nothing here re-implements one.
+//! against [`LocalRoot`] (plus, for [`new`], the reviewer's own member id —
+//! the composite key's `<member>` segment, `meta-ref.identity-binding`) and
+//! translates a reached `Outcome` into a CLI-facing [`Result`]
+//! (`crate::mutate::outcome_to_result`), exactly as every other mutation
+//! command does. Every operation is the library call itself
+//! (`lens.parity`); nothing here re-implements one.
use ents_forge::comment::Comment;
use ents_forge::review;
use ents_forge::review::{NewReview, Review};
+use ents_model::MemberId;
use ents_receive::Identity;
use super::{actor, signer};
@@ -15,8 +18,10 @@
use crate::mutate::outcome_to_result;
use crate::root::LocalRoot;
-/// `git ents review new`: review a commit, writing both its entity ref and
-/// its retention pin.
+/// `git ents review new`: review a commit as the signer's own member,
+/// writing both its entity ref and its retention pin — or, when this
+/// member already has a review of an ancestor of the target, advancing
+/// that same review fast-forward (`model.review-pin`).
///
/// # Errors
///
@@ -25,30 +30,55 @@
/// [`crate::mutate::outcome_to_result`] for how a reached refusal renders.
pub fn new(root: &LocalRoot, new: NewReview, key: Option<std::path::PathBuf>) -> Result<String> {
let signer = signer(root, key)?;
+ let member = reviewer_member_id(root, &signer)?;
let identity = Identity {
actor: actor(&signer),
sign: &|payload| signer.sign(payload),
};
- let (id, outcome) = review::new(
+ let (target, outcome) = review::new(
&root.refs,
&root.objects,
&root.events,
&root.path,
new,
+ &member,
&identity,
root.mode(),
)?;
outcome_to_result(outcome, None)?;
- Ok(id)
+ Ok(target)
+}
+
+/// The member id owning the signer's key — the composite review key's
+/// `<member>` segment — via the same key-to-member scan
+/// [`super::members::find_by_key`] already performs for `git ents members
+/// check`. When the signing key enrolls no member (`roots.local`'s
+/// advisory gate never requires enrollment before a local mutation lands),
+/// falls back to the same fingerprint-derived placeholder [`super::actor`]
+/// already uses for its own commit signature: a composite review key still
+/// needs *some* member segment, and `gate.owner-mutation` — not this
+/// fallback — is what actually keys ownership once a real deployment's
+/// mandatory gate is in force.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+fn reviewer_member_id(root: &LocalRoot, signer: &crate::sign::Signer) -> Result<MemberId> {
+ let pubkey = signer.public_openssh();
+ if let Some((username, _state)) = super::members::find_by_key(root, &pubkey)? {
+ return Ok(MemberId::new(username));
+ }
+ Ok(MemberId::new(super::short_fingerprint(signer)))
}
/// `git ents review list [--target rev]`: every review recorded in this
-/// repository, optionally filtered to those reviewing `target`.
+/// repository, keyed by its composite `(target, member)` segments,
+/// optionally filtered to those reviewing `target`.
///
/// # Errors
///
/// Propagates a ref-store, object read, or revision-resolution failure.
-pub fn list(root: &LocalRoot, target: Option<String>) -> Result<Vec<(String, Review)>> {
+pub fn list(root: &LocalRoot, target: Option<String>) -> Result<Vec<((String, MemberId), Review)>> {
Ok(review::list(
&root.refs,
&root.objects,
@@ -57,12 +87,22 @@
)?)
}
-/// `git ents review show`: `id`'s review, plus its discussion thread.
+/// `git ents review show`: `target`/`member`'s review, plus its discussion
+/// thread.
///
/// # Errors
///
/// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`])
-/// if `id` has no review ref.
-pub fn show(root: &LocalRoot, id: &str) -> Result<(Review, Vec<(String, Comment)>)> {
- Ok(review::show(&root.refs, &root.objects, id)?)
+/// if `target`/`member` has no review ref.
+pub fn show(
+ root: &LocalRoot,
+ target: &str,
+ member: &str,
+) -> Result<(Review, Vec<(String, Comment)>)> {
+ Ok(review::show(
+ &root.refs,
+ &root.objects,
+ target,
+ &MemberId::new(member),
+ )?)
}
crates/forge/ents-forge/src/comment/cli.rs
@@ -69,7 +69,7 @@
#[facet(args::named, default)]
worktree: bool,
/// Canonical ref path below refs/meta/ of the entity this comment
- /// belongs to, e.g. `issues/<id>` or `reviews/<id>`.
+ /// belongs to, e.g. `issues/<id>` or `reviews/<target>/<member>`.
#[facet(args::named)]
context: Option<String>,
/// Id of the comment this one replies to.
crates/forge/ents-forge/src/comment/command.rs
@@ -14,7 +14,7 @@
//! lens are three callers of exactly these functions.
use ents_anchor::{Anchor, LineRange, Projection, project, project_worktree, snippet};
-use ents_receive::{Identity, Mode, Outcome, propose_entity};
+use ents_receive::{Identity, Mode, Outcome, propose_entity, propose_genesis};
use facet_git_tree::RawTree;
use gix_hash::ObjectId;
use gix_object::{CommitRef, Find, Kind, Write};
@@ -201,7 +201,8 @@
/// `git ents comment add`: create a comment about something.
///
-/// Returns the generated comment id alongside the raw
+/// Returns the comment's id — its genesis commit's own oid
+/// (`model.comment`, `meta-ref.identity-binding`) — alongside the raw
/// [`Outcome`] `receive` reached — callers interpret it themselves (the
/// CLI's own `outcome_to_result`, for instance), the same shape
/// `ents_effect::run::run_one` returns its own raw `Outcome` in.
@@ -215,7 +216,7 @@
/// not form a valid ref path below `refs/meta/`; [`Error::NotFound`] if
/// `parent` names no existing comment (`model.comment-thread`); otherwise
/// propagates capture, serialization, or `receive` failures.
-// @relation(model.comment, model.comment-state, model.comment-context, model.comment-thread, lens.parity, scope=function)
+// @relation(model.comment, model.comment-state, model.comment-context, model.comment-thread, meta-ref.identity-binding, lens.parity, scope=function)
pub fn add(
refs: &dyn RefStore,
objects: &(impl Find + Write),
@@ -274,21 +275,24 @@
parent: new.parent,
};
- // The comment's id is its own genesis tip's short oid, known only once
- // the commit is built — `propose_entity` builds it internally, so this
- // command derives the ref name from a locally generated id instead
- // (`meta-ref.granularity`: one ref per comment).
- let id = uuid::Uuid::new_v4().simple().to_string();
- let ref_name = ents_model::namespace::comment_ref(&id)?;
let subject = match &new.path {
Some(path) => format!("Comment on {path}"),
None => "Comment".to_owned(),
};
- let outcome = propose_entity(
- refs, objects, events, ref_name, &comment, identity, &subject, mode,
+ // A comment's id is the oid of its own genesis commit — sign-then-name,
+ // never a locally minted id (`model.comment`, `meta-ref.identity-binding`).
+ let (ref_name, outcome) = propose_genesis(
+ refs,
+ objects,
+ events,
+ &comment,
+ |oid| ents_model::namespace::comment_ref(&oid.to_string()),
+ identity,
+ &subject,
+ mode,
)?;
- Ok((id, outcome))
+ Ok((crate::genesis_id(&ref_name), outcome))
}
/// `git ents comment reply`: a comment whose parent is `parent_id`
@@ -318,19 +322,17 @@
context: None,
parent: Some(parent_id.to_owned()),
};
- let id = uuid::Uuid::new_v4().simple().to_string();
- let ref_name = ents_model::namespace::comment_ref(&id)?;
- let outcome = propose_entity(
+ let (ref_name, outcome) = propose_genesis(
refs,
objects,
events,
- ref_name,
&comment,
+ |oid| ents_model::namespace::comment_ref(&oid.to_string()),
identity,
&format!("Reply to comment {parent_id}"),
mode,
)?;
- Ok((id, outcome))
+ Ok((crate::genesis_id(&ref_name), outcome))
}
/// `git ents comment resolve`: record state `resolved` as an ordinary
crates/forge/ents-forge/src/comment/entity.rs
@@ -17,7 +17,7 @@
/// tool ([`Comment::is_about_nothing`], enforced in [`super::add`]), never
/// by the gate, which stays content-agnostic. Author and timestamp come
/// from the mutation commit chain rather than a stored field
-/// (`meta-ref.trailers`) — `Comment` therefore has no author or timestamp
+/// (`meta-ref.identity-binding`) — `Comment` therefore has no author or timestamp
/// field, and no reviewer/resolver field either: who changed [`Comment::state`],
/// and when, is the mutation chain's answer too (`model.comment-state`).
///
@@ -67,7 +67,7 @@
/// comment about a context entity or a parent comment only.
pub anchor: Option<RawTree>,
/// The canonical ref path below `refs/meta/` of the entity this
- /// comment belongs to, such as `issues/<id>` or `reviews/<id>`
+ /// comment belongs to, such as `issues/<id>` or `reviews/<target>/<member>`
/// (`model.comment-context`) — an entity's thread is an aggregation
/// query over comments naming it, never a list the entity stores.
pub context: Option<String>,
crates/forge/ents-forge/src/issue/command.rs
@@ -13,7 +13,7 @@
//! `crate::comment::command` never touches a terminal either.
use ents_model::MemberId;
-use ents_receive::{Identity, Mode, Outcome, propose_entity};
+use ents_receive::{Identity, Mode, Outcome, propose_entity, propose_genesis};
use gix_hash::ObjectId;
use gix_object::{CommitRef, Find, Kind};
@@ -75,13 +75,14 @@
pub labels: Vec<String>,
}
-/// `git ents issue new`: create an issue at a freshly generated
-/// `refs/meta/issues/<id>`.
+/// `git ents issue new`: create an issue at `refs/meta/issues/<id>`, where
+/// `<id>` is the oid of the issue's own genesis commit — sign-then-name,
+/// never a locally minted id (`model.issue`, `meta-ref.identity-binding`).
///
/// # Errors
///
/// Propagates serialization or `receive` failures.
-// @relation(model.issue, lens.parity, scope=function)
+// @relation(model.issue, meta-ref.identity-binding, lens.parity, scope=function)
pub fn new(
refs: &dyn gix_ref_store::RefStore,
objects: &(impl Find + gix_object::Write),
@@ -97,19 +98,18 @@
assignees: new.assignees,
labels: new.labels,
};
- let id = uuid::Uuid::new_v4().simple().to_string();
- let ref_name = ents_model::namespace::issue_ref(&id)?;
- let outcome = propose_entity(
+ let subject = format!("Open issue: {}", issue.title);
+ let (ref_name, outcome) = propose_genesis(
refs,
objects,
events,
- ref_name,
&issue,
+ |oid| ents_model::namespace::issue_ref(&oid.to_string()),
identity,
- &format!("Open issue: {}", issue.title),
+ &subject,
mode,
)?;
- Ok((id, outcome))
+ Ok((crate::genesis_id(&ref_name), outcome))
}
/// What `git ents issue edit` changes; a field left `None` is left
crates/forge/ents-forge/src/review/cli.rs
@@ -15,9 +15,11 @@
#[repr(u8)]
pub enum ReviewAction {
/// Review a commit: a verdict plus a body, occupying two refs — the
- /// review's own entity ref at `refs/meta/reviews/<id>`, and a retention
- /// pin at `refs/meta/pins/reviews/<id>` keeping the reviewed commit (and
- /// its ancestry) reachable.
+ /// review's own entity ref at `refs/meta/reviews/<target>/<member>`, and
+ /// a retention pin at `refs/meta/pins/reviews/<target>/<member>` keeping
+ /// the reviewed commit (and its ancestry) reachable. Re-reviewing after
+ /// the target moves advances the same two refs fast-forward rather than
+ /// minting new ones.
New {
/// Revision to review.
#[facet(args::named, default = "HEAD")]
@@ -42,8 +44,11 @@
/// Show one review: its reviewed commit, verdict, body, and discussion
/// thread (comments naming it as their context).
Show {
- /// The review's id.
+ /// The review's genesis target segment (`refs/meta/reviews/<target>/*`).
#[facet(args::positional)]
- id: String,
+ target: String,
+ /// The reviewer's member id (`refs/meta/reviews/*/<member>`).
+ #[facet(args::positional)]
+ member: String,
},
}
crates/forge/ents-forge/src/review/command.rs
@@ -1,8 +1,9 @@
//! The `review` command's business logic: review a commit (`model.review`),
//! writing both the review's own entity ref and its retention pin
-//! (`model.review-pin`), list and read reviews back, and surface a
-//! review's discussion thread by reusing [`crate::comment::thread`] rather
-//! than duplicating context aggregation.
+//! (`model.review-pin`), advancing the same composite-keyed ref on
+//! re-review rather than minting a new one, list and read reviews back,
+//! and surface a review's discussion thread by reusing
+//! [`crate::comment::thread`] rather than duplicating context aggregation.
//!
//! Generalized over the same trait-object/generic seam
//! `crate::comment::command` uses (`&dyn RefStore`/`RefStoreRead`,
@@ -10,6 +11,7 @@
//! composition root wires the concrete types and calls these functions,
//! never the other way around (`lens.parity`).
+use ents_model::MemberId;
use ents_receive::{Identity, Mode, Outcome, propose_entity_with_pin};
use gix_hash::ObjectId;
use gix_object::{CommitRef, Find, Kind, Write};
@@ -48,6 +50,13 @@
/// the repository at `repo_path`.
fn resolve_commit(repo_path: &std::path::Path, rev: &str) -> Result<ObjectId> {
let repo = gix::open(repo_path)?;
+ resolve_in(&repo, rev)
+}
+
+/// [`resolve_commit`], against an already-open `repo` — [`new`] already
+/// holds one open (to check re-review ancestry), so it resolves its target
+/// through this rather than opening the repository a second time.
+fn resolve_in(repo: &gix::Repository, rev: &str) -> Result<ObjectId> {
let resolve = || Error::InvalidArgument(format!("cannot resolve {rev} to a commit"));
let id = repo
.rev_parse_single(rev)
@@ -60,13 +69,64 @@
Ok(id)
}
-/// Read the [`Review`] at `id`'s ref tip, or [`Error::NotFound`] when no
-/// such ref exists.
-fn review_at(refs: &dyn RefStoreRead, objects: &impl Find, id: &str) -> Result<Review> {
- let ref_name = ents_model::namespace::review_ref(id)?;
+/// Whether `ancestor` is `descendant` itself, or reachable from it by parent
+/// edges — checked via the open repository's own merge-base machinery
+/// rather than a bespoke walk. A `false` result on a lookup failure (no
+/// shared history at all) is the correct answer, not an error to propagate:
+/// it just means this is not the review [`new`] should advance.
+fn is_ancestor_or_self(repo: &gix::Repository, ancestor: ObjectId, descendant: ObjectId) -> bool {
+ ancestor == descendant
+ || repo
+ .merge_base(ancestor, descendant)
+ .is_ok_and(|base| base.detach() == ancestor)
+}
+
+/// This member's existing review, if any, whose recorded target
+/// ([`Review::target`]) is `reviewed` itself or one of its ancestors — the
+/// fast-forward re-review case `model.review-pin` describes: "re-reviewing
+/// after the target moves". Returns the found review's own genesis target
+/// segment (the refname's `<target>`, parsed back via
+/// [`ents_model::namespace::parse_review_ref`]) for [`new`] to advance the
+/// same two refs under, rather than minting fresh ones.
+fn find_review_to_advance(
+ refs: &dyn RefStoreRead,
+ objects: &impl Find,
+ repo: &gix::Repository,
+ member: &MemberId,
+ reviewed: ObjectId,
+) -> Result<Option<String>> {
+ for entry in refs.iter_prefix("refs/meta/reviews/")? {
+ let (name, tip) = entry?;
+ let Some((target, entry_member)) = ents_model::namespace::parse_review_ref(name.as_ref())
+ else {
+ continue;
+ };
+ if entry_member != *member {
+ continue;
+ }
+ let tree = commit_tree(objects, tip)?;
+ let Ok(review) = facet_git_tree::deserialize::<Review>(&tree, objects) else {
+ continue;
+ };
+ if is_ancestor_or_self(repo, review.target(), reviewed) {
+ return Ok(Some(target));
+ }
+ }
+ Ok(None)
+}
+
+/// Read the [`Review`] at `target`/`member`'s ref tip, or [`Error::NotFound`]
+/// when no such ref exists.
+fn review_at(
+ refs: &dyn RefStoreRead,
+ objects: &impl Find,
+ target: &str,
+ member: &MemberId,
+) -> Result<Review> {
+ let ref_name = ents_model::namespace::review_ref(target, member)?;
let Some(tip) = refs.get(ref_name.as_ref())? else {
return Err(Error::NotFound {
- what: format!("review {id}"),
+ what: format!("review {target}/{member}"),
});
};
let tree = commit_tree(objects, tip)?;
@@ -86,12 +146,21 @@
pub body: String,
}
-/// `git ents review new`: review `new.target`, writing both refs
-/// `model.review` requires — the review's own entity ref at
-/// `refs/meta/reviews/<id>`, and the retention pin at
-/// `refs/meta/pins/reviews/<id>` keeping the reviewed commit (and its
-/// ancestry) reachable (`model.review-pin`) — under one locally generated
-/// id shared by both.
+/// `git ents review new`: review `new.target` as `member`, writing both
+/// refs `model.review` requires — the review's own entity ref at
+/// `refs/meta/reviews/<target>/<member>`, and the retention pin at
+/// `refs/meta/pins/reviews/<target>/<member>` keeping the reviewed commit
+/// (and its ancestry) reachable (`model.review-pin`) — a composite natural
+/// key, no minted id anywhere (`meta-ref.identity-binding`).
+///
+/// When `member` already has a review whose own recorded target
+/// ([`Review::target`]) is `new.target` itself or one of its ancestors,
+/// this is a re-review: the *same* two refs advance fast-forward under the
+/// original genesis target segment, with [`Review::target`] updated to the
+/// newly reviewed commit, rather than a fresh pair being minted
+/// (`model.review-pin`: "re-reviewing after the target moves MUST advance
+/// the pin fast-forward"). Otherwise this is the review's genesis, keyed by
+/// the reviewed commit's own oid.
///
/// The two refs travel in one atomic mutation via
/// [`propose_entity_with_pin`] (`receive.multi-ref-atomicity`): the
@@ -99,68 +168,82 @@
/// transitions together, so a review is never left with its entity written
/// but its retention pin missing. One [`Outcome`] covers the whole batch.
///
+/// Returns the composite key's target segment (the review's genesis
+/// target, unchanged across re-reviews) alongside the reached [`Outcome`].
+///
/// # Errors
///
/// [`Error::InvalidArgument`] if `new.target` does not resolve to a commit;
/// otherwise propagates serialization or `receive` failures.
-// @relation(model.review, model.review-pin, receive.multi-ref-atomicity, lens.parity, scope=function)
+// @relation(model.review, model.review-pin, meta-ref.identity-binding, receive.multi-ref-atomicity, lens.parity, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one field per mutation shape (refs, objects, events, repo, the draft, the acting \
+ member, identity, mode), mirroring propose_entity_with_pin's identically-justified \
+ shape one layer down"
+)]
pub fn new(
refs: &dyn RefStore,
objects: &(impl Find + Write),
events: &dyn ents_receive::EventSink,
repo_path: &std::path::Path,
new: NewReview,
+ member: &MemberId,
identity: &Identity<'_>,
mode: Mode,
) -> Result<(String, Outcome)> {
- let reviewed = resolve_commit(repo_path, &new.target)?;
+ let repo = gix::open(repo_path)?;
+ let reviewed = resolve_in(&repo, &new.target)?;
let review = Review::new(reviewed, new.verdict, new.body);
- // The review's id is derived locally, once, and shared by both refs
- // (`meta-ref.granularity`: one ref per review, one ref per pin) —
- // mirroring `crate::comment::command::add`'s own locally generated id.
- let id = uuid::Uuid::new_v4().simple().to_string();
+ // Re-reviewing after the target moves advances the SAME ref rather than
+ // minting a new one (`model.review-pin`): find this member's existing
+ // review, if any, whose own recorded target is an ancestor of (or equal
+ // to) the commit reviewed now, and advance it in place.
+ let target_hex = find_review_to_advance(refs, objects, &repo, member, reviewed)?
+ .unwrap_or_else(|| reviewed.to_string());
let outcome = propose_entity_with_pin(
refs,
objects,
events,
- ents_model::namespace::review_ref(&id)?,
+ ents_model::namespace::review_ref(&target_hex, member)?,
&review,
- ents_model::namespace::review_pin_ref(&id)?,
+ ents_model::namespace::review_pin_ref(&target_hex, member)?,
reviewed,
identity,
&format!("Review {reviewed}"),
- &format!("Pin review {id}"),
+ &format!("Pin review {target_hex}/{member}"),
mode,
)?;
- Ok((id, outcome))
+ Ok((target_hex, outcome))
}
/// `git ents review list [--target rev]`: every review recorded in this
-/// repository, optionally filtered to those whose most recently reviewed
-/// commit ([`Review::commit`]) resolves to `target`.
+/// repository, keyed by its composite `(target, member)` segments
+/// (`model.review`), optionally filtered to those whose most recently
+/// reviewed commit ([`Review::target`]) resolves to `target`.
///
/// # Errors
///
/// [`Error::InvalidArgument`] if `target` is given but does not resolve;
/// otherwise propagates a ref-store or object read failure.
-// @relation(model.review, scope=function)
+// @relation(model.review, meta-ref.identity-binding, scope=function)
pub fn list(
refs: &dyn RefStoreRead,
objects: &impl Find,
repo_path: &std::path::Path,
target: Option<&str>,
-) -> Result<Vec<(String, Review)>> {
+) -> Result<Vec<((String, MemberId), Review)>> {
let target_oid = target
.map(|rev| resolve_commit(repo_path, rev))
.transpose()?;
let mut out = Vec::new();
for entry in refs.iter_prefix("refs/meta/reviews/")? {
let (name, tip) = entry?;
- let path = name.as_bstr().to_string();
- let Some(id) = path.strip_prefix("refs/meta/reviews/") else {
+ let Some((target_hex, member)) = ents_model::namespace::parse_review_ref(name.as_ref())
+ else {
continue;
};
let tree = commit_tree(objects, tip)?;
@@ -168,33 +251,34 @@
continue;
};
if let Some(target_oid) = target_oid
- && review.commit() != target_oid
+ && review.target() != target_oid
{
continue;
}
- out.push((id.to_owned(), review));
+ out.push(((target_hex, member), review));
}
Ok(out)
}
-/// `git ents review show`: `id`'s review, plus its discussion thread —
-/// every [`Comment`] naming `reviews/<id>` as its context (or a reply into
-/// one), reusing [`crate::comment::thread`] rather than a second
-/// aggregation query (`model.comment-context`, `model.review`: "the review
-/// itself MUST NOT store a list of its comments").
+/// `git ents review show`: `target`/`member`'s review, plus its discussion
+/// thread — every [`Comment`] naming `reviews/<target>/<member>` as its
+/// context (or a reply into one), reusing [`crate::comment::thread`] rather
+/// than a second aggregation query (`model.comment-context`, `model.review`:
+/// "the review itself MUST NOT store a list of its comments").
///
/// # Errors
///
-/// [`Error::NotFound`] if `id` has no review ref; otherwise propagates a
-/// ref-store or object read failure.
+/// [`Error::NotFound`] if `target`/`member` has no review ref; otherwise
+/// propagates a ref-store or object read failure.
// @relation(model.review, model.comment-context, lens.parity, scope=function)
pub fn show(
refs: &dyn RefStoreRead,
objects: &impl Find,
- id: &str,
+ target: &str,
+ member: &MemberId,
) -> Result<(Review, Vec<(String, Comment)>)> {
- let review = review_at(refs, objects, id)?;
- let context = format!("reviews/{id}");
+ let review = review_at(refs, objects, target, member)?;
+ let context = format!("reviews/{target}/{member}");
let thread = crate::comment::thread(refs, objects, &context)?;
Ok((review, thread))
}
crates/forge/ents-forge/src/review/entity.rs
@@ -9,19 +9,23 @@
/// A verdict on a commit, plus a body (`model.review`).
///
/// Every review occupies exactly two refs: this entity's own tree at
-/// `refs/meta/reviews/<id>`, and a retention pin at
-/// `refs/meta/pins/reviews/<id>` anchoring the reviewed content itself
-/// (`model.review-pin`) — [`super::new`] writes both. `commit` is the id
-/// of the most recently reviewed commit, stored as a plain data field the
-/// same way [`ents_anchor::Anchor::commit`] stores its own commit: a
-/// `[u8; 20]` field plus a [`Review::commit`] accessor, so reading it back
-/// never requires the pin ref — the pin anchors reachability, the entity
-/// describes what was reviewed. `approve` and `request-changes` are
-/// conventions, not an enum: custom verdicts are schema, not a platform
-/// feature (`model.extensibility`), exactly as custom states are for
+/// `refs/meta/reviews/<target>/<member>`, and a retention pin at
+/// `refs/meta/pins/reviews/<target>/<member>` anchoring the reviewed content
+/// itself (`model.review-pin`) — [`super::new`] writes both. `target` is
+/// the oid of the most recently reviewed commit, stored as a plain data
+/// field the same way [`ents_model::ResultRecord`]'s own `target` field
+/// stores the commit it judged: a `[u8; 20]` field plus a [`Review::target`]
+/// accessor, so reading it back never requires the pin ref — the pin
+/// anchors reachability, the entity describes what was reviewed. At
+/// genesis this field equals the refname's `<target>` segment and binds
+/// it (`meta-ref.identity-binding`); re-reviewing a descendant advances
+/// this field while the refname stays keyed by genesis
+/// (`model.review-pin`). `approve` and `request-changes` are conventions,
+/// not an enum: custom verdicts are schema, not a platform feature
+/// (`model.extensibility`), exactly as custom states are for
/// [`crate::Issue`] and [`crate::comment::Comment`]. Reviewer and
/// timestamp come from the mutation commit chain rather than a stored
-/// field (`meta-ref.trailers`), so `Review` carries no author or
+/// field (`meta-ref.identity-binding`), so `Review` carries no author or
/// timestamp field — the same omission [`crate::comment::Comment`] makes.
/// A review's discussion is [`crate::comment::Comment`] entities naming
/// the review as their context (`model.comment-context`); `Review` itself
@@ -32,18 +36,18 @@
/// ```
/// use ents_forge::review::Review;
///
-/// let commit = gix_hash::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567")
+/// let target = gix_hash::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567")
/// .expect("valid hex");
-/// let review = Review::new(commit, "approve", "looks good");
+/// let review = Review::new(target, "approve", "looks good");
/// let (root, store) = facet_git_tree::serialize(&review).expect("serialize");
/// let back: Review = facet_git_tree::deserialize(&root, &store).expect("deserialize");
/// assert_eq!(back, review);
-/// assert_eq!(back.commit(), commit);
+/// assert_eq!(back.target(), target);
/// ```
-// @relation(model.review, meta-ref.typed-tree, model.extensibility, scope=file)
+// @relation(model.review, meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, scope=file)
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct Review {
- pub(crate) commit: [u8; 20],
+ target: [u8; 20],
/// The review's verdict — `approve`, `request-changes`, or any custom
/// value a schema defines; not a fixed enum (`model.review`,
/// `model.extensibility`).
@@ -53,14 +57,14 @@
}
impl Review {
- /// Build a review of `commit` carrying `verdict` and `body`
+ /// Build a review of `target` carrying `verdict` and `body`
/// (`model.review`).
#[must_use]
- pub fn new(commit: ObjectId, verdict: impl Into<String>, body: impl Into<String>) -> Self {
+ pub fn new(target: ObjectId, verdict: impl Into<String>, body: impl Into<String>) -> Self {
let mut bytes = [0u8; 20];
- bytes.copy_from_slice(commit.as_slice());
+ bytes.copy_from_slice(target.as_slice());
Self {
- commit: bytes,
+ target: bytes,
verdict: verdict.into(),
body: body.into(),
}
@@ -68,11 +72,11 @@
/// The id of the most recently reviewed commit (`model.review`):
/// reading this never requires the pin ref
- /// (`refs/meta/pins/reviews/<id>`) — the pin anchors reachability, the
- /// entity describes what was reviewed.
+ /// (`refs/meta/pins/reviews/<target>/<member>`) — the pin anchors
+ /// reachability, the entity describes what was reviewed.
#[must_use]
- pub fn commit(&self) -> ObjectId {
- ObjectId::from_bytes_or_panic(&self.commit)
+ pub fn target(&self) -> ObjectId {
+ ObjectId::from_bytes_or_panic(&self.target)
}
}
@@ -91,21 +95,21 @@
#[case::custom_verdict("needs-design-doc")]
// @relation(model.review, model.extensibility, meta-ref.typed-tree, scope=function, role=Verifies)
fn review_round_trips_with_any_verdict_string(#[case] verdict: &str) {
- let commit =
+ let target =
ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex");
- let review = Review::new(commit, verdict, "reviewed the change");
+ let review = Review::new(target, verdict, "reviewed the change");
let (root, store) = serialize(&review).expect("serialize");
let back: Review = deserialize(&root, &store).expect("deserialize");
assert_eq!(back, review);
- assert_eq!(back.commit(), commit);
+ assert_eq!(back.target(), target);
}
#[rstest]
// @relation(model.review, scope=function, role=Verifies)
- fn commit_accessor_reflects_the_stored_bytes() {
- let commit =
+ fn target_accessor_reflects_the_stored_bytes() {
+ let target =
ObjectId::from_hex(b"fedcba9876543210fedcba9876543210fedcba98").expect("valid hex");
- let review = Review::new(commit, "approve", "");
- assert_eq!(review.commit(), commit);
+ let review = Review::new(target, "approve", "");
+ assert_eq!(review.target(), target);
}
}