jj / How jj resolve works

How jj resolve works

jj resolve hands each conflicted file in a commit to a merge tool, usually a 3-way one, and writes the tool's output back as the file's new content. With --list it only lists the conflicts.

On this page
  1. 1. Command-line surface
  2. 2. Configuration
  3. 3. End-to-end flow
  4. 4. Core architecture touchpoints
  5. 5. Transaction and operation
  6. 6. Algorithm and invariants
  7. Finding and checking conflicts
  8. An external tool
  9. The :editor tool
  10. The built-in tools
  11. 7. Errors and edge cases
  12. 8. Worked example

In short: jj resolves -r (default @) and finds the conflicted paths that match the arguments. With none, it fails with No conflicts found. With --list, it prints them and stops. Otherwise it refuses an immutable commit, checks that every chosen conflict is between regular files, and 2-sided unless the tool is :editor, and runs the merge tool on them in order. The first failure stops it. jj rewrites the commit with the files resolved so far, even if a later one failed, then reports that error. Descendants are rebased by tx.finish. The operation is Resolve conflicts in commit <id>.

The command is cmd_resolve. Its help text notes that you can also resolve conflicts by editing the conflict markers in the file directly (source). How conflicts are stored is explained on the conflicts page; this page covers only the command and the merge tools.

1. Command-line surface

FlagTypeDefaultMeaning
FILESETS (positional)filesetsall filesOnly resolve conflicts in these paths (source).
-r, --revision REVSETrevset@The commit whose conflicts to resolve. It must be exactly one commit (source).
-l, --listboolfalseList the conflicts instead of resolving them (source).
--tool NAMEstringui.merge-editorThe merge tool to use. :editor opens the conflict markers in your text editor; :ours and :theirs pick side 1 or side 2. Conflicts with --list (source).

ResolveArgs

2. Configuration

KeyDefaultEffect
ui.merge-editorunset, meaning :builtinThe tool used without --tool. When it's unset jj prints a hint and uses the built-in one. A plain name is looked up as a tool; anything with arguments is run as a command whose arguments are the merge arguments (from_settings, default).
ui.editorplatform defaultThe text editor :editor runs. The file's path is appended unless the command uses $path; $line is the line of the first conflict (source).
merge-tools.<name>.merge-argsemptyArguments for an external tool, with $base, $left, $right, $output, $marker_length and $path substituted. A tool without them can't be used here (field, check, variables).
merge-tools.<name>.merge-tool-edits-conflict-markersfalseIf true, $output starts with the conflict markers and jj re-parses any left in it. If false, it starts empty and is taken as the full resolution (source).
merge-tools.<name>.merge-conflict-exit-codesemptyExit codes meaning the tool left some conflicts in $output, as conflict markers (source).
merge-tools.<name>.conflict-marker-style, ui.conflict-marker-styleunset; diffMarker style for a pre-filled $output. :editor uses ui.conflict-marker-style (source, default).

jj ships settings for several tools, including meld, kdiff3, vimdiff, vscode and mergiraf, so a bare name works for those (merge_tools.toml). The names :builtin, :editor, :ours and :theirs are built in and checked before any configured tool (source, docs).

3. End-to-end flow

