13 KiB
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_targets
andgit_refs["refs/remotes"]
. These two are kept in sync unless the branch is removed byjj branch forget
command. - Pseudo
@git
remote 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
andgit_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"].branches
corresponds togit_refs["refs/heads"]
. - New
remotes["git"].tags
corresponds togit_refs["refs/tags"]
. - New
remotes["git"].head
corresponds togit_head
. - New
remotes[remote].branches
corresponds togit_refs["refs/remotes/{remote}"]
andbranches[].remote_targets[remote]
. - If
git_refs["refs/remotes/{remote}"]
exists but.remote_targets
doesn't, it meansstate = forgotten
in new model. state = new|tracking
doesn't exist in the current model. It's determined bygit.auto-local-branch
config.
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 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
- Calculates diff from the known
remotes
to the actual git repo."refs/heads" - remotes["git"].branches
"refs/tags" - remotes["git"].tags
"HEAD" - remotes["git"].head
(unused)"refs/remotes/{remote}" - remotes[remote]
- Merges diff in local
branches
andtags
ifstate
istracking
.- If the branch is new, the default
state
should be calculated. - If
state
isforgotten
, the known branch is supposed to be removed, and the defaultstate
should be calculated.
- If the branch is new, the default
- Updates
remotes
reflecting the import.absent
entries are removed fromremotes
.
- Abandons commits that are no longer referenced.
- Calculates diff from the known
push/export
-
jj git push
- Calculates diff from the known
remotes[remote]
to the local changes.branches - remotes[remote].branches
- If
state
isnew|forgotten
(i.e. untracked), the known remote branchtarget
is consideredabsent
. - If
state
isnew|forgotten
, and if the local branchtarget
isabsent
, the diff[absent, remote] - absent
is noop. So it's not allowed to push deleted branch to untracked remote. - TODO: Copy Git's
--force-with-lease
behavior?
- If
(not implemented, but should be the same astags
branches
)
- Pushes diff to the remote Git repo (as well as remote tracking branches in the backing Git repo.)
- Sets
remotes[remote].branches[name].state = tracking
- Import changes only for
remotes[remote].branches[glob]
- Calculates diff from the known
-
jj git export
- Calculates diff from the known
remotes["git"]
to the local changes and forgotten branches.branches - remotes["git"].branches
ifstate
istracking
- If
remotes["git"].branches[name]
isabsent
, the defaultstate = tracking
applies. - If
state
isforgotten
but local branch exists,remotes["git"].branches[name]
is supposed to be removed, and the defaultstate = tracking
applies.
- If
(not implemented, but should be the same astags
branches
)absent - remotes[remote].branches
ifstate
isforgotten
- Applies diff to the backing Git repo.
- Updates
remotes
reflecting the export.absent
entries are removed fromremotes
.
- Calculates diff from the known
init/clone
-
jj init
- Import, track, and merge per
git.auto_local_branch
config. - If
!git.auto_local_branch
, notracking
state will be set.
- Import, track, and merge per
-
jj git clone
- Import, track, and merge per
git.auto_local_branch
config. - The default branch will be tracked regardless of
git.auto_local_branch
config. (Because local branch is created for the default remote branch, it makes sense to track.)
- Import, track, and merge per
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. - Sets all
remotes[remote].branches[name].state = forgotten
.
- 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
fetch/import
- Fetching/importing new branch
- Decides new
state = new|tracking
based ongit.auto_local_branch
- If new
state
istracking
, merges[absent, new_remote] - [absent]
(i.e. creates local branch withnew_remote
target) - 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|tracking
based ongit.auto_local_branch
- If new
state
istracking
, 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]
(target
becomesabsent
) (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|tracking
based ongit.auto_local_branch
- Noop anyway since
[local, absent] - [absent]
->local
- Decides new
- Fetching previously-forgotten branch from remote
- Decides new
state = new|tracking
based ongit.auto_local_branch
- If new
state
istracking
, merges[absent, new_remote] - [absent]
->new_remote
(The knowntarget
of forgotten remote branch isabsent
) - 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 never betracking
- Therefore, no local commits should be abandoned
- Same as "remotely-deleted branch from untracked remote" since
push/export
- Pushing/exporting new branch, remote doesn't exist
- Exports
[local, absent] - [absent]
->local
- Sets
remotes[remote].branches[name].state = tracking
import_refs()
merges[local, local] - [absent]
->local
(noop)
- Exports
- Pushing/exporting new branch, untracked remote exists
- Exports
[local, remote] - [absent]
- Fails if
local
moved backwards or sideways
- Fails if
- Sets
remotes[remote].branches[name].state = tracking
import_refs()
merges[local, local] - [remote]
->local
(noop)
- Exports
- Pushing/exporting existing branch to tracking remote
- Exports
[local, remote] - [remote]
->local
- Fails if
local
moved backwards or sideways, and ifremote
is out of sync
- Fails if
import_refs()
merges[local, local] - [remote]
->local
(noop)
- Exports
- Pushing/exporting existing branch to untracked remote
- Same as "new branch"
- Pushing/exporting deleted branch to tracking remote
- Exports
[absent, remote] - [remote]
->absent
- TODO: Fails if
remote
is out of sync?
- TODO: Fails if
import_refs()
merges[absent, absent] - [remote]
->absent
- Removes
remotes[remote].branches[name]
(target
becomesabsent
)
- Exports
- Pushing/exporting 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"
- Exporting forgotten branch
- Local branch change is noop since
[absent, absent] - [absent]
->absent
- Exports
forgotten
state to the backing Git repo:[absent, known_remote] - [known_remote]
->absent
(This includes local branch in the pseudo"git"
remote) - Removes
remotes[remote].branches[name]
(target
becomesabsent
)
- Local branch change is noop since
- Pushing previously-forgotten branch to remote
- Same as "new branch, untracked remote exists"
- The known
target
of 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 track
andgit::export_refs()
only for local branches.
Remaining issues
git.auto_local_branch = false
by default to help Git interop?- https://github.com/martinvonz/jj/issues/1278 pushing to tracked remote
- Option could be added to push to all
tracking
remotes?
- 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)