jj / How jj git export works

How jj git export works

jj git export writes jj's bookmarks and tags into the backing Git repository as Git refs. In a colocated workspace this already happens after every command, so the command only matters for non-colocated workspaces or with --ignore-working-copy.

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. The diff
  8. Compare-and-swap writes
  9. 7. Errors and edge cases
  10. 8. Worked example

In short: jj compares every local bookmark, remote bookmark and local tag with the Git ref it last wrote or read (the view's git_refs). For each difference it creates, moves or deletes the Git ref, but only if Git still has the value jj expects. Refs Git changed in the meantime, refs on the root commit and names Git can't store are reported as failures and left alone. Successfully exported local refs are copied to the git remote (main@git). Everything happens in one operation, export git refs.

The command is cmd_git_export and the work is jj_lib::git::export_refs. The reverse direction is jj git import. How bookmarks, tags and git_refs are stored is on the view page.

1. Command-line surface

jj git export takes no arguments of its own (GitExportArgs). Two global flags change what it does:

Global flagEffectSource
--ignore-working-copyForces the export in a colocated workspace, where it would otherwise be skipped.export.rs, is_working_copy_writable
--at-op (not the head operation)Also counts as "working copy not writable", so the export runs even when colocated.cli_util.rs
--no-integrate-operationRefused: --no-integrate-operation is not respected, because exported refs shouldn't be left unintegrated.export.rs

2. Configuration

No configuration key changes how refs are exported. Colocation decides whether the command is needed: in a colocated workspace every command's transaction already calls export_refs when it finishes (source), and the Git import at the start of a command exports again even when it imported nothing, so a failed export is retried (source). Colocation is set when the repo is created; see jj git clone.

3. End-to-end flow

jj git export: call flow cmd_git_export refuses --no-integrate-operation, loads the workspace, and in a colocated workspace with a writable working copy prints No export needed and stops. Otherwise it starts a transaction and calls export_refs, which diffs jj's refs against git_refs, detaches Git HEAD if it points at a ref about to change, deletes and then updates Git refs, records each success in git_refs, and copies exported local refs to the git remote. The transaction finishes as export git refs, then failures are printed. refuse --no-integrate-operation; load the workspace colocated and working copy writable → "No export needed in colocated workspaces." export_refs(tx.repo_mut()) diff_refs_to_export: jj's refs (new) against view git_refs (old) detach Git HEAD (and each worktree's HEAD) if it points at a ref that will change export_refs_to_git batched deletions, then creates/moves; set_git_ref_target on success copy exported local refs to NAME@git, state Tracked (failed names are skipped) tx.finish("export git refs"), then print_git_export_stats (warnings for failures)
  1. Colocated check. Git refs are imported during the snapshot, so jj assumes there is nothing to export and prints No export needed in colocated workspaces. (source).
  2. Diff. See §6.
  3. HEAD. If Git's HEAD is a symbolic ref to a branch or tag whose value is about to change, jj detaches it at its current commit first, so moving the branch doesn't move Git's checkout. An unborn branch is pointed at the placeholder refs/jj/root (source, update_git_head).
  4. Write. For each kind (bookmarks, then tags), deletions are applied first so a new ref can't collide on disk with one being removed, then creations and moves (source). Every write is a compare-and-swap with the reflog message export from jj; see §6.
  5. Remember. Each success updates git_refs in the view, and local refs that exported cleanly are copied to NAME@git (source).

4. Core architecture touchpoints

  • View: reads local bookmarks, remote bookmarks, local tags and git_refs; writes git_refs and the @git remote refs. See the view page.
  • Git repository: refs are written directly with gix; no git subprocess runs (source).
  • Commits: none are created or rewritten. Commits are already in the Git object store because the Git backend writes every commit there; see the commits page.

Refs map to Git like this (to_git_ref_name):

Which jj refs become which Git refs on export A local bookmark NAME is exported to refs/heads/NAME. A remote bookmark NAME@REMOTE, for any remote except git, is exported to refs/remotes/REMOTE/NAME. A local tag NAME is exported to refs/tags/NAME as a lightweight tag, or by copying an annotated tag object a tracked remote tag already points to. Remote tags are not exported because Git has no remote tags. jj view Git refs local bookmark NAME remote bookmark NAME@REMOTE local tag NAME remote tags: not exported refs/heads/NAME refs/remotes/REMOTE/NAME refs/tags/NAME lightweight, or a copied annotated tag

5. Transaction and operation

  • Transaction: one, finished as export git refs (source). If the view didn't change, it prints Nothing changed. and records nothing (source).
  • Commits: none.
  • jj undo restores git_refs and the @git refs, but not the refs in the Git repo: those stay where the export put them (test). Running jj git export again rewrites the @git refs (test).

6. Algorithm and invariants

The diff

diff_refs_to_export builds an (old, new) pair per symbol. New is the local bookmark or tag (as NAME@git), or the remote bookmark for other remotes; old is the matching entry in git_refs. A ref in git_refs with no jj counterpart gets an absent new value and is deleted. A comment names the two ways remote refs fall out of sync this way: jj bookmark forget --include-remotes, and jj op revert or restore in a colocated repo (source).

Each pair is then classified (collect_changed_refs_to_export):

Old (git_refs)New (jj)Result
anysame as oldskipped
anythe root commitfailed: Ref cannot point to the root commit in Git
conflictedanything elsefailed: Ref was in a conflicted state from the last import
absent or one commitone commitcreate or move
absent or one commitconflictedskipped; git_refs keeps the old value
one commitabsentdelete

Compare-and-swap writes

jj never overwrites a change made in Git. A create requires the ref not to exist; if Git already created it at the same commit, that still counts as success (create_git_ref). A move requires the old commit; if Git already moved it to the new commit, that is fine, and if the ref points at an annotated tag of the old commit, jj retries against that object (move_git_ref). A delete only removes a ref that still resolves to the old commit, and a ref already gone counts as deleted (delete_git_refs). The deletes are gathered as ref edits that each expect the old target (remove_ref) and applied in one edit_references transaction; if that fails, jj retries them one at a time and records each failure (export_refs_to_git). Creates and moves are still written one at a time. New tags are lightweight unless a tracked remote tag with the same name points at the same commit through an annotated tag object; then that object is copied (find_git_tag_oid_to_copy).

Invariants: after the export, git_refs records exactly what jj last wrote, so the next import can tell Git's own changes apart from jj's. A failed ref keeps its old git_refs value and doesn't get an @git copy, so it is retried on the next export. Conflicted bookmarks and tags are never exported.

7. Errors and edge cases

Failures don't stop the command: they're collected per ref and printed as warnings after the transaction is recorded (print_git_export_stats). Reasons come from FailedRefExportReason:

MessageWhen
Name is not allowed in GitNo Git ref name can be formed, e.g. a bookmark named HEAD.
Ref was in a conflicted state from the last importOld value in git_refs is a conflict; a re-import should fix it.
Ref cannot point to the root commit in GitThe bookmark or tag is on the root commit.
Deleted ref had been modified in GitDeleted in jj, moved in Git.
Added ref had been added with a different target in GitCreated in both, at different commits.
Modified ref had been deleted in GitMoved in jj, deleted in Git.
Failed to set / Failed to deleteThe Git write failed. For Failed to set, a hint explains that Git can't have both foo and foo/bar (test).
--no-integrate-operation is not respected (exit 2)That global flag was given (test).

8. Worked example

From upstream's test_git_import_move_export_with_default_undo, in a non-colocated repo. Bookmark a was imported from Git at e8849ae1. Then jj new and jj bookmark set a --to=@ move it to e7d0d5fd, so a@git is one commit behind. jj git export prints nothing and moves both:

Bookmark a before and after jj git export Before: local bookmark a is on e7d0d5fd, while a@git and the Git ref refs/heads/a are on its parent e8849ae1. After the export, refs/heads/a has been moved from e8849ae1 to e7d0d5fd, and a@git is copied from the local bookmark, so all three agree. before after jj git export root e8849ae1 a@git, Git refs/heads/a e7d0d5fd @ a root e8849ae1 e7d0d5fd @ a, a@git, Git refs/heads/a

Bookmark listing before and after (source):

a: royxmykx e7d0d5fd (empty) (no description set)
  @git (behind by 1 commits): qpvuntsm e8849ae1 (empty) (no description set)

a: royxmykx e7d0d5fd (empty) (no description set)
  @git: royxmykx e7d0d5fd (empty) (no description set)

The test then runs jj op restore back to before the import. The bookmarks disappear from jj, but Git's refs/heads/a stays at e7d0d5fd. Because the restore kept git_refs, the next jj git import sees no change in Git and prints Nothing changed., and a second jj git export deletes refs/heads/a so Git matches the restored state (source).