jj / How jj git import works

How jj git import works

jj git import reads the branches and tags in the backing Git repository and brings jj's view up to date with them: it imports new commits and moves bookmarks and tags. It also abandons commits that Git no longer reaches. In a colocated workspace this already happens at the start of 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. Remote refs and local refs
  9. Abandoning and rewrites
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: jj lists Git's refs/heads/*, refs/remotes/*/* and refs/tags/* and compares them with the Git refs it last saw (git_refs) and with its remote bookmarks and tags. For each change it imports the commits, updates the remote ref (NAME@git for local Git branches and tags), and, if that remote ref is tracked, merges the change into the local bookmark or tag. Commits that only the old ref targets reached are abandoned. New commits that share a change ID with old ones are recorded as their rewrites. Everything happens in one operation, import git refs.

The command is cmd_git_import and the work is jj_lib::git::import_refs. jj git fetch shares its second half (import_refs_inner) but computes its diff from jj's private fetch refs instead (diff_fetched_refs_to_import). The reverse direction is jj git export. Bookmarks, tags and tracking are described on the view page.

1. Command-line surface

jj git import takes no arguments of its own (GitImportArgs). Like jj git export, it is skipped in a colocated workspace unless the working copy isn't writable: with --ignore-working-copy, or at an operation other than the head (source, is_working_copy_writable).

2. Configuration

KeyDefaultEffect
git.abandon-unreachable-commitstrueAbandon commits that were reachable from the old Git refs and no longer are (default, schema).
git.record-synthetic-predecessorstrueLink newly imported commits to older commits with the same change ID (default, schema).
remotes.<name>.auto-track-bookmarks"~*" per the schemaWhich newly seen remote bookmarks start out tracked (schema).

The three are gathered into GitImportOptions by load_git_import_options (source).

3. End-to-end flow

jj git import: call flow cmd_git_import loads the workspace and, in a colocated workspace with a writable working copy, prints No import needed and stops. Otherwise it loads the import options and starts a transaction. import_refs registers remotes from the Git config, diffs Git's refs against git_refs and the remote refs, imports the new head commits, records the new Git ref values, updates remote bookmarks and tags and merges tracked ones into local ones, abandons commits no longer reachable, and records synthetic predecessors. The command prints the ref changes, and tx.finish rebases descendants and records import git refs. colocated and working copy writable → "No import needed in colocated workspaces." load GitImportOptions; start a transaction import_refs(tx.repo_mut(), options) ensure_remote for every Git remote; diff_refs_to_import import the new head commits into the backend and index; add them as heads set git_refs; set each remote bookmark/tag if tracked: merge_local_bookmark / merge_local_tag (3-way, base = old tracked target) abandon_unreachable_commits if enabled and old refs changed record_synthetic_predecessors match by change ID; mark rewrites print_git_import_stats; tx.finish("import git refs") finish rebases descendants of rewritten or abandoned commits
  1. Colocated check. In a colocated workspace, the snapshot at the start of every command imports Git's HEAD and refs itself, in its own import git head and import git refs operations, and prints Done importing changes from the underlying Git repo. (snapshot, import_export_git_refs). When the import finds nothing new, that function still re-exports refs, so an export that failed earlier is retried on the next command. The command then has nothing to add (source).
  2. Remotes. Every remote in the Git config gets an entry in the view, even one with no refs yet, so its absent refs can be tracked (source).
  3. Diff. See §6.
  4. Commits. The new ref targets that jj's index doesn't have are imported in bulk. If that fails, each one is retried alone so the error names the ref (source). They are indexed and added as visible heads.
  5. Refs. See §6.
  6. Report and finish. Each changed ref is printed, for example bookmark: a@git [new] tracked, followed by abandoned and rewritten commits (print_imported_changes) and refs that couldn't be imported. Then tx.finish rebases descendants (Rebased N descendant commits.) and records the operation (source).

4. Core architecture touchpoints

  • Git backend: import_head_commits copies commits Git already has into jj's backend metadata; see the commits page.
  • Index: imported commits are indexed so revsets can see them.
  • View: git_refs, remote bookmarks and tags, local bookmarks and tags, and the visible heads all change here; see the view page.
  • Rewrites: abandoned commits and synthetic predecessors are recorded on the transaction, so descendants are rebased when it finishes; see transactions.

Git refs map to jj like this (parse_git_ref):

Which Git refs become which jj refs on import refs/heads/NAME becomes the remote bookmark NAME@git, which is always tracked, so it is merged into the local bookmark NAME. refs/remotes/REMOTE/NAME becomes NAME@REMOTE, which is tracked only if it already was or auto-track-bookmarks matches it. refs/tags/NAME becomes the tag NAME@git, always tracked, so it is merged into the local tag NAME. refs/remotes/git/ refs are reserved and fail to import; HEAD refs are skipped. Git refs jj remote ref if tracked, merged into refs/heads/NAME refs/remotes/REMOTE/NAME refs/tags/NAME NAME@git (always tracked) NAME@REMOTE new: tracked if auto-track tag NAME@git (tracked) local bookmark NAME merge_local_bookmark local tag NAME refs/remotes/git/*: reserved, reported as failed · */HEAD and refs/heads/HEAD: skipped · non-UTF-8 names: failed

