jj / How jj sign works

How jj sign works

jj sign rewrites revisions so they carry a cryptographic signature from the configured signing backend (GPG, GPGSM or SSH). It always signs again, even commits that are already signed, and rebases their descendants.

On this page
  1. 1. Command-line surface
  2. 2. Configuration
  3. 3. End-to-end flow
  4. 4. Core architecture touchpoints
  5. 5. Transaction and operation
  6. 6. Algorithm and invariants
  7. Sign behaviors
  8. 7. Errors and edge cases
  9. 8. Worked example

In short: jj refuses if no signing backend is configured. It resolves -r, or the revsets.sign setting (default reachable(@, mutable())), and checks the commits are mutable. In one transaction it rewrites them and their descendants: target commits are written with the sign behavior forced on, optionally with --key; descendants are just reparented, which signs them only as signing.behavior says. It prints Signed N commits:, warns about commits authored by someone else, and records sign commit <id>.

The command is cmd_sign. The reverse is jj unsign. Signatures are also made automatically when commits are written, depending on signing.behavior, and at push time with git.sign-on-push; see jj git push. How commits are stored is on the commits page.

1. Command-line surface

FlagTypeDefaultMeaning
-r, --revision (alias --revisions)revsets, repeatablerevsets.signWhat to sign. Always re-signed: jj can't check whether a commit is already signed by you without creating a signature (source).
--key KEYstringsigning.keyThe key to sign with, passed to the backend (source).

SignArgs

2. Configuration

KeyDefaultEffect
signing.backend"none"Which backend signs; with none, jj sign refuses (default).
signing.behavior"keep"What every commit write does about signatures, including the rebased descendants here (§6).
signing.keyunsetDefault key; --key overrides it.
signing.backends.gpg, .gpgsm, .sshprograms gpg, gpgsm, ssh-keygenBackend-specific settings (defaults).
revsets.sign"reachable(@, mutable())"What to sign without -r (default, test).

3. End-to-end flow

jj sign: call flow cmd_sign checks the store's signer can sign, resolves -r or revsets.sign, checks the commits are rewritable and collects them. A transaction runs transform_descendants from those commits: each target is written with SignBehavior Force and the optional key override, each other descendant is just reparented. Then it prints the signed commits, warns about commits not authored by you, reports rebased descendants, and finishes with sign commit and the first ID. signer().can_sign()? otherwise "No signing backend configured" resolve -r (or revsets.sign); check_rewritable_expr; collect the commits transform_descendants(targets), in one transaction target commit reparent; set_sign_key_override(--key) set_sign_behavior(Force); write descendant (not a target) reparent; write with signing.behavior counted as rebased "Signed N commits:" list; warn "N of these commits are not authored by you."; "Rebased N ..." tx.finish("sign commit ID" or "sign commit ID and N more")
  1. Backend check. The store's signer can sign only if a main backend is configured (Signer::can_sign, source).
  2. Targets. -r values are unioned; without any, revsets.sign is parsed. Immutable commits are refused before anything is written (source).
  3. Rewrite. transform_descendants visits the targets and all their descendants, parents first, with new parents already filled in (source). A progress bar shows the change being signed (source).
  4. Report and finish. See §5 and §7.

4. Core architecture touchpoints

  • Commit writing: every commit write decides whether to sign with write_to_store: sign only if the signer can sign and should_sign says so, then pass a signing function to the backend.
  • Git backend: the backend serializes the commit, signs those bytes, and adds the signature as a header of the Git commit object, so the commit ID changes (source); see the commits page.
  • Signer: wraps the main backend plus the others used for verifying, with a cache (Signer).
  • Rewrites: each signed commit is a rewrite of the old one, so bookmarks follow and the working copy moves if @ was signed.

5. Transaction and operation

  • Transaction: one, covering all targets and descendants.
  • Operation description: sign commit <id>, or sign commit <first id> and <N> more, using the new commit IDs (source).
  • No targets: -r 'none()' is a valid no-op that prints Nothing changed. (test).
  • jj undo restores the unsigned commits.

6. Algorithm and invariants

Sign behaviors

The four sign behaviors and when each signs a commit drop never signs. keep, the default, signs only commits authored by you that were already signed. own signs every commit authored by you. force signs every commit, which is what jj sign uses for its targets. Descendants rebased by jj sign use the configured behavior. drop never sign keep (default) yours and already signed own authored by you force always jj sign targets set_sign_behavior(Force) rebased descendants: whichever behavior signing.behavior selects "yours" = author email equals user.email

SignBehavior and SignSettings::should_sign decide per commit. "Yours" means the commit's author email equals user.email. With the default keep, a signed descendant you authored is signed again when jj sign rebases it; an unsigned one stays unsigned (test with drop).

Invariants: every target is signed afterwards, whoever authored it. Commit contents, descriptions and authors are unchanged; the committer timestamp and the commit IDs change. No immutable commit is touched.

7. Errors and edge cases

MessageWhenSource
No signing backend configured, with a hint linking the commit-signing docssigning.backend is none.sign.rs, test
Warning: N of these commits are not authored by you.Some targets have another author email; they are still signed.sign.rs, test
Immutable-commit errorA target is immutable.sign.rs
Signing error from the backendThe signing program failed.signing.rs

Re-signing on every run is deliberate but costly with hardware keys: upstream issue #5786 tracks skipping commits already signed by you (source).

8. Worked example

From upstream's test_sign, with a test signing backend and behavior = "keep". Three commits one, two, three and an empty @ are unsigned. jj sign -r ..@ signs all four, so every commit ID changes:

jj sign -r ..@ Before: root, then one 401ea16f, two 26a2c4cb, three 8c63f712 and the working copy fbef508b, none signed. After: one 7fb98da0, two 062a3c5a, three d2174a79 and the working copy 8d7bc037, all with a good signature. before after jj sign -r ..@ root 401ea16f one 26a2c4cb two 8c63f712 three fbef508b @ root 7fb98da0 one signed 062a3c5a two signed d2174a79 three signed 8d7bc037 @ signed

Upstream's output (source):

Signed 4 commits:
  qpvuntsm 7fb98da0 (empty) one
  rlvkpnrz 062a3c5a (empty) two
  kkmpptxz d2174a79 (empty) three
  zsuskuln 8d7bc037 (empty) (no description set)
Working copy  (@) now at: zsuskuln 8d7bc037 (empty) (no description set)
Parent commit (@-)      : kkmpptxz d2174a79 (empty) three

Running the same command again signs all four again, with new IDs (source). There is no Rebased line because every descendant was itself a target.