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