jj / How jj git push works

How jj git push works

jj git push works out which bookmarks and tags to update on a remote, checks every commit it would send, and runs one git push per remote. Every ref is pushed with --force-with-lease, using what jj last fetched as the lease. Afterwards jj's remote bookmarks and tags are updated to match.

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. Classifying a ref
  8. Force-with-lease
  9. Commit validation
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: jj picks the remotes. For each one, it collects ref updates from the flags, or by default from the revsets.git-push revset (remote_bookmarks(remote=…)..@). --change and --named first create local bookmarks. Each candidate ref is classified against its tracked remote ref: no change, conflicted, untracked, or an update. Every commit between the remote's known heads and the new targets is validated: it needs a description, an author and committer, no conflicts, and it can't be private. If git.sign-on-push is set, your unsigned mutable commits are signed and recorded as their own operation first. jj prints Changes to push to <remote>:, runs git push --porcelain --no-verify --force-with-lease=…, updates the pushed remote refs, and records push … to git remote ….

The command is cmd_git_push. The decision for each ref is classify_ref_push_action in jj-lib. The push itself is push_refs. Remote bookmarks and their tracking state live in the view (view page). The inverse command is jj git fetch.

1. Command-line surface

FlagTypeDefaultMeaning
--remote REMOTEname patterns, repeatablegit.push, else the only remote, else originRemotes to push to, globs by default (source).
-b, --bookmarkname patterns, repeatablenonePush matching bookmarks. Creating a new remote bookmark is allowed if the name isn't tracked on any remote yet, and deleting is allowed because the name was given explicitly (source, params).
-t, --tagname patterns, repeatablenonePush matching tags, with the same rules (source).
--allbooloffEvery bookmark and tag, including new ones (source).
--trackedbooloffEvery tracked bookmark and tag (source).
--deletedbooloffPush deletions of bookmarks and tags deleted locally. With --all/--tracked, it allows deletions in that set (source).
-r, --revisions REVSETSrevsets, repeatablenonePush bookmarks and tags pointing at these commits (source).
-c, --change REVSETSrevsets, repeatablenoneCreate a bookmark named by templates.git_push_bookmark on each commit, then push it (source).
--named NAME=REVISIONrepeatablenoneCreate a new bookmark NAME on one commit and push it (source).
--allow-empty-description, --allow-private, --allow-conflictsbooloffSkip one of the commit checks (source).
--dry-runbooloffPrint the changes and stop (source).
-o, --optionstring, repeatablenonePassed to Git as --push-option (source).

Two clap groups shape the flags. --bookmark, --tag, -r, --change and --named form the "specific" group, and --all and --tracked form a "what" group that conflicts with it (source). The global --no-integrate-operation is rejected with a hint to use --dry-run (source).

GitPushArgs

2. Configuration

KeyDefaultEffect
git.pushunsetRemote name pattern, or a list of them, used when --remote isn't given (source, docs).
revsets.git-pushremote_bookmarks(remote=exact:remote)..@The default selection, with remote bound to each remote's name (default, evaluation).
templates.git_push_bookmark"push-" ++ change_id.short()Names the bookmarks --change creates (default).
git.private-commitsnone()A revset of commits that must not be pushed (default).
git.sign-on-pushfalseSign unsigned mutable commits authored by you (author email equal to user.email) before pushing, whatever signing.behavior says (source, filter, should_sign).
revset-aliases."immutable_heads()"(built-in)Immutable commits are treated as already validated, and never signed.
git.executable-pathgitThe Git binary jj runs.

3. End-to-end flow

