jj / How jj unsign works

How jj unsign works

jj unsign rewrites signed revisions without their cryptographic signature and rebases their descendants. Unlike jj sign, it has no default revset and doesn't need a signing backend.

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. 7. Errors and edge cases
  8. 8. Worked example

In short: jj resolves -r (nothing if it's omitted), checks the commits are mutable, and keeps only those that are signed. In one transaction it rewrites them with the sign behavior set to drop and reparents their descendants, which keep or lose signatures according to signing.behavior. It prints Unsigned N commits:, warns about commits authored by someone else, and records unsign commit <id>.

The command is cmd_unsign. It mirrors jj sign step for step; this page focuses on the differences. How commits are stored is on the commits page.

1. Command-line surface

FlagTypeDefaultMeaning
-r, --revision (alias --revisions)revsets, repeatablenoneWhat to unsign (source).

UnsignArgs

Without -r the list is empty, and a union of no revsets is none() (union_all), so the command changes nothing.

2. Configuration

No setting chooses what to unsign. signing.behavior (default keep) still applies to the rebased descendants, and user.email decides the "not authored by you" warning. Because the targets are written with drop, no backend is consulted for them, so jj unsign works even with signing.backend = "none" (write_to_store).

3. End-to-end flow

jj unsign: call flow cmd_unsign resolves -r, checks the commits are rewritable and collects them, keeping only signed ones. A transaction runs transform_descendants from those: each target is written with SignBehavior Drop, each other descendant is reparented and counted. It prints the unsigned commits, warns about commits not authored by you, reports rebased descendants and finishes with unsign commit. resolve -r (empty → none()); check_rewritable_expr; collect the commits keep only commit.is_signed() transform_descendants(signed targets), in one transaction signed target reparent; set_sign_behavior(Drop); write descendant reparent; write with signing.behavior "Unsigned N commits:"; not-authored-by-you warning; "Rebased N ..." tx.finish("unsign commit ID" or "... and N more")

Filtering to signed commits means unsigned targets aren't rewritten at all and don't appear in the output, and their descendants aren't rebased because of them (source).

4. Core architecture touchpoints

  • Commit writing: Drop makes should_sign false, and the old signature is cleared before writing, so the new Git commit has no signature header (source).
  • Rewrites: each unsigned commit is a rewrite of the signed one; bookmarks follow and the working copy moves if @ was a target. See transactions.
  • Commit model: a commit is signed when its secure_sig is set (is_signed).

5. Transaction and operation

  • Transaction: one.
  • Operation description: unsign commit <id> or unsign commit <first id> and <N> more, with the new IDs (source).
  • Nothing to do: -r 'none()', or only unsigned targets, prints Nothing changed. (test).
  • jj undo brings the signed commits back.

6. Algorithm and invariants

What happens to targets and descendants on jj unsign A signed target always loses its signature. An unsigned target is skipped. A descendant is rewritten with the configured signing.behavior: under keep, a descendant you authored that was signed is signed again; under drop it loses its signature; under own every descendant you authored is signed. signed target always unsigned (Drop) unsigned target skipped, not rewritten descendant per signing.behavior keep: yours and signed → re-signed drop: signature removed own / force: yours / all signed

So under the default keep, unsigning one commit in the middle of a signed stack leaves its descendants signed: they are rewritten, but signed again (test). Unsigning doesn't need your key, but re-signing a descendant does, so the backend must still be usable when descendants are signed.

Invariants: afterwards no target is signed. Unsigned targets are untouched. Contents, descriptions and authors don't change.

7. Errors and edge cases

MessageWhenSource
Warning: N of these commits are not authored by you.Some unsigned commits have another author email.unsign.rs, test
Immutable-commit errorA target is immutable, even if it isn't signed.unsign.rs
Nothing changed.No signed targets.test

8. Worked example

From upstream's test_unsign, with behavior = "keep". All of one, two, three and @ are signed. jj unsign -r @- unsigns three and rebases @, which stays signed:

jj unsign -r @- Before: one fbef1f02, two 8dc06170, three 7b6ad8e6 and the working copy be4609e2, all signed. After: one and two unchanged and signed, three rewritten to 9efcace6 without a signature, and the working copy rebased to 654e12b9 and signed again. before after jj unsign -r @- root fbef1f02 one signed 8dc06170 two signed 7b6ad8e6 three signed be4609e2 @ signed root fbef1f02 one signed 8dc06170 two signed 9efcace6 three unsigned 654e12b9 @ rebased, signed again (keep)

Upstream's output (source):

Unsigned 1 commits:
  kkmpptxz 9efcace6 (empty) three
Rebased 1 descendant commits.
Working copy  (@) now at: zsuskuln 654e12b9 (empty) (no description set)
Parent commit (@-)      : kkmpptxz 9efcace6 (empty) three

Next, jj unsign -r ..@-- unsigns one and two and rebases three (still unsigned) and @ (still signed) (source).