just playing with tangled
at main 628 lines 21 kB view raw
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}