git-ents.gitmain
⌘K
foforge
commit f9f8fc8
feat: add `git ents comment` — add, list, show, and remove code comments

Comments follow the members flow: fetch the refs/meta/comments/* refs from a remote, write through git-comment locally, and push back signed with a compare-and-swap lease. list and show project each anchor onto a revision (current, relocated, outdated, or deleted) and recover the author from the ref’s commits; show prints the anchored text derived from the blob.

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 @@ -1271,6 +1271,8 @@ "facet", "form_urlencoded", "futures-util", + "git-anchor", + "git-comment", "git-store", "iddqd", "inquire",
Cargo.toml @@ -47,6 +47,7 @@ facet-git-tree = { git = "https://github.com/git-ents/facet-git-tree" } form_urlencoded = "1" git-anchor = { path = "crates/git-anchor" } +git-comment = { path = "crates/git-comment" } git-store = { path = "crates/git-store" } gix = "0.84" gix-hash = { version = "0.25", features = ["sha1"] }
crates/git-ents/Cargo.toml @@ -11,6 +11,8 @@ facet = { workspace = true } form_urlencoded.workspace = true futures-util = { version = "0.3.32", default-features = false, features = ["sink", "std"] } +git-anchor = { workspace = true } +git-comment = { workspace = true } git-store = { workspace = true } iddqd = { workspace = true } inquire = { version = "0.9.4", default-features = false, features = ["crossterm"] }
crates/git-ents/src/main.rs @@ -3,7 +3,8 @@ //! It carries `git ents members` for managing the repository members recorded //! one-ref-per-person at `refs/meta/member/<username>`, `git ents account` for //! the account identity at `refs/meta/account`, `git ents checks` for the check -//! set, and the client setup that produces the signed pushes the server +//! set, `git ents comment` for the code comments at `refs/meta/comments/<id>`, +//! and the client setup that produces the signed pushes the server //! requires. The member commands read and write a remote's set by fetching the //! `refs/meta/member/*` refs into the local repository, editing them through //! [`git_ents::members`], and pushing them back. @@ -16,6 +17,8 @@ use std::process::{Command, ExitCode, Stdio}; use clap::{Parser, Subcommand}; +use git_anchor::{LineRange, Projection}; +use git_comment::{COMMENTS_NS, Comment}; use git_ents::account::{self, Account}; use git_ents::checks::{self, CHECKS_REF, Check}; use git_ents::members::{self, MEMBER_NS, Member, Trust, member_ref}; @@ -45,6 +48,12 @@ #[command(subcommand)] action: ChecksAction, }, + /// Comment on code: one comment per ref at `refs/meta/comments/<id>`, + /// anchored to a blob (and optionally lines) at a commit. + Comment { + #[command(subcommand)] + action: CommentAction, + }, /// Sign in to a remote's server the same way the web UI does — sign a /// server-issued challenge with your key — so this machine can also open a /// debug session (`checks debug`). @@ -202,12 +211,67 @@ }, } +#[derive(Subcommand)] +enum CommentAction { + /// Anchor a comment to a file at a revision and push it. Prompts for the + /// path and body when left unset at an interactive terminal. + Add { + /// Repository-relative path of the file the comment anchors to. + path: Option<String>, + /// Remote whose comment refs to update. + #[arg(default_value = "origin")] + remote: String, + /// The comment's body text. + #[arg(long)] + body: Option<String>, + /// Lines to anchor, as `<start>[:<end>]` (1-based, inclusive); omit + /// for a whole-file comment. + #[arg(long)] + lines: Option<String>, + /// Revision to anchor against. + #[arg(long, default_value = "HEAD")] + rev: String, + /// Genesis id of the issue the comment belongs to. + #[arg(long)] + issue: Option<String>, + }, + /// List the comments on a remote, each projected onto a revision. + List { + /// Remote to read the `refs/meta/comments/*` refs from. + #[arg(default_value = "origin")] + remote: String, + /// Revision to project each comment's anchor onto. + #[arg(long, default_value = "HEAD")] + rev: String, + }, + /// Show one comment: author, anchor, projection, anchored text, and body. + Show { + /// The comment's id (or a unique prefix of it). + id: String, + /// Remote to read the `refs/meta/comments/*` refs from. + #[arg(default_value = "origin")] + remote: String, + /// Revision to project the comment's anchor onto. + #[arg(long, default_value = "HEAD")] + rev: String, + }, + /// Remove a comment, deleting its ref on a remote. + Remove { + /// The comment's id (or a unique prefix of it). + id: String, + /// Remote whose comment ref to delete. + #[arg(default_value = "origin")] + remote: String, + }, +} + fn main() -> ExitCode { let cli = Cli::parse(); let result = match cli.command { Top::Members { action } => run_members(action), Top::Account { action } => run_account(action), Top::Checks { action } => run_checks(action), + Top::Comment { action } => run_comment(action), Top::Login { remote, key } => login(&remote, key.as_deref()), }; match result { @@ -278,6 +342,200 @@ } } +fn run_comment(action: CommentAction) -> Result<(), String> { + match action { + CommentAction::Add { + path, + remote, + body, + lines, + rev, + issue, + } => comment_add(path, body, lines.as_deref(), &rev, issue, &remote), + CommentAction::List { remote, rev } => comment_list(&remote, &rev), + CommentAction::Show { id, remote, rev } => comment_show(&id, &remote, &rev), + CommentAction::Remove { id, remote } => comment_remove(&id, &remote), + } +} + +/// Anchor a comment to `path` (and optionally `lines`) as it exists at `rev`, +/// record it at `refs/meta/comments/<id>` authored as the configured git +/// identity, and push it. Prompts for the path and body left `None` when run +/// at an interactive terminal. +fn comment_add( + path: Option<String>, + body: Option<String>, + lines: Option<&str>, + rev: &str, + issue: Option<String>, + remote: &str, +) -> Result<(), String> { + let path = interactive::text_or(path, "File path")?; + let body = interactive::text_or(body, "Comment")?; + let lines = parse_lines(lines)?; + let repo = repo()?; + let anchor = + git_anchor::capture(&repo, rev, &path, lines).map_err(|error| error.to_string())?; + let comment = Comment { + body, + anchor, + issue, + }; + let id = git_comment::new_id(None, &comment).map_err(|error| error.to_string())?; + let refname = format!("{COMMENTS_NS}/{id}"); + let expected = sync(remote, &refname)?; + let name = config_get("user.name").ok_or("user.name is unset")?; + let email = config_get("user.email").ok_or("user.email is unset")?; + git_comment::store(&repo, &id, &comment, (&name, &email)).map_err(|error| error.to_string())?; + push_signed(remote, &refname, expected.as_deref())?; + println!("recorded comment {id}"); + Ok(()) +} + +/// List every comment on `remote` as `<id> <author> <location> <body>`, +/// with each anchor projected onto `rev`. +fn comment_list(remote: &str, rev: &str) -> Result<(), String> { + let repo = repo()?; + sync_namespace(remote, COMMENTS_NS)?; + let comments = git_comment::list(&repo).map_err(|error| error.to_string())?; + if comments.is_empty() { + println!("no comments on {remote}"); + return Ok(()); + } + for (id, comment) in comments { + let author = git_comment::provenance(&repo, &id) + .map_err(|error| error.to_string())? + .map_or_else(|| "?".to_owned(), |provenance| provenance.created.name); + let place = describe_projection(&repo, &comment, rev); + let title = comment.body.lines().next().unwrap_or_default(); + println!("{} {author} {place} {title}", short_id(&id)); + } + Ok(()) +} + +/// Show the comment `id` (or a unique prefix): who wrote and last edited it, +/// where it was anchored, where that sits on `rev`, the anchored text, and +/// the body. +fn comment_show(id: &str, remote: &str, rev: &str) -> Result<(), String> { + let repo = repo()?; + sync_namespace(remote, COMMENTS_NS)?; + let id = resolve_comment_id(&repo, id, remote)?; + let comment = git_comment::load(&repo, &id) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("no comment {id} on {remote}"))?; + println!("comment {id}"); + if let Some(provenance) = + git_comment::provenance(&repo, &id).map_err(|error| error.to_string())? + { + println!( + "author {} <{}>", + provenance.created.name, provenance.created.email + ); + if provenance.updated != provenance.created { + println!( + "edited {} <{}>", + provenance.updated.name, provenance.updated.email + ); + } + } + println!( + "anchor {} @ {}", + location(&comment.anchor.path, comment.anchor.lines), + short_id(&comment.anchor.commit) + ); + println!("on {rev}: {}", describe_projection(&repo, &comment, rev)); + if let Some(issue) = &comment.issue { + println!("issue {issue}"); + } + if comment.anchor.lines.is_some() + && let Ok(snippet) = git_anchor::snippet(&repo, &comment.anchor) + { + println!(); + for line in snippet.lines() { + println!(" | {line}"); + } + } + println!(); + for line in comment.body.lines() { + println!(" {line}"); + } + Ok(()) +} + +/// Remove the comment `id` (or a unique prefix) on `remote`, deleting its ref +/// and pushing the deletion. +fn comment_remove(id: &str, remote: &str) -> Result<(), String> { + let repo = repo()?; + sync_namespace(remote, COMMENTS_NS)?; + let id = resolve_comment_id(&repo, id, remote)?; + let refname = format!("{COMMENTS_NS}/{id}"); + let expected = sync(remote, &refname)?.ok_or_else(|| format!("no comment {id} on {remote}"))?; + push_delete(remote, &refname, &expected)?; + println!("removed comment {}", short_id(&id)); + Ok(()) +} + +/// Parse `--lines` as `<start>[:<end>]`, 1-based inclusive; a bare `<start>` +/// anchors that single line. +fn parse_lines(lines: Option<&str>) -> Result<Option<LineRange>, String> { + let Some(lines) = lines else { + return Ok(None); + }; + let (start, end) = lines.split_once(':').unwrap_or((lines, lines)); + let parse = |number: &str| { + number + .trim() + .parse::<u64>() + .map_err(|_error| format!("invalid line number {number:?} in --lines")) + }; + Ok(Some(LineRange { + start: parse(start)?, + end: parse(end)?, + })) +} + +/// Resolve `id` — a full comment genesis hash or a unique prefix of one — +/// against the synced local comment refs. +fn resolve_comment_id(repo: &Path, id: &str, remote: &str) -> Result<String, String> { + let all = git_comment::list(repo).map_err(|error| error.to_string())?; + let mut matches = all + .into_iter() + .map(|(full, _comment)| full) + .filter(|full| full.starts_with(id)); + let Some(first) = matches.next() else { + return Err(format!("no comment {id} on {remote}")); + }; + if matches.next().is_some() { + return Err(format!("comment id {id} is ambiguous on {remote}")); + } + Ok(first) +} + +/// `path:lines` as the CLI prints an anchored location. +fn location(path: &str, lines: Option<LineRange>) -> String { + match lines { + Some(range) if range.start == range.end => format!("{path}:{}", range.start), + Some(range) => format!("{path}:{}-{}", range.start, range.end), + None => path.to_owned(), + } +} + +/// One-line description of where `comment` sits on `rev`. +fn describe_projection(repo: &Path, comment: &Comment, rev: &str) -> String { + match git_comment::project(repo, comment, rev) { + Ok(Projection::Current) => location(&comment.anchor.path, comment.anchor.lines), + Ok(Projection::Relocated { path, lines }) => location(&path, lines), + Ok(Projection::Outdated { path }) => format!("{path} [outdated]"), + Ok(Projection::FileDeleted) => format!("{} [deleted]", comment.anchor.path), + Err(_error) => format!("{} [unresolved]", comment.anchor.path), + } +} + +/// The first 12 characters of a hex id, as listings abbreviate it. +fn short_id(id: &str) -> &str { + id.get(..12).unwrap_or(id) +} + /// A `refs/meta/*` set the porcelain manages uniformly: a named ref synced from /// and pushed to a remote, holding entries the CLI lists and removes from. The /// check set runs through this; the member set is decomposed across