jj / First-class conflicts

First-class conflicts

How jj records a conflict instead of stopping on it: Merge<T>, an odd-length list of alternating added and removed terms. The same type holds conflicted commits, paths, bookmarks and file hunks. This page covers how it simplifies and resolves, how a conflicted tree is stored, and how a conflict becomes conflict markers in a file and back.

On this page
  1. 1. The data model
  2. 2. Merge&lt;T&gt;
  3. Absent values: Merge&lt;Option&lt;T&gt;&gt;
  4. The two-term companion: Diff&lt;T&gt;
  5. 3. Where Merge appears
  6. 4. Trivial merge and the same-change rule
  7. 5. Flattening and simplification
  8. 6. Conflict labels
  9. 7. Resolving a conflicted tree
  10. 8. How each backend stores a conflict
  11. 9. Materialization: from Merge to conflict markers
  12. Back again: parsing markers on snapshot
  13. 10. Configuration
  14. 11. Worked example: a three-sided conflict

In short: a conflict is the expression A + (C − B) + (E − D) …, stored as the list [A, B, C, D, E]. Nothing is ever merged up front. Whenever jj needs a value it first tries trivial_merge, which cancels equal added and removed terms, and only falls back to reading contents (subtrees, then file hunks) when that fails. Rebasing a conflicted commit flattens the nested expressions and simplifies away cancelling pairs, so conflicts don't pile up. When written to disk, a conflicted file gets conflict markers, and jj remembers the marker length so it can parse the file back into a Merge on the next snapshot.

1. The data model

The design doc puts it this way: "When a merge conflict happens, it is recorded as an ordered list of tree objects linked from the commit (instead of the usual single tree per commit). There will always be an odd number of trees". The first tree is a starting point, and every following pair is a diff to apply to it: trees A, B, C, D, E mean A+(C-B)+(E-D), and an ordinary three-way merge of A and C with base B is just A, B, C (conflicts.md).

How Merge stores a five-term conflict Merge's single field, values, is a SmallVec holding A, B, C, D and E at indexes 0 to 4. Even indexes are adds (positive terms) and odd indexes are removes (negative terms). adds() returns A, C and E; removes() returns B and D. get_add(i) reads index 2i and get_remove(i) reads index 2i+1. The conflict reads as A plus the diff from B to C plus the diff from D to E, and has three sides. Merge<T> values: SmallVec<[T; 1]> index 0 1 2 3 4 + A first() / get_add(0) − B get_remove(0) + C get_add(1) − D get_remove(1) + E get_add(2) diff 0: B → C diff 1: D → E = A + (C − B) + (E − D) num_sides() = len / 2 + 1 = 3

2. Merge<T>

Merge<T> has one private field, values: SmallVec<[T; 1]>, which "alternates between positive and negative terms, starting with positive". The invariant is in the doc comment: "There is exactly one more adds() than removes()", and "the merge's (i+1)-st add is matched with the i-th remove. The zeroth add is considered a diff from the non-existent state." A resolved value is the one-element case, so Merge<T> costs nothing extra when there is no conflict: the SmallVec holds one item inline. Its Debug output prints Resolved(..) or Conflicted([..]) (source), and ContentHash hashes the slice of terms (source).

FunctionWhat it does
from_vecWrap an alternating list. Panics with "must have an odd number of terms".
from_removes_addsInterleave separate lists; panics unless there is exactly one more add than removes.
from_diffsA first side plus a series of Diffs, each contributing [before, after].
resolved, repeatedOne term, or one value repeated for num_sides * 2 - 1 terms.
removes, addsOdd indexes and even indexes, via step_by(2).
first, get_remove, get_addIndex 0, 2i+1 and 2i.
num_sides, is_resolved, as_resolved, into_resolvedThe number of adds, and whether there is exactly one term. These "do not resolve trivial merges": [0, 0, 1] is not resolved until you ask.
simplify, simplify_by, update_from_simplifiedDrop cancelling add/remove pairs (section 5), optionally comparing through a projection, and write a simplified result back into the original shape.
resolve_trivialRun trivial_merge on the terms (section 4).
map, into_map, try_map, try_map_async, zipTerm-by-term transforms that keep the shape. zip asserts both sides have the same length.
MergeBuilderCollect an iterator, then check the odd length in build(). It exists so Merge never needs a FromIterator that could see an even count.
flattenTurn a Merge<Merge<T>> into a Merge<T> (section 5).

