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