docs/design/tracking-branches.md
@git tracking branchesThis is a plan to implement more Git-like remote tracking branch UX.
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
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]: target
tags
[name]: target
git_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 tags
git_head: target?
branches[name].remote_targets and
git_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/restore in colocated workspace@git tracking branches are stored in git_refs["refs/heads"]. We
need special case to resolve @git branches, and their behavior is slightly
different from the other remote-tracking branches.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]: target
tags
[name]: target
remotes
["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}"]: target
With the proposed data model, we can
branches[name].remote_targets and git_refs["refs/remotes"] 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 import applies diff between git_refs and remotes[]. git_refs is
always copied from the backing Git repo.jj git export copies jj's remotes view 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 restore never rolls back git_refs.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,
}
}
remotes["git"].branches corresponds to git_refs["refs/heads"], but
forgotten branches are removed from remotes["git"].branches.remotes["git"].tags corresponds to git_refs["refs/tags"].remotes["git"].head corresponds to git_head.remotes[remote].branches corresponds to
branches[].remote_targets[remote].state = new|tracking doesn't exist in the current model. It's determined
by git.auto-local-bookmark config.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].
jj git fetch
remotes[remote].branches[glob] (see below)
.tags?jj git import
git_refs from the backing Git repo.remotes to the new git_refs.
git_refs["refs/heads"] - remotes["git"].branchesgit_refs["refs/tags"] - remotes["git"].tags"HEAD" - remotes["git"].head (unused)git_refs["refs/remotes/{remote}"] - remotes[remote]branches and tags if state is tracking.
target is absent, the default state should be
calculated. This also applies to previously-forgotten branches.remotes reflecting the import.jj git push
remotes[remote] to the local changes.
branches - remotes[remote].branches
state is new (i.e. untracked), the known remote branch target
is considered absent.state is new, and if the local branch target is absent, the
diff [absent, remote] - absent is noop. So it's not allowed to push
deleted branch to untracked remote.--force-with-lease behavior?tagsbranches)remotes[remote] and git_refs reflecting the push.jj git export
branches/tags back to remotes["git"].
remotes["git"].branches[name].state can be set to
untracked. Untracked local branches won't be exported to Git.remotes["git"].branches[name] is absent, the default
state = tracking applies. This also applies to forgotten branches.tagsbranches)git_refs to the new remotes[remote].git_refs reflecting the export.If a ref failed to export at the step 3, the preceding steps should also be rolled back for that ref.
jj init
git.auto_local_branch config.!git.auto_local_branch, no tracking state will be set.jj git clone
git.auto_local_branch config.git.auto_local_branch
config. This isn't technically needed, but will help users coming from Git.jj branch set {name}
branches[name] entry.jj branch delete {name}
branches[name] entry.jj branch forget {name}
branches[name] entry if exists.remotes[remote].branches[name] entries if exist.
TODO: maybe better to not remove non-tracking remote branches?jj branch track {name}@{remote} (new command)
[local, remote] - [absent] in local branch.
remotes[remote].branches[name].state = tracking.jj branch untrack {name}@{remote} (new command)
remotes[remote].branches[name].state = new.jj branch list
Note: desired behavior of jj branch forget is to
state = new|tracking based on git.auto_local_branchstate is tracking, merges [absent, new_remote] - [absent]
(i.e. creates local branch with new_remote target)remotes[remote].branches[name].state[local, new_remote] - [known_remote]state = new|tracking based on git.auto_local_branchstate is tracking, merges [local, new_remote] - [absent]remotes[remote].branches[name].state[local, absent] - [known_remote]remotes[remote].branches[name] (target becomes absent)
(i.e. the remote branch is no longer tracked)state = new|tracking based on git.auto_local_branch[local, absent] - [absent] -> localstate = new|tracking based on git.auto_local_branchstate is tracking, merges
[absent, new_remote] - [absent] -> new_remoteremotes[remote].branches[name].statestate = new[local, absent] - [absent] -> localremotes[remote].branches[name].target = local, .state = tracking[local, remote] - [absent]
local moved backwards or sidewaysremotes[remote].branches[name].target = local, .state = tracking[local, remote] - [remote] -> local
local moved backwards or sideways, and if remote is out of
syncremotes[remote].branches[name].target = local[absent, remote] - [remote] -> absent
remote is out of sync?remotes[remote].branches[name] (target becomes absent)[absent, remote] - [absent] -> remotetarget of forgotten remote branch is absentremotes["git"].branches[name].target = local, .state = tracking[local, absent] - [absent] -> local[local, git] - [absent] -> failremotes["git"].branches[name].target = local[local, git] - [git] -> localremotes["git"].branches[name][absent, git] - [git] -> absent[absent, git] - [git] -> absent for forgotten local/remote
branches[old, git] - [git] -> old for undone local/remote branchesgit_refs isn't diffed against the
refs in the backing Git repo.@git remotejj branch untrack {name}@git
jj git fetch --remote git
git::import_refs() only for local branches.jj git push --remote git
jj branch track and git::export_refs() only for
local branches.tracking remotes?