In short: the server computes every content ID, in
server/src/hash_utils.rs. Files, trees and commits get Git
object IDs (20-byte SHA-1), built with the gix crate. Operations and views get the first
16 bytes of a SHA-1 over a custom length-prefixed encoding. The client computes none of them: it takes
whatever ID the write RPC returns. Trees and files get the same IDs jj's GitBackend
would give them. Commits never do, because the change-id header is spelled differently.
Several jj-only commit fields, such as conflict terms beyond the first, predecessors and signatures,
aren't hashed at all. Two different jj commits can therefore share an ID, and the second write
silently replaces the first.
This page describes upstream at the pinned commit with jj-lib
0.43.0 and gix 0.68.0
(server/Cargo.toml). For how jj itself names objects,
see /jj/commits,
/jj/trees and
/jj/operations. Those pages describe jj's own
pinned version, which is newer than 0.43.0.
Every ID
The lengths come from the shared constants in
common/src/lib.rs, which the client reports to
jj-lib through commit_id_length and change_id_length
(source). Every ID travels as raw bytes in the protos.
jj displays commit, operation and view IDs as lowercase hex, and change IDs in its reversed
z–k alphabet.
| ID | Computed by | Length | What it is | Root / special value |
|---|---|---|---|---|
| commit ID | server, compute_git_commit_hash, called from write_commit |
20 bytes | Git commit object ID: SHA-1 over "commit <len>\0" and a Git commit built from some of the jj commit's fields |
root commit: 20 zero bytes, never stored on the server (source) |
| change ID | client: jj-lib draws a random one when it builds a new commit (commit_builder.rs) | 16 bytes | random, not a hash; stored in the commit and hashed into the commit ID as a header | root change: 16 zero bytes (source) |
| tree ID | server, compute_git_tree_hash, from write_tree; the client itself answers for the empty tree |
20 bytes | Git tree object ID over the entries the client sends | empty tree: Git's 4b825dc642cb6eb9a060e54bf8d69288fbee4904 (source) |
| file ID | server, compute_git_blob_hash, from write_file |
20 bytes | Git blob ID of the file's full contents | none |
| symlink ID | nobody: both symlink RPCs answer UNIMPLEMENTED and the client returns Unsupported (source) |
n/a | a tree can't contain a symlink written through this client | n/a |
| operation ID | server, hash_operation, from write_operation |
16 bytes | first 16 bytes of a Git blob SHA-1 over a custom length-prefixed encoding | root operation: 16 zero bytes, made up by the server on read (source) |
| view ID | server, hash_view, from write_view |
16 bytes | same scheme as operation IDs | root view: 16 zero bytes, made up by the server on read (source) |
| repo ID | server, register_repository |
36 characters | a random UUID v4 as a string, not a hash; the client keeps it in its store config and sends it with every request | none |
The root IDs aren't hashes of anything. They're agreed constants: no root commit, operation or view is
ever written to the server, and both sides recognise the all-zero IDs on read. Operation and view IDs
are 16 bytes here. jj's own SimpleOpStore uses 64
(simple_op_store.rs).
Git-compatible hashing
All three Git hashes end in the same call, gix::objs::compute_hash(Kind::Sha1, kind, &buf).
It hashes Git's object header ("<kind> <decimal length>\0") followed by
buf, exactly as git hash-object does. Only how buf is built differs.
Files: compute_git_blob_hash
Lines 89–93. buf is the file content, unchanged. The
client reads the whole file into memory and sends it in a single WriteFile
(source), so the ID is exactly git hash-object of
the file. Executability isn't part of the blob. It lives in the tree entry.
Trees: compute_git_tree_hash
Lines 95–143. For each TreeEntry in the request:
-
Pick a Git mode and a 20-byte object ID from the entry's value
(lines 101–120):
The
Value gix EntryKindMode written ID used File,executable = falseBlob100644the file ID File,executable = trueBlobExecutable100755the file ID TreeIdTree40000the subtree ID SymlinkIdLink120000the symlink ID ConflictId, or no value at allBlob10064420 zero bytes File'scopy_idis never hashed. -
Convert the ID with
gix::hash::ObjectId::try_from. If it isn't exactly 20 bytes, the entry is silently dropped from the hash, though it's still stored with the tree (lines 121–127). -
Sort the
gix::objs::tree::Entryvalues withsort_unstable, which uses Git's tree order: a directory sorts as if its name ended in/. Soa.txtcomes before directorya, which comes beforea0(lines 130–131). -
Serialise the
gix::objs::TreewithWriteTo: for each entry, the mode in ASCII, a space, the name, a NUL byte, then the 20 raw ID bytes. Hash that asKind::Tree(lines 133–142).
The client never sends a ConflictId. Trees in jj-lib 0.43.0 have
no conflict values, and tree_entry_to_proto refuses only Git submodules
(source). The zero-ID fallback is reached only by a request that
some other client built.
Commits: compute_git_commit_hash
Lines 42–87 build a gix::objs::Commit from the proto
Commit, then serialise it with WriteTo and hash it as
Kind::Commit. Field by field:
| Proto field | Hashed? | How |
|---|---|---|
root_tree_id | first term only | The tree line uses the first tree ID. A missing or non-20-byte first ID becomes Git's empty tree. Every other term of a conflicted tree is ignored (lines 46–50). |
parent_commit_ids | yes, minus the root | One parent line per parent, in order. The all-zero root ID is filtered out, and so is any ID that isn't 20 bytes (lines 52–58). A child of the root becomes a parentless Git commit. |
author, committer | yes | Through signature_to_git into a gix::actor::Signature. An empty name or email, or a missing signature, becomes JJ_EMPTY_STRING. The time is millis_since_epoch / 1000 seconds, so sub-second precision is lost, with tz_offset minutes × 60 as the offset (a gix::date::Time). |
change_id | yes, as a header | If it isn't empty, an extra header change-id whose value is the change ID's bytes in reverse order, as lowercase hex (lines 60–69). |
description | yes | The message, byte for byte. encoding is None (lines 71–79). |
predecessors | no | Not read. |
conflict_labels | no | Not read. |
secure_sig | no | Not read. No gpgsig header is written. |
commit_id | n/a | If the request already carries one, the hash isn't computed at all (see below). |
WriteTo lays these out in Git's order: tree, each parent,
author, committer, the extra headers, a blank line, then the message. A
signature line is name <email> seconds ±hhmm.
Worked example: a one-file commit
A commit whose only parent is the root, containing one file hello.txt with the six bytes
hello\n. Author and committer are both Alice <alice@example.com> at
1759492800 seconds in UTC−4. The change ID is the bytes 10 11 … 1f and the description is
Add hello\n. The client writes the file, then the tree, then the commit, and the server
hashes each. Every ID below was computed by running the same gix 0.68.0 calls as
hash_utils.rs, and checked with git hash-object.
Change any field the commit hash ignores, such as the predecessors, a second tree term or the
milliseconds of a timestamp, and the ID stays 2b6bbf38…. With an empty author and
committer name, both lines read JJ_EMPTY_STRING <alice@example.com> and the ID
becomes c020c94bcb0ed4afe8a748df01d35c352b2a5995.
Operations and views
Operations and views have no Git equivalent, so hash_utils.rs serialises them by hand into
a byte buffer, then hashes that buffer as if it were a Git blob:
compute_hash(Sha1, Kind::Blob, &buf), which is SHA-1 over
"blob <len>\0" plus the buffer. It keeps the first 16 bytes
(OPERATION_ID_LENGTH, VIEW_ID_LENGTH).
The length-prefix helpers
append_length_prefixed_bytes writes the byte count as an
8-byte little-endian u64, then the bytes. append_length_prefixed_str does
the same for a string's UTF-8 bytes. The doc comment gives the reason: without a length, two adjacent
fields "foo" + "bar" and "foob" + "ar" would
produce the same bytes. With it, a reader always knows where one variable-length field ends and the
next begins. Fixed-width fields (the two i64 times and the is_snapshot byte)
are written raw, little-endian. Maps are walked in sorted key order (via a BTreeMap or an
explicit sort), because protobuf map iteration order isn't fixed
(line 145).
hash_operation
Lines 146–176. Field by field, in this order:
commit_predecessors_set (whether jj recorded predecessors at all) isn't hashed, so an
operation with no predecessor map and one with an empty map get the same ID. The metadata's
time-zone offsets never reach the server: the proto has no field for them, and the client reads them
back as zero (source).
hash_view
Lines 178–225, in this order, with no counts anywhere:
head_ids, sorted, each length-prefixed.wc_commit_idssorted by workspace name: the name, then the commit ID, both length-prefixed.-
local_bookmarkssorted by name: the name, then the target'sremoves(sorted), then itsadds(sorted), each commit ID length-prefixed. -
remote_bookmarkssorted by key: the key,is_trackedas one byte, then the target as above if there is one.
How far the encoding avoids ambiguity
Length prefixes make each field unambiguous, which is the case the doc comment guards against. They don't make the whole buffer unambiguous. Neither function writes how many items a list or map holds, or which section an item belongs to, and the optional workspace name has no presence flag. So two different values can serialise to identical bytes, and therefore share an ID. Both cases below were checked by reimplementing the two functions byte for byte:
-
Views. View A has one workspace,
default, at commitW, and no bookmarks. View B has no workspaces and a local bookmark nameddefaultatW. Both have headW. A's working-copy section and B's bookmark section write the same two fields, so the bytes match (diagram below). -
Operations. One operation has workspace
A, the attributeB = Cand no predecessors. Another, otherwise identical, has no workspace, the attributeA = B, and a predecessor entry for a commit whose ID is the three bytesC. Each writes the same three length-prefixed strings in a row.
Both examples need unusual inputs, and nothing in normal jj use is known to produce them. The consequence would be the same as for commits, though: the server stores by ID with a plain map insert, so the second write replaces the first. Writing a count before each list, and a presence byte before the workspace name, would close the gap.
Agreement with the client
The client never computes a content hash. Every write method sends the object and builds its ID from
the bytes the server returns: files (line 305), trees
(line 395), commits (lines 458–459),
views (line 294) and operations
(line 335). Nothing checks a returned ID's length or recomputes it.
For commits, commit_to_proto always sends an empty commit_id
(source). The server hashes only when that field is empty, and
otherwise stores the commit under whatever ID the request names
(source). This client never takes that path, but the server doesn't
verify anyone who does.
The client does assume four IDs without asking the server. Each must match what the server does:
| Assumed ID | Client | Server | If they disagreed |
|---|---|---|---|
| empty tree | write_tree returns EMPTY_TREE_ID_HEX for an empty tree without an RPC (source) |
read_tree answers that ID with no entries even though it was never stored (source). Hashing an empty entry list gives the same ID anyway. |
Reading any empty directory, or the root commit's tree, would fail with NOT_FOUND. |
| root commit | read_commit builds it locally with jj-lib's make_root_commit (source) |
never stores it; compute_git_commit_hash drops it from parent lists (source) |
The root's 20 zero bytes would be hashed as a real Git parent. |
| root operation | root_operation_id is 16 zero bytes (source) |
read_operation makes it up on a miss (source), and op heads start there |
A fresh repository's operation log would have no base. |
| root view | implied by the root operation's view_id |
read_view makes it up with the root commit as the only head (source) |
Loading the root operation would fail. |
The server rebuilds every ID from content. So two clients that write the same file, tree or commit get
the same ID, and the second write replaces an identical entry. jj-lib keeps the Commit
value it passed in, not one read back from the server, as the client returns it unchanged alongside
the server's ID (source).
Compared with jj's GitBackend
jj-lib's GitBackend also writes commits, trees and blobs as Git objects with
gix, so the formats look alike. For jj's own backends, see
/jj/backends, which describes jj's own pinned version. The
comparison here is against git_backend.rs at v0.43.0,
the version this project builds against.
| Aspect | Commit cloud | GitBackend (0.43.0) | Same ID? |
|---|---|---|---|
| file IDs | Git blob of the content | Git blob of the content | yes |
| tree IDs | Git tree, same modes and order | Git tree | yes, for trees this client can write: files, executables and subtrees. Symlinks and submodules can't be written here. |
change-id header |
change ID bytes reversed, then ordinary hex: 1f1e1d…1110 in the example |
bytes in order, in jj's reverse-hex alphabet zyxwvutsrqponmlk: yzyyyx…ylyk (source, hex_util.rs), written by default (misc.toml) |
no. The example commit would be 061e91a7ce0ddba6b6fc7d285b8dd9519662248c under GitBackend, not 2b6bbf38…. Every commit ID differs. |
| conflicted trees | hashes only the first tree term; no extra headers | writes a combined conflict tree as the Git tree, plus a jj:trees header listing every term and a jj:conflict-labels header (tree, headers) |
no |
| same Git ID, different jj commit | not detected; the store's map insert replaces the earlier commit (source) | detected through its extras table (change ID and predecessors). It moves the committer time back one second until the ID is new (source). | n/a |
| signatures | not hashed; sign_with is ignored, and secure_sig is dropped on read (write, read) |
signs the serialised commit and adds a gpgsig header, which is part of the ID (source) |
only for unsigned commits, and they already differ by change-id |
| timestamp to seconds | millis / 1000, which rounds toward zero |
div_euclid(1000), which rounds down (source) |
differ only before 1970 with a fractional second |
| committer time returned | the commit is returned unchanged, milliseconds included | the returned commit's committer time is cut to whole seconds, matching what was hashed (source) | n/a |
| empty name or email | JJ_EMPTY_STRING |
JJ_EMPTY_STRING (the cloud's signature_to_git is a copy of jj's) |
same bytes |
| root parent | filtered out of any parent list | filtered out, but a merge of the root with another commit is rejected as Unsupported (source) |
same bytes where both accept it |
| ID lengths | commit 20, change 16, operation 16, view 16 | commit 20, change 16 (source); operations and views belong to the op store, 64 bytes in SimpleOpStore |
n/a |
A repository moved between the two backends would keep its file and tree IDs, but every commit would get a new ID.
Edge cases
| Case | What happens | Source |
|---|---|---|
| empty tree | The client never sends one. The server answers reads of 4b825dc6… itself, and would hash an empty list to that same ID. | client, server |
| no tree ID, or an invalid first term | The commit is hashed as if its tree were empty, and stored with the original field. | hash_utils.rs 46–50 |
| missing signature | Name and email become JJ_EMPTY_STRING and the time becomes 0 +0000. The client always sends both signatures. | hash_utils.rs 29–33, client |
| empty name or email | Hashed as JJ_EMPTY_STRING, stored as empty. Only the hash sees the placeholder. | hash_utils.rs 17–28 |
| root commit | Never written or read over RPC. It is dropped from parent lists before hashing, so a child of the root has no parent line. | client, hash_utils.rs 52–58 |
| root operation and view | Never hashed. The server returns made-up all-zero objects on a lookup miss. | operation, view |
| empty change ID | No change-id header. jj always has one, so only another client could hit this. | hash_utils.rs 61–69 |
| tree entry with a non-20-byte ID | Left out of the hash but kept in the stored tree. | hash_utils.rs 121–127 |
| conflict or empty tree value | Hashed as a regular file whose ID is 20 zero bytes. The client rejects a ConflictId when it reads the tree back. | hash_utils.rs 116–119, client |
| commits differing only in ignored fields | Same commit ID, so the later write replaces the earlier one in the store. That covers predecessors, conflict terms after the first, conflict labels, signatures, and the sub-second part of a timestamp. | hash_utils.rs 42–87, store |
| client-supplied commit ID | Used as is, without checking. | backend.rs 67–71 |
| op with no predecessor map, or an empty one | Same ID, since commit_predecessors_set isn't hashed. | hash_utils.rs 168–173 |
No upstream test targets hash_utils.rs. The server's only test checks that it starts, and
the CLI tests reach the hashing only indirectly. The one ID they assert is the root commit's 40 zeros
(source). None of the behaviour above is
pinned by a test.