Absent values: Merge<Option<T>>

Paths and refs use Option terms, where None means "not present on this term". A file added on two sides is [Some(x), None, Some(y)], a modify/delete conflict has a None add. The extra methods (source) are absent() (resolved None), normal(v) (resolved Some(v)), is_absent/is_present, as_normal, present_adds/present_removes, and a conversion to and from the "legacy form" of two lists with the Nones dropped. That conversion "is lossy: the order of None values is not preserved".

The two-term companion: Diff<T>

Diff<T> is { before, after }, "a generic diff/transition from one value to another", explicitly "not a diff in the patch(1) sense". It has map, zip, invert (swap the two), as_ref, into_array and is_changed (source). Tree diffs yield Diff<MergedTreeValue>, and Merge::from_diffs builds a conflict out of a sequence of them.

3. Where Merge appears

TypeMeaningExplained on
Commit.root_tree: Merge<TreeId>The commit's contents. Conflicted means several whole root trees.commits
Commit.conflict_labels: Merge<String>"If resolved, must be empty string. Otherwise, must have same number of terms as root_tree."section 6
MergedTreeValue = Merge<Option<TreeValue>>One path looked up in every term of a conflicted tree.trees
RefTarget = Merge<Option<CommitId>>A bookmark or tag, conflicted when concurrent operations moved it differently.view
Merge<Option<FileId>>, Merge<BString>A file conflict as IDs, then as contents and as individual hunks.section 9

This page is about the first three. The design doc notes that conflicts between bookmark targets are a separate topic (conflicts.md); they use the same type and the same trivial merge, and the operations page covers how they arise.

4. Trivial merge and the same-change rule

trivial_merge(values, same_change) resolves a merge using only equality of its terms, without looking inside them. It asserts the term count is odd, then:

  1. One term: that's the answer.
  2. Three terms [add0, remove, add1] (the common case, handled directly): if both adds are equal and same_change is Accept, return it; else if add0 == remove return add1; else if add1 == remove return add0; else no result.
  3. Otherwise count each distinct value, +1 for an add and −1 for a remove, and drop values whose count is zero ("they have canceled out"). One value left (with count 1): that's the result. Two left and Accept: "All sides made the same change", so return the one with the positive count. Anything else is a real conflict.

SameChange has two values. Keep "leaves same-change conflict unresolved". Accept "resolves same-change conflict as if one side were unchanged (i.e. A+(A-B)=A)", matching Git and Mercurial. The doc comment adds that this "means that repeated 3-way merging of multiple trees may give different results depending on the order of merging". The design doc calls it lossy: rebasing onto a commit that already contains the same change and back again can lose changes. jj accepts that "because it is more user-friendly in the vast majority of cases" (conflicts.md). The setting is merge.same-change, default "accept".

TermsKeepAcceptWhy
[0, 0, 1]11Side 1 is unchanged from the base, so side 2 wins.
[0, 1, 1]00Side 2 is unchanged.
[0, 1, 0]none0Both sides made the same change from 1 to 0.
[0, 1, 2]nonenoneA real conflict.
[0, 0, 1, 1, 2]220 and 1 each cancel; only 2 is left.
[0, 1, 2, 0, 2]none20 cancels; 2 appears twice as an add and once as a remove.

Rows taken from test_trivial_merge.

5. Flattening and simplification

