crates/cli/git-ents/tests/comment.rs
comment.rshistorycomment on this file
| 1 | //! Integration coverage for `git ents comment` against a real local |
| 2 | //! composition root (`roots.local`) — the phase-9 comment loop: a comment |
| 3 | //! anchored against a dirty working tree (`anchor.working-tree`) is listed |
| 4 | //! open by the machine-readable `comment list --worktree` form |
| 5 | //! (`lens.parity`), resolved through the CLI (`model.comment-state`), and |
| 6 | //! gone from the open listing afterwards. |
| 7 | |
| 8 | #![allow( |
| 9 | clippy::expect_used, |
| 10 | clippy::indexing_slicing, |
| 11 | reason = "integration test" |
| 12 | )] |
| 13 | |
| 14 | mod common; |
| 15 | |
| 16 | use std::path::Path; |
| 17 | use std::process::Command; |
| 18 | |
| 19 | use ents_forge::comment::{ListFilter, NewComment}; |
| 20 | use git_ents::commands::comment; |
| 21 | use git_ents::root::LocalRoot; |
| 22 | |
| 23 | /// Seed `dir`'s working tree with `path` and commit it under a fixed test |
| 24 | /// identity — the content a comment anchors to, distinct from the signed |
| 25 | /// `refs/meta/*` mutation commits `common::Fixture`'s key produces. |
| 26 | fn commit_file(dir: &Path, path: &str, contents: &str) { |
| 27 | std::fs::write(dir.join(path), contents).expect("write"); |
| 28 | let status = Command::new("git") |
| 29 | .arg("-C") |
| 30 | .arg(dir) |
| 31 | .args(["add", "-A"]) |
| 32 | .status() |
| 33 | .expect("git add"); |
| 34 | assert!(status.success()); |
| 35 | let status = Command::new("git") |
| 36 | .arg("-C") |
| 37 | .arg(dir) |
| 38 | .args([ |
| 39 | "-c", |
| 40 | "user.name=test", |
| 41 | "-c", |
| 42 | "user.email=test@example.com", |
| 43 | "commit", |
| 44 | "-q", |
| 45 | "-m", |
| 46 | "seed", |
| 47 | ]) |
| 48 | .status() |
| 49 | .expect("git commit"); |
| 50 | assert!(status.success()); |
| 51 | } |
| 52 | |
| 53 | fn draft(body: &str) -> NewComment { |
| 54 | NewComment { |
| 55 | body: body.to_owned(), |
| 56 | path: Some("file.txt".to_owned()), |
| 57 | lines: None, |
| 58 | rev: "HEAD".to_owned(), |
| 59 | worktree: false, |
| 60 | context: None, |
| 61 | parent: None, |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// `git ents comment list` surfaces every recorded comment's id and body — |
| 66 | /// the only way to discover a comment's id before `show` can be run |
| 67 | /// against it (`model.comment`). |
| 68 | // @relation(roots.local, model.comment, scope=function, role=Verifies) |
| 69 | #[test] |
| 70 | fn list_returns_every_recorded_comment() { |
| 71 | let fixture = common::Fixture::new(1); |
| 72 | commit_file(fixture.path(), "file.txt", "line one\nline two\n"); |
| 73 | let root = LocalRoot::open(fixture.path()).expect("opens"); |
| 74 | |
| 75 | let id = comment::add( |
| 76 | &root, |
| 77 | draft("looks off by one"), |
| 78 | Some(fixture.key_path.clone()), |
| 79 | ) |
| 80 | .expect("adds"); |
| 81 | |
| 82 | let listed = comment::list(&root).expect("lists"); |
| 83 | assert_eq!(listed.len(), 1); |
| 84 | assert_eq!(listed[0].0, id); |
| 85 | assert_eq!(listed[0].1.body, "looks off by one"); |
| 86 | assert_eq!(listed[0].1.state, "open"); |
| 87 | } |
| 88 | |
| 89 | /// The phase-9 comment loop, CLI end: a comment anchored to a *dirty* |
| 90 | /// working tree is listed open by the machine-readable form with its |
| 91 | /// worktree projection, resolved, and gone from the open listing — an |
| 92 | /// agent needs nothing but this surface (`lens.parity`, |
| 93 | /// `anchor.working-tree`, `model.comment-state`). |
| 94 | // @relation(lens.parity, anchor.working-tree, model.comment-state, roots.local, scope=function, role=Verifies) |
| 95 | #[test] |
| 96 | fn the_comment_loop_runs_through_the_machine_readable_listing() { |
| 97 | let fixture = common::Fixture::new(1); |
| 98 | let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect(); |
| 99 | commit_file(fixture.path(), "file.txt", &contents); |
| 100 | // Dirty the working tree: the comment anchors to bytes HEAD never saw. |
| 101 | let dirty = contents.replace("line 5\n", "line five\n"); |
| 102 | std::fs::write(fixture.path().join("file.txt"), &dirty).expect("write"); |
| 103 | |
| 104 | let root = LocalRoot::open(fixture.path()).expect("opens"); |
| 105 | let mut new = draft("this new line looks wrong\n\nsecond paragraph"); |
| 106 | new.worktree = true; |
| 107 | new.lines = Some("5".to_owned()); |
| 108 | new.context = Some("issues/42".to_owned()); |
| 109 | let id = comment::add(&root, new, Some(fixture.key_path.clone())).expect("adds"); |
| 110 | |
| 111 | // Open, current against the working tree, machine-readable. |
| 112 | let open = ListFilter { |
| 113 | state: Some("open".to_owned()), |
| 114 | context: None, |
| 115 | }; |
| 116 | let (rows, _unreadable) = comment::list_projected(&root, true, &open).expect("lists"); |
| 117 | let rendered = comment::porcelain(&rows); |
| 118 | let expected = format!( |
| 119 | "{id} open current file.txt:5-5\ncontext issues/42\n\tthis new line looks wrong\n\t\n\tsecond paragraph\n" |
| 120 | ); |
| 121 | assert_eq!(rendered, expected); |
| 122 | |
| 123 | // Resolve through the CLI surface; the open listing no longer shows |
| 124 | // it, the unfiltered one shows it resolved. |
| 125 | comment::set_state(&root, &id, true, Some(fixture.key_path.clone())).expect("resolves"); |
| 126 | let (rows, _unreadable) = comment::list_projected(&root, true, &open).expect("lists"); |
| 127 | assert!(rows.is_empty(), "a resolved comment is not open"); |
| 128 | let (all, _unreadable) = |
| 129 | comment::list_projected(&root, true, &ListFilter::default()).expect("lists"); |
| 130 | assert_eq!(all.len(), 1); |
| 131 | assert_eq!(all[0].comment.state, "resolved"); |
| 132 | } |
| 133 | |
| 134 | /// `git ents comment add` with no `--body`, run as the real binary with a |
| 135 | /// fake `$EDITOR`: the body composes from the scratch file, `#` lines |
| 136 | /// stripped — the editor fallback the `ents::compose` attribute on |
| 137 | /// `CommentAction::Add` declares. |
| 138 | // @relation(model.comment, roots.local, scope=function, role=Verifies) |
| 139 | #[test] |
| 140 | fn comment_add_composes_body_from_a_fake_editor() { |
| 141 | let fixture = common::Fixture::new(2); |
| 142 | commit_file(fixture.path(), "file.txt", "line one\nline two\n"); |
| 143 | let editor_path = fixture.path().join("fake-editor.sh"); |
| 144 | common::write_fake_editor( |
| 145 | &editor_path, |
| 146 | "composed comment body\n# a stray comment line", |
| 147 | ); |
| 148 | |
| 149 | let output = Command::new(common::bin_path()) |
| 150 | .current_dir(fixture.path()) |
| 151 | .args(["comment", "add", "file.txt", "--key"]) |
| 152 | .arg(&fixture.key_path) |
| 153 | .env("GIT_EDITOR", &editor_path) |
| 154 | .env("EDITOR", &editor_path) |
| 155 | .output() |
| 156 | .expect("runs"); |
| 157 | assert!(output.status.success(), "{output:?}"); |
| 158 | |
| 159 | let root = LocalRoot::open(fixture.path()).expect("opens"); |
| 160 | let listed = comment::list(&root).expect("lists"); |
| 161 | assert_eq!(listed.len(), 1); |
| 162 | assert_eq!(listed[0].1.body, "composed comment body"); |
| 163 | } |
| 164 | |
| 165 | /// An empty composed comment body aborts with a failing exit status, |
| 166 | /// mirroring `git commit`'s own empty-message abort. |
| 167 | // @relation(model.comment, roots.local, scope=function, role=Verifies) |
| 168 | #[test] |
| 169 | fn comment_add_aborts_on_an_empty_editor_body() { |
| 170 | let fixture = common::Fixture::new(3); |
| 171 | commit_file(fixture.path(), "file.txt", "line one\n"); |
| 172 | let editor_path = fixture.path().join("fake-editor.sh"); |
| 173 | common::write_fake_editor(&editor_path, "# only a comment, no body"); |
| 174 | |
| 175 | let output = Command::new(common::bin_path()) |
| 176 | .current_dir(fixture.path()) |
| 177 | .args(["comment", "add", "file.txt", "--key"]) |
| 178 | .arg(&fixture.key_path) |
| 179 | .env("GIT_EDITOR", &editor_path) |
| 180 | .env("EDITOR", &editor_path) |
| 181 | .output() |
| 182 | .expect("runs"); |
| 183 | assert!( |
| 184 | !output.status.success(), |
| 185 | "an empty body must abort comment creation: {output:?}" |
| 186 | ); |
| 187 | let root = LocalRoot::open(fixture.path()).expect("opens"); |
| 188 | assert_eq!(comment::list(&root).expect("lists").len(), 0); |
| 189 | } |
| 190 | |
| 191 | /// Two records separate with exactly one blank line, and an unanchored |
| 192 | /// reply renders `-` for projection and location — the porcelain grammar |
| 193 | /// an agent parses (`lens.porcelain`, `lens.parity`). |
| 194 | // @relation(lens.porcelain, lens.parity, scope=function, role=Verifies) |
| 195 | #[test] |
| 196 | fn porcelain_separates_records_and_renders_unanchored_comments() { |
| 197 | let fixture = common::Fixture::new(1); |
| 198 | commit_file(fixture.path(), "file.txt", "line one\nline two\n"); |
| 199 | let root = LocalRoot::open(fixture.path()).expect("opens"); |
| 200 | |
| 201 | let first = comment::add(&root, draft("root"), Some(fixture.key_path.clone())).expect("adds"); |
| 202 | let second = comment::reply( |
| 203 | &root, |
| 204 | &first, |
| 205 | "reply".to_owned(), |
| 206 | Some(fixture.key_path.clone()), |
| 207 | ) |
| 208 | .expect("replies"); |
| 209 | |
| 210 | let (rows, _unreadable) = |
| 211 | comment::list_projected(&root, false, &ListFilter::default()).expect("lists"); |
| 212 | let rendered = comment::porcelain(&rows); |
| 213 | let records: Vec<&str> = rendered.split("\n\n").collect(); |
| 214 | assert_eq!(records.len(), 2); |
| 215 | let root_record = records |
| 216 | .iter() |
| 217 | .find(|r| r.starts_with(&first)) |
| 218 | .expect("root listed"); |
| 219 | assert!(root_record.contains(&format!("{first} open current file.txt\n"))); |
| 220 | let reply_record = records |
| 221 | .iter() |
| 222 | .find(|r| r.starts_with(&second)) |
| 223 | .expect("reply listed"); |
| 224 | assert!(reply_record.contains(&format!("{second} open - -\n"))); |
| 225 | assert!(reply_record.contains(&format!("parent {first}\n"))); |
| 226 | } |