diff --git a/CHANGELOG.md b/CHANGELOG.md index 6498ec0c82e..6d9284a6dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * `jj absorb` now abandons the source commit if it becomes empty and has no description. +* `jj resolve` will now attempt to resolve all conflicted files instead of + resolving the first conflicted file. To resolve a single file, pass a file + path to `jj resolve`. + ### Deprecations * `--config-toml=TOML` is deprecated in favor of `--config=NAME=VALUE` and diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index 1c8980dfe8b..b67eb4a7ba8 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -1459,9 +1459,19 @@ to the current parents may contain changes from multiple commits. ) -> Result { let conflict_marker_style = self.env.conflict_marker_style(); if let Some(name) = tool_name { - MergeEditor::with_name(name, self.settings(), conflict_marker_style) + MergeEditor::with_name( + name, + self.settings(), + self.path_converter(), + conflict_marker_style, + ) } else { - MergeEditor::from_settings(ui, self.settings(), conflict_marker_style) + MergeEditor::from_settings( + ui, + self.settings(), + self.path_converter(), + conflict_marker_style, + ) } } diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs index cc29f5913cd..1bb696b8880 100644 --- a/cli/src/command_error.rs +++ b/cli/src/command_error.rs @@ -420,7 +420,12 @@ impl From for CommandError { impl From for CommandError { fn from(err: ConflictResolveError) -> Self { - user_error_with_message("Failed to resolve conflicts", err) + match err { + ConflictResolveError::Backend(err) => err.into(), + ConflictResolveError::Io(err) => err.into(), + ConflictResolveError::PartialResolution { .. } => user_error(err), + _ => user_error_with_message("Failed to resolve conflicts", err), + } } } diff --git a/cli/src/commands/resolve.rs b/cli/src/commands/resolve.rs index fd4df8110ef..043d48a55e9 100644 --- a/cli/src/commands/resolve.rs +++ b/cli/src/commands/resolve.rs @@ -26,12 +26,16 @@ use crate::cli_util::RevisionArg; use crate::command_error::cli_error; use crate::command_error::CommandError; use crate::complete; +use crate::merge_tools::ConflictResolveError; use crate::ui::Ui; -/// Resolve a conflicted file with an external merge tool +/// Resolve conflicted files with an external merge tool /// /// Only conflicts that can be resolved with a 3-way merge are supported. See -/// docs for merge tool configuration instructions. +/// docs for merge tool configuration instructions. External merge tools will be +/// invoked for each conflicted file one-by-one until all conflicts are +/// resolved. To stop resolving conflicts, exit the merge tool without making +/// any changes. /// /// Note that conflicts can also be resolved without using this command. You may /// edit the conflict markers in the conflicted file directly with a text @@ -52,7 +56,7 @@ pub(crate) struct ResolveArgs { add = ArgValueCandidates::new(complete::mutable_revisions), )] revision: RevisionArg, - /// Instead of resolving one conflict, list all the conflicts + /// Instead of resolving conflicts, list all the conflicts // TODO: Also have a `--summary` option. `--list` currently acts like // `diff --summary`, but should be more verbose. #[arg(long, short)] @@ -60,10 +64,8 @@ pub(crate) struct ResolveArgs { /// Specify 3-way merge tool to be used #[arg(long, conflicts_with = "list", value_name = "NAME")] tool: Option, - /// Restrict to these paths when searching for a conflict to resolve. We - /// will attempt to resolve the first conflict we can find. You can use - /// the `--list` argument to find paths to use here. - // TODO: Find the conflict we can resolve even if it's not the first one. + /// Only resolve conflicts in these paths. You can use the `--list` argument + /// to find paths to use here. #[arg( value_name = "FILESETS", value_hint = clap::ValueHint::AnyPath, @@ -103,16 +105,32 @@ pub(crate) fn cmd_resolve( ); }; - let (repo_path, _) = conflicts.first().unwrap(); + let repo_paths = conflicts + .iter() + .map(|(path, _)| path.as_ref()) + .collect_vec(); workspace_command.check_rewritable([commit.id()])?; - let merge_editor = workspace_command.merge_editor(ui, args.tool.as_deref())?; - writeln!( - ui.status(), - "Resolving conflicts in: {}", - workspace_command.format_file_path(repo_path) - )?; let mut tx = workspace_command.start_transaction(); - let new_tree_id = merge_editor.edit_file(&tree, repo_path)?; + let merge_editor = tx + .base_workspace_helper() + .merge_editor(ui, args.tool.as_deref())?; + let mut suppressed_error = None; + let new_tree_id = match merge_editor.edit_files(ui, &tree, &repo_paths) { + Ok(new_tree_id) => new_tree_id, + Err(err) => { + // If resolution failed completely, we can return immediately since there's + // nothing to commit + let ConflictResolveError::PartialResolution { resolved_tree, .. } = &err else { + return Err(err.into()); + }; + + // Some files were resolved successfully, so we want to commit the transaction + // before returning the error + let resolved_tree = resolved_tree.clone(); + suppressed_error = Some(err.into()); + resolved_tree + } + }; let new_commit = tx .repo_mut() .rewrite_commit(command.settings(), &commit) @@ -139,5 +157,9 @@ pub(crate) fn cmd_resolve( } } } + + if let Some(err) = suppressed_error { + return Err(err); + } Ok(()) } diff --git a/cli/src/merge_tools/builtin.rs b/cli/src/merge_tools/builtin.rs index 9f62b0af432..b545965d6a8 100644 --- a/cli/src/merge_tools/builtin.rs +++ b/cli/src/merge_tools/builtin.rs @@ -658,26 +658,28 @@ fn make_merge_file( pub fn edit_merge_builtin( tree: &MergedTree, - merge_tool_file: &MergeToolFile, + merge_tool_files: &[MergeToolFile], ) -> Result { let mut input = scm_record::helpers::CrosstermInput; let recorder = scm_record::Recorder::new( scm_record::RecordState { is_read_only: false, - files: vec![make_merge_file(merge_tool_file)?], + files: merge_tool_files.iter().map(make_merge_file).try_collect()?, commits: Default::default(), }, &mut input, ); let state = recorder.run()?; - let file = state.files.into_iter().exactly_one().unwrap(); apply_diff_builtin( tree.store(), tree, tree, - vec![merge_tool_file.repo_path.clone()], - &[file], + merge_tool_files + .iter() + .map(|file| file.repo_path.clone()) + .collect_vec(), + &state.files, ) .map_err(BuiltinToolError::BackendError) } diff --git a/cli/src/merge_tools/external.rs b/cli/src/merge_tools/external.rs index 87acc23d708..5c219334e19 100644 --- a/cli/src/merge_tools/external.rs +++ b/cli/src/merge_tools/external.rs @@ -20,6 +20,8 @@ use jj_lib::matchers::Matcher; use jj_lib::merge::Merge; use jj_lib::merged_tree::MergedTree; use jj_lib::merged_tree::MergedTreeBuilder; +use jj_lib::repo_path::RepoPathUiConverter; +use jj_lib::store::Store; use jj_lib::working_copy::CheckoutOptions; use pollster::FutureExt; use thiserror::Error; @@ -168,12 +170,13 @@ pub enum ExternalToolError { Io(#[source] std::io::Error), } -pub fn run_mergetool_external( +fn run_mergetool_external_single_file( editor: &ExternalMergeTool, - tree: &MergedTree, + store: &Store, merge_tool_file: &MergeToolFile, default_conflict_marker_style: ConflictMarkerStyle, -) -> Result { + tree_builder: &mut MergedTreeBuilder, +) -> Result<(), ConflictResolveError> { let MergeToolFile { repo_path, conflict, @@ -273,7 +276,7 @@ pub fn run_mergetool_external( let new_file_ids = if editor.merge_tool_edits_conflict_markers || exit_status_implies_conflict { conflicts::update_from_content( file_merge, - tree.store(), + store, repo_path, output_file_contents.as_slice(), conflict_marker_style, @@ -281,8 +284,7 @@ pub fn run_mergetool_external( ) .block_on()? } else { - let new_file_id = tree - .store() + let new_file_id = store .write_file(repo_path, &mut output_file_contents.as_slice()) .block_on()?; Merge::normal(new_file_id) @@ -310,8 +312,51 @@ pub fn run_mergetool_external( }), Err(new_file_ids) => conflict.with_new_file_ids(&new_file_ids), }; - let mut tree_builder = MergedTreeBuilder::new(tree.id()); tree_builder.set_or_remove(repo_path.to_owned(), new_tree_value); + Ok(()) +} + +pub fn run_mergetool_external( + ui: &Ui, + path_converter: &RepoPathUiConverter, + editor: &ExternalMergeTool, + tree: &MergedTree, + merge_tool_files: &[MergeToolFile], + default_conflict_marker_style: ConflictMarkerStyle, +) -> Result { + // TODO: add support for "dir" invocation mode, similar to the + // "diff-invocation-mode" config option for diffs + let mut tree_builder = MergedTreeBuilder::new(tree.id()); + for (i, merge_tool_file) in merge_tool_files.iter().enumerate() { + writeln!( + ui.status(), + "Resolving conflicts in: {}", + path_converter.format_file_path(&merge_tool_file.repo_path) + )?; + match run_mergetool_external_single_file( + editor, + tree.store(), + merge_tool_file, + default_conflict_marker_style, + &mut tree_builder, + ) { + Ok(()) => {} + Err(err) if i == 0 => { + // If the first resolution fails, just return the error normally + return Err(err); + } + Err(err) => { + // Some conflicts were already resolved, so we should return an error with the + // partially-resolved tree so that the caller can save the resolved files. + let resolved_tree = tree_builder.write_tree(tree.store())?; + return Err(ConflictResolveError::PartialResolution { + source: Box::new(err), + resolved_count: i, + resolved_tree, + }); + } + } + } let new_tree = tree_builder.write_tree(tree.store())?; Ok(new_tree) } diff --git a/cli/src/merge_tools/mod.rs b/cli/src/merge_tools/mod.rs index 42e21d79c35..696fb20ad15 100644 --- a/cli/src/merge_tools/mod.rs +++ b/cli/src/merge_tools/mod.rs @@ -19,6 +19,7 @@ mod external; use std::sync::Arc; use bstr::BString; +use itertools::Itertools; use jj_lib::backend::FileId; use jj_lib::backend::MergedTreeId; use jj_lib::config::ConfigGetError; @@ -34,6 +35,7 @@ use jj_lib::merged_tree::MergedTree; use jj_lib::repo_path::InvalidRepoPathError; use jj_lib::repo_path::RepoPath; use jj_lib::repo_path::RepoPathBuf; +use jj_lib::repo_path::RepoPathUiConverter; use jj_lib::settings::UserSettings; use jj_lib::working_copy::SnapshotError; use pollster::FutureExt; @@ -101,8 +103,16 @@ pub enum ConflictResolveError { see the exact invocation)." )] EmptyOrUnchanged, - #[error("Backend error")] + #[error(transparent)] Backend(#[from] jj_lib::backend::BackendError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("Stopped due to error after resolving {resolved_count} conflicts")] + PartialResolution { + source: Box, + resolved_count: usize, + resolved_tree: MergedTreeId, + }, } #[derive(Debug, Error)] @@ -271,28 +281,31 @@ struct MergeToolFile { /// Configured 3-way merge editor. #[derive(Clone, Debug)] -pub struct MergeEditor { +pub struct MergeEditor<'a> { tool: MergeTool, + path_converter: &'a RepoPathUiConverter, conflict_marker_style: ConflictMarkerStyle, } -impl MergeEditor { +impl<'a> MergeEditor<'a> { /// Creates 3-way merge editor of the given name, and loads parameters from /// the settings. pub fn with_name( name: &str, settings: &UserSettings, + path_converter: &'a RepoPathUiConverter, conflict_marker_style: ConflictMarkerStyle, ) -> Result { let tool = get_tool_config(settings, name)? .unwrap_or_else(|| MergeTool::external(ExternalMergeTool::with_program(name))); - Self::new_inner(name, tool, conflict_marker_style) + Self::new_inner(name, tool, path_converter, conflict_marker_style) } /// Loads the default 3-way merge editor from the settings. pub fn from_settings( ui: &Ui, settings: &UserSettings, + path_converter: &'a RepoPathUiConverter, conflict_marker_style: ConflictMarkerStyle, ) -> Result { let args = editor_args_from_settings(ui, settings, "ui.merge-editor")?; @@ -302,12 +315,13 @@ impl MergeEditor { None } .unwrap_or_else(|| MergeTool::external(ExternalMergeTool::with_merge_args(&args))); - Self::new_inner(&args, tool, conflict_marker_style) + Self::new_inner(&args, tool, path_converter, conflict_marker_style) } fn new_inner( name: impl ToString, tool: MergeTool, + path_converter: &'a RepoPathUiConverter, conflict_marker_style: ConflictMarkerStyle, ) -> Result { if matches!(&tool, MergeTool::External(mergetool) if mergetool.merge_args.is_empty()) { @@ -317,51 +331,65 @@ impl MergeEditor { } Ok(MergeEditor { tool, + path_converter, conflict_marker_style, }) } - /// Starts a merge editor for the specified file. - pub fn edit_file( + /// Starts a merge editor for the specified files. + pub fn edit_files( &self, + ui: &Ui, tree: &MergedTree, - repo_path: &RepoPath, + repo_paths: &[&RepoPath], ) -> Result { - let conflict = match tree.path_value(repo_path)?.into_resolved() { - Err(conflict) => conflict, - Ok(Some(_)) => return Err(ConflictResolveError::NotAConflict(repo_path.to_owned())), - Ok(None) => return Err(ConflictResolveError::PathNotFound(repo_path.to_owned())), - }; - let file_merge = conflict.to_file_merge().ok_or_else(|| { - let summary = conflict.describe(); - ConflictResolveError::NotNormalFiles(repo_path.to_owned(), summary) - })?; - let simplified_file_merge = file_merge.clone().simplify(); - // We only support conflicts with 2 sides (3-way conflicts) - if simplified_file_merge.num_sides() > 2 { - return Err(ConflictResolveError::ConflictTooComplicated { - path: repo_path.to_owned(), - sides: simplified_file_merge.num_sides(), - }); - }; - let content = - extract_as_single_hunk(&simplified_file_merge, tree.store(), repo_path).block_on()?; - let merge_tool_file = MergeToolFile { - repo_path: repo_path.to_owned(), - conflict, - file_merge, - content, - }; + let merge_tool_files: Vec = repo_paths + .iter() + .map(|&repo_path| { + let conflict = match tree.path_value(repo_path)?.into_resolved() { + Err(conflict) => conflict, + Ok(Some(_)) => { + return Err(ConflictResolveError::NotAConflict(repo_path.to_owned())) + } + Ok(None) => { + return Err(ConflictResolveError::PathNotFound(repo_path.to_owned())) + } + }; + let file_merge = conflict.to_file_merge().ok_or_else(|| { + let summary = conflict.describe(); + ConflictResolveError::NotNormalFiles(repo_path.to_owned(), summary) + })?; + let simplified_file_merge = file_merge.clone().simplify(); + // We only support conflicts with 2 sides (3-way conflicts) + if simplified_file_merge.num_sides() > 2 { + return Err(ConflictResolveError::ConflictTooComplicated { + path: repo_path.to_owned(), + sides: simplified_file_merge.num_sides(), + }); + }; + let content = + extract_as_single_hunk(&simplified_file_merge, tree.store(), repo_path) + .block_on()?; + Ok(MergeToolFile { + repo_path: repo_path.to_owned(), + conflict, + file_merge, + content, + }) + }) + .try_collect()?; match &self.tool { MergeTool::Builtin => { - let tree_id = edit_merge_builtin(tree, &merge_tool_file).map_err(Box::new)?; + let tree_id = edit_merge_builtin(tree, &merge_tool_files).map_err(Box::new)?; Ok(tree_id) } MergeTool::External(editor) => external::run_mergetool_external( + ui, + self.path_converter, editor, tree, - &merge_tool_file, + &merge_tool_files, self.conflict_marker_style, ), } @@ -630,7 +658,11 @@ mod tests { let get = |name, config_text| { let config = config_from_string(config_text); let settings = UserSettings::from_config(config).unwrap(); - MergeEditor::with_name(name, &settings, ConflictMarkerStyle::Diff) + let path_converter = RepoPathUiConverter::Fs { + cwd: "".into(), + base: "".into(), + }; + MergeEditor::with_name(name, &settings, &path_converter, ConflictMarkerStyle::Diff) .map(|editor| editor.tool) }; @@ -682,7 +714,11 @@ mod tests { let config = config_from_string(text); let ui = Ui::with_config(&config).unwrap(); let settings = UserSettings::from_config(config).unwrap(); - MergeEditor::from_settings(&ui, &settings, ConflictMarkerStyle::Diff) + let path_converter = RepoPathUiConverter::Fs { + cwd: "".into(), + base: "".into(), + }; + MergeEditor::from_settings(&ui, &settings, &path_converter, ConflictMarkerStyle::Diff) .map(|editor| editor.tool) }; diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index ea7cbd178c3..d794d50486a 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -146,7 +146,7 @@ To get started, see the tutorial at https://jj-vcs.github.io/jj/latest/tutorial/ * `parallelize` — Parallelize revisions by making them siblings * `prev` — Change the working copy revision relative to the parent revision * `rebase` — Move revisions to different parent(s) -* `resolve` — Resolve a conflicted file with an external merge tool +* `resolve` — Resolve conflicted files with an external merge tool * `restore` — Restore paths from another revision * `root` — Show the current workspace root directory * `show` — Show commit description and changes in a revision @@ -1885,9 +1885,9 @@ commit. This is true in general; it is not specific to this command. ## `jj resolve` -Resolve a conflicted file with an external merge tool +Resolve conflicted files with an external merge tool -Only conflicts that can be resolved with a 3-way merge are supported. See docs for merge tool configuration instructions. +Only conflicts that can be resolved with a 3-way merge are supported. See docs for merge tool configuration instructions. External merge tools will be invoked for each conflicted file one-by-one until all conflicts are resolved. To stop resolving conflicts, exit the merge tool without making any changes. Note that conflicts can also be resolved without using this command. You may edit the conflict markers in the conflicted file directly with a text editor. @@ -1895,14 +1895,14 @@ Note that conflicts can also be resolved without using this command. You may edi ###### **Arguments:** -* `` — Restrict to these paths when searching for a conflict to resolve. We will attempt to resolve the first conflict we can find. You can use the `--list` argument to find paths to use here +* `` — Only resolve conflicts in these paths. You can use the `--list` argument to find paths to use here ###### **Options:** * `-r`, `--revision ` Default value: `@` -* `-l`, `--list` — Instead of resolving one conflict, list all the conflicts +* `-l`, `--list` — Instead of resolving conflicts, list all the conflicts * `--tool ` — Specify 3-way merge tool to be used diff --git a/cli/tests/test_resolve_command.rs b/cli/tests/test_resolve_command.rs index 7bae69d5bfd..7c5d2fc513b 100644 --- a/cli/tests/test_resolve_command.rs +++ b/cli/tests/test_resolve_command.rs @@ -672,7 +672,6 @@ fn test_too_many_parents() { let error = test_env.jj_cmd_failure(&repo_path, &["resolve"]); insta::assert_snapshot!(error, @r###" Hint: Using default editor ':builtin'; run `jj config set --user ui.merge-editor :builtin` to disable this message. - Resolving conflicts in: file Error: Failed to resolve conflicts Caused by: The conflict at "file" has 3 sides. At most 2 sides are supported. "###); @@ -880,7 +879,6 @@ fn test_file_vs_dir() { let error = test_env.jj_cmd_failure(&repo_path, &["resolve"]); insta::assert_snapshot!(error, @r###" Hint: Using default editor ':builtin'; run `jj config set --user ui.merge-editor :builtin` to disable this message. - Resolving conflicts in: file Error: Failed to resolve conflicts Caused by: Only conflicts that involve normal files (not symlinks, not executable, etc.) are supported. Conflict summary for "file": Conflict: @@ -937,7 +935,6 @@ fn test_description_with_dir_and_deletion() { let error = test_env.jj_cmd_failure(&repo_path, &["resolve"]); insta::assert_snapshot!(error, @r###" Hint: Using default editor ':builtin'; run `jj config set --user ui.merge-editor :builtin` to disable this message. - Resolving conflicts in: file Error: Failed to resolve conflicts Caused by: Only conflicts that involve normal files (not symlinks, not executable, etc.) are supported. Conflict summary for "file": Conflict: @@ -1495,43 +1492,22 @@ fn test_multiple_conflicts() { insta::assert_snapshot!(stdout, @""); insta::assert_snapshot!(stderr, @""); - // For the rest of the test, we call `jj resolve` several times in a row to - // resolve each conflict in the order it chooses. + // Without a path, `jj resolve` should call the merge tool multiple times test_env.jj_cmd_ok(&repo_path, &["undo"]); insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["diff", "--git"]), @""); std::fs::write( &editor_script, - "expect\n\0write\nfirst resolution for auto-chosen file\n", - ) - .unwrap(); - test_env.jj_cmd_ok(&repo_path, &["resolve"]); - insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["diff", "--git"]), - @r###" - diff --git a/another_file b/another_file - index 0000000000..7903e1c1c7 100644 - --- a/another_file - +++ b/another_file - @@ -1,7 +1,1 @@ - -<<<<<<< Conflict 1 of 1 - -%%%%%%% Changes from base to side #1 - --second base - -+second a - -+++++++ Contents of side #2 - -second b - ->>>>>>> Conflict 1 of 1 ends - +first resolution for auto-chosen file - "###); - insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["resolve", "--list"]), - @r###" - this_file_has_a_very_long_name_to_test_padding 2-sided conflict - "###); - std::fs::write( - &editor_script, - "expect\n\0write\nsecond resolution for auto-chosen file\n", + [ + "expect\n", + "write\nfirst resolution for auto-chosen file\n", + "next invocation\n", + "expect\n", + "write\nsecond resolution for auto-chosen file\n", + ] + .join("\0"), ) .unwrap(); - test_env.jj_cmd_ok(&repo_path, &["resolve"]); insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["diff", "--git"]), @r###" @@ -1572,3 +1548,181 @@ fn test_multiple_conflicts() { Error: No conflicts found at this revision "###); } + +#[test] +fn test_multiple_conflicts_with_error() { + let mut test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + // Create two conflicted files, and one non-conflicted file + create_commit( + &test_env, + &repo_path, + "base", + &[], + &[ + ("file1", "base1\n"), + ("file2", "base2\n"), + ("file3", "base3\n"), + ], + ); + create_commit( + &test_env, + &repo_path, + "a", + &["base"], + &[("file1", "a1\n"), ("file2", "a2\n")], + ); + create_commit( + &test_env, + &repo_path, + "b", + &["base"], + &[("file1", "b1\n"), ("file2", "b2\n")], + ); + create_commit(&test_env, &repo_path, "conflict", &["a", "b"], &[]); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["resolve", "--list"]), + @r#" + file1 2-sided conflict + file2 2-sided conflict + "#); + insta::assert_snapshot!( + std::fs::read_to_string(repo_path.join("file1")).unwrap(), + @r##" + <<<<<<< Conflict 1 of 1 + %%%%%%% Changes from base to side #1 + -base1 + +a1 + +++++++ Contents of side #2 + b1 + >>>>>>> Conflict 1 of 1 ends + "## + ); + insta::assert_snapshot!( + std::fs::read_to_string(repo_path.join("file2")).unwrap(), + @r##" + <<<<<<< Conflict 1 of 1 + %%%%%%% Changes from base to side #1 + -base2 + +a2 + +++++++ Contents of side #2 + b2 + >>>>>>> Conflict 1 of 1 ends + "## + ); + let editor_script = test_env.set_up_fake_editor(); + + // Test resolving one conflict, then exiting without resolving the second one + std::fs::write( + &editor_script, + ["write\nresolution1\n", "next invocation\n"].join("\0"), + ) + .unwrap(); + let stderr = test_env.jj_cmd_failure(&repo_path, &["resolve"]); + insta::assert_snapshot!(stderr.replace("exit code", "exit status"), @r#" + Resolving conflicts in: file1 + Resolving conflicts in: file2 + Working copy now at: vruxwmqv d2f3f858 conflict | (conflict) conflict + Parent commit : zsuskuln 9db7fdfb a | a + Parent commit : royxmykx d67e26e4 b | b + Added 0 files, modified 1 files, removed 0 files + There are unresolved conflicts at these paths: + file2 2-sided conflict + New conflicts appeared in these commits: + vruxwmqv d2f3f858 conflict | (conflict) conflict + To resolve the conflicts, start by updating to it: + jj new vruxwmqv + Then use `jj resolve`, or edit the conflict markers in the file directly. + Once the conflicts are resolved, you may want to inspect the result with `jj diff`. + Then run `jj squash` to move the resolution into the conflicted commit. + Error: Stopped due to error after resolving 1 conflicts + Caused by: The output file is either unchanged or empty after the editor quit (run with --debug to see the exact invocation). + "#); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["diff", "--git"]), + @r##" + diff --git a/file1 b/file1 + index 0000000000..95cc18629d 100644 + --- a/file1 + +++ b/file1 + @@ -1,7 +1,1 @@ + -<<<<<<< Conflict 1 of 1 + -%%%%%%% Changes from base to side #1 + --base1 + -+a1 + -+++++++ Contents of side #2 + -b1 + ->>>>>>> Conflict 1 of 1 ends + +resolution1 + "##); + insta::assert_snapshot!( + test_env.jj_cmd_success(&repo_path, &["resolve", "--list"]), + @"file2 2-sided conflict" + ); + + // Test resolving one conflict, then failing during the second resolution + test_env.jj_cmd_ok(&repo_path, &["undo"]); + std::fs::write( + &editor_script, + ["write\nresolution1\n", "next invocation\n", "fail"].join("\0"), + ) + .unwrap(); + let stderr = test_env.jj_cmd_failure(&repo_path, &["resolve"]); + insta::assert_snapshot!(stderr.replace("exit code", "exit status"), @r#" + Resolving conflicts in: file1 + Resolving conflicts in: file2 + Working copy now at: vruxwmqv 0a54e8ed conflict | (conflict) conflict + Parent commit : zsuskuln 9db7fdfb a | a + Parent commit : royxmykx d67e26e4 b | b + Added 0 files, modified 1 files, removed 0 files + There are unresolved conflicts at these paths: + file2 2-sided conflict + New conflicts appeared in these commits: + vruxwmqv 0a54e8ed conflict | (conflict) conflict + To resolve the conflicts, start by updating to it: + jj new vruxwmqv + Then use `jj resolve`, or edit the conflict markers in the file directly. + Once the conflicts are resolved, you may want to inspect the result with `jj diff`. + Then run `jj squash` to move the resolution into the conflicted commit. + Error: Stopped due to error after resolving 1 conflicts + Caused by: Tool exited with exit status: 1 (run with --debug to see the exact invocation) + "#); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["diff", "--git"]), + @r##" + diff --git a/file1 b/file1 + index 0000000000..95cc18629d 100644 + --- a/file1 + +++ b/file1 + @@ -1,7 +1,1 @@ + -<<<<<<< Conflict 1 of 1 + -%%%%%%% Changes from base to side #1 + --base1 + -+a1 + -+++++++ Contents of side #2 + -b1 + ->>>>>>> Conflict 1 of 1 ends + +resolution1 + "##); + insta::assert_snapshot!( + test_env.jj_cmd_success(&repo_path, &["resolve", "--list"]), + @"file2 2-sided conflict" + ); + + // Test immediately failing to resolve any conflict + test_env.jj_cmd_ok(&repo_path, &["undo"]); + std::fs::write(&editor_script, "fail").unwrap(); + let stderr = test_env.jj_cmd_failure(&repo_path, &["resolve"]); + insta::assert_snapshot!(stderr.replace("exit code", "exit status"), @r#" + Resolving conflicts in: file1 + Error: Failed to resolve conflicts + Caused by: Tool exited with exit status: 1 (run with --debug to see the exact invocation) + "#); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["diff", "--git"]), @""); + insta::assert_snapshot!( + test_env.jj_cmd_success(&repo_path, &["resolve", "--list"]), + @r#" + file1 2-sided conflict + file2 2-sided conflict + "# + ); +}