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