Skip to content

Commit

Permalink
conflicts: add "git" conflict marker style
Browse files Browse the repository at this point in the history
Adds a new "git" conflict marker style option. This option matches Git's
"diff3" conflict style, allowing these conflicts to be parsed by some
external tools that don't support JJ-style conflicts. If a conflict has
more than 2 sides, then it falls back to the similar "snapshot" conflict
marker style.

The conflict parsing code now supports parsing Git-style conflict
markers in addition to the normal JJ-style conflict markers, regardless
of the conflict marker style setting. This has the benefit of allowing
the user to switch the conflict marker style while they already have
conflicts checked out, and their old conflicts will still be parsed
correctly.

Example of "git" conflict markers:

```
<<<<<<< Side #1 (Conflict 1 of 1)
fn example(word: String) {
    println!("word is {word}");
||||||| Base
fn example(w: String) {
    println!("word is {w}");
=======
fn example(w: &str) {
    println!("word is {w}");
>>>>>>> Side #2 (Conflict 1 of 1 ends)
}
```
  • Loading branch information
scott2000 committed Nov 23, 2024
1 parent ec6220d commit 26f5d61
Show file tree
Hide file tree
Showing 5 changed files with 534 additions and 7 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
materialized in the working copy. The default option ("diff") renders
conflicts as a snapshot with a list of diffs to apply to the snapshot.
The new "snapshot" option renders conflicts as a series of snapshots, showing
each side and base of the conflict.
each side and base of the conflict. The new "git" option replicates Git's
"diff3" conflict style, meaning it is more likely to work with external tools,
but it doesn't support conflicts with more than 2 sides.

### Fixed bugs

Expand Down
3 changes: 2 additions & 1 deletion cli/src/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@
"description": "Conflict marker style to use when materializing conflicts in the working copy",
"enum": [
"diff",
"snapshot"
"snapshot",
"git"
],
"default": "diff"
}
Expand Down
102 changes: 102 additions & 0 deletions cli/tests/test_working_copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use indoc::indoc;

use crate::common::TestEnvironment;

#[test]
Expand Down Expand Up @@ -57,3 +59,103 @@ fn test_snapshot_large_file() {
let stdout = test_env.jj_cmd_success(&repo_path, &["file", "list"]);
insta::assert_snapshot!(stdout, @"");
}

