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 pub name: Option<String>,
51 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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
66 self.name = Some(name.into());
67 self
68 }
69}
70
71#[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 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 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 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
152fn 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
187pub 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 pub descriptions: HashMap<T, String>,
228 pub missing: Vec<String>,
231 pub duplicates: Vec<String>,
234 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
245fn 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 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
303pub 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
319pub 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 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
366pub fn join_message_paragraphs(paragraphs: &[String]) -> String {
371 paragraphs
374 .iter()
375 .map(|p| text_util::complete_newline(p.as_str()))
376 .join("\n")
377}
378
379pub 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
394pub 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 description.push('\n');
413 }
414 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
425pub 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
442pub fn description_template(
444 ui: &Ui,
445 tx: &WorkspaceCommandTransaction,
446 intro: &str,
447 commit: &Commit,
448) -> Result<String, CommandError> {
449 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 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}