When one of the inputs to a merge is itself a conflict, jj "simply insert[s] the conflict expression there. Then we simplify by removing canceling terms" (conflicts.md). The two steps are separate functions.

flatten takes the outer terms in order. Added inner merges are appended as they are. A removed inner merge is negated: its adds become removes and vice versa, done by rotating its terms left by one and then swapping each adjacent pair, "so we preserve the diffs". Its doc comment shows a three-way merge of three-way merges becoming one nine-term merge.

simplify "joins diffs like A->B and B->C into A->C" and "drops trivial diffs like A->A". The work is in get_simplified_mapping: walk the adds; for each, find a remove with an equal value; if found, move the add into that remove's diff slot and delete the pair. It returns index positions rather than values so that simplify_by can compare through a projection and update_from_simplified can map an edited, simplified merge back to the original shape. Neither function looks inside the terms or cares about SameChange.

Rebasing a conflicted commit: flatten then simplify Commit X has the conflict C plus the diff from A to B, stored as C, A, B. Rebasing X from parent C to parent D builds the outer merge D, C, X: new base, old base, old tree. Flattening appends D, then the negated C, then X's three terms, giving D, C, C, A, B. Simplification finds that the add C at index 2 equals the remove C at index 1 and deletes that pair, leaving D, A, B: a plain three-way merge of D and B with base A, with no trace of C. rebase: MergedTree::merge([new_base, old_base, old_tree]) = [ [D], [C], [C, A, B] ] (X itself is the conflict C + (B − A)) flatten() +D −C +C −A +B 5 terms simplify() +D −A +B = D + (B − A) MergedTree::resolve() then merges what is left path by path (section 7)

This is the design doc's own example: B based on A, rebased to C with conflicts, left unresolved, then rebased to D. "That expression can be simplified to D+(B-A) … This is what lets the user keep old commits rebased to head without resolving conflicts and still not get messy recursive conflicts." Reverting a conflicted change cancels the same way, back to a plain tree (conflicts.md). In code, the rebase in CommitRewriter::rebase_with_empty_behavior passes the three trees to MergedTree::merge, which flattens the tree IDs and their labels together, simplifies both with ConflictLabels::simplify_with, and then calls resolve().

6. Conflict labels

ConflictLabels wraps a Merge<String> with one label per term. "If the merge is resolved, the label must be empty", and from_merge/from_vec collapse a resolved or all-empty merge to unlabeled(). get_add/get_remove treat an empty string as "no label" (source). simplify_with zips the labels with a merge, simplifies by the merge's values only, and unzips, so each label stays attached to its term.

Labels are chosen where a merge is built. A rebase labels its three inputs "<parents> (rebase destination)", "<parents> (parents of rebased revision)" and "<commit> (rebased revision)" (source). A commit's label is its short change ID and commit ID plus the quoted first line of its description, with control characters removed; several parents are joined with ", " using only the short form (commit.rs). When one of the inputs is already conflicted, labels_by_term keeps its existing labels instead, "This ensures that rebasing conflicted commits keeps meaningful labels"; old conflicts without labels get empty strings. Labels show up in conflict markers (+++++++ side #1 is the fallback when there is none) and in the commit's stored form below.

7. Resolving a conflicted tree

merge_trees(store, Merge<TreeId>) is the recursive merge behind MergedTree::resolve. It promises "The returned conflict will either be resolved or have the same number of sides as the input." It runs a TreeMerger, a small work queue of async jobs bounded by store.concurrency(), that starts by reading the root directory of every term. The design doc's point is that it only descends where it must: "if we can't resolve a sub-tree conflict trivially by looking at just the tree id, we recurse into the sub-tree. Similarly, if we can't resolve a file conflict trivially by looking at just the id, we recurse into the hunks within the file" (conflicts.md).

