Skip to main content

vcs_git/
conflict.rs

1//! Typed model of git conflict markers — parse a conflicted file's *content*
2//! into structured regions and write a chosen resolution back. Pure functions
3//! (no subprocess), so everything here is hermetic.
4//!
5//! Handles git's three `merge.conflictStyle`s with one grammar: `merge`
6//! (2-way: ours/theirs), `diff3` (3-way: ours/base/theirs), and `zdiff3`
7//! (same markers as diff3 — the common affixes are already outside the
8//! region). Marker length is variable (`merge.conflictMarkerSize`, default 7)
9//! and is detected per region — **but only at 7 or above** (see
10//! [`has_conflict_markers`] and [`parse_conflicts`]): a file materialized with
11//! a smaller `merge.conflictMarkerSize` (or the `conflict-marker-size`
12//! attribute) is silently parsed as ordinary text by this module, not as a
13//! conflict. Lines are kept verbatim (including `\r\n` and a missing trailing
14//! newline), so [`render`] is a byte-exact roundtrip.
15//!
16//! jj note: files materialized with jj's `ui.conflict-marker-style = "git"`
17//! use this exact grammar (with jj's own labels) and parse here; jj's native
18//! `diff`/`snapshot` styles live in `vcs_jj::conflict`.
19
20use processkit::{Error, Result};
21
22use crate::BINARY;
23
24/// Which side of a conflict a resolution keeps.
25///
26/// Intentionally **exhaustive** (no `#[non_exhaustive]`): a git conflict has
27/// exactly these three sides — the domain is closed, so `#[non_exhaustive]` would
28/// buy no future variant while forcing a wildcard arm on any caller that matches
29/// this (callers usually *construct* it to pass to [`resolve`]) and wrongly
30/// signalling a fourth side could appear.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize))]
33pub enum ResolutionSide {
34    /// The `<<<<<<<` side (typically `HEAD`).
35    Ours,
36    /// The `|||||||` base (diff3/zdiff3 only).
37    Base,
38    /// The `>>>>>>>` side (the merged-in branch).
39    Theirs,
40}
41
42/// One conflicted region: the lines of each side plus the verbatim marker
43/// lines (kept so rendering is byte-exact).
44///
45/// All line vectors store lines **with** their original endings; the last
46/// line of a file may have none.
47///
48/// **`serde` wire shape.** Under the optional `serde` feature this serializes to
49/// exactly its **public** fields — the labels, the three sides, and
50/// [`marker_len`](Self::marker_len). The private verbatim marker lines are
51/// `skip`ped: they exist only so [`render`] can reproduce the file byte-for-byte,
52/// they are not part of the modelled conflict, and emitting them would publish an
53/// implementation detail on the wire. Nothing the *public* model carries is lost.
54#[derive(Debug, Clone, PartialEq, Eq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize))]
56#[non_exhaustive]
57pub struct ConflictRegion {
58    /// Label after the `<<<<<<<` marker (e.g. `HEAD`); empty when absent.
59    pub ours_label: String,
60    /// Label after the `|||||||` marker; `None` for 2-way conflicts.
61    pub base_label: Option<String>,
62    /// Label after the `>>>>>>>` marker (e.g. the branch name).
63    pub theirs_label: String,
64    /// The `<<<<<<<`-side lines.
65    pub ours: Vec<String>,
66    /// The base lines (`diff3`/`zdiff3`); `None` for 2-way conflicts.
67    pub base: Option<Vec<String>>,
68    /// The `>>>>>>>`-side lines.
69    pub theirs: Vec<String>,
70    /// The marker run length (7 unless `merge.conflictMarkerSize` raised it).
71    pub marker_len: usize,
72    // Verbatim marker lines, for byte-exact rendering. Private implementation
73    // detail — kept off the `serde` wire shape (see the type docs).
74    #[cfg_attr(feature = "serde", serde(skip))]
75    marker_ours: String,
76    #[cfg_attr(feature = "serde", serde(skip))]
77    marker_base: Option<String>,
78    #[cfg_attr(feature = "serde", serde(skip))]
79    marker_sep: String,
80    #[cfg_attr(feature = "serde", serde(skip))]
81    marker_end: String,
82}
83
84/// A conflicted file as a sequence of plain-text runs and conflict regions —
85/// the shape that keeps [`render`] a byte-exact roundtrip.
86///
87/// Intentionally **exhaustive**: a file is text-or-conflict, and consumers match
88/// every segment in the resolve/render loop this crate exists to serve, so the
89/// closed enum stays ergonomic. Field-level evolution rides [`ConflictRegion`],
90/// which *is* `#[non_exhaustive]`.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize))]
93// Adjacently tagged so the JSON is a *type-stable object* for both variants —
94// `{"kind":"Text","value":[…]}` and `{"kind":"Conflict","value":{…}}` — rather
95// than serde's default externally-tagged shape. Same rationale (and spelling
96// style) as `vcs_core::MergeProbe`: an agent consumer can branch on one `kind`
97// field instead of sniffing which single key an object happens to carry.
98#[cfg_attr(feature = "serde", serde(tag = "kind", content = "value"))]
99pub enum ConflictSegment {
100    /// Lines outside any conflict (verbatim, endings included).
101    Text(Vec<String>),
102    /// One conflicted region (boxed — much larger than a text run).
103    Conflict(Box<ConflictRegion>),
104}
105
106/// Does `content` contain a line that looks like a conflict-start marker?
107/// Cheap pre-check before a full [`parse_conflicts`].
108///
109/// **Contract: only marker runs of length `>= 7` count.** This guards against
110/// false positives on ordinary text containing short `<`/`=`/`>` runs (e.g. a
111/// `<<<` XML-ish snippet or a short divider). Consequently, a file whose
112/// `<<<<<<<` markers were written with `merge.conflictMarkerSize` (or the
113/// `conflict-marker-size` attribute) set below 7 will make this function
114/// return `false` even though a real `git` configured the same way would
115/// treat it as conflicted.
116pub fn has_conflict_markers(content: &str) -> bool {
117    content
118        .split_inclusive('\n')
119        .any(|line| marker_run(line, '<').is_some_and(|n| n >= 7))
120}
121
122/// The length of the leading `ch` run when `line` is a marker line for it:
123/// the run must be followed by a space + label, or end the line.
124fn marker_run(line: &str, ch: char) -> Option<usize> {
125    let trimmed = line.trim_end_matches(['\r', '\n']);
126    let n = trimmed.chars().take_while(|&c| c == ch).count();
127    if n == 0 {
128        return None;
129    }
130    let rest = &trimmed[n..];
131    (rest.is_empty() || rest.starts_with(' ')).then_some(n)
132}
133
134/// The label after an `n`-char marker run (empty when none).
135fn marker_label(line: &str, n: usize) -> String {
136    line.trim_end_matches(['\r', '\n'])[n..]
137        .trim_start()
138        .to_string()
139}
140
141fn parse_error(message: String) -> Error {
142    Error::parse(BINARY, message)
143}
144
145/// Parse a conflicted file's content into text/conflict segments.
146///
147/// Errors with [`ErrorReason::Parse`](processkit::ErrorReason::Parse) only on a genuinely malformed *region*: a
148/// `<<<<<<<`-opened region missing its `=======` separator or `>>>>>>>`
149/// terminator. A `=======`/`>>>>>>>` run **outside** any region is treated as
150/// ordinary content (a Markdown/RST underline, a divider, a quoted email), so a
151/// file with no real conflict — or a real conflict alongside marker-like content
152/// — parses cleanly.
153///
154/// **Contract: only marker runs of length `>= 7` open a region** (same
155/// threshold as [`has_conflict_markers`]), to avoid false positives on
156/// ordinary text with short `<`/`=`/`>` runs. A file whose `<<<<<<<` markers
157/// were written with `merge.conflictMarkerSize` (or the `conflict-marker-size`
158/// attribute) below 7 will *not* be recognized as conflicted here: it comes
159/// back as a single `ConflictSegment::Text` even though a real `git`
160/// configured the same way would have treated it as conflicted. Callers that
161/// need to support smaller marker sizes must detect that out of band.
162pub fn parse_conflicts(content: &str) -> Result<Vec<ConflictSegment>> {
163    let mut segments = Vec::new();
164    let mut text: Vec<String> = Vec::new();
165    let mut lines = content.split_inclusive('\n').peekable();
166
167    while let Some(line) = lines.next() {
168        // A region starts at a `<<<<<<<`-run of length ≥ 7. A `=======` / `>>>>>>>`
169        // run *outside* a region is ordinary content — a Markdown/RST setext
170        // underline (`=========`), a `=======` divider banner, a deep `>>>>>>>`
171        // email quote — NOT a malformed conflict, so it is kept verbatim as text
172        // (a real conflict is delimited by a `<<<<<<<` opener; the region loops
173        // below consume the `=`/`>` markers that belong to it). A genuinely broken
174        // region (an opener with no separator/terminator) is still caught inside
175        // those loops.
176        let Some(n) = marker_run(line, '<').filter(|&n| n >= 7) else {
177            text.push(line.to_string());
178            continue;
179        };
180        if !text.is_empty() {
181            segments.push(ConflictSegment::Text(std::mem::take(&mut text)));
182        }
183
184        let marker_ours = line.to_string();
185        let ours_label = marker_label(line, n);
186        let mut ours = Vec::new();
187        let mut base: Option<Vec<String>> = None;
188        let mut marker_base = None;
189        let mut base_label = None;
190
191        // Ours, until the base marker (diff3) or the separator.
192        let marker_sep = loop {
193            let Some(line) = lines.next() else {
194                return Err(parse_error(format!(
195                    "unterminated conflict (no ======= after {:?})",
196                    marker_ours.trim_end()
197                )));
198            };
199            // Only the FIRST `|`-run is the diff3 base marker; a later matching
200            // line is base *content* (a region has exactly one base marker — a
201            // repeated one used to overwrite it and lose a line on render).
202            if base.is_none() && marker_run(line, '|') == Some(n) {
203                base_label = Some(marker_label(line, n));
204                marker_base = Some(line.to_string());
205                base = Some(Vec::new());
206                continue;
207            }
208            if marker_run(line, '=') == Some(n) {
209                break line.to_string();
210            }
211            match &mut base {
212                Some(base_lines) => base_lines.push(line.to_string()),
213                None => ours.push(line.to_string()),
214            }
215        };
216
217        // Theirs, until the end marker.
218        let mut theirs = Vec::new();
219        let marker_end = loop {
220            let Some(line) = lines.next() else {
221                return Err(parse_error(format!(
222                    "unterminated conflict (no >>>>>>> after {:?})",
223                    marker_ours.trim_end()
224                )));
225            };
226            if marker_run(line, '>') == Some(n) {
227                break line.to_string();
228            }
229            theirs.push(line.to_string());
230        };
231        let theirs_label = marker_label(&marker_end, n);
232
233        segments.push(ConflictSegment::Conflict(Box::new(ConflictRegion {
234            ours_label,
235            base_label,
236            theirs_label,
237            ours,
238            base,
239            theirs,
240            marker_len: n,
241            marker_ours,
242            marker_base,
243            marker_sep,
244            marker_end,
245        })));
246    }
247    if !text.is_empty() {
248        segments.push(ConflictSegment::Text(text));
249    }
250    Ok(segments)
251}
252
253/// Re-render segments verbatim — the byte-exact inverse of
254/// [`parse_conflicts`].
255pub fn render(segments: &[ConflictSegment]) -> String {
256    let mut out = String::new();
257    for segment in segments {
258        match segment {
259            ConflictSegment::Text(lines) => lines.iter().for_each(|l| out.push_str(l)),
260            ConflictSegment::Conflict(region) => {
261                out.push_str(&region.marker_ours);
262                region.ours.iter().for_each(|l| out.push_str(l));
263                if let Some(marker) = &region.marker_base {
264                    out.push_str(marker);
265                    if let Some(base) = &region.base {
266                        base.iter().for_each(|l| out.push_str(l));
267                    }
268                }
269                out.push_str(&region.marker_sep);
270                region.theirs.iter().for_each(|l| out.push_str(l));
271                out.push_str(&region.marker_end);
272            }
273        }
274    }
275    out
276}
277
278/// Produce the file content with every conflict resolved to `side`.
279///
280/// Errors with a clear message when `side` is [`ResolutionSide::Base`] and a
281/// region has no base (2-way `merge` style records none).
282pub fn resolve(segments: &[ConflictSegment], side: ResolutionSide) -> Result<String> {
283    let mut out = String::new();
284    for segment in segments {
285        match segment {
286            ConflictSegment::Text(lines) => lines.iter().for_each(|l| out.push_str(l)),
287            ConflictSegment::Conflict(region) => {
288                let chosen = match side {
289                    ResolutionSide::Ours => &region.ours,
290                    ResolutionSide::Theirs => &region.theirs,
291                    ResolutionSide::Base => region.base.as_ref().ok_or_else(|| {
292                        Error::spawn(
293                            BINARY,
294                            std::io::Error::new(
295                                std::io::ErrorKind::InvalidInput,
296                                "cannot resolve to Base: this conflict records no base \
297                                 (2-way `merge` style; use diff3/zdiff3)",
298                            ),
299                        )
300                    })?,
301                };
302                chosen.iter().for_each(|l| out.push_str(l));
303            }
304        }
305    }
306    Ok(out)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use processkit::ErrorReason;
313
314    const MERGE_2WAY: &str =
315        "line 1\n<<<<<<< HEAD\nmain line 2\n=======\nfeature line 2\n>>>>>>> feature\nline 3\n";
316    const DIFF3: &str = "line 1\n<<<<<<< HEAD\nmain line 2\n||||||| 0b025ce\nline 2\n=======\nfeature line 2\n>>>>>>> feature\nline 3\n";
317
318    #[test]
319    fn parses_two_way_merge_style() {
320        let segments = parse_conflicts(MERGE_2WAY).expect("parse");
321        assert_eq!(segments.len(), 3);
322        let ConflictSegment::Conflict(region) = &segments[1] else {
323            panic!("expected a conflict, got {segments:?}");
324        };
325        assert_eq!(region.ours_label, "HEAD");
326        assert_eq!(region.theirs_label, "feature");
327        assert_eq!(region.ours, ["main line 2\n"]);
328        assert_eq!(region.theirs, ["feature line 2\n"]);
329        assert!(region.base.is_none());
330        assert_eq!(region.marker_len, 7);
331    }
332
333    #[test]
334    fn parses_diff3_with_base() {
335        let segments = parse_conflicts(DIFF3).expect("parse");
336        let ConflictSegment::Conflict(region) = &segments[1] else {
337            panic!("expected a conflict");
338        };
339        assert_eq!(region.base_label.as_deref(), Some("0b025ce"));
340        assert_eq!(region.base.as_deref(), Some(&["line 2\n".to_string()][..]));
341    }
342
343    // Proptest-found regression (seed committed in proptest-regressions/): a
344    // SECOND `|`-run line inside a diff3 region is base *content*, not a
345    // replacement base marker — the overwrite used to drop a line on render,
346    // breaking the byte-exact roundtrip.
347    #[test]
348    fn repeated_base_marker_line_is_base_content() {
349        let s = "<<<<<<<< HEAD\n|||||||| base\n|||||||| base\n========\n>>>>>>>> branché\n";
350        let segments = parse_conflicts(s).expect("parse");
351        let ConflictSegment::Conflict(region) = &segments[0] else {
352            panic!("expected a conflict, got {segments:?}");
353        };
354        assert_eq!(
355            region.base.as_deref(),
356            Some(&["|||||||| base\n".to_string()][..]),
357            "the second |-run line is content of the base section"
358        );
359        assert_eq!(render(&segments), s, "roundtrip must be byte-exact");
360    }
361
362    // Roundtrip must be byte-exact — including CRLF, custom marker sizes,
363    // and a conflict at EOF with no trailing newline.
364    #[test]
365    fn render_roundtrips_exactly() {
366        let crlf = "a\r\n<<<<<<< HEAD\r\nours\r\n=======\r\ntheirs\r\n>>>>>>> b\r\nz\r\n";
367        let wide = "<<<<<<<<<<<<<<< HEAD\nours\n===============\ntheirs\n>>>>>>>>>>>>>>> b\n";
368        let eof = "x\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b";
369        for sample in [MERGE_2WAY, DIFF3, crlf, wide, eof] {
370            let segments = parse_conflicts(sample).expect("parse");
371            assert_eq!(render(&segments), sample, "roundtrip");
372        }
373        // The wide sample detected the larger marker run.
374        let segments = parse_conflicts(wide).unwrap();
375        let ConflictSegment::Conflict(region) = &segments[0] else {
376            panic!()
377        };
378        assert_eq!(region.marker_len, 15);
379    }
380
381    #[test]
382    fn resolve_takes_one_side_everywhere() {
383        let two = format!("{MERGE_2WAY}between\n{MERGE_2WAY}");
384        let segments = parse_conflicts(&two).expect("parse");
385        assert_eq!(
386            resolve(&segments, ResolutionSide::Ours).unwrap(),
387            "line 1\nmain line 2\nline 3\nbetween\nline 1\nmain line 2\nline 3\n"
388        );
389        assert_eq!(
390            resolve(&segments, ResolutionSide::Theirs).unwrap(),
391            "line 1\nfeature line 2\nline 3\nbetween\nline 1\nfeature line 2\nline 3\n"
392        );
393        // No base recorded in merge style → Base resolution is refused.
394        assert!(resolve(&segments, ResolutionSide::Base).is_err());
395
396        let diff3 = parse_conflicts(DIFF3).expect("parse");
397        assert_eq!(
398            resolve(&diff3, ResolutionSide::Base).unwrap(),
399            "line 1\nline 2\nline 3\n"
400        );
401    }
402
403    #[test]
404    fn empty_sides_and_clean_files_parse() {
405        // One side deleted everything.
406        let deletion = "<<<<<<< HEAD\n=======\nkept\n>>>>>>> b\n";
407        let segments = parse_conflicts(deletion).expect("parse");
408        assert_eq!(resolve(&segments, ResolutionSide::Ours).unwrap(), "");
409        // A file without conflicts is one text segment.
410        let clean = parse_conflicts("just\ntext\n").expect("parse");
411        assert_eq!(clean.len(), 1);
412        assert!(!has_conflict_markers("just\ntext\n"));
413        assert!(has_conflict_markers(MERGE_2WAY));
414    }
415
416    #[test]
417    fn malformed_files_are_parse_errors() {
418        // Only a genuinely broken *region* (an opener with no separator/terminator)
419        // is an error.
420        for bad in [
421            "<<<<<<< HEAD\nours\n",                  // no separator
422            "<<<<<<< HEAD\nours\n=======\ntheirs\n", // no terminator
423        ] {
424            assert!(
425                matches!(
426                    parse_conflicts(bad).map_err(Error::into_reason),
427                    Err(ErrorReason::Parse { .. })
428                ),
429                "{bad:?} must fail"
430            );
431        }
432    }
433
434    // A `=======`/`>>>>>>>` run outside any region is ordinary content (Markdown
435    // underline, divider, quoted email), not a malformed conflict — parsed as text,
436    // never an error, and round-trips byte-exact. (H6)
437    #[test]
438    fn marker_like_content_outside_a_region_is_text() {
439        for content in [
440            "Heading\n=======\nbody\n",          // RST/Markdown setext underline
441            "a\n=======================\nb\n",   // divider banner
442            ">>>>>>> deep email quote\nreply\n", // quoted email
443            "code: a <<<<<<< b\n",               // marker run not at line start
444        ] {
445            let segments = parse_conflicts(content).expect("parses as text, no error");
446            assert!(
447                segments
448                    .iter()
449                    .all(|s| matches!(s, ConflictSegment::Text(_))),
450                "{content:?} must be all text, got {segments:?}"
451            );
452            assert_eq!(render(&segments), content, "round-trips byte-exact");
453        }
454    }
455}
456
457// The optional `serde` feature derives `Serialize` on the public conflict model.
458// Pins the wire shape the MCP `repo_conflict_regions` tool publishes: the
459// adjacently-tagged segment envelope, every public region field, and the
460// deliberate *absence* of the private verbatim marker lines.
461#[cfg(all(test, feature = "serde"))]
462mod serde_tests {
463    use super::*;
464
465    #[test]
466    fn region_serializes_its_public_fields_only() {
467        let segments = parse_conflicts(
468            "<<<<<<< HEAD\nmain\n||||||| 0b025ce\nbase\n=======\nfeat\n>>>>>>> feature\n",
469        )
470        .expect("parse");
471        let value = serde_json::to_value(&segments).expect("segments serialise");
472        assert_eq!(
473            value,
474            serde_json::json!([{
475                "kind": "Conflict",
476                "value": {
477                    "ours_label": "HEAD",
478                    "base_label": "0b025ce",
479                    "theirs_label": "feature",
480                    "ours": ["main\n"],
481                    "base": ["base\n"],
482                    "theirs": ["feat\n"],
483                    "marker_len": 7,
484                }
485            }]),
486            "public fields only — the verbatim marker lines stay private"
487        );
488    }
489
490    #[test]
491    fn text_segments_and_sides_are_type_stable_objects() {
492        let segments = parse_conflicts("plain\n").expect("parse");
493        assert_eq!(
494            serde_json::to_value(&segments).unwrap(),
495            serde_json::json!([{"kind": "Text", "value": ["plain\n"]}]),
496            "a marker-free file is one adjacently-tagged Text segment"
497        );
498        assert_eq!(
499            serde_json::to_value(ResolutionSide::Theirs).unwrap(),
500            serde_json::json!("Theirs")
501        );
502    }
503}
504
505// Property-based fuzzing. The marker grammar slices on marker-run lengths and
506// must never panic on a hostile file (a real conflicted file from a git we
507// don't control), and `render(parse(x)?) == x` must hold byte-for-byte — the
508// regression net for the marker-detection / byte-offset logic.
509#[cfg(test)]
510mod proptests {
511    use super::*;
512    use proptest::prelude::*;
513
514    /// A line drawn from the conflict-marker vocabulary plus multibyte text,
515    /// with variable marker-run lengths (7..16) and CRLF, so a joined document
516    /// reaches the marker-slicing branches with adversarial content.
517    fn conflict_line() -> impl Strategy<Value = String> {
518        prop_oneof![
519            (7usize..16).prop_map(|n| format!("{} HEAD\n", "<".repeat(n))),
520            (7usize..16).prop_map(|n| format!("{}\n", "=".repeat(n))),
521            (7usize..16).prop_map(|n| format!("{} branché\n", ">".repeat(n))),
522            (7usize..16).prop_map(|n| format!("{} base\n", "|".repeat(n))),
523            "[a-zé<>=|]{0,14}\r?\n", // text incl. marker-ish chars + multibyte + CRLF
524            Just("\n".to_string()),
525        ]
526    }
527
528    fn conflict_doc() -> impl Strategy<Value = String> {
529        prop::collection::vec(conflict_line(), 0..30).prop_map(|lines| lines.concat())
530    }
531
532    proptest! {
533        #[test]
534        fn parse_never_panics_on_arbitrary_text(s in any::<String>()) {
535            let _ = has_conflict_markers(&s);
536            // Whatever arbitrary text happens to parse must also round-trip
537            // byte-exact — the load-bearing invariant, asserted on this generator
538            // too (not just the structured one below).
539            if let Ok(segments) = parse_conflicts(&s) {
540                prop_assert_eq!(render(&segments), s);
541            }
542        }
543
544        #[test]
545        fn parse_never_panics_on_structured_text(s in conflict_doc()) {
546            let _ = parse_conflicts(&s);
547        }
548
549        // The load-bearing invariant: whenever the file parses, re-rendering is
550        // byte-exact.
551        #[test]
552        fn render_roundtrips_whatever_parses(s in conflict_doc()) {
553            if let Ok(segments) = parse_conflicts(&s) {
554                prop_assert_eq!(render(&segments), s);
555            }
556        }
557
558        // A marker-free file is one Text segment that renders back unchanged.
559        #[test]
560        fn marker_free_files_are_a_single_text_segment(s in "[a-zé \t\r\n]{0,80}") {
561            prop_assume!(!has_conflict_markers(&s));
562            let segments = parse_conflicts(&s).expect("no markers → Ok");
563            prop_assert_eq!(render(&segments), s);
564        }
565    }
566}