jj / Commits and change IDs

Commits and change IDs

A jj commit is a snapshot of a tree plus metadata, identified two ways: a content hash that changes every time the commit is rewritten, and a change ID that stays the same across rewrites. This page covers the stored object field by field, the in-memory wrapper, how commits are built and written, the virtual root commit, and signing.

On this page
  1. 1. The stored commit: backend::Commit
  2. 2. The in-memory commit: commit::Commit
  3. 3. Commit IDs and change IDs
  4. 4. The root commit
  5. 5. Building and writing commits
  6. 6. Predecessors: from the commit to the operation
  7. 7. Signing

In short: backend::Commit is the data a backend stores; commit::Commit is that data plus its ID and a handle to the store. You never edit a commit. You build a new one with a CommitBuilder from MutableRepo::new_commit or rewrite_commit, and the repo records that the new commit replaced the old one.

1. The stored commit: backend::Commit

FieldTypeMeaning
parentsVec<CommitId>Usually one, any number for merges. Only the root commit has none, and backends refuse to write a commit without parents.
predecessorsVec<CommitId>Commits this one was rewritten from. Deprecated: rewrites are now recorded per operation in Operation.commit_predecessors (§6). Skipped in serialization.
root_treeMerge<TreeId>The root tree. A single ID when resolved; an odd number of alternating add/remove terms when the commit is conflicted.
conflict_labelsMerge<String>Empty when resolved. Otherwise one label per root_tree term, used in conflict markers.
change_idChangeIdStable identity of the change across rewrites (§3).
descriptionStringThe commit message.
authorSignatureWho wrote the change.
committerSignatureWho last wrote this version. It's reset on every rewrite (§5).
secure_sigOption<SecureSig>Cryptographic signature: data (the bytes that were signed) and sig (§7).

backend::Commit

A Signature is name, email and timestamp. A Timestamp is milliseconds since the Unix epoch (MillisSinceEpoch(i64)) plus tz_offset in minutes. Backends may store less precision; Git, for example, stores whole seconds (Timestamp, Signature, SecureSig). How each backend serializes these fields is on Every schema.

2. The in-memory commit: commit::Commit

jj-lib passes around commit::Commit, which holds an Arc<Store>, the CommitId, and an Arc<backend::Commit>. Equality, ordering and hashing all use the ID alone, and Debug deliberately prints only the ID. The wrapper adds the operations most code needs:

MethodWhat it does
tree()Builds a MergedTree from root_tree and the conflict labels (source).
parent_tree(index)The parent's tree, or the merge of all parents' trees. If the index says the commit changed no paths, it short-circuits to the commit's own tree (source).
is_empty(index)No content change from its parent(s). The index's changed-path data answers this when available; otherwise it compares root_tree with the parent tree (source, helpers).
has_conflict()root_tree isn't resolved (source).
is_discardable(index)Empty description and empty content (source).
verification()Verifies secure_sig through the store's Signer. Results are cached per commit ID (source).
conflict_label()Text for conflict markers: the first 8 characters of the change ID and commit ID, plus the description's first line, e.g. nlqwxzwn 7dd24e73 "subject" (source).

3. Commit IDs and change IDs

CommitIdChangeId
What it identifiesOne exact version of a commitA change, across all its versions
Where it comes fromThe backend's content hash (a Git object ID in the Git backend; BLAKE2b in the simple backend)Random bytes from jj's seeded RNG, change_id_length() long (16 bytes for Git)
On rewriteChangesKept
Displayed asNormal hex, 0-9a-f"Reverse hex": the same nibbles written with z to k, so 0 is z and 15 is k

Because the two use disjoint alphabets, a short prefix such as kx or 3f is never ambiguous between a change ID and a commit ID (ID types, alphabets, new_change_id).

A Git commit may carry its change ID in a change-id header. If it doesn't, for example a commit made by plain git, the Git backend derives a synthetic change ID: the last 16 bytes of the commit ID, in reverse order, with each byte's bits reversed. That way it looks nothing like the commit ID it came from (source).

