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::cob::Title;
14use radicle::git;
15use radicle::patch::{Patch, PatchId};
16use radicle::prelude::Profile;
17use radicle::storage::git::Repository;
18use radicle::storage::WriteRepository as _;
19
20use crate::terminal as term;
21use crate::terminal::Element;
22
23pub use common::*;
24
25#[derive(Debug, Error)]
26pub enum Error {
27 #[error(transparent)]
28 Fmt(#[from] fmt::Error),
29 #[error("git: {0}")]
30 Git(#[from] git::raw::Error),
31 #[error("i/o error: {0}")]
32 Io(#[from] io::Error),
33 #[error("invalid utf-8 string")]
34 InvalidUtf8,
35}
36
37#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub enum Message {
40 #[default]
42 Edit,
43 Blank,
45 Text(String),
47}
48
49impl Message {
50 pub fn get(self, help: &str) -> std::io::Result<String> {
52 let comment = match self {
53 Message::Edit => {
54 if io::stderr().is_terminal() {
55 term::Editor::comment()
56 .extension("markdown")
57 .initial(help)?
58 .edit()?
59 } else {
60 Some(help.to_owned())
61 }
62 }
63 Message::Blank => None,
64 Message::Text(c) => Some(c),
65 };
66 let comment = comment.unwrap_or_default();
67 let comment = term::format::html::strip_comments(&comment);
68 let comment = comment.trim();
69
70 Ok(comment.to_owned())
71 }
72
73 pub fn edit_title_description(
76 title: Option<cob::Title>,
77 description: Option<String>,
78 help: &str,
79 ) -> std::io::Result<Option<(Title, String)>> {
80 let mut placeholder = String::new();
81
82 if let Some(title) = title {
83 placeholder.push_str(title.as_ref());
84 placeholder.push('\n');
85 }
86 if let Some(description) = description {
87 placeholder.push('\n');
88 placeholder.push_str(description.trim());
89 placeholder.push('\n');
90 }
91 placeholder.push_str(help);
92
93 let output = Self::Edit.get(&placeholder)?;
94 let (title, description) = match output.split_once("\n\n") {
95 Some((x, y)) => (x, y),
96 None => (output.as_str(), ""),
97 };
98
99 let Ok(title) = Title::new(title) else {
100 return Ok(None);
101 };
102
103 Ok(Some((title, description.trim().to_owned())))
104 }
105
106 pub fn append(&mut self, arg: &str) {
107 if let Message::Text(v) = self {
108 v.extend(["\n\n", arg]);
109 } else {
110 *self = Message::Text(arg.into());
111 };
112 }
113}
114
115pub const PATCH_MSG: &str = r#"
116<!--
117Please enter a patch message for your changes. An empty
118message aborts the patch proposal.
119
120The first line is the patch title. The patch description
121follows, and must be separated with a blank line, just
122like a commit message. Markdown is supported in the title
123and description.
124-->
125"#;
126
127const REVISION_MSG: &str = r#"
128<!--
129Please enter a comment for your patch update. Leaving this
130blank is also okay.
131-->
132"#;
133
134#[inline]
136pub fn message(title: &str, description: &str) -> String {
137 format!("{title}\n\n{description}").trim().to_string()
138}
139
140fn message_from_commits(name: &str, commits: Vec<git::raw::Commit>) -> Result<String, Error> {
142 let mut commits = commits.into_iter().rev();
143 let count = commits.len();
144 let Some(commit) = commits.next() else {
145 return Ok(String::default());
146 };
147 let commit_msg = commit.message().ok_or(Error::InvalidUtf8)?.to_string();
148
149 if count == 1 {
150 return Ok(commit_msg);
151 }
152
153 let mut msg = String::new();
155 writeln!(&mut msg, "<!--")?;
156 writeln!(
157 &mut msg,
158 "This {name} is the combination of {count} commits.",
159 )?;
160 writeln!(&mut msg, "This is the first commit message:")?;
161 writeln!(&mut msg, "-->")?;
162 writeln!(&mut msg)?;
163 writeln!(&mut msg, "{}", commit_msg.trim_end())?;
164 writeln!(&mut msg)?;
165
166 for (i, commit) in commits.enumerate() {
167 let commit_msg = commit.message().ok_or(Error::InvalidUtf8)?.trim_end();
168 let commit_num = i + 2;
169
170 writeln!(&mut msg, "<!--")?;
171 writeln!(&mut msg, "This is commit message #{commit_num}:")?;
172 writeln!(&mut msg, "-->")?;
173 writeln!(&mut msg)?;
174 writeln!(&mut msg, "{commit_msg}")?;
175 writeln!(&mut msg)?;
176 }
177
178 Ok(msg)
179}
180
181pub fn patch_commits<'a>(
183 repo: &'a git::raw::Repository,
184 base: &git::Oid,
185 head: &git::Oid,
186) -> Result<Vec<git::raw::Commit<'a>>, git::raw::Error> {
187 let mut commits = Vec::new();
188 let mut revwalk = repo.revwalk()?;
189 revwalk.push_range(&format!("{base}..{head}"))?;
190
191 for rev in revwalk {
192 let commit = repo.find_commit(rev?)?;
193 commits.push(commit);
194 }
195 Ok(commits)
196}
197
198fn create_display_message(
200 repo: &git::raw::Repository,
201 base: &git::Oid,
202 head: &git::Oid,
203) -> Result<String, Error> {
204 let commits = patch_commits(repo, base, head)?;
205 if commits.is_empty() {
206 return Ok(PATCH_MSG.trim_start().to_string());
207 }
208
209 let summary = message_from_commits("patch", commits)?;
210 let summary = summary.trim();
211
212 Ok(format!("{summary}\n{PATCH_MSG}"))
213}
214
215pub fn get_create_message(
220 message: term::patch::Message,
221 repo: &git::raw::Repository,
222 base: &git::Oid,
223 head: &git::Oid,
224) -> Result<(Title, String), Error> {
225 let display_msg = create_display_message(repo, base, head)?;
226 let message = message.get(&display_msg)?;
227
228 let (title, description) = message.split_once('\n').unwrap_or((&message, ""));
229 let (title, description) = (title.trim().to_string(), description.trim().to_string());
230
231 let title = Title::new(title.as_str()).map_err(|err| {
232 io::Error::new(
233 io::ErrorKind::InvalidInput,
234 format!("invalid patch title: {err}"),
235 )
236 })?;
237
238 Ok((title, description))
239}
240
241fn edit_display_message(title: &str, description: &str) -> String {
243 format!("{title}\n\n{description}\n{PATCH_MSG}")
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<(Title, 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 let title = Title::new(title.as_str()).map_err(|err| {
263 io::Error::new(
264 io::ErrorKind::InvalidInput,
265 format!("invalid patch title: {err}"),
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, verbose);
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}