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