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
| Key | Default | Effect |
|---|---|---|
git.abandon-unreachable-commits | true | Abandon commits that were reachable from the old Git refs and no longer are (default, schema). |
git.record-synthetic-predecessors | true | Link newly imported commits to older commits with the same change ID (default, schema). |
remotes.<name>.auto-track-bookmarks | "~*" per the schema | Which 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
- Colocated check. In a colocated workspace, the snapshot at the start of every command
imports Git's
HEADand refs itself, in its ownimport git headandimport git refsoperations, and printsDone 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). - 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).
- Diff. See §6.
- 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.
- Refs. See §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. Thentx.finishrebases descendants (Rebased N descendant commits.) and records the operation (source).
4. Core architecture touchpoints
- Git backend:
import_head_commitscopies 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):
5. Transaction and operation
- Transaction: one, finished as
import git refs(source). If nothing changed, it printsNothing 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(orjj undo) fully reverses an import's bookmarks and commits, because it only changed jj's own state. It keepsgit_refs, though (source), so runningjj git importagain finds nothing new and printsNothing changed.; runjj git exportto 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
| Message | When | Source |
|---|---|---|
No import needed in colocated workspaces. | Colocated, working copy writable. | import.rs |
Warning: Failed to import some Git refs: with the names | Non-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 missing | A 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:
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).