Jump to…
snowinitial commitqoxwzsukwmkx1mo
1# jj change-id on the Git backend — verified format
2
3**Status:** RESOLVED. This closes spec §13 open question 1, the hard prerequisite for M1.
4
5**Method:** empirical, against real repositories produced by the `jj` CLI
6(`jj 0.43.0-89f62ede8c1c611eaf134c0c49252efd65c7945d`, x86_64-unknown-linux-musl).
7No commit objects were hand-authored. Every claim below is a direct `git cat-file commit`
8observation, and the reproduction steps live in `fixtures/gen.sh`.
9
10---
11
12## 1. The header
13
14```
15change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu
16```
17
18- **Key:** `change-id` — lowercase, hyphenated. Not `Change-Id`, not `jj:change-id`.
19- **Value:** 32 characters, stored **in jj's reverse-hex letter encoding** — the exact
20 string the CLI displays. It is *not* stored as hex and rendered as letters.
21
22This is the single most important finding, and it is the opposite of what the spec
23anticipated ("the CLI's letter form is a presentation encoding of the stored bytes").
24On this backend the stored bytes **are** the letter form.
25
26**Consequence:** the canonical encoding for `changes.change_id` is the letter string
27exactly as it arrives on the wire. Do not hex-decode on ingest and re-encode for display —
28that round-trip is pure risk with no benefit, and any mismatch means users cannot
29cross-reference the UI against their terminal.
30
31The alphabet is the reverse-hex mapping `0123456789abcdef` → `zyxwvutsrqponmlk`.
32Corroborated by the root change, which jj displays as `zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz`
33against commit id `0000000000000000000000000000000000000000`.
34
35## 2. Header position is NOT fixed — parse properly
36
37Two observed layouts place `change-id` in different positions:
38
39Signed commit — `change-id` *before* `gpgsig`:
40```
41tree ec67420ed747b72ce94854190b4c59deff01b9db
42author Fixture Author <fixture@dogfood.sh> 1785482470 +0000
43committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000
44change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn
45gpgsig -----BEGIN SSH SIGNATURE-----
46 U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl
47 ...
48 -----END SSH SIGNATURE-----
49
50signed via jj
51```
52
53Conflicted commit — `change-id` *after* two other `jj:` headers, one of which is multi-line:
54```
55tree 125bc2eed773e5eacbb11544e9966dab850ed4f9
56parent 8fa0e2bff61fc63eb09ade2565647e3bda6e8cbf
57parent e2263f3f9f90bbfac26edb8e43b348bc9ab32e99
58author Fixture Author <fixture@dogfood.sh> 1785482444 +0000
59committer Fixture Author <fixture@dogfood.sh> 1785482444 +0000
60jj:conflict-labels vtrtrnpx 8fa0e2bf "sideA"
61 wokvnsmz ebc596ab "base"
62 lvtnxnzk e2263f3f "sideB"
63jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21
64change-id otuuklrtzzlqvnwuxvsvttrrsompzpzx
65
66conflicted merge
67```
68
69`extract_change_id` must therefore be a real RFC-822-style header parser:
70
711. Read lines until the **first empty line** (that terminates the header block; everything
72 after is the message).
732. A line beginning with a **single space** is a continuation of the previous header's value.
74 Both `gpgsig` and `jj:conflict-labels` use this. Splitting naively on `\n` and matching
75 `^change-id ` happens to work today, but a continuation line inside a signature whose
76 base64 payload begins with `change-id ` would produce a bogus id — cheap to prevent, so
77 prevent it.
783. Match the header key `change-id` exactly, case-sensitively.
794. Validate the value: exactly 32 bytes, all within `[k-z]`. Reject anything else rather than
80 storing a malformed id.
81
82Do not assume the header block ends at a fixed line number, and do not assume `change-id`
83precedes or follows any particular other header.
84
85## 3. Stability across rewrite — confirmed
86
87The property the entire product rests on:
88
89| description | commit id | change id |
90|---|---|---|
91| `first change` | `857ec6861ea3cd5dfe57f0751c0435c8165c1460` | `qstvwxmpkvovsosxxmrnxpqmuuyllsqu` |
92| `first change AMENDED` | `9d7c2334d79969ff8e788af6407e04e1d4fd1a35` | `qstvwxmpkvovsosxxmrnxpqmuuyllsqu` |
93
94The commit id changed, the change id did not. Verified to survive `jj git push` into a bare
95remote — the header is present byte-identical when read back with
96`git --git-dir=remote.git cat-file commit main`, which is exactly the path the indexer reads.
97
98## 4. Plain-git commits carry no header — confirmed
99
100```
101tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd
102author Plain Git <pg@dogfood.sh> 1785482410 +0000
103committer Plain Git <pg@dogfood.sh> 1785482410 +0000
104
105plain git commit
106```
107
108`extract_change_id` returns `None`; these take the synthetic-identity path (spec §4) and are
109flagged `changes.synthetic = true`.
110
111## 5. Merge commits carry the header normally — confirmed
112
113A two-parent jj commit carries `change-id` like any other. No special handling needed beyond
114recording both parents.
115
116## 6. Conflict representation — for the M4 indexer
117
118A conflicted revision is detectable **two** ways, and they agree:
119
120- **Header:** `jj:trees` lists the constituent tree OIDs, in alternating
121 `side_0 base_0 side_1 [base_1 side_2 …]` order — always `sides == bases + 1`.
122 `jj:conflict-labels` carries human-readable labels for each, in the same order.
123- **Tree:** the commit's root tree contains `.jjconflict-side-N` and `.jjconflict-base-N`
124 subtrees, plus a `JJ-CONFLICT-README` blob explaining the layout to plain-Git users.
125
126Observed for a 2-sided conflict:
127
128| header `jj:trees` position | tree entry | label |
129|---|---|---|
130| 0 | `.jjconflict-side-0` → `ada380c7…` | `vtrtrnpx 8fa0e2bf "sideA"` |
131| 1 | `.jjconflict-base-0` → `892cad2b…` | `wokvnsmz ebc596ab "base"` |
132| 2 | `.jjconflict-side-1` → `14a356d6…` | `lvtnxnzk e2263f3f "sideB"` |
133
134Note the root tree **also** contains the materialized path (`c.txt`) holding one side's
135content, alongside the `.jjconflict-*` subtrees. A tree listing must filter the
136`.jjconflict-*` entries and `JJ-CONFLICT-README` out of the user-facing file list, or every
137conflicted change will show phantom directories in the browser.
138
139Prefer the `jj:trees` header for detection — it is unambiguous and does not require loading
140the tree. Store the parsed sides/bases into `revisions.conflict_data` at index time so the
141conflict viewer never re-walks objects (spec §4).
142
143## 7. Reference: jj's own documentation
144
145jj documents this mapping at
146<https://docs.jj-vcs.dev/latest/git-compatibility/#format-mapping-details>, cited by the
147`JJ-CONFLICT-README` blob jj itself writes into conflicted trees.
148
149## 8. Version sensitivity
150
151All of the above is observed on jj 0.43.0. The `jj:` header namespace and the letter-encoded
152`change-id` are backend format, not CLI presentation, so they are stable in practice — but
153the fixture corpus is generated by a pinned jj version in CI precisely so a format change
154fails a test rather than silently corrupting the index.

154 lines · Markdown