Remote/@git tracking branches¶
This is a plan to implement more Git-like remote tracking branch UX.
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-branch 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)¶
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?
- Remote branches are stored in both
branches[name].remote_targetsandgit_refs["refs/remotes"]. These two are kept in sync unless the branch is removed byjj branch forgetcommand. - Pseudo
@gitremote branches are stored ingit_refs["refs/heads"].
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
| forgotten # to be expunged on the next export
# `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 git_refs and branches[name].remote_targets in
the current model.
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)
With the proposed data model, we can
* naturally support remote branches which have no local counterparts
* deduplicate branches[name].remote_targets and git_refs["refs/remotes"]
* eliminate git_ variables and methods from the view object
The git.auto-local-branch 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::Tracking
} else if config["git.auto-local-branch"] {
State::Tracking
} 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::Tracking => known_target,
State::Forgotten => RefTarget::absent(),
}
}
Mapping to the current data model¶
- New
remotes["git"].branchescorresponds togit_refs["refs/heads"]. - New
remotes["git"].tagscorresponds togit_refs["refs/tags"]. - New
remotes["git"].headcorresponds togit_head. - New
remotes[remote].branchescorresponds togit_refs["refs/remotes/{remote}"]andbranches[].remote_targets[remote]. - If
git_refs["refs/remotes/{remote}"]exists but.remote_targetsdoesn't, it meansstate = forgottenin new model. state = new|trackingdoesn't exist in the current model. It's determined bygit.auto-local-branchconfig.
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¶
-
jj git fetch1. Fetches remote changes to the backing Git repo. 2. Import changes only forremotes[remote].branches[glob](see below)- TODO: how about fetched
.tags?
- TODO: how about fetched
-
jj git import1. Calculates diff from the knownremotesto the actual git repo."refs/heads" - remotes["git"].branches"refs/tags" - remotes["git"].tags"HEAD" - remotes["git"].head(unused)"refs/remotes/{remote}" - remotes[remote]2. Merges diff in localbranchesandtagsifstateistracking.- If the branch is new, the default
stateshould be calculated. - If
stateisforgotten, the known branch is supposed to be removed, and the defaultstateshould be calculated. 3. Updatesremotesreflecting the import. absententries are removed fromremotes. 4. Abandons commits that are no longer referenced.
push/export¶
-
jj git push1. Calculates diff from the knownremotes[remote]to the local changes.branches - remotes[remote].branches- If
stateisnew|forgotten(i.e. untracked), the known remote branchtargetis consideredabsent. - If
stateisnew|forgotten, 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? - ~
tags~ (not implemented, but should be the same asbranches) 2. Pushes diff to the remote Git repo (as well as remote tracking branches in the backing Git repo.) 3. Setsremotes[remote].branches[name].state = tracking4. Import changes only forremotes[remote].branches[glob]
-
jj git export1. Calculates diff from the knownremotes["git"]to the local changes and forgotten branches.branches - remotes["git"].branchesifstateistracking- If
remotes["git"].branches[name]isabsent, the defaultstate = trackingapplies. - If
stateisforgottenbut local branch exists,remotes["git"].branches[name]is supposed to be removed, and the defaultstate = trackingapplies. - ~
tags~ (not implemented, but should be the same asbranches) absent - remotes[remote].branchesifstateisforgotten2. Applies diff to the backing Git repo. 3. Updatesremotesreflecting the export.absententries are removed fromremotes.
init/clone¶
jj init- Import, track, and merge per
git.auto_local_branchconfig. -
If
!git.auto_local_branch, notrackingstate will be set. -
jj git clone - Import, track, and merge per
git.auto_local_branchconfig. - The default branch will be tracked regardless of
git.auto_local_branchconfig. (Because local branch is created for the default remote branch, it makes sense to track.)
branch¶
jj branch set {name}1. Sets localbranches[name]entry.jj branch delete {name}1. Removes localbranches[name]entry.jj branch forget {name}1. Removes localbranches[name]entry if exists. 2. Sets allremotes[remote].branches[name].state = forgotten.jj branch track {name}@{remote}(new command) 1. Merges[local, remote] - [absent]in local branch.- Same as "fetching/importing existing branch from untracked remote".
2. Sets
remotes[remote].branches[name].state = tracking.
- Same as "fetching/importing existing branch from untracked remote".
2. Sets
jj branch untrack {name}@{remote}(new command) 1. Setsremotes[remote].branches[name].state = new.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¶
fetch/import¶
- Fetching/importing new branch
1. Decides new
state = new|trackingbased ongit.auto_local_branch2. If newstateistracking, merges[absent, new_remote] - [absent](i.e. creates local branch withnew_remotetarget) 3. Setsremotes[remote].branches[name].state - Fetching/importing existing branch from tracking remote
1. Merges
[local, new_remote] - [known_remote] - Fetching/importing existing branch from untracked remote
1. Decides new
state = new|trackingbased ongit.auto_local_branch2. If newstateistracking, merges[local, new_remote] - [absent]3. Setsremotes[remote].branches[name].state - Fetching/importing remotely-deleted branch from tracking remote
1. Merges
[local, absent] - [known_remote]2. Removesremotes[remote].branches[name](targetbecomesabsent) (i.e. the remote branch is no longer tracked) 3. Abandons commits in the deleted branch - Fetching/importing remotely-deleted branch from untracked remote
1. Decides new
state = new|trackingbased ongit.auto_local_branch2. Noop anyway since[local, absent] - [absent]->local - Fetching previously-forgotten branch from remote
1. Decides new
state = new|trackingbased ongit.auto_local_branch2. If newstateistracking, merges[absent, new_remote] - [absent]->new_remote(The knowntargetof forgotten remote branch isabsent) 3. Setsremotes[remote].branches[name].state - Fetching forgotten and remotely-deleted branch
- Same as "remotely-deleted branch from untracked remote" since
forgottenremote branch should never betracking - Therefore, no local commits should be abandoned
push/export¶
- Pushing/exporting new branch, remote doesn't exist
1. Exports
[local, absent] - [absent]->local2. Setsremotes[remote].branches[name].state = tracking3.import_refs()merges[local, local] - [absent]->local(noop) - Pushing/exporting new branch, untracked remote exists
1. Exports
[local, remote] - [absent]- Fails if
localmoved backwards or sideways 2. Setsremotes[remote].branches[name].state = tracking3.import_refs()merges[local, local] - [remote]->local(noop)
- Fails if
- Pushing/exporting existing branch to tracking remote
1. Exports
[local, remote] - [remote]->local- Fails if
localmoved backwards or sideways, and ifremoteis out of sync 2.import_refs()merges[local, local] - [remote]->local(noop)
- Fails if
- Pushing/exporting existing branch to untracked remote
- Same as "new branch"
- Pushing/exporting deleted branch to tracking remote
1. Exports
[absent, remote] - [remote]->absent- TODO: Fails if
remoteis out of sync? 2.import_refs()merges[absent, absent] - [remote]->absent3. Removesremotes[remote].branches[name](targetbecomesabsent)
- TODO: Fails if
- Pushing/exporting deleted branch to untracked remote
- Noop since
[absent, remote] - [absent]->remote - Perhaps, UI will report error
- Pushing forgotten branch to untracked remote
- Same as "deleted branch to untracked remote"
- Exporting forgotten branch
1. Local branch change is noop since
[absent, absent] - [absent]->absent2. Exportsforgottenstate to the backing Git repo:[absent, known_remote] - [known_remote]->absent(This includes local branch in the pseudo"git"remote) 3. Removesremotes[remote].branches[name](targetbecomesabsent) - Pushing previously-forgotten branch to remote
- Same as "new branch, untracked remote exists"
- The known
targetof forgotten remote branch isabsent
@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- Maybe rejected (to avoid confusion)?
- Conceptually, it's
git::import_refs()only for local branches. jj git push --remote git- Maybe rejected (to avoid confusion)?
- Conceptually, it's
jj branch trackandgit::export_refs()only for local branches.
Remaining issues¶
git.auto_local_branch = falseby default to help Git interop?- https://github.com/martinvonz/jj/issues/1862
- https://github.com/martinvonz/jj/issues/1278 pushing to tracked remote
- Option could be added to push to all
trackingremotes? - 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)
References¶
- https://github.com/martinvonz/jj/issues/1136
- https://github.com/martinvonz/jj/issues/1666
- https://github.com/martinvonz/jj/issues/1690
- https://github.com/martinvonz/jj/issues/1734
- https://github.com/martinvonz/jj/pull/1739