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 pub name: Option<String>,
52 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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
67 self.name = Some(name.into());
68 self
69 }
70}
71
72#[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 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 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 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
153fn 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
188pub 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 pub descriptions: HashMap<T, String>,
229 pub missing: Vec<String>,
232 pub duplicates: Vec<String>,
235 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
246fn 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 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
304pub 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
320pub 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 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
367pub fn join_message_paragraphs(paragraphs: &[String]) -> String {
372 paragraphs
375 .iter()
376 .map(|p| text_util::complete_newline(p.as_str()))
377 .join("\n")
378}
379
380pub 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
395pub 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 description.push('\n');
414 }
415 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
426pub 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
443pub fn description_template(
445 ui: &Ui,
446 tx: &WorkspaceCommandTransaction,
447 intro: &str,
448 commit: &Commit,
449) -> Result<String, CommandError> {
450 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 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}