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