# 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 `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:

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-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.
