Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

merge_tools: add merge-tool-conflict-exit-codes option #4981

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* New `merge-tools.<TOOL>.conflict-marker-style` config option to override the
conflict marker style used for a specific merge tool.

* New `merge-tools.<TOOL>.merge-conflict-exit-codes` config option to allow a
merge tool to exit with a non-zero code to indicate that not all conflicts
were resolved.

* `jj simplify-parents` now supports configuring the default revset when no
`--source` or `--revisions` arguments are provided with the
`revsets.simplify-parents` config.
Expand Down
8 changes: 8 additions & 0 deletions cli/src/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,14 @@
"type": "string"
}
},
"merge-conflict-exit-codes": {
"type": "array",
"items": {
"type": "number"
},
"description": "Array of exit codes to indicate that the conflict was only partially resolved. See https://martinvonz.github.io/jj/latest/config/#editing-conflict-markers-with-a-tool-or-a-text-editor",
"default": []
},
"merge-tool-edits-conflict-markers": {
"type": "boolean",
"description": "Whether to populate the output file with conflict markers before starting the merge tool. See https://martinvonz.github.io/jj/latest/config/#editing-conflict-markers-with-a-tool-or-a-text-editor",
Expand Down
35 changes: 33 additions & 2 deletions cli/src/merge_tools/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ pub struct ExternalMergeTool {
/// `$left`, `$right`, `$base`, and `$output` are replaced with
/// paths to the corresponding files.
pub merge_args: Vec<String>,
/// By default, if a merge tool exits with a non-zero exit code, then the
/// merge will be cancelled. Some merge tools allow leaving some conflicts
/// unresolved, in which case they will be left as conflict markers in the
/// output file. In that case, the merge tool may exit with a non-zero exit
/// code to indicate that not all conflicts were resolved. Adding an exit
/// code to this array will tell `jj` to interpret that exit code as
/// indicating that the `$output` file should contain conflict markers.
pub merge_conflict_exit_codes: Vec<i32>,
/// If false (default), the `$output` file starts out empty and is accepted
/// as a full conflict resolution as-is by `jj` after the merge tool is
/// done with it. If true, the `$output` file starts out with the
Expand Down Expand Up @@ -93,6 +101,7 @@ impl Default for ExternalMergeTool {
diff_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),
edit_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),
merge_args: vec![],
merge_conflict_exit_codes: vec![],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
diff_invocation_mode: DiffToolMode::Dir,
Expand Down Expand Up @@ -150,6 +159,11 @@ pub enum ExternalToolError {
},
#[error("Tool exited with {exit_status} (run with --debug to see the exact invocation)")]
ToolAborted { exit_status: ExitStatus },
#[error(
"Tool exited with {exit_status}, but did not produce valid conflict markers (run with \
--debug to see the exact invocation)"
)]
InvalidConflictMarkers { exit_status: ExitStatus },
#[error("I/O error")]
Io(#[source] std::io::Error),
}
Expand Down Expand Up @@ -218,7 +232,13 @@ pub fn run_mergetool_external(
tool_binary: editor.program.clone(),
source: e,
})?;
if !exit_status.success() {

// Check whether the exit status implies that there should be conflict markers
let exit_status_implies_conflict = exit_status
.code()
.is_some_and(|code| editor.merge_conflict_exit_codes.contains(&code));

if !exit_status.success() && !exit_status_implies_conflict {
return Err(ConflictResolveError::from(ExternalToolError::ToolAborted {
exit_status,
}));
Expand All @@ -230,7 +250,7 @@ pub fn run_mergetool_external(
return Err(ConflictResolveError::EmptyOrUnchanged);
}

let new_file_ids = if editor.merge_tool_edits_conflict_markers {
let new_file_ids = if editor.merge_tool_edits_conflict_markers || exit_status_implies_conflict {
conflicts::update_from_content(
&file_merge,
tree.store(),
Expand All @@ -246,6 +266,17 @@ pub fn run_mergetool_external(
.block_on()?;
Merge::normal(new_file_id)
};

// If the exit status indicated there should be conflict markers but there
// weren't any, it's likely that the tool generated invalid conflict markers, so
// we need to inform the user. If we didn't treat this as an error, the user
// might think the conflict was resolved successfully.
if exit_status_implies_conflict && new_file_ids.is_resolved() {
return Err(ConflictResolveError::ExternalTool(
ExternalToolError::InvalidConflictMarkers { exit_status },
scott2000 marked this conversation as resolved.
Show resolved Hide resolved
));
}

let new_tree_value = match new_file_ids.into_resolved() {
Ok(new_file_id) => Merge::normal(TreeValue::File {
id: new_file_id.unwrap(),
Expand Down
12 changes: 12 additions & 0 deletions cli/src/merge_tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ mod tests {
"$right",
],
merge_args: [],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down Expand Up @@ -433,6 +434,7 @@ mod tests {
"$right",
],
merge_args: [],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down Expand Up @@ -473,6 +475,7 @@ mod tests {
"$right",
],
merge_args: [],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand All @@ -497,6 +500,7 @@ mod tests {
"$right",
],
merge_args: [],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand All @@ -520,6 +524,7 @@ mod tests {
"$right",
],
merge_args: [],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down Expand Up @@ -549,6 +554,7 @@ mod tests {
"$right",
],
merge_args: [],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down Expand Up @@ -576,6 +582,7 @@ mod tests {
"$right",
],
merge_args: [],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand All @@ -597,6 +604,7 @@ mod tests {
"$right",
],
merge_args: [],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down Expand Up @@ -650,6 +658,7 @@ mod tests {
"$right",
"$output",
],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down Expand Up @@ -698,6 +707,7 @@ mod tests {
"$right",
"$output",
],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down Expand Up @@ -727,6 +737,7 @@ mod tests {
"$right",
"$output",
],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down Expand Up @@ -759,6 +770,7 @@ mod tests {
"$right",
"$output",
],
merge_conflict_exit_codes: [],
merge_tool_edits_conflict_markers: false,
conflict_marker_style: None,
},
Expand Down
110 changes: 110 additions & 0 deletions cli/tests/test_resolve_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,116 @@ fn test_resolution() {
file 2-sided conflict
"###);

// Check that merge tool can leave conflict markers by returning exit code 1
// when using `merge-conflict-exit-codes = [1]`. The Git "diff3" conflict
// markers should also be parsed correctly.
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,
[
"dump editor5",
indoc! {"
write
<<<<<<<
some
|||||||
fake
=======
conflict
>>>>>>>
"},
"fail",
]
.join("\0"),
)
.unwrap();
let (stdout, stderr) = test_env.jj_cmd_ok(
&repo_path,
&[
"resolve",
"--config-toml",
"merge-tools.fake-editor.merge-conflict-exit-codes = [1]",
],
);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(stderr, @r#"
Resolving conflicts in: file
Working copy now at: vruxwmqv 6690cdf4 conflict | (conflict) conflict
Parent commit : zsuskuln aa493daf a | a
Parent commit : royxmykx db6a4daf b | b
Added 0 files, modified 1 files, removed 0 files
There are unresolved conflicts at these paths:
file 2-sided conflict
New conflicts appeared in these commits:
vruxwmqv 6690cdf4 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!(
std::fs::read_to_string(test_env.env_root().join("editor5")).unwrap(), @"");
insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["diff", "--git"]),
@r##"
diff --git a/file b/file
--- a/file
+++ b/file
@@ -1,7 +1,7 @@
<<<<<<< Conflict 1 of 1
%%%%%%% Changes from base to side #1
--base
-+a
+-fake
++some
+++++++ Contents of side #2
-b
+conflict
>>>>>>> Conflict 1 of 1 ends
"##);
insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["resolve", "--list"]),
@r###"
file 2-sided conflict
"###);

// Check that an error is reported if a merge tool indicated it would leave
// conflict markers, but the output file didn't contain valid conflict markers.
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,
[
indoc! {"
write
<<<<<<< this isn't diff3 style!
some
=======
conflict
>>>>>>>
"},
"fail",
]
.join("\0"),
)
.unwrap();
let stderr = test_env.jj_cmd_failure(
&repo_path,
&[
"resolve",
"--config-toml",
"merge-tools.fake-editor.merge-conflict-exit-codes = [1]",
],
);
// On Windows, the ExitStatus struct prints "exit code" instead of "exit status"
insta::assert_snapshot!(stderr.replace("exit code", "exit status"), @r#"
Resolving conflicts in: file
Error: Failed to resolve conflicts
Caused by: Tool exited with exit status: 1, but did not produce valid conflict markers (run with --debug to see the exact invocation)
"#);

// TODO: Check that running `jj new` and then `jj resolve -r conflict` works
// correctly.
}
Expand Down
17 changes: 14 additions & 3 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,9 +841,20 @@ merge-tool-edits-conflict-markers = true # See below for an explanation
### Editing conflict markers with a tool or a text editor

By default, the merge tool starts with an empty output file. If the tool puts
anything into the output file, and exits with the 0 exit code,
`jj` assumes that the conflict is fully resolved. This is appropriate for most
graphical merge tools.
anything into the output file and exits with the 0 exit code,
`jj` assumes that the conflict is fully resolved, while if the tool exits with
a non-zero exit code, `jj` assumes that the merge should be cancelled.
This is appropriate for most graphical merge tools.

For merge tools which try to automatically resolve conflicts without user input,
this behavior may not be desired. For instance, some automatic merge tools use
an exit code of 1 to indicate that some conflicts were unable to be resolved and
that the output file should contain conflict markers. In that case, you could
set the config option `merge-tools.TOOL.merge-conflict-exit-codes = [1]` to tell
`jj` to expect conflict markers in the output file if the exit code is 1. If a
merge tool produces output using Git's "diff3" conflict style, `jj` should be
able to parse it correctly, so many Git merge drivers should be usable with `jj`
as well.

Some tools (e.g. `vimdiff`) can present a multi-way diff but don't resolve
conflict themselves. When using such tools, `jj`
Expand Down
Loading