415 lines · 12.8 KB · Rust
RawDownload
use std::collections::HashMap;use std::io::Write as _;use std::path::Path; 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::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) -> Stringwhere    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(    repo_path: &Path,    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",        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(    repo_path: &Path,    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(repo_path, &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());    }}