5. Transaction and operation

  • Transaction: one, finished as import git refs (source). If nothing changed, it prints Nothing changed. and records nothing (source).
  • Commits: new commits are imported, not created; old ones may be abandoned, and their descendants rebased.
  • Undo: jj op restore (or jj undo) fully reverses an import's bookmarks and commits, because it only changed jj's own state. It keeps git_refs, though (source), so running jj git import again finds nothing new and prints Nothing changed.; run jj git export to make Git match the restored state instead (test).

6. Algorithm and invariants

The diff

diff_refs_to_import starts from what jj knew: the git_refs, the refs it last saw in Git (source). It then walks Git's local branches, remote branches and tags. Each Git ref that resolves to a commit is removed from the known set, and if its value differs from git_refs it is a changed Git ref (collect_changed_git_refs_to_import). Whatever is left in the known set has disappeared from Git and is changed to absent. Only then are remote refs compared: each changed Git ref whose new value differs from its remote bookmark or tag becomes a remote ref update (source). Refs Git didn't move are never compared, so a bookmark that differs from Git only because an export failed, or because an undo rewound jj, isn't treated as an external change. Refs that don't point to a commit are skipped, not reported. Results are sorted so merges and output are stable.

Remote refs and local refs

A remote ref keeps its old tracking state; a remote ref seen for the first time gets the default from default_remote_ref_state_for: @git bookmarks and all tags are tracked, other remote bookmarks only if auto-track-bookmarks matches. For tracked refs, the change is merged into the local bookmark or tag as a 3-way merge whose base is the remote ref's old tracked target. So if both jj and Git moved a bookmark, the local bookmark becomes conflicted rather than one side winning. The remote ref itself is simply set to the new value (source).

Abandoning and rewrites

With abandon-unreachable-commits, jj abandons the commits that were ancestors of the old ref targets but are no longer ancestors of anything pinned: local bookmarks and tags, untracked remote refs that are still visible, or the root (abandon_unreachable_commits, pinning). Tracked remote refs don't pin, because their changes were merged into the local refs.

With record-synthetic-predecessors, each newly imported commit whose change ID also appears among the commits that became unreachable gets that old commit as its predecessor. If abandoning is also on, the old commit is marked rewritten into the new one, or divergently rewritten if there are several. Such commits are then not reported as abandoned (record_synthetic_predecessors, source). This is how a commit amended with plain Git shows up as a rewrite of the jj commit, and why its descendants are rebased onto it when the transaction finishes.

Invariants: after an import, every Git branch and tag that resolves to a commit is mirrored in the remote refs and git_refs, and its commits are visible. Tracked changes are merged, never overwritten. git_refs holds exactly what jj last saw in Git.

7. Errors and edge cases

MessageWhenSource
No import needed in colocated workspaces.Colocated, working copy writable.import.rs
Warning: Failed to import some Git refs: with the namesNon-UTF-8 ref names, or refs under refs/remotes/git/.git_util.rs
Hint: Git remote named 'git' is reserved for local Git repository.A Git remote is called git; rename it with jj git remote rename.git_util.rs
Ancestor of Git ref <symbol> is missingA ref's commit or its ancestors can't be read from Git.git.rs
Abandoned N commits that are no longer reachable:Old ref targets were rewritten or deleted in Git.git_util.rs

8. Worked example

From upstream's test_git_import_move_export_with_default_undo, in a non-colocated repo. The test creates refs/heads/a directly in the Git repo, pointing at the working-copy commit e8849ae1. jj has no bookmarks yet. jj git import imports it as a@git, tracked, and the merge creates the local bookmark a:

Bookmark a before and after jj git import Before: the Git ref refs/heads/a points at e8849ae1, the working-copy commit, and jj has no bookmarks. After the import, e8849ae1 carries the remote bookmark a@git and the local bookmark a, merged from it. before after jj git import root e8849ae1 @ Git refs/heads/a (jj: no bookmarks) root e8849ae1 @ a, a@git (tracked)

Upstream's output and the bookmark listing (source):

bookmark: a@git [new] tracked

a: qpvuntsm e8849ae1 (empty) (no description set)
  @git: qpvuntsm e8849ae1 (empty) (no description set)

Later in the test, after jj moves and exports a and then jj op restore goes back to before the first import, Git still has a at the moved commit. Importing again prints Nothing changed.: git_refs already matches Git, so nothing looks new. A jj git export then deletes refs/heads/a from Git, matching the restored state (source).