jj-dojo / Merge conflict support

Merge conflict support

How Jujutsu Dojo finds jj's conflict markers in an open editor, colours each side, puts "Accept" CodeLens above them, and rewrites the block when you click one.

On this page
  1. 1. Wiring
  2. 2. The marker grammar
  3. Test vectors
  4. 3. Types
  5. 4. The tracker
  6. 5. Decorations
  7. 6. CodeLens
  8. 7. The resolver and the two commands
  9. 8. How this maps to jj's conflicts
  10. 9. Loose ends at this pin

In short: a ConflictTracker re-parses every visible editor whenever its document changes. The parser walks the lines looking for <<<<<<< conflict … >>>>>>> conflict blocks and splits each into sides, one per +++++++ snapshot or %%%%%%%/\\\\\\\ diff header that names a change ID and a commit ID. A decorator paints the block, and a CodeLens provider offers "Accept all sides" and one "Accept this side" per side. The two commands turn a side's text back into plain content (applying a diff section, copying a snapshot section) and replace the whole block with it. Only jj's default diff marker style with stored labels is understood. Git-style markers, ------- base sections and longer-than-seven markers are not.

Merge conflict feature: data flow activateMergeConflict creates the ConflictTracker, the ConflictDecorator, the ConflictCodeLensProvider and two commands. Text document changes and visible-editor changes make the tracker call parseTextDocument and fire onDidChange with a ConflictEvent. The decorator listens and calls setDecorations. VS Code asks the CodeLens provider for lenses, which reads getConflict. Clicking a lens runs acceptAllSides or acceptOneSide, which use the resolver to compute plain text and replace the block with an editor edit. That edit is a document change, so the tracker re-parses. VS Code events onDidChangeTextDocument onDidChangeVisibleTextEditors ConflictTracker processEditor: cache by URI and document version parseTextDocument lines → Conflict[] getChangeId per header ConflictDecorator onDidChange → setDecorations 5 decoration types ConflictCodeLensProvider getConflict(uri) → 1 + sides.length lenses commands.ts acceptAllSides(conflict) acceptOneSide(conflict, i) resolver.ts getResolvedText getResolvedTextForAllSides editor.edit: replace the whole block beginLine.start … endLine.end (or the start of the next line if the result is empty) → a document change, so the tracker re-parses and the decorations clear

1. Wiring

activateMergeConflict is called from the JjUiBase constructor (ui.ts:27; see Activation and wiring). It builds the tracker first, hands it to the decorator and the CodeLens provider, registers the two commands, and returns all of them as one Disposable.from(…). package.json declares the two commands (source) and the third-side colour (source). There are no menu entries or keybindings, so CodeLens is the only way in.

2. The marker grammar

parseTextDocument is one pass over the document's lines with a single open ConflictBuilder. Every rule is a prefix test on the line text:

Line starts withEffectSource
<<<<<<< conflict Opens a new conflict at this line. An unfinished one is dropped.78–81
anything, outside a conflictIgnored.82–84
\\\\\\\, when the previous line starts with %%%%%%%A diff section. Closes the open side at the end of the line before the %%%%%%% header. If this line names a change, opens a side that starts at the %%%%%%% line.87–112
+++++++A snapshot section. Closes the open side at the end of the previous line. If this line names a change, opens a side starting here.89–113
>>>>>>> conflict Closes the open side, then emits the conflict, but only if it has at least one side.86–120
anything else, inside a conflictContent of the open side, or of nothing if no side is open.—

