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 22, 2024
1 parent f32ee24 commit 1c6d84b
Show file tree
Hide file tree
Showing 5 changed files with 540 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
112 changes: 112 additions & 0 deletions cli/tests/test_config_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,118 @@ fn test_config_author_change_warning_root_env() {
);
}

#[test]
fn test_config_change_conflict_marker_style() {
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.jj_cmd_ok(
&repo_path,
&["config", "set", "--repo", "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.jj_cmd_ok(
&repo_path,
&[
"config",
"set",
"--repo",
"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
"##);
}

fn find_stdout_lines(keyname_pattern: &str, stdout: &str) -> String {
let key_line_re = Regex::new(&format!(r"(?m)^{keyname_pattern} = .*$")).unwrap();
key_line_re
Expand Down
118 changes: 114 additions & 4 deletions lib/src/conflicts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use std::io::Write;
use std::iter::zip;

use bstr::BString;
use bstr::ByteSlice;
use futures::stream::BoxStream;
use futures::try_join;
use futures::Stream;
Expand Down Expand Up @@ -55,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 @@ -241,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 @@ -290,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 @@ -473,7 +512,29 @@ 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.
fn parse_conflict_hunk(input: &[u8]) -> Merge<BString> {
let Some(first_line) = input.lines_with_terminator().next() else {
// Conflict hunks must have at least one line
return Merge::resolved(BString::new(vec![]));
};

if CONFLICT_MARKER_REGEX.is_match_at(first_line, 0)
&& first_line[0] != CONFLICT_GIT_ANCESTOR_LINE_CHAR
{
// JJ-style conflicts always start with a conflict marker header, and can't
// start with "|||||||"
parse_jj_style_conflict_hunk(input)
} else {
// Git-style conflicts either don't start with a conflict marker header, or they
// start with "|||||||" if the left side is empty
parse_git_style_conflict_hunk(input)
}
}

fn parse_jj_style_conflict_hunk(input: &[u8]) -> Merge<BString> {
enum State {
Diff,
Minus,
Expand All @@ -483,7 +544,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 CONFLICT_MARKER_REGEX.is_match_at(line, 0) {
match line[0] {
CONFLICT_DIFF_LINE_CHAR => {
Expand All @@ -504,7 +565,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 @@ -540,6 +601,55 @@ fn parse_conflict_hunk(input: &[u8]) -> Merge<BString> {
}
}

fn parse_git_style_conflict_hunk(input: &[u8]) -> Merge<BString> {
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 CONFLICT_MARKER_REGEX.is_match_at(line, 0) {
match line[0] {
CONFLICT_GIT_ANCESTOR_LINE_CHAR => {
if let State::Left = state {
state = State::Base;
continue;
} else {
// Base must come after left
return Merge::resolved(BString::new(vec![]));
}
}
CONFLICT_GIT_SEPARATOR_LINE_CHAR => {
if let State::Base = state {
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 let State::Right = state {
Merge::from_vec(vec![left, base, right])
} else {
// Doesn't look like a valid conflict
Merge::resolved(BString::new(vec![]))
}
}

/// Parses conflict markers in `content` and returns an updated version of
/// `file_ids` with the new contents. If no (valid) conflict markers remain, a
/// single resolves `FileId` will be returned.
Expand Down
Loading

0 comments on commit 1c6d84b

Please sign in to comment.