How merge_trees resolves a conflicted tree merge_trees returns at once if the input is resolved. Otherwise TreeMerger reads the root directory of every term. process_tree runs resolve_trivial on every entry. Trivially resolved entries go straight into the output. An unresolved subtree is read and processed the same way, recursively. An unresolved file goes to try_resolve_file_conflict, which needs every term to be a file with agreeing executable bit and copy ID, then merges contents hunk by hunk with files::try_merge. Each finished entry is marked completed; when a directory has no pending entries, into_backend_trees builds one tree per side, or a single tree if nothing conflicts, and writes it. The written tree is reported to the parent directory, and when the root is written the result is a Merge of TreeIds. merge_trees resolved input: return it unchanged enqueue_tree_read / read_trees load this dir in every term process_tree resolve_trivial on each entry MergedTreeInput.resolved trivially resolved entries enqueue_file_merge non-tree entry try_resolve_file_conflict all files? exec bit and copy ID agree? then files::try_merge on contents mark_completed resolved, or kept in conflicts into_backend_trees no conflicts: one tree else one tree per term enqueue_tree_write / write_trees empty tree becomes absent in the parent TreeMerger::merge root written: return Merge<TreeId> subtree: recurse resolved

A few details matter for correctness:

  • Files are merged only if they can be. try_resolve_file_conflict gives up if any term is absent or not a file. The executable bit and copy ID must resolve trivially (always with Accept, a TODO notes); otherwise "We're unable to determine whether the result should be executable". If the file IDs resolve trivially it doesn't read contents at all. Otherwise it simplifies the file-ID merge again, because terms that differed only in the executable bit can now cancel, reads each content, and calls files::try_merge; a fully merged result is written as a new file.
  • The content merge is one multi-way diff. files::merge_inner diffs all removes and adds together by line, and resolve_diff_hunks turns each differing hunk into a Merge and runs resolve_trivial on it. With merge.hunk-level = "word", an unresolved line hunk is re-diffed by word and accepted only if it then resolves completely. A TODO notes that using the first remove as the base "is how it's usually done for 3-way conflicts" and asks whether there is a better heuristic for more sides.
  • Arity is preserved. An unresolved directory produces one tree per term, each holding all the resolved entries plus that term's side of every conflicted entry. Shrinking a conflict is left to the caller: MergedTree::resolve simplifies the result with its labels afterwards, and in debug builds checks that merging again changes nothing (source).
  • Settings come from the store. MergeOptions is hunk_level plus same_change, read from merge.hunk-level and merge.same-change by UserSettings::merge_options when the repo is loaded and kept on the Store (repo.rs).

resolve_file_values is the single-path version used outside the tree walk: trivial merge first, then the same file merge.

8. How each backend stores a conflict

A conflicted commit is still one commit object. What changes is how its root_tree and conflict_labels are written.

BackendTreesLabels
GitThe term IDs, hex-encoded and joined by spaces, in a non-standard jj:trees commit header. The Git tree field points at a synthetic tree (below).A jj:conflict-labels header: the labels joined with \n, plus a trailing newline. Labels may not contain a newline (asserted).
Simplesimple_store.Commit.root_tree (field 3, repeated bytes), "Alternating positive and negative terms".conflict_labels (field 10, repeated string), written only when conflicted.

Git write · Git read · simple backend · simple_store.proto

Git itself can't represent a conflict, so write_tree_conflict writes a tree that keeps every term reachable: subtrees .jjconflict-base-N for the removes and .jjconflict-side-N for the adds, a JJ-CONFLICT-README blob, and then the entries of the first add, "which prevents editors with Git support from highlighting all files as new". The README tells anyone who checked it out with plain git to "Use jj abandon to recover." The Git compatibility doc says the same: those directories exist "only to prevent GC of the relevant trees; the authoritative information is in a non-standard jj:trees commit header" (git-compatibility.md).