A header names a change when getChangeId matches SIDE_HEADER_REGEX, /\s+(\w+)\s+\w+(?:\s+".*")?(?:\s+\(.*\))*$/. Read from the end of the line: a change ID and a commit ID (both word characters), then an optional quoted description, then any number of parenthesised notes such as (no terminating newline). The captured word is the change ID. That is the shape of jj's stored conflict labels. jj's fallback labels (side #1, base) do not match, because # is not a word character. A side's range runs from its header line (the %%%%%%% line for a diff section) to the end of its last content line (ConflictSide). The doc comment shows the full shape (source):

<<<<<<< conflict 1 of 1
%%%%%%% diff from: base_change_id base_commit_id "base commit message" (blah)
\\\\\\\        to: change_id_1 commit_id_1 (blah)
 apple
-grape
+grapefruit
 orange
+++++++ change_id_2 commit_id_2 "commit message 2" (blah)
APPLE
GRAPE
ORANGE
>>>>>>> conflict 1 of 1 ends

The base's IDs on the %%%%%%% line are never read. The side takes its change ID from the to: line, which is the side the diff produces.

Test vectors

parseTextDocument (parser_test.ts). Lines are 0-based. "Side" gives the change ID and the line range.

CaseInput shapeResultSource
1 sidebegin, %%%%%%%, \\\\\\\ to:, 4 diff lines, endbegin 0; change_id_1 1–6; end 727–59
2 sidesthe doc-comment shape abovechange_id_1 1–6; change_id_2 7–10; end 1161–104
3 sidesone diff section, then two +++++++ sectionschange_id_1 1–3; change_id_2 4–5; change_id_3 6–7; end 8106–153
Missing sidesbegin then endno conflicts155–163
Git style<<<<<<< HEAD:file.txt / ======= / >>>>>>> <sha>no conflicts165–176
No change IDsbare %%%%%%% and bare +++++++no conflicts178–191
No to: line, bare +++++++%%%%%%% diff from: … then an unlabelled snapshotno conflicts193–206
No diff headerbegin, a content line, labelled +++++++change_id_2 2–3; end 4; line 1 belongs to no side208–237
No to: line, labelled +++++++%%%%%%% diff from: …, a content line, labelled snapshotchange_id_2 3–4; end 5; lines 1–2 belong to no side239–269

getChangeId (parser_test.ts):

HeaderResult
+++++++ change_id commit_id, also with (foo), (foo) (no terminating newline), "commit message", or "commit message" (foo)change_id
\\\\\\\ to: change_id commit_id with the same five tailschange_id
"", side #2undefined
+++++++ change_id, also with "commit message" or "commit message" (foo)undefined: a commit ID is required
\\\\\\\ to: change_id, with the same three tailsundefined

3. Types

FieldTypeMeaningSource
Conflict.beginLinevscode.RangeThe <<<<<<< conflict line.parser.ts
Conflict.sidesConflictSide[]Sides in file order. Never empty for an emitted conflict.parser.ts
Conflict.endLinevscode.RangeThe >>>>>>> conflict … ends line.parser.ts
ConflictSide.changeIdstringThe change ID from the side's header. Carried along but not used by any consumer at this pin.parser.ts
ConflictSide.rangevscode.RangeAll of the side's lines, header lines included.parser.ts
ConflictEvent.editorvscode.TextEditorThe editor that was last processed for this document.tracker.ts
ConflictEvent.conflictsConflict[]The parse result. May be empty.tracker.ts
ConflictEvent.versionnumberTextDocument.version at parse time. Used to skip re-parsing.tracker.ts

4. The tracker

ConflictTracker keeps a HashMap<Uri, ConflictEvent> (source). On construction it processes every visible editor. It then listens for text-document changes (re-processing each visible editor showing that document) and for changes to the set of visible editors (source). Only visible editors are ever parsed. processEditor re-parses only when the document version differs from the cached one; otherwise it just updates the cached editor. Either way it fires onDidChange, so a decorator sees every editor that becomes visible again. getConflict(uri) returns the cached event, and getConflicts() only those with at least one conflict (source). Entries are never removed, so a closed document's last parse stays in the map.

5. Decorations

ConflictDecorator creates five whole-line decoration types, each with a background and an overview-ruler colour in the Full lane (generateBlockRenderOptions):

Applied toBackgroundOverview rulerSource
Begin linemerge.commonContentBackgroundeditorOverviewRuler.commonContentForeground26–33
End linemerge.commonContentBackgroundeditorOverviewRuler.commonContentForeground34–40
Sides 1, 4, 7, …merge.currentContentBackgroundeditorOverviewRuler.currentContentForeground42–47
Sides 2, 5, 8, …merge.incomingContentBackgroundeditorOverviewRuler.incomingContentForeground48–53
Sides 3, 6, 9, …jj.mergeConflict.thirdSideBackgroundthe same colour54–62

The first four colours are VS Code's own theme colours for its built-in Git merge-conflict support, so jj conflicts look like Git ones. VS Code has no third colour, so the extension contributes jj.mergeConflict.thirdSideBackground, defaulting to #ff800033 (light) and #ff983133 (dark) (package.json). More sides cycle through the three (source). Because a side's range includes its header, each header line takes its side's colour. The diff's +/- lines are not coloured separately.

What upstream's test calls the "minimap" (decorator_test.ts) is the overview ruler: the marks in the editor's scrollbar, across its full width. No minimap render option is set. Each onDidChange event resets all five types on that editor with setDecorations, so an empty parse clears them (source). The constructor first decorates the editors the tracker has already parsed, because those events fired before the decorator subscribed (source).

6. CodeLens

ConflictCodeLensProvider registers itself for the file, vscode-vfs, untitled and vscode-userdata schemes (source). Its doc comment presents it as the jj counterpart of VS Code's built-in "Accept Current Change" lenses. For each conflict in tracker.getConflict(document.uri), provideCodeLenses returns an Accept all sides lens on the begin line (command jj.mergeConflict.acceptAllSides, arguments [conflict]), then an Accept this side lens on each side's range (jj.mergeConflict.acceptOneSide, [conflict, i]). It returns null for a document the tracker has never seen (source; tested in codelens_test.ts). The provider reads the tracker's cache and never parses, as its doc comment asks (source).

7. The resolver and the two commands

getResolvedText(text) turns one side's text, header included, into plain content:

  1. Drop the first line (the header). If it started with %%%%%%% and the next line starts with \\\\\\\, drop that too.
  2. If the header was +++++++, return the rest unchanged. A snapshot is already content.
  3. Otherwise, if any non-empty line starts with something other than a space, + or -, return the rest unchanged. The user has edited the diff by hand, and stripping would guess wrong (source).
  4. Otherwise apply the diff: drop - lines and strip the first character of the rest.

getResolvedTextForAllSides resolves each side, drops empty results, and joins the rest with \n. Upstream's cases (resolver_test.ts):

InputOutputSource
Diff side: apple, -grape, +grapefruit, orangeapple / grapefruit / orange26–37
The same lines under a +++++++ headerunchanged, prefixes kept39–50
Diff side whose first line lost its leading spaceunchanged, prefixes kept52–65
All sides: diff apple/-grape + snapshot GRAPEapple\nGRAPE80–92
All sides: same diff + empty snapshotapple94–105
All sides: diff -grape only + snapshot GRAPEGRAPE107–117
All sides: diff -grape only + empty snapshot""119–128

The commands (commands.ts) take the Conflict from the lens arguments and read the text from the active editor. acceptAllSides resolves every side. acceptOneSide resolves side i. Both throw if the arguments are missing or there is no active editor. Each then replaces the whole block, from the start of the begin line to the end of the end line, with the result in one editor.edit. If the result is empty, the range extends to the start of the following line, so the block disappears without leaving a blank line (getRangeToReplace). Nothing calls jj. The file is just edited, and the user saves it.

8. How this maps to jj's conflicts

jj stores a conflicted file as a Merge of terms and writes markers into the working copy only when it materializes it (/jj: materialization). In jj's default diff style, a conflict is one +++++++ snapshot plus a %%%%%%%/\\\\\\\ diff from a base to each remaining side, and that is the layout this parser expects. Applying a diff section yields that side's content, which is why "Accept this side" on a diff section gives the side and not the base. When the file is saved, jj's next snapshot parses it (/jj: parsing markers on snapshot). With no markers left, the path is resolved to the edited content. The extension and jj never talk to each other here. The text on disk is the only interface.

Mismatches that follow from reading the code. None was observed in a running VS Code:

  • Other marker styles. The snapshot style's ------- base sections are not side boundaries, so they become part of the preceding side's range and its "Accept" text. Git-style markers are ignored (tested). The parser's TODO points at jj's alternative marker styles (source).
  • Longer markers. jj lengthens its markers beyond seven characters when the file already contains marker-like lines (/jj). The begin and end tests require exactly seven </> followed by a space, so such conflicts are not found.
  • Unlabelled terms. Without stored conflict labels jj writes side #N. getChangeId rejects those, so no side opens and the conflict is dropped.

9. Loose ends at this pin

  • ConflictSide.changeId is parsed and tested but read by nothing.
  • The decorator's five decoration types and the CodeLens registration aren't added to any disposable list (decorator.ts, codelens.ts), so disposing the bundle doesn't remove them. In practice this only matters on deactivation.
  • The commands edit the active editor's document, not the document the lens came from. They agree whenever the lens was clicked.