Normally exactly one visible commit has a given change ID. When several do, for instance after concurrent rewrites of the same change, the change is divergent. jj tracks visibility per change ID in the index: a commit is hidden when the change-ID index doesn't list it as a visible target, which is what the hidden() template method checks (source).

4. The root commit

Every repo has a virtual root commit, the ancestor of everything. make_root_commit builds it in memory: no parents, the backend's empty tree, the backend's root change ID, an empty description, and empty signatures at timestamp 0. The Git backend uses Git's null object ID (all zeros) as the root commit ID, and 16 zero bytes as the root change ID, which displays as zzzzzzzz…. It's never written to Git: reading a parentless Git commit adds the root as its parent, and writing a commit whose only parent is the root writes a parentless Git commit. "Since the root commit is an ancestor of every commit", a merge that lists the root among its parents is written without it, and the commit that comes back has only the other parents (read, write).

5. Building and writing commits

How a commit is created or rewritten MutableRepo::new_commit and MutableRepo::rewrite_commit both produce a CommitBuilder wrapping a DetachedCommitBuilder. Calling write sends the commit through write_to_store, which decides whether to sign it, to Store and Backend::write_commit, which returns the new CommitId. MutableRepo then adds the commit as a head, records its predecessors, and, for rewrites, records the old commit as rewritten to the new one. new_commit(parents, tree) fresh change ID, empty description rewrite_commit(old) copy of old, new committer CommitBuilder set_tree · set_parents · … write_to_store should_sign? → sign_fn Backend::write_commit → (CommitId, Commit) MutableRepo bookkeeping add_head set_predecessors set_rewritten_commit (rewrites) write()

CommitBuilder is a DetachedCommitBuilder attached to a &mut MutableRepo. detach() gives a builder that can make throwaway commits without holding the repo. There are two constructors:

  • for_new_commit (via MutableRepo::new_commit): asserts there's at least one parent, generates a fresh change ID, and sets an empty description. Author and committer both come from settings.signature(), whose timestamp is "now" unless debug.commit-timestamp is set (source).
  • for_rewrite_from (via MutableRepo::rewrite_commit): copies the old commit, sets a new committer, fills an empty author name or email from the committer, and remembers the old commit as the rewrite source and predecessor. If the author is also the committer and the old commit was discardable, the author timestamp is reset to now too.

write() then:

  1. Copies the predecessors into the deprecated commit field, but only if experimental.record-predecessors-in-commit is set.
  2. Writes through write_to_store (§7).
  3. Refuses to record a commit ID that already exists in the index: Newly-created commit … already exists. Recording an existing commit as new would put a cycle into the predecessor graph.
  4. Adds the commit as a head, records its predecessors for the operation's commit_predecessors map, and for rewrites calls set_rewritten_commit(old, new). That's what later makes rebase_descendants move the old commit's children onto the new one.

write_hidden() writes to the store without making the commit visible. abandon() writes nothing, but records the rewrite source as abandoned, so its children get rebased onto its parents.

6. Predecessors: from the commit to the operation

Originally each commit stored its own predecessors, which drove jj evolog. Since jj 0.30, each operation records a commit_predecessors map (new commit → the commits it was rewritten from) for everything the transaction created. The per-commit field is no longer written unless experimental.record-predecessors-in-commit is on. The source marks it for deletion (source). See the Operation schema for how the map is stored.

7. Signing

write_to_store decides whether to sign: the store's Signer must have a backend that can sign, and the configured SignBehavior (signing.behavior) must say yes:

SignBehaviorSigns when
DropNever. Existing signatures are dropped on rewrite.
KeepThe commit was already signed and the author's email is the user's: the signature is preserved across rewrites.
OwnThe author's email is the user's.
ForceAlways (jj sign -f).

SignSettings::should_sign

The decision is made against the commit as built, which for a rewrite still carries the old signature. That's how Keep knows the commit was signed. Then secure_sig is cleared, because backends assert it's empty, and the backend calls the signing function on its own serialized bytes before storing them. The Signer is built from the gpg, gpgsm and ssh backends, plus a test backend, and signing.backend picks the one used for new signatures. Verification results are kept in an LRU cache keyed by commit ID (factory, Signer).