Reading is strict. extract_root_tree_from_commit rejects a jj:trees header with one term or an even number of terms, or with IDs of the wrong length: "It is invalid to use jj:trees with a non-conflicted tree. If this were allowed, it would be possible to construct a commit which appears to have different contents depending on whether it is viewed using jj or git." The error is "Invalid jj:trees header". Conflicts written before the header existed ("~March 2024") kept their terms in the git_store.Commit.root_tree field of jj's extra metadata; on read, deserialize_extras still uses that field when the header gave a resolved tree and uses_tree_conflict_format is set. New commits never fill it: serialize_extras only sets the flag. The protobufs page lists those fields.

9. Materialization: from Merge to conflict markers

To show a conflict in the working copy or in a diff, jj reads each path's value with materialize_tree_value, which returns a MaterializedTreeValue: Absent, AccessDenied, File (with a content reader), Symlink, GitSubmodule, Tree, or one of two conflict variants:

  • FileConflict, when every term is a file or absent (try_materialize_file_conflict_value). It carries the unsimplified_ids (to write an edit back in the original shape), the simplified ids and labels, the contents of each simplified term as Merge<BString> (an absent term reads as empty), the merged executable bit and a placeholder copy ID.
  • OtherConflict, for anything else, such as a file against a directory or a symlink. The working copy writes a plain text description instead: Conflict: followed by Removing … and Adding … lines naming each term's kind and ID (describe, used at local_working_copy.rs).

resolve_file_executable trivially merges the executable bits; if the file resolves to absent it falls back to the bit all removes agree on, "so the new file inherits the original executable bit".

materialize_merge_result_to_bytes then runs the same hunk merge (files::merge_hunks). If every hunk resolves, the merged text is written with no markers at all. Otherwise materialize_conflict_hunks writes resolved hunks verbatim and numbers the conflicted ones (conflict 1 of 2, …).

ConflictMarkerStyleOutput
diff (default)"a snapshot and a series of diffs to apply". For each base, it diffs to the next side; until a snapshot has been written, if the following side is a smaller diff from that base, it writes the current side as the snapshot and diffs to the following one instead. If no snapshot has been written by the end, the last side is the snapshot.
diff-experimental"Similar to "diff", but always picks the first side as the snapshot. May become the default in a future version."
snapshot"a snapshot for each base and side": +++++++ and ------- sections, no diffs.
gitGit's "diff3" layout (<<<<<<<, |||||||, =======, >>>>>>>) "to support external tools". Only possible with exactly two sides; more sides fall back to the jj layout as snapshot.

materialize_jj_style_conflict · materialize_git_style_conflict · diff_size

Some details that keep the round trip lossless:

  • Marker length. Markers are at least MIN_CONFLICT_MARKER_LEN = 7 characters. If any term already contains lines that look like markers, choose_materialized_conflict_marker_len uses the longest such run plus 4, so the real markers are "noticeably longer than the existing markers".
  • Missing final newline. If any side lacks one, every side gets the separator newline and the end marker loses its own, and labels gain "(no terminating newline)" (source).
  • Line endings. detect_eol uses CRLF for markers only if every term with a newline uses CRLF.
  • Default labels. Without stored labels, terms are side #N and base (or base #N with several bases), "The vast majority of conflicts one actually tries to resolve manually have 1 base" (build_hunk_sides).

Back again: parsing markers on snapshot

When the working copy writes a conflicted file, it records the marker length it used in the file's state as MaterializedConflictData { conflict_marker_len } (source). On the next snapshot, if the path was conflicted and is now a normal file, it calls update_from_content with that length (source):

  1. Simplify the old file IDs and re-materialize their hunks. If the new content still parses to exactly those hunks (or to the same resolved text), return the old IDs unchanged, so "unchanged file conflicts aren't updated to partially-resolved contents".
  2. parse_conflict looks for start and end markers at least the expected length, and parses each hunk in whichever style its first line implies (jj or Git), so switching ui.conflict-marker-style doesn't break existing files. A hunk with the wrong number of sides is treated as plain text.
  3. No valid markers left: the file is resolved, and its content is written as one new file.
  4. Otherwise the parsed hunks are reassembled into one content per term and each is written as a new file (a term that was absent and is still empty stays absent). If the conflict had been simplified, update_from_simplified expands the result back to the original number of terms.
