528 lines
15 KiB
Rust
528 lines
15 KiB
Rust
use std::fmt;
|
|
use std::fmt::Write;
|
|
use std::io;
|
|
use std::io::IsTerminal as _;
|
|
|
|
use thiserror::Error;
|
|
|
|
use radicle::cob;
|
|
use radicle::cob::patch;
|
|
use radicle::git;
|
|
|
|
use crate::terminal as term;
|
|
use crate::terminal::Element;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum Error {
|
|
#[error(transparent)]
|
|
Fmt(#[from] fmt::Error),
|
|
#[error("git: {0}")]
|
|
Git(#[from] git::raw::Error),
|
|
#[error("i/o error: {0}")]
|
|
Io(#[from] io::Error),
|
|
#[error("invalid utf-8 string")]
|
|
InvalidUtf8,
|
|
}
|
|
|
|
/// The user supplied `Patch` description.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Message {
|
|
/// Prompt user to write comment in editor.
|
|
Edit,
|
|
/// Don't leave a comment.
|
|
Blank,
|
|
/// Use the following string as comment.
|
|
Text(String),
|
|
}
|
|
|
|
impl Message {
|
|
/// Get the `Message` as a string according to the method.
|
|
pub fn get(self, help: &str) -> std::io::Result<String> {
|
|
let comment = match self {
|
|
Message::Edit => {
|
|
if io::stderr().is_terminal() {
|
|
term::Editor::new().extension("markdown").edit(help)?
|
|
} else {
|
|
Some(help.to_owned())
|
|
}
|
|
}
|
|
Message::Blank => None,
|
|
Message::Text(c) => Some(c),
|
|
};
|
|
let comment = comment.unwrap_or_default();
|
|
let comment = term::format::html::strip_comments(&comment);
|
|
let comment = comment.trim();
|
|
|
|
Ok(comment.to_owned())
|
|
}
|
|
|
|
/// Open the editor with the given title and description (if any).
|
|
/// Returns the edited title and description, or nothing if it couldn't be parsed.
|
|
pub fn edit_title_description(
|
|
title: Option<String>,
|
|
description: Option<String>,
|
|
help: &str,
|
|
) -> std::io::Result<Option<(String, String)>> {
|
|
let mut placeholder = String::new();
|
|
|
|
if let Some(title) = title {
|
|
placeholder.push_str(title.trim());
|
|
placeholder.push('\n');
|
|
}
|
|
if let Some(description) = description {
|
|
placeholder.push('\n');
|
|
placeholder.push_str(description.trim());
|
|
placeholder.push('\n');
|
|
}
|
|
placeholder.push_str(help);
|
|
|
|
let output = Self::Edit.get(&placeholder)?;
|
|
let Some((title, description)) = output.split_once("\n\n") else {
|
|
return Ok(None);
|
|
};
|
|
let (title, description) = (title.trim(), description.trim());
|
|
|
|
if title.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
Ok(Some((title.to_owned(), description.to_owned())))
|
|
}
|
|
|
|
pub fn append(&mut self, arg: &str) {
|
|
if let Message::Text(v) = self {
|
|
v.extend(["\n\n", arg]);
|
|
} else {
|
|
*self = Message::Text(arg.into());
|
|
};
|
|
}
|
|
}
|
|
|
|
impl Default for Message {
|
|
fn default() -> Self {
|
|
Self::Edit
|
|
}
|
|
}
|
|
|
|
pub const PATCH_MSG: &str = r#"
|
|
<!--
|
|
Please enter a patch message for your changes. An empty
|
|
message aborts the patch proposal.
|
|
|
|
The first line is the patch title. The patch description
|
|
follows, and must be separated with a blank line, just
|
|
like a commit message. Markdown is supported in the title
|
|
and description.
|
|
-->
|
|
"#;
|
|
|
|
const REVISION_MSG: &str = r#"
|
|
<!--
|
|
Please enter a comment for your patch update. Leaving this
|
|
blank is also okay.
|
|
-->
|
|
"#;
|
|
|
|
/// Combine the title and description fields to display to the user.
|
|
#[inline]
|
|
pub fn message(title: &str, description: &str) -> String {
|
|
format!("{title}\n\n{description}").trim().to_string()
|
|
}
|
|
|
|
/// Create a helpful default `Patch` message out of one or more commit messages.
|
|
fn message_from_commits(name: &str, commits: Vec<git::raw::Commit>) -> Result<String, Error> {
|
|
let mut commits = commits.into_iter().rev();
|
|
let count = commits.len();
|
|
let Some(commit) = commits.next() else {
|
|
return Ok(String::default());
|
|
};
|
|
let commit_msg = commit.message().ok_or(Error::InvalidUtf8)?.to_string();
|
|
|
|
if count == 1 {
|
|
return Ok(commit_msg);
|
|
}
|
|
|
|
// Many commits
|
|
let mut msg = String::new();
|
|
writeln!(&mut msg, "<!--")?;
|
|
writeln!(
|
|
&mut msg,
|
|
"This {name} is the combination of {count} commits.",
|
|
)?;
|
|
writeln!(&mut msg, "This is the first commit message:")?;
|
|
writeln!(&mut msg, "-->")?;
|
|
writeln!(&mut msg)?;
|
|
writeln!(&mut msg, "{}", commit_msg.trim_end())?;
|
|
writeln!(&mut msg)?;
|
|
|
|
for (i, commit) in commits.enumerate() {
|
|
let commit_msg = commit.message().ok_or(Error::InvalidUtf8)?.trim_end();
|
|
let commit_num = i + 2;
|
|
|
|
writeln!(&mut msg, "<!--")?;
|
|
writeln!(&mut msg, "This is commit message #{commit_num}:")?;
|
|
writeln!(&mut msg, "-->")?;
|
|
writeln!(&mut msg)?;
|
|
writeln!(&mut msg, "{commit_msg}")?;
|
|
writeln!(&mut msg)?;
|
|
}
|
|
|
|
Ok(msg)
|
|
}
|
|
|
|
/// Return commits between the merge base and a head.
|
|
pub fn patch_commits<'a>(
|
|
repo: &'a git::raw::Repository,
|
|
base: &git::Oid,
|
|
head: &git::Oid,
|
|
) -> Result<Vec<git::raw::Commit<'a>>, git::raw::Error> {
|
|
let mut commits = Vec::new();
|
|
let mut revwalk = repo.revwalk()?;
|
|
revwalk.push_range(&format!("{base}..{head}"))?;
|
|
|
|
for rev in revwalk {
|
|
let commit = repo.find_commit(rev?)?;
|
|
commits.push(commit);
|
|
}
|
|
Ok(commits)
|
|
}
|
|
|
|
/// The message shown in the editor when creating a `Patch`.
|
|
fn create_display_message(
|
|
repo: &git::raw::Repository,
|
|
base: &git::Oid,
|
|
head: &git::Oid,
|
|
) -> Result<String, Error> {
|
|
let commits = patch_commits(repo, base, head)?;
|
|
if commits.is_empty() {
|
|
return Ok(PATCH_MSG.trim_start().to_string());
|
|
}
|
|
|
|
let summary = message_from_commits("patch", commits)?;
|
|
let summary = summary.trim();
|
|
|
|
Ok(format!("{summary}\n{PATCH_MSG}"))
|
|
}
|
|
|
|
/// Get the Patch title and description from the command line arguments, or request it from the
|
|
/// user.
|
|
///
|
|
/// The user can bail out if an empty title is entered.
|
|
pub fn get_create_message(
|
|
message: term::patch::Message,
|
|
repo: &git::raw::Repository,
|
|
base: &git::Oid,
|
|
head: &git::Oid,
|
|
) -> Result<(String, String), Error> {
|
|
let display_msg = create_display_message(repo, base, head)?;
|
|
let message = message.get(&display_msg)?;
|
|
|
|
let (title, description) = message.split_once('\n').unwrap_or((&message, ""));
|
|
let (title, description) = (title.trim().to_string(), description.trim().to_string());
|
|
|
|
if title.is_empty() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"a patch title must be provided",
|
|
)
|
|
.into());
|
|
}
|
|
|
|
Ok((title, description))
|
|
}
|
|
|
|
/// The message shown in the editor when editing a `Patch`.
|
|
fn edit_display_message(title: &str, description: &str) -> String {
|
|
format!("{}\n\n{}\n{PATCH_MSG}", title, description)
|
|
.trim_start()
|
|
.to_string()
|
|
}
|
|
|
|
/// Get a patch edit message.
|
|
pub fn get_edit_message(
|
|
patch_message: term::patch::Message,
|
|
patch: &cob::patch::Patch,
|
|
) -> io::Result<(String, String)> {
|
|
let display_msg = edit_display_message(patch.title(), patch.description());
|
|
let patch_message = patch_message.get(&display_msg)?;
|
|
let patch_message = patch_message.replace(PATCH_MSG.trim(), ""); // Delete help message.
|
|
|
|
let (title, description) = patch_message
|
|
.split_once('\n')
|
|
.unwrap_or((&patch_message, ""));
|
|
let (title, description) = (title.trim().to_string(), description.trim().to_string());
|
|
|
|
if title.is_empty() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"a patch title must be provided",
|
|
));
|
|
}
|
|
|
|
Ok((title, description))
|
|
}
|
|
|
|
/// The message shown in the editor when updating a `Patch`.
|
|
fn update_display_message(
|
|
repo: &git::raw::Repository,
|
|
last_rev_head: &git::Oid,
|
|
head: &git::Oid,
|
|
) -> Result<String, Error> {
|
|
if !repo.graph_descendant_of(**head, **last_rev_head)? {
|
|
return Ok(REVISION_MSG.trim_start().to_string());
|
|
}
|
|
|
|
let commits = patch_commits(repo, last_rev_head, head)?;
|
|
if commits.is_empty() {
|
|
return Ok(REVISION_MSG.trim_start().to_string());
|
|
}
|
|
|
|
let summary = message_from_commits("patch", commits)?;
|
|
let summary = summary.trim();
|
|
|
|
Ok(format!("{summary}\n{REVISION_MSG}"))
|
|
}
|
|
|
|
/// Get a patch update message.
|
|
pub fn get_update_message(
|
|
message: term::patch::Message,
|
|
repo: &git::raw::Repository,
|
|
latest: &patch::Revision,
|
|
head: &git::Oid,
|
|
) -> Result<String, Error> {
|
|
let display_msg = update_display_message(repo, &latest.head(), head)?;
|
|
let message = message.get(&display_msg)?;
|
|
let message = message.trim();
|
|
|
|
Ok(message.to_owned())
|
|
}
|
|
|
|
/// List the given commits in a table.
|
|
pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
|
|
let mut table = term::Table::default();
|
|
|
|
for commit in commits {
|
|
let message = commit
|
|
.summary_bytes()
|
|
.unwrap_or_else(|| commit.message_bytes());
|
|
table.push([
|
|
term::format::secondary(term::format::oid(commit.id()).into()),
|
|
term::format::italic(String::from_utf8_lossy(message).to_string()),
|
|
]);
|
|
}
|
|
table.print();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Print commits ahead and behind.
|
|
pub fn print_commits_ahead_behind(
|
|
repo: &git::raw::Repository,
|
|
left: git::raw::Oid,
|
|
right: git::raw::Oid,
|
|
) -> anyhow::Result<()> {
|
|
let (ahead, behind) = repo.graph_ahead_behind(left, right)?;
|
|
|
|
term::info!(
|
|
"{} commit(s) ahead, {} commit(s) behind",
|
|
term::format::positive(ahead),
|
|
if behind > 0 {
|
|
term::format::negative(behind)
|
|
} else {
|
|
term::format::dim(behind)
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
use radicle::git::refname;
|
|
use radicle::test::fixtures;
|
|
use std::path;
|
|
|
|
fn commit(
|
|
repo: &git::raw::Repository,
|
|
branch: &git::RefStr,
|
|
parent: &git::Oid,
|
|
msg: &str,
|
|
) -> git::Oid {
|
|
let sig = git::raw::Signature::new(
|
|
"anonymous",
|
|
"anonymous@radicle.xyz",
|
|
&git::raw::Time::new(0, 0),
|
|
)
|
|
.unwrap();
|
|
let head = repo.find_commit(**parent).unwrap();
|
|
let tree =
|
|
git::write_tree(path::Path::new("README"), "Hello World!\n".as_bytes(), repo).unwrap();
|
|
|
|
let branch = git::refs::branch(branch);
|
|
let commit = git::commit(repo, &head, &branch, msg, &sig, &tree).unwrap();
|
|
|
|
commit.id().into()
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_display_message() {
|
|
let tmpdir = tempfile::tempdir().unwrap();
|
|
let (repo, commit_0) = fixtures::repository(&tmpdir);
|
|
let commit_0 = commit_0.into();
|
|
let commit_1 = commit(
|
|
&repo,
|
|
&refname!("feature"),
|
|
&commit_0,
|
|
"Commit 1\n\nDescription\n",
|
|
);
|
|
let commit_2 = commit(
|
|
&repo,
|
|
&refname!("feature"),
|
|
&commit_1,
|
|
"Commit 2\n\nDescription\n",
|
|
);
|
|
|
|
let res = create_display_message(&repo, &commit_0, &commit_0).unwrap();
|
|
assert_eq!(
|
|
"\
|
|
<!--\n\
|
|
Please enter a patch message for your changes. An empty\n\
|
|
message aborts the patch proposal.\n\
|
|
\n\
|
|
The first line is the patch title. The patch description\n\
|
|
follows, and must be separated with a blank line, just\n\
|
|
like a commit message. Markdown is supported in the title\n\
|
|
and description.\n\
|
|
-->\n\
|
|
",
|
|
res
|
|
);
|
|
|
|
let res = create_display_message(&repo, &commit_0, &commit_1).unwrap();
|
|
assert_eq!(
|
|
"\
|
|
Commit 1\n\
|
|
\n\
|
|
Description\n\
|
|
\n\
|
|
<!--\n\
|
|
Please enter a patch message for your changes. An empty\n\
|
|
message aborts the patch proposal.\n\
|
|
\n\
|
|
The first line is the patch title. The patch description\n\
|
|
follows, and must be separated with a blank line, just\n\
|
|
like a commit message. Markdown is supported in the title\n\
|
|
and description.\n\
|
|
-->\n\
|
|
",
|
|
res
|
|
);
|
|
|
|
let res = create_display_message(&repo, &commit_0, &commit_2).unwrap();
|
|
assert_eq!(
|
|
"\
|
|
<!--\n\
|
|
This patch is the combination of 2 commits.\n\
|
|
This is the first commit message:\n\
|
|
-->\n\
|
|
\n\
|
|
Commit 1\n\
|
|
\n\
|
|
Description\n\
|
|
\n\
|
|
<!--\n\
|
|
This is commit message #2:\n\
|
|
-->\n\
|
|
\n\
|
|
Commit 2\n\
|
|
\n\
|
|
Description\n\
|
|
\n\
|
|
<!--\n\
|
|
Please enter a patch message for your changes. An empty\n\
|
|
message aborts the patch proposal.\n\
|
|
\n\
|
|
The first line is the patch title. The patch description\n\
|
|
follows, and must be separated with a blank line, just\n\
|
|
like a commit message. Markdown is supported in the title\n\
|
|
and description.\n\
|
|
-->\n\
|
|
",
|
|
res
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_edit_display_message() {
|
|
let res = edit_display_message("title", "The patch description.");
|
|
assert_eq!(
|
|
"\
|
|
title\n\
|
|
\n\
|
|
The patch description.\n\
|
|
\n\
|
|
<!--\n\
|
|
Please enter a patch message for your changes. An empty\n\
|
|
message aborts the patch proposal.\n\
|
|
\n\
|
|
The first line is the patch title. The patch description\n\
|
|
follows, and must be separated with a blank line, just\n\
|
|
like a commit message. Markdown is supported in the title\n\
|
|
and description.\n\
|
|
-->\n\
|
|
",
|
|
res
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_display_message() {
|
|
let tmpdir = tempfile::tempdir().unwrap();
|
|
let (repo, commit_0) = fixtures::repository(&tmpdir);
|
|
let commit_0 = commit_0.into();
|
|
|
|
let commit_1 = commit(&repo, &refname!("feature"), &commit_0, "commit 1\n");
|
|
let commit_2 = commit(&repo, &refname!("feature"), &commit_1, "commit 2\n");
|
|
let commit_squashed = commit(
|
|
&repo,
|
|
&refname!("squashed-feature"),
|
|
&commit_0,
|
|
"commit squashed",
|
|
);
|
|
|
|
let res = update_display_message(&repo, &commit_1, &commit_1).unwrap();
|
|
assert_eq!(
|
|
"\
|
|
<!--\n\
|
|
Please enter a comment for your patch update. Leaving this\n\
|
|
blank is also okay.\n\
|
|
-->\n\
|
|
",
|
|
res
|
|
);
|
|
|
|
let res = update_display_message(&repo, &commit_1, &commit_2).unwrap();
|
|
assert_eq!(
|
|
"\
|
|
commit 2\n\
|
|
\n\
|
|
<!--\n\
|
|
Please enter a comment for your patch update. Leaving this\n\
|
|
blank is also okay.\n\
|
|
-->\n\
|
|
",
|
|
res
|
|
);
|
|
|
|
let res = update_display_message(&repo, &commit_1, &commit_squashed).unwrap();
|
|
assert_eq!(
|
|
"\
|
|
<!--\n\
|
|
Please enter a comment for your patch update. Leaving this\n\
|
|
blank is also okay.\n\
|
|
-->\n\
|
|
",
|
|
res
|
|
);
|
|
}
|
|
}
|