jj change-id on the Git backend — verified format
Status: RESOLVED. This closes spec §13 open question 1, the hard prerequisite for M1.
Method: empirical, against real repositories produced by the jj CLI
(jj 0.43.0-89f62ede8c1c611eaf134c0c49252efd65c7945d, x86_64-unknown-linux-musl).
No commit objects were hand-authored. Every claim below is a direct git cat-file commit
observation, and the reproduction steps live in fixtures/gen.sh.
1. The header
change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu
- Key:
change-id— lowercase, hyphenated. NotChange-Id, notjj:change-id. - Value: 32 characters, stored in jj's reverse-hex letter encoding — the exact string the CLI displays. It is not stored as hex and rendered as letters.
This is the single most important finding, and it is the opposite of what the spec anticipated ("the CLI's letter form is a presentation encoding of the stored bytes"). On this backend the stored bytes are the letter form.
Consequence: the canonical encoding for changes.change_id is the letter string
exactly as it arrives on the wire. Do not hex-decode on ingest and re-encode for display —
that round-trip is pure risk with no benefit, and any mismatch means users cannot
cross-reference the UI against their terminal.
The alphabet is the reverse-hex mapping 0123456789abcdef → zyxwvutsrqponmlk.
Corroborated by the root change, which jj displays as zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz
against commit id 0000000000000000000000000000000000000000.
2. Header position is NOT fixed — parse properly
Two observed layouts place change-id in different positions:
Signed commit — change-id before gpgsig:
tree ec67420ed747b72ce94854190b4c59deff01b9db
author Fixture Author <fixture@dogfood.sh> 1785482470 +0000
committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000
change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn
gpgsig -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl
...
-----END SSH SIGNATURE-----
signed via jj
Conflicted commit — change-id after two other jj: headers, one of which is multi-line:
tree 125bc2eed773e5eacbb11544e9966dab850ed4f9
parent 8fa0e2bff61fc63eb09ade2565647e3bda6e8cbf
parent e2263f3f9f90bbfac26edb8e43b348bc9ab32e99
author Fixture Author <fixture@dogfood.sh> 1785482444 +0000
committer Fixture Author <fixture@dogfood.sh> 1785482444 +0000
jj:conflict-labels vtrtrnpx 8fa0e2bf "sideA"
wokvnsmz ebc596ab "base"
lvtnxnzk e2263f3f "sideB"
jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21
change-id otuuklrtzzlqvnwuxvsvttrrsompzpzx
conflicted merge
extract_change_id must therefore be a real RFC-822-style header parser:
- Read lines until the first empty line (that terminates the header block; everything after is the message).
- A line beginning with a single space is a continuation of the previous header's value.
Both
gpgsigandjj:conflict-labelsuse this. Splitting naively on\nand matching^change-idhappens to work today, but a continuation line inside a signature whose base64 payload begins withchange-idwould produce a bogus id — cheap to prevent, so prevent it. - Match the header key
change-idexactly, case-sensitively. - Validate the value: exactly 32 bytes, all within
[k-z]. Reject anything else rather than storing a malformed id.
Do not assume the header block ends at a fixed line number, and do not assume change-id
precedes or follows any particular other header.
3. Stability across rewrite — confirmed
The property the entire product rests on:
| description | commit id | change id |
|---|---|---|
first change |
857ec6861ea3cd5dfe57f0751c0435c8165c1460 |
qstvwxmpkvovsosxxmrnxpqmuuyllsqu |
first change AMENDED |
9d7c2334d79969ff8e788af6407e04e1d4fd1a35 |
qstvwxmpkvovsosxxmrnxpqmuuyllsqu |
The commit id changed, the change id did not. Verified to survive jj git push into a bare
remote — the header is present byte-identical when read back with
git --git-dir=remote.git cat-file commit main, which is exactly the path the indexer reads.
4. Plain-git commits carry no header — confirmed
tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd
author Plain Git <pg@dogfood.sh> 1785482410 +0000
committer Plain Git <pg@dogfood.sh> 1785482410 +0000
plain git commit
extract_change_id returns None; these take the synthetic-identity path (spec §4) and are
flagged changes.synthetic = true.
5. Merge commits carry the header normally — confirmed
A two-parent jj commit carries change-id like any other. No special handling needed beyond
recording both parents.
6. Conflict representation — for the M4 indexer
A conflicted revision is detectable two ways, and they agree:
- Header:
jj:treeslists the constituent tree OIDs, in alternatingside_0 base_0 side_1 [base_1 side_2 …]order — alwayssides == bases + 1.jj:conflict-labelscarries human-readable labels for each, in the same order. - Tree: the commit's root tree contains
.jjconflict-side-Nand.jjconflict-base-Nsubtrees, plus aJJ-CONFLICT-READMEblob explaining the layout to plain-Git users.
Observed for a 2-sided conflict:
header jj:trees position |
tree entry | label |
|---|---|---|
| 0 | .jjconflict-side-0 → ada380c7… |
vtrtrnpx 8fa0e2bf "sideA" |
| 1 | .jjconflict-base-0 → 892cad2b… |
wokvnsmz ebc596ab "base" |
| 2 | .jjconflict-side-1 → 14a356d6… |
lvtnxnzk e2263f3f "sideB" |
Note the root tree also contains the materialized path (c.txt) holding one side's
content, alongside the .jjconflict-* subtrees. A tree listing must filter the
.jjconflict-* entries and JJ-CONFLICT-README out of the user-facing file list, or every
conflicted change will show phantom directories in the browser.
Prefer the jj:trees header for detection — it is unambiguous and does not require loading
the tree. Store the parsed sides/bases into revisions.conflict_data at index time so the
conflict viewer never re-walks objects (spec §4).
7. Reference: jj's own documentation
jj documents this mapping at
https://docs.jj-vcs.dev/latest/git-compatibility/#format-mapping-details, cited by the
JJ-CONFLICT-README blob jj itself writes into conflicted trees.
8. Version sensitivity
All of the above is observed on jj 0.43.0. The jj: header namespace and the letter-encoded
change-id are backend format, not CLI presentation, so they are stable in practice — but
the fixture corpus is generated by a pinned jj version in CI precisely so a format change
fails a test rather than silently corrupting the index.