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::config::ConfigGetError;
17use jj_lib::file_util::IoResultExt as _;
18use jj_lib::file_util::PathError;
19use jj_lib::settings::UserSettings;
20use thiserror::Error;
21
22use crate::cli_util::short_commit_hash;
23use crate::cli_util::WorkspaceCommandTransaction;
24use crate::command_error::CommandError;
25use crate::config::CommandNameAndArgs;
26use crate::formatter::PlainTextFormatter;
27use crate::text_util;
28use crate::ui::Ui;
29
30#[derive(Debug, Error)]
31pub enum TextEditError {
32 #[error("Failed to run editor '{name}'")]
33 FailedToRun { name: String, source: io::Error },
34 #[error("Editor '{command}' exited with {status}")]
35 ExitStatus { command: String, status: ExitStatus },
36}
37
38#[derive(Debug, Error)]
39#[error("Failed to edit {name}", name = name.as_deref().unwrap_or("file"))]
40pub struct TempTextEditError {
41 #[source]
42 pub error: Box<dyn std::error::Error + Send + Sync>,
43 /// Short description of the edited content.
44 pub name: Option<String>,
45 /// Path to the temporary file.
46 pub path: Option<PathBuf>,
47}
48
49impl TempTextEditError {
50 fn new(error: Box<dyn std::error::Error + Send + Sync>, path: Option<PathBuf>) -> Self {
51 TempTextEditError {
52 error,
53 name: None,
54 path,
55 }
56 }
57
58 /// Adds short description of the edited content.
59 pub fn with_name(mut self, name: impl Into<String>) -> Self {
60 self.name = Some(name.into());
61 self
62 }
63}
64
65/// Configured text editor.
66#[derive(Clone, Debug)]
67pub struct TextEditor {
68 editor: CommandNameAndArgs,
69 dir: Option<PathBuf>,
70}
71
72impl TextEditor {
73 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
74 let editor = settings.get("ui.editor")?;
75 Ok(TextEditor { editor, dir: None })
76 }
77
78 pub fn with_temp_dir(mut self, dir: impl Into<PathBuf>) -> Self {
79 self.dir = Some(dir.into());
80 self
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 = self.dir.clone().unwrap_or_else(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 template = description_template(ui, tx, "", temp_commit)?;
205 bulk_message.push_str(&template);
206 append_blank_line(&mut bulk_message);
207 }
208 bulk_message.push_str("JJ: Lines starting with \"JJ:\" (like this one) will be removed.\n");
209
210 let bulk_message = editor
211 .edit_str(bulk_message, Some(".jjdescription"))
212 .map_err(|err| err.with_name("description"))?;
213
214 Ok(parse_bulk_edit_message(&bulk_message, &commits_map)?)
215}
216
217#[derive(Debug)]
218pub struct ParsedBulkEditMessage<T> {
219 /// The parsed, formatted descriptions.
220 pub descriptions: HashMap<T, String>,
221 /// Commit IDs that were expected while parsing the edited messages, but
222 /// which were not found.
223 pub missing: Vec<String>,
224 /// Commit IDs that were found multiple times while parsing the edited
225 /// messages.
226 pub duplicates: Vec<String>,
227 /// Commit IDs that were found while parsing the edited messages, but which
228 /// were not originally being edited.
229 pub unexpected: Vec<String>,
230}
231
232#[derive(Debug, Error, PartialEq)]
233pub enum ParseBulkEditMessageError {
234 #[error(r#"Found the following line without a commit header: "{0}""#)]
235 LineWithoutCommitHeader(String),
236}
237
238/// Parse the bulk message of edited commit descriptions.
239fn parse_bulk_edit_message<T>(
240 message: &str,
241 commit_ids_map: &IndexMap<String, &T>,
242) -> Result<ParsedBulkEditMessage<T>, ParseBulkEditMessageError>
243where
244 T: Eq + std::hash::Hash + Clone,
245{
246 let mut descriptions = HashMap::new();
247 let mut duplicates = Vec::new();
248 let mut unexpected = Vec::new();
249
250 let mut messages: Vec<(&str, Vec<&str>)> = vec![];
251 for line in message.lines() {
252 if let Some(commit_id_prefix) = line.strip_prefix("JJ: describe ") {
253 let commit_id_prefix =
254 commit_id_prefix.trim_end_matches(|c: char| c.is_ascii_whitespace() || c == '-');
255 messages.push((commit_id_prefix, vec![]));
256 } else if let Some((_, lines)) = messages.last_mut() {
257 lines.push(line);
258 }
259 // Do not allow lines without a commit header, except for empty lines or comments.
260 else if !line.trim().is_empty() && !line.starts_with("JJ:") {
261 return Err(ParseBulkEditMessageError::LineWithoutCommitHeader(
262 line.to_owned(),
263 ));
264 };
265 }
266
267 for (commit_id_prefix, description_lines) in messages {
268 let Some(&commit_id) = commit_ids_map.get(commit_id_prefix) else {
269 unexpected.push(commit_id_prefix.to_string());
270 continue;
271 };
272 if descriptions.contains_key(commit_id) {
273 duplicates.push(commit_id_prefix.to_string());
274 continue;
275 }
276 descriptions.insert(
277 commit_id.clone(),
278 cleanup_description_lines(&description_lines),
279 );
280 }
281
282 let missing: Vec<_> = commit_ids_map
283 .iter()
284 .filter(|(_, commit_id)| !descriptions.contains_key(*commit_id))
285 .map(|(commit_id_prefix, _)| commit_id_prefix.to_string())
286 .collect();
287
288 Ok(ParsedBulkEditMessage {
289 descriptions,
290 missing,
291 duplicates,
292 unexpected,
293 })
294}
295
296/// Combines the descriptions from the input commits. If only one is non-empty,
297/// then that one is used.
298pub fn try_combine_messages(sources: &[Commit], destination: &Commit) -> Option<String> {
299 let non_empty = sources
300 .iter()
301 .chain(std::iter::once(destination))
302 .filter(|c| !c.description().is_empty())
303 .take(2)
304 .collect_vec();
305 match *non_empty.as_slice() {
306 [] => Some(String::new()),
307 [commit] => Some(commit.description().to_owned()),
308 [_, _, ..] => None,
309 }
310}
311
312/// Produces a combined description with "JJ: " comment lines.
313///
314/// This includes empty descriptins too, so the user doesn't have to wonder why
315/// they only see 2 descriptions when they combined 3 commits.
316pub fn combine_messages_for_editing(sources: &[Commit], destination: &Commit) -> String {
317 let mut combined = String::new();
318 combined.push_str("JJ: Description from the destination commit:\n");
319 combined.push_str(destination.description());
320 for commit in sources {
321 combined.push_str("\nJJ: Description from source commit:\n");
322 combined.push_str(commit.description());
323 }
324 combined
325}
326
327/// Create a description from a list of paragraphs.
328///
329/// Based on the Git CLI behavior. See `opt_parse_m()` and `cleanup_mode` in
330/// `git/builtin/commit.c`.
331pub fn join_message_paragraphs(paragraphs: &[String]) -> String {
332 // Ensure each paragraph ends with a newline, then add another newline between
333 // paragraphs.
334 paragraphs
335 .iter()
336 .map(|p| text_util::complete_newline(p.as_str()))
337 .join("\n")
338}
339
340/// Renders commit description template, which will be edited by user.
341pub fn description_template(
342 ui: &Ui,
343 tx: &WorkspaceCommandTransaction,
344 intro: &str,
345 commit: &Commit,
346) -> Result<String, CommandError> {
347 // TODO: Should "ui.default-description" be deprecated?
348 // We might want default description templates per command instead. For
349 // example, "backout_description" template will be rendered against the
350 // commit to be backed out, and the generated description could be set
351 // without spawning editor.
352
353 // Named as "draft" because the output can contain "JJ:" comment lines.
354 let template_key = "templates.draft_commit_description";
355 let template_text = tx.settings().get_string(template_key)?;
356 let template = tx.parse_commit_template(ui, &template_text)?;
357
358 let mut output = Vec::new();
359 if !intro.is_empty() {
360 writeln!(output, "JJ: {intro}").unwrap();
361 }
362 template
363 .format(commit, &mut PlainTextFormatter::new(&mut output))
364 .expect("write() to vec backed formatter should never fail");
365 // Template output is usually UTF-8, but it can contain file content.
366 Ok(output.into_string_lossy())
367}
368
369#[cfg(test)]
370mod tests {
371 use indexmap::indexmap;
372 use indoc::indoc;
373 use maplit::hashmap;
374
375 use super::parse_bulk_edit_message;
376 use crate::description_util::ParseBulkEditMessageError;
377
378 #[test]
379 fn test_parse_complete_bulk_edit_message() {
380 let result = parse_bulk_edit_message(
381 indoc! {"
382 JJ: describe 1 -------
383 Description 1
384
385 JJ: describe 2
386 Description 2
387
388 JJ: describe 3 --
389 Description 3
390 "},
391 &indexmap! {
392 "1".to_string() => &1,
393 "2".to_string() => &2,
394 "3".to_string() => &3,
395 },
396 )
397 .unwrap();
398 assert_eq!(
399 result.descriptions,
400 hashmap! {
401 1 => "Description 1\n".to_string(),
402 2 => "Description 2\n".to_string(),
403 3 => "Description 3\n".to_string(),
404 }
405 );
406 assert!(result.missing.is_empty());
407 assert!(result.duplicates.is_empty());
408 assert!(result.unexpected.is_empty());
409 }
410
411 #[test]
412 fn test_parse_bulk_edit_message_with_missing_descriptions() {
413 let result = parse_bulk_edit_message(
414 indoc! {"
415 JJ: describe 1 -------
416 Description 1
417 "},
418 &indexmap! {
419 "1".to_string() => &1,
420 "2".to_string() => &2,
421 },
422 )
423 .unwrap();
424 assert_eq!(
425 result.descriptions,
426 hashmap! {
427 1 => "Description 1\n".to_string(),
428 }
429 );
430 assert_eq!(result.missing, vec!["2".to_string()]);
431 assert!(result.duplicates.is_empty());
432 assert!(result.unexpected.is_empty());
433 }
434
435 #[test]
436 fn test_parse_bulk_edit_message_with_duplicate_descriptions() {
437 let result = parse_bulk_edit_message(
438 indoc! {"
439 JJ: describe 1 -------
440 Description 1
441
442 JJ: describe 1 -------
443 Description 1 (repeated)
444 "},
445 &indexmap! {
446 "1".to_string() => &1,
447 },
448 )
449 .unwrap();
450 assert_eq!(
451 result.descriptions,
452 hashmap! {
453 1 => "Description 1\n".to_string(),
454 }
455 );
456 assert!(result.missing.is_empty());
457 assert_eq!(result.duplicates, vec!["1".to_string()]);
458 assert!(result.unexpected.is_empty());
459 }
460
461 #[test]
462 fn test_parse_bulk_edit_message_with_unexpected_descriptions() {
463 let result = parse_bulk_edit_message(
464 indoc! {"
465 JJ: describe 1 -------
466 Description 1
467
468 JJ: describe 3 -------
469 Description 3 (unexpected)
470 "},
471 &indexmap! {
472 "1".to_string() => &1,
473 },
474 )
475 .unwrap();
476 assert_eq!(
477 result.descriptions,
478 hashmap! {
479 1 => "Description 1\n".to_string(),
480 }
481 );
482 assert!(result.missing.is_empty());
483 assert!(result.duplicates.is_empty());
484 assert_eq!(result.unexpected, vec!["3".to_string()]);
485 }
486
487 #[test]
488 fn test_parse_bulk_edit_message_with_no_header() {
489 let result = parse_bulk_edit_message(
490 indoc! {"
491 Description 1
492 "},
493 &indexmap! {
494 "1".to_string() => &1,
495 },
496 );
497 assert_eq!(
498 result.unwrap_err(),
499 ParseBulkEditMessageError::LineWithoutCommitHeader("Description 1".to_string())
500 );
501 }
502
503 #[test]
504 fn test_parse_bulk_edit_message_with_comment_before_header() {
505 let result = parse_bulk_edit_message(
506 indoc! {"
507 JJ: Custom comment and empty lines below should be accepted
508
509
510 JJ: describe 1 -------
511 Description 1
512 "},
513 &indexmap! {
514 "1".to_string() => &1,
515 },
516 )
517 .unwrap();
518 assert_eq!(
519 result.descriptions,
520 hashmap! {
521 1 => "Description 1\n".to_string(),
522 }
523 );
524 assert!(result.missing.is_empty());
525 assert!(result.duplicates.is_empty());
526 assert!(result.unexpected.is_empty());
527 }
528}