jj resolve: call flow cmd_resolve loads the workspace through the shared lifecycle, which snapshots the working copy. It parses the paths, resolves -r (default @), and collects the matching conflicts from the commit's tree, warning about unmatched paths. No conflicts is an error. With --list it prints them and stops. Otherwise it checks the commit is rewritable, loads the merge editor and starts a transaction. MergeEditor::edit_files validates every conflict (at most 2 sides unless the tool is :editor), then runs the built-in tool, picks a side for :ours or :theirs, or runs the external tool or the text editor once per file, stopping at the first failure. The commit is rewritten with the resulting tree and tx.finish records "Resolve conflicts in commit", rebasing descendants. Finally it warns about remaining conflicts if the commit isn't the working copy, and returns any partial-resolution error. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) resolve -r; tree.conflicts_matching(paths) warn on unmatched paths; none → "No conflicts found …" --list print_conflicted_paths, stop check_rewritable(commit); merge_editor(--tool or ui.merge-editor); start_transaction validate every conflict: regular files, executable bit resolvable; at most 2 sides unless :editor :builtin terminal UI over all files; keep hunks from either side :ours / :theirs take side 1 or side 2 of every file external tool or :editor, file by file "Resolving conflicts in: …"; stop at the first failure rewrite_commit(commit).set_tree(new tree); tx.finish(ui, "Resolve conflicts in commit <id>") warn about remaining conflicts (if not @); then return the partial-resolution error, if any

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Target commitrewrittenA new commit whose tree has the resolved files. It keeps its parents, change ID, description and author.commits
Conflicted tree valuesread, replacedEach conflict is a Merge of file IDs. A resolution replaces it with one file, or with new file IDs if conflicts remain.trees
File objectsread, writtenThe base and both sides are read; the tool's output is written as a new file.trees
DescendantsrewrittenRebased onto the new commit by tx.finish.transactions
View: heads, bookmarks, wc_commit_idswriteFollow the rewritten commits.view
OperationwriteOne operation, even when a later file failed.operations
Working copyread, then maybe writeSnapshotted first. If @ was rewritten, the resolved files are written to disk.working-copy

5. Transaction and operation

  • Transaction: one, after the conflict list and before the tool runs. --list, and errors before any file is resolved, record nothing (source).
  • Operation description: Resolve conflicts in commit <full hex id before the rewrite>. It is capitalized, unlike most of jj's (source).
  • Partial resolution: if an external tool or :editor fails on the second file or later, the files already resolved are still committed and the operation is recorded. Only then does jj resolve exit with Stopped due to error after resolving <n> conflicts (source, return).
  • Descendants: the command doesn't rebase them itself; tx.finish does (source).
  • jj undo restores the previous view, bringing back the conflicted commit.

6. Algorithm and invariants

Finding and checking conflicts

tree.conflicts_matching(&matcher) collects the conflicted paths under the arguments (source). Before any tool runs, MergeToolFile::from_tree_and_path checks every one of them, and any failure stops the whole command:

  1. The value must actually be a conflict, and present.
  2. Every term must be a regular file or absent. Symlinks, directories and submodules can't be resolved here.
  3. Unless the tool is :editor, the conflict must have at most 2 sides after simplifying. Simplifying removes terms that cancel out, so a conflict that only looks larger can still qualify (source, check_two_sided, try_materialize_file_conflict_value).
  4. The executable bit must resolve on its own. If it conflicts, the hint points to jj file chmod.

An external tool

run_mergetool_external hands resolve_files_one_by_one a closure. That loop prints Resolving conflicts in: <path> and calls run_mergetool_external_single_file for each file in turn:

  1. It writes base_<name>, left_<name>, right_<name> and output_<name> into a jj-resolve- temp directory. Left is side 1, right is side 2. All but output are read-only (source).
  2. output starts empty, or with conflict markers if the tool edits markers. The marker length is the minimum unless the tool sees markers or asks for $marker_length; then it is long enough not to clash with the file's content (source).
  3. It runs the tool. A non-zero exit is a failure unless the code is listed in merge-conflict-exit-codes (source).
  4. An empty or unchanged output is a failure. That's how you stop: quit the tool without saving (source).
  5. If the tool edits markers, or exited with a conflict code, jj parses output for markers and may keep a smaller conflict. Otherwise output becomes the file. A conflict exit code with no markers is reported as invalid (source).
  6. A resolved file keeps the resolved executable bit. A still-conflicted one keeps its executable bits and gets new file IDs (new_tree_value).

The :editor tool

:editor runs ui.editor on the conflict markers, one file at a time through the same resolve_files_one_by_one loop (source). It is the only tool that accepts conflicts with more than 2 sides. edit_conflict_markers_in_text_editor does this for each file:

  1. It picks a marker length that can't clash with the file's content, and materializes the conflict with markers in ui.conflict-marker-style (source).
  2. It writes that into a jj-resolve- temp directory, under the conflicted file's own name so the editor can pick the right syntax highlighting (source).
  3. It opens the editor at the line of the first conflict start marker (source).
  4. An empty or unchanged file is a failure, as with an external tool (source).
  5. Otherwise jj parses the file against the conflict's unsimplified terms. Markers left in it are kept as a conflict, possibly a smaller one. The tree value is built the same way as for an external tool (source).

