1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
---
name: resolve-conflicts
description: Use when resolving jj conflicts after rebase, squash, or merge — handles conflict markers, mergiraf automation, and file-by-file manual resolution, in place with `jj resolve -r` or in an isolated workspace
argument-hint: [change-id]
allowed-tools: [Bash(mkdir -p work), Bash(jj workspace add --name=resolve-* -r * work/resolve-*), Bash(cd work/resolve-*), Bash(jj status), Bash(jj resolve *), Bash(jj diff *), Bash(jj log *), Bash(jj op log *), Bash(jj op show *), Bash(jj squash *), Bash(jj abandon *), Bash(jj bookmark list), Bash(jj workspace remove resolve-*), Bash(jj workspace update-stale)]
---
# Resolve Conflicts
`$ARGUMENTS`
This skill resolves conflicts without disturbing the main workspace's `@` —
in place with `jj resolve -r` where that suffices (step 1a), otherwise in an
isolated workspace (step 1b). Invoke the `jj-workspaces`
skill for the general mechanics referenced below (workspace naming,
`work/` layout, sync behavior); the steps here are the conflict-specific
application of it, using the exact commands `allowed-tools` permits.
## Step 0 — Check where the conflict came from
**"New conflicts appeared in 1 commits" doesn't mean the command you just
ran caused them.** A conflict already sitting in the working copy — usually
from a rebase someone ran in another terminal — gets carried into the new
working copy commit and reported as new. With a fileset, `jj commit` keeps
the named commit clean and leaves the conflict behind in `@`. Check
provenance before resolving anything:
```
jj op log --limit 5 # find the rebase that introduced it
jj op show <op-id> # commits it marked (conflict)
jj log -r @ -T 'if(conflict, "CONFLICT", "clean")' # which commits are conflicted now
```
**A conflict can mean the commit is dead, not that there is a merge to
make.** After a `jj git fetch` plus a rebase onto the new trunk, a commit
whose work already landed as a squash merge comes back as a live conflicted
copy — the tell is two sides differing by a line or two with one of them
matching trunk exactly. The usual `jj abandon 'trunk():: & empty() & mine()'`
sweep misses it whenever a later commit in the same pull request touched the
same line, because a cancelling pair leaves neither commit empty relative to
its own parent. So check the tree, not the diff, before resolving anything:
```
jj diff --from 'trunk()' --to <rev> --stat # `0 files changed` = trunk already has this tree
```
Run it over every mutable head rather than only the conflicted one, since one
rebase produced all the siblings. When it comes back empty the resolution is
`jj abandon`, and resolving the text instead is worse than slow — taking
either side leaves the pair reverting each other, which `empty()` still will
not sweep. Confirm `jj bookmark list` first: abandoning drops a bookmark
pointing at the commit, and a later bare `jj git push` then tries to delete
that branch on the remote.
**Conflicted `(divergent)` commits may be a stale copy of a branch another
machine already fixed.** When the other machine rebases and pushes a branch,
`jj git fetch` brings in its clean commits. The local auto-rebase then
rewrites the old local copies too, so every change exists twice: `/0` is the
local copy and conflicted, `/1` came from the remote and is clean. The
bookmark goes conflicted as well. Diff each pair. If the only difference is
conflict markers, there is nothing to merge:
```
jj diff --from <change>/1 --to <change>/0 --stat # markers only = stale copy
jj bookmark set <bookmark> -r <bookmark>@origin # take the remote tip
jj rebase -s mm -o <other-parent> -o <bookmark> # repoint the megamerge
jj abandon <commit ids of the /0 copies> # commit IDs: change IDs are ambiguous
```
Collect the `/0` commit IDs up front with `jj log --no-graph -r
'trunk()..<tip>/0' -T 'commit_id.short() ++ "\n"'`.
## Step 1 — Pick the revision and the mechanism
If `$ARGUMENTS` contains a change ID, use it as the conflicted revision.
Otherwise run `jj status` in the main workspace and use `@-` if it has
conflicts; stop and ask the user for a change ID if it doesn't.
**Anchor on the oldest conflicted commit, not `@-`.** A conflict inherited
through ancestry shows up in every descendant, so `@-` is usually a symptom
rather than the source. `jj status`'s hint points at a root ("start by
creating a commit on top of the first conflicted commit"), but it
**under-reports when there is more than one** — it can name two while a third
commit holds conflicts no ancestor produced. Treat it as a starting point, not
the set. The discriminating check is to run `jj resolve --list -r <rev>` over
every revision `jj log -r 'conflicts()'` lists, and compare: a commit whose
paths are absent from its parent is its own root. Miss one and the resolution
looks like it is working — descendants clear, the count drops — and then the
megamerge keeps reporting conflicts you cannot trace. Fix the root and jj rebases the rest —
step 4's squash then reports `Existing conflicts were resolved or abandoned
from N commits`. Resolving at `@-` instead leaves every ancestor conflicted.
**When the conflict is in `@` itself, skip to step 3 and resolve in
place.** A rebase run while the working copy holds uncommitted work leaves
the conflict in `@`, not in a commit. `jj workspace add -r @` can't check
out a revision that's already checked out, and the isolation buys nothing
because there is no other `@` to protect. Edit the files directly and drop
the `-r @-` from every `jj resolve` in the steps below.
**Try `jj resolve -r <rev>` before adding a workspace.** It rewrites the
named revision and rebases descendants in place, so `@` never moves — which
is what a workspace was buying you. That makes it the right default when a
megamerge is checked out and `@` must stay put. A workspace only earns its
cost when resolution needs a real checkout to work in: running the project's
tooling, or hand-editing several files against a build.
Always pass `--tool` explicitly. A bare `jj resolve` launches the
interactive merge editor, which hangs a non-interactive Bash call.
## Step 1a — Resolve in place with `jj resolve -r`
`--tool :ours` and `--tool :theirs` take side #1 and side #2 whole, and they
handle a `2-sided conflict including 1 deletion` that mergiraf can't touch —
one command per file, no checkout:
```
jj resolve -r <rev> --tool :theirs <path> # side #2 wins; removes the file if that side deleted it
```
Read the sides off the materialized markers first (`jj file show -r <rev>
<path>`) to learn which number is which: the `+++++++ <change-id>` labelled
`(rebased revision)` is your own commit's side.
**"Whole" is the trap when the file survives on both sides.** `:ours` and
`:theirs` replace the file with that side's entire content, so on a path where
only one hunk conflicted, a side predating a `jj git fetch` silently reverts
every other edit trunk made to it. Reserve them for a file whose intended result
is "absent", and use the `cp` route below for one that survives. The marker names
side #2's commit, so you can read exactly what `:theirs` would have written —
still reachable by commit id after the resolution, since the commit is hidden
rather than gone:
```
jj file show -r <side-2-commit-id> <path> | diff - <path>
```
Worth running once on any shared file. What it caught here was a repo-wide
`* @owner` backstop line that side #2 still held at its pre-handover value —
nothing in the conflict region, and it would have re-owned every path in the
repo. Either way, verify the resolution stayed scoped by diffing the fixed
commit against its own parent:
```
jj diff --from <parent-of-rev> --to <rev> --stat
```
Only what the commit means to change should appear. Unrelated churn in a shared
file means a whole-file take ate someone else's edits — markers being gone does
not catch it.
**No merge tool takes a type conflict**, `:ours` and `:theirs` included — a
side that adds a symlink fails with "Only conflicts that involve normal files
(not symlinks, etc.) are supported." Recreate the winning side in the working
copy per step 3, then land it in the conflicted ancestor with `jj squash --into
<rev> <path>`; the fileset keeps unrelated working-copy edits out, and jj
rebases the descendants and reports `Existing conflicts were resolved or
abandoned from N commits`.
That squash only works when `@`'s parent carries the same conflict as the
target. When `@-` holds a different one at that path — `@` sits below a
megamerge or other descendants with their own sides — squashing applies
`@-`→`@` onto the target and leaves it `3-sided`. `jj undo`, then open a step 1b
workspace on the target, recreate the side there, and run a bare `jj squash`.
For a file needing real merge judgment, resolve it as a temp file and hand
that to `cp` as a merge tool — the same in-place rewrite, but with content
you wrote:
```
jj file show -r <rev> <path> > /tmp/fix # materialized conflict markers
# edit /tmp/fix — remove markers, write the merged content
jj resolve -r <rev> --tool cpfix \
--config 'merge-tools.cpfix.program=cp' \
--config 'merge-tools.cpfix.merge-args=["/tmp/fix", "$output"]' \
<path>
```
Work root-first: each resolution clears that path from every descendant, and
jj reports `Existing conflicts were resolved or abandoned from N commits`.
When no conflicts remain, skip steps 2–4 entirely — there is nothing to
squash, because the revisions were rewritten directly.
**Resolving the root can push a fresh conflict into a downstream
megamerge.** A merge commit whose tree recorded the old resolution goes
`(conflict)` once its parents change, so a clean run at the root can be
followed by `New conflicts appeared in N commits` naming the megamerge. That
conflict is `3-sided` when the merge has three parents, and **no merge tool
can take it** — `:ours`, `:theirs`, and the `cp` trick all fail with "has 3
sides. At most 2 sides are supported." Resolve it by writing the file in the
working copy (the conflict materializes there through ancestry) and then
`jj squash --into <megamerge>`; that clears the megamerge and every
descendant without moving `@` off it.
Run a bare `jj restore` first. After a rebase, `@` can hold diffs that only
relabel the markers, such as a dropped `(parents of rebased revision)`, and
`jj st` reports those files as modified. When `@` holds uncommitted work, a
bare restore discards it too — restore only the conflicted paths instead.
When taking one side of a 3-sided materialization, don't splice the
`+++++++` block in as-is — its region can overlap content that also appears
after `>>>>>>>`, so a literal copy duplicates lines. Reconstruct the intended
file from `jj file show -r <parent> <path>` for each parent instead.
`cp -p` rather than plain `cp` as the merge-tool program preserves the
executable bit, which matters for any conflict jj labels `including an
executable`.
**Check generated files afterward.** A generated artifact that auto-merged
cleanly is the classic stale-output trap: the text merged, but the generator
would emit something else. Re-run the generator and squash any diff into the
commit that owns it — and run it from the main workspace, never from
`work/`, where repo tooling reading `git ls-files` goes green-but-wrong.
**A file neither side conflicted on can still be broken.** When one branch
deletes a data source and another adds code that reads it, the two changes
never touch the same lines, so `jj resolve --list` stays silent while the
reader is left addressing fields that no longer exist. Nothing in the conflict
machinery catches this; a test that loads the deleted file is what surfaces it,
as an unrelated-looking `ENOENT` at import time. Resolving it means writing an
adapter, not merging text — so find the authority on the surviving shape rather
than inventing one. Comments saying a function "mirrors" some module name that
module as the authority; read it. Land the fix with `jj squash --into <rev>
<path>` on the commit that caused the break, which is the one that deleted the
source, not the one that added the reader.
**Taking the deletion side loses whatever trunk added to the dying file.**
When a branch retires a registry, config, or any single source of truth and
trunk kept editing it, `:theirs` is the correct resolution and still drops every
entry trunk added in the meantime. No marker mentions it. Before taking the
deletion, diff the dying file's names against the union of names from every
surviving authority:
```
jj file show -r 'trunk()' <dying-file> | jq -r '.<entries>[].name' | sort > /tmp/was
# ...and the replacement authority's names, however it is discovered, into /tmp/now
comm -23 /tmp/was /tmp/now # present only in the dying file
```
Everything that prints is either a deliberate retirement or a silent drop, and
only the branch's intent tells them apart. The ones that bite are entries with
no home in the replacement — a plugin sourced from a whole git repo has no
directory for a directory-discovery scheme to find, so it survives only if
something re-declares it. Land the re-declaration with `jj squash --into <rev>`
on the commit that deleted the file, then re-run any generator that read it.
Also diff field-by-field for entries present on both sides with changed values;
the name set will not show those.
**Compare against the authority, never against a generated artifact that
filters.** The name-set check only means anything when both sides are the full
set. A generated manifest is often a *subset* by design — one per target
harness, each carrying only the entries whose `tools`-style field names that
harness — so diffing the authority against one of them prints every entry the
filter legitimately excluded. Sixteen entries read as silently dropped when
nothing was. Compute the expected set with the same predicate the generator
uses and compare against that instead:
```
jq -r --arg h claude '.plugins[] | select((.tools // ["claude","cursor"]) | index($h)) | .name' <authority> | sort > /tmp/exp
```
The default for a missing field is part of the predicate and branches change it,
so read it off the generator rather than assuming. Run the comparison for every
artifact the generator writes, not just the one you happen to care about — and
when the tool ships a `validate` subcommand, it already encodes the predicate
correctly, so reach for that first and use the hand-rolled diff to find out
*which* entries moved.
**Check the files that *didn't* conflict.** When a rebase turns a linear stack
into siblings under a merge — `jj rebase --insert-after 'trunk()'` on part of
the stack — content that used to arrive from an ancestor now arrives through
the merge, and jj unions both sides. Tables, lists, and JSON objects merge
textually clean and semantically wrong: two keys added at different offsets
both survive, so a rename recorded on one side leaves the old entry alive on
the other. `jj resolve --list` will not mention the file. Diff the merge
against each parent for every path both siblings touched, and validate
structured files (`jq .`, the language's parser) rather than eyeballing the
diff. Fixes here belong in the merge commit — the sibling's base never had the
line to remove — so hand-edit in the working copy and `jj squash --into <merge>`.
## Step 1b — Set up an isolated resolution workspace
Per `jj-workspaces`' "Create" step, but anchored on the
conflicted revision instead of trunk (skip adding it to `mm` —
this workspace's working-copy commit belongs directly on `<change-id>`,
not on trunk):
```
mkdir -p work
jj workspace add --name=resolve-<change-id> -r <change-id> work/resolve-<change-id>
cd work/resolve-<change-id>
```
`-r <change-id>` makes the new workspace's working-copy commit a child of
`<change-id>` — the same relationship `jj new <change-id>` would have
created in the main workspace — but isolated in its own checkout. Run all
remaining steps from inside `work/resolve-<change-id>`.
## Step 2 — Run mergiraf
Attempt automatic resolution with mergiraf, targeting the conflicted parent:
```
jj resolve --tool mergiraf -r @-
```
The `-r @-` flag targets the conflicted parent rather than the working copy.
After it runs, check what's left:
```
jj resolve --list -r @-
```
If the list is empty, all conflicts are resolved — skip to step 4.
## Step 3 — Manual resolution loop
**A type conflict has no markers to edit.** When one side changes a path's
type — a tracked file replaced by a symlink, say — `jj resolve --list` labels
it `2-sided conflict including a symlink`, mergiraf has nothing textual to
merge, and the materialized file is a description rather than content:
```
Conflict:
Removing file with id 401ff494… (<base> "…" (parents of rebased revision))
Adding file with id 0f6b69c3… (<side> "…" (rebase destination))
Adding symlink with id c4563938… (<side> "…" (rebased revision))
```
`jj file show` refuses these with "Path exists but is not a file." Read the
intent from `jj diff --git -r <side> <path>` instead, where the mode change
(`100644` → `120000`) and the symlink target both appear, then recreate the
winning side by hand — `rm <path> && ln -s <target> <path>`. The Edit tool
cannot write a symlink.
On the conflicted revision that diff prints the `Conflict:` description rather
than a mode change, so the symlink target isn't in it. Get the target from the
pre-rebase commit — `jj evolog -r <rev>` names it, and `jj diff --git -r
<old-commit-id> <path>` on that hidden commit shows the real change. For
scripting, evolog's template keyword is `commit`, not the log's `commit_id`:
`jj evolog -r <rev> --no-graph -T 'commit.commit_id().short() ++ "\n"'`.
For each text file still listed by `jj resolve --list -r @-`:
1. Read the file to see its conflict markers. jj's format differs from
git's, and jj has two marker styles. The default (`diff`) shows one
side as a diff against the base:
```
<<<<<<< conflict 1 of 1
%%%%%%% diff from: <base commit>
\\\\\\\ to: <side #1 commit>
-base line
+side #1 line
+++++++ <side #2 commit>
side #2 content
>>>>>>> conflict 1 of 1 ends
```
The `%%%%%%%` block is a diff (`-` base, `+` side) turning the base
into one side; the `+++++++` block is the other side's full content.
Apply the diff mentally or take one side, then delete every marker line
(`<<<`, `%%%`, `\\\`, `+++`, `>>>`).
The `snapshot` style (set via `ui.conflict-marker-style`) instead shows
every side and the base in full:
```
<<<<<<< conflict 1 of 1
+++++++ <side #1 commit>
side #1 content
------- <base commit>
base content
+++++++ <side #2 commit>
side #2 content
>>>>>>> conflict 1 of 1 ends
```
Here the `+++++++` sections are the sides and `-------` is the common
ancestor. Either way, remove all marker lines and keep the correct
merged content. Each label carries the commit's change ID and
description, which orients which side is which.
jj also emits a **diff-style** form where only one side is literal
content and the other is a diff from the base:
```
<<<<<<< conflict 1 of 1
+++++++ yuwoklmp 1056139b "mm" (rebase destination)
(full content of this side)
%%%%%%% diff from: yuwoklmp 80b2d213 "mm" (parents of rebased revision)
\\\\\\\ to: omkzmwrk cc06fc10 (rebased revision)
-(lines the other side removed from the base)
+(lines the other side added)
>>>>>>> conflict 1 of 1 ends
```
The `%%%%%%%` section is **not** file content — deleting its markers
would splice `-`/`+` prefixes into the file. Reconstruct each side with
`jj file show -r <rev> <path>` (the revisions are named in the marker
lines) and compare those instead of editing the hunks in place.
2. Run `jj diff -r @-` for context on what each side was trying to
accomplish.
3. Use the Edit tool to resolve the conflict — remove the markers and
write the correct merged content.
4. Move to the next conflicted file.
After editing all files, run `jj resolve --list -r @-` to confirm no
conflicts remain in the parent.
> The Read and Edit tools handle file-level resolution directly. They are not
> restricted by `allowed-tools`, which only governs Bash commands.
## Step 4 — Squash the resolution and clean up
Do not run `jj squash` automatically. Prompt the user:
> All conflicts are resolved. Run `jj squash` (from
> `work/resolve-<change-id>`) to fold the resolution back into
> `<change-id>`, or review the diff first with `jj diff`.
Once the user confirms the squash, `<change-id>` is fixed up and the
workspace's working-copy commit is left empty. From the main workspace —
`remove` deletes `work/resolve-<change-id>`, so don't run it from inside —
clean up per `jj-workspaces`' cleanup recipe, then sync so the main
workspace sees the resolved change:
```
jj workspace remove resolve-<change-id>
jj workspace update-stale
```
---
> **Self-improving note:** This is a self-improving skill. If you used it and
> it came up short — a new conflict marker format, an edge case with mergiraf,
> a better resolution pattern — invoke the `self-improving-skills` skill and
> follow it before you finish.