#[test]
fn test_materialize_and_snapshot_different_conflict_markers() {
let 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");

// Configure to use Git-style conflict markers
test_env.add_config(r#"ui.conflict-marker-style = "git""#);

// Create a conflict in the working copy
let conflict_file = repo_path.join("file");
std::fs::write(
&conflict_file,
indoc! {"
line 1
line 2
line 3
"},
)
.unwrap();
test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "base"]);
std::fs::write(
&conflict_file,
indoc! {"
line 1
line 2 - a
line 3
"},
)
.unwrap();
test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "side-a"]);
test_env.jj_cmd_ok(&repo_path, &["new", "description(base)", "-m", "side-b"]);
std::fs::write(
&conflict_file,
indoc! {"
line 1
line 2 - b
line 3 - b
"},
)
.unwrap();
test_env.jj_cmd_ok(
&repo_path,
&["new", "description(side-a)", "description(side-b)"],
);

// File should have Git-style conflict markers
insta::assert_snapshot!(std::fs::read_to_string(&conflict_file).unwrap(), @r##"
line 1
<<<<<<< Side #1 (Conflict 1 of 1)
line 2 - a
line 3
||||||| Base
line 2
line 3
=======
line 2 - b
line 3 - b
>>>>>>> Side #2 (Conflict 1 of 1 ends)
"##);

// Configure to use JJ-style "snapshot" conflict markers
test_env.add_config(r#"ui.conflict-marker-style = "snapshot""#);

// Update the conflict, still using Git-style conflict markers
std::fs::write(
&conflict_file,
indoc! {"
line 1
<<<<<<<
line 2 - a
line 3 - a
|||||||
line 2
line 3
=======
line 2 - b
line 3 - b
>>>>>>>
"},
)
.unwrap();

// Git-style markers should be parsed, then rendered with new config
insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["diff", "--git"]), @r##"
diff --git a/file b/file
--- a/file
+++ b/file
@@ -2,7 +2,7 @@
<<<<<<< Conflict 1 of 1
+++++++ Contents of side #1
line 2 - a
-line 3
+line 3 - a
------- Contents of base
line 2
line 3
"##);
}
122 changes: 118 additions & 4 deletions lib/src/conflicts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,23 @@ const CONFLICT_END_LINE: &str = ">>>>>>>";
const CONFLICT_DIFF_LINE: &str = "%%%%%%%";
const CONFLICT_MINUS_LINE: &str = "-------";
const CONFLICT_PLUS_LINE: &str = "+++++++";
const CONFLICT_GIT_ANCESTOR_LINE: &str = "|||||||";
const CONFLICT_GIT_SEPARATOR_LINE: &str = "=======";
const CONFLICT_START_LINE_CHAR: u8 = CONFLICT_START_LINE.as_bytes()[0];
const CONFLICT_END_LINE_CHAR: u8 = CONFLICT_END_LINE.as_bytes()[0];
const CONFLICT_DIFF_LINE_CHAR: u8 = CONFLICT_DIFF_LINE.as_bytes()[0];
const CONFLICT_MINUS_LINE_CHAR: u8 = CONFLICT_MINUS_LINE.as_bytes()[0];
const CONFLICT_PLUS_LINE_CHAR: u8 = CONFLICT_PLUS_LINE.as_bytes()[0];
const CONFLICT_GIT_ANCESTOR_LINE_CHAR: u8 = CONFLICT_GIT_ANCESTOR_LINE.as_bytes()[0];
const CONFLICT_GIT_SEPARATOR_LINE_CHAR: u8 = CONFLICT_GIT_SEPARATOR_LINE.as_bytes()[0];

/// A conflict marker is one of the separators, optionally followed by a space
/// and some text.
// TODO: All the `{7}` could be replaced with `{7,}` to allow longer
// separators. This could be useful to make it possible to allow conflict
// markers inside the text of the conflicts.
static CONFLICT_MARKER_REGEX: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
RegexBuilder::new(r"^(<{7}|>{7}|%{7}|\-{7}|\+{7})( .*)?$")
RegexBuilder::new(r"^(<{7}|>{7}|%{7}|\-{7}|\+{7}|\|{7}|={7})( .*)?$")
.multi_line(true)
.build()
.unwrap()
Expand Down Expand Up @@ -242,6 +246,8 @@ pub enum ConflictMarkerStyle {
Diff,
/// Style which shows a snapshot for each base and side.
Snapshot,
/// Style which replicates Git's "diff3" style to support external tools.
Git,
}

pub fn materialize_merge_result<T: AsRef<[u8]>>(
Expand Down Expand Up @@ -291,12 +297,44 @@ fn materialize_conflict_hunks(
conflict_index += 1;
let conflict_info = format!("Conflict {conflict_index} of {num_conflicts}");

materialize_jj_style_conflict(hunk, &conflict_info, conflict_marker_style, output)?;
match (conflict_marker_style, hunk.as_slice()) {
// 2-sided conflicts can use Git-style conflict markers
(ConflictMarkerStyle::Git, [left, base, right]) => {
materialize_git_style_conflict(left, base, right, &conflict_info, output)?;
}
_ => {
materialize_jj_style_conflict(
hunk,
&conflict_info,
conflict_marker_style,
output,
)?;
}
}
}
}
Ok(())
}

fn materialize_git_style_conflict(
left: &[u8],
base: &[u8],
right: &[u8],
conflict_info: &str,
output: &mut dyn Write,
) -> io::Result<()> {
writeln!(output, "{CONFLICT_START_LINE} Side #1 ({conflict_info})")?;
output.write_all(left)?;
writeln!(output, "{CONFLICT_GIT_ANCESTOR_LINE} Base")?;
output.write_all(base)?;
// VS Code doesn't seem to support any trailing text on the separator line
writeln!(output, "{CONFLICT_GIT_SEPARATOR_LINE}")?;
output.write_all(right)?;
writeln!(output, "{CONFLICT_END_LINE} Side #2 ({conflict_info} ends)")?;

Ok(())
}

fn materialize_jj_style_conflict(
hunk: &Merge<BString>,
conflict_info: &str,
Expand Down Expand Up @@ -474,7 +512,33 @@ pub fn parse_conflict(input: &[u8], num_sides: usize) -> Option<Vec<Merge<BStrin
}
}

