jj-commit-cloud-poc / Object IDs and hashing

Object IDs and hashing

Every ID in the commit cloud, who computes it, and the exact bytes behind it: Git-compatible SHA-1 for commits, trees and files, a length-prefixed encoding for operations and views, and where all of this matches, and doesn't match, what jj's own Git backend would produce.

On this page
  1. Every ID
  2. Git-compatible hashing
  3. Files: compute_git_blob_hash
  4. Trees: compute_git_tree_hash
  5. Commits: compute_git_commit_hash
  6. Worked example: a one-file commit
  7. Operations and views
  8. The length-prefix helpers
  9. hash_operation
  10. hash_view
  11. How far the encoding avoids ambiguity
  12. Agreement with the client
  13. Compared with jj's GitBackend
  14. Edge cases

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.

IDComputed byLengthWhat it isRoot / 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:

  1. Pick a Git mode and a 20-byte object ID from the entry's value (lines 101–120):
    Valuegix EntryKindMode writtenID 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
    The File's copy_id is never hashed.
  2. 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).
  3. Sort the gix::objs::tree::Entry values with sort_unstable, which uses Git's tree order: a directory sorts as if its name ended in /. So a.txt comes before directory a, which comes before a0 (lines 130–131).
  4. Serialise the gix::objs::Tree with WriteTo: for each entry, the mode in ASCII, a space, the name, a NUL byte, then the 20 raw ID bytes. Hash that as Kind::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 fieldHashed?How
root_tree_idfirst term onlyThe 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_idsyes, minus the rootOne 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, committeryesThrough 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_idyes, as a headerIf 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).
descriptionyesThe message, byte for byte. encoding is None (lines 71–79).
predecessorsnoNot read.
conflict_labelsnoNot read.
secure_signoNot read. No gpgsig header is written.
commit_idn/aIf 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.

Hashing a one-file commit Step 1: the blob preimage is the header "blob 6", a NUL, then "hello\n", which hashes to ce013625030ba8dba906f756967f9e9ca394464a. Step 2: the tree preimage is "tree 37", a NUL, then the entry "100644 hello.txt", a NUL and the blob's 20 raw bytes, which hashes to aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7. Step 3: the commit preimage is "commit 203", a NUL, then the tree line naming that tree, author and committer lines for Alice at 1759492800 -0400, a change-id header holding the change ID's bytes reversed as hex, a blank line, and the message "Add hello". There is no parent line because the only parent is the root. It hashes to the commit ID 2b6bbf38192d5e2fc7271fe272935a3b6a0283f0. 1. File: compute_git_blob_hash blob 6\0 hello\n SHA-1 ce013625030ba8dba906f756967f9e9ca394464a as 20 raw bytes 2. Tree: compute_git_tree_hash tree 37\0 100644␣ hello.txt \0 ce 01 36 … 46 4a aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7 3. Commit: compute_git_commit_hash commit 203\0 tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7⏎ author Alice <alice@example.com> 1759492800 -0400⏎ committer Alice <alice@example.com> 1759492800 -0400⏎ change-id 1f1e1d1c1b1a19181716151413121110⏎ ⏎ Add hello⏎ Git's object header: kind, byte length, NUL first root_tree_id term; no parent line follows: the only parent is the root, which is filtered out signature_to_git: millis / 1000, tz_offset × 60 change ID bytes 10 … 1f, reversed, as hex end of headers description, unchanged SHA-1 commit ID 2b6bbf38192d5e2fc7271fe272935a3b6a0283f0

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:

The bytes hash_operation hashes The operation preimage, in order: the view ID with an 8-byte length; each parent operation ID with its length, with no count; start and end time as raw 8-byte integers; description, hostname and username, each length-prefixed; is_snapshot as one byte; the workspace name, length-prefixed, only when present and with no flag; each attribute key and value in sorted key order, with no count; then for each commit-predecessors entry the commit ID and each predecessor ID, all length-prefixed, with no counts. The buffer is hashed as a Git blob with SHA-1, and the first 16 bytes are the operation ID. commit_predecessors_set is not hashed. Solid: always written. Dashed: written only when present, with nothing marking its absence. "×" repeats with no count. len u64 8 B view_id 16 B len u64 8 B parent op ID × each parent if metadata is present (the client always sends it): start_time i64 LE, ms end_time i64 LE, ms len | description len | hostname len | username is_snapshot u8 len | workspace_name only if Some len | key len | value × each attribute, sorted by key len | commit_id len | predecessor ID × each × each commit_predecessors entry "blob n\0"+ SHA-1 20 B operation ID first 16 B Not hashed: commit_predecessors_set. Not in the proto at all: the timestamps' time-zone offsets.

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:

  1. head_ids, sorted, each length-prefixed.
  2. wc_commit_ids sorted by workspace name: the name, then the commit ID, both length-prefixed.
  3. local_bookmarks sorted by name: the name, then the target's removes (sorted), then its adds (sorted), each commit ID length-prefixed.
  4. remote_bookmarks sorted by key: the key, is_tracked as 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 commit W, and no bookmarks. View B has no workspaces and a local bookmark named default at W. Both have head W. 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 attribute B = C and no predecessors. Another, otherwise identical, has no workspace, the attribute A = B, and a predecessor entry for a commit whose ID is the three bytes C. Each writes the same three length-prefixed strings in a row.
Two views with the same view ID View A, with head W, workspace "default" at W and no bookmarks, serialises to: length and W, length and "default", length and W. View B, with head W, no workspaces and a local bookmark "default" adding W, serialises to the same three fields, the last two from the bookmark section instead of the working-copy section. Both hash to view ID 71d65d953ace7743773509367157d9a2. View A head_ids wc_commit_ids: default → W local_bookmarks: none len 20 | W len 7 | "default" len 20 | W View B head_ids wc_commit_ids: none … local_bookmarks: default → adds [W] len 20 | W len 7 | "default" len 20 | W identical bytes view ID 71d65d953ace…57d9a2 W stands for any 20-byte commit ID. With nothing marking where one section ends, a working-copy entry and a single-target bookmark write the same two fields.

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

AspectCommit cloudGitBackend (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

CaseWhat happensSource
empty treeThe 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 termThe commit is hashed as if its tree were empty, and stored with the original field.hash_utils.rs 46–50
missing signatureName 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 emailHashed as JJ_EMPTY_STRING, stored as empty. Only the hash sees the placeholder.hash_utils.rs 17–28
root commitNever 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 viewNever hashed. The server returns made-up all-zero objects on a lookup miss.operation, view
empty change IDNo 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 IDLeft out of the hash but kept in the stored tree.hash_utils.rs 121–127
conflict or empty tree valueHashed 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 fieldsSame 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 IDUsed as is, without checking.backend.rs 67–71
op with no predecessor map, or an empty oneSame 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.