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