Skip to main content

jj_cli/
description_util.rs

1use std::collections::HashMap;
2use std::fmt::Write as _;
3use std::fs;
4use std::io;
5use std::io::Write as _;
6use std::path::Path;
7use std::path::PathBuf;
8use std::process::ExitStatus;
9
10use bstr::ByteVec as _;
11use indexmap::IndexMap;
12use indoc::indoc;
13use itertools::FoldWhile;
14use itertools::Itertools as _;
15use jj_lib::backend::CommitId;
16use jj_lib::commit::Commit;
17use jj_lib::commit_builder::DetachedCommitBuilder;
18use jj_lib::config::ConfigGetError;
19use jj_lib::file_util::IoResultExt as _;
20use jj_lib::file_util::PathError;
21use jj_lib::settings::UserSettings;
22use jj_lib::trailer::parse_description_trailers;
23use jj_lib::trailer::parse_trailers;
24use thiserror::Error;
25
26use crate::cli_util::WorkspaceCommandTransaction;
27use crate::cli_util::short_commit_hash;
28use crate::command_error::CommandError;
29use crate::command_error::user_error;
30use crate::config::CommandNameAndArgs;
31use crate::formatter::PlainTextFormatter;
32use crate::templater::TemplateRenderer;
33use crate::text_util;
34use crate::ui::Ui;
35
36#[derive(Debug, Error)]
37pub enum TextEditError {
38    #[error("Failed to run editor '{name}'")]
39    FailedToRun { name: String, source: io::Error },
40    #[error("Editor '{command}' exited with {status}")]
41    ExitStatus { command: String, status: ExitStatus },
42}
43
44#[derive(Debug, Error)]
45#[error("Failed to edit {name}", name = name.as_deref().unwrap_or("file"))]
46pub struct TempTextEditError {
47    #[source]
48    pub error: Box<dyn std::error::Error + Send + Sync>,
49    /// Short description of the edited content.
50    pub name: Option<String>,
51    /// Path to the temporary file.
52    pub path: Option<PathBuf>,
53}
54
55impl TempTextEditError {
56    fn new(error: Box<dyn std::error::Error + Send + Sync>, path: Option<PathBuf>) -> Self {
57        Self {
58            error,
59            name: None,
60            path,
61        }
62    }
63
64    /// Adds short description of the edited content.
65    pub fn with_name(mut self, name: impl Into<String>) -> Self {
66        self.name = Some(name.into());
67        self
68    }
69}
70
71/// Configured text editor.
72#[derive(Clone, Debug)]
73pub struct TextEditor {
74    editor: CommandNameAndArgs,
75    dir: Option<PathBuf>,
76}
77
78impl TextEditor {
79    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
80        let editor = settings.get("ui.editor")?;
81        Ok(Self { editor, dir: None })
82    }
83
84    pub fn with_temp_dir(mut self, dir: impl Into<PathBuf>) -> Self {
85        self.dir = Some(dir.into());
86        self
87    }
88
89    /// Opens the given `path` in editor.
90    pub fn edit_file(&self, path: impl AsRef<Path>) -> Result<(), TextEditError> {
91        let mut cmd = self.editor.to_command();
92        cmd.arg(path.as_ref());
93        tracing::info!(?cmd, "running editor");
94        let status = cmd.status().map_err(|source| TextEditError::FailedToRun {
95            name: self.editor.split_name().into_owned(),
96            source,
97        })?;
98        if status.success() {
99            Ok(())
100        } else {
101            let command = self.editor.to_string();
102            Err(TextEditError::ExitStatus { command, status })
103        }
104    }
105
106    /// Writes the given `content` to temporary file and opens it in editor.
107    pub fn edit_str(
108        &self,
109        content: impl AsRef<[u8]>,
110        suffix: Option<&str>,
111    ) -> Result<String, TempTextEditError> {
112        let path = self
113            .write_temp_file(content.as_ref(), suffix)
114            .map_err(|err| TempTextEditError::new(err.into(), None))?;
115        self.edit_file(&path)
116            .map_err(|err| TempTextEditError::new(err.into(), Some(path.clone())))?;
117        let edited = fs::read_to_string(&path)
118            .context(&path)
119            .map_err(|err| TempTextEditError::new(err.into(), Some(path.clone())))?;
120        // Delete the file only if everything went well.
121        fs::remove_file(path).ok();
122        Ok(edited)
123    }
124
125    fn write_temp_file(&self, content: &[u8], suffix: Option<&str>) -> Result<PathBuf, PathError> {
126        let dir = self.dir.clone().unwrap_or_else(tempfile::env::temp_dir);
127        let mut file = tempfile::Builder::new()
128            .prefix("editor-")
129            .suffix(suffix.unwrap_or(""))
130            .tempfile_in(&dir)
131            .context(&dir)?;
132        file.write_all(content).context(file.path())?;
133        let (_, path) = file
134            .keep()
135            .or_else(|err| Err(err.error).context(err.file.path()))?;
136        Ok(path)
137    }
138}
139
140fn append_blank_line(text: &mut String) {
141    if !text.is_empty() && !text.ends_with('\n') {
142        text.push('\n');
143    }
144    let last_line = text.lines().next_back();
145    if last_line.is_some_and(|line| line.starts_with("JJ:")) {
146        text.push_str("JJ:\n");
147    } else {
148        text.push('\n');
149    }
150}
151
152/// Cleanup a description by normalizing line endings, and removing leading and
153/// trailing blank lines.
154fn cleanup_description_lines<I>(lines: I) -> String
155where
156    I: IntoIterator,
157    I::Item: AsRef<str>,
158{
159    let description = lines
160        .into_iter()
161        .fold_while(String::new(), |acc, line| {
162            let line = line.as_ref();
163            if line.strip_prefix("JJ: ignore-rest").is_some() {
164                FoldWhile::Done(acc)
165            } else if line.starts_with("JJ:") {
166                FoldWhile::Continue(acc)
167            } else {
168                FoldWhile::Continue(acc + line + "\n")
169            }
170        })
171        .into_inner();
172    text_util::complete_newline(description.trim_matches('\n'))
173}
174
175pub fn edit_description(editor: &TextEditor, description: &str) -> Result<String, CommandError> {
176    let mut description = description.to_owned();
177    append_blank_line(&mut description);
178    description.push_str("JJ: Lines starting with \"JJ:\" (like this one) will be removed.\n");
179
180    let description = editor
181        .edit_str(description, Some(".jjdescription"))
182        .map_err(|err| err.with_name("description"))?;
183
184    Ok(cleanup_description_lines(description.lines()))
185}
186
187/// Edits the descriptions of the given commits in a single editor session.
188pub fn edit_multiple_descriptions(
189    ui: &Ui,
190    editor: &TextEditor,
191    tx: &WorkspaceCommandTransaction,
192    commits: &[(&CommitId, Commit)],
193) -> Result<ParsedBulkEditMessage<CommitId>, CommandError> {
194    let mut commits_map = IndexMap::new();
195    let mut bulk_message = String::new();
196
197    bulk_message.push_str(indoc! {r#"
198        JJ: Enter or edit commit descriptions after the `JJ: describe` lines.
199        JJ: Warning:
200        JJ: - The text you enter will be lost on a syntax error.
201        JJ: - The syntax of the separator lines may change in the future.
202        JJ:
203    "#});
204    for (commit_id, temp_commit) in commits {
205        let commit_hash = short_commit_hash(commit_id);
206        bulk_message.push_str("JJ: describe ");
207        bulk_message.push_str(&commit_hash);
208        bulk_message.push_str(" -------\n");
209        commits_map.insert(commit_hash, *commit_id);
210        let intro = "";
211        let template = description_template(ui, tx, intro, temp_commit)?;
212        bulk_message.push_str(&template);
213        append_blank_line(&mut bulk_message);
214    }
215    bulk_message.push_str("JJ: Lines starting with \"JJ:\" (like this one) will be removed.\n");
216
217    let bulk_message = editor
218        .edit_str(bulk_message, Some(".jjdescription"))
219        .map_err(|err| err.with_name("description"))?;
220
221    Ok(parse_bulk_edit_message(&bulk_message, &commits_map)?)
222}
223
224#[derive(Debug)]
225pub struct ParsedBulkEditMessage<T> {
226    /// The parsed, formatted descriptions.
227    pub descriptions: HashMap<T, String>,
228    /// Commit IDs that were expected while parsing the edited messages, but
229    /// which were not found.
230    pub missing: Vec<String>,
231    /// Commit IDs that were found multiple times while parsing the edited
232    /// messages.
233    pub duplicates: Vec<String>,
234    /// Commit IDs that were found while parsing the edited messages, but which
235    /// were not originally being edited.
236    pub unexpected: Vec<String>,
237}
238
239#[derive(Debug, Error, PartialEq)]
240pub enum ParseBulkEditMessageError {
241    #[error(r#"Found the following line without a commit header: "{0}""#)]
242    LineWithoutCommitHeader(String),
243}
244
245/// Parse the bulk message of edited commit descriptions.
246fn parse_bulk_edit_message<T>(
247    message: &str,
248    commit_ids_map: &IndexMap<String, &T>,
249) -> Result<ParsedBulkEditMessage<T>, ParseBulkEditMessageError>
250where
251    T: Eq + std::hash::Hash + Clone,
252{
253    let mut descriptions = HashMap::new();
254    let mut duplicates = Vec::new();
255    let mut unexpected = Vec::new();
256
257    let mut messages: Vec<(&str, Vec<&str>)> = vec![];
258    for line in message.lines() {
259        if let Some(commit_id_prefix) = line.strip_prefix("JJ: describe ") {
260            let commit_id_prefix =
261                commit_id_prefix.trim_end_matches(|c: char| c.is_ascii_whitespace() || c == '-');
262            messages.push((commit_id_prefix, vec![]));
263        } else if let Some((_, lines)) = messages.last_mut() {
264            lines.push(line);
265        }
266        // Do not allow lines without a commit header, except for empty lines or comments.
267        else if !line.trim().is_empty() && !line.starts_with("JJ:") {
268            return Err(ParseBulkEditMessageError::LineWithoutCommitHeader(
269                line.to_owned(),
270            ));
271        }
272    }
273
274    for (commit_id_prefix, description_lines) in messages {
275        let Some(&commit_id) = commit_ids_map.get(commit_id_prefix) else {
276            unexpected.push(commit_id_prefix.to_string());
277            continue;
278        };
279        if descriptions.contains_key(commit_id) {
280            duplicates.push(commit_id_prefix.to_string());
281            continue;
282        }
283        descriptions.insert(
284            commit_id.clone(),
285            cleanup_description_lines(&description_lines),
286        );
287    }
288
289    let missing: Vec<_> = commit_ids_map
290        .iter()
291        .filter(|(_, commit_id)| !descriptions.contains_key(*commit_id))
292        .map(|(commit_id_prefix, _)| commit_id_prefix.clone())
293        .collect();
294
295    Ok(ParsedBulkEditMessage {
296        descriptions,
297        missing,
298        duplicates,
299        unexpected,
300    })
301}
302
303/// Combines the descriptions from the input commits. If only one is non-empty,
304/// then that one is used.
305pub fn try_combine_messages(sources: &[Commit], destination: &Commit) -> Option<String> {
306    let non_empty = sources
307        .iter()
308        .chain(std::iter::once(destination))
309        .filter(|c| !c.description().is_empty())
310        .take(2)
311        .collect_vec();
312    match *non_empty.as_slice() {
313        [] => Some(String::new()),
314        [commit] => Some(commit.description().to_owned()),
315        [_, _, ..] => None,
316    }
317}
318
319/// Produces a combined description with "JJ: " comment lines.
320///
321/// This includes empty descriptins too, so the user doesn't have to wonder why
322/// they only see 2 descriptions when they combined 3 commits.
323pub fn combine_messages_for_editing(
324    ui: &Ui,
325    tx: &WorkspaceCommandTransaction,
326    sources: &[Commit],
327    destination: Option<&Commit>,
328    commit_builder: &DetachedCommitBuilder,
329) -> Result<String, CommandError> {
330    let mut combined = String::new();
331    if let Some(destination) = destination {
332        combined.push_str("JJ: Description from the destination commit:\n");
333        combined.push_str(destination.description());
334    }
335    for commit in sources {
336        combined.push_str("\nJJ: Description from source commit:\n");
337        combined.push_str(commit.description());
338    }
339
340    if let Some(template) = parse_trailers_template(ui, tx)? {
341        // show the user only trailers that were not in one of the squashed commits
342        let old_trailers: Vec<_> = sources
343            .iter()
344            .chain(destination)
345            .flat_map(|commit| parse_description_trailers(commit.description()))
346            .collect();
347        let commit = commit_builder.write_hidden()?;
348        let trailer_lines = template
349            .format_plain_text(&commit)
350            .into_string()
351            .map_err(|_| user_error("Trailers should be valid utf-8"))?;
352        let new_trailers = parse_trailers(&trailer_lines)?;
353        let mut trailers = new_trailers
354            .iter()
355            .filter(|&t| !old_trailers.contains(t))
356            .peekable();
357        if trailers.peek().is_some() {
358            combined.push_str("\nJJ: Trailers not found in the squashed commits:\n");
359            combined.extend(trailers.flat_map(|t| [&t.key, ": ", &t.value, "\n"]));
360        }
361    }
362
363    Ok(combined)
364}
365
366/// Create a description from a list of paragraphs.
367///
368/// Based on the Git CLI behavior. See `opt_parse_m()` and `cleanup_mode` in
369/// `git/builtin/commit.c`.
370pub fn join_message_paragraphs(paragraphs: &[String]) -> String {
371    // Ensure each paragraph ends with a newline, then add another newline between
372    // paragraphs.
373    paragraphs
374        .iter()
375        .map(|p| text_util::complete_newline(p.as_str()))
376        .join("\n")
377}
378
379/// Parse the commit trailers template from the configuration
380///
381/// Returns None if the commit trailers template is empty.
382pub fn parse_trailers_template<'a>(
383    ui: &Ui,
384    tx: &'a WorkspaceCommandTransaction,
385) -> Result<Option<TemplateRenderer<'a, Commit>>, CommandError> {
386    let trailer_template = tx.settings().get_string("templates.commit_trailers")?;
387    if trailer_template.is_empty() {
388        Ok(None)
389    } else {
390        tx.parse_commit_template(ui, &trailer_template).map(Some)
391    }
392}
393
394/// Add the trailers from the given `template` in the last paragraph of
395/// the description
396///
397/// It just lets the description untouched if the trailers are already there.
398pub fn add_trailers_with_template(
399    template: &TemplateRenderer<'_, Commit>,
400    commit: &Commit,
401) -> Result<String, CommandError> {
402    let trailers = parse_description_trailers(commit.description());
403    let trailer_lines = template
404        .format_plain_text(commit)
405        .into_string()
406        .map_err(|_| user_error("Trailers should be valid utf-8"))?;
407    let new_trailers = parse_trailers(&trailer_lines)?;
408    let mut description = commit.description().to_owned();
409    if trailers.is_empty() && !new_trailers.is_empty() {
410        if description.is_empty() {
411            // a first empty line where the user will edit the commit summary
412            description.push('\n');
413        }
414        // create a new paragraph for the trailer
415        description.push('\n');
416    }
417    for new_trailer in new_trailers {
418        if !trailers.contains(&new_trailer) {
419            writeln!(description, "{}: {}", new_trailer.key, new_trailer.value).unwrap();
420        }
421    }
422    Ok(description)
423}
424
425/// Add the trailers from `templates.commit_trailers` in the last paragraph of
426/// the description
427///
428/// It just lets the description untouched if the trailers are already there.
429pub fn add_trailers(
430    ui: &Ui,
431    tx: &WorkspaceCommandTransaction,
432    commit_builder: &DetachedCommitBuilder,
433) -> Result<String, CommandError> {
434    if let Some(renderer) = parse_trailers_template(ui, tx)? {
435        let commit = commit_builder.write_hidden()?;
436        add_trailers_with_template(&renderer, &commit)
437    } else {
438        Ok(commit_builder.description().to_owned())
439    }
440}
441
442/// Renders commit description template, which will be edited by user.
443pub fn description_template(
444    ui: &Ui,
445    tx: &WorkspaceCommandTransaction,
446    intro: &str,
447    commit: &Commit,
448) -> Result<String, CommandError> {
449    // Named as "draft" because the output can contain "JJ:" comment lines.
450    let template_key = "templates.draft_commit_description";
451    let template_text = tx.settings().get_string(template_key)?;
452    let template = tx.parse_commit_template(ui, &template_text)?;
453
454    let mut output = Vec::new();
455    if !intro.is_empty() {
456        writeln!(output, "JJ: {intro}").unwrap();
457    }
458    template
459        .format(commit, &mut PlainTextFormatter::new(&mut output))
460        .expect("write() to vec backed formatter should never fail");
461    // Template output is usually UTF-8, but it can contain file content.
462    Ok(output.into_string_lossy())
463}
464
465#[cfg(test)]
466mod tests {
467    use indexmap::indexmap;
468    use indoc::indoc;
469    use maplit::hashmap;
470
471    use super::parse_bulk_edit_message;
472    use crate::description_util::ParseBulkEditMessageError;
473
474    #[test]
475    fn test_parse_complete_bulk_edit_message() {
476        let result = parse_bulk_edit_message(
477            indoc! {"
478                JJ: describe 1 -------
479                Description 1
480
481                JJ: describe 2
482                Description 2
483
484                JJ: describe 3 --
485                Description 3
486            "},
487            &indexmap! {
488                "1".to_string() => &1,
489                "2".to_string() => &2,
490                "3".to_string() => &3,
491            },
492        )
493        .unwrap();
494        assert_eq!(
495            result.descriptions,
496            hashmap! {
497                1 => "Description 1\n".to_string(),
498                2 => "Description 2\n".to_string(),
499                3 => "Description 3\n".to_string(),
500            }
501        );
502        assert!(result.missing.is_empty());
503        assert!(result.duplicates.is_empty());
504        assert!(result.unexpected.is_empty());
505    }
506
507    #[test]
508    fn test_parse_bulk_edit_message_with_missing_descriptions() {
509        let result = parse_bulk_edit_message(
510            indoc! {"
511                JJ: describe 1 -------
512                Description 1
513            "},
514            &indexmap! {
515                "1".to_string() => &1,
516                "2".to_string() => &2,
517            },
518        )
519        .unwrap();
520        assert_eq!(
521            result.descriptions,
522            hashmap! {
523                1 => "Description 1\n".to_string(),
524            }
525        );
526        assert_eq!(result.missing, vec!["2".to_string()]);
527        assert!(result.duplicates.is_empty());
528        assert!(result.unexpected.is_empty());
529    }
530
531    #[test]
532    fn test_parse_bulk_edit_message_with_duplicate_descriptions() {
533        let result = parse_bulk_edit_message(
534            indoc! {"
535                JJ: describe 1 -------
536                Description 1
537
538                JJ: describe 1 -------
539                Description 1 (repeated)
540            "},
541            &indexmap! {
542                "1".to_string() => &1,
543            },
544        )
545        .unwrap();
546        assert_eq!(
547            result.descriptions,
548            hashmap! {
549                1 => "Description 1\n".to_string(),
550            }
551        );
552        assert!(result.missing.is_empty());
553        assert_eq!(result.duplicates, vec!["1".to_string()]);
554        assert!(result.unexpected.is_empty());
555    }
556
557    #[test]
558    fn test_parse_bulk_edit_message_with_unexpected_descriptions() {
559        let result = parse_bulk_edit_message(
560            indoc! {"
561                JJ: describe 1 -------
562                Description 1
563
564                JJ: describe 3 -------
565                Description 3 (unexpected)
566            "},
567            &indexmap! {
568                "1".to_string() => &1,
569            },
570        )
571        .unwrap();
572        assert_eq!(
573            result.descriptions,
574            hashmap! {
575                1 => "Description 1\n".to_string(),
576            }
577        );
578        assert!(result.missing.is_empty());
579        assert!(result.duplicates.is_empty());
580        assert_eq!(result.unexpected, vec!["3".to_string()]);
581    }
582
583    #[test]
584    fn test_parse_bulk_edit_message_with_no_header() {
585        let result = parse_bulk_edit_message(
586            indoc! {"
587                Description 1
588            "},
589            &indexmap! {
590                "1".to_string() => &1,
591            },
592        );
593        assert_eq!(
594            result.unwrap_err(),
595            ParseBulkEditMessageError::LineWithoutCommitHeader("Description 1".to_string())
596        );
597    }
598
599    #[test]
600    fn test_parse_bulk_edit_message_with_comment_before_header() {
601        let result = parse_bulk_edit_message(
602            indoc! {"
603                JJ: Custom comment and empty lines below should be accepted
604
605
606                JJ: describe 1 -------
607                Description 1
608            "},
609            &indexmap! {
610                "1".to_string() => &1,
611            },
612        )
613        .unwrap();
614        assert_eq!(
615            result.descriptions,
616            hashmap! {
617                1 => "Description 1\n".to_string(),
618            }
619        );
620        assert!(result.missing.is_empty());
621        assert!(result.duplicates.is_empty());
622        assert!(result.unexpected.is_empty());
623    }
624}