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