Jump to…
snowinitial commitqoxwzsukwmkx1mo

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. Not Change-Id, not jj: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 0123456789abcdefzyxwvutsrqponmlk. 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:

  1. Read lines until the first empty line (that terminates the header block; everything after is the message).
  2. A line beginning with a single space is a continuation of the previous header's value. Both gpgsig and jj:conflict-labels use this. Splitting naively on \n and matching ^change-id happens to work today, but a continuation line inside a signature whose base64 payload begins with change-id would produce a bogus id — cheap to prevent, so prevent it.
  3. Match the header key change-id exactly, case-sensitively.
  4. 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:trees lists the constituent tree OIDs, in alternating side_0 base_0 side_1 [base_1 side_2 …] order — always sides == bases + 1. jj:conflict-labels carries human-readable labels for each, in the same order.
  • Tree: the commit's root tree contains .jjconflict-side-N and .jjconflict-base-N subtrees, plus a JJ-CONFLICT-README blob explaining the layout to plain-Git users.

Observed for a 2-sided conflict:

header jj:trees position tree entry label
0 .jjconflict-side-0ada380c7… vtrtrnpx 8fa0e2bf "sideA"
1 .jjconflict-base-0892cad2b… wokvnsmz ebc596ab "base"
2 .jjconflict-side-114a356d6… 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.