git-ents.gitmain
⌘K
foforge
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

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1137,6 +1137,7 @@ "gix-ref-store", "proptest", "rstest", + "tempfile", "thiserror 2.0.18", "uuid", ]
crates/forge/ents-forge/Cargo.toml @@ -23,6 +23,7 @@ ents-testutil = { workspace = true } proptest = { workspace = true } rstest = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true
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); - } -}