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).
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).
| Function | What it does |
|---|---|
from_vec | Wrap an alternating list. Panics with "must have an odd number of terms". |
from_removes_adds | Interleave separate lists; panics unless there is exactly one more add than removes. |
from_diffs | A first side plus a series of Diffs, each contributing [before, after]. |
resolved, repeated | One term, or one value repeated for num_sides * 2 - 1 terms. |
removes, adds | Odd indexes and even indexes, via step_by(2). |
first, get_remove, get_add | Index 0, 2i+1 and 2i. |
num_sides, is_resolved, as_resolved, into_resolved | The 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_simplified | Drop cancelling add/remove pairs (section 5), optionally comparing through a projection, and write a simplified result back into the original shape. |
resolve_trivial | Run trivial_merge on the terms (section 4). |
map, into_map, try_map, try_map_async, zip | Term-by-term transforms that keep the shape. zip asserts both sides have the same length. |
MergeBuilder | Collect an iterator, then check the odd length in build(). It exists so Merge never needs a FromIterator that could see an even count. |
flatten | Turn 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
| Type | Meaning | Explained 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:
- One term: that's the answer.
- Three terms
[add0, remove, add1](the common case, handled directly): if both adds are equal andsame_changeisAccept, return it; else ifadd0 == removereturnadd1; else ifadd1 == removereturnadd0; else no result. - Otherwise count each distinct value,
+1for an add and−1for 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 andAccept: "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".
| Terms | Keep | Accept | Why |
|---|---|---|---|
[0, 0, 1] | 1 | 1 | Side 1 is unchanged from the base, so side 2 wins. |
[0, 1, 1] | 0 | 0 | Side 2 is unchanged. |
[0, 1, 0] | none | 0 | Both sides made the same change from 1 to 0. |
[0, 1, 2] | none | none | A real conflict. |
[0, 0, 1, 1, 2] | 2 | 2 | 0 and 1 each cancel; only 2 is left. |
[0, 1, 2, 0, 2] | none | 2 | 0 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.
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).
A few details matter for correctness:
- Files are merged only if they can be.
try_resolve_file_conflictgives up if any term is absent or not a file. The executable bit and copy ID must resolve trivially (always withAccept, 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 callsfiles::try_merge; a fully merged result is written as a new file. - The content merge is one multi-way diff.
files::merge_innerdiffs all removes and adds together by line, andresolve_diff_hunksturns each differing hunk into aMergeand runsresolve_trivialon it. Withmerge.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::resolvesimplifies the result with its labels afterwards, and in debug builds checks that merging again changes nothing (source). - Settings come from the store.
MergeOptionsishunk_levelplussame_change, read frommerge.hunk-levelandmerge.same-changebyUserSettings::merge_optionswhen the repo is loaded and kept on theStore(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.
| Backend | Trees | Labels |
|---|---|---|
| Git | The 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). |
| Simple | simple_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 theunsimplified_ids(to write an edit back in the original shape), the simplifiedidsandlabels, thecontentsof each simplified term asMerge<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 byRemoving …andAdding …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, …).
ConflictMarkerStyle | Output |
|---|---|
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. |
git | Git'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_lenuses 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_eoluses CRLF for markers only if every term with a newline uses CRLF. - Default labels. Without stored labels, terms are
side #Nandbase(orbase #Nwith 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):
- 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".
parse_conflictlooks 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 switchingui.conflict-marker-styledoesn't break existing files. A hunk with the wrong number of sides is treated as plain text.- No valid markers left: the file is resolved, and its content is written as one new file.
- 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_simplifiedexpands the result back to the original number of terms.
10. Configuration
| Key | Default | Effect |
|---|---|---|
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.
| Term | Lines 2 to 4 |
|---|---|
side #1 (a) | line 2 a.1, line 3 a.2, line 4 base |
| base #1 | line 2 base, line 3 base, line 4 base |
side #2 (b) | line 2 b.1, line 3 base, line 4 b.2 |
| base #2 | line 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.