multi: make comments conversational across the stack
commit d4c3514
multi: make comments conversational across the stack
A comment is now about an anchor, a context entity, or a parent
comment, in any combination, and carries an open/resolved state. This
is one API evolution spanning the entity, the forge command layer, and
the CLI, committed together so no intermediate state half-migrates the
crate.
Trees written by phase-7 code (body + anchor only) still read back
through a legacy fallback; a mutation rewrites them under the current
struct as a commit on top of the old tip (meta-ref.migration). Every
operation is an ents-forge library call the CLI merely dispatches, so
the coming LSP frontend calls the same functions in-process
(lens.parity).
model: add state, optional anchor, context, and parent to Comment
model: read pre-migration {body, anchor} comment trees via a fallback
model: drop the dead ents-model Comment duplicate
roots: add comment reply, resolve, and reopen state mutations
roots: project comment list onto the working tree with --worktree
roots: emit a machine-readable comment listing with --porcelain
Assisted-by: Claude:claude-fable-5
crates/cli/git-ents/src/exe.rs
@@ -187,9 +187,35 @@
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::List {
+ worktree,
+ state,
+ open,
+ context,
+ porcelain,
+ } => {
+ let state = match (state, open) {
+ (Some(state), false) => Some(state),
+ (None, true) => Some("open".to_owned()),
+ (None, false) => None,
+ (Some(_), true) => {
+ return Err(crate::Error::InvalidArgument(
+ "--open is shorthand for --state open; give one or the other".into(),
+ ));
+ }
+ };
+ let filter = ents_forge::comment::ListFilter { state, context };
+ let rows = commands::comment::list_projected(&root, worktree, &filter)?;
+ if porcelain {
+ let _ = write!(out, "{}", commands::comment::porcelain(&rows));
+ } else {
+ for row in rows {
+ let _ = writeln!(
+ out,
+ "{}\t{}\t{}",
+ row.id, row.comment.state, row.comment.body
+ );
+ }
}
}
CommentAction::Add {
@@ -197,15 +223,49 @@
body,
lines,
rev,
+ worktree,
+ context,
+ parent,
key,
} => {
- let id = commands::comment::add(&root, &path, body, lines, &rev, key)?;
+ let new = ents_forge::comment::NewComment {
+ body,
+ path,
+ lines,
+ rev,
+ worktree,
+ context,
+ parent,
+ };
+ let id = commands::comment::add(&root, new, key)?;
let _ = writeln!(out, "commented {id}");
}
- CommentAction::Show { id, rev } => {
- let (comment, anchor, projection) = commands::comment::show(&root, &id, &rev)?;
- let _ = writeln!(out, "path: {}", anchor.path);
- let _ = writeln!(out, "projection: {projection:?}");
+ CommentAction::Reply { id, body, key } => {
+ let reply_id = commands::comment::reply(&root, &id, body, key)?;
+ let _ = writeln!(out, "replied {reply_id}");
+ }
+ CommentAction::Resolve { id, key } => {
+ commands::comment::set_state(&root, &id, true, key)?;
+ let _ = writeln!(out, "resolved {id}");
+ }
+ CommentAction::Reopen { id, key } => {
+ commands::comment::set_state(&root, &id, false, key)?;
+ let _ = writeln!(out, "reopened {id}");
+ }
+ CommentAction::Show { id, rev, worktree } => {
+ let (comment, projected) = commands::comment::show(&root, &id, &rev, worktree)?;
+ let _ = writeln!(out, "state: {}", comment.state);
+ if let Some(context) = &comment.context {
+ let _ = writeln!(out, "context: {context}");
+ }
+ if let Some(parent) = &comment.parent {
+ let _ = writeln!(out, "parent: {parent}");
+ }
+ if let Some((anchor, projection)) = projected {
+ let _ = writeln!(out, "path: {}", anchor.path);
+ let target = if worktree { "worktree" } else { rev.as_str() };
+ let _ = writeln!(out, "projection at {target}: {projection:?}");
+ }
let _ = writeln!(out, "body: {}", comment.body);
}
}
crates/cli/git-ents/tests/comment.rs
@@ -1,6 +1,9 @@
//! Integration coverage for `git ents comment` against a real local
-//! composition root (`roots.local`) — adding a comment, then listing it
-//! back (`model.comment`).
+//! composition root (`roots.local`) — the phase-9 comment loop: a comment
+//! anchored against a dirty working tree (`anchor.working-tree`) is listed
+//! open by the machine-readable `comment list --worktree` form
+//! (`lens.parity`), resolved through the CLI (`model.comment-state`), and
+//! gone from the open listing afterwards.
#![allow(
clippy::expect_used,
@@ -13,6 +16,7 @@
use std::path::Path;
use std::process::Command;
+use ents_forge::comment::{ListFilter, NewComment};
use git_ents::commands::comment;
use git_ents::root::LocalRoot;
@@ -46,6 +50,18 @@
assert!(status.success());
}
+fn draft(body: &str) -> NewComment {
+ NewComment {
+ body: body.to_owned(),
+ path: Some("file.txt".to_owned()),
+ lines: None,
+ rev: "HEAD".to_owned(),
+ worktree: false,
+ context: None,
+ parent: None,
+ }
+}
+
/// `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`).
@@ -58,10 +74,7 @@
let id = comment::add(
&root,
- "file.txt",
- "looks off by one".to_owned(),
- None,
- "HEAD",
+ draft("looks off by one"),
Some(fixture.key_path.clone()),
)
.expect("adds");
@@ -70,4 +83,85 @@
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].0, id);
assert_eq!(listed[0].1.body, "looks off by one");
+ assert_eq!(listed[0].1.state, "open");
+}
+
+/// The phase-9 comment loop, CLI end: a comment anchored to a *dirty*
+/// working tree is listed open by the machine-readable form with its
+/// worktree projection, resolved, and gone from the open listing — an
+/// agent needs nothing but this surface (`lens.parity`,
+/// `anchor.working-tree`, `model.comment-state`).
+// @relation(lens.parity, anchor.working-tree, model.comment-state, roots.local, scope=function, role=Verifies)
+#[test]
+fn the_comment_loop_runs_through_the_machine_readable_listing() {
+ let fixture = common::Fixture::new(1);
+ let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect();
+ commit_file(fixture.path(), "file.txt", &contents);
+ // Dirty the working tree: the comment anchors to bytes HEAD never saw.
+ let dirty = contents.replace("line 5\n", "line five\n");
+ std::fs::write(fixture.path().join("file.txt"), &dirty).expect("write");
+
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ let mut new = draft("this new line looks wrong\n\nsecond paragraph");
+ new.worktree = true;
+ new.lines = Some("5".to_owned());
+ new.context = Some("issues/42".to_owned());
+ let id = comment::add(&root, new, Some(fixture.key_path.clone())).expect("adds");
+
+ // Open, current against the working tree, machine-readable.
+ let open = ListFilter {
+ state: Some("open".to_owned()),
+ context: None,
+ };
+ let rows = comment::list_projected(&root, true, &open).expect("lists");
+ let rendered = comment::porcelain(&rows);
+ let expected = format!(
+ "{id} open current file.txt:5-5\ncontext issues/42\n\tthis new line looks wrong\n\t\n\tsecond paragraph\n"
+ );
+ assert_eq!(rendered, expected);
+
+ // Resolve through the CLI surface; the open listing no longer shows
+ // it, the unfiltered one shows it resolved.
+ comment::set_state(&root, &id, true, Some(fixture.key_path.clone())).expect("resolves");
+ let rows = comment::list_projected(&root, true, &open).expect("lists");
+ assert!(rows.is_empty(), "a resolved comment is not open");
+ let all = comment::list_projected(&root, true, &ListFilter::default()).expect("lists");
+ assert_eq!(all.len(), 1);
+ assert_eq!(all[0].comment.state, "resolved");
+}
+
+/// Two records separate with exactly one blank line, and an unanchored
+/// reply renders `-` for projection and location — the porcelain grammar
+/// an agent parses (`lens.parity`).
+// @relation(lens.parity, scope=function, role=Verifies)
+#[test]
+fn porcelain_separates_records_and_renders_unanchored_comments() {
+ 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 first = comment::add(&root, draft("root"), Some(fixture.key_path.clone())).expect("adds");
+ let second = comment::reply(
+ &root,
+ &first,
+ "reply".to_owned(),
+ Some(fixture.key_path.clone()),
+ )
+ .expect("replies");
+
+ let rows = comment::list_projected(&root, false, &ListFilter::default()).expect("lists");
+ let rendered = comment::porcelain(&rows);
+ let records: Vec<&str> = rendered.split("\n\n").collect();
+ assert_eq!(records.len(), 2);
+ let root_record = records
+ .iter()
+ .find(|r| r.starts_with(&first))
+ .expect("root listed");
+ assert!(root_record.contains(&format!("{first} open current file.txt\n")));
+ let reply_record = records
+ .iter()
+ .find(|r| r.starts_with(&second))
+ .expect("reply listed");
+ assert!(reply_record.contains(&format!("{second} open - -\n")));
+ assert!(reply_record.contains(&format!("parent {first}\n")));
}
crates/forge/ents-forge/src/lib.rs
@@ -17,17 +17,28 @@
//!
//! # Spec coverage
//!
-//! From `docs/spec/model.sdoc` and `docs/spec/meta-ref.sdoc`:
+//! From `docs/spec/model.adoc` and `docs/spec/meta-ref.adoc`:
//!
//! - `model.issue` — [`Issue`].
-//! - `model.comment` — [`comment::Comment`].
+//! - `model.comment`, `model.comment-state`, `model.comment-context`,
+//! `model.comment-thread` — [`comment::Comment`] and the command layer
+//! around it ([`comment::add`], [`comment::reply`],
+//! [`comment::resolve`]/[`comment::reopen`], [`comment::thread`]).
+//! - `meta-ref.migration` — pre-broadening comment trees still read back
+//! through the legacy fallback, and any mutation rewrites them under
+//! the current struct on top of the old tip.
//! - `meta-ref.granularity` — one ref per issue/comment
//! (`refs/meta/issues/<id>`, `refs/meta/comments/<id>`); see
//! [`comment::add`] for how a comment's id is generated locally rather
//! than derived from the entity itself.
//! - `meta-ref.typed-tree` — every entity module's round-trip test.
-//! - `anchor.definition`, `anchor.projection` — [`comment::add`] and
-//! [`comment::show`], built directly on `ents_anchor::capture`/`project`.
+//! - `anchor.definition`, `anchor.projection`, `anchor.working-tree` —
+//! [`comment::add`], [`comment::show`], and [`comment::list_projected`],
+//! built directly on `ents_anchor::capture`/`capture_worktree` and
+//! `project`/`project_worktree`.
+//! - `lens.parity` — every operation the CLI, the web UI, or an editor
+//! lens offers over these entities is one of this crate's library
+//! functions; frontends only wire stores and render.
//!
//! # Examples
//!
@@ -59,7 +70,10 @@
//! let anchor_tree = store.write(&gix_object::Tree { entries: vec![] }).expect("tree");
//! let comment = Comment {
//! body: "looks off by one".to_owned(),
-//! anchor: RawTree::new(anchor_tree),
+//! state: "open".to_owned(),
+//! anchor: Some(RawTree::new(anchor_tree)),
+//! context: Some("issues/42".to_owned()),
+//! parent: None,
//! };
//! let root = facet_git_tree::serialize_into(&comment, &store).expect("serialize");
//! let back: Comment = facet_git_tree::deserialize(&root, &store).expect("deserialize");
crates/forge/ents-forge/tests/round_trip.rs
@@ -36,3 +36,31 @@
prop_assert_eq!(back, issue);
}
}
+
+proptest! {
+ #![proptest_config(ProptestConfig::with_cases(64))]
+
+ // @relation(meta-ref.typed-tree, model.comment, scope=function, role=Verifies)
+ #[test]
+ fn comment_round_trips_for_any_fields(
+ body in any::<String>(),
+ state in any::<String>(),
+ context in proptest::option::of(any::<String>()),
+ parent in proptest::option::of(any::<String>()),
+ anchored in any::<bool>(),
+ ) {
+ use ents_forge::comment::Comment;
+ use facet_git_tree::{ObjectStore, RawTree};
+ use gix_object::Write as _;
+
+ let store = ObjectStore::default();
+ let anchor = anchored.then(|| {
+ let tree = gix_object::Tree { entries: vec![] };
+ RawTree::new(store.write(&tree).expect("tree"))
+ });
+ let comment = Comment { body, state, anchor, context, parent };
+ let root = facet_git_tree::serialize_into(&comment, &store).expect("serialize");
+ let back: Comment = facet_git_tree::deserialize(&root, &store).expect("deserialize");
+ prop_assert_eq!(back, comment);
+ }
+}
crates/cli/ents-web/src/pages/comments.rs
@@ -124,12 +124,13 @@
where
O: Find + Write + Send + 'static,
{
- let (comment, anchor, projection) = comment::show(
+ let (comment, projected) = comment::show(
state.refs.as_ref(),
&*state.objects(),
&state.path,
&id,
&query.rev,
+ false,
)?;
Ok(super::layout(
&super::RepoHeader::from_state(&state),
@@ -138,9 +139,18 @@
&id,
html! {
dl {
- dt { "path" } dd { (anchor.path) }
- dt { "lines" } dd { (format!("{:?}", anchor.lines)) }
- dt { "projection at " (query.rev) } dd { (format!("{projection:?}")) }
+ dt { "state" } dd { (comment.state) }
+ @if let Some(context) = &comment.context {
+ dt { "context" } dd { (context) }
+ }
+ @if let Some(parent) = &comment.parent {
+ dt { "parent" } dd { (parent) }
+ }
+ @if let Some((anchor, projection)) = &projected {
+ dt { "path" } dd { (anchor.path) }
+ dt { "lines" } dd { (format!("{:?}", anchor.lines)) }
+ dt { "projection at " (query.rev) } dd { (format!("{projection:?}")) }
+ }
dt { "body" } dd { (comment.body) }
}
},
@@ -184,15 +194,21 @@
let lines = (!form.lines.trim().is_empty()).then(|| form.lines.trim().to_owned());
let identity = state.identity.as_ref();
+ let new = ents_forge::comment::NewComment {
+ body: form.body,
+ path: Some(form.path),
+ lines,
+ rev: form.rev,
+ worktree: false,
+ context: None,
+ parent: None,
+ };
let (id, outcome) = comment::add(
state.refs.as_ref(),
&*state.objects(),
state.events.as_ref(),
&state.path,
- &form.path,
- form.body,
- lines,
- &form.rev,
+ new,
&crate::receive_identity!(identity),
state.mode,
)?;
@@ -290,8 +306,12 @@
};
let mut out = Vec::new();
for (id, comment) in rows {
- let Ok(anchor) =
- facet_git_tree::deserialize::<Anchor>(&comment.anchor.oid(), &*state.objects())
+ let Some(raw) = &comment.anchor else {
+ // An unanchored comment (context or reply aboutness only) has
+ // no line in any file to land on.
+ continue;
+ };
+ let Ok(anchor) = facet_git_tree::deserialize::<Anchor>(&raw.oid(), &*state.objects())
else {
continue;
};
@@ -350,8 +370,10 @@
};
let mut out = Vec::new();
for (id, comment) in rows {
- let Ok(anchor) =
- facet_git_tree::deserialize::<Anchor>(&comment.anchor.oid(), &*state.objects())
+ let Some(raw) = &comment.anchor else {
+ continue;
+ };
+ let Ok(anchor) = facet_git_tree::deserialize::<Anchor>(&raw.oid(), &*state.objects())
else {
continue;
};
crates/cli/git-ents/src/commands/comment.rs
@@ -1,11 +1,13 @@
//! `git ents comment`: a thin wrapper around `ents_forge::comment`'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.
+//! against [`LocalRoot`], translates a reached `Outcome` into a CLI-facing
+//! [`Result`] (`crate::mutate::outcome_to_result`), and renders the
+//! machine-readable listing, 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;
-use ents_forge::comment::Comment;
+use ents_forge::comment::{Comment, ListFilter, Listed, NewComment};
use ents_receive::Identity;
use super::{actor, signer};
@@ -22,22 +24,36 @@
Ok(comment::list(&root.refs, &root.objects)?)
}
-/// `git ents comment add`: anchor `body` to `path` (optionally `lines`) at
-/// `rev`.
+/// `git ents comment list [--worktree] [--state ...] [--context ...]`:
+/// matching comments with each anchor projected onto the working tree
+/// (with `worktree`) or `HEAD`.
///
/// # Errors
///
-/// [`crate::error::Error::Forge`] if `lines` does not parse, or anchoring,
-/// serialization, or `receive` itself fails; see
-/// [`crate::mutate::outcome_to_result`] for how a reached refusal renders.
-pub fn add(
+/// Propagates a ref-store, object read, or projection failure.
+pub fn list_projected(
root: &LocalRoot,
- path: &str,
- body: String,
- lines: Option<String>,
- rev: &str,
- key: Option<std::path::PathBuf>,
-) -> Result<String> {
+ worktree: bool,
+ filter: &ListFilter,
+) -> Result<Vec<Listed>> {
+ Ok(comment::list_projected(
+ &root.refs,
+ &root.objects,
+ &root.path,
+ worktree,
+ filter,
+ )?)
+}
+
+/// `git ents comment add`: create a comment about something.
+///
+/// # Errors
+///
+/// [`crate::error::Error::Forge`] if the comment is about nothing, its
+/// arguments do not parse, or anchoring, serialization, or `receive`
+/// itself fails; see [`crate::mutate::outcome_to_result`] for how a
+/// reached refusal renders.
+pub fn add(root: &LocalRoot, new: NewComment, key: Option<std::path::PathBuf>) -> Result<String> {
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
@@ -48,10 +64,7 @@
&root.objects,
&root.events,
&root.path,
- path,
- body,
- lines,
- rev,
+ new,
&identity,
root.mode(),
)?;
@@ -59,8 +72,78 @@
Ok(id)
}
-/// `git ents comment show`: `id`'s anchor (projected onto `rev`), anchored
-/// text, and body.
+/// `git ents comment reply`: a comment whose parent is `parent_id`.
+///
+/// # Errors
+///
+/// See [`add`]; additionally [`ents_forge::Error::NotFound`] (wrapped)
+/// when `parent_id` names no comment.
+pub fn reply(
+ root: &LocalRoot,
+ parent_id: &str,
+ body: String,
+ key: Option<std::path::PathBuf>,
+) -> Result<String> {
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let (id, outcome) = comment::reply(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ parent_id,
+ body,
+ &identity,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(id)
+}
+
+/// `git ents comment resolve` / `reopen`: record the state mutation on the
+/// comment's own ref.
+///
+/// # Errors
+///
+/// See [`add`].
+pub fn set_state(
+ root: &LocalRoot,
+ id: &str,
+ resolve: bool,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = if resolve {
+ comment::resolve(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ id,
+ &identity,
+ root.mode(),
+ )?
+ } else {
+ comment::reopen(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ id,
+ &identity,
+ root.mode(),
+ )?
+ };
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents comment show`: `id`'s comment and, when anchored, its anchor
+/// projected onto `rev` or the working tree.
///
/// # Errors
///
@@ -70,12 +153,85 @@
root: &LocalRoot,
id: &str,
rev: &str,
-) -> Result<(Comment, ents_anchor::Anchor, ents_anchor::Projection)> {
+ worktree: bool,
+) -> Result<(
+ Comment,
+ Option<(ents_anchor::Anchor, ents_anchor::Projection)>,
+)> {
Ok(comment::show(
&root.refs,
&root.objects,
&root.path,
id,
rev,
+ worktree,
)?)
}
+
+/// One record of `git ents comment list --porcelain`'s stable
+/// machine-readable form (`lens.parity`: id, state, projected location,
+/// and body, sufficient for an agent to enumerate and resolve every open
+/// comment with no editor attached):
+///
+/// ```text
+/// <id> <state> <projection> <location>
+/// context <c> (only when the comment names one)
+/// parent <id> (only when the comment is a reply)
+/// \t<body line> (every body line, tab-prefixed)
+/// ```
+///
+/// `projection` is `current`, `relocated`, `outdated`, or `deleted`, and
+/// `-` for a comment with no anchor; `location` is `path:start-end`
+/// (`path` alone for a whole-file anchor) and `-` when there is no anchor
+/// or the file is gone. Records are separated by one blank line — a blank
+/// body line renders as a lone tab, so it can never terminate a record.
+#[must_use]
+pub fn porcelain(rows: &[Listed]) -> String {
+ let mut out = String::new();
+ for (index, row) in rows.iter().enumerate() {
+ if index > 0 {
+ out.push('\n');
+ }
+ let (projection, location) = match (&row.projection, &row.anchor) {
+ (Some(projection), Some(anchor)) => porcelain_projection(projection, anchor),
+ _ => ("-".to_owned(), "-".to_owned()),
+ };
+ out.push_str(&format!(
+ "{} {} {} {}\n",
+ row.id, row.comment.state, projection, location
+ ));
+ if let Some(context) = &row.comment.context {
+ out.push_str(&format!("context {context}\n"));
+ }
+ if let Some(parent) = &row.comment.parent {
+ out.push_str(&format!("parent {parent}\n"));
+ }
+ for line in row.comment.body.lines() {
+ out.push('\t');
+ out.push_str(line);
+ out.push('\n');
+ }
+ }
+ out
+}
+
+/// The `(projection, location)` columns of one porcelain record.
+fn porcelain_projection(
+ projection: &ents_anchor::Projection,
+ anchor: &ents_anchor::Anchor,
+) -> (String, String) {
+ use ents_anchor::Projection;
+ match projection {
+ Projection::Current => ("current".to_owned(), location(&anchor.path, anchor.lines)),
+ Projection::Relocated { path, lines } => ("relocated".to_owned(), location(path, *lines)),
+ Projection::Outdated { path } => ("outdated".to_owned(), location(path, None)),
+ Projection::Deleted => ("deleted".to_owned(), "-".to_owned()),
+ }
+}
+
+fn location(path: &str, lines: Option<ents_anchor::LineRange>) -> String {
+ match lines {
+ Some(range) => format!("{path}:{}-{}", range.start, range.end),
+ None => path.to_owned(),
+ }
+}
crates/forge/ents-forge/src/comment/cli.rs
@@ -15,13 +15,45 @@
#[derive(Facet)]
#[repr(u8)]
pub enum CommentAction {
- /// List the comments recorded in this repository.
- List,
- /// Anchor a comment to a file at a revision.
+ /// List the comments recorded in this repository, each anchor
+ /// projected onto HEAD (or, with --worktree, onto the working tree).
+ ///
+ /// With --porcelain, emits a stable machine-readable form:
+ /// blank-line-separated records, each starting with the line
+ /// "<id> <state> <projection> <location>" — projection is current,
+ /// relocated, outdated, or deleted ("-" for a comment with no
+ /// anchor); location is "path:start-end", "path" for a whole-file
+ /// anchor ("-" when there is no anchor or the file is gone) —
+ /// followed by optional "context <c>" and "parent <id>" lines, then
+ /// the body with every line prefixed by one tab.
+ List {
+ /// Project each anchor onto the working tree's on-disk bytes
+ /// instead of HEAD.
+ #[facet(args::named, default)]
+ worktree: bool,
+ /// Keep only comments in this state (e.g. open, resolved).
+ #[facet(args::named)]
+ state: Option<String>,
+ /// Shorthand for --state open.
+ #[facet(args::named, default)]
+ open: bool,
+ /// Keep only comments naming this context (a ref path below
+ /// refs/meta/, e.g. issues/<id>).
+ #[facet(args::named)]
+ context: Option<String>,
+ /// Emit the stable machine-readable form described above.
+ #[facet(args::named, default)]
+ porcelain: bool,
+ },
+ /// Create a comment about something: anchor it to a file (at a
+ /// revision or in the working tree), name a context entity, reply to
+ /// a parent comment, or any combination. A comment about none of
+ /// these is refused.
Add {
- /// Repository-relative path of the file the comment anchors to.
- #[facet(args::positional)]
- path: String,
+ /// Repository-relative path of the file the comment anchors to;
+ /// omit for a comment about a context or parent only.
+ #[facet(args::positional, default)]
+ path: Option<String>,
/// The comment's body text.
#[facet(args::named)]
body: String,
@@ -32,12 +64,56 @@
/// Revision to anchor against.
#[facet(args::named, default = "HEAD")]
rev: String,
+ /// Anchor against the working tree's current on-disk bytes
+ /// instead of --rev.
+ #[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>.
+ #[facet(args::named)]
+ context: Option<String>,
+ /// Id of the comment this one replies to.
+ #[facet(args::named)]
+ parent: Option<String>,
/// Key to sign with; defaults to `user.signingkey`.
#[facet(args::named)]
key: Option<PathBuf>,
},
- /// Show one comment: its anchor, projected onto a revision, and its
- /// body.
+ /// Reply to a comment: a new comment whose parent is <id>, inheriting
+ /// its aboutness from the thread — no anchor or context required.
+ Reply {
+ /// The comment being replied to.
+ #[facet(args::positional)]
+ id: String,
+ /// The reply's body text.
+ #[facet(args::named)]
+ body: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Mark a comment resolved: an ordinary mutation commit on the
+ /// comment's own ref, never a deletion.
+ Resolve {
+ /// The comment to resolve.
+ #[facet(args::positional)]
+ id: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Reopen a resolved comment, the same way resolve marks it.
+ Reopen {
+ /// The comment to reopen.
+ #[facet(args::positional)]
+ id: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Show one comment: its state, context, parent, body, and — when
+ /// anchored — its anchor projected onto a revision or the working
+ /// tree.
Show {
/// The comment's id.
#[facet(args::positional)]
@@ -45,5 +121,9 @@
/// Revision to project the comment's anchor onto.
#[facet(args::named, default = "HEAD")]
rev: String,
+ /// Project onto the working tree's on-disk bytes instead of
+ /// --rev.
+ #[facet(args::named, default)]
+ worktree: bool,
},
}
crates/forge/ents-forge/src/comment/command.rs
@@ -1,6 +1,8 @@
-//! The `comment` command's business logic: anchor a comment to code and
-//! show it back, projected onto a revision (`model.comment`,
-//! `anchor.definition`, `anchor.projection`).
+//! The `comment` command's business logic: create a comment about
+//! something (`model.comment`), reply to one (`model.comment-thread`),
+//! resolve and reopen one (`model.comment-state`), and read them back —
+//! projected onto a revision or the working tree (`anchor.projection`,
+//! `anchor.working-tree`).
//!
//! Generalized over the same trait-object/generic seam
//! `ents_effect::run` uses (`&dyn RefStore`/`RefStoreRead`,
@@ -8,8 +10,10 @@
//! any concrete composition-root type, so this crate never depends on a
//! CLI or a specific store implementation — a composition root wires the
//! concrete types and calls these functions, never the other way around.
+//! `lens.parity` makes this binding: the CLI, the web UI, and the editor
+//! lens are three callers of exactly these functions.
-use ents_anchor::{Anchor, LineRange, Projection, project, snippet};
+use ents_anchor::{Anchor, LineRange, Projection, project, project_worktree, snippet};
use ents_receive::{Identity, Mode, Outcome, propose_entity};
use facet_git_tree::RawTree;
use gix_hash::ObjectId;
@@ -17,6 +21,7 @@
use gix_ref_store::{RefStore, RefStoreRead};
use super::Comment;
+use super::entity::read_comment;
use crate::error::{Error, Result};
/// The tree of the commit at `oid` — read back into a typed entity by
@@ -42,7 +47,22 @@
Ok(commit.tree())
}
-/// `git ents comment list`: every comment recorded in this repository.
+/// Read the [`Comment`] at `id`'s ref tip, through the legacy-shape
+/// fallback (`meta-ref.migration`), or [`Error::NotFound`] when no such
+/// ref exists.
+fn comment_at(refs: &dyn RefStoreRead, objects: &impl Find, id: &str) -> Result<Comment> {
+ let ref_name = ents_model::namespace::comment_ref(id)?;
+ let Some(tip) = refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("comment {id}"),
+ });
+ };
+ let tree = commit_tree(objects, tip)?;
+ read_comment(&tree, objects)
+}
+
+/// `git ents comment list`: every comment recorded in this repository,
+/// pre-migration trees included (`meta-ref.migration`).
///
/// # Errors
///
@@ -67,15 +87,121 @@
continue;
};
let tree = commit_tree(objects, tip)?;
- if let Ok(comment) = facet_git_tree::deserialize::<Comment>(&tree, objects) {
+ if let Ok(comment) = read_comment(&tree, objects) {
out.push((id.to_owned(), comment));
}
}
Ok(out)
}
-/// `git ents comment add`: anchor `body` to `path` (optionally `lines`) at
-/// `rev`.
+/// One row of [`list_projected`]: the comment, and — when it carries an
+/// anchor — that anchor and its projection onto the requested target.
+#[derive(Debug, Clone)]
+pub struct Listed {
+ /// The comment's id (its refname below `refs/meta/comments/`).
+ pub id: String,
+ /// The comment itself.
+ pub comment: Comment,
+ /// The comment's anchor, read back from its embedded tree, or `None`
+ /// for a comment with no anchor — carried alongside [`Listed::projection`]
+ /// so a [`Projection::Current`] outcome still knows the anchored
+ /// path and lines it applies at.
+ pub anchor: Option<Anchor>,
+ /// Where the anchor lands on the projection target, or `None` for a
+ /// comment with no anchor.
+ pub projection: Option<Projection>,
+}
+
+/// Which filters [`list_projected`] applies before projecting.
+#[derive(Debug, Clone, Default)]
+pub struct ListFilter {
+ /// Keep only comments in this state (`model.comment-state`).
+ pub state: Option<String>,
+ /// Keep only comments naming this context (`model.comment-context`).
+ pub context: Option<String>,
+}
+
+/// `git ents comment list [--worktree] [--state ...] [--context ...]`:
+/// every matching comment, each anchor projected onto the working tree
+/// (`anchor.working-tree`) when `worktree` is set, onto `HEAD` otherwise —
+/// the listing `lens.parity` requires to be one library call shared by the
+/// CLI's machine-readable form, the web UI, and the editor lens.
+///
+/// # Errors
+///
+/// Propagates a ref-store, object read, repository open, or projection
+/// failure.
+// @relation(lens.parity, model.comment-state, model.comment-context, scope=function)
+pub fn list_projected(
+ refs: &dyn RefStoreRead,
+ objects: &impl Find,
+ repo_path: &std::path::Path,
+ worktree: bool,
+ filter: &ListFilter,
+) -> Result<Vec<Listed>> {
+ let repo = gix::open(repo_path)?;
+ let mut out = Vec::new();
+ for (id, comment) in list(refs, objects)? {
+ if let Some(state) = &filter.state
+ && comment.state != *state
+ {
+ continue;
+ }
+ if let Some(context) = &filter.context
+ && comment.context.as_ref() != Some(context)
+ {
+ continue;
+ }
+ let (anchor, projection) = match &comment.anchor {
+ None => (None, None),
+ Some(raw) => {
+ let anchor = facet_git_tree::deserialize::<Anchor>(&raw.oid(), objects)?;
+ let projection = if worktree {
+ project_worktree(&repo, &anchor, None)?
+ } else {
+ project(&repo, &anchor, "HEAD")?
+ };
+ (Some(anchor), Some(projection))
+ }
+ };
+ out.push(Listed {
+ id,
+ comment,
+ anchor,
+ projection,
+ });
+ }
+ Ok(out)
+}
+
+/// What `git ents comment add` writes, before the mechanism-side
+/// arguments: the body, what the comment is about (`model.comment` — at
+/// least one of an anchored path, a context, or a parent), and where its
+/// anchor captures from (`rev`, or the working tree per
+/// `anchor.working-tree` when `worktree` is set).
+#[derive(Debug, Clone)]
+pub struct NewComment {
+ /// The comment's body text.
+ pub body: String,
+ /// Repository-relative path to anchor to, or `None` for an unanchored
+ /// comment (about a context or a parent instead).
+ pub path: Option<String>,
+ /// Lines to anchor, as `<start>[:<end>]`; requires `path`.
+ pub lines: Option<String>,
+ /// Revision to anchor against; ignored when `worktree` is set.
+ pub rev: String,
+ /// Anchor against the working tree's on-disk bytes instead of `rev`
+ /// (`anchor.working-tree`).
+ pub worktree: bool,
+ /// Canonical ref path below `refs/meta/` of the entity this comment
+ /// belongs to, such as `issues/<id>` (`model.comment-context`).
+ pub context: Option<String>,
+ /// Id of the comment this one replies to (`model.comment-thread`);
+ /// [`reply`] is the porcelain shortcut that sets only this.
+ pub parent: Option<String>,
+}
+
+/// `git ents comment add`: create a comment about something.
///
/// Returns the generated comment id alongside the raw
/// [`Outcome`] `receive` reached — callers interpret it themselves (the
@@ -84,32 +210,70 @@
///
/// # Errors
///
-/// [`Error::InvalidArgument`] if `lines` does not parse as `<start>[:<end>]`;
-/// otherwise propagates capture, serialization, or `receive` failures.
-#[expect(
- clippy::too_many_arguments,
- reason = "one input per capture/build/propose step, mirrors ents_effect::run::run_one's shape"
-)]
+/// [`Error::InvalidArgument`] if the comment is about nothing — no path,
+/// no context, no parent (`model.comment`: refused at creation by the
+/// writing tool, never by the gate) — if `lines` does not parse as
+/// `<start>[:<end>]` or names lines without a path, or if `context` does
+/// 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)
pub fn add(
refs: &dyn RefStore,
objects: &(impl Find + Write),
events: &dyn ents_receive::EventSink,
repo_path: &std::path::Path,
- path: &str,
- body: String,
- lines: Option<String>,
- rev: &str,
+ new: NewComment,
identity: &Identity<'_>,
mode: Mode,
) -> Result<(String, Outcome)> {
- let repo = gix::open(repo_path)?;
- let range = lines.map(|text| parse_line_range(&text)).transpose()?;
- let anchor = ents_anchor::capture(&repo, rev, path, range)?;
+ // A comment about nothing is refused here, at creation, by the
+ // writing tool — the gate stays content-agnostic (`model.comment`).
+ if new.path.is_none() && new.context.is_none() && new.parent.is_none() {
+ return Err(Error::InvalidArgument(
+ "a comment must be about something: anchor it to a path, name a context, \
+ or reply to a parent"
+ .into(),
+ ));
+ }
+ if new.lines.is_some() && new.path.is_none() {
+ return Err(Error::InvalidArgument(
+ "--lines needs a path to anchor to".into(),
+ ));
+ }
+ if let Some(context) = &new.context {
+ validate_context(context)?;
+ }
+ if let Some(parent) = &new.parent {
+ // The parent must exist when the reply is created
+ // (`model.comment-thread`).
+ comment_at(refs, objects, parent)?;
+ }
+
+ let anchor = match &new.path {
+ None => None,
+ Some(path) => {
+ let repo = gix::open(repo_path)?;
+ let range = new.lines.map(|text| parse_line_range(&text)).transpose()?;
+ Some(if new.worktree {
+ ents_anchor::capture_worktree(&repo, path, range)?
+ } else {
+ ents_anchor::capture(&repo, &new.rev, path, range)?
+ })
+ }
+ };
+ let anchor = anchor
+ .map(|anchor| facet_git_tree::serialize_into(&anchor, objects))
+ .transpose()?
+ .map(RawTree::new);
- let anchor_tree = facet_git_tree::serialize_into(&anchor, objects)?;
let comment = Comment {
- body,
- anchor: RawTree::new(anchor_tree),
+ body: new.body,
+ // A new comment's state is `open` (`model.comment-state`).
+ state: "open".to_owned(),
+ anchor,
+ context: new.context,
+ parent: new.parent,
};
// The comment's id is its own genesis tip's short oid, known only once
@@ -118,7 +282,46 @@
// (`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,
+ )?;
+ Ok((id, outcome))
+}
+
+/// `git ents comment reply`: a comment whose parent is `parent_id`
+/// (`model.comment-thread`) — its aboutness is inherited from its thread
+/// root, so no anchor or context is required or set.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `parent_id` names no existing comment; otherwise
+/// see [`add`].
+// @relation(model.comment-thread, lens.parity, scope=function)
+pub fn reply(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ parent_id: &str,
+ body: String,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<(String, Outcome)> {
+ // The parent must exist when the reply is created.
+ comment_at(refs, objects, parent_id)?;
+ let comment = Comment {
+ body,
+ state: "open".to_owned(),
+ anchor: None,
+ 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(
refs,
objects,
@@ -126,39 +329,168 @@
ref_name,
&comment,
identity,
- &format!("Comment on {path}"),
+ &format!("Reply to comment {parent_id}"),
mode,
)?;
Ok((id, outcome))
}
-/// `git ents comment show`: `id`'s anchor (projected onto `rev`), anchored
-/// text, and body.
+/// `git ents comment resolve`: record state `resolved` as an ordinary
+/// mutation commit on the comment's own ref — never a deletion, so the
+/// conversation stays auditable (`model.comment-state`).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no comment ref; otherwise propagates
+/// read, serialization, or `receive` failures.
+// @relation(model.comment-state, lens.parity, scope=function)
+pub fn resolve(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ set_state(refs, objects, events, id, "resolved", identity, mode)
+}
+
+/// `git ents comment reopen`: record state `open` again, the same way
+/// [`resolve`] records `resolved` (`model.comment-state`).
+///
+/// # Errors
+///
+/// See [`resolve`].
+// @relation(model.comment-state, lens.parity, scope=function)
+pub fn reopen(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ set_state(refs, objects, events, id, "open", identity, mode)
+}
+
+/// The shared state mutation [`resolve`] and [`reopen`] are: read the
+/// comment at `id` — through the legacy fallback, so a pre-migration ref
+/// is rewritten under the broadened struct by this very commit
+/// (`meta-ref.migration`) — set `state`, and propose the new tree on top
+/// of the old tip.
+fn set_state(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ id: &str,
+ state: &str,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<Outcome> {
+ let mut comment = comment_at(refs, objects, id)?;
+ comment.state = state.to_owned();
+ let ref_name = ents_model::namespace::comment_ref(id)?;
+ Ok(propose_entity(
+ refs,
+ objects,
+ events,
+ ref_name,
+ &comment,
+ identity,
+ &format!("Mark comment {id} {state}"),
+ mode,
+ )?)
+}
+
+/// `git ents comment show`: `id`'s comment and — when it carries an
+/// anchor — that anchor, projected onto `rev` or (with `worktree`) onto
+/// the working tree (`anchor.working-tree`).
///
/// # Errors
///
/// [`Error::NotFound`] if `id` has no comment ref.
+// @relation(lens.parity, scope=function)
pub fn show(
refs: &dyn RefStoreRead,
objects: &impl Find,
repo_path: &std::path::Path,
id: &str,
rev: &str,
-) -> Result<(Comment, Anchor, Projection)> {
- let ref_name = ents_model::namespace::comment_ref(id)?;
- let Some(tip) = refs.get(ref_name.as_ref())? else {
- return Err(Error::NotFound {
- what: format!("comment {id}"),
- });
+ worktree: bool,
+) -> Result<(Comment, Option<(Anchor, Projection)>)> {
+ let comment = comment_at(refs, objects, id)?;
+ let Some(raw) = &comment.anchor else {
+ return Ok((comment, None));
};
- let tree = commit_tree(objects, tip)?;
- let comment = facet_git_tree::deserialize::<Comment>(&tree, objects)?;
- let anchor = facet_git_tree::deserialize::<Anchor>(&comment.anchor.oid(), objects)?;
-
+ let anchor = facet_git_tree::deserialize::<Anchor>(&raw.oid(), objects)?;
let repo = gix::open(repo_path)?;
- let projection = project(&repo, &anchor, rev)?;
+ let projection = if worktree {
+ project_worktree(&repo, &anchor, None)?
+ } else {
+ project(&repo, &anchor, rev)?
+ };
let _ = snippet(&anchor)?; // Confirm the anchored text still reads back.
- Ok((comment, anchor, projection))
+ Ok((comment, Some((anchor, projection))))
+}
+
+/// The thread of `context` (`model.comment-context`,
+/// `model.comment-thread`): every comment naming `context` directly, plus
+/// every reply whose parent chain reaches one — an aggregation query over
+/// decomposed comment refs, never a list any entity stores
+/// (`meta-ref.granularity`). Rows come back sorted by id, roots and
+/// replies alike; the `parent` field reconstructs the tree.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+// @relation(model.comment-context, model.comment-thread, scope=function)
+pub fn thread(
+ refs: &dyn RefStoreRead,
+ objects: &impl Find,
+ context: &str,
+) -> Result<Vec<(String, Comment)>> {
+ let all = list(refs, objects)?;
+ let mut included: std::collections::BTreeMap<&str, &Comment> = all
+ .iter()
+ .filter(|(_, comment)| comment.context.as_deref() == Some(context))
+ .map(|(id, comment)| (id.as_str(), comment))
+ .collect();
+ // Close over parent links: a reply names a comment already in the
+ // thread, transitively — no comment stores a list of its replies.
+ loop {
+ let mut grew = false;
+ for (id, comment) in &all {
+ if included.contains_key(id.as_str()) {
+ continue;
+ }
+ if let Some(parent) = &comment.parent
+ && included.contains_key(parent.as_str())
+ {
+ included.insert(id.as_str(), comment);
+ grew = true;
+ }
+ }
+ if !grew {
+ break;
+ }
+ }
+ Ok(included
+ .into_iter()
+ .map(|(id, comment)| (id.to_owned(), comment.clone()))
+ .collect())
+}
+
+/// Validate a `model.comment-context` value: the canonical ref path below
+/// `refs/meta/` of the entity the comment belongs to, such as
+/// `issues/<id>` — checked by building the full refname it names.
+fn validate_context(context: &str) -> Result<()> {
+ let full = format!("refs/meta/{context}");
+ if context.is_empty() || gix::refs::FullName::try_from(full).is_err() {
+ return Err(Error::InvalidArgument(format!(
+ "context {context:?} is not a ref path below refs/meta/"
+ )));
+ }
+ Ok(())
}
/// Parse a `<start>[:<end>]` line-range argument.
crates/forge/ents-forge/src/comment/entity.rs
@@ -1,23 +1,38 @@
-//! The Comment entity: a body anchored to specific content.
+//! The Comment entity: a body about something — an anchor, a context
+//! entity, a parent comment, or any combination.
//!
-//! Spec coverage: `model.comment`.
+//! Spec coverage: `model.comment`, `model.comment-state`,
+//! `model.comment-context`, `model.comment-thread`, `meta-ref.migration`.
use facet::Facet;
use facet_git_tree::RawTree;
+use gix_hash::ObjectId;
+use gix_object::Find;
-/// A body of text anchored to the exact content it was written against.
+/// A body of text about something: an anchor into content, a context
+/// entity, a parent comment, or any combination (`model.comment`).
///
-/// `model.comment` requires a body and an anchor, and that a comment's
-/// author and timestamp come from the mutation commit chain rather than a
-/// stored field — the same rule `meta-ref.trailers` states for ref-level
-/// metadata generally. `Comment` therefore has no author or timestamp
-/// field.
+/// `model.comment` requires a body and that the comment identify what it
+/// is about; a comment about nothing is refused at creation by the writing
+/// 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
+/// field, and no reviewer/resolver field either: who changed [`Comment::state`],
+/// and when, is the mutation chain's answer too (`model.comment-state`).
///
-/// The anchor itself is stored as an opaque [`RawTree`]: `anchor.adoc`
-/// (`anchor.definition`, `anchor.retention`, `anchor.projection`) defines
-/// what it identifies and how it survives force-push and gc, and is owned
-/// by [`ents_anchor`], this crate's own dependency for anchoring a comment
-/// to code (`super::command`).
+/// The anchor, when present, is stored as an opaque [`RawTree`]:
+/// `anchor.adoc` (`anchor.definition`, `anchor.retention`,
+/// `anchor.projection`, `anchor.working-tree`) defines what it identifies
+/// and how it survives force-push and gc, and it is owned by
+/// [`ents_anchor`], this crate's own dependency for anchoring a comment to
+/// code (`super::command`).
+///
+/// Comment trees written before this struct broadened (a bare
+/// `{body, anchor}` shape) still read back through
+/// [`read_comment`]'s legacy fallback (`meta-ref.migration`); mutating one
+/// rewrites its tree under this struct as an ordinary commit on top of the
+/// old tip, keeping the old encoding as archive.
///
/// # Examples
///
@@ -34,25 +49,111 @@
///
/// let comment = Comment {
/// body: "this line looks off by one".to_owned(),
-/// anchor: RawTree::new(anchor_oid),
+/// state: "open".to_owned(),
+/// anchor: Some(RawTree::new(anchor_oid)),
+/// context: Some("issues/42".to_owned()),
+/// parent: None,
/// };
/// let root = facet_git_tree::serialize_into(&comment, &store).expect("serialize");
/// let back: Comment = facet_git_tree::deserialize(&root, &store).expect("deserialize");
/// assert_eq!(back, comment);
/// ```
-// @relation(model.comment, meta-ref.typed-tree, model.extensibility, scope=file)
+// @relation(model.comment, model.comment-state, model.comment-context, model.comment-thread, meta-ref.typed-tree, model.extensibility, scope=file)
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct Comment {
/// The comment's text.
pub body: String,
+ /// The comment's state (`model.comment-state`): `open` for a new
+ /// comment, `resolved` once resolved — not a fixed enum, because
+ /// custom states are schema, not platform features, exactly as for
+ /// issues (`model.issue`).
+ pub state: String,
/// The anchor identifying the exact content the comment was written
- /// against (`anchor.definition`), opaque to this crate.
- pub anchor: RawTree,
+ /// against (`anchor.definition`), opaque to this crate; `None` for a
+ /// 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>`
+ /// (`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>,
+ /// The id of the comment this one replies to (`model.comment-thread`);
+ /// a reply inherits its aboutness from its thread root rather than
+ /// repeating an anchor or context.
+ pub parent: Option<String>,
+}
+
+impl Comment {
+ /// Whether this comment identifies nothing at all — no anchor, no
+ /// context, no parent. `model.comment` requires the writing tool to
+ /// refuse such a comment at creation ([`super::add`] does), though
+ /// never the gate.
+ #[must_use]
+ pub fn is_about_nothing(&self) -> bool {
+ self.anchor.is_none() && self.context.is_none() && self.parent.is_none()
+ }
+}
+
+/// The comment tree shape phase-7 code wrote: a body and a mandatory,
+/// directly-embedded anchor — no state, context, or parent. Kept only as
+/// [`read_comment`]'s fallback target (`meta-ref.migration`: history keeps
+/// the old encoding as archive, and the tip of a pre-migration ref *is*
+/// still this encoding until something mutates it).
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub(crate) struct LegacyComment {
+ pub(crate) body: String,
+ pub(crate) anchor: RawTree,
+}
+
+impl From<LegacyComment> for Comment {
+ fn from(legacy: LegacyComment) -> Self {
+ Self {
+ body: legacy.body,
+ state: "open".to_owned(),
+ anchor: Some(legacy.anchor),
+ context: None,
+ parent: None,
+ }
+ }
+}
+
+/// Read the [`Comment`] stored at `tree`, falling back to the legacy
+/// `{body, anchor}` shape (`meta-ref.migration`).
+///
+/// The two encodings are structurally disjoint, so no version marker is
+/// consulted (`meta-ref.typed-tree` forbids one in the tree, and the
+/// reserved `Schema-Version:` trailer stays unused while detection works
+/// structurally): a legacy tree has no `state` entry and embeds its anchor
+/// tree directly where the broadened struct expects an `Option` wrapper,
+/// so it can never be misread as a current [`Comment`] — and a current tree
+/// always carries `state`, so it is never consulted against the legacy
+/// shape at all.
+///
+/// A legacy read maps to the broadened struct exactly as the migration
+/// commit would rewrite it: state `open` (`model.comment-state`'s value
+/// for every comment created before states existed), no context, no
+/// parent.
+///
+/// # Errors
+///
+/// The current shape's own [`facet_git_tree::Error`] when `tree` reads as
+/// neither encoding.
+// @relation(meta-ref.migration, scope=function)
+pub(crate) fn read_comment(tree: &ObjectId, objects: &impl Find) -> crate::Result<Comment> {
+ match facet_git_tree::deserialize::<Comment>(tree, objects) {
+ Ok(comment) => Ok(comment),
+ Err(error) => match facet_git_tree::deserialize::<LegacyComment>(tree, objects) {
+ Ok(legacy) => Ok(legacy.into()),
+ // Report the *current* shape's failure: a tree that is neither
+ // encoding is diagnosed against the schema in force.
+ Err(_legacy_error) => Err(error.into()),
+ },
+ }
}
#[cfg(test)]
mod tests {
- #![allow(clippy::expect_used, reason = "unit test")]
+ #![allow(clippy::expect_used, clippy::unwrap_used, reason = "unit test")]
use facet_git_tree::{ObjectStore, deserialize, serialize_into};
use gix_object::Write as _;
@@ -60,19 +161,88 @@
use super::*;
- #[rstest]
- // @relation(model.comment, meta-ref.typed-tree, scope=function, role=Verifies)
- fn comment_round_trips_through_a_tree() {
- let store = ObjectStore::default();
- let anchor_tree = gix_object::Tree { entries: vec![] };
- let anchor_oid = store.write(&anchor_tree).expect("tree");
+ fn anchor_tree(store: &ObjectStore) -> RawTree {
+ let tree = gix_object::Tree { entries: vec![] };
+ RawTree::new(store.write(&tree).expect("tree"))
+ }
+ #[rstest]
+ #[case::anchored_only(true, None, None)]
+ #[case::context_only(false, Some("issues/42"), None)]
+ #[case::reply_only(false, None, Some("abc123"))]
+ #[case::every_kind_of_aboutness(true, Some("reviews/7"), Some("abc123"))]
+ // @relation(model.comment, model.comment-state, model.comment-context, model.comment-thread, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn comment_round_trips_through_a_tree(
+ #[case] anchored: bool,
+ #[case] context: Option<&str>,
+ #[case] parent: Option<&str>,
+ ) {
+ let store = ObjectStore::default();
let comment = Comment {
body: "looks off by one".to_owned(),
- anchor: RawTree::new(anchor_oid),
+ state: "open".to_owned(),
+ anchor: anchored.then(|| anchor_tree(&store)),
+ context: context.map(str::to_owned),
+ parent: parent.map(str::to_owned),
};
let root = serialize_into(&comment, &store).expect("serialize");
let back: Comment = deserialize(&root, &store).expect("deserialize");
assert_eq!(back, comment);
}
+
+ /// `meta-ref.migration`: a tree written by phase-7 code — the bare
+ /// `{body, anchor}` shape — still reads back, mapping to state `open`
+ /// with no context or parent; a current tree reads as itself.
+ // @relation(meta-ref.migration, scope=function, role=Verifies)
+ #[rstest]
+ fn legacy_trees_still_read_back() {
+ let store = ObjectStore::default();
+ let legacy = LegacyComment {
+ body: "written by phase-7 code".to_owned(),
+ anchor: anchor_tree(&store),
+ };
+ let root = serialize_into(&legacy, &store).expect("serialize");
+
+ let read = read_comment(&root, &store).expect("legacy fallback reads");
+ assert_eq!(read.body, legacy.body);
+ assert_eq!(read.state, "open");
+ assert_eq!(read.anchor, Some(legacy.anchor));
+ assert_eq!(read.context, None);
+ assert_eq!(read.parent, None);
+ }
+
+ /// A tree that is neither encoding fails against the schema in force,
+ /// not the archival one.
+ // @relation(meta-ref.migration, scope=function, role=Verifies)
+ #[rstest]
+ fn a_foreign_tree_reads_as_neither_encoding() {
+ let store = ObjectStore::default();
+ let root = store
+ .write(&gix_object::Tree { entries: vec![] })
+ .expect("tree");
+ let _error = read_comment(&root, &store).unwrap_err();
+ }
+
+ #[rstest]
+ #[case::anchored(true, None, None, false)]
+ #[case::contextual(false, Some("issues/42"), None, false)]
+ #[case::reply(false, None, Some("abc"), false)]
+ #[case::about_nothing(false, None, None, true)]
+ // @relation(model.comment, scope=function, role=Verifies)
+ fn is_about_nothing_requires_all_three_absent(
+ #[case] anchored: bool,
+ #[case] context: Option<&str>,
+ #[case] parent: Option<&str>,
+ #[case] expected: bool,
+ ) {
+ let store = ObjectStore::default();
+ let comment = Comment {
+ body: "b".to_owned(),
+ state: "open".to_owned(),
+ anchor: anchored.then(|| anchor_tree(&store)),
+ context: context.map(str::to_owned),
+ parent: parent.map(str::to_owned),
+ };
+ assert_eq!(comment.is_about_nothing(), expected);
+ }
}
crates/forge/ents-forge/src/comment/mod.rs
@@ -9,5 +9,7 @@
mod entity;
pub use cli::CommentAction;
-pub use command::{add, list, show};
+pub use command::{
+ ListFilter, Listed, NewComment, add, list, list_projected, reopen, reply, resolve, show, thread,
+};
pub use entity::Comment;
crates/forge/ents-forge/tests/conversations.rs
@@ -1,0 +1,578 @@
+//! Integration coverage for the comment command layer: the broadened
+//! `model.comment` (aboutness refused at creation, `model.comment-state`
+//! transitions, `model.comment-context`/`model.comment-thread`
+//! aggregation) and — with the most care, per this phase's plan row — the
+//! `meta-ref.migration` guarantee that comment trees written by phase-7
+//! code still read back, and are rewritten under the current struct only
+//! when mutated.
+
+#![allow(
+ clippy::expect_used,
+ clippy::unwrap_used,
+ clippy::indexing_slicing,
+ clippy::panic,
+ reason = "integration test: fixtures panic on setup failure"
+)]
+
+use ents_forge::comment::{self, ListFilter, NewComment};
+use ents_receive::{Identity, Mode, NullEventSink, TxResult};
+use ents_testutil::{Keypair, MemRefStore, ObjectStore, write_meta_entity};
+use facet_git_tree::RawTree;
+use gix_object::Write as _;
+use gix_ref_store::RefStoreRead as _;
+use rstest::rstest;
+
+/// The exact struct phase-7 code declared for its Comment entity — the
+/// on-disk encoding `meta-ref.migration` requires the broadened reader to
+/// keep reading. Declared independently here (not imported) so this test
+/// pins the *storage shape*, not whatever the crate's internal legacy
+/// struct happens to be.
+#[derive(facet::Facet)]
+struct Phase7Comment {
+ body: String,
+ anchor: RawTree,
+}
+
+/// A throwaway on-disk repository holding one committed file — the
+/// content anchors capture against — alongside the in-memory ref/object
+/// fixtures every library test uses.
+/// A detached signer over some bytes, returning an armored signature.
+type Signer = Box<dyn Fn(&[u8]) -> String>;
+
+struct Fixture {
+ dir: tempfile::TempDir,
+ refs: MemRefStore,
+ objects: ObjectStore,
+ sign: Signer,
+}
+
+impl Fixture {
+ fn new() -> Self {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let git = |args: &[&str]| {
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args(["-c", "user.name=test", "-c", "user.email=test@example.com"])
+ .args(args)
+ .status()
+ .expect("git runs");
+ assert!(status.success());
+ };
+ git(&["init", "-q"]);
+ let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect();
+ std::fs::write(dir.path().join("file.txt"), contents).unwrap();
+ git(&["add", "-A"]);
+ git(&["commit", "-q", "-m", "seed"]);
+ let key = Keypair::from_seed(1);
+ Self {
+ dir,
+ refs: MemRefStore::default(),
+ objects: ObjectStore::default(),
+ sign: Box::new(move |payload| key.sign(payload)),
+ }
+ }
+
+ fn path(&self) -> &std::path::Path {
+ self.dir.path()
+ }
+
+ fn identity(&self) -> Identity<'_> {
+ Identity {
+ actor: gix::actor::Signature {
+ name: "test".into(),
+ email: "test@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ },
+ sign: &*self.sign,
+ }
+ }
+
+ fn draft(&self) -> NewComment {
+ NewComment {
+ body: "looks off by one".to_owned(),
+ path: Some("file.txt".to_owned()),
+ lines: Some("3:4".to_owned()),
+ rev: "HEAD".to_owned(),
+ worktree: false,
+ context: None,
+ parent: None,
+ }
+ }
+
+ fn add(&self, draft: NewComment) -> String {
+ let (id, outcome) = comment::add(
+ &self.refs,
+ &self.objects,
+ &NullEventSink,
+ self.path(),
+ draft,
+ &self.identity(),
+ Mode::Advisory,
+ )
+ .expect("adds");
+ assert_eq!(outcome.result, TxResult::Applied);
+ id
+ }
+
+ /// Seed a comment ref exactly as phase-7 code wrote one: the bare
+ /// `{body, anchor}` tree under `refs/meta/comments/<id>`.
+ fn seed_phase7(&self, id: &str, body: &str) -> gix_hash::ObjectId {
+ let anchor = ents_anchor::capture(
+ &gix::open(self.path()).expect("opens"),
+ "HEAD",
+ "file.txt",
+ None,
+ )
+ .expect("captures");
+ let anchor_tree =
+ facet_git_tree::serialize_into(&anchor, &self.objects).expect("serializes");
+ let legacy = Phase7Comment {
+ body: body.to_owned(),
+ anchor: RawTree::new(anchor_tree),
+ };
+ let name = ents_model::namespace::comment_ref(id).expect("valid");
+ write_meta_entity(&self.refs, &self.objects, name, &legacy, None, 500)
+ }
+}
+
+// ---------------------------------------------------------------------
+// meta-ref.migration: phase-7 trees still read; mutation rewrites.
+// ---------------------------------------------------------------------
+
+/// A pre-migration ref's tip reads back through every read surface —
+/// list, show — mapping to state `open` with no context or parent.
+// @relation(meta-ref.migration, scope=function, role=Verifies)
+#[rstest]
+fn phase7_comment_refs_still_read_back() {
+ let fixture = Fixture::new();
+ fixture.seed_phase7("legacy1", "written by phase-7 code");
+
+ let listed = comment::list(&fixture.refs, &fixture.objects).expect("lists");
+ assert_eq!(listed.len(), 1);
+ let (id, read) = &listed[0];
+ assert_eq!(id, "legacy1");
+ assert_eq!(read.body, "written by phase-7 code");
+ assert_eq!(read.state, "open");
+ assert_eq!(read.context, None);
+ assert_eq!(read.parent, None);
+
+ let (shown, projected) = comment::show(
+ &fixture.refs,
+ &fixture.objects,
+ fixture.path(),
+ "legacy1",
+ "HEAD",
+ false,
+ )
+ .expect("shows");
+ assert_eq!(shown.body, "written by phase-7 code");
+ let (anchor, projection) = projected.expect("legacy comments always carry an anchor");
+ assert_eq!(anchor.path, "file.txt");
+ assert_eq!(projection, ents_anchor::Projection::Current);
+}
+
+/// Mutating a pre-migration ref rewrites its tree under the current
+/// struct as a commit on top of the old tip (`meta-ref.migration`):
+/// the new tip deserializes directly as the broadened `Comment`, its
+/// parent is the untouched legacy commit, and nothing rewrote history.
+// @relation(meta-ref.migration, model.comment-state, scope=function, role=Verifies)
+#[rstest]
+fn mutating_a_phase7_ref_migrates_it_on_top_of_the_old_tip() {
+ use gix_object::Find as _;
+
+ let fixture = Fixture::new();
+ let old_tip = fixture.seed_phase7("legacy1", "written by phase-7 code");
+
+ let outcome = comment::resolve(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ "legacy1",
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("resolves");
+ assert_eq!(outcome.result, TxResult::Applied);
+
+ let name = ents_model::namespace::comment_ref("legacy1").expect("valid");
+ let new_tip = fixture
+ .refs
+ .get(name.as_ref())
+ .expect("readable")
+ .expect("set");
+ assert_ne!(new_tip, old_tip);
+
+ // The new tip's tree is the *current* encoding, no fallback needed...
+ let mut buf = Vec::new();
+ let data = fixture
+ .objects
+ .try_find(&new_tip, &mut buf)
+ .expect("readable")
+ .expect("present");
+ let commit = gix_object::CommitRef::from_bytes(data.data, new_tip.kind()).expect("parses");
+ let migrated: ents_forge::comment::Comment =
+ facet_git_tree::deserialize(&commit.tree(), &fixture.objects)
+ .expect("the migrated tip deserializes directly as the broadened struct");
+ assert_eq!(migrated.state, "resolved");
+ assert_eq!(migrated.body, "written by phase-7 code");
+ assert!(migrated.anchor.is_some());
+
+ // ...and it sits on top of the old tip: history keeps the old
+ // encoding as archive, nothing was rewritten or deleted.
+ let parents: Vec<_> = commit.parents().collect();
+ assert_eq!(parents, vec![old_tip]);
+}
+
+/// The legacy fallback holds for any body content, not just the fixture
+/// string — old-shape trees over an unenumerable input space read back
+/// with the same mapping.
+// @relation(meta-ref.migration, scope=function, role=Verifies)
+#[test]
+fn any_phase7_tree_reads_back_mapped_to_open() {
+ let runner = proptest::test_runner::Config::with_cases(64);
+ proptest::proptest!(runner, |(body in proptest::prelude::any::<String>())| {
+ let objects = ObjectStore::default();
+ let refs = MemRefStore::default();
+ let anchor_tree = objects
+ .write(&gix_object::Tree { entries: vec![] })
+ .expect("tree");
+ let legacy = Phase7Comment {
+ body: body.clone(),
+ anchor: RawTree::new(anchor_tree),
+ };
+ let name = ents_model::namespace::comment_ref("p").expect("valid");
+ write_meta_entity(&refs, &objects, name, &legacy, None, 500);
+
+ let listed = comment::list(&refs, &objects).expect("lists");
+ proptest::prop_assert_eq!(listed.len(), 1);
+ let read = &listed[0].1;
+ proptest::prop_assert_eq!(&read.body, &body);
+ proptest::prop_assert_eq!(&read.state, "open");
+ proptest::prop_assert_eq!(read.anchor.as_ref(), Some(&RawTree::new(anchor_tree)));
+ proptest::prop_assert_eq!(&read.context, &None);
+ proptest::prop_assert_eq!(&read.parent, &None);
+ });
+}
+
+// ---------------------------------------------------------------------
+// model.comment: aboutness is required at creation, never by the gate.
+// ---------------------------------------------------------------------
+
+/// The library refuses a comment about nothing and malformed aboutness
+/// arguments; every well-formed combination is accepted.
+// @relation(model.comment, model.comment-context, scope=function, role=Verifies)
+#[rstest]
+#[case::about_nothing(None, None, None, false)]
+#[case::lines_without_a_path(None, Some("issues/42"), Some("3:4"), false)]
+#[case::bad_context(None, Some("not a ref\u{7f}"), None, false)]
+#[case::context_only(None, Some("issues/42"), None, true)]
+#[case::anchored(Some("file.txt"), None, None, true)]
+#[case::anchored_and_contextual(Some("file.txt"), Some("reviews/7"), None, true)]
+fn add_refuses_a_comment_about_nothing(
+ #[case] path: Option<&str>,
+ #[case] context: Option<&str>,
+ #[case] lines: Option<&str>,
+ #[case] accepted: bool,
+) {
+ let fixture = Fixture::new();
+ let draft = NewComment {
+ body: "b".to_owned(),
+ path: path.map(str::to_owned),
+ lines: lines.map(str::to_owned),
+ rev: "HEAD".to_owned(),
+ worktree: false,
+ context: context.map(str::to_owned),
+ parent: None,
+ };
+ let result = comment::add(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ fixture.path(),
+ draft,
+ &fixture.identity(),
+ Mode::Advisory,
+ );
+ match (accepted, result) {
+ (true, Ok((_, outcome))) => assert_eq!(outcome.result, TxResult::Applied),
+ (false, Err(error)) => assert!(matches!(error, ents_forge::Error::InvalidArgument(_))),
+ (expected, got) => panic!("expected accepted={expected}, got {got:?}"),
+ }
+}
+
+/// A reply's parent must exist when the reply is created
+/// (`model.comment-thread`) — both through `reply` and through `add
+/// --parent`.
+// @relation(model.comment-thread, scope=function, role=Verifies)
+#[rstest]
+fn a_reply_to_a_missing_parent_is_refused() {
+ let fixture = Fixture::new();
+ let error = comment::reply(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ "no-such-id",
+ "reply".to_owned(),
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect_err("refused");
+ assert!(matches!(error, ents_forge::Error::NotFound { .. }));
+
+ let mut draft = fixture.draft();
+ draft.parent = Some("no-such-id".to_owned());
+ let error = comment::add(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ fixture.path(),
+ draft,
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect_err("refused");
+ assert!(matches!(error, ents_forge::Error::NotFound { .. }));
+}
+
+// ---------------------------------------------------------------------
+// model.comment-state: resolve and reopen are ordinary ref mutations.
+// ---------------------------------------------------------------------
+
+/// A new comment opens `open`; resolve records `resolved`; reopen records
+/// `open` again — three commits on one ref, never a deletion.
+// @relation(model.comment-state, scope=function, role=Verifies)
+#[rstest]
+fn resolve_and_reopen_advance_the_same_ref() {
+ let fixture = Fixture::new();
+ let id = fixture.add(fixture.draft());
+ let state = |fixture: &Fixture| {
+ comment::list(&fixture.refs, &fixture.objects).expect("lists")[0]
+ .1
+ .state
+ .clone()
+ };
+ assert_eq!(state(&fixture), "open");
+
+ let outcome = comment::resolve(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("resolves");
+ assert_eq!(outcome.result, TxResult::Applied);
+ assert_eq!(state(&fixture), "resolved");
+
+ let outcome = comment::reopen(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &id,
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("reopens");
+ assert_eq!(outcome.result, TxResult::Applied);
+ assert_eq!(state(&fixture), "open");
+}
+
+// ---------------------------------------------------------------------
+// model.comment-context / model.comment-thread: threads are aggregation
+// queries over decomposed refs.
+// ---------------------------------------------------------------------
+
+/// `thread` aggregates the comments naming a context plus every reply
+/// reachable through parent links — a reply repeats neither anchor nor
+/// context, and no entity stored a list of anything.
+// @relation(model.comment-context, model.comment-thread, scope=function, role=Verifies)
+#[rstest]
+fn a_thread_aggregates_context_roots_and_their_replies() {
+ let fixture = Fixture::new();
+ let mut root_draft = fixture.draft();
+ root_draft.context = Some("reviews/7".to_owned());
+ let root = fixture.add(root_draft);
+ let (reply, outcome) = comment::reply(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &root,
+ "agreed".to_owned(),
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("replies");
+ assert_eq!(outcome.result, TxResult::Applied);
+ // A second-level reply, and an unrelated comment that must stay out.
+ let (nested, _) = comment::reply(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &reply,
+ "further".to_owned(),
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("replies");
+ let mut unrelated = fixture.draft();
+ unrelated.context = Some("issues/9".to_owned());
+ fixture.add(unrelated);
+
+ let thread = comment::thread(&fixture.refs, &fixture.objects, "reviews/7").expect("aggregates");
+ let mut ids: Vec<_> = thread.iter().map(|(id, _)| id.clone()).collect();
+ ids.sort();
+ let mut expected = vec![root.clone(), reply.clone(), nested.clone()];
+ expected.sort();
+ assert_eq!(ids, expected);
+
+ // The reply carried no anchor and no context of its own — aboutness
+ // is inherited from the thread root.
+ let replied = thread
+ .iter()
+ .find(|(id, _)| *id == reply)
+ .map(|(_, c)| c)
+ .expect("present");
+ assert_eq!(replied.anchor, None);
+ assert_eq!(replied.context, None);
+ assert_eq!(replied.parent, Some(root));
+}
+
+// ---------------------------------------------------------------------
+// lens.parity: the projected listing is one library call.
+// ---------------------------------------------------------------------
+
+/// `list_projected` filters by state and context and projects each anchor
+/// onto the working tree when asked — the exact call the CLI's
+/// machine-readable form and the editor lens both consume.
+// @relation(lens.parity, anchor.working-tree, scope=function, role=Verifies)
+#[rstest]
+fn list_projected_filters_and_projects_onto_the_working_tree() {
+ let fixture = Fixture::new();
+ let anchored = fixture.add(fixture.draft());
+ let mut contextual = fixture.draft();
+ contextual.path = None;
+ contextual.lines = None;
+ contextual.context = Some("issues/42".to_owned());
+ let unanchored = fixture.add(contextual);
+ comment::resolve(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &unanchored,
+ &fixture.identity(),
+ Mode::Advisory,
+ )
+ .expect("resolves");
+
+ // Dirty the working tree above the anchored range: the worktree
+ // projection relocates while a HEAD projection would say Current.
+ let dirty: String = std::iter::once("inserted\n".to_owned())
+ .chain((1..=10).map(|n| format!("line {n}\n")))
+ .collect();
+ std::fs::write(fixture.path().join("file.txt"), dirty).unwrap();
+
+ let open_only = comment::list_projected(
+ &fixture.refs,
+ &fixture.objects,
+ fixture.path(),
+ true,
+ &ListFilter {
+ state: Some("open".to_owned()),
+ context: None,
+ },
+ )
+ .expect("lists");
+ assert_eq!(open_only.len(), 1);
+ assert_eq!(open_only[0].id, anchored);
+ assert_eq!(
+ open_only[0].projection,
+ Some(ents_anchor::Projection::Relocated {
+ path: "file.txt".to_owned(),
+ lines: Some(ents_anchor::LineRange { start: 4, end: 5 }),
+ })
+ );
+
+ let by_context = comment::list_projected(
+ &fixture.refs,
+ &fixture.objects,
+ fixture.path(),
+ true,
+ &ListFilter {
+ state: None,
+ context: Some("issues/42".to_owned()),
+ },
+ )
+ .expect("lists");
+ assert_eq!(by_context.len(), 1);
+ assert_eq!(by_context[0].id, unanchored);
+ assert_eq!(by_context[0].projection, None, "no anchor, no projection");
+}
+
+/// `--worktree` end to end at the library layer: a comment anchored to
+/// dirty, uncommitted content is Current against the working tree and
+/// survives the content being discarded (its content is embedded).
+// @relation(anchor.working-tree, model.comment, scope=function, role=Verifies)
+#[rstest]
+fn a_worktree_anchored_comment_tracks_the_dirty_file() {
+ let fixture = Fixture::new();
+ let dirty: String = (1..=10)
+ .map(|n| {
+ if n == 5 {
+ "line five\n".to_owned()
+ } else {
+ format!("line {n}\n")
+ }
+ })
+ .collect();
+ std::fs::write(fixture.path().join("file.txt"), &dirty).unwrap();
+
+ let mut draft = fixture.draft();
+ draft.worktree = true;
+ draft.lines = Some("5".to_owned());
+ let id = fixture.add(draft);
+
+ let (_, projected) = comment::show(
+ &fixture.refs,
+ &fixture.objects,
+ fixture.path(),
+ &id,
+ "HEAD",
+ true,
+ )
+ .expect("shows");
+ let (anchor, projection) = projected.expect("anchored");
+ assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "line five\n");
+ assert_eq!(projection, ents_anchor::Projection::Current);
+
+ // Discard the dirty content: the anchor's own text still reads back
+ // (embedded), and the worktree projection reports the region edited.
+ let git = std::process::Command::new("git")
+ .arg("-C")
+ .arg(fixture.path())
+ .args(["checkout", "--", "file.txt"])
+ .status()
+ .expect("git runs");
+ assert!(git.success());
+ let (_, projected) = comment::show(
+ &fixture.refs,
+ &fixture.objects,
+ fixture.path(),
+ &id,
+ "HEAD",
+ true,
+ )
+ .expect("shows");
+ let (anchor, projection) = projected.expect("anchored");
+ assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "line five\n");
+ assert_eq!(
+ projection,
+ ents_anchor::Projection::Outdated {
+ path: "file.txt".to_owned(),
+ }
+ );
+}
crates/kernel/ents-model/src/comment.rs
@@ -1,80 +1,0 @@
-//! The Comment entity: a body anchored to specific content.
-//!
-//! Spec coverage: `model.comment`.
-
-use facet::Facet;
-use facet_git_tree::RawTree;
-
-/// A body of text anchored to the exact content it was written against.
-///
-/// `model.comment` requires a body and an anchor, and that a comment's
-/// author and timestamp come from the mutation commit chain rather than a
-/// stored field — the same rule `meta-ref.trailers` states for ref-level
-/// metadata generally. `Comment` therefore has no author or timestamp
-/// field.
-///
-/// The anchor itself is stored as an opaque [`RawTree`]: `anchor.adoc`
-/// (`anchor.definition`, `anchor.retention`, `anchor.projection`) defines
-/// what it identifies and how it survives force-push and gc, and is owned
-/// by `ents-anchor` (phase 3 — not started by this crate). `ents-model`
-/// only reserves the slot `model.comment` requires; the tree `ents-anchor`
-/// writes there must already exist in the store being serialized into, per
-/// `RawTree`'s own contract.
-///
-/// # Examples
-///
-/// ```
-/// use ents_model::Comment;
-/// use facet_git_tree::{ObjectStore, RawTree};
-/// use gix_object::{Kind, Write as _};
-///
-/// // Stand in for what `ents-anchor` will actually write: any pre-existing
-/// // tree, embedded unchanged.
-/// let store = ObjectStore::default();
-/// let anchor_tree = gix_object::Tree { entries: vec![] };
-/// let anchor_oid = store.write(&anchor_tree).expect("tree");
-///
-/// let comment = Comment {
-/// body: "this line looks off by one".to_owned(),
-/// anchor: RawTree::new(anchor_oid),
-/// };
-/// let root = facet_git_tree::serialize_into(&comment, &store).expect("serialize");
-/// let back: Comment = facet_git_tree::deserialize(&root, &store).expect("deserialize");
-/// assert_eq!(back, comment);
-/// ```
-// @relation(model.comment, meta-ref.typed-tree, model.extensibility, scope=file)
-#[derive(Debug, Clone, PartialEq, Eq, Facet)]
-pub struct Comment {
- /// The comment's text.
- pub body: String,
- /// The anchor identifying the exact content the comment was written
- /// against (`anchor.definition`), opaque to this crate.
- pub anchor: RawTree,
-}
-
-#[cfg(test)]
-mod tests {
- #![allow(clippy::expect_used, reason = "unit test")]
-
- use facet_git_tree::{ObjectStore, deserialize, serialize_into};
- use gix_object::Write as _;
- use rstest::rstest;
-
- use super::*;
-
- #[rstest]
- // @relation(model.comment, meta-ref.typed-tree, scope=function, role=Verifies)
- fn comment_round_trips_through_a_tree() {
- let store = ObjectStore::default();
- let anchor_tree = gix_object::Tree { entries: vec![] };
- let anchor_oid = store.write(&anchor_tree).expect("tree");
-
- let comment = Comment {
- body: "looks off by one".to_owned(),
- anchor: RawTree::new(anchor_oid),
- };
- let root = serialize_into(&comment, &store).expect("serialize");
- let back: Comment = deserialize(&root, &store).expect("deserialize");
- assert_eq!(back, comment);
- }
-}