-
Notifications
You must be signed in to change notification settings - Fork 319
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
doc: add plan for non-tracking branches support
- Loading branch information
Showing
1 changed file
with
299 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,299 @@ | ||
# 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. | ||
|
||
```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 | ||
|
||
### 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 them 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`. | ||
|
||
### push/export | ||
|
||
* `jj git push` | ||
1. Calculates diff from the known `remotes` to the local changes. | ||
* `branches - remotes[remote].branches` | ||
* If `state` is `new|forgotten`, 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 them 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` to the local changes. | ||
* `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 them 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. | ||
* https://github.com/martinvonz/jj/issues/1862 | ||
|
||
* `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. (Since 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] - [old_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] - [old_remote]` | ||
2. Removes `remotes[remote].branches[name]` (`target` becomes `absent`) | ||
* 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. Merges `[absent, new_remote] - [absent]` -> `new_remote` | ||
(The known `target` of forgotten remote branch is `absent`) | ||
3. Sets `remotes[remote].branches[name].state` | ||
|
||
### 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` | ||
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` | ||
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` | ||
* Pushing forgotten branch to untracked remote | ||
* Noop since `[absent, remote] - [absent]` -> `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, old_remote] - [old_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)? | ||
* It means 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 | ||
|
||
* 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 | ||
|
||
## 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 |