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
| Field | Type | Meaning |
|---|---|---|
parents | Vec<CommitId> | Usually one, any number for merges. Only the root commit has none, and backends refuse to write a commit without parents. |
predecessors | Vec<CommitId> | Commits this one was rewritten from. Deprecated: rewrites are now recorded per operation in Operation.commit_predecessors (§6). Skipped in serialization. |
root_tree | Merge<TreeId> | The root tree. A single ID when resolved; an odd number of alternating add/remove terms when the commit is conflicted. |
conflict_labels | Merge<String> | Empty when resolved. Otherwise one label per root_tree term, used in conflict markers. |
change_id | ChangeId | Stable identity of the change across rewrites (§3). |
description | String | The commit message. |
author | Signature | Who wrote the change. |
committer | Signature | Who last wrote this version. It's reset on every rewrite (§5). |
secure_sig | Option<SecureSig> | Cryptographic signature: data (the bytes that were signed) and sig (§7). |
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:
| Method | What 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
CommitId | ChangeId | |
|---|---|---|
| What it identifies | One exact version of a commit | A change, across all its versions |
| Where it comes from | The 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 rewrite | Changes | Kept |
| Displayed as | Normal 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
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(viaMutableRepo::new_commit): asserts there's at least one parent, generates a fresh change ID, and sets an empty description. Author and committer both come fromsettings.signature(), whose timestamp is "now" unlessdebug.commit-timestampis set (source). -
for_rewrite_from(viaMutableRepo::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:
-
Copies the predecessors into the deprecated commit field, but only if
experimental.record-predecessors-in-commitis set. - Writes through
write_to_store(§7). -
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. -
Adds the commit as a head, records its predecessors for the operation's
commit_predecessorsmap, and for rewrites callsset_rewritten_commit(old, new). That's what later makesrebase_descendantsmove 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:
SignBehavior | Signs when |
|---|---|
Drop | Never. Existing signatures are dropped on rewrite. |
Keep | The commit was already signed and the author's email is the user's: the signature is preserved across rewrites. |
Own | The author's email is the user's. |
Force | Always (jj sign -f). |
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).