/// This method handles parsing both JJ-style and Git-style conflict markers,
/// meaning that switching conflict marker styles won't prevent existing files
/// with other conflict marker styles from being parsed successfully. The
/// conflict marker style to use for parsing is determined based on the first
/// line of the hunk.
fn parse_conflict_hunk(input: &[u8]) -> Merge<BString> {
// If the hunk starts with a conflict marker, find its first character
let initial_conflict_marker_char = input
.lines_with_terminator()
.next()
.filter(|line| is_conflict_marker_line(line))
.map(|line| line[0]);

match initial_conflict_marker_char {
// JJ-style conflicts must start with one of these 3 conflict marker lines
Some(CONFLICT_DIFF_LINE_CHAR | CONFLICT_MINUS_LINE_CHAR | CONFLICT_PLUS_LINE_CHAR) => {
parse_jj_style_conflict_hunk(input)
}
// Git-style conflicts either must not start with a conflict marker line, or must start with
// the "|||||||" conflict marker line (if the first side was empty)
None | Some(CONFLICT_GIT_ANCESTOR_LINE_CHAR) => parse_git_style_conflict_hunk(input),
// No other conflict markers are allowed at the start of a hunk
Some(_) => Merge::resolved(BString::new(vec![])),
}
}

fn parse_jj_style_conflict_hunk(input: &[u8]) -> Merge<BString> {
enum State {
Diff,
Minus,
Expand All @@ -484,7 +548,7 @@ fn parse_conflict_hunk(input: &[u8]) -> Merge<BString> {
let mut state = State::Unknown;
let mut removes = vec![];
let mut adds = vec![];
for line in input.split_inclusive(|b| *b == b'\n') {
for line in input.lines_with_terminator() {
if is_conflict_marker_line(line) {
match line[0] {
CONFLICT_DIFF_LINE_CHAR => {
Expand All @@ -505,7 +569,7 @@ fn parse_conflict_hunk(input: &[u8]) -> Merge<BString> {
}
_ => {}
}
};
}
match state {
State::Diff => {
if let Some(rest) = line.strip_prefix(b"-") {
Expand Down Expand Up @@ -547,6 +611,56 @@ fn parse_conflict_hunk(input: &[u8]) -> Merge<BString> {
}
}

fn parse_git_style_conflict_hunk(input: &[u8]) -> Merge<BString> {
#[derive(PartialEq, Eq)]
enum State {
Left,
Base,
Right,
}
let mut state = State::Left;
let mut left = BString::new(vec![]);
let mut base = BString::new(vec![]);
let mut right = BString::new(vec![]);
for line in input.lines_with_terminator() {
if is_conflict_marker_line(line) {
match line[0] {
CONFLICT_GIT_ANCESTOR_LINE_CHAR => {
if state == State::Left {
state = State::Base;
continue;
} else {
// Base must come after left
return Merge::resolved(BString::new(vec![]));
}
}
CONFLICT_GIT_SEPARATOR_LINE_CHAR => {
if state == State::Base {
state = State::Right;
continue;
} else {
// Right must come after base
return Merge::resolved(BString::new(vec![]));
}
}
_ => {}
}
}
match state {
State::Left => left.extend_from_slice(line),
State::Base => base.extend_from_slice(line),
State::Right => right.extend_from_slice(line),
}
}

if state == State::Right {
Merge::from_vec(vec![left, base, right])
} else {
// Doesn't look like a valid conflict
Merge::resolved(BString::new(vec![]))
}
}

/// Check whether a line is a conflict marker. Removes trailing whitespace
/// before checking against regex to ensure it parses CRLF endings correctly.
fn is_conflict_marker_line(line: &[u8]) -> bool {
Expand Down
Loading

0 comments on commit 26f5d61

Please sign in to comment.