git-ents.gitmain
⌘K
foforge
comment.rs226 lines · 8.5 KB · rusthistorycomment 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
14mod common;
15
16use std::path::Path;
17use std::process::Command;
18
19use ents_forge::comment::{ListFilter, NewComment};
20use git_ents::commands::comment;
21use 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.
26fn 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
53fn 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]
70fn 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]
96fn 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]
140fn 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]
169fn 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]
196fn 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}