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