1mod common;
2mod timeline;
3
4use std::fmt;
5use std::fmt::Write;
6use std::io;
7use std::io::IsTerminal as _;
8
9use thiserror::Error;
10
11use radicle::cob;
12use radicle::cob::patch;
13use radicle::git;
14use radicle::patch::{Patch, PatchId};
15use radicle::prelude::Profile;
16use radicle::storage::git::Repository;
17use radicle::storage::WriteRepository as _;
18
19use crate::terminal as term;
20use crate::terminal::Element;
21
22pub use common::*;
23
24#[derive(Debug, Error)]
25pub enum Error {
26 #[error(transparent)]
27 Fmt(#[from] fmt::Error),
28 #[error("git: {0}")]
29 Git(#[from] git::raw::Error),
30 #[error("i/o error: {0}")]
31 Io(#[from] io::Error),
32 #[error("invalid utf-8 string")]
33 InvalidUtf8,
34}
35
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
38pub enum Message {
39 #[default]
41 Edit,
42 Blank,
44 Text(String),
46}
47
48impl Message {
49 pub fn get(self, help: &str) -> std::io::Result<String> {
51 let comment = match self {
52 Message::Edit => {
53 if io::stderr().is_terminal() {
54 term::Editor::comment()
55 .extension("markdown")
56 .initial(help)?
57 .edit()?
58 } else {
59 Some(help.to_owned())
60 }
61 }
62 Message::Blank => None,
63 Message::Text(c) => Some(c),
64 };
65 let comment = comment.unwrap_or_default();
66 let comment = term::format::html::strip_comments(&comment);
67 let comment = comment.trim();
68
69 Ok(comment.to_owned())
70 }
71
72 pub fn edit_title_description(
75 title: Option<String>,
76 description: Option<String>,
77 help: &str,
78 ) -> std::io::Result<Option<(String, String)>> {
79 let mut placeholder = String::new();
80
81 if let Some(title) = title {
82 placeholder.push_str(title.trim());
83 placeholder.push('\n');
84 }
85 if let Some(description) = description {
86 placeholder.push('\n');
87 placeholder.push_str(description.trim());
88 placeholder.push('\n');
89 }
90 placeholder.push_str(help);
91
92 let output = Self::Edit.get(&placeholder)?;
93 let (title, description) = match output.split_once("\n\n") {
94 Some((x, y)) => (x, y),
95 None => (output.as_str(), ""),
96 };
97 let (title, description) = (title.trim(), description.trim());
98
99 if title.is_empty() | title.contains('\n') {
100 return Ok(None);
101 }
102 Ok(Some((title.to_owned(), description.to_owned())))
103 }
104
105 pub fn append(&mut self, arg: &str) {
106 if let Message::Text(v) = self {
107 v.extend(["\n\n", arg]);
108 } else {
109 *self = Message::Text(arg.into());
110 };
111 }
112}
113
114pub const PATCH_MSG: &str = r#"
115<!--
116Please enter a patch message for your changes. An empty
117message aborts the patch proposal.
118
119The first line is the patch title. The patch description
120follows, and must be separated with a blank line, just
121like a commit message. Markdown is supported in the title
122and description.
123-->
124"#;
125
126const REVISION_MSG: &str = r#"
127<!--
128Please enter a comment for your patch update. Leaving this
129blank is also okay.
130-->
131"#;
132
133#[inline]
135pub fn message(title: &str, description: &str) -> String {
136 format!("{title}\n\n{description}").trim().to_string()
137}
138
139fn message_from_commits(name: &str, commits: Vec<git::raw::Commit>) -> Result<String, Error> {
141 let mut commits = commits.into_iter().rev();
142 let count = commits.len();
143 let Some(commit) = commits.next() else {
144 return Ok(String::default());
145 };
146 let commit_msg = commit.message().ok_or(Error::InvalidUtf8)?.to_string();
147
148 if count == 1 {
149 return Ok(commit_msg);
150 }
151
152 let mut msg = String::new();
154 writeln!(&mut msg, "<!--")?;
155 writeln!(
156 &mut msg,
157 "This {name} is the combination of {count} commits.",
158 )?;
159 writeln!(&mut msg, "This is the first commit message:")?;
160 writeln!(&mut msg, "-->")?;
161 writeln!(&mut msg)?;
162 writeln!(&mut msg, "{}", commit_msg.trim_end())?;
163 writeln!(&mut msg)?;
164
165 for (i, commit) in commits.enumerate() {
166 let commit_msg = commit.message().ok_or(Error::InvalidUtf8)?.trim_end();
167 let commit_num = i + 2;
168
169 writeln!(&mut msg, "<!--")?;
170 writeln!(&mut msg, "This is commit message #{commit_num}:")?;
171 writeln!(&mut msg, "-->")?;
172 writeln!(&mut msg)?;
173 writeln!(&mut msg, "{commit_msg}")?;
174 writeln!(&mut msg)?;
175 }
176
177 Ok(msg)
178}
179
180pub fn patch_commits<'a>(
182 repo: &'a git::raw::Repository,
183 base: &git::Oid,
184 head: &git::Oid,
185) -> Result<Vec<git::raw::Commit<'a>>, git::raw::Error> {
186 let mut commits = Vec::new();
187 let mut revwalk = repo.revwalk()?;
188 revwalk.push_range(&format!("{base}..{head}"))?;
189
190 for rev in revwalk {
191 let commit = repo.find_commit(rev?)?;
192 commits.push(commit);
193 }
194 Ok(commits)
195}
196
197fn create_display_message(
199 repo: &git::raw::Repository,
200 base: &git::Oid,
201 head: &git::Oid,
202) -> Result<String, Error> {
203 let commits = patch_commits(repo, base, head)?;
204 if commits.is_empty() {
205 return Ok(PATCH_MSG.trim_start().to_string());
206 }
207
208 let summary = message_from_commits("patch", commits)?;
209 let summary = summary.trim();
210
211 Ok(format!("{summary}\n{PATCH_MSG}"))
212}
213
214pub fn get_create_message(
219 message: term::patch::Message,
220 repo: &git::raw::Repository,
221 base: &git::Oid,
222 head: &git::Oid,
223) -> Result<(String, String), Error> {
224 let display_msg = create_display_message(repo, base, head)?;
225 let message = message.get(&display_msg)?;
226
227 let (title, description) = message.split_once('\n').unwrap_or((&message, ""));
228 let (title, description) = (title.trim().to_string(), description.trim().to_string());
229
230 if title.is_empty() {
231 return Err(io::Error::new(
232 io::ErrorKind::InvalidInput,
233 "a patch title must be provided",
234 )
235 .into());
236 }
237
238 Ok((title, description))
239}
240
241fn edit_display_message(title: &str, description: &str) -> String {
243 format!("{}\n\n{}\n{PATCH_MSG}", title, description)
244 .trim_start()
245 .to_string()
246}
247
248pub fn get_edit_message(
250 patch_message: term::patch::Message,
251 patch: &cob::patch::Patch,
252) -> io::Result<(String, String)> {
253 let display_msg = edit_display_message(patch.title(), patch.description());
254 let patch_message = patch_message.get(&display_msg)?;
255 let patch_message = patch_message.replace(PATCH_MSG.trim(), ""); let (title, description) = patch_message
258 .split_once('\n')
259 .unwrap_or((&patch_message, ""));
260 let (title, description) = (title.trim().to_string(), description.trim().to_string());
261
262 if title.is_empty() {
263 return Err(io::Error::new(
264 io::ErrorKind::InvalidInput,
265 "a patch title must be provided",
266 ));
267 }
268
269 Ok((title, description))
270}
271
272fn update_display_message(
274 repo: &git::raw::Repository,
275 last_rev_head: &git::Oid,
276 head: &git::Oid,
277) -> Result<String, Error> {
278 if !repo.graph_descendant_of(**head, **last_rev_head)? {
279 return Ok(REVISION_MSG.trim_start().to_string());
280 }
281
282 let commits = patch_commits(repo, last_rev_head, head)?;
283 if commits.is_empty() {
284 return Ok(REVISION_MSG.trim_start().to_string());
285 }
286
287 let summary = message_from_commits("patch", commits)?;
288 let summary = summary.trim();
289
290 Ok(format!("{summary}\n{REVISION_MSG}"))
291}
292
293pub fn get_update_message(
295 message: term::patch::Message,
296 repo: &git::raw::Repository,
297 latest: &patch::Revision,
298 head: &git::Oid,
299) -> Result<String, Error> {
300 let display_msg = update_display_message(repo, &latest.head(), head)?;
301 let message = message.get(&display_msg)?;
302 let message = message.trim();
303
304 Ok(message.to_owned())
305}
306
307pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
309 let mut table = term::Table::default();
310
311 for commit in commits {
312 let message = commit
313 .summary_bytes()
314 .unwrap_or_else(|| commit.message_bytes());
315 table.push([
316 term::format::secondary(term::format::oid(commit.id()).into()),
317 term::format::italic(String::from_utf8_lossy(message).to_string()),
318 ]);
319 }
320 table.print();
321
322 Ok(())
323}
324
325pub fn print_commits_ahead_behind(
327 repo: &git::raw::Repository,
328 left: git::raw::Oid,
329 right: git::raw::Oid,
330) -> anyhow::Result<()> {
331 let (ahead, behind) = repo.graph_ahead_behind(left, right)?;
332
333 term::info!(
334 "{} commit(s) ahead, {} commit(s) behind",
335 term::format::positive(ahead),
336 if behind > 0 {
337 term::format::negative(behind)
338 } else {
339 term::format::dim(behind)
340 }
341 );
342 Ok(())
343}
344
345pub fn show(
346 patch: &Patch,
347 id: &PatchId,
348 verbose: bool,
349 stored: &Repository,
350 workdir: Option<&git::raw::Repository>,
351 profile: &Profile,
352) -> anyhow::Result<()> {
353 let (_, revision) = patch.latest();
354 let state = patch.state();
355 let branches = if let Some(wd) = workdir {
356 common::branches(&revision.head(), wd)?
357 } else {
358 vec![]
359 };
360 let ahead_behind = common::ahead_behind(
361 stored.raw(),
362 *revision.head(),
363 *patch.target().head(stored)?,
364 )?;
365 let author = patch.author();
366 let author = term::format::Author::new(author.id(), profile);
367 let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();
368
369 let mut attrs = term::Table::<2, term::Line>::new(term::TableOptions {
370 spacing: 2,
371 ..term::TableOptions::default()
372 });
373 attrs.push([
374 term::format::tertiary("Title".to_owned()).into(),
375 term::format::bold(patch.title().to_owned()).into(),
376 ]);
377 attrs.push([
378 term::format::tertiary("Patch".to_owned()).into(),
379 term::format::default(id.to_string()).into(),
380 ]);
381 attrs.push([
382 term::format::tertiary("Author".to_owned()).into(),
383 author.line(),
384 ]);
385 if !labels.is_empty() {
386 attrs.push([
387 term::format::tertiary("Labels".to_owned()).into(),
388 term::format::secondary(labels.join(", ")).into(),
389 ]);
390 }
391 attrs.push([
392 term::format::tertiary("Head".to_owned()).into(),
393 term::format::secondary(revision.head().to_string()).into(),
394 ]);
395 if verbose {
396 attrs.push([
397 term::format::tertiary("Base".to_owned()).into(),
398 term::format::secondary(revision.base().to_string()).into(),
399 ]);
400 }
401 if !branches.is_empty() {
402 attrs.push([
403 term::format::tertiary("Branches".to_owned()).into(),
404 term::format::yellow(branches.join(", ")).into(),
405 ]);
406 }
407 attrs.push([
408 term::format::tertiary("Commits".to_owned()).into(),
409 ahead_behind,
410 ]);
411 attrs.push([
412 term::format::tertiary("Status".to_owned()).into(),
413 match state {
414 patch::State::Open { .. } => term::format::positive(state.to_string()),
415 patch::State::Draft => term::format::dim(state.to_string()),
416 patch::State::Archived => term::format::yellow(state.to_string()),
417 patch::State::Merged { .. } => term::format::primary(state.to_string()),
418 }
419 .into(),
420 ]);
421
422 let commits = patch_commit_lines(patch, stored)?;
423 let description = patch.description().trim();
424 let mut widget = term::VStack::default()
425 .border(Some(term::colors::FAINT))
426 .child(attrs)
427 .children(if !description.is_empty() {
428 vec![
429 term::Label::blank().boxed(),
430 term::textarea(description).boxed(),
431 ]
432 } else {
433 vec![]
434 })
435 .divider()
436 .children(commits.into_iter().map(|l| l.boxed()))
437 .divider();
438
439 for line in timeline::timeline(profile, patch) {
440 widget.push(line);
441 }
442
443 if verbose {
444 for (id, comment) in revision.replies() {
445 let hstack = term::comment::header(id, comment, profile);
446
447 widget = widget.divider();
448 widget.push(hstack);
449 widget.push(term::textarea(comment.body()).wrap(60));
450 }
451 }
452 widget.print();
453
454 Ok(())
455}
456
457fn patch_commit_lines(
458 patch: &patch::Patch,
459 stored: &Repository,
460) -> anyhow::Result<Vec<term::Line>> {
461 let (from, to) = patch.range()?;
462 let mut lines = Vec::new();
463
464 for commit in patch_commits(stored.raw(), &from, &to)? {
465 lines.push(term::Line::spaced([
466 term::label(term::format::secondary::<String>(
467 term::format::oid(commit.id()).into(),
468 )),
469 term::label(term::format::default(
470 commit.summary().unwrap_or_default().to_owned(),
471 )),
472 ]));
473 }
474 Ok(lines)
475}
476
477#[cfg(test)]
478mod test {
479 use super::*;
480 use radicle::git::refname;
481 use radicle::test::fixtures;
482 use std::path;
483
484 fn commit(
485 repo: &git::raw::Repository,
486 branch: &git::RefStr,
487 parent: &git::Oid,
488 msg: &str,
489 ) -> git::Oid {
490 let sig = git::raw::Signature::new(
491 "anonymous",
492 "anonymous@radicle.xyz",
493 &git::raw::Time::new(0, 0),
494 )
495 .unwrap();
496 let head = repo.find_commit(**parent).unwrap();
497 let tree =
498 git::write_tree(path::Path::new("README"), "Hello World!\n".as_bytes(), repo).unwrap();
499
500 let branch = git::refs::branch(branch);
501 let commit = git::commit(repo, &head, &branch, msg, &sig, &tree).unwrap();
502
503 commit.id().into()
504 }
505
506 #[test]
507 fn test_create_display_message() {
508 let tmpdir = tempfile::tempdir().unwrap();
509 let (repo, commit_0) = fixtures::repository(&tmpdir);
510 let commit_0 = commit_0.into();
511 let commit_1 = commit(
512 &repo,
513 &refname!("feature"),
514 &commit_0,
515 "Commit 1\n\nDescription\n",
516 );
517 let commit_2 = commit(
518 &repo,
519 &refname!("feature"),
520 &commit_1,
521 "Commit 2\n\nDescription\n",
522 );
523
524 let res = create_display_message(&repo, &commit_0, &commit_0).unwrap();
525 assert_eq!(
526 "\
527 <!--\n\
528 Please enter a patch message for your changes. An empty\n\
529 message aborts the patch proposal.\n\
530 \n\
531 The first line is the patch title. The patch description\n\
532 follows, and must be separated with a blank line, just\n\
533 like a commit message. Markdown is supported in the title\n\
534 and description.\n\
535 -->\n\
536 ",
537 res
538 );
539
540 let res = create_display_message(&repo, &commit_0, &commit_1).unwrap();
541 assert_eq!(
542 "\
543 Commit 1\n\
544 \n\
545 Description\n\
546 \n\
547 <!--\n\
548 Please enter a patch message for your changes. An empty\n\
549 message aborts the patch proposal.\n\
550 \n\
551 The first line is the patch title. The patch description\n\
552 follows, and must be separated with a blank line, just\n\
553 like a commit message. Markdown is supported in the title\n\
554 and description.\n\
555 -->\n\
556 ",
557 res
558 );
559
560 let res = create_display_message(&repo, &commit_0, &commit_2).unwrap();
561 assert_eq!(
562 "\
563 <!--\n\
564 This patch is the combination of 2 commits.\n\
565 This is the first commit message:\n\
566 -->\n\
567 \n\
568 Commit 1\n\
569 \n\
570 Description\n\
571 \n\
572 <!--\n\
573 This is commit message #2:\n\
574 -->\n\
575 \n\
576 Commit 2\n\
577 \n\
578 Description\n\
579 \n\
580 <!--\n\
581 Please enter a patch message for your changes. An empty\n\
582 message aborts the patch proposal.\n\
583 \n\
584 The first line is the patch title. The patch description\n\
585 follows, and must be separated with a blank line, just\n\
586 like a commit message. Markdown is supported in the title\n\
587 and description.\n\
588 -->\n\
589 ",
590 res
591 );
592 }
593
594 #[test]
595 fn test_edit_display_message() {
596 let res = edit_display_message("title", "The patch description.");
597 assert_eq!(
598 "\
599 title\n\
600 \n\
601 The patch description.\n\
602 \n\
603 <!--\n\
604 Please enter a patch message for your changes. An empty\n\
605 message aborts the patch proposal.\n\
606 \n\
607 The first line is the patch title. The patch description\n\
608 follows, and must be separated with a blank line, just\n\
609 like a commit message. Markdown is supported in the title\n\
610 and description.\n\
611 -->\n\
612 ",
613 res
614 );
615 }
616
617 #[test]
618 fn test_update_display_message() {
619 let tmpdir = tempfile::tempdir().unwrap();
620 let (repo, commit_0) = fixtures::repository(&tmpdir);
621 let commit_0 = commit_0.into();
622
623 let commit_1 = commit(&repo, &refname!("feature"), &commit_0, "commit 1\n");
624 let commit_2 = commit(&repo, &refname!("feature"), &commit_1, "commit 2\n");
625 let commit_squashed = commit(
626 &repo,
627 &refname!("squashed-feature"),
628 &commit_0,
629 "commit squashed",
630 );
631
632 let res = update_display_message(&repo, &commit_1, &commit_1).unwrap();
633 assert_eq!(
634 "\
635 <!--\n\
636 Please enter a comment for your patch update. Leaving this\n\
637 blank is also okay.\n\
638 -->\n\
639 ",
640 res
641 );
642
643 let res = update_display_message(&repo, &commit_1, &commit_2).unwrap();
644 assert_eq!(
645 "\
646 commit 2\n\
647 \n\
648 <!--\n\
649 Please enter a comment for your patch update. Leaving this\n\
650 blank is also okay.\n\
651 -->\n\
652 ",
653 res
654 );
655
656 let res = update_display_message(&repo, &commit_1, &commit_squashed).unwrap();
657 assert_eq!(
658 "\
659 <!--\n\
660 Please enter a comment for your patch update. Leaving this\n\
661 blank is also okay.\n\
662 -->\n\
663 ",
664 res
665 );
666 }
667}