Skip to main content

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]
143#[must_use]
144pub fn message(title: &str, description: &str) -> String {
145    format!("{title}\n\n{description}").trim().to_string()
146}
147
148/// Create a helpful default `Patch` message out of one or more commit messages.
149fn 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    // Many commits
162    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
189/// Return commits between the merge base and a head.
190pub 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
206/// The message shown in the editor when creating a `Patch`.
207fn 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
223/// Get the Patch title and description from the command line arguments, or request it from the
224/// user.
225///
226/// The user can bail out if an empty title is entered.
227pub 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
249/// The message shown in the editor when editing a `Patch`.
250fn 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
256/// Get a patch edit message.
257pub 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(), ""); // Delete help message.
264
265    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
280/// The message shown in the editor when updating a `Patch`.
281fn 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
301/// Get a patch update message.
302pub 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
315/// List the given commits in a table.
316pub 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
335/// Print commits ahead and behind.
336pub 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}