a73x

8485214a

Let prose in through more than one door, and let it be corrected

a73x   2026-08-10 17:02

Commit message
Let prose in through more than one door, and let it be corrected

Bodies could only arrive as a shell argument and could never be changed
afterwards. Three issues, one root cause.

Input (6edf0fa2). Every command taking --body now also takes
--body-file <path>, with '-' for stdin, under git's own -F. With no body
at all and a terminal, $EDITOR opens; without a terminal it is a clear
error rather than a hang, which is the failure mode that matters for
scripts and agents. Nothing is stripped or trimmed on the way in: a body
is markdown, so a leading '#' is a heading and a trailing blank line is
prose. Byte-identity through the DAG is tested directly.

Correction (e804b3e4, 7a299d2c). A new BodyEdit event supersedes an
earlier event's body, the same shape IssueEdit has always had, and
CommentDelete tombstones a comment. One mechanism covers thread
comments, inline comments, review bodies and revision descriptions,
since to it they are all a body hanging off an event OID. The DAG stays
append-only and the superseded event stays in it.

Deletion leaves a tombstone. The event is still in the DAG either way,
so showing nothing would misrepresent a record anyone can read; a
comment may have replies that reference it; and a tombstone makes delete
behave exactly like edit rather than being a second mechanism. The text
is dropped from derived state, so it stops reaching --json, search and
the web UI.

Corrections are applied in a second pass keyed on (clock, oid), so they
are independent of walk order and two clones editing offline converge.
An edit whose author is not the target's author is ignored by the fold,
not merely refused at the CLI: anyone with a copy of the DAG can append
to it, so the rule has to hold where every reader derives state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