A conflicted file's round trip through the working copy On checkout, materialize_tree_value turns a conflicted path into a FileConflict with one content per term. materialize_merge_result_to_bytes writes conflict markers in the configured style, and the working copy stores the file plus MaterializedConflictData with the marker length. On snapshot, update_from_content reads the edited file, parse_conflict finds markers of that length, and the result is either the old IDs unchanged, a single resolved FileId, or a new Merge of FileIds with the same number of terms. MergedTreeValue conflicted path in @ materialize_tree_value → FileConflict materialize_merge_ result_to_bytes file on disk with markers + MaterializedConflictData { conflict_marker_len } update_from_content on snapshot, if the path was conflicted parse_conflict markers ≥ stored length unchanged old IDs no markers one FileId still conflicted new Merge, same arity edit the markers, or delete them to resolve

10. Configuration

KeyDefaultEffect
ui.conflict-marker-style"diff" (misc.toml)Marker style when writing conflicts to files; read into the command environment (workspace_env.rs). The built-in smerge, vscode and vscodium merge tools override it with "git" (merge_tools.toml).
merge.hunk-level"line"Granularity of content merges: line or word (FileMergeHunkLevel).
merge.same-change"accept"The same-change rule; keep leaves such conflicts in place. The default file has a TODO about making keep the default.

Library defaults: lib/src/config/misc.toml

11. Worked example: a three-sided conflict

Upstream's test_materialize_conflict_three_sides builds this conflict with Merge::from_removes_adds([base1, base2], [a, b, c]), so the stored order is [a, base1, b, base2, c]: a + (b − base1) + (c − base2). Every version starts with line 1 and ends with line 5, so those lines merge cleanly and appear outside the markers.

TermLines 2 to 4
side #1 (a)line 2 a.1, line 3 a.2, line 4 base
base #1line 2 base, line 3 base, line 4 base
side #2 (b)line 2 b.1, line 3 base, line 4 b.2
base #2line 2 base (no lines 3 or 4)
side #3 (c)line 2 base, line 3 c.2

With the default diff style, the snapshot upstream asserts is:

line 1
<<<<<<< conflict 1 of 1
%%%%%%% diff from: base #1
\\\\\\\        to: side #1
-line 2 base
-line 3 base
+line 2 a.1
+line 3 a.2
 line 4 base
+++++++ side #2
line 2 b.1
line 3 base
line 4 b.2
%%%%%%% diff from: base #2
\\\\\\\        to: side #3
 line 2 base
+line 3 c.2
>>>>>>> conflict 1 of 1 ends
line 5

Why that shape: for base #1, the diff to side #1 and the diff to side #2 are the same size (two changed lines each), and the code switches only when the next side is strictly smaller, so side #1 is written as a diff. For base #2, the diff to side #2 would be large while the diff to side #3 adds one line, so side #2 becomes the snapshot and side #3 the diff. Markers are 7 characters because no input line looks like a marker.

The other styles, from the same test: diff-experimental writes side #1 as the snapshot and then diffs base #1 → side #2 and base #2 → side #3. snapshot writes all five terms in order as +++++++ side #1, ------- base #1, +++++++ side #2, ------- base #2, +++++++ side #3. And git produces exactly the snapshot output, since Git's markers can't express three sides.

If the user edits only the lines under +++++++ side #2 and saves, the next snapshot parses five terms again and writes each term's content back, so the commit is still a three-sided conflict with only side #2 changed. If they delete all the markers, parse_conflict finds none, and the path becomes a single resolved file. Resolving with a merge tool instead is covered on the resolve page.