This 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-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
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 mostly kept in sync, but there are two scenarios where remote-tracking branches and git refs can diverge:jj branch forget
jj op undo
/restore
in colocated repo
- Pseudo
@git
tracking branches are stored ingit_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
- naturally support remote branches which have no local counterparts
- deduplicate
branches[name].remote_targets
andgit_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 betweengit_refs
andremotes[]
.git_refs
is always copied from the backing Git repo.jj git export
copies jj'sremotes
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 backgit_refs
.
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,
}
}
- New
remotes["git"].branches
corresponds togit_refs["refs/heads"]
, but forgotten branches are removed fromremotes["git"].branches
. - New
remotes["git"].tags
corresponds togit_refs["refs/tags"]
. - New
remotes["git"].head
corresponds togit_head
. - New
remotes[remote].branches
corresponds tobranches[].remote_targets[remote]
. state = new|tracking
doesn't exist in the current model. It's determined bygit.auto-local-branch
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
- 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_refs
from the backing Git repo. - Calculates diff from the known
remotes
to the newgit_refs
.git_refs["refs/heads"] - remotes["git"].branches
git_refs["refs/tags"] - remotes["git"].tags
- TBD:
"HEAD" - remotes["git"].head
(unused) git_refs["refs/remotes/{remote}"] - remotes[remote]
- Merges diff in local
branches
andtags
ifstate
istracking
.- If the known
target
isabsent
, the defaultstate
should be calculated. This also applies to previously-forgotten branches.
- If the known
- Updates
remotes
reflecting the import. - Abandons commits that are no longer referenced.
- Copies
-
jj git push
- Calculates diff from the known
remotes[remote]
to the local changes.branches - remotes[remote].branches
- If
state
isnew
(i.e. untracked), the known remote branchtarget
is consideredabsent
. - If
state
isnew
, 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.)
- Updates
remotes[remote]
andgit_refs
reflecting the push.
- Calculates diff from the known
-
jj git export
- Copies local
branches
/tags
back toremotes["git"]
.- Conceptually,
remotes["git"].branches[name].state
can be set to untracked. Untracked local branches won't be exported to Git. - If
remotes["git"].branches[name]
isabsent
, the defaultstate = tracking
applies. This also applies to forgotten branches. (not implemented, but should be the same astags
branches
)
- Conceptually,
- Calculates diff from the known
git_refs
to the newremotes[remote]
. - Applies diff to the backing Git repo.
- Updates
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.
- Copies local
-
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. This isn't technically needed, but will help users coming from Git.
- Import, track, and merge per
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)
- 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
- 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
local
moved 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
local
moved backwards or sideways, and ifremote
is 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
remote
is out of sync?
- TODO: Fails if
- Removes
remotes[remote].branches[name]
(target
becomesabsent
)
- 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
target
of forgotten remote branch isabsent
- 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]
->absent
for forgotten local/remote branches
- Exports
- Exporting undone fetch, git branches are synced
- Exports
[old, git] - [git]
->old
for undone local/remote branches
- Exports
- Redoing undone fetch without exporting
- Same as plain fetch since the known
git_refs
isn't diffed against the refs in the backing Git repo.
- Same as plain fetch since the known
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 track
andgit::export_refs()
only for local branches.
- #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)