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 flag | Effect | Source |
|---|---|---|
--ignore-working-copy | Forces 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-operation | Refused: --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
- 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). - Diff. See §6.
- HEAD. If Git's
HEADis 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 placeholderrefs/jj/root(source,update_git_head). - 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. - Remember. Each success updates
git_refsin the view, and local refs that exported cleanly are copied toNAME@git(source).
4. Core architecture touchpoints
- View: reads local bookmarks, remote bookmarks, local tags and
git_refs; writesgit_refsand the@gitremote refs. See the view page. - Git repository: refs are written directly with
gix; nogitsubprocess 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):
5. Transaction and operation
- Transaction: one, finished as
export git refs(source). If the view didn't change, it printsNothing changed.and records nothing (source). - Commits: none.
jj undorestoresgit_refsand the@gitrefs, but not the refs in the Git repo: those stay where the export put them (test). Runningjj git exportagain rewrites the@gitrefs (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 |
|---|---|---|
| any | same as old | skipped |
| any | the root commit | failed: Ref cannot point to the root commit in Git |
| conflicted | anything else | failed: Ref was in a conflicted state from the last import |
| absent or one commit | one commit | create or move |
| absent or one commit | conflicted | skipped; git_refs keeps the old value |
| one commit | absent | delete |
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:
| Message | When |
|---|---|
Name is not allowed in Git | No Git ref name can be formed, e.g. a bookmark named HEAD. |
Ref was in a conflicted state from the last import | Old value in git_refs is a conflict; a re-import should fix it. |
Ref cannot point to the root commit in Git | The bookmark or tag is on the root commit. |
Deleted ref had been modified in Git | Deleted in jj, moved in Git. |
Added ref had been added with a different target in Git | Created in both, at different commits. |
Modified ref had been deleted in Git | Moved in jj, deleted in Git. |
Failed to set / Failed to delete | The 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 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).