forked from mirrors/jj
415 lines
13 KiB
Rust
415 lines
13 KiB
Rust
use std::collections::HashMap;
|
|
use std::io::Write as _;
|
|
|
|
use bstr::ByteVec as _;
|
|
use indexmap::IndexMap;
|
|
use indoc::indoc;
|
|
use itertools::Itertools;
|
|
use jj_lib::backend::CommitId;
|
|
use jj_lib::commit::Commit;
|
|
use jj_lib::settings::UserSettings;
|
|
use thiserror::Error;
|
|
|
|
use crate::cli_util::edit_temp_file;
|
|
use crate::cli_util::short_commit_hash;
|
|
use crate::cli_util::WorkspaceCommandHelper;
|
|
use crate::cli_util::WorkspaceCommandTransaction;
|
|
use crate::command_error::CommandError;
|
|
use crate::formatter::PlainTextFormatter;
|
|
use crate::text_util;
|
|
use crate::ui::Ui;
|
|
|
|
/// Cleanup a description by normalizing line endings, and removing leading and
|
|
/// trailing blank lines.
|
|
fn cleanup_description_lines<I>(lines: I) -> String
|
|
where
|
|
I: IntoIterator,
|
|
I::Item: AsRef<str>,
|
|
{
|
|
let description = lines
|
|
.into_iter()
|
|
.filter(|line| !line.as_ref().starts_with("JJ: "))
|
|
.fold(String::new(), |acc, line| acc + line.as_ref() + "\n");
|
|
text_util::complete_newline(description.trim_matches('\n'))
|
|
}
|
|
|
|
pub fn edit_description(
|
|
workspace_command: &WorkspaceCommandHelper,
|
|
description: &str,
|
|
settings: &UserSettings,
|
|
) -> Result<String, CommandError> {
|
|
let description = format!(
|
|
r#"{description}
|
|
JJ: Lines starting with "JJ: " (like this one) will be removed.
|
|
"#
|
|
);
|
|
|
|
let description = edit_temp_file(
|
|
"description",
|
|
".jjdescription",
|
|
workspace_command.repo_path(),
|
|
&description,
|
|
settings,
|
|
)?;
|
|
|
|
Ok(cleanup_description_lines(description.lines()))
|
|
}
|
|
|
|
/// Edits the descriptions of the given commits in a single editor session.
|
|
pub fn edit_multiple_descriptions(
|
|
ui: &Ui,
|
|
tx: &WorkspaceCommandTransaction,
|
|
commits: &[(&CommitId, Commit)],
|
|
settings: &UserSettings,
|
|
) -> Result<ParsedBulkEditMessage<CommitId>, CommandError> {
|
|
let mut commits_map = IndexMap::new();
|
|
let mut bulk_message = String::new();
|
|
|
|
bulk_message.push_str(indoc! {r#"
|
|
JJ: Enter or edit commit descriptions after the `JJ: describe` lines.
|
|
JJ: Warning:
|
|
JJ: - The text you enter will be lost on a syntax error.
|
|
JJ: - The syntax of the separator lines may change in the future.
|
|
|
|
"#});
|
|
for (commit_id, temp_commit) in commits {
|
|
let commit_hash = short_commit_hash(commit_id);
|
|
bulk_message.push_str("JJ: describe ");
|
|
bulk_message.push_str(&commit_hash);
|
|
bulk_message.push_str(" -------\n");
|
|
commits_map.insert(commit_hash, *commit_id);
|
|
let template = description_template(ui, tx, "", temp_commit)?;
|
|
bulk_message.push_str(&template);
|
|
bulk_message.push('\n');
|
|
}
|
|
bulk_message.push_str("JJ: Lines starting with \"JJ: \" (like this one) will be removed.\n");
|
|
|
|
let bulk_message = edit_temp_file(
|
|
"description",
|
|
".jjdescription",
|
|
tx.base_workspace_helper().repo_path(),
|
|
&bulk_message,
|
|
settings,
|
|
)?;
|
|
|
|
Ok(parse_bulk_edit_message(&bulk_message, &commits_map)?)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ParsedBulkEditMessage<T> {
|
|
/// The parsed, formatted descriptions.
|
|
pub descriptions: HashMap<T, String>,
|
|
/// Commit IDs that were expected while parsing the edited messages, but
|
|
/// which were not found.
|
|
pub missing: Vec<String>,
|
|
/// Commit IDs that were found multiple times while parsing the edited
|
|
/// messages.
|
|
pub duplicates: Vec<String>,
|
|
/// Commit IDs that were found while parsing the edited messages, but which
|
|
/// were not originally being edited.
|
|
pub unexpected: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Error, PartialEq)]
|
|
pub enum ParseBulkEditMessageError {
|
|
#[error(r#"Found the following line without a commit header: "{0}""#)]
|
|
LineWithoutCommitHeader(String),
|
|
}
|
|
|
|
/// Parse the bulk message of edited commit descriptions.
|
|
fn parse_bulk_edit_message<T>(
|
|
message: &str,
|
|
commit_ids_map: &IndexMap<String, &T>,
|
|
) -> Result<ParsedBulkEditMessage<T>, ParseBulkEditMessageError>
|
|
where
|
|
T: Eq + std::hash::Hash + Clone,
|
|
{
|
|
let mut descriptions = HashMap::new();
|
|
let mut duplicates = Vec::new();
|
|
let mut unexpected = Vec::new();
|
|
|
|
let mut messages: Vec<(&str, Vec<&str>)> = vec![];
|
|
for line in message.lines() {
|
|
if let Some(commit_id_prefix) = line.strip_prefix("JJ: describe ") {
|
|
let commit_id_prefix =
|
|
commit_id_prefix.trim_end_matches(|c: char| c.is_ascii_whitespace() || c == '-');
|
|
messages.push((commit_id_prefix, vec![]));
|
|
} else if let Some((_, lines)) = messages.last_mut() {
|
|
lines.push(line);
|
|
}
|
|
// Do not allow lines without a commit header, except for empty lines or comments.
|
|
else if !line.trim().is_empty() && !line.starts_with("JJ: ") {
|
|
return Err(ParseBulkEditMessageError::LineWithoutCommitHeader(
|
|
line.to_owned(),
|
|
));
|
|
};
|
|
}
|
|
|
|
for (commit_id_prefix, description_lines) in messages {
|
|
let Some(&commit_id) = commit_ids_map.get(commit_id_prefix) else {
|
|
unexpected.push(commit_id_prefix.to_string());
|
|
continue;
|
|
};
|
|
if descriptions.contains_key(commit_id) {
|
|
duplicates.push(commit_id_prefix.to_string());
|
|
continue;
|
|
}
|
|
descriptions.insert(
|
|
commit_id.clone(),
|
|
cleanup_description_lines(&description_lines),
|
|
);
|
|
}
|
|
|
|
let missing: Vec<_> = commit_ids_map
|
|
.iter()
|
|
.filter(|(_, commit_id)| !descriptions.contains_key(*commit_id))
|
|
.map(|(commit_id_prefix, _)| commit_id_prefix.to_string())
|
|
.collect();
|
|
|
|
Ok(ParsedBulkEditMessage {
|
|
descriptions,
|
|
missing,
|
|
duplicates,
|
|
unexpected,
|
|
})
|
|
}
|
|
|
|
/// Combines the descriptions from the input commits. If only one is non-empty,
|
|
/// then that one is used. Otherwise we concatenate the messages and ask the
|
|
/// user to edit the result in their editor.
|
|
pub fn combine_messages(
|
|
workspace_command: &WorkspaceCommandHelper,
|
|
sources: &[&Commit],
|
|
destination: &Commit,
|
|
settings: &UserSettings,
|
|
) -> Result<String, CommandError> {
|
|
let non_empty = sources
|
|
.iter()
|
|
.chain(std::iter::once(&destination))
|
|
.filter(|c| !c.description().is_empty())
|
|
.take(2)
|
|
.collect_vec();
|
|
match *non_empty.as_slice() {
|
|
[] => {
|
|
return Ok(String::new());
|
|
}
|
|
[commit] => {
|
|
return Ok(commit.description().to_owned());
|
|
}
|
|
_ => {}
|
|
}
|
|
// Produce a combined description with instructions for the user to edit.
|
|
// Include empty descriptins too, so the user doesn't have to wonder why they
|
|
// only see 2 descriptions when they combined 3 commits.
|
|
let mut combined = "JJ: Enter a description for the combined commit.".to_string();
|
|
combined.push_str("\nJJ: Description from the destination commit:\n");
|
|
combined.push_str(destination.description());
|
|
for commit in sources {
|
|
combined.push_str("\nJJ: Description from source commit:\n");
|
|
combined.push_str(commit.description());
|
|
}
|
|
edit_description(workspace_command, &combined, settings)
|
|
}
|
|
|
|
/// Create a description from a list of paragraphs.
|
|
///
|
|
/// Based on the Git CLI behavior. See `opt_parse_m()` and `cleanup_mode` in
|
|
/// `git/builtin/commit.c`.
|
|
pub fn join_message_paragraphs(paragraphs: &[String]) -> String {
|
|
// Ensure each paragraph ends with a newline, then add another newline between
|
|
// paragraphs.
|
|
paragraphs
|
|
.iter()
|
|
.map(|p| text_util::complete_newline(p.as_str()))
|
|
.join("\n")
|
|
}
|
|
|
|
/// Renders commit description template, which will be edited by user.
|
|
pub fn description_template(
|
|
ui: &Ui,
|
|
tx: &WorkspaceCommandTransaction,
|
|
intro: &str,
|
|
commit: &Commit,
|
|
) -> Result<String, CommandError> {
|
|
// TODO: Should "ui.default-description" be deprecated?
|
|
// We might want default description templates per command instead. For
|
|
// example, "backout_description" template will be rendered against the
|
|
// commit to be backed out, and the generated description could be set
|
|
// without spawning editor.
|
|
|
|
// Named as "draft" because the output can contain "JJ: " comment lines.
|
|
let template_key = "templates.draft_commit_description";
|
|
let template_text = tx.settings().config().get_string(template_key)?;
|
|
let template = tx.parse_commit_template(ui, &template_text)?;
|
|
|
|
let mut output = Vec::new();
|
|
if !intro.is_empty() {
|
|
writeln!(output, "JJ: {intro}").unwrap();
|
|
}
|
|
template
|
|
.format(commit, &mut PlainTextFormatter::new(&mut output))
|
|
.expect("write() to vec backed formatter should never fail");
|
|
// Template output is usually UTF-8, but it can contain file content.
|
|
Ok(output.into_string_lossy())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use indexmap::indexmap;
|
|
use indoc::indoc;
|
|
use maplit::hashmap;
|
|
|
|
use super::parse_bulk_edit_message;
|
|
use crate::description_util::ParseBulkEditMessageError;
|
|
|
|
#[test]
|
|
fn test_parse_complete_bulk_edit_message() {
|
|
let result = parse_bulk_edit_message(
|
|
indoc! {"
|
|
JJ: describe 1 -------
|
|
Description 1
|
|
|
|
JJ: describe 2
|
|
Description 2
|
|
|
|
JJ: describe 3 --
|
|
Description 3
|
|
"},
|
|
&indexmap! {
|
|
"1".to_string() => &1,
|
|
"2".to_string() => &2,
|
|
"3".to_string() => &3,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
result.descriptions,
|
|
hashmap! {
|
|
1 => "Description 1\n".to_string(),
|
|
2 => "Description 2\n".to_string(),
|
|
3 => "Description 3\n".to_string(),
|
|
}
|
|
);
|
|
assert!(result.missing.is_empty());
|
|
assert!(result.duplicates.is_empty());
|
|
assert!(result.unexpected.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_bulk_edit_message_with_missing_descriptions() {
|
|
let result = parse_bulk_edit_message(
|
|
indoc! {"
|
|
JJ: describe 1 -------
|
|
Description 1
|
|
"},
|
|
&indexmap! {
|
|
"1".to_string() => &1,
|
|
"2".to_string() => &2,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
result.descriptions,
|
|
hashmap! {
|
|
1 => "Description 1\n".to_string(),
|
|
}
|
|
);
|
|
assert_eq!(result.missing, vec!["2".to_string()]);
|
|
assert!(result.duplicates.is_empty());
|
|
assert!(result.unexpected.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_bulk_edit_message_with_duplicate_descriptions() {
|
|
let result = parse_bulk_edit_message(
|
|
indoc! {"
|
|
JJ: describe 1 -------
|
|
Description 1
|
|
|
|
JJ: describe 1 -------
|
|
Description 1 (repeated)
|
|
"},
|
|
&indexmap! {
|
|
"1".to_string() => &1,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
result.descriptions,
|
|
hashmap! {
|
|
1 => "Description 1\n".to_string(),
|
|
}
|
|
);
|
|
assert!(result.missing.is_empty());
|
|
assert_eq!(result.duplicates, vec!["1".to_string()]);
|
|
assert!(result.unexpected.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_bulk_edit_message_with_unexpected_descriptions() {
|
|
let result = parse_bulk_edit_message(
|
|
indoc! {"
|
|
JJ: describe 1 -------
|
|
Description 1
|
|
|
|
JJ: describe 3 -------
|
|
Description 3 (unexpected)
|
|
"},
|
|
&indexmap! {
|
|
"1".to_string() => &1,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
result.descriptions,
|
|
hashmap! {
|
|
1 => "Description 1\n".to_string(),
|
|
}
|
|
);
|
|
assert!(result.missing.is_empty());
|
|
assert!(result.duplicates.is_empty());
|
|
assert_eq!(result.unexpected, vec!["3".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_bulk_edit_message_with_no_header() {
|
|
let result = parse_bulk_edit_message(
|
|
indoc! {"
|
|
Description 1
|
|
"},
|
|
&indexmap! {
|
|
"1".to_string() => &1,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
result.unwrap_err(),
|
|
ParseBulkEditMessageError::LineWithoutCommitHeader("Description 1".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_bulk_edit_message_with_comment_before_header() {
|
|
let result = parse_bulk_edit_message(
|
|
indoc! {"
|
|
JJ: Custom comment and empty lines below should be accepted
|
|
|
|
|
|
JJ: describe 1 -------
|
|
Description 1
|
|
"},
|
|
&indexmap! {
|
|
"1".to_string() => &1,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
result.descriptions,
|
|
hashmap! {
|
|
1 => "Description 1\n".to_string(),
|
|
}
|
|
);
|
|
assert!(result.missing.is_empty());
|
|
assert!(result.duplicates.is_empty());
|
|
assert!(result.unexpected.is_empty());
|
|
}
|
|
}
|