mirror of
https://github.com/martinvonz/jj.git
synced 2024-12-24 12:48:55 +00:00
parent
872a0932cd
commit
aae9aa4a7a
1 changed files with 325 additions and 0 deletions
325
docs/design/tracking-branches.md
Normal file
325
docs/design/tracking-branches.md
Normal file
|
@ -0,0 +1,325 @@
|
|||
# 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 kept in sync unless the branch is
|
||||
removed by `jj branch forget` command.
|
||||
* Pseudo `@git` remote branches are stored in `git_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.
|
||||
|
||||
```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,
|
||||
State::Forgotten => RefTarget::absent(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mapping to the current data model
|
||||
|
||||
* New `remotes["git"].branches` corresponds to `git_refs["refs/heads"]`.
|
||||
* New `remotes["git"].tags` corresponds to `git_refs["refs/tags"]`.
|
||||
* New `remotes["git"].head` corresponds to `git_head`.
|
||||
* New `remotes[remote].branches` corresponds to
|
||||
`git_refs["refs/remotes/{remote}"]` and `branches[].remote_targets[remote]`.
|
||||
* If `git_refs["refs/remotes/{remote}"]` exists but `.remote_targets` doesn't,
|
||||
it means `state = forgotten` in new model.
|
||||
* `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. 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]`
|
||||
2. Merges diff in local `branches` and `tags` if `state` is `tracking`.
|
||||
* If the branch is new, the default `state` should be calculated.
|
||||
* If `state` is `forgotten`, the known branch is supposed to be removed,
|
||||
and the default `state` should be calculated.
|
||||
3. Updates `remotes` reflecting the import.
|
||||
* `absent` entries are removed from `remotes`.
|
||||
4. 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|forgotten` (i.e. untracked), the known remote
|
||||
branch `target` is considered `absent`.
|
||||
* If `state` is `new|forgotten`, 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. Sets `remotes[remote].branches[name].state = tracking`
|
||||
4. Import changes only for `remotes[remote].branches[glob]`
|
||||
|
||||
* `jj git export`
|
||||
1. Calculates diff from the known `remotes["git"]` to the local changes
|
||||
and forgotten branches.
|
||||
* `branches - remotes["git"].branches` if `state` is `tracking`
|
||||
* If `remotes["git"].branches[name]` is `absent`, the default
|
||||
`state = tracking` applies.
|
||||
* If `state` is `forgotten` but local branch exists,
|
||||
`remotes["git"].branches[name]` is supposed to be removed, and
|
||||
the default `state = tracking` applies.
|
||||
* ~`tags`~ (not implemented, but should be the same as `branches`)
|
||||
* `absent - remotes[remote].branches` if `state` is `forgotten`
|
||||
2. Applies diff to the backing Git repo.
|
||||
3. Updates `remotes` reflecting the export.
|
||||
* `absent` entries are removed from `remotes`.
|
||||
|
||||
### 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. Sets all `remotes[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`.
|
||||
* `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`
|
||||
(The known `target` of forgotten remote branch is `absent`)
|
||||
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 never be `tracking`
|
||||
* Therefore, no local commits should be abandoned
|
||||
|
||||
### push/export
|
||||
|
||||
* Pushing/exporting new branch, remote doesn't exist
|
||||
1. Exports `[local, absent] - [absent]` -> `local`
|
||||
2. Sets `remotes[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. Sets `remotes[remote].branches[name].state = tracking`
|
||||
3. `import_refs()` merges `[local, local] - [remote]` -> `local` (noop)
|
||||
* Pushing/exporting existing branch to tracking remote
|
||||
1. Exports `[local, remote] - [remote]` -> `local`
|
||||
* Fails if `local` moved backwards or sideways, and if `remote` is out of
|
||||
sync
|
||||
2. `import_refs()` merges `[local, local] - [remote]` -> `local` (noop)
|
||||
* 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. Removes `remotes[remote].branches[name]` (`target` becomes `absent`)
|
||||
* 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. Exports `forgotten` state to the backing Git repo:
|
||||
`[absent, known_remote] - [known_remote]` -> `absent`
|
||||
(This includes local branch in the pseudo `"git"` remote)
|
||||
3. Removes `remotes[remote].branches[name]` (`target` becomes `absent`)
|
||||
* Pushing previously-forgotten branch to remote
|
||||
* Same as "new branch, untracked remote exists"
|
||||
* The known `target` of forgotten remote branch is `absent`
|
||||
|
||||
### `@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` 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
|
Loading…
Reference in a new issue