radicle_cli/terminal/
patch.rs

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/// The user supplied `Patch` description.
37#[derive(Clone, Debug, Default, PartialEq, Eq)]
38pub enum Message {
39    /// Prompt user to write comment in editor.
40    #[default]
41    Edit,
42    /// Don't leave a comment.
43    Blank,
44    /// Use the following string as comment.
45    Text(String),
46}
47
48impl Message {
49    /// Get the `Message` as a string according to the method.
50    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    /// Open the editor with the given title and description (if any).
73    /// Returns the edited title and description, or nothing if it couldn't be parsed.
74    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/// Combine the title and description fields to display to the user.
134#[inline]
135pub fn message(title: &str, description: &str) -> String {
136    format!("{title}\n\n{description}").trim().to_string()
137}
138
139/// Create a helpful default `Patch` message out of one or more commit messages.
140fn 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    // Many commits
153    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
180/// Return commits between the merge base and a head.
181pub 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
197/// The message shown in the editor when creating a `Patch`.
198fn 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
214/// Get the Patch title and description from the command line arguments, or request it from the
215/// user.
216///
217/// The user can bail out if an empty title is entered.
218pub 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
241/// The message shown in the editor when editing a `Patch`.
242fn 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
248/// Get a patch edit message.
249pub 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(), ""); // Delete help message.
256
257    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
272/// The message shown in the editor when updating a `Patch`.
273fn 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
293/// Get a patch update message.
294pub 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
307/// List the given commits in a table.
308pub 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
325/// Print commits ahead and behind.
326pub 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}