forked from mirrors/jj
e0965c4533
This means that the commits previously pinned by remote branches are no longer abandoned. I think that's more correct since "push" is the operation to propagate local view to remote, and uninteresting commits should have been locally abandoned.
375 lines
15 KiB
Markdown
375 lines
15 KiB
Markdown
# 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
|
|
|
|
[#1136]: https://github.com/martinvonz/jj/issues/1136
|
|
|
|
## 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` 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:
|
|
1. `jj branch forget`
|
|
2. `jj op undo`/`restore` in colocated repo
|
|
* Pseudo `@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.
|
|
|
|
## 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
|
|
# `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` and `git_refs["refs/remotes"]`
|
|
|
|
### Import/export data flow
|
|
|
|
```
|
|
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`.
|
|
|
|
### Tracking state
|
|
|
|
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.
|
|
|
|
```rust
|
|
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`.
|
|
|
|
```rust
|
|
fn target_in_merge_context(known_target, state) {
|
|
match state {
|
|
State::New => RefTarget::absent(),
|
|
State::Tracking => known_target,
|
|
}
|
|
}
|
|
```
|
|
|
|
### Mapping to the current data model
|
|
|
|
* New `remotes["git"].branches` corresponds to `git_refs["refs/heads"]`, but
|
|
forgotten branches are removed from `remotes["git"].branches`.
|
|
* New `remotes["git"].tags` corresponds to `git_refs["refs/tags"]`.
|
|
* New `remotes["git"].head` corresponds to `git_head`.
|
|
* New `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-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 for `remotes[remote].branches[glob]` (see below)
|
|
* TODO: how about fetched `.tags`?
|
|
|
|
* `jj git import`
|
|
1. Copies `git_refs` from the backing Git repo.
|
|
2. Calculates diff from the known `remotes` to the new `git_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]`
|
|
3. Merges diff in local `branches` and `tags` if `state` is `tracking`.
|
|
* If the known `target` is `absent`, the default `state` should be
|
|
calculated. This also applies to previously-forgotten branches.
|
|
4. Updates `remotes` reflecting the import.
|
|
5. Abandons commits that are no longer referenced.
|
|
|
|
### push/export
|
|
|
|
* `jj git push`
|
|
1. Calculates diff from the known `remotes[remote]` to the local changes.
|
|
* `branches - remotes[remote].branches`
|
|
* If `state` is `new` (i.e. untracked), the known remote branch `target`
|
|
is considered `absent`.
|
|
* If `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.
|
|
* TODO: Copy Git's `--force-with-lease` behavior?
|
|
* ~`tags`~ (not implemented, but should be the same as `branches`)
|
|
2. Pushes diff to the remote Git repo (as well as remote tracking branches
|
|
in the backing Git repo.)
|
|
3. Updates `remotes[remote]` and `git_refs` reflecting the push.
|
|
|
|
* `jj git export`
|
|
1. Copies local `branches`/`tags` back to `remotes["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]` is `absent`, the default
|
|
`state = tracking` applies. This also applies to forgotten branches.
|
|
* ~`tags`~ (not implemented, but should be the same as `branches`)
|
|
2. Calculates diff from the known `git_refs` to the new `remotes[remote]`.
|
|
3. Applies diff to the backing Git repo.
|
|
4. 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.
|
|
|
|
### init/clone
|
|
|
|
* `jj init`
|
|
* Import, track, and merge per `git.auto_local_branch` config.
|
|
* If `!git.auto_local_branch`, no `tracking` 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 local `branches[name]` entry.
|
|
* `jj branch delete {name}`
|
|
1. Removes local `branches[name]` entry.
|
|
* `jj branch forget {name}`
|
|
1. Removes local `branches[name]` entry if exists.
|
|
2. Removes `remotes[remote].branches[name]` entries if exist.
|
|
TODO: maybe better to not remove non-tracking remote branches?
|
|
* `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`.
|
|
* `jj branch untrack {name}@{remote}` (new command)
|
|
1. Sets `remotes[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 on `git.auto_local_branch`
|
|
2. If new `state` is `tracking`, merges `[absent, new_remote] - [absent]`
|
|
(i.e. creates local branch with `new_remote` target)
|
|
3. Sets `remotes[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 on `git.auto_local_branch`
|
|
2. If new `state` is `tracking`, merges `[local, new_remote] - [absent]`
|
|
3. Sets `remotes[remote].branches[name].state`
|
|
* Fetching/importing remotely-deleted branch from tracking remote
|
|
1. Merges `[local, absent] - [known_remote]`
|
|
2. Removes `remotes[remote].branches[name]` (`target` becomes `absent`)
|
|
(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 on `git.auto_local_branch`
|
|
2. Noop anyway since `[local, absent] - [absent]` -> `local`
|
|
* Fetching previously-forgotten branch from remote
|
|
1. Decides new `state = new|tracking` based on `git.auto_local_branch`
|
|
2. If new `state` is `tracking`, merges
|
|
`[absent, new_remote] - [absent]` -> `new_remote`
|
|
3. Sets `remotes[remote].branches[name].state`
|
|
* 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
|
|
|
|
### push
|
|
|
|
* Pushing new branch, remote doesn't exist
|
|
1. Pushes `[local, absent] - [absent]` -> `local`
|
|
2. Sets `remotes[remote].branches[name].target = local`, `.state = tracking`
|
|
* Pushing new branch, untracked remote exists
|
|
1. Pushes `[local, remote] - [absent]`
|
|
* Fails if `local` moved backwards or sideways
|
|
2. Sets `remotes[remote].branches[name].target = local`, `.state = tracking`
|
|
* Pushing existing branch to tracking remote
|
|
1. Pushes `[local, remote] - [remote]` -> `local`
|
|
* Fails if `local` moved backwards or sideways, and if `remote` is out of
|
|
sync
|
|
2. Sets `remotes[remote].branches[name].target = local`
|
|
* Pushing existing branch to untracked remote
|
|
* Same as "new branch"
|
|
* Pushing deleted branch to tracking remote
|
|
1. Pushes `[absent, remote] - [remote]` -> `absent`
|
|
* TODO: Fails if `remote` is out of sync?
|
|
2. Removes `remotes[remote].branches[name]` (`target` becomes `absent`)
|
|
* Pushing 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"
|
|
* Pushing previously-forgotten branch to remote
|
|
* Same as "new branch, untracked remote exists"
|
|
* The `target` of forgotten remote branch is `absent`
|
|
|
|
### export
|
|
|
|
* Exporting new local branch, git branch doesn't exist
|
|
1. Sets `remotes["git"].branches[name].target = local`, `.state = tracking`
|
|
2. Exports `[local, absent] - [absent]` -> `local`
|
|
* Exporting new local branch, git branch is out of sync
|
|
1. Exports `[local, git] - [absent]` -> fail
|
|
* Exporting existing local branch, git branch is synced
|
|
1. Sets `remotes["git"].branches[name].target = local`
|
|
2. Exports `[local, git] - [git]` -> `local`
|
|
* Exporting deleted local branch, git branch is synced
|
|
1. Removes `remotes["git"].branches[name]`
|
|
2. Exports `[absent, git] - [git]` -> `absent`
|
|
* Exporting forgotten branches, git branches are synced
|
|
1. Exports `[absent, git] - [git]` -> `absent` for forgotten local/remote
|
|
branches
|
|
|
|
### undo fetch
|
|
|
|
* Exporting undone fetch, git branches are synced
|
|
1. Exports `[old, git] - [git]` -> `old` for undone local/remote branches
|
|
* 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.
|
|
|
|
### `@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`
|
|
* 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` and `git::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
|