README.md
Old New
@@ -130,12 +130,49 @@ $ git-collab patch review a1b2c3d4 --verdict request-changes -b "see inline"
130 `git-collab dashboard` opens a TUI over the same data if you would rather browse 130 `git-collab dashboard` opens a TUI over the same data if you would rather browse
131 than type. 131 than type.
132 132
133 ## Writing and correcting prose
134
135 Review prose is long, multi-line, and full of things a shell wants to
136 interpret. Every command that takes `--body` also reads one from a file, or
137 from stdin with `-`, using git's own `-F` convention — so a body never has to
138 be written around the shell:
139
140 ```console
141 $ git-collab patch review a1b2c3d4 -v approve -F review.md
142 $ some-tool | git-collab patch comment a1b2c3d4 -F -
143 $ git-collab patch comment a1b2c3d4 # no body: opens $EDITOR
144 ```
145
146 Whatever you supply is stored byte for byte — trailing newlines, non-ASCII and
147 leading `#` included. Nothing is stripped, because a body is markdown, not a
148 commit message.
149
150 Typos are fixable. Editing appends a new event that supersedes the old body;
151 the original stays in the DAG, so the log remains an audit trail:
152
153 ```console
154 $ git-collab patch show a1b2c3d4 # comment IDs are printed in [brackets]
155 $ git-collab patch edit-comment a1b2c3d4 f39bf43b -b "what I meant to say"
156 $ git-collab patch edit-revision a1b2c3d4 3 -F - # fix a revision's description
157 $ git-collab patch delete-comment a1b2c3d4 f39bf43b
158 ```
159
160 Deleting leaves a tombstone rather than a hole: the comment keeps its position,
161 author and timestamp, and only its text is dropped. A review is a conversation,
162 and a comment that silently vanished would leave every reply to it pointing at
163 nothing.
164
165 Only a comment's own author can change it. An edit signed by anyone else is
166 ignored when state is derived, not merely refused at the command line — anyone
167 holding a copy of the DAG can append to it, so the rule has to hold where every
168 reader folds it.
169
133 ## Commands 170 ## Commands
134 171
135 | | | 172 | | |
136 |---|---| 173 |---|---|
137 | `issue` | open, list, show, comment, edit, label, assign, close | 174 | `issue` | open, list, show, comment, edit, edit-comment, delete-comment, label, assign, close |
138 | `patch` | create, list, show, diff, comment, review, revise, log, checkout, merge, close | 175 | `patch` | create, list, show, diff, comment, review, revise, edit-comment, delete-comment, edit-revision, log, checkout, merge, close |
139 | `sync` | fetch, reconcile and push collab refs | 176 | `sync` | fetch, reconcile and push collab refs |
140 | `hooks` | install and inspect the `commit-msg` trailer hook | 177 | `hooks` | install and inspect the `commit-msg` trailer hook |
141 | `status` | project overview | 178 | `status` | project overview |
src/body.rs
Old New
@@ -0,0 +1,205 @@
1 //! Getting prose into git-collab.
2 //!
3 //! A body used to arrive exactly one way: as a shell argument. That is a bad
4 //! door for the kind of text this tool carries. Review prose is long,
5 //! multi-line, and full of things a shell wants to interpret — backticks for
6 //! code spans, `$` in a variable name being discussed, quotes of both kinds,
7 //! and `?` or `*` in ordinary sentences. A reviewer on a hardened shell had a
8 //! comment rejected outright for containing a `?` and had to re-author it
9 //! around the shell. Prose should never have to be written around the tool
10 //! that stores it.
11 //!
12 //! So every command taking `--body` also takes `--body-file <path>`, with `-`
13 //! meaning stdin, under the short flag `-F`. That is git's own convention
14 //! (`git commit -F`), which is the point: users already know it, and one
15 //! convention covering the whole tool is worth more than a bespoke one
16 //! covering the three commands an issue happened to name.
17 //!
18 //! Nothing here trims, strips, or normalizes. What the caller supplies is
19 //! what gets recorded, byte for byte, including trailing newlines, CRLF and
20 //! non-ASCII. Bodies are markdown and go into an append-only DAG: a tidy-up
21 //! applied on the way in is a corruption nobody can correct afterwards, and
22 //! one that would only surface in someone's review three weeks later.
23
24 use std::io::{IsTerminal, Read};
25
26 use crate::editor;
27 use crate::error::Error;
28
29 /// How a command was asked to find its body.
30 pub struct BodyArgs<'a> {
31 pub body: Option<&'a str>,
32 pub body_file: Option<&'a str>,
33 }
34
35 impl<'a> BodyArgs<'a> {
36 pub fn new(body: Option<&'a str>, body_file: Option<&'a str>) -> Self {
37 BodyArgs { body, body_file }
38 }
39
40 /// Read whichever of the two was supplied, rejecting both at once.
41 fn read_supplied(&self) -> Result<Option<String>, Error> {
42 match (self.body, self.body_file) {
43 (Some(_), Some(_)) => Err(Error::Cmd(
44 "--body and --body-file are mutually exclusive; pass one or the other".to_string(),
45 )),
46 (Some(text), None) => Ok(Some(text.to_string())),
47 (None, Some(path)) => read_file_or_stdin(path).map(Some),
48 (None, None) => Ok(None),
49 }
50 }
51 }
52
53 /// Read a body from a path, or from stdin when the path is `-`.
54 fn read_file_or_stdin(path: &str) -> Result<String, Error> {
55 let bytes = if path == "-" {
56 let mut buf = Vec::new();
57 std::io::stdin()
58 .read_to_end(&mut buf)
59 .map_err(|e| Error::Cmd(format!("failed to read the body from stdin: {}", e)))?;
60 buf
61 } else {
62 std::fs::read(path)
63 .map_err(|e| Error::Cmd(format!("failed to read the body from {}: {}", path, e)))?
64 };
65
66 String::from_utf8(bytes).map_err(|_| {
67 Error::Cmd(format!(
68 "the body read from {} is not valid UTF-8",
69 if path == "-" { "stdin" } else { path }
70 ))
71 })
72 }
73
74 /// The error a non-interactive caller gets when it supplies no body at all.
75 ///
76 /// Naming the alternatives matters more here than anywhere else in the tool:
77 /// the callers that hit this are scripts and agents, which have no terminal
78 /// to open an editor on and no human to read a terse refusal.
79 fn no_body_error(what: &str) -> Error {
80 Error::Cmd(format!(
81 "no {what} given: pass --body <text>, --body-file <path>, or --body-file - to read stdin.\n\
82 (An editor is opened instead only when stdin is a terminal and $EDITOR or $VISUAL is set.)"
83 ))
84 }
85
86 /// Reject a body that is entirely blank.
87 ///
88 /// An empty body is almost always an accident — an editor closed without
89 /// saving, an empty file passed by a script — and an accident recorded in an
90 /// append-only DAG is permanent. Only whitespace-only bodies are refused;
91 /// anything with content in it is passed through untouched, trailing blank
92 /// lines and all.
93 fn reject_if_blank(body: String, what: &str) -> Result<String, Error> {
94 if body.trim().is_empty() {
95 return Err(Error::Cmd(format!("empty {what}; aborting")));
96 }
97 Ok(body)
98 }
99
100 /// Whether an editor can be opened right now: there has to be one configured,
101 /// and stdin has to be a terminal.
102 ///
103 /// The terminal check is what keeps this safe for scripts and agents. An
104 /// editor launched with no terminal does not fail — it blocks, forever, on a
105 /// command the caller expected to return. A clear error beats a hang.
106 fn can_open_editor() -> bool {
107 editor::resolve_editor().is_some() && std::io::stdin().is_terminal()
108 }
109
110 /// Resolve a body that the command requires, opening an editor seeded with
111 /// `initial` when none was supplied and a terminal is available.
112 ///
113 /// `what` names the thing being written ("comment", "review body") so the
114 /// error reads as a sentence.
115 pub fn resolve_required(args: &BodyArgs, initial: &str, what: &str) -> Result<String, Error> {
116 if let Some(body) = args.read_supplied()? {
117 return reject_if_blank(body, what);
118 }
119 if !can_open_editor() {
120 return Err(no_body_error(what));
121 }
122 reject_if_blank(editor::compose(initial)?, what)
123 }
124
125 /// Resolve a body that the command treats as optional.
126 ///
127 /// No body means "leave it as it is" (`issue edit`) or "there isn't one"
128 /// (`issue open`), which are both meaningful answers — so unlike
129 /// [`resolve_required`], omitting it never opens an editor. Doing otherwise
130 /// would turn every existing non-interactive `issue open -t ...` into a
131 /// command that blocks on an editor.
132 pub fn resolve_optional(args: &BodyArgs) -> Result<Option<String>, Error> {
133 args.read_supplied()
134 }
135
136 #[cfg(test)]
137 mod tests {
138 use super::*;
139
140 #[test]
141 fn body_flag_is_passed_through_verbatim() {
142 let args = BodyArgs::new(Some(" spaced\n\n"), None);
143 assert_eq!(
144 resolve_required(&args, "", "comment").unwrap(),
145 " spaced\n\n"
146 );
147 }
148
149 #[test]
150 fn both_options_together_are_rejected() {
151 let args = BodyArgs::new(Some("a"), Some("b"));
152 let err = format!("{}", resolve_required(&args, "", "comment").unwrap_err());
153 assert!(err.contains("--body") && err.contains("--body-file"), "{}", err);
154 }
155
156 #[test]
157 fn a_file_is_read_byte_for_byte() {
158 let mut file = tempfile::NamedTempFile::new().unwrap();
159 let content = "trailing newlines\n\n\nnaïve 🎉\r\n";
160 std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
161 let path = file.path().to_str().unwrap().to_string();
162
163 let args = BodyArgs::new(None, Some(&path));
164 assert_eq!(resolve_required(&args, "", "comment").unwrap(), content);
165 }
166
167 #[test]
168 fn a_missing_file_names_the_path() {
169 let args = BodyArgs::new(None, Some("/nonexistent/git-collab-body.txt"));
170 let err = format!("{}", resolve_required(&args, "", "comment").unwrap_err());
171 assert!(err.contains("/nonexistent/git-collab-body.txt"), "{}", err);
172 }
173
174 #[test]
175 fn a_blank_body_is_refused() {
176 let args = BodyArgs::new(Some(" \n\t\n"), None);
177 let err = format!("{}", resolve_required(&args, "", "comment").unwrap_err());
178 assert!(err.contains("empty comment"), "{}", err);
179 }
180
181 #[test]
182 fn invalid_utf8_is_refused_rather_than_mangled() {
183 let mut file = tempfile::NamedTempFile::new().unwrap();
184 std::io::Write::write_all(&mut file, &[0xff, 0xfe, 0x00]).unwrap();
185 let path = file.path().to_str().unwrap().to_string();
186
187 let args = BodyArgs::new(None, Some(&path));
188 let err = format!("{}", resolve_required(&args, "", "comment").unwrap_err());
189 assert!(err.contains("not valid UTF-8"), "{}", err);
190 }
191
192 #[test]
193 fn an_optional_body_stays_absent_when_nothing_is_given() {
194 let args = BodyArgs::new(None, None);
195 assert_eq!(resolve_optional(&args).unwrap(), None);
196 }
197
198 #[test]
199 fn an_optional_body_may_be_blank() {
200 // `issue open -b ""` has always meant "no description", and still does.
201 let args = BodyArgs::new(Some(""), None);
202 assert_eq!(resolve_optional(&args).unwrap(), Some(String::new()));
203 }
204
205 }
src/cache.rs
Old New
@@ -32,7 +32,15 @@ fn sanitize_ref_name(ref_name: &str) -> String {
32 /// indistinguishably from a patch that really has no recorded merge, so a 32 /// indistinguishably from a patch that really has no recorded merge, so a
33 /// merged patch cached before this field existed would read as merged with no 33 /// merged patch cached before this field existed would read as merged with no
34 /// commit forever. Only the version can force the refold. 34 /// commit forever. Only the version can force the refold.
35 const CACHE_FORMAT_VERSION: u32 = 6; 35 /// v7: bodies became correctable. Comments, reviews and revisions gained the
36 /// event OID that identifies them, plus `edited`/`deleted`, and the fold grew
37 /// a second pass that applies `BodyEdit`/`CommentDelete`. Every one of those
38 /// fields is `#[serde(default)]`, so a v6 entry deserializes perfectly — and
39 /// wrongly: a comment deleted since would be served with its text intact and
40 /// `deleted: false`, which is the deleted text leaking back out through the
41 /// cache. That is the whole point of versioning this separately from the
42 /// shape, so bump rather than trust the defaults.
43 const CACHE_FORMAT_VERSION: u32 = 7;
36 44
37 /// Cache entry stored on disk: the tip OID at cache time + serialized state. 45 /// Cache entry stored on disk: the tip OID at cache time + serialized state.
38 #[derive(serde::Serialize, serde::Deserialize)] 46 #[derive(serde::Serialize, serde::Deserialize)]
src/cli.rs
Old New
@@ -295,6 +295,15 @@ pub enum HookCmd {
295 }, 295 },
296 } 296 }
297 297
298 /// Doc text for `--body-file`, shared by every command that takes one so the
299 /// convention is stated identically wherever it appears.
300 ///
301 /// `-F` rather than `-f` throughout: `-f` is already `--file` on
302 /// `patch comment`, where it names the source file an inline comment is
303 /// attached to. `-F` is also what `git commit` uses, which is the whole
304 /// argument for the flag.
305 const BODY_FILE_HELP: &str = "Read the body from a file, or from stdin with '-'";
306
298 #[derive(Subcommand, Debug)] 307 #[derive(Subcommand, Debug)]
299 pub enum IssueCmd { 308 pub enum IssueCmd {
300 /// Open a new issue 309 /// Open a new issue
@@ -306,8 +315,10 @@ pub enum IssueCmd {
306 #[arg(short, long)] 315 #[arg(short, long)]
307 title: String, 316 title: String,
308 /// Issue body 317 /// Issue body
309 #[arg(short, long, default_value = "")] 318 #[arg(short, long)]
310 body: String, 319 body: Option<String>,
320 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
321 body_file: Option<String>,
311 /// Related issue ID 322 /// Related issue ID
312 #[arg(long)] 323 #[arg(long)]
313 relates_to: Option<String>, 324 relates_to: Option<String>,
@@ -347,12 +358,16 @@ pub enum IssueCmd {
347 json: bool, 358 json: bool,
348 }, 359 },
349 /// Comment on an issue 360 /// Comment on an issue
361 ///
362 /// With no --body and no --body-file, opens $EDITOR.
350 Comment { 363 Comment {
351 /// Issue ID (prefix match) 364 /// Issue ID (prefix match)
352 id: String, 365 id: String,
353 /// Comment body 366 /// Comment body
354 #[arg(short, long)] 367 #[arg(short, long)]
355 body: String, 368 body: Option<String>,
369 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
370 body_file: Option<String>,
356 }, 371 },
357 /// Edit an issue's title or body 372 /// Edit an issue's title or body
358 Edit { 373 Edit {
@@ -364,6 +379,34 @@ pub enum IssueCmd {
364 /// New body 379 /// New body
365 #[arg(short, long)] 380 #[arg(short, long)]
366 body: Option<String>, 381 body: Option<String>,
382 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
383 body_file: Option<String>,
384 },
385 /// Correct the text of a comment you wrote
386 ///
387 /// Records a new event superseding the old body; the original stays in
388 /// the DAG. Only the comment's own author can edit it. With no --body and
389 /// no --body-file, opens $EDITOR seeded with the current text.
390 EditComment {
391 /// Issue ID (prefix match)
392 id: String,
393 /// Comment ID (prefix match), as printed by `issue show`
394 comment: String,
395 /// New body
396 #[arg(short, long)]
397 body: Option<String>,
398 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
399 body_file: Option<String>,
400 },
401 /// Delete a comment you wrote, leaving a tombstone in its place
402 ///
403 /// The comment keeps its position, author and timestamp so replies to it
404 /// still make sense; only its text is dropped.
405 DeleteComment {
406 /// Issue ID (prefix match)
407 id: String,
408 /// Comment ID (prefix match), as printed by `issue show`
409 comment: String,
367 }, 410 },
368 /// Add a label to an issue 411 /// Add a label to an issue
369 Label { 412 Label {
@@ -488,8 +531,10 @@ pub enum PatchCmd {
488 #[arg(short, long)] 531 #[arg(short, long)]
489 title: String, 532 title: String,
490 /// Patch description 533 /// Patch description
491 #[arg(short, long, default_value = "")] 534 #[arg(short, long)]
492 body: String, 535 body: Option<String>,
536 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
537 body_file: Option<String>,
493 /// Base branch ref 538 /// Base branch ref
494 #[arg(long, default_value = "main")] 539 #[arg(long, default_value = "main")]
495 base: String, 540 base: String,
@@ -549,12 +594,16 @@ pub enum PatchCmd {
549 between: Option<Vec<u32>>, 594 between: Option<Vec<u32>>,
550 }, 595 },
551 /// Comment on a patch (use --file and --line for inline comments) 596 /// Comment on a patch (use --file and --line for inline comments)
597 ///
598 /// With no --body and no --body-file, opens $EDITOR.
552 Comment { 599 Comment {
553 /// Patch ID (prefix match) 600 /// Patch ID (prefix match)
554 id: String, 601 id: String,
555 /// Comment body 602 /// Comment body
556 #[arg(short, long)] 603 #[arg(short, long)]
557 body: String, 604 body: Option<String>,
605 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
606 body_file: Option<String>,
558 /// File path for inline comment 607 /// File path for inline comment
559 #[arg(short, long)] 608 #[arg(short, long)]
560 file: Option<String>, 609 file: Option<String>,
@@ -566,6 +615,8 @@ pub enum PatchCmd {
566 revision: Option<u32>, 615 revision: Option<u32>,
567 }, 616 },
568 /// Review a patch 617 /// Review a patch
618 ///
619 /// With no --body and no --body-file, opens $EDITOR.
569 Review { 620 Review {
570 /// Patch ID (prefix match) 621 /// Patch ID (prefix match)
571 id: String, 622 id: String,
@@ -574,7 +625,9 @@ pub enum PatchCmd {
574 verdict: String, 625 verdict: String,
575 /// Review body 626 /// Review body
576 #[arg(short, long)] 627 #[arg(short, long)]
577 body: String, 628 body: Option<String>,
629 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
630 body_file: Option<String>,
578 /// Target revision for review 631 /// Target revision for review
579 #[arg(long)] 632 #[arg(long)]
580 revision: Option<u32>, 633 revision: Option<u32>,
@@ -587,10 +640,58 @@ pub enum PatchCmd {
587 /// Revision description 640 /// Revision description
588 #[arg(short, long)] 641 #[arg(short, long)]
589 body: Option<String>, 642 body: Option<String>,
643 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
644 body_file: Option<String>,
590 /// Source branch to snapshot (defaults to HEAD) 645 /// Source branch to snapshot (defaults to HEAD)
591 #[arg(short = 'B', long)] 646 #[arg(short = 'B', long)]
592 branch: Option<String>, 647 branch: Option<String>,
593 }, 648 },
649 /// Correct the text of a comment or review you wrote
650 ///
651 /// Takes the ID of a thread comment, an inline comment or a review, as
652 /// printed by `patch show`. Records a new event superseding the old body;
653 /// the original stays in the DAG, and a review's verdict is untouched.
654 /// Only the author can edit their own. With no --body and no --body-file,
655 /// opens $EDITOR seeded with the current text.
656 EditComment {
657 /// Patch ID (prefix match)
658 id: String,
659 /// Comment or review ID (prefix match), as printed by `patch show`
660 comment: String,
661 /// New body
662 #[arg(short, long)]
663 body: Option<String>,
664 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
665 body_file: Option<String>,
666 },
667 /// Delete a comment you wrote, leaving a tombstone in its place
668 ///
669 /// The comment keeps its position, author and timestamp so replies to it
670 /// still make sense; only its text is dropped. Reviews cannot be deleted
671 /// — they carry a vote; submit a new review to change it.
672 DeleteComment {
673 /// Patch ID (prefix match)
674 id: String,
675 /// Comment ID (prefix match), as printed by `patch show`
676 comment: String,
677 },
678 /// Correct a revision's description
679 ///
680 /// A revision's commit and tree are immutable and stay so; this changes
681 /// only the descriptive body, for the case where a placeholder or a typo
682 /// was recorded. With no --body and no --body-file, opens $EDITOR seeded
683 /// with the current text.
684 EditRevision {
685 /// Patch ID (prefix match)
686 id: String,
687 /// Revision number, as printed by `patch log`
688 revision: u32,
689 /// New description
690 #[arg(short, long)]
691 body: Option<String>,
692 #[arg(short = 'F', long, help = BODY_FILE_HELP)]
693 body_file: Option<String>,
694 },
594 /// Show revision log for a patch 695 /// Show revision log for a patch
595 Log { 696 Log {
596 /// Patch ID (prefix match) 697 /// Patch ID (prefix match)
@@ -660,6 +761,8 @@ impl Commands {
660 | IssueCmd::Comment { .. } 761 | IssueCmd::Comment { .. }
661 | IssueCmd::Close { .. } 762 | IssueCmd::Close { .. }
662 | IssueCmd::Edit { .. } 763 | IssueCmd::Edit { .. }
764 | IssueCmd::EditComment { .. }
765 | IssueCmd::DeleteComment { .. }
663 | IssueCmd::Label { .. } 766 | IssueCmd::Label { .. }
664 | IssueCmd::Unlabel { .. } 767 | IssueCmd::Unlabel { .. }
665 | IssueCmd::Relate { .. } 768 | IssueCmd::Relate { .. }
@@ -674,6 +777,9 @@ impl Commands {
674 | PatchCmd::Comment { .. } 777 | PatchCmd::Comment { .. }
675 | PatchCmd::Review { .. } 778 | PatchCmd::Review { .. }
676 | PatchCmd::Revise { .. } 779 | PatchCmd::Revise { .. }
780 | PatchCmd::EditComment { .. }
781 | PatchCmd::DeleteComment { .. }
782 | PatchCmd::EditRevision { .. }
677 | PatchCmd::Label { .. } 783 | PatchCmd::Label { .. }
678 | PatchCmd::Unlabel { .. } 784 | PatchCmd::Unlabel { .. }
679 | PatchCmd::Close { .. } 785 | PatchCmd::Close { .. }
src/dag.rs
Old New
@@ -338,6 +338,8 @@ fn commit_message(action: &Action) -> String {
338 } 338 }
339 Action::PatchClose { .. } => "patch: close".to_string(), 339 Action::PatchClose { .. } => "patch: close".to_string(),
340 Action::PatchMerge { .. } => "patch: merge".to_string(), 340 Action::PatchMerge { .. } => "patch: merge".to_string(),
341 Action::BodyEdit { target, .. } => format!("collab: edit body of {:.8}", target),
342 Action::CommentDelete { target } => format!("collab: delete comment {:.8}", target),
341 Action::Merge => "collab: merge".to_string(), 343 Action::Merge => "collab: merge".to_string(),
342 } 344 }
343 } 345 }
src/editor.rs
Old New
@@ -1,7 +1,63 @@
1 use std::io::Write;
1 use std::process::Command; 2 use std::process::Command;
2 3
3 use crate::error::Error; 4 use crate::error::Error;
4 5
6 /// Compose text in the user's editor, seeded with `initial`.
7 ///
8 /// Whatever the editor leaves in the file *is* the body: nothing is stripped,
9 /// trimmed or normalized on the way back. This is deliberately unlike `git
10 /// commit`, which strips `#` lines and trailing whitespace — a review body is
11 /// markdown, where a leading `#` is a heading and a trailing blank line is
12 /// part of the prose. Anything that "helpfully" tidied it would silently
13 /// corrupt a body nobody could then correct.
14 pub fn compose(initial: &str) -> Result<String, Error> {
15 let editor_str = resolve_editor()
16 .ok_or_else(|| Error::Cmd("No editor configured. Set $EDITOR or $VISUAL.".to_string()))?;
17 compose_with(initial, &editor_str)
18 }
19
20 /// Inner helper: compose with an explicit editor command, so tests can drive
21 /// it without mutating environment variables.
22 fn compose_with(initial: &str, editor_str: &str) -> Result<String, Error> {
23 let parts: Vec<&str> = editor_str.split_whitespace().collect();
24 let (program, extra_args) = parts
25 .split_first()
26 .ok_or_else(|| Error::Cmd("Editor command is empty".to_string()))?;
27
28 // `.md` so editors pick markdown highlighting and wrapping for what is,
29 // in practice, always markdown.
30 let mut file = tempfile::Builder::new()
31 .prefix("git-collab-")
32 .suffix(".md")
33 .tempfile()
34 .map_err(|e| Error::Cmd(format!("Failed to create a buffer for the editor: {}", e)))?;
35 file.write_all(initial.as_bytes())
36 .and_then(|()| file.flush())
37 .map_err(|e| Error::Cmd(format!("Failed to seed the editor buffer: {}", e)))?;
38
39 let path = file.path().to_path_buf();
40 let status = Command::new(program)
41 .args(extra_args)
42 .arg(&path)
43 .status()
44 .map_err(|e| Error::Cmd(format!("Failed to launch editor '{}': {}", program, e)))?;
45 if !status.success() {
46 return Err(Error::Cmd(format!(
47 "Editor exited with status: {}",
48 status.code().unwrap_or(-1)
49 )));
50 }
51
52 // Read the path rather than the handle: editors routinely save by writing
53 // a new file and renaming it over the old one, which leaves the original
54 // handle pointing at the unedited inode.
55 let bytes = std::fs::read(&path)
56 .map_err(|e| Error::Cmd(format!("Failed to read back the editor buffer: {}", e)))?;
57 String::from_utf8(bytes)
58 .map_err(|_| Error::Cmd("Editor produced text that is not valid UTF-8".to_string()))
59 }
60
5 /// Resolve the user's preferred editor by checking $VISUAL, then $EDITOR. 61 /// Resolve the user's preferred editor by checking $VISUAL, then $EDITOR.
6 /// Returns `None` if neither is set or both are empty. 62 /// Returns `None` if neither is set or both are empty.
7 pub fn resolve_editor() -> Option<String> { 63 pub fn resolve_editor() -> Option<String> {
@@ -246,6 +302,76 @@ mod tests {
246 assert!(result.is_ok(), "Expected Ok, got {:?}", result); 302 assert!(result.is_ok(), "Expected Ok, got {:?}", result);
247 } 303 }
248 304
305 // ---- compose tests ----
306
307 /// The property the whole feature rests on: what the editor saves is what
308 /// gets recorded. Trailing newlines, CRLF, tabs, non-ASCII, a combining
309 /// mark, and a leading `#` all survive untouched.
310 #[test]
311 fn test_compose_returns_the_buffer_byte_for_byte() {
312 let content = "# Heading\n\n\tTabbed\r\nnaïve café — 🎉 e\u{0301}\n\n";
313 let script = fake_editor(&format!(
314 "#!/bin/sh\ncat > \"$1\" <<'GITCOLLABEOF'\n{}GITCOLLABEOF\n",
315 content
316 ));
317 // A heredoc adds the newline the content already ends with, so drop
318 // one to get back exactly what went in.
319 let out = compose_with("", &script).unwrap();
320 assert_eq!(out.trim_end_matches('\n'), content.trim_end_matches('\n'));
321 assert!(out.starts_with("# Heading"), "no stripping of leading #");
322 assert!(out.contains('\t') && out.contains('\r'), "no whitespace tidying");
323 assert!(out.contains('\u{301}'), "non-ASCII preserved");
324 }
325
326 #[test]
327 fn test_compose_seeds_the_buffer_with_the_initial_text() {
328 let script = fake_editor("#!/bin/sh\nprintf '%s!' \"$(cat \"$1\")\" > \"$1\"\n");
329 let out = compose_with("seed", &script).unwrap();
330 assert_eq!(out, "seed!");
331 }
332
333 /// Editors commonly save by writing a temp file and renaming it over the
334 /// original. Reading the path rather than the open handle is what keeps
335 /// that from silently returning the unedited seed.
336 #[test]
337 fn test_compose_survives_an_editor_that_saves_by_rename() {
338 let script = fake_editor(
339 "#!/bin/sh\nprintf 'renamed in\\n' > \"$1.new\"\nmv \"$1.new\" \"$1\"\n",
340 );
341 let out = compose_with("seed", &script).unwrap();
342 assert_eq!(out, "renamed in\n");
343 }
344
345 #[test]
346 fn test_compose_propagates_a_failing_editor() {
347 let result = compose_with("seed", "false");
348 assert!(result.is_err());
349 assert!(format!("{}", result.unwrap_err()).contains("Editor exited with status"));
350 }
351
352 #[test]
353 fn test_compose_rejects_non_utf8() {
354 let script = fake_editor("#!/bin/sh\nprintf '\\377\\376' > \"$1\"\n");
355 let result = compose_with("", &script);
356 assert!(format!("{}", result.unwrap_err()).contains("not valid UTF-8"));
357 }
358
359 /// Write a shell script to a temp dir and return an editor *command* that
360 /// runs it via `sh`, leaking the dir so the script outlives the call.
361 ///
362 /// `sh <path>` rather than making the file executable and running it
363 /// directly: these tests run on many threads, and a `Command::spawn` in
364 /// one thread inherits any write file descriptor another thread happens
365 /// to have open, which makes exec'ing a freshly written script fail with
366 /// ETXTBSY at random. `sh` only ever opens it for reading. Test-only.
367 fn fake_editor(body: &str) -> String {
368 let dir = tempfile::TempDir::new().unwrap();
369 let path = dir.path().join("fake-editor.sh");
370 std::fs::write(&path, body).unwrap();
371 std::mem::forget(dir);
372 format!("sh {}", path.display())
373 }
374
249 #[test] 375 #[test]
250 fn test_open_editor_at_bad_command() { 376 fn test_open_editor_at_bad_command() {
251 let mut tmp = tempfile::NamedTempFile::new().unwrap(); 377 let mut tmp = tempfile::NamedTempFile::new().unwrap();
src/event.rs
Old New
@@ -154,6 +154,46 @@ pub enum Action {
154 #[serde(default, skip_serializing_if = "String::is_empty")] 154 #[serde(default, skip_serializing_if = "String::is_empty")]
155 commit: String, 155 commit: String,
156 }, 156 },
157 /// Supersede the body of an earlier event in the same DAG.
158 ///
159 /// Prose enters this tool once and used to be stuck there. This is the
160 /// correction, and it is a new event rather than a mutation because the
161 /// DAG is append-only: the superseded event stays exactly where it is,
162 /// still signed by whoever wrote it, and the log remains an audit trail
163 /// rather than a summary. It is the same shape as `IssueEdit`, which has
164 /// always superseded an issue's title and body this way.
165 ///
166 /// `target` is the hex OID of the event commit that first carried the
167 /// body. One event covers thread comments, inline comments, review bodies
168 /// and revision descriptions, because to this mechanism they are all just
169 /// a body hanging off an event OID — there is no reason for four.
170 ///
171 /// An edit whose author differs from the target's author is **ignored by
172 /// the fold**, not honoured and not an error: anyone holding a copy of the
173 /// DAG can append anything to it, so the rule that nobody rewrites
174 /// somebody else's words has to hold where every reader derives state,
175 /// not merely at the CLI that politely declines. See
176 /// `state::apply_body_overrides`.
177 #[serde(rename = "body.edit")]
178 BodyEdit { target: String, body: String },
179
180 /// Tombstone a comment: its text is dropped from derived state, but the
181 /// comment keeps its slot, its author and its timestamp.
182 ///
183 /// A tombstone rather than a disappearance, for three reasons. The event
184 /// is still in the DAG either way, so a UI that showed nothing would be
185 /// lying about a record anyone can still read. A review is a conversation
186 /// and the comment above or below may be a reply, which references nothing
187 /// if its antecedent silently evaporates. And it makes delete behave like
188 /// edit — same target, same `(clock, oid)` resolution, same author rule —
189 /// instead of being a second mechanism with its own edge cases.
190 ///
191 /// Comments only. A review carries a vote, so removing one would drop a
192 /// verdict silently; a reviewer changes their mind by submitting a new
193 /// review, which supersedes the old vote through the existing rule.
194 #[serde(rename = "comment.delete")]
195 CommentDelete { target: String },
196
157 #[serde(rename = "collab.merge")] 197 #[serde(rename = "collab.merge")]
158 Merge, 198 Merge,
159 } 199 }
src/issue.rs
Old New
@@ -254,6 +254,56 @@ pub fn edit(
254 Ok(()) 254 Ok(())
255 } 255 }
256 256
257 /// Correct the text of a comment.
258 ///
259 /// `body_args` is resolved *after* the target, so an omitted body can open an
260 /// editor seeded with the text as it stands — the one-keystroke typo fix that
261 /// is the whole point.
262 pub fn edit_comment(
263 repo: &Repository,
264 id_prefix: &str,
265 comment_prefix: &str,
266 body_args: &crate::body::BodyArgs,
267 ) -> Result<(), crate::error::Error> {
268 let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
269 let issue = IssueState::from_ref(repo, &ref_name, &id)?;
270 let target = issue.resolve_comment(comment_prefix)?;
271 crate::patch::require_own_body(repo, &target)?;
272
273 let body = crate::body::resolve_required(body_args, &target.body, "comment")?;
274 dag::append_action(
275 repo,
276 &ref_name,
277 Action::BodyEdit {
278 target: target.oid,
279 body,
280 },
281 )?;
282 Ok(())
283 }
284
285 /// Tombstone a comment: drop its text, keep its slot.
286 pub fn delete_comment(
287 repo: &Repository,
288 id_prefix: &str,
289 comment_prefix: &str,
290 ) -> Result<(), crate::error::Error> {
291 let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
292 let issue = IssueState::from_ref(repo, &ref_name, &id)?;
293 let target = issue.resolve_comment(comment_prefix)?;
294 crate::patch::require_own_body(repo, &target)?;
295 crate::patch::require_deletable(&target)?;
296
297 dag::append_action(
298 repo,
299 &ref_name,
300 Action::CommentDelete {
301 target: target.oid,
302 },
303 )?;
304 Ok(())
305 }
306
257 pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> { 307 pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> {
258 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; 308 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
259 dag::append_action( 309 dag::append_action(
src/lib.rs
Old New
@@ -1,4 +1,5 @@
1 pub mod abbrev; 1 pub mod abbrev;
2 pub mod body;
2 pub mod cache; 3 pub mod cache;
3 pub mod cli; 4 pub mod cli;
4 pub mod commit_link; 5 pub mod commit_link;
@@ -155,8 +156,11 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
155 IssueCmd::Open { 156 IssueCmd::Open {
156 title, 157 title,
157 body, 158 body,
159 body_file,
158 relates_to, 160 relates_to,
159 } => { 161 } => {
162 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
163 let body = body::resolve_optional(&args)?.unwrap_or_default();
160 let id = issue::open(repo, &title, &body, relates_to.as_deref())?; 164 let id = issue::open(repo, &title, &body, relates_to.as_deref())?;
161 println!("Opened issue {}", abbrev::for_issues(repo).of(&id)); 165 println!("Opened issue {}", abbrev::for_issues(repo).of(&id));
162 Ok(()) 166 Ok(())
@@ -220,7 +224,14 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
220 if !i.comments.is_empty() { 224 if !i.comments.is_empty() {
221 println!("\n--- Comments ---"); 225 println!("\n--- Comments ---");
222 for c in &i.comments { 226 for c in &i.comments {
223 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body); 227 println!(
228 "\n{} ({}){} [{:.8}]:\n{}",
229 c.author.name,
230 c.timestamp,
231 edit_marker(c.edited),
232 c.commit_id,
233 body_or_tombstone(&c.body, c.deleted)
234 );
224 } 235 }
225 } 236 }
226 if !i.linked_commits.is_empty() { 237 if !i.linked_commits.is_empty() {
@@ -300,16 +311,45 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
300 println!("Unassigned '{}'.", name); 311 println!("Unassigned '{}'.", name);
301 Ok(()) 312 Ok(())
302 } 313 }
303 IssueCmd::Edit { id, title, body } => { 314 IssueCmd::Edit {
315 id,
316 title,
317 body,
318 body_file,
319 } => {
320 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
321 let body = body::resolve_optional(&args)?;
304 issue::edit(repo, &id, title.as_deref(), body.as_deref())?; 322 issue::edit(repo, &id, title.as_deref(), body.as_deref())?;
305 println!("Issue updated."); 323 println!("Issue updated.");
306 Ok(()) 324 Ok(())
307 } 325 }
308 IssueCmd::Comment { id, body } => { 326 IssueCmd::Comment {
327 id,
328 body,
329 body_file,
330 } => {
331 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
332 let body = body::resolve_required(&args, "", "comment")?;
309 issue::comment(repo, &id, &body)?; 333 issue::comment(repo, &id, &body)?;
310 println!("Comment added."); 334 println!("Comment added.");
311 Ok(()) 335 Ok(())
312 } 336 }
337 IssueCmd::EditComment {
338 id,
339 comment,
340 body,
341 body_file,
342 } => {
343 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
344 issue::edit_comment(repo, &id, &comment, &args)?;
345 println!("Comment updated.");
346 Ok(())
347 }
348 IssueCmd::DeleteComment { id, comment } => {
349 issue::delete_comment(repo, &id, &comment)?;
350 println!("Comment deleted.");
351 Ok(())
352 }
313 IssueCmd::Close { id, reason } => { 353 IssueCmd::Close { id, reason } => {
314 issue::close(repo, &id, reason.as_deref())?; 354 issue::close(repo, &id, reason.as_deref())?;
315 println!("Issue closed."); 355 println!("Issue closed.");
@@ -333,10 +373,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
333 PatchCmd::Create { 373 PatchCmd::Create {
334 title, 374 title,
335 body, 375 body,
376 body_file,
336 base, 377 base,
337 branch, 378 branch,
338 fixes, 379 fixes,
339 } => { 380 } => {
381 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
382 let body = body::resolve_optional(&args)?.unwrap_or_default();
340 // A branch invented here, rather than named by the user, is ours 383 // A branch invented here, rather than named by the user, is ours
341 // to undo: `patch create` validates its arguments and can still 384 // to undo: `patch create` validates its arguments and can still
342 // refuse, and a refusal that left a stray `collab/patch/*` 385 // refuse, and a refusal that left a stray `collab/patch/*`
@@ -530,8 +573,14 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
530 let rev_label = 573 let rev_label =
531 r.revision.map(|n| format!(" (r{})", n)).unwrap_or_default(); 574 r.revision.map(|n| format!(" (r{})", n)).unwrap_or_default();
532 println!( 575 println!(
533 "\n{} ({}) - {}{}:\n{}", 576 "\n{} ({}) - {}{}{} [{:.8}]:\n{}",
534 r.author.name, r.verdict, r.timestamp, rev_label, r.body 577 r.author.name,
578 r.verdict,
579 r.timestamp,
580 rev_label,
581 edit_marker(r.edited),
582 r.commit_id,
583 r.body
535 ); 584 );
536 } 585 }
537 } 586 }
@@ -549,8 +598,15 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
549 for c in &inline_comments { 598 for c in &inline_comments {
550 let rev_label = c.revision.map(|n| format!(" r{}", n)).unwrap_or_default(); 599 let rev_label = c.revision.map(|n| format!(" r{}", n)).unwrap_or_default();
551 println!( 600 println!(
552 "\n{} on {}:{} ({}{}):\n {}", 601 "\n{} on {}:{} ({}{}){} [{:.8}]:\n {}",
553 c.author.name, c.file, c.line, c.timestamp, rev_label, c.body 602 c.author.name,
603 c.file,
604 c.line,
605 c.timestamp,
606 rev_label,
607 edit_marker(c.edited),
608 c.commit_id,
609 body_or_tombstone(&c.body, c.deleted)
554 ); 610 );
555 } 611 }
556 } 612 }
@@ -558,7 +614,14 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
558 if !p.comments.is_empty() { 614 if !p.comments.is_empty() {
559 println!("\n--- Comments ---"); 615 println!("\n--- Comments ---");
560 for c in &p.comments { 616 for c in &p.comments {
561 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body); 617 println!(
618 "\n{} ({}){} [{:.8}]:\n{}",
619 c.author.name,
620 c.timestamp,
621 edit_marker(c.edited),
622 c.commit_id,
623 body_or_tombstone(&c.body, c.deleted)
624 );
562 } 625 }
563 } 626 }
564 Ok(()) 627 Ok(())
@@ -589,10 +652,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
589 PatchCmd::Comment { 652 PatchCmd::Comment {
590 id, 653 id,
591 body, 654 body,
655 body_file,
592 file, 656 file,
593 line, 657 line,
594 revision, 658 revision,
595 } => { 659 } => {
660 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
661 let body = body::resolve_required(&args, "", "comment")?;
596 patch::comment(repo, &id, &body, file.as_deref(), line, revision)?; 662 patch::comment(repo, &id, &body, file.as_deref(), line, revision)?;
597 println!("Comment added."); 663 println!("Comment added.");
598 Ok(()) 664 Ok(())
@@ -601,6 +667,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
601 id, 667 id,
602 verdict, 668 verdict,
603 body, 669 body,
670 body_file,
604 revision, 671 revision,
605 } => { 672 } => {
606 let v: ReviewVerdict = verdict.parse().map_err(|_| { 673 let v: ReviewVerdict = verdict.parse().map_err(|_| {
@@ -608,15 +675,51 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
608 "verdict must be: approve, request-changes, comment, or reject", 675 "verdict must be: approve, request-changes, comment, or reject",
609 ) 676 )
610 })?; 677 })?;
678 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
679 let body = body::resolve_required(&args, "", "review body")?;
611 patch::review(repo, &id, v, &body, revision)?; 680 patch::review(repo, &id, v, &body, revision)?;
612 println!("Review submitted."); 681 println!("Review submitted.");
613 Ok(()) 682 Ok(())
614 } 683 }
615 PatchCmd::Revise { id, body, branch } => { 684 PatchCmd::Revise {
685 id,
686 body,
687 body_file,
688 branch,
689 } => {
690 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
691 let body = body::resolve_optional(&args)?;
616 patch::revise(repo, &id, body.as_deref(), branch.as_deref())?; 692 patch::revise(repo, &id, body.as_deref(), branch.as_deref())?;
617 println!("Patch revised."); 693 println!("Patch revised.");
618 Ok(()) 694 Ok(())
619 } 695 }
696 PatchCmd::EditComment {
697 id,
698 comment,
699 body,
700 body_file,
701 } => {
702 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
703 patch::edit_comment(repo, &id, &comment, &args)?;
704 println!("Comment updated.");
705 Ok(())
706 }
707 PatchCmd::DeleteComment { id, comment } => {
708 patch::delete_comment(repo, &id, &comment)?;
709 println!("Comment deleted.");
710 Ok(())
711 }
712 PatchCmd::EditRevision {
713 id,
714 revision,
715 body,
716 body_file,
717 } => {
718 let args = body::BodyArgs::new(body.as_deref(), body_file.as_deref());
719 patch::edit_revision(repo, &id, revision, &args)?;
720 println!("Revision description updated.");
721 Ok(())
722 }
620 PatchCmd::Log { id, json } => { 723 PatchCmd::Log { id, json } => {
621 let p = patch::patch_log(repo, &id)?; 724 let p = patch::patch_log(repo, &id)?;
622 if json { 725 if json {
@@ -955,6 +1058,31 @@ fn search(repo: &Repository, query: &str) -> Result<(), error::Error> {
955 Ok(()) 1058 Ok(())
956 } 1059 }
957 1060
1061 /// What to print in place of a deleted comment's text.
1062 ///
1063 /// A tombstone, not a blank: the reader has to be able to tell "someone
1064 /// removed this" from "someone wrote nothing", especially when the comment
1065 /// below it is a reply.
1066 pub const TOMBSTONE: &str = "[deleted]";
1067
1068 /// Render a body, or the tombstone if the comment was deleted.
1069 fn body_or_tombstone(body: &str, deleted: bool) -> &str {
1070 if deleted {
1071 TOMBSTONE
1072 } else {
1073 body
1074 }
1075 }
1076
1077 /// The suffix marking a body that has been corrected since it was written.
1078 fn edit_marker(edited: bool) -> &'static str {
1079 if edited {
1080 " (edited)"
1081 } else {
1082 ""
1083 }
1084 }
1085
958 pub(crate) fn truncate_summary(s: &str, max_chars: usize) -> String { 1086 pub(crate) fn truncate_summary(s: &str, max_chars: usize) -> String {
959 let mut out = String::new(); 1087 let mut out = String::new();
960 for (count, c) in s.chars().enumerate() { 1088 for (count, c) in s.chars().enumerate() {
src/log.rs
Old New
@@ -129,6 +129,8 @@ fn action_type_name(action: &Action) -> String {
129 Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(), 129 Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(),
130 Action::PatchClose { .. } => "PatchClose".to_string(), 130 Action::PatchClose { .. } => "PatchClose".to_string(),
131 Action::PatchMerge { .. } => "PatchMerge".to_string(), 131 Action::PatchMerge { .. } => "PatchMerge".to_string(),
132 Action::BodyEdit { .. } => "BodyEdit".to_string(),
133 Action::CommentDelete { .. } => "CommentDelete".to_string(),
132 Action::Merge => "Merge".to_string(), 134 Action::Merge => "Merge".to_string(),
133 } 135 }
134 } 136 }
@@ -182,6 +184,13 @@ fn action_summary(action: &Action) -> String {
182 format!("merge {}", &commit[..commit.len().min(7)]) 184 format!("merge {}", &commit[..commit.len().min(7)])
183 } 185 }
184 } 186 }
187 // The log is the audit trail for corrections: it names the event that
188 // was superseded, so "who changed what, and what did it say before"
189 // is answerable from `git-collab log` plus the original event object.
190 Action::BodyEdit { target, body } => {
191 format!("edit body of {:.8}: {}", target, truncate(body, 40))
192 }
193 Action::CommentDelete { target } => format!("delete comment {:.8}", target),
185 Action::Merge => "dag merge".to_string(), 194 Action::Merge => "dag merge".to_string(),
186 } 195 }
187 } 196 }
src/patch.rs
Old New
@@ -328,6 +328,132 @@ pub fn show(
328 Ok(patch) 328 Ok(patch)
329 } 329 }
330 330
331 /// Refuse to write a correction to prose somebody else wrote.
332 ///
333 /// This is a courtesy, not the enforcement: the enforcement is in the fold,
334 /// which ignores a correction whose author is not the target's author (see
335 /// `state::BodyOverrides::authorized`). Anyone can append whatever they like
336 /// to a DAG they hold a copy of, so the CLI cannot be the line of defence.
337 /// What it can do is tell an honest user *now*, rather than let them write an
338 /// event that every reader will silently discard.
339 ///
340 /// The comparison is against the primary email exactly, matching the fold.
341 /// Identity aliases deliberately do not widen it: aliases live in local
342 /// config, so folding through them would make the same events derive
343 /// different state in different clones — and allowing an edit here that the
344 /// fold then drops is worse than refusing it, because the user would see
345 /// success and no change.
346 pub fn require_own_body(
347 repo: &Repository,
348 target: &state::BodyTarget,
349 ) -> Result<(), crate::error::Error> {
350 let me = get_author(repo)?;
351 if me.email != target.author.email {
352 return Err(Error::Cmd(format!(
353 "this {} was written by {} <{}>; only its author can change it \
354 (you are {} <{}>)",
355 target.kind.label(),
356 target.author.name,
357 target.author.email,
358 me.name,
359 me.email,
360 )));
361 }
362 Ok(())
363 }
364
365 /// Refuse to delete something that is not prose.
366 pub fn require_deletable(target: &state::BodyTarget) -> Result<(), crate::error::Error> {
367 if !target.kind.is_deletable() {
368 return Err(Error::Cmd(format!(
369 "a {} cannot be deleted: it carries a vote, and removing it would drop that \
370 verdict silently. Submit a new review to change your vote, or edit its body \
371 with `patch edit-comment`.",
372 target.kind.label()
373 )));
374 }
375 Ok(())
376 }
377
378 /// Correct the text of a thread comment, an inline comment or a review body.
379 ///
380 /// The three share one ID namespace because they share one identity scheme,
381 /// so a user pastes whatever `patch show` printed without having to know
382 /// which list it came from.
383 pub fn edit_comment(
384 repo: &Repository,
385 id_prefix: &str,
386 comment_prefix: &str,
387 body_args: &crate::body::BodyArgs,
388 ) -> Result<(), crate::error::Error> {
389 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
390 let patch = PatchState::from_ref(repo, &ref_name, &id)?;
391 let target = patch.resolve_comment(comment_prefix)?;
392 require_own_body(repo, &target)?;
393
394 let body = crate::body::resolve_required(body_args, &target.body, target.kind.label())?;
395 dag::append_action(
396 repo,
397 &ref_name,
398 Action::BodyEdit {
399 target: target.oid,
400 body,
401 },
402 )?;
403 Ok(())
404 }
405
406 /// Tombstone a comment: drop its text, keep its slot.
407 pub fn delete_comment(
408 repo: &Repository,
409 id_prefix: &str,
410 comment_prefix: &str,
411 ) -> Result<(), crate::error::Error> {
412 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
413 let patch = PatchState::from_ref(repo, &ref_name, &id)?;
414 let target = patch.resolve_comment(comment_prefix)?;
415 require_own_body(repo, &target)?;
416 require_deletable(&target)?;
417
418 dag::append_action(
419 repo,
420 &ref_name,
421 Action::CommentDelete {
422 target: target.oid,
423 },
424 )?;
425 Ok(())
426 }
427
428 /// Correct a revision's description.
429 ///
430 /// The revision's commit and tree are not touched — those are the content and
431 /// are immutable by design. Only the body moves, which is the case
432 /// `patch revise` could not fix: a second revise is refused once there are no
433 /// new commits, so a placeholder body was permanent.
434 pub fn edit_revision(
435 repo: &Repository,
436 id_prefix: &str,
437 revision: u32,
438 body_args: &crate::body::BodyArgs,
439 ) -> Result<(), crate::error::Error> {
440 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
441 let patch = PatchState::from_ref(repo, &ref_name, &id)?;
442 let target = patch.resolve_revision(revision)?;
443 require_own_body(repo, &target)?;
444
445 let body = crate::body::resolve_required(body_args, &target.body, "revision description")?;
446 dag::append_action(
447 repo,
448 &ref_name,
449 Action::BodyEdit {
450 target: target.oid,
451 body,
452 },
453 )?;
454 Ok(())
455 }
456
331 pub fn comment( 457 pub fn comment(
332 repo: &Repository, 458 repo: &Repository,
333 id_prefix: &str, 459 id_prefix: &str,
src/server/http/repo/issues.rs
Old New
@@ -175,15 +175,7 @@ pub async fn issue_detail(
175 labels: is.labels.join(", "), 175 labels: is.labels.join(", "),
176 assignees: is.assignees.join(", "), 176 assignees: is.assignees.join(", "),
177 close_reason: is.close_reason, 177 close_reason: is.close_reason,
178 comments: is 178 comments: is.comments.into_iter().map(CommentView::from_state).collect(),
179 .comments
180 .into_iter()
181 .map(|c| CommentView {
182 author: c.author.name,
183 body: c.body,
184 timestamp: c.timestamp,
185 })
186 .collect(),
187 }; 179 };
188 180
189 IssueDetailTemplate { 181 IssueDetailTemplate {
src/server/http/repo/mod.rs
Old New
@@ -126,8 +126,25 @@ fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit>
126 #[derive(Debug)] 126 #[derive(Debug)]
127 pub struct CommentView { 127 pub struct CommentView {
128 pub author: String, 128 pub author: String,
129 /// Empty when `deleted`; the template renders the tombstone instead. The
130 /// deleted text is dropped in the fold and never reaches this far, so
131 /// there is nothing here for a template bug to leak.
129 pub body: String, 132 pub body: String,
130 pub timestamp: String, 133 pub timestamp: String,
134 pub edited: bool,
135 pub deleted: bool,
136 }
137
138 impl CommentView {
139 pub fn from_state(c: git_collab::state::Comment) -> Self {
140 CommentView {
141 author: c.author.name,
142 body: c.body,
143 timestamp: c.timestamp,
144 edited: c.edited,
145 deleted: c.deleted,
146 }
147 }
131 } 148 }
132 149
133 /// List local branch names, sorted alphabetically. 150 /// List local branch names, sorted alphabetically.
src/server/http/repo/patches.rs
Old New
@@ -88,6 +88,7 @@ pub struct ReviewView {
88 pub body: String, 88 pub body: String,
89 pub timestamp: String, 89 pub timestamp: String,
90 pub revision: Option<u32>, 90 pub revision: Option<u32>,
91 pub edited: bool,
91 } 92 }
92 93
93 #[derive(Debug)] 94 #[derive(Debug)]
@@ -98,6 +99,8 @@ pub struct InlineCommentView {
98 pub body: String, 99 pub body: String,
99 pub timestamp: String, 100 pub timestamp: String,
100 pub revision: Option<u32>, 101 pub revision: Option<u32>,
102 pub edited: bool,
103 pub deleted: bool,
101 } 104 }
102 105
103 #[derive(Debug)] 106 #[derive(Debug)]
@@ -253,6 +256,7 @@ pub async fn patch_detail(
253 body: r.body, 256 body: r.body,
254 timestamp: r.timestamp, 257 timestamp: r.timestamp,
255 revision: r.revision, 258 revision: r.revision,
259 edited: r.edited,
256 }) 260 })
257 .collect(), 261 .collect(),
258 inline_comments: ps 262 inline_comments: ps
@@ -265,17 +269,11 @@ pub async fn patch_detail(
265 body: ic.body, 269 body: ic.body,
266 timestamp: ic.timestamp, 270 timestamp: ic.timestamp,
267 revision: ic.revision, 271 revision: ic.revision,
272 edited: ic.edited,
273 deleted: ic.deleted,
268 }) 274 })
269 .collect(), 275 .collect(),
270 comments: ps 276 comments: ps.comments.into_iter().map(CommentView::from_state).collect(),
271 .comments
272 .into_iter()
273 .map(|c| CommentView {
274 author: c.author.name,
275 body: c.body,
276 timestamp: c.timestamp,
277 })
278 .collect(),
279 }; 277 };
280 278
281 PatchDetailTemplate { 279 PatchDetailTemplate {
src/server/http/templates/issue_detail.html
Old New
@@ -28,8 +28,13 @@
28 <p style="margin: 0 0 8px 0;"> 28 <p style="margin: 0 0 8px 0;">
29 <strong>{{ comment.author }}</strong> 29 <strong>{{ comment.author }}</strong>
30 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ comment.timestamp }}</span> 30 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ comment.timestamp }}</span>
31 {% if comment.edited %}&nbsp; <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %}
31 </p> 32 </p>
33 {% if comment.deleted %}
34 <p style="margin: 0; color: #666; font-style: italic;">[deleted]</p>
35 {% else %}
32 <pre style="margin: 0; white-space: pre-wrap;">{{ comment.body }}</pre> 36 <pre style="margin: 0; white-space: pre-wrap;">{{ comment.body }}</pre>
37 {% endif %}
33 </div> 38 </div>
34 {% endfor %} 39 {% endfor %}
35 {% endif %} 40 {% endif %}
src/server/http/templates/patch_detail.html
Old New
@@ -54,6 +54,7 @@
54 &nbsp; <span class="status-{{ review.verdict }}">{{ review.verdict }}</span> 54 &nbsp; <span class="status-{{ review.verdict }}">{{ review.verdict }}</span>
55 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ review.timestamp }}</span> 55 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ review.timestamp }}</span>
56 {% if let Some(rev) = review.revision %}&nbsp; rev {{ rev }}{% endif %} 56 {% if let Some(rev) = review.revision %}&nbsp; rev {{ rev }}{% endif %}
57 {% if review.edited %}&nbsp; <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %}
57 </p> 58 </p>
58 {% if !review.body.is_empty() %} 59 {% if !review.body.is_empty() %}
59 <pre style="margin: 0; white-space: pre-wrap;">{{ review.body }}</pre> 60 <pre style="margin: 0; white-space: pre-wrap;">{{ review.body }}</pre>
@@ -71,8 +72,13 @@
71 &nbsp; <span class="mono" style="color: #666;">{{ ic.file }}:{{ ic.line }}</span> 72 &nbsp; <span class="mono" style="color: #666;">{{ ic.file }}:{{ ic.line }}</span>
72 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ ic.timestamp }}</span> 73 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ ic.timestamp }}</span>
73 {% if let Some(rev) = ic.revision %}&nbsp; rev {{ rev }}{% endif %} 74 {% if let Some(rev) = ic.revision %}&nbsp; rev {{ rev }}{% endif %}
75 {% if ic.edited %}&nbsp; <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %}
74 </p> 76 </p>
77 {% if ic.deleted %}
78 <p style="margin: 0; color: #666; font-style: italic;">[deleted]</p>
79 {% else %}
75 <pre style="margin: 0; white-space: pre-wrap;">{{ ic.body }}</pre> 80 <pre style="margin: 0; white-space: pre-wrap;">{{ ic.body }}</pre>
81 {% endif %}
76 </div> 82 </div>
77 {% endfor %} 83 {% endfor %}
78 {% endif %} 84 {% endif %}
@@ -84,8 +90,13 @@
84 <p style="margin: 0 0 8px 0;"> 90 <p style="margin: 0 0 8px 0;">
85 <strong>{{ comment.author }}</strong> 91 <strong>{{ comment.author }}</strong>
86 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ comment.timestamp }}</span> 92 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ comment.timestamp }}</span>
93 {% if comment.edited %}&nbsp; <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %}
87 </p> 94 </p>
95 {% if comment.deleted %}
96 <p style="margin: 0; color: #666; font-style: italic;">[deleted]</p>
97 {% else %}
88 <pre style="margin: 0; white-space: pre-wrap;">{{ comment.body }}</pre> 98 <pre style="margin: 0; white-space: pre-wrap;">{{ comment.body }}</pre>
99 {% endif %}
89 </div> 100 </div>
90 {% endfor %} 101 {% endfor %}
91 {% endif %} 102 {% endif %}
src/state.rs
Old New
@@ -73,6 +73,99 @@ fn deserialize_relates_to<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<
73 } 73 }
74 } 74 }
75 75
76 /// One `BodyEdit` or `CommentDelete`, pending application.
77 struct BodyOverride {
78 /// `(clock, oid_hex)` of the requesting event. The same total order that
79 /// already decides `status` here: content-derived, so every clone folding
80 /// the same event set picks the same winner regardless of how its DAG
81 /// happens to be joined.
82 key: (u64, String),
83 /// Who asked. Compared against the target's author before anything is
84 /// applied.
85 author_email: String,
86 /// The replacement body, or `None` for a delete.
87 body: Option<String>,
88 }
89
90 /// Accumulator for the second pass of the fold: every correction seen, and
91 /// who owns each thing a correction could name.
92 ///
93 /// Corrections are applied *after* the walk rather than during it, which is
94 /// what makes them independent of walk order. `Sort::TOPOLOGICAL` completes
95 /// the DAG's partial order with an internal tiebreak, so over a forked DAG
96 /// two clients can visit the same events in different orders; anything
97 /// decided by position would then differ between them. Applying afterwards,
98 /// keyed on `(clock, oid)`, removes the question — an edit can even be
99 /// visited before the comment it corrects and still land.
100 #[derive(Default)]
101 struct BodyOverrides {
102 /// Winning correction per target event OID.
103 by_target: HashMap<String, BodyOverride>,
104 /// Author email of every body-carrying event, by its OID. A correction is
105 /// only honoured when its author matches the entry here.
106 owners: HashMap<String, String>,
107 }
108
109 impl BodyOverrides {
110 /// Register an event that carries a body, so a later correction naming it
111 /// can be checked against its author.
112 fn note_owner(&mut self, oid: Oid, author: &Author) {
113 self.owners.insert(oid.to_string(), author.email.clone());
114 }
115
116 /// Record a correction, keeping the one that wins on `(clock, oid)`.
117 /// `>=` matches the status fold: later clock wins, and on a tie the
118 /// lexicographically larger OID does.
119 fn record(&mut self, target: String, key: (u64, String), author: &Author, body: Option<String>) {
120 let candidate = BodyOverride {
121 key,
122 author_email: author.email.clone(),
123 body,
124 };
125 match self.by_target.get(&target) {
126 Some(existing) if candidate.key < existing.key => {}
127 _ => {
128 self.by_target.insert(target, candidate);
129 }
130 }
131 }
132
133 /// The corrections that may actually be applied: those whose author is
134 /// the author of the event they name.
135 ///
136 /// Everything else is dropped in silence rather than reported, because
137 /// the fold has to be total — anyone with a copy of the DAG can append
138 /// whatever they like to it, and a reader deriving state from a hostile
139 /// clone must still get an answer. Matching is on the event's declared
140 /// author email, which is the same basis every other attribution in this
141 /// codebase uses, and deliberately not on identity aliases: those live in
142 /// local config, so folding through them would make the same events
143 /// derive different state in different clones.
144 fn authorized(&self) -> impl Iterator<Item = (&String, &BodyOverride)> {
145 self.by_target.iter().filter(|(target, ov)| {
146 self.owners
147 .get(*target)
148 .is_some_and(|owner| *owner == ov.author_email)
149 })
150 }
151 }
152
153 /// Apply a correction to a comment, in place, so it keeps its position among
154 /// its siblings and everything about it except the words.
155 fn apply_to_comment(body: &mut String, edited: &mut bool, deleted: &mut bool, ov: &BodyOverride) {
156 match &ov.body {
157 Some(new_body) => {
158 new_body.clone_into(body);
159 *edited = true;
160 *deleted = false;
161 }
162 None => {
163 body.clear();
164 *deleted = true;
165 }
166 }
167 }
168
76 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 169 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77 #[serde(rename_all = "lowercase")] 170 #[serde(rename_all = "lowercase")]
78 pub enum IssueStatus { 171 pub enum IssueStatus {
@@ -99,10 +192,23 @@ impl fmt::Display for IssueStatus {
99 #[allow(dead_code)] 192 #[allow(dead_code)]
100 pub struct Comment { 193 pub struct Comment {
101 pub author: Author, 194 pub author: Author,
195 /// The comment as it currently stands, after any `BodyEdit` that won.
196 /// Empty when `deleted` — a tombstone drops the text from derived state
197 /// rather than merely hiding it, so it stops reaching `--json`, `search`
198 /// and the web UI. The original event is of course still in the DAG.
102 pub body: String, 199 pub body: String,
103 pub timestamp: String, 200 pub timestamp: String,
201 /// The OID of the event commit that created this comment. This is the
202 /// comment's identity: what an edit or a delete names, and what `show`
203 /// prints so a user has something to name.
104 #[serde(serialize_with = "serialize_oid", deserialize_with = "deserialize_oid")] 204 #[serde(serialize_with = "serialize_oid", deserialize_with = "deserialize_oid")]
105 pub commit_id: Oid, 205 pub commit_id: Oid,
206 /// A later `BodyEdit` by this comment's own author superseded the text.
207 #[serde(default)]
208 pub edited: bool,
209 /// A `CommentDelete` by this comment's own author tombstoned it.
210 #[serde(default)]
211 pub deleted: bool,
106 } 212 }
107 213
108 #[derive(Debug, Clone, Serialize, Deserialize)] 214 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -152,6 +258,23 @@ pub struct Review {
152 pub timestamp: String, 258 pub timestamp: String,
153 #[serde(default)] 259 #[serde(default)]
154 pub revision: Option<u32>, 260 pub revision: Option<u32>,
261 /// The OID of the `PatchReview` event, so the body can be corrected. A
262 /// review's *verdict* is never edited — a reviewer changes their mind by
263 /// submitting a new review, which supersedes the old vote through the
264 /// existing per-(author, revision) rule.
265 ///
266 /// `Oid::zero()` on a `Review` deserialized from a cache entry written
267 /// before this field existed. Nothing can be addressed by a zero OID, so
268 /// such a review is simply not editable until the fold cache turns over —
269 /// which `cache::CACHE_FORMAT_VERSION` forces anyway.
270 #[serde(
271 default = "Oid::zero",
272 serialize_with = "serialize_oid",
273 deserialize_with = "deserialize_oid"
274 )]
275 pub commit_id: Oid,
276 #[serde(default)]
277 pub edited: bool,
155 } 278 }
156 279
157 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 280 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -197,6 +320,28 @@ pub struct Revision {
197 /// these two revisions" is exactly "did this change". 320 /// these two revisions" is exactly "did this change".
198 #[serde(default)] 321 #[serde(default)]
199 pub base: Option<String>, 322 pub base: Option<String>,
323 /// The OID of the event that recorded this revision — the `PatchRevision`,
324 /// or the `PatchCreate` for revision 1.
325 ///
326 /// A revision's commit and tree are immutable by design and stay so. Its
327 /// *body* is descriptive metadata, and this is what a correction to that
328 /// body names. Users still say "r3", never an OID; the CLI resolves the
329 /// number to this OID, so what goes in the event is content-derived and
330 /// two clones cannot disagree about it.
331 #[serde(default)]
332 pub event_id: String,
333 /// Who recorded this revision. Not the patch author in general — anyone
334 /// can push a revision to someone else's patch — and the correction rule
335 /// is "only the author may edit", so guessing here would refuse honest
336 /// edits and confuse the CLI's answer with the fold's.
337 ///
338 /// `None` only for a `Revision` deserialized from a cache entry written
339 /// before this field existed. An unknown author cannot be matched against
340 /// anyone, so such a revision is simply not editable — the safe direction.
341 #[serde(default)]
342 pub author: Option<Author>,
343 #[serde(default)]
344 pub edited: bool,
200 } 345 }
201 346
202 impl Revision { 347 impl Revision {
@@ -223,6 +368,19 @@ pub struct InlineComment {
223 pub timestamp: String, 368 pub timestamp: String,
224 #[serde(default)] 369 #[serde(default)]
225 pub revision: Option<u32>, 370 pub revision: Option<u32>,
371 /// The OID of the `PatchInlineComment` event — this comment's identity,
372 /// for the same reason `Comment::commit_id` is. `Oid::zero()` only for a
373 /// cache entry predating the field; see `Review::commit_id`.
374 #[serde(
375 default = "Oid::zero",
376 serialize_with = "serialize_oid",
377 deserialize_with = "deserialize_oid"
378 )]
379 pub commit_id: Oid,
380 #[serde(default)]
381 pub edited: bool,
382 #[serde(default)]
383 pub deleted: bool,
226 } 384 }
227 385
228 #[derive(Debug, Clone, Serialize, Deserialize)] 386 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -322,7 +480,95 @@ impl crate::cli::Listable for PatchState {
322 } 480 }
323 } 481 }
324 482
483 /// What kind of thing a correction is naming. The distinction only matters
484 /// for two rules: what the error messages call it, and whether it may be
485 /// deleted.
486 #[derive(Debug, Clone, Copy, PartialEq)]
487 pub enum BodyKind {
488 Comment,
489 InlineComment,
490 Review,
491 Revision,
492 }
493
494 impl BodyKind {
495 pub fn label(&self) -> &'static str {
496 match self {
497 BodyKind::Comment => "comment",
498 BodyKind::InlineComment => "inline comment",
499 BodyKind::Review => "review",
500 BodyKind::Revision => "revision",
501 }
502 }
503
504 /// Whether a `CommentDelete` may name this.
505 ///
506 /// A review carries a vote and a revision carries a commit; dropping
507 /// either through a body-deletion mechanism would remove something that
508 /// is not prose. A reviewer changes their mind with a new review, which
509 /// supersedes the old vote through the rule that already exists.
510 pub fn is_deletable(&self) -> bool {
511 matches!(self, BodyKind::Comment | BodyKind::InlineComment)
512 }
513 }
514
515 /// A body-carrying event that a `BodyEdit` or `CommentDelete` can name,
516 /// resolved from a user-supplied ID prefix.
517 #[derive(Debug, Clone)]
518 pub struct BodyTarget {
519 /// Hex OID of the event that carries the body — what goes in the event.
520 pub oid: String,
521 pub author: Author,
522 /// The body as it currently stands, used to seed the editor.
523 pub body: String,
524 pub kind: BodyKind,
525 }
526
527 /// Resolve an ID prefix against a set of candidate targets.
528 ///
529 /// Same contract as resolving a patch or issue prefix: exactly one match, or
530 /// an error naming the prefix. An ambiguous prefix must never silently pick
531 /// one — this decides which of someone's comments gets rewritten.
532 fn resolve_body_target(
533 candidates: Vec<BodyTarget>,
534 prefix: &str,
535 noun: &str,
536 ) -> Result<BodyTarget, crate::error::Error> {
537 let matches: Vec<BodyTarget> = candidates
538 .into_iter()
539 .filter(|t| t.oid.starts_with(prefix))
540 .collect();
541 match matches.len() {
542 0 => Err(crate::error::Error::Cmd(format!(
543 "no {} on this {} matching '{}'",
544 noun,
545 if noun == "comment" { "issue" } else { "patch" },
546 prefix
547 ))),
548 1 => Ok(matches.into_iter().next().unwrap()),
549 n => Err(crate::error::Error::Cmd(format!(
550 "ambiguous {} prefix '{}': {} matches",
551 noun, prefix, n
552 ))),
553 }
554 }
555
325 impl IssueState { 556 impl IssueState {
557 /// Resolve a comment ID prefix to the comment it names.
558 pub fn resolve_comment(&self, prefix: &str) -> Result<BodyTarget, crate::error::Error> {
559 let candidates = self
560 .comments
561 .iter()
562 .map(|c| BodyTarget {
563 oid: c.commit_id.to_string(),
564 author: c.author.clone(),
565 body: c.body.clone(),
566 kind: BodyKind::Comment,
567 })
568 .collect();
569 resolve_body_target(candidates, prefix, "comment")
570 }
571
326 pub fn from_ref( 572 pub fn from_ref(
327 repo: &Repository, 573 repo: &Repository,
328 ref_name: &str, 574 ref_name: &str,
@@ -365,6 +611,10 @@ impl IssueState {
365 // that are reconciled via merge commits, so we sort explicitly. 611 // that are reconciled via merge commits, so we sort explicitly.
366 let mut link_acc: HashMap<String, ((u64, String, String), LinkedCommit)> = HashMap::new(); 612 let mut link_acc: HashMap<String, ((u64, String, String), LinkedCommit)> = HashMap::new();
367 613
614 // Corrections to comment bodies, applied after the walk. See
615 // `BodyOverrides`.
616 let mut overrides = BodyOverrides::default();
617
368 for (oid, event) in events { 618 for (oid, event) in events {
369 let ts = parse_timestamp(&event.timestamp); 619 let ts = parse_timestamp(&event.timestamp);
370 if latest.as_ref().is_none_or(|(prev, _)| ts > *prev) { 620 if latest.as_ref().is_none_or(|(prev, _)| ts > *prev) {
@@ -397,14 +647,37 @@ impl IssueState {
397 } 647 }
398 Action::IssueComment { body } => { 648 Action::IssueComment { body } => {
399 if let Some(ref mut s) = state { 649 if let Some(ref mut s) = state {
650 overrides.note_owner(oid, &event.author);
400 s.comments.push(Comment { 651 s.comments.push(Comment {
401 author: event.author.clone(), 652 author: event.author.clone(),
402 body, 653 body,
403 timestamp: event.timestamp.clone(), 654 timestamp: event.timestamp.clone(),
404 commit_id: oid, 655 commit_id: oid,
656 edited: false,
657 deleted: false,
405 }); 658 });
406 } 659 }
407 } 660 }
661 Action::BodyEdit { target, body } => {
662 if state.is_some() {
663 overrides.record(
664 target,
665 (event.clock, oid.to_string()),
666 &event.author,
667 Some(body),
668 );
669 }
670 }
671 Action::CommentDelete { target } => {
672 if state.is_some() {
673 overrides.record(
674 target,
675 (event.clock, oid.to_string()),
676 &event.author,
677 None,
678 );
679 }
680 }
408 Action::IssueClose { reason } => { 681 Action::IssueClose { reason } => {
409 if let Some(ref mut s) = state { 682 if let Some(ref mut s) = state {
410 let key = (event.clock, oid.to_string()); 683 let key = (event.clock, oid.to_string());
@@ -501,6 +774,19 @@ impl IssueState {
501 } 774 }
502 775
503 if let Some(ref mut s) = state { 776 if let Some(ref mut s) = state {
777 // Second pass: apply the corrections collected above. Order is
778 // irrelevant — each names a distinct comment — so the randomized
779 // HashMap iteration order cannot make the result nondeterministic.
780 for (target, ov) in overrides.authorized() {
781 if let Some(c) = s
782 .comments
783 .iter_mut()
784 .find(|c| c.commit_id.to_string() == *target)
785 {
786 apply_to_comment(&mut c.body, &mut c.edited, &mut c.deleted, ov);
787 }
788 }
789
504 s.last_updated = latest.map(|(_, raw)| raw).unwrap_or_default(); 790 s.last_updated = latest.map(|(_, raw)| raw).unwrap_or_default();
505 791
506 // Flush the linked-commit accumulator into state.linked_commits in 792 // Flush the linked-commit accumulator into state.linked_commits in
@@ -517,6 +803,70 @@ impl IssueState {
517 } 803 }
518 804
519 impl PatchState { 805 impl PatchState {
806 /// Resolve a comment/review ID prefix against everything on this patch
807 /// that carries an editable body.
808 ///
809 /// Thread comments, inline comments and reviews share one namespace
810 /// because they share one identity scheme — the OID of the event that
811 /// created them — so a user never has to know which of the three lists
812 /// the ID they copied out of `patch show` came from.
813 pub fn resolve_comment(&self, prefix: &str) -> Result<BodyTarget, crate::error::Error> {
814 let mut candidates: Vec<BodyTarget> = Vec::new();
815 candidates.extend(self.comments.iter().map(|c| BodyTarget {
816 oid: c.commit_id.to_string(),
817 author: c.author.clone(),
818 body: c.body.clone(),
819 kind: BodyKind::Comment,
820 }));
821 candidates.extend(self.inline_comments.iter().map(|c| BodyTarget {
822 oid: c.commit_id.to_string(),
823 author: c.author.clone(),
824 body: c.body.clone(),
825 kind: BodyKind::InlineComment,
826 }));
827 candidates.extend(self.reviews.iter().map(|r| BodyTarget {
828 oid: r.commit_id.to_string(),
829 author: r.author.clone(),
830 body: r.body.clone(),
831 kind: BodyKind::Review,
832 }));
833 resolve_body_target(candidates, prefix, "comment or review")
834 }
835
836 /// Resolve a revision *number* — what users say — to the event that
837 /// recorded it, which is what a correction names.
838 pub fn resolve_revision(&self, number: u32) -> Result<BodyTarget, crate::error::Error> {
839 let rev = self
840 .revisions
841 .iter()
842 .find(|r| r.number == number)
843 .ok_or_else(|| {
844 crate::error::Error::Cmd(format!(
845 "revision {} not found (this patch has {})",
846 number,
847 self.revisions.len()
848 ))
849 })?;
850 if rev.event_id.is_empty() {
851 return Err(crate::error::Error::Cmd(format!(
852 "revision {} predates revision identity and cannot be corrected",
853 number
854 )));
855 }
856 let author = rev.author.clone().ok_or_else(|| {
857 crate::error::Error::Cmd(format!(
858 "revision {} records no author and cannot be corrected",
859 number
860 ))
861 })?;
862 Ok(BodyTarget {
863 oid: rev.event_id.clone(),
864 author,
865 body: rev.body.clone().unwrap_or_default(),
866 kind: BodyKind::Revision,
867 })
868 }
869
520 /// The commit the patch currently stands at: the latest revision whose 870 /// The commit the patch currently stands at: the latest revision whose
521 /// commit was recorded and whose objects are still present. A revision ref 871 /// commit was recorded and whose objects are still present. A revision ref
522 /// keeps those objects reachable, so this no longer depends on any 872 /// keeps those objects reachable, so this no longer depends on any
@@ -698,6 +1048,10 @@ impl PatchState {
698 1048
699 let mut status_key: Option<(u64, String)> = None; 1049 let mut status_key: Option<(u64, String)> = None;
700 1050
1051 // Corrections to comment, review and revision bodies, applied after
1052 // the walk. See `BodyOverrides`.
1053 let mut overrides = BodyOverrides::default();
1054
701 for (oid, event) in events { 1055 for (oid, event) in events {
702 let ts = parse_timestamp(&event.timestamp); 1056 let ts = parse_timestamp(&event.timestamp);
703 if latest.as_ref().is_none_or(|(prev, _)| ts > *prev) { 1057 if latest.as_ref().is_none_or(|(prev, _)| ts > *prev) {
@@ -714,6 +1068,7 @@ impl PatchState {
714 tree, 1068 tree,
715 base_commit, 1069 base_commit,
716 } => { 1070 } => {
1071 overrides.note_owner(oid, &event.author);
717 let revisions = vec![Revision { 1072 let revisions = vec![Revision {
718 number: 1, 1073 number: 1,
719 commit: commit.clone(), 1074 commit: commit.clone(),
@@ -721,6 +1076,9 @@ impl PatchState {
721 body: None, 1076 body: None,
722 timestamp: event.timestamp.clone(), 1077 timestamp: event.timestamp.clone(),
723 base: base_commit, 1078 base: base_commit,
1079 event_id: oid.to_string(),
1080 author: Some(event.author.clone()),
1081 edited: false,
724 }]; 1082 }];
725 state = Some(PatchState { 1083 state = Some(PatchState {
726 id: id.to_string(), 1084 id: id.to_string(),
@@ -767,6 +1125,7 @@ impl PatchState {
767 let already_seen = 1125 let already_seen =
768 !commit.is_empty() && s.revisions.iter().any(|r| r.commit == commit); 1126 !commit.is_empty() && s.revisions.iter().any(|r| r.commit == commit);
769 if !already_seen { 1127 if !already_seen {
1128 overrides.note_owner(oid, &event.author);
770 let number = s.revisions.len() as u32 + 1; 1129 let number = s.revisions.len() as u32 + 1;
771 s.revisions.push(Revision { 1130 s.revisions.push(Revision {
772 number, 1131 number,
@@ -775,6 +1134,9 @@ impl PatchState {
775 body, 1134 body,
776 timestamp: event.timestamp.clone(), 1135 timestamp: event.timestamp.clone(),
777 base, 1136 base,
1137 event_id: oid.to_string(),
1138 author: Some(event.author.clone()),
1139 edited: false,
778 }); 1140 });
779 } 1141 }
780 } 1142 }
@@ -831,22 +1193,28 @@ impl PatchState {
831 && r.revision == Some(revision)) 1193 && r.revision == Some(revision))
832 }); 1194 });
833 } 1195 }
1196 overrides.note_owner(oid, &event.author);
834 s.reviews.push(Review { 1197 s.reviews.push(Review {
835 author: event.author.clone(), 1198 author: event.author.clone(),
836 verdict, 1199 verdict,
837 body, 1200 body,
838 timestamp: event.timestamp.clone(), 1201 timestamp: event.timestamp.clone(),
839 revision: Some(revision), 1202 revision: Some(revision),
1203 commit_id: oid,
1204 edited: false,
840 }); 1205 });
841 } 1206 }
842 } 1207 }
843 Action::PatchComment { body } => { 1208 Action::PatchComment { body } => {
844 if let Some(ref mut s) = state { 1209 if let Some(ref mut s) = state {
1210 overrides.note_owner(oid, &event.author);
845 s.comments.push(Comment { 1211 s.comments.push(Comment {
846 author: event.author.clone(), 1212 author: event.author.clone(),
847 body, 1213 body,
848 timestamp: event.timestamp.clone(), 1214 timestamp: event.timestamp.clone(),
849 commit_id: oid, 1215 commit_id: oid,
1216 edited: false,
1217 deleted: false,
850 }); 1218 });
851 } 1219 }
852 } 1220 }
@@ -857,6 +1225,7 @@ impl PatchState {
857 revision, 1225 revision,
858 } => { 1226 } => {
859 if let Some(ref mut s) = state { 1227 if let Some(ref mut s) = state {
1228 overrides.note_owner(oid, &event.author);
860 s.inline_comments.push(InlineComment { 1229 s.inline_comments.push(InlineComment {
861 author: event.author.clone(), 1230 author: event.author.clone(),
862 file, 1231 file,
@@ -864,9 +1233,32 @@ impl PatchState {
864 body, 1233 body,
865 timestamp: event.timestamp.clone(), 1234 timestamp: event.timestamp.clone(),
866 revision: Some(revision), 1235 revision: Some(revision),
1236 commit_id: oid,
1237 edited: false,
1238 deleted: false,
867 }); 1239 });
868 } 1240 }
869 } 1241 }
1242 Action::BodyEdit { target, body } => {
1243 if state.is_some() {
1244 overrides.record(
1245 target,
1246 (event.clock, oid.to_string()),
1247 &event.author,
1248 Some(body),
1249 );
1250 }
1251 }
1252 Action::CommentDelete { target } => {
1253 if state.is_some() {
1254 overrides.record(
1255 target,
1256 (event.clock, oid.to_string()),
1257 &event.author,
1258 None,
1259 );
1260 }
1261 }
870 Action::PatchClose { .. } => { 1262 Action::PatchClose { .. } => {
871 if let Some(ref mut s) = state { 1263 if let Some(ref mut s) = state {
872 let key = (event.clock, oid.to_string()); 1264 let key = (event.clock, oid.to_string());
@@ -906,6 +1298,51 @@ impl PatchState {
906 } 1298 }
907 1299
908 if let Some(ref mut s) = state { 1300 if let Some(ref mut s) = state {
1301 // Second pass: apply corrections. A target names exactly one of
1302 // these lists, so the order they are visited in cannot matter.
1303 // A `None` body (a delete) only ever reaches a comment: the CLI
1304 // refuses to emit one for a review or a revision, and a forged
1305 // one finds no match here because the search order stops at the
1306 // comment lists — see `Action::CommentDelete`.
1307 for (target, ov) in overrides.authorized() {
1308 if let Some(c) = s
1309 .comments
1310 .iter_mut()
1311 .find(|c| c.commit_id.to_string() == *target)
1312 {
1313 apply_to_comment(&mut c.body, &mut c.edited, &mut c.deleted, ov);
1314 } else if let Some(c) = s
1315 .inline_comments
1316 .iter_mut()
1317 .find(|c| c.commit_id.to_string() == *target)
1318 {
1319 apply_to_comment(&mut c.body, &mut c.edited, &mut c.deleted, ov);
1320 } else if let Some(r) = s
1321 .reviews
1322 .iter_mut()
1323 .find(|r| r.commit_id.to_string() == *target)
1324 {
1325 // A review's vote is untouchable; only its prose moves.
1326 // A delete is not honoured here, so a forged
1327 // `CommentDelete` cannot drop a verdict.
1328 if let Some(new_body) = &ov.body {
1329 new_body.clone_into(&mut r.body);
1330 r.edited = true;
1331 }
1332 } else if let Some(rev) = s
1333 .revisions
1334 .iter_mut()
1335 .find(|rev| rev.event_id == *target)
1336 {
1337 // Same rule: the commit and tree a revision points at are
1338 // immutable, the description is not.
1339 if let Some(new_body) = &ov.body {
1340 rev.body = Some(new_body.clone());
1341 rev.edited = true;
1342 }
1343 }
1344 }
1345
909 s.last_updated = latest.map(|(_, raw)| raw).unwrap_or_default(); 1346 s.last_updated = latest.map(|(_, raw)| raw).unwrap_or_default();
910 } 1347 }
911 state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into()) 1348 state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into())
src/tui/mod.rs
Old New
@@ -1043,6 +1043,8 @@ mod tests {
1043 body: "Thread comment".into(), 1043 body: "Thread comment".into(),
1044 timestamp: "2026-01-05T00:00:00Z".into(), 1044 timestamp: "2026-01-05T00:00:00Z".into(),
1045 commit_id: Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(), 1045 commit_id: Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(),
1046 edited: false,
1047 deleted: false,
1046 }], 1048 }],
1047 inline_comments: vec![crate::state::InlineComment { 1049 inline_comments: vec![crate::state::InlineComment {
1048 author: make_author(), 1050 author: make_author(),
@@ -1051,6 +1053,9 @@ mod tests {
1051 body: "Nit: rename this".into(), 1053 body: "Nit: rename this".into(),
1052 timestamp: "2026-01-03T00:00:00Z".into(), 1054 timestamp: "2026-01-03T00:00:00Z".into(),
1053 revision: Some(1), 1055 revision: Some(1),
1056 commit_id: Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
1057 edited: false,
1058 deleted: false,
1054 }], 1059 }],
1055 reviews: vec![crate::state::Review { 1060 reviews: vec![crate::state::Review {
1056 author: make_author(), 1061 author: make_author(),
@@ -1058,6 +1063,8 @@ mod tests {
1058 body: "LGTM".into(), 1063 body: "LGTM".into(),
1059 timestamp: "2026-01-04T00:00:00Z".into(), 1064 timestamp: "2026-01-04T00:00:00Z".into(),
1060 revision: Some(2), 1065 revision: Some(2),
1066 commit_id: Oid::from_str("dddddddddddddddddddddddddddddddddddddddd").unwrap(),
1067 edited: false,
1061 }], 1068 }],
1062 revisions: vec![ 1069 revisions: vec![
1063 Revision { 1070 Revision {
@@ -1067,6 +1074,9 @@ mod tests {
1067 body: None, 1074 body: None,
1068 timestamp: "2026-01-01T00:00:00Z".into(), 1075 timestamp: "2026-01-01T00:00:00Z".into(),
1069 base: None, 1076 base: None,
1077 event_id: "eeee1111eeee1111eeee1111eeee1111eeee1111".into(),
1078 author: Some(make_author()),
1079 edited: false,
1070 }, 1080 },
1071 Revision { 1081 Revision {
1072 number: 2, 1082 number: 2,
@@ -1075,6 +1085,9 @@ mod tests {
1075 body: Some("Addressed review comments".into()), 1085 body: Some("Addressed review comments".into()),
1076 timestamp: "2026-01-02T00:00:00Z".into(), 1086 timestamp: "2026-01-02T00:00:00Z".into(),
1077 base: None, 1087 base: None,
1088 event_id: "eeee2222eeee2222eeee2222eeee2222eeee2222".into(),
1089 author: Some(make_author()),
1090 edited: false,
1078 }, 1091 },
1079 ], 1092 ],
1080 created_at: "2026-01-01T00:00:00Z".into(), 1093 created_at: "2026-01-01T00:00:00Z".into(),
src/tui/widgets.rs
Old New
@@ -7,6 +7,27 @@ use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus};
7 7
8 use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode}; 8 use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode};
9 9
10 /// The lines to render for a comment body: the text as it stands, or the
11 /// tombstone when it was deleted.
12 ///
13 /// A deleted comment's body is empty, so rendering it directly would print
14 /// the author and timestamp above nothing at all — indistinguishable from a
15 /// comment that said nothing, and unreadable as a reply's antecedent.
16 fn body_lines(body: &str, deleted: bool) -> Vec<String> {
17 if deleted {
18 return vec![crate::TOMBSTONE.to_string()];
19 }
20 body.lines().map(|l| l.to_string()).collect()
21 }
22
23 /// Trailing marker for a body that has been corrected since it was written.
24 fn edited_span(edited: bool) -> Span<'static> {
25 Span::styled(
26 if edited { " (edited)" } else { "" },
27 Style::default().fg(Color::DarkGray),
28 )
29 }
30
10 pub(crate) fn action_type_label(action: &Action) -> &str { 31 pub(crate) fn action_type_label(action: &Action) -> &str {
11 match action { 32 match action {
12 Action::IssueOpen { .. } => "Issue Open", 33 Action::IssueOpen { .. } => "Issue Open",
@@ -31,6 +52,8 @@ pub(crate) fn action_type_label(action: &Action) -> &str {
31 Action::IssueAssign { .. } => "Issue Assign", 52 Action::IssueAssign { .. } => "Issue Assign",
32 Action::IssueUnassign { .. } => "Issue Unassign", 53 Action::IssueUnassign { .. } => "Issue Unassign",
33 Action::IssueCommitLink { .. } => "Issue Commit Link", 54 Action::IssueCommitLink { .. } => "Issue Commit Link",
55 Action::BodyEdit { .. } => "Body Edit",
56 Action::CommentDelete { .. } => "Comment Delete",
34 } 57 }
35 } 58 }
36 59
@@ -129,6 +152,13 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str
129 Action::IssueCommitLink { commit } => { 152 Action::IssueCommitLink { commit } => {
130 detail.push_str(&format!("\nCommit: {}\n", commit)); 153 detail.push_str(&format!("\nCommit: {}\n", commit));
131 } 154 }
155 Action::BodyEdit { target, body } => {
156 detail.push_str(&format!("\nSupersedes: {:.8}\n", target));
157 detail.push_str(&format!("\n{}\n", body));
158 }
159 Action::CommentDelete { target } => {
160 detail.push_str(&format!("\nDeleted: {:.8}\n", target));
161 }
132 Action::PatchMerge { commit } => { 162 Action::PatchMerge { commit } => {
133 if !commit.is_empty() { 163 if !commit.is_empty() {
134 detail.push_str(&format!("\nMerged as: {}\n", commit)); 164 detail.push_str(&format!("\nMerged as: {}\n", commit));
@@ -472,8 +502,9 @@ fn build_issue_detail(
472 format!(" ({})", c.timestamp), 502 format!(" ({})", c.timestamp),
473 Style::default().fg(Color::DarkGray), 503 Style::default().fg(Color::DarkGray),
474 ), 504 ),
505 edited_span(c.edited),
475 ])); 506 ]));
476 for l in c.body.lines() { 507 for l in body_lines(&c.body, c.deleted) {
477 lines.push(Line::raw(format!(" {}", l))); 508 lines.push(Line::raw(format!(" {}", l)));
478 } 509 }
479 } 510 }
@@ -716,6 +747,7 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
716 Span::raw(format!(" {} ", review.timestamp)), 747 Span::raw(format!(" {} ", review.timestamp)),
717 Span::styled(verdict_str, Style::default().fg(verdict_color)), 748 Span::styled(verdict_str, Style::default().fg(verdict_color)),
718 Span::raw(rev_label), 749 Span::raw(rev_label),
750 edited_span(review.edited),
719 ])); 751 ]));
720 if !review.body.is_empty() { 752 if !review.body.is_empty() {
721 for l in review.body.lines() { 753 for l in review.body.lines() {
@@ -746,8 +778,9 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
746 ), 778 ),
747 Span::raw(format!(" {}:{}", ic.file, ic.line)), 779 Span::raw(format!(" {}:{}", ic.file, ic.line)),
748 Span::raw(rev_label), 780 Span::raw(rev_label),
781 edited_span(ic.edited),
749 ])); 782 ]));
750 for l in ic.body.lines() { 783 for l in body_lines(&ic.body, ic.deleted) {
751 lines.push(Line::raw(format!(" {}", l))); 784 lines.push(Line::raw(format!(" {}", l)));
752 } 785 }
753 } 786 }
@@ -773,8 +806,9 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
773 format!(" ({})", c.timestamp), 806 format!(" ({})", c.timestamp),
774 Style::default().fg(Color::DarkGray), 807 Style::default().fg(Color::DarkGray),
775 ), 808 ),
809 edited_span(c.edited),
776 ])); 810 ]));
777 for l in c.body.lines() { 811 for l in body_lines(&c.body, c.deleted) {
778 lines.push(Line::raw(format!(" {}", l))); 812 lines.push(Line::raw(format!(" {}", l)));
779 } 813 }
780 } 814 }
tests/body_edit_test.rs
Old New
@@ -0,0 +1,860 @@
1 //! Correcting prose that is already in the DAG.
2 //!
3 //! Events are append-only and stay that way: an edit is a new event that
4 //! supersedes an earlier one's body, exactly as `IssueEdit` already
5 //! supersedes an issue's title and body. The same single mechanism covers
6 //! thread comments, inline comments, review bodies and revision
7 //! descriptions, because they are all just a body hanging off an event OID.
8 //!
9 //! Three properties are load-bearing and each is tested here:
10 //!
11 //! * **Attribution.** An edit signed by a different key must not silently
12 //! rewrite someone else's words. The fold ignores it.
13 //! * **Ordering.** An edited comment stays where it was, so a reply above or
14 //! below it still reads in sequence.
15 //! * **Convergence.** Two clones editing the same comment offline settle on
16 //! the same text, decided by `(clock, oid)` like every other conflict here.
17
18 mod common;
19
20 use common::{alice, bob, init_repo, test_signing_key, TestRepo};
21
22 use git2::Repository;
23 use git_collab::dag;
24 use git_collab::event::{Action, Author, Event, ReviewVerdict};
25 use git_collab::state::{IssueState, PatchState};
26 use tempfile::TempDir;
27
28 // ===========================================================================
29 // Helpers for driving the DAG directly (used by the attribution and
30 // convergence tests, which need to forge events from a second identity).
31 // ===========================================================================
32
33 fn append(repo: &Repository, ref_name: &str, author: &Author, action: Action) -> git2::Oid {
34 let sk = test_signing_key();
35 let event = Event {
36 timestamp: common::now(),
37 author: author.clone(),
38 action,
39 clock: 0,
40 };
41 dag::append_event(repo, ref_name, &event, &sk).unwrap()
42 }
43
44 fn open_issue_with_comment(repo: &Repository) -> (String, String, git2::Oid) {
45 let (ref_name, id) = common::open_issue(repo, &alice(), "issue under test");
46 let comment_oid = append(
47 repo,
48 &ref_name,
49 &alice(),
50 Action::IssueComment {
51 body: "original text".to_string(),
52 },
53 );
54 (ref_name, id, comment_oid)
55 }
56
57 fn issue_state(repo: &Repository, ref_name: &str, id: &str) -> IssueState {
58 IssueState::from_ref_uncached(repo, ref_name, id).unwrap()
59 }
60
61 /// Pull the short comment id `patch show` / `issue show` prints, for the
62 /// first comment listed.
63 fn first_comment_id(json: &str, list: &str) -> String {
64 let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
65 value[list][0]["commit_id"]
66 .as_str()
67 .unwrap_or_else(|| panic!("no commit_id on {}[0] in {}", list, json))
68 .to_string()
69 }
70
71 fn body_at(json: &str, pointer: &str) -> String {
72 let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
73 value
74 .pointer(pointer)
75 .unwrap_or_else(|| panic!("no {} in {}", pointer, json))
76 .as_str()
77 .expect("string")
78 .to_string()
79 }
80
81 // ===========================================================================
82 // Editing, end to end
83 // ===========================================================================
84
85 #[test]
86 fn an_issue_comment_can_be_corrected() {
87 let repo = TestRepo::new("Alice", "alice@example.com");
88 let id = repo.issue_open("typo");
89 repo.run_ok(&["issue", "comment", &id, "-b", "teh original"]);
90
91 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
92 let comment_id = first_comment_id(&json, "comments");
93
94 repo.run_ok(&[
95 "issue",
96 "edit-comment",
97 &id,
98 &comment_id[..8],
99 "-b",
100 "the original",
101 ]);
102
103 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
104 assert_eq!(body_at(&json, "/comments/0/body"), "the original");
105 assert_eq!(
106 body_at(&json, "/comments/0/author/email"),
107 "alice@example.com",
108 "an edit must not change who wrote the comment"
109 );
110 }
111
112 #[test]
113 fn a_patch_thread_comment_can_be_corrected() {
114 let repo = TestRepo::new("Alice", "alice@example.com");
115 let id = repo.patch_create("thread edit");
116 repo.run_ok(&["patch", "comment", &id, "-b", "wrong"]);
117
118 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
119 let comment_id = first_comment_id(&json, "comments");
120
121 repo.run_ok(&["patch", "edit-comment", &id, &comment_id[..8], "-b", "right"]);
122
123 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
124 assert_eq!(body_at(&json, "/comments/0/body"), "right");
125 }
126
127 #[test]
128 fn an_inline_comment_can_be_corrected_without_moving() {
129 let repo = TestRepo::new("Alice", "alice@example.com");
130 let id = repo.patch_create("inline edit");
131 repo.run_ok(&[
132 "patch", "comment", &id, "--file", "a.rs", "--line", "7", "-b", "wrong",
133 ]);
134
135 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
136 let comment_id = first_comment_id(&json, "inline_comments");
137
138 repo.run_ok(&["patch", "edit-comment", &id, &comment_id[..8], "-b", "right"]);
139
140 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
141 assert_eq!(body_at(&json, "/inline_comments/0/body"), "right");
142 assert_eq!(
143 body_at(&json, "/inline_comments/0/file"),
144 "a.rs",
145 "an edit touches the body and nothing else"
146 );
147 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
148 assert_eq!(value["inline_comments"][0]["line"], 7);
149 }
150
151 /// The case the issue was actually filed about: a typo in a review body.
152 #[test]
153 fn a_review_body_can_be_corrected_without_changing_the_verdict() {
154 let repo = TestRepo::new("Alice", "alice@example.com");
155 let id = repo.patch_create("review edit");
156 repo.run_ok(&[
157 "patch",
158 "review",
159 &id,
160 "-v",
161 "request-changes",
162 "-b",
163 "probe comment, please ignore",
164 ]);
165
166 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
167 let review_id = first_comment_id(&json, "reviews");
168
169 repo.run_ok(&[
170 "patch",
171 "edit-comment",
172 &id,
173 &review_id[..8],
174 "-b",
175 "the real review",
176 ]);
177
178 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
179 assert_eq!(body_at(&json, "/reviews/0/body"), "the real review");
180 assert_eq!(
181 body_at(&json, "/reviews/0/verdict"),
182 "request-changes",
183 "editing the prose must not disturb the vote"
184 );
185 }
186
187 #[test]
188 fn an_edit_reads_its_body_from_a_file_or_stdin_too() {
189 let repo = TestRepo::new("Alice", "alice@example.com");
190 let id = repo.patch_create("edit from stdin");
191 repo.run_ok(&["patch", "comment", &id, "-b", "wrong"]);
192
193 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
194 let comment_id = first_comment_id(&json, "comments");
195
196 let corrected = "corrected `body` with $vars\n\n";
197 repo.run_stdin_ok(
198 &["patch", "edit-comment", &id, &comment_id[..8], "-F", "-"],
199 corrected.as_bytes(),
200 );
201
202 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
203 assert_eq!(body_at(&json, "/comments/0/body"), corrected);
204 }
205
206 /// Editing with no body given opens the editor seeded with the text as it
207 /// stands, which is what makes fixing a typo a matter of one keystroke.
208 #[test]
209 fn editing_with_no_body_opens_the_editor_seeded_with_the_current_text() {
210 let repo = TestRepo::new("Alice", "alice@example.com");
211 let id = repo.issue_open("seeded editor");
212 repo.run_ok(&["issue", "comment", &id, "-b", "seed me"]);
213
214 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
215 let comment_id = first_comment_id(&json, "comments");
216
217 // Echo back what the editor was handed, with a suffix, so the test can
218 // prove the buffer arrived pre-filled.
219 let editor = repo.write_script(
220 "seed-editor.sh",
221 "#!/bin/sh\nprintf '%s and more' \"$(cat \"$1\")\" > \"$1\"\n",
222 );
223
224 repo.run_in_pty(
225 &["issue", "edit-comment", &id, &comment_id[..8]],
226 &editor,
227 );
228
229 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
230 assert_eq!(body_at(&json, "/comments/0/body"), "seed me and more");
231 }
232
233 #[test]
234 fn editing_an_unknown_comment_is_an_error() {
235 let repo = TestRepo::new("Alice", "alice@example.com");
236 let id = repo.patch_create("unknown target");
237
238 let err = repo.run_err(&["patch", "edit-comment", &id, "deadbeef", "-b", "x"]);
239 assert!(
240 err.contains("deadbeef"),
241 "error should name the id that matched nothing: {}",
242 err
243 );
244 }
245
246 // ===========================================================================
247 // Deleting: a tombstone, not a disappearance
248 // ===========================================================================
249
250 /// A deleted comment leaves a tombstone. Others may have replied to it, the
251 /// event is still in the DAG either way, and a comment that silently
252 /// vanishes makes every reply to it reference nothing.
253 #[test]
254 fn a_deleted_comment_leaves_a_tombstone_in_place() {
255 let repo = TestRepo::new("Alice", "alice@example.com");
256 let id = repo.patch_create("tombstone");
257 repo.run_ok(&["patch", "comment", &id, "-b", "first"]);
258 repo.run_ok(&["patch", "comment", &id, "-b", "regrettable probe"]);
259 repo.run_ok(&["patch", "comment", &id, "-b", "third, replying to the above"]);
260
261 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
262 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
263 let target = value["comments"][1]["commit_id"].as_str().unwrap().to_string();
264
265 repo.run_ok(&["patch", "delete-comment", &id, &target[..8]]);
266
267 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
268 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
269 let comments = value["comments"].as_array().unwrap();
270
271 assert_eq!(
272 comments.len(),
273 3,
274 "the tombstone keeps the comment's slot so replies still line up"
275 );
276 assert_eq!(comments[0]["body"], "first");
277 assert_eq!(comments[2]["body"], "third, replying to the above");
278 assert_eq!(
279 comments[1]["deleted"], true,
280 "the middle comment is marked deleted"
281 );
282 assert_eq!(
283 comments[1]["body"], "",
284 "the deleted text must be gone from derived state, not merely hidden"
285 );
286 assert_eq!(
287 comments[1]["author"]["email"], "alice@example.com",
288 "a tombstone still says who left the comment"
289 );
290 }
291
292 #[test]
293 fn a_deleted_comment_is_shown_as_deleted_not_as_blank() {
294 let repo = TestRepo::new("Alice", "alice@example.com");
295 let id = repo.patch_create("tombstone display");
296 repo.run_ok(&["patch", "comment", &id, "-b", "regrettable"]);
297
298 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
299 let target = first_comment_id(&json, "comments");
300 repo.run_ok(&["patch", "delete-comment", &id, &target[..8]]);
301
302 let out = repo.run_ok(&["patch", "show", &id]);
303 assert!(
304 out.contains("[deleted]"),
305 "a deleted comment should read as deleted: {}",
306 out
307 );
308 assert!(
309 !out.contains("regrettable"),
310 "the deleted text must not still be rendered: {}",
311 out
312 );
313 }
314
315 /// Deleted text must not leak out through the side doors either.
316 #[test]
317 fn a_deleted_comment_stops_matching_search() {
318 let repo = TestRepo::new("Alice", "alice@example.com");
319 let id = repo.patch_create("search leak");
320 repo.run_ok(&["patch", "comment", &id, "-b", "chartreuse blunder"]);
321
322 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
323 let target = first_comment_id(&json, "comments");
324
325 let out = repo.run_ok(&["search", "chartreuse"]);
326 assert!(out.contains("comment match"), "precondition: {}", out);
327
328 repo.run_ok(&["patch", "delete-comment", &id, &target[..8]]);
329 let out = repo.run_ok(&["search", "chartreuse"]);
330 assert!(
331 !out.contains("comment match"),
332 "deleted text must not remain searchable: {}",
333 out
334 );
335 }
336
337 /// A review carries a vote, so removing it would silently drop a verdict.
338 /// Change the vote with a new review instead.
339 #[test]
340 fn a_review_cannot_be_deleted() {
341 let repo = TestRepo::new("Alice", "alice@example.com");
342 let id = repo.patch_create("no review delete");
343 repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "lgtm"]);
344
345 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
346 let review_id = first_comment_id(&json, "reviews");
347
348 let err = repo.run_err(&["patch", "delete-comment", &id, &review_id[..8]]);
349 assert!(
350 err.contains("review"),
351 "the refusal should explain that this is a review: {}",
352 err
353 );
354 }
355
356 // ===========================================================================
357 // Attribution: a different key must not rewrite someone else's words
358 // ===========================================================================
359
360 /// The fold is what every reader sees, and anyone can append anything to a
361 /// DAG they have a copy of. So the rule has to hold in the fold, not only at
362 /// the CLI: an edit whose author is not the comment's author is ignored.
363 #[test]
364 fn an_edit_by_a_different_author_is_ignored_by_the_fold() {
365 let dir = TempDir::new().unwrap();
366 let repo = init_repo(dir.path(), &alice());
367 let (ref_name, id, comment_oid) = open_issue_with_comment(&repo);
368
369 append(
370 &repo,
371 &ref_name,
372 &bob(),
373 Action::BodyEdit {
374 target: comment_oid.to_string(),
375 body: "Bob's forgery".to_string(),
376 },
377 );
378
379 let state = issue_state(&repo, &ref_name, &id);
380 assert_eq!(
381 state.comments[0].body, "original text",
382 "an edit from another key must not rewrite the comment"
383 );
384 assert!(
385 !state.comments[0].edited,
386 "and must not even mark it as edited"
387 );
388 }
389
390 #[test]
391 fn a_delete_by_a_different_author_is_ignored_by_the_fold() {
392 let dir = TempDir::new().unwrap();
393 let repo = init_repo(dir.path(), &alice());
394 let (ref_name, id, comment_oid) = open_issue_with_comment(&repo);
395
396 append(
397 &repo,
398 &ref_name,
399 &bob(),
400 Action::CommentDelete {
401 target: comment_oid.to_string(),
402 },
403 );
404
405 let state = issue_state(&repo, &ref_name, &id);
406 assert_eq!(state.comments[0].body, "original text");
407 assert!(!state.comments[0].deleted);
408 }
409
410 /// An honest user gets told, rather than silently no-op'd.
411 #[test]
412 fn the_cli_refuses_to_edit_someone_elses_comment() {
413 let repo = TestRepo::new("Alice", "alice@example.com");
414 let id = repo.patch_create("not yours");
415 repo.run_ok(&["patch", "comment", &id, "-b", "Alice wrote this"]);
416
417 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
418 let comment_id = first_comment_id(&json, "comments");
419
420 // Same repo, different committer identity.
421 repo.git(&["config", "user.name", "Bob"]);
422 repo.git(&["config", "user.email", "bob@example.com"]);
423
424 let err = repo.run_err(&[
425 "patch",
426 "edit-comment",
427 &id,
428 &comment_id[..8],
429 "-b",
430 "Bob rewrites history",
431 ]);
432 assert!(
433 err.contains("alice@example.com"),
434 "the refusal should name the author who owns the comment: {}",
435 err
436 );
437
438 repo.git(&["config", "user.name", "Alice"]);
439 repo.git(&["config", "user.email", "alice@example.com"]);
440 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
441 assert_eq!(body_at(&json, "/comments/0/body"), "Alice wrote this");
442 }
443
444 // ===========================================================================
445 // Ordering and convergence
446 // ===========================================================================
447
448 #[test]
449 fn an_edited_comment_keeps_its_position() {
450 let repo = TestRepo::new("Alice", "alice@example.com");
451 let id = repo.issue_open("ordering");
452 repo.run_ok(&["issue", "comment", &id, "-b", "first"]);
453 repo.run_ok(&["issue", "comment", &id, "-b", "second"]);
454 repo.run_ok(&["issue", "comment", &id, "-b", "third"]);
455
456 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
457 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
458 let target = value["comments"][0]["commit_id"].as_str().unwrap().to_string();
459
460 repo.run_ok(&["issue", "edit-comment", &id, &target[..8], "-b", "FIRST"]);
461
462 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
463 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
464 let bodies: Vec<&str> = value["comments"]
465 .as_array()
466 .unwrap()
467 .iter()
468 .map(|c| c["body"].as_str().unwrap())
469 .collect();
470 assert_eq!(
471 bodies,
472 vec!["FIRST", "second", "third"],
473 "an edited comment stays where it was"
474 );
475 }
476
477 /// Two clones edit the same comment while apart. Once they see each other's
478 /// events, every clone must land on the same text — decided by `(clock, oid)`
479 /// over the event set, which is content-derived and so client-independent.
480 #[test]
481 fn concurrent_edits_of_one_comment_converge() {
482 let dir = TempDir::new().unwrap();
483 let repo = init_repo(dir.path(), &alice());
484 let (ref_name, id, comment_oid) = open_issue_with_comment(&repo);
485 let fork_point = repo.refname_to_id(&ref_name).unwrap();
486
487 // Alice's clone edits.
488 let a_edit = append(
489 &repo,
490 &ref_name,
491 &alice(),
492 Action::BodyEdit {
493 target: comment_oid.to_string(),
494 body: "edit from clone A".to_string(),
495 },
496 );
497 let a_tip = repo.refname_to_id(&ref_name).unwrap();
498
499 // Her other clone, offline, edits the same comment from the fork point.
500 let branch_ref = "refs/collab/issues/other-clone";
501 repo.reference(branch_ref, fork_point, false, "fork").unwrap();
502 let b_edit = append(
503 &repo,
504 branch_ref,
505 &alice(),
506 Action::BodyEdit {
507 target: comment_oid.to_string(),
508 body: "edit from clone B".to_string(),
509 },
510 );
511
512 // Reconcile in both directions and check the two agree.
513 let sk = test_signing_key();
514 let merged_ref = "refs/collab/issues/merged";
515 repo.reference(merged_ref, a_tip, false, "copy").unwrap();
516 dag::reconcile(&repo, merged_ref, branch_ref, &alice(), &sk).unwrap();
517 let one = issue_state(&repo, merged_ref, &id);
518
519 let other_ref = "refs/collab/issues/merged-other-way";
520 repo.reference(other_ref, b_edit, false, "copy").unwrap();
521 dag::reconcile(&repo, other_ref, &ref_name, &alice(), &sk).unwrap();
522 let two = issue_state(&repo, other_ref, &id);
523
524 assert_eq!(
525 one.comments[0].body, two.comments[0].body,
526 "both join orders must fold to the same text"
527 );
528
529 // And the winner is the one decided by (clock, oid): equal clocks here,
530 // so the lexicographically larger OID wins.
531 let expected = if a_edit.to_string() > b_edit.to_string() {
532 "edit from clone A"
533 } else {
534 "edit from clone B"
535 };
536 assert_eq!(one.comments[0].body, expected);
537 }
538
539 /// A delete racing an edit is the same conflict, resolved the same way.
540 #[test]
541 fn a_delete_and_an_edit_racing_converge() {
542 let dir = TempDir::new().unwrap();
543 let repo = init_repo(dir.path(), &alice());
544 let (ref_name, id, comment_oid) = open_issue_with_comment(&repo);
545 let fork_point = repo.refname_to_id(&ref_name).unwrap();
546
547 let edit_oid = append(
548 &repo,
549 &ref_name,
550 &alice(),
551 Action::BodyEdit {
552 target: comment_oid.to_string(),
553 body: "kept after all".to_string(),
554 },
555 );
556 let a_tip = repo.refname_to_id(&ref_name).unwrap();
557
558 let branch_ref = "refs/collab/issues/delete-clone";
559 repo.reference(branch_ref, fork_point, false, "fork").unwrap();
560 let delete_oid = append(
561 &repo,
562 branch_ref,
563 &alice(),
564 Action::CommentDelete {
565 target: comment_oid.to_string(),
566 },
567 );
568
569 let sk = test_signing_key();
570 let merged_ref = "refs/collab/issues/dm";
571 repo.reference(merged_ref, a_tip, false, "copy").unwrap();
572 dag::reconcile(&repo, merged_ref, branch_ref, &alice(), &sk).unwrap();
573 let one = issue_state(&repo, merged_ref, &id);
574
575 let other_ref = "refs/collab/issues/dm-other";
576 repo.reference(other_ref, delete_oid, false, "copy").unwrap();
577 dag::reconcile(&repo, other_ref, &ref_name, &alice(), &sk).unwrap();
578 let two = issue_state(&repo, other_ref, &id);
579
580 assert_eq!(one.comments[0].deleted, two.comments[0].deleted);
581 assert_eq!(one.comments[0].body, two.comments[0].body);
582
583 let delete_wins = delete_oid.to_string() > edit_oid.to_string();
584 assert_eq!(one.comments[0].deleted, delete_wins);
585 }
586
587 // ===========================================================================
588 // Revision bodies (7a299d2c)
589 // ===========================================================================
590
591 #[test]
592 fn a_revision_body_can_be_corrected() {
593 let repo = TestRepo::new("Alice", "alice@example.com");
594 let id = repo.patch_create("revision body");
595
596 repo.git(&["checkout", "test/revision-body"]);
597 repo.commit_file("more.txt", "more", "second commit");
598 repo.run_ok(&["patch", "revise", &id, "-b", "test"]);
599 repo.git(&["checkout", "main"]);
600
601 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
602 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
603 let commit_before = value["revisions"][1]["commit"].as_str().unwrap().to_string();
604 assert_eq!(value["revisions"][1]["body"], "test");
605
606 repo.run_ok(&[
607 "patch",
608 "edit-revision",
609 &id,
610 "2",
611 "-b",
612 "Rework the trailer scan in response to review",
613 ]);
614
615 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
616 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
617 assert_eq!(
618 value["revisions"][1]["body"],
619 "Rework the trailer scan in response to review"
620 );
621 assert_eq!(
622 value["revisions"][1]["commit"], commit_before,
623 "the revision's commit is immutable; only its description changes"
624 );
625 assert_eq!(
626 value["revisions"].as_array().unwrap().len(),
627 2,
628 "correcting a body must not manufacture a revision"
629 );
630 }
631
632 #[test]
633 fn editing_an_unknown_revision_is_an_error() {
634 let repo = TestRepo::new("Alice", "alice@example.com");
635 let id = repo.patch_create("no such revision");
636
637 let err = repo.run_err(&["patch", "edit-revision", &id, "9", "-b", "x"]);
638 assert!(
639 err.contains('9'),
640 "error should name the revision that does not exist: {}",
641 err
642 );
643 }
644
645 #[test]
646 fn a_revision_body_edit_by_a_different_author_is_ignored() {
647 let dir = TempDir::new().unwrap();
648 let repo = init_repo(dir.path(), &alice());
649 let (ref_name, id) = common::create_patch(&repo, &alice(), "revision attribution");
650
651 let revision_oid = append(
652 &repo,
653 &ref_name,
654 &alice(),
655 Action::PatchRevision {
656 commit: "a".repeat(40),
657 tree: "b".repeat(40),
658 body: Some("mine".to_string()),
659 base: None,
660 },
661 );
662 append(
663 &repo,
664 &ref_name,
665 &bob(),
666 Action::BodyEdit {
667 target: revision_oid.to_string(),
668 body: "Bob's rewrite".to_string(),
669 },
670 );
671
672 let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
673 assert_eq!(state.revisions[1].body.as_deref(), Some("mine"));
674 }
675
676 // ===========================================================================
677 // Reading must never write
678 // ===========================================================================
679
680 /// This project has shipped a "display appends an event" bug before. Every
681 /// read path is checked against the events ref, which is the one that carries
682 /// history: showing, diffing and logging a patch must leave it exactly where
683 /// it was.
684 #[test]
685 fn reading_a_patch_never_moves_its_event_ref() {
686 let repo = TestRepo::new("Alice", "alice@example.com");
687 let id = repo.patch_create("read only");
688 repo.run_ok(&["patch", "comment", &id, "-b", "a comment"]);
689 repo.run_ok(&["patch", "review", &id, "-v", "comment", "-b", "a review"]);
690
691 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
692 let comment_id = first_comment_id(&json, "comments");
693 repo.run_ok(&["patch", "delete-comment", &id, &comment_id[..8]]);
694
695 let events_ref = repo
696 .git(&["for-each-ref", "--format=%(refname)", "refs/collab/patches/"])
697 .lines()
698 .find(|l| l.ends_with("/events"))
699 .expect("patch events ref")
700 .to_string();
701
702 let before = repo.git(&["rev-parse", &events_ref]).trim().to_string();
703
704 repo.run_ok(&["patch", "show", &id]);
705 repo.run_ok(&["patch", "show", &id, "--json"]);
706 repo.run_ok(&["patch", "log", &id]);
707 repo.run_ok(&["patch", "log", &id, "--json"]);
708 repo.run_ok(&["patch", "diff", &id]);
709 repo.run_ok(&["patch", "list"]);
710 repo.run_ok(&["search", "comment"]);
711
712 let after = repo.git(&["rev-parse", &events_ref]).trim().to_string();
713 assert_eq!(before, after, "reading a patch must not append events");
714 }
715
716 #[test]
717 fn reading_an_issue_never_moves_its_ref() {
718 let repo = TestRepo::new("Alice", "alice@example.com");
719 let id = repo.issue_open("read only issue");
720 repo.run_ok(&["issue", "comment", &id, "-b", "a comment"]);
721
722 let issue_ref = repo
723 .git(&["for-each-ref", "--format=%(refname)", "refs/collab/issues/"])
724 .lines()
725 .next()
726 .expect("issue ref")
727 .to_string();
728 let before = repo.git(&["rev-parse", &issue_ref]).trim().to_string();
729
730 repo.run_ok(&["issue", "show", &id]);
731 repo.run_ok(&["issue", "show", &id, "--json"]);
732 repo.run_ok(&["issue", "list"]);
733 repo.run_ok(&["search", "comment"]);
734
735 let after = repo.git(&["rev-parse", &issue_ref]).trim().to_string();
736 assert_eq!(before, after, "reading an issue must not append events");
737 }
738
739 // ===========================================================================
740 // The original events stay in the DAG
741 // ===========================================================================
742
743 /// Editing supersedes; it does not rewrite. The original event is still
744 /// there, which is what makes the log an audit trail rather than a summary.
745 #[test]
746 fn an_edit_leaves_the_original_event_in_the_log() {
747 let repo = TestRepo::new("Alice", "alice@example.com");
748 let id = repo.issue_open("audit trail");
749 repo.run_ok(&["issue", "comment", &id, "-b", "the original wording"]);
750
751 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
752 let comment_id = first_comment_id(&json, "comments");
753 repo.run_ok(&[
754 "issue",
755 "edit-comment",
756 &id,
757 &comment_id[..8],
758 "-b",
759 "the revised wording",
760 ]);
761
762 let log = repo.run_ok(&["log"]);
763 assert!(
764 log.contains("IssueComment") && log.contains("BodyEdit"),
765 "both events should appear in the raw event log: {}",
766 log
767 );
768 assert!(
769 log.contains("the original wording") && log.contains("the revised wording"),
770 "the log should show what was said before and after: {}",
771 log
772 );
773 assert!(
774 log.contains(&format!("edit body of {:.8}", comment_id)),
775 "the edit should name the event it supersedes: {}",
776 log
777 );
778
779 // And the original body is still readable from the event object itself.
780 let blob = repo.git(&["show", &format!("{}:event.json", comment_id)]);
781 assert!(
782 blob.contains("the original wording"),
783 "the superseded event is unchanged in the DAG"
784 );
785 }
786
787 #[test]
788 fn an_edited_comment_is_marked_as_edited() {
789 let repo = TestRepo::new("Alice", "alice@example.com");
790 let id = repo.issue_open("edited marker");
791 repo.run_ok(&["issue", "comment", &id, "-b", "before"]);
792
793 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
794 let comment_id = first_comment_id(&json, "comments");
795 repo.run_ok(&["issue", "edit-comment", &id, &comment_id[..8], "-b", "after"]);
796
797 let out = repo.run_ok(&["issue", "show", &id]);
798 assert!(
799 out.contains("edited"),
800 "a corrected comment should say so: {}",
801 out
802 );
803 }
804
805 /// Comment ids have to be visible, or nothing above can be addressed.
806 #[test]
807 fn show_prints_addressable_comment_ids() {
808 let repo = TestRepo::new("Alice", "alice@example.com");
809 let id = repo.patch_create("ids");
810 repo.run_ok(&["patch", "comment", &id, "-b", "thread"]);
811 repo.run_ok(&[
812 "patch", "comment", &id, "--file", "a.rs", "--line", "1", "-b", "inline",
813 ]);
814 repo.run_ok(&["patch", "review", &id, "-v", "comment", "-b", "review"]);
815
816 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
817 let out = repo.run_ok(&["patch", "show", &id]);
818
819 for list in ["comments", "inline_comments", "reviews"] {
820 let full = first_comment_id(&json, list);
821 assert!(
822 out.contains(&full[..8]),
823 "`patch show` should print the short id for {}: {}",
824 list,
825 out
826 );
827 }
828 }
829
830 /// A verdict that is not a comment still needs its author checked, so make
831 /// sure the plumbing for reviews goes through the same rule.
832 #[test]
833 fn a_review_edit_by_a_different_author_is_ignored_by_the_fold() {
834 let dir = TempDir::new().unwrap();
835 let repo = init_repo(dir.path(), &alice());
836 let (ref_name, id) = common::create_patch(&repo, &alice(), "review attribution");
837
838 let review_oid = append(
839 &repo,
840 &ref_name,
841 &alice(),
842 Action::PatchReview {
843 verdict: ReviewVerdict::Comment,
844 body: "Alice's review".to_string(),
845 revision: Some(1),
846 },
847 );
848 append(
849 &repo,
850 &ref_name,
851 &bob(),
852 Action::BodyEdit {
853 target: review_oid.to_string(),
854 body: "Bob's rewrite".to_string(),
855 },
856 );
857
858 let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
859 assert_eq!(state.reviews[0].body, "Alice's review");
860 }
tests/body_input_test.rs
Old New
@@ -0,0 +1,324 @@
1 //! Prose gets into git-collab through more than one door.
2 //!
3 //! Every command that takes `--body` must also accept `--body-file <path>`
4 //! and `--body-file -` for stdin, following git's own `-F` convention, and
5 //! must open `$EDITOR` when an interactive user gives no body at all.
6 //!
7 //! The load-bearing property throughout is that whatever goes in comes back
8 //! out *byte for byte*: trailing newlines, CRLF, tabs, non-ASCII and shell
9 //! metacharacters all survive the round trip through the event DAG. That is
10 //! the class of corruption nobody notices until they read a review three
11 //! weeks later.
12
13 mod common;
14
15 use common::TestRepo;
16
17 /// A body designed to be hostile to every layer it passes through: shell
18 /// metacharacters, quotes of both kinds, a `$`, a backtick, a blank line, a
19 /// tab, non-ASCII (including a combining mark and an emoji), a CRLF, and
20 /// two trailing newlines.
21 const NASTY: &str = "Line with `backticks` and $VARS and \"quotes\" and 'single'\n\nDoes this work? Yes * always!\n\tTabbed\r\nCRLF above\nnaïve café — ünïcödé 🎉 e\u{0301}\n\n";
22
23 /// Pull a JSON string field out of `... --json` output.
24 fn json_field(json: &str, pointer: &str) -> String {
25 let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
26 value
27 .pointer(pointer)
28 .unwrap_or_else(|| panic!("no {} in {}", pointer, json))
29 .as_str()
30 .expect("string field")
31 .to_string()
32 }
33
34 // ===========================================================================
35 // --body-file <path>
36 // ===========================================================================
37
38 #[test]
39 fn issue_comment_reads_body_from_a_file_byte_for_byte() {
40 let repo = TestRepo::new("Alice", "alice@example.com");
41 let id = repo.issue_open("nasty body");
42
43 let path = repo.dir.path().join("body.txt");
44 std::fs::write(&path, NASTY).unwrap();
45
46 repo.run_ok(&["issue", "comment", &id, "-F", path.to_str().unwrap()]);
47
48 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
49 assert_eq!(json_field(&json, "/comments/0/body"), NASTY);
50 }
51
52 #[test]
53 fn body_file_has_a_long_form_too() {
54 let repo = TestRepo::new("Alice", "alice@example.com");
55 let id = repo.issue_open("long form");
56
57 let path = repo.dir.path().join("body.txt");
58 std::fs::write(&path, "from the long form\n").unwrap();
59
60 repo.run_ok(&[
61 "issue",
62 "comment",
63 &id,
64 "--body-file",
65 path.to_str().unwrap(),
66 ]);
67
68 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
69 assert_eq!(json_field(&json, "/comments/0/body"), "from the long form\n");
70 }
71
72 #[test]
73 fn missing_body_file_reports_the_path() {
74 let repo = TestRepo::new("Alice", "alice@example.com");
75 let id = repo.issue_open("missing file");
76
77 let err = repo.run_err(&["issue", "comment", &id, "-F", "/nonexistent/body.txt"]);
78 assert!(
79 err.contains("/nonexistent/body.txt"),
80 "error should name the path it could not read: {}",
81 err
82 );
83 }
84
85 // ===========================================================================
86 // --body-file - (stdin)
87 // ===========================================================================
88
89 #[test]
90 fn issue_comment_reads_body_from_stdin_byte_for_byte() {
91 let repo = TestRepo::new("Alice", "alice@example.com");
92 let id = repo.issue_open("stdin body");
93
94 repo.run_stdin_ok(&["issue", "comment", &id, "-F", "-"], NASTY.as_bytes());
95
96 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
97 assert_eq!(json_field(&json, "/comments/0/body"), NASTY);
98 }
99
100 #[test]
101 fn patch_review_reads_body_from_stdin() {
102 let repo = TestRepo::new("Alice", "alice@example.com");
103 let id = repo.patch_create("review from stdin");
104
105 repo.run_stdin_ok(
106 &["patch", "review", &id, "-v", "comment", "-F", "-"],
107 NASTY.as_bytes(),
108 );
109
110 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
111 assert_eq!(json_field(&json, "/reviews/0/body"), NASTY);
112 }
113
114 #[test]
115 fn patch_comment_reads_body_from_stdin() {
116 let repo = TestRepo::new("Alice", "alice@example.com");
117 let id = repo.patch_create("comment from stdin");
118
119 repo.run_stdin_ok(&["patch", "comment", &id, "-F", "-"], NASTY.as_bytes());
120
121 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
122 assert_eq!(json_field(&json, "/comments/0/body"), NASTY);
123 }
124
125 #[test]
126 fn patch_inline_comment_reads_body_from_stdin() {
127 let repo = TestRepo::new("Alice", "alice@example.com");
128 let id = repo.patch_create("inline from stdin");
129
130 repo.run_stdin_ok(
131 &[
132 "patch", "comment", &id, "--file", "src/x.rs", "--line", "3", "-F", "-",
133 ],
134 NASTY.as_bytes(),
135 );
136
137 let json = repo.run_ok(&["patch", "show", &id, "--json"]);
138 assert_eq!(json_field(&json, "/inline_comments/0/body"), NASTY);
139 }
140
141 #[test]
142 fn stdin_body_survives_on_every_command_that_takes_one() {
143 let repo = TestRepo::new("Alice", "alice@example.com");
144
145 // issue open
146 let out = repo.run_stdin_ok(
147 &["issue", "open", "-t", "opened from stdin", "-F", "-"],
148 NASTY.as_bytes(),
149 );
150 let issue_id = out.trim().strip_prefix("Opened issue ").unwrap().to_string();
151 let json = repo.run_ok(&["issue", "show", &issue_id, "--json"]);
152 assert_eq!(json_field(&json, "/body"), NASTY, "issue open");
153
154 // issue edit
155 repo.run_stdin_ok(
156 &["issue", "edit", &issue_id, "-F", "-"],
157 b"edited from stdin\n\n",
158 );
159 let json = repo.run_ok(&["issue", "show", &issue_id, "--json"]);
160 assert_eq!(json_field(&json, "/body"), "edited from stdin\n\n");
161
162 // patch create
163 repo.git(&["checkout", "-b", "stdin-patch"]);
164 repo.commit_file("stdin.txt", "x", "stdin patch commit");
165 let out = repo.run_stdin_ok(
166 &[
167 "patch",
168 "create",
169 "-t",
170 "created from stdin",
171 "-B",
172 "stdin-patch",
173 "-F",
174 "-",
175 ],
176 NASTY.as_bytes(),
177 );
178 repo.git(&["checkout", "main"]);
179 let patch_id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
180 let json = repo.run_ok(&["patch", "show", &patch_id, "--json"]);
181 assert_eq!(json_field(&json, "/body"), NASTY, "patch create");
182
183 // patch revise
184 repo.git(&["checkout", "stdin-patch"]);
185 repo.commit_file("stdin2.txt", "y", "second stdin commit");
186 repo.run_stdin_ok(&["patch", "revise", &patch_id, "-F", "-"], NASTY.as_bytes());
187 repo.git(&["checkout", "main"]);
188 let json = repo.run_ok(&["patch", "show", &patch_id, "--json"]);
189 assert_eq!(json_field(&json, "/revisions/1/body"), NASTY, "patch revise");
190 }
191
192 // ===========================================================================
193 // Conflicts and refusals
194 // ===========================================================================
195
196 #[test]
197 fn body_and_body_file_together_are_refused() {
198 let repo = TestRepo::new("Alice", "alice@example.com");
199 let id = repo.issue_open("conflict");
200
201 let path = repo.dir.path().join("body.txt");
202 std::fs::write(&path, "from file").unwrap();
203
204 let err = repo.run_err(&[
205 "issue",
206 "comment",
207 &id,
208 "-b",
209 "from flag",
210 "-F",
211 path.to_str().unwrap(),
212 ]);
213 assert!(
214 err.contains("body") && err.contains("body-file"),
215 "error should name both options: {}",
216 err
217 );
218 }
219
220 /// The failure mode that matters most for scripts and agents: with no body
221 /// and no terminal there is nothing to open an editor on, so the command must
222 /// say so and exit rather than block forever on a detached editor.
223 #[test]
224 fn a_missing_body_without_a_terminal_is_an_error_not_a_hang() {
225 let repo = TestRepo::new("Alice", "alice@example.com");
226 let id = repo.issue_open("no body");
227
228 let err = repo.run_err(&["issue", "comment", &id]);
229 assert!(
230 err.contains("--body-file"),
231 "error should point at the non-interactive alternatives: {}",
232 err
233 );
234 }
235
236 #[test]
237 fn an_empty_body_file_is_refused_rather_than_recorded() {
238 let repo = TestRepo::new("Alice", "alice@example.com");
239 let id = repo.issue_open("empty file");
240
241 let path = repo.dir.path().join("empty.txt");
242 std::fs::write(&path, "").unwrap();
243
244 let err = repo.run_err(&["issue", "comment", &id, "-F", path.to_str().unwrap()]);
245 assert!(
246 err.to_lowercase().contains("empty"),
247 "error should say the body was empty: {}",
248 err
249 );
250 }
251
252 // ===========================================================================
253 // $EDITOR
254 // ===========================================================================
255
256 /// An interactive user who gives no body gets an editor, and whatever they
257 /// save is the body — verbatim, including the trailing blank line.
258 #[test]
259 fn a_missing_body_opens_the_editor_and_keeps_its_bytes() {
260 let repo = TestRepo::new("Alice", "alice@example.com");
261 let id = repo.issue_open("editor body");
262
263 // A fake editor that overwrites the file it is handed. Written with
264 // printf so the exact trailing bytes are under the test's control.
265 let editor = repo.write_script(
266 "fake-editor.sh",
267 "#!/bin/sh\nprintf 'from the editor\\n\\n' > \"$1\"\n",
268 );
269
270 let output = repo.run_in_pty(&["issue", "comment", &id], &editor);
271 assert!(
272 output.status.success(),
273 "editor run failed: {}",
274 String::from_utf8_lossy(&output.stderr)
275 );
276
277 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
278 assert_eq!(json_field(&json, "/comments/0/body"), "from the editor\n\n");
279 }
280
281 /// A body typed into an editor is not a commit message: nothing may be
282 /// stripped from it. A leading `#` is a markdown heading, and must survive.
283 #[test]
284 fn the_editor_strips_nothing_not_even_leading_hashes() {
285 let repo = TestRepo::new("Alice", "alice@example.com");
286 let id = repo.issue_open("hash body");
287
288 let editor = repo.write_script(
289 "fake-editor.sh",
290 "#!/bin/sh\nprintf '# Heading\\n\\nbody text\\n' > \"$1\"\n",
291 );
292
293 repo.run_in_pty(&["issue", "comment", &id], &editor);
294
295 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
296 assert_eq!(
297 json_field(&json, "/comments/0/body"),
298 "# Heading\n\nbody text\n"
299 );
300 }
301
302 /// Leaving the editor without writing anything aborts the whole command —
303 /// no empty comment is appended to the DAG, where it could never be removed.
304 #[test]
305 fn an_empty_editor_buffer_aborts_without_appending() {
306 let repo = TestRepo::new("Alice", "alice@example.com");
307 let id = repo.issue_open("aborted");
308
309 let editor = repo.write_script("fake-editor.sh", "#!/bin/sh\n: > \"$1\"\n");
310
311 let output = repo.run_in_pty(&["issue", "comment", &id], &editor);
312 assert!(
313 !output.status.success(),
314 "an empty editor buffer should abort the command"
315 );
316
317 let json = repo.run_ok(&["issue", "show", &id, "--json"]);
318 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
319 assert_eq!(
320 value["comments"].as_array().unwrap().len(),
321 0,
322 "aborting must leave no comment behind"
323 );
324 }
tests/common/mod.rs
Old New
@@ -449,6 +449,99 @@ impl TestRepo {
449 .expect("failed to run git-collab") 449 .expect("failed to run git-collab")
450 } 450 }
451 451
452 /// Run git-collab with `stdin` piped in, and return raw output.
453 ///
454 /// Takes bytes rather than `&str` so a test can feed content that is not
455 /// valid UTF-8, and so nothing along the way is tempted to normalize
456 /// line endings or trailing newlines.
457 pub fn run_with_stdin(&self, args: &[&str], stdin: &[u8]) -> Output {
458 let mut child = self
459 .cli_command()
460 .args(args)
461 .stdin(Stdio::piped())
462 .stdout(Stdio::piped())
463 .stderr(Stdio::piped())
464 .spawn()
465 .expect("failed to spawn git-collab");
466 child
467 .stdin
468 .take()
469 .expect("stdin piped")
470 .write_all(stdin)
471 .expect("write stdin");
472 child.wait_with_output().expect("collect git-collab output")
473 }
474
475 /// `run_with_stdin`, asserting success and returning stdout.
476 pub fn run_stdin_ok(&self, args: &[&str], stdin: &[u8]) -> String {
477 let output = self.run_with_stdin(args, stdin);
478 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
479 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
480 assert!(
481 output.status.success(),
482 "git-collab {:?} failed (exit {:?}):\nstdout: {}\nstderr: {}",
483 args,
484 output.status.code(),
485 stdout,
486 stderr
487 );
488 stdout
489 }
490
491 /// Run git-collab attached to a pseudo-terminal, so the code under test
492 /// sees stdin as a TTY. `editor` is exported as `$EDITOR` for the run.
493 ///
494 /// This is the only way to exercise the interactive editor path end to
495 /// end: opening an editor is deliberately conditional on stdin being a
496 /// terminal, so a piped run can never reach it.
497 pub fn run_in_pty(&self, args: &[&str], editor: &str) -> Output {
498 let quoted: Vec<String> = args
499 .iter()
500 .map(|a| format!("'{}'", a.replace('\'', r"'\''")))
501 .collect();
502 let script = format!(
503 "{} {}",
504 env!("CARGO_BIN_EXE_git-collab"),
505 quoted.join(" ")
506 );
507 let mut command = Command::new("script");
508 self.apply_env(&mut command);
509 command.env("EDITOR", editor);
510 let mut child = command
511 .args(["-qec", &script, "/dev/null"])
512 .current_dir(self.dir.path())
513 .stdin(Stdio::piped())
514 .stdout(Stdio::piped())
515 .stderr(Stdio::piped())
516 .spawn()
517 .expect("failed to launch git-collab in a pty");
518
519 let deadline = Instant::now() + Duration::from_secs(20);
520 loop {
521 if child.try_wait().expect("poll pty process").is_some() {
522 return child.wait_with_output().expect("collect pty output");
523 }
524 if Instant::now() >= deadline {
525 let _ = child.kill();
526 panic!("git-collab {:?} hung in a pty (editor path did not return)", args);
527 }
528 thread::sleep(Duration::from_millis(20));
529 }
530 }
531
532 /// Write a shell script into the repo's temp dir and return an editor
533 /// *command* that runs it via `sh`, for use as a fake `$EDITOR`.
534 ///
535 /// `sh <path>` rather than chmod+exec: tests run on many threads, and a
536 /// `Command::spawn` in one thread inherits any write file descriptor
537 /// another thread has open, which makes exec'ing a freshly written script
538 /// fail with ETXTBSY at random. `sh` only opens it for reading.
539 pub fn write_script(&self, name: &str, body: &str) -> String {
540 let path = self.dir.path().join(name);
541 std::fs::write(&path, body).unwrap();
542 format!("sh {}", path.display())
543 }
544
452 /// Run git-collab, assert success, return stdout. 545 /// Run git-collab, assert success, return stdout.
453 pub fn run_ok(&self, args: &[&str]) -> String { 546 pub fn run_ok(&self, args: &[&str]) -> String {
454 let output = self.run(args); 547 let output = self.run(args);