Expand description
§Conflict resolution guide
vcs-git and vcs-jj each ship a typed model of conflict markers:
vcs_git::conflict and vcs_jj::conflict. These are pure parsers over a
file’s content — no subprocess, no repo handle, nothing to mock. You feed in
the marker soup a merge left behind, you get back structured regions, and you
can re-render or resolve to a chosen side. That makes them the primitive for
programmatic conflict resolution: the clients (next section) fetch the bytes,
these modules reason about them.
Two invariants hold across both modules — they are property-tested and fuzzed, not aspirational:
- Byte-exact round-trip.
render(parse(x)?) == xfor any input that parses, including CRLF, custom marker sizes, multibyte text, and a conflict at EOF with no trailing newline. Lines are kept with their endings (the last line of a file may have none), and the verbatim marker lines are stored alongside the parsed data — so rendering never reconstructs a marker, it replays it. - Never panics. The grammars slice on marker-run lengths against hostile
input (a real conflicted file from a
git/jjyou don’t control); arbitrary bytes parse-or-error, never abort.
The split is deliberate, not duplication: git and jj materialize conflicts with
different grammars. jj configured with ui.conflict-marker-style = "git"
emits git’s grammar (with jj’s labels) — parse those with vcs_git::conflict.
jj’s native diff/snapshot styles live in vcs_jj::conflict. This asymmetry
is documented in both modules, not an oversight.
§git conflicts (vcs_git::conflict)
§The marker model
One grammar covers git’s three merge.conflictStyles — merge (2-way),
diff3, and zdiff3 (same markers as diff3; the common affixes are already
hoisted outside the region by git):
<<<<<<< HEAD // ours-side marker + label
ours line
||||||| 0b025ce // base marker + label — diff3/zdiff3 only
base line
======= // separator
theirs line
>>>>>>> feature // theirs-side marker + labelMarker length is variable — 7 by default, more if merge.conflictMarkerSize
raised it — and detected per region (marker_len). A line counts as a marker
only when the run is followed by a space + label or ends the line, so a line of
literal ======= inside content with no following space is not mistaken for a
separator.
§Types
pub enum ResolutionSide { Ours, Base, Theirs } // Base is diff3/zdiff3 only
pub enum ConflictSegment {
Text(Vec<String>), // lines outside any conflict, verbatim
Conflict(Box<ConflictRegion>), // boxed — far larger than a text run
}
#[non_exhaustive]
pub struct ConflictRegion {
pub ours_label: String, // after `<<<<<<<` (e.g. "HEAD"); "" if absent
pub base_label: Option<String>, // after `|||||||`; None for 2-way
pub theirs_label: String, // after `>>>>>>>` (e.g. branch name)
pub ours: Vec<String>, // `<<<<<<<`-side lines
pub base: Option<Vec<String>>, // base lines (diff3/zdiff3); None for 2-way
pub theirs: Vec<String>, // `>>>>>>>`-side lines
pub marker_len: usize, // 7 unless merge.conflictMarkerSize raised it
// plus private verbatim marker lines, for byte-exact rendering
}ConflictRegion is #[non_exhaustive] — match it with .. and construct it
only via parse_conflicts. The label/line vectors are public so you can
inspect a region; the marker lines themselves are private, which is why a
hand-built region can’t exist and render can stay byte-exact.
§Functions
pub fn has_conflict_markers(content: &str) -> bool;
pub fn parse_conflicts(content: &str) -> Result<Vec<ConflictSegment>>;
pub fn render(segments: &[ConflictSegment]) -> String;
pub fn resolve(segments: &[ConflictSegment], side: ResolutionSide) -> Result<String>;has_conflict_markers is a cheap pre-check (any line that looks like a
<<<<<<< start) before committing to a full parse. parse_conflicts errors
with Error::Parse on a malformed file — a region missing its ======= or
>>>>>>>, or a stray separator/end marker outside a region. resolve errors
when you ask for Base on a 2-way merge-style conflict that records none.
§Worked examples
Detect, then parse:
if has_conflict_markers(content) {
let segments = parse_conflicts(content)?; // Vec<ConflictSegment>
// ... inspect / resolve ...
}Iterate regions, printing each side:
for segment in parse_conflicts(content)? {
let ConflictSegment::Conflict(region) = segment else { continue };
println!("<<< {} | >>> {}", region.ours_label, region.theirs_label);
print!("ours: {}", region.ours.concat()); // lines keep their endings
print!("theirs: {}", region.theirs.concat());
if let Some(base) = ®ion.base { // diff3/zdiff3 only
print!("base: {}", base.concat());
}
}Resolve every region to ours:
let segments = parse_conflicts(content)?;
let resolved = resolve(&segments, ResolutionSide::Ours)?; // String, write it back
// resolve(&segments, ResolutionSide::Base) errors on 2-way `merge` style.The round-trip invariant, made concrete:
let segments = parse_conflicts(content)?;
assert_eq!(render(&segments), content); // byte-for-byte, CRLF and EOF included§jj conflicts (vcs_jj::conflict)
jj’s materialized markers are not git’s. A region is delimited by a counter
(conflict N of M) and contains one or more sections:
<<<<<<< conflict 1 of 1
%%%%%%% diff from: rnxsupvw 638ae425 "base" // DIFF section
\\\\\\\ to: ozvltnxm 92f2b14f "side-a"
-line 2 // old (base) text
+main line 2 // new (side) text
+++++++ xyrusolp ad268d1f "side-b" // SNAPSHOT section (a side)
feature line 2
>>>>>>> conflict 1 of 1 endsTwo section styles, set by ui.conflict-marker-style:
diff(the jj 0.38 default) — one side rendered as a unified diff from the base. A%%%%%%%line opens it (diff from:label), followed by a\\\\\\\continuation line (to:label), then-/+/-prefixed lines. The side’s content is the diff’s new text (+/); the base is its old text (-/).snapshot— every side and the base rendered verbatim. A+++++++line opens a side (Snapshot); a-------line opens the base (Base).
Both styles can coexist in one region (the diff example above mixes a %%%%%%%
side with a +++++++ side). Section/end markers must match the region’s opening
run length — jj lengthens all of a file’s markers together when content
contains marker-like runs, so a shorter run is content, not a marker.
§Types
#[non_exhaustive]
pub enum JjConflictSection {
Diff { // `%%%%%%%` — one side as a unified diff
from_label: String, // the `diff from:` label (base's ids/desc)
to_label: String, // the `to:` label (this side's ids/desc)
lines: Vec<String>, // raw diff lines, verbatim
},
Snapshot { label: String, lines: Vec<String> }, // `+++++++` — a side, verbatim
Base { label: String, lines: Vec<String> }, // `-------` — the base, verbatim
}
#[non_exhaustive]
pub struct JjConflictRegion {
pub number: u32, // the `N` of `conflict N of M`
pub total: u32, // the `M`
pub sections: Vec<JjConflictSection>, // in file order
// plus private verbatim marker lines, for byte-exact rendering
}
pub enum JjConflictSegment {
Text(Vec<String>),
Conflict(Box<JjConflictRegion>),
}
pub enum JjResolution {
Side(usize), // N-th side, 0-based, file order
Base, // the recorded base
}JjConflictRegion carries two materializers — they apply the recorded diff so
you don’t reason about -/+ prefixes yourself:
impl JjConflictRegion {
pub fn sides(&self) -> Vec<Vec<String>>; // each side's content, file order
pub fn base(&self) -> Option<Vec<String>>; // the base, when one is recorded
}sides() returns one entry per side: a Diff section contributes its applied
new text, a Snapshot its verbatim lines, and a Base section is not a
side (skipped). base() finds the first base it can: a Diff section’s applied
old text, or a Snapshot-style ------- section’s lines — None if the
region records neither.
§Functions
pub fn has_conflict_markers(content: &str) -> bool;
pub fn parse_conflicts(content: &str) -> Result<Vec<JjConflictSegment>>;
pub fn render(segments: &[JjConflictSegment]) -> String;
pub fn resolve(segments: &[JjConflictSegment], resolution: JjResolution) -> Result<String>;has_conflict_markers looks for a <<<<<<< line whose label parses as
conflict N of M — git-style markers are not jj’s and won’t match.
parse_conflicts errors with Error::Parse on an unterminated region, content
before the first section marker, or a git-style file (the error tells you to
use vcs_git::conflict). resolve errors when the requested Side(i) doesn’t
exist or Base is requested on a region with no base — the message names the
conflict number and its side count.
§Worked examples
Parse, then materialize each side and the base — no prefix-stripping by hand:
for segment in parse_conflicts(content)? {
let JjConflictSegment::Conflict(region) = segment else { continue };
println!("conflict {} of {}", region.number, region.total);
for (i, side) in region.sides().iter().enumerate() {
print!("side {i}: {}", side.concat()); // diff applied, prefixes gone
}
if let Some(base) = region.base() {
print!("base: {}", base.concat());
}
}Resolve to the first side, or to the base:
let segments = parse_conflicts(content)?;
let theirs = resolve(&segments, JjResolution::Side(0))?; // first side, file order
let base = resolve(&segments, JjResolution::Base)?; // errors if none recorded
// resolve(&segments, JjResolution::Side(99)) errors: that side doesn't exist.Round-trip — exact even with a conflict at EOF and no trailing newline:
let segments = parse_conflicts(content)?;
assert_eq!(render(&segments), content); // byte-for-byte§Pairing with the clients
These modules parse content — they don’t fetch it. Get the bytes from the client crate, then hand them over.
git: list the unmerged paths, read each file’s working-tree content (the worktree holds the conflict markers), and parse:
for path in git.conflicted_files(repo).await? { // Vec<String>, unmerged paths
let content = fs::read_to_string(repo.join(&path)).unwrap();
if conflict::has_conflict_markers(&content) {
let segments = conflict::parse_conflicts(&content)?;
let resolved = conflict::resolve(&segments, conflict::ResolutionSide::Ours)?;
fs::write(repo.join(&path), resolved).unwrap(); // then `git add` it
}
}git.show_file(repo, rev, path) fetches a specific revision’s blob when you
want a stage rather than the worktree.
jj: list conflicted paths in a revset, materialize each, and parse:
for path in jj.resolve_list(repo, "@").await? { // Vec<String> of paths
let content = jj.file_show(repo, "@", &path).await?; // materialized markers
let segments = conflict::parse_conflicts(&content)?;
let resolved = conflict::resolve(&segments, conflict::JjResolution::Side(0))?;
// ... write `resolved` back via your file-update path ...
}For the full client surface — handles, mocking, error shapes — see the per-crate guides: vcs-git guide and vcs-jj guide.
§Robustness
Both parsers are built to survive content they didn’t author. The grammars only
ever slice on detected marker-run lengths, and every slice is guarded — so
parse_conflicts on arbitrary bytes returns Ok/Err, never panics. The jj
side additionally exercises sides(), base(), and render on whatever parsed,
so the diff materializer (apply_diff) is in the panic-free guarantee too.
The round-trip is the load-bearing invariant and is pinned three ways:
- Unit tests on captured-verbatim samples (jj 0.38
diffandsnapshotoutput; gitmerge/diff3, CRLF, wide markers, EOF-without-newline). proptestgenerators that draw from each tool’s marker vocabulary with variable counters/marker lengths and multibyte text, assertingrender(parse(x)?) == xfor everything that parses.cargo-fuzztargets infuzz/for continuous coverage beyond the proptest corpus.
See the workspace README for how to run the build, tests, and fuzz targets.