| 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). |
| 7 | No commit objects were hand-authored. Every claim below is a direct `git cat-file commit` |
| 8 | observation, and the reproduction steps live in `fixtures/gen.sh`. |
| 9 | |
| 10 | --- |
| 11 | |
| 12 | ## 1. The header |
| 13 | |
| 14 | ``` |
| 15 | change-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 | |
| 22 | This is the single most important finding, and it is the opposite of what the spec |
| 23 | anticipated ("the CLI's letter form is a presentation encoding of the stored bytes"). |
| 24 | On this backend the stored bytes **are** the letter form. |
| 25 | |
| 26 | **Consequence:** the canonical encoding for `changes.change_id` is the letter string |
| 27 | exactly as it arrives on the wire. Do not hex-decode on ingest and re-encode for display — |
| 28 | that round-trip is pure risk with no benefit, and any mismatch means users cannot |
| 29 | cross-reference the UI against their terminal. |
| 30 | |
| 31 | The alphabet is the reverse-hex mapping `0123456789abcdef` → `zyxwvutsrqponmlk`. |
| 32 | Corroborated by the root change, which jj displays as `zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz` |
| 33 | against commit id `0000000000000000000000000000000000000000`. |
| 34 | |
| 35 | ## 2. Header position is NOT fixed — parse properly |
| 36 | |
| 37 | Two observed layouts place `change-id` in different positions: |
| 38 | |
| 39 | Signed commit — `change-id` *before* `gpgsig`: |
| 40 | ``` |
| 41 | tree ec67420ed747b72ce94854190b4c59deff01b9db |
| 42 | author Fixture Author <fixture@dogfood.sh> 1785482470 +0000 |
| 43 | committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000 |
| 44 | change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn |
| 45 | gpgsig -----BEGIN SSH SIGNATURE----- |
| 46 | U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl |
| 47 | ... |
| 48 | -----END SSH SIGNATURE----- |
| 49 | |
| 50 | signed via jj |
| 51 | ``` |
| 52 | |
| 53 | Conflicted commit — `change-id` *after* two other `jj:` headers, one of which is multi-line: |
| 54 | ``` |
| 55 | tree 125bc2eed773e5eacbb11544e9966dab850ed4f9 |
| 56 | parent 8fa0e2bff61fc63eb09ade2565647e3bda6e8cbf |
| 57 | parent e2263f3f9f90bbfac26edb8e43b348bc9ab32e99 |
| 58 | author Fixture Author <fixture@dogfood.sh> 1785482444 +0000 |
| 59 | committer Fixture Author <fixture@dogfood.sh> 1785482444 +0000 |
| 60 | jj:conflict-labels vtrtrnpx 8fa0e2bf "sideA" |
| 61 | wokvnsmz ebc596ab "base" |
| 62 | lvtnxnzk e2263f3f "sideB" |
| 63 | jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21 |
| 64 | change-id otuuklrtzzlqvnwuxvsvttrrsompzpzx |
| 65 | |
| 66 | conflicted merge |
| 67 | ``` |
| 68 | |
| 69 | `extract_change_id` must therefore be a real RFC-822-style header parser: |
| 70 | |
| 71 | 1. Read lines until the **first empty line** (that terminates the header block; everything |
| 72 | after is the message). |
| 73 | 2. 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. |
| 78 | 3. Match the header key `change-id` exactly, case-sensitively. |
| 79 | 4. Validate the value: exactly 32 bytes, all within `[k-z]`. Reject anything else rather than |
| 80 | storing a malformed id. |
| 81 | |
| 82 | Do not assume the header block ends at a fixed line number, and do not assume `change-id` |
| 83 | precedes or follows any particular other header. |
| 84 | |
| 85 | ## 3. Stability across rewrite — confirmed |
| 86 | |
| 87 | The 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 | |
| 94 | The commit id changed, the change id did not. Verified to survive `jj git push` into a bare |
| 95 | remote — 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 | ``` |
| 101 | tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd |
| 102 | author Plain Git <pg@dogfood.sh> 1785482410 +0000 |
| 103 | committer Plain Git <pg@dogfood.sh> 1785482410 +0000 |
| 104 | |
| 105 | plain git commit |
| 106 | ``` |
| 107 | |
| 108 | `extract_change_id` returns `None`; these take the synthetic-identity path (spec §4) and are |
| 109 | flagged `changes.synthetic = true`. |
| 110 | |
| 111 | ## 5. Merge commits carry the header normally — confirmed |
| 112 | |
| 113 | A two-parent jj commit carries `change-id` like any other. No special handling needed beyond |
| 114 | recording both parents. |
| 115 | |
| 116 | ## 6. Conflict representation — for the M4 indexer |
| 117 | |
| 118 | A 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 | |
| 126 | Observed 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 | |
| 134 | Note the root tree **also** contains the materialized path (`c.txt`) holding one side's |
| 135 | content, 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 |
| 137 | conflicted change will show phantom directories in the browser. |
| 138 | |
| 139 | Prefer the `jj:trees` header for detection — it is unambiguous and does not require loading |
| 140 | the tree. Store the parsed sides/bases into `revisions.conflict_data` at index time so the |
| 141 | conflict viewer never re-walks objects (spec §4). |
| 142 | |
| 143 | ## 7. Reference: jj's own documentation |
| 144 | |
| 145 | jj 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 | |
| 151 | All 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 |
| 153 | the fixture corpus is generated by a pinned jj version in CI precisely so a format change |
| 154 | fails a test rather than silently corrupting the index. |
154 lines · Markdown