From 4dac240b1d7837a2ac77add1eefef5e2241104a4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 20 Jul 2026 11:12:36 -0500 Subject: [PATCH] merge: add an option to select how incomplete hunks are rendered Commit 31de940 (merge: keep conflict markers on their own lines, #85) changed how conflict markers are rendered when a conflicting hunk ends in an incomplete line (one without a trailing newline), inserting a newline so that every marker starts at the beginning of a line. That matched the behavior of `git merge-file`, but silently diverged from GNU `diff3 -m`, which the diffutils manual documents as appending the succeeding markers directly to the incomplete line. With this commit, the behavior is now selectable via a new two-variant enum, `IncompleteHunkStyle`, on `MergeOptions`: * `Diff3` (the default) appends markers directly to the incomplete line, matching GNU `diff3 -m` and restoring the pre-#85 output. * `Git` inserts a newline after the incomplete line, matching `git merge-file`. Also add a table-driven test covering all eight permutations of the three inputs having or lacking a trailing newline, for both styles and for both the str and bytes paths. The expected outputs were verified against GNU diff3 3.12 and git 2.55.0: git produces byte-identical output for every permutation, while GNU diff3 glues each side's succeeding marker independently. --- src/lib.rs | 1 + src/merge/mod.rs | 118 ++++++++++++++++++++++--- src/merge/tests.rs | 213 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 315 insertions(+), 17 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 162484a..cda7854 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -330,6 +330,7 @@ pub use diff::DiffOptions; pub use diff::create_patch; pub use diff::create_patch_bytes; pub use merge::ConflictStyle; +pub use merge::IncompleteHunkStyle; pub use merge::MergeOptions; pub use merge::merge; pub use merge::merge_bytes; diff --git a/src/merge/mod.rs b/src/merge/mod.rs index 320d834..a16cf01 100644 --- a/src/merge/mod.rs +++ b/src/merge/mod.rs @@ -115,6 +115,40 @@ pub enum ConflictStyle { Diff3, } +/// Style used when rendering conflict markers after a hunk that ends in an +/// incomplete line (one without a trailing newline). +/// +/// An incomplete line can only appear as the final line of a file, so this +/// only affects conflicts that include the end of the file. +#[derive(Copy, Clone, Debug)] +pub enum IncompleteHunkStyle { + /// Appends conflict markers directly to the incomplete line, matching the + /// behavior of GNU `diff3 -m`. + /// + /// ```console + /// <<<<<<< ours + /// ours line||||||| original + /// original line======= + /// theirs line>>>>>>> theirs + /// ``` + Diff3, + + /// Inserts a newline after the incomplete line so that every conflict + /// marker starts at the beginning of a line, matching the behavior of + /// `git merge-file`. + /// + /// ```console + /// <<<<<<< ours + /// ours line + /// ||||||| original + /// original line + /// ======= + /// theirs line + /// >>>>>>> theirs + /// ``` + Git, +} + /// A collection of options for modifying the way a merge is performed /// /// # Examples @@ -145,6 +179,7 @@ pub enum ConflictStyle { pub struct MergeOptions { conflict_marker_length: usize, style: ConflictStyle, + incomplete_hunk_style: IncompleteHunkStyle, } impl MergeOptions { @@ -153,10 +188,12 @@ impl MergeOptions { /// ## Defaults /// * conflict_marker_length = 7 /// * style = ConflictStyle::Diff3 + /// * incomplete_hunk_style = IncompleteHunkStyle::Diff3 pub fn new() -> Self { Self { conflict_marker_length: DEFAULT_CONFLICT_MARKER_LENGTH, style: ConflictStyle::Diff3, + incomplete_hunk_style: IncompleteHunkStyle::Diff3, } } @@ -172,6 +209,13 @@ impl MergeOptions { self } + /// Set the style used when rendering conflict markers after a hunk that + /// ends in an incomplete line + pub fn set_incomplete_hunk_style(&mut self, style: IncompleteHunkStyle) -> &mut Self { + self.incomplete_hunk_style = style; + self + } + /// Merge two files, given a common ancestor, based on the configured options pub fn merge<'a>( &self, @@ -200,6 +244,7 @@ impl MergeOptions { &merge, self.conflict_marker_length, self.style, + self.incomplete_hunk_style, ) } @@ -231,6 +276,7 @@ impl MergeOptions { &merge, self.conflict_marker_length, self.style, + self.incomplete_hunk_style, ) } } @@ -556,6 +602,7 @@ fn output_result<'a, T: ?Sized>( merge: &[MergeRange], marker_len: usize, style: ConflictStyle, + incomplete_hunk_style: IncompleteHunkStyle, ) -> Result { let mut conflicts = 0; let mut output = String::new(); @@ -566,17 +613,35 @@ fn output_result<'a, T: ?Sized>( output.extend(ancestor[range.range()].iter().copied()); } MergeRange::Conflict(ancestor_range, ours_range, theirs_range) => { - add_conflict_marker(&mut output, '<', marker_len, Some("ours")); + add_conflict_marker( + &mut output, + '<', + marker_len, + Some("ours"), + incomplete_hunk_style, + ); output.extend(ours[ours_range.range()].iter().copied()); if let ConflictStyle::Diff3 = style { - add_conflict_marker(&mut output, '|', marker_len, Some("original")); + add_conflict_marker( + &mut output, + '|', + marker_len, + Some("original"), + incomplete_hunk_style, + ); output.extend(ancestor[ancestor_range.range()].iter().copied()); } - add_conflict_marker(&mut output, '=', marker_len, None); + add_conflict_marker(&mut output, '=', marker_len, None, incomplete_hunk_style); output.extend(theirs[theirs_range.range()].iter().copied()); - add_conflict_marker(&mut output, '>', marker_len, Some("theirs")); + add_conflict_marker( + &mut output, + '>', + marker_len, + Some("theirs"), + incomplete_hunk_style, + ); conflicts += 1; } MergeRange::Ours(range) => { @@ -603,8 +668,12 @@ fn add_conflict_marker( marker: char, marker_len: usize, filename: Option<&str>, + incomplete_hunk_style: IncompleteHunkStyle, ) { - if !output.is_empty() && !output.ends_with('\n') { + if matches!(incomplete_hunk_style, IncompleteHunkStyle::Git) + && !output.is_empty() + && !output.ends_with('\n') + { output.push('\n'); } for _ in 0..marker_len { @@ -625,6 +694,7 @@ fn output_result_bytes<'a, T: ?Sized>( merge: &[MergeRange], marker_len: usize, style: ConflictStyle, + incomplete_hunk_style: IncompleteHunkStyle, ) -> Result, Vec> { let mut conflicts = 0; let mut output: Vec = Vec::new(); @@ -637,23 +707,47 @@ fn output_result_bytes<'a, T: ?Sized>( .for_each(|line| output.extend_from_slice(line)); } MergeRange::Conflict(ancestor_range, ours_range, theirs_range) => { - add_conflict_marker_bytes(&mut output, b'<', marker_len, Some(b"ours")); + add_conflict_marker_bytes( + &mut output, + b'<', + marker_len, + Some(b"ours"), + incomplete_hunk_style, + ); ours[ours_range.range()] .iter() .for_each(|line| output.extend_from_slice(line)); if let ConflictStyle::Diff3 = style { - add_conflict_marker_bytes(&mut output, b'|', marker_len, Some(b"original")); + add_conflict_marker_bytes( + &mut output, + b'|', + marker_len, + Some(b"original"), + incomplete_hunk_style, + ); ancestor[ancestor_range.range()] .iter() .for_each(|line| output.extend_from_slice(line)); } - add_conflict_marker_bytes(&mut output, b'=', marker_len, None); + add_conflict_marker_bytes( + &mut output, + b'=', + marker_len, + None, + incomplete_hunk_style, + ); theirs[theirs_range.range()] .iter() .for_each(|line| output.extend_from_slice(line)); - add_conflict_marker_bytes(&mut output, b'>', marker_len, Some(b"theirs")); + add_conflict_marker_bytes( + &mut output, + b'>', + marker_len, + Some(b"theirs"), + incomplete_hunk_style, + ); conflicts += 1; } MergeRange::Ours(range) => { @@ -686,8 +780,12 @@ fn add_conflict_marker_bytes( marker: u8, marker_len: usize, filename: Option<&[u8]>, + incomplete_hunk_style: IncompleteHunkStyle, ) { - if !output.is_empty() && output.last() != Some(&b'\n') { + if matches!(incomplete_hunk_style, IncompleteHunkStyle::Git) + && !output.is_empty() + && output.last() != Some(&b'\n') + { output.push(b'\n'); } for _ in 0..marker_len { diff --git a/src/merge/tests.rs b/src/merge/tests.rs index 7a3a8c6..96f644f 100644 --- a/src/merge/tests.rs +++ b/src/merge/tests.rs @@ -406,20 +406,19 @@ fn delete_and_insert_conflict() { } #[test] -fn conflict_hunks_without_trailing_newline_keep_markers_on_own_lines() { +fn conflict_hunks_without_trailing_newline_glue_markers_by_default() { let base = "This is line 1.\nThis is line 2."; let ours = "This is line 1.\nThis is line 2 changed."; let theirs = "This is line 1.\nThis is line 2 also changed."; + // The default IncompleteHunkStyle::Diff3 matches GNU `diff3 -m`, which + // appends the succeeding markers directly to incomplete lines. let expected = "\ This is line 1. <<<<<<< ours -This is line 2 changed. -||||||| original -This is line 2. -======= -This is line 2 also changed. ->>>>>>> theirs +This is line 2 changed.||||||| original +This is line 2.======= +This is line 2 also changed.>>>>>>> theirs "; assert_merge!( @@ -430,3 +429,203 @@ This is line 2 also changed. "file-final hunks without trailing newline", ); } + +#[test] +fn conflict_hunks_without_trailing_newline_git_style_keeps_markers_on_own_lines() { + let base = "This is line 1.\nThis is line 2."; + let ours = "This is line 1.\nThis is line 2 changed."; + let theirs = "This is line 1.\nThis is line 2 also changed."; + + // IncompleteHunkStyle::Git matches `git merge-file --diff3`, which inserts + // a newline after an incomplete line so that every marker starts a line. + let expected = "\ +This is line 1. +<<<<<<< ours +This is line 2 changed. +||||||| original +This is line 2. +======= +This is line 2 also changed. +>>>>>>> theirs +"; + + let mut options = MergeOptions::new(); + options.set_incomplete_hunk_style(IncompleteHunkStyle::Git); + + assert_eq!( + options.merge(base, ours, theirs), + Err(String::from(expected)), + "file-final hunks without trailing newline with the git style", + ); + assert_eq!( + options.merge_bytes(base.as_bytes(), ours.as_bytes(), theirs.as_bytes()), + Err(expected.as_bytes().to_vec()), + "file-final hunks without trailing newline with the git style (bytes)", + ); +} + +#[test] +fn conflict_hunk_trailing_newline_permutations() { + const BASE: &str = "This is line 1.\nThis is line 2."; + const BASE_NL: &str = "This is line 1.\nThis is line 2.\n"; + const OURS: &str = "This is line 1.\nThis is line 2 changed."; + const OURS_NL: &str = "This is line 1.\nThis is line 2 changed.\n"; + const THEIRS: &str = "This is line 1.\nThis is line 2 also changed."; + const THEIRS_NL: &str = "This is line 1.\nThis is line 2 also changed.\n"; + + // Each case lists the inputs along with the expected output of the + // default IncompleteHunkStyle::Diff3. The expected outputs were verified + // against GNU diff3 3.12 (`diff3 -m`), which glues the marker succeeding + // each incomplete hunk onto the hunk's final line. + let cases = [ + ( + BASE, + OURS, + THEIRS, + "\ +This is line 1. +<<<<<<< ours +This is line 2 changed.||||||| original +This is line 2.======= +This is line 2 also changed.>>>>>>> theirs +", + ), + ( + BASE, + OURS, + THEIRS_NL, + "\ +This is line 1. +<<<<<<< ours +This is line 2 changed.||||||| original +This is line 2.======= +This is line 2 also changed. +>>>>>>> theirs +", + ), + ( + BASE, + OURS_NL, + THEIRS, + "\ +This is line 1. +<<<<<<< ours +This is line 2 changed. +||||||| original +This is line 2.======= +This is line 2 also changed.>>>>>>> theirs +", + ), + ( + BASE, + OURS_NL, + THEIRS_NL, + "\ +This is line 1. +<<<<<<< ours +This is line 2 changed. +||||||| original +This is line 2.======= +This is line 2 also changed. +>>>>>>> theirs +", + ), + ( + BASE_NL, + OURS, + THEIRS, + "\ +This is line 1. +<<<<<<< ours +This is line 2 changed.||||||| original +This is line 2. +======= +This is line 2 also changed.>>>>>>> theirs +", + ), + ( + BASE_NL, + OURS, + THEIRS_NL, + "\ +This is line 1. +<<<<<<< ours +This is line 2 changed.||||||| original +This is line 2. +======= +This is line 2 also changed. +>>>>>>> theirs +", + ), + ( + BASE_NL, + OURS_NL, + THEIRS, + "\ +This is line 1. +<<<<<<< ours +This is line 2 changed. +||||||| original +This is line 2. +======= +This is line 2 also changed.>>>>>>> theirs +", + ), + ( + BASE_NL, + OURS_NL, + THEIRS_NL, + "\ +This is line 1. +<<<<<<< ours +This is line 2 changed. +||||||| original +This is line 2. +======= +This is line 2 also changed. +>>>>>>> theirs +", + ), + ]; + + // `git merge-file --diff3` (verified with git 2.55.0) appends a newline to + // every incomplete hunk, so all of the permutations render identically + // with IncompleteHunkStyle::Git. + let expected_git = "\ +This is line 1. +<<<<<<< ours +This is line 2 changed. +||||||| original +This is line 2. +======= +This is line 2 also changed. +>>>>>>> theirs +"; + + let diff3_options = MergeOptions::new(); + let mut git_options = MergeOptions::new(); + git_options.set_incomplete_hunk_style(IncompleteHunkStyle::Git); + + for (base, ours, theirs, expected_diff3) in cases { + assert_eq!( + diff3_options.merge(base, ours, theirs), + Err(String::from(expected_diff3)), + "diff3 style: base={base:?} ours={ours:?} theirs={theirs:?}", + ); + assert_eq!( + diff3_options.merge_bytes(base.as_bytes(), ours.as_bytes(), theirs.as_bytes()), + Err(expected_diff3.as_bytes().to_vec()), + "diff3 style (bytes): base={base:?} ours={ours:?} theirs={theirs:?}", + ); + assert_eq!( + git_options.merge(base, ours, theirs), + Err(String::from(expected_git)), + "git style: base={base:?} ours={ours:?} theirs={theirs:?}", + ); + assert_eq!( + git_options.merge_bytes(base.as_bytes(), ours.as_bytes(), theirs.as_bytes()), + Err(expected_git.as_bytes().to_vec()), + "git style (bytes): base={base:?} ours={ours:?} theirs={theirs:?}", + ); + } +}