Skip to main content

jj_lib/
conflicts.rs

1// Copyright 2020 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![expect(missing_docs)]
16
17use std::io;
18use std::io::Write;
19use std::iter::zip;
20use std::pin::Pin;
21
22use bstr::BStr;
23use bstr::BString;
24use bstr::ByteSlice as _;
25use bstr::ByteVec as _;
26use futures::Stream;
27use futures::StreamExt as _;
28use futures::stream::BoxStream;
29use futures::try_join;
30use itertools::Itertools as _;
31use pollster::FutureExt as _;
32use tokio::io::AsyncRead;
33use tokio::io::AsyncReadExt as _;
34
35use crate::backend::BackendError;
36use crate::backend::BackendResult;
37use crate::backend::CommitId;
38use crate::backend::CopyId;
39use crate::backend::FileId;
40use crate::backend::SymlinkId;
41use crate::backend::TreeId;
42use crate::backend::TreeValue;
43use crate::conflict_labels::ConflictLabels;
44use crate::copies::CopiesTreeDiffEntry;
45use crate::copies::CopiesTreeDiffEntryPath;
46use crate::diff::ContentDiff;
47use crate::diff::DiffHunk;
48use crate::diff::DiffHunkKind;
49use crate::files;
50use crate::files::MergeResult;
51use crate::merge::Diff;
52use crate::merge::Merge;
53use crate::merge::MergedTreeValue;
54use crate::merge::SameChange;
55use crate::repo_path::RepoPath;
56use crate::store::Store;
57use crate::tree_merge::MergeOptions;
58
59/// Minimum length of conflict markers.
60pub const MIN_CONFLICT_MARKER_LEN: usize = 7;
61
62/// If a file already contains lines which look like conflict markers of length
63/// N, then the conflict markers we add will be of length (N + increment). This
64/// number is chosen to make the conflict markers noticeably longer than the
65/// existing markers.
66const CONFLICT_MARKER_LEN_INCREMENT: usize = 4;
67
68/// Comment for missing terminating newline in a term of a conflict.
69const NO_ENDING_EOL_COMMENT: &str = "(no terminating newline)";
70
71fn write_diff_hunks(hunks: &[DiffHunk], file: &mut dyn Write) -> io::Result<()> {
72    for hunk in hunks {
73        match hunk.kind {
74            DiffHunkKind::Matching => {
75                debug_assert!(hunk.contents.iter().all_equal());
76                for line in hunk.contents[0].lines_with_terminator() {
77                    file.write_all(b" ")?;
78                    file.write_all(line)?;
79                }
80            }
81            DiffHunkKind::Different => {
82                for line in hunk.contents[0].lines_with_terminator() {
83                    file.write_all(b"-")?;
84                    file.write_all(line)?;
85                }
86                for line in hunk.contents[1].lines_with_terminator() {
87                    file.write_all(b"+")?;
88                    file.write_all(line)?;
89                }
90            }
91        }
92    }
93    Ok(())
94}
95
96async fn get_file_contents(
97    store: &Store,
98    path: &RepoPath,
99    term: Option<&FileId>,
100) -> BackendResult<BString> {
101    match term {
102        Some(id) => {
103            let mut reader = store.read_file(path, id).await?;
104            let mut content = vec![];
105            reader
106                .read_to_end(&mut content)
107                .await
108                .map_err(|err| BackendError::ReadFile {
109                    path: path.to_owned(),
110                    id: id.clone(),
111                    source: err.into(),
112                })?;
113            Ok(BString::new(content))
114        }
115        // If the conflict had removed the file on one side, we pretend that the file
116        // was empty there.
117        None => Ok(BString::new(vec![])),
118    }
119}
120
121pub async fn extract_as_single_hunk(
122    merge: &Merge<Option<FileId>>,
123    store: &Store,
124    path: &RepoPath,
125) -> BackendResult<Merge<BString>> {
126    merge
127        .try_map_async(|term| get_file_contents(store, path, term.as_ref()))
128        .await
129}
130
131/// A type similar to `MergedTreeValue` but with associated data to include in
132/// e.g. the working copy or in a diff.
133pub enum MaterializedTreeValue {
134    Absent,
135    AccessDenied(Box<dyn std::error::Error + Send + Sync>),
136    File(MaterializedFileValue),
137    Symlink {
138        id: SymlinkId,
139        target: String,
140    },
141    FileConflict(MaterializedFileConflictValue),
142    OtherConflict {
143        id: MergedTreeValue,
144        labels: ConflictLabels,
145    },
146    GitSubmodule(CommitId),
147    Tree(TreeId),
148}
149
150impl MaterializedTreeValue {
151    pub fn is_absent(&self) -> bool {
152        matches!(self, Self::Absent)
153    }
154
155    pub fn is_present(&self) -> bool {
156        !self.is_absent()
157    }
158}
159
160/// [`TreeValue::File`] with file content `reader`.
161pub struct MaterializedFileValue {
162    pub id: FileId,
163    pub executable: bool,
164    pub copy_id: CopyId,
165    pub reader: Pin<Box<dyn AsyncRead + Send>>,
166}
167
168impl MaterializedFileValue {
169    /// Reads file content until EOF. The provided `path` is used only for error
170    /// reporting purpose.
171    pub async fn read_all(&mut self, path: &RepoPath) -> BackendResult<Vec<u8>> {
172        let mut buf = Vec::new();
173        self.reader
174            .read_to_end(&mut buf)
175            .await
176            .map_err(|err| BackendError::ReadFile {
177                path: path.to_owned(),
178                id: self.id.clone(),
179                source: err.into(),
180            })?;
181        Ok(buf)
182    }
183}
184
185/// Conflicted [`TreeValue::File`]s with file contents.
186pub struct MaterializedFileConflictValue {
187    /// File ids which preserve the shape of the tree conflict, to be used with
188    /// [`Merge::update_from_simplified()`].
189    pub unsimplified_ids: Merge<Option<FileId>>,
190    /// Simplified file ids, in which redundant id pairs are dropped.
191    pub ids: Merge<Option<FileId>>,
192    /// Simplified conflict labels, matching `ids`.
193    pub labels: ConflictLabels,
194    /// File contents corresponding to the simplified `ids`.
195    // TODO: or Vec<(FileId, Box<dyn Read>)> so that caller can stop reading
196    // when null bytes found?
197    pub contents: Merge<BString>,
198    /// Merged executable bit. `None` if there are changes in both executable
199    /// bit and file absence.
200    pub executable: Option<bool>,
201    /// Merged copy id. `None` if no single value could be determined.
202    pub copy_id: Option<CopyId>,
203}
204
205/// Reads the data associated with a `MergedTreeValue` so it can be written to
206/// e.g. the working copy or diff.
207pub async fn materialize_tree_value(
208    store: &Store,
209    path: &RepoPath,
210    value: MergedTreeValue,
211    conflict_labels: &ConflictLabels,
212) -> BackendResult<MaterializedTreeValue> {
213    match materialize_tree_value_no_access_denied(store, path, value, conflict_labels).await {
214        Err(BackendError::ReadAccessDenied { source, .. }) => {
215            Ok(MaterializedTreeValue::AccessDenied(source))
216        }
217        result => result,
218    }
219}
220
221async fn materialize_tree_value_no_access_denied(
222    store: &Store,
223    path: &RepoPath,
224    value: MergedTreeValue,
225    conflict_labels: &ConflictLabels,
226) -> BackendResult<MaterializedTreeValue> {
227    match value.into_resolved() {
228        Ok(None) => Ok(MaterializedTreeValue::Absent),
229        Ok(Some(TreeValue::File {
230            id,
231            executable,
232            copy_id,
233        })) => {
234            let reader = store.read_file(path, &id).await?;
235            Ok(MaterializedTreeValue::File(MaterializedFileValue {
236                id,
237                executable,
238                copy_id,
239                reader,
240            }))
241        }
242        Ok(Some(TreeValue::Symlink(id))) => {
243            let target = store.read_symlink(path, &id).await?;
244            Ok(MaterializedTreeValue::Symlink { id, target })
245        }
246        Ok(Some(TreeValue::GitSubmodule(id))) => Ok(MaterializedTreeValue::GitSubmodule(id)),
247        Ok(Some(TreeValue::Tree(id))) => Ok(MaterializedTreeValue::Tree(id)),
248        Err(conflict) => {
249            match try_materialize_file_conflict_value(store, path, &conflict, conflict_labels)
250                .await?
251            {
252                Some(file) => Ok(MaterializedTreeValue::FileConflict(file)),
253                None => Ok(MaterializedTreeValue::OtherConflict {
254                    id: conflict,
255                    labels: conflict_labels.clone(),
256                }),
257            }
258        }
259    }
260}
261
262/// Suppose `conflict` contains only files or absent entries, reads the file
263/// contents.
264pub async fn try_materialize_file_conflict_value(
265    store: &Store,
266    path: &RepoPath,
267    conflict: &MergedTreeValue,
268    conflict_labels: &ConflictLabels,
269) -> BackendResult<Option<MaterializedFileConflictValue>> {
270    let (Some(unsimplified_ids), Some(executable_bits)) =
271        (conflict.to_file_merge(), conflict.to_executable_merge())
272    else {
273        return Ok(None);
274    };
275    let (labels, ids) = conflict_labels.simplify_with(&unsimplified_ids);
276    let contents = extract_as_single_hunk(&ids, store, path).await?;
277    let executable = resolve_file_executable(&executable_bits);
278    Ok(Some(MaterializedFileConflictValue {
279        unsimplified_ids,
280        ids,
281        labels,
282        contents,
283        executable,
284        copy_id: Some(CopyId::placeholder()),
285    }))
286}
287
288/// Resolves conflicts in file executable bit, returns the original state if the
289/// file is deleted and executable bit is unchanged.
290pub fn resolve_file_executable(merge: &Merge<Option<bool>>) -> Option<bool> {
291    let resolved = merge.resolve_trivial(SameChange::Accept).copied()?;
292    if resolved.is_some() {
293        resolved
294    } else {
295        // If the merge is resolved to None (absent), there should be the same
296        // number of Some(true) and Some(false). Pick the old state if
297        // unambiguous, so the new file inherits the original executable bit.
298        merge.removes().flatten().copied().all_equal_value().ok()
299    }
300}
301
302/// Describes what style should be used when materializing conflicts.
303#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Deserialize)]
304#[serde(rename_all = "kebab-case")]
305pub enum ConflictMarkerStyle {
306    /// Style which shows a snapshot and a series of diffs to apply.
307    Diff,
308    /// Similar to "diff", but always picks the first side as the snapshot. May
309    /// become the default in a future version.
310    DiffExperimental,
311    /// Style which shows a snapshot for each base and side.
312    Snapshot,
313    /// Style which replicates Git's "diff3" style to support external tools.
314    Git,
315}
316
317impl ConflictMarkerStyle {
318    /// Returns true if this style allows `%%%%%%%` conflict markers.
319    pub fn allows_diff(&self) -> bool {
320        matches!(self, Self::Diff | Self::DiffExperimental)
321    }
322}
323
324/// Options for conflict materialization.
325#[derive(Clone, Debug)]
326pub struct ConflictMaterializeOptions {
327    pub marker_style: ConflictMarkerStyle,
328    pub marker_len: Option<usize>,
329    pub merge: MergeOptions,
330}
331
332/// Characters which can be repeated to form a conflict marker line when
333/// materializing and parsing conflicts.
334#[derive(Clone, Copy, PartialEq, Eq)]
335#[repr(u8)]
336enum ConflictMarkerLineChar {
337    ConflictStart = b'<',
338    ConflictEnd = b'>',
339    Add = b'+',
340    Remove = b'-',
341    Diff = b'%',
342    Note = b'\\',
343    GitAncestor = b'|',
344    GitSeparator = b'=',
345}
346
347impl ConflictMarkerLineChar {
348    /// Get the ASCII byte used for this conflict marker.
349    fn to_byte(self) -> u8 {
350        self as u8
351    }
352
353    /// Parse a byte to see if it corresponds with any kind of conflict marker.
354    fn parse_byte(byte: u8) -> Option<Self> {
355        match byte {
356            b'<' => Some(Self::ConflictStart),
357            b'>' => Some(Self::ConflictEnd),
358            b'+' => Some(Self::Add),
359            b'-' => Some(Self::Remove),
360            b'%' => Some(Self::Diff),
361            b'\\' => Some(Self::Note),
362            b'|' => Some(Self::GitAncestor),
363            b'=' => Some(Self::GitSeparator),
364            _ => None,
365        }
366    }
367}
368
369/// Represents a conflict marker line parsed from the file. Conflict marker
370/// lines consist of a single ASCII character repeated for a certain length.
371struct ConflictMarkerLine {
372    kind: ConflictMarkerLineChar,
373    len: usize,
374}
375
376/// Write a conflict marker to an output file.
377fn write_conflict_marker(
378    output: &mut dyn Write,
379    kind: ConflictMarkerLineChar,
380    len: usize,
381    suffix_text: &str,
382) -> io::Result<()> {
383    let conflict_marker = BString::new(vec![kind.to_byte(); len]);
384
385    if suffix_text.is_empty() {
386        write!(output, "{conflict_marker}")
387    } else {
388        write!(output, "{conflict_marker} {suffix_text}")
389    }
390}
391
392/// Parse a conflict marker from a line of a file. The conflict marker may have
393/// any length (even less than MIN_CONFLICT_MARKER_LEN).
394fn parse_conflict_marker_any_len(line: &[u8]) -> Option<ConflictMarkerLine> {
395    let first_byte = *line.first()?;
396    let kind = ConflictMarkerLineChar::parse_byte(first_byte)?;
397    let len = line.iter().take_while(|&&b| b == first_byte).count();
398
399    if let Some(next_byte) = line.get(len) {
400        // If there is a character after the marker, it must be ASCII whitespace
401        if !next_byte.is_ascii_whitespace() {
402            return None;
403        }
404    }
405
406    Some(ConflictMarkerLine { kind, len })
407}
408
409/// Parse a conflict marker, expecting it to be at least a certain length. Any
410/// shorter conflict markers are ignored.
411fn parse_conflict_marker(line: &[u8], expected_len: usize) -> Option<ConflictMarkerLineChar> {
412    parse_conflict_marker_any_len(line)
413        .filter(|marker| marker.len >= expected_len)
414        .map(|marker| marker.kind)
415}
416
417/// Given a Merge of files, choose the conflict marker length to use when
418/// materializing conflicts.
419pub fn choose_materialized_conflict_marker_len<T: AsRef<[u8]>>(single_hunk: &Merge<T>) -> usize {
420    let max_existing_marker_len = single_hunk
421        .iter()
422        .flat_map(|file| file.as_ref().lines_with_terminator())
423        .filter_map(parse_conflict_marker_any_len)
424        .map(|marker| marker.len)
425        .max()
426        .unwrap_or_default();
427
428    max_existing_marker_len
429        .saturating_add(CONFLICT_MARKER_LEN_INCREMENT)
430        .max(MIN_CONFLICT_MARKER_LEN)
431}
432
433fn detect_eol(single_hunk: &Merge<impl AsRef<[u8]>>) -> &'static BStr {
434    let use_crlf = single_hunk
435        .iter()
436        .filter_map(|content| {
437            let content = content.as_ref();
438            let newline_index = content.find_byte(b'\n')?;
439            Some(newline_index > 0 && content[newline_index - 1] == b'\r')
440        })
441        .all_equal_value()
442        .unwrap_or(false);
443    if use_crlf {
444        b"\r\n".into()
445    } else {
446        b"\n".into()
447    }
448}
449
450pub fn materialize_merge_result<T: AsRef<[u8]>>(
451    single_hunk: &Merge<T>,
452    labels: &ConflictLabels,
453    output: &mut dyn Write,
454    options: &ConflictMaterializeOptions,
455) -> io::Result<()> {
456    let merge_result = files::merge_hunks(single_hunk, &options.merge);
457    match merge_result {
458        MergeResult::Resolved(content) => output.write_all(&content),
459        MergeResult::Conflict(hunks) => {
460            let marker_len = options
461                .marker_len
462                .unwrap_or_else(|| choose_materialized_conflict_marker_len(single_hunk));
463            materialize_conflict_hunks(
464                hunks,
465                options.marker_style,
466                marker_len,
467                labels,
468                output,
469                detect_eol(single_hunk),
470            )
471        }
472    }
473}
474
475pub fn materialize_merge_result_to_bytes<T: AsRef<[u8]>>(
476    single_hunk: &Merge<T>,
477    labels: &ConflictLabels,
478    options: &ConflictMaterializeOptions,
479) -> BString {
480    let merge_result = files::merge_hunks(single_hunk, &options.merge);
481    match merge_result {
482        MergeResult::Resolved(content) => content,
483        MergeResult::Conflict(hunks) => {
484            let marker_len = options
485                .marker_len
486                .unwrap_or_else(|| choose_materialized_conflict_marker_len(single_hunk));
487            let mut output = Vec::new();
488            materialize_conflict_hunks(
489                hunks,
490                options.marker_style,
491                marker_len,
492                labels,
493                &mut output,
494                detect_eol(single_hunk),
495            )
496            .expect("writing to an in-memory buffer should never fail");
497            output.into()
498        }
499    }
500}
501
502fn materialize_conflict_hunks(
503    // We may modify the conflict hunks when materialize the ending EOL conflict, so we take the
504    // ownership.
505    hunks: Vec<Merge<BString>>,
506    conflict_marker_style: ConflictMarkerStyle,
507    conflict_marker_len: usize,
508    labels: &ConflictLabels,
509    output: &mut dyn Write,
510    eol: &BStr,
511) -> io::Result<()> {
512    let num_conflicts = hunks
513        .iter()
514        .filter(|hunk| hunk.as_resolved().is_none())
515        .count();
516    let mut conflict_index = 0;
517    for hunk in hunks {
518        if let Some(content) = hunk.as_resolved() {
519            output.write_all(content)?;
520        } else {
521            conflict_index += 1;
522            let conflict_info = format!("conflict {conflict_index} of {num_conflicts}");
523
524            // If any side doesn't have the ending EOL, we remove the ending EOL from the
525            // conflict end marker line and "spread" the ending EOL to every side as a
526            // separator, so that contents without an ending EOL won't be concatenated with
527            // the conflict markers.
528            let all_sides_have_ending_eol = hunk
529                .iter()
530                .all(|content| content.last().is_none_or(|last| *last == b'\n'));
531            let mut sides = build_hunk_sides(hunk, labels);
532            if !all_sides_have_ending_eol {
533                for side in &mut sides {
534                    side.contents.push_str(eol);
535                }
536            }
537
538            match (conflict_marker_style, sides.as_slice()) {
539                // 2-sided conflicts can use Git-style conflict markers
540                (ConflictMarkerStyle::Git, [left, base, right]) => {
541                    materialize_git_style_conflict(
542                        left,
543                        base,
544                        right,
545                        conflict_marker_len,
546                        output,
547                        eol,
548                    )?;
549                }
550                _ => {
551                    materialize_jj_style_conflict(
552                        sides,
553                        &conflict_info,
554                        conflict_marker_style,
555                        conflict_marker_len,
556                        output,
557                        eol,
558                    )?;
559                }
560            }
561
562            if all_sides_have_ending_eol {
563                output.write_all(eol)?;
564            }
565        }
566    }
567    Ok(())
568}
569
570#[derive(Debug)]
571struct HunkTerm {
572    contents: BString,
573    label: String,
574}
575
576fn build_hunk_sides(hunk: Merge<BString>, labels: &ConflictLabels) -> Merge<HunkTerm> {
577    let (removes, adds) = hunk.into_removes_adds();
578    let num_bases = removes.len();
579    let removes = removes.enumerate().map(|(base_index, contents)| {
580        let label = labels
581            .get_remove(base_index)
582            .map(|label| label.to_owned())
583            .unwrap_or_else(|| {
584                // The vast majority of conflicts one actually tries to resolve manually have 1
585                // base.
586                if num_bases == 1 {
587                    "base".to_string()
588                } else {
589                    format!("base #{}", base_index + 1)
590                }
591            });
592        HunkTerm { contents, label }
593    });
594    let adds = adds.enumerate().map(|(add_index, contents)| {
595        let label = labels.get_add(add_index).map_or_else(
596            || format!("side #{}", add_index + 1),
597            |label| label.to_owned(),
598        );
599        HunkTerm { contents, label }
600    });
601    let mut hunk_terms = Merge::from_removes_adds(removes, adds);
602    for term in &mut hunk_terms {
603        // We don't add the no eol comment if the side is empty.
604        if term.contents.last().is_some_and(|ch| *ch != b'\n') {
605            term.label.push(' ');
606            term.label.push_str(NO_ENDING_EOL_COMMENT);
607        }
608    }
609    hunk_terms
610}
611
612fn materialize_git_style_conflict(
613    left: &HunkTerm,
614    base: &HunkTerm,
615    right: &HunkTerm,
616    conflict_marker_len: usize,
617    output: &mut dyn Write,
618    eol: &BStr,
619) -> io::Result<()> {
620    write_conflict_marker(
621        output,
622        ConflictMarkerLineChar::ConflictStart,
623        conflict_marker_len,
624        &left.label,
625    )?;
626    output.write_all(eol)?;
627    output.write_all(&left.contents)?;
628
629    write_conflict_marker(
630        output,
631        ConflictMarkerLineChar::GitAncestor,
632        conflict_marker_len,
633        &base.label,
634    )?;
635    output.write_all(eol)?;
636    output.write_all(&base.contents)?;
637
638    // VS Code doesn't seem to support any trailing text on the separator line
639    write_conflict_marker(
640        output,
641        ConflictMarkerLineChar::GitSeparator,
642        conflict_marker_len,
643        "",
644    )?;
645    output.write_all(eol)?;
646
647    output.write_all(&right.contents)?;
648    // The caller handles the ending EOL conflict and decides whether to append the
649    // ending EOL to the end of the conflict hunk, so we don't write an extra new
650    // line character after the conflict end marker.
651    write_conflict_marker(
652        output,
653        ConflictMarkerLineChar::ConflictEnd,
654        conflict_marker_len,
655        &right.label,
656    )?;
657
658    Ok(())
659}
660
661fn materialize_jj_style_conflict(
662    hunk: Merge<HunkTerm>,
663    conflict_info: &str,
664    conflict_marker_style: ConflictMarkerStyle,
665    conflict_marker_len: usize,
666    output: &mut dyn Write,
667    eol: &BStr,
668) -> io::Result<()> {
669    // Write a positive snapshot (side) of a conflict
670    let write_side = |side: &HunkTerm, output: &mut dyn Write| {
671        write_conflict_marker(
672            output,
673            ConflictMarkerLineChar::Add,
674            conflict_marker_len,
675            &side.label,
676        )?;
677        output.write_all(eol)?;
678        output.write_all(&side.contents)
679    };
680
681    // Write a negative snapshot (base) of a conflict
682    let write_base = |side: &HunkTerm, output: &mut dyn Write| {
683        write_conflict_marker(
684            output,
685            ConflictMarkerLineChar::Remove,
686            conflict_marker_len,
687            &side.label,
688        )?;
689        output.write_all(eol)?;
690        output.write_all(&side.contents)
691    };
692
693    // Write a diff from a negative term to a positive term
694    let write_diff =
695        |base: &HunkTerm, add: &HunkTerm, diff: &[DiffHunk], output: &mut dyn Write| {
696            write_conflict_marker(
697                output,
698                ConflictMarkerLineChar::Diff,
699                conflict_marker_len,
700                &format!("diff from: {}", base.label),
701            )?;
702            output.write_all(eol)?;
703            write_conflict_marker(
704                output,
705                ConflictMarkerLineChar::Note,
706                conflict_marker_len,
707                &format!("       to: {}", add.label),
708            )?;
709            output.write_all(eol)?;
710            write_diff_hunks(diff, output)
711        };
712
713    write_conflict_marker(
714        output,
715        ConflictMarkerLineChar::ConflictStart,
716        conflict_marker_len,
717        conflict_info,
718    )?;
719    output.write_all(eol)?;
720    let mut snapshot_written = false;
721    // The only conflict marker style which can start with a diff is "diff".
722    if conflict_marker_style != ConflictMarkerStyle::Diff {
723        write_side(hunk.first(), output)?;
724        snapshot_written = true;
725    }
726    for (base_index, left) in hunk.removes().enumerate() {
727        let add_index = if snapshot_written {
728            base_index + 1
729        } else {
730            base_index
731        };
732
733        let right1 = hunk.get_add(add_index).unwrap();
734
735        // Write the base and side separately if the conflict marker style doesn't
736        // support diffs.
737        if !conflict_marker_style.allows_diff() {
738            write_base(left, output)?;
739            write_side(right1, output)?;
740            continue;
741        }
742
743        let diff1 = ContentDiff::by_line([&left.contents, &right1.contents])
744            .hunks()
745            .collect_vec();
746        // If we haven't written a snapshot yet, then we need to decide whether to
747        // format the current side as a snapshot or a diff. We write the current side as
748        // a diff unless the next side has a smaller diff compared to the current base.
749        if !snapshot_written {
750            let right2 = hunk.get_add(add_index + 1).unwrap();
751            let diff2 = ContentDiff::by_line([&left.contents, &right2.contents])
752                .hunks()
753                .collect_vec();
754            if diff_size(&diff2) < diff_size(&diff1) {
755                // If the next positive term is a better match, emit the current positive term
756                // as a snapshot and the next positive term as a diff.
757                write_side(right1, output)?;
758                write_diff(left, right2, &diff2, output)?;
759                snapshot_written = true;
760                continue;
761            }
762        }
763
764        write_diff(left, right1, &diff1, output)?;
765    }
766
767    // If we still didn't emit a snapshot, the last side is the snapshot.
768    if !snapshot_written {
769        write_side(hunk.get_add(hunk.num_sides() - 1).unwrap(), output)?;
770    }
771    write_conflict_marker(
772        output,
773        ConflictMarkerLineChar::ConflictEnd,
774        conflict_marker_len,
775        &format!("{conflict_info} ends"),
776    )?;
777    Ok(())
778}
779
780fn diff_size(hunks: &[DiffHunk]) -> usize {
781    hunks
782        .iter()
783        .map(|hunk| match hunk.kind {
784            DiffHunkKind::Matching => 0,
785            DiffHunkKind::Different => hunk.contents.iter().map(|content| content.len()).sum(),
786        })
787        .sum()
788}
789
790pub struct MaterializedTreeDiffEntry {
791    pub path: CopiesTreeDiffEntryPath,
792    pub values: BackendResult<Diff<MaterializedTreeValue>>,
793}
794
795pub fn materialized_diff_stream(
796    store: &Store,
797    tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
798    conflict_labels: Diff<&ConflictLabels>,
799) -> impl Stream<Item = MaterializedTreeDiffEntry> {
800    tree_diff
801        .map(async |CopiesTreeDiffEntry { path, values }| match values {
802            Err(err) => MaterializedTreeDiffEntry {
803                path,
804                values: Err(err),
805            },
806            Ok(values) => {
807                let before_future = materialize_tree_value(
808                    store,
809                    path.source(),
810                    values.before,
811                    conflict_labels.before,
812                );
813                let after_future = materialize_tree_value(
814                    store,
815                    path.target(),
816                    values.after,
817                    conflict_labels.after,
818                );
819                let values = try_join!(before_future, after_future)
820                    .map(|(before, after)| Diff { before, after });
821                MaterializedTreeDiffEntry { path, values }
822            }
823        })
824        .buffered((store.concurrency() / 2).max(1))
825}
826
827/// Parses conflict markers from a slice.
828///
829/// Returns `None` if there were no valid conflict markers. The caller
830/// has to provide the expected number of merge sides (adds). Conflict
831/// markers that are otherwise valid will be considered invalid if
832/// they don't have the expected arity.
833///
834/// All conflict markers in the file must be at least as long as the expected
835/// length. Any shorter conflict markers will be ignored.
836// TODO: "parse" is not usually the opposite of "materialize", so maybe we
837// should rename them to "serialize" and "deserialize"?
838pub fn parse_conflict(
839    input: &[u8],
840    num_sides: usize,
841    expected_marker_len: usize,
842) -> Option<Vec<Merge<BString>>> {
843    if input.is_empty() {
844        return None;
845    }
846    let mut hunks = vec![];
847    let mut pos = 0;
848    let mut resolved_start = 0;
849    let mut conflict_start = None;
850    let mut conflict_start_len = 0;
851    for line in input.lines_with_terminator() {
852        match parse_conflict_marker(line, expected_marker_len) {
853            Some(ConflictMarkerLineChar::ConflictStart) => {
854                conflict_start = Some(pos);
855                conflict_start_len = line.len();
856            }
857            Some(ConflictMarkerLineChar::ConflictEnd) => {
858                if let Some(conflict_start_index) = conflict_start.take() {
859                    let conflict_body = &input[conflict_start_index + conflict_start_len..pos];
860                    let mut hunk = parse_conflict_hunk(conflict_body, expected_marker_len);
861                    if hunk.num_sides() == num_sides {
862                        let resolved_slice = &input[resolved_start..conflict_start_index];
863                        if !resolved_slice.is_empty() {
864                            hunks.push(Merge::resolved(BString::from(resolved_slice)));
865                        }
866                        if !line.ends_with(b"\n") {
867                            // If the conflict end marker doesn't end with an EOL, the last EOL on
868                            // every side performs only as a separator, and we need to do remove the
869                            // last EOL to retrieve the original contents.
870                            for term in &mut hunk {
871                                if term.pop_if(|x| *x == b'\n').is_some() {
872                                    term.pop_if(|x| *x == b'\r');
873                                }
874                            }
875                        }
876                        hunks.push(hunk);
877                        resolved_start = pos + line.len();
878                    }
879                }
880            }
881            _ => {}
882        }
883        pos += line.len();
884    }
885
886    if hunks.is_empty() {
887        None
888    } else {
889        if resolved_start < input.len() {
890            hunks.push(Merge::resolved(BString::from(&input[resolved_start..])));
891        }
892        Some(hunks)
893    }
894}
895
896/// This method handles parsing both JJ-style and Git-style conflict markers,
897/// meaning that switching conflict marker styles won't prevent existing files
898/// with other conflict marker styles from being parsed successfully. The
899/// conflict marker style to use for parsing is determined based on the first
900/// line of the hunk.
901fn parse_conflict_hunk(input: &[u8], expected_marker_len: usize) -> Merge<BString> {
902    // If the hunk starts with a conflict marker, find its first character
903    let initial_conflict_marker = input
904        .lines_with_terminator()
905        .next()
906        .and_then(|line| parse_conflict_marker(line, expected_marker_len));
907
908    match initial_conflict_marker {
909        // JJ-style conflicts must start with one of these 3 conflict marker lines
910        Some(
911            ConflictMarkerLineChar::Diff
912            | ConflictMarkerLineChar::Remove
913            | ConflictMarkerLineChar::Add,
914        ) => parse_jj_style_conflict_hunk(input, expected_marker_len),
915        // Git-style conflicts either must not start with a conflict marker line, or must start with
916        // the "|||||||" conflict marker line (if the first side was empty)
917        None | Some(ConflictMarkerLineChar::GitAncestor) => {
918            parse_git_style_conflict_hunk(input, expected_marker_len)
919        }
920        // No other conflict markers are allowed at the start of a hunk
921        Some(_) => Merge::resolved(BString::new(vec![])),
922    }
923}
924
925fn parse_jj_style_conflict_hunk(input: &[u8], expected_marker_len: usize) -> Merge<BString> {
926    enum State {
927        Diff,
928        Remove,
929        Add,
930        Unknown,
931    }
932    let mut state = State::Unknown;
933    let mut removes = vec![];
934    let mut adds = vec![];
935    for line in input.lines_with_terminator() {
936        match parse_conflict_marker(line, expected_marker_len) {
937            Some(ConflictMarkerLineChar::Diff) => {
938                state = State::Diff;
939                removes.push(BString::new(vec![]));
940                adds.push(BString::new(vec![]));
941                continue;
942            }
943            Some(ConflictMarkerLineChar::Remove) => {
944                state = State::Remove;
945                removes.push(BString::new(vec![]));
946                continue;
947            }
948            Some(ConflictMarkerLineChar::Add) => {
949                state = State::Add;
950                adds.push(BString::new(vec![]));
951                continue;
952            }
953            Some(ConflictMarkerLineChar::Note) => {
954                continue;
955            }
956            _ => {}
957        }
958        match state {
959            State::Diff => {
960                if let Some(rest) = line.strip_prefix(b"-") {
961                    removes.last_mut().unwrap().extend_from_slice(rest);
962                } else if let Some(rest) = line.strip_prefix(b"+") {
963                    adds.last_mut().unwrap().extend_from_slice(rest);
964                } else if let Some(rest) = line.strip_prefix(b" ") {
965                    removes.last_mut().unwrap().extend_from_slice(rest);
966                    adds.last_mut().unwrap().extend_from_slice(rest);
967                } else if line == b"\n" || line == b"\r\n" {
968                    // Some editors strip trailing whitespace, so " \n" might become "\n". It would
969                    // be unfortunate if this prevented the conflict from being parsed, so we add
970                    // the empty line to the "remove" and "add" as if there was a space in front
971                    removes.last_mut().unwrap().extend_from_slice(line);
972                    adds.last_mut().unwrap().extend_from_slice(line);
973                } else {
974                    // Doesn't look like a valid conflict
975                    return Merge::resolved(BString::new(vec![]));
976                }
977            }
978            State::Remove => {
979                removes.last_mut().unwrap().extend_from_slice(line);
980            }
981            State::Add => {
982                adds.last_mut().unwrap().extend_from_slice(line);
983            }
984            State::Unknown => {
985                // Doesn't look like a valid conflict
986                return Merge::resolved(BString::new(vec![]));
987            }
988        }
989    }
990
991    if adds.len() == removes.len() + 1 {
992        Merge::from_removes_adds(removes, adds)
993    } else {
994        // Doesn't look like a valid conflict
995        Merge::resolved(BString::new(vec![]))
996    }
997}
998
999fn parse_git_style_conflict_hunk(input: &[u8], expected_marker_len: usize) -> Merge<BString> {
1000    #[derive(PartialEq, Eq)]
1001    enum State {
1002        Left,
1003        Base,
1004        Right,
1005    }
1006    let mut state = State::Left;
1007    let mut left = BString::new(vec![]);
1008    let mut base = BString::new(vec![]);
1009    let mut right = BString::new(vec![]);
1010    for line in input.lines_with_terminator() {
1011        match parse_conflict_marker(line, expected_marker_len) {
1012            Some(ConflictMarkerLineChar::GitAncestor) => {
1013                if state == State::Left {
1014                    state = State::Base;
1015                    continue;
1016                } else {
1017                    // Base must come after left
1018                    return Merge::resolved(BString::new(vec![]));
1019                }
1020            }
1021            Some(ConflictMarkerLineChar::GitSeparator) => {
1022                if state == State::Base {
1023                    state = State::Right;
1024                    continue;
1025                } else {
1026                    // Right must come after base
1027                    return Merge::resolved(BString::new(vec![]));
1028                }
1029            }
1030            _ => {}
1031        }
1032        match state {
1033            State::Left => left.extend_from_slice(line),
1034            State::Base => base.extend_from_slice(line),
1035            State::Right => right.extend_from_slice(line),
1036        }
1037    }
1038
1039    if state == State::Right {
1040        Merge::from_vec(vec![left, base, right])
1041    } else {
1042        // Doesn't look like a valid conflict
1043        Merge::resolved(BString::new(vec![]))
1044    }
1045}
1046
1047/// Parses conflict markers in `content` and returns an updated version of
1048/// `file_ids` with the new contents. If no (valid) conflict markers remain, a
1049/// single resolves `FileId` will be returned.
1050pub async fn update_from_content(
1051    file_ids: &Merge<Option<FileId>>,
1052    store: &Store,
1053    path: &RepoPath,
1054    content: &[u8],
1055    conflict_marker_len: usize,
1056) -> BackendResult<Merge<Option<FileId>>> {
1057    let simplified_file_ids = file_ids.simplify();
1058
1059    let old_contents = extract_as_single_hunk(&simplified_file_ids, store, path).await?;
1060    let old_hunks = files::merge_hunks(&old_contents, store.merge_options());
1061
1062    // Parse conflicts from the new content using the arity of the simplified
1063    // conflicts.
1064    let new_hunks = parse_conflict(
1065        content,
1066        simplified_file_ids.num_sides(),
1067        conflict_marker_len,
1068    );
1069
1070    // Check if the new hunks are unchanged. This makes sure that unchanged file
1071    // conflicts aren't updated to partially-resolved contents.
1072    let unchanged = match (&old_hunks, &new_hunks) {
1073        (MergeResult::Resolved(old), None) => old == content,
1074        (MergeResult::Conflict(old), Some(new)) => old == new,
1075        (MergeResult::Resolved(_), Some(_)) | (MergeResult::Conflict(_), None) => false,
1076    };
1077    if unchanged {
1078        return Ok(file_ids.clone());
1079    }
1080
1081    let Some(hunks) = new_hunks else {
1082        // Either there are no markers or they don't have the expected arity
1083        let file_id = store.write_file(path, &mut &content[..]).await?;
1084        return Ok(Merge::normal(file_id));
1085    };
1086
1087    let mut contents = simplified_file_ids.map(|_| vec![]);
1088    for hunk in hunks {
1089        if let Some(slice) = hunk.as_resolved() {
1090            for content in &mut contents {
1091                content.extend_from_slice(slice);
1092            }
1093        } else {
1094            for (content, slice) in zip(&mut contents, hunk) {
1095                content.extend(Vec::from(slice));
1096            }
1097        }
1098    }
1099
1100    // Now write the new files contents we found by parsing the file with conflict
1101    // markers.
1102    // TODO: Write these concurrently
1103    let new_file_ids: Vec<Option<FileId>> = zip(&contents, &simplified_file_ids)
1104        .map(|(content, file_id)| -> BackendResult<Option<FileId>> {
1105            if file_id.is_some() || !content.is_empty() {
1106                let file_id = store.write_file(path, &mut content.as_slice()).block_on()?;
1107                Ok(Some(file_id))
1108            } else {
1109                // The missing side of a conflict is still represented by
1110                // the empty string we materialized it as
1111                Ok(None)
1112            }
1113        })
1114        .try_collect()?;
1115
1116    // If the conflict was simplified, expand the conflict to the original
1117    // number of sides.
1118    let new_file_ids = if new_file_ids.len() != file_ids.iter().len() {
1119        file_ids
1120            .clone()
1121            .update_from_simplified(Merge::from_vec(new_file_ids))
1122    } else {
1123        Merge::from_vec(new_file_ids)
1124    };
1125    Ok(new_file_ids)
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    #![expect(clippy::too_many_arguments)]
1131
1132    use test_case::test_case;
1133    use test_case::test_matrix;
1134
1135    use super::*;
1136    use crate::files::FileMergeHunkLevel;
1137
1138    #[test]
1139    fn test_resolve_file_executable() {
1140        fn resolve<const N: usize>(values: [Option<bool>; N]) -> Option<bool> {
1141            resolve_file_executable(&Merge::from_vec(values.to_vec()))
1142        }
1143
1144        // already resolved
1145        assert_eq!(resolve([None]), None);
1146        assert_eq!(resolve([Some(false)]), Some(false));
1147        assert_eq!(resolve([Some(true)]), Some(true));
1148
1149        // trivially resolved
1150        assert_eq!(resolve([Some(true), Some(true), Some(true)]), Some(true));
1151        assert_eq!(resolve([Some(true), Some(false), Some(false)]), Some(true));
1152        assert_eq!(resolve([Some(false), Some(true), Some(false)]), Some(false));
1153        assert_eq!(resolve([None, None, Some(true)]), Some(true));
1154
1155        // unresolvable
1156        assert_eq!(resolve([Some(false), Some(true), None]), None);
1157
1158        // trivially resolved to absent, so pick the original state
1159        assert_eq!(resolve([Some(true), Some(true), None]), Some(true));
1160        assert_eq!(resolve([None, Some(false), Some(false)]), Some(false));
1161        assert_eq!(
1162            resolve([None, None, Some(true), Some(true), None]),
1163            Some(true)
1164        );
1165
1166        // trivially resolved to absent, and the original state is ambiguous
1167        assert_eq!(
1168            resolve([Some(true), Some(true), None, Some(false), Some(false)]),
1169            None
1170        );
1171        assert_eq!(
1172            resolve([
1173                None,
1174                Some(true),
1175                Some(true),
1176                Some(false),
1177                Some(false),
1178                Some(false),
1179                Some(false),
1180            ]),
1181            None
1182        );
1183    }
1184
1185    #[test_case(Merge::resolved("\n") => "\n"; "starts with LF")]
1186    #[test_case(Merge::resolved("a\r\n") => "\r\n"; "crlf")]
1187    #[test_case(Merge::resolved("a\n") => "\n"; "lf")]
1188    #[test_case(Merge::resolved("abc") => "\n"; "no eol")]
1189    #[test_case(Merge::from_vec(vec![
1190        "a",
1191        "a\n",
1192        "ab",
1193    ]) => "\n"; "only the second side has the LF eol")]
1194    #[test_case(Merge::from_vec(vec![
1195        "a\r\n",
1196        "ab",
1197        "a\n",
1198    ]) => "\n"; "both sides have different EOLs")]
1199    #[test_case(Merge::from_vec(vec![
1200        "a",
1201        "a\r\n",
1202        "ab",
1203    ]) => "\r\n"; "only the second side has the CRLF eol")]
1204    fn test_detect_eol(single_hunk: Merge<impl AsRef<[u8]>>) -> &'static str {
1205        detect_eol(&single_hunk).to_str().unwrap()
1206    }
1207
1208    #[test]
1209    fn test_detect_eol_consistency() {
1210        let crlf_side = "crlf\r\n";
1211        let lf_side = "lf\n";
1212        let merges = [
1213            Merge::from_vec(vec![crlf_side, "base", lf_side]),
1214            Merge::from_vec(vec![lf_side, "base", crlf_side]),
1215        ];
1216
1217        assert_eq!(detect_eol(&merges[0]), detect_eol(&merges[1]));
1218    }
1219
1220    #[test_case(indoc::indoc!{b"
1221        <<<<<<< conflict 1 of 1
1222        %%%%%%% diff from base to side #1
1223        -aa
1224        +cc
1225        +++++++ side #2
1226        bb
1227        >>>>>>> conflict 1 of 1 ends
1228    "}, Merge::from_vec(vec![
1229        "cc\n",
1230        "aa\n",
1231        "bb\n",
1232    ]); "all sides end with EOL")]
1233    #[test_case(indoc::indoc!{b"
1234        <<<<<<< conflict 1 of 1
1235        %%%%%%% diff from base to side #1
1236        -aa
1237        +cc
1238        +++++++ side #2
1239        bb
1240        >>>>>>> conflict 1 of 1 ends"
1241    }, Merge::from_vec(vec![
1242        "cc",
1243        "aa",
1244        "bb",
1245    ]); "all sides end without EOL")]
1246    #[test_case(indoc::indoc!{b"
1247        <<<<<<< conflict 1 of 1
1248        %%%%%%% diff from base to side #1
1249        -aa
1250        +cc
1251
1252        +++++++ side #2
1253        bb
1254        >>>>>>> conflict 1 of 1 ends"
1255    }, Merge::from_vec(vec![
1256        "cc\n",
1257        "aa\n",
1258        "bb",
1259    ]); "side 2 removes the ending EOL")]
1260    #[test_case(indoc::indoc!{b"
1261        <<<<<<< conflict 1 of 1
1262        %%%%%%% diff from base to side #1
1263        -aa
1264        +cc
1265        +++++++ side #2
1266        bb
1267
1268        >>>>>>> conflict 1 of 1 ends"
1269    }, Merge::from_vec(vec![
1270        "cc",
1271        "aa",
1272        "bb\n",
1273    ]); "side 2 adds the ending EOL")]
1274    #[test_case(indoc::indoc!{b"
1275        <<<<<<< conflict 1 of 1
1276        %%%%%%% diff from base to side #1
1277        -aa
1278        -
1279        +cc
1280        +++++++ side #2
1281        bb
1282        
1283        >>>>>>> conflict 1 of 1 ends"
1284    }, Merge::from_vec(vec![
1285        "cc",
1286        "aa\n",
1287        "bb\n",
1288    ]); "side 1 removes the ending EOL")]
1289    #[test_case(indoc::indoc!{b"
1290        <<<<<<< conflict 1 of 1
1291        %%%%%%% diff from base to side #1
1292        -aa
1293        +cc
1294        +
1295        +++++++ side #2
1296        bb
1297        >>>>>>> conflict 1 of 1 ends"
1298    }, Merge::from_vec(vec![
1299        "cc\n",
1300        "aa",
1301        "bb",
1302    ]); "side 1 adds the ending EOL")]
1303    fn test_parse_conflict(contents: &[u8], expected_merge: Merge<&str>) {
1304        let actual_result = parse_conflict(contents, 2, 7).unwrap()[0]
1305            .clone()
1306            .map(|content| content.to_str().unwrap().to_owned());
1307        let expected_merge = expected_merge.map(|content| content.to_string());
1308        assert_eq!(actual_result, expected_merge);
1309
1310        // Change the EOL to CRLF and test again.
1311        let actual_result = parse_conflict(&contents.replace(b"\n", b"\r\n"), 2, 7).unwrap()[0]
1312            .clone()
1313            .map(|content| content.to_str().unwrap().to_owned());
1314        let expected_merge = expected_merge.map(|content| content.replace('\n', "\r\n"));
1315        assert_eq!(actual_result, expected_merge);
1316    }
1317
1318    const BASE: &str = "aa";
1319    const SIDE1: &str = "bb";
1320    const SIDE2: &str = "cc";
1321    const WITH_ENDING_EOL: &str = "\n";
1322    const WITHOUT_ENDING_EOL: &str = "";
1323    const GIT_STYLE: ConflictMarkerStyle = ConflictMarkerStyle::Git;
1324    const DIFF_STYLE: ConflictMarkerStyle = ConflictMarkerStyle::Diff;
1325    const DIFF_EXPERIMENTAL_STYLE: ConflictMarkerStyle = ConflictMarkerStyle::DiffExperimental;
1326    const SNAPSHOT_STYLE: ConflictMarkerStyle = ConflictMarkerStyle::Snapshot;
1327    const LF_EOL: &str = "\n";
1328    const CRLF_EOL: &str = "\r\n";
1329    fn long(original: &str) -> String {
1330        std::iter::repeat_n(original, 3).collect_vec().join("\n")
1331    }
1332    fn prepended(original: &str) -> String {
1333        format!("{original}\n{BASE}")
1334    }
1335    #[test_matrix(
1336        BASE,
1337        [WITH_ENDING_EOL, WITHOUT_ENDING_EOL],
1338        [SIDE1, &long(SIDE1), &prepended(SIDE1)],
1339        [WITH_ENDING_EOL, WITHOUT_ENDING_EOL],
1340        [SIDE2, &long(SIDE2), &prepended(SIDE2)],
1341        [WITH_ENDING_EOL, WITHOUT_ENDING_EOL],
1342        [GIT_STYLE, DIFF_STYLE, DIFF_EXPERIMENTAL_STYLE, SNAPSHOT_STYLE],
1343        [LF_EOL, CRLF_EOL]
1344    )]
1345    fn test_materialize_conflict(
1346        base: &str,
1347        base_ending_eol: &str,
1348        side1: &str,
1349        side1_ending_eol: &str,
1350        side2: &str,
1351        side2_ending_eol: &str,
1352        style: ConflictMarkerStyle,
1353        eol: &str,
1354    ) {
1355        // Add a leading EOL to suggest the correct EOL to use for materialization.
1356        let base = format!("\n{base}{base_ending_eol}").replace('\n', eol);
1357        let side1 = format!("\n{side1}{side1_ending_eol}").replace('\n', eol);
1358        let side2 = format!("\n{side2}{side2_ending_eol}").replace('\n', eol);
1359        let merge = Merge::from_vec(vec![side2.as_str(), base.as_str(), side1.as_str()]);
1360        let options = ConflictMaterializeOptions {
1361            marker_style: style,
1362            marker_len: None,
1363            merge: MergeOptions {
1364                hunk_level: FileMergeHunkLevel::Line,
1365                same_change: SameChange::Accept,
1366            },
1367        };
1368        let actual_contents = String::from_utf8(
1369            materialize_merge_result_to_bytes(&merge, &ConflictLabels::unlabeled(), &options)
1370                .into(),
1371        )
1372        .unwrap();
1373        // We expect the materialized conflict to keep the original EOL, LF or CRLF.
1374        for line in actual_contents.as_bytes().lines_with_terminator() {
1375            let line = line.as_bstr();
1376            if !line.ends_with(b"\n") {
1377                continue;
1378            }
1379            let should_end_with_crlf = eol == "\r\n";
1380            assert!(
1381                line.ends_with(b"\r\n") == should_end_with_crlf,
1382                "Expect all the lines with EOL to end with {eol:?}, but got {line:?} from\n{}",
1383                actual_contents
1384                    // Replace \r to ␍ and \n to ␊ for clarity in the panic message.
1385                    .replace('\r', "\u{240D}")
1386                    .replace('\n', "\u{240A}\n")
1387            );
1388        }
1389        let hunks = parse_conflict(actual_contents.as_bytes(), 2, 7).unwrap();
1390        assert!(hunks.len() >= 2);
1391        // The first hunk is the empty line.
1392        let leading_eol = hunks[0].as_resolved().unwrap();
1393        let mut actual_merge = hunks[1].clone();
1394        for content in &mut actual_merge {
1395            content.insert_str(0, leading_eol);
1396        }
1397        // When both sides prepend contents, we end up with 3 hunks.
1398        if hunks.len() == 3 {
1399            let new_content = hunks[2].as_resolved().unwrap();
1400            for content in &mut actual_merge {
1401                content.extend_from_slice(new_content);
1402            }
1403        }
1404        assert!(hunks.len() <= 3);
1405        let actual_merge = actual_merge.map(|content| content.to_str().unwrap().to_owned());
1406        let merge = merge.map(|content| content.to_string());
1407        assert_eq!(actual_merge, merge);
1408    }
1409}