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::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/// The user supplied `Patch` description.
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub enum Message {
40    /// Prompt user to write comment in editor.
41    #[default]
42    Edit,
43    /// Don't leave a comment.
44    Blank,
45    /// Use the following string as comment.
46    Text(String),
47}
48
49impl Message {
50    /// Get the `Message` as a string according to the method.
51    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    /// Open the editor with the given title and description (if any).
74    /// Returns the edited title and description, or nothing if it couldn't be parsed.
75    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/// Combine the title and description fields to display to the user.
142#[inline]
143pub fn message(title: &str, description: &str) -> String {
144    format!("{title}\n\n{description}").trim().to_string()
145}
146
147/// Create a helpful default `Patch` message out of one or more commit messages.
148fn 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    // Many commits
161    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
188/// Return commits between the merge base and a head.
189pub 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
205/// The message shown in the editor when creating a `Patch`.
206fn 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
222/// Get the Patch title and description from the command line arguments, or request it from the
223/// user.
224///
225/// The user can bail out if an empty title is entered.
226pub 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
248/// The message shown in the editor when editing a `Patch`.
249fn 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
255/// Get a patch edit message.
256pub 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(), ""); // Delete help message.
263
264    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
279/// The message shown in the editor when updating a `Patch`.
280fn 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
300/// Get a patch update message.
301pub 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
314/// List the given commits in a table.
315pub 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
334/// Print commits ahead and behind.
335pub 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}