jj git push: call flow cmd_git_push loads the workspace and picks the remotes, failing if none match. It starts a transaction. With --all, --tracked or --deleted it classifies every matching ref. Otherwise it creates --change and --named bookmarks, then collects explicit bookmarks and tags, validates their commits strictly, and adds refs pointing into the target revisions (revsets.git-push or -r). Each ref goes through classify_ref_push_action and the commit validator. If nothing is left it prints "Nothing changed.". It optionally signs commits, prints the changes per remote, and runs push_refs, which uses git push with --force-with-lease and then updates the remote refs in the view. If anything was pushed, tx.finish records "push … to git remote …". load workspace (snapshot); remotes = --remote | git.push | only remote | origin none match → "No git remotes to push to"; start_transaction --all / --tracked / --deleted every local+remote ref pair (tracked only, or deleted only) rejected refs are warned about and skipped otherwise, per remote --change / --named → create local bookmarks first explicit bookmarks and tags → a rejection is an error refs pointing into revsets.git-push or -r → only new or tracked updates; rejections are warned about and skipped classify_ref_push_action(local, remote) equal → nothing; conflicted → reject remote present but untracked → reject else Update(remote → local); new/delete gated CommitsValidator (remote heads | immutable)..new targets: description, author/committer, no conflicts, not in git.private-commits nothing to push → "Nothing changed."; git.sign-on-push → sign and rebase descendants "Changes to push to R:" "bookmark: name [move forward from A to B]" / [add to B] / [delete from A] push_refs → git push --porcelain --no-verify --force-with-lease=refs/heads/N:<expected> -- R <new>:refs/heads/N pushed refs → update refs/jj/remote-* and set name@R to the new target, Tracked all ok or some pushed → tx.finish("push … to git remote R"); any failure → "Failed to push some bookmarks"

4. Core architecture touchpoints

