Remote/@git tracking branches
This is a plan to implement more Git-like remote tracking branch UX.
Objective
Section titled “Objective”jj imports all remote branches to local branches by default. As described in
#1136, this doesn’t interact nicely with Git if we have multiple Git remotes
with a number of branches. The git.auto-local-bookmark config can mitigate this
problem, but we’ll get locally-deleted branches instead.
The goal of this plan is to implement
- proper support for tracking/non-tracking remote branches
- logically consistent data model for importing/exporting Git refs
Current data model (as of jj 0.8.0)
Section titled “Current data model (as of jj 0.8.0)”Under the current model, all remote branches are “tracking” branches, and remote changes are merged into the local counterparts.
branches [name]: local_target? remote_targets[remote]: targettags [name]: targetgit_refs ["refs/heads/{name}"]: target # last-known local branches ["refs/remotes/{remote}/{name}"]: target # last-known remote branches # (copied to remote_targets) ["refs/tags/{name}"]: target # last-known tagsgit_head: target?- Remote branches are stored in both
branches[name].remote_targetsandgit_refs["refs/remotes"]. These two are mostly kept in sync, but there are two scenarios where remote-tracking branches and git refs can diverge:jj branch forgetjj op revert/restorein colocated workspace
- Pseudo
@gittracking branches are stored ingit_refs["refs/heads"]. We need special case to resolve@gitbranches, and their behavior is slightly different from the other remote-tracking branches.
Proposed data model
Section titled “Proposed data model”We’ll add a per-remote-branch state to distinguish non-tracking branches
from tracking ones.
state = new # not merged in the local branch or tag | tracking # merged in the local branch or tag# `ignored` state could be added if we want to manage it by view, not by# config file. target of ignored remote branch would be absent.We’ll add a per-remote view-like object to record the last known remote
branches. It will replace branches[name].remote_targets in the current model.
@git branches will be stored in remotes["git"].
branches [name]: targettags [name]: targetremotes ["git"]: branches [name]: target, state # refs/heads/{name} tags [name]: target, state = tracking # refs/tags/{name} head: target?, state = TBD # refs/HEAD [remote]: branches [name]: target, state # refs/remotes/{remote}/{name} tags: (empty) head: (empty)git_refs # last imported/exported refs ["refs/heads/{name}"]: target ["refs/remotes/{remote}/{name}"]: target ["refs/tags/{name}"]: targetWith the proposed data model, we can
- naturally support remote branches which have no local counterparts
- deduplicate
branches[name].remote_targetsandgit_refs["refs/remotes"]
Import/export data flow
Section titled “Import/export data flow” export flow import flow ----------- ----------- +----------------+ --. +------------------->|backing Git repo|---+ : | +----------------+ | : unchanged |[update] |[copy] : on "op restore" | +----------+ | : | +-------------->| git_refs |<------+ : | | +----------+ | --' +--[compare] [diff]--+ | .-- +---------------+ | | --. | : +--->|remotes["git"] | | | : +---: | | |<---+ | : : | |remotes[remote]| | : restored '-- | +---------------+ |[merge] : on "op restore" | | : by default [copy]| +---------------+ | : +----| (local) |<---------+ : | branches/tags | : +---------------+ --'jj git importapplies diff betweengit_refsandremotes[].git_refsis always copied from the backing Git repo.jj git exportcopies jj’sremotesview back to the Git repo. If a ref in the Git repo has been updated since the last import, the ref isn’t exported.jj op restorenever rolls backgit_refs.
Tracking state
Section titled “Tracking state”The git.auto-local-bookmark config knob is applied when importing new remote
branch. jj branch sub commands will be added to change the tracking state.
fn default_state_for_newly_imported_branch(config, remote) { if remote == "git" { State::Tracked } else if config["git.auto-local-bookmark"] { State::Tracked } else { State::New }}A branch target to be merged is calculated based on the state.
fn target_in_merge_context(known_target, state) { match state { State::New => RefTarget::absent(), State::Tracked => known_target, }}Mapping to the current data model
Section titled “Mapping to the current data model”- New
remotes["git"].branchescorresponds togit_refs["refs/heads"], but forgotten branches are removed fromremotes["git"].branches. - New
remotes["git"].tagscorresponds togit_refs["refs/tags"]. - New
remotes["git"].headcorresponds togit_head. - New
remotes[remote].branchescorresponds tobranches[].remote_targets[remote]. state = new|trackingdoesn’t exist in the current model. It’s determined bygit.auto-local-bookmarkconfig.
Common command behaviors
Section titled “Common command behaviors”In the following sections, a merge is expressed as adds - removes.
In particular, a merge of local and remote targets is
[local, remote] - [known_remote].
fetch/import
Section titled “fetch/import”-
jj git fetch- Fetches remote changes to the backing Git repo.
- Import changes only for
remotes[remote].branches[glob](see below)- TODO: how about fetched
.tags?
- TODO: how about fetched
-
jj git import- Copies
git_refsfrom the backing Git repo. - Calculates diff from the known
remotesto the newgit_refs.git_refs["refs/heads"] - remotes["git"].branchesgit_refs["refs/tags"] - remotes["git"].tags- TBD:
"HEAD" - remotes["git"].head(unused) git_refs["refs/remotes/{remote}"] - remotes[remote]
- Merges diff in local
branchesandtagsifstateistracking.- If the known
targetisabsent, the defaultstateshould be calculated. This also applies to previously-forgotten branches.
- If the known
- Updates
remotesreflecting the import. - Abandons commits that are no longer referenced.
- Copies
push/export
Section titled “push/export”-
jj git push- Calculates diff from the known
remotes[remote]to the local changes.branches - remotes[remote].branches- If
stateisnew(i.e. untracked), the known remote branchtargetis consideredabsent. - If
stateisnew, and if the local branchtargetisabsent, the diff[absent, remote] - absentis noop. So it’s not allowed to push deleted branch to untracked remote. - TODO: Copy Git’s
--force-with-leasebehavior?
- If
(not implemented, but should be the same astagsbranches)
- Pushes diff to the remote Git repo (as well as remote tracking branches in the backing Git repo.)
- Updates
remotes[remote]andgit_refsreflecting the push.
- Calculates diff from the known
-
jj git export- Copies local
branches/tagsback toremotes["git"].- Conceptually,
remotes["git"].branches[name].statecan be set to untracked. Untracked local branches won’t be exported to Git. - If
remotes["git"].branches[name]isabsent, the defaultstate = trackingapplies. This also applies to forgotten branches. (not implemented, but should be the same astagsbranches)
- Conceptually,
- Calculates diff from the known
git_refsto the newremotes[remote]. - Applies diff to the backing Git repo.
- Updates
git_refsreflecting the export.
If a ref failed to export at the step 3, the preceding steps should also be rolled back for that ref.
- Copies local
init/clone
Section titled “init/clone”-
jj init- Import, track, and merge per
git.auto_local_branchconfig. - If
!git.auto_local_branch, notrackingstate will be set.
- Import, track, and merge per
-
jj git clone- Import, track, and merge per
git.auto_local_branchconfig. - The default branch will be tracked regardless of
git.auto_local_branchconfig. This isn’t technically needed, but will help users coming from Git.
- Import, track, and merge per
branch
Section titled “branch”jj branch set {name}- Sets local
branches[name]entry.
- Sets local
jj branch delete {name}- Removes local
branches[name]entry.
- Removes local
jj branch forget {name}- Removes local
branches[name]entry if exists. - Removes
remotes[remote].branches[name]entries if exist. TODO: maybe better to not remove non-tracking remote branches?
- Removes local
jj branch track {name}@{remote}(new command)- Merges
[local, remote] - [absent]in local branch.- Same as “fetching/importing existing branch from untracked remote”.
- Sets
remotes[remote].branches[name].state = tracking.
- Merges
jj branch untrack {name}@{remote}(new command)- Sets
remotes[remote].branches[name].state = new.
- Sets
jj branch list- TODO: hide non-tracking branches by default? …
Note: desired behavior of jj branch forget is to
- discard both local and remote branches (without actually removing branches at remotes)
- not abandon commits which belongs to those branches (even if the branch is removed at a remote)
Command behavior examples
Section titled “Command behavior examples”fetch/import
Section titled “fetch/import”- Fetching/importing new branch
- Decides new
state = new|trackingbased ongit.auto_local_branch - If new
stateistracking, merges[absent, new_remote] - [absent](i.e. creates local branch withnew_remotetarget) - Sets
remotes[remote].branches[name].state
- Decides new
- Fetching/importing existing branch from tracking remote
- Merges
[local, new_remote] - [known_remote]
- Merges
- Fetching/importing existing branch from untracked remote
- Decides new
state = new|trackingbased ongit.auto_local_branch - If new
stateistracking, merges[local, new_remote] - [absent] - Sets
remotes[remote].branches[name].state
- Decides new
- Fetching/importing remotely-deleted branch from tracking remote
- Merges
[local, absent] - [known_remote] - Removes
remotes[remote].branches[name](targetbecomesabsent) (i.e. the remote branch is no longer tracked) - Abandons commits in the deleted branch
- Merges
- Fetching/importing remotely-deleted branch from untracked remote
- Decides new
state = new|trackingbased ongit.auto_local_branch - Noop anyway since
[local, absent] - [absent]->local
- Decides new
- Fetching previously-forgotten branch from remote
- Decides new
state = new|trackingbased ongit.auto_local_branch - If new
stateistracking, merges[absent, new_remote] - [absent]->new_remote - Sets
remotes[remote].branches[name].state
- Decides new
- Fetching forgotten and remotely-deleted branch
- Same as “remotely-deleted branch from untracked remote” since forgotten
remote branch should be
state = new - Therefore, no local commits should be abandoned
- Same as “remotely-deleted branch from untracked remote” since forgotten
remote branch should be
- Pushing new branch, remote doesn’t exist
- Pushes
[local, absent] - [absent]->local - Sets
remotes[remote].branches[name].target = local,.state = tracking
- Pushes
- Pushing new branch, untracked remote exists
- Pushes
[local, remote] - [absent]- Fails if
localmoved backwards or sideways
- Fails if
- Sets
remotes[remote].branches[name].target = local,.state = tracking
- Pushes
- Pushing existing branch to tracking remote
- Pushes
[local, remote] - [remote]->local- Fails if
localmoved backwards or sideways, and ifremoteis out of sync
- Fails if
- Sets
remotes[remote].branches[name].target = local
- Pushes
- Pushing existing branch to untracked remote
- Same as “new branch”
- Pushing deleted branch to tracking remote
- Pushes
[absent, remote] - [remote]->absent- TODO: Fails if
remoteis out of sync?
- TODO: Fails if
- Removes
remotes[remote].branches[name](targetbecomesabsent)
- Pushes
- Pushing deleted branch to untracked remote
- Noop since
[absent, remote] - [absent]->remote - Perhaps, UI will report error
- Noop since
- Pushing forgotten branch to untracked remote
- Same as “deleted branch to untracked remote”
- Pushing previously-forgotten branch to remote
- Same as “new branch, untracked remote exists”
- The
targetof forgotten remote branch isabsent
export
Section titled “export”- Exporting new local branch, git branch doesn’t exist
- Sets
remotes["git"].branches[name].target = local,.state = tracking - Exports
[local, absent] - [absent]->local
- Sets
- Exporting new local branch, git branch is out of sync
- Exports
[local, git] - [absent]-> fail
- Exports
- Exporting existing local branch, git branch is synced
- Sets
remotes["git"].branches[name].target = local - Exports
[local, git] - [git]->local
- Sets
- Exporting deleted local branch, git branch is synced
- Removes
remotes["git"].branches[name] - Exports
[absent, git] - [git]->absent
- Removes
- Exporting forgotten branches, git branches are synced
- Exports
[absent, git] - [git]->absentfor forgotten local/remote branches
- Exports
undo fetch
Section titled “undo fetch”- Exporting undone fetch, git branches are synced
- Exports
[old, git] - [git]->oldfor undone local/remote branches
- Exports
- Redoing undone fetch without exporting
- Same as plain fetch since the known
git_refsisn’t diffed against the refs in the backing Git repo.
- Same as plain fetch since the known
@git remote
Section titled “@git remote”jj branch untrack {name}@git- Maybe rejected (to avoid confusion)?
- Allowing this would mean different local branches of the same name coexist in jj and git.
jj git fetch --remote git- Rejected. The implementation is different.
- Conceptually, it’s
git::import_refs()only for local branches.
jj git push --remote git- Rejected. The implementation is different.
- Conceptually, it’s
jj branch trackandgit::export_refs()only for local branches.
Remaining issues
Section titled “Remaining issues”- https://github.com/jj-vcs/jj/issues/1278 pushing to tracked remote
- Option could be added to push to all
trackingremotes?
- Option could be added to push to all
- Track remote branch locally with different name
- Local branch name could be stored per remote branch
- Consider UI complexity
- “private” state (suggested by @ilyagr)
- “private” branches can be pushed to their own remote, but not to the upstream repo
- This might be a state attached to a local branch (similar to Mercurial’s “secret” phase)