Skip to content

Commit

Permalink
conflicts: add "git-diff3" conflict marker style
Browse files Browse the repository at this point in the history
Adds a new "git-diff3" 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-diff3" conflict markers:

```
<<<<<<< Side jj-vcs#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 jj-vcs#2 (Conflict 1 of 1 ends)
}
```
  • Loading branch information
scott2000 committed Nov 22, 2024
1 parent 085fb8b commit d559282
Show file tree
Hide file tree
Showing 5 changed files with 441 additions and 13 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 just renders conflicts as a series of snapshots,
showing each side of the conflict and the base(s).
showing each side and base of the conflict. The new "git-diff3" 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-diff3"
],
"default": "diff"
}
Expand Down
118 changes: 118 additions & 0 deletions cli/tests/test_config_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,124 @@ 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-diff3",
],
);

// 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
92 changes: 83 additions & 9 deletions lib/src/conflicts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,23 @@ const CONFLICT_END_LINE: &[u8] = b">>>>>>>";
const CONFLICT_DIFF_LINE: &[u8] = b"%%%%%%%";
const CONFLICT_MINUS_LINE: &[u8] = b"-------";
const CONFLICT_PLUS_LINE: &[u8] = b"+++++++";
const CONFLICT_GIT_ANCESTOR_LINE: &[u8] = b"|||||||";
const CONFLICT_GIT_SEPARATOR_LINE: &[u8] = b"=======";
const CONFLICT_START_LINE_CHAR: u8 = CONFLICT_START_LINE[0];
const CONFLICT_END_LINE_CHAR: u8 = CONFLICT_END_LINE[0];
const CONFLICT_DIFF_LINE_CHAR: u8 = CONFLICT_DIFF_LINE[0];
const CONFLICT_MINUS_LINE_CHAR: u8 = CONFLICT_MINUS_LINE[0];
const CONFLICT_PLUS_LINE_CHAR: u8 = CONFLICT_PLUS_LINE[0];
const CONFLICT_GIT_ANCESTOR_LINE_CHAR: u8 = CONFLICT_GIT_ANCESTOR_LINE[0];
const CONFLICT_GIT_SEPARATOR_LINE_CHAR: u8 = CONFLICT_GIT_SEPARATOR_LINE[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 @@ -240,6 +244,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.
GitDiff3,
}

pub fn materialize_merge_result<T: AsRef<[u8]>>(
Expand Down Expand Up @@ -289,12 +295,48 @@ 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::GitDiff3, [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,
) -> std::io::Result<()> {
output.write_all(CONFLICT_START_LINE)?;
output.write_all(format!(" Side #1 ({conflict_info})\n").as_bytes())?;
output.write_all(left)?;
output.write_all(CONFLICT_GIT_ANCESTOR_LINE)?;
output.write_all(b" Base\n")?;
output.write_all(base)?;
output.write_all(CONFLICT_GIT_SEPARATOR_LINE)?;
// VS Code doesn't seem to support any trailing text on the separator line
output.write_all(b"\n")?;
output.write_all(right)?;
output.write_all(CONFLICT_END_LINE)?;
output.write_all(format!(" Side #2 ({conflict_info} ends)\n").as_bytes())?;

Ok(())
}

fn materialize_jj_style_conflict(
hunk: &Merge<BString>,
conflict_info: &str,
Expand Down Expand Up @@ -473,38 +515,71 @@ 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 Git "|||||||" and "=======" sections are treated as "removes" and "adds"
/// respectively. If they are the first marker present in the conflict, then any
/// lines before them are treated as an "add" implicitly (because Git doesn't
/// put any marker before the first "add").
fn parse_conflict_hunk(input: &[u8]) -> Merge<BString> {
enum State {
Diff,
Minus,
Plus,
Unknown,
BeforeMarkers,
}
let mut state = State::Unknown;
let mut state = State::BeforeMarkers;
// Git-style conflicts have an implicit add section before the first marker
let mut before_markers = BString::new(vec![]);
let mut removes = vec![];
let mut adds = vec![];
for line in input.split_inclusive(|b| *b == b'\n') {
if CONFLICT_MARKER_REGEX.is_match_at(line, 0) {
match line[0] {
// %%%%%%% indicates JJ-style diff section
CONFLICT_DIFF_LINE_CHAR => {
state = State::Diff;
removes.push(BString::new(vec![]));
adds.push(BString::new(vec![]));
continue;
}
// ------- indicates JJ-style remove section
CONFLICT_MINUS_LINE_CHAR => {
state = State::Minus;
removes.push(BString::new(vec![]));
continue;
}
// +++++++ indicates JJ-style add section
CONFLICT_PLUS_LINE_CHAR => {
state = State::Plus;
adds.push(BString::new(vec![]));
continue;
}
// ||||||| indicates Git-style remove section
CONFLICT_GIT_ANCESTOR_LINE_CHAR => {
// Take lines before the first marker and treat them as an add
if let State::BeforeMarkers = state {
adds.push(std::mem::take(&mut before_markers));
}
state = State::Minus;
removes.push(BString::new(vec![]));
continue;
}
// ======= indicates Git-style add section
CONFLICT_GIT_SEPARATOR_LINE_CHAR => {
// Take lines before the first marker and treat them as an add
if let State::BeforeMarkers = state {
adds.push(std::mem::take(&mut before_markers));
}
state = State::Plus;
adds.push(BString::new(vec![]));
continue;
}
_ => {}
}
};
}
match state {
State::Diff => {
if let Some(rest) = line.strip_prefix(b"-") {
Expand All @@ -525,14 +600,13 @@ fn parse_conflict_hunk(input: &[u8]) -> Merge<BString> {
State::Plus => {
adds.last_mut().unwrap().extend_from_slice(line);
}
State::Unknown => {
// Doesn't look like a valid conflict
return Merge::resolved(BString::new(vec![]));
State::BeforeMarkers => {
before_markers.extend_from_slice(line);
}
}
}

if adds.len() == removes.len() + 1 {
if before_markers.is_empty() && adds.len() == removes.len() + 1 {
Merge::from_removes_adds(removes, adds)
} else {
// Doesn't look like a valid conflict
Expand Down
Loading

0 comments on commit d559282

Please sign in to comment.