StructureRead / writeHowExplained on
View.local_bookmarks, local_tagsread, maybe writeThe local side of every comparison. --change and --named add new bookmarks (create_change_bookmarks, named).view
View.remote_viewsread, writeThe tracked remote target is both the comparison base and the lease. Pushed refs become Tracked at their new target (source).view
Git refs refs/jj/remote-*writePushed bookmarks update jj's private fetch refs under refs/jj/remote-bookmarks/, and pushed tags update refs/jj/remote-tags/; a failure there is only logged (source, to_fetched_ref_update). Git's own refs/remotes/<remote>/* are left alone: the subprocess is given a negative fetch refspec so git push doesn't update them (source). In a non-colocated workspace, jj git export writes them.view
Commitsread, maybe rewriteValidated. With git.sign-on-push, your unsigned mutable commits are rewritten with signatures and their descendants rebased, and the ref updates are remapped to the new IDs (sign_commits_before_push). If anything was signed, the signatures are committed before the push starts (source).commits
OperationwriteOne operation if anything was pushed, preceded by a sign commits before push operation if commits were signed. is_push_operation recognises it by its push prefix (source).operations

5. Transaction and operation

  • Transaction: one, started right after the remotes are chosen (source). Created bookmarks and updated remote refs go into it.
  • Signing: if git.sign-on-push signed anything, the bookmarks --change/--named created are taken out, the transaction is committed as sign commits before push, and a new transaction is started with those bookmarks put back (source). A failed push keeps the signatures but still drops the created bookmarks; if nothing needed signing, there is no extra operation (docs).
  • When it is committed: only if every push succeeded or at least one ref was pushed (source). --dry-run and "Nothing changed." return without committing, so bookmarks created by --change are dropped. The source notes that on partial success those bookmarks are kept.
  • Operation description: push all bookmarks/tags to …, push all tracked bookmarks/tags to …, push all deleted bookmarks/tags to …, or push bookmark X / push bookmarks X, Y / push tag T followed by to git remote R (or to git remotes R1, R2) (make_updates_term, source).
  • Working copy: untouched, unless signing rebased @.
  • jj undo restores the old remote-tracking state locally. It doesn't unpush anything from the remote, and it doesn't undo the signatures, which are a separate operation.

6. Algorithm and invariants

Classifying a ref

classify_ref_push_action compares the local target with the remote ref's tracked target, which is absent when the remote ref is untracked:

  1. Equal: nothing to push.
  2. Local conflicted, or remote conflicted: rejected.
  3. Remote present but not tracked: rejected (Non-tracking remote bookmark … exists).
  4. Otherwise, Update(before = remote, after = local), where either side may be absent.

The command then gates updates (classify_bookmark_update). Creating an untracked remote ref needs allow_new, and a deletion needs allow_delete. The default and -r paths set neither, so they only update refs already tracked on that remote. That includes a tracked name the remote doesn't have yet, which is created. --bookmark allows creation only for names tracked nowhere (source).

Force-with-lease

Every update is a forced refspec, guarded by a lease on the expected remote value, the before side. An empty lease means "must not exist yet" (push_updates, RefToPush::to_git_lease). Git ignores leases on +-prefixed refspecs, so the refspecs are passed unforced (spawn_push). --no-verify skips Git hooks. If the remote ref moved since the last fetch, Git rejects it and jj reports it under The following references unexpectedly moved on the remote (source). This is how jj avoids overwriting someone else's push without a separate fetch.

Commit validation

validate_commits walks (known remote heads | immutable_heads())..new_heads, where the known heads are the targets of the remote's bookmarks and tags, so a commit already on the remote as a tag isn't re-checked (source). It rejects the first commit that has an empty description, a missing author or committer name or email, a conflict, or that is private. Validated heads are added to the known heads, so later refs don't re-check shared ancestry. Refs named explicitly fail the whole command. Refs picked up by the default revset or -r are skipped with Won't push bookmark … (source).

Invariants: the remote is only changed where its current value equals jj's last recorded one. Untracked remote refs are never overwritten. Conflicted bookmarks are never pushed. The default selection never deletes a remote ref, and never creates one for a name that isn't tracked.

7. Errors and edge cases

MessageWhenSource
No git remotes to push toThe remote selection matched nothing.push.rs
Nothing changed.No ref needs updating on any remote.push.rs
Bookmark <name> is conflicted / Bookmark <name>@<remote> is conflictedThe local or remote target has several sides.push.rs
Non-tracking remote bookmark <name>@<remote> existsPushing over an untracked remote bookmark. The hint suggests jj bookmark track.push.rs
Refusing to create new remote bookmark <name>@<remote>Creating a remote bookmark from a path that doesn't allow new ones.push.rs
Refusing to push deleted bookmark <name>A deletion without --deleted or an explicit name.push.rs
Won't push commit <id> since it has no description (or has conflicts, is private, has no author and/or committer set)An explicitly selected ref would send a failing commit.push.rs
Bookmark already exists: <name>--named, or a --change name already in use elsewhere.push.rs
Argument '…' must include '=' and have the form NAME=REVISIONA malformed --named value.push.rs
Warning: No bookmarks/tags found in revsets.git-push: … (remote=…)The default revset selected no refs, for example after moving a bookmark backwards.push.rs
The following references unexpectedly moved on the remote: … (reason: stale info)The lease failed. The command ends with Failed to push some bookmarks.git_util.rs, push.rs
The remote rejected the following updates:The server refused, for example a protected branch.git_util.rs
--no-integrate-operation is not respected; use --dry-runThat global flag was passed.push.rs

8. Worked example

From upstream's test_git_push_current_bookmark. bookmark2@origin is on 38a20473. Locally, bookmark2 and a new my-bookmark are on its child 88ca14a7, which is @. bookmark1 was rewritten to e5ce6d9a on a side branch. Then jj git push uses the default revset remote_bookmarks(remote=origin)..@:

jj git push with the default revset Before: root has two children, e5ce6d9a with local bookmark1 (bookmark1@origin is on the hidden older version 9b2e76de) and 38a20473 with bookmark2@origin. 38a20473's child 88ca14a7 is @, with bookmark2 and my-bookmark. After jj git push: bookmark2@origin and my-bookmark@origin are on 88ca14a7. bookmark1 is not an ancestor of @, so it isn't pushed. before after jj git push root 38a20473 bookmark2@origin @ 88ca14a7 bookmark2 my-bookmark e5ce6d9a bookmark1 (not pushed) root 38a20473 @ 88ca14a7 bookmark2 my-bookmark (+ @origin) e5ce6d9a bookmark1 (not pushed)

Upstream's snapshot:

$ jj git push
Changes to push to origin:
  bookmark: bookmark2 [move forward from 38a204733702 to 88ca14a7d46f]
  bookmark: my-bookmark [add to 88ca14a7d46f]

my-bookmark is new on the remote, but the setup configures auto-track-bookmarks = '*', so it was already tracked and counts as an update. Later in the same test, after bookmark2 is moved back to its parent, plain jj git push finds nothing (No bookmarks/tags found in revsets.git-push), and jj git push -b bookmark2 pushes [move backward from 88ca14a7d46f to 38a204733702] (source). If someone else had moved the bookmark on the remote, the lease would fail instead, as in test_git_push_forward_unexpectedly_moved.