diff --git a/CHANGELOG.md b/CHANGELOG.md index c94e2cbe4c..6a77bc2fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * The deprecated `[alias]` config section is no longer respected. Move command aliases to the `[aliases]` section. +* `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/command_error.rs b/cli/src/command_error.rs index cc29f5913c..3e7d8e31d6 100644 --- a/cli/src/command_error.rs +++ b/cli/src/command_error.rs @@ -420,7 +420,11 @@ impl From<DiffRenderError> for CommandError { impl From<ConflictResolveError> 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(), + _ => user_error_with_message("Failed to resolve conflicts", err), + } } } @@ -795,7 +799,7 @@ fn print_error( Ok(()) } -fn print_error_sources(ui: &Ui, source: Option<&dyn error::Error>) -> io::Result<()> { +pub fn print_error_sources(ui: &Ui, source: Option<&dyn error::Error>) -> io::Result<()> { let Some(err) = source else { return Ok(()); }; diff --git a/cli/src/commands/resolve.rs b/cli/src/commands/resolve.rs index bd29479ab1..fe8be87f74 100644 --- a/cli/src/commands/resolve.rs +++ b/cli/src/commands/resolve.rs @@ -28,10 +28,13 @@ use crate::command_error::CommandError; use crate::complete; 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 @@ -51,7 +54,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)] @@ -59,10 +62,8 @@ pub(crate) struct ResolveArgs { /// Specify 3-way merge tool to be used #[arg(long, conflicts_with = "list", value_name = "NAME")] tool: Option<String>, - /// 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_hint = clap::ValueHint::AnyPath, add = ArgValueCompleter::new(complete::revision_conflicted_files), @@ -101,16 +102,19 @@ 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 new_tree_id = merge_editor.edit_file( + ui, + tx.base_workspace_helper().path_converter(), + &tree, + &repo_paths, + )?; let new_commit = tx .repo_mut() .rewrite_commit(command.settings(), &commit) diff --git a/cli/src/merge_tools/builtin.rs b/cli/src/merge_tools/builtin.rs index 9f62b0af43..b545965d6a 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<MergedTreeId, BuiltinToolError> { 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 a9cd43440e..7817e03060 100644 --- a/cli/src/merge_tools/external.rs +++ b/cli/src/merge_tools/external.rs @@ -18,6 +18,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; @@ -31,6 +33,7 @@ use super::ConflictResolveError; use super::DiffEditError; use super::DiffGenerateError; use super::MergeToolFile; +use crate::command_error::print_error_sources; use crate::config::find_all_variables; use crate::config::interpolate_variables; use crate::config::CommandNameAndArgs; @@ -166,12 +169,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<MergedTreeId, ConflictResolveError> { + tree_builder: &mut MergedTreeBuilder, +) -> Result<(), ConflictResolveError> { let MergeToolFile { repo_path, conflict, @@ -255,15 +259,14 @@ 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, ) .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) @@ -286,8 +289,49 @@ 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<MergedTreeId, ConflictResolveError> { + 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 => { + // Since no conflicts were successfully resolved, return the error + return Err(err); + } + Err(err) => { + // Since some conflicts were already successfully resolved, just print a warning + // and stop resolving conflicts + writeln!( + ui.warning_default(), + "Stopping due to error after resolving {i} conflicts" + )?; + print_error_sources(ui, Some(&err))?; + break; + } + } + } 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 1760fcfed0..1fb1a5a4cd 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,10 @@ 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), } #[derive(Debug, Error)] @@ -324,44 +328,58 @@ impl MergeEditor { /// Starts a merge editor for the specified file. pub fn edit_file( &self, + ui: &Ui, + path_converter: &RepoPathUiConverter, tree: &MergedTree, - repo_path: &RepoPath, + repo_paths: &[&RepoPath], ) -> Result<MergedTreeId, ConflictResolveError> { - 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<MergeToolFile> = 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, + path_converter, editor, tree, - &merge_tool_file, + &merge_tool_files, self.conflict_marker_style, ), } diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index acb1e46dde..2bec0797a9 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -145,7 +145,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 @@ -1884,9 +1884,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. @@ -1894,14 +1894,14 @@ Note that conflicts can also be resolved without using this command. You may edi ###### **Arguments:** -* `<PATHS>` — 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 +* `<PATHS>` — Only resolve conflicts in these paths. You can use the `--list` argument to find paths to use here ###### **Options:** * `-r`, `--revision <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 <NAME>` — 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 2dd59b90af..47fa46cd84 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: @@ -1089,43 +1086,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###" @@ -1166,3 +1142,183 @@ 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 (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["resolve"]); + insta::assert_snapshot!(stdout, @r#""#); + insta::assert_snapshot!(stderr.replace("exit code", "exit status"), @r#" + Resolving conflicts in: file1 + Resolving conflicts in: file2 + Warning: Stopping 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). + 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. + "#); + 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 (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["resolve"]); + insta::assert_snapshot!(stdout, @r#""#); + insta::assert_snapshot!(stderr.replace("exit code", "exit status"), @r#" + Resolving conflicts in: file1 + Resolving conflicts in: file2 + Warning: Stopping due to error after resolving 1 conflicts + Caused by: Tool exited with exit status: 1 (run with --debug to see the exact invocation) + 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. + "#); + 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 + "# + ); +}