The built-in tools

:builtin opens one terminal UI over all the conflicted files, where you choose which side's changes to keep. :ours and :theirs need no UI: pick_conflict_side sets every file to side 1 or side 2 of its simplified conflict. A side where the file is absent deletes it (edit_files).

Invariants: only conflicted paths are touched, and only the target commit's tree changes. Files are handled in the order the tree lists them, and a failure on the first one records nothing. 3-sided and larger conflicts are only sent to :editor; with any other tool, use :editor or edit the markers by hand.

7. Errors and edge cases

MessageWhenSource
No conflicts found at this revision / No conflicts found at the given path(s)Nothing to resolve. A CLI error, exit status 2.resolve.rs
Failed to resolve conflicts, caused by The conflict at "…" has <n> sides. At most 2 sides are supported., hint Edit the conflict markers to resolve this, e.g. with `jj resolve --tool :editor`.A chosen conflict has more than 2 sides, and the tool isn't :editor.mod.rs, command_error.rs
Only conflicts that involve normal files (not symlinks, etc.) are supported. …A term is a symlink, directory or submodule.mod.rs
"…" has conflicts in executable bit, hint Use `jj file chmod` …The sides disagree on the executable bit.mod.rs
The output file is either unchanged or empty after the editor quit …The tool or :editor was closed without a resolution.mod.rs
Tool exited with <status> …; … but did not produce valid conflict markers …The tool failed, or used a conflict exit code without leaving markers.external.rs
Failed to run editor '…'; Editor '…' exited with <status>:editor couldn't start ui.editor, or it failed.description_util.rs
Stopped due to error after resolving <n> conflictsA later file failed. The earlier resolutions are kept.mod.rs
The tool `<name>` cannot be used as a merge tool with `jj resolve`, with a hint to define merge-argsThe tool has no merge arguments.command_error.rs
Warning: After this operation, some files at this revision still have conflicts:Conflicts remain in a commit other than @. For @, tx.finish reports them instead.resolve.rs
Commit … is immutableThe commit is immutable. --list works on any commit.cli_util.rs
clap: --tool cannot be used with --listBoth given.resolve.rs

8. Worked example

From upstream's test_resolution. base has file = base; a and b change it to a and b; conflict merges them. jj resolve --list prints file 2-sided conflict. With a merge tool that writes resolution into $output:

jj resolve on a 2-sided conflict The conflict commit merges a and b, both children of base, so file is a 2-sided conflict with base as the removed term and a and b as the two sides. jj writes base_file, left_file (a), right_file (b) and an empty output_file into a temp directory and runs the tool. The tool writes resolution into output_file. jj rewrites the conflict commit with file set to resolution, keeping its change ID, and the conflict is gone. base file = base a file = a b file = b conflict @ file: 2-sided conflict jj-resolve-…/ base_filebase (read-only) left_filea (read-only) right_fileb (read-only) output_fileempty tool writes "resolution" conflict @ file = resolution same change ID, new commit

Upstream's snapshot (source):

Resolving conflicts in: file
Working copy  (@) now at: vruxwmqv 741263c9 conflict | conflict
Parent commit (@-)      : zsuskuln 45537d53 a | a
Parent commit (@-)      : royxmykx 89d1b299 b | b
Added 0 files, modified 1 files, removed 0 files

The tool saw an empty output file, as the test checks by dumping it. jj diff then shows the conflict markers replaced by resolution, and jj resolve --list fails with No conflicts found at this revision.

Upstream's test_multiple_conflicts_with_error shows a partial run. The tool resolves file1, then quits on file2 without writing. jj still rewrites the commit, so file1 is resolved, warns that file2 is still conflicted, and then exits with:

Error: Stopped due to error after resolving 1 conflicts
Caused by: The output file is either unchanged or empty after the editor quit (run with --debug to see the exact invocation).

Upstream's test_resolve_many_sided_with_text_editor merges three children of base, so file is a 3-sided conflict in a commit that isn't @. jj resolve -r conflict --tool :editor opens the markers. If the editor only changes c to c2, the commit keeps a 3-sided conflict with the new content and jj warns that the file still has conflicts. If it writes resolution, the conflict is gone.