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
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"].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
1. 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 import
1. Calculates diff from the knownremotes
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]
2. Merges diff in localbranches
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. 3. Updatesremotes
reflecting the import. absent
entries are removed fromremotes
. 4. Abandons commits that are no longer referenced.
push/export¶
-
jj git push
1. Calculates diff from the knownremotes[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? - ~
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 = tracking
4. Import changes only forremotes[remote].branches[glob]
-
jj git export
1. Calculates diff from the knownremotes["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. - ~
tags
~ (not implemented, but should be the same asbranches
) absent - remotes[remote].branches
ifstate
isforgotten
2. Applies diff to the backing Git repo. 3. Updatesremotes
reflecting the export.absent
entries are removed fromremotes
.
init/clone¶
jj init
- Import, track, and merge per
git.auto_local_branch
config. -
If
!git.auto_local_branch
, notracking
state will be set. -
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.)
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|tracking
based ongit.auto_local_branch
2. If newstate
istracking
, merges[absent, new_remote] - [absent]
(i.e. creates local branch withnew_remote
target) 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|tracking
based ongit.auto_local_branch
2. If newstate
istracking
, 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]
(target
becomesabsent
) (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|tracking
based ongit.auto_local_branch
2. Noop anyway since[local, absent] - [absent]
->local
- Fetching previously-forgotten branch from remote
1. Decides new
state = new|tracking
based ongit.auto_local_branch
2. If newstate
istracking
, merges[absent, new_remote] - [absent]
->new_remote
(The knowntarget
of 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
forgotten
remote 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]
->local
2. Setsremotes[remote].branches[name].state = tracking
3.import_refs()
merges[local, local] - [absent]
->local
(noop) - Pushing/exporting new branch, untracked remote exists
1. Exports
[local, remote] - [absent]
- Fails if
local
moved backwards or sideways 2. Setsremotes[remote].branches[name].state = tracking
3.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
local
moved backwards or sideways, and ifremote
is 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
remote
is out of sync? 2.import_refs()
merges[absent, absent] - [remote]
->absent
3. Removesremotes[remote].branches[name]
(target
becomesabsent
)
- 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]
->absent
2. Exportsforgotten
state 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]
(target
becomesabsent
) - 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/1862
- https://github.com/martinvonz/jj/issues/1278 pushing to tracked remote
- Option could be added to push to all
tracking
remotes? - 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